第四章:模型压缩技术:剪枝、蒸馏与低秩分解
模型压缩,说白了就是给深度学习模型「减肥」。
我在嵌入式端摸爬滚打这些年,最深的体会就是:模型再牛逼,跑不动也是白搭。你想想看,一个几百兆的ResNet-152,放到Cortex-M4上,连加载都成问题,更别提推理了。
所以这一章,我们来聊聊三种主流的压缩手段:剪枝、知识蒸馏、低秩分解。我会结合自己在项目中的踩坑经历,把它们的原理和嵌入式实践讲透。
4.1 剪枝:结构化 vs 非结构化
剪枝,就是砍掉模型中不重要的连接或神经元。我习惯把它比作修剪果树——剪掉冗余的枝条,让养分集中到结果的部分。
4.1.1 非结构化剪枝
非结构化剪枝,是对单个权重下手。设定一个阈值,低于阈值的权重直接置零。
# 伪代码示例:非结构化剪枝
def unstructured_prune(model, threshold=0.01):
for param in model.parameters():
mask = torch.abs(param) > threshold
param.data *= mask # 小于阈值的置零
return model
这样做的好处是压缩率高,理论上可以砍掉90%以上的参数。但问题也很明显——稀疏矩阵在通用硬件上加速效果很差。
所以,非结构化剪枝更适合那些有专用硬件(比如NVIDIA的稀疏Tensor Core)的平台。在嵌入式端,我个人不太推荐。
4.1.2 结构化剪枝
结构化剪枝,是以通道、卷积核甚至整个层为单位进行裁剪。它不破坏模型的结构,所以对硬件非常友好。
# 伪代码示例:通道剪枝(基于L1范数)
def channel_prune(model, prune_ratio=0.3):
for name, module in model.named_modules():
if isinstance(module, nn.Conv2d):
# 计算每个卷积核的L1范数
l1_norm = torch.sum(torch.abs(module.weight), dim=(1,2,3))
# 找到要保留的通道索引
num_keep = int(module.out_channels * (1 - prune_ratio))
_, indices = torch.topk(l1_norm, num_keep)
# 裁剪卷积核
module.weight.data = module.weight.data[indices]
module.bias.data = module.bias.data[indices] if module.bias is not None else None
return model
结构化剪枝的压缩率虽然不如非结构化,但胜在立竿见影。我在STM32H7上做过测试,对MobileNetV1进行30%的通道剪枝,推理速度提升了近40%,精度只掉了不到1%。
4.2 知识蒸馏:大模型教小模型
知识蒸馏,核心思想就一句话:让一个小模型(学生)去模仿一个大模型(老师)的行为。
为什么这么做有效?因为老师模型输出的软标签(soft label)包含了类别之间的相似性信息。比如一张猫的图片,老师模型可能输出「猫:0.9, 狗:0.08, 老虎:0.02」。这个分布告诉学生:猫和狗更像,和老虎差得远。这些信息是硬标签(one-hot)给不了的。
# 知识蒸馏损失函数示例
def distillation_loss(student_logits, teacher_logits, labels, T=4, alpha=0.7):
# 软标签损失(KL散度)
soft_loss = nn.KLDivLoss()(
F.log_softmax(student_logits / T, dim=1),
F.softmax(teacher_logits / T, dim=1)
) * (T * T)
# 硬标签损失(交叉熵)
hard_loss = nn.CrossEntropyLoss()(student_logits, labels)
# 加权组合
return alpha * soft_loss + (1 - alpha) * hard_loss
温度T是个关键参数。T越大,软标签的分布越平滑,学生能学到更多「暗知识」。我一般从T=4开始调,效果不好再往上加。
嵌入式实践要点:
- 老师模型不一定要很大。我用ResNet-18教MobileNetV2,效果就比用ResNet-50教差不了太多,但训练速度快了一倍。
- 蒸馏后的模型可以直接部署,不需要额外处理。
- 如果数据量少,蒸馏效果可能不如直接训练小模型。我踩过这个坑,后来加了数据增强才解决。
4.3 低秩分解:矩阵的「降维打击」
低秩分解,本质上是把一个大矩阵拆成几个小矩阵的乘积。比如一个m×n的矩阵,可以拆成m×r和r×n两个矩阵。如果r远小于m和n,参数总量就大大减少了。
在卷积神经网络中,最常用的是奇异值分解(SVD)。我拿全连接层举个例子:
# SVD分解全连接层权重
import numpy as np
def svd_decompose(weight_matrix, rank_ratio=0.5):
U, S, Vt = np.linalg.svd(weight_matrix, full_matrices=False)
# 保留前k个奇异值
k = int(weight_matrix.shape[0] * rank_ratio)
U_k = U[:, :k]
S_k = np.diag(S[:k])
Vt_k = Vt[:k, :]
# 分解后的两个矩阵
W1 = U_k @ S_k # shape: (m, k)
W2 = Vt_k # shape: (k, n)
return W1, W2
分解后,原来的一个全连接层变成了两个小全连接层。参数量从m×n变成了m×k + k×n。如果k取m的一半,参数量大约减少一半。
4.4 嵌入式端的实践策略
讲了这么多理论,最后说说我在实际项目中的组合策略。
| 场景 | 推荐方案 | 压缩率 | 精度损失 |
|---|---|---|---|
| MCU(如STM32) | 结构化剪枝 + 知识蒸馏 | 3-5x | <2% |
| 边缘AI芯片(如RK3588) | 结构化剪枝 + 低秩分解 | 4-8x | <1% |
| 手机端NPU | 非结构化剪枝(需硬件支持)+ 蒸馏 | 8-15x | <3% |
我个人习惯的流程是:先用知识蒸馏得到一个精度不错的小模型,再用结构化剪枝进一步压缩,最后用低秩分解收尾。三步走下来,模型大小能降到原来的1/5到1/10,推理速度提升3-5倍。
避坑指南:
我曾经在一个语音唤醒项目里,一上来就用了非结构化剪枝,结果模型是变小了,但推理速度反而慢了。后来改成结构化剪枝+蒸馏的组合,才把模型从8MB压到1.2MB,在Cortex-M7上跑到了实时。
所以我的建议是:先搞清楚你的目标硬件支持什么,再选压缩方案。别为了追求压缩率,搞出一个跑不动的模型。
好了,这一章的内容就到这里。下一章我们会聊聊量化——另一种更激进的压缩手段,也是嵌入式端最常用的优化技术之一。