4. 损失函数优化:从CIoU到自定义损失
损失函数,说白了就是告诉模型「你错在哪了」。YOLO的损失函数一直是个混合体——分类损失、回归损失、置信度损失搅在一起。我刚开始调YOLO的时候,总觉得这部分太理论,后来被一个项目狠狠教育了一回,才明白损失函数写得好不好,直接决定模型能不能收敛。
这一章,我们重点聊四个方向:CIoU与EIoU的对比、分类与回归损失的平衡、Focal Loss怎么用、以及如何写自己的损失函数。
4.1 CIoU vs EIoU:边界框回归的进化
YOLOv5之后,默认的回归损失从GIoU换成了CIoU。为什么?因为CIoU考虑得更全。
| 损失函数 | 考虑因素 | 典型问题 |
|---|---|---|
| IoU | 重叠面积 | 不重叠时梯度为0 |
| GIoU | 重叠面积 + 最小外接框 | 收敛慢,不稳定 |
| DIoU | 重叠面积 + 中心点距离 | 忽略长宽比 |
| CIoU | 重叠面积 + 中心距离 + 长宽比 | 长宽比描述不够精确 |
| EIoU | 重叠面积 + 中心距离 + 宽高差 | 计算稍复杂 |
CIoU的公式长这样:
CIoU = IoU - (ρ²(b, b_gt) / c²) - αv
其中:
ρ²(b, b_gt) = 预测框与真实框中心点的欧氏距离平方
c = 最小外接框的对角线长度
v = (4/π²) * (arctan(w_gt/h_gt) - arctan(w/h))²
α = v / ((1 - IoU) + v)
我在项目中遇到过一个问题:用CIoU训练小目标检测,loss降不下去。后来排查发现,CIoU的长宽比惩罚项v对宽高比接近1的框不敏感。说白了,它只惩罚「比例不一致」,不惩罚「宽高各自偏差多少」。
这时候EIoU就派上用场了。EIoU把v拆成了两个独立项:
EIoU = IoU - (ρ²(b, b_gt) / c²) - (ρ²(w, w_gt) / c_w²) - (ρ²(h, h_gt) / c_h²)
你看,它直接惩罚宽和高的偏差,而不是比例。我个人的经验是:检测大物体时CIoU够用,检测小物体或长条形物体时,EIoU明显更稳。
实战建议:在YOLOv8的loss.py中,找到bbox_loss函数,把CIoU换成EIoU只需要改几行代码。但要注意——EIoU的梯度更陡,学习率要适当调低,不然容易震荡。
4.2 分类损失与回归损失的平衡
YOLO的损失函数是三个任务的加权和:
L_total = λ_cls * L_cls + λ_box * L_box + λ_obj * L_obj
这三个λ怎么设?我见过很多新手直接抄默认值(一般是0.5、0.05、1.0),结果训练出来的模型要么框不准,要么分类错。
为什么会这样?因为不同任务的学习难度不一样。回归任务(框的位置)比分类任务难学得多,尤其是在小目标上。如果你给回归的权重太小,模型会倾向于「猜对类别但框得稀烂」。
我个人的调参习惯:
- 小目标检测:λ_box提到0.1甚至0.2,λ_cls降到0.3
- 大目标检测:保持默认,或者λ_box降到0.05
- 类别不平衡严重:λ_obj适当提高,让模型先学会「有没有物体」
避坑指南:我曾经在一个工业质检项目里,把λ_box设到0.3,结果loss直接炸了。后来发现是回归梯度过大,导致分类分支的梯度被淹没。解决办法是——用梯度裁剪,或者对回归损失做log变换。
4.3 Focal Loss在YOLO中的应用
Focal Loss最早是RetinaNet提出来的,专门解决正负样本不平衡的问题。YOLO虽然用了正负样本匹配策略,但背景框的数量还是远多于前景框。
Focal Loss的核心思想很简单:让模型更关注难分类的样本,少关注容易分类的样本。
FL(p_t) = -α_t * (1 - p_t)^γ * log(p_t)
其中:
p_t = 模型预测的概率
γ = 聚焦参数(默认2.0)
α_t = 类别权重(平衡正负样本)
当γ=0时,Focal Loss退化成标准交叉熵。γ越大,对易分类样本的惩罚越小。
我在YOLOv5里试过把分类损失换成Focal Loss,效果确实有提升,但要注意几点:
- γ不要太大:超过3.0会导致模型过于关注难样本,反而过拟合
- α_t要调:正样本权重一般设0.25,负样本0.75,但具体看你的数据集
- 只改分类损失:回归损失不要用Focal Loss,没意义
注意:YOLOv8官方已经内置了Focal Loss选项,在配置文件中设置focal_loss_gamma=2.0即可。但如果你用的是YOLOv5或更早版本,需要手动修改loss.py。
4.4 自定义损失函数
有时候,现成的损失函数不够用。比如我做过一个项目,要求检测的框必须紧贴物体边缘,不能有太多空白。CIoU和EIoU都做不到这一点。
这时候就需要自己写损失函数。我一般遵循三个原则:
- 可微:所有操作必须能求导
- 数值稳定:避免除0、log(0)等情况
- 与原有损失兼容:不要破坏梯度尺度
举个例子,我想加一个「框的紧凑度惩罚」:
def compactness_loss(pred_boxes, target_boxes):
"""
惩罚预测框面积与目标框面积的比值偏差
"""
pred_area = (pred_boxes[..., 2] - pred_boxes[..., 0]) * \
(pred_boxes[..., 3] - pred_boxes[..., 1])
target_area = (target_boxes[..., 2] - target_boxes[..., 0]) * \
(target_boxes[..., 3] - target_boxes[..., 1])
ratio = pred_area / (target_area + 1e-6)
loss = torch.abs(ratio - 1.0)
return loss.mean()
然后在主损失函数里加上去:
L_total = λ_cls * L_cls + λ_box * L_box + λ_obj * L_obj + λ_compact * compact_loss
嗯,这里要注意:自定义损失的权重λ_compact要从小开始试。我一般从0.01开始,观察几个epoch,如果loss下降正常再慢慢加大。
核心经验:自定义损失函数最怕的是「好心办坏事」。你加了一个看起来很合理的惩罚项,结果模型反而学歪了。我的建议是——每次只加一个项,用ablation study验证效果,别一口气加三四个。
4.5 损失函数选择流程图
下面这张图总结了我平时选择损失函数的思路:
4.6 本章小结
损失函数优化,说白了就是让模型知道该往哪个方向走。CIoU和EIoU的选择取决于目标大小,Focal Loss解决的是样本不平衡,自定义损失解决的是特殊需求。
我个人最深的体会是:不要盲目追求复杂的损失函数。很多时候,把CIoU的权重调对,效果比换一个花哨的损失函数好得多。先跑通,再优化,最后才考虑自定义——这个顺序别搞反了。