案例拆解:三层网络的反向传播逐层手推
上一篇把链式法则和计算图讲完了,但公式里全是 ∂L/∂W 这样的符号,没有一个具体数字。这一篇换一种做法:固定一张只有 14 个参数的小网络,喂一个样本,把前向、反向、权重梯度全部手算出来。
算完你会发现两件事。第一,反向传播没有新数学,每一层都是「上游梯度 × 本地导数」。第二,手算的结果和 PyTorch 的 autograd 在每个元素上都对得上,最大绝对差是 0。
一、固定一张网:2 → 3 → 2 → 2
网络结构:
输入 2 维
→ Linear(2, 3) + ReLU
→ Linear(3, 2) + ReLU
→ Linear(2, 2)
→ softmax + 交叉熵
<!-- 配图:2→3→2→2 的圆点网络,标出 W₁(3,2)、W₂(2,3)、W₃(2,2) 与每个单元的偏置 -->
权重和偏置全部固定:
W₁ = [[ 0.2, 0.1], b₁ = [0.1, 0.2, 0.3]
[ 0.3, -0.2],
[-0.1, 0.4]]
W₂ = [[ 0.1, 0.2, -0.1], b₂ = [ 0.1, -0.1]
[ 0.2, -0.1, 0.3]]
W₃ = [[ 0.3, 0.1], b₃ = [ 0.05, -0.05]
[-0.2, 0.4]]输入和标签:
x = [1.0, 0.5]
y = 0之所以选这么小的网络,是因为每个梯度都能用笔算复现,而且 2 分类的输出层只有两个节点,softmax 和交叉熵可以直接写成算式。真实网络有 109,386 个参数,但反向传播的规则一模一样,只是矩阵大了。
二、前向:把反向要用的值缓存下来

<!-- 配图:x→z₁/a₁→z₂/a₂→logits/p→L 五个框,右侧缓存形状面板 -->
第一层:
z₁ = W₁x + b₁
= [0.2×1.0 + 0.1×0.5 + 0.1,
0.3×1.0 − 0.2×0.5 + 0.2,
−0.1×1.0 + 0.4×0.5 + 0.3]
= [0.35, 0.40, 0.40]
a₁ = ReLU(z₁) = [0.35, 0.40, 0.40]第二层:
z₂ = W₂a₁ + b₂
= [0.1×0.35 + 0.2×0.40 − 0.1×0.40 + 0.1,
0.2×0.35 − 0.1×0.40 + 0.3×0.40 − 0.1]
= [0.175, 0.050]
a₂ = ReLU(z₂) = [0.175, 0.050]输出层:
logits = W₃a₂ + b₃
= [0.3×0.175 + 0.1×0.050 + 0.05,
−0.2×0.175 + 0.4×0.050 − 0.05]
= [0.1075, −0.0650]softmax:
e⁰·¹⁰⁷⁵ = 1.11351
e⁻⁰·⁰⁶⁵ = 0.93707
分母 = 2.05058
p = [1.11351 / 2.05058, 0.93707 / 2.05058]
= [0.54301838, 0.45698162]交叉熵:
L = −log p₀ = −log(0.54301838) = 0.6106121093到这里前向结束。反向需要哪些值?
| 缓存 | 形状 | 反向时给谁用 |
|---|---|---|
| x | (1, 2) | 算 dW₁ |
| z₁ | (1, 3) | 算 ReLU 的导数 |
| a₁ | (1, 3) | 算 dW₂ |
| z₂ | (1, 2) | 算 ReLU 的导数 |
| a₂ | (1, 2) | 算 dW₃ |
| p | (1, 2) | 算 dz₃ |
| y | (1,) | 算 onehot |
七样东西,一个都不能少。这就是「前向存值、反向用值」的具体含义:反向传播不是把前向重算一遍,而是复用前向缓存的中间结果。
三、输出层:dz₃ = p − onehot

