Focal Loss与Circle Loss:损失函数选型与PyTorch落地
2026/9/18 10:07:26 网站建设 项目流程

1. 损失函数选型:先搞清楚这两个损失在谱系里的坐标

做了几年视觉和检索方向的训练,我逐渐形成一个习惯:模型训不动的时候,先别急着换网络结构,先看损失函数。这次要聊的Focal LossCircle Loss,就是两个把"损失函数设计"这件事做到极致的代表——前者解决正负样本极度失衡下梯度被淹没的问题,后者解决类内紧凑、类间可分这件事怎么用统一形式表达出来。它们算不上新东西了,但在检测、人脸识别、商品召回、ReID 这些任务里,依然是我案头常备的工具。这份笔记来自我最近一次重构损失函数模块的过程,包含推导、参数选择、PyTorch 落地代码,以及几条只有踩过才记得住的坑。适合已经能写基础训练循环、想把损失函数从"能跑"调到"好用"的读者,也适合做课程实验时需要手写损失函数的人参考。

1.1 标准交叉熵从哪一刻开始不够用

交叉熵的形式简单到不能再简单,L = -log(p_y),它对每个样本一视同仁。这在类别均衡、标签干净的时候完全没问题。可一旦进入检测、分割、长尾分类这些场景,问题就冒出来了。

第一类问题是正负样本数量差三到四个数量级。一张 640x640 的图,单阶段检测器能产生十万级 anchor,真正命中的正样本可能只有几十个。梯度被负样本淹没,模型学到的是"全预测背景"这个退化解。

第二类问题是简单样本占了绝大多数。它们的 loss 数值很小,但架不住数量多,累加之后主导了梯度方向。模型停在"把容易的分对就行"这个局部解上,难样本永远排不上队。

第三类问题是难样本和脏标签混在一起。模型分不清"这个是难例"还是"这个标注本身错了",最后倾向于把噪声一起记住,泛化直接塌掉。

我实测过一个数字:在 1:1000 正负比的二分类任务上,标准 BCE 训练 20 个 epoch 后,正样本对总梯度的贡献占比不到 3%。这个比例下,判别边界基本建立不起来。Focal Loss 就是冲着前两类问题去的,第三类问题的解法在数据侧,不在损失侧,这点要先拎清楚。

1.2 两个损失各自盯住的目标

Focal Loss的立场是:这是分类问题,我在分类损失上加一个调制项,让"已经分对的样本"贡献变小,把预算留给还没学会的。它是point-wise的,优化对象是每个样本属于某个类的概率。

Circle Loss的立场完全不同:这是相似度问题,我把类内相似度和类间相似度放进同一个公式,并且让每个 pair 拥有自己的权重。它是pair-wise的,优化对象是样本对之间的相似度与理想值的差距。

这个区分看着抽象,实际非常关键,它直接决定你该在哪个分支上用它。你把 Circle Loss 接到检测的分类头上,会发现它压根不关心 anchor 和 gt 的匹配关系,算出来的量纲和 loss 语义都对不上;反过来,你把 Focal Loss 塞进检索任务的嵌入头,它只会让模型对"分对类别"敏感,对"类内是否紧凑"毫无约束,特征该散还是散。

所以选型的第一步不是比谁涨点多,而是问自己一句:我这个任务的监督信号,本质上是"样本属于哪一类",还是"谁和谁更接近"。前者走 Focal,后者走 Circle。

1.3 放进更大的损失函数谱系里看

单独比较两个损失容易迷失,我习惯把它们放回整张地图里看。下面这张表是我自己选型时经常翻的,它最大的价值不是告诉你哪个更好,而是提醒你"你正在优化的到底是什么对象"。

损失优化对象典型场景关键超参
Cross Entropy单样本类别概率通用分类无 / class weight
Focal Loss单样本概率 + 难易调制检测、长尾分类α、γ
Circle Loss样本对相似度人脸、ReID、检索m、γ、s
Triplet Loss三元组距离差度量学习margin
InfoNCE正负对的对比自监督、图文对齐温度 τ
Wasserstein Loss两个分布的搬土距离生成、分布对齐无固定项
GIoU / CIoU / Inner-IoU框的重叠程度检测回归分支系数权重

