你有没有好奇过,GPT 的词表里为什么会有“ing”这种半个词?我最初看到 tokenizer 输出的 token 列表时,心里一咯噔——按说英文单词不应该是完整的吗?直到我把 Byte Pair Encoding(BPE)的训练过程完整跑了一遍,才意识到:它本来就不是按“词”来切的。这件事,还得从“怎么让机器读文本”讲起。

机器读文本,第一步不是“理解”,而是“切分”。最原始的做法是按字符切:每个字母一个 token。这么做确实能覆盖所有拼写,但序列长度会变得很长,模型也学不到“ing”这类有含义的后缀。反过来按单词切呢?词表动辄几十万,而且遇到没见过的词(out-of-vocabulary)就直接懵了。于是大家开始寻找一种介于两者之间的单位——子词。
BPE 就是最有名的一种子词切分方法。它最初只是数据压缩算法,1994 年由 Philip Gage 提出,后来被 Sennrich 等人引入 NLP,用于机器翻译的分词(这篇论文)。你可能听说过“贪心合并”,但具体每一步怎么操作?我用一个最小的例子,亲手带你算一遍。
训练过程,四步走
- 预分词:先把语料按空格和标点切成一个个“单词”。注意这里不是最终 token,只是把文本切成词单位。比如
hugs和hug是两个不同的词。 - 初始化词表:把所有单字符和特殊符号(比如词尾标记
</w>)放进词表。此时词表就是一堆字母。 - 统计相邻对:在每个单词内部,把所有相邻符号对的频率统计出来。别忘了在单词末尾加上词尾标记,否则
hug和hugs里的g就会和后面的s配对,模糊了词边界。 - 合并最高频对:找到出现次数最多的那一对,把它们合并成一个新符号,加入词表。然后回到第 3 步,重复直到词表大小达到目标。
注意,这里的“符号”不只是字符。第一次合并后,新符号可以和别的符号再合并,比如 u+g 合成 ug,紧接着 h+ug 又能合成 hug。子词就是从零开始“长”出来的。
动手算一次:从 u 和 g 开始
我用 Hugging Face 课程里的经典例子,语料只有五个词,括号里是出现次数:
hug : 10
pug : 5
pun : 12
bun : 4
hugs : 5
初始词表是 b, g, h, n, p, s, u。为了简化,先不管词尾标记。统计所有相邻字符对,出现次数最多的组合如下:
| 字符对 | 出现次数 | 来自哪些词 |
|---|---|---|
| (u, g) | 20 | hug 10 + pug 5 + hugs 5 |
| (p, u) | 17 | pug 5 + pun 12 |
| (u, n) | 16 | pun 12 + bun 4 |
| (h, u) | 15 | hug 10 + hugs 5 |
| (g, s) | 5 | hugs 5 |
最高频的是 (u, g),合并成 ug。语料更新为:
h ug
p ug
p un
b un
h ug s
第二轮,重新统计相邻对(注意 ug 现在是一个符号):
| 符号对 | 出现次数 |
|---|---|
| (h, ug) | 15 |
| (p, un) | 12 |
| (p, ug) | 5 |
| (b, un) | 4 |
| (ug, s) | 5 |
这次是 (h, ug) 以 15 次胜出,合并成 hug。词表里多了一个完整的词。
第三轮,最高频的是 (p, un),合并成 pun。然后 (b, un) 合并成 bun,(hug, s) 合并成 hugs,(p, ug) 合并成 pug。最终词表:
b, g, h, n, p, s, u, ug, hug, pun, bun, hugs, pug
这些词没有一个是人工指定的,全是语料统计出来的。这就是 BPE 的本质:从字符起步,让语料“投票”决定该合并哪些相邻符号。高频组合先胜出。
词边界:为什么需要 </w>
刚才为了简化,我刻意没提词尾标记。真实训练中,每个单词后面会加一个特殊符号,比如 </w>。它的作用是告诉模型:这个符号出现的位置是一个词的结尾。
为什么不加不行?因为在字符对统计时,hug 的 g 如果遇到字符串结束,就没有相邻对。但如果语料里有 hugs,那么 g 和 s 就成了相邻对,g 就没有机会和“词尾”结合。更麻烦的是,hug 作为完整词出现的信息会被稀释,模型很难学到“hug”是一个稳定的整体。加上 </w> 之后,g 和 </w> 的配对频率反映了“hug”这个词的完整出现次数,模型才知道什么时候该把词尾“焊死”。
BPE 很贪心,所以并不完美
BPE 每次只选全局频率最高的一对,这是一个贪心策略。它不会“回头”纠正之前的合并。比如某个字符对在整体语料中很高频,但在某个特定语境下其实是错误的合并,BPE 不会为此开恩。
我最早天真地以为,BPE 分出来的词一定符合语言直觉,比如 “un” 和 “able” 会被拆开。但实际训练后你会发现,词表里充满了奇怪的半截词,比如 “low” 和 “er” 可能被合并成 “lower”,而不是按词根拆成 “low” + “er”。这完全取决于语料。BPE 学到的不是词根词缀,只是统计频率。
BPE 分出来的“子词”,本质是“语料压缩的产物”,不是“语言学的产物”。这一点常常被初学者忽略。
WordPiece 和 Unigram 是怎么做的
你应该也听说过 WordPiece 和 Unigram,它们是另外两种子词切分方法。简单对比一下:
| 方法 | 合并/切分依据 | 典型使用 |
|---|---|---|
| BPE | 频率最高的相邻符号对 | GPT, RoBERTa |
| WordPiece | 使语言模型似然提升最大的对 | BERT |
| Unigram | 按概率逐步删掉不重要的 token | T5, XLNet |
BPE 只看“合并后频次”,WordPiece 会考虑合并后对语言模型损失的影响,Unigram 则反过来:先造一个超大词表,然后按 EM 算法逐步删掉贡献最小的 token。原理不太一样,但最终都是得到一组子词和一套切分规则。你可以在 Hugging Face 的这份笔记里看到更详细的对比。
训练学的是规则,推理用的是规则
BPE 的训练过程产出两样东西:一个词表,一组 merge 规则。词表就是那些最终被合并出来的符号,merge 规则是每一步“合并了哪两个相邻符号”的记录。推理时,新文本要被转换 token,不是重新训练,而是把 merge 规则从头到尾按顺序应用一遍。
举个例子,如果训练时的第一条规则是“u 和 g 合并成 ug”,那么推理时遇到 “hug” 这个单词,就会先按字符拆开 h-u-g,然后应用规则,合并成 h-ug,再合并成 hug。如果遇到训练中没见过的词,比如 “huggy”,由于只有部分规则能匹配,它可能会变成 “hug” + “gy” 之类的子词,而不是报错。这是 BPE 能处理未登录词的原因。
尾声:BPE 教会我的事
回头来看,BPE 给我的启发不只是“怎么分词”,而是:很多看似智能的预处理,本质上是“利用统计规律做最优化”。它不追求理解语言,只追求在压缩率、覆盖率和序列长度之间找一个平衡点。你用 GPT 的时候,看到 tokenizer 把 “ChatGPT” 切成了 “Chat” 和 “GPT”,可能会觉得它“恰好”懂得这两个词的语义。其实那只是语料里这两个片段总一起出现,于是被焊在了一起。
这种“统计上的合理,语义上的意外”,恰恰是深度学习最迷人的地方。它用规则超越了规则,却依然可以被我们一行一行地推演出来。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/641.html