YOLO 目标检测自学教程 05:动手——从零实现 mini-YOLOv1

0 阅读40分钟

系列导航:上一篇:04 YOLOv1——把检测变成回归 | 下一篇:06 YOLOv2——锚框与直接位置预测

本篇目标:读完本篇,你将能够——

  • 用 PyTorch 从零搭建、训练一个 YOLOv1 式的检测器,并完整跑通“数据 → 训练 → 推理 → NMS → mAP”;
  • 读懂训练过程中五项损失和验证集 mAP 的变化;
  • 用错误类型分析、置信度—IoU 散点图等工具诊断检测器的问题;
  • 设计消融实验,定量检验第 04 篇讨论过的各个设计到底有多大作用;
  • 说出 YOLOv1 在实践中暴露出的问题,以及它们怎样引出了 YOLOv2 的改进。

前置知识:第 01 篇(PyTorch、TinyCNN)、第 03 篇(mAP)、第 04 篇(YOLOv1 的编码、解码和损失函数)。

前四篇的代码都没有真正训练过一个检测器:第 00、02、03 篇用的是“模拟检测器”,第 04 篇只是在构造的例子上计算了损失。本篇把前面的零件全部组装起来,在合成形状数据集上从零训练一个小型 YOLOv1,然后像研究者那样分析它:它学会了什么,错在哪里,论文里的每个设计到底起了多大作用。

训练只需要 CPU,基准模型大约十几分钟。本篇的数字全部来自实际训练:附录代码 example.py 训练基准模型并做全部分析,ablation.py 跑消融实验。

一、整体流程

一个检测器从数据到评估,要经过下面这些步骤,每一步都对应前面某一篇的内容:

步骤做什么用到的函数出处
1. 数据生成图像和真实框make_sample第 00 篇
2. 编码把真实框变成 8×8×138 \times 8 \times 13 的训练目标encode_targets第 04 篇
3. 网络图像 → 8×8×138 \times 8 \times 13 的预测MiniYOLOv1本篇(骨干来自第 01 篇)
4. 损失预测与目标的五项损失yolo_v1_loss第 04 篇
5. 训练前向、反向、更新train本篇(训练循环来自第 01 篇)
6. 解码预测 → 128 个框和类别分数decode第 04 篇
7. 后处理分数过滤、按类别 NMSnms_per_class第 02 篇
8. 评估COCO 规则的 mAPevaluate第 03 篇

为了不重复代码,前几篇中反复用到的函数已经原样放进了 common/ 文件夹:shapes.py(数据)、boxes.py(框与 NMS)、yolov1.py(编码、解码、损失)、metrics.py(mAP)。它们与对应篇目附录里的代码逐字相同,metrics.py 的结果在第 03 篇已经用 COCO 官方的 pycocotools 验证过。本篇的附录代码只包含新写的部分。

二、数据

训练集 3000 张、验证集 500 张,都由第 00 篇的 make_sample 生成(随机种子分别为 0 和 1):128×128 的图像,每张 1~4 个物体,物体的大小、宽高比、颜色、位置和背景都是随机的。

图像物体圆 / 矩形 / 三角形
训练集300074982505 / 2465 / 2528
验证集5001225

网格取 S=8S = 8(每格 16 像素)、B=2B = 2。第 04 篇统计过,在这个数据集上 S=8S = 8 时几乎不会出现两个物体中心落进同一个格子的情况;附录代码确认,训练集的 7498 个物体中,因格子冲突而在训练目标里丢掉的个数是 0。

两个实现上的小细节:

  • 3000 张 128×128×3 的图像如果存成 float32 要占 590 MB,所以图像存成 uint8(0~255),每个批次用到时再除以 255 转成浮点数,内存降为四分之一。
  • 训练目标不预先算好,而是每个批次现场调用 encode_targets 编码。这里没有数据增强,所以两种做法结果相同;但以后加入随机翻转、缩放等增强时(第 14 篇),框会随图像一起变,目标必须现场编码。

三、网络

fig1_architecture.png

图 1:mini-YOLOv1。骨干沿用第 01 篇 TinyCNN 的结构,检测头沿用 YOLOv1 的两个全连接层。

骨干沿用第 01 篇的 TinyCNN:4 个“3×3 卷积 → BN → 激活 → 2×2 最大池化”块,把 128×128 降到 8×8,通道数依次为 16、32、64、128;之后再加一个不降采样的 3×3 卷积块。激活函数按 YOLOv1 的做法换成斜率 0.1 的 Leaky ReLU。由第 01 篇的递推公式,4 个块之后的感受野是 46,最后那个 3×3 卷积再加 2×16=322 \times 16 = 32,达到 78 像素,足以覆盖最大 48 像素的物体。

检测头与 YOLOv1 相同:展平后接两个全连接层,8×8×128=8192→512→832=8×8×138 \times 8 \times 128 = 8192 \to 512 \to 832 = 8 \times 8 \times 13。第一个全连接层后接 dropout(丢弃率 0.3)。最后一层是线性的,不加任何激活函数,输出的 13 个数按第 04 篇的布局解读为 [x,y,w,h,C]×2[x, y, \sqrt{w}, \sqrt{h}, C] \times 2 加 3 个类别概率。

部分参数量占比
骨干(5 个卷积块)245,3925.0%
检测头(两个全连接层)4,621,63295.0%
合计4,867,024

和第 04 篇的完整 YOLOv1(全连接层占 77.9%)相比,这里全连接层的占比更高,因为骨干很小。后面会看到,这 460 万个参数是过拟合的主要来源。

四、训练

1. 训练循环

训练循环就是第 01 篇的五步,只是损失换成了第 04 篇的 yolo_v1_loss:

for i in range(0, len(perm), BATCH):
    idx = perm[i:i + BATCH]
    x = torch.tensor(tr_x[idx]).float() / 255                      # uint8 → [0, 1]
    obj, box, cls = batch_targets([tr_gt[k] for k in idx])         # 现场编码训练目标
    loss, terms, _, _ = yolo_v1_loss(model(x), obj, box, cls, B)   # 第 04 篇的五项损失
    opt.zero_grad()
    loss.backward()
    opt.step()
    sched.step()

优化器用 Adam,批量 64,训练 30 轮。学习率用 OneCycle 调度:前 10% 的步数从很小升到 10−310^{-3},然后按余弦曲线降到接近 0。这和论文“先预热、再分段降低”的思路相同。论文用带动量的 SGD 训练 135 轮,我们的数据和网络都小得多,用 Adam 收敛更快。

2. 训练过程

fig2_training.png

图 2:左:五项损失(已乘 λ)随训练轮数的变化。右:验证集 mAP,每 5 轮评估一次。

节选几轮的损失(每张图的平均值,已乘 λ):

轮坐标宽高有物体的置信度无物体的置信度类别总损失验证 mAP@0.5
14.4012.2230.2111.6072.29210.733
50.9780.3170.3650.4021.1413.2030.204
100.5230.1860.3870.2310.3641.6910.540
150.3180.1170.3820.2300.2021.2490.382
200.2170.0790.3670.2350.1391.0370.725
300.1620.0610.3510.2400.1090.9230.755

