ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手写YOLO检测头:网格预测、标签分配与损失收敛实战

从零手写YOLO检测头:网格预测、标签分配与损失收敛实战

1. 为什么我选择从零手写一遍YOLO检测头

先说结论:如果你已经能熟练调用现成的检测框架跑通推理,却说不清一张 640×640 的图进去之后,为什么输出是 8400 个候选框、为什么正样本只有寥寥几个、为什么 loss 里有一项几乎不下降——那这篇内容就是写给你的。我打算把一次完整的实验过程摊开讲:用 PyTorch 把一个 YOLO 风格的单阶段目标检测模型从数据管道、骨干网络、检测头、标签分配、损失函数一路搭到训练收敛和推理可视化。

这里说的“YOLO”不是指某个具体版本的官方实现,而是指单阶段、Anchor-based、输出为网格化预测张量这一整类检测范式的核心骨架。选它做实验的理由很实在:相比两阶段方法,它没有区域建议网络那一层,整条链路是端到端可微的,任何一个环节写错都会立刻体现在 loss 曲线上,调试反馈非常直接;相比纯分类任务,它又多出了坐标回归、正负样本分配、NMS 后处理这几块真正的“工程难点”,正好是检验自己是否真的理解检测模型的分水岭。

适合谁看:有 PyTorch 基础、写过 Dataset 和训练循环、能看懂卷积和矩阵运算,但对“检测模型到底怎么训起来”还停留在调包阶段的同学。全文的代码都是可运行的片段级实现,我刻意避开了过度封装,参数含义全部写进注释,你可以直接抄进自己的实验仓库。至于我为什么坚持手写而不是直接改开源仓库——因为改别人的代码时,你永远在猜作者为什么这么写;自己写一遍,错一次就永远记住了。

2. 先想清楚输入输出约定,再动手写第一行代码

动手搭网络之前,我强制自己先在纸上写清楚三件事:输入张量的形状、输出张量的形状、以及两者之间的语义映射。这一步偷懒,后面 80% 的 bug 都源于此。很多人一上来就抄 backbone,结果训了两天发现 loss 不降,回头一查是 anchor 顺序和输出通道的排布对不上。这种错误不会报异常,只会安静地浪费你的时间。

2.1 网格化预测张量的形状为什么是 S×S×(B×(5+C))

先定义清楚符号。把输入图像统一缩放到 640×640,网络以 32 倍下采样输出特征图,于是得到 20×20 的网格。每个网格单元负责预测 B 个边界框,每个框带 5 个量:中心点相对网格的偏移 x、y,宽高 w、h,以及一个 objectness 置信度。再加 C 个类别的条件概率,通道数就是 B×(5+C)。

以单尺度、B=3、C=80 的配置为例,输出张量形状是 1×20×20×3×85。但我更推荐把三个尺度拼起来看:stride 8 得到 80×80,stride 16 得到 40×40,stride 32 得到 20×20,把它们全部展平并按网格顺序拼接,总数是 80×80×3 + 40×40×3 + 20×20×3 = 19200 + 4800 + 1200 = 25200 个预测。这个数字很重要,它就是你后面标签分配时要处理的候选总量,也是你评估显存占用的依据。

补充说明一个容易混淆的点:不同实现里“(5+C)”和“(C+5)”的排布顺序不一样,有的是[x, y, w, h, obj, cls...],有的是[obj, cls..., x, y, w, h]。这纯粹是工程约定,但一旦定了就必须全链路统一,从标签编码、损失计算到解码推理,任何一处反了都会导致模型学到一个完全错位的映射。我的习惯是把它写成常量并只在一处引用:

# 每个 anchor 的预测向量长度:4 个坐标 + 1 个 objectness + C 个类别 NUM_CLASSES = 80 NOOBJ = 4 + 1 + NUM_CLASSES # 85 # 通道索引约定,全局唯一定义,禁止在别处硬编码数字 IDX_XYWH = slice(0, 4) IDX_OBJ = 4 IDX_CLS = slice(5, 5 + NUM_CLASSES)

这套 slice 常量看起来啰嗦,但我在排查一次“类别分数全部趋同”的问题时,就是靠全局替换 slice 才确认不是索引错位导致的,省了至少半天。

2.2 坐标为什么要预测偏移量而不是绝对值

