第 8 章 评估与对比 训练完了,怎么知道蒸馏到底有没有用?本章用三个维度衡量学生质量,并把教师当作参照上界,回答「学生学得有多好、离教师还有多远、学得有多像」。 8.1 评估的三个维度 蒸馏效果评估要回答三个问题,对应三个指标: 维度 | 指标 | 反映 自身质量 | 困惑度 PPL、Top-1 准确率 | 学生语言建模能力 参照对比 | 同指标的教师值 | 学生达到教师性能的百分比 对齐质量 | 师生分布 KL | 学生模仿教师有多到位 第三个维度「分布 KL」是蒸馏特有的指标——普通训练不会关心学生像不像某个教师,但蒸馏的核心目标恰恰是「模仿」,所以 KL 是衡量蒸馏成败的关键。 8.2 指标一:困惑度 PPL 8.2.
训练完了,怎么知道蒸馏到底有没有用?本章用三个维度衡量学生质量,并把教师当作参照上界,回答「学生学得有多好、离教师还有多远、学得有多像」。
蒸馏效果评估要回答三个问题,对应三个指标:
| 维度 | 指标 | 反映 |
|---|---|---|
| 自身质量 | 困惑度 PPL、Top-1 准确率 | 学生语言建模能力 |
| 参照对比 | 同指标的教师值 | 学生达到教师性能的百分比 |
| 对齐质量 | 师生分布 KL | 学生模仿教师有多到位 |
第三个维度「分布 KL」是蒸馏特有的指标——普通训练不会关心学生像不像某个教师,但蒸馏的核心目标恰恰是「模仿」,所以 KL 是衡量蒸馏成败的关键。
困惑度(Perplexity)是语言模型最经典的指标,定义为平均交叉熵的指数:
PPL = exp(平均 token 交叉熵)
直觉理解:PPL 表示「模型对真实文本的困惑程度」。
PPL 越低越好。
import math import torch.nn.functional as F @torch.no_grad() def evaluate_model(model, eval_loader, device, desc="eval"): model.eval() total_loss = 0.0 n_tokens = 0 for x, y, _ in eval_loader: x, y = x.to(device), y.to(device) logits = model(input_ids=x).logits # shift 对齐后计算交叉熵 V = logits.size(-1) pred = logits[..., :-1, :].contiguous().view(-1, V) gold = y[..., 1:].contiguous().view(-1) mask = gold != -100 total_loss += F.cross_entropy( pred, gold, ignore_index=-100, reduction="sum" ).item() n_tokens += int(mask.sum().item()) avg_loss = total_loss / max(n_tokens, 1) return { "loss": avg_loss, "ppl": math.exp(min(avg_loss, 20)), # 截断防爆指数 }
注意 min(avg_loss, 20) 这个截断——指数函数增长极快,exp(20) ≈ 4.85 亿,如果 loss 偶然偏大(比如训练初期的随机学生),PPL 会爆成天文数字,不便观察。截断到 20 以内更实用。
为什么用 reduction="sum" 再除以 token 数:因为不同 batch 可能有不同数量的有效 token(被 ignore 的 padding),用 sum 累加再统一除以总有效 token 数,能得到准确的「平均每个 token 的损失」。
Top-1 准确率:模型预测概率最高的 token 恰好是真实下一个 token 的比例。
Top-1 = (预测 argmax == 真实标签) 的 token 数 / 总 token 数
它比 PPL 更直观——「猜对的占比」。但要注意,语言模型的 Top-1 通常不高(语言本身有创造性,下一个词并非唯一),所以它更适合做相对对比(学生 vs 教师),而非绝对评判。
@torch.no_grad() def evaluate_top1(model, eval_loader, device): model.eval() n_correct = 0 n_tokens = 0 for x, y, _ in eval_loader: x, y = x.to(device), y.to(device) logits = model(input_ids=x).logits V = logits.size(-1) pred = logits[..., :-1, :].contiguous().view(-1, V) gold = y[..., 1:].contiguous().view(-1) mask = gold != -100 pred_tok = pred.argmax(dim=-1) n_correct += (pred_tok[mask] == gold[mask]).sum().item() n_tokens += int(mask.sum().item()) return n_correct / max(n_tokens, 1)
pred.argmax(dim=-1) 取概率最大的 token id,和真实标签逐位比较,统计正确数。
这是蒸馏特有的核心指标,衡量「学生输出分布偏离教师多远」。
训练时的 kd_loss 也能反映这一点,但有两个区别:
| 维度 | 训练中的 kd_loss | 评估的 distribution_kl |
|---|---|---|
| 数据 | 训练集 | 验证集(模型没见过) |
| 温度 | 蒸馏温度 T | 默认 1.0(原始分布) |
| 用途 | 优化目标 | 泛化评估 |
训练中的 kd_loss 在训练集上算、用蒸馏温度,反映的是「训练目标本身」。而评估时的 KL 在验证集上算、用原始分布(T=1),反映的是「学生真实输出和教师的差距」——后者更能说明蒸馏的泛化效果。
@torch.no_grad() def evaluate_alignment(student, teacher, eval_loader, device, temperature=1.0): student.eval() teacher.eval() kl_sum = 0.0 n_tokens = 0 for x, y, _ in eval_loader: x = x.to(device) s_logits = student(input_ids=x).logits t_logits = teacher(input_ids=x).logits # shift 对齐 V = s_logits.size(-1) s_pred = s_logits[..., :-1, :].contiguous().view(-1, V) t_pred = t_logits[..., :-1, :].contiguous().view(-1, V) gold = y[..., 1:].contiguous().view(-1) mask = gold != -100 s_pred = s_pred[mask] t_pred = t_pred[mask] soft_t = F.softmax(t_pred / temperature, dim=-1) log_soft_s = F.log_softmax(s_pred / temperature, dim=-1) kl_sum += F.kl_div(log_soft_s, soft_t, reduction="sum").item() n_tokens += int(mask.sum().item()) return kl_sum / max(n_tokens, 1)
逻辑和第 6 章的损失函数一致,只是:① 用 sum 累加再除以 token 数得到准确均值;② 返回 Python float(不参与反传);③ 默认 T=1。
KL 方向同样是
kl_div(学生 log_softmax, 教师 softmax)= KL(教师‖学生)。值越小,学生越像教师。
把三个指标合起来,对学生和教师各跑一遍,得到对比表:
def evaluate(student_path, cfg, device, report_path="eval_report.json"): text = get_dataset() _, eval_loader, _ = build_train_eval_loaders(...) teacher = build_teacher(cfg); teacher.to(device) student = load_student(student_path, cfg, str(device)) s_metrics = evaluate_model(student, eval_loader, device, "student") t_metrics = evaluate_model(teacher, eval_loader, device, "teacher") kl = evaluate_alignment(student, teacher, eval_loader, device) report = {"teacher": t_metrics, "student": s_metrics, "alignment_kl": kl} # 打印对比表 + 存 JSON ...
============================================================ 评估结果对比 ============================================================ 指标 教师 学生 ------------------------------------------------------------ 平均 token 损失 3.3356 3.8142 困惑度 PPL 28.15 45.32 Top-1 准确率 0.4112 0.3421 ------------------------------------------------------------ 师生分布平均 KL (越小越像教师): 0.0823 ============================================================
| 指标 | 怎么解读 |
|---|---|
| 学生 PPL < 教师 PPL | 学生比教师还好?几乎不可能,通常是 bug 或验证集太小 |
| 学生 PPL 接近教师 PPL | 蒸馏非常成功,学生继承了大部分能力 |
| 学生 PPL 远高于教师 | 学生太小或训练不足,可加大容量或延长训练 |
| alignment_kl 很小 | 学生模仿得到位,蒸馏对齐好 |
| alignment_kl 很大 | 学生输出和教师差异大,蒸馏没充分学 |
理想情况下,一次成功的蒸馏会呈现:
评估时加载学生,必须用与训练时一致的学生架构。本项目在 checkpoint 里存了配置,加载时自动恢复:
ckpt = torch.load(args.checkpoint, map_location="cpu") if "distill_config" in ckpt: saved = ckpt["distill_config"] # 只覆盖架构相关字段,不影响数据加载配置 for k in ("teacher_name", "student_n_layer", "student_n_head", "student_n_embd", "vocab_size", "block_size", "dropout"): setattr(cfg, k, saved[k])
这样即使你忘了训练时用的什么学生配置,也能从 checkpoint 自动还原,避免架构不匹配导致加载失败。
结果同时存成 JSON,方便后续对比多次实验:
import json from pathlib import Path Path(report_path).write_text( json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8" )
eval_report.json 内容示例:
{ "teacher": {"loss": 3.3356, "ppl": 28.15, "top1": 0.4112}, "student": {"loss": 3.8142, "ppl": 45.32, "top1": 0.3421}, "alignment_kl": 0.0823 }
跑多组实验后,把这些 JSON 汇总成一张大表,就能横向对比不同温度、不同 α、不同学生规模的效果——这是做蒸馏研究的标准工作流。
命令行一行搞定:
# 评估默认的最终学生 python eval.py # 评估指定 checkpoint python eval.py --checkpoint checkpoints/student_step3000.pt # 自定义报告路径 python eval.py --report results/T4_alpha03.json
动手实验:训练两个不同温度的学生(如 T=2 和 T=6),分别评估,对比它们的 PPL 和 alignment_kl。你会发现温度对「学生像不像教师」和「学生自身能力」有微妙的不同影响——这正是第 10 章要深入调参的内容。
下一站:学生表现合格了,能用来做什么?在《第 9 章 推理与采样生成》中,我们让学生模型真正"开口说话",实现交互式文本生成。