4. 模型剪枝

任务目标

对PyTorch实现的简单CNN(用于CIFAR-10分类)执行通道级结构化剪枝:按BN层gamma系数排序,裁剪贡献低的卷积通道,从而减少参数量与FLOPs,并通过短时微调恢复精度。本案例聚焦剪枝流程本身,不追求SOTA精度。

环境依赖

  • Python 3.8+,PyTorch 1.12+,torchvision,numpy
  • 无需GPU,CPU可完成(训练轮次已缩减)

实现要点

结构化剪枝的核心是识别并移除整组关联参数。本案例采用BN层gamma值作为通道重要性指标:剪枝时同时删除:
1) 前一卷积层中对应的输出通道(即该BN层输入通道);
2) BN层本身被剪的通道;
3) 下一卷积层中对应的输入通道。
通过prune_model函数一次性完成上述三处裁剪,避免产生权重形状不匹配。

完整代码

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.utils.data import DataLoader, Subset
import torchvision
import torchvision.transforms as transforms
import numpy as np

# ---------- 1. 定义简单CNN ----------
class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
        self.bn1 = nn.BatchNorm2d(32)
        self.conv2 = nn.Conv2d(32, 64, 3, padding=1)
        self.bn2 = nn.BatchNorm2d(64)
        self.conv3 = nn.Conv2d(64, 128, 3, padding=1)
        self.bn3 = nn.BatchNorm2d(128)
        self.pool = nn.MaxPool2d(2)
        self.fc = nn.Linear(128 * 4 * 4, 10)

    def forward(self, x):
        x = self.pool(F.relu(self.bn1(self.conv1(x))))
        x = self.pool(F.relu(self.bn2(self.conv2(x))))
        x = self.pool(F.relu(self.bn3(self.conv3(x))))
        x = x.view(x.size(0), -1)
        return self.fc(x)

# ---------- 2. 通道剪枝函数 ----------
def prune_model(model, keep_ratio):
    """按BN gamma值保留比例keep_ratio的通道,返回剪枝后的新模型"""
    bn_layers = [model.bn1, model.bn2, model.bn3]
    conv_layers = [model.conv1, model.conv2, model.conv3]

    # 计算每层保留的通道数
    keep_channels = []
    for bn in bn_layers:
        gamma = bn.weight.data.abs().cpu().numpy()
        k = max(1, int(len(gamma) * keep_ratio))
        keep_channels.append(k)

    # 逐层剪枝
    new_model = SimpleCNN()
    # 修改新模型的通道数
    new_model.conv1 = nn.Conv2d(3, keep_channels[0], 3, padding=1)
    new_model.bn1 = nn.BatchNorm2d(keep_channels[0])
    new_model.conv2 = nn.Conv2d(keep_channels[0], keep_channels[1], 3, padding=1)
    new_model.bn2 = nn.BatchNorm2d(keep_channels[1])
    new_model.conv3 = nn.Conv2d(keep_channels[1], keep_channels[2], 3, padding=1)
    new_model.bn3 = nn.BatchNorm2d(keep_channels[2])
    new_model.fc = nn.Linear(keep_channels[2] * 4 * 4, 10)

    # 复制保留的权重
    for i, (conv, bn) in enumerate(zip(conv_layers, bn_layers)):
        gamma = bn.weight.data.abs().cpu().numpy()
        idx = np.argsort(gamma)[::-1][:keep_channels[i]]  # 保留gamma最大的通道
        idx = torch.from_numpy(np.sort(idx))  # 保持原始顺序

        # 剪枝当前卷积层输出通道
        if i == 0:
            new_model.conv1.weight.data = conv.weight.data[idx]
            new_model.conv1.bias.data = conv.bias.data[idx]
            new_model.bn1.weight.data = bn.weight.data[idx]
            new_model.bn1.bias.data = bn.bias.data[idx]
            new_model.bn1.running_mean.data = bn.running_mean.data[idx]
            new_model.bn1.running_var.data = bn.running_var.data[idx]
        elif i == 1:
            # 先剪输入通道(来自上一层保留的idx)
            prev_idx = torch.from_numpy(np.sort(np.argsort(
                bn_layers[i-1].weight.data.abs().cpu().numpy())[::-1][:keep_channels[i-1]]))
            new_model.conv2.weight.data = conv.weight.data[prev_idx][:, idx]
            new_model.conv2.bias.data = conv.bias.data[idx]
            new_model.bn2.weight.data = bn.weight.data[idx]
            new_model.bn2.bias.data = bn.bias.data[idx]
            new_model.bn2.running_mean.data = bn.running_mean.data[idx]
            new_model.bn2.running_var.data = bn.running_var.data[idx]
        else:
            prev_idx = torch.from_numpy(np.sort(np.argsort(
                bn_layers[i-1].weight.data.abs().cpu().numpy())[::-1][:keep_channels[i-1]]))
            new_model.conv3.weight.data = conv.weight.data[prev_idx][:, idx]
            new_model.conv3.bias.data = conv.bias.data[idx]
            new_model.bn3.weight.data = bn.weight.data[idx]
            new_model.bn3.bias.data = bn.bias.data[idx]
            new_model.bn3.running_mean.data = bn.running_mean.data[idx]
            new_model.bn3.running_var.data = bn.running_var.data[idx]

    # 处理全连接层
    last_idx = torch.from_numpy(np.sort(np.argsort(
        bn_layers[-1].weight.data.abs().cpu().numpy())[::-1][:keep_channels[-1]]))
    new_model.fc.weight.data = model.fc.weight.data[:, last_idx]
    new_model.fc.bias.data = model.fc.bias.data
    return new_model

