Chapter 12 — step11 BPE 分词

对应实践course/practice/labs/lab12-step11/
主要修改文件course/practice/labs/lab12-step11/framework/student.c
验证命令make clean && make test

前面的课程一直默认在一个比较小、比较稳定的字符级词表上工作。
这种设计在教学初期非常合理,因为它简单、直观,而且几乎不会遇到 OOV 之类的问题。

但字符级表示也有很明显的代价:
一句普通文本会被拆成很多很碎的小 token,模型不得不在更长的序列上工作。

BPE 章节要解决的,就是这个输入表示层的问题。
它并不直接改变 GPT 结构,也不直接改变训练算法,而是在更上游的地方改变:

文本在进入模型之前,到底被切成怎样的 token 单位。

在当前课程里,Lab12 同样采用了非常克制的实践切法。
它没有要求学员从头写完整 BPE 系统,而是只把两个最核心的算子拆出来:

  • student_bpe_count_pairs
  • student_bpe_apply_merge

这意味着本章真正要你掌握的是 BPE 的核心循环直觉,而不是记住一整个工程实现的所有细节。

12.1 为什么字符级表示会变得不够用

字符级 tokenizer 的最大优点是稳:
只要字符集定义好了,任何文本都能被拆开。

但它的最大问题也很明显:
很多本来可以作为整体被理解的常见片段,会被拆得过碎。

比如:

hello

在字符级里会被拆成 5 个 token。
而对于一个已经在语料中反复出现过的常见词,这种拆法会让模型承担额外的序列长度和学习负担。

BPE 的出发点正是:

如果一对相邻 token 总是一起出现,那不如把它们合成一个新的、更大的 token。

12.2 本章你要建立哪些判断

这一章结束后,你应当能够清楚说出:

  1. BPE 的一个训练循环为什么可以概括成“统计相邻 pair -> 合并最常见 pair”。
  2. 为什么只有频次足够高的 pair 才值得合并。
  3. 为什么一次合并会让序列长度变短,而词表大小变大。
  4. 为什么 BPE 既能压缩常见词,又仍然保留退回字符级的能力。
  5. 为什么 BPE 的真正核心并不在“花哨数据结构”,而在那两个基本算子。

这些判断一旦建立起来,你后面看 bpe_train 这种完整实现时,就不会只看到一团循环和数组。

12.3 先看 practice target:这章改哪里

本章实践目录是:

course/practice/labs/lab12-step11/
├── TASK.md
├── Makefile
└── framework/
    ├── student.c      <- 主要修改这里
    ├── student.h
    ├── verify.c       <- 自动验证,不改
    └── verify.h

本章需要你实现的函数有两个:

  • student_bpe_count_pairs
  • student_bpe_apply_merge

这个切法非常好,因为它恰好把 BPE 的训练循环拆成了最核心的两半:

  1. 先看当前序列里哪一对最常一起出现;
  2. 再把这一对在序列里替换成新的 token。

你只要把这两步真正想明白,BPE 的整体直觉就已经建立起来了。

12.4 为什么“找最高频 pair”是 BPE 的核心观察

如果把一段文本先看成字符序列,那么相邻两个 token 组成的 pair 就是 BPE 最基础的观察单位。

例如在:

ababab

里,(a, b) 显然反复出现。
这说明对当前语料来说,a 后面很常跟着 b,它们作为整体出现的频率很高。

于是 BPE 的想法非常朴素:

既然这对经常一起出现,那就把它们焊成一个新的 token。

这一步不是在猜语义,而是在做统计压缩。
也正因为如此,本章非常适合先把“统计最频繁 pair”的动作单独拿出来实现。

12.5 为什么“频次至少 2”是一个自然阈值

如果一对相邻 token 只出现过一次,那把它们合并通常没有太大意义。
因为这更像一次偶然相邻,而不是语料中稳定存在的局部模式。

所以 BPE 会天然偏向那些至少重复出现过几次的 pair。
在当前教学框架里,验证器就采用了一个最小可理解阈值:

  • 最高频如果连 2 次都不到,就返回 0,表示没有值得合并的 pair。

这有很好的教学价值。因为它把“什么时候该停止合并”这件事也一起讲清楚了。

12.6 为什么“应用 merge”不是简单替换字符串

student_bpe_apply_merge 的任务,看起来像是在做替换。
但它和普通字符串替换又不完全一样。

