☰
YOLOv5目标检测样本不均衡解决方案:数据增强与损失函数调优实战
2026/10/20 5:25:48 网站建设 项目流程

目标检测里最怕的从来不是模型选不对,而是数据本身就不给你“公平”的机会。这句话在牛仔行头检测这个项目上体现得淋漓尽致。上篇我们把一批西部风格的服饰素材整理成训练集,用YOLOv5跑通了baseline,整体mAP@0.5到了0.72,看着是能出框了,但把检测结果逐类一拉,问题就全都浮出来了——领巾这类小目标经常漏检,皮靴和腰带扣一旦凑在一起就张冠李戴,牛仔衬衫和马甲更是因为纹理相似互相误判。说白了,就是样本不均衡在捣乱。

这次我把完整解决过程整理出来,包括数据侧怎么做过采样和复制粘贴增强、损失函数侧怎么加类别权重和Focal Loss、评估侧怎么按类别拆AP找短板,以及最后是怎么通过消融实验一步步把mAP拉上去的。整个过程都是用YOLOv5做baseline,换backbone也可以参考,适合正在做多类别细粒度检测、被少数类拖后腿的朋友。下面直接进入正题,从问题定位开始讲。

1. 问题定位:牛仔行头数据集的不均衡到底有多严重

1.1 先量化,再谈方案

做检测项目最忌讳一拍脑袋就说“我的数据不均衡”,然后直接上一堆增强策略。我拿到手上的这批牛仔行头素材,一共标注了六类:牛仔帽、牛仔衬衫、马甲、领巾、皮靴、皮带扣。标注框总数大概有一万八千多个,乍一看数量不少,可一旦拆到每个类别上,差异就很扎眼了。

我统计的时候,先按类别数了标注框数量,再计算每类框的平均宽高比。具体的统计结果如下表:

类别标注框数量占比平均宽高比
牛仔帽620034%1.05
牛仔衬衫480026%0.72
皮靴310017%1.86
马甲220012%0.68
皮带扣11506%1.12
领巾8505%0.58

领巾的框数量只有牛仔帽的七分之一左右,这是典型的“长尾分布”。更麻烦的是,领巾不仅数量少,平均宽高比只有0.58,也就是细长条形的目标,在检测里属于难样本的典型特征——面积小、纹理弱、容易被其他衣物遮挡。皮带扣虽然数量比领巾多一点,但它是金属材质,反光明显,在不同光照下外观差异很大,也容易漏检。

把这些数字摆在桌上,问题就清晰了:少数类的绝对数量不足,加上目标本身的视觉难度高,模型根本没有足够的正样本来学习“什么是领巾”“领巾长什么样”。你光调模型结构是没用的,得先让模型见够这些类别。

1.2 不均衡对检测模型的三重打击

样本不均衡在目标检测里不是简单“某类准确率低”一句话能概括的,它会在训练过程和推理结果上同时产生三类负面影响,这也是我这次踩坑后的核心体会。

第一类是训练梯度被多数类主导。YOLOv5默认使用BCEWithLogitsLoss作为分类损失,所有类别的loss直接求和回传梯度。如果牛仔帽的预测错误有1000个,领巾的预测错误只有50个,那模型在梯度下降时自然会把“改正帽子错误”放在优先位置,领巾的错误信号被淹没在整体loss里,学不学得到完全看运气。

第二类是正负样本比例严重失衡。目标检测的每个anchor都会被划分为前景或背景,而真正包含目标的anchor只是极小一部分。这里说的不均衡还叠加了“类别内不均衡”:领巾的数量少,所以负责检测领巾的正样本anchor更少,模型很难学到该在哪个位置激活。

第三类是评估指标被“平均”掩盖。mAP是各类AP的平均值,多数类表现好可以把整体mAP拉高,让你以为模型还不错。但一旦拆开看,很可能牛仔帽AP是0.85,领巾AP只有0.24,这种“被平均的美好”会误导后续判断。所以第一件事就是建立一个能细看每个类别的评估流程。

这也是为什么我在后面所有的实验里,不再只用mAP@0.5一个数字汇报结果,而是固定输出每个类别的AP和F1,这样才能看清改动到底是帮了谁、伤了谁。

2. 数据侧均衡:让少数类在训练中“多露脸”

2.1 复制粘贴增强的落地细节

