4、模型导出进阶:使用TensorFlow/Keras导出ONNX模型(tf2onnx),处理动态轴与批处理
好,咱们接着聊模型导出。前面几章我们主要讲了 PyTorch 的导出,但实际工作中,TensorFlow 和 Keras 的用户群体也非常庞大。我自己的团队里,就有好几个同事是 TF 的忠实用户。说实话,TF 的导出流程比 PyTorch 要稍微绕一点,但掌握了套路,其实也挺顺手的。
今天我们就来啃这块硬骨头——用 tf2onnx 把 TensorFlow/Keras 模型转成 ONNX,并且重点解决动态轴和批处理的问题。嗯,这里要注意,动态轴处理不好,模型部署到生产环境时,遇到不同大小的输入就会直接崩掉。
4.1 为什么需要 tf2onnx?
TensorFlow 官方其实提供了 TF-TRT、TFLite 等部署方案,但 ONNX 的优势在于它的「中间人」角色。你想想看,一个模型如果转成 ONNX,就能在 PyTorch、TensorRT、OpenVINO 甚至 Apple CoreML 之间自由切换。说白了,ONNX 就是深度学习模型的通用语言。
我个人习惯用 tf2onnx 这个工具,因为它对 Keras 和 TF2 的支持比较完善。我记得最早接触它的时候,还是 TF1.x 时代,那时候踩了不少坑。现在 TF2 出来之后,整个流程简化了很多。
4.2 环境准备与安装
在开始之前,先把环境搭好。我建议你创建一个干净的虚拟环境,避免依赖冲突。
# 创建虚拟环境
conda create -n tf2onnx_env python=3.8
conda activate tf2onnx_env
# 安装 TensorFlow 和 tf2onnx
pip install tensorflow==2.10.0
pip install tf2onnx==1.14.0
pip install onnxruntime # 用于验证导出结果
⚠️ 版本兼容性提醒
我曾经因为 TensorFlow 版本和 tf2onnx 版本不匹配,折腾了一整天。建议使用 TensorFlow 2.8~2.12 之间的版本,配合 tf2onnx 1.13~1.15。太新的 TF 版本(比如 2.15+)可能会有一些算子兼容性问题。
4.3 基础导出流程:从 Keras 模型到 ONNX
我们先从一个最简单的例子开始。假设你训练了一个 Keras 的 Sequential 模型,用来做 MNIST 手写数字识别。
import tensorflow as tf
import tf2onnx
import onnx
# 1. 构建一个简单的 Keras 模型
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=(28, 28)),
tf.keras.layers.Dense(128, activation='relu'),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.Dense(10, activation='softmax')
])
# 2. 编译并训练(这里省略训练过程,假设模型已经训练好)
# model.compile(...)
# model.fit(...)
# 3. 导出为 ONNX
spec = (tf.TensorSpec((None, 28, 28), tf.float32, name="input"),)
output_path = "./mnist_model.onnx"
model_proto, _ = tf2onnx.convert.from_keras(
model,
input_signature=spec,
output_path=output_path
)
print(f"模型已导出到: {output_path}")
这里有几个关键点:
- input_signature:这是 tf2onnx 的「眼睛」,它告诉转换器输入的形状和数据类型。注意我用了
None作为 batch size,这就是动态轴的起点。 - name="input":给输入节点起个名字,方便后续推理时引用。
4.4 处理动态轴:让模型支持可变 batch size
为什么动态轴这么重要?我举个例子:你在训练时用的 batch size 是 32,但部署到生产环境后,客户端可能一次只发来 1 张图片,或者并发请求时一次发来 64 张。如果模型固定了 batch size,那就只能重新训练或者做各种 hack。
在 tf2onnx 中,处理动态轴其实很简单——你只需要在 input_signature 中把对应维度设为 None 即可。但这里有个坑:不是所有算子都天然支持动态轴。
💡 核心原则
在定义 input_signature 时,把 batch size 维度设为 None,ONNX 模型就会自动生成动态轴。但要注意,模型内部的某些操作(比如 Reshape、Flatten)可能会破坏动态性。
我们来看一个更复杂的例子——处理多输入、多输出的模型,并且所有输入都支持动态 batch。
import tensorflow as tf
import tf2onnx
# 构建一个多输入模型
input_a = tf.keras.Input(shape=(224, 224, 3), name="image")
input_b = tf.keras.Input(shape=(10,), name="metadata")
x = tf.keras.layers.Conv2D(32, 3, activation='relu')(input_a)
x = tf.keras.layers.GlobalAveragePooling2D()(x)
x = tf.keras.layers.Concatenate()([x, input_b])
output = tf.keras.layers.Dense(1, activation='sigmoid', name="score")(x)
model = tf.keras.Model(inputs=[input_a, input_b], outputs=output)
# 定义动态输入签名
spec = [
tf.TensorSpec((None, 224, 224, 3), tf.float32, name="image"),
tf.TensorSpec((None, 10), tf.float32, name="metadata")
]
# 导出
model_proto, _ = tf2onnx.convert.from_keras(
model,
input_signature=spec,
output_path="./multi_input_model.onnx"
)
# 验证动态轴
onnx_model = onnx.load("./multi_input_model.onnx")
for inp in onnx_model.graph.input:
print(f"输入: {inp.name}, 形状: {[dim.dim_value if dim.dim_value else 'dynamic' for dim in inp.type.tensor_type.shape.dim]}")
运行这段代码,你会看到输出中 batch size 维度显示为 dynamic。嗯,这就是我们想要的效果。
4.5 处理批处理:固定 batch vs 动态 batch 的取舍
有些场景下,你可能希望固定 batch size。比如你的推理服务是批量处理的,一次固定处理 32 张图。固定 batch 的好处是:推理性能更稳定,内存分配更可控。
我个人建议:
- 在线推理(实时响应):使用动态 batch,因为请求量不可预测。
- 离线批处理:使用固定 batch,可以做一些算子融合优化。
固定 batch 的导出方式很简单:
# 固定 batch size 为 32
spec = (tf.TensorSpec((32, 28, 28), tf.float32, name="input"),)
model_proto, _ = tf2onnx.convert.from_keras(model, input_signature=spec, output_path="./fixed_batch.onnx")
📌 小技巧
如果你不确定该用动态还是固定,我建议先导出动态版本。因为 ONNX Runtime 支持动态 batch,而且性能损失很小。我曾经在项目中做过对比,动态 batch 比固定 batch 慢不到 5%,但灵活性高了很多。
4.6 常见问题与避坑指南
这部分是我最想分享的。我在实际项目中遇到过不少坑,这里挑几个典型的说说。
4.6.1 问题一:Keras 的 Input Layer 名称冲突
如果你在 Keras 中定义了多个 Input 层,但没有显式指定 name,tf2onnx 可能会报错。我曾经遇到一个模型,两个输入都叫 "input_1",结果导出时直接崩溃。
解决方案:给每个 Input 层显式命名,并且在 input_signature 中使用相同的名字。
4.6.2 问题二:自定义层不支持导出
如果你在 Keras 中写了自定义层(继承自 tf.keras.layers.Layer),tf2onnx 可能无法自动转换。这时候需要手动注册算子映射。
我记得有一次,团队里有人写了一个自定义的 Attention 层,导出时各种报错。最后我们不得不把自定义层改写成标准 Keras 层的组合,才顺利导出。
4.6.3 问题三:动态轴导致 Reshape 出错
当 batch size 是动态时,某些 Reshape 操作会出问题。比如你写了个 Reshape((-1, 64)),ONNX 可能无法正确推断形状。
解决方案:使用 tf.reshape 时,尽量用 tf.shape 动态获取维度信息,而不是硬编码。
# 错误的做法
x = tf.reshape(x, (-1, 64))
# 正确的做法
batch_size = tf.shape(x)[0]
x = tf.reshape(x, (batch_size, -1, 64))
4.7 验证导出结果
导出完成后,一定要验证。我习惯用 ONNX Runtime 跑一遍推理,对比原始 Keras 模型的输出。
import onnxruntime as ort
import numpy as np
# 加载 ONNX 模型
ort_session = ort.InferenceSession("./mnist_model.onnx")
# 构造输入数据(batch size = 1)
input_data = np.random.randn(1, 28, 28).astype(np.float32)
# 运行推理
onnx_output = ort_session.run(None, {"input": input_data})
# 对比 Keras 输出
keras_output = model.predict(input_data)
# 检查误差
print(f"最大误差: {np.max(np.abs(onnx_output[0] - keras_output))}")
如果误差在 1e-5 以内,基本就稳了。如果误差很大,可能是某些算子转换出了问题,需要检查 tf2onnx 的日志。
4.8 总结与建议
好了,这一章的内容就到这里。我们来快速回顾一下:
- 使用
tf2onnx.convert.from_keras导出 Keras 模型 - 通过
input_signature中的None实现动态 batch - 固定 batch 和动态 batch 各有适用场景,按需选择
- 注意自定义层、Reshape 操作等常见坑点
下一章我们会深入 ONNX 的算子层面,看看如何手动修改 ONNX 图结构。如果你在导出过程中遇到任何问题,欢迎在课程群里交流。我个人觉得,模型导出这个环节,多踩几次坑就熟练了——嗯,毕竟我也是这么过来的。