手写一个 Autograd
Day 08 知道了 PyTorch 的 Autograd 是 Dispatcher 上的一层 key。
今天把它彻底拆开——
用 200 行 Python 实现一个能跑 MLP 的 mini-autograd,
亲手完成"前向构图 → 拓扑排序 → 反向链式法则"的全过程。
跑通之后,PyTorch backward() 对你不再有秘密。
思维导图
链式法则——一切的起点
Autograd 不是魔法,它只是链式法则的工程化。 数学上一行公式,实现上需要回答三个问题: 谁是谁的输入?局部梯度怎么算?多路径梯度怎么合并? 理清这三件事,backward 算法就只剩下"按图反着走一遍"。
∂z/∂x = ∂z/∂y · ∂y/∂x
多输入:若 z 依赖 x 通过多条路径 y₁, y₂, ...,则梯度按路径相加:
∂z/∂x = Σᵢ (∂z/∂yᵢ · ∂yᵢ/∂x)
三个核心原语的局部梯度
| op | 正向 | 对 a 的局部梯度 | 对 b 的局部梯度 | 分发规则 |
|---|---|---|---|---|
| add | c = a + b | ∂c/∂a = 1 | ∂c/∂b = 1 | 梯度等额传给两个输入 |
| mul | c = a * b | ∂c/∂a = b | ∂c/∂b = a | 梯度交叉乘对方的值 |
| relu | c = max(a, 0) | ∂c/∂a = 1 if a>0 else 0 | — | 负值断流,梯度归零 |
反向模式 = 一次反向遍历
前向模式(forward-mode AD)每次只能求一个输入的偏导,要算所有输入要跑 N 次前向。反向模式只要 1 次反向遍历就拿到所有输入的梯度——这就是为什么深度学习只用反向模式:参数数千万,输入是 loss 单点。
"局部"是组合的力量
每个 op 只需要知道自己的局部梯度(只关心自己的输入输出),全局梯度由链式法则自动组合。这就是为什么 Autograd 容易扩展——加一个新 op 只需提供它的 forward 和 local backward,不必关心整张图。
Value 节点——计算图的原子
链式法则是数学,要把它跑起来还得有承载图结构的数据结构。
我们设计一个 Value 类,它既存储数据,
又是计算图的一个节点——前向时被链入图,反向时被遍历到。
只需要 4 个字段 + 1 个闭包。
Value 类骨架
class Value: """标量自动微分节点。data: float, grad: float, _children: tuple[Value].""" def __init__(self, data, _children=(), _op=''): self.data = data # 前向值 self.grad = 0.0 # 累积梯度,初始 0 self._children = _children # 反向遍历用的"边" self._op = _op # 仅用于调试 / 打印 self._backward = lambda: None # 默认 no-op,叶子节点用 def __repr__(self): return f"Value(data={self.data:.4f}, grad={self.grad:.4f}, op={self._op!r})"
为什么用闭包存反向逻辑
每个 op 的反向行为不一样,但调用方式必须统一。用闭包把 op 的反向逻辑"封装在结构里"——backward 算法只要无脑调 node._backward(),不需要 if-else 判断 op 类型。这是 micrograd 最优雅的设计。
梯度累加而不是覆盖
同一个变量可能多次进入计算(比如 y = x*x 里 x 出现两次)。如果用 = 覆盖,只会保留最后一条路径的贡献。必须 +=,这就是 PyTorch 也要 optimizer.zero_grad() 的原因——上一步的累加要清掉。
叶节点的特殊性
叶节点(.children = ())的 _backward 是 no-op——它没有上游可以再分发。但它的 .grad 会被所有指向它的 op 节点 += 累加,最终就是该变量的总梯度。
对照 PyTorch
Value.data ↔ Tensor.data;.grad ↔ .grad;._children ↔ grad_fn.next_functions;._backward ↔ grad_fn.apply()。结构完全同构,只是 PyTorch 在 C++ 里实现并支持张量、设备、dtype。
backward 算法——拓扑排序 + 反向遍历
数据结构准备好,backward 算法只有两个步骤:
① 用 DFS 把整个 DAG 拓扑排序;② 按逆序遍历,每个节点调一次 _backward()。
为什么拓扑排序?因为必须先收齐所有上游贡献的梯度,再往下游分发——
否则一个节点的 .grad 还不完整就用它做局部反向,结果就错了。
backward 的 12 行实现
def backward(self): """从 self 出发触发反向传播,梯度回填到所有可达的叶节点。""" # 1) DFS 后置顺序构造拓扑序(从叶到根) topo, visited = [], set() def build(v): if v in visited: return visited.add(v) for child in v._children: build(child) topo.append(v) build(self) # 2) 自己的梯度初始化为 1 (默认 dL/dL = 1) self.grad = 1.0 # 3) 反向遍历,逐节点调用其闭包 for v in reversed(topo): v._backward()
顺序错了梯度就错
设想反向时先处理 u 再处理 z——u.grad 还没被 z._backward() 写入,就被用去算 a.grad,等于用了一个未完成的梯度。必须保证一个节点的所有上游都处理完,它的 .grad 才是终值。拓扑序就是干这件事。
为什么 self.grad = 1
反向传播的边界条件:计算 ∂L/∂L = 1。从这个"种子"出发链式法则才能展开。PyTorch 里 loss.backward() 默认也是这个;若调 tensor.backward(grad_outputs) 就是显式指定上游梯度——可以是向量,常用于 Jacobian-vector product。
O(V + E) 时间
DFS 拓扑排序 O(V+E),反向遍历 O(V),每个节点的 _backward 摊销 O(d) 其中 d 是入边数。总复杂度 = O(V + E),与正向传播同阶——这是反向模式 AD 最强大的性质,backward 的代价不超过前向。
深图要小心栈溢出
递归 DFS 在很深的网络(LLM 数百层)上会触发 Python 默认 1000 的栈深。生产实现用迭代版 DFS 或 sys.setrecursionlimit。Day 11/13 我们看 PyTorch 的工程实现就是迭代的。
完整实现 — micrograd.py
把前三节合起来,完整代码不到 200 行。
支持 + - * / ** exp log relu 等基本 op,
足以训练一个手写的 MLP——
这就是 Karpathy micrograd 的核心思想。
Value 类全实现
# micrograd.py — 标量自动微分,纯 Python ~150 行 from __future__ import annotations import math class Value: def __init__(self, data, _children=(), _op=''): self.data = float(data) self.grad = 0.0 self._children = _children self._op = _op self._backward = lambda: None # ----- 二元运算 ----- 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 += 1.0 * out.grad other.grad += 1.0 * out.grad out._backward = _backward return out 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 # ∂out/∂self = other other.grad += self.data * out.grad # ∂out/∂other = self out._backward = _backward return out def __pow__(self, k): # 仅支持常数指数 assert isinstance(k, (int, float)) out = Value(self.data ** k, (self,), f'**{k}') def _backward(): self.grad += (k * self.data ** (k - 1)) * out.grad 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 def exp(self): out = Value(math.exp(self.data), (self,), 'exp') def _backward(): self.grad += out.data * out.grad # d(eˣ)/dx = eˣ out._backward = _backward return out def log(self): out = Value(math.log(self.data), (self,), 'log') def _backward(): self.grad += (1.0 / self.data) * out.grad out._backward = _backward return out # ----- 派生运算 (复用上面已有的) ----- def __neg__(self): return self * -1 def __sub__(self, o): return self + (-o) def __rsub__(self, o): return o + (-self) def __truediv__(self, o): return self * (o ** -1 if isinstance(o, (int, float)) else o ** -1) def __radd__(self, o): return self + o def __rmul__(self, o): return self * o # ----- backward ----- def backward(self): topo, visited = [], set() def build(v): if id(v) in visited: return visited.add(id(v)) for c in v._children: build(c) topo.append(v) build(self) self.grad = 1.0 for v in reversed(topo): v._backward() def __repr__(self): return f"Value(data={self.data:.4f}, grad={self.grad:.4f}, op={self._op!r})"
最小冒烟测试
from micrograd import Value a = Value(2.0) b = Value(-3.0) c = Value(10.0) e = a * b d = e + c # d = a·b + c = -6 + 10 = 4 f = d.relu() # f = relu(4) = 4 f.backward() print(a.grad, b.grad, c.grad) # 期望: # ∂f/∂a = b * 1 (relu 通过) = -3 # ∂f/∂b = a * 1 = 2 # ∂f/∂c = 1 # 输出:-3.0 2.0 1.0 ✓
用它训练一个 MLP
import random from micrograd import Value # --- 用 Value 搭建一个最小神经网络 --- class Neuron: def __init__(self, nin): self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)] self.b = Value(0.0) def __call__(self, x): act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b) return act.relu() def parameters(self): return self.w + [self.b] class Layer: def __init__(self, nin, nout): self.neurons = [Neuron(nin) for _ in range(nout)] def __call__(self, x): return [n(x) for n in self.neurons] def parameters(self): return [p for n in self.neurons for p in n.parameters()] class MLP: def __init__(self, sizes): self.layers = [Layer(sizes[i], sizes[i+1]) for i in range(len(sizes)-1)] def __call__(self, x): for l in self.layers: x = l(x) return x[0] if len(x)==1 else x def parameters(self): return [p for l in self.layers for p in l.parameters()] # --- 数据:学一个简单 XOR-like 数据集 --- xs = [[2.0,3.0], [3.0,-1.0], [0.5,1.0], [1.0,1.0]] ys = [1.0, -1.0, -1.0, 1.0] model = MLP([2, 4, 4, 1]) # --- 训练循环 --- for step in range(200): # 1) 前向 + loss preds = [model(x) for x in xs] loss = sum((p - y)**2 for p, y in zip(preds, ys)) # 2) 清梯度 + 反向 for p in model.parameters(): p.grad = 0.0 loss.backward() # 3) SGD 更新 lr = 0.05 for p in model.parameters(): p.data -= lr * p.grad if step % 20 == 0: print(f"step {step:3d} loss = {loss.data:.4f}") # 你应该看到 loss 一路下降到接近 0,200 行代码就训练了一个 MLP
整个 PyTorch 训练循环就是这五行
前向 → loss → zero_grad → backward → optimizer.step,每个真实训练循环都是这五步。PyTorch 复杂度全在"如何把每一步加速到 GPU、张量化、并行化",逻辑上和这 150 行没有不同。
从标量到张量
micrograd 是标量自动微分,每个数都是单独节点。PyTorch 是张量自动微分,一个节点持有整个张量,局部梯度也是张量。抽象同构,只是把数换成张量后局部梯度公式更复杂(矩阵乘的反向是另一个矩阵乘)。
怎么加一个新 op
三步走:① 计算 out.data;② 构造 Value(out_data, (self, ...), 'op_name');③ 定义 _backward(写出对每个输入的局部梯度)。比如要加 tanh:out.data = tanh(x),self.grad += (1 - out.data**2) * out.grad。
用 Graphviz 看图
给 Value 写个简单的递归 dot 导出器(每个节点画圆,每条 _children 画边),用 Graphviz 渲出来——你会得到和 PyTorch torchviz 输出几乎一样的反向图。调试梯度问题的第一手段。
与 PyTorch 对照验证
手写的代码要和参考实现交叉验证才算可信。 把同一个表达式分别在 micrograd 和 PyTorch 里算一遍, 对比梯度——一致就说明你的实现是对的。 这也是写新 op 时最有效的回归测试方法。
import torch from micrograd import Value # ----- 测试函数:f(a, b, c) = (a * b + c).relu() ** 2 ----- # 1) micrograd 版本 a1 = Value(2.0); b1 = Value(-3.0); c1 = Value(10.0) y1 = ((a1 * b1 + c1).relu()) ** 2 y1.backward() mg = (y1.data, a1.grad, b1.grad, c1.grad) # 2) PyTorch 版本 a2 = torch.tensor(2.0, requires_grad=True) b2 = torch.tensor(-3.0, requires_grad=True) c2 = torch.tensor(10.0, requires_grad=True) y2 = ((a2 * b2 + c2).relu()) ** 2 y2.backward() pt = (y2.item(), a2.grad.item(), b2.grad.item(), c2.grad.item()) # 3) 对比 for name, x, y in zip(["y", "a.grad", "b.grad", "c.grad"], mg, pt): print(f"{name:8s} micrograd={x:8.4f} pytorch={y:8.4f} ok={abs(x-y)<1e-6}") # 期望输出: # y micrograd= 16.0000 pytorch= 16.0000 ok=True # a.grad micrograd=-24.0000 pytorch=-24.0000 ok=True # b.grad micrograd= 16.0000 pytorch= 16.0000 ok=True # c.grad micrograd= 8.0000 pytorch= 8.0000 ok=True
两边的对应关系
| 概念 | micrograd | PyTorch | 区别 |
|---|---|---|---|
| 节点 | Value | Tensor | PyTorch 持有多维数据 + 设备 + dtype |
| 数据 | .data (float) | .data (Tensor) | 同名,语义一致 |
| 梯度 | .grad (float) | .grad (Tensor) | 都是累加(+=) |
| 反向边 | ._children | grad_fn.next_functions | PyTorch 把图节点和 Tensor 分开 |
| 反向逻辑 | ._backward (闭包) | grad_fn.apply() (C++ 类) | PyTorch 为性能用静态分发 |
| 触发 | v.backward() | v.backward() | 完全同名 |
| 种子梯度 | self.grad = 1 | 等价默认行为 | PyTorch 支持 grad_outputs 显式指定 |
| 清梯度 | 循环置 0 | optimizer.zero_grad() | 语义一致 |
推荐扩展练习
加一个 sigmoid
实现 sigmoid(x) = 1/(1+exp(-x)),梯度 σ(x)(1-σ(x))。在 MLP 里把 ReLU 换成 sigmoid 看训练效果。
实现 detach()
给 Value 加 .detach():返回一个新 Value,_children=()。验证用它能切断梯度路径(对应 PyTorch 同名 API)。
检测重复 backward
PyTorch 默认 .backward() 后会释放图,再次 backward 会报错(retain_graph=False)。给 micrograd 加同样的保护——backward 后清空 _children。
张量化:TinyTensor
把 Value 换成 ndarray:每个节点持有 numpy 数组,实现 matmul/sum/broadcast 等。能跑通一个真正的张量 autograd,你就理解了 PyTorch 的核心——这是一个不错的周末项目。
常见疑问
Q1 闭包捕获的 self / other 是什么时候求值的?会不会出问题? +
闭包按引用捕获变量名,在调用时才查值。但这里 self、other、out 都是对象引用(Python 里就是指针),即使 data 后来变了,引用仍指向同一对象,因此能正确读到当时的 self.data 等。
常见陷阱:在 for 循环里定义闭包并保存到列表,所有闭包会引用同一个循环变量,导致全部读到最后一次的值。micrograd 这里没有这个问题,因为每次调 __mul__ 都是新的函数作用域。
Q2
为什么必须用 += 累加梯度?
+
因为一个节点可能被多次"使用",沿不同路径都有梯度贡献。例如 y = x*x:
计算 y 的 _backward 时,self 和 other 都是同一个 x。两行 self.grad += other.data * out.grad 和 other.grad += self.data * out.grad 都会写到 x.grad,结果 x.grad = 2x——恰好就是 d(x²)/dx。如果用 =,只会保留后一次,结果错。
这也是为什么 PyTorch 每次训练前要 optimizer.zero_grad()——上一步累积的梯度要清,否则会和本步混在一起。
Q3 DFS 拓扑排序为什么必须是"后置"顺序? +
后置 DFS(先递归 children,再 append self)的产出顺序是"叶在前,根在后"。反转一下就是"根在前,叶在后"——正好是反向传播需要的顺序:从输出节点的 .grad 出发,逐步往叶子分发。
如果用前置 DFS(先 append 再递归),顺序会变成"根在前,叶在后"。反转后是"叶在前,根在后",反向遍历时根的梯度还没写就开始往下分发 —— 错。
本质:后置 DFS 保证一个节点被 append 时它的所有 children 都已经在序列里了,反过来就保证反向遍历时所有上游都处理完了。
Q4 如果计算图有环会怎样?神经网络真的是 DAG 吗? +
必须是 DAG。如果有环,DFS 会无限递归(或者用 visited 集合跳过环节点,但梯度结果不正确)。
神经网络的计算图每个时刻都是 DAG。即使 RNN/Transformer 看起来"循环",实际上是展开(unroll)后的 DAG——每个时间步是独立节点,只是参数共享(同一个 Value 被多个 op 引用,这正是 += 累加发挥作用的地方)。
所以"参数共享"和"图里有环"完全是两回事:前者是同一节点被多次使用,后者是真正的循环依赖。神经网络只有前者,没有后者。
Q5 PyTorch 的 backward 比 micrograd 复杂在哪里? +
核心算法完全一样(toposort + reversed 遍历),复杂度全在工程细节:
(1) 张量梯度,不是标量:matmul 的反向是另一个 matmul,conv 的反向需要 transposed conv,这些反向公式都不平凡。
(2) 原地操作 (in-place):x.add_(1) 会修改 x.data,但反向可能需要原值——PyTorch 用 version counter 检测违规。
(3) 多 GPU / 异步 / stream:backward 也要派发到正确的 device,跨 device 时插入 memcpy。
(4) 高阶导数 (double backward):对 grad 再求 grad,需要图里嵌套图。
(5) 计算图释放策略:retain_graph=False 默认 backward 后销毁 saved tensors 释放显存。
(6) 并行 backward:大图可以拆成几路并发反向,在 C++ 引擎里有专门的 ready queue 调度。
但理解了 micrograd,这些都是对同一个算法骨架的工程加固。
复盘问题
- 手推
z = (a + b * c) ** 2对 a / b / c 的偏导,写出每个中间变量的局部梯度,标出反向遍历顺序。 - 写一个
tanh算子加入 micrograd:实现_backward,并用 PyTorch 对照验证(梯度公式1 - tanh²(x))。 - 解释为什么反向模式 AD 的总耗时与前向同阶,但前向模式 AD 需要 N 次前向才能算所有 N 个输入的梯度。
- 把 micrograd 的递归 DFS 改成迭代版本(用显式栈)。运行你的 MLP demo 验证结果一致。
- 设计实验:把
y = x * x的 backward 跑一遍,然后故意把+=改成=,观察x.grad错在什么数字上,解释为什么。
今日检查清单
- 能用一张图说出 z = (a+b)·c 的局部梯度分布和反向累加路径
- 理解为什么深度学习只用反向模式,不用前向模式 AD
- 能写出 Value 类的 4 个核心字段,解释每一个的作用
- 能解释"用闭包存反向逻辑"相比"按 op 类型 if-else 派发"的优势
- 能写出 backward 的 12 行实现,解释为什么必须用后置 DFS
- micrograd.py 跑通,Value 的 + / * / ** / relu 全部测试通过
- MLP demo 跑通,loss 能稳定下降到接近 0
- 与 PyTorch 对照验证至少 3 个表达式,梯度全部一致(差 < 1e-6)
推荐阅读
Karpathy — micrograd / Lecture
"The spelled-out intro to neural networks and backpropagation" 这一讲视频 + 代码仓库。今天的所有思路都是它的精炼版,Karpathy 在视频里手把手 live coding 极清晰。
Baydin et al. — Automatic Differentiation in Machine Learning: a Survey
2018 年的综述,系统讲清楚 AD 的四种模式(symbolic / numeric / forward / reverse)以及各自的复杂度权衡。读完不会再混淆这些概念。
PyTorch torch/autograd/__init__.py
PyTorch Python 层 backward 入口,虽然真正的执行在 C++ 引擎里,但 Python 这层薄薄的入口很值得读——可以看到 grad_outputs、retain_graph 等参数的真实语义。
ezyang — How PyTorch's Autograd Engine works
从 C++ 引擎角度讲 PyTorch backward 怎么调度多 GPU、怎么用 ready queue 做并行。和 micrograd 是不同维度的同一件事。
Goodfellow DL — Chapter 6.5 Back-Propagation
《Deep Learning》经典教材 6.5 节,从矩阵微分的视角推 backward,可以补齐"从标量到张量"的数学跨越。
Day 10 预告
算子与后端 — ATen / c10 / CUDA kernel 注册
今天看清了 Autograd,Day 10 把目光投向算子的实现层:ATen 是什么、c10 在哪一层、一个 CUDA kernel 在源码里是怎样注册到 Dispatcher 的?读懂这一层之后,"给 PyTorch 加一个自定义算子"对你不再神秘——这是 Phase 2 实战分布式时必备的能力。