对应实践:
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_pairsstudent_bpe_apply_merge
这意味着本章真正要你掌握的是 BPE 的核心循环直觉,而不是记住一整个工程实现的所有细节。
12.1 为什么字符级表示会变得不够用
字符级 tokenizer 的最大优点是稳:
只要字符集定义好了,任何文本都能被拆开。
但它的最大问题也很明显:
很多本来可以作为整体被理解的常见片段,会被拆得过碎。
比如:
hello
在字符级里会被拆成 5 个 token。
而对于一个已经在语料中反复出现过的常见词,这种拆法会让模型承担额外的序列长度和学习负担。
BPE 的出发点正是:
如果一对相邻 token 总是一起出现,那不如把它们合成一个新的、更大的 token。
12.2 本章你要建立哪些判断
这一章结束后,你应当能够清楚说出:
- BPE 的一个训练循环为什么可以概括成“统计相邻 pair -> 合并最常见 pair”。
- 为什么只有频次足够高的 pair 才值得合并。
- 为什么一次合并会让序列长度变短,而词表大小变大。
- 为什么 BPE 既能压缩常见词,又仍然保留退回字符级的能力。
- 为什么 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_pairsstudent_bpe_apply_merge
这个切法非常好,因为它恰好把 BPE 的训练循环拆成了最核心的两半:
- 先看当前序列里哪一对最常一起出现;
- 再把这一对在序列里替换成新的 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 序列,因此更准确地说,是在做:
- 从左到右扫描;
- 一旦看到
(first, second)这个相邻 pair; - 就写入一个新的
new_id,并跳过下一个位置。
这里最值得建立的直觉是:
一次 merge 会把两个旧 token 折叠成一个新 token,所以序列长度只会变短或不变,不会变长。
这也是 BPE 能压缩输入长度的直接原因。
12.7 为什么本章故意不用完整系统来起步
如果一上来就让学员去读完整 bpe_tokenizer.c,大多数人会先被各种工程细节包住:
- 词表存储;
- merges 列表;
- encode / decode 路径;
- 文件保存加载。
这些当然都是真实系统的一部分,但它们不适合做第一观察点。
当前课程更务实的策略是:
先把“统计 pair”和“应用 merge”这两个核心动作亲手写出来。
这样你后面再看完整 BPE 系统时,就会知道哪些代码是在服务这两个动作,哪些只是外围管理。
12.8 本章实践步骤
task 12.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab12-step11
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器会检查:
- 在
ababab里能否找到最高频 pair; - 没有重复 pair 时,是否正确返回 0;
- 把
(a, b)合并成新 id 后,序列内容和长度是否正确; - 在一段更长文本上,重复做合并时,序列是否真的变短。
这些检查很适合作为 BPE 入门,因为它们全都对准“局部统计”和“局部替换”这两个核心动作。
task 12.2:实现 student_bpe_count_pairs
这一题建议你不要一开始就追求复杂数据结构。
当前 lab 的输入规模完全允许你先用简单暴力的方式,把逻辑写清楚。
最重要的是建立顺序:
- 先枚举所有相邻 pair;
- 再统计每个 pair 的次数;
- 选出最高频那个;
- 如果频次不到 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 常见错误与排查顺序
最常见的错误一般是:
- pair 次序写反;
- 统计次数时漏掉某些位置;
- 合并时没有正确跳过已经消费的第二个 token;
out_len忘写;- 没处理
len < 2这种边界情况。
建议排查顺序是:
- 先看简单样例
ababab; - 再看“无重复 pair”边界;
- 最后看多轮合并长度变化。
12.10 思考题
- 为什么频次只有 1 的 pair 往往不值得合并?
- 为什么一次 merge 会让序列缩短、词表变大?
- 为什么 BPE 既是压缩手段,又仍然保留退回细粒度字符的能力?
12.11 本章小结
Chapter 12 把课程视角再次往前移了一层,从模型内部运算回到了输入表示本身。
你现在应该能明确看到:
文本进入模型之前怎样被切分,会直接影响序列长度、训练负担和生成表现。
这一步打通之后,课程的最后一章就有了清晰目标:把 BPE、模型前向、采样和对话循环重新拼成一条完整路径。