系列导航 :上一篇: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×13 的训练目标 | encode_targets |
第 04 篇 |
| 3. 网络 | 图像 → 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=8(每格 16 像素)、 B=2。第 04 篇统计过,在这个数据集上 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=32,达到 78 像素,足以覆盖最大 48 像素的物体。
检测头 与 YOLOv1 相同:展平后接两个全连接层, 8×8×128=8192→512→832=8×8×13。第一个全连接层后接 dropout(丢弃率 0.3)。最后一层是线性的,不加任何激活函数,输出的 13 个数按第 04 篇的布局解读为 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−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×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 篇第七节的方法,对每个类别取分数最高的前 N 个框( 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。也就是说,它们画出的框差不多。
这两个现象其实是同一个原因:
- 两个预测器从同样的随机初始化出发,学的又是同样的训练目标,所以画出的框很接近。
- 框很接近时,"谁的 IoU 更大"几乎是随机的,每次迭代都可能换人。
- 被选中负责的那个,置信度的目标是 IoU(约 0.7);没被选中的那个,置信度的目标是 0。
- 同一个预测器在不同的迭代里时而负责、时而不负责,得到互相矛盾的目标,最后只能学到两者之间的一个折中值。
这也解释了第四节的两个现象:有物体的置信度损失降不下去,无物体的置信度损失停在 0.23(那个"不负责"的预测器的置信度也降不到 0)。
如果这个解释是对的,那么每个格子只留一个预测器( B=1),负责权就不会再来回切换,置信度应该学得更好。我们做了这个实验(只把 B 改成 1,其余与基准完全相同):
| B=2(基准) | 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=1 的实验代码与 example.py 相同,只需在开头把 B = 2 改成 B = 1。)
这个发现很有启发性:两个预测器之所以没有分工,是因为它们没有任何不同的先验 。YOLOv2 的解决办法是给每个预测器一个形状不同的锚框,让它们从一开始就专门负责不同形状的物体,负责权的分配也就稳定了(第 06 篇)。
八、消融实验
第 04 篇讨论了 YOLOv1 的很多设计:两个 λ、预测平方根、置信度的目标是 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 | 0.736 | 0.098 | 0.248 |
| 不开平方 | 直接预测 w,h,宽高损失用 (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 放大了坐标损失,让网络更重视框的位置;去掉它,网络还能大致找到物体,但框得更不准了。
(2) 预测平方根在这个数据集上几乎没有作用 (0.750 对 0.755,在单次实验的波动范围内)。这并不说明第 04 篇的分析错了:合成数据集里物体的边长只在 18~48 像素之间,最大和最小的物体只差 2.7 倍;第 04 篇的分析表明,平方根的作用是让惩罚与 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=7,每格 64 像素 | S=8,每格 16 像素 |
| 骨干 | 24 个卷积层,ImageNet 预训练 | 5 个卷积层,从零训练 |
| 归一化 | 无 BN | 有 BN |
| 优化 | SGD,135 轮 | Adam,30 轮 |
| 数据增强 | 缩放、平移、HSV | 无 |
| 参数量 | 2.72 亿 | 487 万 |
尽管规模相差悬殊,两者暴露出的问题惊人地一致:定位误差是最大的错误来源,背景误检很少,物体靠近时容易出错。这说明这些问题源于 YOLOv1 的结构和损失本身,而不是数据或规模。
十、参考资料
- J. Redmon, S. Divvala, R. Girshick, A. Farhadi. You Only Look Once: Unified, Real-Time Object Detection. CVPR, 2016.
- A. Zhang, Z. C. Lipton, M. Li, A. J. Smola. Dive into Deep Learning. Cambridge University Press, 2023. 第 14 章,含一个从零训练的单阶段检测器(SSD)。
- L. N. Smith, N. Topin. Super-Convergence: Very Fast Training of Neural Networks Using Large Learning Rates. 2019.(OneCycle 学习率调度)
- 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 改用全卷积网络的直接证据。
练习题
- 计算题:mini-YOLOv1 的两个全连接层各有多少个参数?如果把第一个全连接层的宽度从 512 改为 256,检测头的参数量变为多少?
- 计算题:用第 01 篇的递推公式,验证 mini-YOLOv1 骨干最后一层的感受野是 78。
- 思考题:第四节的表格中,"有物体的置信度"这一项在第 1 轮是 0.211,到第 10 轮反而升到 0.387。为什么网络学得更好了,这一项损失却变大了?
- 思考题:第六节中,部署阈值取 0.25 时召回率只有 0.498。如果你必须用 0.25 这个固定阈值部署,有哪些办法可以提高召回率,而不需要重新设计网络?
- 编程题:把训练集从 3000 张减少到 1500 张(只用前 1500 张),其他不变,训练后在验证集和训练集前 500 张上分别计算 mAP。与基准相比,过拟合是加重还是减轻了?
- 编程题 :在训练循环里加入随机水平翻转:每个批次中随机一半的图像左右翻转,框按第 02 篇第二节的公式 (W−x2,y1,W−x1,y2) 跟着翻转。训练后比较验证集和训练集的 mAP。(提示:翻转之后再调用
batch_targets编码,第二节说过为什么训练目标要现场编码。) - 编程题:把训练轮数从 30 增加到 60,其他不变。验证集 mAP 能提高多少?训练集呢?
- 思考题:第八节的消融实验中,"不开平方"几乎没有影响,而"卷积检测头"的提升是压倒性的。如果把这两个实验搬到 COCO 这样的真实数据集上,你预计结论会有什么变化?
参考答案
- 第一个全连接层: 8192×512+512=4,194,816;第二个: 512×832+832=426,816;合计 4,621,632,与正文一致。宽度改为 256 后: 8192×256+256=2,097,408, 256×832+832=213,824,合计 2,311,232,少了一半。
- 前 4 个块与第 01 篇的 TinyCNN 相同,感受野到第 4 个池化层为 46,累计步长 j=16。最后一个 3×3 卷积(步长 1): r=46+(3−1)×16=78。
- 第 (3) 项的目标是当前 预测框与真实框的 IoU。第 1 轮时网络的框几乎是随机的,与真实框的 IoU 接近 0,目标也接近 0;网络输出的置信度也接近 0(初始化时输出都很小),所以损失很小。随着框越来越准,IoU 目标升到 0.6~0.7,而置信度没有跟着升上去(第七节的原因),差距反而拉大了。这说明损失变大不一定代表学得更差:当目标本身在变化时,要结合其他指标一起看。
- 几种不改网络结构的办法:(a) 用 F1 最大的阈值 0.16 代替 0.25 ,这是最直接的;(b) 对分数做单调变换后再用 0.25 ,比如把分数除以 0.16 再乘以 0.25。第 03 篇证明过 mAP 只取决于排序,单调变换不改变 mAP,只是让同一个"工作点"对应不同的阈值;(c) 用 B = 1 或者把置信度目标改成 1 重新训练 (见第七、八节),让分数整体变高;(d) 校准:在验证集上拟合一个把分数映射到实际精确率的函数(比如 Platt 缩放或保序回归),再在映射后的分数上设阈值。
- 运行结果(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 张图远远不够。
- 运行结果:验证集 0.833 / 0.355,训练集 0.930 / 0.519。与基准相比,验证集提高了 0.078 / 0.057,训练集反而略有下降(0.960 → 0.930),两者的差距从 0.205 缩小到 0.097。水平翻转让网络每轮看到的图像都不完全相同,相当于把训练集扩大了一倍,网络更难"背下"每张训练图,于是被迫学习更一般的规律。这就是数据增强的作用(第 14 篇详细讨论)。注意这个数据集里的形状都左右对称,翻转后物体的类别不变;如果数据里有左右不对称的类别(比如"左转标志"和"右转标志"),就不能随便做水平翻转。
- 运行结果:验证集 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 轮,网络把训练图像上的框"背"得越来越准,但这种精度大部分推广不到验证集。继续加大轮数,验证集的提升会越来越小。
- 可以预计:(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 分钟