手写反向传播
写一个几十行的类,让每个数都记住自己是怎么算出来的,就能从损失出发,用链式法则把梯度一路传回每个参数。用数值求导核对它,再用它训练一个小网络学会异或。
- 约 60 分钟
- 难度:进阶
- 实测:2026-09-14 纯 Python,固定随机种子
上一课的梯度,是对着一个具体的公式手推出来的。可神经网络是一层套一层的复杂函数,参数成百上千,手推每一个的导数是不可能的。
这一课写一个小工具,让计算机自动算出所有参数的梯度。它的名字叫反向传播(backpropagation)。PyTorch 最核心的功能就是它,这一课用纯 Python 写一个只有几十行的版本,写完你就知道 loss.backward() 这一行背后在做什么。
链式法则
先看一个简单的例子。假设 y = 3x,z = y²。问:x 变一点点,z 变多少?
分两步想:x 变一点点,y 变 3 倍那么多(y 对 x 的导数是 3);y 变一点点,z 变 2y 倍那么多(z 对 y 的导数是 2y)。所以 x 变一点点,z 变 3 × 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 + b:a变一点,结果就变一点,所以对a、对b的局部导数都是 1。 - 乘法
a × b:a变一点,结果变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
分两步:
- 排好顺序。
visit保证一个数总是排在所有算出它的数之后。反过来遍历,就是从损失往回走,并且处理到某个数时,所有依赖它的数都已经处理完了,它的梯度已经累加齐了。 - 传递梯度。损失对自己的导数是 1。往回走的每一步,都用链式法则:上游的梯度加上"下游的梯度 × 局部导数"。
为什么是"加上"(+=),而不是直接赋值?因为一个数可能被用了好几次。比如 y = x * x,x 同时是乘法的两个输入,两条路径传回来的梯度要加在一起。
验证:和数值求导比一比
写完先测试。用一个小算式 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 = 4,f = 4² = 16;f 对 (a×b+c) 的导数是 2 × 4 = 8,所以 df/dc = 8,df/da = 8 × b = -24,df/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,它做的是完全相同的事,只是一次处理一整批数字(张量),并且用优化过的底层代码计算,快了成千上万倍。
练习
- 给
Num加一个relu方法:输入大于 0 时原样输出,否则输出 0。它的局部导数是什么?写好后用数值求导核对。 - 把训练循环里清零梯度的那两行删掉,重新运行,看看会发生什么。
- 把隐藏层的神经元从 4 个改成 1 个、2 个,还能学会异或吗?再把随机种子换几个试试。
自测
1. 链式法则说的是什么?它和反向传播是什么关系?
链式法则:一串运算的导数,等于每一步局部导数的乘积。反向传播就是系统地运用链式法则:从损失出发,沿着计算过程往回走,每经过一步就乘上这一步的局部导数,最终得到损失对每个参数的导数。
2. 反向传播时,为什么用 += 累加梯度,而不是直接赋值?
一个数可能在计算中被用了多次,比如 y = x × x 里 x 出现了两次。每一次使用都会有一条路径把梯度传回来,这些梯度要加在一起才是完整的导数。
3. 为什么一条直线学不会异或,加了隐藏层和 tanh 的网络就能学会?
异或的四个点没法用一条直线分开。多层直线叠在一起,结果仍然是一条直线,所以关键在于非线性的激活函数 tanh:它让网络能组合出弯曲的边界,把这四个点分开。
提问与讨论
这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。
提问 +3 积分,回答别人 +6 积分。内容经审核后公开。
正在加载讨论…