资讯动态

迁移学习实战:PyTorch微调ResNet18的完整指南与踩坑记录

发布时间:2026/10/15 7:42:26 来源:尧图企业网站定制
跟着深度之眼第七期刷CS231n已经写到第二十六篇笔记了前面二十多篇把图像分类、线性分类器、反向传播、卷积神经网络这些基础内容一路捋了下来。理论啃得七七八八之后真正让人头大的其实是动手环节——尤其当你手里只有一个几千张图片的小数据集却想训练出一个能用的分类模型时按课程作业里的方式从随机初始化开始搭CNN效果基本都是灾难。这篇笔记我打算把最近折腾迁移学习Transfer Learning的完整过程复盘一遍包括方案决策、代码实现、参数调整以及那些深夜调参时才会撞见的诡异问题。为什么专门写迁移学习因为它是计算机视觉里最实用、上手最快的一类方法也是CS231n课程里明确强调过的实战套路。我这次在自己收集的六分类图像任务上完整走了一遍微调流程从加载预训练权重、冻结网络、分阶段解冻到调学习率、排查loss异常整个过程踩了不少坑也积累了一些常规文档里不会写的经验。这篇笔记适合正在刷CS231n的人、刚上手PyTorch的初学者以及标注数据不多但想做靠谱分类模型的开发者参考。下面直接进入正文我会把每个操作背后的原因讲清楚而不是只贴一堆API调用。1. 迁移学习方案选型从课程理论到实战决策1.1 小数据集从零训CNN三个坑我都踩过先说这次任务的背景。我手里有一批自己整理的小型图像数据集一共6个类别每类400到600张不等都是日常场景下的物体照片。数据量不大类别之间有些相似性比如有两类容易混淆的鸟类。如果按照CS231n作业里的流程从随机初始化开始训练一个ResNet18我几乎可以预见到三个问题。第一是过拟合。深层卷积网络的参数量动辄上千万几千张图塞进去训练集准确率可以冲得很高验证集却惨不忍睹。我一开始做过一次对照实验随机初始化ResNet18硬训50个epoch训练集98%验证集只有70%出头典型的把样本特征背下来了。这种过拟合不是单纯加dropout和weight decay就能救回来的根源是数据量撑不起模型容量。第二是收敛慢。随机初始化的深层网络前向和反向传播的信号在几十层里穿行梯度很容易不稳定需要精心的初始化策略和学习率调度稍不注意loss就直接飞到NaN。我那次实验调了三天最终精度也就那样投入产出比极低。第三是时间成本。小ResNet在GPU上跑50个epoch也要好几个小时换一组参数又得重来。学习过程中最贵的就是反复实验的时间这条路走不通就该立刻换方案。反观迁移学习前面的特征提取器已经在大规模数据上见过世面我们要做的不是“从零建造”而是“适配”,难度完全不在一个量级。1.2 预训练特征为什么能“换个任务继续用”这里得把CS231n课程里的一个核心观点再展开一下卷积神经网络学到的特征是有层次性的。浅层卷积核学的是边缘、纹理、颜色块这些极度通用的低层模式中层开始组合出轮廓、局部部件深层才慢慢出现和具体任务强相关的语义信息比如狗头、车轮、人脸这类概念。这就意味着一个在大规模图像分类数据上训练好的网络它的浅层和中层特征对绝大多数自然图像任务都有很强的复用价值。打个比方你请一位成熟的画师换一种画风他上手很快因为他已经掌握了构图、笔触、色彩这些基本功你让一个刚学画的孩子直接临摹大师作品连笔都拿不稳自然不行。预训练模型就是那位成熟画师基本功已经练好了换任务只是换画风。不过要注意前提这套逻辑在目标图像和预训练数据分布相近时最有效。如果任务是医学影像、卫星遥感图这种和日常照片差异极大的领域浅层通用性还在但中高层语义特征就得重新学迁移学习的收益会明显下降。这也是为什么后面做方案选型时要先评估“分布相似度”。1.3 三种微调路线怎么选我最后选了分阶段迁移学习在实践层面一般有三条路。第一条是特征提取把backbone完全冻结只训练最后新加的线性分类层。优点是快、省显存、几乎不过拟合缺点是模型对目标任务的理解停留在预训练特征层面效果上限有限。第二条是全量微调所有层都参与训练效果上限高但需要更多数据和更精细的调参否则容易灾难性遗忘。第三条就是分阶段微调也是最稳的折中方案。我把三种方案做了一个对比方便大家直接抄作业方案数据量要求训练成本适用场景效果上限从零训练很大最好百万级高数据充足、领域独特上限高但代价大冻结backbone只训分类头小低数据少、分布接近中等偏上全量微调所有层较大高数据量中等以上高分阶段微调先冻结后解冻小到中中数据量小但想压榨性能较高我最终选的是分阶段微调具体拆成两步走第一阶段冻结整个backbone只训练新替换的fc分类层让分类头先适应预训练特征的空间分布第二阶段再解冻靠近输出端的layer4和fc层用更小的学习率一起微调让高层特征向目标任务靠拢。这样既避免了随机分类头在训练初期剧烈震荡又能给模型留出调整空间对几千张图的数据量来说是最稳妥的组合。2. 微调前的五个关键细节弄懂才能不白训2.1 归一化必须和预训练保持一致这是新手最容易忽略、影响却最直接的细节。torchvision加载的预训练模型在训练时输入图像做了固定的归一化处理均值和标准差分别是mean [0.485, 0.456, 0.406] std [0.229, 0.224, 0.225]这个组合对应的是ImageNet数据集的RGB通道统计量。使用预训练模型时我们的数据预处理也必须用同一套归一化参数。否则等于把模型期望的输入分布硬生生扭曲掉第一步就把特征带偏后面再微调都容易事倍功半。我见过有人用自己数据集的mean/std去归一化预训练模型的输入结果验证准确率掉了十几个点排查半天才发现是这里出了问题。实现上就是torchvision的transforms.Normalize直接套用上面这组数值。如果你用的是自己训练的模型归一化统计量应该从你自己的训练集统计而不是套ImageNet的。总之记住一句话预训练和微调阶段的输入分布必须对齐。2.2 冻结层与BN层的微妙关系冻结层在PyTorch里实现很简单把对应参数的requires_grad置为False就行。但这里藏着一个很隐蔽的坑BatchNorm层。BN层有两套东西一套是可学习的缩放和偏移参数weight和bias另一套是running_mean和running_var统计量。把BN参数的requires_grad设为False只是不再更新可学习参数但如果整个模型处于train模式BN仍然会继续计算并更新running_mean和running_var。这就导致一种“半冻结”状态你以为backbone完全没动实际上BN统计量一直在随着新数据漂移训练和验证时的行为不一致结果忽高忽低。我的处理方法是第一阶段冻结backbone时把backbone整体切到eval模式让BN统计量固定住只让新加的fc层处于train模式dropout也不会影响backbone。第二阶段解冻后再把整个模型切回train模式让BN跟着一起更新。这种做法实测最稳不会出现验证时精度突然跳水的情况。2.3 分类头和学习率为什么要“区别对待”微调时如果把所有参数丢进同一个优化器、用同一个学习率往往会出问题。新加的fc层是随机初始化的它需要较大的学习率去快速拟合任务预训练得到的backbone参数已经有很好的初始化学习率太大一步就冲坏了原来的特征空间这就是所谓的“灾难性遗忘”。所以正确做法是给优化器传入多个参数组不同层配不同学习率。我第一阶段只训练fc层给它1e-3的学习率第二阶段解冻layer4和fc预训练部分统一用1e-4新fc层如果觉得收敛太慢可以单独保持1e-4到5e-5之间的值。实现代码很简单optimizer optim.SGD([ {params: model.fc.parameters(), lr: 1e-3}, {params: backbone_params, lr: 1e-4} ], momentum0.9, weight_decay1e-4)需要单独把backbone里requires_grad为True的参数筛出来避免传空列表报错。2.4 数据增强的分寸感数据增强对微调同样重要但力度要比从零训练克制一些。我这次用的训练增强组合是RandomResizedCrop、RandomHorizontalFlip和适度的ColorJitter验证集只用Resize加CenterCrop绝不让验证集参与任何随机增强。这里有个值得注意的教训如果你的分类任务对颜色、纹理这类属性很敏感ColorJitter的强度一定要控制好。我在实验中发现ColorJitter拉得太强模型在验证集上表现反而下降因为增强把类别间的关键区分信号给“搅浑”了。我最后把ColorJitter的亮度、对比度、饱和度因子都压在0.2以内效果才恢复正常。数据增强的本质是做数据分布的鲁棒化但过头了会把任务本身也增强没了这个分寸得靠实验去摸。2.5 输出层和损失函数的小细节修改预训练模型输出层时常用做法是把最后一层全连接替换成符合自己类别数的线性层。以ResNet18为例它最后fc层的输入维度是512替换代码就一行model.fc nn.Linear(model.fc.in_features, 6)注意损失函数的选择。图像分类任务用CrossEntropyLoss这个函数内部已经集成了Softmax所以模型最后一层不要手动加Softmax否则等于做了两次指数归一化训练初期loss会异常且梯度不稳定。另外标签必须是从0开始的连续整数如果有类别缺失标签编码要提前处理好否则CrossEntropyLoss会直接报错。3. 实操全流程PyTorch微调ResNet的完整记录3.1 数据准备目录结构、加载与增强我这边的数据目录采用ImageFolder的标准结构每个类别一个子目录名字就是类别名省去手写Dataset的麻烦data/ ├── train/ │ ├── class_0/ │ ├── class_1/ │ └── class_2/ └── val/ ├── class_0/ ├── class_1/ └── class_2/加载代码非常标准import torch import torch.nn as nn import torch.optim as optim import torchvision from torchvision import transforms, datasets from torchvision.models import resnet18, ResNet18_Weights train_tf transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_tf transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_ds datasets.ImageFolder(data/train, transformtrain_tf) val_ds datasets.ImageFolder(data/val, transformval_tf) train_loader torch.utils.data.DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4) val_loader torch.utils.data.DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4)batch_size我选32这个值对我手头的显存来说是安全的。num_workers按机器CPU核心数调能明显加速数据读取但Windows上偶尔有兼容问题如果遇到卡死可以降到0先跑通。3.2 模型搭建加载权重、改分类头、冻结参数模型部分用torchvision新版API加载预训练权重注意不同版本的API差异旧版本是pretrainedTrue新版本推荐用weights参数model resnet18(weightsResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, 6) # 第一阶段冻结整个backbone for name, param in model.named_parameters(): if not name.startswith(fc): param.requires_grad False optimizer optim.SGD([ {params: model.fc.parameters(), lr: 1e-3} ], momentum0.9, weight_decay1e-4)model.fc.in_features这个写法很实用不用硬编码512换ResNet50、EfficientNet都通用。需要提醒的是改完fc层之后不要试图直接加载原来的完整state_dict因为最后一层key对不上会报unexpected key错误。第一阶段训练结束后进入第二阶段我把layer4和fc解冻其余层继续冻结for name, param in model.named_parameters(): if name.startswith(layer4) or name.startswith(fc): param.requires_grad True else: param.requires_grad False optimizer optim.SGD([ {params: [p for n, p in model.named_parameters() if p.requires_grad], lr: 1e-4} ], momentum0.9, weight_decay1e-4)这里有个小技巧用列表推导式筛出所有requires_grad的参数组就不用手动维护两段参数列表了换解冻策略的时候代码改动最小。3.3 训练循环从第一行代码到模型保存训练循环是整篇文章的地基我习惯把训练和验证各封装成函数方便复用。关键点在混合精度训练用torch.cuda.amp能把显存占用压下来一小半训练速度也有肉眼可见的提升对显存吃紧的场景几乎是必备技能。def train_one_epoch(model, loader, optimizer, criterion, scaler, device): model.train() total_loss, correct, total 0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): outputs model(images) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss loss.item() * images.size(0) correct (outputs.argmax(1) labels).sum().item() total labels.size(0) return total_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() total_loss, correct, total 0, 0, 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) correct (outputs.argmax(1) labels).sum().item() total labels.size(0) return total_loss / total, correct / total主循环里每个epoch跑完验证集用验证集准确率判断是否保存最优模型顺便打印当前进度。我这次用CosineAnnealingLR把学习率随epoch平滑下降比固定学习率稳不少device torch.device(cuda if torch.cuda.is_available() else cpu) criterion nn.CrossEntropyLoss() scaler torch.cuda.amp.GradScaler() scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20) best_acc 0 for epoch in range(20): train_loss, train_acc train_one_epoch( model, train_loader, optimizer, criterion, scaler, device) val_loss, val_acc validate(model, val_loader, criterion, device) scheduler.step() if val_acc best_acc: best_acc val_acc torch.save({ model: model.state_dict(), optimizer: optimizer.state_dict(), epoch: epoch, val_acc: val_acc, }, best_model.pth) print(fepoch {epoch1}: train_loss{train_loss:.4f}, train_acc{train_acc:.4f}, fval_loss{val_loss:.4f}, val_acc{val_acc:.4f})保存模型时建议带上验证准确率和epoch信息后面翻实验记录时能省很多事。3.4 超参数表与两阶段实验结果记录这篇笔记的实验配置可以直接参考我整理成表配置项阶段一值阶段二值冻结范围backbone全部冻结解冻layer4 fcbase学习率1e-3fc层1e-4解冻层batch_size3232epochs2020优化器SGDmomentum0.9, weight_decay1e-4同左调度器CosineAnnealingLRCosineAnnealingLR混合精度开启开启两阶段的验证集准确率记录如下。先说明一点这个数据集是我自己整理的数字只代表这次任务的情况大家跑自己的数据肯定会有差异但趋势是可参考的实验节点操作内容验证集准确率baseline加载预训练模型不做任何训练直接跑验证集85.2%阶段一结束冻结backbone只训练fc层20轮91.3%阶段二结束解冻layer4和fc微调20轮96.8%baseline那一步很多人会跳过我强烈建议不要省。它相当于告诉你预训练特征在你任务上的“出厂水平”如果baseline已经很高说明任务和预训练分布非常接近后面微调空间不大早停即可如果baseline很低说明分布差异大可能需要更激进的解冻策略。3.5 训练曲线怎么读Loss和准确率背后的信号训练过程中我把每个epoch的loss和准确率都打印出来再画成曲线读曲线是有讲究的。阶段一的训练loss快速下降验证准确率在85%到89%之间波动训练结束时稳定在91%出头。这个阶段的特点是分类头在快速寻找预训练特征空间里“最合适”的线性分割面波动是正常的。进入阶段二训练loss会先小幅跳一下因为layer4的BN统计量重新开始更新短暂震荡后又继续下降。此时验证准确率阶梯式上升最后来到96.8%的平台期。平台期就是损失不再明显下降、准确率反复横跳的区间到了这个状态基本就该停了再训也是白烧电。我自己读曲线还有一个习惯如果验证loss持续上升而训练loss还在降说明正在过拟合应该立刻早停或者加大weight decay如果两个loss都在高位震荡先怀疑学习率是不是太大再查数据有没有问题。曲线是最直接的反馈信号比一堆训练日志直观得多。4. 调参与踩坑实录六个高频问题一次说清4.1 加载预训练权重报错底层API变了这个问题我遇到过两次都是torchvision版本升级导致的。旧版写法是model resnet18(pretrainedTrue)新版本更推荐model resnet18(weightsResNet18_Weights.IMAGENET1K_V1)如果你用旧写法跑新版torchvision会收到DeprecationWarning版本跨度大时甚至直接报错。另外自己改过fc层之后加载权重经常出现“Unexpected key(s) in state_dict: fc.weight, fc.bias”这种提示原因是fc层形状变了。解决办法是按我上面写的流程先加载预训练权重再替换fc层顺序不能反。4.2 训练Loss变成NaN先查这三件事NaN是深度学习调参路上最著名的噩梦。我这次的实践经验是按顺序排查三件事。第一学习率过大。尤其阶段一新加的fc层是随机初始化的如果学习率给到1e-2甚至更大loss第一轮就能飞到NaN。解决办法是把fc层学习率降到1e-3以下还不行就一路降到1e-4。第二数据里有问题。比如图片损坏导致读入全零、标签出现负数或者超出类别数。用ImageFolder加载时基本不会出现标签问题但图片损坏是可能存在的多线程加载时不一定会立刻抛错表现就是某个batch的loss突然异常。排查方法是在训练前遍历一遍数据集把形状不对、像素值异常的样本筛掉。第三混合精度的缩放参数异常。用AMP时如果出现loss损失极小或梯度爆炸可以先关闭GradScaler纯FP32跑几个step对比定位是不是精度问题。4.3 验证集精度上不去过拟合和增强过度这个问题的特征很典型训练集准确率一路冲高验证集准确率迟迟不动甚至往下掉。我踩过一次“增强过度”的坑当时把ColorJitter调得比较激进模型在训练集上的augmented样本间找不到稳定规律验证集表现一塌糊涂。把增强强度降下来之后效果立刻恢复了。另一个常见原因是dropout和weight decay不足。微调阶段backbone已经很强新fc层很容易把训练集背下来建议weight decay至少给到1e-4如果验证集和训练集差距超过5到8个点可以尝试把weight decay调到1e-3或者增加dropout。还有一点验证集划分本身要合理。图片不能有重复或泄漏否则验证集数字虚高看起来“精度上不去”其实是假问题。4.4 显存不够四板斧逐个上显存不够是学习阶段的家常便饭我的应对顺序很明确。第一板斧开混合精度AMP显存能省三分之一到一半。第二板斧减小batch_size比如从32降到16大不了多跑几个step。第三板斧梯度累积用时间换显存逻辑是每N个小batch累积一次梯度再更新效果等效于把batch_size放大N倍。代码片段如下accum_steps 4 for i, (images, labels) in enumerate(loader): images, labels images.to(device), labels.to(device) with torch.cuda.amp.autocast(): outputs model(images) loss criterion(outputs, labels) / accum_steps scaler.scale(loss).backward() if (i 1) % accum_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()第四板斧冻结更多层。如果第二阶段显存还是爆就把layer4也冻结只解冻fc损失一点精度但能跑起来。实在不行就换更小的模型比如EfficientNet系列或者MobileNet微调同样能出结果。4.5 BN“幻觉精度”冻结模式的隐形陷阱这个坑特别隐蔽值得单独说。前面提到BN层在train模式下会更新running_mean和running_var哪怕它的weight和bias被requires_gradFalse锁住了。我在实验中发现一个现象冻结阶段如果让整个模型保持train模式验证集准确率会随着epoch小幅震荡甚至出现“训练时高、验证时掉”的诡异情况。原因是BN统计量在训练过程中不断被当前batch“污染”但验证时BN用的是更新后的统计量两边的行为对不上。解决方法是本文2.2节的做法冻结backbone时把backbone切成eval模式只让fc层训练。如果你后续全量微调就放心让整个网络处于train模式BN会跟着参数一起适配不会出现这种错位。4.6 实验复现不稳定随机种子和验证集划分最后一个问题是我自己反复栽跟头的同一套代码跑两次结果能差一到两个点。原因是PyTorch、NumPy、Python随机数的种子都没固定数据加载的shuffle顺序每次都不一样。如果是做实验记录、对比方案不固定种子会严重影响判断。建议在训练脚本开头固定所有能固定的人为随机性import random import numpy as np import torch seed 42 random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)同时尽量保证验证集划分固定不变用统一的拆分文件或固定随机种子生成划分索引。这样两次实验的差异才真正来自模型或参数变化而不是运气。整套流程走下来我的体感是迁移学习并不是什么高深技巧它更像一个工程决策什么时候该复用、复用哪几层、给多大的学习率都需要用数据和实验去回答。最后再分享两个我这段时间养成的习惯。第一每次开始调参之前先拿预训练模型原地跑一遍验证集把baseline记下来后续所有操作都要能解释“比baseline好在哪”第二保存模型文件名里一定带上best_acc和日期比如resnet18_96.8_0105.pth翻旧模型时不用一个个加载去看。迁移学习不是万能的但它确实是数据量有限时最值得优先尝试的路线希望这篇笔记能帮大家在微调的路上少走几个弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