ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch requires_grad_ 详解:叶子张量、计算图与冻结参数避坑指南

PyTorch requires_grad_ 详解:叶子张量、计算图与冻结参数避坑指南

1. 从一个“梯度消失”的诡异现象说起

如果你在 PyTorch 里写过自定义层、做过迁移学习,或者尝试过冻结网络的部分参数,大概率见过requires_grad_这个带下划线的方法。它看起来简单到不值一提——不就是把某个张量的requires_grad属性设成True或False吗?但恰恰是这个“简单”的方法,坑过不少人。

我印象很深的一次,是帮一位做图像分割的朋友排查一个训练不收敛的问题。他的模型结构是“预训练主干 + 自定义解码头”,主干参数全部冻结,只训练解码头。代码写得很规整,for p in backbone.parameters(): p.requires_grad = False,然后正常前向、反向、更新。结果训练了十几个 epoch,loss 几乎纹丝不动,梯度检查发现主干确实没更新,但解码头的梯度也小得可怜,几乎等于零。

排查了大半天,最后发现问题出在一行不起眼的代码上:他在构建解码头时,用了一个从主干中间层“借”过来的特征张量,而这个张量在主干冻结之前就已经被算出来了,它的requires_grad是False。更隐蔽的是,他在某个分支里对这个张量调用了requires_grad_(),但没有接收返回值,以为原地修改生效了,实际上因为张量是叶子节点之外的中间结果,这个调用直接抛了异常被 try 吞掉了,导致整个计算图在这里断掉。

这件事让我意识到,requires_grad_这个 API 虽然只有一行签名,但它牵扯到 PyTorch 自动微分机制里几个非常核心的概念:叶子张量与非叶子张量的区别、计算图的构建时机、原地操作对 autograd 的影响、以及冻结参数时到底该改谁。很多人只记住了“冻结用requires_grad = False”,却说不清楚为什么有时候改了没用、为什么有时候会报 “you can only change requires_grad flags of leaf variables” 这个经典错误。

这篇内容就围绕torch.Tensor.requires_grad_()展开,把它背后的机制、正确的使用姿势、常见的翻车场景,以及和它容易混淆的几个操作一次性讲透。不管你是刚接触 PyTorch 的新手,还是已经能熟练搭网络但没深究过 autograd 细节的老手,应该都能从中找到一些之前忽略的东西。

2. requires_grad_ 到底改了什么:叶子张量与计算图的绑定关系

2.1 一个方法签名背后的三层含义

先看它的签名:

Tensor.requires_grad_(requires_grad=True) -> Tensor

官方文档一句话带过:把当前张量的requires_grad属性设为指定值,并且原地修改(所以带下划线),返回self。但这句话里藏着三个关键信息,每一个都值得展开。

第一,它是原地操作。这意味着它不创建新张量,直接改内存里这个张量对象的属性。这一点和tensor.requires_grad = True这种属性赋值在效果上类似,但requires_grad_是方法调用,可以链式写在表达式里,比如x = torch.randn(3).requires_grad_()。

第二,它只能作用于叶子张量。这是最容易踩的坑。所谓叶子张量,简单说就是“由用户直接创建的、不是通过其他张量运算得到的”张量。比如torch.randn(3)、torch.zeros(2,2)、torch.tensor([1.0])创建出来的都是叶子张量。而y = x * 2得到的y就是非叶子张量,它的grad_fn不为None,记录着它是怎么从x算出来的。

第三,它修改的是这个张量参与后续计算图构建的资格。一旦某个叶子张量的requires_grad为True,那么所有由它参与运算得到的新张量,都会自动带上requires_grad=True,并且构建出反向传播所需的计算图。反过来,如果它是False,那么由它算出来的张量默认也不追踪梯度,计算图在这里就断了。

理解这三点,后面所有的坑都能顺藤摸瓜找到原因。

2.2 为什么非叶子张量不能改:autograd 的“历史不可篡改”原则

