chapter02.md — Tokenizer 与数据预处理(BPE 优化)

1. 开篇段落

本章是连接原始文本语料与模型数值输入的关键桥梁,也是整个 LLM 训练流程中 杠杆效应最强 的环节之一。一个精心设计和优化的 Tokenizer 如同为模型安装了一副高清、宽视角的“眼睛”,它定义了模型理解和生成文本的基本单元,其质量直接影响最终模型的性能天花板、计算效率乃至对特定领域(如代码、多语言、数学公式)的掌握程度。本章的学习目标是:深入掌握 Byte-Pair Encoding (BPE) 的训练原理与关键超参的精细权衡;理解并实现高效的 packed sequence 策略以最大化训练吞吐量;并最终将海量文本语料转换为适合大规模并行加载、高性能的二进制格式(如 .idx/.binParquet),为后续的万亿 token 级训练铺平道路。

2. 文字论述

2.1 语料治理概览(面向训练的轻量级视角)

在启动 Tokenizer 训练之前,我们必须对原始语料进行一系列预处理。尽管完整的数据治理(Data Governance)是一个庞大的独立工程,涉及复杂的流水线和数据血缘追踪,但作为算法科学家,我们需要关注并确保几个核心环节的质量,因为它们直接影响模型的稳定性和最终能力。

  • 全局去重 (Global Deduplication)

    • 目的:大规模网络语料(如 Common Crawl)中存在海量的精确或近似重复内容(例如,网站页眉页脚、引用转发、文章转载)。若不去除,模型将在这些冗余数据上浪费巨量算力,导致其对高频模式的“过拟合”,损害泛化能力和多样性。验证集和测试集的污染尤其致命,会导致评估指标虚高,产生模型性能优越的假象。
    • 方法:对于 TB 级别的语料,精确的 n-gram 比较不可行。工业界标准做法是采用 MinHash + LSH (Locality-Sensitive Hashing)
      • MinHash: 将每个文档表示为一个固定大小的整数签名(signature),这个签名可以近似地保持文档间的 Jaccard 相似度。
      • LSH: 将这些签名放入多个哈希桶中,使得相似的签名有很大概率落入同一个桶。我们只需在桶内进行精确比较,大大降低了计算复杂度。
    • Rule-of-thumb:至少对文档级别进行一次严格的全局去重。对于高质量数据集,可以进一步进行段落级别的去重。
  • 启发式过滤 (Heuristic Filtering)

    • 目的:移除低质量、无信息或可能对模型产生负面影响的文本。
    • 常见策略
      • 长度过滤:移除过短(如少于 200 字符)或过长(如超过 100,000 字符)的文档。
      • 符号/数字比例:移符号或数字占比异常高的文档,它们通常是乱码、代码片段或表格数据,可能需要专门处理。
      • 词汇丰富度:使用“重复词比例”等指标过滤掉内容单调的文本(如 "A A A A...")。
      • 语言识别:使用 fastText 等库识别并过滤掉非目标语种的文本,或为多语言模型打上语种标签。
  • PII 与安全过滤 (PII & Safety Filtering)

    • 目的:移除个人身份信息(PII)如姓名、电话、邮箱、身份证号等,并过滤掉有害内容(仇恨言论、暴力、色情等)。
    • 方法:通常结合 正则表达式基于模型的分类器。这是一个复杂的领域,通常需要专门的团队和工具链支持。对于研究性质的训练,至少应采用开源的 PII 移除工具和内容分类器进行一轮清洗。

完成上述步骤后,我们得到一个相对干净、去重的文本语料库,可以用于训练一个高质量的 Tokenizer。

2.2 BPE 训练与优化:LLM 的“字母表”

BPE (Byte-Pair Encoding) 算法通过迭代合并最高频的字节对,构建了一个从字节到高层语义概念(子词)的层级化词表。它优雅地解决了纯词级(OOV问题)和纯字符/字节级(序列过长) tokenizer 的弊端。

Tokenizer 实现管线对比:huggingface/tokenizers vs sentencepiece

