4、Logits蒸馏(下):温度T的调参技巧与多Teacher聚合
上一节我们把Logits蒸馏的数学原理讲透了。这一节,咱们来点实战中真正磨人的东西——温度T怎么调?多个Teacher怎么合并?
说实话,我见过不少同学在论文里把T设成4.0或者5.0,然后跑完就说“蒸馏成功”。但实际落地时,T这个超参数对性能的影响,比你想象的大得多。我自己踩过坑,也帮别人调过,今天把这些经验全盘托出。
4.1 温度T的调参技巧
4.1.1 网格搜索:最笨但最稳的方法
网格搜索听起来土,但它是理解T行为最直接的方式。我一般这样操作:
- 先定范围:T从1.0到10.0,步长0.5或1.0
- 固定其他超参:学习率、batch size、KD损失权重α都锁死
- 跑小规模验证:用验证集的一个子集(比如1000张图)快速扫一遍
- 观察两个指标:Student的Top-1准确率 + Teacher和Student的KL散度值
举个例子,我在CIFAR-100上做ResNet18蒸馏ResNet34时,粗扫发现T在3.0~5.0之间效果最好,细扫后锁定T=3.5。如果一开始就用步长0.5从1.0扫到10.0,得跑19组实验,太浪费了。
4.1.2 自适应温度:动态调整的思路
网格搜索的问题是:训练过程中最优T可能变化。早期Student很弱,需要高T来软化分布;后期Student变强了,低T反而更好。
自适应温度的做法是:让T随着训练轮数动态变化。
def adaptive_temperature(epoch, total_epochs, T_init=5.0, T_final=1.0):
"""
线性衰减温度
epoch: 当前轮数
total_epochs: 总轮数
T_init: 初始温度
T_final: 最终温度
"""
return T_init - (T_init - T_final) * (epoch / total_epochs)
# 使用示例
for epoch in range(num_epochs):
T = adaptive_temperature(epoch, num_epochs, T_init=5.0, T_final=1.5)
loss = kd_loss(student_logits, teacher_logits, T)
还有一种更聪明的做法——基于KL散度自适应:如果当前Student和Teacher的分布差异大,就降低T让Student学得更“狠”;差异小,就升高T让Student学得更“细”。
4.2 不同任务下的T值经验范围
不同任务对温度T的敏感度完全不同。我整理了一张表,都是我自己实验验证过的:
| 任务类型 | 推荐T范围 | 典型值 | 说明 |
|---|---|---|---|
| 图像分类(CIFAR/ImageNet) | 2.0 ~ 6.0 | 3.0 ~ 4.0 | 类别多,需要适度软化 |
| 细粒度分类(鸟类/车型) | 1.0 ~ 3.0 | 1.5 ~ 2.0 | 类别间差异小,T太高会模糊细节 |
| 目标检测(YOLO/SSD) | 3.0 ~ 8.0 | 5.0 | 多任务损失,需要更强软化 |
| 自然语言处理(BERT蒸馏) | 1.0 ~ 5.0 | 2.0 ~ 3.0 | 词表大,T太高会导致梯度消失 |
| 语音识别(ASR) | 2.0 ~ 4.0 | 3.0 | 时序建模,T影响帧级别对齐 |
为什么会这样?你想想看,细粒度分类里“红尾伯劳”和“棕背伯劳”的logits差异本来就小,T一高直接把差异抹平了,Student啥也学不到。而目标检测里分类和回归一起优化,T太低的话回归分支的梯度会被分类分支淹没。
4.3 多Teacher蒸馏的Logits聚合策略
多Teacher蒸馏在工业界很常见——你手里有好几个预训练模型,每个都有自己的强项,怎么把它们合并成一个Student?
我总结了几种主流策略:
4.3.1 平均聚合(Averaging)
最简单,把所有Teacher的logits取平均。但问题是:如果某个Teacher很拉胯,它会拖累整体质量。
def average_teacher_logits(teacher_logits_list):
"""
teacher_logits_list: [T1_logits, T2_logits, ...]
每个元素形状: [batch_size, num_classes]
"""
return torch.mean(torch.stack(teacher_logits_list), dim=0)
4.3.2 加权聚合(Weighted Averaging)
给每个Teacher分配一个权重,权重可以基于验证集准确率、置信度或者可学习的参数。
def weighted_teacher_logits(teacher_logits_list, weights):
"""
weights: [w1, w2, ...] 需要归一化
"""
weighted_sum = sum(w * logits for w, logits in zip(weights, teacher_logits_list))
return weighted_sum / sum(weights)
4.3.3 置信度门控(Confidence Gating)
每个样本只选最自信的Teacher。比如Teacher A对这张猫图有95%的把握,Teacher B只有60%,那就只用A的logits。
def confidence_gating(teacher_logits_list):
"""
对每个样本,选择置信度最高的Teacher的logits
"""
# 计算每个Teacher的置信度(softmax后的最大值)
confidences = [torch.max(F.softmax(logits, dim=-1), dim=-1)[0] for logits in teacher_logits_list]
# 堆叠并取argmax
conf_stack = torch.stack(confidences, dim=-1) # [batch_size, num_teachers]
best_teacher_idx = torch.argmax(conf_stack, dim=-1) # [batch_size]
# 根据索引选择对应logits
logits_stack = torch.stack(teacher_logits_list, dim=-1) # [batch_size, num_classes, num_teachers]
batch_indices = torch.arange(logits_stack.size(0))
return logits_stack[batch_indices, :, best_teacher_idx]
4.3.4 知识蒸馏集成(Ensemble Distillation)
先训练一个“超级Teacher”——把多个Teacher的logits作为输入,训练一个小的MLP来预测融合后的logits。这个方法效果好,但成本高。
4.4 实战:手写KD Loss代码
说了这么多理论,咱们直接写代码。下面是一个完整的KD Loss实现,支持温度调节、多Teacher聚合和自适应温度:
import torch
import torch.nn as nn
import torch.nn.functional as F
class KDLoss(nn.Module):
def __init__(self, T=4.0, alpha=0.7, reduction='batchmean'):
"""
T: 温度参数
alpha: 蒸馏损失权重 (总loss = alpha * kd_loss + (1-alpha) * ce_loss)
reduction: 'batchmean' 或 'sum'
"""
super(KDLoss, self).__init__()
self.T = T
self.alpha = alpha
self.reduction = reduction
def forward(self, student_logits, teacher_logits, target=None):
"""
student_logits: [batch_size, num_classes]
teacher_logits: [batch_size, num_classes] 或 [batch_size, num_classes, num_teachers]
target: 硬标签 [batch_size],可选
"""
# 处理多Teacher情况
if teacher_logits.dim() == 3:
# 默认使用平均聚合
teacher_logits = torch.mean(teacher_logits, dim=-1)
# 温度缩放
s_logits_soft = student_logits / self.T
t_logits_soft = teacher_logits / self.T
# KL散度损失
kd_loss = F.kl_div(
F.log_softmax(s_logits_soft, dim=-1),
F.softmax(t_logits_soft, dim=-1),
reduction=self.reduction
) * (self.T * self.T) # 温度补偿
# 如果有硬标签,加入CE损失
if target is not None:
ce_loss = F.cross_entropy(student_logits, target)
total_loss = self.alpha * kd_loss + (1 - self.alpha) * ce_loss
return total_loss
return kd_loss
# 使用示例
kd_criterion = KDLoss(T=4.0, alpha=0.7)
student_out = student_model(images)
teacher_out = teacher_model(images)
loss = kd_criterion(student_out, teacher_out, labels)
kd_loss * (self.T * self.T)。这是温度补偿,因为除以T后梯度会缩小T²倍,乘回来才能保持梯度量级不变。很多初学者会漏掉这一步。
如果你需要自适应温度,可以这样封装:
class AdaptiveKDLoss(KDLoss):
def __init__(self, T_init=5.0, T_final=1.0, total_epochs=100,
alpha=0.7, reduction='batchmean'):
super(AdaptiveKDLoss, self).__init__(T=T_init, alpha=alpha, reduction=reduction)
self.T_init = T_init
self.T_final = T_final
self.total_epochs = total_epochs
self.current_epoch = 0
def update_epoch(self, epoch):
self.current_epoch = epoch
# 余弦衰减
cos_inner = math.pi * (epoch / self.total_epochs)
self.T = self.T_final + 0.5 * (self.T_init - self.T_final) * (1 + math.cos(cos_inner))
def forward(self, student_logits, teacher_logits, target=None):
# 使用当前温度
self.T = self.T # 已由update_epoch更新
return super().forward(student_logits, teacher_logits, target)
嗯,代码就这些。你可能会问:为什么不用现成的torch.nn.KLDivLoss?因为那个需要自己处理温度缩放和补偿,封装起来更干净。
4.5 本章知识结构图
下面这张图把温度T的调参、多Teacher聚合和KD Loss的关系串起来了:
这张图把整个流程串起来了:输入数据分别过Teacher和Student,Teacher这边先做多Teacher聚合,然后两边都做温度缩放,最后用KL散度计算损失。温度T的调参贯穿整个过程——它决定了Teacher的“软知识”怎么被Student吸收。