ONNX模型基础:ONNX格式、计算图结构、节点与张量、输入输出定义

在开始模型裁剪之前,我们必须先建立对ONNX模型底层结构的清晰认知。ONNX(Open Neural Network Exchange)本质上是一种序列化的计算图描述协议,它将深度学习模型从训练框架(如PyTorch、TensorFlow)中解耦,形成一种框架无关的中间表示。裁剪操作,本质上是对这张计算图进行"删减"和"整理"——删除不再需要的计算节点,调整模型的输入输出接口。如果对计算图的结构理解不透彻,裁剪操作极易破坏模型的拓扑完整性,导致推理引擎加载失败或产生错误结果。本章将系统拆解ONNX格式的四个核心维度:格式封装、计算图结构、节点与张量、输入输出定义,为后续的裁剪实操打下坚实的理论基础。

ONNX模型结构全景图 ONNX文件格式(.onnx) Protobuf序列化二进制 包含计算图 + 元数据 + 版本信息 计算图(Graph) 节点列表 + 初始张量 + 输入/输出 有向无环图(DAG) 节点(Node)与张量(Tensor) 算子类型 + 输入/输出张量名 张量:数据容器 + 形状 + 数据类型 输入/输出定义(ValueInfo) • 名称(Name):张量在计算图中的唯一标识 • 数据类型(DataType):float32、int64、bool等 • 形状(Shape):[batch, channel, height, width] 裁剪操作(Pruning) • 删除无用节点:识别并移除死路径 • 输出整理:重定义输出张量名称与形状 • 保持图拓扑完整性:输入→节点→输出链路 裁剪前:分析计算图依赖关系 遍历节点,标记不可达路径 裁剪后:验证输出正确性 对比输出张量,确保数值一致

2.1 ONNX格式:Protobuf序列化与模型封装

ONNX模型文件采用Google Protobuf(Protocol Buffers)进行序列化存储。Protobuf是一种高效的二进制序列化协议,相比JSON或XML,它具有更小的体积和更快的解析速度。一个标准的.onnx文件,其顶层结构包含三个关键部分:ModelProto、GraphProto和OperatorSetIdProto。其中ModelProto是根对象,它封装了模型的元数据(如生产者名称、版本)、图结构(GraphProto)以及使用的算子集版本(Opset Version)。

在裁剪操作中,我们通常使用Python的onnx库加载模型,该库会将.onnx文件解析为内存中的ModelProto对象。通过修改这个对象的属性(如删除node列表中的元素、修改output列表),再重新序列化为文件,即可完成裁剪。理解Protobuf的嵌套结构至关重要:ModelProto包含GraphProto,GraphProto包含NodeProto列表、TensorProto(初始权重)列表、ValueInfoProto(输入输出描述)列表。裁剪时,我们主要操作的是NodeProto列表和ValueInfoProto列表。

核心要点:ONNX的Protobuf结构决定了裁剪操作的本质——对内存中的对象图进行增删改查。任何裁剪工具(如onnx.utils.extract_model)底层都是对GraphProto的节点列表进行遍历和过滤。

2.2 计算图结构:有向无环图(DAG)与数据流

ONNX的计算图是一个有向无环图(DAG),其中节点(Node)代表算子(如Conv、Relu、Add),边(Edge)代表张量(Tensor)的流动方向。数据从输入张量开始,经过一系列节点的计算,最终流向输出张量。理解DAG的拓扑结构是裁剪的前提——我们必须知道哪些节点是"死节点"(即其输出不被任何后续节点或最终输出所引用),才能安全地删除它们。

计算图中有三种特殊的张量角色:图输入(Graph Input)、图输出(Graph Output)和中间张量(Intermediate Tensor)。图输入是模型对外部数据的入口,图输出是模型对外部调用者返回的结果,中间张量则是节点之间传递的临时数据。裁剪时,我们通常关注的是:删除那些只产生中间张量、且该中间张量不被任何输出路径引用的节点。

张量角色 定义位置 裁剪时的处理策略
图输入(Graph Input) graph.input列表 通常保留,除非裁剪后模型不再需要该输入
图输出(Graph Output) graph.output列表 裁剪的核心目标之一:重新定义输出张量
中间张量(Intermediate) 节点间的连接边 若不被任何输出路径引用,则其上游节点可被删除
初始权重(Initializer) graph.initializer列表 若对应的节点被删除,权重也应一并移除

2.3 节点与张量:算子属性与数据容器

