news 2026/10/26 5:34:16

从公式到代码:YOLO目标检测的PyTorch实现与调参实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从公式到代码:YOLO目标检测的PyTorch实现与调参实践

第一次把 YOLO 的论文公式一行行翻译成 PyTorch 代码,是我这几年里最折腾也最值回票价的一段经历。写完那一刻你会发现,目标检测这件事从“黑盒调包”变成了可以拆开看的齿轮组:骨干网络怎么吐特征、anchor 怎么跟真值框配对、损失函数里哪一项在管位置、哪一项在管“这里到底有没有东西”。这篇东西就是把这个过程原原本本写下来——YOLO 目标检测模型在 PyTorch 中的实现,从网络结构、数据管道、损失函数一直写到推理后处理和调参踩坑。它解决的问题很具体:很多人跑通过现成库的 train.py,但一到自己改结构、换数据集、调损失就抓瞎;或者模型训起来了但 loss 不降、框全糊在一起、mAP 卡在 0.1 上不去。适合已经会用 PyTorch 写基本训练循环、想真正吃透单阶段检测器内部机制的读者,也适合要在一个自定义数据集上从零搭检测基线的人。我会给出可直接跑的代码骨架和参数取舍的理由,不讲空话。

1. 整体设计:先把一个能跑的最小闭环搭出来

1.1 别一上来就抠论文,先划清模块边界

我见过太多人卡在第一步:打开论文,看到密密麻麻的公式,然后开始从上往下抄,抄到 FPN 部分就断了,因为不知道特征图尺寸怎么对齐。我的建议是先把整个系统切成五个互不干扰的模块,每个模块有明确的输入输出契约,这样你可以单独调试任何一个。

  • 数据模块:输入图片路径和标注文件,输出(image_tensor, targets),其中 targets 是[batch, num_gt, 5]的形状,五个数是cls, cx, cy, w, h,坐标全部归一化到 0~1。
  • 骨干模块:输入[B, 3, H, W],输出三个尺度的特征图,比如输入 416×416 时输出 13×13、26×26、52×52。
  • 颈部模块:把三个尺度做自顶向下的融合,输出仍然是三个尺度,但通道数统一。
  • 检测头模块:把融合后的特征图变成预测张量,每个格子输出(4 + 1 + num_classes)维。
  • 后处理模块:把预测张量解码成绝对坐标框,做置信度过滤和 NMS。

这样划分之后,你可以先拿随机张量把整个前向跑通,确认每一层的形状都对得上,再去接真实数据。这一步能省掉大量“训到一半报维度错误”的时间。

提示:形状校验不要靠肉眼数,写一段断言代码把所有中间张量的 shape 打出来,跑一次随机输入,比对着论文推半小时都靠谱。

选择这种模块化路线而不是“一个大 nn.Module 从头串到尾”,核心原因是调试成本。检测模型的报错往往不直观——比如正样本匹配出错,表现是 loss 一直不降,而不是抛异常。模块切开后,你可以单独给检测头喂构造好的张量,验证解码公式是不是写反了。

1.2 结构版本的选择:从锚框式单阶段结构入手

现在检测模型的花样很多,有 anchor-free 的、有基于 Transformer 的、有用动态标签分配的。但我依然建议复现的第一个版本选锚框式的三尺度结构,理由有三个。

第一,它的每个设计决策都能对应到一个具体可调的超参,学起来成体系:anchor 尺寸、正样本 IoU 阈值、损失权重,这些都是你能亲手拨动的旋钮。第二,工程资料最丰富,遇到问题容易找到对照。第三,它的推理链路清晰,从张量到最终框每一步都能打印出来看,不像某些端到端结构中间结果难以解释。

网络规模上我建议先做一个“缩小版”:骨干用类似残差堆叠的思路搭 20 层左右,通道从 16 起步翻倍到 128,三个输出尺度。这个规模在单张 8GB 显存的卡上,输入 416×416、batch 16 能跑起来,训 200 个 epoch 大概几个小时。等整个链路验证无误,再把深度和宽度放大到正式配置。直接上大模型的结果往往是:跑了三小时,loss 曲线纹丝不动,你连问题出在数据还是出在模型都分不清。

