在我们之前学习的所有模型中——从Word2Vec到LSTM,从seq2seq到注意力机制——都有一个共同的假设:以“词”作为基本单位。我们用词嵌入表示词,用词ID序列表示句子,词表是所有模型的基础。
但这个看似自然的选择带来了一个棘手的问题:词表应该有多大?
如果词表太小(比如只包含最常见的10,000个词),我们会遇到大量未登录词(Out-of-Vocabulary, OOV)。任何不在词表中的词,模型都无法处理,只能用一个特殊的<UNK>标记代替。
在英语中,专有名词(人名、地名、公司名)、新造词(“selfie”、“tweet”)、拼写变体(“color” vs “colour”)都可能成为OOV。在形态丰富的语言(如德语、土耳其语)中,一个词根可以衍生出几十种变形,词表爆炸式增长。
如果词表太大(比如包含100,000个词),我们又面临新问题:词嵌入矩阵的参数量巨大(100,000 × 300 = 3000万参数),低频词的嵌入无法得到充分训练(一个只出现5次的词,很难学到好的向量表示),而且推理时计算softmax的计算量也很大。
有没有一种方法,既能处理任意新词(no OOV),又能保持词表在合理大小?**子词模型(Subword Models)**优雅地解决了这个两难问题。核心思想是:不要把词当作不可分割的原子,而是将其分解为更小的单元(子词),模型操作的是子词而非完整的词。这样,即使遇到训练时未见过的词,也能通过其子词成分来理解。

