图片分类做到一半,最烦的不是模型结构选不好,而是标注成本卡住脖子。我上一个项目大概就是这么死磕了快两周:有标签的样本只有500张,无标签的图片堆了3万张,分类目标倒是不难,10个类别的厨房器物,但让我一张张去标,每张都要盯着边缘纹理和颜色渐变反复确认,标完500张已经快瞎了。当时团队里有人提了一句,要不要试试半监督学习——先用少量标注把模型拉起来,再用海量无标注图片反哺训练。我本来将信将疑,毕竟“无标注图片”在常规有监督流程里只是测试集和验证集的原料,还能翻出别的花来?结果用了之后,精度从78.4%干到了87.3%,虽然离全监督的天花板还有距离,但已经足够省下八成标注工作量。
这篇文章就把我这次跑半监督图片分类的完整过程写出来,包括三类主流方法的取舍、我落地的混合方案、踩过的伪标签大坑,以及一套可以直接抄作业的训练节奏和超参配置。如果你是那种标注预算紧张、但手里握着大量“废图”的图片分类项目负责人,或者刚入门半监督学习想找一个能复现的技术路线,这篇应该对你有用。
1. 为什么“标注越少,越该试试半监督图片分类”
1.1 一个真实项目卡点:500张标签撑不起3万张图
先交代一下背景。这个项目要做一个10类厨房器物的图片分类器,类别包括盘子、碗、玻璃杯、保温杯、刀具、砧板、锅铲、打蛋器、漏勺、蒸架。公开图库里能扒到不少图片,但爬下来的都是一堆没有类别标记的文件名,真正要用于模型训练必须有标注。我一开始走了最常规的路:人工标注500张,训练一个有监督基线,准确率到78.4%就上不去了。
瓶颈倒不是模型太弱,而是标注样本太少导致同类之间的变化没被充分覆盖。比如“玻璃杯”这个类,有的样本是透明厚壁杯,有的是磨砂表面,有的带把手,有的底部收腰,如果某个子型没有任何标注样本,模型几乎必然分错。我试过加大骨干网络、调学习率、加各种轻量增强,指标始终在76%~79%之间震荡,损失曲线倒是很好看,验证集表现就是不涨。
更头疼的是标注成本。工业场景里的器物类目还好,人工审核至少不用太纠结;但如果是更细分的任务,比如当前这个厨房器物项目,类别边界已经开始模糊——“碗”和“盘子”之间本来就不存在绝对标准,深口的盘子算盘子还是碗?两个同事现场标注同一批图,一致率大概只有九成。这种情况下,盲目扩充标注集的边际收益很低,而时间成本却每天都在涨。于是我停下来想,能不能不扩大人工标注,而是把3万张无标注图片变成训练资源。
1.2 无标注图片不是废料,而是分布信息的载体
半监督学习的核心洞察其实很朴素:无标注图片虽然没有类别名,但它们不是没有信息。每张图片都从真实的类别分布 p(x) 中采样,图片与图片之间的远近关系、簇状结构、流形形状,通通都写在像素里。分类模型要做的无非是把输入空间划分成若干区域,无标注数据恰好告诉我们这些区域在哪里、边界应该怎么绕开密度高的地方。
打个不太严谨的比方。教小孩认动物,大人不可能每见到一只动物都告诉他名字,更多时候是看着一张照片说“这是猫”,然后孩子自己会从轮廓、耳朵形状、毛色纹理里总结规律。偶尔一次纠正——“那不是猫,是狸猫”——就足够了。半监督学习让模型处于同样的状态:少量标注负责给出类别语义锚点,大量无标注图片负责让模型观察“同类之间长什么样、不同类之间在什么地方断开”。
正是这种思路,决定了半监督方法和正则化、无监督预训练这类手段分家。有监督基线只计算标注样本上的交叉熵损失,模型对无标注图片的预测不管对错都不产生梯度;半监督方法则额外设计一个损失项,要求模型对无标注图片的预测满足某种“合理的约束”——要么预测结果必须足够有把握,要么同一张图片在不同增强视角下的预测不能打架,要么特征空间里同类样本必须彼此靠近。这些约束手段,就是下面要展开的三类主流路线。
2. 预算有限时怎么选模型:三类主流半监督思路的取舍
2.1 伪标签路线:先让模型给自己打工
伪标签(Pseudo-Labeling)是最容易理解的一类:拿当前模型去预测无标注图片,把预测概率最高的类别当作“伪标签”,再混入训练集按正常分类任务算损失。实操里几乎都会加一个置信度门槛,概率没到阈值就不要这个伪标签,常见阈值在0.9到0.95之间。
它的优点是实现特别简单,一个torch.no_grad()推理再加一行 threshold 过滤就能跑起来,几乎不改变原有训练框架。缺点也藏在这个“简单”里:模型自己的预测可能是错的,如果早期模型置信度过高但方向偏了,伪标签就会把错误信号固化,我们管这叫“确认偏差”。更要命的是置信度不等于准确度,一个模型完全可以在某个类别上非常“自信地错”,后面第4章我会专门讲这个坑。
2.2 一致性正则路线:同一张图的两种视角不应该吵起来
一致性正则(Consistency Regularization)的核心要求是:把同一张图片做两个不同的数据增强,得到两个视角,模型在这两个视角上的预测分布应当接近。如果一张碗的照片,翻转后预测变成盘子,说明模型没有真正学到“碗”这个概念的稳定特征,而是在利用图片里的伪相关线索。
为什么要用增强后的两个视角来约束?因为近邻平滑先验告诉我们,同一物体在不同光照、不同裁剪、不同旋转变换下,语义类别应该是局部不变的。模型如果只能在原图上认对,一旦换个背景就翻车,那这种“会分类”是脆弱的。一致性约束相当于强迫模型从形状、纹理这类有物理意义的线索里找判别依据,而不是从背景颜色、位置这些偶然因素里抄近路。
它的优点是对伪标签的绝对正确性要求没有那么死,就算错得离谱,只要两个视角的预测一致,损失也不会对模型造成剧烈冲击。但缺点也很明显:强增强的图片可能被破坏得太过分,模型在暴力增广的输入上根本学不到有效梯度;而且如果不加置信度筛选,损失会被某些“增强后都认成同一类”的高置信噪声样本主导。
2.3 对比学习路线:先别急着分类,把特征空间建好
对比学习在半监督里的定位更像“基建”。它完全不依赖标签,让同一个样本的多个增强视图在特征空间里靠近,让不同样本的视图远离。这样学出来的特征本身就有不错的语义区分度,之后再用少量标注做线性探测或者微调分类头,效果往往比从随机初始化一路硬训要好得多。
它适合那种极低标注率场景,比如每个类只有几张图。但成本不能忽略:对比学习需要大batch size,至少256起步,还需要做额外的正样本对采样,训练周期轻松翻倍。我当时因为手里只有单卡,并没有把对比学习作为主路线,只是借了它的一个思想——在伪标签基础上叠加特征对齐约束,让同类特征聚得更紧。
这三类方法不是互斥的,现在的成熟做法基本全是混合体。我在最终方案里用伪标签做主监督信号,用一致性正则做无标注分支的损失,再用EMA教师模型辅助生成更稳定的伪标签,相当于把三条路线的优点揉在一起。下面是我最终落地版本的完整设计。
| 路线 | 成本 | 优点 | 风险 |
|---|---|---|---|
| 伪标签 | 低 | 实现简单,训练快 | 置信度不等于准确度,错误会累积 |
| 一致性正则 | 中 | 对错误伪标签鲁棒 | 强增强可能破坏输入语义 |
| 对比学习 | 高 | 低标注率下特征更稳 | 训练时间长,显存需求高 |
3. 我跑通的混合方案:伪标签加一致性约束
3.1 数据划分与增强策略
我把500张标注数据拆成450张训练、50张验证;3万张无标注图片作为无监督池,全程不进验证集。这里的核心原则是:验证集必须只来源标注数据,不能混入任何哪怕“看起来像是”某一类的推断样本,否则精度评估就是自欺欺人。
增强策略上,我做了两套分支,对应一致性约束的两个视角:
- 弱增强:随机水平翻转、随机裁剪、颜色抖动幅度较小。弱增强视图用于生成伪标签,因为它的预测分布相对接近原始图片,不容易被增广破坏。
- 强增强:仿照RandAugment的思路,从一组图像变换(旋转、平移、对比度、锐度、太阳化等)中随机抽2到3个操作,强度随训练轮数从轻到重逐步提升。强增强视图用于计算一致性损失,迫使模型在困难输入上依然维持和弱增强一致的判断。
实践经验是这样的:强增强强度不要一开始就拉满。前10个epoch让模型先学会基本特征,再逐步加大增强强度,否则模型会在早期被过于畸形的输入带偏,损失曲线看起来在降,验证集准确率却纹丝不动。
3.2 训练流程伪代码与loss设计
整个训练循环的伪代码如下:
for epoch in range(total_epochs): ramp_up = min(1.0, epoch / warmup_epochs) for x_l, y_l, x_u in loader: # 有标签分支:标准交叉熵 sup_logits = model(x_l) sup_loss = cross_entropy(sup_logits, y_l) # 无标签分支:弱增强生成伪标签 + 强增强做一致性约束 with torch.no_grad(): teacher_logits = model(weak_aug(x_u)) teacher_prob = softmax(teacher_logits, dim=-1) max_prob, pseudo_label = teacher_prob.max(dim=-1) confidence_mask = (max_prob > threshold).float() strong_logits = model(strong_aug(x_u)) unsup_loss = cross_entropy(strong_logits, pseudo_label, reduction="none") unsup_loss = (unsup_loss * confidence_mask).mean() # 合成总损失,lambda_u 在前 warmup 阶段缓慢拉升 total_loss = sup_loss + lambda_u * ramp_up * unsup_loss total_loss.backward() optimizer.step() ema_update(teacher, student)这里我额外套了一层EMA(指数滑动平均)教师模型,而不是直接用学生模型的输出去生成伪标签。原因很简单:学生模型在训练初期权重抖动剧烈,生成的伪标签噪声大;教师模型是学生权重滑动平均的结果,决策边界要平滑不少。每一轮迭代里教师模型的参数等于过去若干版本学生模型的折中,相当于用“多个时代的模型投票”去决定伪标签,比单一时间点的模型可靠得多。
Loss设计上,无监督分支用的是带confidence_mask过滤的交叉熵。只有弱增强预测最大概率超过阈值的样本,才允许其伪标签去约束强增强分支的输出。这个mask一方面切掉噪声样本,另一方面也让模型的“无标注学习”始终盯住自己最有把握的区域,间接实现了熵最小化——模型在无标注数据上的预测分布会越来越尖锐、越来越像one-hot。
3.3 关键超参与训练节奏
我最终跑通的一组超参数如下,这里直接给出来,方便复现:
| 超参数 | 取值 | 设定依据 |
|---|---|---|
| 骨干网络 | 轻量级卷积网络,约880万参数 | 快速迭代为主 |
| 优化器 | SGD,动量为0.9,初始学习率0.3 | 比Adam泛化好,损失收敛更平稳 |
| 学习率调度 | 余弦退火,总轮数60 | 后期精细收敛,帮助熵最小化 |
| 有标签batch size | 32 | 显存限制下的实用值 |
| 无标签batch size | 96 | 有监督与无监督比例约1:3 |
| 伪标签阈值 | 0.95 | 初始保守,后期动态微调 |
| lambda_u | 1.0+ramp_up | warmup阶段线性从0升到1 |
| warmup轮数 | 10 | 先跑纯有监督,让模型不至于一开始就吃坏伪标签 |
| EMA衰减系数 | 0.999 | 教师模型长期平滑 |
| 训练精度 | FP16混合精度 | 显存减半,速度提升约30% |
前10轮的warmup阶段没有无监督损失,只做有监督训练,目的是让模型先具备一点基础的类别感知。10轮之后再让无标注分支介入,lambda_u从0线性爬到1,直到第20轮左右稳定。这样做的原因是:一个连100个类别都还没分清的模型去生成伪标签,可靠性还不如随机猜测,早期强行加入只会污染特征。
4. 排坑实录:伪标签“自信地错”是怎么一步步定位的
4.1 现象:准确率不涨反降
这套方案在一个平滑的公开图片分类数据上表现不错,但当我把它原样搬到一个工业表面缺陷分类的场景时,翻车了。缺陷分类一共有6个类别:划痕、脏污、麻点、异物、氧化色、正常。有监督基线在2000张标注上能到80.5%,加了半监督分支之后反而掉到了76.9%,而且是无监督损失不断下降、验证准确率持续走低那种阴间曲线。
我第一反应是学习率太大,调了一轮之后发现不是;又怀疑强增强把缺陷区域的纹理破坏得太严重,但单独关闭一致性约束后,准确率也只是回到80%附近,反而没有提升。这时候才开始把注意力转向伪标签本身的质量。
4.2 排查:从loss曲线看到校准问题
排查过程的第一步,是给训练循环里加了几行统计代码:每个epoch结束之后,统计无标注池里通过置信度阈值的样本数量、这些伪标签里预测为每个类别的占比,以及随机抽200个被采纳的伪标签样本做人工核对,估算伪标签准确率。结果非常难看:
| 训练轮数 | 被采纳伪标签数量 | 平均置信度 | 人工核对准确率 | 预测为“划痕”占比 |
|---|---|---|---|---|
| 15 | 1024 | 0.978 | 64.0% | 41.2% |
| 30 | 6932 | 0.969 | 71.5% | 38.7% |
| 45 | 15087 | 0.955 | 74.2% | 36.1% |
问题一目了然:大量高置信度伪标签集中在“划痕”这个类别上,平均置信度在0.96以上但人工核对准确率只有七成左右。也就是说模型在某些“划痕”样本上极度自信,但它的自信跟事实偏差很大。进一步画置信度分布才发现,这类样本往往是表面反光区域带几条假纹理,模型把反光误判成了划痕。
这让我意识到,伪标签现场真正要防的不是“低置信度错误”——低置信度样本会被阈值过滤掉;真正危险的是“高置信度系统性错误”。模型在某个特征子空间里形成错误偏好之后,会稳定地给出高概率预测,这些样本不仅不会被过滤,反而不断强化错误偏好,形成自我固化的恶性循环。
4.3 修复:分阶段的伪标签阈值策略
修复方式不是无脑调高阈值。我改成了一套分阶段动态阈值策略:
- 前10轮(warmup):阈值0.99,宁缺毋滥,只接受模型几乎肯定无误的样本;
- 10到30轮:阈值0.95,模型基础能力已建立,适当扩量;
- 30轮以后:阈值退到0.90,此时特征空间已经比较稳定,用更多伪标签继续压实决策边界。
同时,我在统计中发现“划痕”类别伪标签数量畸高,不是因为它真多,而是因为模型给它分配了过大的概率空间。于是又加了一个按类别统计的EMA调节项:如果某个类别近期的伪标签占比明显高于它在有监督训练集中的真实占比,就对阈值做小幅度惩罚。比如“划痕”在标注集里占20%,但伪标签占比冲到38%,说明模型在该类别上过度自信,阈值从0.95上调到0.98;反过来,“氧化色”伪标签占比过低,就把阈值往下微调,多放进来一些样本。
修复后,模型在工业缺陷集上的准确率从76.9%回升到82.5%,相比纯有监督基线提升了2个百分点。这个提升量级看着不大,但在分类任务里已经是从“勉强能用”到“可以上线”的区别。
5. 类别失衡让半监督更脆弱:长尾分布下的自救办法
5.1 长尾数据下,伪标签会让多数类更膨胀
第4章的缺陷分类任务其实还有一层背景:标注集中“正常”类占了一半,“划痕”和“脏污”加在一起大概三成,“异物”和“氧化色”各自只有不到一成的标注样本。这种长尾分布在半监督训练里会产生一个可怕的放大效应。
初始模型因为训练样本多,天然对“正常”“划痕”这类高频类别有偏好,预测概率高;伪标签挑选阶段,置信度阈值又恰好把高频类样本大量放进来,低频类样本往往因为概率低于阈值被拒之门外。于是每次迭代,高频类都获得更多新样本,低频类却几乎得不到新资源,类别之间的比例失衡越来越严重。这不是一个理论问题,我在日志里看到“异物”类在整个无监督池里最终只贡献了一百多张有效伪标签,而“正常”类贡献了两万多张。
这种坍缩的幕后机制其实和高置信度错误一样,都源于模型把概率空间过度分配给优势类,导致少数类的真实样本在特征空间里被逐步挤占。解决这个问题的思路,不是简单降低全局阈值——那样只会把更多高频类噪声放进来,而是要让伪标签的准入标准做到“类间公平”。
5.2 按类动态阈值与类平衡加权
我在工程里做的是两件事的结合。第一,按类动态调整阈值,实现逻辑并不复杂:
# 按类维护历史置信度的 EMA class_ema_prob = ema_update(class_ema_prob, current_class_prob) # 若某类平均置信度高,说明模型在该类上过于自信,提高阈值 adjusted_threshold = base_threshold + (class_ema_prob - unbiased_prior) * kunbiased_prior用有监督训练集里该类别的先验概率,k取2左右的缩放系数。高频类别因为平均置信度高,阈值会被提上去,等于“闸门收紧”;低频类别平均置信度低,阈值自动下调,多放进来一些样本。第二,对无监督损失做类平衡加权,低频类被采纳的伪标签样本,在损失里乘以一个放大系数,防止它们的梯度被高频类淹没。
用了这个策略之后,“异物”类的伪标签占比从3.2%提到了10.1%,其他低频类也有类似改善,全局准确率又涨了约1.5个百分点。这里要提醒一句:按类动态阈值也会引入新噪声,低频类被多放进来一些错误伪标签是不可避免的,所以类平衡加权不能力度过大,我试过把低频类损失放大到3倍以上,结果整体指标掉了——因为噪声样本也被乘以了放大系数。
6. 最终效果与下一步想继续实验的三个方向
6.1 结果对比:半监督到底省了多少标注成本
复盘全部实验,我把最终结果整理成了一张对比表,这里直接用相对准确率来表示:
| 方案 | 验证集准确率 | 说明 |
|---|---|---|
| 有监督基线(500张标注) | 78.4% | 人工标注500张,常规训练 |
| 半监督(早期失败版) | 76.9% | 固定阈值0.95,无类别调节 |
| 半监督(修复版) | 82.5% | 动态阈值+类平衡加权 |
| 有监督基线(2500张标注) | 84.6% | 大概相当于扩大5倍标注量的收益 |
| 全监督上限(全部图片标注) | 93.8% | 作为上界参考,现实中不可能标注 |
这个表最值钱的对比是第二行和第四行:500张标注+3万张无标注照片,通过半监督达到了接近2500张标注的准确率,而人工标注2500张的成本至少是500张的五六倍。也就是说,半监督在这个项目上等于帮我“凭空攒出了”约2000张标注的模型收益,代价只是多消耗了一些GPU训练时间和调参精力。
不过也要泼一盆冷水:半监督不会让模型学到它完全没有线索的东西。“异物”类如果一开始就只有3张标注图片,无监督池再多也顶多让它的特征边界稳固一点,不可能凭空推出全新的子类结构。低标注率下,标签质量、类别覆盖度仍然是决定上限的因素。
6.2 下一步:变化检测、更强增广与更多类别
这次做完之后,我给自己列了三个继续实验的方向,也都跟半监督图片分类直接相关。
第一个方向是把EMA教师模型和类平衡队列做一次更彻底的融合。现在的实现里,伪标签的置信度和EMA阈值仍然分开存储,各自独立更新。我想试试在EMA模型上直接维护一个按类别的置信度记忆库,用它同时决定阈值和损失权重,让“该信谁”这件事不再是拍脑袋调参,而是一个自适应机制。
第二个方向是更激进的数据增强。RandAugment对大部分图片有效,但对工业缺陷这类纹理细腻的任务,过度旋转和裁剪反而会破坏缺陷的形态信息。下一步想尝试将局部裁剪和亮度扰动组合在一起,让强增强更多聚焦在像素级细节上,同时降低几何形变强度,看能否进一步提升一致性约束的质量。
第三个方向是类别数量扩到40个以上。类别越多,长尾分布越明显,伪标签的自固化风险也会越强。我担心现在这套按类EMA阈值的方法在细粒度类别上会不够敏锐,到时候可能需要在两个相似类之间引入“分类难度感知”的伪标签调节,不过目前还只是草稿。
做半监督图片分类这一个月,最大的体会是:这条技术路线真正拼的不是花哨的模型结构,而是对“模型还在哪个阶段、它敢相信什么、它错在哪些地方”的持续觉察。伪标签、一致性正则、动态阈值这些方法都只是工具,真正让模型弯道超车的,是用一整套监控和裁剪机制,把无标注数据里那些可靠的信号一点点挑出来,把噪声信号坚决挡在外面。我接下来应该会继续沿着这个方向优化,尤其想验证一下动态阈值策略在大类目、强细分类场景下是否依然稳健,如果跑出结果,再回来更新一篇实操笔记。