从这张表可以读出几件事:

  • 坐标、宽高、类别三项一直在下降,30 轮后分别降到第 1 轮的 4%、3%、5%。网络确实在学习框在哪里、是什么类别。
  • 无物体的置信度很快降到 0.23 左右就不动了。第 04 篇说过,这一项一开始是最大的;网络很快学会把绝大多数空格子的置信度压到接近 0。剩下的 0.23 主要来自有物体格子里那个“不负责”的预测器,下文会解释为什么它降不下去。
  • 有物体的置信度反而从 0.21 升到 0.35~0.39,之后几乎不降。第 1 轮时预测框还很差,与真实框的 IoU 接近 0,置信度的目标也接近 0,这一项自然很小;框变准以后,目标 IoU 变大,而网络预测的置信度没有跟上。这是一个值得追查的异常,第七节会找到原因。
  • 验证 mAP 在第 15 轮掉到了 0.382,之后又回升到 0.7 以上。训练早期学习率最高,参数变化剧烈,BN 的滑动统计量跟不上(第 01 篇第十三节遇到过同样的现象),验证结果波动很大。所以不要只看某一轮的结果。

30 轮训练在 8 线程的 CPU 上用了 10~13 分钟(附录输出是 10.7 分钟;同时运行其他实验时会更慢)。

五、推理与后处理

推理时,网络对每张图输出 8×8×138 \times 8 \times 13 个数,解码成 128 个框。每个框的类别分数是置信度乘以类别概率(第 04 篇第一节),取分数最高的类别作为它的类别,然后按类别做 NMS:

def to_detections(pred, gts, conf_thr=0.001, nms_thr=0.5, use_nms=True):
    images = []
    for p, (gb, gc) in zip(pred, gts):
        boxes, confs, scores = decode(p, S, B, C, IMG)      # 128 个框,每个框 3 个类别分数
        cls, sc = scores.argmax(1), scores.max(1)
        k = np.where(sc >= conf_thr)[0]
        if use_nms and len(k):
            k = k[nms_per_class(boxes[k], sc[k], cls[k], nms_thr)]
        images.append(dict(gt_boxes=gb, gt_classes=gc, boxes=boxes[k], scores=sc[k], classes=cls[k]))
    return images

评估 mAP 时用很低的分数阈值 0.001(第 03 篇第六节:阈值越低,AP 越不会被低估)。

推理非常快:附录代码在 CPU 上测得网络前向传播单张约 2.2 毫秒,批量 64 时平均每张约 1.1 毫秒(具体取决于机器)。这就是“只看一眼”的好处:不论图中有几个物体,计算量都一样。

fig3_postprocess.png

图 3:一张验证图像的后处理过程。①:全部 128 个框;②:类别分数不低于 0.16(F1 最大的阈值,见第六节)的框;③:NMS 之后。

六、评估

1. mAP

验证集上的结果(分数阈值 0.001,NMS 阈值 0.5,COCO 规则):

类别AP@0.5AP@0.75AP@[.5:.95]
圆0.7830.2060.331
矩形0.7590.1540.282
三角形0.7240.1530.283
mAP0.7550.1710.298

fig4_pr_iou.png

图 4:左:三个类别的 PR 曲线。右:AP 随 IoU 阈值的变化。

两个现象:

(1) 定位不准。mAP@0.5 有 0.755,mAP@0.75 却只有 0.171。图 4 右边,AP 随 IoU 阈值几乎直线下降,到 0.85 时已接近 0。第 03 篇第六节用模拟检测器做过同样的图:抖动 12% 的模拟检测器在 0.75 处还有 0.43。我们的网络框得比那还要粗糙。这与论文的结论一致:定位误差是 YOLOv1 最大的弱点。

(2) 过拟合。训练集前 500 张图上的 mAP@0.5 是 0.960,mAP@[.5:.95] 是 0.619,比验证集分别高 0.205 和 0.321。网络在训练集上框得准得多,说明它“记住”了训练图像,而没有学到能推广的规律。全连接检测头的 460 万个参数是嫌疑最大的地方,第八节的消融实验会验证这一点。

2. NMS 的作用

用同一个训练好的网络,只改变后处理:

后处理mAP@0.5mAP@[.5:.95]
不做 NMS0.6470.277
NMS 阈值 0.30.7680.294
NMS 阈值 0.50.7550.298
NMS 阈值 0.70.6990.291

NMS 把 mAP@0.5 提高了 0.108。论文说 NMS 给 YOLOv1 带来 2~3 个百分点,我们的提升更大,因为我们的网络有更多的重复框(第七节)。阈值 0.3 时 mAP@0.5 略高于 0.5,说明这个网络的重复框之间的 IoU 常常在 0.3~0.5 之间(比如一个大框里套着一个小框),阈值 0.5 删不掉它们。

3. 部署时的阈值

按第 03 篇第七节的方法选部署阈值,F1 最大的置信度阈值只有 0.16,此时精确率 0.755、召回率 0.733、F1 0.744。如果按推理时常用的 0.25,召回率会掉到 0.498;用 0.5,召回率只剩 0.113,绝大多数物体都看不见了。

fig5_predictions.png

图 5:验证集前 8 张图像的检测结果(类别分数 ≥ 0.16,NMS 之后)。

能看出几个问题:正确的检测分数大多只有 0.2~0.5;有些物体被检测了两次(验证图像 2 的圆、图像 5 的矩形、图像 6 下方的椭圆),其中一个是套在里面的小框;小三角形(图像 6、7)没有被检测到。最佳阈值低到 0.16,说明网络的置信度普遍偏低。这又指向第四节那个异常:有物体时的置信度没有学好。

七、诊断:它错在哪里

1. 错误类型

仿照第 04 篇第七节的方法,对每个类别取分数最高的前 NN 个框(NN 为该类物体数),归入以下几类。因为合成数据只有 3 个互不相似的类别,把论文的“相似类别”和“其他类别”合并为“类别错误”;另外单独统计“重复检测”:IoU ≥ 0.5、类别正确,但那个物体已经被一个分数更高的框匹配过了。

正确定位误差重复检测类别错误背景误检
mini-YOLOv1(形状数据集)74.4%18.0%6.9%0.7%0.0%
YOLOv1(VOC 2007,论文)65.5%19.0%—10.75%4.75%

fig6_errors.png

图 6:mini-YOLOv1 与论文中 YOLOv1 的错误类型。

我们的小模型重现了论文的主要发现:定位误差是最大的错误来源(18.0%,与论文的 19.0% 非常接近),背景误检极少(0.0%)。类别错误几乎为 0,是因为三种形状很好区分;VOC 的 20 个类别里有很多相似的(猫和狗、自行车和摩托车)。另外有 6.9% 是重复检测,它们本该被 NMS 去掉,但重复框与主框的 IoU 不到 0.5。

fig7_failures.png

图 7:错误最多的 4 张验证图像。

错误最多的图像都有一个共同点:物体靠得很近或者互相遮挡。这时网络会画出夹在两个物体之间的框、同一物体的多个重叠框,或者干脆漏掉被遮挡的那个。这与第 04 篇第八节分析的 YOLOv1 的空间约束一致。

2. 置信度学到了什么

第 04 篇说,置信度的目标是预测框与真实框的 IoU。那么训练好之后,置信度真的等于 IoU 吗?对验证集的每个物体,找出负责它的预测器,比较这个预测器的置信度和它的 IoU:

fig8_conf_and_specialization.png

图 8:左:负责预测器的置信度与它的实际 IoU。右:两个预测器各自负责的物体的宽高比分布。

  • 平均置信度只有 0.325,而平均 IoU 是 0.676;两者的相关系数是 0.476。几乎所有点都在虚线(置信度 = IoU)下方:置信度被系统性地低估了一半。
  • 两个预测器负责的物体比例是 55.7% 和 44.3%,负责的物体宽高比中位数分别是 1.00 和 0.98,分布几乎重合:两个预测器没有分工。