看这张表你会发现一件事:回归分支的 IoU 系列损失和分类分支的 Focal 是两条平行线,它们各自解决不同子问题,最后靠一个加权和拼到一起。而 Circle Loss 和 InfoNCE、Triplet 是同一条线上的不同粒度版本,Circle 可以退化成它们中的任何一个。

常见的误用就是把不同线的损失硬凑,比如给回归头加 InfoNCE,或者给分类头加 Wasserstein。Wasserstein 度量的是分布之间的距离,它的输入得是分布,不是标量标签。搞错对象,代码能跑,梯度也在动,但优化目标和你的任务没关系。

2. Focal Loss:把简单样本的梯度压下去

2.1 从交叉熵到调制因子,一步步推

二分类交叉熵写作L = -[y·log p + (1-y)·log(1-p)]。为了简化记号,定义p_t = p(当 y=1)或1-p(当 y=0),于是L = -log(p_t)。这里的p_t有个自然含义:模型对真实类别给出的概率,p_t越大说明这个样本越被分对了。

Focal Loss 做的事情,就是在前面乘一个调制因子(1 - p_t)^γ,得到FL = -(1 - p_t)^γ · log(p_t)。注意这里没有加平衡因子的简单形式。看这个因子的行为:

  • p_t → 1,说明样本已经分对且分得很自信,(1-p_t)^γ → 0,这部分损失被压到接近零。
  • p_t → 0,说明分错了或者分得很不自信,(1-p_t)^γ → 1,损失基本保持原样。

γ 控制压缩的力度。γ = 0时退化成普通交叉熵;γ = 1时是弱压缩;论文推荐的γ = 2是中等偏强的压缩。光看图可能没感觉,我给一个具体数字:p_t = 0.9的样本,在γ = 2时它的损失被乘了0.01,也就是降到原来的一百分之一;而p_t = 0.1的样本,乘的是0.81,基本没动。一百倍的差距就是这么来的。

再引入平衡因子α_t,最终形式是FL = -α_t · (1 - p_t)^γ · log(p_t)。这里α_t(1-p_t)^γ的分工完全不同:α 管类别层面的权重,γ 管样本难度层面的权重。很多人调参时只改 α,然后抱怨效果不明显,其实是因为他调的是类别平衡,而他的数据瓶颈是简单样本主导,该动的是 γ。

从梯度角度看更清楚。对 logits 求导之后,p_t高区域的梯度会被(1-p_t)^γ二次衰减。这意味着模型的"有效样本数"变少了,梯度方差变大。这直接解释了一个现象:γ 太大时训练会抖。它不是 bug,是机制本身带来的。

2.2 α 和 γ 怎么定:几个能直接抄的起点

我先说结论:γ 的经验区间是 1 到 3,落在 1.5 到 2.0 之间的最多;α 要看你数据里正样本占比,占比越低,α 越要往正样本侧偏。下面这张表是我在不同任务上验证过、可以当起点的组合。

场景αγ说明
单阶段检测(COCO 风格)0.252.0RetinaNet 默认,负样本多故 α 偏负
二分类正样本极少(<1%)0.75 ~ 0.92.0α 必须偏向正样本
类别不均衡多分类按类频反向加权1.0 ~ 2.0需要 per-class 的 α 向量
长尾分类类别数不多1.01.5不加 α,只靠 γ
训练后期微调保持原值降到 1.0让简单样本重新参与

这张表里最后一行是我自己摸出来的一个技巧,也是我想重点说的。Focal Loss 在训练前期帮你快速把判别边界立起来,但它到后期还在持续压制简单样本,会导致某些已经学对的类又被带偏,表现为某几类的召回率在训练后半段下滑。我在长尾任务上会在最后 20% 的 epoch 里把 γ 从 2.0 线性降到 1.0,让简单样本重新进入梯度。

注意:这个做法没有严格的理论保证,只是我在几个长尾数据集上重复观察到有效。你要用的话,一定做消融对比,别当成通用结论。

多分类版本的 α 有个坑,得单独说。二分类里的 α 是一个标量,多分类里如果所有类共用一个标量,那等于只做了全局缩放,没有做类别平衡。正确的做法是传入长度为C的权重向量,第c个位置取类频的倒数再归一化。归一化那一步不能省,否则损失的量级会随类别数暴涨,学习率就得跟着重调。