import torch import torch.nn as nn class ConvBNSiLU(nn.Module): """基础卷积块:Conv2d -> BatchNorm -> SiLU 用 SiLU 而不是 ReLU,是因为负值区间有微小梯度, 深网络里不容易出现大片神经元失活。 """ def __init__(self, cin, cout, k=3, s=1): super().__init__() p = k // 2 self.block = nn.Sequential( nn.Conv2d(cin, cout, k, s, p, bias=False), nn.BatchNorm2d(cout), nn.SiLU(inplace=True), ) def forward(self, x): return self.block(x) class Residual(nn.Module): """残差块,两个 3x3 卷积,通道不变""" def __init__(self, c): super().__init__() self.c1 = ConvBNSiLU(c, c, 1) self.c2 = ConvBNSiLU(c, c, 3) def forward(self, x): return x + self.c2(self.c1(x))

1.3 目录结构与依赖:把不确定性挡在门外

工程目录我会这样分,简单但够用:

detector/ data/ dataset.py augment.py labels/ models/ backbone.py neck.py head.py build.py loss/ yolo_loss.py assigner.py utils/ box.py nms.py metrics.py train.py val.py infer.py configs/base.yaml

依赖尽量少:torch、torchvision、numpy、opencv-python、pyyaml、tqdm。不要引入额外的检测框架,否则你会在别人的封装里迷路——这也是这个项目的意义所在。

配置全部走 YAML,包括输入尺寸、anchor、损失权重、学习率、增强概率。所有超参必须能从配置文件里读到,禁止在代码里写魔法数字。我吃过这个亏:某次调完 anchor 忘了改代码里硬编码的解码尺寸,训练 loss 正常但验证 mAP 是 0,查了一整天才发现是两处尺寸不一致。

2. 网络结构:三个尺度是怎么咬合起来的

2.1 骨干网络的降采样节奏

骨干的作用是把 416×416 的图压成 13×13,同时通道数从 3 涨到 512 或 1024。降采样靠步长为 2 的卷积完成,一共 5 次,正好 32 倍。这里有个细节值得说:第一次降采样我习惯用步长 2 的 3×3 卷积,而不是常见的 7×7 大核加池化,因为小数据集上大卷积核参数量浪费严重,且对输入分辨率变化不友好。

每次降采样后堆叠若干残差块,堆叠数量按[1, 2, 4, 4, 2]这样的节奏递增。为什么越深越多?因为分辨率越低,单个像素的感受野越大,语义信息越抽象,需要更强的非线性变换来提炼;而浅层特征图尺寸大,堆太多层显存吃不消。

class Backbone(nn.Module): def __init__(self): super().__init__() self.stem = ConvBNSiLU(3, 16, 3, 2) # 208 self.s1 = nn.Sequential(ConvBNSiLU(16, 32, 3, 2), Residual(32)) # 104 self.s2 = nn.Sequential(ConvBNSiLU(32, 64, 3, 2), *[Residual(64) for _ in range(2)]) # 52 self.s3 = nn.Sequential(ConvBNSiLU(64, 128, 3, 2), *[Residual(128) for _ in range(4)]) # 26 self.s4 = nn.Sequential(ConvBNSiLU(128, 256, 3, 2), *[Residual(256) for _ in range(2)])# 13 def forward(self, x): x = self.stem(x) x = self.s1(x) c3 = self.s2(x) # 52x52,小目标 c4 = self.s3(c3) # 26x26,中目标 c5 = self.s4(c4) # 13x13,大目标 return c3, c4, c5

2.2 颈部融合:自顶向下加横向连接的必要性

13×13 的特征图语义强但位置粗,52×52 的位置准但语义弱。颈部做的事情就是把强语义往下传,让每个尺度都既“看得懂”又“对得准”。具体操作是:13×13 经过一次上采样变成 26×26,和骨干的 26×26 逐元素相加,再过几个卷积消除混叠;然后重复一次到 52×52。

这里最容易踩的坑是上采样方式的选择。用nn.Upsample(mode='nearest')最简单也最稳,双线性插值虽然更平滑但会引入额外计算,而且在小特征图上差异可以忽略。另一个坑是通道对齐——横向连接相加之前,两侧通道数必须相等,所以骨干输出的每一级都要先用 1×1 卷积压到统一通道(比如 128)。

融合完之后,每个尺度再接一个检测头。检测头的输出通道数固定为3 × (5 + num_classes),其中 3 是每个格子的 anchor 数量。以 20 类为例,就是 3 × 25 = 75。

2.3 解码公式:把网络输出翻译成真实框

网络输出的tx, ty, tw, th是相对量,必须经过解码才能变成图片上的坐标。这套公式是整个实现里最容易被写错的地方,我把它拆开讲。

设格子左上角在特征图上的坐标为(cx, cy),该格子对应的 anchor 宽高为(pw, ph),特征图相对原图的步长为stride(比如 13×13 那层是 32)。

def decode(pred, anchors, stride, num_classes): """ pred: [B, 3, H, W, 5 + C] 最后一维顺序为 tx,ty,tw,th,obj,cls... anchors: [3, 2] 当前尺度的三个 anchor 返回 [B, H*W*3, 5 + C],xywh 已转成相对原图的归一化坐标 """ B, A, H, W, D = pred.shape device = pred.device # 构造网格坐标 gy, gx = torch.meshgrid( torch.arange(H, device=device), torch.arange(W, device=device), indexing='ij') grid = torch.stack((gx, gy), dim=-1).float() # [H, W, 2] grid = grid.view(1, 1, H, W, 2) # 广播用 xy = (torch.sigmoid(pred[..., 0:2]) + grid) * stride # 绝对像素坐标 wh = torch.exp(pred[..., 2:4].clamp(-8, 8)) * anchors.view(1, A, 1, 1, 2) * stride conf = torch.sigmoid(pred[..., 4:5]) cls = torch.sigmoid(pred[..., 5:]) out = torch.cat([xy, wh, conf, cls], dim=-1) return out.view(B, -1, D)

三个细节必须注意。第一,sigmoid保证了中心点偏移量落在 0~1 之间,也就是预测中心不会跑出自己所在的格子,这是单阶段检测器的核心约束,早期版本靠它来稳定训练。第二,宽高用exp而不是 sigmoid,因为宽高可能远大于 anchor,需要无上界的缩放;但exp在输入稍大时就会爆炸成 inf,所以我加了clamp(-8, 8),这个保护在实际训练中救过我至少两次。第三,meshgrid的indexing参数一定要显式写'ij',新版 PyTorch 默认行为变了,不写会得到一个转置的网格,症状是模型能训但框全部上下颠倒。

注意:解码和编码必须严格互为逆运算。训练时把真值框编码成tx, ty, tw, th用的公式,必须和解码公式完全对应,包括是否乘 stride、是否取 log。这两处不一致是“loss 能降但框不准”的头号原因。

3. 数据管道:大部分精度问题其实出在这里

3.1 标注转换与 Dataset 的两个硬约束

自定义数据集的标注格式五花八门,有的是左上右下角点,有的是中心点加宽高。我统一在 Dataset 里做一次转换,全部落成cx, cy, w, h归一化格式,并且存盘时就转好,不要在__getitem__里每次都转,那样会让数据加载成为瓶颈。

Dataset 返回前有两个硬约束必须满足。一是所有坐标裁剪到[0, 1]区间,因为增强操作(比如随机缩放)会把框推到图外,不裁的话编码时算出来的负宽高会让 loss 直接变 nan。二是过滤掉宽或高小于 2 像素的框,这类框在 13×13 的尺度上不到 0.1 个格子,根本没法分配到正样本,留着只会污染统计。

class DetDataset(torch.utils.data.Dataset): def __init__(self, list_file, img_size=416, augment=None): self.items = [l.strip().split() for l in open(list_file)] self.img_size = img_size self.augment = augment def __len__(self): return len(self.items) def __getitem__(self, i): img_path, lbl_path = self.items[i] img = cv2.imread(img_path)[:, :, ::-1] # BGR -> RGB h0, w0 = img.shape[:2] labels = np.loadtxt(lbl_path, ndmin=2) # [N, 5] cls cx cy w h # 统一缩放到方形输入,保持长宽比,空余部分填 114 r = min(self.img_size / h0, self.img_size / w0) nh, nw = int(h0 * r), int(w0 * r) img = cv2.resize(img, (nw, nh)) canvas = np.full((self.img_size, self.img_size, 3), 114, np.uint8) dh, dw = (self.img_size - nh) // 2, (self.img_size - nw) // 2 canvas[dh:dh+nh, dw:dw+nw] = img if len(labels): labels[:, 1] = (labels[:, 1] * nw + dw) / self.img_size labels[:, 2] = (labels[:, 2] * nh + dh) / self.img_size labels[:, 3] = labels[:, 3] * nw / self.img_size labels[:, 4] = labels[:, 4] * nh / self.img_size img_t = torch.from_numpy(canvas.transpose(2, 0, 1)).float().div(255) if self.augment is not None and len(labels): img_t, labels = self.augment(img_t, labels) return img_t, torch.from_numpy(labels).float()

