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

系列导航 :上一篇: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 8×8×13 的训练目标 encode_targets 第 04 篇
3. 网络 图像 → 8×8×138 \times 8 \times 13 8×8×13 的预测 MiniYOLOv1 本篇(骨干来自第 01 篇)
4. 损失 预测与目标的五项损失 yolo_v1_loss 第 04 篇
5. 训练 前向、反向、更新 train 本篇(训练循环来自第 01 篇)
6. 解码 预测 → 128 个框和类别分数 decode 第 04 篇
7. 后处理 分数过滤、按类别 NMS nms_per_class 第 02 篇
8. 评估 COCO 规则的 mAP evaluate 第 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 个物体,物体的大小、宽高比、颜色、位置和背景都是随机的。

图像 物体 圆 / 矩形 / 三角形
训练集 3000 7498 2505 / 2465 / 2528
验证集 500 1225

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

两个实现上的小细节:

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

三、网络

图 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 2×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 8×8×128=8192→512→832=8×8×13。第一个全连接层后接 dropout(丢弃率 0.3)。最后一层是线性的,不加任何激活函数,输出的 13 个数按第 04 篇的布局解读为 x,y,w,h,C×2x, y, \\sqrt{w}, \\sqrt{h}, C \times 2 x,y,w ,h ,C×2 加 3 个类别概率。

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

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

四、训练

1. 训练循环

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

python 复制代码
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} 10−3,然后按余弦曲线降到接近 0。这和论文"先预热、再分段降低"的思路相同。论文用带动量的 SGD 训练 135 轮,我们的数据和网络都小得多,用 Adam 收敛更快。

2. 训练过程

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

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

轮 坐标 宽高 有物体的置信度 无物体的置信度 类别 总损失 验证 mAP@0.5
1 4.401 2.223 0.211 1.607 2.292 10.733
5 0.978 0.317 0.365 0.402 1.141 3.203 0.204
10 0.523 0.186 0.387 0.231 0.364 1.691 0.540
15 0.318 0.117 0.382 0.230 0.202 1.249 0.382
20 0.217 0.079 0.367 0.235 0.139 1.037 0.725
30 0.162 0.061 0.351 0.240 0.109 0.923 0.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 8×8×13 个数,解码成 128 个框。每个框的类别分数是置信度乘以类别概率(第 04 篇第一节),取分数最高的类别作为它的类别,然后按类别做 NMS:

python 复制代码
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 毫秒(具体取决于机器)。这就是"只看一眼"的好处:不论图中有几个物体,计算量都一样。

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

六、评估

1. mAP

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

类别 AP@0.5 AP@0.75 AP@.5:.95
圆 0.783 0.206 0.331
矩形 0.759 0.154 0.282
三角形 0.724 0.153 0.283
mAP 0.755 0.171 0.298

图 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.5 mAP@.5:.95
不做 NMS 0.647 0.277
NMS 阈值 0.3 0.768 0.294
NMS 阈值 0.5 0.755 0.298
NMS 阈值 0.7 0.699 0.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,绝大多数物体都看不见了。

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

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

七、诊断:它错在哪里

1. 错误类型

仿照第 04 篇第七节的方法,对每个类别取分数最高的前 NN N 个框( NN N 为该类物体数),归入以下几类。因为合成数据只有 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%

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

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

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

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

2. 置信度学到了什么

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

图 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 B=1),负责权就不会再来回切换,置信度应该学得更好。我们做了这个实验(只把 BB B 改成 1,其余与基准完全相同):

B=2B = 2 B=2(基准) B=1B = 1 B=1
mAP@0.5 0.755 0.814
mAP@0.75 0.171 0.193
mAP@.5:.95 0.298 0.326
负责预测器的平均置信度 0.325 0.431
置信度与 IoU 的相关系数 0.476 0.595
参数量 4,867,024 4,702,864

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

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

八、消融实验

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

