第6课:文本标记化与词汇表构建

107 阅读26分钟

引言

欢迎来到《从零构建大型语言模型:Python实现20亿参数LLM的完整指南》的第六课。在上一课中,我们学习了训练数据的获取与预处理,为模型训练奠定了数据基础。本课我们将深入探讨语言模型开发中的另一个关键环节:文本标记化(tokenization)与词汇表构建。

标记化是连接人类语言与机器理解的桥梁。它将原始文本转换为模型可以处理的数字序列,这一过程直接影响模型的学习能力、表达效率和计算性能。不同的标记化方法会导致不同的模型行为,从而影响最终的生成质量和推理效率。对于我们的20亿参数模型,选择和实现适当的标记化策略尤为重要。

在本课中,我们将首先比较主流的子词标记化技术,包括BPE、WordPiece和SentencePiece,理解它们的原理、优缺点及适用场景。然后,我们将从零开始实现字节对编码(BPE)算法,这是现代大型语言模型中最常用的标记化方法之一。接着,我们将探讨如何构建和优化词汇表,平衡表达能力和计算效率。最后,我们将讨论特殊标记处理和序列长度管理,这对于控制模型输入输出行为至关重要。

通过本课的学习,你将掌握:

  1. 主流子词标记化技术的工作原理及比较
  2. BPE算法的完整实现过程
  3. 词汇表设计与优化的关键策略
  4. 特殊标记处理和长序列管理技术

无论是从头训练模型还是微调现有模型,这些知识都将帮助你理解和控制模型与文本之间的关键接口。让我们开始探索标记化的奥秘!

1. 子词标记化技术比较

标记化的演变

标记化技术经历了从简单到复杂的发展过程:

  1. 单词级标记化:最早的方法,简单按空格和标点分割

    • 优点:直观,保留完整单词语义
    • 缺点:词汇表极大,无法处理未登录词(OOV),模型参数膨胀
  2. 字符级标记化:将文本分解为单个字符

    • 优点:词汇表小,无OOV问题
    • 缺点:序列过长,丢失词级语义,计算成本高
  3. 子词标记化:在单词和字符之间寻找平衡点

    • 优点:词汇表大小适中,能处理OOV,保留部分语义结构
    • 缺点:实现复杂,需要训练,单词可能被不直观地分割

现代大型语言模型几乎都采用子词标记化,它在表达效率和计算成本之间取得了最佳平衡。以下我们将深入比较几种主流子词标记化技术。

BPE (Byte-Pair Encoding)

BPE算法最初用于数据压缩,后被改编用于NLP领域。它是GPT系列、RoBERTa等模型使用的标记化方法。

基本原理

  1. 从字符级词汇表开始
  2. 统计文本中最频繁的字符对(相邻字符)
  3. 将最频繁的字符对合并为新的子词单元
  4. 重复过程直到达到目标词汇表大小或满足其他停止条件

示例: 假设有文本:"low lower lowest lowering" 初始分词:l o w _ l o w e r _ l o w e s t _ l o w e r i n g

  1. 最频繁对: l o 出现4次,合并为 lo
  2. 新表示: lo w _ lo w e r _ lo w e s t _ lo w e r i n g
  3. 最频繁对: lo w 出现4次,合并为 low
  4. 新表示: low _ low e r _ low e s t _ low e r i n g
  5. 继续合并...最终得到: low _ lower _ lowest _ lowering

优势

  • 适应性强,根据训练语料自动发现常见模式
  • 有效处理复合词和词缀
  • 生成的词汇表大小可控
  • 编码和解码过程高效

局限性

  • 纯频率驱动,可能忽略语义边界
  • 对罕见词的处理相对粗糙
  • 不直接支持多语言场景(需要特殊处理)

WordPiece

WordPiece是BERT、DistilBERT等模型采用的标记化方法,由Google开发。

基本原理

  1. 从字符级词汇表开始
  2. 对每对可能的子词组合,计算合并后语言模型似然提升
  3. 选择提升最大的对合并
  4. 重复直到达到目标词汇表大小

与BPE不同,WordPiece使用语言模型似然而非简单频率,计算公式:

得分(x,y) = (freq(xy) / (freq(x) * freq(y))) - 1

示例标记化

  • "playing" → ["play", "##ing"]
  • "unbelievable" → ["un", "##bel", "##iev", "##able"]

注意"##"前缀标记子词位于单词中间

