☰
PyTorch requires_grad_ 完全指南:梯度追踪、参数冻结与自动微分实战
2026/10/17 3:31:46 网站建设 项目流程

1. 从一个“梯度消失”的调试现场说起

如果你写过一段 PyTorch 训练代码,跑完loss.backward()之后发现某个参数的.grad是None,或者更隐蔽地——梯度值恒为零、模型完全不收敛,那你大概率已经和requires_grad_这个看似不起眼的方法打过照面了。torch.Tensor.requires_grad_是 PyTorch 中控制张量是否参与梯度追踪的开关式方法,它决定了自动微分引擎在反向传播时会不会为这个张量计算并累积梯度。听起来简单,但实际项目里,它牵扯到迁移学习中的参数冻结、对抗样本生成、元学习中的二阶梯度、以及各种自定义层的梯度流控制。这篇文章就是围绕这个方法的完整使用说明展开,把它的行为机制、典型场景、参数细节、踩坑记录和排查技巧一次讲透。

适合谁看?如果你已经能跑通一个基础的训练循环,但对“为什么这个参数没梯度”“为什么显存突然爆了”“为什么冻结了层反而更慢”这类问题还停留在试错阶段,那这篇内容就是给你准备的。我会尽量用从业者之间交流的方式来讲,不堆公式,但该说清楚的机制一个不落。核心关键词requires_grad_、梯度追踪、参数冻结、自动微分会自然分布在各个章节里,你跟着读下来就能建立起一套完整的判断逻辑。

先说结论性的认知:requires_grad_是一个原地(in-place)方法,它直接修改调用它的张量自身的requires_grad属性,并返回这个张量本身。注意是原地修改,不是返回一个新张量。这个特性决定了它在使用时有几个必须注意的边界,后面会专门用一节来讲。理解这一点,是理解它所有行为的前提。

2. requires_grad_ 的机制拆解与设计逻辑

2.1 自动微分到底在追踪什么

PyTorch 的自动微分引擎(autograd)本质上是在做一件事:记录一张动态计算图。每当你对一个requires_grad=True的张量做运算,引擎就会在后台记录这个运算节点,形成从输入到输出的有向无环图。反向传播时,引擎沿着这张图从输出往输入走,用链式法则逐节点计算梯度。

关键点在于:只有requires_grad=True的张量才会被纳入这张图。一个张量如果requires_grad=False,它参与的运算不会被记录,反向传播走到它这里就断了。这就是requires_grad_存在的意义——它是你手动控制“哪些张量进入计算图”的开关。

这里有个容易被忽略的细节:requires_grad属性具有传播性。如果a.requires_grad=True,那么b = a * 2得到的b自动就是requires_grad=True,因为b依赖a,需要为a计算梯度。反过来,如果所有输入都是False,输出也是False。这个传播规则决定了你在冻结参数时,必须从源头把叶子张量设成False,而不是去改中间结果。

2.2 为什么是原地方法而不是返回新张量

很多初学者会下意识写成x = x.requires_grad_(True),觉得这样“赋值”更安全。实际上requires_grad_返回的就是x本身,这个赋值是多余的,但不会出错。真正会出错的是另一种写法:以为它返回了一个新张量,然后对原张量继续操作,结果发现原张量也被改了。

PyTorch 把它设计成原地方法,是有性能考量的。张量在底层是一块连续内存加上元数据,requires_grad是元数据里的一个标志位。原地翻转这个标志位,成本几乎为零,不需要重新分配内存、不需要拷贝数据。如果设计成返回新张量,每次切换都要复制一份数据,在动辄几亿参数的大模型上这是不可接受的浪费。

但原地操作带来一个约束:你不能对一个非叶子张量(non-leaf tensor)随意调用requires_grad_。所谓叶子张量,是用户直接创建的、不是由其他运算产生的张量,比如nn.Parameter或者你手动torch.randn出来的张量。非叶子张量是计算图的中间产物,它的requires_grad是由输入决定的,你强行改它,PyTorch 会直接报错:

a = torch.randn(3, requires_grad=True) b = a * 2 # b 是非叶子张量 b.requires_grad_(False) # RuntimeError: you can only change requires_grad flags of leaf variables

这个报错信息其实说得很清楚,但很多人第一次遇到会懵。记住:要改就改叶子张量。如果你确实想阻断某个中间结果的梯度,正确做法是用.detach(),它会返回一个脱离计算图的新张量,而不是去改原张量的标志位。

2.3 与 detach、no_grad 的分工

requires_grad_、detach()、torch.no_grad()这三个东西经常被混用,但它们的分工其实很清晰,我整理成一张表方便对照:

方法作用对象是否原地典型用途
requires_grad_叶子张量是冻结/解冻参数,控制长期状态
detach()任意张量否临时切断某条路径的梯度
torch.no_grad()代码块否推理、验证阶段全局关闭追踪

选择逻辑是这样的:如果你要长期改变一个参数的追踪状态(比如冻结预训练层),用requires_grad_;如果你只是临时想让某个中间结果不参与反向(比如从特征里取一部分做对比学习),用detach();如果你整个阶段都不需要梯度(比如评估),用torch.no_grad()包起来,它还能顺带省显存。

提示:torch.no_grad()是上下文管理器,进入这个块之后,所有新创建的张量requires_grad都是False,且块内的运算不建图。它比逐个requires_grad_(False)更高效,也更不容易漏。

3. 参数冻结与迁移学习中的实操要点

3.1 冻结预训练层的标准写法

迁移学习里最常见的需求就是:加载一个预训练模型,冻结主干网络,只训练最后的分类头。用requires_grad_实现起来很直接:

import torch import torch.nn as nn model = SomePretrainedNet(num_classes=10) # 第一步:冻结所有参数 for param in model.parameters(): param.requires_grad_(False) # 第二步:替换分类头(新层的参数默认 requires_grad=True) model.classifier = nn.Linear(model.classifier.in_features, 10) # 第三步:确认可训练参数 trainable = [p for p in model.parameters() if p.requires_grad] print(f"可训练参数数量: {sum(p.numel() for p in trainable)}")

这里有个顺序问题值得强调:先冻结全部,再替换分类头。如果你先替换了分类头再冻结全部,新加的分类头也会被冻上,模型就完全学不动了。这个坑我见过不止一个同事踩过,表现是 loss 从头到尾不下降,查半天才发现是分类头也被冻了。

另外,nn.Parameter默认requires_grad=True,所以新建的nn.Linear层天然是可训练的,不需要额外设置。但如果你是用nn.Sequential手动拼的网络,里面的nn.Parameter同样默认是True,这点是一致的。

3.2 冻结之后优化器该怎么传参

冻结参数之后,一个常见的疑问是:优化器要不要把冻结的参数也传进去?答案是不要。优化器只应该接收requires_grad=True的参数:

optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3 )

为什么?因为优化器在step()时会遍历它持有的所有参数,对每个参数检查.grad是否存在。如果传入了冻结参数,它们的.grad永远是None,优化器会跳过它们,但遍历本身有开销。更重要的是,某些优化器(比如带 weight decay 的)可能会对None梯度做额外判断,虽然不会报错,但白白浪费计算。用filter过滤一遍,干净利落。

还有一个更隐蔽的问题:如果你在训练中途解冻了某些层,而优化器是在冻结状态下创建的,那些新解冻的参数不在优化器的参数列表里,它们的梯度算出来了但不会被更新。这时候你需要重新创建优化器,或者用参数组的方式动态管理。我个人的习惯是,如果冻结策略会在训练中变化,就一开始把所有参数都放进优化器,靠requires_grad控制是否更新——虽然有点浪费,但省心。

3.3 冻结带来的显存与速度变化

很多人以为冻结参数会显著减少显存占用和加速训练,实际效果取决于你冻结的位置。冻结不会减少前向传播的激活值显存,因为前向还是要算的。它减少的是反向传播时存储的中间梯度,以及优化器状态(比如 Adam 的动量和方差)。

实测下来,冻结一个 ResNet 的主干,显存大概能省 20% 到 30%,训练速度提升 15% 左右。这个提升主要来自反向传播少算了一部分梯度。但如果你冻结的是浅层、只训练深层,省得更多;反过来冻结深层、训练浅层,省得就少,因为浅层的激活值通常更大。

注意:冻结参数后,记得把模型设成model.train()还是model.eval()要分清。冻结只影响梯度,不影响 BatchNorm 和 Dropout 的行为。如果你冻结了主干但还在用 BatchNorm 的 running stats,训练时它仍然会更新,这可能不是你想要的。要彻底固定主干,得把主干里的 BN 层也设成eval()模式。

4. 进阶场景:对抗样本、二阶梯度与自定义层

4.1 对抗样本生成中的动态开关

对抗样本(比如 FGSM、PGD)的核心思路是:固定模型参数,对输入图像求梯度,然后沿着梯度方向扰动输入。这里requires_grad_的用法和训练正好相反——参数冻结,输入开启梯度:

model.eval() for param in model.parameters(): param.requires_grad_(False) x_adv = x.clone().detach().requires_grad_(True) for _ in range(pgd_steps): logits = model(x_adv) loss = nn.CrossEntropyLoss()(logits, target) grad = torch.autograd.grad(loss, x_adv)[0] x_adv = x_adv.detach() + alpha * grad.sign() x_adv = torch.clamp(x_adv, x - eps, x + eps).detach().requires_grad_(True)

注意这里每一轮迭代都要detach()再requires_grad_(True)。原因是:x_adv在上一轮已经变成了非叶子张量(因为它是由x_adv + 扰动算出来的),你不能直接对它调requires_grad_。必须先detach()把它从计算图里摘出来,变成一个新的叶子张量,再开启梯度。这个模式在对抗训练、元学习里反复出现,是必须掌握的套路。

4.2 二阶梯度与 create_graph

有些场景需要梯度的梯度,比如 MAML、WGAN-GP 的梯度惩罚。默认情况下,backward()计算出的梯度张量requires_grad=False,你没法对它再求导。要让梯度本身可导,需要在autograd.grad里设create_graph=True:

x = torch.randn(3, requires_grad=True) y = x ** 2 grad = torch.autograd.grad(y.sum(), x, create_graph=True)[0] # grad 现在 requires_grad=True,可以对它再求导 grad2 = torch.autograd.grad(grad.sum(), x)[0]

这里requires_grad_没有直接出场,但理解它的传播规则很关键:create_graph=True相当于让梯度计算过程本身也被记录进图里,所以产出的梯度张量自然带requires_grad=True。如果你手动把某个中间张量requires_grad_(False),二阶梯度链就断了。调试二阶梯度问题时,第一件事就是打印每个中间张量的requires_grad状态,看链条在哪里断的。

4.3 自定义 autograd.Function 中的标志位

写自定义算子时,forward里创建的张量默认requires_grad=False,backward返回的梯度需要和输入对应。如果你在forward里保存了中间结果给backward用,要注意这些保存的张量是否需要在图里。一个常见错误是:在forward里对输入做了detach()保存,结果backward时发现梯度传不回去。正确做法是直接保存输入张量,或者用ctx.save_for_backward(),让引擎自己管理。

