别人教 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(x+h)-f(x-h))/2h去近似导数。但它的精度受到h取值影响,选大了误差大,选小了浮点数精度又出问题。而且在神经网络里,每次求梯度都要重新把所有参数往前跑一遍,代价太高。
符号微分,就是我们初高中学的那种,用工具(比如 SymPy)直接推导出导函数的表达式。问题在于,多层网络堆出来的表达式会极度膨胀。你看着那个几千项甚至几万项的公式,别说存了,光是被浏览器打开都要卡半天。工程上根本不可行。
自动微分(Automatic Differentiation),走的是第三条路:不直接推一个完整表达式,而是在程序实际运行计算的过程中,把每一步运算都记录下来搭成一张计算图,之后从最终输出开始,沿着图把链式法则一步步用起来。比如你做z = x * y,它就记住"这里发生过乘法",等到反向传播时直接告诉你x和y分别收到了什么梯度。这种方式兼顾了精度和效率,也是神经网络这种"参数海量、输出单一"场景的天然匹配方案。
PyTorch 的 autograd 采用的正是反向模式的自动微分。通俗理解就是:从最终的loss这个节点出发,往输入参数的方向反向扫一遍,把梯度挨个发到有requires_grad=True的节点上。
2. 计算图:autograd 的心脏
2.1 前向传播时,autograd 在背后悄悄建了一棵树
在 PyTorch 里,只要你把某个张量设置为requires_grad=True,接下来所有跟它相关的运算都会被"盯上"。
来看一段最简单的代码:
import torch x = torch.tensor(2.0, requires_grad=True) y = torch.tensor(3.0, requires_grad=True) z = x * y + x print(z) # tensor(8., grad_fn=<AddBackward0>)注意到输出里的grad_fn=<AddBackward0>了吗?这个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_grad=True的张量,或者requires_grad=False的普通张量。
动手看一组真实输出,比背定义有效得多:
import torch a = torch.tensor(2.0, requires_grad=True) b = torch.tensor(3.0, requires_grad=True) 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}, " f"grad_fn={tensor.grad_fn}, is_leaf={tensor.is_leaf}")运行后你会看到:
a: requires_grad=True, grad_fn=None, is_leaf=True b: requires_grad=True, grad_fn=None, is_leaf=True c: requires_grad=True, grad_fn=<MulBackward0>, is_leaf=False d: requires_grad=True, grad_fn=<AddBackward0>, is_leaf=False也就是说,a和b是你亲手创建的,所以是叶子;c和d是通过运算得到的,所以不是叶子,它们带着grad_fn,记录了自己的"身世"。
注意:默认情况下,反向传播之后,只有叶子节点的
grad属性会被填充,非叶子节点的梯度会被丢弃。这不是省内存的问题,而是绝大多数训练场景根本不需要中间变量的梯度,你要的是参数的梯度。
2.3 中间节点的梯度去哪了?如何查看?
既然中间节点的grad会被丢弃,那如果我就是想看看某一层输出的梯度,用来调试网络呢?
答案是使用retain_grad()方法,显式要求 PyTorch"这个中间结果我要留作后用"。
import torch x = torch.tensor(1.0, requires_grad=True) w = torch.tensor(2.0, requires_grad=True) 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,在hidden=2的时候等于 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_grad=True) w = torch.tensor([2.0, 3.0], requires_grad=True) 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_grad=True) y = x ** 3 y.backward() print(x.grad) # tensor(12.)手动算一下:dy/dx = 3 * x^2,在x=2处等于3*4=12,完全一致。这个例子虽然简单,却是理解"反向传播就是把链式法则按图执行"的最佳起点。
这里我不展开讲雅可比矩阵的数学细节,但你要知道一件事:在 autograd 内部处理多输入多输出时,它本质上就是在做雅可比向量积(VJP)。你不需要手动构建任何矩阵,只需要提供给最终的梯度向量,所有中间传递都由框架自动完成。
3.3 梯度是累加的,不是覆盖的
这是无数新手掉进去的坑。看看下面这段代码:
import torch x = torch.tensor(1.0, requires_grad=True) w = torch.tensor(2.0, requires_grad=True) for i in range(3): loss = (x * w) ** 2 loss.backward() print(f"step {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_grad=True的张量,尽量避免 in-place 操作。想更新参数,用optimizer.step()或weight.data.add_();想生成新值,就x = x + 1,别用x += 1。这两个看似等价,但在 autograd 的世界里,一个是安全的新节点,一个是对原节点的破坏。
4.3 detach():把张量从计算图里"摘"出来
detach()的作用是返回一个新的张量,这个张量和原来的张量共享数据存储,但requires_grad=False,并且完全和当前计算图脱离关系。
最常见的两大用途:
一是模型输出转 numpy。你训练时拿到了output,但output还在图里,直接output.numpy()会报错。先output.detach().numpy()就行。
output = model(x) output_np = output.detach().cpu().numpy()二是固定特征,只训练下游模块。比如迁移学习里想用预训练模型当特征提取器,不再回传梯度给前面的层,就可以把前向结果detach()出来再往后面传。
还有一个细节容易忽略:detach()出来的张量如果继续参与其他运算,只要被requires_grad=True的张量"带"起来,它还是可以继续建图的。它不是彻底屏蔽梯度,只是"从这一处剪断"。
与no_grad()的差别我也说一下:no_grad()是一个上下文管理器,它禁止的是这段代码范围内所有张量建图;detach()针对的是单个张量,把某个具体的张量从现有图里摘出来。
4.4 retain_graph=True:什么时候你不得不用它
默认情况下,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_graph=True):
loss_d.backward(retain_graph=True) loss_g.backward()每加一个retain_graph=True,这个图就要多存活一次,内存开销是实打实的。所以能用一次backward完成的任务,就不要拆成两次。
如果实在要拆,另一个办法是把两个 loss 相加之后一次性反向:
(total_loss).backward()这在很多场景下和分别 backward(retain_graph=True) 的效果是等价的,且内存压力小得多。我自己在写多任务模型时,倾向于优先合并 loss。
5. 实操对比:手动梯度 vs autograd
5.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 + b,loss = 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_grad=False) b = torch.tensor(0.0, requires_grad=False) 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(f"epoch {epoch}, loss {loss.item():.4f}")你能跑通,而且结果也不错。但问题是:每多一层、每换一次损失函数,这套手动梯度推导就要重来,而且推导中的任何一个小错误都很难被察觉。一个常见的错误是把.mean()写成.sum(),或者漏乘了一个x,训练结果就会变得诡异。
5.2 换成 autograd 的版本
现在把requires_grad=True打开,把手动梯度全部交给backward():
w = torch.tensor(0.0, requires_grad=True) b = torch.tensor(0.0, requires_grad=True) 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(f"epoch {epoch}, loss {loss.item():.4f}")注意三个细节:第一,更新参数时我把w -= lr * w.grad包进torch.no_grad(),防止更新过程又串进计算图;第二,更新完立刻zero_()清梯度;第三,如果后面接的是optimizer,zero_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(), lr=0.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 += 1 |
grad 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_graph=True,或把多个 loss 合并后一次反传 |
| 验证阶段显存暴涨 | 前向没有包torch.no_grad() | 推理和验证用with torch.no_grad(): |
| 训练 loss 一会儿涨一会儿跌,不稳定 | 可能是梯度没清零 | 检查optimizer.zero_grad()是否在每轮训练前调用 |
| 梯度变成了 NaN | 学习率太大,或者计算里出现了数值不稳定的操作 | 降低学习率、检查输入是否有较大的异常值、适当做梯度裁剪 |
某个参数的grad一直为None | 该参数没有参与本次计算图;或requires_grad=False | 查看网络结构确认参数被用在前向里,并设置requires_grad=True |
6.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_graph=True,就不要用。它在多 loss 场景里确实省事,但内存开销很现实。分配好两个 loss 的计算方式,能合并就合并。
第四,detach()和torch.no_grad()要分清楚场景,一个是对单张量切断链路,一个是对整段代码关闭建图。推理时优先用no_grad()。
最后说一点个人体会。以前我总觉得"用框架的自动求导,是不是说明我数学功底不行",后来才明白,autograd 的价值恰恰在于把复杂且机械的链式法则推导交给机器,把注意力还给真正重要的网络设计和实验思路。这是工具该有的样子。
新手阶段,我建议你花半天时间把这篇里的代码都照着敲一遍,重点看grad_fn和is_leaf的变化,再手动推一次简单函数的梯度,和 autograd 对答案。等你建立了计算图直觉,后面再去看那些花哨的魔改训练技巧,都不会觉得虚。别背公式,去感受数据在图上流动的方向,这才是真正理解反向传播的方式。