量化验证的取样偏差:IoU 0.98 的模型如何在真实图片上掉到 0.30
量化验证的取样偏差:IoU 0.98 的模型如何在真实图片上掉到 0.30
站内搜索
直接问 AI

量化验证的取样偏差:IoU 0.98 的模型如何在真实图片上掉到 0.30

压缩模型和 FP32 输出一致,不等于它们都接近真实目标。本文保留一次浏览器抠图排查的历史线索,但重点改为三个能检查的问题:IoU 究竟和谁比较、二值化发生在哪个阶段、一次局部检查能排除多大范围的错误。

2026-09-08 修订:标题里的 0.98 与 0.30 来自旧实验记录,不是本轮重新测得的分数。目前可复核的模型材料只有现有 FP16 文件,原始失败图片、FP32/INT8 文件、完整输出和计时日志仍待补齐。本次新增的是可复跑的指标反例,不把合成遮罩当作历史模型输出,也不再把均值和最大值相同写成“逐位一致”。

一、历史记录能告诉我们什么

旧记录描述的是 ONNX Runtime Web 浏览器抠图:先生成软遮罩,再二值化、分层和描轮廓。动态 8 位量化版本在部分动漫插画上与 FP32 很接近,遇到主体与背景颜色接近的输入时保留区域明显减少。这是值得追查的现象,但没有样本清单与原始张量,就不能把它升级为总体准确率或已确定的唯一原因。

旧记录 数值与边界
先前样例 INT8 对 FP32 的 IoU 范围 0.976–0.984;未记录完整样本数、抽样方式和逐样本结果。
失败输入 INT8 对 FP32:[email protected] 为 0.296,最大绝对差 0.388,平均绝对差 0.0547。不是对人工标注的 IoU。
FP16 对照 对 FP32 的 IoU 记为 0.9996,最大差约 0.001。没有原始输出时,不能在本轮确认这些数值。
速度与体积 旧稿的“快 35%”、79/337 ms,以及 167.9/84.0/42.1 MB,缺少完整计时边界、环境或单位核对,不作为性能排名。
展开旧稿的覆盖率表,仅作待复核记录
threshold    int8     fp16     fp32
      48    32.4%    33.3%    33.3%
      80    25.9%    32.0%    32.0%
     112    11.8%    29.2%    29.2%
     127     7.6%    25.9%    25.9%

这个表记录的是覆盖率,不是 IoU。相同覆盖率不代表前景位于相同坐标;阈值 127 也不能在未说明取整与比较规则时直接标成原始浮点阈值 0.5。旧稿中“身体至少占四成”一类目测不能代替逐像素标注。

二、四个排查方向,不等于四个全面证伪

检查 能支持什么,不能支持什么
输出形状 名称、类型、维度和 1,048,576 个元素吻合,支持这组接口约定;不能排除转置、坐标偏移、错误张量或裁剪错误。
预处理 一张图片结果正常,只验证该例。复核同一失败输入还需检查解码、方向、色彩、插值、padding、通道顺序与实际输入张量哈希。
轮廓 包围盒吻合不等于面积、孔洞和连通性正确。应把轮廓栅格化回同一网格,比较逐像素差异,并声明像素边界约定。
后端 max=0.9727、mean=0.1264 相同只说明这两个舍入统计相同。需相同模型和输入,比较完整输出、NaN/Inf、最大差及误差位置。

即使逐值对照通过,也只约束指定模型、输入、版本和选项,不能宣布后端对所有图片都“无辜”。四个候选原因没有得到支持,也不自动证明剩下的某个原因唯一成立;应先固定模型、输入、预处理、后端和后处理,再每次改变一个因素。

同样,试过两个模型仍失败,不足以说明换模型永远无效。覆盖率更大未必更准确,没有架构、训练集与特征分析,也不能断言“不同架构证明图片在特征空间里太接近”。软遮罩的灰度是输出分数,不自动等于经过校准的不确定性,更不能据此判断模型是否“看见了”某个部位。

三、用 16 个像素把 IoU 的含义讲清楚