<!-- 配图:类别/p/onehot/p−onehot 四列表格;L=0.610612;推导面板 -->
softmax 加交叉熵的梯度有一个非常简洁的结果:
∂L/∂zᵢ = pᵢ − 1{i = y}对 y = 0:
dz₃ = [0.54301838 − 1, 0.45698162 − 0]
= [−0.45698162, 0.45698162]为什么这么简单?因为 softmax 的雅可比是:
∂pᵢ/∂zⱼ = pᵢ(1{i=j} − pⱼ)交叉熵对概率的导数是:
∂L/∂pᵢ = −1{i=y} / pᵢ两者相乘,pᵢ 和 1/pᵢ 抵消,剩下 pᵢ − 1{i=y}。框架把这两步合并成一个 CrossEntropyLoss,所以反向时看不到 softmax 的雅可比矩阵,只看到一次减法。
两个可以当场验证的性质:
Σ pᵢ = 1.0
Σ dz₃ = −0.45698162 + 0.45698162 = 0第二行不是巧合:输出层梯度沿概率单纯形的方向没有自由度,所有分量加起来必须是 0。
权重梯度:
dW₃ = dz₃ᵀ a₂
= [[−0.45698162 × 0.175, −0.45698162 × 0.050],
[ 0.45698162 × 0.175, 0.45698162 × 0.050]]
= [[−0.07997178, −0.02284908],
[ 0.07997178, 0.02284908]]
db₃ = dz₃ = [−0.45698162, 0.45698162]dW₃ 的两行符号相反:正确类 0 的概率要往上推,所以它的梯度是负的(梯度下降会加回去);错误类 1 的概率要往下压,所以梯度是正的。
四、隐藏层:把梯度传回去

<!-- 配图:两段递推 dz₃→da₂→dz₂ 与 dz₂→da₁→dz₁,标出 W₃、W₂ 的手算 -->
先算第二层隐藏层收到的梯度:
da₂ = dz₃ W₃
= [−0.45698162, 0.45698162] @ [[0.3, 0.1],
[−0.2, 0.4]]
= [−0.45698162×0.3 + 0.45698162×(−0.2),
−0.45698162×0.1 + 0.45698162×0.4]
= [−0.22849081, 0.13709449]注意这里用的是 W₃ 本身,不是它的转置。形状对一下:
dz₃: (1, 2) W₃: (2, 2) da₂: (1, 2)(1,2) @ (2,2) 得到 (1,2),正好是 a₂ 的形状。
再穿过 ReLU:
dz₂ = da₂ ⊙ ReLU'(z₂)
= [−0.22849081, 0.13709449] ⊙ [1, 1]
= [−0.22849081, 0.13709449]z₂ = [0.175, 0.050],两个都大于 0,所以 ReLU 的导数都是 1,梯度原样通过。如果某个 z₂ 是负的,对应位置就会被乘成 0,这条路径上的梯度就断了。
第二层的权重梯度:
dW₂ = dz₂ᵀ a₁
= [[−0.22849081],
[ 0.13709449]] @ [[0.35, 0.40, 0.40]]
= [[−0.07997178, −0.09139632, −0.09139632],
[ 0.04798307, 0.05483779, 0.05483779]]
db₂ = dz₂ = [−0.22849081, 0.13709449]继续往第一层传:
da₁ = dz₂ W₂
= [−0.22849081, 0.13709449] @ [[0.1, 0.2, −0.1],
[0.2, −0.1, 0.3]]
= [0.00456982, −0.05940761, 0.06397743]形状:(1,2) @ (2,3) → (1,3),和 a₁ 对齐。
穿过第一层 ReLU:
dz₁ = da₁ ⊙ ReLU'(z₁)
= [0.00456982, −0.05940761, 0.06397743] ⊙ [1, 1, 1]
= [0.00456982, −0.05940761, 0.06397743]第一层的权重梯度:
dW₁ = dz₁ᵀ x
= [[ 0.00456982],
[−0.05940761],
[ 0.06397743]] @ [[1.0, 0.5]]
= [[ 0.00456982, 0.00228491],
[−0.05940761, −0.02970381],
[ 0.06397743, 0.03198871]]
db₁ = dz₁到这里所有梯度都出来了。回看整个过程,每一层只做了三件事:
dW = dzᵀ a_prev
db = sum(dz)
da_prev = dz W
dz_prev = da_prev ⊙ activation'(z_prev)这四行就是反向传播的全部代码。
五、权重梯度:三条能看出来的规律

