4、模型剪枝技术:结构化剪枝与非结构化剪枝、剪枝策略(L1/L2范数)、剪枝后微调

模型剪枝,说白了就是给神经网络「减肥」。

我在嵌入式AI项目里摸爬滚打这么多年,最深的体会就是:模型太大,跑不动。你想想看,一个几百兆的模型,塞到只有几兆内存的MCU里,那不是开玩笑吗?剪枝就是解决这个问题的利器。

4.1 非结构化剪枝:简单粗暴,但有点「野」

非结构化剪枝,我习惯叫它「细粒度剪枝」。它的思路很简单:把权重矩阵里那些绝对值很小的参数直接干掉。

为什么要干掉小参数?因为绝对值小,意味着它对最终输出的贡献也小。干掉它,模型精度损失不大,但模型体积能小不少。

核心思想: 权重绝对值 → 排序 → 干掉排名靠后的

举个例子,一个卷积层有100万个参数,我们设定剪枝率50%。那就把绝对值最小的50万个参数置为0。嗯,就是这么直接。

# 非结构化剪枝示例(PyTorch风格)
import torch
import torch.nn.utils.prune as prune

# 假设我们有一个训练好的卷积层
conv_layer = torch.nn.Conv2d(3, 64, kernel_size=3)

# L1范数非结构化剪枝,剪掉50%的参数
prune.l1_unstructured(conv_layer, name='weight', amount=0.5)

# 看看效果
print(f"原始参数数量: {conv_layer.weight.nelement()}")
print(f"非零参数数量: {torch.sum(conv_layer.weight != 0).item()}")

我在项目中遇到过一个问题:非结构化剪枝后,模型确实变小了,但推理速度没怎么提升。为什么?因为硬件对稀疏矩阵的支持很差。你想想看,CPU和GPU都是为密集矩阵运算优化的,突然来一堆0,反而要额外判断,速度不升反降。

避坑指南: 我曾经在一个Cortex-M7的项目里用非结构化剪枝,模型体积从10MB降到了3MB,但推理时间反而增加了20%。后来查资料才发现,MCU的SIMD指令对稀疏矩阵支持极差。所以,非结构化剪枝更适合存储受限、但计算资源相对充裕的场景。

4.2 结构化剪枝:规整、高效、硬件友好

结构化剪枝就不一样了。它不是单个参数地剪,而是整行、整列、整个通道地剪。说白了,就是直接砍掉一个卷积核,或者砍掉一个全连接层的神经元。

这样做的好处很明显:剪完之后,模型结构还是规整的矩阵,硬件跑起来毫无压力。

对比项 非结构化剪枝 结构化剪枝
剪枝粒度 单个参数 通道/卷积核/神经元
硬件加速 差(稀疏矩阵) 好(密集矩阵)
压缩率 高(可到90%+) 中等(通常30%-50%)
精度损失 较小 较大(需要微调补偿)
部署难度 高(需要特殊库支持) 低(直接删层即可)
# 结构化剪枝示例:按通道剪枝
# 计算每个通道的L2范数,干掉范数小的通道

def channel_prune(conv_layer, prune_ratio):
    # 获取卷积核权重 shape: [out_channels, in_channels, k_h, k_w]
    weight = conv_layer.weight.data
    
    # 计算每个输出通道的L2范数
    l2_norm = torch.norm(weight.view(weight.size(0), -1), dim=1)
    
    # 找到要保留的通道索引
    num_keep = int(weight.size(0) * (1 - prune_ratio))
    _, indices = torch.topk(l2_norm, num_keep)
    
    # 只保留这些通道
    conv_layer.weight.data = weight[indices]
    if conv_layer.bias is not None:
        conv_layer.bias.data = conv_layer.bias[indices]
    
    return conv_layer, indices

我个人习惯在嵌入式项目里优先用结构化剪枝。虽然压缩率不如非结构化,但部署起来省心。你想想看,直接删掉几个卷积层,模型结构图都变简单了,推理框架不用做任何特殊处理。

4.3 剪枝策略:L1范数 vs L2范数

