资讯动态

Focal Loss原理与PyTorch实现:化解样本不平衡难题

发布时间:2026/10/19 2:33:27 来源:尧图企业网站定制
1. 动态交叉熵损失函数为什么我们需要Focal Loss做深度学习分类任务的朋友十有八九都遇到过样本不平衡的问题。二分类还好说一旦跑到目标检测、语义分割、文本分类这种真实场景里正负样本的比例经常能到1:100甚至1:10000。用普通交叉熵训练出来的模型表现常常很诡异整体准确率看着挺高但真正关心的那一类——比如医学图像里的病变区域、工业质检里的瑕疵、自动驾驶里的行人——召回率却惨不忍睹。我之前在某公司做过一个工业质检项目生产线上拍摄的零件图像里合格品占了绝大多数瑕疵品可能只有千分之几。用标准交叉熵训练一个分割网络loss在验证集上很快降到很低看起来一切正常。但把模型放到实际产线上跑瑕疵的检出率不到40%而且大量误报。后来我把训练日志拉出来逐个分析发现问题出在“简单样本主导梯度”上大量容易分类的正确样本贡献了绝大部分损失网络只需要把那些背景和正常区域学好loss就很漂亮了根本不需要认真学那些稀少的瑕疵。Focal Loss就是为了解决这种问题提出的。它本质上是一个动态调整的交叉熵损失函数核心思想就一句话让模型把注意力放在那些难分类的样本上同时抑制简单样本的干扰。它不是简单地给少数类加权重而是根据每个样本当前的预测置信度动态调整损失贡献难分样本权重增大易分样本权重减小。这种动态特性比传统的固定类别权重灵活得多也稳定得多。这篇文章我会从数学原理、PyTorch实现、训练技巧三个层面把Focal Loss讲透最后还会分享一些我在实际项目里踩过的坑和排查经验。无论你是做目标检测、图像分割、文本分类还是推荐系统只要遇到样本不平衡这篇内容都能直接用。2. 交叉熵与Focal Loss的数学逻辑2.1 从普通交叉熵说起在进入Focal Loss之前必须先搞清楚普通的交叉熵到底在做什么。以二分类为例标准交叉熵定义是这样的[ CE(p, y) \begin{cases} -log(p) \text{if } y 1 \ -log(1 - p) \text{if } y 0 \end{cases} ]用 PyTorch 表示也很简单import torch import torch.nn.functional as F def binary_cross_entropy(logits, targets): # logits: 网络输出未经过sigmoid # targets: 0或1 return F.binary_cross_entropy_with_logits(logits, targets)如果定义 pt 表示模型对正确类别的预测概率——即当 y1 时 ptp当 y0 时 pt1-p——那么交叉熵可以简洁地写成[ CE(p, y) -log(pt) ]这里有个关键点如果模型对一个样本预测正确且置信度很高比如 pt0.99那么它的损失只有 -log(0.99) ≈ 0.01非常小梯度也很小。如果模型预测正确但置信度只有 pt0.5损失大约是 0.69梯度就大得多。换句话说普通交叉熵天然对“难分样本”更敏感问题在于它的敏感程度不够。为什么不够假设你有10000个背景样本和10个瑕疵样本。背景样本很容易学模型很快就能把它们分到 pt0.99 以上每个样本贡献的损失只有0.01但10000个样本加起来就是100。10个瑕疵样本在初期可能被错分每个贡献2左右的损失总共才20。所以在总损失里简单背景样本拿走了大约80%的梯度贡献网络自然倾向于把背景学好。2.2 Focal Loss的公式拆解Focal Loss 在交叉熵基础上加了一个调制因子公式如下[ FL(p_t) -\alpha_t (1 - p_t)^\gamma \log(p_t) ]这里面多了两个超参数α_t类别权重用于平衡正负样本本身的频率差异。γ聚焦参数控制对难分样本的关注程度一般取2。关键就在 (1 - pt)^γ 这个调制项。当一个样本被模型高置信度地正确分类时pt → 1调制因子 (1 - pt)^γ → 0整个样本的损失被压得极低。当一个样本被错分或者置信度不高时pt 较小比如0.3那么 (1 - 0.3)^2 0.49损失只被削减一半左右。我用一个例子说明调制因子的效果假设 γ2某个正样本被正确分类ptCE损失调制系数(1-pt)^2Focal Loss0.90.1050.010.0010.50.6930.250.1730.12.3030.811.865看到了吗pt0.9 的简单样本损失只有原来的百分之一而 pt0.1 的困难样本损失还保留了81%。模型来看简单样本几乎不产生梯度压力它必须花费主要精力去把那些难样本搞定。这就是“动态”二字的含义——权重不是预设的而是跟随模型对每个样本的置信度实时变化。2.3 为什么α和γ要配合使用很多人一开始只加γ不加α结果发现效果并不好。原因在于γ虽然压制了简单样本但它没有办法解决“极少数类样本在总梯度中的绝对贡献太小”的问题。设想一个极端场景正样本只有5个负样本有10000个。即便负样本的损失被调制到很低的量级5个正样本每个pt再低总梯度贡献也可能敌不过数千个负样本的残余梯度。这时候就需要α来人为抬高少数类的地位。α_t 的取值一般设置为[ \alpha_t \begin{cases} \alpha \text{if } y 1 \ 1 - \alpha \text{if } y 0 \end{cases} ]α的取值可以按正样本频率的反比来定。比如正样本占总数量的0.1%那么 α 可以取0.9左右。更精细的做法是把 α 设置成正负样本数量比例的函数[ \alpha \frac{N_{neg}}{N_{pos} N_{neg}} ]我强烈建议先定γ再调α。γ主要控制模型的“注意力分配”α主要控制“类别平衡”。两者搭配使用γ起主导作用α做精细微调。不同任务的最优组合不同但 γ2、α0.25 是很多检测框架的默认配置可以作为起步值。3. Focal Loss的PyTorch实现与参数调优3.1 二分类Focal Loss实现先说最常用的二分类版本。这里我直接给出一个稳定、支持自动求导、兼容混合精度训练的写法import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha0.25, gamma2.0, reductionmean): super(FocalLoss, self).__init__() self.alpha alpha self.gamma gamma self.reduction reduction def forward(self, logits, targets): # logits: [N, 1] 或 [N]未经过sigmoid # targets: [N]取值0或1 logits logits.view(-1) targets targets.view(-1) # 计算所有样本的pt bce_loss F.binary_cross_entropy_with_logits(logits, targets, reductionnone) probs torch.sigmoid(logits) pt torch.where(targets 1, probs, 1 - probs) # 调制因子 focal_weight (1 - pt) ** self.gamma # 类别权重alpha alpha_t torch.where(targets 1, torch.full_like(targets, self.alpha), torch.full_like(targets, 1 - self.alpha)) loss alpha_t * focal_weight * bce_loss if self.reduction mean: return loss.mean() elif self.reduction sum: return loss.sum() else: return loss有几个细节必须注意第一不要在手动计算 sigmoid 后用F.binary_cross_entropy容易导致数值不稳定。正确的是用binary_cross_entropy_with_logits它内部融合了sigmoid和交叉熵计算时做了数值上的保护。虽然我们为了求pt额外算了一次sigmoid但那只是为了计算调制因子不参与梯度传播可以接受。第二targets的数据类型必须是浮点型不能是整型。binary_cross_entropy_with_logits内部要求 target 与 logits 同型且为float否则会报类型错误。第三alpha的逻辑要理解清楚。我传入的是alpha0.25那么对正样本target1使用0.25对负样本使用0.75。也就是说正样本被降低权重负样本被提高了权重。这个0.25来源于检测任务中正样本占比极小的场景。如果你的正样本很少这个值需要反过来思考甚至可以自己测试不同取值。3.2 多分类Focal Loss实现多分类场景稍微复杂一点但原理完全一致。核心是把 pt 定义为模型对目标类别预测的概率class MultiClassFocalLoss(nn.Module): def __init__(self, gamma2.0, alphaNone, reductionmean): super(MultiClassFocalLoss, self).__init__() self.gamma gamma self.alpha alpha # 可以传入一个list表示每个类别的权重 self.reduction reduction def forward(self, logits, targets): # logits: [N, C]未经过softmax # targets: [N]取值0到C-1的整数 log_probs F.log_softmax(logits, dim1) probs torch.exp(log_probs) # 取出每个样本对应目标类别的概率 targets_onehot F.one_hot(targets, num_classeslogits.size(1)).float() pt (probs * targets_onehot).sum(dim1) # focal权重 focal_weight (1 - pt) ** self.gamma if self.alpha is not None: alpha_t torch.tensor(self.alpha, dtypelogits.dtype, devicelogits.device) alpha_t alpha_t[targets] else: alpha_t 1.0 loss -alpha_t * focal_weight * log_probs.gather(1, targets.unsqueeze(1)).squeeze(1) if self.reduction mean: return loss.mean() elif self.reduction sum: return loss.sum() else: return loss这里有一个容易出错的地方多分类的targets是整数索引千万不要直接和 logits 做乘法。我用one_hot提取pt只是提取概率值不参与梯度计算所以用torch.exp(log_probs)没问题。但如果追求更高的数值稳定性也可以用log_probs.gather和exp的组合。3.3 超参数选择的实验方法γ 和 α 到底取多少不能纯靠感觉要做小规模实验。我的经验是先用默认值 γ2、α0.25 跑一轮观察验证集的PR曲线和损失分布。然后固定α0.25分别试 γ1、2、3、5。再固定最优γ试 α0.1、0.25、0.5、0.75。γ 太大有一个明显副作用当 pt 接近1的简单样本几乎不产生任何梯度模型可能会陷入欠拟合尤其在小数据集上训练速度明显变慢。γ5 时pt0.9 的调制系数是 0.00001这个样本的梯度基本消失了。所以在小数据集上不要盲目追求高γ。α 的取值如果太大也会带来问题。比如 α0.9 表示正样本损失放大9倍相对于负样本这会让模型在训练初期强烈偏向正类产生大量假阳性。检测任务里 α0.25 是原作者在RetinaNet上网格搜索出来的但在你自己的数据上未必最优。我遇到过一个场景正样本比例只有0.01%把α提到0.7才勉强有效但代价是误报率激增后来我把检测阈值从0.5调到了0.8才平衡回来。3.4 训练过程中的动态调整Focal Loss本来是“样本级动态”但在训练过程中γ和α也可以做“训练级动态”。一个实用的做法是训练前期用较小的γ比如1让模型先学会基本特征训练中期逐渐把γ调到2或3让模型把注意力转移到困难样本上。这种退火策略在部分任务上能比固定γ获得更好的收敛效果。PyTorch里实现也不复杂可以在每个epoch开头调整模块里的gammadef adjust_gamma(model, epoch, max_epoch): target_gamma 3.0 start_gamma 1.0 gamma start_gamma (target_gamma - start_gamma) * (epoch / max_epoch) for module in model.modules(): if isinstance(module, FocalLoss): module.gamma gamma不过我个人使用下来这种动态调整在非常不平衡的数据集上效果显著但在中等不平衡场景下和固定γ差别不大。如果你的时间有限建议先从固定γ开始把基线跑稳再考虑退火策略。4. 损失值异常与训练稳定性的排查实录4.1 损失变成NaN这是最常见的问题尤其在使用 Focal Loss 的初期。主要原因是数值溢出常见诱因有三个第一个logits 输出过大。当网络最后没有加归一化层或者权重初始化不合适logits 可能达到几百甚至上千。sigmoid(1000) 在 PyTorch 里计算结果也是1pt1而 (1-pt)^2 是0损失为0看起来不NaN。但更危险的是 log_probs在多分类场景如果logits巨大softmax后某个类别的 log 概率可能变成 -inf取负就是inf乘以0后仍是 NaN。解决办法很简单检查输入数据是否归一化、检查网络最后一层是否初始化过大。另一个通用做法是在损失函数里对 logits 做 clamp但我不建议因为会引入梯度截断。更合理的是在网络最后一层加LayerNorm或者在初始化时把最后一层bias设为0、权重设为均值为0方差极小的值。第二个α 设置成极端值。比如你把 α 设为0.9999 或 0.0001类别加权后某个类别的损失会被放大到非常大的量级如果梯度裁剪没开很容易爆掉。解决方案是开启梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)第三个混合精度训练下的精度损失。AMP模式下FP16的表示范围有限如果损失值太大或太小可能下溢或上溢。解决方法是在 loss 计算完成后乘一个 loss_scale或者直接用torch.cuda.amp.GradScaler自动处理。如果发现 NaN可以临时关闭AMP或者把scaler.scale(loss)改成scaler.scale(loss * 0.01)试试。4.2 训练损失不降验证指标没变化Focal Loss不是万灵丹很多情况下效果不好不是因为实现错了而是因为它掩盖了模型能力不足的问题。我遇到过好几次加了 Focal Loss 之后总loss降得很慢验证集精确率提升了但召回率下降整体F1还不如普通交叉熵。这时候先不要急着调参动手画一下训练集上正样本的 pt 分布。如果大部分正样本的 pt 长期集中在0.5以下说明模型根本没有能力区分它们Focal Loss并不能凭空创造信息它只是放大“学习注意力”。此时真正该做的是增强模型容量、加数据增强、引入额外特征而不是修改损失权重。另一个隐蔽问题是标签噪声。Focal Loss对难样本的强调会把一些误标注的样本当作“难样本”来强化学习反而让模型记住噪声。比如某一张瑕疵图实际上是误检标签却是瑕疵模型每次都在这个样本上产生高损失Focal Loss会把它放大数倍导致模型反复被一个错误样本带偏。如果你怀疑标签质量先用普通交叉熵训练把预测置信度低的样本找出来人工复核一遍再使用 Focal Loss。4.3 类别权重和调阈值的关系很多人用 Focal Loss 训练完模型后依然用默认的 0.5 作为分类阈值这是不对的。Focal Loss偏置了模型对少数类的输出概率最终预测概率往往被压得偏低或偏高不再具有天然的概率解释意义。举个例子我用 Focal Loss 做瑕疵检测时模型对真实瑕疵样本输出的 sigmoid 概率普遍只有0.2附近但同时也把很多正常样本输出在0.15左右。如果阈值取0.5真瑕疵全被滤掉效果极差。正确做法是在验证集上画出PR曲线选择能使 F1 最大化的阈值而不是直接0.5。另外α 调得越大模型输出概率分布偏移越严重。所以调阈值和调α要配合进行。一种更稳的操作是不调整α仅通过Focal Loss的γ让模型关注难样本然后用一个加权的决策阈值来控制最终输出。这样损失函数和决策函数解耦开排查问题更方便。4.4 常见问题速查表现象可能原因排查方法解决方案loss为NaN数值溢出 / logits爆炸查看logits范围梯度裁剪、初始化调整loss为NaNalpha设置极端检查alpha数值设回0.1~0.9范围loss为NaNAMP混合精度溢出关闭AMP试跑调整loss scale训练过慢gamma过大对比gamma1与gamma3降低gamma或启用退火指标不如交叉熵模型容量不足看pt分布增强特征或模型指标不如交叉熵标签噪声低置信样本人工复核清洗数据集少数类预测概率异常网络过拟合少数样本观察训练/验证差异提前早停、降低alpha5. Focal Loss在典型任务中的实战配置5.1 目标检测RetinaNet的经典选择Focal Loss第一次出名就是因为单阶段检测器RetinaNet。在检测任务里输入的图像经过特征金字塔之后会生成成千上万个锚框其中绝大多数是背景。标准交叉熵下哪怕一个背景框的损失很小数量一多也会淹没少数前景框的梯度。RetinaNet 论文里给出了一套经过反复实验的配置γ2α0.25并且在所有检测头输出上分别计算每个锚框的Focal Loss然后求平均。实际使用中我一般会额外加上一个细节仅在正负样本匹配后的锚框上计算损失使用OHEM或ATSS匹配策略而不是把全部锚框都拉进去计算。如果使用的是现成框架如MMDetection只需要在配置里设置loss_clsdict( typeFocalLoss, gamma2.0, alpha0.25, loss_weight1.0 )框架内部会处理好正负样本分配你主要需要关注的是在验证时选择合适阈值。检测场景的评估指标通常用 mAP不太依赖单一阈值所以调阈值压力小很多。5.2 图像分割处理小目标与样本稀疏分割任务中背景像素通常占90%以上病灶或目标区域可能只有零星几个像素。直接用Dice Loss或Focal Loss都可以但很多人不知道Focal Loss和Dice Loss互补性很强。我常用的组合是主损失用Dice Loss辅助用Focal Loss二者加权相加loss dice_loss(pred, target) 0.5 * focal_loss(pred, target)Dice Loss擅长处理类别不平衡对区域重叠敏感但梯度在极端情况下可能会不稳定Focal Loss擅长抑制背景噪声和聚焦边界困难像素。两者叠加在很多分割比赛里效果显著。不过要注意Focal Loss在图像分割的逐像素计算时简单像素数量极大即使调制后仍可能有大量梯度来自背景边界因此可以结合top-k损失或只计算目标类别区域内的Focal Loss。5.3 文本分类与推荐系统文本分类里Focal Loss同样适用尤其是情感分析、垃圾识别、异常行为检测这类少数类占比极低的任务。文本数据通常没有图像那么显著的“简单样本”因为一个句子要么类别明确要么模棱两可很难像像素那样逐点置信度。因此Focal Loss在文本任务上的收益往往没有检测和分割明显。推荐系统里隐式反馈数据中负样本未点击数量远超正样本点击但这里的“难样本”含义不同——那些用户没看到因而没点击的样本根本不是“难”而是“不该出现”。强行用Focal Loss给低置信度负样本加权重可能导致系统拼命学那些不可能被点击的样本得不偿失。此时更适合做负采样加普通交叉熵而不是直接上Focal Loss。所以我要强调一点Focal Loss不是任何不平衡问题都有效的万金油。它更适合“简单样本和困难样本混杂、模型有一定区分能力”的场景。如果你的数据噪声极大或者负样本定义本身不清晰优先处理数据质量别指望损失函数救场。6. 从Focal Loss延伸出去动态损失的更多可能6.1 自适应调制γ随训练动态变化前面提过退火策略更进一步的想法是让γ根据每个样本的pt自适应。有人提出把固定γ换成函数形式比如 γ(pt) γ0 λ * (1 - pt)这样简单样本和困难样本的聚焦力度不同调制曲线更平滑。不过在工程实践中这种自适应带来的收益不明显却引入了额外超参数我建议是先做基准实验确定收益后再考虑复杂度。6.2 与其他损失函数的组合使用把Focal Loss直接替换掉所有任务的交叉熵并不一定最优。很多情况下Focal Loss可以作为一个辅助损失和原本的主损失混合。比如在分割任务中主损失用IoU损失辅助损失用Focal Loss让网络兼顾区域一致性和难点挖掘在检测任务中分类分支用Focal Loss回归分支用Smooth L1或者GIoU损失。组合时要小心损失权重Focal Loss的量级通常比普通交叉熵低很多因为调制因子削弱了简单样本如果不放大loss_weight它可能被其他损失完全压制。具体来说可以先把普通交叉熵的初始loss值记为 L1把Focal Loss加上相同权重后的初始loss值记为 L2然后是 L2/L1 的比值决定是否需要乘一个系数。我一般会加一个focal_weight2.0到5.0具体看实验。6.3 和其他不平衡处理技术的配合Focal Loss和很多不平衡技术并不冲突但要讲究顺序。最常见且有效的工作流是先用类别频率统计设置合理的α。对少数类做数据增强比如在图像任务中做随机裁剪、翻转、色彩抖动。使用Focal Loss训练监控梯度分布。在验证集上选择最佳阈值。不建议同时使用Focal Loss重采样过采样少数类高α一起上否则少数类样本被重复加权模型容易过拟合。按照我的经验数据增强优先于损失函数Focal Loss是第二步而重采样通常不需要了——因为调制因子已经从梯度层面平衡了样本贡献。6.4 Focal Loss的局限与替代方案Focal Loss的主要局限在于调制函数是启发式的不具有理论上的最优性。在某些任务中它可能比不过直接优化AUPRC通过排序损失或者用对比学习先学表征再微调。特别是当类别极不平衡且同类内差异极大时单纯调制损失不如换个特征表示。我见过一个实际案例某公司做信用卡欺诈检测正样本比例只有0.001%特征高度重叠。用Focal Loss训练XGBoost很难调参因为他们用的是基于树的模型损失函数不可导。后来团队改用Isolation Forest做无监督异常检测再叠加一个有监督排序模型效果反而更好。这说明损失函数只是整个解决方案的一部分特征工程、模型选择、评估指标同样重要。7. 我的一些实操心得与建议先说一个反直觉的发现Focal Loss在你最需要它的时候正样本极少、模型很差可能会让训练更难收敛。因为初期模型对几乎所有样本的置信度都不高pt普遍在0.5以下调制因子 (0.5)^20.25 并没有很强地抑制简单样本反而整体损失被压缩到很小的量级梯度整体变小训练变慢。这时可以适当调大学习率或者先用普通交叉熵训练几个epoch让模型有一个基本判断能力后再切换为Focal Loss。这个trick在多个项目里帮我把收敛速度提升了近一倍。另一个心得是监控调制后的有效样本数。每次训练时可以统计一下平均focal_weight也就是 (1-pt)^γ 的均值。如果这个值一直很大说明大部分样本都很难分模型能力不够如果一直很小说明简单样本被压制得太狠模型在“躺平”。我会在TensorBoard里实时记录这个指标直观看到模型的注意力状态。以下是一个简单的统计代码def log_focal_weight(logits, targets, gamma2.0): probs torch.sigmoid(logits).detach() pt torch.where(targets 1, probs, 1 - probs) weight (1 - pt) ** gamma return weight.mean().item() # 每次迭代后 fw log_focal_weight(logits, targets, model.gamma) logger.add_scalar(focal_avg_weight, fw, global_step)如果训练初期平均focal_weight高于0.5我会检查数据是否存在严重噪声或者模型结构是否太弱如果低于0.1且验证指标不再上升说明难点已经被攻克可以端到端收工了。最后说说评估心态的问题。Focal Loss改变了模型内部的概率分布所以显示在验证集上的准确率可能没有提升甚至有所下降。但看PR曲线和F1指标往往会发现少数类召回率大幅上升。在一个医疗影像辅助诊断项目中使用Focal Loss后病变检出率从58%提升到79%而整体准确率只下降了1%。如果你只看准确率可能会误判这个改进是无效的。评估指标必须跟着业务目标走这点比损失函数的任何参数都重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