原文链接:https://zhuanlan.zhihu.com/p/93632171

前言

前面的 YOLOv2 推文详细讲解了 YOLOv2 的算法原理,但官方论文没有像 YOLOv1 那样提供 YOLOv2 的损失函数,难怪 Ng 说 YOLO 是目标检测中最难懂的算法。今天我们尝试结合 DarkNet 的源码来分析 YOLOv2 的损失函数。

关键点回顾

直接位置预测

YOLOv2 借鉴 RPN 网络使用 Anchor boxes 来预测边界框相对于先验框的 offsets。边界框的实际中心位置YOLOv2损失函数详解 - 图1
需要利用预测的坐标偏移值YOLOv2损失函数详解 - 图2
,先验框的尺度YOLOv2损失函数详解 - 图3
以及中心坐标YOLOv2损失函数详解 - 图4
来计算,这里的YOLOv2损失函数详解 - 图5
YOLOv2损失函数详解 - 图6
也即是特征图每个位置的中心点:

YOLOv2损失函数详解 - 图7

上面的公式也是 Faster-RCNN 中预测边界框的方式。但上面的预测方式是没有约束的,预测的边界框容易向任何方向偏移,例如当YOLOv2损失函数详解 - 图8
时边界框将向右偏移 Anchor 的一个宽度大小,导致每个位置预测的边界框可以落在图片的任意位置,这就导致模型训练的不稳定性,在训练的时候要花很长时间才可以得到正确的 offsets。以,YOLOv2 弃用了这种预测方式,而是沿用 YOLOv1 的方法,就是预测边界框中心点相对于对应 cell 左上角位置的相对偏移值,为了将边界框中心点约束在当前 cell 中,使用 sigmoid 函数处理偏移值,这样预测的偏移值在 (0,1) 范围内(每个 cell 的尺度看做 1)。

综上,根据边界框预测的 4 个偏移值YOLOv2损失函数详解 - 图9
,可以使用如下公式来计算边界框实际中心位置和长宽,公式在图中:

YOLOv2损失函数详解 - 图10

其中,YOLOv2损失函数详解 - 图11
为 cell 的左上角坐标。在 Fig3 中,当前的 cell 的左上角坐标为YOLOv2损失函数详解 - 图12
。由于YOLOv2损失函数详解 - 图13
函数的处理,边界框的中心位置会被约束在当前 cell 的内部,防止偏移过多,然后YOLOv2损失函数详解 - 图14
YOLOv2损失函数详解 - 图15
是先验框的宽度与高度,它们的值也是相对于特征图(这里是 13*13,我们把特征图的长宽记作 H,W)大小的,在特征图中的 cell 长宽均为 1。这样我们就可以算出边界框相对于整个特征图的位置和大小了,公式如下: YOLOv2损失函数详解 - 图16
YOLOv2损失函数详解 - 图17
YOLOv2损失函数详解 - 图18
YOLOv2损失函数详解 - 图19
我们如果将上面边界框的 4 个值乘以输入图像长宽,就可以得到边界框在原图中的位置和大小了。

细粒度特征

YOLOv2 提取 Darknet-19 最后一个 max pool 层的输入,得到 26x26x512 的特征图。经过 1x1x64 的卷积以降低特征图的维度,得到 26x26x64 的特征图,然后经过 pass through 层的处理变成 13x13x256 的特征图(抽取原特征图每个 2x2 的局部区域组成新的 channel,即原特征图大小降低 4 倍,channel 增加 4 倍),再与 13x13x1024 大小的特征图连接,变成 13x13x1280 的特征图,最后在这些特征图上做预测。使用 Fine-Grained Features,YOLOv2 的性能提升了 1%。这个过程可以在下面的 YOLOv2 的结构图中看得很清楚:

YOLOv2损失函数详解 - 图20

这个地方今天还要补充一点,那就是 passthrough 层到底是怎么操作的,在 DarkNet 中 passthough 层叫作 reorg_layer,可以用下图来表示这个操作:

YOLOv2损失函数详解 - 图21

训练

