← 返回首页
18_bpe_viz.html

手写 BPE:自己训一个分词器

第 1 章用字符级分词,65 个字符;第 6 章起一直直接调 tiktoken.get_encoding("gpt2")。 这一关从零写一个同类的 字节级 BPE: 从 256 个字节出发,数相邻对、合并出现最多的一对,重复 16,128 次。配套代码 phase5-fullstack/16_bpe.py, 在第 6 章那份 FineWeb-Edu 的 8 MB 原文上训练, 再在另外 1 MB 上和 GPT-2 的分词器对照。Phase 5 补齐全链路,这是第一关。

STEP 1
任何文本先变成字节
STEP 2
数对,合并一次
STEP 3
预切分:合并只在段内
STEP 4
词表多大
STEP 5
训出来的分词器
这一关 训练语料 8 MB FineWeb-Edu 原文 测试语料 1 MB 同一 shard 靠后,不重叠 词表 16,384 256 个字节 + 16,128 次合并 GPT-2 词表 50,257 对照 n_embd 768 只用来算嵌入表多大

① 任何文本先变成字节

第 1 章的字符级词表只认莎士比亚里出现过的 65 个字符,换一段中文就编不出来。 字节级的做法是先把文本按 UTF-8 编成字节:英文字母 1 个字节,中文 3 个,emoji 4 个。 一个字节只有 256 种取值,所以初始词表固定是 256,任何文本都编得出来。改下面的文字试试:

1 字节(ASCII) 2 字节 3 字节(常用汉字) 4 字节(emoji 等)
–
字符数
–
字节数 = 初始 token 数
–
1024 上下文能装几个这样的句子
初始词表 = 256 个字节,任何 UTF-8 文本都能编码,不会出现"未知字符"。代价是序列长:英文一个 token 只代表 1 个字节。
↳ 代码:16_bpe.py 的 train_bpe 开头 list(w.encode("utf-8"))
为什么不拿 Unicode 字符当初始词表?
Unicode 有十几万个码位,全放进基础词表太大;只放训练语料里见过的,又会遇到没见过的字符。 GPT-2 论文 §2.2 的说法:字节级只需要 256 个基础符号,就能表示任意字符串,不需要未知符号。 字节的代价是中文、日文这类每字 3 个字节的文字,初始序列是英文的 3 倍长,要靠合并压回来(第 5 步看训出来的效果)。
一个 token 只是半个汉字,解码时怎么办?
解码时先把每个 token 查回它代表的字节,整串拼起来,再按 UTF-8 解开。单独看一个 token 可能是半个汉字(比如 <e5><85>), 拼上后一个就完整了。第 5 章 05_sample.py 的 TokenStreamer 边生成边打印,就是凑齐一个完整字符再输出,避免吐出 �。
↳ 下一步:英文 1 个字节 1 个 token,1024 的上下文只装得下 1 KB 文本。把最常一起出现的两个字节合成一个 token,序列就短了。

② 数相邻对,合并出现最多的一对

下面是一小段玩具语料,已经按空格切成词(每个词前面的空格记成 ␣,算词的一部分)。 左边是现在所有相邻对的出现次数,橙色那一对最多。点「合并一次」,它就变成一个新 token,记进 合并表:

语料
相邻对出现次数(前 8)
合并表
–
词表大小(这段语料里的字节种类 + 合并次数)
–
语料现在几个 token
–
每 token 平均几个字节
↳ 代码:16_bpe.py 的 train_bpe(真实版会记下每一对出现在哪些段里,合并时只改这些段)
两对次数一样多,先合哪个?
规则里没有标准答案,只要固定一个次序,每次训练结果就一样。这个演示取先出现的那一对;16_bpe.py 用 Python max 在计数字典上取最大值, 次数相同时取字典里排在前面的,同样是确定的。不同实现在这里的选择不同,所以用同一份语料、同样的词表大小训出来的合并表可能不完全相同。
编码新文本时,也要重新数对吗?
不用。训练完只留下合并表。编码时对每一段:在当前相邻对里找合并表里排得最靠前的一对,合掉,再找,直到没有一对在表里。 这就是 16_bpe.py 的 make_encoder,和 tiktoken 的规则相同。解码更简单:每个 token 查回它的字节,拼起来。
↳ 下一步:这里是先按空格切词再合并,标点粘在词上,于是 "cat." 和 "cat" 成了两个不同的段。GPT-2 用一条正则切得更细,看看差在哪。

③ 预切分:合并只在段内发生

合并之前先把文本切成小段,合并不能跨段。GPT-2 的 预切分 正则把字母串、数字串、其他符号、空白分成不同的段;只按空格切时,标点跟着前面的词。切换看同一句话怎么分段:

实测两种切法各训一个 16,384 词表

同样 8 MB 语料、同样 16,128 次合并,只换预切分。点一边看词表里字母和标点粘在一起的 token:

GPT-2 正则
–
个 token 里字母和标点粘在一起
–
测试语料每 token 字节数
只按空格
–
个 token 里字母和标点粘在一起
–
测试语料每 token 字节数
↳ 代码:16_bpe.py 的 GPT2_SPLIT / SPACE_SPLIT · 对照跑法 --split space
只按空格切,压缩率反而更高,为什么还要细切?
标点粘在词上时,information, 这样一个 token 能顶两个,测试语料上每 token 多装了 –。 代价在词表:s, s. ing, ation. 这类变体占掉 2,279 个位置,约 14%, 同一个词带不同标点的几种写法各有各的嵌入向量,各自只拿到一部分训练信号。 GPT-2 论文 §2.2 描述了这个现象:BPE 会学出 dog. dog! dog? 这类变体,词表和模型容量分配得不理想,所以禁止合并跨越字符类别。
正则开头那几个 's 't 're 是做什么的?
英文缩写单独成段,dog's 切成 dog 和 's,don't 切成 don 和 't。 用 GPT-2 正则训出的词表里,字母和标点粘在一起的 7 个 token 全是这几个缩写。完整正则见 16_bpe.py 的 GPT2_SPLIT,取自 openai/gpt-2 仓库 src/encoder.py。 GPT-4 的 cl100k 改过这条正则:缩写不分大小写,数字最多 3 位一段。
↳ 下一步:预切分定好了。合并多少次、词表多大才合适?

④ 词表多大:压缩率与嵌入表

同一个分词器,只用合并表的前一部分,就是一个更小的词表。下面是 1 MB 测试语料在不同词表下的 每 token 字节数, 灰点是 GPT-2 的 50,257 词表。拖滑块:

4,096
GPT-2 正则预切分 只按空格(对照) GPT-2(tiktoken)
–
每 token 字节数
–
1 MB 测试语料的 token 数
–
1024 上下文约装多少 KB 文本
–
嵌入表参数(词表 × n_embd=768)
↳ 代码:16_bpe.py 的 make_encoder(merges, limit):只用前 limit 条合并
词表越大越好吗?
两头都有代价。词表每翻一倍,嵌入表(与输出头共用)多 词表 × n_embd 个参数;第 6 章 124M 的 50,304 × 768 = 38.6M,占全部参数的约 31%。 输出层每一步都要对整个词表算 softmax,词表大,这一步也更贵。另一头,排在后面的合并对应的 token 出现次数少,嵌入学得差。 本页曲线越往右越平:从 256 到 512 每 token 多 1.02 字节,从 8,192 到 16,384 只多 0.38 字节。
GPT-2 的 50,257 是怎么来的?
256 个字节 + 50,000 次合并 + 1 个 <|endoftext|> 特殊 token。第 6 章代码里向上取整到 50,304(128 的倍数),多出来的 47 行永远不会被用到。 它是在 GPT-2 自己的 WebText 上训的,不是 FineWeb-Edu;本页对照用的是同一段测试语料,两者训练语料不同,压缩率差距里也含有这部分影响。
↳ 下一步:同一个句子在合并 0 次、100 次、全部 16,128 次时分别怎么切?中文又会怎样?

⑤ 训出来的分词器

选一个句子,再选合并次数,看它被切成哪些 token(相邻 token 交替着色,<e5> 这类是还没拼成完整字符的字节):

词表学出来的 token 长什么样
中文为什么一个字都没合并出来?
训练语料 FineWeb-Edu 是英文网页,8 MB 里几乎没有汉字,汉字的字节对出现次数排不进 16,128 次合并, 所以 22 个汉字编成 66 个 token,和字节数一样。GPT-2 的词表里有少量常用汉字(如本例的「光」「作」「的」),同一句是 45 个 token。 分词器的压缩率取决于训练语料里有什么:Qwen、DeepSeek 这类面向中文的模型在含大量中文的语料上训分词器,词表也更大(十几万),常用汉字和词都能合成单个 token。
数字 8,045,311,447 为什么切得零零碎碎?
GPT-2 正则里数字串 \p{N}+ 是一段,合并就在这段里按频率进行,哪几位合在一起取决于训练语料里哪些数字组合常见。 本分词器切成 8 , 0 45 …,GPT-2 切成 045 311 447,都不按位值对齐。 第 21 章做加法时就遇到这个问题:37、85 在 GPT-2 词表里各是一个整体 token,模型看不到个位和十位,所以那一章把数字逐位写开。
达标本关实测
↳ 跑法:python 16_bpe.py --json runs/ch18_bpe.json(纯 CPU)· 对照 --split space
🎉 手写 BPE · 通关
你从 256 个字节出发,用"数对、合并出现最多的一对"训出了一个 16,384 词表的分词器,编码再解码逐字还原;也看到预切分让词表少花在标点变体上,以及词表大小、训练语料怎样决定压缩率。下一关拆开注意力的另一个黑盒:FlashAttention。