半监督图片分类实战:伪标签+一致性正则,标注省八成
图片分类做到一半最烦的不是模型结构选不好而是标注成本卡住脖子。我上一个项目大概就是这么死磕了快两周有标签的样本只有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, reductionnone) 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 size32显存限制下的实用值无标签batch size96有监督与无监督比例约1:3伪标签阈值0.95初始保守后期动态微调lambda_u1.0ramp_upwarmup阶段线性从0升到1warmup轮数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个被采纳的伪标签样本做人工核对估算伪标签准确率。结果非常难看训练轮数被采纳伪标签数量平均置信度人工核对准确率预测为“划痕”占比1510240.97864.0%41.2%3069320.96971.5%38.7%45150870.95574.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阈值的方法在细粒度类别上会不够敏锐到时候可能需要在两个相似类之间引入“分类难度感知”的伪标签调节不过目前还只是草稿。做半监督图片分类这一个月最大的体会是这条技术路线真正拼的不是花哨的模型结构而是对“模型还在哪个阶段、它敢相信什么、它错在哪些地方”的持续觉察。伪标签、一致性正则、动态阈值这些方法都只是工具真正让模型弯道超车的是用一整套监控和裁剪机制把无标注数据里那些可靠的信号一点点挑出来把噪声信号坚决挡在外面。我接下来应该会继续沿着这个方向优化尤其想验证一下动态阈值策略在大类目、强细分类场景下是否依然稳健如果跑出结果再回来更新一篇实操笔记。