深度学习之模子压缩三驾马车:基于ResNet18的模子剪枝实战(1)

[复制链接]
发表于 2025-6-21 12:43:55 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
一、背景:为什么需要模子剪枝?

随着深度学习的发展,模子参数目和盘算量呈指数级增长。以ResNet18为例,其在ImageNet上的参数目约为1100万,固然在服务器端运行流畅,但在移动端或嵌入式设备上摆设时,内存和盘算资源的限定使得直接利用大模子变得困难。模子剪枝(Model Pruning)作为模子压缩的核心技术之一,通过删除冗余的神经元或通道,在保持模子性能的条件下显着降低模子巨细和盘算量,是办理这一问题的关键本事。
在前面一篇文章我们也提到了模子压缩的一些基本定义和核心原理:《深度学习之模子压缩三驾马车:模子剪枝、模子量化、知识蒸馏》。
本文将基于PyTorch框架,以ResNet18在CIFAR-10数据集上的分类使命为例,详细解说布局化通道剪枝的完整实现流程,包罗模子训练、剪枝策略、剪枝后布局调解、微调及效果评估。
二、团体流程概览

本文代码的核心流程可总结为以下6步:

  • 环境初始化与数据集加载
  • 原始模子训练与评估
  • 卷积层布局化剪枝(以conv1层为例)
  • 剪枝后模子布局调解(BN层、残差下采样层等)
  • 剪枝模子微调
  • 剪枝前后模子效果对比
    专程分析:在这里选择conv1层作为例子,不是因为选择这个就会效果更好。
三、关键步调代码解析

3.1 环境初始化与数据集预备

首先需要配置盘算设备(GPU/CPU),并加载CIFAR-10数据集。CIFAR-10包罗10类32x32的彩色图像,训练集5万张,测试集1万张。
  1. def setup_device():
  2.     return torch.device("cuda" if torch.cuda.is_available() else "cpu")
  3. def load_dataset():
  4.     transform = transforms.Compose([
  5.         transforms.ToTensor(),
  6.         transforms.Normalize((0.5,), (0.5,))  # 归一化到[-1,1]
  7.     ])
  8.     train_dataset = datasets.CIFAR10(root='./data', train=True, transform=transform, download=True)
  9.     test_dataset = datasets.CIFAR10(root='./data', train=False, transform=transform, download=True)
  10.     return train_dataset, test_dataset
复制代码
3.2 原始模子训练

利用预训练的ResNet18模子,修改全连接层输出为10类(匹配CIFAR-10的类别数),并进行5轮训练:
  1. def create_model(device):
  2.     model = models.resnet18(pretrained=True)  # 加载ImageNet预训练权重
  3.     model.fc = nn.Linear(512, 10)  # 修改输出层为10类
  4.     return model.to(device)
  5. def train_model(model, train_loader, criterion, optimizer, device, epochs=3):
  6.     model.train()
  7.     for epoch in range(epochs):
  8.         running_loss = 0.0
  9.         for images, labels in tqdm(train_loader):
  10.             images, labels = images.to(device), labels.to(device)
  11.             optimizer.zero_grad()
  12.             outputs = model(images)
  13.             loss = criterion(outputs, labels)
  14.             loss.backward()
  15.             optimizer.step()
  16.             running_loss += loss.item()
  17.         print(f"Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}")
  18.     return model
复制代码
3.3 布局化通道剪枝核心实现

本文重点是对卷积层进行布局化剪枝(按通道剪枝),具体步调如下:
3.3.1 盘算通道重要性

通过盘算卷积核的L2范数评估通道重要性。假设卷积层权重维度为[out_channels, in_channels, kernel_h, kernel_w],将每个输出通道的权重展平为一维向量,盘算其L2范数,范数越小表现该通道对模子性能贡献越低,越应被剪枝。
  1. layer = dict(model.named_modules())[layer_name]  # 获取目标卷积层
  2. weight = layer.weight.data
  3. channel_norm = torch.norm(weight.view(weight.shape[0], -1), p=2, dim=1)  # 计算每个输出通道的L2范数
复制代码
3.3.2 天生剪枝掩码

根据剪枝比例(如20%),选择范数最小的通道天生掩码:
  1. num_channels = weight.shape[0]  # 原始输出通道数(如ResNet18的conv1层为64)
  2. num_prune = int(num_channels * amount)  # 需剪枝的通道数(如64*0.2=12)
  3. _, indices = torch.topk(channel_norm, k=num_prune, largest=False)  # 找到最不重要的12个通道
  4. mask = torch.ones(num_channels, dtype=torch.bool)
  5. mask[indices] = False  # 掩码:保留的通道标记为True(52个),剪枝的标记为False(12个)
复制代码
3.3.3 更换卷积层

