4. 模型剪枝与知识蒸馏:结构化剪枝、非结构化剪枝、知识蒸馏原理与实战

模型量化咱们聊完了,接下来这个环节,说实话,是真正让模型「瘦身」的硬功夫。剪枝和蒸馏,这两个技术我经常放在一起用。为什么?因为它们解决的是不同层面的冗余问题。

剪枝,说白了就是砍掉模型里不重要的连接或者神经元。蒸馏呢,是让一个小模型去模仿大模型的「思考方式」。我个人的习惯是:先剪枝,再蒸馏,最后量化。三步走,效果最稳。

核心观点: 剪枝减少参数数量,蒸馏提升小模型质量。两者互补,不是替代关系。
模型压缩技术体系 模型剪枝 非结构化剪枝 结构化剪枝 权重置零 通道/层裁剪 稀疏矩阵存储 硬件加速友好 知识蒸馏 教师模型 学生模型 软标签输出 蒸馏损失训练 温度系数控制知识迁移粒度 剪枝 + 蒸馏 + 量化 = 嵌入式AI部署三件套 先剪枝减少冗余 → 再蒸馏恢复精度 → 最后量化压缩体积

4.1 非结构化剪枝:最直接的砍法

非结构化剪枝,操作起来很直观。就是把绝对值小于某个阈值的权重直接置零。你想想看,一个权重如果只有0.001,它对最终结果的影响能有多大?砍掉它,模型几乎感觉不到。

我在项目里常用的一种做法是:训练完成后,统计所有权重的绝对值分布,然后设定一个百分位阈值。比如砍掉50%的权重,那就把最小的50%置零。

我的经验: 非结构化剪枝后,模型精度通常不会掉太多。但问题在于——STM32这类硬件对稀疏矩阵的支持有限。你虽然把权重置零了,但存储和计算时还是得按全尺寸来,加速效果不明显。

不过,如果你用的是支持稀疏计算的专用硬件(比如某些NPU),那非结构化剪枝就很有价值了。对于STM32,我一般只把它作为「预处理」步骤。

4.2 结构化剪枝:为硬件量身定制

结构化剪枝就不一样了。它不是单个权重地砍,而是整块整块地砍。比如砍掉一个卷积核、一个通道、甚至一整层。这样做的好处是:剪完之后模型结构还是规整的,硬件跑起来毫无压力。

具体怎么做?我常用的方法是基于BN层(Batch Normalization)的gamma系数来判断。gamma系数越小,说明这个通道对输出的贡献越低,优先剪掉。

# 伪代码:基于BN层gamma的结构化剪枝
import torch
import torch.nn.utils.prune as prune

model = load_pretrained_model()

# 获取所有BN层的gamma系数
gamma_list = []
for name, param in model.named_parameters():
    if 'weight' in name and 'bn' in name:
        gamma_list.append(param.data.abs().mean().item())

# 设定剪枝比例,比如剪掉30%的通道
threshold = sorted(gamma_list)[int(len(gamma_list) * 0.3)]

# 对卷积层进行通道剪枝
for name, module in model.named_modules():
    if isinstance(module, torch.nn.Conv2d):
        # 找到对应的BN层
        bn_module = find_bn_for_conv(model, name)
        mask = bn_module.weight.data.abs() > threshold
        # 保留mask为True的通道
        prune.custom_from_mask(module, name='weight', mask=mask)
注意: 结构化剪枝后一定要做微调(fine-tune)。我曾经跳过这一步,结果模型精度掉了8个点,后来老老实实微调了10个epoch才恢复回来。

剪枝比例怎么定?我一般遵循「20%法则」:每次剪20%,评估精度,如果下降在1%以内,继续剪。直到精度开始明显下滑,就回退到上一次的状态。

剪枝类型 粒度 硬件加速 精度影响 STM32适用性
非结构化剪枝 单个权重 差(需稀疏硬件) 较小 低
结构化剪枝 通道/卷积核 好(直接减少计算量) 中等 高
层剪枝 整层 最好 较大 中(需谨慎)

4.3 知识蒸馏:让大模型当老师

知识蒸馏这个概念,我第一次接触时觉得挺玄乎的。说白了就是:你有一个大模型(教师),它学得很好。你想让一个小模型(学生)也学得这么好。怎么办?让教师模型教学生模型。

怎么教?不是直接教答案(硬标签),而是教「思考过程」(软标签)。比如分类任务,教师模型输出的是[0.7, 0.2, 0.1]这样的概率分布,而不是[1, 0, 0]这样的硬标签。这个分布里包含了类别之间的相似性信息——「猫」和「狗」的分数都比「汽车」高,这个信息对学生模型很有价值。

蒸馏的关键参数是温度系数T。T越大,软标签越平滑,知识迁移得越充分。我一般把T设在3-5之间。

# 知识蒸馏训练伪代码
def distillation_loss(student_output, teacher_output, true_labels, T=4, alpha=0.7):
    # 软标签损失:学生和教师的KL散度
    soft_loss = KL_div(
        softmax(student_output / T),
        softmax(teacher_output / T)
    ) * (T * T)
    
    # 硬标签损失:学生的交叉熵
    hard_loss = cross_entropy(student_output, true_labels)
    
    # 总损失
    return alpha * soft_loss + (1 - alpha) * hard_loss

# 训练循环
for batch in dataloader:
    teacher_logits = teacher_model(batch['image'])
    student_logits = student_model(batch['image'])
    
    loss = distillation_loss(
        student_logits, 
        teacher_logits, 
        batch['label'],
        T=4,
        alpha=0.7
    )
    
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
实战要点: 教师模型在蒸馏过程中要冻结权重,只更新学生模型。alpha值我建议从0.5开始调,软标签和硬标签各占一半。

4.4 剪枝+蒸馏的组合拳

单独用剪枝,精度掉得厉害。单独用蒸馏,学生模型容量有限。两个一起上,效果1+1>2。

我推荐的工作流是这样的:

  1. 训练教师模型:一个参数量较大的模型,精度尽可能高
  2. 结构化剪枝:对教师模型剪枝,得到一个较小的学生模型
  3. 知识蒸馏:用原始教师模型指导学生模型训练
  4. 微调:在目标数据集上做最后的微调
  5. 量化:最后做INT8量化,部署到STM32

我在一个实际项目中,用这种方法把MobileNetV2从4.2MB压缩到了1.1MB,精度只掉了0.3%。而单独用剪枝,同样大小下精度掉了2.1%。差距很明显。

避坑指南: 我曾经犯过一个错误——先蒸馏再剪枝。结果学生模型学到的知识在剪枝时又被砍掉了。正确的顺序应该是先剪枝再蒸馏,让蒸馏去弥补剪枝造成的精度损失。

4.5 在STM32上的部署考量

剪枝和蒸馏都是在PC上完成的。部署到STM32时,你只需要导出最终的小模型。但有几个点要注意:

  • 结构化剪枝后的模型:导出的ONNX或TFLite文件会自动去掉被剪的通道,计算量直接减少
  • 非结构化剪枝后的模型:虽然权重是0,但模型文件大小不变,STM32跑起来也没加速
  • 蒸馏后的模型:模型结构不变,但权重更优,量化后精度损失更小

所以我的建议很明确:在STM32上,优先用结构化剪枝+知识蒸馏的组合。非结构化剪枝留给有稀疏加速硬件的平台去用。