021、损失函数改进(三):Distribution Focal Loss与不确定性建模
从一次深夜调试说起
上周在部署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%。特别是对于远处小车辆,模型现在更倾向于“有保留地检测”而不是“武断地忽略”。
训练技巧(血泪教训)
-
初始化很重要:pred_dist的最后一层初始化用很小的正数,我习惯用nn.init.constant_(conv.bias, 0.01)。全零初始化会导致训练初期梯度爆炸。
-
标签平滑的配合使用:硬标签0/1不适合DFL。我用的平滑策略:
# 原来:positive=1.0, negative=0.0 # 现在:positive=0.95, negative=0.05 # 极端困难样本甚至可以给0.8,让模型知道这是模糊情况 -
损失权重需要调:DFL损失通常比分类损失小一个数量级。我的经验是分类损失权重1.0,DFL损失权重0.1开始,根据验证集调整。
-
推理时别忘转换:训练时用分布,推理时要取期望。这个步骤容易忘,我吃过亏——直接取分布最大值,结果指标掉点。
部署考量
边缘设备上,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 # 限制方差范围
# 需要时再重建近似分布
个人建议
-
先在小数据集上验证:别直接上COCO。我在VisDrone上先跑通,确认有效后再迁移到主数据集,节省大量时间。
-
可视化分布变化:训练过程中定期可视化难样本的预测分布。我见过三种典型模式:
- 尖锐单峰(模型很确定)
- 平坦分布(模型很困惑)
- 双峰分布(模型在两个答案间犹豫)
第三种情况最有意思,往往对应真实世界的模糊样本。
-
结合具体业务:医疗影像中,不确定性高的样本应该交给医生复核;自动驾驶中,不确定性高的检测应该触发保守策略(如减速)。单纯追求mAP提升可能不是最终目标。
-
别神话DFL:它解决的是“认知不确定性”,对于数据本身的噪声(偶然不确定性)还需要其他手段。我现在的方案是DFL+MC Dropout,一个管认知,一个管偶然。
最后说句实话,改进损失函数就像调参——没有银弹。DFL在我这个项目里有效,可能是因为数据集中包含大量“边界情况”。如果你的数据都很清晰,传统Focal Loss可能更简单高效。多实验,多分析,找到适合你问题的那个解法,这才是工程师的价值所在。
更多推荐
所有评论(0)