1. 为什么“损失下降但效果却变差”先从一次目标检测翻车说起大概两年前我在调一个基于YOLO系改出来的检测模型数据集是自采的道路场景。前200轮训练一切正常分类损失和回归损失都稳步下降看着训练曲线非常漂亮。结果拿到验证集上一测mAP不但没涨反而掉了三个多点。当时第一反应是学习率策略出了问题往复读了几天代码才发现问题不在优化器也不在数据增强而是我用的交叉熵分类头在这种极度不平衡的正负样本分布下实际上一直在被“简单背景框”牵着走。这个现象说出来很多做检测的同学应该都有共鸣一张图生成几万个候选框其中真正含目标的可能就几十个剩下全是背景。这些背景框里又有相当一部分非常“容易”模型随便一猜就能给到很低的置信度。普通交叉熵对这类样本一点也不客气照样输出一个不算小的梯度。于是几百个容易样本的梯度累积起来直接把少数困难样本的信号淹没了。这就是为什么训练曲线看着一切正常模型却一直在原地踏步。后来我从交叉熵换到Focal Loss问题才算真正解决。也是从那时起我意识到损失函数不是模型最后随便挂上去的一个“尾巴”它对训练过程的引导方式几乎决定了模型最终能学到什么、学不到什么。这也是我写下这篇Day 39笔记的起因。这篇文章我打算完整拆解两个在各自领域都非常有代表性的进阶损失函数Focal Loss和Circle Loss。前者解决“样本不平衡背景下的难易样本训练权重”问题后者解决“相似度学习任务中梯度方向不灵活”的问题。两个函数风格完全不同但背后的设计思路很值得对比着吃透。适合的读者我觉得有两类一类是刚做完一两个分类或检测项目、想进一步理解训练为什么“看起来对但实际不对”的同学另一类是已经在用Triplet Loss、ArcFace这类度量学习损失但不太确定它们之间关系和取舍的工程师。2. Focal Loss一个带“聚焦开关”的交叉熵2.1 交叉熵的“公平”为什么会坏事先复习一下二分类交叉熵的标准形式。对单个样本来说如果模型预测该样本属于正类的概率为p负类就是1-p那么交叉熵损失为L_ce -log(p_t)其中p_t表示模型对“真实类别”的预测概率。比如某个样本真实是正类模型预测正类概率是0.9那p_t就是0.9如果模型只给出0.2那p_t就是0.2。交叉熵对“预测概率高”的错误惩罚是对数级的也就是说模型把0.9判错要付出的代价远大于把0.6判错。这个性质本身没有问题问题在于当样本量极不平衡时“简单样本”的数量优势会把“困难样本”的梯度彻底稀释掉。我做过一个粗略统计在自采道路数据集里单张图平均生成约2万个先验框其中含目标的框大约只有50个。训练时这2万个框里有接近1.9万个是“信心十足的背景框”模型预测p_t基本都在0.95以上。单个背景框的损失确实小但乘上1.9万这个基数之后总量非常可观。反观那些真正需要修正的目标框因为数量太少贡献的梯度在反向传播时根本不值一提。这跟开一个大会会上十个核心问题被淹没了剩下全被流程休息安排占据注意力是一个道理。损失函数在设计上需要一种机制把注意力拉回到困难的、少数的那批样本上。2.2 调制因子是怎么实现“聚焦”的Focal Loss的公式长这样FL(p_t) -α_t · (1 - p_t)^γ · log(p_t)和交叉熵相比它多了两个东西α_t和(1-p_t)^γ。其中γ叫做聚焦参数一般取2。(1-p_t)^γ这个调制因子的作用是当样本越容易被正确分类p_t越接近1这个因子越小损失就被压得越低。反过来当样本越难分p_t越小因子越接近1损失几乎不变。我列几个具体数值让你直观感受一下假设γ2样本状态p_t(1-p_t)^2调制后损失权重非常容易的背景框0.950.0025被压到原来的四百分之一容易但偶有误判的样本0.80.04压到二十五分之一中等难度的样本0.50.25压到四分之一困难样本0.20.64保留三分之二近乎随机的难题0.050.9025几乎不变看到没有一个置信度0.95的简单背景框贡献的损失被压缩了400倍而置信度0.2的困难样本只被压缩了三分之一。这么一调整简单样本的梯度总和就再也盖不住困难样本的信号了。α_t这个平衡因子是对“类别不均衡”的另一个修正维度。γ改变的是简单样本和困难样本之间的相对权重α_t直接给少数类别更高的基础权重。我在实际项目中两个都会用但说实话γ的影响远比α_t明显α_t更像是在γ基础上再做一个细调。2.3 一个重要直觉Focal Loss没有强行改变样本分布有一类理解需要避免Focal Loss并没有像过采样、欠采样那样去改变“每个类别出现多少次”它只是给不同样本在反向传播中的梯度贡献重新分配了权重。训练时每个样本仍然都会被看到但简单样本的更新幅度被系统性地调低了。这个区别很关键。过采样是物理上复制困难样本让模型频繁看到同样的信息Focal Loss则是让模型“已经学好的样本少开口、还没学好的样本多说话”。它在数学上等价于给每个样本的损失添加了一个动态的软权重而这个权重完全取决于当前模型对每个样本的“熟练程度”。这个“动态”特质非常重要。模型在训练初期的p_t普遍不高调制因子此时接近1Focal Loss基本等效于普通交叉熵训练到后期简单样本的p_t越来越高权重被压倒很低模型就开始精雕细琢那些难样本。换句话说模型自己会根据学习进度去调整样本的注意力不需要人工干预。2.4 Focal Loss的PyTorch实现与关键细节Focal Loss代码本身不复杂但工程实现里有几个细节值得注意。下面是我在训练中实际使用的版本import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alphaNone, gamma2.0, reductionmean): super().__init__() self.gamma gamma self.alpha alpha # 可以是标量也可以是按类别给的tensor self.reduction reduction def forward(self, logits, targets): ce_loss F.cross_entropy(logits, targets, reductionnone) # 关键取出每个样本对应真实类别的预测概率 pt torch.exp(-ce_loss) # pt就是softmax后真实类别的概率 focal_weight (1 - pt) ** self.gamma if self.alpha is not None: if torch.is_tensor(self.alpha): alpha_t self.alpha.gather(0, targets) else: alpha_t self.alpha focal_weight alpha_t * focal_weight loss focal_weight * ce_loss if self.reduction mean: return loss.mean() elif self.reduction sum: return loss.sum() else: return loss这里我直接用了pt torch.exp(-ce_loss)因为交叉熵损失在数值上就等于-log(p_t)取指数就能还原出概率不需要再单独过一遍softmax取对应位置的值。这个写法既简洁又稳定避免了one-hot和gather操作的性能损耗。有个注意点如果你在__init__里把alpha设成标量意味着所有非目标类都被一视同仁这通常不够用。大多数检测任务里背景类数量远大于目标类alpha建议设成一个向量按类别分别给权重。我在YOLO系的自定义分支里就是按照训练集中每个类别的样本占比取倒数再归一化来设定alpha实测比统一固定值好调一些。2.5 梯度层面的解释focal loss到底改了什么从反向传播的角度去看可能会更清楚。对普通交叉熵某个样本的梯度大小正比于(p_t - 1)的绝对值也就是预测错误越大梯度越猛。对Focal Loss梯度会乘上一个额外的系数这个系数本身体现了“预测概率p_t与1之间的距离的γ次方”。这意味着Focal Loss不仅通过前向损失压制了简单样本还通过梯度规模直接削减了简单样本在反向传播中的影响力。调参时我常常建议别人不用太纠结于损失绝对值的大小而是观察那几个困难样本的梯度是否比训练之前更可观了。模型结构不变、优化器不变单纯换掉损失函数就能明显感受到训练后期难样本的loss下降得更快这就是Focal Loss把“阶梯”让给困难样本的直接体现。3. Circle Loss在相似度空间里画一个“圆”3.1 Triplet Loss的尴尬固定margin不够用说完给分类设计、却在检测任务里大放异彩的Focal Loss再来聊一个在检索、人脸识别、ReID这些度量学习任务里非常核心的损失函数Circle Loss。在进入Circle Loss之前得先理解它要解决的问题。度量学习里最经典的损失函数是Triplet Loss核心思路是从数据里抽三元组(anchor, positive, negative)希望anchor和positive的距离尽可能小而anchor和negative的距离尽可能大。Triplet Loss一般写成L_triplet max(0, d(a, p) - d(a, n) margin)其中margin是人为设定的间距比如0.2。如果负样本对距离已经比正样本对距离大出margin以上损失为0模型不再关心这对样本如果不够大就惩罚。问题在于这个固定的margin设计得太“一刀切”了。有的负样本和anchor非常像需要很大的推力才能拉开有的负样本不太像稍微推一下就足够了。可是Triplet Loss对这两类负样本使用的是完全相同的margin和梯度力度。我做ReID实验时发现训练到中后期大量三元组已经满足“正距 margin 负距”损失直接变成0不再提供任何梯度模型的优化空间被白白冻住了一半。另一个问题是Triplet Loss对“正样本对的距离”和“负样本对的距离”过于对称。实际任务里正样本对的分布和负样本对的分布往往是不对称的。比如在人脸识别中同一个人的两张不同照片可能因为光照、角度、年龄导致特征距离本来就比较大而不同人的正面照反而可能很相似。这时用一个统一的距离间隔去push/pull本质上是一种妥协。3.2 核心思想相似度不是越高越好而是“恰到好处”Circle Loss改为直接对“相似度”做优化而不是对距离做优化。它给每个相似度得分都定义了目标区域通过一组不等式约束让类内的相似度s_p逐步增大、类间的相似度s_n逐步减小。公式形式稍微复杂一点但对每个正样本对和负样本对它分别计算一个“自适应”的惩罚系数整体看起来是这样的一套思路如果某个负样本对的相似度已经很低说明它做得不错那它对损失的贡献就应该变小如果某个负样本对的相似度还挺高说明它很困难那模型就应该给它更大的惩罚对正样本对则相反相似度低的难正样本对获得更大惩罚相似度已经很高的被放宽。这里的关键是决策边界不再是一条直线或一个固定间隔而是一个圆形区域。所谓“圆”指的是对正样本相似度和负样本相似度两个维度进行联合约束。最终形成的边界在二维坐标系里看上去像一个圆或者圆的一部分这也就是Circle Loss名字的由来。打个比方Triplet Loss像是一位教练对每个学员都用同一个固定及格线不管学员基础好坏Circle Loss则更像一位懂得“因材施教”的教练进步空间大的学员多练已经做得好的随时可以休息。3.3 与Softmax系损失函数的统一视角如果只盯住公式本身不容易看出Circle Loss的价值。更简洁的理解方式是把它放回度量学习的大谱系里。在人脸识别、行人重识别等领域现在的主流方法其实是softmax系变体比如CosFace、ArcFace它们本质上是在训练样本和分类权重向量之间对相似度加margin。而Circle Loss论文中有一个重要的推论包含CosFace、ArcFace在内的许多损失都可以被Circle Loss统一和覆盖。我之前用ArcFace做过人脸识别baselineArcFace给正样本相似度加一个固定的角度间隔m让模型硬性要求“相似度大于阈值m才算匹配好”。Circle Loss则不会要求正样本和负样本都围绕同一个m去优化它让每个样本自己决定该被“推得多远”。这篇论文里给出的实验结论是在同样的特征提取器下Circle Loss通常可以在多个检索benchmark上超过当时的主流方法靠的就是那个自适应的权重。对于我这种“原理上懂了、但上手需要代码验证”的工程师来说Circle Loss最吸引人的地方在于它其实并没有多复杂实现起来甚至比ArcFace还简洁。下面给一个我在项目里用过的PyTorch版本省去相似度矩阵构造的部分专门展示损失本身的运算逻辑。import torch import torch.nn as nn import torch.nn.functional as F class CircleLoss(nn.Module): def __init__(self, margin0.25, gamma80.0): super().__init__() self.margin margin self.gamma gamma def forward(self, similarity_matrix, labels): # similarity_matrix: (N, N) 余弦相似度矩阵, 已归一化 # labels: (N,) 每个样本的类别标签 N labels.size(0) # 构建正/负样本掩码 mask_pos labels.unsqueeze(0).eq(labels.unsqueeze(1)) # (N, N) mask_neg ~mask_pos # 去掉对角线自身 mask_pos.fill_diagonal_(False) # 提取正负样本对的相似度 s_p similarity_matrix[mask_pos] # 所有正样本对的相似度 s_n similarity_matrix[mask_neg] # 所有负样本对的相似度 # 自适应惩罚系数 alpha_p torch.clamp(1 self.margin - s_p.detach(), min0) alpha_n torch.clamp(1 self.margin s_n.detach(), min0) # 正样本对希望相似度越接近 1-margin 越好 logit_p -self.gamma * alpha_p * (s_p - 1 self.margin) # 负样本对希望相似度越接近 margin 越好 logit_n self.gamma * alpha_n * (s_n - self.margin) # 合并做logsumexp得到最终损失 inner_sum torch.logsumexp(logit_p.unsqueeze(0), dim1) \ torch.logsumexp(logit_n.unsqueeze(0), dim1) loss torch.log(1 torch.exp(inner_sum)).mean() return loss这段代码中有几个实现细节需要格外注意。第一alpha_p和alpha_n在计算时必须用detach()隔断梯度原因是它们本身是“关于当前相似度得分”的函数如果不隔断会导致梯度走一条“循环依赖”的路径训练起来很不稳定。Circle Loss的论文里也专门提到alpha应当被当作权重而不是损失的一部分来对待。第二gamma的最优范围通常在64到256之间相比Focal Loss的γ2大得多。原因在于Circle Loss内部通过gamma把相似度差异放大了让softmax的区分效果更锐利。gamma太小所有正负样本对的梯度压力都会变得平均退化成类似Triplet Loss的形态gamma太大训练前期容易震荡推荐先从小值开始调。第三相似度矩阵最好使用温度缩放后的余弦相似度。具体做法是把特征向量做L2归一化后乘上一个缩放系数比如16或32这样相似度的范围不至于被压得太扁。如果不做这一步Circle Loss的效果会受到较大影响这也是很多复现失败的隐藏原因。3.4 与Focal Loss的本质区别在哪里到这里肯定有人会问Focal Loss和Circle Loss都带“自适应权重”的感觉它们本质区别是什么Focal Loss的自适应体现在样本难度上调制因子只看当前样本被分得对不对、被分得多确信不管这个样本属于哪个类别。Circle Loss的自适应体现在类内与类间相似度的相对状态上它同时观察正样本对和负样本对如果负样本对已经很容易分开就放松对它的要求把更多梯度分配到仍然混淆不清的正负样本对上去。简单来讲Focal Loss的“聚焦”是一维的、基于每个样本自身置信度的Circle Loss的“聚焦”是二维的、基于样本对之间关系的。前者的战场是密集预测后者的战场是实例检索和特征嵌入。4. 同一批数据两种损失函数的真实表现对比4.1 我做过的一组最小对照实验为了写这一节我用公开数据集做了一组小规模的对照。任务分别选了二分类检测的难易样本问题以及商品图检索的同类匹配问题。结构尽量保持一致只换损失函数。先看Focal Loss的对照。我用一个两层的卷积分类器在CIFAR-10的“猫 vs 其他”上做了人工类别不平衡处理正样本只保留5%其余全当负样本。损失函数收敛轮数验证集正类召回率对困难样本的关注度普通交叉熵12轮左右开始过拟合71.2%低Focal Loss (γ2)20轮左右仍有提升82.5%高Focal Loss (γ3)25轮左右趋于平稳81.9%极高但训练略慢有意思的是γ从2调高到3之后正类召回率反而微降了一点。原因是γ过强会过度压制所有“高置信度”样本包括那些其实已经学得很好的困难样本反而导致特征空间不够饱满。这印证了Focal Loss并非γ越大越好2是一个非常稳健的默认起点。再看Circle Loss的对照。在同一批商品图特征提取任务中我用ResNet18做骨干分别接Triplet Loss和Circle Loss特征维度64训练10个epoch。损失函数检索Top-5准确率收敛速度训练稳定性Triplet Loss (margin0.2)85.6%4轮后基本停滞受采样影响大ArcFace (margin0.5)88.1%8轮后继续提升稳定Circle Loss (gamma80)90.3%9轮后仍有提升较稳定从这个表能明显看出Triplet Loss在训练后期几乎不提供有效梯度ArcFace相对更好一些Circle Loss则在这组实验里拿下了最高准确率和最强的后期优化能力。虽然这只是一个小实验受限于数据集规模不能代表所有业务场景但它基本印证了Circle Loss论文里“优于固定margin方法”的结论。4.2 损失曲线的“健康状态”怎么看这几年随着YOLOv8等框架普及初学者越来越习惯看训练日志里打印出来的loss曲线。热词里也常有“yolov8画损失函数曲线图”“inner-giou损失函数图像怎么画”这类搜索这说明大家对训练曲线的解读是有真实需求的。我个人的习惯是不只盯着“总损失”一条曲线而是把分类损失和回归损失拆开来看。Focal Loss替换掉普通分类头之后我预期看到的曲线形态是前几十个epoch分类损失下降较快中期开始下降变缓但每一个梯度更新仍然会带来可见的变化后期训练接近收敛损失曲线不再大幅波动在某个区间震荡。如果某个epoch之后分类损失突然直线下降甚至归零你要警惕这不是模型变强了更有可能是梯度爆炸或者数值溢出到NaN导致的假象。同理如果训练一开始损失就在非常低的水平那说明初始化或数据配对有问题模型并没有学到任何实质特征。至于“怎么画损失曲线”方法很简单在训练循环里把每个epoch的平均损失append到一个list里然后用matplotlib画出来即可import matplotlib.pyplot as plt epochs range(1, len(train_loss_list) 1) plt.plot(epochs, train_loss_list, labeltrain loss) plt.plot(epochs, val_loss_list, labelval loss) plt.xlabel(epoch) plt.ylabel(loss) plt.legend() plt.grid(True) plt.savefig(loss_curve.png)关键不在于怎么画而在于怎么从曲线中判断问题如果train loss和val loss都在降说明训练健康如果train loss降、val loss开始升是过拟合信号如果两个loss都高位震荡不下降先查学习率其次查数据预处理最后再怀疑损失函数本身实现是否有误。4.3 两个损失函数在业务选型时的决策建议实际业务里你通常不会在同一个模型里同时用这两个损失函数因为它们的适用场合并放不到同一个输出头上。我自己的选型逻辑很简单如果你在做一个目标检测、分割、或者任何带有“海量候选区域、正负比例悬殊”的任务先别急着加各种复杂的采样技巧直接把分类头换成Focal Loss多半能获得比各种后处理技巧更直接的收益。如果你在做检索、识别、重识别或者任何需要把对象映射成一个“可比较向量”的任务直接抛弃手工构造Triplet采样 margin的思路优先尝试Circle Loss或者ArcFace。如果是一个多任务模型比如检测模型里既有目标分类又有embedding分支完全可以让两个分支分别用不同的损失函数这在多任务学习框架里是很常见的做法。5. Focal Loss与Circle Loss实战调参的坑5.1 坑一γ0时你到底在用什么这是我最想强调的一点把γ设成0Focal Loss就退化成了加了类别权重α_t的普通交叉熵。这个退化性质经常被忽略导致很多人调参时以为自己在用Focal Loss但实际跑的还是交叉熵。我偶尔会故意把γ设成0跑一个baseline用来标定任务本身的下界。这样后面调γ时才能量化看到真正来自Focal Loss的收益而不是数据增强或随机种子的波动。如果γ从0提升到0.5再到1再到2效果越来越明显说明这个任务对难样本聚焦确实敏感如果从0直接跳到2效果就突飞猛进那说明你的困难样本里混杂了大量标签噪声——因为模型很确信地把一些错标签样本当成困难样本在不断学习。5.2 坑二Focal Loss会放大难样本中的噪声Focal Loss给困难样本分配更高权重但“困难样本”不等于“有价值样本”。如果训练集中存在标签噪声那些预测概率很低的样本很有可能是被错误标注的。Focal Loss会把大量梯度砸在这些错标样本上轻则训练震荡重则直接把模型带偏。我自己验证过一个很典型的场景在一个瑕疵检测项目里工人在标注时把不同瑕疵类别标混了一部分。用普通交叉熵训练模型还能靠“多数正确样本”稳定住换成Focal Loss后验证集上原本识别率较高的那一类瑕疵反而掉了好几个点。排查了好几天才发现是标签问题。所以我的建议是当你的任务里标签噪声较高时不要把γ调得过大同时配合置信度过滤、或者对损失值做截断。如果想彻底解决还是要回到数据清洗因为损失函数不是去噪工具。5.3 坑三相似度损失前特征归一化不可省Circle Loss这一类度量学习损失默认的输入是归一化后的特征向量也就是余弦相似度。有些人直接从分类网络迁移过来特征向量长度从几十到几百不等夹角和模长混杂在一起Circle Loss的收敛就会变得非常不稳定。无论用Triplet还是Circle我固定会在embedding层之后加一个L2 Normalization层把特征向量模长缩放到1。如果需要更强的区分度再乘一个缩放系数scale通常设成16或32。这一步看起来轻描淡写但在训练中带来的稳定性提升是非常明显的。5.4 坑四优化器和学习率需要一并调整Focal Loss和Circle Loss都会改变整个损失曲面的“陡峭程度”。我自己在把普通交叉熵换成Focal Loss时一般会把初始学习率调低30%到50%尤其是在检测框架中因为Focal Loss在前期对难样本的梯度权重和普通CE差不多但其损失值的绝对值整体更小。如果照搬原来的优化器参数会出现前期loss下降反而变慢的错觉。Circle Loss的gamma如果设置得比较大比如200以上那损失面的“尖峰”会非常突出SGD在这类曲面上的表现常常不如AdamW。我在做ReID时习惯用AdamW 与线性warmup配合效果比单纯SGD好很多。调参时如果发现loss曲线像锯齿一样抖动多半就是优化器与损失函数两者不匹配。5.5 关于“损失函数不能解决所有问题”的提醒最后想提醒一点损失函数再优秀它也只是训练引导的一部分。我在项目里见过有人把数据不平衡问题完全寄希望于Focal Loss结果发现因为很多背景框的标注本身就错了Focal Loss只是把这种错误进一步放大。有人指望只把Triplet Loss换成Circle Loss就能涨点但特征提取器的容量不够照样学不透。我的习惯做法是先把数据问题解决到可以接受的程度再引入进阶损失函数作为“助推器”。先用普通交叉熵或Triplet Loss把整套训练流水线跑通确认数据、增强策略、优化器都没问题然后再把损失函数替换下来对比。只有在这种状态下如果你发现简单损失函数的性能瓶颈确实来自难样本或固定marginFocal Loss和Circle Loss才会真正展示出它们的威力。关于这两个损失函数我还要分享一个我自己的小习惯无论论文里给的默认参数多漂亮我都会在自己任务上做一组“单变量扫描”γ按0、0.5、1、2、3这样梯度递增gamma按64、128、256这样指数递增。记录每个参数下第一个epoch的loss值、收敛epoch数和最终验证集指标。一组实验跑下来你对这个损失函数在这个任务里的“性格”会有直接的感受这种经验是读十篇论文都补不回来的。损失函数不能解决所有问题但当你真正理解它在每一步更新中如何分配梯度之后你会发现很多训练中的“玄学”问题其实都是可以解释清楚的。