这是我在第一次实验里踩得最深的坑。如果直接让网络回归框中心的绝对像素坐标,训到中期 loss 会卡在一个较高的平台反复震荡。原因是绝对坐标的取值范围是 0~640,而卷积输出的数值分布天然集中在较小的区间,网络为了输出大数值必须把权重推到很大的量级,梯度尺度失衡。

正解是让网络只预测相对网格左上角的偏移量,并用 sigmoid 压到 0~1,解码时再加回网格坐标;宽高则预测相对于 anchor 尺度的对数缩放比。这样每个输出单元的回归目标都落在 0~1 或接近 0 的小范围内,数值稳定得多。解码公式我固定成下面这样,写在decode函数里,训练和推理共用同一份:

def decode(pred, anchors, stride, grid_size): # pred: [B, A, H, W, 5+C],A 为该尺度 anchor 数 device = pred.device gy, gx = torch.meshgrid( torch.arange(grid_size, device=device), torch.arange(grid_size, device=device), indexing="ij") grid = torch.stack((gx, gy), dim=-1).float() # [H, W, 2] xy = (torch.sigmoid(pred[..., 0:2]) + grid) * stride # 解码到输入尺度 wh = torch.exp(pred[..., 2:4]) * anchors.view(1, -1, 1, 1, 2) obj = torch.sigmoid(pred[..., 4:5]) cls = torch.sigmoid(pred[..., 5:]) return torch.cat([xy, wh, obj, cls], dim=-1)

注意:meshgrid的indexing参数必须显式写成"ij",否则在较新版本的 PyTorch 里会产生警告,而且在宽高不相等的特征图上会静默给出转置后的网格,坐标全错却不会报错。我因为这个排查过一整晚。

宽高用exp而不是 sigmoid,是因为尺度缩放比理论上无上界,用指数映射可以覆盖任意大小目标,同时让梯度形式保持简洁。

3. 数据管道:比模型更容易出错的地方

我现在的经验是,检测任务的调试时间大概七成花在数据和标签上。模型结构写错了顶多 shape 报错,数据写错了是“能跑、loss 也降、但 mAP 上不去”,最难定位。所以这一节我把数据管道拆得细一些。

3.1 图像与框的同步增强,最容易写错的是翻转后的 x 坐标

检测的增强和分类最大的区别:所有几何变换必须同时作用于图像和标注框,而且框的表示形式在变换前后要来回转换。我采用内部统一的中间格式(cx, cy, w, h)归一化到 0~1,因为缩放、翻转在这个格式下计算最简洁,只在最终编码时才转成像素单位。

水平翻转的坑在于:框中心 x 变换后应该是1 - cx,但宽不变。而如果你顺手把 x1、x2 也做了1 - x再重排,结果虽然等价,代码里却很容易忘记重排导致 x1 > x2。我的做法是统一走中心点格式,翻转只改一个数:

def hflip(img, boxes): img = img[:, ::-1, :].copy() boxes = boxes.copy() boxes[:, 0] = 1.0 - boxes[:, 0] # 只改中心 x,宽高不变 return img, boxes

另外,随机缩放和随机裁剪之后,那些面积被裁掉大半的框必须过滤。我用的阈值是保留可见面积占原面积 30% 以上、且裁剪后宽高都不小于 4 像素的框。阈值不是玄学,是基于实测:低于这个比例的目标在特征图上已经不足一个感受野的中心区域,强行保留只会给回归分支注入噪声标签。

3.2 一个 batch 内如何组织多尺度标签

多尺度输出意味着标签也要按尺度分别准备。我的Dataset.__getitem__返回的是原始图像张量和未编码的归一化框 + 类别,编码放到collate_fn里批量做,理由是编码过程依赖 anchor 配置,而这些配置是全局的,放在 batch 级别做可以顺便完成 padding 对齐。

collate 阶段的处理顺序我固定为:取 batch 内最大宽高做 letterbox 填充(保持长宽比,灰色 114 填充),记录每张图的缩放比和填充偏移,然后按三个尺度分别计算每个 GT 落在哪个网格、与哪几个 anchor 的 IoU 超过阈值。这里的 DOI(IoU 阈值)我设成 0.5 起步,但对小目标尺度放宽到 0.4,因为小目标匹配到的 anchor 天然 IoU 偏低,卡太死会导致正样本极度稀疏。

下面这个表格是我实测的 anchor 配置,针对自建数据集(以行人和车辆为主)调过两轮,供参考:

输出尺度stride特征图anchor 尺寸(像素)适用目标
小880×80(12,16), (19,24), (28,36)小于 32px 的目标
中1640×40(38,52), (56,76), (82,110)32~96px 的目标
大3220×20(116,150), (160,210), (240,320)大于 96px 的目标

anchor 不是随便给的,我的做法是先对自己的训练集跑一遍 K-means(距离用 1-IoU),把聚类中心按面积排序后三等分,再微调让相邻尺度的比例接近 2 倍。直接用公开数据集的默认 anchor 到自己数据上,召回率通常会掉几个点。

4. 骨干网络与颈部结构的选择逻辑

结构选型这件事,我的判断标准只有一个:在目标硬件的推理预算内,保证足够的下采样倍数和足够的跨尺度融合能力。检测需要大感受野来定位大目标,也需要高分辨率特征图来定位小目标,这两者靠单一尺度无法同时满足,所以多尺度输出是必需项,不是可选优化。

4.1 从 Darknet 风格残差块讲起的最小可用骨干

我不建议一开始就上很复杂的结构。实验初期我的骨干只用了“卷积 + 残差块堆叠”的最小组合:每个 stage 由一个 stride=2 的下采样卷积开头,后接 N 个残差单元,通道数按 32→64→128→256→512 递增。这套结构的好处是每一层的形状都能手算出来,出问题一眼能定位。

残差块内部我用 1×1 降维 + 3×3 提特征的瓶颈结构,并在 3×3 卷积后接 BatchNorm 和 LeakyReLU(负斜率 0.1)。选 LeakyReLU 而不是 ReLU 的原因很实际:检测任务里大量回归目标的梯度在负半轴也有贡献,ReLU 在负区直接截断会让回归收敛变慢。这个差异我在对比实验中观察到过,换成 ReLU 后坐标 loss 的下降速度明显滞后。

class Bottleneck(nn.Module): def __init__(self, cin, cout): super().__init__() cmid = cout // 2 self.conv1 = nn.Conv2d(cin, cmid, 1, bias=False) self.bn1 = nn.BatchNorm2d(cmid) self.conv2 = nn.Conv2d(cmid, cout, 3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(cout) self.act = nn.LeakyReLU(0.1, inplace=True) self.short = (nn.Identity() if cin == cout else nn.Conv2d(cin, cout, 1, bias=False)) def forward(self, x): y = self.act(self.bn1(self.conv1(x))) y = self.bn2(self.conv2(y)) return self.act(y + self.short(x))

显存方面提醒一句:训练时把torch.backends.cudnn.benchmark打开,输入尺寸固定的话能白捡一成左右的速度;但如果你的 dataloader 会输出不同尺寸的图(比如没做统一 letterbox),这个开关反而会导致反复重选算法,速度更慢。

4.2 颈部融合:上采样加拼接,而不是直接相加

三个尺度的特征要互相“看见”彼此的信息,否则小尺度分支缺语义、大尺度分支缺细节。我采用的路径是自顶向下:从 stride 32 的特征图开始,1×1 卷积把通道压到统一值(我设 256),然后双线性上采样 2 倍,与 stride 16 的同通道特征沿通道维拼接,再过一组 3×3 卷积融合;重复一次到 stride 8。

用拼接而不是相加,是因为拼接保留了不同来源特征的独立性,让后续 1×1 卷积自己去学如何加权;相加则强制假设两路特征语义对齐,这个假设在浅层往往不成立。代价是通道数翻倍带来的显存开销,所以我在拼接后紧跟一个 1×1 卷积把通道压回去,实测能省下约 15% 的显存而不掉精度。

提示:上采样我用F.interpolate(..., mode="nearest")而不是转置卷积。转置卷积容易在特征图上产生棋盘格伪影,对小目标的定位有肉眼可见的负面影响,而且参数更多。最近邻插值虽然“粗糙”,但在检测任务里配合后续 3×3 卷积完全够用。

5. 检测头、标签分配与损失函数:整个实验的核心

我把这一节放在最中间,因为这里是 YOLO 类模型真正区别于普通回归网络的地方。头部结构本身很简单(几个卷积),难的是如何决定哪个预测该对哪个 GT 负责,以及如何为不同质量的预测赋不同权重。

5.1 检测头的三种输出分支与解耦设计

每个尺度上我接一个检测头,结构是:先 3×3 卷积把颈部输出压到中间通道(我取 256),然后分出三个并行的 1×1 卷积分支,分别输出坐标(4 通道)、objectness(1 通道)、类别(C 通道)。这就是所谓的解耦头。

为什么要解耦?因为分类需要的是语义不变性(同一个物体平移、缩放后类别不变),回归需要的是位置敏感性。让同一组卷积核同时服务这两个目标存在内在冲突。我在实验中做过对照:耦合头训到第 30 轮时,类别 loss 和坐标 loss 会互相拉扯,表现为其中一个下降另一个就抬头;换成解耦头后两条曲线都能单调下降。代价是参数量增加不多,完全值得。

单尺度检测头的实现如下,注意输出通道顺序要和前面定义的常量严格一致:

class DetectHead(nn.Module): def __init__(self, cin, num_anchors, num_classes): super().__init__() self.stem = nn.Sequential( nn.Conv2d(cin, 256, 3, padding=1, bias=False), nn.BatchNorm2d(256), nn.LeakyReLU(0.1, inplace=True)) self.reg = nn.Conv2d(256, num_anchors * 4, 1) self.obj = nn.Conv2d(256, num_anchors * 1, 1) self.cls = nn.Conv2d(256, num_anchors * num_classes, 1) def forward(self, x): x = self.stem(x) # 输出统一调整为 [B, A, H, W, C] 便于后续按 anchor 维处理 b, _, h, w = x.shape reg = self.reg(x).view(b, -1, 4, h, w).permute(0, 1, 3, 4, 2) obj = self.obj(x).view(b, -1, 1, h, w).permute(0, 1, 3, 4, 2) cls = self.cls(x).view(b, -1, self.num_classes, h, w).permute(0, 1, 3, 4, 2) return reg, obj, cls

坐标分支我不加任何激活,原始输出直接交给 loss(配合前面说的 sigmoid/exp 在解码时处理);objectness 和类别分支同理,sigmoid 在 loss 内部配合 BCEWithLogits 使用,避免数值不稳定。这是一个非常容易出错的细节:如果在头部就套了 sigmoid,再用 BCEWithLogits 就相当于做了两次变换,loss 会一直偏高且梯度异常。

5.2 正负样本分配:我用的简化版动态分配策略

早期的 YOLO 只按“GT 中心落在哪个网格”来分配正样本,一个 GT 一个正样本,监督信号太稀疏。我现在的做法是:对每个 GT,找出中心点落在其内部或附近的网格,计算这些网格上所有 anchor 与该 GT 的 IoU,凡是 IoU 超过正阈值的都作为正样本,IoU 低于负阈值的作为负样本,中间的忽略不计入 loss。

关键在于引入中心先验:一个网格即使 IoU 够高,如果它的中心离 GT 中心太远,也不应该算正样本,否则会让模型学到“从远处预测物体”的坏习惯。我的过滤条件是中心距离落在 GT 宽高的一半范围内。这个约束加上之后,训练初期 loss 的抖动明显减小。

分配过程我用一个 mask 张量实现,形状与输出对齐,1 表示正样本、0 表示负样本、-1 表示忽略:

def assign_targets(gt_boxes, anchors, strides, grid_sizes, pos_thr=0.5, neg_thr=0.4): # 返回每个尺度上的 mask 与回归目标,此处展示单尺度核心逻辑 masks, targets = {}, {} for s, (anc, st, gs) in enumerate(zip(anchors, strides, grid_sizes)): # 展开该尺度所有预测框:中心为网格中心,尺寸为 anchor cx = (torch.arange(gs).float() + 0.5) * st cy = (torch.arange(gs).float() + 0.5) * st # ... 构造 [gs*gs*A, 4] 的候选框,与每个 GT 算 IoU iou = pairwise_iou(candidates, gt_boxes) # [N_cand, N_gt] best_iou, best_gt = iou.max(dim=1) mask = torch.full_like(best_iou, -1.0) # 默认忽略 mask[best_iou < neg_thr] = 0.0 # 负样本 mask[best_iou >= pos_thr] = 1.0 # 正样本候选 # 中心先验过滤:网格中心必须落在 GT 内部 mask[~inside_center(candidates, gt_boxes[best_gt])] = -1.0 masks[s], targets[s] = mask, build_reg_target(candidates, gt_boxes[best_gt]) return masks, targets

代码里pairwise_iou和build_reg_target是辅助函数,分别算两两 IoU 和把 GT 编码成相对偏移量。我在实现pairwise_iou时犯过一个经典错误:直接用x1 = cx - w/2这种形式计算,但没有对坐标做 clamp,导致超出图像边界的框算出负宽度,IoU 出现负值,进而让一批本该是负样本的候选被误判为忽略。修复方法是在算面积前统一clamp(min=0)。

