跳到主要内容

数据截至 (上游 commit 7bc720e951fe)

03 · demo 训练闭环与对拍验证

这一章讲什么: 引擎和网络库怎么被真正用起来——demo.ipynb 端到端训出一个二分类器;以及 test/test_engine.py 怎么借 PyTorch 当裁判,验证前两章的实现没算错。


1. 它要解决的小问题

有了能求梯度的 Value 和能搭网络的 MLP,还差最后一步才能把「深度学习」跑起来:

  • 损失函数:把「模型分数」和「真实标签」变成一个可微的标量——梯度总得有个源头。
  • 训练循环:把「前向 → 反传 → 更新」按正确顺序组织起来,重复几百次。
  • 正确性证据:凭什么相信这 150 行算的梯度是对的?

demo 和测试文件分别回答前两个和第三个问题。


2. 思路:损失也是 Value,优化器就是四行循环

两个直觉:

  1. 损失函数不需要任何特殊地位。 只要它由 Value 运算组成,loss.backward() 就能直接把梯度送到每个参数——损失和「普通表达式」在引擎眼里没有区别。demo 选了 SVM 风格的 max-margin(hinge)损失再加 L2 正则,全由已实现的算子组成。
  2. SGD 不需要一个类。 梯度下降更新就是 p.data -= lr * p.grad;有了 parameters() 清单,徒手写个 for 循环就是优化器。框架里的 Optimizer 类只是这个循环的封装。

3. 图示:一步迭代的四个动作

┌──────────────── 一步训练迭代 ────────────────┐
│ │
① forward loss():scores = map(model, inputs) │
→ max-margin 损失 + α·Σp² → total_loss │
│ │
② 清账 model.zero_grad() 所有 p.grad = 0 │
│ │
③ backward total_loss.backward() │
梯度沿图流回 337 个参数 │
│ │
④ 更新 for p in parameters(): │
p.data -= lr * p.grad │
└──────────────────────────────────────────────┘
循环 100 步,lr 从 1.0 线性退火到 0.1

怎么读这张图: ②必须发生在③之前,这是第一章「梯度 += 累加语义」的直接后果——顺序写反了,梯度就会一步叠一步地涨上去。


4. 原理演示:训练循环的最小形态

# 示意,非源码
for k in range(100):
total_loss, acc = loss() # ① 前向:损失本身也是 Value
model.zero_grad() # ② 清账:与 grad += 语义配套
total_loss.backward() # ③ 反传:填充所有 p.grad
lr = 1.0 - 0.9 * k / 100 # 线性退火:越到后面步子越小
for p in model.parameters(): # ④ 徒手的 SGD
p.data -= lr * p.grad # 只动 .data,不建图

重点看 ④ 改的是 p.data 而不是给 p 赋新 Value——更新参数不经过计算图,这是「参数更新」与「前向计算」的分界线。


5. 真实实现

5.1 数据与模型

demo 用 sklearn 的 make_moons(n_samples=100, noise=0.1) 造 100 个二维点,标签改成 ±1(demo.ipynb:71-74);模型是 MLP(2, [16, 16, 1]),337 个参数(demo.ipynb:96 构造,demo.ipynb:90 打印参数量;手算见 02 章 §5.3)。

5.2 损失:max-margin + L2

demo.ipynb:130-135,函数 loss 内:

losses = [(1 + -yi*scorei).relu() for yi, scorei in zip(yb, scores)]
data_loss = sum(losses) * (1.0 / len(losses))
alpha = 1e-4
reg_loss = alpha * sum((p*p for p in model.parameters()))
total_loss = data_loss + reg_loss

逐句看:

  • 1 + -yi*scorei:标签 yi∈{±1} 与分数同号且 |score|≥1 时此项 ≤0,被 relu() 截为 0——「分对且有把握就不罚」。这就是 hinge 损失,而 ReLU 恰是引擎原生算子,一行写完。
  • sum(losses) * (1.0 / len(losses)):求均值。没用 / 而用乘倒数,纯粹是个人写法(__truediv__ 其实存在,micrograd/engine.py:87-88)。
  • reg_loss:L2 正则 = α·Σp²,p*p 又是一次 Value.__mul__——正则项也走同一张图,梯度自动流向每个参数。

批处理是手写的:inputs = [list(map(Value, xrow)) for xrow in Xb] 包输入,scores = list(map(model, inputs)) 逐样本前向(demo.ipynb:124-127);batch_size=None 时全量(注释里自称 "inline DataLoader :)")。

5.3 优化循环

demo.ipynb:258-273:

model.zero_grad()
total_loss.backward()
learning_rate = 1.0 - 0.9*k/100
for p in model.parameters():
p.data -= learning_rate * p.grad

与 §4 的示意一一对应。学习率线性退火(1.0 → 0.1)是唯一的「训练技巧」;没有动量、没有 Adam。100 步后 demo 画出非线性决策边界(效果图存为仓库里的 moon_mlp.png,见 README.md:41-43)。

5.4 对拍:让 PyTorch 当裁判

test/test_engine.py 的两个测试用同一招:同一段表达式,micrograd 跑一遍,PyTorch 跑一遍,断言两边完全一致

test_sanity_check(test/test_engine.py:4-26)的断言:

assert ymg.data == ypt.data.item() # 前向值相等
assert xmg.grad == xpt.grad.item() # 反向梯度相等

test_more_ops(test/test_engine.py:28-67)覆盖更杂的运算组合(+=、幂、除法、ReLU 混用),容差 tol = 1e-6(test/test_engine.py:62),断言三个量:g 的前向值、a 的梯度、b 的梯度(test/test_engine.py:64-67)。

这套测试的深意:它同时验证了第一章的每一个机制——闭包里的局部法则、拓扑排序的顺序、+= 的多次使用累加、归约出的便利运算。能对拍通过,说明这套最小实现与工业级引擎在数学上严格一致。PyTorch 仅是测试依赖,运行时不需要(README.md:65)。


6. 关键细节与坑

  1. zero_grad() 必须在 backward() 之前。 顺序反了,旧梯度会和新梯度叠加(见 01 章 §6)。demo 的顺序(demo.ipynb:265-266)是唯一正确顺序。
  2. 更新参数时绕开计算图。 循环里写 p.data -= ...(demo.ipynb:271)而不是 p = p - lr*p.grad:后者会造出新 Value 节点、让 model.parameters() 里的旧对象失效。动 .data 是原地改数值,图对象不动。
  3. 没有验证集/早停。 demo 在训练集上直接报准确率(demo.ipynb:138-139),纯演示用途;别把它当训练范式模板。
  4. 效率天花板肉眼可见。 每个样本一次 Python 级前向,100 个点 × 100 步 × 337 参数级别的图重建。再大就慢了——这正是作者在 README 指路 microgpt(README.md:47)的原因:同一思路的张量化、实用化版本。
  5. 测试即文档。 想知道引擎支持哪些运算组合、边界行为如何,test_more_ops(test/test_engine.py:28-67)是比 README 更准的清单。