1. 从 micrograd 开始

本节源自 Andrej Karpathy 的 micrograd,一个极简的手写 autograd 引擎。我也会补充实现中的注意点、trick,以及自己踩过的坑和改进。

1.1 反向传播基础原理

首先是微积分中最熟悉的链式法则。设有中间变量 $z$,且 $x = f(z),\ z = g(y)$:

$$ \frac{dx}{dy} = \frac{dx}{dz} \cdot \frac{dz}{dy} $$

在神经网络中对应着 input → ... → z → ... → y → output (loss)。我们需要知道每一步由哪几个变量通过什么运算得到,再套用求导公式即可。下面是一个简易的 autograd 引擎实现:

import math

class Value:
    def __init__(self, data, _child=(), op=''):
        self.data = data
        self._prev = set(_child)
        self.op = op
        self.grad = 0.0
        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 += out.grad * 1.0
            other.grad += out.grad * 1.0

        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
            other.grad += self.data * out.grad

        out._backward = _backward
        return out

    def exp(self):
        out = Value(math.exp(self.data), (self,), 'exp')

        def _backward():
            self.grad += out.grad * math.exp(self.data)

        out._backward = _backward
        return out

    def __pow__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        p = other.data
        out = Value(math.pow(self.data, p), (self, other), '**')

        def _backward():
            # ∂(x^p)/∂x = p * x^(p-1)
            self.grad += p * math.pow(self.data, p - 1) * out.grad
            # ∂(x^p)/∂p = x^p * ln(x),仅当 other 需要梯度时有效
            if isinstance(other, Value) and other._prev:
                other.grad += out.data * math.log(self.data) * out.grad

        out._backward = _backward
        return out

    def tanh(self):
        data = self.data
        e = math.exp(2 * data)
        out = Value((e - 1) / (e + 1), (self,), 'tanh')

        def _backward():
            self.grad += (1 - out.data ** 2) * out.grad

        out._backward = _backward
        return out

    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 child in reversed(topo):
            child._backward()

    def __truediv__(self, other):
        return self * (other ** -1)

    def __radd__(self, other):
        return self + other

    def __rmul__(self, other):
        return self * other

    def __sub__(self, other):
        return self + (-other)

    def __rsub__(self, other):
        return (-self) + other

    def __neg__(self):
        return self * Value(-1)

    def __repr__(self):
        return f'Value: {self.data}, grad: {self.grad}'

几个容易出错的地方:

  • 梯度是累加的:一个节点可能被多条路径复用(例如残差连接),所以 .grad 必须用 += 而非 =。
  • tanh 的导数:$(\tanh x)' = 1 - \tanh^2 x$,不少人对这个公式不太熟悉。
  • __rsub__ 的方向:a - b 在执行 b.__rsub__(a) 时等价于 $a + (-b)$,所以实现是 (-self) + other。

1.2 验证 engine 的正确性

写几行简单的测试,确保梯度计算正确:

from engine import Value

a = Value(1.0)
b = Value(2.0)
c = a / b
d = a ** b
e = c + d
f = e.tanh()
g = f.exp()

output = g
output.backward()

for var in [a, b, c, d, e, f, g, output]:
    print(var.grad)

输出如下(可以手动计算,或用 PyTorch 的官方 backward 对比验证):

a.grad = 1.1169018461554512
b.grad = -0.11169018461554513
c.grad = 0.4467607384621805
d.grad = 0.4467607384621805
e.grad = 0.4467607384621805
f.grad = 2.472298423141742
g.grad = 1.0
output.grad = 1.0

可以看到 output 和 g 的 grad 都是 1——两者其实是同一个对象,此处分开打印只是为了直观。

梯度从结尾开始往回传播,直到每个叶子节点。虽然叶子节点的 grad 也有值,但这实际上没有意义:input 是不可更新的,这里只是传播过去了而已。在实际应用中,创建 tensor 时默认 requires_grad=False,一方面是减少计算量,另一方面是更好地管理显存——详见附录。


2. 把 autograd 用到神经网络上

沿用 Karpathy 的例子实现一个简单的 MLP,我在此基础上做了一些改动以支持更大范围的拟合。

from engine import Value
import random


class Neuron:
    def __init__(self, nin, activation=True):
        """nin: 该 neuron 的输入个数"""
        self.w = [Value(random.uniform(-10, 10)) for _ in range(nin)]
        self.b = Value(random.uniform(-1, 1))
        self.activation = activation

    def __call__(self, x):
        # output = w·x + b
        out = sum(xi * wi for xi, wi in zip(x, self.w)) + self.b
        out = out.tanh() if self.activation else out
        return out

    def parameter(self):
        return self.w + [self.b]


