主题
第 03 章 文本怎样变成 token:词表、BPE 与特殊符号
文本进入模型之前,要先变成一串整数 ID。分词器负责确定文本片段与 ID 的对应关系,并在编码时遵守同一套规则。模型学到的是这些 ID 对应的规律,所以训练后不能随意调换它们:即使词表大小没变,原来学到的含义也会错位。
本章沿着文本进入模型的过程展开:先看分词器如何分配 ID,再用滑窗构造训练样本,最后把 ID 转成带有位置信息的向量。内容覆盖上游 ch02 的主 notebook、压缩版 dataloader、习题,以及六组目录中的 BPE 对照、embedding 对照、滑窗直觉、自写 BPE 和保留空白的 V3。各个教学实现的适用范围会分别说明;某个示例能编码后还原,并不等于它兼容所有 Unicode 文本或 GPT-2。固定版本 ch02
3.1 先实现一个会失败的分词器
先用 re.split 拆开文本,分词时保留了什么、丢掉了什么,会比较容易看清。正则表达式中的捕获括号会保留分隔符,因此按空格和逗号切开“青山, 青山。”时,可以同时得到文字、标点、空白和空字符串。空字符串可以去掉。不过,如果对每个片段调用 .strip(),再丢掉空白片段,连续空格、缩进和换行信息也就无法恢复了。
上游 SimpleTokenizerV1 把切好的片段去重、排序,再用 enumerate 生成 str_to_int;逆向字典 int_to_str 用来解码。只要词的集合相同,排序就能让 ID 分配保持稳定;换了语料、改变了词集合,旧 ID 仍可能变化。encode 对每个片段查字典;遇到未见词会抛出 KeyError。decode 用空格拼接,再用正则删除部分标点前的空格。这样的解码按规则重新排版,无法保证还原任意原文。
V2 添加 <|unk|> 和 <|endoftext|>。前者让未见词不再报错,却把所有未见词压成同一个 ID;后者标记文档边界。若“青山”和“远海”都不在词表中,它们都会变成未知词,解码时不可能猜回原词。因此,检查分词器时还要看它丢了哪些信息,不能只看是否报错。
V3 针对空白丢失的问题改了两处:编码时保留每个非空字符串片段,包括空格和换行;解码时用空字符串连接。只要所有片段已在词表中,就可精确往返,包括两个连续空格。它仍依赖封闭词表,未知词仍丢失,因此不是字节级通用分词器。V3 源码
3.2 特殊符号是协议,不是普通装饰
BOS、EOS、PAD、UNK 分别常用于序列开始、结束、补齐和未知片段,但并非每个模型都有这四种独立符号。GPT-2 使用 50,257 项词表,其中 <|endoftext|> 的 ID 为 50,256。PAD 与 EOS 是否可以共享 ID,取决于数据和损失掩码怎样处理,不能因为 ID 相同就把真实结束位置一起忽略。
特殊符号还有一个容易混淆的地方:用户也可能在普通文字中写出它的字面字符串。程序此时需要决定,是把这段文字当作普通字符编码,还是解释为控制 token。上游 tiktoken 示例通过 allowed_special={"<|endoftext|>"} 显式允许这一项。把任意外部文本都当作允许的控制符号可能改变文档边界或模板含义。相反,不允许时出现错误也不说明文本本身无法编码,而是当前接口拒绝把它解释为特殊符号。
即使正确插入了边界 token,文档之间也未必彼此隔离。它告诉模型“这里出现过边界”,却不会自动阻止注意力看见上一份文档。若任务要求严格文档隔离,还要构造分块注意力掩码。这个区别会影响预训练样本拼接,也会影响后面的指令模板。
3.3 字节与字符的区别先于 BPE
前面的分词器依赖词表里已经出现过的片段。要理解字节级分词器为何能覆盖更多输入,需要先分清字符和字节:UTF-8 把文本编码成字节序列,每个字节在 0—255 之间。“山”是一个 Unicode 字符,却对应三个 UTF-8 字节;某些表情需要四个字节。因此一个含全部 256 种单字节的词表能表示任意有效 UTF-8 文本,但一个只含 chr(0) 到 chr(255) 的字符词表并不等价。
本书 ByteBPE 直接保存 bytes 片段,从 256 个单字节开始。解码时先把所有 token 对应的字节拼起来,最后才执行 UTF-8 解码。这个顺序不能颠倒。一个 token 可能只包含某个字符的一部分字节;若先分别解码再拼接,就可能失败或产生替换字符。
GPT-2 的公开编码器使用 bytes_to_unicode 把所有字节可逆地映射到一组可打印字符,避免空白和控制字符干扰字符串处理。映射后的字符只是保存字节的中间表示,不能按原文字符来理解。例如显示出来的 Ġ 常用于代表空格字节;它只是编码表示,不是要求在用户文本里插入一个拉丁字母。上游所附 GPT-2 编码器
3.4 训练 BPE:数相邻对,再无重叠替换
字节提供了最初的单位,BPE 再把经常相邻出现的单位合成更长的片段。先看一个抽象例子:给定序列 [a,b,a,b,a],相邻对 (a,b) 出现两次,(b,a) 也出现两次。需要规定并列时怎样选择,否则不同实现可能学到不同 ID。假设选择 (a,b),分配新 ID c,从左向右无重叠替换,得到 [c,c,a]。再数相邻对,重复直到达到词表预算或停止条件。
为什么替换时要强调“无重叠”?序列 [a,a,a] 有两个相邻 (a,a) 位置,但一次替换最多得到 [c,a],不能同时把中间的 a 用两遍。计数反映候选频率,替换反映不重叠的实际编码。
本书实现的训练规则是:选择频率最高的相邻对;频率并列时按两个整数的字典序;只合并至少出现两次的对。按这套规则,目标词表大小只是上限;如果已没有满足频率条件的相邻对,训练会提前停止。用 低山 low low lower low\n低山 训练,预算 274,实测得到 265 项词表、9 次合并。上游自写 BPE 的停止规则不同,可以继续合并只出现一次的对,不能拿本书的词表大小直接对照其 1,000 项示例。
代码中 Counter(zip(ids, ids[1:])) 建立计数,merge 同时移动指针一格或两格,新 token 的字节内容是两个旧片段的拼接。完整实现位于 examples/core_training/core.py,这些动作都没有依赖预训练词表。
3.5 编码时为什么看合并排名,不重新训练
训练结束后,合并规则及其先后顺序就确定了。编码新文本时,要按保存的规则执行,不能根据这段文本的频率重新训练。否则同一个 token ID 在不同请求里可能表示不同内容。
设已有合并 (b,c)→u,排名 0;(a,b)→v,排名 1。新输入 [a,b,c] 应先合并排名更小的 (b,c),得到 [a,u]。一个从左到右看见可合并对就立刻合并的算法会得到 [v,c]。两种结果都可能解码回原文,却给出了不同的 token 序列。预训练模型依赖训练时的编码方式,因此还原文本正确,并不足以证明两种编码方式可以互换。
本书的 encode 每轮取当前相邻对与规则集合的交集,选择最早学到的规则,再合并该对的所有不重叠出现位置。GPT-2 Encoder.bpe 也以最低 rank 选择合并,并用缓存避免反复处理相同预分词片段。缓存只提高速度,不应改变编码结果。
GPT-2 在 BPE 前还有正则预分词:分别处理缩写、字母、数字、标点和空白片段,BPE 不跨这些片段合并。因此,仅拥有同一词表和合并规则还不足以保证兼容;预分词、字节映射、特殊符号和解码规则也必须相同。
3.6 上游两种自写 BPE 的用途和实测边界
bpe-from-scratch-simple.ipynb 优先展示数据结构:vocab、逆词表、bpe_merges,训练时用 find_freq_pair 和 replace_pair。find_freq_pair 还接受 most 和 least 两种模式,非法模式报错;空序列或单项序列没有相邻对,返回 None。其编码路径通过 .split() 分词,连续空白和换行因此丢失。它从字符而非完整 UTF-8 字节集合构建实际输入表示,未见 Unicode 字符可能报错。
较复杂的 bpe-from-scratch.ipynb 增加 pretokenize_text,明确保存空格、LF、CR、CRLF;训练在每个预分词片段内计数与合并,避免跨词边界产生难以解释的空格标记。load_vocab_and_merges_from_openai 读取 encoder.json 和 vocab.bpe,保留官方合并 rank,并验证换行显示字符和结束符号的指定 ID。encode 先检查禁用特殊符号,再按允许的符号切分,普通部分递归进入常规编码。按长度递减排列特殊符号可以避免短符号抢先匹配长符号的前缀。
save_vocab_and_merges 把词表和合并列表写为 JSON;加载时 JSON 对象键是字符串,必须转回整数。get_special_token_id 用缓存查 ID。若同一个对象的词表重新训练或重新加载,缓存及旧合并规则也需要保持一致;最容易审计的做法是创建新对象再加载,而不是假定所有可变状态都会自动重置。
增加这些处理后,复杂版仍不能直接当作适用于所有输入的正式 GPT-2 替代品。原有材料记载:抽取已检查的类定义,离线训练一个小词表,再运行以下探针,结果记在 qa/core_training/tokenizer_probes.json。当前版本未附这份结果文件,以下仅转述原有反例,尚不能据此独立复核:
| 输入 | 简单版解码 | 复杂版解码 | 说明 |
|---|---|---|---|
a␠␠b\n | a␠b | 原样恢复 | 复杂版改善空格和换行 |
a\tb | a␠b | ab | tab 不满足任意空白可逆的承诺 |
Æ | Æ | 换行 | 自训字符 ID 198 与官方换行 ID 的专门处理冲突 |
| 未训练字符“山” | 报错 | 报错 | 字符初始化不等于全字节覆盖 |
表中的 ␠ 仅用于显示空格。Æ 反例来自复杂版 decode 无条件把 ID 198 当换行,而它从零训练时 chr(198) 正好是 Æ。演示中的英文样例仍然可以成立,但加载官方词表时使用的特殊约定,不能直接套到自训词表上。本书字节版保留了中文、表情、CRLF、连续空格和普通特殊符号字面量的精确往返,但没有实现 GPT-2 正则或特殊控制符号,明确不声称 token ID 兼容。
这些探针与上游完整测试的范围也要分开。上游 tests.py 检查固定训练词表、预期 token IDs、标点组合、换行、空格和结束符号。其 notebook 导入器会执行全部代码单元,可能触发下载,所以本书没有直接运行该测试入口,只执行了定义和独立探针。下载路径与完整 tiktoken 测试套件仍属未运行范围。
固定源树已经包含 encoder.json 与 vocab.bpe,原有材料记载,曾用原始 GPT-2 Encoder 离线读取它们,逐项比较六条输入。四条英文、空格与换行样例 ID 相同;中文加表情在原始编码器可往返,教学复杂版报未见字符;tab 在教学版丢失。原材料指向 source_variant_checks.json,但当前版本也未附这份结果文件。上述六项对照尚待复核;即便记录齐全,有限探针也不是全量兼容性证明。
3.7 比较分词器时先比较结果,再比较速度
上游对照 notebook 比较原始 Python GPT-2 编码器、tiktoken、Hugging Face 普通与 fast 分词器,以及自写版本。它们先编码同一短文本,核对 ID 和解码,再用同一篇文本计时。这个顺序能先确认各个实现是否在做同一件事。若输出不同,速度数字就不能直接比较。
确认输出一致后,再检查计时范围:首次加载、文件下载和缓存预热是否算在内?Hugging Face 的截断选项还会改变处理范围,必须确认返回的 token 数相同。%timeit 是 notebook 魔法命令,不能直接放进普通 Python 脚本。上游曾展示某个环境下的速度差,本书没有复测这些依赖和下载路径,不把那个倍数写成普遍结论。
原始编码器的 get_encoder 只读取词表与合并文件,download_vocab 负责下载;两件事应分开。主 notebook 下载短文时检查本地文件,网络请求设置超时并检查状态。证书失败应修复环境的证书链,不能关闭验证。配套实验完全离线,不需要下载这篇短文或官方词表。
3.8 滑窗负责构造问题和答案
分词器给出 ID 序列后,还要把它切成模型能学习的输入和目标。令完整 ID 序列长度为
每个输入位置的目标,都是序列中紧随其后的 token。所谓“右移”或“左移”容易因参照对象不同而混淆,直接写出索引就能看清对应关系。
本书用整数 0—10、[0,1,2,3]、[3,4,5,6]、[6,7,8,9],对应目标 [1,2,3,4]、[4,5,6,7]、[7,8,9,10]。断言 X[:,1:] == Y[:,:-1] 能检查内部移位,但还应核对每条的最后标签。

