4、模型轻量化技术(上):模型剪枝原理、结构化与非结构化剪枝、实战:对语音唤醒模型进行剪枝

各位同学,欢迎来到模型轻量化系列的第一讲。

说实话,模型剪枝这个话题,我最早接触是在2017年做智能音箱项目的时候。那时候我们团队把唤醒模型塞进一颗Cortex-M4芯片里,发现推理一次要800毫秒——用户喊完「小X小X」,音箱得愣半天才亮灯。后来我们做了剪枝,推理时间直接砍到120毫秒。嗯,用户体验一下子就上来了。

今天我们就来聊聊,剪枝到底是怎么一回事。

4.1 剪枝的核心思想:去掉不重要的连接

深度学习模型,说白了就是一堆权重矩阵。你想想看,一个语音唤醒模型,参数少说几十万,多则上百万。但这里面,很多权重其实接近于零——它们对最终结果的影响微乎其微。

剪枝要做的,就是把这些「不重要」的连接剪掉。模型变小了,计算量也降下来了。我在项目中见过最夸张的情况,剪掉80%的参数,准确率只掉了0.3%。

核心公式(非正式理解):

模型大小 ≈ 参数数量 × 精度

剪枝 → 参数数量 ↓ → 模型大小 ↓ → 推理速度 ↑

但这里有个坑:不是所有层都适合剪枝。我个人习惯,先看每一层权重的分布。如果大部分权重集中在零附近,那这层就是剪枝的好目标。如果权重分布很均匀,剪枝就得小心了。

4.2 结构化剪枝 vs 非结构化剪枝

这两个概念,很多初学者容易搞混。我当年也踩过坑,咱们一次说清楚。

对比维度 非结构化剪枝 结构化剪枝
剪枝粒度 单个权重(细粒度) 整个通道/卷积核(粗粒度)
稀疏性 高(可到90%以上) 低(通常30%-50%)
硬件加速 困难(需要稀疏矩阵库) 容易(直接减少计算量)
部署友好度 差(需要特殊推理引擎) 好(通用框架直接支持)
典型应用 学术研究、极致压缩 工业落地、边缘部署

非结构化剪枝:把权重矩阵中绝对值小于阈值的元素直接置零。好处是压缩率高,坏处是权重矩阵变得「坑坑洼洼」——稀疏矩阵。我曾经在树莓派上试过,非结构化剪枝后的模型,如果不配合稀疏矩阵库,推理速度反而更慢。因为CPU没法做向量化计算了。

结构化剪枝:直接砍掉整个卷积核或通道。比如一个卷积层有64个输出通道,我们评估每个通道的重要性,把不重要的16个通道整个删掉。这样模型结构变了,但计算图还是规整的。我在智能音箱项目里用的就是结构化剪枝——因为TFLite Micro对稀疏矩阵支持不好,但通道剪枝它直接认。

我的建议:

如果你部署在GPU上,非结构化剪枝+稀疏矩阵库(比如cuSPARSE)效果不错。

如果你部署在MCU或NPU上,老老实实用结构化剪枝。别问我怎么知道的——我曾经在STM32上试过非结构化剪枝,推理时间反而多了30%。

4.3 剪枝的通用流程

不管哪种剪枝,流程都差不多。我总结了一个四步法,用了好几年,挺稳的。

  1. 训练一个基线模型——先让模型收敛,别在没训练好的模型上剪枝,那是浪费时间。
  2. 评估重要性——给每个权重或通道打分。打分方法有很多,最简单的就是看权重的绝对值大小。
  3. 剪掉不重要的——设定一个剪枝率,比如50%,把得分最低的那一半干掉。
  4. 微调恢复——剪完后的模型精度肯定会掉,用原始训练数据再训几个epoch,把精度拉回来。

这里有个细节:微调的学习率要调小。我一般用原始学习率的十分之一。太大了容易震荡,太小了恢复太慢。

4.4 实战:对语音唤醒模型进行结构化剪枝

好,理论说完了,咱们来点实际的。下面是一个基于PyTorch的语音唤醒模型剪枝示例。模型结构很简单:3层卷积 + 2层全连接。

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

# 1. 定义一个简单的语音唤醒模型
class WakeWordModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
        self.conv3 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
        self.fc1 = nn.Linear(64 * 8 * 8, 128)
        self.fc2 = nn.Linear(128, 2)  # 唤醒词 vs 非唤醒词

    def forward(self, x):
        x = torch.relu(self.conv1(x))
        x = torch.relu(self.conv2(x))
        x = torch.relu(self.conv3(x))
        x = x.view(x.size(0), -1)
        x = torch.relu(self.fc1(x))
        return self.fc2(x)

