资讯详情

资讯详情

Focal Loss详解:从交叉熵到one-hot实现,解决类别不平衡的实战指南

写这个标题是因为我自己在训练一阶段检测模型时被类别不平衡搞得焦头烂额背景样本淹没了前景样本模型训到最后只会无脑输出“无目标”。后来切到Focal Loss一天之内指标就回暖了。这篇文章想把Focal Loss的核心逻辑、one-hot实现细节、以及我在实际项目里踩过的坑完整梳理一遍。它适合正在做目标检测、图像分割、文本分类或多标签分类手上正好有类别不平衡问题的同学也适合那些想从损失函数底层理解模型在“学什么”的人。1. 先搞清楚交叉熵在做什么不平衡问题到底出在哪1.1 交叉熵的“置信度”视角大多数分类任务的基础损失是交叉熵。对于单标签多分类模型输出的logits经过softmax变成概率分布然后用one-hot标签取交叉熵。公式长这样[ CE -\log(p_t) ]其中 (p_t) 是指“标签真实类别对应的预测概率”。如果标签是猫模型预测猫的概率为0.9那么p_t就是0.9损失是-log(0.9)很小如果模型预测猫的概率只有0.1损失就是-log(0.1)很大。这个视角很重要交叉熵本质上是在督促模型对正确类别给出高置信度。它不关心错误类别之间怎么分配只关心“正确的那一类是不是概率最大、够不够自信”。1.2 类别不平衡不是“样本少”这么简单类别不平衡问题大家第一反应是“少数类样本太少”。这句话对但还差一层真正影响训练的不只是样本数量而是每个类别的总梯度贡献。拿目标检测里的anchor分类举例。一张图可能产生几万个候选框其中绝大多数是背景。如果直接用交叉熵每个背景框都是容易样本——模型很快就学会把它们判为背景而且判得很自信p_t接近1单个损失非常小。但架不住数量多几万个背景框的损失加起来照样把少数几个前景框的损失淹没。最终的梯度方向完全被背景主导模型对前景的学习效率极低。这种情况下少数的前景样本其实已经不能靠“减少背景样本数量”来解决——减少负样本又会丢失难例信息。真正的痛点是交叉熵给所有样本的权重是等价的大量简单样本贡献了大量无用的梯度。1.3 Focal Loss 的引入逻辑Focal Loss的核心思想非常直接给容易样本降权给难样本加权的自然结果。怎么判断一个样本难易就看p_t。p_t大说明模型已经能正确且自信地预测这是简单样本权重应该压低p_t小说明模型预测得糟糕这是难样本权重应该保持甚至放大。标准Focal Loss公式[ FL -(1-p_t)^\gamma \log(p_t) ]对比交叉熵多了一个调制因子 ((1-p_t)^\gamma)。注意理解这个因子的作用当p_t接近1时(1-p_t)^γ接近0简单样本的loss被压得很低当p_t接近0时(1-p_t)^γ接近1难样本的loss基本不降权γ控制降权力度γ越大简单样本权重被压得越狠。这个设计的巧妙之处在于它不是靠“采样”来平衡而是靠“连续加权”来平滑调节每个样本的贡献。样本的难易程度都是动态变化的——一开始都是难的模型学好了它们变成容易样本权重自动下降。这就是Focal Loss相比固定采样策略更优雅的原因。2. 基于one-hot的Focal Loss公式到代码的每一处细节2.1 从零点展开one-hot到底扮演什么角色既然p_t是“标签对应类别的预测概率”那么在代码里就面临一个选择怎么把标签和概率对应起来。如果用稀疏标签比如整数索引3表示类别3通常的做法是用gather操作从预测概率里把第3个元素摘出来。但如果标签是one-hot向量处理方式就变成“点乘”[ p_t \sum_{c} one_hot_c \cdot softmax(logits)_c ]因为one-hot里只有真实类别那一列为1其余为0所以点乘结果正好就是真实类别的预测概率。这个形式有两个明显好处表达简洁用矩阵运算一步算完天然兼容多标签场景。多标签时one-hot有多列为1点乘结果就是所有正类预测概率之和展开后等价于对每个正类分别算损失再汇总。所以“基于one-hot的Focal Loss”本质上就是把Focal Loss的p_t用one-hot标签与预测概率的内积来实现。它在很多深度学习框架里都很常见尤其是目标检测头输出sigmoid、标签又恰好是one-hot矩阵时代码写起来非常自然。2.2 从one-hot实现的Focal Loss两种派生形式需要先厘清一个容易混淆的点Focal Loss在原文里是基于二分类交叉熵推导的但在多分类场景里可以有两种实现路线。路线一softmax one-hot。模型最后一层是全连接logits经过softmax得到所有类别的概率分布然后按one-hot标签取交叉熵。此时p_t max(真实类别的概率)调制因子针对这个概率计算。这种形式适用于单标签多分类比如图像分类、文本分类。路线二sigmoid one-hot。模型对每个类别独立输出一个二分类概率激活函数是sigmoid标签仍然是one-hot向量。此时Focal Loss写成逐类别的形式[ L -\sum_c \left[ \alpha y_c (1-p_c)^\gamma \log(p_c) (1-\alpha)(1-y_c) p_c^\gamma \log(1-p_c) \right] ]这就是目标检测里最常见的写法因为每个anchor框是否属于某个类别是一个独立的二分类问题。one-hot在这个形式里不是“选类别标签”而是逐元素地给每个类别的二分类交叉熵加mask。两种路线都叫Focal Loss但在代码里p_t的取法完全不同。我看到很多初学者把softmax版本和sigmoid版本搞混最后调试半天发现loss根本不收敛。记住一个判断标准输出层激活函数是softmax就走路线一是sigmoid就走路线二不要混用。2.3 alpha平衡因子怎么接进one-hot代码原论文里Focal Loss还带了类别平衡因子alpha。对于二分类alpha_t定义为正样本权重alpha负样本权重1-alpha。在基于one-hot的实现里alpha可以有两种接法。接法一先计算逐样本的focal weight再乘上该样本真实类别对应的alpha。代码上是这样alpha_weight (alpha * labels_onehot).sum(dim-1)因为one-hot乘alpha再求和自然就拿到了“这个样本标签类别的alpha”。接法二逐类别计算后再加权。每个类别都有自己的alpha这么写loss_per_class focal_weight_per_class * ce_per_class loss (loss_per_class * alpha.view(1, -1) * labels_onehot).sum()对于多标签sigmoid路线alpha的语义还要细究通常正类的alpha取小于0.5的值负类的alpha取1-alpha目的是抑制大量简单负样本。这时不能在标签维度上直接做alpha与标签的点乘因为负类类别也需要参与计算。标准二分类写法里alpha_t是对每个“类别维度”分别取的而非对样本整体取一个。下表把两种实现路线的关键区别总结一下对比项softmax one-hotsigmoid one-hot输出激活softmax所有类别概率和为1sigmoid每个类别独立概率p_t含义真实类别的概率每个类别各自的正类概率alpha接法通常按真实类别取一个alpha正负类各取alpha和1-alpha适用场景单标签多分类多标签分类、目标检测代码复杂度较低稍高需要逐元素处理2.4 为什么“基于one-hot”这个表述值得注意很多人写Focal Loss时直接对整数标签gather并没有显式做one-hot。那为什么基于one-hot的实现依然是主流因为在统一框架里one-hot让损失函数可以同时兼容单标签和多标签、兼容softmax和sigmoid、兼容各种reduction方式。尤其是在TensorFlow/Keras里标签经常就是one-hot矩阵模型输出也是概率矩阵一个统一公式搞定不需要写分支判断。另外one-hot点乘的实现方式对向量化计算很友好GPU利用率高写出来也容易验证。我在实际项目里倾向于把“one-hot实现”作为默认实现原因只有一个它能强迫你把“p_t是哪个概率”这件事想清楚。反而是一上来就写gather版本p_t的意义容易变成黑盒。3. 手写一版可以上训练的Focal Loss这一节直接给代码。我以PyTorch和TensorFlow/Keras各写一版都基于one-hot标签实现并且重点解释每个步骤为什么这么写。3.1 PyTorch版本逐类别加权推荐import torch import torch.nn.functional as F def focal_loss_onehot(logits, labels_onehot, gamma2.0, alphaNone, reductionmean): logits: [B, C] 未经过softmax的原始输出 labels_onehot: [B, C] one-hot标签 alpha: 可选[C] 每个类别的权重若不传则不做类别平衡 log_probs F.log_softmax(logits, dim-1) probs torch.exp(log_probs) # 每个类别的focal weight形状 [B, C] focal_weight (1 - probs) ** gamma # 对每个类别计算带权重的交叉熵再用one-hot掩码取出真实类别的loss per_class_loss -focal_weight * log_probs * labels_onehot if alpha is not None: per_class_loss per_class_loss * alpha.view(1, -1) # 对类别维度求和得到每个样本的损失 loss per_class_loss.sum(dim-1) if reduction mean: return loss.mean() elif reduction sum: return loss.sum() else: return loss解释几个关键点为什么不直接对softmax后的probs取对数因为log_softmax在数值上更稳定它内部做了平移处理避免softmax上溢出。后续取exp得到probs虽然会有一点精度损失但整体影响很小。per_class_loss -focal_weight * log_probs * labels_onehot这一行是在“逐类别加权”只有真实类别那一列会被保留其他列乘0。这样做的好处是如果你想扩展成软标签比如label smoothing把labels_onehot换成软标签weight就可以直接支持。alpha在乘之前要在类别维度上对齐所以用了alpha.view(1, -1)。3.2 PyTorch快捷版一维点乘简洁但要注意梯度如果确定是单标签分类还可以写得更短def focal_loss_pt_quick(logits, labels_onehot, gamma2.0): probs F.softmax(logits, dim-1) pt (probs * labels_onehot).sum(dim-1) ce -torch.log(pt.clamp(min1e-8)) focal_weight (1 - pt) ** gamma return (focal_weight * ce).mean()这个版本很直观p_t就是one-hot与预测概率的内积损失就是所有的标准Focal。但它有两个需要注意的地方pt.clamp(min1e-8)是必要的否则pt可能因为浮点下溢变成0log(0)就是负无穷。这个写法里ce是用“摘出来的p_t”再取对数和逐类别版本的-log_probs * labels_onehot在数学上等价但数值稳定性略差。此外如果以后要支持软标签这个点乘版本会直接失效因为软标签不是纯one-hot点乘出来的p_t不再代表“真实类别的单一概率”。我的建议是写代码的时候尽量用稳定版本不要用快捷版。快捷版适合写博客、做Demo不适合长期维护的训练代码。3.3 TensorFlow/Keras版本TensorFlow里如果模型输出层已经接了softmax可以直接这样写import tensorflow as tf def focal_loss_onehot_tf(y_true, y_pred, gamma2.0, alphaNone): y_true: [B, C] one-hot标签 y_pred: [B, C] softmax输出概率 epsilon 1e-8 y_pred tf.clip_by_value(y_pred, epsilon, 1.0 - epsilon) # 逐类别交叉熵 ce -y_true * tf.math.log(y_pred) # p_t: 每个样本真实类别的预测概率 pt tf.reduce_sum(y_true * y_pred, axis-1, keepdimsTrue) focal_weight (1.0 - pt) ** gamma # 损失逐类别加权并汇总 loss focal_weight * ce if alpha is not None: # alpha: [C]与one-hot相乘后按类别加权 loss loss * tf.reshape(alpha, (1, -1)) return tf.reduce_mean(tf.reduce_sum(loss, axis-1))如果模型输出层是sigmoid的多标签二分类标准写法应该是逐类别的def focal_loss_binary_tf(y_true, y_pred, gamma2.0, alpha0.25): y_pred tf.clip_by_value(y_pred, 1e-7, 1.0 - 1e-7) bce -y_true * tf.math.log(y_pred) - (1 - y_true) * tf.math.log(1 - y_pred) # 逐类别的focal weight正样本用y_true标记的位置 focal_weight tf.pow(1 - y_pred, gamma) * y_true \ tf.pow(y_pred, gamma) * (1 - y_true) loss focal_weight * bce # 类别平衡正样本alpha负样本1-alpha alpha_weight alpha * y_true (1 - alpha) * (1 - y_true) loss loss * alpha_weight return tf.reduce_mean(loss)注意sigmoid版本里alpha的接法是逐类别的不是对整个样本统一乘一个数。这是很多实现里最容易出错的地方。3.4 训练时怎么接入Loss尺度需要重新评估Focal Loss和交叉熵在数值尺度上差异巨大。对于已经能很好分类的简单样本Focal Loss会把loss压到交叉熵的零头整个batch的平均loss也会比交叉熵小一个数量级。这直接影响反向传播的梯度幅度。我第一次把模型的BCE Loss切换成Focal Loss时直接沿用了原来的学习率结果训练loss下降非常慢指标几乎没有变化。排查了很久才发现是损失尺度缩水导致梯度变小。调整策略无非两种学习率放大2到5倍对Focal Loss整体乘一个缩放系数比如10或100把尺度拉回和交叉熵接近的水平。我推荐先做后者因为改学习率会影响优化器的动量历史。loss缩放和目标检测里常见的“loss权重”本质是同一个操作代码改动最小。训练开始时记录一下Focal Loss和之前交叉熵的scale对比做到心里有数。4. 那些文档里不写的坑调参、数值、初始化4.1 gamma2是起点不是终点很多人一上来就用γ2且从不调整。原论文里γ2确实效果不错但它背后的含义是“简单样本的梯度贡献要压到原来的(1-p_t)^2”。如果p_t0.9权重是0.01降到了百分之一。这个力度对你的数据分布是否合适只有试了才知道。按我的观察γ1时Focal Loss对简单样本的抑制已经很明显适合类别不平衡程度不高的任务γ2是目标检测的典型起点适合背景占比90%以上的场景γ3以上会让简单样本权重低到“几乎不参与训练”此时模型很可能在难样本上过拟合或者整体收敛变慢。调参建议先用γ2跑一个baseline然后固定其他条件对比γ1.5、γ2.5、γ3.0的验证集曲线。不要只看最终指标还要看训练速度——如果前几个epoch的loss下降很慢说明γ可能偏高。4.2 alpha的两种设置哲学alpha在Focal Loss里是个容易被低估的参数。原文里它叫class weight作用是把正负类别的相对权重做一次静态调整。实际项目里alpha怎么设常见的有两种思路按类别频率反比统计训练集中每个类别的样本数将alpha设为频率反比并归一化。这个做法适合类别间数量差距极大的情况比如语义分割中的小目标类别。按验证集搜索直接把alpha当成超参数在{0.25, 0.5, 0.75}里做小范围网格搜索。目标检测里经典的0.25就是这么搜出来的因为前景框本来就少再压低正类alpha会让模型过于保守。两个经验gamma增大时alpha往往需要同步调大。γ越大难样本被保留的权重越多但难样本里大部分是“背景中长得像前景”的负样本如果alpha不跟着调模型容易疯狂把难负样本误判为正类。alpha不要和过采样/欠采样同时用得过猛。一边对少数类过采样一边又把alpha拉得很高少数类的loss会被过度放大出现训练初期“少数类指标虚高、后期迅速过拟合”的情况。4.3 数值稳定性one-hot点乘的隐藏陷阱one-hot实现里最常见的数值问题是p_t下溢。如果模型经过若干轮训练对某个正确类别的输出概率极度接近1那么(1-p_t)就接近0(1 - p_t) ** gamma在gamma比较大的时候可能直接下溢为0。对应的梯度也会变成0这倒不是大问题因为简单样本本来就不希望贡献梯度。真正的风险是另一个方向log里面出现0。pt.clamp(min1e-8)能解决CE爆炸但引入了一个新的偏差如果pt真的小于1e-8clamp之后算出的是log(1e-8)而非log(pt)。在训练初期模型还没学起来时很多样本的p_t确实可能非常小这时clamp值的选择会影响loss的绝对数值。我通常建议在softmax路线里用log_softmax而不是clamp因为log_softmax内部已经处理了极端概率不需要额外截断。只有在sigmoid路线上才需要clip并且clip的下限要低于1e-7不能用1e-8这种过于激进的值。4.4 启动阶段先CE再切换FocalFocal Loss在训练早期的表现经常被忽略。模型刚随机初始化时所有样本都是“难样本”p_t普遍很小(1-p_t)^γ ≈ 1这时Focal Loss约等于交叉熵但alpha的参与又会让正负样本的初始权重被拉偏。结果就是模型在第一个epoch里学到的东西完全被alpha主导而不是被数据分布主导容易走上歪路。我在实际项目里踩过这个坑解决办法是“两阶段loss切换”先让模型用普通交叉熵训练2到4个epoch让特征提取器具备基本判别能力再切到Focal Loss精调。这个方法乍听有点绕但效果很稳定。特别是在检测任务里backbone如果是从随机初始化开始训的直接用Focal Loss很容易让模型陷入“全部预测为背景”的死区——所有前景样本的p_t都很低focal weight很大但梯度方向被海量背景样本的静态alpha压制模型学不到任何有效特征。一个折衷做法Focal Loss公式里给调制因子加一个“退火版本”让γ从一个较小的值慢慢升到目标值。比如前5个epoch用γ0.5后面逐步涨到γ2。这个方案我没有长期验证过但在某个分割项目里短暂试过收敛过程比直接硬切更平滑。4.5 one-hot label smoothing / 多标签的兼容问题如果你在做知识蒸馏或者想用label smoothing提升泛化能力one-hot路线的Focal Loss会遇到一个语义冲突label smoothing把0和1变成了ε和1-ε这时候“one-hot点乘得到的p_t”就不再是真实类别的预测概率而是所有类别预测概率的加权和。调制因子(1-p_t)^γ的含义就变得模糊了。所以如果使用了label smoothing我建议不要去动Focal Loss的调制因子改用另一种策略保持Focal的p_t按原始one-hot计算但交叉熵部分使用平滑后的软标签。相当于focal权重和分类损失各自使用不同版本的标签。这样既保留了难易样本调制又享受了平滑带来的泛化收益。多标签场景下同样要注意one-hot里有多个1p_t的计算如果简单点乘会把所有正类概率加在一起。当正类数量很多时这个“总和p_t”很容易接近1导致每个类别都被当成简单样本降权Focal Loss退化。正确的做法是逐类别计算p_c分别算focal weight最后再聚合。这一点在3.3节的sigmoid版本里已经体现。5. 一些个人经验和后续扩展方向我现在的默认实现已经固定成“逐类别加权”的版本不再用点乘快捷版。理由是它更好扩展、数值更稳也更容易在代码review时让其他人一眼看懂。如果你的项目里数据不平衡不算严重可以考虑给Focal Loss加一个自动停止机制当验证集的难样本比例下降到一个阈值后自动把γ降低甚至退回交叉熵防止后期模型在少数难样本上过拟合。这个思路我当时试过能稳定提升一点指标但代码复杂度会增加属于“锦上添花”的操作。另外Focal Loss不是银弹。它解决的是“简单样本淹没难样本”的问题但如果少数类本身的特征就和多数类高度重叠即使把所有难样本的权重拉满模型也没有足够的信息去区分它们。这种时候该做的是改进特征表示、引入更强的数据增强或者换损失函数。最后说一个我到现在都保留的习惯每换一次损失函数我都会在训练日志里同时记录CE loss和Focal Loss两条曲线。如果两条曲线走势完全一致说明Focal Loss的调制因子还没有起作用——不是gamma太小就是训练数据里根本没有“容易样本”。这个习惯帮我找到了好几次数据预处理上的bug比任何trick都实在。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →