跳到主要内容

分词器详解

分词器(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 不是基于频率,而是基于语言模型似然度来选择合并:

score(A,B)=P(AB)P(A)×P(B)\text{score}(A, B) = \frac{P(AB)}{P(A) \times P(B)}

选择使合并后序列概率提升最大的字符对。

┌─────────────────────────────────────────────────────────────────┐
│ 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)

算法对比总结

特性BPEWordPieceUnigram
代表模型GPT-2, RoBERTaBERT, DistilBERTT5, 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}")

下一步

掌握分词器后,你可以继续学习: