新闻详情

新闻详情

首页 / 资讯中心 / 详情

062、v8DetectionLoss 损失计算源码:pred与target 的矩阵维度对齐与 Loss 加和

发布时间:2026/6/9 12:31:29
062、v8DetectionLoss 损失计算源码:pred与target 的矩阵维度对齐与 Loss 加和
062、v8DetectionLoss 损失计算源码pred与target 的矩阵维度对齐与 Loss 加和上周调一个YOLOv8的自定义数据集发现loss死活不降打印出来一看分类loss直接是0。debug了一下午最后发现是pred和target的维度没对齐——分类分支的pred shape是[4, 8400, 80]target的class index却传成了[4, 8400, 1]广播的时候直接吞掉了所有信息。这种坑踩过一次就再也不会忘了。今天就把v8DetectionLoss里最绕的维度对齐逻辑掰开揉碎讲清楚。代码基于ultralytics v8.1.0核心文件在ultralytics/utils/loss.py。损失函数的入口v8DetectionLoss.init先看初始化这里埋了两个关键组件classv8DetectionLoss:def__init__(self,model):self.bcenn.BCEWithLogitsLoss(reductionnone)# 注意是none后面自己加权self.hypmodel.hyp# 超参包含box_loss_gain等self.stridemodel.stride# 下采样倍数[8,16,32]self.ncmodel.model[-1].nc# 类别数self.nomodel.model[-1].no# 每个anchor的输出维度 41ncself.assignerTaskAlignedAssigner()# 正样本分配器这里有个容易忽略的点BCEWithLogitsLoss用了reduction‘none’。别写成默认的’mean’否则后面正负样本加权的时候你会疯掉——每个像素的loss权重不一样必须保留逐元素结果。前向传播pred和target的维度战争def__call__(self,preds,batch):losstorch.zeros(3,deviceself.device)# [box, cls, dfl]featspreds[1]ifisinstance(preds,tuple)elsepreds# 兼容推理模式pred_distri,pred_scorestorch.split(feats,[4*16,self.nc],dim1)# 这里踩过坑等一下这里为什么是416因为YOLOv8把bbox回归换成了DFLDistribution Focal Loss每个坐标用16个bin表示。所以回归分支的输出维度是41664分类分支是nc。如果你改了reg_max这里要同步改。pred_scores的shape[batch, nc, h, w]但后面要变成[batch, num_anchors, nc]。怎么变看下面pred_scorespred_scores.permute(0,2,3,1).contiguous()# [b, h, w, nc]pred_scorespred_scores.view(batch,-1,self.nc)# [b, num_anchors, nc]这里permute之后一定要contiguous()否则view会报错。别问我怎么知道的debug两小时的血泪史。正样本分配TaskAlignedAssigner的维度魔法这是整个loss计算最绕的地方。TaskAlignedAssigner的输入是pred_scores: [b, num_anchors, nc]pred_bboxes: [b, num_anchors, 4] (注意这里是cxcywh格式)gt_labels: [b, max_gt, 1] (每个gt的类别索引)gt_bboxes: [b, max_gt, 4] (归一化的xywh)输出是target_bboxes: [b, num_anchors, 4] (正样本对应的gt bbox)target_scores: [b, num_anchors, nc] (one-hot形式正样本位置为1)fg_mask: [b, num_anchors] (正样本掩码)关键来了target_scores的shape是[b, num_anchors, nc]但gt_labels是[b, max_gt, 1]。怎么对齐# 在assigner内部先做维度扩展# gt_labels: [b, max_gt, 1] - [b, max_gt, nc] one-hot编码target_scorestorch.zeros_like(pred_scores)# [b, num_anchors, nc]# 对每个正样本anchor把对应gt类别的score设为1target_scores[fg_mask]F.one_hot(gt_labels[assigned_gt],self.nc).float()这里fg_mask是[b, num_anchors]的bool矩阵assigned_gt是每个正样本对应的gt索引。别写成target_scores F.one_hot(gt_labels)那样shape直接炸掉。Loss计算三个分支的维度对齐1. 分类Loss# target_scores: [b, num_anchors, nc]# pred_scores: [b, num_anchors, nc]# 直接逐元素计算但只对正样本加权loss_clsself.bce(pred_scores,target_scores)# [b, num_anchors, nc]loss_clsloss_cls*target_scores# 只保留正样本的loss负样本置0loss_clsloss_cls.sum()/(fg_mask.sum()1e-7)# 除以正样本数这里有个trick为什么用target_scores乘而不是fg_mask因为target_scores已经是one-hot了乘完之后负样本位置自动为0。但注意这样写有个隐患——如果某个正样本的类别预测完全错误它的loss会被保留但负样本的loss被完全忽略。YOLOv8的默认做法是只对正样本计算分类loss负样本不参与分类loss计算。2. 回归LossCIoU DFL回归分支的维度对齐更绕。pred_distri的shape是[b, 4*16, h, w]要变成[b, num_anchors, 4, 16]pred_distripred_distri.permute(0,2,3,1).contiguous()# [b, h, w, 64]pred_distripred_distri.view(batch,-1,4,16)# [b, num_anchors, 4, 16]然后通过积分得到预测的bbox坐标# 对16个bin做softmax然后加权求和pred_bboxesself.bbox_decode(pred_distri)# [b, num_anchors, 4] cxcywh格式target_bboxes来自assigner的输出shape是[b, num_anchors, 4]但只有正样本位置有值负样本位置是0。# 计算CIoU loss只对正样本ioubbox_iou(pred_bboxes[fg_mask],target_bboxes[fg_mask],CIoUTrue)loss_iou1.0-iou# [num_pos]loss_iouloss_iou.mean()这里注意bbox_iou的输入必须是两个shape相同的tensor不能直接传[b, num_anchors, 4]和[b, num_anchors, 4]因为负样本的bbox是0计算iou会得到0但loss会变成1导致负样本也参与回归。所以必须用fg_mask过滤。3. DFL LossDFL的输入是pred_distri和target的分布。target的分布怎么得到把gt bbox的坐标映射到16个bin上# target_bboxes是cxcywh格式需要转成ltrb格式target_ltrbbbox_cxcywh_to_ltrb(target_bboxes[fg_mask])# [num_pos, 4]# 每个坐标映射到[0, 15]的区间target_binstarget_ltrb*(self.reg_max-1)# 假设reg_max16# 取整得到左右边界target_lefttarget_bins.floor().long()target_righttarget_bins.ceil().long()然后计算交叉熵# pred_distri[fg_mask]: [num_pos, 4, 16]# 对每个坐标计算左边界和右边界的交叉熵loss_dflF.cross_entropy(pred_distri[fg_mask].view(-1,16),# [num_pos*4, 16]target_left.view(-1),# [num_pos*4]reductionnone)F.cross_entropy(pred_distri[fg_mask].view(-1,16),target_right.view(-1),reductionnone)loss_dflloss_dfl.mean()/2# 左右边界平均这里有个细节cross_entropy的输入是[num_pos4, 16]和[num_pos4]因为每个bbox有4个坐标每个坐标独立计算。别写成[num_pos, 4, 16]和[num_pos, 4]那样维度不匹配。Loss加和权重分配loss_boxloss_iou*self.hyp.box_loss_gain# 默认7.5loss_clsloss_cls*self.hyp.cls_loss_gain# 默认0.5loss_dflloss_dfl*self.hyp.dfl_loss_gain# 默认1.5total_lossloss_boxloss_clsloss_dfl这些超参在hyp.yaml里配置。注意box_loss_gain特别大因为CIoU loss本身数值很小0.1~0.9而分类loss是BCE数值可能到几十。如果不加权回归分支的梯度会被淹没。踩坑经验总结维度检查是第一生产力每次写loss之前先print所有tensor的shape。我习惯在__call__开头加一行print(pred_scores.shape, target_scores.shape)调试完再删掉。fg_mask的维度陷阱fg_mask是[b, num_anchors]的bool矩阵但target_scores是[b, num_anchors, nc]。用fg_mask索引时要确保维度匹配。比如target_scores[fg_mask]会得到[num_pos, nc]而target_bboxes[fg_mask]会得到[num_pos, 4]。别混用。BCEWithLogitsLoss的reduction一定要用’none’因为后面要手动加权。如果用’mean’正负样本的权重就混在一起了。DFL的reg_max一致性模型定义里的reg_max和loss里的reg_max必须一致。如果你改了模型结构记得同步改loss里的self.reg_max。梯度检查如果loss不降先检查梯度是否正常。在backward之前加一行loss.backward(retain_graphTrue)然后print每个参数的grad。如果某个分支的grad全是0说明那个分支的loss没参与计算。数值稳定性在除以fg_mask.sum()时加个1e-7防止batch里没有正样本导致除零。虽然YOLOv8的anchor分配策略保证至少有一个正样本但自定义数据集可能出幺蛾子。最后说一句别迷信默认超参。我调过的一个工业检测项目box_loss_gain从7.5改到15mAP直接涨了3个点。loss的权重分配最终还是要看你的数据分布。
网站建设 高端定制 企业官网