很多人第一次遇到这个报错都会懵:

RuntimeError: you can only change requires_grad flags of leaf variables.

翻译过来就是:你只能改叶子变量的 requires_grad 标志。为什么?因为非叶子张量的requires_grad状态不是它自己能决定的,而是由它的“祖先”决定的。如果x.requires_grad = True,那么y = x * 2的y.requires_grad必然是True,这是 autograd 为了保证梯度链完整而强制设定的。如果你允许把y.requires_grad改成False,那就等于在计算图中间硬生生剪断一条边,反向传播时梯度传到这里就没了,整个图的语义就乱了。

PyTorch 的设计哲学是:计算图一旦构建,它的拓扑结构和梯度传播路径就是确定的,不允许在中间节点上做破坏性的修改。你要么在源头(叶子张量)上控制,要么用detach()显式地切断某个张量与图的联系,生成一个新的、不追踪梯度的张量。这两条路是清晰且安全的,而“中途改非叶子张量的 requires_grad”则是危险且被禁止的。

这里有个生活化的类比:叶子张量像是水源,非叶子张量像是水流经过的管道节点。你可以决定要不要打开水源(改叶子),但你不能在管道中间某个节点上突然说“这里的水不许流了”,因为水已经流过来了,你只能在下游重新接一根新管子(detach)把水引走。

2.3 冻结参数时,到底该用 requires_grad_ 还是 requires_grad =

这是实际项目里问得最多的一个问题。两种写法:

# 写法一:属性赋值 for p in model.backbone.parameters(): p.requires_grad = False # 写法二:方法调用 for p in model.backbone.parameters(): p.requires_grad_(False)

从效果上说,对于nn.Parameter对象(也就是model.parameters()返回的东西),这两种写法完全等价。因为nn.Parameter本身就是叶子张量,而且 PyTorch 对Parameter的requires_grad属性做了特殊处理,允许直接赋值。

那什么时候必须用requires_grad_()?当你处理的是一个普通的Tensor,而不是Parameter,并且你想在创建它的同一行代码里就设定好梯度追踪状态时。比如:

x = torch.randn(3, 3).requires_grad_()

这比先创建再赋值要紧凑。另外,在需要链式调用的场景下,requires_grad_()返回self的特性让它能无缝嵌入表达式。

但要注意一个反直觉的点:对nn.Parameter用requires_grad_()和直接赋值虽然效果一样,但在某些旧版本 PyTorch 里,对Parameter调用requires_grad_()会触发一次额外的内部检查,性能上有极微小的开销。当然这个开销可以忽略不计,实际选型更多看代码风格统一。

3. 冻结与解冻的实战:从迁移学习到对抗训练

3.1 迁移学习里的标准冻结流程与常见误区

迁移学习是requires_grad_出现频率最高的场景。典型流程是:加载预训练权重,冻结主干,替换分类头,只训练新加的部分。标准写法如下:

import torch import torch.nn as nn class Net(nn.Module): def __init__(self, backbone, num_classes): super().__init__() self.backbone = backbone self.head = nn.Linear(backbone.out_features, num_classes) def forward(self, x): with torch.no_grad(): feat = self.backbone(x) return self.head(feat) model = Net(pretrained_backbone, 10) # 冻结主干 for param in model.backbone.parameters(): param.requires_grad_(False) # 确认冻结状态 for name, param in model.named_parameters(): print(name, param.requires_grad)

这里有个细节很多人会忽略:冻结之后,前向传播时最好用torch.no_grad()包住主干的计算。为什么?因为即使requires_grad=False,PyTorch 默认仍然会为这些操作构建一部分中间结果(虽然不会构建完整的反向图),这会带来不必要的显存占用和计算开销。用torch.no_grad()可以彻底关掉这些追踪,显存能省下不少,尤其是在主干网络很深的时候。

