4、BPE算法原理:Byte Pair Encoding核心思想、合并规则、迭代过程详解
说到子词切分,BPE(Byte Pair Encoding)是最经典、最实用的一种。我最早接触它是在做机器翻译的时候,那时候词典动不动就几十万词,OOV(未登录词)问题让人头大。BPE 的出现,算是真正让我看到了“用有限符号表示无限词汇”的可能。
说白了,BPE 的核心思路就是:从字符出发,逐步合并出现频率最高的相邻符号对。你想想看,英语里像“es”、“ing”、“tion”这种常见子词,其实就是在多次合并中自然形成的。BPE 不做任何语言学假设,它只靠统计,简单粗暴,却非常有效。
4.1 核心思想
BPE 最初是为数据压缩设计的(1994 年 Philip Gage 提出),后来被引入 NLP。它的目标是把一个词表压缩到固定大小。在 tokenization 场景里,我们倒过来用:从最小的字符集开始,通过不断合并,构建一个固定大小的子词词表。
- 初始词汇表:所有训练文本中出现的独立字符(包括空格、标点)
- 合并操作:统计每一对相邻符号的出现次数,合并最高频的那一对
- 终止条件:达到预设的词汇量大小(比如 32K、50K)
我个人的习惯是,先设定词汇量为 32K 或者 50K,然后观察在验证集上的压缩率。如果压缩率太低,说明词汇量可能太大了,产生很多稀疏的合并;如果压缩率太高,又可能过度分割。这个平衡点需要实验,但一般来说 32K 是个不错的起点。
4.2 合并规则
合并规则其实就一句话:每次只合并当前出现次数最多的相邻符号对。但这里有几个细节要讲清楚。
合并的粒度
合并是对“符号”而言的,符号可以是字符,也可以是已经合并产生的子词。合并之后,新符号成为一个整体,下次统计时不再拆开。
如何处理跨词边界?
我曾在项目里踩过一个坑——如果直接在所有文本上统计相邻对,那“word1末尾 + word2开头”这种跨词组合会被统计进去,结果学出一些奇怪的合并(比如 “ing “+“the”)。正确的做法是:在每个词内部统计相邻对,并且给每个词末尾加一个特殊标识,比如 `` 或者 `_`,避免跨词合并。
合并的频次计算
每次合并后,词表变了,相邻对也会变。比如原始文本 "ab ca bc",假设我们合并了 "bc" -> "X",那么需要重新统计所有包含 "b" 和 "c" 的相邻对。这个更新通常需要遍历整个语料库,但我们可以用动态维护的方法加速。
4.3 迭代过程详解
整个迭代过程可以用一个非常清晰的循环来描述。我用伪代码展示,方便你理解每一步做了什么。
# 伪代码:BPE 学习过程
# 0. 初始化
vocab = {所有独立字符 + 特殊标记 <UNK>, </w> 等}
corpus = 原始文本(已按空格分词,并每词后加 </w>)
# 1. 将每个词拆成字符序列
words = [list(word_chars) for word in corpus]
# 2. 循环合并
for step in range(merge_ops):
# 统计所有相邻符号对的出现次数
pair_counts = {}
for word in words:
for i in range(len(word)-1):
pair = (word[i], word[i+1])
pair_counts[pair] = pair_counts.get(pair, 0) + 1
# 找到最高频对
best_pair = max(pair_counts, key=pair_counts.get)
best_count = pair_counts[best_pair]
# 将 best_pair 合并成一个新符号
new_symbol = best_pair[0] + best_pair[1]
vocab.add(new_symbol)
# 在 words 中替换所有出现
new_words = []
for word in words:
new_word = []
i = 0
while i < len(word):
if i < len(word)-1 and (word[i], word[i+1]) == best_pair:
new_word.append(new_symbol)
i += 2
else:
new_word.append(word[i])
i += 1
new_words.append(new_word)
words = new_words
# 可选:输出过程日志
print(f"Step {step}: merge '{best_pair[0]}' + '{best_pair[1]}' -> '{new_symbol}' (count={best_count})")
这个过程看起来简单,但实际实现时有几个细节值得注意。
- 何时停止? 达到预设词汇量大小(merge_ops = target_vocab_size - initial_vocab_size)。我一般设 target=32K,初始字符集大概 100-200,所以需要合并约 31K 次。
- 合并顺序的选择:如果多个对出现次数相同,任意选一个即可。但为了可复现,最好固定一个排序(比如按符号字典序)。
- 词表大小控制:合并次数越多,词表越大,子词长度越长。我建议先跑一遍小规模实验,观察“次数分布”的肘部——如果某些合并的频次断崖式下降,说明再合并下去意义不大。
4.4 BPE 学习过程示意图
下面这张 SVG 图把 BPE 的迭代流程可视化出来了。你可以对照伪代码看,每个节点对应一个关键步骤。
4.5 实例:从字符到子词的演变
我用一个极简的例子帮你走一遍。假设语料只有两个词:
- "low" 出现 3 次
- "lower" 出现 1 次
每个词后面加 ``,初始词汇表:{l, o, w, e, r, } 共 6 个符号。
| 迭代步 | 最高频对 | 合并结果 | 新词汇表(增量) | 经过合并后的词表示 |
|---|---|---|---|---|
| 1 | ("l", "o") 出现 4 次 | lo | lo | low → lo w; lower → lo w e r |
| 2 | ("lo", "w") 出现 4 次 | low | low | low → low; lower → low e r |
| 3 | ("low", "") 出现 3 次 | low | low | low → low; lower → low e r |
| 4 | ("e", "r") 出现 1 次 | er | er | low → low; lower → low er |
| 5 | ("er", "") 出现 1 次 | er | er | low → low; lower → low er |
你看,经过 5 次合并,我们得到了一个包含 11 个符号的词表(初始 6 + 5 个新合并)。最终 "low" 被完整保留,"lower" 被分割为 "low" + "er"。这个例子虽然小,但画出了 BPE 的本质:高频词保留完整,低频词被拆成常见子词。
我这里插一句——实际项目中,你可能会发现某些低频词被拆得非常碎,比如 "misunderstanding" 可能拆成 "mis" + "under" + "stand" + "ing" 这种有语义的子串,这很好。但也有可能拆成 "m" + "is" + "understa" + "nding" 这种看起来不合理的组合。为什么?因为 BPE 只认频次,不认语义。不过别担心,模型在训练中会学到这些组合的向量表示,效果通常不错。
4.6 BPE 的优缺点(我个人的体会)
- 完全数据驱动,不需要语言学知识
- 词表大小可控,适合下游模型
- 能平衡词级和字符级切分
- 实现简单,OOV 问题几乎消失
- 合并规则是贪心的,不一定全局最优
- 低频词的切分可能不稳定(更换语料库后合并结果变化很大)
- 对跨语言场景,BPE 倾向于按字符形态合并,可能丢失语言特定的形态边界
有一次我在做中英文混合的翻译任务,直接用 BPE 把中文也按字符切分,结果模型学得很差。后来我换成先对中文用 unigram 切词、再对英文用 BPE 的混合方案,效果就上来了。所以——BPE 不是万能的,要根据语言特性选择。