在前两篇深度前馈网络笔记中,我们已经介绍了感知机、隐藏层、激活函数和代价函数。真正让神经网络能够训练起来的关键步骤,是如何高效计算每一层参数的梯度。反向传播(Backpropagation)就是这一过程的核心算法。

  这一节不再停留在“会背公式”的层面,而是把反向传播拆成几个真正需要理解的部分:梯度为什么要从后往前传、误差项到底是什么、每一层参数梯度为什么都能写成统一形式、不同损失函数与激活函数组合会对训练产生什么影响。在最后的实验部分,也会用一个 NumPy 版本的两层网络验证推导结果。

反向传播解决了什么问题

  考虑一个多层感知机,其前向传播过程本质上是若干个复合函数的嵌套:

$$
\hat{y}=f(x; \theta)=f^{(L)}(f^{(L-1)}(\cdots f^{(1)}(x)))
$$

其中,参数集合 $\theta={W^{(1)}, b^{(1)}, W^{(2)}, b^{(2)}, \ldots }$。训练神经网络的目标,是最小化损失函数:

$$
\mathcal{L}(\theta)=\frac{1}{N}\sum_{i=1}^{N}\ell(\hat{y}^{(i)}, y^{(i)})
$$

  如果我们逐个参数单独求导,会发现每一个权重都嵌套在多层复合运算中,直接展开既繁琐又低效。反向传播的作用,就是利用链式法则中间结果复用,从输出层开始逐层向前计算梯度,从而在线性于网络边数的复杂度内完成求导。

从计算图理解反向传播

  神经网络可以看作一张有向无环图。前向传播是把输入沿着图向前计算,得到预测值;反向传播则是从损失函数开始,将“损失对当前节点输出的敏感度”逐层传回前面的节点。

  假设存在如下复合关系:

$$
z=f(y), \quad y=g(x)
$$

那么根据链式法则:

$$
\frac{dz}{dx}=\frac{dz}{dy}\frac{dy}{dx}
$$

  这意味着如果我们已经知道 $\frac{dz}{dy}$,再乘上当前这一层的局部导数 $\frac{dy}{dx}$,就可以继续把梯度往前传。反向传播的本质,就是不断重复这个过程。

符号约定与维度说明

  在具体推导之前,先把常用符号统一下来。设第 $l$ 层的输入为上一层激活值 $a^{(l-1)}$,则当前层的线性组合与激活值分别写成:

$$
z^{(l)} = W^{(l)}a^{(l-1)} + b^{(l)}
$$

$$
a^{(l)} = g^{(l)}(z^{(l)})
$$

其中:

  • $W^{(l)}$ 表示第 $l$ 层权重矩阵
  • $b^{(l)}$ 表示第 $l$ 层偏置向量
  • $z^{(l)}$ 表示激活函数之前的线性输出
  • $a^{(l)}$ 表示经过激活函数后的输出

  区分 $z^{(l)}$ 和 $a^{(l)}$ 很重要,因为反向传播里误差项定义在 $z^{(l)}$ 上,而不是直接定义在 $W^{(l)}$ 上。这样做的好处,是整层参数的梯度都能通过同一个误差项统一表达。

两层神经网络的前向传播

  下面以一个典型的两层全连接网络为例说明。输入为 $x$,隐藏层激活函数使用 sigmoid,输出层也使用 sigmoid:

$$
z^{(1)} = W^{(1)}x+b^{(1)}
$$

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

$$
z^{(2)} = W^{(2)}a^{(1)}+b^{(2)}
$$

$$
\hat{y} = a^{(2)} = \sigma(z^{(2)})
$$

若采用均方误差损失:

$$
\mathcal{L}=\frac{1}{2}|\hat{y}-y|^{2}
$$

那么整个计算过程可以理解为:

输入 $x$ -> 线性变换 -> 非线性激活 -> 线性变换 -> 输出 $\hat{y}$ -> 损失 $\mathcal{L}$

误差项的定义

  为了让推导更紧凑,通常定义第 $l$ 层的误差项为:

