跳过正文

当使用Tokenizers的时候,内部发生了什么?

·7987 字·16 分钟
AI 深度学习 大模型
作者
马斯陶
前TGUCSDN社团主席 现北邮深度学习开发者
目录

如果你实际部署过大语言模型,你会在很多地方看到,在加载大模型的参数之前,需要加载一个tokenizer。tokenizer到底是什么?在大模型的流程中到底发挥什么作用?网上很多教学贴写的云里雾里,实际上没那么玄妙,一句话概括就是把文字变成数字,便于大模型理解。

Tokenizer是什么?

Tokenizer将文本转换为数字序列,它的主要目的是将自然语言文本转换为模型可以理解的形式。直接举个例子:

假如你有一段话要问大模型:“请问天津工业大学是顶级985大学吗?”

tokenizer会将这句话转换成类似这样的数字序列:[101, 5401, 3282, 6663, 3717, 2145, 4500, 1920, 7027, 5303, 4638, 9985, 2145, 7558, 8043, 102]。

这些数字是模型能够理解和处理的形式,也就是所谓ids。每个数字代表了词汇表中的一个token。转换成这样的数字序列之后,大模型才会进行后续的处理。

Tokenizers内部流程

当你使用Tokenizers的时候,内部经过了几个阶段:

1.归一化(normalization)

  • 清理文本(删除空格、变音符号等)
  • 转换为小写
  • Unicode标准化
from tokenizers import normalizers
from tokenizers.normalizers import NFD, StripAccents, Lowercase

normalizer = normalizers.Sequence([
    NFD(),             # Unicode正规化
    StripAccents(),    # 去除音调符号
    Lowercase()])      # 转小写

normalizer.normalize_str("Héllò hõw are ù?")
tokenizer.normalizer = normalizer

2. 预分词(pre-tokenization)

预分词将文本拆分为更小的单位(“单词”),这些单位是实际token的边界。例如,将句子分割成单词或子词:

from tokenizers.pre_tokenizers import Whitespace, Digits

# 简单的空格分词器
pre_tokenizer = Whitespace()
pre_tokenizer.pre_tokenize_str("Hello! How are you? I'm fine, thank you.")
# 会按空格分割文本

# 或者组合多个预分词器
pre_tokenizer = pre_tokenizers.Sequence([
    Whitespace(),
    Digits(individual_digits=True)
])

pre_tokenizer.pre_tokenize_str("Call 911! How are you? I'm fine thank you") # 空格分词+数字单独处理

tokenizer.pre_tokenizer = pre_tokenizer

3. 模型(model)

决定了实际的分词算法,例如BPE(字节对编码):

tokenizer = Tokenizer(BPE(unk_token="[UNK]"))

4. 后处理(post-processing)

在分词后添加特殊token,如句子分隔符[CLS][SEP]等。

完整流程

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.normalizers import NFD, StripAccents, Lowercase, Sequence as NormSequence
from tokenizers.pre_tokenizers import Whitespace, Digits, Sequence as PreSequence
from tokenizers.processors import TemplateProcessing

# 1. 创建一个基本的分词器
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))

# 2. 设置归一化组件
normalizer = NormSequence([
    NFD(),             # Unicode标准化
    StripAccents(),    # 去除音调符号
    Lowercase()        # 转小写
])
tokenizer.normalizer = normalizer

# 3. 设置预分词组件
pre_tokenizer = PreSequence([
    Whitespace(),      # 按空格分词
    Digits(individual_digits=True)  # 数字单独处理
])
tokenizer.pre_tokenizer = pre_tokenizer

# 4. 设置后处理组件
tokenizer.post_processor = TemplateProcessing(
    single="[CLS] $A [SEP]",
    pair="[CLS] $A [SEP] $B [SEP]",
    special_tokens=[
        ("[CLS]", 1),
        ("[SEP]", 2),
    ],
)

# 假设已经训练好了这个分词器
# 对文本进行分词处理
text = "Hello! 你好123 Cómo estás?"

# 实际处理流程:
# 1. 归一化: "hello! 你好123 como estas?"
# 2. 预分词: ["hello!", "你好", "1", "2", "3", "como", "estas?"]
# 3. 模型处理: 将预分词结果转换为token IDs
# 4. 后处理: 添加特殊标记 "[CLS] ... [SEP]"

encoded = tokenizer.encode(text)
print(f"Tokens: {encoded.tokens}")
print(f"IDs: {encoded.ids}")
# tokens = ["[CLS]", "hello", "!", "你好", "1", "2", "3", "como", "estas", "?", "[SEP]"]
# ids = [1, 25, 16, 8930, 13, 14, 15, 345, 789, 19, 2]

