qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

蒸馏把温度升高,为什么还要把损失乘回 T²?

直接计算老师与学生的三个类别分数,观察软标签、KL 方向和梯度尺度怎样一起变化。

展开本文目录
  1. 下载完整练习包
  2. 先保证两组分数在说同一件事
  3. 先看精确梯度,再谈高温近似
  4. 混合真实标签时,先写清两笔账
  5. 用有限差分核对这一次导数
  6. 可选:用实际自动求导核对这三个分数
  7. 怎样读输出,才不会把尺度误当效果
  8. 记住这些要点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 4 个解释
知识蒸馏
让学生模型学习老师模型给出的输出分布,而不只学习一个最终类别。这个分布能保留老师对多个候选的相对倾向,也会带入老师的错误。
原始类别分数(Logit)
模型在转换为概率之前,为各个候选给出的数字。分数可以为负,也不要求相加为一;Softmax 等转换才把它们变成一组概率。
软目标
老师给各个候选分别分配概率,让学生学到不止一个最终标签。
T² 尺度补偿
温度调高会让学生分数对软目标损失的影响变小;乘回 T² 是为了调整这部分更新的力度。
打开完整术语小抄 ↗

老师只告诉学生“选第一项”,和告诉学生“第一项最像、第二项有点像、第三项差得远”,传递的信息不同。知识蒸馏中的软标签保留了这种分布形状;温度调高后,小概率类别的相对差异也更容易被看见。

但温度不仅改变老师的答案分布,也改变学生分数对损失的影响。若同时搭配真实标签训练,不做尺度处理,调温度时也在悄悄改变两种训练目标的相对力度。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。可选框架脚本需要已有 PyTorch;实测 Python 3.14.0 + PyTorch 2.9.1+cpu,CPU float64 单线程。基础脚本仍无第三方依赖。下方命令运行基础 Python 标准库示例;可选对照的环境和命令见对应 README。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 11 个文件
包里有什么
  • distillation_temperature.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要
  • distillation_torch_cpu.py:可选框架对照配套文件
  • README-distillation-framework.md:可选框架对照配套文件
  • observed-distillation.stdout.txt:可选框架对照配套文件
  • distillation-torch-report.json:可选框架对照配套文件
  • framework-distillation-verification.json:可选框架对照配套文件
  • supplement-verification.json:附加实现的独立复跑记录

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B distillation_temperature.py

解压到独立练习文件夹,在含 distillation_temperature.py 的目录打开终端运行。基础为固定老师/学生分数的标准库概率、KL 与中心差分;额外在 PyTorch 2.9.1+cpu 上核对固定分数的 autograd 和 reduction。

未训练神经网络、未执行优化步骤、未运行 GPU 或模型 API。 压缩包还附可选 PyTorch 对照;请按 README-*-framework.md 使用已安装 PyTorch 的解释器运行。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先保证两组分数在说同一件事

设老师对三个固定类别的分数为 [4, 1, -2],学生为 [1, 0, -1]。索引 0 在两边必须对应同一个类别。长度相同还不够:如果一边是 [猫, 狗, 鸟],另一边是 [狗, 猫, 鸟],直接比较就会把类别对错位。

对温度 T,分别计算 p=softmax(老师分数/T)、q=softmax(学生分数/T)。两边用同一个 T。T 较大时分布更平,排序通常不变,但各类别得到的概率会变。这里讲的是训练中的软目标,不是在生成阶段控制采样随机性。

我们采用 KL(p||q)=Σ pᵢ(log pᵢ−log qᵢ)。老师 p 固定,学生 q 随学生分数变化。KL 的两个位置有方向,倒过来会改变目标;它不是普通的对称距离。

先看精确梯度,再谈高温近似

记学生第 i 个原始分数为 zᵢ。未乘 T² 的精确导数是 (qᵢ−pᵢ)/T。这里一个 1/T 来自 softmax 输入除以温度;另外,温度提高后 p 与 q 本身通常也更接近。

在温度相对分数足够高、逐样本分数居中后的近似下,q−p 也大约按 1/T 缩小,因而梯度约按 1/T² 缩小。把蒸馏项乘 T² 后,精确梯度变成 T×(q−p)。这是补偿相对尺度,不是让所有 T 的梯度严格相等。

本例中未缩放梯度范数从 T=1 的 0.357680 降到 T=16 的 0.003661;乘回 T² 后分别为 0.357680 和 0.937317,显然并不相等。温度仍然改变了目标形状;不能把 T² 理解成撤销了温度的全部作用。

混合真实标签时,先写清两笔账

