YOLOv3 整体归纳总结




2019-04-26

blog_main_img

如果前面几篇已经把 `YOLOv3` 的网络结构、数据输入、损失函数分别拆过一遍,那么最后这一篇就很适合做个收口:它到底好在哪,坑又埋在哪。

这篇不再顺着层数硬啃,而是从三个更有用的角度看它:

  • 为什么它能做到 you only look once
  • 为什么它的损失设计让训练和输出结构贴得这么紧
  • 为什么它在速度和效果之间找到了一个很实用的平衡

当然,也顺手聊聊它没解决干净的问题。

一眼看核心:YOLOv3 能跑得顺,不只是因为它“快”

很多人提到 YOLO,第一反应就是快。这个判断没错,但不够完整。

YOLOv3 真正厉害的地方,是它把下面几件事揉到了同一套输出结构里:

  • 图像中的绝对位置
  • 某一层输出对应的 grid
  • 某个 anchor
  • 目标框的相对参数 xywh
  • 是否有目标
  • 目标类别

也就是说,它不是“先这里算一点,再那里补一点”,而是把监督信息尽量直接地投到输出张量上。

YOLOv3 端到端映射示意

这一点非常关键。因为一旦输出结构和损失函数咬合得够紧,模型就能比较自然地学到:

  • 哪个尺度负责哪个目标
  • 哪个 anchor 更适合当前框
  • 某个格点到底该不该站出来负责预测

从工程视角看,这就是它之所以能维持 one-stage 风格的根基。

亮点 1: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
  • 再往后就是类别信息

于是几个本来分散的概念被串起来了:

  1. grid 和目标所在位置有了映射关系
  2. anchor 和当前输出层有了归属关系
  3. xywh 和具体框回归有了明确接口

说得更接地气一点就是:

目标框不再只是“图里有个框”,而是被翻译成了“某一层、某个格点、某个 anchor 该输出什么”。

这就是 YOLOv3 能把检测任务一次性打包做完的基础。

亮点 2:损失函数不是零散拼装,而是带点“整体配平”的味道

如果只盯着公式看,YOLOv3 的 loss 可能会让人觉得支线很多。但换个视角看,它其实挺有章法。

其中两个特别值得单拎出来看:

  • object_mask
  • box_loss_scale

YOLOv3 损失平衡示意

2.1 object_mask:像一个总闸门

object_mask 可以粗暴理解成:

这个 grid / anchor 位置,到底该不该认真算。

有目标,它就把这个位置提起来;没目标,它就把很多无效响应按住。

这意味着很多损失分支并不是平铺开来算,而是先经过一个“这里有没有东西”的判断。

这个角色很像总阀门,所以它的重要性往往比表面看起来更高。

2.2 box_loss_scale:别让小目标永远吃亏

另一个很妙的点,是 box_loss_scale

直觉上,它和目标面积近似成反比。目标越小,这一项带来的权重通常越明显。

为什么这点有意思?

因为在固定 anchor 的前提下,小目标本来就更容易吃亏:

  • 占图像面积小
  • 置信度容易偏低
  • 回归误差一点点偏差,看起来就会很明显

box_loss_scale 的加入,相当于在说:

小框别总当边角料,也得给它一点发言权。

于是原文里提到的那个观察就很成立:

  • 置信度会受面积影响
  • 但回归权重又会反向把小目标往上托一点

两股力量一起作用,训练过程就更容易形成某种制衡。

亮点 3:Sigmoid + BCE 的“硬判断”思路,很有个性

YOLOv3 在一些分支上采用 sigmoid + binary_crossentropy 的思路,这种设计其实挺有性格。

它背后的态度可以概括成一句话:

你对就是对,不对就是不对,别老在中间打太极。

这类建模方式的好处是:

  • 目标性更强
  • 学出来的判断边界往往更直接
  • 某些分支会显得特别干脆

尤其在目标是否存在、某些类别判断上,这种“硬一点”的风格确实很有效。

当然,它也会带来副作用,这个后面再说。

亮点 4:三尺度输出,让它不再只擅长“看大块头”

YOLOv3 的另一个很核心的提升,是多尺度检测。

它通常会输出三组特征:

  • 13 x 13
  • 26 x 26
  • 52 x 52

大概可以这么理解:

  • 深层特征看得懂大目标
  • 中层特征负责折中
  • 浅层特征更照顾小目标

