在将自然语言文本输入大语言模型之前,必须先将其转换为模型能够计算的数字序列。这一过程被称为分词(Tokenization),而执行该转换的模块即为分词器(Tokenizer)。分词器定义了一套规则,将原始文本切分为一个个最小的语义单元,这些单元叫作词元(Token)。
为了在词汇表大小与语义表达能力之间取得平衡,现代大语言模型普遍采用子词分词(Subword Tokenization)算法。其核心思想是:将高频词(如 "agent")保留为一个完整的词元,而将低频或未登录词(如 "Tokenization")拆分成多个有意义的子词片段(如 "Token" 和 "ization")。这样做既能控制词汇表规模,又赋予模型通过子词组合来理解与生成新词的能力。最终,所有词元会被映射为固定的整数 ID,形成模型可直接计算的数字序列。
本文围绕子词分词技术,详细讲解三种主流算法:Byte-Pair Encoding (BPE) 、WordPiece 和 SentencePiece (Unigram 语言模型)。
1. Byte-Pair Encoding (BPE)
原理
BPE 最早是一种数据压缩算法,后被用于机器翻译等 NLP 任务。它从字符级 开始,统计所有相邻符号对的出现频率,反复将频率最高的那一对合并成一个新符号,直到词汇表达到预定大小(或没有可合并的对)。
训练 BPE 前通常需要一个预分词步骤(如按空格分词),并为每个词加上结尾标记(如 </w>),保证合并出的子词能还原词边界。最终词汇表包含单字符、合并得到的子词以及词尾标记。
举例
假设有词频数据:low</w> 出现 5 次,lower</w> 出现 2 次,newest</w> 出现 6 次,widest</w> 出现 3 次。
- 初始分割为字符序列,例如
l o w </w>。 - 统计所有相邻对:
(l, o)出现 5+2=7 次,(o, w)出现 7 次,(w, </w>)出现 5 次,(e, w)出现 2 次... - 最高频对为
(e, s)出现 6+3=9 次,于是合并成es。 - 继续迭代,直到词汇表达到预设大小。
编码/分词
对于一个新词,按照训练时学到的合并顺序(merges),依次应用合并规则,将词拆分为子词序列。
Python 实现
python
import re
from collections import Counter, defaultdict
def get_initial_vocab(word_freqs):
"""将词拆为字符序列,末尾加特殊标记</w>"""
vocab = {}
for word, freq in word_freqs.items():
chars = ' '.join(list(word)) + ' </w>'
vocab[chars] = freq
return vocab
def get_pair_stats(vocab):
"""统计所有相邻符号对的频率"""
pairs = Counter()
for word, freq in vocab.items():
symbols = word.split()
for i in range(len(symbols) - 1):
pairs[(symbols[i], symbols[i+1])] += freq
return pairs
def merge_vocab(pair, vocab):
"""将指定符号对在词汇表中合并"""
bigram = ' '.join(pair)
replacement = ''.join(pair)
new_vocab = {}
for word, freq in vocab.items():
new_word = word.replace(bigram, replacement)
new_vocab[new_word] = freq
return new_vocab
def bpe_train(word_freqs, num_merges):
"""训练 BPE,返回合并列表和最终词汇表"""
vocab = get_initial_vocab(word_freqs)
merges = []
for i in range(num_merges):
pairs = get_pair_stats(vocab)
if not pairs:
break
best_pair = max(pairs, key=pairs.get)
merges.append(best_pair)
vocab = merge_vocab(best_pair, vocab)
return merges, vocab
# ---- 使用演示 ----
word_freqs = {"low":5, "lower":2, "newest":6, "widest":3}
merges, final_vocab = bpe_train(word_freqs, num_merges=10)
print("合并顺序:", merges)
print("最终词汇表示例:", list(final_vocab.items())[:5])
2. WordPiece
原理
WordPiece 与 BPE 非常相似,最大的区别在于合并准则 :WordPiece 不选频率最高的对,而是选择最大化语言模型似然的符号对。实际实现中,计算分数:
score = freq ( x , y ) freq ( x ) × freq ( y ) \text{score} = \frac{\text{freq}(x,y)}{\text{freq}(x) \times \text{freq}(y)} score=freq(x)×freq(y)freq(x,y)
其中 freq ( x , y ) \text{freq}(x,y) freq(x,y) 是相邻对共现次数, freq ( x ) \text{freq}(x) freq(x) 和 freq ( y ) \text{freq}(y) freq(y) 分别是两个符号单独出现的次数。这样倾向于合并那些经常一起出现、且单独出现相对较少的组合,得到的子词更具语言学意义。
与 BPE 的另一区别是,WordPiece 在词内部用 ## 前缀标记非起始子词(如 word 与 ##piece),以防止跨词边界的歧义合并。
举例
词频同 BPE 例子,但合并时选择 score 最高的对:
- 统计出
(e, s)频率高,但e和s本身也频繁出现,因此 score 可能不如(w, ##i)(在wide中)等高。 - 最终合并会优先使单个符号之间依赖更强的合并。
编码/分词
采用贪心最长匹配策略:从左到右,逐步匹配词汇表中最长的子词。
Python 实现
python
def wordpiece_train(word_freqs, num_merges):
"""简化的 WordPiece 训练"""
# 词内符号以 ## 标记非首字符
vocab = {}
for word, freq in word_freqs.items():
chars = word[0] # 首字符不加 ##
for c in word[1:]:
chars += ' ##' + c
chars += ' </w>'
vocab[chars] = freq
merges = []
for _ in range(num_merges):
pairs = Counter()
# 统计每个符号的独立频率
symbol_freq = Counter()
for word, freq in vocab.items():
symbols = word.split()
for s in symbols:
symbol_freq[s] += freq
for i in range(len(symbols)-1):
pairs[(symbols[i], symbols[i+1])] += freq
if not pairs:
break
# 计算 score,选最大
best_pair = max(pairs, key=lambda p: pairs[p] / (symbol_freq[p[0]] * symbol_freq[p[1]]))
merges.append(best_pair)
bigram = ' '.join(best_pair)
replacement = ''.join(best_pair)
new_vocab = {}
for word, freq in vocab.items():
new_word = word.replace(bigram, replacement)
new_vocab[new_word] = freq
vocab = new_vocab
return merges, vocab
3. SentencePiece (Unigram 语言模型)
原理
SentencePiece 是 Google 推出的分词框架,它不依赖预分词(即不需要事先按空格切分),而是把整个输入视为原始字节流 ,直接学习子词。虽然它支持 BPE,但其最具代表性的算法是Unigram 语言模型,配合子词正则化。
核心思想:从一个很大的候选词汇表(如所有可能的子串)出发,假设每个子词的出现相互独立,句子的概率为各子词概率的乘积:
P ( s e n t e n c e ) = ∏ x ∈ s e g m e n t a t i o n p ( x ) P(sentence) = \prod_{x \in segmentation} p(x) P(sentence)=∏x∈segmentationp(x)
通过期望最大化(EM)算法优化训练数据的边缘似然,同时逐步剪掉概率较低的候选子词,直到词汇表达到指定大小。训练完成后,编码可以用维特比算法寻找概率最大的分割,也可以根据概率采样多种分割以实现正则化。
SentencePiece 把空格编码为 _(或其它元符号),并将所有字符统一处理,非常适合多语言或无需分词的场景(如中文、日文)。
举例
训练数据:"low lower newest widest"
- 初始候选词汇:所有单个字符
l, o, w, e, r, n, s, t, d, i, _(空格)以及高频率的 bigram(如lo,ow,er,es, ...)。 - 给每个候选子词均匀概率,然后开始 EM 迭代:
- E 步:计算每个句子所有可能分割下各子词的期望出现次数。
- M 步:用期望计数重新估计子词概率。
- 每轮剪掉概率最低的 20% 子词,重复直到词汇表大小符合要求。
Python 实现
python
import math, re
from collections import Counter, defaultdict
def init_vocab(texts, max_len=3):
"""从文本中收集所有长度 <= max_len 的子串作为初始候选词汇"""
vocab = Counter()
for text in texts:
# 将空格替换为特殊标记,使其可分割
text = text.replace(' ', '_')
for i in range(len(text)):
for j in range(1, max_len+1):
if i+j <= len(text):
vocab[text[i:i+j]] += 1
# 只保留出现次数足够多的候选(例如至少2次)
return {w: c for w, c in vocab.items() if c >= 2}
def viterbi_segment(text, vocab_probs):
"""用动态规划找概率最大的分割路径"""
n = len(text)
dp = [0.0] * (n+1)
dp[0] = 1.0
back = [0] * (n+1)
for i in range(n):
if dp[i] == 0:
continue
for j in range(i+1, n+1):
sub = text[i:j]
if sub in vocab_probs:
prob = dp[i] * vocab_probs[sub]
if prob > dp[j]:
dp[j] = prob
back[j] = i
# 回溯
segments = []
idx = n
while idx > 0:
prev = back[idx]
segments.append(text[prev:idx])
idx = prev
return segments[::-1]
def train_unigram(texts, vocab_size=100, num_iters=10):
"""简化的 Unigram 训练(Viterbi 近似 EM)"""
# 初始词汇表
raw_vocab = init_vocab(texts, max_len=4)
vocab = list(raw_vocab.keys())
print("初始候选词汇数:", len(vocab))
# 均匀概率
probs = {w: 1/len(vocab) for w in vocab}
for it in range(num_iters):
# 用维特比硬分割,并统计频率
counts = Counter()
total = 0
for text in texts:
text = text.replace(' ', '_')
seg = viterbi_segment(text, probs)
for sub in seg:
counts[sub] += 1
total += 1
# 重新估计概率
probs = {w: counts[w]/total for w in vocab if counts[w] > 0}
# 剪枝:如果词汇量超过目标,去掉概率最低的
while len(probs) > vocab_size:
worst = min(probs, key=probs.get)
del probs[worst]
vocab = list(probs.keys())
print(f"Iter {it+1}, vocab size: {len(vocab)}")
return probs
# ---- 使用演示 ----
texts = ["low lower newest widest"]
probs = train_unigram(texts, vocab_size=20, num_iters=10)
print("最终词汇:", sorted(probs.keys()))
三者优缺点对比
| 方法 | 优点 | 缺点 |
|---|---|---|
| BPE | 简单、高效;完全基于频率,可解释性强;训练速度快;编码规则确定。 | 依赖预分词;可能合并不合理的子词;无法解决多分割歧义;对低频对容易过拟合。 |
| WordPiece | 合并准则基于概率最大化,子词更具语言学意义;结合 ## 前缀,边界清晰;在 BERT 等模型中效果好。 |
训练开销稍大于 BPE;需维护单符号频率;依赖预分词;贪心分词可能非最优。 |
| SentencePiece | 不依赖预分词,将输入视为字节流,天然支持所有语言;Unigram 提供概率模型和子词正则化,增强鲁棒性;空格可见标记。 | 训练复杂(EM),速度慢;实现难度高;Viterbi 解码开销较大;超参数调节需经验。 |
三者都在现代 NLP 中被广泛使用。BPE 适合快速实现与清晰规则,WordPiece 在理解型模型(如 BERT)中平衡了效果与速度,而 SentencePiece(Unigram)提供了最灵活、最一致的多语言处理方案,尤其在需要避免预分词偏见的生产系统中优势明显。