做检测或者做极端不平衡分类的朋友大概率都经历过这种卡顿loss 一直在降但验证集的召回率就是不动。把所有预测结果拉出来一看模型对绝大多数样本已经很有把握了却对真正有价值的几个难样本毫无办法。我第一次认真啃Focal Loss就是在这样的状态下开始的——当时我自己写的一个二分类模型在 1:200 的正负样本比下怎么调权重都没用换成 Focal Loss 之后梯度方向才算真正发生了质变。这篇文章从原理推导到 PyTorch 落地代码把我踩过的坑和验证过的写法完整梳理一遍适合两类人看一是刚接触不平衡分类、想知道 Focal Loss 到底在做什么的新手二是已经在用交叉熵硬扛、想通过改损失函数提升最终指标的人。1. 交叉熵到底是怎么被绝大多数易样本绑架的1.1 一个 1000:1 的场景损失值被谁控制了Focal Loss 最早是面向单阶段目标检测提出的但今天你用它在任何不平衡分类场景里跑碰到的依然是同一个问题负样本数量远大于正样本而且其中大多数是易样本。什么叫易样本拿二分类举例模型给一个负样本背景打出的正类概率 p 只有 0.1说明它判断得很果断这就是易负样本。这样一个样本的交叉熵损失是CE -log(1 - p) -log(0.9) ≈ 0.105单个看0.105 很小几乎可以忽略。但如果图片里背景框有 1000 个光这些很轻松的负样本加起来就是1000 × 0.105 ≈ 105而一个真正难分的负样本模型给它的正类概率 p 0.8也就是模型其实难以分辨它的交叉熵是CE -log(1 - 0.8) -log(0.2) ≈ 1.609到这里对比已经很明显了一个难样本的损失只有 1.61000 个易样本的总损失却有 105后者是前者的 65 倍左右。这个场景里损失函数实际上被庞大的易样本集群绑架了。模型只要能把背景框都压低到一个还不错的概率loss 就很好看训练注意力自然不在难样本上。1.2 梯度视角下的真相易样本数量碾压难样本只看损失还不够更关键的是反向传播时梯度到底由谁贡献。对 sigmoid 二分类的交叉熵负样本的梯度大小就是模型预测的正类概率 p。还是上面的例子1000 个易负样本p 0.1单个梯度 0.1合计梯度贡献 1001 个难负样本p 0.8单个梯度 0.8也就是说梯度的 100.8 个单位里有 100 个单位来自那些早就被判对的易样本占比 99% 以上。难样本那 0.8 的梯度在优化过程中基本被淹没了。这里有个反直觉的点易样本单个梯度小但架不住数量大它们的总和反而会主导参数更新方向。加权交叉熵能缓解一部分问题但解决不了本质——因为加权只是给负样本整体一个缩放系数并没有改变置信度越高、贡献越大这个规律。真正需要的是让易样本的贡献随置信度提高而快速衰减这正是 Focal Loss 的调制因子要做的事。2. Focal Loss 的三步改造从加权交叉熵到难易感知的损失曲面2.1 公式递进一张表看懂 p_t、调制因子和 α 的分工Focal Loss 不是拍脑袋想出来的它是从交叉熵一步步改出来的。先把核心变量说清楚。交叉熵针对的是真实类别的预测概率记作 p_t。二分类里正样本y1p_t p也就是模型预测为正类的概率负样本y0p_t 1 - p也就是模型预测为负类的概率p_t 越接近 1说明样本被分得越对、越容易p_t 越小说明样本分错了、越困难。标准的交叉熵是CE(p_t) -log(p_t)第一步加入类别平衡因子 α_t解决数量不平衡CE_balanced(p_t) -α_t · log(p_t)α_t 是一个查表权重正样本取 α负样本取 1-α。这一步相当于给不同类别的样本定一个基础贡献度和常见的pos_weight一个思路它解决的是数量问题。第二步加入调制因子 (1-p_t)^γ解决难易不平衡FL(p_t) -α_t · (1-p_t)^γ · log(p_t)这就是完整形式。调制因子的意图非常明确p_t 越接近 1意味着样本越容易乘上的 (1-p_t)^γ 就越小损失贡献被压得越低p_t 很小说明样本很难调制因子趋近 1损失几乎不受影响。整个式子等于在说分类对的样本少学一点分类错的样本多学一点。2.2 调制因子的数值直觉(1-p_t)^γ 在典型 p_t 下的衰减幅度空谈公式不够直观我贴一张常用的数值对照γ 取论文推荐的 2.0p_t含义调制因子 (1-p_t)^2损失被缩放的倍数0.9非常容易的样本0.01缩小到 1/1000.7中等偏易0.09缩小到约 1/110.5临界样本0.25缩小到 1/40.2比较难0.64仅缩小到 64%0.1很难0.81基本不缩把刚才 1000:1 的例子重新算一遍。易负样本 p 0.1p_t 0.9调制因子 0.01单个 focal loss 约等于 0.001051000 个合计约 1.05难负样本 p 0.8p_t 0.2调制因子 0.64单个 focal loss 约 1.03。现在两者几乎打平了。这就是 Focal Loss 能从根源上扭转局面的原因不是简单把负样本变小而是按难度动态重新分配了损失预算。2.3 α 与 γ 联动的方向数量与难易是两回事很多人会忽略一个问题α 和 γ 的作用其实是正交的。α 只负责在数量层面为少数类找回均衡它不关心样本难易γ 负责在难易层面压掉易样本它不关心类别占比。两者组合起来才是完整的损失函数这也是 Focal Loss 比单纯的加权交叉熵灵活的地方。提一下论文里的取值经验γ2、α0.25 用得最多。注意这里的 α0.25 是给正样本前景类的权重负样本权重要取 1-α0.75。这个设定让不少第一次看的人困惑负样本明明更多为什么权重反而更大原因很简单调制因子已经在负样本那边大幅压低了易样本的贡献α 在这里主要是微调剩余难样本在两类之间的分布。实际项目里α 不是必须等于类别比例的它更多是一个和 γ 需要一起搜的超参数。我自己的经验是把 γ 固定然后从 {0.25, 0.5, 0.75} 里试 α再看验证集上稀有类别的召回和精确度平衡。3. PyTorch 落地二分类与多分类的完整代码及易错细节3.1 二分类/多标签版本用 logits 而不是手工 sigmoid很多网上流传的 Focal Loss 实现是先把 logits 过一遍 sigmoid再对概率取 log这是个隐患。更稳的做法是直接用binary_cross_entropy_with_logits让 PyTorch 内部处理 sigmoid 和 log 的数值稳定问题。下面是我实际在用的二分类版本适用于单标签二分类也可以直接扩展到多标签场景。import torch import torch.nn as nn import torch.nn.functional as F class BinaryFocalLoss(nn.Module): 二分类或多标签Focal Loss。 输入必须是不带 sigmoid 的原始 logits 内部用 binary_cross_entropy_with_logits 计算 避免手动 sigmoid log 造成的数值不稳定。 def __init__(self, alpha: float 0.25, gamma: float 2.0, reduction: str mean): super().__init__() self.alpha alpha self.gamma gamma self.reduction reduction def forward(self, logits: torch.Tensor, targets: torch.Tensor): logits logits.float().reshape(-1) targets targets.float().reshape(-1) # 1. 稳定的 BCE 项内部完成 sigmoid bce F.binary_cross_entropy_with_logits( logits, targets, reductionnone ) # 2. p_t真实类别的预测概率 # 正样本 p_t sigmoid(logits) # 负样本 p_t sigmoid(-logits) p_t targets * torch.sigmoid(logits) \ (1 - targets) * torch.sigmoid(-logits) # 3. 调制因子易样本 p_t 接近 1权重趋近 0 modulating (1 - p_t) ** self.gamma # 4. alpha 平衡项 alpha_t self.alpha * targets (1 - self.alpha) * (1 - targets) loss alpha_t * modulating * bce if self.reduction mean: return loss.mean() if self.reduction sum: return loss.sum() return loss这套代码我在一个多标签分类任务上用过每张图平均有 6 个标签、标签稀疏度在 5% 以下结果比之前用pos_weight的加权 BCE 稳定不少。特别注意p_t的写法用了一个小技巧——targets 是 0/1 浮点正样本走sigmoid(logits)负样本走sigmoid(-logits)一次乘加就完成不需要torch.where分支。3.2 多分类版本用 exp(-ce) 代替手动 softmax多分类场景下交叉熵公式变成了对目标类别那条 logit 的 softmax 概率取负对数。最稳健的实现方式是先调F.cross_entropy拿到每个样本的损失再用torch.exp(-ce)反推出 p_t。为什么可以这样反推因为F.cross_entropy内部做的是log_softmaxce -log(p_t)那么 exp(-ce) 恰好就是 p_t。这个做法的好处是 p_t 完全在 log 空间里被计算出来规避了先算 softmax 再取 log 的溢出风险。完整代码如下class MultiClassFocalLoss(nn.Module): 多分类 Focal Loss。 logits : [N, C] 原始输出 targets : [N] long 类型的类别索引 def __init__(self, alphaNone, gamma: float 2.0, reduction: str mean): super().__init__() self.alpha alpha # 可以是长度为 C 的 Tensor或 None self.gamma gamma self.reduction reduction def forward(self, logits: torch.Tensor, targets: torch.Tensor): # 1. 先算标准交叉熵reduction 改成 none 保留逐样本损失 ce F.cross_entropy(logits, targets, reductionnone) # 2. 用 exp(-ce) 反推目标类别概率数值稳定 p_t torch.exp(-ce) # 3. 调制因子 BCE 项组合 loss (1 - p_t) ** self.gamma * ce # 4. 按类别查表 alpha if self.alpha is not None: if isinstance(self.alpha, torch.Tensor): alpha_t self.alpha[targets].float() else: # 标量 alpha 视为第 1 类权重其余类别取 1-alpha alpha_t torch.full_like( targets, 1.0 - self.alpha, dtypetorch.float32 ) alpha_t[targets 1] self.alpha loss alpha_t * loss if self.reduction mean: return loss.mean() if self.reduction sum: return loss.sum() return loss这个版本在处理 10 分类以上的不平衡分类任务时比二分类版本更通用。需要注意如果要用ignore_index比如 NLP 里的 padding tokenF.cross_entropy对忽略位置返回的 ce 是 0此时 p_t exp(0) 1调制因子为 0loss 自然为 0逻辑是通的但如果你传入标量 alpha被忽略位置仍可能参与 alpha 的查表逻辑建议这种情况下简单按 mask 把 loss 清 0。3.3 训练循环里的接入示例与最后一层偏置初始化写完了两个类怎么接到训练循环里其实很简单但有一个细节经常被人忽略最后一层偏置的初始化。如果你的正样本先验比例大约是 0.01比如 100 个背景里才有 1 个目标默认的偏置初始化为 0意味着模型一开始预测正类概率接近 0.5。对于 1000:1 的数据开局阶段负样本会集体误判出不小的损失哪怕之后会降下来早期训练也是浪费。建议把最后一层偏置直接初始化为 log(π/(1-π))π 就是正样本先验比例。import math # 训练骨架 model SomeClassifier(num_classes10) criterion MultiClassFocalLoss(alphaNone, gamma2.0) optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9) # 二分类场景正样本先验比例 pi 0.01 # pi 0.01 # model.last_layer.bias.data.fill_(math.log(pi / (1 - pi))) for images, labels in train_loader: images, labels images.cuda(), labels.cuda() logits model(images) loss criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step()这样初始化之后模型开头输出的概率就接近真实的先验比例不会一上来就产生成百上千个高置信度误判的负样本Focal Loss 的调制因子在早期也能更快进入正常调节状态。3.4 三个经常写错的细节我在这里把踩过三次的坑集中说一次。第一个坑是手工算 sigmoid 和 log。不少人写成p torch.sigmoid(logits)然后-torch.log(p)一旦 logits 出现极端值比如 20sigmoid 后 p 几乎等于 1log(p) 直接取到 0导致梯度爆炸或 NaN。用binary_cross_entropy_with_logits或者cross_entropy即可绕开。第二个坑是 alpha 的作用范围。alpha_t 是按目标类别取的权重不是对整个 batch 乘同一个标量。多分类里如果直接把self.alpha乘到整批 loss 上等于给所有类别均匀缩放没有任何平衡效果。第三个坑是 reduction。很多实现默认mean但在样本极端不平衡时mean会把少数类样本的损失稀释掉。试想整个 batch 3000 个全是负样本、只有 5 个正样本mean 出来的数值对正样本几乎没有感知。遇到这种情况我通常直接用sum再单独除以正样本数或者自己重新定义加权平均。4. 调参经验与边界γ、α 怎么选以及什么时候别用4.1 默认参数、网格搜索范围与失衡程度的关系初始值记住两个γ2α0.25。这是原始论文里经过大量实验扫出来的组合绝大多数检测任务用它起步都合理。但分类任务里的最优失衡程度和检测不一样所以我习惯用一个小网格做粗搜γ 在 {0.5, 1.0, 2.0, 3.0} 里试α 在 {0.1, 0.25, 0.5, 0.75} 里试每次只动一个参数另一个固定失衡程度越轻比如 10:1、5:1γ 可以选得越小甚至 0.5~1.0 就够。失衡非常极端1000:1 甚至更高γ 拉到 2~3 才有效果。反过来γ 太高也有副作用早期训练几乎没人给梯度因为大部分样本都处于 p_t 略高于 0.5 的状态调制因子把它们全削掉了你会看到 loss 前几十个 iteration 几乎不动。此时可以先把 γ 调到 1 跑几个 epoch再切回 2。我项目里用这个策略做过几次迁移都比直接上 γ3 更稳。4.2 Focal Loss 与类别加权交叉熵的边界有个问题值得单独说都有现成的类权重方案为什么还要 Focal Loss因为两者的边界很清晰。加权交叉熵是对样本数量做线性缩放。还是 1000:1 的例子你把负样本权重设成 0.1每个易负样本的损失从 0.105 变成 0.01051000 个合计仍有 10.5而难样本的 1.609 在里面依然站不住。加权解决的问题是数量失衡但它不改变置信度越高、贡献越大的规律。Focal Loss 的问题是难易失衡它把高置信度样本的贡献非线性地压下去。注意这两者可以叠加使用——实际项目里我经常在 Focal Loss 的 α 之外再额外给极端少见的类别乘一个较小的class_weight效果是两层平衡同时作用。但你要知道自己在干什么既要调数量又要调难度超参空间会变大得有验证集上的量化指标兜底不能靠感觉。4.3 与 label smoothing、梯度裁剪的相互作用Focal Loss 的调制因子基于 p_t一旦你开了 label smoothing目标类别的 p_t 就不再是模型真实输出的概率了。常见做法是把 one-hot 标签换成平滑值比如 0.1 给到其他类别此时用 exp(-ce) 反推出来的 p_t 会被低估或高估调制因子的易样本归零性质就会走样。我的处理方案是非必要不混用如果确实需要正则化就单独放开一个权重参数把 label smoothing 当辅助 loss 加在总体 loss 上而不是混进 focal 的 p_t 计算里。梯度裁剪在 Focal Loss 下更重要。因为难样本的梯度会被放大尤其在 γ 较大时少数极端难样本可能产生非常大的梯度单步更新把参数撞飞。我一般会配一个 max_grad_norm 在 5 到 10 之间具体值看 loss 曲线是否出现尖刺出现尖刺就往下降。4.4 什么时候别用 Focal LossFocal Loss 不是万能药。以下三种情况我明确不建议用一是数据本身已经平衡或者你只关心整体准确率不关心少数类。这种情况下 Focal Loss 削掉易样本纯属降低信息利用率收敛会变慢指标还可能不如交叉熵。二是标签噪声明显的任务。调制因子会把难样本的权重抬起来但你很难分辨它到底是真难还是标签标错了。噪声标签往往是模型高频记忆的对象Focal Loss 相当于主动给这些错误样本发权重很容易过拟合到错误上。三是需要良好概率校准的场景。Focal Loss 改变的是损失分布不是模型输出分布。我在一个风控项目里试过最终 precision/recall 确实涨了 2 个点但输出的 sigmoid 概率整体偏移了不做温度缩放直接当置信度用会出事。5. 验证它是否起效三个可落地的检查方法与快速A/B方案5.1 训练中要盯的三类指标我见过不少朋友把 loss 换成 Focal Loss 之后就只盯着 train loss 看这是不够的。至少要看三类东西。第一类是难度分层的损失统计。把每个 batch 的 p_t 算出来分组统计 p_t 0.9易样本组和 p_t 0.5难样本组的平均损失。理想状态是易样本组的平均损失随训练快速下降并保持低位难样本组的平均损失下降相对缓慢但持续下降。如果难样本组损失长期不动要么 γ 太强把早期信号都削没了要么模型容量不足后者不是换损失函数能解决的。第二类是稀有类别的召回率变化。在验证集上固定一个阈值分别统计正负样本的召回和误报。Focal Loss 的目标不是让整体 loss 变小而是让稀有类别的边界变好。只看整体 AUC 容易骗自己要看 PR 曲线或者 AP。第三类是梯度方向的质量。取几个 batch记录参数更新前梯度的 L2 范数再按 p_t 分层看各类样本对梯度的贡献占比。如果调完 γ 后难样本的梯度贡献占比从 1% 涨到了 20%说明损失函数确实把训练注意力拉过去了后面的指标才有盼头。5.2 一个最小可行的对比实验方案如果要在自己的任务里确认 Focal Loss 值不值得用我建议做这个最小实验一周内可以出结论。固定训练数据、batch size、优化器、学习率、epoch 数只换损失函数三个对照组组别损失函数说明A 组标准交叉熵基线B 组加权交叉熵按类别数量倒数加权C 组Focal Lossγ2, α0.25目标方案每组跑同样的 epoch最后比较三个东西稀有类别的 AP / F1、整体 AUC、以及验证集上难样本的 p_t 分布。实验做完你会发现B 组通常能提升一点召回但精确度和难样本识别上往往不如 C 组少数情况下 C 组会劣于 B 组那多半是 γ 选太大或者数据本身就没什么难样本这时候老老实实用回加权交叉熵。实践里还有一个经验Focal Loss 的收益在难样本有信息量的任务里最明显。如果你的数据里少数类样本之间彼此长得差异不大、模型只是单纯没见过足够多的量那么把它当成采样问题增加数据、做增广、重采样比换损失函数更有效。调损失函数是对症下药但不是所有不平衡都能靠它对症。回到代码层面我最后再分享一个小技巧写 Focal Loss 的时候梯度覆盖和内存占用是容易被忽视的。像分割任务里输入是 [B, C, H, W]直接把空间维全部展平再算 loss中间会生成一个和标签一样大的浮点张量。图片分辨率高、batch 大时显存会明显吃紧。这时可以把空间维拆成若干 chunk 分批算或者把p_t、modulating这样的中间量用torch.clamp限制在合法区间后及时释放代码逻辑不变显存压力小很多。我现在写任何不平衡分类的 baseline都是先跑一遍这套 Focal Loss 实现再决定要不要往上层加采样、加权或伪标签方案。