模块 08 · 第 3 课

手写反向传播

写一个几十行的类,让每个数都记住自己是怎么算出来的,就能从损失出发,用链式法则把梯度一路传回每个参数。用数值求导核对它,再用它训练一个小网络学会异或。

  • 约 60 分钟
  • 难度:进阶
  • 实测:2026-09-14 纯 Python,固定随机种子

上一课的梯度,是对着一个具体的公式手推出来的。可神经网络是一层套一层的复杂函数,参数成百上千,手推每一个的导数是不可能的。

这一课写一个小工具,让计算机自动算出所有参数的梯度。它的名字叫反向传播(backpropagation)。PyTorch 最核心的功能就是它,这一课用纯 Python 写一个只有几十行的版本,写完你就知道 loss.backward() 这一行背后在做什么。

链式法则

先看一个简单的例子。假设 y = 3xz = y²。问:x 变一点点,z 变多少?

分两步想:x 变一点点,y 变 3 倍那么多(yx 的导数是 3);y 变一点点,z2y 倍那么多(zy 的导数是 2y)。所以 x 变一点点,z3 × 2y 倍。

z 对 x 的导数 = (z 对 y 的导数) × (y 对 x 的导数)

这就是链式法则:一串运算的导数,等于每一步的导数乘起来。每一步的导数只和这一步本身有关,叫局部导数。

神经网络就是一长串运算:乘、加、再经过一个非线性函数,一层一层往下,最后算出损失。只要知道每一步的局部导数,就能用链式法则,从损失出发一步一步往回乘,得到损失对每个参数的导数。

让每个数都记账

办法是:每做一次运算,就把"结果对每个输入的局部导数"记下来。写一个类 Num,它除了保存数值,还保存一个列表:[(输入的数, 局部导数), ...]

class Num:
    """一个会记账的数:记下自己的值、梯度,以及"我对每个上游数的局部导数"。"""

    def __init__(self, value, parents=()):
        self.value = value
        self.grad = 0.0
        self.parents = parents  # [(上游的 Num, 局部导数), ...]

    def __add__(self, other):
        other = other if isinstance(other, Num) else Num(other)
        # a + b 对 a 的导数是 1,对 b 的导数也是 1
        return Num(self.value + other.value, [(self, 1.0), (other, 1.0)])

    def __mul__(self, other):
        other = other if isinstance(other, Num) else Num(other)
        # a × b 对 a 的导数是 b,对 b 的导数是 a
        return Num(self.value * other.value, [(self, other.value), (other, self.value)])

    def __pow__(self, n):
        # x 的 n 次方,导数是 n × x 的 (n-1) 次方
        return Num(self.value ** n, [(self, n * self.value ** (n - 1))])

    def tanh(self):
        t = math.tanh(self.value)
        # tanh 的导数是 1 - tanh²
        return Num(t, [(self, 1 - t * t)])

每种运算都只需要知道自己的局部导数:

  • 加法 a + ba 变一点,结果就变一点,所以对 a、对 b 的局部导数都是 1。
  • 乘法 a × ba 变一点,结果变 b 倍,所以对 a 的局部导数是 b,对 b 的是 a
  • 乘方 xⁿ:局部导数是 n × xⁿ⁻¹,高中数学学过。
  • tanh:一个把任意数压到 -1 到 1 之间的函数,神经网络里常用,它的导数是 1 - tanh²

用 Python 的运算符重载(__add____mul__ 这些),a * b + c 这样的普通写法就会自动产生 Num 对象,并悄悄记下了整个计算过程。

(完整的代码里还有减法和让普通数字也能参与运算的几行,见 code/08-neural-nets/backprop.py。)

反向传播

计算完成后,每个 Num 都知道自己是从哪几个数算出来的,整个计算过程形成了一张图。反向传播就是从最终的结果(损失)出发,沿着这张图往回走:

    def backward(self):
        """从这个数(通常是损失)出发,把梯度传给所有上游的数。"""
        order, seen = [], set()

        def visit(node):  # 先访问完所有上游,再把自己放进列表:得到一个"从上游到下游"的顺序
            if id(node) not in seen:
                seen.add(id(node))
                for parent, _ in node.parents:
                    visit(parent)
                order.append(node)

        visit(self)
        self.grad = 1.0  # 损失对自己的导数是 1
        for node in reversed(order):  # 从下游往上游,链式法则:上游梯度 += 下游梯度 × 局部导数
            for parent, local in node.parents:
                parent.grad += node.grad * local

分两步:

  1. 排好顺序visit 保证一个数总是排在所有算出它的数之后。反过来遍历,就是从损失往回走,并且处理到某个数时,所有依赖它的数都已经处理完了,它的梯度已经累加齐了。
  2. 传递梯度。损失对自己的导数是 1。往回走的每一步,都用链式法则:上游的梯度加上"下游的梯度 × 局部导数"。

为什么是"加上"(+=),而不是直接赋值?因为一个数可能被用了好几次。比如 y = x * xx 同时是乘法的两个输入,两条路径传回来的梯度要加在一起。

验证:和数值求导比一比

写完先测试。用一个小算式 f = (a × b + c)²a=2, b=-3, c=10

a, b, c = Num(2.0), Num(-3.0), Num(10.0)
f = (a * b + c) ** 2
f.backward()
print(f"  f = {f.value}")
print(f"  自动算出的梯度:df/da={a.grad}, df/db={b.grad}, df/dc={c.grad}")

再用上一课的数值办法(这次用更准确的"左右各挪一点")核对:

def numeric(fn, x, h=1e-6):
    return (fn(x + h) - fn(x - h)) / (2 * h)
