从零训练LLM-1.训练BPE

发布于:2025-04-14 ⋅ 阅读:(23) ⋅ 点赞:(0)


本文基于 HF -tokenizer 训练,更便捷


BPE 简介

分词器将单词从自然语言通过“词典”映射到0, 1, 36这样的数字,可以理解为数字就代表了单词在“词典”中的页码。 可以选择自己构造词表训练一个“词典”或者选择比较出名的开源大模型分词器(直接将 tokenizer 的模型文件复制过来,然后 tokenizer.from_pretrained), 正如同直接用新华/牛津词典的优点是token编码压缩率很好,缺点是页数太多,动辄数十万个词汇短语; 自己训练的分词器,优点是词表长度和内容随意控制,缺点是压缩率很低(例如"hello"也许会被拆分为"h e l l o" 五个独立的token),且生僻词难以覆盖。 “词典”的选择固然很重要,LLM的输出本质上是SoftMax到词典N个词的多分类问题,然后通过“词典”解码到自然语言。 因为模型体积需要严格控制,为了避免模型头重脚轻(词嵌入embedding层参数在LLM占比太高),所以词表长度短益善。


BPE (Byte-Pair Encoding) 算法训练流程

  1. 准备语料库,将文本拆分为基本单位(通常是字符或字节)

  2. 统计所有相邻单元对的频率

  3. 选择最高频率的单元对合并为新的单元

  4. 更新语料库,替换所有该单元对为新单元

  5. 重复步骤2-4直到达到预设的词表大小或合并次数

BPE 编码流程

  1. 把文本先拆成最小单位(比如单个字母或字节)

  2. 识别文本中的特殊token(如, ),这些特殊token会直接映射到对应ID

  3. 对于普通文本部分:

    a. 初始化为基本单位序列

    b. 扫描当前序列中的所有相邻单位对

    c. 查找这些单位对是否在训练时学到的"合并规则表"中

    d. 优先选择合并后ID值最小的单位对进行合并(即优先合并更基础、更短的单位)

    e. 合并后继续重复b-d步骤,直到没有可合并的单位对

  4. 将特殊token和处理好的普通文本部分组合,得到完整的token序列

  5. 最后得到的单位序列就是文本的token编码结果


BPE 评估

评估 BPE 分词器质量的主要方法包括:

  1. 分词质量指标
  • 词表利用率: 检查分词后的词表中各 token 使用频率分布,好的分词器应当避免出现大量低频或未使用的 token
  • 平均 token 长度: 较好的分词器通常能将文本压缩为更少的 token
  • 未知词率 (OOV率): 在测试集上计算未登录词(需用 表示)的比例,越低越好
  1. 下游任务评估
  • 困惑度 (Perplexity): 在语言模型上测试,较低的困惑度表示分词更有效
  • 下游任务表现: 在翻译、分类等任务中比较不同分词方案的效率差异
def eval_tokenizer():
    # 加载预训练的tokenizer
    tokenizer = AutoTokenizer.from_pretrained("./EmoLLM_tokenizer")
    
    # 准备测试数据
    test_texts = [
        "你好,我最近感到有些焦虑,不知道该怎么办。",
        "我对未来感到迷茫,希望能得到一些建议。",
        "最近工作压力很大,感觉睡不好觉。"
    ]
    
    # 1. 计算平均token长度
    token_lengths = []
    for text in test_texts:
        tokens = tokenizer.encode(text)
        token_lengths.append(len(tokens))
    avg_token_length = sum(token_lengths) / len(token_lengths)
    print(f"平均token长度: {avg_token_length:.2f}")
    
    # 2. 检查词表利用率
    all_tokens = []
    for text in test_texts:
        tokens = tokenizer.encode(text)
        all_tokens.extend(tokens)
    
    unique_tokens = set(all_tokens)
    vocab_size = len(tokenizer)
    utilization_rate = len(unique_tokens) / vocab_size * 100
    print(f"词表利用率: {utilization_rate:.2f}%")
    
    # 3. 分析最常见的tokens
    from collections import Counter
    token_counter = Counter(all_tokens)
    print("最常见的10个token:")
    for token_id, count in token_counter.most_common(10):
        token = tokenizer.decode([token_id])
        print(f"  Token: '{token}', ID: {token_id}, 出现次数: {count}")
    
    # 4. 检查长文本的编码/解码一致性
    long_text = " ".join(test_texts)
    encoded = tokenizer.encode(long_text)
    decoded = tokenizer.decode(encoded)
    print(f"编码/解码一致性: {long_text == decoded}")
  • 也可以与其他 tokenizer 进行比较
