1. 为什么要在 PyTorch 和 TensorFlow 之间做模型转换
做过深度学习项目的人多半都遇到过这种尴尬:训练阶段用 PyTorch 写得飞起,动态图调试顺手,自定义层也灵活,结果一到部署环节,发现目标环境只认 TensorFlow 的 SavedModel 或者 TFLite,甚至有些边缘设备只提供了 TensorFlow Lite 的推理接口。这时候摆在面前的路无非两条——要么用 TensorFlow 把模型重写一遍,要么找一个靠谱的中间格式把权重和计算图搬过去。前者费时费力还容易引入不一致,后者就是我这篇要聊的核心:用 ONNX 作为桥梁,把 PyTorch 模型转到 TensorFlow 生态里。
ONNX(Open Neural Network Exchange)本质上是一种开放的模型表示格式,它把网络的计算图、算子、权重、输入输出形状都用一套与框架无关的方式描述出来。PyTorch 有官方的torch.onnx.export,TensorFlow 这边有onnx-tensorflow这个转换后端,两边一对接,理论上就能实现"一次训练,多端部署"。这个思路听起来很美好,但真正动手之后你会发现,坑主要集中在算子支持度、动态维度、数据类型映射和版本兼容这几块。我前后在几个图像分类和检测类项目上试过这条链路,有的模型十分钟就转通了,有的折腾了两天才把精度对齐。
这篇文章适合三类人看:一是手里有 PyTorch 训练好的模型、但部署环境是 TensorFlow 的工程师;二是想了解 ONNX 作为中间表示到底能覆盖多少算子、边界在哪里的研究者;三是刚接触模型转换、想找一份能直接照着走的实操记录的新手。我会把整个链路的思路、每一步的参数含义、踩过的坑和排查方法都摊开讲,尽量让你少走弯路。
2. 整体转换思路与方案选型拆解
2.1 为什么选 ONNX 而不是直接重写或其它中间格式
先说清楚为什么是 ONNX。模型转换的中间格式其实不止一种,但 ONNX 的生态成熟度是最高的。PyTorch 官方从很早就内置了导出接口,算子覆盖随着版本迭代越来越全;TensorFlow 侧虽然官方没有直接支持 ONNX 导入,但社区维护的onnx-tensorflow一直在跟进,能把大部分常见算子映射成 TF 的等价实现。相比之下,直接重写模型的问题在于:权重迁移容易出错,尤其是自定义初始化和特殊归一化层,重写后精度对不齐是家常便饭;而其它中间格式要么生态窄,要么工具链不完整。
另一个关键考量是调试成本。ONNX 文件本身是可读的 protobuf 结构,你可以用onnx库加载后逐层打印节点、检查输入输出形状、甚至用onnxruntime单独跑一遍推理,把问题定位在"导出阶段"还是"导入阶段"。这种可观测性是直接重写给不了的。我个人的经验是,只要模型里没有大量冷门自定义算子,ONNX 这条路的成功率在八成以上。
2.2 整条链路的分段与各段职责
把 PyTorch 转到 TensorFlow,实际要经过三段:
第一段是PyTorch 导出 ONNX。这一步的核心是torch.onnx.export,它会把模型的计算图 trace 或 script 出来,连同权重一起序列化成.onnx文件。这里最关键的是提供一个正确的 dummy input,因为导出过程需要真实跑一遍前向来确定各节点的形状。
第二段是ONNX 校验与优化。导出完不要急着转,先用onnxruntime加载跑一遍,确认输出和 PyTorch 原模型一致,再用onnxsim之类的工具做常量折叠和算子简化,减少后续转换的负担。
第三段是ONNX 导入 TensorFlow。用onnx-tensorflow把.onnx转成 TensorFlow 的 GraphDef 或 SavedModel,然后在 TF 环境里加载、验证、必要时冻结图或转 TFLite。
这三段各自有独立的失败模式:第一段常见的是算子不支持或动态 shape 报错;第二段常见的是数值精度偏差;第三段常见的是算子映射缺失或维度顺序问题。分开验证的好处是,出问题时你能快速判断是哪一段的锅。
2.3 版本兼容性:最容易被忽视的前置条件
我踩过最深的坑就是版本。ONNX、PyTorch、TensorFlow、onnx-tensorflow 这四个东西的版本是强耦合的,随便升一个都可能让整条链路断掉。比如某个版本的 onnx-tensorflow 只支持到 ONNX opset 13,而你用新版 PyTorch 默认导出的是 opset 17,导入时就会直接报"unsupported opset"。再比如 TensorFlow 2.x 和 1.x 的图加载方式完全不同,onnx-tensorflow 在不同 TF 版本下的行为也有差异。
我的建议是:先确定 onnx-tensorflow 支持的 ONNX opset 上限,再反推 PyTorch 导出时该指定哪个 opset,最后锁定 TensorFlow 版本。不要反过来先装最新版再想办法降级,那样会陷入依赖地狱。下面这张表是我实测下来比较稳的一组搭配,供参考:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| PyTorch | 1.13.x | 导出接口稳定,opset 支持范围广 |
| ONNX | 1.13.x | 与 PyTorch 1.13 配套 |
| onnx-tensorflow | 1.10.0 | 支持到 opset 15 左右 |
| TensorFlow | 2.11.x | 与 onnx-tensorflow 1.10 兼容较好 |
| onnxruntime | 1.14.x | 用于校验,版本相对宽松 |
注意:这张表不是唯一解,但它是我在多个项目里验证过能跑通的组合。如果你用的是更新的版本,务必先查 onnx-tensorflow 的 release note 确认 opset 支持范围。
3. 核心细节解析与实操要点
3.1 PyTorch 导出 ONNX 的关键参数
torch.onnx.export的参数不少,但真正影响转换成败的就那么几个。我把它们拆开讲:
model:要导出的模型,必须先.eval(),否则 BatchNorm 和 Dropout 会以训练模式导出,导致推理结果不对。args:dummy input,通常是一个 tuple。它的形状决定了导出图的输入形状,所以要和实际推理时的输入对齐。f:输出文件路径。opset_version:算子集版本,这个必须和 onnx-tensorflow 支持的上限匹配,不能盲目用最新。input_names/output_names:给输入输出节点命名,方便后续在 TF 里定位。dynamic_axes:声明哪些维度是动态的,比如 batch 维。不声明的话,导出的图会把 batch 固定死。do_constant_folding:是否做常量折叠,一般开着能简化图。
一个典型的导出代码长这样:
import torch model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "model.onnx", opset_version=13, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, do_constant_folding=True, )这里dynamic_axes的写法容易搞错。它的 key 是节点名,value 是一个字典,键是维度索引,值是维度名称。上面这段的意思是:input 和 output 的第 0 维(batch 维)是动态的。如果你有多个动态维度,比如序列长度也可变,就再加一个{1: "seq_len"}。
3.2 动态维度与固定维度的取舍
动态维度是个双刃剑。声明了动态维度,模型能接受可变 batch 或可变序列长度,灵活性高;但代价是转换到 TensorFlow 后,某些算子对动态 shape 的支持不好,可能报错或者性能下降。我的经验是:如果部署时 batch 固定,就别声明动态维度,固定 shape 的图在 TF 里优化得更彻底,转换成功率也更高。只有当确实需要变长输入时,才开动态维度,并且要重点验证转换后的图在 TF 里能否正确处理。
还有一个细节:PyTorch 导出时如果用了dynamic_axes,ONNX 图里对应维度会变成符号维度(symbolic dimension)。onnx-tensorflow 在处理符号维度时,有时会把它当成None,导致 TF 图里出现未知形状。这时候你可能需要在导入后手动 reshape 或者用tf.ensure_shape补上形状信息。
3.3 ONNX 校验:别跳过这一步
导出完 ONNX 之后,我强烈建议先用 onnxruntime 跑一遍,和 PyTorch 的输出做数值对比。这一步能帮你把"导出错误"和"导入错误"分开。校验代码大概是这样:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("model.onnx") input_name = sess.get_inputs()[0].name ort_input = {input_name: dummy_input.numpy()} ort_output = sess.run(None, ort_input)[0] with torch.no_grad(): torch_output = model(dummy_input).numpy() diff = np.abs(ort_output - torch_output).max() print("max diff:", diff)如果diff在 1e-5 量级,说明导出没问题;如果差得离谱,那问题就在导出阶段,可能是某个算子导出实现有 bug,或者模型里有不支持的操作被静默忽略了。我遇到过一次,模型里用了torch.nn.functional.interpolate的align_corners=False,导出后 ONNX 里的插值算子和 PyTorch 行为有细微差异,导致输出偏差在 1e-3 量级,虽然不影响分类结果,但如果做回归任务就得注意了。
3.4 onnx-tensorflow 导入的注意事项
导入阶段用onnx-tensorflow的命令行工具或者 Python API 都行。命令行方式:
onnx-tf convert -i model.onnx -o saved_model_dirPython API 方式:
import onnx from onnx_tf.backend import prepare onnx_model = onnx.load("model.onnx") tf_rep = prepare(onnx_model) tf_rep.export_graph("saved_model_dir")prepare这一步会做算子映射,如果遇到不支持的算子会直接抛异常,异常信息里通常会带上算子类型和节点名,方便你定位。导出成 SavedModel 之后,就可以用tf.saved_model.load加载,或者用tf.lite.TFLiteConverter转成 TFLite。
这里有个坑:onnx-tensorflow对某些算子的实现和 ONNX 规范有细微出入,尤其是涉及 padding、dilation 的卷积,以及 reduce 类算子。转完之后一定要用同样的输入在 TF 里跑一遍,和 ONNX 输出对比。我一般会写一个统一的验证脚本,把 PyTorch、ONNX、TF 三边的输出都算出来,两两对比 max diff。
4. 完整实操流程与关键环节实现
4.1 环境搭建与依赖安装
环境这块我建议用 conda 建独立环境,避免和系统里的其它包冲突。安装顺序也有讲究,先装 TensorFlow,再装 onnx-tensorflow,最后装 PyTorch 和 ONNX,这样依赖解析不容易出问题。
conda create -n pt2tf python=3.9 conda activate pt2tf pip install tensorflow==2.11.0 pip install onnx-tensorflow==1.10.0 pip install torch==1.13.1 pip install onnx==1.13.1 pip install onnxruntime==1.14.1 pip install onnxsim装完之后验证一下:
import torch, onnx, tensorflow as tf from onnx_tf.backend import prepare print(torch.__version__, onnx.__version__, tf.__version__)如果prepare能正常 import,说明基础环境没问题。这里提醒一句:onnx-tensorflow在 TF 2.x 下有时会因为 protobuf 版本冲突报错,如果遇到,把 protobuf 降到 3.20.x 通常能解决。
4.2 一个完整的转换示例:图像分类模型
我拿一个自己写的简单 CNN 分类器做演示,结构是几层卷积加全连接,输入 1x3x224x224,输出 10 类。完整流程如下:
第一步,定义并加载模型,切到 eval 模式:
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.AdaptiveAvgPool2d(1), ) self.classifier = nn.Linear(64, num_classes) def forward(self, x): x = self.features(x) x = x.flatten(1) return self.classifier(x) model = SimpleCNN() model.load_state_dict(torch.load("weights.pth", map_location="cpu")) model.eval()第二步,导出 ONNX,opset 用 13,声明 batch 动态:
dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "simple_cnn.onnx", opset_version=13, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, )第三步,用 onnxruntime 校验:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("simple_cnn.onnx") ort_out = sess.run(None, {"input": dummy.numpy()})[0] with torch.no_grad(): pt_out = model(dummy).numpy() print("onnx vs pytorch max diff:", np.abs(ort_out - pt_out).max())第四步,简化 ONNX 图:
from onnxsim import simplify import onnx m = onnx.load("simple_cnn.onnx") m_sim, ok = simplify(m) onnx.save(m_sim, "simple_cnn_sim.onnx") print("simplify ok:", ok)第五步,转 TensorFlow SavedModel:
from onnx_tf.backend import prepare tf_rep = prepare(onnx.load("simple_cnn_sim.onnx")) tf_rep.export_graph("simple_cnn_tf")第六步,在 TF 里加载并验证:
import tensorflow as tf loaded = tf.saved_model.load("simple_cnn_tf") infer = loaded.signatures["serving_default"] tf_out = infer(input=tf.constant(dummy.numpy()))["output"].numpy() print("tf vs onnx max diff:", np.abs(tf_out - ort_out).max())如果最后一步的 diff 在 1e-5 以内,恭喜你,整条链路通了。我实测这个简单模型全程不到五分钟,diff 在 1e-6 量级。
4.3 参数选择背后的计算逻辑
有人可能会问,opset 为什么选 13 而不是更高?原因是 onnx-tensorflow 1.10 对 opset 的支持上限大概在 15,但 15 里有些新算子它映射得不好,13 是兼容性和功能性的平衡点。再比如do_constant_folding=True,它会把推理时不变的子图提前算出来,减少运行时节点数,对转换后的 TF 图也有好处,因为常量折叠能减少 onnx-tensorflow 需要映射的算子数量。
动态维度的取舍前面说过,这里补充一个量化判断:如果你的部署场景 batch size 固定为 1,那声明动态维度纯属给自己找麻烦,固定 shape 的图在 TFLite 转换时也更友好。只有当你要做服务端推理、batch 会变的时候,才值得开动态维度。
4.4 转成 TFLite 的后续步骤
很多时候转 TensorFlow 只是中间步骤,最终目标是 TFLite。从 SavedModel 转 TFLite 的代码如下:
converter = tf.lite.TFLiteConverter.from_saved_model("simple_cnn_tf") converter.optimizations = [tf.lite.Optimize.DEFAULT] tflite_model = converter.convert() with open("simple_cnn.tflite", "wb") as f: f.write(tflite_model)这里optimizations开了默认优化,会做权重量化。如果你需要全整数量化,还得提供代表性数据集,那个流程更复杂,且对转换后的精度影响较大,建议单独验证。我一般先用默认优化跑通,再根据部署设备的算力决定要不要上全整数量化。
5. 常见问题与排查技巧实录
5.1 算子不支持怎么办
这是最常见的问题,报错信息通常是Unsupported operator: XXX。遇到这种情况,先确认这个算子在 ONNX 里的 opset 版本,再看 onnx-tensorflow 的算子映射表里有没有。如果没有,有几个应对思路:
一是用等价算子替换。比如某些自定义激活函数,如果它可以用基础算子组合出来,就在 PyTorch 侧改写 forward,用组合方式实现,导出后自然就是支持的算子。二是升级或降级 onnx-tensorflow,有时候新版本刚加了某个算子的支持。三是手动注册算子映射,onnx-tensorflow 允许你写自定义 handler,但这需要读它的源码,成本较高。
我遇到过一个HardSwish不支持的情况,最后是在 PyTorch 里把它拆成x * relu6(x + 3) / 6的形式,导出后就是一组基础算子,顺利转过去了。
5.2 数值精度对不齐的排查思路
精度问题比算子问题更隐蔽,因为不报错,只是结果有偏差。排查顺序建议是:先比 PyTorch 和 ONNX,再比 ONNX 和 TF。如果 PyTorch 和 ONNX 就有偏差,问题在导出;如果这两者一致但 TF 不一致,问题在导入。
导出阶段的偏差常见于插值、池化、归一化这几类算子。导入阶段的偏差常见于卷积的 padding 模式和 reduce 算子的 keepdims 处理。我一般会把中间层的输出也 dump 出来逐层对比,定位到具体是哪一层开始偏的。虽然麻烦,但比盲猜高效得多。
5.3 动态 shape 导致的导入失败
如果导出时声明了动态维度,导入 TF 时可能报 shape 相关的错误。这时候可以尝试在导出时先不声明动态维度,转通之后再研究怎么加动态支持。另一个办法是在 TF 侧用tf.reshape或tf.ensure_shape手动修正形状。实测下来,动态 shape 在 onnx-tensorflow 里的支持确实不如固定 shape 稳,如果部署时能固定就尽量固定。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| Unsupported operator | 算子不在映射表 | 替换等价算子或升级工具 |
| opset 不支持 | 导出 opset 过高 | 降低 opset_version 重新导出 |
| 输出全为 0 或 NaN | 权重未正确加载 | 检查 state_dict 加载和 eval 模式 |
| 精度偏差 1e-3 以上 | 插值/池化实现差异 | 逐层对比定位,替换算子 |
| shape 报错 | 动态维度处理不当 | 改固定 shape 或手动 reshape |
| protobuf 冲突 | 依赖版本不匹配 | 降级 protobuf 到 3.20.x |
| TF 加载后无输出 | 输出节点名不对 | 检查 output_names 和 signature |
5.5 几条独家避坑经验
第一,永远先跑通一个小模型再上大模型。我一开始拿 ResNet50 直接转,报错信息一大堆,根本不知道从哪下手。后来换成一个三层的小 CNN,十分钟跑通,再逐步加复杂度,效率高得多。
第二,保存中间产物。导出的 ONNX、简化后的 ONNX、转换后的 SavedModel 都留着,出问题时可以逐段回退,不用每次从头来。
第三,验证脚本要统一。我写了一个函数,输入是模型路径和测试数据,输出是三边的 max diff,每次改完参数跑一遍,心里有数。
第四,注意 PyTorch 的 trace 和 script 区别。torch.onnx.export默认用 trace,它只记录实际执行路径,如果模型里有 if/else 分支依赖输入,trace 会丢分支。这种情况要用torch.jit.script先转成 ScriptModule 再导出。我有个模型里用了条件分支,trace 导出的图只保留了其中一条路径,排查了半天才发现是这个原因。
6. 转换后的部署验证与性能考量
6.1 部署前的三重验证
模型转完不等于能用,我一般做三重验证。第一重是数值一致性,用一批真实数据跑 PyTorch、ONNX、TF 三边,统计 max diff 和平均 diff,确认在可接受范围。第二重是端到端功能验证,把 TF 模型接到实际的前处理和后处理流程里,看最终业务指标(比如分类准确率)有没有掉。第三重是边界输入验证,用全零、全一、极端值等输入跑一遍,看有没有 NaN 或溢出。
这三重里,第二重最容易被跳过,但恰恰最重要。我见过数值 diff 很小、但业务指标掉了两个点的案例,原因是转换后某个归一化层的 epsilon 被改了,数值上差异不大,但累积到深层就放大了。
6.2 性能对比与优化方向
转换后的 TF 模型性能不一定比 PyTorch 好,这取决于目标硬件和运行时。我在 CPU 上实测,同一个模型 ONNX Runtime 的推理速度通常比原生 PyTorch 快一些,TF 的速度介于两者之间。如果目标是 GPU,差异会更明显,因为各家对 CUDA 算子的优化程度不同。
优化方向主要有几个:一是图优化,用 TF 的 Grappler 做常量折叠、算子融合;二是量化,FP32 转 FP16 或 INT8,能显著提速但要注意精度;三是算子替换,把 TF 里实现较慢的算子换成等价的高效实现。这些都需要在目标设备上实测,不能只看理论。
6.3 什么情况下不建议走这条路
说了这么多,也得说说什么情况下这条路不划算。如果你的模型里有大量自定义算子,且这些算子没有 ONNX 等价实现,那转换成本会非常高,不如直接用 TensorFlow 重写。如果部署环境对延迟极其敏感,转换引入的额外算子映射可能带来性能损失,也需要权衡。还有就是模型结构频繁变动的阶段,每次改动都要重新走一遍转换链路,维护成本不低。
我的判断标准是:模型结构稳定、算子常规、部署环境明确要求 TF 生态,这三个条件同时满足时,ONNX 转换是最优解。缺一个,就要重新评估。
7. 我在实际操作中的几点体会
整条链路走下来,最大的感受是"版本"和"验证"这两个词贯穿始终。版本决定了你能不能跑通,验证决定了你跑通的东西对不对。我见过太多人导出成功就以为万事大吉,结果上线后精度崩了才发现中间某层早就偏了。
另外,ONNX 作为中间格式,它的价值不只是转换,更是一个"模型体检"的机会。导出过程中暴露出来的算子问题、动态 shape 问题,往往也反映了模型本身在设计上的一些隐患。借着转换的机会把这些问题理一遍,对模型的可维护性和可移植性都有好处。
最后分享一个小技巧:如果你经常做这类转换,可以把整个流程封装成一个脚本,输入是 PyTorch 模型和 dummy input,输出是验证报告和 TF SavedModel。这样每次换模型只需要改几个参数,省去重复劳动。我自己维护了这么一个脚本,后来转十几个模型都是几分钟的事。