== 1. f = (a × b + c)²,a=2, b=-3, c=10
  f = 16.0
  自动算出的梯度:df/da=-24.0, df/db=16.0, df/dc=8.0
  数值求导核对: df/da≈-24.0000, df/db≈16.0000, df/dc≈8.0000

完全一致。手算一下也能验证:a × b + c = 4f = 4² = 16f(a×b+c) 的导数是 2 × 4 = 8,所以 df/dc = 8df/da = 8 × b = -24df/db = 8 × a = 16

用它搭一个神经网络

有了自动求导,就可以搭神经网络了。

一个神经元做的事情很简单:把每个输入乘以一个权重,加起来,再加一个偏置,最后经过 tanh

class Neuron:
    def __init__(self, n_inputs):
        self.w = [Num(random.uniform(-1, 1)) for _ in range(n_inputs)]
        self.b = Num(0.0)

    def __call__(self, xs):
        total = self.b
        for w, x in zip(self.w, xs):
            total = total + w * x
        return total.tanh()

去掉最后的 tanh,它就是第 1 课的那条直线,只是输入从 1 个变成了多个。tanh 这种非线性函数(也叫激活函数)少不了:没有它,很多层直线叠在一起,结果还是一条直线,什么复杂的规律都学不了。

一排神经元组成一层,两层叠起来就是一个小网络:2 个输入 → 4 个隐藏的神经元 → 1 个输出。数一下参数:隐藏层 4 个神经元,每个有 2 个权重加 1 个偏置,共 12 个;输出层 1 个神经元,4 个权重加 1 个偏置,共 5 个。一共 17 个参数。

学会异或

异或(XOR):两个输入相同时输出 -1,不同时输出 1(因为 tanh 的输出在 -1 到 1 之间,用 -1 和 1 代替通常说的 0 和 1)。

它是一个经典的例子,因为一条直线做不到:在平面上画出这四个点,你没法用一条直线把 (0,1)(1,0)(0,0)(1,1) 分开。必须要有隐藏层和非线性函数。

训练的循环和上一课的梯度下降一模一样,只是算梯度那一步换成了 loss.backward()

data = [([0, 0], -1), ([0, 1], 1), ([1, 0], 1), ([1, 1], -1)]
lr = 0.1
for epoch in range(1, 301):
    loss = Num(0.0)
    for xs, y in data:
        pred = output(hidden(xs))[0]
        loss = loss + (pred - y) ** 2
    for p in params:
        p.grad = 0.0  # 每一轮都要清零,否则梯度会一直累加
    loss.backward()
    for p in params:
        p.value -= lr * p.grad

注意每一轮开始前要把梯度清零。因为 backward 用的是 +=,不清零的话,这一轮的梯度会加在上一轮的梯度上。这是一个非常经典的错误,用 PyTorch 时也一样(下一课的 optimizer.zero_grad())。

== 2. 网络:2 个输入 → 4 个隐藏神经元 → 1 个输出,共 17 个参数
  第   1 轮  损失 4.1022
  第  10 轮  损失 3.7902
  第  50 轮  损失 0.1093
  第 100 轮  损失 0.0346
  第 200 轮  损失 0.0134
  第 300 轮  损失 0.0081
  训练后的预测:
    输入 [0, 0] → -0.965(目标 -1)
    输入 [0, 1] → +0.952(目标 +1)
    输入 [1, 0] → +0.953(目标 +1)
    输入 [1, 1] → -0.951(目标 -1)

一开始损失是 4.1,四个预测几乎都是错的。前 10 轮进展很慢,然后突然开始下降,50 轮就降到了 0.1。300 轮后,四个预测都非常接近目标。

一条直线学不会的规律,17 个参数的小网络学会了。整个过程没有任何人告诉它"异或是什么",它只是一次次地算损失、算梯度、往梯度的反方向调整参数。

我们写了什么

回头看,这一课的几十行代码已经包含了深度学习框架最核心的东西:

  • 自动求导:每个运算记下局部导数,反向传播用链式法则把梯度传回去。
  • 神经元和层:加权求和,加偏置,过激活函数。
  • 训练循环:前向计算损失,清零梯度,反向传播,更新参数。

它当然非常慢:每个数都是一个 Python 对象,一个大一点的网络会有几百万个这样的对象。下一课换成 PyTorch,它做的是完全相同的事,只是一次处理一整批数字(张量),并且用优化过的底层代码计算,快了成千上万倍。

练习

  1. Num 加一个 relu 方法:输入大于 0 时原样输出,否则输出 0。它的局部导数是什么?写好后用数值求导核对。
  2. 把训练循环里清零梯度的那两行删掉,重新运行,看看会发生什么。
  3. 把隐藏层的神经元从 4 个改成 1 个、2 个,还能学会异或吗?再把随机种子换几个试试。

自测

1. 链式法则说的是什么?它和反向传播是什么关系?

链式法则:一串运算的导数,等于每一步局部导数的乘积。反向传播就是系统地运用链式法则:从损失出发,沿着计算过程往回走,每经过一步就乘上这一步的局部导数,最终得到损失对每个参数的导数。

2. 反向传播时,为什么用 += 累加梯度,而不是直接赋值?

一个数可能在计算中被用了多次,比如 y = x × x 里 x 出现了两次。每一次使用都会有一条路径把梯度传回来,这些梯度要加在一起才是完整的导数。

3. 为什么一条直线学不会异或,加了隐藏层和 tanh 的网络就能学会?

异或的四个点没法用一条直线分开。多层直线叠在一起,结果仍然是一条直线,所以关键在于非线性的激活函数 tanh:它让网络能组合出弯曲的边界,把这四个点分开。

提问与讨论

这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。

提问 +3 积分,回答别人 +6 积分。内容经审核后公开。

正在加载讨论…