另一个误区是:冻结了主干,但忘了冻结 BatchNorm 的 running_mean 和 running_var。requires_grad=False只影响梯度计算,不影响 BN 层在前向时更新 running statistics。如果你在微调时希望 BN 层保持预训练时的统计量,必须额外调用model.backbone.eval(),把 BN 层切到评估模式。否则即使参数不更新,running_mean 和 running_var 也会被当前 batch 的数据带偏,导致推理时表现下降。

3.2 解冻部分层的精细操作:按名称筛选参数

实际项目里很少“全冻”或“全解冻”,更多是冻住底层、解冻高层。这时候就需要按参数名称来筛选:

# 只解冻 backbone 最后两个 stage for name, param in model.backbone.named_parameters(): if 'layer3' in name or 'layer4' in name: param.requires_grad_(True) else: param.requires_grad_(False)

这种写法在 ResNet 系列上很常见。但要注意,解冻的层如果包含 BN,同样要考虑是否切回 train 模式。一个稳妥的做法是:在train()之后,手动把冻结部分的 BN 再设回eval():

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

这样能保证解冻部分的 BN 正常更新统计量,而冻结部分的 BN 保持稳定。

3.3 对抗训练中的动态梯度控制

在对抗样本生成(比如 FGSM、PGD)里,requires_grad_的用法又不一样。生成对抗样本时,我们需要对输入图像求梯度,而不是对模型参数求梯度。这时候标准操作是:

x_adv = x.clone().detach().requires_grad_(True) logits = model(x_adv) loss = criterion(logits, y) loss.backward() # 此时 x_adv.grad 就是对抗梯度 x_adv = x_adv + eps * x_adv.grad.sign()

这里x.clone().detach()是为了从原图复制一份、切断与原计算图的关系,然后requires_grad_(True)让这份副本成为新的叶子张量,从而能对它求梯度。如果少了detach(),x_adv会带着原图的grad_fn,对它调用requires_grad_()就会报“非叶子张量”的错误。

这个模式在对抗训练、可解释性分析(如 Grad-CAM 的变体)、以及一些元学习算法里反复出现。记住一个口诀:要对输入求梯度,先 detach 再 requires_grad_,两步不能省。

4. 那些年我们踩过的 requires_grad_ 的坑

4.1 坑一:在非叶子张量上调用导致计算图断裂

回到开头那个案例。那位朋友在解码头里对主干输出的特征图调用了requires_grad_(),想“重新激活”梯度。但那个特征图是主干前向算出来的,是非叶子张量,调用直接抛异常。他用了 try-except 把异常吞了,结果计算图在这里断掉,解码头的梯度传不回去,训练自然不收敛。

正确的做法是:不要试图在中间特征上改 requires_grad,而应该在主干参数上解冻,或者用 detach 后重新建立一条可追踪的路径。如果确实想让某段中间特征参与梯度计算,可以这样:

feat = backbone(x) feat = feat.detach().requires_grad_(True) # 切断旧图,新建叶子 out = head(feat)

但要注意,这样做的后果是梯度不会传回主干,只会在feat这个新叶子上累积。这通常不是迁移学习想要的效果,更多用在一些特殊的梯度分析场景。

4.2 坑二:误以为改了 requires_grad 就能改变已有计算图

另一个高频误区是:先做了前向传播,构建了计算图,然后才去改某个张量的requires_grad,以为反向传播会按新状态来。这是不可能的。计算图是在前向传播时动态构建的,一旦构建完成,它的梯度传播路径就固定了。你在前向之后改requires_grad,只影响之后新构建的图,对已经算出来的 loss 没有任何影响。

所以正确的顺序永远是:先设置好所有参数的 requires_grad 状态,再做前向传播。如果你在训练循环里动态调整冻结策略(比如先冻几个 epoch 再解冻),一定要在下一个 epoch 的前向之前改好,而不是在 backward 之后改。

4.3 坑三:优化器状态与 requires_grad 的错配

还有一个隐蔽的坑:你冻结了某些参数,但优化器里仍然包含它们。比如:

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for p in model.backbone.parameters(): p.requires_grad_(False)

这时候优化器里依然有主干的参数,虽然它们的grad是None,Adam 更新时会跳过(因为p.grad is None),但优化器的状态(如动量、二阶矩)里可能还残留着之前的信息。更稳妥的做法是:冻结之后重新构建优化器,只传入需要训练的参数:

trainable_params = [p for p in model.parameters() if p.requires_grad] optimizer = torch.optim.Adam(trainable_params, lr=1e-3)

这样不仅语义清晰,还能避免优化器在无关参数上浪费内存和计算。

4.4 坑四:DataParallel / DistributedDataParallel 下的同步问题

在多卡训练时,requires_grad的状态需要在所有卡上保持一致。如果你在某个卡上单独改了requires_grad,可能会导致梯度同步时出现形状不匹配或通信挂起。标准做法是:在模型包装成 DDP 之前,就设置好所有参数的 requires_grad 状态。DDP 在初始化时会广播参数状态,之后再改容易出问题。

如果确实需要在训练中途动态解冻,建议用model.module访问原始模型,改完之后手动调用一次dist.barrier()确保所有进程同步,再继续训练。

5. 和 requires_grad_ 容易混淆的几个操作对比

5.1 requires_grad_ vs detach:一个改状态,一个断关系

这两个操作经常被放在一起讨论,但作用完全不同。用表格对比最清楚:

操作作用对象效果是否原地典型场景
requires_grad_()叶子张量改梯度追踪开关是冻结参数、激活输入梯度
detach()任意张量返回一个共享数据但不追踪梯度的新张量否切断计算图、生成对抗样本
with torch.no_grad()代码块块内所有操作不构建计算图上下文管理推理、冻结主干前向
requires_grad = False叶子张量/Parameter同 requires_grad_是(属性赋值)冻结参数

关键区别:detach()返回的是新张量,和原张量共享底层数据,但requires_grad=False且grad_fn=None。而requires_grad_()改的是原张量本身的状态,不创建新对象。

一个常见的组合用法是x.detach().requires_grad_(True),先切断旧图,再把新张量设为可追踪,这在对抗样本和某些元学习场景里是标准操作。

5.2 requires_grad_ vs torch.no_grad:粒度不同

torch.no_grad()是一个上下文管理器,作用范围是整个代码块。在块内,所有新创建的张量默认requires_grad=False,所有运算不构建计算图。它适合“整段推理”或“整段冻结前向”的场景。

而requires_grad_()是单个张量级别的开关,粒度更细。你可以在一个no_grad块里,对某个特定张量调用requires_grad_()把它“捞”出来,让它重新参与梯度追踪。这种“块内开小灶”的用法在一些复杂的梯度操作里很有用。

5.3 一个容易忽略的细节:requires_grad_ 的返回值

虽然requires_grad_()是原地操作,但它返回 self。这意味着你可以这样写:

x = torch.randn(3).requires_grad_()

但不能这样写:

torch.randn(3).requires_grad_() x = torch.randn(3)

第二种写法里,requires_grad_()的返回值被丢弃了,但因为它原地修改了那个临时张量,而临时张量随后被垃圾回收,所以等于什么都没做。这个坑在链式调用里容易犯,记住要么接收返回值,要么确保你操作的是有名字的变量。

6. 进阶:requires_grad_ 在自定义 autograd.Function 中的角色

6.1 自定义 Function 里为什么要手动设置 requires_grad

写自定义torch.autograd.Function时,forward里创建的输出张量默认是不追踪梯度的,即使输入需要梯度。这时候需要在forward里手动设置:

class MyFunction(torch.autograd.Function): @staticmethod def forward(ctx, x): ctx.save_for_backward(x) out = x * 2 out.requires_grad_(True) # 手动激活 return out @staticmethod def backward(ctx, grad_output): x, = ctx.saved_tensors return grad_output * 2

