4. 模型剪枝
任务说明
本案例针对一个包含两个卷积层的简易CNN模型,实现通道级结构化剪枝:按L2范数评估卷积核重要性,裁剪贡献较小的输出通道,从而减少中间特征图通道数。剪枝后通过少量epoch微调恢复精度,并验证模型大小与推理速度的变化。
环境依赖
需要 torch、torchvision(仅用于MNIST数据集)。若使用CPU训练,请将下文代码中的设备改为 cpu。
完整实现
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# ---------- 1. 定义模型 ----------
class SmallCNN(nn.Module):
def __init__(self, ch1=16, ch2=32):
super().__init__()
self.conv1 = nn.Conv2d(1, ch1, 3, padding=1)
self.conv2 = nn.Conv2d(ch1, ch2, 3, padding=1)
self.pool = nn.MaxPool2d(2)
self.fc = nn.Linear(ch2 * 7 * 7, 10)
def forward(self, x):
x = torch.relu(self.conv1(x))
x = self.pool(x)
x = torch.relu(self.conv2(x))
x = self.pool(x)
x = x.view(x.size(0), -1)
return self.fc(x)
# ---------- 2. 结构化剪枝函数 ----------
def prune_conv_channel(conv, ratio):
"""按L2范数裁剪conv输出通道,返回剪枝后的新卷积层"""
l2_norm = torch.norm(conv.weight.data.view(conv.out_channels, -1), dim=1)
keep_num = max(1, int(conv.out_channels * (1 - ratio)))
_, indices = torch.topk(l2_norm, keep_num)
indices = torch.sort(indices)[0] # 保持通道顺序
new_conv = nn.Conv2d(conv.in_channels, keep_num,
conv.kernel_size, conv.stride, conv.padding)
new_conv.weight.data = conv.weight.data[indices].clone()
new_conv.bias.data = conv.bias.data[indices].clone()
return new_conv, indices
def structured_prune(model, ratio1, ratio2):
"""对conv1和conv2执行结构化剪枝,并调整后续层"""
# 剪枝conv1
new_conv1, keep_idx1 = prune_conv_channel(model.conv1, ratio1)
# 剪枝conv2(输入通道需与conv1保留通道一致)
old_conv2 = model.conv2
l2_norm2 = torch.norm(old_conv2.weight.data.view(old_conv2.out_channels, -1), dim=1)
keep_num2 = max(1, int(old_conv2.out_channels * (1 - ratio2)))
_, idx2 = torch.topk(l2_norm2, keep_num2)
idx2 = torch.sort(idx2)[0]
new_conv2 = nn.Conv2d(new_conv1.out_channels, keep_num2,
old_conv2.kernel_size, old_conv2.stride, old_conv2.padding)
# 同时按keep_idx1筛选输入通道,按idx2筛选输出通道
new_conv2.weight.data = old_conv2.weight.data[idx2][:, keep_idx1].clone()
new_conv2.bias.data = old_conv2.bias.data[idx2].clone()
# 调整全连接层输入维度
new_fc = nn.Linear(keep_num2 * 7 * 7, 10)
# 全连接权重按输出通道索引重排(对应conv2保留的通道)
fc_weight = model.fc.weight.data.view(10, old_conv2.out_channels, 7, 7)
new_fc.weight.data = fc_weight[:, idx2].reshape(10, -1).clone()
new_fc.bias.data = model.fc.bias.data.clone()
pruned_model = SmallCNN(ch1=new_conv1.out_channels, ch2=new_conv2.out_channels)
pruned_model.conv1 = new_conv1
pruned_model.conv2 = new_conv2
pruned_model.fc = new_fc
return pruned_model
# ---------- 3. 训练与验证 ----------
def train(model, loader, epochs=1, lr=0.001):
device = next(model.parameters()).device
model.train()
optimizer = optim.Adam(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss()
for _ in range(epochs):
for data, target in loader:
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
loss = criterion(model(data), target)
loss.backward()
optimizer.step()
def evaluate(model, loader):
device = next(model.parameters()).device
model.eval()
correct = 0
with torch.no_grad():
for data, target in loader:
data, target = data.to(device), target.to(device)
pred = model(data).argmax(dim=1)
correct += (pred == target).sum().item()
return correct / len(loader.dataset)
# ---------- 4. 主流程 ----------
if __name__ == "__main__":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
transform = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))])
train_loader = DataLoader(datasets.MNIST("./data", train=True, download=True,
transform=transform), batch_size=64, shuffle=True)
test_loader = DataLoader(datasets.MNIST("./data", train=False, download=True,
transform=transform), batch_size=256)
# 训练原始模型(2个epoch作为基线)
model = SmallCNN().to(device)
train(model, train_loader, epochs=2)
acc_before = evaluate(model, test_loader)
print(f"原始模型准确率: {acc_before:.4f}")
# 结构化剪枝:conv1保留50%,conv2保留50%
pruned = structured_prune(model, ratio1=0.5, ratio2=0.5).to(device)
acc_pruned = evaluate(pruned, test_loader)
print(f"剪枝后准确率(未微调): {acc_pruned:.4f}")
# 微调3个epoch恢复精度
train(pruned, train_loader, epochs=3)
acc_finetuned = evaluate(pruned, test_loader)
print(f"微调后准确率: {acc_finetuned:.4f}")
# 统计参数量
def count_params(m):
return sum(p.numel() for p in m.parameters())
print(f"原始参数量: {count_params(model)}")
print(f"剪枝后参数量: {count_params(pruned)}")
运行方式
python prune_cnn.py
首次运行会自动下载MNIST数据集至 ./data 目录。
验证方法与判定标准
- 功能正确性:程序正常结束且无维度不匹配报错,说明剪枝后各层连接关系正确。
- 精度变化:观察输出——剪枝后未微调准确率应略低于原始模型(通常下降1%~5%),微调3个epoch后准确率应回升至接近原始水平(差距在2%以内视为恢复良好)。
- 压缩效果:参数量输出应显示剪枝后约为原始模型的1/4(因两通道均减半,全连接层同步缩小)。
若微调后精度未恢复,可适当增加微调epoch数或降低剪枝比例(如改为0.3)。
常见问题
- 维度不匹配:多出现在修改剪枝函数后未同步调整后续层。本实现已处理conv2输入通道索引和全连接层重排,若自行修改剪枝比例,请确保
keep_idx1被正确用于conv2的输入筛选。 - 显存不足:将batch_size调小至32或16。