转换器

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

什么是转换库?

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

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

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

转换库彼此之间不兼容。tensorflow-onnx 专门用于 tensorflow,且仅支持 tensorflow。专门用于 scikit-learn 的 sklearn-onnx 也是如此。

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

替代方案

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

算子集 (Opsets)

ONNX 发布软件包时使用 major.minor.fix 这样的版本号。每个次要更新意味着算子列表不同或签名已更改。它也与一个算子集相关联,版本 1.10 是 opset 15,1.11 将是 opset 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 转换器

贡献

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.cc, old.cc, onnx/defs/operator_sets*.h, onnx/defs/schema.h

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

  • 构建 onnx。

  • 构建文档。

  • 更新单元测试。

PR 应包含修改后的文件和修改后的 Markdown 文档,通常是 docs/docs/Changelog-ml.md, docs/Changelog.md, docs/Operators-ml.md, docs/Operators.md, docs/TestCoverage-ml.md, docs/TestCoverage.md 的子集。