本例约定:分数 s >= t 为前景,IoU 是前景交集像素数除以前景并集像素数。两份预测之间的 IoU衡量一致性;预测对独立标注的 IoU才在这个标注任务上评价质量。两边都是空前景时,本实验返回 null,并报告数量,不悄悄记成满分。

构造两个 4×4 分数阵列:A 每行是 [0.8,0.8,0.2,0.2],B 每行反过来。它们的完整直方图、均值、最大值和前景覆盖率都一样;在阈值 0.5 下,各有 8 个前景像素,却完全不重合,所以 IoU 为 0。若把 A 指定为这个合成例子的目标,两份完全相同的 B 相互 IoU 为 1,但对目标都为 0。

四组合成遮罩的逐像素对照:位置交换、共同错误、远离阈值和靠近阈值;红色标出判定变化
图由实验文件直接绘制,阈值均为 0.5。青色为前景,红色为两份预测的判定差异;Target 是构造规则指定的目标,不是真实图片的人工标注。
展开完整 spatial_counterexample.py
"""Equal histograms and coverage do not imply spatial or label agreement."""
def iou(a, b):
    if len(a) != len(b) or not a:
        raise ValueError('Expected equally sized, nonempty masks')
    intersection = sum(x and y for x, y in zip(a, b, strict=True))
    union = sum(x or y for x, y in zip(a, b, strict=True))
    return intersection / union if union else None


a = [0.8, 0.8, 0.2, 0.2] * 4
b = [0.2, 0.2, 0.8, 0.8] * 4
mask_a = [x >= 0.5 for x in a]
mask_b = [x >= 0.5 for x in b]
assert sorted(a) == sorted(b)
assert sum(a) == sum(b) and max(a) == max(b)
assert sum(mask_a) == sum(mask_b) == 8
assert iou(mask_a, mask_b) == 0.0
assert iou(mask_b, mask_b) == 1.0
assert iou([False]*16, [False]*16) is None
print('same histogram; foreground 8/16 in each; A versus B IoU = 0')
print('if truth is A, two copies of B agree perfectly but both have truth IoU = 0')
print('SPATIAL_COUNTEREXAMPLE_OK')

实验包还区分逐样本平均与合并计数:一张图交集/并集是 1/1,另一张是 0/99,则宏平均是 0.5,合并交并集得到的微平均是 0.01。两种计算回答不同问题,都应写出分母及空集处理。本包五组合成数据在 0.5 下,排除一组空并集后的宏平均为 0.5,微平均为 12/32=0.375;这些不是任何模型的验证成绩。

四、距离阈值,比“模型很有把握”更可检查

设原始分数为 s,扰动后为 s+δ,阈值为 t。若每个像素都满足 |δ| < |s-t|,则其二值判定不会翻转。这是一个充分条件,不是“量化误差永远很小”的保证;真实量化误差可能有结构、受算子和输入影响,不能一律建模为独立小噪声。

本包把中央 2×2 区域设为目标。同样减去 0.02,原分数为前景 0.95/背景 0.05 时,在 0.5 阈值下前景不变;换成 0.51/0.49,四个前景像素全部消失,预测间 IoU 从 1 变为 0。这只是分数扰动反例,没有运行量化器。

把后一例阈值降低到 48/255,两份预测的一致性又变成 1,但全图都成了前景,对构造目标的 IoU 只有 0.25。降低阈值可能增加召回,也可能引入背景,不能只看覆盖率变大就宣称修复。

本站现有代码还有一个具体细节:裁剪后的分数先写入 Uint8ClampedArray,经 Canvas 缩放后,再比较 full[k*4] > cut。本实验的原始浮点指标没有这些步骤。下面只隔离 8 位取整和比较:缩放值 80、80.25、80.5、80.75 被转换成 80、80、80、81;使用 >80 只保留最后一个,而原始分数 >=80/255 会保留四个。

