MMDetection损失函数详解:从配置到实战调优指南
很多刚开始接触mmdetection的同学跑通训练流程之后大概率都会遇到同一个问题训练日志里那几行loss_cls、loss_bbox、loss_rpn_cls到底在算什么为什么别人改一个typeFocalLoss或者把SmoothL1换成GIoU模型效果就上去了自己照着改却越调越差我当时也有同样的困惑花了不少时间翻源码、查文档才把损失函数这条线彻底捋顺。这篇笔记不打算复述官方说明而是从一个小白研究者的视角把mmdetection框架里的损失函数这件事讲透损失由哪几部分构成、配置文件里每一项是什么意思、每种loss到底在优化什么、真正踩坑的时候该怎么定位问题。内容会尽量直接可操作源码阅读思路和调试经验也会一并给出。1. 理解损失函数之前先弄懂目标检测在“学什么”1.1 任务拆解分类和定位是两条独立的“评分线”目标检测和一个普通分类任务最大的区别在于它同时输出两类预测一个框在哪里位置以及框里的东西是什么类别。在数据标注里每个真值框gt bbox除了四个坐标一定还会带一个类别id这两个信息在网络里由不同的分支负责预测。mmdetection里的检测头head大都遵循同一种设计模式分类分支输出每个候选区域的类别概率分布回归分支输出四个位置偏移参数。训练时这两个分支各自计算损失再按一定权重加到一起得到最终的总loss。所以你会在训练日志里看到loss_cls和loss_bbox这样的字段——它们一个是分类任务的“成绩单”一个是定位任务的“成绩单”二者独立计算却共享网络的backbone特征。打个比方检测模型像一个同时考语文和数学的学生两个科目分别批改分数最后加权总分决定他这轮“学习”进步的方向。如果某个科目的评分方式不公平比如把大量低难度题目的分数权重设得太高模型的精力就会全花在应付这些简单题上难题反而学不好——分类损失里正负样本失衡的坑本质上就是这么来的。1.2 损失值如何变成训练动力一次完整训练迭代的过程可以用一句话概括网络拿到图片前向计算出预测值损失函数把预测值和真值做比较得出一个标量loss然后调用loss.backward()计算梯度优化器用梯度更新所有参数。这里的关键是loss是整个训练闭环里唯一连接“网络输出”和“标注真值”的桥梁。你希望模型最终具备什么能力就要把对应能力的“评分标准”设计进损失函数里然后用梯度下降让模型逐轮降低这个分数。理解了这层关系再看mmdetection的损失设计思路就不会觉得它只是零零散散的几个公式了——它整套配置体系解决的核心问题就三个预测什么、哪些样本参与计算、每个样本的误差被如何加权。1.3 总损失公式与mmdetection中的“多损失共存”大多数检测器训练时的总损失可以写成L_total λ_cls * L_cls λ_bbox * L_bbox但实际训练时往往不止这两个。两阶段模型里有RPN的损失和RoI head的损失带mask的模型还要加mask损失再加上可能存在的辅助损失。mmdetection的做法是让每个模块自己计算出一组loss最后在detector层面汇总成一个字典再统一求和做反向传播。这种做法有两个直接好处一是每个loss分量都是可解释的你可以从训练日志里看到是分类在拖后腿还是回归在摆烂二是不同loss的权重可以独立调节科研做消融实验时非常方便改一行配置就行不用动训练主流程的代码。2. mmdetection的配置哲学一个dict控制一个Loss2.1 典型配置文件里的loss_cls与loss_bbox打开任意一个mmdetection的config文件只要看到带loss_cls和loss_bbox的字段那就是检测头训练时使用的损失函数定义。以两阶段模型中常见的配置为例loss_clsdict( typeCrossEntropyLoss, use_sigmoidTrue, loss_weight1.0 ), loss_bboxdict( typeSmoothL1Loss, beta1.0, loss_weight1.0 )这个dict的含义非常直白type字段指定使用哪一类loss其余字段是传给该loss类的初始化参数。loss_weight是前面公式里的λ表示这个损失在总loss里占多大比重。RPN阶段和RoI阶段的损失是分开配置的所以你会看到rpn_loss_cls、rpn_loss_bbox以及roi_head内部的loss_cls、loss_bbox它们各自独立不会互相覆盖。理解这个结构之后换损失函数就变成了一件“改配置”的事不想用SmoothL1把type改成GIoULoss就行想试Focal Loss把分类损失整体替换掉。mmdetection的这套设计把实验灵活性拉满了对做研究的人来说非常友好。2.2 weighted_loss封装均值、求和与归一化的处理细节有同学自己改过loss之后会问loss返回的是一个向量还是一堆loss的求和为什么我写的loss和mmdet自带loss在数值量级上总是对不上这里要提到mmdetection里的一个核心包装器weighted_loss装饰器。它本质上是一个高阶函数把原始的逐样本损失函数包装成支持weight、avg_factor、reduction参数的版本。举个例子weighted_loss def smooth_l1_loss(pred, target, beta1.0): # 计算每个样本的smooth_l1误差返回一个向量 ... weighted_loss def cross_entropy(pred, label): # 计算每个样本的交叉熵返回一个向量 ...被weighted_loss装饰后这些函数在外部调用时接收到的就不再是逐样本向量而是经过reductionmean或sum处理后、并乘上loss_weight的标量。mmdetection里绝大多数loss默认取mean也就是对一批样本的误差取平均这样loss数值不会随着batch size增大而无限膨胀。实际调试时如果你发现某个自定义loss的数值比自带loss大了很多倍先别急着怀疑前向计算检查一下是否漏了avg_factor的处理。在分布式的多卡训练中avg_factor还会承担多卡间样本数量的聚合缺了它不同卡之间的loss表现会非常不一致。2.3 注册机制config里的type为什么能被自动识别mmdetection能靠一个字符串调用具体的loss实现靠的是它的注册表机制。每个loss类定义时都在类名上方加一行装饰器from mmdet.models.losses import LOSSES LOSSES.register_module() class SmoothL1Loss(nn.Module): def __init__(self, beta1.0, reductionmean, loss_weight1.0): super().__init__() ...注册表里的LOSSES本质上是一个dictkey是type字符串value是类本身。框架在build阶段会执行LOSSES.build(cfg)也就是根据cfg[type]从注册表里取出对应类再传入其余参数实例化。因此自定义一个损失函数后必须保证两点一是类定义上用LOSSES.register_module()注册二是该模块文件被import过。mmdetection在mmdet/models/losses/__init__.py里集中导入了所有loss模块如果你新建了自己的loss文件记得在__init__.py里补一行导入否则框架会一直报TypeError: LOSSES is not registered。2.4 从config到backward的完整调用链把整条调用链在心里梳理清楚排查问题时就能少走很多弯路。一次训练中损失相关的路径大致如下train_step() → model.forward_train() → roi_head.forward_train() → roi_head.loss(...) → self.loss_cls(cls_score, labels) → self.loss_bbox(bbox_pred, bbox_targets) → losses_dict detector.parse_losses(...) → loss_total.sum().backward()parse_losses会把所有模块返回的loss字典汇总成一个加权和。在这个链路里loss_cls和loss_bbox调用的就是我们在config里指定的那个类实例。如果你想改损失函数通常有两条路线改config选用现成的loss或者写一个新的loss类并注册接入。两种方式都不需要动训练框架本身。3. 分类损失CrossEntropyLoss与Focal Loss的底层逻辑3.1 检测中的分类任务和图像分类有什么不一样平时做图像分类一张图对应一个标签类别之间通常互斥训练样本也比较均衡。到了目标检测里情况完全不同一张图可能只有几个真值框但RPN阶段生成的候选区域动辄成千上万其中绝大多数是背景。这意味着分类分支要面对的样本极大、正负比例严重失衡。极端情况下比如一张图里有1个目标、1000个候选区域负样本占比99.9%。如果直接用普通交叉熵训练模型很容易学成一个“把什么都预测成背景”的傻瓜——因为背景类预测对了就能拿到极低的loss这个“偷懒”方案在梯度上占绝对优势真正难学的前景类别反而学不好。两阶段模型靠RPN先筛选一轮候选框情况还好一阶段模型在全图anchor上直接做分类必须在损失函数上想办法这就是Focal Loss出场的背景。3.2 CrossEntropyLoss的两种形态use_sigmoid是关键在mmdetection配置里使用CrossEntropyLoss时一定会遇到use_sigmoid这个参数。它的作用一句话就能说清决定网络最后一层分类输出是经过sigmoid还是softmax。use_sigmoidFalse每个类别走softmax所有类别概率之和为1类别之间互斥。use_sigmoidTrue把每个类别当成独立的二分类问题各自套sigmoid类别之间不互斥背景类也是一个独立位置。目标检测里为什么普遍用use_sigmoidTrue一是因为检测任务允许同一位置既属于A类又属于B类的情况少见但存在二是因为Focal Loss等后续方法天然建立在sigmoid输出之上。很多刚上手的人在“多类别检测”场景下会把use_sigmoid改成False结果发现模型AP怎么都不涨——不是模型坏了是穷举空间从“每个类别独立判断”变成了“所有类别二选一”信息表达方式变了。一般默认保持True就好。3.3 Focal Loss正负样本不平衡问题的针对性解法Focal Loss的核心思想用一句话总结让模型把注意力集中在“难分”的样本上减少“易分”的背景样本对梯度的主导。公式长这样FL(p_t) -α_t * (1 - p_t)^γ * log(p_t)其中p_t是模型对正确类别的预测概率。门道全在那个调制系数(1 - p_t)^γ上如果一个样本很容易被正确分类p_t接近1调制系数趋近于0它对loss的贡献被大幅压低。如果一个样本很难分p_t较小调制系数接近1loss几乎不受影响。举个例子假设正样本概率为0.9γ2时调制系数是(1-0.9)^20.01这个样本的loss被压到原来的1%另一个难样本概率是0.4调制系数是0.36loss只缩到原来的36%。一对比就清楚了模型会把梯度集中在难样本上。mmdetection里的FocalLoss还支持alpha参数用来调节正负样本的整体权重。默认γ2、α0.25是论文里的经验值实际使用时在0.5~2针对γ之间调都有依据。特别说明一点在mmdetection里如果类别数大于1alpha可以传一个list每类一个权重这对类别极不平衡的科研场景非常实用。我从实践中得到的建议是换Focal Loss之后先别急着先调参数先把γ固定在2跑50个epoch看曲线如果发现难样本overtrain或者背景类分数仍居高不下再分别动alpha和γ。3.4 分类损失调试心得调试分类损失时我最常做的事情是查看训练日志中每个iteration的cls_loss分布。正常情况下训练初期loss数值比较大是合理的但如果loss掉得异常快——比如第一个epoch就跌到初始值的十分之一——往往意味着绝大多数样本都是简单背景样本模型在“偷懒”。这个时候可以检查一下数据增广是否合适、采样器是否真的让模型见到了足够多的前景样本、以及负样本的loss_weight是否被某种方式降低。另一种常见问题是验证集上某些类别的AP长期为0。从损失角度看有可能这类样本太少少量训练样本的梯度被大量背景样本淹没。解决思路不是盲目增大loss_weight而是先检查这类样本在训练集里的数量占比必要时先做数据层面的事增广、过采样让模型有足够机会“见到”这类目标再考虑用alpha列表给稀有类别更高权重。4. 回归损失从SmoothL1到IoU级联家族4.1 回归分支到底在预测什么delta编码先解决一个很基础但很多人没搞清的问题回归分支的输出并不是绝对坐标而是相对于anchor或proposal的偏移量。以最常见的delta编码为例dx (gt_cx - anchor_cx) / anchor_wdy (gt_cy - anchor_cy) / anchor_hdw log(gt_w / anchor_w)dh log(gt_h / anchor_h)四个目标值里前两项是中心点偏移除以宽高做归一化后两项是宽高比的对数。这样做的原因很直观直接预测绝对坐标的数值范围变化太大网络不好学转成相对偏移后数值大体分布在0附近分布更平稳回归任务更容易收敛。所以回归loss比较的对象是网络输出的四个delta值通常记为delta_pred和按照上述公式算出的真实delta值通常记为delta_target。如果你自己动手写回归分支一定要把编码和loss比较的对象统一否则训练一开始就会出大问题。4.2 为什么回归损失常用SmoothL1而不是MSE或L1从直观感受上说回归误差无非是看预测偏移和真值偏移差多少那用L2MSE、L1或SmoothL1有什么区别L2损失对偏差大的样本惩罚特别重一次离群值就可能把梯度拉飞训练不稳定。L1损失虽然对离群值鲁棒但梯度恒定是±1到了误差很小的阶段依然维持同样步长不容易精修。SmoothL1结合了两者误差绝对值小于某个阈值beta时使用二次函数梯度随误差变小而减小适合精细收敛大于阈值时保持线性梯度不会因大误差爆炸。SmoothL1的完整定义是分段函数这里的beta就是分段阈值默认1.0。在实际使用中SmoothL1是两阶段检测器的“万金油”大多数情况都不需要调beta。如果你觉得定位精度不够差的往往不是回归loss的形式而是前面anchor匹配和样本采样的问题。4.3 IoU Loss家族GIoU、DIoU、CIoU到底提升了什么SmoothL1优化的是四个delta值的绝对误差而IoU系列损失的核心思想变成了直接优化预测框和真值框的重叠程度。听起来更合理但各有各的问题。损失核心公式解决的核心问题IoU Loss1 - IoU把优化目标和评估指标直接统一GIoU Loss1 - IoU (C - A∪B)/C当两框不重叠时梯度过小导致优化停滞DIoU Loss1 - IoU 中心点距离² / 外包框对角线²两框中心点距离没有被直接建模CIoU LossDIoU基础上再加长宽比一致性惩罚预测框和真值框的长宽比不一致时缺少约束逐个看动机就能明白这个家族的演进逻辑IoU这个指标和检测的AP直接相关但它有个麻烦——IoU为0时梯度也为0框完全不重叠时模型学不到任何东西。GIoU引入最小外接矩形C目的就是不重叠时也能“有梯可学”。但GIoU对两框中心点距离不敏感可能出现“预测框完全包含gt框”时loss几乎不变的情况。DIoU直接惩罚中心点偏差往前走了一大步。CIoU再补上长宽比的约束是目前比较常用的回归损失。在mmdetection里切换回归损失方法很简单比如loss_bboxdict(typeGIoULoss, loss_weight2.0)把loss_weight适当调大是比较常见的操作因为IoU相关损失的量级通常比SmoothL1小需要更大权重才能对总loss产生足够影响。实际我自己的实验体会是两阶段模型默认SmoothL1已经很稳强行换GIoU提升未必明显反倒是anchor-free或者一阶段模型在框回归质量和收敛稳定性上从SmoothL1切到CIoU往往能看到更直接的变化。4.4 回归损失选择建议如果刚开始跑实验我的建议是别在回归损失上过度纠结。先把模型跑通看loss收敛情况再在验证集上确认瓶颈。如果你确认回归精度是主要瓶颈优先从GIoU或CIoU开始试如果你的数据里小目标多、框的尺寸波动大那先检查回归目标的编码是否合理再考虑换损失。很多时候损失函数只是压死骆驼的最后一根稻草前面anchor匹配、样本采样、学习率策略才是地基。5. 实操手写一个自定义损失函数并接入mmdetection5.1 场景设定给分类分支加一个标签平滑损失看再多源码也不如自己动手写一个。这里我以“标签平滑交叉熵损失”Label Smoothing CrossEntropy为例演示如何在mmdetection里从零注册一个自定义损失并在config里把它接进训练流程。为什么要做标签平滑分类分支在训练时对置信度极高的预测给出极低loss容易导致模型过度自信泛化能力下降。标签平滑把“非正确类别的目标概率”从0改成一个很小的均匀值ε/(C-1)逼着模型不要对某个类别“赌得太大”。这是很多现代分类网络训练中的标配技巧检测任务里偶尔也会有用。5.2 完整实现与注册新建一个文件比如mmdet/models/losses/label_smooth_ce_loss.py内容如下import torch import torch.nn as nn import torch.nn.functional as F from mmdet.models import LOSSES LOSSES.register_module() class LabelSmoothCrossEntropyLoss(nn.Module): def __init__(self, epsilon0.1, reductionmean, loss_weight1.0): super().__init__() self.epsilon epsilon self.reduction reduction self.loss_weight loss_weight def forward(self, cls_score, label, weightNone, avg_factorNone): 自定义标签平滑交叉熵。 cls_score: [N, C]分类头的原始输出 label: [N]每个样本的类别id num_classes cls_score.size(1) # 构造平滑后的标签分布 one_hot torch.zeros_like(cls_score).scatter_( 1, label.unsqueeze(1), 1.0) smooth_label one_hot * (1 - self.epsilon) \ (1 - one_hot) * self.epsilon / (num_classes - 1) # 按类计算交叉熵 log_probs F.log_softmax(cls_score, dim1) loss -(smooth_label * log_probs).sum(dim1) if self.reduction mean: loss loss.mean() elif self.reduction sum: loss loss.sum() return self.loss_weight * loss这段代码本身不难但要注意几个设计选择第一我用的是softmax版本的交叉熵所以这个自定义损失适合use_sigmoidFalse的场景或者你自己沿用类似的互斥多分类假设第二训练时背景类通常是类别0也会被平滑这可能改变网络对背景置信度的分布导致和原有检测逻辑不完全一致——这是一个真实的科研坑改之前要有预期第三这个实现没有使用mmdetection标准的weighted_loss装饰器虽然能用但缺失了avg_factor在多卡场景下的统一处理实际科研中长期使用的话建议补上装饰器保持和内置loss行为一致。改完文件后在mmdet/models/losses/__init__.py里加入一行导入确保模块被加载。5.3 修改配置文件启用自定义损失假设我们在一个一阶段检测模型的config基础上做实验。只需要在head的配置位置替换对应的loss_clsmodel dict( typeRetinaNet, ... bbox_headdict( ... loss_clsdict( typeLabelSmoothCrossEntropyLoss, epsilon0.1, loss_weight1.0 ), loss_bboxdict(typeSmoothL1Loss, beta1.0, loss_weight1.0), ) )然后正常启动训练。如果你看到日志里出现loss_cls且数值没有报错说明自定义loss已经成功接入。5.4 接入后的验证思路每次自定义loss我强烈建议先在极小数据集上跑几个迭代目标是验证两个东西loss能正常反传且数值稳定训练能正常收敛。具体操作办法用一个只有几百张图片的子集把max_epochs改成1、save_epoch_intervals改成1跑完看loss曲线和验证集AP是否随训练下降。如果前几次迭代loss就出现NaN优先检查自己的forward计算里有没有log(0)、除零或者输入张量里出现inf的情况。6. 损失函数相关的常见问题与排错实录6.1 loss报NaN先别慌按层排查训练到一半loss突然变成NaN或者第一个iteration就是NaN最可能的原因按概率排序大致是学习率过大、数据里有异常gt框宽度或高度为0、损失函数内部出现log(0)、某个位置的特征数值溢出。排查建议从最简单的开始先降低学习率到原来的十分之一跑一个最短iteration看是否复现然后用官方自带的config在相同数据上做一次训练如果官方config没问题问题大概率出在你自己的改动里这时候优先检查自定义的loss实现和输入数据的shape如果连官方数据都报NaN再检查数据预处理管线里是否有无效标注。6.2 loss_weight怎么调比较好很多新手一上来就把loss_weight改得很大希望让某个loss主导训练结果反而把模型训崩。我的经验是loss_weight的作用用一句话总结平衡不同loss在总梯度中的占比而不是单纯代表“哪个任务更重要”。调节的基本原则是观察梯度量级。可以在训练脚本里插入一个钩子打印每个head的梯度范数哪个范数明显小于其他任务就适当增大对应loss的weight。没有工具时退而求其次也可以看总loss里各项占比如果一个loss占总loss的99%它几乎垄断了梯度方向其他任务等于白训。有人会直接照搬论文里的loss_weight但不同框架对loss归一化的处理方式不同mmdetection默认做mean归一化数值尺度相对稳定。绝大多数情况下保持loss_weight1.0起步训练稳定后再微调是更稳妥的路线。6.3 训练正常但某些类别AP很低从损失函数的角度这类问题本质上还是“模型对某些类别学不到有效信息”。三个常见对策供参考检查采样器有些采样策略默认按照gt框数量分配样本稀有类别可能连一个正样本都拿不到此时问题就不在损失函数而在样本分配。考虑类别级权重如果确认采样没问题可以在Focal Loss的alpha参数上对稀有类别加权重给它们更大的梯度贡献。观察loss曲线如果稀有类别的分类loss在训练前期快速下降后停滞往往是模型把该类当成了背景的“近似类”需要在数据或标签上下功夫损失函数只能缓解不能根治。6.4 实用调试工具自定义hook打印梯度最后分享一个很实用的调试技巧。在mmdetection的config里可以通过custom_hooks挂一个简单的梯度统计hook帮自己判断每个模块是否真的在更新custom_hooks [ dict( typeGradStatHook, interval50, target_module_names[bbox_head] ) ]GradStatHook的具体实现可以非常简单在每个iteration结束时遍历指定模块的参数统计梯度范数并打印。比如连续很多个iteration里RPN的梯度都是0那问题一定出在RPN的loss没有正确接到backward链路上或者是样本分配阶段一个正样本都没采到。这类排查工具的价值远比反复猜config参数要高。最后再分享一个我个人总结的经验不要在拿到新数据集之后第一件事就折腾损失函数。先把默认配置跑通确认loss曲线稳步下降、验证集AP有正常起色这个基线才是你有资格讨论“换什么损失更好”的起点。损失函数是模型训练的引擎但不是唯一的可直接修改零件。我在用mmdetection做实验时最常犯的错误之一就是“为了换而换”——把SmoothL1换成GIoU之后AP没变化甚至下降花了一周时间回头查才发现瓶颈根本不在回归损失而在样本采样策略。所以这篇笔记写了这么多损失函数的知识其实最想传达的还是那句老话先理解再修改先确认瓶颈再动手优化。希望能帮你少走一点弯路。