一种常见目标是 λ×T²×KL(p_T||q_T) + (1−λ)×CE(真实标签, q_1)。软目标分支使用温度 T,真实标签分支使用温度 1。λ 与 T 都需要通过独立验证集选择;T² 不会自动选出合适的 λ。

框架接口还会影响尺度。PyTorch KLDivLoss 的 input 默认应是学生的对数概率,target 默认是老师的概率;batchmean 对批大小做平均,而 mean 会对全部元素平均。固定批大小、增加类别数时,选错 reduction 仍可能改变梯度量级。下方基础脚本只用标准库;后面的可选框架对照另外核对了 PyTorch 2.9 文档,并实际执行了 2.9.1+cpu。

老师可能错。本例还把老师最高分改成第二类,检查学生第二类分数的梯度为负:若做梯度下降,它会被提高。这个方向只说明学生在靠近老师,不说明更靠近真实答案。

用有限差分核对这一次导数

把代码保存为 distillation_temperature.py 后执行 python -B distillation_temperature.py。它比较四个温度,用中心差分核对十二个学生分数导数,并检查整体平移分数不改变结果。报告写入当前目录 report.json。

这里直接把三个学生分数当作可变化的数字,未构造神经网络,也未执行多步训练。有限差分只核对这几个输入附近的算术导数,不是对任意模型反向传播的全面证明。

展开收起代码 · 小实验python · 75 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Exact arithmetic on fixed teacher/student logits; no neural network training."""
import json
import math
from pathlib import Path


def log_softmax(logits, temperature):
    if not logits or not math.isfinite(temperature) or temperature <= 0:
        raise ValueError("nonempty logits and positive finite temperature required")
    if any(not math.isfinite(x) for x in logits):
        raise ValueError("finite logits required")
    scaled = [x / temperature for x in logits]
    high = max(scaled)
    log_sum = math.log(sum(math.exp(x-high) for x in scaled))
    return [x-high-log_sum for x in scaled]


def kl_and_gradient(teacher, student, temperature, rescale):
    if len(teacher) != len(student):
        raise ValueError("same ordered classes required")
    lp = log_softmax(teacher, temperature)
    lq = log_softmax(student, temperature)
    p, q = [math.exp(v) for v in lp], [math.exp(v) for v in lq]
    multiplier = temperature ** 2 if rescale else 1.0
    loss = multiplier * sum(pi*(a-b) for pi, a, b in zip(p, lp, lq))
    gradient = [multiplier*(qi-pi)/temperature for pi, qi in zip(p, q)]
    return loss, gradient, p, q


def norm(values):
    return math.sqrt(sum(v*v for v in values))


def main():
    teacher, student = [4.0, 1.0, -2.0], [1.0, 0.0, -1.0]
    results, errors = [], []
    for temperature in [1.0, 2.0, 4.0, 16.0]:
        raw, raw_grad, p, q = kl_and_gradient(teacher, student, temperature, False)
        scaled, grad, _, _ = kl_and_gradient(teacher, student, temperature, True)
        eps = 1e-4
        for j in range(len(student)):
            plus, minus = student.copy(), student.copy()
            plus[j] += eps
            minus[j] -= eps
            numeric = (kl_and_gradient(teacher, plus, temperature, True)[0]
                       - kl_and_gradient(teacher, minus, temperature, True)[0]) / (2*eps)
            errors.append(abs(numeric-grad[j]))
        assert math.isclose(sum(grad), 0.0, abs_tol=1e-12)
        assert math.isclose(scaled, raw * temperature**2, abs_tol=1e-12)
        results.append({"temperature": temperature, "teacher_probabilities": p,
                        "student_probabilities": q, "raw_kl": raw, "scaled_kl": scaled,
                        "raw_gradient_norm": norm(raw_grad), "scaled_gradient_norm": norm(grad)})
        print(f"T={temperature:>4.1f} raw_KL={raw:.6f} scaled_KL={scaled:.6f} "
              f"raw_grad={norm(raw_grad):.6f} scaled_grad={norm(grad):.6f}")
    assert max(errors) < 1e-7
    shifted = kl_and_gradient([x+100 for x in teacher], [x-75 for x in student], 4, True)[0]
    assert math.isclose(shifted, results[2]["scaled_kl"], abs_tol=1e-12)
    assert kl_and_gradient(teacher, teacher, 4, True)[0] == 0.0
    forward = kl_and_gradient(teacher, student, 1, False)[0]
    reverse = kl_and_gradient(student, teacher, 1, False)[0]
    assert not math.isclose(forward, reverse)
    teacher_argmax = max(range(3), key=teacher.__getitem__)
    # A local derivative on three free logits, not an optimizer or trained student.
    wrong_teacher = [-2.0, 4.0, 1.0]
    _, wrong_grad, _, _ = kl_and_gradient(wrong_teacher, student, 2, True)
    assert wrong_grad[1] < 0  # gradient descent increases the teacher-favored logit
    print(f"KL(teacher||student)={forward:.6f}; KL(student||teacher)={reverse:.6f}")
    print(f"teacher_argmax={teacher_argmax}; max_gradient_error={max(errors):.3e}")
    print("checks=25 passed; fixed-logit arithmetic only")
    Path("report.json").write_text(json.dumps({"results": results, "gradient_errors": errors,
        "forward_kl": forward, "reverse_kl": reverse, "checks": 25}, indent=2), encoding="utf-8")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中执行 python -B distillation_temperature.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。固定老师/学生分数的标准库数学计算及中心差分;未运行神经网络、蒸馏训练、PyTorch、GPU 或模型 API。

运行后对照结果
T= 1.0 raw_KL=0.252609 scaled_KL=0.252609 raw_grad=0.357680 scaled_grad=0.357680
T= 2.0 raw_KL=0.185443 scaled_KL=0.741771 raw_grad=0.171009 scaled_grad=0.684036
T= 4.0 raw_KL=0.070460 scaled_KL=1.127366 raw_grad=0.053873 scaled_grad=0.861965
T=16.0 raw_KL=0.005151 scaled_KL=1.318709 raw_grad=0.003661 scaled_grad=0.937317
KL(teacher||student)=0.252609; KL(student||teacher)=0.492919
teacher_argmax=0; max_gradient_error=2.032e-10
checks=25 passed; fixed-logit arithmetic only

可选:用实际自动求导核对这三个分数

基础标准库脚本保留不变。我们另外复用本机已有的 PyTorch 2.9.1+cpu,实际调用 softmax、log_softmax、kl_div 与 autograd;没有安装或升级依赖。输入仍是同一组三类别分数,CPU、float64、单线程,老师固定且不参与求导。

四个温度下,框架的原始梯度与 (q−p)/T、乘 T² 后的梯度与 T(q−p) 逐项一致到所设容差;本次观察到的最大绝对差约 1.776×10⁻¹⁵。B=1、C=3 时,batchmean 的值恰好是 mean 的三倍,这次差异确实来自 reduction,而不是模型学习。

下载包中的 distillation_torch_cpu.py 可与基础文件放在同一目录执行。要使用已经装有 PyTorch 的解释器;只运行标准库练习时不需要它。该脚本写入独立的 distillation-torch-report.json,不覆盖基础 report.json。

此次补充只是固定分数的框架算子与自动求导对照,未构造网络、未更新参数、未使用真实数据或评估蒸馏效果。浮点末位可能随构建与平台改变;2.9.1 的实测不自动证明所有其他版本完全相同。

展开收起代码 · 小实验python · 62 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Small CPU autograd cross-check against the frozen scalar distillation lesson."""
import json
import math
import platform
import sys
import warnings
from pathlib import Path
import torch
from torch.nn import functional as F

