BPE 把一个 1994 年的数据压缩技巧搬进 NLP:用语料自身的共现频率归纳出一组有序 merge 规则,让词表在「字符太碎」与「整词太稀」之间自动找到落点。理解 BPE 的关键不是词表,而是 merge 的顺序。

从压缩算法到子词切分

Byte Pair Encoding 原本是 Philip Gage 提出的压缩方法:反复把出现最频繁的相邻字节对替换成一个未使用的新字节,用一张替换表换取更短的码流。Sennrich 等人在 2016 年的 NMT 工作中做了一次关键改写——不再追求还原压缩,而是保留那张替换表本身作为词表。新符号不回填,而是层层叠加成子词单元。

这次迁移解决的是翻译系统的 OOV 问题。固定词表遇到未登录词只能吐 <unk>,而 BPE 的底座是全部单字符,任何字符串都能被切开,同时高频词仍然保持整词粒度。这个「字符兜底 + 高频合并」的骨架,成为后续所有子词方案的共同起点,参见 Tokenizer 概述 对整条编解码管线的定位。

训练:merge 规则的贪心归纳

训练输入是语料折叠后的 word → frequency 字典,而非原始文本流;每个词初始切成字符序列,并附一个词尾标记(</w> 或 ▁)以区分 est 出现在词中还是词尾。

循环直到词表达到目标大小:

  1. 统计所有相邻 symbol pair 的加权频次(权重为所在词的词频)
  2. 取频次最高的 pair,频次相同时按确定性规则打破平局(首次出现顺序或字典序)
  3. 把该 pair 记入 merge 列表,在所有词中就地替换为合并后的新 symbol

产物是两样东西:词表(字符底座 + 所有合并产物)和有序 merge 列表。后者是编码阶段的唯一依据,顺序即优先级。

手推示例

语料 low×5, lower×2, newest×6, widest×3:

轮次最高频 pair频次新 symbol
1(e, s)9es
2(es, t)9est
3(est, </w>)9est</w>
4(l, o)7lo
5(lo, w)7low

第 3 轮把词尾标记吸收进 est</w>,于是「作为词尾后缀的 est」与「词内的 est」分化成两个 token——这正是词尾标记存在的意义。五轮之后 newest 的表示从 7 个字符降到 n e w est</w> 共 4 个 symbol。

参考实现

from collections import Counter
 
def train_bpe(word_freq: dict[str, int], num_merges: int):
    # 每个词切成字符元组,末尾追加词尾标记
    corpus = {tuple(w) + ("</w>",): f for w, f in word_freq.items()}
    merges = []
    for _ in range(num_merges):
        pairs = Counter()
        for symbols, freq in corpus.items():
            for pair in zip(symbols, symbols[1:]):
                pairs[pair] += freq          # 按词频加权,而非按出现次数
        if not pairs:
            break
        best = max(pairs, key=pairs.get)  # Counter 保插入序,平局取首次出现的 pair
        merges.append(best)
        corpus = {_apply(s, best): f for s, f in corpus.items()}
    return merges
 
def _apply(symbols: tuple, pair: tuple) -> tuple:
    out, i = [], 0
    while i < len(symbols):
        # 命中目标 pair 则合并并跳过两个位置,否则原样保留
        if i < len(symbols) - 1 and (symbols[i], symbols[i + 1]) == pair:
            out.append(symbols[i] + symbols[i + 1])
            i += 2
        else:
            out.append(symbols[i])
            i += 1
    return tuple(out)

编码:按 rank 重放,而非最长匹配

最常见的误解是「BPE 编码 = 在词表里做贪心最长匹配」。真正的 BPE 编码是重放训练时的 merge 顺序:把词切成字符,反复找出当前序列中 rank 最小(即训练中最早学到)的可用 pair 并合并,直到无 pair 可合。

def encode(word: str, merges: list[tuple]) -> list[str]:
    rank = {p: i for i, p in enumerate(merges)}
    symbols = list(word) + ["</w>"]
    while len(symbols) > 1:
        # 在所有相邻 pair 中选 rank 最小者——顺序决定结果
        cand = min(zip(symbols, symbols[1:]), key=lambda p: rank.get(p, float("inf")))
        if cand not in rank:
            break
        symbols = list(_apply(tuple(symbols), cand))
    return symbols

用上表的 merge 列表编码 lowest:(e,s)→es、(es,t)→est、(est,</w>)→est</w>、(l,o)→lo、(lo,w)→low,输出 low | est</w>。训练中未见的词也能切开,因为字符底座永远兜得住。

两种策略何时分叉:若词表同时含 ab、abc,而 (a,b) 的 rank 早于生成 abc 的那条规则,输入 abc 会先合出 ab,随后因缺少 (ab,c) 规则而停在 ab | c;最长匹配则直接输出 abc。最长匹配是 WordPiece 与 Unigram LM 中 WordPiece 的做法,二者不可混用——用错策略等于换了一个 tokenizer,embedding 查表全盘错位。

复杂度

阶段朴素实现工程实现
训练每轮全量统计 pair, 轮共 pair 位置倒排索引 + 最大堆,每次 merge 只更新受影响的词,摊还接近 ( 为唯一词数)
编码每步扫 个 pair 取最小 rank,最多 步,双向链表 + 优先队列降到 ;更实用的是 pre-tokenization 把 压到单词长度,再加结果缓存

GPT-2 的 bpe() 至今是 实现,靠正则预切分与 LRU cache 把实际开销掩盖掉;Tiktoken 则用 Rust 重写核心循环,常数级优化带来数量级吞吐提升。

边界与局限

  • 词尾标记方案决定行为差异:subword-nmt 用后缀 </w>,SentencePiece 用前缀 ▁ 并把空格当普通字符,可无损还原原文
  • pre-tokenization 依赖:经典 BPE 先按空白切词,这让它在中日韩等无空格语言上退化,需要正则或语言无关方案接管
  • 字符底座仍会 OOV:Unicode 罕见字符若未进入训练语料就无处可切,BBPE 字节级分词 改用 256 个字节作底座彻底消除这一漏洞
  • 纯频率驱动缺少概率解释:merge 是局部贪心,无全局似然目标,也无法为同一字符串给出多种切分的概率分布
  • 词表大小是超参:merge 轮数直接等于新增 token 数,取值权衡见 词表设计与裁剪