1. 为什么需要给不同层设置不同学习率
做过迁移学习或者微调大模型的人,大概率都遇到过这样的场景:加载了一个预训练网络,想在自己的小数据集上微调,结果训练几轮下来,准确率不升反降,loss 曲线像过山车一样上下乱窜。排查半天,数据没问题、标签没问题、优化器也没写错,最后发现问题出在一个很隐蔽的地方——所有层用了同一个学习率。
这个坑我自己踩过不止一次。最典型的一次是拿一个在大型数据集上预训练好的图像分类网络,迁移到一个小规模工业缺陷检测任务上。骨干网络已经学到了非常通用的特征,而新加的分类头是随机初始化的。如果统一用 0.01 的学习率,骨干网络原本学好的权重会被迅速破坏,相当于把人家辛苦学的东西几轮就冲垮了;而分类头因为学习率不够大,收敛又特别慢。两边互相拖后腿,最后模型表现还不如从头训练。
核心矛盾在于:不同层的“知识状态”是不一样的。预训练层已经接近一个较优的解,只需要微调;而新初始化的层还是一张白纸,需要大步走。用一个学习率去同时满足这两种需求,本质上是在做妥协,而妥协的结果往往两头不讨好。
PyTorch 的optimizer在设计上其实早就考虑到了这一点。torch.optim里的每个优化器都接受一个参数组(param_groups)列表,每个参数组可以独立指定lr、weight_decay、momentum等超参数。也就是说,你完全可以把模型参数拆成几组,每组给不同的学习率。问题在于,很多人知道这个机制,但真到用的时候卡在第一步:怎么在模型里准确找到我想要的那一层?
这就是这篇要解决的核心问题。我会从“怎么定位层”讲到“怎么分组设学习率”,再到“实际训练中怎么验证设置生效了”,把整条链路拆开讲清楚。适合已经会写基本训练循环、但对参数组操作不太熟的读者,也适合想系统梳理迁移学习微调策略的人。
2. 在 PyTorch 模型里精准定位目标层
2.1 先搞清楚模型的结构长什么样
动手改学习率之前,第一件事是把模型“打印”出来看清楚。PyTorch 的nn.Module提供了几个非常实用的接口,我常用的有三个:
import torch import torch.nn as nn from torchvision import models model = models.resnet18(pretrained=True) # 方式一:直接 print,看整体层级 print(model) # 方式二:named_modules,看每一层的名字和类型 for name, module in model.named_modules(): print(name, "->", type(module).__name__) # 方式三:named_parameters,看具体参数张量的名字和形状 for name, param in model.named_parameters(): print(name, param.shape)这三个接口的区别很关键,很多人会混淆:
model.children()只返回直接子模块,不递归。model.named_modules()递归返回所有子模块,包括容器本身。model.named_parameters()递归返回所有可学习参数,名字里带层级路径。
实际定位层的时候,named_parameters()是最有用的,因为优化器最终操作的是参数,不是模块。你给优化器传的是model.parameters()或者具体的参数列表,所以必须精确到参数级别。
以 ResNet18 为例,named_parameters()的输出大概长这样:
conv1.weight bn1.weight bn1.bias layer1.0.conv1.weight layer1.0.bn1.weight ... layer4.1.conv2.weight layer4.1.bn2.bias fc.weight fc.bias看到这个命名规律,定位就成功了一半。layer1到layer4是四个残差阶段,fc是最后的全连接分类头。名字里的前缀就是天然的“分组依据”。
2.2 用名字前缀筛选参数
最直接的方式是用字符串匹配。比如我想把fc层单独拿出来:
fc_params = [p for n, p in model.named_parameters() if n.startswith("fc.")] backbone_params = [p for n, p in model.named_parameters() if not n.startswith("fc.")]这种写法简单粗暴,但非常有效。对于 ResNet 这种命名规范的网络,一行代码就能完成分组。
不过要注意一个细节:startswith("fc.")里的点号不能省。如果写成startswith("fc"),虽然在这个例子里不会误伤,但如果模型里有fc_extra之类的层就会出错。养成带点号的习惯,能避免很多隐蔽的 bug。
再复杂一点,比如我想把layer1和layer2归为“浅层”,layer3和layer4归为“深层”,可以这样写:
shallow_params = [] deep_params = [] head_params = [] for n, p in model.named_parameters(): if n.startswith("layer1") or n.startswith("layer2"): shallow_params.append(p) elif n.startswith("layer3") or n.startswith("layer4"): deep_params.append(p) else: head_params.append(p)这里else分支会捕获conv1、bn1和fc,如果想把conv1单独处理,需要再细化条件。实际项目中我建议先把所有参数名打印出来,用眼睛过一遍,确认分组逻辑没有遗漏或重叠,再写筛选代码。
2.3 用模块类型筛选参数
有些模型的命名不那么规范,或者你拿到的是别人封装好的模型,层名是一串无规律的编号。这时候按名字筛选就不太好使了,可以改用模块类型。
import torch.nn as nn conv_params = [] bn_params = [] linear_params = [] for module in model.modules(): if isinstance(module, nn.Conv2d): conv_params.extend(module.parameters()) elif isinstance(module, nn.BatchNorm2d): bn_params.extend(module.parameters()) elif isinstance(module, nn.Linear): linear_params.extend(module.parameters())这种方式的思路是“按功能分组”,而不是“按位置分组”。在迁移学习里,一个常见的策略是给 BatchNorm 层设置更小的学习率甚至冻结,因为 BN 的统计量对数据分布很敏感,微调时容易跑偏。用类型筛选就能很方便地实现这一点。
但要注意,module.parameters()会递归返回该模块下所有参数。对于nn.Sequential这种容器,它会一次性把里面所有层的参数都拿出来。如果你只想处理叶子模块,需要加一个判断:
for module in model.modules(): if len(list(module.children())) == 0: # 只处理叶子模块 if isinstance(module, nn.Conv2d): conv_params.extend(module.parameters())2.4 定位层时最容易犯的三个错误
第一个错误:参数重复添加。如果你同时用名字和类型筛选,很可能把同一个参数加进两个组。优化器收到重复参数会直接报错,提示“some parameters appear in more than one parameter group”。解决办法是用id()去重,或者干脆只用一种筛选逻辑。
第二个错误:漏掉 bias 和 BN 参数。很多人筛选时只关注weight,忘了bias。对于卷积层和全连接层,bias 也是需要学习的参数;对于 BN 层,weight和bias都可学习。如果漏掉,这些参数会落到默认组里,学习率可能不是你想要的。
第三个错误:冻结的层还留在优化器里。如果你把某些层的requires_grad设为False,但筛选参数时没有排除它们,优化器仍然会接收这些参数。虽然它们不会更新,但会占用显存和计算资源。正确的做法是筛选时加一个条件:
params = [p for n, p in model.named_parameters() if p.requires_grad and n.startswith("layer4")]3. 参数组机制:把不同学习率真正落到优化器上
3.1 param_groups 的数据结构
PyTorch 优化器的param_groups是一个列表,列表里每个元素是一个字典。字典里必须有一个params键,值是一个参数列表(或迭代器);其余键是超参数,比如lr、momentum、weight_decay。
optimizer = torch.optim.SGD([ {"params": backbone_params, "lr": 1e-4}, {"params": head_params, "lr": 1e-2}, ], momentum=0.9, weight_decay=1e-4)这段代码的意思是:backbone 用 1e-4 的学习率,head 用 1e-2 的学习率,两者共享 momentum 和 weight_decay。注意,写在字典外面的超参数是“默认值”,字典里面的会覆盖默认值。
一个很实用的技巧是:先给所有参数建一个默认组,再针对特定层覆盖。这样能保证没有参数被遗漏。
optimizer = torch.optim.SGD([ {"params": model.parameters(), "lr": 1e-3}, # 默认组 ], momentum=0.9) # 然后单独修改某一组的学习率 for group in optimizer.param_groups: for p in group["params"]: if p.shape == ...: # 或者用其他方式识别 ...不过这种“先建后改”的方式不太直观,我更推荐显式地分好组再传进去。
3.2 分层学习率的常见设置策略
在迁移学习里,分层学习率有一个经验性的规律:越靠近输入的层,学习率越小;越靠近输出的层,学习率越大。原因是浅层学到的是边缘、纹理这类通用特征,跨任务迁移性好,不需要大改;深层学到的是语义相关的特征,和具体任务绑定更紧,需要更多调整。
以 ResNet 为例,一个我常用的配置是:
| 层组 | 学习率 | 理由 |
|---|---|---|
| conv1 + bn1 | 1e-5 | 最底层特征,几乎不动 |
| layer1 + layer2 | 1e-4 | 浅层特征,微调 |
| layer3 + layer4 | 1e-3 | 深层特征,较大调整 |
| fc | 1e-2 | 新初始化层,大步收敛 |
这个比例不是固定的,实际用的时候要根据数据集大小和与预训练数据的相似度来调。如果新数据集和预训练数据很像,整体学习率都可以调小;如果差异很大,深层和 head 的学习率可以再放大。
还有一个变体是指数衰减式分层,每一层的组学习率是上一组的一半或十分之一。这种设置在一些细粒度分类任务里效果不错,但调参成本更高,因为组数多了之后不好判断哪一组出了问题。
3.3 动态调整:训练过程中改学习率
参数组的学习率不是建好就锁死的,训练过程中可以随时改。PyTorch 提供了lr_scheduler,但标准的 scheduler 是统一调整所有组的。如果你想对不同的组用不同的衰减策略,需要手动操作optimizer.param_groups。
def adjust_lr(optimizer, epoch): for i, group in enumerate(optimizer.param_groups): if i == 0: # backbone group["lr"] = 1e-4 * (0.1 ** (epoch // 30)) else: # head group["lr"] = 1e-2 * (0.1 ** (epoch // 30))这种手动方式灵活但容易写错,建议把分组逻辑和调整逻辑封装成函数,并且打印每轮的学习率确认。
另外,PyTorch 1.13 之后支持lr_scheduler配合参数组使用,但需要给每个组单独建 scheduler,或者用LambdaLR配合自定义函数。我个人的经验是,如果组数不超过 4 组,手动调整反而更清晰。
3.4 验证学习率是否真的生效
设置完之后,一定要验证。最直接的方式是打印optimizer.param_groups:
for i, group in enumerate(optimizer.param_groups): print(f"Group {i}: lr={group['lr']}, num_params={len(group['params'])}")如果发现某一组的参数数量是 0,说明筛选逻辑有问题;如果发现两组的学习率一样,说明覆盖没生效。
还有一个更隐蔽的验证方式:在训练第一步之后,检查各层权重的变化量。如果某一层的学习率设得很小,它的权重变化应该微乎其微。
# 记录初始权重 initial_weights = {n: p.clone() for n, p in model.named_parameters()} # 训练一步 loss.backward() optimizer.step() # 检查变化 for n, p in model.named_parameters(): diff = (p - initial_weights[n]).abs().mean().item() print(f"{n}: diff={diff:.6f}")这个技巧在调试分层学习率时特别有用,能直观看到哪些层在动、哪些层没动。
4. 完整实战:从加载模型到分层训练
4.1 场景设定与数据准备
假设我们有一个在大型数据集上预训练好的 ResNet18,现在要迁移到一个 10 类的小规模分类任务上。数据集大概几千张图,类别不平衡不严重。目标是尽量保留预训练特征,同时让新分类头快速收敛。
数据加载部分不是这篇的重点,简单带过:
from torch.utils.data import DataLoader from torchvision import transforms, datasets train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_dataset = datasets.ImageFolder("path/to/train", transform=train_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)4.2 替换分类头并冻结底层
model = models.resnet18(pretrained=True) # 替换 fc 层 num_features = model.fc.in_features model.fc = nn.Linear(num_features, 10) # 冻结浅层 for name, param in model.named_parameters(): if name.startswith("conv1") or name.startswith("layer1") or name.startswith("layer2"): param.requires_grad = False冻结之后,这些层的参数不会更新,也不会出现在优化器的有效参数里(如果你筛选时加了requires_grad条件)。
4.3 构建分层参数组
def build_param_groups(model, base_lr=1e-4, head_lr=1e-2): head_params = [] deep_params = [] shallow_params = [] for name, param in model.named_parameters(): if not param.requires_grad: continue if name.startswith("fc."): head_params.append(param) elif name.startswith("layer3") or name.startswith("layer4"): deep_params.append(param) else: shallow_params.append(param) param_groups = [ {"params": shallow_params, "lr": base_lr * 0.1}, {"params": deep_params, "lr": base_lr}, {"params": head_params, "lr": head_lr}, ] return param_groups param_groups = build_param_groups(model) optimizer = torch.optim.SGD(param_groups, momentum=0.9, weight_decay=1e-4)这里base_lr是深层的学习率,浅层是它的十分之一,head 单独给一个较大的值。这个比例可以根据任务调整。
4.4 训练循环与学习率调度
criterion = nn.CrossEntropyLoss() num_epochs = 30 for epoch in range(num_epochs): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() # 每 10 轮把 head 的学习率降一半 if (epoch + 1) % 10 == 0: for group in optimizer.param_groups: if group["lr"] > 1e-5: group["lr"] *= 0.5 print(f"Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}, " f"Lrs: {[g['lr'] for g in optimizer.param_groups]}")这个训练循环里,我每 10 轮把所有组的学习率减半,但保留一个下限 1e-5,避免学习率降到 0。实际项目中,更精细的做法是给不同组设置不同的衰减节奏,比如 head 衰减快一点,backbone 衰减慢一点。
4.5 训练中需要盯住的几个信号
分层学习率设好之后,训练过程中有几个信号值得关注:
第一个信号:各组的梯度范数。如果某一组的梯度范数远大于其他组,说明它的学习率可能偏大;反之可能偏小。可以在loss.backward()之后、optimizer.step()之前打印:
for i, group in enumerate(optimizer.param_groups): total_norm = 0.0 for p in group["params"]: if p.grad is not None: total_norm += p.grad.data.norm(2).item() ** 2 total_norm = total_norm ** 0.5 print(f"Group {i} grad norm: {total_norm:.4f}")第二个信号:验证集准确率的波动。如果验证准确率在前几轮就冲得很高然后迅速掉下来,大概率是 head 学习率太大,把预训练特征带偏了。这时候应该降低 head 的学习率,或者增加 warmup。
第三个信号:BN 层的 running_mean 和 running_var。如果这些统计量变化剧烈,说明数据分布和预训练时有明显差异,可以考虑冻结 BN 层或者给 BN 参数单独设一个很小的学习率。
5. 踩坑记录:那些让我调了半天的细节
5.1 参数组顺序与 scheduler 的隐式依赖
PyTorch 的lr_scheduler在step()时,默认会按照param_groups的顺序依次调整。如果你手动调整过某一组的学习率,再调用 scheduler,可能会得到意想不到的结果。我遇到过一次:手动把 head 的学习率设成 1e-2,然后用了StepLR,结果 scheduler 把 head 也按 backbone 的节奏衰减了,导致 head 收敛变慢。
解决办法是:要么完全手动管理学习率,要么给每组单独建 scheduler。混用两种方式时,一定要清楚 scheduler 内部是怎么遍历参数组的。
5.2 冻结层与优化器的“幽灵参数”
前面提到过,冻结的层如果被传进优化器,虽然不更新,但会占资源。更麻烦的是,有些优化器(比如 Adam)会为每个参数维护状态(一阶矩、二阶矩),冻结参数也会占用这部分显存。在大模型上,这可能是几个 GB 的浪费。
所以筛选参数时,务必加requires_grad判断。如果训练中途要解冻某些层,需要重新构建优化器,或者手动把参数加进param_groups。
5.3 学习率比例不是越细越好
刚开始用分层学习率时,我总想把组数分得越细越好,每一层一个学习率。结果调参空间爆炸,根本不知道是哪一组出了问题。后来我固定用 3 到 4 组:浅层、中层、深层、head。这个粒度足够覆盖大多数迁移学习场景,调参也可控。
如果任务特别复杂,比如多模态模型,可以适当增加组数,但每一组都要有明确的语义(比如“视觉编码器”“文本编码器”“融合层”“分类头”),而不是按层号机械切分。
5.4 学习率 warmup 在分层设置下的写法
Warmup 是训练初期让学习率从很小的值逐渐升到目标值,避免一开始就大步更新破坏预训练权重。在分层设置下,warmup 需要按组分别计算:
def warmup_lr(optimizer, current_step, warmup_steps, base_lrs): if current_step >= warmup_steps: return scale = current_step / warmup_steps for i, group in enumerate(optimizer.param_groups): group["lr"] = base_lrs[i] * scale这里base_lrs是各组的目标学习率,需要提前存下来。warmup 结束后,再把学习率恢复到目标值,并交给正常的调度逻辑。
5.5 一个容易被忽略的验证:参数是否真的在更新
有一次我设好了分层学习率,训练了几轮发现 loss 几乎不动。排查后发现,我在筛选参数时用了model.named_parameters(),但模型被DataParallel包装过,参数名字前面多了module.前缀,导致startswith("fc.")匹配失败,所有参数都落到了默认组,而默认组的学习率设得很小。
这个坑的教训是:在多卡或分布式训练时,先打印参数名确认前缀。如果用了DataParallel或DistributedDataParallel,筛选逻辑要相应调整,或者用model.module.named_parameters()来获取原始名字。
6. 几个可以直接抄的配置模板
6.1 小数据集微调(数据量几千张)
param_groups = [ {"params": shallow_params, "lr": 1e-5}, {"params": deep_params, "lr": 1e-4}, {"params": head_params, "lr": 1e-3}, ] optimizer = torch.optim.AdamW(param_groups, weight_decay=1e-4)小数据集容易过拟合,整体学习率偏小,head 也不要太大。
6.2 中等数据集微调(几万到十几万张)
param_groups = [ {"params": shallow_params, "lr": 1e-4}, {"params": deep_params, "lr": 1e-3}, {"params": head_params, "lr": 1e-2}, ] optimizer = torch.optim.SGD(param_groups, momentum=0.9, weight_decay=1e-4)数据量上来了,可以给深层和 head 更大的学习率,加快收敛。
6.3 只训练分类头(冻结全部骨干)
for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)这种配置适合数据量很小、或者预训练特征已经足够好的场景。注意这里不需要参数组,因为只有一组参数在训练。
6.4 大模型 LoRA 风格的分层设置
如果模型很大,全量微调不现实,可以只训练部分层,并给这些层设不同的学习率。思路和上面一样,只是筛选逻辑更复杂,需要根据模型的具体结构来定。核心原则不变:先定位,再分组,最后落到优化器。
7. 我个人的几条经验
分层学习率这件事,说到底是在平衡“保留预训练知识”和“适应新任务”这两个目标。参数组只是工具,真正决定效果的是你对模型结构和任务的理解。
我现在的习惯是:拿到一个新任务,先跑一个统一学习率的 baseline,记录 loss 和准确率曲线。然后再上分层学习率,对比两者的差异。如果分层之后提升不明显,说明这个任务对预训练特征的依赖没那么强,或者数据量足够大到不需要精细保护。如果分层之后提升显著,再进一步细调各组的比例。
另外,不要迷信网上的“最佳实践”。不同的预训练模型、不同的数据集、不同的任务,最优的学习率比例可能差一个数量级。多试几组,用验证集说话,比任何经验帖都靠谱。
最后分享一个小技巧:在训练脚本里加一个命令行参数,控制是否启用分层学习率。这样可以在同一份代码里快速切换两种模式,方便做对比实验。我通常用--layered_lr这个 flag,默认关闭,需要时打开。这个习惯帮我省了很多重复改代码的时间。