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 剪枝的通用流程
不管哪种剪枝,流程都差不多。我总结了一个四步法,用了好几年,挺稳的。
- 训练一个基线模型——先让模型收敛,别在没训练好的模型上剪枝,那是浪费时间。
- 评估重要性——给每个权重或通道打分。打分方法有很多,最简单的就是看权重的绝对值大小。
- 剪掉不重要的——设定一个剪枝率,比如50%,把得分最低的那一半干掉。
- 微调恢复——剪完后的模型精度肯定会掉,用原始训练数据再训几个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%记录一次准确率。你会发现,准确率在某个剪枝率之后会突然跳水——那个点,就是你的模型能承受的最大剪枝率。