数据增强是缓解样本不均衡最直接的手段,但前提是“增得对”。我试过简单地做翻转、旋转、HSV扰动这些基础增强,对多数类没什么影响,对领巾这种细长条目标帮助有限——因为样本本身的语义多样性没有增加,模型看到的还是那几张图在变来变去。

后来我换成了复制粘贴增强(Copy-Paste),思路很简单:把少数类的实例从原始图片里抠出来,粘贴到其他训练图片上,同时让位置、尺度和背景都合理。这个做法对于服饰类目标格外好用,因为牛仔行头是“穿在人身上”的,而人的姿态和拍摄场景在数据集里本来就多种多样,把一件领巾贴到另一张有人物的图片上,看起来很自然。

具体我分三步做:

第一步,预处理阶段把所有少数类实例的mask保存下来。如果你用的是LabelMe或CVAT这类工具,导出时是polygon格式,可以直接通过polygon转mask获得实例分割掩码。如果手里只有矩形框标注,也没关系,可以按框中心裁剪出一块包含目标的区域,后续再用边缘检测或简单的颜色分割把前景抠出来。

第二步,选择粘贴的宿主图。这里有个容易被忽视的细节——宿主图里最好已经有该类的上下文。比如领巾通常出现在颈部区域,如果把领巾贴在纯背景或腰部,模型学到的是“领巾可以在任何位置出现”,反而引入噪声。所以我会给每类目标定义一组“合理粘贴区域”,比如领巾贴在脖子附近,皮带扣贴在腰带附近,皮靴贴在腿部末端。这个步骤需要人工标注一些anchor位置,但工作量不大,因为每个类别只需要定义一次规则。

第三步,对粘贴的目标做随机缩放、旋转、水平翻转,缩放范围控制在0.6到1.3倍之间,旋转角度不要超过正负30度。这个限制很关键,服饰类目标一旦旋转过大或者拉伸过狠,人眼都很难认出是领巾,模型学到的特征就歪了。

# 伪代码示例:复制粘贴增强流程 def copy_paste_augment(src_img, src_mask, dst_img, paste_position, scale_range=(0.6, 1.3), max_angle=30): # 1. 根据mask裁剪前景 foreground = extract_foreground(src_img, src_mask) # 2. 随机尺度与旋转 scale = random.uniform(*scale_range) angle = random.uniform(-max_angle, max_angle) foreground = resize_and_rotate(foreground, scale, angle) # 3. 计算粘贴区域并融合(支持透明度混合) x, y = paste_position blended = alpha_blend(dst_img, foreground, x, y) # 4. 同步更新标注框 new_bbox = compute_bbox_from_mask(foreground.mask, x, y) return blended, new_bbox

只做了一遍复制粘贴,领巾的框数量就从850个涨到了2600个左右,数据分布看着舒服了很多。

2.2 图像级采样权重调整

复制粘贴增强解决了“样本数量不够”的问题,还有一个问题是“包含少数类的图像不够”。即使经过增强,一批数据里有多少张图真正包含领巾,直接决定了模型在一个batch里能看到几次领巾。所以我同时在dataloader层面做了图像级过采样。

YOLOv5的DataLoader是每次从数据集中随机采样,我改成给每张图设置一个采样权重。包含少数类的图片权重提高,不包含的权重降低。具体权重我一开始用的是“逆频率”策略:包含领巾的图片权重设为其它图片的3倍,包含皮带扣的设2倍。但这个倍数不能拍脑袋定,我后来是通过验证集上每类recall的变化来调的,谁掉下去了就把谁的权重加一点,慢慢稳定下来。

这里有一个实际的坑要提醒:图像级过采样会让模型反复看到同一批包含少数类的图片,如果叠加复制粘贴增强,模型很容易对某些特定的粘贴位置产生记忆,出现“伪过拟合”现象。所以我的做法是过采样和复制粘贴同时开,但把粘贴的随机性调大一点,让同一张图每次经过dataloader时产生的样本都不一样。

2.3 增强强度的权衡与陷阱

数据侧增强不是越猛越好,我在这轮实验中踩了两个坑,都值得说一说。

第一个坑是过度粘贴导致“语义污染”。有一版实验里我把领巾的粘贴比例调到了原始数量的四倍,结果发现模型对领巾的recall确实上去了,但precision掉得很厉害。后来看检测结果才明白,有些粘贴到背景上的领巾边缘融合不自然,模型学到的其实是“有红色纹理的东西就是领巾”,而不是真正学会通过形状和佩戴位置判断。简单说,增强样本质量不高,反而让模型学到了假特征。

