4、向量级剪枝:对卷积核的通道进行剪枝,减少计算量
好,咱们接着聊剪枝。前面讲了非结构化剪枝,说白了就是把单个权重干掉。但那种方式有个问题——稀疏矩阵在硬件上跑起来并不友好。你想想看,GPU和NPU都是为密集计算设计的,你突然给它一堆零,它反而要花额外时间去跳过这些零。
所以,向量级剪枝就来了。它的思路很直接:我不剪单个权重,我直接剪整个通道。这样一来,模型结构还是规整的,硬件跑起来也舒服。
什么是向量级剪枝?
向量级剪枝,也叫通道剪枝。它针对的是卷积核的某个通道。一个卷积层有多个卷积核,每个卷积核又有多个通道。我们评估每个通道的重要性,然后把不重要的通道整个砍掉。
举个例子。假设你有一个卷积层,输入是64个通道,输出是128个通道。那中间就有128个卷积核,每个卷积核有64个通道。通道剪枝就是:从64个输入通道里挑出一些没用的,直接删掉。删完之后,输入通道变成比如48个,那对应的卷积核通道数也变成48。计算量直接减少。
核心思想:评估每个通道对最终输出的贡献度,保留重要的,剪掉不重要的。
通道重要性的评估方法
怎么判断一个通道重不重要?我做过几种尝试,这里分享几个实用的方法。
1. 基于权重的范数
最简单的方法。每个通道对应一组权重,我们计算这组权重的L1或L2范数。范数小的,说明这个通道的权重值普遍很小,对输出的影响也小。
import torch
import torch.nn as nn
def channel_l1_norm(conv_layer):
"""
计算卷积层每个通道的L1范数
conv_layer: nn.Conv2d
"""
# conv_layer.weight.shape: [out_channels, in_channels, k_h, k_w]
# 对每个输入通道计算L1范数
channel_norms = torch.sum(torch.abs(conv_layer.weight), dim=(0, 2, 3))
return channel_norms
# 使用示例
conv = nn.Conv2d(64, 128, kernel_size=3)
norms = channel_l1_norm(conv)
# 按范数排序,保留前80%的通道
threshold = torch.quantile(norms, 0.2)
keep_mask = norms > threshold
print(f"保留通道数: {keep_mask.sum().item()} / 64")
我在项目中用过这个方法,效果还不错。但要注意一点:范数小不代表一定没用。有时候某个通道的权重虽然小,但它激活后的特征图对后续层很重要。所以这个方法适合做快速粗剪。
2. 基于BN层的缩放因子
这个方法我个人比较喜欢。如果你模型里用了Batch Normalization,那BN层有个gamma参数,它控制着每个通道的缩放比例。gamma值小的通道,说明这个通道被抑制了,剪掉它影响不大。
def prune_by_bn_gamma(model, prune_ratio=0.3):
"""
根据BN层的gamma值进行通道剪枝
"""
gamma_list = []
for name, module in model.named_modules():
if isinstance(module, nn.BatchNorm2d):
gamma_list.append(module.weight.data.abs().clone())
# 合并所有gamma值
all_gamma = torch.cat(gamma_list)
threshold = torch.quantile(all_gamma, prune_ratio)
# 标记要保留的通道
prune_masks = {}
for name, module in model.named_modules():
if isinstance(module, nn.BatchNorm2d):
mask = module.weight.data.abs() > threshold
prune_masks[name] = mask
return prune_masks
我的经验:用BN gamma剪枝时,剪枝比例不要一次设太大。我一般先剪10%-20%,然后微调一下,再继续剪。这样模型恢复得比较好。
3. 基于特征图激活值
这个方法需要跑一些数据。你把一批数据送进模型,记录每个通道输出的特征图。然后计算每个特征图的平均激活值。激活值一直很小的通道,说明它对输入不敏感,可以剪掉。
def activation_based_pruning(model, dataloader, layer_name, prune_ratio=0.2):
"""
基于激活值进行通道剪枝
"""
activations = []
# 注册hook来获取中间层输出
def hook_fn(module, input, output):
# output.shape: [batch, channels, h, w]
# 计算每个通道的平均激活值
channel_means = output.mean(dim=(0, 2, 3))
activations.append(channel_means)
# 找到目标层并注册hook
layer = dict(model.named_modules())[layer_name]
handle = layer.register_forward_hook(hook_fn)
# 跑一批数据
model.eval()
with torch.no_grad():
for inputs, _ in dataloader:
model(inputs)
break
handle.remove()
# 计算平均激活值
avg_activations = torch.stack(activations).mean(dim=0)
threshold = torch.quantile(avg_activations, prune_ratio)
keep_mask = avg_activations > threshold
return keep_mask
剪枝后的模型结构调整
剪完通道之后,模型结构变了。你得手动调整一下。具体来说:
- 当前层:输入通道数不变,输出通道数减少(因为剪掉了部分输出通道)
- 下一层:输入通道数要跟着减少(因为上一层的输出通道变少了)
这个调整过程比较繁琐。我建议写个自动化脚本,或者直接用一些剪枝框架,比如torch-pruning、NNI这些。
避坑指南:我曾经在调整模型结构时,忘记更新下一层的输入通道数,结果模型跑起来直接报shape不匹配的错误。排查了半天才发现是这里的问题。所以,剪枝后一定要检查相邻层的通道数是否对齐。
剪枝后的微调
通道剪枝之后,模型精度肯定会掉一些。这时候需要微调。微调的策略我一般这样:
- 先用小学习率:剪枝后的模型参数是原模型的一部分,不需要从头学。学习率设为原来的0.1倍就行。
- 微调轮数不用太多:一般5-10个epoch就够了。太多反而容易过拟合。
- 先剪枝再微调,还是边剪边调?我个人习惯先剪枝再微调。边剪边调虽然理论上更好,但实现起来复杂,而且训练时间会翻倍。
实际效果
拿我做过的一个项目来说。一个ResNet-50模型,我用BN gamma方法剪掉了30%的通道。结果:
| 指标 | 剪枝前 | 剪枝后(未微调) | 剪枝后(微调5轮) |
|---|---|---|---|
| 参数量 | 25.6M | 17.9M | 17.9M |
| 计算量(FLOPs) | 4.1G | 2.9G | 2.9G |
| Top-1精度 | 76.1% | 73.8% | 75.6% |
你看,剪掉30%的通道,计算量减少了近30%,但精度只掉了0.5%。这个性价比还是很高的。
总结一下
向量级剪枝,说白了就是砍掉不重要的通道。它比非结构化剪枝更实用,因为剪完后的模型结构规整,硬件加速效果好。评估通道重要性的方法有很多,我个人推荐BN gamma方法,简单有效。剪枝后别忘了微调,不然精度掉得厉害。
嗯,下一章咱们聊聊结构化剪枝的进阶玩法——层剪枝。到时候我会讲讲怎么判断哪些层可以整个删掉。