4. 结构化剪枝:通道剪枝与滤波器剪枝
好,咱们接着聊剪枝。前面讲的是非结构化剪枝,说白了就是把不重要的单个权重干掉。但这种方式有个硬伤——稀疏矩阵在硬件上跑起来并不快,除非你有专门的硬件支持。所以,工业界更常用的是结构化剪枝。
结构化剪枝,顾名思义,是以整个结构为单位进行裁剪。比如一个卷积层有 64 个滤波器(filter),我直接砍掉 16 个,剩下的 48 个继续工作。这样一来,模型结构就变小了,推理速度直接提升,不需要任何特殊硬件。
我个人习惯把结构化剪枝分成两类:通道剪枝和滤波器剪枝。其实它们本质是一回事,只是视角不同。
4.1 通道剪枝 vs 滤波器剪枝
咱们先看一个典型的卷积层。输入是一个 3 通道的图片,经过 64 个 3x3 的滤波器,输出 64 个通道的特征图。
- 滤波器剪枝:直接删除某个滤波器。比如删掉第 10 个滤波器,那输出特征图就少了一个通道。同时,下一层对应这个通道的输入也被删掉了。
- 通道剪枝:删除输出特征图的某个通道。效果和滤波器剪枝完全一样——你删掉一个输出通道,等价于删掉了产生它的那个滤波器。
所以,这两个术语经常混用。你只要记住:剪掉一个滤波器 = 剪掉一个输出通道。
核心思想:通过评估每个滤波器的重要性,删除不重要的滤波器,从而压缩模型。
4.2 L1 范数剪枝策略
那问题来了:怎么判断一个滤波器重不重要?
最直观的方法就是看它的权重绝对值之和,也就是 L1 范数。为什么?
你想想看,一个滤波器的权重如果都很小,那它对输出特征图的贡献就很小。删掉它,对最终结果影响不大。反之,如果某个滤波器的权重都很大,那它提取的特征就很关键,不能随便删。
我在项目中遇到过这样一个情况:有个模型剪枝后精度掉得特别厉害,后来一查,发现有个滤波器虽然 L1 范数很小,但它负责检测的是边缘信息,对后续层很重要。所以,L1 范数只是一个启发式指标,不是万能的。
小技巧:L1 范数剪枝适合作为第一轮剪枝策略,简单高效。后续可以结合其他指标(比如 BN 层的 gamma 值)做精细化剪枝。
4.3 PyTorch 实现通道剪枝
好了,理论说完了,咱们直接上代码。我用 PyTorch 实现一个简单的通道剪枝流程。
假设我们有一个训练好的 VGG-16 模型,要对它的卷积层进行剪枝。
4.3.1 计算每个滤波器的 L1 范数
import torch
import torch.nn as nn
def compute_l1_norm(layer):
"""
计算卷积层每个滤波器的 L1 范数
layer: nn.Conv2d
"""
weight = layer.weight.data # shape: [out_channels, in_channels, k_h, k_w]
l1_norm = torch.sum(torch.abs(weight), dim=(1, 2, 3))
return l1_norm
# 示例:取第一个卷积层
conv1 = model.features[0] # 假设 model 是 VGG
norms = compute_l1_norm(conv1)
print("各滤波器 L1 范数:", norms)
4.3.2 按比例剪枝
假设我们要剪掉 30% 的滤波器。那就按 L1 范数排序,把最小的 30% 干掉。
def prune_conv_layer(layer, prune_ratio=0.3):
"""
对卷积层进行通道剪枝
"""
norms = compute_l1_norm(layer)
num_filters = norms.size(0)
num_prune = int(num_filters * prune_ratio)
# 找到要保留的滤波器索引
_, indices = torch.sort(norms)
keep_indices = indices[num_prune:].sort()[0] # 保留的索引,排序后方便后续操作
# 创建新的卷积层
in_channels = layer.in_channels
out_channels = num_filters - num_prune
kernel_size = layer.kernel_size
stride = layer.stride
padding = layer.padding
new_conv = nn.Conv2d(in_channels, out_channels, kernel_size,
stride=stride, padding=padding, bias=layer.bias is not None)
# 复制保留的权重
new_conv.weight.data = layer.weight.data[keep_indices]
if layer.bias is not None:
new_conv.bias.data = layer.bias.data[keep_indices]
return new_conv, keep_indices
注意:剪枝后,下一层的输入通道数变了。你需要同步更新下一层的权重。比如下一层原来是 64 输入通道,现在只剩 48 个,那下一层的权重也要对应删掉 16 个输入通道。
4.3.3 处理层间依赖
这是最容易踩坑的地方。我曾经因为没处理好层间依赖,剪完枝后模型直接崩了。
def prune_model(model, prune_ratio=0.3):
"""
对整个模型进行剪枝,处理层间依赖
"""
pruned_model = nn.Sequential()
prev_keep_indices = None
for name, module in model.named_children():
if isinstance(module, nn.Conv2d):
if prev_keep_indices is not None:
# 当前层的输入通道需要根据上一层的剪枝结果调整
# 这里简化处理:假设所有层都是顺序连接的
new_conv, keep_indices = prune_conv_layer(module, prune_ratio)
# 调整输入通道:只保留上一层的保留通道
new_conv.weight.data = new_conv.weight.data[:, prev_keep_indices, :, :]
pruned_model.add_module(name, new_conv)
prev_keep_indices = keep_indices
else:
# 第一层,输入通道不变
new_conv, keep_indices = prune_conv_layer(module, prune_ratio)
pruned_model.add_module(name, new_conv)
prev_keep_indices = keep_indices
elif isinstance(module, nn.ReLU):
pruned_model.add_module(name, nn.ReLU())
elif isinstance(module, nn.MaxPool2d):
pruned_model.add_module(name, nn.MaxPool2d(module.kernel_size,
stride=module.stride))
# 其他层类似处理...
return pruned_model
4.3.4 微调(Fine-tuning)
剪枝完别急着部署。模型精度肯定会掉一点,需要做几轮微调来恢复。
def fine_tune(model, train_loader, epochs=10, lr=0.001):
"""
剪枝后的微调
"""
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9)
for epoch in range(epochs):
model.train()
for inputs, targets in train_loader:
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, targets)
loss.backward()
optimizer.step()
print(f"Epoch {epoch+1}/{epochs}, Loss: {loss.item():.4f}")
return model
我的经验:微调时学习率不要太大,一般是原训练学习率的 1/10 到 1/100。我习惯用 0.001 起步,观察 loss 下降情况再调整。
4.4 剪枝效果评估
剪枝完成后,我们需要评估效果。主要看两个指标:
| 指标 | 说明 | 我的经验值 |
|---|---|---|
| 参数量减少比例 | 剪枝后参数总量 / 原参数总量 | 通常可减少 50%-80% |
| 推理速度提升 | 实际在硬件上跑一遍的耗时对比 | GPU 上提升 1.5x-3x |
| 精度损失 | 剪枝后精度 - 原精度 | 微调后应控制在 1% 以内 |
嗯,这里要注意:参数量减少不等于推理速度提升。因为结构化剪枝后,虽然参数少了,但如果你用的框架没有针对小卷积核做优化,速度提升可能不明显。我建议剪枝后一定要在目标硬件上实际测一下。
4.5 避坑指南
最后,分享几个我踩过的坑:
- 不要一次性剪太多:我刚开始做剪枝时,一口气剪掉 70%,结果精度直接掉到 10%。后来改成渐进式剪枝,每次剪 10%-20%,微调后再剪,效果好很多。
- 注意 shortcut 连接:ResNet 这类有残差连接的网络,剪枝时要保证 shortcut 两边的通道数一致。否则维度对不上,模型直接报错。
- BN 层也要跟着剪:剪掉一个滤波器后,对应的 BN 层的 gamma 和 beta 也要删掉。这个很容易漏掉。
- 保存剪枝后的模型结构:不要只保存权重,还要保存剪枝后的网络结构定义。否则加载模型时对不上。
好了,通道剪枝的内容就这些。说白了,结构化剪枝就是用更少的滤波器,做同样的事。L1 范数是个不错的起点,但实际项目中往往需要结合多种策略。下一章咱们聊聊更高级的剪枝方法——基于 BN 层 gamma 值的剪枝。