Transformer 实现机制 · 第 2 课

分词与 BPE —— 词表是怎么从语料里长出来的

⏱ 约 25 分钟 · 前置:第 1 课(LLM 全景) · 主干教材:Vizuara《The Transformers》1.3–1.4 · 本课目标:说清三种分词策略的取舍;亲手用 BPE 从 4 个单词"长"出一个词表,并拿它切没见过的新词

第 1 课的地图上,流水线第一格写着「文本 → 分词 / BPE」。当时我们直接用了词——今天补上这一格:模型的输入不是词,是词元(token);把文本切成词元的那把刀,就是分词器(tokenizer)。刀法好不好,直接决定模型后面 12 层 Transformer 的难度。

一、三种刀法:按词切、按字符切、按子词切

同一个词 tokenization,三种切法完全不同(原文图 1.13):

刀法tokenization 被切成词表大小主要问题
按词tokenization(1 个)巨大(英语 20 万+)没见过的词直接失联
按字符t·o·k·e·n·i·z·a·t·i·o·n(12 个)极小(256 个字符)序列暴长,单个字符无语义
按子词token·ization(2 个)适中(GPT-2 约 5 万)几乎没有——这就是现代 LLM 的选择

按词切:三宗罪

① 词表爆炸:光英语就 20 万+ 词,其中还挤满了 this、is、a 这类信息量极低的填充词;② 亲缘词形同陌路:learn、learning、learned、learnt 被切成四个毫无关联的独立词元,模型得各学各的(原文图 1.15);③ 生词失联(OOV):拼错一个字母,running 变 runing,词表里查无此词,模型当场宕机。

按字符切:词表是小了,代价更狠

Hello, world! 从 2~6 个词元变成 13 个字符词元(原文图 1.17),序列长度暴涨,而注意力计算量随长度平方增长(这个平方我们第 11 课细算)。更要命的是:单个字母不携带词义,模型得从零重建「lowest 和 highest 共享 est」这类本可白捡的知识。

按子词切:甜点区

playground → play + ground;unhappiness → un + happiness;modernization → modern + ization(原文图 1.14/1.18)。常用片段进词表当常客,生僻内容拆回零件——词表适中、序列不爆炸、新词可以用熟悉的零件拼出来。剩下的问题只有一个:learning 到底该切几刀?按词?切 learn+ing?还是更碎?这个问题不该由人来拍脑袋,该由数据回答。这就是 BPE。

二、BPE:让语料自己投票决定怎么切

一句话直觉 BPE 像拼乐高:开局全是 1×1 的小块;哪两块总是一起出现,就把它们焊成一块预制大块;反复焊接,常用的形状都有了预制件,新作品照样能拼——用大块,不够再用小块。

BPE(Byte Pair Encoding)1990 年代本是压缩算法,2016 年被搬进 NLP 分词(Sennrich et al.),GPT 系列沿用至今。算法只有四步,拿原文 1.4 的迷你语料走一遍——4 个单词,词频如下:

单词词频第 ② 步后(拆成字符)
old×7o · l · d · </w>
older×3o · l · d · e · r · </w>
finest×9f · i · n · e · s · t · </w>
lowest×4l · o · w · e · s · t · </w>

四步:① 每个词末尾贴上词尾哨兵 </w>;② 拆成单字符;③ 数相邻对——统计每种相邻符号对在语料里出现多少次(乘上词频);④ 把频次冠军焊成一个新词元,回到 ③,重复。

为什么要 </w>? est 在 lowest 尾部是「最……」(后缀,该独立成词元);同样的 est 出现在 esteem 开头则毫无后缀含义。不标词尾,BPE 就分不清这两种身份。哨兵一贴,「带 </w> 的 est」和「不带的全靠位置说话」泾渭分明——词形相同、语言角色不同的片段不再混淆。

第一轮数对子的结果(数字 = 出现次数,已乘词频):

