1. 从链式法则到反向传播:为什么这一步必须学扎实
做深度学习也好几年了,带过不少新人。每次讲到 Back Propagation(反向传播)这一节,我都要专门停下来,因为这是一个分水岭——懂不懂反向传播,直接决定你是"调参侠"还是真的入门了深度学习。现在框架都帮你把梯度算好了,你调个loss.backward()就能训练模型,但要是心里没数,出了 NaN、梯度爆炸、不收敛这些问题,你连排查的方向都没有。
这门 PyTorch 深度学习实践系列写到第四篇,前面的内容基本都在讲线性模型、梯度下降这些基础概念。这一篇我们专门把反向传播拆开揉碎。先给你一个结论:反向传播本质上就是高数里的链式法则,只不过神经网络层数一多,手动算偏导不现实,于是 PyTorch 用一套叫 "autograd"(自动微分)的机制帮你把所有梯度算得明明白白。
这篇文章适合两类人看:一类是把 PyTorch 的tensor、requires_grad这些 API 用得很懵的初学者;另一类是已经能跑通简单模型,但想搞清楚计算图内部到底发生了什么的学习者。我会从数学原理讲到代码实现,再拆解一个全连接网络的反向传播全过程,最后把我在实际工作中踩过的坑都整理出来——这些坑,官方文档里可不会写。
2. 反向传播的核心原理:计算图与链式法则
2.1 为什么不能直接算偏导
先想一个最简单的场景。假设我有一个函数y = x * w,我想优化w,那就需要求∂y/∂w。这很简单,直接算就行。但真实神经网络不是这种单层结构,而是一个嵌套函数:
z = w3 * (w2 * (w1 * x + b1) + b2) + b3如果我要更新w1,求∂z/∂w1,就要对 z 关于 w1 求偏导。由于 z 是通过 w3、w2 等一层层传下来的,根据链式法则:
∂z/∂w1 = ∂z/∂a2 * ∂a2/∂a1 * ∂a1/∂w1其中a1是第一层输出,a2是第二层输出。你会发现一个关键问题:如果把每一层的计算看作一个节点,这些节点之间形成了有向无环图(DAG)。正向传播是从输入到输出,而反向传播则是从输出往输入方向逐层求梯度。中间层的梯度可以被上游复用,这就是链式法则带来的计算优势——不用对每个参数单独从输出开始求一遍,而是顺着计算图倒着走一遍,所有参数梯度都能拿到。
为什么叫"反向"?因为计算的方向和组织正向推理的方向刚好反过来。正向传播时,数据从输入流向输出;反向传播时,梯度从损失函数流回每个参数。这也是计算图存在的意义:它把正向计算过程中的中间结果保留下来,供反向传播时使用。
2.2 一个三层复合函数的完整手算过程
这里我不扯虚的,拿一个非常具体的三层函数手推一遍。假设:
x = 2 w1 = 3, b1 = 1 w2 = 2, b2 = 0第一层:a1 = w1 * x + b1 = 3 * 2 + 1 = 7
第二层:a2 = w2 * a1 + b2 = 2 * 7 + 0 = 14
假设最终损失就是输出本身:loss = a2 - target = 14 - 10 = 4
现在更新w2,求∂loss/∂w2。这个直接从第二层就能算:因为loss = w2 * a1 + b2 - target,所以∂loss/∂w2 = a1 = 7。
再更新w1,情况就不一样了。loss对w1没有直接可见的求导关系,必须走链式法则:
∂loss/∂w1 = ∂loss/∂a2 * ∂a2/∂a1 * ∂a1/∂w1逐项看:
∂loss/∂a2 = 1(因为 loss 对 a2 的导数是 1)∂a2/∂a1 = w2 = 2∂a1/∂w1 = x = 2
所以:
∂loss/∂w1 = 1 * 2 * 2 = 4只要层数再多几层,比如 50 层的网络,你手动算一次就会崩溃。但注意观察这个过程:∂loss/∂a2算出来后,传给下一层时可以直接复用;每层的反向过程只需要知道本层的输入、输出和上游传回来的梯度。这种逐层传递的模式,就是自动微分的雏形。
2.3 链式法则为什么高效
一个直观对比:如果不用链式法则,对每个参数单独求导,50 层网络里每个参数的梯度都要从 loss 一路求到那一层,复杂度是 O(N²)。而使用链式法则从后往前算,每个中间层只算一次梯度,复杂度降到 O(N)。
我常用一个邮局寄包裹的类比解释这件事:正向传播就像你把包裹从始发站发往终点站,每一站都记录了包裹的信息;反向传播则像回执单从终点站送回始发站,每一站只需要看自己记录的信息加上上一站传来的回执,不用把整个路程重新走一遍。所以计算图里"保存中间结果"这个动作,本质上就是每站留下的记录。
3. PyTorch 的自动微分机制:autograd 是怎么运作的
3.1 Tensor 与 requires_grad
PyTorch 实现反向传播的核心就是torch.Tensor上加了一个requires_grad的开关。你只需要把需要求梯度的张量设置成requires_grad=True,PyTorch 就会在正向计算过程中自动记录计算图。
这背后的逻辑是:PyTorch 定义了一个Function类体系,每次张量运算(比如torch.mm、w * x)都会生成一个对应的反向函数节点。你调用backward()时,PyTorch 从输出张量出发,沿着计算图的反向边逐节点调用这些反向函数,最终算出所有叶子节点(即requires_grad=True的输入参数)的梯度。
这里有个新手最容易搞混的点:requires_grad是"我要不要算这个张量的梯度"的意思。如果你把输入数据的requires_grad也设为True,PyTorch 会额外计算输入对损失的影响——这在做对抗样本、梯度归因分析时才有用。正常训练时,只有模型参数需要开requires_grad,数据不需要。
3.2 计算图是动态构建的
PyTorch 和 TensorFlow 1.x 一个显著不同点在于:PyTorch 的计算图是动态的,每次正向传播都会重新构建一张图。这意味着:
- 你可以在不同的 iteration 里使用不同的网络结构(比如循环神经网络里序列长度可变)
- 调试的时候可以随时打印中间结果,不用先把图编译好
- 条件分支、循环这些 Python 原生控制流都可以直接用,因为图是跟着代码走的
代价是动态图构建会有一些额外开销,但绝大多数实际场景下这个开销完全可接受。
为了帮助你理解,看一段最小示例:
import torch x = torch.tensor(2.0, requires_grad=False) # 输入数据不需要梯度 w = torch.tensor(3.0, requires_grad=True) # 模型参数需要梯度 b = torch.tensor(1.0, requires_grad=True) # 偏置需要梯度 y = w * x + b # 正向传播 loss = (y - 10) ** 2 loss.backward() # 反向传播 print("dy/dw:", w.grad) # 2 * (y - 10) * x = 2 * (7 - 10) * 2 = -12 print("dy/db:", b.grad) # 2 * (y - 10) * 1 = -6注意,我这里把loss写成(y - 10) ** 2而不是y - 10,是为了模拟真实的损失函数场景。加了平方后,链式法则多了一层2 * (y - 10)。你去看w.grad输出,正好是-12,和手算完全一致。
3.3 backward() 到底做了什么
很多人用loss.backward()用得贼顺手,但没想过一个问题:backward()为什么不需要传参数?
这是因为loss是一个标量。如果loss是向量或矩阵,backward()需要传入一个和它形状一致的"上游梯度",表示"这个多维输出的每个分量对最终目标的贡献权重"。而标量的上游梯度默认就是 1,所以不用传。
什么情况下需要手动传gradient参数?比如你有一个中间层输出out的形状是[batch, seq_len, hidden],你想让梯度按照某个权重矩阵加权回传,这时候就要传一个同形状的张量。这个用法比较高级,但我在做多任务学习、自定义损失加权时经常用到。
再补充一个非常容易踩的坑:默认情况下,梯度是"累加"的。你调用一次backward(),梯度会加到.grad上,而不会自动清零。所以在训练循环里必须手动调用optimizer.zero_grad()。为什么设计成累加?因为某些场景(比如梯度累积模拟更大 batch)正需要这种行为。但如果你忘了清零,梯度会在迭代之间不断叠加,loss 不下降反而上升,这是新手最常遇到的"诡异问题"之一。
4. 反向传播在真实网络中的实战表现
4.1 搭建带隐藏层的全连接网络
光看标量例子还不够,我们直接在 PyTorch 里搭一个含隐藏层的全连接网络,把反向传播过程完整走一遍。这个例子我故意不用nn.Module封装,而是用纯张量操作实现,这样每一步在做什么都清清楚楚。
import torch import torch.nn.functional as F # 固定随机种子方便复现 torch.manual_seed(42) # 构造数据:100 个样本,每个样本 10 个特征 X = torch.randn(100, 10) y = torch.randn(100, 1) # 模型参数:隐藏层 10 -> 8,输出层 8 -> 1 w1 = torch.randn(10, 8, requires_grad=True) b1 = torch.zeros(8, requires_grad=True) w2 = torch.randn(8, 1, requires_grad=True) b2 = torch.zeros(1, requires_grad=True) # 学习率 lr = 0.01 for step in range(500): # 正向传播:计算预测值 z1 = X @ w1 + b1 a1 = torch.sigmoid(z1) z2 = a1 @ w2 + b2 # 损失函数:均方误差 loss = ((z2 - y) ** 2).mean() # 反向传播 loss.backward() # 手动更新参数(演示用,实际用优化器) with torch.no_grad(): w1 -= lr * w1.grad b1 -= lr * b1.grad w2 -= lr * w2.grad b2 -= lr * b2.grad # 清零梯度 w1.grad.zero_() b1.grad.zero_() w2.grad.zero_() b2.grad.zero_() if step % 100 == 0: print(f"step {step}, loss: {loss.item():.4f}")我特别强调这个with torch.no_grad()的写法。更新参数这件事不应该影响计算图,如果不加这个上下文管理器,PyTorch 会把参数更新动作也记录进计算图,导致内存泄漏和图越积越深。这是除了忘记zero_grad()之外,第二个高频踩坑点。
看看运行逻辑:正向传播时,X 经过@ w1变成 8 维,再过 sigmoid 激活,然后@ w2变成 1 维预测值。反向传播时,梯度从 loss 出发,先对w2、b2直接求偏导,再穿过 sigmoid 的导数,回到w1、b1。sigmoid 导数的公式是σ(z) * (1 - σ(z)),它天然把梯度压缩在 0~0.25 之间,这也是为什么深层网络用 sigmoid 容易梯度消失——层数一多,梯度乘上好几个 0.25 以下的小数,很快就趋近于零。
4.2 用 nn.Module 重构后的等价实现
纯手工实现能让你看清机制,但日常开发肯定还是用nn.Module封装。我把上面的例子重写一遍:
import torch import torch.nn as nn import torch.optim as optim torch.manual_seed(42) X = torch.randn(100, 10) y = torch.randn(100, 1) class TwoLayerNet(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(10, 8) self.fc2 = nn.Linear(8, 1) def forward(self, x): x = torch.sigmoid(self.fc1(x)) x = self.fc2(x) return x model = TwoLayerNet() criterion = nn.MSELoss() optimizer = optim.SGD(model.parameters(), lr=0.01) for step in range(500): optimizer.zero_grad() pred = model(X) loss = criterion(pred, y) loss.backward() optimizer.step() if step % 100 == 0: print(f"step {step}, loss: {loss.item():.4f}")看到没?训练循环里的四板斧:zero_grad()、正向传播、backward()、step(),顺序不能乱。optimizer.step()内部做的就是我在手工版里w -= lr * w.grad的操作。
一个容易被忽略的细节:nn.Linear默认会对输入做x @ W.T + b的运算,其中W的形状是[out_features, in_features]。这和手工实现的X @ w + b(w 形状[in, out])转置方向相反,但数学本质一样。我见过不止一个新手在这一步被绕晕,调试时以为 forward 写错了,其实只是维度方向理解反了。
4.3 反向传播时的中间激活值为什么要保留
前面提过计算图要保存中间结果供反向使用。换成神经网络的语言来说:中间激活值(比如a1)必须在前向传播时保留下来,因为反向传播求w2梯度时要用到a1。
这也解释了为什么你在训练阶段会看到显存占用明显高于推理阶段——推理模式不需要保留中间激活值,训练模式要全部留着,直到backward()结束才释放。如果网络特别深、batch 特别大,显存经常会因此爆掉。
日常开发中如果碰到"训练时显存不够、推理时没这个问题",首先就要想到是不是中间激活值占用太大。常规解法是减小 batch、使用梯度检查点(gradient checkpointing),或者把部分中间张量用detach()分离开。
5. 反向传播相关的常见问题与排查实录
5.1 一个非常典型的"梯度不更新"案例
我带团队时遇到过一个特别典型的 case。某开发者在训练一个图像分类模型,loss 前几个 epoch 正常下降,然后突然变成 NaN。翻代码发现他把学习率设成了 0.5,而且网络里用了 ReLU 后接了一层没有做归一化的全连接。原因是 ReLU 会让部分神经元输出为 0,梯度在多层累积下数值变得不稳定,学习率一高直接梯度爆炸,loss 变 NaN。
排查思路供参考:
- 先看 loss 是在哪个 step 变成 NaN 的。如果一开始就是 NaN,多半是输入数据里有 NaN 或者标签异常;如果训练到某一步突然 NaN,优先怀疑梯度过大。
- 在
backward()前打印所有参数的梯度范数:torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)可以帮你限制梯度大小。 - 确认激活函数是否存在梯度消失风险。Sigmoid、Tanh 都容易让梯度越来越小,深层网络里换成 ReLU 或 LeakyReLU 更稳。
5.2 常见问题速查表
| 现象 | 大概率原因 | 排查/解决手段 |
|---|---|---|
| loss 不降反升 | 忘了zero_grad(),梯度累加 | 在每次backward()前调用optimizer.zero_grad() |
| 修改参数报错"leaf variable" | 对叶子张量原地操作 | 用with torch.no_grad()包住参数更新 |
| loss 变成 NaN | 学习率过高或梯度爆炸 | 降低学习率,加梯度裁剪,检查输入是否含 NaN |
| 梯度一直为 None | 张量没有requires_grad=True,或经过detach() | 检查参数是否设置requires_grad,是否误用detach() |
| 显存 OOM | 计算图保存的中间激活值太多 | 减小 batch、分批前向传播、用梯度检查点 |
多个 loss 相加后backward()报错 | 某个 loss 不是标量 | 确保每个 loss 都mean()或sum()成标量 |
5.3 梯度检查:一个我每篇代码都会做的小动作
我的习惯是,新写的网络第一次跑之前,先做一次梯度检查。做法很简单:把模型输出backward()之后,打印每一层参数的梯度均值、标准差和最大绝对值。如果某些层梯度全为零,就说明这一层可能没有收到有效梯度信号;如果某些层梯度比其他层大几个数量级,就要警惕训练过程中数值不稳定。
for name, param in model.named_parameters(): if param.grad is not None: print(f"{name}: grad_mean={param.grad.mean().item():.6f}, " f"grad_std={param.grad.std().item():.6f}, " f"grad_max={param.grad.abs().max().item():.6f}")这个习惯帮我省了很多调试时间。有些问题从 loss 曲线上看完全正常,但梯度分布已经坏了——比如某些层梯度爆炸,某些层梯度消失。训练几轮之后回过头来看看梯度分布,比只盯着 loss 靠谱得多。
还有一个偏门的检查:用有限差分验证梯度是否正确。手动给某个参数加上一个微小扰动δ,计算(loss(x + δ) - loss(x)) / δ,和backward()得到的梯度对比。差别应该在 1e-5 量级以内。自己实现自定义网络层时,这个检查几乎是必须做的。
6. 反向传播的扩展认知:从全连接到更复杂的架构
6.1 卷积和循环网络中的反向传播
全连接网络里,反向传播是简单的矩阵求导。但 CNN 里的池化层max pooling反向传播时,梯度只传给前向传播时最大的那个位置,其他位置梯度为零;RNN 里梯度在时间维度上传播,层数等于序列长度,所以更容易梯度消失或爆炸——这也是 LSTM 和 GRU 出现的主要原因。
理解这些的前提是先掌握反向传播的基本范式。一旦你明白"梯度沿计算图反向传播、每个节点只做本地计算"这个抽象,CNN 的unpooling、RNN 的BPTT(沿时间反向传播)都是同一个思想的变体。PyTorch 会自动处理这些细节,但你在设计模型结构时会更有底气,比如知道为什么 RNN 里要用 LSTM 代替裸 RNN、为什么 CNN 的池化层不加偏置。
6.2 反向传播的数值稳定性和优化器
动量(momentum)、RMSProp、Adam 这些优化器,本质上都在做同一件事:对反向传播算出来的梯度做"加工"。SGD 直接用原始梯度,但梯度噪声大、收敛慢。Adam 给每个参数维护了一阶动量(梯度的均值)和二阶动量(梯度平方的均值),相当于对不同参数自适应地调学习率。
所以你会看到,学习率的选择高度依赖优化器。SGD 搭配普通学习率 0.01 可能还行,但换到 Adam 后 0.01 往往偏大,我一般从 1e-3 起步调。反向传播算出的梯度本身没变,变的是怎么消费这些梯度。
另外提一下 weight decay。它是在梯度上额外加一项参数本身的 L2 范数梯度,相当于每轮更新时把权重往零方向拉一点。很多人把它理解成正则化,确实也对,但从反向传播的角度看,它只是在原有梯度上加了一个修正项,实现起来就是w.grad += w * weight_decay。
6.3 从自动求导到自定义反向传播函数
既然原理都清楚了,我也顺手展示一下怎么自定义反向传播。torch.autograd.Function允许你自定义一个操作的正向和反向逻辑。这在实现某些不可微操作(比如梯度采样、Straight-Through Estimator)时特别有用。
import torch class CustomOp(torch.autograd.Function): @staticmethod def forward(ctx, x): # 保存正向传播需要的值,供反向使用 ctx.save_for_backward(x) return x * 2 @staticmethod def backward(ctx, grad_output): x, = ctx.saved_tensors # 对 d(loss)/d(x) 来说,y = 2x, dy/dx = 2 return grad_output * 2 x = torch.tensor(3.0, requires_grad=True) y = CustomOp.apply(x) y.backward() print(x.grad) # 输出 2.0这里ctx.save_for_backward(x)就是在做和框架内部一样的事情——保存中间值供反向传播用。你自己定义反向函数时,必须保证梯度形状和输入形状一致,否则backward()会报形状不匹配的错误。这个报错信息挺吓人的,但本质就一句话:你写的反向函数返回的梯度形状对不上输入。
7. 实操过程中的几条肺腑之言
写到这里,反向传播的原理已经讲透了。我最后想分享几条自己踩过坑之后沉淀下来的经验。
第一条:一定要亲手从头实现一次两层网络的反向传播。不是让你在生产代码里手写,而是拿出一小段代码,朴素地用w -= lr * w.grad的方式训练一个小网络。我每个带的新人,只要肯花一小时做这件事,后面看任何源码都不会发怵。框架封装得太好了,反而让人失去了对核心机制的感觉。
第二条:看计算图的工具要会用。PyTorch 有torchviz可以把计算图可视化,调试时能直观看到每一步的流向。比起瞎猜梯度哪里断了,直接看图高效得多。我自己排查复杂模型问题时,经常把子模块拆出来单独画图看梯度链路。
第三条:反向传播的数值行为直接影响训练稳定性。LeakyReLU 比 ReLU 更抗死神经元、梯度裁剪能兜底、BatchNorm 能平滑梯度传播——这些技巧不是独立的,它们都在调节梯度信号的"质量"。不要只是记住结论,要能理解每个技巧到底在动梯度的哪一部分。
第四条:学会用torch.autograd.set_detect_anomaly(True)。这个开关开着的时候,PyTorch 会在梯度出现 NaN 时帮你定位到具体是哪个操作产生的。虽然它会让训练速度变慢,但在 Debug 阶段远比瞎猜强。找到问题源头后记得关掉,否则线上训练会被拖累。
反向传播是整个深度学习的基石,它的思想也影响了很多其他领域。PyTorch 把这套复杂机制封装得极其好用,但封装不代表你可以完全忽略底层的逻辑。当你真正理解梯度从哪里来、到哪里去,碰到问题才不至于束手无策。这一节的代码量不大,建议你把每个例子都跑一遍,然后试着把网络改成三层、四层,观察梯度变化规律。亲手试过之后,你对深度学习的理解会进入一个完全不同的层次。