1. 基于PyTorch的ResNet-18结构化剪枝

任务说明

对ResNet-18实施通道剪枝(结构化剪枝),在CIFAR-10数据集上完成训练、剪枝与微调,对比剪枝前后模型的Top-1精度与FLOPs变化。本案例聚焦于卷积层输出通道的剪枝流程,不涉及BN层折叠与推理加速内核实现。

环境依赖

  • Python 3.8+,PyTorch 1.12+,torchvision 0.13+
  • numpy,tqdm(可选)

实现代码

import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as transforms
import numpy as np

# ---------- 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 = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train)
testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test)
trainloader = torch.utils.data.DataLoader(trainset, batch_size=128, shuffle=True, num_workers=2)
testloader = torch.utils.data.DataLoader(testset, batch_size=256, shuffle=False, num_workers=2)

# ---------- 2. 模型定义 ----------
def conv3x3(in_planes, out_planes, stride=1):
    return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride, padding=1, bias=False)

class BasicBlock(nn.Module):
    expansion = 1
    def __init__(self, inplanes, planes, stride=1, downsample=None):
        super().__init__()
        self.conv1 = conv3x3(inplanes, planes, stride)
        self.bn1 = nn.BatchNorm2d(planes)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = conv3x3(planes, planes)
        self.bn2 = nn.BatchNorm2d(planes)
        self.downsample = downsample
        self.stride = stride

    def forward(self, x):
        identity = x
        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)
        out = self.conv2(out)
        out = self.bn2(out)
        if self.downsample is not None:
            identity = self.downsample(x)
        out += identity
        out = self.relu(out)
        return out

class ResNet18(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.inplanes = 64
        self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
        self.layer1 = self._make_layer(BasicBlock, 64, 2)
        self.layer2 = self._make_layer(BasicBlock, 128, 2, stride=2)
        self.layer3 = self._make_layer(BasicBlock, 256, 2, stride=2)
        self.layer4 = self._make_layer(BasicBlock, 512, 2, stride=2)
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(512, num_classes)

    def _make_layer(self, block, planes, blocks, stride=1):
        downsample = None
        if stride != 1 or self.inplanes != planes * block.expansion:
            downsample = nn.Sequential(
                nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(planes * block.expansion),
            )
        layers = []
        layers.append(block(self.inplanes, planes, stride, downsample))
        self.inplanes = planes * block.expansion
        for _ in range(1, blocks):
            layers.append(block(self.inplanes, planes))
        return nn.Sequential(*layers)

    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)
        x = self.avgpool(x)
        x = torch.flatten(x, 1)
        x = self.fc(x)
        return x

# ---------- 3. 训练/测试工具 ----------
def train(model, epochs=1, lr=0.01):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model.to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = torch.optim.SGD(model.parameters(), lr=lr, momentum=0.9, weight_decay=5e-4)
    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
    model.train()
    for epoch in range(epochs):
        for inputs, targets in trainloader:
            inputs, targets = inputs.to(device), targets.to(device)
            optimizer.zero_grad()
            outputs = model(inputs)
            loss = criterion(outputs, targets)
            loss.backward()
            optimizer.step()
        scheduler.step()

def evaluate(model):
    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 inputs, targets in testloader:
            inputs, targets = inputs.to(device), targets.to(device)
            outputs = model(inputs)
            _, predicted = outputs.max(1)
            total += targets.size(0)
            correct += predicted.eq(targets).sum().item()
    return 100.0 * correct / total

# ---------- 4. 通道剪枝实现 ----------
def prune_resnet18_channels(model, prune_ratio=0.3):
    """按比例剪枝每个BasicBlock中conv2的输出通道(即残差块内部第二层卷积)。"""
    pruned_model = ResNet18(num_classes=10)
    pruned_model.load_state_dict(model.state_dict())

    for name, module in pruned_model.named_modules():
        if isinstance(module, BasicBlock):
            # 获取conv2的BN层gamma值
            bn2 = module.bn2
            gamma = bn2.weight.detach().cpu().numpy()
            num_channels = gamma.shape[0]
            k = int(num_channels * prune_ratio)
            if k == 0:
                continue
            # 选择gamma最小的k个通道索引
            indices = np.argsort(np.abs(gamma))[:k]
            mask = torch.ones(num_channels, dtype=torch.bool)
            mask[indices] = False

            # 剪枝conv2输出通道
            conv2 = module.conv2
            new_conv2 = nn.Conv2d(conv2.in_channels, num_channels - k,
                                  kernel_size=conv2.kernel_size, stride=conv2.stride,
                                  padding=conv2.padding, bias=False)
            keep_idx = torch.where(mask)[0]
            with torch.no_grad():
                new_conv2.weight.data = conv2.weight.data[keep_idx]
            module.conv2 = new_conv2

            # 剪枝bn2
            new_bn2 = nn.BatchNorm2d(num_channels - k)
            with torch.no_grad():
                new_bn2.weight.data = bn2.weight.data[keep_idx]
                new_bn2.bias.data = bn2.bias.data[keep_idx]
                new_bn2.running_mean.data = bn2.running_mean.data[keep_idx]
                new_bn2.running_var.data = bn2.running_var.data[keep_idx]
            module.bn2 = new_bn2

            # 调整下一个卷积层的输入通道(若存在)
            # 找到该block之后的下一个卷积层
            parent_name = name
            # 处理layer内部的顺序:block内conv2之后是残差连接,下一个卷积在下一层或下个block
            # 简化处理:遍历找到下一个使用该block输出的卷积层
            # 此处通过模块遍历实现
            next_conv = None
            found = False
            for n, m in pruned_model.named_modules():
                if found and isinstance(m, nn.Conv2d):
                    next_conv = m
                    break
                if n == parent_name:
                    found = True
            if next_conv is not None:
                old_weight = next_conv.weight.data
                new_next_conv = nn.Conv2d(num_channels - k, next_conv.out_channels,
                                          kernel_size=next_conv.kernel_size, stride=next_conv.stride,
                                          padding=next_conv.padding, bias=next_conv.bias is not None)
                with torch.no_grad():
                    new_next_conv.weight.data = old_weight[:, keep_idx]
                    if next_conv.bias is not None:
                        new_next_conv.bias.data = next_conv.bias.data
                # 替换
                for n, m in pruned_model.named_modules():
                    if m is next_conv:
                        parent = pruned_model
                        parts = n.split('.')
                        for p in parts[:-1]:
                            parent = getattr(parent, p)
                        setattr(parent, parts[-1], new_next_conv)
                        break
    return pruned_model