Byte Pair Encoding(BPE) 最初是1994年由Philip Gage提出的一种数据压缩算法,用于减少文本的存储空间。2016年,Sennrich等人将其引入自然语言处理领域,用于解决机器翻译中的OOV问题。这个看似简单的想法,却成为了现代NLP的基础技术之一。
BPE的核心洞察是,最常见的词往往由最常见的子词组合而成。如果我们从字符级别开始,逐步合并最频繁出现的符号对,就能自动发现语言中的常见模式——比如英语中的"ing"、"tion"、"ed"等后缀,或者"un"、"re"等前缀。
算法步骤:
BPE的训练过程是一个迭代的贪心算法:
初始化阶段:将词表初始化为所有可能的字符(包括字母、数字、标点符号等)。每个词被表示为字符序列,并在末尾添加一个特殊的词尾标记</w>,用于区分词边界。例如,"low"会被表示为["l", "o", "w", "</w>"]。
频率统计:遍历整个训练语料,统计所有相邻符号对的出现频率。这里的"符号"可能是单个字符,也可能是之前合并得到的子词。
合并操作:选择频率最高的符号对,将其合并为一个新的子词单元。这个新单元会被添加到词表中,并在后续迭代中被视为一个不可分割的符号。
迭代重复:重复步骤2和3,直到达到预设的词表大小(通常是16K到50K之间)。每次迭代都会增加一个子词单元,逐步构建出从字符到常见词缀、再到常见词段的层次化词表。
完整示例:
让我们通过一个具体的例子来理解BPE的工作过程。假设我们的训练语料包含以下词:
|初始状态: ["l o w </w>", "l o w e r </w>", "n e w e s t </w>", "w i d e s t </w>"]
首先,我们统计所有相邻符号对的频率:
("e", "s"): 出现2次(在"newest"和"widest"中)("s", "t"): 出现2次("l", "o"): 出现2次("o", "w"): 出现2次假设("e", "s")频率最高,我们进行第一次合并:
|迭代1: 合并 "e" + "s" → "es" 结果: ["l o w </w>", "l o w e r </w>", "n e w es t </w>", "w i d es t </w>"]
现在"es"成为了词表中的一个新单元。继续统计,发现("es", "t")出现了2次,进行第二次合并:
|迭代2: 合并 "es" + "t" → "est" 结果: ["l o w </w>", "l o w e r </w>", "n e w est </w>", "w i d est </w>"]
继续这个过程,可能会合并("l", "o")→"lo",然后("lo", "w")→"low"。最终,我们得到一个包含常见子词的词表:{l, o, w, e, r, n, i, d, s, t, es, est, low, ...}。
为什么BPE有效?
BPE之所以有效,是因为它遵循了语言的统计规律。高频的符号对往往对应有意义的语言单元:词根、前缀、后缀等。例如,在英语中,("ing", "</w>")会频繁出现,因为许多动词都有现在分词形式。通过自动发现这些模式,BPE能够在保持词表大小的同时,捕获语言的形态学结构。
训练完成后,我们需要使用学到的合并规则对新文本进行分词。这个过程与训练时的合并顺序相反:我们从字符级别开始,按照训练时学到的合并规则(按优先级顺序)尝试合并符号对。
|def bpe_segment(word, merges): """ BPE分词函数 Args: word: 待分词的词(字符串) merges: 训练得到的合并规则列表,按优先级排序 Returns: 分词结果(子词列表) """ # 初始化为字符序列,添加词尾标记 symbols = list(word) + ['</w>'] # 按照训练时的顺序应用合并规则 # 注意:这里应该按照训练时的顺序(从最早到最晚)应用规则 for merge in merges: # merge格式: "symbol1 symbol2" pair = tuple
实际应用中的优化:在实际实现中(如GPT-2、RoBERTa),BPE分词通常使用更高效的算法,比如使用优先队列或哈希表来快速查找可合并的符号对。但核心思想是一致的:从细粒度到粗粒度,逐步应用合并规则。
优势:
彻底解决OOV问题:由于BPE从字符级别开始,任何词都可以被分解为子词序列。即使是训练时从未见过的词(如新造词"cryptocurrency"),也能被分解为已知的子词(如"crypto"、"currency"),模型可以通过这些子词的组合来理解新词的含义。
自动捕获形态学信息:BPE不需要人工定义词根、前缀、后缀,而是通过统计规律自动发现这些语言模式。例如,它会自动学习到"un-"表示否定、"-ing"表示进行时等常见模式。
参数效率高:相比传统的词级模型,BPE的词表更小,但表达能力更强。一个32K的BPE词表可以覆盖比100K词表更多的词汇,因为子词可以组合成无数种词。
跨语言泛化能力强:对于相似的语言(如英语和法语),许多子词是共享的(如拉丁词根)。这使得多语言模型能够利用这种共享性,提高跨语言迁移能力。
局限:
贪心算法的局限性:BPE使用贪心策略,每次只合并频率最高的对,这可能不是全局最优的。例如,它可能先合并("t", "h")→"th",然后("th", "e")→"the",但如果("the", "</w>")的频率很高,直接合并("t", "he")可能更合理。
对低频词的处理:虽然BPE能处理OOV,但对于低频词,它可能会将其分解为很多细粒度的子词,导致表示不够紧凑。例如,"antidisestablishmentarianism"(反政教分离主义)可能被分解为10多个子词。
语言特定性:BPE的效果很大程度上依赖于训练语料的语言特性。对于形态变化丰富的语言(如芬兰语、土耳其语),可能需要更大的词表才能有效捕获形态学模式。
WordPiece是Google在2016年为BERT模型开发的分词算法。虽然WordPiece与BPE在流程上非常相似,但它们在选择合并对象的标准上有根本性的不同,这导致了不同的分词效果。
核心区别:BPE是一个纯粹的频率驱动方法——它选择出现次数最多的符号对进行合并。但WordPiece认为,频率高不等于语言学上有意义。例如,("t", "h")可能频率很高,但"th"作为一个整体可能没有独立的语义价值。WordPiece通过引入语言模型来评估合并的“价值”。

