3. 基于BN层缩放因子的通道剪枝

基于BN层缩放因子的通道剪枝 学习卡片

任务目标

在边缘设备部署CNN模型时,通道剪枝可显著减少计算量与模型体积。本案例以ResNet-18在CIFAR-10上的分类任务为例,利用BN层缩放因子γ作为通道重要性指标,实现通道级剪枝,并对比不同剪枝比例(30%、50%、70%)对模型参数量与准确率的影响。

核心原理

BN层对每个通道执行 y = γ * (x - μ) / √(σ² + ε) + β,其中γ(缩放因子)的值大小反映了该通道对输出的贡献程度。训练时对γ施加L1稀疏正则,使不重要通道的γ趋近于0,随后按γ值排序剪除低贡献通道,最后微调恢复精度。

环境依赖

torch==2.0.1  torchvision==0.15.1  numpy  tqdm

完整实现代码

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.utils.prune as prune
from torchvision import models, datasets, transforms
from torch.utils.data import DataLoader
import copy

# ---------- 1. 数据加载 ----------
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(),
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])
transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)),
])
trainset = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train)
testset = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test)
trainloader = DataLoader(trainset, batch_size=128, shuffle=True, num_workers=2)
testloader = DataLoader(testset, batch_size=256, shuffle=False, num_workers=2)

# ---------- 2. 模型与稀疏正则训练 ----------
def add_sparsity_regularization(model, lambda_reg=1e-4):
    """对BN层γ施加L1正则"""
    reg_loss = 0.0
    for module in model.modules():
        if isinstance(module, nn.BatchNorm2d):
            reg_loss += torch.abs(module.weight).sum()
    return lambda_reg * reg_loss

def train(model, epochs=5, lr=0.01, lambda_reg=1e-4):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model.to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9, weight_decay=5e-4)
    scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
    for epoch in range(epochs):
        model.train()
        running_loss = 0.0
        for inputs, labels in trainloader:
            inputs, labels = inputs.to(device), labels.to(device)
            optimizer.zero_grad()
            outputs = model(inputs)
            loss = criterion(outputs, labels) + add_sparsity_regularization(model, lambda_reg)
            loss.backward()
            optimizer.step()
            running_loss += loss.item()
        scheduler.step()
        print(f'Epoch {epoch+1}/{epochs}, Loss: {running_loss/len(trainloader):.4f}')

def evaluate(model):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model.to(device)
    model.eval()
    correct = total = 0
    with torch.no_grad():
        for inputs, labels in testloader:
            inputs, labels = inputs.to(device), labels.to(device)
            outputs = model(inputs)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()
    return 100.0 * correct / total

# ---------- 3. 通道剪枝实现 ----------
def prune_channels_by_bn(model, prune_ratio):
    """按BN层γ值剪枝,返回剪枝后的模型"""
    model = copy.deepcopy(model)
    # 收集所有BN层γ值
    bn_modules = [m for m in model.modules() if isinstance(m, nn.BatchNorm2d)]
    all_gamma = torch.cat([m.weight.detach().abs().view(-1) for m in bn_modules])
    # 计算全局剪枝阈值
    k = int(all_gamma.numel() * prune_ratio)
    threshold = torch.kthvalue(all_gamma, k).values.item()

    for name, module in model.named_modules():
        if isinstance(module, nn.BatchNorm2d):
            # 找到γ < threshold的通道索引
            mask = module.weight.detach().abs() > threshold
            keep_idx = torch.where(mask)[0].tolist()
            if len(keep_idx) == 0 or len(keep_idx) == module.num_features:
                continue
            # 剪枝当前BN层
            module.weight.data = module.weight.data[keep_idx]
            module.bias.data = module.bias.data[keep_idx]
            module.running_mean = module.running_mean[keep_idx]
            module.running_var = module.running_var[keep_idx]
            module.num_features = len(keep_idx)
            # 剪枝前一卷积层输出通道
            parent_conv = None
            for n, m in model.named_modules():
                if n == name:
                    break
                if isinstance(m, nn.Conv2d):
                    parent_conv = m
            if parent_conv is not None:
                parent_conv.weight.data = parent_conv.weight.data[:, keep_idx, :, :]
                if parent_conv.bias is not None:
                    parent_conv.bias.data = parent_conv.bias.data[keep_idx]
            # 剪枝后一卷积层输入通道
            next_conv = None
            found = False
            for n, m in model.named_modules():
                if found and isinstance(m, nn.Conv2d):
                    next_conv = m
                    break
                if n == name:
                    found = True
            if next_conv is not None:
                next_conv.weight.data = next_conv.weight.data[keep_idx, :, :, :]
    return model

