深入理解 LLM 分词器:BPE、WordPiece 与 Unigram

前言

在大模型中,分词(Tokenization) 是将原始文本转换为模型可处理的离散单元(称为 tokens)的关键预处理步骤。这些 tokens 随后被映射为向量,作为神经网络的输入。分词质量直接影响模型对语言的理解能力、泛化性能以及对罕见词或未知词的处理效果。

传统自然语言处理(NLP)中,分词常依赖于语言特定的规则或词典,但在大模型时代,为支持多语言、高效压缩和鲁棒性,子词(subword)级别的分词算法 成为主流。现代大模型普遍采用如 Byte Pair Encoding (BPE)WordPieceUnigram Language Model 等算法,并结合字节级(byte-level)表示以避免信息丢失。

在将文本拆分为子词前,分词器会先执行归一化和预分词两个步骤。

归一化(Normalization)

归一化是指将原始文本中的字符、符号或格式统一转换为标准形式,目的是消除文本中不必要的变体,使模型更鲁棒、更一致地处理输入。 常见的归一化操作包括:

  1. Unicode 标准化(Unicode Normalization)
    • 将不同形式的 Unicode 字符统一为标准形式(如 NFC、NFD 等)。
    • 例如:é 可以表示为单个字符 U+00E9,也可以表示为 e + U+0301(组合字符)。归一化可统一为一种形式。
  2. 大小写转换(Lowercasing)
    • 将所有字母转换为小写(某些模型如 BERT 不做此操作)。
  3. 去除或替换特殊字符
    • 如将不间断空格(\u00A0)替换为普通空格,移除控制字符。
  4. 处理标点符号和空格
    • 统一空格形式(如多个连续空格合并为一个),或标准化引号、破折号等。
  5. 语言特定的归一化
    • 例如中文中将全角字符转为半角,统一繁体简体。

预分词

预分词 是在正式分词(如 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)

轮流合并,直到达到所需的词汇表大小。

演示代码:

py 复制代码
from transformers import AutoTokenizer

tokenizer = 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] += 1

print(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:
            continue
        for i in range(len(split) - 1):
            pair = (split[i], split[i + 1])
            pair_freqs[pair] += freq
    return pair_freqs

best_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 = pair
        max_freq = freq

print(best_pair, max_freq)

def merge_pair(a, b, splits):
    for word in word_freqs:
        split = splits[word]
        if len(split) == 1:
            continue

        i = 0
        while i < len(split) - 1:
            if split[i] == a and split[i + 1] == b:
                split = split[:i] + [a + b] + split[i + 2 :]
            else:
                i += 1
        splits[word] = split
    return splits

vocab_size = 50

merges = {}

while len(vocab) < vocab_size:
    pair_freqs = compute_pair_freqs(splits)
    best_pair = ""
    max_freq = None
    for pair, freq in pair_freqs.items():
        if max_freq is None or max_freq < freq:
            best_pair = pair
            max_freq = freq
    splits = 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 基于语言模型概率的贪心合并 ,通过最大化训练数据的似然概率选择合并的子词对。选择有最大互信息的子词对来合并可以最大化似然概率(证明见下),互信息的公式:
s c o r e = f r e q _ o f _ p a i r s f r e q _ o f _ f i r s t _ e l e m e n t ∗ f r e q _ o f _ s e c o n d _ e l e m e n t score = \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)

轮流合并,直到达到所需的词汇表大小。

证明:

假设训练语料 C C C 被切分为子词序列 w 1 , w 2 , ... , w N w_1, w_2, \dots, w_N w1,w2,...,wN,WordPiece 假设子词独立同分布,则整个语料的似然为:
L ( V ) = ∏ i = 1 N P ( w i ) \mathcal{L}(V) = \prod_{i=1}^{N} P(w_i) L(V)=i=1∏NP(wi)

其中:

  • V V V 是当前子词词汇表;
  • P ( w ) = count ( w ) N P(w) = \dfrac{\text{count}(w)}{N} P(w)=Ncount(w) 是子词 w w w 的最大似然估计(MLE);
  • count ( w ) \text{count}(w) count(w) 是子词 w w w 在语料中的出现次数;
  • N = ∑ w ∈ V count ( w ) N = \sum_{w \in V} \text{count}(w) N=∑w∈Vcount(w) 是总子词数。

取对数后,对数似然为:

log ⁡ L ( V ) = ∑ w ∈ V count ( 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 采用贪心增量策略 ,每次只评估合并两个子词 a a a 和 b b b 为 a b ab ab 所带来的似然变化 Δ L \Delta L ΔL。

设:

  • c a = count ( a ) c_a = \text{count}(a) ca=count(a)
  • c b = count ( b ) c_b = \text{count}(b) cb=count(b)
  • c a b = count ( a b ) c_{ab} = \text{count}(ab) cab=count(ab):当前分词下相邻出现 a 后紧跟 b 的次数

忽略总词数 N 的微小变化,共现次数远小于总频次,似然增益近似为:

Δ L ≈ c a b ⋅ log ⁡ ( c a b c a ⋅ c b ) \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 举例:

py 复制代码
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

相关推荐
一条数据库3 小时前
中文粤语(广州)语音语料库:6219条高质量语音数据助力粤语语音识别与自然语言处理研究
人工智能·自然语言处理·语音识别
Sunhen_Qiletian3 小时前
从语言到向量:自然语言处理核心转换技术的深度拆解与工程实践导论(自然语言处理入门必读)
人工智能·自然语言处理
金井PRATHAMA4 小时前
产生式规则在自然语言处理深层语义分析中的演变、影响与未来启示
人工智能·自然语言处理·知识图谱
*星星之火*5 小时前
【大模型评估】大模型评估框架 HELM(Holistic Evaluation of Language Models)全解析:原理、工具与实践
windows·语言模型·数据挖掘
算家云5 小时前
化学专业大型语言模型——SparkChemistry-X1-13B本地部署教程:洞察分子特性,精准预测化学行为
人工智能·语言模型·自然语言处理·算家云·镜像社区·化学专业大模型·sparkchemistry
西猫雷婶6 小时前
pytorch基本运算-torch.normal()函数输出多维数据时,如何绘制正态分布函数图
人工智能·pytorch·python·深度学习·神经网络·机器学习·线性回归
wa的一声哭了7 小时前
Stanford CS336 Lecture3 | Architectures, hyperparameters
人工智能·pytorch·python·深度学习·机器学习·语言模型·自然语言处理
LETTER•7 小时前
从GPT-1到GPT-3:生成式预训练语言模型的演进之路
gpt·深度学习·语言模型·自然语言处理
l12345sy10 小时前
Day31_【 NLP _1.文本预处理 _(1)文本处理的基本方法】
人工智能·自然语言处理·nlp·文本基本处理·jieba词性标注对照表