深度学习基础:反向传播推导

反向传播的公式看起来复杂,但拆开只有一个东西:链式法则。这里用一个最小的三层网络,把每一步都完整算一遍。

前五节是 MSE + Sigmoid 的完整推导,第六节再把实际中最常用的四种组合(Softmax + 交叉熵、Sigmoid + BCE、ReLU + 线性输出、Batch 形式)各推一遍。

一、网络结构

  • 输入维度 2,隐藏层 2 个神经元(Sigmoid),输出层 1 个神经元(Sigmoid)
  • 损失函数用均方误差(MSE)

符号定义:

  • 输入
  • 隐藏层权重 ,偏置
  • 输出层权重 ,偏置
  • 激活函数 ,其导数
  • 真实标签

前向传播:

损失(前面乘 是为了求导时把平方的 2 消掉):

要算的是 、、、。

二、核心中间量:误差项 δ

按定义 ,即损失对本层「加权和」的梯度。

之所以拿 而不是 当锚点,是因为参数 、 直接作用在 上:

一旦 已知,这一层的两个参数梯度都是它的直接产物,形状也自动对上。剩下的工作就只是把 一层层往前递推。

三、逐层反向推导

3.1 输出层

先对 求导:

再乘 Sigmoid 导数,得到输出层误差项:

于是输出层参数梯度:

直觉:权重梯度 = 本层误差项 × 本层输入(即上一层的激活值)。某个输入越大,对应的权重就要为这份误差负更多责任。

3.2 隐藏层

误差从输出层回传到隐藏层。因为 ,对 求导时权重矩阵要转置:

再逐元素乘上 Sigmoid 导数,得到隐藏层误差项:

其中 表示逐元素乘。由于 ,可以完全用前向的激活值表示,不必把 留着:

隐藏层参数梯度:

注意这里用的是 而不是 ——输入层没有激活函数,。

四、一般化到第 l 层

设第 层有:

反向传播就是两个公式:

误差项递推:

参数梯度:

输出层的起点(MSE + Sigmoid):

换损失函数和激活函数,改的只有两处:输出层 的起点、以及各层 的具体形式。递推结构一个字都不用动。

维度校验

手工推导最容易在转置上出错,用形状表核对一遍:

量 形状 说明
的形状由它决定
与隐藏层宽度一致
(标量) 单输出
✓
✓

规则只有一条:梯度的形状必须与参数本身完全一致。写成 (而不是 )在形状上就会对不上,这一条能挡掉大部分笔误。

五、一个具体数值例子

取 ,,初始权重:

5.1 前向

5.2 反向

回传到隐藏层:

5.3 参数更新

学习率 ,按 更新:

这样一次完整的「前向—反向—更新」就手工算完了。

5.4 用 PyTorch 验算

手算结果对不对,让 autograd 说一遍最省事:

import torch

x = torch.tensor([1.0, 0.5])
y = torch.tensor(1.0)

W1 = torch.tensor([[0.1, 0.2],
                   [0.3, 0.4]], requires_grad=True)
b1 = torch.tensor([0.1, 0.2], requires_grad=True)
W2 = torch.tensor([[0.5, 0.6]], requires_grad=True)
b2 = torch.tensor([0.1], requires_grad=True)

a1 = torch.sigmoid(W1 @ x + b1)
y_hat = torch.sigmoid(W2 @ a1 + b2)
loss = 0.5 * (y_hat - y) ** 2
loss.backward()

print(y_hat.item())   # 0.6874(手算 0.6876,差在中间步骤的四舍五入)
print(W2.grad)        # tensor([[-0.0385, -0.0448]])
print(b2.grad)        # tensor([-0.0671])
print(W1.grad)        # 与手算矩阵一致

两边的数字对齐,说明推导里的每个转置都放对了位置。

六、常见组合的推导

MSE + Sigmoid 只是众多搭配之一,而且在实际分类任务里几乎没人用。下面把四种最常用的组合各推一遍,重点看输出层——因为第四章已经说明,递推结构对任何组合都成立,唯一的变量是 的起点和各层导数。

