bobofamilyNIU CHUN
06/36

训练循环:loss 把 41 个梯度接通

2026-09-026 分钟—阅读

前两篇造了两个零件:一个自动求导引擎,一套网络结构类。但 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 轮:

同一份代码、同一个随机种子,只差清零那两行。纵轴是对数轴,跨了 15 个数量级。
轮清零不清零
06.346.34
50.2200.0518
100.08405.02e−04
200.03565.44e−10
290.02309.26e−14

反直觉的地方:不清零反而降得快

两条曲线的性质完全不同:

  • 清零版降速越来越慢,最后停在 0.023 附近。这是对的 —— 越接近谷底梯度越小,步子自动收窄,和第二篇那张收敛表一致
  • 不清零版加速下降,一路冲到 9.26e−14,也就是浮点精度的地板

原因是梯度在逐轮累加,有效学习率随轮次线性增长:

第 k 轮的实际步长 ≈ 0.05 × k × |∇|

第 29 轮相当于用 1.45 的学习率 —— 比设定值大 29 倍。

换十个随机种子再跑一遍

上面那条曲线是 random.seed(3) 的结果。换种子重跑,两边的表现完全不是一回事:

清零不清零
30 轮后的 loss0.022 ~ 0.0521.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 准备的。 从那一集开始会逐步转向张量运算。