<!-- 配图:dW₃、dW₂、dW₁ 三个矩阵与三条规律面板 -->
把三个矩阵放在一起看,有三条规律。
规律一:dW 的每一行正比于输入激活。 dW₁ 的两列之比是 1 : 0.5,正好是输入 x 的两维之比:
dW₁ 第 1 列 / 第 2 列 = 2.0
x 第 1 维 / 第 2 维 = 2.0dW₂ 的三列之比是 0.35 : 0.40 : 0.40,正好是 a₁。dW₃ 的两列之比是 0.175 : 0.050,正好是 a₂。这就是 dW = dzᵀ a 的直接后果:输入激活大的那一维,权重梯度也大。
规律二:dW 的每一行正比于上游梯度。 dW₃ 第 1 行全是负的,第 2 行全是正的,因为 dz₃ 是 [−0.4569, +0.4569]。上游梯度决定这一行往哪个方向推,输入激活决定推多大。
规律三:如果激活是 0,整行梯度是 0。 这个案例里 a₁、a₂ 全部为正,所以三条规律看不出来。把 x 改成 [1.0, −0.5],第一层某个 z 落到负半轴,a 变成 0,那么 dW₂ 对应那一列就是 0,这条连接这一轮完全学不到东西。这就是第 3 章「死亡 ReLU」的伏笔。
再看梯度量级:
| 梯度 | 范数 |
|---|---|
| W₁ | 0.09774 |
| b₁ | 0.08743 |
| W₂ | 0.17725 |
| b₂ | 0.26646 |
| W₃ | 0.11762 |
| b₃ | 0.64627 |
db₃ 最大,因为它直接等于输出层的 dz₃,没有经过任何衰减。dW₁ 最小,因为梯度从输出层传到第一层,穿过了两层矩阵乘和两次 ReLU,量级被压小了。这个趋势在真实网络里也一样,只是不会这么夸张——因为真实网络用的是 He 初始化和 Adam。
六、一个 batch:两条样本取平均
真实训练不会一次只喂一个样本。把 batch 设成 2,再加一个样本:
x₂ = [−0.5, 1.0]
y₂ = 1前向结果:
z₁ = [[0.35, 0.40, 0.40],
[0.10, −0.15, 0.75]]
a₁ = [[0.35, 0.40, 0.40],
[0.10, 0.00, 0.75]]
z₂ = [[0.175, 0.050],
[0.035, 0.145]]
a₂ = [[0.175, 0.050],
[0.035, 0.145]]
logits = [[0.1075, −0.0650],
[0.0750, 0.0010]]
p = [[0.54301838, 0.45698162],
[0.51849156, 0.48150844]]第二个样本的真实类是 1,它的 p₁ = 0.4815,预测得并不好,所以损失更大:
L₂ = −log(0.48150844) = 0.7308315
L = (0.6106121 + 0.7308315) / 2 = 0.6707218
<!-- 配图:两条样本的 dz₃、db₃ 与平均损失 0.6707,右侧 batch=1 对比面板 -->
batch 的反向,就是每个样本各自算一遍,再取平均。等价写法是在 dz₃ 上先除以 batch:
dz₃ = (p − onehot) / batch两条样本的 dz₃:
样本 1:p − onehot = [−0.45698162, 0.45698162]
样本 2:p − onehot = [ 0.51849156, −0.51849156]
除以 2:
dz₃ = [[−0.22849081, 0.22849081],
[ 0.25924578, −0.25924578]]db₃ 是两行相加:
db₃ = [−0.22849081 + 0.25924578, 0.22849081 − 0.25924578]
= [0.03075497, −0.03075497]和单样本的 db₃ = [−0.45698162, 0.45698162] 相比,量级小了十几倍。这不是因为梯度变弱了,是因为两条样本的梯度方向部分抵消,再除以 2。
dW₃ 也变成两条样本的平均:
dW₃ = [[−0.03091229, 0.02616610],
[ 0.03091229, −0.02616610]]逐项验证第一行第一列:
样本 1 贡献:−0.22849081 × 0.175 = −0.03998589
样本 2 贡献: 0.25924578 × 0.035 = 0.00907360
相加:−0.03091229这个数字和上面的矩阵对上了。
为什么 loss 要取平均
如果 loss 取和而不是平均,batch 翻倍,梯度也翻倍,等效于学习率翻倍。取平均之后,梯度的量级和 batch 大小无关,学习率才能跨 batch 复用。这是框架默认 reduction="mean" 的原因,也是手写实现里必须写 / batch 的原因。
七、三重校验:手算、autograd、数值差分

