图像多标签分类这个概念,我刚开始接触时也以为只是把单标签分类改个输出层就行,真正动手做下来才发现完全不是那么回事。所谓多标签分类,就是一张输入图像不再只输出一个唯一的类别,而是输出一组可能同时成立的标签。举个例子,一张街拍照片可以同时包含“行人”“自行车”“夜晚”这几个标签;一张电商商品图也可以同时被打上“连衣裙”“红色”“印花”多个标签。这个任务在深度学习落地项目里非常常见,却经常被新手当成单标签任务的简单变形,结果训练出来的模型一测就翻车。这篇博文我详细拆一下这类项目的完整落地流程,包括数据组织、模型改造、损失函数、评估指标、阈值调整以及我实际踩过的坑,适合已经会跑图像分类训练、想进一步做多标签项目的同学。
1. 项目概述与整体设计思路
1.1 单标签与多标签无法互相套用的本质原因
单标签分类任务里,一张图只能属于一个互斥类别,比如“猫”“狗”“鸟”,模型输出层用Softmax把所有类别的概率归一化成总和为1,最后取概率最大的那个作为预测结果。数据集的组织也简单,直接按类别分文件夹就行,ImageFolder这个工具可以直接读取并生成标签。
但多标签任务里,标签之间不存在互斥约束。输入一张图片,输出层是N个独立的神经元,每个神经元对应一个标签,预测值代表这个标签存在的概率。多个标签可以同时为1,也可以同时为0,概率之和没有任何物理意义。
这意味着下面这些东西全部都要换:
| 对比维度 | 单标签分类 | 多标签分类 |
|---|---|---|
| 数据集组织 | 按标签建文件夹,天然互斥 | 一张图对应一个标签列表,需要额外文件记录 |
| 输出层 | Softmax | 每个标签独立,常用Sigmoid |
| 损失函数 | 交叉熵 CrossEntropy | BCE 或带权重的BCE变体 |
| 预测判定 | 取最大概率对应的类别 | 给每个标签设独立阈值,超过阈值即判定存在 |
| 评估指标 | Accuracy 基本够用 | Accuracy 会骗人,要看F1、mAP等 |
项目一开始就应明确这个差异,否则选型阶段就会走偏。我见过不止一个人把多标签项目当成单标签做,输出层用了Softmax,损失函数用了CrossEntropy,训练时损失确实在下降,但预测结果永远只有一个标签生效,整张图的多语义信息全丢了。
1.2 为什么这类项目必须选择深度学习方案
很多人会问,多标签分类用传统机器学习不行吗?比如每个标签单独训练一个二分类器,图像特征用颜色直方图、SIFT、HOG这些手工特征提取。理论上确实能跑通,但实际工程里几乎没人这么做,原因也很直接。
第一,每个标签单独训练分类器,N个标签就是N个独立的特征提取流程,计算量接近线性增长,共享不了视觉特征。第二,标签之间的关系无法建模。比如“笔记本电脑”和“键盘”这两个标签在数据里往往同时出现,单独训练两个分类器时这种共现信息完全被丢弃了,模型等于双倍努力。第三,手工特征的表达上限太低,遇到细粒度标签、姿态变化大、光照复杂的场景,准确性掉得很快。
深度学习方案的核心优势是特征共享。一个卷积网络把图像映射成高维特征向量,后面接N个并列的预测头,特征提取部分被所有标签共用。训练时每个标签的梯度都会回传到特征网络,等于一份算力同时优化多个任务。标签之间的共现关系也可以被网络隐式学习到,比如网络会注意到“键盘”出现时,“电脑”区域的特征通常也在前面几层被激活。
所以这类项目的标准技术路线是:预训练卷积网络作为特征提取器 + 多标签输出层 + BCE损失 + 独立阈值判定。这个组合在绝大多数场景下是性价比最高的起点。
1.3 哪些场景和人群适合参考这个方案
多标签分类的落地场景非常广。
电商平台给商品图打属性标签,一张衣服图同时标出款式、颜色、材质,一套模型替代多套单标签模型的维护成本。内容审核场景里,一张图片需要同时判断是否包含人物、是否涉及特定物体、是否需要打码处理。相册应用自动按地点、物品、场景给照片打标。医学影像里一张片子可能同时存在多种病变征象,虽然那套系统对精度要求更苛刻,但底层的“多标签”建模逻辑是共通的。
如果你已经能跑通一个基本的深度学习图像分类代码,但想进一步处理“一张图有多个语义”的现实业务问题,这篇博文可以帮你把整条链路串起来。如果你只是刚接触深度学习,建议先花一两周过一遍基础的分类训练流程,再来读这篇会更顺手。
2. 数据准备与标注:多标签项目的地基
2.1 多标签数据最理想的组织方式
单标签分类可以直接按照类别建文件夹,但多标签不行。一张图属于多个标签,无法复制多份存到不同文件夹里,那样会让数据量虚增,还会导致标签之间的关系被割裂。实际项目中更推荐用一张表把“图片ID”和“标签列表”绑定起来。
最常用的格式是CSV或者JSON。CSV结构大概是:
image_id,labels 000001.jpg,cat;indoor 000002.jpg,dog;outdoor;daytime 000003.jpg,cat;dog;sofa我建议用分号分隔标签,而不是用逗号。因为很多标注工具导出的字段本身就会带逗号,比如标签“人在路边,低头看手机”这种描述性文本,用逗号分隔会直接就把解析逻辑搞乱。分号分隔虽然也不绝对安全,但在实际项目里比逗号稳很多。
如果你用的是JSON,结构可以这样:
{ "000001.jpg": ["cat", "indoor"], "000002.jpg": ["dog", "outdoor", "daytime"] }JSON的好处是标签列表天然是数组,不用处理字符串拆分,解析更干净。缺点是文件体积比CSV略大一些,在数据量达到百万量级时差异会比较明显。百万张以下选哪种都行,我习惯用CSV,因为可以直接用Excel简单查看和修改。
对比单标签的ImageFolder方式,多标签项目必须自定义Dataset类。torchvision自带的ImageFolder默认按子文件夹名生成类别索引,完全无法处理一个样本对应多个标签的情况。这个问题我在第四章会给完整代码。
2.2 标签词表不规范会让模型白训
数据准备阶段最容易忽视的是标签词表的规范性。多个标注人员、多批标注数据合在一起时,经常出现同一个意思用了两种写法的情况,比如“小汽车”和“轿车”、“户外”和“室外”、“person”和“people”。模型输入层看到的是两个独立神经元,它会努力区分这两个其实相同的东西,白白消耗模型容量,最后训练出来的结果也莫名其妙:一张图明明只有一个实体,模型却同时输出了两个相似标签,概率还都挺高。
规范标签词表这件事必须放在项目启动时做,不要拖到第一轮训练之后。我当时拿到的原始数据里有六百多个不同的标签写法,清理完同义合并之后只剩一百八十多个。这一步看起来繁琐,但对最终模型效果的影响不亚于换一个更强的网络骨架。
清理完之后要做第二个动作:统计每个标签的正样本数量,过滤掉出现次数过少的标签。比如某个标签只有5张正样本,模型基本学不出什么有效特征,留着只会增加输出维度、拖慢收敛,还会让评估阶段出现一堆不可靠的指标波动。我的经验是,先去掉正样本数小于50的标签,如果业务上确实需要这些长尾标签,再单独研究小样本策略,不在一开始就混进主模型。
2.3 多标签场景的数据增强红线
数据增强部分有个很容易踩的坑:不是所有增强操作对多标签任务都安全。
水平翻转在绝大多数场景下是安全的,因为“猫在左边”还是“猫在右边”通常不影响标签是否成立。但涉及方向性属性时就要小心了,比如标签里有“文字左对齐”或者“人朝向左侧”,水平翻转之后标签的语义已经变了,原标签不能直接沿用。
随机裁剪对单标签任务很常用,对多标签任务却要谨慎。剪掉图像一半之后,图像里那个物体可能只剩局部,标签“完整的笔记本电脑”就不再成立了。如果你业务上能接受局部物品识别,那可以保留裁剪增强,否则建议只做轻微缩放或中央区域附近的裁剪,避免破坏标签定义的完整性。
随机遮挡和CutMix这类操作在多标签任务里反而相对友好。遮挡掉一块小区域后,如果被遮挡的部分恰好对应某个标签的视觉证据,这个样本相当于变成了该标签的难例,对模型鲁棒性有正向帮助。CutMix把两张图拼在一起,两张图的标签列表可以直接合并成新的标签列表,这在语义上是合法的。如果后续训练遇到欠拟合或者泛化性不足,可以考虑引入CutMix看看效果。
3. 模型选型与关键原理
3.1 特征提取骨架的选择思路
多标签分类的模型结构通常由两部分组成:特征提取骨干网络和分类输出层。骨干网络决定了模型能学到什么样的视觉特征,输出层决定了特征的组合方式。
骨干网络选择上,不一定要盲目追新。ResNet50、ResNet101作为预训练模型,在图像分类任务上有一整套成熟的加载方案和调参经验,用它作为默认起点是最稳妥的。EfficientNet系列在同参数量下精度更高,但训练时对输入分辨率和BatchSize更敏感,调起来要多花一些时间。Vision Transformer在数据量足够大的情况下有上限优势,但数据量不足时就容易欠拟合,小团队的项目里我用下来反而没有ResNet稳。
我的建议是,第一版先用ResNet50预训练权重跑通整条流水线,把数据、阈值、评估这些外围问题全部解决之后,再回来考虑要不要换更强的主干。换骨干只是改一行代码的事情,但如果你连阈值调优和标签清洗都没做,换再强的网络也只是在糟糕的地基上盖高楼。
3.2 输出层必须用Sigmoid,不能用Softmax
这个点我在前文已经强调过一次,但实操中踩坑的人实在太多,值得从原理层面再讲透。
Softmax的输出有一个硬约束:所有类别的概率加起来等于1。这个约束本身来自单标签分类的假设——图片一定属于某个类别,且只属于一个类别。用在这个假设不成立的多标签任务上,等于强行给模型加上了一个“标签之间互斥”的错误先验。
Sigmoid则是对每个标签独立计算概率,它的公式是 1 / (1 + exp(-z)),输出范围在0到1之间,但没有任何跨标签归一化约束。标签A的预测概率是0.9,标签B的预测概率也可以是0.9,两者互不影响。这正好契合多标签的语义:图像里可以同时存在多个物体、多个属性。
对应到具体实现上,最后一层全连接的输出维度从1000改成标签总数N,之后接Sigmoid或者直接输出logits。训练时更推荐直接使用logits配BCEWithLogitsLoss,因为PyTorch的BCEWithLogitsLoss在数学上做了数值稳定性优化,避免了对Sigmoid输出做log运算时出现0概率导致的误差,这是一个经常被忽略的实现细节。
3.3 损失函数选择与正负样本不均衡处理
多标签任务最常用的损失函数是Binary Cross Entropy,通俗说就是每个标签单独算一个二分类交叉熵,然后取平均。
PyTorch里的对应接口是BCEWithLogitsLoss。它有几个关键参数需要关注。
pos_weight参数专门用来处理正负样本不均衡。多标签数据里很多标签的正样本占比很低,比如“跑步”这个标签可能只出现在全部图片的3%里,剩下97%都是负样本。如果不加权,模型会把精力全放在让负样本输出接近0上,因为负样本数量多、对损失的贡献大,结果是有没有“跑步”它都倾向预测0,回召率很低。
pos_weight的计算方法是每个标签的负样本数除以正样本数。假设某个标签有100张正样本、1000张负样本,pos_weight就是10。这个值会放大正样本对应的损失项,让模型更重视正样本的判别。实际操作时可以先按公式算出来,再根据验证集效果微调,有时候给个一半数值效果更稳,因为权重过大会导致正样本误报暴增。
如果数据里存在极端难分的样本,可以进一步引入Focal Loss的思想。它给易分样本的损失降低权重,把训练重心引导到难分样本上。多标签场景中,如果某些标签的视觉特征非常微弱,常规BCE怎么训都学不出来,Focal Loss往往会有惊喜。不过实现起来比BCE复杂一些,我建议先把BCE加pos_weight跑通,确实不够再换。
3.4 正确选择评估指标,别被Accuracy骗了
多标签分类里,Accuracy是一个极具迷惑性的指标。假设某个标签只有1%的正样本,模型把所有样本都预测为负,它在每个标签上的Accuracy仍然高达99%,看起来相当不错,实际预测能力等于零。
更合理的评估方式是看F1分数。F1结合了精确率Precision和召回率Recall,能惩罚那种“一味预测负样本”的偷懒策略。每个标签可以单独算F1,再汇总成两种形式:
- Macro-F1:所有标签F1取平均,对每个标签一视同仁,适合关注长尾标签的场景。
- Micro-F1:把所有标签的预测结果汇总成一个大混淆矩阵再算F1,天然偏向样本量大的标签,适合关注整体预测量的场景。
此外还有mAP,全称是mean Average Precision,在多标签分类里的含义是对每个标签按预测概率排序,计算精确率随召回率变化的曲线下面积,最后取平均。它不需要先设定阈值,直接衡量模型对概率排序的好坏,在检索类业务里非常常用。注意这个mAP和目标检测里的mAP概念不一样,前者是标签级别的排序质量,后者是框级别的定位质量,别弄混了。
实际项目中我一般会同时看Macro-F1和每个标签单独的F1,尤其是那些业务上不允许漏掉的标签,必须单独跟踪Recall值,一旦出现大幅下滑就立刻排查数据或阈值变化。
4. 实操落地:完整训练与评估流水线
4.1 自定义数据加载器
前面说过,多标签数据不能直接扔给ImageFolder。这里给一份可以直接改的Dataset代码,假设训练集信息在一个CSV文件里,第一列是image_id,后面N列是N个标签的0/1数值。
import os import pandas as pd import torch from PIL import Image from torch.utils.data import Dataset class MultiLabelDataset(Dataset): def __init__(self, csv_path, img_dir, transform=None): self.df = pd.read_csv(csv_path) self.img_dir = img_dir self.transform = transform # csv中除了image_id这列,其余列都是标签列 self.label_cols = [col for col in self.df.columns if col != 'image_id'] def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img_path = os.path.join(self.img_dir, row['image_id']) image = Image.open(img_path).convert('RGB') if self.transform: image = self.transform(image) label = torch.tensor( row[self.label_cols].values.astype('float32'), dtype=torch.float32 ) return image, label这个类的关键点在label_cols的构造:拿出CSV里所有列名,去掉首列,剩下的全部当作标签。只要数据表里标签列顺序固定,训练和评估就不会出现标签错位的问题。
对应的transform里要注意,训练阶段和验证阶段不能完全一样。训练可以用RandomResizedCrop、RandomHorizontalFlip、ColorJitter这些增强,验证阶段只用Resize和CenterCrop,保证评价指标的稳定性。我通常把输入分辨率设在224或256,ResNet系列在这个尺寸上收敛稳定,显存占用也可控。
4.2 模型改造:保留预训练权重,换掉输出层
多标签模型可以在预训练分类模型基础上改造。以下以ResNet50为例。
import torch.nn as nn from torchvision import models class MultiLabelModel(nn.Module): def __init__(self, num_labels, backbone='resnet50', pretrained=True): super().__init__() if backbone == 'resnet50': self.backbone = models.resnet50(pretrained=pretrained) # 去掉原有的1000类分类层 in_features = self.backbone.fc.in_features self.backbone.fc = nn.Identity() # 换成多标签输出头 self.classifier = nn.Linear(in_features, num_labels) def forward(self, x): features = self.backbone(x) logits = self.classifier(features) return logits替换掉backbone.fc的方式很省事,但要注意预训练网络最后的全连接层原本是专门学1000类ImageNet语义的,直接把它整体换成Identity再接一个新的Linear层,新层的权重需要从头学。这没问题,预训练卷积层已经提供了足够强的视觉特征,新线性层很快就能收敛。
如果数据集特别小或者和预训练任务分布差异很大,可以考虑冻结骨干的前几层,只训练高层和分类头,等训练稳定后再解冻全部层做微调。这样可以降低前期训练的不稳定性。
4.3 训练循环与阈值自动优化
训练循环主体和普通分类任务差别不大,差异集中在损失函数和优化器配置上。
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import transforms # 计算每个标签的pos_weight def compute_pos_weight(labels, epsilon=1e-6): # labels: numpy数组或DataFrame,每一列是一个标签 positive = labels.sum(axis=0) negative = labels.shape[0] - positive return torch.tensor(negative / (positive + epsilon), dtype=torch.float32) labels_np = train_dataset.df[train_dataset.label_cols].values pos_weight = compute_pos_weight(labels_np) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)关于学习率,用预训练权重时不要开太大,1e-4是个保守而稳定的起点。如果你用的是ImageNet预训练模型在数据规模不大的项目里,1e-4配合StepLR或CosineAnnealing都能稳定收敛。
每轮训练结束之后在验证集上计算F1并保存最优模型,这部分必须写进流水线,不能靠“看损失曲线觉得差不多了”来判断。
模型输出的logits经过Sigmoid得到每个标签的概率,然后需要确定判定阈值。很多人直接用0.5,但这是默认值,不是最优值。每个标签的正负样本比例、特征难度都不同,合理做法是为每个标签单独搜索阈值:
import numpy as np from sklearn.metrics import f1_score def search_best_thresholds(y_true, y_prob): # y_true: (num_samples, num_labels) 0/1标签 # y_prob: (num_samples, num_labels) 模型预测的概率 num_labels = y_true.shape[1] best_thresholds = [] for i in range(num_labels): best_thr = 0.5 best_f1 = 0.0 for thr in np.arange(0.05, 0.95, 0.05): pred = (y_prob[:, i] >= thr).astype(int) f1 = f1_score(y_true[:, i], pred) if f1 > best_f1: best_f1 = f1 best_thr = thr best_thresholds.append(best_thr) return best_thresholds这个搜索范围从0.05到0.95,步长0.05已经够用。想更细可以缩小步长,但要注意在验证集上调得太细可能会过拟合验证集本身,不如保持0.05的粗粒度稳定。
4.4 推理部署时的两个细节
模型训练完保存到文件之后,部署阶段的细节经常被忽略。
第一,推理阶段建议输出概率而不是二值化结果。下游系统可以根据自己的业务场景调整判断阈值,比如审核场景希望漏报少,就降低阈值提高召回;推荐场景希望精准一些,就提高阈值。如果只输出0和1,下游就没有调节余地了。
第二,多标签预测返回结果时没必要局限于“超过阈值的标签”。有些业务更需要Top-K结果,比如相册检索里习惯返回最相关的5个标签,即使某些标签概率未过阈值也可以作为弱关联输出给用户。因此推理服务设计成返回每个标签的概率数组,由上层业务决定怎么消费。
如果需要上线上用了ONNX或TensorRT,需要注意输入尺寸的固定问题。动态尺寸会带来额外的转换复杂度,如果线上可接受,先把输入分辨率固定在训练时的尺寸,能省掉大量工程麻烦。
5. 常见问题与排查技巧实录
5.1 模型把多标签学成了单标签
这是最典型的问题。症状是模型对一张图预测的多个标签概率总和接近于1,或者一个批次里的预测结果几乎每个样本都只有一个高概率标签。
排查思路从代码层面过一遍:
- 输出层是否误用了
nn.Softmax或者nn.LogSoftmax? - 损失函数是否误用了
nn.CrossEntropyLoss而不是nn.BCEWithLogitsLoss? - 标签编码是否正确?有没有把多标签向量错误地转换成了类别索引,比如取了最大索引当唯一标签?
如果这三处都没有问题,再看数据。有的标注规范里本身存在“每张图只能选一个主要标签”的规则,那数据本质上还是单标签,就算模型结构改对了也学不出多标签效果。需要重新确认业务标注协议。
5.2 正样本极少的标签完全训不出来
某个标签在训练集里只有几十个样本,其它标签都有上万样本。训练几轮之后发现该标签的概率输出几乎恒为0,怎么调学习率都没用。
这种情况可以从三个方向解决:
第一,调整pos_weight。按“负样本数除以正样本数”计算,给这个稀有标签一个较大的权重,强制模型在训练时多关注它。但权重不要给得过高,否则会出现大量假阳性。我一般会限制pos_weight的上限,比如不超过20,防止训练不稳定。
第二,标签过滤。如果这个标签在业务上的优先级不高,直接删掉它比硬训练更科学。模型输出维度越多,样本不平衡带来的干扰越大,删掉低价值长尾标签可以提升其它标签的效果。
第三,额外补充数据。这招最慢但最有效。如果业务铁定需要这个标签,就去针对性做一轮定向召回,把包含这个标签的图片补到一定数量级。数据量不足时任何算法技巧都只是延缓问题,而不是解决问题。
5.3 阈值不是0.5,需要单独搜索
另一个常见的误区是认为Sigmoid输出0.5是天然的分类边界。实际上0.5只是概率等分点,并不是最优判定点。
举个例子。某个标签负样本极多,正样本很少,模型对正样本输出的概率普遍在0.2到0.4之间,对负样本输出的概率普遍在0.5以下。这时候如果采用0.5阈值,所有正样本全被漏掉,F1直接为0。在验证集上搜索阈值后,最优阈值可能只有0.15,就能让召回率显著提升。
反过来也有情况。有些标签特征非常清晰,正样本概率输出高达0.9以上,负样本也在0.7左右徘徊,这时0.5阈值会产生大量误报,最优阈值可能要到0.8以上。
所以阈值搜索这一步不是可选项,而是多标签项目评估阶段的标准步骤。每个标签各用各的阈值,这个灵活度也是多标签任务相比单标签任务的优势之一。
5.4 一个“自行车骑手”标签翻车案例
分享一个我实际遇到过的排查案例。某安防场景项目里有“行人”和“自行车骑手”两个标签。模型训练完成之后,测试集上“自行车骑手”的精度很高,但我总觉得哪里不对,抽样查看结果后发现很多只有自行车停在路边、旁边根本没有人的图片也被预测成了“自行车骑手”。
进一步分析训练数据才发现问题:标注人员把所有“有人推着自行车”的图也都标成了“自行车骑手”,模型学到的其实是“图像里出现自行车”就输出“自行车骑手”,而不是真正理解了“人在骑自行车”这个动态语义。
处理方式分两步。第一步清洗训练标签,把概念界限重新定义清楚,只保留“骑行中的人”才标骑手,旁边停着车不算。第二步针对性增加难例,加入一批“有自行车、没有骑手”的负样本,让模型学会区分“只有车”和“人在骑车”。
这个案例给我一个教训:多标签模型表现差的很多时候不是网络结构的问题,而是标签定义和视觉概念没有对齐。模型学的是数据分布,标注员怎么标,它就怎么学。
6. 写在最后
这个多标签分类项目如果只留一条经验,我会说“先把数据和评估搞清楚,再碰模型结构”。很多人一上来就盯着哪个主干网络更涨点,折腾半天发现训练集里标签本身就互相矛盾,或者评估指标根本没反映业务目标,白费力气。我自己做得比较顺的项目,一半时间花在数据清洗、标签词表统一、阈值调优和评估设计上,模型结构反而永远是那套ResNet加BCE。另外一个实用建议是保存模型时把阈值一起存下来,做成一个配置文件,否则重新推理时你根本记不清当时用的是什么阈值。下次再启动多标签项目,我建议你照着这条链路由数据走到阈值搜索,第一版跑通再谈优化,方向就不会歪。