torch.set_num_threads(1)
DTYPE = torch.float64


def main():
    teacher = torch.tensor([[4.0,1.0,-2.0]],dtype=DTYPE,device='cpu')
    rows = []
    print(f'Python {platform.python_version()}; torch {torch.__version__}; device=cpu; dtype=float64; threads=1')
    for temperature in [1.0,2.0,4.0,16.0]:
        student = torch.tensor([[1.0,0.0,-1.0]],dtype=DTYPE,device='cpu',requires_grad=True)
        target = F.softmax(teacher/temperature,dim=-1)
        student_log = F.log_softmax(student/temperature,dim=-1)
        q = student_log.exp()
        raw_loss = F.kl_div(student_log,target,reduction='batchmean')
        scaled_loss = raw_loss * temperature**2
        raw_gradient, = torch.autograd.grad(raw_loss,student,retain_graph=True)
        scaled_gradient, = torch.autograd.grad(scaled_loss,student)
        expected_raw = (q-target)/temperature
        expected_scaled = temperature*(q-target)
        raw_error = (raw_gradient-expected_raw).abs().max().item()
        scaled_error = (scaled_gradient-expected_scaled).abs().max().item()
        assert raw_error < 1e-12 and scaled_error < 1e-12
        assert not teacher.requires_grad and teacher.grad is None
        raw_norm = torch.linalg.vector_norm(raw_gradient).item()
        scaled_norm = torch.linalg.vector_norm(scaled_gradient).item()
        assert math.isclose(scaled_norm,raw_norm*temperature**2,rel_tol=1e-12)
        row = {'temperature':temperature,'kl_batchmean':raw_loss.item(),
            'raw_gradient':raw_gradient.detach().tolist(),'scaled_gradient':scaled_gradient.detach().tolist(),
            'raw_norm':raw_norm,'scaled_norm':scaled_norm,'raw_formula_max_error':raw_error,
            'scaled_formula_max_error':scaled_error}
        rows.append(row)
        print(f'T={temperature:g}: KL={raw_loss.item():.6f}; raw_grad_norm={raw_norm:.6f}; '
              f'scaled_grad_norm={scaled_norm:.6f}; formula_max_error={max(raw_error,scaled_error):.3e}')
    target = F.softmax(teacher/4,dim=-1)
    log_input = F.log_softmax(torch.tensor([[1.0,0.0,-1.0]],dtype=DTYPE)/4,dim=-1)
    batchmean = F.kl_div(log_input,target,reduction='batchmean')
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter('always')
        mean = F.kl_div(log_input,target,reduction='mean')
    assert torch.allclose(mean*3,batchmean,rtol=1e-12,atol=1e-12)
    print(f'reduction at B=1,C=3: batchmean/mean={(batchmean/mean).item():.6f}')
    print('checks passed: 4 raw/scaled autograd comparisons, frozen teacher, reduction factor')
    report = {'python':sys.version,'executable':sys.executable,'torch':torch.__version__,
        'torch_path':torch.__file__,'device':'cpu','dtype':'float64','threads':torch.get_num_threads(),
        'rows':rows,'reduction':{'batchmean':batchmean.item(),'mean':mean.item(),
            'captured_warnings':[str(w.message) for w in caught]},
        'scope':'Actual CPU PyTorch autograd on one fixed row of logits; no network training, optimizer steps, task data or model quality claim.'}
    Path('distillation-torch-report.json').write_text(json.dumps(report,indent=2,allow_nan=False)+'\n',encoding='utf-8')


