3、模型轻量化技术(上):模型剪枝(Pruning)原理

各位同学,咱们今天聊聊模型剪枝。说实话,这是我在嵌入式部署中最常用的招数之一。你想想看,一个动辄几百兆的模型,要塞进只有几兆Flash的MCU里,不剪几刀怎么行?

我记得刚入行那会儿,接手一个视觉检测项目。模型在PC上跑得飞起,一上ARM Cortex-M4,直接内存溢出。当时我就在想——模型里那么多参数,真的每个都那么重要吗?后来发现,还真不是。

3.1 为什么需要模型剪枝?

深度学习模型有个特点:过度参数化。说白了,就是参数太多了,很多是冗余的。有研究表明,一个训练好的模型里,可能有50%-90%的权重可以去掉,而精度几乎不受影响。

剪枝的好处很明显:

  • 模型变小——存储空间省了
  • 计算变快——推理延迟降了
  • 功耗降低——电池续航长了

我在项目中遇到过最极端的情况:一个3MB的人脸识别模型,剪枝后只剩400KB,精度只掉了0.3%。嗯,这种收益,谁用谁知道。

核心思想:剪枝就是去掉不重要的连接或神经元,保留关键信息通路。

3.2 剪枝的基本流程

剪枝不是瞎剪的,一般遵循这个套路:

  1. 训练一个基准模型——先让模型收敛到不错的状态
  2. 评估参数重要性——判断哪些权重可以砍掉
  3. 剪掉不重要的参数——置零或直接移除
  4. 微调(Fine-tune)——让剩下的参数重新适应
  5. 迭代——重复2-4步,直到达到目标

这里有个坑:一次性剪太多。我曾经试过一步到位剪掉80%,结果模型直接崩了,精度掉到30%。后来学乖了,每次剪10%-20%,微调后再剪,效果就好多了。

避坑指南:我曾经在剪枝后忘记微调,直接部署到板子上,结果识别率惨不忍睹。记住:剪枝后的微调不是可选项,是必选项。

3.3 结构化剪枝 vs 非结构化剪枝

这是剪枝里最重要的分类。我刚开始也搞混过,咱们捋一捋。

3.3.1 非结构化剪枝(Unstructured Pruning)

非结构化剪枝,说白了就是单个权重级别的剪枝。把绝对值小的权重直接置零。

举个例子:

# 非结构化剪枝示例
import numpy as np

# 假设这是某个卷积层的权重
weights = np.array([
    [0.5, -0.02, 0.8, 0.01],
    [-0.1, 0.6, -0.03, 0.9],
    [0.7, 0.02, -0.5, 0.04]
])

# 设置阈值,剪掉绝对值小于0.05的权重
threshold = 0.05
mask = np.abs(weights) > threshold
pruned_weights = weights * mask

print("原始权重:\n", weights)
print("剪枝后权重:\n", pruned_weights)

输出结果:

原始权重:
 [[ 0.5  -0.02  0.8   0.01]
 [-0.1   0.6  -0.03  0.9 ]
 [ 0.7   0.02 -0.5   0.04]]
剪枝后权重:
 [[ 0.5   0.    0.8   0.  ]
 [-0.1   0.6   0.    0.9 ]
 [ 0.7   0.   -0.5   0.  ]]

看到没?那些小权重直接变零了。但问题来了——权重矩阵变得稀疏了。稀疏矩阵在普通硬件上并不好加速,需要专门的稀疏计算库支持。

我的经验:非结构化剪枝适合在GPU上做推理加速,因为cuSPARSE等库能利用稀疏性。但在MCU或普通CPU上,收益有限。

3.3.2 结构化剪枝(Structured Pruning)

结构化剪枝是通道级别或滤波器级别的剪枝。直接砍掉整个卷积核或整个通道。

我画个图帮你理解:

原始卷积层:64个滤波器 → 输出64个通道
结构化剪枝后:保留32个滤波器 → 输出32个通道

这样做的好处是:模型结构变了,但仍然是密集矩阵。任何硬件都能直接加速,不需要特殊优化。

代码实现思路:

