3. 模型剪枝入门:结构化剪枝与非结构化剪枝、剪枝策略(L1/L2范数)、剪枝后的微调技巧
模型剪枝,说白了就是给神经网络「瘦身」。
你想想看,一个训练好的大模型,里面其实有很多参数是冗余的。就像一棵大树,有些枝叶其实没什么用,剪掉反而能让主干长得更好。我在做嵌入式部署的时候,经常遇到模型太大塞不进芯片的情况。这时候,剪枝就是我的第一选择。
3.1 非结构化剪枝:精细到每个参数
非结构化剪枝,是对单个权重下手。它会找出那些「不重要」的权重,直接把它们设成0。
怎么判断一个权重重不重要?最常用的方法就是看它的绝对值大小。绝对值小的权重,对最终结果影响也小,剪掉它影响不大。
举个例子:
import torch
import torch.nn.utils.prune as prune
model = torch.nn.Linear(10, 5)
# 对权重进行L1范数剪枝,剪掉20%的权重
prune.l1_unstructured(model, name='weight', amount=0.2)
print(model.weight)
# 你会看到,有20%的权重变成了0
这种方法的优点是精度损失小。缺点也很明显——剪完后的权重矩阵变得稀疏,普通硬件没法加速。你得用专门的稀疏矩阵库才能提速。
核心要点:非结构化剪枝适合追求极致压缩率的场景,但部署时需要考虑硬件支持。
3.2 结构化剪枝:整块整块地剪
结构化剪枝就不一样了。它不剪单个权重,而是剪整个通道、整个卷积核,甚至整个层。
我习惯用L2范数来评估一个通道的重要性。为什么?因为L2范数能反映一个通道的整体能量。能量低的通道,贡献也小。
看代码:
def channel_prune(conv_layer, prune_ratio):
# 计算每个输出通道的L2范数
l2_norm = torch.norm(conv_layer.weight.data, p=2, dim=(1, 2, 3))
# 找到要剪掉的通道索引
num_channels = l2_norm.size(0)
num_prune = int(num_channels * prune_ratio)
_, indices = torch.topk(l2_norm, num_prune, largest=False)
# 创建一个掩码,保留重要通道
mask = torch.ones(num_channels, dtype=torch.bool)
mask[indices] = False
# 只保留重要通道
conv_layer.weight.data = conv_layer.weight.data[mask]
conv_layer.bias.data = conv_layer.bias.data[mask]
return conv_layer, mask
结构化剪枝的好处是,剪完后的模型还是「规整」的。普通硬件可以直接加速,不需要特殊支持。
我的经验:在移动端部署时,我优先用结构化剪枝。虽然压缩率不如非结构化,但实际推理速度快得多。
3.3 剪枝策略:L1范数 vs L2范数
到底用L1还是L2?这个问题我纠结过很久。
简单说:
- L1范数:关注权重的绝对值之和。它倾向于产生稀疏解,很多权重会变成0。
- L2范数:关注权重的平方和。它更关注权重的整体能量分布。
我做过对比实验:
| 剪枝策略 | 适用场景 | 精度保留 | 稀疏度 |
|---|---|---|---|
| L1范数 | 非结构化剪枝 | 较好 | 高 |
| L2范数 | 结构化剪枝 | 更好 | 中等 |
为什么会这样?因为L1范数对每个权重「一视同仁」,容易把很多小权重直接清零。而L2范数会放大那些大权重的影响,更适合评估一个通道的整体重要性。
避坑指南:我曾经在结构化剪枝时用了L1范数,结果剪完精度掉了5个点。后来换成L2范数,只掉了1个点。记住:结构化剪枝用L2,非结构化剪枝用L1。
3.4 剪枝后的微调技巧
剪完枝,模型精度肯定会掉一点。这时候就需要微调。
微调不是简单地重新训练。我有几个心得:
- 学习率要小:我一般用原学习率的十分之一。太大了容易破坏剩下的权重。
- 先冻结再解冻:先冻结剪枝后的结构,只训练分类头。等分类头稳定了,再解冻整个网络。
- 渐进式剪枝:不要一次剪太多。我习惯每次剪10%,然后微调几个epoch,再继续剪。
看一个完整的微调流程:
def iterative_pruning(model, train_loader, prune_ratio, epochs):
# 渐进式剪枝
for prune_step in range(5):
# 剪枝10%
model = prune_model(model, prune_ratio=0.1)
# 微调
optimizer = torch.optim.SGD(model.parameters(), lr=0.001)
for epoch in range(epochs):
for data, target in train_loader:
optimizer.zero_grad()
output = model(data)
loss = F.cross_entropy(output, target)
loss.backward()
optimizer.step()
print(f"第{prune_step+1}轮剪枝完成,当前稀疏度:{compute_sparsity(model)}")
return model
嗯,这里要注意:微调的时候,那些被剪掉的权重不要恢复。我见过有人微调时忘了加掩码,结果剪掉的权重又长回来了,白忙一场。
总结一下:剪枝不是一锤子买卖。它是个「剪一点、调一点」的迭代过程。我做过一个项目,从90%的稀疏度开始,每轮剪5%,微调50个epoch,最后精度只掉了0.3%,模型大小却缩小了10倍。
说白了,剪枝的艺术就在于找到那个「精度和速度的平衡点」。剪多了,精度崩了;剪少了,速度没提升。这个度,得靠实验来摸索。