优势

  • 考虑了子词对语言模型概率的贡献
  • 倾向于保留有意义的词素(语言学单位)
  • 通常对英语等拼接式语言切分更自然

局限性

  • 计算成本高于BPE
  • 需要预先进行单词分割(不适合中文等无明显分词界限的语言)
  • 对数字、URL等特殊序列处理不佳

SentencePiece

SentencePiece是由Google开发的更全面的标记化解决方案,用于T5、XLM-R等多语言模型。

关键特性

  1. 无预分词:将所有输入视为字符序列,包括空格
  2. 真正的可逆操作:保证解码能恢复原始文本(包括空格)
  3. 支持多种算法:内置BPE和Unigram两种算法
  4. 多语言友好:对非拉丁语系语言表现良好
  5. 直接支持字节级操作:可处理任何Unicode字符

Unigram算法原理

  1. 从一个大型候选子词集开始
  2. 迭代移除使模型似然减少最少的标记
  3. 使用基于概率的分词(可能有多种分词方式)

示例: 文本:"I like SentencePiece" SentencePiece标记化:["▁I", "▁like", "▁S", "ent", "ence", "P", "iece"]

注意"▁"表示前导空格,成为标记的一部分

优势

  • 真正语言无关,适用于任何语言
  • 无需语言特定的预处理(如空格分词)
  • 支持子采样以加速训练
  • 具备统一的API和工具链

局限性

  • 实现复杂度高于单纯的BPE或WordPiece
  • Unigram模型训练较慢
  • 需要额外处理空格标记

技术选择与性能对比

为了选择适合我们20亿参数模型的标记化技术,以下是关键性能指标比较:

指标BPEWordPieceSentencePiece
训练速度中等慢(BPE模式)/较慢(Unigram模式)
分词一致性确定性确定性可能概率性(Unigram模式)
多语言支持有限有限优秀
内存效率中等
实现复杂度中等
主流LLM使用GPT系列, LLaMABERT系列T5, mT5, XLM-R

对于我们的20亿参数模型,BPE标记化是一个平衡的选择,因为:

  1. 具有出色的计算效率,适合大规模训练
  2. 实现相对简单,便于定制和优化
  3. 与GPT系列架构兼容性好
  4. 在主要英文语料上表现优异
  5. 可通过适当的预处理步骤扩展到多语言场景

如果多语言能力是重点,则SentencePiece是更好的选择;如果希望保留更多语言学合理性,WordPiece可能更合适。

2. 从零实现BPE标记化算法

在理解了BPE的概念后,现在我们将从零实现这一算法,包括训练过程和编码/解码过程。这不仅能加深对算法的理解,也为我们提供一个可定制的标记化工具。

BPE算法流程详解

完整的BPE实现包括两个主要阶段:

阶段1: 训练(构建合并规则)

  1. 将训练语料分割成字符序列,添加结束标记
  2. 构建初始词汇表(所有出现的字符)
  3. 计算所有相邻字符对的频率
  4. 选择最高频的字符对合并
  5. 更新词汇表和所有文本中的字符对
  6. 返回步骤3,直到达到目标词汇量或迭代次数

阶段2: 编码(应用合并规则)

  1. 将输入文本分割成字符
  2. 按训练时获得的合并优先级顺序应用合并规则
  3. 返回最终的标记序列

BPE词汇表训练实现

让我们开始实现BPE训练过程:

