1. 那个让我撞了三天墙的97%准确率
先说我遇到过的一个真实麻烦。某个二分类项目,正样本少得可怜,差不多是1:99的真负比。我按部就班用交叉熵损失去训练,validation准确率一路冲到97%以上,当时还挺高兴。结果一看混淆矩阵,差点没把咖啡喷屏幕上——正样本的召回率只有12%,模型基本上把所有东西都预测成负类了。准确率被压倒性多数类骗得死死的。
这种情况在分类任务里太常见了:类别极度不平衡,模型学会了“躺平”——反正把所有样本判成大样本类,准确率也不会难看。交叉熵对这种场景几乎无能为力,它在梯度层面被多数类绑架,少数类的信号微弱得可以忽略。
随后我试过一堆方案:对少数类过采样、对多数类欠采样、调整class weight、改阈值。各有各的效果,但都有副作用。过采样容易过拟合,欠采样又浪费数据,class weight试来试去也没找到一个稳定的区间,阈值后处理更像是亡羊补牢——训练出的模型表征本身就烂,光调阈值救不回来。
真正的转机是换了Focal Loss。这个最初写给目标检测的损失函数,放到普通分类任务里一样能打。它最核心的思路就一句话:让模型主动关注那些还没学会的样本,而不是在所有样本上一视同仁地求损失。看起来只是给交叉熵加了一个调制因子,但这个因子彻底改变了我训练时的行为曲线——正样本的召回率从12%拉到了78%左右,同时负样本的误报率只涨了不到两个百分点。
这篇文章我会把自己踩过的坑、调过的参数、试过的动态变体全部摊开讲。内容覆盖Focal Loss从公式推导到实际调参的完整链路,适合正好被类别不平衡折磨的人,也适合想在损失函数层面做文章的进阶玩家。我尽量不堆理论,用实际训练日志和代码说话。
2. 从交叉熵到Focal Loss:一个调制因子引发的连锁反应
2.1 交叉熵的“公平”在类别失衡时反而是不公平
交叉熵损失的标准形式是:
L_CE = -log(p_t)这里的p_t指的是正确类别的预测概率。样本易分的时候,p_t接近1,loss小;样本难分的时候,p_t很小,loss很大。看起来很合理对吧?问题恰恰出在这个“合理”上。
在一个1:99的不平衡数据集里,模型只需要把全部样本都判成负类,负样本的p_t就会非常接近1,它们对梯度的贡献虽然单看很小,但胜在数量大、积少成多,整体梯度方向完全被负样本主导。正样本本身稀疏,即便它们的loss很大、梯度方向很重要,但每批batch里就那么几个,连被优化器“注意到”的机会都不多。
更麻烦的是,当模型已经能轻易识别多数一个批次里的绝大多数样本时,交叉熵并不会因为“这些样本已经学好了”就减小它们的梯度权重。它对待一个p_t=0.99的多数类样本,和对待一个p_t=0.6的少数类样本,权重差异远不够大。训练后期就是这种“垃圾梯度”在拖后腿——占绝大多数的易分样本持续提供大量冗余梯度,模型在多数类上打磨得越来越精细,少数类的决策边界却始终模糊。
所以问题的本质不是交叉熵公式错了,而是在类别极不平衡时,它预设的“公平竞争”环境根本不成立。压缩少数类的信号,放大多数类的噪声,这才是真实的训练状态。
2.2 Focal Loss的公式变形到底改了什么
Focal Loss的公式长这样:
L_FL = -α_t * (1 - p_t)^γ * log(p_t)对比交叉熵,多出来两个东西:α_t和**(1-p_t)^γ**。
(1-p_t)^γ是核心调制因子。它的行为可以分成两个区间看:
- 对于易分样本(p_t → 1),调制因子趋近于0,loss被大幅压下来。压制的程度由γ决定,γ越大压制越狠。
- 对于难分样本(p_t 很小),调制因子接近1,loss几乎不受影响,梯度保持原样。
这里拿γ=2举例,p_t=0.9的样本,(1-0.9)^2=0.01,loss被压到了原来的百分之一;p_t=0.5的样本则保留了四分之一的权重;p_t=0.1的样本几乎完整保留。这样一来,模型在反向传播时接收到的梯度信号,从“所有样本的平均反馈”变成了“困难样本集中反馈”。
α_t是类别权重,解决的是正负样本绝对数量不均衡的问题。它和γ配合时有个很微妙的关系:α在整体上给少数类提高比重,γ在具体样本粒度上给难分样本保留比重。两者不是一个维度的东西,但经常被混为一谈。
直观理解的话,你可以把交叉熵的梯度场看作“所有人投票”,Focal Loss相当于引入了两轮投票权分配:第一轮按类别分配基础权重(α),第二轮按每届当选难度分配额外权重(1-p_t)^γ。多数类里的简单样本被大幅剥夺投票权,少数类里的困难样本则被全额保留投票权。
2.3 我一开始对调制因子的误解
我第一次看论文时,以为调制因子只跟“难易”有关,跟“类别”无关。后来跑实验才发现这理解有偏差。
如果只有(1-p_t)^γ而没有α,训练时会有个奇葩现象:训练前期模型能力弱,所有样本的p_t都不高,调制因子差别不大,整体loss曲线看起来和交叉熵差不多;到训练中后期,模型开始能正确判断多数类样本,多数类大量样本的p_t变高,权重被压下来,但少数类因为样本太少,即便它们的p_t普遍偏低,产生的梯度量依然很有限。结果就是模型很容易陷入“什么都不学”的惰性状态——难分样本权重高,但样本量太少;易分样本被压制,没什么梯度贡献。
加上α之后,才真正把少数类的样本权重“抬上来”,让它们哪怕数量少,总梯度贡献也足以引导模型更新方向。论文里给的默认配置是α=0.25、γ=2,这个组合在COCO检测任务上效果好,但放到你自己的数据集上基本都得重调。
| 场景 | α倾向 | γ倾向 | 原因 |
|---|---|---|---|
| 正样本极稀(1:1000以上) | α往0.5~0.7调 | γ适当减小到1~1.5 | 单靠γ压不了多数类,需要α强行抬少数类 |
| 正负比1:10左右 | α保持0.25附近 | γ往1.5~2靠 | 类别不算极端,γ主导效果更好 |
| 噪声样本多 | α不要太极端 | γ减小到1左右 | γ太大会让模型死磕噪声难分样本 |
3. 实际调参日志:γ和α在我数据集上的规律性验证
3.1 我的实验设定和评估方式
项目背景是某跨平台系统的日志异常检测,两条数据类别非常不均:正常日志约50万条,异常日志约6000条,比例接近83:1。特征提取后是一个20维的数值向量,模型用的是简单的四层全连接网络——这种场景根本不需要花哨结构,损失函数的影响比网络结构大得多。
评估不看accuracy,因为97%的accuracy在类别不平衡任务里完全没有参考价值。我主要看异常类F1分数和PR曲线下面积。F1能同时惩罚漏报和误报,PR-AUC对少数类的排序质量敏感,更直接反映模型区分能力。
训练参数:Adam优化器,初始学习率0.001,batch size128,训练30个epoch,早停设在验证F1连续两轮不升。
3.2 横扫γ的实测结果
我先固定α=0.25,把γ从0到5横着扫了一遍。γ=0的情况其实就是带α的加权交叉熵。
结果挺有意思:
| γ | 异常类F1 | 负样本误报率 | 训练收敛轮数 |
|---|---|---|---|
| 0 | 0.52 | 3.1% | 11 |
| 0.5 | 0.60 | 3.8% | 12 |
| 1.0 | 0.68 | 4.6% | 14 |
| 1.5 | 0.74 | 5.2% | 16 |
| 2.0 | 0.76 | 6.0% | 19 |
| 3.0 | 0.72 | 8.7% | 24 |
| 5.0 | 0.58 | 15.4% | 不收敛 |
几点规律:
γ从0上升到2的过程中,F1稳步上升,但代价是误报率同步走高。这符合直觉——γ越大,模型对困难样本越敏感,而困难样本里除了罕见异常,还包含一部分特征模糊、甚至被标注错误的正常日志,把它们也算“困难”去放大权重,必然制造误报。
γ=2之后边际收益递减,过头反而崩。γ=3时F1开始回落但误报率继续涨;γ=5时训练过程直接不稳定——验证loss剧烈震荡,模型开始对若干顽固的噪声样本过拟合。我的判断是γ太大会让优化器“死盯”最难的几个点,导致方向震荡甚至发散。
3.3 α和γ的交互效应
另一个常被忽视的点:α和γ不是独立变量,它们的组合效果非线性很强。我固定γ=2,把α从0.1调到0.7,结果如下:
| α | 异常类F1 | 负样本误报率 |
|---|---|---|
| 0.1 | 0.68 | 3.5% |
| 0.25 | 0.76 | 6.0% |
| 0.4 | 0.74 | 10.2% |
| 0.7 | 0.65 | 17.8% |
α=0.4时F1表面看起来还行,但误报率已经接近失控。α=0.7时模型基本在批量制造误报——因为负样本占了98%以上,提高少数类权重等同于让模型把很多疑似边界样本全部判定为异常。
我后面又试了动态调整α的思路:训练初期α大一点,让模型快速学到少数类的粗粒度模式,训练后期α衰减到较小值,避免过度偏置。这个想法实践效果不错,但放在第四节统一讲。
3.4 稳定的经验区间总结
基于这批日志,我总结出一个规律性经验:γ在1到2之间的收益最稳定,α不要超过0.3,除非你的分类任务里“分错某类”的代价极度不均衡。多数场景下,α=0.25、γ=2这个论文默认值是个不错的起点,但后续一定要做几步网格微调:
- 固定α=0.25,γ按0.5步长从1扫到3;
- 选F1最高的γ,固定它,让α按0.1、0.2、0.25、0.3、0.4扫一圈;
- 把最终两个参数组合放到不同随机种子下跑三遍,确认方差不大。
一个容易踩的坑:不要只盯着验证集的F1选参数。模型对于“特征为正常日志但语义极为异常”的漏报和“特征模糊但真实正常”的误报,代价完全不同。F1只考虑数量,不考虑代价。我后来倾向于在F1接近的情况下,优先选误报率更低的参数组合——线上系统里误报对用户的骚扰比漏报的一次日志翻查更讨厌。
按上面的网格扫法,你大概需要跑20~30组训练。每组如果样本量大、网络深,时间成本确实不小。我后续的做法是先用一个抽样子集快速筛,把候选区间锁定后再跑全量数据微调,能省三分之二的时间。
4. “动态”的几种实操路径:Focal Loss不只是固定γ和α
标题既然带了“动态交叉熵”这几个字,我得把自己的体会说清楚:Focal Loss在原始论文里γ和α是超参数,固定值。但在实际工程中,把这两个参数固定死远远没有“动态调整”效果好。紧接着介绍几个我自己试过且效果明确的动态变体思路。它们不一定比“一群专家精心调出的固定参数”更强,但优势在于不用花那么多时间在网格搜索上,而且更稳健。
4.1 动态γ:从温和启动到逐步聚焦
最常见的做法是让γ随训练进程从0线性增加到目标值(比如2)。我实现过一段PyTorch代码,核心逻辑很简单:
class FocalLossDynamicGamma(nn.Module): def __init__(self, alpha=0.25, gamma_start=0.0, gamma_end=2.0, warmup_epochs=15, total_epochs=30): super().__init__() self.alpha = alpha self.gamma_start = gamma_start self.gamma_end = gamma_end self.warmup_epochs = warmup_epochs self.total_epochs = total_epochs self.current_epoch = 0 def forward(self, logits, targets): ce_loss = F.cross_entropy(logits, targets, reduction='none') pt = torch.exp(-ce_loss) gamma = self._get_gamma() alpha_t = torch.where(targets == 1, torch.tensor(self.alpha, device=logits.device), torch.tensor(1 - self.alpha, device=logits.device)) focal_loss = alpha_t * (1 - pt) ** gamma * ce_loss return focal_loss.mean() def _get_gamma(self): progress = min(1.0, max(0.0, self.current_epoch / self.warmup_epochs)) return self.gamma_start + (self.gamma_end - self.gamma_start) * progress def set_epoch(self, epoch): self.current_epoch = epoch这个方案的心理预期是:训练早期模型还没有辨别能力,几乎所有样本的p_t都不高,这时候用大γ会破坏梯度稳定性,让训练从第一天起就陷入混沌。先以γ≈0(即加权交叉熵)起步,保证模型学到全局区分能力,再逐步加大γ去聚焦难样本。
实测下来,动态γ方案最明显的收益是训练稳定。固定γ=2时,前几个epoch的验证F1经常在0.2到0.6之间剧烈跳动;动态γ版本前几个epoch的F1波动幅度小了近一半,收敛路径平滑很多。
此外,ε需要跨epoch传递,还要配合一个learning rate schedule。我自己习惯把γ的增长和warmup lr同步,其实效果也会更好。
4.2 动态α:基于类别频率的自适应权重
另一种动态思路是让α跟着训练阶段的类别分布走。这里的“类别分布”不是指数据集静态标签的分布,而是每个batch里预测错误的类别分布。
我的实现方式:维护两个指数滑动平均变量ema_pos_loss和ema_neg_loss,分别统计当前batch中正负样本的平均loss。然后:
α = ema_pos_loss / (ema_pos_loss + ema_neg_loss)这个公式的思想是:如果正样本的平均loss远大于负样本,说明模型还没学好正类,就给正类分配更大权重;反之亦然。本质上它把α变成了一个动态的“损失缺失项补偿器”,优先级是“哪个类更拖后腿,就倾斜哪个类”。
代码长这样:
class FocalLossAdaptiveAlpha(nn.Module): def __init__(self, gamma=1.5, momentum=0.9): super().__init__() self.gamma = gamma self.momentum = momentum self.ema_pos = None self.ema_neg = None def forward(self, logits, targets): ce_loss = F.cross_entropy(logits, targets, reduction='none') pt = torch.exp(-ce_loss) gamma = self.gamma focal_base = (1 - pt) ** gamma * ce_loss pos_mask = targets == 1 neg_mask = ~pos_mask pos_loss_mean = focal_base[pos_mask].mean().detach() neg_loss_mean = focal_base[neg_mask].mean().detach() if self.ema_pos is None: self.ema_pos = pos_loss_mean self.ema_neg = neg_loss_mean else: self.ema_pos = self.momentum * self.ema_pos + (1 - self.momentum) * pos_loss_mean self.ema_neg = self.momentum * self.ema_neg + (1 - self.momentum) * neg_loss_mean alpha_pos = self.ema_pos / (self.ema_pos + self.ema_neg + 1e-8) alpha_neg = 1 - alpha_pos alpha_t = torch.where(pos_mask, torch.tensor(alpha_pos, device=logits.device), torch.tensor(alpha_neg, device=logits.device)) return (alpha_t * focal_base).mean()实测这个方案在训练初期的表现很亮眼:α会快速飙到0.5以上,因为正样本loss远大于负样本;随着模型逐渐学会区分,α慢慢回落到0.3左右。它在某种意义上代替了手动网格搜索α的过程,而且比固定α更“跟手”。
但要注意一个坑:如果某个batch里没有正样本,pos_loss_mean会出现NaN。这时只更新neg侧的EMA、保持pos侧不变,即可避免位置NaN导致后续全部失效。
4.3 结合软标签的“温度”动态软化
严格说这个不算是Focal Loss的变体,而是软化logits的技巧,和FL配合会产生很奇妙的化学反应。
常规的softmax交叉熵,模型的预测置信度会迅速走向两极化——正确的样本p_t很快逼近0.99以上。但Focal Loss的调制因子对这类样本的压制非常狠,相当于把“模型已经会了”的信息反复灌输。问题在于,模型在早期阶段形成的极端置信度,往往带有过拟合噪声的痕迹,过早进入“高置信+被压制”状态会让训练变成挤牙膏。
我用了一个简单的方案:在logits进入softmax之前加一个温度缩放:
p_t = softmax(logits / T)T=1就是原版softmax;T>1会让概率分布更平滑,最大概率不会太高,调制因子的压制效果被延后;T<1则让概率更极端,调制因子更快发力。运行时可以动态衰减:训练前10个epoch用T=2,逐渐降到T=1,等于让模型先广泛探索、再局部收紧。
这个方案的额外收益是能天然防止“模型对几个难样本死磕”导致的过拟合。固定γ=2配合温度从2到1的降温,在日志异常检测上F1比固定参数版本又涨了三个百分点。
不过要说明:温度缩放本身会让交叉熵的数值变小,如果你习惯用学习率0.001这种常规值,建议T=2对应地把学习率乘上2左右,补偿一下梯度量级的下降,否则前几个epoch损失下降慢得仿佛模型根本没在学。
5. 我自己总结的一套Focal Loss使用决策树
5.1 何时使用Focal Loss,何时老老实实用交叉熵
Focal Loss不是万能药,在很多场景下加进去反而更差。我的经验是拿下面几条过一遍:
| 场景 | 用什么 | 原因 |
|---|---|---|
| 类别不平衡但不极端(<5:1) | 交叉熵 + class weight | Focal Loss的优势不容易体现,反而多出两个超参数要调 |
| 类别极不平衡(>10:1) | Focal Loss / 动态Focal Loss | 调制因子带来的困难样本聚焦效果明显 |
| 噪声样本比例高 | 交叉熵或γ很小的FL | 大γ会死磕噪声样本并过拟合 |
| 多分类且每个类样本量差异大 | FL + 数据集均衡采样 | Focal Loss作为损失函数在多个类别之间需要额外定制α向量 |
| 回归任务 | 不用FL | Focal Loss针对分类型的概率分布;回归建议Huber或分位数损失 |
关于最后这个用途,我踩过一次坑:在某传感器数值预测项目中把回归当作分类尝试用Focal Loss,因为数值本身就连续,切bin之后各类别间的“难分”定义非常模糊,调制因子带来的影响不可控。最后还是老老实实回退到Huber Loss。
5.2 和采样器配合时容易犯的错误
采样器和Focal Loss在功能上重叠但不互补,叠加使用需要格外小心。
我在一组对照实验里试过三种组合:仅Focal Loss、仅RandomOverSampler过采样、Focal Loss加过采样。结果发现Focal Loss加过采样导致验证集F1反而比单独用FL低了两个百分点,原因是过采样把少数类复制进了训练分布,模型有效训练样本里的“多数类”和“少数类”差距被抹平,Focal Loss的调制因子开始压制的就不再是“难正样本”,而是被重复复制后“显得易分”的正样本。
如果你非要用采样器和Focal Loss搭配,我建议只用欠采样把极端多数类砍到和少数类比不超过20:1,剩下的不平衡交给Focal Loss去处理。换句话说,采样器负责把不平衡拉到一个“可控范围”,Focal Loss负责在这个范围内深挖困难样本,两者错峰不重叠。
5.3 我自己日常使用的模板化配置
现在每次遇到类别不平衡的表格数据二分类,我先锁这个模板:
criterion = FocalLossDynamicGamma( alpha=0.25, gamma_start=0.5, gamma_end=1.5, warmup_epochs=int(total_epochs * 0.5), total_epochs=total_epochs ) # 每epoch结束调用: # criterion.set_epoch(epoch) 配合lr_scheduler然后跑10个epoch的快速验证,看验证F1有没有追杀起来。如果F1能到0.7以上,再考虑加温度软化进一步提点;如果F1长期在0.5以下,优先提升特征质量而不是继续调损伤函数。
6. 数值稳定性和PyTorch实现细节
6.1 我的一个NaN事故
开始手写Focal Loss时,我直接照抄了某些版本的公式:
pt = torch.softmax(logits, dim=1)[range(n), targets] loss = -torch.log(pt)遇到类别很多且logits极端时,torch.log(pt)会把pt极小(小于1e-30)的情况算成-inf,接着(-inf) * 0变成NaN。这种NaN出过两次才让我老老实实换了更稳的写法。
稳定的做法是绕过pt,直接用交叉熵的结果:
ce_loss = F.cross_entropy(logits, targets, reduction='none') pt = torch.exp(-ce_loss)因为F.cross_entropy在内部做了log-softmax的数值稳定处理,torch.exp(-ce_loss)天然等价于正确类预测概率且不会下溢到0,也就消除了inf的产生条件。
6.2 一个一劳永逸的实现版本
我后来把动态γ和自适应α合在一个类里,做成一个可以开箱即用的实现。全程只有不到40行:
import torch import torch.nn as nn import torch.nn.functional as F class DynamicFocalLoss(nn.Module): """带动态γ和自适应α的Focal Loss 用法: criterion = DynamicFocalLoss(gamma_start=0.5, gamma_end=1.5, warmup_epochs=15, momentum=0.9) ... for epoch in range(total_epochs): criterion.set_epoch(epoch) for x, y in dataloader: loss = criterion(logits, y) """ def __init__(self, gamma_start, gamma_end, warmup_epochs, momentum=0.9, use_adaptive_alpha=True): super().__init__() self.gamma_start = gamma_start self.gamma_end = gamma_end self.warmup_epochs = warmup_epochs self.momentum = momentum self.use_adaptive_alpha = use_adaptive_alpha self.ema_pos = None self.ema_neg = None self.epoch = 0 @property def gamma(self): p = min(1, max(0, self.epoch / max(1, self.warmup_epochs))) return self.gamma_start + (self.gamma_end - self.gamma_start) * p def set_epoch(self, epoch): self.epoch = epoch def forward(self, logits, targets): ce_loss = F.cross_entropy(logits, targets, reduction='none') pt = torch.exp(-ce_loss) focal = (1 - pt) ** self.gamma * ce_loss pos_mask = targets == 1 neg_mask = ~pos_mask if self.use_adaptive_alpha: pos_loss = focal[pos_mask].mean().detach() neg_loss = focal[neg_mask].mean().detach() if torch.isfinite(pos_loss): self.ema_pos = pos_loss if self.ema_pos is None else \ self.momentum * self.ema_pos + (1 - self.momentum) * pos_loss if torch.isfinite(neg_loss): self.ema_neg = neg_loss if self.ema_neg is None else \ self.momentum * self.ema_neg + (1 - self.momentum) * neg_loss alpha_pos = (self.ema_pos / (self.ema_pos + self.ema_neg + 1e-8)).clamp(0.1, 0.9) alpha = torch.where(pos_mask, torch.tensor(alpha_pos, device=logits.device), torch.tensor(1 - alpha_pos, device=logits.device)) else: alpha = torch.tensor([0.25], device=logits.device) alpha = torch.where(pos_mask, alpha, 1 - alpha) return (alpha * focal).mean()里面埋了两个小心机:torch.isfinite检查空batch导致的NaN;clamp(0.1,0.9)避免α跑到太极端的位置引发训练失控。这两个保护在真实项目中救过我,强烈建议保留。
6.3 关于梯度视角的小验证
为了验证Focal Loss确实改变了“样本对梯度的贡献”,我手工算了一个简单case:两个样本,一个p_t=0.9的易分样本,一个p_t=0.3的难分样本。
标准交叉熵下,易分样本的梯度范数是0.105,难分样本是1.73,比例约1:16。换成α=0.25、γ=2的Focal Loss后,易分样本梯度被压到0.001左右,难分样本保持1.73且乘以0.25变成了0.43,比例拉到约1:430。难分样本的梯度主导地位提升了一个数量级以上。梯度反向传播的效率完全不一样,这就是Focal Loss能逼着模型去啃硬骨头的底层原理。
7. 训练录得与对比结果:一组我实际跑过的完整实验记录
7.1 基线交叉熵,看起来很坏但不知道为什么坏
基线实验:交叉熵,无任何处理,训练30轮。验证集loss从0.41降到0.22左右,看起来一切正常。但验证集的异常类F1只有0.18。训练集F1也才0.35——说明模型问题是结构性的,不是过拟合导致的泛化失败,而是训练阶段就根本没学会拿正样本的正确信号。
7.2 加入静态Focal Loss,明显好转但还不够
α=0.25、γ=2,静态参数。异常类F1升到0.76,最后五个epoch收敛速度明显变慢,说明模型把相当大比例的“少数字符特征异常”样本处理得比较好了,但剩余的是和正常日志特征高度重合的模糊样本。
7.3 动态版本再进一步,最明显的是训练稳定性
换成动态γ(0.5→1.5)加自适应α的版本,异常类F1到了0.81,最重要的是训练过程几乎是单调的,没有出现几个epoch之后重新剧烈起伏的现象。
| 方案 | 异常类F1 | PR-AUC | 训练稳定性 | 收敛轮数 |
|---|---|---|---|---|
| 交叉熵 | 0.18 | 0.42 | 稳定但无用 | 8 |
| 加权交叉熵 (α=0.25) | 0.52 | 0.61 | 稳定 | 11 |
| Focal Loss 静态 | 0.76 | 0.82 | 中期有抖动 | 19 |
| Focal Loss 动态γ+自适应α | 0.81 | 0.87 | 平稳单调 | 17 |
| 动态FL + 温度缩放 | 0.84 | 0.90 | 平稳单调 | 18 |
这个对比表不是论文级严谨,但足以说明方向:动态化带来的收益在真实项目里是可感知的,而且它还不要求你花几个小时去网格搜索最优静态参数。
7.4 为什么我就这么不舍弃加权交叉熵?
不只是它简单稳。我发现加权交叉熵在某些数据分布下的表现和Focal Loss差距真的不大——特别是在“少数类本身很少很纯、难分样本占比不高”的数据集里。Focal Loss的优势前提是大量难分样本,不是单纯多数/少数失衡。
如果你的项目里少数类虽然少,但特征十分鲜明,模型只要看到一个就能分清,加权交叉熵就够了,Focal Loss的调制因子像是个“备而未用”的雨伞,反而增加了复杂度。判断方法很简单:先跑一版加权交叉熵,如果验证F1已经到了0.8以上,没必要强行换FL;如果卡在0.6、0.7上不去,再上Focal Loss,效果会立竿见影。
8. 训练完成后,我还得说说的几个“容易被忽略但致命”的小事
8.1 验证集的采样方式决定了你调参的“视力”
我曾经直接在原始分布上切验证集,异常类样本占比0.3%——每个batch只有个别正样本,算出来的F1方差大得惊人:同样的配置,不同随机种子跑出来的F1波动有±0.08。
解决办法是给验证集做分层采样:保证验证集里正样本占比不低于5%左右,或者干脆按固定数量抽固定量的正样本进去(比如每条验证集都固定1000条异常样本+2000条正常样本)。评估结果稳定了,才能让参数调整有可靠的参照。这个坑一定要早挖出来,不然后续所有超参数对比都是在噪声里找信号。
8.2 类别不平衡的模型,预测概率本身就有偏移
Focal Loss优化后的模型输出的概率,不代表真实的贝叶斯后验概率,而是被调制因子扭曲过的“聚焦分数”。如果你后续要卡阈值上线,别忘了用验证集重新校准一次阈值。
我常用做法就是在验证集上画出PR曲线后,直接取PR曲线上F1最高或代价最低的那个点,换算成阈值,而不是简单用0.5。项目上线后如果线上分布变化,阈值还要配合漂移监测做定期重估。
8.3 我的第一次“动态”尝试其实失败了
坦白说,我最早做的“动态Focal Loss”不是动态γ也不是自适应α,而是每N个epoch让模型自己打印出p_t分布直方图,我看着直方图手动改参数。这个方法不能说毫无帮助,但它本质还是试错,改着改着就忘了前面改过什么,反复来回横跳。后来彻底转向公式化的自动调整,才真正解脱。
要把动态化实现在代码里,而不是实现在你“盯曲线”的眼睛里。这也是这篇博文愿意把代码直接放出来的原因——实操的毛刺实在太多,能抄就抄,省下的时间不如多看几行训练日志。