资讯详情

迁移学习实战:用PyTorch微调实现宠物品种识别

📅 2026/10/21 15:10:33 | 华诺云谱 👁 阅读
迁移学习实战:用PyTorch微调实现宠物品种识别
最近在本地机器上把图像分类的完整流程重新走了一遍这次选的是oxford-iiit-pet数据集做宠物品种识别。选它的原因很简单这个数据集比ImageNet轻量得多一张消费级显卡就能跑同时它又不是MNIST、CIFAR那种玩具级数据样本里带着真实的遮挡、姿态变化和类别混淆用来验证迁移学习非常合适。我前后试了ResNet50、EfficientNet系列和MobileNetV3折腾了大概两周把数据准备、模型选型、微调策略、踩坑点都补齐了。这篇就把整个过程记录下来包括一些常规教程里不会写的细节你可以直接拿它当一份可复用的实验手册。1. 为什么宠物分类任务最适合练手迁移学习1.1 从零训练到迁移学习一个直观对比很多人对迁移学习这个词停留在加载预训练权重这个动作上却不太清楚它到底带来多少收益。我在oxford-iiit-pet上做过一个粗糙但足够说明问题的对比同一个ResNet50使用随机初始化从头训练50轮验证集准确率停在78%左右换成ImageNet预训练权重只冻结骨干层、训练最后的全连接层同样的epoch数和优化器准确率直接跳到92%以上。全量微调再把骨干放出来最终能到94.8%上下。这个差距不是超参数能补回来的根源在数据量。oxford-iiit-pet包含37个猫狗品种每个品种大约200张图片总共7300多张。对深层CNN来说这点数据只够让模型记住训练集学不到能泛化的纹理、轮廓、姿态特征。预训练权重相当于给模型一个见过世面的起点它已经在超大规模数据上学会了通用视觉结构拿到宠物任务上只需要在品种差异这个维度上做精细调整。训练方案验证集准确率个人实验ResNet50随机初始化从头训练约78%冻结骨干仅训练分类头约92.4%解冻骨干全量微调约94.8%当然具体数值和你选的预训练版本、数据增强强度、训练轮次都有关系但大趋势是稳定的在这个体量的数据集上迁移学习几乎是必须项不是可选项。1.2 数据集规模与任务难度oxford-iiit-pet的定位oxford-iiit-pet涵盖37个猫狗品种其中25个狗品种、12个猫品种。图像不是干净的纯背景证件照很多照片里动物处于室内或户外场景存在遮挡、姿态各异、光照变化的情况部分品种之间外观高度接近比如布偶猫和波斯猫、各种梗犬即使是人工辨认也需要一定的经验。这个数据集的学术定位是图像分类和细粒度识别但我更愿意把它当作中等规模领域数据集的典型代表。它不像ImageNet那样动辄百万张图片也不像工业场景里只有几百张高噪声数据。每一类两百张左右恰好处于直接训练会过拟合、迁移学习能发挥奇效的区间。所以用这个数据集跑通迁移学习的流程学到的经验可以直接迁移到很多真实项目里数据不够多、类别高度相似、需要快速迭代出可用模型。2. 数据准备oxford-iiit-pet的目录结构与Dataset加载2.1 原始数据长什么样把官方压缩包解压之后images目录下所有.jpg文件是平铺在同一个文件夹里的没有按品种分子目录。文件名格式像Abyssinian_1.jpg、Bengal_2.jpg这样前面是品种名后面是样本编号。标注信息在annotations目录里其中有一个txt文件每行包含图像ID、类别编号、品种名称、品种ID等字段。这里有一个最容易踩的坑官方标注里的类别编号并不是从0开始的部分版本从1开始而PyTorch的CrossEntropyLoss要求标签从0开始且必须是连续的整数。如果直接拿原始编号当标签loss大概率不收敛。正确做法是把37个品种按字母序映射成0到36。我习惯先写一段快速检查代码打印前20条样本的标签、文件名、类别名称确认映射无误再进训练。这一步虽然看起来繁琐但能帮你省掉后面排查错位数小时。2.2 两种加载方式torchvision内置Dataset与自定义Dataset如果你用的是较新版本的torchvision可以直接用内置Datasetfrom torchvision import datasets, transforms ds_all datasets.OxfordIIITPet( root./data, splittrainval, downloadTrue )这种方式最省事但它返回的是PIL图像和整数标签标签的起点、trainval与test的划分逻辑都封装在库里。如果你只是想快速跑通流程直接用没问题。但当你需要把训练集再划分出一部分做验证集或者要做更复杂的数据采样策略时内置Dataset就没有那么灵活了。我建议额外准备一个自定义Dataset既能复用也能搞清楚数据到底是怎么被加载的import os from PIL import Image from torch.utils.data import Dataset class OxfordPetsDataset(Dataset): def __init__(self, image_dir, label_file, class_to_idx, transformNone): self.image_dir image_dir self.transform transform self.samples [] with open(label_file, r) as f: for line in f: parts line.strip().split() if len(parts) 3: continue image_id, breed parts[0], parts[1] label class_to_idx[breed] self.samples.append((f{image_id}.jpg, label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): filename, label self.samples[idx] image Image.open(os.path.join(self.image_dir, filename)).convert(RGB) if self.transform: image self.transform(image) return image, label核心是将品种名映射到连续整数。你可以从所有品种名的排序表中建立class_to_idx或者直接从原始的trainval.txt里提取唯一的品种名。2.3 预处理与数据划分的细节预处理直接照搬ImageNet训练时的标准流程即可因为我们的预训练模型就是在ImageNet分布上学出来的。训练阶段使用RandomResizedCrop和RandomHorizontalFlip验证阶段使用Resize加CenterCrop归一化用ImageNet的均值方差。mean [0.485, 0.456, 0.406] std [0.229, 0.224, 0.225] train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.08, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize(mean, std) ]) val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean, std) ])RandomResizedCrop中scale参数从0.08开始是为了保持和预训练一致。很多教程会把scale设为(0.5, 1.0)这在数据充足时没问题但宠物分类数据量不大crop范围太小会让模型对物体大小过于敏感反而影响泛化。数据划分方面原始数据的trainval包含约5880张图片test约1470张。我一般会把trainval按8:2再拆成训练集和验证集测试集留到最后评测。划分时最好按品种分层采样因为某些品种样本数可能有微小差异简单随机划分可能导致某个冷门品种在验证集里缺失。用sklearn的StratifiedShuffleSplit可以干净地解决。3. 模型选型ResNet、MobileNet、EfficientNet怎么挑3.1 三个候选模型的参数与理论对比迁移学习的模型选型不像从零训练那样需要在意模型的从头学习能力更重要的是预训练特征的质量、微调时的稳定性和显存开销。我重点对比过三个模型族模型参数量ImageNet Top-1参考单张224推理耗时微调难度适合场景ResNet50约25.6M76%左右中等低生态最成熟基线首选出问题好排查MobileNetV3-Large约5.4M75%左右快低显存紧张后续要部署移动端EfficientNet-B1约7.8M78%左右较慢中需要注意BN和结构细节追求精度上限显存可控ResNet50最大的优势是资料多坑少网上任何报错几乎都能搜到解决办法。EfficientNet在同等参数规模下精度更高它的Compound Scaling让网络在宽度、深度、分辨率之间做了平衡但这种结构在微调时对学习率更敏感数据增强过猛容易掉点。MobileNetV3则适合你打算把模型迁移到手机或嵌入式设备毕竟参数量只有ResNet50的五分之一。3.2 实战中我的选型逻辑我建议的路线是先用ResNet50把整条训练、评估流程跑通得到一个可靠的基线数字。然后换成EfficientNet-B1或B2来冲击精度。不要一上来就试最大的EfficientNet-B4或B7因为微调大模型需要更精细的学习率调度和更多的epoch训练周期长排查问题也不方便。如果显存只有4GBMobileNetV3-Large配batch size 64通常能撑得住如果显存有8GB以上EfficientNet-B1配batch size 32比较稳。你可以在同一个验证集上比较这几个模型的精度我最终用的是EfficientNet-B1比ResNet50基线提高了约1.5个百分点推理时间也在可接受范围内。3.3 修改分类头与冻结策略的代码实践以EfficientNet-B1为例加载预训练权重并替换最后的分类器import torch import torchvision.models as models model models.efficientnet_b1( weightsmodels.EfficientNet_B1_Weights.IMAGENET1K_V1 ) # 替换最后全连接层 num_features model.classifier[1].in_features model.classifier[1] torch.nn.Linear(num_features, 37)如果想把所有骨干层冻结只训练分类头可以这样for name, param in model.named_parameters(): if classifier not in name: param.requires_grad False但要注意冻结requires_grad并不意味着BatchNorm层完全不更新。PyTorch在train模式下BatchNorm的running_mean和running_var会继续根据当前batch统计更新即使对应层的weight和bias不计算梯度。如果你确实希望骨干部分完全冻结应该在训练时把模型设为eval模式但那会连Dropout也一起关掉分类头的训练效果会受影响所以更推荐的做法是冻结参数但保留BN更新这需要我们单独把骨干中的BN层设置为trainable。实际实验里大多数情况下保留BN更新反而是有益的不需要刻意冻结。4. 微调训练学习率、优化器、数据增强的调参心得4.1 学习率策略分层差异化学习率迁移学习最关键的设置是分类头学快一点主干学慢一点。分类头是随机初始化的需要较大的学习率才能快速收敛预训练主干已经处于较好的局部最优过大的学习率会迅速破坏它学到的通用特征。我常用的配置是优化器采用AdamW分类头学习率设置为1e-3骨干学习率设置为1e-4两者相差10倍。具体通过参数分组实现optimizer torch.optim.AdamW([ {params: [p for n, p in model.named_parameters() if classifier not in n], lr: 1e-4}, {params: [p for n, p in model.named_parameters() if classifier in n], lr: 1e-3}, ], weight_decay1e-4)在解冻骨干之前我一般先只训练分类头5个epoch让随机初始化的分类头先适应预训练特征的分布。然后解冻所有参数用上面的分层学习率继续训练。这种做法比一开始就全量微调更稳定尤其当你对学习率不够有把握的时候。4.2 优化器与调度器AdamW加Cosine的搭配SGD加momentum是传统微调的标配但我个人更推荐AdamW。原因很简单AdamW对学习率的敏感度低容错更好配合CosineAnnealingLR基本不需要反复手动调整学习率下降节点。调度器我喜欢这样设置epochs 40 scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxepochs, eta_min1e-6 )Cosine调度会让学习率从初始值平滑下降到极小值很适合迁移学习的微调阶段。如果你担心后期收敛不稳也可以在最后5个epoch恢复到较小的固定学习率不过大多数情况下Cosine这一段已经够稳了。训练轮次方面我一般设置40轮同时配合EarlyStopping当验证集准确率连续8个epoch不再提升时提前结束。这个数据集上通常在25到35轮之间就能看到收敛。4.3 数据增强和正则化别把预训练知识学歪了数据增强的度要小心。预训练模型已经见过大量自然图像经过标准RandomResizedCrop和翻转之后特征分布基本不会偏移太多。但如果你盲目加入强增强比如严重的色彩扭曲、随机擦除、GridMask可能导致特征分布偏移严重预训练知识反而被破坏。在oxford-iiit-pet上我最终使用的增强组合是RandomResizedCrop、RandomHorizontalFlip、ColorJitter(0.2, 0.2, 0.2)。ColorJitter幅度不能太大否则毛色特征会被污染毕竟宠物品种识别很大程度上依赖毛色和花纹。没有加CutMix、MixUp这类强增强它们在从零训练时很有用但在微调阶段不太划算容易让模型混淆相近品种的局部特征。正则化方面我用了label smoothing值设为0.1。宠物品种存在天然混淆比如很多猫的照片如果只看局部确实介于两个品种之间。label smoothing让模型不要过度自信通常能提升一点泛化精度。weight decay设1e-4不需要更大微调阶段权重衰减太大会拖慢收敛。5. 训练过程中的踩坑记录与排查思路5.1 问题一loss下降到一定值后震荡不收敛我第一版自定义Dataset跑起来训练loss降到0.8附近就开始上下震荡验证集指标也不动。我一开始怀疑是学习率偏大降到1e-4还是老样子。后来打印了一批训练样本发现文件名和标签对不上了。问题出在哪里我使用原始txt文件时没有先做排序而是直接按行读取。原始标注tx t中的品种是按图像ID排序的但品种编号在另一个文件里是按字母序排列的。两边对齐时错位模型拿到了大量张冠李戴的样本。这种错误非常隐蔽因为loss还是会下降只是降到一定程度就卡住。排查方法建议训练启动前单独写一段脚本从train_loader里拎出16张图用matplotlib显示图像和预测标签肉眼检查是否对应。任何自定义Dataset都必须过这一关不要相信应该没问题。5.2 问题二验证集指标波动大像在坐过山车另一个常见情况是训练loss稳定下降但验证准确率忽高忽低单次评估结果完全没有参考价值。我排查后发现两个原因。第一评估时忘记把模型切换成eval模式。迁移学习模型里的Dropout和BatchNorm在train和eval模式下行为完全不同如果验证过程还停留在train模式BatchNorm会继续用当前batch统计量更新导致推理不稳定。第二验证集样本量太小。我把trainval按8:2划分后验证集约1170张本身数量不算少但有些品种只有20多张验证图单次随机抽样会碰巧抽到特别简单或特别难的样本导致指标波动。解决方法是固定随机种子并采用每N个step评估一次而不是只在一个epoch结束时评估。我最终设为了每200个step评估一次曲线平滑很多。5.3 问题三GPU显存溢出OOM的应急处理EfficientNet-B1配合batch size 64在我的8GB显卡上直接OOM。遇到这种问题第一个念头往往是减小batch size但这会改变训练稳定性。更推荐的方式是梯度累积和混合精度训练。梯度累积的思路是把一个大batch拆成几个小batch分别计算梯度后累加再统一更新一次参数。例如想把effective batch size保持为64显存只够batch size 32那就每2个step执行一次优化器stepaccumulation_steps 2 scaler torch.cuda.amp.GradScaler() for step, (images, labels) in enumerate(train_loader): images, labels images.cuda(), labels.cuda() with torch.cuda.amp.autocast(): outputs model(images) loss criterion(outputs, labels) / accumulation_steps scaler.scale(loss).backward() if (step 1) % accumulation_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()注意混合精度训练在梯度更新前需要调用scaler.scale和scaler.update不能和普通训练混用。另外冻结部分层并不能节省显存只要模型被加载进显存参数本身就会占据空间只是这些层不计算梯度、不保存中间激活显存。所以OOM的瓶颈更多来自激活值而不是参数数量。6. 评估与可视化除了准确率还要看什么6.1 混淆矩阵与单类精度整体准确率只能告诉你模型有多好不能告诉你模型哪里不好。在宠物分类任务中不同品种的识别难度差异很大有些品种准确率能到99%有些只有85%。我习惯每次训练结束后保存混淆矩阵重点查看对角线之外的密集块。绘制混淆矩阵的代码不复杂用sklearn即可from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay import matplotlib.pyplot as plt cm confusion_matrix(y_true, y_pred, labelsrange(37)) disp ConfusionMatrixDisplay(confusion_matrixcm, display_labelsclass_names) disp.plot(xticks_rotation90, figsize(12, 12)) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150)6.2 错误样本分析为什么把布偶猫认成波斯猫观察混淆矩阵之后我的模型主要混在几个品种之间布偶猫和波斯猫、柯基犬和威尔士柯基犬、各种黑色梗犬。这类错误不是偶然而是品种本身在毛色、脸型、耳朵形态上高度相似。细粒度识别的难点就在这些看起来差不多的类别上。我随机挑了几十张错误样本发现一个规律很多被误判的图片都存在遮挡或运动模糊模型能看到的线索太少。一方面是数据本身困难另一方面也可以启用TTA来缓解。TTA的意思是测试时对同一张图像做多次变换比如5次随机裁剪再对模型输出取平均。在验证集上TTA给我带来了约0.6个点的提升代价是推理时间变为5倍。如果你关注的是离线评测指标可以打开如果是要上线做实时推理这个性价比通常不高。6.3 类别不均衡的隐藏坑oxford-iiit-pet整体类别分布接近均衡每个品种约200张但拆分成训练集和验证集之后某些品种可能只剩170张训练、20张验证。此时如果只看全局准确率会遇到一个隐蔽问题模型在样本多的品种上表现好少数品种拖后腿。我在实验中会同时打印每个类别的最小准确率如果某个品种准确率明显低于均值就要针对它单独补充数据或使用类别权重。用Pytorch实现类别权重很简单先统计各类别样本数再取倒数归一化传给CrossEntropyLoss的weight参数。但要注意迁移学习预训练模型本身已经在类别上分布较均匀如果类别不均衡不明显强加类别权重反而可能干扰预训练特征所以只有当某个类真的严重短缺时才建议使用。7. 模型导出与后续扩展方向7.1 导出为TorchScript或ONNX训练完成之后总要把模型从实验脚本里拿出来。如果你打算部署到服务端或移动端建议直接导出为ONNX或TorchScript。以TorchScript为例model.eval() example_input torch.randn(1, 3, 224, 224) traced_model torch.jit.trace(model, example_input) traced_model.save(oxford_pets_b1.pt)注意trace之前必须调用model.eval()并且输入尺寸要和训练时的预处理一致。导出后会丢掉动态控制流对EfficientNet这种结构比较固定的模型没有影响。导出ONNX时也可以直接用torch.onnx.export不过需要额外处理下输出节点名以便在推理引擎里读取。7.2 细粒度分类、增量学习与模型压缩这次实验除了得到一个能用的宠物分类模型更重要的是把迁移学习的调参思路沉淀下来了。如果你想在这个项目上继续深入我觉得有三个方向很值得走。第一把任务从37类品种分类升级为细粒度识别加入关键点定位或者注意力机制让模型重点关注耳部、面部花纹这些区分性区域。第二尝试增量学习比如先训练猫狗二分类再逐步加入新品种这样更贴近实际业务中类别不断增长的场景。第三做模型压缩把训练好的EfficientNet-B1蒸馏到MobileNetV3蒸馏之后精度损失通常能控制在1个点以内但推理速度会快很多。就我个人的经验而言做这类视觉任务的顺序永远是先保证数据加载正确再用一个小模型跑通流程然后才去堆复杂模型和花式调参。oxford-iiit-pet是一个足够标准的benchmark非常适合用来验证你的整套训练管线。等你把这里面的细节跑顺了遇到真实业务数据时最大的坑往往已经提前踩过了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