def train_bpe(text_iterator, vocab_size, special_tokens=None):
    """
    从文本语料库训练BPE标记化器
    
    参数:
        text_iterator: 文本迭代器,每次返回一行文本
        vocab_size: 目标词汇表大小
        special_tokens: 特殊标记列表,如[PAD], [CLS]等
        
    返回:
        vocabulary: 最终词汇表
        merges: 合并规则列表
    """
    # 初始化特殊标记
    if special_tokens is None:
        special_tokens = []
    
    # 步骤1: 构建初始字符级词汇表
    char_vocab = set()
    word_counts = {}
    
    print("构建初始字符词汇表...")
    for line in text_iterator:
        # 简单按空格分词,实际中可能需要更复杂的预处理
        words = line.strip().split()
        for word in words:
            # 添加结束符号
            word = word + "</w>"
            
            # 更新词频统计
            if word not in word_counts:
                word_counts[word] = 0
            word_counts[word] += 1
            
            # 更新字符词汇表
            for char in word:
                char_vocab.add(char)
    
    # 将单词表示为字符列表,便于后续合并
    splits = {word: [c for c in word] for word in word_counts.keys()}
    
    # 当前词汇表:特殊标记 + 字符词汇表
    vocabulary = special_tokens + list(char_vocab)
    merges = []
    
    # 当词汇表大小小于目标大小时,继续合并
    while len(vocabulary) < vocab_size:
        # 统计所有相邻标记对的频率
        pair_counts = {}
        for word, freq in word_counts.items():
            split = splits[word]
            if len(split) == 1:
                continue
                
            for i in range(len(split) - 1):
                pair = (split[i], split[i+1])
                if pair not in pair_counts:
                    pair_counts[pair] = 0
                pair_counts[pair] += freq
        
        # 如果没有更多可合并的对,提前退出
        if not pair_counts:
            break
            
        # 找出最高频的对
        best_pair = max(pair_counts, key=pair_counts.get)
        new_token = best_pair[0] + best_pair[1]
        merges.append(best_pair)
        
        # 将该对合并添加到词汇表
        vocabulary.append(new_token)
        
        # 更新所有分割,应用新合并规则
        for word in word_counts:
            split = splits[word]
            i = 0
            while i < len(split) - 1:
                if split[i] == best_pair[0] and split[i+1] == best_pair[1]:
                    split = split[:i] + [new_token] + split[i+2:]
                    i = 0  # 重新开始,因为合并可能创造新的可合并对
                else:
                    i += 1
            splits[word] = split
        
        print(f"合并: {best_pair} -> {new_token}, 词汇表大小: {len(vocabulary)}")
        
        # 如果达到目标词汇量,停止
        if len(vocabulary) >= vocab_size:
            break
    
    return vocabulary, merges

BPE编码与解码实现

有了训练好的合并规则,我们现在可以实现编码函数,将文本转换为标记ID:

def encode(text, merges, vocab):
    """
    使用BPE将文本编码为标记ID
    
    参数:
        text: 输入文本
        merges: BPE合并规则
        vocab: 词汇表(标记到ID的映射)
        
    返回:
        token_ids: 标记ID列表
    """
    # 按空格分词
    words = text.strip().split()
    token_ids = []
    
    for word in words:
        # 添加结束符号
        word = word + "</w>"
        
        # 初始分割为字符
        chars = [c for c in word]
        
        # 应用合并规则
        for pair, merge in enumerate(merges):
            i = 0
            while i < len(chars) - 1:
                if chars[i] == merge[0] and chars[i+1] == merge[1]:
                    chars = chars[:i] + [merge[0] + merge[1]] + chars[i+2:]
                else:
                    i += 1
        
        # 将分词结果转换为ID
        for token in chars:
            if token in vocab:
                token_ids.append(vocab[token])
            else:
                # 处理OOV:可以使用特殊的[UNK]标记或字符级回退
                if "<unk>" in vocab:
                    token_ids.append(vocab["<unk>"])
                else:
                    # 字符级回退处理
                    for char in token:
                        if char in vocab:
                            token_ids.append(vocab[char])
                        elif "<unk>" in vocab:
                            token_ids.append(vocab["<unk>"])
    
    return token_ids
​
def decode(token_ids, id_to_token):
    """
    将标记ID解码回文本
    
    参数:
        token_ids: 标记ID列表
        id_to_token: ID到标记的映射
        
    返回:
        text: 解码后的文本
    """
    tokens = [id_to_token[id] for id in token_ids]
    text = ""
    current_word = ""
    
    for token in tokens:
        if token.endswith("</w>"):
            # 结束当前单词
            current_word += token[:-4]  # 移除</w>标记
            text += current_word + " "
            current_word = ""
        else:
            current_word += token
    
    return text.strip()

提升实现的效率与健壮性

上述实现是为了教学目的而设计的,强调清晰度。实际应用中,我们需要考虑以下优化:

  1. 算法优化

    • 使用优先队列存储合并候选项,避免每次重新计算全部频率
    • 实现增量式更新而非全量重新计算
    • 利用并行处理加速频率统计和合并操作
  2. 内存优化

    • 使用更高效的数据结构(如字典树)存储分词状态
    • 实现流式处理大文件,避免一次加载全部语料
    • 采用稀疏表示存储频率信息
  3. 健壮性增强

    • 添加正则表达式预处理,统一处理数字、URL等特殊文本
    • 实现更智能的OOV处理策略
    • 增加Unicode规范化和特殊字符处理
  4. 缓存机制

    • 缓存常见单词的标记化结果
    • 实现分层缓存策略(内存+磁盘)
    • 添加预热机制,提前处理高频词