WordPiece的训练过程与BPE类似,也是迭代合并符号对。但关键区别在于合并标准:
BPE的选择标准:
|# BPE: 选择频率最高的符号对 score = count(pair) # 简单的计数
WordPiece的选择标准: WordPiece使用互信息(Mutual Information)来评估合并的价值:
这个公式的含义是:如果xy一起出现的概率远大于它们独立出现的概率,说明x和y之间有很强的关联性,合并它们是有意义的。这个分数实际上衡量的是x和y之间的互信息。
为什么互信息更好?
考虑一个例子:假设在语料中,"th"出现了1000次,"e"出现了5000次,"the"出现了800次。在BPE中,("t", "h")的频率是1000,可能被优先合并。但在WordPiece中:
P("th") = 1000/N(N是总符号对数)P("t") × P("h")可能很小score("t", "h") = P("th") / (P("t") × P("h"))会很大但如果"the"作为一个整体出现频率很高,那么:
score("th", "e") = P("the") / (P("th") × P("e"))可能更大这样,WordPiece更倾向于合并那些形成有意义的语言单元的符号对。
训练过程:
WordPiece在BERT中的实现有一个重要特点:它使用##前缀来标记子词片段。这是为了区分词的开始和中间部分。
|from transformers import BertTokenizer # 加载BERT的tokenizer(使用WordPiece) tokenizer = BertTokenizer.from_pretrained('bert-base-uncased') # 分词示例 text = "unbelievable" tokens = tokenizer.tokenize(text) print(tokens) # 输出可能是: ['un', '##believ', '##able'] # 或者: ['un', '##be', '##liev', '##able'] # 取决于具体的词表大小和训练数据 # 转换为ID ids = tokenizer.convert_tokens_to_ids(tokens) print(ids) # [2027, 2944, 5253] 等 # 完整文本的分词 sentence =
##标记的作用:
##前缀是WordPiece的一个重要设计。它标记了"这不是词的开始"。例如:
"un":词的开始,没有##"##believable":词的中间部分,有##这种设计使得模型能够区分:
"play" + "##er" = "player"(一个词)"play" + "er" = "play er"(两个词)这对于理解词边界和语义非常重要。
BERT使用WordPiece分词,并引入了一系列特殊标记来支持各种NLP任务:
[CLS]:Classification token,序列的开始标记。在分类任务中,[CLS]位置的隐藏状态通常被用作整个序列的表示。[SEP]:Separator token,用于分隔不同的序列。在句子对任务(如问答、自然语言推理)中,[SEP]分隔两个句子。[MASK]:Mask token,用于BERT的掩码语言模型预训练。在训练时,随机选择15%的token进行掩码,其中80%替换为[MASK],10%替换为随机token,10%保持不变。[UNK]:Unknown token,虽然WordPiece理论上不会有OOV,但在某些边缘情况下(如特殊字符、罕见组合)仍可能出现未知token。[PAD]:Padding token,用于将不同长度的序列填充到相同长度,便于批处理。实际应用示例:
|# 句子对任务(如自然语言推理) sentence1 = "The cat sat on the mat" sentence2 = "The cat is on the mat" # BERT的输入格式 tokens = ['[CLS]'] + tokenizer.tokenize(sentence1) + ['[SEP]'] + tokenizer.tokenize(sentence2) + ['[SEP]'] # ['[CLS]', 'the', 'cat', 'sat', 'on', 'the', 'mat', '[SEP]', 'the', 'cat', 'is', 'on', 'the', 'mat', '[SEP]'] # 转换为ID并添加padding input_ids = tokenizer.convert_tokens_to_ids(tokens) # 如果序列长度不足max_length,会在末尾添加[PAD]
虽然WordPiece和BPE在流程上相似,但它们在实际应用中表现出不同的特性:
分词粒度:WordPiece倾向于产生更细粒度的分词,因为它更关注语言学上的合理性。例如,对于"unbelievable":
["un", "believ", "able"]["un", "##be", "##liev", "##able"]对低频词的处理:WordPiece对低频词的处理可能更激进,将其分解为更小的单元,因为它的目标是最大化语言模型的似然,而不是最小化token数量。
计算复杂度:WordPiece的训练需要维护和更新语言模型,计算复杂度高于BPE。但在推理时,两者的速度差异不大。
实际效果:在大多数NLP任务中,WordPiece和BPE的表现相当。BERT的成功更多来自于其架构和预训练方法,而不是WordPiece本身。GPT-2使用BPE也取得了很好的效果。

