2、ONNX基础:ONNX格式简介、模型文件结构、节点与张量概念
2.1 ONNX 格式简介:为什么需要一种“中间格式”
在深度学习生态中,PyTorch、TensorFlow、PaddlePaddle 等框架各自拥有独立的模型保存格式。这些格式与框架的运行时、算子实现深度绑定,导致模型难以跨框架部署。ONNX(Open Neural Network Exchange)正是为解决此问题而诞生的开放式中间表示格式。它不隶属于任何单一框架,而是定义了一套与框架无关的计算图描述规范。训练好的模型可以导出为 ONNX 文件,再通过各平台的 ONNX Runtime 或转换工具,部署到云端服务器、移动端甚至嵌入式设备上。
从本质上看,ONNX 文件是一个协议缓冲区(Protobuf)序列化文件,它记录了模型的计算图结构、算子类型、张量维度、初始权重以及元数据。这种二进制格式具有跨平台、可扩展、体积紧凑的特点。理解 ONNX 的结构,是后续进行模型可视化、编辑和优化的基础。
核心要点:ONNX 不是一个新的训练框架,而是一个“翻译层”和“通用语言”。它将不同框架训练出的模型统一为同一种计算图描述,使得模型可以在不同环境间流转。
2.2 模型文件结构:从 Protobuf 到计算图
ONNX 模型文件采用 Protobuf 序列化格式存储。打开一个 .onnx 文件,其顶层结构是一个 ModelProto 对象,它主要包含以下关键字段:
- ir_version:ONNX 规范版本号,决定了图结构的解析规则。
- opset_import:声明该模型使用的算子集版本(如 ai.onnx v17),不同版本的算子可能具有不同的属性定义。
- producer_name / producer_version:生成该模型的框架名称与版本(如 PyTorch 2.1)。
- graph:模型的核心,即计算图(GraphProto)。
而 GraphProto 内部又由四个主要部分组成:node(节点列表)、initializer(初始权重)、input(图输入)和 output(图输出)。此外还有 value_info 用于记录中间张量的形状信息。我们可以用 Python 快速查看模型结构:
import onnx
model = onnx.load("resnet18.onnx")
graph = model.graph
print("节点数量:", len(graph.node))
print("初始权重数量:", len(graph.initializer))
print("输入:", [(i.name, i.type.tensor_type.shape) for i in graph.input])
print("输出:", [o.name for o in graph.output])
# 打印前三个节点的信息
for n in graph.node[:3]:
print(f"节点类型: {n.op_type}, 名称: {n.name}")
print(f" 输入: {list(n.input)}")
print(f" 输出: {list(n.output)}")
从上述代码可以看到,节点(Node)是计算图的基本执行单元,每个节点代表一个算子操作(如 Conv、Relu、Add),通过输入输出张量名称与其他节点连接。
| GraphProto 成员 | 作用 | 可视化中的意义 |
|---|---|---|
| node | 算子节点列表,描述计算逻辑 | 图中的方框/圆形节点 |
| initializer | 存储权重、偏置等常量张量 | 通常不显示或显示为独立小节点 |
| input | 模型输入张量定义 | 图的入口节点 |
| output | 模型输出张量定义 | 图的出口节点 |
| value_info | 中间张量的形状与类型信息 | 辅助显示数据维度变化 |
2.3 节点与张量:理解计算图的核心要素
ONNX 计算图本质上是一个有向无环图(DAG)。图中的节点(Node)代表算子,张量(Tensor)则作为边在节点间传递数据。每个节点通过 input 字段引用上游张量名称,通过 output 字段定义下游张量名称。这种设计使得模型结构完全由张量的数据依赖关系决定。
以经典的卷积块为例,一个 Conv 节点通常接收三个输入:输入特征图、权重(W)和偏置(B),输出一个特征图。权重和偏置通常存放在 initializer 中,它们不来自上游节点,而是直接作为常量嵌入模型。这种“权重即常量”的设计是 ONNX 图的一个重要特征。
# 假设某节点定义如下(伪代码)
node {
op_type: "Conv"
name: "conv1"
input: "data" # 来自上游或图输入
input: "conv1_w" # 来自 initializer
input: "conv1_b" # 来自 initializer
output: "conv1_out"
attribute {
name: "kernel_shape"
ints: [3, 3]
}
attribute {
name: "strides"
ints: [2, 2]
}
}
在可视化工具中,节点通常被绘制为矩形或圆形,张量则表现为连接节点的边。理解节点与张量的关系,是读懂可视化结果的前提:张量名是全局唯一的标识符,它决定了数据如何流动;而节点属性(如卷积核大小、步长)则决定了数据如何被变换。
可视化技巧:在 Netron 中,点击任意节点可以查看其详细属性(如 Conv 的 kernel_shape、strides、padding)。点击张量边可以查看该张量的维度、数据类型以及产生它的节点。利用这些信息,可以快速定位模型中的关键结构。
2.4 案例:从 PyTorch 导出并解析一个简单模型
为了更直观地理解上述概念,我们以一个两层全连接网络为例,演示如何导出 ONNX 并解析其结构。
import torch
import torch.nn as nn
import onnx
class SimpleNet(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(4, 8)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(8, 2)
def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x
model = SimpleNet()
dummy_input = torch.randn(1, 4)
torch.onnx.export(model, dummy_input, "simple_net.onnx",
input_names=["input"], output_names=["output"],
opset_version=17)
print("导出成功!")
# 解析 ONNX 结构
onnx_model = onnx.load("simple_net.onnx")
graph = onnx_model.graph
print(f"图输入: {[i.name for i in graph.input]}")
print(f"图输出: {[o.name for o in graph.output]}")
print(f"节点数: {len(graph.node)}")
for node in graph.node:
print(f" [{node.op_type}] {node.name} -> 输出: {list(node.output)}")
print(f"初始权重数: {len(graph.initializer)}")
for init in graph.initializer:
print(f" 权重: {init.name}, 形状: {list(init.dims)}")
运行上述代码后,你会看到输出中清晰地列出了 3 个节点(Gemm、Relu、Gemm)以及对应的权重张量。其中 Gemm 是 ONNX 中全连接层的通用算子,它接收输入和权重,执行矩阵乘法并加偏置。通过这种解析方式,我们能够验证导出的模型是否符合预期,也为后续的可视化分析打下基础。
2.5 常见误区与复盘
在学习和使用 ONNX 的过程中,初学者容易陷入以下几个误区:
- 误区一:认为 ONNX 是某种运行时。实际上 ONNX 只是模型描述格式,真正执行推理的是 ONNX Runtime 或其他推理引擎。
- 误区二:忽略 opset 版本的影响。不同 opset 版本对算子定义有差异,旧版本导出的模型在新版本中可能无法解析,反之亦然。导出时应明确指定 opset 版本。
- 误区三:混淆 initializer 与节点输入。initializer 中的权重是常量,不参与梯度计算,也不作为图输入。在可视化时,它们通常被折叠或单独显示,不应与真正的数据输入混淆。
- 误区四:认为节点顺序代表执行顺序。ONNX 图是数据流图,节点执行顺序由张量依赖关系决定,而非节点在列表中的排列顺序。可视化工具通常采用拓扑排序来布局。