做图像分类的同行应该都有过这种体验:模型结构换了一茬又一茬,精度卡在一个点死活上不去,最后回头一看,瓶颈根本不在网络设计上,而在数据标注上。手里握着几万张甚至几十万张未标注的图片,但标注好的一共就几千张。找人来标,成本高、周期长、质量还得反复抽检;不标,又觉得这批数据白白躺在硬盘里。我的经验是,把这个场景直接交给半监督学习。
半监督学习(Semi-Supervised Learning,SSL)要解决的,正是"少量标注 + 大量无标注"这种现实困境。它不需要像强化学习那样精心设计奖励函数,也不像纯自监督训练那样完全抛弃标签,而是在现有分类任务上做文章:用同一个训练流程,把无标注数据的价值尽可能榨出来。这篇文章是写给正在做图像分类、手里有明确标注瓶颈、想在不追加人工标注的前提下继续提升模型效果的朋友们。我会把半监督学习的三条主流路线、整体设计思路、训练细节和踩坑记录完整整理出来,照着操作基本可以复现到一个能用的状态。
1. 这个任务的核心矛盾与整体设计思路
1.1 标注成本才是真正的天花板
很多人一开始做分类任务,第一反应是换更大的模型。我也这么干过:把ResNet换成更深的版本,精度涨了一两个点,然后继续换,再涨一两个点,最后发现边际收益越来越小,训练时间倒是翻了倍。真正让我意识到问题所在的,是一次数据扩充实验——我把有标注数据从每类500张加到每类2000张,同样的模型结构,精度直接涨了将近4个百分点。那一刻我就明白:在这个任务里,数据量的收益远大于模型复杂度。
但标注成本摆在那里。一张工业场景下的缺陷图,需要老师傅逐张确认类别,一天下来一个人标不了几百张;如果是医疗影像或者遥感图像,标注门槛更高,普通标注员根本干不了。所以"用算法省标注"就成了刚需,而不是锦上添花。
半监督学习的核心设定很直接:训练集里只有一小部分样本带标签,剩下的大部分样本没有标签。你要设计一套训练策略,让模型能从这些无标签样本里学到分布信息。这里有个容易被忽略的前提——无标签数据的分布必须跟有标签数据同源。比如你做的是猫狗分类,有标签的是家养猫狗照片,无标签的如果全是动物园里拍的,那分布偏移会带来不少麻烦。这一点我后面在避坑部分还会展开。
1.2 半监督学习赖以成立的三个假设
半监督学习能work,依赖三个基本假设,理解这三个假设你才能判断一个SSL方法在你的数据上到底适不适用。
第一个是连续性假设:如果两个样本在特征空间里离得近,那它们的标签也应该接近。这个假设很好理解,狗的图片和猫的图片在特征空间天然分成两簇,簇内的点理应有相同标签。
第二个是平滑性假设:特征空间里的决策边界应该尽量平滑,而不是锯齿状。换句话说,数据分布密度高的区域不应该被决策边界切开,边界应该往样本密度低的地方走。这也解释了为什么SSL方法普遍对数据增强敏感——增强就是在制造更多的"近邻样本",逼迫决策边界变得更平滑。
第三个是低密度分离假设:不同类别之间的决策边界,应该穿过特征空间的低密度区域。这个假设是伪标签法的基础——如果模型对某个无标签样本给出了很高的置信度预测,说明这个样本大概率落在某一簇的核心区域,给它打上伪标签是合理的。
我之前测试过一个真实场景:产品外观缺陷分类,缺陷类型有划痕、脏污、凹陷等,其中划痕和脏污在低分辨率下特征非常接近,两类样本在特征空间中交叠严重。这种情况下,低密度分离假设就不太成立,SSL方法的表现也会打折扣。所以不是拿到SSL就无脑套,先判断任务是否符合这些假设,能帮你省下大量试错时间。
1.3 为什么"预训练+微调"不等于半监督
有朋友问过我:我直接用ImageNet预训练模型,再在自己数据上微调,这不也算用了大量无标注数据吗?严格来说不算。迁移学习用的是外部大数据集的监督信号,跟你任务里的无标注数据没有直接关系。半监督学习的本质是让"当前任务的数据本身"参与训练,无论它有没有标签。
这两者的差异会直接影响精度上限。预训练模型提供的是一个好的初始化起点,但如果你任务的数据分布跟预训练分布差得远,这个起点反而可能成为阻碍。而半监督训练是让模型在你自己数据的分布上持续学习,随着训练推进,模型对任务特有结构的拟合程度是迁移学习给不了的。实际项目里,SSL和预训练往往叠加使用——先加载预训练权重,再用半监督方式微调,效果最好。
2. 核心方法拆解:三条技术路线
2.1 伪标签法:最简单的半监督思路
伪标签法(Pseudo Labeling)的思路可以用一句话概括:先用有标签数据训练一个基线模型,然后用这个模型去预测无标签数据,把置信度高的预测结果当成"伪标签",混合进训练集继续训练。
这里的核心是置信度阈值。模型对无标签样本预测出的概率分布,如果最大概率超过某个设定值(比如0.95),就认为这个预测可信,把对应样本连同预测类别加入训练集;低于阈值的样本则丢弃。阈值设置直接决定了伪标签的噪声水平。
我用一个实际例子来说明。假设你有一个三分类任务,某批无标签数据里混合着各种难度的样本。如果阈值设得太低,比如0.7,模型很容易把模糊样本也打上标签,这批伪标签的错误率可能高达15%到20%,训练后期模型会被这些错误信号带偏。设得太高,比如0.99,确实能保证伪标签质量,但符合条件的样本数量可能少得可怜,无标签数据大部分被浪费。我的经验是从0.95起步,然后根据验证集表现动态调整。
伪标签法还有一个进阶操作叫"锐化"(Sharpening):对预测的概率分布做温度缩放,让分布更尖锐。比如原始预测是[0.6, 0.3, 0.1],温度系数0.5处理后,分布接近[0.78, 0.19, 0.03],类间差异更明显,相当于把模型的"信心"放大。这个技巧在样本量少的时候特别有用。
2.2 一致性正则:让模型对图像变换不敏感
一致性正则(Consistency Regularization)的出发点更巧妙:给同一个无标签样本施加不同的数据增强,模型对两个增强版本的预测应该一致。这个约束不需要任何标签,纯粹利用图像的语义不变性——你把一张猫的图片翻转、裁切、调色,它依然是一只猫。
具体做法是,对同一个无标签样本生成两个增强版本,分别输入模型,得到两组预测分布,然后计算这两组预测之间的差异作为损失,反向传播迫使模型输出趋于一致。常用的差异度量是KL散度或者均方误差。
早期的一致性正则方法有个实现细节需要注意:模型对弱增强和强增强版本的输出,不应该被一视同仁地拉近。因为强增强可能引入过多噪声,如果强行让模型在两个版本上输出完全一致,反而会限制模型的特征表达能力。后来的方法普遍采用"弱增强版本提供监督信号,强增强版本接受监督"这种不对称设计,效果明显更好。
还有一个经典实现是临时模型(Temporal Ensemble):不直接比较同一个batch的两个增强版本,而是维护一个模型预测的历史平均(用EMA方式更新),让当前预测跟历史平均保持一致。这种做法的好处是监督信号更稳定,不易被单次增强的随机噪声带偏,代价是需要额外维护一份EMA参数,内存和计算开销都增加了。
2.3 FixMatch:把前两者拧在一起的实用方案
FixMatch是我实际项目里用得最多的方案,它把伪标签法和一致性正则结合在一个框架里:用无标签样本的弱增强版本生成伪标签,然后用这个伪标签监督强增强版本的预测。
流程是这样的:无标签图像先经过弱增强(标准翻转、平移),输入模型得到预测;把预测中置信度超过阈值(默认0.95)的样本筛选出来,用它的预测类别作为伪标签;然后同一张图像再经过强增强(RandAugment、Cutout等),输入同一个模型,要求它的输出跟伪标签一致。只有弱增强的置信度超过阈值的样本,才计入无监督损失。
FixMatch之所以效果好,有三个原因。第一,强增强带来的扰动比弱增强大得多,模型不得不学会在各种苛刻变换下保持语义稳定,这种学习信号非常强。第二,伪标签质量由置信度阈值和弱增强双重保证,噪声水平可控。第三,实现简单,不需要额外的模型或者复杂的调度策略,一个阈值就能把两个模块串起来。
在FixMatch基础上还有不少变体。FlexMatch引入了类别自适应的阈值:置信度阈值不再全局统一,而是根据每个类别当前的学习状态动态调整。训练初期模型对某些类别的置信度整体偏低,这时候统一阈值会过滤掉大量有效样本,FlexMatch的思路是对难的类放宽阈值,对简单的类收紧阈值,让每个类都能持续获得监督信号。
另外提一下MixMatch,它是一致性正则的另一种包装方式:对无标签样本做多次增强,把多次预测结果平均后做锐化,再把有标签样本、无标签样本以及它们的标签做MixUp混合,生成最终训练数据。MixUp说白了就是样本之间的线性插值,让模型在类别之间学会平滑过渡。这套方案效果好,但训练开销比FixMatch大不少,工程项目里我一般优先试FixMatch。
我把三条路线的特性整理成了一个对比表,方便你在选型时快速判断:
| 方法 | 核心思想 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| 伪标签法 | 用高置信度预测作为标签 | 实现简单、训练开销小 | 伪标签噪声可能累积 | 标注数据相对较多、分布简单的任务 |
| 一致性正则 | 增强不变性约束 | 不依赖伪标签、训练稳定 | 需要合理设置强弱增强策略 | 无标签数据充足、类别边界清晰的场景 |
| FixMatch | 伪标签+一致性正则结合 | 精度高、收敛快 | 对阈值和增强策略敏感 | 通用首选方案 |
3. 实操全过程记录
3.1 数据集组织与数据划分
实操第一步是数据准备。我建议把数据集分成三部分:有标签训练集、无标签训练集、验证集。要注意的是,无标签数据不是随便凑数的——它必须来自跟有标签数据相同或者非常接近的分布。
一个常见的错误做法是:有标签数据来自A采集点,无标签数据来自B采集点,两个点的光照条件、拍摄角度都不一样,结果半监督训练不仅没提升精度,反而把有标签部分的性能拉低了。这是因为模型强行从分布不一致的无标签数据里学结构,学到的"结构"跟任务无关,甚至有害。我的做法是先做一次简单的分布检查——用预训练模型提取特征做降维可视化,确认两类数据的特征分布有足够重叠,再进入训练流程。
数据预处理要保持统一。Resize、归一化的参数必须有标签和无标签共用一套,否则模型看到的数据分布天然不一致,SSL的效果会被削弱。另外,训练阶段的增强策略和验证阶段必须是两套,验证时只做Resize和归一化,不加任何随机增强。
3.2 模型与训练配置选型
模型结构的选择上,我倾向于先用ResNet50级别的网络把SSL流程跑通,确认收益之后再考虑更强的结构。SSL方法对模型容量的要求比普通监督训练略高,因为模型既要拟合有标签样本,还要对无标签样本的一致性约束进行学习,容量太小的模型容易顾此失彼。
优化器方面,我推荐SGD配合动量,学习率用余弦退火调度。Adam在SSL任务里表现并不差,但需要注意权重衰减的适配。SGD配合动量收敛更稳,且对学习率的敏感度更低,调参成本小一些。如果数据量特别大,可以用LARS优化器配合大批次训练,但常规项目SGD足够了。
BatchSize的设定也有讲究。FixMatch这类方法对batch size比较敏感,我通常把有标签和无标签的batch大小设为一致,比如各64。如果显存吃紧,至少要保证无监督部分的batch在32以上,否则一致性损失的方差太大,训练会不稳定。
3.3 核心训练循环实现
下面给出FixMatch的核心训练循环实现,我用PyTorch风格写,关键部分都加了注释。
import torch import torch.nn.functional as F def train_fixmatch(model, labeled_loader, unlabeled_loader, optimizer, threshold=0.95, lambda_u=1.0): model.train() labeled_iter = iter(labeled_loader) unlabeled_iter = iter(unlabeled_loader) for step in range(args.total_steps): # 有标签batch try: x_l, y_l = next(labeled_iter) except StopIteration: labeled_iter = iter(labeled_loader) x_l, y_l = next(labeled_iter) # 无标签batch(一个原图batch,后续分别做弱增强和强增强) try: x_u, _ = next(unlabeled_iter) except StopIteration: unlabeled_iter = iter(unlabeled_loader) x_u, _ = next(unlabeled_iter) x_l, y_l = x_l.cuda(), y_l.cuda() x_u_w = weak_augment(x_u).cuda() # 弱增强:翻转、平移 x_u_s = strong_augment(x_u).cuda() # 强增强:RandAugment、Cutout # 有监督损失 logits_l = model(x_l) loss_sup = F.cross_entropy(logits_l, y_l) # 无监督损失 with torch.no_grad(): logits_u_w = model(x_u_w) probs_u_w = torch.softmax(logits_u_w, dim=1) max_probs, pseudo_labels = torch.max(probs_u_w, dim=1) mask = (max_probs >= threshold).float() # 置信度掩码 logits_u_s = model(x_u_s) loss_unsup = F.cross_entropy(logits_u_s, pseudo_labels, reduction='none') loss_unsup = (loss_unsup * mask).mean() # 总损失 loss = loss_sup + lambda_u * loss_unsup optimizer.zero_grad() loss.backward() optimizer.step() if step % 100 == 0: print(f"Step {step}: sup={loss_sup.item():.4f}, " f"unsup={loss_unsup.item():.4f}")这段代码里有几个细节值得说道说道:无监督损失计算的时候,伪标签生成和强增强预测必须是"分离"的——伪标签要在torch.no_grad()下生成,否则梯度会顺着伪标签路径反传到弱增强分支,干扰训练;而强增强分支的预测需要梯度,这样才能把一致性信号传回模型。这两种状态的分隔是最容易写错的地方。
另外,mask用float类型而不是bool类型来计算,是为了后续mean()操作的数值稳定性。如果用bool mask配合mean(),可能导致分母为0的时候出现NaN,尤其在训练初期置信度普遍较低的情况下。我一开始没注意这个细节,出现过训练几个step就爆NaN的情况,排查了半天才发现是mask统计的问题。
3.4 关键超参数怎么调
FixMatch里最关键的三个超参数是:置信度阈值(threshold)、无监督损失权重(lambda_u)、强弱增强策略。
置信度阈值默认0.95,这个值在大多数情况下可以先用。如果训练过程中发现无监督损失很小但验证集精度上不去,可能是阈值太低导致伪标签噪声太大;如果无监督损失一直很高,则可能是阈值太高导致有效样本太少。我习惯每500步打印一次当前batch中通过mask的有效样本比例,这个比例能直观反映阈值的合理性。有效样本比例在20%到60%之间是比较健康的区间,低于10%就该考虑降低阈值了。
lambda_u控制无监督损失对总损失的贡献。默认1.0是个安全的起点,但要注意:当有标签数据特别少(比如每类只有几十张)的时候,有监督损失很快收敛到很小,无监督损失会主导整个训练过程,这时候模型可能被一致性约束带偏,对分类边界过度平滑。我的做法是先用固定lambda_u=1.0训练一轮,观察训练曲线,如果无监督损失占比过高导致有监督损失回升,再把lambda_u降到0.5甚至0.3。
增强策略方面,弱增强我通常固定为随机水平翻转和随机平移,强增强用RandAugment,强度系数根据数据集规模调整。有个小技巧:强增强的强度系数不要一开始就拉满。数据集小的时候,过强的增强会让模型看到大量畸变样本,学习反而变慢。建议先用默认强度,观察无监督损失的变化趋势,如果损失下降过快且验证精度同步上升,再逐步加强增强幅度。
4. 常见问题与排查技巧实录
4.1 伪标签噪声过大怎么办
训练到中后期,伪标签的噪声会逐渐累积,这是SSL最常见的翻车点。表现特征是:验证集精度先升后降,或者训练过程中分类准确率出现周期性波动。
我排查噪声问题有三板斧:第一板斧是检查置信度阈值的有效性,可以用上面提到的有效样本比例来监控;第二板斧是检查无标签数据的分布,如果某个类别在无标签集中占比极小,模型对这个类别的伪标签质量会非常差;第三板斧是直接抽样可视化,把模型预测错误但置信度很高的样本打印出来看看,如果错误样本集中在特定类别上,说明模型对某些类别存在系统性偏差。
针对系统性偏差,一个有效的补救措施是类别自适应阈值。与其全局统一用0.95,不如按类别分别统计置信度分布,每类单独设阈值。实现不复杂,就是在每个epoch结束时,记录各类别通过阈值筛选的样本数和平均置信度,如果某个类别的平均置信度显著低于整体水平,就把这个类别的阈值调低0.05到0.1。这个方法在我的一个六分类项目里,把最终精度提升了1.7个百分点。
4.2 训练崩溃与退化解
半监督训练最让人头疼的问题就是"退化解":模型把所有无标签样本都预测成同一个类别,无监督损失降得很低,但验证精度直线下降。原因在于一致性正则约束下,模型找到了一个"偷懒"的解决方案——把所有样本的特征都映射到同一个区域,这样不管怎么增强,预测都一致,损失自然最小。
我遇到退化解时,第一反应是检查是不是学习率过大导致特征空间被粗暴压缩。把学习率降到原来的三分之一,重新训练,通常能缓解。如果降学习率无效,那就检查伪标签的类别分布——如果某个类别的伪标签占比超过60%,说明模型已经严重偏向这个类别了。
另一个有效手段是给无监督损失加一个熵正则项。具体做法是在总损失里加上-entropy(probs_u_w)这一项,惩罚模型对无标签数据的预测过于"自信"的极端情况。这相当于鼓励模型对无标签数据保持一定的不确定性,防止特征坍缩。
4.3 类别不均衡的陷阱
半监督学习对类别不均衡特别敏感,原因很微妙:有标签数据里的类别比例会直接影响初始模型的预测偏好,而初始模型的偏好又决定了伪标签的生成质量,最终导致无标签数据被"劫持"成有偏分布。
举个例子,有标签数据里类别A占70%,类别B占15%,类别C占15%。初始模型对A的预测置信度天然偏高,导致无标签数据里A类的伪标签通过率远高于B和C,训练越往后,模型对A越熟练,B和C越被忽视,形成恶性循环。
解决这个问题,我的经验有两条:一是从数据侧入手,对有标签数据做类别重采样,确保每个batch里类别平衡;二是从伪标签侧入手,监控伪标签的类别分布,如果某类占比过高,用上文提到的类别自适应阈值来压制。另外,训练结束时不要只看总体准确率,一定要分三类单独看recall,类别不均衡造成的性能塌陷往往藏在细分指标里。
4.4 评测与验收的注意事项
半监督项目的评测很容易掉进两个坑:一个是拿有标签训练集的精度当作最终效果,另一个是只用总体精度评估模型。
先说第一个坑。SSL训练过程中,有标签训练集上的精度通常涨得很快,因为模型对这批数据已经"死记硬背"了。真正能反映模型能力的指标,是在一个独立验证集上的表现。建议训练过程中每500步在验证集上测一次精度,同时记录无监督损失和有监督损失,三个指标一起看才不容易被单一指标误导。
第二个坑是忽视类别级别的评估。半监督学习的一个隐藏风险是:总体精度可能提升,但某几个困难类别的精度反而下降了。因为在没有标签监督的情况下,模型会把困难类别的样本"归并"到容易类别里。所以验收的时候一定要看混淆矩阵,特别关注那些容易混淆的类别对。我有个习惯,最终验收时不仅看验证集整体精度,还要对比SSL训练前后每类精度的变化,确保性能提升不是以牺牲困难类别为代价的。
最后再分享一个小技巧:SSL训练完的模型,不要直接拿去部署。我之前在自己数据集上做过对比,把SSL训练过的模型在纯有标签数据上做一轮短微调,最终推理精度高于直接用SSL模型。原因可能是SSL训练过程中的一致性约束对模型做了一定程度的"正则化",微调阶段去除这种约束,让模型在有标签数据上再校准一遍决策边界,效果会更好。微调用学习率1e-4,训练100个step就够了,成本很低,收益却很实在。
半监督学习这个方向,实操中踩坑的体验比看论文深刻得多。我在实际使用中最大的感受是:它不是一个"数据不够时的作弊器",而是一套需要仔细设计训练策略的工程方案。每次调阈值、调增强策略、观察损失曲线,都是在跟数据的分布做博弈。如果你正卡在标注成本上,建议先拿FixMatch跑通基线,再用本文提到的手段逐步优化,大概率能在不追加标注的前提下,再榨出几个点的精度提升。