PyTorch requires_grad_() 深度解析:梯度冻结与解冻的陷阱与最佳实践
1. 从一次“诡异”的梯度消失说起
最近在复现一个经典的图像分类网络时,我遇到了一个让人挠头的现象:模型在训练初期,损失值纹丝不动,准确率也卡在随机猜测的水平。我检查了数据加载、损失函数、优化器,甚至怀疑是不是学习率设得太小。一通排查下来,问题竟然出在一个看似不起眼的地方——我在加载预训练模型权重后,为了“保护”某些层不被更新,手动将它们的requires_grad属性设为了False。后来,当我需要重新微调这些层时,我理所当然地使用了tensor.requires_grad_(True)来“激活”梯度计算。然而,训练依然没有起色。
这个经历让我意识到,很多朋友对 PyTorch 中requires_grad和requires_grad_()的理解,可能还停留在“开/关梯度”的层面。实际上,它们的行为远比一个简单的布尔开关要微妙,尤其是在涉及计算图构建、参数冻结与解冻、以及模型部署等场景时,理解其深层机制至关重要。今天,我们就来彻底拆解requires_grad_(True)这个操作,看看它背后到底发生了什么,以及我踩过的那个坑究竟是怎么形成的。
简单来说,requires_grad是 PyTorchTensor的一个属性,它决定了在反向传播过程中,是否需要计算该张量相对于某个标量损失函数的梯度。而requires_grad_()是一个就地(in-place)方法,用于修改这个属性。但“设置为 True”并不总是意味着“梯度开始流动”,其生效条件和影响范围,是理解 PyTorch 自动微分(Autograd)系统的关键一环。
2. requires_grad 与 Autograd 计算图:不仅仅是属性开关
要理解requires_grad_(True),我们必须先回到 PyTorch 自动微分的核心——动态计算图。当你对requires_grad=True的张量进行操作时,PyTorch 会跟踪所有操作,构建一个由Function节点组成的有向无环图(DAG)。这个图记录了从输入到输出的完整计算路径。
2.1 计算图的构建与梯度流
假设我们有两个张量:
import torch a = torch.tensor([1.0, 2.0], requires_grad=True) b = torch.tensor([3.0, 4.0], requires_grad=True)此时,a和b是计算图中的“叶子节点”(leaf tensor),它们是由用户直接创建的,并且显式要求计算梯度。
当我们执行运算:
c = a * b # 逐元素相乘 d = c.sum() # 求和得到一个标量PyTorch 会动态地构建计算图。c是MulBackward函数的输出,d是SumBackward函数的输出。a和b是这些函数的输入。当我们调用d.backward()时,Autograd 引擎会沿着这个图反向执行,调用每个Function节点中定义的backward()方法,将梯度从d一路传播回a和b。
这里的关键在于:计算图是在前向传播(执行运算)时即时构建的。一个张量的requires_grad属性,决定了它是否会被记录到计算图中,以及后续的运算是否会以它为基础继续构建需要梯度的子图。
2.2 requires_grad_(False) 的真正含义:从图中“剥离”
当我们对一个叶子张量(比如模型参数param)调用param.requires_grad_(False)时,我们做了两件事:
- 将张量本身的
requires_grad属性设置为False。 - 更重要的是,这向 Autograd 引擎发出信号:在未来的前向传播中,任何以该张量为起点的运算,其产生的中间张量默认都不会被纳入需要梯度的计算图。
例如:
param = torch.randn(3, 3, requires_grad=True) # 初始需要梯度 param.requires_grad_(False) # 冻结该参数 # 前向传播 output = model(input) # 假设model使用了param loss = criterion(output, target) loss.backward() # 此时,param.grad 将为 None,因为计算图在param处“断开”了在这个例子中,param的梯度不会被计算,也不会被优化器更新。这是模型微调中冻结底层特征的常用技巧。
2.3 requires_grad_(True) 的生效条件:时机至关重要
现在来到核心问题:如果之后我想解冻这个参数,调用param.requires_grad_(True),会发生什么?
答案是:它只对未来的计算有效。
requires_grad_(True)仅仅修改了张量自身的属性。它不会追溯性地修改已经构建好的计算图。之前因为param.requires_grad为False而导致没有记录梯度计算路径的运算,其历史不会被改变。
这就是我踩坑的原因。我的操作顺序是:
- 加载预训练模型(所有参数
requires_grad=True)。 - 冻结部分层(
layer.parameters()遍历并设置param.requires_grad_(False))。 - 训练几个 epoch(冻结层无梯度)。
- 想要解冻这些层,于是调用
param.requires_grad_(True)。 - 继续训练,发现解冻的层仍然没有梯度更新。
问题根源:在第4步之后,我直接开始了新的训练循环。但是,优化器(如torch.optim.SGD或Adam)在初始化时,会捕获所有requires_grad=True的参数。当我第2步冻结参数后,这些参数从优化器的param_groups中被“移除”(严格说是优化器不再更新它们,但PyTorch的优化器设计是仍然持有引用,只是忽略grad为None的参数)。当我第4步将requires_grad改回True后,优化器并不知道这一变化,它内部维护的参数列表和状态(如动量)并没有自动更新。
所以,正确的解冻流程应该是:
# 1. 设置参数需要梯度 for param in layer.parameters(): param.requires_grad_(True) # 2. 关键步骤:将解冻的参数重新添加到优化器中,或者创建一个新的优化器。 # 更简单通用的做法是,在修改requires_grad后,重新定义优化器,只传入requires_grad=True的参数。 optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=0.001)如果不重新创建或更新优化器,即使梯度计算出来了(param.grad不为None),优化器的step()函数也可能不会正确地更新这些参数,因为它的内部状态没有为这些“新加入”的可训练参数初始化相应的缓存(如Adam的动量缓存)。
3. 深入辨析:requires_grad_()、detach() 与 no_grad() 的边界
在控制梯度流时,我们还有detach()和torch.no_grad()这两个常用工具。它们与requires_grad_()有何区别?理解这个能帮你更精准地控制模型行为。
3.1 detach():创建无梯度的“副本”,切断历史
tensor.detach()会返回一个新的张量,这个新张量与原始张量共享底层数据存储,但它是一个新的叶子节点,并且requires_grad=False。最关键的是,它从当前计算图中被“分离”出来,其之前的运算历史被完全切断。
x = torch.tensor([1.0], requires_grad=True) y = x * 2 z = y.detach() # z是一个新叶子,requires_grad=False w = z * 3 # w的运算不会记录梯度,因为z不需要梯度 loss = y.sum() # loss的计算图包含 x -> y loss.backward() # x.grad = 2 # z.grad 和 w.grad 不存在,因为它们不在计算图中与requires_grad_(False)的区别:
x.requires_grad_(False)是就地修改x本身,影响之后所有以x为起点的运算。y = x.detach()是创建了一个新的张量y,x本身的性质不变,y与x之后的计算图无关。detach()常用于将中间变量从计算图中取出,用于可视化、评估或作为不需要梯度的输入传递给另一个网络,而原始计算图的其他部分保持不变。
3.2 torch.no_grad():上下文管理器,临时禁用梯度追踪
torch.no_grad()是一个上下文管理器。在其作用域内进行的所有运算,无论输入张量的requires_grad是什么,都不会被记录到计算图中。它主要用于推理阶段,可以显著减少内存消耗,因为不需要为反向传播存储中间变量。
x = torch.tensor([1.0], requires_grad=True) with torch.no_grad(): y = x * 2 # y.requires_grad = False, 且运算不被记录 # 即使x需要梯度,这里的乘法也不会构建计算图 z = x * 3 # 离开no_grad上下文,这里会正常构建计算图与requires_grad_(False)的区别:
requires_grad_(False)是针对特定张量的持久性设置。torch.no_grad()是针对一段代码块的临时性设置,影响其中所有张量的行为。它更轻量,常用于模型验证 (model.eval()常与torch.no_grad()搭配使用)。
3.3 使用场景对比表格
| 操作 | 作用对象 | 是否就地 | 主要用途 | 对计算图的影响 |
|---|---|---|---|---|
tensor.requires_grad_(False) | 单个张量 | 是 | 永久冻结模型参数(如微调时冻结特征提取器)。 | 该张量之后参与的前向运算默认不构建梯度计算路径。 |
tensor.requires_grad_(True) | 单个张量 | 是 | 解冻之前冻结的参数。 | 仅影响该张量之后参与的前向运算。需同步更新优化器。 |
tensor.detach() | 单个张量 | 否(返回新张量) | 获取一个与当前张量数据相同但无梯度、无历史的新张量。常用于阻止梯度流向某一部分(如GAN中固定生成器训练判别器)。 | 完全切断返回的新张量与之前计算图的联系。原张量不受影响。 |
torch.no_grad() | 上下文内的所有运算 | 不适用 | 模型推理、计算评估指标、手动更新参数(避免在优化器外产生梯度)。 | 临时禁用整个上下文内的梯度计算图构建。 |
注意:一个常见的误区是,认为设置了
requires_grad=False或使用了no_grad()后,计算速度会大幅提升。实际上,主要节省的是内存(因为不用存储中间变量的梯度信息)和反向传播的计算开销。前向计算本身的开费几乎不变。
4. 实战中的典型场景与避坑指南
理解了原理,我们来看看requires_grad_(True)在真实项目中的应用场景和那些容易踩的坑。
4.1 场景一:渐进式解冻与差分学习率
在迁移学习中,渐进式解冻是一种有效策略:先冻结所有层训练分类头,然后从顶层开始逐层解冻并训练。这里就涉及到requires_grad_(True)的批量操作。
# 假设我们有一个预训练模型 `pretrained_model` # 第一步:冻结所有层 for param in pretrained_model.parameters(): param.requires_grad = False # 注意,这里直接赋值属性也可以,与调用方法等效。 # 第二步:替换并训练新的分类头 classifier = nn.Linear(pretrained_model.feature_dim, num_classes) model = nn.Sequential(pretrained_model, classifier) # 此时只有classifier的参数需要梯度 # 训练几个epoch后,解冻最后两个骨干网络块 unfreeze_layers = [pretrained_model.layer4, pretrained_model.layer3] for layer in unfreeze_layers: for param in layer.parameters(): param.requires_grad_(True) # 解冻 # *** 关键步骤:更新优化器 *** # 方法A:重新创建优化器(简单可靠) optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3) # 方法B:如果使用像Adam这样有状态的优化器,且想保留其他参数的状态,操作会复杂很多。 # 通常更推荐方法A,除非有特别理由需要保留优化器状态。避坑点:
- 优化器状态:如上所述,解冻后必须更新优化器。对于 Adam 等带有动量(momentum)和自适应学习率(如
exp_avg,exp_avg_sq)的优化器,新解冻的参数没有历史状态。如果强行将它们加入旧的优化器参数组,可能会导致训练初期不稳定。新建优化器是最稳妥的做法。 - 学习率设置:解冻的层通常需要更小的学习率,以防止破坏预训练的特征。这就是差分学习率。在新建优化器时,可以通过传递一个字典列表给
param_groups来实现。optimizer = torch.optim.Adam([ {'params': classifier.parameters(), 'lr': 1e-3}, # 分类头用较大学习率 {'params': pretrained_model.layer3.parameters(), 'lr': 1e-4}, # 浅层用较小学习率 {'params': pretrained_model.layer4.parameters(), 'lr': 1e-4}, ])
4.2 场景二:梯度检查与模型调试
有时我们需要检查模型中特定部分的梯度是否正常传播。requires_grad_()可以用于临时“激活”某个中间层的梯度检查,而不影响其他部分。
# 假设我们怀疑某个中间层 `mid_layer` 的输出没有梯度 # 可以先将其输入和该层本身的参数梯度需求打开 original_requires_grad = [] for param in mid_layer.parameters(): original_requires_grad.append(param.requires_grad) param.requires_grad_(True) # 执行一次前向和反向 output = model(input) loss = output.sum() loss.backward() # 检查 mid_layer 输出的梯度 if mid_layer.output is not None: print(mid_layer.output.grad) # 查看梯度是否存在且不为零 # 恢复原来的 requires_grad 状态 for param, original_flag in zip(mid_layer.parameters(), original_requires_grad): param.requires_grad_(original_flag)避坑点:
- 状态污染:调试完毕后,务必恢复参数的原始
requires_grad状态,否则会影响后续的正常训练流程。最好在修改前进行备份。 - 计算图残留:即使恢复了
requires_grad,这次调试运行构建的计算图可能已经增加了内存占用。在不需要时,最好在torch.no_grad()上下文中运行此类检查,或者直接使用loss.backward(retain_graph=False)让计算图在反向传播后自动释放。
4.3 场景三:自定义操作与 in-place 操作的风险
当你需要实现自定义的 PyTorch 函数(通过继承torch.autograd.Function)时,或者进行原地(in-place)操作时,对requires_grad的处理需要格外小心。
原地操作的风险:像tensor.add_(x)这样的原地操作,如果tensor的requires_grad=True,并且x也需要梯度,那么这个操作可能会破坏计算图,导致梯度错误或RuntimeError。Autograd 很难追踪原地修改。一个安全的模式是,在需要梯度计算的张量上,尽量避免原地操作,除非你非常清楚其后果。
自定义 Function:在编写forward和backward方法时,你需要根据输入张量的requires_grad属性来决定是否需要计算和返回梯度。通常,backward的输入是上游梯度,你需要计算并返回本层所有需要梯度的输入的梯度。如果某个输入在forward时requires_grad=False,那么在backward中对应位置应返回None。
class MyCustomFunction(torch.autograd.Function): @staticmethod def forward(ctx, input): ctx.save_for_backward(input) # 保存供backward用的数据 # ... 执行前向计算 return output @staticmethod def backward(ctx, grad_output): input, = ctx.saved_tensors # 计算梯度 grad_input = grad_output * some_operation(input) # 如果 forward 有多个输入,这里需要返回对应数量的梯度。 # 对于不需要梯度的输入,返回 None。 return grad_input # 使用 x = torch.randn(5, requires_grad=True) y = MyCustomFunction.apply(x) # y的requires_grad会自动继承自x(如果forward中所有输入都不需要梯度,则y也不需要)避坑点:
- 在自定义
Function的backward中,返回的梯度数量必须与forward的输入数量严格一致。对于不需要梯度的输入,返回None。 - 原地操作在自定义
Function中尤其危险,除非你明确知道自己在做什么,并且处理好了梯度传播。
5. 高级话题:requires_grad 与模型部署、量化及 ONNX 导出
在模型训练完成后,requires_grad的角色并未结束。在部署和转换阶段,它同样扮演着重要角色。
5.1 推理优化:统一设置 requires_grad=False
在将模型部署到生产环境时,为了获得最佳性能和最小内存占用,一个标准的做法是将整个模型设置为推理模式:
model.eval() # 将模型设置为评估模式(影响Dropout、BatchNorm等层的行为) with torch.no_grad(): # 上下文管理器,禁用梯度计算 for input in data_loader: output = model(input) # ... 后续处理但torch.no_grad()是上下文管理器。如果你希望模型对象本身在全局状态下都不计算梯度(例如,将模型保存后加载到另一个完全不需要训练的环境中),可以在导出前将所有参数的requires_grad设为False:
for param in model.parameters(): param.requires_grad_(False) # 然后保存模型 torch.save(model.state_dict(), 'inference_model.pth')这样,即使加载模型后不小心在非no_grad上下文中运行,也不会产生多余的计算图和内存开销。不过,更常见的做法是配合torch.jit.script或torch.jit.trace进行模型编译,编译过程会自动处理梯度需求。
5.2 模型量化与 requires_grad
PyTorch 的动态量化(Dynamic Quantization)或静态量化(Static Quantization)通常用于训练后,以减少模型大小和加速推理。量化过程本身(如寻找缩放比例和零点)可能需要微调(Quantization-Aware Training, QAT)。
在 QAT 中,你会在模型中插入伪量化(FakeQuantize)模块来模拟量化误差。这些伪量化模块通常需要梯度来进行微调。因此,在准备 QAT 时,你需要确保模型处于训练模式 (model.train()),并且相关参数的requires_grad为True。量化工具链(如torch.quantization.prepare_qat)会帮你处理大部分设置。
避坑点:如果你在量化后加载模型进行纯推理,记得将模型设置为eval()模式,并且使用torch.no_grad()。量化后的模型其参数本身可能已经是requires_grad=False的(例如torch.quantization.convert后的模型),但养成好习惯总是没错的。
5.3 导出 ONNX 模型
将 PyTorch 模型导出为 ONNX 格式时,requires_grad的状态会影响导出结果。
- 训练模式 vs 推理模式:ONNX 导出通常期望模型处于推理模式 (
model.eval())。在训练模式下,一些具有随机性的操作(如 Dropout)会被导出,这可能不是你想要在推理时看到的。 - 梯度信息:ONNX 图主要描述前向传播。即使你的 PyTorch 模型参数
requires_grad=True,导出的 ONNX 模型默认也不包含反向传播信息。ONNX 可以导出用于梯度计算的图(需要指定training=TrainingMode.TRAINING或启用export_params=True等),但这主要用于支持某些需要梯度的后端或框架(如 ONNX Runtime 的培训 API),并非典型推理场景。
一个稳健的 ONNX 导出流程是:
import torch model.eval() # 设置为评估模式 dummy_input = torch.randn(1, 3, 224, 224) # 示例输入 # 在 no_grad 上下文中导出,确保不构建计算图 with torch.no_grad(): torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}}, # 动态轴示例 )避坑点:确保你的dummy_input的requires_grad属性与推理时的输入一致(通常为False)。如果模型包含条件判断(如if语句),依赖于输入数据,导出可能会失败或产生不符合预期的静态图。对于复杂控制流,可能需要使用torch.jit.script先编译再导出。
6. 性能考量与最佳实践总结
最后,我们来聊聊requires_grad对性能的实际影响,并总结一些黄金法则。
6.1 内存与计算开销
- 内存开销:这是最主要的影响。对于
requires_grad=True的张量,在前向传播中,PyTorch 会为几乎所有中间计算结果(非叶子节点)保留一份副本,用于反向传播时的梯度计算。这被称为“计算图保留”。对于一个深度网络,这可能会使内存占用增加数倍。使用torch.no_grad()或设置requires_grad=False可以避免这部分开销。 - 计算开销:反向传播本身需要计算梯度,这当然会增加计算时间。但对于前向传播,
requires_grad的状态对计算速度影响微乎其微,因为前向计算的核心运算是相同的。
性能建议:
- 推理时务必使用
model.eval()和torch.no_grad():这是最重要的性能优化习惯,可以立即释放大量显存。 - 及时释放不需要的计算图:在训练循环中,如果某些中间变量在后续不再需要,可以考虑使用
.detach()将其从计算图中分离,或者确保loss.backward()调用后计算图被释放(默认retain_graph=False)。 - 梯度累加:当 GPU 内存不足时,一种技巧是使用梯度累加(Gradient Accumulation)。即在多个小批次(micro-batch)上计算损失和梯度,但不立即调用
optimizer.step(),而是让梯度在.grad属性中累加。在累积了足够步数后,再调用step()和optimizer.zero_grad()。在这个过程中,所有参数的requires_grad必须为True。
6.2 最佳实践清单
基于以上的讨论,我总结出以下处理requires_grad和requires_grad_()的最佳实践:
- 明确意图:在修改
requires_grad前,想清楚你的目的是永久冻结/解冻参数,还是临时禁用梯度计算。前者用requires_grad_(),后者用torch.no_grad()。 - 冻结/解冻后,更新优化器:这是最容易被忽略的坑。修改了参数的
requires_grad属性后,特别是解冻操作,一定要重新创建优化器,或者确保优化器的参数组包含了所有当前需要梯度的参数。对于简单的 SGD,可能影响不大,但对于 Adam 等复杂优化器,这是必须的。 - 使用
model.requires_grad_():PyTorch 的nn.Module也有requires_grad_()方法,可以递归地设置其所有子模块参数的requires_grad属性。这比手动遍历参数更简洁。# 冻结整个特征提取器 backbone.requires_grad_(False) # 只解冻分类层 classifier.requires_grad_(True) - 推理优先:任何不需要训练/微调的代码路径(如验证、测试、部署),都放在
model.eval()和torch.no_grad()上下文管理器中。 - 谨慎使用原地操作:在需要梯度的张量上,尽量避免
add_(),mul_()等原地操作,除非你完全理解其对 Autograd 的影响。非原地操作(如add())总是更安全。 - 调试工具:利用
torch.autograd.gradcheck来检查自定义autograd.Function的梯度实现是否正确。在复杂模型梯度异常时,可以使用torch.autograd.detect_anomaly()上下文管理器来帮助定位产生NaN或Inf梯度的操作。
回到我最初遇到的问题,根本原因就是忽略了“解冻参数需同步更新优化器”这一条。PyTorch 的自动微分系统设计得非常灵活和动态,requires_grad是这个动态系统的阀门之一。理解它何时、如何影响计算图的构建与销毁,而不仅仅是把它看成一个布尔标志,能让你在模型训练、调试和部署中更加游刃有余,避免很多隐晦的 bug。记住,在 PyTorch 的世界里,梯度流不是理所当然的,它是由你通过requires_grad、计算图上下文和优化器共同精心控制的。