# 结构化剪枝示例(伪代码)
def prune_channels(layer, keep_ratio=0.5):
    """
    按通道重要性剪枝
    """
    # 计算每个通道的重要性(比如L1范数)
    importance = torch.norm(layer.weight, p=1, dim=(1,2,3))
    
    # 排序,保留最重要的通道
    num_keep = int(layer.out_channels * keep_ratio)
    _, indices = torch.topk(importance, num_keep)
    
    # 只保留这些通道
    layer.weight = layer.weight[indices]
    layer.bias = layer.bias[indices] if layer.bias is not None else None
    
    return layer

两种剪枝对比

对比项 非结构化剪枝 结构化剪枝
剪枝粒度 单个权重 通道/滤波器
压缩率 高(可到90%+) 中等(通常30%-50%)
硬件加速 需要稀疏计算支持 通用硬件直接加速
精度损失 较小 相对较大
部署难度

3.4 实际动手:剪枝一个简单模型

光说不练假把式。咱们用PyTorch动手剪一个简单的全连接网络。

先定义一个三层网络:

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

class SimpleNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(784, 256)
        self.fc2 = nn.Linear(256, 128)
        self.fc3 = nn.Linear(128, 10)
        
    def forward(self, x):
        x = torch.relu(self.fc1(x))
        x = torch.relu(self.fc2(x))
        x = self.fc3(x)
        return x

model = SimpleNet()
print("原始参数量:", sum(p.numel() for p in model.parameters()))

接下来,对fc1层做非结构化剪枝,剪掉20%的权重:

# 对fc1层应用L1非结构化剪枝
prune.l1_unstructured(model.fc1, name='weight', amount=0.2)

# 查看剪枝效果
print("fc1权重中零的比例:", 
      float(torch.sum(model.fc1.weight == 0)) / model.fc1.weight.numel())

再试试结构化剪枝,剪掉fc2层30%的通道:

# 对fc2层应用L1结构化剪枝(按神经元)
prune.l1_unstructured(model.fc2, name='weight', amount=0.3)

# 注意:PyTorch内置的prune工具对结构化剪枝支持有限
# 实际项目中我一般自己写剪枝逻辑

注意:PyTorch的prune模块默认是非结构化的。它只是把权重置零,并没有真正减少参数量。要真正减少模型大小,需要配合prune.remove()方法。

正确的做法是:

# 先应用剪枝
prune.l1_unstructured(model.fc1, name='weight', amount=0.2)

# 永久化剪枝(真正移除被剪掉的参数)
prune.remove(model.fc1, 'weight')

# 现在参数量真的减少了
print("剪枝后参数量:", sum(p.numel() for p in model.parameters()))

3.5 剪枝后的微调

剪完枝,模型精度肯定会掉。这时候需要微调。我一般这样做:

# 微调代码片段
optimizer = torch.optim.SGD(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()

for epoch in range(10):  # 微调10个epoch
    for data, target in dataloader:
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()
    
    print(f"Epoch {epoch}: Loss = {loss.item():.4f}")

微调的学习率一般设小一点,我习惯用原始训练学习率的十分之一。太大容易破坏已有的权重分布。

小技巧:剪枝后可以先做几个epoch的知识蒸馏,用原始模型当老师,剪枝后的模型当学生。这样精度恢复得更快。我在一个语音识别项目里试过,剪枝50%后蒸馏微调,精度只掉了0.1%。

3.6 实际部署中的注意事项

最后聊几个实战中容易踩的坑:

  • 剪枝率不是越高越好——我见过有人追求90%剪枝率,结果模型直接废了。一般从20%开始试,逐步增加。
  • 不同层敏感度不同——第一层和最后一层通常最敏感,剪多了精度掉得厉害。中间层可以多剪一些。
  • 剪枝后要重新校准BN层——如果模型有BatchNorm,剪枝后统计量会变,需要重新跑一遍前向传播来更新running_mean和running_var。
  • 硬件限制——有些NPU要求通道数是8或16的倍数,剪枝时要注意对齐。

好了,这一章咱们把剪枝的原理和两种主要方式都过了一遍。下一章我会继续讲量化(Quantization),那是另一个让模型变轻的利器。到时候咱们再聊怎么把剪枝和量化结合起来,效果1+1>2。

记住一句话:剪枝不是目的,部署才是。一切以最终在目标硬件上的运行效果为准。