news 2026/10/21 12:46:37

深入理解PyTorch autograd:从计算图到backward的梯度传播与实战排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解PyTorch autograd:从计算图到backward的梯度传播与实战排查

如果你已经开始用 PyTorch 写模型,那你大概率每天都会碰到loss.backward()。大多数教程都会告诉你“这是自动求导,你不需要自己算梯度”,但很少有人讲清楚这行代码背后到底发生了什么。结果就是:一旦 loss 是 NaN、梯度突然消失、retain_graph=True报错、或者你想实现一个自定义算子,就只能靠猜。

我一直觉得,autograd 是 PyTorch 最核心、也最容易被当黑盒的一部分。这篇文章就不打算做浮于表面的功能介绍,而是带你从计算图、反向传播、梯度的流向开始,把backward()的每一步拆开看。你会弄明白requires_grad、grad_fn、.grad这几个东西的真实身份,也会理解为什么更新模型参数时要用torch.no_grad()。更关键的是,文末我整理了实际训练里最容易踩的坑和排查技巧,可以当工具文收藏。

1. 先从一张计算图开始:autograd 到底在帮你算什么

1.1 前向传播构建的是一张有向无环图

很多人在初学 PyTorch 时会有个错觉:loss.backward()是一个“自动求导引擎”,在 loss 出来之后才开始“从 loss 出发去计算梯度”。严格来说,这个说法不够准确。autograd 的实际工作方式,是你在执行前向传播的同时就动态构建了一张计算图。

用一段极简代码来说:

import torch x = torch.tensor([2.0], requires_grad=True) w = torch.tensor([3.0], requires_grad=True) b = torch.tensor([1.0], requires_grad=True) y = w * x # y.eval() = 6,一个乘法节点 z = y + b # z.eval() = 7,一个加法节点 loss = z.sum() # 标量

当你执行y = w * x时,PyTorch 不只是算出一个数值6,它同时创建了一个记录“这个值是怎么来的”的节点——MulBackward。接着z = y + b继续记录AddBackward,loss = z.sum()记录SumBackward。这些节点串起来,就是一张记录前向运算顺序的图:x -> w -> y -> z -> loss。

那张图在内存里并不是一个抽象的“数据结构”,它就体现在每个 Tensor 的属性上。打印一下你就懂了:

print(x.grad_fn) # None,因为 x 是叶子 print(y.grad_fn) # <MulBackward0 object at ...> print(z.grad_fn) # <AddBackward0 object at ...> print(loss.grad_fn) # <SumBackward0 object at ...>

叶子x、w、b是我们自己创建的,所以没有grad_fn;非叶子节点y、z、loss都是由运算产生的,它们各自的grad_fn就代表“哪个反向函数负责计算我的梯度”。沿这条链回溯,PyTorch 就能在backward()调用时,逐个节点地倒推梯度。

1.2 为什么requires_grad=True只控制叶子节点

requires_grad很容易被人误解成“这个 Tensor 是否参与计算”,实际上它的准确含义是“是否需要为这个 Tensor 计算梯度”。如果你把requires_grad=True放在w、b上,那么所有从它们运算得到的 Tensor 都会默认requires_grad=True,因为它们的梯度需要继续往后传。

而如果你有某个中间量不想让梯度流过,正确做法是用x.detach()截断,或者把它包在torch.no_grad()里。注意:requires_grad不是一个永久的 mark,它会在每个新张量上动态传递。

提示:requires_grad=True只表示“这个张量本身有资格获得梯度”,而最终能不能真的得到梯度,还要看它是否在从某个标量loss到它的反向路径上。

1.3 一个能帮助记忆的类比:反向传播是流水线上往回找“谁贡献了多少”

把计算图想象成一家工厂的流水线。原料是x、w、b,每个工位(运算节点)都会对上一环节的产出做加工,最终产出loss。正向的时候,流水线从左走到右,只传产品或中间件。

