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

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

我把一个抠图模型从 167.9 MB 量化到 42.1 MB,在测试集上量到 IoU 0.976–0.984,推理快了 35%,于是上线了。一周后收到一张图,模型只抠出了角色头顶王冠的一个角。

同一个模型,同一套预处理,IoU 从 0.98 掉到 0.30

这篇不是量化教程,是一次排查记录。我先后提出四个假设,前三个都被实验推翻,第四个才是真的——而真正的教训不在量化本身,在我当初验证它的取样方式

现场

场景是浏览器端的动漫角色抠图:ONNX Runtime Web 加载 isnet-anime,在用户设备上跑分割,输出遮罩后做矢量化。模型用 quantize_dynamic(QuantType.QUInt8) 动态量化。

上线前我在动漫人物插画上测了量化前后的一致性:

IoU (int8 vs fp32)     0.976 – 0.984
平均绝对差              0.004 – 0.023
推理耗时                 -35%
体积                     167.9 MB -> 42.1 MB

这组数字看起来没有任何问题。出问题的那张图是一只卡通生物:浅蓝色身体、金色头冠,背景是蓝绿色的山和黄色的天空。主体和背景大面积同色。

返回的统计是「主体占比 8.2%」。这只生物在画面里至少占四成。

假设一:模型输出的形状对不上

代码里裁剪 letterbox 填充的那一步,是按 (y + dy) * 1024 + dx 直接索引的,前提是模型输出恰好是 1024×1024。如果实际输出是别的尺寸——比如某个低分辨率的侧输出——索引就会读到错位的内存,产出的正是那种扭曲色块。

ISNet 训练时确实有多个侧输出,这个假设很具体。查 ONNX 元数据:

输入  img   [1, 3, 1024, 1024]  tensor(float)
输出数 1
输出  mask  [1, 1, 1024, 1024]  tensor(float)

实跑后第 0 个输出元素数 = 1048576
代码假设的元素数        = 1048576

单输出,尺寸严格吻合。推翻。

假设二:预处理写错了

这个模型对预处理很挑剔——它要的是「除以 255、不做均值减法、保持长宽比 letterbox 到 1024、输出直接当 alpha」。之前我照搬过通用的 ImageNet 归一化,模型峰值输出只有 0.02,几乎是全黑遮罩。有过前科,值得怀疑。

验证方式是把浏览器里那段 JavaScript 逐行在 Python 里复刻一遍:同样的 letterbox 计算、同样的画布填充、同样的通道排布、同样的裁剪与缩放。然后拿一张普通的动漫人物图跑。

letterbox: w=934 h=1024 dx=45 dy=0
模型原始输出范围: 0.0000 .. 1.0000   均值 0.2183
最终主体占比: 23.9%

输出干净、范围正常、抠图结果是完整的人物剪影。预处理没问题。推翻。

假设三:矢量化环节把遮罩毁了

遮罩之后还有一整条链路:颜色量化分层、连通域标记、轮廓追踪、折线简化。这条链路里的轮廓追踪我修过一个真实的 bug(停止条件判断早于方向更新,导致只沿上边缘走),所以它有嫌疑。

验证方式是把真实的 alpha 和图像喂进 Node,用线上同一份 core.js,参数和用户当时完全一致(分层 6、简化 0、锐化 0):

quantiseForeground -> 6 个色簇
  rgb(73,45,44)  rgb(208,159,139)  rgb(152,58,51) ...
labelComponents(minArea=417) -> 19 个区域
区域 id=0     area=13155  bbox=[251,138,435,402]
轮廓追踪结果            轮廓bbox=[251,138,436,403]

19 个区域的轮廓包围盒和区域包围盒逐一吻合(差 1 像素是外沿追踪的正常偏移),色簇也是合理的人物配色:深棕头发、肤色、红衣、白边。推翻。

假设四:WASM 后端跑量化模型会退化

到这里我开始怀疑运行环境。我验证量化用的是 Python 的 CPU 执行提供者,而浏览器跑的是 WebAssembly 后端——两者的量化算子实现不同,MatMulInteger / ConvInteger 这类算子在 WASM 上的完备性历来不如原生。

这个假设的检验必须干净:只留后端一个变量。做法是用 Python 把预处理后的输入张量原样导出成 .f32 文件,然后让 Node 里的 onnxruntime-web 读同一份字节去跑。

Python CPU 后端:  max=0.9727  mean=0.1264
Node  WASM 后端:  max=0.9727  mean=0.1264

逐位一致。后端是清白的。推翻。

真正的原因

四个假设排除完,剩下的只有量化本身。把同一张图在三种精度下跑一遍,和 fp32 对比:

                最大绝对差    平均绝对差     [email protected]
int8              0.388        0.0547        0.296
fp16              0.001        0.00008       0.9996

int8 在这张图上的 IoU 是 0.296。而我上线前测出来的是 0.976–0.984。

换不同阈值看得更清楚:

阈值      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%

fp16 和 fp32 在每个阈值下都吻合;int8 随着阈值升高急剧塌陷。

插一段:这是不是选错模型了

看到量化背锅之前,我先怀疑过一件更简单的事:isnet-anime 是为动漫人物训练的,而这张图是一只非人形的卡通生物。会不会换个通用主体分割模型就好了?

isnet-general-use 试。第一次跑出来主体占比只有 1.9%,比 anime 模型还差——但这个结果不能信,因为我又一次用错了预处理。

这个模型在 rembg 里的调用是 mean=(0.5,0.5,0.5)std=(1.0,1.0,1.0),而且除的是像素最大值而不是 255。我下意识套了 ImageNet 的均值方差。改成正确的预处理后:

ImageNet 归一化(错误)    主体占比  1.9%
官方预处理(正确)         主体占比 21.4%

21.4% 比 anime 模型 fp32 的 25.9% 还低,而且失败模式完全一样:抠出了头部和王冠,身体、四肢、尾巴全丢。

所以换模型解决不了这个问题。两个不同架构、不同训练数据的模型,在同一张图上犯同一个错误——这说明难点不在模型选型,在图片本身:主体的平涂浅蓝和背景的蓝绿在特征空间里靠得太近。

顺带一提,”预处理套错了归一化参数”这个错我在这次排查里犯了两次(anime 模型一次、general 模型一次)。每个分割模型的预处理都是它训练时的一部分,不能跨模型套用,哪怕它们看起来是同一个系列。

把原始输出画出来看

确定是量化的锅之后,我把 fp32 的原始软遮罩(没有二值化)保存成灰度图。这张图比任何指标都直观:

头部 + 王冠 + 颈毛     明亮,接近纯白
身体、四肢             暗灰
尾巴                   几乎看不见

模型不是”没看见”身体,而是看见了但不确定。量化后的版本在同样的位置上把这些本就微弱的信号又压低了一档,于是二值化时整片消失。

这解释了为什么最终结果看起来像”随机色块”而不是”缺了一块的角色”:留下来的是若干个高置信孤岛,孤岛之间的连接部分被切断了,矢量化再把这些碎片各自描成轮廓。

为什么两次测量差这么多

关键在于模型对样本的置信度

量化误差不会凭空制造或消灭结构,它只是让每个像素的输出值抖动一点。对一个模型很有把握的样本——干净的动漫人物、主体和背景颜色分明——输出要么接近 1 要么接近 0,中间地带很窄。零点几的抖动落在 0.95 上还是 0.95,落在 0.03 上还是 0.03,判定不会翻转,所以 IoU 高达 0.98。

而这只卡通生物的身体是平涂浅蓝,背景也是蓝绿——模型本来就拿不准,大片区域的输出徘徊在 0.3–0.5。这时候同样的抖动足以把判定推到阈值另一侧

把这两件事放在一起,结论就很难看了:

我只在模型有把握的样本上验证量化,等于在最不敏感的区域测量灵敏度。测出来的 0.98 不是量化安全的证据,它只是说明我的测试集里没有难样本。

这不是量化特有的问题。任何”压缩后精度损失可接受”的结论——剪枝、蒸馏、低秩分解、降采样——只要验证集偏向简单样本,都会得到同样过于乐观的数字。

换成 fp16

修法是放弃 int8 改用 fp16。转换时保留输入输出为 float32,前端一行代码都不用改:

from onnxconverter_common import float16
m16 = float16.convert_float_to_float16(model, keep_io_types=True)

keep_io_types=True 是关键:它让计算图内部走 fp16,但输入输出仍然是 float32。前端那句 new ort.Tensor('float32', data, [1,3,1024,1024]) 完全不用动,输出读取也不用改。

转换过程会刷一堆这样的警告:

UserWarning: the float32 number -4.266044584255724e-08
will be truncated to -1e-07

这些是权重里绝对值小于 fp16 最小非规格化数的值被钳到 ±1e-07。看着吓人,实际影响可以直接量:最大绝对差 0.001070,IoU 0.9996。这类警告要看实测差异,不能靠它本身判断严重性。

fp32  167.9 MB
fp16   84.0 MB      IoU vs fp32 = 0.9996
int8   42.1 MB      IoU vs fp32 = 0.296(该图)

体积是 int8 的两倍,但在 WASM 上实测加载更快(79 ms vs 337 ms,量化模型要额外解析量化参数),推理耗时基本持平。对这个场景,int8 省下的 42 MB 换来的是不可用的输出——不是一笔划算的交易。

顺带被这次排查暴露的第二个问题

把软遮罩切成二值时,我把阈值写死成了 127。统计那张图的原始输出:

头部区域   中位数 154
身体区域   中位数  89

就算用 fp32,127 这个阈值也正好把身体切掉了。低置信主体的四肢天然落在 80–90 区间,一个写死的阈值等于替所有图片预设了模型的置信度。默认值改成 80,并且做成可调。

下次我会怎么验证

这次排查真正值得留下的是方法,不是结论:

验证集必须包含模型不擅长的样本。专门找主体与背景同色、低对比度、非典型构图的输入。如果找不到失败样本,说明取样有偏,不说明模型稳健。

看 IoU 或逐样本指标,别只看均值和峰值。这次 int8 的输出峰值是 0.9727,看起来相当健康——但 IoU 只有 0.296。均值和峰值对空间结构的崩坏完全不敏感。

在真正运行的后端上验证。我差点把锅扣给 WASM。检验方法很简单:把输入张量导成文件,让两个后端读同一份字节,只留一个变量。这次的结论是后端无辜,但如果不做这个对照,我会改错东西。

先证伪再动手。四个假设里前三个都很合理,如果任选一个直接开改,都会在错误的地方浪费时间,而且改完问题还在——最坏的情况是”改了之后好像好一点”,然后带着一个错误的因果认知继续往前走。

这里描述的失效机制做成了可交互的演示:软遮罩阈值实验。拖动二值化阈值和噪声幅度,可以直接看到高置信区纹丝不动、低置信区碎成孤立像素,而总覆盖率几乎不变——这正是只看 IoU 均值会漏掉的那类破坏。

发表回复

向下探索