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 是个不错的起点。

关键:BPE 不保证语义边界。 它只是频率驱动的贪心合并。所以你会看到像“un”+“able”被合并成“unable”,但也可能出现“the”+“re”这种不太语义的合并。没关系——下游模型会自己学到这些组合的表示。

4.2 合并规则

合并规则其实就一句话:每次只合并当前出现次数最多的相邻符号对。但这里有几个细节要讲清楚。

合并的粒度

合并是对“符号”而言的,符号可以是字符,也可以是已经合并产生的子词。合并之后,新符号成为一个整体,下次统计时不再拆开。

如何处理跨词边界?

我曾在项目里踩过一个坑——如果直接在所有文本上统计相邻对,那“word1末尾 + word2开头”这种跨词组合会被统计进去,结果学出一些奇怪的合并(比如 “ing “+“the”)。正确的做法是:在每个词内部统计相邻对,并且给每个词末尾加一个特殊标识,比如 `` 或者 `_`,避免跨词合并。

避坑: 我曾经手写 BPE 时忘了处理词边界,结果模型学到了一个叫 “isa” 的合并,推理时完全乱掉。记得给每个词后加上边界标记,并且这个标记永远不参与合并(也不包含在最终词表里),只用来限制合并范围。

合并的频次计算

每次合并后,词表变了,相邻对也会变。比如原始文本 "ab ca bc",假设我们合并了 "bc" -> "X",那么需要重新统计所有包含 "b" 和 "c" 的相邻对。这个更新通常需要遍历整个语料库,但我们可以用动态维护的方法加速。

性能建议: 如果语料很大(比如几百GB),逐轮遍历会非常慢。我推荐用“优先队列 + 延迟更新”的方法:维护一个符号对频次的大顶堆,每次合并后只更新受影响的相邻对,把旧对标记失效,插入新对。这样单次合并的复杂度可以降到 O(log N)。

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 的迭代流程可视化出来了。你可以对照伪代码看,每个节点对应一个关键步骤。

BPE 迭代流程 初始字符集 所有独立字符 + </w> 统计相邻对频次 遍历每个词的相邻字符对 选择最高频对 如 ("a","b") -> 1234次 合并 & 更新词表 生成新符号 "ab" 达到预设词汇量? vocab_size >= target? 输出 BPE 词表 用于 Tokenization 否 (继续合并) 注意事项 • 每次合并只影响局部相邻对 • 词边界符号 </w> 不参与合并

4.5 实例:从字符到子词的演变

我用一个极简的例子帮你走一遍。假设语料只有两个词:

  • "low" 出现 3 次
  • "lower" 出现 1 次

每个词后面加 ``,初始词汇表:{l, o, w, e, r, } 共 6 个符号。

迭代步最高频对合并结果新词汇表(增量)经过合并后的词表示
1("l", "o") 出现 4 次lololow → lo w; lower → lo w e r
2("lo", "w") 出现 4 次lowlowlow → low; lower → low e r
3("low", "") 出现 3 次lowlowlow → low; lower → low e r
4("e", "r") 出现 1 次ererlow → low; lower → low er
5("er", "") 出现 1 次ererlow → 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 不是万能的,要根据语言特性选择。