注意
转到末尾下载完整的示例代码。
切换到float时的兼容问题¶
绝大多数scikit-learn模型使用双精度(double)进行计算,而非单精度(float)。深度学习模型通常使用单精度,因为这是GPU最常见的情况。ONNX最初旨在促进深度学习模型的部署,这解释了为何许多转换器假定转换后的模型应使用单精度。这种假定通常不会影响预测结果,将双精度转换为单精度引入的差异通常很小。如果预测函数是连续的(),这种假定通常是正确的,因为
。我们可以确定差异的上限:
。dx 是单精度转换引入的差异,
dx = x - numpy.float32(x)
。
然而,并非所有模型都是如此。用于回归的决策树不是连续函数。因此,即使是很小的 dx 也可能引入巨大的差异。我们来看一个总是产生差异的例子,以及克服这种情况的一些方法。
深入了解问题¶
下面的例子是为了演示失败情况而构建的。它包含不同数量级并四舍五入为整数的整数特征。决策树将特征与阈值进行比较。在大多数情况下,单精度和双精度比较结果相同。我们将 表示转换(或称类型转换)
numpy.float32(x)
。
然而,这两种比较得出不同结果的概率并非为零。下图展示了不一致的区域。
from skl2onnx.sklapi import CastTransformer
from skl2onnx import to_onnx
from onnxruntime import InferenceSession
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.datasets import make_regression
import numpy
import matplotlib.pyplot as plt
def area_mismatch_rule(N, delta, factor, rule=None):
if rule is None:
def rule(t):
return numpy.float32(t)
xst = []
yst = []
xsf = []
ysf = []
for x in range(-N, N):
for y in range(-N, N):
dx = (1.0 + x * delta) * factor
dy = (1.0 + y * delta) * factor
c1 = 1 if numpy.float64(dx) <= numpy.float64(dy) else 0
c2 = 1 if numpy.float32(dx) <= rule(dy) else 0
key = abs(c1 - c2)
if key == 1:
xsf.append(dx)
ysf.append(dy)
else:
xst.append(dx)
yst.append(dy)
return xst, yst, xsf, ysf
delta = 36e-10
factor = 1
xst, yst, xsf, ysf = area_mismatch_rule(100, delta, factor)
fig, ax = plt.subplots(1, 1, figsize=(5, 5))
ax.plot(xst, yst, ".", label="agree")
ax.plot(xsf, ysf, ".", label="disagree")
ax.set_title("Region where x <= y and (float)x <= (float)y agree")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.plot([min(xst), max(xst)], [min(yst), max(yst)], "k--")
ax.legend()

<matplotlib.legend.Legend object at 0x7f58cc8103e0>
流水线和数据¶
现在我们可以构建一个示例,其中学习到的决策树在这个不一致区域进行了大量比较。这是通过将特征四舍五入为整数来实现的,这在处理类别特征时经常发生。
X, y = make_regression(10000, 10)
X_train, X_test, y_train, y_test = train_test_split(X, y)
Xi_train, yi_train = X_train.copy(), y_train.copy()
Xi_test, yi_test = X_test.copy(), y_test.copy()
for i in range(X.shape[1]):
Xi_train[:, i] = (Xi_train[:, i] * 2**i).astype(numpy.int64)
Xi_test[:, i] = (Xi_test[:, i] * 2**i).astype(numpy.int64)
max_depth = 10
model = Pipeline(
[("scaler", StandardScaler()), ("dt", DecisionTreeRegressor(max_depth=max_depth))]
)
model.fit(Xi_train, yi_train)
差异¶
让我们重用第一个示例比较中实现的功能,并查看转换情况。
def diff(p1, p2):
p1 = p1.ravel()
p2 = p2.ravel()
d = numpy.abs(p2 - p1)
return d.max(), (d / numpy.abs(p1)).max()
onx = to_onnx(model, Xi_train[:1].astype(numpy.float32), target_opset=15)
sess = InferenceSession(onx.SerializeToString(), providers=["CPUExecutionProvider"])
X32 = Xi_test.astype(numpy.float32)
skl = model.predict(X32)
ort = sess.run(None, {"X": X32})[0]
print(diff(skl, ort))
(np.float64(161.55073408351439), np.float64(1.673367650274533))
差异很显著。ONNX模型在每个步骤都保持单精度(float)。
CastTransformer¶
我们可以尝试在所有地方都使用双精度。不幸的是,ONNX ML Operators只允许 TreeEnsembleRegressor 运算符使用单精度系数。我们可以考虑折中一下,在scikit-learn流水线中将归一化器的输出转换为单精度。
model2 = Pipeline(
[
("scaler", StandardScaler()),
("cast", CastTransformer()),
("dt", DecisionTreeRegressor(max_depth=max_depth)),
]
)
model2.fit(Xi_train, yi_train)
差异。
onx2 = to_onnx(model2, Xi_train[:1].astype(numpy.float32), target_opset=15)
sess2 = InferenceSession(onx2.SerializeToString(), providers=["CPUExecutionProvider"])
skl2 = model2.predict(X32)
ort2 = sess2.run(None, {"X": X32})[0]
print(diff(skl2, ort2))
(np.float64(161.55073408351439), np.float64(1.673367650274533))
这仍然会失败,因为scikit-learn和ONNX中的归一化器使用不同的类型。类型转换仍然会发生,dx 仍然存在。为了消除它,我们需要在ONNX归一化器中使用双精度。
model3 = Pipeline(
[
("cast64", CastTransformer(dtype=numpy.float64)),
("scaler", StandardScaler()),
("cast", CastTransformer()),
("dt", DecisionTreeRegressor(max_depth=max_depth)),
]
)
model3.fit(Xi_train, yi_train)
onx3 = to_onnx(
model3,
Xi_train[:1].astype(numpy.float32),
options={StandardScaler: {"div": "div_cast"}},
target_opset=15,
)
sess3 = InferenceSession(onx3.SerializeToString(), providers=["CPUExecutionProvider"])
skl3 = model3.predict(X32)
ort3 = sess3.run(None, {"X": X32})[0]
print(diff(skl3, ort3))
(np.float64(2.389650524037279e-05), np.float64(5.636140502347132e-08))
这样就行了。这也意味着当流水线包含不连续函数时,很难改变计算类型。最好在使用决策树之前始终保持相同的类型。
脚本总运行时间: (0 分钟 0.467 秒)