Transformers 分词算法深入解析:BPE、WordPiece、Unigram 与 SentencePiece 原理与实战
本文依据仓库中的 分词器概述文档(西班牙语版《Descripción general de los tokenizadores》)整理而成,并结合作者所在仓库的源码实现与测试用例进行补充。核心内容覆盖 🤗 Transformers 中四种主流子词分词算法——Byte-Pair Encoding(BPE)、WordPiece、Unigram 与 SentencePiece——的完整原理、训练流程、优缺点,以及它们在各预训练模型中的实际对应关系。读完本文,你将能理解"为什么每个模型都有自己专属的分词器",掌握
BertTokenizer、GPT2Tokenizer、XLNetTokenizer等类背后的分词机制,并知道如何在仓库源码中验证这些算法的真实实现。
引言:分词到底难在哪里
分词(tokenización)是把文本切分成更小单元的过程。在 预处理教程 中我们已经看到:分词后的单词或子词会通过一张查找表(词汇表)转换为索引或 id。把"词/子词"映射成 id 本身很简单,真正的难点在于如何把一段文本切分成合适的词或子词。
以句子 "Don't you love 🤗 Transformers? We sure do." 为例,最简单的做法是按空格切分:
["Don't", "you", "love", "🤗", "Transformers?", "We", "sure", "do."]
这是一个合理的起步,但仔细观察 "Transformers?" 和 "do." 会发现:标点符号被粘连在单词上。这会让模型被迫为"同一个单词 + 每一种可能的标点"都学习一套独立的表示,词汇表规模会爆炸式增长。考虑标点后,分词结果变为:
["Don", "'", "t", "you", "love", "🤗", "Transformers", "?", "We", "sure", "do", "."]
好一些了,但 "Don't" 的处理仍不理想——它本意是 "do not",更合理的切分应该是 ["Do", "n't"]。到这里事情开始变得复杂,这正是每个模型拥有自己专属分词器的根本原因:对同一段文本应用不同的切分规则,会得到不同的 token 序列,而一个预训练模型只有在输入与其训练数据采用相同分词规则切分时,才能正确工作。
除了自己实现规则,业界还有两个经典的基于规则的分词器:spaCy 与 Moses。将它们应用于上面的句子,会得到类似:
["Do", "n't", "you", "love", "🤗", "Transformers", "?", "We", "sure", "do", "."]
可见其中综合运用了空格切分、标点切分和规则切分。这三者都属于词级分词(word-level tokenization)——把句子切成单词。它虽然最直观,但在海量语料上会带来一个致命问题:词汇表过大。例如 Transformer XL 使用空格+标点分词,词汇表高达 267,735 个词条。
过大的词汇表迫使模型在输入输出层配备巨大的 embedding 矩阵,同时推高内存复杂度与时间复杂度。一般而言,Transformer 类模型的词汇表规模很少超过 50,000,单语预训练模型尤其如此。
既然词级分词不理想,那干脆按字符切分呢?字符级分词实现极其简单,能大幅降低内存与时间开销,但它会让模型难以学到有意义的输入表示——例如为字母 "t" 学习一个独立于上下文的表示,远比为单词 "today" 学习要困难得多,通常伴随明显的性能损失。因此,Transformer 模型采用了一种介于词级与字符级之间的折中方案——子词分词(tokenización de subpalabras)。
子词分词:两全其美的折中方案
子词分词算法的核心原则是:高频词保持完整、不做切分;罕见词则被拆解为有意义的子词。例如 "annoyingly" 属于低频词,可以被拆成 "annoying" 和 "ly";而 "annoying" 与 "ly" 作为独立子词出现频率更高,且组合后仍完整保留了 "annoyingly" 的含义。这一特性对**黏着语(agglutinative languages)**尤其有用——例如土耳其语可以通过拼接子词构造(几乎)任意长的复杂单词。
子词分词让模型在保持合理词汇表规模的同时,仍能学到有意义的上下文无关表示;更重要的是,它使模型能够处理从未见过的生词——通过把生词拆解为已知子词。来看仓库中 BertTokenizer 的实际表现:
>>> from transformers import BertTokenizer
>>> tokenizer = BertTokenizer.from_pretrained("google-bert/bert-base-uncased")
>>> tokenizer.tokenize("I have a new GPU!")
["i", "have", "a", "new", "gp", "##u", "!"]
由于该模型是 uncased(不区分大小写)版本,句子先被转为小写。可以看到 ["i", "have", "a", "new"] 都在词汇表中,而 "gpu" 不在;于是分词器把它拆成已知子词 ["gp" 和 "##u"]。这里的 "##" 前缀表示该 token 应紧贴前一个 token 拼接(不带空格),这正是 WordPiece 解码(逆分词)时还原文本的关键标记。
再看 XLNet 的分词器对同一例句的处理:
>>> from transformers import XLNetTokenizer
>>> tokenizer = XLNetTokenizer.from_pretrained("xlnet/xlnet-base-cased")
>>> tokenizer.tokenize("Don't you love 🤗 Transformers? We sure do.")
["▁Don", "'", "t", "▁you", "▁love", "▁", "🤗", "▁", "Transform", "ers", "?", "▁We", "▁sure", "▁do", "."]
这里的 "▁" 符号我们会在 SentencePiece 一节解释。可以看到罕见词 "Transformers" 被拆解为更高频的子词 "Transform" 与 "ers"。
接下来的内容将逐个剖析这些子词算法的内部机制。需要注意的是,所有这些算法都依赖某种形式的训练过程,通常是在对应模型将要训练的语料上进行。
Byte-Pair Encoding(BPE)
BPE 由 Neural Machine Translation of Rare Words with Subword Units(Sennrich 等,2015) 提出,是 🤗 Transformers 中使用最广的子词分词算法之一,GPT-2、RoBERTa、XLM、FlauBERT、GPT 等模型均基于它(更现代的 Llama、Gemma、Qwen2 系列同样使用 BPE 或其字节级变体)。
BPE 的工作依赖一个**预分词器(pre-tokenizer)**先把训练数据切成单词:
- 最简单的预分词就是按空格切分,例如 GPT-2 与 [RoBERTa];
- 更进阶的预分词包含基于规则的分词,例如 [XLM] 与 [FlauBERT] 使用 Moses(对多数语言),[GPT] 则使用 spaCy 与 ftfy。
预分词完成后,算法得到唯一词集合以及每个词在训练语料中的出现频次,随后进入 BPE 的核心训练流程:
- 建立基础词汇表:由唯一词集合中出现过的全部字符构成;
- 统计相邻符号对的共现频次,选择出现频率最高的符号对合并为一个新符号,加入词汇表;
- 重复步骤 2,直到词汇表达到目标词汇大小。
目标词汇大小是一个需要在训练分词器之前确定的超参数,其含义为"基础词汇数 + 合并规则数"。
用一个完整的推导示例说明(该示例完整保留自 分词器概述文档)。假设预分词后得到如下带频次的词集合:
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
则基础词汇表为 ["b", "g", "h", "n", "p", "s", "u"],所有词被拆解为基础符号:
("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)
接下来统计每个相邻符号对的频次。"h" 后跟 "u" 出现 10 + 5 = 15 次;但最频繁的符号对是 "u" 后跟 "g",总计出现 10 + 5 + 5 = 20 次。于是第一条合并规则把 "u"+"g" 合并为 "ug",加入词汇表,词集合更新为:
("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)
下一组最常见的符号对是 "u" 后跟 "n"(共 16 次),合并为 "un";再下一组是 "h" 后跟 "ug"(共 15 次),合并为 "hug"。此时词汇表为 ["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"],词集合变为:
("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)
假设训练在此停止,那么学到的合并规则将用于切分新词(前提是新词不包含基础词汇表之外的符号)。例如 "bug" 会被切分为 ["b", "ug"];而 "mug" 会被切分为 ["<unk>", "ug"],因为符号 "m" 不在基础词汇表中。实践中训练语料通常至少包含每个字母的一次出现,所以单字母一般不会落到 <unk>;但特殊字符(如 emoji)很可能触发 <unk>。
如上所述,词汇大小(= 基础词汇数 + 合并数)是必须预先选定的超参数。例如 GPT 的词汇表大小为 40,478,即 478 个基础字符 + 40,000 次合并后停止训练。
Byte-level BPE:以字节为基础词汇
如果把所有 Unicode 字符都纳入基础词汇表,词汇规模会非常庞大。GPT-2 采用了一个巧妙的技巧:用 256 个字节值作为基础词汇表,既把基础词汇牢牢限定在 256,又确保任何字符都能被覆盖。再配合若干处理标点的补充规则,GPT-2 的分词器可以在不依赖 <unk> 符号的情况下切分任意文本。GPT-2 的词汇表大小为 50,257,构成如下:
- 256 个字节基础 token;
- 1 个文本结束特殊 token;
- 50,000 个通过合并规则学到的符号。
在仓库源码中,GPT2Tokenizer 的实现 明确标注为 "Based on byte-level Byte-Pair-Encoding",其 VOCAB_FILES_NAMES 声明了 vocab.json 与 merges.txt 两个文件(merges.txt 正是合并规则的存储载体),并提供了 add_prefix_space 参数——因为该分词器把空格也当作 token 的一部分来处理(这一点与 SentencePiece 有相似之处),文档注释中展示了 "Hello world" 与 " Hello world" 会得到不同 input_ids([15496, 995] vs [18435, 995])的现象。
WordPiece
WordPiece 是 BERT、DistilBERT、Electra 等 BERT 家族模型使用的子词分词算法,最早由 Japanese and Korean Voice Search(Schuster 等,2012) 描述。它与 BPE 非常相似:同样先初始化词汇表(包含训练数据中出现的每个字符),再逐步学习固定数量的合并规则。关键区别在于合并对象的选择标准:
- BPE 选择出现频次最高的符号对;
- WordPiece 选择能使训练数据概率最大化的符号对。
更精确地说,最大化训练数据概率等价于寻找这样的符号对:其组合符号的概率除以两个独立符号概率的乘积,在所有候选对中最大。即对符号 "u" 与 "g",只有当 "ug" 的概率除以 "u" 与 "g" 的概率之积大于任何其他符号对时,它们才会被合并。直观理解:WordPiece 在评估"合并两个符号会损失什么",以确保这次合并"值得"——两个共现频率远超随机预期的符号会被优先合并。
在仓库源码中,BertTokenizer 的实现 直接引用了 tokenizers.models.WordPiece 作为底层模型,并配置了:
normalizer = normalizers.BertNormalizer(...),负责清洗文本、处理中文、剥离重音与转小写(由do_lower_case、tokenize_chinese_chars、strip_accents控制);pre_tokenizer = pre_tokenizers.BertPreTokenizer(),负责预切分;decoder = decoders.WordPiece(prefix="##"),正是前文提到的"##"前缀在这里被定义。
源码还定义了 BERT 家族的标准特殊 token 及其默认值:unk_token="[UNK]"、sep_token="[SEP]"、pad_token="[PAD]"、cls_token="[CLS]"、mask_token="[MASK]"。
仓库中的测试用例进一步印证了 "##" 的行为,tests/models/bert/test_tokenization_bert_legacy.py 中:
self.assertListEqual(tokenizer.tokenize("unwanted , running"), ["un", "##want", "##ed", ",", "runn", "##ing"])
self.assertListEqual(tokenizer.tokenize("unwanted, running"), ["[UNK]", "runn", "##ing"])
self.assertEqual(tokenizer.convert_tokens_to_string(["un", "##want", "##ed"]), "unwanted")
注意第二个用例:当 "unwanted," 与逗号之间没有空格时,"unwanted," 整体不在词汇表中,整词退化为 [UNK]——这正是"必须用与预训练一致的分词规则"的生动例证。而 convert_tokens_to_string 则展示了 "##" token 如何被拼接还原为原词 "unwanted"。
Unigram
Unigram 由 Subword Regularization(Kudo,2018) 提出,与 BPE、WordPiece 的"自底向上合并"路径相反:它先以大量候选符号初始化词汇表,再逐步裁剪,最终得到更小的词汇表。初始词汇表可以包含所有预分词得到的词以及最常见子串。Unigram 并不直接单独用于任何 Transformer 模型,而是与 SentencePiece 配合使用。
训练过程如下:
- 在每个训练步骤,基于当前词汇表和一个 unigram 语言模型,对训练数据定义一个损失(通常为对数似然);
- 对词汇表中的每个符号,计算"若将该符号从词汇表删除,整体损失会增加多少";
- 删除损失增量最小的
p百分比符号(p通常取 10% 或 20%),即那些对整体损失影响最小的符号; - 重复上述过程,直到词汇表达到目标大小。基础字符永远被保留,从而保证任何单词都能被切分。
由于 Unigram 不依赖合并规则(与 BPE、WordPiece 相反),它在训练后切分新文本时有多种可行路径。假设一个训练好的 Unigram 分词器词汇表为:
["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"],
那么 "hugs" 既可以被切为 ["hug", "s"],也可以切为 ["h", "ug", "s"] 或 ["h", "u", "g", "s"]。选哪一条?Unigram 在训练时把每个 token 在语料中的概率与词汇表一同保存,因此训练后可以计算每种切分方式的概率,实践中直接选择概率最高的切分;同时它也支持按概率对候选切分进行采样(这正是"子词正则化"的训练技巧)。
这些概率由训练损失定义:假设训练数据由词 \(x_{1}, \dots, x_{N}\) 组成,词 \(x_{i}\) 的全部可能切分集合记为 \(S(x_{i})\),则整体损失为:
SentencePiece:语言无关的子词分词
到目前为止介绍的所有算法都有一个共同假设:输入文本以空格分隔单词。但并非所有语言都用空格分词(例如中文、日文、泰文)。一种思路是使用语言专属的预分词器(如 [XLM] 对中文、日文、泰文各配一个预分词器),而 SentencePiece(Kudo 等,2018) 提供了更通用的解法:把输入文本当作原始字符/字节流处理,将空格本身也纳入字符集合,然后在此基础上应用 BPE 或 Unigram 构建词汇表。
这就是前文 XLNet 示例中 "▁" 符号的来历:"▁"(U+2581)在 SentencePiece 中代表空格。解码时非常简单——把所有 token 直接拼接,再把 "▁" 替换为空格即可。
仓库源码中,XLNetTokenizer 的实现 直接引用了 tokenizers.models.Unigram,并定义了常量 SPIECE_UNDERLINE = "▁";其 VOCAB_FILES_NAMES 指向 spiece.model(SentencePiece 的模型文件)。这也印证了文档中的说法:本库中使用 SentencePiece 的 Transformer 模型,全部以 Unigram 作为其底层算法。仓库中基于 SentencePiece(Unigram)的模型包括:
在仓库中查看每个模型的分词器类型
在模型的文档页面可以查到与其关联的分词器,从而确认预训练时使用的是哪种子词算法。例如查看 BertTokenizer 文档 即可确认 BERT 使用 WordPiece。也可以直接阅读仓库源码——每个模型的 tokenization_*.py 文件头部注释都会声明所用算法:
| 模型 | 分词器类 | 底层算法 | 源码位置 |
|---|---|---|---|
| BERT | BertTokenizer |
WordPiece(## 前缀) |
src/transformers/models/bert/tokenization_bert.py |
| GPT-2 | GPT2Tokenizer |
byte-level BPE(256 字节基础词汇) | src/transformers/models/gpt2/tokenization_gpt2.py |
| XLNet | XLNetTokenizer |
SentencePiece + Unigram(▁ 表示空格) |
src/transformers/models/xlnet/tokenization_xlnet.py |
| ALBERT / T5 | AlbertTokenizer / T5Tokenizer |
SentencePiece + Unigram | src/transformers/models/albert/tokenization_albert.py、src/transformers/models/t5/tokenization_t5.py |
实战:加载预训练分词器
结合 预处理教程 的用法,实践中应始终使用与预训练模型配套的分词器,以确保文本切分方式与预训练语料一致、且 token 索引(词汇表)完全对应:
>>> from transformers import AutoTokenizer
>>> tokenizer = AutoTokenizer.from_pretrained("google-bert/bert-base-cased")
>>> encoded_input = tokenizer("Do not meddle in the affairs of wizards, for they are subtle and quick to anger.")
{'input_ids': [101, 2079, 2025, 19960, 10362, 1999, 1996, 3821, 1997, 16657, 1010, 2005, 2027, 2024, 11259, 1998, 4248, 2000, 4963, 1012, 102],
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
>>> tokenizer.decode(encoded_input["input_ids"])
'[CLS] Do not meddle in the affairs of wizards, for they are subtle and quick to anger. [SEP]'
decode 会逆推 token 序列:WordPiece 的 "##" 前缀 token 在此过程中被正确拼接(可对照 test_tokenization_bert_legacy.py 中的 convert_tokens_to_string 测试);而 SentencePiece 分词器的 decode 则等价于"拼接所有 token 并把 "▁" 替换为空格"。理解这一点,就能在不同模型的输出之间自如地做 token 级的对照与调试。
小结
本文完整梳理了 🤗 Transformers 中三种核心子词分词算法及其变体:BPE(含 byte-level 变体)通过反复合并最高频符号对构建词汇表;WordPiece 以"最大化训练数据概率"为标准选择合并对象,并以 "##" 标记子词拼接;Unigram 反其道而行,从大词汇表出发按损失影响逐步剪枝,并在推理时选择概率最高的切分路径;SentencePiece 则将空格纳入字符集合并以 "▁" 表示,从而摆脱对"空格分词"这一语言假设的依赖。这些算法共同解决了三个核心问题——控制词汇表规模、保持有意义的输入表示、处理训练中从未见过的生词——这正是 Transformer 模型能够高效处理海量多语言文本的基础设施。无论你是在排查分词异常、训练自定义分词器,还是想深入理解某个模型的输入管线,本文所引用的源码与测试路径都可以作为继续深挖的起点。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00