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不匹配的错误。排查了半天才发现是这里的问题。所以,剪枝后一定要检查相邻层的通道数是否对齐

剪枝后的微调

通道剪枝之后,模型精度肯定会掉一些。这时候需要微调。微调的策略我一般这样:

  1. 先用小学习率:剪枝后的模型参数是原模型的一部分,不需要从头学。学习率设为原来的0.1倍就行。
  2. 微调轮数不用太多:一般5-10个epoch就够了。太多反而容易过拟合。
  3. 先剪枝再微调,还是边剪边调?我个人习惯先剪枝再微调。边剪边调虽然理论上更好,但实现起来复杂,而且训练时间会翻倍。

实际效果

拿我做过的一个项目来说。一个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方法,简单有效。剪枝后别忘了微调,不然精度掉得厉害。

嗯,下一章咱们聊聊结构化剪枝的进阶玩法——层剪枝。到时候我会讲讲怎么判断哪些层可以整个删掉。