论文说,“IoU 最大者负责”的规则会让预测器逐渐专门化。但在我们的实验里,这件事没有发生。附录代码还统计了有物体的格子里两个预测器的框之间的 IoU:中位数 0.513。也就是说,它们画出的框差不多。

这两个现象其实是同一个原因:

  1. 两个预测器从同样的随机初始化出发,学的又是同样的训练目标,所以画出的框很接近。
  2. 框很接近时,“谁的 IoU 更大”几乎是随机的,每次迭代都可能换人。
  3. 被选中负责的那个,置信度的目标是 IoU(约 0.7);没被选中的那个,置信度的目标是 0。
  4. 同一个预测器在不同的迭代里时而负责、时而不负责,得到互相矛盾的目标,最后只能学到两者之间的一个折中值。

这也解释了第四节的两个现象:有物体的置信度损失降不下去,无物体的置信度损失停在 0.23(那个“不负责”的预测器的置信度也降不到 0)。

如果这个解释是对的,那么每个格子只留一个预测器(B=1B = 1),负责权就不会再来回切换,置信度应该学得更好。我们做了这个实验(只把 BB 改成 1,其余与基准完全相同):

B=2B = 2(基准)B=1B = 1
mAP@0.50.7550.814
mAP@0.750.1710.193
mAP@[.5:.95]0.2980.326
负责预测器的平均置信度0.3250.431
置信度与 IoU 的相关系数0.4760.595
参数量4,867,0244,702,864

每个格子少一个预测器,反而全面变好:mAP@0.5 提高了 0.059,置信度更接近 IoU,与 IoU 的相关性也更强。这支持上面的解释。不过置信度仍然只有 IoU 的六成多,说明负责权的切换只是原因之一。其他可能的原因(这里没有逐一验证)包括:置信度的目标 IoU 本身随着框的变化而不断变动,用平方误差回归这样一个有噪声的目标,预测容易向中间值收缩;全连接层把所有格子的特征混在一起,也让每个格子的预测更难精确。(B=1B = 1 的实验代码与 example.py 相同,只需在开头把 B = 2 改成 B = 1。)

这个发现很有启发性:两个预测器之所以没有分工,是因为它们没有任何不同的先验。YOLOv2 的解决办法是给每个预测器一个形状不同的锚框,让它们从一开始就专门负责不同形状的物体,负责权的分配也就稳定了(第 06 篇)。

八、消融实验

第 04 篇讨论了 YOLOv1 的很多设计:两个 λ\lambda、预测平方根、置信度的目标是 IoU。它们到底有多大作用?最可靠的回答方法是消融实验(ablation study):每次只改动一处,其余(数据、随机种子、训练轮数)完全不变,比较结果。ablation.py 做了 5 组实验:

实验改动mAP@0.5mAP@0.75mAP@[.5:.95]
基准与 example.py 相同0.7550.1710.298
不加权λcoord=λnoobj=1\lambda_{\text{coord}} = \lambda_{\text{noobj}} = 10.7360.0980.248
不开平方直接预测 w,hw, h,宽高损失用 (w−w^)2(w - \hat w)^20.7500.1670.295
置信度 = 1有物体时置信度的目标恒为 1,而不是 IoU0.7330.1370.276
卷积检测头两个全连接层换成一个 1×1 卷积(参数 247,069)0.9570.5260.525

fig9_ablation.png

图 9:消融实验的结果。

逐项来看:

(1) 两个 λ 有用,主要作用在定位上。去掉权重后 mAP@0.5 只降了 0.019,mAP@0.75 却从 0.171 降到 0.098,几乎减半。λcoord=5\lambda_{\text{coord}} = 5 放大了坐标损失,让网络更重视框的位置;去掉它,网络还能大致找到物体,但框得更不准了。

(2) 预测平方根在这个数据集上几乎没有作用(0.750 对 0.755,在单次实验的波动范围内)。这并不说明第 04 篇的分析错了:合成数据集里物体的边长只在 18~48 像素之间,最大和最小的物体只差 2.7 倍;第 04 篇的分析表明,平方根的作用是让惩罚与 1/w1/w 成正比,物体大小变化不大时,加不加平方根差别就很小。在物体大小相差几十倍的真实数据(比如 VOC、COCO)上,它的作用会大得多。消融实验的结论只对做实验的那个数据集成立。

(3) 置信度目标改成 1,mAP 反而下降了(0.733 对 0.755)。按第七节的分析,把目标从 IoU 改成 1 应该能让置信度变高;但置信度一旦不再反映框的准确程度,排序时就无法把“框得准的”排在“框得不准的”前面,AP 尤其是高 IoU 阈值下的 AP 会下降(mAP@0.75:0.137 对 0.171)。这正是第 04 篇练习 4 预言的结果。

(4) 卷积检测头的提升是压倒性的:参数量只有基准的 1/20(24.7 万对 487 万),mAP@0.5 从 0.755 升到 0.957,mAP@[.5:.95] 从 0.298 升到 0.525。原因正是第 04 篇练习 8 的分析:

  • 全连接层有 460 万个参数,而训练集只有 3000 张图,很容易过拟合;
  • 更根本的是,全连接层破坏了卷积的平移等变性(第 01 篇)。每个格子的输出都由不同的权重算出,在左上角学会的“看到三角形就输出一个框”,不能直接用到右下角,网络必须在每个位置分别学一遍;
  • 1×1 卷积检测头在每个格子上用同一组权重,从该位置的特征直接算出预测。一个位置学到的东西自动推广到所有位置,同一组权重在每张图上都能从 64 个位置得到训练信号。

这也是 YOLOv2 去掉全连接层、改用全卷积网络的原因之一(第 06 篇)。从 YOLOv2 开始,所有的 YOLO 都是全卷积的。

关于消融实验,还要提醒一点:这里每组只训练了一次。不同的随机种子会带来零点几到一两个百分点的波动,所以相差很小的两组(比如相差 0.01)不能说明谁更好。严肃的研究会用多个随机种子重复实验,报告平均值和标准差。

九、与论文中的 YOLOv1 的差别

我们的 mini-YOLOv1 在结构和损失上忠实于论文,但规模和训练设置差别很大:

论文的 YOLOv1本篇的 mini-YOLOv1
数据VOC 2007+2012,约 1.6 万张真实照片,20 类3000 张合成图像,3 类
输入448×448128×128
网格S=7S = 7,每格 64 像素S=8S = 8,每格 16 像素
骨干24 个卷积层,ImageNet 预训练5 个卷积层,从零训练
归一化无 BN有 BN
优化SGD,135 轮Adam,30 轮
数据增强缩放、平移、HSV无
参数量2.72 亿487 万

尽管规模相差悬殊,两者暴露出的问题惊人地一致:定位误差是最大的错误来源,背景误检很少,物体靠近时容易出错。这说明这些问题源于 YOLOv1 的结构和损失本身,而不是数据或规模。

十、参考资料

  1. J. Redmon, S. Divvala, R. Girshick, A. Farhadi. You Only Look Once: Unified, Real-Time Object Detection. CVPR, 2016.
  2. A. Zhang, Z. C. Lipton, M. Li, A. J. Smola. Dive into Deep Learning. Cambridge University Press, 2023. 第 14 章,含一个从零训练的单阶段检测器(SSD)。
  3. L. N. Smith, N. Topin. Super-Convergence: Very Fast Training of Neural Networks Using Large Learning Rates. 2019.(OneCycle 学习率调度)
  4. A. Karpathy. A Recipe for Training Neural Networks. 2019. 一篇关于如何调试和诊断神经网络训练的经典博客文章,本篇第七节的诊断思路与之一致。