上篇推文讲了 YOLOv2 的训练分为三个阶段,具体就不再赘述了。这里主要重新关注一下训练后的维度变化,我们从上一小节可以看到最后 YOLOv2 的输出维度是YOLOv2损失函数详解 - 图22
。这个 125 使用下面的公式来计算的: YOLOv2损失函数详解 - 图23

和训练采用的数据集有关系。由于 anchors 数为 5,对于 VOC 数据集输出的 channels 数就是 125,而对于 COCO 数据集则为 425。这里以 VOC 数据集为例,最终的预测矩阵为YOLOv2损失函数详解 - 图24
,shape 为YOLOv2损失函数详解 - 图25
,可以将其 reshape 成YOLOv2损失函数详解 - 图26
,这样YOLOv2损失函数详解 - 图27
是边界框的位置和大小YOLOv2损失函数详解 - 图28
YOLOv2损失函数详解 - 图29
表示边界框的置信度YOLOv2损失函数详解 - 图30
,而YOLOv2损失函数详解 - 图31
表示类别预测值。

YOLOv2 的模型结构

YOLOv2损失函数详解 - 图32
YOLOv2损失函数详解 - 图33

损失函数

接下来就说一说今天的主题,损失函数。损失函数我看网上的众多讲解,发现有两种解释。

解释 1

YOLOv2 的损失函数和 YOLOv1 一样,对于训练集中的 ground truth,中心落在哪个 cell,那么该 cell 的 5 个 Anchor box 对应的边界框就负责预测它,具体由哪一个预测同样也是根据 IOU 计算后卡阈值来确定的,最后选 IOU 值最大的那个。这也是建立在每个 Cell 至多含有一个目标的情下,实际上也基本不会出现多余 1 个的情况。和 ground truth 匹配上的先验框负责计算坐标误差,置信度误差以及分类误差,而其它 4 个边界框只计算置信度误差。这个解释参考的 YOLOv2 实现是 darkflow. 源码地址为:https://github.com/thtrieu/darkflow

解释 2

在官方提供的 Darknet 中,YOLOv2 的损失函数可以不是和 YOLOv1 一样的,损失函数可以用下图来进行表示:

YOLOv2损失函数详解 - 图34

可以看到这个损失函数是相当复杂的,损失函数的定义在 Darknet/src/region_layer.c 中。对于上面这一堆公式,我们先简单看一下,然后我们在源码中去找到对应部分。这里的YOLOv2损失函数详解 - 图35
YOLOv2损失函数详解 - 图36
代表的是特征图的高宽,都为YOLOv2损失函数详解 - 图37
,而 A 指的是 Anchor 个数,YOLOv2 中是 5,各个YOLOv2损失函数详解 - 图38
值是各个 loss 部分的权重系数。我们将损失函数分成 3 大部分来解释:

  • 第一部分:

YOLOv2损失函数详解 - 图39

第一项需要好好解释一下,这个 loss 是计算 background 的置信度误差,这也是 YOLO 系列算法的特色,但是用哪些预测框来预测背景呢?这里需要计算各个预测框和所有的 ground truth 之间的 IOU 值,并且取最大值记作 MaxIOU,如果该值小于一定的阈值,YOLOv2 论文取了 0.6,那么这个预测框就标记为 background,需要计算YOLOv2损失函数详解 - 图40
这么多倍的损失函数。为什么这个公式可以这样表达呢?因为我们有物体的话,那么YOLOv2损失函数详解 - 图41
,如果没有物体YOLOv2损失函数详解 - 图42
,我们把这个值带入到下面的公式就可以推出第一项啦!

YOLOv2损失函数详解 - 图43

  • 第二部分:

YOLOv2损失函数详解 - 图44

这一部分是计算 Anchor boxes 和预测框的坐标误差,但是只在前 12800 个 iter 计算,这一项应该是促进网络学习到 Anchor 的形状。

  • 第三部分:

YOLOv2损失函数详解 - 图45

