Post

P0:标量自动微分

P0:标量自动微分

对应代码src/micrograd/engine.py(~150 行) 对应测试tests/test_autograd.py 参考:Karpathy · micrograd;Zero to Hero Lecture 1


本章目标

读完能回答的问题:

  1. 为什么”自动微分”叫 automatic 而不是 symbolic?和手算、符号求导有什么本质区别?
  2. 计算图上的 forward 和 backward 各做了什么?为什么 backward 必须拓扑排序?
  3. 链式法则在代码里具体长什么样?为什么每个算子只要管”局部导数”就够了?
  4. 梯度为什么是 += 而不是 =?(一个节点被多次使用会发生什么)
  5. 数值梯度(finite difference)为什么不适合用在真实训练里,却适合做单元测试?

能动手实现的东西:

  1. 一个 ~150 行的 Value 类,支持 + - * / ** exp tanh relubackward()
  2. Value 手搓一个 2 层 MLP,在 XOR 问题上训练到 loss < 0.01
  3. 一个 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。 卡点:Lw 经过几十层非线性变换后的结果,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 = 4dc/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 前向:一边算值,一边建图

每做一次算术运算都:

  1. 算出结果数值
  2. 创建一个新 Value 包住结果
  3. 把”左右操作数”记进 _prev
  4. 把”如何把我的 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 反向:链式法则的图上版本

链式法则的本质:复合函数的导数 = 逐段局部导数相乘

在计算图上这件事变得极简:

  1. 输出节点 grad = 1.0dL/dL = 1
  2. 拓扑排序:每个节点 必须等它的所有后代节点处理完才能轮到它
  3. 逆序遍历所有节点
  4. 每访问一个节点,调用它的 _backward——把自己的 grad 按本节点算子的局部导数 累加+=)到父节点

2.4 为什么只需要管”局部导数”

链式法则的奇迹:每个算子只要知道”我对自己输入的导数”就够了

算子前向局部梯度(如何更新 _prev 的 grad)
a + ba + ba.grad += out.gradb.grad += out.grad
a * ba * ba.grad += b * out.gradb.grad += a * out.grad
a ** na ** na.grad += n * a**(n-1) * out.grad
exp(a)e^aa.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})"

设计选择

  • _childrentuple 入参_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 倍。


踩坑清单

  1. 忘了 +=:一个节点被两次使用(a*b + a)时,= 会覆盖第一次的贡献,梯度直接错一半

  2. 梯度没清零:下一步用的是累加过的梯度,训练直接炸——每次 backward 前必须 for p in params: p.grad = 0

  3. 拓扑序错了:如果你直接用”入队顺序”(BFS 正序)而不是 DFS 后序逆序,父节点会在子节点之前被 _backward,后面到的贡献永远丢失

  4. __radd__ / __rmul__ 忘写2 * aTypeError,因为 int.__mul__ 不认 Value,没有 __rmul__ 兜底就挂了

  5. __pow__ 把指数也当 Value:递归结构错位;实现里必须 assert isinstance(other, (int, float))——真的要支持 x^y(y 也可变)要用 (y*log(x)).exp() 组合

  6. tanh 值域边界误解:tanh 输出 ∈ (-1, 1)不含端点。XOR 的标签用 ±1 刚好贴着值域极限,用 0/1 模型永远收敛不了

  7. 数值梯度的 h 选太小h=1e-10 时浮点舍入误差 > 截断误差,数值梯度反而变差。经验值 h=1e-5 是甜点

  8. Python 的 float * Value 不用 __rmul__:没写 __rmul__ = __mul__2.0 * a 会得到 Python float 而非 Value,计算图断了但不会报错——沉默的 bug

  9. 输出节点 self.grad = 1.0 忘设:整张图梯度全 0,loss 一动不动,debug 半天

  10. 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

This post is licensed under CC BY 4.0 by the author.