BPE 本质是一个贪心的字典压缩算法——和 gzip / LZ 系列"把高频字节序列替换成短码"压缩文件是同一个祖先(1994 年 Gage 的数据压缩算法)。区别只在优化目标:压缩软件让文件变小,BPE 让词表够用又不爆炸。Sennrich 等 2016 年把它从压缩界搬进了 NLP。
两个极端都不行。词级 vocab(每词一个 token):英语几十万词 + 拼写变体 + 新词,词表无限大,且遇到训练时没见过的词(OOV,out-of-vocabulary,未登录词)直接歇菜。字符级:词表只剩几十个字符,但一句话炸成几百个 token,而 attention 是 O(n²),序列一长就吃不消。BPE 取中间点:常见词整体成一个 token,罕见词拆成子词片段。
机制是贪心合并:① 初始词表 = 所有单字符;② 统计语料里相邻 token 对的出现频率;③ 把最高频的一对合并成新 token 加入词表;④ 重复 ②③ 直到词表达到目标大小(如 5 万)。训练产物就是一张有序的 merge 规则表,推理时按同样顺序套用:
from tokenizers import Tokenizer from tokenizers.models import BPE from tokenizers.trainers import BpeTrainer from tokenizers.pre_tokenizers import Whitespace # 1) 空 BPE,先按空格预切分(BPE 假设已分词) tok = Tokenizer(BPE(unk_token="[UNK]")) tok.pre_tokenizer = Whitespace() # 2) 训练:目标词表 5000,反复合并高频对 trainer = BpeTrainer(vocab_size=5000, special_tokens=["[UNK]"]) tok.train(["corpus.txt"], trainer) # 产物 = 有序 merge 规则表 # 3) 编码:把同一套 merge 规则套到新文本 out = tok.encode("tokenization") print(out.tokens) # 可能 → ['token','ization'],也可能拆得更碎
WordPiece 和 BPE 是"同一套贪心合并框架,换了打分函数"——好比两个数据库都用 B-tree,但一个按访问频率决定缓存什么,另一个按收益/成本比决定。BPE 看绝对频率,WordPiece 看互信息式的增益。来源是 Schuster & Nakajima 2012(日韩语音搜索),被 BERT (Devlin et al. 2018) 发扬光大。
BPE 合并"出现最多的对"有个毛病:两个各自都高频、只是碰巧常相邻的片段(如 "the" 后面跟各种词),会被错误地优先合并。WordPiece 改成合并"合并后最能提升语料似然"的对,打分公式:
score(a, b) = freq(ab) / ( freq(a) × freq(b) )
直觉:分子是这对一起出现的频率,分母是两者各自单独的频率。如果 a、b 只是各自高频却随机相邻,分母很大、得分低、不合并;只有当 a、b 强绑定(合体远比拆开常见)时得分才高。这正是统计学的点互信息(PMI)思想——衡量"两个东西一起出现,是否超过纯随机"。
推理时用最长匹配贪心切分,词内续接的子词用 ## 前缀标记(playing → play + ##ing),这样能无损还原原文:带 ## 的接前面、不带的前面补空格。
from transformers import BertTokenizer tok = BertTokenizer.from_pretrained("bert-base-uncased") # ## = "紧接前一个子词,中间无空格",用于无损还原 print(tok.tokenize("playing tokenization")) # 示意 → ['playing', 'token', '##ization'] # 高频词整体保留,罕见词被拆成一串 ##子词 print(tok.tokenize("antidisestablishment")) # 越罕见 → ## 碎片越多(实际切法取决于词表)
BPE / WordPiece 都预设你能先按空格切词——这个隐藏假设对中文 / 日文(本就没有空格分词)直接崩。SentencePiece 把整句话当成一串原始 Unicode 流处理,连空格也当普通字符(用 ▁ 表示)。类比:BPE 像"先按分隔符 split 再解析"的解析器,SentencePiece 像直接在原始字节流上跑的无 schema 解析器——不依赖任何语言的分词规则,因此能无损还原(detokenize 回原文一字不差)。
先厘清一个常见混淆:SentencePiece 是框架 / 工具(Google 开源),内部可跑 BPE 或 Unigram 两种算法。Unigram 是它的默认、也是最大贡献(Kudo 2018)。
Unigram 的方向和 BPE 正好相反:BPE 是自底向上合并(从字符往上拼),Unigram 是自顶向下裁剪——
好处:每个 token 自带概率,同一个词允许多种合法切分——这被用作 subword regularization(训练时随机采样不同切法做数据增强)。T5、LLaMA、Gemma 等都用 SentencePiece。
import sentencepiece as spm # 训练 unigram:直接吃原始文本,无需预分词 spm.SentencePieceTrainer.train( input="corpus.txt", model_prefix="m", vocab_size=8000, model_type="unigram") # 也可 "bpe" sp = spm.SentencePieceProcessor(model_file="m.model") print(sp.encode("机器学习 hello", out_type=str)) # 空格 → ▁,中英文统一处理 print(sp.decode(sp.encode("机器学习 hello"))) # == 原文(无损)
再聪明的子词算法也会撞上训练时没见过的字符(生僻字、emoji、新符号)。byte-level BPE 的解法像编程里"永远有 fallback 到原始字节":任何字符在 UTF-8 下最终都是 1–4 个字节,而字节只有 256 种——把这 256 个塞进基础词表,就永不 OOV。GPT-2 起就用这招。
代价是UTF-8 边界问题:一个中文字 = 3 个 UTF-8 字节,一个 emoji 可能 4 字节,而 token 边界可能切在一个字符的字节中间。两个反直觉后果:
Vocab 大小的权衡(核心 trade-off,纯机制非调参):
趋势是词表越做越大:Llama 2 = 3.2 万,Llama 3 = 12.8 万,GPT-4(cl100k)≈ 10 万,GPT-4o(o200k)≈ 20 万。主因是多语言效率——非英语常用 2–4 倍 token(俗称 "tokenization tax,分词税"),大词表能缓解。
import tiktoken enc = tiktoken.get_encoding("cl100k_base") # GPT-4 的词表 ids = enc.encode("你好🌍") print(len(ids), ids) # 中文/emoji 往往多个 token # 单个汉字在 UTF-8 下是 3 字节 print("你".encode("utf-8")) # b'\xe4\xbd\xa0' —— 3 字节 # 256 个字节 token 兜底,所以永不 OOV for t in ids: print(t, enc.decode_single_token_bytes(t))