分词器详解
分词器(Tokenizer)是 Transformer 模型的核心组件之一,负责将原始文本转换为模型可以理解的数字表示。本章将深入介绍分词器的工作原理和使用方法。
什么是分词器?
分词器执行以下转换:
原始文本 → 分词 → Token ID → 模型输入
"Hello world!"
↓
["Hello", "world", "!"]
↓
[15496, 995, 0]
↓
input_ids, attention_mask, token_type_ids
分词算法类型
现代分词器采用子词(Subword)分词策略,核心思想是将罕见词分解为更小的有意义单元。这种方式既能控制词表大小,又能处理未登录词(OOV)问题。
1. BPE (Byte Pair Encoding)
BPE 最初是一种数据压缩算法,后被引入到 NLP 领域作为分词方法。GPT-2、RoBERTa 等模型使用此算法。
算法原理:
BPE 通过迭代合并频率最高的相邻字符对来构建词表:
┌─────────────────────────────────────────────────────────────────┐
│ BPE 算法示例 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 初始状态: │
│ 词表: {h, e, l, o, w, r, d} │
│ 语料: {"low": 5, "lower": 2, "newest": 6, "widest": 3} │
│ │
│ 步骤1: 统计相邻字符对频率 │
│ "lo" 出现 5+2=7 次 → 最高频率 │
│ │
│ 步骤2: 合并 "l"+"o" → "lo" │
│ 词表: {h, e, l, o, w, r, d, lo} │
│ │
│ 步骤3: 继续统计,"lo"+"w"=6 次 最高 │
│ 合并 → "low" │
│ 词表: {h, e, l, o, w, r, d, lo, low} │
│ │
│ 重复直到达到目标词表大小... │
│ │
└─────────────────────────────────────────────────────────────────┘
BPE 的特点:
- 从字符级别开始,逐步构建更大的单元
- 合并操作基于频率统计,常见词会被保留为完整单元
- 使用特殊符号(如
Ġ)标记空格后的词首 - 对未见过的词可以进行字符级分解
from transformers import GPT2Tokenizer
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
text = "Transformers are awesome!"
tokens = tokenizer.tokenize(text)
print(tokens)
# ['Trans', 'form', 'ers', 'Ġare', 'Ġawesome', '!']
# Ġ 表示空格(在 GPT-2 的实现中,空格被编码为 Ġ)
2. WordPiece
WordPiece 与 BPE 类似,但合并策略不同。BERT、DistilBERT 等模型使用此算法。
算法原理:
WordPiece 不是基于频率,而是基于语言模型似然度来选择合并:
选择使合并后序列概率提升最大的字符对。
┌─────────────────────────────────────────────────────────────────┐
│ WordPiece 算法示例 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ BPE: 选择频率最高的字符对 │
│ WordPiece: 选择使语言模型概率提升最大的字符对 │
│ │
│ 示例:语料中 "unwanted" 出现多次 │
│ │
│ BPE 可能合并: "un" + "want" → "unwant" (频率高) │
│ WordPiece 可能合并: "want" + "ed" → "wanted" (概率提升大) │
│ │
│ WordPiece 使用 ## 前缀标记非词首子词: │
│ "unwanted" → ["un", "##want", "##ed"] │
│ 或如果词表中已有 "wanted": │
│ "unwanted" → ["un", "wanted"] │
│ │
└─────────────────────────────────────────────────────────────────┘
WordPiece 的特点:
- 使用
##前缀标记子词(表示该子词不能作为词的开头) - 基于语言模型概率而非纯粹频率
- 更倾向于保留语义完整的子词单元
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
text = "unhappiness"
tokens = tokenizer.tokenize(text)
print(tokens)
# ['un', '##ha', '##pp', '##iness'] 或 ['un', '##happiness']
# 取决于词表
text = "Transformers are awesome!"
tokens = tokenizer.tokenize(text)
print(tokens)
# ['transformers', 'are', 'awesome', '!']
3. Unigram
Unigram 采用概率模型,从大到小逐步删减词表。T5、ALBERT 等模型使用此算法。
算法原理:
Unigram 从一个足够大的初始词表开始,逐步移除对总概率贡献最小的 token:
┌─────────────────────────────────────────────────────────────────┐
│ Unigram 算法示例 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 初始词表: 包含所有可能的子词 │
│ ["a", "b", "c", "ab", "bc", "abc", ...] │
│ │
│ 步骤1: 计算每个 token 在最优分词路径中的期望频率 │
│ │
│ 步骤2: 计算移除每个 token 后的损失(困惑度增加量) │
│ │
│ 步骤3: 移除损失最小的 token(通常移除 10-20%) │
│ │
│ 步骤4: 重复直到词表达到目标大小 │
│ │
│ 最终结果: 词表中保留对语言模型最有价值的子词 │
│ │
└─────────────────────────────────────────────────────────────────┘
Unigram 的特点:
- 一个词可能有多种分词方式,算法返回概率最高的一种
- 使用
▁(下划线)标记词首 - 词表生成后,推理时分词路径是概率最优的
- 更好地处理歧义分词
from transformers import T5Tokenizer
tokenizer = T5Tokenizer.from_pretrained("t5-small")
text = "Transformers are awesome!"
tokens = tokenizer.tokenize(text)
print(tokens)
# ['▁Transformers', '▁are', '▁awesome', '!']
# ▁ 表示词首(该 token 可以出现在词的开头)
4. SentencePiece
SentencePiece 是一个语言无关的分词框架,将文本视为原始字节序列处理。XLNet、Marian 等模型使用。
算法原理:
SentencePiece 将输入文本视为字节序列,然后应用 BPE 或 Unigram 算法:
┌─────────────────────────────────────────────────────────────────┐
│ SentencePiece 特点 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 语言无关:不依赖预分词(空格分隔) │
│ │
│ 2. 字节级:可以直接处理任意 Unicode 字符 │
│ │
│ 3. 端到端:从原始文本到 token ID 一站式处理 │
│ │
│ 示例: │
│ 英文: "Hello world" → ["▁Hello", "▁world"] │
│ 中文: "你好世界" → ["▁你好", "世界"] │
│ 混合: "Hello世界" → ["▁Hello", "世界"] │
│ │
└─────────────────────────────────────────────────────────────────┘
SentencePiece 的优势:
- 不需要预分词,适合中日韩等无空格分隔的语言
- 统一处理多语言文本
- 可以直接处理原始文本流
from transformers import XLNetTokenizer
tokenizer = XLNetTokenizer.from_pretrained("xlnet-base-cased")
text = "Transformers are awesome!"
tokens = tokenizer.tokenize(text)
print(tokens)
算法对比总结
| 特性 | BPE | WordPiece | Unigram |
|---|---|---|---|
| 代表模型 | GPT-2, RoBERTa | BERT, DistilBERT | T5, ALBERT |
| 构建方式 | 频率合并 | 概率提升合并 | 概率删减 |
| 子词标记 | Ġ (空格) | ## (非词首) | ▁ (词首) |
| 分词确定性 | 确定性 | 确定性 | 概率最优 |
| 训练速度 | 快 | 中等 | 较慢 |
| 适用场景 | 通用 | 理解任务 | 生成任务 |
AutoTokenizer 使用
基本用法
from transformers import AutoTokenizer
# 自动加载对应的分词器
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
# 编码文本
encoding = tokenizer("Hello, world!")
print(encoding)
# {'input_ids': [101, 7592, 1010, 2088, 102],
# 'attention_mask': [1, 1, 1, 1, 1]}
编码选项
# 单条文本编码
text = "Hello, world!"
encoding = tokenizer(
text,
padding=True, # 填充
truncation=True, # 截断
max_length=512, # 最大长度
return_tensors="pt" # 返回 PyTorch 张量
)
print(encoding)
批量编码
texts = [
"Hello, world!",
"This is a longer sentence that might need padding."
]
# 批量编码
encoding = tokenizer(
texts,
padding=True, # 自动填充到最长
truncation=True,
max_length=512,
return_tensors="pt"
)
print(f"Input IDs shape: {encoding['input_ids'].shape}")
print(f"Attention mask shape: {encoding['attention_mask'].shape}")
编码方法详解
encode 方法
# 返回 token IDs 列表
token_ids = tokenizer.encode("Hello, world!")
print(token_ids)
# [101, 7592, 1010, 2088, 102]
# 添加特殊标记
token_ids = tokenizer.encode("Hello, world!", add_special_tokens=True)
# 限制长度
token_ids = tokenizer.encode("Hello, world!", max_length=5, truncation=True)
encode_plus 方法
# 返回完整的编码信息
encoding = tokenizer.encode_plus(
"Hello, world!",
add_special_tokens=True,
max_length=512,
padding='max_length',
truncation=True,
return_attention_mask=True,
return_tensors='pt'
)
print(encoding.keys())
# dict_keys(['input_ids', 'attention_mask'])
call 方法(推荐)
# 最灵活的方法,支持单条/批量、单句/句对
# 单句
encoding = tokenizer("Hello, world!")
# 句对(用于 NLI、问答等任务)
encoding = tokenizer(
"What is the capital of France?",
"Paris is the capital of France."
)
# 批量句对
encoding = tokenizer(
["Question 1", "Question 2"],
["Answer 1", "Answer 2"],
padding=True,
truncation=True
)
解码方法
decode 方法
# 将 token IDs 转换回文本
token_ids = [101, 7592, 1010, 2088, 102]
text = tokenizer.decode(token_ids)
print(text)
# [CLS] Hello, world! [SEP]
# 跳过特殊标记
text = tokenizer.decode(token_ids, skip_special_tokens=True)
print(text)
# Hello, world!
batch_decode 方法
# 批量解码
batch_ids = [
[101, 7592, 1010, 2088, 102],
[101, 2023, 2003, 1037, 102]
]
texts = tokenizer.batch_decode(batch_ids, skip_special_tokens=True)
print(texts)
# ['Hello, world!', 'This is a']
特殊标记
常用特殊标记
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
print(f"CLS token: {tokenizer.cls_token} (ID: {tokenizer.cls_token_id})")
print(f"SEP token: {tokenizer.sep_token} (ID: {tokenizer.sep_token_id})")
print(f"PAD token: {tokenizer.pad_token} (ID: {tokenizer.pad_token_id})")
print(f"UNK token: {tokenizer.unk_token} (ID: {tokenizer.unk_token_id})")
print(f"MASK token: {tokenizer.mask_token} (ID: {tokenizer.mask_token_id})")
# BERT 输出示例:
# CLS token: [CLS] (ID: 101)
# SEP token: [SEP] (ID: 102)
# PAD token: [PAD] (ID: 0)
# UNK token: [UNK] (ID: 100)
# MASK token: [MASK] (ID: 103)
添加新标记
# 添加单个新标记
tokenizer.add_tokens(["new_token"])
# 添加多个新标记
tokenizer.add_tokens(["token1", "token2", "token3"])
# 添加特殊标记
tokenizer.add_special_tokens({'cls_token': '<s>'})
# 调整模型嵌入层大小(如果需要)
model.resize_token_embeddings(len(tokenizer))
高级功能
填充和截断策略
texts = ["Short.", "This is a much longer sentence that needs attention."]
# 填充策略
## padding=True - 填充到批次中最长
## padding='max_length' - 填充到 max_length
## padding=False - 不填充
# 截断策略
## truncation=True - 截断到模型最大长度
## truncation='only_first' - 只截断第一个序列
## truncation='only_second' - 只截断第二个序列
encoding = tokenizer(
texts,
padding='max_length',
max_length=20,
truncation=True
)
返回特定张量类型
# PyTorch 张量
encoding = tokenizer("Hello", return_tensors="pt")
# TensorFlow 张量
encoding = tokenizer("Hello", return_tensors="tf")
# NumPy 数组
encoding = tokenizer("Hello", return_tensors="np")
# Python 列表(默认)
encoding = tokenizer("Hello", return_tensors=None)
获取词表信息
# 词表大小
vocab_size = tokenizer.vocab_size
print(f"词表大小: {vocab_size}")
# 获取所有标记(谨慎使用,可能很大)
# vocab = tokenizer.get_vocab()
# ID 转 token
token = tokenizer.convert_ids_to_tokens(101)
print(f"ID 101 对应的 token: {token}")
# token 转 ID
token_id = tokenizer.convert_tokens_to_ids("[CLS]")
print(f"[CLS] 对应的 ID: {token_id}")
多语言支持
中文分词
# BERT 中文模型
tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")
text = "你好,世界!"
encoding = tokenizer(text)
print(tokenizer.convert_ids_to_tokens(encoding['input_ids']))
# ['[CLS]', '你', '好', ',', '世', '界', '!', '[SEP]']
多语言模型
# XLM-RoBERTa
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
# 支持 100 种语言
texts = {
"en": "Hello, world!",
"zh": "你好,世界!",
"ja": "こんにちは、世界!",
"fr": "Bonjour le monde!"
}
for lang, text in texts.items():
encoding = tokenizer(text)
print(f"{lang}: {tokenizer.convert_ids_to_tokens(encoding['input_ids'][:5])}")
保存和加载
保存分词器
# 保存到本地
tokenizer.save_pretrained("./my_tokenizer")
# 目录结构:
# ./my_tokenizer/
# ├── tokenizer_config.json
# ├── vocab.txt (或 tokenizer.json)
# └── special_tokens_map.json
加载本地分词器
# 从本地加载
tokenizer = AutoTokenizer.from_pretrained("./my_tokenizer")
# 从 Hugging Face Hub 加载
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
快速分词器
使用 Rust 实现的分词器
# 默认使用快速分词器(基于 Rust)
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased", use_fast=True)
# 检查是否使用快速分词器
print(f"使用快速分词器: {tokenizer.is_fast}")
# 快速分词器支持额外功能
encoding = tokenizer(
"Hello, world!",
return_offsets_mapping=True # 返回字符偏移映射
)
print(encoding['offset_mapping'])
# [(0, 0), (0, 5), (5, 6), (7, 12), (12, 13), (0, 0)]
批量处理性能对比
import time
texts = ["This is a test sentence."] * 1000
# 快速分词器
fast_tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased", use_fast=True)
start = time.time()
_ = fast_tokenizer(texts, padding=True, truncation=True)
fast_time = time.time() - start
# 慢速分词器
slow_tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased", use_fast=False)
start = time.time()
_ = slow_tokenizer(texts, padding=True, truncation=True)
slow_time = time.time() - start
print(f"快速分词器: {fast_time:.4f}s")
print(f"慢速分词器: {slow_time:.4f}s")
print(f"加速比: {slow_time/fast_time:.2f}x")
实际应用示例
文本分类数据预处理
from transformers import AutoTokenizer
import torch
def prepare_classification_data(texts, labels, tokenizer_name="bert-base-uncased", max_length=512):
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
encodings = tokenizer(
texts,
truncation=True,
padding=True,
max_length=max_length,
return_tensors="pt"
)
labels = torch.tensor(labels)
return encodings, labels
# 使用示例
texts = [
"This movie was fantastic!",
"I didn't like this film at all."
]
labels = [1, 0] # 1: positive, 0: negative
encodings, labels = prepare_classification_data(texts, labels)
print(f"Input IDs shape: {encodings['input_ids'].shape}")
print(f"Labels shape: {labels.shape}")
问答任务数据预处理
def prepare_qa_data(questions, contexts, answers, tokenizer_name="bert-base-uncased"):
tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
# 编码问题和上下文
encodings = tokenizer(
questions,
contexts,
truncation=True,
padding=True,
max_length=512,
return_tensors="pt"
)
# 计算答案的起始和结束位置
start_positions = []
end_positions = []
for i, (question, context, answer) in enumerate(zip(questions, contexts, answers)):
# 找到答案在上下文中的字符位置
answer_start = context.find(answer)
answer_end = answer_start + len(answer)
# 获取字符到 token 的映射
encoding = tokenizer(question, context)
offsets = encoding.offset_mapping
# 找到对应的 token 位置
start_token = end_token = 0
for idx, (start, end) in enumerate(offsets):
if start <= answer_start < end:
start_token = idx
if start < answer_end <= end:
end_token = idx
break
start_positions.append(start_token)
end_positions.append(end_token)
encodings['start_positions'] = torch.tensor(start_positions)
encodings['end_positions'] = torch.tensor(end_positions)
return encodings
# 使用示例
questions = ["What is the capital of France?"]
contexts = ["Paris is the capital of France."]
answers = ["Paris"]
encodings = prepare_qa_data(questions, contexts, answers)
常见问题
1. 处理长文本
# 方法1:截断
tokenizer(text, truncation=True, max_length=512)
# 方法2:滑动窗口
def sliding_window_tokenize(text, tokenizer, max_length=512, stride=256):
tokens = tokenizer(text, add_special_tokens=False)
input_ids = tokens['input_ids']
chunks = []
for i in range(0, len(input_ids), stride):
chunk = input_ids[i:i + max_length - 2] # 留出特殊标记位置
chunks.append(chunk)
return chunks
2. 处理未知词汇
text = "This is a rarewordxyz"
# 查看未知词汇的处理
tokens = tokenizer.tokenize(text)
print(tokens)
# 检查词汇是否在词表中
print(f"'rarewordxyz' in vocab: {'rarewordxyz' in tokenizer.get_vocab()}")
3. 对齐不同分词器
# 当需要对比不同模型的输出时
text = "Hello, world!"
bert_tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
gpt2_tokenizer = AutoTokenizer.from_pretrained("gpt2")
bert_tokens = bert_tokenizer.tokenize(text)
gpt2_tokens = gpt2_tokenizer.tokenize(text)
print(f"BERT: {bert_tokens}")
print(f"GPT-2: {gpt2_tokens}")
下一步
掌握分词器后,你可以继续学习: