skl2onnx 转换翻车实录:随机森林准确率暴跌 41%

一个开发者把 sklearn 的 RandomForestClassifier 导出成 ONNX 后,准确率从 90% 掉到 49%。问题不出在模型,而是 ONNX 输出被误当成了标签。这个坑,不少人踩过。
一个 41% 的准确率鸿沟
这两天 Stack Overflow 上有个帖子挺有意思:一位开发者训练了个 RandomForestClassifier,在测试集上稳稳跑到 90% 的准确率,兴致勃勃地用 skl2onnx 导出成 ONNX 想拿去部署,结果推理准确率直接掉到 49%——足足少了 41 个百分点。
如果你做过模型部署,看到这个数字第一反应大概是:量化出问题了?算子精度损失?还是 opset 不兼容?
都不是。这次翻车的原因,比想象中蠢得多,也普遍得多。

问题的真相:你把概率当标签用了
先说结论:skl2onnx 转换 RandomForestClassifier 后,ONNX 模型有两个输出——第一个是预测标签(label),第二个是每个类别的预测概率(probabilities)。
而那位开发者的代码,取的是 session.run(None, {...})[0],看起来没问题吧?取第一个输出嘛。但他后续的处理逻辑是按概率数组来处理的——也就是说,他实际上把 label 当成 probability 在用,或者反过来。具体细节因写法而异,但根因就是输出顺序和语义没搞清楚。
更坑的是,ONNX 里 RandomForestClassifier 的概率输出不是一个规整的二维数组 [n_samples, n_classes],而是一个 ZipMap 结构——每个样本对应一个 {类别: 概率} 的字典序列。你直接 np.argmax 是拿不到东西的,甚至类型都对不上。
于是就出现了那种诡异的现象:代码不报错,能跑出结果,但结果基本等同于随机猜测。
复现一下这个坑
为了让你有更直观的感受,我们把这个场景复现一遍:
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
import onnxruntime as rt
import numpy as np
# 训练一个基础模型
X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
clf = RandomForestClassifier(n_estimators=50, random_state=42)
clf.fit(X_train, y_train)
print("sklearn 准确率:", clf.score(X_test, y_test))
# 转 ONNX
initial_type = [('float_input', FloatTensorType([None, 4]))]
onx = convert_sklearn(clf, initial_types=initial_type)
with open("rf.onnx", "wb") as f:
f.write(onx.SerializeToString())
# 加载并推理
sess = rt.InferenceSession("rf.onnx")
input_name = sess.get_inputs()[0].name
label_name = sess.get_outputs()[0].name
proba_name = sess.get_outputs()[1].name
print("输出节点:", [o.name for o in sess.get_outputs()])
# 你会看到: ['output_label', 'output_probability']
注意最后打印出来的输出节点,output_label 和 output_probability 明晃晃地摆在那儿。但很多教程里的示例代码是这么写的:
pred_onx = sess.run(None, {input_name: X_test.astype(np.float32)})
# 有些人这里直接 pred_onx[0],有些人取 pred_onx[1]
# 然后传给 accuracy_score,就出事了
如果你把 pred_onx[1](也就是 ZipMap 结构的概率输出)当成标签数组去和 y_test 比较,那结果就是灾难。
正确的姿势
拿 label 就明确拿 label:
pred_label = sess.run([label_name], {input_name: X_test.astype(np.float32)})[0]
print("ONNX 准确率:", np.mean(pred_label == y_test))
这时候你会发现,准确率和 sklearn 原模型几乎完全一致(差异通常在 1e-6 量级以内,来自浮点计算顺序)。
如果你需要概率——比如做校准、算 AUC——那就正确解析 ZipMap:
pred_proba_raw = sess.run([proba_name], {input_name: X_test.astype(np.float32)})[0]
# pred_proba_raw 是 list of dict
print(pred_proba_raw[0]) # {0: 0.02, 1: 0.95, 2: 0.03}
# 转成二维数组
n_classes = len(clf.classes_)
pred_proba = np.array([[d[c] for c in clf.classes_] for d in pred_proba_raw])
嫌 ZipMap 麻烦?转换的时候直接关掉它:
onx = convert_sklearn(
clf,
initial_types=initial_type,
options={id(clf): {'zipmap': False}}
)
加了这个 option 之后,概率输出就是标准的 [n_samples, n_classes] 二维数组,直接 argmax 就能用,不用再解字典。
为什么会有 ZipMap 这么反直觉的东西
这里插一段背景。ZipMap 是 ONNX ML 算子集里为了兼容传统机器学习模型输出而设计的——它把类别标签(可能是字符串或整数)和概率一一对应起来,避免了"第 0 列是哪个类别"这种隐式约定。
从设计角度看它是合理的:sklearn 里 clf.classes_ 顺序可能和你想象的不一样,尤其是标签是字符串('cat', 'dog', 'bird')或者非连续整数的时候。ZipMap 显式绑定了类别和概率,避免歧义。
但代价是,它输出的是 Sequence<Map<int64, float>> 这种非张量结构,对下游推理框架不友好,很多部署环境(尤其是 C++/移动端)解析起来相当别扭。所以在纯数值管线里,zipmap=False 几乎是默认最优解。
这类坑的共性
聊完这个具体问题,值得多说两句。从 sklearn/PyTorch 转 ONNX,准确率"暴跌"的情况其实分几类,别一看到掉点就以为是精度损失:
- 输出解析错位:本次这个案例,最常见也最容易被误诊。表现是准确率断崖式下跌(掉 30% 以上),而不是小幅波动。
- 输入 dtype 不对:sklearn 里
X是float64,ONNX 通常用float32,如果你训练数据本身范围大或者特征间尺度悬殊,转换后会有可见的数值偏差,但一般不会掉 40%,最多几个点。 - 预处理丢了:训练时用了
StandardScaler,导出模型时忘了把 scaler 一起打包进 pipeline。这个坑在生产环境里排第一。 - opset 版本不匹配:某些算子在低版本 opset 下会退化实现,比如
TreeEnsembleClassifier在早期 opset 下对多分类的处理有已知 issue。sklearn-onnx 1.20 默认支持到 opset 22,遇到怪问题先检查这个。 - 算子精度损失:真正的浮点精度差异,一般是深度学习模型才会有明显感知,树模型基本不会。
判断顺序建议是:先看输出是不是解析对了,再看输入 dtype 和预处理,最后才怀疑算子实现。80% 的"精度暴跌"停在第一步就能解决。
一个更靠谱的验证流程
无论你转什么模型,导出 ONNX 后做一次"逐样本 diff"是个好习惯:
# sklearn 原始预测
sk_pred = clf.predict(X_test)
sk_proba = clf.predict_proba(X_test)
# ONNX 预测
onnx_pred = sess.run([label_name], {input_name: X_test.astype(np.float32)})[0]
# 标签一致性
label_match = np.mean(sk_pred == onnx_pred)
print(f"标签一致率: {label_match:.4f}")
# 如果一致率 < 0.99,八成是解析或预处理问题,不是精度问题
assert label_match > 0.99, "检查输入 dtype、预处理、输出节点顺序"
把这段 assert 加到你的部署 CI 里,能省掉后面一堆奇奇怪怪的线上事故。
顺带说下 sklearn-onnx 的现状
sklearn-onnx 1.20 是目前的稳定版本,覆盖了 sklearn 主流算法,包括树模型、线性模型、SVM、KNN,甚至一部分 pipeline 里的预处理器。它对 opset 22 有比较完整的支持,配合 onnxruntime 1.18+ 使用体验最好。
对开发者来说,这套工具链的价值在于——你不用把 sklearn 模型重写成 C++ 或者塞进一个更重的推理服务,直接导出 ONNX 就能在几乎任何地方跑,包括浏览器(onnxruntime-web)、移动端、边缘设备。
但正因为它是"黑盒转换",输入输出的约定就变得格外重要。ZipMap 这种设计对新手极其不友好,官方文档里对这个坑的提示也不够显眼,导致每隔一段时间就有新人在 Stack Overflow 上问同样的问题。
所以如果你正在做 sklearn 模型的部署,记住三件事:
- 导出后必做一致性验证,别信"转完就能用"。
- 数值管线里加
zipmap=False,能省大量下游代码。 - 预处理一起打包成 pipeline 再转,别指望部署时再手动补。
这次这位提问者的问题算是个典型样本——错误足够低级,但踩坑的成本足够高,40% 的准确率鸿沟在生产环境足以让一个项目直接被判死刑。工程上的很多灾难,都是这种"看起来能跑"的代码堆出来的。



