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 逐级向左求导:

LLyLa2Lz1La1LW1

6.3 符号约定

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

变量含义梯度简写数学表达矩阵形状 (Shape)
第二层激活输出 (预测概率)dyoutLy(n, 10)
第二层线性输出 (Logits)da2La2(n, 10)
第一层激活输出dz1Lz1(n, 50)
第一层线性输出da1La1(n, 50)
第二层权重参数dW2LW2(50, 10)
第二层偏置参数db2Lb2(10,)
第一层权重参数dW1LW1(784, 50)
第一层偏置参数db1Lb1(50,)

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


6.4 第一步:损失对激活输出求导 (Ly)

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

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

=j=0C1tjlog(yj)

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

=(0log(y0)++1log(yt)++0log(y9))

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

=log(yt)

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

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

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

    yj=0

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

6.5 第二步:激活输出对线性输出求导 (ya2)

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

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

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

为什么分两种情况?

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

ysas=(eas)Seas(S)S2=easSeaseasS2

将其拆分并化简:

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

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

yjas=(eaj)Seaj(S)S2=0SeajeasS2

将其拆分并化简:

=(eajS)(easS)=yjys

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


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

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

a2=yJ

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

as=j=09yjyjas

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

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

as=ytytas=(1yt)ytas

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

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

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

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

da2=1n(yT)

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


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

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

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

6.8 第四步:梯度穿透隐藏层激活函数 (z1a1)

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

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

da1=dz1(z1(1z1))

6.9 第五步:第一层参数的梯度 (a1W1,b1)

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

权重梯度:

dW1=XTda1

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

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


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

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