训练循环:loss 把 41 个梯度接通
前两篇造了两个零件:一个自动求导引擎,一套网络结构类。但 MLP 目前只会前向吐出一个随机数 —— 参数是随机初始化的,输出毫无意义。
这一篇把它们接通:给一个目标,让网络自己把 41 个参数调到能达成目标。缺了这一段,前面的东西都只能算、不能学。
数学依然没有新东西 —— 平方误差和梯度下降在系列第二篇就讲过了。新的只有「把它们连起来」。
数据集
xs = [ [2.0, 3.0, -1.0], [3.0, -1.0, 0.5], [0.5, 1.0, 1.0], [1.0, 1.0, -1.0],]ys = [1.0, -1.0, -1.0, 1.0]四个样本,每个 3 维,目标是 ±1。
为什么目标是 ±1 而不是 0/1:
tanh的输出范围就是 −1~1。目标值必须落在这个区间里,否则网络永远够不着。
损失函数
loss = sum((yout - ygt)**2 for ygt, yout in zip(ys, ypred))这就是为什么第四篇要补 __sub__ 和 __pow__ —— 减法和平方在这里第一次派上用场。
为什么要平方:误差有正有负,直接相加会互相抵消。平方保证每一项非负,而且错得越离谱惩罚越重。
loss 是一个 Value:四个输出被收拢成一个数,整张计算图汇成一个终点。
这正好解决了上一篇留下的那个现象 —— 只对单个神经元的输出调 backward() 时,同层其他神经元的梯度是 0。loss 把所有输出汇到同一个终点,41 个参数这才全部进入同一张计算图。
训练循环
for k in range(30): # 前向 ypred = [n(x) for x in xs] loss = sum((yout - ygt)**2 for ygt, yout in zip(ys, ypred))
# 反向:先清零,再 backward for p in n.parameters(): p.grad = 0.0 loss.backward()
# 更新 for p in n.parameters(): p.data += -0.05 * p.grad
print(k, loss.data)三段结构和系列第二篇那个动画一模一样:正向算代价 → 反向算梯度 → 沿负梯度更新。
注意 p.data += -0.05 * p.grad 里的负号 —— 要让 loss 变小,所以往负梯度方向走。
清零必须在
backward()之前。 写反了(先 backward 再清零)会把刚算好的梯度抹掉,参数永远不动。
故意不清零会怎样
把清零那两行去掉,其余一切不变,两边跑同样 30 轮:
| 轮 | 清零 | 不清零 |
|---|---|---|
| 0 | 6.34 | 6.34 |
| 5 | 0.220 | 0.0518 |
| 10 | 0.0840 | 5.02e−04 |
| 20 | 0.0356 | 5.44e−10 |
| 29 | 0.0230 | 9.26e−14 |
反直觉的地方:不清零反而降得快
两条曲线的性质完全不同:
- 清零版降速越来越慢,最后停在 0.023 附近。这是对的 —— 越接近谷底梯度越小,步子自动收窄,和第二篇那张收敛表一致
- 不清零版加速下降,一路冲到 9.26e−14,也就是浮点精度的地板
原因是梯度在逐轮累加,有效学习率随轮次线性增长:
第 k 轮的实际步长 ≈ 0.05 × k × |∇|第 29 轮相当于用 1.45 的学习率 —— 比设定值大 29 倍。
换十个随机种子再跑一遍
上面那条曲线是 random.seed(3) 的结果。换种子重跑,两边的表现完全不是一回事:
| 清零 | 不清零 | |
|---|---|---|
| 30 轮后的 loss | 0.022 ~ 0.052 | 1.4e−26 ~ 0.69 |
| 十次里中途反弹 | 0 次 | 3 次 |
清零版十次都落在同一个窄区间里;不清零版跨了二十六个数量级,其中三次先冲到 1e−16 附近、又被甩了回来(有一次回弹到 0.69,比清零版差三十倍)。
这个例子之所以常常「侥幸成功」,是三个条件恰好都满足:四个样本简单、梯度方向不冲突,累加相当于同向加速;4 个样本配 41 个参数严重过参数化,怎么走都能到终点;而目标 ±1 正是 tanh 的渐近线,这个任务天然奖励大步子。把学习率调大再不清零,震荡和飞掉立刻就出来了。
真正的危害不是「会失败」
而是你失去了控制:
| 清零 | 不清零 | |
|---|---|---|
| 学习率 | 0.05,恒定 | 0.05k,每轮在变 |
| 可复现 | 是 | 否 —— 跑 30 轮和 300 轮是两种行为 |
| 可调 | 改学习率有可预期的效果 | 改了也被 k 淹没 |
| 真实任务 | 稳定 | 样本一多、梯度方向一冲突就崩 |
上面那张种子表就是这一栏的实测版本。想要降得更快,应该调学习率,而不是靠一个 bug。
loss 降到 0 不代表学会了
「损失越来越小不正是我们要的吗?」
训练集的 loss 不是目标,它只是代理指标。 真正的目标是在没见过的数据上表现好。
这个例子里只有 4 个样本、41 个参数 —— 参数比样本多十倍。它完全可以把四个答案「背下来」,而不是学到任何规律。
极端对照:把训练集存进字典查表,训练 loss 精确等于 0,但对任何新输入毫无办法。loss 为 0 不代表学会了,可能只是记住了。
典型的过拟合信号是两条曲线分道扬镳:
训练 loss: 0.5 → 0.1 → 0.01 → 0.001 ↓ 一直降验证 loss: 0.6 → 0.3 → 0.35 → 0.52 ↑ 开始反弹验证 loss 触底反弹的那一刻,就该停了。
一个值得建立的直觉:loss 降得异常快,往往是 bug 而不是惊喜。 不清零、数据泄漏、评测集混进训练集 —— 这类问题都表现为「效果好得不真实」。上面那条冲到 1e−14 的曲线就是活例子。
一个 Jupyter 陷阱
新建了 n = MLP(...),但 loss 算出来是 0.07 —— 训练好的水平。
原因是 ypred 还是上一轮训练结束时的旧变量,新建网络后没重算。变量在内存里跨格子存活,不会因为重跑了别的格子就自动更新。 改了上游,下游必须跟着重跑。
一个好习惯:训练前后各打印一次预测值,对比才有说服力。
小结
训练循环就是把前两篇的零件接通:loss 把所有输出汇成一个数,整张图才有唯一终点;backward() 填好 41 个梯度;沿负梯度更新参数。三段循环重复几十次,网络就学会了。
而 += 欠下的债,就是每轮必须清零。
到这里 V1 完结,产出三样东西,全部从零写起、不依赖任何框架:一个完整的自动求导引擎(Value)、一套网络结构类(Neuron / Layer / MLP)、一个能训练的完整循环。
下一步是 V2:makemore bigram。开始接触真实数据(人名数据集)、字符级语言模型、softmax、交叉熵损失。
第四篇补的那个 exp 算子,就是为 softmax 准备的。 从那一集开始会逐步转向张量运算。