完整的生产级实现会更复杂,通常包含2000-3000行代码,并使用C++等高性能语言实现核心部分。对于我们的教学目的,上述Python实现已经捕捉了BPE的核心原理。

3. 构建与优化词汇表

词汇表(Vocabulary)是将标记映射到数字ID的字典,它直接影响模型的性能、大小和训练效率。现在我们将深入探讨如何构建和优化词汇表。

词汇表设计原则

词汇表设计需要平衡多种因素:

  1. 表达效率

    • 较大的词汇表能减少每个序列的标记数
    • 更有效地表示常见词/短语
    • 但会增加嵌入层的参数数量
  2. 计算效率

    • 嵌入层大小与词汇表大小成正比
    • 较小的词汇表降低内存需求和计算负担
    • 影响训练和推理速度
  3. 泛化能力

    • 适当的词汇大小有助于模型学习更好的表示
    • 过大的词汇表可能导致数据稀疏性问题
    • 过小的词汇表则表达能力受限
  4. 领域适应性

    • 通用词汇表 vs. 领域特定词汇表
    • 常用词/专业术语的覆盖率
    • 不同语言或方言的支持

词汇表大小选择

词汇表大小是一个关键决策,主流LLM的选择各不相同:

模型词汇表大小备注
GPT-250,257包括字节级回退
BERT30,522英文版本
RoBERTa50,265与GPT-2相似
T532,128SentencePiece实现
GPT-3约50,000基于GPT-2词汇表扩展
LLaMA32,000基于BPE,但有独特处理

对于我们的20亿参数模型,推荐的词汇表大小范围是32,000-50,000,这一范围能够:

  • 提供足够的表达能力处理广泛文本
  • 保持合理的嵌入层大小(约0.5-1.5亿参数)
  • 与主流模型兼容,便于迁移学习
  • 在序列长度和计算效率间取得平衡

词汇表大小最佳值取决于多种因素,如语言、领域、模型大小和计算预算。实验表明,对于英文通用模型,增加词汇表超过50,000后收益递减,而维持至少30,000通常是必要的。

词汇表内容优化

