NLP 分词
大约 14 分钟约 4188 字
NLP 分词
简介
分词(Tokenization)是 NLP 的基础步骤,将原始文本转换为模型可处理的 token 序列。从基于规则的分词到 BPE、WordPiece 和 SentencePiece,分词策略直接影响词表大小、未登录词处理和模型效果。理解分词是排查 NLP 模型问题的第一步。
分词在 NLP 管线中的位置是"第一道关卡"——所有后续的嵌入、编码、注意力计算都建立在分词结果之上。分词的错误或不合理会导致信息丢失,这种损失无法通过后续模型来弥补。
分词技术的发展经历了三个阶段:基于规则的方法(如中文的正向最大匹配)、统计方法(如 HMM、CRF 分词)和子词方法(BPE、WordPiece、SentencePiece)。现代大语言模型几乎都采用子词分词,因为它在词表大小和语义完整性之间取得了最佳平衡。
从信息论的角度看,分词本质上是一种有损压缩:将连续的文本信号离散化为有限的 token 序列。好的分词方案应该最大化压缩效率(用更少的 token 表示更多信息),同时最小化语义损失(保持语义边界的完整性)。
特点
分词粒度的权衡
| 粒度 | 示例 | 词表大小 | OOV 问题 | 序列长度 | 语义完整性 |
|---|---|---|---|---|---|
| 字符级 | 深/度/学/习 | 极小(~100) | 无 | 极长 | 差 |
| 子词级 | 深度/学习/的 | 中等(30K-100K) | 几乎无 | 适中 | 较好 |
| 词级 | 深度学习/是/人工智能 | 大(100K+) | 严重 | 较短 | 好 |
| BPE | 深度/学习/是/人工/智能 | 可控(32K) | 无 | 适中 | 较好 |
实现
# 示例1:对比不同分词粒度
text = "自然语言处理是人工智能的重要方向"
# 字符级分词
char_tokens = list(text)
print(f"字符级 ({len(char_tokens)} tokens): {char_tokens[:10]}...")
# 词级分词(基于 jieba)
import jieba
word_tokens = list(jieba.cut(text))
print(f"词级 ({len(word_tokens)} tokens): {word_tokens}")
# 子词级分词(BERT WordPiece)
from transformers import BertTokenizer
bert_tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
subword_tokens = bert_tokenizer.tokenize(text)
print(f"子词级 ({len(subword_tokens)} tokens): {subword_tokens}")
# 编码为 ID
ids = bert_tokenizer.encode(text)
print(f"Token IDs: {ids}")
print(f"解码还原: {bert_tokenizer.decode(ids)}")# 示例2:BPE 分词算法原理
from collections import Counter
def train_bpe(corpus, vocab_size=100, num_merges=50):
"""简化的 BPE 训练过程"""
# 将每个词拆成字符序列(末尾加 </w> 标记词尾)
word_freqs = Counter(corpus.split())
splits = {word: list(word) + ['</w>'] for word in word_freqs}
merges = []
for i in range(num_merges):
# 统计相邻 token 对的频率
pair_freqs = Counter()
for word, freq in word_freqs.items():
tokens = splits[word]
for j in range(len(tokens) - 1):
pair = (tokens[j], tokens[j + 1])
pair_freqs[pair] += freq
if not pair_freqs:
break
# 合并频率最高的 token 对
best_pair = pair_freqs.most_common(1)[0][0]
merges.append(best_pair)
for word in splits:
tokens = splits[word]
new_tokens = []
j = 0
while j < len(tokens):
if j < len(tokens) - 1 and (tokens[j], tokens[j+1]) == best_pair:
new_tokens.append(tokens[j] + tokens[j+1])
j += 2
else:
new_tokens.append(tokens[j])
j += 1
splits[word] = new_tokens
return merges
# 演示
corpus = "low lower lowest low low lower low"
merges = train_bpe(corpus, num_merges=10)
print("BPE 合并规则:")
for i, (a, b) in enumerate(merges[:5]):
print(f" {i+1}. {a} + {b} -> {a+b}")# 示例3:SentencePiece 多语言分词
# pip install sentencepiece
import sentencepiece as spm
# 训练 SentencePiece 模型(实际使用时需要更大的语料)
# spm.SentencePieceTrainer.train(
# input='corpus.txt',
# model_prefix='my_model',
# vocab_size=32000,
# model_type='bpe',
# character_coverage=0.9995,
# )
# 使用预训练的 SentencePiece 模型
# sp = spm.SentencePieceProcessor()
# sp.load('my_model.model')
# 多种编码方式演示(用概念说明)
text_cn = "深度学习改变了自然语言处理"
text_en = "Deep learning has transformed NLP"
# 不同模型的 token 化结果对比
from transformers import AutoTokenizer
models = {
"BERT-base-chinese": "bert-base-chinese",
"GPT-2": "gpt2",
}
for name, model_id in models.items():
try:
tok = AutoTokenizer.from_pretrained(model_id)
tokens = tok.tokenize(text_en)
print(f"{name} 分词 '{text_en}': {tokens}")
except Exception as e:
print(f"{name}: {e}")# 示例4:分词对模型输入的影响分析
from transformers import AutoTokenizer
def analyze_tokenization(tokenizer_name, texts):
"""分析不同分词器对文本的切分效果和序列长度"""
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
for text in texts:
tokens = tokenizer.tokenize(text)
ids = tokenizer.encode(text, add_special_tokens=True)
print(f"文本: {text}")
print(f" Token数: {len(tokens)}, 含特殊token的ID数: {len(ids)}")
print(f" Tokens: {tokens[:15]}{'...' if len(tokens) > 15 else ''}")
print()
texts = [
"这是一个简单的中文句子。",
"This is a simple English sentence.",
"深度学习(Deep Learning)是AI的核心技术。",
]
# 分析中文 BERT 的分词效果
analyze_tokenization("bert-base-chinese", texts)
# 特殊 token 处理
tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")
print("特殊 Tokens:")
print(f" [CLS] id: {tokenizer.cls_token_id}")
print(f" [SEP] id: {tokenizer.sep_token_id}")
print(f" [PAD] id: {tokenizer.pad_token_id}")
print(f" [UNK] id: {tokenizer.unk_token_id}")
print(f" 🕳 id: {tokenizer.mask_token_id}")
print(f" 词表大小: {tokenizer.vocab_size}")深入理解:BPE 与 WordPiece 的区别
def compare_bpe_wordpiece():
"""BPE vs WordPiece 核心区别
相同点:
- 都是通过迭代合并子词来构建词表
- 都从字符级开始,逐步合并高频对
- 都能有效处理 OOV 问题
不同点——合并选择标准:
- BPE: 按相邻对的频率选择合并
score(pair) = count(pair)
合并最高频的对
- WordPiece: 按合并后的语言模型得分选择
score(pair) = count(pair) / (count(token_a) * count(token_b))
即合并后使得整个语料的似然增加最多的对
实际影响:
- BPE 倾向于合并高频的常见对,即使合并后的 token 不太"有意义"
- WordPiece 倾向于合并后语义更完整的对
- 在实际效果上差异不大,但 WordPiece 在低资源场景下可能更好
使用场景:
- BPE: GPT-2, GPT-3, GPT-4, RoBERTa, LLaMA
- WordPiece: BERT, DistilBERT, ALBERT
- Unigram: T5, XLNet (SentencePiece)
"""
print("BPE vs WordPiece:")
print(" BPE: 按频率合并 -> GPT 系列, RoBERTa")
print(" WordPiece: 按似然合并 -> BERT 系列")
print(" Unigram: 按损失裁剪 -> T5, XLNet")
print("\n核心区别: 合并选择标准不同,但最终效果接近")
compare_bpe_wordpiece()深入理解:特殊 Token 的作用
from transformers import AutoTokenizer
def explain_special_tokens():
"""特殊 Token 详解
不同模型架构使用不同的特殊 Token:
BERT 系列:
- [CLS]: 序列开头,分类任务使用其最终隐藏状态
- [SEP]: 分隔两个句子(句子对任务)
- [PAD]: 填充到相同长度
- [UNK]: 未登录词
- [MASK]: 掩码语言模型训练时遮盖的 token
GPT 系列:
- <|endoftext|>: 文本结束标记
- <|pad|>: 填充(可选)
- 无 [CLS] 或 [SEP](自回归模型不需要)
T5:
- <pad>: 填充
- </s>: 序列结束
- <extra_id_0>, <extra_id_1>, ...: 用于 span 遮盖
LLaMA:
- <s>: 序列开始 (BOS)
- </s>: 序列结束 (EOS)
- <|reserved_special_token_*>: 保留的特殊 token
"""
models_to_check = [
("bert-base-chinese", ["[CLS]", "[SEP]", "[PAD]", "[UNK]", "[MASK]"]),
("gpt2", ["<|endoftext|>"]),
("t5-base", ["<pad>", "</s>", "<extra_id_0>"]),
]
for model_name, special_tokens in models_to_check:
try:
tokenizer = AutoTokenizer.from_pretrained(model_name)
print(f"\n{model_name} 特殊 Token:")
for token in special_tokens:
token_id = tokenizer.convert_tokens_to_ids(token)
print(f" {token:30s} -> {token_id}")
print(f" 词表大小: {tokenizer.vocab_size}")
except Exception as e:
print(f"\n{model_name}: {e}")
explain_special_tokens()深入理解:Byte-level BPE (BBPE)
def explain_bbpe():
"""Byte-level BPE (BBPE) — GPT-2/3/4 使用的分词方案
BBPE 的创新:
1. 将所有文本先编码为 UTF-8 字节序列(256 个可能值)
2. 然后在字节级别上执行 BPE
3. 词表中的 token 是字节的组合(而非 Unicode 字符)
优势:
- 完全无 OOV:任何 Unicode 字符都可以被编码为字节
- 多语言天然支持:不需要为每种语言单独设计预分词器
- 可以编码任意 Unicode 字符(包括 emoji、特殊符号)
- 词表更紧凑:256 字节基础 + 合并后的子词
GPT-2 的 BBPE 实现:
- 基础字节:256 个
- 合并后的 token:约 50,000 个
- 总词表:50,257(256 + 50,000 + 1 特殊 token)
一个有趣的观察:
- "hello" 可能被编码为 ["hel", "lo"]
- "你好" 可能被编码为 ["ä", "½", "å", "¥"](UTF-8 字节的映射)
- 这是因为 GPT-2 用了一个字节到可打印字符的映射表
"""
print("Byte-level BPE (BBPE) 要点:")
print(" 1. 文本 -> UTF-8 字节 -> BPE 合并")
print(" 2. 完全无 OOV,支持任意 Unicode")
print(" 3. GPT-2/3/4, RoBERTa 都使用 BBPE")
print(" 4. 词表大小: 50,257 (GPT-2)")
explain_bbpe()深入理解:词表大小的影响
def analyze_vocab_size_impact():
"""词表大小对模型的影响
词表大小的权衡:
小词表 (8K - 16K):
- 优点: 嵌入矩阵小,内存占用低
- 缺点: 序列更长,token 语义粒度细
- 适合: 边缘部署、移动端
中等词表 (32K - 50K):
- 优点: 平衡序列长度和语义粒度
- 缺点: 嵌入矩阵较大
- 适合: 通用场景 (BERT, GPT-2)
大词表 (100K+):
- 优点: 序列短,语义粒度粗
- 缺点: 嵌入矩阵大,训练数据稀疏
- 适合: 多语言大模型 (XLM-R, LLaMA)
嵌入矩阵的参数量 = vocab_size * embedding_dim
- BERT: 30,000 * 768 = 23M 参数(占总参数的 ~12%)
- GPT-2: 50,257 * 768 = 38.6M 参数(占总参数的 ~22%)
- LLaMA-2 70B: 32,000 * 5120 = 164M 参数(占总参数的 ~0.2%)
"""
configs = [
("BERT-base", 30000, 768),
("GPT-2", 50257, 768),
("LLaMA-2 7B", 32000, 4096),
("LLaMA-2 70B", 32000, 5120),
]
print(f"{'模型':>15s} | {'词表大小':>8s} | {'嵌入维度':>8s} | {'嵌入参数量':>12s}")
print("-" * 55)
for name, vocab, dim in configs:
params = vocab * dim
print(f"{name:>15s} | {vocab:>8d} | {dim:>8d} | {params/1e6:>10.1f}M")
analyze_vocab_size_impact()深入理解:中文分词的特殊挑战
def chinese_tokenization_challenges():
"""中文分词的特殊挑战
1. 没有天然的分词边界(不像英文有空格)
2. 分词粒度影响语义理解
- "南京市/长江大桥" vs "南京/市长/江大桥"
3. 新词不断涌现(网络用语、专业术语)
4. 同一字在不同上下文中有不同含义
主流中文模型的分词策略:
- BERT-base-chinese: 逐字分词(每个中文字是一个 token)
- 优点: 无 OOV,简单
- 缺点: 丢失词语边界信息,序列较长
- MacBERT / RoBERTa-wwm: 使用 Whole Word Masking
- 在训练时遮盖整个词而非单个字
- 需要预分词工具(如 jieba/LTP)确定词语边界
- ChatGLM / Qwen: 使用 SentencePiece BPE
- 可以合并常见字为词
- 更接近自然语言的处理方式
- LLaMA 中文适配: 使用扩展的 SentencePiece
- 在中英文混合语料上训练
- 支持中文子词
"""
from transformers import AutoTokenizer
text = "自然语言处理技术在工业界得到了广泛应用"
# BERT-base-chinese: 逐字分词
bert_tok = AutoTokenizer.from_pretrained("bert-base-chinese")
bert_tokens = bert_tok.tokenize(text)
print(f"BERT-base-chinese ({len(bert_tokens)} tokens): {bert_tokens}")
print("\n中文分词建议:")
print(" 1. 通用任务: BERT-base-chinese 逐字分词已足够")
print(" 2. 需要词语语义: 考虑使用 WWM 或 SentencePiece 模型")
print(" 3. 领域适配: 扩展词表,添加领域术语")
print(" 4. 长文本: 关注序列长度限制,考虑分段处理")
chinese_tokenization_challenges()训练自定义分词器
from tokenizers import Tokenizer, models, pre_tokenizers, decoders, trainers, processors
def train_custom_bpe_tokenizer():
"""使用 HuggingFace Tokenizers 库训练自定义 BPE 分词器
步骤:
1. 定义分词器类型(BPE, WordPiece, Unigram)
2. 设置预分词器(如何初步切分文本)
3. 准备训练语料
4. 训练分词器
5. 添加特殊 token 和后处理器
"""
# 1. 初始化 BPE 分词器
tokenizer = Tokenizer(models.BPE(unk_token="[UNK]"))
# 2. 设置预分词器(英文按空格和标点切分)
tokenizer.pre_tokenizer = pre_tokenizers.Whitespace()
# 3. 定义训练器
trainer = trainers.BpeTrainer(
vocab_size=30000,
special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"],
min_frequency=2, # 最少出现 2 次才合并
)
# 4. 模拟训练(实际使用时提供真实语料文件)
# files = ["corpus.txt"]
# tokenizer.train(files, trainer)
print("自定义 BPE 分词器训练流程已定义")
print("实际使用时: tokenizer.train(['corpus.txt'], trainer)")
print("保存: tokenizer.save('my_tokenizer.json')")
print("加载: Tokenizer.from_file('my_tokenizer.json')")
train_custom_bpe_tokenizer()分词效率优化
def tokenization_performance_tips():
"""分词效率优化建议
1. 使用 HuggingFace 的 fast tokenizer (Rust 实现)
- 比纯 Python 实现快 10-100 倍
- AutoTokenizer 默认使用 fast 版本
2. 批量编码
- tokenizer(texts, padding=True, truncation=True, return_tensors='pt')
- 比逐条编码快得多
3. 缓存分词结果
- 对于不变的文本,缓存编码结果避免重复计算
- datasets 库的 map 函数支持缓存
4. 避免重复加载分词器
- 在服务启动时加载一次,全局复用
"""
print("分词效率优化:")
print(" 1. 使用 fast tokenizer (use_fast=True)")
print(" 2. 批量编码而非逐条编码")
print(" 3. 合理设置 max_length 和截断策略")
print(" 4. 避免频繁重新加载分词器")
tokenization_performance_tips()优点
缺点
总结
分词是 NLP 管线的第一道关卡,直接影响模型输入的质量和效率。选择合适的分词方案(BPE、WordPiece、SentencePiece)并理解其特性,是构建高质量 NLP 系统的基础。
在实际项目中,最重要的原则是:始终使用模型预训练时使用的分词器。不同的分词器会将相同的文本映射到不同的 token ID 序列,混用分词器会导致模型接收到完全错误的输入。
关键知识点
- BPE 通过迭代合并最高频的相邻字符对来构建词表,是 GPT 系列使用的分词方案
- WordPiece 基于 likelihood 而非频率选择合并对,是 BERT 使用的分词方案
- SentencePiece 将文本视为原始字节流,不依赖空格分词,适合中日韩等语言
- 特殊 token([CLS]、[SEP]、[PAD])在模型输入中有特定用途,不可随意替换
- BBPE 在字节级别执行 BPE,完全消除 OOV 问题
- 词表大小影响嵌入矩阵的参数量和序列长度
项目落地视角
- 领域适配时考虑训练自定义分词器或扩展现有词表(如添加医学术语)
- 长文本场景要关注 max_length 限制,选择合适的截断和分段策略
- 多模型协作时要统一分词方案,避免同一个词被映射到不同 ID
常见误区
- 忽略分词对模型效果的直接影响——换一个分词器可能带来显著的性能差异
- 不检查 UNK token 的比例——高 UNK 比例意味着词表覆盖不足
- 混用不同模型的分词器和模型——BERT 的分词器不能直接用在 GPT 上
- 忽略特殊 token 的正确使用——忘记添加 [CLS] 或 [SEP] 会导致模型输入错误
- 对中文使用英文分词器——会导致每个中文字都被拆为多个 byte-level token
进阶路线
- 深入学习 Byte-level BPE(BBPE),理解 GPT-2/4 的分词原理
- 掌握 HuggingFace Tokenizers 库的自定义训练和优化
- 探索 Unicode 和字节级别的分词方案如何解决多语言问题
- 了解大模型中词表大小对性能和推理效率的影响
- 研究多语言分词和跨语言模型的分词策略
适用场景
- 任何需要将文本输入给模型的 NLP 任务
- 多语言文本处理和跨语言模型构建
- 领域定制:医疗、法律、金融等专业文本的分词适配
落地建议
- 优先使用模型配套的预训练分词器,保证分词与预训练一致
- 领域数据中频繁出现 UNK token 时,考虑扩展词表或训练专用分词器
- 上线前验证分词速度是否满足推理延迟要求
排错清单
- 模型输出乱码:检查分词器是否与模型匹配,decode 编码是否正确
- 效果突然下降:确认分词器版本是否被意外更换
- 推理速度慢:检查是否有过长的序列,考虑截断或分段策略
- UNK token 比例高:检查词表覆盖,考虑扩展词表
复盘问题
- 你的项目中使用了哪种分词方案?为什么选择它?
- 词表中 UNK token 在目标数据上的出现频率是多少?
- 是否对比过不同分词粒度对下游任务效果的影响?
