开始前,会这些就够了
- 知道 Softmax 把一组分数转换成概率
- 知道梯度下降沿梯度反方向更新
- 能读 Python 列表推导式
- 需要补基础时:先补 Softmax 与分类损失
- 需要补基础时:再补导数与链式法则
遇到不熟悉的词,先看这 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。
这里直接把三个学生分数当作可变化的数字,未构造神经网络,也未执行多步训练。有限差分只核对这几个输入附近的算术导数,不是对任意模型反向传播的全面证明。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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 的实测不自动证明所有其他版本完全相同。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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,而真实标签为类别 0。
- 实现温度 1 的真实标签交叉熵及其导数。
- 选至少三个 λ、三个 T,打印两项导数与合成导数。
- 让老师分数整体加一个常数,检查合成导数是否保持。
做到这些,就算完成
- 明确 λ 是给哪一项的权重。
- 把 T² 放在软目标分支,并保留真实标签分支温度为 1。
- 只讨论局部更新方向;没有独立评测时不声称泛化提升。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Distilling the Knowledge in a Neural Network (新标签页)↗
软目标、相同温度、T² 及高温分数匹配近似的来源。
- PyTorch:Knowledge Distillation Tutorial (新标签页)↗
官方示例中的相同 T、软目标 KL、T² 和真实标签混合。
- PyTorch 2.14:KLDivLoss (新标签页)↗
log 输入、target 语义与 batchmean
/mean的区别。 - PyTorch 2.9:KLDivLoss(此次 CPU 实测对应版本) (新标签页)↗
额外框架对照的 log 输入、target、batchmean
/mean与数学 KL 方向。