python神经网络编程入门(四十)——分词与数据管线:BPE 子词切分
📍 路标:本篇位于《从零构建 Transformer》系列 第 11/16 章 · 预训练篇。
系列主线:注意力思想 → 自注意力 → 多头 → 位置编码 → 编解码架构 → 手撕实现 → 预训练模型 → 实战微调 → 知识收官。
进度:▸ 基石篇(1-5) ▸ 架构篇(6-10) ▸ 预训练篇(11-13·本篇) ▸ 实战篇(14-16)
摘要:从这一章起进入"预训练篇":我们要理解 BERT、GPT 真实大模型,而大模型吃的第一口饭是数字。这一章解决"文字怎么变成数字":为什么不能用整词(词表爆炸+OOV),也不能用单字符(序列太长)?什么是 BPE 子词切分?它如何从语料里一步步合并出子词,让新词永不 OOV、还能无损还原?全章用纯 Python 手写八个示例:三种切分对比、BPE 训练(经典语料逐轮打印合并规则)、编码、解码、中文语料(合并出"人工"“智能”)、词表增长直线、填充与掩码、端到端分词器。
上一篇(三十九):训练细节:标签平滑、AdamW 与学习率调度
引言:机器只认识数字,不认识字
前 10 章我们造了一台完整的、能训练的 Transformer:前向传播(第 9 章)+ 训练三件套(第 10 章)。但它有一个"隐藏的假设"——它吃的输入是 token id(整数)。第 9 章我们随手捏了个 src_ids = [[1,2,3,4,5]] 就喂进去了,但从没认真问过:
- 这些 id 从哪来? 真实世界是 “I love cats” 这样的字符串,不是整数。把字符串变成整数序列,中间隔着什么?
- 凭什么这么切? 把 “cats” 当成一个整体(词级)、还是拆成 c-a-t-s(字符级)、还是切成 cat-s(子词级)?三种切法各有各的坑,选哪种?
- 句子长短不一怎么办? 有的句子 5 个词、有的 50 个词,而模型的输入张量必须是矩形 (B, S, d)——S 不统一怎么进模型?
这三个问题,就是"数据管线"(Data Pipeline)要回答的,而它的核心答案是本章主角:BPE 子词切分。它是 GPT、BERT、T5、Llama 全部真实模型的第一步——你今天打开任何一个大模型开源仓库,第一段代码一定是它的 tokenizer。
说句题外话:你现在用过的所有 AI 助手(包括和你聊天的这个),底层都有自己的一版 tokenizer——GPT 系列用的是 BPE 的后代(byte-level BPE),它把"分词"这个细节藏得极深,你几乎感觉不到它的存在。但分词方式直接决定了模型的"世界分辨率":词表切得太粗,模型看不见单词内部的规律;切得太细,长文本又记不住。理解 BPE,是理解真实大模型的第一块敲门砖——本章之后,你再看到"token"这个词,脑子里浮现的就不是一个抽象的整数,而是一整套"字符统计、贪心合并、查表、填充"的流水线。
🎯 本章目标
- 说清"整词/整字符/子词"三种切分各自的代价,以及为什么子词是折中解(demo1);
- 理解 BPE 的核心循环:统计相邻对 → 合并最频繁对 → 词表 +1,重复 N 次;
- 手写 BPE 训练,在经典语料 low/lowest/newer/wider 上逐轮打印合并规则(demo2);
- 手写 BPE 编码,验证"没见过的词也能切"(demo3);
- 手写 BPE 解码,验证"无损还原"(demo4);
- 用中文语料跑一遍 BPE,看懂"汉字就是天然子词"(demo5);
- 搞清词表大小与合并次数的关系(词表 = 初始字符 + 合并次数,demo6);
- 搭建数据管线:变长句子 → padding + attention mask → batch,并组装一个端到端 tokenizer 类(demo7、demo8)。
一、先把问题问清楚:模型吃的到底是什么?
回顾第 9 章:Transformer 的输入是 token id——一个整数矩阵 (B, S)。其中每个整数是"词表"(vocabulary)里的一个下标:词表第 3 号位置代表某个词,输入里的 3 就代表"这里出现这个词"。模型从头到尾没见过一个字符,它只在整数和整数之间计算。
所以"分词"(tokenization)的任务是:给定一串字符,把它切成词表中的一段段"token",再把每个 token 换成它在词表里的下标。切法千千万,但都要回答同一个问题:token 的"粒度"定多大?
把 “i love cats and i love dogs” 分别用三种粒度切一刀,token 数量立刻见分晓(demo1):
sentence = 'i love cats and i love dogs'
words = sentence.split()
print('句子: %r' % sentence)
print('字符级: %s' % list(sentence.replace(' ', '')))
print(' token 数 =', len(list(sentence.replace(' ', ''))))
print('词级 : %s' % words)
print(' token 数 =', len(words))
真实输出:
句子: 'i love cats and i love dogs'
字符级: ['i', 'l', 'o', 'v', 'e', 'c', 'a', 't', 's', 'a', 'n', 'd', 'i', 'l', 'o', 'v', 'e', 'd', 'o', 'g', 's']
token 数 = 21
词级 : ['i', 'love', 'cats', 'and', 'i', 'love', 'dogs']
token 数 = 7
子词级(示例): ['i', 'love', 'cats', 'and', 'dogs']
token 数 = 5
同一句话:字符级 21 个 token、词级 7 个、子词级(去重后)5 个。画成图直观对比:

