要说清楚深度学习做图像多标签分类,说白了就一句话:一张图里同时有好几个目标,模型要把它们全部认出来,而不是只给一个结果。今年我完整做了一个这方面的项目,从数据清洗一路跑到模型部署,踩了不少坑,也攒了一些能直接抄作业的经验,这里系统性整理出来,希望对正在做或者准备做类似项目的朋友有点帮助。
这个项目我选择了一个非常典型的业务场景:商品图片的多标签分类。数据集大约有10万张图片,80个标签,一张图平均要打上3到5个标签,标签之间不互斥,有大量组合关系。适合拿来讲解,因为它的数据形态、类别分布、工程问题都非常有代表性。下文我会直接以这个项目为例子,讲设计思路、数据准备、模型结构、损失函数、训练策略、评估方法,以及那些坑。
1. 项目背景与整体设计思路
1.1 先搞清楚多标签分类和多分类的区别
很多刚接触深度学习的人会把多分类和多标签混在一起,但它们完全是两回事。多分类是“这张图是猫、是狗还是猪”,答案三选一,最终输出层用Softmax,所有类别概率加起来等于1。多标签是“这张图里同时有猫也有狗”,模型要为每个标签独立判断存在或不存在,输出层每个神经元接一个Sigmoid,各自输出0到1的概率。
这个区别听起来只是一层激活函数的事,实际上它会连锁影响损失函数、评估指标、甚至后处理逻辑。比如多分类任务你只需要考虑概率最大的那个类别,多标签任务却要决定“哪些标签应该被激活”,阈值是0.5合适,还是某个类别单独设0.3更合适,这些都是新问题。
我做项目时跟产品方反复确认了两件事:漏检和误报哪个代价更高;标签粒度应该是什么水平。这两个问题直接影响技术选型。如果产品方非常在意漏检,那就倾向于调低阈值、增加召回;如果误报会引发严重的售后问题,那就要拉高阈值、提高精确率。很多人一上来就想直接跑模型,忽略了需求层的确认,后面容易返工。
1.2 项目数据概况与目标
这个商品多标签项目的数据长这样:图片是业务方在不同渠道积累的商品图,包含主体商品、背景、搭配物等元素。标签体系是商品运营已经定义好的80个标签,比如“黑色”“白色”“夏季”“运动”“居家”“复古”等。标签之间有交叉,比如一张图可以是“黑色”+“运动”+“夏季”,这三个标签同时为1。
我拿到数据第一件事不是写训练代码,而是做统计分析。统计完发现长尾分布非常典型:头部的十几个标签占了大量样本,比如“黑色”“白色”这两类图片数量都超过两万;而尾部有一批标签只有几百张甚至几十张图片,比如“波西米亚”“工装风”。这种分布不均几乎是所有真实多标签项目的共性,不是个别现象。
项目技术目标有两个硬指标:测试集上MAP值要超过0.8,单张图片的GPU推理时间控制在50毫秒以内。这两个指标会同时约束模型容量和工程实现方式,不是说你无脑上大模型就行。
1.3 整体技术方案与模块划分
我最终选的技术栈是PyTorch,骨干网络用预训练权重做迁移学习,输出层按标签数量改成多头Sigmoid,损失函数用加权BCE,训练时先冻结骨干再解冻微调,评估用MAP和各类别F1综合判断。
整个项目按模块拆成四条线:
- 数据处理:格式清洗、标签统计分析、过采样和权重计算
- 模型设计:骨干网络替换、输出层改造、偏置初始化
- 训练验证:两阶段训练、学习率调度、关键指标曲线监控
- 导出部署:模型转换、简化输入输出、速度测试
这样拆的好处是责任清楚,某一块出问题时不会牵一发动全身。真实项目中,我发现很多人喜欢把所有代码堆在一个文件里,前期很爽,出了问题特别痛苦,尤其是当你想对比多个实验配置的时候,还是模块化思路靠谱。
2. 数据集准备与预处理
2.1 数据清洗和标注格式统一
数据准备阶段最花时间的不是采集图片,而是清洗。原始数据里存在明显的脏数据问题:有大量完全相同的图片重复出现,有标注错误(比如一张纯色背景图被标上了“居家”标签),还有一小部分图片根本打不开。如果带病训练,模型大概率会被这些噪音带偏,尤其是少数类标签,一两条错误标注就会产生明显影响。
清洗时我做了一个小型的规则管线:先按文件MD5去重,再自动检测灰图、模糊图、异常尺寸图片,最后做人工抽检。这个流程看起来琐碎,但滑动一个阈值都能影响最终精度。去重尤其重要,因为素材采集渠道经常会把同一张图放在不同目录里,如果不去重,训练集和验证集可能混入同一张图,导致评估结果虚高,这个后面单独讲。
标注格式我用的是JSON,每行一条样本,包含图片路径和一个标签ID数组。这种格式比一个图片一个文件夹的方式灵活得多,因为多标签本身就不适合用文件夹组织。实际代码里我用了dataclass管理样本数据,加载起来非常清爽。
2.2 标签统计分析与类别不平衡处理
清洗完之后必须做标签分布统计,这是整个项目最值得投入时间的一步。我画了每个标签的样本数直方图,80个标签里头部有个尾巴,分布一目了然。基于这个分布,我做了三件事:
第一,对少数类做过采样。实现上是构造一个采样权重列表,每个样本的权重等于它包含的标签中“最稀有”的那个标签的稀有程度,权重越大的样本被抽中的概率越高。这样少数类图片会更多地进入每个batch。
第二,对头部类别进行损失加权,避免大众类别主导梯度方向。具体做法是在BCE损失里为每个标签设置正样本权重,权重的依据是负样本数除以正样本数的平方根,这样既能提升少数类,又不至于让训练完全偏向稀有标签。
第三,检查标签组合的分布。多标签项目比单标签项目多一个值得关注的地方:高频共现。比如“白色”和“简约”经常一起出现,模型可能被带偏,学到“看到白色就无脑预测简约”。我发现有几组标签几乎完全绑定,进一步检查后发现是因为某批商品本身就有这些属性,是真实的相关性,但也有一些组合是人为主观标注造成的偏差,对这些异常样本做了剔除。
2.3 图像预处理和数据增强策略
图像输入尺寸统一为224乘224,和预训练模型的输入对齐。颜色通道用ImageNet的均值和方差做标准化,这一步是必须的,不标准化直接训练预训练模型,早期的loss会异常高。
训练时我加了随机裁剪、水平翻转、颜色抖动等基本增强。这里有一条多标签项目独有的原则:增强操作不能改变语义标签。以水平翻转为例,一张商品图和它的镜像语义完全不变,可以放心用;但如果你要做“方向盘在左边还是在右边”这种标签,水平翻转就会让标签变成错误的,这时候就必须关掉翻转,或者翻转后同步改标签。颜色抖动同样要小心,如果标签里包含“黑色”“红色”这种颜色属性,粗暴地随机改颜色可能让同一个语义的图片被标成不同标签,干扰训练。
我实际用过的一个更高级的方法是CutOut,随机遮掉图片一块区域。它对于多标签任务通常比较友好,因为即使遮掉一部分,其他标签仍然正确。但使用时要注意遮挡比例不能太大,我一般控制在总面积15%以下,太大反而让模型丢失关键细节。
3. 模型构建与训练细节
3.1 骨干网络怎么选
骨干网络决定了特征提取能力的上限,一般不用自己设计,直接选择成熟的分类骨干结构。我项目初期用了ResNet50作为主力,理由很直接:它有一整套可靠的预训练权重,特征表达能力足够,训练稳定性也好。ResNet50在换到多标签场景后,输出特征能直接迁移,收敛速度非常快。
后来我也试过EfficientNet系列和轻量的MobileNetV3。EfficientNet精度略高一点,但训练时对显存和调参的要求更高,收益有限。MobileNetV3的精度掉得不明显,但推理速度几乎快了一倍,如果你有明确的时延要求,这个方向性价比很高。
如果你是首次做多标签项目,我建议直接起步用ResNet50,不要纠结。先把整个流程跑通,后面再为了性能瓶颈去换更复杂的骨干,这样调试成本最低。
预训练权重是必须用的,随机初始化在小数据集上很难收敛,预训练权重从一开始就提供了一个优秀的特征提取器。多标签分类不是完全从零学习,而是让一个已经很强的视觉模型适配新的输出语法。这个认知能让你的训练策略走少很多弯路。
3.2 输出层设计和偏置初始化
输出层的设计其实非常简单:骨干网络后面接一个全连接层,输出维度等于标签数量。关键细节是激活函数的选择,多标签任务不用Softmax,每个输出神经元用Sigmoid单独判断“这个标签有没有”。
代码实现里,我建议把Sigmoid放在损失函数里面,而不是自已在forward里写死。PyTorch里对应的是BCEWithLogitsLoss,它内部组合了Sigmoid和二元交叉熵,数值上比分别算更稳定。如果你的代码里写成“先用Sigmoid,再算BCELoss”,也可以运行,但在极端logit值下会出现NaN风险,这点值得注意。
还有一个小技巧,就是在第一个epoch前给模型的bias做特殊初始化。对于低频标签,如果初始输出概率是0.5,网络早期会接收到非常大的梯度信号,训练很不稳。更优的初始化方法是根据正样本比例设置bias,让初始预测概率接近该类别的先验概率。这个技巧在论文中经常看到,但实际用的团队很少,却能实打实减少前期震荡。
# 多标签输出头初始化示例 num_classes = 80 pos_ratio = [...] # 每个标签的正样本占比 for idx, ratio in enumerate(pos_ratio): # 让 Sigmoid(logits) 的初始值接近 ratio bias = -math.log((1.0 - ratio) / ratio) model.classifier.bias.data[idx] = bias3.3 损失函数选择:从BCE到Focal Loss
多标签分类的默认损失函数是二元交叉熵BCE,每个标签独立计算交叉熵再求和平均。它简单有效,适合绝大多数场景。在类别不均衡明显时,我会在BCE里为每个类别加正样本权重,权重值通过统计得到,避免多数类别主导损失。
如果类别不均衡特别严重,我会改用Focal Loss。Focal Loss的核心思想是让模型少关注那些已经能正确分类的简单样本,把注意力放到难分样本上。它对长尾分布的少数类提升很明显,但需要调两个超参:gamma和alpha,没有BCE那种开箱即用的省心。
我在这两个项目里测试下来,结论是:如果你的标签分布比较正常,BCE加权就够了;如果少数类标签非常少且难学,Focal Loss值得尝试,不过别指望它解决所有问题,还要配合过采样一起用。
3.4 两阶段训练策略与超参数
训练流程我分成两个阶段:
阶段一:冻结骨干网络,只训练最后的全连接输出层。这一步大约跑5个epoch,让新初始化的输出头在特征不变的前提下先稳定下来。如果一上来就放开骨干,特征尺度和输出头不匹配,模型容易乱跳。
阶段二:解冻骨干网络,整套模型一起微调。学习率要比阶段一更低,我用的是骨干部分1e-5、输出头部分1e-4的分层学习率。这个阶段跑15个epoch左右,观察验证集趋势,哪一轮开始掉点就提前停。
优化器我用了SGD配合动量0.9,配合学习率warmup和cosine衰减。AdamW收敛更快,但最终精度上SGD配合合理的衰减通常更稳。如果你时间紧张,直接用AdamW然后早点停也是可行的。batch size用32,训练大约7到8个小时能到目标指标。
4. 评估指标与调优过程
4.1 为什么Accuracy不适用于多标签
很多从单标签迁移过来的同学还是会习惯性去看Accuracy,这是最大的认知误区。多标签场景下一张图可能同时有3个标签,如果一个模型预测对了2个错判了1个,Accuracy会把它归到“错误”那一类,完全没有反映出模型的实际能力。
我用两组指标进行综合评估:
第一组是MAP,也就是所有类别平均精确度的均值。它衡量的是模型把正样本排在前面的能力,对阈值不敏感,适合评估排序质量。
第二组是每个类别的F1分数、精确率和召回率。F1能把主观差别的类别暴露出来,哪个类别强哪个弱一目了然。
最终汇报给项目方我会同时给MAP和一条类别的F1曲线,不能只说一个数。有一个数好看就完事的心理,到了真实业务必栽跟头。
4.2 阈值搜索:0.5不是唯一解
Sigmoid输出的是0到1的概率,模型预测“某个标签存在”要基于一个阈值。默认0.5很常用,但几乎不是最优解。我在验证集上对每个类别单独搜索阈值,思路非常简单:遍历0.1到0.9,在每个候选值下计算该类的F1,取F1最大的作为该类的阈值。
实际操作下来,高频类别在低阈值下效果更好,因为牺牲一点精确率换来大量召回是划算的;一些稀疏且难以判定的类别,比如“简约”“高级感”,则要拉高阈值,避免一堆误报。最终每个类别有一套自己的阈值,推理时逐类别应用,而不是全局统一。
这里有一个重要提醒:阈值搜索会过拟合验证集。如果你反复在同一个验证集上调阈值,验证集就不再是“验证”而变成了“训练”。我的习惯是把数据切三份:训练集、验证集、测试集。训练和阈值搜索在训练集和验证集上完成,测试集只在最终评估时碰一次,绝不在调参阶段反复使用。
4.3 实验对比表与最终选型
为了把多个实验横向对比,我建了一个简单的记录表,包含模型结构、数据增强方式、损失函数配置、阈值策略、MAP、F1均值、推理时间几个字段。每次实验都往表里填一行,决策就能基于数据而不是记忆。
最终我选定的方案是:ResNet50 + 过采样训练 + 加权BCE + 逐类别阈值搜索。在测试集上MAP为0.83,平均F1为0.76,单图推理时间38毫秒,满足全部业务目标。
如果你把同样的数据和模型训练多次,就会发现每次结果有小幅度波动,这是正常的。我习惯每个配置至少跑两遍取平均,如果两个结果差的特别大,说明训练不稳定,优先去查数据预处理和随机种子,而不是调更多参数。
5. 常见问题与踩坑记录
5.1 训练损失下降但MAP不动
我一度看到训练损失稳步下降,验证损失也在降,但MAP始终在0.7徘徊。排查之后发现两个原因:
一是输出概率分布过于集中在中间区域,模型给出的预测都模棱两可,排序能力弱。解决办法是加强难样本的梯度权重,或者使用Focal Loss,让模型把概率推到接近0或接近1。
二是评估时没有做逐类别阈值搜索,MAP虽然是排序指标,但如果你在输出层之后又叠加了不合理的判断逻辑,一样会把好的排序给搞坏。先把评估代码推理一遍,看输出统计分布,再决定怎么调。
每个成熟项目都要有这种情况:训练曲线正常,效果不理想。别急着堆数据改模型,先把评估链路逐个环节过一遍。
5.2 少数类标签几乎预测不出来
尾部标签样本少,模型倾向于把它们预测为0,因为预测为0的损失期望最低。哪怕加了损失权重,提升也很有限。
我用了两种方案,实测有效:
第一种是过采样。构造数据采样器时,根据样本包含的最稀有标签设置采样权重,让少数类样本在训练时多次出现。这个做法简单直接,但对某些样本会复制学习,容易过拟合,所以过采样配合强数据增强一起用更安全。
第二种是对高频共现的标签做规则补全。有一些组合在业务上几乎是确定的,比如“运动”和“运动鞋”同时出现,如果模型对“运动”的打分足够高,就对“运动鞋”做后处理加分。这个方案严格讲不是模型能力,但在业务落地里非常实用,能救回几个模型学不好的长尾标签。
5.3 验证集评估结果虚高
虚高的典型来源有三个:数据泄露、不当的随机切分、反复用测试集调参。
数据泄露最常见的情况是同一商品的图片同时出现在训练集和测试集。图片虽然图像上看起来不同,但内容高度相关,模型记住了商品规律,而不是学习了视觉语义。避免方法是按商品ID或图片主键切分数据,保证同一ID下的所有图片只进一个集合。
随机切分的问题更加隐蔽。如果直接对图片路径做随机划分,同一张重复图片也可能一头穿训练集一头穿测试集。所以先做MD5去重,再按主键划分,顺序不能反。
反复用测试集调参这个问题更多是自控。很多团队拿测试集当验证集用,因为指标好看,汇报也开心,但一上真实场景就现原形。我的做法是测试集锁死在一次最终评估上,并且明确写进项目约定,谁都不允许私自去跑测试集。
5.4 部署阶段的额外注意
多标签模型部署时有一个容易被忽略的点:输入尺寸。训练时如果用224乘224,部署时最好不要换成更大的尺寸。虽然理论上模型对任意尺寸都能推理,但如果预处理流程不一致,输出的概率分布会出现偏移,导致线上效果打折。
另一个点是batch推理和单条推理的差异。很多框架在batch模式下表现很好,单条推理时因为并行度不足时间反而增加。如果业务主要走单条请求,就应该在部署时直接用单条路径做性能压测,避免上线前才发现延迟超标。
我这次项目使用ONNX导出的模型,推理速度比PyTorch原生更快,依赖也更少。不过ONNX导出也有一些坑,比如某些动态维度处理不干净,导出后要做输出对齐测试,确认结果和PyTorch原模型一致再切换。
6. 一些实际操作的个人体会
最后分享几点我自己的感受,不一定写在哪篇文档里,但实操中确实管用。
多标签分类项目的难度大头不在模型结构,大家用的骨干网络都差不多,差别在哪呢?在数据清洗是否到位、标签统计有没有做透、损失权重和阈值有没有根据分布去调。这几年做下来我的体会是,一个用ResNet50加BCE为代表的扎实方案,比花里胡哨的模型更容易稳定。
每换一个数据集,一定重新做统计分析,不要沿用上一个项目的过采样比例或者损失权重,标签分布几乎不可能一样。这个习惯看起来费时间,但节省的总调试时间远超投入。
另外,训练曲线尽可能记录下来,包括每个epoch的loss、MAP、每个类别的F1。有时候项目跑完三四个星期,再回头看曲线,很多当时的直觉判断都变得清晰,有这个记录在,问题定位会快很多。
如果你对多标签分类有兴趣,我建议可以自己找一个小型的公开数据做练手,把全流程从数据处理跑到评估复盘做一遍。过程中肯定会遇到一些我上面提到的坑,解决完再回来体会这篇文章,很多东西就串起来了。