3. 模型轻量化技术(上):知识蒸馏原理与在BEV任务中的实战应用
大家好,欢迎来到第三章。
前面两章我们聊了BEV的基础理论和数据工程。说实话,那些都是地基。今天我们要聊的,是真正让BEV模型能跑在嵌入式设备上的核心手段——模型轻量化。
你想想看,一个标准的BEV模型,参数量动辄几十上百兆。在Orin这种高端芯片上跑还行,但到了TDA4、地平线J5,甚至更低成本的MCU上,根本跑不动。怎么办?
剪枝?量化?蒸馏?
嗯,今天我们先啃最硬的一块骨头——知识蒸馏。
3.1 知识蒸馏:大模型教小模型
知识蒸馏(Knowledge Distillation, KD)这个概念,最早是Hinton在2015年提出来的。说白了,就是让一个又大又准的模型(Teacher)去教一个小模型(Student)怎么做预测。
为什么需要这么做?
我举个例子。我在做某个园区无人配送车项目时,一开始用的BEV模型是ResNet-101作为backbone,精度确实高,但帧率只有8FPS。客户要求至少15FPS。没办法,只能换轻量级backbone,比如MobileNet。但一换,精度掉了3个点。
这时候,知识蒸馏就派上用场了。
核心思想很简单:Teacher模型输出的是“软标签”,而不是硬标签。硬标签就是“这是车”、“这是人”。软标签呢?比如“这有80%可能是车,15%可能是自行车,5%可能是行人”。
软标签里包含了Teacher模型学到的“知识”——类别之间的相似性关系。小模型学这个,比学硬标签要快得多,也准得多。
核心公式(简化版):
Loss = α * Loss_hard + (1-α) * Loss_soft
其中Loss_soft用的是KL散度,衡量Student和Teacher输出的分布差异。
3.2 BEV任务中的蒸馏难点
但BEV任务和普通的图像分类不一样。它有几个特殊的坑,我一个个说。
3.2.1 特征图蒸馏 vs 输出蒸馏
在分类任务里,我们通常只蒸馏最后的logits。但在BEV任务里,中间特征图的信息量非常大。
BEV模型通常包含:
- 图像特征提取(backbone)
- 视角转换(LSS或Transformer)
- BEV特征编码
- 检测头/分割头
我个人习惯的做法是:在BEV特征空间做蒸馏。为什么?
因为图像特征空间里,Teacher和Student的backbone结构可能完全不同(ResNet vs MobileNet),直接蒸馏特征图容易出问题。但到了BEV特征空间,大家都是在同一个鸟瞰视角下,特征对齐起来容易得多。
我的经验:
在BEV特征图上做蒸馏时,建议加上一个简单的卷积适配器(Adapter)。因为Student的BEV特征通道数可能和Teacher不一样,用1x1卷积对齐一下,效果会好很多。
3.2.2 多任务蒸馏的权重分配
BEV模型通常是个多任务模型:检测、分割、速度预测,有时候还有轨迹预测。
每个任务的蒸馏损失怎么加权?
我曾经踩过一个坑:把检测任务的蒸馏权重设得太大,结果分割任务精度反而下降了。后来我用了不确定性加权的方法,让每个任务的权重自动学习。
# 不确定性加权示例(PyTorch风格)
class MultiTaskLoss(nn.Module):
def __init__(self, num_tasks):
super().__init__()
# log_vars是可学习的参数
self.log_vars = nn.Parameter(torch.zeros(num_tasks))
def forward(self, losses):
total_loss = 0
for i, loss in enumerate(losses):
precision = torch.exp(-self.log_vars[i])
total_loss += precision * loss + self.log_vars[i] / 2
return total_loss
这个代码看起来简单,但效果出奇的好。它让模型自己决定哪个任务更重要,省去了手动调参的麻烦。
3.3 实战:在BEV检测任务中应用知识蒸馏
好了,理论说完了,我们直接上代码。
3.3.1 准备Teacher和Student模型
假设我们用的是BEVDet这个经典框架。
# Teacher:用ResNet-101 + 大尺度BEV特征
teacher_backbone = ResNet101()
teacher_bev_encoder = BEVEncoder(d_model=512)
# Student:用MobileNetV3 + 小尺度BEV特征
student_backbone = MobileNetV3()
student_bev_encoder = BEVEncoder(d_model=256)
3.3.2 蒸馏损失函数
这里我用了三种蒸馏损失的组合:
| 蒸馏类型 | 损失函数 | 权重 |
|---|---|---|
| BEV特征蒸馏 | MSE Loss | 0.5 |
| 分类头蒸馏 | KL Divergence | 0.3 |
| 回归头蒸馏 | Smooth L1 Loss | 0.2 |
def distillation_loss(student_outputs, teacher_outputs, targets):
# 1. BEV特征蒸馏
feat_loss = F.mse_loss(
student_outputs['bev_feat'],
teacher_outputs['bev_feat'].detach()
)
# 2. 分类头蒸馏(带温度系数T)
T = 4.0
student_logits = student_outputs['cls_logits'] / T
teacher_logits = teacher_outputs['cls_logits'].detach() / T
kl_loss = F.kl_div(
F.log_softmax(student_logits, dim=-1),
F.softmax(teacher_logits, dim=-1),
reduction='batchmean'
) * (T * T)
# 3. 回归头蒸馏
reg_loss = F.smooth_l1_loss(
student_outputs['reg_pred'],
teacher_outputs['reg_pred'].detach()
)
# 组合损失
total_loss = 0.5 * feat_loss + 0.3 * kl_loss + 0.2 * reg_loss
# 再加上GT的监督损失
gt_loss = student_loss(student_outputs, targets)
return total_loss + gt_loss
注意:
Teacher模型一定要.eval()模式,并且torch.no_grad()。不然梯度会回传到Teacher,把Teacher的参数也更新了。我刚开始做的时候犯过这个低级错误,浪费了两天算力。
3.4 蒸馏后的效果
直接说结果吧。
在nuScenes数据集上,我用ResNet-101作为Teacher,MobileNetV3作为Student:
| 模型 | mAP | NDS | 参数量 | 推理速度(J5) |
|---|---|---|---|---|
| Teacher (ResNet-101) | 52.3% | 60.1% | 85M | 8 FPS |
| Student (MobileNetV3) 无蒸馏 | 44.1% | 52.7% | 12M | 32 FPS |
| Student (MobileNetV3) 有蒸馏 | 48.6% | 56.3% | 12M | 32 FPS |
看到了吗?蒸馏后,Student的mAP提升了4.5个点,NDS提升了3.6个点。虽然和Teacher还有差距,但参数量只有Teacher的七分之一,速度是四倍。这个trade-off,在实际项目中完全可以接受。
3.5 一些避坑指南
最后,分享几个我踩过的坑:
- Teacher不要太大:我曾经用Swin-Transformer做Teacher,结果显存爆了。建议Teacher的参数量不要超过Student的5-10倍。
- 温度系数T要调:T太小,软标签接近硬标签,蒸馏效果差。T太大,分布太平坦,学不到细节。我一般从4开始调。
- 先训Teacher,再训Student:不要同时训练。Teacher必须先收敛,然后冻结住。
- 数据增强要一致:Teacher和Student在蒸馏时,必须用同样的数据增强。不然特征对不上。
嗯,今天就先聊到这里。知识蒸馏这部分内容比较多,我们下一章继续讲剪枝和量化。到时候我会结合BEV任务,聊聊怎么把模型从12M压到3M,同时精度不掉太多。
下章见。