展开完整 uint8_threshold.cjs
'use strict';
const assert = require('node:assert/strict');
const scaled = [80, 80.25, 80.5, 80.75];
const score = scaled.map(x => x / 255);
const byte = Array.from(new Uint8ClampedArray(score.map(x => x * 255)));
const byteMask = byte.map(x => x > 80);
const rawMask = score.map(x => x >= 80 / 255);
assert.deepEqual(byte, [80, 80, 80, 81]);
assert.deepEqual(byteMask, [false, false, false, true]);
assert.deepEqual(rawMask, [true, true, true, true]);
console.log(JSON.stringify({ scaled, byte, byte_gt_80: byteMask, raw_ge_80_over_255: rawMask }));
console.log('UINT8_THRESHOLD_OK');

所以要同时导出原始模型输出、缩放后的 8 位图和最终二值遮罩,在同一阶段比较。交互阈值实验可以帮助理解扰动,但其人工分数与噪声不是原始模型,也不能替代真实量化验证。

五、FP16 保留接口,不代表无损或一定更快

keep_io_types=True 表示转换时保留 float32 输入输出;不意味着所有内部节点都变成 FP16,阻止转换的算子可能仍保留 float32,并插入类型转换。是否能执行、是否加速、误差是否满足需求,都要对实际图和后端测量。参数语义见 ONNX Runtime FP16 转换说明。

旧稿把“截到 ±1e-7”的警告直接归因于 FP16 最小非规格化数,这不准确。IEEE binary16 最小正非规格化数是 2^-24,约 5.96e-8;转换器的 min_positive_val=1e-7 是另一个裁剪参数。数值还要经过 FP16 舍入,不能把警告阈值当作物理表示下限。具体逻辑见 转换器实现。警告本身既不能证明质量受损,也不能证明没有影响。

当前可复核的 FP16 文件为 88,070,593 字节。ONNX 部署教程已经公开该文件的哈希、两组合成输入和完整 WASM 输出;它验证资源和接口,不验证本篇旧记录中的 0.9996,也不建立 FP16/INT8 速度排名。

六、下一次应怎样组成验证集

先定义用途,再固定抽样与评价规则。用能代表预计输入的独立验证集估计日常表现,另设低对比度、细结构、遮挡和非典型画风等压力分组;两部分分开报告,不把刻意放大的困难样例比例当作真实流量分布。

每个样本记录输入与模型哈希、预处理版本、后端、阈值和导出阶段。报告原始分数误差、预测间一致性、对独立标注的质量、前景计数和失败样例,不只给一个平均数。比较后端时还要冻结图优化等选项;性能测试分别记录下载、创建会话、预热和重复推理。

在调参集上选择阈值,冻结后再看独立留出集。已经反复调试过的失败图片可以保留作回归测试,但不再是独立确认。没有找到失败样例,既不能证明稳健,也不能单凭这一点断言取样有偏;要检查覆盖范围、样本量和遗漏的输入类型。

七、下载指标实验,或换成自己的导出文件

下载软遮罩指标实验包。五组 4×4 合成输入在五个阈值下产生 25 行记录,附原始文件、CSV、JSON、绘图源码和验证测试。测量部分只用 Python 标准库;8 位取整例子需要 Node,不下载模型、不需要 GPU。

python3 spatial_counterexample.py
node uint8_threshold.cjs
python3 make_fixtures.py --output reproduced-fixtures
python3 evaluate_masks.py --manifest reproduced-fixtures/pairs.json --output reproduced
python3 -B test_metrics.py

输出目录必须尚不存在。测量成功会显示 MASK_METRICS_OK 5 samples 25 rows。换成自己的文件时,先按README声明 H×W、原始小端 float32 分数、可选的 0/1 标注和阈值,再运行同一测量器。它会拒绝长度错误、非有限值、越界分数和无效标注,但不能替你证明原始坐标已经对齐或数据确实独立。

这次修订能确认的是指标定义、反例结果和现有代码的阈值步骤。历史模型的精度比较仍需要原始材料,而“量化是唯一原因”“FP16 对这类输入普遍无损”都不应从现有证据推出。

发表回复

向下探索