4、Logits蒸馏(下):温度T的调参技巧与多Teacher聚合

上一节我们把Logits蒸馏的数学原理讲透了。这一节,咱们来点实战中真正磨人的东西——温度T怎么调?多个Teacher怎么合并?

说实话,我见过不少同学在论文里把T设成4.0或者5.0,然后跑完就说“蒸馏成功”。但实际落地时,T这个超参数对性能的影响,比你想象的大得多。我自己踩过坑,也帮别人调过,今天把这些经验全盘托出。

4.1 温度T的调参技巧

4.1.1 网格搜索:最笨但最稳的方法

网格搜索听起来土,但它是理解T行为最直接的方式。我一般这样操作:

  1. 先定范围:T从1.0到10.0,步长0.5或1.0
  2. 固定其他超参:学习率、batch size、KD损失权重α都锁死
  3. 跑小规模验证:用验证集的一个子集(比如1000张图)快速扫一遍
  4. 观察两个指标:Student的Top-1准确率 + Teacher和Student的KL散度值
我的习惯:先粗扫(步长2.0),找到峰值区域,再细扫(步长0.5)。这样能省至少一半时间。

举个例子,我在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学得更“细”。

我曾经踩过的坑:自适应温度不是万能的。有一次我在一个细粒度分类任务上用了线性衰减,结果T降得太快,Student还没学到Teacher的“软知识”就被迫去拟合硬标签了。后来改成余弦衰减才稳住。

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)
我的经验:权重不要拍脑袋定。我一般先在验证集上跑每个Teacher的准确率,然后用softmax归一化作为初始权重。如果资源允许,把权重也作为可学习参数,和Student一起训练。

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的关系串起来了:

Logits蒸馏核心流程与温度T调参 Logits蒸馏:温度T与多Teacher聚合流程 输入数据 (Images/Text) Teacher模型 (多个) Student模型 多Teacher聚合策略 平均 / 加权 / 置信度门控 / 集成 温度T调参 网格搜索 / 自适应衰减 Teacher Logits (聚合后) Student Logits KD Loss (KL散度 + 温度补偿) Loss = α · KL(Softmax(S/T) || Softmax(T/T)) · T² + (1-α) · CE(S, y) 优化Student参数 T值经验:分类3~4,检测5~8,NLP 2~3 多Teacher推荐加权聚合 + 置信度门控

这张图把整个流程串起来了:输入数据分别过Teacher和Student,Teacher这边先做多Teacher聚合,然后两边都做温度缩放,最后用KL散度计算损失。温度T的调参贯穿整个过程——它决定了Teacher的“软知识”怎么被Student吸收。

核心总结:温度T不是拍脑袋定的,网格搜索打底,自适应衰减提上限。多Teacher聚合时,别无脑平均,试试加权或者置信度门控。代码实现时别忘了温度补偿那一步。