class Layer:
    def __init__(self, nin, nout, activation=True):
        """nin: 输入维度(上一层 neuron 数)  nout: 输出维度(本层 neuron 数)"""
        self.neurons = [Neuron(nin, activation) for _ in range(nout)]

    def __call__(self, x):
        return [n(x) for n in self.neurons]

    def parameter(self):
        return [p for n in self.neurons for p in n.parameter()]


class MLP:
    def __init__(self, nin, nout):
        """nin: 初始 input 维度  nout: 各层 neuron 数的列表,如 [16, 16, 4]"""
        sz = [nin] + nout
        self.layer = [Layer(sz[i], sz[i + 1]) for i in range(len(nout) - 1)]
        self.layer.append(Layer(sz[-2], sz[-1], activation=False))  # 输出层不加激活

    def __call__(self, x):
        for l in self.layer:
            x = l(x)
        return x

    def parameter(self):
        return [p for l in self.layer for p in l.parameter()]

2.1 实践中踩过的坑

激活函数是必需品。 没有激活函数,多层线性变换叠加后仍是线性的,多层的意义就消失了,而且容易梯度爆炸。工程实践中会越来越多地体会到这一点。

输出层不要加激活函数。 如果最后一层加了 tanh,你的 output 就被限制在了 $(-1, 1)$ 内,对于很多回归任务来说这完全不够。

2.2 手动训练循环

from engine import Value
from nn import MLP
import random

# ===== 超参和数据的定义放前面 =====
input_len = 4
x = [Value(random.uniform(-10, 10)) for _ in range(4)]
target = [1, 100, -100, -100]
target_len = 4

n = MLP(4, [16, 16, 4])
lr = 0.01


def update():
    # 前向传播
    loss = Value(0.0)
    output = n(x)
    for i in range(target_len):
        loss += (output[i] - Value(target[i])) ** 2

    # 梯度清零 + 反向传播 + 参数更新
    for p in n.parameter():
        p.grad = 0
    loss.backward()
    for p in n.parameter():
        p.data -= lr * p.grad

    print(output)
    print('----------------')
    print(loss)


epoch = 50
for i in range(epoch):
    update()

梯度清零是每轮训练前必须做的事。 代码里的 p.grad = 0 就是在手动完成这一步。如果不清理,梯度会跨 batch 累加——不仅毫无意义,显存也会被逐渐占满(在实际 PyTorch 中由 optimizer.zero_grad() 完成)。

这里手动模拟了完整的训练过程:前向传播 → 计算 loss → 梯度清零 → 反向传播 → 参数更新。理解了这一套流程,再去用 PyTorch 的 optimizer.step() 和 optimizer.zero_grad() 就会觉得它们不过是这套逻辑的封装而已。


附录:为什么这件事值得花时间

理解反向传播的实现,最直接的好处是让训练不再是黑盒——你能从 loss 数值之外,获得更细粒度的诊断信号。

A. 梯度异常时快速定位问题

知道 loss.backward() 沿计算图逐层回传后,遇到梯度爆炸/消失、NaN loss 时就不会抓瞎。可以在 backward() 之后、optimizer.step() 之前,注册 hook 检查各层梯度:

for name, param in model.named_parameters():
    param.register_hook(
        lambda g, n=name: print(f"{n}: mean={g.mean():.6f}, std={g.std():.6f}, nan={g.isnan().any()}")
    )

看一眼各层梯度量级的变化趋势,就能锁定是哪个层先出问题的。很多 NaN 本质上是某个 op 的局部梯度在某些输入下无定义(如 $1/x$ 在 $0$ 附近),理解 backward 计算规则后可以直接推理。

B. loss 曲线与梯度行为的对应

loss 是标量,梯度是逐参数的向量。只盯着 loss 曲线,很多时候只能猜:

  • loss 不动 + 梯度接近 0 → 学习率太小或卡在平坦区
  • loss 震荡 + 梯度忽大忽小 → 学习率太大或 batch 太小
  • loss 稳定下降但某层梯度始终很小 → 那层可能没在学(梯度消失)

知道了 backward 怎么传之后,你自然会去同时监控 loss 和梯度分布,而不是只盯着 loss 下降。

C. 自定义 loss / 自定义算子

自己写 loss 函数或自定义 autograd.Function 时,forward 和 backward 都得实现,不理解反向传播的计算规则就无从下手。

D. 显存占用

理解 backward 需要保留中间激活(用于链式法则),就会明白为什么 torch.no_grad() 在 eval 阶段能省显存,以及为什么 loss.backward() 后计算图默认会被释放——这些都是从反向传播机制自然推导出来的。

更具体的例子我会在学习过程中慢慢加上!

✎ 最后更新:2026-06-08