实验 改动 mAP@0.5 mAP@0.75 mAP@.5:.95
基准 与 example.py 相同 0.755 0.171 0.298
不加权 λcoord=λnoobj=1 \lambda_{\text{coord}} = \lambda_{\text{noobj}} = 1 λcoord=λnoobj=1 0.736 0.098 0.248
不开平方 直接预测 w,hw, h w,h,宽高损失用 (w−w^)2 (w - \hat w)^2 (w−w^)2 0.750 0.167 0.295
置信度 = 1 有物体时置信度的目标恒为 1,而不是 IoU 0.733 0.137 0.276
卷积检测头 两个全连接层换成一个 1×1 卷积(参数 247,069) 0.957 0.526 0.525

图 9:消融实验的结果。

逐项来看:

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

(2) 预测平方根在这个数据集上几乎没有作用 (0.750 对 0.755,在单次实验的波动范围内)。这并不说明第 04 篇的分析错了:合成数据集里物体的边长只在 18~48 像素之间,最大和最小的物体只差 2.7 倍;第 04 篇的分析表明,平方根的作用是让惩罚与 1/w1/w 1/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×448 128×128
网格 S=7S = 7 S=7,每格 64 像素 S=8S = 8 S=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) (W−x2,y1,W−x1,y2) 跟着翻转。训练后比较验证集和训练集的 mAP。(提示:翻转之后再调用 batch_targets 编码,第二节说过为什么训练目标要现场编码。)
  7. 编程题:把训练轮数从 30 增加到 60,其他不变。验证集 mAP 能提高多少?训练集呢?
  8. 思考题:第八节的消融实验中,"不开平方"几乎没有影响,而"卷积检测头"的提升是压倒性的。如果把这两个实验搬到 COCO 这样的真实数据集上,你预计结论会有什么变化?

参考答案

  1. 第一个全连接层: 8192×512+512=4,194,8168192 \times 512 + 512 = 4{,}194{,}816 8192×512+512=4,194,816;第二个: 512×832+832=426,816512 \times 832 + 832 = 426{,}816 512×832+832=426,816;合计 4,621,632,与正文一致。宽度改为 256 后: 8192×256+256=2,097,4088192 \times 256 + 256 = 2{,}097{,}408 8192×256+256=2,097,408, 256×832+832=213,824256 \times 832 + 832 = 213{,}824 256×832+832=213,824,合计 2,311,232,少了一半。
  2. 前 4 个块与第 01 篇的 TinyCNN 相同,感受野到第 4 个池化层为 46,累计步长 j=16j = 16 j=16。最后一个 3×3 卷积(步长 1): r=46+(3−1)×16=78r = 46 + (3 - 1) \times 16 = 78 r=46+(3−1)×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 要用到它们。

python 复制代码
"""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")

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

text 复制代码
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。

python 复制代码
"""第 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")

运行输出:

text 复制代码
基准      :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 分钟
相关推荐
用户老李8 分钟前
视频编码自学教程 02:颜色与色彩空间——从 RGB 到 Y'CbCr 4:2:0
前端
用户老李12 分钟前
无线电自学教程 02:数学工具箱——正弦、复数、相量与分贝
前端
lpp1918 分钟前
一个网页的完整一生:从敲网址到页面显示,中间发生了什么
前端
春涧草茶2 小时前
慢就是快16-1协程
开发语言·前端·python
Oiiouui2 小时前
linux服务器部署Logto后端与对应前端的实现
linux·服务器·前端·后端
前端小D2 小时前
JS核心底层
前端·javascript
律宏阔2 小时前
Taro 4 项目从 npm 迁移到 pnpm 踩坑实录
前端·微信小程序·taro
用户30816998764852 小时前
同一句话问 AI 15 次,答案 15 个:temperature 0 实测也不稳
前端
SameX2 小时前
ArkTS 做 5 态数据流 —— 咖啡电量的代谢状态机怎么驱动 UI
前端