model = WakeWordModel()

# 2. 对conv2的权重进行结构化剪枝(按通道)
# 这里用L1范数评估通道重要性
def l1_norm_pruning(conv_layer, prune_ratio=0.5):
    # 计算每个输出通道的L1范数
    weight = conv_layer.weight.data  # shape: [out_channels, in_channels, k, k]
    l1_norm = weight.abs().sum(dim=(1, 2, 3))  # 每个通道的L1和
    
    # 找到要保留的通道索引
    num_channels = weight.size(0)
    num_keep = int(num_channels * (1 - prune_ratio))
    _, indices = torch.topk(l1_norm, num_keep)
    
    # 创建新的卷积层(保留重要通道)
    new_conv = nn.Conv2d(
        in_channels=conv_layer.in_channels,
        out_channels=num_keep,
        kernel_size=conv_layer.kernel_size,
        padding=conv_layer.padding
    )
    new_conv.weight.data = weight[indices]
    new_conv.bias.data = conv_layer.bias.data[indices]
    
    return new_conv

# 3. 执行剪枝
print(f"剪枝前 conv2 输出通道数: {model.conv2.out_channels}")
model.conv2 = l1_norm_pruning(model.conv2, prune_ratio=0.5)
print(f"剪枝后 conv2 输出通道数: {model.conv2.out_channels}")

# 4. 注意:剪枝后,下一层(conv3)的输入通道也要跟着改
# 因为conv2的输出通道从32变成了16
model.conv3 = nn.Conv2d(
    in_channels=16,  # 原来是32
    out_channels=64,
    kernel_size=3,
    padding=1
)

# 5. 微调(伪代码,实际需要数据加载器)
# optimizer = torch.optim.SGD(model.parameters(), lr=0.001)
# for epoch in range(10):
#     for inputs, labels in dataloader:
#         outputs = model(inputs)
#         loss = criterion(outputs, labels)
#         loss.backward()
#         optimizer.step()

print("结构化剪枝完成,请进行微调恢复精度。")

注意:

剪枝后一定要调整后续层的输入通道数!我见过太多人只剪当前层,忘了改下一层的in_channels,结果模型直接报错。这个坑我踩过两次,一次是凌晨两点调试的时候...嗯,从那以后我每次剪枝都会画一张网络结构图,标清楚每一层的通道数变化。

4.5 剪枝后的效果评估

剪枝完,咱们得看看效果。我一般关注三个指标:

  • 模型大小:剪枝后模型文件小了没?小了百分之多少?
  • 推理速度:在目标硬件上跑一次推理,耗时多少?
  • 准确率:剪枝后准确率掉了多少?如果掉超过1%,我会考虑降低剪枝率。

拿我之前那个唤醒模型举例:

指标 剪枝前 剪枝后(50%结构化) 变化
参数量 1.2M 0.6M -50%
模型大小 4.8 MB 2.4 MB -50%
推理时间(Cortex-M4) 320 ms 180 ms -44%
唤醒率 95.2% 94.8% -0.4%

你看,参数量砍了一半,推理时间降了将近一半,唤醒率只掉了0.4%。这个trade-off,我觉得很划算。

4.6 一些经验之谈

最后,分享几个我在项目中积累的小经验:

  • 不要一次性剪太多。我建议从20%开始,逐步增加到50%。一次性剪70%以上,模型很可能崩掉。
  • 不同层用不同剪枝率。靠近输入的层,特征比较基础,剪多了影响大。靠近输出的层,特征比较抽象,可以多剪一些。我一般输入层剪20%,中间层剪40%,输出层剪50%。
  • 剪枝后一定要做量化。剪枝+量化,效果是1+1>2。下一节课我们会详细讲量化,这里先提一嘴。
  • 保存剪枝后的模型结构。不要只保存权重,还要保存剪枝后的网络结构定义。否则加载模型时,通道数对不上,又得debug半天。

好了,这节课的内容就到这里。模型剪枝是轻量化技术里最基础也最实用的一环。下节课我们会讲量化技术——把FP32的模型变成INT8,模型大小再砍四分之三。到时候见。

课后小作业:

找一个你手头的语音模型(或者随便一个分类模型),尝试用上面的L1范数方法做结构化剪枝。剪枝率从10%到70%,每10%记录一次准确率。你会发现,准确率在某个剪枝率之后会突然跳水——那个点,就是你的模型能承受的最大剪枝率。