每个节点(NodeProto)包含四个核心字段:op_type(算子类型,如"Conv")、name(节点名称,可选)、input(输入张量名称列表)、output(输出张量名称列表)以及attribute(算子属性,如卷积的kernel_shape、strides)。在裁剪时,我们通过op_type识别节点功能,通过input和output追踪数据依赖关系。

张量(TensorProto)是数据的容器,它包含dims(形状)、data_type(数据类型)和raw_data(原始二进制数据)。在计算图中,张量名称是全局唯一的字符串标识。裁剪操作中,我们经常需要检查某个张量名称是否出现在节点的input或output列表中,以此判断该张量是否仍然被需要。

# 示例:遍历节点并打印其输入输出张量名
import onnx
model = onnx.load("model.onnx")
for node in model.graph.node:
    print(f"Op: {node.op_type}, Inputs: {list(node.input)}, Outputs: {list(node.output)}")

2.4 输入输出定义:ValueInfoProto与模型接口

模型的输入输出定义存储在graph.input和graph.output列表中,每个元素是一个ValueInfoProto对象,包含name、type(数据类型)和shape(形状信息)。这些定义构成了模型对外部世界的"接口契约"。在模型裁剪中,输出整理是核心环节——当我们删除了模型尾部的某些节点后,需要将新的中间张量提升为图输出,并更新graph.output列表。

例如,原始模型输出是分类概率(经过Softmax),但裁剪后我们只需要倒数第二层的特征向量(未经过Softmax)。此时,我们需要将特征向量的张量名称添加到graph.output中,并移除原有的Softmax输出。同时,必须确保新输出的ValueInfoProto中正确声明了形状和数据类型,否则推理引擎可能因形状不匹配而报错。

经验提醒:在修改graph.output时,建议使用onnx.helper.make_tensor_value_info()函数创建新的ValueInfoProto,并显式指定形状。如果形状未知(动态维度),可以使用None或"batch"等符号名称。

2.5 案例拆解:从分类模型提取特征提取器

假设我们有一个训练好的图像分类模型(ResNet-18),输入为[1, 3, 224, 224]的RGB图像,输出为[1, 1000]的分类概率。现在,我们需要将该模型裁剪为特征提取器——只保留到全局平均池化层(GlobalAveragePool)的输出,去掉后面的全连接层(FC)和Softmax层。

初始现象与目标:原始模型输出是概率分布,但我们希望获得图像的特征向量(维度为[1, 512]),用于下游的相似度检索任务。直接使用原始模型无法获取中间层特征。

分析判断过程:首先,我们加载模型并打印所有节点的op_type,定位到GlobalAveragePool节点的输出张量名称(假设为"pool_output")。然后,检查该张量之后的所有节点(FC、Softmax),确认它们只服务于分类输出,不参与其他分支。

实际操作步骤:

  1. 使用onnx.utils.extract_model()函数,指定输入张量名称为"input",输出张量名称改为"pool_output"。
  2. 该函数会自动遍历计算图,保留从输入到"pool_output"的所有必要节点,删除FC和Softmax节点。
  3. 重新保存裁剪后的模型为feature_extractor.onnx。
import onnx
from onnx.utils import extract_model

# 提取从输入到pool_output的子图
extract_model(
    "resnet18.onnx",
    "feature_extractor.onnx",
    input_names=["input"],
    output_names=["pool_output"]
)
print("特征提取器已生成")

结果对比与风险:裁剪后的模型参数量从约11.7M减少到约11.2M(减少了FC层的参数),推理速度提升约15%。风险在于:如果后续有节点引用了被删除的FC层的输出,则裁剪会失败。在本案例中,FC层是末端节点,因此安全。

风险提示:使用extract_model时,如果指定的输出张量是某个节点的中间输出,且该节点有多个输出分支,工具会保留所有通向该输出的路径,但可能意外保留一些不需要的分支。务必在裁剪后使用onnx.checker.check_model()验证模型完整性。

2.6 常见误区与总结复盘

误区一:混淆张量名称与节点名称。张量名称是数据流的标识,节点名称是算子实例的标识。裁剪时,我们操作的是张量名称(通过node.input和node.output),而非节点名称。如果错误地通过节点名称查找张量,可能导致找不到目标。

误区二:忽略Initializer的清理。删除节点后,其对应的权重张量(Initializer)仍然残留在graph.initializer列表中。虽然这不影响推理正确性,但会增加模型文件体积。裁剪后应遍历graph.initializer,移除未被任何节点引用的权重。

误区三:输出形状未更新。修改graph.output后,如果新输出的形状与原输出不一致,且未更新ValueInfoProto,某些推理引擎(如ONNX Runtime)会因形状推断失败而报错。