分词预处理和后处理
6.TOKENIZERS 库
- 当我们需要微调模型时,我们需要使用与模型预训练相同的tokenizer
- 但是当我们想从头开始训练模型时应该选用哪个 tokenizer?使用来自其他领域或语言的语料库上预训练的 tokenizer 通常是不理想的
本章讲述如何在一份文本语料库上训练一个全新的 tokenizer,然后将使用它来预训练语言模型。
⚠️ 训练 tokenizer 与训练模型不同!模型训练使用随机梯度下降使每个 batch 的 loss 小一点。
它本质上是随机的(这意味着在即使两次训练的参数和算法完全相同,你也必须设置一些随机数种子才能获得相同的结果)。
训练 tokenizer 是一个统计过程,它试图确定哪些子词最适合为给定的语料库选择,确定的过程取决于分词算法。
它是确定性的,这意味着在相同的语料库上使用相同的算法进行训练时,得到的结果总是相同的。
1. 根据已有的 tokenizer 训练新的 tokenizer
如何利用现有的分词器(Tokenizer)架构,在新的语料库上训练一个新的分词器?。
- 为什么要训练新的分词器?
当现有的预训练模型分词器(例如 GPT-2 的分词器)在处理与之领域差异巨大的文本(如中文、法律文档或 Python 代码)时, 效率会非常低。 为了让模型更好地理解特定领域的词汇、减少序列长度并提升计算效率,我们需要根据自己的数据集重新训练分词器。
- tokenizer 变化 → 模型 embedding 层必须重新训练 【即训练了新的分词器生成了新的词汇表,模型也必须基于新的词汇表重新微调训练】
方案对比
┌──────────────────┬──────────────────────────────┬────────────────────────────────────┬──────────────────────────┐
│ 方案 │ 做法 │ 优点 │ 缺点 │
├──────────────────┼──────────────────────────────┼────────────────────────────────────┼──────────────────────────┤
│ 基于旧 tokenizer │ train_new_from_iterator() │ 继承已有词汇、训练快、保留通用能力 │ 受旧 tokenizer 结构限制 │
├──────────────────┼──────────────────────────────┼────────────────────────────────────┼──────────────────────────┤
│ 从头训练 │ 直接用 ByteLevelBPETokenizer │ 完全定制、不受限制 │ 需要学习基础词汇、训练慢 │
└──────────────────┴──────────────────────────────┴────────────────────────────────────┴──────────────────────────┘
为什么脚本选择"基于旧 tokenizer"
1. GPT-2 词汇表已经很好
- 已覆盖常用英文单词(the, function, return 等)
- 已覆盖数字、标点、特殊字符
- 不需要重新学习这些基础组合
2. 只需补充代码特有模式
- Python 缩进 (4空格)
- 操作符 ->, **, :=
- 函数命名风格 get_user_id
- 用新数据补充这些即可
3. 节省时间
- BPE 从字节级别开始,需要逐步合并成词
- 旧 tokenizer 已经完成了前几万步合并
如果从头训练
from tokenizers import ByteLevelBPETokenizer
# 从零开始训练
tokenizer = ByteLevelBPETokenizer()
tokenizer.train_from_iterator(
training_corpus,
vocab_size=52000,
min_frequency=2,
special_tokens=["<s>", "<pad>", "</s>", "<unk>", "<mask>"]
)
这样会更"纯净",但:
- 前 5000-10000 个 token 可能是基础字母组合(浪费词汇表位置)
- 需要更多训练数据才能达到相同质量
结论
脚本的做法是工程上的务实选择:借用 GPT-2 已完成的"基础工作",只针对代码领域做增量学习。
如果你追求完全定制化,从头训练也是可以的,只是成本更高、收益未必更大。- 分词器训练 vs 模型训练
- 模型训练:基于随机梯度下降,具有随机性,旨在最小化损失函数。
- 分词器训练:是一个统计过程。它通过扫描语料库来识别最频繁出现的子词(Subwords)。它是确定性的,即在相同算法和数据下,结果始终一致。
- 核心步骤与 API
如何为 Python 代码训练一个新的 GPT-2 分词器:
- 准备语料库(Assembling a corpus)
- 推荐使用 Python 生成器(Generator) 来加载数据。这样可以分批次将文本喂给分词器,避免一次性将数 GB 的数据加载到内存中导致 OOM(内存溢出)。
- 使用
train_new_from_iterator():它允许你继承现有分词器的特殊字符**(如 GPT-2 的空格符Ġ)和算法逻辑 ,但会根据新数据生成全新的词表(Vocabulary)。
"""
CodeSearchNet Tokenizer Training Script
训练一个专门用于Python代码的tokenizer
"""
# ==================== 第一部分:加载数据集 ====================
print("\n" + "=" * 60)
print("第一部分:加载 CodeSearchNet 数据集")
print("=" * 60 + "\n")
from datasets import load_dataset
# 加载CodeSearchNet数据集的Python子集
# 该数据集包含大量Python函数代码,适合训练代码tokenizer
raw_datasets = load_dataset("code_search_net", "python")
# 打印数据集结构信息
print("数据集结构:")
print(raw_datasets)
"""
DatasetDict({
train: Dataset({
features: ['repository_name', 'func_path_in_repository', 'func_name', 'whole_func_string', 'language', 'func_code_string', 'func_code_tokens', 'func_documentation_string', 'func_documentation_tokens', 'split_name', 'func_code_url'],
num_rows: 412178
})
test: Dataset({
features: ['repository_name', 'func_path_in_repository', 'func_name', 'whole_func_string', 'language', 'func_code_string', 'func_code_tokens', 'func_documentation_string', 'func_documentation_tokens', 'split_name', 'func_code_url'],
num_rows: 22176
})
validation: Dataset({
features: ['repository_name', 'func_path_in_repository', 'func_name', 'whole_func_string', 'language', 'func_code_string', 'func_code_tokens', 'func_documentation_string', 'func_documentation_tokens', 'split_name', 'func_code_url'],
num_rows: 23107
})
})
"""
# 打印一个示例函数,查看数据格式
print("\n示例函数(索引123456):")
print(raw_datasets["train"][123456]["whole_func_string"])
"""
def oauth_token_create(self, data, **kwargs):
api_path = "/api/v2/oauth/tokens.json"
return self.call(api_path, method="POST", data=data, **kwargs)
"""
# ==================== 第二部分:准备训练语料 ====================
print("\n" + "=" * 60)
print("第二部分:准备训练语料(Training Corpus)")
print("=" * 60 + "\n")
def get_training_corpus():
"""
生成器函数:分批提取训练语料
tokenizer训练需要迭代器形式的数据,
每次返回一批文本样本,避免一次性加载全部数据到内存。
Args:
无参数,使用全局的raw_datasets
Yields:
list: 包含1000个函数字符串的批次
"""
dataset = raw_datasets["train"]
# 每1000个样本为一批,逐步遍历整个训练集
for start_idx in range(0, len(dataset), 1000):
samples = dataset[start_idx: start_idx + 1000]
# 提取完整函数字符串作为训练文本
yield samples["whole_func_string"]
# 创建训练语料迭代器
training_corpus = get_training_corpus()
print("训练语料迭代器已创建,将分批提供函数代码样本")
# 打印模型的 embedding 层参数
from transformers import GPT2LMHeadModel
model = GPT2LMHeadModel.from_pretrained('gpt2')
print(model.config.vocab_size) # 模型使用的词汇表大小
print(model.config.n_embd) # 每个 token 用多少维的向量表示
"""
50257
768
"""
# ==================== 第三部分:加载旧tokenizer ====================
print("\n" + "=" * 60)
print("第三部分:加载基础 GPT-2 Tokenizer")
print("=" * 60 + "\n")
from transformers import AutoTokenizer
# 加载预训练的GPT-2 tokenizer作为基础
# GPT-2 tokenizer主要针对自然语言,对代码的分词效果不佳
old_tokenizer = AutoTokenizer.from_pretrained("gpt2")
print("已加载 GPT-2 tokenizer 作为基础模型")
# 定义一个Python函数示例用于对比测试
example = '''def add_numbers(a, b):
"""Add the two numbers `a` and `b`."""
return a + b'''
# 使用旧tokenizer进行分词
tokens = old_tokenizer.tokenize(example)
print("\n使用 GPT-2 tokenizer 分词结果:")
print(tokens)
print(f"分词数量: {len(tokens)}")
"""
['def', 'Ġadd', '_', 'n', 'umbers', '(', 'a', ',', 'Ġb', '):', ..., Ġ`', 'b', '`', '."', '""', 'Ċ', 'Ġ', 'Ġ', 'Ġ', 'Ġreturn', 'Ġa', 'Ġ+', 'Ġb']
分词数量: 36
"""
# ==================== 第四部分:训练新tokenizer ====================
print("\n" + "=" * 60)
print("第四部分:训练新的代码专用 Tokenizer")
print("=" * 60 + "\n")
# 从旧tokenizer训练新版本,词汇表大小设为52000
# 训练过程中会学习Python代码的特殊模式(如缩进、函数名、操作符等)
# GPT-2 基础词汇表大小: GPT-2 原始 tokenizer 的词汇表大小是 50257 个 token
# 52000 比原始多出约 1743 个新 token 位置,这些额外位置用于学习 Python 代码特有的模式(如缩进 、操作符 ->、函数命名风格等)
print("开始训练新tokenizer(词汇表大小: 52000)...") # 自定义的
# train_new_from_iterator 只是在统计"哪些字符组合出现最频繁",然后把高频组合合并成新 token。全程是纯数学统计,没有任何神经网络参与。
tokenizer = old_tokenizer.train_new_from_iterator(training_corpus, 52000)
print("训练完成!")
# 使用新tokenizer对相同示例进行分词,对比效果
tokens = tokenizer.tokenize(example)
print("\n使用新训练的 tokenizer 分词结果:")
print(tokens)
print(f"分词数量: {len(tokens)}")
"""
['def', 'Ġadd', '_', 'numbers', '(', 'a', ',', 'Ġb', '):', 'ĊĠĠĠ', 'Ġ"', ....]
分词数量: 27
"""
# 对比说明:新tokenizer能更好地识别代码结构
# 例如:函数名、缩进空格、特殊字符等会被更合理地分割
# ==================== 第五部分:保存tokenizer ====================
print("\n" + "=" * 60)
print("第五部分:保存和上传 Tokenizer")
print("=" * 60 + "\n")
# 保存tokenizer到本地目录
tokenizer.save_pretrained("code-search-net-tokenizer")
print("Tokenizer 已保存到本地目录: code-search-net-tokenizer")
# 对比新旧词汇表
old_vocab = old_tokenizer.get_vocab()
new_vocab = tokenizer.get_vocab()
# 检查是否有差异
same_tokens = old_vocab == new_vocab
print(f"词汇表是否完全相同: {same_tokens}")
"""
词汇表是否完全相同: False
相对于旧的 tokenizer 变动了 62503 个 token(其中30380个旧token被替换,32123个新token被加入)。
如果你想要保留全部旧 vocab 只新增 1743 个,需要设置不同的参数:
tokenizer = old_tokenizer.train_new_from_iterator(
training_corpus,
vocab_size=52000,
new_tokens=None # 不强制替换旧token
)
但这通常不是最优做法——因为保留大量代码中不常用的自然语言 token 会浪费词汇表空间。
"""
# 上传tokenizer到Hugging Face Hub
# 需要已登录Hugging Face账号(huggingface-cli login)
# tokenizer.push_to_hub("code-search-net-tokenizer")
print("Tokenizer 已上传到 Hugging Face Hub")
print("\n" + "=" * 60)
print("全部完成!")
print("=" * 60 + "\n")
"""
新的数据有几十万条,可以把词汇表扩充到几十万的大小吗?
⏺ 可以,但不建议。原因如下:
词汇表大小的权衡
┌─────────────────┬───────────────────────────┬───────────────────────────────────────────────────────────────┐
│ 词汇表大小 │ 优点 │ 缺点 │
├─────────────────┼───────────────────────────┼───────────────────────────────────────────────────────────────┤
│ 大 (几十万) │ 每个词/token 都有唯一表示 │ Embedding 层参数爆炸、内存占用大、推理慢、很多 token 出现稀少. │
├─────────────────┼───────────────────────────┼───────────────────────────────────────────────────────────────┤
│ 适中 (5万-10万) │ 平衡表示能力和效率 │ 主流做法,经验验证有效 │
├─────────────────┼───────────────────────────┼───────────────────────────────────────────────────────────────┤
│ 小 (<5万) │ 参数少、速度快 │ 频繁拆分,序列过长 │
└─────────────────┴───────────────────────────┴───────────────────────────────────────────────────────────────┘
具体计算
假设模型 hidden_size = 768(类似 GPT-2 small):
┌────────────┬──────────────────────┬──────────┐
│ vocab_size │ Embedding 参数量 │ 相对大小 │
├────────────┼──────────────────────┼──────────┤
│ 52,000 │ 52,000 × 768 = 40M │ 基准 │
├────────────┼──────────────────────┼──────────┤
│ 100,000 │ 100,000 × 768 = 77M │ ~2倍 │
├────────────┼──────────────────────┼──────────┤
│ 300,000 │ 300,000 × 768 = 230M │ ~6倍 │
├────────────┼──────────────────────┼──────────┤
│ 500,000 │ 500,000 × 768 = 384M │ ~10倍 │
└────────────┴──────────────────────┴──────────┘
词汇表从 5万 扩到 50万,仅 Embedding 层就增加 340M 参数。
数据量 vs 词汇表大小
- 数据量 主要影响 tokenizer 学习合并规则的质量
- 词汇表大小 决定最终保留多少 token
即使有 40万条代码,真正高频出现的 token 模式远少于这个数字。大部分词会被拆分成子词(subword),而不是每个函数名都单独成为一个 token。
建议范围
对于代码 tokenizer:
- 50,000 - 100,000 是合理范围
- 超过 100,000 收益递减,成本显著上升
如果想扩充,可以尝试:
tokenizer = old_tokenizer.train_new_from_iterator(training_corpus, 100000) # 适度扩充
但几十万(300k+)的词汇表大小在实际中很少使用,除非有特殊需求(如多语言覆盖)。
"""
"""
┌────────────────────────────────────────────────────────────┐
│ tokenizer 变化 → 模型 embedding 层必须重新训练 │
├────────────────────────────────────────────────────────────┤
│ │
│ GPT-2 原模型: │
│ embedding 层形状 = [50257 × 768] │
│ 每个 token id 对应一个 768 维向量 │
│ │
│ 新 tokenizer: │
│ vocab_size = 52000 │
│ token id 映射完全不同(同个词可能变成不同 id) │
│ │
│ 问题: │
│ 1. 形状不匹配 → embedding 层需要扩展到 [52000 × 768] │
│ 2. id 映射变了 → 原 embedding 向量无法直接复用 │
│ ("def" 在旧 tokenizer id=100,新 tokenizer id=200) │
│ │
│ 解决方案: │
│ - 从头训练新模型 │
│ - 或保留原模型权重,初始化新 embedding,然后微调 │
│ │
└────────────────────────────────────────────────────────────┘
简单说:tokenizer 和模型 embedding 层是绑定的。vocab 变了,embedding 必须变,否则模型输出就是乱码。
"""- 训练前后的对比示例
以 Python 代码 def add_numbers(a, b): 为例:
- 旧分词器(GPT-2 英文版):会将空格、下划线、函数名拆解得很碎(如将
_numbers拆成多个部分),生成的 Token 数量多。 - 新分词器(Python 专用):学会了 Python 特有的模式,例如将缩进(四个空格)识别为一个单独的 Token
ĊĠĠĠ,显著压缩了序列长度。
2.训练新的分词器时会使用新的语料库,但是会基于之前的词汇表吗?
简单直接的回答是:分词算法逻辑会保留,但“词汇表”本身是推倒重来的。
- “继承”的是:分词的规则和架构
当你使用 train_new_from_iterator 并在代码中传入一个 old_tokenizer 时,新分词器会继承旧分词器的以下属性:
- 标准化逻辑 (Normalization):比如是否需要把大写转小写、是否移除重音符号。
- 预分词逻辑 (Pre-tokenization):比如是在空格处切分,还是在标点符号处切分。
- 特殊标记 (Special Tokens):比如起始符
[CLS]、结尾符[SEP]、填充符[PAD]等。 - 算法逻辑:如果旧的是 BPE(如 GPT-2),新的也会用 BPE;如果旧的是 WordPiece(如 BERT),新的也会用 WordPiece。
- “舍弃”的是:旧的词汇库和合并规则
新的语料库会产生一套完全不同的“零件库”:
- 词汇表 (Vocabulary) 是全新的:训练过程会扫描你的新语料库(比如 Python 代码或医学文献),统计哪些字符组合出现频率最高。如果你训练的是 Python 代码分词器,那么
self.、def、return可能会被识别为单个“零件”,而旧的通用英文词表里这些可能被拆得很碎。 - 合并规则 (Merges) 是重新生成的:在 BPE 等算法中,算法会根据新语料重新计算哪两个字符该合并。旧词表里的“单词组合优先度”在新训练中完全失效。
- 为什么要“推倒重来”而不是“增量更新”?
你可能会想,能不能在旧词表的基础上只增加新词?
- 冲突风险:不同的语料库统计特征不同。如果强行保留旧词表,旧词表里的大量通用词会占用宝贵的“词表槽位”(Vocab Size),导致新语料里的核心术语反而挤不进去。
- ID 混乱:模型(LLM)的 Embedding 层是和分词器的 ID 严格对应的。如果词表变了,旧模型的参数就完全没法用了。
- 独立性:训练新分词器通常是为了从零开始训练(Pre-train from scratch)一个新模型,或者进行大规模的领域适配。
总结
当你“基于旧分词器训练新分词器”时,你其实是在复用它的“模具”(分词架构和参数配置),但填充的是“新材料”(新语料)。最终得到的 tokenizer.json 文件中,你会发现它的词汇表(Vocab)和旧的完全不一样,它是为了最贴合你的新语料而生的。
3.快速分词器的特殊能力
介绍了 Fast Tokenizer(快速分词器) 的核心优势及其在复杂 NLP 任务(如命名实体识别 NER)中的高级应用。
- 快速 vs. 慢速分词器 (Fast vs. Slow Tokenizers)
- 慢速分词器:使用 Python 编写。
- 快速分词器:由
🤗 Tokenizers库提供,底层基于 Rust 编写。 - 优势:
- 高性能:在大批量文本处理时速度显著快于 Python 版本。
- 特殊功能:最重要的功能是 偏移映射 (Offset Mapping),它记录了每个 Token 对应原始文本的具体字符范围(起始和结束位置)。
- 偏移映射 (Offset Mapping) 的强大功能
快速分词器生成的 BatchEncoding 对象提供了许多额外方法,允许开发者在 Token、单词(Words)和字符(Characters)之间进行自由切换:
word_ids():获取每个 Token 对应原始文本中单词的索引(处理特殊 Token 时返回None)。tokens():直接查看分词后的子词。word_to_chars()/token_to_chars():将单词或 Token 映射回原始文本的字符跨度。char_to_word()/char_to_token():将原始字符索引映射回分词器生成的单词或 Token。
- 实战演示:复现 Token 分类 (NER) 管道
通过手动实现 token-classification 管道的过程,展示了快速分词器的应用:
- 数据流:文本 -> Token IDs -> 模型预测 (Logits) -> 标签 (Labels)。
- 处理子词问题:模型通常在子词级别进行预测(例如将 "Sylvain" 拆为
S,##yl,##va,##in)。 - 利用偏移量复现
start和end: - 在调用分词器时设置
return_offsets_mapping=True。 - 这样就可以知道每个被标记为实体的 Token 在原句中的确切位置。
- 实体分组 (Aggregation Strategy):
- 讲解了如何将多个连续的子词标签(如 I-PER)合并为一个完整的实体。
- 使用偏移量可以轻松地从原始文本中截取出完整的实体单词(如 "Hugging Face"),而无需编写针对特定分词器(如 BERT 的
##或 GPT 的Ġ)的复杂清理规则。
- 总结与意义
快速分词器不仅仅是为了提升速度,它提供的字符级位置信息是许多下游任务(如问答系统中的答案定位、信息抽取中的实体识别)的关键技术支撑。
4.快速分词器在问答任务中的应用
重点介绍了 Fast Tokenizer(快速分词器)在问答(Question Answering, QA)任务中的应用。
问答任务比简单的文本分类更复杂,因为它需要模型在一段长文本中定位答案的起始和结束位置。
- 问答模型的输出逻辑
与之前的任务不同,问答模型(如 BertForQuestionAnswering)不返回单一的分类 Logits,而是返回两个向量:
- Start Logits:预测答案开始位置的 Token 索引。
- End Logits:预测答案结束位置的 Token 索引。
- 处理长文本的挑战:滑动窗口(Sliding Window)
这是本节最关键的技术点。(Transformers)模型通常有最大输入长度限制(如 512 个 Token)。 如果背景文档(Context)非常长,直接截断会导致模型“看不见”位于文档后半部分的答案。
- 解决方案:快速分词器允许使用
return_overflowing_tokens=True和stride(步长) 参数。 - 效果:分词器会将一个长文档切分成多个相互重叠的子块(Chunks),确保答案不会因为刚好落在切分点而被遗漏。
- 快速分词器的核心功能:建立映射
为了将模型预测出的 Token 索引转换回用户能够理解的原始文本,快速分词器提供了以下关键辅助信息:
overflow_to_sample_mapping:当一个长示例被切成多个 Chunk 时,这个映射可以告诉我们每个 Chunk 属于原始数据集中的哪一个样本。offset_mapping:如前一节所述,它记录了每个 Token 在原句中的字符起止位置。
- 复杂的手动后处理逻辑
页面详细演示了如何不使用高层 API(Pipeline),而是手动处理模型输出:
- 识别有效索引:需要使用
sequence_ids来区分哪些 Token 属于“问题”,哪些属于“背景文档”。答案只能从“背景文档”部分提取。 - 分数计算与排序:模型会对每个可能的 [start, end] 组合计算得分。开发者需要通过循环找出得分最高的组合。
- 转换回字符:一旦确定了得分最高的 [start, end] Token 索引,利用
offset_mapping就能瞬间从原始文本中截取出答案字符串。
- 总结
这一节展示了快速分词器在处理长文档时的不可替代性。如果没有快速分词器提供的 stride 切片能力和 offset_mapping 字符追踪能力,处理大规模问答数据集并精准定位答案几乎是不可能完成的任务。
5.标准化和预分词
这一部分探讨了分词器的底层运作机制,即 标准化(Normalization)和预分词(Pre-tokenization)。 这是文本进入分词算法(如 BPE、WordPiece)之前的关键处理步骤。
1. 分词器的流水线 (The Tokenizer Pipeline)
一个完整的分词过程包含四个步骤,本节重点讨论前两步:
- Normalization(标准化):对原始文本进行初步清理。
- Pre-tokenization(预分词):将文本切分成初步的“块”(通常是单词)。
- Model(模型算法):应用 BPE 或 WordPiece 等算法进一步细分为子词。
- Post-processing(后处理):添加特殊标记(如
[CLS],[SEP])并生成注意力掩码。
2. 标准化 (Normalization)
标准化的目标是减少文本的冗余变化,使模型更容易学习。
- 常见操作:
- 去除重音符号(例如将
é变为e)。 - 大小写转换(Lowercase)。
- Unicode 规范化(例如处理不同编码下表现一致的字符)。
- 去除重音符号(例如将
- 实例:BERT 分词器会进行小写化和移除重音符号;而某些模型(如 RoBERTa)则选择不进行标准化以保留更多原始信息。
3. 预分词 (Pre-tokenization)
在文本被拆解为子词之前,预分词器会先将其划分为较小的单元(通常是单词)。
- 作用:它定义了分词的“边界”。分词模型在训练和推理时,不能跨越预分词确定的边界。也就是说,一个子词不能属于两个不同的单词。
- 不同模型的策略:
- BERT:根据空格和标点符号进行切分。
- GPT-2:同样基于空格,但它会保留空格并将其转换为特殊字符(如
Ġ),这样可以确保从 Token 序列能 100% 还原原始文本(无损还原)。 - T5:不仅处理空格,还针对不同的语言特征进行特定的切分。
4. 为什么这很重要?
- 防止信息丢失:如果标准化太激进(比如强制小写),模型可能无法区分某些专有名词。
- 定制化需求:如果你正在处理特殊领域的数据(如医学或编程代码),你可能需要自定义预分词逻辑(例如在代码中,你不希望在
_处切分,但在普通英文中则可以)。
总结
本节解释了分词器不仅仅是“切词机”,而是一个精密的预处理流水线。标准化负责“弄干净”文本,预分词负责“定好边界”。 理解这些步骤有助于你在面对特定任务时,调试分词效果或自定义新的分词器。
6.字节对编码分词
这一章节解析了目前最流行的子词分词算法:BPE(Byte-Pair Encoding,字节对编码)。
BPE 是 GPT-2、RoBERTa、LLaMA 等主流模型都在使用的分词算法。以下是该页面的核心内容总结:
1. BPE 的核心逻辑
BPE 的基本思想是从字符级别开始,通过不断地合并出现频率最高的相邻 Token 对,逐步构建出更大的词汇单元。
- 初始状态:将所有单词拆解为单个字符。
- 训练过程:
- 统计语料库中所有相邻 Token 对的频率。
- 找到频率最高的一对(例如
h和u),将它们合并为一个新的 Token(hu)。 - 重复此过程,直到达到预设的词表大小(Vocabulary Size)。
- 特性:它是一种自下而上的方法,能够平衡词表大小和序列长度。
2. BPE 的训练示例
页面通过一个简单的语料库(如包含 hug, pug, pun, bun 等词)演示了合并过程:
- 假设
u和g经常相邻出现,算法会生成合并规则u + g -> ug。 - 接着如果
h和ug经常出现,则生成h + ug -> hug。 - 合并规则库:训练结束后,分词器会保留一份按顺序排列的“合并规则列表”。
3. 字节级(Byte-level)BPE
这是 GPT-2 引入的一个重要改进,也是现在主流模型采用的方法:
- 问题:如果只基于 Unicode 字符,词表可能会因为包含大量生僻字符而变得臃肿,且容易遇到 OOM(词表外)错误。
- 解决方法:将文本视为 UTF-8 字节序列。
- 优势:基础词表大小固定为 256(字节的所有可能值),无论遇到什么奇奇怪怪的符号(甚至 Emoji),BPE 都能将其拆解为字节并处理,从而实现 100% 的字符覆盖率,彻底解决
[UNK](未知字符)问题。
4. 算法特点总结
- 确定性:对于相同的输入和词表,分词结果始终唯一。
- 高效性:常用词会成为单个 Token,而罕见词会被拆解为有意义的子词(如
pre-+training),兼顾了词义表达和处理未知词的能力。 - 无损还原:像 GPT-2 的 BPE 实现会保留空格信息(将其转换为特殊符号
Ġ),使得解码过程可以完全还原原始文本的空格和格式。
总结
本节通过详细的逻辑推演告诉我们,BPE 并不是简单的随机切分,而是一个基于统计频率的贪心合并算法。 它通过字节级处理解决了未知字符难题,成为了现代大语言模型最稳健的基础组件。
6.wordPiece分词
介绍了 WordPiece 分词算法。它是 BERT、DistilBERT 和 Electra等模型的核心分词技术。
虽然 WordPiece 在逻辑上与 BPE 有些相似,但它在“合并”规则和“处理方式”上有其独特的逻辑。
1. WordPiece 的核心理念
WordPiece 也是一种**子词分词(Subword Tokenization)**算法,它通过在词表库中不断增加子词来平衡词表大小和信息密度。其最大的特色在于:
- 前缀标记:在 BERT 的实现中,如果一个子词不是单词的开头,它会加上
##前缀(例如learning可能被拆分为learn和##ing)。 - 自下而上:从单个字符开始,逐步合并。
2. 与 BPE 的关键区别:合并标准
这是 WordPiece 最独特的地方。
- BPE:合并频率最高的相邻 Token 对。
- WordPiece:合并**最能提升语言模型似然度(Likelihood)**的相邻 Token 对。
- 简单来说,它会计算:$\frac{P(AB)}{P(A)P(B)}$。
- 这个公式衡量的是:$A$ 和 $B$ 一起出现的概率相对于它们分别独立出现概率的比值。如果这个比值很高,说明 $A$ 和 $B$ 之间有很强的关联(例如
h和u),即使它们的绝对频率不是最高的,也会被优先合并。
3. 分词(推理)阶段的逻辑
WordPiece 在对新句子进行分词时,采用的是**最大向左匹配(Max-match / Longest matching first)**算法:
- 从单词的开头开始,寻找词表中存在的最长子词。
- 找到后,对剩余部分继续寻找以
##开头的最长子词。 - 容错机制:如果在过程中发现某个部分无法在词表中找到对应的子词,则整个单词会被标记为
[UNK](未知字符)。这与 BPE(总能拆成字节或字符)的处理方式不同。
4. 算法流程示例
页面通过一个简单的例子展示了训练过程:
- 初始词表包含所有基础字符和
##字符。 - 每一轮迭代,算法会计算所有可能的组合的得分。
- 选出得分最高(对模型贡献最大)的组合加入词表。
5. 总结
- 优点:WordPiece 的评分机制比 BPE 更“智能”一点,它倾向于保留那些具有强关联性的组合。
- 缺点:由于其匹配逻辑,如果基础字符不全,容易产生
[UNK]。 - 适用场景:它是 BERT 及其衍生模型的基础,在理解语义和上下文关联方面表现优异。
这一节的学习能让你理解为什么 BERT 的分词结果里会有那么多 ##,以及它是如何通过数学概率而非单纯的计数来决定如何拆分词汇的。
7.Unigram分词
这一章节介绍了 Unigram 分词算法。它是 T5、mBART 以及许多基于 SentencePiece 构建的模型所采用的核心技术。
Unigram 的逻辑与 BPE 和 WordPiece 完全不同,它采取的是一种“减法”逻辑。
1. Unigram 的核心逻辑:从大到小
- BPE/WordPiece:从基础字符开始,不断合并(加法)。
- Unigram:从一个巨大的初始词表(通常包含语料库中出现的所有子词和单词)开始,不断剔除(减法)。
2. 训练过程
- 建立初始词表:收集语料库中所有单词的各种可能子词。
- 计算损失(Loss):在当前词表下,计算语料库的整体似然度(Likelihood)。
- 评估贡献度:对词表中的每个 Token 进行评估,如果删掉它,整体损失会增加多少。
- 剔除冗余:保留贡献度最高(即最能减少损失)的 Token,删掉那些“可有可无”的 Token(例如占总数 10% 或 20% 的低贡献词)。
- 迭代:重复上述过程,直到词表大小达到预设目标。
3. 分词(推理)阶段:寻找最优路径
Unigram 最独特的点在于,一个句子可以有多种分词方式(例如 "understand" 可以拆成 under + stand 也可以拆成 u + n + d...)。
- 策略:它会计算每种分词组合的概率乘积,并利用 Viterbi 算法 找出其中概率最高(最符合统计规律)的那种分词结果。
- 优势:由于它基于概率模型,能够提供更好的语言建模特性。
4. 算法特点总结
- 概率性:它是唯一一个基于概率模型的分词算法,能够处理分词歧义。
- 灵活性:在 SentencePiece 中使用时,它通常可以配合“采样(Sampling)”技术,即每次分词结果可能略有不同,这在训练时可以起到**数据增强(Data Augmentation)**的作用,提升模型的鲁棒性。
- 无损性:与 BPE 类似,通常配合字节级(Byte-level)处理,不会产生
[UNK]。
总结
如果说 BPE 是通过“堆砖块”来盖房子,那么 Unigram 就是从一整块大石头中“雕刻”出最精华的部分。虽然它的算法实现比 BPE 复杂(需要计算概率和路径优化),但在处理多语言任务和大型模型(如 T5)时,它往往能提供更高质量的分词效果。