018、困难样本挖掘策略:训练中自动发现易错样本,定向补充标注
018、困难样本挖掘策略:训练中自动发现易错样本,定向补充标注
去年秋天我在调试一个工业质检项目,模型在产线上跑了一周,漏检率始终卡在0.3%下不去。翻看日志发现,那些漏掉的缺陷样本几乎全是同一个类型——边缘模糊的划痕,标注框里只有几个像素宽。我盯着检测结果看了半小时,突然意识到一个残酷的事实:训练集里这类样本太少了,模型根本没见过足够多的“难例”。
这就是困难样本挖掘(Hard Example Mining)要解决的问题。不是所有样本对模型提升都有同等价值,那些让模型“犹豫不决”的样本,才是真正能推动性能边界的关键。
从损失函数里“抓”出困难样本
困难样本挖掘的核心思路很简单:在训练过程中,让模型自己告诉我们哪些样本它学得不好。最直接的做法就是看损失值——损失大的样本,就是模型当前阶段搞不定的。
我在YOLOv8里实现过一个粗糙版本,代码大概长这样:
# 别这样写,这只是演示思路defhard_example_mining(batch_losses,ratio=0.3):# 按损失降序排列,取前30%sorted_indices=torch.argsort(batch_losses,descending=True)hard_indices=sorted_indices[:int(len(batch_losses)*ratio)]returnhard_indices这里踩过坑:直接按损失排序取top-k,会导致训练初期大量背景样本被选进来。因为模型刚开始啥都认不出来,背景区域的损失反而最大。正确的做法是只对正样本(包含目标的区域)计算损失排序,或者至少给正负样本分别设置不同的采样比例。
OHEM:在线困难样本挖掘的经典实现
OHEM(Online Hard Example Mining)是目标检测领域的老牌方法,核心思想是让模型先跑一次前向传播,找出损失最大的那些样本,然后只在这些样本上做反向传播。
在YOLOv6里集成OHEM时,我踩过一个坑:OHEM要求两次前向传播,第一次只做推理不更新梯度,第二次才在选出的困难样本上训练。这会带来显存翻倍的问题。我的解决方案是共享特征图——第一次前向传播时把中间特征缓存下来,第二次直接复用。
# 伪代码,实际实现要处理batch维度defohem_forward(model,images,targets):# 第一次前向:只算损失,不更新梯度withtorch.no_grad():features=model.extract_features(images)losses=model.compute_loss(features,targets)# 按损失排序,选出困难样本索引_,hard_indices=torch.topk(losses,k=int(len(losses)*0.3))# 第二次前向:只在困难样本上训练hard_images=images[hard_indices]hard_targets=[targets[i]foriinhard_indices]hard_features=model.extract_features(hard_images)loss=model.compute_loss(hard_features,hard_targets)loss.backward()这里有个细节容易被忽略:OHEM的采样比例不是固定的。我试过0.1到0.5之间的各种比例,发现0.25左右效果最好。比例太小,模型学不到足够多的难例;比例太大,又退化成全量训练。
Focal Loss:让模型自己“关注”困难样本
OHEM需要显式的采样操作,而Focal Loss是一种更优雅的隐式方案。它通过修改损失函数,让模型自动给困难样本分配更大的梯度权重。
Focal Loss的公式看起来简单,但调参是个技术活:
deffocal_loss(pred,target,gamma=2.0,alpha=0.25):# gamma控制困难样本的关注程度# alpha平衡正负样本ce_loss=F.binary_cross_entropy_with_logits(pred,target,reduction='none')pt=torch.exp(-ce_loss)focal_weight=(1-pt)**gammaifalphaisnotNone:alpha_weight=target*alpha+(1-target)*(1-alpha)focal_weight=focal_weight*alpha_weightreturn(focal_weight*ce_loss).mean()我在YOLOv11上试过Focal Loss,发现gamma=2.0对大多数场景都够用,但有个例外:当你的数据集里困难样本占比特别高(比如超过40%),gamma反而要调低到1.5左右。因为gamma太大,模型会过度关注那些极难样本,反而忽略了中等难度的样本——这些样本才是提升泛化能力的关键。
动态阈值策略:让挖掘过程自适应
固定比例的困难样本挖掘有个问题:训练初期和后期,模型的“困难”标准完全不同。初期可能所有样本都难,后期可能只有极少数样本难。固定比例会导致后期选进来的样本其实并不难。
我后来在YOLOv8里实现了一个动态阈值策略,效果比固定比例好不少:
classAdaptiveHardMining:def__init__(self,initial_ratio=0.3,decay_factor=0.95):self.ratio=initial_ratio self.decay_factor=decay_factor self.loss_history=[]defupdate_ratio(self,current_losses):# 记录最近N个batch的平均损失self.loss_history.append(current_losses.mean().item())iflen(self.loss_history)>100:self.loss_history.pop(0)# 如果平均损失持续下降,说明模型在变好,可以降低采样比例iflen(self.loss_history)>=10:recent_avg=np.mean(self.loss_history[-10:])old_avg=np.mean(self.loss_history[-20:-10])ifrecent_avg<old_avg*0.9:self.ratio=max(0.1,self.ratio*self.decay_factor)# 根据当前比例计算阈值threshold=np.percentile(current_losses.cpu().numpy(),(1-self.ratio)*100)returnthreshold这个策略的核心逻辑是:模型学得越好,需要挖掘的困难样本就越少。但要注意,decay_factor不能设得太小,否则采样比例下降太快,模型会错过一些潜在的难例。
数据层面的补充标注策略
困难样本挖掘不只是训练时的技巧,它还能指导数据标注。我在项目中做过一个“主动学习”流程:
- 用当前模型对未标注数据做推理
- 选出置信度在0.3-0.7之间的样本(这些是模型最不确定的)
- 把这些样本交给标注员补充标注
- 用新标注的数据继续训练
这个流程跑三轮,标注效率提升至少3倍。因为标注员不再需要标注那些模型已经能搞定的简单样本,只聚焦在模型搞不定的难例上。
个人经验总结
写了这么多,说点实在的。困难样本挖掘不是银弹,它解决的是“样本分布不均衡”的问题。如果你的数据集本身质量很高、分布均匀,强行上困难样本挖掘反而可能破坏训练稳定性。
我的建议是:先用全量数据训练一个baseline,然后分析错误样本的类型。如果错误集中在某几类样本上,再针对性地做困难样本挖掘。别一上来就上OHEM或Focal Loss,先搞清楚问题出在哪。
另外,困难样本挖掘和模型结构是耦合的。YOLOv6的RepVGG结构对OHEM比较友好,因为它的梯度传播更稳定;而YOLOv11的C2f结构配合Focal Loss效果更好。这个没有标准答案,得自己试。
最后提醒一句:困难样本挖掘会增加训练时间,OHEM大概增加30%-50%,Focal Loss基本不增加。如果你的项目对训练速度敏感,优先考虑Focal Loss。