相邻对次数来源
e·s13finest 的 9 + lowest 的 4
s·t13同上(并列)
t·</w>13同上(并列)
o·l / l·d10old 的 7 + older 的 3
f·i / i·n / n·e9仅 finest

冠军是 e·s(并列时取先扫到的)。第一刀:finest → f·i·n·es·t·</w>,lowest → l·o·w·es·t·</w>。重新数一轮,冠军变成 es·t(13)——第二刀合出 est;再数,est·</w> 又是冠军——第三刀合出带词尾的 est</w>。三刀过后,词表里已经长出了层级:

三、亲手合:演算器

下面是真实算法在跑(不是动画脚本)。点「合并」按钮,黄块是本轮频次冠军,右侧条形图是全部相邻对的得票。连点几下,看 est 长出来、old 整个成词、词表从 15 个零件长到几十个:

注意一个反直觉细节 合到第 4 刀时,冠军是 o·l(10 次),而不是 finest 家的 9 次对——finest 词频虽高,但它的零件对在别处帮不上忙。BPE 完全数频次说话,不懂语言学;合并顺序由全局票数决定,不由任何单词的"重要性"决定。

四、写下来跑:迷你 BPE,然后去切没见过的新词

刚才的演算器,二十行 Python 就是全部。先训练(做 5 次合并),再用学到的词表去切语料里没有的词——重点看 slowest 和 oldest 会被切成什么:

# ============================================================ # 【演示目标】亲手训练一个迷你 BPE,再拿它切"语料里根本没有的新词", # 验证第 2 课最重要的一句话: 词表 = 一份"合并规则清单",新词靠零件拼装。 # 【思路】代码分两段,和课文的四步一一对应: # A. 训练 = 反复做两件事: # ① 数一遍所有相邻词元的出现次数(乘词频,票数) # ② 把得票最高的一对"焊接"成一个新词元,并记下这条规则 # 课文四步里的 ①贴 ②拆字符 是开局准备,③数对子 ④合并 是循环体 # B. 切新词(encode) = 从单字符开局,按训练时的顺序"重放"规则清单 # 对照实验: 演算器里点的每一刀,应与这里每次循环打印的完全一致 # ============================================================ corpus = {"old": 7, "older": 3, "finest": 9, "lowest": 4} # 词 → 词频(第 2 课原语料) num_merges = 5 # 改我!试试 2 / 10 / 13 # ---- 开局准备(课文第①②步): 每个词拆成单字符 + 词尾哨兵 ---- # 用 tuple(不可变序列)当字典键,把"一个词当前的切法"作为整体来存 words = {tuple(list(w) + [""]): f for w, f in corpus.items()} merges = [] # 训练的真正产物:合并规则清单(不断加长) # ---- A. 训练循环:每轮 = 数票 + 焊接票王 ---- for step in range(num_merges): # ① 数相邻对: 对每个词的每一对相邻词元,累加词频 # zip(toks, toks[1:]) 把序列错开一位 → (t0,t1)(t1,t2)(t2,t3)… pairs = {} for toks, f in words.items(): for a, b in zip(toks, toks[1:]): pairs[(a, b)] = pairs.get((a, b), 0) + f if not pairs: break # 全部焊成整词了,没对可数 # ② 取票数冠军。max 并列时取先扫到的——与课文/演算器一致(es 先于 st) best = max(pairs, key=lambda p: pairs[p]) merges.append(best) # 记下这条规则,encode 时要用 # ③ 全语料执行焊接: 命中 best 的相邻对拼成一个词元,其余原样保留 nxt = {} for toks, f in words.items(): out, i = [], 0 while i < len(toks): if i < len(toks) - 1 and (toks[i], toks[i + 1]) == best: out.append(toks[i] + toks[i + 1]) # 焊接: 两个词元 → 一个 i += 2 # 吃掉两个位置 else: out.append(toks[i]) # 与 best 无关,照抄 i += 1 nxt[tuple(out)] = f words = nxt print(f"第 {step + 1:2d} 次合并: {best[0]} + {best[1]} (出现 {pairs[best]} 次)") # ---- 看训练成果: 4 个词现在的切法(对照课文第⑤步的层级词表) ---- print("\n合并后的切法:") for toks, f in sorted(words.items(), key=lambda kv: -kv[1]): print(f" {'/'.join(toks):<30} ×{f}") # ---- B. encode: 用学到的规则切(可能没见过的)新词 ---- # 关键认知: 切词用的信息 100% 来自 merges 这份规则清单——词表即规则。 # 所以"slowest 语料里没有"不碍事: 只要它的字节零件命中过任何规则,就能拼 def encode(word): toks = tuple(list(word) + [""]) # 和训练开局一样: 先拆成单字符 for a, b in merges: # 按训练时的先后顺序重放每条规则 out, i = [], 0 while i < len(toks): if i < len(toks) - 1 and (toks[i], toks[i + 1]) == (a, b): out.append(a + b); i += 2 # 规则命中 → 焊接 else: out.append(toks[i]); i += 1 # 没命中 → 原样保留 toks = tuple(out) return list(toks) print("\n语料里没有的新词,用学到的词表切:") for w in ["slowest", "oldest", "lowest"]: print(f" {w:<9} → {encode(w)}") # 对照正文 win 卡片读结果: oldest → old+est 整词命中; # slowest 的 l·o 方向对不上 o+l 规则,只能借到 est 这一个零件
慢着,slowest 和 oldest 语料里根本没有——它们是怎么被切开的? 训练只产出合并规则清单(如 e+s→es、es+t→est、ol+d→old)。切新词 = 从单字符开始,按训练时的顺序重放这些规则。于是:oldest 直接拼成 old + est</w>(整词+后缀,漂亮);slowest 和 lowest 都借到了尾巴 est</w>,但前几个字符只能保持单件——训练的第 4 条规则是 o+l,而它们体内是 l·o 顺序,方向对不上,规则就用不上(把 num_merges 改成 10,l+o 规则学到手,两词立刻变成 lo·w…)。新词不再失联,而是降级成零件拼装——这正是第 1 课地图里"分词"那格的全部魔法。
试试 13 次合并,欣赏一个算法的"愚蠢"瞬间 改成 13 会看到 older 被切成 olde·r·</w>——BPE 想合 old·e,因为那一刻它是剩下频次最高的对。它不懂"olde 不是英语词",它只数频次。真实词表里这类历史遗留切片不少见,纯属正常。

