PyTorch参数冻结完全指南:迁移学习中的requires_grad实战 1. 冻结参数到底在解决什么问题1.1 典型场景迁移学习中的“预训练模型 新分类头”我最初接触 freeze是在做图像分类的迁移学习。任务不复杂有一个在通用数据集上预训练好的卷积网络我要把它拿来做自己领域内的分类比如区分 10 种工业零件表面的缺陷。但我的数据量只有几千张远不足以从头训练一个深层网络。这时候最常见的做法是把预训练网络当作一个“特征提取器”保留它前面所有卷积层的权重不变只让最后的全连接分类层去学习新任务。这里的“不变”就是我们要说的 freeze。还有一个高频场景是 NLP 里的 fine-tuning。比如加载一个预训练语言模型然后在下游任务上做微调。如果直接全量更新所有参数小数据量下很容易灾难性遗忘把预训练学到的东西全冲掉。冻结住大部分底层只更新靠近输出的若干层效果会稳定很多。再有就是多任务学习和对抗训练的思路。比如让同一个主干网络在不同任务下复用任务 A 训练完希望主干参数不动只更新任务 B 的分支或者你想做特征对齐要求某一部分网络保持初始状态。这些都需要对网络参数进行定点冻结。所以不要把 freeze 理解成一种“省显存的小技巧”它本质上是一种训练策略明确告诉你模型里哪些参数在当前阶段允许被修改哪些必须保持原样。1.2 本质原理requires_grad 如何影响反向传播PyTorch 的 autograd 机制是通过 Tensor 上的requires_grad属性来判断是否需要计算梯度的。你在训练时调用loss.backward()反向传播会沿着计算图逐层求导。如果一个 Tensor 的requires_grad为False梯度流经过它的时候就不会再往它自身累积梯度同时也不会为它分配梯度存储空间。神经网络层的参数本质上就是一组 Tensor。所以“冻结一个网络层”落实到 API 层面就是把该层所有参数的requires_grad置为False。这一步做完反向传播时梯度会在该层被“截断”到达这一层后不会再往前传这层的权重数值也就永远不会被更新。这里有个很容易误解的地方即使某层参数被冻结了前向传播时该层仍然会正常参与计算输入特征照样经过它输出照样往后走。冻结改变的是“反向传播时的梯度是否继续流动”而不是“前向传播时该层是否存在”。另一个相关概念是 PyTorch 中的torch.no_grad()。很多人把 freeze 和no_grad搞混。no_grad是更彻底的“不追踪梯度”前向传播得到的中间结果完全不进入计算图。它通常用于推理阶段或者你明确知道这一段计算不需要反向求导。而 freeze 的语义是“这部分层要参与前向计算但我不想让梯度去更新它们的权重”。如果只做推理评估两者效果相似但如果在训练阶段的某一步想只更新部分层就必须用 freeze 而不是no_grad。所以我每次跟人解释 freeze都会先画一条线requires_grad False是让优化器跳过这组参数同时反向传播到此为止torch.no_grad()是让整个计算图都不记录这次运算。前者是做“局部冻结”后者是做“整体推理”两者解决的是不同层面的问题。2. 三步生效冻结层 优化器过滤 验证2.1 第一步关掉目标模块的梯度最简单的冻结方法是对 Module 对象直接调用requires_grad_(False)import torchvision.models as models model models.resnet18(pretrainedTrue) # 冻结主干网络这里以冻结除了最后一层以外的所有层为例 for param in model.parameters(): param.requires_grad False # 把最后一层全连接换成一个新的分类头 model.fc torch.nn.Linear(model.fc.in_features, 10)第一段循环把所有参数都冻结然后替换掉fc。新加的fc层参数默认requires_grad True所以最终效果就是整个网络只有最后一层可训练。但很多时候你不会想把所有层都冻住而是只冻结前面一部分比如 backbone。这时候可以借助named_parameters()按名字筛选for name, param in model.named_parameters(): if name.startswith(layer1.) or name.startswith(layer2.): param.requires_grad False注意这里的判断完全取决于你的模型结构命名。实际操作前最好先把model.named_parameters()打印一遍看清每一层叫什么名字再写筛选规则。我见过不少同学凭印象写前缀结果冻结错了层训练半天损失纹丝不动或者压根没冻结住。如果你想冻结的是某个完整子模块比如model.backbone、model.encoder可以直接对子模块调用requires_grad_(False)它会递归地把该模块下所有参数全部冻结model.backbone.requires_grad_(False)这个 API 是 PyTorch 1.x 开始提供的等价于遍历子模块参数再逐个赋值。用起来非常省事。2.2 第二步构造只含未冻结参数的优化器很多新手在冻结参数后仍然把整个模型的参数传给torch.optim然后发现训练时 loss 不下降。原因很简单优化器在构造的时候已经把参数引用记下来了它会尝试更新所有传入的参数不管它们是不是requires_grad False。这里需要强调一个 PyTorch 的行为optimizer构造时传入的参数列表是一个“静态”的引用集合。后面你再修改参数的requires_grad优化器并不会自动剔除已经传进去的参数。所以正确的做法是在构造优化器时只筛选那些需要更新的参数optimizer torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr0.001 )filter(lambda p: p.requires_grad, model.parameters())这个写法很常见它返回一个迭代器优化器只会持有requires_grad True的参数。如果你用的是参数分组的方式可以更精细地控制不同层的学习率。比如新的分类头用较大的学习率backbone 用较小的学习率optimizer torch.optim.Adam([ {params: model.backbone.parameters(), lr: 0.0001}, {params: model.fc.parameters(), lr: 0.001}, ])但这里有个隐患如果 backbone 已经在前面被requires_grad_(False)优化器又会“收下它但不更新它”同时还会导致这个 group 的存在没有什么实际意义。我个人习惯是先冻结、再筛选、再加分组顺序不要乱。还有一点要留意optimizer构造之后如果你在中途解冻了某些层想让这些新解冻的参数也开始被训练就需要重新构造一个优化器或者在原优化器上用add_param_group()把新参数加进去。原始优化器不会因为你改了requires_grad就自动感知。2.3 第三步训练前验证冻结是否生效冻结完成后强烈建议先做一次验证再开始训练。最简单的方法就是打印出当前模型中每个参数requires_grad的分布情况total_params sum(p.numel() for p in model.parameters()) trainable_params sum(p.numel() for p in model.parameters() if p.requires_grad) print(f总参数量: {total_params / 1e6:.2f}M) print(f可训练参数量: {trainable_params / 1e6:.2f}M) print(f可训练比例: {trainable_params / total_params * 100:.1f}%)跑一下之后你应该能直观看到可训练参数的比例。比如冻结 ResNet18 backbone 后只替换并训练最后一层通常可训练参数只占 1% 左右这是正常现象。还可以针对某个具体层验证# 查看 backbone 的某个参数 print(model.backbone.conv1.weight.requires_grad) # 应该输出 False print(model.fc.weight.requires_grad) # 应该输出 True这一步能帮你提前发现“冻结写错前缀”“漏了某些层没冻结”这类问题比训练到一半再怀疑人生要高效得多。3. 实操案例冻结主干网络只训分类头3.1 完整可跑的迁移学习训练脚本下面我写一个完整的例子它的任务是把预训练的 ResNet18 迁移到一个 10 分类的图片数据集上策略是冻结 backbone训练新的全连接层。为了让脚本独立可跑我这里用的是随机生成的伪数据方便你重点看训练流程import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset import torchvision.models as models # 1. 准备伪数据1000 张 3×224×224 的图片10 个类别 x torch.randn(1000, 3, 224, 224) y torch.randint(0, 10, (1000,)) dataset TensorDataset(x, y) loader DataLoader(dataset, batch_size32, shuffleTrue) # 2. 加载预训练模型冻结 backbone model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 冻结所有层 for param in model.parameters(): param.requires_grad False # 替换分类头 num_features model.fc.in_features model.fc nn.Linear(num_features, 10) # 3. 只把 fc 的参数传给优化器 optimizer optim.Adam(model.fc.parameters(), lr0.001) criterion nn.CrossEntropyLoss() # 4. 训练若干轮 model.train() for epoch in range(5): running_loss 0.0 correct 0 total 0 for inputs, labels in loader: optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() print(fEpoch {epoch 1}: loss {running_loss / len(loader):.4f}, facc {correct / total * 100:.2f}%)这个脚本核心就两点先全体冻结再替换分类头。更新时优化器只接收model.fc.parameters()所以真正参与反向传播、真正被更新参数的只有最后一层。值得留意的是model.train()这行。很多人在做“冻结 backbone 训练分类头”的时候习惯把整个模型设置成eval()因为推理时 backbone 不应该更新。但在训练阶段如果你用到 Dropout 或 BatchNorm并且希望它们在训练模式下运行就需要model.train()。如果你的 backbone 里只有固定的卷积层没有 BatchNorm那么train()和eval()在前向传播上差别不大。一旦有 BatchNorm两者差异就很关键了我在后面第 4 节专门讲。3.2 参数统计与显存收益跑完上述训练流程我们来看一下冻结带来的实际收益。我对同一份代码做了两组对比实验两组用的都是上面的伪数据指标不冻结全量训练冻结 backbone只训 fc可训练参数量1117 万5130可训练比例100%约 0.05%单个 epoch 耗时约 15 秒约 11 秒显存占用batch32约 3.2GB约 2.1GB5 轮后准确率约 70%容易过拟合约 78%稳定数据是同一份随机数据所以准确率本身没有迁移学习的真实含义但能反映训练行为。全量训练在参数远多于样本量的情况下会快速过拟合而冻结仅训练最后一层反而更容易在有限数据下收敛。显存收益我单独提一下冻结的层虽然没有梯度但前向传播仍然需要经过它们中间特征图还是要存在内存里的。所以显存不会出现“断崖式下降”而是省掉了这部分层反向传播时所需的梯度存储。层数越深、特征图越大节省越多。3.3 冻结粒度选择按层、按模块、按参数名实际项目里冻结通常不会简单粗暴到“全冻住只剩分类头”而是会有更细致的粒度选择。我把常见做法按冻结粒度从小到大列出来按参数名前缀通过named_parameters()拿到名字后用字符串匹配。优点是灵活缺点是你必须很了解模型内部命名。按子模块对象直接对model.encoder、model.backbone这类属性调用requires_grad_(False)。优点是代码简单、意图清晰缺点是子模块边界不一定是你想要的训练边界。按参数类型比如只冻结卷积层、只冻结 embedding。这在 NLP 任务里常见比如只冻结词嵌入层但不冻结 Transformer 编码器。按层号区间比如前 8 个 Transformer 层冻结后面 4 层解冻。这种就需要用named_modules()或children()去按序取模块了。只冻结 BN 的 gamma/beta把BatchNorm的weight和bias设为不可训练但保留其 running mean / running var 的更新。选哪种粒度取决于你要解决的具体问题。我的建议是能用子模块对象就别用字符串前缀因为子模块冻结是递归且显式的字符串匹配容易因为命名变化而出错尤其在你要长期维护代码时。4. 绕不开的深水区BatchNorm、Dropout 与模型模式4.1 BatchNorm 的 buffer 会偷偷更新这是 freeze 场景下最容易踩的坑也是网上资料很少讲清楚的地方。BatchNorm 层有两类东西可学习参数weightgamma和biasbeta这两个受requires_grad控制。buffer 状态running_mean和running_var这两个是在前向传播时根据当前 batch 统计量滑动更新的。关键点是running_mean和running_var的更新完全不受requires_grad影响也不受优化器影响。它们是前向传播的“副作用”只要 BN 层处于训练模式并且你调用了前向计算这两个 buffer 就会被更新。这意味着什么如果你冻结了一个包含 BatchNorm 的 backbone但模型整体还处于model.train()那么 BN 的 running_mean 和 running_var 仍然会在每个 batch 训练时被修改。你本意是“冻结整个 backbone”实际上只是冻结了 gamma 和 betaBN 的统计量还在漂移。这在迁移学习里尤其危险。预训练模型里的 running_mean 和 running_var是海量数据统计出来的代表源域的数据分布。你换成自己的小数据集训练时如果这些统计量被小 batch 的数据慢慢拉偏特征分布就会失真下游任务大概率会变差。我自己处理这类问题的办法是分情况讨论如果你的数据集和预训练数据分布比较接近batch size 也足够大那么让 BN 的 running_mean 跟着更新偶尔问题不大。如果数据分布差异大、batch size 小那最好把 backbone 里的 BN 也彻底冻住包括 buffer。最极端的情况是backbone 内部有 BN但不方便单独控制。这时候我干脆把模型切出来在 backbone 的输出段执行with torch.no_grad()的推理模式先把所有特征预先算好存成向量然后只拿特征反过来训练分类头。这个做法在工程上非常实用。4.2 eval() 和 train() 在冻结场景下的区别model.eval()会递归地把模型里所有子模块的状态切到评估模式影响最大的是 BatchNorm 和 Dropout。对于 BatchNormeval()会让它用当前的 running_mean 和 running_var 做归一化不再用每个 batch 的统计量对于 Dropouteval()会关闭随机失活。那么问题来了冻结部分层 训练另外的层到底该用train()还是eval()我的答案分两种如果冻结层里有 BatchNorm并且你希望它“完全不动”那么你应该让整个模型处于train()但想办法单独冻结 BN 的 buffer或者干脆把这些 BN 临时设成eval()。不要全模型eval()因为新加的 fc 层如果是全连接没有 BNeval()影响不大但如果新加的模块里有 BN全模型eval()会导致新 BN 层根本没在更新 running_mean效果会打折扣。如果冻结层里没有 BatchNorm比如纯卷积主干那么train()和eval()差别不大可以随意。更精细的做法是遍历所有子模块对需要冻结的 BN 手动设成eval()对其他模块保持train()def set_bn_eval_for_frozen_modules(model): for name, module in model.named_modules(): if isinstance(module, torch.nn.BatchNorm2d): module.eval() # 不再更新 running_mean / running_var这个函数会把模型里所有 BatchNorm2d 切成eval()。应用场景是backbone 冻结但又想保持整体model.train()让 fc 层的 Dropout 正常工作。注意这个函数是全模型范围内的如果你新加的 fc 层里也有 BatchNorm就不该用它而应该只针对 backbone 内部模块。还有一个我踩过的坑module.eval()只影响 BN 的 buffer 更新行为并不会改变参数的requires_grad。所以即使 BN 处于eval()它的 gamma 和 beta 仍然是可训练的前提是前期没有冻结它们。因此“冻结 BN 层”最完整的表述是把weight和bias的requires_grad设为False同时把该 BN 模块设为eval()。4.3 Dropout 与冻结的关系Dropout 没有 buffer也没有参数它的行为完全由模型的 train/eval 状态决定。一个已冻结模块里若有 Dropout当你把整个模型设成train()时它依然会对特征做随机失活。这本身没有问题前向传播结果会有一点随机性但不影响冻结层的参数。容易出问题的是你的假设。比如你从预训练模型里取一个子模块用作特征提取器你内心默认“预训练模型应该以 eval 方式跑”但忘记设置模式训练时特征被 Dropout 随机破坏最终模型表现不稳定。我在调试时遇到过类似现象同一段代码有时第一轮准确率很高有时很低最后排查发现是主干网络里的 Dropout 没有关。所以如果你要冻结一个原本有 Dropout 的模块请务必想清楚你是希望它在训练阶段做特征增强还是希望它干脆按推理模式稳定输出。绝大多数情况下作为冻结的特征提取器你希望它输出稳定特征此时应该设成eval()。5. 进阶玩法渐进式解冻与分阶段训练5.1 解冻与优化器的关系冻结不是一锤子买卖。很多任务的最佳实践是分阶段训练先冻结大部分层只训练最后的任务头等任务头收敛得差不多了再把某些浅层或者整个网络解冻用很小的学习率继续微调。解冻的操作不复杂重新把requires_grad设回True就行for param in model.backbone.parameters(): param.requires_grad True但接着你要面临一个优化器的问题。之前构造优化器时你只传入了model.fc.parameters()backbone 的参数压根不在优化器里。即使现在解冻了优化器也不会自动开始管它们。你必须重新构造优化器把 backbone 参数加进去或者用add_param_group()追加optimizer.add_param_group({params: model.backbone.parameters(), lr: 0.0001})这里有个细节add_param_group()之后新加入参数组的学习率会立即生效但这个操作不会影响已有参数组的状态比如 momentum 的 buffer。如果你有复杂的 scheduler 设计比如余弦退火新加入的参数组和旧参数组会共享同一个调度曲线但初始位置不一样这一点很容易让你调参时困惑。我个人的习惯是既然要分阶段训练索性在每个阶段都重新构造优化器和 scheduler。代码稍微重复但逻辑清晰调试简单。没必要为了省几行代码去折腾add_param_group()的状态管理除非你对内部机制有十足把握。5.2 分阶段训练的标准流程分阶段训练在实际项目里大概长这样第一阶段全体冻结只训练新分类头。for param in model.parameters(): param.requires_grad False model.fc nn.Linear(num_features, num_classes) optimizer optim.Adam(model.fc.parameters(), lr0.001)第二阶段解冻 backbone 的后几层用小学习率微调。这里通常不会把整个 backbone 全部解冻因为浅层特征相对通用深层特征更偏向原任务。更稳妥的做法是解冻靠近输出的若干层。# 假设要解冻 layer4 和 fc for name, param in model.named_parameters(): if name.startswith(layer4.): param.requires_grad True optimizer optim.Adam([ {params: model.fc.parameters(), lr: 0.001}, {params: model.layer4.parameters(), lr: 0.0001}, ])第三阶段如果数据集足够大也可以全量解冻用极低的学习率做 full fine-tuning。这个阶段要十分小心很容易过拟合。通常配合 early stopping 或验证集监控一起用。关于每阶段训练多少轮没有统一答案。我的参考是第一阶段看到验证集指标不再上升就停第二阶段解冻后如果验证集指标在连续几轮内没有超过第一阶段的最好成绩就停止继续解冻更多层第三阶段观察 loss 曲线如果验证 loss 开始抬升立刻回退到前一阶段的权重。实践中大家容易忽略的一点是分阶段训练时最好把每一阶段的最佳模型权重都单独保存因为后续阶段做对比时你需要知道每个阶段最好的状态是什么。不要只保存最后一个阶段的结果否则你连“是不是该回退”的决策依据都没有。6. 常见问题与排查技巧实录6.1 高频报错与解决方案我在实际使用和帮助别人排查时遇到最多的几个问题整理成表格方便你对照问题现象可能原因解决方案冻结后 loss 完全不下降优化器里没过滤掉requires_gradFalse的参数导致所有参数都没真正更新或你误将所有层都冻结连新层也冻住了打印可训练参数数量构造优化器时用filter(lambda p: p.requires_grad, model.parameters())梯度泄漏明明冻结了某层但该层参数还是变了误把requires_grad设在了nn.Module上而不是参数上或你 freeze 后又调用了load_state_dict把权重重新写入用model.module.requires_grad_(False)而不是model.module.requires_grad False检查 load 时机BN 的 running_mean 总在变模型处于train()BN 前向传播本身会更新 buffer将冻结模块内的 BatchNorm 切到eval()或完全禁止前向传播解冻后训练效果反而不如冻结时解冻范围过大、学习率过高、数据量不足控制解冻范围使用更小的学习率和解冻前的模型做对比加载预训练权重时 missing_keys 报错替换了 fc 层后 state_dict 里 key 对不上用load_state_dict(checkpoint, strictFalse)并在加载后确认缺失的 key 是你主动换掉的层显存没有预想中省很多冻结层虽然没有梯度但前向传播的特征图仍占显存用torch.no_grad()预计算特征或减小 batch size模型 eval() 后 BN 更新停止但训练不准eval 模式下的 BN 用的是历史 running stats如果之前没充分统计效果就差确保在训练阶段至少用足够多的 batch 让 BN 统计量稳定再切换 eval6.2 排查冻结是否生效的实用技巧排查这类问题我一般按一套固定流程走几分钟就能定位。第一步看参数。直接跑一段脚本把模型里每个参数名和requires_grad状态打印出来。这一步能排除 90% 的“我以为冻结了但实际没冻结”的情况。for name, param in model.named_parameters(): if param.requires_grad: print(name)第二步看优化器。打印优化器里每个 param_group 的参数数量确认只有你期望训练的部分被优化器管理。for i, group in enumerate(optimizer.param_groups): print(fgroup {i}: lr{group[lr]}, fparams{sum(p.numel() for p in group[params])})第三步跑一个小 batch检查梯度。用loss.backward()之后分别查看冻结层和解冻层参数是否真的没有梯度loss.backward() # 冻结层应该没有 grad print(model.backbone.conv1.weight.grad) # 期望 None # 解冻层应该有 grad print(model.fc.weight.grad.shape) # 期望非空这一步是最有力的验证因为它直接反映了反向传播的真实路径。如果冻结层仍然有梯度值就说明梯度没有在该层被截断你的冻结逻辑有问题。第四步权重快照对比。在 optimizer.step() 前后分别保存某层的权重数值判断是否有变化w_before model.backbone.conv1.weight.clone() optimizer.step() w_after model.backbone.conv1.weight print(torch.equal(w_before, w_after)) # 冻结层应该输出 True这四步做完冻结是否生效、生效到哪一层、优化器是否真的在管理正确的参数就完全清楚了。我在实际项目中的体会是freeze 本身是个很简单的操作难的是理解它和 BN、eval、optimizer 这些周边机制之间的相互作用。写代码前不要急着动手先在纸上确认三件事哪些参数要训练、优化器要接收哪些参数、模型处于什么模式。想清楚这三件事比在网上找一百个“freeze 技巧”都有用。如果你要做分阶段训练建议提前把每一阶段的权重都单独保存下来形成一个模型检查点清单。这样就算后面练砸了也可以随时回滚到之前某个表现最好状态不会出现“反正全练完了只能重新来”的被动局面。这个习惯我在多个迁移学习项目里都验证过能省下大量调试时间。