图像分类这个任务,听起来是计算机视觉里最“人畜无害”的:给一张图片,告诉模型它是哪一类。可真把“4图像分类任务”从零开始落到生产环境,你就会发现,从数据整理到最后一公里部署,每一步都有让人血压升高的地方。我这次想完整复盘一个真实推进过的项目——把日常照片粗分为猫、狗、鸟、鱼四类,多分类里最基本的四分类场景。文章后半部分会集中写训练过程中踩过的坑和排查链路,这也是我觉得比所谓“标准流程”更值钱的部分。如果你正准备做图像分类,或者正在被分类准确率卡在某个数值上不来,这篇内容应该能给你省下好几天的试错时间。
1. 先把“4”拆清楚:图像分类任务的四种常见形态
很多拿到“4图像分类任务”这个说法的人,第一反应是“不就是四分类吗,softmax一接,交叉熵一算,结束了”。实际上一旦开始动手,你会发现“图像分类”这四个字下面藏了四种完全不同的任务形态,它们决定了你的输出层、损失函数、评估指标和上线方式,从一开始就混为一谈,后面基本会越做越乱。
第一种是二分类,一张图只回答“是或者不是”,典型场景是质检流水线上判断零件表面有没有划痕。第二种是多分类,也就是本项目遇到的猫、狗、鸟、鱼四选一,类别之间互斥。第三种是多标签分类,一张图同时存在多个属性,比如一张风景照里既有天空又有海水,模型要同时输出多个“有/没有”。第四种是细粒度分类,同样是鸟类,要区分白颊噪鹛和灰头鹪莺,看着像但本质上差很远。四种形态的差异,核心在三个地方:输出层的神经元设计、损失函数、以及评估指标。
| 任务形态 | 输出层 | 损失函数 | 主要评估指标 | 典型场景 |
|---|---|---|---|---|
| 二分类 | 1个神经元 + Sigmoid | BCEWithLogitsLoss | AUC、F1 | 缺陷检测、垃圾邮件识别 |
| 多分类(四分类属于此类) | 4个神经元 + Softmax | CrossEntropyLoss | Accuracy、Top-1/Top-5 | 图片归档、物种识别 |
| 多标签 | 多个神经元 + Sigmoid | BCEWithLogitsLoss | mAP、F1 | 图像打标、内容审核 |
| 细粒度 | 子类别神经元 + Softmax | CrossEntropyLoss + 排序损失 | Top-1、一致性 | 车型识别、鸟种鉴定 |
当时我们拿到这个四分类任务时,第一件事不是急着下数据集,而是把任务形态确定成“多分类”。这句话看起来多余,但实际上它决定了后面所有动作的方向:输出层固定为4类,损失函数直接用交叉熵,评估指标看Top-1和混淆矩阵。如果你把多分类当成多标签去做,输出层会变成4个独立的Sigmoid,那么一张图可能同时被预测成“猫”和“鱼”,这在互斥任务里完全不可接受。
另外,“4”这个数字本身还有一层意思:它是一个足够小但又不至于过于简单的类别数。二分类做到90%准确率可能大家都觉得不错,但四分类的随机基线只有25%,模型哪怕做到70%,很多人也会觉得“还有很大提升空间”。这种心理预期会逼着项目组去认真处理数据质量和类别边界问题,这反而是好事。我见过太多一上来就做1000类分类的项目,结果连最低类别的baseline都建不出来。从四分类入手,是一个性价比极高的训练场。
2. 数据先行:没有靠谱标注,后面全是无效努力
接手这个任务时,大多数人最先问的问题是“用什么模型”。我的答案是:在动手选模型之前,先把图片整理清楚。图像分类任务里,数据质量对最终结果的影响远大于模型结构的影响。同一个ResNet,在一个清洗干净的数据集上能跑到95%,在一个标注混乱的数据集上可能只有60%,差别全在源头。
2.1 数据集目录怎么组织
我们当时的数据来源比较杂,有从用户相册里搜集的日常照片、有从图库里购买的正版授权图片、也有同事手动拍摄的补充素材。四类分别是猫、狗、鸟、鱼,原始图片数量大约6000多张。第一件事是按下面的格式把目录建起来:
data/ train/ cat/ dog/ bird/ fish/ val/ cat/ dog/ bird/ fish/ test/ cat/ dog/ bird/ fish/这个目录结构是绝大多数深度学习框架和开源工具默认支持的格式,PyTorch的ImageFolder类可以直接读取,不需要额外维护一份CSV标注文件。训练集、验证集、测试集三者的划分比例,我们用了大约7:2:1。这里有一个经常被忽略的点:验证集和测试集必须来自不同的分布,比如验证集里可以有部分同一批次的图片,但测试集最好是从另一个采集渠道拿到的新数据。小样本情况下,如果你把同一来源的数据既当验证集又当测试集,最终跑出的指标会虚高。
划分完之后,建议每个类别的图片数量做个统计。我们当时的数量并不均衡:猫最多,差不多2000张;鱼最少,只有800张。这种不均衡如果不在前期意识到,后期模型会悄悄变成“看到棕色毛发的物体就猜猫”,而评估指标因为整体准确率还不错,很难暴露问题。
2.2 标注审核:比模型训练更磨人的环节
数据集建好目录之后,我强烈建议你亲自把所有图片过一遍,尤其是在别人的标注结果上做二次检查。这不是信不过人,而是图像分类的标注里面有一种特殊的错误——主观边界模糊。比如一张图片里同时出现猫和狗,标注员的判断可能完全取决于画面主体离镜头远近;又比如“鸟”这个类别包括飞在空中的、站在树枝上的、甚至是只露出半个身子的,不同标注员对“这只算不算鸟”会给出完全不同的答案。
我们当时自己做过一次统计,随机抽了300张训练图重新标注,发现大概有12%的图片存在类别争议。这12%如果直接留在训练集里,模型学到的不是“猫和狗的区别”,而是“哪些图被错误标注了”。更麻烦的是,模型会在验证集上误打误撞命中一些错误标注,导致验证集准确率越高,反而越不可信。
清洗的做法是:把所有疑似有争议的图片单独拉出一个文件夹,让三个人分别标注,取多数票决定类别;如果三个人都给不出统一答案,就把图从训练集剔除,保留到测试集里做难度样本。这看起来像笨办法,但对小数据集的提升非常明显。我们清洗前后,同一个模型在测试集上的准确率提升了将近5个百分点,而这5个百分点完全没花一分钱算力。
2.3 数据增强:不是越多越好
数据增强是图像分类里最容易被“多即是好”误导的部分。PyTorch的torchvision.transforms里有一堆增强方法,但一股脑全用上去,效果反而会变差。我们最终稳定使用的一组增强配置如下:
train_transform = transforms.Compose([ transforms.RandomResizedCrop((224, 224), scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])有几个配置细节值得说一下。RandomResizedCrop的scale参数我没有用默认的(0.08, 1.0),而是调到了(0.6, 1.0)。原因是我们的原始图片里有很多“猫趴在沙发角落”这种小目标,如果裁剪比例过小,增强后模型看到的是一个完全缺失主体的区域,反而学不到有效特征。Normalize用的均值方差是某个大规模公开图像数据集的统计值,这是迁移学习的通用习惯——因为后面要用预训练模型,输入分布需要和预训练时的输入分布一致。
那些随机旋转、随机灰度化、高斯噪声等增强,我建议你在实验初期先不加。等模型出现明显过拟合时,再逐步加进去对比验证集指标的变化。无脑堆增强,只会让训练更加不稳定。
3. 模型选型与训练管线:为什么我默认先上迁移学习
模型选型可能是这个项目里最不用纠结的部分。对于四分类这种规模的任务,结论非常明确:除非你手里有几十万张同类图片,否则永远不要自己从头训练一个卷积网络。从头训练的代价不仅是时间,更在于你根本凑不出那么多样本空间。图像分类模型能识别“猫”这个语义,靠的是底层纹理、边缘、形状等大量基础特征,这些特征在大规模数据集上已经学好了,你需要的只是换掉最后一层。
3.1 迁移学习的具体做法
以PyTorch为例,当时我们选的骨干网络是ResNet18,四分类任务它已经足够。加载方式非常简单:
import torch.nn as nn import torchvision.models as models def build_model(num_classes=4): model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) return model关键就在最后一行:把原模型的1000类全连接层替换成4类全连接层。但“替换”之后还有一个隐藏的细节——整个模型被分成了两个部分:前面是预训练骨干,后面是随机初始化的分类头。如果一上来就用同一个学习率训练,随机初始化的分类头梯度会非常大,而预训练骨干的梯度很小,模型训练会非常不稳定。
我们的标准做法是分两阶段训练:第一阶段先把骨干部分冻结,只训练新加的全连接层,大概3到5个epoch;第二阶段再解冻骨干,用更小的学习率微调整个模型。这比直接从第一秒就全量微调更稳,特别适合数据量只有几千张的项目。第一阶段能让分类头先学会充分利用预训练特征,第二阶段再让骨干特征向你的数据分布轻微偏移,两个阶段各司其职。
3.2 各类骨干模型的取舍
ResNet18之外,我们也对比过其他几个常用选择,结论供参考:
| 模型 | 参数量 | 推理速度 | 四分类效果 | 适用场景 |
|---|---|---|---|---|
| ResNet18 | 约1100万 | 快 | 很好 | 数据量小、追求稳定、CPU也能跑 |
| ResNet50 | 约2500万 | 中等 | 略好但有限 | 数据量超过1万张、GPU资源充足 |
| EfficientNet-B0 | 约400万 | 快 | 与ResNet18相当 | 对模型体积敏感、需要省内存 |
| MobileNetV3 | 约250万 | 最快 | 略差一两个点 | 移动端/嵌入式设备部署 |
特别想提醒的是:在四分类这类简单任务上,把ResNet18换成ResNet50或者更大的模型,带来的提升往往只有1到2个百分点,而训练时间和推理延迟都会成倍增加。不要迷信模型大小,先看数据是否配得上模型复杂度。我们最终保留ResNet18,不是因为不能选更大的,是因为在清洗后的数据集上,ResNet50只比ResNet18高了0.7%的准确率,但推理时间慢了大约一倍。考虑到后面要部署到普通服务器上为实时接口服务,这点提升完全不划算。
3.3 训练超参数与优化器选择
优化器我们选择的是AdamW,学习率设为3e-4,权重衰减1e-4,批次大小32,训练总轮数30。第二阶段解冻骨干时,骨干部分的学习率单独降为3e-5,分类头保持3e-4。这种“分组学习率”的做法比全局用一个学习率要好很多。
学习率调度使用余弦退火,用一个简单的PyTorch代码片段示意核心逻辑:
optimizer = torch.optim.AdamW([ {"params": backbone.parameters(), "lr": 3e-5}, {"params": head.parameters(), "lr": 3e-4} ], weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=30, eta_min=1e-6 )损失函数直接用CrossEntropyLoss,但加了一个小技巧:标签平滑,参数设为0.1。四分类任务的训练集标注本身就存在那12%的争议,如果模型对某个类别的预测概率被训练成绝对的1.0,说明它已经开始“死记硬背”训练集里的标注噪声了。标签平滑的作用是告诉模型:不要对标签过于自信,保留一点点不确定性。这0.1的平滑值,在验证集上确实带来了约1.5%的提升。
另外有一个新手很容易犯的错误:验证集和测试集的数据加载器务必设置shuffle=False,否则你每次评估时看到的指标都在随机波动,因为样本顺序不同,batch划分不同,根本分不清是模型变好了还是随机性变大了。我见过有人因为没关shuffle,同一个模型前后两次验证准确率相差4个点,白白浪费了一整天排查。
4. 训练过程的真实翻车现场:三条排查链路
再顺利的项目,训练过程都不可能一遍过。这一部分我想完整还原我们遇到的三个问题以及排查思路。它们都不是什么“高深”的问题,但每个都足够让人卡住大半天。
4.1 链路一:验证集准确率卡在25%附近
第一次训练跑完30个epoch,验证集准确率停留在24.9%,和随机猜测的25%几乎没有差别。这种“模型完全没学习”的现象,绝大多数情况下不是模型结构的问题,而是数据管道出了问题。
我的排查步骤是固定的:第一步,打印一个batch的数据,人工检查图片和标签是否对应。我们当时照着做,立刻发现问题:数据加载时标签顺序和文件夹顺序错位了。ImageFolder是按照文件夹的字母顺序分配类别的,但我们对cat这个文件夹做了某种手动改名操作,导致加载器和统计脚本之间出现了不一致。模型虽然在训练,但每次看到的图片和标签根本对不上,它只能学习到“每个类别概率差不多”这种最平庸的策略。
第二步,检查损失值的变化曲线。如果训练时loss在下降但验证集准确率不动,多半是标签系统性问题;如果loss也不下降,那可能是学习率太大或者模型没有真正解冻。我们当时属于前者。修复数据加载顺序后,重新训练,同一个模型架构直接跳到了78%的准确率。这个坑看起来很低级,但在真实项目中出现的频率远比你想象的高。所以遇到准确率卡在随机基线的场景,第一反应不应该是调模型,而是去打印数据看看。
4.2 链路二:过拟合,训练集98%但验证集只有78%
第二版模型训练集准确率轻松到了98%,但验证集一直在78%附近徘徊。这是一个典型的过拟合信号。四分类任务,训练集只有几千张,模型容量相对数据量还是太大了。
我们做的第一件事是把数据增强强度拉高,重点增加了两个操作:随机遮挡和颜色扰动。RandomErasing把图片中随机一个小区域抹成纯色,强迫模型不要只依赖某个局部特征;ColorJitter的强度从0.2提高到0.3,让模型不要对光线条件过度敏感。增强之后验证集涨了2个百分点。
第二件事是给分类头之前加一个Dropout层,丢弃率设为0.3。预训练骨干的输出特征维度是512维,对四分类来说信息量是冗余的,加Dropout能有效降低全连接层对某些特征通道的依赖。第三件事是启用EarlyStopping,不再无脑跑满30个epoch,而是监控验证集F1,连续5个epoch不提升就停止,然后回滚到验证集指标最优的模型权重。
这里有个教训:很多人只保存最后一个epoch的权重,但最后一个epoch往往不是验证集表现最好的。我们统一在训练循环里加了一个逻辑:每个epoch结束评估验证集指标,如果优于历史最佳,就保存一份checkpoint。最终交付的模型从来不是训练日志里最后一个,而是验证集上最好的一版。
4.3 链路三:鱼类的召回率一直上不去
当整体准确率稳定在92%左右时,我们看混淆矩阵,发现一个具体问题:鱼类图片的召回率只有70%,大量鱼被误判成鸟类。原因很直接:训练集里鱼只有800张,而猫有2000张,模型天然倾向于学习数据量多的类别。
处理类别不均衡,我们尝试了两个方案。第一个是使用WeightedRandomSampler,让每个batch里每个类别的图片出现概率相同。这个方案改动最小,效果立竿见影,鱼类的召回率从70%升到82%,但猫的准确率略微下降了一点点。第二个方案是改用Focal Loss,让模型把注意力集中在那些难分类的样本上。Focal Loss在鱼类的提升幅度没有采样器明显,但它对整体指标更加平滑。
最终我选择了组合方案:训练前期用加权采样解决数量差异,训练中后期切换到Focal Loss精调。个人体会是,在四分类这样类别数不多的任务里,加权采样往往比复杂的损失函数更直接有效。如果你不想引入太多新概念,先试WeightedRandomSampler就好。
5. 评估不只是看准确率:混淆矩阵、F1与模型的“误判气质”
准确率是一个很有欺骗性的指标,尤其在四分类这种类别不均衡的任务里。假设鱼只有800张,猫有2000张,模型把所有图都猜成猫,准确率也能到33%。所以,我们后来把所有评估都改成以混淆矩阵为核心,以宏平均F1为综合参考。
5.1 用混淆矩阵找出系统性的误判模式
混淆矩阵体现的是模型在“预定义类别对”上的表现。我们打印出来的矩阵里,狗和猫之间有明显的互相误判,鱼和鸟之间也有明显的互相误判。这相当符合直观认知:猫和狗都是四足哺乳动物,身体结构和背景环境相似;鱼和鸟都经常出现在水域或天空场景里,背景特征互相干扰。
如果只看准确率,你只会知道“模型做错了12%的图片”,但不知道错在哪。而混淆矩阵会告诉你,是应该去补充更多猫狗站姿差异大的样本,还是应该重点收集鱼类在水下和鸟类在天空中的图片。我们当时的做法是:根据混淆矩阵里最高的误判对,定向扩充数据。鱼被误判成鸟的图,仔细看后发现很多是“鸟站在水边”的场景,而鱼类大多是“鱼缸里/水中”的图。这说明模型很可能在学背景,而不是学鱼本身的形态。
5.2 宏平均F1比准确率更值得盯
宏平均F1的计算方式是:先分别算出四个类别的精确率和召回率,再对每个类别单独计算F1,最后四个F1取平均。这个指标对类别不均衡更敏感。当时我们整体的准确率92%,宏平均F1只有88%,两者相差的4个点就是“模型偏科”的代价。
阈值调整在小样本多分类任务里也能起作用。默认情况下,模型输出经过Softmax后,取概率最大的那个类作为预测结果。但有些图片的置信度分布很平缓,比如猫和狗的得分分别是0.45和0.40,模型虽然选了猫,实际是非常犹豫的。对这类低置信度样本,我们用了一个简单的策略:设置一个置信度阈值,低于阈值的不直接返回分类结果,而是标记为“待人工复核”。这个策略在准确率上没带来多大提升,但显著提升了用户对系统的信任度——宁可说“我不确定”,也不要瞎猜。
5.3 可视化:看看模型到底在“看”什么
我们对验证集里一批预测错误的图片做了Grad-CAM可视化,也就是把模型决策时关注的区域热力图叠加到原图上。结果非常有意思:被误判的那批猫图片里,模型关注焦点大部分在沙发、地毯、窗帘这些背景元素上,而不是猫的脸或身体;正确的猫图,关注焦点则集中在猫的轮廓和耳朵附近。
这个发现直接改变了我们的数据处理策略。此前我们认为数据增强已经够强,但Grad-CAM证明模型仍然在偷懒,用背景线索来分类。于是我们增加了两类操作:一是给训练集中的猫狗图随机更换一个纯色背景,切断背景和类别之间的伪相关;二是对图片做中心裁剪,让模型无法利用角落里的环境线索。改完这一轮,误判数量明显下降,尤其是鱼和鸟的混淆情况大幅好转。
6. 从模型到交付:导出、量化与线上心态
模型在验证集上做到92%准确率,项目并没有结束,后面还有一整套和“训练”无关却同样重要的工作。
6.1 导出ONNX与量化
训练阶段用的是PyTorch,但生产服务端用的是推理引擎,不可能让线上接口直接依赖PyTorch。我们统一走ONNX导出,再转换为推理引擎格式部署。导出时有几个参数必须注意,否则后面调用会极其痛苦:
dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model.cpu(), dummy_input, "image_classifier.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=17 )dynamic_axes非常关键。如果不声明它,导出的模型就固定死了输入批次大小必须为1。线上接口接收的请求批次大小并不恒定,声明动态轴之后,同一个模型既能处理单张图,也能处理批量请求。opset_version不要用默认值,根据目标推理引擎支持的版本去设置,否则可能出现算子不兼容。
导出之后,我们对模型做了精度量化对比。FP32模型的单张CPU推理延迟大约28毫秒,INT8动态量化之后降到11毫秒,准确率只下降了0.8个百分点。对于四分类任务来说,这个精度损失完全可以接受,所以线上最终跑的是INT8模型。量化的教训是:不要想当然地认为INT8一定会掉点,实际效果可能比预期好得多;但也不要批量上线前不验证,不同模型的量化敏感度差异很大,严格按照测试集做精度对比再决定是否上线。
6.2 模型维护比训练更考验心态
最后想聊一个不太技术但非常重要的部分:模型上线的第一周,准确率可能会比验证集低。原因无他——线上真实图片的分布和测试集分布永远有差异。用户上传的猫照片可能逆光、模糊、有大量文字水印,甚至画面里同时有三只猫一只狗。这些“脏样本”在训练集中很少见到,模型表现自然打折。
我们当时建立了一个反馈闭环:线上预测置信度低于80%的图片,全部自动保存下来,每周抽一批回填到训练集里,人工标注后做增量微调。这个机制运行了一个月后,线上准确率从87%稳步回升到91%。所以,千万不要把模型当成一次性交付的产物。图像分类项目的生命周期,从模型上线那一刻才真正开始。
我个人在这几轮迭代里最大的感受是,四分类任务虽然简单,但它是完整走通一遍“数据-训练-评估-部署-迭代”链路的最佳训练场。后面再做更复杂的分类项目,前面的经验几乎都能直接复用——清理数据永远比调模型重要,混淆矩阵永远比准确率诚实,置信度阈值和人工复核永远是线上兜底的好朋友。希望这篇复盘能帮你少踩几个我已经替你踩过的坑。