3、模型轻量化技术(上):模型剪枝(Pruning)原理
各位同学,咱们今天聊聊模型剪枝。说实话,这是我在嵌入式部署中最常用的招数之一。你想想看,一个动辄几百兆的模型,要塞进只有几兆Flash的MCU里,不剪几刀怎么行?
我记得刚入行那会儿,接手一个视觉检测项目。模型在PC上跑得飞起,一上ARM Cortex-M4,直接内存溢出。当时我就在想——模型里那么多参数,真的每个都那么重要吗?后来发现,还真不是。
3.1 为什么需要模型剪枝?
深度学习模型有个特点:过度参数化。说白了,就是参数太多了,很多是冗余的。有研究表明,一个训练好的模型里,可能有50%-90%的权重可以去掉,而精度几乎不受影响。
剪枝的好处很明显:
- 模型变小——存储空间省了
- 计算变快——推理延迟降了
- 功耗降低——电池续航长了
我在项目中遇到过最极端的情况:一个3MB的人脸识别模型,剪枝后只剩400KB,精度只掉了0.3%。嗯,这种收益,谁用谁知道。
核心思想:剪枝就是去掉不重要的连接或神经元,保留关键信息通路。
3.2 剪枝的基本流程
剪枝不是瞎剪的,一般遵循这个套路:
- 训练一个基准模型——先让模型收敛到不错的状态
- 评估参数重要性——判断哪些权重可以砍掉
- 剪掉不重要的参数——置零或直接移除
- 微调(Fine-tune)——让剩下的参数重新适应
- 迭代——重复2-4步,直到达到目标
这里有个坑:一次性剪太多。我曾经试过一步到位剪掉80%,结果模型直接崩了,精度掉到30%。后来学乖了,每次剪10%-20%,微调后再剪,效果就好多了。
避坑指南:我曾经在剪枝后忘记微调,直接部署到板子上,结果识别率惨不忍睹。记住:剪枝后的微调不是可选项,是必选项。
3.3 结构化剪枝 vs 非结构化剪枝
这是剪枝里最重要的分类。我刚开始也搞混过,咱们捋一捋。
3.3.1 非结构化剪枝(Unstructured Pruning)
非结构化剪枝,说白了就是单个权重级别的剪枝。把绝对值小的权重直接置零。
举个例子:
# 非结构化剪枝示例
import numpy as np
# 假设这是某个卷积层的权重
weights = np.array([
[0.5, -0.02, 0.8, 0.01],
[-0.1, 0.6, -0.03, 0.9],
[0.7, 0.02, -0.5, 0.04]
])
# 设置阈值,剪掉绝对值小于0.05的权重
threshold = 0.05
mask = np.abs(weights) > threshold
pruned_weights = weights * mask
print("原始权重:\n", weights)
print("剪枝后权重:\n", pruned_weights)
输出结果:
原始权重:
[[ 0.5 -0.02 0.8 0.01]
[-0.1 0.6 -0.03 0.9 ]
[ 0.7 0.02 -0.5 0.04]]
剪枝后权重:
[[ 0.5 0. 0.8 0. ]
[-0.1 0.6 0. 0.9 ]
[ 0.7 0. -0.5 0. ]]
看到没?那些小权重直接变零了。但问题来了——权重矩阵变得稀疏了。稀疏矩阵在普通硬件上并不好加速,需要专门的稀疏计算库支持。
我的经验:非结构化剪枝适合在GPU上做推理加速,因为cuSPARSE等库能利用稀疏性。但在MCU或普通CPU上,收益有限。
3.3.2 结构化剪枝(Structured Pruning)
结构化剪枝是通道级别或滤波器级别的剪枝。直接砍掉整个卷积核或整个通道。
我画个图帮你理解:
原始卷积层:64个滤波器 → 输出64个通道
结构化剪枝后:保留32个滤波器 → 输出32个通道
这样做的好处是:模型结构变了,但仍然是密集矩阵。任何硬件都能直接加速,不需要特殊优化。
代码实现思路:
# 结构化剪枝示例(伪代码)
def prune_channels(layer, keep_ratio=0.5):
"""
按通道重要性剪枝
"""
# 计算每个通道的重要性(比如L1范数)
importance = torch.norm(layer.weight, p=1, dim=(1,2,3))
# 排序,保留最重要的通道
num_keep = int(layer.out_channels * keep_ratio)
_, indices = torch.topk(importance, num_keep)
# 只保留这些通道
layer.weight = layer.weight[indices]
layer.bias = layer.bias[indices] if layer.bias is not None else None
return layer
两种剪枝对比:
| 对比项 | 非结构化剪枝 | 结构化剪枝 |
|---|---|---|
| 剪枝粒度 | 单个权重 | 通道/滤波器 |
| 压缩率 | 高(可到90%+) | 中等(通常30%-50%) |
| 硬件加速 | 需要稀疏计算支持 | 通用硬件直接加速 |
| 精度损失 | 较小 | 相对较大 |
| 部署难度 | 高 | 低 |
3.4 实际动手:剪枝一个简单模型
光说不练假把式。咱们用PyTorch动手剪一个简单的全连接网络。
先定义一个三层网络:
import torch
import torch.nn as nn
import torch.nn.utils.prune as prune
class SimpleNet(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(784, 256)
self.fc2 = nn.Linear(256, 128)
self.fc3 = nn.Linear(128, 10)
def forward(self, x):
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
x = self.fc3(x)
return x
model = SimpleNet()
print("原始参数量:", sum(p.numel() for p in model.parameters()))
接下来,对fc1层做非结构化剪枝,剪掉20%的权重:
# 对fc1层应用L1非结构化剪枝
prune.l1_unstructured(model.fc1, name='weight', amount=0.2)
# 查看剪枝效果
print("fc1权重中零的比例:",
float(torch.sum(model.fc1.weight == 0)) / model.fc1.weight.numel())
再试试结构化剪枝,剪掉fc2层30%的通道:
# 对fc2层应用L1结构化剪枝(按神经元)
prune.l1_unstructured(model.fc2, name='weight', amount=0.3)
# 注意:PyTorch内置的prune工具对结构化剪枝支持有限
# 实际项目中我一般自己写剪枝逻辑
注意:PyTorch的prune模块默认是非结构化的。它只是把权重置零,并没有真正减少参数量。要真正减少模型大小,需要配合prune.remove()方法。
正确的做法是:
# 先应用剪枝
prune.l1_unstructured(model.fc1, name='weight', amount=0.2)
# 永久化剪枝(真正移除被剪掉的参数)
prune.remove(model.fc1, 'weight')
# 现在参数量真的减少了
print("剪枝后参数量:", sum(p.numel() for p in model.parameters()))
3.5 剪枝后的微调
剪完枝,模型精度肯定会掉。这时候需要微调。我一般这样做:
# 微调代码片段
optimizer = torch.optim.SGD(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
for epoch in range(10): # 微调10个epoch
for data, target in dataloader:
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
print(f"Epoch {epoch}: Loss = {loss.item():.4f}")
微调的学习率一般设小一点,我习惯用原始训练学习率的十分之一。太大容易破坏已有的权重分布。
小技巧:剪枝后可以先做几个epoch的知识蒸馏,用原始模型当老师,剪枝后的模型当学生。这样精度恢复得更快。我在一个语音识别项目里试过,剪枝50%后蒸馏微调,精度只掉了0.1%。
3.6 实际部署中的注意事项
最后聊几个实战中容易踩的坑:
- 剪枝率不是越高越好——我见过有人追求90%剪枝率,结果模型直接废了。一般从20%开始试,逐步增加。
- 不同层敏感度不同——第一层和最后一层通常最敏感,剪多了精度掉得厉害。中间层可以多剪一些。
- 剪枝后要重新校准BN层——如果模型有BatchNorm,剪枝后统计量会变,需要重新跑一遍前向传播来更新running_mean和running_var。
- 硬件限制——有些NPU要求通道数是8或16的倍数,剪枝时要注意对齐。
好了,这一章咱们把剪枝的原理和两种主要方式都过了一遍。下一章我会继续讲量化(Quantization),那是另一个让模型变轻的利器。到时候咱们再聊怎么把剪枝和量化结合起来,效果1+1>2。
记住一句话:剪枝不是目的,部署才是。一切以最终在目标硬件上的运行效果为准。