第二个坑是类别间相似性被增强放大。牛仔衬衫和马甲在某些角度下本来就很像,我为了平衡马甲数量,把马甲的样例大幅复制粘贴,结果模型彻底分不清衬衫和马甲了,混淆矩阵里这两类互相误判的比例暴涨。后来我把马甲的增强倍率降下来,回落到数据比例的1.5倍左右,情况才好转。

所以数据增强的合理策略是:少数类的增强倍数宁可在2到3倍这个区间慢慢试,不要一步拉到5倍以上。一边增强一边看验证集的每类F1,找到那根“recall上去了但precision还没掉下来”的平衡线。

3. 损失函数侧均衡:让模型为少数类“多买单”

3.1 类别频率逆权重法

数据侧增强帮模型“多看见”少数类,但训练过程中每个anchor产生的loss依然存在“多头信号压过少数信号”的问题。这种情况下最直接的办法是在损失函数上做文章,给少数类更大的loss权重。

YOLOv5的分类头用的是BCEWithLogitsLoss,支持传入pos_weight参数。这个参数的含义是正样本loss的放大倍数。一种经典做法是取类别频率的倒数作为权重,再做一次归一化。

以我的数据为例:领巾占比5%,那么它的pos_weight可以粗略设为1/0.05=20。但直接用20倍的权重会让训练初期loss抖动太大,容易把整个模型训崩。我更推荐用“软版本”——权重取频率倒数的幂次,通常0.5次方到0.7次方之间比较稳妥。也就是说领巾的权重是sqrt(20)≈4.47,而不是20。

修改YOLOv5的损失函数时,在yolo.py的ComputeLoss类里找到分类损失部分,把原始的BCEWithLogitsLoss换成带pos_weight的版本即可。

# YOLOv5 loss中修改分类损失的参考示例 import torch.nn as nn # 以类别频率倒数的平方根作为权重 class_weights = [1.0, 1.0, 1.2, 1.5, 2.5, 4.5] # 对应六类,顺序自定义 pos_weight = torch.tensor(class_weights, device=device) self.bce = nn.BCEWithLogitsLoss(pos_weight=pos_weight, reduction='mean')

这个改动本身很小,但效果立竿见影。领巾的AP从0.24直接跳到0.35左右,代价是牛仔帽的AP轻微掉了0.02,整体mAP反而涨了。后续我再对权重做微调,把掉的那点补了回来。

3.2 引入Focal Loss改造损失计算

类别权重解决了“类别间不均衡”,但检测里还有另一个层面的不均衡:简单样本和困难样本。很多anchor是背景,模型很容易把它们判对,这部分loss却占据了训练的大部分,挤占了对困难样本的学习空间。这就是Focal Loss解决的场景。

Focal Loss的核心思想是在交叉熵损失前乘一个调制因子(1-p_t)^gamma,其中p_t是模型对该样本预测正确的概率。预测正确概率越高,loss权重被压得越低,模型被迫把注意力转向那些预测不准的样本。

我在YOLOv5里改造损失时,把原来的BCEWithLogitsLoss替换成Focal Loss,同时保留pos_weight机制,两者可以叠加使用。

import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha=0.25, gamma=2.0, reduction='mean'): super().__init__() self.alpha = alpha self.gamma = gamma self.reduction = reduction def forward(self, preds, targets): # preds: [N, C] targets: [N, C] bce_loss = F.binary_cross_entropy_with_logits(preds, targets, reduction='none') pt = torch.exp(-bce_loss) # 预测正确的概率 focal_weight = (1 - pt) ** self.gamma if self.alpha is not None: # alpha_t:对正样本和负样本施加不同的权重 alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets) focal_weight = alpha_t * focal_weight loss = focal_weight * bce_loss if self.reduction == 'mean': return loss.mean() elif self.reduction == 'sum': return loss.sum() return loss

alpha取0.25表示正样本占25%权重、负样本占75%权重。这个参数在目标检测里是常见配置,但具体值也要看你的正负样本比例。YOLOv5一个训练批次里,负样本anchor的数量可能是正样本的几十倍,这种情况下alpha取0.25比取0.5好,因为不给负样本压制太多会让模型把所有anchor都判成背景。gamma取2.0是论文里的默认值,我调过1.5和3.0,2.0在这个场景下收敛速度和最终精度最平衡。