$$
\delta^{(l)}=\frac{\partial \mathcal{L}}{\partial z^{(l)}}
$$

  这个定义非常重要。因为参数 $W^{(l)}$ 和 $b^{(l)}$ 都直接作用在 $z^{(l)}$ 上,一旦我们求出了 $\delta^{(l)}$,参数梯度就会变得很容易写出。

  可以把 $\delta^{(l)}$ 理解为:当前层线性输出发生一个极小扰动时,损失函数会变化多少。误差项越大,说明这一层当前输出对最终损失越敏感;误差项越小,说明该层对损失的即时影响较弱。

输出层梯度推导

  对于输出层,有:

$$
\delta^{(2)}=\frac{\partial \mathcal{L}}{\partial z^{(2)}}=\frac{\partial \mathcal{L}}{\partial a^{(2)}}\odot\frac{\partial a^{(2)}}{\partial z^{(2)}}
$$

又因为

$$
\mathcal{L}=\frac{1}{2}|\hat{y}-y|^{2}
$$

所以

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

若激活函数为 sigmoid,则

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

因此输出层误差项为:

$$
\delta^{(2)}=(\hat{y}-y)\odot a^{(2)}(1-a^{(2)})
$$

  这组公式说明,输出层梯度实际上由两部分共同决定:

  1. 预测误差 $\hat{y}-y$
  2. 输出层激活函数的局部导数

  这也是为什么输出层的激活函数和损失函数组合非常重要。如果输出层使用 sigmoid,而损失函数选择交叉熵,那么很多情况下公式可以进一步化简,误差项直接变为 $\delta^{(2)}=\hat{y}-y$,训练往往比 sigmoid + MSE 更稳定。

接下来求参数梯度:

$$
\frac{\partial \mathcal{L}}{\partial W^{(2)}}=\delta^{(2)}(a^{(1)})^{\top}
$$

$$
\frac{\partial \mathcal{L}}{\partial b^{(2)}}=\delta^{(2)}
$$

  这里可以看出一个很重要的结构:当前层参数的梯度 = 当前层误差项 × 上一层输出

隐藏层梯度推导

  隐藏层没有直接与损失函数相连,因此需要先从后一层把误差传回来:

$$
\delta^{(1)}=\frac{\partial \mathcal{L}}{\partial z^{(1)}}=
\left((W^{(2)})^{\top}\delta^{(2)}\right)\odot \sigma’(z^{(1)})
$$

若隐藏层激活函数也是 sigmoid,则

$$
\delta^{(1)}=\left((W^{(2)})^{\top}\delta^{(2)}\right)\odot a^{(1)}(1-a^{(1)})
$$

类似地,可以得到第一层参数的梯度:

$$
\frac{\partial \mathcal{L}}{\partial W^{(1)}}=\delta^{(1)}x^{\top}
$$

$$
\frac{\partial \mathcal{L}}{\partial b^{(1)}}=\delta^{(1)}
$$

  这就是全连接神经网络中最经典的一组反向传播公式。更深层网络时,只需要继续把误差项向前传递即可:

$$
\delta^{(l)}=((W^{(l+1)})^{\top}\delta^{(l+1)})\odot g’(z^{(l)})
$$

  这个递推式非常关键,它揭示了反向传播的两个本质动作:

  1. 先把后一层误差通过权重矩阵转回当前层
  2. 再乘上当前层激活函数的局部导数

  前者描述了网络拓扑结构中的“影响回传”,后者描述了非线性单元本身对梯度流动的调制作用。

参数梯度为什么能写成统一形式

  以第 $l$ 层为例:

$$
z^{(l)} = W^{(l)}a^{(l-1)} + b^{(l)}
$$

对其中某一个权重 $w_{ij}^{(l)}$ 来说,$z_i^{(l)}$ 对它的偏导只等于上一层对应输入:

$$
\frac{\partial z_i^{(l)}}{\partial w_{ij}^{(l)}} = a_j^{(l-1)}
$$

于是根据链式法则:

$$
\frac{\partial \mathcal{L}}{\partial w_{ij}^{(l)}} =
\frac{\partial \mathcal{L}}{\partial z_i^{(l)}}
\frac{\partial z_i^{(l)}}{\partial w_{ij}^{(l)}}
= \delta_i^{(l)} a_j^{(l-1)}
$$

将所有元素写成矩阵形式,就得到:

$$
\frac{\partial \mathcal{L}}{\partial W^{(l)}} = \delta^{(l)}(a^{(l-1)})^{\top}
$$

  偏置项更简单,因为偏置对线性输出的偏导恒等于 1,所以:

$$
\frac{\partial \mathcal{L}}{\partial b^{(l)}} = \delta^{(l)}
$$

  这也是为什么在代码实现中,权重梯度通常是一个外积或矩阵乘法,而偏置梯度通常是对 batch 维度做求和或平均。

参数更新公式

  当所有梯度计算完成后,就可以配合梯度下降更新参数。设学习率为 $\eta$,则有:

$$
W^{(l)} \leftarrow W^{(l)}-\eta \frac{\partial \mathcal{L}}{\partial W^{(l)}}
$$

$$
b^{(l)} \leftarrow b^{(l)}-\eta \frac{\partial \mathcal{L}}{\partial b^{(l)}}
$$

  这一步本身并不复杂,真正困难的是如何高效、准确地得到梯度,而这恰恰就是反向传播算法的价值所在。

损失函数与输出层的关系

  反向传播公式本身不依赖某一种固定损失函数,但不同任务中,输出层与损失函数的搭配会直接影响梯度的数值性质。

二分类任务

  输出层常使用 sigmoid。如果配合二元交叉熵,梯度形式更简洁,数值上也更稳定,是实践中最常见的组合。

多分类任务

  输出层常使用 softmax,并与交叉熵搭配。这样可以直接建模各类别的概率分布,同时避免人为地把多个类别当作互不相关的独立二分类问题。

回归任务

  输出层通常使用线性单元,再配合均方误差或平均绝对误差。此时重点不在概率解释,而在预测连续值本身。

结合示意图理解推导流程

  下面这组图对应了一个小型神经网络在前向传播和反向传播时的计算路径。先看前向传播的中间变量是如何层层计算出来的:

正向传播

  • 第一层
  • 第二层

正向传播2

  • 第三层

正向传播3

反向传播

  • 从输出层开始计算预测误差

反向传播1

  • 误差项向隐藏层传播

反向传播2

  • 继续向前传播到更早一层

反向传播3

  • 利用误差项计算参数梯度并执行更新

反向传播4

反向传播5

反向传播6

手算示例:从误差到单个权重梯度

  如果刚接触反向传播,最容易困惑的地方通常不是公式本身,而是“为什么每一个权重都能通过链式法则拆开”。下面这个例子展示了一个两层网络中,如何一步一步求损失对单个权重的导数。

  • 正向计算得到各个中间节点值
  • 反向求损失对各参数的梯度
  • 用学习率更新参数
  • 以某一个具体权重为例展开求导

实验:使用 NumPy 手写反向传播

  下面用一个最小示例来验证:只要梯度推导正确,经过若干轮参数更新后,损失就会持续下降。这里用 XOR 数据做二分类,网络结构为 2 -> 4 -> 1

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import numpy as np

np.random.seed(42)

X = np.array([
[0.0, 0.0],
[0.0, 1.0],
[1.0, 0.0],
[1.0, 1.0],
])
y = np.array([[0.0], [1.0], [1.0], [0.0]])

W1 = np.random.randn(2, 4) * 0.5
b1 = np.zeros((1, 4))
W2 = np.random.randn(4, 1) * 0.5
b2 = np.zeros((1, 1))


def sigmoid(x):
return 1.0 / (1.0 + np.exp(-x))


def sigmoid_grad(a):
return a * (1.0 - a)


lr = 0.5
epochs = 5000

for epoch in range(epochs):
# forward
z1 = X @ W1 + b1
a1 = sigmoid(z1)
z2 = a1 @ W2 + b2
y_hat = sigmoid(z2)

# mse loss
loss = np.mean(0.5 * (y_hat - y) ** 2)

# backward
delta2 = (y_hat - y) * sigmoid_grad(y_hat)
dW2 = a1.T @ delta2 / len(X)
db2 = np.mean(delta2, axis=0, keepdims=True)

delta1 = (delta2 @ W2.T) * sigmoid_grad(a1)
dW1 = X.T @ delta1 / len(X)
db1 = np.mean(delta1, axis=0, keepdims=True)

