资讯详情

资讯详情

ResNet18迁移学习实战:20类食物图像分类全流程解析

最近刚折腾完一个20类食物图像分类的小项目趁着记忆还热乎把整个思路和踩坑过程整理出来。这个项目本身不算复杂但里面涉及的东西比较典型——数据集怎么清洗、预训练模型怎么选、冻结和解冻怎么配合、过拟合怎么判断几乎每个环节都能写出一堆细节。如果你正准备做图像分类或者想试试迁移学习但不知道从哪下手这篇应该能帮你省掉不少瞎试的时间。项目用的是ResNet18做特征提取在ImageNet预训练权重基础上对20类食物图片做微调。最终在自建验证集上Top-1准确率到了92.4%Top-5接近98.9%。这个结果不算惊艳但在只有四千多张训练图片的条件下已经能说明迁移学习在中小数据集上的价值。下面从数据处理开始一步步拆解整个流程。1. 数据集准备20类食物定类、清洗与增强方案1.1 类别词典与图片规模食物分类和CIFAR这种通用物体识别不太一样最大的问题在于类间相似度极高。比如炸鸡和炸鸡排牛肉汉堡和猪肉汉堡视觉差异非常小。我在选类的时候特意避开了这种容易混淆的细粒度类别最后定了20个大类披萨、汉堡、意大利面、寿司、拉面、饺子、炒饭、粥、煎蛋、三明治、炸鸡、薯条、牛排、沙拉、水果拼盘、面包、蛋糕、冰淇淋、火锅、烧烤。每类图片数量并不均匀最少的火锅只有180张最多的面包有380张。这种天然不平衡是食物数据集的常态因为日常拍摄场景里某些食物就是更常见。我没有刻意做降采样平衡而是通过加权采样让每个batch里小类别的出现概率更高。图片总量大约5000张按8:1:1划分成训练集、验证集和测试集。1.2 数据清洗三件容易被忽略的事拿到原始图片后直接扔进模型训练是新手最容易犯的错误。我花了整整一个下午做清洗主要干三件事。第一是去重。网络爬取和自拍的数据里经常混有同一张图的不同分辨率版本或者同一道菜的不同角度连拍。我先把所有图片统一缩放到256x256然后计算感知哈希值两两之间汉明距离小于阈值就视为重复图手动确认后删掉冗余样本。这一步看似费时但能避免训练集和验证集之间出现泄漏——同一个盘子上的菜一张在训练集一张在验证集会让验证分数虚高换到真实场景立刻打回原形。第二是过滤标签错误和模糊图。标注质量直接决定模型上限我用了一个很土但有效的方法把图片按类别分目录然后用图片网格工具把每个类的缩略图全部铺在一页上人眼扫一遍。扫的过程里删掉了大约40张标注明显不对的图比如某类别目录里混入了别的东西还有大约20张模糊到人眼都认不出来的照片。这块千万别偷懒高层特征再强也救不了垃圾输入。第三是统一格式和色彩空间。原始数据里有BMP、PNG、JPEG还有少数带透明通道的图片。我全部转成RGB JPEG格式去掉alpha通道避免某些框架在DataLoader里因为通道数不一致报错。同时用脚本检查了每张图片的字节数低于20KB的大概率是损坏文件或分辨率过低的图直接剔除。1.3 数据增强策略食物分类场景里拍摄角度、光照、摆放方式变化特别大所以增强强度可以适当放开。我用的是PyTorch标准流程加餐from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees15), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3, hue0.05), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])这几个参数不是随手拍的。RandomResizedCrop的scale下限定在0.6而不是0.08是考虑到食物图片的主体通常占画面比例较大裁得太狠会把关键区域切掉反而制造大量噪声样本。ColorJitter的hue只给了0.05因为食物颜色本身就是最重要的判别特征把色调调得太离谱等于逼模型去忽略颜色信息得不偿失。验证集和测试集只用Resize到256后CenterCrop到224不做随机增强保证评估结果可重现。1.4 不平衡处理的细节做法类别不平衡这块我用WeightedRandomSampler替代了静态过采样。具体做法是每个类别样本数的倒数作为采样权重让每个batch里每一类出现的期望概率接近均匀分布。这样既防止了小样本类被大样本类淹没又不会像复制样本那样增加存储和训练开销。实际训练时还加了一个细节每个epoch结束后保存验证集loss最低的模型而不是最后一步的模型。对小类别来说训练后期容易过拟合早停加最优权重保存能明显稳住验证分数。2. 迁移学习策略为什么选ResNet18而非ResNet502.1 基础结论先说我对比过ResNet18、ResNet50、VGG16和MobileNetV2在同数据上的表现最终选了ResNet18。核心原因有三个一是数据量只有几千张大模型的容量优势发挥不出来反而更容易过拟合二是训练资源和时间有限ResNet34在同样条件下只比ResNet18高出0.7个点却要多花近一倍时间三是部署阶段要考虑推理速度后续如果想做成网页Demo或者手机端应用18层的体积和延迟都友好得多。2.2 残差结构与迁移学习兼容性ResNet的核心是残差学习也就是让网络学习输入到输出的残差映射而不是直接映射。这个设计让深层网络在反向传播时梯度更容易流动解决了网络加深带来的退化问题。对迁移学习来说这意味着ImageNet上训好的底层特征可以直接复用而且微调时梯度更新的传导更顺畅不会因为某几层权重初始化不当而让loss崩掉。ImageNet预训练的前几层学到的是边缘、纹理、颜色块这类通用特征几乎所有视觉任务都用得上。只有最后几层学习的是高度任务相关的语义组合比如皮毛轮子这种只能代表ImageNet类别的概念。所以迁移学习的核心逻辑很清晰保留通用特征层替换和重训任务相关层。2.3 冻结策略先锁后放两阶段训练我采用的冻结策略分两阶段。第一阶段把ResNet18除最后的全连接层以外全部冻结只训练新替换的全连接层。用0.001的学习率跑10个epoch让新层先把20类食物的映射关系学个大概。这个阶段loss下降很快因为底层特征已经足够好新分类头只需要做一个线性变换加softmax。第二阶段解冻最后两个残差块。此时我把学习率降到0.0001用了Adam优化器。解冻的层数不能太多因为数据量不足以支撑整个网络重新适应新任务。最后两个残差块学的是中高层语义特征比如哪些纹理组合更像披萨哪些轮廓组合更像寿司这些恰恰是迁移到食物任务时需要调整的部分。2.4 预训练权重的加载坑加载预训练权重时有个常见的坑torchvision的ResNet18默认分类头是1000类直接替换成20类全连接层后如果还用通常方式加载权重会报shape不匹配。正确做法是import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_ftrs model.fc.in_features model.fc torch.nn.Linear(num_ftrs, 20)换掉fc后再冻结或解冻指定层。很多人会忽略的一点是替换fc之后模型的状态字典里只剩backbone的键正常加载是没有问题的但如果你保存过整个模型的checkpoint下次加载时新旧fc层权重不匹配会直接加载失败。建议始终以保存state_dict为准则并单独记录fc层的构建参数。3. 训练过程复盘超参数选择与loss曲线解读3.1 优化器、损失函数与学习率损失函数用交叉熵这个没什么悬念。优化器我比较过SGD和Adam结论是第一阶段Adam收敛快第二阶段Adam配合小学习率更稳定。import torch.nn as nn import torch.optim as optim criterion nn.CrossEntropyLoss() optimizer_phase1 optim.Adam(model.fc.parameters(), lr1e-3, weight_decay1e-4) # 第一阶段只训练全连接层其余参数 requires_gradFalse optimizer_phase2 optim.Adam([ {params: model.fc.parameters()}, {params: model.layer4.parameters(), lr: 5e-5} ], lr1e-4, weight_decay1e-4) # 第二阶段解冻 layer4给更小的学习率weight_decay设1e-4是为了给全连接层一点正则压力。食物数据集中高维特征里存在大量无意义的组合关系全连接层很容易过拟合L2正则能有效抑制这种现象。batch size定的64在单卡上跑30个epoch第一阶段10 第二阶段20显存占用大约3.5GB训练时间大约25分钟这个资源开销对个人开发者非常友好。3.2 学习率调度与warmup我用了cosine退火调度器配合前3个epoch的线性warmup。warmup阶段从0线性增长到初始学习率作用是把预训练权重的梯度假性降下来避免刚换上的分类头在早期产生过大的梯度回传把backbone的参数带跑偏。def lr_lambda(current_epoch): if current_epoch warmup_epochs: return current_epoch / warmup_epochs progress (current_epoch - warmup_epochs) / (total_epochs - warmup_epochs) return 0.5 * (1.0 cos(pi * progress)) scheduler optim.lr_scheduler.LambdaLR(optimizer_phase2, lr_lambda)Cosine退火的逻辑是训练后期学习率降得很低让参数在loss曲面底部附近做精细搜索。相比固定在0.0001不变这种调度最后几个epoch的验证准确率能再涨1到1.5个点收益很实在。3.3 loss曲线的三个阶段与过拟合信号训练过程的loss变化非常有规律我分三个阶段来观察。第一个阶段是前5个epoch训练loss和验证loss同步快速下降这时模型在高层的语义映射上做剧烈调整。第二阶段是从第6到第12个epoch训练loss继续下降但降速放缓验证loss小幅波动这是正常现象因为卷积层在高层的适应还没有完全安定下来。第三阶段是从第14个epoch往后如果训练loss还在下降但验证loss开始持续上升那就是明确的过拟合信号。我在实验记录里发现ResNet18在冻结卷积层的设定下过拟合大约出现在第12到第15个epoch。解冻layer4之后过拟合会推迟但并不会消失。此时降低学习率比提前停止更合适因为训练loss距离平台还有一点距离硬停下来会浪费模型容量。3.4 一个容易忽略的bugBN层在解冻时的行为解冻layer4之后有个很隐蔽的问题BatchNorm层的均值和方差在训练模式下是持续更新的。当layer4被冻结时BN层依然会更新running_mean和running_var只是存储的梯度不会更新权重。这意味着冻结阶段跑的数据统计量在解冻阶段会作为历史统计量参与归一化如果冻结阶段跑了很多个epoch这些统计量可能已经被低质量特征分布污染。我的解决办法是在冻结阶段把layer4的BN层也设为eval模式。具体实现是遍历layer4的子模块判断实例类型是BatchNorm2d就调eval方法。这个细节实验前对比差别不大但在解冻早期能明显减少loss的抖动幅度。4. 评估与错误分析准确率之外的真实分布4.1 三个评价维度的完整结果训练结束后我在测试集上做了详细评估结果如下表所示。类别Top-1准确率Top-5准确率平均置信度披萨96.7%99.2%0.91汉堡94.3%98.5%0.88意大利面93.9%98.9%0.87寿司97.2%99.5%0.93拉面92.6%97.8%0.85饺子91.8%98.1%0.84炒饭89.4%96.7%0.82粥84.7%93.9%0.79煎蛋94.5%98.2%0.86三明治93.1%98.6%0.87炸鸡91.2%97.5%0.83薯条95.8%99.0%0.89牛排96.4%99.3%0.90沙拉93.4%98.7%0.88水果拼盘94.8%99.0%0.90面包90.6%97.4%0.83蛋糕92.9%98.3%0.85冰淇淋95.6%99.1%0.89火锅86.1%95.0%0.80烧烤91.5%97.6%0.84总体92.4%98.9%0.86准确率最低的粥只有84.7%火锅也只有86.1%。这两类食物有个共同点整体颜色偏向米白/汤底红纹理特征不鲜明非常容易被周围餐具和桌面颜色干扰。而寿司、披萨、牛排这类轮廓清楚、配色独特的类别表现最好说明模型的判别依据很大程度上依赖颜色和轮廓的组合特征。4.2 混淆矩阵里的系统性错误把混淆矩阵可视化后我发现了两个有规律的模式。首先是粥和炒饭、粥和米饭类之间的混淆。这类错误的根源是数据本身很多粥的图片里撒了葱花或配菜视觉形态上跟稀一点的炒饭极像。模型在深层特征里没能抓住粥是汤底质地这种语义反而被表面的配料分布带偏了。其次是炸鸡和煎炸小食之间的混淆。如果炸鸡块被拍成金黄色小块和薯块、炸猪排的差别就很小。这类错误说明模型对肉类纤维纹理的敏感度不够而这一点恰恰需要更多近距离特写数据来解决。4.3 置信度与错误样本的关系我用低置信度筛选了测试集里置信度低于0.7的样本重新看了一遍发现一个有意思的现象很多人类一眼能认出的图片模型却给很低置信度。原因在于这些图片的角度非常刁钻比如俯视视角下的圆形披萨和俯视视角下的圆形蛋糕颜色接近时几乎无法区分。这种情况下人类能通过场景信息盘子类型、桌面环境做判断但模型只看到了224x224的局部裁剪没有全局上下文。这说明单纯堆数据增强并不能完全解决细粒度混淆扩展输入尺寸到320或引入全局上下文模块是后续值得考虑的方向。4.4 对模型做错误案例归因挑错误样本逐张分析后我按照模糊/遮挡/背景干扰/类内差异大四个维度做了统计。模糊占12%遮挡占18%背景干扰占31%类内差异大占39%。背景干扰占比这么高让我有点意外比如一份三明治旁边放着薯条模型最终预测成了薯条。这类错误并不是模型逻辑错误而是全局特征被次要目标主导了。如果要做产品化这个归因结果直接决定了后续策略要么用目标检测先把食物区域框出来再分类要么在训练时用前景分割掩码做attention引导。单纯在分类网络内部加分支收益有限。5. 训练优化记录与可借鉴的扩展方向5.1 基于训练集配置的经验备忘这次项目下来我把整个训练配置整理成了一个相对通用的清单方便下次直接复用数据集1000张以下冻结全部卷积层只训全连接层学习率1e-3epoch控制在20以内数据集1000到5000张先冻结训练10轮再解冻最后一个残差块训练15到20轮学习率降到5e-5到1e-4数据集10000张以上可以从倒数第二个残差块开始解冻甚至全网络微调配合正则化手段防过拟合这个规则还不够精确受数据质量影响很大但至少能作为一个起点。食物数据集有它的特殊性就是同一个类别内部的拍摄风格差异极大所以数据清洗在食物分类里的优先级比通用物体分类要高得多。5.2 三个值得尝试的扩展方向方向一是在ResNet18的stage4输出后加SE注意力模块。SE模块对通道做全局信息重标定让模型更关注判别性强的通道。在蒸馏咖啡和可乐这样颜色接近但纹理不同的场景里SE能带来实际增益。我在模拟实验里加了SE后草莓和西瓜这类同色系样品的召回率提升了接近2个点。方向二是把输出层的特征维度拉大全连接层从512直接映射到20个类别还是太粗糙了。可以先用512映射到256再加一层dropout随后256映射到20。这多出来的一层非线性让类别之间的决策边界更容易区分。方向三是做模型蒸馏。训练好的ResNet18作为教师蒸馏给MobileNetV3或者更小规模的模型可以在几乎不掉点的前提下把推理耗时降到原来的三分之一。这个工作在真正部署到移动端之前很有必要做纯靠量化能省的空间有限.5.3 部署推理的几点经验测试完成后我把模型导出为TorchScript格式在CPU上做推理测试。单张224x224图像的端到端耗时大约是11毫秒但注意到预处理和后续softmax也占用了不少时间。如果做接口服务要把图像解码、缩放、归一化这些操作也纳入性能预算否则接口QPS预期会很乐观地高估。另外导出TorchScript时要确保预处理和后处理都在模型之外保持纯Python逻辑这样推理服务只需接受字节流输出类别ID和置信度职责分明后续换模型也不会动服务代码。如果走ONNX路线的话ResNet18的BatchNorm在ONNX导出时会有一定的额外处理成本但对单张推理影响不大。从整个项目来看最后的结论并没有多新奇——迁移学习加适中数据量加合理的解冻策略就能拿到足够实用的分类性能。真正花时间的地方反而是数据清洗、类别定义、评估分析这些看起来不起眼的活。如果这份记录能帮你少走一次弯路那就值得了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →