SERIES · 斯坦福CS336: Language Modeling from Scratch

Stanford CS336: assignment 1

2025-11-22 · 60 min read · by GUMP

Stanford CS336: assignment 1

assignment 1

低资源/降级技巧:性能分析 应使⽤如 cProfile 或 scalene 等性能分析⼯具来识别实现中的瓶颈,并重点优化这些部分。

字节对编码(BPE)分词器

2.1 Unicode standard

Problem (unicode1): Understanding Unicode (1 point)

2.2 Unicode Encodings

Unicode 与编码

  • Unicode 标准定义了 字符到码点(整数)的映射
  • 直接用 Unicode 码点训练 tokenizer 不现实:
    • 词表太大(~150K)
    • 稀疏(很多字符很少出现)

解决方法:Unicode 编码

  • 将 Unicode 字符转换为 字节序列
  • 标准三种编码:
    • UTF-8(互联网主流,占比 >98%)
    • UTF-16
    • UTF-32

Python 示例:UTF-8 编码

python
test_string = "hello! こんにちは!"
# 字符串编码为 UTF-8
utf8_encoded = test_string.encode("utf-8")
print(utf8_encoded)
# b'hello! \\xe3\\x81\\x93\\xe3\\x82\\x93\\xe3\\x81\\xab\\xe3\\x81\\xa1\\xe3\\x81\\xaf!'
# 类型:bytes
print(type(utf8_encoded)) # <class 'bytes'>
# 获取底层字节值(范围 0~255)
list(utf8_encoded)
# [104, 101, 108, 108, 111, 33, 32, 227, 129, 147, 227, 130, 147, 227, 129, 171, 227, 129, 161, 227, 129, 175, 33]
# 字符数 vs 字节数
len(test_string) # 13
len(utf8_encoded) # 23
# 解码回 Unicode 字符串
print(utf8_encoded.decode("utf-8")) # "hello! こんにちは!"

关键现象

  • bytes 类型可通过 list() 查看每个字节的整数值(0–255)。
  • 一个 Unicode 字符 ≠ 一个字节(UTF-8 可变长编码)。
  • 示例:
    • len("hello! こんにちは!") = 13 (13 个字符)
    • len(utf8_encoded) = 23 (23 个字节)

意义

  • Unicode 码点序列(0–154,997) 转换为 字节序列(0–255)
  • 字节词表大小仅 256,更易处理。
  • 字节级分词优势
    • 不会有 OOV(out-of-vocabulary)问题。
    • 任意文本都能转化为字节序列。

Problem (unicode2): Unicode Encodings

2.3 Subword Tokenization

  • 背景
    • 词级分词器:词汇外(OOV)问题严重,但序列较短(例如 10 个词 = 10 个 token)。
    • 字节级分词器:没有 OOV 问题,但序列过长(10 个词可能变成 50+ 个 token),训练更慢,并引入更长的依赖关系。
  • 子词分词器(Subword Tokenizer)
    • 介于词级和字节级之间,平衡 OOV 问题和序列长度。
    • 字节级分词器词表只有 256 个 token(所有字节)。
    • 子词分词器通过增加词表规模,压缩输入字节序列。
    • 例如:字节序列 b'the' 出现频繁 → 在词表中加入 'the' → 原本 3 个 token 变成 1 个 token。
  • BPE(Byte-Pair Encoding)
    • 一种压缩算法(Gage, 1994;Sennrich et al., 2016 提出用于 NLP)。
    • 迭代合并训练语料中最频繁的字节对,生成新的 token。
    • 如果一个词出现足够多次,它就会被整体作为单个子词单元。
  • 结果
    • BPE 分词器(BPE Tokenizer) = 字节或合并后的字节序列。
    • 既能避免 OOV 问题,又能保持合理的输入长度。
    • 词表的构建过程称为 训练分词器

2.4 BPE Tokenizer Training

BPE 分词器的训练过程主要分为 三步

  1. Vocabulary Initialization(词表初始化)

    • 初始词表为 所有可能的字节值(0–255,共 256 个 token)。
    • 每个字节对应一个唯一的整数 ID。
  2. Pre-tokenization(预分词)

    • 目的:避免逐字节扫描语料库的高计算开销,并减少语义相近但带标点的词被割裂(如 dog! vs dog.)。
    • 方法
      • 将语料进行粗粒度分词(pre-token)。
      • 每个 pre-token 转换为 UTF-8 字节序列。
      • 统计字节对的频率时,按 pre-token 出现次数加权(如 'text' 出现 10 次,则 't''e' 的相邻统计 +10)。
    • 实现方式
      • Sennrich et al. (2016):用空格分词 s.split(" ")

      • GPT-2 / tiktoken:使用 正则表达式预分词器

        python
        PAT = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
      • 推荐用 re.finditer 来遍历预分词结果(避免存储大规模 token 列表)。

  3. Compute BPE Merges(计算 BPE 合并)

    • 算法核心:
      1. 迭代统计所有相邻字节对的频率。
      2. 找到频率最高的字节对 (A, B)
      3. 将所有 (A, B) 替换为新 token "AB"
      4. "AB" 加入词表。
    • 结果:最终词表大小 = 初始 256 + BPE 合并次数
    • 注意事项
      • 不跨 pre-token 边界统计合并对。

      • 若存在频率并列,按 字典序最大 的 pair 合并:

        python
        max([("A", "B"), ("A", "C"), ("B", "ZZ"), ("BA", "A")])
        # 结果: ('BA', 'A')
  4. Special Tokens(特殊 token)

    • 一些字符串(如 &lt;|endoftext|&gt;)需保留为单一 token,不应被拆分。
    • 在训练时,必须把这些特殊 token 加入词表,并为其分配固定 ID。
  5. 参考实现

    • Sennrich et al. (2016) 的 Algorithm 1:给出了一版低效的 BPE 实现,可作为练习帮助理解上述过程。