如何训练一个新的tokenizer?

每次部署大模型,使用tokenizer的时候,似乎都是从人家训练好的tokenizer加载一个。具体如何训练一个自己的新的tokneizer?我给出一份示例代码:

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.normalizers import NFD, Lowercase, StripAccents
from tokenizers.processors import TemplateProcessing
import os

# 步骤1: 准备训练数据
# 这里假设我们有一些文本文件用于训练
def get_training_corpus(folder_path, batch_size=1000):
    # 这个函数会批量读取文本文件
    files = [os.path.join(folder_path, f) for f in os.listdir(folder_path) if f.endswith('.txt')]
    for file_path in files:
        with open(file_path, 'r', encoding='utf-8') as f:
            lines = []
            for line in f:
                line = line.strip()
                if line:  # 跳过空行
                    lines.append(line)
                    if len(lines) >= batch_size:
                        yield lines
                        lines = []
            if lines:  # 不要忘记最后一批
                yield lines

# 步骤2: 初始化tokenizer
# 创建一个使用BPE算法的tokenizer
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))

# 步骤3: 配置normalizer(归一化处理)
tokenizer.normalizer = NFD() + Lowercase() + StripAccents()

# 步骤4: 配置pre-tokenizer(预分词)
tokenizer.pre_tokenizer = Whitespace()  # 基于空格的预分词

# 步骤5: 配置训练器
# 我们需要指定特殊token和词汇表大小
trainer = BpeTrainer(
    vocab_size=20000,  # 词汇表大小
    min_frequency=2,   # 最小词频
    special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"]  # 特殊token
)

# 步骤6: 训练tokenizer
# 假设我们的训练文本在'corpus'文件夹中
corpus_folder = "corpus"

# 训练过程
tokenizer.train_from_iterator(get_training_corpus(corpus_folder), trainer)

# 步骤7: 添加后处理
# 例如,自动在序列前后添加[CLS]和[SEP]
tokenizer.post_processor = TemplateProcessing(
    single="[CLS] $A [SEP]",
    pair="[CLS] $A [SEP] $B [SEP]",
    special_tokens=[
        ("[CLS]", tokenizer.token_to_id("[CLS]")),
        ("[SEP]", tokenizer.token_to_id("[SEP]")),
    ],
)

# 步骤8: 保存训练好的tokenizer
tokenizer.save("custom_tokenizer.json")

# 测试我们的tokenizer
test_sentence = "这是一个用于测试的中文句子。It also contains some English words!"
encoded = tokenizer.encode(test_sentence)
print(f"Tokens: {encoded.tokens}")
print(f"IDs: {encoded.ids}")

# 测试解码
decoded = tokenizer.decode(encoded.ids)
print(f"Decoded: {decoded}")

Tokenizer训练的本质

在刚开始学的时候,很多教学贴都写的是训练,其实对新手不友好,容易把这个“训练”和深度学习的那种训练搞混。实际上与深度学习训练更新权重不同,tokenizer训练主要是一个统计学习过程,不涉及反向传播或梯度下降。

以BPE规则为例,训练过程如下:

  1. 初始化

    1. 从单个字符/字节的词汇表开始
    2. 将所有文本拆分为单个字符
  2. 统计频率

    1. 统计所有相邻字符对在语料库中的出现频率
  3. 选择合并

    1. 选择频率最高的字符对
    2. 将该字符对添加到合并规则中
    3. 在语料库中执行该合并
  4. 重复步骤2-3

    1. 重新统计合并后的频率
    2. 选择新的最高频率字符对
    3. 继续合并,直到达到预设的词汇量大小
  5. 构建最终词汇表

    1. 收集所有产生的token(包括原始字符和合并后的token)

    2. 为每个token分配唯一ID

    3. 假设我们有一个微型语料库:

      "人工智能", "人工合成", "智能手机" # 这是三个语料
      

      BPE训练过程可能如下:

      1. 初始分词
      ["人", "工", "智", "能"], ["人", "工", "合", "成"], ["智", "能", "手", "机"]
      
      1. 统计频率
      ("人", "工"): 2次
      ("工", "智"): 1次
      ("智", "能"): 2次
      ("工", "合"): 1次
      ("合", "成"): 1次
      ("能", "手"): 1次
      ("手", "机"): 1次
      
      1. 第一次合并(选择频率最高的对):
        1. 合并"人工"和"智能"(都出现2次)
        2. 合并规则1: 人+工 → 人工
        3. 合并规则2: 智+能 → 智能
        4. 更新文本:[“人工”, “智能”], [“人工”, “合”, “成”], [“智能”, “手”, “机”]
      2. 再次统计频率
      ("人工", "智能"): 1次
      ("人工", "合"): 1次
      ("合", "成"): 1次
      ("智能", "手"): 1次
      ("手", "机"): 1次
      
      1. 再次合并
        1. 所有对都是1次,按顺序选择一个,如"合成"
        2. 合并规则3: 合+成 → 合成
        3. 更新文本:[“人工”, “智能”], [“人工”, “合成”], [“智能”, “手”, “机”]
      2. 最终词汇表
      "人": 0
      "工": 1
      "智": 2
      "能": 3
      "合": 4
      "成": 5
      "手": 6
      "机": 7
      "人工": 8
      "智能": 9
      "合成": 10
      

