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 范数来评估重要性,而是用老师模型的输出来指导。如果一个通道被剪掉后,学生模型的输出与老师模型的输出差异很大,说明这个通道很重要,不能剪。
嗯,这里要注意,这种方法计算量比较大,但精度损失通常最小。适合对精度要求极高的场景。
总结一下我的经验:
- 如果追求快速部署,用结构化剪枝 + 后蒸馏。
- 如果追求极致压缩率,用边剪枝边蒸馏。
- 如果精度是第一位,用蒸馏辅助剪枝。
好了,这一章的内容就到这里。下一章我们会聊聊量化,那是另一个让模型“瘦身”的利器。