3.3 动态权重策略

静态权重的问题在于,训练过程中各类的学习难度是变化的。训练初期牛仔帽这种类别学得快,领巾学得慢,固定权重会让领巾始终处于“追赶”状态;训练到后期领巾提升了,原本静态的高权重反而会干扰已学会的类别。

我试过一种“动态权重”思路:每训练10个epoch,就在验证集上跑一次每类AP,然后根据“AP越低的类权重越高”的原则更新pos_weight。这个方案有一定效果,但操作起来比较繁琐,尤其训练一跑就是五六十个epoch,中间反复停下验证会拖慢整体节奏。

后来我采用了一种更轻量化的做法:先固定权重训练40个epoch,看一版验证结果,如果发现某个类依然落后,就针对这一类把权重提升50%,再继续训练20个epoch。这个方法看似朴素,但实际效果比动态更新更稳定。原因是动态更新频率太高,权重变化带来的波动会让模型在收敛后期反复震荡,反而得不偿失。

4. 评估体系升级:用正确的指标指导调参

4.1 按类别AP拆解短板

做样本不均衡项目,最怕的就是只盯整体mAP。我在baseline阶段吃过这个亏,那时候mAP看着还行,但把每类AP一打印出来,立马就发现问题了。所以这个项目从第二轮实验开始,我养成了一个习惯:每次验证都要输出一份完整的按类别指标表,不只报均值。

一种最简单有效的办法是修改YOLOv5验证脚本的打印逻辑,在每次验证结束时按类别遍历输出AP、AP50、AP75和F1。

类别AP50(baseline)AP50(增强后)AP50(最终)
牛仔帽0.860.850.87
牛仔衬衫0.780.800.81
皮靴0.720.740.76
马甲0.630.690.71
皮带扣0.410.520.58
领巾0.240.350.49

这张表是整个项目调参的核心依据。哪一类AP掉下去了,立刻就能定位到是哪次改动出了问题。比如后期有一版实验引入马甲增强后,衬衫AP从0.80掉到0.75,看这张表马上就意识到是类别混淆问题,回滚增强并调整倍率后恢复正常。

4.2 PR曲线与置信度阈值调整

除了按类别看AP,我还建议做一件事:把各类的PR曲线拉出来看一眼。AP是PR曲线下的面积,但同样AP下曲线的形状可以完全不同。

领巾这种难样本的PR曲线通常是“低斜率高拉长”的形态,也就是说模型在低置信度时能捞回很多真样本,但误检也一堆;马甲的PR曲线则相对靠外,形状更理想。面对这两种不同形态,你可以在后处理阶段对类别使用不同的置信度阈值,而不是所有类别共用一个。

YOLOv5推理时默认阈值是0.25,我在测试集上调过一次:领巾降到0.15,皮带扣降到0.2,其他类保持0.25。结果领巾的recall提升了大约7个百分点,代价是precision下降了3个百分点。对于服饰检测这种“漏检比误检更影响体验”的场景,这个代价可以接受。

4.3 测试时增强TTA

YOLOv5推理时自带--augment参数,开启后会对输入做多尺度、翻转等增强再取平均预测,效果等于免费获得了一部分“模型集成”。我在最终测试时开着TTA跑了一遍,整体mAP又涨了大概1.5个百分点,其中领巾和皮靴这种小目标受益最明显。

不过TTA也有代价:推理时间大概变成原来的2到3倍。如果项目是实时场景,比如摄像头实时检测行人服饰,TTA基本不实用;如果是离线批量处理图片,建议直接开,收益稳定且改动成本为零。

5. 消融实验:从baseline到最终模型的全过程

5.1 实验配置与训练参数

这轮实验我统一用YOLOv5s作为baseline,输入分辨率640x640,训练120个epoch,前3个epoch用warm-up,初始学习率0.01,权重衰减0.0005。优化器用SGD,momentum等于0.937,batch size设成16。

这个配置跑baseline时显存占用大概7GB左右,用的单卡训练,整体训练时间在2到3个小时之间。因为要反复做消融实验,我没有急着上更大的模型,而是先在s版本上把方案验证清楚,再考虑迁移到m或l。

为保证对比公平,所有实验共用同一套训练集和验证集,只在增强策略和损失函数上做区分。验证集固定为200张包含各类目标的图片,确保每一类都有足够的评估样本。