上游 dataloader 直觉 notebook 把文本换成 0—1000 的数字,暂时绕开分词器,依次观察 stride 1、stride 4、批量与 shuffle。这样可以把两个问题分开:数据窗口不对时,先核对整数序列,不必同时排查 BPE。压缩版 dataloader.ipynb 与主版本保持相同 Dataset/Loader 接口;练习版本默认不打乱、不丢最后一批,与主版本默认值不同,比较“第一批”时必须统一设置。
步长小于窗口长度时,窗口会重叠,同一个 token 也可能被多次用于训练。重叠本身未必有问题,但划分训练集和验证集时,需要先按文档或不重叠的文本区间分开,再分别建窗口。先造所有重叠窗口再随机分到训练、验证,几乎相同的上下文可能落到两边,验证损失会误导。
3.9 Embedding 是可训练的取行操作
窗口里的 ID 只是编号。进入模型后,每个编号要从 embedding 矩阵中取出一行向量。词表大小
上游 embedding 对照把 Linear.weight 设为 embedding.weight.T,因为 Linear 内部使用权重转置。仅给两层相同随机种子不够:初始化规则、形状及随机数消费顺序可以不同。比较时应显式复制权重。
这种等价关系也延伸到反向传播。若 ID 2 出现两次,损失对
本书已有的 float64 检查使用 [2,2,6]。保存结果中的查表与 one-hot 梯度误差为 0;脚本另有前向一致性断言,但结果文件未单独记录前向误差数值。
3.10 加入位置后才知道“在哪儿”
查表解决了“这是哪个 token”,还没有解决“它在哪里”。相同 ID 查到相同向量,所以单靠 token embedding 无法区分两个相同词出现的位置。GPT-2 使用可训练的绝对位置矩阵
token embedding 形状是 arange(T),而不是 token ID。位置矩阵的容量有限,直接把位置索引设为
3.11 练习、提示与答案
上游练习 2.1 用一个罕见字符串逐项检查分词,说明一个未见词可以由已有子词组成。不要把某次分词输出当成所有分词器的答案。练习 2.2 改 max_length 和 stride,观察输入窗口。其关键结论可由 3.8 的索引直接验证。
补充题一:构造 [a,b,c] 的两条合并规则,让从左到右合并与按 rank 合并不同。提示:让右侧的合并排名更早。答案就是 3.5 的 (b,c) 与 (a,b)。
补充题二:为什么单 token 的 UTF-8 解码可能失败,而整个序列能解码?答案:token 边界可以切在多字节字符内部,正确顺序是先拼字节再解码。
补充题三:
运行主断言用 python examples/core_training/run_checks.py。若已拥有固定源码,运行 python examples/core_training/tokenizer_probes.py runtime_sources/upstream qa/core_training/tokenizer_probes.json 可复查上述上游反例;此脚本不执行下载单元。
参考与实现覆盖
- 主 notebook 与习题:正则、词表、特殊符号、滑窗、嵌入、位置。
- BPE 对照和 GPT-2 编码器:字节映射、rank、缓存、文件加载和计时边界。
- embedding 对照、数字滑窗。
- 两版自写 BPE 与测试:已区分自训和官方加载路径,未声明任意 Unicode 兼容。
交互实验:教学 BPE 合并
从字符到 token:逐条应用合并规则
改一段文本,看相邻符号如何按固定顺序合并,再核对每个 token 的教学 ID。这是自定义字符级 BPE,不是 GPT 分词器,也不在输入文字上重新训练规则。
01
输入 · 给固定规则一段文本
02
过程 · 一条规则,合并所有匹配位置
规则 0 / 7当前:按字符拆开
初始状态:按字符拆分 · 当前 18 个 token。
当前 token 序列
每张卡是完整 token;␠ 表示空格。可横向滚动,聚焦后用左右方向键查看。
- lID 11
- oID 14
- wID 22
- eID 4
- rID 17
- ␠ID 26
- lID 11
- oID 14
- wID 22
- eID 4
- sID 18
- tID 19
- ␠ID 26
- lID 11
- oID 14
- wID 22
- eID 4
- rID 17
当前 token 数18按当前规则进度得到的符号数量
本步合并处数0尚未应用合并规则
固定合并表
从上往下应用,不按当前文本重新排序。每条规则从左到右合并所有不重叠的匹配;窄屏可横向滚动,聚焦后用左右方向键查看。
- 1l + o → lo教学 ID 31待应用
- 2lo + w → low教学 ID 32待应用
- 3e + r → er教学 ID 33待应用
- 4low + er → lower教学 ID 34待应用
- 5s + t → st教学 ID 35待应用
- 6e + st → est教学 ID 36待应用
- 7low + est → lowest教学 ID 37待应用
03
解释 · token 不必是完整单词
先猜一条规则能合并几处。默认文本里,第一次合并会同时处理三个 l + o。走到最后再核对:符号数量变少了,把所有 token 拼回去,原文没有改变。
完整教学词表与边界
基础词表按 a–z、空格、句点、逗号、感叹号、问号排序,ID 从 0 开始;新符号按合并表追加。␠ 只是空格的显示标记。
词表保留完整符号;键盘聚焦区域后,可用左右方向键查看。
- ID 0: a
- ID 1: b
- ID 2: c
- ID 3: d
- ID 4: e
- ID 5: f
- ID 6: g
- ID 7: h
- ID 8: i
- ID 9: j
- ID 10: k
- ID 11: l
- ID 12: m
- ID 13: n
- ID 14: o
- ID 15: p
- ID 16: q
- ID 17: r
- ID 18: s
- ID 19: t
- ID 20: u
- ID 21: v
- ID 22: w
- ID 23: x
- ID 24: y
- ID 25: z
- ID 26: ␠
- ID 27: .
- ID 28: ,
- ID 29: !
- ID 30: ?
- ID 31: lo
- ID 32: low
- ID 33: er
- ID 34: lower
- ID 35: st
- ID 36: est
- ID 37: lowest
本章若采用 UTF-8 字节级 BPE,其基础单元和训练流程与本演示不同;此处只帮助观察“按既定规则合并”这一步。不做大小写转换或其他规范化;合并规则不跨空格。未知字符会明确报错,不伪造 token ID。真实 tokenizer 可能使用字节基础词表、预切分、特殊 token 和不同的合并规则;不能用这里的计数估算 API 账单。
不操作也能读懂:l + o → lo,lo + w → low,e + r → er,low + er → lower。token 是词表中的符号,不必等于一个字或一个完整单词。
依据:Hugging Face:BPE 的训练与编码。本例规则为明确列出的教学规则,并非从真实 GPT 词表摘取。