在BPE和WordPiece中,我们通常假设输入文本已经被预分词(pre-tokenized)——也就是说,词之间已经有空格分隔。这在英语中很自然,但对于许多语言来说,这个假设并不成立:
Lebensversicherungsgesellschaftsangestellter(人寿保险公司员工),一个“词”可能包含多个语义单元SentencePiece的核心创新:Google在2018年提出的SentencePiece方法,直接对原始文本(raw text)进行处理,不需要预分词。这使得同一个分词器可以处理任何语言,真正实现了语言无关。
1. 直接处理原始文本
传统的分词流程是:
|原始文本 → 预分词(按空格/标点) → 子词分词 → token序列
SentencePiece的流程是:
|原始文本 → 子词分词 → token序列
这意味着SentencePiece将空格、标点符号都视为普通字符,统一处理。
2. 可逆性(Reversibility)
SentencePiece的一个重要特性是完全可逆:可以从token序列完美还原原始文本。这是通过特殊的▁(U+2581,下划线符号)来实现的。
▁标记了"词开始"的位置。例如:
"Hello world"['▁Hello', '▁world']▁,在▁位置插入空格 → "Hello world"这种设计使得SentencePiece可以处理任何文本,包括代码、URL、特殊符号等,而不会丢失信息。
3. 语言无关性
由于不依赖空格,SentencePiece可以无缝处理:
"我喜欢机器学习" → ['▁我', '▁喜欢', '▁机器', '▁学习']"こんにちは" → ['▁こん', '▁にち', '▁は']"Hello world" → ['▁Hello', '▁world']同一个模型可以处理多种语言,这对于多语言NLP任务非常重要。
SentencePiece支持两种训练算法,可以根据需求选择:
BPE模式与标准BPE类似,但有一个关键区别:它直接处理原始文本,包括空格。
|# 标准BPE的输入(需要预分词) corpus = ["low", "lower", "newest", "widest"] # 已经分词 # SentencePiece BPE的输入(原始文本) corpus = ["low lower newest widest"] # 原始文本,空格也是字符
在SentencePiece的BPE中,空格会被编码为▁,然后像其他字符一样参与合并过程。
Unigram模式是SentencePiece的另一个重要创新。与BPE的"自底向上"(从字符开始合并)不同,Unigram采用"自顶向下"的策略:
训练过程:
初始化:从一个非常大的词表开始(比如包含所有可能的字符组合,或从语料中提取的所有子串)
迭代删除:在每次迭代中:
收敛:重复步骤2,直到词表大小达到目标值
为什么Unigram模式有效?
Unigram模式的优势在于它直接优化语言模型的似然,而不是像BPE那样只关注频率。这使得它能够:
数学表示:
Unigram语言模型假设每个token是独立生成的:
训练目标是最大化训练数据的似然,同时最小化词表大小。这是一个典型的模型选择问题。
SentencePiece是T5、mT5、ALBERT等模型使用的分词方法。让我们看看如何训练和使用它:
|import sentencepiece as spm # 训练SentencePiece模型 spm.SentencePieceTrainer.train( input='corpus.txt', # 输入文件(原始文本,不需要预分词) model_prefix='sp_model', # 输出模型前缀 vocab_size=32000, # 目标词表大小 model_type='unigram', # 或 'bpe' character_coverage=0.9995, # 字符覆盖率(对多语言很重要) max_sentence_length=4192, # 最大句子长度 shuffle_input_sentence
关键参数说明:
character_coverage:字符覆盖率,对于多语言语料很重要。如果设置为0.9995,意味着词表要覆盖99.95%的字符。对于中文、日文等字符集大的语言,可能需要更大的值。
model_type:
'bpe':BPE算法,训练快,适合大多数场景'unigram':Unigram算法,训练慢但通常效果更好,特别是对低频词vocab_size:词表大小。对于多语言模型,通常使用32K-128K。
优势:
应用场景:
与BPE/WordPiece的对比:

如果我们继续沿着“更小粒度”的思路走下去,最终会到达字符级别。字符级模型将每个字符(字母、数字、标点)作为基本单位,这是最细粒度的表示方法。
字符级模型的优势:
color和colour在字符级表示中会有相似的模式。字符级模型的劣势:
序列长度急剧增加:一个词平均5-6个字符,一个句子可能有数百个字符。这导致:
难以捕获高层语义:字符级别的表示距离语义太远。模型需要学习从字符到词、从词到短语、从短语到句子的多层抽象,这比直接从词开始要困难得多。
长距离依赖问题:在字符序列中,相关的语义单元可能相距很远。例如,在"the cat sat on the mat"中,字符级的序列长度是30+,而词级只有6个token。
实际应用场景:
字符级模型在以下场景中仍然有价值:
既然字符级和词级各有优劣,一个自然的想法是结合两者:用字符级CNN编码每个词的字符序列,得到词的表示,然后将这些词表示输入到词级RNN中。这样既能利用字符级的信息(处理OOV、拼写变体),又能保持词级模型的效率。
架构设计:
这种混合架构分为两个阶段:
|import torch import torch.nn as nn import torch.nn.functional as F class CharCNN(nn.Module): """ 字符级CNN词编码器 这个模块将词的字符序列编码为固定维度的词向量。 使用卷积神经网络捕获字符n-gram模式(如"ing"、"tion"等)。 """ def __init__(self, char_vocab_size, char_embed_size, word_embed_size, kernel_sizes=[3, 4, 5], num_filters
这种混合方法的优势:
局限性:
在Transformer时代,字符级模型的使用场景更加有限。大多数现代模型(BERT、GPT、T5等)都使用子词分词,它们已经能够很好地平衡OOV处理和效率。字符级模型主要用于:
在实际项目中,选择合适的分词方法至关重要。这不仅影响模型性能,还影响训练速度、推理效率和部署复杂度。
第一步:确定语言类型
第二步:确定任务类型
第三步:考虑预训练模型
如果使用预训练模型(BERT、GPT、T5等),必须使用与预训练时相同的分词器,否则会严重影响性能。
1. 理解BPE如何解决OOV问题
选择一个训练时未见过的词(如cryptocurrency),假设你已经有了一个BPE词表。手动演示如何将这个词分解为子词序列。思考:为什么即使词本身不在词表中,模型也能理解它?
提示:考虑子词的组合语义。如果crypto和currency都在词表中,模型可以通过它们的组合来理解cryptocurrency。
2. 比较不同分词方法
选择一段文本(可以是英语、中文或多语言混合),分别用BPE、WordPiece和SentencePiece进行分词。观察:
3. 训练自己的BPE模型
使用一个小型语料库(如新闻文章、小说片段),训练一个BPE模型:
ing、tion)实现参考:
|from tokenizers import Tokenizer from tokenizers.models import BPE from tokenizers.trainers import BpeTrainer tokenizer = Tokenizer(BPE(unk_token="<UNK>")) trainer = BpeTrainer(vocab_size=16000, special_tokens=["<UNK>", "<CLS>", "<SEP>", "<PAD>", "<MASK>"]) files
| BERT | WordPiece | BertTokenizer |
| GPT-2 | BPE | GPT2Tokenizer |
| GPT-3/GPT-4 | BPE | tiktoken |
| T5 | SentencePiece | T5Tokenizer |
| mT5 | SentencePiece | MT5Tokenizer |
| ALBERT | SentencePiece | AlbertTokenizer |