反向传播与自动微分:PyTorch中梯度计算的原理与实践

发布时间:2026/10/22 23:26:09
反向传播与自动微分:PyTorch中梯度计算的原理与实践 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_gradPyTorch 实现反向传播的核心就是torch.Tensor上加了一个requires_grad的开关。你只需要把需要求梯度的张量设置成requires_gradTruePyTorch 就会在正向计算过程中自动记录计算图。这背后的逻辑是PyTorch 定义了一个Function类体系每次张量运算比如torch.mm、w * x都会生成一个对应的反向函数节点。你调用backward()时PyTorch 从输出张量出发沿着计算图的反向边逐节点调用这些反向函数最终算出所有叶子节点即requires_gradTrue的输入参数的梯度。这里有个新手最容易搞混的点requires_grad是我要不要算这个张量的梯度的意思。如果你把输入数据的requires_grad也设为TruePyTorch 会额外计算输入对损失的影响——这在做对抗样本、梯度归因分析时才有用。正常训练时只有模型参数需要开requires_grad数据不需要。3.2 计算图是动态构建的PyTorch 和 TensorFlow 1.x 一个显著不同点在于PyTorch 的计算图是动态的每次正向传播都会重新构建一张图。这意味着你可以在不同的 iteration 里使用不同的网络结构比如循环神经网络里序列长度可变调试的时候可以随时打印中间结果不用先把图编译好条件分支、循环这些 Python 原生控制流都可以直接用因为图是跟着代码走的代价是动态图构建会有一些额外开销但绝大多数实际场景下这个开销完全可接受。为了帮助你理解看一段最小示例import torch x torch.tensor(2.0, requires_gradFalse) # 输入数据不需要梯度 w torch.tensor(3.0, requires_gradTrue) # 模型参数需要梯度 b torch.tensor(1.0, requires_gradTrue) # 偏置需要梯度 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_gradTrue) b1 torch.zeros(8, requires_gradTrue) w2 torch.randn(8, 1, requires_gradTrue) b2 torch.zeros(1, requires_gradTrue) # 学习率 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(fstep {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(), lr0.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(fstep {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 bw 形状[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_gradTrue或经过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}, fgrad_std{param.grad.std().item():.6f}, fgrad_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_gradTrue) 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 把这套复杂机制封装得极其好用但封装不代表你可以完全忽略底层的逻辑。当你真正理解梯度从哪里来、到哪里去碰到问题才不至于束手无策。这一节的代码量不大建议你把每个例子都跑一遍然后试着把网络改成三层、四层观察梯度变化规律。亲手试过之后你对深度学习的理解会进入一个完全不同的层次。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询