在上一篇文章中,我们构建了一个神经网络,了解了前向传播是如何产生预测的,也知道了损失函数可以衡量预测有多差。但我们遗留了最核心的问题:那13,002个参数到底该怎么调整,才能让网络变得更好?

这就是反向传播算法(Backpropagation)要回答的问题。它是神经网络学习的核心引擎,几乎所有现代深度学习的训练都建立在它之上。

一、梯度下降:从直觉开始

1.1 一个简单的类比

想象你站在一片浓雾中的山地上,看不到全貌,但你能感受到脚下地面的倾斜方向。你的目标是找到最低的山谷。最自然的策略是什么?

顺着脚下最陡的下坡方向走一小步。

这就是**梯度下降(Gradient Descent)**的核心思想。

1.2 数学表述

假设损失函数 $L$ 取决于所有参数 $\theta = (w_1, w_2, ..., w_n, b_1, b_2, ..., b_m)$。

梯度是一个向量,指向损失增长最快的方向:

$$\nabla L = \left(\frac{\partial L}{\partial w_1}, \frac{\partial L}{\partial w_2}, \cdots, \frac{\partial L}{\partial b_m}\right)$$

梯度的每个分量告诉我们:如果微微增大某个参数,损失会增加多少。

要降低损失,我们朝梯度的反方向迈步:

$$\theta_{\text{new}} = \theta_{\text{old}} - \eta \cdot \nabla L$$

其中 $\eta$ 是学习率(learning rate),控制每一步的步长。

1.3 学习率的微妙之处

学习率下降行为结果
太小每步挪动极小,到达局部最小值后就停住了,无法跳出收敛极慢,可能困在局部最小值
太大一步跨过了最低点,又跨回来,反复来回在最低点附近震荡,甚至发散
合适每步大小适中,沿着曲面稳步走向谷底稳步下降,逐渐逼近最优解

1.4 梯度下降的关键问题

现在整个问题归结为一件事:怎么高效地计算 $\nabla L$,即损失对每一个参数的偏导数?

你可能会想:对每个参数微微扰动一下,看看损失变化了多少。但如果有13,002个参数,每个参数都扰动一次,就需要做13,002次前向传播——太慢了。

反向传播的天才之处在于:只需要一次前向传播和一次反向传播,就能算出所有参数的梯度。

二、从链式法则开始

2.1 复合函数的导数

反向传播的数学基础是微积分中的链式法则(Chain Rule)

如果 $y = f(g(x))$,那么:

$$\frac{dy}{dx} = \frac{dy}{dg} \cdot \frac{dg}{dx}$$

直觉理解:$x$ 的微小变化首先影响 $g$,然后 $g$ 的变化影响 $y$。总效应是两个效应的乘积。

让我们用一个具体的数值例子感受一下:

$$y = (3x + 2)^2$$

设 $g = 3x + 2$,$y = g^2$。

当 $x = 1$ 时:

  • $g = 5$,$y = 25$
  • $\frac{dg}{dx} = 3$:$x$ 增加一点点,$g$ 增加3倍
  • $\frac{dy}{dg} = 2g = 10$:$g$ 增加一点点,$y$ 增加10倍
  • $\frac{dy}{dx} = 10 \times 3 = 30$:$x$ 增加一点点,$y$ 增加30倍

2.2 多变量链式法则

在神经网络中,一个变量通常通过多条路径影响最终的损失。此时需要对每条路径的贡献求和:

如果 $L$ 通过 $z_1, z_2, ..., z_k$ 间接依赖于 $w$,那么:

$$\frac{\partial L}{\partial w} = \sum_{i=1}^{k} \frac{\partial L}{\partial z_i} \cdot \frac{\partial z_i}{\partial w}$$

三、反向传播的推导

让我们用一个足够简单但能说明一切的例子来推导反向传播。

3.1 一个最小的网络

考虑一个只有单条路径的简化网络:

graph LR
    A["a⁰
输入"] -->|"w¹"| B["z¹ = w¹a⁰ + b¹"] B --> C["a¹ = σ(z¹)"] C -->|"w²"| D["z² = w²a¹ + b²"] D --> E["a² = σ(z²)"] E --> F["L = (a² - y)²"]

这里我们用最简单的均方误差作为损失函数:$L = (a^{(2)} - y)^2$,其中 $y$ 是真实标签。

我们的目标是求 $\frac{\partial L}{\partial w^{(1)}}$——损失对第一层权重的敏感度。

3.2 逐步展开链式法则

从损失 $L$ 到权重 $w^{(1)}$,中间经过了这些环节:

$$L \leftarrow a^{(2)} \leftarrow z^{(2)} \leftarrow a^{(1)} \leftarrow z^{(1)} \leftarrow w^{(1)}$$

根据链式法则:

$$\frac{\partial L}{\partial w^{(1)}} = \frac{\partial L}{\partial a^{(2)}} \cdot \frac{\partial a^{(2)}}{\partial z^{(2)}} \cdot \frac{\partial z^{(2)}}{\partial a^{(1)}} \cdot \frac{\partial a^{(1)}}{\partial z^{(1)}} \cdot \frac{\partial z^{(1)}}{\partial w^{(1)}}$$

让我们逐一计算每个偏导数:

第一步:损失对输出激活值的导数

$$\frac{\partial L}{\partial a^{(2)}} = 2(a^{(2)} - y)$$

如果预测接近真实值,这个值很小(网络已经做得不错了);如果偏差大,这个值很大(需要大幅调整)。

第二步:激活值对加权和的导数

$$\frac{\partial a^{(2)}}{\partial z^{(2)}} = \sigma'(z^{(2)})$$

这就是Sigmoid函数的导数。对于Sigmoid:$\sigma'(z) = \sigma(z)(1 - \sigma(z))$。

这里有一个重要的隐患:当 $z$ 的绝对值很大时,$\sigma'(z)$ 接近0——这就是梯度消失问题的根源,后面会详细讨论。

第三步:加权和对上一层激活值的导数

$$\frac{\partial z^{(2)}}{\partial a^{(1)}} = w^{(2)}$$

这很直观:$z^{(2)} = w^{(2)} a^{(1)} + b^{(2)}$,所以对 $a^{(1)}$ 的偏导就是 $w^{(2)}$。

第四步:上一层激活值对其加权和的导数

$$\frac{\partial a^{(1)}}{\partial z^{(1)}} = \sigma'(z^{(1)})$$

第五步:加权和对权重的导数

$$\frac{\partial z^{(1)}}{\partial w^{(1)}} = a^{(0)}$$

因为 $z^{(1)} = w^{(1)} a^{(0)} + b^{(1)}$。

3.3 完整结果

把所有项乘起来:

$$\frac{\partial L}{\partial w^{(1)}} = 2(a^{(2)} - y) \cdot \sigma'(z^{(2)}) \cdot w^{(2)} \cdot \sigma'(z^{(1)}) \cdot a^{(0)}$$

注意这个表达式从右到左的阅读顺序:

$$a^{(0)} \xrightarrow{\sigma'(z^{(1)})} \xrightarrow{w^{(2)}} \xrightarrow{\sigma'(z^{(2)})} \xrightarrow{2(a^{(2)}-y)} L$$

每一步都是信息向前传播时各环节的局部导数,而整体梯度是这些局部导数的连乘积。

同样,损失对偏置 $b^{(1)}$ 的梯度是:

$$\frac{\partial L}{\partial b^{(1)}} = 2(a^{(2)} - y) \cdot \sigma'(z^{(2)}) \cdot w^{(2)} \cdot \sigma'(z^{(1)}) \cdot 1$$

唯一的区别是最后一项:$\frac{\partial z^{(1)}}{\partial b^{(1)}} = 1$(偏置的系数永远是1)。

四、推广到真实的网络

4.1 符号约定

在真实的多层、多神经元网络中,我们需要更精确的符号:

  • $a_j^{(l)}$:第 $l$ 层第 $j$ 个神经元的激活值
  • $w_{jk}^{(l)}$:从第 $l-1$ 层第 $k$ 个神经元到第 $l$ 层第 $j$ 个神经元的权重
  • $b_j^{(l)}$:第 $l$ 层第 $j$ 个神经元的偏置
  • $z_j^{(l)}$:第 $l$ 层第 $j$ 个神经元的加权和

4.2 定义误差信号

为了让推导更简洁,我们定义一个关键中间量——误差信号(error signal)

$$\delta_j^{(l)} = \frac{\partial L}{\partial z_j^{(l)}}$$

它衡量的是:如果第 $l$ 层第 $j$ 个神经元的加权和 $z$ 发生微小变化,损失会变化多少。

一旦我们知道了所有的 $\delta$,就能立刻算出所有参数的梯度:

$$\frac{\partial L}{\partial w_{jk}^{(l)}} = \delta_j^{(l)} \cdot a_k^{(l-1)}$$$$\frac{\partial L}{\partial b_j^{(l)}} = \delta_j^{(l)}$$

这两个公式非常优雅。权重的梯度等于"误差信号乘以输入激活值";偏置的梯度就是误差信号本身。

4.3 反向传播的四个核心公式

反向传播算法可以被浓缩为四个公式:

公式1:输出层的误差

$$\delta_j^{(L)} = \frac{\partial L}{\partial a_j^{(L)}} \cdot \sigma'(z_j^{(L)})$$

(损失对输出的敏感度,乘以激活函数的导数)

公式2:误差的反向传播

$$\delta_j^{(l)} = \left(\sum_{i} w_{ij}^{(l+1)} \delta_i^{(l+1)}\right) \cdot \sigma'(z_j^{(l)})$$

这是核心递推公式。它说:第 $l$ 层的误差等于下一层误差的加权回传,再乘以激活函数的导数。

用矩阵形式写更简洁:

$$\boldsymbol{\delta}^{(l)} = \left((W^{(l+1)})^T \boldsymbol{\delta}^{(l+1)}\right) \odot \sigma'(\mathbf{z}^{(l)})$$

其中 $\odot$ 表示逐元素乘法(Hadamard积)。

公式3:权重梯度

$$\frac{\partial L}{\partial w_{jk}^{(l)}} = \delta_j^{(l)} \cdot a_k^{(l-1)}$$

公式4:偏置梯度

$$\frac{\partial L}{\partial b_j^{(l)}} = \delta_j^{(l)}$$

4.4 算法流程

graph TD
    A["1. 前向传播
计算所有 z 和 a"] --> B["2. 计算输出层误差
δ^(L) = ∂L/∂a · σ'(z)"] B --> C["3. 反向传递误差
δ^(l) = (W^(l+1))ᵀ·δ^(l+1) ⊙ σ'(z^(l))"] C --> D["4. 计算所有梯度
∂L/∂W = δ · aᵀ
∂L/∂b = δ"] D --> E["5. 更新参数
θ = θ - η·∇L"] C -->|"l = L-1, L-2, ..., 1"| C

关键观察:

  1. 前向传播从输入到输出,反向传播从输出到输入——所以叫"反向"传播
  2. 前向传播时需要保存每一层的 $z$ 和 $a$,反向传播时会用到
  3. 整个过程只需要一次前向传播 + 一次反向传播,就得到了所有参数的梯度

五、一个完整的数值例子

让我们用具体数字走一遍。考虑一个极简网络:2个输入,2个隐藏神经元,1个输出。

5.1 网络结构与初始参数

输入: x₁ = 0.5, x₂ = 0.8
真实标签: y = 1

权重和偏置:
W¹ = [[0.1, 0.3],    b¹ = [0.2, 0.1]
      [0.2, 0.4]]
      
W² = [[0.5, 0.6]]    b² = [0.3]

5.2 前向传播

隐藏层:

$$z_1^{(1)} = 0.1 \times 0.5 + 0.3 \times 0.8 + 0.2 = 0.49$$

$$z_2^{(1)} = 0.2 \times 0.5 + 0.4 \times 0.8 + 0.1 = 0.52$$$$a_1^{(1)} = \sigma(0.49) = 0.6201$$

$$a_2^{(1)} = \sigma(0.52) = 0.6271$$

输出层:

$$z^{(2)} = 0.5 \times 0.6201 + 0.6 \times 0.6271 + 0.3 = 0.9863$$$$a^{(2)} = \sigma(0.9863) = 0.7283$$

损失(均方误差):

$$L = (0.7283 - 1)^2 = 0.0738$$

5.3 反向传播

输出层误差:

$$\delta^{(2)} = 2(0.7283 - 1) \cdot \sigma'(0.9863)$$

$$= 2 \times (-0.2717) \times 0.7283 \times (1 - 0.7283)$$

$$= -0.5434 \times 0.1978$$

$$= -0.1075$$

隐藏层误差:

$$\delta_1^{(1)} = w_{11}^{(2)} \cdot \delta^{(2)} \cdot \sigma'(z_1^{(1)}) = 0.5 \times (-0.1075) \times 0.6201 \times (1-0.6201) = -0.01267$$$$\delta_2^{(1)} = w_{12}^{(2)} \cdot \delta^{(2)} \cdot \sigma'(z_2^{(1)}) = 0.6 \times (-0.1075) \times 0.6271 \times (1-0.6271) = -0.01509$$

权重梯度(输出层):

$$\frac{\partial L}{\partial w_{11}^{(2)}} = \delta^{(2)} \cdot a_1^{(1)} = -0.1075 \times 0.6201 = -0.0667$$$$\frac{\partial L}{\partial w_{12}^{(2)}} = \delta^{(2)} \cdot a_2^{(1)} = -0.1075 \times 0.6271 = -0.0674$$

权重梯度(隐藏层),以 $w_{11}^{(1)}$ 为例:

$$\frac{\partial L}{\partial w_{11}^{(1)}} = \delta_1^{(1)} \cdot x_1 = -0.01267 \times 0.5 = -0.00634$$

5.4 参数更新

设学习率 $\eta = 0.5$:

$$w_{11}^{(2)} \leftarrow 0.5 - 0.5 \times (-0.0667) = 0.5334$$$$w_{11}^{(1)} \leftarrow 0.1 - 0.5 \times (-0.00634) = 0.1032$$

每个参数都朝着减小损失的方向微调了一小步。经过成千上万次这样的迭代,网络就能逐渐学会正确的映射。

六、随机梯度下降与Mini-batch

6.1 朴素梯度下降的问题

理论上,我们应该在所有训练数据上计算损失和梯度,然后更新一次参数。但如果有60,000个训练样本,每次更新都遍历一遍全部数据,速度太慢了。

6.2 随机梯度下降(SGD)

随机梯度下降每次只用一个样本来估计梯度:

for each epoch:
    打乱训练数据
    for each 样本 (x, y):
        前向传播
        计算损失
        反向传播
        更新参数

优点是更新频率极高(每个样本更新一次),但梯度估计噪声很大,优化路径很震荡。

6.3 Mini-batch梯度下降

实践中最常用的折中方案是 Mini-batch 梯度下降:将训练数据分成小批次(通常32、64、128或256个样本),每个batch计算一次平均梯度并更新。

graph LR
    subgraph Epoch ["一个Epoch"]
        B1["Batch 1
64个样本"] B2["Batch 2
64个样本"] B3["..."] BN["Batch N
64个样本"] end B1 --> U1["前向 + 反向
更新参数"] B2 --> U2["前向 + 反向
更新参数"] BN --> UN["前向 + 反向
更新参数"]
方法Batch大小梯度质量更新频率GPU利用率
批量梯度下降全部数据最准确最低最高
随机梯度下降1噪声最大最高最低
Mini-batch32~256较好适中较高

Mini-batch的一个意外好处是:梯度中的噪声实际上可以帮助跳出局部最小值,找到更好的解。

七、梯度消失与梯度爆炸

7.1 梯度消失问题

回顾反向传播的误差传递公式:

$$\boldsymbol{\delta}^{(l)} = \left((W^{(l+1)})^T \boldsymbol{\delta}^{(l+1)}\right) \odot \sigma'(\mathbf{z}^{(l)})$$

每向前传一层,梯度都要乘以一个 $\sigma'(z)$。

对于Sigmoid函数,$\sigma'(z)$ 的最大值只有0.25(在 $z=0$ 时取到):

σ'(z)
0.25 |        *
     |      *   *
     |    *       *
     |  *           *
0    |*               *
     +---------+---------→ z
              0

这意味着每经过一层,梯度至少缩小到原来的1/4。如果有10层隐藏层:

$$\text{梯度衰减} \leq (0.25)^{10} = 0.000000954$$

最前面几层的梯度几乎为零——参数根本学不动。这就是为什么早期的深层网络无法训练。

7.2 ReLU:梯度消失的解药

ReLU的导数是:

$$\text{ReLU}'(z) = \begin{cases} 1 & \text{if } z > 0 \\ 0 & \text{if } z \leq 0 \end{cases}$$

当 $z > 0$ 时,导数恒为1——梯度可以无衰减地传播。这是ReLU成为现代网络默认激活函数的主要原因。

但ReLU有自己的问题:Dead ReLU。如果某个神经元的 $z$ 始终为负,它的梯度永远是0,该神经元"死了",再也无法被激活。解决方案包括Leaky ReLU($z<0$ 时给一个小斜率)和He初始化。

7.3 梯度爆炸问题

与梯度消失相反,如果权重矩阵的特征值大于1,梯度在反向传播中会指数级增长,导致参数更新量过大,训练不稳定。

常见解决方案:

  • 梯度裁剪(Gradient Clipping):当梯度的范数超过阈值时,等比例缩小
  • 合理的权重初始化:如Xavier初始化、He初始化
  • Batch Normalization:对每层的输出做归一化
  • 残差连接(Skip Connection):让梯度可以跳过某些层直接传播

八、优化器的进化

朴素的SGD只是起点。实践中有一系列更智能的优化算法:

8.1 动量(Momentum)

想象一个球在损失函数的地形上滚动。朴素SGD像是在光滑冰面上走路——每一步只考虑脚下的坡度。Momentum则像是有惯性的球——会累积之前的运动方向:

$$v_t = \beta v_{t-1} + \nabla L$$

$$\theta = \theta - \eta \cdot v_t$$

$\beta$ 通常取0.9。好处是:

  • 在持续向同一方向下降时,速度越来越快
  • 在来回震荡的方向上,震荡会被抵消

8.2 Adam(Adaptive Moment Estimation)

Adam是目前最广泛使用的优化器,结合了Momentum和自适应学习率:

$$m_t = \beta_1 m_{t-1} + (1-\beta_1) \nabla L \quad \text{(一阶矩估计,类似Momentum)}$$

$$v_t = \beta_2 v_{t-1} + (1-\beta_2) (\nabla L)^2 \quad \text{(二阶矩估计,梯度的方差)}$$$$\hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t} \quad \text{(偏差校正)}$$$$\theta = \theta - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}$$

直觉理解:

  • 对于梯度大的参数(经常被更新的方向),Adam自动减小学习率
  • 对于梯度小的参数(稀少更新的方向),Adam自动增大学习率
  • 这让每个参数有了自己"定制"的学习率

8.3 优化器对比

优化器核心思想常用场景
SGD最基础,只用当前梯度简单问题,配合学习率调度
SGD + Momentum累积历史梯度方向CNN训练
RMSProp自适应学习率RNN训练
AdamMomentum + 自适应学习率通用默认选择
AdamWAdam + 权重衰减Transformer训练

九、自动微分

9.1 为什么不用手动推导

在实际的深度学习框架(PyTorch、TensorFlow等)中,你不需要手动推导每个操作的梯度。框架使用**自动微分(Automatic Differentiation)**技术。

核心思想是将整个前向计算记录为一个计算图(Computational Graph),然后利用链式法则自动反向计算梯度。

9.2 计算图的直觉

考虑 $L = (w \cdot x + b - y)^2$:

graph LR
    W["w"] --> MUL["×"]
    X["x"] --> MUL
    MUL --> ADD1["+"]
    B["b"] --> ADD1
    ADD1 --> SUB["-"]
    Y["y"] --> SUB
    SUB --> SQ["²"]
    SQ --> L["L"]

前向传播:从左到右计算每个节点的值。

反向传播:从右到左,每个节点根据其局部导数传递梯度。每个基本操作(加、乘、求幂等)的导数都是已知的简单规则。

框架的做法是:

  1. 前向传播时,构建计算图并记录每步的中间值
  2. 调用 .backward() 时,自动从输出到输入遍历计算图,用链式法则算出每个参数的梯度
  3. 梯度存储在每个参数的 .grad 属性中

这就是为什么在PyTorch中训练一个网络如此简洁:

output = model(input)
loss = criterion(output, target)
loss.backward()          # 自动计算所有梯度
optimizer.step()         # 用梯度更新参数

四行代码背后,就是完整的前向传播、反向传播和参数更新。

十、总结:反向传播的精髓

反向传播并不是一个高深莫测的黑魔法。它的核心思想可以总结为一句话:

利用链式法则,从输出层到输入层逐层计算损失对每个参数的偏导数,然后用这些偏导数(梯度)指导参数更新。

graph TB
    subgraph 前向传播 ["前向传播(从左到右)"]
        direction LR
        FI["输入"] --> FH["隐藏层
计算z和a"] --> FO["输出
计算预测"] end subgraph 反向传播 ["反向传播(从右到左)"] direction RL BO["输出层
计算δ^L"] --> BH["隐藏层
传递δ^l"] --> BI["输入侧
得到所有梯度"] end 前向传播 --> 计算损失["计算损失L"] 计算损失 --> 反向传播 反向传播 --> 更新["更新所有参数
θ = θ - η·∇L"]

它之所以强大,是因为:

  1. 高效:一次前向 + 一次反向就得到所有梯度,复杂度与前向传播同阶
  2. 通用:适用于任何可微分的网络结构
  3. 可组合:每个操作只需知道自己的局部导数,全局梯度通过链式法则自动组合

有了反向传播这个学习引擎,我们就有能力训练任意深度和宽度的网络。而这个工具最辉煌的应用之一,就是训练 Transformer 架构——它彻底改变了自然语言处理领域,也是现代大语言模型的基石。这将是下一篇文章的主题。