把 Focal Loss 论文重新翻出来读了一遍,距离第一次看已经有好几年。当年为了赶实验,只急着把它塞进检测代码里调参,后来手头项目一忙就放下了;这次趁着空闲把公式从头推了一遍,又对着论文里的实验表格逐行看,才发现很多当年踩过的坑,答案其实都写在论文的细节里,只是当时的自己读得太糙。这篇被广泛称作 RetinaNet 的论文,核心贡献可以拆成两件事:第一,用 Focal Loss 解决密集目标检测中极端类别不平衡的问题;第二,配合特征金字塔网络(FPN)把单阶段检测器的精度拉升了一个档次。如果你正在被正负样本比例失衡搞得训练不稳定,或者在目标检测、长尾分类的任务里琢磨怎么给难样本加权重,这篇学习笔记应该能给你一些落到实处的参考。我不只讲公式结论,还会把推导过程、实现细节、复现时容易踩的坑一并写出来。
1. 为什么密集检测器会被类别不平衡卡死
1.1 一阶段与两阶段检测器在样本分布上的差异
目标检测里"一阶段"和"两阶段"的争论,表面上是精度与速度的取舍,但本质差别在于拿到手的训练样本分布完全不同。
两阶段检测器(比如经典的"先提区域再分类"路线)在第一阶段用区域提案网络先把可能有目标的区域筛出来,第二阶段再对这些区域做精细分类和回归。这个流程里,第二阶段拿到的候选区域数量是可控的,常见做法是每张图采样 256 或 512 个 proposal,并且强制保证正负样本比例在 1:3 左右。也就是说,两阶段检测器在训练阶段已经把最难缠的"海量背景"问题压住了。
一阶段检测器就没有这个缓冲。它直接在整张图的密集网格上预测,以常见的 anchor 方式为例,输入一张 800 像素左右的图,经过多层特征金字塔后,产生的 anchor 数量经常是十万甚至更多。而一张图里的真实目标通常只有几个到几十个,算下来正负样本比例随便就达到 1:1000。你可以把训练过程想象成一场课堂考试:布置了一万道一眼就会的送分题,真正有区分度的难题只有几十道,如果总分主要由送分题决定,那无论你难题怎么答,最后排名都差不多。
如果这里只靠随机抽样把负样本数量压下来,场景会变好一些吗?有一定作用,但随即带来的问题是抽样不固定、模型对不同场景的负样本覆盖不充分,而且你没法保证抽出来的负样本就有代表性。论文作者当时盯住的就是这种结构性问题:一阶段想保持快速,就必须在密集预测的框架下找到一种让"大部分简单负样本不那么影响训练"的办法。
1.2 简单负样本才是真正的"梯度刺客"
很多人以为类别不平衡的麻烦主要来自正样本太少、负样本太多,于是第一时间想"给负样本降个权不就行了"。但 Focal Loss 论文里的分析更细一层:真正的问题不只是负样本数量多,而是负样本里有大量"已经被正确分类、且非常确信"的简单样本。
用交叉熵损失训练时,一个预测置信度很高的简单负样本(比如 p_t = 0.9),单个样本的 loss 只有 0.1 出头;但十万个这样的样本加起来,贡献出的梯度绝对量非常大。相比之下,一个真正难分的样本(比如 p_t = 0.5,模棱两可),单个 loss 是 0.69,数量又少。于是在最后一次梯度累加时,简单负样本的权重通过“数量优势”完全压制了难样本和正样本,模型就被带偏了。
这种"简单样本压制难样本"的现象,普通样本均衡的数据集里也有,但没那么致命。一旦到了密集目标检测这种极端不平衡场景,就变成主要矛盾。论文的高明之处在于:不抛弃任何样本,不显式采样,而是用一条连续的曲线把每个样本的贡献按"难度"重新分配——这正是 Focal Loss 的思路起点。
2. 从交叉熵一步步走到 Focal Loss
2.1 交叉熵的符号问题与 p_t 的提法
为了把公式的来历讲清楚,先把二分类交叉熵写出来。对于一个真实标签 y(1 或 0),模型对正类的预测概率为 p,交叉熵可以写成:
CE(p, y) = -log(p),当 y=1
CE(p, y) = -log(1-p),当 y=0
在目标检测里,很多时候用 sigmoid 对每个 anchor 做独立二分类,也就是多标签的形式。为了方便统一,论文引入一个记号 p_t:
p_t = p,当 y=1 时
p_t = 1-p,当 y=0 时
这样一来,交叉熵就成了一句话:CE(p_t) = -log(p_t)。p_t 越接近 1,说明模型对这个样本越有把握,分类越"简单";p_t 越小,说明模型越犹豫或直接错了,样本越"难"。
我一开始不太习惯这个记号,总觉得它就是换了个写法。后来发现,p_t 是理解 Focal Loss 的一把钥匙,因为后面整个调制因子都建立在 p_t 之上,而不是建立在模型原始输出 p 上。如果你拿着 p 而不是 p_t 去算,很容易在正负样本上写出两个分支,公式就变得啰嗦。
2.2 关键一步:给每个样本加上难度调制因子
交叉熵的问题是"一视同仁":不管 p_t 是 0.9 还是 0.5,每个样本按自己的 loss 参与求和,简单样本靠数量获胜。Focal Loss 做的改动只有一处,却非常关键:在交叉熵前面乘一个调制因子:
FL(p_t) = -(1 - p_t)^γ * log(p_t)
这里 γ 是聚焦参数(focal parameter),通常取 2。拆开看这个调制因子 (1-p_t)^γ:
- 当 p_t 很接近 1,比如 0.9,(1-0.9)^2 = 0.01,原来 0.1 的 loss 被压到 0.001。简单样本的贡献被大幅削减。
- 当 p_t 在 0.5 附近,(1-0.5)^2 = 0.25,loss 从 0.69 变成约 0.17。中等难度的样本保留了一部分。
- 当 p_t 很小,比如 0.1,也就是模型把正类分得很糟,(1-0.1)^2 = 0.81,loss 从 2.3 变成约 1.87。难样本几乎原样保留。
也就是说,这个因子像一个"难度筛子",自动把训练焦点从堆积如山的简单背景转移到少数难样本上。关键是这个筛子不是硬性的丢与不丢,而是连续且可微的,所以整个模型依然能用标准反向传播训练,不需要额外设计样本选择逻辑。
为什么 γ=2 而不是 γ=1 或者 γ=0?γ=0 时 Focal Loss 退化成普通交叉熵;γ 越大,简单样本被压得越狠,但难样本的权重并没有无限放大,因为 (1-p_t)^γ 对难样本趋近 1,只是保留,不放大。到 γ=5 的时候,作者观察到简单样本几乎不提供梯度,训练变得困难,AP 反而下降。你可以在自己的数据上从 γ=2 起步,然后往 1 和 3 两边试。
2.3 α-balanced 变体:把类别权重和难度权重解耦
只看调制因子的话,它只调整了难易程度,并没有处理正负类别数量本身的不平衡。Focal Loss 里还有一个经常被人忽略的配套项:α-balanced 版本:
FL(p_t) = -α_t * (1 - p_t)^γ * log(p_t)
其中 α_t 是一个与类别相关的权重:正样本用 α,负样本用 1-α。论文里建议 α=0.25、γ=2 组合效果最好。α 负责"正负类别的数量差异",γ 负责"简单难样本的权重差异",两个维度是正交的,最好不要混着说。
为什么有了 γ 还需要 α?因为调制因子只改变难易样本之间的相对权重,没有改变正负类别之间的总体比例。同一难度下,负样本基数依然可能是正样本的上百倍;不引入 α 的话,整体梯度依然会偏向负样本方向。论文实验里只调 γ 不调 α,或者只调 α 不调 γ,效果都不如两个一起用。这一点我在复现的时候也验证过,单独把 γ 拉到 2 不加 α 时,检测 AP 会低一些,尤其是小物体那一栏掉得明显。
2.4 看一个数值例子就全明白了
口说无凭,我列一个简单的数值表,假设 γ=2、α=1,也就是先不看 α 的版本:
| 样本类型 | p_t 值 | CE 损失 | 调制因子 (1-p_t)^2 | Focal Loss 值 | 直观结论 |
|---|---|---|---|---|---|
| 简单背景 | 0.9 | 0.105 | 0.01 | 0.001 | 损失被压到几乎不贡献 |
| 中等样本 | 0.5 | 0.693 | 0.25 | 0.173 | 损失保留约 25% |
| 困难正样本 | 0.1 | 2.303 | 0.81 | 1.865 | 损失几乎原样保留 |
看到这个表就明白 Focal Loss 为什么能稳定压制简单负样本了:它不是把负样本删掉,而是让它们在梯度求和里的发言权指数级下降。这样即使负样本数量是正样本的几百倍,只要大部分负样本已经学得好、p_t 高,它们就不会再继续霸占梯度。
反过来也说明一个实际问题:如果训练初期所有样本都还没分好,p_t 普遍不高,那 Focal Loss 的压制效应还体现不出来,loss 会显得很高。这不是 bug,而是因为调制因子的目标是"学得好的别再抢戏",而不是"一开始就把总 loss 压低"。很多第一次用的人看训练曲线大跌眼镜,以为模型坏了,其实是没理解这个动态特性。
2.5 手写一个 PyTorch 实现
理解了公式之后,写实现就是水到渠成的事。下面是一个贴合论文公式的 PyTorch 版本,我习惯把它封装成函数,方便在训练循环里直接调用:
import torch import torch.nn.functional as F def focal_loss(logits, targets, alpha=0.25, gamma=2.0): # logits: [N, num_classes],targets: one-hot 或 float 标签 [N, num_classes] ce = F.binary_cross_entropy_with_logits(logits, targets, reduction="none") p = torch.sigmoid(logits) # p_t: 正样本取 p,负样本取 1-p p_t = p * targets + (1 - p) * (1 - targets) # alpha_t: 正样本用 alpha,负样本用 1-alpha alpha_t = alpha * targets + (1 - alpha) * (1 - targets) # focal weight focal_weight = (1 - p_t) ** gamma loss = alpha_t * focal_weight * ce return loss.mean()有几个实现细节说明一下:这里用binary_cross_entropy_with_logits,因为分类子网络输出的是未过 sigmoid 的 logits,直接在 loss 里做 sigmoid 更稳定。targets我传的是与 logits 形状一致的 one-hot 浮点数,因为检测任务中一个 anchor 只分配一个类别,但训练时也可以用多标签。真正要小心的是 loss 的归一化:很多开源实现不会直接把所有样本的 focal loss 求平均,而是只除以正样本的数量,这样能避免负样本数量影响 loss 的绝对值尺度。上面的代码为了演示简洁用了 mean,实际项目里建议参考检测框架的归一化方式,按正样本数归一化。
3. RetinaNet 里那些容易忽略的实现细节
3.1 架构:FPN 负责定位"在哪里看",分类子网络负责"看到什么"
Focal Loss 不是一个孤立技巧,它是嵌在 RetinaNet 整个框架里起作用的。RetinaNet 的结构可以拆成三块:一个主干网络提取特征,一个特征金字塔网络(FPN)输出多层不同尺度的特征图,两个任务子网络分别做分类和回归。
FPN 在这里很重要,因为密集检测要在多种尺度下进行。RetinaNet 用 P3 到 P7 五层金字塔,每层 anchor 的尺度从 32×32 到 512×512,基本覆盖了从小到大各种目标。如果没有 FPN,只靠最后一层高层特征做检测,小目标定位信息早就被下采样丢光了,Focal Loss 再厉害也救不回来。
分类子网络的设计也值得留意:它不是把特征图拉平后过一个全连接,而是用一组小卷积,一层一层把语义信息抽出来。具体来说,分类塔用 4 个 3×3 卷积,输出通道保持 256,最后再用一个 3×3 卷积输出 K×A 个分数,K 是类别数,A 是每个位置的 anchor 数量。论文里所有分类都用 sigmoid,不用 softmax。我一开始觉得奇怪:常规分类不都应该 softmax 吗?但在密集检测的多标签场景下,sigmoid 更合理,因为一个位置上的几个 anchor 可能属于不同类别,而且 sigmoid 对每个类别独立建模,训练效率也更高。
3.2 分类分支的 bias 初始化,其实比想象中重要
这是我复现时吃了大亏的一个细节。论文里专门提了一句:分类子网络的最后一层卷积,bias 要初始化成:
b = -log((1 - π) / π)
其中 π = 0.01。算一下就知道 b 大概是 -4.595。这么做的目的是让训练刚开始的每个 anchor 都被预测为"背景"的概率接近 0.99,也就是 p_t 接近 0.99,Focal Loss 的调制因子一开始就处于强压低状态。
如果不做这个初始化,训练第一个 step 的损失可能高到离谱,梯度从一堆还没分清的样本上涌入,前几百步训练会非常不稳定。我踩过的坑是:当时偷懒没设置 bias,结果 loss 前期震荡剧烈,后来 AP 一直上不去。最初我还以为是学习率的问题,后来对照论文才发现是分类头初始预测置信度太中立,导致每一轮都有海量背景样本参与梯度计算,把优化方向搅乱了。
这个初始化技巧其实暴露了 Focal Loss 的一个特性:它是一个"训练动态相关"的损失。样本权重不是固定的,而是随着模型当前置信度变化。因此初始化状态会影响后续所有样本的权重分配,不能只顾着把网络结构搭出来,分类分支的起点也要符合整体设计。
3.3 Anchor 与匹配规则
RetinaNet 的 anchor 设计是:金字塔的每一层都使用 9 个 anchor,即 3 个尺度(scale)乘以 3 个长宽比(ratio),长宽比通常取 1:2、1:1、2:1。不同金字塔层级对应不同基础尺度,这样同一张图的候选框能够覆盖各种大小的目标。
正负样本的分配规则在论文里用的是 IoU 阈值:和某个真实目标 IoU 超过 0.5 的 anchor 算正样本;与所有真实目标 IoU 都低于 0.4 的算负样本;落在 0.4 到 0.5 之间的不参与训练。这个“灰色地带”很关键,因为这些 anchor 说它是正也不是、说负也不是,强行分配只会给训练塞噪声。很多复现效果差的代码库就是没有仔细处理这个区间,把 0.4-0.5 都当负样本来算,结果引入大量边界模糊样本,Focal Loss 反而被这些难样本带偏。
与两阶段检测器不同,RetinaNet 不会强制限制每张图正负样本的比例,而是靠 Focal Loss 来自动调节贡献。所以 anchor 匹配规则对最终效果的影响比想象中更大:正样本太苛刻,就会造成匹配不上的目标对模型完全不可见;负样本太宽,又会让灰色地带的难样本大量涌入。建议复现时先按论文的 0.5/0.4 来,不要凭感觉改。
3.4 训练超参和实验效果
论文里的实验配置大致是这样的:主干网络用 ResNet-50 或 ResNet-101,前面接 FPN;输入图像缩放到最短边 800、最长边不超过 1333;SGD 优化器,momentum 0.9,weight decay 0.0001,初始学习率 0.01,batch size 16 左右,训练步数在 90k 步上下。分类和回归两个子网络共享 FPN 特征,但各自保持独立权重。
关于实验结果,我记得读论文时对比表里印象最深的一组:当时的单阶段检测器在公开检测数据集上的 AP 普遍在 30 上下,而 RetinaNet 用 ResNet-101 当骨干时,AP 能到 39 附近,跨过了一段不小的精度鸿沟,甚至能跟许多两阶段检测器打平或更好。这里不要拿我记忆里的数字当标准答案,不同输入尺度和训练设置下会有浮动,但趋势是确定的:引入 Focal Loss 之后,单阶段检测器从"速度快但精度差"变成了"速度和精度都能打"。
3.5 γ 的选择:效果最好的是 2,不是 5
论文里专门做了 γ 的消融实验,从 0 扫到 5,结论是 γ=2 时 AP 最高。γ=0 就是普通的 α-balanced 交叉熵,相当于没加调制因子;γ=1 能压低一部分简单样本,但对那些已经很确信的背景样本压得不到位;γ=5 时简单样本几乎完全不贡献梯度,好处是难样本主导训练,坏处是模型容易被个别标注错误或者极其难分的样本牵着走,AP 反而下降。
这个趋势在不同数据集上会有细微差异,但我建议调参时把 γ 当作“注意力强度”来理解:太小,模型注意力分散,简单背景仍然参与度高;太大,注意力过于集中,模型可能钻牛角尖。我自己试下来,γ=2 对大多数检测任务都是个稳定起点,α 可以优先试 0.25,然后根据结果微调。
4. Focal Loss 与其他困难样本方法的关系
4.1 OHEM 是硬挖,Focal Loss 是软加权
在读 Focal Loss 论文之前,我最熟悉的困难样本处理方法是 OHEM(在线困难样本挖掘)。OHEM 的思路很直接:计算完一轮 loss 之后,把所有样本按 loss 从大到小排序,只挑出 loss 最高的那部分样本做反向传播,其余样本的梯度直接置零。这样模型每轮都只看最难的样本。
OHEM 和 Focal Loss 的目的一致,但实现哲学完全不同。OHEM 是一个离散的选择过程:样本要么被选中、梯度完整计算,要么被丢弃、梯度为零。Focal Loss 则是连续的:每个样本都保留,只是根据难度乘上一个 0 到 1 之间的系数。换句话说,Focal Loss 可以看作 OHEM 的一个光滑近似,它把"前 k 个"的硬阈值换成了"按难度衰减"的软权重。
这种连续性带来的好处是训练过程平稳。OHEM 在样本排序边界附近会有突然的梯度跳变,而且一旦某一轮选中的难样本存在标注噪声,整个更新方向很容易被带歪。Focal Loss 给难样本的权重虽然大,但不是非 0 即 1,对噪声的耐受性要好一些。论文里对比过这种做法在密集检测框架里的效果,OHEM 在区域提案式的检测器里有效,但直接搬到密集预测框架里,AP 提升有限,Focal Loss 提升明显得多。
4.2 与常规类别加权、负样本降采样的区别
还有一种常见的做法是给正负样本分配不同权重,比如正样本权重设为负样本的 3 倍。这解决的是类别数量不平衡,但解决不了简单样本数量优势的问题。你可以做这样一个思想实验:即使把负样本总权重压到和正样本一个量级,这堆权重还是会被几万个简单负样本瓜分,每个简单负样本依然分到一点梯度;模型觉得"反正我大体分对了,没必要专门去改那些少数难样本"。
负样本随机降采样也有类似局限。降采样把负样本总数砍下来之后,难样本占比会相对提高,但采样的随机性意味着很多有难度的负样本可能压根没被采到,模型没见过,自然学不好。Focal Loss 不采样,它让全部样本都参与前向计算,只是反向传播时给梯度按难度重新分配。听起来是一个很小的差别,实际操作上却省去了很多调采样比例的麻烦。
4.3 出了目标检测,它还能用在哪
Focal Loss 的设计并不绑定目标检测,只要任务面临"极度不平衡 + 大量简单样本"就可以尝试。比如长尾图像分类里,头部类别样本多、尾部类别样本少,直接用交叉熵训练很容易让尾部类别被忽略,这时候给每个样本按置信度加调制因子,模型就会把注意力往尾部类别转移。
语义分割里也有类似的例子:图像中一个小目标区域可能只占几个像素,但背景像素占了绝大部分。把 Focal Loss 用在分割的像素级分类头上,能让模型更关注那些难分的边缘像素和稀少类别。我在自己的模拟实验里也验证过,Focal Loss 对识别小目标的 recall 提升立竿见影,但也要注意它会让错误标注的样本权重变大,标注质量差的数据集上要谨慎。
5. 复现过程中的常见问题与排查心得
5.1 如果损失贴地但 AP 上不去,先看 bias 初始化
复现 Focal Loss 时最诡异的现象之一是:训练 loss 收敛得很漂亮,曲线一路下降,但验证集 AP 就是上不去。这种情况多半不是 Focal Loss 本身的问题,而是分类分支的初始化或正负样本匹配有问题。
先说 bias 初始化。如果没有把分类头的 bias 设成 -log((1-0.01)/0.01),训练初期模型对所有 anchor 的初始置信度就是 0.5 上下,Focal Loss 的调制因子都是 0.25 左右的中间值,简单背景和难样本权重没有拉开,等于 Focal Loss 没有进入预期的“压制简单样本”状态。虽然训练后期模型自己也会慢慢调整,但前期优化轨迹已经偏了,最终 AP 会差一截。
再说 anchor 匹配。检查一下你的代码里,灰色地带(IoU 在 0.4 到 0.5 之间)的 anchor 是被正确忽略了,还是被错误地归为负样本。如果错误归为负样本,这些既有点目标影子又算背景的 anchor 一开始 p_t 就低,Focal Loss 会给它们很大的权重,模型花大量精力去学这些模糊样本,干净的正样本反而被冷落。这两处是我最常踩的坑。
5.2 γ 和 α 的手感:我调参的一些经验
先记住一个原则:γ 调难度权重,α 调类别权重,不要同时大改。我一般先固定 γ=2、α=0.25 复现出论文量级的基线,然后再单变量地去调。
如果 γ 调大后发现 loss 曲线波动剧烈,就看是不是难样本里有太多标注噪声。因为 γ 越大,模型越盯着 p_t 小的样本,而这些样本里很可能混着边界模糊的 anchor。一个可行的缓解方式是把 γ 退回 1.5 或者搭配更严格的 anchor 匹配阈值,把模糊负样本挡在训练之外。
α 的手感更直接:如果检测结果里漏检多(正样本拉不起来),试着把 α 从 0.25 调向 0.5,给正样本更高权重;如果误检多(负样本压制不住),就调向 0.1 或 0.15。但注意 α 和 γ 有轻微耦合,α=0.25 之所以是论文推荐值,是因为它在公开的密集检测场景下平衡得比较好,你的数据分布跟它差别大时,最好做个小网格搜索。
5.3 有关 Loss 数值和收敛曲线的几个盲区
用 Focal Loss 之后,训练 loss 的绝对数值会比交叉熵低很多,这是正常的,别一看到 loss 变低就觉得模型变好。因为调制因子把大量简单样本的贡献压到了 0.001 量级,总 loss 自然好看,但你要关注的是验证集上的 AP 或精确率召回率,而不是 loss 大小。
还有一个盲区是:Focal Loss 对正样本的贡献几乎是“全程保护”的,但正样本数量太少的时候,即便权重很高,梯度也扛不住海量负样本的总体量。这时候除了调 α,还要检查是不是 anchor 匹配阈值太严、正样本太少,或者 FPN 某些层没有分配到正样本(常见于小物体层)。我习惯在训练时打印每个金字塔层级的正样本数量,一旦发现某层长期为 0,首先怀疑 anchor 尺度设置和真实目标尺寸分布不匹配。
5.4 一个建议的调试清单
我把排查经验整理成一张表,方便你对照:
| 现象 | 优先检查项 | 建议动作 |
|---|---|---|
| 训练初期 loss 爆炸或震荡 | 分类头 bias 初始化 | b = -log((1-0.01)/0.01) |
| loss 很低但 AP 不高 | anchor 匹配阈值、灰色地带处理 | 确认 IoU>0.5 为正,<0.4 为负,中间忽略 |
| 漏检严重 | α 过小、正样本太少 | α 从 0.25 上调,检查每层正样本数 |
| 误检严重 | α 过大、γ 过小 | α 下调,γ 上调到 2 左右 |
| 某个尺度目标检测很差 | FPN 对应层 anchor 尺度不匹配 | 调整该层 anchor 基础尺度 |
| 难样本噪声干扰 | γ 过大 | γ 退回 1.5,复查标注质量 |
这份清单不是银弹,每条都需要结合你自己的数据显示来验证。但按照这个顺序查,能省下不少“对着空气调参”的时间。
最后我再说说个人体会。把 Focal Loss 论文从头到尾推一遍之后,我最大的感触是:它真正的贡献不是发明了一个 loss 公式,而是把一个训练动态问题显式地建模进了目标函数。你在读的时候,光背结论不够,最好自己把 p_t 的定义、调制因子的梯度行为、bias 初始化的数值影响都手推一遍。哪怕只是对着公式算三分钟,后面调参手感都不一样。如果你正准备在项目里用它,建议先不要急着换大模型大框架,而是用一个小数据集,把 γ=2、α=0.25 的基线跑通,再逐步试 1、3 和 0.1、0.5。这样踩过一轮坑之后,你对 Focal Loss 的每个旋钮都会有自己的直觉。