Skip to content

第 6 章:反向传播 ​

💡 导读: 这是整本小册子最核心的章节。反向传播是神经网络训练的引擎,我们将从最末端的损失函数出发,一步步反向推导,带你领略数学公式中奇妙的“抵消与坍缩”之美。

6.1 问题:参数该如何更新?(量化推导) ​

训练的终极目标是让损失函数 L 的值尽可能小。我们目前有 39,760 个参数,每次前向传播计算出损失后,我们需要弄清楚:该把每个参数调大一点,还是调小一点?

这里我们需要用微积分中的一阶泰勒展开式来进行量化。假设我们给参数 θ 施加一个极小的变化量 Δθ,那么新的损失可以近似表示为:

L(θ+Δθ)≈L(θ)+Δθ⋅∂L∂θ

我们的目标是让新的损失变小,即 L(θ+Δθ)<L(θ)。这就要求公式右边的尾巴必须是负数:

Δθ⋅∂L∂θ<0

这就得出了参数更新的铁律:变化量 Δθ 的符号,必须和梯度 ∂L∂θ 的符号相反!

  • 如果梯度 > 0:为了让乘积小于 0,Δθ 必须为负(即减小参数)。
  • 如果梯度 < 0:为了让乘积小于 0,Δθ 必须为正(即增大参数)。

反向传播算法,就是用来极其高效地计算出所有参数对应的梯度 ∂L∂θ。


6.2 计算图与链式法则 ​

我们的神经网络前向传播是一条严密的流水线(从左到右):

输入 X→ [线性输出 a1] → [激活输出 z1] → [线性输出 a2] → [激活输出 y] → [计算损失 L]

反向传播则是时光倒流,利用链式法则,从最右边的 L 逐级向左求导:

L→∂L∂y→∂L∂a2→∂L∂z1→∂L∂a1→∂L∂W1

6.3 符号约定 ​

为了让接下来的推导清晰无负担,我们约定以下符号。黄金规律:任何一层变量的梯度矩阵,其形状(Shape)必定与原变量完全一致。

变量含义梯度简写数学表达矩阵形状 (Shape)
第二层激活输出 (预测概率)dyout∂L∂y(n, 10)
第二层线性输出 (Logits)da2∂L∂a2(n, 10)
第一层激活输出dz1∂L∂z1(n, 50)
第一层线性输出da1∂L∂a1(n, 50)
第二层权重参数dW2∂L∂W2(50, 10)
第二层偏置参数db2∂L∂b2(10,)
第一层权重参数dW1∂L∂W1(784, 50)
第一层偏置参数db1∂L∂b1(50,)

(注:n 为批量样本大小,推导时我们先按单个样本推导,最后求平均)


6.4 第一步:损失对激活输出求导 (L→y) ​

我们要弄清楚:交叉熵的导数 ∂ℓ∂yt=−1yt 到底是怎么来的?

首先,我们给出多分类交叉熵损失函数(Cross-Entropy Loss)的通用标准公式。 对于单个样本,假设网络总共有 C 个类别(在我们的数字识别里 C=10)。yj 是网络预测的第 j 个类别的概率,tj 是该类别的真实标签。公式为:

ℓ=−∑j=0C−1tjlog⁡(yj)

化简过程(One-Hot 编码的魔法): 这里的真实标签 t 是一个 One-Hot 向量。这意味着,除了正确的那个类别(假设索引为 t)对应的值 tt=1 之外,其他所有错误类别对应的值全是 0。 当你把这个 One-Hot 向量代入求和公式时,所有等于 0 的项全部灰飞烟灭了:

ℓ=−(0⋅log⁡(y0)+⋯+1⋅log⁡(yt)+⋯+0⋅log⁡(y9))

所以,标准公式直接坍缩成了我们在书里常用的极简形式:

ℓ=−log⁡(yt)