反向传播则是把这些中间产品重新走回去,每个工位同时向它的所有上游分发“这东西最终影响的敏感度”。具体来说,从loss出发,先算出它对z的敏感度,也就是∂loss/∂z;这个值再经过z工位,算出∂loss/∂y和∂loss/∂b;再往前传,得到∂loss/∂x和∂loss/∂w。

注意一个关键点:某个节点可能被多个下游使用,比如loss = (y1**2).sum() + (y2**2).sum(),x被两套运算共用。反向传播在这种情况下,会分别计算两条路径上loss对x的贡献,然后把它们相加。这与链式法则完全一致,也是后面梯度累加机制的基础。

2. 反向传播的三条铁律与设计逻辑

2.1 第一条:梯度默认会累加到.grad,而不是覆盖

第一次写训练循环的人,几乎都踩过这个坑:一轮更新参数后不zero_grad(),下一轮backward(),梯度直接变成原来的两倍,模型训练发散。

这其实是autograd 有意设计的行为,因为很多场景下你需要对多个 batch 梯度求和,或者对同一份参数在不同分支上回传的梯度做累加。PyTorch在Tensor.backward()的时候,会把这个 Tensor 的梯度grad累加进每个非叶子/叶子的.grad属性里,而不是重新计算后覆盖。

所以训练循环的标准顺序必须是:

optimizer.zero_grad() # 第一步:清空上一步遗留的梯度 loss.backward() # 第二步:反向传播,将计算出的梯度累加到 .grad optimizer.step() # 第三步:根据 .grad 更新参数

调换到zero_grad()放在backward()之后也行,但绝大多数优化器 API 的设计就是让你先清空。如果你写成backward()之后zero_grad(),那这次的梯度就没了,相当于没有更新。

实操提醒:如果在backward()之前忘了zero_grad(),得到的不是“梯度更准”,而是“上一轮和这一轮梯度求和”。尤其是 batch_size 较小时,这种错误不会立刻让你的 loss 飙到 NaN,而是让训练曲线出现奇怪的锯齿状波动。

2.2 第二条:loss.backward()只对标量有效,非标量必须提供 “gradient” 参数

源码里torch.autograd.backward(tensors, grad_tensors=None, retain_graph=None, create_graph=False),其中的grad_tensors参数就是为“非标量输出”设计的。如果你直接对outputs = model(x)(形状是[batch, num_classes])调用outputs.backward(),会报错:

RuntimeError: grad can be implicitly created only for scalar outputs

为什么会这样?因为对一个[batch, num_classes]的输出,严格来说“梯度”不是一个向量,而是一个雅可比矩阵:每个输出分量对每个输入分量都有偏导。默认情况下,PyTorch 要你指定一个与outputs同样形状的gradient张量,作为“上游传来的梯度种子”。然后它通过雅可比-向量积(JVP/VJP)的方式计算出loss对参数的梯度。

换言之,autograd 内部并不真正妄图构造完整雅可比矩阵,而是实现了一个“向量-雅可比积”的算子。你传入的gradient就相当于给每个输出分量一个权重,相当于是某个假定的上层 loss 对输出的偏导。

out = torch.tensor([1.0, 2.0, 3.0], requires_grad=True) # 不能直接 out.backward() # 需要提供一个同样形状的“上游梯度” out.backward(torch.tensor([0.1, 0.2, 0.3])) print(out.grad) # 输出 [0.1, 0.2, 0.3]

这正好解释了为什么模型训练里都写loss.backward(),因为loss已经是标量。如果你想对非标量调用backward(),最常见的做法是先loss = outputs.mean()或者loss = outputs.sum()转成标量。

2.3 第三条:默认反向只计算一次,多次调用要显式retain_graph=True

每次loss.backward(),autograd 会沿着计算图从loss一路回传到叶子,完成梯度计算后,它会释放这张计算图(或者更准确地说,释放反向过程中的中间缓冲区)。这是为了节省内存——对于训练场景,一批数据算完了,图就没有用了。

但如果你需要在一个循环里多次backward()(例如某些 GAN 训练,判别器要基于同一份特征图计算多次 loss;或者你想在同一个 loss 上既反传又再次计算梯度),第二次调用就会出现:

