4. 模型剪枝与知识蒸馏:结构化剪枝、非结构化剪枝、知识蒸馏原理与实战
模型量化咱们聊完了,接下来这个环节,说实话,是真正让模型「瘦身」的硬功夫。剪枝和蒸馏,这两个技术我经常放在一起用。为什么?因为它们解决的是不同层面的冗余问题。
剪枝,说白了就是砍掉模型里不重要的连接或者神经元。蒸馏呢,是让一个小模型去模仿大模型的「思考方式」。我个人的习惯是:先剪枝,再蒸馏,最后量化。三步走,效果最稳。
4.1 非结构化剪枝:最直接的砍法
非结构化剪枝,操作起来很直观。就是把绝对值小于某个阈值的权重直接置零。你想想看,一个权重如果只有0.001,它对最终结果的影响能有多大?砍掉它,模型几乎感觉不到。
我在项目里常用的一种做法是:训练完成后,统计所有权重的绝对值分布,然后设定一个百分位阈值。比如砍掉50%的权重,那就把最小的50%置零。
不过,如果你用的是支持稀疏计算的专用硬件(比如某些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)
剪枝比例怎么定?我一般遵循「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()
4.4 剪枝+蒸馏的组合拳
单独用剪枝,精度掉得厉害。单独用蒸馏,学生模型容量有限。两个一起上,效果1+1>2。
我推荐的工作流是这样的:
- 训练教师模型:一个参数量较大的模型,精度尽可能高
- 结构化剪枝:对教师模型剪枝,得到一个较小的学生模型
- 知识蒸馏:用原始教师模型指导学生模型训练
- 微调:在目标数据集上做最后的微调
- 量化:最后做INT8量化,部署到STM32
我在一个实际项目中,用这种方法把MobileNetV2从4.2MB压缩到了1.1MB,精度只掉了0.3%。而单独用剪枝,同样大小下精度掉了2.1%。差距很明显。
4.5 在STM32上的部署考量
剪枝和蒸馏都是在PC上完成的。部署到STM32时,你只需要导出最终的小模型。但有几个点要注意:
- 结构化剪枝后的模型:导出的ONNX或TFLite文件会自动去掉被剪的通道,计算量直接减少
- 非结构化剪枝后的模型:虽然权重是0,但模型文件大小不变,STM32跑起来也没加速
- 蒸馏后的模型:模型结构不变,但权重更优,量化后精度损失更小
所以我的建议很明确:在STM32上,优先用结构化剪枝+知识蒸馏的组合。非结构化剪枝留给有稀疏加速硬件的平台去用。