PyTorch自动求导深度解析:从计算图到反向传播实战 别人教 autograd多半上来就给你扔一个链式法则的公式然后写两行代码就完事了。今天这篇我们换个搞法从头聊清楚 PyTorch 的 autograd 到底在背后偷偷干了什么帮你建立计算图的直觉再带你实操几个最经典、最容易踩坑的场景。不管你手里的 PyTorch 是哪个版本原理层面的东西都通用学会了之后看别人的代码、自己写训练循环都会踏实很多。这个教程适合两类人第一类是刚入门深度学习被backward()、requires_grad这些词弄得头疼的初学者第二类是能跑通模型但一遇到梯度怎么没更新为什么报 in-place 操作的错就懵的实践选手。我会尽量把每一个概念都和真实代码对上你完全可以照着敲一遍。1. 先说清楚autograd 到底帮我们解决了什么问题1.1 没有 autograd 的年代手动算梯度有多痛苦想象一个非常简单的函数y x1 * x2 sin(x2)如果让你手动对x1、x2求偏导你大概还能写出来dy/dx1 x2 dy/dx2 x1 cos(x2)可一旦到了神经网络里事情就失控了。一个三层网络加一个交叉熵损失参数就是几十上百个每层还嵌着非线性激活函数你每改一次网络结构所有梯度公式都要重新推一遍。这个过程的痛苦程度和你手工解四元一次方程组差不多——不是不会是根本算不完而且极容易算错一个符号然后整个训练就崩了。有一次我在初学阶段试着手动实现过一个两层的反向传播推导就花了整整一页纸中间还把某一个W的转置漏掉了。梯度一下变大一下变小那一整天都在和自己的公式搏斗最后也没能找到稳定的学习率。后来我彻底换到loss.backward()那个感受真的是——从煤油灯时代直接跳到电灯时代。1.2 数值微分、符号微分、自动微分为什么最终选择自动微分先花一分钟把这三种常见做法拆开看你就知道 autograd 的设计为什么高明。数值微分实现最简单就是拿(f(xh)-f(x-h))/2h去近似导数。但它的精度受到h取值影响选大了误差大选小了浮点数精度又出问题。而且在神经网络里每次求梯度都要重新把所有参数往前跑一遍代价太高。符号微分就是我们初高中学的那种用工具比如 SymPy直接推导出导函数的表达式。问题在于多层网络堆出来的表达式会极度膨胀。你看着那个几千项甚至几万项的公式别说存了光是被浏览器打开都要卡半天。工程上根本不可行。自动微分Automatic Differentiation走的是第三条路不直接推一个完整表达式而是在程序实际运行计算的过程中把每一步运算都记录下来搭成一张计算图之后从最终输出开始沿着图把链式法则一步步用起来。比如你做z x * y它就记住这里发生过乘法等到反向传播时直接告诉你x和y分别收到了什么梯度。这种方式兼顾了精度和效率也是神经网络这种参数海量、输出单一场景的天然匹配方案。PyTorch 的 autograd 采用的正是反向模式的自动微分。通俗理解就是从最终的loss这个节点出发往输入参数的方向反向扫一遍把梯度挨个发到有requires_gradTrue的节点上。2. 计算图autograd 的心脏2.1 前向传播时autograd 在背后悄悄建了一棵树在 PyTorch 里只要你把某个张量设置为requires_gradTrue接下来所有跟它相关的运算都会被盯上。来看一段最简单的代码import torch x torch.tensor(2.0, requires_gradTrue) y torch.tensor(3.0, requires_gradTrue) z x * y x print(z) # tensor(8., grad_fnAddBackward0)注意到输出里的grad_fnAddBackward0了吗这个grad_fn就是 autograd 给你记下的一笔账这个z不是凭空冒出来的它是由一个加操作产生的。你再细看这个加操作的两个输入来源分别是x*y乘法操作和x本身。这段前向计算过程中 PyTorch 所建立的记录结构就是我们常说的计算图。它是动态图也就是说不是先定义好一个固定模板再往里填数据而是每执行一行代码图就实时多长出几个节点。这带来的好处是你可以用普通的 Python 条件语句、循环来控制计算流程网络结构写起来非常灵活这也是 PyTorch 在研究和快速试验时特别顺手的原因之一。生活里找个类比的话前向传播就好比一个快递员边走边在签收单上记这个包裹从哪来、要送到哪。反向传播时他只需要把签收单倒过来读就能知道每个环节的误差应该退回给哪一站。2.2 requires_grad、grad_fn、is_leaf 这三个属性务必分清这三个概念是理解 autograd 的最核心基础。requires_grad表示该张量是否需要梯度。只有为True的节点会在反向传播中收到梯度。grad_fn表示该张量是通过什么运算产生出来的。如果是一个用户直接创建的张量它的grad_fn通常是None。is_leaf表示这个张量是不是叶子节点。叶子节点指的是由用户直接创建、requires_gradTrue的张量或者requires_gradFalse的普通张量。动手看一组真实输出比背定义有效得多import torch a torch.tensor(2.0, requires_gradTrue) b torch.tensor(3.0, requires_gradTrue) c a * b d c 1 for name, tensor in [(a, a), (b, b), (c, c), (d, d)]: print(f{name}: requires_grad{tensor.requires_grad}, fgrad_fn{tensor.grad_fn}, is_leaf{tensor.is_leaf})运行后你会看到a: requires_gradTrue, grad_fnNone, is_leafTrue b: requires_gradTrue, grad_fnNone, is_leafTrue c: requires_gradTrue, grad_fnMulBackward0, is_leafFalse d: requires_gradTrue, grad_fnAddBackward0, is_leafFalse也就是说a和b是你亲手创建的所以是叶子c和d是通过运算得到的所以不是叶子它们带着grad_fn记录了自己的身世。注意默认情况下反向传播之后只有叶子节点的grad属性会被填充非叶子节点的梯度会被丢弃。这不是省内存的问题而是绝大多数训练场景根本不需要中间变量的梯度你要的是参数的梯度。2.3 中间节点的梯度去哪了如何查看既然中间节点的grad会被丢弃那如果我就是想看看某一层输出的梯度用来调试网络呢答案是使用retain_grad()方法显式要求 PyTorch这个中间结果我要留作后用。import torch x torch.tensor(1.0, requires_gradTrue) w torch.tensor(2.0, requires_gradTrue) hidden x * w # 非叶子节点 out hidden ** 2 hidden.retain_grad() out.backward() print(hidden.grad) # 不为 None而是 4.0 print(x.grad) # 8.0这里hidden.grad是 4.0因为它对out的梯度就是2*hidden在hidden2的时候等于 4最终x.grad 4 * w 8。我在实际调试中用过几次retain_grad()它确实能帮你验证某一层梯度是否符合数学预期。但我必须提醒一句别一上来就给所有中间层都加retain_grad()内存会涨得非常快。仅在查 bug 时临时用一下排查完就删掉。3. backward()反向传播到底做了什么3.1 标量可以无参直接 backward但向量/矩阵不行先看最常规的用法loss.backward()。这里的loss绝大多数情况下是一个标量也就是一个 0 维张量因为我们要最小化的就是单个数值比如交叉熵损失的平均值。如果backward()被调用在一个向量或矩阵上事情就没那么简单了。比如import torch x torch.tensor([1.0, 2.0], requires_gradTrue) w torch.tensor([2.0, 3.0], requires_gradTrue) z x * w此时z是一个长度为 2 的向量。如果你直接执行z.backward()RuntimeError: grad can be implicitly created only for scalar outputs这个报错新手都见过。它的意思很简单z有多个输出维度我只知道z的值但你并没有告诉我损失具体是怎么由z里的各个分量组合出来的所以我无法确定每个分量对最终损失的权重。那怎么解决一种做法是先把它变成一个标量再反传z.sum().backward()这个操作等价于告诉 autograd我的最终损失取的是z各分量的和。另一种做法是显式传入和z形状一致的上游梯度权重z.backward(torch.ones_like(z)) # 等价于 z.sum().backward()你传入的这个torch.ones_like(z)其实就是在告诉 autograd每个分量对最终损失的敏感度都是 1。如果你想定不同权重完全可以传入[1.0, 10.0]这样的张量。3.2 链式法则在计算图上是如何跑的反向传播的本质就是链式法则的工程化执行。你肯定知道这个公式dz/dx dz/dy * dy/dx在 autograd 里z是最终节点y是中间节点x是叶子节点。反向传播就是从z出发先算出dz/dy然后乘上dy/dx一路乘到所有叶子节点。每一层的操作只需要关心我收到的上游梯度乘上我自己这个操作对输入的本地梯度然后传给下游。用一段非常简单的代码验证一下import torch x torch.tensor(2.0, requires_gradTrue) y x ** 3 y.backward() print(x.grad) # tensor(12.)手动算一下dy/dx 3 * x^2在x2处等于3*412完全一致。这个例子虽然简单却是理解反向传播就是把链式法则按图执行的最佳起点。这里我不展开讲雅可比矩阵的数学细节但你要知道一件事在 autograd 内部处理多输入多输出时它本质上就是在做雅可比向量积VJP。你不需要手动构建任何矩阵只需要提供给最终的梯度向量所有中间传递都由框架自动完成。3.3 梯度是累加的不是覆盖的这是无数新手掉进去的坑。看看下面这段代码import torch x torch.tensor(1.0, requires_gradTrue) w torch.tensor(2.0, requires_gradTrue) for i in range(3): loss (x * w) ** 2 loss.backward() print(fstep {i}, w.grad {w.grad})你会发现w.grad依次输出 2、4、6——它是累加的每次backward()都会把新的梯度加到已经存在的grad上而不是覆盖。PyTorch 这样设计是为了照顾某些需要梯度累加的场景比如梯度累积训练模拟大批量。但对于最常用的 mini-batch 训练你必须每次迭代清零一次梯度optimizer.zero_grad() # 或者手动 w.grad.zero_() loss.backward() optimizer.step()如果你忘了zero_grad()每轮 batch 的梯度就会叠加在一起等价于你用一个越来越大的 batch 去更新参数训练曲线会乱成一片。我把一个经验规则放在这里如果你发现 loss 不下降先检查是不是梯度没清零。4. 一个都不能少的核心 API 与实战坑位4.1 torch.no_grad()推理、验证阶段保平安训练模型时我们需要反向传播因此每前向一次都得保留计算图但验证、测试时我们只拿结果不需要任何梯度这时候就该用torch.no_grad()包住前向过程import torch model torch.nn.Linear(4, 1) with torch.no_grad(): val_output model(val_data)它的作用是把当前上下文中的计算图记录功能暂时关掉不再新建任何grad_fn省下大量显存也加速推理。有一次我用一个稍大点的模型做验证忘了加no_grad()结果显存一直在涨每个 batch 的中间激活值都被记进图里等到 Val Loss 计算完才释放。训练到一半 GPU 直接 OOM我排查了很久才发现是这个原因。补充一句model.eval()和torch.no_grad()是两个不同维度的东西。eval()改的是 BatchNorm、Dropout 等模块的前向行为no_grad()管的是是否记录梯度。二者通常一起出现但概念上不要混在一起。4.2 in-place 操作反向传播最恨的操作之一x 1、x[0] 0、x.add_(1)这类会直接修改原张量数据的操作都属于 in-place 操作。为什么 autograd 不喜欢它因为计算图在反向传播时需要用到本地的旧值来计算梯度。你把原值就地改了相当于警察查案时把案发现场的证据提前破坏了梯度自然算不准所以 PyTorch 干脆直接拦截。最常见的报错是RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.这种错误我最初遇到时也是一脸懵后面才发现是自己在训练循环里写了个weights lr * grad把叶子变量给就地改了。实际使用中我的建议很明确对设置了requires_gradTrue的张量尽量避免 in-place 操作。想更新参数用optimizer.step()或weight.data.add_()想生成新值就x x 1别用x 1。这两个看似等价但在 autograd 的世界里一个是安全的新节点一个是对原节点的破坏。4.3 detach()把张量从计算图里摘出来detach()的作用是返回一个新的张量这个张量和原来的张量共享数据存储但requires_gradFalse并且完全和当前计算图脱离关系。最常见的两大用途一是模型输出转 numpy。你训练时拿到了output但output还在图里直接output.numpy()会报错。先output.detach().numpy()就行。output model(x) output_np output.detach().cpu().numpy()二是固定特征只训练下游模块。比如迁移学习里想用预训练模型当特征提取器不再回传梯度给前面的层就可以把前向结果detach()出来再往后面传。还有一个细节容易忽略detach()出来的张量如果继续参与其他运算只要被requires_gradTrue的张量带起来它还是可以继续建图的。它不是彻底屏蔽梯度只是从这一处剪断。与no_grad()的差别我也说一下no_grad()是一个上下文管理器它禁止的是这段代码范围内所有张量建图detach()针对的是单个张量把某个具体的张量从现有图里摘出来。4.4 retain_graphTrue什么时候你不得不用它默认情况下backward()执行完之后会释放这整张计算图这是为了节省内存。但如果你想对同一个计算图执行两次反向传播第二次就会报错。RuntimeError: Trying to backward through the graph a second time (or directly access saved tensors after they have already been freed).多发生在 GAN 那种先更新生成器、再更新判别器或者多损失累加的场景。解决办法是第一次backward(retain_graphTrue)loss_d.backward(retain_graphTrue) loss_g.backward()每加一个retain_graphTrue这个图就要多存活一次内存开销是实打实的。所以能用一次backward完成的任务就不要拆成两次。如果实在要拆另一个办法是把两个 loss 相加之后一次性反向(total_loss).backward()这在很多场景下和分别 backward(retain_graphTrue) 的效果是等价的且内存压力小得多。我自己在写多任务模型时倾向于优先合并 loss。5. 实操对比手动梯度 vs autograd5.1 线性回归的手动梯度版本先造一批带噪声的线性数据import torch torch.manual_seed(0) x torch.linspace(-2, 2, 200) y_true 2 * x 1 0.2 * torch.randn(x.shape)手动推导最小二乘损失对参数的梯度。定义y_pred w * x bloss mean((y_pred - y_true)^2)那么dloss/dw mean(2 * (y_pred - y_true) * x) dloss/db mean(2 * (y_pred - y_true))手动实现训练循环w torch.tensor(0.0, requires_gradFalse) b torch.tensor(0.0, requires_gradFalse) lr 0.1 for epoch in range(100): y_pred w * x b loss ((y_pred - y_true) ** 2).mean() grad_w (2 * (y_pred - y_true) * x).mean() grad_b (2 * (y_pred - y_true)).mean() w - lr * grad_w b - lr * grad_b if epoch % 20 0: print(fepoch {epoch}, loss {loss.item():.4f})你能跑通而且结果也不错。但问题是每多一层、每换一次损失函数这套手动梯度推导就要重来而且推导中的任何一个小错误都很难被察觉。一个常见的错误是把.mean()写成.sum()或者漏乘了一个x训练结果就会变得诡异。5.2 换成 autograd 的版本现在把requires_gradTrue打开把手动梯度全部交给backward()w torch.tensor(0.0, requires_gradTrue) b torch.tensor(0.0, requires_gradTrue) lr 0.1 for epoch in range(100): y_pred w * x b loss ((y_pred - y_true) ** 2).mean() loss.backward() with torch.no_grad(): w - lr * w.grad b - lr * b.grad w.grad.zero_() b.grad.zero_() if epoch % 20 0: print(fepoch {epoch}, loss {loss.item():.4f})注意三个细节第一更新参数时我把w - lr * w.grad包进torch.no_grad()防止更新过程又串进计算图第二更新完立刻zero_()清梯度第三如果后面接的是optimizerzero_grad()和step()几乎总是成对出现。和手动版本相比autograd 版本的正确性完全由框架保障你只需要把精力放在定义网络结构上。这也是为什么我会强烈建议初学者不要为了感受底层而长期停留在手写梯度上。手写一两次帮助建立直觉就够之后放心交给 autograd。5.3 多一个隐藏层自动求导优势更加明显再加深一层在不手写任何推导的情况下随时可以扩展import torch.nn as nn class TwoLayerNet(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(1, 8) self.fc2 nn.Linear(8, 1) def forward(self, x): h torch.relu(self.fc1(x)) return self.fc2(h) model TwoLayerNet() optimizer torch.optim.SGD(model.parameters(), lr0.05) for epoch in range(200): y_pred model(x.unsqueeze(1)) loss ((y_pred - y_true.unsqueeze(1)) ** 2).mean() optimizer.zero_grad() loss.backward() optimizer.step()如果这要手动写梯度你光写fc1和fc2的反向传播就要好几行而且中间还要处理 ReLU 的分段。autograd 帮你省去的就是这一大堆容易出错又重复的样板活。我还做过一件事证明 autograd 的正确性在网络里加入手算梯度然后和backward()出来的结果对比。只要对得上就说明自己对链式法则的理解是准确的。如果你对某一块梯度公式没把握用这种对照实验去验证是很好的学习方法。6. 高频问题速查表与个人避坑心得6.1 常见报错和现象一次讲全现象 / 报错常见原因解决思路a leaf Variable that requires grad is being used in an in-place operation对叶子节点做了 in-place 修改避免就地修改用x x 1替代x 1grad can be implicitly created only for scalar outputs对非标量直接backward()没传梯度权重用loss.sum().backward()或loss.backward(torch.ones_like(loss))Trying to backward through the graph a second time对同一计算图重复backward第二次传retain_graphTrue或把多个 loss 合并后一次反传验证阶段显存暴涨前向没有包torch.no_grad()推理和验证用with torch.no_grad():训练 loss 一会儿涨一会儿跌不稳定可能是梯度没清零检查optimizer.zero_grad()是否在每轮训练前调用梯度变成了 NaN学习率太大或者计算里出现了数值不稳定的操作降低学习率、检查输入是否有较大的异常值、适当做梯度裁剪某个参数的grad一直为None该参数没有参与本次计算图或requires_gradFalse查看网络结构确认参数被用在前向里并设置requires_gradTrue6.2 调试 autograd 问题的四件套我遇到梯度问题时会先打印四个东西定位print(x.requires_grad) print(x.is_leaf) print(x.grad_fn) print(x.grad)requires_grad告诉我这个节点是否被追踪is_leaf告诉我它是不是参数节点grad_fn告诉我它是由什么操作产生的grad告诉我当前拿到或没拿到梯度。这四步走完大多数问题都能定位到具体环节。6.3 一些我踩过之后沉淀下来的经验第一写训练循环时把optimizer.zero_grad()、loss.backward()、optimizer.step()当作一组固定动作每次写完检查这一组是否都在。第二调试时尽量从小模型起步。我在排查梯度 bug 时总是先缩小到单层、单样本用retain_grad()看每层输出梯度。等数字对上了再放大规模。这比直接在一个大网络上瞎试高效得多。第三能不用retain_graphTrue就不要用。它在多 loss 场景里确实省事但内存开销很现实。分配好两个 loss 的计算方式能合并就合并。第四detach()和torch.no_grad()要分清楚场景一个是对单张量切断链路一个是对整段代码关闭建图。推理时优先用no_grad()。最后说一点个人体会。以前我总觉得用框架的自动求导是不是说明我数学功底不行后来才明白autograd 的价值恰恰在于把复杂且机械的链式法则推导交给机器把注意力还给真正重要的网络设计和实验思路。这是工具该有的样子。新手阶段我建议你花半天时间把这篇里的代码都照着敲一遍重点看grad_fn和is_leaf的变化再手动推一次简单函数的梯度和 autograd 对答案。等你建立了计算图直觉后面再去看那些花哨的魔改训练技巧都不会觉得虚。别背公式去感受数据在图上流动的方向这才是真正理解反向传播的方式。