除了大小,词汇表的内容组成也至关重要:

  1. 基本标记分布

    • 字符级标记:保证任何文本都可表示
    • 常见子词:高频率部分和词缀
    • 完整单词:常用词如"the", "and", "is"等
    • 常见短语:如"in the", "of the"等
  2. 领域特定标记

    • 根据目标应用添加专业术语
    • 为高价值领域增加特定词汇表示
    • 平衡通用性和专业性
  3. 特殊标记分配

    • 系统预留标记(如<s>, </s>, <pad>, <mask>
    • 任务特定标记(如<img>, <code>, <math>
    • 控制标记(用于引导生成)
  4. 多样性考虑

    • 不同语言的平衡表示
    • 各种编写风格(正式/非正式)的覆盖
    • 技术性和非技术性内容的平衡

实现词汇表优化流程

以下是一个词汇表优化流程实现:

def optimize_vocabulary(base_vocab, training_data, target_size, 
                        special_tokens, domain_specific_texts=None):
    """
    优化模型词汇表
    
    参数:
        base_vocab: 初始词汇表
        training_data: 训练数据迭代器
        target_size: 目标词汇表大小
        special_tokens: 必须保留的特殊标记
        domain_specific_texts: 可选的领域特定文本
        
    返回:
        optimized_vocab: 优化后的词汇表
    """
    # 步骤1: 分析当前词汇表使用情况
    token_usage = {token: 0 for token in base_vocab}
    total_tokens = 0
    
    print("分析标记使用频率...")
    for text in training_data:
        # 使用当前词汇表标记化文本
        tokens = tokenize(text, base_vocab)
        
        # 更新使用计数
        for token in tokens:
            if token in token_usage:
                token_usage[token] += 1
                total_tokens += 1
    
    # 步骤2: 识别低频和无用标记
    token_usage_ratio = {token: count/total_tokens 
                         for token, count in token_usage.items()}
    
    # 按使用率排序标记
    sorted_tokens = sorted(token_usage_ratio.items(), 
                          key=lambda x: x[1], reverse=True)
    
    # 提取必须保留的标记(特殊标记和高频标记)
    essential_tokens = set(special_tokens)
    essential_tokens.update([token for token, _ in 
                            sorted_tokens[:int(target_size * 0.7)]])
    
    # 步骤3: 如果有领域特定文本,处理它们
    domain_tokens = set()
    if domain_specific_texts:
        # 使用BPE在领域文本上训练额外标记
        print("处理领域特定文本...")
        domain_vocab, _ = train_bpe(domain_specific_texts, 
                                   int(target_size * 0.2))
        domain_tokens = set(domain_vocab)
    
    # 步骤4: 构建最终词汇表
    final_vocab = list(essential_tokens)
    
    # 添加领域特定标记
    remaining_slots = target_size - len(final_vocab)
    domain_to_add = min(len(domain_tokens), int(remaining_slots * 0.6))
    
    print(f"添加{domain_to_add}个领域特定标记...")
    final_vocab.extend(list(domain_tokens)[:domain_to_add])
    
    # 用一般频率标记填充剩余空间
    remaining_slots = target_size - len(final_vocab)
    if remaining_slots > 0:
        # 找出尚未包含的频率标记
        remaining_tokens = [token for token, _ in sorted_tokens 
                          if token not in essential_tokens 
                          and token not in domain_tokens]
        
        final_vocab.extend(remaining_tokens[:remaining_slots])
    
    # 确保词汇表大小正确
    assert len(final_vocab) <= target_size, "词汇表超出目标大小"
    
    print(f"最终词汇表大小: {len(final_vocab)}")
    return final_vocab

词汇表评估指标

如何评估词汇表质量?以下是一些关键指标:

  1. 压缩率:平均每个原始词被压缩为多少标记

    • 压缩率 = 原始单词数 / 标记数
    • 较高的压缩率表示更高效的编码
    • 通常在0.6-0.9之间,取决于语言和领域
  2. 覆盖率:单个标记表示完整单词的比例

    • 覆盖率 = 单标记词数 / 总词数
    • 反映了常用词的有效性
    • 对于英语,优秀的覆盖率在15-25%之间
  3. 均匀度:标记使用分布的均匀程度

    • 可以使用熵或基尼系数测量
    • 过低的均匀度表示资源浪费
    • 过高的均匀度可能表示特定领域表示不足
  4. OOV处理效率:处理未见词的效率

    • 平均每个OOV词被拆分为多少标记
    • 回退机制的成功率

一个平衡的词汇表应该在这些指标上表现良好,但具体权重取决于应用场景。例如,对于翻译系统,压缩率可能更重要;而对于代码生成,精确表示特定符号的能力可能更关键。

4. 特殊标记处理与序列长度管理

除了常规单词和子词标记外,现代LLM还需要特殊标记和长序列管理策略。这些机制控制着模型的输入/输出行为和上下文窗口利用。

特殊标记设计

特殊标记是词汇表中预留的、具有特定功能的标记,它们服务于多种目的:

  1. 结构标记

    • <s>/<bos> :序列开始标记
    • </s>/<eos> :序列结束标记
    • <pad> :填充标记,用于批处理对齐
    • <mask> :掩码标记,用于掩码语言模型训练
  2. 功能标记

    • <sep> :分隔不同文本段
    • <cls> :分类标记,通常用于表示整句
    • <unk> :未知标记,表示词汇表外单词
  3. 控制标记

    • <human>/<assistant> :对话角色标记
    • <system> :系统指令标记
    • 自定义控制标记:如<code>, <math>, <translation>
  4. 语言和任务标记

    • 语言标识符:如<en>, <zh>, <fr>
    • 任务指示器:如<summarize>, <qa>, <translate>

为20亿参数模型设计特殊标记时,建议采用以下策略:

  • 保持最小必要集:每个特殊标记都占用词汇空间,应谨慎添加
  • 一致的格式:如使用尖括号<>表示所有特殊标记
  • 有意义的命名:名称应直观反映功能
  • 预留扩展空间:为未来功能预留额外特殊标记

特殊标记实现与处理

特殊标记不仅需要添加到词汇表,还需要在模型架构和训练过程中特殊处理:

class SpecialTokenHandler:
    """特殊标记处理工具"""
    
    def __init__(self, tokenizer, special_token_dict):
        """
        初始化特殊标记处理器
        
        参数:
            tokenizer: 基础标记化器
            special_token_dict: 特殊标记配置,如
                {'bos_token': '<s>', 'eos_token': '</s>'}
        """
        self.tokenizer = tokenizer
        self.special_tokens = special_token_dict
        
        # 确保特殊标记在词汇表中
        self._add_special_tokens()
        
        # 缓存特殊标记ID
        self.special_token_ids = {
            name: self.tokenizer.convert_tokens_to_ids(token)
            for name, token in self.special_tokens.items()
        }
    
    def _add_special_tokens(self):
        """将特殊标记添加到词汇表"""
        special_tokens_list = list(self.special_tokens.values())
        original_vocab_size = len(self.tokenizer.vocab)
        
        # 添加缺失的特殊标记
        for token in special_tokens_list:
            if token not in self.tokenizer.vocab:
                self.tokenizer.vocab[token] = len(self.tokenizer.vocab)
        
        # 更新反向映射
        self.tokenizer.ids_to_tokens = {
            v: k for k, v in self.tokenizer.vocab.items()
        }
        
        new_vocab_size = len(self.tokenizer.vocab)
        print(f"添加了{new_vocab_size - original_vocab_size}个特殊标记")
    
    def prepare_for_model(self, token_ids, max_length=None, 
                          add_bos=True, add_eos=True, padding=True):
        """
        准备标记序列用于模型输入
        
        参数:
            token_ids: 原始标记ID序列
            max_length: 最大序列长度,None表示不截断
            add_bos: 是否添加开始标记
            add_eos: 是否添加结束标记
            padding: 是否填充到最大长度
            
        返回:
            processed_ids: 处理后的标记ID序列
            attention_mask: 注意力掩码,1表示非填充位置
        """
        processed_ids = []
        
        # 添加BOS标记
        if add_bos and 'bos_token' in self.special_tokens:
            processed_ids.append(self.special_token_ids['bos_token'])
        
        # 添加主体标记ID
        processed_ids.extend(token_ids)
        
        # 添加EOS标记
        if add_eos and 'eos_token' in self.special_tokens:
            processed_ids.append(self.special_token_ids['eos_token'])
        
        # 处理序列长度
        if max_length is not None:
            # 截断
            if len(processed_ids) > max_length:
                processed_ids = processed_ids[:max_length]
                # 确保EOS仍然存在(如果需要)
                if add_eos and 'eos_token' in self.special_tokens:
                    processed_ids[-1] = self.special_token_ids['eos_token']
            
            # 填充
            attention_mask = [1] * len(processed_ids)
            if padding and len(processed_ids) < max_length:
                pad_id = self.special_token_ids.get(
                    'pad_token', 0)  # 默认使用0
                padding_length = max_length - len(processed_ids)
                processed_ids.extend([pad_id] * padding_length)
                attention_mask.extend([0] * padding_length)
        else:
            attention_mask = [1] * len(processed_ids)
        
        return processed_ids, attention_mask

长序列处理

大型语言模型的一个关键特性是处理长上下文的能力,这需要有效的序列长度管理策略:

  1. 最大长度限制

    • 模型架构决定的硬性限制(如位置编码长度)
    • 内存和计算资源施加的实际限制
    • 典型值:2048-8192标记(较大模型达到32k以上)
  2. 长序列切分策略

    • 固定长度分块:简单但可能破坏文档结构
    • 文档边界感知:在文档边界处分割
    • 语义边界分割:在段落或章节边界分割
    • 滑动窗口:使用重叠窗口增加连续性
  3. 注意力掩码管理

    • 全局注意力 vs. 局部注意力
    • 分块注意力策略
    • 稀疏注意力模式
  4. 位置编码扩展

    • 插值方法扩展学习的位置编码
    • 相对位置编码减轻长度限制
    • ALiBi等无限长度方法

以下是处理长序列的实用实现:

def process_long_text(text, tokenizer, max_seq_length, 
                      overlap=0.1, respect_boundaries=True):
    """
    处理超出最大长度的文本
    
    参数:
        text: 输入文本
        tokenizer: 标记化器
        max_seq_length: 最大序列长度
        overlap: 重叠比例(0-1之间)
        respect_boundaries: 是否尊重文档边界
        
    返回:
        sequences: 处理后的序列列表
    """
    # 将文本标记化
    all_tokens = tokenizer.tokenize(text)
    all_token_ids = tokenizer.convert_tokens_to_ids(all_tokens)
    sequences = []
    
    # 如果总长度小于最大长度,直接返回
    if len(all_token_ids) <= max_seq_length:
        token_ids, mask = prepare_sequence(
            all_token_ids, max_seq_length, tokenizer)
        sequences.append((token_ids, mask))
        return sequences
    
    # 计算步长(考虑重叠)
    stride = int(max_seq_length * (1 - overlap))
    
    # 不考虑边界的简单分块
    if not respect_boundaries:
        for i in range(0, len(all_token_ids), stride):
            chunk = all_token_ids[i:i + max_seq_length]
            token_ids, mask = prepare_sequence(
                chunk, max_seq_length, tokenizer)
            sequences.append((token_ids, mask))
    
    # 尊重文档边界的分块
    else:
        # 首先按段落分割文本
        paragraphs = text.split("\n\n")
        paragraphs = [p for p in paragraphs if p.strip()]
        
        # 标记化每个段落
        paragraph_tokens = []
        for para in paragraphs:
            tokens = tokenizer.tokenize(para)
            token_ids = tokenizer.convert_tokens_to_ids(tokens)
            paragraph_tokens.append(token_ids)
        
        # 将段落组合成不超过最大长度的块
        current_chunk = []
        current_length = 0
        
        for para_ids in paragraph_tokens:
            if current_length + len(para_ids) > max_seq_length:
                # 如果当前块不为空,添加到序列中
                if current_chunk:
                    token_ids, mask = prepare_sequence(
                        current_chunk, max_seq_length, tokenizer)
                    sequences.append((token_ids, mask))
                
                # 重置当前块
                current_chunk = para_ids
                current_length = len(para_ids)
            else:
                # 添加段落到当前块
                current_chunk.extend(para_ids)
                current_length += len(para_ids)
        
        # 添加最后一个块
        if current_chunk:
            token_ids, mask = prepare_sequence(
                current_chunk, max_seq_length, tokenizer)
            sequences.append((token_ids, mask))
    
    return sequences
​
def prepare_sequence(token_ids, max_length, tokenizer):
    """准备单个序列,添加特殊标记和填充"""
    # 添加特殊标记
    if hasattr(tokenizer, 'bos_token_id') and tokenizer.bos_token_id:
        token_ids = [tokenizer.bos_token_id] + token_ids
    
    # 截断
    if len(token_ids) > max_length:
        token_ids = token_ids[:max_length]
        if hasattr(tokenizer, 'eos_token_id') and tokenizer.eos_token_id:
            token_ids[-1] = tokenizer.eos_token_id
    
    # 添加EOS(如果有空间)
    elif hasattr(tokenizer, 'eos_token_id') and tokenizer.eos_token_id:
        if len(token_ids) < max_length:
            token_ids.append(tokenizer.eos_token_id)
    
    # 创建注意力掩码
    attention_mask = [1] * len(token_ids)
    
    # 填充
    padding_length = max_length - len(token_ids)
    if padding_length > 0:
        if hasattr(tokenizer, 'pad_token_id') and tokenizer.pad_token_id:
            token_ids = token_ids + [tokenizer.pad_token_id] * padding_length
        else:
            token_ids = token_ids + [0] * padding_length
        attention_mask = attention_mask + [0] * padding_length
    
    return token_ids, attention_mask

序列批处理优化

高效的批处理策略可以显著提升训练效率。主要考虑因素包括:

  1. 动态批大小 vs. 固定序列长度

    • 固定标记数策略:总标记数固定(如batch_size * seq_length = constant
    • 可变批大小:根据序列长度动态调整批大小
    • 梯度累积:允许有效使用大批量
  2. 批内序列分组

    • 长度分桶(bucketing):相似长度的序列分到同一批次
    • 减少填充浪费:最小化每批次中的填充标记比例
    • 动态填充:仅填充到批次中最长序列的长度
  3. 注意力掩码优化

    • 全局注意力:每个位置可以关注所有位置
    • 因果注意力:每个位置只能关注自身及之前的位置
    • 分块注意力:仅允许关注相关块内的位置

高效批处理实现示例:

def create_length_based_batches(sequences, batch_size, 
                                max_length, length_bucket_width=32):
    """
    基于序列长度创建批次,减少填充
    
    参数:
        sequences: 序列列表
        batch_size: 目标批大小
        max_length: 最大序列长度
        length_bucket_width: 长度桶宽度
        
    返回:
        batches: 批次列表
    """
    # 计算每个序列的长度
    seq_lengths = [len(seq[0]) for seq in sequences]
    
    # 创建长度桶
    buckets = {}
    for i, length in enumerate(seq_lengths):
        # 向上取整到最近的桶边界
        bucket_idx = (length + length_bucket_width - 1) // length_bucket_width
        if bucket_idx not in buckets:
            buckets[bucket_idx] = []
        buckets[bucket_idx].append(i)
    
    # 从每个桶中创建批次
    batches = []
    for bucket_idx in sorted(buckets.keys()):
        bucket_indices = buckets[bucket_idx]
        
        # 将桶中的序列分成批次
        for i in range(0, len(bucket_indices), batch_size):
            batch_indices = bucket_indices[i:i + batch_size]
            
            # 获取当前批次的序列
            batch_sequences = [sequences[idx] for idx in batch_indices]
            
            # 找出当前批次中的最大序列长度
            batch_max_len = max(len(seq[0]) for seq in batch_sequences)
            # 将批次中的最大长度向上取整到桶宽度的倍数
            batch_max_len = ((batch_max_len + length_bucket_width - 1) 
                             // length_bucket_width) * length_bucket_width
            # 确保不超过全局最大长度
            batch_max_len = min(batch_max_len, max_length)
            
            # 准备批次输入
            batch_input_ids = []
            batch_attention_masks = []
            
            for token_ids, attention_mask in batch_sequences:
                # 剪裁或填充到批次最大长度
                if len(token_ids) < batch_max_len:
                    padding_length = batch_max_len - len(token_ids)
                    token_ids = token_ids + [0] * padding_length
                    attention_mask = attention_mask + [0] * padding_length
                else:
                    token_ids = token_ids[:batch_max_len]
                    attention_mask = attention_mask[:batch_max_len]
                
                batch_input_ids.append(token_ids)
                batch_attention_masks.append(attention_mask)
            
            batches.append({
                'input_ids': batch_input_ids,
                'attention_mask': batch_attention_masks,
                'batch_max_len': batch_max_len
            })
    
    return batches

这种优化的批处理可以显著减少填充浪费,提高训练效率。在某些情况下,它可以将训练吞吐量提高20-40%。

总结与展望

在本课中,我们深入探讨了文本标记化与词汇表构建的理论和实践。我们比较了主流子词标记化技术(BPE、WordPiece和SentencePiece),从零实现了BPE算法,探讨了词汇表构建与优化的关键策略,并学习了特殊标记处理和序列长度管理的实用技术。

标记化是连接自然语言与模型理解的桥梁,直接影响着模型的表达能力、训练效率和生成质量。通过本课的学习,我们不仅理解了现代LLM如何处理和表示文本,还掌握了实现和优化这些组件的具体方法。

关键要点回顾:

  1. 子词标记化平衡了词汇表大小和表达效率,是现代LLM的标准方法
  2. BPE算法通过迭代合并最频繁的相邻标记对,自动发现有用的子词单元
  3. 词汇表设计需要平衡表达效率、计算资源和泛化能力
  4. 特殊标记序列长度管理对控制模型行为和处理长文本至关重要

展望未来,标记化技术还有多个发展方向:

  • 多语言标记化:更好地处理不同语言的特性,尤其是低资源语言
  • 上下文感知标记化:考虑语境的动态标记化技术
  • 多模态标记化:整合文本、代码、数学符号等多种表示形式
  • 可控制的标记化:允许用户根据应用需求自定义标记化行为

在下一课中,我们将把注意力转向模型的基础架构,学习如何实现transformer编码器和解码器,这是构建LLM的核心组件。

延伸阅读

  1. "Neural Machine Translation of Rare Words with Subword Units" (Sennrich et al., 2016) - BPE在NLP中的首次应用
  2. "SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing" (Kudo & Richardson, 2018)
  3. "Revisiting Pre-Trained Models for Chinese Natural Language Processing" (Cui et al., 2020) - 中文等语言的标记化挑战
  4. "Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer" (Raffel et al., 2020) - T5模型的标记化策略
  5. "RoFormer: Enhanced Transformer with Rotary Position Embedding" (Su et al., 2021) - 处理长序列位置表示的新方法

思考问题:

  1. 在为不同语言设计标记化策略时,应该使用单一多语言词汇表还是每种语言独立的词汇表?讨论两种方法的优缺点及其对模型性能的潜在影响。
  2. 如果你需要为特定领域(如医学、法律或编程)构建LLM,你会如何调整标记化和词汇表策略以最大化模型在该领域的效果?
  3. BPE算法是基于频率的贪婪合并策略。你能想到如何改进这个算法,使其考虑更多语言学特性或生成更有意义的子词单位吗?
  4. 比较字节级BPE和字符级BPE的优缺点。在什么情况下字节级BPE可能是更好的选择,特别是考虑到多语言处理和稀有字符?