剪枝的时候,总得有个标准来判断「谁该被剪掉」吧?最常用的就是L1范数和L2范数。

L1范数剪枝: 计算每个通道(或每个参数)的绝对值之和。L1范数小的,说明这个通道整体激活值偏小,贡献不大,剪掉它。

L2范数剪枝: 计算平方和的平方根。L2范数更关注「能量」分布。如果一个通道的L2范数很小,说明它的权重整体偏小,对输出的影响也小。

我的经验: L1范数剪枝更容易产生稀疏解,剪得更「干净」。L2范数剪枝则更平滑,保留的通道质量更高。我在一个语音唤醒模型上做过对比:L1剪枝后精度掉了2.3%,微调后恢复到0.8%;L2剪枝后精度掉了1.5%,微调后恢复到0.3%。所以,如果对精度要求高,我建议优先试L2。

# L1 vs L2 范数计算对比
def l1_prune_score(weight):
    # 对每个输出通道计算L1范数
    return torch.sum(torch.abs(weight.view(weight.size(0), -1)), dim=1)

def l2_prune_score(weight):
    # 对每个输出通道计算L2范数
    return torch.norm(weight.view(weight.size(0), -1), dim=1)

# 实际使用
weight = torch.randn(64, 3, 3, 3)  # 64个输出通道
l1_scores = l1_prune_score(weight)
l2_scores = l2_prune_score(weight)

print(f"L1 scores: {l1_scores[:5]}")
print(f"L2 scores: {l2_scores[:5]}")

4.4 剪枝后微调:别指望一刀切

剪枝完直接部署?别急,精度大概率会掉。这时候就需要微调(Fine-tuning)。

微调的原理很简单:剪枝后的模型结构变了,权重也变了,需要重新适应数据分布。通常的做法是,用原始训练数据,以较小的学习率再训练几个epoch。

我建议的微调策略:

  • 学习率: 原始学习率的1/10到1/100。剪枝后的模型比较脆弱,学习率大了容易崩。
  • 微调轮数: 通常5-10个epoch就够了。太多容易过拟合,太少恢复不过来。
  • 逐层微调: 如果剪枝率很高(比如超过50%),我习惯先冻结未剪枝的层,只微调被剪过的层。等这些层稳定了,再全模型微调。

关键点: 剪枝后的微调不是从头训练,而是「修复」。目的是让模型适应新的结构,而不是学习新特征。

# 剪枝后微调示例
def prune_and_finetune(model, train_loader, prune_ratio=0.3, epochs=10):
    # 1. 先剪枝
    for name, module in model.named_modules():
        if isinstance(module, torch.nn.Conv2d):
            # 结构化剪枝:按L2范数剪掉30%的通道
            module, _ = channel_prune(module, prune_ratio)
    
    # 2. 设置小学习率
    optimizer = torch.optim.SGD(model.parameters(), lr=0.001)  # 原始可能是0.01
    
    # 3. 微调
    for epoch in range(epochs):
        for inputs, labels in train_loader:
            outputs = model(inputs)
            loss = torch.nn.functional.cross_entropy(outputs, labels)
            
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
        
        print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}")
    
    return model

我曾经在一个目标检测模型上试过,剪掉40%的通道后,精度从85%掉到了78%。微调了8个epoch后,精度恢复到了84.2%。虽然没完全恢复,但模型体积小了40%,推理速度快了35%,这个trade-off我觉得很值。

注意: 微调不是万能的。如果剪枝率过高(比如超过70%),模型容量已经不够了,再怎么微调也回不去。这时候就要考虑重新设计网络结构,或者用知识蒸馏等其他技术。

总结一下我的剪枝流程:

  1. 先训练一个基线模型,记录精度
  2. 用L2范数做结构化剪枝,从10%开始试
  3. 剪完后用1/10学习率微调5个epoch
  4. 看精度恢复情况,如果恢复得好,继续加大剪枝率
  5. 找到精度和速度的平衡点

嗯,这就是我这些年做模型剪枝的实战经验。说白了,剪枝不是目的,让模型在嵌入式设备上跑起来才是。别追求极致的压缩率,够用就好。