第四章:模型压缩技术:剪枝、蒸馏与低秩分解

模型压缩,说白了就是给深度学习模型「减肥」。

我在嵌入式端摸爬滚打这些年,最深的体会就是:模型再牛逼,跑不动也是白搭。你想想看,一个几百兆的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%以上的参数。但问题也很明显——稀疏矩阵在通用硬件上加速效果很差

⚠️ 我曾经在一个项目里,把MobileNetV2剪掉了85%的权重,模型大小从14MB降到了2MB。结果在树莓派上一跑,推理时间反而增加了30%。为什么?因为CPU和GPU对非结构化稀疏的支持非常有限,稀疏矩阵的存储和计算反而带来了额外开销。

所以,非结构化剪枝更适合那些有专用硬件(比如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%。

💡 小技巧:剪枝后一定要做微调(fine-tune)。我习惯用较小的学习率(比如原学习率的1/10),训练5-10个epoch。不微调的话,精度可能会掉得让你怀疑人生。

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维张量,分解起来更复杂。我一般只对全连接层和1x1卷积做SVD分解。对于3x3以上的卷积,效果不如剪枝来得直接。

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上跑到了实时。

所以我的建议是:先搞清楚你的目标硬件支持什么,再选压缩方案。别为了追求压缩率,搞出一个跑不动的模型。

好了,这一章的内容就到这里。下一章我们会聊聊量化——另一种更激进的压缩手段,也是嵌入式端最常用的优化技术之一。