news 2026/10/14 7:02:40

PyTorch参数冻结全攻略:从requires_grad到迁移学习实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch参数冻结全攻略:从requires_grad到迁移学习实战

用PyTorch训练模型时,冻结、固定部分参数这个需求有多常见?我自己的项目里,某次要把一个在大型公开数据集上预训练好的主干网络搬到另一个分类任务上,只希望更新后面的分类头,当时就是被“参数冻结”折腾了整整一个下午。后来想明白了,这个操作本质上非常简单:设置requires_grad = False,再让优化器别管这些参数,然后注意一下BN层和dropout的隐藏行为,基本就能跑通。这篇文章围绕这个核心操作展开,把底层原理、三种基础写法、解冻和差异化学习率这些进阶玩法,以及一个可以直接抄的完整案例全部讲透。不管你是刚开始写PyTorch的新手,还是已经写过不少训练脚本但没系统研究过冻结逻辑的老手,这篇都值得花几分钟过一遍,里面有我踩过的坑和后面总结出的经验。

1. 为什么需要冻结参数:先搞懂它到底在解决什么问题

1.1 冻结参数最常见的三类使用场景

第一个场景,也是最常见的,就是迁移学习。假设你已经有一个在大型数据集上训练好的模型,权重文件里存着模型从浅层边缘特征到深层语义特征的全部能力。现在要把它用到一个新的小数据集上,比如从通用的图像分类切换到某个特定领域的细粒度识别。如果你直接在新数据上从头训练整个模型,很可能因为数据量不够而严重过拟合,同时训练时间也会拉得很长。这时候最合理的做法是:把主干网络完全冻结,只训练新加的分类头,让模型保留原有特征提取能力,同时快速适应新任务。我第一次用这种方式做迁移时,效果立竿见影,训练时间和最终精度都比全量微调更可控。

第二个场景是资源受限。很多时候你的显存就那么大,模型又比较大,如果每个参数都计算梯度、保存梯度,可能直接在反向传播时爆显存。冻结一部分参数后,优化器不再为这些参数维护状态,显存能省出一块,训练速度也会提升。我印象很深的一次是在一张老显卡上调大模型,只靠冻结前面几层,就把训练跑起来了。当然这里要提前说清楚:冻结参数节省的主要是梯度张量空间和优化器状态空间,前向传播本身的计算量并不会明显减少,但它对整张大训练流程的影响仍然非常可观。

第三个场景是多任务学习和增量学习。比如你在做一个多任务模型,前面一个共享编码器,后面接了好几个任务头。为了让共享编码器学会通用特征,不因为某个新任务头而剧烈改变,你可以把共享编码器冻结,只训练当前任务相关的分支。又比如你已经在一个任务上训练好了某个模型,想把它接进另一个系统继续训,但担心旧能力被“灾难性遗忘”,这时候把旧模块冻结住也是一种很实用的保护手段。

1.2 冻结参数的底层原理:requires_grad与自动求导

要理解冻结参数,先得明白PyTorch自动求导的一个核心逻辑:每个Tensor都有一个requires_grad属性,默认创建的张量是False,但nn.Parameter默认是True。反向传播的时候,autograd只追踪requires_grad=True的张量以及由它们计算出的结果。如果你把一个参数字段的requires_grad置为False,那么在loss.backward()执行时,这个参数和一个普通的Tensor没有区别,不会被分配梯度,也不会参与梯度回传。

把这个机制说白一点:你的模型训练过程就像一个流水线,权重是流水线上的工人。requires_grad=False相当于给工人发了一张“只许按要求做事、不许自己学习改进”的工牌。前向传播时它的计算照常发生,输出的特征照样往下传;但到了反向传播计算梯度的时候,它直接跳过,不给自己记一笔“该往哪边调整”的账。这样一来,优化器拿不到它的梯度,自然也就不会更新它的值。

这里有一个容易被误解的点:冻结参数不代表这个层“不参与计算”。它只是不参与“学习”,但它的前向计算结果依然会影响后面的所有层。以我自己的经验,很多人第一次冻结后就以为模型输出会变成一成不变,其实完全不是这么回事——主干输出的特征还是会变化的,因为后面可训练层的输入依赖主干,但主干自身权重保持不变。理解这一点,对后面排查问题会很有帮助。

还有个细节值得单独说。PyTorch中的nn.Module.parameters()返回的是模型里所有nn.Parameter,但BN层里还有一对running_mean和running_var,这两个属于buffer而不是parameter,它们的requires_grad天然是False,不参与梯度更新。这很容易让人以为“冻结BN就等于冻结了BN的一切”,但实际上它俩在训练模式下依然会被更新,这个坑放到后面细讲。

2. 冻结参数的基础操作:一套标准组合拳

2.1 核心第一步:把参数的requires_grad置为False

最直接的冻结写法是遍历模型参数,逐个修改requires_grad。我常用的方式有两种:

# 写法一:冻结整个模型的全部参数 for param in model.parameters(): param.requires_grad = False # 写法二:冻结某个子模块的全部参数 for param in model.backbone.parameters(): param.requires_grad = False

如果模型是通过nn.ModuleList或nn.Sequential搭起来的子模块,写法二尤其好用。比如你想冻结主干网络的第1到第5个stage,但保留第6个stage继续训练,直接用model.features[:5]这类切片方式就能拿到对应参数集合。

完整的做法是配合named_parameters做按名筛选,这样你能清楚知道每个参数在哪一层,不用凭感觉去猜:

for name, param in model.named_parameters(): if name.startswith("backbone"): param.requires_grad = False else: param.requires_grad = True

说到这我提醒一句:named_parameters返回的是参数名和参数张量,你可以在启动训练前打印一遍,确认每个模块的requires_grad状态,这个操作成本极低,但能帮你省掉一上午的排查时间。我自己每次写冻结逻辑都会先在终端里打印出参数名、形状、requires_grad,确认无误再开始训练。

2.2 核心第二步:让优化器只接收可训练参数

设置完requires_grad = False之后,如果你直接用原来的优化器继续训练,有些情况下并不会立刻报错,但行为很可能不是你以为的那样。因为优化器里保存的param_groups仍然是之前传入的全部参数,step阶段会对每个参数检查grad,如果某个参数grad是None,它就会跳过。

麻烦的是,如果过滤后优化器里一个参数都不剩,PyTorch会让你看到一条非常明确的报错:ValueError: optimizer got an empty list of parameters。这个错误在迁移学习场景里太经典了,因为很多人把模型所有参数都设成False,然后忘了把分类头重新设为可训练。

标准写法是构建优化器的时候做一次过滤:

trainable_params = [p for p in model.parameters() if p.requires_grad] if len(trainable_params) == 0: raise ValueError("No trainable parameters found. Did you freeze everything?") optimizer = torch.optim.Adam(trainable_params, lr=1e-3)

注意我用列表推导式而不是filter(lambda ...),主要原因是列表推导式会立即计算并返回一个真正的列表,检查数量更方便,后续如果需要调试也很直观。生成的optimizer.param_groups[0]["params"]里就只会有这些可训练参数,反向传播后它们都有grad,优化器不会不知所措。

2.3 容易被忽略的BN层与Dropout

这是冻结参数操作里最隐蔽的坑,没有之一。很多人冻结完参数后,发现模型验证集上的指标怪怪的:明明权重没变,训练中BN的统计量却一直在变。原因是这样:PyTorch的BatchNorm层在model.train()模式下,前向传播时不仅会使用当前batch的均值和方差做归一化,还会用指数滑动平均更新running_mean和running_var。你即使把BN层的weight和bias的requires_grad全部设成False,只要模型处于train模式,这两份统计量照样会更新。

如果我不想让BN统计量也变,正确做法是手动把要冻结的BN层切到eval模式:

def freeze_bn(module): for m in module.modules(): if isinstance(m, torch.nn.BatchNorm2d): m.eval() m.weight.requires_grad = False m.bias.requires_grad = False

这里的关键点是m.eval()会把该模块的training标志设为False,即使整个模型仍处于model.train()状态,这个BN层也不会再更新running_mean和running_var。如果你用的是SyncBatchNorm或GroupNorm,处理逻辑类似,只是判断类型时换成对应类。这个技巧我在做生成模型和检测模型微调时反复用到,强烈建议写进自己的冻结工具函数里。

Dropout的问题也值得顺带提一句。如果你为了冻结BN把整个模型model.eval(),那么所有Dropout都会关闭,这可能会影响后面可训练模块的泛化表现。所以在“只想冻结参数、但希望模型整体仍然保持训练状态”的时候,千万别整个模型调用eval(),要精准地把BN层挑出来单独设置,这样Dropout该开的还开着,BN该冻的也冻住了。

3. 冻结参数的进阶操作:选择性冻结与解冻

3.1 按层名精准冻结:不止一刀切

实际项目中,冻结范围经常不是“整个主干”或“整个模型”这么简单。比如你用的预训练模型结构里,越靠前的层学到的越通用,越靠后的层越贴近原任务。这时候一个常见策略是冻结最前面的若干层,让后面的层跟着新任务继续微调。

用named_parameters精准定位是最好用的方式。假设你的模型里主干网络叫features,层级编号从0到6,你想冻结前4层:

freeze_prefixes = ("features.0", "features.1", "features.2", "features.3") for name, param in model.named_parameters(): if name.startswith(freeze_prefixes): param.requires_grad = False

还有一种更灵活的方式,用一个函数来判断该层的名字是否应该被冻结:

def should_freeze(name): frozen_keywords = ("backbone", "stem", "layer1", "layer2") return any(kw in name for kw in frozen_keywords) for name, param in model.named_parameters(): param.requires_grad = not should_freeze(name)

这种写法在模型结构经常调整的项目里特别省心。你把冻结规则集中在一个函数里,每次改模型结构后只改这个函数就行。另外提一句,不同层的参数数量差异可能很大,你最好在冻结前用param.numel()统计一下冻结量和可训练量,别指望靠眼睛看出来。我一般会写个报告输出,比如"Trainable params: 2.3M / Frozen params: 11.8M",心里有数再开跑。

3.2 解冻与渐进式训练

冻结并不意味着永远冻住。现在很多微调策略采用的是“先冻结后解冻”两段式:第一阶段把全部参数冻住,只训练分类头,让新头从随机初始化稳定到可用水平;第二阶段解冻主干尾部的一些层,用更小的学习率联合训练,效果往往比从头到尾全量微调更好,而且更稳。

解冻本身很简单,把requires_grad改回True即可:

for name, param in model.named_parameters(): if name.startswith("backbone.features.4"): # 举例子,解冻第4层之后的层 param.requires_grad = True

但这里有一个超级容易踩的坑:如果你直接沿用旧的优化器,它是不会自动把新解冻的参数纳入管理的。因为优化器的param_groups在创建时就已经固化了参数列表。必须手动把解冻的参数加进去:

optimizer.add_param_group({ "params": [p for p in model.backbone.features[4:].parameters() if p.requires_grad], "lr": 1e-5, })

更省心的做法,是把解冻逻辑和优化器重建放在一起:

model.train() for param in model.parameters(): param.requires_grad = True # 也可以只解冻部分层,不全部放开 # 重建优化器 trainable_params = [p for p in model.parameters() if p.requires_grad] optimizer = torch.optim.AdamW(trainable_params, lr=1e-5)

我个人在实际项目中通常选择重建优化器,因为重建之后param_groups干净利落,不容易出现“我以为它更新了其实没有”的错觉。如果训练中间有学习率调度器,记得也重新构建或重置一下,否则调度器的状态也可能对不上。

3.3 不同层不同学习率

冻结严格来说只是“把学习率设为0”,而差异化学习率则是“给不同层分配不同的学习率”。它和冻结并不冲突,反而经常组合使用。比如主干解冻了但希望它变化尽量小,可以给主干一个很小的学习率,同时给分类头比较大的学习率。

在PyTorch里通过param_group实现:

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

这里有个理解重点:param_groups是列表,每个元素是一个字典,里面的"params"都对应一组参数,同时可以单独指定"lr"、"weight_decay"等超参数。如果你初始化时没给某一组指定lr,PyTorch会使用传入的默认学习率。我建议显式写清楚,别依赖默认值。

另外,不同组的学习率调度器行为也需要注意。如果你用torch.optim.lr_scheduler.StepLR这种全局调度器,它会给每组参数统一按比例缩放学习率;如果你希望不同组用完全不同的衰减策略,就得自己写回调,或者创建多个调度器分别管理。

一个我在实践中验证有效的小技巧:给weight_decay也做差异化配置。主干网络某些层冻结后,还留在优化器里的残差层可能更需要限制权重衰减,而分类头因为数据量少,反而希望weight_decay小一点。把weight_decay也放进param_group里分开调,经常能比只调lr获得更稳的收敛。

4. 完整实操案例:从模型定义到冻结训练全流程

4.1 一个适合演示的Demo模型

为了把上面这些知识点串起来,我写一个可以直接跑通的小案例。假设我们要做一个图像分类迁移实验,模型结构是“主干网络+分类头”,用完全自定义的轻量网络演示,方便你看到每一行代码在干什么。

import torch import torch.nn as nn import torch.nn.functional as F class DemoNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.backbone = nn.Sequential( nn.Conv2d(3, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1), ) self.head = nn.Linear(32, num_classes) def forward(self, x): feat = self.backbone(x) return self.head(feat.flatten(1)) model = DemoNet(num_classes=10)

这个模型很小,但已经包含了卷积、BN、池化、全连接这些常见结构,完全能展示冻结、解冻、BN处理等操作。如果换成真实的预训练大模型,代码逻辑完全一样,只是参数数量更大、BN层更多。

为了模拟迁移学习场景,我可以像下面这样先“预训练”一下主干网络并保存权重。实际操作中你更可能是直接加载别人训练好的权重文件,而不是真的在自己机器上从头预训练一遍。

4.2 冻结流程编排与训练循环

接下来进入正题,按“冻结主干、只训练分类头”的流程来组织代码。

# 假设模型加载了预训练权重 model.load_state_dict(...) # 第一步:冻结主干 for param in model.backbone.parameters(): param.requires_grad = False # 第二步:单独处理BN,防止running stats在train模式下继续更新 for m in model.backbone.modules(): if isinstance(m, torch.nn.BatchNorm2d): m.eval() m.weight.requires_grad = False m.bias.requires_grad = False # 第三步:构建优化器,只保留requires_grad=True的参数 trainable_params = [p for p in model.parameters() if p.requires_grad] print(f"Trainable params: {sum(p.numel() for p in trainable_params)}") optimizer = torch.optim.Adam(trainable_params, lr=1e-3)

注意第二步里,我既设置了BN的eval(),又把它的weight和bias设为requires_grad=False。这里有个细节:BN的weight和bias是参数,如果主干冻结时我已经把所有backbone.parameters()设成False,其实已经包含它们了。但这里单独写出来,是因为实际项目中主干里某些层可能因为你的冻结规则并没有被完全覆盖,在写通用工具函数时最好同时保证这两件事,避免出现“没设全”的遗漏。

然后是一个最简训练循环,用于演示冻结后的实际训练流程:

def train_one_epoch(model, loader, optimizer, device="cpu"): model.train() total_loss = 0.0 for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() logits = model(x) loss = F.cross_entropy(logits, y) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader)

在上面的训练循环里,模型整体调用的是model.train(),但因为我们单独把backbone里的BN层设成了eval模式,所以它们处于一种“整体训练、局部冻结”的混合状态。这正好是最常见的迁移学习配置。

4.3 冻结前后对比与预期收益

为了让“冻结到底带来了什么收益”这个问题更直观,我列一张基于上面这个Demo模型的估算表。假设使用Adam优化器,单精度训练,主干参数约10万,分类头参数约300:

对比项全部参数可训练冻结主干、只训分类头
参与更新的参数数量约100300约300
优化器维护的Adam状态约100300 × 2份状态约300 × 2份状态
梯度张量空间全部参数都要存梯度仅分类头需要存梯度
反向传播计算范围全网络只回传到分类头附近
每轮训练时间基准通常明显缩短

以这个小模型为例,Adam优化器每个参数会维护exp_avg和exp_avg_sq两份状态,每个状态按4字节float32算,100300个参数在优化器状态上大约需要0.8MB,看似不多,但换到一个千万级参数的预训练模型上,这个数字会放大到几十上百MB。显存不够的时候,冻结参数省下的这部分空间就很关键了。

实际项目里我还观察到一个更有趣的现象:冻结主干后,loss曲线通常会更快下降。原因是分类头在初始阶段只需要学习如何利用固定特征做映射,不用同时担心主干特征漂移带来的连锁反应。等分类头的loss下降到一个平台期,再解冻部分主干继续联合训练,收敛曲线往往比一次全量微调更平滑,最终精度也经常更稳。

5. 我在冻结参数时踩过的坑:排查与经验速查

5.1 四个高频报错与解决办法

第一个高频报错是ValueError: optimizer got an empty list of parameters。出现原因很简单:你把模型所有参数都设成了requires_grad=False,然后构建优化器时一个可训练参数都挑不出来。最常见于迁移学习的初级代码里,冻结完整个模型忘了把分类头或新加模块重新设为可训练。解法就是按需把分类头参数置为True,或者开局就用for n, p in model.named_parameters():打印一遍状态做检查。

第二个坑比报错更隐蔽:解冻后optimizer不更新这部分参数。很多人解冻时只改了requires_grad=True,但没重新构建优化器,也没有add_param_group。结果训练跑了几百步,loss纹丝不动,打印参数权重却发现那几层根本没变。原因是优化器的param_groups里根本没有这些参数。排查思路很简单,打印optimizer.param_groups[0]["params"]看长度就知道有没有被纳入。我建议把“是否重建优化器”当成解冻操作的固定步骤,避免这个坑。

第三个问题与BN有关:模型改了requires_grad=False,但验证集的指标仍然在波动,而且模型保存的权重没有变化,可预测结果会变。这种现象多半是因为BN层的running_mean/running_var还在被更新。你冻结的是参数,但如果模型处于train模式,BN统计量会继续滑动更新,这会直接影响前向输出。前面提到过,解法是单独把要冻结的BN层m.eval(),或者使用自定义层来禁用统计量更新。另一个备选方案是在训练循环里手动记录BN统计量并回填,但没必要,eval()已经能解决。

第四个坑出现在加载checkpoint时,报错文字类似加载optimizer.state_dict时发现param group数量不匹配。原因是你冻结/解冻之后重建了优化器,新的优化器里param group顺序、数量或参数列表和保存时不一致,但你又试图把旧优化器的状态直接加载进来。解决思路分两步:第一步,如果冻结/解冻后重建了优化器,就不要加载旧optimizer state,只加载model state,或干脆把optimizer state也按新结构重新组织;第二步,如果你想继续之前的优化器状态,那就在冻结/解冻之前保存好param group结构,保证重建后与保存时完全一致。我在实际项目中比较省心,凡涉及冻结状态变化的阶段,都不加载旧优化器,只保留模型参数。

5.2 冻结操作避坑速查表

我把上面的经验整理成一张速查表,方便你直接在训练前对照检查:

检查项错误示范正确做法
冻结标记设了requires_grad=False但不检查效果打印named_parameters的requires_grad状态
优化器过滤直接用model.parameters()建优化器用[p for p in model.parameters() if p.requires_grad]
BN层冻结只冻结weight/bias还需要对BN层单独调用eval()
整体train/eval为了冻结BN把整个model设为eval精准对BN模块m.eval(),保留Dropout等训练行为
解冻后更新改requires_grad=True但沿用旧优化器重新构建优化器或用add_param_group
checkpoint加载重建优化器后强行加载旧optimizer state只加载model state,或严格保证param group一致

这张表几乎覆盖了我遇到过的大部分冻结问题。每次写新训练脚本时,我都会照着表自查一遍,五分钟就能避免半天排查时间。尤其是BN和优化器那两行,是在各种项目里反复踩过的,非常值得你提前留个心。

最后再分享一个我自己的使用习惯:在冻结相关代码里加一行日志输出,训练启动时打印当前可训练参数数量和占比。比如"Trainable: 2.35M / Total: 12.1M (19.4%)"。这个数字能让你一眼判断当前配置是否符合预期,避免“以为只在训头部,实际把所有参数都解冻了”或者反过来的乌龙。我自己看多了之后,已经形成条件反射:模型一旦表现异常,第一件事就是看这个数字和BN的状态。写训练脚本本来就是为了提高效率,这种小习惯虽然不起眼,但长期下来省下的时间真不少。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 7:02:34

AI时代软件工程新范式:从编排智能到评测驱动的工程实践

从去年开始,"AI能不能帮我把活干了"就成了团队里讨论最多的话题。一边是各类编码助手越来越顺手,一边是项目里因为AI代码引入的诡异Bug越来越多。直到我偶然看到某高校公开课上那份名为"CS146S"的教学大纲,才意识到问题不…

作者头像 李华
网站建设 2026/10/14 7:02:11

当 AI 开始「用」Office:从 univer 看文档运行时的下一站

🌊 专注 AI 大模型与前沿科技深度解析,习惯从工程师视角拆解技术热点,让我们一起在技术浪潮中保持清醒与好奇 🚀当 AI 开始「用」Office:从 univer 看文档运行时的下一站你大概遇到过这种场景:让 AI 帮忙把…

作者头像 李华
网站建设 2026/10/14 7:02:09

电脑怎么录制屏幕?别乱录!参数+代码+输出效果一文搞定

一、场景痛点:你录屏时踩过的坑,可能比你想象的多不管你是程序员录制技术演示、教师制作在线课件、产品经理输出操作说明,还是自媒体人剪辑游戏高光——‌电脑怎么录制屏幕‌这个问题,看似按下快捷键就能解决,实则暗藏…

作者头像 李华
网站建设 2026/10/14 7:02:00

FreeRTOS任务栈溢出根因分析与高水位排查实战

1. 任务栈为什么这么容易翻车:先从机制看根因做嵌入式这几年,FreeRTOS 项目里最磨人的一类问题就是任务栈溢出。它不像编译错误那样当场报错,而是设备跑了几小时甚至几天后才突然硬故障,或者某任务里的全局变量被悄悄改掉&#xf…

作者头像 李华
网站建设 2026/10/14 7:01:26

安卓日志抓取全攻略:ADB logcat、Bugreport与内核日志实战指南

先说个真实场景:手机游戏打到一半直接闪退回桌面,或者微信视频突然断流,你去联系官方售后,工程师开口第一句大概率是“麻烦抓一份 Log”。很多朋友一听“Log”就懵了,觉得是不是要刷机、要写代码、要拿到什么内部工具才…

作者头像 李华
网站建设 2026/10/14 7:01:17

企业生产网NTP时间同步方案:从授时原理到部署排障实践

1. 先把“为什么”讲清楚:生产网里的时间不是小事这些年做企业网络项目,几乎每个生产网都被时间问题坑过。最典型的一幕是:故障告警拉响,运维把应用日志、数据库日志、交换机日志组织出来比对,结果发现同一事件的时间差…

作者头像 李华