# ---------- 3. 训练/微调与验证 ----------
def train(model, loader, epochs=1, lr=0.01):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model.to(device)
    optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
    criterion = nn.CrossEntropyLoss()
    model.train()
    for epoch in range(epochs):
        for x, y in loader:
            x, y = x.to(device), y.to(device)
            optimizer.zero_grad()
            loss = criterion(model(x), y)
            loss.backward()
            optimizer.step()

def evaluate(model, loader):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model.to(device)
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for x, y in loader:
            x, y = x.to(device), y.to(device)
            pred = model(x).argmax(dim=1)
            correct += (pred == y).sum().item()
            total += y.size(0)
    return correct / total

def count_params(model):
    return sum(p.numel() for p in model.parameters())

# ---------- 4. 主流程 ----------
if __name__ == '__main__':
    # 数据准备(使用CIFAR-10子集加速)
    transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
    ])
    full_train = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
    full_test = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
    # 取子集加速演示
    train_loader = DataLoader(Subset(full_train, range(2000)), batch_size=64, shuffle=True)
    test_loader = DataLoader(Subset(full_test, range(500)), batch_size=64)

    # 初始训练(仅2轮,用于演示)
    model = SimpleCNN()
    train(model, train_loader, epochs=2, lr=0.01)
    acc_before = evaluate(model, test_loader)
    params_before = count_params(model)
    print(f'原始模型: 参数量={params_before}, 精度={acc_before:.4f}')

    # 剪枝(保留50%通道)
    pruned_model = prune_model(model, keep_ratio=0.5)
    acc_pruned = evaluate(pruned_model, test_loader)
    params_pruned = count_params(pruned_model)
    print(f'剪枝后(未微调): 参数量={params_pruned}, 精度={acc_pruned:.4f}')

    # 微调恢复
    train(pruned_model, train_loader, epochs=3, lr=0.005)
    acc_finetuned = evaluate(pruned_model, test_loader)
    print(f'微调后: 参数量={params_pruned}, 精度={acc_finetuned:.4f}')
    print(f'参数压缩比: {params_before / params_pruned:.2f}x')

运行与验证

  1. 运行:python prune_demo.py(首次运行会自动下载CIFAR-10)
  2. 预期输出(数值因随机性略有波动):
    原始模型: 参数量≈123706, 精度≈0.35~0.45
    剪枝后(未微调): 参数量≈31000, 精度≈0.15~0.25
    微调后: 参数量≈31000, 精度≈0.30~0.40
    参数压缩比≈4.0x
  3. 通过条件:
    - 剪枝后参数量显著下降(约减少50%以上);
    - 微调后精度相比剪枝后明显回升(提升≥5个百分点),且接近原始模型精度(差距≤10个百分点)。

关键说明

  • 剪枝比例由keep_ratio=0.5控制,可调整为0.3或0.7观察精度-压缩权衡。
  • 本案例使用CIFAR-10的2000张子集训练,仅用于演示流程;实际应用需使用完整数据集并训练更多轮次。
  • 剪枝后模型结构与原始类相同,但通道数已改变,因此需重建模型实例并复制保留的权重(见prune_model)。