3. 模型轻量化技术(上):模型剪枝原理与实现
模型剪枝,说白了就是给神经网络“瘦身”。
你想想看,一个训练好的模型,里面很多参数其实都是冗余的。就像一棵树,有些枝叶不结果,剪掉反而能让主干长得更好。我刚开始做端侧部署时,第一个遇到的坎就是模型太大,MCU根本塞不下。后来发现,剪枝是最直接、最有效的轻量化手段之一。
3.1 剪枝的核心思想
剪枝的本质,是移除模型中不重要的权重或神经元。判断“重要性”的标准有很多,最常见的是看权重绝对值的大小。绝对值小的权重,对最终输出的贡献就小,剪掉它们对精度影响也最小。
我在项目中遇到过这样一个情况:一个3MB的模型,剪掉60%的参数后,精度只掉了0.5%。但模型大小直接缩到了1.2MB,推理速度也快了近一倍。嗯,这种性价比,谁用谁知道。
核心原则:剪枝不是乱剪,而是要在模型大小、推理速度和精度之间找到平衡点。剪枝后的模型通常需要微调(Fine-tuning)来恢复精度。
3.2 结构化剪枝 vs 非结构化剪枝
这是剪枝领域最基础、也最重要的分类。我建议你先把这两个概念吃透,后面用工具时才不会懵。
| 对比维度 | 结构化剪枝 | 非结构化剪枝 |
|---|---|---|
| 剪枝粒度 | 以通道(Channel)、卷积核(Filter)或层(Layer)为单位 | 以单个权重(Weight)为单位 |
| 硬件加速 | 直接支持,无需特殊硬件 | 需要稀疏计算库或专用硬件 |
| 压缩效果 | 模型体积减小,推理速度明显提升 | 模型体积减小,但推理速度可能不变 |
| 精度损失 | 相对较大,需要仔细调参 | 相对较小,保留更多细节 |
| 部署难度 | 低,直接导出即可 | 高,需要稀疏矩阵支持 |
举个例子。结构化剪枝就像把一整块木板锯掉一截,尺寸变了,但结构完整。非结构化剪枝则像在木板上打孔,孔多了木板变轻,但形状没变。
我个人习惯在MCU部署时优先用结构化剪枝。为什么?因为Cortex-M系列内核没有稀疏矩阵加速指令,非结构化剪枝带来的稀疏权重,反而会让内存访问变得碎片化,速度不升反降。
避坑指南:我曾经在一个项目里用了80%的非结构化剪枝,模型文件从4MB压缩到800KB,心里美滋滋。结果一上STM32F4跑,推理时间从200ms变成了350ms。后来一查,是稀疏权重导致的内存随机访问拖了后腿。从此以后,MCU部署我只用结构化剪枝。
3.3 剪枝的完整流程
一个标准的剪枝流程,通常包含以下几步。我把它画成了流程图,方便你理解。
3.4 基于TensorFlow的剪枝工具
TensorFlow提供了tensorflow-model-optimization库,里面集成了剪枝API。我个人觉得它的设计很清晰,适合快速上手。
下面是一个结构化剪枝的示例。注意,这里用的是多项式衰减调度策略,剪枝率从0逐步增加到目标值。
import tensorflow as tf
from tensorflow_model_optimization.sparsity import keras as sparsity
# 定义剪枝参数
pruning_params = {
'pruning_schedule': sparsity.PolynomialDecay(
initial_sparsity=0.0, # 初始剪枝率
final_sparsity=0.5, # 目标剪枝率(剪掉50%)
begin_step=0,
end_step=1000,
frequency=100
)
}
# 对模型应用剪枝
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(32, 3, activation='relu', input_shape=(32, 32, 3)),
tf.keras.layers.MaxPooling2D(),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(10, activation='softmax')
])
pruned_model = sparsity.prune_low_magnitude(model, **pruning_params)
# 编译并训练
pruned_model.compile(
optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy']
)
# 训练时需要使用回调
callbacks = [
sparsity.UpdatePruningStep(),
sparsity.PruningSummaries(log_dir='./logs')
]
pruned_model.fit(x_train, y_train, epochs=5, callbacks=callbacks)
# 导出剪枝后的模型
final_model = sparsity.strip_pruning(pruned_model)
final_model.save('pruned_model.h5')
小技巧:strip_pruning()这一步千万别忘。它会把剪枝相关的包装层去掉,只保留真正的权重。我见过有人忘了这步,导出的模型反而比原来还大。
3.5 基于PyTorch的剪枝工具
PyTorch的torch.nn.utils.prune模块提供了更灵活的剪枝方式。它支持全局剪枝和局部剪枝,你可以精确控制每一层的剪枝率。
下面是一个非结构化剪枝的例子。不过,我建议你在MCU部署时,把它改成结构化剪枝。
import torch
import torch.nn.utils.prune as prune
import torch.nn as nn
# 定义一个简单模型
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 32, 3)
self.fc = nn.Linear(32 * 30 * 30, 10)
def forward(self, x):
x = self.conv1(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
return x
model = SimpleCNN()
# 对conv1层进行非结构化剪枝(剪掉30%的权重)
prune.l1_unstructured(model.conv1, name='weight', amount=0.3)
# 对fc层进行结构化剪枝(剪掉20%的神经元)
prune.ln_structured(model.fc, name='weight', amount=0.2, n=2, dim=0)
# 查看剪枝后的权重掩码
print(model.conv1.weight_mask)
# 永久化剪枝(移除掩码,只保留剪枝后的权重)
prune.remove(model.conv1, 'weight')
prune.remove(model.fc, 'weight')
注意:PyTorch的剪枝默认是非永久性的。剪枝后的权重会保留一个掩码(mask),实际计算时掩码会把剪掉的权重置零。但模型文件里仍然保存着这些零值,文件大小不会减少。必须调用prune.remove()才能真正释放空间。
3.6 剪枝率的选择策略
剪枝率不是越高越好。我总结了一个经验表格,你可以作为参考起点。
| 应用场景 | 推荐剪枝率 | 预期精度损失 | 备注 |
|---|---|---|---|
| 简单分类(CIFAR-10) | 50% - 70% | < 1% | 模型冗余度高,可大胆剪 |
| 目标检测(Tiny YOLO) | 30% - 50% | 1% - 3% | 检测任务对细节敏感 |
| 语义分割 | 20% - 40% | 2% - 5% | 逐像素任务,谨慎剪枝 |
| MCU端侧部署 | 40% - 60% | 1% - 2% | 结合量化效果更佳 |
我个人习惯从30%的剪枝率开始试。如果精度损失在可接受范围内,再逐步增加。每次增加10%,直到精度出现明显下降,然后回退到上一个值。
3.7 剪枝后的模型验证
剪枝完成后,别急着部署。先做三件事:
- 精度验证:在验证集上跑一遍,确认精度损失在预期范围内。
- 模型大小检查:对比剪枝前后的模型文件大小,确认压缩效果。
- 推理速度测试:在目标硬件上跑一次推理,确认速度提升。
我的经验:有一次剪枝后精度只掉了0.3%,我直接部署了。结果在MCU上跑,发现推理速度只提升了10%。后来一查,是剪枝后的模型虽然参数少了,但内存访问模式没优化,导致缓存命中率下降。所以,速度测试一定要在目标硬件上做,别只看参数数量。