这段代码里有个容易忽略的坑:填边用的灰度值 114 不是随便选的。它是 ImageNet 归一化后的近似中位数,用 0 填充会让网络把大片黑色区域当成强特征,尤其在边缘有小目标时表现很明显。另外缩放后再算框坐标,容易把归一化顺序搞反,我的做法是先还原到像素域再重新除以目标尺寸,逻辑上不容易错。

3.2 增强策略的取舍:不是越多越好

检测任务的增强和分类任务完全不是一回事。分类任务里你随便翻转裁剪都没事,检测里每一次几何变换都必须同步变换框坐标,而且有些增强会直接破坏检测的合理性。

增强手段建议概率说明
水平翻转0.5最安全,几乎所有场景都适用
随机缩放 + 平移0.5缩放范围控制在 0.5~1.5,超出会让小目标彻底消失
色彩抖动0.5亮度、对比度、饱和度各 ±0.2,别调太狠
随机擦除0.1~0.3概率过高会误删目标,导致标签噪声
垂直翻转视场景行人、车辆等有明确上下方向的场景不要用
任意角度旋转慎用框会变成外接矩形,引入大量背景,小目标尤其吃亏

我踩过最典型的一次坑是给一个俯拍场景加了 90 度随机旋转,想着反正是俯视没有方向性。结果 mAP 掉了 6 个点,排查发现是标注本身有方向偏差——标注员是按“车头朝上”的习惯标的,旋转后框的位置系统性偏移了几个像素。增强策略必须服从数据本身的统计规律,而不是反正不要钱的往上堆。

3.3 Mosaic 增强:收益明显但要控制频率

把四张图拼成一张,是单阶段检测里性价比最高的增强之一。它的好处是让一张图里出现更多小目标和更丰富的背景组合,等于变相扩充了数据。实现上要注意两点。

一是拼接之后的框坐标要重新计算,四张图各自缩放到 2 倍输入尺寸的一半再随机裁切,最后拼成完整的输入尺寸。二是拼完必须过滤掉太小的框,因为拼的过程中会有框被裁掉大半,剩下一条边,这种框留着就是毒样本。

