3. 模型剪枝技术

模型剪枝,说白了就是给神经网络“瘦身”。

你想想看,一个训练好的大模型,里面其实有很多参数是冗余的。有些神经元压根不干活,有些权重小到可以忽略。把它们去掉,模型照样能跑,而且跑得更快。

我在项目中遇到过好几次这种情况:模型在服务器上精度不错,一搬到嵌入式设备上,推理速度直接卡成PPT。后来发现,不是模型结构有问题,是参数太多了,内存带宽扛不住。剪枝就是解决这个问题的利器。

3.1 非结构化剪枝 vs 结构化剪枝

剪枝分两大类。先看个对比表格,心里有个底。

对比维度 非结构化剪枝 结构化剪枝
剪枝粒度 单个权重/神经元 通道/卷积核/层
稀疏模式 不规则稀疏 规则稀疏
硬件加速 需要专用硬件/库 通用硬件直接加速
压缩率上限 高(可到90%+) 中等(通常30%-60%)
精度恢复难度 较容易微调恢复 微调难度稍高
部署友好度 低(需稀疏计算库) 高(直接减少计算量)

非结构化剪枝,就是直接把值小的权重置为零。剪完之后,权重矩阵变成稀疏矩阵。好处是压缩率可以很高,坏处是——嗯,硬件不买账。CPU和GPU做密集矩阵乘法很快,但遇到不规则分布的零,反而会变慢。除非你用专门的稀疏计算库,比如NVIDIA的cuSPARSE,或者某些NPU支持细粒度稀疏。

结构化剪枝,则是整块整块地砍。比如把某个卷积核整个删掉,或者把某个通道全部移除。剪完之后,模型结构变小了,但依然是密集的。任何硬件都能直接受益,不需要特殊支持。我个人习惯在边缘端部署时优先考虑结构化剪枝,省心。

核心结论:边缘端部署,结构化剪枝是首选。非结构化剪枝更适合云端推理,或者你的目标芯片明确支持稀疏计算。

3.2 剪枝策略与实现

剪枝不是瞎剪。常见的策略有三种,我按推荐顺序讲。

3.2.1 训练后剪枝(Post-training Pruning)

模型训练完,直接剪。最简单,但精度损失也最大。适合对精度要求不高的场景,或者模型本身就有很大冗余。

3.2.2 训练中剪枝(Training-aware Pruning)

训练过程中边训边剪。比如每训练几个epoch,就剪掉一批权重,然后继续训练。精度比训练后剪枝好不少。我在项目中常用这种方法,效果比较稳。

3.2.3 迭代剪枝(Iterative Pruning)

剪一点,微调恢复,再剪一点,再微调。循环往复,直到达到目标压缩率。这是精度最高的方法,但也是最耗时的。我曾经为了把一个检测模型压缩到原来的1/4,迭代了5轮,每轮微调50个epoch。效果确实好,精度只掉了0.3%。

下面是一个基于PyTorch的非结构化剪枝代码示例,用的是训练后剪枝策略。

import torch
import torch.nn.utils.prune as prune

# 假设 model 是一个训练好的 CNN
model = torch.load('trained_model.pth')

# 对第一个卷积层进行 L1 非结构化剪枝,剪掉 30% 的权重
prune.l1_unstructured(
    model.conv1,
    name='weight',
    amount=0.3
)

# 剪枝后,权重被替换为参数化版本
# 原始权重保存在 weight_orig,掩码保存在 weight_mask
print(model.conv1.weight_mask)

# 移除剪枝参数化,使剪枝永久生效
prune.remove(model.conv1, 'weight')

# 保存剪枝后的模型
torch.save(model.state_dict(), 'pruned_model.pth')

小技巧:剪枝时,amount 参数可以传一个浮点数(比例),也可以传一个整数(保留的权重数量)。我建议先用比例试,观察精度变化,再微调。

结构化剪枝的实现稍微复杂一点。以通道剪枝为例,你需要找到每个通道的重要性指标,比如L1范数,然后砍掉范数最小的通道。

import torch
import torch.nn as nn

