简介:这份资源面向希望掌握模型压缩与剪枝技术的深度学习开发者,围绕VGGNet给出从训练、稀疏训练到剪枝、微调的完整实战流程。核心思路是在BN层引入稀疏因子,训练后统计并排序BN层权重,按保留数量确定阈值thres,再逐层生成mask,依据非零索引重建新模型结构,将原始卷积层、BN层与全连接层的权重和偏置迁移到剪枝后模型,最后加载并微调,最终得到仅约3M的轻量模型。资源包共2000个文件,以1992个png图片为主,另有5个py脚本、2个json配置和1个txt说明,压缩包约871.17MB,图片可用于记录训练曲线、剪枝前后结构对比与结果可视化,脚本与json则对应模型定义和实验配置。目前已有957人学习下载,适合想系统理解通道剪枝、稀疏化训练与微调恢复精度全流程的读者参考。
1. VGGNet剪枝实战:从22M到3M,一条能跑通的压缩路线
VGGNet 在分类任务里是个经典 backbone,结构规整、特征提取能力强,但它的参数量实在不友好——标准 VGG16 光权重就 500MB 出头,部署到边缘设备或者塞进移动端几乎不可能。我这次要做的,是把一个训练好的 VGGNet 通过稀疏训练、剪枝、微调三步,压到只有 3M 左右,同时精度不掉太多。这不是理论推演,而是一条能复现的工程路线:先正常训练一个 baseline,再用 L1 正则做稀疏训练让权重分布向零靠拢,然后按通道或卷积核做非结构化剪枝,最后微调恢复精度。适合谁?手里有 VGGNet 模型、想把它塞进小设备、又不想重头设计轻量网络的工程师。模型剪枝不是玄学,但参数设错一步,精度直接崩给你看。
2. 稀疏训练:让权重自己学会“哪些不重要”
2.1 为什么剪枝前必须做稀疏训练
直接对训练好的稠密模型剪枝,精度会断崖式下跌。原因很简单:稠密模型里每个权重都在参与前向传播,你突然砍掉 80%,相当于把网络里大量连接直接断开,特征提取能力瞬间瓦解。稀疏训练的核心目的,是让模型在训练过程中逐渐学会“哪些权重可以不要”——通过给损失函数加一个稀疏正则项,逼迫大部分权重趋近于零,只有真正重要的权重才会保持较大数值。
常见做法是在损失里加 L1 正则,因为 L1 的梯度是常数,能持续把小权重往零推。L2 正则虽然也能限制权重幅度,但它不会产生真正的稀疏性,权重只会变小而不会变成零。所以做剪枝前的稀疏训练,L1 是更合适的选择。我一般会在原损失后面加一个系数乘上所有卷积层权重的 L1 范数,系数从 1e-5 到 1e-4 之间调,太大模型欠拟合,太小稀疏效果不明显。
2.2 稀疏训练的具体实现与参数设置
下面是一个在 PyTorch 里给 VGGNet 加 L1 稀疏正则的训练循环核心代码。假设你已经定义好了 VGGNet 模型和 DataLoader,这里只展示稀疏训练的关键部分。
import torch import torch.nn as nn import torch.optim as optim # 假设 model 是已经定义好的 VGGNet # 稀疏系数,控制 L1 正则强度 sparsity_lambda = 5e-5 # 只对卷积层权重做稀疏,全连接层和 bias 不参与 def get_sparse_params(model): sparse_params = [] for name, param in model.named_parameters(): if 'features' in name and 'weight' in name: sparse_params.append(param) return sparse_params optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4) criterion = nn.CrossEntropyLoss() for epoch in range(sparse_epochs): model.train() for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(images) cls_loss = criterion(outputs, labels) # L1 稀疏正则项 sparse_loss = 0.0 for param in get_sparse_params(model): sparse_loss += torch.sum(torch.abs(param)) total_loss = cls_loss + sparsity_lambda * sparse_loss total_loss.backward() optimizer.step() # 每个 epoch 打印一下稀疏程度 zero_ratio = compute_zero_ratio(model) print(f"Epoch {epoch}, cls_loss: {cls_loss.item():.4f}, " f"sparse_loss: {sparse_loss.item():.2f}, zero_ratio: {zero_ratio:.3f}")逻辑说明:get_sparse_params只收集features部分(即卷积层)的权重,全连接层不参与稀疏,因为 VGGNet 的参数量大头在全连接层,但剪枝通常先剪卷积层,全连接层后面单独处理。sparsity_lambda是关键参数,我一般从 1e-5 开始试,如果训练完 zero_ratio 不到 0.5,就加大到 5e-5 或 1e-4。zero_ratio是统计权重绝对值小于 1e-4 的比例,用来判断稀疏训练是否生效。
参数说明:学习率保持和正常训练一致或略低,因为加了正则后损失曲面会变。weight_decay可以保留,但不要和 L1 系数冲突太大。稀疏训练的 epoch 数不需要太多,通常是正常训练 epoch 的 1/3 到 1/2,比如正常训练 100 epoch,稀疏训练 30-50 epoch 就够。训练完后你会看到权重分布出现一个尖峰在零附近,这就是可以剪枝的信号。
2.3 稀疏训练后的权重分布检查
稀疏训练结束后,别急着剪枝,先看一眼权重分布。用 matplotlib 画个直方图,如果大部分权重集中在零附近,说明稀疏训练成功。我一般会统计每一层卷积的 zero_ratio,如果某些层 zero_ratio 特别低,说明那一层对 L1 不敏感,剪枝时就要小心。常见做法是只剪 zero_ratio 高于 0.6 的层,低于这个值的层保留原样,否则精度掉得厉害。
3. 剪枝:按通道还是按卷积核,怎么选怎么剪
3.1 非结构化剪枝与结构化剪枝的取舍
剪枝算法分两大类:非结构化剪枝和结构化剪枝。非结构化剪枝是把单个权重置零,不管它属于哪个通道或卷积核,剪完模型看起来稀疏了,但实际存储和计算并不会减少,因为 GPU 和大多数推理框架不支持稀疏矩阵加速。结构化剪枝则是直接砍掉整个卷积核或整个通道,剪完模型尺寸和计算量都真实下降,部署时直接受益。
我这次要压到 3M,必须用结构化剪枝。具体做法是:对每个卷积层,计算每个卷积核的 L1 范数,然后按范数从小到大排序,剪掉最小的那一批。剪掉卷积核后,对应的输出通道也去掉,下一层的输入通道也要相应调整。这个过程需要逐层处理,不能一次性全剪,否则中间特征图对不上。
3.2 逐层剪枝的实现与通道对齐
下面是一个按卷积核 L1 范数剪枝的核心代码,处理 VGGNet 的 features 部分。注意 VGGNet 的 features 是连续的卷积+ReLU+MaxPool,剪枝时要跳过 MaxPool 层,只处理卷积层。
import torch.nn.utils.prune as prune import torch def prune_vgg_conv_layers(model, prune_ratio=0.5): """ 对 VGGNet 的卷积层按 L1 范数剪枝 prune_ratio: 每层剪掉的卷积核比例 """ conv_layers = [] for name, module in model.named_modules(): if isinstance(module, torch.nn.Conv2d): conv_layers.append((name, module)) for name, conv in conv_layers: # 计算每个卷积核的 L1 范数 # conv.weight shape: [out_channels, in_channels, k, k] kernel_norms = conv.weight.data.abs().sum(dim=(1, 2, 3)) # 按范数排序,保留大的 num_kernels = conv.weight.size(0) num_prune = int(num_kernels * prune_ratio) if num_prune == 0: continue # 保留范数最大的那些卷积核 _, keep_indices = torch.topk(kernel_norms, num_kernels - num_prune) keep_indices = keep_indices.sort().values # 新建一个更小的卷积层 new_conv = torch.nn.Conv2d( in_channels=conv.in_channels, out_channels=len(keep_indices), kernel_size=conv.kernel_size, stride=conv.stride, padding=conv.padding, bias=conv.bias is not None ) # 复制保留的权重和 bias new_conv.weight.data = conv.weight.data[keep_indices].clone() if conv.bias is not None: new_conv.bias.data = conv.bias.data[keep_indices].clone() # 替换原层 replace_module(model, name, new_conv) return model def replace_module(model, name, new_module): """按名称替换模型中的子模块""" parts = name.split('.') parent = model for part in parts[:-1]: parent = getattr(parent, part) setattr(parent, parts[-1], new_module)逻辑说明:kernel_norms计算每个卷积核的 L1 范数,范数越小说明这个核的权重整体越接近零,越不重要。torch.topk选出范数最大的num_kernels - num_prune个核保留。新建卷积层时,in_channels不变,out_channels变成保留的核数。替换模块后,下一层的in_channels需要手动调整——因为上一层输出通道变了,下一层输入通道必须跟着变。这个对齐操作我一般单独写一个函数,遍历所有卷积层,把当前层的in_channels改成上一层剪枝后的out_channels。
参数说明:prune_ratio是每层剪枝比例,我一般从 0.3 开始试,逐层调整。VGGNet 前面几层特征图大、通道少,剪太多会伤精度;后面几层通道多,可以多剪。常见做法是前面 4 个卷积层剪 0.2-0.3,中间剪 0.4-0.5,后面剪 0.5-0.6。剪完后模型大小会明显下降,但精度也会掉,需要微调恢复。
3.3 全连接层的处理与模型大小估算
VGGNet 的参数量大头在全连接层,尤其是第一个全连接层,输入是 7×7×512=25088,输出 4096,光这一层就 1 亿参数。要压到 3M,全连接层必须大砍。常见做法是把全连接层换成全局平均池化加一个小的全连接,或者直接对全连接层做低秩分解。我这次选择直接剪全连接层的神经元,按权重 L1 范数剪掉一部分输出维度,然后微调。
模型大小估算:卷积层剪掉 50% 后,参数量大概降到原来的 1/4 到 1/3;全连接层剪掉 80% 后,参数量降到原来的 1/5。综合下来,从 22M 压到 3M 是可行的。注意剪枝后要重新统计参数量,用sum(p.numel() for p in model.parameters())看实际数字,别凭感觉。
4. 微调:把剪枝掉的精度找回来
4.1 微调策略与学习率设置
剪枝后的模型精度通常会掉 5-15 个百分点,微调是必须的。微调不是从头训练,而是用较小的学习率在训练集上再跑几个 epoch,让剩下的权重重新适应新的结构。我一般用 SGD,学习率设为正常训练的 1/10 到 1/100,比如正常训练用 0.01,微调用 0.001 或 0.0005。epoch 数不用太多,10-20 个就够,太多会过拟合。
微调时有个细节:剪枝后模型结构变了,优化器要重新创建,不能复用剪枝前的优化器状态。另外,如果剪枝时把某些层的 bias 也剪了,微调时这些 bias 会重新学习。我一般会冻结前面几层,只微调后面几层和全连接层,因为前面层提取的是通用特征,剪枝后变化不大,冻结可以防止过拟合。
4.2 微调代码与精度监控
# 剪枝后重新创建优化器 optimizer_ft = optim.SGD(model.parameters(), lr=0.001, momentum=0.9, weight_decay=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer_ft, step_size=7, gamma=0.1) best_acc = 0.0 for epoch in range(finetune_epochs): model.train() for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer_ft.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer_ft.step() scheduler.step() # 验证集评估 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.cuda(), labels.cuda() outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() acc = correct / total print(f"Finetune Epoch {epoch}, val_acc: {acc:.4f}") if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'vgg_pruned_best.pth')逻辑说明:微调时用 StepLR 每 7 个 epoch 降一次学习率,帮助模型收敛到更优解。验证集精度是判断微调效果的指标,如果连续几个 epoch 不涨,就可以停了。保存最佳模型,避免最后过拟合。
参数说明:finetune_epochs一般 15-20,lr从 0.001 开始,如果精度不涨就降到 0.0005。weight_decay保持 1e-4 防止过拟合。如果训练集小,可以加数据增强。
4.3 精度恢复的预期与调整
微调后精度能恢复多少,取决于剪枝比例和微调策略。我这次剪到 3M,精度从 baseline 的 92% 掉到 78%,微调后恢复到 88% 左右,掉了 4 个点,可以接受。如果掉太多,说明剪枝太狠,需要降低prune_ratio重新剪。常见做法是剪枝后先微调一轮,看精度恢复情况,如果恢复不到可接受范围,就回退到剪枝前,减小剪枝比例再试。
5. 避坑与排查:剪枝路上常见的五个翻车点
5.1 稀疏训练后权重没变稀疏
现象:稀疏训练跑完,zero_ratio 还是 0.1 左右,权重分布和训练前差不多。原因:sparsity_lambda太小,L1 正则没起作用。解决:把sparsity_lambda加大 10 倍,比如从 1e-5 调到 1e-4,重新稀疏训练。如果还不行,检查get_sparse_params是不是没收集到卷积层权重。
5.2 剪枝后模型前向传播报错
现象:剪枝完一跑model(images)就报维度不匹配。原因:剪枝时只改了当前层的out_channels,没改下一层的in_channels。解决:写一个adjust_in_channels函数,遍历所有卷积层,把当前层的in_channels改成上一层剪枝后的out_channels。注意 MaxPool 层不改变通道数,跳过。
5.3 微调后精度不升反降
现象:微调几个 epoch 后验证集精度比剪枝后还低。原因:学习率太大,把剪枝后脆弱的权重直接打乱。解决:把微调学习率降到 1/10,比如从 0.001 降到 0.0001,重新微调。如果还不行,冻结前面层,只微调后面层。
5.4 模型大小没降到预期
现象:剪枝完统计参数量,发现还是 10M 以上。原因:全连接层没剪,VGGNet 全连接层占了大头。解决:对全连接层也做剪枝,按权重 L1 范数剪掉一部分输出神经元,或者直接把全连接层换成全局平均池化加小全连接。
5.5 剪枝后推理速度没提升
现象:模型参数量降了,但推理时间没变。原因:用了非结构化剪枝,权重虽然稀疏了,但计算还是稠密的。解决:改用结构化剪枝,直接砍卷积核或通道,推理框架才能真实加速。
6. 进阶技巧:用敏感度分析决定每层剪多少
一刀切剪枝比例是懒人做法,精度掉得多。更精细的做法是做敏感度分析:对每一层单独剪枝,看精度掉多少,掉得少的层多剪,掉得多的层少剪。具体操作是,固定其他层不变,只剪当前层 10%、20%、30%……记录精度变化,画一条曲线,找到精度骤降的拐点,拐点之前就是安全剪枝比例。
我一般会写一个循环,对每个卷积层做一次剪枝+微调+评估,记录精度。这个过程比较耗时,但能帮你把每层剪枝比例调到最优。最后按敏感度结果逐层剪枝,再整体微调一次,精度通常比一刀切高 2-3 个点。
另一个技巧是迭代剪枝:不要一次剪到位,而是剪一点、微调一点、再剪一点。比如先剪 20%,微调恢复,再剪 20%,再微调。这样模型有时间适应新结构,最终能剪到更小。我这次压到 3M 就是用了三轮迭代剪枝,每轮剪 30% 左右,微调 10 个 epoch,最后精度稳在 88%。
希望帮到你。
本文还有配套的精品资源,点击获取