1. 为什么值得把反向传播的公式亲手推一遍
很多人第一次接触神经网络,是从一行model.fit()或者一个loss.backward()开始的。框架把梯度算好了,参数也更新了,代码跑通的那一刻确实很爽,但心里总有点虚——那个backward()到底在背后干了什么?为什么它能知道每个权重该往哪个方向调?调多少?如果哪天损失函数不收敛、梯度爆炸或者梯度消失,我该从哪一层、哪个公式去找原因?
这些问题的答案,全都藏在反向传播这四个字里。它不是什么玄学,本质就是微积分里的链式法则加上一点动态规划的思想,把一堆复合函数的求导拆成一层一层可以复用的局部计算。你把它推一遍,就会发现所谓“神经网络在学习”,其实就是在一个高维的误差曲面上,沿着梯度的反方向一小步一小步地往下走。
这篇内容适合三类人:第一类是把框架当黑盒用、想搞明白底层原理的开发者;第二类是正在准备面试、被问到“手推反向传播”就卡壳的同学;第三类是想自己从零写一个小型神经网络、不依赖任何自动微分库的动手派。我会从计算图讲起,把前向传播、损失函数、链式法则、逐层梯度公式、参数更新完整走一遍,中间穿插数值验算和踩坑经验。看完之后,你应该能拿一张纸、一支笔,对一个三层网络把梯度从头推到尾,并且知道每一步为什么这么算。
需要先说明一点:下面涉及的公式推导,我会尽量用最朴素的记号和最直白的语言,不堆砌矩阵微分的高级技巧。因为对绝大多数从业者来说,能理解“每个梯度是怎么由后一层传回来的”比会背雅可比矩阵的转置更有价值。真正卡住人的从来不是数学难度,而是符号太多、下标太乱、不知道哪一步用了哪个中间结果。
2. 反向传播的数学地基:从计算图到链式法则
2.1 用计算图把复合函数拆开看
神经网络本质上是一个巨大的复合函数。输入 x 经过第一层变换得到 h1,h1 经过第二层得到 h2,一直到输出层的预测值,再和真实标签一起送进损失函数,得到一个标量损失 L。整个过程中,每一层都是前一层输出的函数,层层嵌套。
要理解反向传播,最直观的工具是计算图。计算图把每个运算(加法、乘法、激活、矩阵乘)画成一个节点,数据沿着边向前流动。比如一个最简单的标量例子:z = (a + b) * c。计算图里先有一个加法节点算出 a+b,再有一个乘法节点把它和 c 相乘得到 z。
为什么计算图这么重要?因为它把“求整个复合函数的导数”这件事,拆成了“求每个局部节点的导数”。每个局部节点的导数都很简单——加法的导数是 1,乘法的导数是另一个因子。反向传播要做的,就是把这些局部导数沿着图的反方向乘起来。这就是链式法则在计算图上的具体体现。
我个人的经验是,初学的时候不要一上来就对着多层矩阵公式硬啃,先拿几个标量例子把计算图画出来,手动做一遍前向和反向,把“局部导数相乘”这个感觉建立起来,再去推广到矩阵和批量数据,会顺畅得多。
2.2 链式法则:反向传播唯一的数学武器
链式法则说的是:如果 y = f(u),而 u = g(x),那么 y 对 x 的导数等于 y 对 u 的导数乘以 u 对 x 的导数。写成公式就是 dy/dx = (dy/du) * (du/dx)。这个规则可以无限嵌套,不管复合多少层都成立。
放到神经网络里,假设损失 L 依赖某一层的输出 a,而 a 又依赖这一层的权重 W 和前一层的输出。那么 L 对 W 的导数,就等于 L 对 a 的导数,乘以 a 对 W 的导数。而 L 对 a 的导数,又可以继续往后面(更靠近输出的方向)追溯。这就是为什么反向传播是“从后往前”算的——因为损失在最末端,只有先知道损失对最后一层输出的敏感度,才能一层层往前推。
这里有个关键点容易被忽略:链式法则里的每一项都是局部导数,它只依赖当前节点和它的直接输入输出,不需要知道整个网络的全局结构。正是这个性质,让反向传播可以模块化——每一层只需要实现两个功能:前向计算输出,反向接收来自后一层的梯度、计算自己参数的梯度并继续往前传。这种模块化设计是所有深度学习框架的基石。
2.3 前向传播:先把账算清楚
在推反向之前,必须把前向传播的每一步都写清楚,因为反向用到的所有中间量都是前向算出来的。以一个标准的三层全连接网络为例,记号约定如下:
- 输入向量 x,维度为 d。
- 第一层权重 W1,偏置 b1,线性输出 z1 = W1·x + b1,激活后 a1 = σ(z1)。
- 第二层权重 W2,偏置 b2,线性输出 z2 = W2·a1 + b2,激活后 a2 = σ(z2)。
- 输出层权重 W3,偏置 b3,线性输出 z3 = W3·a2 + b3,经过 softmax 或直接作为回归输出。
- 损失函数 L,比如均方误差或交叉熵。
前向传播就是按顺序把 z1、a1、z2、a2、z3、L 全部算出来并缓存。为什么要缓存?因为反向传播计算梯度时,需要用到这些中间值。比如激活函数的导数 σ'(z1) 需要 z1 的值,权重梯度需要前一层的激活输出。如果不缓存、反向时重算,计算量会翻倍。框架里所谓的“保存中间激活”,说的就是这件事。
提示:缓存中间激活是反向传播省时间的核心手段,但代价是显存占用。这也是为什么训练时 batch size 不能无限大——激活值占的显存往往比参数本身还多。
3. 逐层拆解:梯度到底是怎么一层层传回去的
3.1 输出层梯度:一切的起点
反向传播的起点是损失函数对输出层线性输出的导数,记作 δ3 = ∂L/∂z3。这个量是整个反向过程的“种子”,后面所有梯度都由它推出来。
以最常用的两个损失为例。如果是均方误差 L = ½(y - ŷ)²,其中 ŷ = z3(回归场景),那么 ∂L/∂z3 = ŷ - y,非常干净。如果是 softmax 加交叉熵,结论更漂亮:∂L/∂z3 = ŷ - y,也就是预测概率减去真实标签的 one-hot 向量。这个结果不是巧合,而是 softmax 和交叉熵这对组合在导数上互相“抵消”后的产物,也是它们被绑在一起用的重要原因。
拿到 δ3 之后,输出层的权重和偏置梯度立刻可以写出来:∂L/∂W3 = δ3 · a2ᵀ,∂L/∂b3 = δ3。这里 a2 是第二层的激活输出,也就是输出层的输入。为什么权重梯度是 δ 乘输入?因为 z3 = W3·a2 + b3,对 W3 求导时,a2 相当于常数系数,导数的结果就是“误差信号乘以输入信号”。这个形式在所有线性层里都一样,记住它。
3.2 隐藏层梯度:误差信号的接力
真正体现链式法则的地方,是把误差信号从输出层往隐藏层传。核心公式是:δ2 = (W3ᵀ · δ3) ⊙ σ'(z2)。拆开看,它包含两步。
第一步,把输出层的误差信号 δ3 通过权重矩阵 W3 的转置传回来,得到 W3ᵀ·δ3。这一步的物理含义是:输出层的每个神经元对第二层每个神经元的“责任分摊”。权重越大,说明这个连接越重要,那么输出端的误差就有越大的比例归咎于对应的隐藏神经元。转置的作用是把维度对齐——前向是 W3 把 a2 映射到 z3,反向就是 W3ᵀ 把 δ3 映射回 a2 的空间。
第二步,乘以激活函数的导数 σ'(z2),这是逐元素相乘(Hadamard 积)。为什么要有这一步?因为前向时 a2 = σ(z2),误差要穿过激活函数才能到达线性输出 z2。如果激活函数是 ReLU,那么 σ'(z2) 在 z2>0 时为 1,z2≤0 时为 0。这就解释了 ReLU 的“死亡神经元”问题——一旦某个神经元的 z2 长期为负,它的梯度永远是 0,参数再也更新不了。
同样的逻辑继续往前:δ1 = (W2ᵀ · δ2) ⊙ σ'(z1)。可以看到,除了输出层用损失函数的导数作为种子,其余每一层的误差信号都是“后一层的误差经权重转置传回,再乘本层激活导数”。这个模式高度统一,写代码时完全可以抽象成一个循环。
3.3 参数梯度与更新:把误差变成实际的调整量
有了每一层的 δ,参数梯度就是顺手的事。对第 l 层:∂L/∂Wl = δl · a(l-1)ᵀ,∂L/∂bl = δl。这里的 a(l-1) 是这一层的输入(第一层的输入就是 x)。
拿到梯度后,用梯度下降更新:Wl ← Wl - η · ∂L/∂Wl,bl ← bl - η · ∂L/∂bl。η 是学习率,控制每一步走多大。学习率太大会震荡甚至发散,太小则收敛慢。实际中常用 Adam、动量等优化器,它们本质上是在梯度基础上加了历史信息的加权,但底层依赖的梯度还是反向传播算出来的这一套。
注意:偏置的梯度就是 δ 本身,不需要乘任何输入。因为 z = W·a + b 对 b 求导是 1,链式法则传下来就只剩 δ。这一点新手经常写错,把偏置梯度和权重梯度搞混。
4. 手把手数值验算:一个能跑通的最小例子
4.1 设定一个极简网络和初始值
光看公式容易飘,我们用一个具体数字例子把它落地。为了能手算,网络设得极小:输入是二维向量 x = [1.0, 2.0],没有隐藏层,直接一个线性层加 sigmoid 激活,输出一个标量,损失用均方误差。
参数:W = [0.5, -0.3](对应两个输入),b = 0.1。真实标签 y = 1.0。
前向第一步,算线性输出:z = 0.5×1.0 + (-0.3)×2.0 + 0.1 = 0.5 - 0.6 + 0.1 = 0.0。第二步,激活:ŷ = σ(0) = 0.5。第三步,损失:L = ½(0.5 - 1.0)² = ½ × 0.25 = 0.125。
现在开始反向。种子:∂L/∂ŷ = ŷ - y = 0.5 - 1.0 = -0.5。激活导数:σ'(z) = σ(z)(1-σ(z)) = 0.5 × 0.5 = 0.25。所以 δ = ∂L/∂z = -0.5 × 0.25 = -0.125。
权重梯度:∂L/∂W = δ · x = [-0.125×1.0, -0.125×2.0] = [-0.125, -0.25]。偏置梯度:∂L/∂b = δ = -0.125。
4.2 用数值微分交叉验证
手推的结果对不对?用数值微分验一下。数值微分的思路是给参数加一个极小扰动 ε,看损失变化多少。取 ε = 0.0001,对 W 的第一个分量:W 变成 [0.5001, -0.3],重新前向,z = 0.5001×1.0 - 0.6 + 0.1 = 0.0001,ŷ = σ(0.0001) ≈ 0.500025,L ≈ ½(0.500025-1)² ≈ 0.1249875。原损失 0.125,差值约 -0.0000125,除以 ε 得 -0.125。和解析梯度 -0.125 完全吻合。
这个交叉验证的方法非常实用。当你自己实现反向传播、怀疑某层梯度写错时,用数值微分对几个参数抽查一下,能快速定位问题。代价是每个参数都要额外做两次前向,所以只适合调试小网络,不能用于正式训练。
4.3 一次参数更新后的效果
取学习率 η = 0.1,更新参数:W_new = [0.5, -0.3] - 0.1×[-0.125, -0.25] = [0.5125, -0.275]。b_new = 0.1 - 0.1×(-0.125) = 0.1125。
重新前向:z = 0.5125×1.0 - 0.275×2.0 + 0.1125 = 0.5125 - 0.55 + 0.1125 = 0.075。ŷ = σ(0.075) ≈ 0.5187。损失 L ≈ ½(0.5187-1)² ≈ 0.1158。相比原来的 0.125,损失下降了。这就是一次完整的“前向算损失、反向算梯度、更新参数”的闭环。
虽然这个例子简单到只有一层,但多层网络的反向过程只是把这个流程重复叠加。理解了单层的“种子→δ→参数梯度”,多层无非是 δ 在层间传递时多乘一个权重转置和激活导数。
5. 实操中绕不开的坑与排查思路
5.1 梯度消失与梯度爆炸的根源
把反向公式连起来看:δ1 = (W2ᵀ·((W3ᵀ·δ3) ⊙ σ'(z2))) ⊙ σ'(z1)。每往前传一层,就多乘一次权重矩阵和一次激活导数。如果激活导数长期小于 1(比如 sigmoid 的导数最大只有 0.25),多层连乘后梯度会指数级衰减,这就是梯度消失。反过来,如果权重初始化过大,连乘后梯度指数级增长,就是梯度爆炸。
排查方法很直接:在训练循环里打印每一层梯度的范数。如果发现靠近输入的层梯度范数比输出层小好几个数量级,基本就是梯度消失;如果范数一路飙升甚至变成 NaN,就是爆炸。对应的缓解手段包括:用 ReLU 替代 sigmoid、合理的权重初始化(如 He 初始化)、加批归一化、用残差连接给梯度开一条“高速公路”。
5.2 常见问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 损失不下降 | 学习率过小、梯度消失 | 打印各层梯度范数,调大学习率或换激活函数 |
| 损失震荡发散 | 学习率过大、梯度爆炸 | 减小学习率,加梯度裁剪 |
| 部分神经元输出恒为0 | ReLU 死亡 | 换 LeakyReLU,检查初始化 |
| 梯度与数值微分对不上 | 公式写错、维度对不齐 | 用数值微分抽查单个参数 |
| 训练正常但验证差 | 过拟合 | 加正则、Dropout、早停 |
5.3 自己实现反向传播时的三个经验
第一,先写标量版本再写矩阵版本。标量版本逻辑清晰、容易调试,确认无误后再用矩阵批量实现,能避免大量维度错误。第二,每一层都做梯度检查。不要等整个网络写完再查,写完一层就用数值微分验一层,问题定位成本最低。第三,注意转置的方向。前向是 W·a,反向传误差是 Wᵀ·δ,参数梯度是 δ·aᵀ,这三个转置关系搞混是最高频的 bug 来源。
提示:梯度检查时记得关掉 Dropout 和批归一化的训练模式,否则数值微分的结果会因为随机性对不上,白白浪费时间。
6. 从公式到框架:反向传播的工程化延伸
6.1 自动微分为什么能取代手推
现代框架用的是自动微分,它把前向的每个运算记录成计算图,反向时按图自动应用链式法则。你写的loss.backward()背后,就是框架沿着计算图把上面那套 δ 传递和参数梯度计算自动执行了一遍。自动微分分两种模式:前向模式和反向模式。神经网络参数多、输出是标量,反向模式的计算量只和参数数量成正比,效率远高于前向模式,所以主流框架都用反向模式。
理解手推过程的价值在于:当自动微分出问题时(比如自定义了一个不可导的操作、梯度传成了 None),你能知道该从哪里查。框架报的“梯度为 None”往往意味着某个操作没有定义反向,或者计算图在中间被意外断开。
6.2 计算图视角下的自定义层
如果你要写一个自定义层,本质上就是实现两个函数:前向forward返回输出并保存反向需要的中间量,反向backward接收来自后一层的梯度、计算输入梯度和参数梯度。输入梯度要继续往前传,参数梯度交给优化器。这个接口设计和我们手推时的“δ 传递 + 参数梯度”完全对应。
举个实际场景:实现一个自定义的激活函数。前向时保存输入 z,反向时用 z 算出导数再乘传来的梯度。只要这两步写对,这个层就能无缝嵌入任何网络。很多人觉得自定义层很难,其实难的从来不是接口,而是把那个局部导数推对。
6.3 反向传播之外:它不负责的事
需要澄清一个常见误解:反向传播只负责高效计算梯度,它不负责决定怎么用这些梯度。学习率怎么设、用不用动量、要不要自适应,这些是优化器的职责。反向传播也不保证一定能找到全局最优——它给的是局部梯度,非凸的损失曲面上,梯度下降可能停在局部极小或鞍点。把这两件事分清楚,排查问题时就不会把“优化器调参”和“梯度算错”混为一谈。
我在实际项目里遇到过一个典型情况:损失卡住不动,第一反应是反向传播写错了,查了半天梯度没问题,最后发现是学习率设得太小,梯度方向对但步子太小。所以排查顺序建议是:先确认梯度数值正确(数值微分验证),再调优化器超参,最后才怀疑网络结构。
7. 把公式变成手感:几条个人体会
推反向传播这件事,第一次推完可能还是懵的,这很正常。我的建议是隔几天再推一遍,这次不看任何参考,纯凭记忆从损失函数一路推到第一层权重梯度。能独立推下来,说明真的理解了;推一半卡住,就回去看卡住的那一步用了什么规则。反复两三次,这套东西就会变成肌肉记忆。
另外一个很有效的练习是:拿一个两层网络,故意把某一层的激活导数写错(比如把 ReLU 的导数写成恒为 1),然后观察训练会出什么现象。这种“故意制造 bug 再观察”的方式,比单纯看正确公式更能加深理解,因为你会亲眼看到错误公式导致的梯度异常长什么样。
最后分享一个小技巧:推导时给每个中间量都标上维度。比如 δ3 的维度等于输出神经元数,W3ᵀ·δ3 的维度等于第二层神经元数,和 δ2 对齐。维度对不上,公式一定错了。这个习惯能帮你挡掉一大半的符号错误,比事后调试高效得多。