把 ResNet50 在自建数据集上训到 92% 的验证准确率,导出模型丢进投放点上方那颗摄像头里跑,第二天翻日志,真实图片上的准确率只剩六成出头——落差几乎全部来自“训练时看到的图和现场拍到的图不是一回事”。基于卷积神经网络的垃圾图像分类系统,听起来是一个标准的图像分类作业,实际上它把数据工程、类别体系设计、迁移学习策略和端侧部署的坑全凑齐了。这篇内容面向已经能跑通分类 Demo、准备把模型推到真实场景的人,也面向刚接手这类课题、想知道除了调参之外还要处理什么的人。我会把从类别体系怎么定、数据怎么攒、骨干网络怎么挑,到训练故障怎么排查、指标怎么看、模型怎么落地这一整条链路拆开讲,中间穿插一些常规文档里不会写、但真做起来一定会撞上的问题。
1. 这个任务的难点从来不在网络结构
1.1 输出到底给谁用,决定了后面所有设计
动手写第一行代码之前,有一件事必须和需求方对齐:模型的分类结果是给人看还是给机器用。这两个方向的差别大到会影响数据采集量和模型容量。如果结果是给投放的人看,屏幕上弹一句“请投放到其他垃圾”,那么四分类(可回收物、厨余垃圾、有害垃圾、其他垃圾)基本够用,模型只需要在粗粒度上判断对,偶尔把纸巾认成塑料袋,用户体验的损失有限。如果结果要驱动分选线、机械臂或者称重计费,粒度就得往细里走,纸、塑料、玻璃、金属、织物往往要各自成类,塑料还要再分 PET、HDPE、PP,因为不同材质后续的处理工艺完全不同。
我在实际项目里见过一种很常见的前期失误:直接拿开源的四分类数据集训一个模型,然后发现下游需要区分“干净的可回收塑料瓶”和“被油污污染的一次性餐盒”,这两个东西在四分类标签体系里根本没有对应的格子,模型再怎么调也表达不出来。所以类别体系的定义要写进项目文档,明确每个类别的判定条件、边界案例、以及和当地投放标准的一致性,这份文档的详细程度直接决定了后面标注质量的上限。
1.2 塑料袋、纸巾、纸杯:真实世界的边界比标签模糊得多
图像分类的教科书例子是猫和狗,类别之间界限清晰,视觉特征差异明显。垃圾图像完全不是这样。一次性纸杯多数地方算其他垃圾,因为内壁有淋膜;但一个干净的、没有被污染的纸杯,在不同地区的标准里可能被归到可回收。保鲜膜和塑料袋的视觉差异极小,都可能是透明的、带褶皱的薄膜。更要命的是“污染程度”这个维度:一个干净的塑料瓶和一个沾满油污的塑料瓶,在很多标准里归类不同,但两张图的像素差异可能只有瓶身一小片油渍。
这带来一个非常具体的技术后果——标签噪声不是随机的,而是系统性的。同一个人在不同时间看到同一张图可能给出不同标签,不同标注员对“污染到什么程度算污染”的理解也不一样。随机噪声还能靠模型容量和正则化扛一扛,系统性噪声会直接把模型往错误的方向拉,因为模型会去学“油污的纹理”而不是“瓶子这个物体”。解决这件事只能靠标注规范前置,而不是靠后面换个损失函数。
1.3 训练分布和部署分布之间的那条鸿沟
开源垃圾数据集里大量图片是白底、正面、居中、光照均匀的单物体图,构图接近商品拍摄。投放点摄像头拍到的是什么?俯拍、逆光、阴影、物体互相遮挡、套着袋子、堆叠在一起、广角镜头边缘畸变。同一个塑料瓶,在数据集里是白底正面照,在摄像头里是一个被压扁的、带反光的、只露出三分之一的深色形状。这种分布漂移是实验室指标和现场指标差距的主因。
还有一个容易被忽略的因素是摄像头本身。消费级摄像头的自动白平衡和自动曝光会在不同时段对同一个物体给出差异很大的颜色,早上八点和下午六点的同一袋垃圾,色温可能完全不同。如果训练时用的增强手段只有随机裁剪和翻转,模型对色温变化的鲁棒性基本为零。这一点在后面的数据增强小节会再展开。
2. 数据这一关过不去,后面全是无用功
2.1 采集渠道的拼盘与必须做的清洗动作
数据来源通常是三块拼起来:开源数据集、网络图片、自己拍。开源数据集的好处是量大、标注现成,问题有三个——类别体系和你的对不齐、国外垃圾包装和国内差异明显、白底单物体图占比过高导致分布严重偏斜。网络图片的问题是真假混杂,很多是宣传图、插画、带水印的电商图。自己拍的质量最好但成本最高,需要覆盖不同时段、不同天气、不同角度。
拼起来之后必须做一轮机械清洗,这一步不要省。我常用的动作是:用感知哈希(pHash)做近重复检测,把同一张图的不同尺寸版本剔掉,否则同一物体在训练集和验证集各出现一次就是数据泄漏;用拉普拉斯方差筛掉模糊图,方差低于阈值的直接丢弃;再按文件大小和分辨率过滤掉缩略图级别的图片。这三步做完,通常会去掉 5% 到 15% 的数据,但换来的是训练过程的可信度。
import cv2 import numpy as np from PIL import Image import imagehash def is_blurry(path, threshold=100): img = cv2.imread(path, cv2.IMREAD_GRAYSCALE) if img is None: return True return cv2.Laplacian(img, cv2.CV_64F).var() < threshold def dedup_by_phash(paths, max_dist=5): seen = [] keep = [] for p in paths: h = imagehash.phash(Image.open(p)) if all(h - s > max_dist for s in seen): seen.append(h) keep.append(p) return keep2.2 标注一致性比标注数量重要得多
一个常见的误区是拿标注量当 KPI,标了五千张就很满意,但一测一致性发现两个标注员之间只有 0.6 的 Kappa 系数,意味着这批标签本身就在互相打架。我的做法是让两个人独立标同一批 300 张的样本,算 Cohen's Kappa,低于 0.75 就说明标准文档写得不够细,要回去补边界案例,而不是催标注员加快速度。
标准文档要写到什么程度?举个例子,“塑料瓶”这个类别,需要明确:瓶盖算不算、瓶身标签要不要看、压扁的算不算、带残留液体的算不算、瓶身被撕掉一半的算不算。每一条都要配一张示例图。这份文档写两三天,能省掉后面两三周的返工,性价比极高。
2.3 长尾分布是常态,处理手法要分场景
真实采集的数据几乎不可能均衡。纸张、饮料瓶这类高频物品很容易攒到几千张,电池、灯管、过期药品这类有害垃圾可能只有一两百张。
| 类别 | 采集到样本量 | 占比 | 典型问题 |
|---|---|---|---|
| 塑料瓶 | 3200 | 38% | 背景单一,白底图过多 |
| 纸类 | 2100 | 25% | 与纸杯、纸巾混标 |
| 玻璃 | 900 | 11% | 透明物体,边缘难辨 |
| 金属罐 | 780 | 9% | 与塑料瓶外形相似 |
| 织物 | 620 | 7% | 与塑料袋纹理接近 |
| 有害垃圾 | 380 | 5% | 样本极少,形态差异大 |
| 其他 | 420 | 5% | 类内差异极大 |
处理长尾有三条路,各自的代价不同。重采样最直接,对少数类做随机过采样或者对多数类做欠采样,问题是过采样容易过拟合,欠采样会丢信息。类别加权损失函数更温和,把交叉熵里的权重按类别频次反比设置,效果稳定但收益有限。针对性补采是最根本的办法,专门去收集有害垃圾的样本,哪怕用摆拍的方式补充一部分,也比在算法上折腾半天强。合成数据要谨慎,把电池贴到不同背景上生成样本,很容易让模型学到“边缘不自然”这个伪特征,反而损害泛化。
3. 骨干网络选型和训练策略的真实取舍
3.1 从 ResNet 到轻量骨干,选的是约束不是精度
很多人一上手就选最大的模型,觉得精度高总没错。垃圾图像分类的实际约束通常来自部署端:投放点上的设备可能是一块低功耗边缘板子,算力和内存都有限,模型再准,跑不动就是零。所以骨干网络的选择应该从部署条件反推。
| 骨干网络 | 参数量(约) | ImageNet Top-1 | 单张推理耗时(相对值) | 适用场景 |
|---|---|---|---|---|
| MobileNetV3-Large | 5.4M | 75.2% | 1.0 | 边缘设备、实时视频流 |
| EfficientNet-B0 | 5.3M | 77.1% | 1.2 | 边缘设备、精度稍优先 |
| ResNet50 | 25.6M | 76.1% | 3.5 | 服务器端、批量离线分析 |
| ConvNeXt-T | 28.6M | 82.1% | 4.0 | 服务器端、追求精度 |
| ViT-B/16 | 86M | 81.8% | 6.0+ | 数据量大时的备选 |
在垃圾这种类内差异大、纹理重要的任务上,卷积结构的归纳偏置(局部性、平移等变性)其实是优势,纯 Transformer 通常需要更多数据才能追平。我的默认选择是:边缘端 MobileNetV3-Large 或 EfficientNet-B0,服务器端 EfficientNet-B3 或 ResNet50,先跑通再看要不要往上加。别一上来就上大模型,先确认数据质量配不配得上模型的容量。
3.2 冻结与解冻的时机,比学习率数值更关键
迁移学习里最常见的操作失误是直接全网络解冻、用统一的学习率训练。随机初始化的分类头在最初几个 batch 里梯度非常大,这个梯度会顺着反向传播灌进预训练的骨干,把原本有用的特征图搅乱,等于是把好不容易学来的东西砸了。
稳妥的做法是分两段。第一段冻结骨干,只训分类头,跑 3 到 5 个 epoch,让分类头先收敛到一个合理的初始状态。第二段全网络解冻,但给骨干一个很小的学习率,给分类头一个大一些的学习率,用参数组的方式实现差分学习率。
import torch import torch.nn as nn from torchvision import models model = models.mobilenet_v3_large(weights="IMAGENET1K_V1") in_features = model.classifier[3].in_features model.classifier[3] = nn.Linear(in_features, num_classes) # 第一阶段:冻结骨干,只训分类头 for p in model.features.parameters(): p.requires_grad = False optimizer = torch.optim.AdamW(model.classifier.parameters(), lr=1e-3) # 第二阶段:解冻,差分学习率 for p in model.features.parameters(): p.requires_grad = True optimizer = torch.optim.AdamW([ {"params": model.features.parameters(), "lr": 1e-5}, {"params": model.classifier.parameters(), "lr": 1e-4}, ], weight_decay=1e-4)差分学习率这个细节,很多教程会一笔带过,但在小数据集上它的影响非常明显。骨干的学习率设置成分类头的十分之一是个比较稳的起点,数据量越小,这个比例越应该拉大。
3.3 数据增强不是越多越好,垃圾任务尤其要注意边界
增强手段的选择要和任务特性绑定,不能照抄 ImageNet 的配方。逐条说几个关键点。
随机裁剪缩放(RandomResizedCrop)是标配,但裁剪比例要收着点。垃圾里存在电池、药片板、灯管这类小目标,裁剪比例下限设到 0.5 以下的时候,小目标经常被裁得只剩一个角,标签还在但信息没了,等于给模型喂噪声。我的经验是 scale 范围设成 (0.6, 1.0) 比较安全。
水平翻转安全,几乎可以无脑开。垂直翻转要看拍摄角度:如果数据全是俯拍,垂直翻转后图像在物理上仍然合理,可以开;如果数据里有大量正视角度的图,翻转后会出现“瓶子倒立”这种不自然的形态,建议关掉或者用低概率。
颜色抖动(ColorJitter)在垃圾任务里要格外小心。颜色本身携带信息——有害垃圾的容器颜色、水果蔬菜的颜色、塑料瓶的材质色差,都可能被过度的色调偏移破坏。亮度、对比度可以适度调整(对付现场光照变化),饱和度控制在 0.3 以内,色相(hue)尽量不要动,或者限制在 0.05 左右。
MixUp 和 CutMix 这类混合增强对分类任务有帮助,能提升校准度和鲁棒性,但用在垃圾数据上要评估一个副作用:混合之后生成的图可能呈现出两件垃圾叠在一起的样子,这在真实场景里确实存在,所以通常是加分的。但如果类别体系里有“污染程度”这类细粒度标签,混合会破坏这个语义,就要谨慎。
3.4 损失函数和类别不平衡的配合方式
最基础的交叉熵配合 0.1 的标签平滑,是我在绝大多数情况下的起点。标签平滑的作用是防止模型对训练标签过度自信,缓解标注噪声带来的过拟合,垃圾数据标签本身有系统性噪声,这个改动收益很直接。
当类别不平衡超过 10:1 时,加权交叉熵或者 Focal Loss 值得试。Focal Loss 的 gamma 参数控制对易分样本的抑制程度,gamma 越大越关注难样本。但要注意一个反直觉的现象:Focal Loss 在训练早期会让损失下降变得很慢,因为大量易分样本的贡献被压掉了,如果你盯着 loss 曲线看,很容易误判成训练出了问题然后去调别的超参。我的做法是先关掉 Focal 用加权 CE 跑通,确认基线指标之后再开 Focal 做对比实验。
4. 训练曲线不对劲时的排查顺序
4.1 训练损失下降、验证损失回升:先怀疑数据再怀疑正则化
看到这条曲线,大部分人的第一反应是加 Dropout、加权重衰减。但在这个任务上,更常见的原因是训练集和验证集的分布不一致。比如训练集里白底图占八成,验证集是从现场图里抽的,那验证损失上升根本不是过拟合,是模型没学到现场那部分特征。
判断方法很简单:从训练集里单独切出一小份“现场风格”的子集,看它的损失曲线是跟着训练集走还是跟着验证集走。如果它跟着验证集走,问题在分布,不在正则化。真正处理分布不一致要靠补数据、靠针对性增强(比如模拟俯拍、模拟阴影、模拟逆光),而不是靠调 dropout 比率。
4.2 验证准确率突然高得离谱,八成是泄漏
如果验证准确率在训练几个 epoch 后直接冲到 98% 以上,先别高兴,先查泄漏。最常见的三种泄漏:同一张图的不同增强版本分别落在训练集和验证集;同一物体的连拍照片被随机划分到了两边;用整图统计量(比如全局均值方差)做归一化时把验证集算进去了。
第二种泄漏在自采数据里特别容易出现,因为一个人拿着手机对着一个垃圾桶连拍三十张,这三十张图高度相似,随机按 8:2 划分,一定有相似的图跨在两边。正确的划分方式是按“采集批次”或者“物体实例”分组划分,也就是 GroupSplit,保证同一个物体只在一边出现。这个方法实现起来就是给每张图打一个 group id,然后用 sklearn 的 GroupShuffleSplit。
4.3 指标卡在某个值不动,可能是学习率或增强的锅
训练跑了二十个 epoch,准确率在 78% 附近来回震荡,怎么也不涨。按顺序排查:先看学习率是不是太大导致在最优点附近跳,用余弦退火或者 ReduceLROnPlateau 试一下;再看增强是不是过强,尤其是裁剪和颜色抖动,临时关掉增强跑一轮对比;最后看模型容量是不是真的不够,换一个大一档的骨干试试。
还有一个隐蔽的原因:数据加载的 shuffle 没开,或者用了固定的 sampler,导致每个 epoch 的训练顺序一样,梯度方向的周期性震荡会让指标卡住。这个错误在自定义 Dataset 的时候很容易犯,检查一下 DataLoader 的 shuffle 参数。
4.4 小 batch 下的 BatchNorm 陷阱
边缘部署场景往往要求模型小,训练时为了省显存也会把 batch size 压到 8 甚至 4。这时候 BatchNorm 层统计的均值和方差会非常不稳定,因为每个 batch 的样本太少,统计量噪声大,训练和推理时的行为差异明显。
处理方式有两种。一是换 GroupNorm 或者 LayerNorm,代价是需要重新预训练或者至少微调较长时间,因为预训练权重是按 BN 训的。二是保持 BN 但在训练末尾用较大的 batch 跑几百个 iteration 重新校准 BN 的 running statistics,具体做法是把模型切到 train 模式但冻结所有参数,只让 BN 层更新统计量。第二种方式成本低得多,我在小 batch 场景下基本都用这个。
5. 评估这件事,准确率是最没用的那个数
5.1 混淆矩阵要按类别看,别只看总体
总体准确率在类别均衡时还算合理,在长尾分布下会严重失真。占比 38% 的塑料瓶全分对,占比 5% 的有害垃圾全分错,总体准确率依然能到 90% 以上,但这个模型在有害垃圾上是完全不可用的。
必须看的是每类召回率和宏平均 F1。有害垃圾这一类召回率低于 0.75 的时候,我会直接判定这个模型不能上线,因为漏检有害垃圾的后果比把塑料瓶错认成其他垃圾严重得多。混淆矩阵要打印出来逐格看,重点看“高危混淆对”:玻璃和塑料(都是透明容器)、纸杯和纸巾、电池和其他。这些格子对应的样本要单独拿出来分析,看看是标注问题还是特征确实可分。
| 评估指标 | 适用场景 | 我的常用阈值 |
|---|---|---|
| 总体准确率 | 类别均衡时的粗略参考 | 不作为验收依据 |
| 宏平均 F1 | 类别不均衡的主指标 | ≥ 0.85 |
| 有害垃圾召回率 | 安全相关的硬指标 | ≥ 0.85 |
| 混淆矩阵最大误判对 | 定位具体问题 | 单独分析 |
| Top-3 准确率 | 人机协同场景 | ≥ 0.95 |
5.2 加一个拒识通道,比强行分类更实用
真实场景里存在大量模型没见过的物体,强行让模型在已知类别里选一个,结果就是高置信度的错误。实际部署时留一个拒识机制很有价值:当最大 softmax 概率低于某个阈值时,输出“无法判断,请人工确认”。
这个阈值怎么定,不能拍脑袋。做法是在验证集上画置信度分布,看正确分类和错误分类的置信度分布重叠区域,把阈值设在能接受误拒率的位置。我一般会控制在拒识率 8% 到 12% 之间,这个区间里通常能挡掉三成以上的高置信度错误。
5.3 线上验收要靠抽样,而不是靠离线指标
模型上线之后,每周从真实日志里随机抽 200 到 300 张图,人工标注一遍,算线上指标。这个数字通常比离线低 5 到 15 个百分点,差距就是分布漂移的量化结果。抽样的时候要注意分层,按时段、按投放点、按摄像头型号各抽一部分,不然抽到的全是光照最好的中午时段,指标会虚高。
6. 从训练脚本到能跑的服务,中间还有一段路
6.1 导出和加速的实际选择
训练完的 PyTorch 模型直接拿去推理,速度通常够用但不划算。边缘部署基本都要走 ONNX 导出,再配合推理引擎做图优化。导出时要注意两点:一是固定输入尺寸,动态轴虽然支持但会损失一部分优化空间;二是确认算子兼容,自定义的激活函数或者特殊的池化方式可能导致导出失败。
量化是另一个提速手段。训练后静态量化(PTQ)用一批校准数据跑一遍,通常能把模型体积压到原来的四分之一,推理速度提升 2 到 3 倍,精度损失在 1 个百分点以内。校准数据的选取很关键,必须用真实场景的图,不能拿训练集的子集凑数,否则量化参数会偏。
import torch model.eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "clf.onnx", input_names=["input"], output_names=["logits"], opset_version=13, dynamic_axes=None, # 固定尺寸,换取更好的图优化 )6.2 服务封装的几个细节
封装成 HTTP 服务的时候,几个细节值得注意。输入图片要统一做尺寸和归一化,但归一化的均值方差必须和训练时完全一致,这个参数建议写进配置文件跟着模型一起版本管理,我曾经因为均值方差写错导致线上指标莫名下降,排查了大半天。批量推理能显著提升吞吐,但要注意把小请求攒批的超时设短一点,投放点场景对延迟敏感。
日志要记录原始图片、预测类别、置信度、模型版本,这是后面回流训练的数据来源。存储成本允许的话,建议按天分目录存原图,方便按时间段抽样。
6.3 数据回流才是模型持续可用的关键
任何一个上线时的模型都会随时间衰减,因为垃圾的品类在变——新的包装材料、新的产品形态、季节性的物品。所以上线只是开始,真正决定模型长期表现的是回流机制。
我的做法是每周从日志里按“低置信度”“拒识”“人工纠正”三个条件各抽一批图,标注之后混入训练集做增量训练。增量训练不需要从头训,加载上一版权重,用较小的学习率训 3 到 5 个 epoch 就够,然后在固定的回归测试集上验证没有退化再发布。回归测试集要单独维护,不能混进训练数据,样本量 500 张左右就够,覆盖所有类别和主要场景。
最后分享一个小技巧:如果投放点有多个,不同点位的光照和背景差异很大的话,与其训一个通用模型硬扛,不如加一个极轻量的场景判别分支,先判断图片来自哪类点位,再走对应的分类头。这个改动的工程量不大,效果往往比单纯加大模型容量明显。