Skip to content

案例拆解:三层网络的反向传播逐层手推 ​

上一篇把链式法则和计算图讲完了,但公式里全是 ∂L/∂W 这样的符号,没有一个具体数字。这一篇换一种做法:固定一张只有 14 个参数的小网络,喂一个样本,把前向、反向、权重梯度全部手算出来。

算完你会发现两件事。第一,反向传播没有新数学,每一层都是「上游梯度 × 本地导数」。第二,手算的结果和 PyTorch 的 autograd 在每个元素上都对得上,最大绝对差是 0。

一、固定一张网:2 → 3 → 2 → 2 ​

网络结构:

text
输入 2 维
  → Linear(2, 3) + ReLU
  → Linear(3, 2) + ReLU
  → Linear(2, 2)
  → softmax + 交叉熵

![三层网络的结构与权重](/ai-four-kings/第 2 章:反向传播/assets/ch2_case_network.svg)

<!-- 配图:2→3→2→2 的圆点网络,标出 W₁(3,2)、W₂(2,3)、W₃(2,2) 与每个单元的偏置 -->

权重和偏置全部固定:

text
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]]

输入和标签:

text
x = [1.0, 0.5]
y = 0

之所以选这么小的网络,是因为每个梯度都能用笔算复现,而且 2 分类的输出层只有两个节点,softmax 和交叉熵可以直接写成算式。真实网络有 109,386 个参数,但反向传播的规则一模一样,只是矩阵大了。

二、前向:把反向要用的值缓存下来 ​

![前向的五步与缓存](/ai-four-kings/第 2 章:反向传播/assets/ch2_case_forward.svg)

<!-- 配图:x→z₁/a₁→z₂/a₂→logits/p→L 五个框,右侧缓存形状面板 -->

第一层:

text
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]

第二层:

text
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]

输出层:

text
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:

text
e⁰·¹⁰⁷⁵ = 1.11351
e⁻⁰·⁰⁶⁵ = 0.93707
分母 = 2.05058

p = [1.11351 / 2.05058, 0.93707 / 2.05058]
  = [0.54301838, 0.45698162]

交叉熵:

text
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 ​

![输出层梯度](/ai-four-kings/第 2 章:反向传播/assets/ch2_case_output_grad.svg)

<!-- 配图:类别/p/onehot/p−onehot 四列表格;L=0.610612;推导面板 -->

softmax 加交叉熵的梯度有一个非常简洁的结果:

text
∂L/∂zᵢ = pᵢ − 1{i = y}

对 y = 0:

text
dz₃ = [0.54301838 − 1, 0.45698162 − 0]
    = [−0.45698162, 0.45698162]

为什么这么简单?因为 softmax 的雅可比是:

text
∂pᵢ/∂zⱼ = pᵢ(1{i=j} − pⱼ)

交叉熵对概率的导数是:

text
∂L/∂pᵢ = −1{i=y} / pᵢ

两者相乘,pᵢ 和 1/pᵢ 抵消,剩下 pᵢ − 1{i=y}。框架把这两步合并成一个 CrossEntropyLoss,所以反向时看不到 softmax 的雅可比矩阵,只看到一次减法。

两个可以当场验证的性质:

text
Σ pᵢ = 1.0
Σ dz₃ = −0.45698162 + 0.45698162 = 0

第二行不是巧合:输出层梯度沿概率单纯形的方向没有自由度,所有分量加起来必须是 0。

权重梯度:

text
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 的概率要往下压,所以梯度是正的。

四、隐藏层:把梯度传回去 ​

![隐藏层梯度递推](/ai-four-kings/第 2 章:反向传播/assets/ch2_case_hidden_backward.svg)

<!-- 配图:两段递推 dz₃→da₂→dz₂ 与 dz₂→da₁→dz₁,标出 W₃、W₂ 的手算 -->

先算第二层隐藏层收到的梯度:

text
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₃ 本身,不是它的转置。形状对一下:

text
dz₃: (1, 2)      W₃: (2, 2)      da₂: (1, 2)

(1,2) @ (2,2) 得到 (1,2),正好是 a₂ 的形状。

再穿过 ReLU:

text
dz₂ = da₂ ⊙ ReLU'(z₂)
    = [−0.22849081, 0.13709449] ⊙ [1, 1]
    = [−0.22849081, 0.13709449]

z₂ = [0.175, 0.050],两个都大于 0,所以 ReLU 的导数都是 1,梯度原样通过。如果某个 z₂ 是负的,对应位置就会被乘成 0,这条路径上的梯度就断了。

第二层的权重梯度:

text
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]

继续往第一层传:

text
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:

text
dz₁ = da₁ ⊙ ReLU'(z₁)
    = [0.00456982, −0.05940761, 0.06397743] ⊙ [1, 1, 1]
    = [0.00456982, −0.05940761, 0.06397743]

第一层的权重梯度:

text
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₁

到这里所有梯度都出来了。回看整个过程,每一层只做了三件事:

text
dW = dzᵀ a_prev
db = sum(dz)
da_prev = dz W
dz_prev = da_prev ⊙ activation'(z_prev)

这四行就是反向传播的全部代码。

五、权重梯度:三条能看出来的规律 ​

![三个权重梯度矩阵](/ai-four-kings/第 2 章:反向传播/assets/ch2_case_weight_grad.svg)

<!-- 配图:dW₃、dW₂、dW₁ 三个矩阵与三条规律面板 -->

把三个矩阵放在一起看,有三条规律。

规律一:dW 的每一行正比于输入激活。 dW₁ 的两列之比是 1 : 0.5,正好是输入 x 的两维之比:

text
dW₁ 第 1 列 / 第 2 列 = 2.0
x  第 1 维 / 第 2 维 = 2.0

dW₂ 的三列之比是 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,再加一个样本:

text
x₂ = [−0.5, 1.0]
y₂ = 1

前向结果:

text
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,预测得并不好,所以损失更大:

text
L₂ = −log(0.48150844) = 0.7308315
L  = (0.6106121 + 0.7308315) / 2 = 0.6707218

![batch=2 的梯度](/ai-four-kings/第 2 章:反向传播/assets/ch2_case_batch2.svg)

<!-- 配图:两条样本的 dz₃、db₃ 与平均损失 0.6707,右侧 batch=1 对比面板 -->

batch 的反向,就是每个样本各自算一遍,再取平均。等价写法是在 dz₃ 上先除以 batch:

text
dz₃ = (p − onehot) / batch

两条样本的 dz₃:

text
样本 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₃ 是两行相加:

text
db₃ = [−0.22849081 + 0.25924578, 0.22849081 − 0.25924578]
    = [0.03075497, −0.03075497]

和单样本的 db₃ = [−0.45698162, 0.45698162] 相比,量级小了十几倍。这不是因为梯度变弱了,是因为两条样本的梯度方向部分抵消,再除以 2。

dW₃ 也变成两条样本的平均:

text
dW₃ = [[−0.03091229, 0.02616610],
       [ 0.03091229, −0.02616610]]

逐项验证第一行第一列:

text
样本 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、数值差分 ​

![三重校验](/ai-four-kings/第 2 章:反向传播/assets/ch2_case_check.svg)

<!-- 配图:六个梯度张量的三重校验表,autograd 差全为 0,数值差分最差 3.76e−11 -->

三套结果放在一起:

梯度与 autograd 最大绝对差与中心差分最大相对误差
W₁0.01.300e−10
b₁0.01.300e−10
W₂0.03.482e−12
b₂0.01.089e−11
W₃0.03.605e−12
b₃0.03.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 对齐:两条路径,同一个结果 ​

![手写与 autograd 两条路径](/ai-four-kings/第 2 章:反向传播/assets/ch2_case_autograd.svg)

<!-- 配图:手写 NumPy 与 PyTorch autograd 两条路径,比较条 max|ΔW|=0.0、loss 差 1.8e−12 -->

PyTorch 侧只做四件事:

python
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:

python
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)

对照结果:

text
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.0

loss 差 1.8e−12 来自 softmax 的 exp 和 log 实现差异:NumPy 和 PyTorch 的数学库不是同一份代码,最后几位会有分歧。但梯度的差是 0,说明反向传播的路径完全一致。

这个案例说明了一件事:autograd 不神秘。它做的就是上面这十二行,只是把形状、转置、链式调用自动化了。下一篇会把这十二行扩展到 109,386 个参数,并和 PyTorch 做逐轮对照。

九、这一篇没解决的问题 ​

14 个参数可以手算,109,386 个参数不行。真实网络里还有四个新问题:

  1. 形状怎么系统地对?(64,128) 的 dz 和 (128,784) 的 W 怎么乘?
  2. 手写的 backward 怎么组织成循环,而不是十二行硬编码?
  3. 怎么确认手写实现没有在某个形状上悄悄广播错?
  4. 训到 97.5% 需要多少轮、多少时间,和 PyTorch 差多少?

实战篇会把这些问题一个个落地。这一篇只需要记住那四行递推公式——它们是后面所有代码的骨架。

自检 ​

合上笔记,试着回答:

  • 这个网络有几个参数?每层的 W、b 形状是什么?
  • 前向需要缓存哪些值?每个值在反向时给谁用?
  • 为什么 dz₃ = p − onehot?Σdz₃ 为什么必须是 0?
  • da₂ = dz₃W₃ 为什么不用 W₃ᵀ?形状怎么对上?
  • dW₁ 的两列之比为什么等于输入的两维之比?
  • batch=2 时,db₃ 为什么比单样本小?/batch 的作用是什么?
  • 手写梯度和 autograd 的最大绝对差是多少?这个 0 说明什么?

答不上来的那一条,回到对应小节,把矩阵乘的形状和数字重新算一遍。下一篇开始写真正的反向传播代码。