<!-- 配图:六个梯度张量的三重校验表,autograd 差全为 0,数值差分最差 3.76e−11 -->
三套结果放在一起:
| 梯度 | 与 autograd 最大绝对差 | 与中心差分最大相对误差 |
|---|---|---|
| W₁ | 0.0 | 1.300e−10 |
| b₁ | 0.0 | 1.300e−10 |
| W₂ | 0.0 | 3.482e−12 |
| b₂ | 0.0 | 1.089e−11 |
| W₃ | 0.0 | 3.605e−12 |
| b₃ | 0.0 | 3.762e−11 |
第一列全是 0,意思不是「差不多」,是逐位相同。手写 NumPy 和 PyTorch autograd 在 float64 下,对这 14 个参数算出的梯度一个 bit 都不差。
第二列是数值差分的结果。最差的一项是 b₃ 的 3.76e−11,仍然比验收线 1e−6 低四个数量级。差异来自中心差分本身的截断误差和舍入误差,不是反向传播算错了。
为什么 autograd 能逐位相同?因为两边的运算顺序足够接近:都是先算 p − onehot,再乘 aᵀ,再 sum。矩阵只有 2×2、3×2,浮点加法的结合顺序没有分歧,舍入结果自然一致。真实网络里矩阵更大,求和顺序不同,绝对差会到 1e−16 量级,但相对误差仍然是机器精度。
八、autograd 对齐:两条路径,同一个结果

<!-- 配图:手写 NumPy 与 PyTorch autograd 两条路径,比较条 max|ΔW|=0.0、loss 差 1.8e−12 -->
PyTorch 侧只做四件事:
x = torch.tensor([[1.0, 0.5]], dtype=torch.float64)
y = torch.tensor([0], dtype=torch.long)
logits = x @ W1.T + b1
a1 = torch.relu(logits)
logits = a1 @ W2.T + b2
a2 = torch.relu(logits)
logits = a2 @ W3.T + b3
loss = F.cross_entropy(logits, y)
loss.backward()手写侧用同样的权重、同样的输入、同样的 float64:
z1 = x @ W1.T + b1
a1 = np.maximum(z1, 0.0)
z2 = a1 @ W2.T + b2
a2 = np.maximum(z2, 0.0)
logits = a2 @ W3.T + b3
p = softmax(logits)
onehot = np.zeros_like(p)
onehot[np.arange(1), y] = 1.0
dz3 = (p - onehot) / 1
dW3 = dz3.T @ a2
db3 = dz3.sum(axis=0)
da2 = dz3 @ W3
dz2 = da2 * (z2 > 0)
dW2 = dz2.T @ a1
db2 = dz2.sum(axis=0)
da1 = dz2 @ W2
dz1 = da1 * (z1 > 0)
dW1 = dz1.T @ x
db1 = dz1.sum(axis=0)对照结果:
loss:手写 0.6106121092982567,autograd 0.6106121093000982
loss 差:1.8e−12
max|ΔW₁| = 0.0
max|ΔW₂| = 0.0
max|ΔW₃| = 0.0
max|Δb₁| = 0.0
max|Δb₂| = 0.0
max|Δb₃| = 0.0loss 差 1.8e−12 来自 softmax 的 exp 和 log 实现差异:NumPy 和 PyTorch 的数学库不是同一份代码,最后几位会有分歧。但梯度的差是 0,说明反向传播的路径完全一致。
这个案例说明了一件事:autograd 不神秘。它做的就是上面这十二行,只是把形状、转置、链式调用自动化了。下一篇会把这十二行扩展到 109,386 个参数,并和 PyTorch 做逐轮对照。
九、这一篇没解决的问题
14 个参数可以手算,109,386 个参数不行。真实网络里还有四个新问题:
- 形状怎么系统地对?
(64,128)的dz和(128,784)的W怎么乘? - 手写的
backward怎么组织成循环,而不是十二行硬编码? - 怎么确认手写实现没有在某个形状上悄悄广播错?
- 训到 97.5% 需要多少轮、多少时间,和 PyTorch 差多少?
实战篇会把这些问题一个个落地。这一篇只需要记住那四行递推公式——它们是后面所有代码的骨架。
自检
合上笔记,试着回答:
- 这个网络有几个参数?每层的
W、b形状是什么? - 前向需要缓存哪些值?每个值在反向时给谁用?
- 为什么
dz₃ = p − onehot?Σdz₃为什么必须是 0? da₂ = dz₃W₃为什么不用W₃ᵀ?形状怎么对上?dW₁的两列之比为什么等于输入的两维之比?- batch=2 时,
db₃为什么比单样本小?/batch的作用是什么? - 手写梯度和 autograd 的最大绝对差是多少?这个 0 说明什么?
答不上来的那一条,回到对应小节,把矩阵乘的形状和数字重新算一遍。下一篇开始写真正的反向传播代码。