RuntimeError: Trying to backward through the graph a second time, but the buffers have already been freed. Specify retain_graph=True when calling backward the first time.

解决办法是在第一次backward()时传retain_graph=True。但这是有代价的:图不会被释放,内存开销变大。所以只在确实需要时才开,用完记得关掉或者把能算的先算完。

3. 实操案例:从一个模型训练循环看懂 autograd 的全部关键动作

3.1 搭建一个极简模型并手动观察梯度流

下面我构造一个最简单的线性回归模型,只用两个参数w和b。这个例子里没有用nn.Linear,目的是让你能直接看到参数在backward()前后的.grad值是怎么变化的。

import torch torch.manual_seed(0) # 真实模型:y = 2x + 1 true_w = torch.tensor(2.0) true_b = torch.tensor(1.0) # 随机生成训练数据 x_data = torch.linspace(-1, 1, 100).reshape(-1, 1) # [100, 1] y_data = true_w * x_data + true_b + 0.05 * torch.randn_like(x_data) # 初始化待训练参数 w = torch.tensor([0.0], requires_grad=True) b = torch.tensor([0.0], requires_grad=True) # 简单前向:模型 y_hat = w * x + b def model(x): return w * x + b # 训练参数 lr = 0.1 steps = 3 for step in range(steps): # 前向计算 pred = model(x_data) loss = ((pred - y_data) ** 2).mean() # 关键:先清空梯度,再反向 if w.grad is not None: w.grad.zero_() if b.grad is not None: b.grad.zero_() loss.backward() if step == 0: print(f"step {step}, w.grad = {w.grad}, b.grad = {b.grad}") # 手动更新参数 with torch.no_grad(): w -= lr * w.grad b -= lr * b.grad print(f"step {step}, loss = {loss.item():.6f}, w = {w.item():.4f}, b = {b.item():.4f}")

这里你会在第一步就看到w.grad和b.grad是一个标量 Tensor,约等于梯度。这不是 PyTorch 在backward()时“返回”梯度,而是PyTorch把梯度“写到”了张量自带的.grad属性上。

3.2 为什么更新参数时要套torch.no_grad()

代码里更新参数用了with torch.no_grad(): w -= lr * w.grad。如果你去掉这个上下文管理器,会发生什么?w -= lr * w.grad是一个“减法和赋值”的 in-place 操作。在没有no_grad的情况下,w是一个requires_grad=True的张量,lr * w.grad也是一个有 grad 的张量,两者相减会得到一个新的张量,而把新的张量 in-place 赋值回w时,会导致原有计算图上的叶子 Tensor 值被修改,进而在下次前向时产生混乱。

从语义上讲,更新参数这个动作本身不应该被纳入计算图——你不想让“用什么学习率、减去多大梯度”也变成需要反向传播的一环。no_grad()就是做这个边界用的。

如果你用的是optimizer.step(),优化器内部也早就用torch.no_grad()包住了参数更新逻辑。所以你不会看到它暴露出来,但它确实存在。

3.3 训练循环里的标准模板和常见误区

实际做深度模型时,你不会手写w.grad更新,而是用nn.Module配合优化器:

model = torch.nn.Linear(1, 1) optimizer = torch.optim.SGD(model.parameters(), lr=0.1) for x, y in dataloader: optimizer.zero_grad() pred = model(x) loss = ((pred - y) ** 2).mean() loss.backward() optimizer.step()

这个模板里最值得玩味的是optimizer.zero_grad()的位置。它必须在backward()之前,因为在backward()之后梯度已经算好,optimizer.step()要用。如果zero_grad()放到step()之后,等于清空了刚算出来的梯度,这轮更新就作废了。

另外,很多人会混淆model.eval()和torch.no_grad()。eval()只影响 dropout、batchnorm 等模块的前向行为,不关闭梯度计算;no_grad()则不改变模型 mode,只是关闭自动求导以节省内存。推理时尽量两者都做,但这是两个独立概念,别混用。

4. 升级打法:非标量、高阶导、autograd.grad 与自定义反向