那么,在训练Tokenizer的时候,建立的所谓“词表”到底在哪里发挥作用呢?

  1. 文本标准化 (Normalization)
    1. 处理大小写、重音符号、Unicode标准化等
    2. 词表不参与此步骤
  2. 预分词 (Pre-tokenization)
    1. 将文本分割成初步的单元(如按空格或字符分割)
    2. 词表不参与此步骤
  3. 模型处理 (Model Processing)
    1. 应用学习到的合并规则(BPE)或分割规则(WordPiece)
    2. 这里使用的是合并规则,而非词表
    3. 合并规则决定了如何将初步分割的单元合并成实际的token
  4. 词表查找 (Vocabulary Lookup)
    1. 这是词表发挥作用的地方
    2. 将前面步骤生成的token转换为数字ID
    3. 对每个token进行查表操作,找到对应的ID
  5. 后处理 (Post-processing)
    1. 添加特殊token、截断长度等
    2. 词表用于查找特殊token的ID

可见,词表的作用是在token已经生成之后,把token映射成id。也就是说,tokenizer内部记录了两个主要的东西:一个是合并规则,一个是词表。

如何查看一个tokenizer的合并规则?

from transformers import AutoTokenizer
import json
import os

# 方法1:加载预训练模型的tokenizer并查看合并规则
def inspect_tokenizer_merge_rules(model_name):
    # 加载tokenizer
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    
    # 检查是否是BPE类型的tokenizer
    if hasattr(tokenizer, 'bpe_ranks'):
        print(f"BPE合并规则 (前20个):")
        for i, (pair, rank) in enumerate(sorted(tokenizer.bpe_ranks.items(), key=lambda x: x[1])):
            if i >= 20:
                break
            print(f"规则 {i}: {pair[0]}+{pair[1]} -> {''.join(pair)} (优先级: {rank})")
    else:
        # 尝试另一种方式访问 - 对于较新的Tokenizers
        vocab_file = tokenizer.vocab_file if hasattr(tokenizer, 'vocab_file') else None
        merges_file = tokenizer.merges_file if hasattr(tokenizer, 'merges_file') else None
        
        print(f"Tokenizer类型: {type(tokenizer).__name__}")
        print(f"词汇表文件: {vocab_file}")
        print(f"合并规则文件: {merges_file}")
        
        # 如果有merges文件,尝试读取
        if merges_file and os.path.exists(merges_file):
            with open(merges_file, 'r', encoding='utf-8') as f:
                merges = f.readlines()[1:]  # 跳过表头
            
            print(f"\n前20个合并规则:")
            for i, merge in enumerate(merges[:20]):
                print(f"规则 {i}: {merge.strip()}")

# 方法2:从保存的tokenizer.json文件中提取合并规则
def inspect_tokenizer_json(tokenizer_json_path):
    with open(tokenizer_json_path, 'r', encoding='utf-8') as f:
        tokenizer_data = json.load(f)
    
    # 检查是否包含merges
    if 'model' in tokenizer_data and 'merges' in tokenizer_data['model']:
        merges = tokenizer_data['model']['merges']
        print(f"合并规则总数: {len(merges)}")
        print(f"前20个合并规则:")
        for i, merge in enumerate(merges[:20]):
            print(f"规则 {i}: {merge}")
    else:
        print("该tokenizer.json不包含明确的合并规则")
        # 打印tokenizer的模型类型
        if 'model' in tokenizer_data and 'type' in tokenizer_data['model']:
            print(f"Tokenizer模型类型: {tokenizer_data['model']['type']}")

# 示例用法
print("查看BERT tokenizer:")
inspect_tokenizer_merge_rules('bert-base-uncased')

print("\n查看GPT-2 tokenizer (使用BPE):")
inspect_tokenizer_merge_rules('gpt2')

# 如果有保存的tokenizer.json文件,也可以直接查看
# inspect_tokenizer_json('path/to/tokenizer.json')

BPE类型(如GPT-2, RoBERTa):

  • 可以通过.bpe_ranks属性或.merges_file文件查看合并规则
  • 合并规则是明确的字符对合并列表