5.3 三项损失的权重配比与不收敛的排查经验

损失由三部分组成:坐标回归 loss、objectness 的二元交叉熵、类别的二元交叉熵。坐标我用 CIoU 形式,因为它同时考虑重叠面积、中心距离和长宽比,收敛更快;后两项用带 logits 的 BCE。

权重配比是这套实验里我最不愿意给“标准答案”的部分,因为强依赖数据分布。我踩坑后稳定下来的一组起点是:坐标 7.5、objectness 1.0、类别 0.5。给坐标更高的权重是因为定位精度直接决定 mAP 的上限;objectness 保持 1.0 是因为正负样本极度不均衡,如果权重给太高,负样本的 loss 会淹没正样本;类别权重压到 0.5 是因为在正样本已经很少的情况下,类别分支的梯度容易过大。

另外必须处理正负样本比例。实测下一个 batch 里正样本可能只有几十个,而负样本上万。我用的策略是只对负样本中 objectness 预测值最高的前 K 个计算 loss(K 取正样本数的 3 倍),其余负样本直接忽略。这一步听起来粗暴,但它比直接给负样本乘个小权重更有效,因为它聚焦在“模型最困惑的负样本”上。

判断训练是否正常,我只看三个信号:一是坐标 loss 应该在 10 轮内降到初始值的三成以下;二是 objectness loss 的下降曲线要平滑,如果出现周期性尖峰,多半是某个 batch 的标签编码有问题;三是正样本数量均值应该稳定在一个区间内,如果随 epoch 剧烈波动,说明标签分配的中心先验在边界目标上失效了。我曾遇到 objectness loss 每 5 个 epoch 准时跳一次的问题,最后定位到是 dataloader 的 shuffle 与采样器组合导致某个类别的样本被集中投放,改回标准随机采样就平了。

6. 训练循环、显存控制与推理后处理

网络和数据都跑通之后,剩下的工作是从“能训”到“训得动、训得好”。这一节的每一条都是我在有限显存条件下被逼出来的,直接照做能省很多试错时间。

6.1 学习率调度、混合精度与梯度累积的组合

我用 AdamW 起步,初始学习率 1e-3 配 weight decay 5e-4,前 3 个 epoch 做线性 warmup,之后余弦退火到 1e-5。warmup 不是可有可无的:检测头是随机初始化的,前几百步梯度尺度很大,直接上高学习率会把坐标分支推到一个 bad local minimum,表现为坐标 loss 长期卡在高位不下来。

混合精度我用torch.cuda.amp,但loss 计算部分强制在 float32 下进行。原因是 objectness 的 BCE 在 fp16 下容易溢出,尤其是当某个 batch 里出现极端难负样本时。写法上我用 autocast 包裹前向,loss 前用float()转换:

scaler = torch.cuda.amp.GradScaler() for imgs, gts in loader: imgs = imgs.cuda(non_blocking=True) with torch.cuda.amp.autocast(dtype=torch.float16): reg, obj, cls = model(imgs) # 关键:损失计算退回到 fp32,保证数值稳定 loss = compute_loss(reg.float(), obj.float(), cls.float(), gts) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True)

梯度裁剪的阈值我设 10.0,实测下来只有当标签编码出现异常时才会触发,所以它其实充当了一个免费的异常探针:如果你发现裁剪频繁触发,别急着调大阈值,先去查标签。

显存不够时用梯度累积:每 4 个 micro-batch 做一次step,等效 batch 翻 4 倍。这里有一个细节必须配对处理——BN 的统计量在小 batch 下会失真,我改用SyncBatchNorm或者在冻结 BN 统计量的前提下累积,否则等效 batch 放大了,但 BN 看到的仍是小 batch,精度对不上。

6.2 NMS 的实现细节与置信度阈值的标定方法

推理阶段把三个尺度的解码结果拼成一个[N, 5+C]的大张量,先按 objectness 过滤(阈值 0.05 起步,宁低勿高,因为后续 NMS 还会再筛),再依次做按类别的 NMS。我的 NMS 实现里最容易写错的边界是:IoU 计算要用到裁剪后的坐标,且不同类别的框不互相抑制。