2.3 数值稳定性和初始化,两个必踩的坑

第一个坑:一定要用 logits 版本。也就是说,sigmoid 和 BCE 要合成一步算,用binary_cross_entropy_with_logits,不要先手动 sigmoid 再送进 BCE。原因有两层,一是手动 sigmoid 在 logits 绝对值很大时会饱和,log(0)直接给你个inf;二是合算的版本用了 log-sum-exp 的稳定写法,精度更稳。我在混合精度训练里见过因为这一点导致 loss 变 NaN 的例子,排查了两小时才定位到。

第二个坑:分类头的 bias 初始化。单阶段检测在训练初期,十万个 anchor 里正样本只有几十个,如果分类头 bias 是 0,那么初始时每个 anchor 输出的前景概率都是 0.5,背景 loss 会给出巨大的初始梯度,训练前几千步基本在跟这个初始偏差较劲。RetinaNet 的解法是把 bias 初始化成-log((1-π)/π),π 取 0.01,让初始前景概率只有 1%。

import math import torch.nn as nn prior_prob = 0.01 bias_value = -math.log((1 - prior_prob) / prior_prob) # 约等于 4.595 nn.init.constant_(cls_head.bias, bias_value)

加上这一步之后,训练初期的 loss 曲线会从原来的"高位平台"变成"平滑下降",收敛速度肉眼可见地快。这个技巧在别的任务上也通用,只要你遇到"前景极度稀疏 + 训练初期不动"的组合,都可以先试试调 prior。

第三个坑跟混合精度有关。(1 - p_t) ** gamma这个运算在 fp16 下,当1 - p_t接近 0 时,0.0 ** 2.0还好,但1e-8 ** 2.0会下溢成 0,虽然结果上看着没问题,但反向传播时会有精度损失。稳妥的做法是在算调制因子的那一步强制升到 fp32,算完再降回来。

3. Circle Loss:把类内和类间放到同一个式子里

3.1 相似度视角下的 s_p 和 s_n

切换思路。假设你现在有一个嵌入网络,输出经过 L2 归一化后落在单位球面上,任意两个样本之间的余弦相似度就是它们的"接近程度"。设同类样本对的相似度为s_p,异类样本对的相似度为s_n。理想情况下s_p → 1s_n → 0

传统做法用固定 margin 的三元组损失:L = [s_n - s_p + m]+。它的毛病在于所有 pair 用同一个 m。可现实是,一个已经推得很开的负对(s_n很小)和一个贴着边界的负对(s_n ≈ m),它们的"该被优化程度"差得很远,用同一个权重是浪费。

Circle Loss 的解法是给每个 pair 一个自步权重

  • 负对的权重a_n = [s_n + m]+s_n越大说明这个负对还没被推开,越该被重视。
  • 正对的权重a_p = [1 + m - s_p]+s_p越小说明还没拉近,越该被重视。

然后把所有 pair 汇总:

L = log[ 1 + Σ_j exp(γ·a_n^j·(s_n^j - Δ_n)) · Σ_i exp(-γ·a_p^i·(s_p^i - Δ_p)) ]

其中Δ_n = mΔ_p = 1 - m。工程实现时把外层的log(1 + ...)写成softplus,两个求和用logsumexp,数值上更稳:

L = F.softplus( torch.logsumexp(gamma * a_n * (s_n - delta_n), dim=0) + torch.logsumexp(-gamma * a_p * (s_p - delta_p), dim=0) )

这里有个容易看漏的地方:第二个 logsumexp 里带负号。当s_p < Δ_p(正对还不够近)时,-γ·a_p·(s_p - Δ_p)变成正值,对损失的贡献上升;当s_p > Δ_p时它变成负值,贡献被压下去。这个符号设计就是"自步"的来源。

我特别喜欢这套设计的思路:它把"难易程度"和"该不该继续优化"绑定在了一起,而不是用一个人为设定的固定 margin 一刀切。这跟 Focal Loss 用(1-p_t)^γ做难易调制,在哲学上是同一类操作。