三者的差异不是"好看不好看",而是实打实的代价,下一节逐个算账。先把完整链路看一遍,避免只见树木:字符串 → 分词(切成 token)→ 查词表(token 换成 id)→ 嵌入层查表(id 换成向量)→ Transformer。第 9 章我们从"id"开始讲,本章补上"字符串 → id"这两步——第 9 章那张嵌入查表图(emb[id]),id 的来源就是本章的 tokenizer。链条上的每一环环环相扣:分词决定词表,词表决定 id,id 决定嵌入向量,嵌入向量决定模型看到的"世界"。
二、三种切分策略的取舍:为什么两个极端都走不通
2.1 词级(Word-level):词表爆炸 + 未登录词(OOV)
把每个词当一个 token,词表就得覆盖"世界上所有的词"。英语有多少词?一百万是保守估计,而且每天都在产生新词(“internet”、“selfie”、“prompt”……)。更致命的是未登录词(Out-of-Vocabulary,OOV):训练时没见过的词,查表直接失败。常见处理是塞一个 <unk>(unknown)占位符,但代价是所有没见过的词都变成同一个符号——“catss” 和 “量子力学” 在模型眼里毫无区别,信息全丢。词表大还有第二个代价:输出层是一个 (d, 词表大小) 的大矩阵,第 10 章算过参数量,词表从 12 变到 100 万,光输出层就是几亿参数。
2.2 字符级(Character-level):序列太长
那干脆每个字符一个 token?26 个字母 + 标点,词表小到 70 个左右,而且永远不会 OOV(任何词都能拆成字符)。但代价转移到序列长度:刚才的例子,词级 7 个 token,字符级 21 个——序列被拉长 3 倍。而注意力是 O(S²):序列长 3 倍,计算量长 9 倍;1000 词的句子变成 5000 字符,注意力矩阵 2500 万个元素。长句直接训不动,而且"单词的语义单元"被切得稀碎,模型很难学到词的完整含义。
还有第三层隐性代价:单个字符几乎没有独立语义。“c” 是什么意思?“a” 呢?字符的"含义"只在与前后字符组合后才出现(c-a-t 才有"猫")。模型被迫在非常浅的层就要学会"字符如何拼成词素、词素如何拼成词",学习负担远高于直接见到 “cat” 这种有意义的单元。相比之下,子词 “cat”、“est” 本身就有可解释的含义——模型见到的是"有意义的最小单元",而不是"无意义的零件"。
两个极端的问题画成一张图:

2.3 子词级(Subword-level):两个代价各打五折
第三路:子词——比词小、比字符大。比如 “lowest” 切成 low + est,“cats” 切成 cat + s。它同时解决两个问题:
- OOV 消失:没见过的词,用见过的子词拼出来(“newest” 由 new + e + s + t 拼出,一个字符都没浪费);
- 序列可控:大部分高频词是完整 token,只有罕见词才拆开,序列长度接近词级,远小于字符级;
- 词表可控:几千到十几万,由训练时"合并多少次"直接决定。
那"子词"具体怎么定?由谁说了算?——这就是 BPE 登场的时刻。
2.4 子词家族的三个兄弟:BPE、WordPiece、Unigram
顺手把名词理清:工业界有三个"长得像"的子词算法,别搞混。BPE(本章主角):贪心合并最频繁的相邻字符对,合并依据是"出现次数";WordPiece(BERT 用的):同样是逐对合并,但合并依据不是"出现次数"而是"合并后让语料似然度提升多少"(选提升最大的对)——效果相近、逻辑略高级;Unigram(SentencePiece 的默认):反过来,从一个超大词表开始,逐步删除"删掉后损失最小"的 token,用概率模型做切分。三兄弟的产出形式完全一样(词表 + 切分规则),学懂 BPE 之后看另外两个,只是"合并/删除的评分标准"不同而已。而 GPT 系用的 byte-level BPE 是 BPE 的又一个变体:把"字符"换成"UTF-8 字节",从而能处理全世界所有语言的文字(包括中文、emoji),词表固定 256 字节起步。本章学的原理,往下兼容所有这些变体。
三、BPE 是什么:一个循环解决所有问题
BPE(Byte Pair Encoding,字节对编码) 最早是数据压缩算法(1994),2016 年被引入神经机器翻译做子词切分,后来成为 GPT/BERT 系列的事实标准。它的思想朴素到令人震惊:从"每个字符一个 token"出发,反复把"出现次数最多的相邻字符对"合并成一个新 token,合并 N 次,词表就多了 N 个"子词"。整个训练过程就是一个循环:

3.1 准备:词尾标记 为什么必须有?
动手前先解决一个细节:训练前,每个词都要在词尾粘一个特殊标记 </w>(表示"词结束")。为什么?因为如果不标,合并时模型分不清 “er” 是"词中间的 er"还是"词尾巴上的 er"——而这两种情况的统计意义完全不同。举个具体例子:newer 和 wider 都以 “er” 结尾,如果能合并出 er</w>,那么以后见到以 er 结尾的动词变化(teach-er、wait-er)都能整体识别"这是一个词尾"。</w> 让 BPE 能学到"词的形态学边界"——比如 er</w> 专门吸收"以 er 结尾"的尾巴,est</w> 专门吸收最高级。没有它,BPE 只会盲目合并,学不到这些规律。
顺便说清"为什么标记词尾、不标记词首":理论上词首词尾都标记(比如 <w> 标词首)也能工作,但英文的"形态学尾巴"(-er、-est、-ing、-tion)远比"词头"丰富,标词尾收益最大;而且词首通常是词干本身(low、new),没必要多一个符号。真实实现里,还有用"空格前缀"代替 </w> 的做法(SentencePiece 把空格编码成 ▁,放在词首),原理一样:给"词的边界"一个显式的记号,BPE 才能学到边界处的规律。
3.2 核心循环:统计、合并、更新
代码就三个函数级步骤,先看训练(语料是 {词: 词频} 字典):
from collections import Counter
def train_bpe(corpus, num_merges):
"""corpus: {word: freq},返回按学习顺序排列的 merges 列表"""
merges = []
words = {tuple(list(w) + ['</w>']): f for w, f in corpus.items()} # 词尾加 </w>
for step in range(num_merges):
pair_freq = Counter()
for sym, freq in words.items(): # ① 统计相邻对(按词频加权)
for j in range(len(sym) - 1):
pair_freq[(sym[j], sym[j + 1])] += freq
if not pair_freq:
break
best, bf = max(pair_freq.items(), key=lambda kv: (kv[1], -len(merges)))
merges.append(best) # ② 选出最频繁的相邻对
new_words = {} # ③ 把它合并进所有词
for sym, freq in words.items():
ns, i = [], 0
while i < len(sym):
if i < len(sym) - 1 and (sym[i], sym[i + 1]) == best:
ns.append(sym[i] + sym[i + 1]); i += 2
else:
ns.append(sym[i]); i += 1
new_words[tuple(ns)] = freq
words = new_words
return merges
三个注意点:①相邻对按词频加权(同一个 pair 在频次 6 的词里出现,比在频次 1 的词里出现"值钱" 6 倍);②合并规则要按学习顺序存(第 5 章说过位置、这里记住顺序——编码时"先合并学得早的规则",顺序错了切分就错);③每次合并后语料全部重写(比如合并出 er 之后,newer 里的 “newer” 就变成 n-e-w-er,下一轮统计的是 er 与相邻符号的关系)。
3.3 为什么叫"字节对"编码?名字里的故事
BPE 全称 Byte Pair Encoding,直译"字节对编码"——但刚才我们的例子合并的是"字符对",不是"字节对",名字从哪来?历史原因:BPE 1994 年诞生时是数据压缩算法,处理的对象是二进制字节流:反复把"出现次数最多的字节对"替换成一个未使用的新字节,从而压缩文件大小。2016 年神经机器翻译(Sennrich 等)把这个算法原封不动搬到"文本压缩"上:把"字节"换成"字符",把"压缩文件"换成"压缩词表",于是名字沿用了 BPE。所以严格说,今天 NLP 圈说的 BPE 是"字符对编码",GPT 的 byte-level BPE 才是真正的"字节对"版本。名字带点历史包袱,但算法内核完全一致:统计最频繁的相邻对,合并,重复。
3.4 合并次数怎么定:一个实用的数量级直觉
BPE 的"合并次数"要多大?看一条经验:真实英文语料里,合并到几千次时,高频词(the、and、of)先变成完整 token;合并到几万次时,常见词干(-ing、-tion、un-)和常见词都齐了;再往上合并,新增的 token 越来越少被用到(边际收益递减)。所以 3 万~5 万次的量级是常见选择——词表既有"常用词整词"(序列短),又有"常见词素"(能拼生词),两全其美。第七节我们会用真实数据验证"词表大小 = 初始符号 + 合并次数"这条直线,到时候"合并次数"这个旋钮的含义就彻底清楚了。
3.5 经典语料跑一遍:6 轮合并,肉眼可见地长出"词"
用 BPE 论文里的经典小语料实测(demo2):low(5) / lowest(2) / newer(6) / wider(3)。
真实输出:
【demo2】BPE 训练:经典语料 low/lowest/newer/wider
语料与词频: {'low': 5, 'lowest': 2, 'newer': 6, 'wider': 3}
学到的 6 个合并规则(按学习顺序):
第1轮: e + r → er
第2轮: er + </w> → er</w>
第3轮: l + o → lo
第4轮: lo + w → low
第5轮: n + e → ne
第6轮: ne + w → new
最终词表 (17 个): ['</w>', 'd', 'e', 'er', 'er</w>', 'i', 'l', 'lo', 'low', 'n', 'ne', 'new', 'o', 'r', 's', 't', 'w']
逐步解读这 6 轮,每一步都有明确理由:
- 第 1 轮 e+r→er:newer(频次 6)和 wider(频次 3)里都有 “er”,wider 里是 e-r 相邻、newer 里也是——共 9 次,全场最高;
- 第 2 轮 er+→er:合并出 er 后,newer 和 wider 都变成 …er,这个"以 er 结尾"的模式出现 9 次——注意这里
</w>的价值兑现了:模型学会了"er 是很多词的尾巴"; - 第 3 轮 l+o→lo:low 和 lowest 都以 lo 开头(5+2=7 次);
- 第 4 轮 lo+w→low:lo 后面都是 w(7 次)→ “low” 成为一个完整子词;
- 第 5 轮 n+e→ne、第 6 轮 ne+w→new:newer 贡献 6 次,“new” 也成为一个完整子词。
最终词表 17 个 = 初始 11 个字符(d,e,i,l,n,o,r,s,t,w + )… 数一下初始字符集 {d,e,i,l,n,o,r,s,t,w} 10 个 + = 11,加上 6 次合并 = 17——词表大小 = 初始符号数 + 合并次数,这个线性关系第六节还会再验证一次。把 6 轮过程画出来:

