3. 蒸馏损失函数设计:KL散度损失、MSE损失、蒸馏损失的加权融合策略
好,咱们直接进入正题。
损失函数的设计,说白了就是告诉学生模型「你要往哪个方向学」。方向不对,努力白费。我在做蒸馏项目时,踩过最大的坑就是——只用一个损失函数,结果模型学歪了。
今天咱们把三种核心损失函数掰开揉碎讲清楚:KL散度、MSE、以及怎么把它们揉在一起。
3.1 KL散度损失:让概率分布对齐
KL散度,全称Kullback-Leibler divergence。名字挺唬人,其实核心思想就一句话:衡量两个概率分布之间的差异。
在蒸馏场景里,我们拿教师模型的softmax输出(经过温度缩放后的概率分布)当「标准答案」,让学生模型的输出尽量靠近它。
公式长这样:
KL(P || Q) = Σ P(x) * log(P(x) / Q(x))
其中P是教师分布,Q是学生分布。
嗯,这里要注意:KL散度不是对称的。也就是说KL(P||Q) ≠ KL(Q||P)。我刚开始用的时候没注意这个,结果训练出来的学生模型总是「过度自信」——因为它只关注教师认为「重要」的类别,忽略了那些概率虽小但仍有信息量的类别。
PyTorch实现很简单:
import torch
import torch.nn.functional as F
def kl_div_loss(student_logits, teacher_logits, temperature=4.0):
"""
计算KL散度损失
"""
# 温度缩放
student_soft = F.log_softmax(student_logits / temperature, dim=1)
teacher_soft = F.softmax(teacher_logits / temperature, dim=1)
# KL散度
loss = F.kl_div(student_soft, teacher_soft, reduction='batchmean')
# 乘以温度平方(论文中的标准做法)
loss = loss * (temperature ** 2)
return loss
为什么最后要乘以温度平方?这是Hinton论文里的trick。因为温度缩放后,梯度会变小,乘回来可以保持梯度量级不变。我试过不乘,训练直接崩了。
3.2 MSE损失:让特征空间对齐
KL散度管的是输出层,那中间层怎么办?
这时候MSE(均方误差)就派上用场了。说白了,就是让学生的中间特征图和教师的对应层特征图「长得像」。
公式:
MSE = (1/N) * Σ (teacher_feat - student_feat)²
你可能会问:直接用MSE拉近特征图,会不会太粗暴?
嗯,确实有这个问题。我做过一个实验:在ResNet-18蒸馏到MobileNet-V2时,直接对最后一层特征图做MSE,结果学生模型反而学坏了——因为它拼命去拟合教师的高维特征,忽略了分类任务本身。
代码示例:
def mse_feature_loss(student_feat, teacher_feat):
"""
特征图MSE损失
student_feat, teacher_feat: [batch, channels, h, w]
"""
# 先做归一化,防止量级差异
student_norm = F.normalize(student_feat, p=2, dim=1)
teacher_norm = F.normalize(teacher_feat, p=2, dim=1)
loss = F.mse_loss(student_norm, teacher_norm)
return loss
归一化这步很关键。我见过有人直接算MSE,结果损失值忽大忽小,调参调到崩溃。
3.3 蒸馏损失的加权融合策略
好了,现在我们有三个损失:
- 硬损失:学生输出 vs 真实标签(通常是交叉熵)
- 软损失:学生输出 vs 教师输出(KL散度)
- 特征损失:学生特征 vs 教师特征(MSE)
怎么把它们组合起来?
最经典的公式:
L_total = α * L_hard + β * L_soft + γ * L_feat
α、β、γ是权重系数。怎么调?
我个人的习惯是:
| 场景 | α(硬损失) | β(软损失) | γ(特征损失) |
|---|---|---|---|
| 教师和学生差距大(如ResNet→MobileNet) | 0.5 | 0.3 | 0.2 |
| 教师和学生结构相似 | 0.3 | 0.5 | 0.2 |
| 数据量少 | 0.7 | 0.2 | 0.1 |
注意,这只是初始值。实际项目中,我一般会做一次小规模网格搜索。比如固定α=0.5,然后试β在[0.2, 0.4, 0.6]里选,γ在[0.1, 0.2, 0.3]里选。
完整代码示例:
def distillation_loss(student_logits, teacher_logits,
student_feat, teacher_feat,
labels, temperature=4.0,
alpha=0.5, beta=0.3, gamma=0.2):
"""
蒸馏损失融合
"""
# 硬损失:交叉熵
loss_hard = F.cross_entropy(student_logits, labels)
# 软损失:KL散度
loss_soft = kl_div_loss(student_logits, teacher_logits, temperature)
# 特征损失:MSE
loss_feat = mse_feature_loss(student_feat, teacher_feat)
# 加权融合
total_loss = alpha * loss_hard + beta * loss_soft + gamma * loss_feat
return total_loss, {
'hard_loss': loss_hard.item(),
'soft_loss': loss_soft.item(),
'feat_loss': loss_feat.item()
}
返回字典是为了方便记录日志。我习惯把每个损失分量都打印出来,这样一眼就能看出哪个部分在主导训练。
3.4 一些实战中的坑
- 温度T的选择:T太大,分布太平坦,信息丢失;T太小,分布太尖锐,学生学不到「暗知识」。我一般从4开始试,然后看验证集效果微调。
- 特征对齐的层数:不是越多越好。我试过对齐5层特征图,结果训练速度慢了一倍,效果没提升。通常选2-3个关键层就够了。
- 权重初始化:如果特征损失一开始就很大(比如>10),说明特征图量级不匹配。先做归一化,或者把γ设小一点(比如0.01)起步。
最后说一句:损失函数设计没有银弹。同一个任务,换一个教师模型,最优权重可能就变了。多试、多记录、多分析——这才是工程师该干的事。