3.2 退火参数 γ 和 margin m 的取值

m通常取 0.25,这个值比较稳定,我基本没怎么动过。γ才是需要调的那个,论文里人脸识别任务用的是 256。这是个很大的数字,作用是在 logsumexp 里放大不同 pair 之间的差异——γ越大,softmax 越接近 max 操作,损失越"硬",越只盯着最难的那个 pair 优化。

但 256 不是万能药。它成立的前提是 batch 足够大(几百到几千),嵌入维度够高(512 以上),样本对的数量足够多。我在 ReID 任务上用小 batch(比如 32 或 64)时,直接把 γ 设成 256 会立刻看到 loss 尖刺,梯度爆炸。降 到 64 到 128 之间会稳定得多。

我给一个实用的判断方法:把 γ 想象成 softmax 的温度倒数。温度低就是 γ 大,分布更尖锐。你希望模型关注全局难度分布还是只关注最难那几个,决定了你该用多大的 γ。数据噪声大的时候,γ 要调小,因为"最难的那几个 pair"里面大概率混着标注错误。

另外一个工程上的取舍:Circle Loss 要对所有 pair 算权重,pair 数是O(N²)级别。batch 512 的时候就是 26 万个 pair,显存和算力都要考虑。实际落地时我会做两件事,一是只在 batch 内计算,不做跨 batch 的全局 pair;二是用类级近似(后面代码部分讲)把 pair 数从O(N²)降到O(N·C)

3.3 和 Triplet、AM-Softmax、InfoNCE 的关系

把这几条放在一起对照,能省掉很多"到底该用哪个"的纠结:

损失采样粒度权重方式计算量
Triplet(hard mining)1 正 + 1 负硬采样
Triplet(all pairs)全部对均匀
InfoNCE1 正 + N 负温度 softmax
AM-Softmax类中心固定 margin
Circle Loss全部对 / 类级近似自步加权高(可近似)

从这张表看,Triplet 的 hard mining 是 Circle Loss 在"只取权重最大的那一个 pair"时的特例;AM-Softmax 是在类中心上做 margin,粒度粗但算得快;InfoNCE 用 softmax 归一化正对在全部对中的占比,Circle Loss 可以看成它的加权版本加上非对称的 margin 设计。

我个人的经验是:如果任务数据干净、类数不多、batch 能开大,直接上 Circle Loss 收益最明显;如果数据有噪声、batch 开不大,用 AM-Softmax 更稳,或者先用 InfoNCE 把预训练做扎实再切 Circle 微调。硬上 Circle 在高噪声数据上是真的会崩,我踩过这个坑,类内相似度被强行拉到一个虚高的水平,测试时泛化一塌糊涂。

4. 落地:两个损失的 PyTorch 实现与接入

4.1 Focal Loss 完整实现

先给检测里最常用的二分类 / 多标签版本,也就是 RetinaNet 的写法,逐行都有注释:

import torch import torch.nn as nn import torch.nn.functional as F class FocalLossBinary(nn.Module): """用于检测分类头、多标签分类的二分类 Focal Loss""" def __init__(self, alpha=0.25, gamma=2.0, reduction='mean'): super().__init__() self.alpha = alpha self.gamma = gamma self.reduction = reduction def forward(self, logits, targets): # logits: [N, ...] 未过 sigmoid # targets: [N, ...] 取值 0 或 1 ce_loss = F.binary_cross_entropy_with_logits( logits, targets.float(), reduction='none' ) p = torch.sigmoid(logits) # p_t 是"真实类别对应的概率" p_t = p * targets + (1 - p) * (1 - targets) loss = ce_loss * ((1 - p_t) ** self.gamma) if self.alpha >= 0: alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets) loss = alpha_t * loss if self.reduction == 'mean': return loss.mean() if self.reduction == 'sum': return loss.sum() return loss

这段代码有几个细节值得单独指出。p_t的构造用了p * targets + (1-p) * (1-targets),这是向量化的写法,比torch.where稍快,而且不会产生额外的中间张量。alpha_t同理,它把标量 α 展开成跟每个位置相关的权重,正样本位置取 α,负样本位置取1-α

再看多分类版本,这里的 α 必须是向量:

class FocalLossMultiClass(nn.Module): def __init__(self, alpha=None, gamma=2.0, reduction='mean'): """ alpha: None 或长度为 C 的 Tensor(类别权重) """ super().__init__() self.alpha = alpha self.gamma = gamma self.reduction = reduction def forward(self, logits, targets): # logits: [N, C] # targets: [N] 整型类别 log_pt = F.log_softmax(logits, dim=1) log_pt = log_pt.gather(1, targets.unsqueeze(1)).squeeze(1) pt = log_pt.exp() loss = -((1 - pt) ** self.gamma) * log_pt if self.alpha is not None: # 按类别取权重 at = self.alpha.to(logits.device)[targets] loss = at * loss return loss.mean() if self.reduction == 'mean' else loss.sum()

这里用log_softmax再 gather,而不是先 softmax 再取索引,原因是数值稳定性。直接 softmax 之后取 log,遇到极端 logits 会得到-inf,反向直接死掉。

4.2 Circle Loss 实现

先给 pair 版本的骨架,方便理解公式和代码的对应关系。实现上为了效率,用掩码矩阵一次性算完所有 pair,而不是写双重循环:

import torch import torch.nn as nn import torch.nn.functional as F class CircleLossPair(nn.Module): def __init__(self, m=0.25, gamma=256.0, eps=1e-12): super().__init__() self.m = m self.gamma = gamma self.eps = eps def forward(self, emb, labels): # emb: [N, D] 需已 L2 归一化 # labels: [N] emb = F.normalize(emb, dim=1) sim = emb @ emb.t() # [N, N] 余弦相似度 N = emb.size(0) eye = torch.eye(N, dtype=torch.bool, device=emb.device) pos_mask = (labels.unsqueeze(0) == labels.unsqueeze(1)) & (~eye) neg_mask = labels.unsqueeze(0) != labels.unsqueeze(1) sp = sim[pos_mask].clamp(-1 + self.eps, 1 - self.eps) sn = sim[neg_mask].clamp(-1 + self.eps, 1 - self.eps) # 自步权重,detach 保证权重不从这步回传 ap = F.relu(-sp.detach() + 1 + self.m) an = F.relu(sn.detach() + self.m) delta_p = 1 - self.m delta_n = self.m logit_p = -self.gamma * ap * (sp - delta_p) # 注意负号 logit_n = self.gamma * an * (sn - delta_n) loss = F.softplus( torch.logsumexp(logit_n, dim=0) + torch.logsumexp(logit_p, dim=0) ) return loss

detach()那两行是这套损失能稳定训练的关键。如果自步权重参与反向传播,损失函数会往"把权重调小"的方向跑,等于模型自己给自己降难度,这就跑偏了。原文明确说权重是作为常数项处理的,代码里必须体现出来。

实际工程里更常用的其实是类级近似版本,因为它把 pair 数从平方降到线性。这个版本给嵌入层单独挂一个可学习的类中心矩阵,本质上是把 Circle 的思想套在 AM-Softmax 的框架上:

class CircleSoftmax(nn.Module): """类级 Circle Loss,计算量友好,适合 batch 不大的场景""" def __init__(self, in_features, num_classes, m=0.25, gamma=64.0): super().__init__() self.m = m self.gamma = gamma self.weight = nn.Parameter(torch.randn(num_classes, in_features)) nn.init.xavier_normal_(self.weight) def forward(self, x, labels): cos = F.linear(F.normalize(x, dim=1), F.normalize(self.weight, dim=1)) cos = cos.clamp(-1 + 1e-7, 1 - 1e-7) one_hot = F.one_hot(labels, num_classes=cos.size(1)).float() sp = (cos * one_hot).sum(dim=1, keepdim=True) # [N, 1] sn = cos - one_hot * 1e4 # 屏蔽正类那一列 ap = F.relu(-sp.detach() + 1 + self.m) an = F.relu(sn.detach() + self.m) logit_p = -self.gamma * ap * (sp - (1 - self.m)) logit_n = self.gamma * an * (sn - self.m) loss = F.softplus( torch.logsumexp(logit_n, dim=1) + logit_p.sum(dim=1) ) return loss.mean()

