3、ONNX模型结构解析:ONNX计算图、节点(Node)、张量(Tensor)、初始器(Initializer)、图(Graph)与元数据

好,咱们今天来拆一个ONNX模型。

说实话,我第一次拿到ONNX文件时,第一反应是:这玩意儿到底长什么样?是像JSON那样能直接看,还是像二进制那样黑盒?后来我打开一看,嗯,它其实是个Protobuf序列化文件。说白了,就是Google那套序列化协议打包出来的。

我个人习惯,拿到一个新模型,第一件事就是把它反序列化,看看里面到底装了啥。今天我就带你一层层剥开ONNX的“洋葱”。

3.1 ONNX计算图:一切的核心

ONNX的核心,就是一张计算图(Graph)。

你想想看,深度学习模型本质上是什么?是一堆数学运算的组合。卷积、池化、激活、全连接……这些运算按照一定的顺序连接起来,就形成了一张图。这张图就是计算图。

我在项目中遇到过,有些同学把ONNX当成一个“黑盒”,直接拿来推理,出了问题也不知道怎么查。其实你只要理解了计算图,很多问题一眼就能看穿。

计算图由几个关键部分组成:

  • 节点(Node):图中的每个运算操作,比如Conv、Relu、Add。
  • 张量(Tensor):节点之间的数据流,也就是输入和输出。
  • 初始器(Initializer):模型的权重和偏置,这些是固定的参数。
  • 图(Graph):把节点、张量、初始器组织起来的容器。
  • 元数据(Metadata):模型的版本、作者、描述等信息。

核心理解:ONNX模型 = 一张计算图 + 一堆权重 + 一些元数据。就这么简单。

3.2 节点(Node):运算的“原子”

节点是计算图的基本单元。每个节点代表一个运算操作。

比如一个卷积层,在ONNX里就是一个Conv节点。它有几个关键属性:

  • op_type:运算类型,比如"Conv"、"Relu"、"Add"。
  • input:输入张量的名称列表。
  • output:输出张量的名称列表。
  • attribute:运算的参数,比如卷积的kernel_shape、strides、pads。

我举个例子,一个简单的卷积节点长这样:

node {
  input: "input_tensor"
  input: "conv_weight"
  input: "conv_bias"
  output: "conv_output"
  op_type: "Conv"
  attribute {
    name: "kernel_shape"
    ints: 3
    ints: 3
  }
  attribute {
    name: "strides"
    ints: 1
    ints: 1
  }
  attribute {
    name: "pads"
    ints: 1
    ints: 1
    ints: 1
    ints: 1
  }
}

你看,这个节点接收三个输入:输入张量、权重、偏置。输出一个张量。属性里定义了卷积核大小、步长、填充。

避坑指南:我曾经在转换模型时,发现ONNX里的卷积padding计算方式和PyTorch不一样。PyTorch默认是"same"或"valid",但ONNX里你得手动算pads。这个坑我踩过,后来我写了个脚本自动对齐padding。

3.3 张量(Tensor):数据的“载体”

张量就是数据。在ONNX里,张量有名字、数据类型、形状和具体的数值。

张量分为两种:

  • 输入/输出张量:节点之间的数据流,比如卷积的输入和输出。
  • 初始器张量:模型的权重和偏置,这些是固定的。

一个张量的定义大概是这样:

tensor {
  dims: 1
  dims: 3
  dims: 224
  dims: 224
  data_type: FLOAT
  name: "input_tensor"
}

这里定义了一个形状为[1, 3, 224, 224]的浮点张量,名字叫"input_tensor"。

你想想看,整个模型推理过程,就是张量在节点之间流动的过程。输入张量经过Conv节点,变成特征图张量;再经过Relu节点,变成激活后的张量……直到最后的输出张量。

3.4 初始器(Initializer):模型的“记忆”

初始器就是模型的权重和偏置。它们是训练好的参数,在推理时不会改变。

在ONNX里,初始器被单独存放在一个列表里。每个初始器就是一个张量,但它是“可读不可写”的。

我举个例子,一个卷积层的权重初始器:

initializer {
  dims: 64
  dims: 3
  dims: 3
  dims: 3
  data_type: FLOAT
  name: "conv1.weight"
  raw_data: "..."
}