🎤 一句话总结 BPE 训练:从"每个字符一个 token"出发,反复合并"语料里出现次数最多的相邻对",合并 N 次得到 N 条规则和词表里的 N 个子词。高频模式(low、new、er)被保留成完整子词,罕见组合继续拆成字符——词表大小和切分粒度完全由"合并次数"这一个旋钮控制。
四、BPE 编码:没见过的词,怎么切?
训练完,词表有了、规则表有了,下一步是编码:给定任意一个词,用规则表把它切成子词序列。算法是贪心的:从左到右扫描,只要发现"能合并的相邻对",就按"规则表里学得早的优先"合并,直到没有可合并的为止。
def bpe_encode(word, merges):
sym = list(word) + ['</w>']
merge_set = set(merges)
while True:
pairs = [(sym[i], sym[i + 1]) for i in range(len(sym) - 1)]
cands = [(merges.index(p), p) for p in pairs if p in merge_set]
if not cands:
break
_, p = min(cands) # 先合并学得早的规则
ns, i = [], 0
while i < len(sym):
if i < len(sym) - 1 and (sym[i], sym[i + 1]) == p:
ns.append(sym[i] + sym[i + 1]); i += 2
else:
ns.append(sym[i]); i += 1
sym = ns
return sym
关键在 min(cands):候选规则里挑 merges.index 最小的(即学得最早的),这是确定性的来源——同样的词,永远切成同样的子词。用学到的 6 条规则去切 5 个词,其中 3 个是训练集里没有的(demo3):
💡 拿 “newest” 完整走一遍编码过程(不跳步):初始符号序列 n-e-w-e-s-t-。第一轮扫描,找"能合并的相邻对":候选有 (n,e)(第 5 轮规则)、(e,w)(无规则)、(w,e)(无)、(e,s)(无)、(s,t)(无)、(t,)(无)——只有 (n,e) 命中,合并成 ne,序列变 ne-w-e-s-t-;第二轮扫描,候选 (ne,w) 命中第 6 轮规则,合并成 new,序列变 new-e-s-t-;第三轮扫描,候选 (e,s) 无规则、(s,t) 无、(t,) 无——没有可合并的了,结束。输出 [‘new’,‘e’,‘s’,‘t’,‘’]。整个过程"每次只合并一个规则",从早到晚,绝不回头——这就是贪心的含义。
真实输出:
【demo3】BPE encode:用学到的 merges 切分(含没见过的词)
lowest → ['low', 'e', 's', 't', '</w>']
newer → ['new', 'er</w>']
newest → ['new', 'e', 's', 't', '</w>']
lowing → ['low', 'i', 'n', 'g', '</w>']
widerest → ['w', 'i', 'd', 'er', 'e', 's', 't', '</w>']
逐个看,惊喜满满:
- lowest:l-o-w 按规则 3、4 合并成 low,剩余 e-s-t 没有规则(我们没训练出 est),保持单字符;
- newer:完美命中——ne+w 合成 new,er+ 合成 er,两个子词精确还原整个词,一个字符都没浪费;
- newest:训练集里没有这个词! 但它依然被切成 new + e + s + t——“new” 来自第 5、6 轮规则,“est” 没有规则就保底成单字符。没有任何字符丢失,也没有 UNK;
- lowing:同样没训练过,low 命中规则,ing 保底;
- widerest:一个训练集从未出现的 8 字母长词,er 命中第 1 轮规则,其余保底——再长的生词,最差也能拆成单字符。
画成示意图:

