Stephen 技术博客

AI和数据平台的工程实践

← 返回文章列表

Micrograd 总结:从零实现自动求导引擎

对应 Karpathy "Neural Networks: Zero to Hero" 第一讲
学习日:Day 1–5

一、为什么先学 Micrograd 再学 PyTorch

直接学 PyTorch 的根本问题:

维度直接学 PyTorch先学 Micrograd
loss.backward() 是什么黑魔法知道每一步在算什么
梯度从哪来"框架自动算"自己写了 _backward 方法
为什么要 zero_grad跟着套路写知道梯度会累加
调试训练 bug不知道哪里坏看得见整个计算图
后面学 Transformer黑盒套黑盒透明,只是 scale up
核心理念:PyTorch 不是魔法 —— 它本质上就是把 Micrograd 这个 200 行的标量自动求导,扩展到了张量并加上 GPU 加速。理解了 Micrograd,你就理解了 PyTorch 的内核。

二、计算图与代码对应

以表达式 L = (a × b + c) ** 2 为例,Micrograd 在背后构建的计算图如下:

Micrograd 计算图示例
蓝实线 = forward 传 data,红虚线 = backward 传 grad(按拓扑逆序)

核心机制

  • 每个 Value 节点保存自己的 datagrad_prev(孩子)、_backward(如何把自己的 grad 传给孩子)
  • 前向时构建图(operator overloading 自动连边)
  • 反向时拓扑排序后逆向遍历,调用每个节点的 _backward()

三、关键超参数

Micrograd 阶段几乎没有"模型超参数",但训练 MLP 时有几个:

参数取值含义
nin3输入特征数(2D 点 + bias)
隐藏层大小[4, 4]两个 4 神经元的隐藏层
nout1输出 1 个值(二分类)
lr0.05 ~ 0.1学习率
训练步数20 ~ 100因为是全量梯度,步数不需要多

参数量:3×4 + 4 + 4×4 + 4 + 4×1 + 1 = 41 个标量参数

四、关键代码

1. Value 类:自动求导的核心

class Value:
    def __init__(self, data, _children=(), _op=''):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None  # 默认什么都不做
        self._prev = set(_children)    # 用于拓扑排序
        self._op = _op                  # 调试用

    def __add__(self, other):
        out = Value(self.data + other.data, (self, other), '+')
        def _backward():
            self.grad  += 1.0 * out.grad   # ∂out/∂self = 1
            other.grad += 1.0 * out.grad   # ∂out/∂other = 1
        out._backward = _backward
        return out

    def __mul__(self, 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 tanh(self):
        t = (math.exp(2*self.data) - 1) / (math.exp(2*self.data) + 1)
        out = Value(t, (self,), 'tanh')
        def _backward():
            self.grad += (1 - t**2) * out.grad   # ∂tanh/∂x = 1-tanh²
        out._backward = _backward
        return out

最关键的洞察_backward 是一个闭包,它捕获了 selfotherout 三个对象的引用,所以反向传播时知道把梯度往哪里加。

2. backward 方法:拓扑排序 + 逆向遍历

def backward(self):
    topo = []
    visited = set()
    def build_topo(v):
        if v not in visited:
            visited.add(v)
            for child in v._prev:
                build_topo(child)
            topo.append(v)
    build_topo(self)

    self.grad = 1.0                  # 起点:dL/dL = 1
    for node in reversed(topo):      # 逆向遍历
        node._backward()

要点:必须拓扑排序,否则可能在父节点 grad 还没算完之前就用它去更新子节点。

3. Neuron / Layer / MLP

class Neuron:
    def __init__(self, nin):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
        self.b = Value(0)

    def __call__(self, x):
        act = sum((wi*xi for wi, xi in zip(self.w, x)), self.b)
        return act.tanh()

    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, nin, nouts):
        sz = [nin] + nouts
        self.layers = [Layer(sz[i], sz[i+1]) for i in range(len(nouts))]
    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x
    def parameters(self):
        return [p for l in self.layers for p in l.parameters()]

4. 训练循环(手工 SGD)

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]

n = MLP(3, [4, 4, 1])

