4、读取ONNX模型:使用onnx.load()加载模型,理解ModelProto对象结构

当我们拿到一个 .onnx 文件时,第一反应往往是“直接打开看看里面是什么”。但 ONNX 采用 Protobuf(Protocol Buffers)序列化格式存储,直接以文本方式打开只会看到乱码。正确的方式是使用官方 Python API 中的 onnx.load() 函数,将二进制文件解析为 ModelProto 对象,再通过该对象的嵌套结构逐层访问模型信息。本章将系统讲解 ModelProto 的层级关系、核心字段的读取方法,以及如何在实际工作中快速定位模型的关键属性。

ModelProto 对象结构解析 ModelProto(根对象) onnx.load() 返回 graph: GraphProto 计算图核心(必读) metadata: 元信息 ir_version / opset_import / producer 其他字段 training_info / functions / doc_string node[] 算子列表 initializer[] 初始权重 input[] 模型输入 output[] 模型输出 NodeProto 关键字段 op_type / name input[] / output[] attribute[] TensorProto 关键字段 dims[] / data_type raw_data / float_data name ValueInfoProto name / type shape / elem_type doc_string 读取路径:model.graph.node[i].op_type → 逐层访问

上图展示了 ModelProto 的核心层级关系。从根对象出发,我们最常访问的是 graph 字段,它包含了模型的计算逻辑;其次是 opset_import 和 ir_version,它们决定了算子语义的版本兼容性。下面我们逐一深入。

4.1 ModelProto 根对象:模型的“身份证”

onnx.load() 函数接受一个文件路径参数,返回 ModelProto 实例。这个对象是 ONNX 模型的最高抽象,它封装了模型的所有信息。我们可以通过 Python 的 dir() 函数查看其可用字段,但更直接的方式是打印对象本身——Protobuf 的 __str__ 方法会以可读文本格式输出全部内容。

import onnx

# 加载模型
model = onnx.load("resnet18.onnx")

# 查看模型基本信息
print("ir_version:", model.ir_version)
print("producer_name:", model.producer_name)
print("producer_version:", model.producer_version)
print("domain:", model.domain)
print("model_version:", model.model_version)
print("doc_string:", model.doc_string[:100] if model.doc_string else "None")

# 查看 opset 信息
for opset in model.opset_import:
    print(f"opset domain={opset.domain}, version={opset.version}")

这些字段中,ir_version 表示 ONNX 中间表示(IR)的版本号,它决定了模型使用的语法特性;opset_import 是一个列表,每个元素包含 domain 和 version,指定了算子集(Operator Set)的版本。例如 domain=""(默认域)对应标准 ONNX 算子,version=13 表示使用 ONNX 13 版本的算子语义。如果模型使用了自定义算子,domain 会显示为自定义命名空间。

经验提示:当模型推理报错“Unsupported operator”时,首先检查 opset_import 的版本是否与推理引擎(如 ONNX Runtime)支持的版本匹配。例如,ONNX Runtime 1.14 默认支持 opset 19,但旧模型可能只声明了 opset 11,此时需要升级算子集或使用兼容模式。

4.2 GraphProto:计算图的核心容器

model.graph 是 GraphProto 对象,它包含了模型的计算逻辑。一个典型的 GraphProto 包含四个主要列表:node(算子节点)、initializer(初始权重)、input(模型输入)、output(模型输出)。此外还有 value_info(中间张量信息)、name(图名称)和 doc_string(文档说明)。

理解 GraphProto 的关键在于:它描述了一个有向无环图(DAG),其中 node 列表中的每个元素代表一个算子,算子的输入输出通过张量名称连接。而 initializer 中的权重张量,实际上也是图的输入——它们不通过 input 列表暴露给外部,而是作为内部常量存在。

4.2.1 遍历 NodeProto:理解算子序列

每个 NodeProto 包含 op_type(算子类型)、name(节点名称)、input(输入张量名列表)、output(输出张量名列表)和 attribute(算子属性列表)。以下代码展示了如何遍历所有节点并打印关键信息:

for i, node in enumerate(model.graph.node):
    print(f"Node {i}: op_type={node.op_type}, name={node.name}")
    print(f"  inputs: {list(node.input)}")
    print(f"  outputs: {list(node.output)}")
    if node.attribute:
        for attr in node.attribute:
            print(f"  attr: {attr.name} = {onnx.helper.get_attribute_value(attr)}")
    print()

在实际工程中,我们通常关注 op_type 的分布,以了解模型使用了哪些算子。例如,一个卷积神经网络可能包含 Conv、Relu、MaxPool、Flatten、Gemm 等算子。通过统计 op_type 的频率,可以快速判断模型类型和计算瓶颈。

4.2.2 读取 Initializer:权重与常量

initializer 列表中的每个元素是 TensorProto 对象,它存储了模型的权重和偏置等常量。读取权重数据有两种方式:

# 方式一:通过 numpy 转换(推荐)
import numpy as np
for init in model.graph.initializer:
    arr = onnx.numpy_helper.to_array(init)
    print(f"Initializer: {init.name}, shape={arr.shape}, dtype={arr.dtype}")
    print(f"  First 5 values: {arr.flatten()[:5]}")

# 方式二:直接访问原始数据
for init in model.graph.initializer:
    print(f"Name: {init.name}, dims: {list(init.dims)}, data_type: {init.data_type}")
    # raw_data 是字节串,需要按类型解析
    if init.raw_data:
        print(f"  raw_data length: {len(init.raw_data)} bytes")

使用 onnx.numpy_helper.to_array() 是最便捷的方式,它自动处理数据类型转换和字节序问题。注意,initializer 中的张量名称与 node.input 中的名称一一对应,这是连接权重与算子的关键。

4.2.3 理解 input/output 与 ValueInfoProto

model.graph.input 和 model.graph.output 是 ValueInfoProto 列表,它们描述了模型对外接口的张量信息。每个 ValueInfoProto 包含 name、type(包含 elem_type 和 shape)以及可选的 doc_string。

def print_value_info(vi, prefix=""):
    print(f"{prefix}Name: {vi.name}")
    if vi.type.HasField("tensor_type"):
        tt = vi.type.tensor_type
        elem_type = tt.elem_type
        print(f"{prefix}  elem_type: {elem_type} ({onnx.TensorProto.DataType.Name(elem_type)})")
        shape = [d.dim_value if d.HasField("dim_value") else d.dim_param for d in tt.shape.dim]
        print(f"{prefix}  shape: {shape}")

print("=== Model Inputs ===")
for inp in model.graph.input:
    print_value_info(inp)

print("\n=== Model Outputs ===")
for out in model.graph.output:
    print_value_info(out)

这里有一个重要细节:graph.input 不仅包含真正的模型输入,还可能包含 initializer 中的权重名称(如果某些框架导出时未将权重从 input 中移除)。因此,判断真正的输入需要排除 initializer 中已有的名称。

常见陷阱:有些模型(尤其是从 PyTorch 导出的)会将所有权重同时列在 graph.input 和 initializer 中。如果直接遍历 graph.input 并尝试为每个输入提供数据,会导致运行时错误。正确做法是:real_inputs = [i.name for i in model.graph.input if i.name not in init_names]。

4.3 实战案例:剖析一个 MNIST 模型

为了将上述方法串联起来,我们以一个手写数字识别模型(MNIST)为例,完整演示从加载到信息提取的过程。假设我们有一个 mnist.onnx 文件,目标是回答以下问题:

  1. 模型输入的形状和数据类型是什么?
  2. 模型包含哪些算子?权重张量的形状和数量?
  3. 模型输出是什么?
import onnx
import numpy as np

# 1. 加载模型
model = onnx.load("mnist.onnx")

