深入理解 LLM 分词器:BPE、WordPiece 与 Unigram
前言
在大模型中,分词(Tokenization) 是将原始文本转换为模型可处理的离散单元(称为 tokens)的关键预处理步骤。这些 tokens 随后被映射为向量,作为神经网络的输入。分词质量直接影响模型对语言的理解能力、泛化性能以及对罕见词或未知词的处理效果。
传统自然语言处理(NLP)中,分词常依赖于语言特定的规则或词典,但在大模型时代,为支持多语言、高效压缩和鲁棒性,子词(subword)级别的分词算法成为主流。现代大模型普遍采用如 Byte Pair Encoding (BPE)、WordPiece 和 Unigram Language Model 等算法,并结合字节级(byte-level)表示以避免信息丢失。
在将文本拆分为子词前,分词器会先执行归一化和预分词两个步骤。
归一化(Normalization)
归一化是指将原始文本中的字符、符号或格式统一转换为标准形式,目的是消除文本中不必要的变体,使模型更鲁棒、更一致地处理输入。 常见的归一化操作包括:
- Unicode 标准化(Unicode Normalization)
- 将不同形式的 Unicode 字符统一为标准形式(如 NFC、NFD 等)。
- 例如:
é
可以表示为单个字符U+00E9
,也可以表示为e + U+0301
(组合字符)。归一化可统一为一种形式。
- 大小写转换(Lowercasing)
- 将所有字母转换为小写(某些模型如 BERT 不做此操作)。
- 去除或替换特殊字符
- 如将不间断空格(
\u00A0
)替换为普通空格,移除控制字符。
- 如将不间断空格(
- 处理标点符号和空格
- 统一空格形式(如多个连续空格合并为一个),或标准化引号、破折号等。
- 语言特定的归一化
- 例如中文中将全角字符转为半角,统一繁体简体。
预分词
预分词是在正式分词(如 BPE、WordPiece、Unigram 等算法)之前,将文本初步切分为“有意义的子单元”,这些子单元通常是单词、标点、数字等基本语言单位。例如按空格和标点分割:"Hello, how are you?"
→ ["Hello", ",", "how", "are", "you", "?"]
分词算法
基于字符(Character-based)分词
将文本拆分为单个字符。优点是词表极小(仅需基础字符和标点),且不存在未知词(OOV)问题。但缺点是序列过长,计算效率低,且难以捕捉语义单元。
"Hello"
["H", "e", "l", "l", "o"]
基于词(Word-based)分词
以完整单词为单位切分。虽然语义清晰,但词表庞大,且对未登录词(如新词、拼写错误)处理能力差,不适用于多语言场景。
"Hello, world!"
["Hello", ",", "world", "!"]
子词(Subword-based)分词(主流方法)
在词与字符之间取得平衡,将词拆分为比单词小比字符大的子单元。主要算法包括:
BPE(Byte Pair Encoding,字节对编码)
BPE 最初是作为一种文本压缩算法开发的,后来 OpenAI 在预训练 GPT 模型时将其用于分词。它被许多 Transformer 模型使用,包括 GPT、GPT-2、RoBERTa等。
BPE 基于频率统计的贪心合并,通过迭代合并文本中出现频率最高的相邻字符对(或子词对),逐步构建子词单元。
假设我们的语料库使用以下五个词:
"hug", "pug", "pun", "bun", "hugs"
基础词汇表将是 ["b", "g", "h", "n", "p", "s", "u"]
。如果你要分词的示例使用了训练语料库中没有的字符(OOV,out of vocabulary),该字符将被转换为未知标记。
BPE 的一个变体 **字节级 BPE(byte-level BPE)**可以很好的解决这个问题:不将词视为由 Unicode 字符书写,而是由字节书写。这样,基础词汇表的规模很小(256,因为字节值只有256种),但你可以想到的每个字符都将包含在内,而不会最终转换为未知标记。
假设语料库中词的频率如下:
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
这意味着 "hug"
在语料库中出现了 10 次,"pug"
出现了 5 次,"pun"
出现了 12 次,"bun"
出现了 4 次,"hugs"
出现了 5 次。将每个单词拆分为字符:
("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)
最常见的对: ("u", "g")
,在词汇表中总共出现了 20 次。
因此,分词器学到的第一个合并规则是 ("u", "g") -> "ug"
,这意味着 "ug"
将被添加到词汇表中,并且该对应该在语料库中的所有单词中合并。在此阶段结束时,词汇表和语料库如下所示:
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)
轮流合并,直到达到所需的词汇表大小。
演示代码:
from transformers import AutoTokenizertokenizer = AutoTokenizer.from_pretrained("gpt2")corpus = ["This is a blog of LETTTER.","This blog is about tokenization.","This blog shows several tokenizer algorithms.","Hopefully, you will be able to understand how they are trained and generate tokens.",
]from collections import defaultdict# 计算每个单词的频率
word_freqs = defaultdict(int)for text in corpus:words_with_offsets = tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(text)new_words = [word for word, offset in words_with_offsets]for word in new_words:word_freqs[word] += 1print(word_freqs) # 空格被替换为特殊符号Ġ# 初始化词汇表
alphabet = []for word in word_freqs.keys():for letter in word:if letter not in alphabet:alphabet.append(letter)
alphabet.sort()print(alphabet)vocab = alphabet.copy()splits = {word: [c for c in word] for word in word_freqs.keys()}# 计算子词对的频率
def compute_pair_freqs(splits):pair_freqs = defaultdict(int)for word, freq in word_freqs.items():split = splits[word]if len(split) == 1:continuefor i in range(len(split) - 1):pair = (split[i], split[i + 1])pair_freqs[pair] += freqreturn pair_freqsbest_pair = ""
max_freq = None
pair_freqs = compute_pair_freqs(splits)
for pair, freq in pair_freqs.items():if max_freq is None or max_freq < freq:best_pair = pairmax_freq = freqprint(best_pair, max_freq)def merge_pair(a, b, splits):for word in word_freqs:split = splits[word]if len(split) == 1:continuei = 0while i < len(split) - 1:if split[i] == a and split[i + 1] == b:split = split[:i] + [a + b] + split[i + 2 :]else:i += 1splits[word] = splitreturn splitsvocab_size = 50merges = {}while len(vocab) < vocab_size:pair_freqs = compute_pair_freqs(splits)best_pair = ""max_freq = Nonefor pair, freq in pair_freqs.items():if max_freq is None or max_freq < freq:best_pair = pairmax_freq = freqsplits = merge_pair(*best_pair, splits)merges[best_pair] = best_pair[0] + best_pair[1]vocab.append(best_pair[0] + best_pair[1])
WordPiece
WordPiece 是 Google 为预训练 BERT 而开发的分词算法。此后,它被 BERT 衍生的许多 Transformer 模型重用,例如 DistilBERT、MobileBERT、Funnel Transformers 和 MPNET。
WordPiece 通过添加前缀(例如 BERT 的 ##
)来识别子词。例如,"word"
会这样拆分
w ##o ##r ##d
因此,初始字母表包含词开头的所有字符以及词内前面带 WordPiece 前缀的字符。
WordPiece 基于语言模型概率的贪心合并,通过最大化训练数据的似然概率选择合并的子词对。选择有最大互信息的子词对来合并可以最大化似然概率(证明见下),互信息的公式:
score=freq_of_pairsfreq_of_first_element∗freq_of_second_elementscore = \dfrac{freq\_of\_pairs}{freq\_of\_first\_element *freq\_of\_second\_element} score=freq_of_first_element∗freq_of_second_elementfreq_of_pairs
假设初始语料库
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
拆分后
("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##g" "##s", 5)
因此初始词汇表将是 ["b", "h", "p", "##g", "##n", "##s", "##u"]
。最频繁的对是 ("##u", "##g")
(出现 20 次),但 "##u"
的单个频率非常高,因此其分数不是最高的(为 1 / 36)。所有带有 "##u"
的对实际上都具有相同的分数(1 / 36),因此最好的分数属于 ("##g", "##s")
对——唯一一个没有 "##u"
的对——为 1 / 20,因此学到的第一个合并是 ("##g", "##s") -> ("##gs")
。
请注意,当我们合并时,我们会删除两个标记之间的 ##
,因此我们将 "##gs"
添加到词汇表中,并在语料库的单词中应用合并
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs"]
Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##gs", 5)
此时,"##u"
存在于所有可能的对中,因此它们都最终获得相同的分数。假设在这种情况下,第一个对被合并,因此 ("h", "##u") -> "hu"
。这将我们带到
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu"]
Corpus: ("hu" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)
轮流合并,直到达到所需的词汇表大小。
证明:
假设训练语料 CCC 被切分为子词序列 w1,w2,…,wNw_1, w_2, \dots, w_Nw1,w2,…,wN,WordPiece 假设子词独立同分布,则整个语料的似然为:
L(V)=∏i=1NP(wi)\mathcal{L}(V) = \prod_{i=1}^{N} P(w_i) L(V)=i=1∏NP(wi)
其中:
- VVV 是当前子词词汇表;
- P(w)=count(w)NP(w) = \dfrac{\text{count}(w)}{N}P(w)=Ncount(w) 是子词 www 的最大似然估计(MLE);
- count(w)\text{count}(w)count(w) 是子词 www 在语料中的出现次数;
- N=∑w∈Vcount(w)N = \sum_{w \in V} \text{count}(w)N=∑w∈Vcount(w) 是总子词数。
取对数后,对数似然为:
logL(V)=∑w∈Vcount(w)⋅log(count(w)N)\log \mathcal{L}(V) = \sum_{w \in V} \text{count}(w) \cdot \log \left( \frac{\text{count}(w)}{N} \right) logL(V)=w∈V∑count(w)⋅log(Ncount(w))
WordPiece 采用贪心增量策略,每次只评估合并两个子词 aaa 和 bbb 为 ababab 所带来的似然变化 ΔL\Delta LΔL。
设:
- ca=count(a)c_a = \text{count}(a)ca=count(a)
- cb=count(b)c_b = \text{count}(b)cb=count(b)
- cab=count(ab)c_{ab} = \text{count}(ab)cab=count(ab):当前分词下相邻出现 $ a $ 后紧跟 $ b $ 的次数
忽略总词数 $ N $ 的微小变化,共现次数远小于总频次,似然增益近似为:
ΔL≈cab⋅log(cabca⋅cb)\Delta L \approx c_{ab} \cdot \log \left( \frac{c_{ab}}{c_a \cdot c_b} \right) ΔL≈cab⋅log(ca⋅cbcab)
考虑计算效率以及大多数情况下,最大化 score 和最大化 ΔL\Delta LΔL是等价的。
Unigram
Unigram 采用概率模型,假设每个词由独立的子词生成。用 EM 算法计算每个字词的概率,从大词表逐步剪枝,保留使整体序列概率最大的子词集合。
与 BPE 和 WordPiece 相比,Unigram 的工作方向相反:它从一个大型词汇表开始,然后从中删除子词,直到达到所需的词汇表大小。有几种选项可用于构建该基本词汇表:例如,我们可以获取预分词单词中最常见的子字符串,或者对初始语料库应用 BPE,并使用较大的词汇表大小。
在训练的每个步骤中,Unigram 算法根据当前词汇表计算语料库上的损失。然后,对于词汇表中的每个子词,算法计算如果删除该子词,总损失会增加多少,并查找增加最少的符号,删除对应的符号。
注意,基本字符不被删除,以确保任何单词都可以被分词。
假设语料库为:
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
把所有子字符串作为初始词汇表
["h", "u", "g", "hu", "ug", "p", "pu", "n", "un", "b", "bu", "s", "hug", "gs", "ugs"]
Unigram 模型是一种语言模型,它认为每个子词都独立于其之前的子词。
给定子词的概率是它在原始语料库中的频率除以词汇表中所有子词的所有频率之和。
以下是词汇表中所有可能子词的频率
("h", 15) ("u", 36) ("g", 20) ("hu", 15) ("ug", 20) ("p", 17) ("pu", 17) ("n", 16)
("un", 16) ("b", 4) ("bu", 4) ("s", 5) ("hug", 15) ("gs", 5) ("ugs", 5)
因此,所有频率的总和为 210,子词 "ug"
的概率为 20/210。
用 Unigram 模型对单词进行分词,就是指具有最高概率的分词。
在 "pug"
的例子中,就有三种分词方式
["p", "u", "g"]
["p", "ug"]
["pu", "g"]
因为 [“pu”, “g”] 的分数(分割的概率)最大,所以是分词方式
每个单词的分词及其相应的分数是
"hug": ["hug"] (score 0.071428)
"pug": ["pu", "g"] (score 0.007710=17*20/210/210)
"pun": ["pu", "n"] (score 0.006168)
"bun": ["bu", "n"] (score 0.001451)
语料库中的每个词都有一个分数,unigram 的损失是这些分数的负对数似然——即语料库中所有词的 —log(P(词))
之和。
loss = 10 * (-log(0.071428)) + 5 * (-log(0.007710)) + 12 * (-log(0.006168)) + 4 * (-log(0.001451)) + 5 * (-log(0.001701)) = 169.8
接下来需要计算删除每个子词造成的损失增加量,删除最大增加损失对应的子词,直到满足词汇表大小即可。
解码
解码是将模型生成的整数序列转换回人类可读文本的过程。
以字节级别的 BPE 举例:
def decode(self, ids):# Step 1: 将整数ID映射为字节序列text_bytes = b"".join(self.vocab[idx] for idx in ids)# Step 2: 将字节序列解码为UTF-8字符串text = text_bytes.decode("utf-8", errors="replace")return text
参考
动手搭建大模型
LLM大语言模型之Tokenization分词方法
逐块构建分词器 - Hugging Face LLM 课程 - Hugging Face 机器学习平台
karpathy/minbpe · Discussions · GitHub