🎤 一句话总结 BPE 编码:拿着规则表从左到右贪心合并,能合就合、合不动就保底成字符。任何词都能切,OOV 在子词世界里不存在——这正是子词方案相对词级方案最硬的优点。
五、BPE 解码:怎么无损拼回去?
编码的逆操作,简单到只有两步:把子词按顺序拼起来,再把 </w> 替换成空格(一个词结束,下一个词开始)。
def bpe_decode(tokens):
return ''.join(tokens).replace('</w>', ' ').strip()
demo4 验证(真实输出):
【demo4】BPE decode:子词拼回原词(</w> 还原为词界)
lowest encode=['low', 'e', 's', 't', '</w>'] decode='lowest'
newest encode=['new', 'e', 's', 't', '</w>'] decode='newest'

💡 为什么"可逆"这么重要? 模型(第 9 章那台机器)输出的是一串概率、argmax 之后是一串 token id,解码器要把这串 id 变回人能读的文本。如果 encode 不可逆(比如两个不同词 encode 成同一个子词序列),decode 时就会"撞车"——BPE 的贪心合并是确定性的,且
</w>标记了词界,所以编码不丢失任何信息,解码永远还原。这一"可逆性"是 tokenizer 的黄金标准。
5.1 一个现实细节:子词之间到底要不要插空格? 有人会问:decode 时把 </w> 换成空格,那英文句子里的"普通空格"和"子词间的边界"怎么区分?答案:子词间的边界本来就不该有空格——“newest wider” 的子词序列是 new+e+s+t++w+i+d+er,其中 new 和 e 之间没有空格(它们属于同一个词),只有 </w> 出现的位置才有空格。所以英文 tokenizer 的惯例是:词内部的所有边界都"无痕拼接",只有 </w> 产生空格。这也解释了为什么子词在代码里常写成 new、est(词中)与 er</w>、est</w>(词尾)的区分——真实模型里还有一种等价写法:词中片段加前缀 ##(如 ##est),效果完全一样,只是记号不同(BERT 的 WordPiece 就用 ##)。看懂一种记号,其他都是换皮。
六、中文场景:汉字就是天然的子词
前面全是英文例子,中文读者可能觉得隔了一层。其实中文反而是理解子词最好的例子:每个汉字本身就是一个"词素"(有含义的最小单元),天然就是理想的子词。中文做 BPE,合并出来的往往是"高频词"——比如"人工"“智能”。
拿一个小语料实测(demo5):人工智能(3) / 人工(4) / 智能(5) / 人工智障(2)。
真实输出:
【demo5】中文语料 BPE:人工/智能/人工智能/人工智障
语料与词频: {'人工智能': 3, '人工': 4, '智能': 5, '人工智障': 2}
学到的合并规则:
第1轮: 人 + 工 → 人工
第2轮: 智 + 能 → 智能
第3轮: 智能 + </w> → 智能</w>
第4轮: 人工 + </w> → 人工</w>
人工智能 → ['人工', '智能</w>']
人工智障 → ['人工', '智', '障', '</w>']
人工智慧 → ['人工', '智', '慧', '</w>']
智能制造 → ['智能', '制', '造', '</w>']
看这组结果,中文 BPE 的三个妙处全出来了:
- 第 1 轮"人+工"→"人工":语料里"人工"出现 4 次(人工)+ 3 次(人工智能)+ 2 次(人工智障)= 9 次,全场最高,优先合并;
- 没见过的词也能切:“人工智慧"和"智能制造"都不在训练集里,但"人工”“智能"作为已学子词被直接复用,新词素"慧”“制”"造"保底成单字——中文新词(网络新词、专有名词)永远不会 UNK;
- 词尾标记同样在中文生效:
智能</w>表示"智能"作为词尾出现(人工智能),人工</w>表示"人工"独立成词。