这里sn1e4去屏蔽正类,比用-inf安全,避免logsumexp遇到-inf时出现 NaN。logit_p.sum(dim=1)是因为每个样本只有一个正类中心,正对只有一条,没必要做logsumexp

4.3 接到不同分支上分别怎么改

检测任务里,替换的是分类分支的损失。以常见的单阶段检测器为例,原来的分类损失是F.cross_entropy或者F.binary_cross_entropy_with_logits,直接换成 Focal 就行,把超参传进去。回归分支不动,IoU 系列损失该用还是用。注意一点:Focal 只影响前景、背景的判别,不影响框的位置回归,所以别指望换了 Focal 之后定位精度也跟着涨,这两件事没关系。

检索或 ReID 任务里,替换的是嵌入头之后的损失。如果原来是nn.CrossEntropyLoss在最后的分类层上,可以换成CircleSoftmax,把num_classes设成训练集 ID 数。注意这个层通常在训练时用、推理时丢掉,因为它依赖类中心这个训练专用参数,推理时只需要嵌入头输出的归一化特征。

再补一个经验:检测里换 Focal 的时候,学习率通常要往下调一点,因为 Focal 的梯度量级和 CE 不一样。我的做法是先用原来的 0.5 倍学习率跑两个 epoch 看曲线,如果 loss 平稳下降再调回去。

5. 训练过程观测:损失曲线怎么读、怎么画

5.1 三个分支的曲线形态差异

训练时我同时盯三条曲线:分类损失、定位损失、总损失。它们的形态差异很大,读懂它们能省掉大量盲目调参。

分类损失换 Focal 之后,前期下降会比 CE 快,因为焦点在难样本上。但它不会像 CE 那样一路平滑降到接近零,而是在某个平台附近波动。这是因为 Focal 主动保留了难样本的损失,损失不可能真的趋近零。如果你看到 Focal 的分类损失降到 0.001 以下,八成是 γ 设得太小,或者数据里根本没有难样本。

定位损失的表现不一样,它通常前几个 epoch 快速下降然后长期缓慢爬升,这是因为初期模型预测的框和 gt 差得远,IoU 损失很大;随着训练推进,难样本逐渐被消化,剩下的都是些边界模糊的框,损失自然有波动。

总损失是加权和,所以在调权重系数的时候,最好把三条曲线分开看,不要只盯总损失。我见过有人因为总损失在降就以为一切正常,结果一看分类损失其实在涨,定位损失降得特别快,把它的信号完全盖住了。

5.2 从日志文件画损失曲线

如果你用的是 YOLO 系列的实现,训练完会生成一个results.csv,每行一个 epoch,列名是train/box_losstrain/cls_losstrain/dfl_loss这种。画图非常简单:

import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv('runs/detect/train/results.csv') # 列名通常带前导空格,统一处理一下 df.columns = [c.strip() for c in df.columns] fig, axes = plt.subplots(1, 3, figsize=(18, 5)) axes[0].plot(df['epoch'], df['train/box_loss'], label='train box') axes[0].plot(df['epoch'], df['val/box_loss'], label='val box') axes[0].set_title('box loss') axes[0].legend() axes[1].plot(df['epoch'], df['train/cls_loss'], label='train cls') axes[1].plot(df['epoch'], df['val/cls_loss'], label='val cls') axes[1].set_title('cls loss') axes[1].legend() axes[2].plot(df['epoch'], df['train/dfl_loss'], label='train dfl') axes[2].plot(df['epoch'], df['val/dfl_loss'], label='val dfl') axes[2].set_title('dfl loss') axes[2].legend() plt.tight_layout() plt.savefig('loss_curves.png', dpi=150)

如果你用的是 PyTorch 原生的SummaryWriter,可以直接从 event 文件里读:

from tensorboard.backend.event_processing import event_accumulator ea = event_accumulator.EventAccumulator('runs/exp1') ea.Reload() scalars = ea.Tags()['scalars'] print(scalars) # 看看有哪些 tag loss_steps = ea.Scalars('train/focal_loss') steps = [s.step for s in loss_steps] values = [s.value for s in loss_steps] plt.plot(steps, values)