理解两种主流实现方式的差异,有助于我们做出更明智的选择。

  • sentencepiece (Google):倾向于一个端到端的解决方案。它将输入视为原始字节流,通过用户定义的规则(如 NFKC 规范化)处理后,直接应用 BPE 合并。其核心设计哲学是“无损”,即任何文本都能被 tokenize 和 de-tokenize 回原始形式,且不依赖于语言特定的预切分规则(如空格)。空格被特殊处理为一个元符号 (U+2581)。
  • huggingface/tokenizers (Hugging Face):提供了一个更模块化、可定制的管线,常包含四个阶段:
    1. Normalizer: 文本规范化,如 NFC, NFKC, 小写转换,去除多余空格。
    2. PreTokenizer: 预切分,根据规则(如空格、标点)将文本分割成初始的“词”单元。这是 BPE 算法开始合并的基础。
    3. Model: BPE 核心算法,在 PreTokenizer 产生的词块内部进行迭代合并。
    4. Post-Processor: 后处理,添加特殊 token,如 <s></s>
Input: "  Hello,   world!!  "
  |
[Normalizer: Strip, NFKC]
  |
V: "Hello, world!!"
  |
[PreTokenizer: WhitespaceSplit]
  |
V: ["Hello,", "world!!"]
  |
[Model: BPE on each sub-word]
  |
V: [["He", "llo", ","], ["wor", "ld", "!!"]]
  |
[Post-Processor: Add BOS/EOS]
  |
Output: [<s>, "He", "llo", ",", "wor", "ld", "!!", </s>]

关键超参与权衡的深度剖析:

  1. vocab_size (词表大小)这是最具战略性的决策
      1. 压缩率vocab_size 越大,越可能将长词或常用短语编码为单个 token,使得平均每个词对应的 token 数减少,序列变短。
      2. 模型参数lm_head(输出层)和 token_embeddings(输入层)的参数量与 vocab_size 成正比。公式为 Params ≈ 2 * vocab_size * hidden_dim。对于一个 hidden_dim=4096 的 7B 模型,将 vocab_size 从 32k 增加到 64k,会增加 2 * (64000 - 32000) * 4096 ≈ 268M 的参数。
      3. 表达能力与粒度:大词表能更精细地捕捉语义单元,尤其在代码(函数名、变量)、多语言混合语料中优势明显。小词表则迫使模型在子词层面组合语义,可能增强其形态学上的泛化能力。
    • 权衡表格: | vocab_size | 优点 | 缺点 | 适用场景 |
vocab_size 优点 缺点 适用场景
小 (32k) 嵌入层参数少,对形态丰富语言友好,迫使模型学习组合泛化。 序列更长,增加计算负担(尤其在 Attention 中),对代码/多语言不友好。 LLaMA-1/2 时代,以英文为主的通用语料。
中 (65k) 兼顾了压缩率和参数量,对中英文混合语料表现稳健。 相比 128k 对代码的表达可能稍弱。 当前主流选择,平衡性好。
大 (128k+) 极高的压缩率(序列短),对代码和多语言非常友好,单个 token 语义更丰富。 显著增加模型参数和显存占用,可能过拟合语料中的罕见词或噪声。 LLaMA-3 时代,高质量、大规模多语言/代码语料。
*   **Rule-of-thumb**:
    *   **启动项目**:从 **65k** 开始是一个非常安全的基线。
    *   **预算紧张/模型小**:可以考虑 **32k**。
    *   **代码/多语言是核心**:并且有高质量数据支持,大胆上调至 **96k 或 128k**。
  1. 数字、空白与结构化信息的处理
    • 数字:LLaMA 系列 Tokenizer 将数字按单个 digit 切分(123 -> 1, 2, 3)。这赋予了模型出色的算术推理和处理任意数字的能力,是当前推荐的标准实践。
    • 空白:对于代码或需要保留格式的文本,必须保留多个连续空格和换行符。huggingface/tokenizers 中的 PreTokenizer.WhitespaceSplit()sentencepiece 的字节级处理都能很好地满足这一需求。
    • 字节级回退 (Byte-level Fallback):当遇到 <unk>(未知 token)时,一个健壮的 Tokenizer 应该能回退到 UTF-8 字节级别进行编码。这确保了 任何字符串 都可以被示,杜绝了真正意义上的 OOV 问题。这是 sentencepiece 的原生特性,在 huggingface/tokenizers 中可以通过 decoders.ByteLevel 实现。

2.3 训练前分块与打包 (Chunking and Packing):最大化计算效率

模型训练的 forwardbackward 操作在固定尺寸的张量上效率最高。因此,我们需要将变长的文档序列转换为固定长度 L_ctx (e.g., 4096) 的训练样本。

  • 策略一:Un-packed (Padding) - 应极力避免

    • 简单地将每个文档 tokenize 后,用 <pad> token 填充到 L_ctx
    • 致命缺陷:假设一个 batch 中有大量短文档,有效 token 的比例可能低于 50%。Transformer 的自注意力计算复杂度是 O(L_ctx^2),这意味着大量的计算和显存带宽被浪费在处理无意义的 <pad> token 上。在万亿 token 级别的训练中,这种浪费是不可接受的
  • 策略二:Packed (Concatenation) - 大规模预训练的标准

    • 流程
      1. 将语料库中的所有文档逐一 tokenize。
      2. 在每个文档的 token 序列末尾添加一个 </s> (EOS) token。
      3. 将所有处理后的 token 序列拼接成一个巨大的、一维的 token "超级流"。
      4. 从这个超级流中,不重叠地、连续地切出长度为 L_ctx 的序列块。
    • 图示与 Attention Mask:
Token Stream: [doc1_toks..., </s>, doc2_toks..., </s>, doc3_toks...]
                <-- Chunk 1 (len=L_ctx) --> <-- Chunk 2 (len=L_ctx) -->

Example Chunk 1: [d1_t1, d1_t2, ..., </s>, d2_t1, d2_t2, ...]

Attention Mask (Causal Packing - 理论上更精确):
Query \ Key | d1_t1 | d1_t2 | </s> | d2_t1 | d2_t2
-------------------------------------------------
d1_t1       |   1   |   0   |   0   |   0   |   0
d1_t2       |   1   |   1   |   0   |   0   |   0
</s>        |   1   |   1   |   1   |   0   |   0   <-- doc1 结束
d2_t1       |   0   |   0   |   0   |   1   |   0   <-- doc2 开始
d2_t2       |   0   |   0   |   0   |   1   |   1

Attention Mask (Simple/Permissive Packing - 实践中更常用):

- 完全忽略文档边界,整个 chunk 使用标准的因果注意力掩码。
- 模型被期望通过 `</s>` token 自行学习文档的边界。
*   **Rule-of-thumb****对于从零预训练直接使用忽略文档边界的 Simple Packing**其实现简单吞吐量最高实证研究表明这对模型最终性能的影响微乎其微因为在海量数据中跨文档的 attention 噪声会被模型自然地平滑掉

2.4 构建最终数据集格式:为高速 IO 做准备