def compare_tokenizers(test_text):
    # 加载不同的tokenizer进行比较
    tokenizers = {
        "EmoLLM": AutoTokenizer.from_pretrained("./EmoLLM_tokenizer"),
        "GPT2": AutoTokenizer.from_pretrained("gpt2"),
        "BERT": AutoTokenizer.from_pretrained("bert-base-uncased")
    }
    
    print(f"文本: {test_text}")
    for name, tok in tokenizers.items():
        tokens = tok.encode(test_text)
        print(f"{name} token数量: {len(tokens)}")
        
        # 可视化分词结果
        if hasattr(tok, "tokenize"):
            print(f"{name} 分词结果: {tok.tokenize(test_text)}")
        else:
            print(f"{name} 分词结果: {[tok.decode([t]) for t in tokens]}")

代码

import json
import os
import random

from tokenizers import (Tokenizer, decoders, models, normalizers,
                        pre_tokenizers, processors, trainers)
from transformers import AutoTokenizer

# 设置随机种子以确保结果可复现
random.seed(42)

# BPE (Byte-Pair Encoding) 算法训练流程:
# 1. 准备语料库,将文本拆分为基本单位(通常是字符或字节)
# 2. 统计所有相邻单元对的频率
# 3. 选择最高频率的单元对合并为新的单元
# 4. 更新语料库,替换所有该单元对为新单元
# 5. 重复步骤2-4直到达到预设的词表大小或合并次数

# BPE 编码流程:
# 1. 把文本先拆成最小单位(比如单个字母或字节)
# 2. 识别文本中的特殊token(如<s>, </s>),这些特殊token会直接映射到对应ID
# 3. 对于普通文本部分:
#    a. 初始化为基本单位序列
#    b. 扫描当前序列中的所有相邻单位对
#    c. 查找这些单位对是否在训练时学到的"合并规则表"中
#    d. 优先选择合并后ID值最小的单位对进行合并(即优先合并更基础、更短的单位)
#    e. 合并后继续重复b-d步骤,直到没有可合并的单位对
# 4. 将特殊token和处理好的普通文本部分组合,得到完整的token序列
# 5. 最后得到的单位序列就是文本的token编码结果