for step in range(100):
    # forward
    ypred = [n(x) for x in xs]
    loss = sum((yout - ygt)**2 for ygt, yout in zip(ys, ypred))

    # zero gradients
    for p in n.parameters():
        p.grad = 0.0

    # backward
    loss.backward()

    # update
    for p in n.parameters():
        p.data += -0.05 * p.grad

    print(step, loss.data)

跑完后 loss ≈ 0.004,模型学会了把 4 个 2D 点分类到正负两类:

训练完成后的 2D 决策边界
红色 = +1 类,蓝色 = -1 类;黑色等高线 = 决策边界(输出 = 0)

五、本阶段引入的核心概念

这是后面所有神经网络的"原子",一定要彻底掌握:

1. 计算图(Computational Graph)

  • 每个数值不是单纯的数,而是一个节点
  • 节点之间通过运算符自动连成 DAG(有向无环图)
  • 这是 PyTorch、TensorFlow、JAX 的统一底层抽象

2. 链式法则的工程实现

  • 数学上:dL/dx = dL/dy × dy/dx
  • 工程上:每个算子只需要写自己的"局部导数"(_backward),全局梯度自动组合
  • 关键:你永远不需要手写整个网络的导数,只需要写每个算子的导数

3. 拓扑排序的必要性

  • 反向传播必须先算父节点的 grad,再传给子节点
  • 错误的顺序会用到还没算完的 grad,结果就是错的
  • 这是为什么不能简单地 DFS 直接调 _backward

4. 梯度的累加性

  • 一个变量如果在计算图中被用了多次(比如 a*aw 在多个样本里都用),它的 grad 会被累加
  • 这正是为什么需要 zero_grad:上一步的 grad 不清零就会污染下一步
  • 在公式里写成:self.grad += ... * out.grad,永远是 += 不是 =

5. 手工 SGD 的三步走

  • forward 算 loss
  • backward 算所有参数的 grad
  • parameter -= lr * grad 更新

PyTorch、JAX、所有框架都是这三步,只是写法不同。

六、值得记住的几个"坑"

  1. _backward 内部必须用 += 而不是 =
    一个 Value 可能被多个父节点用,多次 backward 会调用多次,必须累加。

  2. 不要忘记 for p in parameters: p.grad = 0
    不清零,多次 backward 的梯度会持续累加,训练会发散。

  3. self.grad = 1.0 是反向传播的"起点"
    因为 dL/dL = 1,没有这一行整个反向传播都是 0。

  4. 拓扑排序顺序很关键
    必须先递归处理 children,再 append 自己 —— 这样 reverse 后才是正确的反向顺序。

  5. Value 不支持原生 Python 数字混算需要补 __radd__ / __rmul__
    不然 2 * Value(3) 会报错(Python 找的是 int.__mul__)。

七、与后面 PyTorch / Transformer 的关系

Micrograd 是所有现代深度学习框架的"骨架版":

Micrograd (标量)PyTorch (张量)备注
Value(2.0)torch.tensor([1,2,3])标量 → N 维数组
__add____mul__torch.addtorch.matmul算子集大大扩展
_backward 闭包autograd.Function.backward同样的机制
拓扑排序 + reverse同样实现细节一致
p.data -= lr * p.gradoptimizer.step()封装成对象
CPU、单样本GPU、batch 处理性能差几个数量级

理解了 Micrograd,PyTorch 对你来说就只是"标量 → 张量 + GPU 加速 + 更多算子" —— 没有任何概念上的新东西。

后面 Karpathy 在 Makemore 系列里全程用 PyTorch,但他每次讲到反向传播时都会回到 Micrograd 的逻辑 —— 因为本质就是一样的。

八、Day 6 之前的建议练习

  • 不看代码,自己默写出 Value 类的 __add____mul__
  • 推导 tanh 的导数,手写 _backward
  • 给 Micrograd 加一个 __pow__ 算子(指数为常数),自己推导 backward
  • 故意忘记 zero_grad,看 loss 怎么爆炸
  • 把 lr 从 0.05 改成 1.0,看会不会发散
  • 核心检验:你能不能向一个不懂深度学习的朋友,用 5 分钟讲清楚"反向传播是什么"