4.1torch.autograd.grad和backward()的区别

除了Tensor.backward(),你还会遇到torch.autograd.grad(outputs, inputs, grad_outputs=None, create_graph=False, retain_graph=False)。两者都是反向计算,但区别在于:

  • backward():把梯度写入到inputs的.grad属性中,再通过optimizer.step()更新。
  • torch.autograd.grad():直接返回一个梯度张量元组,不写入.grad属性。

比如你想在训练过程中额外输出某个中间层的梯度,又不想污染参数缓冲,就可以用torch.autograd.grad():

grad_magnitude = torch.autograd.grad( outputs=loss, inputs=feature_map, retain_graph=True )[0]

这种方式特别适合调试、分析梯度范数,或者在算法里需要“提取梯度值”但不要影响原有反向流程时。

4.2 非标量反向与雅可比-向量积的直接体验

前面提到非标量backward()必须传入gradient。再举一个直观的例子:假设你的model输出三个值,你想让第一个输出的贡献更大,就可以把gradient设置成[1, 0, 0]用向量积实现“对第一个分量求导”。

import torch x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True) def simple_fun(x): return x ** 2 # 每个元素独立平方 out = simple_fun(x) out.backward(gradient=torch.tensor([1.0, 0.0, 0.0])) print(x.grad) # 相当于只计算了第一个分量的梯度:2 * x[0] = 2,其余为0

这里的gradient就是雅可比-向量积中的“向量”。平时你不写它,是因为标量 loss 相当于向量长度为 1,默认上游梯度就是1.0。

4.3 用create_graph=True实现二阶导

高阶梯度(比如正则化里用到的梯度惩罚、元学习中的 meta-gradient)需要让“反向传播本身也变成可微的”。此时backward()里必须传create_graph=True,它会保留反向过程中额外构建的计算图,从而让你对梯度再进行一次反向。

一个很常见的例子是“梯度惩罚”(gradient penalty):先算出某个输出的梯度,然后继续对参数求二阶导。

x = torch.randn(4, requires_grad=True) loss = (x ** 3).sum() first_grad = torch.autograd.grad(loss, x, create_graph=True)[0] second_grad = torch.autograd.grad(first_grad.sum(), x)[0] print(second_grad) # 6 * x,因为 d/dx (3x^2) = 6x

如果你想只用loss.backward(create_graph=True),那要小心:这会保留整张图的内存,代价不小。只在确实需要高阶导时才打开。

4.4 自定义torch.autograd.Function:当你有反向公式时

当 PyTorch 内置的算子无法覆盖你的需求时(比如自定义量化、自定义科学计算函数),你就需要自己实现前向和反向。这并不神秘,只是告诉 autograd“前向你按我的来,反向你按我给的公式来”。

一个经典例子:实现一个“平方操作”的自定义算子,来替代x**2。

