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 值的剪枝。