同时,网络里还会有 Concatenateroute 这样的拼接操作,把不同层级的特征接起来。

这一步为什么好用?

  • 语义信息和细节信息重新汇合了
  • 感受野和局部细节不再非此即彼
  • 可检测目标的尺寸范围被拉宽了

所以三尺度这件事,不只是“输出多了两层”,而是它真的改善了模型对不同目标大小的适应性。

亮点 5:Anchor 用 K-Means 聚类,不是拍脑袋定的

YOLOv3 并不是随便挑几个锚框尺寸就开训。

很多实现会先在数据集上做 K-Means 聚类,把更常见的框尺寸模式提出来,再拿来作为 anchor 先验。

这个思路非常实在:

  • 数据集里小目标多,就让 anchor 更偏小
  • 数据集里横向目标多,就让 anchor 更偏扁
  • 数据集分布变了,anchor 也能跟着调

它的好处是,让模型在具体数据集上的拟合起点更舒服,不至于一开始就在和一堆不合适的 anchor 较劲。

YOLOv3 的不足,也确实挺真实

说完亮点,接下来就该说实话了。YOLOv3 好用,但不是全能。

YOLOv3 优缺点总结

1. 二分类式建模会让输出偏“硬”

前面说过,这种思路的好处是判断很干脆;但反过来,问题也在这里。

一旦输出倾向太极端,就容易出现下面这类情况:

  • 某些候选值长期卡在很低或很高的位置
  • 中间地带不够平滑
  • 0.490.51 的命运差得有点太大

这类现象在训练步数不够、类别分布不均、样本难度偏高时,会更明显。

换句话说,它有时候会让模型“站队过早”。

2. 同一个 grid / anchor 可能发生标签覆盖

这个问题在原文里提得很到位。

如果同一个输出层、同一个格点、同一个 anchor 上,碰巧出现两个很接近的目标,那么后写入的标注可能把前面的覆盖掉。

这意味着:

  • 密集目标更难处理
  • 重叠目标更容易冲突
  • 尺寸接近、位置又近的目标最容易受影响

极端一点说,同一位置里一猫一狗,模型可能最后只学到其中一个。

3. 和两阶段方法相比,精度仍然会差一些

这一点基本是共识。

YOLOv3 的优势在于:

  • 结构统一
  • 部署友好

但如果把“极致精度”放在第一优先级,它通常还是不如一些两阶段方法那么从容。

这不是什么黑点,更像是设计取舍。

如果往下继续优化,可以往哪走

从文章本身的思路延伸出去,我觉得可以把后续改进方向概括成两类。

方向 1:继续改输出层组织方式

三层输出已经很好用,但并不意味着不能继续改。

可以想的方向包括:

  • 标签分配更细
  • 更多自适应的输出策略
  • 对密集目标更友好的预测单元组织

本质上,还是在问一个问题:

怎样让一个位置表达更多、更不冲突的目标信息?

方向 2:处理重叠目标和标签覆盖

如果同位置目标互相覆盖的问题不解决,那么小目标、密集目标、多实例重叠场景,都会持续吃亏。

可尝试的思路包括:

  • 更精细的 anchor 分配
  • 同位置偏移编码
  • 更灵活的正样本匹配策略

后来的很多检测器,其实都在不同程度上继续回答这个问题。

用 Python 看一眼 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 数
  • 5xywh + objectness
  • 80 是类别数

用 Python 做一个简化版 anchor 聚类示意

如果你想更贴近“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 不是凭感觉拍出来的,而是尽量从数据分布里长出来的。

如果你在 PyTorch 里想观察 objectness 分支

下面给个很小的张量例子,看看 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 下一个很短的评价,我会这么说:

它不是某一个点特别夸张,而是很多设计刚好互相搭上了。

它的亮点在于:

  • 输出结构和损失函数贴得紧
  • one-stage 逻辑完整
  • 多尺度设计很实用
  • anchor 更贴合数据集

它的不足也同样明显:

  • 二分类式输出偏硬
  • 标签覆盖问题真实存在
  • 精度上仍会输给更重型的方案

但也正因为这些优缺点都很清楚,YOLOv3 才特别适合拿来学习目标检测的核心思想。它不像一团雾,反而像一台拆开以后越看越顺的机器。