它处理的是 token 序列,因此更准确地说,是在做:

  1. 从左到右扫描;
  2. 一旦看到 (first, second) 这个相邻 pair;
  3. 就写入一个新的 new_id,并跳过下一个位置。

这里最值得建立的直觉是:

一次 merge 会把两个旧 token 折叠成一个新 token,所以序列长度只会变短或不变,不会变长。

这也是 BPE 能压缩输入长度的直接原因。

12.7 为什么本章故意不用完整系统来起步

如果一上来就让学员去读完整 bpe_tokenizer.c,大多数人会先被各种工程细节包住:

  • 词表存储;
  • merges 列表;
  • encode / decode 路径;
  • 文件保存加载。

这些当然都是真实系统的一部分,但它们不适合做第一观察点。

当前课程更务实的策略是:
先把“统计 pair”和“应用 merge”这两个核心动作亲手写出来。

这样你后面再看完整 BPE 系统时,就会知道哪些代码是在服务这两个动作,哪些只是外围管理。

12.8 本章实践步骤

task 12.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab12-step11

建议先读:

  • framework/student.h
  • framework/student.c
  • framework/verify.c

当前验证器会检查:

  1. ababab 里能否找到最高频 pair;
  2. 没有重复 pair 时,是否正确返回 0;
  3. (a, b) 合并成新 id 后,序列内容和长度是否正确;
  4. 在一段更长文本上,重复做合并时,序列是否真的变短。

这些检查很适合作为 BPE 入门,因为它们全都对准“局部统计”和“局部替换”这两个核心动作。

task 12.2:实现 student_bpe_count_pairs

这一题建议你不要一开始就追求复杂数据结构。
当前 lab 的输入规模完全允许你先用简单暴力的方式,把逻辑写清楚。

最重要的是建立顺序:

  1. 先枚举所有相邻 pair;
  2. 再统计每个 pair 的次数;
  3. 选出最高频那个;
  4. 如果频次不到 2,就明确返回 0。

task 12.3:实现 student_bpe_apply_merge

这一题要特别注意扫描时的位置推进。

一旦你把 (first, second) 合成了 new_id,就意味着:

  • 当前 pair 已经被消耗;
  • 下一轮扫描不能再把 second 当成新的起点重复处理。

所以这题本质上是在训练一种非常基础但非常重要的序列处理意识:
当输入和输出的步长不一致时,扫描指针该怎样推进。

task 12.4:运行当前真实基线

在还没补完两个函数前,先执行:

make clean && make test

当前 Lab12 的真实初始状态是:

  • 1 通过,3 失败

已经通过的通常是“没有可合并 pair 时正确返回 0”这类保底路径。
真正和 BPE 核心相关的部分,例如:

  • 找最高频 pair;
  • 正确合并;
  • 多轮合并后长度变化;

目前都还没完成。

这说明当前实验代码已经把问题范围压得很小,而且失败点高度集中,非常适合作为 BPE 核心算子的第一站。

task 12.5:完成后重新验证

当你补完两个函数后,再运行:

make clean && make test

如果实现正确,你应该看到:

  • 最高频 pair 被正确找出;
  • 合并后序列长度和内容都正确;
  • 多轮合并后序列真的缩短。

到这一章结束,词表和输入表示层就不再只是“固定字符切分”了。

12.9 常见错误与排查顺序

最常见的错误一般是:

  1. pair 次序写反;
  2. 统计次数时漏掉某些位置;
  3. 合并时没有正确跳过已经消费的第二个 token;
  4. out_len 忘写;
  5. 没处理 len < 2 这种边界情况。

建议排查顺序是:

  1. 先看简单样例 ababab
  2. 再看“无重复 pair”边界;
  3. 最后看多轮合并长度变化。

12.10 思考题

  1. 为什么频次只有 1 的 pair 往往不值得合并?
  2. 为什么一次 merge 会让序列缩短、词表变大?
  3. 为什么 BPE 既是压缩手段,又仍然保留退回细粒度字符的能力?

12.11 本章小结

Chapter 12 把课程视角再次往前移了一层,从模型内部运算回到了输入表示本身。

你现在应该能明确看到:
文本进入模型之前怎样被切分,会直接影响序列长度、训练负担和生成表现。

这一步打通之后,课程的最后一章就有了清晰目标:把 BPE、模型前向、采样和对话循环重新拼成一条完整路径。