但更规范的做法是:不要手动改 requires_grad,而是让 PyTorch 根据输入自动推断。实际上,只要输入x的requires_grad=True,forward里由x参与运算得到的out会自动带上requires_grad=True,不需要手动设置。手动设置反而可能在某些边界情况下引入不一致。

真正需要手动设置的是那些不依赖输入、但需要参与梯度的中间张量,比如一些可学习的常量、缓存等。这时候用requires_grad_()把它们标记为可追踪,是合理的。

6.2 双反向传播与 requires_grad 的交互

在计算二阶梯度(如 WGAN-GP 的梯度惩罚)时,requires_grad的设置需要格外小心。典型流程是:

x = torch.randn(4, 3, 32, 32, requires_grad=True) out = model(x) grad = torch.autograd.grad(out.sum(), x, create_graph=True)[0] # 此时 grad 的 requires_grad 为 True,因为 create_graph=True penalty = grad.norm().pow(2) penalty.backward() # 二阶梯度

这里create_graph=True是关键,它让一阶梯度本身也带上计算图,从而能继续求二阶导。如果忘了这个参数,grad的requires_grad会是False,后续penalty.backward()会报错说“不追踪梯度的张量不能反向”。

这个场景里,requires_grad_本身用得不多,但理解requires_grad在多次反向中的传递规律,是写出正确二阶梯度代码的前提。

7. 一些零散但实用的经验

关于requires_grad_,还有几个零散的点值得记下来。

第一,检查一个张量是不是叶子节点,用tensor.is_leaf。这个属性在调试“非叶子张量不能改 requires_grad”错误时非常有用。如果is_leaf是False,那你就别想着直接改它了,先detach()再说。

第二,requires_grad_()对整数张量无效。PyTorch 只允许浮点张量追踪梯度,对torch.int64之类的张量调用requires_grad_()会直接报错。这个在 embedding 层或索引操作附近容易遇到,因为索引张量通常是整数类型。

第三,冻结参数后,记得检查model.training状态。requires_grad=False不影响train()和eval()的切换。如果你冻结了主干但模型整体还在train()模式,Dropout 和 BatchNorm 仍然会按训练模式行为。很多时候“冻结了但效果不对”就是这里出的问题。

第四,用torch.autograd.grad而不是loss.backward()时,requires_grad的状态同样重要。torch.autograd.grad只对requires_grad=True的输入求梯度,如果某个输入被冻结了,传进去会报错或返回None。这时候要么解冻,要么用allow_unused=True参数容忍。

第五,保存和加载模型时,requires_grad状态不会被state_dict保存。state_dict只存参数值,不存梯度开关。所以加载模型后,需要重新设置冻结策略。这个在部署和恢复训练时经常被忽略,导致恢复训练后所有参数都被更新了。

8. 写在最后

torch.Tensor.requires_grad_()这个 API,签名简单到一行就能背下来,但它背后牵扯的是 PyTorch 自动微分机制里最核心的几条规则:叶子与非叶子的区分、计算图的构建时机、原地操作的影响、以及梯度追踪的传递性。我见过太多人因为不理解这些规则,在冻结参数、生成对抗样本、写自定义 Function 时反复踩坑。

如果只记一句话,那就是:改 requires_grad 之前,先问自己这个张量是不是叶子;如果不是,先 detach 再操作。这一条能避开八成以上的相关错误。

另外,实际项目里我更倾向于用param.requires_grad = False这种属性赋值来冻结参数,因为它更直观,而且在nn.Parameter上完全等价于requires_grad_()。只有在需要链式调用或处理普通 Tensor 时,才会用带下划线的方法。选哪种不重要,重要的是清楚它们改的是什么、什么时候改、改了之后对计算图有什么影响。

最后分享一个调试小技巧:当你怀疑梯度没传对时,打印一下关键张量的requires_grad、is_leaf和grad_fn三个属性,基本就能定位问题出在哪一环。这三个属性组合起来,就是 PyTorch 计算图的“体检报告”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进