📑 本页目录(点开跳转)
08 / 反向传播 · 一段一段看影响
先看误差怎么传回去,
再看矩阵怎么写。
反向传播做的事:算出每个参数稍微变化时,最终损失会怎样变化。
你先带走这句就够了
先算结果,
再把局部影响乘回去。
反向传播负责算梯度;优化器才负责用梯度更新参数。
- 1前向
得到中间值和最终损失。
- 2反向
沿依赖关系计算、复用梯度。
- 3更新
按学习率与梯度修改参数。
01 / 一个能手算的例子
只看一个参数 w = 1
这里用平方运算演示链式法则,不是在推荐网络的激活函数。
02 / 链式法则
三个局部比例,乘成一个总影响
在当前数值处:3 × 4 × 2 = 24。参数 w 微增时,损失的一阶变化率是 24。
点完整的分式看解释。它描述当前位置的局部变化,不是任意大步长下的精确差值。
03 / 梯度下降的下一步
有了梯度,才轮到更新
学习率取 0.01:1 − 0.01 × 24 = 0.76。这里梯度为正,沿负梯度就减小 w。
步子过大仍可能使损失上升;算对梯度不等于任何学习率都合适。
可以停在这里
你已经能区分“向前算结果”“向后算梯度”和“用梯度更新”。矩阵写法只是把许多这样的局部影响一起计算。
继续看异或实验、形状对照与梯度检验 →原有正文、图解和例子都在下方。按需跳转,不必一次读完。
08 · 反向传播
⏱ 36 分钟 | ⭐ 手写一遍就永远忘不掉 | 🔨 必须动手
🎯 一句话
反向传播 = 链式法则 + 从后往前算,把中间结果复用起来。 它不是什么高深理论,就是求导时别做重复劳动。
🧠 一、先建立直觉:谁该为错误负责
💡 人话:一个公司业绩差了,要追责到每个部门、每个人。 反向传播就是从结果开始,一层层往回追责——而且追责时会复用上一层已经算好的结论,不重复劳动。
⛓️ 二、链式法则:唯一需要的数学
对照
如果 loss ← a ← z ← w (w 影响 z,z 影响 a,a 影响 loss)
那么 ∂loss/∂w = ∂loss/∂a × ∂a/∂z × ∂z/∂w
↑ 一路乘回去
💡 人话:「w 变一点点,z 变多少;z 变一点点,a 变多少;a 变一点点,loss 变多少」——三个比例连乘。
为什么要从后往前算:因为 ∂loss/∂a 这一段,前面所有参数都要用。
从后往前计算并复用中间梯度,适合“许多参数影响一个标量损失”的训练问题。反向计算量通常与前向计算量同阶。
不要把它简化成无条件的 O(n) 对 O(n²):复杂度还取决于计算图、输入输出维度;前向自动微分也不等于逐参数做数值差分。
📊 这个"复用"能省多少
信息关系
🔑 所以 1986 年反向传播的普及是个里程碑—— 想法不新(链式法则有几百年了), 它推动了多层网络训练的普及。
💡 一个值得记住的推论: 反向传播的代价大约是前向的 2 倍。 所以「训练一个 epoch 的成本 ≈ 推理 3 次全部数据」—— 这是算量的粗估,不是精确耗时;数据加载、通信、优化器等开销还要另算。
🔨 三、动手:纯 numpy 手写一个能学会异或的网络
这段代码是本章的核心。跑一遍,然后逐行对照下面的注解。
import numpy as np
X = np.array([[0,0],[0,1],[1,0],[1,1]], dtype=float)
y = np.array([[0],[1],[1],[0]], dtype=float) # XOR
def sigmoid(z): return 1/(1+np.exp(-z))
rng = np.random.RandomState(0)
# 网络结构:2 → 8 → 1
W1 = rng.randn(2, 8) * 0.5; b1 = np.zeros(8)
W2 = rng.randn(8, 1) * 0.5; b2 = np.zeros(1)
lr = 0.5
for step in range(3001):
# ============ 前向传播:算出预测和损失 ============
z1 = X @ W1 + b1 # 线性
a1 = np.tanh(z1) # 非线性(第 7 章)
z2 = a1 @ W2 + b2 # 线性
a2 = sigmoid(z2) # 输出概率
loss = -np.mean(y*np.log(a2+1e-9) + (1-y)*np.log(1-a2+1e-9)) # 交叉熵
# ============ 反向传播:从后往前追责 ============
n = len(X)
dz2 = (a2 - y) / n # ⭐ sigmoid+交叉熵的梯度就这么干净:预测−真实
dW2 = a1.T @ dz2 # z2 = a1@W2 → ∂z2/∂W2 = a1
db2 = dz2.sum(0)
da1 = dz2 @ W2.T # 责任传给上一层
dz1 = da1 * (1 - a1**2) # 穿过 tanh:tanh 的导数是 1−tanh²
dW1 = X.T @ dz1
db1 = dz1.sum(0)
# ============ 更新参数:朝梯度反方向走一小步 ============
W2 -= lr*dW2; b2 -= lr*db2
W1 -= lr*dW1; b1 -= lr*db1
if step % 1000 == 0:
print(f"step {step:<5} loss={loss:.4f}")
print("预测:", (a2>0.5).astype(int).ravel(), " 真实:", y.astype(int).ravel())
实测输出:
| 训练步 | 损失 loss |
|---|---|
| 0 | 0.8049 |
| 1000 | 0.0038 |
| 2000 | 0.0016 |
| 3000 | 0.0009 |
预测: [0 1 1 0] 真实: [0 1 1 0] ✅ 学会异或了
你刚刚从零实现了一个神经网络的完整训练。 大约 25 行。
🔍 四、五个值得盯着看的细节
① dz2 = (a2 - y) —— 干净得可疑
Sigmoid + 交叉熵组合时,梯度正好就是「预测 − 真实」,所有复杂项全约掉了。
🔗 这就是第 3 章说的「分类为什么不用 MSE」的另一面—— 这个组合是被数学选中的,不是随便配的。
② 反向的每一步都对应前向的一步
信息关系
③ 穿过激活函数时乘它的导数
对照
tanh: dz = da * (1 - a²)
ReLU: dz = da * (z > 0) ← 负区间直接置 0
sigmoid: dz = da * a * (1-a)
💡 梯度消失的根源就在这里:如果激活函数的导数小于 1(如 sigmoid 最大 0.25), 连乘较小的导数可能导致梯度衰减。ReLU 在正区间导数为 1、负区间为 0,能缓解一类饱和问题;梯度还要经过权重等运算,不能保证完全不消失。
④ .sum(0) 是因为 batch
一个 batch 里每个样本都对偏置有贡献,要加起来。
⑤ 每一行梯度的形状怎么推出来(不用背)
结果对照
💡 卡住时先检查形状,再核对链式法则:形状能排除一些错误,但对得上形状不代表导数一定正确。
要得到 (2,8),手上有 X(4,2) 和 dz1(4,8)—— 只有
X.T @ dz1这一种乘法能对上形状。 这个技巧在手写任何层的反向传播时都管用。
🔬 五、梯度检验:怎么确认自己写对了
手写反向传播极易出错,而且错了不报错,只是"学得慢"。 专业做法是数值检验:
# 数值梯度:把某个参数挪动一点点,看 loss 变多少
eps = 1e-5
W1p = W1.copy(); W1p[0,0] += eps
W1m = W1.copy(); W1m[0,0] -= eps
num_grad = (forward_loss(W1p,...) - forward_loss(W1m,...)) / (2*eps)
# 和你的解析梯度对比
print(f"数值梯度 {num_grad:.8f}")
print(f"解析梯度 {dW1[0,0]:.8f}")
实测结果:
要点
数值梯度 -0.00007294
解析梯度 -0.00007294
相对误差 1.49e-08 ✅ 实现正确
📋 直接能跑的完整版(上面那段依赖前面那个 XOR 网络,这里把它带上了)
复制整段到空文件,python 文件名.py 就能跑,只需要 NumPy。
import numpy as np
X = np.array([[0,0],[0,1],[1,0],[1,1]], dtype=float)
y = np.array([[0],[1],[1],[0]], dtype=float)
def sigmoid(z): return 1/(1+np.exp(-z))
rng = np.random.RandomState(0)
W1 = rng.randn(2,8)*0.5; b1 = np.zeros(8)
W2 = rng.randn(8,1)*0.5; b2 = np.zeros(1)
lr = 0.5
for step in range(3001): # 和第三节那段训练循环一样
a1 = np.tanh(X @ W1 + b1); a2 = sigmoid(a1 @ W2 + b2)
n = len(X); dz2 = (a2 - y) / n
dW2 = a1.T @ dz2; db2 = dz2.sum(0)
dz1 = (dz2 @ W2.T) * (1 - a1**2)
dW1 = X.T @ dz1; db1 = dz1.sum(0)
W2 -= lr*dW2; b2 -= lr*db2; W1 -= lr*dW1; b1 -= lr*db1
def loss_at(W1, b1, W2, b2):
a1 = np.tanh(X @ W1 + b1); a2 = sigmoid(a1 @ W2 + b2)
return -np.mean(y*np.log(a2+1e-9) + (1-y)*np.log(1-a2+1e-9))
# ⭐ 关键:在【当前这组权重】上重新算一次解析梯度,
# 别直接用训练循环里留下的 dW1 —— 那是更新【之前】那组权重上算的(见下面第 5 个坑)
a1 = np.tanh(X @ W1 + b1); a2 = sigmoid(a1 @ W2 + b2)
n = len(X); dz2 = (a2 - y) / n
dz1 = (dz2 @ W2.T) * (1 - a1**2)
dW1 = X.T @ dz1
eps = 1e-5
Wp = W1.copy(); Wp[0,0] += eps
Wm = W1.copy(); Wm[0,0] -= eps
num_grad = (loss_at(Wp,b1,W2,b2) - loss_at(Wm,b1,W2,b2)) / (2*eps)
rel = abs(num_grad - dW1[0,0]) / (abs(num_grad) + abs(dW1[0,0]) + 1e-12)
print(f"数值梯度 {num_grad:.8f}")
print(f"解析梯度 {dW1[0,0]:.8f}")
print(f"相对误差 {rel:.2e}")
实跑输出就是上面那三行:-0.00007294 / -0.00007294 / 1.49e-08。
🔑 本例参考:双精度小例子可参考相对误差 < 1e-6;阈值需随数值尺度、精度和算子调整,不能替代完整测试。 自己实现任何自定义层/损失函数时,梯度检验是必做的一步——第 18 章挑战 A 会大量用到。
⚠️ 梯度检验的五个坑:
| 坑 | 说明 |
|---|---|
| 优先用中心差分 | 光滑函数下,中心差分截断误差是 O(ε²),单边差分为 O(ε);不是固定差两个数量级,还要考虑浮点误差 |
| ε 不能太小 | 双精度示例可从 1e-5 左右试起,并按尺度调整;太小可能被浮点舍入误差淹没 |
| ReLU 的折点 | z≈0 处数值梯度天然不准,这是函数本身不可导,不是 bug |
| 必须关掉随机性 | 固定随机性与状态:关闭 Dropout;固定 BN 的模式/统计。BN 在固定批次上不等于随机运算 |
| 💀 两边必须在同一组权重上算 | 训练循环跑完之后,dW1 就躺在作用域里,顺手拿它比最自然 —— ⚠️ 可它是「更新前」那组权重上算的,而数值梯度取在「更新后」的权重上。实测相对误差 3.90e-04,⭐ 按本章 < 1e-6 的判据,你会以为自己的反向传播写错了,而它其实是对的。正确做法:检验前用当前权重重算一次前向和反向(见上面折叠的完整版) |
💡 相对误差怎么算:
|数值−解析| / (|数值|+|解析|+1e-12)。 用相对误差而不是绝对误差,是因为梯度本身的量级可能很小或很大。
🚀 六、现代框架帮你做了什么
# 🧩 骨架:`loss` 来自你自己的代码,这一段只看写法
# 上面 25 行的反向传播,在 PyTorch 里是这样:
loss.backward() # 就这一行
optimizer.step()
框架做的事叫「自动微分」:
- 前向计算时,悄悄记录下一张计算图(谁是谁算出来的)
- .backward() 时沿着图从后往前,自动套用链式法则
🔬 想看严格的推导(任意层数、矩阵形式的四个方程)? 见《机器学习的数学原理》第 12 章—— 那里还解释了残差连接在公式上到底多了什么。
💡 所以你以后不用手写反向传播 —— 但手写过一遍,你就知道: - 梯度爆炸/消失是怎么发生的(连乘) - 为什么要
optimizer.zero_grad()(梯度默认累加) - 自定义损失函数为什么可能不收敛(可导性)第 18 章挑战 A 会让你从零造一个 mini-torch,把这套自动微分自己实现出来。
🔗 和站内其他章的关系
| 相关的地方 | 根因在这 |
|---|---|
| 推荐算法第 5 章手写 MF 的 SGD 更新 | 同一套梯度下降,只是网络更简单 |
| 全景导论第 15 章「求导交给框架」 | 框架做的就是本章的自动微分 |
| 梯度消失/爆炸 | 反向连乘导致的指数衰减/增长 |
✅ 检查点
- 为什么反向传播要"从后往前"算?复杂度差别是多少?
- 反向传播的代价大约是前向的几倍?这个数字有什么实用价值?
- Sigmoid + 交叉熵的梯度是什么?为什么这么干净?
- 手写时梯度的形状怎么快速推出来?
- 梯度穿过 ReLU 时怎么变?穿过 sigmoid 呢?
- 梯度消失的根源是什么?
- 梯度检验怎么做?判断标准是什么?有哪五个坑?
- 为什么用相对误差而不是绝对误差?
👀 答案
- 在标量损失的计算图中,靠后的中间梯度可被前面的参数复用。逐参数做中心差分要 2P 次前向;反向传播只需一次前向和一次反向,计算量通常与前向同阶。
- 反向计算常粗估为前向约 2 倍,因此前向加反向约 3 倍;这是算量的粗估,不是包含加载、优化器与通信等开销的精确耗时。
- 正好是 (预测 − 真实)。因为这个组合在数学上互相约掉了复杂项——它们是被设计成配对使用的。
- 梯度的形状 = 它对应那个量的形状。
X.T @ dz1的结果是 (2,8),与 W1 一致;这是形状自检,还需要由链式法则确认,并用数值梯度检验。 - ReLU:
dz = da * (z>0),负区间直接置 0;sigmoid:dz = da * a * (1-a),最大值只有 0.25。 - 小导数连乘可能衰减;sigmoid 的导数最大为 0.25。ReLU 正区间为 1、负区间为 0,梯度仍受权重等因素影响,并不保证不消失。
- 用中心差分
(L(+ε)−L(−ε))/(2ε)对比解析梯度。本页双精度小例子可参考相对误差 1e-6,并检查:①中心差分;②ε 与数值尺度适配;③避开不可导折点;④固定随机性和状态,如 Dropout 与 BN 的运行统计;⑤两边在同一组权重上重算。 - 因为梯度本身的量级可能很小或很大,绝对误差没有统一的判断标准;相对误差
|数值−解析|/(|数值|+|解析|)才是尺度无关的。
🛑 可以停在这里
⚡ 走神救援
反向传播用链式法则,从后向前计算并复用梯度;优化器再用梯度更新参数。
- 先看一条短链,再看异或网络;梯度形状是自检线索,不是推导正确的证明。
- 激活导数和权重共同影响梯度,ReLU 不能保证梯度永不消失。
- 检验时用中心差分、合适的 ε,避开折点、固定状态,并在同一组权重上重算解析梯度。
- 正文保留了五个细节、五个检验陷阱与可展开的完整检验示例,忘了就回到对应卡片。
下一节 👉 09-优化器与学习率.md