转换器

在生产环境中使用 ONNX 意味着模型的预测函数可以用 ONNX 算子来实现。必须选择一个运行时,这个运行时在模型部署的平台上是可用的。检查差异,最后测量延迟。如果存在一个转换库,该库支持模型的所有部分,那么模型转换的第一步可以很简单。如果不是这种情况,则必须在 ONNX 中实现缺失的部分。这可能会非常耗时。

什么是转换库?

sklearn-onnxscikit-learn 模型转换为 ONNX。它使用上面介绍的 API,用 ONNX 算子重写模型的预测函数,无论该函数是什么。它确保预测结果与使用原始模型计算的预期预测结果相等或至少非常接近。

机器学习库通常有自己的设计。这就是为什么每个库都有一个特定的转换库。其中许多都列在这里:转换为 ONNX 格式。下面是一个简短的列表

所有这些库面临的主要挑战是跟上节奏。每当 ONNX 或它们支持的库发布新版本时,它们都必须更新。这意味着每年有三到五个新版本发布。

转换库之间不兼容。tensorflow-onnx 专用于 tensorflow,且仅用于 tensorflow。同样,sklearn-onnx 专门用于 scikit-learn。

一个挑战是定制化。在机器学习模型中支持自定义部分很困难。他们必须为这部分编写特定的转换器。在某种程度上,这就像实现两次预测函数。有一种简单的情况:深度学习框架有自己的原语,以确保相同的代码可以在不同的环境中执行。只要自定义层或子部分使用的是 pytorch 或 tensorflow 的组件,就不需要做太多工作。但对于 scikit-learn 来说,情况就不同了。这个包没有自己的加法或乘法,它依赖于 numpy 或 scipy。用户必须使用 ONNX 原语来实现其转换器或预测器,无论它是否是用 numpy 实现的。

替代方案

实现 ONNX 导出功能的另一种方法是利用标准协议,例如 数组 API 标准,该标准标准化了一组通用的数组操作。它使得代码可以在 NumPy、JAX、PyTorch、CuPy 等库之间重用。ndonnx 支持使用 ONNX 后端执行,并为符合数组 API 的代码提供即时 ONNX 导出。这减少了对专用转换器库代码的需求,因为用于实现库大部分功能的相同代码可以在 ONNX 转换中重用。它还为寻求类似 NumPy 体验来构建 ONNX 图的转换器作者提供了一个方便的原语。

算子集 (Opsets)

ONNX 发布的包版本号类似于 主版本号.次版本号.修订号。每次次版本更新都意味着算子列表不同或签名发生了变化。它也与一个算子集相关联,版本 1.10 是算子集 15,1.11 将是算子集 16。每个 ONNX 图都应该定义它遵循的算子集。在不更新算子的情况下更改此版本可能会使图无效。如果未指定算子集,ONNX 将认为该图对于最新的算子集是有效的。

新的算子集通常会引入新的算子。同一个推理函数可以用不同的方式实现,通常更高效。然而,模型运行的运行时可能不支持最新的算子集,或者至少在已安装的版本中不支持。这就是为什么每个转换库都提供了为特定算子集(通常称为 target_opset)创建 ONNX 图的功能。ONNX 语言描述了简单和复杂的算子。更改算子集类似于升级一个库。onnx 和 onnx 运行时必须支持向后兼容。

其他 API

前面章节的示例表明,onnx API 非常冗长。通过阅读代码来了解整个图也很困难,除非它是一个很小的图。几乎每个转换库都实现了一个不同的 API 来创建图,通常比 onnx 包的 API 更简单、更不冗长。所有 API 都自动化了初始化器的添加,隐藏了每个中间结果名称的创建过程,并处理不同算子集的不同版本。

一个带有 add_node 方法的 Graph 类

tensorflow-onnx 实现了一个 graph 类。当 ONNX 没有类似函数时,它会用 ONNX 算子重写 tensorflow 函数(参见 Erf)。

sklearn-onnx 定义了两种不同的 API。第一个在该示例 实现一个转换器 中介绍,遵循与 tensorflow-onnx 类似的设计。以下几行摘自线性分类器的转换器。

# initializer

coef = scope.get_unique_variable_name('coef')
model_coef = np.array(
    classifier_attrs['coefficients'], dtype=np.float64)
model_coef = model_coef.reshape((number_of_classes, -1)).T
container.add_initializer(
    coef, proto_dtype, model_coef.shape, model_coef.ravel().tolist())

intercept = scope.get_unique_variable_name('intercept')
model_intercept = np.array(
    classifier_attrs['intercepts'], dtype=np.float64)
model_intercept = model_intercept.reshape((number_of_classes, -1)).T
container.add_initializer(
    intercept, proto_dtype, model_intercept.shape,
    model_intercept.ravel().tolist())

# add nodes

multiplied = scope.get_unique_variable_name('multiplied')
container.add_node(
    'MatMul', [operator.inputs[0].full_name, coef], multiplied,
    name=scope.get_unique_operator_name('MatMul'))

# [...]

argmax_output_name = scope.get_unique_variable_name('label')
container.add_node('ArgMax', raw_score_name, argmax_output_name,
                   name=scope.get_unique_operator_name('ArgMax'),
                   axis=1)

算子即函数

实现一个新的转换器 中展示的第二种 API 更为紧凑,并将每个 ONNX 算子定义为可组合的函数。对于 KMeans,语法看起来像这样,不那么冗长,也更容易阅读。

rs = OnnxReduceSumSquare(
    input_name, axes=[1], keepdims=1, op_version=opv)

gemm_out = OnnxMatMul(
    input_name, (C.T * (-2)).astype(dtype), op_version=opv)

z = OnnxAdd(rs, gemm_out, op_version=opv)
y2 = OnnxAdd(C2, z, op_version=opv)
ll = OnnxArgMin(y2, axis=1, keepdims=0, output_names=out[:1],
                op_version=opv)
y2s = OnnxSqrt(y2, output_names=out[1:], op_version=opv)

从经验中总结的技巧

差异

ONNX 是强类型的,并针对 float32 进行了优化,这是深度学习中最常见的类型。标准机器学习中的库同时使用 float32 和 float64。numpy 通常会转换为更通用的类型 float64。当预测函数是连续的时,这没有显著影响。但当它不连续时,必须使用正确的类型。示例 切换到浮点数时的问题 对此主题提供了更深入的见解。

并行化会改变计算顺序。这通常不重要,但它可能解释一些奇怪的差异。1 + 1e17 - 1e17 = 01e17 - 1e17 + 1 = 1。高数量级虽然罕见,但在模型使用矩阵的逆时并不少见。

IsolationForest 技巧

ONNX 仅实现了一个 TreeEnsembleRegressor,但它不提供检索决策路径的任何信息或统计信息到图中的可能性。技巧是使用一个森林来预测叶子索引,然后用所需的信息将这个叶子索引映射一次或多次。

../_images/iff.png

离散化

查找特征落入哪个区间。这用 numpy 很容易做到,但用 ONNX 高效地做到就不那么容易了。最快的方法是使用 TreeEnsembleRegressor,这是一种二分搜索,它输出区间索引。这就是这个例子所实现的:WOE 转换器

贡献

必须 fork 并克隆 onnx 仓库

构建

Windows 构建需要 conda。以下步骤可能不是最新的。文件夹 onnx/.github/workflows 包含最新的指令。

Windows

使用 Anaconda 构建会更容易。首先:创建一个环境。这只需要做一次。

conda create --yes --quiet --name py3.9 python=3.9
conda install -n py3.9 -y -c conda-forge numpy libprotobuf=3.16.0

然后构建包

git submodule update --init --recursive
set ONNX_BUILD_TESTS=1
set ONNX_ML=$(onnx_ml)
set CMAKE_ARGS=-DONNX_USE_PROTOBUF_SHARED_LIBS=ON -DONNX_USE_LITE_PROTO=ON -DONNX_WERROR=ON

python -m build --wheel

现在可以安装该软件包了。

Linux

克隆仓库后,可以运行以下指令

python -m build --wheel

构建 markdown 文档

必须先构建该包(参见上一节)。

set ONNX_BUILD_TESTS=1
set ONNX_ML=$(onnx_ml)
set CMAKE_ARGS=-DONNX_USE_PROTOBUF_SHARED_LIBS=ON -DONNX_USE_LITE_PROTO=ON -DONNX_WERROR=ON

python onnx\gen_proto.py -l
python onnx\gen_proto.py -l --ml
pip install -e .
python onnx\backend\test\cmd_tools.py generate-data
python onnx\backend\test\stat_coverage.py
python onnx\defs\gen_doc.py
set ONNX_ML=0
python onnx\defs\gen_doc.py
set ONNX_ML=1

更新现有算子

所有算子都在文件夹 onnx/onnx/defs 中定义。每个子文件夹中有两个文件,一个名为 defs.cc,另一个名为 old.cc

  • defs.cc:包含每个算子的最新定义

  • old.cc:包含先前算子集中已弃用版本的算子

更新一个算子意味着将定义从 defs.cc 复制到 old.cc,并更新 defs.cc 中的现有定义。

必须修改一个遵循 onnx/defs/operator_sets*.h 模式的文件。这些头文件注册了现有算子的列表。

文件 onnx/defs/schema.h 包含最新的算子集版本。如果某个算子集已升级,也必须更新它。

文件 onnx/version_converter/convert.h 包含将节点从一个算子集转换为下一个算子集时应用的规则。这个文件也可能需要更新。

必须编译该软件包,并且必须再次生成文档以自动更新 markdown 文档,并且必须将其包含在 PR 中。

然后必须更新单元测试。

总结

  • 修改文件 defs.ccold.cconnx/defs/operator_sets*.honnx/defs/schema.h

  • 可选:修改文件 onnx/version_converter/convert.h

  • 构建 onnx。

  • 构建文档。

  • 更新单元测试。

PR 应包括修改后的文件和修改后的 markdown 文档,通常是 docs/docs/Changelog-ml.mddocs/Changelog.mddocs/Operators-ml.mddocs/Operators.mddocs/TestCoverage-ml.mddocs/TestCoverage.md 的一个子集。