这里的raw_data就是权重数值的二进制表示。ONNX支持两种存储方式:

  • raw_data:二进制格式,体积小,解析快。
  • float_data / int32_data:明文格式,可读性好,但体积大。

重要提示:量化压缩时,我们主要动的是初始器。把float32的权重变成int8,体积直接缩小4倍。我做过一个项目,把ResNet50的权重从FP32量化到INT8,模型大小从98MB降到25MB,精度只掉了0.3%。

3.5 图(Graph):把一切组织起来

图是ONNX模型的骨架。它把节点、张量、初始器组织成一个完整的计算流程。

一个图包含:

  • node:所有运算节点。
  • initializer:所有权重和偏置。
  • input:模型的输入张量。
  • output:模型的输出张量。
  • value_info:中间张量的形状和类型信息。

我画个简单的图帮你理解:

输入张量 (input_tensor)
    ↓
Conv节点 (conv1)  ← 权重初始器 (conv1.weight) + 偏置初始器 (conv1.bias)
    ↓
Relu节点 (relu1)
    ↓
Conv节点 (conv2)  ← 权重初始器 (conv2.weight) + 偏置初始器 (conv2.bias)
    ↓
输出张量 (output_tensor)

你看,这就是一个最简单的两层卷积网络的计算图。每个节点从初始器或前一个节点获取输入,然后产生输出。

个人经验:我调试模型时,经常用Netron可视化工具看计算图。它能直观地展示节点之间的连接关系。有一次我发现模型里多了一个没用的Identity节点,就是通过可视化发现的。

3.6 元数据(Metadata):模型的“身份证”

元数据记录了模型的基本信息。虽然不参与计算,但非常重要。

常见的元数据包括:

  • ir_version:ONNX中间表示的版本号。
  • opset_import:使用的算子集版本。
  • producer_name:生成模型的框架,比如"pytorch"、"tensorflow"。
  • producer_version:框架的版本号。
  • domain:模型所属的域,通常是"ai.onnx"。
  • model_version:模型版本号,可以自己定义。
  • doc_string:模型的描述信息。

我举个例子:

ir_version: 8
opset_import {
  version: 18
}
producer_name: "pytorch"
producer_version: "2.1.0"
domain: "ai.onnx"
model_version: 1
doc_string: "ResNet50 model for image classification"

注意:元数据里的opset_import版本很重要。不同版本的ONNX支持的算子不一样。我遇到过一个问题:用opset 18导出的模型,在只支持opset 15的推理引擎上跑不了。后来我统一用opset 11,兼容性最好。

3.7 实战:用Python解析ONNX模型

光说不练假把式。咱们写几行代码,看看ONNX模型到底长什么样。

import onnx

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

# 获取图
graph = model.graph

# 打印节点信息
print("=== 节点列表 ===")
for i, node in enumerate(graph.node):
    print(f"节点{i}: op_type={node.op_type}, input={node.input}, output={node.output}")

# 打印初始器信息
print("\n=== 初始器列表 ===")
for initializer in graph.initializer:
    print(f"名称: {initializer.name}, 形状: {initializer.dims}, 数据类型: {initializer.data_type}")

# 打印输入输出
print("\n=== 输入 ===")
for input_tensor in graph.input:
    print(f"名称: {input_tensor.name}, 形状: {input_tensor.type.tensor_type.shape}")

print("\n=== 输出 ===")
for output_tensor in graph.output:
    print(f"名称: {output_tensor.name}, 形状: {output_tensor.type.tensor_type.shape}")

# 打印元数据
print(f"\n=== 元数据 ===")
print(f"IR版本: {model.ir_version}")
print(f"算子集版本: {model.opset_import[0].version}")
print(f"生产者: {model.producer_name} {model.producer_version}")

运行这段代码,你就能看到模型的全貌。我个人习惯,拿到一个新模型,先跑一遍这个脚本,心里就有底了。

总结一下:ONNX模型 = 计算图 + 权重 + 元数据。计算图由节点、张量、初始器组成。理解了这些,你就掌握了ONNX的“骨架”。下一章,咱们聊聊怎么用Python操作ONNX模型,包括增删改查节点。

嗯,今天就到这儿。记住,动手实践是最好的学习方式。打开你的Python,加载一个ONNX模型,看看它里面到底有啥。