求导过程: 现在,我们要看这个损失 ℓ 是如何随着网络的预测概率 y 而变化的。 微积分里有一个基础公式:ddxlog⁡(x)=1x。

  1. 当求导目标是正确类别的概率 yt 时: 公式 ℓ=−log⁡(yt) 里刚好有 yt,直接套用对数求导公式,保留外面的负号:

    ∂ℓ∂yt=−1yt
  2. 当求导目标是其他错误类别的概率 yj 时 (j≠t): 公式 ℓ=−log⁡(yt) 里根本没有 yj 这个变量!对于 yj 来说,整项 −log⁡(yt) 就像一个常数。常数求导等于 0:

    ∂ℓ∂yj=0

这就是第一步梯度的完整由来,没有任何跳跃。这构成了我们向后传递的第一级梯度向量 ∂ℓ∂y,为后面与 Softmax 的雅可比矩阵相乘做好了准备。

6.5 第二步:激活输出对线性输出求导 (y→a2) ​

接下来,梯度要穿过 Softmax 函数,追溯到第二层的线性输出 a2。 Softmax 的公式为:yj=eaj∑keak。

在这里,输入是一个长度为 10 的向量 a2,输出也是一个长度为 10 的向量 y。在微积分中,向量对向量求导,会得到一个 10×10 的雅可比矩阵(Jacobian Matrix) J。矩阵中的每一个元素就是 ∂yj∂as。

为了求这个导数,我们使用商的求导法则:(uv)′=u′v−uv′v2。 我们令分母 S=∑keak。无论对哪个 as 求导,分母 S 的导数始终是 eas。

为什么分两种情况?

情况 A:当 j=s 时(对角线元素) 此时分子 u=eas,分子求导 u′=eas。

∂ys∂as=(eas)′⋅S−eas⋅(S)′S2=eas⋅S−eas⋅easS2

将其拆分并化简:

=easS−(easS)2=ys−(ys)2=ys(1−ys)

情况 B:当 j≠s 时(非对角线元素) 此时分子 u=eaj,因为它不包含 as,所以分子求导 u′=0。

∂yj∂as=(eaj)′⋅S−eaj⋅(S)′S2=0⋅S−eaj⋅easS2

将其拆分并化简:

=−(eajS)⋅(easS)=−yjys

至此,我们得到了完整的雅可比矩阵 J。


6.6 见证奇迹:损失对线性输出的联合求导 (L→a2) ​

现在,我们要将第一步和第二步结合。根据多元微积分的链式法则,损失 ℓ 对线性输出向量 a2 的梯度,等于损失对概率向量的梯度 乘以 雅可比矩阵:

∂ℓ∂a2=∂ℓ∂y⋅J

展开为代数求和的形式,我们要计算 ℓ 对某一个具体线性输出 as 的导数:

∂ℓ∂as=∑j=09∂ℓ∂yj⋅∂yj∂as

魔法时刻 1:雅可比矩阵的坍缩 回想 6.4 节,除了 j=t 的位置,∂ℓ∂yj 全都是 0!

这意味着,长达 10 项的求和公式直接坍缩,只剩下唯一的一项:

∂ℓ∂as=∂ℓ∂yt⋅∂yt∂as=(−1yt)⋅∂yt∂as

魔法时刻 2:复杂分式的完美抵消 现在,把 6.5 节求得的雅可比矩阵元素代入进来:

  • 如果 s=t(即求导位置恰好是正确类别的打分): 代入情况 A 的结果:∂ℓ∂at=(−1yt)⋅[yt(1−yt)]=−(1−yt)=yt−1
  • 如果 s≠t(即求导位置是错误类别的打分): 代入情况 B 的结果:∂ℓ∂as=(−1yt)⋅[−ytys]=ys=ys−0

极简结论: 无论是哪种情况,最终结果都可以统一为一句极度优美的话:损失对该层线性输出的梯度,就等于网络的预测概率 减去 该类别的真实标签(1 或 0)。

若推广到 n 个样本求平均,用矩阵形式表达就是:

da2=1n(y−T)

(其中 T 是正确位置为 1、其余为 0 的 One-Hot 标签矩阵)