# 2. 获取输入输出信息
init_names = {init.name for init in model.graph.initializer}
real_inputs = [i for i in model.graph.input if i.name not in init_names]
print("真实输入:")
for inp in real_inputs:
    tt = inp.type.tensor_type
    shape = [d.dim_value for d in tt.shape.dim]
    print(f"  {inp.name}: shape={shape}, dtype={onnx.TensorProto.DataType.Name(tt.elem_type)}")

print("\n输出:")
for out in model.graph.output:
    tt = out.type.tensor_type
    shape = [d.dim_value for d in tt.shape.dim]
    print(f"  {out.name}: shape={shape}, dtype={onnx.TensorProto.DataType.Name(tt.elem_type)}")

# 3. 统计算子类型
from collections import Counter
op_counter = Counter(node.op_type for node in model.graph.node)
print("\n算子统计:")
for op, cnt in op_counter.most_common():
    print(f"  {op}: {cnt}")

# 4. 查看权重信息
print("\n权重张量:")
for init in model.graph.initializer:
    arr = onnx.numpy_helper.to_array(init)
    print(f"  {init.name}: shape={arr.shape}, dtype={arr.dtype}, size={arr.size}")

运行结果可能如下:

真实输入:
  Input3: shape=[1, 1, 28, 28], dtype=FLOAT

输出:
  Plus214_Output_0: shape=[1, 10], dtype=FLOAT

算子统计:
  Conv: 2
  Relu: 2
  MaxPool: 2
  Flatten: 1
  Gemm: 1

权重张量:
  Conv1_W: shape=[8, 1, 5, 5], dtype=FLOAT, size=200
  Conv1_B: shape=[8], dtype=FLOAT, size=8
  Conv2_W: shape=[16, 8, 5, 5], dtype=FLOAT, size=3200
  Conv2_B: shape=[16], dtype=FLOAT, size=16
  Gemm1_W: shape=[10, 256], dtype=FLOAT, size=2560
  Gemm1_B: shape=[10], dtype=FLOAT, size=10

通过这个案例,我们不仅验证了读取方法的正确性,还获得了模型结构的完整画像。这种信息提取能力是后续进行模型转换、量化、剪枝或调试的基础。

4.4 常见误区与最佳实践

在读取 ONNX 模型时,初学者常犯以下错误,需要特别注意:

误区 后果 正确做法
直接用文本编辑器打开 .onnx 文件 看到乱码,无法获取任何有效信息 使用 onnx.load() 或 onnx.shape_inference.infer_shapes()
忽略 opset_import 版本 推理引擎报算子不支持错误 检查 opset 版本,必要时使用 onnx.version_converter 升级
将 graph.input 全部当作真实输入 推理时多传或错传数据 排除 initializer 中已有的名称
直接访问 init.raw_data 而不解析 得到字节串而非数值数组 使用 onnx.numpy_helper.to_array()
认为 node 顺序就是执行顺序 误解模型拓扑结构 ONNX 图是数据流图,执行顺序由数据依赖决定,而非列表顺序

此外,推荐使用 onnx.helper.printable_graph(model.graph) 快速打印图的文本表示,它比直接打印 model 更聚焦于图结构。对于大型模型(数百个节点),建议使用 onnx.utils.extract_model() 提取子图,或使用 netron 可视化工具辅助分析。

核心要点回顾:
  • onnx.load() 返回 ModelProto,它是访问一切信息的入口。
  • model.graph 是核心,包含 node、initializer、input、output 四个关键列表。
  • 读取权重使用 onnx.numpy_helper.to_array(),读取形状使用 type.tensor_type.shape.dim。
  • 始终检查 opset_import 和 ir_version,确保与推理引擎兼容。
  • 区分真实输入与权重输入,避免推理时数据错配。

掌握这些读取方法后,你已经能够独立分析任意 ONNX 模型的结构。下一步,我们将深入 NodeProto 的 attribute 字段,学习如何解析算子参数(如卷积的 kernel_shape、strides 等),从而为模型修改和优化打下基础。