4、模型剪枝技术:结构化剪枝与非结构化剪枝、剪枝策略(L1/L2范数)、剪枝后微调
模型剪枝,说白了就是给神经网络「减肥」。
我在嵌入式AI项目里摸爬滚打这么多年,最深的体会就是:模型太大,跑不动。你想想看,一个几百兆的模型,塞到只有几兆内存的MCU里,那不是开玩笑吗?剪枝就是解决这个问题的利器。
4.1 非结构化剪枝:简单粗暴,但有点「野」
非结构化剪枝,我习惯叫它「细粒度剪枝」。它的思路很简单:把权重矩阵里那些绝对值很小的参数直接干掉。
为什么要干掉小参数?因为绝对值小,意味着它对最终输出的贡献也小。干掉它,模型精度损失不大,但模型体积能小不少。
核心思想: 权重绝对值 → 排序 → 干掉排名靠后的
举个例子,一个卷积层有100万个参数,我们设定剪枝率50%。那就把绝对值最小的50万个参数置为0。嗯,就是这么直接。
# 非结构化剪枝示例(PyTorch风格)
import torch
import torch.nn.utils.prune as prune
# 假设我们有一个训练好的卷积层
conv_layer = torch.nn.Conv2d(3, 64, kernel_size=3)
# L1范数非结构化剪枝,剪掉50%的参数
prune.l1_unstructured(conv_layer, name='weight', amount=0.5)
# 看看效果
print(f"原始参数数量: {conv_layer.weight.nelement()}")
print(f"非零参数数量: {torch.sum(conv_layer.weight != 0).item()}")
我在项目中遇到过一个问题:非结构化剪枝后,模型确实变小了,但推理速度没怎么提升。为什么?因为硬件对稀疏矩阵的支持很差。你想想看,CPU和GPU都是为密集矩阵运算优化的,突然来一堆0,反而要额外判断,速度不升反降。
避坑指南: 我曾经在一个Cortex-M7的项目里用非结构化剪枝,模型体积从10MB降到了3MB,但推理时间反而增加了20%。后来查资料才发现,MCU的SIMD指令对稀疏矩阵支持极差。所以,非结构化剪枝更适合存储受限、但计算资源相对充裕的场景。
4.2 结构化剪枝:规整、高效、硬件友好
结构化剪枝就不一样了。它不是单个参数地剪,而是整行、整列、整个通道地剪。说白了,就是直接砍掉一个卷积核,或者砍掉一个全连接层的神经元。
这样做的好处很明显:剪完之后,模型结构还是规整的矩阵,硬件跑起来毫无压力。
| 对比项 | 非结构化剪枝 | 结构化剪枝 |
|---|---|---|
| 剪枝粒度 | 单个参数 | 通道/卷积核/神经元 |
| 硬件加速 | 差(稀疏矩阵) | 好(密集矩阵) |
| 压缩率 | 高(可到90%+) | 中等(通常30%-50%) |
| 精度损失 | 较小 | 较大(需要微调补偿) |
| 部署难度 | 高(需要特殊库支持) | 低(直接删层即可) |
# 结构化剪枝示例:按通道剪枝
# 计算每个通道的L2范数,干掉范数小的通道
def channel_prune(conv_layer, prune_ratio):
# 获取卷积核权重 shape: [out_channels, in_channels, k_h, k_w]
weight = conv_layer.weight.data
# 计算每个输出通道的L2范数
l2_norm = torch.norm(weight.view(weight.size(0), -1), dim=1)
# 找到要保留的通道索引
num_keep = int(weight.size(0) * (1 - prune_ratio))
_, indices = torch.topk(l2_norm, num_keep)
# 只保留这些通道
conv_layer.weight.data = weight[indices]
if conv_layer.bias is not None:
conv_layer.bias.data = conv_layer.bias[indices]
return conv_layer, indices
我个人习惯在嵌入式项目里优先用结构化剪枝。虽然压缩率不如非结构化,但部署起来省心。你想想看,直接删掉几个卷积层,模型结构图都变简单了,推理框架不用做任何特殊处理。
4.3 剪枝策略:L1范数 vs L2范数
剪枝的时候,总得有个标准来判断「谁该被剪掉」吧?最常用的就是L1范数和L2范数。
L1范数剪枝: 计算每个通道(或每个参数)的绝对值之和。L1范数小的,说明这个通道整体激活值偏小,贡献不大,剪掉它。
L2范数剪枝: 计算平方和的平方根。L2范数更关注「能量」分布。如果一个通道的L2范数很小,说明它的权重整体偏小,对输出的影响也小。
我的经验: L1范数剪枝更容易产生稀疏解,剪得更「干净」。L2范数剪枝则更平滑,保留的通道质量更高。我在一个语音唤醒模型上做过对比:L1剪枝后精度掉了2.3%,微调后恢复到0.8%;L2剪枝后精度掉了1.5%,微调后恢复到0.3%。所以,如果对精度要求高,我建议优先试L2。
# L1 vs L2 范数计算对比
def l1_prune_score(weight):
# 对每个输出通道计算L1范数
return torch.sum(torch.abs(weight.view(weight.size(0), -1)), dim=1)
def l2_prune_score(weight):
# 对每个输出通道计算L2范数
return torch.norm(weight.view(weight.size(0), -1), dim=1)
# 实际使用
weight = torch.randn(64, 3, 3, 3) # 64个输出通道
l1_scores = l1_prune_score(weight)
l2_scores = l2_prune_score(weight)
print(f"L1 scores: {l1_scores[:5]}")
print(f"L2 scores: {l2_scores[:5]}")
4.4 剪枝后微调:别指望一刀切
剪枝完直接部署?别急,精度大概率会掉。这时候就需要微调(Fine-tuning)。
微调的原理很简单:剪枝后的模型结构变了,权重也变了,需要重新适应数据分布。通常的做法是,用原始训练数据,以较小的学习率再训练几个epoch。
我建议的微调策略:
- 学习率: 原始学习率的1/10到1/100。剪枝后的模型比较脆弱,学习率大了容易崩。
- 微调轮数: 通常5-10个epoch就够了。太多容易过拟合,太少恢复不过来。
- 逐层微调: 如果剪枝率很高(比如超过50%),我习惯先冻结未剪枝的层,只微调被剪过的层。等这些层稳定了,再全模型微调。
关键点: 剪枝后的微调不是从头训练,而是「修复」。目的是让模型适应新的结构,而不是学习新特征。
# 剪枝后微调示例
def prune_and_finetune(model, train_loader, prune_ratio=0.3, epochs=10):
# 1. 先剪枝
for name, module in model.named_modules():
if isinstance(module, torch.nn.Conv2d):
# 结构化剪枝:按L2范数剪掉30%的通道
module, _ = channel_prune(module, prune_ratio)
# 2. 设置小学习率
optimizer = torch.optim.SGD(model.parameters(), lr=0.001) # 原始可能是0.01
# 3. 微调
for epoch in range(epochs):
for inputs, labels in train_loader:
outputs = model(inputs)
loss = torch.nn.functional.cross_entropy(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}")
return model
我曾经在一个目标检测模型上试过,剪掉40%的通道后,精度从85%掉到了78%。微调了8个epoch后,精度恢复到了84.2%。虽然没完全恢复,但模型体积小了40%,推理速度快了35%,这个trade-off我觉得很值。
注意: 微调不是万能的。如果剪枝率过高(比如超过70%),模型容量已经不够了,再怎么微调也回不去。这时候就要考虑重新设计网络结构,或者用知识蒸馏等其他技术。
总结一下我的剪枝流程:
- 先训练一个基线模型,记录精度
- 用L2范数做结构化剪枝,从10%开始试
- 剪完后用1/10学习率微调5个epoch
- 看精度恢复情况,如果恢复得好,继续加大剪枝率
- 找到精度和速度的平衡点
嗯,这就是我这些年做模型剪枝的实战经验。说白了,剪枝不是目的,让模型在嵌入式设备上跑起来才是。别追求极致的压缩率,够用就好。