图像分类这堂课,终于轮到动手了。前几节我们还在和数学公式、网络结构图打交道,到了第五节,很多人的第一个直观感受是:原来从零把一个分类模型训练到能看的程度,中间要踩的坑比想象中多得多。这一节的内容比较贴近实际项目,不是照本宣科地讲卷积层怎么堆,而是带着一张真实图片、一份真实代码,完整走一遍“数据准备—模型搭建—训练调试—评估分析—导出部署”的流程。不管你是刚入门的学生,还是工作里临时接手图像任务的开发,按这条路线复现一遍,会对整个深度学习落地过程建立起更立体的体感。
1. 图像分类问题拆解:从像素到概率之间发生了什么
1.1 一张图片在模型眼里到底是什么
很多人刚学图像分类时,容易把“看图片”想得太拟人化。模型并不像人一样“欣赏”一只猫的轮廓、毛色、姿态,它看到的只是三个数字矩阵:高、宽、通道。RGB彩色图进来,就是三张并排的二维数组,每个位置的值在0到255之间,表示对应像素的亮度。
所以我们做预处理的第一步,通常就是把0到255的整数缩放到一个模型更舒服的数值范围。常见做法是除以255变成0到1的浮点数,更讲究一点会用均值方差归一化,比如ImageNet数据集的统计量均值是[0.485, 0.456, 0.406],标准差是[0.229, 0.224, 0.225],逐通道做(x - mean) / std。这一步不是锦上添花,而是直接影响梯度更新的稳定性。
1.2 分类模型是“打分器”,不是“搜索器”
如果把分类模型理解成一个“搜索引擎”——给定一张图,去数据库里找最相似的图——那就跑偏了。实际上的分类模型是一个打分函数:输入图片,输出一个长度为类别数的向量,每个元素代表模型认为这张图属于某个类别的置信度。最后一层通常接softmax,把这个向量变成一组和为1的概率值。
用生活里的例子类比,就像考试阅卷:模型不是翻参考答案找原题,而是根据平时学到的特征规律,给每个选项打一个分数。卷积网络的作用,其实是在替我们完成“特征工程”——低层提取边缘、纹理,高层组合出轮廓、部件,最后由全连接层把这些特征映射到类别空间。
1.3 实战中的三类分类任务:二分类、多分类、多标签
第五节的重点放在多分类上,但动手前一定要先分清楚自己手上的任务属于哪一类,否则模型结构、损失函数全都会选错。
- 二分类:判断“是”或“否”,比如一张工业产品图里有没有缺陷。输出一个节点,接sigmoid,配合BCE损失。
- 多分类:每张图有且只有一个标签,比如CIFAR-10里的十个类别,或者手写数字0到9。输出类别数个节点,接softmax,配合交叉熵损失。
- 多标签:一张图同时具有多个属性,比如一幅画面里既有人也有车。这种情况下输出多个sigmoid节点,不能用softmax硬压成一个类别。
我在实际带新人时发现,最容易搞混的就是多分类和多标签。多分类的标签是互斥的——一只猫不可能同时是狗;多标签不是互斥的——同一张街景照片可以同时属于“白天”“有行人”“有车辆”。
2. 数据集是第一个大坑:清理、平衡与增强三板斧
2.1 先别急着训练,先做数据体检
很多新手容易犯一个错:拿到数据集,解压,直接写DataLoader,开始训练。跑完一看准确率惨不忍睹,第一反应是换更厉害的模型,最后折腾半天才发现是数据本身有问题。
我的建议是,任何图像分类项目,前半天只做一件事——把数据翻来覆去看一遍。包括这几项:
- 人工抽看图像:随机抽几百张图,检查有没有损坏文件、黑白图混入、标注框错位等肉眼可见的问题。
- 检查类别分布:统计每个类别的样本数量,画出柱状图。如果有类别样本数相差一个数量级,就要考虑是否要做均衡采样。
- 检查标签是否干净:现实项目里错标、漏标太常见了。我见过一个缺陷检测项目,训练集里居然混了一堆正样本,模型训出来之后对真缺陷几乎没有反应。
这一步确实枯燥,但它决定后面所有工作的上限。你后面做模型选型、调参,本质上都是在这个数据集的边界内腾挪。
2.2 数据增强不是在堆工作量,而是在造数据分布
数据增强经常被误解成“为了让训练集看起来更多”。其实它的本质是引入先验知识:告诉模型,一张猫的照片左右翻转后还是猫,稍微调亮一点也还是猫,稍微裁剪掉一点边缘也还是猫。这样模型学到的特征就不会过度依赖某个具体的像素位置或颜色值。
实战中最常用的几招:
| 增强方式 | 参数参考 | 适用场景 |
|---|---|---|
| 随机水平翻转 | p=0.5 | 自然图像通用,注意文字、方向敏感任务慎用 |
| 随机裁剪 | 裁剪后缩放回原尺寸 | 增加位置多样性 |
| 颜色抖动 | brightness=0.2, contrast=0.2, saturation=0.2 | 缓解光照差异,适合拍照类数据 |
| 旋转 | 角度±10左右 | 小角度可,太大容易破坏语义 |
| 归一化 | 按数据统计量 | 几乎必用 |
做增强时还有一个容易忽略的原则:验证集和测试集不要做随机增强,只做尺寸缩放和归一化。很多人把训练时的增强流程直接套在验证数据上,导致验证集也在“变来变去”,每次评估结果波动很大,根本没法判断模型是真在变好还是碰运气。
2.3 训练集、验证集、测试集:划分顺序能决定成败
有的项目在划分数据集时,直接用的随机打乱。如果数据是按时间顺序采集的,这么做会带来一个隐蔽问题:模型的“同分布假设”被打破。
举个例子,某工厂质检项目的数据是先收集前三个月的次品图,再收集后三个月的次品图。直接随机划分,会让训练集和验证集都包含不同批次的数据,模型悄悄学习了“批次特征”而不是真正的缺陷特征,上线之后遇到新型缺陷直接露馅。
更稳妥的做法是:先按采集时间、设备、场景等维度的外部分组,再做划分。如果是比赛或公开数据集,则尽量保持官方划分;自己建数据时,我通常按8:1:1划分训练集、验证集、测试集,并且让验证集和测试集在分布上尽量接近真实上线场景。
3. 模型怎么选:先跑通再调优的渐进式路线
3.1 从零搭网络,还是用预训练模型?
这个问题几乎是每个初学者都会纠结的。从零搭三四个卷积层,能学到东西,但和现代主流模型的精度差距是层级的;直接上预训练模型,又觉得不够“底层”。
我的建议是分阶段处理。第一遍跑通流程时,用一个自己能完全看懂的小网络把代码走通,确保损失能下降、评估脚本没问题。接下来,换预训练模型来做精度提升——这个“先跑通,再调优”的顺序可以帮你在每一步都清楚问题出在哪里。
从零搭网络时,可以参考一个基础结构:卷积-BN-ReLU堆叠,中间穿插池化层,最后加全局平均池化和全连接层。下面的代码是我常用的“第一版草稿”结构:
import torch.nn as nn class TinyCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(64, num_classes), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x3.2 轻量模型的选择参考
当你确认代码流程没问题之后,接下来的问题就是:选哪个模型当主力?
如果项目对推理速度有要求——比如部署在边缘设备、工业相机上——那么直接上大模型往往不是好选择。我做过一个对比,同样的数据,轻量模型的精度虽然低了几个点,但推理速度可以快出一个量级。
| 模型 | 参数量 | 输入尺寸 | 典型场景 | 备注 |
|---|---|---|---|---|
| MobileNetV3-Small | 约2.5M | 224x224 | 端侧、低算力设备 | 速度快,精度稍低 |
| ResNet18 | 约11M | 224x224 | 通用中等算力场景 | 结构经典,调试友好 |
| EfficientNetB0 | 约5.3M | 224x224 | 平衡精度和速度 | 架构复杂些,调参略繁琐 |
| ResNet50 | 约25M | 224x224 | 服务端、GPU资源充足 | 精度上限更高 |
初学者容易陷入“模型越深越好”的惯性,但在实战项目里,数据量、算力、实时性这几个约束往往比模型理论精度更关键。我个人的倾向是:数据量没有大到一定程度时,ResNet18、MobileNetV3这类轻量主干就够用;把多出来的精力放到数据处理和训练策略上,性价比远高于盲目加深网络。
3.3 迁移学习里的“冻结”与“解冻”策略
用预训练模型时,“冻结特征层只训练分类头”是一个常见起步策略。这样做的好处是显存占用小、训练速度快,初期也能快速验证数据集难度。
但有一个坑:如果数据集和预训练数据的域差异很大——比如用ImageNet预训练模型去做X光胸片分类——那么只微调分类头往往效果不好,因为底层学到的颜色纹理特征完全不匹配。这时候正确的做法是“解冻全部层,用较小学习率整体微调”。
我在项目中常用的方案是分两段走:第一段冻结主干,训练分类头,让模型先适应任务;第二段解冻主干的所有层,把学习率调低一个数量级,继续训练几个epoch。这个“先训头、再训全身”的顺序,比一开始就全面微调更容易稳定收敛。
4. 训练过程中的真实反馈:损失不降、过拟合与学习率
4.1 损失曲线困在2.3附近,是网络没学到东西
多分类任务里,如果类别数是10,交叉熵损失的初始值大约在ln(10)≈2.3附近。很多人的训练日志里,第一个epoch结束损失还稳稳停在2.3,就以为模型在正常收敛——其实这时候模型输出的概率基本是均匀分布的,和瞎猜没区别。
这里有个非常实用的诊断标准:如果几步迭代之后损失没有明显低于随机初始值,说明网络没在学,或者当前任务没有学起来。这时候不要急着调学习率,先检查几个基础问题:
- 标签是否错位?比如代码里读数据时索引错位,真实标签和图像内容对不上。
- 数据预处理是否正确归一化?有的框架默认输入范围是0到1,模型却期望0到255,会直接导致梯度异常。
- 最后一层的输出维度和类别数是否一致?
- 是否存在“冷启动”问题?比如预训练模型突然换了个任务头,学习率又设得太小,前几个epoch几乎原地不动。
排除了这些低级错误之后,再去动学习率、优化器这些超参数。
4.2 学习率不是玄学:warmup与余弦退火的实际配置
关于学习率,最稳妥的做法不是在若干个数值里碰运气,而是引入调度策略。我常用的一套组合是:AdamW优化器 + 线性warmup + 余弦退火。
warmup阶段让学习率从接近0逐渐上升到预设峰值,目的是避免训练一开始梯度幅度过大,震坏预训练权重。余弦退火则在训练后半程慢慢降低学习率,让损失在后期能够更精细地收敛到低点。
具体的数值参考:
from torch.optim import AdamW from torch.optim.lr_scheduler import LambdaLR, CosineAnnealingLR import math optimizer = AdamW(model.parameters(), lr=3e-4, weight_decay=0.05) # 线性warmup,持续3个epoch warmup_epochs = 3 total_epochs = 30 def warmup_lr(epoch): if epoch < warmup_epochs: return (epoch + 1) / warmup_epochs return 1.0 warmup_scheduler = LambdaLR(optimizer, lr_lambda=warmup_lr) cosine_scheduler = CosineAnnealingLR(optimizer, T_max=total_epochs - warmup_epochs, eta_min=1e-6) for epoch in range(total_epochs): ... if epoch < warmup_epochs: warmup_scheduler.step() else: cosine_scheduler.step()实际项目中,峰值学习率的选择还是得配合batch size一起看。经验性结论是:线性缩放法则——batch size翻倍,学习率大致也翻倍,前提是其他条件不变。以224x224输入、batch size 64为例,AdamW配3e-4通常是个比较稳的起点。
4.3 过拟合不是模型太“聪明”,是泛化能力被透支
训练集准确率持续走高,验证集准确率却在某一个epoch之后扭头向下,这就是过拟合的典型信号。它的本质是模型把训练数据里的噪声和个例也当成了规律,而不仅仅是“学得太认真”。
应对过拟合,我通常按下面的优先级来处理:
- 加强数据增强:这是最无损的手段。随机裁剪、翻转不够,就再上色彩抖动、随机擦除。先把模型学会的“捷径”切断。
- 降低模型容量:如果用的是大模型,换一个更轻量的主干,或者减少全连接层的宽度。
- 加正则化:weight decay、dropout、标签平滑都可以加,但不要一次性全加,否则很难定位到底是哪个手段起了作用。
- 使用早停:监控验证集指标,在连续若干个epoch没有提升时停止训练,保留最佳模型权重。
我见过不少同学遇到过拟合就直接上dropout,结果验证集掉得更快。原因很简单:模型容量本就不大,正则化太强把能学的东西也压没了。所以处理过拟合时,我更倾向先做数据增强,再动模型结构,最后才动损失函数和优化器。
5. 评估不是只看准确率:混淆矩阵带来的实战信号
5.1 准确率虚高:类别不均衡下的数字幻觉
训练结束,验证集准确率到了97%,看起来皆大欢喜。但如果某个类别占全部样本的95%,那么你“啥也不学、全部预测成多数类”也能拿到95%的准确率。这种虚高的数字在工业场景里特别常见。
我举一个真实发生过的例子:某零件表面缺陷类别只占0.5%,模型准确率99%,看起来无懈可击,但把错误分类拉出来看,缺陷样本几乎没有几个被正确识别出来。整条数据处理链路都停留在“整体指标好看”的幻觉里,直到上线才暴露问题。
所以评估的第一步,永远是做类别分布分析。如果发现不均衡,训练时可以考虑两类手段:一是采样调整,对少数类别做过采样;二是损失函数调整,比如给少数类别更高的权重。
5.2 混淆矩阵是排查方向的“探针”
混淆矩阵是一张行代表真实标签、列代表预测结果的表格。主对角线上的值越大,说明对应类别的识别越好;对角线之外的值,则揭示了模型“把谁认成了谁”。
具体怎么看?我总结了一套比较实用的排查路径:
- 如果某两个类别频繁互相混淆,比如狗和狼分不清,先去看这两类图像的差异是不是本来就很小,是否需要采集更多关键特征的数据。
- 如果某一列的预测结果特别集中,说明模型偏向把多种输入都判成这个类别,可能这个类别的先验概率太高,或者是特征被过度概括。
- 如果某一行几乎没有被正确识别,说明对应类别在特征空间里被淹没了,单靠调参未必能解决。
5.3 从指标反推模型:精读精确率与召回率的取舍
精确率和召回率是分类任务里永远绕不开的一对指标,它们之间的取舍取决于业务场景。
- 缺陷检测:漏掉一个缺陷的代价远高于多报一个假缺陷,所以要优先提高召回率,哪怕精确率低一点。
- 内容安全审核:误伤正常内容的代价可能很高,因此会更看重精确率。
计算方式并不复杂:精确率是“预测为正例的样本中有多少真是正例”,召回率是“真实正例中有多少被找了出来”。F1分数则是对两者的调和平均。在类别不均衡的场景下,光看准确率完全不够,至少要把这三项指标一起看。
最后的建议是:所有评估结果都落到表格化。把每个类别的准确率、召回率、F1打出来,比只看一个总体数字更有指导价值。
6. 部署环节同样有坑:训练和推理要保持一致
6.1 尺寸、归一化、通道顺序,不对齐就翻车
训练时的预处理流程经常会做很多“花活”:缩放、裁剪、翻转、归一化,每一步都有随机参数。这些逻辑只应该在训练阶段出现,到了推理阶段,所有随机性都要丢掉,只剩确定性的缩放加归一化。
最容易翻车的地方有三个:
- 输入尺寸不一致:训练时是224x224,推理时如果直接把原图不缩放、只改网络输入尺寸,结果必然错乱。
- 通道顺序不一致:PyTorch的默认排列是
(batch, channel, height, width),图像库读进来却是(height, width, channel),忘了做transpose就会出现维度报错或者语义错乱。 - 归一化参数不一致:训练时用的是ImageNet的均值和方差,推理时却只除以255,等于把模型放在了一个它没见过的输入分布上。
6.2 推理速度评估:模型大小不是唯一参数
部署时,很多人只关注模型参数量、模型文件大小,却忽略了真正的推理耗时。实际上,推理速度受到输入尺寸、硬件平台、batch大小、是否使用半精度等多重因素影响。
我一般会在选型阶段就做一个简单的Benchmark:固定一张测试图,跑20次推理取平均值。不同平台的结果差异非常大,同一模型在GPU上可能只需要5毫秒,换到CPU上可能会涨到50毫秒。若目标设备是嵌入式,就得特别留意卷积层的算子是否被该平台充分优化。
模型量化和剪枝是后续可以做的优化,但那是另一个话题。这一节只要记住一个原则:模型选型的评估指标,必须建立在你实际部署的硬件上,而不是理论参数对比表上。
6.3 从PyTorch到ONNX导出时的常见注意事项
现在把模型集成到服务里,我比较推荐先导出ONNX格式。这个格式相对通用,不少推理框架都能直接加载。
导出时的常见问题包括:
- 动态轴声明:导出时要指定哪些维度是动态的,否则ONNX默认按固定batch size导出,推理时换一次batch就得重新转换。
- 自定义算子的兼容:某些模型中使用了特殊算子,导出后其他推理框架可能不支持,需要提前确认。
- 输入输出命名和检查:导出的模型输入名、输出名和形状,要和推理脚本里的一致,否则第一次调用就会挂着不动。
import torch import torch.onnx # 把模型切到推理模式 model.eval() x = torch.randn(1, 3, 224, 224) torch.onnx.export( model, x, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}, opset_version=17, )导出完成后,我习惯先用参考实现验证一下输出的数值是否一致,确保训练到推理链路没有断。
最后分享一个我自己的小习惯:每次训练完分类模型,我都会把“数据分布特点—模型结构—最终指标—踩过的坑”四件事写成几行备注,存在项目目录下。图像分类看起来是个标准流程,但每个数据集都有自己的脾气,这些记录往往在项目迭代几个月后,成了回头排查问题的最好参考。实战做多了就会发现,模型本身的进步空间,很多时候不是靠换更大网络换出来的,而是把数据、训练、评估每个环节的细节抠出来的。