分词 入门
模型读的是整数,不是文本。把一者变成另一者的,是一个四行的循环:一遍遍把最常见的一对字节融在一起 —— 而它留下的那张词表,决定了你的上下文长度、你的 API 账单,以及模型能不能数清一个词里有几个字母。
什么算一个 token
模型开始做乘法之前,文本得先变成一串整数。一个整数代表什么,就是这门课题的全部。
模型从来看不到你的字符串。它看到的是一串整数,每一个都是一张表里的行号,而只有 tokenizer 知道是哪一行。下面是一个句子,以及它变成的那些整数 —— 把光标沿着它们走一遍:
注意 id 并不是随便编的。前 256 个是字节值,再往上按「什么时候学到的」编号,所以 ·the 拿到 258,说明它是训练文本第三个要的东西。id 是一段历史里的位置 —— 这就是两个 tokenizer 不能互换表的原因。
对「一行是什么」,多年里桌上摆着三个答案,每一个都在某处破了。一个字符一行,表很小,但每条序列长了五倍 —— 而 attention 的代价是长度的平方。一个词一行,序列很短,但表没有上界,任何没见过的词都是表上的一个洞。真正上线的,是两者之间的一个旋钮。
下面是同一个句子,旋钮拧在最左端,每个原始字节自成一个 token。拖动滑块,每一步都把训练文本里最常见的一对融成一个,于是 token 从字节长成词片,再长成整词:
三个层级之间并没有做任何选择 —— 同一个循环把三者都生产了出来。在零次合并处,也就是初始画面上,词表就是 256 个字节值,这个句子要 16 个 token,一个字节一个。到时,词表是 288,同一个句子只剩 4 个,因为 ·the、·model 和 ·read 各自变成了一条词表项。那个点是空格:在每一个 GPT 词表里,一个词都把它前面的空格一起带着。
旋钮的两端各有各的代价,而这两项代价其实是同一个滑块从两侧读出来的 —— 模型要 attend 的序列,和它要存下并打分的词表行数:
因为 attention 对序列长度是平方的,而嵌入表对词表大小是线性的,旋钮的左端每一步都贵,右端每一行都贵。没有哪个位置是免费的; §06 会给两边都放上真实数字。
单词那一端还带着一项不在那张图上的代价。一个整词的词表只能返回它已经见过的东西。把滑块拖过训练文本里出现过的那些词:
看看走到 时会怎样:单词词表里没有这一条,返回 <unk> —— 一个只表示这里曾经有个东西的 id,而那个东西被扔掉了 —— 而字节词表用 12 个 token 把它拼了出来。这就是每一个上线的 tokenizer 都在底下留着那 256 个字节的原因:当字母表就是文件的字母表时,没有输入能落到词表之外。
不过底下那层地板是字节,不是字符,而这两者只有在 ASCII 里才是一回事。把光标沿着拉丁那一行和中文那一行拖过去,读一个字符的代价:
每个拉丁字母是一个字节;每个中文字符是三个。一个没有为某种文字学到任何合并的 tokenizer,在付别的代价之前,先要为每个字符付三个 token —— 而 §04 讲的就是:本来能修好这件事的那些合并,被花到别处去了。
合并循环
字节对编码(BPE)只有四行,而且从 2016 年起就是这四行。其余的一切,都只是「数什么」的选择。
在一堆文本里数出每一对相邻符号,把最常见的那一对融成一个新符号,把这次融合记下来,再来一轮。词表就是这些融合的产物。但没有任何一次计数会跨过词边界 —— 因为在循环开始之前,文本已经被切开了。
这一刀是一个普通的正则表达式,也正是 ·the 和 the 成为两条不同词表项的原因。把滑块沿着它切出的片段拖过去,读一读每段下面的字节数:
注意边界落在哪里。前导空格跟着它后面的词,所以一个词自带它的空格;标点自成一段;而中文和日文根本没有空格,于是一个字符就是一段。任何合并都不许跨过这些线 —— 这个保证比看上去更强:正是它挡住了词表把 ·the·model 学成一个 token。
现在看循环本身,语料是 11 个词形,写在 11 个不同的字节上。语料里每一对相邻符号都被计数,按它所在词的出现次数加权,然后最常见的那一对胜出:
第一轮里 ·l 以 21 领先 es 的 16 —— 那是 low、lower、lowest、less 和 list 的「空格加 l」加在一起。在这么小的语料上平局层出不穷:到时,领先的是三组并列的 4。每个实现都得有一条平局规则;这里取扫描时先遇到的那一对,所以两个平局规则不同的库,会从完全相同的文本里建出不同的词表。
每一次合并都会改写整个语料,下一轮再数剩下的。按播放,看原始字节融合成词表留下的词片:
看它的形状。最早的几次合并买下词干 —— ·l、·lo、·low —— 然后是共享后缀 est,再然后是整词。 14 次合并把语料从 62 个符号压到 21 个,进度条停在这里,是因为 14 就是这张图给定的预算。循环本身并没有跑完:还有 10 对至少出现两次,再跑 10 次才会没有重复的配对。在真实文本上那个点根本到不了 —— 你永远是停在预算上,而怎么选这个预算正是 §04 的全部主题。
每一次合并就是表上新的一行,铺在一个本来就已经完整的字母表之上。拖动滑块,看学到的词表项一条条到来:
因为底座是固定的、每次合并恰好加一条,词表大小是一道算术题,而不是一个你盲调的超参数:256 + 合并次数 + 特殊 token。 GPT-2 是 256 + 50,000 + 1 = 50,257,那一个特殊 token 是 <|endoftext|>。§04 那张阶梯上的每个数字都这么拆。
编码就是把这张表在一个词上重放一遍。反复找到词里秩最小的那一对并施加它。在一个训练文本里从没出现过的词上,从它的原始字节开始一步步走:
因为重放遵循学到的顺序,它产生的切分,恰好就是训练当初会对这个词产生的切分。整套方案就架在这条不变量上:编码复现训练。这里的顺序不是优化 —— 它就是定义。
两半都很便宜,瓶颈也都不在这里。训练和编码贵的地方不一样。朴素写法的训练是 O(k · N) —— 每次合并都要重扫语料 —— 生产级训练器会把配对计数增量地放进堆里,做到接近 O(N log N)。编码一个 b 字节的词,朴素是 O(b²),用堆是 O(b log b)。在真实文本上,最慢的一环通常是预切分的正则表达式。
于是那个显而易见的捷径成了 bug。不去重放合并,改成每个位置取词表里最长的那一条,一切照样通过类型检查。拨一下开关,读同一个词的两个答案:
重放合并给出 ·n est —— 两个 token。最长匹配给出 ·ne s t —— 三个 id,每一个都在词表里,排成一种模型从没训练过的样子。什么都不会抛异常。模型只是悄悄变差 —— 这跟发布一份和权重差了一个版本的 merge 文件,是同一种失败。
另外两种挑法
WordPiece 和 Unigram 保留了「词片」,换掉的是问题本身。一个把配对打分的方式改了;另一个干脆不给配对打分。
BPE 问的是:哪一对最常见。那就是一个计数,别的什么都不是。WordPiece —— BERT 的 tokenizer,也是多数 encoder 模型的祖先 —— 问的是:哪一对最出人意料。它用配对的计数去除以两半各自单独出现的次数,那是一个似然比,而不是频率。
同一份语料,换第二种打分方式。开关在两者之间切换,初始停在似然准则上,计数准则只有一按之遥:
注意似然把哪一对提了上来。id 只出现 8 次,对上 ·l 的 21 次;但在这份语料里,i 和 d 几乎从不分开出现,所以它们的结合是文本里最强的证据,哪怕它并不是嗓门最大的。频率买给你看得最多的配对,似然买给你有意义的配对。
准则不是损失曲线上的一个细节,它是另一份词表。下面两张表一开始都是十个编号的空位 —— 拖动滑块,两个准则各自一次一条地把自己那行填满,你就能比一比计数买到的和似然买到的:
各做十次合并之后,两张表恰好只有一条是共同的。这就是 tokenizer 不是一个能随便替换的组件的原因:BERT 的词表和 GPT-2 的词表不是同一样东西的两种拼法,而是同一门语言的两种不同划分。
Unigram —— SentencePiece 的另一种模式,也是 Llama 和 T5 用的那种 —— 把配对循环整个扔掉了。它从一个很大的候选词表出发,给每个词片一个概率,然后对一个词问一个不同的问题:不是我怎么把它拼出来,而是哪一种切法最可能。把所有合法切法的排名走一遍:
一共 13 种,排第一的那种正是 BPE 也会返回的切法 —— 对数概率 −2.64,亚军是 −5.20。但其余那些还在,这才是重点:能给候选排名的模型也就能采样它们 —— 子词正则化就是这样,用同一个字符串的好几种切分来训练。
为什么同一句话用中文更贵
不是因为中文更难。是因为合并次数是配给的,而配给跟着语料走。
合并预算在训练开始之前就定死了 —— GPT-2 是 50,000 次,cl100k 是 100,000 次。每一次花在拉丁词片上的合并,都是一次没花在汉字上的合并;而循环会把它们花在计数最高的地方,也就是语料来自哪里,它们就花在哪里。
下面是一份 32 次合并的预算,由两种语言分享。最上面那条是合并去了哪儿;下面两条是同一句话在英文和中文里各要多少。拖动语料:
初始画面里语料全是英文,每一次合并都是拉丁的,两个句子分别是 4 个 token 和 18 个 —— 同样多的文本,账单是 4.5 倍。把语料推到,两条线已经交叉:13 对 12;推到,贵的换成了英文: 16 对 6。这种不对称跟文字本身无关,跟谁的文本在那堆语料里有关。
同一个滑块,作用在那句中文上。一个只装着裸字节的方框,是三分之一个字符在替一整个字符站岗:
语料里没有中文时,每个字符 3 字节,每个字节自成一个 token —— 6 个字符换来 18 个 id,而且没有一个 id 是一个字符。模型仍然读得了,因为字节回退意味着没有什么是表示不了的;但它为一个中文读者眼里的单个符号,花掉了三个上下文位置、三次嵌入查表和三行 attention。
这就是过去六年那道词表阶梯买来的东西。把已发布的几个尺寸走一遍,读手底下那一级的分解:
每一个都是 256 + 合并次数 + 特殊 token —— cl100k 和 o200k 还各自留着一批从没分配出去的 id,那是它们的特殊 token 数和总数之间那道缺口的大半 —— 而增长几乎全在合并上: 2019 年 GPT-2 的 50,257,2022 年 cl100k 的 100,277, 2024 年 o200k 的 200,019,Gemma 的 256,000。五年里把预算翻两番,就是多语言和代码覆盖被买下来的方式 —— 而 §06 是这笔钱的价格。
它弄坏的四样东西
没有一样会抛异常。它们其实是同一个 bug:模型从来看不到字符串,只看到合并循环碰巧产出的那串 id。
先从算术说起。合并循环根本不知道什么叫数;它把语料里常见的数字串融在一起,剩下的不管。沿着数轴走一遍,看切法在你手底下变:
初始画面上的 380 是一个 token。 是两个:38 加 1;而 又是一个,因为年份出现得多。 900 个三位数里只有 108 个在这里是单个 id,是哪 108 个纯属频率的意外。这张图底下的词表是在一份合成的数字语料上训练的,所以那 108 个是我们的,不是任何已发布 tokenizer 的 —— 不合成的是这份参差本身。于是模型做加法时,两个操作数的形状根本不一样 —— 这就是 Llama 3 把数字强制切成最多三位一组、Llama 2 和 Gemma 干脆一位一切,而不肯把这件事交给循环的原因。
同一种失明也解释了数字母的失败。把词表走一遍,比较模型拿到的和下面的字母:
初始画面里 ·model 是一个 id,盖住六个字符。问它有几个字母,模型只能靠背下来关于这个 id 的这条事实 —— 它没法去看,因为那些字母根本不在它的输入里。罕见词反而更好办,恰恰因为它们是碎着来的:这个 tokenizer 把常见词的拼写藏了起来,却把没人训练过的那些词的拼写摊开了。
第三,空白。一个词自带它前面的空格,所以挪动它就换了要查的词表项。把同样两个词的四种排法一步步走过去:
看 token 数。正常的空格是 4 个;是 5 个,因为那个空格没能搭上后一个词,被孤零零地留成一个自己的 token。一个以空格结尾的提示词为什么会比同样但没空格的表现更差,全部解释就在这里:这不是迷信,模型是真的偏离了它训练时的分布。
第四,一个词就是四条词表项。大小写和前导空格都是键的一部分,于是这四种写法里有三种退回到原始字节:
·the 是一个 id;出现在行首、前面没有空格时是三个;首字母大写则是四个。模型得光靠数据学会这四串 id 说的是同一个词。它大体上学会了 —— 而没学会的那些情况,正是同一个基准上,把提示词改成词首大写会得出不同分数的原因。
一个词表的代价
每一条词表项要一行嵌入,还要在每个生成的 token 上出一个 logit。这到底算不算免费,完全取决于模型有多宽。
一个 decoder block 大约是 12·L·d² 个参数 —— 四个 d×d 的 attention 投影加上一个 4 倍宽的 MLP —— 而嵌入表是 V·d。拖动词表,看这个和的两半:
初始画面是 GPT-2 small,d = 768,配 GPT-2 自己的词表:嵌入表是 3860 万参数,对上十二个 block 的 8490 万 —— 31% 的模型就是词表。这个公式给出的总数是 1.235 亿,官方公布是 1.24 亿;在四个 GPT-2 尺寸上误差都在 0.5% 以内 —— 差的那点是 layer norm 和位置表 —— 所以它报出的这个占比是真的。
现在把同一个拆分,在 GPT-2 真正发布过的四个尺寸上各画一遍。第一个滑块仍然是词表,第二个 —— 新加的那个 —— 挑宽度阶梯上的哪一级是主角:
注意嵌入的那一段不用碰就一路往下缩: 31.2%、14.6%、8.3%,到 只剩 5.2%,因为各层按 d² 长,而表只按 d 长。§04 那道阶梯就此解释完了:把词表翻两番,在顶端是舍入误差,在底端是主导项。
def train(corpus, k):
seq = [list(w.encode()) for w in corpus]
merges = []
for _ in range(k):
p = top_pair(seq)
if p is None:
break
seq = [fuse(s, p) for s in seq]
merges.append(p)
# the order IS the model
return merges
def encode(word, merges):
s = list(word.encode())
rank = {p: i for i, p in enumerate(merges)}
while (p := best(s, rank)) is not None:
# lowest rank first, always
s = fuse(s, p)
return s分量都压在两行上。merges 是一个有序列表,不是集合;best 按秩挑,不按位置挑。这就让 merge 文件成了模型的一部分,而不是旁边的一份配置。从训练权重时用的那份列表里删掉一条,读一读模型实际拿到的东西:
注意:什么异常都没抛。下面那行里每个 id 都是合法 id,整串也是合法序列, tokenizer 一声不吭就返回了 —— 被删那条合并之后的 id 整体下移了一位,于是模型被问到 ·model 时,拿到的是它学给别的东西的那个整数。删掉,几乎整句都会挪动。这和 §02 那个最长匹配编码器是同一种失败,只是走了另一条路:两边唯一的症状,都是模型悄悄变差。