2019-04-26
如果前面几篇已经把 `YOLOv3` 的网络结构、数据输入、损失函数分别拆过一遍,那么最后这一篇就很适合做个收口:它到底好在哪,坑又埋在哪。
这篇不再顺着层数硬啃,而是从三个更有用的角度看它:
you only look once当然,也顺手聊聊它没解决干净的问题。
很多人提到 YOLO,第一反应就是快。这个判断没错,但不够完整。
YOLOv3 真正厉害的地方,是它把下面几件事揉到了同一套输出结构里:
gridanchorxywh也就是说,它不是“先这里算一点,再那里补一点”,而是把监督信息尽量直接地投到输出张量上。
这一点非常关键。因为一旦输出结构和损失函数咬合得够紧,模型就能比较自然地学到:
从工程视角看,这就是它之所以能维持 one-stage 风格的根基。
YOLO 系列最让人上头的一点,就是 You Only Look Once。
和两阶段方法相比,它没有先做候选区域、再慢慢筛,而是直接在输出特征图上把任务做完。
这件事能成立,靠的是输出张量的组织方式。
常见地,训练时我们会把真实值和预测值整理成类似这样的结构:
[batch, grid, grid, num_anchor, 5 + num_classes]
这个结构看着有点长,但逻辑非常直白:
grid x grid 表示某个尺度下的网格位置num_anchor 表示这一层负责的锚框集合5 一般是 x, y, w, h, objectness于是几个本来分散的概念被串起来了:
grid 和目标所在位置有了映射关系anchor 和当前输出层有了归属关系xywh 和具体框回归有了明确接口说得更接地气一点就是:
目标框不再只是“图里有个框”,而是被翻译成了“某一层、某个格点、某个 anchor 该输出什么”。
这就是 YOLOv3 能把检测任务一次性打包做完的基础。
如果只盯着公式看,YOLOv3 的 loss 可能会让人觉得支线很多。但换个视角看,它其实挺有章法。
其中两个特别值得单拎出来看:
object_maskbox_loss_scaleobject_mask:像一个总闸门object_mask 可以粗暴理解成:
这个 grid / anchor 位置,到底该不该认真算。
有目标,它就把这个位置提起来;没目标,它就把很多无效响应按住。
这意味着很多损失分支并不是平铺开来算,而是先经过一个“这里有没有东西”的判断。
这个角色很像总阀门,所以它的重要性往往比表面看起来更高。
box_loss_scale:别让小目标永远吃亏另一个很妙的点,是 box_loss_scale。
直觉上,它和目标面积近似成反比。目标越小,这一项带来的权重通常越明显。
为什么这点有意思?
因为在固定 anchor 的前提下,小目标本来就更容易吃亏:
而 box_loss_scale 的加入,相当于在说:
小框别总当边角料,也得给它一点发言权。
于是原文里提到的那个观察就很成立:
两股力量一起作用,训练过程就更容易形成某种制衡。
YOLOv3 在一些分支上采用 sigmoid + binary_crossentropy 的思路,这种设计其实挺有性格。
它背后的态度可以概括成一句话:
你对就是对,不对就是不对,别老在中间打太极。
这类建模方式的好处是:
尤其在目标是否存在、某些类别判断上,这种“硬一点”的风格确实很有效。
当然,它也会带来副作用,这个后面再说。
YOLOv3 的另一个很核心的提升,是多尺度检测。
它通常会输出三组特征:
13 x 1326 x 2652 x 52大概可以这么理解:
同时,网络里还会有 Concatenate 或 route 这样的拼接操作,把不同层级的特征接起来。
这一步为什么好用?
所以三尺度这件事,不只是“输出多了两层”,而是它真的改善了模型对不同目标大小的适应性。
YOLOv3 并不是随便挑几个锚框尺寸就开训。
很多实现会先在数据集上做 K-Means 聚类,把更常见的框尺寸模式提出来,再拿来作为 anchor 先验。
这个思路非常实在:
它的好处是,让模型在具体数据集上的拟合起点更舒服,不至于一开始就在和一堆不合适的 anchor 较劲。
说完亮点,接下来就该说实话了。YOLOv3 好用,但不是全能。
前面说过,这种思路的好处是判断很干脆;但反过来,问题也在这里。
一旦输出倾向太极端,就容易出现下面这类情况:
0.49 和 0.51 的命运差得有点太大这类现象在训练步数不够、类别分布不均、样本难度偏高时,会更明显。
换句话说,它有时候会让模型“站队过早”。
这个问题在原文里提得很到位。
如果同一个输出层、同一个格点、同一个 anchor 上,碰巧出现两个很接近的目标,那么后写入的标注可能把前面的覆盖掉。
这意味着:
极端一点说,同一位置里一猫一狗,模型可能最后只学到其中一个。
这一点基本是共识。
YOLOv3 的优势在于:
但如果把“极致精度”放在第一优先级,它通常还是不如一些两阶段方法那么从容。
这不是什么黑点,更像是设计取舍。
从文章本身的思路延伸出去,我觉得可以把后续改进方向概括成两类。
三层输出已经很好用,但并不意味着不能继续改。
可以想的方向包括:
本质上,还是在问一个问题:
怎样让一个位置表达更多、更不冲突的目标信息?
如果同位置目标互相覆盖的问题不解决,那么小目标、密集目标、多实例重叠场景,都会持续吃亏。
可尝试的思路包括:
后来的很多检测器,其实都在不同程度上继续回答这个问题。
先来个最小例子,感受一下输出结构:
def yolo_output_shapes(input_size=416, num_classes=80, anchors_per_scale=3):
channels = anchors_per_scale * (5 + num_classes)
return {
"scale_13": (input_size // 32, input_size // 32, channels),
"scale_26": (input_size // 16, input_size // 16, channels),
"scale_52": (input_size // 8, input_size // 8, channels),
}
for name, shape in yolo_output_shapes().items():
print(name, "->", shape)
输出大致会是:
scale_13 -> (13, 13, 255)
scale_26 -> (26, 26, 255)
scale_52 -> (52, 52, 255)
这个 255 的来源也很直接:
3 x (5 + 80) = 255
其中:
3 是每个尺度默认的 anchor 数5 是 xywh + objectness80 是类别数如果你想更贴近“anchor 来自数据集”这件事,可以先看一个迷你示例:
import numpy as np
from sklearn.cluster import KMeans
# 假设 boxes 是标注框的宽高,已经归一化到 0~1
boxes = np.array([
[0.12, 0.18],
[0.10, 0.14],
[0.22, 0.30],
[0.38, 0.45],
[0.55, 0.62],
[0.70, 0.80],
[0.16, 0.12],
[0.28, 0.20],
[0.44, 0.36],
], dtype=np.float32)
kmeans = KMeans(n_clusters=3, random_state=0, n_init=10)
kmeans.fit(boxes)
anchors = kmeans.cluster_centers_
print("anchors:")
print(np.round(anchors, 4))
真实训练里不会这么简化,但它足够说明一个重点:
anchor 不是凭感觉拍出来的,而是尽量从数据分布里长出来的。
下面给个很小的张量例子,看看 objectness 这一维长什么样:
import torch
batch = 2
grid = 13
anchors = 3
num_classes = 80
pred = torch.randn(batch, grid, grid, anchors, 5 + num_classes)
objectness = pred[..., 4].sigmoid()
print("pred shape :", pred.shape)
print("objectness shape:", objectness.shape)
print("objectness min :", objectness.min().item())
print("objectness max :", objectness.max().item())
这个例子虽然很小,但能帮你把“总闸门”这个概念具体化一点。
如果要给 YOLOv3 下一个很短的评价,我会这么说:
它不是某一个点特别夸张,而是很多设计刚好互相搭上了。
它的亮点在于:
它的不足也同样明显:
但也正因为这些优缺点都很清楚,YOLOv3 才特别适合拿来学习目标检测的核心思想。它不像一团雾,反而像一台拆开以后越看越顺的机器。