有一个问题困扰了我很久:为什么 GPT 系列从来不需要 <unk>?我最早做机器翻译时,词表里没有的词只能扔给 <unk>,每次看到这个词都心慌。后来做深度学习,我以为子词 BPE 已经解决了未登录词。直到我打开 GPT-2 的论文,发现它的基础词表不是英语高频词,而是 256 个字节。那一刻我才反应过来:OpenAI 绕过了“词”的概念,直接站在字节上说话。

先看一下“未登录词”是怎么来的
词表就是模型能使用的“符号清单”。词级模型把“ChatGPT”整个当成一个 token,训练时没见过就变成 <unk>。BPE 论文《Neural Machine Translation of Rare Words with Subword Units》把思路改成了:别死守整个词,把词拆成子词,“ChatGPT”可以拆成 Chat + G + PT。只要这些小部件在词表里,就能拼出来。
BPE 是在做“高频邻居联姻”
BPE 本来是数据压缩算法,NLP 拿它来造词表。它不关心词,只关心相邻符号。
- 把语料切成最底层符号,比如字符。
- 统计所有相邻对的出现次数。
- 把出现次数最高的那对合并成一个新符号。
- 回到第 2 步,重复 N 次,直到词表大小达到目标。
举个例子,语料里“low”出现 100 次,那么 l 和 o 相邻 100 次,它俩会先变成 lo。之后 lo 和 w 再变成 low。整个过程里,序列在不断变短,而那些被合并的组合,就是语料里最稳定的“碎片”。
字符级 BPE 的盲区:原子本身也可以“未登录”
如果底层符号是 Unicode 字符,BPE 依然没解决所有问题:一个新 emoji、一个生僻字符,根本不在字符表里。字符已经是原子了,没法再拆,只能 <unk>。这就是“把词拆碎”仍然不够的原因——你还要保证最小碎片本身永远存在。
把基础单元换成字节,数学上就赢了
字节级 BPE 的起点是 0~255 共 256 个字节。任何一个 Unicode 字符,用 UTF-8 编码后都是一串这样的字节。比如“你”的码点是 U+4F60,编码成 E4 BD A0;“🚀”的码点是 U+1F680,编码成 F0 9F 9A 80。
| 字符 | Unicode 码点 | UTF-8 字节 | 初始 token 数 |
|---|---|---|---|
| a | U+0061 | 61 | 1 |
| 你 | U+4F60 | E4 BD A0 | 3 |
| 🚀 | U+1F680 | F0 9F 9A 80 | 4 |
这看起来是打碎了字符,但它不是让模型直接数字节。BPE 会在语料上统计高频字节组合,把经常一起出现的字节对不断合并。中文语料里“你”对应的高频字节组会变成一个 token;英文语料里 the 也会慢慢长成一个 token。所以最终词表里既有字节,也有由字节拼出来的词。
一个工程 trick:让 BPE 能处理字节
BPE 通常跑在字符串上。字节里的 0~31 是控制字符,直接拼进字符串会跟空格、换行搅在一起。
GPT-2 的 encoder.py 里有一个 bytes_to_unicode 函数,把每个字节映射到一个可见字符:空格变成 Ġ,换行变成 Ċ,其他不可见字节从 U+0100 开始补位。Hugging Face 的这篇分析把这个映射讲得很细。核心逻辑只有几行:
bs = list(range(33, 127)) + list(range(161, 173)) + list(range(174, 256))
cs = bs[:]
n = 0
for b in range(256):
if b not in bs:
bs.append(b)
cs.append(256 + n)
n += 1
return dict(zip(bs, [chr(c) for c in cs]))
这个映射还带来一个副产品:空格不再是“边界”,而是普通字节。GPT-2 的词表里会出现“Ġthe”这样的 token,前面的 Ġ 就是空格。传统 BPE 通常只在词内部合并,而字节级 BPE 连词间空格都会统计。
为什么这能保证没有 <unk>
因为基础词汇表是所有 256 个字节。任何文本,哪怕是一个从没见过的字形、一段乱码,用 UTF-8 编码后都只是字节序列;每个字节要么单独用一个 token,要么参与 merge,但永远不会找不到对应 id。BPE 的 merge 规则只会把相邻 token 合并成更大的 token,不会把 token 变成“不存在”。所以在 tokenizer 这一层,未登录词在数学上不可能出现。
代价:语义颗粒度变小了
字节粒度的第一步是序列变长。一个英文字母占 1 个字节,一个汉字 3 个字节,一个 emoji 4 个字节。如果某个汉字在训练语料里很罕见,那它的 3 个字节不会被 merge,最终被拆成 3 个 token。GPT-2 主要用英文语料训练,中文在它的 tokenizer 里被拆得很碎,就是这个原因。
GPT-2 的最终词表是 50,257 个 token:256 个初始字节 + 50,000 个 merge 出来的新 token + 1 个 <|endoftext|>。这个数字不是“英语单词数”,而是语料压缩的结果。
另外,字节级 BPE 不保证输出合法 UTF-8。如果模型在采样时生成了一段无法用 UTF-8 解码的字节序列,你会在界面上看到 �。那是解码问题,不是未登录词问题。
几个围绕它的常见疑问
字节级 BPE 和字符级 BPE 最大的差别是什么?
字符级 BPE 的最小碎片是“字符”,所以理论上存在词表覆盖不到的新字符;字节级 BPE 的最小碎片是字节,256 个就是全集,不存在覆盖不到。差别不是“碎得更细”,而是“边界是否封闭”。
为什么不直接喂 UTF-8 字节序列?
直接喂字节,序列会变得很长,模型要重新学习“词块”的统计结构。BPE 在字节上做的压缩把高频组合固化下来,让模型既能看到词块,又不会被未登录词卡住。
中文为什么经常被拆成多个 token?
一个汉字初始对应 3 个字节的 token;能不能合并成单个 token,取决于这个 BPE 训练语料里的出现频率。英文语料练出的 BPE 会把中文拆碎,多语料练出的 BPE 会让常用汉字变成单个 token。
回到开头的问题:字节级 BPE 是否“彻底解决”了未登录词?在 tokenizer 这一层,答案是可以。它解决了“符号覆盖”,但没有解决“语义理解”。模型仍然需要靠训练语料见过这些字节组合,才知道它们意味着什么。
所以下次看到 <unk>,先问一句:你的 tokenizer 是在字符上切,还是在字节上切。如果它还在字符级打转,那你和“彻底解决”之间,还差一个 UTF-8。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/1515.html