# ---------- 4. 主流程 ----------
if __name__ == '__main__':
    print('=== 步骤1: 训练带稀疏正则的原始模型 ===')
    model = models.resnet18(num_classes=10)
    train(model, epochs=5, lambda_reg=1e-4)
    acc_original = evaluate(model)
    print(f'原始模型准确率: {acc_original:.2f}%')

    # 统计原始参数量
    orig_params = sum(p.numel() for p in model.parameters())
    print(f'原始参数量: {orig_params/1e6:.2f}M')

    # 不同剪枝比例对比
    for ratio in [0.3, 0.5, 0.7]:
        print(f'\n=== 剪枝比例: {ratio*100:.0f}% ===')
        pruned_model = prune_channels_by_bn(model, ratio)
        pruned_params = sum(p.numel() for p in pruned_model.parameters())
        print(f'剪枝后参数量: {pruned_params/1e6:.2f}M (减少{(1-pruned_params/orig_params)*100:.1f}%)')
        # 微调
        train(pruned_model, epochs=3, lr=0.005, lambda_reg=0)
        acc_pruned = evaluate(pruned_model)
        print(f'剪枝+微调后准确率: {acc_pruned:.2f}%')

运行方式

python channel_pruning.py

验证方法与判定标准

  • 训练收敛验证:训练5个epoch后,原始模型在CIFAR-10测试集上准确率应达到70%以上(若未达到,可增加epoch至10或调大学习率)。
  • 剪枝有效性验证:程序会打印每个剪枝比例下的参数量减少百分比。30%剪枝应减少约25-35%参数,70%剪枝应减少约60-75%参数。
  • 精度保持验证:剪枝后经3个epoch微调,30%剪枝比例下准确率下降应小于2%;50%剪枝下降应小于5%;70%剪枝下降可能超过10%,属正常现象。
  • 通道稀疏性验证:训练完成后,可打印BN层γ值分布,应观察到大量接近0的γ值(稀疏正则生效)。

预期输出示例

=== 步骤1: 训练带稀疏正则的原始模型 ===
Epoch 1/5, Loss: 1.5234
...
Epoch 5/5, Loss: 0.8921
原始模型准确率: 78.45%
原始参数量: 11.17M

=== 剪枝比例: 30% ===
剪枝后参数量: 7.82M (减少30.0%)
Epoch 1/3, Loss: 1.1023
...
剪枝+微调后准确率: 77.21%

=== 剪枝比例: 50% ===
剪枝后参数量: 5.59M (减少50.0%)
...
剪枝+微调后准确率: 74.83%

=== 剪枝比例: 70% ===
剪枝后参数量: 3.35M (减少70.0%)
...
剪枝+微调后准确率: 68.12%

常见问题

  • 剪枝后模型无法前向传播:ResNet的残差连接要求输入输出通道一致。若剪枝导致shortcut维度不匹配,需对shortcut中的1x1卷积同步剪枝。本实现中ResNet-18的basic block在通道变化处有downsample层,需额外处理。
  • γ值不够稀疏:增大lambda_reg至1e-3或增加训练epoch,可增强稀疏效果。
  • 剪枝比例过高导致精度骤降:建议从30%开始逐步尝试,并增加微调epoch数(如10个epoch)以恢复精度。