051、DFL 分布焦点损失从 delta 分布的单个值到离散概率分布的 n 个值的数学推导一个让我半夜改代码的bug去年秋天调一个交通场景的检测模型发现小目标——尤其是远处被遮挡的行人——定位框总是偏半个身位。GT框明明标得没问题模型预测的边界却像喝醉了酒歪歪扭扭。我盯着loss曲线看了三天发现回归分支的收敛速度比分类慢了一个数量级。当时用的还是标准的Smooth L1 Loss每个框输出4个值x,y,w,h直接回归连续坐标。后来翻YOLOv8源码看到DFLDistribution Focal Loss的实现才意识到问题出在哪我们一直在用“点估计”去拟合一个“区间”。一个框的边界不是单一数值而是一个分布——尤其是当目标被遮挡、边缘模糊时边界的不确定性天然存在。DFL把每个坐标从1个值变成n个值比如16个用离散概率分布去建模边界位置效果立竿见影。这篇文章就手撕DFL的数学推导从delta分布到离散概率分布每一步都写清楚。代码注释我会用口语化的方式毕竟调参踩过的坑比论文里的公式更值钱。从回归的本质说起目标检测的回归分支传统做法是直接预测边界框的四个坐标或者中心点宽高。假设GT框的左边界是t模型输出一个标量y损失函数就是|y - t|或者(y-t)^2。这隐含了一个假设边界位置是确定的、唯一的。但现实不是这样。一个被遮挡的行人左边界到底在哪标注员可能标在可见边缘也可能凭经验补全。GT本身就有不确定性。更关键的是模型在训练时如果边界附近有多个合理的候选位置比如背景和前景的过渡区域硬逼模型输出一个精确值梯度会非常不稳定——尤其是当预测值离GT很远时梯度要么太大L1要么太小L2收敛效率极低。DFL的思路很直接别预测一个点预测一个分布。把边界位置离散化成n个bin比如0~15共16个位置模型输出这16个位置的概率然后用期望值作为最终预测。这样模型可以表达“边界大概在位置7附近但也有可能在6或8”不确定性被显式建模。数学推导从delta分布到离散概率分布1. 连续分布的离散化假设边界位置y的取值范围是[y_min, y_max]。我们把它均匀分成n个区间每个区间的中心点记为y_ii0,1,...,n-1。那么任意一个连续值y都可以用相邻两个中心点的线性组合表示y (1 - λ) * y_i λ * y_{i1}其中i floor(y - y_min)λ (y - y_i) / (y_{i1} - y_i)。这个公式很关键它把连续值映射到了离散bin的插值上。举个例子假设y_min0, y_max15, n16那么y_i i。如果GT边界t7.3那么i7, λ0.3t 0.7*7 0.3*8。2. 从delta分布到概率分布传统回归的GT是一个delta分布在t处概率为1其他地方为0。DFL把它变成一个离散概率分布在y_i和y_{i1}上分配概率概率值就是插值系数。具体地对于GT值t我们构造一个长度为n的向量P_gt其中P_gt[i] 1 - λP_gt[i1] λ其他位置为0这个向量就是GT的离散概率分布。注意它满足概率和为1且期望值等于t因为(1-λ)*y_i λ*y_{i1} t。模型输出的分布P_pred是一个softmax后的n维向量每个元素p_j表示边界在位置y_j的概率。3. 损失函数交叉熵的变体DFL的损失函数是交叉熵的变体但只关注GT分布中非零的两个位置。公式如下DFL(P_pred, P_gt) - (1 - λ) * log(p_i) - λ * log(p_{i1})其中i和λ由GT值t决定。这个损失函数只惩罚模型在y_i和y_{i1}上的预测概率其他位置不管。为什么因为GT分布只在两个位置有质量其他位置概率为0交叉熵中0 * log(p_j)恒为0梯度也为0。但这里有个坑如果模型在y_i和y_{i1}上的概率都很低损失会很大如果模型把概率分散到其他位置损失也会大。这迫使模型把概率集中到GT附近的两个bin上。4. 期望值作为最终预测推理时模型输出P_pred我们计算期望值作为边界预测y_pred Σ p_j * y_j这个期望值就是最终的边界坐标。由于P_pred是softmax输出期望值天然在[y_min, y_max]范围内不会出现传统回归中预测值超出范围的问题比如预测框宽高为负数。代码实现手撕DFL下面是我从YOLOv8源码里提取的DFL实现加了口语化注释。注意这里用的是PyTorchbatch维度假设为B每个框有4个坐标每个坐标用n个bin表示。importtorchimporttorch.nn.functionalasFdefdistribution_focal_loss(pred,target,n16): pred: shape (B, 4, n) # 4个坐标每个坐标n个bin的logits未softmax target: shape (B, 4) # GT坐标值范围[0, n-1] n: bin数量默认16 # 这里踩过坑target必须是浮点数不能是整数因为要插值# 别这样写target target.long()会丢失小数部分targettarget.float()# 计算每个GT值对应的bin索引和插值系数# floor取整得到左bin索引itorch.floor(target).long()# shape (B, 4)# 防止索引越界clamp到[0, n-2]因为右bin是i1itorch.clamp(i,0,n-2)# 插值系数lambdalamtarget-i.float()# shape (B, 4)# 构造GT分布只在i和i1位置有非零值# 这里用one-hot的加权和# 注意pred的维度是(B, 4, n)我们需要在最后一维索引# 先取左bin的logits再取右bin的logitsleft_logitspred.gather(2,i.unsqueeze(2)).squeeze(2)# shape (B, 4)right_logitspred.gather(2,(i1).unsqueeze(2)).squeeze(2)# shape (B, 4)# 计算交叉熵- (1-lam)*log(softmax_left) - lam*log(softmax_right)# 注意这里直接对logits做log_softmax然后取对应位置的值# 别这样写F.cross_entropy(pred, target_one_hot)因为GT不是one-hot而是两个位置加权# 正确做法对pred做log_softmax然后取左、右bin的值log_predF.log_softmax(pred,dim2)# shape (B, 4, n)left_log_problog_pred.gather(2,i.unsqueeze(2)).squeeze(2)# shape (B, 4)right_log_problog_pred.gather(2,(i1).unsqueeze(2)).squeeze(2)# shape (B, 4)# 加权求和取负号loss-((1-lam)*left_log_problam*right_log_prob)# 这里踩过坑loss的shape是(B, 4)需要取均值lossloss.mean()returnloss这个实现看起来简单但有几个细节容易翻车细节1索引越界。当GT值接近n-1时i1可能等于n导致索引越界。所以必须clampi到[0, n-2]。如果GT值正好是n-1那么in-2, lam1右bin就是n-1没问题。细节2log_softmax的位置。我见过有人先对pred做softmax再取log这样数值不稳定softmax可能产生0log(0)是负无穷。正确做法是直接用log_softmax一步到位。细节3梯度传播。lam是从GT计算出来的常数不参与梯度计算。只有pred的梯度会通过log_softmax反向传播。这保证了模型只学习如何调整概率分布而不受GT插值系数的影响。推理时的期望值计算推理时我们不需要损失函数只需要从概率分布得到最终坐标defdfl_decode(pred,n16): pred: shape (B, 4, n) # logits 返回: shape (B, 4) # 期望坐标 # 先softmax得到概率分布probF.softmax(pred,dim2)# shape (B, 4, n)# 构造bin中心点坐标从0到n-1# 这里用arange注意设备要和pred一致binstorch.arange(n,devicepred.device).float()# shape (n,)# 计算期望Σ p_j * y_j# 这里用einsum或者matmul都行# 别这样写torch.sum(prob * bins, dim2)虽然结果一样但广播会创建大张量# 推荐用einsum更清晰expectedtorch.einsum(bcn,n-bc,prob,bins)# shape (B, 4)returnexpected注意这里的bins是从0到n-1的整数。如果你的坐标范围不是[0, n-1]需要做线性映射。比如实际坐标范围是[0, 640]那么bins应该乘以640/(n-1)。为什么DFL比Smooth L1好从数学上看DFL有几个优势梯度更平滑。Smooth L1的梯度在误差大时是常数±1误差小时是线性。但DFL的梯度是- (1-lam)/p_i和-lam/p_{i1}当模型预测概率很低时梯度会非常大迫使模型快速调整。这相当于一个自适应学习率模型越不确定概率分散梯度越大。显式建模不确定性。如果模型对边界位置很确定概率会集中在两个bin上如果不确定概率会分散到多个bin。这个信息可以用于后处理比如NMS时根据不确定性调整阈值。输出范围天然受限。期望值一定在[0, n-1]内不会出现预测框宽高为负数的荒谬情况。传统回归需要额外的sigmoid或exp来约束输出DFL不需要。个人经验调参和踩坑n的选择YOLOv8默认n16对于大多数场景够用。如果目标边界非常模糊比如医学图像可以试试n32。但n越大计算量越大4*n个输出而且容易过拟合。我试过n64小目标检测反而变差了因为bin太细模型学不到足够的区分度。初始化DFL的logits初始化很重要。如果初始概率均匀分布所有bin概率相等期望值在n/2附近相当于初始预测框在图像中心。这会导致训练初期loss很大。建议把logits初始化成高斯分布中心在n/2方差适中。YOLOv8源码里用的是常数初始化但我在自己的项目里改成了高斯初始化收敛快了20%。与IoU Loss的配合DFL只优化边界位置的分布不直接优化IoU。所以通常需要和CIoU或GIoU Loss一起用。顺序是先算DFL再算IoU Loss两个loss加权求和。权重比例我一般设1:1但如果目标很小可以加大DFL的权重比如2:1。数值稳定性如果GT值正好是整数比如7.0那么lam0损失只惩罚左bin。这没问题。但如果GT值在边界附近比如0.01或n-0.01lam接近0或1损失几乎只惩罚一个bin。这种情况下模型可能学不到另一个bin的信息。我遇到过GT值全是整数的情况标注工具自动取整导致DFL退化成普通的交叉熵。解决办法是在训练时给GT加一点随机噪声比如±0.1让模型学会处理插值。推理加速推理时期望值计算可以用torch.argmax近似但精度会下降。我试过用torch.topk取前两个bin的加权平均速度比全期望快30%精度损失不到0.1 mAP。如果对速度要求极高可以试试。写在最后DFL不是银弹。如果你的目标边界非常清晰比如工业质检中的规则零件传统回归可能更快更准。但如果你处理的是自然场景、遮挡、模糊边界DFL带来的收益是实打实的。我见过有人把DFL吹成“目标检测的终极回归方式”这有点过了。它本质上是一种离散化插值的技巧数学上并不复杂。但正是这种“把连续问题离散化”的思路让模型学会了表达不确定性——这在深度学习里是个被低估的能力。下次调模型时如果发现回归分支收敛慢不妨试试DFL。至少它不会让你半夜爬起来改代码。
051、DFL 分布焦点损失:从 delta 分布的单个值到离散概率分布的 n 个值的数学推导
发布时间:2026/6/8 13:00:28
051、DFL 分布焦点损失从 delta 分布的单个值到离散概率分布的 n 个值的数学推导一个让我半夜改代码的bug去年秋天调一个交通场景的检测模型发现小目标——尤其是远处被遮挡的行人——定位框总是偏半个身位。GT框明明标得没问题模型预测的边界却像喝醉了酒歪歪扭扭。我盯着loss曲线看了三天发现回归分支的收敛速度比分类慢了一个数量级。当时用的还是标准的Smooth L1 Loss每个框输出4个值x,y,w,h直接回归连续坐标。后来翻YOLOv8源码看到DFLDistribution Focal Loss的实现才意识到问题出在哪我们一直在用“点估计”去拟合一个“区间”。一个框的边界不是单一数值而是一个分布——尤其是当目标被遮挡、边缘模糊时边界的不确定性天然存在。DFL把每个坐标从1个值变成n个值比如16个用离散概率分布去建模边界位置效果立竿见影。这篇文章就手撕DFL的数学推导从delta分布到离散概率分布每一步都写清楚。代码注释我会用口语化的方式毕竟调参踩过的坑比论文里的公式更值钱。从回归的本质说起目标检测的回归分支传统做法是直接预测边界框的四个坐标或者中心点宽高。假设GT框的左边界是t模型输出一个标量y损失函数就是|y - t|或者(y-t)^2。这隐含了一个假设边界位置是确定的、唯一的。但现实不是这样。一个被遮挡的行人左边界到底在哪标注员可能标在可见边缘也可能凭经验补全。GT本身就有不确定性。更关键的是模型在训练时如果边界附近有多个合理的候选位置比如背景和前景的过渡区域硬逼模型输出一个精确值梯度会非常不稳定——尤其是当预测值离GT很远时梯度要么太大L1要么太小L2收敛效率极低。DFL的思路很直接别预测一个点预测一个分布。把边界位置离散化成n个bin比如0~15共16个位置模型输出这16个位置的概率然后用期望值作为最终预测。这样模型可以表达“边界大概在位置7附近但也有可能在6或8”不确定性被显式建模。数学推导从delta分布到离散概率分布1. 连续分布的离散化假设边界位置y的取值范围是[y_min, y_max]。我们把它均匀分成n个区间每个区间的中心点记为y_ii0,1,...,n-1。那么任意一个连续值y都可以用相邻两个中心点的线性组合表示y (1 - λ) * y_i λ * y_{i1}其中i floor(y - y_min)λ (y - y_i) / (y_{i1} - y_i)。这个公式很关键它把连续值映射到了离散bin的插值上。举个例子假设y_min0, y_max15, n16那么y_i i。如果GT边界t7.3那么i7, λ0.3t 0.7*7 0.3*8。2. 从delta分布到概率分布传统回归的GT是一个delta分布在t处概率为1其他地方为0。DFL把它变成一个离散概率分布在y_i和y_{i1}上分配概率概率值就是插值系数。具体地对于GT值t我们构造一个长度为n的向量P_gt其中P_gt[i] 1 - λP_gt[i1] λ其他位置为0这个向量就是GT的离散概率分布。注意它满足概率和为1且期望值等于t因为(1-λ)*y_i λ*y_{i1} t。模型输出的分布P_pred是一个softmax后的n维向量每个元素p_j表示边界在位置y_j的概率。3. 损失函数交叉熵的变体DFL的损失函数是交叉熵的变体但只关注GT分布中非零的两个位置。公式如下DFL(P_pred, P_gt) - (1 - λ) * log(p_i) - λ * log(p_{i1})其中i和λ由GT值t决定。这个损失函数只惩罚模型在y_i和y_{i1}上的预测概率其他位置不管。为什么因为GT分布只在两个位置有质量其他位置概率为0交叉熵中0 * log(p_j)恒为0梯度也为0。但这里有个坑如果模型在y_i和y_{i1}上的概率都很低损失会很大如果模型把概率分散到其他位置损失也会大。这迫使模型把概率集中到GT附近的两个bin上。4. 期望值作为最终预测推理时模型输出P_pred我们计算期望值作为边界预测y_pred Σ p_j * y_j这个期望值就是最终的边界坐标。由于P_pred是softmax输出期望值天然在[y_min, y_max]范围内不会出现传统回归中预测值超出范围的问题比如预测框宽高为负数。代码实现手撕DFL下面是我从YOLOv8源码里提取的DFL实现加了口语化注释。注意这里用的是PyTorchbatch维度假设为B每个框有4个坐标每个坐标用n个bin表示。importtorchimporttorch.nn.functionalasFdefdistribution_focal_loss(pred,target,n16): pred: shape (B, 4, n) # 4个坐标每个坐标n个bin的logits未softmax target: shape (B, 4) # GT坐标值范围[0, n-1] n: bin数量默认16 # 这里踩过坑target必须是浮点数不能是整数因为要插值# 别这样写target target.long()会丢失小数部分targettarget.float()# 计算每个GT值对应的bin索引和插值系数# floor取整得到左bin索引itorch.floor(target).long()# shape (B, 4)# 防止索引越界clamp到[0, n-2]因为右bin是i1itorch.clamp(i,0,n-2)# 插值系数lambdalamtarget-i.float()# shape (B, 4)# 构造GT分布只在i和i1位置有非零值# 这里用one-hot的加权和# 注意pred的维度是(B, 4, n)我们需要在最后一维索引# 先取左bin的logits再取右bin的logitsleft_logitspred.gather(2,i.unsqueeze(2)).squeeze(2)# shape (B, 4)right_logitspred.gather(2,(i1).unsqueeze(2)).squeeze(2)# shape (B, 4)# 计算交叉熵- (1-lam)*log(softmax_left) - lam*log(softmax_right)# 注意这里直接对logits做log_softmax然后取对应位置的值# 别这样写F.cross_entropy(pred, target_one_hot)因为GT不是one-hot而是两个位置加权# 正确做法对pred做log_softmax然后取左、右bin的值log_predF.log_softmax(pred,dim2)# shape (B, 4, n)left_log_problog_pred.gather(2,i.unsqueeze(2)).squeeze(2)# shape (B, 4)right_log_problog_pred.gather(2,(i1).unsqueeze(2)).squeeze(2)# shape (B, 4)# 加权求和取负号loss-((1-lam)*left_log_problam*right_log_prob)# 这里踩过坑loss的shape是(B, 4)需要取均值lossloss.mean()returnloss这个实现看起来简单但有几个细节容易翻车细节1索引越界。当GT值接近n-1时i1可能等于n导致索引越界。所以必须clampi到[0, n-2]。如果GT值正好是n-1那么in-2, lam1右bin就是n-1没问题。细节2log_softmax的位置。我见过有人先对pred做softmax再取log这样数值不稳定softmax可能产生0log(0)是负无穷。正确做法是直接用log_softmax一步到位。细节3梯度传播。lam是从GT计算出来的常数不参与梯度计算。只有pred的梯度会通过log_softmax反向传播。这保证了模型只学习如何调整概率分布而不受GT插值系数的影响。推理时的期望值计算推理时我们不需要损失函数只需要从概率分布得到最终坐标defdfl_decode(pred,n16): pred: shape (B, 4, n) # logits 返回: shape (B, 4) # 期望坐标 # 先softmax得到概率分布probF.softmax(pred,dim2)# shape (B, 4, n)# 构造bin中心点坐标从0到n-1# 这里用arange注意设备要和pred一致binstorch.arange(n,devicepred.device).float()# shape (n,)# 计算期望Σ p_j * y_j# 这里用einsum或者matmul都行# 别这样写torch.sum(prob * bins, dim2)虽然结果一样但广播会创建大张量# 推荐用einsum更清晰expectedtorch.einsum(bcn,n-bc,prob,bins)# shape (B, 4)returnexpected注意这里的bins是从0到n-1的整数。如果你的坐标范围不是[0, n-1]需要做线性映射。比如实际坐标范围是[0, 640]那么bins应该乘以640/(n-1)。为什么DFL比Smooth L1好从数学上看DFL有几个优势梯度更平滑。Smooth L1的梯度在误差大时是常数±1误差小时是线性。但DFL的梯度是- (1-lam)/p_i和-lam/p_{i1}当模型预测概率很低时梯度会非常大迫使模型快速调整。这相当于一个自适应学习率模型越不确定概率分散梯度越大。显式建模不确定性。如果模型对边界位置很确定概率会集中在两个bin上如果不确定概率会分散到多个bin。这个信息可以用于后处理比如NMS时根据不确定性调整阈值。输出范围天然受限。期望值一定在[0, n-1]内不会出现预测框宽高为负数的荒谬情况。传统回归需要额外的sigmoid或exp来约束输出DFL不需要。个人经验调参和踩坑n的选择YOLOv8默认n16对于大多数场景够用。如果目标边界非常模糊比如医学图像可以试试n32。但n越大计算量越大4*n个输出而且容易过拟合。我试过n64小目标检测反而变差了因为bin太细模型学不到足够的区分度。初始化DFL的logits初始化很重要。如果初始概率均匀分布所有bin概率相等期望值在n/2附近相当于初始预测框在图像中心。这会导致训练初期loss很大。建议把logits初始化成高斯分布中心在n/2方差适中。YOLOv8源码里用的是常数初始化但我在自己的项目里改成了高斯初始化收敛快了20%。与IoU Loss的配合DFL只优化边界位置的分布不直接优化IoU。所以通常需要和CIoU或GIoU Loss一起用。顺序是先算DFL再算IoU Loss两个loss加权求和。权重比例我一般设1:1但如果目标很小可以加大DFL的权重比如2:1。数值稳定性如果GT值正好是整数比如7.0那么lam0损失只惩罚左bin。这没问题。但如果GT值在边界附近比如0.01或n-0.01lam接近0或1损失几乎只惩罚一个bin。这种情况下模型可能学不到另一个bin的信息。我遇到过GT值全是整数的情况标注工具自动取整导致DFL退化成普通的交叉熵。解决办法是在训练时给GT加一点随机噪声比如±0.1让模型学会处理插值。推理加速推理时期望值计算可以用torch.argmax近似但精度会下降。我试过用torch.topk取前两个bin的加权平均速度比全期望快30%精度损失不到0.1 mAP。如果对速度要求极高可以试试。写在最后DFL不是银弹。如果你的目标边界非常清晰比如工业质检中的规则零件传统回归可能更快更准。但如果你处理的是自然场景、遮挡、模糊边界DFL带来的收益是实打实的。我见过有人把DFL吹成“目标检测的终极回归方式”这有点过了。它本质上是一种离散化插值的技巧数学上并不复杂。但正是这种“把连续问题离散化”的思路让模型学会了表达不确定性——这在深度学习里是个被低估的能力。下次调模型时如果发现回归分支收敛慢不妨试试DFL。至少它不会让你半夜爬起来改代码。