3、数据层面方法(上):过采样(Oversampling)——复制尾部样本的利与弊
3.1 先聊聊我为什么开始关注过采样
做目标检测的人,十有八九都遇到过类别不平衡的问题。我记得刚入行那会儿,接了个工业质检的项目,要检测十几种缺陷。跑了一版模型上去,精度还行,但一看混淆矩阵——好家伙,头部那两三类占了90%的样本,尾部那些小缺陷几乎全漏检了。
当时我第一反应是:加数据呗。但现实很骨感,尾部类别的样本本来就少,去现场采集又费时费力。于是我开始研究数据层面的方法,说白了就是——在不增加新数据的前提下,怎么让模型「多看几眼」那些稀缺类别。
过采样(Oversampling)就是最直接的一招。你想想看,既然尾部样本少,那我就把它们复制几份,塞回训练集里。听起来简单粗暴,但实际用起来,坑可不少。
3.2 过采样的核心思路
过采样的本质,就是人为地增加少数类样本的数量,让模型在训练时能「平等地」看到每个类别。具体做法分两种:
- 随机过采样:从尾部类别中随机抽取样本,直接复制多份。
- 针对性过采样:不是盲目复制,而是选择那些「有代表性」或「难分」的尾部样本进行复制。
在目标检测里,过采样不是简单地复制整张图,而是复制图像中的某个目标实例。比如一张图里有10个目标,其中9个是「猫」,1个是「老鼠」。我们就把那个「老鼠」的标注框连同它周围的图像区域一起复制,然后粘贴到其他图像中。
关键点:过采样操作的是「实例级别」,不是「图像级别」。复制整张图会导致背景重复,模型容易过拟合。
3.3 我在项目中用过的几种过采样方式
3.3.1 最简单的:随机实例复制
这个方法我最早在自动驾驶场景里试过。行人、车辆这些类别样本充足,但「交通锥」和「施工标志」这类小物体少得可怜。我的做法是:
# 伪代码示意
def random_oversample(annotations, tail_classes, oversample_ratio=3):
new_annotations = []
for ann in annotations:
if ann['class'] in tail_classes:
# 复制 oversample_ratio 份
for _ in range(oversample_ratio):
new_annotations.append(copy.deepcopy(ann))
return annotations + new_annotations
效果立竿见影,尾部类别的召回率从20%提到了50%左右。但问题也来了——模型开始对「交通锥」的位置产生记忆,因为复制的样本总是出现在相似的背景里。
我的经验:随机复制时,最好把复制后的实例粘贴到不同的背景图像中。我后来写了个脚本,把尾部实例抠出来,随机贴到训练集的其他图像上,效果好了不少。
3.3.2 进阶版:基于难度的过采样
随机复制有个明显的缺点——它不管这个样本是「容易」还是「难」。你想想看,如果复制的是那些模型已经能很好识别的样本,那意义不大。真正需要加强的是那些「难例」。
我曾经在一个遥感图像检测项目里试过这种方法:
- 先用初始模型跑一遍训练集,记录每个尾部样本的损失值。
- 损失值高的样本,说明模型当前还搞不定,这些就是「难例」。
- 对这些难例进行过采样,复制比例按损失值排序分配。
# 按损失值分配复制次数
def difficulty_based_oversample(loss_dict, tail_classes, max_ratio=5):
for cls in tail_classes:
losses = loss_dict[cls]
# 归一化损失值到 [1, max_ratio]
ratios = normalize(losses) * (max_ratio - 1) + 1
# 按 ratios 复制对应样本
...
这个方法比随机复制更「聪明」,但也更复杂。你得先跑一轮模型才能拿到损失值,相当于多了一步预处理。
3.4 过采样的「利」——为什么大家都爱用
| 优势 | 说明 |
|---|---|
| 实现简单 | 不需要生成新数据,复制粘贴就行,代码量很少 |
| 效果直接 | 尾部类别的样本量上去了,召回率通常会有明显提升 |
| 不依赖外部数据 | 完全在现有数据集内操作,不需要额外采集或生成 |
| 可解释性强 | 你很清楚自己做了什么——就是让模型多看几遍尾部样本 |
说白了,过采样是「性价比」很高的方法。我经常在项目初期先用它快速验证,看看尾部类别的潜力有多大。如果过采样都救不回来,那可能就得考虑更复杂的方法了。
3.5 过采样的「弊」——那些年我踩过的坑
警告:过采样不是万能的,用不好反而会拖后腿。我曾经在一个项目中吃过亏,下面这几个坑你一定要留意。
3.5.1 过拟合风险
这是最明显的问题。你复制了尾部样本,模型就会反复看到同样的图像区域。久而久之,它开始「记住」这些样本,而不是「学习」它们的通用特征。
我曾经做过一个实验:对某个尾部类别过采样了10倍,结果在验证集上这个类别的AP确实涨了,但换了一个测试集,AP直接掉了一半。这就是典型的过拟合——模型只认得训练集里的那几个样本。
3.5.2 类别失衡加剧
听起来有点反直觉,对吧?过采样本来是为了平衡类别,怎么反而会加剧失衡?
原因在于:过采样只增加了尾部样本的数量,但头部样本的「多样性」远高于尾部。你复制了10个尾部样本,它们还是那10个不同的实例。而头部样本可能有1000个不同的实例。模型在训练时,虽然尾部样本的「出现次数」上去了,但「多样性」依然不足。
结果就是:模型对尾部样本的判别边界变得非常「窄」,稍微有点变化就认不出来了。
3.5.3 训练效率下降
这个坑我是在一次大规模训练中发现的。过采样之后,训练集的总样本量变大了,每个epoch的训练时间也变长了。但问题是——增加的样本都是重复的,对模型收敛的帮助有限。
说白了,你花了更多时间训练,但收益却在递减。我建议过采样的倍数不要超过3-5倍,再往上性价比就很低了。
3.6 我的避坑指南
嗯,这里我总结几条实战经验,都是真金白银换来的教训:
- 过采样倍数控制在3倍以内:超过这个数,过拟合风险急剧上升。如果3倍还不够,建议结合其他方法(比如数据增强)。
- 配合数据增强使用:复制后的样本一定要做数据增强(随机裁剪、颜色抖动、旋转等),增加多样性。我习惯在复制后立即做一次随机增强。
- 不要对所有尾部类别一视同仁:不同尾部类别的「难易程度」不同。我建议先跑一轮基线模型,看看哪些类别表现最差,再针对性地过采样。
- 验证集要保持原始分布:过采样只改训练集,验证集和测试集不要动。否则你评估出来的指标是「假的」,上线后准翻车。
我曾经踩过的坑:有一次我忘了把验证集分开,结果过采样后的验证集里也混入了复制样本。模型在验证集上表现「完美」,一上线就露馅了。从那以后,我每次做数据预处理都会单独写一个脚本,确保验证集「干净」。
3.7 什么时候该用,什么时候不该用
最后说说我的判断标准。过采样不是银弹,它适合的场景和不适合的场景都很明确:
| 适合用 | 不适合用 |
|---|---|
| 尾部类别样本量极少(几十到几百张) | 尾部类别样本量已经上千,但头部更多 |
| 类别数量不多(10类以内) | 类别数量很多(上百类),过采样会导致训练集膨胀 |
| 数据增强手段丰富,能增加多样性 | 数据增强手段有限,复制后样本高度相似 |
| 项目初期快速验证 | 已经接近上线,需要追求极致精度 |
我个人习惯是:先用过采样快速试水,如果效果不错,再考虑更精细的方法(比如后面要讲的类别重加权或合成样本)。如果过采样效果不好,那说明问题可能不在数据量上,而是特征本身就不够区分——这时候就该从模型结构或特征提取入手了。
下一节我们会聊过采样的「进阶版」——合成样本方法,比如SMOTE在目标检测里的变体。到时候再细聊怎么在「复制」和「生成」之间做选择。