🎤 一句话总结中文 BPE:汉字即天然子词,BPE 在中文里做的是"把高频词对合并成词"——词表不大、OOV 没有、新词能切,这就是 BERT 中文版(bert-base-chinese)也用它分词的原因。
6.1 中文分词的前世今生:从"词"到"字"再到"子词"。中文处理曾经的主流是"分词"(把句子切成词,比如"人工智能"切成"人工/智能"),经典工具有 jieba、THULAC,它们靠词典 + 统计规则切词。但分词有几个老毛病:①歧义(“南京市长江大桥"怎么切?);②未登录词(新词、人名、网络词,词典里没有);③错误不可逆——一旦切错,下游全错。所以今天的模型(BERT、GPT 中文版)普遍放弃"分词"这一步:直接按字(字符)起步,用 BPE 合并出高频词对。字是"永远不会切错的原子”,BPE 在字之上自动长出"词"——既躲开分词歧义,又保留词的语义。本章 demo5 已经演示了这个过程:没有词典、没有规则,仅凭"共现频率"就合并出"人工"“智能”。这就是为什么说"子词方案是中文 NLP 的分水岭"。
七、词表大小怎么定:一个旋钮,一条直线
BPE 唯一的超参数是"合并多少次"。合并次数直接决定词表大小,而且关系简单得惊人:词表大小 = 初始符号数 + 合并次数——每合并一次,就引入一个新符号(子词),词表恰好 +1。用一个 100 词的英文语料实测(demo6),合并 0 到 100 次:
真实输出:
【demo6】合并次数 vs 词表大小(用一个更大的英文语料)
语料: 100 个不同的英文词
初始字符词表大小(0 次合并): 24
合并 0 次 → 词表 24 个
合并 10 次 → 词表 34 个
合并 20 次 → 词表 44 个
合并 30 次 → 词表 54 个
合并 40 次 → 词表 64 个
合并 60 次 → 词表 84 个
合并 80 次 → 词表 104 个
合并 100 次 → 词表 124 个
每行都是 24 + 合并次数,一条完美的直线:

这条直线给我们一个实用经验:想要多大词表,就合并多少次。真实模型怎么选?GPT-2 用 5 万,BERT 用 3 万,中文版 BERT 用 2 万+——量级都是"几万",因为这个量级下:高频词是完整 token(序列短),中低频词是常见子词(能复用),生僻词拆成字符(有保底)。合并次数太少 → 词表小、序列长;太多 → 词表大、参数多、罕见子词学不好——和所有超参数一样,要按语料规模权衡。
几个真实模型的词表,感受一下"几万"是什么概念:GPT-2 的词表约 50,257 个(50,000 合并 + 特殊符号),BERT-base 约 30,522 个,Llama-2 用 SentencePiece 的 32,000 个,中文版 BERT 约 21,128 个。对照本章 17 个、124 个的小词表——规模差三个数量级,但原理完全一样:都是"初始符号 + 合并/学习出来的子词"。你以后在 tokenizer.json 里看到的 vocab 字典,就是这 3 万个"词表 + 规则"的展开形式。
八、数据管线:变长句子怎么打包成 batch?
分词解决了"文字→id",还剩最后一个问题:句子有长有短,模型的输入张量却是固定形状 (B, S, d)。3 个句子分别有 3、1、5 个词,怎么塞进一个张量?答案两步:padding(填充)+ attention mask(掩码)。
sentences = ['i love cats', 'cats', 'i love dogs and cats']
word2id = {'<pad>': 0, '<unk>': 1, 'i': 2, 'love': 3, 'cats': 4, 'dogs': 5, 'and': 6}
max_len = 5
id_seqs = [[word2id[w] for w in s.split()] for s in sentences]
padded = [ids + [0] * (max_len - len(ids)) for ids in id_seqs]
mask = [[1] * len(ids) + [0] * (max_len - len(ids)) for ids in id_seqs]
真实输出(demo7):
【demo7】batch 构建:变长句子 → padding + attention mask
token → id 映射: {'<pad>': 0, '<unk>': 1, 'i': 2, 'love': 3, 'cats': 4, 'dogs': 5, 'and': 6}
编码后的 id 序列: [[2, 3, 4], [4], [2, 3, 5, 6, 4]]
padding 后 (B=3, S=5):
[2, 3, 4, 0, 0]
[4, 0, 0, 0, 0]
[2, 3, 5, 6, 4]
attention mask(1=有效词,0=padding 位置,模型不该关注):
[1, 1, 1, 0, 0]
[1, 0, 0, 0, 0]
[1, 1, 1, 1, 1]
- padding:用特殊 token
<pad>(id=0)把短句子补齐到最长句子的长度(5),所有句子变成同样形状的矩形; - attention mask:一张同样形状的 0/1 矩阵,1 表示"这里是真词,模型可以看",0 表示"这里是我填的假词,别当真"。
mask 怎么用?回顾第 8 章:注意力打分矩阵算出来后,把 (1-mask) × (-1e9) 加进去,pad 位置的分数变成负无穷,softmax 后权重归零——模型彻底无视这些假词。注意它和第 8 章的"lookahead mask"(防偷看未来)是两张不同的 mask,真实训练里两张要同时用:先做 lookahead 遮未来,再做 padding 遮假词(实践上常用一张相加的复合 mask)。

🎤 一句话总结数据管线:分词把"文字"变"id",padding 把"变长"变"等长",mask 把"假位置"变"隐身"——三步之后,任意长度的句子都能喂进固定形状的 Transformer。
8.4 一个现实优化:按长度分桶(bucketing)。把 batch 里所有句子都 pad 到"本批最长句"的长度,如果一批里既有 5 个词的短句又有 200 个词的长句,短句就得白白 pad 到 200,一半以上的计算量花在 <pad> 上。工业界的标准做法是先把语料按长度分桶(比如 <20 词、20~40 词、40~80 词……),每个 batch 只从同一个桶里采样——这样批内句子长度接近,padding 浪费最小,训练速度能快 2~3 倍。本章的例子小,体会不到差距;等你第 14 章用真实数据集训练时,这个细节会直接影响训练时长。原理永远一样(pad + mask),工程上怎么省 padding 是另一门学问。
8.5 特殊 token 家族:<pad>、<unk>、<cls>、<sep>、<mask>。本章已经见过 <pad>(填充)和 <unk>(兜底),真实模型的词表里还有一群"带尖括号的老熟人":<cls>(句首分类标记,BERT 用它汇总全句信息)、<sep>(句间分隔符,BERT 的"两句话任务"用它隔开)、<mask>(掩码占位,第 12 章 BERT 预训练的主角)、<bos>/<eos>(生成模型的开头/结尾标记,第 8 章我们手动往目标句前面补过 <BOS>,就是它)。这些特殊 token 不是词,而是"协议的语法符号"——它们和普通 token 一样占一个词表 id(所以真实词表 = 子词数 + 特殊符号数,GPT-2 的 50,257 = 50,000 + 257 就是这么来的),一样参与嵌入、注意力,只是含义由"协议"规定。到第 12 章你会看到,BERT 的输入 = 特殊 token + 文本 token 的精心编排。
8.6 一套完整的 batch 长什么样? 把本章所有零件组装起来,一个"模型能吃"的 batch 就是三件套:ids(形状 (B,S) 的整数矩阵,含 pad)、attention_mask(形状 (B,S) 的 0/1 矩阵,1 是有效词)、labels(形状 (B,S) 的目标 id,pad 位置填 -100 之类的"忽略值",让 loss 只算有效位置)——真实框架(HuggingFace)的 BatchEncoding 对象就是这三样的翻版。你以后在任何 Transformer 训练代码里看到的 batch['input_ids']、batch['attention_mask']、batch['labels'],全部是本章概念的"工业实现"。
九、端到端:一个完整的 tokenizer 类
最后把本章所有零件组装成一个完整的 tokenizer 类,验证"文本 → 子词 → id → batch → 文本"全链路闭环(demo8):
class TinyTokenizer:
def __init__(self, merges, corpus):
self.merges = merges
base = set(''.join(corpus)) | {'</w>'} | {a + b for a, b in merges}
self.vocab = ['<pad>', '<unk>'] + sorted(base - {'<pad>', '<unk>', '</w>'}) + ['</w>']
self.stoi = {t: i for i, t in enumerate(self.vocab)}
def encode(self, word): # 词 → 子词
return bpe_encode(word, self.merges)
def encode_sentence(self, text): # 句 → 子词序列
toks = []
for w in text.split():
toks += self.encode(w)
return toks
def to_ids(self, text): # 句 → id 序列
return [self.stoi.get(t, self.stoi['<unk>']) for t in self.encode_sentence(text)]
def decode_sentence(self, ids): # id 序列 → 文本
return bpe_decode([self.vocab[i] for i in ids])
tok = TinyTokenizer(merges, ['low', 'lowest', 'newer', 'wider'])
真实输出:
【demo8】端到端数据管线:文本 → BPE → id → batch
词表(19 个): ['<pad>', '<unk>', 'd', 'e', 'er', 'er</w>', 'i', 'l', 'lo', 'low', 'n', 'ne', 'new', 'o', 'r', 's', 't', 'w', '</w>']
训练语料: ['low', 'lowest', 'newer', 'wider']
'lowest newer' → tokens=['low', 'e', 's', 't', '</w>', 'new', 'er</w>'] → ids=[9, 3, 15, 16, 18, 12, 5] → decode='lowest newer'
'newest wider' → tokens=['new', 'e', 's', 't', '</w>', 'w', 'i', 'd', 'er</w>'] → ids=[12, 3, 15, 16, 18, 17, 6, 2, 5] → decode='newest wider'
端到端 batch(ids + mask):
ids=[9, 3, 15, 16, 18, 0, 0, 0, 0] mask=[1, 1, 1, 1, 1, 0, 0, 0, 0]
ids=[9, 3, 15, 16, 18, 12, 5, 0, 0] mask=[1, 1, 1, 1, 1, 1, 1, 0, 0]
ids=[17, 6, 2, 5, 12, 3, 15, 16, 18] mask=[1, 1, 1, 1, 1, 1, 1, 1, 1]
看第二行数据:'newest wider'(两个词都不在训练语料里)被切成 new + e + s + t + + w + i + d + er,映射成 id,再 decode 回来一字不差——这就是"预处理 → 模型 → 后处理"的完整闭环。整条数据管线的全景图:

十、常见坑与自查
- 合并时忘了词尾标记
</w>:不标,BPE 学不到"er 是词尾"这类形态学规律,切分质量明显下降。自查:训练结果里应出现er</w>、est</w>这类"带尾巴"的子词; - 编码时合并顺序用错:必须按"学得早的规则优先",用
merges.index(p)取最小。顺序乱 → 同一个词每次切得不一样 → decode 撞车; - 把 BPE 训练当"一次性的离线步骤":BPE 在训练集上学好 merge 规则和词表后,模型训练和推理全程复用这套规则,不再更新。规则和词表要单独保存,推理时加载同一份(现实中就是 tokenizer 的 json/vocab 文件);
- padding 了却忘了 mask:只 pad 不 mask,模型会把
<pad>当成真词去算注意力,等于往句子里掺噪声。自查:batch 里 mask 的 0 位置必须与 pad 位置一一对应; - 把 lookahead mask 和 padding mask 混成一张:解码器里两张都要,lookahead 遮未来、padding 遮假词,实操中相加成一张复合 mask;
- 词表开多大拍脑袋:用"词表 = 初始字符 + 合并次数"这条直线来预估——想要 3 万词表,初始字符约 1000,就合并约 2.9 万次;
- 中文直接照抄英文流程:中文可以跳过"字符化"步骤(汉字就是天然 token 起点),BPE 直接合并"字对"成词,效果更好——这也是中文 BERT 的常见做法;
- 把
<pad>也算进 loss:训练时如果对 padding 位置也计算损失(比如用交叉熵时 pad 位置的目标是 0),会白白增加 loss 噪声——标准做法是用 mask 把 pad 位置的 loss 置零,只统计有效 token 的 loss(第 12 章 BERT 的 MLM 里会再遇到); - 词表里塞了"从未见过的符号"却忘了留
<unk>兜底:即使子词方案 OOV 极少,也建议保留<unk>作为"最后防线"(比如遇到编码错误、异常字符),否则dict[key]直接 KeyError 崩溃; - BPE 对拼写噪音敏感:训练语料里如果混着大量拼写错误(“teh” 代替 “the”),BPE 会为错误拼写专门合并出 token,浪费词表名额——真实流水线里通常先做清洗(小写化、去重、纠错)再训练 tokenizer,这也是"数据管线"里"数据清洗"环节存在的意义。
小结
这一章我们把"文字 → 数字"这条数据管线彻底打通,核心收获八条:
- 模型只吃整数:字符串 → 分词 → token id → (B,S) 矩阵,是任何文本模型的必经之路;
- 三种切分各有代价:词级词表爆炸 + OOV(新词变 UNK 丢信息),字符级序列拉长 3~4 倍(注意力 O(S²) 爆炸),子词级两者各打五折(demo1、图 1、图 2);
- BPE 训练 = 一个循环:统计相邻对(按词频加权)→ 合并最频繁对 → 词表 +1 → 重复 N 次(图 3);
- 词尾标记
</w>让 BPE 学到形态学:经典语料 6 轮合并出 low、new 两个完整词和 er 词尾(demo2、图 4); - 编码贪心且确定:能合就合、合不动保底成字符——newest、lowing、widerest 这些没见过的词全部可切,OOV 在子词世界不存在(demo3、图 5);
- 解码无损可逆:拼接 +
</w>→空格,encode→decode 一字不差(demo4、图 6); - 中文汉字即天然子词:小语料上直接合并出"人工"“智能”,没见过的"人工智慧""智能制造"照样能切(demo5、图 7);
- 词表大小 = 初始符号 + 合并次数(一条直线),变长句子用 padding + attention mask 打包成固定形状 batch,端到端 tokenizer 类把整条管线闭环(demo6/7/8、图 8/9/10);
- 现实全景:BPE 的三兄弟(WordPiece/Unigram/byte-level BPE)只是"评分标准"不同;特殊 token(
<pad>/<cls>/<sep>/<mask>)是协议的语法符号,同样占词表 id——看懂本章,真实模型的tokenizer.json就是一张展开的表。
有了 tokenizer,我们终于可以用真实文本训练模型了。下一章进入预训练篇的核心:BERT——双向编码器与掩码语言模型。它会用到本章的每一个概念(词表、token id、padding、mask),并把 Transformer 的 Encoder 变成一台能"读"双向上下文的机器。
下一篇(四十一):BERT——双向编码器与掩码语言模型
动手指南:五道练习题,把"看懂"变成"会切"
BPE 是出了名的"改了语料立刻见效果"的算法,强烈建议动手:
- 换你自己的语料:把 demo2 的
corpus换成你的中文名、常用词(比如{'深度学习': 5, '深度': 3, '学习': 4}),跑train_bpe看它合并出什么——大概率先合并出"深度"“学习”,这就是"高频词优先变 token"的直接体验; - 验证"直线":把 demo6 的语料换掉(比如只用 20 个词),重新记录"合并次数 → 词表大小",核对是否仍然满足"词表 = 初始符号 + 合并次数"——这是对算法不变式的一次亲自证明;
- 把合并次数翻倍:demo2 从 6 轮改成 12 轮,看词表多出哪些新子词(est、es、st…?),体会"次数越多,切分越粗";
- 打乱规则顺序:把
merges列表random.shuffle后再bpe_encode('lowest'),对比切分结果是否变化——你会发现同一句话被切成不同样子,这正是"顺序决定确定性"的实证; - 实现 char 与 word 两级对照:对 demo1 的句子,分别统计"字符级"和"词级"的 token 数,再对比 BPE 的——把三种粒度的"序列长度/词表大小"画成一张小表,你就会彻底明白"折中"二字的分量。
五题做完,你对"为什么所有大模型都用子词切分"就有了肌肉记忆——不是背下来的,是亲手切出来的。隐藏彩蛋题:去 HuggingFace 随便下一个真实 tokenizer(比如 bert-base-chinese),用它的 encode('我爱北京天安门') 看看输出——对照本章,你会发现它内部做的事和我们手写的 TinyTokenizer 一模一样,只是规模大了四个数量级。
更多推荐

所有评论(0)