6.7 第三步:线性输出对参数及前一层求导 (a2→W2,b2,z1) ​

源头梯度 da2 拿到了,接下来的推导就是纯粹的矩阵乘法了。 第二层前向传播公式:a2=z1⋅W2+b2

  1. 对权重 W2 求导: 根据矩阵微积分法则,梯度等于输入端 z1 的转置 乘以 输出端梯度 da2:dW2=z1T⋅da2
  2. 对偏置 b2 求导: 因为偏置是对所有 n 个样本进行“广播”相加的,反向传播时需要把这 n 个样本的梯度按列累加:db2=∑i=1nda2(i)
  3. 向上一层回传梯度: 为了让网络继续反向传播,需要求出对隐藏层输出 z1 的梯度:dz1=da2⋅W2T

6.8 第四步:梯度穿透隐藏层激活函数 (z1→a1) ​

梯度来到了第一层的 Sigmoid 激活函数:z1=σ(a1)。

Sigmoid 的导数公式为:σ′(x)=σ(x)(1−σ(x))。 由于激活函数是逐元素独立运算的(没有雅可比矩阵复杂的交叉项),我们可以直接将传回来的梯度 dz1 与局部导数进行逐元素相乘(记为 ⊙):

da1=dz1⊙(z1⊙(1−z1))

6.9 第五步:第一层参数的梯度 (a1→W1,b1) ​

这就完全是 6.7 节的重演了。 第一层前向公式:a1=X⋅W1+b1

权重梯度:

dW1=XT⋅da1

偏置梯度(对 n 个样本求和):

db1=∑i=1nda1(i)

6.10 反向传播:极其优雅的代码实现 ​

将上述纯数学公式翻译成代码,利用 NumPy 的高级索引,我们连构造 One-Hot 矩阵的内存都省了:

python
def _backprop_gradient(self, X, t):
    n = X.shape[0]
    W1, b1 = self.params['W1'], self.params['b1']
    W2, b2 = self.params['W2'], self.params['b2']

    # ================= 前向传播 =================
    a1 = X @ W1 + b1               
    z1 = sigmoid(a1)               
    a2 = z1 @ W2 + b2              
    y  = softmax(a2)               

    # ================= 反向传播 =================
    # 6.4 - 6.6: Softmax 与交叉熵联合梯度 (抵消坍缩后的极简实现)
    da2 = y.copy()
    da2[np.arange(n), t] -= 1       # 精准在正确类别的位置减 1
    da2 /= n

    # 6.7: 第二层参数与回传梯度
    dW2 = z1.T @ da2                
    db2 = np.sum(da2, axis=0)       
    dz1 = da2 @ W2.T                

    # 6.8: 穿透 Sigmoid 激活函数
    da1 = dz1 * (z1 * (1 - z1))     # '*' 代表逐元素相乘

    # 6.9: 第一层参数梯度
    dW1 = X.T @ da1                
    db1 = np.sum(da1, axis=0)      

    return {'W1': dW1, 'b1': db1, 'W2': dW2, 'b2': db2}

6.11 为什么不用数值梯度? ​

你可能会问:既然可以通过微小的变化 h 来近似计算导数(数值梯度),为什么还要费劲推导反向传播?

∂L∂θi≈L(θi+h)−L(θi−h)2h
  • 数值梯度: 要更新 39,760 个参数,你需要做近 80,000 次前向传播。慢如蜗牛,但代码好写,通常只用来做测试,验证反向传播代码是否写错了。
  • 反向传播: 只需要 1 次前向传播 + 1 次反向计算。速度快约 1000 倍。这是工业界实际训练的唯一方案。

下一章预告:现在我们拿到了所有参数的“指导意见”(梯度),接下来该怎么走?SGD、Momentum、Adam 等各种高级“走法”即将登场。


← 第 5 章 | 返回目录 | 第 7 章:优化器 →

基于 Kaggle MNIST 数据集,使用纯 numpy 从零实现