这一部分计算的是和 ground truth 匹配的预测框各部分的损失总和,包括坐标损失,置信度损失以及分类损失。 3.1 坐标损失 这里的匹配原则是指对于某个特定的 ground truth,首先要计算其中心点落在哪个 cell 上,然后计算这个 cell 的 5 个先验框和 grond truth 的 IOU 值,计算 IOU 值的时候不考虑坐标只考虑形状,所以先将 Anchor boxes 和 ground truth 的中心都偏移到同一位置,然后计算出对应的 IOU 值,IOU 值最大的先验框和 ground truth 匹配,对应的预测框用来预测这个 ground truth。 3.2 置信度损失 在计算 obj 置信度时, 增加了一项YOLOv2损失函数详解 - 图46
权重系数,也被称为 rescore 参数,当其为 1 时,损失是预测框和 ground truth 的真实 IOU 值 (darknet 中采用了这种实现方式)。而对于没有和 ground truth 匹配的先验框,除去那些 Max_IOU 低于阈值的,其它就全部忽略。YOLOv2 和 SSD 与 RPN 网络的处理方式有很大不同,因为它们可以将一个 ground truth 分配给多个先验框。 3.3 分类损失 这个和 YOLOv1 一致,没什么好说的了。

我看了一篇讲解 YOLOv2 损失函数非常好的文章:https://www.cnblogs.com/YiXiaoZhou/p/7429481.html 。里面还有一个关键点:

在计算 boxes 的YOLOv2损失函数详解 - 图47
YOLOv2损失函数详解 - 图48
误差时,YOLOv1 中采用的是平方根以降低 boxes 的大小对误差的影响,而 YOLOv2 是直接计算,但是根据 ground truth 的大小对权重系数进行修正:l.coordscale (2 - truth.w_truth.h)(这里YOLOv2损失函数详解 - 图49
YOLOv2损失函数详解 - 图50
都归一化到 (0,1)),这样对于尺度较小的YOLOv2损失函数详解 - 图51
其权重系数会更大一些,可以放大误差,起到和 YOLOv1 计算平方根相似的效果。

代码实现

贴一下 YOLOv2 在 Keras 上的复现代码,地址为:https://github.com/yhcc/yolo2 。网络结构如下,可以结合上面可视化图来看:

  1. def darknet(images, n_last_channels=425):
  2. """Darknet19 for YOLOv2"""
  3. net = conv2d(images, 32, 3, 1, name="conv1")
  4. net = maxpool(net, name="pool1")
  5. net = conv2d(net, 64, 3, 1, name="conv2")
  6. net = maxpool(net, name="pool2")
  7. net = conv2d(net, 128, 3, 1, name="conv3_1")
  8. net = conv2d(net, 64, 1, name="conv3_2")
  9. net = conv2d(net, 128, 3, 1, name="conv3_3")
  10. net = maxpool(net, name="pool3")
  11. net = conv2d(net, 256, 3, 1, name="conv4_1")
  12. net = conv2d(net, 128, 1, name="conv4_2")
  13. net = conv2d(net, 256, 3, 1, name="conv4_3")
  14. net = maxpool(net, name="pool4")
  15. net = conv2d(net, 512, 3, 1, name="conv5_1")
  16. net = conv2d(net, 256, 1, name="conv5_2")
  17. net = conv2d(net, 512, 3, 1, name="conv5_3")
  18. net = conv2d(net, 256, 1, name="conv5_4")
  19. net = conv2d(net, 512, 3, 1, name="conv5_5")
  20. shortcut = net
  21. net = maxpool(net, name="pool5")
  22. net = conv2d(net, 1024, 3, 1, name="conv6_1")
  23. net = conv2d(net, 512, 1, name="conv6_2")
  24. net = conv2d(net, 1024, 3, 1, name="conv6_3")
  25. net = conv2d(net, 512, 1, name="conv6_4")
  26. net = conv2d(net, 1024, 3, 1, name="conv6_5")
  27. # ---------
  28. net = conv2d(net, 1024, 3, 1, name="conv7_1")
  29. net = conv2d(net, 1024, 3, 1, name="conv7_2")
  30. # shortcut
  31. shortcut = conv2d(shortcut, 64, 1, name="conv_shortcut")
  32. shortcut = reorg(shortcut, 2)
  33. net = tf.concat([shortcut, net], axis=-1)
  34. net = conv2d(net, 1024, 3, 1, name="conv8")
  35. # detection layer
  36. net = conv2d(net, n_last_channels, 1, batch_normalize=0,
  37. activation=None, use_bias=True, name="conv_dec")
  38. return net