5.2 逐项消融的结果对比

我按四组配置做了消融实验:

第一组是纯baseline,不加任何均衡策略,目的就是留作对比基准。第二组只加数据侧增强,包括复制粘贴和图像过采样。第三组只改损失函数,加类别权重和Focal Loss。第四组是数据侧加损失侧同时改,也就是最终的完整方案。

四组实验结果汇总如下:

配置mAP@0.5领巾AP50皮带扣AP50训练耗时
Baseline0.720.240.412.2h
仅数据增强0.760.350.522.4h
仅损失改造0.750.320.482.3h
数据增强 + 损失改造0.800.490.582.5h

这个结果有力地说明了一个问题:数据侧和损失侧不是替代关系,而是互补关系。数据增强解决的是“样本不够多”,损失改造解决的是“信号不够强”,两者同时做才能最大程度把少数类从长尾里捞出来。单一方案有效果但有限,叠加方案才会有比较明显的跳变。

5.3 最终模型的实际检测表现

最终模型在验证集上的每类AP已经在上面的表格里展示过了,整体mAP@0.5到了0.80。我另外找了一批没有参与过训练的真实场景图片做了目测验证——包括强光照、背光、多人同框、卧姿、拖影等特殊场景。检测结果基本符合预期,领巾和皮带扣这类原本的难点有了显著改善,但在少数极端场景下依旧会漏检,比如领巾被大衣完全遮住只露出一小条边的情况,这类“不可见的视觉特征”已经超出了单纯样本均衡能解决的范围。

做项目到这个阶段,我的判断是:样本不均衡问题已经基本处理到位,下一步如果再想提升,更适合从更难的方向入手,比如更强的backbone、更细的实例分割标注,或者引入上下文信息的模型结构,而不应该在当前方案里继续盲目堆叠增强策略了。

6. 实操中的几个关键提醒

6.1 先排查标注质量,再谈样本均衡

这是我踩过最深的坑之一。有一版实验里我发现皮带扣的AP怎么都上不去,一开始以为是数据不够,拼命加增强,结果效果不明显。后来我把皮带扣的标注样本翻出来逐张看,发现有不少标注框其实只框了皮带扣的一半,还有几圈金属配饰被错误标成了皮带扣。标注噪声直接干扰了模型学习,这种问题靠增强是解决不了的。

所以在投入精力做均衡之前,我强烈建议先做一轮标注质量抽检。每类至少抽20到30个样本,人工确认:框是否紧贴目标、类别是否正确、是否存在漏标或重标。这一步听起来笨,但性价比极高,经常能发现“模型学不好”的真正原因。

6.2 不要盲目叠加技巧,逐项记录回退点

样本不均衡的解法一抓一大把,但每个技巧之间可能有交互作用。比如我试过把Focal Loss的gamma设为2.0,同时把类别权重调得很高,结果训练中期loss出现明显震荡,后来才意识到两者叠加会让少数类的loss权重过大,训练不稳定。

因此每加一个技巧,我都会在实验记录里记清楚“加了这个东西之后哪个类涨了、哪个类跌了、有没有新的失败案例”。如果发现某个改动让整体效果变差,能快速回退到上一个记录点,而不是在一堆参数里做排除法。对个人开发者来说,这种“可回退”的实验习惯比调参本身更重要。

6.3 类别难度不等于类别不均衡

最后想提醒一点:领巾AP低,不完全是样本数量少导致的。它本身就是一个高难度类别,即使样本数量拉到和牛仔帽一样多,AP大概率也达不到帽子那么高。因为类别难度还取决于目标大小、长宽比、遮挡程度、类间相似性这些因素。

所以在做项目汇报的时候,我会把“样本不平衡修正了多少”和“类别本身的难度上限在哪”分开来说。前者是可以通过数据增强和损失改造持续推进的,后者则需要更大的模型容量,或者更多视角的原始素材来突破。想明白这两者的区别,你才不会被一个“等到了70%准确率怎么都上不去”的项目卡住心态。

对我个人来说,这次项目最值得沉淀的经验并不是某个具体参数调到了多少,而是一套应对长尾分布的稳定流程:先量化分布、再做数据过采样、再改损失权重、最后用按类别指标验收。每一步都有明确目的,不玄学、不盲目。下次遇到任何检测项目里的样本不均衡问题,我都打算沿用这套流程,只是把类别换成新任务的类别而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询