4. 置信度损失详解:Focal Loss在YOLO中的应用

正负样本不平衡,这问题在目标检测里太常见了。你想想看,一张图里可能就几个目标,但背景区域占了绝大多数。YOLO在训练时,每个网格会预测多个锚框,其中大部分都是背景——也就是负样本。这些负样本数量多、贡献小,却会主导损失函数,让模型学不到真正重要的东西。

我刚开始做YOLO训练时,就吃过这个亏。模型训练完,loss降得挺漂亮,但一跑推理,全是误检。说白了,模型被海量负样本带偏了,它学会了「说没有」,却没学会「说有什么」。

核心问题:负样本数量远大于正样本,导致损失函数被负样本主导,模型难以聚焦于正样本的学习。

4.1 标准交叉熵的困境

先回顾一下YOLOv1/v2用的标准交叉熵损失。对于二分类的置信度预测,公式长这样:

CE(p, y) = -y * log(p) - (1 - y) * log(1 - p)

其中y是真实标签(1表示有目标,0表示背景),p是模型预测的置信度。

这个公式有什么问题?我举个例子。假设一张图有100个锚框,其中只有1个正样本,99个负样本。每个负样本的损失虽然小,但99个加起来,轻松碾压那1个正样本的损失。模型优化时,会优先把负样本的预测值往0压,正样本反而被忽略了。

嗯,这里要注意:不是所有负样本都「平等」。那些靠近目标但没完全对齐的锚框,预测置信度可能在0.4~0.6之间,它们产生的损失比那些远离目标的锚框大得多。但即便如此,数量优势依然让负样本占据主导。

4.2 Focal Loss的诞生

2017年,何恺明团队在RetinaNet中提出了Focal Loss。我第一眼看到这个公式时,就觉得——这思路太巧妙了。它本质上是对标准交叉熵的「加权改造」:

FL(p, y) = -y * (1 - p)^γ * log(p) - (1 - y) * p^γ * log(1 - p)

关键就在那个 (1 - p)^γp^γ 上。γ是一个可调的超参数,通常取2。

为什么会这样?你想想看:

  • 对于正样本(y=1):如果模型预测p接近1,说明这个样本已经学好了,(1 - p)^γ 接近0,损失被大幅降低。如果p很小(模型预测错了),(1 - p)^γ 接近1,损失几乎不变。
  • 对于负样本(y=0):如果模型预测p接近0(正确预测为背景),p^γ 接近0,损失被压到几乎忽略。如果p很大(模型误判为目标),p^γ 接近1,损失保留。

说白了,Focal Loss就是「让模型更关注那些难分样本,忽略那些已经学好的样本」。这正好解决了正负样本不平衡的问题——大量容易分类的负样本被降权,模型可以把精力集中在少数正样本和难分负样本上。

我的经验:γ=2是最常用的取值。我在项目中试过γ=1.5和γ=3,效果都不如γ=2稳定。但如果你遇到极端不平衡(比如1:10000),可以试试γ=3甚至γ=4。

4.3 Focal Loss在YOLO中的落地

YOLOv3及之后的版本,其实并没有直接使用Focal Loss。为什么?因为YOLO本身已经通过一些设计缓解了正负样本不平衡:

  • YOLOv3只对与GT有最高IoU的锚框计算正样本损失
  • 忽略那些IoU在0.5~0.7之间的锚框(不参与损失计算)
  • 只对IoU小于0.5的锚框计算负样本损失

但我在实际项目中,还是经常把Focal Loss加进去。特别是当你的数据集本身就有严重的长尾分布时——比如工业质检场景,缺陷样本可能只占0.1%。

我曾经在一个PCB缺陷检测项目里,正负样本比例达到了1:5000。用标准交叉熵训练,模型几乎检测不到任何缺陷。后来我把置信度损失替换成Focal Loss,γ设成2.5,mAP直接从0.12跳到了0.67。嗯,这个提升让我印象很深。

4.4 代码实现:自定义Focal Loss

下面是我在YOLOv5基础上修改的Focal Loss实现。注意,这里只替换了置信度损失部分,分类和回归损失保持不变:

import torch
import torch.nn as nn
import torch.nn.functional as F

class FocalLoss(nn.Module):
    def __init__(self, gamma=2.0, alpha=None, reduction='mean'):
        super(FocalLoss, self).__init__()
        self.gamma = gamma
        self.alpha = alpha  # 类别权重,一般不用
        self.reduction = reduction

    def forward(self, pred, target):
        """
        pred: 预测置信度,shape [N, 1] 或 [N]
        target: 真实标签,0或1,shape [N]
        """
        # 计算标准交叉熵
        ce_loss = F.binary_cross_entropy_with_logits(
            pred, target, reduction='none'
        )
        
        # 计算pt(预测正确的概率)
        pt = torch.exp(-ce_loss)  # pt = p if y=1, pt = 1-p if y=0
        
        # 应用Focal Loss调制因子
        focal_weight = (1 - pt) ** self.gamma
        
        # 如果使用alpha平衡,可以加上
        if self.alpha is not None:
            alpha_t = self.alpha * target + (1 - self.alpha) * (1 - target)
            focal_weight = alpha_t * focal_weight
        
        # 最终损失
        loss = focal_weight * ce_loss
        
        if self.reduction == 'mean':
            return loss.mean()
        elif self.reduction == 'sum':
            return loss.sum()
        else:
            return loss

使用时,只需要把YOLO中计算置信度损失的部分替换掉:

# 原来的置信度损失
# loss_conf = F.binary_cross_entropy_with_logits(pred_conf, tconf)

# 替换为Focal Loss
focal_loss = FocalLoss(gamma=2.0)
loss_conf = focal_loss(pred_conf, tconf)

注意:Focal Loss不是万能的。如果你的数据集正负样本比例已经比较均衡(比如1:3以内),加Focal Loss反而可能降低性能。我建议先跑一版基线,确认正负样本比例确实失衡,再考虑使用。

4.5 调参建议与避坑指南

关于γ的取值,我整理了一个经验表:

正负样本比例 推荐γ值 说明
1:10 ~ 1:50 1.5 ~ 2.0 轻度不平衡,γ=2足够
1:50 ~ 1:500 2.0 ~ 2.5 中度不平衡,需要更强调制
1:500 ~ 1:5000 2.5 ~ 3.5 严重不平衡,注意不要过拟合
1:5000以上 3.5 ~ 5.0 极端情况,建议配合数据增强

我曾经犯过一个错误:在正负样本比例1:20的数据集上,直接把γ设成4。结果模型训练时loss降得飞快,但验证集mAP反而下降了。后来分析发现,γ太大导致模型几乎忽略了所有负样本,连那些「应该被学习」的难分负样本也被忽略了。

所以我的建议是:从γ=2开始,观察训练集和验证集的loss曲线。如果训练loss下降很快但验证loss不降,说明γ可能偏大。如果两个loss都降得慢,可以适当增大γ。

4.6 小结

Focal Loss是解决正负样本不平衡的利器,但它不是银弹。我个人习惯把它作为「备选方案」——先用标准交叉熵跑一版,如果发现模型对正样本召回率偏低,再换成Focal Loss。调参时从γ=2起步,根据实际效果微调。

下一章,我会讲分类损失和回归损失的协同优化。这两个损失如果配合不好,模型要么定位不准,要么分类出错。嗯,到时候我会分享一个我踩过的坑——分类损失权重设太大,导致模型变成了「分类器」而不是「检测器」。