创建新的卷积层,仅保留掩码为True的通道:
  1. new_conv = nn.Conv2d(
  2.     in_channels=layer.in_channels,
  3.     out_channels=num_channels - num_prune,  # 剪枝后输出通道数(52)
  4.     kernel_size=layer.kernel_size,
  5.     stride=layer.stride,
  6.     padding=layer.padding,
  7.     bias=layer.bias is not None
  8. ).to(device)  # 移动到模型所在设备
  9. new_conv.weight.data = layer.weight.data[mask]  # 保留掩码为True的通道权重
  10. if layer.bias is not None:
  11.     new_conv.bias.data = layer.bias.data[mask]  # 偏置同理
复制代码
3.3.4 关键:剪枝后布局调解

直接剪枝会导致后续层(如BN层、残差连接中的下采样层)的输入/输出通道不匹配,必须同步调解:
(1) 调解BN层
卷积层后通常接BN层,BN的num_features需与卷积输出通道数同等:
  1. if 'conv1' in layer_name:
  2.     bn1 = model.bn1
  3.     new_bn1 = nn.BatchNorm2d(new_conv.out_channels).to(device)  # 新BN层通道数52
  4.     with torch.no_grad():
  5.         # 同步原始BN层的参数(仅保留未被剪枝的通道)
  6.         new_bn1.weight.data = bn1.weight.data[mask].clone()
  7.         new_bn1.bias.data = bn1.bias.data[mask].clone()
  8.         new_bn1.running_mean.data = bn1.running_mean.data[mask].clone()
  9.         new_bn1.running_var.data = bn1.running_var.data[mask].clone()
  10.     model.bn1 = new_bn1
复制代码
(2) 调解残差下采样层
ResNet的残差块(如layer1.0)中,若主路径的通道数被剪枝,需要通过1x1卷积的下采样层(downsample)匹配 shortcut 的通道数:
  1. block = model.layer1[0]
  2. if not hasattr(block, 'downsample') or block.downsample is None:
  3.     # 原始无downsample,创建新的1x1卷积+BN
  4.     downsample_conv = nn.Conv2d(
  5.         in_channels=new_conv.out_channels,  # 52(剪枝后的conv1输出)
  6.         out_channels=block.conv2.out_channels,  # 64(主路径conv2的输出)
  7.         kernel_size=1,
  8.         stride=1,
  9.         bias=False
  10.     ).to(device)
  11.     torch.nn.init.kaiming_normal_(downsample_conv.weight, mode='fan_out', nonlinearity='relu')  # 初始化权重
  12.    
  13.     downsample_bn = nn.BatchNorm2d(downsample_conv.out_channels).to(device)
  14.     block.downsample = nn.Sequential(downsample_conv, downsample_bn)  # 添加downsample层
  15. else:
  16.     # 原有downsample层,调整输入通道
  17.     downsample_conv = block.downsample[0]
  18.     downsample_conv.in_channels = new_conv.out_channels  # 输入通道改为52
  19.     downsample_conv.weight = nn.Parameter(downsample_conv.weight.data[:, mask, :, :].clone())  # 输入通道用掩码筛选
复制代码
(3) 前向流传验证
调解后需验证模子可否正常前向流传,避免通道不匹配导致的错误:
  1. with torch.no_grad():
  2.     test_input = torch.randn(1, 3, 32, 32).to(device)  # 测试输入(B, C, H, W)
  3.     try:
  4.         model(test_input)
  5.         print("✅ 前向传播验证通过")
  6.     except Exception as e:
  7.         print(f"❌ 验证失败: {str(e)}")
  8.         raise
