4、模型转换基础:从PyTorch导出ONNX(torch.onnx.export)、从TensorFlow/Keras转换(tf2onnx)、从其他框架转换

模型转换是ONNX Runtime部署链路中的第一道关卡,也是决定后续推理性能与兼容性的关键环节。本章聚焦于三种主流转换路径:PyTorch的torch.onnx.export、TensorFlow/Keras的tf2onnx工具链,以及涵盖PaddlePaddle、MindSpore、scikit-learn等框架的通用转换策略。我们将从转换原理、操作流程、实战案例与常见误区四个维度展开,帮助读者建立一套可复用的转换方法论。

PyTorch 路径 TensorFlow 路径 其他框架路径 torch.onnx.export 动态轴 / 算子集 onnx.checker 验证 onnxruntime 推理 Keras 模型保存 tf2onnx.convert 输入签名 / 优化 ONNX 模型输出 Paddle2ONNX MindSpore 导出 sklearn-onnx 统一 ONNX IR ONNX Runtime 统一推理 动态形状 图优化 算子映射

4.1 核心原理:ONNX作为中间表示的转换本质

ONNX(Open Neural Network Exchange)本质上是一种计算图中间表示(IR),它定义了标准化的算子集合、张量数据类型和图结构。模型转换并非简单的文件格式变更,而是将源框架的计算图逐算子映射到ONNX算子集(Opset)的过程。每个框架都有自己独特的图表示方式:PyTorch使用动态图(Eager模式)的追踪(Tracing)或脚本(Scripting)机制;TensorFlow使用静态图(GraphDef);PaddlePaddle使用ProgramDesc。转换工具的核心任务就是解析这些异构表示,提取出计算逻辑,再按照ONNX的规范重新构建图结构。

理解转换原理需要把握三个关键点:算子映射表决定了源算子能否找到对应的ONNX算子;Opset版本决定了可用算子的集合与行为语义;张量形状与数据类型的推断直接影响后续优化器的执行。转换过程中,如果遇到不支持的算子,工具通常会抛出错误或插入自定义算子(Custom Op),后者需要额外的实现才能被ONNX Runtime加载。

关键认知:转换不是“翻译”,而是“重建”。源框架中的某些高级API(如PyTorch的nn.Sequential或TensorFlow的Keras层)在转换时会被展开为底层算子序列。因此,转换后的ONNX模型可能比原始模型更“庞大”,但计算逻辑完全等价。

4.2 PyTorch导出ONNX:torch.onnx.export深度实践

torch.onnx.export是PyTorch官方提供的导出接口,其底层基于TorchScript的追踪(Tracing)机制。当调用该函数时,PyTorch会使用给定的示例输入实际执行一次模型前向传播,记录所有被调用的张量操作,并将这些操作序列化为ONNX图。这意味着控制流(如if-else、循环)只有在被追踪路径实际执行时才会被记录,这是Tracing模式最大的局限性。

4.2.1 操作流程与参数详解

import torch
import torch.onnx

# 定义模型
class MyModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = torch.nn.Conv2d(3, 16, kernel_size=3, padding=1)
        self.relu = torch.nn.ReLU()
        self.fc = torch.nn.Linear(16 * 8 * 8, 10)

    def forward(self, x):
        x = self.conv(x)
        x = self.relu(x)
        x = x.view(x.size(0), -1)
        x = self.fc(x)
        return x

model = MyModel()
model.eval()  # 必须切换到eval模式

# 构造示例输入
dummy_input = torch.randn(1, 3, 8, 8)

# 导出ONNX
torch.onnx.export(
    model,                      # 模型
    dummy_input,                # 示例输入
    "my_model.onnx",            # 输出路径
    export_params=True,         # 导出权重
    opset_version=13,           # Opset版本
    do_constant_folding=True,   # 常量折叠优化
    input_names=["input"],      # 输入名称
    output_names=["output"],    # 输出名称
    dynamic_axes={              # 动态轴定义
        "input": {0: "batch_size"},
        "output": {0: "batch_size"}
    }
)

上述代码展示了导出流程的核心要素。其中opset_version的选择至关重要:较新的Opset支持更多算子且语义更精确,但可能不被旧版ONNX Runtime支持。建议根据部署环境的ONNX Runtime版本选择兼容的Opset。dynamic_axes参数允许指定哪些维度是动态的,例如批次大小可变,这在处理变长输入时非常有用。

