1. 先把问题想清楚:为什么值得在 PyTorch 里手写一遍 YOLO
“YOLO 目标检测模型在 PyTorch 中的实现”这个题目,乍看像课程实验的例行作业,但真从零跑通一遍,你会发现它检验的是目标检测整条流水线:标注文件怎么解析、网格怎么划分、损失函数每一项到底在惩罚什么、后处理怎么把几百个重叠框压到只剩几个。我第一次做这个实验时以为两三天能搞定,结果在坐标系转换和正负样本分配上反复折腾了两个星期。这里把整个过程完整记一遍,包括那些文档里不会写、只有真正跑崩过才知道的细节。
有一点必须先说清楚:这篇内容适合已经懂 PyTorch 基础(会写 Dataset、会搭 nn.Module)、但没完整做过检测任务的人;也适合做过分类、想跨到检测领域的人。如果你连卷积输出尺寸公式都还要查,建议先去补一下《深度学习入门》那类材料里的卷积章节,否则后面网格尺寸算错一位,排查起来非常痛苦。整套实现我建议控制在单卡 8G 显存以内能跑,输入分辨率 416,数据集规模几百到几千张就够验证思路,别一上来就想着复现大规模训练,那样只会把时间浪费在等 loss 曲线下降上。
1.1 目标检测到底比分类难在哪儿
分类任务里,一张图对应一个标签,模型输出一个概率向量,交叉熵一算就完事。检测不一样,它要回答两个耦合的问题:图里有什么,以及它在哪。这两个问题一旦耦合,麻烦就来了,因为“在哪”这个监督信号本身是随模型输出变化的,你的网络预测出一个框,才知道该拿哪个真值框去跟它算误差,这中间的匹配过程就是所谓的“正负样本分配”,是整个检测任务里最容易出 bug 的地方,也是最难从论文里直接看懂的部分。
更麻烦的是数量不平衡。以 416×416 输入、stride 32 的检测头为例,特征图是 13×13,每个格子挂 3 个先验框,一共 507 个预测框。一张图如果只有 2 个目标,那正样本可能只有 2 到 6 个,剩下 500 多个全是背景。这个比例大概是 1:100 到 1:250,如果不对负样本做降权,网络很快学会“全部预测背景”,损失降得飞快,指标纹丝不动。理解这一点,你才会明白损失函数里那个 noobj 权重系数为什么存在,而不是照着论文抄一个 0.5 就完事。
1.2 为什么不用现成的库,非要手写
直接用成熟框架三行代码就能训起来,为什么还要自己写?我的理由很实际:不手写一遍,你永远不知道 mAP 到 0 是标注格式错了、坐标系错了、还是正样本压根没分配上。手写的最大价值不是性能,而是可观测性——你可以把每个格子的 obj mask 打印出来看,可以把预测框画到原图上跟真值对比,可以在损失函数的每一行加断点。这些能力在封装好的接口里是没有的。
另一个原因是实验本身要考察“理解”。我见过不少人能调通官方仓库,但你问他 t_w 为什么要取 log、sigmoid 到底作用在哪一步,答不上来。这两件事在实际工作中很要命,因为一旦数据分布偏移(比如换成小目标密集的工业质检场景),你必须知道该动哪里,是改 anchor 尺寸、改特征图尺度,还是改正样本匹配策略。这些问题的答案都藏在实现细节里,读代码不如自己写一遍。
1.3 整套实验的技术路线与工作量预估
我这次的路线是:单尺度检测头 + 手写损失 + 手写 NMS + 手写 mAP 评估,不用任何检测库的现成模块。之所以先做单尺度,是因为多尺度(类似 FPN 的三层输出)会让正样本分配、损失聚合、后处理全部翻倍复杂,第一遍就上多尺度,debug 成本会失控。等你把单尺度跑出能看的 mAP,再扩展成三层输出,改动量其实只有几十行。
工作量上,我按模块拆了一下,供你安排时间参考:
| 模块 | 预估时间 | 主要难点 |
|---|---|---|
| 数据解析与 Dataset | 4 到 6 小时 | 标注格式转换、letterbox 坐标映射 |
| 模型结构搭建 | 3 到 5 小时 | 输出张量形状对齐、通道数计算 |
| 损失函数 | 6 到 10 小时 | 正负样本 mask、权重配比、数值稳定 |
| 训练循环 | 2 到 4 小时 | 学习率调度、显存管理 |
| 推理与 NMS | 3 到 5 小时 | 解码坐标、框映射回原图 |
| 评估与调参 | 8 小时以上 | mAP 计算逻辑、问题排查 |
这个表里损失函数和评估两块时间最长,因为它们的错误不会立刻报异常,只会让指标莫名其妙地不动。经验上,如果你搭完模型跑第一次训练,loss 从 10 降到 2 然后就卡住不动,八成是正样本分配出了问题,而不是学习率的问题——这个判断我后面会详细展开。
2. 核心原理拆解:YOLO 到底在算什么
2.1 网格划分与输出张量的物理含义
假设输入 416×416,主干网络做 5 次下采样,最后一个特征图是 13×13,每个像素对应原图 32×32 的区域,这个 32 就是 stride。每个格子负责预测落在它中心附近的那些目标。每个格子挂 3 个不同尺寸的先验框,所以输出张量形状是 (B, 3, 13, 13, 5+C),其中 5 是 tx、ty、tw、th、置信度,C 是类别数。如果你做的是 20 类,那最后一个维度是 25。
这里有个非常容易搞混的点:谁负责哪个目标。规则是拿真值框的中心点去算它落在哪个格子里,那个格子(以及该格子中 IoU 最大的那个先验框)负责这个目标。注意是中心点,不是左上角,也不是整个框覆盖的所有格子。我第一版就写成了"框覆盖的所有格子都算正样本",结果一个目标被分配了十几个正样本,模型学出一堆重复框,NMS 都救不回来。这个错误很典型,因为直觉上你会觉得"框盖住的区域都该学",但检测的监督信号必须是稀疏的、唯一的,否则网络不知道该往哪个方向收敛。
还有一点值得提:v1 时代是不带先验框的,直接让每个格子回归两个框,那时候对密集小目标效果很差。后来引入 anchor 机制,本质是把"预测绝对尺寸"变成了"预测相对于某个先验尺寸的偏移量",这是一个极其重要的思路转变,因为数据集里目标的尺寸分布是高度集中的,让网络去学"这个框比先验框大 1.3 倍"远比学"这个框宽 87 像素"容易得多。
2.2 坐标编码与解码:网络为什么不直接输出绝对坐标
直接回归绝对坐标的后果,我实测过一次:训练前期 loss 剧烈震荡,收敛后大目标定位还行,小目标几乎全偏。原因有两个,一是绝对坐标数值范围是 0 到 416,梯度尺度比分类分支大好几个量级;二是不同目标尺寸差异极大,大框的误差天然比小框大,损失会被大框主导。
解决办法就是编码。对于中心点,网络输出 tx、ty,经过 sigmoid 压到 (0,1),再加上所在格子的左上角坐标,就得到相对特征图的中心点位置;对应到原图再乘 stride。这个设计的好处是:网络只需要学"中心点在这个格子里的相对位置",输出范围天然被限制住,不会跑到格子外面去。
对于宽高,网络输出 tw、th,解码方式是w = pw * exp(tw),pw 是先验框宽度。取 exp 而不是直接乘,是为了保证宽高恒为正,同时在 log 空间里做回归,让大框小框的误差处在同一量级。举个例子,一个宽 300 的框偏了 30 像素,一个宽 30 的框偏了 3 像素,在绝对空间里前者的误差是后者的 10 倍,但在 log 空间里两者的相对误差都是 10% 左右,这才是我们想让网络学的东西。这个换算我建议你在草稿纸上推一遍:
编码:tw = log(gt_w / pw) 解码:w = pw * exp(tw)推完之后你会发现,如果 gt_w 和 pw 差很多,tw 的绝对值会很大,所以先验框的选择必须贴近数据分布,否则一开始 tw 就在 3 以上,exp 之后数值爆炸,梯度也爆炸。这就是为什么要做 anchor 聚类,后面数据准备那节我会给出具体做法。
2.3 损失函数三部分的构成与权重配比
损失分三块:定位损失、置信度损失、分类损失。定位用带 obj mask 的误差,只对正样本算;置信度分两段,正样本算"应该接近 1",负样本算"应该接近 0";分类只对正样本算,用交叉熵或二元交叉熵(多标签场景用后者更合适)。
权重配比是这套损失里最"玄学"的部分,但背后的逻辑其实很清楚。坐标损失权重给 5,因为定位误差直接决定框准不准,而且正样本数量少,不放大权重根本压不住负样本的主导;负样本置信度权重给 0.5,因为 500 多个负样本乘上 1.0 的权重,会把总损失完全撑起来,网络会优先优化"别预测框"这件事,导致漏检严重;正样本置信度权重给 1.0。
还有一个细节:v1 用的是 sqrt(w) 和 sqrt(h) 来算宽高误差,目的是缓解大小框不平衡(大框开根号后相对缩小更多),后来的版本改成在 log 空间回归,本质目的一样。如果你手写的是 v1 思路,记得加根号;如果按 v2 之后的设计,直接用编码后的 tw、th 算平方误差就行,别两套混着用,我就混过,训出来框尺寸系统性偏小。
注意:正负样本的 mask 一定要用
float()转成浮点型再相乘,如果直接用布尔张量做乘法,某些 PyTorch 版本会报类型错误,还有些版本会静默做类型提升,导致损失数值翻倍且不易察觉。
2.4 后处理:置信度阈值与 NMS 的联动
推理阶段,507 个预测框先按置信度阈值粗筛,通常取 0.25 到 0.3。这一步筛完一般还剩几十到几百个框,然后进 NMS。NMS 的逻辑很朴素:按置信度降序排,取最高的那个,把它和剩下的框算 IoU,超过阈值的删掉,重复直到清空。IoU 阈值一般给 0.45,密集场景可以调到 0.5 到 0.6,因为阈值太低会把相邻的两个真实目标误删,这是密集小目标检测里最常见的"漏检元凶"。
多类别情况下有个坑要注意:NMS 是按类别分别做还是跨类别一起做。如果两个不同类别的框重叠度很高(比如"人"和"骑手"),跨类别 NMS 会把其中一个删掉。所以正确做法是按类别分组,每组内部做 NMS,最后合并。我见过不少实现为了省事直接全局 NMS,在单类别数据集上看不出问题,一换多类别就崩。
粗筛阈值和 NMS 阈值这两个参数是联动的。你把置信度阈值降到 0.1,会捞回一部分漏检,但误检框也多了,这时候 NMS 的作用就更关键;如果 NMS 的 IoU 阈值给得太高(比如 0.8),大量重叠框留着,最终结果看起来就是"一堆框套在同一个目标上"。我的经验配置是:训练时置信度阈值 0.25、NMS IoU 0.45,评估时置信度阈值可以扫几个值取最优,这个细节在 mAP 计算里影响很大。
3. 工程准备:数据、环境与目录结构
3.1 环境依赖与版本选择
PyTorch 版本建议 1.12 以上,配合对应的 torchvision。这里不需要 torchvision 的检测模块,但它的 transforms 和 IoU 工具函数用起来方便。有几个版本相关问题我踩过,提前说明:一是torchvision.ops.nms在不同版本里返回值类型有差异,如果你要拿它跟自己写的 NMS 做对比验证,注意统一 dtype;二是混合精度训练在 1.10 之前的版本里和手写 loss 配合容易出 nan,原因是 log 的输入出现了 fp16 的零,解决办法是在 log 前面加一个极小值 eps。
依赖清单大致是:torch、torchvision、numpy、opencv-python、matplotlib、tqdm、pyyaml。数据集解析如果标注是 XML 格式,还需要标准库的 xml 解析;如果是 JSON,直接用 json 模块。我不建议引入额外的检测框架依赖,这次实验的意义就在于不依赖它们。
目录结构我习惯这样组织,好处是数据、代码、输出彻底分离,跑多个实验不会互相覆盖:
project/ configs/ base.yaml data/ images/ labels/ train.txt val.txt src/ dataset.py model.py loss.py postprocess.py metric.py train.py infer.py runs/ exp01/ weights/ logs/3.2 数据集组织与标注格式转换
标注最常见的两种格式,一种是每个目标一行、记录类别和归一化后的中心点与宽高;另一种是每个目标一个节点、记录类别和左上角与右下角的绝对坐标。训练前必须统一成第一种,因为损失函数里要用到中心点做格子分配。
转换代码里有个高频错误:归一化的分母到底用哪个。正确做法是用图片自身的宽和高,不是数据集的最大尺寸,也不是输入尺寸。我第一版图省事用 416 做了统一归一化,结果所有框都挤在左上角一小块区域,因为原图宽度普遍是 1000 以上,归一化之后数值全小于 0.4。这种 bug 从 loss 曲线看不出来,只有把框画到图上才会发现。
转换的核心逻辑大概是这样:
def xyxy_to_xywh_norm(x1, y1, x2, y2, W, H): cx = (x1 + x2) / 2.0 / W cy = (y1 + y2) / 2.0 / H w = (x2 - x1) / W h = (y2 - y1) / H # 越界裁剪,标注人工失误时很常见 cx = min(max(cx, 0.0), 1.0) cy = min(max(cy, 0.0), 1.0) w = min(w, 1.0) h = min(h, 1.0) return cx, cy, w, h越界裁剪那几行别省。真实标注里出界、负宽、高度为 0 的情况比你想象的多,尤其是有遮挡边缘目标的数据集。一旦出现负宽,后面算 IoU 会得到负数,NMS 排序全乱,最终表现为"检测结果随机跳变",很难定位。
3.3 Dataset 与 DataLoader 的写法要点
Dataset 的核心职责是:读图、做 letterbox 缩放、把标注框跟着缩放、返回图像张量和框张量。letterbox 是等比缩放后补灰边,而不是直接 resize,因为直接 resize 会改变长宽比,导致框的形状信息失真,模型学到的宽高比跟实际不匹配,测试时定位偏差会明显变大。
letterbox 的实现要点是记录两个值:缩放比例 ratio 和左上角填充量 pad。这两个值在推理时要用,因为你把预测框映射回原图时必须逆运算。我的做法是把它一起返回:
def letterbox(img, new_shape=416, color=(114, 114, 114)): h, w = img.shape[:2] r = min(new_shape / h, new_shape / w) nh, nw = int(round(h * r)), int(round(w * r)) img_resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR) canvas = np.full((new_shape, new_shape, 3), color, dtype=np.uint8) top = (new_shape - nh) // 2 left = (new_shape - nw) // 2 canvas[top:top + nh, left:left + nw] = img_resized return canvas, (r, left, top)标注框的换算就是x_new = x_old * r + left,宽高只乘 r。注意这里我用的是中心点坐标的话,也同样处理,因为平移对中心点和角点的效果一样。
DataLoader 这边,batch 内图片尺寸已经通过 letterbox 统一了,所以不需要自定义 collate_fn 做 padding,直接用默认的就可以。num_workers 建议设成 4 到 8,但我踩过一个坑:在 Windows 上多进程 DataLoader 加上 opencv 读图,偶尔会卡死,解决办法是设persistent_workers=False或者把 reads 放到主进程。另外pin_memory=True在单卡训练里能省一点数据搬运时间,值得开。
3.4 数据增强的取舍与实现顺序
增强这块,我的强烈建议是:第一遍训练不要开任何几何增强。理由很直接,当你还在排查"框位置偏了""正样本数量不对"这类问题时,增强引入的坐标变换会让错误来源变得无法区分。等 baseline 跑通了,再逐个加上去。
常开的增强按收益排序大致是:随机水平翻转、随机缩放加裁剪、色彩抖动(亮度、对比度、饱和度)、Mosaic 拼接。前两个是几何增强,收益最大,翻转实现最简单,注意把 x 坐标做1 - x的镜像;随机缩放裁剪要注意裁完之后如果某个目标的可见面积小于原来的 30%,应该把它丢掉,否则会引入大量只露出边角的样本,模型对这些残缺目标的学习效果很差,反而干扰正常目标。
Mosaic 是四个图拼一张,对小目标检测提升明显,因为它让目标在拼接后的图里相对变小。但实现时要注意,拼接后每张子图的标注都必须正确偏移,偏移量算错一个像素,整个框就偏了。我建议先把 Mosaic 的拼接结果连框一起可视化二十张,确认无误再开始训练,别指望从 loss 曲线看出偏移。
4. 模型结构与训练循环的落地实现
4.1 主干网络与检测头的搭建
主干我用的是一个精简版结构:5 个 stage,每个 stage 有 1 到 2 个卷积块,通道数依次是 32、64、128、256、512,最后经过 stride 32 得到 13×13 的特征图。这个规模参数量在 3000 万左右,单卡 8G 显存跑 batch 16 完全够。如果你想更快出结果,把通道数砍半,mAP 会掉一两个点,但训练速度快一倍。
卷积块我用 Conv + BN + LeakyReLU(0.1) 的组合,LeakyReLU 而不是 ReLU,是因为检测任务里负梯度信息对定位回归还是有用的,用 ReLU 会出现一部分神经元彻底死亡,表现为某些格子永远预测同一个框。这个结论我没有做过严格的消融对比,但在两个小数据集上观察到的现象是一致的。
检测头就是一个 1×1 卷积,输出通道数是num_anchors * (5 + num_classes)。这里要特别注意输出张量的形状,我习惯转成 (B, A, H, W, 5+C) 以便和标注做逐元素运算,但卷积出来的原始形状是 (B, A*(5+C), H, W),所以需要一个 permute 加 reshape:
pred = self.head(x) # (B, A*(5+C), H, W) B, _, H, W = pred.shape pred = pred.view(B, self.num_anchors, 5 + self.num_classes, H, W) pred = pred.permute(0, 1, 3, 4, 2).contiguous() # (B, A, H, W, 5+C)这个 permute 的顺序错了会非常隐蔽:程序不报错,训练也能跑,但坐标和类别通道全串位了,最终表现是 mAP 永远在 0 附近。我建议张量转换之后立刻用一个小 batch 打印几个格子的值,确认前两维(tx、ty)经过 sigmoid 之后落在 (0,1),置信度那一维在初始阶段接近 0,类别维度和为 1。这三条能用肉眼验证,比看 loss 靠谱。
4.2 前向传播与损失计算的关键实现
损失函数是整个实现里最需要小心的地方。我的写法是先把标注框转成"格子索引 + 相对偏移 + 尺寸编码"的形式,然后构建两个 mask:obj_mask 标记哪些位置有正样本,noobj_mask 标记哪些位置是纯背景。注意 noobj_mask 不是 obj_mask 取反,因为处于"忽略区"的位置(比如两个目标框重叠区域内的格子)既不算正也不算负,这个忽略区设计在小目标密集场景里很重要。
具体流程是这样的:
# gt: (B, N, 5) -> cx, cy, w, h, cls 归一化到 0~1 gi = (gt[..., 0] * W).long().clamp(0, W - 1) # 格子列索引 gj = (gt[..., 1] * H).long().clamp(0, H - 1) # 格子行索引 # 相对格子的偏移量,范围天然在 0~1 tx = gt[..., 0] * W - gi.float() ty = gt[..., 1] * H - gj.float() # 编码宽高 tw = torch.log(gt[..., 2] / anchors[:, 0] + 1e-9) th = torch.log(gt[..., 3] / anchors[:, 1] + 1e-9)anchor 匹配那一步,我的做法是算真值框和 3 个先验框的 IoU,选最大的那个 anchor 作为负责者,这样每个目标只对应一个正样本位置,简单但有效。更先进的做法(多正样本分配)能提升召回,但对第一版实现来说是过度设计,先不上。
损失计算的核心:
loss_xy = (coord_w * obj_mask * ((pred_xy - tx) ** 2).sum(-1)).sum() / n_pos loss_wh = (coord_w * obj_mask * ((pred_wh - tw) ** 2).sum(-1)).sum() / n_pos loss_obj = (obj_mask * BCE(pred_conf, 1.0)).sum() / n_pos loss_noobj = (noobj_w * noobj_mask * BCE(pred_conf, 0.0)).sum() / n_pos loss_cls = (obj_mask.unsqueeze(-1) * BCE(pred_cls, onehot)).sum() / n_pos除以 n_pos 是关键,它把损失按正样本数量归一化,这样不同 batch 之间 loss 尺度稳定。但要注意 n_pos 可能为 0(整个 batch 都没有目标,极端情况),所以要加max(n_pos, 1)防止除零。这个 bug 我在一个批次里全是负样本的数据集上遇到过,loss 直接变 nan,训练前三千步全部白跑。
4.3 训练循环、学习率调度与显存管理
优化器用 AdamW,学习率 1e-3,weight_decay 5e-4。检测任务里 AdamW 比 SGD 收敛快,虽然理论上最终精度 SGD 可能略高,但实验阶段没那个耐心等。学习率调度用余弦退火加 3 个 epoch 的 warmup,warmup 阶段从 1e-5 线性升到 1e-3,这个设计能避免训练初期的梯度爆炸,尤其在你用了 BN 和较大的 batch 时非常必要。
显存管理这块,先算一笔账。3000 万参数,fp32 每个 4 字节,参数占 120MB;梯度同样 120MB;AdamW 维护两个状态,再占 240MB,加起来接近 500MB。剩下的全是激活值,输入 batch 16、416×416、5 个 stage,激活值大概在 3 到 4GB。所以 8G 卡跑 batch 16 是安全线,想跑 batch 32 就要上混合精度或者梯度累积。
实测下来,torch.cuda.amp在这个任务上很稳,显存能省 30% 到 40%,速度提升 20% 左右。但有两个注意点:一是损失里的 log、exp 操作要放到 autocast 范围外,或者给输入加 eps,否则 fp16 下出现零会直接 nan;二是 BCE 计算前把 logits 转回 fp32,这样数值更稳。这两条是我用 nan 换了两次教训总结出来的。
训练循环里还有个小技巧:每 50 个 step 打印一次 loss 分项,而不是只看总 loss。总 loss 往下走不代表模型在学,可能是负样本置信度损失在降,而定位损失一直不降。分项打印能让你一眼看出问题在哪一块,这个习惯我从第一次做检测实验保留到现在,实在太好用。
4.4 推理实现与框还原
推理流程:图片 letterbox 到 416、前向传播、把输出解码成绝对坐标、按置信度粗筛、NMS、把框映射回原图。解码这一步要严格按编码的逆运算来:
# pred: (A, H, W, 5+C) xy = torch.sigmoid(pred[..., 0:2]) wh = torch.exp(pred[..., 2:4]) * anchors.view(1, 1, A, 2) conf = torch.sigmoid(pred[..., 4]) cls_prob = torch.sigmoid(pred[..., 5:]) scores = conf.unsqueeze(-1) * cls_prob # 加上格子偏移,得到特征图坐标 grid_x, grid_y = torch.meshgrid(torch.arange(W), torch.arange(H)) cx = (xy[..., 0] + grid_x.view(1, H, W)) cy = (xy[..., 1] + grid_y.view(1, H, W))最后算出的框是相对 416 输入尺寸的,映射回原图要做(x_letterbox - left) / r。这一步如果漏掉,画出来的框会整体偏移,而且偏移量与图片比例有关,看起来像"模型对某些图特别不准",其实是代码问题。我建议写一个visualize.py,把预测框和真值框用不同颜色画在同一张图上,前二十张图逐张检查,比看任何指标都直接。
5. 评估、调参与常见问题排查
5.1 mAP 的计算逻辑与常见误区
mAP 的计算链条是:对每个类别,把所有预测按置信度排序,逐个计算 precision 和 recall,画出 PR 曲线,算曲线下面积得到 AP,所有类别取平均得到 mAP。IoU 阈值取 0.5 时叫 mAP@0.5,取 0.5 到 0.95 平均叫 mAP@0.5:0.95,后者更严格。
手写这块最容易错的是"匹配唯一性"。一个真值框只能被一个预测框匹配上,匹配完之后要把它标记为已用,后面的预测不能再匹配它。如果不做这个标记,同一堆预测全部匹配到同一个真值,recall 会虚高到 1.0,precision 却很低,最终 mAP 看起来还行但实际错得离谱。正确写法是每次匹配时检查matched_gt_idx是否已占用,已占用就找次优的。
另一个误区是置信度阈值。评估时不应该固定一个阈值,而应该用全部预测(或一个很低的阈值,比如 1e-3)参与排序,让 PR 曲线画完整。我第一版为了省内存,只用置信度大于 0.25 的预测算 mAP,结果曲线右侧被砍掉,AP 被系统性低估了十几个点,排查了半天才发现是这个原因。
5.2 训练不收敛与损失异常的排查路径
这部分我用表格整理,因为这几种现象在实际操作里高频出现,且现象到原因的映射不是唯一的,需要按顺序排除:
| 现象 | 最可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| loss 直接变 nan | fp16 下 log(0) 或除零 | 关掉 AMP 重跑 | 加 eps,n_pos 取 max(1, ·) |
| loss 降得飞快但 mAP 为 0 | 全预测背景 | 打印正样本数量 | 检查格子索引与 mask 构建 |
| loss 卡在某个值不动 | 学习率过大或正样本极少 | 打印分项 loss | 降 lr,检查匹配逻辑 |
| 框位置系统性偏移 | letterbox 逆运算漏做 | 可视化叠加 | 补上 pad 和 ratio 的还原 |
| 框尺寸普遍偏小 | 编码用 log 但解码用平方 | 检查解码代码 | 统一编码解码方式 |
| 训练集好验证集差 | 数据增强过猛 | 关增强对比 | 降低增强强度 |
我自己遇到过最坑的一次是"loss 正常下降,mAP 在 0.05 附近停住"。排查路径是:先可视化预测框,发现框的位置基本对,但类别全是错的;再去看类别分支,发现我在计算分类损失时用的是pred_cls而不是pred_cls[obj_mask],导致背景位置也参与了分类监督,类别分布被背景稀释。改成只对正样本算分类损失之后,mAP 一周内从 0.05 涨到 0.42。
5.3 检测框重复、漏检与抖动的针对性处理
重复框是最常见的现象,表现是同一个目标被 3 到 5 个框套住。原因通常是 NMS 的 IoU 阈值给太高(比如 0.7 以上),或者多个 anchor 同时被判为正样本。前者调低阈值到 0.45 就能解决;后者要看你的匹配逻辑,如果用的是"所有 IoU 大于 0.5 的 anchor 都算正样本",那就会出现多正样本,训练时可能提升召回,但推理时必须靠 NMS 兜底,这时阈值要给到 0.4 左右。
漏检分两种情况。一种是置信度分数低被筛掉了,把粗筛阈值从 0.25 降到 0.1,如果漏检目标重新出现,说明是阈值问题,可以考虑在训练时对正样本的置信度目标给一个软化值(比如 0.9 而不是 1.0),避免模型过度自信。另一种是压根没框,那是候选框生成阶段的问题,通常是 anchor 尺寸和数据不匹配。我做过一次统计,数据集里目标宽度集中在 20 到 40 像素,而我用的 anchor 最小都是 60 像素,那必然撒不到点上。
anchor 聚类是解决这个的标准做法:把所有真值框的宽高提取出来,用 IoU 作为距离度量做 k-means,k 取 3 到 9。k=3 适合单尺度检测头,k=9 适合三层输出各挂 3 个。聚类完成后把结果作为先验框写进配置。我做这一步之后,最小 anchor 从 60 降到 22,小目标召回直接提升了接近 20 个百分点,效果非常明显。
框抖动指的是同一个视频序列里,框在相邻帧之间跳来跳去。这个不是模型本身的问题,是训练数据里同一目标的不同姿态都有标注,模型输出在边界附近震荡。缓解办法是推理时对置信度和坐标做时序平滑,或者简单地取多尺度测试的平均。
5.4 从零到能看:我的实操顺序与避坑清单
如果让我重做一遍,我会按这个顺序推进,每一步都有明确的完成标志,避免在错误的基座上继续搭建。
第一步,先过拟合 10 张图。关掉所有增强,batch 设 2,学习率 1e-3,训练 200 个 step。完成标志是总 loss 降到接近 0.1 以下,且可视化出来的框跟真值几乎重合。这一步能一次性验证数据管道、模型结构、损失函数、解码逻辑全部正确。如果这一步都过不了,后面所有调参都是白费。我用这个方法定位过至少五六个 hidden bug,包括张量 permute 顺序错、坐标没归一化、格子索引用错。
第二步,扩到 200 张图,打开水平翻转,训练 50 个 epoch,看验证集 mAP 能不能到 0.2 以上。到不了就查验证集的标注转换和评估代码。
第三步,上全量数据,加随机缩放和色彩抖动,用余弦调度训 200 个 epoch。这时如果 mAP 卡住,再去调 anchor 和损失权重。
避坑清单我列几条最有价值的:
- 固定随机种子,包括 numpy、torch、cuda 三处,否则两次实验的差异你分不清是改动带来的还是随机性带来的。
- 权重保存按验证集 mAP 存最优,不要按 loss 存。lost 最低的模型往往过拟合,实测两者能差 5 到 10 个 mAP 点。
- 每次改动只改一个变量,并且记录进实验日志。我曾经同时改了学习率和 anchor,结果指标掉了,完全无法归因,白花三天。
- 可视化工具要早写。我在项目第 3 天就写了把预测框画到原图上的脚本,后面节省的排查时间远超写脚本的两小时。
- 显存不足时优先降 batch 而不是降分辨率,因为分辨率从 416 降到 320,小目标特征直接消失,定位精度断崖式下跌,而 batch 从 16 降到 8 只是训练慢一点、BN 统计稍微不稳。
最后分享一个我自己觉得很有用的小习惯:训练开始前,先跑一遍全负样本的 batch(就是空图),看 loss 是否为一个合理的常数值、有没有 nan。这个测试能提前暴露除零和 log 零的问题,三十秒就能做完,比我训了半天发现 nan 要划算太多。