复制代码
3.3的总结,直接上代码
  1. def prune_conv_layer(model, layer_name, amount=0.2):
  2.     # 获取模型当前所在设备
  3.     device = next(model.parameters()).device  # 新增:获取设备
  4.    
  5.     layer = dict(model.named_modules())[layer_name]
  6.     weight = layer.weight.data
  7.     channel_norm = torch.norm(weight.view(weight.shape[0], -1), p=2, dim=1)
  8.    
  9.     num_channels = weight.shape[0]  # 原始通道数(如 64)
  10.     num_prune = int(num_channels * amount)
  11.     _, indices = torch.topk(channel_norm, k=num_prune, largest=False)
  12.    
  13.     mask = torch.ones(num_channels, dtype=torch.bool)
  14.     mask[indices] = False  # 生成剪枝掩码(长度 64,52 个 True)
  15.    
  16.     new_conv = nn.Conv2d(
  17.         in_channels=layer.in_channels,
  18.         out_channels=num_channels - num_prune,  # 剪枝后通道数(如 52)
  19.         kernel_size=layer.kernel_size,
  20.         stride=layer.stride,
  21.         padding=layer.padding,
  22.         bias=layer.bias is not None
  23.     )
  24.     new_conv = new_conv.to(device)  # 新增:移动到模型所在设备
  25.    
  26.     new_conv.weight.data = layer.weight.data[mask]  # 保留 mask 为 True 的通道
  27.     if layer.bias is not None:
  28.         new_conv.bias.data = layer.bias.data[mask]
  29.    
  30.     # 替换原始卷积层
  31.     parent_name, sep, name = layer_name.rpartition('.')
  32.     parent = model.get_submodule(parent_name)
  33.     setattr(parent, name, new_conv)
  34.     if 'conv1' in layer_name:
  35.         # 1. 更新与 conv1 直接关联的 BN1 层
  36.         bn1 = model.bn1
  37.         new_bn1 = nn.BatchNorm2d(new_conv.out_channels)  # 新 BN 层通道数 52
  38.         new_bn1 = new_bn1.to(device)  # 新增:移动到模型所在设备
  39.         with torch.no_grad():
  40.             new_bn1.weight.data = bn1.weight.data[mask].clone()
  41.             new_bn1.bias.data = bn1.bias.data[mask].clone()
  42.             new_bn1.running_mean.data = bn1.running_mean.data[mask].clone()
  43.             new_bn1.running_var.data = bn1.running_var.data[mask].clone()
  44.         model.bn1 = new_bn1
  45.         # 2. 处理残差连接中的 downsample(关键修正:添加缺失的 downsample)
  46.         block = model.layer1[0]
  47.         if not hasattr(block, 'downsample') or block.downsample is None:
  48.             # 原始无 downsample,需创建新的 1x1 卷积+BN 来匹配通道
  49.             downsample_conv = nn.Conv2d(
  50.                 in_channels=new_conv.out_channels,  # 52
  51.                 out_channels=block.conv2.out_channels,  # 64(主路径输出通道数)
  52.                 kernel_size=1,
  53.                 stride=1,
  54.                 bias=False
  55.             )
  56.             downsample_conv = downsample_conv.to(device)  # 新增:移动到模型所在设备
  57.             # 初始化 1x1 卷积权重(这里简单复制原模型可能的统计量,实际可根据需求调整)
  58.             torch.nn.init.kaiming_normal_(downsample_conv.weight, mode='fan_out', nonlinearity='relu')
  59.             
  60.             downsample_bn = nn.BatchNorm2d(downsample_conv.out_channels)
  61.             downsample_bn = downsample_bn.to(device)  # 新增:移动到模型所在设备
  62.             with torch.no_grad():
  63.                 # 初始化 BN 参数(可保持默认,或根据原模型统计量调整)
  64.                 downsample_bn.weight.fill_(1.0)
  65.                 downsample_bn.bias.zero_()
  66.                 downsample_bn.running_mean.zero_()
  67.                 downsample_bn.running_var.fill_(1.0)
  68.             
  69.             block.downsample = nn.Sequential(downsample_conv, downsample_bn)
  70.             print("✅ 为 layer1.0 添加新的 downsample 层")
  71.         else:
  72.             # 原有 downsample 层,调整输入通道
  73.             downsample_conv = block.downsample[0]
  74.             downsample_conv.in_channels = new_conv.out_channels  # 输入通道调整为 52
  75.             downsample_conv.weight = nn.Parameter(downsample_conv.weight.data[:, mask, :, :].clone())  # 输入通道用 mask 筛选
  76.             downsample_conv = downsample_conv.to(device)  # 新增:移动到模型所在设备
  77.             
  78.             downsample_bn = block.downsample[1]
  79.             new_downsample_bn = nn.BatchNorm2d(downsample_conv.out_channels)
  80.             new_downsample_bn = new_downsample_bn.to(device)  # 新增:移动到模型所在设备
  81.             with torch.no_grad():
  82.                 new_downsample_bn.weight.data = downsample_bn.weight.data.clone()
  83.                 new_downsample_bn.bias.data = downsample_bn.bias.data.clone()
  84.                 new_downsample_bn.running_mean.data = downsample_bn.running_mean.data.clone()
  85.                 new_downsample_bn.running_var.data = downsample_bn.running_var.data.clone()
  86.             block.downsample[1] = new_downsample_bn
  87.         # 3. 同步 layer1.0.conv1 的输入通道(保持原有逻辑)
  88.         next_convs = ['layer1.0.conv1']
  89.         for conv_path in next_convs:
  90.             try:
  91.                 conv = model.get_submodule(conv_path)
  92.                 if conv.in_channels != new_conv.out_channels:
  93.                     print(f"同步输入通道: {conv.in_channels} → {new_conv.out_channels}")
  94.                     conv.in_channels = new_conv.out_channels
  95.                     conv.weight = nn.Parameter(conv.weight.data[:, mask, :, :].clone())
  96.                     conv = conv.to(device)  # 新增:移动到模型所在设备
  97.             except AttributeError as e:
  98.                 print(f"⚠️ 卷积层调整失败: {conv_path} ({str(e)})")
  99.         # 验证前向传播
  100.         with torch.no_grad():
  101.             test_input = torch.randn(1, 3, 32, 32).to(device)  # 确保测试输入也在相同设备
  102.             try:
  103.                 model(test_input)
  104.                 print("✅ 前向传播验证通过")
  105.             except Exception as e:
  106.                 print(f"❌ 验证失败: {str(e)}")
  107.                 raise
  108.     return model
复制代码
3.4 剪枝模子微调

剪枝后模子的部分参数被删除,需要通过微调规复性能。一开始,我们只是在微调时冻结了除 fc 层外的全部参数,但是效果并欠好,固然分析原因,除了动了conv1的原因(conv1 是模子的第一个卷积层,负责提取最基础的图像特征(如边沿、纹理、颜色等)。这些底层特征对后续全部层的特征提取至关重要。),最重要的是裁剪后,需要对裁剪的层进行微调,确保参数适应新的特征维度。
微调时冻结了除 fc 层外的全部参数的代码和结果:
  1. for name, param in pruned_model.named_parameters():
  2.         if 'fc' not in name:
  3.             param.requires_grad = False
  4.     optimizer = optim.Adam(pruned_model.fc.parameters(), lr=0.001)
  5.     print("微调剪枝后的模型")
  6.     pruned_model = train_model(pruned_model, train_loader, criterion, optimizer, device,epochs=5)
复制代码
  1. 原始模型准确率: 80.07%
  2. 剪枝后模型准确率: 37.80%
复制代码
可以看到这个相差很大
本文选择解冻被剪枝的层(如conv1、bn1)及相关层(如layer1.0.conv1、downsample)进行参数更新:
  1. print("开始微调剪枝后的模型")
  2. for name, param in pruned_model.named_parameters():
  3.     # 仅解冻与剪枝相关的层
  4.     if 'conv1' in name or 'bn1' in name or 'layer1.0.conv1' in name or 'layer1.0.downsample' in name or 'fc' in name:
  5.         param.requires_grad = True
  6.     else:
  7.         param.requires_grad = False
  8. optimizer = optim.Adam(filter(lambda p: p.requires_grad, pruned_model.parameters()), lr=0.001)
  9. pruned_model = train_model(pruned_model, train_loader, criterion, optimizer, device, epochs=5)
复制代码
  1. 原始模型准确率: 78.94%
  2. 剪枝后模型准确率:  81.30%
复制代码
重新微调了裁剪后的层后,结果有了很大改变。
四、实行结果与分析

通过代码中的evaluate_model函数评估剪枝前后的模子准确率:
  1. def evaluate_model(model, device, test_loader):
  2.     model.eval()
  3.     correct = 0
  4.     total = 0
  5.     with torch.no_grad():
  6.         for images, labels in test_loader:
  7.             images, labels = images.to(device), labels.to(device)
  8.             outputs = model(images)
  9.             _, predicted = torch.max(outputs.data, 1)
  10.             total += labels.size(0)
  11.             correct += (predicted == labels).sum().item()
  12.     acc = 100 * correct / total
  13.     return acc
复制代码
假设原始模子准确率为88.5%,剪枝20%通道后(模子巨细降低约20%),通过微调可规复至87.2%,验证了剪枝策略的有效性。
五、总结与改进方向

本文实现了基于通道L2范数的布局化剪枝,重点办理了剪枝后模子布局不同等的问题(如BN层、残差下采样层的调解),并通过微调规复了模子性能。
在这个例子中,仅裁剪 conv1 层的影响
仅裁剪 conv1 层对模子的影响极大,原因如下:


  • 底层特征的重要性 : conv1 输出的是最基础的图像特征,全部后续层的特征均基于此天生。裁剪 conv1 会直接限定后续全部层的特征表达能力。
  • 布局连锁反应 : conv1 的输出通道减少会触发 bn1 、 layer1.0.conv1 、 downsample 等多个模块的调解,任何一个模块的调解失误(如通道数不匹配、参数初始化不妥)都会导致团体性能下降。
    实际应用中可从以下方向改进:
模子裁剪通常优先选择 中间层(如ResNet的 layer2 、 layer3 ) ,而非底层或顶层,原因如下:


  • 底层(如 conv1 ) :负责基础特征提取,裁剪后特征损失大,对性能影响显着。
  • 中间层(如 layer2 、 layer3 ) :特征具有肯定抽象性但冗余度高(同一层的多个通道大概提取相似特征),裁剪后对性能影响较小。
  • 顶层(如 fc 层) :负责分类决策,参数密度高但冗余度低,裁剪易导致分类能力下降。
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表