if __name__ == '__main__':
    main()

怎样运行:python -B distillation_torch_cpu.py

运行后对照结果
Python 3.14.0; torch 2.9.1+cpu; device=cpu; dtype=float64; threads=1
T=1: KL=0.252609; raw_grad_norm=0.357680; scaled_grad_norm=0.357680; formula_max_error=1.110e-16
T=2: KL=0.185443; raw_grad_norm=0.171009; scaled_grad_norm=0.684036; formula_max_error=0.000e+00
T=4: KL=0.070460; raw_grad_norm=0.053873; scaled_grad_norm=0.861965; formula_max_error=4.441e-16
T=16: KL=0.005151; raw_grad_norm=0.003661; scaled_grad_norm=0.937317; formula_max_error=1.776e-15
reduction at B=1,C=3: batchmean/mean=3.000000
checks passed: 4 raw/scaled autograd comparisons, frozen teacher, reduction factor

怎样读输出,才不会把尺度误当效果

同一个例子在 T=1 时,KL(老师||学生)=0.252609,反过来为 0.492919。方向差异已经足够提醒我们:写 KL 之前先写清谁是目标、谁是被调整的模型。

因为温度不同,分布本身不同,不能把不同 T 下更小的原始 KL 直接当作更好蒸馏。判断效果需要固定评测数据,分别比较真实任务指标、老师错误的传播、学生成本与训练条件。

本次只验证固定分数的概率、KL 与导数,没有使用真实图像、语言模型、蒸馏训练、GPU 或 API。对大词表还要处理类别映射、缺失概率、精度及样本掩码;这些没有在三个类别中被充分覆盖。

记住这些要点

  • 蒸馏两边先对齐类别,再用相同温度形成软分布。
  • T² 补偿梯度尺度,不会让不同温度的目标完全相同。
  • KL 有方向,框架的输入格式和 reduction 也属于公式的一部分。
  • 靠近老师不等于靠近真实答案。

别把例子用过头

  • 只对老师升温,学生仍用 T=1,却继续套用本文导数。
  • 把不同温度的原始 KL 大小直接比较为训练效果。
  • 将老师的概率放进要求 log 概率的接口位置。
  • 宣称固定三分数的导数检查证明真实模型蒸馏有效。

自己试一小步

如果软目标和真实标签发生冲突,改变 λ 与 T 会怎样改变同一类的更新方向?

  1. 设置老师偏向类别 1,而真实标签为类别 0。
  2. 实现温度 1 的真实标签交叉熵及其导数。
  3. 选至少三个 λ、三个 T,打印两项导数与合成导数。
  4. 让老师分数整体加一个常数,检查合成导数是否保持。

做到这些,就算完成

  • 明确 λ 是给哪一项的权重。
  • 把 T² 放在软目标分支,并保留真实标签分支温度为 1。
  • 只讨论局部更新方向;没有独立评测时不声称泛化提升。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。