从一次深夜调试说起

上周在部署YOLO模型到边缘设备时遇到一个诡异现象:同一个检测框,在白天光照充足时置信度0.92,到了黄昏就掉到0.67。阈值设0.7吧,漏检;设0.6吧,误检满天飞。这让我开始怀疑,我们是不是太过信任模型输出的那个单一置信度值了?

传统检测任务中,我们习惯用sigmoid把输出压缩到[0,1],然后当成概率直接使用。但现实世界的模糊性、遮挡、光照变化,真的能用一个标量完全表达吗?这个问题引出了今天要讨论的核心:不确定性建模。

传统Focal Loss的局限

Focal Loss大家都很熟了,解决正负样本不平衡确实有效。但仔细想想,它只关心“当前预测与真实标签的差距”,没考虑“模型对这个预测有多大把握”。举个例子:

# 常见的分类头输出
cls_output = self.conv(x)  # [B, C, H, W]
cls_score = torch.sigmoid(cls_output)  # 直接当概率用

# Focal Loss计算
ce_loss = -label * torch.log(score) - (1-label) * torch.log(1-score)
pt = label * score + (1-label) * (1-score)
fl_loss = ((1-pt) ** self.gamma) * ce_loss

问题在哪?cls_score这个值既包含了“是什么类别”的信息,又隐含了“有多确定”的信息,两者混在一起。当模型遇到难样本时,它可能不是“预测错了”,而是“真的不确定”。

Distribution Focal Loss:让模型学会说“不知道”

DFL的核心思想很直观:我们不直接预测一个概率值,而是预测一个概率分布。比如,原来输出0.7,现在输出[0.1, 0.2, 0.4, 0.2, 0.1]表示概率分布在0.7附近。

实现细节(踩坑记录)

class DistributionFocalLoss(nn.Module):
    def __init__(self, bins=10, gamma=2.0):
        super().__init__()
        self.bins = bins  # 把[0,1]区间分成多少份
        self.gamma = gamma
        
        # 生成离散的锚点,这里注意要均匀分布
        # 我试过对数间隔,效果反而变差
        self.anchors = torch.linspace(0, 1, bins)  
        
    def forward(self, pred_dist, target_score):
        """
        pred_dist: [B, bins, H, W] 每个位置预测一个分布
        target_score: [B, H, W] 真实标签(连续值)
        """
        # 找到目标值最近的两个锚点
        # 这里有个坑:target_score可能超出[0,1],记得clamp一下
        target_score = target_score.clamp(0, 1)
        
        # 计算权重
        idx = (target_score * (self.bins - 1)).long()
        weight_right = target_score * (self.bins - 1) - idx.float()
        weight_left = 1 - weight_right
        
        # 提取对应位置的预测概率
        # 注意维度对齐,我在这里debug了半小时
        pred_left = pred_dist.gather(1, idx.unsqueeze(1)).squeeze(1)
        pred_right = pred_dist.gather(1, (idx+1).clamp(max=self.bins-1).unsqueeze(1)).squeeze(1)
        
        # 双线性加权损失
        loss_left = -weight_left * torch.log(pred_left + 1e-8)
        loss_right = -weight_right * torch.log(pred_right + 1e-8)
        
        # 加上Focal weighting
        pt_left = pred_left.detach()
        pt_right = pred_right.detach()
        focal_weight_left = (1 - pt_left) ** self.gamma
        focal_weight_right = (1 - pt_right) ** self.gamma
        
        return (focal_weight_left * loss_left + focal_weight_right * loss_right).mean()

实际部署时发现,bins数量不是越多越好。我测试过bins=5,10,20,50,发现bins=10在精度和计算量之间取得最好平衡。bins=50时训练不稳定,容易过拟合到噪声。

不确定性怎么用?

预测出分布后,我们得到两个宝贵信息:期望值(作为最终得分)和方差(作为不确定性度量)。

# 计算期望和方差
expectation = (pred_dist * self.anchors.view(1, -1, 1, 1)).sum(dim=1)
variance = (pred_dist * (self.anchors.view(1, -1, 1, 1) - expectation.unsqueeze(1))**2).sum(dim=1)

# 应用场景1:动态阈值
base_thresh = 0.5
dynamic_thresh = base_thresh - 0.3 * variance  # 不确定性高时降低阈值
keep_mask = expectation > dynamic_thresh

# 应用场景2:不确定性加权NMS
def uncertainty_aware_nms(boxes, scores, variances, iou_thresh):
    # 不确定性大的框权重降低
    weights = torch.exp(-2 * variances)  # 简单加权函数
    weighted_scores = scores * weights
    
    # 用加权分数做NMS
    return traditional_nms(boxes, weighted_scores, iou_thresh)

在交通场景测试中,这种动态阈值策略将黄昏时段的mAP提升了3.2%。特别是对于远处小车辆,模型现在更倾向于“有保留地检测”而不是“武断地忽略”。

训练技巧(血泪教训)

  1. 初始化很重要:pred_dist的最后一层初始化用很小的正数,我习惯用nn.init.constant_(conv.bias, 0.01)。全零初始化会导致训练初期梯度爆炸。

  2. 标签平滑的配合使用:硬标签0/1不适合DFL。我用的平滑策略:

    # 原来:positive=1.0, negative=0.0
    # 现在:positive=0.95, negative=0.05
    # 极端困难样本甚至可以给0.8,让模型知道这是模糊情况
    
  3. 损失权重需要调:DFL损失通常比分类损失小一个数量级。我的经验是分类损失权重1.0,DFL损失权重0.1开始,根据验证集调整。

  4. 推理时别忘转换:训练时用分布,推理时要取期望。这个步骤容易忘,我吃过亏——直接取分布最大值,结果指标掉点。

部署考量

边缘设备上,DFL增加的计算量主要在于:

  • 额外卷积层输出bins个通道(bins=10时增加10倍通道)
  • 期望计算(一次加权求和)

实测在Jetson Nano上,bins=10导致推理速度下降约15%。我的优化策略:

# 训练时用完整分布,部署时用简化计算
# 技巧:用两个高斯分量近似整个分布
if is_training:
    output = full_distribution_head(x)
else:
    # 部署时只输出均值和方差
    mean = conv_mean(x)
    var = torch.sigmoid(conv_var(x)) * 0.5  # 限制方差范围
    # 需要时再重建近似分布

个人建议

  1. 先在小数据集上验证:别直接上COCO。我在VisDrone上先跑通,确认有效后再迁移到主数据集,节省大量时间。

  2. 可视化分布变化:训练过程中定期可视化难样本的预测分布。我见过三种典型模式:

    • 尖锐单峰(模型很确定)
    • 平坦分布(模型很困惑)
    • 双峰分布(模型在两个答案间犹豫)
      第三种情况最有意思,往往对应真实世界的模糊样本。
  3. 结合具体业务:医疗影像中,不确定性高的样本应该交给医生复核;自动驾驶中,不确定性高的检测应该触发保守策略(如减速)。单纯追求mAP提升可能不是最终目标。

  4. 别神话DFL:它解决的是“认知不确定性”,对于数据本身的噪声(偶然不确定性)还需要其他手段。我现在的方案是DFL+MC Dropout,一个管认知,一个管偶然。

最后说句实话,改进损失函数就像调参——没有银弹。DFL在我这个项目里有效,可能是因为数据集中包含大量“边界情况”。如果你的数据都很清晰,传统Focal Loss可能更简单高效。多实验,多分析,找到适合你问题的那个解法,这才是工程师的价值所在。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