class MyFunction(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 return grad_output * 2

这个例子里没有显式用requires_grad_,但save_for_backward保存的张量会保留其requires_grad状态,引擎据此决定是否继续往回传。如果你在forward里对x做了x = x.detach()再保存,梯度链就断了,backward返回的梯度无法传到更早的层。这个坑在写复杂自定义层时特别容易踩。

5. 常见问题与排查技巧实录

5.1 梯度为 None 的排查路径

梯度为None是最常见的问题,排查顺序我总结成下面这张表:

现象可能原因排查方法
参数.grad为 None参数requires_grad=False打印param.requires_grad
参数.grad为 None参数未参与前向计算检查前向是否用到了该参数
参数.grad为 None计算图被 detach 切断检查中间是否有.detach()
梯度全为 0激活函数饱和(如 sigmoid)换 ReLU 或检查输入范围
梯度全为 0学习率过大导致死亡降低学习率观察
梯度爆炸学习率过大或深层网络加梯度裁剪

排查的第一步永远是:打印所有叶子张量的requires_grad状态。一行代码就能定位大部分问题:

for name, param in model.named_parameters(): if not param.requires_grad: print(f"冻结: {name}")

如果发现该开的没开,用param.requires_grad_(True)补上。如果状态都对但梯度还是 None,那就是计算图断了,往中间张量上找.detach()或者torch.no_grad()的痕迹。

5.2 原地修改引发的报错

前面提过,对非叶子张量调requires_grad_会报RuntimeError。但还有一种更隐蔽的报错:在torch.no_grad()块里创建张量,然后出块后调requires_grad_(True)。这个操作本身是合法的,因为块里创建的张量是叶子张量。但如果你在块里对某个需要梯度的张量做了原地操作(比如x.add_(1)),PyTorch 会报“a leaf Variable that requires grad is being used in an in-place operation”。这是因为原地操作会破坏反向传播所需的原始值。

解决办法有两个:要么用非原地版本(x = x + 1),要么在no_grad块里操作前先detach()。我个人的习惯是,凡是涉及需要梯度的张量,一律避免原地操作,虽然多占一点内存,但省去了大量调试时间。

5.3 冻结参数后 BatchNorm 的陷阱

这个坑值得单独拎出来说。假设你加载了一个预训练模型,冻结了主干,然后直接model.train()开始训练。你以为主干完全固定了,但实际上主干里的 BatchNorm 层在train()模式下会继续更新 running mean 和 running variance。这意味着主干的输出分布会随着你的新数据变化,预训练学到的统计量被覆盖了。

正确做法是:冻结参数后,把主干里的 BN 层显式设成eval()模式:

for module in model.modules(): if isinstance(module, nn.BatchNorm2d): module.eval()

或者更彻底一点,如果主干整体不需要训练,直接model.backbone.eval(),只把分类头设成train()。这个细节在微调小数据集时特别关键,因为小数据集的统计量和大规模预训练数据差异很大,BN 更新会严重破坏预训练特征。

5.4 解冻与重新训练的注意事项

训练中途解冻某些层,除了前面说的优化器参数列表问题,还有一个学习率的问题。预训练层通常需要更小的学习率,如果你解冻后直接用和分类头一样的学习率,很容易把预训练特征“冲垮”。推荐用参数组给不同层设置不同学习率:

optimizer = torch.optim.Adam([ {'params': model.backbone.parameters(), 'lr': 1e-5}, {'params': model.classifier.parameters(), 'lr': 1e-3}, ])

解冻的时机也有讲究。常见策略是先冻结主干训练几轮分类头,等分类头稳定后再解冻主干做小学习率微调。这样比一上来就全量微调更稳,尤其是在目标数据集很小的时候。

6. 一些容易被忽略的边界与经验

requires_grad_还有一个不太为人知的边界:它不能用于整数类型的张量。如果你对一个torch.LongTensor调requires_grad_(True),会直接报错,因为整数张量不支持梯度。这个在 embedding 层附近容易遇到——input_ids是 long 类型,你不需要也不能给它开梯度,梯度是给 embedding 权重算的。

另一个经验是:尽量在模型定义阶段就确定好冻结策略,而不是在训练循环里反复切换。反复调requires_grad_虽然开销小,但会让代码逻辑变得难以追踪,尤其是多人协作时,别人很难一眼看出哪些层在什么时候是可训练的。如果确实需要动态切换,建议封装成一个函数,把切换逻辑集中管理,并在日志里记录每次切换的状态。

关于性能,还有一个实测结论:requires_grad_(False)之后再requires_grad_(True),张量的梯度缓冲区不会自动清空。如果你在解冻后直接backward(),之前累积的旧梯度可能还在。稳妥做法是解冻后手动param.grad = None,或者用优化器的zero_grad()清一遍。这个细节在需要精确控制梯度累积的场景下很重要。

最后分享一个调试小技巧:在训练循环里加一个断言,检查可训练参数的数量是否符合预期。一行代码,能帮你挡住大部分因为冻结/解冻逻辑写错导致的问题:

assert sum(p.requires_grad for p in model.parameters()) == expected_count

这个断言在重构模型结构或者调整冻结策略后特别有用,跑一次就能确认状态对不对,比盯着 loss 曲线猜要高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询