注意:EventAccumulator默认只加载部分数据,如果 event 文件很大(几十万步),它会截断。加载前先调ea.Reload()之前设置size_guidance={'scalars': 0},表示不限制数量。

画图的时候我会额外做一件事:把 train 和 val 的损失画在同一张图上,用不同线型区分。这样可以一眼看出过拟合的起点——train 还在降、val 已经抬头,那个交叉点就是该 early stop 的位置。只看 train loss 的人是发现不了这个的。

5.3 异常曲线形态与病因对照

训练久了会发现,损失曲线的"形状"本身就是诊断信息。我把常见的几种异常形态整理成一张表:

曲线形态可能病因排查方向
一上来就 NaN学习率过大 / 无 logits 稳定写法降 lr,检查是否手动 sigmoid
前 100 步平在高位分类头 bias 未做 prior 初始化加 bias 初始化
持续震荡不收敛γ 过大 / batch 过小γ 降到 1.0~1.5,加大 batch
降到底后突然反弹过拟合 / γ 后期压制简单样本加正则,后期降 γ
长期卡在平台学习率太小 / 标签噪声升 lr,清洗数据
val 损失远高于 train分布不一致 / 过拟合检查数据划分

这张表我贴在显示器边上,每次训崩了先扫一遍再动手。

6. 常见问题与排查实录

6.1 常见问题速查表

现象原因解决办法
Focal Loss 训练完全不涨点α 和 γ 的组合跟数据不匹配先把 γ 设 0,退化成 CE 验证流程,再逐步加
换成 Circle Loss 后 loss 尖刺γ 过大从 32 开始往上试
Circle Loss 训练后期类内塌缩噪声标签被强行拉紧降 γ,或先做标签清洗
显存爆掉pair 数平方增长改用类级近似版本
损失曲线抖动剧烈batch 太小加梯度累积,或降 γ
混合精度下 loss 变 inffp16 下溢调制因子计算段强制 fp32
多分类 Focal 效果不如二分类α 用了标量改成 per-class 向量

6.2 几条只有踩过才记得住的心得

第一条,也是最重要的一条:别一上来就用奇技淫巧。我见过太多人在还没搞清数据是否有类别失衡的情况下就上 Focal Loss,结果发现根本原因是标注质量差或者学习率不对。正确的顺序是先用标准 CE 跑通全流程,看损失曲线形态,确认瓶颈后再考虑换。Focal 是解决特定问题的工具,不是万能加速器。

第二条,γ 的调整要配合学习率。γ 越大,有效梯度信号越稀疏,等效于减小了有效 batch。这时候如果学习率还维持原位,训练会明显变慢甚至卡住。我的一般做法是 γ 从 1.0 起,每上调 0.5,学习率下调 10% 左右。

第三条,Circle Loss 的detach千万别忘。我第一版实现的时候忘了加detach,训练前几个 epoch 看着正常,到中后期突然发现 loss 越降越假,模型学到的是一堆权重接近零的 pair,本质上是把损失函数本身优化掉了,而不是把特征学好。这个 bug 很隐蔽,因为 loss 曲线看起来漂亮得不行。

第四条,评估指标才是最终标准,损失只是过程量。我遇到过一次 Focal Loss 的分类损失降得特别好,mAP 反而掉了,原因是 γ 太大导致小目标这类"永远是难例"的类别梯度被过度放大,把其他类带偏了。所以每个超参改动之后都要在验证集上跑评估,别信 loss 曲线。

第五条,做过课程实验的人可能会发现,这类损失函数的"标准答案"在不同题目里长得不太一样——有的要你实现类级 softmax 版本,有的要 pair 版本。判断方法很简单:看它给不给类中心参数。给了就用类级,只给了嵌入向量和标签,那就得老老实实构造 pair 矩阵。搞清楚这个,比死记公式有用得多。

第六条,如果你是在做分布对齐或者生成任务,别把这两个损失当主力。Focal 是给离散分类用的,Circle 是给相似度排序用的,它们的输入形式决定了它们的适用边界。遇到"两个分布怎么拉近"这类问题,考虑的是别的族的工具,比如基于搬土距离的那类损失,或者干脆回归到对抗训练框架里。认清边界,比硬套工具省事得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询