07

BPE 算法——一步一步合并出词表

阅读约 11 分钟第 7/60 课

上一章节说到,子词分词是现代语言模型的标准方案。但子词分词需要一张词表——哪些子词应该被收录?「machine」应该是一个 token 还是拆成「mach」和「ine」?「学习」应该整体保留还是拆成「学」和「习」?

这些决定不是人工拍板的,而是由一个算法从大量文本中自动学出来的。这个算法叫 BPE——Byte Pair Encoding,字节对编码。

核心思路

BPE 的思路可以用一句话概括:从最小的片段开始,反复把出现频率最高的相邻片段合并成一个新片段,直到词表达到预设的大小为止。

让我们用一个具体的例子一步一步走一遍。

准备:从单个字符开始

假设我们的全部训练文本只有这么短的一句话(实际当然是几十 GB 的文本,但原理完全一样):

low low low low low lower lower newest newest newest newest newest newest widest widest widest

第一步,把所有单词拆成单个字符,并在每个词的末尾加一个特殊符号 _ 表示词尾:

l o w _           ×5("low" 出现了 5 次)
l o w e r _       ×2("lower" 出现了 2 次)
n e w e s t _     ×6("newest" 出现了 6 次)
w i d e s t _     ×3("widest" 出现了 3 次)

此时的词表是所有出现过的单个字符:

词表 = {l, o, w, e, r, n, s, t, i, d, _}

11 个 token。

第一次合并

现在统计所有相邻字符对的出现频率。扫一遍全部数据:

(e, s) 出现了 6+3 = 9 次(newest 的 es × 6,widest 的 es × 3)
(s, t) 出现了 6+3 = 9 次
(l, o) 出现了 5+2 = 7 次
(o, w) 出现了 5+2 = 7 次
(t, _) 出现了 6+3 = 9 次
(w, _) 出现了 5 次
(e, r) 出现了 2 次
……

频率最高的对有好几个并列第一(都是 9 次),BPE 选其中一个,比如 (e, s)。把所有的 e s 合并成一个新 token es:

l o w _           ×5
l o w e r _       ×2
n e w es t _      ×6
w i d es t _      ×3

词表 = {l, o, w, e, r, n, s, t, i, d, _, es}   ← 多了一个 es

第二次合并

重新统计相邻对的频率:

(es, t) 出现了 6+3 = 9 次
(t, _)  出现了 6+3 = 9 次
(l, o)  出现了 5+2 = 7 次
(o, w)  出现了 5+2 = 7 次
……

选 (es, t),合并成 est:

l o w _           ×5
l o w e r _       ×2
n e w est _       ×6
w i d est _       ×3

词表 = {l, o, w, e, r, n, s, t, i, d, _, es, est}

继续合并

第三次:(est, _) 频率最高(9 次),合并成 est_。

第四次:(l, o) 频率最高(7 次),合并成 lo。

第五次:(lo, w) 频率最高(7 次),合并成 low。

第六次:(low, _) 频率最高(5 次),合并成 low_——此时 “low” 这个词已经是一个完整的 token 了。

……以此类推。

你看到了什么

经过几轮合并之后:

  • “low” 已经变成了一个 token——因为它在训练数据中出现了很多次,所以它的组成部分被一步步合并,最终成为一个整体。
  • “est” 也变成了一个 token——作为一个常见的英文后缀,它在 “newest” 和 “widest” 中都出现了。
  • “newer” 可能需要两个 token——“new” + “er”,因为 “newer” 本身出现的次数不够多,没有被合并成一个整体。

越常见的字符组合越早被合并,越早成为一个 token。 这就是 BPE 的核心逻辑。

放大到真实规模

在实际训练 tokenizer 时,这个过程发生在 GB 级别的文本上。起始词表通常不是单个字符,而是 256 个字节值(0 到 255)——这意味着任何文件中的任何字节都能被表示,无论是什么语言、什么编码。

合并过程重复几万次。每一次合并都产生一个新的 token,词表大小增加 1。如果你从 256 个基础 token 开始,合并 65,280 次,就得到 65,536 个 token 的词表。

合并的结果取决于训练文本的组成。如果训练文本大部分是英文,那英文的常见词会更早被合并成完整 token,中文汉字可能需要多个 token 才能表示一个字。反过来,如果训练文本大部分是中文,中文的常见词组会更早被整体收录。

这就是为什么不同模型的 tokenizer 差异很大——因为它们的训练文本组成不同。

一个 Python 演示

下面用 Python 走一遍完整的 BPE 过程。代码比较长,但每一步都有注释,跟着读就能理解:

from collections import Counter

# 训练数据:每个词和它的出现频率
corpus = {
    tuple("l o w _".split()):     5,
    tuple("l o w e r _".split()): 2,
    tuple("n e w e s t _".split()): 6,
    tuple("w i d e s t _".split()): 3,
}

def count_pairs(corpus):
    """统计所有相邻 token 对的频率"""
    pairs = Counter()
    for tokens, freq in corpus.items():
        for i in range(len(tokens) - 1):
            pairs[(tokens[i], tokens[i+1])] += freq
    return pairs

def merge(corpus, pair):
    """把语料中所有出现的 pair 合并成一个新 token"""
    new_corpus = {}
    bigram = pair[0] + pair[1]          # 新 token = 两个旧 token 拼起来
    for tokens, freq in corpus.items():
        new_tokens = []
        i = 0
        while i < len(tokens):
            if i < len(tokens) - 1 and tokens[i] == pair[0] and tokens[i+1] == pair[1]:
                new_tokens.append(bigram) # 遇到这个对,合并
                i += 2                    # 跳过两个位置
            else:
                new_tokens.append(tokens[i])
                i += 1
        new_corpus[tuple(new_tokens)] = freq
    return new_corpus

# 运行 10 次合并
for step in range(10):
    pairs = count_pairs(corpus)
    if not pairs:
        break
    best_pair = max(pairs, key=pairs.get)
    print(f"第 {step+1} 次合并:{best_pair} → {best_pair[0]+best_pair[1]}(出现 {pairs[best_pair]} 次)")
    corpus = merge(corpus, best_pair)

# 看看最终的分词结果
print("\n最终分词:")
for tokens, freq in corpus.items():
    print(f"  {' '.join(tokens)}  ×{freq}")

运行这段代码,你会看到合并一步步进行,常见的组合逐渐变成一个 token。

BPE 的两个阶段

需要区分清楚:BPE 有两个阶段,做的事情不同。

训练阶段:在大量文本上运行上面描述的合并过程,确定合并规则和最终词表。这只做一次,产出是一张词表和一个合并规则列表。

使用阶段(编码):拿训练好的词表和合并规则去切分新的文本。对新文本先拆成最小的单位(字节),然后按照训练阶段确定的合并顺序,逐步合并,直到没有可以合并的对为止。

训练阶段产出的合并规则是有顺序的——第一条规则(最高频的对)优先应用,第二条其次,以此类推。这个顺序保证了同一段文本每次都会被切成完全相同的 token 序列。

Sophia 的 tokenizer 在模型训练之前就已经确定好了,整个训练过程中不会改变。它是模型和文本之间的固定翻译层。

下一章节来讨论一个重要的设计决策:词表应该多大?大词表和小词表各有什么利弊?为什么 Sophia 选择了 65,536 这个数字?