刚接触反向传播算法的时候,我总觉得它是一套需要死记的公式。后来某次训练一个小型网络,梯度直接变成 NaN,才逼着我开始逐行推公式。推完之后我才意识到,这套东西不是用来背的,而是用来定位问题和理解网络行为的。这篇内容围绕反向传播算法的公式推导展开,从符号约定、输出层和隐藏层的梯度计算,到矩阵化实现、完整网络的手推验证,再到训练中常见梯度问题的排查思路,一次讲透。
整个推导过程我会尽量贴近实际工程视角,不堆抽象概念。适合两类人:一类是刚学完前向传播、看着反传公式一头雾水的初学者;另一类是框架用得很熟、但一遇到梯度异常就不知道从哪里查起的开发者。看完之后,你应该能回答三个问题:梯度从哪里来、往哪里去、为什么有时会消失或爆炸。
1. 别急着背公式:反传到底在解决什么问题
1.1 前向传播的每一步都留下了什么
要理解反向传播,先得把前向传播看仔细。以一个两层网络为例,单个样本输入 $x$ 经过网络得到预测值,可以用这样一组式子表示:
$$z^1 = W^1 x + b^1, \quad a^1 = \sigma(z^1)$$
$$z^2 = W^2 a^1 + b^2, \quad a^2 = \sigma(z^2)$$
最后一步是计算损失 $L(a^2, y)$。整个过程就像一条外卖流水线:食材(输入)经过配菜(第一层)、烹饪(第二层),最后出餐(预测值)。如果顾客不满意(Loss 太大),你想知道是哪个环节出了问题,就得逆着流程回去检查每个环节。
前向传播除了给出最终预测值,还缓存了每一层加权求和的结果 $z^l$ 和激活输出 $a^l$。这些缓存看起来只是中间变量,但它们是反向传播计算梯度的原料。后面你会看到,反向传播的每一个梯度公式,都在引用前向传播缓存下来的这些量。所以现代深度学习框架在 forward 的时候一定会把中间激活值存下来,就是为了 backward 的时候能用。
1.2 没有反向传播,梯度还能怎么算
很多人问过一个问题:算梯度为什么非要用反向传播,直接对损失函数求导不行吗?问题在于,损失函数是权重的高度复合函数,直接展开求导几乎不可能。一个可行但不聪明的方案是数值微分:
$$\frac{\partial L}{\partial w} \approx \frac{L(w+\varepsilon) - L(w-\varepsilon)}{2\varepsilon}$$
这个公式本身没问题,但每算一个权重梯度,就要完整做两次前向传播。假设网络有 100 万个参数,算完所有梯度需要 200 万次前向,训练一轮的时间根本无法接受。反向传播的厉害之处在于:一次前向传播加上一次反向传播,就能拿到所有参数的梯度。计算代价从“参数个数 × 前向次数”降到了“约等于两次前向”。
| 梯度计算方法 | 前向传播次数 | 适用场景 |
|---|---|---|
| 数值微分 | 2 × 参数个数 | 验证梯度正确性,调试用 |
| 符号微分 | 推导复杂,表达式爆炸 | 小规模公式推导 |
| 反向传播 | 1 次前向 + 1 次反向 | 工程训练,大规模网络 |
1.3 链式法则为什么是核心
反向传播唯一的数学基础就是链式法则。对于复合函数,梯度的计算可以逐层拆解:
$$\frac{\partial L}{\partial w} = \frac{\partial L}{\partial a} \times \frac{\partial a}{\partial z} \times \frac{\partial z}{\partial w}$$
这个式子要拆成三部分看:第一部分是损失对预测值的敏感度,第二部分是激活函数对加权和的敏感度,第三部分是加权和对具体某个权重的敏感度。前向传播恰好算出了每一层的 $a$ 和 $z$,所以这三部分都能找到具体数值。任何深度的网络,无非是把链式法则反复嵌套。理解了这条主线,后面所有公式都是套这个框架。
2. 从最简单的单层网络开始推导
2.1 先把符号约定固定下来
推导之前必须把符号说清楚,否则公式一长就乱。我这里采用大多数教材和实战代码通用的约定:
| 符号 | 含义 |
|---|---|
| $L$ | 损失函数,本文用二次损失 $L = \frac{1}{2}|a^L - y|_2^2$ |
| $W^l$ | 第 $l$ 层权重矩阵,形状为 $(n_l, n_{l-1})$ |
| $b^l$ | 第 $l$ 层偏置向量,形状为 $(n_l, 1)$ |
| $z^l$ | 第 $l$ 层加权输入,$z^l = W^l a^{l-1} + b^l$ |
| $a^l$ | 第 $l$ 层激活输出,$a^l = \sigma(z^l)$ |
| $\delta^l$ | 第 $l$ 层误差项,定义为 $\delta^l = \frac{\partial L}{\partial z^l}$ |
| $\odot$ | Hadamard 积,即逐元素相乘 |
| $L$ 上标表示最后一层,即输出层 |
这里用二次损失加 sigmoid 激活,因为它能让每一步求导都清晰可见。实际工程里交叉熵损失更常用,但推导逻辑完全一致,后面第 6 章我会专门说配对问题。
2.2 输出层的输出层梯度是怎么算出来的
用 $j$ 表示输出层第 $j$ 个神经元,那么输出层的加权输入是:
$$z_j^L = \sum_k W_{jk}^L a_k^{L-1} + b_j^L$$
损失函数对 $z_j^L$ 的偏导数就是误差项 $\delta_j^L$。通过链式法则:
$$\delta_j^L = \frac{\partial L}{\partial a_j^L} \times \frac{\partial a_j^L}{\partial z_j^L} = (a_j^L - y_j) \times \sigma'(z_j^L)$$
写成向量形式:
$$\delta^L = (a^L - y) \odot \sigma'(z^L)$$
拿到 $\delta^L$ 之后,权重和偏置的梯度就顺理成章了。因为 $z_j^L = \sum_k W_{jk}^L a_k^{L-1} + b_j^L$,所以:
$$\frac{\partial L}{\partial W_{jk}^L} = \delta_j^L \times a_k^{L-1}, \quad \frac{\partial L}{\partial b_j^L} = \delta_j^L$$
用矩阵表达就是:
$$\frac{\partial L}{\partial W^L} = \delta^L (a^{L-1})^T, \quad \frac{\partial L}{\partial b^L} = \delta^L$$
这里要特别注意,$\delta^L$ 和 $\sigma'(z^L)$ 都是向量,所以它们之间用 Hadamard 积而不是普通的矩阵乘法。很多初学推导时在此处卡住,就是搞混了“逐元素相乘”和“矩阵相乘”。
2.3 隐藏层的误差项是如何反向传递的
输出层梯度好算,关键在于隐藏层的梯度怎么传递回来。以第 $l$ 层第 $j$ 个神经元为例,它的梯度影响下一层所有神经元的 $z^{l+1}$,因此求和不可避免:
$$\delta_j^l = \frac{\partial L}{\partial z_j^l} = \sum_m \frac{\partial L}{\partial z_m^{l+1}} \times \frac{\partial z_m^{l+1}}{\partial z_j^l}$$
其中:
$$z_m^{l+1} = \sum_j W_{mj}^{l+1} \sigma(z_j^l) + b_m^{l+1}$$
对 $z_j^l$ 求导:
$$\frac{\partial z_m^{l+1}}{\partial z_j^l} = W_{mj}^{l+1} \times \sigma'(z_j^l)$$
带到前面的式子里:
$$\delta_j^l = \sigma'(z_j^l) \times \sum_m W_{mj}^{l+1} \delta_m^{l+1}$$
写成矩阵形式,就是最经典的反向传播递推公式:
$$\delta^l = (W^{l+1})^T \delta^{l+1} \odot \sigma'(z^l)$$
这个公式可以这样理解:第 $l$ 层的误差,先让上一层误差 $\delta^{l+1}$ 通过转置权重矩阵 $(W^{l+1})^T$ 传回来,再乘上第 $l$ 层激活函数的导数。转置的作用就是把梯度沿着前向传播的路径原路送回去,形状刚好匹配。
2.4 局部梯度与模块化思想的工程价值
到这里其实可以停下来观察一个规律:每一层的计算只需要两样东西——上游传下来的误差 $\delta^{l+1}$,以及本层前向缓存下来的 $z^l$ 和 $a^{l-1}$。这给了工程实现极大的便利,每一层可以独立实现 forward 和 backward,不需要知道整个网络的全局结构。
这就是现代自动求导框架“反向模式”的基本雏形。你在框架里自定义一个算子,只需要实现 forward 时缓存必要的中间变量,backward 时接收上游梯度并输出本地梯度。信号的传播完全遵守局部的规则:上游梯度乘以本地导数,再传给下一层。理解了这条,你再看框架文档里关于自定义层的说明,会发现原来没有想象中那么神秘。
3. 从标量公式到矩阵实现
3.1 维度检查法,公式没记错的关键
把公式从标量推广到矩阵,最怕的就是转置和维度对不上。我自己的经验是:写完公式后立刻做维度检查。
数学推导中约定 $a^{l-1}$ 是列向量,形状为 $(n_{l-1}, 1)$,$\delta^l$ 是形状 $(n_l, 1)$ 的列向量,因此:
$$\frac{\partial L}{\partial W^l} = \delta^l (a^{l-1})^T$$
维度上看是 $(n_l, 1) \times (1, n_{l-1}) = (n_l, n_{l-1})$,恰好和权重矩阵 $W^l$ 的形状一致。这是非常好的自检信号:梯度的维度必须和参数的维度相同,否则更新参数时无法逐元素对齐。
到了实际代码实现,数据往往是按行组织的矩阵,也就是一个 batch 的多行样本。假设输入矩阵 $A$ 形状为 $(m, n_{l-1})$,其中 $m$ 是 batch size,那么前向计算为:
$$Z = A W^T + b$$
此时误差项 $\delta$ 形状为 $(m, n_l)$。权重梯度变成了:
$$\frac{\partial L}{\partial W^l} = \frac{1}{m} (\delta^l)^T A$$
维度上是 $(n_l, m) \times (m, n_{l-1}) = (n_l, n_{l-1})$。前面公式里的列向量转置,在这里被矩阵乘法的顺序自然消化掉了。如果你对照论文公式实现代码发现对不上,首先检查是不是行向量和列向量两种约定的差异。
3.2 一个 Numpy 版 MiniMLP 的前向和反向
把上面的思路落到代码里,可以用一个很小的多层感知机来验证。我建议所有想真正理解反向传播的人,至少用纯 Numpy 手写一次这样的类,别急着上框架。
import numpy as np def relu(z): return np.maximum(0, z) def relu_prime(z): return (z > 0).astype(float) class MiniMLP: def __init__(self, sizes, seed=42): np.random.seed(seed) self.sizes = sizes self.W = [] self.b = [] for i in range(1, len(sizes)): fan_in = sizes[i-1] self.W.append(np.random.randn(sizes[i], fan_in) * np.sqrt(2.0 / fan_in)) self.b.append(np.zeros((1, sizes[i]))) def forward(self, X): self.zs = [] self.activations = [X] a = X for i, (W, b) in enumerate(zip(self.W, self.b)): z = a @ W.T + b self.zs.append(z) if i == len(self.W) - 1: a = 1 / (1 + np.exp(-z)) else: a = relu(z) self.activations.append(a) return a def backward(self, X, y, lr=0.01): m = X.shape[0] a_out = self.activations[-1] # 输出层误差:MSE + sigmoid,δ^L = (a-y) ⊙ σ'(z) delta = (a_out - y) * a_out * (1 - a_out) dW_list = [] db_list = [] dW = delta.T @ self.activations[-2] / m db = np.sum(delta, axis=0, keepdims=True) / m dW_list.append(dW) db_list.append(db) # 隐藏层递推:δ^l = (δ^{l+1} W^{l+1}) ⊙ σ'(z^l) for i in range(len(self.W) - 2, -1, -1): delta = (delta @ self.W[i+1]) * relu_prime(self.zs[i]) dW = delta.T @ self.activations[i] / m db = np.sum(delta, axis=0, keepdims=True) / m dW_list.append(dW) db_list.append(db) dW_list.reverse() db_list.reverse() for i in range(len(self.W)): self.W[i] -= lr * dW_list[i] self.b[i] -= lr * db_list[i]这段代码里最核心的是隐藏层的递推行:
delta = (delta @ self.W[i+1]) * relu_prime(self.zs[i])对照数学公式 $\delta^l = (W^{l+1})^T \delta^{l+1} \odot \sigma'(z^l)$,这里因为采用行向量存储,$W^{l+1}$ 的形状是 $(n_{l+1}, n_l)$,所以直接用 $\delta^{l+1}$ 去右乘 $W^{l+1}$,效果和转置再左乘一致。这个对应关系我在纸上推了三遍才彻底理顺,建议你也在纸上画一画维度变化。
3.3 张量形状匹配的实战经验
手写反向传播最容易踩的是三个形状相关的坑。第一个坑是漏了转置。前向传播中z = a @ W.T + b,反向传播时会把W.T误写成W,导致 shape 直接对不上。第二个坑是忘记对 batch size 做归一化。如果你的损失函数对每个样本求和再平均,那么反向传播时梯度也必须除以 $m$,否则学习率稍微大一点就会发散。第三个坑比较隐蔽:b的梯度写成(m, n_l)而不是(1, n_l),虽然 Numpy 广播机制可能不报错,但更新参数时的语义就错了。
我调试时有个习惯:每次写完 backward,先用一组随机数据做一次梯度检查,再开始训练。别嫌麻烦,这一步能省下后面几个小时的排查时间。
4. 完整四层网络的手推与验证
4.1 网络结构与前向计算配置
为了把递推过程走完整,我们构造一个四层网络,结构为 2-4-4-1。输入维度是 2,两个隐藏层各 4 个神经元,输出层 1 个神经元。隐藏层用 ReLU,输出层用 Sigmoid,损失函数取均方误差。选这个结构是因为它足够表达多层递推,又不会大到难以跟踪。
前向传播阶段,我建议按层记录中间结果,格式就是z1 shape, a1 shape这样一路写到输出。实际运行中会得到类似下面的形状轨迹:
| 层级 | 变量 | 形状 |
|---|---|---|
| 输入 | $a^0$ | $(1, 2)$ |
| 第 1 层 | $z^1, a^1$ | $(1, 4)$ |
| 第 2 层 | $z^2, a^2$ | $(1, 4)$ |
| 第 3 层 | $z^3, a^3$ | $(1, 1)$ |
把每一层的输出形状写下来,不是为了好看,而是方便在反向传播时核对维度。只要反向传播中任何一个中间变量的形状和前向对不上,一定有问题。
4.2 反向传播逐层递推的手推记录
有了前向缓存,反向传播可以从输出层开始往回推。输出层的误差项:
$$\delta^3 = (a^3 - y) \odot \sigma'(z^3)$$
这里输出层是 Sigmoid,所以 $\sigma'(z^3) = a^3 (1 - a^3)$。假设某次前向得到 $a^3 = 0.62$,真实标签 $y = 1$,那么 $\delta^3 = (0.62 - 1) \times 0.62 \times 0.38 \approx -0.089$。
接着往第 2 层传:
$$\delta^2 = (\delta^3 W^3) \odot relu'(z^2)$$
这里 $W^3$ 的 shape 是 $(1, 4)$,$\delta^3$ 是 $(1, 1)$,相乘得到 $(1, 4)$,再逐元素乘上relu_prime(z2),得到第 2 层的误差向量。再往第 1 层传:
$$\delta^1 = (\delta^2 W^2) \odot relu'(z^1)$$
权重梯度则是每一层的误差乘以上一层的激活转置:
$$\frac{\partial L}{\partial W^3} = (\delta^3)^T a^2, \quad \frac{\partial L}{\partial W^2} = (\delta^2)^T a^1, \quad \frac{\partial L}{\partial W^1} = (\delta^1)^T a^0$$
整个过程手推下来,你会发现每一层做的事情完全一致:拿上层误差乘当前层激活导数,得到当前层误差;当前层误差乘以上层缓存的激活转置,得到当前层权重梯度。没有新花样,就是同一套公式在不同层重复。
4.3 参数更新后的效果验证
参数更新的规则非常简单:
$$W^l \leftarrow W^l - \eta \frac{\partial L}{\partial W^l}, \quad b^l \leftarrow b^l - \eta \frac{\partial L}{\partial b^l}$$
我习惯在更新前后都算一次 Loss,这样能直观看到这个公式的变化。用一个小学习率比如 $\eta = 0.1$,首轮 Loss 可能是 0.5 左右,更新后下一轮降到 0.4 以内,再往后慢慢下降。如果你看到的 Loss 完全不降甚至上升,基本可以断定是学习率太大、梯度计算有误或数据归一化没做好。
我手推时还会做一个非常简单的单参数实验来验证公式:把网络退化成只有一个权重、没有隐藏层的模型,输入 $x=1$,目标 $y=1$,初始权重 $w=0.1$,Sigmoid 输出。手动计算一轮更新,确认 Loss 确实从 0.1128 降到 0.1059 附近,再去跑完整网络。这种小实验能极大增强对反向传播的信心。
5. 训练中常见的梯度问题与排查手段
5.1 梯度消失与梯度爆炸的识别与应对
如果你训练深层网络时发现 Loss 几乎不动,或者梯度突然变成 NaN,大概率是遇到了梯度消失或梯度爆炸。核心原因藏在递推公式里:$\delta^l = (W^{l+1})^T \delta^{l+1} \odot \sigma'(z^l)$。如果激活函数的导数总是小于 1,比如 Sigmoid 的最大导数是 0.25,那么多层连乘之后梯度会指数级衰减,这就是梯度消失。反过来,如果权重矩阵的特征值大于 1,多轮连乘后梯度会指数级放大,梯度爆炸就来了。
排查方法是打印每一层梯度的 L2 范数。如果看到靠前的层梯度比靠后的层小好几个数量级,说明梯度在往回传的过程中衰减太严重;如果出现 Inf 或 NaN,基本就是梯度爆炸。
应对手段可以组合使用:
| 手段 | 作用 |
|---|---|
| 改用 ReLU 等导数恒为 0 或 1 的激活函数 | 避免导数连乘导致的消失 |
| 合理的权重初始化(He / Xavier) | 控制权重矩阵的特征值尺度 |
| 批量归一化 | 让每层输入的分布相对稳定 |
| 梯度裁剪 | 对梯度范数设上限,防止爆炸 |
| 残差连接 | 给梯度提供一条“高速公路” |
5.2 死亡 ReLU 会让梯度静默消失
ReLU 的梯度在输入为负时是 0,这既是优点也是隐患。如果某神经元在训练中被更新到合适的负偏置,使得它的输入永远小于 0,梯度就一直是 0,权重再也不更新,这个神经元就“死”了。死亡 ReLU 不像梯度爆炸那样产生 NaN,它的特点是网络里大量神经元输出恒为 0,模型表现停滞但 Loss 并不发散。
想确认是不是出现了这种情况,可以在训练时打印隐藏层的激活值。如果某一层的输出有很高比例是 0,甚至某个神经元对所有样本都输出 0,就要处理了。改进方案包括把初始化方案换成 He 初始化、降低学习率,或者把 ReLU 换成 LeakyReLU,让负半轴保留一个小梯度。
5.3 学习率过大导致的震荡与发散
学习率设置不当是训练中最常见的问题之一。学习率太大会让 Loss 出现锯齿状震荡,甚至直接冲到 NaN;学习率太小则收敛慢得让人怀疑人生。我自己的经验是,排查时先用一个非常小的学习率,比如 0.0001,确认 Loss 能稳定下降,再逐渐增大学习率,观察哪个位置开始震荡。如果手推反向传播过程中发现权重更新一步之后 Loss 反而变大很多,那几乎可以断定是学习率过大。
一个比较省心的经验法则是:先试 0.1,再试 0.01、0.001、0.0001,每个数量级跑十几个迭代,看 Loss 的前几个 Epoch 变化趋势。找到能稳定下降的最大数量级就基本可用了。使用优化器时,如果选择了 Adam,默认学习率用 0.001 起步通常会比较稳。
5.4 梯度检查:验证反传最有力的工具
反向传播公式推导容易出错,但最麻烦的是错了还不报错,只是收敛得慢。这时梯度检查就派上用场了。思路很简单:用数值微分算出的梯度作为“参考答案”,和反向传播算出的梯度比较。
def numerical_gradient(loss_fn, w, eps=1e-5): grad = np.zeros_like(w) it = np.nditer(w, flags=['multi_index']) while not it.finished: idx = it.multi_index old = w[idx] w[idx] = old + eps fp = loss_fn(w) w[idx] = old - eps fm = loss_fn(w) w[idx] = old grad[idx] = (fp - fm) / (2 * eps) it.iternext() return grad比较时用相对误差而不是绝对误差。对于浮点数,相对误差在 $10^{-6}$ 量级基本可信,在 $10^{-3}$ 量级就要回头查反向传播代码了。这个工具最大的价值在于:你手写新层、自定义算子或修改网络结构之后,可以快速确认梯度方向是否正确,避免带着错误的梯度训练几小时。
5.5 数值稳定性问题
推导反向传播时,我们默认所有数值都在合理范围内。实际上,exp、log这类运算特别容易溢出。比如直接计算 Softmax 时,z很大时指数会爆炸;计算交叉熵时,log(0)直接就是负无穷。这也是为什么工程上不会单独先算 Softmax 再算交叉熵,而是把两者合并成一个算子,内部用 log-sum-exp 技巧保证数值稳定。
更妙的是,合并 Softmax 和交叉熵之后,输出层误差项会化简成 $\delta^L = a^L - y$,非常干净。这个简洁的式子背后是数值稳定性和梯度表达式的双赢,也是推导公式时需要额外留意的一点。
6. 一点延伸与个人体会
6.1 损失函数和激活函数的配对关系
输出层误差 $\delta^L$ 的表达式和损失函数、激活函数的选择直接相关,不同组合对训练速度影响很大。
| 输出层激活 + 损失 | 输出层误差 $\delta^L$ | 特点 |
|---|---|---|
| Sigmoid + MSE | $(a-y) a(1-a)$ | 梯度较小,收敛慢 |
| Sigmoid + 交叉熵 | $a-y$ | 梯度抵消了导数项,更健康 |
| Softmax + 交叉熵 | $a-y$ | 多分类标配,推导最干净 |
| 线性 + MSE | $a-y$ | 回归问题常用 |
这就是为什么实践中分类问题总是直接使用logits + 交叉熵损失,而不是先套 Softmax 再算交叉熵。合并处理既避免了数值溢出,又让梯度公式变得极其简洁。
6.2 亲手推导一遍之后,我注意到的训练细节
把反向传播公式手推几遍之后,再看训练过程就不一样了。我现在调试模型时会关注三样东西:Loss 曲线的形态、每一层梯度的范数分布、每一层激活值的统计信息。这三样东西基本能定位大部分训练问题。看到 Loss 变成 NaN,我第一反应不是减小学习率,而是先查梯度范数,确认是爆炸还是数值溢出;看到网络收敛很慢,我会去查激活函数和损失函数的配对是否合理,而不是盲目加层数。
如果你现在正被这一堆公式绕得头疼,我的建议是先别急着背结论。拿一张纸,从输出层开始,一层一层把每个误差项和梯度写清楚,再把 Numpy 代码跑通,最后试着给网络换一种激活函数或损失函数,看看梯度公式怎么变。这样走一遍之后,你再回头看框架的自动求导机制,会有一种“原来如此”的透彻感。毕竟,反向传播不是一个需要膜拜的黑盒算法,它只是一套能让你看清网络内部动态的普通数学工具。