# update
W2 -= lr * dW2
b2 -= lr * db2
W1 -= lr * dW1
b1 -= lr * db1

if epoch % 500 == 0:
print(f"epoch={epoch:4d}, loss={loss:.6f}")

print("prediction:")
print(np.round(y_hat, 4))

预期现象

训练过程中,损失会逐步下降,例如:

1
2
3
4
5
6
7
8
9
10
epoch=   0, loss=0.131245
epoch= 500, loss=0.123607
epoch=1000, loss=0.111904
epoch=1500, loss=0.090152
epoch=2000, loss=0.060981
epoch=2500, loss=0.036587
epoch=3000, loss=0.022264
epoch=3500, loss=0.014411
epoch=4000, loss=0.010008
epoch=4500, loss=0.007387

最终预测值会逐步接近:

1
2
3
4
[[0.05]
[0.93]
[0.93]
[0.08]]

  这说明手写的梯度计算方向基本正确,否则损失通常不会稳定下降,更难拟合 XOR 这样线性不可分的问题。

实验中的关键实现细节

1. 为什么代码里要缓存中间变量

  反向传播并不是凭空回传梯度,而是需要使用前向传播时得到的中间结果,例如 $z^{(l)}$、$a^{(l)}$。如果这些量不保存,反向阶段就必须重复计算,不仅效率低,还容易在实现时出错。

2. 为什么 batch 训练要对梯度做平均

  代码里使用了 len(X)dW 做归一化,本质上是把一个 batch 中多个样本的梯度取平均。这样做可以让学习率更稳定,不会因为 batch 大小变化而导致梯度尺度剧烈波动。

3. 为什么初始化不能全为 0

  如果一层神经元的权重全部初始化为相同数值,那么这一层各神经元在前向和反向阶段都会保持完全相同的状态,模型无法学到有区分性的特征。因此权重通常需要随机初始化,以打破对称性。

训练中常见问题与注意事项

1. 为什么梯度会消失

  当隐藏层大量使用 sigmoid 或 tanh 时,若输入落在饱和区间,导数会变得很小。反向传播时这些小导数不断相乘,前面层的梯度就会迅速衰减,导致训练困难。

2. 为什么激活函数会影响训练难度

  因为误差项中总是包含激活函数导数 $g’(z)$。如果导数长期接近 0,梯度难以有效传回早期层;如果导数分布更稳定,如 ReLU 在激活区间的一阶导数为 1,则通常更利于深层网络训练。

3. 为什么要先算误差项再算参数梯度

  因为同一层中所有参数的梯度都共享同一个误差项 $\delta^{(l)}$。先把误差项求出来,可以避免重复推导和重复计算,这是反向传播高效的关键。

4. 为什么学习率会直接影响训练效果

  学习率过大时,参数更新可能在最优点附近来回震荡,甚至直接发散;学习率过小时,虽然理论上也能收敛,但训练速度会非常慢。因此在实际训练中,学习率往往和初始化方式、优化器、batch size 一起联动调整。

5. 为什么要做梯度检查

  当手写反向传播时,最容易出现的是矩阵维度错误、转置方向错误以及求和轴错误。此时可以用数值微分做梯度检查,即用很小的扰动近似计算:

$$
\frac{\partial \mathcal{L}}{\partial w} \approx \frac{\mathcal{L}(w+\epsilon)-\mathcal{L}(w-\epsilon)}{2\epsilon}
$$

  如果数值梯度和反向传播算出的解析梯度接近,说明实现通常是正确的。梯度检查开销较大,不适合正式训练,但非常适合在调试阶段定位错误。

小结

  反向传播不是一个脱离网络结构之外的“额外技巧”,而是链式法则在神经网络中的系统化实现。其核心步骤可以概括为:

  1. 先完成前向传播,保存每层的线性输出和激活值。
  2. 从损失函数出发,计算输出层误差项。
  3. 按照链式法则逐层向前传播误差项。
  4. 使用“误差项 × 上一层输出”得到参数梯度。
  5. 结合梯度下降或其他优化器更新参数。

  理解了这一过程,再去学习自动求导、卷积网络训练、循环神经网络和 Transformer 优化时,会更容易把握“梯度是怎样在模型中流动”的本质。