第一次把现成框架里的检测模型跑起来,很多人会产生一种错觉:检测好像没那么难,配置文件改一改、权重下载下来、几行脚本一跑,框就画出来了。等到换成自己的数据、自己的场景,框开始飘、置信度开始失真、换个分辨率结果就完全不一样,才发现自己其实从来没搞明白那个框到底是怎么被算出来的。这篇东西就是写给处在这个阶段的人——逐行手写一个单目标检测算法,从数据管道、网络结构、损失函数到训练循环和推理评估,全部用最朴素的代码写一遍。单目标检测是最好的练手入口:一张图里只有一个要定位的目标,问题被压缩到"回归一个五维向量 + 判断有没有目标",没有 Anchor、没有 NMS、没有多尺度 FPN,所有复杂度都被剥掉,剩下的就是检测最核心的那点骨架。把这条线走通一遍,再回头看那些工业级模型,你会发现它们的每一处设计都不是凭空来的。下面的内容默认你会一点 Python、知道卷积是什么、跑过最简单的分类训练,其余的都从零讲。
1. 单目标检测的数学本质:一张图只回归一个框,问题简化在哪里
1.1 从"找框 + 分类"到"一个五维向量回归"
多目标检测的完整问题是在一张图里找出数量不定的若干目标,每个目标给出类别和位置。这个"数量不定"是所有复杂度的源头——你没法用一个固定维度的向量去表达不定长的输出,所以才需要网格、Anchor、Query 这些中间结构把不定长问题转成定长问题。单目标检测把这个源头直接掐掉:一张图里最多一个目标,输出维度天然固定。
于是模型要干的活变成了一件很朴素的事:输入一张 H×W×3 的图,输出一个五维向量[cx, cy, w, h, conf]。前四个数描述框的位置和大小,最后一个是"这张图里到底有没有目标"的置信度。整个网络就是一个从图像空间到五维向量空间的映射函数,训练就是让这个映射在训练集上尽可能逼近真值。
这个视角一确立,很多原本模糊的东西就清楚了。卷积骨干负责把像素压成能表示"那里有个东西、大概什么形状"的特征,全连接头负责把这堆特征翻译成四个坐标数字。它本质上是回归任务,不是分类任务,所以损失函数用 Smooth L1 或 IoU 系列,而不是交叉熵;所以学习率不能照搬分类任务的设置;所以输出层不能随便加 Softmax。这些结论后面都会展开,但根都在这里——你把问题看成什么,决定了你后面每一个技术选择。
顺带说一个常被忽略的点:单目标检测在"一张图必有一个目标"的假设下,其实可以完全砍掉置信度分支,退化成纯坐标回归。很多工业场景的裁剪图、证件图、票据图就属于这一类,目标必然存在。先做纯回归版本,跑通、看到框能对齐,再加置信度分支,这个递进顺序比一上来就搞全套要稳得多。
1.2 坐标参数化的三种写法与各自的下限
同样是回归四个数,写法不同,收敛速度和最终精度能差出一大截。我试过三种,逐个说。
第一种,直接回归归一化的中心点和宽高。把所有坐标除以图像宽高,映射到 [0,1],网络输出经过 Sigmoid 后与真值算损失。写法最简单,输出天然有界,不会出现负宽高这种荒谬结果。缺点是当目标普遍很小的时候,四个数的数值都挤在 0 附近,梯度信号弱,小目标的定位精度上不去。
第二种,回归绝对像素坐标。输出层不加激活,直接回归[x1, y1, x2, y2]的像素值。好处是尺度信息保留完整,小目标的坐标差值不会被压缩。坏处是换输入分辨率就得重新调损失权重,而且网络初期很容易输出负值或者超出图像范围的值,需要额外的裁剪和约束。
第三种,偏移量形式。先给一个粗糙的先验框(比如整张图的中心 60% 区域),网络回归的是真值框相对于先验框的偏移:中心点偏移量除以先验宽高,宽高比取对数。这是 Anchor 机制的核心思想,放在单目标场景下就是"用一个先验代替一堆先验"。它的数值分布最均匀,收敛最稳,但引入了一个额外超参——先验框的尺寸,选得不合适反而拖慢收敛。
我最后采用的是第一种加一点改良:中心点用 Sigmoid 约束到 [0,1],宽高不做归一化压缩,而是先取对数再回归。原因很实际——目标宽高的动态范围通常比中心点大得多,一张图里目标可能占 10% 也可能占 90%,直接回归原始比例时,大目标的损失会压倒性主导梯度。取对数之后,10% 和 90% 的差距从 9 倍压缩到约 2.2 倍,梯度尺度就均衡了。这个改动只有一行,但实测在小目标占比高的数据集上,收敛轮数能少三成左右。
| 参数化方式 | 数值范围 | 小目标表现 | 收敛稳定性 | 额外超参 |
|---|---|---|---|---|
| 归一化 cx,cy,w,h | [0,1] | 一般 | 较好 | 无 |
| 绝对像素 x1,y1,x2,y2 | [0, 图像尺寸] | 较好 | 较差 | 损失权重 |
| 先验框偏移量 | 近似零均值 | 好 | 好 | 先验尺寸 |
| 中心 Sigmoid + 宽高取对数 | 中心 [0,1],宽高对数域 | 好 | 好 | 无 |
表格里最后一行是我推荐的默认起点,不需要额外调参,也不容易炸。宽高取对数之后要记得在推理时做指数还原,这个还原步骤漏掉的话,画出来的框会小得离谱,我第一次写的时候就栽在这,对着满屏的小方框查了两个小时才发现是 exp 忘了加。
2. 数据管道搭建:标注格式、解析逻辑与增强同步
2.1 单目标标注的最小数据结构
单目标检测的数据组织比多目标简单太多,一张图配一个框,用不着 JSON 里套列表再套字典。我习惯用一个纯文本清单,每行四个数加一个路径:
images/0001.jpg 112 88 340 296 images/0002.jpg 45 130 402 355四个数就是x1 y1 x2 y2的像素坐标,左上角加右下角。选这个格式而不是中心点加宽高,理由很实在:标注工具导出的原始结果大多就是角点形式,少一次转换就少一次出错的机会,而中心点和宽高在 Dataset 里算一次只要两行代码。
Dataset 类的职责就三件事:读图、增强、把标注转成网络要的格式。写法如下:
import torch from torch.utils.data import Dataset import numpy as np import cv2 class SingleObjDataset(Dataset): def __init__(self, list_file, img_size=320, train=True): self.items = [] with open(list_file, 'r') as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue path = parts[0] box = np.array([float(v) for v in parts[1:]], dtype=np.float32) self.items.append((path, box)) self.img_size = img_size self.train = train def __len__(self): return len(self.items) def __getitem__(self, idx): path, box = self.items[idx] img = cv2.imread(path) if img is None: raise FileNotFoundError(path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h0, w0 = img.shape[:2] # 先做几何增强,再统一缩放 if self.train: img, box = self.random_scale_shift(img, box) img, box = self.resize_letterbox(img, box, self.img_size) img = img.astype(np.float32) / 255.0 img = np.transpose(img, (2, 0, 1)) target = self.encode(box, self.img_size) return torch.from_numpy(img), torch.from_numpy(target)这里有个顺序问题值得说清楚:几何增强必须在统一缩放之前做。反过来先缩放到固定尺寸再做增强,随机缩放的幅度就会被固定尺寸限制住,增强的多样性大打折扣。我早期就是先 resize 后增强,训出来的模型对目标尺度的鲁棒性明显偏差,换个输入分辨率就掉点。
另一个细节是decode的写法。角点转中心点加宽高,再归一化:
def encode(self, box, size): x1, y1, x2, y2 = box cx = (x1 + x2) * 0.5 / size cy = (y1 + y2) * 0.5 / size w = max((x2 - x1) / size, 1e-6) h = max((y2 - y1) / size, 1e-6) return np.array([cx, cy, np.log(w), np.log(h)], dtype=np.float32)max(..., 1e-6)这一句不是多余的防御。标注数据里偶尔会出现长宽为零的退化框,多半是标注工具手抖点出来的,如果不做保护,log(0)直接给你一个负无穷,反向传播的时候整个 batch 的梯度全变 NaN,而且报错位置离真正的问题点很远,排查起来非常折磨。数据清洗阶段就该把这些退化框过滤掉,Dataset 里再加一层保险。
2.2 增强必须和框坐标同步做的原因与写法
图像增强分两类,处理方式完全不同。颜色类的(亮度、对比度、色调抖动)只改像素不改几何,框不用动,放心用。几何类的(缩放、平移、裁剪、翻转)改了像素的空间位置,框必须跟着做完全相同的变换,否则标签和图像就对不上了。
对不上的后果不是训练报错,而是模型学到一个"图像内容和坐标无关"的映射,表现是损失降不下去、框在图上乱飘。这种问题最难查,因为代码不报错,loss 也在动,只是收敛到一个很差的水平。
平移加缩放的同步写法:
def random_scale_shift(self, img, box): h, w = img.shape[:2] scale = np.random.uniform(0.85, 1.15) dx = np.random.uniform(-0.08, 0.08) * w dy = np.random.uniform(-0.08, 0.08) * h M = np.array([[scale, 0, dx], [0, scale, dy]], dtype=np.float32) img2 = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT_101) x1, y1, x2, y2 = box pts = np.array([[x1, y1], [x2, y2]], dtype=np.float32) pts = pts * scale + np.array([dx, dy], dtype=np.float32) x1, y1 = pts[0] x2, y2 = pts[1] # 关键:变换后必须重新裁剪到图像范围内 x1 = np.clip(x1, 0, w - 1) y1 = np.clip(y1, 0, h - 1) x2 = np.clip(x2, 0, w - 1) y2 = np.clip(y2, 0, h - 1) if x2 - x1 < 4 or y2 - y1 < 4: return img, box # 退化就放弃这次增强 return img2, np.array([x1, y1, x2, y2], dtype=np.float32)clip那段是重点。增强之后框很容易有一部分跑到图像外面,不裁剪的话,归一化后的坐标会出现负数或者大于 1 的值,而网络的中心点分支经过 Sigmoid 只能输出 [0,1] 范围内的数,永远拟合不了这个越界标签,结果就是这个样本持续贡献一份降不下去的损失。我在一个数据集上见过损失卡在 0.8 左右死活不降,最后发现是 3% 的样本增强后框越界导致的。
还有一个容易被忽略的判断:裁剪之后框可能变得极小甚至反向(x2 < x1)。这种样本留着就是噪声,直接退回原图原框更稳妥。上面代码里的if x2 - x1 < 4就是干这个的。
Letterbox 缩放也要处理同步问题。等比缩放加灰边填充,比直接拉伸要好,因为拉伸会改变目标的宽高比,而宽高比本身是模型要学的信息之一:
def resize_letterbox(self, img, box, size): h, w = img.shape[:2] r = min(size / h, size / w) nh, nw = int(round(h * r)), int(round(w * r)) img_r = cv2.resize(img, (nw, nh)) canvas = np.full((size, size, 3), 114, dtype=np.uint8) top = (size - nh) // 2 left = (size - nw) // 2 canvas[top:top + nh, left:left + nw] = img_r x1, y1, x2, y2 = box box2 = np.array([x1 * r + left, y1 * r + top, x2 * r + left, y2 * r + top], dtype=np.float32) return canvas, box2注意填充值用 114 而不是 0。纯黑边在卷积里会产生很强的边缘响应,模型容易把"图像边界"和"目标边界"搞混;114 是个中性灰,接近常见图像的平均亮度,边缘响应弱得多。这个技巧在分类任务里也通用。
2.3 负样本的构造——很多人漏掉的一环
如果你的模型带置信度分支,那负样本就是必需品,而且是最容易漏掉的一环。假设训练集里每张图都有目标,置信度标签全是 1,那这个分支学到的就是一个恒等于 1 的常数函数,推理时任何输入都会被判成"有目标",分支等于白加。
构造负样本有三条路,按推荐程度排:
第一条,收集真实背景图。从部署场景里截一些确实没有目标的图,这是最贴近真实分布的负样本,效果最好。麻烦在于要额外收集,而且正负比例不好控制。
第二条,从正样本里裁掉目标区域。取一张有目标的图,把目标框区域用周边像素填充或者直接裁掉一块背景,得到一张"没有目标但风格一致"的图。这条路的优点是风格天然匹配,不需要额外数据;缺点是要注意填充方式,简单涂黑会留下明显的伪影,模型可能学会"看到黑色方块就判负"这种捷径。
第三条,随机裁剪背景块。从图像的非目标区域随机裁一块出来当负样本。实现最简单,缺点是多样性有限,同一张图裁出来的块长得很像。
实际做法通常是三条混着用,正负比例控制在 1:1 到 1:3 之间。比例太高会让模型过于保守,明明有目标也判负;比例太低则分支学不扎实。我一般从 1:1 开始,看验证集上的误检率再调。
这里有个设计选择值得说清楚:负样本的坐标标签怎么办?常规做法是坐标损失对负样本不计算,只算置信度损失。实现上用一个 mask 把负样本的坐标项屏蔽掉就行:
pos_mask = target[:, 4] > 0.5 # 置信度标签为 1 的才是正样本 coord_loss = coord_criterion(pred[pos_mask][:, :4], target[pos_mask][:, :4]) conf_loss = bce(pred[:, 4], target[:, 4]) loss = coord_loss + conf_loss如果偷懒不屏蔽,让负样本也参与坐标回归,模型会被迫去拟合一堆随意填的坐标值,坐标分支的精度会明显下降。这个坑我在早期版本里踩过,当时的症状是置信度判断很准,但框的位置总是差那么一点,后来加了 mask 才恢复正常。
3. 网络与回归头:为什么这条路线可以彻底甩掉 Anchor 和 NMS
3.1 骨架选型的取舍:轻量 CNN 够不够用
单目标检测对骨干网络的要求比多目标低一个量级。多目标需要骨干在每个空间位置都保留足够强的语义信息,才能分辨相邻的小目标;单目标只需要骨干把整张图压成一个有判别力的全局特征向量,空间分辨率的要求低得多。所以没必要上 ResNet-50 这种量级的骨干,一个五六层的轻量卷积网络配合全局池化就够了。
我常用的骨架是这样的结构:五组"卷积 + BN + ReLU + 步长 2",通道数从 32 递增到 256,最后接一个自适应全局平均池化把任意尺寸的特征图压成 256 维向量。
import torch.nn as nn def conv_block(cin, cout): return nn.Sequential( nn.Conv2d(cin, cout, 3, stride=2, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), ) class TinyDetector(nn.Module): def __init__(self, num_out=4, use_conf=True): super().__init__() self.use_conf = use_conf self.backbone = nn.Sequential( conv_block(3, 32), # /2 conv_block(32, 64), # /4 conv_block(64, 128), # /8 conv_block(128, 192), # /16 conv_block(192, 256), # /32 ) out_dim = num_out + (1 if use_conf else 0) self.head = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(256, 128), nn.ReLU(inplace=True), nn.Linear(128, out_dim), ) def forward(self, x): return self.head(self.backbone(x))步长 2 的卷积连续用五次,输入 320 的图进入瓶颈层的时候特征图是 10×10,感受野已经覆盖整张图。这个设计有个隐含好处:模型天然对输入尺寸不敏感,因为全局池化把任意尺寸都压成同一个向量,推理时换分辨率不需要改结构。当然精度会有变化,因为感受野的相对尺度变了,但至少不会报错。
通道数不往上涨的原因也要说清楚。单目标检测的判别难度低,256 个通道已经能表达足够的信息,继续加到 512、1024 只会让参数暴增、过拟合提前到来。我在一个小数据集上试过把通道翻倍,训练集损失降得更快,验证集精度反而掉了两个点,典型的容量过剩。
还有个细节:所有卷积都设了bias=False,因为紧跟 BN,偏置会被 BN 的减均值操作吸收掉,留着纯属浪费参数。这个习惯值得养成。
3.2 回归头的输出维度设计与激活函数选择
回归头是整个模型里最需要小心的地方,因为激活函数选错会直接让某类目标永远学不会。
回到第 1 章定下的参数化方案:输出[cx, cy, log_w, log_h, conf]。对应的激活处理是这样的:
cx、cy走 Sigmoid,约束到 [0,1]log_w、log_h不加激活,保持线性输出conf走 Sigmoid,输出概率
为什么中心点要加 Sigmoid 而宽高不加?中心点有明确的值域——一定在图像内部,Sigmoid 提供了这个先验,等于免费给模型减少了一半的搜索空间。宽高取对数之后值域是(-∞, +∞),理论上目标可以极小也可以极大,加任何有界激活都会截断这个范围。如果给宽高加 Sigmoid,模型输出的归一化宽高就永远小于 1,虽然大部分情况够用,但遇到占满整张图的目标时,梯度会被饱和区压死,框永远贴不满。
实现上在 forward 里做拆分:
def forward(self, x): raw = self.head(self.backbone(x)) xy = torch.sigmoid(raw[:, :2]) wh = raw[:, 2:4] if self.use_conf: conf = torch.sigmoid(raw[:, 4:5]) return torch.cat([xy, wh, conf], dim=1) return torch.cat([xy, wh], dim=1)这里有个容易混淆的地方:log_w直接线性输出,意味着网络输出的数值可能很大也可能很小。为了防止初期梯度爆炸,可以在 head 的最后一个 Linear 上做权重初始化,把初始输出压在零附近。用nn.init.zeros_初始化最后一层的 bias 是个简单有效的做法,初始时所有框都预测在图像中心、宽高为 1(因为exp(0)=1),起点非常中性,不会给训练初期引入奇怪的偏置。
另一个选择是输出维度到底要不要拆成两个头。有人喜欢把坐标和置信度分成两个独立的全连接分支,理由是两类任务的梯度性质不同,分开可以减少干扰。我试过,在单目标这个规模上差异不明显,反而多了一组参数要调。共用一个 head 的输出、在损失层面分开计算,已经足够。
4. 损失函数逐行推导:坐标项与置信度项怎么配平
4.1 Smooth L1 与 GIoU 的实测差异
坐标损失有两个主流选择,Smooth L1(也叫 Huber 损失)和 IoU 系列。它们优化的目标不一样,效果也不一样。
Smooth L1 是逐元素比较预测值和真值的差,误差小的时候用平方(梯度随误差线性减小),误差大的时候用绝对值(梯度恒定,不会被离群点带飞)。公式是:
$$ L = \begin{cases} 0.5 (x - y)^2 & |x - y| < 1 \ |x - y| - 0.5 & \text{otherwise} \end{cases} $$
它的优点是实现简单、数值稳定、四个坐标独立优化。缺点是四个坐标独立并不等于框整体最优。举个例子,预测框和真值框的宽高都很接近,但中心点偏了一点,Smooth L1 会认为这是个不错的预测,而实际上 IoU 可能只有 0.6。反过来,宽高差得多但位置准,Smooth L1 给出很大的损失,IoU 反而可能还行。这种错位在小目标上尤其明显。
GIoU 直接优化框的重叠程度,把四个坐标当作一个整体来考虑。计算流程是先算 IoU,再算能包住两个框的最小闭包面积,用闭包面积做惩罚项:
def giou_loss(pred, target, eps=1e-7): # pred/target: [N, 4] 格式为 cx, cy, w, h(归一化,w/h 已 exp 还原) px1 = pred[:, 0] - pred[:, 2] * 0.5 py1 = pred[:, 1] - pred[:, 3] * 0.5 px2 = pred[:, 0] + pred[:, 2] * 0.5 py2 = pred[:, 1] + pred[:, 3] * 0.5 tx1 = target[:, 0] - target[:, 2] * 0.5 ty1 = target[:, 1] - target[:, 3] * 0.5 tx2 = target[:, 0] + target[:, 2] * 0.5 ty2 = target[:, 1] + target[:, 3] * 0.5 ix1 = torch.max(px1, tx1); iy1 = torch.max(py1, ty1) ix2 = torch.min(px2, tx2); iy2 = torch.min(py2, ty2) iw = (ix2 - ix1).clamp(min=0) ih = (iy2 - iy1).clamp(min=0) inter = iw * ih area_p = (px2 - px1).clamp(min=0) * (py2 - py1).clamp(min=0) area_t = (tx2 - tx1).clamp(min=0) * (ty2 - ty1).clamp(min=0) union = area_p + area_t - inter + eps cx1 = torch.min(px1, tx1); cy1 = torch.min(py1, ty1) cx2 = torch.max(px2, tx2); cy2 = torch.max(py2, ty2) area_c = (cx2 - cx1).clamp(min=0) * (cy2 - cy1).clamp(min=0) + eps iou = inter / union giou = iou - (area_c - union) / area_c return (1 - giou).mean()两个损失我都实测过。在目标尺寸分布比较均匀的数据集上,两者最终精度差不太多,GIoU 收敛稍快一点,大概能省 10% 到 15% 的轮数。在尺寸差异大的数据集上,GIoU 的优势明显,尤其是小目标,因为 Smooth L1 在小目标上的损失值本来就很小,梯度被大目标的损失淹没,模型会倾向于先把大目标做好、小目标凑合。GIoU 是按比例算的,小目标的损失不会被尺度稀释。
代价是 GIoU 的数值稳定性差一些。当预测框和真值框完全不相交时,闭包面积的计算会退化,梯度方向可能不稳。实践中加一个小的 eps 就够了,但如果训练初期预测框全挤在中心点附近、完全覆盖不到真值,GIoU 的梯度会比较乱。我的做法是前几个 epoch 用 Smooth L1 预热,等框大致散开了再切到 GIoU,切换点一般在验证集 IoU 达到 0.3 左右。这个技巧在文献里不常见,但实测很有效。
4.2 置信度分支的标签设计与 BCE
置信度分支是一个二分类问题,用二元交叉熵,标签是有目标为 1、无目标为 0。听上去没什么可讲的,实际上有两个细节决定成败。
第一个细节是标签平滑。直接给 0 和 1 的硬标签,模型会倾向于输出极端的值(0.001 或 0.999),这会让模型过度自信,遇到分布外的输入时校准很差。用 0.05 的标签平滑,把正样本标签从 1 改成 0.95、负样本从 0 改成 0.05,模型输出的分布会温和很多,实际部署时的阈值选择也更宽松。这个改动在分类任务里很常见,检测的置信度分支同样适用。
第二个细节是正负样本在损失里的权重。负样本通常远多于正样本(即使你按 1:1 构造,实际 batch 里也可能失衡),BCE 会被负样本主导。解决办法是用pos_weight参数给正样本加权,或者用 Focal Loss 压制易分负样本的贡献。在小数据集上我倾向于直接调pos_weight,一般设成 2 到 5 之间,看验证集上的误检和漏检比例再定。
import torch.nn.functional as F def conf_loss_fn(pred_conf, target_conf, pos_weight=3.0): w = torch.where(target_conf > 0.5, torch.full_like(target_conf, pos_weight), torch.ones_like(target_conf)) return F.binary_cross_entropy(pred_conf, target_conf, weight=w)注意pos_weight不能设得太高。设到 10 以上,模型会变得极度激进,宁可错检也不漏检,在实际场景里通常不可接受。这个参数本质上是业务取舍的编码,不是纯粹的调参技巧,设之前先想清楚你的场景更怕误检还是更怕漏检。
4.3 权重系数的调节顺序
两个损失加起来的时候,系数怎么定?最省事的做法是都设 1.0,但这样往往不work,因为两个损失的数值量级天然不同。坐标损失(Smooth L1)在训练初期的典型值可能是 0.5 左右,置信度损失在 0.7 左右,数量级接近,问题不大;但如果坐标损失用了 GIoU,初期值可能在 0.9,而置信度损失可能只有 0.3,直接相加就是坐标主导。
我的调节顺序是这样的:
- 先把置信度损失的系数设成 0,只训坐标分支,看验证集 IoU 能不能稳定上升。这一步是确认数据管道和坐标回归本身没问题。
- 坐标分支能收敛之后,把置信度系数加到 1,观察置信度的准确率。如果一直上不去,检查负样本构造。
- 最后回头微调两者的比例,一般坐标项稍微重一点(系数 1.0 对 0.5 到 0.8),因为定位精度是检测的核心指标,置信度的作用只是过滤。
这个顺序的价值在于把问题隔离。两个分支一起训的时候,如果整体不收敛,你没法判断是坐标错了还是置信度错了,得同时排查两条线,效率极低。分开训,每一步只有一个变量。
顺便说一个数值上的小技巧:训练前几个 batch 打印一下两个损失的具体值,如果某个损失的初始值超过 10,说明大概率有问题(参数化写错了、标签没归一化、或者激活函数用错了),不要急着调系数,先查实现。
5. 训练循环从零手写:优化器、调度与梯度观测
5.1 手写训练循环的骨架
不用框架自带的高层 Trainer,自己写循环的好处是每一步都看得见。骨架不长:
import torch from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, device, epoch, use_giou=False): model.train() total_loss = 0.0 total_coord = 0.0 total_conf = 0.0 for step, (imgs, targets) in enumerate(loader): imgs = imgs.to(device) targets = targets.to(device) preds = model(imgs) # 统一还原宽高:网络输出的是 log(w), log(h) pred_box = torch.cat([ preds[:, :2], torch.exp(preds[:, 2:4].clamp(-4, 4)) ], dim=1) gt_box = torch.cat([ targets[:, :2], torch.exp(targets[:, 2:4].clamp(-4, 4)) ], dim=1) pos_mask = targets[:, 4] > 0.5 if targets.shape[1] > 4 else \ torch.ones(targets.shape[0], dtype=torch.bool, device=device) if use_giou: coord_loss = giou_loss(pred_box[pos_mask], gt_box[pos_mask]) else: coord_loss = F.smooth_l1_loss(pred_box[pos_mask], gt_box[pos_mask]) if preds.shape[1] > 4: conf_loss = conf_loss_fn(preds[:, 4], targets[:, 4]) else: conf_loss = torch.tensor(0.0, device=device) loss = coord_loss + 0.8 * conf_loss optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() total_coord += coord_loss.item() total_conf += conf_loss.item() n = len(loader) return total_loss / n, total_coord / n, total_conf / ntorch.exp(...clamp(-4, 4))里的 clamp 是个保险。如果网络某一时刻输出了 10 这样的大值,exp(10)是 22026,参与 GIoU 计算时面积项直接爆掉,反传回来又是 NaN。加上 clamp 之后,宽高最大是exp(4)≈54.6,最小是exp(-4)≈0.018,都在合理范围内。这个限制在训练初期特别重要,因为那时候网络的输出完全是随机的。
5.2 梯度裁剪与学习率探测
梯度裁剪在检测任务里几乎是必需的。原因在于坐标回归的损失对预测框的位置很敏感,训练初期预测框可能离真值十万八千里,梯度会很大,一个 batch 就能把权重推到一个很糟糕的位置,之后再也回不来。clip_grad_norm_把所有参数的梯度范数限制在阈值以内,阈值设 5.0 或者 10.0 都行,太小会拖慢收敛,太大会失去保护作用。我一般从 10.0 开始,看到 loss 有尖峰就往下调。
学习率的设置有个简单可靠的探测方法:从小往大扫,观察前 50 个 batch 的 loss 曲线。从 1e-5 开始,每次乘 3,一直试到 loss 开始发散为止,取发散前那个值的十分之一作为初始学习率。这个方法比凭感觉猜要靠谱得多,尤其是在你没跑过的新数据集上。
优化器用 Adam 就够,单目标检测这个规模用不着 SGD 加 momentum 那套调参手艺。Adam 的默认参数(lr=1e-3, betas=(0.9, 0.999))已经是很不错的起点,但要注意 weight decay 的设置有差异:Adam 的 L2 正则和 AdamW 的解耦正则不是一回事,用小数据集时 AdamW 的泛化通常更好一点。我现在的默认选择是 AdamW,weight decay 设 1e-4。
学习率调度用余弦退火最省心:
from torch.optim.lr_scheduler import CosineAnnealingLR scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-6)每个 epoch 结束后调一次scheduler.step()。余弦的好处是前期降得慢、中期降得快、后期趋近于零,正好匹配训练的实际需求——前期需要学习率保持稳定以便快速下降,后期需要小学习率做精修。对比阶梯式衰减,余弦调度不需要人工设定下降的节点,少一个超参要调。
另外强烈建议加余弦重启或者早停。单目标检测在小数据集上很容易在 30 到 50 个 epoch 之后开始过拟合,验证集 IoU 掉头向下。早停的判据选验证集 IoU,patience 设 10 个 epoch,回退到最好的那个检查点。这个机制救过我很多次,尤其是在数据量不确定的情况下。
5.3 用可视化盯住收敛过程
训练过程中的日志数字能告诉你"有没有在学",但告诉你"学成什么样"的,只有可视化。
我习惯每 5 个 epoch 从验证集里抽 8 张图,把预测框和真值框同时画上去,绿色是真值、红色是预测,存成一张拼图。观察的重点不是"框准不准",而是错误有没有规律:
- 所有预测框都偏大或者偏小 → 宽高参数化的还原可能有问题
- 所有预测框都往某个方向偏 → 中心点的坐标计算可能有偏移(比如忘了减半或者多减了)
- 部分图准部分图不准 → 数据分布不均或者增强过度
- 预测框全部集中在图像中心 → 模型没在学,输出退化成初始值
这四种模式对应的排查方向完全不同,光看 loss 曲线是分不出来的。我遇到过一次训练 loss 稳定下降、验证 loss 也稳定下降,但画出来的框全部偏离真值半个身位,最后发现是 letterbox 的 padding 偏移在 decode 时忘记加回去了。如果只看数字,这个 bug 可能永远发现不了。
可视化的另一个用处是检查数据管道的正确性。在训练之前,先把 Dataset 出来的图连框画一遍,确认框和图像内容对得上。这一步只花五分钟,但能省掉后面几小时的痛苦排查。我现在的习惯是每次换数据集、改增强逻辑之后,都先跑一遍这个检查。
6. 推理与评估:IoU 命中率与可视化回写
6.1 推理流程与反归一化
推理的流程是训练流程的逆运算,顺序不能乱。先把图 letterbox 到训练尺寸,前向得到五维向量,然后依次做:宽高取指数还原、坐标反归一化到 letterbox 尺寸、减去 padding 偏移、除以缩放比例回到原图尺寸。
@torch.no_grad() def predict(model, img_path, img_size=320, conf_thr=0.5): model.eval() img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h0, w0 = img.shape[:2] r = min(img_size / h0, img_size / w0) nh, nw = int(round(h0 * r)), int(round(w0 * r)) resized = cv2.resize(img, (nw, nh)) canvas = np.full((img_size, img_size, 3), 114, dtype=np.uint8) top = (img_size - nh) // 2 left = (img_size - nw) // 2 canvas[top:top + nh, left:left + nw] = resized x = canvas.astype(np.float32) / 255.0 x = torch.from_numpy(x.transpose(2, 0, 1)).unsqueeze(0) out = model(x)[0].cpu().numpy() cx, cy = out[0], out[1] w = np.exp(np.clip(out[2], -4, 4)) h = np.exp(np.clip(out[3], -4, 4)) conf = out[4] if len(out) > 4 else 1.0 if conf < conf_thr: return None, conf x1 = (cx - w / 2) * img_size y1 = (cy - h / 2) * img_size x2 = (cx + w / 2) * img_size y2 = (cy + h / 2) * img_size # 减 padding、除缩放,回到原图坐标 x1 = (x1 - left) / r y1 = (y1 - top) / r x2 = (x2 - left) / r y2 = (y2 - top) / r x1 = np.clip(x1, 0, w0 - 1); y1 = np.clip(y1, 0, h0 - 1) x2 = np.clip(x2, 0, w0 - 1); y2 = np.clip(y2, 0, h0 - 1) return np.array([x1, y1, x2, y2]), conf这段代码里有三个地方极容易出错,我按踩坑频率排序。
第一,减 padding 和除缩放的比例要对齐。先把 letterbox 坐标减掉 top/left,再除以 r,顺序反了结果就错。因为 padding 是在缩放后的图上加的,所以必须先减 padding 再除缩放。
第二,宽高的还原用的是训练时同样的 clamp 范围。如果训练时 clamp 到 [-4,4],推理时也要用同样的范围,否则可能出现训练时不会出现的极端宽高。
第三,原图尺寸用img.shape拿,不要用文件元信息。有些图的 EXIF 里带着旋转标记,OpenCV 读的时候会自动应用(取决于版本),而标注是在未旋转的图上做的,两者就对不上了。稳妥的做法是统一在读取时处理旋转,或者干脆在数据准备阶段把带旋转标记的图全部转正保存。
6.2 评估指标的简化实现与解读
单目标检测的评估比多目标简单太多,没有匹配问题(一张图最多一个预测对上一个真值),所以直接算命中率就行:IoU 大于阈值算命中,否则算未命中。
def iou_xyxy(a, b): ix1 = max(a[0], b[0]); iy1 = max(a[1], b[1]) ix2 = min(a[2], b[2]); iy2 = min(a[3], b[3]) iw = max(0.0, ix2 - ix1) ih = max(0.0, iy2 - iy1) inter = iw * ih area_a = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1]) area_b = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1]) union = area_a + area_b - inter return inter / union if union > 1e-7 else 0.0 def evaluate(model, list_file, iou_thr=0.5, conf_thr=0.5): hits, total = 0, 0 for path, gt in load_list(list_file): pred, conf = predict(model, path, conf_thr=conf_thr) total += 1 if pred is not None and iou_xyxy(pred, gt) >= iou_thr: hits += 1 return hits / total指标本身简单,解读起来有几个注意点。
第一,IoU 阈值的选择影响很大。0.5 是通用标准,但对小目标来说,0.5 其实很宽松——一个 20×20 的目标,预测框偏移 6 个像素 IoU 就掉到 0.5 了。如果你的场景对小目标定位精度要求高,应该同时报 0.5 和 0.75 两个阈值下的结果,两者的差距能反映定位的精细程度。
第二,只看命中率会掩盖问题。一个模型可能命中率 90%,但那 10% 的失败集中在某一类目标或者某个尺度区间。评估的时候按目标面积分桶统计,把面积分成小(小于图像面积的 1%)、中、大三档,分别报命中率,才能看出模型的短板在哪。我做过一个项目,总体命中率 0.92 看着不错,分桶一看小目标只有 0.61,问题一下就暴露了。
第三,验证集和测试集的划分要按场景分,不能随机分。如果数据是连续采集的视频帧,随机划分会让相邻帧分别落进训练集和验证集,两帧几乎一样,验证集指标虚高得离谱。正确做法是按时间段或者按采集批次划分,保证验证集和训练集在分布上有真正的差异。这个坑我踩得很深,曾经有一个模型在随机划分下验证集 0.95,按批次划分直接掉到 0.72。
6.3 可视化调试的价值
评估指标是给结论用的,可视化是给过程用的。除了看最终的框,我还会把中间结果可视化出来,特别是置信度的分布。
具体做法是把验证集所有样本的置信度画成直方图,正样本(有目标)和负样本(无目标)分开画。一个健康的模型,两条分布应该分得很开,正样本集中在 0.8 以上、负样本集中在 0.2 以下,中间有个明显的低谷可以用来选阈值。如果两条分布重叠严重,说明置信度分支学得不好,需要回头检查负样本构造或者正负权重设置。
还有一个可视化技巧是在验证集上跑预测,把预测框和真值框的中心点距离和宽高比误差分别统计出来。这两个量比 IoU 更有诊断价值:中心点距离大说明定位整体偏移,宽高比误差大说明尺度估计有问题。IoU 是这两个量的综合,但综合之后就看不出是哪边的问题了。
7. 跑通却收敛不了:五类典型现场与排查链
7.1 框整体偏移的排查顺序
这是最常见的问题:模型训了几十个 epoch,loss 在降,但画出来的框整体偏上、偏左或者偏一个固定距离。这种"整体偏移"的特征非常有辨识度,因为它说明模型学到了正确的相对关系,只是坐标系的原点搞错了。
排查按这个顺序走:
第一步,检查 letterbox 的 padding 是否在 decode 环节被减掉。这是最高频的原因。训练时图像被加了灰边放到 320×320 的画布上,框的坐标是相对于画布算的;如果推理时直接从画布坐标除以缩放比,没有先减掉 padding 偏移,框就会整体往右下偏(因为 padding 在左上)。我遇到过偏移量正好等于top/r和left/r的情况,一算就对上号了。
第二步,检查中心点和角点的转换是否对称。训练时用的是cx = (x1+x2)/2,推理时用的是x1 = cx - w/2,两边必须严格互逆。如果训练时忘了除以 2,或者推理时减了w而不是w/2,就会出现成比例的偏移。
第三步,检查图像读取时的尺寸是否和标注一致。有些标注是标注工具按原始分辨率记的,但图像在预处理阶段被统一缩放过,两者对不上就会引入系统偏移。验证方法很简单:随便取一张图,把真值框和缩放后的框同时画出来,看是否重合。
第四步,检查数据增强里的随机平移是否在验证时被误用。如果验证集的 Dataset 也开了随机平移,那评估结果每次都不同,看起来像"整体偏移"。这个错误很低级,但我见过不止一次,根源是train=True这个参数在构建验证集时忘了改。
7.2 置信度恒为 1 或恒为 0
置信度输出恒为 1,几乎可以肯定是负样本的问题:要么训练集里根本没有负样本,要么负样本的标签被写成了 1,要么负样本在 batch 里始终没被采样到。
排查的起点是打印每个 batch 的正负比例。在训练循环里加一行统计targets[:, 4].mean(),如果一直是 1.0,那就是没有负样本。如果比例正常但置信度还是恒为 1,检查conf_loss_fn里的标签是否被正确传入——有一种隐蔽的 bug 是 target 的列索引写错了,把坐标当成了置信度标签。
置信度恒为 0 则是反过来的问题:负样本权重给得太高,模型不敢判正。这种情况在pos_weight设得很大时很常见。排查方法是临时把pos_weight调回 1,看置信度是否恢复正常;如果恢复,说明权重是元凶,慢慢往上加找到平衡点。
还有一种中间状态:置信度分布正常,但阈值附近大量样本挤在一起,导致阈值稍微一动准确率就大幅波动。这说明模型对正负样本的判别力不够,通常是特征提取能力不足或者训练数据太少,需要加容量或者加数据,调阈值解决不了根本问题。
7.3 过拟合与"框贴脸"
"框贴脸"是我自己起的名,指的是预测框紧紧贴合目标,看起来甚至比真值框还准,但换一张没见过的图就崩。这是典型的过拟合表现,而且因为它在小数据集上出现得特别早,很多人会误以为模型训得很好。
判断方法很简单:同时看训练集和验证集的命中率。训练集 0.98、验证集 0.65,就是明确的过拟合。健康的状态是两者差距在 10 个百分点以内。
应对手段按性价比排序:
第一,加数据。这是最有效的,没有之一。单目标检测对数据量的要求比分类高,因为要同时学位置和尺度。经验上至少需要几百张图才能训出一个可用的模型,几百张以下就得靠强增强来撑。
第二,加强增强。随机缩放的范围从 0.85-1.15 放大到 0.7-1.3,随机平移的幅度从 8% 加到 20%,再加随机水平翻转(如果目标没有方向性的话)。增强的强度上限取决于目标的先验——如果你的场景里目标总是水平居中的,那平移增强开到 20% 反而有害。
第三,降容量。把通道数从 256 降到 128,或者去掉一层卷积。单目标检测很容易过参数化,小数据集上小模型往往表现更好。
第四,加正则。weight decay 从 1e-4 加到 1e-3,或者在 head 前面加 Dropout(p=0.2 到 0.3)。Dropout 在检测任务里用得不多,但在单目标这个规模上是有帮助的,因为它作用在全连接层,正好压制回归头对训练样本的记忆。
我个人的经验是,如果加数据不可行,把增强开到当前的两倍强度,往往能换来 3 到 5 个点的验证集提升,这个性价比远高于调网络结构。而所有的正则手段加在一起,效果通常也不如"多 200 张标注数据"。
最后分享一个我在调试这类算法时一直用的笨办法:找一个最小的可复现单元。比如只用 10 张图、固定随机种子、关掉所有增强,让模型去拟合这 10 张图。如果连 10 张图都拟合不了(训练集 IoU 上不到 0.9),那问题一定在实现层面,不在数据或者超参。这个办法能把排查范围从"整个系统"压缩到"某几行代码",是我见过的最高效的定位手段。