ONNX模型基础:ONNX格式、计算图结构、节点与张量、输入输出定义
在开始模型裁剪之前,我们必须先建立对ONNX模型底层结构的清晰认知。ONNX(Open Neural Network Exchange)本质上是一种序列化的计算图描述协议,它将深度学习模型从训练框架(如PyTorch、TensorFlow)中解耦,形成一种框架无关的中间表示。裁剪操作,本质上是对这张计算图进行"删减"和"整理"——删除不再需要的计算节点,调整模型的输入输出接口。如果对计算图的结构理解不透彻,裁剪操作极易破坏模型的拓扑完整性,导致推理引擎加载失败或产生错误结果。本章将系统拆解ONNX格式的四个核心维度:格式封装、计算图结构、节点与张量、输入输出定义,为后续的裁剪实操打下坚实的理论基础。
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),确认它们只服务于分类输出,不参与其他分支。
实际操作步骤:
- 使用
onnx.utils.extract_model()函数,指定输入张量名称为"input",输出张量名称改为"pool_output"。 - 该函数会自动遍历计算图,保留从输入到
"pool_output"的所有必要节点,删除FC和Softmax节点。 - 重新保存裁剪后的模型为
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)会因形状推断失败而报错。