def channel_prune(conv_layer, prune_ratio):
    """
    对卷积层进行通道剪枝
    :param conv_layer: nn.Conv2d 层
    :param prune_ratio: 剪枝比例 (0~1)
    """
    # 计算每个输出通道的 L1 范数
    l1_norm = conv_layer.weight.abs().sum(dim=(1, 2, 3))
    
    # 确定要保留的通道数
    num_channels = conv_layer.out_channels
    num_keep = int(num_channels * (1 - prune_ratio))
    
    # 找到最重要的通道索引
    _, indices = torch.topk(l1_norm, num_keep)
    
    # 只保留这些通道
    conv_layer.weight.data = conv_layer.weight.data[indices]
    if conv_layer.bias is not None:
        conv_layer.bias.data = conv_layer.bias.data[indices]
    
    # 更新 out_channels
    conv_layer.out_channels = num_keep
    
    return conv_layer, indices

# 使用示例
model.conv1, kept_indices = channel_prune(model.conv1, prune_ratio=0.4)

注意:通道剪枝后,下一层的输入通道数也要同步修改。否则维度对不上。我刚开始做的时候忘了这茬,debug了一下午。切记,剪枝是连锁反应,上下游都要照顾到。

3.3 剪枝后微调

剪完枝,模型精度一定会掉。掉多掉少的问题。微调就是把这个精度捡回来。

微调的策略很简单:用原来的训练数据,以较小的学习率(通常是原学习率的1/10到1/100),再训练几个epoch。为什么学习率要小?因为剪枝后的模型已经接近局部最优了,学习率太大容易跑飞。

我一般这样操作:

  1. 先评估:剪枝后,在验证集上跑一次,记录精度下降幅度。
  2. 设置学习率:如果精度下降小于2%,学习率设为原学习率的1/10。如果下降超过5%,学习率设为1/50,甚至1/100。
  3. 微调轮数:通常10-20个epoch就够了。如果模型很大,可以适当增加。
  4. 早停:监控验证集精度,一旦不再提升,立即停止。不要过拟合。

经验之谈:迭代剪枝+微调的组合,是我在边缘端部署项目中最常用的套路。每轮剪枝10%-20%,然后微调恢复。这样精度损失可控,最终压缩率也能做得很高。

下面是一个微调代码片段,配合上面的剪枝代码使用。

import torch.optim as optim

# 加载剪枝后的模型
model.load_state_dict(torch.load('pruned_model.pth'))
model.train()

# 使用较小的学习率
optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
criterion = nn.CrossEntropyLoss()

# 微调 15 个 epoch
for epoch in range(15):
    running_loss = 0.0
    for inputs, labels in train_loader:
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        running_loss += loss.item()
    
    print(f'Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}')

# 保存微调后的模型
torch.save(model.state_dict(), 'pruned_finetuned_model.pth')

微调之后,别忘了再评估一次。如果精度恢复到可接受范围,就可以部署了。如果恢复得不好,说明剪枝太狠了,需要降低剪枝比例,或者改用更精细的剪枝策略。

避坑指南:我曾经在一个语义分割模型上,剪枝后微调了30个epoch,精度反而比剪枝前还高了0.1%。后来分析发现,剪枝起到了正则化的作用,帮助模型摆脱了过拟合。所以,剪枝不一定是坏事,有时候还能带来意外惊喜。

3.4 剪枝知识体系总览

下面这张SVG图,把本章的核心逻辑串起来了。从剪枝分类到策略选择,再到微调恢复,一目了然。

模型剪枝知识体系 模型剪枝 非结构化剪枝 结构化剪枝 训练后剪枝 训练中剪枝 迭代剪枝 剪枝后微调恢复精度 边缘端部署 结构化剪枝 + 迭代剪枝 + 微调 = 边缘端部署的最佳实践组合

剪枝不是一锤子买卖。它是一个“剪-评估-微调”的循环。你剪得越多,微调的压力就越大。找到那个精度和速度的平衡点,才是关键。

嗯,这一章的内容就到这里。记住,剪枝是手段,不是目的。我们的目标是在边缘端把模型跑起来,跑得快,跑得稳。