
不少朋友第一次接触模型微调时都会遇到一个困惑为什么大家都在说“冻结网络”、“freeze层参数”这个词听起来很玄乎其实说白了就是让某些层的权重在训练过程中保持不变只更新剩下的部分。PyTorch里做这件事非常灵活可以用requires_grad False控制梯度也可以在优化器里手动过滤参数还可以配合eval()模式处理BatchNorm这类有状态层的坑。这篇文章我想把这套东西彻底讲明白。我会从为什么要冻结参数讲起然后展示几种常见的冻结姿势再结合一个贴近真实业务的多任务模型例子把“冻结哪些层、怎么解冻、学习率怎么配”这些实操细节全部过一遍。你可能是刚入门PyTorch的新手也可能已经在做迁移学习但总被BN层坑得头皮发麻这篇文章的思路和踩坑经验对你应该都有直接帮助。1. 为什么要冻结网络参数从省显存到防过拟合先说结论冻结参数这个操作本质上是在干预优化过程。神经网络靠反向传播更新参数每一步都需要计算损失函数对各层权重的梯度。如果把某些层的requires_grad设为False梯度根本不会流到那里参数自然保持不动。这一招最常见的应用场景有三个。第一个场景是迁移学习。你拿一个在大规模数据集上预训练好的模型当特征提取器比如图像分类里的ResNet、文本分类里的BERT这些模型底层已经学会了通用的视觉纹理或句法结构。下游任务的训练数据往往只有几千张图、几万条文本如果从零开始训练所有层模型很容易把训练集里的噪声背下来。把预训练部分的参数冻住只训练最后新增的分类头相当于站在巨人肩膀上做微调。这个思路在数据量小、任务与预训练任务相似度高的场景下尤其好用。第二个场景是显存和算力限制。训练一个大规模模型显存消耗主要来自中间激活值和优化器状态。冻结部分层之后PyTorch在前向传播时依然需要算这些层的输出但反向传播不再为它们缓存激活值优化器也不再为这些层保存动量或二阶矩估计。我实测过一个约3亿参数量的模型冻结掉底部80%的层之后训练峰值显存直接降了将近一半单卡跑批大小也能翻倍。第三个场景容易被人忽略——防止灾难性遗忘。在做增量学习或者多任务学习时如果希望模型在完成新任务的同时不丢掉旧任务的能力冻结掉共享底层参数是性价比最高的方案之一。我在某个多语言文本分类项目中一开始让所有层都参与训练结果发现模型在新语言上的指标上去了旧语言的F1值却掉得厉害。后来把共享编码器的前几层冻住只训练顶层和任务专属的分类分支旧任务的表现就稳定下来了。当然冻结参数不是万能的它也有明显的适用边界。比如预训练模型和下游任务差异巨大时底层特征完全不更新会导致模型学不到任务相关的特定表示。再比如你对某个任务的标注数据足够多那让所有层都参与微调通常能拿到更好的最终精度。所以实际操作中我一般不会一上来就把模型焊死而是先做几组“全量微调 vs 部分冻结”的对照实验看指标差距再决定。2. 核心机制拆解requires_grad、优化器过滤与训练/评估模式要把冻结参数玩明白先得弄懂PyTorch底层那套梯度传播逻辑。很多人只知道设requires_gradFalse就行但对它背后的机制、和eval()的搭配、还有优化器行为之间的关系并不清楚结果总是遇到“明明冻住了参数怎么还在变”这种怪事。2.1requires_gradFalse的传播阻断原理PyTorch的自动求导引擎基于计算图工作。每当你做一次前向传播框架会记录哪些张量参与了运算反向传播时沿着这个图把梯度从损失函数一路传回各层的权重矩阵。requires_gradFalse的作用非常直接这个张量不会再出现在计算图的叶子节点列表里反向传播经过它时直接跳过梯度传不到它身上参数自然纹丝不动。这里有个细节值得说透——即使你冻结了某一层前向传播仍然会正常计算该层的输出结果这个结果会被继续送入后续层。也就是说网络结构没有变“前向推理路径”是完整的只是“参数的更新路径”被切断了。我在给别人讲这个概念时经常打一个比方冻结参数相当于把一台机器里某个调节旋钮拧紧并锁死机器还在运转产品还是会出来只是这个旋钮以后不会再被工人调整了。写代码时要注意requires_grad必须递归设置到所有子模块上。比如你用了torchvision.models.resnet50想冻结整个特征提取器只写model.layer1.requires_grad_(False)可不够因为layer1里面还有若干个Bottleneck模块每个模块里面又有多个卷积层和BN层。正确的做法是遍历模块递归设置def freeze_module(module): for param in module.parameters(): param.requires_grad False这个函数会遍历传入模块的所有子模块并冻结其参数比直接操作某个模块要稳妥得多。2.2 优化器里的“隐形坑”参数过滤与缓存状态很多人在冻结参数后忽略了一个关键步骤——优化器需要重新构建。假设你先创建了一个优化器传入所有参数然后才把某些层设成requires_gradFalse那么优化器内部依然保留着这些参数的引用并且会在每个step里试图更新它们。虽然由于梯度为0参数实际上不会变化但这会白白浪费计算和显存而且没有意义的动量状态累积起来还可能留下隐患。正确做法是先用requires_gradFalse冻结参数再创建优化器并且只把那些仍然requires_gradTrue的参数传进去frozen_params [] trainable_params [] for name, param in model.named_parameters(): if param.requires_grad: trainable_params.append(param) else: frozen_params.append(param) optimizer optim.Adam(trainable_params, lr1e-4)这里我用named_parameters()而不是parameters()目的是拿到每个参数的名字方便后续打印、筛选或者按层名称做精细化的学习率分组。而且把这个参数列表打印出来你会很清楚当前到底哪些层在训练、哪些层是冻结的。另一个容易踩的坑是优化器缓存。某些优化器比如SGD的动量、Adam的一阶二阶动量是根据参数状态来维护缓存的。你虽然已经设了requires_gradFalse但旧优化器里关于这些参数的缓存可能依然存在。更稳妥的做法是只要你调整了冻结范围就直接重新创建一个优化器实例不要复用旧的。这不用写多少代码但能有效避免很多诡异的问题。2.3train()与eval()模式不只是Dropout的开关初学者很容易把参数冻结和model.eval()混为一谈。它俩的确有交集但完全是两码事。model.eval()会把模型切到评估模式影响的是Dropout、BatchNorm这类在训练和推理时行为不同的层但它不会把任何参数设成requires_gradFalse。也就是说即使你调用了model.eval()只要优化器里还有这些参数它们照样会被更新。反过来冻结参数也不意味着你要把整个模型切到eval()模式。这里有个典型误区很多人冻结了特征提取层之后习惯性地调用了model.eval()结果BN层在推理时用的是全局统计量而不是当前batch的统计量这反而影响了训练效果。更常见的做法是只对含BatchNorm的部分单独处理或者干脆将BN层也放在训练模式下但冻结其中的可学习参数。在动手之前请务必弄清楚这几件事的边界requires_gradFalse控制的是梯度计算和参数更新optimizer决定哪些参数参与更新model.train()/model.eval()控制的是层的行为模式。参数冻结时我一般会把最终要训练的那部分模块保持train()模式但会根据BN层是否会被冻结来单独处理。3. 实操从一个多任务模型看不同冻结策略的代码落地理论说得再多不如看一个完整的实战例子。我在很多业务场景中会遇到这样的情况模型需要同时完成一个主任务和一个辅助任务但辅助任务的标注数据非常有限如果让所有参数都参与优化很容易在辅助任务上过拟合。下面我用一个简化版本的多任务分类模型来演示不同的冻结策略。3.1 搭建一个共享底层、多分支输出的模型假设我们有一个众包标注平台需要同时预测图片的“主类别”和“质量分数”。主类别的标注数据很充裕质量分数的标注却比较少。一个合理的模型结构是共享一个ResNet风格的编码器然后分出两个分类头。这个模拟项目X的目标就是验证“冻结共享编码器、只训练两个分类头”能否在辅助任务上取得更好的泛化效果。import torch import torch.nn as nn from torchvision import models class MultiTaskModel(nn.Module): def __init__(self): super().__init__() self.backbone models.resnet18(pretrainedTrue) self.backbone.fc nn.Identity() # 去掉原来的全连接层 self.head_main nn.Sequential( nn.Linear(512, 128), nn.ReLU(inplaceTrue), nn.Linear(128, 10) ) self.head_aux nn.Sequential( nn.Linear(512, 64), nn.ReLU(inplaceTrue), nn.Linear(64, 1) ) def forward(self, x): feat self.backbone(x) out_main self.head_main(feat) out_aux self.head_aux(feat) return out_main, out_aux这个模型很典型共享编码器是预训练好的ResNet18两个任务各有一个小分类头。现在我们要把策略落在代码上。3.2 策略一只训练分类头冻结全部backbone参数这个策略最简单也是我早期最常用的方法。整个预训练编码器一笔不动只训练两个新增的全连接分支。这样做的好处是训练速度极快baseline跑起来特别快几分钟就能看到路线是否可行。def freeze_encoder_and_train_heads(model): # 冻结backbone for name, param in model.backbone.named_parameters(): param.requires_grad False # 只把head里的参数传给优化器 trainable_params [] for name, param in model.named_parameters(): if param.requires_grad: trainable_params.append(param) optimizer torch.optim.Adam(trainable_params, lr1e-3) return optimizer这里有个很容易被忽略的细节我们用的是model.named_parameters()但param.requires_grad是直接读的参数属性。如果某些层之前已经被设置为不可训练这里会自动跳过它们无需额外过滤。训练时你可以顺手打印当前正在训练的层名确认自己的意图真正落到了代码层面for name, param in model.named_parameters(): if param.requires_grad: print(训练中的参数:, name)3.3 策略二冻结backbone的前几层解冻后面高层并设置差异化学习率冻结全部backbone最省事但上限也不高。如果任务和预训练任务差距比较大底层特征完全不动的话最终精度往往不够理想。实践中我会用一个更精细的策略底层的浅层特征比如边缘、颜色、纹理这些通用结构完全冻结高层的语义特征比如跟具体物体部件相关的特征解冻微调两个分类头用更高的学习率backbone的高层用较低学习率。def partial_freeze_with_lr(model): # 冻结layer1和layer2解冻layer3和layer4 for name, param in model.backbone.named_parameters(): if name.startswith(layer1) or name.startswith(layer2): param.requires_grad False else: param.requires_grad True optimizer torch.optim.Adam([ # 分类头大学习率 {params: model.head_main.parameters(), lr: 1e-3}, {params: model.head_aux.parameters(), lr: 1e-3}, # backbone高层小学习率 {params: filter(lambda p: p.requires_grad, model.backbone.layer3.parameters()), lr: 1e-5}, {params: filter(lambda p: p.requires_grad, model.backbone.layer4.parameters()), lr: 1e-5}, ]) return optimizer差异化学习率是这策略的灵魂。底层特征通用性强、更新风险高就完全锁死高层特征与具体任务相关性强可以适当更新但预训练的知识也别丢得太多所以用一个很小的学习率。分类头是随机初始化的要从头学所以给它最大的学习率。这样一组参数在分类准确率和训练稳定性上都比较平衡。关于解冻的边界一个稳妥的方法是观察每层特征图的激活变化。你可以拿一个固定batch的数据在前向传播时打印每层输出的均值方差对比冻结前后哪些层的输出变化最大。如果发现layer2解冻后输出分布已经乱得不成样子说明学习率太高了或者干脆把它冻回去更稳。3.4 策略三在冻结状态下做“冷启动”再用“解冻全网微调”做热阶段有些任务虽然数据量一般但迁移学习的起点非常关键。我惯用的一个方法是两阶段训练第一阶段让backbone全面冻结只训练分类头目标是让分类头在预训练特征上找到一个大致的解第二阶段解冻全部参数用一个非常小的学习率做全网微调。这比直接从随机初始化训练整个网络要稳定不少也能明显提升最终精度。# 阶段一只训练head def stage_one(model): for p in model.backbone.parameters(): p.requires_grad False return torch.optim.Adam(model.head_main.parameters(), lr1e-3) # 阶段二全部解冻 低学习率 def stage_two(model): for p in model.backbone.parameters(): p.requires_grad True optimizer torch.optim.Adam(model.parameters(), lr1e-5) return optimizer这里的关键点是阶段一和阶段二之间除了学习率变化最好把阶段一的优化器丢弃掉重新建一个新的。原因前面提过优化器缓存里还保存着阶段一的状态突然换到全网微调时那些状态可能会干扰更新方向重新创建更干净。4. BN层、参数加载与多卡训练的连环坑冻结参数看起来是个小操作但在真实项目里总会碰到各种“看着没事、跑起来就挂”的情况。这里面的坑我基本都踩过一遍一个个说。4.1 BatchNorm层只冻结可学习参数远远不够这是最容易踩、也最隐蔽的坑。BatchNorm层虽然是通过weight和bias学习缩放和平移但它的行为还依赖两个“状态量”——running_mean和running_var。这两个量不是靠反向传播更新的而是在每次前向传播时用一个动量系数在线更新的。所以即使你设了requires_gradFalse只要model.train()模式下这个BN层还在跑前向计算running_mean和running_var就会不断被当前batch的统计量更新。这在训练和推理时会出现严重的分布漂移问题。如何正确冻结BatchNorm层我给你一个标准做法def freeze_bn(mod): for m in mod.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() # 让BN层使用全局统计量而不是更新running_mean for p in m.parameters(): p.requires_grad False这样做的本质是让BN层切到评测模式前向传播时直接使用running_mean和running_var不再用本batch的统计量更新它们。这样才能做到真正的“冻结”。很多人只设了requires_gradFalse却忘了让BN层切模式结果模型在训练时表现正常、在验证时指标掉得厉害八成就是这里出了问题。那到底要冻结BN吗需要分场景讨论。如果冻结的是整个backboneBN层务必按上面这种方式处理因为它内部的统计数据如果持续变化会干扰冻结层输出的特征一致性如果模型很小、任务分布和预训练分布差别很大BN也可以保持默认的跟踪更新只是这时它没有被真正冻结你需要心里有数。4.2 加载预训练权重时严格对齐参数名和形状用别人训练好的权重来微调如果你的模型结构是从torchvision.models.resnet18(pretrainedTrue)直接加载的那名字天然对得上。但如果你自己改了结构比如把fc换成一个双分支头加载权重时就很可能报size mismatch的错。这类报错原因很直接预训练权重的某个层形状和你的模型对不上。比如原始的ResNet18最后一个全连接层是(512, 1000)形状你把它换成了(512, 10)那加载时就肯定会冲突。解决办法是strictFalse只加载能对上的部分checkpoint torch.load(pretrained_weights.pth) model.load_state_dict(checkpoint, strictFalse)但这里要小心strictFalse会把无法匹配的层全部忽略包括你本来想加载的层。要确保想加载的特征提取层能被正确覆盖我建议加载前先打印一下检查一下model.load_state_dict(checkpoint, strictFalse) for name, param in model.named_parameters(): print(name, param.shape, param.requires_grad)另外如果你是自己动手做的模型加载之前还应该确认state_dict里的键名是否跟模型里的一致。比如某层以前叫backbone.conv1.weight后来你重构成了encoder.conv1.weight键名就对不上了得手工做一下映射。最省事的做法是在模型保存和加载时都使用同一套命名规则别在中间改名。4.3 多卡训练时冻结逻辑与All-Reduce的交互使用DataParallel或DistributedDataParallel时冻结参数还有一个需要提前想清楚的地方。框架在每次反向传播时会对梯度做跨卡同步All-Reduce。如果你只在一张卡上冻结参数而在其他卡上没有保持一致梯度同步就会出问题最终导致更新行为完全不可控。而且要特别注意在DistributedDataParallel模式下归一化层和同步层本来就是全局同步的。如果通过某些方式把BN层也带上了同步逻辑你冻结时也要考虑sync BN的行为。我的经验是在做多卡训练前先把冻结参数的全部逻辑固定下来让所有进程在完全一致的参数状态下进入训练在训练过程中不要动态增删requires_grad的冻结范围否则每个step之间的行为会出现不可复现的差异。如果你的显存足够而DistributedDataParallel在冻结部分层的条件下依然会因为“某些参数虽然requires_gradFalse但依然侵入同步逻辑”而产生警告可以设置find_unused_parametersTrue但要注意这会在每次前向时多花一点点时间做探测。这不是安全问题但会让你多排查一会儿。4.4 冻结后验证参数真的“冻住”了有时候你按照教程写了冻结代码模型跑起来也挺正常却拿到的结果不对。别急着调整超参数先花10秒钟确认参数到底有没有在变。一个非常实用的检查手段是记录训练前后参数矩阵的差异def check_frozen_param_change(model, before_state, after_state, name_prefix): changed [] for name, param_before in before_state.items(): if name.startswith(name_prefix): param_after after_state[name] if not torch.equal(param_before, param_after): changed.append(name) return changed这能帮你迅速排查到底是哪一层参数被动过。另一个快速的检查手段是训练几百个step之后直接查看某个冻结层权重的数值变化有多小。如果在双精度下依然完全没有变化那说明确实冻住了如果有微小变化说明它其实还在更新得去优化器和requires_grad上找原因。5. 常见问题速查表与排查指南最后把这些年实际遇到过的问题整理成一个速查表方便你直接对照定位。现象常见原因排查方法设置了requires_gradFalse但参数还在变化优化器是之前创建好的仍包含这些参数引用重新创建优化器只传入requires_gradTrue的参数模型训练表现正常验证指标却很差BN层还在用当前batch统计量更新running_mean将含BN的模块切到eval()模式并冻结其可学习参数load_state_dict报size mismatchfc等层结构调整后和预训练权重形状不匹配使用strictFalse并手动确认能匹配的层数量多卡训练时某个batch的loss不正常各卡冻结范围不一致梯度同步出问题保证所有进程的冻结逻辑一致避免动态增删冻结层明明把某个层冻住了但显存没有降低冻结并不会修改前向传播路径激活值仍会缓存想省显存需要结合torch.no_grad()或重写前向逻辑冻结后训练不收敛分类头学习率过大或冻结范围过大导致模型表达能力不足调低学习率或者部分解冻backbone高层换数据集后效果反而更差预训练特征与目标领域差异较大增加解冻层范围或改用更大学习率微调高层验证时使用了随机初始化参数忘记加载预训练权重就开始训练初始化加载后再冻结并训练这张表看起来简洁但每一个现象背后我都交过“学费”。尤其是BN层那条我早期做迁移学习时反反复复被它折磨后来才意识到requires_gradFalse和BN.eval()是两件必须同时做的事。另一个值得单独提一下的建议是在冻结参数时把训练过程分成热身阶段再进入正轨。比如先只训练分类头10个epoch再用很小的学习率解冻全部参数微调5个epoch很多任务上效果都会比从头全量微调更稳。我自己的经验是这种“先冷后热”的做法通常不额外增加太多训练耗时但收敛效果和最终指标都会明显更稳。最后再分享一个小技巧如果你在跑一个现有项目的代码别人已经把冻结逻辑写好了但你并不确定它冻结了哪些层那就直接在第一个step之后把全部参数快照打印出来就是前面那个对比函数一眼就能看出实际生效范围。这个习惯帮我避免过至少十次无效训练。关于固定网络这件事本质上不复杂但也不是“设一行requires_gradFalse”就完事了。它和优化器、BatchNorm、模型结构、加载逻辑、分布式训练全都牵涉在一起。只要你把层次搞清楚遇到问题时按上面这张表对号入座绝大多数坑都能迈过去。希望这篇分享对你接下来的训练项目有帮助。