参数名 作用 推荐设置
opset_version 指定ONNX算子集版本 11-15(根据Runtime版本)
do_constant_folding 在导出时执行常量折叠优化 True(默认)
dynamic_axes 定义动态维度 按需设置批次维度
input_names/output_names 指定输入输出张量名称 建议使用语义化名称

4.2.2 案例拆解:带条件分支的模型导出

假设我们有一个包含if条件分支的模型,该分支根据输入张量的均值决定是否应用额外的全连接层。使用Tracing方式导出时,只有if条件为True的分支会被记录,导致导出的模型在推理时永远走同一个分支。解决方案有两种:一是使用torch.jit.script将模型转换为ScriptModule后再导出;二是将条件逻辑改为张量运算(如使用torch.where)。

# 问题代码
class ConditionalModel(torch.nn.Module):
    def forward(self, x):
        if x.mean() > 0:
            x = self.extra_fc(x)
        return x

# 解决方案1:使用torch.jit.script
scripted_model = torch.jit.script(ConditionalModel())
torch.onnx.export(scripted_model, dummy_input, "conditional.onnx", opset_version=13)

# 解决方案2:改用张量运算
class SafeModel(torch.nn.Module):
    def forward(self, x):
        mask = (x.mean() > 0).float()
        x = x + mask * self.extra_fc(x)
        return x

这个案例揭示了Tracing机制的本质限制:它记录的是“数据流”而非“控制流”。在实际项目中,如果模型包含复杂的动态控制流,建议优先考虑Scripting方式或重构模型逻辑。

常见陷阱:导出前忘记调用model.eval()会导致BatchNorm和Dropout层的行为不一致。BatchNorm在训练模式下使用批统计量,而推理模式使用运行均值/方差;Dropout在训练时随机失活,推理时保持恒等。务必在导出前切换到eval模式。

4.3 TensorFlow/Keras转换:tf2onnx工具链

TensorFlow生态的转换主要依赖tf2onnx工具,它支持将三种格式的模型转换为ONNX:SavedModel、Keras H5、以及Checkpoint。与PyTorch的Tracing不同,tf2onnx直接解析TensorFlow的GraphDef,因此能够完整保留控制流结构。转换过程分为两步:首先将TensorFlow图转换为ONNX图,然后执行图优化(如算子融合、常量折叠)。

4.3.1 转换流程与命令行操作

# 方式1:命令行转换
python -m tf2onnx.convert \
    --saved-model ./saved_model_dir \
    --output model.onnx \
    --opset 13 \
    --inputs input:0 \
    --outputs output:0

# 方式2:Python API转换
import tf2onnx
import tensorflow as tf

# 加载Keras模型
keras_model = tf.keras.models.load_model("my_model.h5")

# 指定输入签名
spec = (tf.TensorSpec((None, 224, 224, 3), tf.float32, name="input"),)

# 执行转换
onnx_model, _ = tf2onnx.convert.from_keras(keras_model, input_signature=spec, opset=13)

# 保存ONNX模型
with open("model.onnx", "wb") as f:
    f.write(onnx_model.SerializeToString())

在转换Keras模型时,input_signature参数非常重要,它定义了ONNX模型的输入张量名称、形状和数据类型。如果不指定,tf2onnx会尝试从Keras模型的输入层推断,但可能无法正确识别动态维度。

4.3.2 实战场景:处理TensorFlow自定义层

当Keras模型包含自定义层(继承tf.keras.layers.Layer)时,tf2onnx可能无法自动映射这些层。此时需要手动注册算子映射或使用tf2onnx的select_opset功能。一个典型的处理流程是:先尝试直接转换,如果报错则检查日志中未映射的算子,然后通过tf2onnx.rewriter模块编写自定义重写规则。

# 自定义层示例
class MyLayer(tf.keras.layers.Layer):
    def call(self, inputs):
        return tf.math.asin(inputs)  # 反正弦函数

# 转换时可能报错:Unsupported op: Asin
# 解决方案:检查ONNX Opset是否支持Asin(Opset 7+支持)
# 如果仍不支持,可以手动替换为等价运算
# 或者使用tf2onnx的custom_op机制

在实际项目中,遇到自定义层时建议优先检查ONNX算子集是否包含对应算子。大多数常用数学函数在Opset 11之后都有对应实现。如果确实缺失,可以考虑在转换前将自定义层替换为原生TensorFlow算子组合。

经验提示:tf2onnx转换后的模型建议使用onnxruntime的onnxruntime.transformers.optimizer进行进一步优化。该优化器可以自动融合Attention、LayerNorm等Transformer结构,显著提升推理性能。

4.4 其他框架转换:PaddlePaddle、MindSpore与经典机器学习

除了PyTorch和TensorFlow,ONNX生态还覆盖了PaddlePaddle、MindSpore、MXNet等深度学习框架,以及scikit-learn等传统机器学习库。这些转换工具大多遵循“框架导出中间格式→ONNX转换器”的两阶段设计。

4.4.1 PaddlePaddle与MindSpore转换

# PaddlePaddle → ONNX
import paddle
import paddle2onnx

# 加载Paddle模型
paddle_model = paddle.jit.load("inference_model/model")
paddle_model.eval()

# 转换
onnx_model = paddle2onnx.run_convert(
    model=paddle_model,
    input_spec=[paddle.static.InputSpec([None, 3, 224, 224], "float32", "input")],
    opset_version=13
)

# MindSpore → ONNX
import mindspore as ms
from mindspore import export, Tensor

# 定义模型
class Net(ms.nn.Cell):
    def construct(self, x):
        return ms.ops.relu(x)

net = Net()
input_tensor = Tensor(np.ones([1, 3, 224, 224]), ms.float32)
export(net, input_tensor, file_name="model", file_format="ONNX")

Paddle2ONNX和MindSpore的导出工具都支持动态形状和Opset选择。需要注意的是,PaddlePaddle的paddle.jit.save保存的推理模型包含输入输出信息,转换时可以直接复用;而MindSpore的export接口需要显式传入示例输入。

4.4.2 scikit-learn模型转换

对于传统机器学习模型,sklearn-onnx库提供了转换支持。它可以将训练好的Pipeline(如预处理+分类器)转换为ONNX格式,从而统一部署到ONNX Runtime中。

from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
from sklearn.ensemble import RandomForestClassifier

# 训练模型
clf = RandomForestClassifier(n_estimators=100)
clf.fit(X_train, y_train)

# 定义输入类型
initial_type = [("input", FloatTensorType([None, X_train.shape[1]]))]

# 转换
onx = convert_sklearn(clf, initial_types=initial_type, target_opset=13)

# 保存
with open("rf_model.onnx", "wb") as f:
    f.write(onx.SerializeToString())

sklearn-onnx支持大多数主流模型(随机森林、XGBoost、逻辑回归等),但需要注意树模型在转换时会将树结构展开为ONNX的TreeEnsemble算子,这可能导致模型文件变大,但推理效率更高。

4.5 常见误区与总结复盘

在模型转换实践中,以下误区最为常见:

  • 忽略Opset版本兼容性:使用过新的Opset导出,导致旧版ONNX Runtime无法加载。建议在导出前确认目标Runtime版本,并选择对应的Opset。
  • 动态形状处理不当:将动态维度固定为静态值,导致推理时遇到不同形状输入报错。应使用dynamic_axes或input_signature明确声明动态维度。
  • 忽略预处理/后处理节点:ONNX模型只包含网络计算图,不包含图像解码、归一化等预处理逻辑。这些操作需要在部署代码中单独实现。
  • 未验证转换后精度:转换后模型的数值输出可能与原始框架存在微小差异(通常<1e-5),但在某些敏感场景(如检测框回归)可能被放大。建议使用onnxruntime与原始框架进行输出对比。
转换验证清单:
  1. 使用onnx.checker.check_model验证模型结构合法性。
  2. 使用onnxruntime加载模型并执行推理,与原始框架输出对比。
  3. 检查模型文件大小是否合理(过大可能包含冗余节点)。
  4. 使用onnx.shape_inference推断张量形状,确认动态维度正确。
  5. 在目标硬件(CPU/GPU/NPU)上测试推理延迟和内存占用。