小结

  • 把前几篇的零件组装起来:make_sample → encode_targets → MiniYOLOv1 → yolo_v1_loss → decode → nms_per_class → evaluate,就是一个完整的检测器。
  • mini-YOLOv1 的骨干是第 01 篇的 TinyCNN,检测头是 YOLOv1 的两个全连接层,共 487 万个参数,其中 95% 在检测头。在 CPU 上训练 30 轮约 11 分钟。
  • 验证集 mAP@0.5 = 0.755,但 mAP@0.75 只有 0.171:能找到物体,框得不准。训练集 mAP@0.5 高达 0.960,过拟合明显。NMS 把 mAP@0.5 从 0.647 提高到 0.755。
  • 错误分析重现了论文的结论:定位误差是最大的错误来源(18.0%),背景误检极少。
  • 置信度被系统性地低估(平均 0.325,实际 IoU 0.676),两个预测器也没有分工。原因是两个预测器没有不同的先验,负责权在它们之间来回切换,置信度得到互相矛盾的目标。这引出了 YOLOv2 的锚框。
  • 消融实验:去掉两个 λ 主要损害定位(mAP@0.75 从 0.171 降到 0.098);在大小变化不大的合成数据上,预测平方根几乎没有作用;置信度目标改为 1 会降低 mAP;把全连接检测头换成 1×1 卷积,参数减为 1/20,mAP@0.5 从 0.755 升到 0.957,这是 YOLOv2 改用全卷积网络的直接证据。

练习题

  1. 计算题:mini-YOLOv1 的两个全连接层各有多少个参数?如果把第一个全连接层的宽度从 512 改为 256,检测头的参数量变为多少?
  2. 计算题:用第 01 篇的递推公式,验证 mini-YOLOv1 骨干最后一层的感受野是 78。
  3. 思考题:第四节的表格中,“有物体的置信度”这一项在第 1 轮是 0.211,到第 10 轮反而升到 0.387。为什么网络学得更好了,这一项损失却变大了?
  4. 思考题:第六节中,部署阈值取 0.25 时召回率只有 0.498。如果你必须用 0.25 这个固定阈值部署,有哪些办法可以提高召回率,而不需要重新设计网络?
  5. 编程题:把训练集从 3000 张减少到 1500 张(只用前 1500 张),其他不变,训练后在验证集和训练集前 500 张上分别计算 mAP。与基准相比,过拟合是加重还是减轻了?
  6. 编程题:在训练循环里加入随机水平翻转:每个批次中随机一半的图像左右翻转,框按第 02 篇第二节的公式 (W−x2,y1,W−x1,y2)(W - x_2, y_1, W - x_1, y_2) 跟着翻转。训练后比较验证集和训练集的 mAP。(提示:翻转之后再调用 batch_targets 编码,第二节说过为什么训练目标要现场编码。)
  7. 编程题:把训练轮数从 30 增加到 60,其他不变。验证集 mAP 能提高多少?训练集呢?
  8. 思考题:第八节的消融实验中,“不开平方”几乎没有影响,而“卷积检测头”的提升是压倒性的。如果把这两个实验搬到 COCO 这样的真实数据集上,你预计结论会有什么变化?

参考答案

  1. 第一个全连接层:8192×512+512=4,194,8168192 \times 512 + 512 = 4{,}194{,}816;第二个:512×832+832=426,816512 \times 832 + 832 = 426{,}816;合计 4,621,632,与正文一致。宽度改为 256 后:8192×256+256=2,097,4088192 \times 256 + 256 = 2{,}097{,}408,256×832+832=213,824256 \times 832 + 832 = 213{,}824,合计 2,311,232,少了一半。
  2. 前 4 个块与第 01 篇的 TinyCNN 相同,感受野到第 4 个池化层为 46,累计步长 j=16j = 16。最后一个 3×3 卷积(步长 1):r=46+(3−1)×16=78r = 46 + (3 - 1) \times 16 = 78。
  3. 第 (3) 项的目标是当前预测框与真实框的 IoU。第 1 轮时网络的框几乎是随机的,与真实框的 IoU 接近 0,目标也接近 0;网络输出的置信度也接近 0(初始化时输出都很小),所以损失很小。随着框越来越准,IoU 目标升到 0.6~0.7,而置信度没有跟着升上去(第七节的原因),差距反而拉大了。这说明损失变大不一定代表学得更差:当目标本身在变化时,要结合其他指标一起看。
  4. 几种不改网络结构的办法:(a) 用 F1 最大的阈值 0.16 代替 0.25,这是最直接的;(b) 对分数做单调变换后再用 0.25,比如把分数除以 0.16 再乘以 0.25。第 03 篇证明过 mAP 只取决于排序,单调变换不改变 mAP,只是让同一个“工作点”对应不同的阈值;(c) 用 B = 1 或者把置信度目标改成 1 重新训练(见第七、八节),让分数整体变高;(d) 校准:在验证集上拟合一个把分数映射到实际精确率的函数(比如 Platt 缩放或保序回归),再在映射后的分数上设阈值。
  5. 运行结果(mAP@0.5 / mAP@[.5:.95]):验证集 0.491 / 0.161,训练集前 500 张 0.941 / 0.581。基准的验证集是 0.755 / 0.298,训练集是 0.960 / 0.619。训练集上几乎没变,验证集却大幅下降,两者的差距从 0.205 扩大到 0.450:数据越少,过拟合越严重。这个模型有 487 万个参数,1500 张图远远不够。
  6. 运行结果:验证集 0.833 / 0.355,训练集 0.930 / 0.519。与基准相比,验证集提高了 0.078 / 0.057,训练集反而略有下降(0.960 → 0.930),两者的差距从 0.205 缩小到 0.097。水平翻转让网络每轮看到的图像都不完全相同,相当于把训练集扩大了一倍,网络更难“背下”每张训练图,于是被迫学习更一般的规律。这就是数据增强的作用(第 14 篇详细讨论)。注意这个数据集里的形状都左右对称,翻转后物体的类别不变;如果数据里有左右不对称的类别(比如“左转标志”和“右转标志”),就不能随便做水平翻转。
  7. 运行结果:验证集 0.803 / 0.327,训练集 0.989 / 0.737,用时约 20 分钟(与其他实验同时运行,比单独运行慢)。验证集 mAP@0.5 比 30 轮提高了 0.048,说明 30 轮还没有完全收敛。训练集上的提升集中在定位精度上:mAP@[.5:.95] 从 0.619 升到 0.737,而验证集只从 0.298 升到 0.327。所以 mAP@0.5 的训练—验证差距从 0.205 略缩小到 0.186,mAP@[.5:.95] 的差距却从 0.321 扩大到 0.410:多训练的那 30 轮,网络把训练图像上的框“背”得越来越准,但这种精度大部分推广不到验证集。继续加大轮数,验证集的提升会越来越小。
  8. 可以预计:(a) 预测平方根的作用会变大。COCO 里物体的边长从十几像素到几百像素,相差几十倍,平方根让小物体的宽高误差受到更重的惩罚,对小物体的定位有明显帮助。合成数据集里物体只差 2.7 倍,看不出这个作用。(b) 卷积检测头的优势仍然存在,但提升的幅度很可能没有这么夸张。真实数据集更大(COCO 有 11.8 万张训练图像),全连接层的过拟合会轻一些;而且真实场景中物体与上下文的关系更复杂,全连接层“看全图”的能力(第 04 篇练习 8)有一定价值。不过平移等变性带来的数据效率优势仍然很大,这正是 YOLOv2 以后都改用全卷积网络的原因。这个练习说明:消融实验的结论只对做实验的那个数据集和那个规模成立,推广到别处之前需要重新验证。

