4. 模型剪枝与蒸馏:结构化剪枝与非结构化剪枝、知识蒸馏原理、剪枝与蒸馏的联合优化策略

好,咱们今天聊点硬核的。模型剪枝和知识蒸馏,说白了就是给大模型“减肥”和“传功”。

我在边缘设备上部署模型时,最头疼的就是内存和算力不够。一个动辄几百兆的模型,根本塞不进嵌入式芯片。这时候,剪枝和蒸馏就成了救命稻草。

4.1 结构化剪枝与非结构化剪枝

剪枝,就是把神经网络里不重要的连接或神经元砍掉。但怎么砍,差别很大。

4.1.1 非结构化剪枝:精细但难落地

非结构化剪枝,是对单个权重下手。它会将绝对值小于某个阈值的权重直接置为零。

举个例子,一个卷积核是 3x3 的矩阵:

原始权重:
[[0.9, 0.1, 0.8],
 [0.05, 0.7, 0.02],
 [0.6, 0.3, 0.4]]

剪枝后(阈值 0.1):
[[0.9, 0.0, 0.8],
 [0.0, 0.7, 0.0],
 [0.6, 0.3, 0.4]]

你看,权重矩阵变得稀疏了。理论上,压缩率可以很高。但问题来了——

我曾经踩过这个坑:非结构化剪枝后的模型,虽然参数少了,但权重分布变得不规则。标准硬件(比如 GPU 的 Tensor Core)根本没法加速。你得依赖专门的稀疏矩阵计算库,比如 NVIDIA 的 cuSPARSE。在普通 CPU 上跑,甚至可能更慢。

说白了,非结构化剪枝适合学术研究,或者你有定制硬件。否则,我建议你慎重。

4.1.2 结构化剪枝:粗暴但高效

结构化剪枝,是整块整块地砍。比如直接去掉一个卷积核、一个通道,甚至一整层。

我习惯用 L1 范数 来评估卷积核的重要性。L1 范数小的,说明这个核学到的特征很弱,砍掉影响不大。

# 伪代码示例:按通道剪枝
def prune_channel(layer, prune_ratio):
    # 计算每个卷积核的 L1 范数
    l1_norm = torch.sum(torch.abs(layer.weight), dim=(1,2,3))
    # 按重要性排序
    _, indices = torch.sort(l1_norm)
    # 砍掉最不重要的 prune_ratio 比例的通道
    keep_indices = indices[int(len(indices) * prune_ratio):]
    layer.weight = torch.nn.Parameter(layer.weight[keep_indices])
    return layer

这样做的好处是,剪完后的模型结构是规整的。你不需要改推理代码,硬件也能充分利用。我在项目中用结构化剪枝,把 MobileNetV2 的通道数砍掉 30%,精度只掉了 0.5%,但推理速度提升了近 40%。

剪枝类型 粒度 硬件加速 压缩率 适用场景
非结构化 单个权重 困难 学术研究、定制硬件
结构化 通道/层 容易 中等 边缘设备、移动端部署

4.2 知识蒸馏原理

知识蒸馏,就是让一个小模型(学生)去模仿一个大模型(老师)的行为。你想想看,老师模型已经学会了复杂的特征分布,学生模型直接学这个分布,比学原始数据要容易得多。

核心公式很简单,但很巧妙:

损失函数 = α * 硬标签损失 + (1 - α) * 软标签损失

其中,软标签损失 = KL散度(学生输出, 老师输出)

这里的“软标签”,就是老师模型输出的概率分布。为了让分布更“软”,我们会引入一个温度参数 T:

softmax(x_i / T)

T 越大,分布越平滑,学生能学到更多类间关系。比如,一张猫的图片,老师可能输出“猫: 0.9, 狗: 0.08, 老虎: 0.02”。这个 0.08 和 0.02 就是宝贵的“暗知识”。

我的个人习惯:T 值一般设在 3 到 10 之间。T 太小,软标签接近硬标签,蒸馏效果不明显;T 太大,分布过于平滑,学生学不到细节。我通常先设 T=5,然后根据验证集精度微调。

4.3 剪枝与蒸馏的联合优化策略

单独用剪枝或蒸馏,效果都有限。但把它们结合起来,往往能产生 1+1 > 2 的效果。我常用的策略有三种:

4.3.1 先剪枝,后蒸馏

这是最直接的方法。先对大模型剪枝,得到一个中等大小的模型,再用蒸馏让学生模型去学。我在一个语音识别项目里用过,效果不错。但要注意,剪枝后的模型如果精度掉得太多,蒸馏时学生也学不好。

4.3.2 边剪枝,边蒸馏

在训练过程中,同时进行剪枝和蒸馏。每训练几个 epoch,就剪掉一部分不重要的权重,同时用蒸馏损失来约束学生模型。这样做的好处是,剪枝和蒸馏互相促进,模型能更快收敛。

# 伪代码:边剪枝边蒸馏
for epoch in range(epochs):
    for batch in dataloader:
        # 前向传播
        student_output = student_model(batch)
        teacher_output = teacher_model(batch)
        # 计算损失
        hard_loss = cross_entropy(student_output, labels)
        soft_loss = kl_divergence(student_output, teacher_output)
        total_loss = alpha * hard_loss + (1 - alpha) * soft_loss
        # 反向传播
        total_loss.backward()
        # 剪枝(每 5 个 epoch 执行一次)
        if epoch % 5 == 0:
            prune_model(student_model, prune_ratio=0.1)
        optimizer.step()

我曾经犯过一个错误:剪枝频率太高,导致模型还没恢复过来又被砍一刀,最终模型崩溃。后来我改成每 5 个 epoch 剪一次,每次只剪 10% 的通道,效果就稳定多了。

4.3.3 蒸馏辅助剪枝

这个方法比较高级。在剪枝时,不是用 L1 范数来评估重要性,而是用老师模型的输出来指导。如果一个通道被剪掉后,学生模型的输出与老师模型的输出差异很大,说明这个通道很重要,不能剪。

嗯,这里要注意,这种方法计算量比较大,但精度损失通常最小。适合对精度要求极高的场景。

总结一下我的经验

  • 如果追求快速部署,用结构化剪枝 + 后蒸馏。
  • 如果追求极致压缩率,用边剪枝边蒸馏。
  • 如果精度是第一位,用蒸馏辅助剪枝。

好了,这一章的内容就到这里。下一章我们会聊聊量化,那是另一个让模型“瘦身”的利器。