五、真实世界:GPT-2 的 50257 个词元

GPT-2 把这套流程做到底:在字节层面做 50,000 次合并,得到 256 个基础字节 + 5 万个合并词元 + 1 个特殊词元 <|endoftext|> = 50,257 个词元,每个词元一个编号:Building → 25954,<|endoftext|> → 50256(原文图 1.25)。因为基础层是字节而不是字母,任何字符串都能切——生词、错拼、表情、中文,大不了退回字节零件,永远不会 OOV。

想亲眼看看真实 GPT-2 怎么切你的名字或一句中文?打开 Tiktokenizer,选 gpt2,输入随便什么文字——注意观察:常用词一整块,生僻词碎成几块。

检索练习

本课主读材料

去读(约 12 分钟)

Vizuara《The Transformers》1.3–1.4 节(本课的原始出处)

读法:今天课内已把 1.3(三种刀法)和 1.4(BPE 四步)完整推过,包括原文没有的代码实现。去原文重点看图:1.15(词级三宗罪)、1.17(字符序列爆炸)、1.20–1.25(BPE 逐步配图),与你的演算器操作互相对照。

学有余力:Karpathy 的 minbpe(约 100 行的生产级 BPE,含字节级处理)——想看"工业版和我们玩具版的差距"就读它。

我是你的老师,别客气 比如"为什么并列时取先扫到的也行?"或者"字节级和字符级到底差在哪",随时贴回来问我。下一课我们把词元 ID 变成向量——第 1 课地图的第三格。