015、损失函数三元结构:分类损失、边界框回归损失、置信度损失的分离与协同
015、损失函数三元结构分类损失、边界框回归损失、置信度损失的分离与协同去年有个项目客户要求检测流水线上不同尺寸的螺丝精度要求mAP0.5达到0.95以上。我调了三天损失函数权重发现分类损失降得飞快但边界框回归损失死活下不去置信度损失反而在震荡。后来拆开每个损失项单独可视化才发现问题出在边界框回归的CIoU损失里——预测框宽高比和真实框差太多时梯度会变得极小几乎不更新。这个坑让我意识到YOLO的损失函数不是简单加和而是三个独立模块在互相拉扯。损失函数的三元结构YOLOv5/v8的损失函数由三部分组成分类损失Classification Loss、边界框回归损失Bounding Box Regression Loss、置信度损失Confidence Loss。它们各自负责不同的任务但共享同一个特征提取网络。# 这是YOLOv5源码中损失计算的简化结构classComputeLoss:def__init__(self,model):self.bcenn.BCEWithLogitsLoss(reductionnone)# 二分类交叉熵别用默认的meanself.hypmodel.hyp# 超参数包含损失权重self.gr1.0# 置信度标签的平滑系数def__call__(self,p,targets):# p: 预测结果 [batch, anchors, grid, grid, (x,y,w,h,obj,cls)]# targets: 真实标签 [num_targets, (image, class, x, y, w, h)]lclstorch.zeros(1,devicedevice)# 分类损失lboxtorch.zeros(1,devicedevice)# 边界框回归损失lobjtorch.zeros(1,devicedevice)# 置信度损失# 这里踩过坑必须把三个损失分开计算不能合并成一个tensor# 否则梯度回传会互相干扰fori,piinenumerate(p):# 遍历三个检测头# 计算每个检测头的损失lcls_i,lbox_i,lobj_iself.build_targets(pi,targets,i)lclslcls_i lboxlbox_i lobjlobj_i# 最终损失 权重 * 各项损失lossself.hyp[box]*lboxself.hyp[cls]*lclsself.hyp[obj]*lobjreturnloss分类损失为什么用BCE而不是CEYOLO的分类损失用的是二分类交叉熵BCEWithLogitsLoss而不是多分类交叉熵CrossEntropyLoss。原因很简单YOLO的每个锚框可以预测多个类别虽然实际场景中一个物体只有一个类别但BCE允许模型输出多个高置信度的类别这在处理重叠物体时更灵活。# 分类损失计算细节defbuild_targets(self,p,targets,img_idx):# 这里别这样写直接对全图计算分类损失# 应该只对正样本有物体的锚框计算tcls,tbox,indices,anchorsself.select_training_samples(targets,img_idx)# indices: 正样本的索引 (batch, anchor, grid_y, grid_x)# 提取预测的分类分数pclsp[...,5:]# 假设前5个是坐标和置信度# 只对正样本计算分类损失# 这里踩过坑负样本的分类损失应该被忽略否则模型会学习“所有类别都不存在”pcls_pospcls[indices]# 只取正样本位置# 创建one-hot标签tcls_onehottorch.zeros_like(pcls_pos)tcls_onehot[range(len(tcls)),tcls]1.0# 计算BCE损失lclsself.bce(pcls_pos,tcls_onehot)lclslcls.sum()/max(len(tcls),1)# 除以正样本数量避免batch大小影响returnlcls关键点分类损失只对正样本计算。负样本背景的分类损失被直接忽略因为背景没有类别。如果对负样本也计算分类损失模型会学到“所有类别概率都很低”导致正样本的分类置信度也被压低。边界框回归损失从IoU到CIoU的进化边界框回归损失经历了从L1/L2损失到IoU系列损失的演变。YOLOv5默认使用CIoU损失它考虑了三个因素重叠面积、中心点距离、宽高比。# CIoU损失计算defbbox_iou(box1,box2,xywhTrue,GIoUFalse,DIoUFalse,CIoUTrue,eps1e-7):# 别这样写直接计算IoU而不考虑梯度稳定性# 这里踩过坑eps必须加在分母上防止除以0ifxywh:# 将xywh格式转换为xyxy格式b1_x1,b1_x2box1[...,0]-box1[...,2]/2,box1[...,0]box1[...,2]/2b1_y1,b1_y2box1[...,1]-box1[...,3]/2,box1[...,1]box1[...,3]/2b2_x1,b2_x2box2[...,0]-box2[...,2]/2,box2[...,0]box2[...,2]/2b2_y1,b2_y2box2[...,1]-box2[...,3]/2,box2[...,1]box2[...,3]/2# 计算交集面积inter(torch.min(b1_x2,b2_x2)-torch.max(b1_x1,b2_x1)).clamp(0)*\(torch.min(b1_y2,b2_y2)-torch.max(b1_y1,b2_y1)).clamp(0)# 计算并集面积w1,h1b1_x2-b1_x1,b1_y2-b1_y1 w2,h2b2_x2-b2_x1,b2_y2-b2_y1 unionw1*h1w2*h2-intereps# IoUiouinter/unionifCIoUorDIoU:# 计算最小外接矩形cwtorch.max(b1_x2,b2_x2)-torch.min(b1_x1,b2_x1)chtorch.max(b1_y2,b2_y2)-torch.min(b1_y1,b2_y1)# 中心点距离的平方c2cw**2ch**2eps# 这里踩过坑c2可能为0必须加epsrho2((b2_x1b2_x2-b1_x1-b1_x2)**2(b2_y1b2_y2-b1_y1-b1_y2)**2)/4ifCIoU:# 宽高比一致性v(4/math.pi**2)*torch.pow(torch.atan(w2/h2)-torch.atan(w1/h1),2)withtorch.no_grad():alphav/(v-iou(1eps))returniou-(rho2/c2v*alpha)elifDIoU:returniou-rho2/c2returniouCIoU损失 1 - CIoU。当预测框和真实框完全重合时CIoU1损失为0。当完全不重叠时CIoU趋近于-1损失趋近于2。这个范围比普通IoU0到1更宽梯度更丰富。但有个问题当预测框和真实框的宽高比差异很大时v的梯度会变得极小。这就是我开头说的那个坑——螺丝检测时预测框的宽高比和真实框差太多CIoU损失几乎不更新。后来我换成了EIoU损失直接优化宽高差异问题就解决了。置信度损失正负样本的平衡艺术置信度损失是最容易被忽视但最关键的损失。它决定了模型是否认为某个位置有物体。YOLO的置信度损失使用BCE但正负样本的权重差异很大。# 置信度损失计算defcompute_obj_loss(self,pi,targets,img_idx):# pi: 某个检测头的预测结果# 提取置信度预测值pobjpi[...,4]# 第5个通道是置信度# 创建置信度标签tobjtorch.zeros_like(pobj)# 默认所有位置都是背景# 对正样本位置设置置信度为1# 这里别这样写直接设置所有正样本为1# 应该使用IoU作为软标签让模型学习更精细的定位质量fori,(b,a,gj,gi)inenumerate(indices):tobj[b,a,gj,gi]1.0# 或者使用预测框与真实框的IoU# 计算BCE损失lobjself.bce(pobj,tobj)# 这里踩过坑正负样本比例严重失衡1:1000以上# 必须对正样本加权否则模型只会预测所有位置都是背景# YOLOv5的做法对正样本的损失乘以一个系数pos_weight10.0# 这个系数需要根据数据集调整lobjlobj*(tobj*pos_weight(1-tobj))returnlobj.sum()/max(len(tobj),1)置信度损失的核心问题是正负样本不平衡。一张图片可能有几十个物体但锚框数量是几万个。如果不做处理模型会倾向于把所有位置都预测为背景置信度为0因为这样损失最小。YOLOv5的解决方案是对正样本的置信度损失乘以一个权重系数默认是10让模型更关注正样本。但这个系数不是固定的需要根据数据集调整。如果物体很小比如螺丝正样本数量更少权重可能需要更大。三个损失的协同权重调参的艺术三个损失不是简单相加它们的权重需要精心调整。YOLOv5的默认权重是box0.05, cls0.5, obj1.0。但别直接照搬这个权重是针对COCO数据集的。# 损失权重调整# 别这样写直接使用默认权重# 应该根据验证集的表现动态调整# 我的经验法则# 1. 如果mAP低但分类准确率高 - 增大box权重# 2. 如果分类错误多 - 增大cls权重# 3. 如果漏检多 - 增大obj权重# 实际调试代码defadjust_loss_weights(self,val_metrics):# val_metrics: 验证集指标ifval_metrics[mAP]0.5andval_metrics[precision]0.8:# 精度高但mAP低说明定位不准self.hyp[box]*1.2print(增大box权重)elifval_metrics[recall]0.6:# 召回率低说明漏检多self.hyp[obj]*1.1print(增大obj权重)这里踩过坑不要同时调整多个权重。一次只调一个观察3-5个epoch的效果再决定下一步。否则你根本不知道是哪个权重起了作用。个人经验性建议可视化每个损失项在训练脚本里加上每个损失的记录用TensorBoard看它们的曲线。如果某个损失震荡剧烈说明权重可能太大如果某个损失下降太慢说明权重可能太小。边界框回归损失优先我个人的经验是先调好box损失再调cls和obj。因为定位不准分类再准也没用。而且box损失对学习率最敏感如果学习率太大box损失会先发散。置信度损失用软标签不要硬性地把正样本的置信度设为1而是用预测框和真实框的IoU作为标签。这样模型不仅能判断“有没有物体”还能判断“定位准不准”。YOLOv8已经默认这么做了。负样本采样如果正负样本比例超过1:10000考虑对负样本进行随机采样只计算一部分负样本的置信度损失。这比单纯加权更有效。损失权重不是固定的训练初期box损失应该大一些让模型先学会定位训练后期cls损失应该大一些让模型精细分类。可以写一个动态调整策略比如前50个epoch box权重是0.05后面变成0.02。最后说一句损失函数调参没有银弹。同样的权重换一个数据集可能就完全失效。多可视化、多实验找到适合你数据的组合。