6.1 案例一:Softmax + 交叉熵(多分类,最常用)

设定。 输出层有 个类别分数 ,Softmax 把它们变成概率分布,交叉熵衡量它与 one-hot 标签的差距:

要算的仍然是 。这里不能像 Sigmoid 那样直接乘导数——Softmax 的每个输出都依赖所有的 ,必须走完整的链式法则求和:

两个偏导分别算:

Softmax 对自身的偏导分两种情况( 时分子里有 , 时没有):

代回去:

最后一步用了 one-hot 标签的性质 。于是:

对比第五章 MSE + Sigmoid 的 ,多出来的 在这里被整个约掉了。这不是巧合:交叉熵的定义里带着 ,对 Softmax 求导时分母的 正好把这一项消干净。框架里把 Softmax 和交叉熵打包成一个算子(CrossEntropyLoss),就是为了走这条化简路径,同时用 LogSumExp 技巧避免 上溢和 。

数值例。 取 ,,标签 :

三个分量之和为 0——这是 Softmax 的性质决定的(概率和为 1,梯度必然正交于全 1 方向),也可以当成一个自检。

为什么不用 MSE。 若把损失换成 MSE, 里的 会保留下来。设想 而正确类别是第 2 个:预测错得极其离谱,但那两个极端分量上的 已经接近 0,最该修正的时刻梯度反而最小。交叉熵没有这个因子, 直接等于 ,误差多大推力就多大。

6.2 案例二:Sigmoid + 二分类交叉熵(BCE)

设定。 输出单个概率 ,损失:

先对 求导:

分母正好是 Sigmoid 的导数 ,两者相乘直接约掉:

和案例一完全同形。

数值对比。 仍用第五章的 、:

损失 相对大小
MSE 1×
BCE 4.7×

越接近错误端点,差距越夸张。设 、:

损失
MSE
BCE

差了 21 倍。MSE 在这个点上几乎推不动参数,而 BCE 的梯度只由误差本身决定,不受激活函数饱和的影响——这就是二分类默认用 BCE 的原因。

6.3 案例三:ReLU 隐藏层 + 线性输出(回归)

设定。 隐藏层换成 ReLU,输出层不加激活(回归任务不该把输出压进 ),损失用 MSE:

输出层。 没有激活函数,就少一层链式:

(若坚持用 MSE + Sigmoid,则要乘 ,回到第五章的形式。)

隐藏层。 递推公式不变,只把导数换成 0/1 门:

数值例。 权重沿用第五章,但把隐藏层偏置改成 :

反向:

更新后():

看第一行:全是 0。 这个神经元的 落在负半轴,梯度被门挡掉了,本次更新对它毫无影响。如果它长期收不到正输入,就会一直保持「学不到 → 输出恒 0 → 更学不到」的状态,这就是 dying ReLU。Leaky ReLU、PReLU 给负半轴留一个小斜率,正是为了让这种神经元还有机会爬回来。

另外注意与第五章的对比:同样的 、,只把 换掉,ReLU 版本的 量级是 ,Sigmoid 版本是 ——ReLU 的导数非 0 即 1,不会像 Sigmoid 那样每层再乘一次小于 1 的因子。

6.4 案例四:Batch 形式(一次算 B 个样本)

前面都是单样本。实际训练一次喂一个批次,把所有公式的向量换成矩阵:

这里 的形状是 ,靠广播加到每一列上。

损失取批内平均,注意那个 :

这个系数会平移到 上,之后所有梯度都带着它:

两处容易出错:

  1. 偏置要沿 batch 维求和,即乘以全 1 向量 。单样本时 ,这步退化成「梯度就是 」——第五章的写法在 batch 下会得到一个 的错误形状。
  2. 只出现一次。放在 的起点最省事,后面全部继承;如果每层都乘一遍,梯度会被缩小 倍。