👉 总结一句话:

BPE 分词器训练 = 初始化 256 字节词表 → 预分词(统计频率)→ 迭代字节对合并 → 加入特殊 token,最终得到既紧凑又可扩展的词表。

Example (bpe_example): BPE training example

2.5 Experimenting with BPE Tokenizer Training

在 TinyStories 数据集上训练一个字节级 BPE(Byte Pair Encoding)分词器,并通过多进程并行化来优化性能。

并行化预分词处理

问题: 预分词是主要性能瓶颈 解决方案: 使用 multiprocessing 库进行并行处理

关键实现:

  • 将语料库按特殊标记 &lt;|endoftext|&gt; 分块
  • 确保分块边界始终在特殊标记开头,避免跨文档合并
  • 使用提供的 find_chunk_boundaries() 函数获取分块边界

分块原理:

python
# 每个进程处理一个文本块
for start, end in zip(boundaries[:-1], boundaries[1:]):
f.seek(start)
chunk = f.read(end - start).decode("utf-8", errors="ignore")
# 对chunk进行预分词处理

优势:

  • 语义完整性:不会在文档中间切断
  • 独立处理:各块可以并行处理
  • 内存效率:每次只处理部分数据

预分词前移除特殊标记

目的: 防止跨文档边界的合并操作

处理流程:

  1. 按特殊标记分割文本块:[文档1] &lt;|endoftext|&gt; [文档2]
  2. 移除特殊标记
  3. 分别对每个文档进行预分词:[文档1][文档2]

实现方式:

python
# 使用 re.split 按特殊标记分割
pattern = "|".join(re.escape(token) for token in special_tokens)
documents = re.split(pattern, text_chunk)

注意事项:

  • 使用 re.escape() 处理特殊字符(如 |
  • 避免跨文档的字节对合并

优化合并步骤

问题: 朴素实现每次合并都要遍历所有字节对 解决方案: 建立计数索引,增量更新统计

优化策略:

  • 建立索引: 创建所有字节对的计数缓存
  • 增量更新: 只更新与已合并字节对重叠的配对统计
  • 避免全遍历: 不再显式遍历每个字节对进行频率统计

性能提升:

  • 显著加速 BPE 训练过程
  • 减少重复计算开销

限制:

  • 合并环节在 Python 中无法并行化
  • 但缓存机制仍能带来可观加速

实施要点

数据处理流程

  1. 数据准备: 查看和理解 TinyStories 数据集结构
  2. 文件分块: 使用 find_chunk_boundaries()&lt;|endoftext|&gt; 分块
  3. 并行预分词: 多进程处理各个文本块
  4. 特殊标记处理: 移除标记,避免跨文档合并
  5. BPE训练: 使用优化的合并算法

关键技术细节

  • 分块边界: 确保在 &lt;|endoftext|&gt; 处分割
  • 内存管理: 使用 4KB 小块逐步读取
  • 编码处理: 使用 decode("utf-8", errors="ignore") 处理编码问题
  • 去重排序: sorted(set(chunk_boundaries)) 确保边界唯一性

测试验证

  • 使用 test_train_bpe_special_tokens 测试用例验证特殊标记处理功能
  • 确保分块和合并逻辑的正确性

性能优化总结

  1. 并行化: 预分词阶段通过多进程提升速度
  2. 智能分块: 保证语义完整性的同时实现并行处理
  3. 缓存优化: 合并步骤使用增量更新减少计算开销
  4. 内存优化: 分块读取避免内存溢出

这种实现方式在保证训练质量的同时,显著提升了 BPE 分词器的训练效率。

Problem (train_bpe): BPE Tokenizer Training

Problem (train_bpe_tinystories): BPE Training on TinyStories

Build and Train GPT-4 Tokenizer from scratch

Implementing A Byte Pair Encoding (BPE) Tokenizer From Scratch

How to Train BPE, WordPiece, and Unigram Tokenizers from Scratch using Hugging Face

NLP From Scratch— Part 1: BPE Tokenization

https://github.com/karpathy/minbpe

https://github.com/JohannesVod/QuickBPE

https://github.com/openai/tiktoken

https://github.com/glample/fastBPE

https://github.com/bheinzerling/bpemb