# ---------- 5. FLOPs计算 ----------
def compute_flops(model, input_size=(1, 3, 32, 32)):
    """简化FLOPs计算:仅统计卷积层和全连接层乘法次数。"""
    model.eval()
    flops = 0
    hooks = []
    def conv_hook(module, input, output):
        nonlocal flops
        batch, in_c, h, w = input[0].size()
        out_c, _, kh, kw = module.weight.size()
        out_h, out_w = output.size(2), output.size(3)
        flops += batch * out_c * out_h * out_w * in_c * kh * kw
    def fc_hook(module, input, output):
        nonlocal flops
        flops += input[0].size(0) * input[0].size(1) * output.size(1)
    for m in model.modules():
        if isinstance(m, nn.Conv2d):
            hooks.append(m.register_forward_hook(conv_hook))
        elif isinstance(m, nn.Linear):
            hooks.append(m.register_forward_hook(fc_hook))
    dummy = torch.zeros(input_size)
    model(dummy)
    for h in hooks:
        h.remove()
    return flops / 1e6  # 返回MFLOPs

# ---------- 6. 主流程 ----------
if __name__ == '__main__':
    # 训练原始模型(为节省时间,可加载预训练权重或减少epoch)
    print("训练原始ResNet-18...")
    model = ResNet18(num_classes=10)
    # 实际使用时建议加载预训练权重或训练更久
    train(model, epochs=5, lr=0.01)
    acc_orig = evaluate(model)
    flops_orig = compute_flops(model)
    print(f"原始模型: Top-1 Acc = {acc_orig:.2f}%, FLOPs = {flops_orig:.2f}M")

    # 剪枝
    print("执行通道剪枝...")
    pruned_model = prune_resnet18_channels(model, prune_ratio=0.3)
    acc_pruned = evaluate(pruned_model)
    flops_pruned = compute_flops(pruned_model)
    print(f"剪枝后(未微调): Top-1 Acc = {acc_pruned:.2f}%, FLOPs = {flops_pruned:.2f}M")

    # 微调
    print("微调剪枝模型...")
    train(pruned_model, epochs=5, lr=0.005)
    acc_finetuned = evaluate(pruned_model)
    print(f"微调后: Top-1 Acc = {acc_finetuned:.2f}%, FLOPs = {flops_finetuned:.2f}M")
    print(f"FLOPs 减少: {(1 - flops_pruned/flops_orig)*100:.1f}%")

运行方式

  1. 将上述代码保存为 resnet18_prune.py,首次运行会自动下载CIFAR-10数据集(约170MB)至 ./data 目录。
  2. 执行 python resnet18_prune.py。若GPU可用则自动使用GPU,否则回退CPU(训练时间会显著增加)。
  3. 为快速验证流程,示例中训练轮数设为5;实际使用建议加载torchvision预训练权重或训练50轮以上以获得基准精度。

验证方法

  • 观察终端输出:原始模型精度应接近CIFAR-10合理水平(训练充分时约90%+,5轮快速验证约60-75%)。
  • 剪枝后未微调精度会明显下降(通常下降5-15个百分点),微调后精度回升。
  • FLOPs输出应显示剪枝后计算量减少约30%(与prune_ratio对应),验证剪枝生效。
  • 通过条件:程序无异常执行完毕,输出三个阶段的精度与FLOPs数值,且剪枝后FLOPs低于原始模型。

常见问题

  • 维度不匹配错误:剪枝后残差连接的维度需保持一致。本实现仅剪枝BasicBlock内部conv2的通道,不影响shortcut分支,因此不会出现维度冲突。
  • 精度过低:5轮训练仅为流程演示,实际使用请增加训练轮数或加载预训练权重。
  • FLOPs计算精度:此处为简化统计,未包含BN层和ReLU的FLOPs,但卷积与全连接层占主导,相对变化趋势准确。