推理能跑完、结果却和预期对不上时,先别动权重,也别急着重训。多数偏差落在两处接缝上:进网络之前的预处理(通道顺序、归一化、尺寸变换),和出网络之后的后处理(阈值、坐标还原、类别映射)。这两类错误的表现很像,都是框偏、类别错、置信度怪,但验证动作完全不同,混在一起改只会互相干扰。可行的做法是把输入张量和输出张量各打印一次,再用“一次只改一个变量”的对照实验,把预处理和后处理分开定性。
适用场景:模型能加载、推理能跑完,但可视化结果与预期不一致。判断方向是先确认可复现性,再按“输入张量 → 原始输出张量 → 解码结果”三段对齐,而不是先怀疑权重。操作动作:固定一张图与随机种子,逐项对照通道顺序、归一化、尺寸变换,再核对解码与坐标还原。验证方式:两次运行输出一致,且解码后的框能对回原图坐标。风险边界:两次运行不一致时,先解决随机性再谈对齐。
固定一张图和一个随机种子复现问题
先把输入固定:选一张内容明确、目标清晰的图,存到一个固定路径,后面所有实验都用它。然后关掉推理链路里的随机源,常见的有:模型没切到 eval 模式(dropout、BN 仍在按 batch 统计)、推理时仍带随机数据增强或 TTA、多尺度多裁剪投票、后处理里的随机采样。用固定种子加显式关闭开关:
import random, numpy as np, torch
def seed_everything(seed=0):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
# 推理前
seed_everything(0)
model.eval()两次运行一致性的验证方法:把后处理之前的原始输出存成 npy,或者打印张量的 shape、min、max,再用 allclose 比较两次结果。如果两次不一致,先固定 cudnn 行为、固定线程数,再继续后面的对照。边界在于:GPU 上个别算子本身存在非确定性,若差异只出现在小数点后很多位,通常可以接受;但如果结果的结构都变了,说明随机源没关干净,此时任何对照结论都不可信。
通道顺序与归一化参数逐项对照
输入端的偏差一般来自三处,逐项对,不要一起改。
通道顺序
读图库的默认顺序并不统一,OpenCV 常见 BGR,PIL 常见 RGB。检查方法可以造一张只有三种已知颜色的图,或打印一个已知像素的三通道值,确认进入网络的第一维到底是 R 还是 B。
img = cv2.imread(path) # 默认 BGR
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
print('after convert, pixel(0,0) =', img[0, 0])归一化均值方差
均值方差的来源应当是训练侧的预处理配置,不是凭经验填。需要拿训练 config、导出脚本或模型说明里的预处理定义来对照,同时确认两点:mean/std 的通道排列顺序是否与你当前的通道顺序一致;是否除以过 255。这两项是分开的开关,容易漏。
x = img.astype(np.float32) / 255.0
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
x = (x - mean) / std
x = x.transpose(2, 0, 1)[None, ...]
print('input tensor', x.shape, float(x.min()), float(x.max()), float(x.mean()))尺寸变换
确认是直接 resize 还是保持长宽比加填充(letterbox),填充值、插值方式也要和训练侧一致。把这一步算出来的缩放比例和 padding 记下来,第 4 节的坐标还原要用。
打印输出张量的统计量判断是否合理
要区分“权重或加载不对”还是“后处理解错了”,最省事的办法是在 forward 之后、任何激活与 NMS 之前,把原始输出打印出来。
with torch.no_grad():
out = model(x)
outs = out if isinstance(out, dict) else {'out': out}
for k, t in outs.items():
print(k, tuple(t.shape), t.dtype,
float(t.min()), float(t.max()), float(t.mean()))
print('nan:', bool(torch.isnan(t).any()),
'inf:', bool(torch.isinf(t).any()))重点看四个观察点:形状是否与导出说明一致,batch 维、锚点维、类别维有没有搞反;dtype 是 float16 还是 float32,有没有意外的整数类型;取值范围是否已经落在 0~1(说明网络内含激活,后处理里不能再套一次 sigmoid);极值分布里是否出现 NaN、Inf,或者整张张量几乎只有一个值。最后一种情况通常指向输入没真正送进网络,或者权重根本没加载上。
后处理解码:阈值、坐标还原、类别映射
阈值
先把置信度阈值临时调低(例如 0.1 这个量级,具体取值以你的配置为准),看框的整体位置对不对。如果位置对、只是数量偏多,往上调阈值就行;如果低阈值下位置也偏,问题在坐标还原或输入对齐,不在阈值。
坐标还原
网络输出的坐标通常是相对“送进网络的那张图”(已 resize 或 letterbox)的归一化值或像素值,还原到原图需要减 padding、除缩放比例:
scale = min(new_w / orig_w, new_h / orig_h)
pad_x = (new_w - orig_w * scale) / 2
pad_y = (new_h - orig_h * scale) / 2
x1 = (x1 - pad_x) / scale
y1 = (y1 - pad_y) / scale
x2 = (x2 - pad_x) / scale
y2 = (y2 - pad_y) / scale把还原后的框画回原图做肉眼比对,是最直接的验证。整体平移说明 pad 的加减方向反了;整体缩放不对,检查 scale 用的是取整后的输入尺寸还是原始浮点尺寸。
类别映射
把索引和名称打印成对照表,核对映射表是否与训练数据集一致。常见问题是映射表里多了一个 background 占位,导致整体错位一位,表现为“框对了、类别全错”。
对照实验:一次只改一个变量看结果怎么动
把可疑变量列成清单,每次只改一个,其余保持不动,逐行记录变化。变量清单和对照记录表可以这样组织:
| 变量 | 改法 | 预期变化 | 实际变化 | 判定 |
|---|---|---|---|---|
| 通道顺序 | RGB ↔ BGR 互换 | 颜色相关的框/类别明显变化 | 待填 | 变化明显则预处理未对齐 |
| 归一化 | 去掉或更换 mean/std | 置信度整体偏移 | 待填 | 同上 |
| 尺寸变换 | resize ↔ letterbox | 框位置整体偏移 | 待填 | 结合坐标还原一起看 |
| 激活 | 加/去一次 sigmoid | 置信度分布形态改变 | 待填 | 重叠则后处理多算了一层 |
| 阈值 | 调低或调高 | 框数量增减,位置不变 | 待填 | 位置也变则不是阈值问题 |
| 坐标还原 | 改 pad 加减或 scale | 框回到目标上 | 待填 | 命中则问题在还原公式 |
| 类别映射 | 整体偏移一位 | 类别名称对得上 | 待填 | 命中则映射表有占位类 |
结论的判定条件可以简化为几条:只改通道顺序或归一化之后结果接近预期,说明是预处理没对齐;输入张量与参考实现一致、只改解码就修正,说明是后处理没对齐;输入和输出张量都一致但渲染结果仍不对,优先怀疑类别映射或坐标还原顺序;所有单项都试过仍无改善,回到第一节确认可复现性,再考虑权重是否加载正确、模型与当前任务是否匹配。