预处理的最后一步是将 packed token 序列持久化存储,以便在训练期间被成百上千个 GPU worker 高效、并行地读取。

  1. .idx/.bin 格式 (MMap-able Binary)

    • 结构:一个 .bin 文件存储所有 token ID(np.uint16np.uint32)的巨大二进制数组,一个 .idx 文件存储元数据(如版本、数据类型、总 token 数)。
    • 核心优势:mmapmmap (memory-map) 是一种操作系统特性,它将文件内容直接映射到进程的虚拟地址空间。这意味着数据不需要从文件系统缓存拷贝到应用内存,而是由操作系统在需要时按页(page)懒加载。对于多 worker 读取同一文件,mmap 提供了极高的效率和内存共享。
    • 适用场景:单机多卡或共享文件系统(如 CPFS/NFS)的 HPC 环境。这是追求极致 IO 性能的经典选择。
  2. Parquet 格式 (Columnar Storage)

    • 结构:一种高效的列式存储格式。我们可以将每个 packed sequence 存为一行,包含 token_ids(一个数组/列表)列,还可以附加其他元数据列,如 source_dataset_id
    • 核心优势:生态与灵活性。Parquet 是大数据生态(Spark, Dask)的标格式。使用 pyarrow 库可以非常高效地流式读取和解码。它支持多种压缩算法(Snappy, ZSTD),可以在磁盘占用和读取速度之间做权衡。
    • 适用场景:需要与数据分析工具链深度集成,或需要存储丰富元数据的场景。在云存储(如 S3, GCS)上表现良好。
  3. WebDataset (Sharded Tar Archives)

    • 结构:将数据分片(shard)存储为一系列 .tar 文件。每个 .tar 文件内部包含多个样本,每个样本可以有多个文件(如 .json for metadata, .txt for text)。
    • 核心优势:流式处理与解耦。WebDataset 天然支持流式读取,非常适合对象存储和分布式环境。它避免了需要一个中心化的索引文件,每个 worker 可以独立处理一部分 .tar 文件。
    • 适用场景:云原生训练环境,数据源在对象存储上,强调去中心化和流式处理。
  • Rule-of-thumb
    • HPC / 共享文件系统优先选择 .idx/.bin,其 mmap 带来的 IO 效率几乎是无开销的。
    • 云环境 / 需要与数据分析集成Parquet 是一个更现代化、更灵活的选择。
    • 数据准备和训练阶段高度解耦的流式作业:可以考虑 WebDataset

3. 本章小结

  • Tokenizer 是战略决策:其设计深刻影响模型的能力上限和计算成本。vocab_size 是核心权衡点,平衡着压缩率、参数量和表达粒度。65k 是一个稳健的现代基线。
  • 细节是魔鬼:数字、空白和特殊符号的处理方式,以及是否具备字节级回退能力,共同决定了 Tokenizer 的鲁棒性。采用模块化的 huggingface/tokenizers 能提供更精细的控制。
  • 效率源于打包 (Packing):在大规模预训练中,必须使用 Packed Sequence 策略,将 tokenized 文档流拼接后分块,以消除因 padding 造成的巨大计算浪费。
  • 为 IO 优化存储:选择合适二进制格式是训练吞吐量的最后保障。.idx/.bin 通过 mmap 提供极致性能,Parquet 则在灵活性和生态系统上更胜一筹。

4. 常见陷阱与错误 (Gotchas)

  1. 词表污染 (Vocabulary Contamination):用包含验证/测试集的语料来训练 Tokenizer。这会泄露评估数据的信息,导致 PPL 等指标虚高。解决方案:严格划分数据集,Tokenizer 只能在训练集的子集(通常是 10-50B token)上训练。
  2. 整数类型溢出:当 vocab_size > 65,536 时,若仍用 uint16 存储 token ID,将导致 ID 回绕,数据被严重破坏且难以排查。解决方案:根据 vocab_size 选用正确的数据类型(np.uint16 vs np.uint32)。
  3. 不一致的文本规范化 (Inconsistent Normalization):在 Tokenizer 训练和实际 tokenize 数据时使用了不同的 Unicode 规范化(如 NFC vs NFKC)。这会导致 token ID 不匹配。解决方案:在整个流程中锁定规范方法。
  4. Tokenizer 速度瓶颈:使用纯 Python 实现的 Tokenizer 处理 TB 级数据会成为整个预处理流程的瓶颈。解决方案:必须使用 Rust-based 的高性能库(如 huggingface/tokenizers)并利用多进程并行处理。
  5. 打包时的边界处理不当 (Off-by-One in Packing):在拼接和切分 token 流时,由于 </s> 的添加和 L_ctx 的整除计算,很容易出现 off-by-one 错误,导致序列长度不匹配或数据丢失。解决方案:编写单元测试,对一个小文件进行打包,并手动验证输出的 token 序列是否正确拼接和切分。对于流末尾不足一个 L_ctx 的部分,通常直接丢弃。