简介:这是一份面向 Go 开发者与数据过滤场景学习者的开源项目源码包,项目聚焦内存数据集的过滤与查询能力,适合用于理解轻量级查询条件、运算符组合及测试驱动实现。压缩包共 40 个文件,约 29KB,以 34 个 Go 源码和测试文件为主体,另含 YAML 持续集成配置、Markdown 说明、JSON 示例及许可证文件,结构紧凑,便于直接阅读与二次开发。已有 193 人学习或下载。读者可从中获得过滤器核心实现、常见比较与匹配运算符的代码范例、较完整的单元测试以及示例配置,用于梳理内存数据筛选的接口设计、边界处理与扩展思路,也可作为小型 Go 项目中引入条件过滤逻辑的参考。
1. 一个压缩包解决的事:把大模型的判断力压进小模型里
教师模型在验证集上 98%,学生模型自己从头训只有 91%,差的这 7 个点往往不是数据不够,而是监督信号太稀疏——硬标签只有一个位置是 1,其余全被当成 0,模型根本不知道"第二像的类别有多像"。蒸馏要干的事就是把教师那一整条概率分布搬过来当监督,让学生的输出分布去逼近教师的输出分布,而不只是猜对答案。这个mattevans-distil项目包走的就是这条路子:教师/学生装配、软标签损失、温度与权重配置、训练入口、验证脚本都齐,解压后改几行配置就能跑起来。它适合两类人:手上已经有可用教师模型、想把线上推理成本砍下来的工程师;以及第一次接蒸馏任务、不想从零拼损失函数和训练循环的同学。它不解决"教师本身就不行"的问题,也别指望拿它把模型压到原来的十分之一还能满血——真实项目里,坑比想象中多得多。
2. 拆包先看结构:模块分工与第一条能跑通的训练链路
拿到一个开源训练项目,我一般不看 README 里的示例命令,先看目录和入口文件。因为示例命令只覆盖作者跑通的那一条主路径,而你要落地的时候,改的往往是他没写出来的那些分支。这个包的组织方式比较典型:模型定义、损失函数、数据管线、训练调度、配置解析各自独立,入口只做装配。理解了这个分层,后面调参数、换教师、加中间层损失,才知道该动哪个文件。
2.1 目录职责与模块边界
按功能拆,这套代码大致可以归成五块,职责边界比较清楚:
| 模块 | 职责 | 落地时最常改的点 |
|---|---|---|
| 模型装配 | 教师、学生两份网络定义与前向接口 | 学生 backbone 换小、教师输出层是否截断 |
| 损失函数 | 软标签 KL、硬标签交叉熵、可选的中间层损失 | 温度 T、权重 alpha、特征对齐维度 |
| 数据管线 | 采样、增广、批组装、教师 logits 缓存读取 | batch 组成、缓存命中率、增广是否双份 |
| 训练调度 | 优化器、学习率策略、梯度累积、精度控制 | 冻结策略、warmup、AMP 开关 |
| 配置与入口 | 参数解析、随机种子、日志、检查点 | 实验编号、恢复训练、评估频率 |
这个分法有个好处:损失函数是纯函数式的,输入 student_logits、teacher_logits、labels,输出一个标量,不依赖模型结构。你后面想换成 JS 散度、想加注意力图对齐,都在这一层动,不会污染训练循环。我见过不少自己拼的蒸馏脚本把损失写死在train()里,改一次温度要翻半个文件,那种代码做第二组实验就开始失控。
提示:解压后先把配置文件和入口文件通读一遍,确认默认配置对应的教师、学生、数据集三者尺寸是否匹配,别上来就
python train.py,很多报错是默认配置和你的数据规模对不上引起的。
再看配置层。这类项目通常用一份 YAML 或 argparse 默认值描述全部超参,入口读进来之后原样传给训练器。你需要确认的是三件事:温度 T 和权重 alpha 是否暴露成可调项;教师模型路径是否走配置文件而不是硬编码;检查点保存间隔是否含步数维度。这三点决定了你后面能不能做参数扫描,而不用每次改代码。
2.2 数据管线:教师前向要不要提前缓存
蒸馏训练最容易被低估的开销是教师前向。每个 batch 都要过一遍教师网络,教师的参数量通常是学生的几倍到十几倍,如果教师不参与反向传播,那这部分算力其实全是纯推理开销。常见做法有两条路:一是训练时实时前向,二是离线把教师 logits 打到磁盘上缓存,训练时直接读。
实时前向的优点是增广可以对齐——同一个 batch 的图做同样的随机裁剪、翻转、混色,教师和学生看到的完全一致;缺点是每轮都要重复算教师,epoch 一多就是成倍的浪费。离线缓存的优点是把教师前向一次性摊掉,训练阶段基本只剩学生前向加反向;缺点是增广必须固定,缓存里存的是确定版本的输入对应的 logits,你后面再改增广策略,缓存就全废了。
我一般的判断标准是看数据量和 epoch 数:数据量大、epoch 少(比如 20 轮以内),实时前向更省心;数据量中等、要跑几十上百轮,缓存收益明显。还有一类折中做法是缓存"教师 logits + 原始样本索引",训练时用弱增广或只用几何变换,能保住一部分增广多样性。这套包里两条路都留了接口,配置里有个开关控制走缓存还是走实时,切换的时候注意缓存文件名要带配置哈希,不然改了 T 之后读到旧缓存,结果会很离谱。
缓存本身也有坑:教师 logits 是 float32 的话体积不小,几百万样本乘上类别数,磁盘占用会很难看。常见做法是存成 float16 或者只保留 top-k 的 logits 和对应索引,训练时再散回完整分布。后一种做法在类别数特别大的任务上几乎是必须的,但要注意 top-k 截断会改变分布的归一化,散回之后要重新做 softmax 或显式补零,这里处理不好,软标签的语义就变了。
2.3 最小可跑通的蒸馏循环
跳过缓存,先写一个能跑通的最小循环。这段代码是把项目里的训练骨架抽出来重写的版本,结构一致,注释补上了每一步在干什么:
# distill_train.py —— 最小蒸馏训练循环 import torch import torch.nn.functional as F from torch.utils.data import DataLoader def build_optimizer(student, cfg): # 学生全部参数参与训练;教师永远冻结 return torch.optim.AdamW( student.parameters(), lr=cfg["lr"], # 学生初始学习率,通常比单独训练略小 weight_decay=cfg["wd"], # 蒸馏任务正则别开太大,容易压住软标签信号 ) def train_one_epoch(student, teacher, loader, optimizer, cfg, device): student.train() teacher.eval() # 教师必须 eval,BN/Dropout 行为要和推理一致 for step, (images, labels) in enumerate(loader): images = images.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) with torch.no_grad(): # 教师前向不进反向图,省显存 t_logits = teacher(images) s_logits = student(images) T = cfg["temperature"] # 温度,软化教师分布 alpha = cfg["alpha"] # 软标签权重,0~1 soft = F.kl_div( F.log_softmax(s_logits / T, dim=-1), F.softmax(t_logits / T, dim=-1), reduction="batchmean", ) * (T * T) # 乘 T^2 抵消温度带来的梯度缩放 hard = F.cross_entropy(s_logits, labels) loss = alpha * soft + (1 - alpha) * hard optimizer.zero_grad(set_to_none=True) loss.backward() torch.nn.utils.clip_grad_norm_(student.parameters(), cfg["clip"]) optimizer.step() if step % cfg["log_every"] == 0: print(f"step={step} loss={loss.item():.4f} " f"soft={soft.item():.4f} hard={hard.item():.4f}")逻辑上有三处值得说清楚。第一,教师放在torch.no_grad()里,这一步省的不只是显存,还避免了教师参数被意外累积梯度——只要你忘了冻结教师,训练跑起来不报错,但结果会莫名其妙地漂。第二,kl_div的输入顺序是 log_softmax(学生) 在前、softmax(教师) 在后,这个顺序搞反了不会报错,算出来的是另一个方向的距离,梯度方向就不对了。第三,batchmean的归一化是按 batch 大小平均,和mean的行为不同,切换 reduction 会让有效学习率变化,做实验时别中途改。
参数上,temperature默认可以从 4 起步,类别多、教师分布很尖的任务往 6~8 调;alpha在 0.7 附近是常见起点,如果学生容量明显小于教师,软标签权重可以再高一点,反之要留更多硬标签信号防止欠拟合。clip是梯度裁剪阈值,蒸馏初期软损失和硬损失量级不一致,偶尔会出尖峰,这个值建议保留。至于log_every,调试阶段设小一点,把 soft 和 hard 分开打印,两者不同步下降是后面排查问题最有用的一条线索。
3. 蒸馏损失怎么拼:温度、软标签与硬标签的配比
损失这一层是整个项目里最需要动手改的地方,也是新手最容易照抄默认值然后踩坑的地方。温度、权重、归一化方式,三个参数互相耦合,改一个另外两个的最优值就变了。这一节把这三者的关系拆开讲,顺便说清中间层损失该接在哪里。
3.1 温度 T 不是玄学参数
温度的作用是把教师的 logits 除以 T 之后再做 softmax,让原本很尖的分布变平缓,把"第二像、第三像"这些暗知识暴露出来。T=1 时就是原始分布,T 越大分布越接近均匀。为什么需要它?因为教师对正确类别的置信度往往极高,软标签里绝大部分信息集中在正确类那一项上,负类之间的相对大小被压得很小,学生在数值精度有限的浮点运算里几乎感受不到这些差异。温度一提,负类之间的相对关系被放大,软标签才真正起到"教导负类相似度"的作用。
取值上没有统一答案,但有个可操作的判断方法:把一批验证样本过教师,用不同的 T 算一下分布的熵,看熵随 T 的变化曲线,选那个熵已经明显上升但还没到接近均匀的区间。经验上 4 到 8 覆盖大多数分类任务,类别数是几十的小任务可以到 3~4,类别上千的任务经常要 8 以上。还有一个容易忽略的点:T 必须和训练结束后的推理温度区分开。训练时用高温软化,推理时学生按 T=1 输出,这是标准做法;如果你在评估时也除 T,指标会全线偏低,那不是模型不行,是评估写错了。
温度还和权重 alpha 耦合。T 越大,软损失的梯度量级越小(在乘 T² 补偿之后仍然有差异),需要相应提高 alpha 才能让软标签发挥同等作用。所以做参数扫描的时候,别固定 alpha 扫 T,两个一起动,或者干脆固定一个,扫另一个,记录清楚,不然实验结论会自相矛盾。
3.2 KLDiv 与 CE 的组合与梯度量级
软标签和硬标签的组合方式有几种写法,效果差别不小。最常见的是加权求和,也就是上一节代码里的形式。另一种是两阶段:先只用软标签训一段,再混入硬标签。还有一种是把硬标签当成温度 T=1 时的软标签一起进 KL,本质上退化成加权求和。
写加权求和时,T*T这个缩放因子经常被漏掉。原因是:对 logits 除以 T 之后做 softmax,再对 logits 求梯度,链式法则会带出一个 1/T² 的因子,乘回 T² 才能让软损失和硬损失的梯度量级可比。漏掉它,软损失的实际梯度会随 T 增大而迅速变小,表现出来就是"温度调大了以后软标签几乎不起作用",很多人以为是 alpha 不够,其实是这里。
# losses.py —— 软硬损失组合的三种写法对比 import torch.nn.functional as F def kd_weighted(s_logits, t_logits, labels, T=4.0, alpha=0.7): """加权求和:软损失乘 T^2 补偿,最常用""" soft = F.kl_div( F.log_softmax(s_logits / T, dim=-1), F.softmax(t_logits / T, dim=-1), reduction="batchmean", ) * (T * T) hard = F.cross_entropy(s_logits, labels) return alpha * soft + (1.0 - alpha) * hard def kd_two_stage(s_logits, t_logits, labels, T, alpha, epoch, switch_epoch=5): """两阶段:前 switch_epoch 轮只学软标签,之后混入硬标签""" soft = F.kl_div( F.log_softmax(s_logits / T, dim=-1), F.softmax(t_logits / T, dim=-1), reduction="batchmean", ) * (T * T) if epoch < switch_epoch: return soft hard = F.cross_entropy(s_logits, labels) return alpha * soft + (1.0 - alpha) * hard两种写法的适用场景不同。加权求和适合教师质量高、学生容量不是太小的情况,训练稳定、收敛快。两阶段适合教师和学生差距很大、或者教师本身在部分类别上并不可靠的场景:前期先让学生把分布学个大概,后期用硬标签把决策边界拉回来,避免被教师的错误置信带偏。实际项目里我倾向于先跑加权求和,如果验证集上出现"整体准确率上不去但软损失一直在降"的现象,再换成两阶段试试。
还有一个细节:batchmean和sum的差别。sum不做 batch 归一化,损失值随 batch 大小线性增长,梯度也同比放大,等于隐式改了学习率。如果你的 batch 大小会变(比如用了梯度累积或者动态 batch),一定要用batchmean,不然同一组超参在不同显存配置下表现不一致,这种问题排查起来非常费劲。
3.3 中间层特征与注意力图的接入点
输出层蒸馏是标配,但只对齐最终分布有个天然上限:学生和教师的结构差异决定了它们中间表征的组织方式不一样,光靠输出约束有时候推不动。这时候可以加中间层损失,让学生的某几层特征去逼近教师的对应层。
接入点怎么选?一般看两件事:空间分辨率要能对上,通道数可以不一样但要有个投影层做映射。常见做法是在学生和教师各取三到四个位置,通常是每个 stage 的最后一层,然后对每个位置做一个 1x1 卷积把学生通道数投到教师通道数,再算均方误差或者余弦距离。如果两边分辨率不一致,学生侧做一次自适应池化对齐。
注意力图对齐是另一条路,把特征图在通道维做 softmax 得到空间注意力,然后对两边的注意力图算 KL 或 MSE。这条路对分类任务的效果不如检测分割明显,因为分类任务里注意力图往往很弥散,约束它带来的收益有限。检测、分割这类密集预测任务,注意力蒸馏的收益会明显一些。
注意:中间层损失一定要配权重衰减项,而且权重要随训练轮次退火。前期特征对齐帮助大,后期如果一直保持高权重,会限制学生自己形成表征,最终精度反而低于只用输出蒸馏的版本。常见做法是从一个小权重开始,在总轮数的 60%~70% 处线性降到 0。
层的选择还有个实操技巧:不要一次全加上,先加一层,看验证集有没有提升,再逐层加。全部加上之后如果掉点,很难判断是哪一层的问题。我见过直接照搬别人论文配置、五个位置全开、权重设 1.0 的,跑出来比不加还差三个点,最后发现是其中一层的分辨率对不齐,池化方式写错了。
4. 显存与吞吐:把蒸馏训练压进一张卡
蒸馏训练的显存占用和普通训练不是一回事。多出来的部分是教师前向的激活和两份 logits,如果教师不冻结或者没放进 no_grad,占用还会翻倍。这一节按显存、精度、学习率三个维度说怎么把这套训练压进一张消费级卡里,同时别把精度压没了。
4.1 batch size、梯度累积与有效样本数
先算一笔账。学生前向反向的激活占大头,教师前向的激活如果只是推理、没有反向图,理论上可以随算随释放,但实际框架里中间张量还是会占一段时间。经验上,蒸馏训练的显存占用大约是同等规模学生单独训练的 1.3 到 1.8 倍,倍数取决于教师比学生大多少。
显存不够时的第一反应是降 batch,但 batch 降太小会让 BatchNorm 的统计量失真,尤其是学生网络里 BN 层多的时候。这时候用梯度累积:把 batch 拆成几个 micro-batch,梯度累加之后再 step 一次。注意两点,一是 BN 的统计量是按 micro-batch 算的,不是按有效 batch 算的,如果 micro-batch 太小(比如 2),BN 会不稳,这种时候要么换 GroupNorm,要么把 micro-batch 保持在 8 以上;二是学习率要按有效 batch 缩放,累积 4 次等于有效 batch 乘 4,学习率不跟着调,收敛速度会变慢。
# 单卡显存紧张时的启动参数示例 python -m torch.distributed.run --nproc_per_node=1 train.py \ --config configs/distill_s.yaml \ --batch-size 32 \ # 单次前向的实际 batch --accum-steps 4 \ # 梯度累积步数,有效 batch = 32 * 4 = 128 --lr 3e-4 \ # 按有效 batch 缩放后的学习率 --amp \ # 开启混合精度 --cache-teacher-logits # 教师 logits 走离线缓存这段命令里,有效 batch 是 128,学习率对应的是 128 这个量级,而不是 32。很多人从单卡 batch 32 的训练脚本直接搬过来改累积步数,学习率不动,结果就是收敛慢一半、白白多跑几十轮。判断标准很简单:改了有效 batch 就在原来学习率基础上按比例放大,线性缩放规则在小范围内基本够用,超出范围就得重新扫。
4.2 混合精度下 logits 的数值稳定性
开 AMP 之后最先出问题的是 softmax 相关的计算。教师 logits 除以一个较大的 T 之后,数值范围被压缩,fp16 的表示精度在这个区间可能不够,尤其是类别数很大、logits 分布很宽的时候。表现出来是损失偶尔变成 NaN,或者训练前几百步正常、后面突然崩掉。
解决办法有三个层次。最省事的是把 softmax 和 kl_div 这几步强制转成 fp32 算,框架一般支持 autocast 的禁用上下文,包一下就行。第二层是给 logits 做数值裁剪,把极端值截掉,但这一步会改变分布,要谨慎。第三层是把教师的输出统一存成 fp32,即使模型前向用 fp16,输出也升精度,这个在缓存场景下尤其重要——缓存成 fp16 之后再读出来做 softmax,精度损失是永久的,无法恢复。
# amp_safe_loss.py —— 把 softmax 相关计算锁在 fp32 import torch import torch.nn.functional as F def safe_kd_loss(s_logits, t_logits, labels, T=4.0, alpha=0.7): # 关键:logits 先升到 fp32,再做除温和 softmax s_fp32 = s_logits.float() t_fp32 = t_logits.float() soft = F.kl_div( F.log_softmax(s_fp32 / T, dim=-1), F.softmax(t_fp32 / T, dim=-1), reduction="batchmean", ) * (T * T) hard = F.cross_entropy(s_fp32, labels) return alpha * soft + (1.0 - alpha) * hard这段的代价是损失计算这一小段跑在 fp32,显存和速度影响很小,因为参与的张量只有 batch × 类别数,和主干网络的激活比起来微不足道。我一般默认就这么写,省得后面出玄学 NaN 再回头查。如果开了 AMP 之后 loss scale 频繁触发回退(日志里能看到 scale 值一路往下掉),基本可以确定是这里的问题。
还有一个容易被忽视的点:教师前向如果也开 AMP,输出分布本身就有精度损失,两个都有损的分布再做 KL,误差会叠加。教师侧建议直接用 fp32 前向,或者至少把最后的分类头保持 fp32。教师是只读的,慢一点无所谓,学生侧才是吞吐瓶颈。
4.3 学习率、冻结策略与 warmup
蒸馏训练的学习率通常比学生单独训练时小。原因是软标签提供的梯度更平滑、更"稠密",学习率太大容易在初期就被教师的分布拽着走,反而丢掉了硬标签本来能提供的判别信息。经验值是单独训练的 0.5 到 0.8 倍,具体还得看 alpha——软标签权重越高,学习率越要往下压。
教师侧当然全程冻结,这个没得商量。但有个更隐蔽的冻结问题:如果教师网络里有 BatchNorm,而且你在训练时忘了切 eval 模式,BN 会用当前 batch 的统计量做归一化,导致同一个样本在不同 batch 组合下输出不一致,软标签变成噪声。这种问题不会报错,只会让指标比预期低几个点,排查时很难想到。所以训练循环里teacher.eval()那一行不是可选项。
warmup 在蒸馏里比普通训练更重要。训练初期学生的输出分布和教师差得很远,KL 的梯度方向可能很不稳定,前几百步用线性 warmup 把学习率从很小拉上来,能明显减少初期的震荡。warmup 步数按总步数的 3% 到 5% 设,总步数很少的实验(比如几百步)可以适当加长。
学习率策略上,余弦退火是稳妥选择,配合 warmup 基本不用调。如果发现训练末期指标还在缓慢上升、没有平台期,说明总轮数不够或者退火太急;如果很早就平了然后开始掉,那是过拟合,回头加正则或者减轮数。这两个现象用同一条损失曲线看是能区分开的,前提是你把训练损失和验证指标都记下来了。
5. 避坑与排查:五条翻车记录
蒸馏这套东西的麻烦之处在于,大部分错误不会报异常,只会让最终指标低几个点,而低几个点在小数据集上完全可能是随机波动,你根本分不清是代码错了还是模型就这样。下面五条是我在不同项目里真实遇到过的,按"现象 → 原因 → 解决"写,遇到类似症状可以先对照着查。
5.1 软损失一直降但验证准确率不涨
现象:训练日志里 soft loss 稳定下降,hard loss 也在降,但验证集准确率卡在比学生单独训练还低的位置,怎么调轮数都没用。
原因:教师和学生的预处理不一致。教师的输入归一化用了自己训练时的那套均值和方差,学生侧用的是另一套,两者看到的像素分布不同,教师输出的软标签建立在"另一套输入"上,学生再怎么学也对不齐。这类问题在跨项目复用教师时特别常见,因为归一化参数往往写在各自的配置里,没人会去核对。
解决:把教师和学生共用的数据管线单独抽出来,归一化参数只在一处定义。检查方法是拿同一个 batch 分别过教师和学生,看两边输入的 min/max/mean 是否一致,不一致就先修这个,别急着调超参。修完之后软损失的量级通常会明显变化,那才是正常状态。
5.2 训练到一半损失突然变成 NaN
现象:前几百步正常,某一步之后 loss 直接 NaN,之后全是 NaN,重启训练换个种子又在别的位置崩。
原因:混合精度下 softmax 溢出,或者教师 logits 里出现了极端大的值。分类头如果没做权重初始化约束,某些类别在训练初期可能给出很大的 logits,除以温度 T 之后仍然超 fp16 范围,softmax 一算就 inf。
解决:把 softmax 和 KL 的计算锁在 fp32,前面 4.2 节的写法可以直接用。另外检查梯度裁剪是否生效,蒸馏初期软硬损失量级不一致,偶尔会出梯度尖峰,裁剪阈值设 1.0 到 5.0 之间比较稳妥。如果加了这些还崩,就去打印每一步 logits 的最大绝对值,定位到具体是哪个 batch 出的问题,大概率是那条样本本身有问题。
5.3 换了大温度 T 之后效果反而变差
现象:按经验把 T 从 4 调到 8,理论上应该暴露更多暗知识,结果验证指标掉了一个多点。
原因:只调了 T 没调 alpha,也没检查 T² 补偿。温度变大之后软损失的有效梯度变小,软标签实际上被削弱了,模型退化成主要靠硬标签训练,而硬标签的效果本来就不如教师。另一个可能是类别数不多,T=8 之后分布接近均匀,软标签里的信息被抹平了。
解决:T 和 alpha 一起扫,别单动一个。判断 T 是否过大,可以算教师分布在验证集上的平均熵,熵接近 log(类别数) 就说明已经太平了。小类别数任务(几十类)建议 T 不超过 5,大类别数任务可以往上走,但也要配合 alpha 上调。
5.4 教师 logits 缓存读到旧版本
现象:改了温度或者换了教师检查点,重新训练,指标和上一轮一模一样,怀疑代码没生效。
原因:缓存文件名没带配置哈希,训练直接命中了旧缓存。这类问题最难查,因为训练过程一切正常,日志也正常,只是数据是旧的。
解决:缓存文件名里带上教师检查点的哈希、温度值、输入分辨率三个要素,任何一个变了就重新生成。运行前先打印一次缓存命中路径和文件修改时间,确认是本次生成的。更彻底的做法是缓存目录按实验编号分,不同实验互不干扰,磁盘不够就定期清理。
5.5 多卡训练后指标比单卡差
现象:同样的配置,单卡跑出来的结果比四卡好,而且不是一点半点。
原因:学习率没按有效 batch 缩放,或者梯度累积和分布式训练的梯度同步有冲突。还有一种是 BN 在多卡下统计量跨卡同步,样本分布不均时统计量会偏。
解决:先按有效 batch 缩放学习率,再做一次单卡和四卡的小规模对照实验(比如各跑总步数的 10%),看损失曲线是否重合。如果四卡的损失曲线前期明显更抖,检查梯度同步是否正确、有没有哪张卡的样本分布特别偏。BN 的问题可以把学生网络的 BN 换成 GroupNorm 做对照,如果换了之后差异消失,那就是统计量的问题。
6. 验证与进阶:怎么确认蒸馏真的学到了东西
跑完训练看一个验证准确率是不够的。准确率只反映最终决策对不对,不反映学生有没有学到教师的分布形状,而后者才是蒸馏的价值所在。我一般会做三组对照,第一组看学生和教师在验证集上的预测一致率——只统计两边都预测正确的样本,这部分一致率应该很高,如果只有七八成,说明学生的表征和教师差得太远,软标签没真正传过去。第二组看置信度分布,把学生和教师在正确样本上的最大 softmax 值画成直方图,正常情况下学生的分布应该比教师更平缓一些,如果学生比教师还尖,那多半是温度设小了或者软损失权重不够。第三组看逐类召回,重点看那些教师强、学生弱的类别,这些类别往往就是样本少或者视觉差异细微的,是下一步加数据或加中间层损失的切入点。
# eval_distill.py —— 三组对照的验证脚本 import torch import torch.nn.functional as F @torch.no_grad() def eval_distill(student, teacher, loader, device): student.eval(); teacher.eval() agree, total, correct_s, correct_t = 0, 0, 0, 0 conf_s, conf_t = [], [] for images, labels in loader: images, labels = images.to(device), labels.to(device) s_logits = student(images) t_logits = teacher(images) s_pred = s_logits.argmax(-1) t_pred = t_logits.argmax(-1) # 一致率只统计教师预测正确的样本,避免被教师错误带偏 mask = t_pred.eq(labels) agree += (s_pred[mask] == t_pred[mask]).sum().item() total += mask.sum().item() correct_s += s_pred.eq(labels).sum().item() correct_t += t_pred.eq(labels).sum().item() # 正确样本上的最大置信度,用于比较分布陡峭程度 conf_s.append(F.softmax(s_logits, -1).max(-1).values.cpu()) conf_t.append(F.softmax(t_logits, -1).max(-1).values.cpu()) print(f"teacher_acc={correct_t/len(loader.dataset):.4f}") print(f"student_acc={correct_s/len(loader.dataset):.4f}") print(f"agreement_on_teacher_correct={agree/max(total,1):.4f}") return torch.cat(conf_s), torch.cat(conf_t)这段脚本里,agreement_on_teacher_correct是最有信息量的一个数。它排除了教师自己判断错的样本,只看"教师认为对的那些,学生跟不跟得上"。这个值低,说明学生没学到教师的判别方式,再怎么调损失权重也有限,得回头查数据管线或者考虑加中间层监督。置信度那两个张量拿回来之后可以直接算均值和分位数,学生均值比教师低 0.05 到 0.15 是常见的健康区间。
把蒸馏当成一次性任务是很容易翻车的。我现在的习惯是:任何一次蒸馏实验开跑之前,先花二十分钟跑一个小规模的健全性检查——取一个 batch,确认教师 eval 模式、确认输入归一化一致、确认软硬损失量级在同一数量级、确认梯度没有 NaN。这四件事花的时间不多,但能挡掉后面八成"跑了一整天发现结果不对"的情况。有一次我图省事跳过了这步,四张卡跑了两天才发现教师侧忘了切 eval,软标签全是带噪声的,最后只能从头再来。从那以后我每次启动作业前都强制走一遍这套检查,写在脚本里,不靠记性。希望帮到你。
本文还有配套的精品资源,点击获取