系列导航:上一篇:06 YOLOv2——锚框与直接位置预测 | 下一篇:08 YOLOv4 / v5——工程集大成
本篇目标:读完本篇,你将能够——
- 说出 YOLOv3 相对 YOLOv2 的改动:3 个尺度的预测、特征金字塔、9 个锚框、独立的 sigmoid 分类、Darknet-53;
- 解释为什么一个尺度的特征图难以同时检测大物体和小物体,以及“自顶向下 + 拼接”的特征金字塔怎样解决这个问题;
- 写出 YOLOv3 的标签分配规则和损失函数,推导二元交叉熵对 logit 的梯度 ;
- 计算按物体大小分的 AP(、、),并用 pycocotools 核对;
- 把第 06 篇的 mini-YOLOv2 改造成 3 个尺度的 mini-YOLOv3,用实验检验每一项改动。
前置知识:第 01 篇(感受野、残差连接)、第 03 篇(mAP)、第 06 篇(锚框、k-means、直接位置预测、YOLOv2 的损失)。
2018 年,Redmon 和 Farhadi 发布了一份技术报告 YOLOv3: An Incremental Improvement。正如标题所说,它没有提出全新的思想,而是把当时别人的好想法(特征金字塔、残差网络、多标签分类)组合进 YOLO。它的语气相当随意,但影响很大:3 个尺度的预测从此成为 YOLO 的标准结构,此后的 v4、v5 直到 v8 都沿用了“骨干 + 特征金字塔 + 多尺度检测头”的框架。
本篇的重点是多尺度预测。为了让它的作用看得出来,本篇换了一个物体大小范围更宽的数据集:物体边长从 6 像素到 64 像素,相差 10 倍以上(第 05、06 篇是 18~48 像素)。本篇的数字全部来自实际训练:附录 A 的 example.py 训练 mini-YOLOv3 并做分析,附录 B 的 path_to_v3.py 从 mini-YOLOv2 出发逐项比较,附录 C 的 verify_with_pycocotools.py 用 pycocotools 核对按大小分的 AP。
一、YOLOv2 留下的问题
YOLOv2 在 VOC 上已经很强,但在 COCO 上暴露了一个弱点:小物体。YOLOv3 论文表 3 列出了 YOLOv2 在 COCO test-dev 上的成绩:AP 21.6,AP50 44.0,其中小物体的 只有 5.0,而大物体的 有 35.5。COCO 按面积把物体分成三类(第 03 篇):面积小于 像素的是小物体, 到 之间的是中物体,大于 的是大物体。COCO 里小物体占了约 41%,检测不好它们,总的 AP 就上不去。
原因不难理解。YOLOv2 只在一个 13×13 的特征图上预测,总步长 32。一个 像素的物体,在特征图上还不到一个格子的 ;它的细节在 5 次下采样中几乎全部丢失。passthrough 层(第 06 篇第八节)把 26×26 的特征叠了进来,但只叠了一层,作用有限。
此外还有两个较小的问题:
- 每个格子只有 5 个锚框,同一格子里中心靠得很近、形状又相似的多个小物体,会争同一个锚框(第 06 篇的
build_targets把这种冲突的物体直接丢掉); - 类别用 softmax,假设每个框只属于一个类别。在类别有包含关系的数据集上(“人”和“女人”),这个假设不成立。
二、YOLOv3 的改动一览
| 部分 | YOLOv2 | YOLOv3 |
|---|---|---|
| 预测的尺度 | 1 个(13×13,步长 32),加 passthrough | 3 个(13×13、26×26、52×52,步长 32、16、8) |
| 特征融合 | passthrough:26×26 的特征重排后拼接 | 特征金字塔:深层特征上采样后,与浅层特征拼接 |
| 锚框 | 5 个 k-means 锚框 | 9 个 k-means 锚框,每个尺度 3 个 |
| 框的参数化 | , | 相同 |
| 置信度的目标 | 预测框与真实框的 IoU | 1(负责的锚框) |
| 类别 | softmax,交叉熵 | 每个类别一个独立的 sigmoid,二元交叉熵 |
| 骨干 | Darknet-19(19 个卷积层) | Darknet-53(53 层,带残差连接) |
输入 416×416、COCO 的 80 个类别时,3 个尺度的输出张量是
每张图一共预测 个框,是 YOLOv2()的 12 倍多。
论文表 3(COCO test-dev)中的部分结果:
| 方法 | 骨干 | AP | AP50 | AP75 | |||
|---|---|---|---|---|---|---|---|
| YOLOv2 | Darknet-19 | 21.6 | 44.0 | 19.2 | 5.0 | 22.4 | 35.5 |
| SSD513 | ResNet-101 | 31.2 | 50.4 | 33.3 | 10.2 | 34.5 | 49.8 |
| RetinaNet | ResNet-101-FPN | 39.1 | 59.1 | 42.3 | 21.8 | 42.7 | 50.2 |
| YOLOv3 608×608 | Darknet-53 | 33.0 | 57.9 | 34.4 | 18.3 | 35.4 | 41.9 |
从 5.0 跳到了 18.3。速度方面,论文(在 M40 或 Titan X 上)测得:YOLOv3-320 每张 22 ms,AP 28.2;YOLOv3-416 每张 29 ms,AP 31.0;YOLOv3-608 每张 51 ms,AP 33.0,AP50 57.9。作为对比,RetinaNet-101-800 的 AP50 是 57.5,每张要 198 ms,慢 3.8 倍。
三、为什么要在多个尺度上预测
1. 一个尺度的两难
卷积网络越深,特征图的分辨率越低,但每个位置的感受野越大(第 01 篇第七节),看到的上下文越多,语义信息越丰富。这带来一个两难:
- 检测大物体需要大的感受野。一个 60 像素宽的物体,如果某一层的感受野只有 30 像素,这一层的任何一个位置都看不全它。
- 检测小物体需要高的分辨率。一个 8 像素宽的物体,在步长 32 的特征图上只占 个格子,它的形状细节早已在下采样中被平均掉了;而且一个格子里可能挤着好几个这样的小物体。
只用最后一层(YOLOv1、v2)能照顾大物体,却牺牲了小物体;只用浅层则反过来。
2. 三种多尺度的做法
图 1:本篇 mini-YOLOv3 的网络结构。左列是骨干,分辨率逐级减半;中列是特征金字塔,紫色箭头是自顶向下的路径;右列是 3 个尺度的检测头。
在多个尺度上检测,历史上有三种做法:
- 图像金字塔:把图像缩放成几种尺寸,分别送进网络。效果好,但计算量成倍增加。
- 特征层级(SSD):直接在骨干的几个不同深度的特征图上分别预测。浅层负责小物体,深层负责大物体,几乎不增加计算量。问题在于浅层特征的语义太弱:它们只经过了几层卷积,还“认不出”物体是什么。
- 特征金字塔(FPN,Lin 等,2017):在特征层级的基础上增加一条自顶向下的路径。把深层的特征上采样,与浅层的特征融合,于是每个尺度的特征都同时具备深层的语义和浅层的分辨率。
YOLOv3 用的是第 3 种,只是融合的方式与 FPN 原文略有不同:FPN 把上采样后的特征与浅层特征逐元素相加,YOLOv3 则沿通道方向拼接(concatenate)。
四、YOLOv3 的网络结构
论文对结构的描述很简短:在骨干之后加几个卷积层,最后一层输出第一个尺度的预测;然后取“往前两层”的特征图,上采样 2 倍,与网络较早的一层特征图拼接,再经过几个卷积层,输出第二个尺度的预测;同样的设计再做一次,得到第三个尺度。
结合 darknet 的配置文件 yolov3.cfg,输入 416×416 时具体是:
- Darknet-53 输出 13×13×1024 的特征,经过 5 个交替的 1×1、3×3 卷积,再经过一个 3×3 卷积和一个 1×1 卷积,输出 13×13×255,负责大物体;
- 从那 5 个卷积的输出引出一支,用 1×1 卷积把通道压到 256,上采样到 26×26,与骨干中 26×26×512 的特征拼接成 26×26×768;同样经过 5 + 2 个卷积,输出 26×26×255,负责中物体;
- 再引出一支,压到 128 个通道,上采样到 52×52,与骨干中 52×52×256 的特征拼接;输出 52×52×255,负责小物体。
上采样用最近邻插值,没有参数。本篇的 mini-YOLOv3(图 1)是它的缩小版:输入 128×128,三个尺度是 16×16(步长 8)、8×8(步长 16)、4×4(步长 32);每个尺度的特征只经过一个 3×3 卷积,检测头是一个 1×1 卷积,输出 个通道。
五、9 个锚框,分给 3 个尺度
YOLOv3 仍然用 k-means 聚类锚框(第 06 篇第五节),只是聚类成 9 个,按面积从小到大排好,平均分给 3 个尺度。论文说这个选择“相当随意”。COCO 上的 9 个锚框(宽 × 高,像素,输入 416)是:
| 尺度 | 网格 | 锚框 |
|---|---|---|
| 步长 8 | 52×52 | 10×13、16×30、33×23 |
| 步长 16 | 26×26 | 30×61、62×45、59×119 |
| 步长 32 | 13×13 | 116×90、156×198、373×326 |
换算成各自尺度的格子,步长 8 的三个锚框是 1.25×1.62、2.00×3.75、4.12×2.88 个格子,而步长 32 的最大一个是 11.66×10.19 个格子,几乎覆盖整张图(练习 5)。COCO 的物体大小分布很广,三个尺度的锚框以格子计也相差很大。
本篇的数据上(附录代码第 1 部分),训练集 3000 张图共有 13501 个框,边长(面积的平方根)从 6.0 到 64.0 像素。9 个 k-means 锚框是:
| 尺度 | 网格 | 锚框(宽×高,像素) | 以本尺度的格子为单位 |
|---|---|---|---|
| 步长 8 | 16×16 | 7.6×7.8、11.8×12.0、17.4×18.1 | 0.95×0.97、1.48×1.50、2.18×2.26 |
| 步长 16 | 8×8 | 22.6×28.8、30.0×24.5、32.1×41.2 | 1.41×1.80、1.88×1.53、2.01×2.57 |
| 步长 32 | 4×4 | 43.0×34.6、46.3×59.7、60.8×48.5 | 1.34×1.08、1.45×1.86、1.90×1.52 |
图 2:左:训练集所有框的宽高(对数坐标),按负责的尺度着色,★ 是 9 个锚框。右:物体边长的分布,按负责的尺度堆叠;两条虚线是本篇 、、 的分界(第九节)。图例里的个数没有扣除第六节所说因冲突被丢掉的 2 个物体,所以步长 32 是 3985 个,比正文多 2 个。
表的最后一列值得留意:以各自的格子为单位,三个尺度的锚框都在 1~2.6 个格子之间。也就是说,在每个尺度看来,“物体比格子大多少”都差不多,三个检测头面对的几乎是同一个任务,只是图像被缩小了不同的倍数。这正是多尺度预测的出发点:与其让一个检测头同时应付 6 像素和 64 像素的物体,不如让每个尺度只负责相对自己大小合适的物体。
平均 IoU 随锚框个数增加而提高: 时 0.663, 时 0.737, 时 0.789。
六、标签分配:每个物体只交给一个锚框
YOLOv3 的分配规则只比 YOLOv2 多了一步“选尺度”:
- 计算真实框与全部 9 个锚框的宽高 IoU(中心对齐),取 IoU 最大的那个锚框;
- 这个锚框属于哪个尺度,就由那个尺度负责;在该尺度的网格上,物体中心所在的格子、这个锚框负责预测它;
- 其余所有锚框都不负责这个物体。
所以一个物体只有一个正样本。论文强调了这一点,并与 Faster R-CNN 对比:Faster R-CNN 把 IoU 大于 0.7 的锚框都当作正样本、小于 0.3 的当作负样本、中间的忽略;论文说他们试过这种“双阈值”的做法,没能得到好结果。
不负责的锚框只计算置信度损失,目标是 0。但有一个例外,与 YOLOv2 相同:如果某个不负责的锚框的预测框与任意真实框的 IoU 超过阈值,就忽略它,不计置信度损失。论文写的阈值是 0.5,darknet 的配置文件 yolov3.cfg 里是 0.7(ignore_thresh = .7)。附录代码用论文的 0.5。
图 3:同一张图像在三个尺度上的标签分配。小物体由 16×16 网格(步长 8)负责,大物体由 4×4 网格(步长 32)负责。白色点线是负责的锚框,画在格子中心:它的形状与物体相近,网络只需要预测一个小的修正。
附录代码第 2 部分统计了训练集上的分配结果:步长 8 负责 5647 个物体,步长 16 负责 3869 个,步长 32 负责 3983 个。从图 2 右边可以看出,边长约 20 像素以下的物体都归步长 8,约 20~44 像素的归步长 16,32 像素以上的归步长 32。分界处有重叠,因为分配看的是宽高 IoU,面积相同、形状不同的物体可能被分到不同的尺度。
多尺度也减少了冲突。如果两个物体的中心落在同一个格子,又选中了同一个锚框,只能保留一个。按第 06 篇的做法(只用步长 16、5 个锚框),训练集上有 13 个物体因冲突被丢掉;按 YOLOv3 的做法只丢掉 2 个,因为小物体被分到了 16×16 的细网格上,挤进同一个格子的机会小得多。本篇的数据里物体不算密集,在人群、鸟群这样的场景里,这个差别会大得多。
七、损失函数
1. 坐标:平方误差,还是二元交叉熵?
论文对坐标损失只有一句话:训练时用平方误差(sum of squared error),梯度是“目标值减预测值”。可 darknet 的实际做法有一个容易忽略的细节。
、 的输出要先经过 sigmoid,即 。darknet 的 yolo_layer.c 计算的“梯度”是 ,并且直接把它传给前一层,没有再乘 sigmoid 的导数 。如果损失真的是平方误差 ,对 的梯度应该是
多了一个因子 。而二元交叉熵(binary cross-entropy,BCE)
对 的梯度是(利用 )
正好是 darknet 传回去的那个量(差一个负号,因为 darknet 存的是负梯度)。所以 darknet 的 YOLOv3 对 、 实际上用的是二元交叉熵,对 、 用的是平方误差 。注意 BCE 的目标 不一定是 0 或 1,这时 BCE 的最小值不是 0,而是 的二元熵 ;但它的梯度在 处为 0,最优解不变。
两种损失的差别在 饱和的时候最明显:
图 4:左:同一组 logit,softmax 的三个概率之和必须为 1,sigmoid 让每个类别独立。右:目标 时,两种损失对 的梯度。 很负时(预测 ,错得很离谱),平方误差的梯度几乎为 0,BCE 的梯度却接近 。
平方误差的梯度带着因子 :预测越接近 0 或 1,梯度越小,哪怕预测是错的。这正是 sigmoid 饱和造成的梯度消失。BCE 的梯度 只看“错了多少”,没有这个问题。
2. 置信度:目标是 1
YOLOv3 的置信度用 logistic 回归(sigmoid + BCE):负责的锚框目标是 1,不负责(且没有被忽略)的锚框目标是 0。这与 YOLOv1、v2 不同,它们的目标是预测框与真实框的 IoU(第 04 篇第三节)。
于是 YOLOv3 的置信度不再估计“框得有多准”,只回答“这里有没有物体”。这让训练更简单(目标是固定的 0 和 1,不随网络的预测而变化),代价是检测框的分数与定位质量无关。第 10 篇会看到,后来的 Quality Focal Loss、Varifocal Loss 又把 IoU 加了回来。
3. 类别:独立的 sigmoid
YOLOv3 不再用 softmax,而是给每个类别一个独立的 sigmoid,用二元交叉熵训练:
论文的理由是:在 Open Images 这样的数据集上,很多标签是重叠的(比如“女人”和“人”),softmax 假设每个框恰好属于一个类别,这在这些数据上不成立。用 sigmoid,一个框可以同时属于多个类别。论文还说,在 COCO 这种类别互斥的数据上,softmax 并不是取得好成绩所必需的。
图 4 左边是附录代码第 2 部分的例子:logit 为 时,softmax 给出 ,“人”和“女人”只能分享一份概率;sigmoid 给出 ,两者都接近 1。
4. 完整的损失
把三部分合起来(darknet 的做法,所有项的权重都是 1):
其中 , 与 YOLOv2 一样给小物体更大的权重( 是归一化的真实宽高),三个尺度的损失直接相加。和 YOLOv2 相比,、、 都没有了。
5. 两处实现上的选择
附录代码在 darknet 的做法之外,做了两处改动。它们不是 YOLOv3 论文的内容,但在本篇的设置下必不可少。
置信度偏置的初始化。mini-YOLOv3 每张图有 1008 个预测框,平均只有约 4.5 个负责物体。网络刚初始化时输出接近 0,所有置信度都是 ,“无物体”一项的损失约为 ,而“有物体”一项只有约 。训练初期几乎所有的梯度都在把置信度压低。RetinaNet(Lin 等,2017)的做法是把置信度那一路输出的偏置初始化为 ,取 ,让一开始所有框的置信度都约为 0.01。这时“无物体”一项的初始损失降到 。Ultralytics 的 YOLOv3、v5 也用了类似的初始化。
学习率。第 05、06 篇的最大学习率是 ;本篇的网络有 3 个尺度、预测框多了 15 倍,用 训练 30 轮明显不够。附录代码用 ,第十节有对比。
八、Darknet-53
YOLOv3 换了一个更大的骨干 Darknet-53。它保留了 Darknet-19 交替使用 3×3 和 1×1 卷积的风格,又借鉴了 ResNet 的残差连接(第 01 篇第十一节):每个残差块是一个把通道减半的 1×1 卷积和一个恢复通道的 3×3 卷积,输出与输入相加。下采样不再用最大池化,而是用步长为 2 的 3×3 卷积。五次下采样之后分别接 1、2、8、8、4 个残差块。论文说它有 53 个卷积层:开头的 1 个卷积、5 个下采样卷积和 23 个残差块里的 46 个卷积,共 52 个,再加上最后做分类的全连接层(darknet 里全连接层相当于 1×1 卷积),正好 53 层。
论文表 2(ImageNet,输入 256×256,Titan X):
| 骨干 | Top-1 | Top-5 | 运算量(十亿次) | 每秒浮点运算(十亿次) | FPS |
|---|---|---|---|---|---|
| Darknet-19 | 74.1 | 91.8 | 7.29 | 1246 | 171 |
| ResNet-101 | 77.1 | 93.7 | 19.7 | 1039 | 53 |
| ResNet-152 | 77.6 | 93.8 | 29.4 | 1090 | 37 |
| Darknet-53 | 77.2 | 93.8 | 18.7 | 1457 | 78 |
Darknet-53 的准确率与 ResNet-152 相当,速度却快一倍。论文的解释是它的每秒浮点运算数最高,结构更适合 GPU:ResNet 的层太多,每层的计算量太小,GPU 的利用率不高。
YOLOv3 的骨干先在 ImageNet 上预训练,再训练检测;本系列从零训练,只有几千张图、30 轮。本篇的 mini-YOLOv3 默认沿用第 06 篇的“卷积 + 池化”骨干,同时保留了一个 Darknet 式的骨干选项(backbone="darknet":步长 2 的卷积下采样 + 残差块),第十节比较了两者。
九、动手:mini-YOLOv3
1. 数据与按大小分的 AP
第 05、06 篇的物体边长在 18~48 像素之间,相差不到 3 倍,一个尺度就能应付。本篇仍然调用 make_sample,只改两个参数:size_range=(6, 64)、n_range=(1, 8),于是物体边长从 6 到 64 像素,每张图 1~8 个物体(平均 4.5 个)。训练集仍是 3000 张,验证集 500 张。
COCO 的大小分界(、)是针对 640 像素左右的图像定的,直接用在 128×128 的图像上,几乎所有物体都会成为“小物体”。本篇按比例取了自己的分界:面积小于 的是小物体(S),~ 之间的是中物体(M),大于 的是大物体(L)。训练集里三者分别占 29.1%、32.4%、38.5%,验证集里分别有 658、758、869 个。
计算 时,不能简单地把中、大物体从真实框里删掉,否则所有检测到中、大物体的框都会变成误检。COCO 的做法(附录代码的 match_class_area)是:
- 面积不在范围内的真实框标记为“忽略”;
- 每个检测框优先与不忽略的真实框匹配;如果只能匹配上被忽略的真实框,这个检测框也被忽略,既不算 TP 也不算 FP;
- 没有匹配上任何真实框的检测框,如果它自己的面积不在范围内,也忽略,否则算 FP。
附录 C 用 pycocotools 核对了这个实现:mAP@[.5:.95]、mAP@0.5、mAP@0.75、、、 六个数与 COCOeval 的差都在 量级。
2. 改了什么
| mini-YOLOv2(第 06 篇) | mini-YOLOv3(本篇) | |
|---|---|---|
| 骨干 | “卷积 + 池化”,128 → 8×8 | 同样的“卷积 + 池化”,多一级到 4×4,取出 3 个尺度的特征 |
| 特征融合 | 无 | 自顶向下:1×1 卷积、上采样、拼接,每级再接一个 3×3 卷积 |
| 检测头 | 1 个:8×8×40 | 3 个:16×16×24、8×8×24、4×4×24 |
| 锚框 | 5 个 | 9 个,每个尺度 3 个 |
| 每张图的预测框 | ||
| 置信度的目标 | IoU | 1 |
| 损失 | 平方误差;类别用 softmax 交叉熵 | 、、置信度、类别用 BCE;、 用平方误差 |
| 参数量 | 250,552 | 1,044,952 |
| 每张图的乘加次数 | 7350 万 | 1.027 亿 |
骨干没有照搬 Darknet-53 的残差结构,而是沿用了第 06 篇的“卷积 + 池化”;第十节比较了两者。
3. 训练与结果
图 5:左:四项损失(对数坐标)。坐标一项一直停在 8.6 附近,因为 、 的 BCE 有一个不为 0 的下限。右:验证集 mAP。
第 5 轮时验证集 mAP@0.5 已达到 0.852,第 15 轮以后基本不再上升。坐标损失始终在 8.6 左右,这不是没学好:第七节说过,BCE 的目标 不是 0 或 1 时,最小值是 的二元熵。练习 9 算出,训练集上坐标损失的下限是每张图 8.571,训练结束时的 8.614 已经很接近了。
30 轮后的结果:
| 类别 | AP@0.5 | AP@0.75 | AP@[.5:.95] |
|---|---|---|---|
| 圆 | 0.936 | 0.786 | 0.648 |
| 矩形 | 0.926 | 0.731 | 0.614 |
| 三角形 | 0.935 | 0.762 | 0.636 |
| mAP | 0.932 | 0.760 | 0.632 |
按大小分:,,。和 COCO 上一样,小物体最难。F1 最大的阈值是 0.48,此时精确率 0.968,召回率 0.908。
训练集前 500 张的 mAP@0.5 是 1.000,mAP@[.5:.95] 是 0.866,与验证集的差距(0.068 和 0.234)比第 06 篇大得多。图 5 中“有物体”和“类别”两项损失降到了 0.006,网络几乎把训练集背了下来。参数从 25 万增加到 104 万,而训练数据仍然只有 3000 张,过拟合是意料之中的事。第 14 篇的数据增强就是针对这个问题的。
图 6:验证集前 8 张图的检测结果,框的颜色表示它来自哪个尺度。小物体(橙色)由步长 8 检测,大物体(蓝色)由步长 32 检测。
图 6 里几乎所有框的分数都是 1.00。这是置信度目标改为 1 的直接后果:网络只学“有没有物体”,不再估计框得准不准。第 06 篇的 mini-YOLOv2 置信度的平均值(0.856)与平均 IoU(0.868)几乎相等,在这里已经不成立了。
4. 每个尺度检测了什么
附录代码第 6 部分做了一个简单的实验:检测时只保留一个尺度的输出,看各种大小的物体还能检测出多少。
| 只保留 | mAP@[.5:.95] | |||
|---|---|---|---|---|
| 步长 8 | 0.279 | 0.485 | 0.427 | 0.000 |
| 步长 16 | 0.345 | 0.002 | 0.526 | 0.450 |
| 步长 32 | 0.252 | 0.000 | 0.059 | 0.607 |
| 3 个尺度一起 | 0.632 | 0.485 | 0.679 | 0.708 |
图 7:左:只保留一个尺度的输出时的 、、。右:验证集上分数 ≥ 0.48 的检测框的边长,按来自的尺度堆叠。
分工非常清楚:
- 小物体只由步长 8 检测:只用步长 8 时 就是 0.485,与三个尺度一起时完全相同;去掉它, 几乎为 0。
- 大物体主要由步长 32 检测(0.607),步长 16 也能检测出一部分(0.450),两者合起来是 0.708。
- 检测框的大小:来自步长 8 的 829 个框,边长中位数 13.6 像素(5.2~22.8);来自步长 16 的 705 个框,中位数 29.7 像素(19.7~45.6);来自步长 32 的 610 个框,中位数 50.7 像素(33.3~67.3)。相邻两个尺度在分界附近有重叠,与图 2 右边的训练标签一致。
十、从 mini-YOLOv2 到 mini-YOLOv3:逐项比较
附录 B 的 path_to_v3.py 在同样的数据和训练设置(30 轮,最大学习率 )下比较 6 个模型:
| 模型 | 改了什么 | mAP@0.5 | mAP@0.75 | mAP@[.5:.95] | 参数量 | 乘加(百万) | |||
|---|---|---|---|---|---|---|---|---|---|
| 0 mini-YOLOv2 | 第 06 篇的网络和损失,单尺度 | 0.882 | 0.640 | 0.572 | 0.260 | 0.587 | 0.774 | 250,552 | 73.5 |
| 1 mini-YOLOv3 | 本篇的主模型 | 0.932 | 0.760 | 0.632 | 0.485 | 0.679 | 0.708 | 1,044,952 | 102.7 |
| 2 去掉自顶向下路径 | 同 1,fpn=False | 0.938 | 0.769 | 0.639 | 0.484 | 0.693 | 0.725 | 917,720 | 87.9 |
| 3 只用一个尺度 | 同 1,9 个锚框都放在步长 16 | 0.880 | 0.615 | 0.560 | 0.265 | 0.597 | 0.785 | 1,046,488 | 102.7 |
| 4 残差骨干 | 同 1,Darknet 式骨干 | 0.928 | 0.798 | 0.658 | 0.466 | 0.718 | 0.753 | 1,556,856 | 120.0 |
| 5 学习率 | 同 1,第 05、06 篇的学习率 | 0.917 | 0.592 | 0.542 | 0.398 | 0.579 | 0.618 | 1,044,952 | 102.7 |
(实验 3 的 P3 分支仍在计算,只是不输出,所以参数量和计算量与实验 1 几乎相同。附录 B 的输出里还有每张图的推理时间,但 6 个实验是同时运行的,互相抢 CPU,这些时间不能互相比较。实验 1 是最后单独运行的,测得 mini-YOLOv3 每张图 1.83 ms;附录 A 的 4.44 ms 也是在其他实验同时运行时测的。)
图 8:6 个模型在验证集上的 mAP@[.5:.95] 与 、、。
第 06 篇的经验是,同一配置只换线程数,结果就有约 0.01 的差别,所以下面只看明显大于 0.01 的差距。
- 0 → 1:小物体的 AP 几乎翻倍。 从 0.260 升到 0.485, 从 0.587 升到 0.679,mAP@[.5:.95] 从 0.572 升到 0.632。但 从 0.774 降到了 0.708,下面再讨论。
- 3:起作用的是“在多个尺度上预测”。实验 3 用的是 mini-YOLOv3 的骨干、损失和 9 个锚框,只是全部放在步长 16 一个尺度上,结果( 0.265,mAP 0.560)几乎回到了 mini-YOLOv2。所以 0 → 1 的提升不是来自新的损失、更多的锚框或更大的网络,而是来自多尺度预测本身。
- 2:自顶向下的路径在这里没有作用。去掉它,各项指标都与实验 1 相差不到 0.02。原因在于合成数据太简单:圆、矩形、三角形的区别只在形状,浅层特征就足以分辨。练习 6 算出,P3 即使没有自顶向下的路径,感受野也有 54 像素,比所有小物体都大得多。在 COCO 上就不同了:要认出 20 像素大的一只鸟,需要深层的语义,FPN 论文报告了显著的提升。这又一次提醒我们:消融实验的结论只对做实验的数据成立。
- 4:残差骨干更好,也更贵。mAP@[.5:.95] 高 0.026,mAP@0.75 高 0.038,主要提升在中、大物体的定位精度上;代价是参数多 49%,计算量多 17%。本系列从零训练,YOLOv3 原文则先在 ImageNet 上预训练骨干,深的残差网络在那样的条件下优势更大。
- 5:学习率很重要。用第 05、06 篇的 ,mAP@0.75 从 0.760 降到 0.592,各项都明显变差。网络更大、预测框多了 3 倍(相对 mini-YOLOv2),30 轮训练里需要更大的步长才能收敛。
为什么大物体变差了? 只用步长 16 的模型(实验 0 和 3) 是 0.774 和 0.785,mini-YOLOv3 只有 0.708。有意思的是,YOLOv3 论文也有类似的观察:与其他检测器相比,YOLOv3 的 相对较高,在中、大物体上却相对较差,论文说“需要更多的研究”。本篇的数据不足以下结论,但有几个可能的原因:
- 信息只自顶向下流动。大物体由 4×4 的 P5 负责,而 P5 只来自骨干最深的 C5,没有任何高分辨率的信息流进来;要精确定位大物体的边界,粗糙的 4×4 特征不够用。只用步长 16 的模型,在 8×8 的特征上预测大物体,空间分辨率高一倍。
- 边界效应。4×4 的特征图上,16 个位置有 12 个在边上,3×3 卷积看到的很大一部分是补的 0。
- 每个尺度的训练样本变少了。大物体只训练步长 32 的那 3 个锚框,共 3983 个样本。
第一点正是 YOLOv4 采用 PANet 的理由:在自顶向下之后,再加一条自底向上的路径,把高分辨率的信息送回粗的尺度(第 08 篇)。练习 8 请你设计实验来检验这些猜测。
十一、论文里“没有用的尝试”
YOLOv3 论文有一节专门写“我们试过但没有用的东西”,这在学术论文里很少见:
- 锚框的 、 偏移:用 Faster R-CNN 的方式,以锚框宽高的倍数、线性输出预测中心偏移。模型不稳定,效果不好。第 06 篇的实验 3 验证了这一点:mAP@0.75 从 0.905 降到 0.700。
- 线性的 、:去掉 sigmoid,直接线性地预测偏移。mAP 降了几个点。
- Focal Loss:mAP 降了约 2 个点。论文的猜测是 YOLOv3 的置信度与条件类别概率是分开预测的,对大多数背景框,类别损失本来就是 0,Focal Loss 想解决的问题在这里不严重。第 10 篇会详细讲 Focal Loss。
- 双 IoU 阈值:像 Faster R-CNN 那样,IoU > 0.7 为正样本,0.3~0.7 忽略,< 0.3 为负样本。没能得到好结果。
论文还顺带提到,他们修复了 YOLOv2 中一个读数据的 bug,带来了约 2 个点的 mAP 提升。这类细节在复现时往往比模型结构的改动更要紧。
十二、YOLOv3 的成绩与局限
YOLOv3 最突出的是 AP50:608×608 输入达到 57.9,与 RetinaNet 的 57.5 相当,速度却快 3.8 倍。但在 COCO 的主指标 AP(IoU 0.5~0.95 的平均)上,它的 33.0 明显落后于 RetinaNet 的 39.1。论文的解读是,YOLOv3 擅长找到“大致对”的框,但在高 IoU 阈值下,框对得不够精确。本篇的 mini-YOLOv3 也是这样:mAP@0.5 是 0.932,mAP@0.75 只有 0.760。
论文接着质疑了 COCO 的主指标:COCO 的原始论文没有解释为什么要平均 0.5 到 0.95;而 Russakovsky 等人的研究发现,人很难分辨 IoU 为 0.3 和 0.5 的框,那么高 IoU 下的差别有多重要?这个问题至今仍有争论;第 09 篇的 IoU 损失、第 10 篇的 IoU 感知的置信度,都在设法提高高 IoU 阈值下的精度。
论文的最后一节谈到了计算机视觉研究的社会影响:检测技术可能被用于军事和侵犯隐私,研究者有责任考虑自己的工作可能带来的伤害。2020 年 2 月,Redmon 宣布出于这方面的担忧,停止了计算机视觉的研究。此后的 YOLOv4、YOLOv5 由其他团队发布(第 08 篇)。
十三、参考资料
- J. Redmon, A. Farhadi. YOLOv3: An Incremental Improvement. arXiv:1804.02767, 2018. 本篇的主要来源。
- T.-Y. Lin, P. Dollár, R. Girshick, K. He, B. Hariharan, S. Belongie. Feature Pyramid Networks for Object Detection. CVPR, 2017.(特征金字塔)
- T.-Y. Lin, P. Goyal, R. Girshick, K. He, P. Dollár. Focal Loss for Dense Object Detection. ICCV, 2017.(RetinaNet;置信度偏置的初始化;YOLOv3 论文表 3 的大部分数据来自这篇)
- W. Liu, D. Anguelov, D. Erhan, et al. SSD: Single Shot MultiBox Detector. ECCV, 2016.(在多个特征层上检测)
- K. He, X. Zhang, S. Ren, J. Sun. Deep Residual Learning for Image Recognition. CVPR, 2016.
- I. Krasin, T. Duerig, N. Alldrin, et al. OpenImages: A public dataset for large-scale multi-label and multi-class image classification, 2017.(多标签分类的动机)
- darknet 源码中的
src/yolo_layer.c和cfg/yolov3.cfg:YOLOv3 损失与网络结构的实际实现。
小结
- YOLOv2 只在 13×13 的特征图上预测,小物体的 在 COCO 上只有 5.0。YOLOv3 在步长 32、16、8 三个尺度上预测, 提高到 18.3。
- 特征金字塔:深层特征经 1×1 卷积、上采样 2 倍,与浅层特征拼接,让高分辨率的特征也有深层的语义。YOLOv3 用拼接,FPN 原文用相加。
- 9 个 k-means 锚框按面积平均分给 3 个尺度。每个物体只分给宽高 IoU 最大的那一个锚框,由它所在的尺度负责;不负责、但预测框与真实框 IoU 超过 0.5 的锚框被忽略。
- 损失:置信度目标改为 1;类别用独立的 sigmoid 和 BCE,允许多标签;darknet 对 、 实际用的是 BCE,梯度是 ,不会像平方误差那样在 sigmoid 饱和时消失;所有权重都是 1。
- Darknet-53:带残差连接、用步长 2 的卷积下采样,ImageNet 准确率与 ResNet-152 相当,速度快一倍。
- 实验:在物体大小相差 10 倍的数据上,mini-YOLOv3 的 从 mini-YOLOv2 的 0.260 升到 0.485,mAP@[.5:.95] 从 0.572 升到 0.632;只用一个尺度时提升消失,说明起作用的是多尺度预测本身。自顶向下的路径在这个简单数据上没有作用;残差骨干再好 0.026;学习率用 则明显变差。大物体的 AP 反而下降,这一点论文也观察到了,第 08 篇的 PANet 是一种解释和改进。
练习题
- 计算题:YOLOv3 在 COCO 上,输入为 416×416、608×608、320×320 时,三个尺度的输出张量形状分别是什么?每张图预测多少个框?与 416 输入的 YOLOv2 比较。
- 计算题:用 COCO 的 9 个锚框,对 416×416 图像中一个宽 40、高 70 像素、中心在 (200, 150) 的物体做 YOLOv3 的标签分配:它与 9 个锚框的宽高 IoU 各是多少?由哪个尺度、哪个格子、哪个锚框负责?、、、 的训练目标是多少?
- 推导题:(a) 推导 对 的梯度;(b) 设 ,分别在 处计算 BCE 与平方误差 对 的梯度;(c) 时 BCE 的最小值是多少?
- 计算题:mini-YOLOv3 每张图 1008 个预测框,平均 4.5 个物体。(a) 置信度的偏置为 0 时,“无物体”一项的初始损失是多少?偏置初始化为 时呢?(b) 对 416 输入的 YOLOv3(10647 个预测框)重复 (a)。(c) 偏置初始化之后,每个负责物体的“有物体”损失一开始是多少?
- 计算题:把 COCO 的 9 个锚框换算成各自尺度的格子。与本篇第五节的表比较,说明为什么本篇数据上三个尺度的锚框以格子计都在 1~2.6 之间,而 COCO 上不是。
- 计算题:本篇的“卷积 + 池化”骨干依次是:3×3 卷积,2×2 池化,3×3 卷积,2×2 池化,3×3 卷积(以上是 stem 和 C3 的第一层),2×2 池化,3×3 卷积(得到 C3);C4、C5 各是 2×2 池化 + 3×3 卷积;P5、P4、P3 各是一个 3×3 卷积。不考虑自顶向下的路径,求 C3、C4、C5 和 P3、P4、P5 的感受野。由此解释第十节实验 2 的结果。
- 思考题:YOLOv3 把置信度的目标从 IoU 改为 1。这对 NMS 保留哪个框、对 mAP@0.75 可能有什么影响?图 6 中几乎所有分数都是 1.00,说明了什么?
- 思考题:第十节列出了 mini-YOLOv3 大物体 AP 下降的三个可能原因。为每个原因设计一个实验来检验它。
- 计算题:训练结束时,坐标损失仍有每张图 8.614。设 、 两项 BCE 都已达到最小值、、 两项为 0,算出坐标损失的下限(提示:对训练集每个负责的锚框,计算 , 是二元熵)。
- 思考题:第十节实验 4 中,残差骨干比普通骨干好 0.026,但参数多 49%。如果你要在手机上部署检测器,会怎么选?如果可以用 ImageNet 预训练呢?
参考答案
- 416:52×52×255、26×26×255、13×13×255,共 个框;608:76×76×255、38×38×255、19×19×255,共 22743 个;320:40×40×255、20×20×255、10×10×255,共 6300 个。416 输入的 YOLOv2 只有 个框,YOLOv3 是它的 12.6 倍。
- 宽高 IoU 依次是 0.046、0.171、0.271、0.654、0.475、0.399、0.268、0.091、0.023,最大的是第 4 个锚框 30×61,属于步长 16、26×26 的网格。格子:列 ,行 。目标:,,,。
- (a) 见第七节第 1 小节,结果是 。(b) :,BCE 的梯度 ,平方误差的梯度 ,只有前者的 1/28;: 与 ;:, 与 。预测错得越离谱,平方误差的梯度反而越小。(c) 最小值在 处取得,等于二元熵 。
- (a) 偏置为 0:;初始化之后:。(b) ;初始化之后 107.0。(c) ,每张图约 。附录 A 第 1 轮的平均“有物体”损失是 13.842,第 1 轮里置信度已经开始上升了。偏置 。
- 步长 8:1.25×1.62、2.00×3.75、4.12×2.88;步长 16:1.88×3.81、3.88×2.81、3.69×7.44;步长 32:3.62×2.81、4.88×6.19、11.66×10.19。本篇的物体边长在 6~64 像素之间均匀分布,按面积三等分后,每一段的最大与最小边长之比只有 2~3 倍,换算成格子后自然落在 1~2.6 之间。COCO 的物体大小是长尾分布:小物体很多,大物体可以大到占满整张图(373×326 几乎就是 416 的整幅图像),最大一段的范围非常宽,所以步长 32 的锚框以格子计也很大。
- 用第 01 篇第七节的递推 、:3×3 卷积 ;池化 ,;卷积 ;池化 ,;卷积 ;池化 ,;卷积得到 C3,。C4:池化 ,,卷积 。C5:池化 ,,卷积 。P3、P4、P5 各加一个 3×3 卷积:,,。所有小物体(边长 < 16)、绝大多数中物体都远小于 P3 的 54 像素感受野,P3 已经能“看全”它们和周围的背景;而合成形状的类别只取决于轮廓,不需要更深的语义。所以自顶向下的路径带来的额外信息用处不大。
- 置信度不再反映定位质量,NMS 按分数排序时,保留下来的不一定是框得最准的那个:两个框重叠在同一个物体上、分数都接近 1 时,保留哪个几乎是随机的,被抑制的可能恰恰是 IoU 更高的框。这对 mAP@0.5 影响不大(两个框都能过 0.5),但会损害 mAP@0.75 这样的高阈值指标。图 6 中分数都是 1.00,说明网络对“有物体”非常确定,分数里几乎没有关于定位质量的信息,也说明在训练集上过拟合了(“有物体”损失只有 0.006)。第 10 篇的 IoU 感知的分类损失会把定位质量重新放进分数里。
- 示例:(1) 信息流向:加一条自底向上的路径(P3 → P4 → P5,用步长 2 的卷积下采样后拼接),看 是否回升;或者把大物体也交给 8×8 的 P4,对比只改分配、不改网络的效果。(2) 边界效应:把输入放大到 256×256(P5 变成 8×8),看 的下降是否减小;或者把 P5 的 3×3 卷积换成 1×1 卷积,去掉补零的影响。(3) 样本数:把每个物体分给多个锚框(IoU 超过阈值的都算正样本,第 08 篇 YOLOv5 的做法),增加大物体的正样本数。每个实验都应该用几个随机种子重复,因为单次运行的噪声约为 0.01。
- 训练集上逐个锚框计算,下限是每张图 8.571(每张图平均 4.50 个负责的锚框,每个约 1.905)。训练结束时的 8.614 只比下限高 0.043,说明 、 在训练集上已经几乎完全拟合。这也提醒我们:用 BCE 拟合软目标时,损失曲线的绝对值没有意义,要看它离下限还有多远。
- 手机上计算量和参数量往往是首要约束:普通骨干少 1/3 的参数、少 14% 的计算,精度只低 0.026,通常值得;也可以把省下的计算量用于更大的输入分辨率,这往往比换骨干更划算(第 06 篇第九节、第 20 篇)。如果能用 ImageNet 预训练,深的残差骨干学到的特征更通用,在复杂的真实数据上优势会更大,这正是 YOLOv3 用 Darknet-53 的原因;而且残差连接让很深的网络也能稳定训练(第 01 篇第十一节)。最终要在目标硬件上实测速度,在自己的数据上比较精度,再做决定。
附录 A:example.py
下面的代码完成本篇第五、六、九节的全部计算:数据、9 个 k-means 锚框与标签分配,训练 mini-YOLOv3,评估(包括 、、)并分析每个尺度的作用。它需要 numpy 和 PyTorch,并从 ../common/ 导入 shapes.py、boxes.py、metrics.py。在本篇文件夹里运行 python example.py 即可。运行后会生成 mini_yolov3.pt 和 history.json,make_figures.py、path_to_v3.py、verify_with_pycocotools.py 要用到它们。
"""YOLO 目标检测自学教程 07:YOLOv3——多尺度预测 —— 附录代码
1. 数据:物体大小范围更宽(6~64 像素)的合成形状数据集;9 个 k-means 锚框,按面积分给 3 个尺度;
2. 标签分配:每个物体只交给 9 个锚框中形状最接近的一个,也就决定了由哪个尺度负责;sigmoid 与 softmax;
3. mini-YOLOv3:骨干取出 3 个尺度的特征,自顶向下上采样 + 拼接成特征金字塔,3 个尺度各有检测头;
4. YOLOv3 式的损失(darknet 的做法):x、y、置信度、类别都用二元交叉熵,w、h 用平方误差,训练并评估;
5. 按物体大小分的 AP(AP_S、AP_M、AP_L,按 COCO 的规则计算);
6. 分析:只用一个尺度的输出时各类大小物体的 AP、每个尺度检测出了多大的物体、计算量与速度。
训练好的权重和训练记录保存在本文件夹的 mini_yolov3.pt、history.json。
需要:numpy、torch,以及 ../common/ 下的 shapes.py、boxes.py、metrics.py。
运行:python example.py(CPU 上约需半小时到一小时)
"""
import json
import pathlib
import sys
import time
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "common"))
from shapes import CLASS_NAMES, make_sample
from boxes import box_iou, cxcywh_to_xyxy, nms_per_class
from metrics import IOU_THRS, ap_coco, evaluate, match_class, pr_curve, summarize
IMG, C = 128, 3
STRIDES = (8, 16, 32) # 3 个尺度:16×16、8×8、4×4 的网格
MASKS = ((0, 1, 2), (3, 4, 5), (6, 7, 8)) # 9 个锚框按面积从小到大,每个尺度 3 个
N_TRAIN, N_VAL = 3000, 500
EPOCHS, BATCH, LR = 30, 64, 3e-3 # 学习率是第 05、06 篇的 3 倍,原因见第七节
SIZE_RANGE, N_RANGE = (6.0, 64.0), (1, 8)
AREA_RNG = {"S": (0, 16 ** 2), "M": (16 ** 2, 32 ** 2), "L": (32 ** 2, 1e10)} # 按面积分小、中、大物体
# ---------------- 1. 数据与锚框 ----------------
def make_data(n, seed):
rng = np.random.default_rng(seed)
imgs, gts = [], []
for _ in range(n):
img, gb, gc, _ = make_sample(rng, IMG, n_range=N_RANGE, size_range=SIZE_RANGE)
imgs.append((img * 255).round().astype(np.uint8).transpose(2, 0, 1))
gts.append((gb, gc))
return np.stack(imgs), gts
def iou_wh(wh, anchors):
"""只看宽高的 IoU(中心对齐),与第 06 篇相同。wh: (N, 2),anchors: (K, 2),返回 (N, K)。"""
inter = np.minimum(wh[:, None, 0], anchors[None, :, 0]) * np.minimum(wh[:, None, 1], anchors[None, :, 1])
return inter / (wh[:, 0:1] * wh[:, 1:2] + (anchors[:, 0] * anchors[:, 1])[None] - inter)
def kmeans_anchors(wh, k, iters=300, seed=0):
"""d = 1 - IoU 的 k-means(第 06 篇),中心取中位数,按面积从小到大返回。"""
rng = np.random.default_rng(seed)
cent = wh[rng.choice(len(wh), k, replace=False)].astype(float)
for _ in range(iters):
assign = np.argmax(iou_wh(wh, cent), 1)
new = np.array([np.median(wh[assign == j], 0) if (assign == j).any() else cent[j] for j in range(k)])
if np.allclose(new, cent):
break
cent = new
return cent[np.argsort(cent[:, 0] * cent[:, 1])]
# ---------------- 2. 标签分配 ----------------
def build_targets(gts, anchors, strides=STRIDES, masks=MASKS):
"""YOLOv3 的标签分配:每个物体只分给全部锚框中与它形状最接近(宽高 IoU 最大)的**一个**,
这个锚框属于哪个尺度,就由那个尺度上、物体中心所在的格子负责。anchors 以像素为单位 (K, 2)。
返回每个尺度的 (obj, box, cls)、每张图的真实框(归一化 cxcywh)和冲突数。
box = (x 偏移, y 偏移, ln(w/p_w), ln(h/p_h)),偏移 ∈ [0,1),配合 σ(t) 使用(第 06 篇)。"""
N = len(gts)
level_of = {a: l for l, m in enumerate(masks) for a in m}
used = [a for m in masks for a in m]
tg = []
for s, m in zip(strides, masks):
S = IMG // s
tg.append((np.zeros((N, S, S, len(m)), bool), np.zeros((N, S, S, len(m), 4), np.float32),
np.zeros((N, S, S, len(m)), np.int64)))
norm, dropped = [], 0
for n, (gb, gc) in enumerate(gts):
cxcywh = np.stack([(gb[:, 0] + gb[:, 2]) / 2, (gb[:, 1] + gb[:, 3]) / 2, gb[:, 2] - gb[:, 0], gb[:, 3] - gb[:, 1]], 1)
norm.append(cxcywh / IMG)
best = np.array(used)[np.argmax(iou_wh(cxcywh[:, 2:], anchors[used]), 1)]
for (xc, yc, w, h), a, c in zip(cxcywh, best, gc):
l = level_of[a]
s, k = strides[l], masks[l].index(a)
S = IMG // s
j, i = min(int(xc // s), S - 1), min(int(yc // s), S - 1)
obj, box, cls = tg[l]
if obj[n, i, j, k]:
dropped += 1
continue
obj[n, i, j, k] = True
box[n, i, j, k] = [xc / s - j, yc / s - i, np.log(w / anchors[a, 0]), np.log(h / anchors[a, 1])]
cls[n, i, j, k] = c
targets = [tuple(torch.tensor(t) for t in lv) for lv in tg]
return targets, [torch.tensor(g, dtype=torch.float32) for g in norm], dropped
# ---------------- 3. 网络 ----------------
def conv(ci, co, k=3, s=1):
return nn.Sequential(nn.Conv2d(ci, co, k, s, k // 2, bias=False), nn.BatchNorm2d(co), nn.LeakyReLU(0.1))
class Residual(nn.Module):
"""Darknet-53 的残差块:1×1 卷积把通道减半,3×3 卷积恢复,再与输入相加。"""
def __init__(self, c):
super().__init__()
self.f = nn.Sequential(conv(c, c // 2, 1), conv(c // 2, c, 3))
def forward(self, x):
return x + self.f(x)
class MiniYOLOv3(nn.Module):
"""骨干 + 特征金字塔 + 3 个检测头。
backbone="plain" :第 06 篇的“3×3 卷积 + 最大池化”骨干,再加一级到步长 32;
backbone="darknet":Darknet-53 的做法——下采样用步长 2 的 3×3 卷积,每次下采样后接残差块。
两种骨干都取出步长 8、16、32 的特征 C3(64 通道)、C4(128)、C5(256)。
fpn=True :自顶向下——C5 → P5;P5 经 1×1 卷积、上采样 2 倍,与 C4 拼接 → P4;P4 同样与 C3 拼接 → P3。
fpn=False:每个尺度只用骨干自己的特征(像 SSD),没有自顶向下的路径。
P5、P4、P3 各接一个 1×1 卷积输出 A×(5+C) 个通道。
heads:哪些尺度输出预测(0=步长 8,1=16,2=32);n_anchors:每个尺度的锚框数。"""
def __init__(self, fpn=True, heads=(0, 1, 2), n_anchors=3, backbone="plain"):
super().__init__()
self.fpn, self.heads, self.nA, self.no = fpn, heads, n_anchors, 5 + C
if backbone == "plain":
P = nn.MaxPool2d
self.stem = nn.Sequential(conv(3, 16), P(2), conv(16, 32), P(2)) # → 32×32(步长 4)
self.c3 = nn.Sequential(conv(32, 64), P(2), conv(64, 64)) # → 16×16(步长 8)
self.c4 = nn.Sequential(P(2), conv(64, 128)) # → 8×8(步长 16)
self.c5 = nn.Sequential(P(2), conv(128, 256)) # → 4×4(步长 32)
else:
self.stem = nn.Sequential(conv(3, 16), conv(16, 32, s=2), conv(32, 32, s=2), Residual(32))
self.c3 = nn.Sequential(conv(32, 64, s=2), Residual(64), Residual(64))
self.c4 = nn.Sequential(conv(64, 128, s=2), Residual(128), Residual(128))
self.c5 = nn.Sequential(conv(128, 256, s=2), Residual(256))
self.p5 = conv(256, 128)
self.p4 = conv(128 + (64 if fpn else 0), 128)
self.p3 = conv(64 + (64 if fpn else 0), 64)
if fpn:
self.lat5, self.lat4 = conv(128, 64, 1), conv(128, 64, 1) # 上采样之前先用 1×1 卷积减少通道
widths = {0: 64, 1: 128, 2: 128}
self.out = nn.ModuleDict({str(l): nn.Conv2d(widths[l], n_anchors * self.no, 1) for l in heads})
# 置信度的偏置初始化为 ln(π/(1-π)),π = 0.01:一开始所有框的置信度都约为 0.01。
# 否则上千个背景框的置信度都从 0.5 起步,训练初期的损失几乎全来自“无物体”这一项
with torch.no_grad():
for head in self.out.values():
head.bias.view(n_anchors, self.no)[:, 4] = float(np.log(0.01 / 0.99))
def forward(self, x):
c3 = self.c3(self.stem(x))
c4 = self.c4(c3)
c5 = self.c5(c4)
p5 = self.p5(c5)
if self.fpn:
p4 = self.p4(torch.cat([F.interpolate(self.lat5(p5), scale_factor=2, mode="nearest"), c4], 1))
p3 = self.p3(torch.cat([F.interpolate(self.lat4(p4), scale_factor=2, mode="nearest"), c3], 1))
else:
p4, p3 = self.p4(c4), self.p3(c3)
feats = {0: p3, 1: p4, 2: p5}
outs = []
for l in self.heads:
o = self.out[str(l)](feats[l])
N, _, S, _ = o.shape
outs.append(o.view(N, self.nA, self.no, S, S).permute(0, 3, 4, 1, 2)) # (N, S, S, A, 5+C)
return outs
def count_macs(model, size=IMG):
"""统计卷积层的乘加次数(每张图)。"""
total = []
def hook(m, inp, out):
total.append(out[0].numel() * m.in_channels // m.groups * m.kernel_size[0] * m.kernel_size[1])
hs = [m.register_forward_hook(hook) for m in model.modules() if isinstance(m, nn.Conv2d)]
model.eval()
with torch.no_grad():
model(torch.zeros(1, 3, size, size))
for h in hs:
h.remove()
return sum(total)
# ---------------- 4. 解码与损失 ----------------
def decode_boxes(out, anchors_cell):
"""网络输出 → 归一化 cxcywh (N,S,S,A,4):b_x = (σ(t_x) + c_x)/S,b_w = p_w·e^{t_w}/S,p_w 以本尺度的格子为单位。"""
S = out.shape[1]
jj, ii = torch.meshgrid(torch.arange(S), torch.arange(S), indexing="xy")
cx = jj[None, :, :, None] + torch.sigmoid(out[..., 0])
cy = ii[None, :, :, None] + torch.sigmoid(out[..., 1])
w = anchors_cell[:, 0] * torch.exp(out[..., 2].clamp(max=8))
h = anchors_cell[:, 1] * torch.exp(out[..., 3].clamp(max=8))
return torch.stack([cx, cy, w, h], -1) / S
def iou_cxcywh(a, b):
"""逐元素 IoU,a、b 最后一维是 cxcywh。"""
a1, a2 = a[..., :2] - a[..., 2:] / 2, a[..., :2] + a[..., 2:] / 2
b1, b2 = b[..., :2] - b[..., 2:] / 2, b[..., :2] + b[..., 2:] / 2
wh = (torch.minimum(a2, b2) - torch.maximum(a1, b1)).clamp(min=0)
inter = wh[..., 0] * wh[..., 1]
return inter / (a[..., 2] * a[..., 3] + b[..., 2] * b[..., 3] - inter + 1e-9)
def level_anchors(anchors, levels, strides=STRIDES, masks=MASKS):
"""每个输出尺度的锚框,换算成以该尺度的格子为单位。"""
return [torch.tensor(anchors[list(masks[l])] / strides[l], dtype=torch.float32) for l in levels]
def yolo_v3_loss(outs, targets, gts, anc, ignore_thr=0.5):
"""darknet(yolo_layer.c)的 YOLOv3 损失。darknet 对 σ 之后的输出直接回传“目标 − 预测”,
这恰好是二元交叉熵对 logit 的梯度,所以 x、y、置信度、类别都相当于用 BCE:
坐标:负责的锚框,(2 - w·h)·[BCE(σ(t_x), x) + BCE(σ(t_y), y) + ½(t_w - ln(w/p_w))² + ½(t_h - ln(h/p_h))²];
有物体:负责的锚框,BCE(σ(t_o), 1)——目标是 1,不再是 IoU;
无物体:其余锚框,BCE(σ(t_o), 0);如果预测框与任意真实框的 IoU > 0.5,就不计这一项;
类别:负责的锚框,每个类别各自一个 sigmoid,BCE(不用 softmax)。所有权重都是 1。"""
N = outs[0].shape[0]
G = max(len(g) for g in gts)
gpad = torch.zeros(N, max(G, 1), 4)
valid = torch.zeros(N, max(G, 1), dtype=torch.bool)
for n, g in enumerate(gts):
gpad[n, :len(g)] = g
valid[n, :len(g)] = True
bce = F.binary_cross_entropy_with_logits
terms = {"坐标": 0.0, "有物体": 0.0, "无物体": 0.0, "类别": 0.0}
for out, (obj, tbox, tcls), a in zip(outs, targets, anc):
S = out.shape[1]
with torch.no_grad():
pbox = decode_boxes(out, a)
ious = iou_cxcywh(pbox.reshape(N, -1, 1, 4), gpad[:, None])
best = (ious * valid[:, None]).max(-1).values.reshape(obj.shape)
scale = 2 - (a[:, 0] * tbox[..., 2].exp() / S) * (a[:, 1] * tbox[..., 3].exp() / S)
o = obj.float()
xy = bce(out[..., :2], tbox[..., :2], reduction="none").sum(-1)
wh = 0.5 * ((out[..., 2:4] - tbox[..., 2:4]) ** 2).sum(-1)
terms["坐标"] = terms["坐标"] + (o * scale * (xy + wh)).sum()
terms["有物体"] = terms["有物体"] + (o * bce(out[..., 4], torch.ones_like(o), reduction="none")).sum()
noobj = ((~obj) & (best < ignore_thr)).float()
terms["无物体"] = terms["无物体"] + (noobj * bce(out[..., 4], torch.zeros_like(o), reduction="none")).sum()
if obj.any():
onehot = F.one_hot(tcls[obj], C).float()
terms["类别"] = terms["类别"] + bce(out[obj][:, 5:], onehot, reduction="sum")
terms = {k: v / N for k, v in terms.items()}
return sum(terms.values()), terms
# ---------------- 5. 训练与推理 ----------------
def train(model, anchors, tr_x, tr_gt, va_x, va_gt, epochs=EPOCHS, seed=0, log=True):
"""与第 05、06 篇相同的训练循环(Adam + OneCycle,30 轮,批大小 64),只是最大学习率改为 3e-3。"""
torch.manual_seed(seed)
rng = np.random.default_rng(seed)
strides, masks = model_layout(model)
anc = level_anchors(anchors, range(len(model.heads)), strides, masks)
steps = epochs * ((len(tr_x) + BATCH - 1) // BATCH)
opt = torch.optim.Adam(model.parameters(), lr=LR)
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=LR, total_steps=steps, pct_start=0.1)
history = []
for ep in range(1, epochs + 1):
model.train()
t0 = time.time()
sums, n = {}, 0
perm = rng.permutation(len(tr_x))
for i in range(0, len(perm), BATCH):
idx = perm[i:i + BATCH]
x = torch.tensor(tr_x[idx]).float() / 255
targets, gts, _ = build_targets([tr_gt[k] for k in idx], anchors, strides, masks)
loss, terms = yolo_v3_loss(model(x), targets, gts, anc)
opt.zero_grad()
loss.backward()
opt.step()
sched.step()
for k, v in terms.items():
sums[k] = sums.get(k, 0.0) + float(v) * len(idx)
n += len(idx)
rec = {"epoch": ep, "time": time.time() - t0, **{k: v / n for k, v in sums.items()}}
rec["total"] = sum(v / n for v in sums.values())
if ep % 5 == 0 or ep == epochs:
rec["val_map50"], _, rec["val_map"] = summarize(evaluate(detect(model, anchors, va_x, va_gt)))
history.append(rec)
if log:
msg = f" 第 {ep:2d} 轮 总损失 {rec['total']:.3f}(坐标 {rec['坐标']:.3f},有物体 {rec['有物体']:.3f}," \
f"无物体 {rec['无物体']:.3f},类别 {rec['类别']:.3f})"
if "val_map" in rec:
msg += f" 验证 mAP@0.5 {rec['val_map50']:.3f} mAP@[.5:.95] {rec['val_map']:.3f}"
print(msg, flush=True)
return history
def model_layout(model):
"""输出尺度对应的步长和锚框编号。3 个尺度:每个尺度 3 个锚框;单尺度:9 个锚框都放在这个尺度上。"""
if len(model.heads) == 1:
return (STRIDES[model.heads[0]],), (tuple(range(9)),)
return tuple(STRIDES[l] for l in model.heads), tuple(MASKS[l] for l in model.heads)
def detect(model, anchors, imgs, gts, levels=None, conf_thr=0.001, nms_thr=0.5, bs=250):
"""推理 → 类别分数 = σ(t_o)·σ(类别) → 过滤 → 按类别 NMS,框换算回像素坐标。
levels 指定只用哪几个输出尺度(默认全部),用来分析每个尺度的作用。返回的字典里多一项 level:框来自哪个尺度。"""
strides, masks = model_layout(model)
anc = level_anchors(anchors, range(len(model.heads)), strides, masks)
levels = range(len(model.heads)) if levels is None else levels
model.eval()
per_img = [[] for _ in range(len(imgs))]
with torch.no_grad():
for i in range(0, len(imgs), bs):
outs = model(torch.tensor(imgs[i:i + bs]).float() / 255)
for l in levels:
out = outs[l]
b = cxcywh_to_xyxy(decode_boxes(out, anc[l]).reshape(len(out), -1, 4).numpy()) * IMG
sc = (torch.sigmoid(out[..., 4:5]) * torch.sigmoid(out[..., 5:])).reshape(len(out), -1, C).numpy()
for k in range(len(out)):
per_img[i + k].append((b[k], sc[k], np.full(len(b[k]), l)))
images = []
for parts, (gb, gc) in zip(per_img, gts):
b = np.concatenate([p[0] for p in parts])
sc_all = np.concatenate([p[1] for p in parts])
lv = np.concatenate([p[2] for p in parts])
cls, sc = sc_all.argmax(1), sc_all.max(1)
k = np.where(sc >= conf_thr)[0]
if len(k):
k = k[nms_per_class(b[k], sc[k], cls[k], nms_thr)]
images.append(dict(gt_boxes=gb, gt_classes=gc, boxes=b[k], scores=sc[k], classes=cls[k], level=lv[k]))
return images
# ---------------- 6. 按物体大小分的 AP ----------------
def match_class_area(images, c, iou_thr, lo, hi, max_dets=100):
"""COCO 的 AP_S / AP_M / AP_L:面积不在 [lo, hi] 内的真实框记为“忽略”。
规则与 pycocotools 相同:检测框优先匹配不忽略的真实框;匹配到被忽略的真实框、
或者没有匹配上而自己的面积不在范围内的检测框,都不参与统计(既不算 TP 也不算 FP)。"""
all_scores, all_tp, all_ig, n_gt = [], [], [], 0
for im in images:
g = im["gt_boxes"][im["gt_classes"] == c]
ga = (g[:, 2] - g[:, 0]) * (g[:, 3] - g[:, 1])
gig = (ga < lo) | (ga > hi)
order = np.argsort(gig, kind="mergesort") # 不忽略的真实框排在前面
g, gig = g[order], gig[order]
n_gt += int((~gig).sum())
keep = np.where(im["classes"] == c)[0]
keep = keep[np.argsort(-im["scores"][keep], kind="mergesort")][:max_dets]
d = im["boxes"][keep]
da = (d[:, 2] - d[:, 0]) * (d[:, 3] - d[:, 1])
ious = box_iou(d, g) if len(g) else np.zeros((len(keep), 0))
used = np.zeros(len(g), dtype=bool)
for k in range(len(keep)):
m, best = -1, iou_thr
for j in range(len(g)):
if used[j]:
continue
if m > -1 and not gig[m] and gig[j]:
break # 已经匹配到不忽略的真实框,不再看被忽略的
if ious[k, j] < best:
continue
m, best = j, ious[k, j]
if m > -1:
used[m] = True
all_tp.append(True)
all_ig.append(gig[m])
else:
all_tp.append(False)
all_ig.append(da[k] < lo or da[k] > hi)
all_scores.append(im["scores"][keep[k]])
scores, tp, ig = np.array(all_scores), np.array(all_tp, dtype=bool), np.array(all_ig, dtype=bool)
order = np.argsort(-scores, kind="mergesort")
scores, tp, ig = scores[order], tp[order], ig[order]
return scores[~ig], tp[~ig], n_gt
def evaluate_area(images, lo, hi, num_classes=C, iou_thrs=IOU_THRS):
"""与 metrics.evaluate 相同,但只统计面积在 [lo, hi] 内的物体。返回 AP 矩阵 (阈值数, 类别数)。"""
ap = np.full((len(iou_thrs), num_classes), np.nan)
for c in range(num_classes):
for t, thr in enumerate(iou_thrs):
_, tp, n_gt = match_class_area(images, c, thr, lo, hi)
if n_gt == 0:
continue
ap[t, c] = ap_coco(*pr_curve(tp, n_gt)) if len(tp) else 0.0
return ap
def size_aps(images):
"""AP_S、AP_M、AP_L(都是 IoU 0.5:0.95 的平均)。"""
return {k: float(np.nanmean(evaluate_area(images, lo, hi))) for k, (lo, hi) in AREA_RNG.items()}
def best_f1_threshold(images, confs=np.round(np.arange(0.02, 0.981, 0.02), 2)):
"""F1 最大的置信度阈值(第 03 篇第七节)。返回 (阈值, P, R, F1)。"""
P = np.zeros((C, len(confs)))
R = np.zeros_like(P)
for c in range(C):
scores, tp, n_gt = match_class(images, c, 0.5)
for k, t in enumerate(confs):
m = scores >= t
P[c, k] = tp[m].sum() / m.sum() if m.any() else 1.0
R[c, k] = tp[m].sum() / n_gt
P, R = P.mean(0), R.mean(0)
F1 = 2 * P * R / np.maximum(P + R, 1e-12)
k = int(np.argmax(F1))
return confs[k], P[k], R[k], F1[k]
def ms_per_image(model, imgs, reps=10):
xb = torch.tensor(imgs[:64]).float() / 255
model.eval()
with torch.no_grad():
model(xb)
t0 = time.perf_counter()
for _ in range(reps):
model(xb)
return (time.perf_counter() - t0) / reps / 64 * 1000
if __name__ == "__main__":
torch.set_num_threads(4)
print("1. 数据与锚框")
tr_x, tr_gt = make_data(N_TRAIN, seed=0)
va_x, va_gt = make_data(N_VAL, seed=1)
wh = np.concatenate([np.stack([gb[:, 2] - gb[:, 0], gb[:, 3] - gb[:, 1]], 1) for gb, _ in tr_gt])
area = wh[:, 0] * wh[:, 1]
print(f" 训练集 {N_TRAIN} 张图,共 {len(wh)} 个框;边长(面积的平方根){np.sqrt(area).min():.1f}~{np.sqrt(area).max():.1f} 像素")
va_area = np.concatenate([(gb[:, 2] - gb[:, 0]) * (gb[:, 3] - gb[:, 1]) for gb, _ in va_gt])
for k, (lo, hi) in AREA_RNG.items():
print(f" {k}(面积 {lo:.0f}~{min(hi, IMG * IMG):.0f}):训练集 {np.mean((area >= lo) & (area < hi)):.1%},"
f"验证集 {((va_area >= lo) & (va_area < hi)).sum()} 个")
anchors = kmeans_anchors(wh, 9)
print(" 9 个 k-means 锚框(宽×高,像素):" + " ".join(f"{w:.1f}×{h:.1f}" for w, h in anchors))
for s, m in zip(STRIDES, MASKS):
print(f" 步长 {s:2d}({IMG // s}×{IMG // s} 网格):锚框 {list(m)},以本尺度格子为单位 "
+ " ".join(f"{anchors[a, 0] / s:.2f}×{anchors[a, 1] / s:.2f}" for a in m))
for k in (3, 5, 9):
print(f" k = {k}:平均 IoU {iou_wh(wh, kmeans_anchors(wh, k)).max(1).mean():.3f}")
print("\n2. 标签分配")
targets, _, dropped = build_targets(tr_gt, anchors)
for (s, m), (obj, _, _) in zip(zip(STRIDES, MASKS), targets):
print(f" 步长 {s:2d} 负责 {int(obj.sum()):5d} 个物体")
a5 = kmeans_anchors(wh, 5)
_, _, dropped_v2 = build_targets(tr_gt, a5, strides=(16,), masks=(tuple(range(5)),))
print(f" 因同一格子同一锚框冲突而丢掉的物体:3 个尺度 {dropped} 个;"
f"只用步长 16 和 5 个锚框(第 06 篇的做法){dropped_v2} 个")
z = torch.tensor([3.0, 2.5, -2.0])
fmt = lambda v: "[" + ", ".join(f"{x:.3f}" for x in v.tolist()) + "]"
print(f" 多标签:logit {z.tolist()} → softmax {fmt(torch.softmax(z, 0))},sigmoid {fmt(torch.sigmoid(z))}")
print("\n3. 网络")
torch.manual_seed(0)
model = MiniYOLOv3()
outs = model.eval()(torch.zeros(1, 3, IMG, IMG))
print(" 输出形状 " + ",".join(str(tuple(o.shape)) for o in outs)
+ f";每张图 {sum(o.shape[1] * o.shape[2] * o.shape[3] for o in outs)} 个预测框")
print(f" 参数 {sum(p.numel() for p in model.parameters()):,},每张图 {count_macs(model) / 1e6:.1f} 百万次乘加")
print("\n4. 训练 mini-YOLOv3")
t0 = time.time()
history = train(model, anchors, tr_x, tr_gt, va_x, va_gt)
print(f" 训练用时 {(time.time() - t0) / 60:.1f} 分钟")
torch.save({"model": model.state_dict(), "anchors": anchors}, HERE / "mini_yolov3.pt")
(HERE / "history.json").write_text(json.dumps(history, ensure_ascii=False, indent=1), encoding="utf-8")
print("\n5. 评估(置信度阈值 0.001,NMS 0.5)")
images = detect(model, anchors, va_x, va_gt)
ap = evaluate(images)
for c, name in enumerate(CLASS_NAMES):
print(f" {name: <3} AP@0.5 = {ap[0, c]:.3f} AP@0.75 = {ap[5, c]:.3f} AP@[.5:.95] = {np.mean(ap[:, c]):.3f}")
m50, m75, m = summarize(ap)
print(f" mAP@0.5 = {m50:.3f},mAP@0.75 = {m75:.3f},mAP@[.5:.95] = {m:.3f}")
sa = size_aps(images)
print(f" AP_S = {sa['S']:.3f},AP_M = {sa['M']:.3f},AP_L = {sa['L']:.3f}")
tr_m = summarize(evaluate(detect(model, anchors, tr_x[:N_VAL], tr_gt[:N_VAL])))
print(f" 训练集前 {N_VAL} 张:mAP@0.5 = {tr_m[0]:.3f},mAP@[.5:.95] = {tr_m[2]:.3f}")
t_b, p_b, r_b, f_b = best_f1_threshold(images)
print(f" F1 最大的置信度阈值 {t_b:.2f}:精确率 {p_b:.3f},召回率 {r_b:.3f},F1 {f_b:.3f}")
print("\n6. 分析")
print(" 只保留一个尺度的输出时(AP@[.5:.95]):")
for l, s in enumerate(STRIDES):
im_l = detect(model, anchors, va_x, va_gt, levels=[l])
r, sa_l = summarize(evaluate(im_l)), size_aps(im_l)
print(f" 步长 {s:2d}:mAP = {r[2]:.3f},AP_S = {sa_l['S']:.3f},AP_M = {sa_l['M']:.3f},AP_L = {sa_l['L']:.3f}")
print(f" 置信度 ≥ {t_b:.2f} 的检测框来自哪个尺度,以及它们的边长中位数:")
lv = np.concatenate([im["level"][im["scores"] >= t_b] for im in images])
side = np.concatenate([np.sqrt((im["boxes"][:, 2] - im["boxes"][:, 0]) * (im["boxes"][:, 3] - im["boxes"][:, 1]))
[im["scores"] >= t_b] for im in images])
for l, s in enumerate(STRIDES):
print(f" 步长 {s:2d}:{(lv == l).sum():4d} 个框,边长中位数 {np.median(side[lv == l]):.1f} 像素,"
f"范围 {side[lv == l].min():.1f}~{side[lv == l].max():.1f}")
print(f" 推理速度:每张 {ms_per_image(model, va_x):.2f} ms(批大小 64)")
运行输出(用 4 个线程,同时还有附录 B 的 5 个实验在运行,所以用时偏长;训练用时与推理速度取决于机器,其余数字在同样的线程数下每次运行都相同。本篇实际运行了两次:第一次的输出中“多标签”一行打印格式不好,修改打印语句后重跑,两次的输出除这一行和用时外逐字相同):
1. 数据与锚框
训练集 3000 张图,共 13501 个框;边长(面积的平方根)6.0~64.0 像素
S(面积 0~256):训练集 29.1%,验证集 658 个
M(面积 256~1024):训练集 32.4%,验证集 758 个
L(面积 1024~16384):训练集 38.5%,验证集 869 个
9 个 k-means 锚框(宽×高,像素):7.6×7.8 11.8×12.0 17.4×18.1 22.6×28.8 30.0×24.5 32.1×41.2 43.0×34.6 46.3×59.7 60.8×48.5
步长 8(16×16 网格):锚框 [0, 1, 2],以本尺度格子为单位 0.95×0.97 1.48×1.50 2.18×2.26
步长 16(8×8 网格):锚框 [3, 4, 5],以本尺度格子为单位 1.41×1.80 1.88×1.53 2.01×2.57
步长 32(4×4 网格):锚框 [6, 7, 8],以本尺度格子为单位 1.34×1.08 1.45×1.86 1.90×1.52
k = 3:平均 IoU 0.663
k = 5:平均 IoU 0.737
k = 9:平均 IoU 0.789
2. 标签分配
步长 8 负责 5647 个物体
步长 16 负责 3869 个物体
步长 32 负责 3983 个物体
因同一格子同一锚框冲突而丢掉的物体:3 个尺度 2 个;只用步长 16 和 5 个锚框(第 06 篇的做法)13 个
多标签:logit [3.0, 2.5, -2.0] → softmax [0.620, 0.376, 0.004],sigmoid [0.953, 0.924, 0.119]
3. 网络
输出形状 (1, 16, 16, 3, 8),(1, 8, 8, 3, 8),(1, 4, 4, 3, 8);每张图 1008 个预测框
参数 1,044,952,每张图 102.7 百万次乘加
4. 训练 mini-YOLOv3
第 1 轮 总损失 46.175(坐标 12.436,有物体 13.842,无物体 11.263,类别 8.634)
第 2 轮 总损失 28.162(坐标 10.131,有物体 6.259,无物体 6.373,类别 5.399)
第 3 轮 总损失 18.401(坐标 9.694,有物体 3.636,无物体 2.892,类别 2.178)
第 4 轮 总损失 14.589(坐标 9.419,有物体 2.437,无物体 1.538,类别 1.195)
第 5 轮 总损失 13.001(坐标 9.279,有物体 1.878,无物体 1.056,类别 0.788) 验证 mAP@0.5 0.852 mAP@[.5:.95] 0.343
第 6 轮 总损失 12.100(坐标 9.194,有物体 1.532,无物体 0.794,类别 0.580)
第 7 轮 总损失 11.319(坐标 9.101,有物体 1.200,无物体 0.578,类别 0.442)
第 8 轮 总损失 10.788(坐标 9.034,有物体 0.976,无物体 0.472,类别 0.305)
第 9 轮 总损失 10.349(坐标 8.986,有物体 0.772,无物体 0.382,类别 0.209)
第 10 轮 总损失 9.990(坐标 8.948,有物体 0.584,无物体 0.307,类别 0.151) 验证 mAP@0.5 0.928 mAP@[.5:.95] 0.519
第 11 轮 总损失 9.717(坐标 8.918,有物体 0.424,无物体 0.255,类别 0.120)
第 12 轮 总损失 9.443(坐标 8.880,有物体 0.273,无物体 0.201,类别 0.089)
第 13 轮 总损失 9.244(坐标 8.842,有物体 0.180,无物体 0.166,类别 0.056)
第 14 轮 总损失 9.099(坐标 8.815,有物体 0.117,无物体 0.127,类别 0.040)
第 15 轮 总损失 8.925(坐标 8.766,有物体 0.055,无物体 0.077,类别 0.026) 验证 mAP@0.5 0.934 mAP@[.5:.95] 0.586
第 16 轮 总损失 8.826(坐标 8.723,有物体 0.031,无物体 0.055,类别 0.017)
第 17 轮 总损失 8.765(坐标 8.695,有物体 0.018,无物体 0.040,类别 0.012)
第 18 轮 总损失 8.725(坐标 8.673,有物体 0.012,无物体 0.030,类别 0.010)
第 19 轮 总损失 8.705(坐标 8.658,有物体 0.011,无物体 0.027,类别 0.009)
第 20 轮 总损失 8.692(坐标 8.650,有物体 0.009,无物体 0.025,类别 0.008) 验证 mAP@0.5 0.932 mAP@[.5:.95] 0.628
第 21 轮 总损失 8.678(坐标 8.640,有物体 0.008,无物体 0.022,类别 0.007)
第 22 轮 总损失 8.669(坐标 8.633,有物体 0.007,无物体 0.021,类别 0.007)
第 23 轮 总损失 8.662(坐标 8.628,有物体 0.007,无物体 0.020,类别 0.006)
第 24 轮 总损失 8.657(坐标 8.624,有物体 0.007,无物体 0.021,类别 0.006)
第 25 轮 总损失 8.652(坐标 8.621,有物体 0.007,无物体 0.019,类别 0.006) 验证 mAP@0.5 0.932 mAP@[.5:.95] 0.629
第 26 轮 总损失 8.652(坐标 8.619,有物体 0.006,无物体 0.021,类别 0.006)
第 27 轮 总损失 8.648(坐标 8.617,有物体 0.007,无物体 0.019,类别 0.006)
第 28 轮 总损失 8.647(坐标 8.616,有物体 0.006,无物体 0.019,类别 0.006)
第 29 轮 总损失 8.645(坐标 8.615,有物体 0.006,无物体 0.018,类别 0.006)
第 30 轮 总损失 8.644(坐标 8.614,有物体 0.006,无物体 0.018,类别 0.006) 验证 mAP@0.5 0.932 mAP@[.5:.95] 0.632
训练用时 46.0 分钟
5. 评估(置信度阈值 0.001,NMS 0.5)
圆 AP@0.5 = 0.936 AP@0.75 = 0.786 AP@[.5:.95] = 0.648
矩形 AP@0.5 = 0.926 AP@0.75 = 0.731 AP@[.5:.95] = 0.614
三角形 AP@0.5 = 0.935 AP@0.75 = 0.762 AP@[.5:.95] = 0.636
mAP@0.5 = 0.932,mAP@0.75 = 0.760,mAP@[.5:.95] = 0.632
AP_S = 0.485,AP_M = 0.679,AP_L = 0.708
训练集前 500 张:mAP@0.5 = 1.000,mAP@[.5:.95] = 0.866
F1 最大的置信度阈值 0.48:精确率 0.968,召回率 0.908,F1 0.937
6. 分析
只保留一个尺度的输出时(AP@[.5:.95]):
步长 8:mAP = 0.279,AP_S = 0.485,AP_M = 0.427,AP_L = 0.000
步长 16:mAP = 0.345,AP_S = 0.002,AP_M = 0.526,AP_L = 0.450
步长 32:mAP = 0.252,AP_S = 0.000,AP_M = 0.059,AP_L = 0.607
置信度 ≥ 0.48 的检测框来自哪个尺度,以及它们的边长中位数:
步长 8: 829 个框,边长中位数 13.6 像素,范围 5.2~22.8
步长 16: 705 个框,边长中位数 29.7 像素,范围 19.7~45.6
步长 32: 610 个框,边长中位数 50.7 像素,范围 33.3~67.3
推理速度:每张 4.44 ms(批大小 64)
附录 B:path_to_v3.py
第十节的实验代码。它导入本篇和第 06 篇的 example.py,训练 6 个模型中的 5 个(实验 1 直接读取附录 A 保存的 mini_yolov3.pt),结果写入 path_to_v3.json。
"""第 07 篇:从 mini-YOLOv2 到 mini-YOLOv3,逐项比较。
所有实验用同样的数据(训练 3000 张、验证 500 张,物体边长 6~64 像素)、同样的随机种子和训练设置(30 轮,最大学习率 3e-3)。
0 mini-YOLOv2 :第 06 篇的网络和损失原样搬来,单尺度(步长 16),5 个 k-means 锚框
1 mini-YOLOv3 :example.py 的主模型(3 个尺度 + 特征金字塔 + BCE),直接读取 mini_yolov3.pt,不重新训练
2 去掉自顶向下路径 :同 1,但每个尺度只用骨干自己的特征(fpn=False)
3 只用一个尺度 :同 1,但只在步长 16 上预测,9 个锚框都放在这个尺度
4 残差骨干 :同 1,骨干换成 Darknet-53 式的“步长 2 卷积 + 残差块”
5 学习率 1e-3 :同 1,最大学习率用第 05、06 篇的 1e-3
每个实验在 CPU 上训练约 30~60 分钟(取决于机器和同时运行的进程数)。结果写入 path_to_v3.json。
运行:python path_to_v3.py (全部;需要先运行 example.py)
python path_to_v3.py 0 3 (只跑第 0、3 个,结果写入 path_to_v3_0_3.json)
python path_to_v3.py 2 -t4 (用 4 个线程,默认 4 个,与 example.py 相同)
"""
import importlib.util
import json
import pathlib
import sys
import time
import numpy as np
import torch
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent / "common"))
import example as ex
from metrics import evaluate, summarize
# 第 06 篇的 example.py 与本篇的同名,用 importlib 按路径导入,起名 v2
_spec = importlib.util.spec_from_file_location("v2", HERE.parent / "06_YOLOv2锚框" / "example.py")
v2 = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(v2)
def v2_ms_per_image(model, imgs, reps=10):
xb = torch.tensor(imgs[:64]).float() / 255
model.eval()
with torch.no_grad():
model(xb)
t0 = time.perf_counter()
for _ in range(reps):
model(xb)
return (time.perf_counter() - t0) / reps / 64 * 1000
if __name__ == "__main__":
torch.set_num_threads(int(sys.argv[-1][2:]) if sys.argv[-1].startswith("-t") else 4)
picks = [int(a) for a in sys.argv[1:] if a.isdigit()] or list(range(6))
tr_x, tr_gt = ex.make_data(ex.N_TRAIN, seed=0)
va_x, va_gt = ex.make_data(ex.N_VAL, seed=1)
wh = np.concatenate([np.stack([gb[:, 2] - gb[:, 0], gb[:, 3] - gb[:, 1]], 1) for gb, _ in tr_gt])
anchors = ex.kmeans_anchors(wh, 9)
variants = [
("mini-YOLOv2", None),
("mini-YOLOv3", None),
("去掉自顶向下路径", dict(fpn=False)),
("只用一个尺度", dict(heads=(1,), n_anchors=9)),
("残差骨干", dict(backbone="darknet")),
("学习率 1e-3", dict(lr=1e-3)),
]
results = {}
for k in picks:
name, kw = variants[k]
t0 = time.time()
torch.manual_seed(0)
if k == 0:
a5 = v2.kmeans_anchors(wh / v2.STRIDE, 5)
v2.LR = ex.LR
model = v2.MiniYOLOv2()
v2.train(model, a5, tr_x, tr_gt, va_x, va_gt, log=False)
images = v2.detect(model, a5, va_x, va_gt)
ms = v2_ms_per_image(model, va_x)
macs = ex.count_macs(model)
else:
kw = dict(kw or {})
lr = kw.pop("lr", ex.LR)
model = ex.MiniYOLOv3(**kw)
if k == 1:
ck = torch.load(HERE / "mini_yolov3.pt", weights_only=False)
model.load_state_dict(ck["model"])
hist = json.loads((HERE / "history.json").read_text(encoding="utf-8"))
t0 -= sum(h["time"] for h in hist)
else:
ex.LR = lr
ex.train(model, anchors, tr_x, tr_gt, va_x, va_gt, log=False)
ex.LR = 3e-3
images = ex.detect(model, anchors, va_x, va_gt)
ms = ex.ms_per_image(model, va_x)
macs = ex.count_macs(model)
m50, m75, m = summarize(evaluate(images))
sa = ex.size_aps(images)
results[name] = dict(map50=float(m50), map75=float(m75), map=float(m), ap_s=sa["S"], ap_m=sa["M"], ap_l=sa["L"],
params=sum(p.numel() for p in model.parameters()), macs=macs, ms=ms,
minutes=(time.time() - t0) / 60)
r = results[name]
print(f"{name:<10}:mAP@0.5 = {m50:.3f},mAP@0.75 = {m75:.3f},mAP@[.5:.95] = {m:.3f};"
f"AP_S = {sa['S']:.3f},AP_M = {sa['M']:.3f},AP_L = {sa['L']:.3f};"
f"参数 {r['params']:,},{macs / 1e6:.1f} 百万次乘加,每张 {ms:.2f} ms,用时 {r['minutes']:.1f} 分钟", flush=True)
suffix = "" if picks == list(range(6)) else "_" + "_".join(map(str, picks))
(HERE / f"path_to_v3{suffix}.json").write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8")
运行输出(本篇把实验 0、2、3、4、5 分成 5 个进程同时运行,最后单独运行实验 1,再把 6 个 json 合并为 path_to_v3.json;同时运行的进程互相抢 CPU,用时和推理速度不能互相比较):
mini-YOLOv2:mAP@0.5 = 0.882,mAP@0.75 = 0.640,mAP@[.5:.95] = 0.572;AP_S = 0.260,AP_M = 0.587,AP_L = 0.774;参数 250,552,73.5 百万次乘加,每张 4.88 ms,用时 36.8 分钟
mini-YOLOv3:mAP@0.5 = 0.932,mAP@0.75 = 0.760,mAP@[.5:.95] = 0.632;AP_S = 0.485,AP_M = 0.679,AP_L = 0.708;参数 1,044,952,102.7 百万次乘加,每张 1.83 ms,用时 53.5 分钟
去掉自顶向下路径 :mAP@0.5 = 0.938,mAP@0.75 = 0.769,mAP@[.5:.95] = 0.639;AP_S = 0.484,AP_M = 0.693,AP_L = 0.725;参数 917,720,87.9 百万次乘加,每张 4.00 ms,用时 52.4 分钟
只用一个尺度 :mAP@0.5 = 0.880,mAP@0.75 = 0.615,mAP@[.5:.95] = 0.560;AP_S = 0.265,AP_M = 0.597,AP_L = 0.785;参数 1,046,488,102.7 百万次乘加,每张 6.33 ms,用时 49.3 分钟
残差骨干 :mAP@0.5 = 0.928,mAP@0.75 = 0.798,mAP@[.5:.95] = 0.658;AP_S = 0.466,AP_M = 0.718,AP_L = 0.753;参数 1,556,856,120.0 百万次乘加,每张 2.24 ms,用时 57.3 分钟
学习率 1e-3 :mAP@0.5 = 0.917,mAP@0.75 = 0.592,mAP@[.5:.95] = 0.542;AP_S = 0.398,AP_M = 0.579,AP_L = 0.618;参数 1,044,952,102.7 百万次乘加,每张 2.50 ms,用时 54.5 分钟
附录 C:verify_with_pycocotools.py
用 pycocotools 核对附录 A 手写的 mAP 和 、、。需要先 pip install pycocotools 并运行 example.py。
"""用 pycocotools 核对 example.py 里手写的 AP、AP_S、AP_M、AP_L(需要先运行 example.py,并 pip install pycocotools)。
把 mini-YOLOv3 在验证集上的检测结果交给 COCOeval,面积分段与 example.py 相同:S < 16²,16² ≤ M ≤ 32²,L > 32²。"""
import contextlib
import io
import pathlib
import sys
import numpy as np
import torch
from pycocotools.coco import COCO
from pycocotools.cocoeval import COCOeval
HERE = pathlib.Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, str(HERE.parent / "common"))
import example as ex
from metrics import evaluate, summarize
torch.set_num_threads(4)
ck = torch.load(HERE / "mini_yolov3.pt", weights_only=False)
model = ex.MiniYOLOv3()
model.load_state_dict(ck["model"])
va_x, va_gt = ex.make_data(ex.N_VAL, seed=1)
images = ex.detect(model, ck["anchors"], va_x, va_gt)
gt = {"images": [], "annotations": [], "categories": [{"id": c + 1} for c in range(ex.C)]}
dets = []
for i, im in enumerate(images):
gt["images"].append({"id": i + 1, "width": ex.IMG, "height": ex.IMG})
for b, c in zip(im["gt_boxes"], im["gt_classes"]):
w, h = b[2] - b[0], b[3] - b[1]
gt["annotations"].append({"id": len(gt["annotations"]) + 1, "image_id": i + 1, "category_id": int(c) + 1,
"bbox": [float(b[0]), float(b[1]), float(w), float(h)], "area": float(w * h), "iscrowd": 0})
for b, s, c in zip(im["boxes"], im["scores"], im["classes"]):
dets.append({"image_id": i + 1, "category_id": int(c) + 1, "score": float(s),
"bbox": [float(b[0]), float(b[1]), float(b[2] - b[0]), float(b[3] - b[1])]})
with contextlib.redirect_stdout(io.StringIO()):
coco = COCO()
coco.dataset = gt
coco.createIndex()
E = COCOeval(coco, coco.loadRes(dets), "bbox")
E.params.areaRng = [[0, 1e10], [0, 16 ** 2], [16 ** 2, 32 ** 2], [32 ** 2, 1e10]]
E.evaluate()
E.accumulate()
E.summarize()
ours = list(summarize(evaluate(images)))
sa = ex.size_aps(images)
ours += [sa["S"], sa["M"], sa["L"]]
names = ["mAP@[.5:.95]", "mAP@0.5", "mAP@0.75", "AP_S", "AP_M", "AP_L"]
theirs = E.stats[:6]
ours = [ours[2], ours[0], ours[1], ours[3], ours[4], ours[5]]
for n, a, b in zip(names, ours, theirs):
print(f"{n:<13} 手写 {a:.6f} pycocotools {b:.6f} 差 {abs(a - b):.1e}")
运行输出:
mAP@[.5:.95] 手写 0.632302 pycocotools 0.632302 差 1.1e-16
mAP@0.5 手写 0.932268 pycocotools 0.932268 差 0.0e+00
mAP@0.75 手写 0.759692 pycocotools 0.759692 差 0.0e+00
AP_S 手写 0.485237 pycocotools 0.485237 差 1.1e-16
AP_M 手写 0.679419 pycocotools 0.679419 差 1.1e-16
AP_L 手写 0.707609 pycocotools 0.707609 差 1.1e-16