BPE 子词分词
按词分太多、按字又没意义,能不能找个不大不小的中间粒度?
折中方案:子词
按词分词颗粒太粗(词表爆炸、未登录词多),按字符分词颗粒太细(序列冗长、缺乏语义)。 理想的粒度应该夹在中间,这就是 子词(subword) 。
子词的核心想法是:
- 常见的词整体保留 为一个 token;
- 罕见的词拆成若干有意义的片段 。例如英文单词 tokenization 可以拆成 token + ization 。 前一半是常见词根,后一半是常见后缀,都是反复出现、值得单独成 token 的片段。
这样一来,词表大小、序列长度、语义保留三者就取得了平衡: 词表不必为每个完整词都留位置,序列也不会碎到逐字符, 而且 由于罕见词总能拆成更小的已知片段,OOV 问题被大大缓解 。 剩下的问题是:片段该怎么定?凭什么决定「token」「ization」是好的切法? 这正是 BPE 要回答的。
BPE 如何构建词表
BPE(Byte Pair Encoding,字节对编码) 用一种朴素而有效的策略, 从语料中逐步合并出子词词表。它的构建过程如下:
- 设定一个 目标词表大小 ;
- 在每个单词末尾加一个结束标记 </w> (用来标明词的边界),并统计每个词的出现频率;
- 把所有单词 拆成单个字符 ,这些字符构成 初始词表 ;
- 反复迭代,在当前语料里找出 出现频次最高的相邻 token 对 (一对相邻 token 的频次,等于 所有含这一对的词的词频之和 ), 把它们 合并成一个新 token 加入词表,并在语料中把所有这一对都替换掉;
- 重复第 4 步,直到词表达到目标大小 ,或没有可合并的对为止。
逐轮演示合并过程
用一个极简的示例语料演示这个过程。假设语料里只有四个词,括号内是它们的词频: low (5) 、 lower (2) 、 newest (6) 、 widest (3) 。 初始时每个词都被拆成字符,例如 newest 是 n e w e s t </w> 。 随后每一轮合并掉当前频次最高的相邻对。频次按前面的定义计算: 比如「e s」在 newest 和 widest 里各相邻出现一次,它的频次就是这两个词的词频之和 。若多对频次并列最高, 则按从前往后扫描语料的顺序,取最先遇到的那一对:
随着轮次推进, es → est → est</w> 这样的常见后缀被逐步合并成一个 token; 高频整词(如 low )也会被合并成完整片段。 这些就是 BPE 学到的子词。
下面用同一份示例语料亲手跑一遍这个过程:每点一次「下一轮合并」, 会先高亮当前频次最高的相邻符号对(并列时取最先遇到的那一对), 再把它们在所有词里合并成一个新符号,同时把这条合并规则记录下来。
- + →
动手:实现一个 mini-BPE
把上面的构建流程写成可运行的代码。整个算法只需要两个函数: 一个统计相邻符号对的频次,另一个执行合并,再用一个循环把「统计 → 找最高频 → 合并」重复若干轮。语料仍是前面那四个词, 终端里打出的每一轮结果都可以和上面的演示逐轮对照。
用 BPE 词表编码与解码
训练得到词表的同时,也得到了一份 按先后顺序排列的合并规则 。 编码一个新词时,做法不是去「凑最长的子词」,而是 重走一遍合并 :
- 先把这个词拆成单个字符;
- 按训练时记录的合并规则,依次应用 ,哪条规则先学到的就先合并, 能合就合;
- 直到没有任何一条规则还能再合并为止,剩下的片段就是这个词的 token。
走一遍:给新词「lowest」编码
lowest 没有在前面的示例语料里出现过, 正适合用来检验编码过程。在「BPE 如何构建词表」一节的演示里跑满 5 轮, 会依次学到 5 条合并规则:e + s、es + t、est + </w>、l + o、lo + w。 按这个先后顺序把它们应用到 lowest 上:
到这里,包括演示中第 5 轮之后学到的规则在内,再没有任何规则能在 low est</w> 里找到可合并的相邻对,编码结束。 lowest 被切成 low + est</w> 两个 token, 一个训练时从未整词出现过的词,由两个学到的子词完整覆盖。
BPE 的编码 严格复用训练时的合并顺序 , 而不是另搞一套「按长度贪心匹配最长子词」的规则,后者是别的分词方法的做法, 和 BPE 不是一回事。
解码 则简单得多:把输出的子词依次拼接起来, 遇到结束标记 </w> 就表示一个词到此结束。 拼接后把每个 </w> 还原成词与词之间的分隔(对英文就是空格), 就还原出了原始文本。
BBPE:把最后那个漏洞也补上
要彻底消除「没见过的字符」这个漏洞, BBPE(Byte-level BPE,字节级 BPE) 换了一个更底层的起点。它的出发点是一个事实: 任何文本在计算机里,最终都是一串字节 , 通过 UTF-8 编码,每个字符都被表示成一个或多个取值在 到 之间的字节。
于是 BBPE 不再以「字符」作为初始词表,而是以 这 256 个可能的字节 作为初始词表,再在字节序列上跑完全相同的 BPE 合并流程。这样一来, 任何字符都能由若干字节拼出来,「没见过的字节」根本不存在 , 从根本上消除了 OOV。这一点对中文、日文等字符极多的语言尤其有用, 它们不必把成千上万的汉字都塞进初始词表,照样能编码任意文本。
这里只需理解 BBPE 的动机,用字节作通用基底来根除 OOV,实现细节不展开。
从算法到工具
现在你已经理解了子词分词的原理和 BPE 的来龙去脉。 但在真实项目里,没有人会每次都手写 BPE,大模型都配有训练好、可直接调用的 分词器。它们长什么样、怎么用、对中文和英文的切分有什么不同,是接下来要动手体验的。