
1. 损失函数选型先搞清楚这两个损失在谱系里的坐标做了几年视觉和检索方向的训练我逐渐形成一个习惯模型训不动的时候先别急着换网络结构先看损失函数。这次要聊的Focal Loss和Circle 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 weightFocal Loss单样本概率 难易调制检测、长尾分类α、γCircle Loss样本对相似度人脸、ReID、检索m、γ、sTriplet Loss三元组距离差度量学习marginInfoNCE正负对的对比自监督、图文对齐温度 τ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当 y1或1-p当 y0于是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), dim0) torch.logsumexp(-gamma * a_p * (s_p - delta_p), dim0) )这里有个容易看漏的地方第二个 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 的关系把这几条放在一起对照能省掉很多到底该用哪个的纠结损失采样粒度权重方式计算量Triplethard mining1 正 1 负硬采样低Tripletall 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, alpha0.25, gamma2.0, reductionmean): 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(), reductionnone ) 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, alphaNone, gamma2.0, reductionmean): 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, dim1) 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, m0.25, gamma256.0, eps1e-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, dim1) sim emb emb.t() # [N, N] 余弦相似度 N emb.size(0) eye torch.eye(N, dtypetorch.bool, deviceemb.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, dim0) torch.logsumexp(logit_p, dim0) ) return lossdetach()那两行是这套损失能稳定训练的关键。如果自步权重参与反向传播损失函数会往把权重调小的方向跑等于模型自己给自己降难度这就跑偏了。原文明确说权重是作为常数项处理的代码里必须体现出来。实际工程里更常用的其实是类级近似版本因为它把 pair 数从平方降到线性。这个版本给嵌入层单独挂一个可学习的类中心矩阵本质上是把 Circle 的思想套在 AM-Softmax 的框架上class CircleSoftmax(nn.Module): 类级 Circle Loss计算量友好适合 batch 不大的场景 def __init__(self, in_features, num_classes, m0.25, gamma64.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, dim1), F.normalize(self.weight, dim1)) cos cos.clamp(-1 1e-7, 1 - 1e-7) one_hot F.one_hot(labels, num_classescos.size(1)).float() sp (cos * one_hot).sum(dim1, keepdimTrue) # [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, dim1) logit_p.sum(dim1) ) return loss.mean()这里sn用1e4去屏蔽正类比用-inf安全避免logsumexp遇到-inf时出现 NaN。logit_p.sum(dim1)是因为每个样本只有一个正类中心正对只有一条没必要做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_loss、train/cls_loss、train/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], labeltrain box) axes[0].plot(df[epoch], df[val/box_loss], labelval box) axes[0].set_title(box loss) axes[0].legend() axes[1].plot(df[epoch], df[train/cls_loss], labeltrain cls) axes[1].plot(df[epoch], df[val/cls_loss], labelval cls) axes[1].set_title(cls loss) axes[1].legend() axes[2].plot(df[epoch], df[train/dfl_loss], labeltrain dfl) axes[2].plot(df[epoch], df[val/dfl_loss], labelval dfl) axes[2].set_title(dfl loss) axes[2].legend() plt.tight_layout() plt.savefig(loss_curves.png, dpi150)如果你用的是 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 是给相似度排序用的它们的输入形式决定了它们的适用边界。遇到两个分布怎么拉近这类问题考虑的是别的族的工具比如基于搬土距离的那类损失或者干脆回归到对抗训练框架里。认清边界比硬套工具省事得多。