第4章:模型转换与优化:从PyTorch/TensorFlow导出ONNX、FP32转FP16/INT8量化、模型剪枝与蒸馏基础
说实话,模型训练出来只是第一步。真正要把模型塞进手机、摄像头或者边缘盒子,你会发现——嗯,事情没那么简单。
我最早做嵌入式AI的时候,踩过一个坑。在服务器上跑得飞快的ResNet,转到树莓派上,推理一次要3秒。这谁受得了?后来才明白,模型转换和优化,才是端侧部署的真正门槛。
这一章,我们就来聊聊这个核心话题。我会把我在项目中积累的经验,尽量说透。
4.1 从PyTorch/TensorFlow导出ONNX
ONNX是什么?说白了,它是一个中间格式。就像你写了一份中文文档,要翻译成英文、日文、法文。ONNX就是那个“中间语言”。
我个人习惯,不管用什么框架训练,最后都转成ONNX。为什么?因为几乎所有端侧推理引擎都支持它。你想想看,如果每个框架都写一套转换代码,那得多累。
4.1.1 PyTorch导出ONNX
PyTorch导出ONNX其实很简单。核心就一个函数:torch.onnx.export()。但有几个坑,我当年可是一个个踩过来的。
import torch
import torchvision.models as models
# 加载一个预训练模型
model = models.resnet18(pretrained=True)
model.eval()
# 创建一个 dummy 输入
dummy_input = torch.randn(1, 3, 224, 224)
# 导出 ONNX
torch.onnx.export(
model, # 模型
dummy_input, # 输入张量
"resnet18.onnx", # 输出文件名
export_params=True, # 是否导出参数
opset_version=11, # ONNX算子集版本
do_constant_folding=True, # 常量折叠优化
input_names=['input'],
output_names=['output'],
dynamic_axes={
'input': {0: 'batch_size'},
'output': {0: 'batch_size'}
}
)
这里有个关键点:opset_version。我建议用11或以上。版本太低,有些算子不支持。版本太高,有些推理引擎又不认识。11是个比较稳妥的选择。
model.eval()。我见过有人忘了这步,结果导出的模型里还带着Dropout和BatchNorm的训练行为,推理结果完全不对。
4.1.2 TensorFlow导出ONNX
TensorFlow这边,我一般用 tf2onnx 这个工具。它支持SavedModel和Keras模型两种格式。
import tensorflow as tf
import tf2onnx
# 加载Keras模型
model = tf.keras.applications.MobileNetV2(weights='imagenet')
# 指定输入签名
spec = (tf.TensorSpec((None, 224, 224, 3), tf.float32, name="input"),)
# 转换
output_path = model.output_names[0]
model_proto, _ = tf2onnx.convert.from_keras(
model,
input_signature=spec,
opset=13,
output_path="mobilenetv2.onnx"
)
我曾经遇到过一个情况:TensorFlow模型里用了自定义层,结果转ONNX时报错。后来发现,需要先注册自定义算子。这个坑,建议大家提前检查模型里有没有非标准操作。
4.1.3 ONNX模型验证
导出之后,一定要验证。别问我为什么强调这个——我吃过亏。
import onnx
import onnxruntime as ort
# 检查模型结构
onnx_model = onnx.load("resnet18.onnx")
onnx.checker.check_model(onnx_model)
print("模型结构检查通过")
# 用ONNX Runtime跑一下推理
session = ort.InferenceSession("resnet18.onnx")
input_name = session.get_inputs()[0].name
output_name = session.get_outputs()[0].name
import numpy as np
dummy = np.random.randn(1, 3, 224, 224).astype(np.float32)
result = session.run([output_name], {input_name: dummy})
print("推理结果形状:", result[0].shape)
onnx.shape_inference.infer_shapes() 来检查张量形状是否合理。如果某个中间层的形状是负数,说明模型可能有问题。
4.2 FP32转FP16/INT8量化
模型转成ONNX之后,下一步就是量化。为什么要量化?说白了,就是让模型变小、跑得更快。
FP32的模型,一个参数占4字节。FP16只占2字节,INT8只占1字节。你想想看,模型大小直接减半甚至减到四分之一。在嵌入式设备上,这差别太大了。
4.2.1 FP16量化
FP16量化是最简单的。几乎不需要额外数据,直接转就行。精度损失也很小,通常不到1%。
import onnx
from onnxconverter_common import float16
# 加载FP32模型
model_fp32 = onnx.load("resnet18.onnx")
# 转FP16
model_fp16 = float16.convert_float_to_float16(model_fp32)
# 保存
onnx.save(model_fp16, "resnet18_fp16.onnx")
我个人习惯,对于视觉模型,FP16基本够用。但如果是语音或某些对精度极其敏感的模型,建议先做对比测试。
4.2.2 INT8量化
INT8量化就复杂多了。它需要校准数据,而且精度损失可能更大。但收益也更大——模型大小只有FP32的四分之一。
INT8量化有两种方式:
- 训练后量化(PTQ):训练完模型后,用少量校准数据做量化。简单快速,但精度可能下降。
- 量化感知训练(QAT):在训练过程中模拟量化效果。精度更高,但需要重新训练。
这里我重点讲PTQ,因为它在实际项目里用得最多。
import onnx
from onnxruntime.quantization import quantize_dynamic, QuantType
# 动态量化
quantized_model = quantize_dynamic(
"resnet18.onnx",
"resnet18_int8.onnx",
weight_type=QuantType.QInt8
)
等等,上面这个只是权重量化。如果你想做全量化(权重+激活),需要准备校准数据:
from onnxruntime.quantization import quantize_static, CalibrationMethod
from onnxruntime.quantization import CalibrationDataReader
class MyCalibrationDataReader(CalibrationDataReader):
def __init__(self):
self.data = [np.random.randn(1, 3, 224, 224).astype(np.float32) for _ in range(100)]
self.index = 0
def get_next(self):
if self.index < len(self.data):
result = {"input": self.data[self.index]}
self.index += 1
return result
return None
# 静态量化
quantize_static(
"resnet18.onnx",
"resnet18_int8_static.onnx",
calibration_data_reader=MyCalibrationDataReader(),
calibration_method=CalibrationMethod.MinMax
)
4.3 模型剪枝与蒸馏基础
量化和剪枝、蒸馏,这三兄弟经常一起用。量化是“压缩数值”,剪枝是“砍掉冗余”,蒸馏是“以大教小”。
4.3.1 模型剪枝
剪枝,说白了就是把不重要的连接或通道删掉。我最早接触剪枝时,觉得这玩意儿很玄学。后来发现,其实原理很简单——那些权重接近0的参数,删了也没太大影响。
剪枝分两种:
- 非结构化剪枝:把单个权重置为0。稀疏度高,但硬件加速难。
- 结构化剪枝:直接删掉整个通道或卷积核。硬件友好,但精度损失可能更大。
PyTorch里做剪枝很方便:
import torch
import torch.nn.utils.prune as prune
# 对卷积层做L1非结构化剪枝
model = models.resnet18(pretrained=True)
prune.l1_unstructured(
model.conv1,
name='weight',
amount=0.3 # 剪掉30%的权重
)
# 移除剪枝掩码,永久生效
prune.remove(model.conv1, 'weight')
结构化剪枝稍微复杂一点:
# 对卷积层做L1结构化剪枝(按通道)
prune.ln_structured(
model.layer1[0].conv1,
name='weight',
amount=0.2, # 剪掉20%的通道
n=2, # L2范数
dim=0 # 按输出通道剪
)
4.3.2 知识蒸馏
知识蒸馏,就是用一个大的教师模型,去教一个小学生模型。教师模型输出的是“软标签”——不仅仅是类别,还有各类别的概率分布。
蒸馏的核心公式:
# 蒸馏损失 = 蒸馏损失权重 * KL散度(学生输出, 教师输出) + (1-蒸馏权重) * 交叉熵(学生输出, 真实标签)
def distillation_loss(student_logits, teacher_logits, labels, T=4.0, alpha=0.7):
"""
T: 温度参数,越大软标签越平滑
alpha: 蒸馏损失权重
"""
# 软标签损失
soft_loss = nn.KLDivLoss()(
F.log_softmax(student_logits / T, dim=1),
F.softmax(teacher_logits / T, dim=1)
) * (T * T)
# 硬标签损失
hard_loss = nn.CrossEntropyLoss()(student_logits, labels)
return alpha * soft_loss + (1 - alpha) * hard_loss
我做过一个实验:用ResNet-50当教师,教一个MobileNet-V2学生。蒸馏后,MobileNet的准确率从72%提升到了76%。虽然还是比不上教师(78%),但模型大小只有教师的十分之一。
4.4 综合优化流程
在实际项目中,我一般按这个流程走:
- 训练FP32模型,确保基线精度达标
- 导出ONNX,验证结构正确
- 尝试FP16量化,如果精度损失可接受,直接用
- 如果FP16不够,做剪枝+微调,再转INT8
- 如果INT8精度不够,用蒸馏训练一个小模型,再量化
这个流程,我用了很多项目,基本都能搞定。当然,具体问题要具体分析。比如有些模型对精度极其敏感,那就只能牺牲一些速度了。
最后说一句:模型优化没有银弹。量化、剪枝、蒸馏,每个方法都有适用场景。关键是多试、多测、多对比。我在项目中养成的习惯是——每次优化后,都跑一遍完整的验证集。别偷懒,这个步骤省不得。
下一章,我们会聊推理引擎的运行时架构。到时候,我会讲讲内存管理和算子调度那些事。嗯,那又是另一番天地了。