词表装不下所有单词怎么办?BPE 的高频合并、字节回退与长度代价
从整词词表的未知词问题出发,手算 BPE 合并与编码顺序,解释字节级覆盖、词表—序列长度权衡,并用 Tokenizers 0.23.2 构建可复现流水线。
上一篇把 token 序列错开一位,构造了因果语言模型的 next-token loss。但“机器学习”究竟是 1 个 token、4 个汉字 token,还是 12 个 UTF-8 字节 token,并不是损失函数决定的。整词词表会把罕见词和新名字压成 <unk>;纯字符或纯字节虽然总能覆盖输入,又会把序列拉长。
字节对编码(Byte-Pair Encoding,BPE)在两者之间学习合并规则:高频相邻单元逐步合成较长 token,低频字符串仍由短单元拼出。本文只追踪三个问题:合并规则怎样学出、编码时为何必须按规则顺序应用,以及字节级初始字母表怎样用长度换开放词表覆盖。
01 整词与字符为什么都不理想?#
整词: [relearner] ─► <unk> 细节丢失
字符: r e l e a r n e r 永不未知,序列很长
BPE: re learn er 复用常见片段text设词表大小 、模型宽度 、序列长度 。Embedding 至少有 个参数;全注意力通常随 增长;输出 softmax 又要产生 [N,L,V]。分词器是在参数、计算、覆盖率和复用之间取舍,不存在孤立的“最佳词表大小”。
02 tokenizer 是流水线,不只是 BPE#
原始文本
│ normalizer:Unicode、大小写策略
▼
规范化文本
│ pre-tokenizer:空格、标点或字节边界
▼
预分片 + 原文 offsets
│ BPE model:按有序 merges 切成子词
▼
token strings ─► vocabulary ─► token ids [L]
│ post-processor:BOS/EOS
▼
input_ids / attention_mask / offsetstextnormalizer、pre-tokenizer、merge 顺序、特殊 token 及其 id、decoder 都是模型输入契约。
03 BPE 训练到底在做什么?#
把词拆成初始单元,并用 </w> 标记词尾:
"low" × 2 -> l o w </w>
"lower" × 1 -> l o w e r </w>text| 相邻对 | 频次 |
|---|---|
(l, o) | 3 |
(o, w) | 3 |
(w, </w>) | 2 |
(w, e) | 1 |
(e, r) | 1 |
选 (l,o) 后重写语料为 lo w ...,再统计;下一轮 (lo,w) 得到 low。经典 BPE 每轮贪心选择:
是预分片集合, 是频次, 是相邻对出现数。它不直接最小化语言模型 loss,也不保证全局最优。
04 编码为何必须遵守 merge rank?#
假设规则依次是 l+o→lo、lo+w→low、e+r→er:
[l,o,w,e,r,</w>]
└ rank 0 ─► [lo,w,e,r,</w>]
└ rank 1 ─► [low,e,r,</w>]
└ rank 2 ─► [low,er,</w>]text编码依据训练所得 rank,不会重新统计测试文本,也不是任意最长匹配。重叠规则若应用次序不同,结果就可能不同。
05 教学版训练器与输入输出#
from collections import Counter
def pair_counts(corpus):
counts = Counter()
for symbols, frequency in corpus.items():
for pair in zip(symbols, symbols[1:]):
counts[pair] += frequency
return counts
def merge_pair(corpus, chosen):
merged = {}
left, right = chosen
for symbols, frequency in corpus.items():
out, i = [], 0
while i < len(symbols):
if i + 1 < len(symbols) and symbols[i:i+2] == chosen:
out.append(left + right); i += 2
else:
out.append(symbols[i]); i += 1
merged[tuple(out)] = merged.get(tuple(out), 0) + frequency
return merged
def train_bpe(word_counts, num_merges):
corpus = {tuple(w) + ('</w>',): f for w, f in word_counts.items()}
merges = []
for _ in range(num_merges):
counts = pair_counts(corpus)
if not counts: break
chosen = min(counts, key=lambda p: (-counts[p], p))
merges.append(chosen)
corpus = merge_pair(corpus, chosen)
return merges, corpus
merges, corpus = train_bpe({'low': 2, 'lower': 1}, 3)
assert merges[:2] == [('l', 'o'), ('lo', 'w')]python输入是“片段到频次”,输出是有序 merges 与最终切分。固定同频 tie-break 才能复现。真实实现用优先队列增量更新计数,但应先用这个小版本核对语义。
06 编码器也要可检查#
def encode_symbols(symbols, merges):
ranks = {pair: rank for rank, pair in enumerate(merges)}
pieces = list(symbols)
while len(pieces) > 1:
candidates = [(ranks[(a,b)], i, a+b)
for i, (a,b) in enumerate(zip(pieces, pieces[1:]))
if (a,b) in ranks]
if not candidates: break
_, i, merged = min(candidates)
pieces[i:i+2] = [merged]
return piecespython测试训练中词、新词、空串、标点与非拉丁字符。若 normalizer 会小写或规范化,decode(encode(text)) 只能对规范化文本保证可逆。
07 字符 BPE 为什么仍可能 <unk>?#
训练没见过 Ω,字符级初始字母表便没有可分解起点。字节级 BPE(Byte-Level BPE)从 256 个字节开始,任何 UTF-8 文本都可表示:
"学" ─UTF-8─► [0xE5,0xAD,0xA6]
│ 高频时再逐步 merge
▼
1 个合并 token,或 3 个基础 byte tokentext它消除普通文本未知字符,却会让罕见脚本或乱码产生更长序列。英文字母常占 1 字节,常用汉字通常占 3 字节,“字节级”不等于“字符级”。
08 用 Tokenizers 0.23.2 落地#
官方当前稳定版文档为 0.23.2。BpeTrainer 的核心参数包括 vocab_size、min_frequency、special_tokens 与 initial_alphabet:
from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers
tokenizer = Tokenizer(models.BPE(unk_token='<unk>'))
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tokenizer.decoder = decoders.ByteLevel()
trainer = trainers.BpeTrainer(
vocab_size=32_000,
min_frequency=2,
initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
special_tokens=['<unk>', '<pad>', '<bos>', '<eos>'],
)
def batches():
yield ['第一批文本', 'the first batch']
yield ['第二批文本', 'identifier: zxq_42']
tokenizer.train_from_iterator(batches(), trainer=trainer)
encoding = tokenizer.encode('机器学习 zxq_42')
print(encoding.tokens) # list[str],长度 L
print(encoding.ids) # list[int],元素在 [0,V)
print(encoding.offsets) # 回指原文区间
tokenizer.save('tokenizer.json')python特殊 token 列表顺序影响 id。tokenizer.json 必须与模型 checkpoint 一同版本化;只记录 无法复现输入。
09 normalization、offsets 与长度预算#
Unicode 规范化会减少表面变体,也可能合并本应区分的标识符、破坏代码,或令字符标注错位。高亮和序列标注应使用 offsets 回指原文,不要用 token 字符串猜位置。
| 指标 | 太小词表 | 太大词表 |
|---|---|---|
| tokens / byte | 序列长 | 通常更短 |
| 稀有片段 | 共享短单元 | 许多词表行几乎不更新 |
| Embedding 参数 | 少 | 多 |
logits [N,L,V] | 小、 大 | 小、每位置分类贵 |
| 低资源语言 | 可能被切得很碎 | 取决于语料和词表配额 |
应按语言统计 tokens/byte 的 50、95、99 分位数,并在固定计算预算下比较验证 loss、吞吐与显存。不同 tokenizer 的每-token perplexity 分母不同,不能直接比较。
10 上线前验证与常见错误#
- 冻结覆盖空白、emoji、URL、数字、代码和多语言的 golden strings;
- 比较保存前后 tokens、ids、offsets 与 decode;
- 核对特殊 id 与模型配置;
- 扫描
<unk>率、长度尾部和被截断的文本侧; - 比较
tokenizer.json哈希,禁止服务端悄悄替换。
| 症状 | 常见原因 | 最短检查 |
|---|---|---|
| 线上 id 改变 | merge/normalizer 版本错配 | 比较 tokenizer 哈希 |
新文字大量 <unk> | 初始字母表不覆盖 | 打印码点或启用字节级 |
| 中文/代码异常长 | 语料偏科、预分片不当 | 分语言统计 tokens/byte |
| decode 空格错误 | ByteLevel decoder 不匹配 | 测 add_prefix_space |
| 标注位置漂移 | 用 token 文本猜原位 | 检查 offsets 及其单位 |
| loss 忽然“变好” | 更换 tokenizer 后按 token 比 | 改报 byte-normalized NLL |
11 失败场景与相近方法#
BPE 偏向高频表面片段,不保证 token 对应语素;语料失衡会让低资源语言承担更长上下文成本。重复日志和分隔符还会污染词表。
WordPiece 常用似然增益式打分;Unigram 从较大候选集出发删除片段并可保留多种切分;字符 tokenizer 词表小但序列长;词级 tokenizer 序列短却难处理开放词表。它们只产生离散 id,并未赋予连续语义。
12 今天真正需要记住什么?#
- BPE 反复合并高频相邻对,得到有顺序的 merge 规则。
- 编码按 rank 应用规则;vocab、merges 与规范化流水线缺一不可。
- 字节级字母表覆盖任意 UTF-8 输入,但罕见文本可能变长。
- tokenizer 改变 、、成本和 loss 分母,是模型版本的一部分。
13 思考题与小练习#
- 对
aaab×2、aaac×1手算前两轮 BPE;同频 tie-break 改变会怎样? - 找一个 emoji,比较 Python 字符长度与 UTF-8 字节数。
- 训练 三个 tokenizer,分语言比较 tokens/byte 的 50、95、99 分位数。
相关工作#
- Gage, A New Algorithm for Data Compression ↗,早期字节对压缩算法。
- Sennrich et al., Neural Machine Translation of Rare Words with Subword Units ↗,将 BPE 用于开放词表翻译。
- Wu et al., Google’s Neural Machine Translation System ↗,大规模 WordPiece 系统。
- Kudo, Subword Regularization ↗,用多种切分增强鲁棒性。
- Wang et al., Neural Machine Translation with Byte-Level Subwords ↗,字节级子词研究。
14 下一篇预告#
BPE 已把文本变成 [N,L] 整数 id,但 Transformer 不能直接在“编号 42”上计算语义。下一篇将拆解 token Embedding 的查表与梯度累加,并解释为什么输入 Embedding 和输出 LM Head 常绑定为同一张 [V,D] 权重矩阵。