P0:标量自动微分
对应代码:
src/micrograd/engine.py(~150 行) 对应测试:tests/test_autograd.py参考:Karpathy · micrograd;Zero to Hero Lecture 1
本章目标
读完能回答的问题:
- 为什么”自动微分”叫 automatic 而不是 symbolic?和手算、符号求导有什么本质区别?
- 计算图上的 forward 和 backward 各做了什么?为什么 backward 必须拓扑排序?
- 链式法则在代码里具体长什么样?为什么每个算子只要管”局部导数”就够了?
- 梯度为什么是
+=而不是=?(一个节点被多次使用会发生什么) - 数值梯度(finite difference)为什么不适合用在真实训练里,却适合做单元测试?
能动手实现的东西:
- 一个 ~150 行的
Value类,支持+ - * / ** exp tanh relu和backward() - 用
Value手搓一个 2 层 MLP,在 XOR 问题上训练到 loss < 0.01 - 一个
gradcheck工具——用数值梯度对拍解析梯度
本章不涉及(defer 到后续章节)
- 向量 / 矩阵 / 张量运算(→ P1)
- GPU / 并行 / 性能优化(全程不做,本项目是学习项目)
- 高阶导数(
grad of grad) - 动态图优化 / JIT 编译
1. 问题动机
1.1 训练神经网络真正的难点在哪
训一个神经网络的伪代码所有人都背过:
1
2
3
4
for batch in data:
loss = model(batch)
gradients = ??? # ← 这里
params -= lr * gradients
Naive 想法:直接梯度下降 w -= lr * dL/dw。 卡点:L 是 w 经过几十层非线性变换后的结果,dL/dw 怎么算?
1.2 三种算梯度的方法
| 方法 | 做法 | 致命问题 |
|---|---|---|
| 手算 | 纸笔推导完整表达式 | 模型一改就全废,30 行网络根本没法手推 |
| 符号求导(SymPy) | 把前向式子当代数对象求偏导 | 表达式爆炸,一个 3 层 MLP 的导数写不下一页纸 |
数值梯度 (f(x+h)-f(x-h))/2h | 对每个参数前后各算一次 | O(N) 次前向:1M 参数就要跑 200 万次 forward;浮点精度差 |
| 反向模式自动微分(本章) | 计算图 + 链式法则 | 一次 forward + 一次 backward 就拿到所有参数的梯度 |
⚠️ 常见误解:autograd ≠ 符号求导。它不推导表达式,它只在数值运算发生时顺手记下”我的孩子是谁 + 遇到梯度该怎么回传”——这是运行时的图结构,不是编译期的代数。
这是它叫 automatic 的原因:你按正常方式写前向代码,系统自动帮你记录梯度信息,写 backward() 那一下才真正沿图把梯度计算出来。
1.3 本章的最小可行目标
让下面这段代码能跑:
1
2
3
4
5
a = Value(2.0)
b = Value(3.0)
c = a * b + a
c.backward()
assert a.grad == 4.0 and b.grad == 2.0
手算验证:c = a*b + a = a*(b+1),所以 dc/da = b+1 = 4,dc/db = a = 2。✅
2. 核心概念
2.1 计算图
每条算术运算都建一个节点。以 c = a * b + a 为例:
1
2
3
4
5
a ────┐
├──► (* )───► t
b ────┘ │
a ──────────────────┼──► (+)──► c
│
- 节点 =
Value对象(一个标量 + 元数据) - 边 = 谁是谁的
_prev(父子关系) - 边上的”标注” = 局部偏导规则(放在
_backward闭包里)
a节点在图里出现了两次(一次作为*的输入,一次作为最后+的输入)——这是梯度必须+=累加的原因,后面会反复强调。
2.2 前向:一边算值,一边建图
每做一次算术运算都:
- 算出结果数值
- 创建一个新
Value包住结果 - 把”左右操作数”记进
_prev - 把”如何把我的 grad 沿本算子回传”这个函数塞进
_backward
代码长这样(这是 __mul__ 的核心):
1
2
3
4
5
6
7
def __mul__(self, other):
out = Value(self.data * other.data, (self, other), "*")
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
关键洞察:前向时闭包还没执行,只是把”如何回传”的函数存下来。真正执行是 backward() 阶段。
2.3 反向:链式法则的图上版本
链式法则的本质:复合函数的导数 = 逐段局部导数相乘。
在计算图上这件事变得极简:
- 输出节点
grad = 1.0(dL/dL = 1) - 拓扑排序:每个节点 必须等它的所有后代节点处理完才能轮到它
- 逆序遍历所有节点
- 每访问一个节点,调用它的
_backward——把自己的 grad 按本节点算子的局部导数 累加(+=)到父节点
2.4 为什么只需要管”局部导数”
链式法则的奇迹:每个算子只要知道”我对自己输入的导数”就够了。
| 算子 | 前向 | 局部梯度(如何更新 _prev 的 grad) |
|---|---|---|
a + b | a + b | a.grad += out.grad; b.grad += out.grad |
a * b | a * b | a.grad += b * out.grad; b.grad += a * out.grad |
a ** n | a ** n | a.grad += n * a**(n-1) * out.grad |
exp(a) | e^a | a.grad += e^a * out.grad |
tanh(a) | tanh(a) | a.grad += (1 - tanh²) * out.grad |
relu(a) | max(0, a) | a.grad += (a > 0 ? 1 : 0) * out.grad |
两条极简规则足以搭出所有网络:
- 加法 把梯度原样分发给每个输入
- 乘法 把梯度乘以 对方 的值再传给自己
所有非线性(tanh、relu、sigmoid)只是套一层”自己的输入是多少”的缩放。
2.5 数学公式 ↔ Python 代码对齐
乘法算子的完整对齐:
1
2
3
4
数学: c = a · b
∂c/∂a = b ∂c/∂b = a
反向: a.grad ← a.grad + b · c.grad (链式法则)
b.grad ← b.grad + a · c.grad
1
2
3
4
5
6
7
8
# Python:
def __mul__(self, other):
out = Value(self.data * other.data, (self, other), "*")
def _backward():
self.grad += other.data * out.grad # += 因为 self 可能被多处使用
other.grad += self.data * out.grad
out._backward = _backward
return out
每个算子都这样一一对齐,不留黑箱。
3. 手写实现
完整代码在 src/micrograd/engine.py。下面按概念顺序拆解每块。
3.1 Value 骨架
1
2
3
4
5
6
7
8
9
10
11
12
13
from __future__ import annotations
import math
class Value:
def __init__(self, data, _children=(), _op=""):
self.data = float(data)
self.grad = 0.0
self._prev = set(_children)
self._op = _op
self._backward = lambda: None
def __repr__(self):
return f"Value(data={self.data:.4g}, grad={self.grad:.4g})"
设计选择:
_children用 tuple 入参、_prev存成 set——方便拓扑排序去重,又不会接受可变默认参数_backward初始是 no-op lambda——叶子节点(手动创建的Value)调用_backward时什么也不做_op只用来人类可读,不参与计算
3.2 加法 __add__
1
2
3
4
5
6
7
8
9
10
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
为什么包一层 other = ... if isinstance ... else Value(other): 这样才能写 a + 2——右边是 Python int,我们得先把它包成 Value。
3.3 乘法 __mul__
1
2
3
4
5
6
7
8
9
10
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
3.4 幂 __pow__(只支持常数指数)
1
2
3
4
5
6
7
8
9
def __pow__(self, other):
assert isinstance(other, (int, float)), "only supporting int/float powers"
out = Value(self.data ** other, (self,), f"**{other}")
def _backward():
self.grad += other * self.data ** (other - 1) * out.grad
out._backward = _backward
return out
刻意限制:指数是标量常数,不接受 Value。这够用了(除法、平方根都能组合出来),也把实现简化到一行梯度。 要支持 Value ** Value(即 x^y 其中 y 也要梯度)的话,得用 exp(y · log(x)) 展开——留给 P1+。
3.5 非线性:exp / tanh / relu
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def exp(self):
x = self.data
out = Value(math.exp(x), (self,), "exp")
def _backward():
self.grad += out.data * out.grad # d(exp)/dx = exp(x) = out.data
out._backward = _backward
return out
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
self.grad += (1 - t * t) * out.grad # d(tanh)/dx = 1 - tanh²
out._backward = _backward
return out
def relu(self):
out = Value(max(0.0, self.data), (self,), "relu")
def _backward():
self.grad += (1.0 if self.data > 0 else 0.0) * out.grad
out._backward = _backward
return out
ReLU 在 x=0 处严格不可导——我们遵循 PyTorch 的工程惯例,把 0 点当负半区处理(梯度 = 0)。 实际训练几乎不会精确踩到 0。
3.6 派生:neg / sub / truediv 全用 add/mul/pow 组合
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def __neg__(self):
return self * -1
def __sub__(self, other):
return self + (-other)
def __rsub__(self, other): # number - Value
return other + (-self)
def __truediv__(self, other):
return self * other ** -1
def __rtruediv__(self, other): # number / Value
return other * self ** -1
__radd__ = __add__
__rmul__ = __mul__
优雅之处:这些派生算子不需要各自的 _backward。它们的梯度自动由组合路径上的 __mul__ / __add__ / __pow__ 反向传播串起来。
⚠️ __radd__ / __rmul__ 不写会怎样:2 * a 先调 int.__mul__(2, a),int 不认 Value,返回 NotImplemented;Python 再调 a.__rmul__(2)。如果 Value 没实现它,就抛 TypeError。
3.7 反向传播 backward()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def backward(self):
topo = []
visited = set()
def build(v):
if v in visited:
return
visited.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
self.grad = 1.0
for v in reversed(topo):
v._backward()
拓扑排序的意义:在遍历到节点 N 之前,N 的所有后代(使用了 N 的节点)必须都已经被遍历——否则 N 从它们那里收到的梯度就不完整。
DFS 的后序遍历(children 先入列,自己后入列)天然就是拓扑序;逆序就是从输出走向输入。
细节:self.grad = 1.0 是赋值不是 +=。这是整张图的”种子梯度”dL/dL = 1。 如果用户在 backward 之前手动给 self.grad 设过别的值,这里会被覆盖——这就是单输出 backward 的正确语义。
3.8 全局视角
总行数:~150 行(算上注释和空行)。这 150 行:
- 表达了完整的反向模式自动微分算法
- 支持任意复合表达式
- 能驱动真实梯度下降训练
这是所有现代深度学习框架(PyTorch autograd / TensorFlow GradientTape / JAX grad)的最小内核。
4. 梯度验证
光写出来不行,必须验证”解析梯度”和”数值梯度”一致。
4.1 数值梯度(中心差分)
1
2
3
4
5
6
def numerical_grad(f, args, i, h=1e-5):
args_plus = [Value(a.data) for a in args]
args_minus = [Value(a.data) for a in args]
args_plus [i].data = args[i].data + h
args_minus[i].data = args[i].data - h
return (f(*args_plus).data - f(*args_minus).data) / (2 * h)
原理:f'(x) ≈ (f(x+h) − f(x−h)) / 2h。 选用中心差分而非前向差分是因为截断误差是 O(h²) 而非 O(h),更准。
4.2 gradcheck 工具
1
2
3
4
5
6
7
8
def gradcheck(f, args, tol=1e-5):
for a in args:
a.grad = 0.0
out = f(*args)
out.backward()
for i, a in enumerate(args):
num = numerical_grad(f, args, i)
assert abs(a.grad - num) < tol
测试样例:
1
2
3
gradcheck(lambda a, b: a * b, [Value(2.0), Value(-3.0)])
gradcheck(lambda a: a.tanh(), [Value(-1.2)])
gradcheck(lambda a, b: a/b + (a*b).exp(), [Value(1.2), Value(0.7)])
4.3 Karpathy sanity 用例(手算 oracle)
这个例子是 micrograd 原仓库给的,手算结果 / PyTorch 对拍结果都是固定的:
1
2
3
4
5
6
7
8
9
x = Value(-4.0)
z = 2 * x + 2 + x
q = z.relu() + z * x
h = (z * z).relu()
y = h + q + q * x
y.backward()
# 标准答案
assert y.data == -20.0
assert x.grad == 46.0
如果你的实现能跑过这个用例,说明链式传播是对的。
4.4 数值梯度为什么不适合做训练
| 维度 | 数值梯度 | 反向传播 |
|---|---|---|
| 时间复杂度 | O(N) 次前向(N=参数量) | 1 次前向 + 1 次反向 |
| GPT-4 级别 | ~10¹² 次前向,不可能 | ~2 次计算,现实 |
| 数值稳定性 | 截断误差 + 浮点误差此消彼长 | 只有浮点误差 |
但做单元测试时数值梯度完美:参数量只有几个,速度不重要;而它的存在不依赖被测代码——真正的独立 oracle。
5. 训练与结果:用 Value 训 XOR
5.1 XOR 问题
1
2
3
4
(0,0) → -1
(0,1) → +1
(1,0) → +1
(1,1) → -1
线性模型不可能拟合(XOR 非线性可分),最小架构是 2 层 MLP。
5.2 用 Value 搭 MLP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Neuron:
def __init__(self, nin, rng):
self.w = [Value(rng.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, xs):
act = self.b
for wi, xi in zip(self.w, xs):
act = act + wi * xi
return act.tanh()
def params(self):
return self.w + [self.b]
class Layer:
def __init__(self, nin, nout, rng):
self.neurons = [Neuron(nin, rng) for _ in range(nout)]
def __call__(self, xs):
outs = [n(xs) for n in self.neurons]
return outs[0] if len(outs) == 1 else outs
def params(self):
return [p for n in self.neurons for p in n.params()]
class MLP:
def __init__(self, nin, nouts, rng):
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i+1], rng) for i in range(len(nouts))]
def __call__(self, xs):
for layer in self.layers:
xs = layer(xs)
return xs
def params(self):
return [p for layer in self.layers for p in layer.params()]
5.3 训练循环
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import random
rng = random.Random(42)
mlp = MLP(2, [4, 1], rng)
X = [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]]
Y = [-1.0, 1.0, 1.0, -1.0]
for step in range(300):
total = Value(0.0)
for xs, y in zip(X, Y):
xs_v = [Value(xi) for xi in xs]
y_pred = mlp(xs_v)
total = total + (y_pred - y) ** 2
for p in mlp.params():
p.grad = 0.0
total.backward()
for p in mlp.params():
p.data -= 0.1 * p.grad
if step % 30 == 0:
print(f"step {step:3d} loss {total.data:.4f}")
5.4 结果
运行 pytest tests/test_autograd.py::test_xor_training,典型输出:
1
2
3
4
5
step 0 loss 3.9283
step 30 loss 0.3714
step 60 loss 0.0452
step 90 loss 0.0163
step 120 loss 0.0087
标签用 ±1 而非 0/1:因为输出层是 tanh,值域为 (-1, 1)。用 0/1 会导致模型永远摸不到上界。
5.5 性能感知(为 P1 铺垫)
打印一下这个 2→4→1 的 MLP 每步前向创建的 Value 对象数:大约 ~50 个 / 样本 × 4 样本 = 200 个 / step。
同样的计算用 numpy 矩阵运算:1 次 matmul(几个 numpy 数组)。
这就是 P1 要解决的问题:标量 autograd 在教学上完美,在性能上不可接受。只要把粒度从 “float” 换成 “numpy array”,同一套算法瞬间加速 10–100 倍。
踩坑清单
忘了
+=:一个节点被两次使用(a*b + a)时,=会覆盖第一次的贡献,梯度直接错一半梯度没清零:下一步用的是累加过的梯度,训练直接炸——每次 backward 前必须
for p in params: p.grad = 0拓扑序错了:如果你直接用”入队顺序”(BFS 正序)而不是 DFS 后序逆序,父节点会在子节点之前被
_backward,后面到的贡献永远丢失__radd__/__rmul__忘写:2 * a报TypeError,因为int.__mul__不认Value,没有__rmul__兜底就挂了__pow__把指数也当Value:递归结构错位;实现里必须assert isinstance(other, (int, float))——真的要支持x^y(y 也可变)要用(y*log(x)).exp()组合tanh值域边界误解:tanh 输出 ∈(-1, 1),不含端点。XOR 的标签用 ±1 刚好贴着值域极限,用 0/1 模型永远收敛不了数值梯度的 h 选太小:
h=1e-10时浮点舍入误差 > 截断误差,数值梯度反而变差。经验值h=1e-5是甜点Python 的
float * Value不用__rmul__:没写__rmul__ = __mul__,2.0 * a会得到 Python float 而非 Value,计算图断了但不会报错——沉默的 bug输出节点
self.grad = 1.0忘设:整张图梯度全 0,loss 一动不动,debug 半天Value默认参数用可变对象:_children=[]是 Python 经典陷阱(所有实例共享同一个 list)。用 tuple()就没事
本章小结
- 实现:
Value类 ~150 行,支持+ - * / ** exp tanh relu+backward() - 实现:用
Value手搓 MLP,XOR loss 训到 < 0.01(300 步) - 实现:
gradcheck工具,9 个算子 + 复合表达式全部通过数值对拍 - 能解释:为什么反向传播必须拓扑排序(答:父节点的梯度必须等所有后代贡献累加完才能继续回传)
- 能解释:为什么每个算子只要管”局部梯度”(答:链式法则把全局导数分解为局部导数的乘积)
- 能解释:梯度为什么是
+=不是=(答:同一节点被多路径使用时,各路径贡献需累加) - 能解释:数值梯度为什么只适合做测试不适合做训练(答:N 个参数要 O(N) 次前向;精度受浮点误差限制)
与 P1 的连接
P0 的本质瓶颈:标量运算粒度太细。
训一张 MNIST(784 维输入、128 隐层、10 类输出)单步前向就要:
- 784 × 128 + 128 × 10 ≈ 10 万次
__mul__ - 每次创建一个
Value对象、调一个 Python 闭包
P1 的答案:把 Value 升级成 Tensor——data 存成 numpy 数组,_backward 一次算完一整层的梯度(一个 matmul 的反向就是两个 matmul)。
数学原理完全一样:链式法则、拓扑排序、梯度累加,一字不改。 变的只有粒度:从 float 升到 np.ndarray;从 for loop 升到 np.dot。
这就是为什么 PyTorch 的名字叫 Tensor——同样的 autograd,只是让每个节点装一堆数,而不是一个数。
P0 完成。下一章见
textbook/P1-向量化与MLP.md。