附录 A:example.py

下面的代码训练基准模型,并完成本篇第二至七节的全部分析。它需要 numpy 和 PyTorch,并从 ../common/ 导入 shapes.py、boxes.py、yolov1.py、metrics.py。保持本系列的文件夹结构,在本篇文件夹里运行 python example.py 即可,CPU 上约需 15 分钟。运行后会在本文件夹里生成 mini_yolov1.pt(训练好的权重)和 history.json(训练记录),make_figures.py 和 make_cover.py 要用到它们。

"""YOLO 目标检测自学教程 05:动手从零实现 mini-YOLOv1 —— 附录代码

在合成形状数据集上完整跑通“数据 → 网络 → 训练 → 推理 → NMS → mAP → 误差分析”:
  1. 生成训练集与验证集,编码成 YOLOv1 的训练目标;
  2. 搭建 mini-YOLOv1:TinyCNN(第 01 篇)式的骨干 + YOLOv1 式的全连接检测头;
  3. 用第 04 篇的损失函数训练,记录每一项损失和验证集 mAP;
  4. 推理、NMS、按 COCO 规则计算 mAP(第 03 篇);
  5. 分析:NMS 的作用、错误类型、置信度与 IoU 的关系、两个预测器的分工、推理速度。
训练好的权重和训练记录保存在本文件夹的 mini_yolov1.pt、history.json 中,make_figures.py 会用到它们。
需要:numpy、torch,以及 ../common/ 下的 shapes.py、boxes.py、yolov1.py、metrics.py。
运行:python example.py(CPU 上约需 15 分钟)
"""
import json
import pathlib
import sys
import time

import numpy as np
import torch
import torch.nn as nn

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, nms_per_class
from yolov1 import decode, encode_targets, yolo_v1_loss
from metrics import evaluate, match_class, summarize

S, B, C, IMG = 8, 2, 3, 128          # 8×8 网格,每格 16 像素;每格 2 个预测器;3 个类别
N_TRAIN, N_VAL = 3000, 500
EPOCHS, BATCH, LR = 30, 64, 1e-3


# ---------------- 1. 数据 ----------------
def make_data(n, seed):
    """生成 n 张图。图像存成 uint8(省内存),真实框和类别另存。"""
    rng = np.random.default_rng(seed)
    imgs, gts = [], []
    for _ in range(n):
        img, gb, gc, _ = make_sample(rng, IMG)
        imgs.append((img * 255).round().astype(np.uint8).transpose(2, 0, 1))
        gts.append((gb, gc))
    return np.stack(imgs), gts


def batch_targets(gts):
    """把一批图像的真实框编码成 (obj, box, cls) 三个张量。"""
    enc = [encode_targets(gb, gc, S, C, IMG) for gb, gc in gts]
    obj = torch.tensor(np.array([e[0] for e in enc]))
    box = torch.tensor(np.array([e[1] for e in enc]), dtype=torch.float32)
    cls = torch.tensor(np.array([e[2] for e in enc]), dtype=torch.float32)
    return obj, box, cls


# ---------------- 2. 网络 ----------------
class MiniYOLOv1(nn.Module):
    """骨干:4 个“卷积-BN-LeakyReLU-池化”块(128 → 8)+ 1 个不降采样的卷积块;
    检测头:与 YOLOv1 相同的两个全连接层,输出 S×S×(5B+C),最后一层是线性的。"""

    def __init__(self, widths=(16, 32, 64, 128), fc_dim=512, dropout=0.3):
        super().__init__()
        layers, c_in = [], 3
        for c_out in widths:
            layers += [nn.Conv2d(c_in, c_out, 3, padding=1, bias=False), nn.BatchNorm2d(c_out),
                       nn.LeakyReLU(0.1), nn.MaxPool2d(2)]
            c_in = c_out
        layers += [nn.Conv2d(c_in, c_in, 3, padding=1, bias=False), nn.BatchNorm2d(c_in), nn.LeakyReLU(0.1)]
        self.features = nn.Sequential(*layers)
        self.head = nn.Sequential(nn.Flatten(), nn.Linear(c_in * S * S, fc_dim), nn.LeakyReLU(0.1),
                                  nn.Dropout(dropout), nn.Linear(fc_dim, S * S * (5 * B + C)))

    def forward(self, x):
        return self.head(self.features(x)).view(-1, S, S, 5 * B + C)


# ---------------- 3. 训练 ----------------
def predict(model, imgs, bs=250):
    model.eval()
    out = []
    with torch.no_grad():
        for i in range(0, len(imgs), bs):
            out.append(model(torch.tensor(imgs[i:i + bs]).float() / 255).numpy())
    return np.concatenate(out)


def to_detections(pred, gts, conf_thr=0.001, nms_thr=0.5, use_nms=True):
    """解码 → 每个框取类别分数最高的类别 → 分数过滤 → 按类别 NMS。返回 evaluate() 需要的列表。"""
    images = []
    for p, (gb, gc) in zip(pred, gts):
        boxes, confs, scores = decode(p, S, B, C, IMG)
        cls, sc = scores.argmax(1), scores.max(1)
        k = np.where(sc >= conf_thr)[0]
        if use_nms and len(k):
            k = k[nms_per_class(boxes[k], sc[k], cls[k], nms_thr)]
        images.append(dict(gt_boxes=gb, gt_classes=gc, boxes=boxes[k], scores=sc[k], classes=cls[k]))
    return images


def train(model, tr_x, tr_gt, va_x, va_gt, epochs=EPOCHS, loss_fn=yolo_v1_loss, seed=0, log=True):
    """Adam + OneCycle 学习率(先升后降,与论文“先预热再分段下降”的思路相同)。返回训练记录。"""
    torch.manual_seed(seed)
    rng = np.random.default_rng(seed)
    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
            obj, box, cls = batch_targets([tr_gt[k] for k in idx])
            loss, terms, _, _ = loss_fn(model(x), obj, box, cls, B)
            opt.zero_grad()
            loss.backward()
            opt.step()
            sched.step()
            for k, v in terms.items():
                sums[k] = sums.get(k, 0.0) + v.item() * 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(to_detections(predict(model, va_x), va_gt)))
        history.append(rec)
        if log:
            msg = f"   第 {ep:2d} 轮  总损失 {rec['total']:.3f}(坐标 {rec['坐标 x,y']:.3f},宽高 {rec['宽高 √w,√h']:.3f}," \
                  f"有物体 {rec['有物体的置信度']:.3f},无物体 {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


# ---------------- 4. 分析 ----------------
def error_breakdown(images, num_classes=C):
    """仿第 04 篇的误差分析:每个类别取分数最高的前 N 个框(N = 该类物体数),按 IoU 和类别归类。"""
    counts = {"正确": 0, "定位误差": 0, "重复检测": 0, "类别错误": 0, "背景误检": 0}
    for c in range(num_classes):
        dets = [(im["scores"][k], i, k) for i, im in enumerate(images) for k in np.where(im["classes"] == c)[0]]
        n_gt = sum(int((im["gt_classes"] == c).sum()) for im in images)
        dets.sort(key=lambda t: -t[0])
        used = set()
        for _, i, k in dets[:n_gt]:
            im = images[i]
            ious = box_iou(im["boxes"][k], im["gt_boxes"])[0] if len(im["gt_boxes"]) else np.zeros(0)
            same = im["gt_classes"] == c
            best_same = ious[same].max() if same.any() else 0.0
            j = np.where(same)[0][np.argmax(ious[same])] if same.any() else -1
            if best_same >= 0.5:
                key = (i, int(j))
                counts["重复检测" if key in used else "正确"] += 1
                used.add(key)
            elif best_same > 0.1:
                counts["定位误差"] += 1
            elif (ious > 0.1).any():
                counts["类别错误"] += 1
            else:
                counts["背景误检"] += 1
    total = sum(counts.values())
    return {k: v / total for k, v in counts.items()}


