对应实践:
course/practice/labs/lab07-step6/
主要修改文件:course/practice/labs/lab07-step6/framework/student.c
验证命令:make clean && make test
上一章你已经把完整 GPT 模型装起来了。
这意味着模型终于有了一个清晰的前向路径,也能被保存和重新加载。
但现在还有一个更关键的问题没有解决:
模型虽然会吐 logits,但它还不会学习。
这就是训练章存在的原因。
从工程角度看,训练并不神秘。它无非是在做一个固定闭环:
- 前向得到 logits;
- 根据正确答案计算损失;
- 从损失对 logits 的误差反推梯度;
- 用优化器拿梯度去更新参数。
真正的困难不在于这四步的口号你会不会背,而在于:
你能不能把这四步拆成最小可验证的数学动作。
Lab07 的实践方式非常克制。它没有要求你一口气写完整训练框架,而是只把三件最关键的局部能力留给你:
- 交叉熵损失;
- softmax + cross-entropy 的梯度;
- Adam 对单个参数向量的一次更新。
这三件事一旦真正写明白,后面的完整训练循环就不再像魔法。
7.1 为什么训练章必须这样拆
如果课程在这里直接让学员读完整的 train.c、backward.c 和 optimizer.c,绝大多数初学者会被三件事同时压住:
- 张量很多;
- 中间状态很多;
- 几乎每一步看起来都像“又一个新公式”。
所以最务实的教学策略,不是先把系统复杂度全部摊给学员,而是先抓住训练里最核心、最不可绕过的三段数学关系:
- 损失到底在度量什么;
- logits 的梯度到底长什么样;
- 参数更新到底怎样发生。
这就是 Lab07 的切法。它不是“缩减版训练”,而是把真正最核心的三颗齿轮先拆出来给你看。
7.2 本章你要建立哪些判断
这一章结束后,你应当能够独立说清楚下面这些事:
- 为什么交叉熵损失在模型很确定时接近 0,而在瞎猜时接近
log(vocab_size)。 - 为什么 softmax 和 cross-entropy 放在一起时,梯度能简化成
softmax - one_hot。 - 为什么梯度在目标位置应该是负数,而在非目标位置应该是正数。
- Adam 为什么同时维护一阶矩
m和二阶矩v。 - 为什么学习率过大时,loss 可能直接跑成
NaN。
如果这些判断还没有建立,即使你把完整训练程序“跑绿”,后面也很难真正理解训练过程出了什么问题。
7.3 先看 practice target:这章改哪里
本章的实践目录是:
course/practice/labs/lab07-step6/
├── TASK.md
├── Makefile
└── framework/
├── student.c <- 主要修改这里
├── student.h
├── verify.c <- 自动验证,不改
└── verify.h
当前需要你实现的函数有三个:
student_cross_entropy_lossstudent_softmax_ce_gradstudent_adam_step
这个切法很合理,因为它刚好对应训练闭环中的三个核心接口:
- 用损失把“预测”和“正确答案”接上;
- 用梯度把“损失”传回 logits;
- 用优化器把“梯度”变成参数更新。
7.4 交叉熵为什么是“预测错误程度”
在语言模型里,每个位置的目标都很简单:
模型应该给“正确下一个 token”更高概率。
交叉熵写成最简单的形式就是:
L = -log P(target)
它之所以合适,是因为这个量的行为非常直观:
- 如果模型给正确 token 的概率接近 1,那么
-log(1)接近 0; - 如果模型给正确 token 的概率很小,那么
-log(P)就会迅速变大。
这意味着交叉熵天然把“越确定越好、越不确定越差”编码进了一个标量里。
更重要的是,它还给出了一个非常强的基线直觉:
如果模型还什么都没学会,词表上几乎均匀瞎猜,那么损失大约就是:
log(vocab_size)
这个判断在调试时非常好用。因为你不需要等完整训练结束,就能先看当前 loss 是不是还停留在“瞎猜水平”。
7.5 为什么梯度会简化成 softmax - one_hot
很多初学者第一次真正接触反向传播时,会以为必须沿着 softmax 和 log 的内部公式一层层手推,才可能得到 logits 的梯度。
在数学上当然可以这么做,但工程里更重要的是:知道哪些组合已经有非常干净的闭式结果。
对于 softmax 和 cross-entropy 的组合,最终梯度正好会简化成:
grad[i] = softmax(logits)[i] - (i == target ? 1 : 0)
这条式子的意义非常强:
- 目标位置会被减 1,所以通常是负的;
- 非目标位置只是保留 softmax 概率,所以是正的;
- 整个梯度向量求和接近 0。
这几个性质都能直接变成实践里的验证条件。
也正因为如此,Lab07 会分别检查:
- 梯度数值是否合理;
- 梯度和是否接近 0;
- target 位置是否为负;
- 非 target 位置是否为正。
这不是多余检查,而是在帮学员把公式和行为一一对应起来。
7.6 Adam 为什么不是“多写几个变量”
如果只看代码表面,Adam 和 SGD 的区别好像只是多了几个状态数组。
但从优化行为上看,它做了两件非常重要的事。
第一件事是:它保留了历史梯度的一阶统计,也就是动量。
这会让参数更新不至于每一步都只看当前这一拍的局部波动。
第二件事是:它还保留了梯度平方的移动平均,也就是二阶统计。
这会让每个参数维度拥有更自适应的更新尺度。
所以 Adam 的核心直觉不是“更复杂的 SGD”,而是:
既看方向,又看这个方向在历史上有多稳定、有多大。
这也是为什么 Lab07 不要求你一上来写完整模型上的 Adam,而是先在一个单独的一维参数数组上把这套更新逻辑写对。
7.7 为什么训练里最怕的不是公式错,而是数值炸掉
到了训练这一章,课程里会第一次大规模遇到数值问题。
不是因为前面没有数值稳定性,而是因为训练把很多小误差和大尺度变化都放大了。
最典型的现象就是:
- 学习率过大;
- 某一步更新把参数推得过猛;
- 下次前向里某些指数或归一化直接溢出;
- loss 变成
NaN或inf。
所以这一章最应该建立的工程意识是:
训练失败不一定是逻辑错,也可能是数值状态已经失控。
这也是为什么 Chapter 7 的讲义一定要把“loss 为什么会发散”和“梯度裁剪为什么是保命装置”专门讲出来,而不是只在优化器参数表里随手带过。
7.8 本章实践步骤
task 7.1:先读 student.c 和 verify.c
进入:
cd course/practice/labs/lab07-step6
建议先读:
framework/student.hframework/student.cframework/verify.c
当前验证器主要检查:
- 均匀 logits 时,交叉熵是否接近
log(vocab_size); - 正确目标 logit 明显更大时,loss 是否显著下降;
- 梯度方向和大小是否符合预期;
- 梯度的符号和和是否满足预期;
- Adam 一步后,
m、v、参数值是否真的变化。
这些检查非常有价值,因为它们都对应训练直觉里最关键的局部现象。
task 7.2:实现 student_cross_entropy_loss
这一步建议你先从“单个位置的 loss”想起,再推广到整个序列平均。
重点不是把公式背出来,而是清楚:
- 为什么要先做数值稳定的
log_sum_exp; - 为什么最终要对
seq_len取平均。
前者解决数值问题,后者解决不同序列长度下的可比性问题。
task 7.3:实现 student_softmax_ce_grad
这一步最适合边写边对照“性质”。
你可以一边实现,一边想这几个问题:
- softmax 后概率和是不是 1;
- target 位置减 1 之后为什么会变成负数;
- 非 target 位置为什么仍然是正数;
- 整个向量为什么总和接近 0。
如果这些性质你在脑子里是清楚的,代码就不容易写偏。
task 7.4:实现 student_adam_step
这一题最好先把循环外和循环内的责任分开。
循环外先算:
1 - beta1^t1 - beta2^t
循环内再按每个参数维度依次更新:
mv- 偏差校正后的
m_hat - 偏差校正后的
v_hat - 参数本身
这能让代码更清楚,也更符合 Adam 的数学结构。
task 7.5:运行当前真实基线
在还没补完三个函数前,先执行:
make clean && make test
当前 Lab07 的真实初始状态是:
- 3 通过,11 失败。
已经通过的部分主要来自:
- 某些“空实现也不会崩”的边界检查;
- 一些结构性返回值检查。
但真正和训练数学直接相关的部分,目前大多还失败。这正说明:
- 框架已经可以正常运行;
- 训练核心逻辑仍然留给学员完成。
task 7.6:完成后重新验证
当你补完三个函数后,再执行:
make clean && make test
如果实现正确,你应当看到:
- 交叉熵数值是否落在预期范围内;
- logits 梯度满足符号和总和条件;
- Adam 真的改变了
m、v和参数。
这一章一旦跑通,课程就真正跨进“模型会学习”的阶段了。
7.9 常见错误与排查顺序
最常见的错误通常是:
cross_entropy_loss忘了做数值稳定版log_sum_exp;softmax_ce_grad忘了归一化或忘了在 target 位置减 1;- Adam 偏差校正写错;
sqrt用成双精度版本或参数顺序写反;weight_decay和主更新顺序混乱。
建议排查顺序是:
- 先看 loss 数量级对不对;
- 再看梯度符号和总和;
- 最后再看 Adam 状态更新。
因为如果前两层已经错了,优化器层面的任何现象都不再可信。
7.10 思考题
- 为什么均匀分布下的交叉熵大约是
log(vocab_size)? - 为什么 softmax + cross-entropy 的梯度总和接近 0?
- 为什么学习率过大时,训练更容易出现
NaN?
7.11 本章小结
Chapter 7 的意义,不只是“开始训练”,而是第一次把模型、目标和参数更新真正接起来。
从这一章开始,你不再只是看模型怎样算输出,而是开始看:
- 模型怎样知道自己错了;
- 错误怎样变成梯度;
- 梯度怎样真正改写参数。
这一步打通后,下一章才顺理成章。因为模型只有学会之后,文本生成和采样策略才会变得真正有意义。