Chapter 7 — step6 训练

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

上一章你已经把完整 GPT 模型装起来了。
这意味着模型终于有了一个清晰的前向路径,也能被保存和重新加载。

但现在还有一个更关键的问题没有解决:
模型虽然会吐 logits,但它还不会学习。

这就是训练章存在的原因。

从工程角度看,训练并不神秘。它无非是在做一个固定闭环:

  1. 前向得到 logits;
  2. 根据正确答案计算损失;
  3. 从损失对 logits 的误差反推梯度;
  4. 用优化器拿梯度去更新参数。

真正的困难不在于这四步的口号你会不会背,而在于:
你能不能把这四步拆成最小可验证的数学动作。

Lab07 的实践方式非常克制。它没有要求你一口气写完整训练框架,而是只把三件最关键的局部能力留给你:

  • 交叉熵损失;
  • softmax + cross-entropy 的梯度;
  • Adam 对单个参数向量的一次更新。

这三件事一旦真正写明白,后面的完整训练循环就不再像魔法。

7.1 为什么训练章必须这样拆

如果课程在这里直接让学员读完整的 train.cbackward.coptimizer.c,绝大多数初学者会被三件事同时压住:

  1. 张量很多;
  2. 中间状态很多;
  3. 几乎每一步看起来都像“又一个新公式”。

所以最务实的教学策略,不是先把系统复杂度全部摊给学员,而是先抓住训练里最核心、最不可绕过的三段数学关系:

  1. 损失到底在度量什么;
  2. logits 的梯度到底长什么样;
  3. 参数更新到底怎样发生。

这就是 Lab07 的切法。它不是“缩减版训练”,而是把真正最核心的三颗齿轮先拆出来给你看。

7.2 本章你要建立哪些判断

这一章结束后,你应当能够独立说清楚下面这些事:

  1. 为什么交叉熵损失在模型很确定时接近 0,而在瞎猜时接近 log(vocab_size)
  2. 为什么 softmax 和 cross-entropy 放在一起时,梯度能简化成 softmax - one_hot
  3. 为什么梯度在目标位置应该是负数,而在非目标位置应该是正数。
  4. Adam 为什么同时维护一阶矩 m 和二阶矩 v
  5. 为什么学习率过大时,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_loss
  • student_softmax_ce_grad
  • student_adam_step

这个切法很合理,因为它刚好对应训练闭环中的三个核心接口:

  1. 用损失把“预测”和“正确答案”接上;
  2. 用梯度把“损失”传回 logits;
  3. 用优化器把“梯度”变成参数更新。

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 变成 NaNinf

所以这一章最应该建立的工程意识是:

训练失败不一定是逻辑错,也可能是数值状态已经失控。

这也是为什么 Chapter 7 的讲义一定要把“loss 为什么会发散”和“梯度裁剪为什么是保命装置”专门讲出来,而不是只在优化器参数表里随手带过。

7.8 本章实践步骤

task 7.1:先读 student.cverify.c

进入:

cd course/practice/labs/lab07-step6

建议先读:

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

当前验证器主要检查:

  1. 均匀 logits 时,交叉熵是否接近 log(vocab_size)
  2. 正确目标 logit 明显更大时,loss 是否显著下降;
  3. 梯度方向和大小是否符合预期;
  4. 梯度的符号和和是否满足预期;
  5. Adam 一步后,mv、参数值是否真的变化。

这些检查非常有价值,因为它们都对应训练直觉里最关键的局部现象。

task 7.2:实现 student_cross_entropy_loss

这一步建议你先从“单个位置的 loss”想起,再推广到整个序列平均。

重点不是把公式背出来,而是清楚:

  1. 为什么要先做数值稳定的 log_sum_exp
  2. 为什么最终要对 seq_len 取平均。

前者解决数值问题,后者解决不同序列长度下的可比性问题。

task 7.3:实现 student_softmax_ce_grad

这一步最适合边写边对照“性质”。

你可以一边实现,一边想这几个问题:

  • softmax 后概率和是不是 1;
  • target 位置减 1 之后为什么会变成负数;
  • 非 target 位置为什么仍然是正数;
  • 整个向量为什么总和接近 0。

如果这些性质你在脑子里是清楚的,代码就不容易写偏。

task 7.4:实现 student_adam_step

这一题最好先把循环外和循环内的责任分开。

循环外先算:

  • 1 - beta1^t
  • 1 - beta2^t

循环内再按每个参数维度依次更新:

  1. m
  2. v
  3. 偏差校正后的 m_hat
  4. 偏差校正后的 v_hat
  5. 参数本身

这能让代码更清楚,也更符合 Adam 的数学结构。

task 7.5:运行当前真实基线

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

make clean && make test

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

  • 3 通过,11 失败

已经通过的部分主要来自:

  • 某些“空实现也不会崩”的边界检查;
  • 一些结构性返回值检查。

但真正和训练数学直接相关的部分,目前大多还失败。这正说明:

  1. 框架已经可以正常运行;
  2. 训练核心逻辑仍然留给学员完成。

task 7.6:完成后重新验证

当你补完三个函数后,再执行:

make clean && make test

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

  • 交叉熵数值是否落在预期范围内;
  • logits 梯度满足符号和总和条件;
  • Adam 真的改变了 mv 和参数。

这一章一旦跑通,课程就真正跨进“模型会学习”的阶段了。

7.9 常见错误与排查顺序

最常见的错误通常是:

  1. cross_entropy_loss 忘了做数值稳定版 log_sum_exp
  2. softmax_ce_grad 忘了归一化或忘了在 target 位置减 1;
  3. Adam 偏差校正写错;
  4. sqrt 用成双精度版本或参数顺序写反;
  5. weight_decay 和主更新顺序混乱。

建议排查顺序是:

  1. 先看 loss 数量级对不对;
  2. 再看梯度符号和总和;
  3. 最后再看 Adam 状态更新。

因为如果前两层已经错了,优化器层面的任何现象都不再可信。

7.10 思考题

  1. 为什么均匀分布下的交叉熵大约是 log(vocab_size)
  2. 为什么 softmax + cross-entropy 的梯度总和接近 0?
  3. 为什么学习率过大时,训练更容易出现 NaN

7.11 本章小结

Chapter 7 的意义,不只是“开始训练”,而是第一次把模型、目标和参数更新真正接起来。

从这一章开始,你不再只是看模型怎样算输出,而是开始看:

  • 模型怎样知道自己错了;
  • 错误怎样变成梯度;
  • 梯度怎样真正改写参数。

这一步打通后,下一章才顺理成章。因为模型只有学会之后,文本生成和采样策略才会变得真正有意义。