WordPiece类型(如BERT):

  • 没有显式的合并规则列表
  • 通过查看词汇表中以##开头的子词来理解分词策略
  • 规则隐含在词汇表结构中

SentencePiece类型(如T5, XLM-R):

  • 规则存储在二进制.model文件中
  • 需要使用sentencepiece库来查看模型细节

为什么是组合规则,而不是直接查词表?

假如你训练好了一个bpe的tokenizer。对于新输入的一句话,tokenizer在处理他的时候组合规则具体是怎么发挥作用的?为什么不直接查词表中存在的词转换成token?我直接举个例子讲解:

BPE Tokenizer处理流程:

  1. 文本标准化

对中文和数字进行Unicode标准化处理:

"天津工业大学相当于中等985的水平" → "天津工业大学相当于中等985的水平"
  1. 预分词

将文本分割成基本单元,包括字符和数字:

["天", "津", "工", "业", "大", "学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]
  1. 应用合并规则

迭代应用合并规则:

初始状态:["天", "津", "工", "业", "大", "学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]

第一轮扫描:

  • 检查所有相邻字符对
  • 假设发现"天"+“津”→“天津"是最高优先级匹配
  • 应用后:["天津", "工", "业", "大", "学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]

第二轮扫描:

  • 假设发现"工”+“业”→“工业"是最高优先级匹配
  • 应用后:["天津", "工业", "大", "学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]

第三轮扫描:

  • 假设发现"大”+“学”→“大学"是最高优先级匹配
  • 应用后:["天津", "工业", "大学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]

第四轮扫描:

  • 假设发现"相”+“当”→“相当"是最高优先级匹配
  • 应用后:["天津", "工业", "大学", "相当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]

第五轮扫描:

  • 假设发现"天津”+“工业”→“天津工业"是最高优先级匹配
  • 应用后:["天津工业", "大学", "相当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]

第六轮扫描:

  • 假设发现"天津工业”+“大学”→“天津工业大学"是最高优先级匹配
  • 应用后:["天津工业大学", "相当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]

第七轮扫描:

  • 假设发现"9”+“8”→“98"是最高优先级匹配
  • 应用后:["天津工业大学", "相当", "于", "中", "等", "98", "5", "的", "水", "平"]

第八轮扫描:

  • 假设发现"98”+“5”→“985"是最高优先级匹配
  • 应用后:["天津工业大学", "相当", "于", "中", "等", "985", "的", "水", "平"]

第九轮扫描:

  • 假设发现"水”+“平”→“水平"是最高优先级匹配
  • 应用后:["天津工业大学", "相当", "于", "中", "等", "985", "的", "水平"]

第十轮扫描:

  • 假设发现"中”+“等”→“中等"是最高优先级匹配
  • 应用后:["天津工业大学", "相当", "于", "中等", "985", "的", "水平"]

第十一轮扫描:

  • 假设没有更多可匹配的规则
  • 最终分词结果:["天津工业大学", "相当", "于", "中等", "985", "的", "水平"]
  1. 词表查找(ID转换)

将token转换为ID:

"天津工业大学" → 8426
"相当" → 2145
"于" → 21
"中等" → 3762
"985" → 5329
"的" → 8
"水平" → 4218

生成最终的ID序列:[8426, 2145, 21, 3762, 5329, 8, 4218]

添加特殊token:

[101, 8426, 2145, 21, 3762, 5329, 8, 4218, 102]

为什么不直接查词表中存在的词转换成token就好了?

原因有几个:

  1. 未知词处理:直接查表无法处理训练中未见过的词,而BPE通过合并规则可以将未知词分解为已知子词
  2. 组合爆炸:语言中可能的词组合是无限的,无法全部放入词表
  3. 效率平衡:通过合并规则可以用有限的词表表达几乎无限的词汇

假设tokenizer从未见过"智慧"这个词,但在词表中有"智"和"慧”:

如果直接查表,“智慧"会被标记为[UNK](未知) 使用BPE,会先分成[“智”, “慧”],然后应用合并规则,由于没有"智”+“慧"的规则,最终结果是[“智”, “慧”],但这比[UNK]保留了更多信息.

结语

tokenizer本质上就是把一段话变成一堆ids,这个过程被称为"编码”(encode)。

当输入"今天天气真好"这样的文本时,tokenizer会将其转换为类似[101, 3352, 5401, 7321, 102]这样的ID序列,然后模型才能处理这些数字并生成响应。

当模型生成回应时,会输出一系列ID,然后tokenizer执行反向操作(decode),将这些ID转换回可读的文本。

发明tokenizer的人真是一个天才,比我也不差多少了。