垃圾图像分类这个题目,我在不同场合被问过不下十次。有人是想做成社区里的智能分类桶,有人是给再生资源回收站做辅助分拣,还有人纯粹是想拿一个"看起来够复杂"的题来做完整落地。真正做过一轮之后你会发现,难的不是调一个ResNet出来跑出95%的准确率,难的是这95%在真实摄像头下、在沾着油污的塑料袋面前、在逆光和一地阴影里,还剩下多少。这篇内容我想把基于深度学习的垃圾图像分类识别系统从设计到落地完整拆一遍,包括类别体系怎么定、数据集怎么清、模型怎么选、训练参数怎么给、部署时延怎么估、线上出问题怎么查。不管你是刚接触图像分类的新手,还是已经跑过几个Demo、准备把模型塞进设备里的开发者,应该都能从里面找到能直接抄的部分。
1. 先想清楚系统要解决的是哪一种"分类"
1.1 三类完全不同的需求,对应三套完全不同的方案
很多人一上来就问"用哪个模型准确率高",这个问题在没有明确场景之前是没法回答的。垃圾图像分类识别在落地时至少分成三种形态,它们对模型的要求差异极大。
第一种是投放端识别,也就是装在分类桶上方或者旁边的小屏设备,用户拿着垃圾靠近,系统判断"这是可回收物还是其他垃圾"。这类场景的特点是距离近、目标大、背景相对干净,但硬件算力很弱,往往是一块低功耗开发板,NPU算力只有零点几TOPS,功耗预算卡在几瓦以内。这种场景下模型的第一优先级是推理速度和内存占用,精度够用就行。
第二种是清运端的车载抓拍或者中转站抽检,图像来自固定角度的高清相机,一次拍一整桶或者一整堆。这类场景目标多、相互遮挡严重、类别混杂,单标签分类根本不够用,本质上更接近目标检测加多标签分类的组合任务。如果硬上单标签分类器,模型只能输出一个"最像"的类别,业务上毫无价值。
第三种是分拣线上的高速识别,传送带上的物料以固定速度通过,相机触发拍照,模型要在几十毫秒内给出结果并驱动气吹或者机械臂。这类场景对时延的要求最严苛,同时也最容易做数据增强——因为相机位置、光照、传送带背景都是固定的,可以针对性地构造训练数据。
我见过最常见的失败案例,是拿投放端的思路去做分拣线:模型在实验室的公开数据集上准确率很高,一上线就崩,因为分拣线上的物料是堆叠的、被压扁的、带着残液的,跟干净的单品照片完全不是同一个分布。所以在动手写第一行代码之前,先把这三类场景对号入座,比选什么骨干网络重要得多。
1.2 为什么传统图像处理方案在这里基本走不通
有人会问,垃圾的颜色、形状特征那么明显,用HSV阈值加轮廓分析不行吗?小规模、品类固定的场景确实可以,比如只区分透明瓶和易拉罐,用颜色直方图加圆形度就能做到不错的效果,代码量还不到深度模型的一个零头。
但一旦品类扩到十类以上,传统方法的维护成本会指数级上升。垃圾这个 domain 的特殊性在于类内差异极大而类间差异极小:同一个"塑料瓶"类别里,有透明的矿泉水瓶、有绿色的饮料瓶、有被压扁的、有贴着大片标签的、有装过酱油发黑的;而"塑料袋"和"保鲜膜"在视觉上几乎是同一种东西。你想靠人工设计特征把这两者分开,基本是徒劳。
深度模型的价值就在这里:它不需要你去定义"什么形状叫瓶子",而是从数据里自己学。而且卷积网络天然具备一定的平移不变性和局部纹理敏感性,对光照变化、轻微形变的鲁棒性远好于手工特征。这也是我在这类项目里几乎不会考虑传统方案的原因——不是传统方法不行,而是它的边际维护成本太高,每加一个新品类就要重新调一轮阈值。
还有一点容易被忽略:传统方法的输出是不可解释的连续分数,很难做出"置信度低就转人工"的降级逻辑。而深度分类器输出的softmax概率,虽然校准得不一定好,但至少提供了一个可用的相对置信度,配合温度缩放之后,能支撑起一套还算靠谱的拒识机制。
2. 方案选型:类别体系、数据集与骨干网络怎么定
2.1 类别体系设计决定了这个系统的天花板
我见过太多项目在这一步就埋下隐患:直接照着四分类国标(可回收物、有害垃圾、厨余垃圾、其他垃圾)做,结果模型训出来一片混乱。原因很简单,四分类是给"人"用的粗粒度规则,不是给"机器"学的视觉类别。同一个"可回收物"里既有纸箱也有玻璃瓶,视觉差异比"玻璃瓶"和"其他垃圾里的陶瓷碗"之间的差异还大。模型被迫把视觉上毫不相干的东西归到一类,学习难度陡增。
我的做法是分两层:底层用细粒度视觉类别,比如"纸箱""报纸""塑料瓶""玻璃瓶""金属罐""塑料袋""厨余""电池"这些,让模型先学视觉上可分的东西;上层再用一张映射表把细类归并到业务标准类别。这样模型的学习目标和视觉可分性是对齐的,准确率会明显好一截,而业务方拿到的仍然是他们要的四分类结果。映射表放在后处理里,改规则不用重训模型,这是很实用的一点。
细类数量建议控制在8到30之间。太少,业务价值不足;太多,每一类的有效样本数会被稀释,而且类间混淆会迅速恶化。我在一个社区项目里最终定的是12类,具体是:纸箱、纸张、塑料瓶、硬质塑料、塑料袋膜、玻璃、金属、织物、厨余、电池、陶瓷砖石、其他。这12类在视觉上有一定区分度,同时覆盖了大部分常见生活垃圾。
还有一条经验:一定要留一个"其他/不确定"类,并且在训练集里放足够多样的样本。没有这个类,模型遇到没见过的物体也会强行塞进最相似的一类,输出的置信度还挺高,这种情况在线上非常危险。
2.2 数据集构建是整件事里最耗时间也最值钱的部分
公开数据集可以拿来起步,但它们和你的真实场景之间通常隔着一条鸿沟。我的习惯是先用公开数据跑通流程,验证模型结构和训练脚本没问题,然后再用自采数据做真正的训练。自采数据的采集方案要提前设计,不能随手拍。
采集时我一般会要求覆盖这几个维度:同一物品的不同角度(俯视、45度、侧视)、不同光照(自然光、室内灯、逆光、夜间补光)、不同状态(干净、脏污、压扁、部分遮挡)、不同背景(桌面、地面、桶内、传送带)。每个维度至少要有一定比例,否则模型会学到"背景即类别"这种捷径。我踩过最典型的一个坑,是早期采集时所有塑料瓶都放在白色桌面上拍,所有纸箱都在木地板上拍,模型很快收敛到98%,一换背景直接掉到60%以下。这个教训值几千块钱的标注费。
样本量的估算有个粗略公式:目标类别数乘以每类最低样本数,再乘以一个冗余系数。经验上,做迁移学习微调时,每类有效样本不低于300张,想做到比较稳的效果建议500到800张,12类的话总量在8000到12000张之间比较合理。冗余系数取1.3左右,用来抵消清洗时被剔掉的重复图和低质图。如果某几类实在采不到,优先考虑合并类别而不是硬凑,凑出来的样本只会污染决策边界。
标注环节的规范要写死在文档里,不能口头交代。核心几条:一张图只标一个主类别(多标签另说)、边界模糊的样本直接丢进"不确定"池不参与训练、同一物品的连拍图不能跨集合。最后这条是数据泄漏的重灾区,后面会专门讲。
2.3 骨干网络选型:不是越深越好,是越匹配越好
模型选型我一般看三个指标:参数量、FLOPs、以及在类似数据规模上的实测表现。下面这张表是我在几个项目里实际测过的对比,输入统一为224×224,数据集约1万张、12类:
| 骨干网络 | 参数量 | FLOPs | 实测Top-1 | 单张推理时延(ARM四核,FP32) |
|---|---|---|---|---|
| ResNet18 | 11.2M | 1.8G | 88.6% | 约 42ms |
| ResNet50 | 25.6M | 4.1G | 90.3% | 约 96ms |
| MobileNetV3-Small | 2.5M | 0.06G | 85.1% | 约 9ms |
| MobileNetV3-Large | 5.5M | 0.22G | 88.9% | 约 18ms |
| EfficientNet-B0 | 5.3M | 0.39G | 90.1% | 约 26ms |
| ConvNeXt-Tiny | 28.6M | 4.5G | 90.8% | 约 105ms |
时延这一列是在同一块板子上量的,只统计前向推理,不含前后处理,实际部署还要加上图像解码和预处理的十几毫秒。数据仅供横向比较,不同板子差异很大,必须自己在目标硬件上实测。
从表里能看出一个很关键的事实:ResNet50 比 MobileNetV3-Large 多花四倍多的算力,换来的精度提升不到1.5个百分点。在我的经验里,1万张量级的数据集上,模型容量超过某个阈值之后收益就非常平缓了,真正卡住精度的是数据质量和类别可分性,不是网络深度。所以除非你有十万级以上的数据,否则没必要一上来就上大模型。
我的默认选择是:服务端跑的、时延不敏感的抽检场景,用 ResNet50 或者 ConvNeXt-Tiny 起步,稳;端侧设备一律从 MobileNetV3-Large 或者 EfficientNet-B0 开始,前者速度更快、后者精度略高,看你的功耗预算。EfficientNet 系列的复合缩放思路在小模型上确实有效,但它的深度可分离卷积在某些老 NPU 上支持不好,转换工具链可能会吐出奇怪的算子,这一点在选型时要提前验证。
还有一个容易被忽略的选项:知识蒸馏。用服务端训好的大模型去教端侧的小模型,通常能在小模型原有精度上再提一到两个点,代价只是训练时间翻倍。我在一个投放端项目里用 ResNet50 当教师、MobileNetV3-Large 当学生,端侧精度从88.9%提到了90.4%,而推理时延一点没变。这笔买卖相当划算。
3. 数据处理与训练前准备的关键细节
3.1 数据清洗:去重比去噪更重要
拿到一批原始数据后,我做的第一件事不是标注,是去重。很多采集方案是视频抽帧或者连拍,同一秒钟同一个物品能产生十几张几乎相同的图。这些图如果随机分到训练集和验证集里,验证指标会虚高得离谱,因为模型在验证集上看到的就是训练时见过的东西。
去重的做法是用感知哈希。把图缩到32×32、转灰度、做离散余弦变换、取左上角8×8的低频块、跟均值比较得到64位指纹,两张图的汉明距离小于5就认为是重复。这个阈值我调过几次,5左右比较合适,太严会漏掉变形重复,太松会误杀不同角度的同一物品。代码不长,几十行就能写完:
import cv2 import numpy as np def phash(img_path, hash_size=8, highfreq_factor=4): size = hash_size * highfreq_factor img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA) dct = cv2.dct(np.float32(img)) low = dct[:hash_size, :hash_size] med = np.median(low) bits = (low > med).flatten() return bits def hamming(a, b): return int(np.count_nonzero(a != b))去重之后还要做质量过滤。我通常会剔除这几类:分辨率短边小于256的、模糊度(拉普拉斯方差)低于100的、大面积过曝或者欠曝的。注意阈值别定太死,垃圾照片本来就不可能都拍得干净漂亮,过滤太狠会把真实场景的难样本全干掉,模型上线后反而更脆弱。我一般的比例是剔掉10%到15%,如果超过这个比例,说明采集方案本身有问题,得回去改流程。
3.2 类别不均衡:从采样、损失、评估三个层面一起下手
真实数据几乎不可能是均衡的。厨余垃圾随手一拍就是几百张,电池可能翻遍整个小区才凑出五十张。直接训的话,模型会倾向于预测样本多的类,评估指标也会被大类带偏。
处理这件事我习惯三个层面同时做。数据层面用重采样,但不是简单复制小类样本,复制会导致过拟合。更好的做法是对小类做更强的增强,包括随机裁剪、颜色抖动、MixUp,把小类的样本多样性"造"出来。我一般会设一个增强强度表,大类用基础增强,小类在这个基础上再叠加旋转、局部遮挡、更强的色彩扰动。
损失函数层面,类别数不多、不均衡程度不算极端(最大类和最小类比例在20倍以内)的情况下,用加权交叉熵就够了,权重按类别样本数的倒数开方来设,比如w_c = (N / (K * n_c)) ** 0.5,这样不会像直接取倒数那样把权重拉得过猛。
import numpy as np import torch counts = np.array([...]) # 每类样本数 K = len(counts) N = counts.sum() weights = (N / (K * counts)) ** 0.5 class_weights = torch.tensor(weights, dtype=torch.float32) criterion = torch.nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.1)标签平滑那0.1是我习惯加的,能缓解模型过度自信的问题,对后面的置信度阈值设定有好处,代价是训练集准确率看着没那么漂亮,纯属正常。
评估层面,绝对不能只看整体准确率。我固定会看三个数:宏平均F1、每类的召回率、以及混淆矩阵。宏平均F1对每个类一视同仁,小类掉点会立刻反映出来;混淆矩阵能直接告诉你哪两类在打架,这是下一步优化的方向。
3.3 数据集划分:按物品ID划分,不是按图片随机划分
这是我最想强调的一条。随机划分在垃圾数据集上会系统性高估性能,因为同一个物品的多张照片高度相关。正确的做法是先给每个物理物品一个ID,然后按ID划分,保证同一个物品的所有图片只出现在一个集合里。
比例上我一般用 7:1.5:1.5,但验证集和测试集要保证每类至少有一定数量,尤其是小类,不能出现测试集里某类只有两张图的情况,那样指标毫无统计意义。如果小类样本实在少,可以把验证集和测试集合并,用交叉验证来估性能,虽然麻烦但至少结论可信。
还有一点:测试集一旦定下来就不要动。我见过有人在调参过程中反复看测试集结果,看着看着就把测试集当验证集用了,最后报出来的数字全是过拟合的产物。测试集应该封存到项目收尾时才打开一次,中间所有决策都基于验证集。
3.4 数据增强:在线增强为主,离线增强只用于小类扩增
增强策略我倾向于在线做,用框架自带的数据管道就行,随机性强、内存占用低。基础组合一般是:随机裁剪加缩放(scale 0.7到1.0)、水平翻转、小角度旋转(正负15度)、颜色抖动(亮度、对比度、饱和度各正负0.3)、随机擦除。
要注意的是,有些增强在这个任务上是有害的。垂直翻转会让易拉罐倒过来,虽然理论上垃圾没有"正"的概念,但考虑到实际相机都是固定角度,模型学到一点方向先验未必是坏事。我第一次做的时候开了全角度旋转,结果模型对倒置的瓶子识别率明显下降,因为增强把方向信息彻底抹掉了,而真实场景里瓶子很少是完全倒置的。这个取舍要看你的相机安装方式,别想当然。
小类扩增我会额外做离线增强,每个小类复制出原数量的1到2倍,加上更强的扰动。离线增强的文件要打上标记,方便后面回溯,别跟原始数据混在一起分不清。
4. 训练流程实现与关键参数设置
4.1 训练脚本骨架:把可变的东西都抽成配置
我写训练脚本有个习惯:所有超参数、路径、模型名全部放进一个配置字典,脚本本身只负责流程。这样后续做消融实验、做多模型对比,改一个字段就能跑,不用翻代码。下面是核心骨架,精简过但能直接跑:
import torch from torch import nn from torch.utils.data import DataLoader from torchvision import transforms, models cfg = { "data_root": "data/split", "num_classes": 12, "img_size": 224, "batch_size": 64, "epochs": 40, "lr": 3e-4, "weight_decay": 1e-4, "warmup_epochs": 3, "label_smoothing": 0.1, "use_amp": True, } train_tf = transforms.Compose([ transforms.RandomResizedCrop(cfg["img_size"], scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(0.3, 0.3, 0.3), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(int(cfg["img_size"] * 1.14)), transforms.CenterCrop(cfg["img_size"]), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ])归一化用的均值方差是ImageNet的统计量,因为我们要加载预训练权重,输入分布必须跟预训练时一致,这一点别自作主张改成自己的数据集统计量,除非你打算从头训。从头训在小数据集上基本没戏,准确率会低一大截。
优化器我一般用 AdamW,学习率3e-4,权重衰减1e-4。有人坚持用 SGD 加动量能获得更好的泛化,这个结论在大数据集上确实成立,但在一万张量级的小数据集微调场景下,AdamW 收敛更快、对学习率更宽容,我更倾向于它。如果最后指标卡住了想再抠一个点,可以试试 SGD,lr 设 0.01、momentum 0.9、cosine 衰减,有时候能有点惊喜。
学习率调度用 warmup 加余弦退火。前3个epoch线性升温到峰值,之后余弦降到接近0。warmup 在小数据集微调时很有必要,因为一开始模型头部是随机初始化的,直接给大学习率会把预训练权重冲坏。
4.2 迁移学习的微调策略:先冻后放,分层给学习率
加载预训练权重之后,我通常分两个阶段。第一阶段冻结骨干网络,只训分类头,跑5个epoch左右,这时候学习率可以给大一点,1e-3。这个阶段的意义是让随机初始化的分类头先收敛到一个合理的区域,避免它产生的大梯度回传污染骨干的预训练特征。
第二阶段解冻全部参数,用较小的学习率整体微调。这里可以做个差分学习率:骨干用峰值学习率的0.1倍,分类头用正常学习率。实现很简单,把参数分成两组传给优化器就行。
backbone_params, head_params = [], [] for name, p in model.named_parameters(): if "classifier" in name or "fc" in name: head_params.append(p) else: backbone_params.append(p) optimizer = torch.optim.AdamW([ {"params": backbone_params, "lr": cfg["lr"] * 0.1}, {"params": head_params, "lr": cfg["lr"]}, ], weight_decay=cfg["weight_decay"])批大小64在单张显存档位够的卡上是没问题的,显存吃紧就降到32,同时把学习率按线性缩放规则调整,32的话学习率减半,别小看这个细节,批大小和学习率不匹配是训练发散的常见原因之一。
混合精度训练(AMP)我默认开,速度大概能快40%,显存占用也能省三成左右,对精度影响很小。用法就是torch.cuda.amp.autocast()包住前向,GradScaler包住反向,标准的几步,没什么坑,唯一要注意的是损失计算如果在 autocast 里做了需要精度的操作(比如算softmax再取log),要手动转成 float32。
4.3 训练过程监控:别只盯着loss曲线
训练过程中我会同时记录训练loss、验证loss、验证宏平均F1、学习率。判断收敛状态主要看验证F1,训练loss只能作为参考。训练loss一直降但验证F1不涨甚至下降,就是过拟合的典型信号,这时候要么加增强、要么加正则、要么提前停。
早停策略我设的耐心值是8个epoch,验证F1连续8轮没刷新最好成绩就停,同时保存最好那一轮的权重。这个耐心值别设太小,学习率余弦衰减的过程中,指标经常会在后期突然跳一下,停早了会错过。
还有一个实用技巧:保存最后5轮的权重,训练结束后在验证集上做一次简单的加权融合(权重按验证F1归一化),通常能再涨零点几个点,成本几乎为零。这个做法在分类任务上很稳,我在多个项目里都验证过。
4.4 结果分析:混淆矩阵告诉你下一步该干什么
训完之后第一件事不是看总体准确率,是画混淆矩阵。我一般会把矩阵按行归一化成召回率,这样小类的问题不会被大类淹没。
假设跑出来的结果里,"塑料袋膜"和"纸张"之间有明显互串,两类各有15%左右被错分。这时候不要急着调模型,先回去看错分样本的原图。我遇到的情况大多是这样的:揉成一团的白色塑料袋,在低分辨率下跟一团废纸几乎无法区分。这不是模型的问题,是信息不足。
对应的解法有三条路。一是提高输入分辨率,从224提到320,让纹理细节更清楚,代价是FLOPs涨一倍多,端侧可能扛不住。二是补数据,专门去采这类"揉成团的白色塑料袋"和"揉皱的白纸",把它们放进训练集,让模型见到足够多的难例。三是加辅助信息,比如在采集端加一个测距或者称重模块,用多模态特征辅助判断。工程上最划算的通常是第二条,成本最低见效最快。
如果混淆集中在两个语义上高度接近的类,比如"硬质塑料"和"塑料瓶",那说明类别体系设计有问题,这两个类在视觉上本来就不该分开。这时候该做的是合并类别,而不是死磕模型。承认类别体系设计失误并及时修正,比硬调参数聪明得多。
5. 部署落地:从服务器模型到能跑的设备
5.1 模型压缩:量化是性价比最高的一步
训练出来的FP32模型直接往端侧塞,通常又大又慢。压缩手段有剪枝、量化、蒸馏、换小模型,按投入产出比排序,我的推荐顺序是:先换小模型(如果精度允许),再做训练后量化,还不够就上量化感知训练,最后才考虑剪枝。
量化这块,训练后静态量化(PTQ)在分类任务上一般能把模型体积压到原来的四分之一,推理速度提升两到三倍,精度损失通常在1个百分点以内。但有个前提:必须有代表性的校准数据集。校准集我一般从验证集里抽300到500张,要覆盖所有类别,而且尽量包含难样本,别只挑干净的。校准集选得不好,量化后的精度掉三五个点很常见。
下面是一个典型的导出加量化流程,先转 ONNX,再走目标平台的量化工具:
import torch model.eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "cls.onnx", input_names=["input"], output_names=["logits"], opset_version=12, dynamic_axes={"input": {0: "batch"}}, )导出之后一定要做一次数值对齐验证:拿100张图分别跑 PyTorch 和 ONNX,比较输出的 top-1 是否一致,以及最大绝对误差。我遇到过因为自适应池化算子导出后行为不一致,导致精度掉了两个点的情况,这种问题不验证根本发现不了。量化之后再对齐一次,确认掉点在接受范围内。
如果 PTQ 掉点超过3个点,就上量化感知训练(QAT)。QAT 是在训练时插入伪量化节点,让模型适应量化误差,训个10轮左右,精度基本能恢复到接近FP32。代价是要改训练代码、要能接触到原始训练流程,如果模型是从别处拿来的黑盒,这条路就走不通。
5.2 推理时延估算:别信理论值,自己上板子测
时延估算不能只看FLOPs。理论上 MobileNetV3-Large 是0.22 GFLOPs,一块算力1 TOPS的NPU跑它应该只要零点几毫秒,实际测出来往往在10到20毫秒。差距来自内存带宽、算子调度、数据搬运,以及大量深度可分离卷积这种"算力利用率低"的结构。
我的做法是直接在目标硬件上跑三组数据:纯推理时延(不含前后处理)、端到端时延(含图像解码和预处理)、以及批量推理的吞吐。这三者对系统设计的意义完全不同。
预处理经常被低估。一张1080P的JPEG解码加缩放到224×224,在低端ARM核上能吃掉15到25毫秒,比模型本身还慢。优化手段有几种:让相机直接输出小分辨率(比如640×480)、用硬件解码器、或者干脆把 resize 和归一化融合进模型图里。最后这个做法在部分推理框架里支持,能省不少事。
如果单帧时延压不下去,还有一个思路是改业务逻辑:投放端根本不需要实时,用户松手后延迟200毫秒给结果完全可接受,这样可以把相机帧率降下来,用更低的功耗跑。分拣线才是真的需要抠每一毫秒,那种场景下通常要上专用加速卡。
5.3 服务封装:置信度阈值和拒识机制是必须的
模型上线不是给个类别就完事,一定要有拒识逻辑。我的默认策略是:softmax 最大概率低于阈值(比如0.6)就输出"无法识别,请手动选择",同时把这张图存下来进人工复核队列。这个机制在真实场景里非常重要,因为用户会往摄像头前放各种奇奇怪怪的东西,模型强行分类的结果只会让体验更差。
阈值怎么定?在验证集上扫一遍,画一条"覆盖率-准确率"曲线:阈值从0.3扫到0.95,每个点算一下被接受样本的比例和这部分样本的准确率。业务上一般要求在覆盖率80%以上的前提下,接受部分的准确率不低于95%。按这个约束反推阈值,比拍脑袋定一个0.5靠谱得多。要注意的是,神经网络的softmax输出往往过度自信,分布偏向两端,所以最好先做一次温度缩放校准,把温度参数在验证集上拟合出来,一般能明显改善概率的可靠性。
服务端封装上,我会把预处理、推理、后处理放在一个常驻进程里,模型只加载一次,用队列接收请求。批处理要注意,端侧场景都是单张来的,做批量反而增加延迟,只有在服务端抽检这种可以攒批的场景才开。另外一定要加超时降级:推理超过设定时间就直接返回默认结果,别让一个卡住的请求把整个队列堵死。
6. 踩坑记录与排查速查表
6.1 训练侧:这些现象对应这些原因
训练阶段的问题大多有明确信号,下面这张表是我这些年攒下来的对照关系,基本覆盖了八成以上的情况:
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 训练loss正常降,验证F1不涨 | 过拟合,或训练验证分布不一致 | 加强增强、加权重衰减、检查划分是否按物品ID |
| 训练loss剧烈震荡甚至变NaN | 学习率过大、批大小与学习率不匹配、AMP溢出 | 降学习率、按线性缩放调整、检查梯度裁剪 |
| 某几类召回率始终很低 | 该类样本不足,或与其他类视觉重叠 | 补样本、加强该类增强、考虑合并类别 |
| 验证准确率奇高(98%以上) | 数据泄漏,同一物品跨集合 | 重新按物品ID划分,做感知哈希去重 |
| 训练集准确率也上不去 | 模型容量不足或欠拟合 | 换更大骨干、减少正则、检查标签是否有错 |
| 微调后精度不如直接训分类头 | 学习率过大冲坏了预训练权重 | 降低骨干学习率、延长冻结阶段 |
表里我最想强调的是"验证准确率奇高"这一条。如果你第一次训就拿到98%,先别高兴,八成是数据出了问题。正常的垃圾图像分类在12类、一万张量级的条件下,能到88%到92%就已经不错了,超过95%基本都有猫腻。
还有一条隐藏的坑:标签噪声。人工标注难免出错,尤其是"塑料袋膜"和"纸张"这种边界模糊的类。我一般在训练前用一次简单的交叉验证做标签清洗——用训好的模型对训练集做预测,把预测置信度很高但和标注不一致的样本挑出来人工复核。通常能揪出2%到5%的错标,把这些修掉之后,指标往往能涨一到两个点,比调模型划算多了。
6.2 部署侧:精度掉了先别怀疑量化
模型从服务器搬到设备上精度下降,是我被问得最多的问题。排查要按顺序来,别一上来就怀疑量化。
第一步,确认预处理完全一致。这是最常见的元凶,占了至少一半的案例。服务器上用 OpenCV 读图是 BGR 顺序,训练时用 PIL 是 RGB,忘了转换,颜色通道就反了;或者 resize 的插值算法不同,服务器用双线性、设备上用最近邻;或者归一化的均值方差写错一位小数。这些错误在单个模型上测不出来,必须做端到端的对齐测试:同一张图,两边各跑一遍,比较中间张量的数值差异。
第二步,确认量化校准集是否合适。前面提过,校准集要有代表性。如果校准集全是白天拍的高亮图,遇到暗光场景量化误差会被放大。
第三步,确认设备的算子实现。某些量化推理框架对特定算子(比如带padding的深度卷积)的实现和训练框架有细微差异,逐个算子对比能定位到问题。这个比较费时间,但如果前两步都排除了,就只能走这一步。
6.3 线上反馈:把用户的错判变成下一轮的训练数据
系统上线不是终点。我通常会在服务端加一个数据回流机制:所有被拒识的样本、所有用户手动纠正的样本、以及所有置信度处于0.4到0.6这个"灰色区间"的样本,全部落盘留存,带着时间戳和设备ID。
这些数据积累到一定量(比如每类新增200张)就做一轮增量训练。增量训练有个坑:直接用新数据微调会导致模型对旧数据的遗忘,也就是灾难性遗忘。我的做法是新旧数据按比例混合,新数据占三成左右,同时降低学习率,只训10轮。这样既能吸收新知识,又不会把之前学的丢掉。
还有一个更省事的技巧:把线上错判的图片作为"难例"混进原有的训练集重新训一轮全量模型。虽然训练成本高,但效果最稳。我在一个项目里用这个办法,把"沾油的塑料袋"这一类样本的召回率从62%提到了81%,而整体准确率只花了不到一周的迭代时间。
我个人在实际操作中的体会是,垃圾图像分类这个任务,模型结构和训练技巧能带来的提升是有限的,真正决定系统好不好用的是数据闭环——你能不能持续拿到真实场景的错判样本,并且高效地把它变成训练数据。前期花在采集规范、标注规范、划分策略上的时间,最后都会以指标的形式还回来。至于那些调参的玄学,等你把数据和流程做扎实了,会发现大部分时候已经不需要了。
最后一个实用建议:在项目一开始就把评估脚本写死,包括混淆矩阵、每类召回、置信度阈值扫描这三样,每次训完自动出一份报告。看着麻烦,但它能让你在几十次实验里始终保持同一个判断标准,不至于被某个偶然跑高的数字带偏。