def best_f1_threshold(images, confs=np.round(np.arange(0.02, 0.981, 0.02), 2), num_classes=C):
    """第 03 篇第七节的方法:在各个置信度阈值上算各类平均的 P、R、F1,返回 F1 最大的 (阈值, P, R, F1)。"""
    P = np.zeros((num_classes, len(confs)))
    R = np.zeros_like(P)
    for c in range(num_classes):
        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 pr_at(images, t, num_classes=C):
    """固定置信度阈值 t 时,各类平均的精确率和召回率(IoU 阈值 0.5)。"""
    P, R = [], []
    for c in range(num_classes):
        scores, tp, n_gt = match_class(images, c, 0.5)
        m = scores >= t
        P.append(tp[m].sum() / m.sum() if m.any() else 1.0)
        R.append(tp[m].sum() / n_gt)
    return float(np.mean(P)), float(np.mean(R))


def predictor_overlap(pred, gts):
    """有物体的格子里,两个预测器画出的框之间的 IoU(B = 2 时)。"""
    ious = []
    for p, (gb, gc) in zip(pred, gts):
        boxes, _, _ = decode(p, S, B, C, IMG)
        obj, _, _, _ = encode_targets(gb, gc, S, C, IMG)
        for i, j in zip(*np.where(obj)):
            k = (i * S + j) * B
            ious.append(box_iou(boxes[k], boxes[k + 1])[0, 0])
    return np.array(ious)


def responsible_stats(pred, gts):
    """在验证集上,对每个真实物体找出“负责”的预测器(与真实框 IoU 最大者),
    返回 (负责预测器的编号, 物体宽高比 w/h, 该预测器的置信度, 该预测器与真实框的 IoU)。"""
    rows = []
    for p, (gb, gc) in zip(pred, gts):
        boxes, confs, _ = decode(p, S, B, C, IMG)
        obj, _, _, _ = encode_targets(gb, gc, S, C, IMG)
        for b_ in gb:
            xc, yc = (b_[0] + b_[2]) / 2, (b_[1] + b_[3]) / 2
            i, j = min(int(yc // (IMG / S)), S - 1), min(int(xc // (IMG / S)), S - 1)
            ks = [(i * S + j) * B + t for t in range(B)]
            ious = box_iou(boxes[ks], b_)[:, 0]
            t = int(np.argmax(ious))
            rows.append((t, (b_[2] - b_[0]) / (b_[3] - b_[1]), confs[ks[t]], ious[t]))
    return np.array(rows)


if __name__ == "__main__":
    torch.set_num_threads(8)

    # ---------------- 1. 数据 ----------------
    print("1. 数据")
    t0 = time.time()
    tr_x, tr_gt = make_data(N_TRAIN, seed=0)
    va_x, va_gt = make_data(N_VAL, seed=1)
    n_obj = sum(len(gb) for gb, _ in tr_gt)
    cls_cnt = np.bincount(np.concatenate([gc for _, gc in tr_gt]), minlength=C)
    lost = sum(encode_targets(gb, gc, S, C, IMG)[3] for gb, gc in tr_gt)
    print(f"   训练集 {N_TRAIN} 张、{n_obj} 个物体(各类 {cls_cnt.tolist()}),验证集 {N_VAL} 张、"
          f"{sum(len(gb) for gb, _ in va_gt)} 个物体;生成用时 {time.time() - t0:.1f} s")
    print(f"   训练集中因“两个中心落进同一格子”而在训练目标里丢掉的物体:{lost} 个")

    # ---------------- 2. 网络 ----------------
    print("\n2. 网络")
    torch.manual_seed(0)
    model = MiniYOLOv1()
    n_par = sum(p.numel() for p in model.parameters())
    n_fc = sum(p.numel() for p in model.head.parameters())
    out = model.eval()(torch.zeros(1, 3, IMG, IMG))
    print(f"   输出形状 {tuple(out.shape)};参数 {n_par:,},其中全连接检测头 {n_fc:,}({n_fc / n_par:.1%})")

    # ---------------- 3. 训练 ----------------
    print(f"\n3. 训练:{EPOCHS} 轮,批量 {BATCH},Adam,最大学习率 {LR},λ_coord = 5,λ_noobj = 0.5")
    t0 = time.time()
    history = train(model, tr_x, tr_gt, va_x, va_gt)
    print(f"   训练用时 {(time.time() - t0) / 60:.1f} 分钟")
    torch.save(model.state_dict(), HERE / "mini_yolov1.pt")
    (HERE / "history.json").write_text(json.dumps(history, ensure_ascii=False, indent=1), encoding="utf-8")

    # ---------------- 4. 评估 ----------------
    print("\n4. 验证集评估(置信度阈值 0.001,NMS 0.5,COCO 规则)")
    va_pred = predict(model, va_x)
    images = to_detections(va_pred, 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}")
    tr_m = summarize(evaluate(to_detections(predict(model, 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}")

    # ---------------- 5. 分析 ----------------
    print("\n5. 分析")
    no_nms = summarize(evaluate(to_detections(va_pred, va_gt, use_nms=False)))
    print(f"   不做 NMS:mAP@0.5 = {no_nms[0]:.3f},mAP@[.5:.95] = {no_nms[2]:.3f}")
    for thr in (0.3, 0.7):
        r = summarize(evaluate(to_detections(va_pred, va_gt, nms_thr=thr)))
        print(f"   NMS 阈值 {thr}:mAP@0.5 = {r[0]:.3f},mAP@[.5:.95] = {r[2]:.3f}")
    t_best, p_best, r_best, f_best = best_f1_threshold(images)
    print(f"   F1 最大的置信度阈值 {t_best:.2f}:精确率 {p_best:.3f},召回率 {r_best:.3f},F1 {f_best:.3f}")
    for t in (0.25, 0.5):
        p_, r_ = pr_at(images, t)
        print(f"   置信度阈值 {t}:精确率 {p_:.3f},召回率 {r_:.3f}")
    eb = error_breakdown(images)
    print("   错误类型(每类取分数最高的前 N 个框):" + ",".join(f"{k} {v:.1%}" for k, v in eb.items()))
    rs = responsible_stats(va_pred, va_gt)
    r_ci = np.corrcoef(rs[:, 2], rs[:, 3])[0, 1]
    print(f"   负责预测器的置信度与它的 IoU:平均置信度 {rs[:, 2].mean():.3f},平均 IoU {rs[:, 3].mean():.3f},"
          f"相关系数 {r_ci:.3f}")
    for t in range(B):
        m_ = rs[:, 0] == t
        print(f"   预测器 {t} 负责了 {m_.mean():.1%} 的物体,这些物体的宽高比 w/h 中位数 {np.median(rs[m_, 1]):.2f}")
    if B == 2:
        ov = predictor_overlap(va_pred, va_gt)
        print(f"   有物体的格子里,两个预测器的框之间的 IoU:中位数 {np.median(ov):.3f},平均 {ov.mean():.3f}")
    x1 = torch.tensor(va_x[:1]).float() / 255
    xb = torch.tensor(va_x[:64]).float() / 255
    model.eval()
    with torch.no_grad():
        for _ in range(5):
            model(xb)
        t0 = time.perf_counter()
        for _ in range(20):
            model(xb)
        t_b = (time.perf_counter() - t0) / 20 / 64
        t0 = time.perf_counter()
        for _ in range(100):
            model(x1)
        t_1 = (time.perf_counter() - t0) / 100
    print(f"   CPU 推理速度:单张 {t_1 * 1000:.2f} ms({1 / t_1:.0f} 张/秒),批量 64 时每张 {t_b * 1000:.2f} ms")

运行输出(训练用时与推理速度取决于机器,其余数字每次运行都相同):

1. 数据
   训练集 3000 张、7498 个物体(各类 [2505, 2465, 2528]),验证集 500 张、1225 个物体;生成用时 27.5 s
   训练集中因“两个中心落进同一格子”而在训练目标里丢掉的物体:0 个

2. 网络
   输出形状 (1, 8, 8, 13);参数 4,867,024,其中全连接检测头 4,621,632(95.0%)

3. 训练:30 轮,批量 64,Adam,最大学习率 0.001,λ_coord = 5,λ_noobj = 0.5
   第  1 轮  总损失 10.733(坐标 4.401,宽高 2.223,有物体 0.211,无物体 1.607,类别 2.292)
   第  2 轮  总损失 5.318(坐标 2.052,宽高 0.437,有物体 0.355,无物体 0.756,类别 1.719)
   第  3 轮  总损失 4.213(坐标 1.428,宽高 0.318,有物体 0.382,无物体 0.515,类别 1.570)
   第  4 轮  总损失 3.736(坐标 1.156,宽高 0.332,有物体 0.372,无物体 0.484,类别 1.392)
   第  5 轮  总损失 3.203(坐标 0.978,宽高 0.317,有物体 0.365,无物体 0.402,类别 1.141)  验证 mAP@0.5 0.204  mAP@[.5:.95] 0.052
   第  6 轮  总损失 2.763(坐标 0.852,宽高 0.276,有物体 0.377,无物体 0.332,类别 0.926)
   第  7 轮  总损失 2.373(坐标 0.737,宽高 0.250,有物体 0.379,无物体 0.291,类别 0.717)
   第  8 轮  总损失 2.088(坐标 0.668,宽高 0.237,有物体 0.368,无物体 0.262,类别 0.553)
   第  9 轮  总损失 1.882(坐标 0.604,宽高 0.215,有物体 0.382,无物体 0.240,类别 0.440)
   第 10 轮  总损失 1.691(坐标 0.523,宽高 0.186,有物体 0.387,无物体 0.231,类别 0.364)  验证 mAP@0.5 0.540  mAP@[.5:.95] 0.162
   第 11 轮  总损失 1.551(坐标 0.463,宽高 0.158,有物体 0.394,无物体 0.227,类别 0.309)
   第 12 轮  总损失 1.442(坐标 0.417,宽高 0.147,有物体 0.384,无物体 0.229,类别 0.265)
   第 13 轮  总损失 1.387(坐标 0.391,宽高 0.141,有物体 0.386,无物体 0.224,类别 0.245)
   第 14 轮  总损失 1.310(坐标 0.352,宽高 0.123,有物体 0.390,无物体 0.228,类别 0.216)
   第 15 轮  总损失 1.249(坐标 0.318,宽高 0.117,有物体 0.382,无物体 0.230,类别 0.202)  验证 mAP@0.5 0.382  mAP@[.5:.95] 0.102
   第 16 轮  总损失 1.212(坐标 0.307,宽高 0.105,有物体 0.386,无物体 0.228,类别 0.187)
   第 17 轮  总损失 1.151(坐标 0.275,宽高 0.098,有物体 0.376,无物体 0.232,类别 0.170)
   第 18 轮  总损失 1.115(坐标 0.251,宽高 0.094,有物体 0.376,无物体 0.234,类别 0.160)
   第 19 轮  总损失 1.076(坐标 0.238,宽高 0.088,有物体 0.366,无物体 0.235,类别 0.150)
   第 20 轮  总损失 1.037(坐标 0.217,宽高 0.079,有物体 0.367,无物体 0.235,类别 0.139)  验证 mAP@0.5 0.725  mAP@[.5:.95] 0.258
   第 21 轮  总损失 1.023(坐标 0.213,宽高 0.077,有物体 0.361,无物体 0.238,类别 0.133)
   第 22 轮  总损失 1.000(坐标 0.202,宽高 0.074,有物体 0.356,无物体 0.237,类别 0.131)
   第 23 轮  总损失 0.979(坐标 0.187,宽高 0.068,有物体 0.363,无物体 0.239,类别 0.122)
   第 24 轮  总损失 0.969(坐标 0.185,宽高 0.067,有物体 0.357,无物体 0.240,类别 0.120)
   第 25 轮  总损失 0.955(坐标 0.179,宽高 0.064,有物体 0.354,无物体 0.241,类别 0.117)  验证 mAP@0.5 0.757  mAP@[.5:.95] 0.295
   第 26 轮  总损失 0.934(坐标 0.169,宽高 0.063,有物体 0.352,无物体 0.239,类别 0.112)
   第 27 轮  总损失 0.933(坐标 0.171,宽高 0.063,有物体 0.346,无物体 0.241,类别 0.113)
   第 28 轮  总损失 0.930(坐标 0.166,宽高 0.063,有物体 0.351,无物体 0.238,类别 0.112)
   第 29 轮  总损失 0.929(坐标 0.166,宽高 0.063,有物体 0.350,无物体 0.241,类别 0.110)
   第 30 轮  总损失 0.923(坐标 0.162,宽高 0.061,有物体 0.351,无物体 0.240,类别 0.109)  验证 mAP@0.5 0.755  mAP@[.5:.95] 0.298
   训练用时 10.7 分钟

4. 验证集评估(置信度阈值 0.001,NMS 0.5,COCO 规则)
   圆    AP@0.5 = 0.783  AP@0.75 = 0.206  AP@[.5:.95] = 0.331
   矩形   AP@0.5 = 0.759  AP@0.75 = 0.154  AP@[.5:.95] = 0.282
   三角形  AP@0.5 = 0.724  AP@0.75 = 0.153  AP@[.5:.95] = 0.283
   mAP@0.5 = 0.755,mAP@0.75 = 0.171,mAP@[.5:.95] = 0.298
   作为对照,训练集前 500 张:mAP@0.5 = 0.960,mAP@[.5:.95] = 0.619

5. 分析
   不做 NMS:mAP@0.5 = 0.647,mAP@[.5:.95] = 0.277
   NMS 阈值 0.3:mAP@0.5 = 0.768,mAP@[.5:.95] = 0.294
   NMS 阈值 0.7:mAP@0.5 = 0.699,mAP@[.5:.95] = 0.291
   F1 最大的置信度阈值 0.16:精确率 0.755,召回率 0.733,F1 0.744
   置信度阈值 0.25:精确率 0.886,召回率 0.498
   置信度阈值 0.5:精确率 0.988,召回率 0.113
   错误类型(每类取分数最高的前 N 个框):正确 74.4%,定位误差 18.0%,重复检测 6.9%,类别错误 0.7%,背景误检 0.0%
   负责预测器的置信度与它的 IoU:平均置信度 0.325,平均 IoU 0.676,相关系数 0.476
   预测器 0 负责了 55.7% 的物体,这些物体的宽高比 w/h 中位数 1.00
   预测器 1 负责了 44.3% 的物体,这些物体的宽高比 w/h 中位数 0.98
   有物体的格子里,两个预测器的框之间的 IoU:中位数 0.513,平均 0.439
   CPU 推理速度:单张 2.22 ms(451 张/秒),批量 64 时每张 1.09 ms

附录 B:ablation.py

消融实验的代码。它导入 example.py 的数据、网络和训练函数,只改动消融的那一处。5 组实验在 CPU 上共需约一小时,结果写入 ablation.json。

"""第 05 篇:消融实验。逐一改动第 04 篇讨论过的设计,在同样的数据、同样的随机种子下各训练一次,比较验证集 mAP。

  基准      :与 example.py 完全相同(全连接检测头、λ_coord = 5、λ_noobj = 0.5、预测平方根、置信度目标 = IoU)
  不加权    :λ_coord = λ_noobj = 1
  不开平方  :直接预测 w、h,宽高损失用 (w - ŵ)²
  置信度 = 1:有物体时置信度的目标恒为 1,而不是 IoU
  卷积检测头:去掉两个全连接层,换成一个 1×1 卷积(全卷积网络,见第 06 篇)
每个实验训练约 7 分钟(CPU,8 线程),全部跑完约 40 分钟。结果写入 ablation.json。
运行:python ablation.py
"""
import json
import pathlib
import sys
import time

import numpy as np
import torch
import torch.nn as nn

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
from yolov1 import iou_cxcywh_torch

S, B, C, IMG = ex.S, ex.B, ex.C, ex.IMG


def loss_variant(pred, obj, tbox, tcls, B, l_coord=5.0, l_noobj=0.5, sqrt=True, conf_target="iou"):
    """与 yolov1.yolo_v1_loss 相同,但可以关掉“预测平方根”、把置信度目标换成 1。"""
    N, S_ = pred.shape[0], pred.shape[1]
    p = pred[..., :5 * B].reshape(N, S_, S_, B, 5)
    jj, ii = torch.meshgrid(torch.arange(S_), torch.arange(S_), indexing="xy")
    wh = p[..., 2:4] ** 2 if sqrt else p[..., 2:4].clamp(min=1e-6)
    pbox = torch.stack([(jj[..., None] + p[..., 0]) / S_, (ii[..., None] + p[..., 1]) / S_, wh[..., 0], wh[..., 1]], -1)
    gbox = torch.stack([(jj + tbox[..., 0]) / S_, (ii + tbox[..., 1]) / S_, tbox[..., 2], tbox[..., 3]], -1)
    iou = iou_cxcywh_torch(pbox, gbox[..., None, :].expand_as(pbox))
    resp = torch.zeros_like(iou, dtype=torch.bool)
    resp.scatter_(-1, iou.argmax(-1, keepdim=True), True)
    resp &= obj[..., None]
    r = resp.float()
    tw, th = tbox[..., None, 2], tbox[..., None, 3]
    if sqrt:
        size = (p[..., 2] - tw.sqrt()) ** 2 + (p[..., 3] - th.sqrt()) ** 2
    else:
        size = (p[..., 2] - tw) ** 2 + (p[..., 3] - th) ** 2
    target = iou.detach() if conf_target == "iou" else torch.ones_like(iou)
    terms = {
        "坐标 x,y": l_coord * (r * ((p[..., 0] - tbox[..., None, 0]) ** 2 + (p[..., 1] - tbox[..., None, 1]) ** 2)).sum(),
        "宽高 √w,√h": l_coord * (r * size).sum(),
        "有物体的置信度": (r * (p[..., 4] - target) ** 2).sum(),
        "无物体的置信度": l_noobj * ((1 - r) * p[..., 4] ** 2).sum(),
        "类别": (obj[..., None].float() * (pred[..., 5 * B:] - tcls) ** 2).sum(),
    }
    terms = {k: v / N for k, v in terms.items()}
    return sum(terms.values()), terms, resp, iou


class ConvHeadYOLO(ex.MiniYOLOv1):
    """与 MiniYOLOv1 相同的骨干,检测头换成 1×1 卷积:每个格子的输出只由该位置的特征算出。"""

    def __init__(self):
        super().__init__()
        self.head = nn.Conv2d(128, 5 * B + C, 1)

    def forward(self, x):
        return self.head(self.features(x)).permute(0, 2, 3, 1)


def to_detections_nosqrt(pred, gts, conf_thr=0.001, nms_thr=0.5):
    """“不开平方”变体的解码:网络直接输出 w、h。把它们开平方后交给标准的 decode()。"""
    q = pred.copy()
    for b in range(B):
        q[..., 5 * b + 2:5 * b + 4] = np.sqrt(np.clip(q[..., 5 * b + 2:5 * b + 4], 0, None))
    return ex.to_detections(q, gts, conf_thr, nms_thr)


VARIANTS = [
    ("基准", ex.MiniYOLOv1, dict()),
    ("不加权", ex.MiniYOLOv1, dict(l_coord=1.0, l_noobj=1.0)),
    ("不开平方", ex.MiniYOLOv1, dict(sqrt=False)),
    ("置信度 = 1", ex.MiniYOLOv1, dict(conf_target="one")),
    ("卷积检测头", ConvHeadYOLO, dict()),
]

if __name__ == "__main__":
    torch.set_num_threads(8)
    tr_x, tr_gt = ex.make_data(ex.N_TRAIN, seed=0)
    va_x, va_gt = ex.make_data(ex.N_VAL, seed=1)
    results = {}
    for name, cls, kw in VARIANTS:
        t0 = time.time()
        torch.manual_seed(0)
        model = cls()
        lf = lambda p, o, b, c, B_, _kw=kw: loss_variant(p, o, b, c, B_, **_kw)
        hist = ex.train(model, tr_x, tr_gt, va_x, va_gt, loss_fn=lf, log=False)
        pred = ex.predict(model, va_x)
        dets = to_detections_nosqrt(pred, va_gt) if kw.get("sqrt") is False else ex.to_detections(pred, va_gt)
        m50, m75, m = summarize(evaluate(dets))
        results[name] = dict(map50=m50, map75=m75, map=m, params=sum(p.numel() for p in model.parameters()),
                             curve=[h.get("val_map50") for h in hist], minutes=(time.time() - t0) / 60)
        print(f"{name:<8}:mAP@0.5 = {m50:.3f},mAP@0.75 = {m75:.3f},mAP@[.5:.95] = {m:.3f},"
              f"参数 {results[name]['params']:,},用时 {results[name]['minutes']:.1f} 分钟", flush=True)
    (HERE / "ablation.json").write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8")

运行输出:

基准      :mAP@0.5 = 0.755,mAP@0.75 = 0.171,mAP@[.5:.95] = 0.298,参数 4,867,024,用时 13.4 分钟
不加权     :mAP@0.5 = 0.736,mAP@0.75 = 0.098,mAP@[.5:.95] = 0.248,参数 4,867,024,用时 10.8 分钟
不开平方    :mAP@0.5 = 0.750,mAP@0.75 = 0.167,mAP@[.5:.95] = 0.295,参数 4,867,024,用时 10.9 分钟
置信度 = 1 :mAP@0.5 = 0.733,mAP@0.75 = 0.137,mAP@[.5:.95] = 0.276,参数 4,867,024,用时 12.5 分钟
卷积检测头   :mAP@0.5 = 0.957,mAP@0.75 = 0.526,mAP@[.5:.95] = 0.525,参数 247,069,用时 10.5 分钟