def train_tokenizer():
    # 读取JSONL文件并提取文本数据
    def read_texts_from_jsonl(file_path):
        with open(file_path, 'r', encoding='utf-8') as f:
            for line in f:
                data = json.loads(line)
                yield data['text']

    data_path = '../dataset/pretrain_hq.jsonl'

    # 初始化tokenizer
    tokenizer = Tokenizer(models.BPE())
    # 预分词器负责将原始文本进行初步分割,为后续的 BPE 合并操作做准备add_prefix_space=False 表示不在每个序列前添加空格
    tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)

    # 定义特殊token
    special_tokens = ["<unk>", "<s>", "</s>"]

    # 设置训练器并添加特殊token
    trainer = trainers.BpeTrainer(
        vocab_size=6400,
        special_tokens=special_tokens,  # 确保这三个token被包含
        show_progress=True,
        # 使用字节级别的基础字符集作为初始词汇,它包含了256个可能的字节值(0-255)对应的Unicode字符表示
        initial_alphabet=pre_tokenizers.ByteLevel.alphabet()
    )

    # 读取文本数据
    texts = read_texts_from_jsonl(data_path)

    # 训练tokenizer
    tokenizer.train_from_iterator(texts, trainer=trainer)

    # 设置解码器
    tokenizer.decoder = decoders.ByteLevel()

    # 检查特殊token的索引
    # 确保三个特殊token被正确分配了预期的ID:
    # <unk>应该是ID 0 - 用于表示未知词汇
    assert tokenizer.token_to_id("<unk>") == 0
    # <s>应该是ID 1 - 用于表示文本开始
    assert tokenizer.token_to_id("<s>") == 1
    # </s>应该是ID 2 - 用于表示文本结束
    assert tokenizer.token_to_id("</s>") == 2

    # 保存tokenizer
    tokenizer_dir = "./EmoLLM_tokenizer"
    os.makedirs(tokenizer_dir, exist_ok=True)
    tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
    tokenizer.model.save("./EmoLLM_tokenizer")

    # 手动创建配置文件
    config = {
        "add_bos_token": False,
        "add_eos_token": False,
        "add_prefix_space": False,
        "added_tokens_decoder": {
            # 配置 <unk> 标记 (未知词标记)
            # 索引为0,用于表示词表外的词或字符
            "0": {
                "content": "<unk>",  # 标记的实际内容
                "lstrip": False,     # 是否从左侧移除空白
                "normalized": False,  # 是否规范化
                "rstrip": False,     # 是否从右侧移除空白
                "single_word": False,  # 是否作为单个词处理
                "special": True      # 标记为特殊标记,在解码时会特殊处理
            },
            # 配置 <s> 标记 (序列开始标记)
            # 索引为1,用于标识文本序列的开始
            "1": {
                "content": "<s>",
                "lstrip": False,
                "normalized": False,
                "rstrip": False,
                "single_word": False,
                "special": True
            },
            # 配置 </s> 标记 (序列结束标记)
            # 索引为2,用于标识文本序列的结束
            "2": {
                "content": "</s>",
                "lstrip": False,
                "normalized": False,
                "rstrip": False,
                "single_word": False,
                "special": True
            }
        },
        "additional_special_tokens": [],
        "bos_token": "<s>",
        "clean_up_tokenization_spaces": False,
        "eos_token": "</s>",
        "legacy": True,
        "model_max_length": 32768,
        "pad_token": "<unk>",
        "sp_model_kwargs": {},
        "spaces_between_special_tokens": False,
        "tokenizer_class": "PreTrainedTokenizerFast",
        "unk_token": "<unk>",
        # 配置聊天模板 - 使用Jinja2模板格式定义模型输入的格式化方式
        "chat_template": "{% if messages[0]['role'] == 'system' %}{% set system_message = messages[0]['content'] %}{{ '<s>system\\n' + system_message + '</s>\\n' }}{% else %}{{ '<s>system\\n你是 EmoLLM,是一个完全开源的心理健康大模型。</s>\\n' }}{% endif %}{% for message in messages %}{% set content = message['content'] %}{% if message['role'] == 'user' %}{{ '<s>user\\n' + content + '</s>\\n<s>assistant\\n' }}{% elif message['role'] == 'assistant' %}{{ content + '</s>' + '\\n' }}{% endif %}{% endfor %}"
        # 聊天模板说明:
        # 1. 如果第一条消息是系统消息,将其作为系统指令;否则使用默认系统消息
        # 2. 用户消息格式: <s>user\n{用户内容}</s>\n<s>assistant\n
        # 3. 助手消息格式: {助手内容}</s>\n
        # 4. 特殊标记<s>和</s>用于标记消息的开始和结束
    }

    # 保存配置文件
    with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w", encoding="utf-8") as config_file:
        json.dump(config, config_file, ensure_ascii=False, indent=4)

    print("Tokenizer training completed and saved.")


def eval_tokenizer():
    # 加载预训练的tokenizer
    tokenizer = AutoTokenizer.from_pretrained("./EmoLLM_tokenizer")

    messages = [
        {"role": "system", "content": "你是一个优秀的聊天机器人,总是给我正确的回应!"},
        {"role": "user", "content": '你来自哪里?'},
        {"role": "assistant", "content": '我来自地球'}
    ]
    new_prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False
    )
    print(new_prompt)

    # 获取实际词汇表长度(包括特殊符号)
    actual_vocab_size = len(tokenizer)
    print('tokenizer实际词表长度:', actual_vocab_size)

    model_inputs = tokenizer(new_prompt)
    print('encoder长度:', len(model_inputs['input_ids']))

    input_ids = model_inputs['input_ids']
    response = tokenizer.decode(input_ids, skip_special_tokens=False)
    print('decoder和原始文本是否一致:', response == new_prompt)


def main():
    train_tokenizer()
    eval_tokenizer()


if __name__ == '__main__':
    main()

参考

  1. https://github.com/jingyaogong/minimind/blob/master/scripts/train_tokenizer.py
  2. https://github.com/SmartFlowAI/EmoLLM
  3. https://github.com/aJupyter/ThinkLLM