def nms(boxes, scores, iou_thr=0.45): # boxes: [N, 4] 格式为 xywh,坐标已还原到原图尺度 x1, y1 = boxes[:, 0] - boxes[:, 2] / 2, boxes[:, 1] - boxes[:, 3] / 2 x2, y2 = boxes[:, 0] + boxes[:, 2] / 2, boxes[:, 1] + boxes[:, 3] / 2 areas = (x2 - x1).clamp(min=0) * (y2 - y1).clamp(min=0) order = scores.argsort(descending=True) keep = [] while order.numel() > 0: i = order[0].item() keep.append(i) xx1 = torch.clamp(x1[order[1:]], min=x1[i]) yy1 = torch.clamp(y1[order[1:]], min=y1[i]) xx2 = torch.clamp(x2[order[1:]], max=x2[i]) yy2 = torch.clamp(y2[order[1:]], max=y2[i]) inter = (xx2 - xx1).clamp(min=0) * (yy2 - yy1).clamp(min=0) iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-7) order = order[1:][iou <= iou_thr] return keep

IoU 阈值我最后定在 0.45。这个值不是拍脑袋:密集场景(比如人群)设 0.5 以上会残留大量重复框,而设 0.3 又会把相邻的两个真实目标合并掉。我的标定方法是拿验证集画一张“置信度阈值-召回率”曲线,找召回率曲线开始陡降前的拐点,通常落在 0.25~0.35 之间;而 NMS 的 IoU 阈值则固定 0.45,两者不要混在一起调,否则你无法判断性能变化归因于哪一个。

6.3 调试检测模型时我常用的三个可视化手段

第一是把标签画回原图。训练前必须做这一步,我见过太多因为归一化坐标和像素坐标混用导致框全部挤在左上角的案例,而 loss 依然在降,因为模型确实在学习这个错误分布。第二是把预测框在训练中期画出来,哪怕框很乱,只要它开始聚集在目标附近就说明坐标分支方向对了。第三是按尺度分别统计正样本数,如果某个尺度长期是 0,说明 anchor 尺寸和你的数据分布完全错配,那个尺度的检测头永远学不会。

有一次我把这三个手段串起来排查了一个典型问题:mAP 卡在 0.2 上不去,可视化发现所有预测框都比真实框小一圈。原因是我在编码时用了log(w_gt / w_anchor),解码时却用了exp(pred) * anchor的宽高顺序搞反了(把 w 对到了 h 的 anchor 上)。这类错误不可能从 loss 数值看出来,只能靠可视化。

7. 几个必须提前知道的边界与经验

最后分享几条我认为比代码本身更值钱的经验。第一,不要在小数据集上追求多尺度。我最初在只有 1200 张图的数据集上直接开三个尺度训练,结果最小尺度分支一直是零正样本,白白多算了 40% 的前向开销。数据量小的时候,先用单尺度(stride 16)把整条链路验证通,再逐步加尺度,效率高得多。

第二,objectness 的分支比类别分支更容易出问题,优先调它。很多“mAP 上不去”的表象,根因是 objectness 的校准不好——模型给了目标很高的类别分数但 objectness 很低,NMS 前就被阈值滤掉了。检查方法是单独统计正样本上的 objectness 均值,正常情况下它应该随训练稳步上升到 0.8 以上。

第三,关于验证集的选择,我坚持按场景而非随机划分。随机划分会让同一段连续视频或被同一组采集条件下的图片同时出现在训练集和验证集里,验证 mAP 虚高 5~10 个点是常有的事。按场景、按采集批次、甚至按时间划分,得到的数字才代表真实泛化能力。

常见症状大概率根因快速验证方式
loss 完全不降标签编码与输出通道顺序不一致单 batch 过拟合测试,看能否记住 4 张图
坐标 loss 平台震荡直接回归绝对坐标,未用相对偏移检查解码公式的缩放项
某尺度无正样本anchor 尺寸与数据分布错配统计各尺度的正样本数
框普遍偏小或偏移anchor 宽高顺序与编码时不一致可视化预测框与 GT 叠加
训练后期 mAP 回落学习率未退火或正样本过拟合打印每轮正样本数与 lr

我还想强调一个调试习惯:用 4 张图做单 batch 过拟合测试。把这个小实验跑通(loss 降到接近 0,预测框和 GT 几乎重合),就等于证明了数据管道、编码、损失、解码这整条链路自洽。凡是链路级别的 bug,都会让这个测试失败;而链路一旦自洽,剩下的问题都只是超参和容量问题,排查范围小得多。这个习惯帮我省掉的调试时间,比我读过的所有文档加起来都多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进