2. 基于L1范数的通道剪枝
任务目标
对训练好的卷积神经网络执行基于L1范数的通道剪枝:计算每个卷积层各通道权重值和,按预设比例剪除L1范数最小的通道,重建剪枝后的模型结构,并通过短时微调恢复精度。本案例以CIFAR-10上的简易CNN为例,完整演示“训练→剪枝→微调→验证”闭环。
环境依赖
Python 3.8+,PyTorch 1.12+,torchvision,numpy。无需额外硬件,CPU即可完成本案例全部流程。
实现原理与关键代码
通道剪枝的核心在于:对卷积层输出通道的权重矩阵(形状为 [out_channels, in_channels, k, k])计算每个输出通道的L1范数(即该通道全部权重值之和),该值越小说明该通道对后续特征贡献越弱,可安全移除。剪枝后需同步删除下一层对应输入通道,并重建网络结构。
以下代码实现完整流程:先训练一个基准CNN,再执行L1通道剪枝(剪除比例50%),最后微调5个epoch恢复精度。
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
# ---------- 1. 定义简易CNN ----------
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 16, 3, padding=1)
self.conv2 = nn.Conv2d(16, 32, 3, padding=1)
self.conv3 = nn.Conv2d(32, 64, 3, padding=1)
self.pool = nn.MaxPool2d(2)
self.fc = nn.Linear(64 * 4 * 4, 10)
def forward(self, x):
x = self.pool(F.relu(self.conv1(x)))
x = self.pool(F.relu(self.conv2(x)))
x = self.pool(F.relu(self.conv3(x)))
x = x.view(x.size(0), -1)
return self.fc(x)
# ---------- 2. 数据加载 ----------
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])
train_set = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
test_set = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=64, shuffle=True)
test_loader = DataLoader(test_set, batch_size=256)
# ---------- 3. 训练/测试工具函数 ----------
def train(model, loader, epochs=1, lr=0.001):
device = next(model.parameters()).device
optimizer = optim.Adam(model.parameters(), lr=lr)
criterion = nn.CrossEntropyLoss()
model.train()
for epoch 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
total = 0
with torch.no_grad():
for data, target in loader:
data, target = data.to(device), target.to(device)
output = model(data)
pred = output.argmax(dim=1)
correct += (pred == target).sum().item()
total += target.size(0)
return correct / total
# ---------- 4. L1范数通道剪枝 ----------
def prune_channels(model, prune_ratio=0.5):
"""按L1范数剪除每个卷积层prune_ratio比例的通道,返回剪枝后的新模型"""
# 收集所有卷积层
conv_layers = [m for m in model.modules() if isinstance(m, nn.Conv2d)]
# 记录每层保留的通道索引
keep_indices = []
for conv in conv_layers:
# 计算每个输出通道的L1范数
l1_norm = conv.weight.abs().sum(dim=(1, 2, 3)) # [out_channels]
num_keep = int(l1_norm.size(0) * (1 - prune_ratio))
# 保留L1范数最大的num_keep个通道
_, indices = torch.topk(l1_norm, num_keep)
keep_indices.append(indices.sort()[0]) # 升序排列,保持通道顺序
# 重建模型
new_model = SimpleCNN()
# 逐层替换卷积权重和偏置
new_convs = [m for m in new_model.modules() if isinstance(m, nn.Conv2d)]
old_convs = conv_layers
# conv1: 输入通道不变,输出通道取keep_indices[0]
idx0 = keep_indices[0]
new_convs[0].weight.data = old_convs[0].weight.data[idx0].clone()
new_convs[0].bias.data = old_convs[0].bias.data[idx0].clone()
# conv2: 输入通道对应conv1保留的通道,输出通道取keep_indices[1]
idx1 = keep_indices[1]
new_convs[1].weight.data = old_convs[1].weight.data[idx1][:, idx0].clone()
new_convs[1].bias.data = old_convs[1].bias.data[idx1].clone()
# conv3: 输入通道对应conv2保留的通道,输出通道取keep_indices[2]
idx2 = keep_indices[2]
new_convs[2].weight.data = old_convs[2].weight.data[idx2][:, idx1].clone()
new_convs[2].bias.data = old_convs[2].bias.data[idx2].clone()
# 全连接层:输入维度需匹配conv3输出通道数 * 4 * 4
new_fc_in = idx2.size(0) * 4 * 4
new_model.fc = nn.Linear(new_fc_in, 10)
# 从原fc中截取对应权重(简单平均池化映射,此处用前new_fc_in行)
with torch.no_grad():
new_model.fc.weight.data = model.fc.weight.data[:, :new_fc_in].clone()
new_model.fc.bias.data = model.fc.bias.data.clone()
return new_model
# ---------- 5. 主流程 ----------
if __name__ == '__main__':
# 训练基准模型(为节省时间仅训练3个epoch,实际应用可训练更久)
model = SimpleCNN()
print("训练基准模型...")
train(model, train_loader, epochs=3)
acc_before = evaluate(model, test_loader)
print(f"剪枝前准确率: {acc_before:.4f}")
# 统计剪枝前参数量
params_before = sum(p.numel() for p in model.parameters())
# 执行剪枝
pruned_model = prune_channels(model, prune_ratio=0.5)
params_after = sum(p.numel() for p in pruned_model.parameters())
print(f"参数量: {params_before} -> {params_after} (减少{100*(1-params_after/params_before):.1f}%)")
# 剪枝后直接评估(未微调)
acc_pruned = evaluate(pruned_model, test_loader)
print(f"剪枝后(未微调)准确率: {acc_pruned:.4f}")
# 微调恢复精度
print("微调中...")
train(pruned_model, train_loader, epochs=5, lr=0.0005)
acc_finetuned = evaluate(pruned_model, test_loader)
print(f"微调后准确率: {acc_finetuned:.4f}")
运行方式与验证
直接运行上述脚本即可。首次运行会自动下载CIFAR-10数据集(约170MB)。程序依次输出:剪枝前准确率、参数量变化、剪枝后未微调准确率、微调后准确率。
通过条件:
- 剪枝后参数量减少约50%(因全连接层占比较大,实际总参数量减少约40%~50%属正常);
- 剪枝后未微调准确率较剪枝前下降(通常下降5~15个百分点);
- 微调5个epoch后准确率回升,与剪枝前差距缩小到3个百分点以内即视为剪枝+微调流程有效。
关键说明
- 本案例中全连接层权重采用直接截断方式适配,严格做法应基于全局平均池化或特征图重计算,但对本演示目标(验证L1剪枝有效性)已足够;
- 剪枝比例0.5为示例值,实际工程中建议从0.3开始逐步调高,观察精度-参数量权衡曲线;
- 若需部署加速,需配合硬件支持的稀疏卷积库或实际通道移除后的模型导出,本案例聚焦于剪枝算法本身。