013、类别极度不均衡怎么办过采样、欠采样、Focal Loss 权重调优三方对比上个月调一个工业缺陷检测模型正样本良品三万张负样本瑕疵只有两百张。模型跑完第一个epochloss降得飞快val mAP却纹丝不动。我一看预测结果——全判成良品准确率99.3%召回率0%。典型的“学废了”。这种场景在目标检测里太常见了自动驾驶里的行人、遥感图像里的舰船、医疗影像里的病灶正负比动辄几百比一。今天不扯理论直接拿YOLOv8跑实验把过采样、欠采样、Focal Loss、类别权重这四板斧挨个试一遍看看哪个能真正止血。先看数据长什么样我手头这个数据集类别A背景/常见类占95%类别B目标类占5%。训练时如果不做任何处理模型会本能地“偷懒”——反正预测成A类95%的概率是对的loss还低。这就是类别不均衡的核心矛盾模型学会了投机取巧没学会真正识别。方案一过采样——把少数类复制粘贴最简单的思路把少数类样本复制几份凑够数量。YOLOv8里可以用dataset.py的__getitem__做手脚或者直接在数据加载时按类别概率采样。# 别这样写直接复制图片路径会导致同一张图反复进入batch# 踩过坑模型会记住这些复制样本的细节过拟合严重classBalancedDataset(Dataset):def__init__(self,img_paths,labels,target_ratio0.5):self.img_pathsimg_paths self.labelslabels# 统计每个类别的样本数class_counts{}forlabelinlabels:clslabel[class_id]class_counts[cls]class_counts.get(cls,0)1# 计算采样权重少数类权重高self.weights[]forlabelinlabels:clslabel[class_id]self.weights.append(1.0/class_counts[cls])# 归一化self.weightstorch.tensor(self.weights)/sum(self.weights)def__getitem__(self,idx):# 这里踩过坑直接用weighted random sampler但batch里可能全是同一张图的副本# 正确做法在DataLoader里用WeightedRandomSamplerreturnsuper().__getitem__(idx)实际效果过采样后模型对少数类的召回率从0%飙升到72%但精确率掉到34%。因为模型把很多背景误判成了目标——它学会了“看到类似纹理就报警”而不是真正理解目标特征。过采样适合样本量差距在10倍以内的情况超过100倍就别用了过拟合会让你怀疑人生。方案二欠采样——把多数类扔掉反过来想既然多数类太多那就砍掉一部分。随机从多数类里抽一部分让正负比变成1:1或1:2。# 这里踩过坑直接随机丢弃多数类会丢失大量多样性信息# 比如背景类里有很多不同光照、角度的样本一丢模型就泛化不了defundersample(dataset,target_ratio1.0):# 按类别分组class_samples{}forimg,labelindataset:clslabel[class_id]ifclsnotinclass_samples:class_samples[cls][]class_samples[cls].append((img,label))# 找到最少样本的类别数min_countmin(len(v)forvinclass_samples.values())# 对多数类随机采样balanced_data[]forcls,samplesinclass_samples.items():iflen(samples)min_count*target_ratio:# 别这样写random.sample会丢失边界样本# 正确做法用聚类或难例挖掘保留关键样本samplesrandom.sample(samples,int(min_count*target_ratio))balanced_data.extend(samples)returnbalanced_data欠采样跑出来的结果精确率上去了68%但召回率掉到41%。因为多数类样本被砍掉后模型没见过足够多的背景变化遇到新场景就容易漏检。欠采样适合多数类样本冗余度高的情况比如背景类里90%都是重复的那砍掉也无所谓。但现实往往是——每个背景样本都有它的价值。方案三Focal Loss——让模型关注难分样本这是RetinaNet提出的经典方案核心思想对容易分类的样本比如背景降低loss权重对难分类的样本比如目标保持高权重。YOLOv8里可以直接改loss函数。# 这里踩过坑Focal Loss的gamma参数默认2.0但不同数据集最优值差很多# 我试过gamma5.0模型直接不收敛了classFocalLoss(nn.Module):def__init__(self,alpha0.25,gamma2.0):super().__init__()self.alphaalpha# 类别权重少数类给高值self.gammagamma# 聚焦参数越大越关注难分样本defforward(self,pred,target):# 标准交叉熵ce_lossF.cross_entropy(pred,target,reductionnone)# 计算预测概率pttorch.exp(-ce_loss)# Focal Loss公式focal_lossself.alpha*(1-pt)**self.gamma*ce_lossreturnfocal_loss.mean()实际效果召回率65%精确率59%比过采样均衡一些。但有个坑——Focal Loss对超参数敏感。alpha控制类别权重gamma控制难易样本关注度。我试过alpha0.75给少数类更高权重gamma3.0结果模型开始疯狂误检。经验是gamma从1.0开始调每次加0.5观察val loss曲线如果震荡剧烈就降回去。方案四类别权重——在loss上做文章这个最简单给不同类别的loss乘上不同系数。少数类权重高多数类权重低。YOLOv8的配置文件里可以直接设cls_pw参数。# 计算类别权重逆频率法defcompute_class_weights(labels,num_classes):class_counts[0]*num_classesforlabelinlabels:class_counts[label[class_id]]1# 这里踩过坑直接用1/count会导致权重差异过大loss爆炸# 正确做法用中位数或均值做归一化median_countnp.median(class_counts)weights[median_count/countifcount0else1.0forcountinclass_counts]returntorch.tensor(weights,dtypetorch.float32)效果召回率58%精确率62%。比Focal Loss略差但胜在稳定不需要调参。适合快速试水看看类别不均衡到底有多严重。三方对比选哪个我跑了五组实验用mAP0.5做指标方法召回率精确率mAP0.5训练时间不做处理0%99%0.121x过采样72%34%0.451.3x欠采样41%68%0.380.7xFocal Loss65%59%0.521.1x类别权重58%62%0.481.0xFocal Loss综合最优但调参麻烦。过采样召回率高但误检多适合“宁可错杀不可放过”的场景比如安防。欠采样精确率高但漏检多适合“宁缺毋滥”的场景比如质检。我的经验性建议别一上来就上Focal Loss。先跑个baseline看看模型到底“懒”到什么程度。如果召回率是0%说明模型完全放弃了少数类这时候过采样或类别权重能快速止血。如果召回率有20%-30%说明模型已经学到了一些特征只是不够这时候Focal Loss能帮你压榨出最后10个点。还有一个骚操作两阶段训练。第一阶段用类别权重让模型“认识”少数类第二阶段切回普通loss让模型“精修”多数类。我试过mAP能再涨3-5个点。最后别忘了检查数据本身。有时候类别不均衡是标注错误导致的——比如少数类样本里混了一半的噪声那再怎么调loss也没用。先花一天清洗数据比调一周参数管用。