import torch class Square(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(x^2) / dx = 2x return grad_output * 2 * x x = torch.tensor(3.0, requires_grad=True) y = Square.apply(x) y.backward() print(x.grad) # 6.0

注意几个细节:backward()接收的grad_output是上游传来的梯度;你需要返回一个与forward输入数量对应的梯度(这里x对应的梯度是2x * grad_output);如果有多个输入,就返回多个梯度组成的元组。ctx.save_for_backward()只用来保存前向的 Tensor,不要保存进入自动内存后可能被修改的变量。

自定义Function的另一个价值是节省内存:你可以在forward里不保存较大的中间结果,在backward里重新计算。这是一种用时间换空间的优化策略。

5. 新手最容易踩的坑:detach、inplace、内存与速度

5.1detach()到底切掉了什么

很多人的直觉是“detach 会让数据断开与模型的关联”,但其实detach()只是让新的 Tensor 与计算图脱离,具体表现是:

  • 返回的新 Tensor 与原 Tensor 共享数据存储(内存相同)。
  • requires_grad变成False。
  • grad_fn变成None。

如果只是取数值,x.detach().cpu().numpy()是为了 convert to numpy;如果你想记录一个“与图无关”的副本,或者做某个分支的判断,也可以用 detach。

需要特别小心的是:.detach()和.clone()组合使用还是.detach()单独使用?如果之后你要修改这个张量且不希望影响原图,要x.detach().clone();如果不改,直接用x.detach()就行,内存更省。

5.2 in-place 操作为什么容易破坏计算图

PyTorch 在构建计算图时,默认假设每个 Tensor 的值在反向传播前不会被改变(或者说,它通过版本计数来检查)。你在两个操作点之间对同一个输入做了 in-place 改写,比如:

x = torch.tensor([1.0], requires_grad=True) y = x * 2 x.add_(1) # 破坏了 y 与 x 之间的依赖关系 z = x * 3 z.backward()

此刻反向时,y = x*2的梯度需要知道原来的x=1,但x已经被改成2。PyTorch 一般会抛出 “one of the variables needed for gradient computation has been modified by an inplace operation” 一类的报错。这正是 autograd 的自我保护机制,防止悄悄算错梯度。

常见的引爆点是torch.relu(x, inplace=True)。如果这个x同时在别的分支上被使用,inplace 改写就会出问题。所以一般除非你能确定这个张量没有其他分支依赖,否则谨慎用inplace=True。某些模型结构里,把ReLU的inplace=True设成False反而更稳。

技巧:将torch.autograd.set_detect_anomaly(True)开启后,PyTorch 会在反向传播报错时提供更多调试信息,指出问题出在哪个 forward 操作。但开启后运行会明显变慢,别在生产环境长期开着。

5.3 梯度爆炸与梯度裁剪的正确时机

反向传播算出梯度后,在optimizer.step()之前,可以对梯度进行裁剪。最常用的是torch.nn.utils.clip_grad_norm_(parameters, max_norm),它按所有参数梯度的整体范数进行缩放;还有clip_grad_value_,它直接对梯度值的每个元素做 clamp。

典型的 LSTM 训练里梯度爆炸很常见:

optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()

裁剪的位置必须在backward()之后、step()之前,因为step()读的是.grad。如果你把它放在zero_grad()后面,就白裁了。

5.4 常见报错与排查速查表

我把这几年经常见到的 autograd 相关报错整理成一个表,方便你在跑代码时快速对照:

报错高发点典型提示原因解决方法
backward()对非标量grad can be implicitly created only for scalar outputs输出不是标量,没有指定上游梯度将输出.mean()/.sum(),或传入与输出形状一致的gradient。
多次 backward 图已释放Trying to backward through the graph a second time每轮反向后计算图默认释放在第一次backward()前设置retain_graph=True,但要评估内存开销。
in-place 修改variable needed for gradient computation has been modified by an inplace operationadd_()、relu(..., inplace=True)破坏了图中张量避免对参与自动求导的张量做 in-place 修改,或改用clone()。
梯度值全部为 None.grad为 Nonerequires_grad=True的参数没有参与 loss 计算,或已被no_grad包裹检查参数是否在前向中使用,确认反向路径上没有detach()。
梯度 NaNloss is nan/grad contains nan学习率过大、数据包含异常值、梯度爆炸、loss 中有 log(0)先用小样本定位,开启anomaly检测,适当 clip gradient,降低 lr。

5.5 省内存与加速的几个 autograd 技巧

  • torch.no_grad():推理或计算 metrics 时关闭自动求导,能明显减少显存占用。
  • torch.inference_mode():比no_grad更激进,也会关闭自动求导,适合纯推理,但在一些情况下不能再创建需要梯度的张量。
  • 减少retain_graph=True的使用:如果一定要多 loss 合并,可以先算两部分 loss 之和再统一 backward;或者用accumulate gradients的方式。
  • 在模型 forward 里少用不必要的中间拼接,减少图中的节点数量;但通常以代码可读性和正确性优先。

5.6 用grad_fn和 hook 来调试模型

不求人小技巧:当你搞不清某个 tensor 是不是来自某个运算时,就直接打印它的grad_fn。它会直接告诉你它是由哪个操作产生的、是不是叶子。

想查看中间层梯度不想改模型,可以给目标 Tensor 注册一个 hook:

def print_grad(tensor): def hook(grad): print(f"grad shape: {grad.shape}, mean: {grad.mean().item()}") tensor.register_hook(hook) return tensor

这个 hook 会在反向传播到该 Tensor 时被调用,不会阻塞原本的梯度更新,属于调试利器。

抛开这么多细节,我自己在实际项目中最大的体会是:autograd 的报错几乎从不“胡写”,它非常精准地指出了你哪里破坏了计算图的边界。与其对着报错吓一跳,不如先记住第 2 节里的三条铁律,很多问题都能自查。如果你现在正在写训练代码,那不妨在第一次碰到坑的时候,多打印一下x.grad_fn和x.is_leaf,往往一眼就能看到问题在哪。最后再分享一个小习惯:把每次训练循环里的zero_grad、backward、step三个动作写在同一处,并且顺序固定,别让它们被某个 if 分支拆开。训练过程后期一旦出现梯度问题,能少很多排查时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 12:46:21

联邦学习通信优化:FedCache缓存机制与日志门控注意力设计

1. 论文背景与研究动机 1.1 联邦学习的核心痛点&#xff1a;通信开销与个性化矛盾 联邦学习这几年已经从学术圈的热门概念&#xff0c;逐步走向了工业界的真实落地。但真正动手做过项目的人都清楚&#xff0c;理想很丰满&#xff0c;现实很骨感。最直接的问题就是通信开销&…

作者头像 李华
网站建设 2026/10/21 12:45:39

AI漫剧规模化生产:从剧本到成片的完整工作流拆解

1. 项目概述与整体思路1.1 为什么AI漫剧能跑通"规模化"这条路先说结论&#xff1a;AI漫剧不是传统动画的替代品&#xff0c;而是一个全新的内容品类。它把动画制作从"人力密集型"彻底转成"算力密集型"&#xff0c;这才是它能规模化的根本原因。过…

作者头像 李华
网站建设 2026/10/21 12:45:23

企业AI落地:从单点Agent到技能中枢的治理实践

1. 从单点炫技到平台化治理&#xff1a;企业AI落地为何需要“技能中枢”过去一年&#xff0c;我接触过不少企业内部的AI项目。一个很普遍的现象是&#xff1a;团队里总有一两个对新技术敏感的同事&#xff0c;用几周时间就能搭出一个能跑通的Agent原型——能查天气、能读内部文…

作者头像 李华
网站建设 2026/10/21 12:45:04

对话式AI如何重塑科研工作流:从算泥MVP看AI科研助手落地

1. 一场直播把"科研AI"讲明白了&#xff1a;从算泥MVP说起 我是在一个科研社区的转发里看到"算泥MVP直播"这个活动的。当时的第一反应是&#xff1a;这名字有意思&#xff0c;既自嘲又专业。"算泥"这两个字&#xff0c;但凡做过几年实验、跑过几…

作者头像 李华
网站建设 2026/10/21 12:44:36

推测解码实战:vLLM 中让 LLM 推理速度翻倍的配置指南

做过推理服务的人都有这种体会&#xff1a;模型参数量上去之后&#xff0c;单 token 生成时间死死卡在几十毫秒&#xff0c;用户问一句话要等好几秒。LLM 推理速度不够快&#xff0c;这个问题卡住了不少人。推测解码&#xff08;Speculative Decoding&#xff09;是这两年开源社…

作者头像 李华
网站建设 2026/10/21 12:42:49

前端接口模拟实战指南:从手动拦截到TypeScript驱动的工程化Mock

1. 为什么前端必须“假装”有后端&#xff1f;——从联调现场的真实窒息感说起 我第一次在某跨平台系统项目里被拉进联调群时&#xff0c;后端同事发来一句&#xff1a;“接口文档已更新&#xff0c;明天下午提测”。我点点头&#xff0c;打开 Postman 粘贴 URL&#xff0c;敲下…

作者头像 李华