概述
BPE(Byte Pair Encoding)是一种基于统计频率的子词切分算法。
它从字符级别开始,不断将语料中出现频率最高的相邻符号对合并成一个新符号,逐步构建出一个包含字符、常见子词甚至完整单词的词表。
由于决策仅依赖于当前相邻对的频次,并且每次都贪心地选择频次最高的进行合并,因此BPE本质上是一种确定性的贪心算法。
数据准备与初始化
BPE 通常会在"预分词"后的单词序列上工作。比如我们已经有了按空格切分好的单词及它们的频次:
text
"old": 7, "older": 3, "finest": 9, "lowest": 4
第一步:添加词边界标记
在每个单词末尾加上一个特殊符号 ,用来标明单词结束,防止跨单词的字符对被错误合并。
text
"old</w>": 7
"older</w>": 3
"finest</w>": 9
"lowest</w>": 4
第二步:拆成字符序列
把每个单词拆成最小单位------字符,每个字符以及 都算作一个独立的 token。
初始词表就是所有出现过的字符加上 </w>。
比如 "old</w>" 会被表示为序列:o l d </w>
第三步:统计每个 token 的初始频次
我们把每个 token 在所有单词中的出现总次数算出来。
这里单词的出现次数会乘以单词本身的频率。比如单词 "old" 出现 7 次,其中 o 出现 1 次,l 出现 1 次,d 出现 1 次, 出现 1 次,因此这 4 个 token 各增加 7 次计数。
汇总后得到初始频次:
text
</w>: 7+3+9+4 = 23
o: 7+3+4 = 14
l: 7+3+4 = 14
d: 7+3 = 10
e: 3+9+4 = 16
r: 3 = 3
f: 9 = 9
i: 9 = 9
n: 9 = 9
s: 9+4 = 13
t: 9+4 = 13
w: 4 = 4
初始词表大小为 12。
词表解析
(o,l): 7+3 = 10 是怎么得出来的?
(o,l) 出现在两个单词开头,把它们的频次相加即可
相邻对频次 = Σ(单词频次 × 该有序对在这个单词的当前符号序列中出现的次数)
逐个单词检查 (o, l) 这个有序对:
| 单词 | 频次 | 当前符号序列 | 含(o,1)次数 | 贡献 |
|---|---|---|---|---|
old</w> |
7 | o, l, d, | 1 | 7 × 1 = 7 |
older</w> |
3 | o, l, d, e, r, | 1 | 3 × 1 = 3 |
finest</w> |
9 | f, i, n, e, s, t, | 0 | 9 × 0 = 0 |
lowest</w> |
4 | l, o, w, e, s, t, | 0 | 4 × 0 = 0 |
old 和 older 都恰好以 o 紧跟 l 开头,各出现 1 次,所以是 7 + 3 = 10。
(l,o) 和 (o,l) 是两个不同的对。
lowest</w> 的开头是 l, o------l 在前 o 在后,所以它对 (l,o) 贡献 4,对 (o,l) 贡献 0。
反过来,old / older 里的 o, l 也不会被算进 (l,o)。
BPE 统计的是有序相邻对,方向不能颠倒。你的汇总表里这两行是并列存在的:
text
(o,l): 7+3 = 10 ← old, older
(l,o): 4 ← lowest
统计相邻对的频次
在 BPE 的每一次迭代中,我们需要扫描所有单词的当前符号序列,统计相邻符号对的出现次数。
以初始态为例,每个单词被看成字符序列,我们列出所有相邻对并计频:
- "old" (频次 7):相邻对 (o,l)、(l,d)、(d,) 各出现 7 次。
- "older" (频次 3):相邻对 (o,l)、(l,d)、(d,e)、(e,r)、(r,) 各 3 次。
- "finest" (频次 9):相邻对 (f,i)、(i,n)、(n,e)、(e,s)、(s,t)、(t,) 各 9 次。
- "lowest" (频次 4):相邻对 (l,o)、(o,w)、(w,e)、(e,s)、(s,t)、(t,) 各 4 次。
汇总所有相邻对频次(仅列出非零):
text
(o,l): 7+3 = 10
(l,d): 7+3 = 10
(d,</w>): 7
(d,e): 3
(e,r): 3
(r,</w>): 3
(f,i): 9
(i,n): 9
(n,e): 9
(e,s): 9+4 = 13
(s,t): 9+4 = 13
(t,</w>): 9+4 = 13
(l,o): 4
(o,w): 4
(w,e): 4
可以看到,频率最高的相邻对是 (e,s)、(s,t) 和 (t,),均为 13 次。
通常算法会选择其中之一合并(若有多个并列最高,可按某种规则如字母序选择,这里与原文一致先合并 (e,s))。
贪心合并与迭代过程
迭代 1
合并:(e,s) → 新 token es
在所有单词序列中,将连续出现的 e 和 s 合并为一个符号 es,并更新序列:
- "finest": f i n es t
- "lowest": l o w es t
- 其他单词不变。
重新统计频次:根据新序列,重新计算每个 token 和相邻对的频次。注意合并后原来的 e 和 s 在 es 中不再独立存在,它们的频次会减少。
新序列下的 token 频次变化:
- es 在 finest 和 lowest 中各出现 1 次 → 9+4 = 13 次。
- e 仅剩 older 中的一个(频次 3);finest 和 lowest 中原本的 e 已被合并掉。
- s 原本只出现在这两个词中,现在完全被 es 替代,频次降为 0。
- 其他 token 频次不变。
更新后的 token 频次表(省略 0 频次):
text
</w>: 23
o: 14
l: 14
d: 10
e: 3 (来自 older 的 e)
r: 3
f: 9
i: 9
n: 9
t: 13
w: 4
es: 13 (新增)
此时词表大小仍为 12(去掉了 s,新增 es)。
新的相邻对频次(在更新后的序列上统计):
- "old": (o,l):7, (l,d):7, (d,):7
- "older": (o,l):3, (l,d):3, (d,e):3, (e,r):3, (r,):3
- "finest": (f,i):9, (i,n):9, (n,es):9, (es,t):9, (t,):9
- "lowest": (l,o):4, (o,w):4, (w,es):4, (es,t):4, (t,):4
高频相邻对:(es,t) 出现 9+4=13 次,(t,) 也是 13 次。选择合并 (es,t)。
合并的要点
合并 (e,s) 不是可选项,而是BPE 每一轮唯一要做的动作------而选它,是因为它在上一轮的相邻对频次表里得分最高(13 分)。
为什么要"合并":这是 BPE 唯一的学习动作
BPE 的训练目标很朴素:从一个很小的初始词表(这里是 12 个字符级 token)出发,每轮往词表里加 1 个新符号,一直加到预设的词表大小为止。
怎么加?不是凭直觉挑,而是按一条铁律:
每轮统计所有相邻符号对的频次,把频次最高的那一对粘成一个新 token。
选"最高频"的理由是贪心压缩:把出现 f 次的对合并成一个符号,整个语料的总 token 数就恰好减少 f。
所以每轮挑最高频对 = 每轮拿最大压缩收益。
我实测了这个语料的初始总 token 数:
text
old 4×7 + older 6×3 + finest 7×9 + lowest 7×4 = 28+18+63+28 = 137
合并频次 13 的 (e,s) 之后:
text
28 + 18 + 6×9 + 6×4 = 28+18+54+24 = 124 = 137 − 13 ✓
finest 从 7 个符号缩到 6 个,lowest 同样,一步省下 13 个 token。这就是"需要合并"的全部动机。
迭代 2
合并:(es,t) → 新 token est
更新序列:
- "finest": f i n est
- "lowest": l o w est
重新统计频次:
- est 频次 = 9+4 = 13
- es 和 t 的单独频次相应减少:es 完全被吸收,频次变为 0;t 也只剩 0(原本只在 finest 和 lowest 中有 t)。
- 其他 token 不变。
新 token 频次(省略 0):
text
</w>: 23
o: 14
l: 14
d: 10
e: 3
r: 3
f: 9
i: 9
n: 9
w: 4
est: 13
词表大小:11。
相邻对:
- 两个含 est 的词序列变为 (n,est):9,(est,):9 和 (w,est):4,(est,):4
- 所以 (est,) 总频次 = 9+4 = 13
最高频次对就是 (est,</w>)(13次)。
迭代 3
合并:(est,) → est
这个合并非常关键,因为它会把词尾的 est 和边界标记粘在一起,形成一个代表"以 est 结尾"的完整词单元。
更新序列:
- "finest": f i n est (注意这里 est 是一个 token)
- "lowest": l o w est
重新统计频次:
- est 频次 = 9+4 = 13
- 原来的 est 和 频次降低:est 降为 0, 从 23 减去 13 剩 10(来自 old 的 7 和 older 的 3)。
- 其他不变。
频次表:
text
</w>: 10
o: 14
l: 14
d: 10
e: 3
r: 3
f: 9
i: 9
n: 9
w: 4
est</w>: 13
词表大小仍是 11。
相邻对:
- "old": (o,l):7, (l,d):7, (d,):7
- "older": (o,l):3, (l,d):3, (d,e):3, (e,r):3, (r,):3
- "finest": (f,i):9, (i,n):9, (n,est):9
- "lowest": (l,o):4, (o,w):4, (w,est):4
此时最高频对是 (o,l):7+3=10 次。
迭代 4
合并:(o,l) → ol
序列更新:
- "old": ol d
- "older": ol d e r
- "lowest" 中的 l o 不会被合并,因为它是 (l,o) 而非 (o,l)。
频次更新:
- ol 获得 7+3 = 10 次。
- o 从 14 减到 4(只剩 "lowest" 中的 o),l 从 14 减到 4(同样只剩 "lowest" 中的 l)。
新 token 频次:
text
</w>: 10
ol: 10
d: 10
e: 3
r: 3
f: 9
i: 9
n: 9
w: 4
est</w>: 13
o: 4
l: 4
词表大小 12。
迭代 5
相邻对统计:
- "old": (ol,d):7, (d,):7
- "older": (ol,d):3, (d,e):3, (e,r):3, (r,):3
- "finest": (f,i):9, (i,n):9, (n,est):9
- "lowest": (l,o):4, (o,w):4, (w,est):4
最高频对:(ol,d) 出现 7+3=10 次。
合并:(ol,d) → old
序列:
- "old": old
- "older": old e r
频次更新:
- old 获得 10 次。
- ol 和 d 频次各减 10:ol 变为 0,d 变为 0(原本只在 old/older 中有 d)。
最终频次:
text
</w>: 10
e: 3
r: 3
f: 9
i: 9
n: 9
w: 4
est</w>: 13
o: 4
l: 4
old: 10
词表大小 11。
至此,常用词 old、词尾 est 等都已形成独立的 subword 单元。
我们可以选择停在这里,或者继续合并 (old,)、(l,o) 等。
解码与新词编码
解码极其简单:将 token 序列直接拼接即可。例如 "old", "", "est" → "old" + "" + "est" ?(这里需要小心)
实际上 token 本身已经包含 ,所以解码时遇到 就知道那是词边界。
例如序列 "old", "", "high", "est" 解码为 "old", "highest"。这保证了 est 只会出现在词尾。
对新词编码:将新词拆成字符,然后用学到的 BPE 合并规则(从最大 token 开始尝试匹配)逐步合并成已知的 subword 序列。如果某部分无法匹配,就用 代替。
为什么说 BPE 是"仅基于频率的贪心算法"?
仅基于频率:每一次选择要合并的相邻对,依据的唯一标准就是它在当前语料中的出现次数,完全不关心这个字符组合是否具有语言学意义。
贪心:每次都选择当前统计下频率最高的对进行合并,不会回溯,不会考虑这一合并对未来步骤的全局最优性。尽管是贪心,实际效果却很好,能够自然地捕捉到常见词根、前缀、后缀等有意义的 subword 单元。
迭代构建词表:从最细粒度的字符开始,每合并一次就向词表中加入一个新 token,同时更新整个语料的符号序列和频率分布,直到词表达到预定大小(如 32k 个 token)或相邻对最高频率为 1。
这种机制让 BPE 能很好地平衡词表大小与编码效率:高频词保持完整,低频词被分解为可共享的 subword,极大缓解了未登录词问题,也因此成为 GPT-2、RoBERTa 等主流预训练模型的核心分词算法。