然后,网络经过我们介绍的损失函数优化训练以后,对网络输出结果进行解码得到最终的检测结果,这部分代码如下:

  1. def decode(detection_feat, feat_sizes=(13, 13), num_classes=80,
  2. anchors=None):
  3. """decode from the detection feature"""
  4. H, W = feat_sizes
  5. num_anchors = len(anchors)
  6. detetion_results = tf.reshape(detection_feat, [-1, H * W, num_anchors,
  7. num_classes + 5])
  8. bbox_xy = tf.nn.sigmoid(detetion_results[:, :, :, 0:2])
  9. bbox_wh = tf.exp(detetion_results[:, :, :, 2:4])
  10. obj_probs = tf.nn.sigmoid(detetion_results[:, :, :, 4])
  11. class_probs = tf.nn.softmax(detetion_results[:, :, :, 5:])
  12. anchors = tf.constant(anchors, dtype=tf.float32)
  13. height_ind = tf.range(H, dtype=tf.float32)
  14. width_ind = tf.range(W, dtype=tf.float32)
  15. x_offset, y_offset = tf.meshgrid(height_ind, width_ind)
  16. x_offset = tf.reshape(x_offset, [1, -1, 1])
  17. y_offset = tf.reshape(y_offset, [1, -1, 1])
  18. # decode
  19. bbox_x = (bbox_xy[:, :, :, 0] + x_offset) / W
  20. bbox_y = (bbox_xy[:, :, :, 1] + y_offset) / H
  21. bbox_w = bbox_wh[:, :, :, 0] * anchors[:, 0] / W * 0.5
  22. bbox_h = bbox_wh[:, :, :, 1] * anchors[:, 1] / H * 0.5
  23. bboxes = tf.stack([bbox_x - bbox_w, bbox_y - bbox_h,
  24. bbox_x + bbox_w, bbox_y + bbox_h], axis=3)
  25. return bboxes, obj_probs, class_probs

补充

这个损失函数最难的地方应该是 YOLOv2 利用 sigmoid 函数计算默认框坐标之后怎么梯度回传,这部分可以看下面的代码 (来自 Darknet 源码):

  1. // box误差函数,计算梯度
  2. float delta_region_box(box truth, float *x, float *biases, int n, int index, int i, int j, int w, int h, float *delta, float scale, int stride)
  3. {
  4. box pred = get_region_box(x, biases, n, index, i, j, w, h, stride);
  5. float iou = box_iou(pred, truth);
  6. // 计算ground truth的offsets值
  7. float tx = (truth.x*w - i);
  8. float ty = (truth.y*h - j);
  9. float tw = log(truth.w*w / biases[2*n]);
  10. float th = log(truth.h*h / biases[2*n + 1]);
  11. delta[index + 0*stride] = scale * (tx - x[index + 0*stride]);
  12. delta[index + 1*stride] = scale * (ty - x[index + 1*stride]);
  13. delta[index + 2*stride] = scale * (tw - x[index + 2*stride]);
  14. delta[index + 3*stride] = scale * (th - x[index + 3*stride]);
  15. return iou;
  16. }

结合一下我们前面介绍的公式,这就是一个逆过程,现在是不是清晰一些了?有任何问题欢迎在留言区和我讨论哦。

后记

今天就介绍到这里了,YOLOv2 的损失函数实现都在 region_layer.c 里面了,同时推荐一下我的一个 Darknet 源码解析项目,我会在里面努力解析 YOLO 目标检测算法的细节,地址为:https://github.com/BBuf/Darknet 。明天开始讲解 YOLOv3,后面安排一下 YOLOv3 的实战,就用 NCNN 和 YOLOv3 为例子吧。

参考

https://zhuanlan.zhihu.com/p/35325884 https://www.cnblogs.com/YiXiaoZhou/p/7429481.html https://github.com/yhcc/yolo2

https://zhuanlan.zhihu.com/p/93632171

https://zhuanlan.zhihu.com/p/93632171