Skip to content

分词预处理和后处理


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)
python
"""
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.defreturn 可能会被识别为单个“零件”,而旧的通用英文词表里这些可能被拆得很碎。
  • 合并规则 (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)。
  • 利用偏移量复现 startend
  • 在调用分词器时设置 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=Truestride(步长) 参数。
  • 效果:分词器会将一个长文档切分成多个相互重叠的子块(Chunks),确保答案不会因为刚好落在切分点而被遗漏。
  • 快速分词器的核心功能:建立映射

为了将模型预测出的 Token 索引转换回用户能够理解的原始文本,快速分词器提供了以下关键辅助信息:

  • overflow_to_sample_mapping:当一个长示例被切成多个 Chunk 时,这个映射可以告诉我们每个 Chunk 属于原始数据集中的哪一个样本。
  • offset_mapping:如前一节所述,它记录了每个 Token 在原句中的字符起止位置。
  • 复杂的手动后处理逻辑

页面详细演示了如何不使用高层 API(Pipeline),而是手动处理模型输出:

  1. 识别有效索引:需要使用 sequence_ids 来区分哪些 Token 属于“问题”,哪些属于“背景文档”。答案只能从“背景文档”部分提取。
  2. 分数计算与排序:模型会对每个可能的 [start, end] 组合计算得分。开发者需要通过循环找出得分最高的组合。
  3. 转换回字符:一旦确定了得分最高的 [start, end] Token 索引,利用 offset_mapping 就能瞬间从原始文本中截取出答案字符串。
  • 总结

这一节展示了快速分词器在处理长文档时的不可替代性。如果没有快速分词器提供的 stride 切片能力和 offset_mapping 字符追踪能力,处理大规模问答数据集并精准定位答案几乎是不可能完成的任务。


5.标准化和预分词

这一部分探讨了分词器的底层运作机制,即 标准化(Normalization)和预分词(Pre-tokenization)。 这是文本进入分词算法(如 BPE、WordPiece)之前的关键处理步骤。

1. 分词器的流水线 (The Tokenizer Pipeline)

一个完整的分词过程包含四个步骤,本节重点讨论前两步:

  1. Normalization(标准化):对原始文本进行初步清理。
  2. Pre-tokenization(预分词):将文本切分成初步的“块”(通常是单词)。
  3. Model(模型算法):应用 BPE 或 WordPiece 等算法进一步细分为子词。
  4. 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 对,逐步构建出更大的词汇单元。

  • 初始状态:将所有单词拆解为单个字符。
  • 训练过程
    1. 统计语料库中所有相邻 Token 对的频率。
    2. 找到频率最高的一对(例如 hu),将它们合并为一个新的 Token(hu)。
    3. 重复此过程,直到达到预设的词表大小(Vocabulary Size)。
  • 特性:它是一种自下而上的方法,能够平衡词表大小和序列长度。

2. BPE 的训练示例

页面通过一个简单的语料库(如包含 hug, pug, pun, bun 等词)演示了合并过程:

  • 假设 ug 经常相邻出现,算法会生成合并规则 u + g -> ug
  • 接着如果 hug 经常出现,则生成 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$ 之间有很强的关联(例如 hu),即使它们的绝对频率不是最高的,也会被优先合并。

3. 分词(推理)阶段的逻辑

WordPiece 在对新句子进行分词时,采用的是**最大向左匹配(Max-match / Longest matching first)**算法:

  1. 从单词的开头开始,寻找词表中存在的最长子词。
  2. 找到后,对剩余部分继续寻找以 ## 开头的最长子词。
  3. 容错机制:如果在过程中发现某个部分无法在词表中找到对应的子词,则整个单词会被标记为 [UNK](未知字符)。这与 BPE(总能拆成字节或字符)的处理方式不同。

4. 算法流程示例

页面通过一个简单的例子展示了训练过程:

  • 初始词表包含所有基础字符和 ## 字符。
  • 每一轮迭代,算法会计算所有可能的组合的得分。
  • 选出得分最高(对模型贡献最大)的组合加入词表。

5. 总结

  • 优点:WordPiece 的评分机制比 BPE 更“智能”一点,它倾向于保留那些具有强关联性的组合。
  • 缺点:由于其匹配逻辑,如果基础字符不全,容易产生 [UNK]
  • 适用场景:它是 BERT 及其衍生模型的基础,在理解语义和上下文关联方面表现优异。

这一节的学习能让你理解为什么 BERT 的分词结果里会有那么多 ##,以及它是如何通过数学概率而非单纯的计数来决定如何拆分词汇的。


7.Unigram分词

这一章节介绍了 Unigram 分词算法。它是 T5、mBART 以及许多基于 SentencePiece 构建的模型所采用的核心技术。

Unigram 的逻辑与 BPE 和 WordPiece 完全不同,它采取的是一种“减法”逻辑。

1. Unigram 的核心逻辑:从大到小

  • BPE/WordPiece:从基础字符开始,不断合并(加法)。
  • Unigram:从一个巨大的初始词表(通常包含语料库中出现的所有子词和单词)开始,不断剔除(减法)。

2. 训练过程

  1. 建立初始词表:收集语料库中所有单词的各种可能子词。
  2. 计算损失(Loss):在当前词表下,计算语料库的整体似然度(Likelihood)。
  3. 评估贡献度:对词表中的每个 Token 进行评估,如果删掉它,整体损失会增加多少。
  4. 剔除冗余:保留贡献度最高(即最能减少损失)的 Token,删掉那些“可有可无”的 Token(例如占总数 10% 或 20% 的低贡献词)。
  5. 迭代:重复上述过程,直到词表大小达到预设目标。

3. 分词(推理)阶段:寻找最优路径

Unigram 最独特的点在于,一个句子可以有多种分词方式(例如 "understand" 可以拆成 under + stand 也可以拆成 u + n + d...)。

  • 策略:它会计算每种分词组合的概率乘积,并利用 Viterbi 算法 找出其中概率最高(最符合统计规律)的那种分词结果。
  • 优势:由于它基于概率模型,能够提供更好的语言建模特性。

4. 算法特点总结

  • 概率性:它是唯一一个基于概率模型的分词算法,能够处理分词歧义。
  • 灵活性:在 SentencePiece 中使用时,它通常可以配合“采样(Sampling)”技术,即每次分词结果可能略有不同,这在训练时可以起到**数据增强(Data Augmentation)**的作用,提升模型的鲁棒性。
  • 无损性:与 BPE 类似,通常配合字节级(Byte-level)处理,不会产生 [UNK]

总结

如果说 BPE 是通过“堆砖块”来盖房子,那么 Unigram 就是从一整块大石头中“雕刻”出最精华的部分。虽然它的算法实现比 BPE 复杂(需要计算概率和路径优化),但在处理多语言任务和大型模型(如 T5)时,它往往能提供更高质量的分词效果。


Move fast and break things