
Transformer(0) - 总览 里把 Transformer 当成了一个黑盒,说它的输入是一串词元,并且粗略的把词元理解成“一个字或者一个词”。但一段文字到底该怎么拆成词元,其实很有讲究。负责拆的这个工具叫分词器(tokenizer),词元对应的英文是 token。
拆词元这件事有点像活字印刷。如果只刻笔画,几十个活字就够了,但印一页书要拼成千上万块;如果每个常用的句子都刻成一整块,印的时候是快,但要刻的块数是天文数字,碰到没刻过的句子还印不了。实际的做法是常用的字和词刻成整块,不常用的用小块拼。分词器面对的问题和这个几乎一模一样:词元切得太细,一句话要拆成很多块;切得太粗,词表会大到装不下,还总有没见过的词。
把两边的概念对应起来就是这样
| 活字印刷 | 分词器 |
| 一块活字 | 一个词元 |
| 所有刻好的活字(字库) | 词表 |
| 字库里有多少种活字 | 词表大小 |
| 排版:用字库里的活字拼出一页文字 | 分词:把一段文字拆成词表里的词元 |
| 印一页要拼多少块 | 拆出来的词元数量 |
| 根据哪些字常用,决定哪些刻成整块 | 根据语料统计,决定词表里放哪些片段 |
| 没刻过的字印不了 | 词表里没有的部分只能当成“未知” |
那这套“字库”是怎么定出来的?下面就以最常用的 BPE(Byte Pair Encoding)算法为例,看看一个分词器是怎么生成的。GPT 系列用的就是它。
BPE 的思路很朴素:一开始字库里只有最小的单块,比如单个的字。然后统计语料里哪两个相邻的块一起出现的次数最多,就把它们刻成一个新的整块加进字库。反复这么做,直到字库达到事先定好的大小。
举个例子,假设语料里只有 3 个词,后面是它们出现的次数
机器学习 × 4 深度学习 × 3 机器翻译 × 2一开始所有词都拆成单个字,字库里就是这些单字:机、器、学、习、深、度、翻、译。
然后开始统计相邻的两个字一起出现了多少次。比如“学习”在“机器学习”里出现了 4 次,在“深度学习”里出现了 3 次,一共 7 次,是最多的,所以第一次合并就是把“学”和“习”合成“学习”。合并完以后,所有词里的“学 习”都变成了一个整块“学习”,再重新统计,再合并出现最多的那一对,依此类推
| 第几次合并 | 合并的两个单元 | 一起出现的次数 | 新词元 |
| 1 | 学 + 习 | 7 | 学习 |
| 2 | 机 + 器 | 6 | 机器 |
| 3 | 机器 + 学习 | 4 | 机器学习 |
| 4 | 深 + 度 | 3 | 深度 |
| 5 | 深度 + 学习 | 3 | 深度学习 |
注意第 3 次合并的是“机器”和“学习”这两个已经合并过的整块,也就是说 BPE 可以在合并的结果上继续合并,越常见的组合就会被拼成越长的词元。
假设字库到这里就满了,最终得到的词表就是
机 器 学 习 深 度 翻 译 学习 机器 机器学习 深度 深度学习出现得多的“机器学习”“深度学习”成了完整的词元,出现得少的“机器翻译”没能合并成整块。
一个分词器生成好以后,手里其实有两样东西:一张词表,以及上面这张按顺序记录下来的合并规则。
整个过程只是在做统计,不涉及神经网络。它是在训练模型之前单独做的一步,分词器一旦定下来,后面训练模型时就一直用这一套。
拆一段新的文字时,先把它拆成最小的单块,然后按照合并规则的顺序,一条一条的试,能合并的就合并。比如拆“学习深度学习”
| 步骤 | 结果 |
| 拆成单字 | 学 | 习 | 深 | 度 | 学 | 习 |
| 规则 1:学 + 习 | 学习 | 深 | 度 | 学习 |
| 规则 2:机 + 器 | 没有可以合并的,不变 |
| 规则 3:机器 + 学习 | 没有可以合并的,不变 |
| 规则 4:深 + 度 | 学习 | 深度 | 学习 |
| 规则 5:深度 + 学习 | 学习 | 深度学习 |
最终就拆成了 学习 | 深度学习 两个词元。同样的道理,“机器翻译”会被拆成 机器 | 翻 | 译 三个词元。

英文也是一样的道理。来看看 tokenization 这个词是怎么被拆成 token + ization 的。假设语料是
token × 5 tokens × 2 realization × 3 organization × 3注意语料里并没有 tokenization 这个词。
一开始全部拆成单个字母。token 里的每一对相邻字母,在 token 和 tokens 里一共出现了 7 次,是最多的;ization 里的每一对相邻字母,在 realization 和 organization 里一共出现了 6 次,排第二。所以前 10 次合并依次是
| 第几次合并 | 合并的两个单元 | 一起出现的次数 | 新词元 |
| 1 | t + o | 7 | to |
| 2 | to + k | 7 | tok |
| 3 | tok + e | 7 | toke |
| 4 | toke + n | 7 | token |
| 5 | i + z | 6 | iz |
| 6 | iz + a | 6 | iza |
| 7 | iza + t | 6 | izat |
| 8 | izat + i | 6 | izati |
| 9 | izati + o | 6 | izatio |
| 10 | izatio + n | 6 | ization |
(次数一样的时候选哪个都可以,这里按先后顺序选)
合并到这里,token 和 ization 都成了完整的词元,而 real、organ 这些只出现了 3 次的部分还没来得及合并。
现在用这套规则来拆 tokenization。先拆成单个字母,然后按规则 1 到 4 依次合并出 token,再按规则 5 到 10 合并出 ization
t o k e n i z a t i o n
→ token i z a t i o n (规则 1~4)
→ token ization (规则 5~10)最终 tokenization 被拆成了 token + ization 两个词元。虽然语料里从来没出现过这个词,但它是由两个常见的片段组成的,BPE 就能把它拆成这两块。这也是子词的好处:没见过的词,也能用认识的片段拼出来。
上面的例子有个问题:如果来了一个语料里从来没出现过的字,比如“人”,字库里根本没有这块活字,就印不出来了。
所以实际的 BPE 一般不从字开始,而是从字节开始。任何文字用 UTF-8 编码以后都是一串字节,而字节一共只有 256 种。只要字库里先备齐这 256 块最小的活字,就没有拼不出来的文字,最坏的情况就是把一个生僻字拆成几个字节。在这个基础上再按前面的方法一轮轮合并,常见的字和词照样会被合成整块。
比如 GPT-2 的词表大小是 50257,就是 256 个字节,加上 50000 次合并出来的词元,再加 1 个表示结束的特殊词元。
如果学过霍夫曼编码,会觉得 BPE 的思路很眼熟。
霍夫曼编码也是先统计频率,再反复合并。比如一段文字里 A 出现了 5 次,B 出现了 2 次,C 和 D 各出现 1 次。它每次把频率最低的两个合并:先合并 C 和 D,再合并 B 和 CD,最后和 A 合并成一棵树。按树上的路径分配编码,得到 A = 0、B = 10、C = 110、D = 111,出现得越多的符号编码越短,整段文字编码以后就更短。
两者的共同点是:都是基于频率统计,都是从小往大反复合并,目的也都是让常见的东西代价更低。但合并的东西和意义完全不一样
| 霍夫曼编码 | BPE | |
| 每次合并什么 | 频率最低的两个符号 | 一起出现次数最多的两个相邻单元 |
| 合并的作用 | 搭一棵树,用来分配编码 | 产生一个新的词元,加进词表 |
| 省在哪 | 常见的符号用更短的编码 | 常见的组合合成一个词元,拆出来的词元更少 |
| 看不看顺序 | 不看,只统计每个符号各自出现几次 | 看,统计的是相邻的两个单元 |
| 什么时候停 | 所有符号合成一棵树 | 词表达到事先定好的大小 |
| 结果好不好 | 数学上保证是最优的 | 贪心算法,不保证最优,但实践中够用 |
用活字印刷来说,霍夫曼编码是“常用的字用更简单的记号”,每个字还是一块,只是记号有长有短;BPE 则是“常用的词刻成一整块”,直接减少了要拼的块数。另外霍夫曼编码的目标是纯粹的压缩,越短越好;而 BPE 要同时照顾词表大小,因为后面的模型要给词表里每个词元算概率,词表不能无限变大。所以它们“长得很像”,但解决的是不同的问题。
BPE 生成分词器的过程,就像从最小的活字开始,看哪两块总是挨在一起用,就把它们刻成一整块,一直刻到字库满为止。常用的组合因为出现得多,会优先被刻成整块,所以拆出来的词元少;字库大小是事先定好的,所以词表也不会无限变大。
拆好以后,每个词元会换成它在词表里的编号交给 Transformer。但就像总览里说的,编号之间的大小没有意义,没法直接拿来计算。下一篇就来看这些编号是怎么变成向量的,也就是 embedding。