def mosaic4(imgs, labels, img_size=416): """四图拼接,imgs 为已 letterbox 到 img_size 的列表""" s = img_size out = np.full((s * 2, s * 2, 3), 114, np.uint8) cx, cy = np.random.randint(s // 2, s * 3 // 2, 2) # 拼接中心点 new_labels = [] for i, (im, lb) in enumerate(zip(imgs, labels)): h, w = im.shape[:2] # 四张图分别放在左上、右上、左下、右下 if i == 0: x1a, y1a, x2a, y2a = max(cx - w, 0), max(cy - h, 0), cx, cy elif i == 1: x1a, y1a, x2a, y2a = cx, max(cy - h, 0), min(cx + w, s*2), cy elif i == 2: x1a, y1a, x2a, y2a = max(cx - w, 0), cy, cx, min(cy + h, s*2) else: x1a, y1a, x2a, y2a = cx, cy, min(cx + w, s*2), min(cy + h, s*2) x1b, y1b = x1a - (cx - w if i in (0, 2) else 0), y1a - (cy - h if i in (0, 1) else 0) x2b, y2b = x1b + (x2a - x1a), y1b + (y2a - y1a) out[y1a:y2a, x1a:x2a] = im[y1b:y2b, x1b:x2b] if len(lb): l = lb.copy() l[:, 1] = (l[:, 1] * w - (cx - w if i in (0, 2) else 0) + x1a) / (s * 2) l[:, 2] = (l[:, 2] * h - (cy - h if i in (0, 1) else 0) + y1a) / (s * 2) l[:, 3] *= w / (s * 2) l[:, 4] *= h / (s * 2) new_labels.append(l) labels = np.concatenate(new_labels, 0) if new_labels else np.zeros((0, 5)) # 过滤无效框 if len(labels): keep = (labels[:, 3] > 4 / (s * 2)) & (labels[:, 4] > 4 / (s * 2)) labels = labels[keep] out = cv2.resize(out, (s, s)) return out, labels

Mosaic 的概率我给 0.5,而不是常见的 1.0。原因是全程开 Mosaic 会让模型严重依赖拼接图的统计特征,验证集(不做拼接)上表现会掉一截。训练最后 10%~15% 的 epoch 关掉 Mosaic,让模型重新适应真实分布,通常能多挤出 1~2 个点。

4. 损失函数:正负样本怎么分才是关键

4.1 三项损失的职责划分

单阶段检测的损失由三部分组成,各自的职责必须分清楚,否则调参就是盲人摸象。

  • 置信度损失:管“这个位置有没有目标”。正样本标签为 1,负样本为 0,忽略样本不参与计算。用二分类交叉熵。
  • 分类损失:管“有目标的话是什么类别”。只在正样本上算。用二分类交叉熵逐类计算,而不是 softmax 多分类,因为一个格子可能同时负责多个目标。
  • 框回归损失:管“框的位置准不准”。只在正样本上算。早期用 L1 或 L2,现在基本都用 IoU 系列的变体。

这里有个关键的失衡问题:一张图里正样本可能就十几个,负样本有几千个。直接用求和会让置信度损失压过另外两项,模型学出来的东西是“到处都说没目标”。解决办法是给正负样本的置信度损失分别加权,正样本权重调高,负样本权重调低。

4.2 正样本分配:三种策略的实操对比

这是整个实现里最影响最终效果的一环。我试过三种,都跑过完整对比。

策略做法优点缺点
最大 IoU每个真值框只分给 IoU 最大的那一个 anchor实现简单,不容易震荡正样本太少,小目标召回差
IoU 阈值IoU 大于 0.5 的都算正样本正样本充足,收敛快阈值固定,对密集目标不友好
中心点+阈值真值中心落在格子内 且 IoU 大于下限正样本分布均匀需要调两个阈值

我用得最多的是第二种的改良版:每个真值框先强行分给 IoU 最大的 anchor(保证每个真值至少有一个正样本),然后把同尺度上 IoU 超过 0.5 的其余 anchor 也标记为正样本。同时把 IoU 在 0.3~0.5 之间的标记为忽略样本——它们跟目标有重叠但又不够准,算作负样本会给模型错误的惩罚信号。这个小细节很关键,加进去之后收敛曲线明显平滑了。

def assign_targets(gt_boxes, gt_cls, anchors, num_classes, strides, img_size): """ gt_boxes: [N, 4] 归一化 cxcywh 返回每个尺度的 (obj_target, cls_target, box_target) """ targets = [] for si, (anchor_set, stride) in enumerate(zip(anchors, strides)): gs = img_size // stride obj = torch.zeros(3, gs, gs) cls = torch.zeros(3, gs, gs, num_classes) box = torch.zeros(3, gs, gs, 4) if len(gt_boxes) == 0: targets.append((obj, cls, box)); continue # 真值框与当前尺度三个 anchor 的 IoU(按宽高算,位置无关) gt_wh = gt_boxes[:, 2:4] inter = torch.min(gt_wh[:, None, :], anchor_set[None]) inter = inter[..., 0] * inter[..., 1] union = (gt_wh[:, None, 0] * gt_wh[:, None, 1] + anchor_set[None, :, 0] * anchor_set[None, :, 1] - inter) iou_wh = inter / union.clamp(min=1e-6) # [N, 3] # 每个真值选最佳 anchor best_a = iou_wh.argmax(1) for n in range(len(gt_boxes)): gx, gy = (gt_boxes[n, :2] * gs).long().clamp(0, gs - 1) a = best_a[n] obj[a, gy, gx] = 1.0 cls[a, gy, gx] = torch.zeros(num_classes).scatter_(0, gt_cls[n].long().view(1), 1.0) box[a, gy, gx] = gt_boxes[n] targets.append((obj, cls, box)) return targets

这段代码有个我自己踩过的坑:best_a用argmax时,如果多个真值框同时指向同一个 anchor 和同一个格子,后写的会覆盖前面的。在密集小目标场景下这会造成漏检。我的处理是统计每个格子被写了几次,超过一次的记录下来单独处理——简单做法是把多余的真值丢给次优 anchor。这一处改动在一个密集小目标数据集上把召回率拉高了接近 8 个点。

4.3 损失权重与学习率调度

权重我给的经验值是:框回归 0.05、分类 0.5、正样本置信度 1.0、负样本置信度 0.5。框回归权重给得小,是因为 IoU 损失的数值范围本来就在 0~1,而分类损失是逐类求和,通道多的时候会放大几十倍。这几个数字不是圣旨,但作为起点很稳。

学习率调度上我用的是线性 warmup + 余弦退火。前 500 到 1000 步从 1e-5 线性升到初始学习率,然后余弦降到初始值的 0.01 倍。warmup 这一步不能省,尤其是 batch 比较大时,一开始就用大学习率会让 BN 层的统计量剧烈波动,前几百步的 loss 会是一个很陡的尖峰,之后要么恢复要么彻底发散。

def build_scheduler(opt, warmup_steps, total_steps, base_lr): def fn(step): if step < warmup_steps: return step / max(1, warmup_steps) p = (step - warmup_steps) / max(1, total_steps - warmup_steps) return 0.01 + 0.99 * 0.5 * (1 + math.cos(math.pi * p)) return torch.optim.lr_scheduler.LambdaLR(opt, fn)

另外梯度裁剪也建议开,clip_grad_norm_(params, 10.0)。检测模型的梯度偶尔会突然飙高,尤其是遇到一张标注特别密集的图,不裁剪的话一个 batch 就能把之前几天的训练全毁掉。

5. 训练循环与验证:让每一步都可观测

5.1 训练循环骨架与日志设计

训练循环本身不复杂,难的是日志。我建议至少打印五项:总 loss、三项分项 loss、当前学习率、正样本数量、以及每层的最大梯度值。前三项看收敛,第四项看学习率调度对不对,第五项看数值是否稳定。

for epoch in range(epochs): model.train() for imgs, targets in loader: imgs = imgs.to(device).float() preds = model(imgs) # 三个尺度 loss, parts = criterion(preds, targets) opt.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 10.0) opt.step() sched.step() if step % 50 == 0: log(f"ep{epoch} it{step} loss={loss:.3f} " f"box={parts['box']:.3f} obj={parts['obj']:.3f} " f"cls={parts['cls']:.3f} lr={sched.get_last_lr()[0]:.2e} " f"pos={parts['num_pos']}")

正样本数量这个指标特别值得盯。正常情况下一张图的正样本数应该在几十到一百多,如果长期是 0,说明分配逻辑写错了或者标注格式不对;如果长期是几千,说明 IoU 阈值设得太低,模型会把大量背景当正样本学。这个数字比 loss 曲线更早暴露问题。

5.2 验证与 mAP 计算

验证阶段要做三件事:切换 eval 模式、关闭增强、按固定流程做 NMS 和指标计算。mAP 的计算我习惯自己写一遍而不是用现成库,因为要知道每个数字怎么来的。

流程是这样的:把所有验证图的预测框按类别收集起来,每张图存下类别、置信度、坐标;然后把真值框按类别和图号索引好;接着对每个类别,按置信度从高到低遍历预测框,逐个判断是否命中同图同类的真值框(IoU 大于阈值且未被匹配过),命中记为 TP,否则记为 FP;最后累积算出精确率和召回率曲线,对曲线做积分得到该类 AP,各类平均得到 mAP。

def compute_ap(rec, prec): """全点插值求 AP""" mrec = np.concatenate(([0.], rec, [1.])) mpre = np.concatenate(([1.], prec, [0.])) for i in range(len(mpre) - 2, -1, -1): mpre[i] = max(mpre[i], mpre[i + 1]) idx = np.where(mrec[1:] != mrec[:-1])[0] return np.sum((mrec[idx + 1] - mrec[idx]) * mpre[idx + 1])

这里有个容易搞错的地方:同一个真值框只能被匹配一次。如果不做已匹配标记,密集场景下多个预测框命中同一个目标,会把召回率虚高,mAP 也就失真了。我第一次写的时候漏了这个标记,结果 mAP 算出来 0.85,实际跑图一看漏了一半的目标。

5.3 NMS 的实现与阈值选择

NMS 的逻辑很直白:按置信度排序,取最高的那个,删掉与它 IoU 超过阈值的其余框,重复直到没有框。用torchvision.ops.nms就够,速度快且经过充分验证。

阈值的选择有讲究。0.45 是我常用的默认值。如果你的场景里目标之间重叠严重(比如密集人群、堆放的商品),把阈值提到 0.6 甚至 0.7,否则相邻目标会被误删;如果是稀疏场景,阈值压到 0.3 能减少误检。另外 NMS 之前一定要先做置信度过滤,我一般设 0.001 到 0.01 之间——这个值只是用来砍掉明显不是目标的框,不能设太高,否则真实目标的低分框会被提前删掉,后面做 mAP 时召回上不去。

def postprocess(preds, conf_thres=0.01, iou_thres=0.45, max_det=300): from torchvision.ops import nms out = [] for p in preds: # 逐尺度 boxes, scores, classes = p[:, :4], p[:, 4], p[:, 5:] cls_conf, cls_idx = classes.max(-1) keep = scores * cls_conf > conf_thres boxes, scores, cls_idx = boxes[keep], scores[keep], cls_idx[keep] if boxes.numel() == 0: out.append(boxes.new_zeros((0, 6))); continue idx = nms(boxes, scores, iou_thres)[:max_det] out.append(torch.cat([boxes[idx], scores[idx, None], cls_idx[idx, None].float()], 1)) return torch.cat(out, 0)

注意max_det这个上限要设。曾经有一次我忘了设,验证集里一张纹理复杂的图预测出两万多个框,后处理阶段直接把内存吃满,进程被系统杀掉。

6. 踩坑记录:那些文档里不会写的排查经验

6.1 损失不降与发散,先怀疑数据再怀疑模型

症状:loss 在前几十步不降,或者先降后突然变成 nan。

我的排查顺序是这样的:先拿一个 batch 的固定数据反复喂,看 loss 是否稳定;然后检查标注里有没有宽高为 0 或者负数的框;再看解码时的exp有没有加保护;最后才是检查网络结构。

八成的情况问题出在标注上。常见的脏数据包括:坐标忘了归一化(值在 0 到 1000 之间)、框的左上右下顺序写反了、类别编号从 1 开始而代码按 0 开始索引。这些错误不会抛异常,只会让 loss 诡异地停在那里。

还有一个隐蔽的原因:学习率相对 batch size 太大。我用 8GB 卡跑小 batch 时,学习率通常设 1e-3 起步;但如果换到 16 张卡分布式,学习率按线性缩放应该到 8e-3,此时 warmup 就必须延长,否则前几百步必崩。

6.2 框全糊在一起 / 检测框疯狂重叠

症状:验证图上的框全都是密密麻麻叠在一起的一坨。

这种情况基本可以锁定为两个原因。一是编码解码不一致,比如训练时编码用了log(w / anchor_w),解码却写成exp(t) * anchor_w / stride,多除了一次 stride,导致预测框全被压缩到左上角。二是meshgrid的索引顺序错了,导致每个位置的预测中心都被映射到了错误的位置。

排查办法很简单:构造一个理想的预测张量——让某个格子的tx, ty都是 0,tw, th都是 0,obj给个很大的正数——直接过一遍解码函数,看输出的框是不是正好落在那个格子的中心、宽高等于 anchor 的宽高。这个单元测试强烈建议写,五分钟的事,能省掉一整天的怀疑人生。

6.3 常见问题速查表

现象最可能的原因处理方式
loss 一直是 nan标注负宽高 / exp 溢出 / 学习率过大裁剪坐标、clamp 指数项、降学习率并延长 warmup
训练 loss 正常,验证 mAP 接近 0训练和验证的预处理不一致对齐缩放方式、归一化参数、通道顺序
正样本数长期为 0标注格式不对 / anchor 尺寸严重不匹配打印真值框宽高分布,重新聚类 anchor
只检出大目标,小目标全漏52×52 尺度的头位置没接对 / 小 anchor 缺失检查颈部到小尺度的路径,重新设计小尺寸 anchor
置信度普遍很低负样本权重压得太狠 / 正样本数量太少上调正样本置信度权重,降低正向匹配阈值
推理速度远低于预期后处理在 CPU 上做了逐框循环 / 输入分辨率过大用向量化 nms,或适当降分辨率
训练几百 epoch 后突然崩学习率在退火末尾降得太低导致 BN 统计漂移检查 BN 的 momentum,或干脆冻结 BN

6.4 几条我反复验证过的实操心得

第一,先用极小数据集过拟合。拿 8 到 16 张图,训 200 个 epoch,如果 loss 不能降到接近 0 且能在这些图上检出所有目标,那说明整个链路有 bug,别急着上全量数据。这个动作我每次都做,它能在十分钟内暴露掉 90% 的实现问题。

第二,把中间结果可视化出来。训练前把增强后的图连同框画一遍,检查框是否和物体对齐;训练中把预测的正样本位置在图上点出来,看是不是落在目标上;推理后把框画出来看。三张图一看,问题基本自己浮现。

第三,anchor 一定要按自己的数据重新聚类。通用 anchor 是在特定数据集上统计出来的,你换成工业质检或者航拍图,宽高比分布可能差出好几倍。用 k-means 对真值框的宽高聚成 9 类,然后按面积从小到大分给三个尺度(小 anchor 给 52×52 那层,大 anchor 给 13×13 那层)。这一步在我做过的项目里,每次都能稳定提升几个点,成本又极低。

第四,别迷信大分辨率。输入从 416 提到 640,小目标召回确实会涨,但显存和耗时是平方级增长。如果你的场景里目标本来就不小,或者目标尺寸分布比较集中,把分辨率降下来换取更大的 batch size 往往收益更高——BN 的统计量在大 batch 下明显更稳。

第五,保存检查点要存三样东西。模型权重、优化器状态、当前 epoch 和 scheduler 状态,缺一样都没法干净地恢复训练。我吃过只存权重、恢复后学习率从头开始算的亏,那次恢复训练后的效果比不恢复还差。

我个人在实际操作中的体会是,把检测模型跑通不难,难的是知道每一步为什么这么做。上面这些参数和阈值,都只是我这些年在具体场景里试出来的一组能用的起点,换成你的数据,anchor 要重算、增强要重挑、阈值要重扫,唯一能带走的经验是那套排查顺序:先确认数据,再确认形状,再确认编码解码一致,最后才动模型本身。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 5:34:15

昇腾AI开发利器:CANN-utils工具集实战解析

1. 项目概述&#xff1a;CANN生态与cann-utils工具集定位在昇腾AI处理器的开发实践中&#xff0c;CANN&#xff08;Compute Architecture for Neural Networks&#xff09;作为基础计算架构&#xff0c;承担着连接上层AI框架与底层硬件的关键角色。而cann-utils正是这个生态中一…

作者头像 李华
网站建设 2026/10/26 5:34:10

Kubernetes资源优化实战:从基础配置到高级策略

1. 为什么Kubernetes资源优化如此重要&#xff1f;当你在凌晨三点被报警电话惊醒&#xff0c;发现生产环境因为OOM&#xff08;内存溢出&#xff09;而崩溃时&#xff0c;就会深刻理解资源优化的重要性。我经历过太多这样的夜晚——不是因为应用本身有bug&#xff0c;而是资源配…

作者头像 李华
网站建设 2026/10/26 5:33:59

Llama 3接入自有知识库:RAG问答全链路落地实践

做知识库问答这套东西&#xff0c;我前后换过三套技术栈&#xff0c;从最早拿小参数模型硬扛&#xff0c;到后来上 Llama 3 重新搭一整套检索增强链路&#xff0c;踩的坑基本都集中在“模型之外”的地方。这次把 Llama 3 接进自有知识库的实践过程完整摊开讲一遍&#xff0c;重…

作者头像 李华
网站建设 2026/10/26 5:33:48

别堵死线程:AI流式输出与异步调度全解析

做 AI 应用最难受的时刻之一,就是你明明调的是流式接口,结果用户那边看到的还是一个转圈圈,过了四五秒才“哗啦”一下全出出来。更难受的是,后端根本没报错,线程池却悄悄死了,接口一个接一个超时,进程没挂,但整个服务已经“假死”。这种问题我见过太多次,也亲手排查过太多回,根…

作者头像 李华
网站建设 2026/10/26 5:33:47

Stable Diffusion底层原理:从扩散模型到文生图的数学推演

1. 这不是“AI绘画工具说明书”&#xff0c;而是一份从零推演Stable Diffusion底层逻辑的实操手记我第一次跑通Stable Diffusion的时候&#xff0c;没用WebUI&#xff0c;也没点“生成”按钮——而是手动写了一个37行的PyTorch脚本&#xff0c;把一张纯噪声图&#xff0c;一帧一…

作者头像 李华
网站建设 2026/10/26 5:33:45

xc7a100tfgg484参考设计实战:供电时钟配置与XDC约束生成

简介&#xff1a;这份资源围绕 Xilinx Artix-7 系列 xc7a100tfgg484 芯片展开&#xff0c;是一套完整的 FPGA 参考设计工程&#xff0c;面向正在做硬件方案评估、原理图与 PCB 设计复用的工程师及高年级学生&#xff0c;可用于快速理解该型号芯片的外围电路组织方式与工程搭建思…

作者头像 李华