为什么框架里看不到 for 循环。 因为上面每个式子都是矩阵乘: 个样本的梯度被合并成了少数几次 GEMM, 越大 GPU 越吃满。这也解释了为什么 batch size 会影响训练——它同时改变梯度噪声(平均得更平滑)和单步的计算效率。

用 PyTorch 核对一遍 batch 的形状和 的位置:

import torch

B, n_in, n_hid = 4, 2, 2
X = torch.randn(n_in, B)
Y = torch.rand(1, B)

W1 = torch.randn(n_hid, n_in, requires_grad=True)
b1 = torch.randn(n_hid, 1, requires_grad=True)
W2 = torch.randn(1, n_hid, requires_grad=True)
b2 = torch.randn(1, 1, requires_grad=True)

A1 = torch.sigmoid(W1 @ X + b1)          # [2, 4]
Y_hat = torch.sigmoid(W2 @ A1 + b2)      # [1, 4]
loss = (0.5 * (Y_hat - Y) ** 2).mean()   # mean() 就是那个 1/B

loss.backward()
print(W2.grad.shape, W1.grad.shape)      # torch.Size([1, 2]) torch.Size([2, 2])

6.5 四种组合横向对比

任务 输出层激活 损失 备注
二分类 Sigmoid BCE 默认选择
多分类 Softmax 交叉熵 (向量) 框架打包成一个算子
回归 无 MSE 不要加 Sigmoid
二分类 Sigmoid MSE 不推荐,饱和时梯度消失

表格里前三行的 完全一样,这不是巧合:只要损失函数取「与激活函数相配的负对数似然」(统计上叫指数族对应的典则链接),输出层的 就会把激活导数整个约掉。这条规律比单记公式有用——看到一个新组合,先看它是不是「配对」的;配对了就是 ,没配对就一定留着一个衰减因子。

七、要点总结

  1. 反向传播本质是链式法则的系统化应用,从损失对输出的梯度开始,逐层向前回传。没有任何「神经网络专属」的数学,只是把链式法则按层的结构组织了一遍。
  2. 误差项 是核心中间量:它等于「后一层误差经权重转置回传」再「逐元素乘本层激活导数」。整个算法可以压缩成一次前向存下所有 ,再一次反向递推。
  3. 参数梯度形式统一:权重梯度 = 本层误差项 × 本层输入;偏置梯度 = 本层误差项。所有层用同一个模板,这也是深度学习框架能写得这么短的原因。
  4. 换损失/激活只改两处:输出层 的起点、各层 的形式,递推结构不变。第六章的四个案例逐个验证了这句话——从 Softmax 到 Batch, 的递推公式一次都没改过。
  5. 梯度形状必须等于参数形状,这是手工推导时最有用的自检手段。
  6. 数值上要注意 Sigmoid 饱和:第五章的例子中 已经掉到 以下,只回传一层就衰减了一个数量级。层数一多,梯度乘到输入端基本归零——这就是梯度消失。实际中常用 ReLU(6.3)、交叉熵损失(6.1、6.2)、BatchNorm 或残差连接缓解;RNN 沿时间回传时这个问题被放大得最厉害,LSTM 的门控和 Transformer 的残差都是冲着它去的。

附:继续往下走

上面覆盖的都是全连接层。再往深处走,值得单独推一遍的有三处,难点各不相同:

  • BPTT(沿时间反向传播): 除了经权重回传,还要沿时间步回传,同一套 的梯度是所有时刻梯度之和——「参数共享 → 梯度累加」的规律在这里第一次出现。参见 深度学习基础结构--RNN结构.md。
  • 卷积层:前向是滑动窗口相乘,反向要把 用转置的卷积核(严格说是相关运算)铺回去,权重梯度则是输入与 的互相关。形状校验在这里尤其重要。
  • LayerNorm / Softmax 的反向:这两处都是「一个输出依赖整条向量的所有元素」,链式法则要像 6.1 那样求和,不能像逐元素激活那样直接乘导数。Transformer 的反向传播里最容易算错的就是 LayerNorm。