4、子词分词算法(BPE)
大家好,我是老张。这一章咱们聊聊 BPE——Byte Pair Encoding。说白了,它就是把词拆成更小的子词单元,让模型不用死记硬背每个单词。我当年第一次接触 BPE 是在做一个小语种翻译模型,跑 WordPiece 怎么调都不对劲,换成 BPE 之后,效果直接起飞。嗯,这玩意儿值得好好讲一下。
4.1 BPE 原理:从「字节对」到「子词」
BPE 一开始是数据压缩领域的算法,后来被谷歌拿来搞机器翻译,再后来被 、LLaMA 这些大模型用成了标配。它的核心思想就八个字:频次优先,逐步合并。
你想啊,一个英文词表动不动几十万个词,但是「playing」「played」「plays」其实都是 play + 时态。BPE 先看哪个相邻符号对出现的次数最多,比如 "pl" 出现了 1000 次,"ay" 出现了 800 次,那就先把 "pl" 合并成一个新符号。合完一轮,再继续统计新的相邻对。这样一步一步合并,直到词表大小达到你想要的规模。
我在项目中遇到过一种特殊情况——俄语形态特别丰富,字符级词表太大,单靠 BPE 合并出来的子词还是太少。后来我把预分词规则加进去,比如先按空白和标点切好,再跑 BPE,词表压缩比提高了将近 30%。
4.2 训练步骤:五步走
训练 BPE 就五步,我习惯用一个假例子走一遍,比干背概念管用。
- 初始化词表:先把所有训练文本切成字符。比如 "low low low low low low low low" —— 我举一个极端例子,文本里只有 "l" "o" "w" 和空格。初始词表就是 {l, o, w, _}。
- 统计相邻符号对:数一下每个相邻对出现的次数。比如 "l o" 出现 8 次(每个 low 里一次),"o w" 出现 8 次,"w _" 出现 7 次(最后一个词后面没空格)…… 实际上我们关注的是词内部的相邻对。
- 合并最高频对:假设 "l o" 和 "o w" 频率一样(都是 8 次),我们选第一个出现的 "l o",合并成 "lo"。然后文本里所有 "l o" 就变成了 "lo"。
- 更新词表和文本:把 "lo" 加入词表。现在文本变成了 "lo w lo w lo w ……"。
- 重复步骤 2~4:直到词表大小达到预设的 num_merges 或者所有相邻对都只剩 1 次。
你想想看,这个过程跟搭积木一样,每次只搭一个最流行的组合。合并次数就是词表增长的数量——初始词表大小(字符数)+ 合并次数 = 最终词表大小。
4.3 合并规则:边界和优先级
合并规则的细节,容易踩坑。我列个表帮你对比清楚。
| 规则项目 | 说明 | 典型坑点 |
|---|---|---|
| 相邻对定义 | 必须是在同一个词内相邻的子词,不能跨词(比如词尾空格通常不参与合并) | 如果预分词没处理好,空格当成普通字符,合并出来的 里可能带"_",导致解码乱串 |
| 合并顺序 | 严格按频率降序,频率相同按第一次出现的顺序 | 两个相邻对频率一样时,不同实现可能有差异,导致词表不同。最好统一 tokenizers 库的排序 |
| 合并后文本更新 | 每合并一次,就要全局重算相邻对频率 | 直接暴力重算很慢,tokenizers 库用了字典优化,但自己实现别偷懒 |
| 非频繁对处理 | 频率为 1 的对也可以合并,但通常合并到目标大小就停 | 我曾经为了凑词表大小,把频率 1 的对也合了,结果出现很多无意义子词,下游任务精度掉了 2% |
4.4 代码实现:用 tokenizers 库
自己写 BPE 训练器当然可以,但没必要重新造轮子。HuggingFace 的 tokenizers 库写得又快又好,底层是 Rust 写的。我一般这么用:
from tokenizers import Tokenizer, models, trainers, pre_tokenizers
# 1. 初始化一个 BPE 模型
tokenizer = Tokenizer(models.BPE())
# 2. 设置预分词器(按空格和标点切分)
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
# 3. 创建训练器,指定词表大小和特殊
trainer = trainers.BpeTrainer(
vocab_size=30000,
min_frequency=2,
special_tokens=["<unk>", "<s>", "</s>", "<pad>"]
)
# 4. 用文件或数组训练
# 假设我们有一个文本列表
texts = ["Hello, how are you?", "I am fine, thank you."]
tokenizer.train_from_iterator(texts, trainer)
# 5. 保存 / 加载
tokenizer.save("my_bpe_tokenizer.json")
跑完上面代码,你就拿到一个 30000 词表的 BPE 分词器。但你肯定会问:「min_frequency 设多少好?」
我的经验是:如果训练语料很大(比如 10GB+),min_frequency 可以设 5 甚至 10,能过滤掉很多噪声。如果语料只有几百 MB,设 2 就够了,否则合并次数不够。
tokenizer.get_vocab() 看看词表里有没有出现 [UNK] 以外的奇怪字符。如果看到 Ġ 这个符号,别慌——那是 ByteLevel 的转义空格,正常的。
4.5 核心知识结构图
下面这张 SVG 把 BPE 从数据到词表的流程画出来了,节点颜色对齐咱们课程一贯的风格,方便你对照理解。
4.6 避坑指南:我曾经踩过的雷
- 文本没有预处理干净:如果文本里混了 HTML 标签或者乱码,BPE 会傻傻地把这些符号也合并进去。我踩过一次,词表里多了 200 多个 "<br>" 变体。建议训练前用正则清洗一下。
- vocab_size 设得太大:词表越大,模型参数越多,但是子词更完整。我见过有人设 100k,结果 BPE 还没来得及合并完,训练就 OOM 了。一般 8k~32k 就能覆盖绝大多数语言,特殊领域 48k 撑死了。
- 特殊 没加进词表:如果你在训练时没把 <pad> 和 <unk> 加到 special_tokens 里,后面用的时候 BPE 会把它们当成普通子词切碎,解码出来都是乱码。一定要在 trainer 里指定。
- 预分词器不匹配:tokenizers 库有 ByteLevel、Whitespace、BertPreTokenizer 好几种。我习惯用 ByteLevel,因为兼容 BPE 的字节级 fallback(碰到任意 Unicode 都不会炸)。但如果你的任务全是英文,用 Whitespace 更简洁。
tokenizer.tokenize("不常见的词") 试试。如果返回的 数量大于 8 个,说明这个词拆得太碎了。可以考虑降低 min_frequency 或者提升 vocab_size,让常见子词更完整。