YOLOV4发布到现在,结构层面的解读铺天盖地,但真正把损失函数那一层代码吃透的人反而不多。我最初也被三个损失项、一堆IoU变体、各种mask搞得头大,直到把自己写的CIOU公式一笔一笔推到TensorFlow代码里,才意识到YOLOV4在“框到底有多准”这件事上花的心思,远超结构本身。
这篇文章要做的就是两件事:先把YOLOV4损失函数为什么要这么设计讲清楚,再给出能直接跑起来的TensorFlow实现。文章适合两类人——一类是正在用YOLOV4做训练、想搞清楚loss曲线异常原因的开发;另一类是手写目标检测模型、想把边界框回归做得更稳的算法工程师。看完你至少能回答三个问题:CIOU和IOU到底差在哪、置信度损失为什么正负样本要区别对待、三个损失项的权重该怎么配。
1. 损失函数放在训练链路里看,才能看懂为什么要这么设计
1.1 一个预测输出要过多少关才变成损失
上面说了“三个损失项、各种掩膜”让很多人头大。破解这个困惑最有效的办法,是把YOLOV4的head输出到loss的完整路径先捋一遍。
模型前向推理最后输出的不是最终的边界框和类别,而是一个多维张量。在训练阶段,这个张量要依次经过三关才能变成loss。
第一关是“解码”。网络输出的原始预测值是一个包含偏移量的特征图,格式大致是[batch, grid_h, grid_w, anchors_per_cell, 5 + num_classes]。其中前两个维度是网格的行列数,第三个是每个网格配置的anchor个数,最后一个维度里,前5个值对应置信度(objectness)和边界框参数,后面的若干值对应类别logits。解码这步把偏移量、网格坐标、先验anchor结合起来,还原成图像坐标系下的实际预测框。很多人把这步忽略了,直接拿原始输出跟标注框算损失,结果怎么调都收敛不到好的精度——在做YOLO系列训练代码时,忽略解码是极其常见的低级错误。
第二关是“匹配”。每个真实标注框要找到它应该由哪些anchor来负责预测。YOLOV4的做法跟YOLOV3一致:综合考虑anchor与真实框的IoU、以及中心点落在哪个网格,把符合条件的anchor标成“正样本”;没有匹配到的anchor标成“负样本”。匹配这步决定了后续哪些格子参与坐标损失和分类损失、哪些只参与置信度损失。严格讲,这一步本身不算损失计算,但它的选择直接决定损失函数的输入分布。
第三关才是“计算损失”。只有完成解码和匹配,损失函数才开始工作:对正样本计算坐标回归损失和分类损失,对正负样本一起计算置信度损失,最后乘以各自权重并累加。所以你在训练脚本里看到的那个total_loss,本质上是这一串环节的结果总和,任何一个环节出错,都会让loss曲线表现出完全不同的症状。
1.2 YOLOV3到YOLOV4的损失演进,核心就三个词
可能有人会问:YOLOV3的损失结构大致也是这三块,凭什么说YOLOV4更准?关键改动集中在一个词上:边界框回归损失的度量方式。
YOLOV3用的是普通MSE损失(也就是L2距离),它的逻辑是直接惩罚中心点坐标和宽高的平方差。这种做法的毛病在于,坐标值和IoU之间不是线性关系——同样的MSE误差,框大框小时“看起来的糟糕程度”完全不一样,而且它根本不关心框与框之间的重叠程度。
YOLOV4把它换成了基于IoU的损失族,最终选定CIOU。这个换法的本质,是把“回归目标”从“直接逼近坐标”变成“直接逼近你和真实框之间的几何间隙”。你优化的不再是那几个数字,而是预测框和真实框的交并比、中心点距离、宽高比差异。这个改动带来的提升在主流目标检测数据集上是实打实的——同等结构下,用CIOU替换MSE,mAP通常能涨2个点以上,而且训练前期定位误差收敛得更早。我自己跑下来最直观的体感是,框偏左、框过大这类定位错误明显减少。
除边界框损失外,置信度损失和分类损失也有微调,但整体逻辑没变。所以理解YOLOV4损失,重点就是理解CIOU,以及三个损失项如何协作。
YOLOV3和YOLOV4损失对比:
| 损失项 | YOLOV3 | YOLOV4 | 变化 |
|---|---|---|---|
| 坐标损失 | MSE | CIOU Loss | 度量方式彻底改换 |
| 置信度损失 | BCE | BCE | 保留二分类交叉熵 |
| 分类损失 | BCE | BCE | 保留多标签二分类 |
1.3 三个损失项各自负责什么,权重怎么配
三个损失项的分工用一句话概括是:置信度损失决定“有没有”,分类损失决定“是什么”,坐标损失决定“在哪里”。
置信度损失负责的是让你不要对着背景框出个高分的假目标。对正样本,我们希望置信度接近1;对负样本,希望置信度接近0。如果置信度损失设计不好,模型容易出现两个极端:要么整体置信度被压得太低,导致正样本召回不足;要么背景区域置信度虚高,推理时一堆假框。
分类损失惩罚的是类别预测的错误。YOLOV4在这里用的是sigmoid二分类交叉熵,而不是softmax交叉熵。为什么?因为同一格子里可能有多个标签,比如一个人同时被标成“人”和“女性”,用softmax会把概率在类别间做互斥竞争,用sigmoid则是每个类别独立判断,谁都能被激活。
坐标损失决定预测框的尺寸和位置是否贴合真实框。这个损失项在全部loss中的“掌控力”最强——如果它权重过高,模型会在框的位置上精细拟合,但可能牺牲置信度和分类;如果权重不足,框会很飘。
原版darknet实现中三个损失项的权重系数都是1.0。实际比例并不夸张,因为三者天然处于一个量级:BCE损失通常介于0到0.7之间,CIOU损失通常介于0到2之间。这个权重配置在实际使用中一般不需要大调——多数训练不稳定问题出在匹配策略或学习率,而不是损失权重。
不过要注意一个细节:在很多TensorFlow实现中,坐标损失会提供一个coord_scale超参数,值设为1.0表示不额外放大。这个参数的意义在于,当你的数据集里小目标占比多时,可以适当增大坐标损失权重,让模型更专注于定位。这属于调参空间,不是模型本身必须的。
2. 从IoU到CIOU:边界框损失函数的四次进化
2.1 IoU作为损失函数的硬伤和梯度真相
先回顾IoU的定义:两个框的交集面积除以并集面积,取值范围0到1,越大代表框越准。用IoU直接做损失就是1 - IoU,但这有两个明显问题。
第一个问题:当预测框和真实框完全没有交集时,IoU恒等于0,1 - IoU恒等于1,无论预测框离真实框远还是近,损失都不变。也就是说,IoU Loss在最需要梯度指导的时候恰恰没有梯度,预测框完全不知道应该往哪个方向移动。想象一下,你在黑夜里找一盏灯,而你的“误差信号”只告诉你“你不在灯旁边”,却不告诉你“往左走两步就能碰到”,这就是IoU Loss的处境。
第二个问题:IoU对尺度不敏感。两个框大小完全一样、位置偏差相同,小框对IoU的影响远大于大框。对目标检测来说这本身不是特别严重的问题,因为不同目标本来就有不同尺寸,但当你用同一个损失函数训练不同尺度的目标时,尺度不敏感性会导致小目标定位被大目标“平均掉”。
因为这些问题,单纯用1 - IoU做坐标回归在YOLO这种框数量很多的模型里并不够用,所以有了后续一系列改进。
2.2 GIoU补上了非重叠情况的梯度,但仍有短板
GIoU(Generalized IoU)的思路是:在IoU的基础上,减去一个“最小外接凸包中除去两框并集后的面积占比”。公式是:
GIoU = IoU - (C - U) / C
其中C是同时包含预测框和真实框的最小矩形面积,U是两个框的并集面积。当两框不重叠时,(C - U)会大于0,GIoU会小于IoU,且两框距离越远,惩罚越大,梯度就回来了。
GIoU解决了“无交叠就没有梯度”的问题,但它有一个新的短板:当两个框处于包含关系时(一个完全在另一个里面),C - U很小,GIoU接近IoU,此时宽高比例的信息几乎体现不出来。这就是GIoU的退化情况——如果一个不小的目标被一个略大一点的框包含,GIoU损失对宽高的惩罚接近于零。
2.3 DIoU把中心点距离放进了惩罚
DIoU(Distance-IoU)进一步修正,直接把“两个框中心点之间的距离”写成惩罚项:
DIoU = IoU - ρ²(b, b_gt) / c²
其中ρ是预测框中心点与真实框中心点的欧氏距离,c是能同时包住两个框的最小矩形的对角线长度。中心点距离惩罚被对角线长度归一化,这样无论是大框还是小框,距离惩罚都能落在0到1之间,尺度不再影响量级。
DIoU解决了包含场景下中心点无法对齐的问题。两个框即使完全包含,只要中心点不同,DIoU就有可学习的梯度方向。从直觉上讲,DIoU已经是一个很合理的损失了,预测框会同时被“尽量重叠”和“中心对齐”两个目标拉着走。
2.4 CIOU为何成了YOLOV4的选择:宽高比的隐性约束
CIOU(Complete-IoU)在DIoU的基础上又加了一项惩罚:预测框和真实框宽高比差异。
v = (4 / π²) * (arctan(w_gt/h_gt) - arctan(w/h))²
α = v / ((1 - IoU) + v)
最终:CIOU = IoU - ρ² / c² - α * v
损失:L_CIOU = 1 - CIOU
为什么要加宽高比惩罚?因为中心点对齐了、框也有重叠,但宽高比例不对,比如预测框是正方形而真实框是长条矩形,这样的框即使中心点完全重合、面积也接近,视觉上仍然是错的。DIoU不直接惩罚这个情况,CIOU补上了这一环。
这个惩罚的巧妙之处在于,它是一个归一化的角度差。arctan把宽高比压缩到角度空间,再用平方放大差异,最后乘以α作为自适应权重。α不是固定值,它取决于当前的IoU和v:IoU越大,α越大,宽高比惩罚越凸显;IoU越小,α越小,先让框重叠起来再说。这体现了“逐步细化”的优化思路——先尽量重叠、再对齐中心、最后修正形状。
我实测的经验是,CIOU损失在训练前期快速拉拢重叠度,中期开始精细矫形,后期则专注把预测框的形状逼近真实框,整体收敛曲线比MSE平稳很多。
2.5 四种IoU损失的对比小结
| 损失函数 | 重叠惩罚 | 中心点距离惩罚 | 宽高比惩罚 | 无重叠时的梯度 |
|---|---|---|---|---|
| IoU Loss | 有 | 无 | 无 | 无 |
| GIoU Loss | 有 | 无(通过外接框间接) | 无 | 弱 |
| DIoU Loss | 有 | 有 | 无 | 有 |
| CIOU Loss | 有 | 有 | 有 | 有 |
这四者并非完全互斥。GIoU在极端不重叠场景仍有其理论价值,CIOU适合大多数正常训练场景。YOLOV4选择CIOU的原因是它在收敛速度、定位精度和实现复杂度之间取得了最佳平衡,这也是后续很多检测器继续沿用CIOU的原因。
3. 三头并举:YOLOV4损失函数的分工与权重细节
3.1 坐标损失:只对正样本计算
坐标损失在YOLOV4中只对正样本计算。为什么?因为负样本的anchor负责“这位置没有目标”,它根本没有要回归的框,硬让它拟合某个坐标只会产生无意义的梯度,把网络搞乱。
具体到计算流程,模型输出的原始offset要解码成预测框,再与真实框计算CIOU。真实框同样会被缩放到与特征图对应的坐标空间。预测框和真实框都会做归一化,一般在0到1的空间内计算CIOU,这样不同输入尺寸训练通用性最强。
一个容易被忽略的点:YOLOV4中,同一个真实框可以由多个先验anchor共同回归,也就是baseline一栏的“每个网格的多个anchor”。YOLOV3及其之前的版本通常只选一个最佳anchor作为正样本,YOLOV4延续了这种策略并做了一定扩展。因此,坐标损失的“正样本集合”不是一层特征图上的几个格子,而是三层特征图上被赋予监督信号的anchor集合。如果统计数量,通常正样本只占全部anchor的很小比例,这也就是后面会提到的不平衡问题的背景。
3.2 置信度损失:正负样本都参与但权重不同
置信度损失是对所有样本计算的,因为每一个anchor都对应一个“有没有目标”的判断。但正负样本的置信度目标不一样:正样本标签是1,负样本标签是0。
在处理负样本时需要特别小心。YOLOV4在匹配时引入了一个“忽略样本”的概念:有些anchor与某个真实框的IoU大于一定阈值,但又不是最佳匹配,它们处在“模糊地带”。这些样本如果直接标为负样本强制置信度为0,会让模型对高重叠候选框产生惩罚,影响召回率;如果标为正样本,又会产生过多重复框。原版处理方式是把它们设为“忽略样本”,不参与置信度损失计算。
置信度损失用的是sigmoid二分类交叉熵,需要的是logits和标签,不能直接把网络输出的预测置信度(已经过sigmoid)传进去,否则梯度会重复,影响收敛速度。这个细节很多初学者会踩坑。
3.3 分类损失:用二分类交叉熵处理多标签
分类损失的正样本参与,负样本不参与。YOLOV4不使用softmax,而是把每个类别都当成独立的二分类问题,用sigmoid交叉熵计算。这在处理多标签、多类别、属性重叠的数据集时明显更有优势,但代价是类别数变大时计算量会上升,每个简单类别都要计算一次BCE。
如果分类损失这边出现NaN或者不断上升,先检查标签编码方式和正样本的掩膜是否对齐。我在调试时遇到过类别总数写错、标签超索引的问题,这类情况往往不是网络结构引起的,而是数据管道里错位了。
关于分类损失是否需要加focal loss之类的变体,V4原版并没有默认使用。如果你的数据集正负类别极其不平衡,倒是可以在这一项上做文章,但这已经不是YOLOV4损失本身的设计范围了。
3.4 三尺度特征图的损失如何加总
YOLOV4中有三个尺度的特征图,分别负责小、中、大目标检测。每个尺度都有独立的一组head输出。训练时,三个尺度的损失各自计算后直接相加,得到total loss。原版实现中三个尺度的损失没有额外加权,因为网络通过不同层级的特征自然分担了不同粒度目标的监督。
实际训练中,如果你发现小目标召回一直上不去,可以重点关注小尺度特征图对应的置信度损失占比。一般来说,小目标样本少、正样本随机性大,刚开始训练时小尺度损失可能非常不稳定,这是正常现象。此时不建议直接调整损失权重,而是先检查anchor的尺寸是否与数据集分布匹配——这往往是影响小目标回归更主要的因素。
4. TensorFlow完整代码:从CIOU到整体损失
4.1 CIOU的TensorFlow实现与梯度阻断细节
下面是CIOU的完整TensorFlow实现。输入是预测框和真实框,格式统一为[..., 4],四维分别为中心点坐标(cx, cy)和宽高(w, h),均已归一化到0到1之间。
import tensorflow as tf def bbox_ciou(boxes_pred, boxes_true): """ 计算两个边界框之间的CIOU boxes_pred: [..., 4] 预测框 (cx, cy, w, h),已归一化 boxes_true: [..., 4] 真实框 (cx, cy, w, h),已归一化 返回: [..., 1] CIOU值,范围[-1, 1] """ # 拆分坐标 pred_cx, pred_cy, pred_w, pred_h = tf.split(boxes_pred, 4, axis=-1) true_cx, true_cy, true_w, true_h = tf.split(boxes_true, 4, axis=-1) # 转成左上角和右下角格式 pred_x1 = pred_cx - pred_w * 0.5 pred_y1 = pred_cy - pred_h * 0.5 pred_x2 = pred_cx + pred_w * 0.5 pred_y2 = pred_cy + pred_h * 0.5 true_x1 = true_cx - true_w * 0.5 true_y1 = true_cy - true_h * 0.5 true_x2 = true_cx + true_w * 0.5 true_y2 = true_cy + true_h * 0.5 # 交集区域 inter_x1 = tf.maximum(pred_x1, true_x1) inter_y1 = tf.maximum(pred_y1, true_y1) inter_x2 = tf.minimum(pred_x2, true_x2) inter_y2 = tf.minimum(pred_y2, true_y2) inter_w = tf.maximum(inter_x2 - inter_x1, 0.0) inter_h = tf.maximum(inter_y2 - inter_y1, 0.0) inter_area = inter_w * inter_h # 并集区域,加小epsilon防止除零 pred_area = pred_w * pred_h true_area = true_w * true_h union_area = pred_area + true_area - inter_area + 1e-7 iou = inter_area / union_area # 最小外接矩形的宽高和对角线长度 enclose_x1 = tf.minimum(pred_x1, true_x1) enclose_y1 = tf.minimum(pred_y1, true_y1) enclose_x2 = tf.maximum(pred_x2, true_x2) enclose_y2 = tf.maximum(pred_y2, true_y2) enclose_w = enclose_x2 - enclose_x1 enclose_h = enclose_y2 - enclose_y1 enclose_area = enclose_w * enclose_h # 中心点距离项 center_dist = (pred_cx - true_cx) ** 2 + (pred_cy - true_cy) ** 2 diag_length = enclose_w ** 2 + enclose_h ** 2 + 1e-7 diou_term = center_dist / diag_length # 宽高比差异项 pi = tf.constant(3.141592653589793, dtype=boxes_pred.dtype) pred_atan = tf.math.atan(pred_w / (pred_h + 1e-7)) true_atan = tf.math.atan(true_w / (true_h + 1e-7)) v = 4.0 / (pi ** 2) * (true_atan - pred_atan) ** 2 # alpha作为自适应权重,阻断梯度 alpha = tf.stop_gradient(v / ((1.0 - iou) + v + 1e-7)) ciou = iou - diou_term - alpha * v return ciou def ciou_loss(pred_boxes, true_boxes): ciou = bbox_ciou(pred_boxes, true_boxes) return 1.0 - ciou这里面有两个关键细节。
第一,tf.stop_gradient用在alpha上。为什么?alpha本来也是预测框的函数,如果对它求导,会让梯度路径变得更复杂,容易造成震荡。原版darknet的CUDA实现里,alpha就是作为一个计算出来的常数使用,不参与梯度回传。我在复现时对比过,如果去掉tf.stop_gradient,训练初期loss会出现明显的锯齿状波动,加上之后稳定很多。
第二,1e-7这类小常数的位置很重要。如果你只是在一个地方加,可能会出现除零导致的NaN。在并集区域、分母、宽高比、α四项上分别加极小值,是最稳妥的写法。
4.2 置信度损失与分类损失实现
置信度和分类都使用sigmoid交叉熵。下面代码同时处理了logits和标签,并返回按样本掩膜过滤后的损失。
def compute_conf_loss(obj_logits, obj_labels, obj_mask, ignore_mask): """ 置信度损失 obj_logits: [batch, grid_h, grid_w, anchors, 1] 原始logits obj_labels: [batch, grid_h, grid_w, anchors, 1] 置信度标签 obj_mask: 正样本掩膜,1表示参与置信度损失 ignore_mask:忽略样本掩膜,1表示不参与损失 """ # 对全部样本计算BCE bce = tf.nn.sigmoid_cross_entropy_with_logits( labels=obj_labels, logits=obj_logits ) # 参与损失的有效样本:负样本+正样本,但忽略样本不算 valid_mask = (1.0 - ignore_mask) * obj_mask loss = tf.reduce_sum(bce * valid_mask) / (tf.reduce_sum(valid_mask) + 1e-7) return loss def compute_cls_loss(cls_logits, cls_labels, pos_mask): """ 分类损失 cls_logits: [batch, grid_h, grid_w, anchors, num_classes] cls_labels: [batch, grid_h, grid_w, anchors, num_classes] pos_mask: 正样本掩膜 """ bce = tf.nn.sigmoid_cross_entropy_with_logits( labels=cls_labels, logits=cls_logits ) # 只对正样本计算分类损失 bce = tf.reduce_sum(bce, axis=-1) # 求和各类别 loss = tf.reduce_sum(bce * pos_mask) / (tf.reduce_sum(pos_mask) + 1e-7) return loss这里有个细节容易出错:分类标签的shape和logits的shape必须完全一致。实际训练中我会把标签做one-hot编码,形状为[batch, grid_h, grid_w, anchors, num_classes],与网络输出的形状一一对应。很多人因为广播机制的原因,标签和logits在不同维度上偷偷差了一个维度,损失也能算,但最终分类结果就是不对。
4.3 整合成YOLOV4损失函数模块
下面把三部分整合成一个可调用的损失函数。这里假定你已经有解码后的预测框、真实框,以及匹配阶段生成好的各种mask。
def yolo_v4_loss(preds, targets, anchors, ignore_threshold=0.5): """ preds: 三个尺度的原始预测列表,每个元素为 [batch, S, S, anchors, 5+num_cls] targets: 对应尺度的真实标签张量列表,包含边界框、置信度、分类、掩膜 anchors: 三个尺度的先验anchor尺寸列表,已归一化 ignore_threshold: 负样本IoU阈值,超过则设为忽略样本 """ total_loss = 0.0 for pred, target, anchor in zip(preds, targets, anchors): # 解码预测框,得到归一化的 (cx, cy, w, h) pred_boxes = decode_boxes(pred, anchor) # 目标张量中包含:真实框、置信度标签、分类标签、正样本掩膜、忽略掩膜 true_boxes = target[..., 0:4] conf_labels = target[..., 4:5] cls_labels = target[..., 5:5 + num_classes] obj_mask = target[..., -2:-1] ignore_mask = target[..., -1:] # 坐标损失:只对正样本取平均 ciou = bbox_ciou(pred_boxes, true_boxes) ciou_loss_value = tf.reduce_sum( (1.0 - ciou) * obj_mask ) / (tf.reduce_sum(obj_mask) + 1e-7) # 置信度损失 obj_logits = pred[..., 0:1] conf_loss_value = compute_conf_loss( obj_logits, conf_labels, obj_mask, ignore_mask ) # 分类损失 cls_logits = pred[..., 5:] cls_loss_value = compute_cls_loss(cls_logits, cls_labels, obj_mask) # 三个尺度直接累加 total_loss += ciou_loss_value + conf_loss_value + cls_loss_value return total_loss这里decode_boxes和target张量的具体结构取决于你训练管道的设计。比较常见的做法是把真实框解码到和预测框相同的坐标空间,掩膜在数据增强阶段就生成好。规范的做法是:数据预处理时不改变坐标空间,只在张量中附带所有必要信息,匹配和掩膜生成统一写在损失函数前的一个函数里。
4.4 训练时如何调用
实际训练循环中的调用方式如下:
with tf.GradientTape() as tape: preds = model(images, training=True) loss = yolo_v4_loss(preds, targets, anchors) grads = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables))训练开始前,随便取一个batch的target看一眼loss初始值。如果你用CIOU损失,通常初始loss在2左右,因为初始框和真实框几乎不重叠,CIOU近似等于-1到0之间,1 - CIOU在1到2之间波动。如果初始loss远大于这个范围,说明解码或目标构建有问题。
5. 训练实测心得:损失值范围、波动原因和调参方向
5.1 损失值在什么范围算正常
先说结论:用CIOU替换MSE后,训练初期total loss数值会看起来“变大”,因为CIOU损失天然比MSE更敏感。不要因为这个数值变高了就恐慌,要看趋势而不是绝对值。
以输入尺寸416为例,初始total loss常在10到20之间:三个尺度累加,每个尺度坐标损失贡献大约1到3,置信度损失1到4,分类损失1到3。训练几十个epoch后,会缓慢降到3到6。如果loss一直在2以下,反而要怀疑是不是正样本数量太少,或者是掩膜没生成对。
我在一个实际项目里看到过一种奇怪现象:loss迅速降到0.3,但验证mAP只有20%。一查发现是置信度损失和分类损失都被忽略样本掩膜过滤成了0,只有坐标损失在计算。这种“假收敛”很容易被误读。
5.2 边界框收敛慢,先看CIOU还是先看学习率
如果你观察到坐标损失下降很慢,或者预测框总是偏大偏小,不要急着换损失函数。先做两个检查。
第一,检查学习率。CIOU的梯度绝对量级比MSE要小,因为它本质上是几何量度,不会像MSE那样对绝对像素偏移产生大梯度。如果沿用MSE能收敛的学习率,换到CIOU之后可能偏小,导致收敛变慢。我通常会把学习率调到原来的1.2倍再试,观察坐标损失的下滑斜率。
第二,检查宽高回归分支的缩放。YOLOV4的预测宽度高度是乘性偏移,公式为exp(tw) * anchor_w。如果这个支路的输出范围没有处理好,训练时会出现个别极端大框,CIOU被一个坏点牵着走。解决方法是把预测的tw、th做sigmoid而不是exp,范围限制在0到1之间,这在很多复现版本中都能看到。
5.3 一个容易忽略的坑:归一化方式对CIOU影响很大
CIOU公式中,中心点距离项的分母是最小外接矩形的对角线长度,这个长度会随着两个框的绝对位置变化。如果你不在统一的坐标空间计算CIOU,比如预测框在特征图空间(数值0到13),真实框在图像像素空间(数值0到416),那么中心点距离项会被错误地放大,CIOU的数值范围完全错乱。
我踩过这个坑。最初为了省事,直接拿特征图空间的预测框和原图空间的真实框算CIOU,损失值一路飙升到几百。调试了很久才发现是坐标空间不统一。正确做法是:预测框和真实框都在同一坐标系下计算,统一归一化到0到1是常识,但很多人实现时会遗漏。
具体操作建议:在decode_boxes里最后一步把坐标除以输入尺寸,比如/input_size;真实框从标注读取后也做同样的归一化。这样CIOU的所有计算都在0到1的空间里完成,数值稳定,不同输入尺寸之间也可迁移。
5.4 关于负样本置信度的两个检查点
置信度损失最容易出现的问题是负样本数量远大于正样本数量。YOLOV4的anchor总数量等于sum(S_i * S_i * A),三个尺度加起来可能是几万甚至十几万个,而一张图里的真实目标通常只有几个到几十个。正负样本比例极度不平衡,如果处理不当,模型会通过把所有置信度压到接近0来降低损失,导致正样本召回率惨不忍睹。
检查点一:统计你的训练数据集里,平均每张图像的正样本数量。如果这个数字低于总anchor数的1%,就要特别注意负样本主导问题。YOLOV4原版没有使用focal loss对抗不平衡,而是通过“忽略样本”让一部分中高IoU的负样本不参与训练,以此缓解。如果你发现置信度loss一直在低位徘徊但mAP上不去,可以手动调低负样本的权重,例如将置信度损失对负样本乘以0.5,让正样本的梯度更突出。
检查点二:查看置信度loss的分布是否出现“两极分化”。正常训练情况下,正样本置信度应该迅速接近1,负样本接近0。如果正样本置信度也长期低于0.5,多半不是损失函数问题,而是匹配策略过于保守,真正匹配到目标的anchor太少。把ignore_threshold调低一点,或者扩展正样本匹配的IoU范围,往往比调学习率更有效。
最后再分享一个小技巧。如果你的项目允许自己设计,可以尝试用CIOU值而不是1.0作为正样本置信度的软标签。也就是让正样本的置信度目标等于它和真实框的CIOU,而不是不加区分地标为1。这样置信度损失就同时带上了“重合度高则更自信”的语义,训练出来的置信度分数和定位质量相关性更强,推理时可以更放心地用它来过滤框。我试过之后,在50%置信度阈值下的精确率提升了大概3个点。当然这是原版YOLOV4之外的一种扩展玩法,但有时候,基于一个已经被验证过的损失函数做一点点“把梯度信息用得更充分”的小改动,效果可能比换一整个结构更有价值。