前一阵子我把知识蒸馏(Knowledge Distillation,KD)用到某个图像分类项目上,遇到一个让我挺意外的情况:教师网络从一个几十MB的模型换成一个接近两倍参数量的大模型之后,蒸馏出来的学生模型精度非但没有涨,分类准确率反而掉了将近0.3个百分点。这个项目当时最棘手的约束是推理资源有限,必须用小模型上线,我指望蒸馏能把大模型的余力都压榨出来,结果换了个“更强”的教师,学生反而不行了。
后来复盘才发现,知识蒸馏从来不是“找一个强模型、把输出搬过来”这么简单。它更像是一门手艺活,教师的选择、温度的设定、特征的匹配方式、多个loss之间的权重调节,每一项都会直接决定最终学生模型的上限。这篇文章我不打算复述KD的基础推导,而是围绕我实际做过、踩过坑的几个高级应用方向展开:蒸馏到底在传递什么、教师模型怎么选、匹配层怎么设计、温度与alpha怎么动态调、不同场景(推荐、检测、跨架构)下蒸馏的差异,以及几个典型“翻车现场”的排查思路。如果你已经跑通过简单的KD流程,正准备进一步压榨蒸馏收益,这篇文章应该能给你一些不一样的参考。
1. 重新理解蒸馏:它传递的不是输出,而是决策边界
1.1 hard label只告诉你“是什么”,soft label还告诉你“像什么”
很多入门教程会把蒸馏解释成“让小模型模仿大模型的输出”,这个说法不算错,但很容易产生误导。因为如果只是模仿输出,那对小模型来说其实只是在做一个“更难的数据标注”任务——把教师模型的预测结果当成新的标签去训练,本质上跟普通监督学习没有区别,收益也非常有限。
KD真正的信息增量在于soft label的分布形状。一个训练充分的教师模型,对“猫”这张图输出的可能不只是“猫:0.95”,它还会给出“狗:0.03”“狐狸:0.01”“老虎:0.005”这样的长尾概率。这些非正确类别的概率虽然很小,却编码了教师对“猫跟狗比跟汽车更相似”这种类间关系的理解。小模型如果只学hard label(one-hot),它唯一的优化目标就是把猫这个类别的logit无限拉高,其他类别全部压到很低;但跟着soft label学的时候,它必须同时拟合“猫跟狗比较像”这一层隐含约束,学到的决策边界自然更平滑、更鲁棒。
我打个比方。hard label就像老师批卷子只写“对”或“错”,soft label则是老师在旁边批注:“你这道题虽然做对了,但离满分还差在某某步骤,思路更接近另一种解法”。后者的信息量明显更大,这也是蒸馏能在小模型上带来稳定收益的根本原因。
1.2 温度T:把暗知识从logits里“逼”出来的旋钮
教师模型的原始输出是logits,也就是softmax之前的那组数值。直接用softmax得到的概率分布往往过于尖锐——正确类别概率接近1,其他类别概率接近0,那些携带类间关系的“暗知识”几乎被淹没。知识蒸馏中引入温度系数T,就是要手动把分布“摊平”,让那些原本非常微小的负类别概率变得可见、可学习。
softmax公式从标准形式变成 $q_i = \frac{e^{z_i / T}}{\sum_j e^{z_j / T}}$ 。T越大,所有 logits 被压缩得越厉害,分布越平滑;T=1时退化成普通softmax。实际应用中,我常用T=3~5,但具体取值跟任务难度强相关,这一点后文会单独讲。
很多人在这一步有一个误解:以为温度T是“越大越好”。其实不是。T过大,分布变得过于均匀,学生学不到类间差异的精细结构;T过小,soft label退化回近似hard label。可以粗浅地理解成:温度负责决定“把暗知识放大到什么程度”,它跟教师模型本身的logits尺度、类别数量、任务复杂度是耦合的。
1.3 从梯度视角看KD:为什么它能让小模型泛化得更好
只看loss公式很难理解蒸馏的威力,换个角度从梯度看就清楚多了。标准交叉熵训练时,一条样本只会对ground truth类别产生正的梯度,其他所有logits都被往低处压,而且梯度幅度跟模型在正确类别上的置信度无关。KD的KL散度项则不同,它对每一个类别都产生梯度,幅度正比于教师和学生在该类别上的概率差。
这意味着蒸馏过程不只是强行把“猫”的logit拉高,而是同时调整整个输出向量,让它整体的分布形状向教师靠近。当教师对“猫跟狗比较像”这件事表达了稳定倾向时,学生也会学到这层信息,相当于在特征空间里多了一组“软约束”。这种约束能显著缓解小模型在数据稀疏情况下的过拟合,因为它提供了类似于正则化的额外监督信号。
理解这一点对后面排查优化器问题很有帮助——如果你发现蒸馏loss下降得很好,但任务指标纹丝不动,大概率是KL散度这一路梯度在优化器里占比太小,被交叉熵梯度挤掉了,这个场景我会在第6节专门展开。
2. 教师模型的选择:强的标准不是参数量,而是泛化能力
2.1 教师过拟合,学生继承噪声
回到开头说的那个“换了大教师反而掉点”的问题。后来我把两个教师的训练曲线拉出来对比,发现那个参数量更大的教师在训练集上的准确率确实更高,但在验证集上反而不如小一号的教师,属于典型的过拟合状态。蒸馏时我们以为在传递“知识”,但实际上教师把自己记住的训练集噪声也一并传给了学生。
这是个很容易被忽略的陷阱:KD教学到的上限取决于教师的泛化误差,而不是训练集上的拟合程度。一个在验证集上更稳健的教师,即使绝对精度略低,蒸馏出来的学生往往更好。所以我现在选教师的第一原则不是“有多大选多大”,而是先看教师的验证集表现,再看训练集和验证集之间的gap。
如果项目里只能用一个已经训练好的大模型当教师,而这个教师明显过拟合,有一个补救办法:对教师logits做平滑,或者先对教师进行SWA(随机权重平均)再蒸馏。SWA相当于把训练末期多个checkpoint的权重做平均,能明显提升泛化性,但很多同学不知道这一步是可以跟蒸馏组合使用的。我试过一次,教师验证集accuracy涨了0.5个点左右,蒸馏出来的学生也跟着涨了不少。
2.2 单教师、多教师集成、同构教师:三种实践打法
教师模型的组成方式也是一个常被低估的设计维度。
单教师:最常见。优点是简单稳定,适合快速实验。缺点是一个教师的知识结构是固定的,学生只能从一种视角理解任务。
多教师集成:把多个教师模型的soft prediction做平均,或者把多个教师模型的特征分别匹配,再融合gradient。这个做法对精度提升非常明显,因为集成的logits分布更平滑、更鲁棒,相当于提供了一个“中庸但更可靠”的软标签。缺点是训练成本翻倍。实操中我见过很多人直接把多个教师的logits取平均,但有一个细节要提醒:如果做logits平均,应该先对每个教师的logits做softmax(带温度),再平均概率分布,而不是直接平均logits。因为不同教师logits的尺度差异很大,直接平均会被绝对值大的教师主导,效果反而不稳定。
同构教师:教师和学生使用同一种网络结构,只是宽度或深度不同。我个人的体验是,同构教师蒸馏的稳定性最好,迁移效果也最可预测;异构教师(比如ResNet教师蒸馏Transformer学生)的收益波动较大,需要更精细的特征匹配设计。所以在新场景里,我一般会先用同构教师跑通基准,再考虑换更强的异构教师。
2.3 什么时候应该转向Feature蒸馏
一个常见问题是:“我已经用KD loss了,效果一般,要不要上特征匹配?”我的判断标准很简单:看任务类型。如果是图像分类这种“全局决策”任务,logits蒸馏基本够用;但如果是目标检测、语义分割这类“稠密预测”任务,教师最后几层的特征图中包含了每个位置的空间语义信息,单靠logits蒸馏,学生很容易学成“全局像、局部崩”。
从工程成本看也是一样。logits蒸馏只需要额外存储教师的logits或实时过一遍教师forward,而特征蒸馏通常需要保存或重算教师中间层feature,显存和内存开销都更高。不要一上来就全上,先明确当前瓶颈到底在决策层面还是表征层面。
3. 特征蒸馏的匹配层设计:对齐什么、怎么对齐才不崩
3.1 为什么logits蒸馏在稠密预测任务上会失灵
图像分类任务中,模型最后的全局平均池化把空间信息全部压缩成一个向量,logits是这个向量的线性映射。所以“模仿logits”基本等同于“模仿高层语义”,学生只要把高层语义学到位,整体决策就差不到哪去。
检测、分割这类稠密预测任务则完全不同。模型的每个位置都有独立的预测头,某个小目标可能只在一小块特征图上有效。教师logits给出的信息是全局聚合后的结果,学生拿到这个信号,根本不知道该把哪个空间位置上的特征调对。这时候就必须让中间层特征直接对齐,把教师特征图每个位置的“局部结论”都作为监督信号。
3.2 匹配层的基本结构与参数选择
教师特征和学生特征往往存在两个天然差异:通道数不一致、空间尺寸不一致。因此不能直接把特征图拿来做loss,中间需要一个匹配层(projector)。
最常见的匹配层结构是一个 $1\times1$ 卷积 + 可选的上采样或池化。伪代码大概是:
class MatchLayer(nn.Module): def __init__(self, student_dim, teacher_dim): super().__init__() # 先对齐通道数,再对齐空间分布 self.align = nn.Conv2d(student_dim, teacher_dim, kernel_size=1, bias=False) def forward(self, student_feat, teacher_feat): s_feat = self.align(student_feat) s_feat = F.normalize(s_feat, dim=1) t_feat = F.normalize(teacher_feat, dim=1) return s_feat, t_feat这里有几个我自己踩过坑的参数细节:
- 通道对齐不要一步到位用大卷积核。教师特征维度可能是2048,学生特征只有256。如果用一个全连接把256强行映射到2048,匹配层参数量暴增,训练初期匹配层的梯度很容易把学生主干弄崩。正确做法是先用 $1\times1$ 卷积把学生通道升到教师附近,再用LayerNorm或BatchNorm把两个特征都归一化。归一化这步非常关键,因为不同模型特征的scale差异很大,不归一化直接算MSE,loss数值会被教师特征的平均能量主导。
- 空间尺寸对齐优先选池化/插值,而不是堆卷积。学生的下采样倍数经常和教师不同(比如教师输出 $14\times14$,学生输出 $7\times7$),用F.interpolate统一到同一尺寸即可,没必要引入学习参数。
3.3 主流特征蒸馏loss的选型对照
特征蒸馏的loss五花八门,我实际用过比较有代表性的是下面几种,列成表格方便对照:
| 方法 | 核心思想 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| L2/MSE | 特征直接逐像素对齐 | 实现简单、稳定 | 强制对齐可能压抑学生自身表达能力 | 教师与学生结构相近 |
| NST | 匹配注意力图而非原始特征 | 对通道对齐不敏感,约束更宽松 | 空间信息容易丢失细粒度 | 分类主干蒸馏 |
| SP相似性保留 | 匹配样本间特征相似度矩阵 | 不过分要求逐点一致 | 依赖batch内样本多样性 | 小批量训练场景 |
| PKT概率迁移 | 把特征embeddings建模为概率分布来匹配 | 对特征维度变化不敏感 | 实现相对复杂 | 跨架构蒸馏 |
我自己的经验是:先用L2跑通,再用NST或SP调优。不要一上来就叠加多个feature loss,两个loss之间如果优化方向冲突,学生模型会陷入震荡。L2就像画工笔画,要求每一笔都跟教师一样;NST更像学笔意,只要求整体的注意力分布相似。很多时候后者的上限反而更高。
3.4 匹配层把学生学崩的一次排查
我遇到过一种非常典型的情况:加上特征蒸馏loss之后,学生模型的loss迅速下降到一个极低值,但验证集准确率却跌了接近两个点。查了很久才发现,问题出在匹配层本身。
当时我把256维的学生特征通过一个Linear映射到教师特征维度,且特征匹配用的是L2 loss。训练初期,匹配层的梯度非常大,学生主干的梯度几乎全部来自匹配层,学生被迫让自己的底层特征向教师的Scale看齐,结果底层特征的判别力被破坏了。直到我做了三件事,这个现象才消失:第一,把Linear换成 $1\times1$ 卷积并加了归一化;第二,给特征蒸馏loss一个较低的初始权重(0.1左右,而不是1.0);第三,匹配层单独使用较低的学习率。之后学生模型的主干特征才开始正常收敛。
这个案例给我的教训是:匹配层不是越大越强,恰恰相反,它越是“轻量”,学生被扭曲的风险越小。匹配层的目标是帮助学生对齐教师的语义空间,而不是让学生去模拟教师的全部数值细节。
4. 温度、Alpha与学习率的动态联动:别把超参当成常数
4.1 温度T和任务混淆度之间的规律
温度T在很多开源代码里被写死成3或4,但如果你做过细粒度分类或者类间高度相似的任务,会发现默认T根本不好使。
我做过一个鸟类细粒度识别项目,类别数量不到20,但不同品种外观极其接近。把T从4调大到8~10之后,蒸馏效果出现明显提升。原因在于:细粒度任务中教师对易混类别给出的概率差非常小(比如“灰莺”和“黄莺”的概率是0.48和0.47),T太小的话,这些细微差异在softmax之后几乎被抹平,学生收不到任何“它们很相似”的信号。而类别之间差异明显的任务(比如日常物品分类),T再调大就只会让分布变均匀,损失信息。
可以总结出一个粗粒度的规律:任务类别越细、越混淆,T越要调大;类别区分度越高,T保持在3~5即可。实际操作时可以把教师logits的分布画出来看一眼:如果正确类别的logit比第二名高出非常多,就不需要大T;如果前几名的logit差距很小,T往8~10甚至更高去调。
4.2 alpha要动态:训练初期靠教师带,后期要让学生独立
常规KD loss写出来是 $\mathcal{L} = \alpha \cdot \mathcal{L}{KD} + (1-\alpha) \cdot \mathcal{L}{CE}$,很多代码把alpha固定为0.5或0.7。我的体感是,固定alpha不是不能用,但存在一个被很多人忽略的问题:训练后期,学生本身已经有了不错的判别能力,此时如果仍然保持很高的KL权重,学生会继续被教师“拖着走”,自己的决策边界反而学不到位。
更合理的一种做法是让alpha动态衰减:训练初期 α≈1.0,让绝大多数学信号都来自教师;随着epoch推进,α线性或余弦衰减到0.1~0.2,把训练重心逐渐交还给ground truth。我当时用一个简单策略:前10个epoch α=0.9,中间20个epoch线性降到0.5,最后10个epoch降到0.2,整体效果比固定α=0.7稳定不少。
还有一个细节:α的调整要和T联动。高温状态下KL蒸馏提供的梯度相对平滑,适合学生快速把整体分布学“像”;后期如果想让学生学到更精细的边界,可以同时把T从高往低调。这个“温度退火 + alpha衰减”的组合,是我在实际项目中复现度最高的一套动态策略。
4.3 在线蒸馏里最容易翻车的BN统计问题
高级应用里经常会用在线蒸馏(教师和学生同步更新),这就涉及一个很隐蔽的问题:教师网络的BatchNorm统计量。
离线蒸馏时,教师是固定不动的,BN统计量也是训练完的,不存在问题。在线蒸馏时,教师如果是冻结的(freeze),BN统计量是历史累积的,还算稳定;但有些工作会让教师和学生一起更新(或者教师用EMA更新),这时候教师的BN统计量会不断漂移,soft label的分布也会不稳定,学生训练loss会频繁抖动。
我建议在线蒸馏时给教师网络单独开一个EMA(指数移动平均)通道,只在推理时用EMA权重更新来生成soft label,且教师BN的momentum要调小。否则你会发现,学生loss曲线看起来在下降,但每次教师权重一更新,loss立刻跳一下,最后模型精度的方差非常大。
5. 跨场景实践:推荐、检测、跨架构蒸馏时的差异
5.1 推荐系统里蒸的不是分类,是排序关系
知识蒸馏在推荐系统里的思路跟图像分类差别很大。推荐场景(尤其是双塔模型)中,学生塔通常部署在资源受限的客户端,教师塔在服务端。这里蒸馏的目标不是拟合教师输出的概率分布,而是拟合一个核心的东西:物品之间的排序顺序。
教师双塔对一个候选物品集合给出的embedding内积分数,构成了一个排序列表。直接拿这个列表的原始分数给学生做回归,会遇到“分数尺度不稳定”的问题,因为教师embedding的范数会随训练波动。我实践下来更稳的方法是:把教师分数转换为候选物品之间的分布(比如softmax + 温度),用KL散度让学生塔在同样的物品集合上输出接近的分布。这等价于让学生学会“教师眼里的相似关系”,而不是死记分数。
另一个推荐蒸馏的坑是冷启动物品。教师训练时没有见过的物品,它的输出分布本身就不可靠,蒸馏到学生身上容易放大误差。我在一个搜索推荐项目里的做法是:对教师logits置信度过低的样本,直接减小蒸馏loss的权重,让ground truth主导这部分学习。
5.2 目标检测蒸馏:分类头、回归头、特征一个都不能少
检测任务里,分类logits蒸馏只占了很小一部分收益,大头来自回归头的分布蒸馏和骨干特征蒸馏。
回归头在检测里输出的是bbox的偏移量,教师对这些偏移量的预测通常不是单点值,而是带不确定性的分布(比如用Laplacian拟合)。但很多实现会直接把教师的回归目标当成确定值去蒸馏,学生学到的只是“教师的预测值”,而不是“教师的预测置信度”。更有效的做法是蒸馏回归的不确定性参数——让学生不仅学会预测bbox,还能学到“教师对这个预测有多确定”。
检测蒸馏里还有一个容易出错的预处理细节:教师和学生如果用了不同的数据增强策略,那么在特征对齐时,teacher feature和student feature对应的空间位置可能根本对不上。我当时在某个检测项目里就犯了这种错——教师用了多尺度训练,学生用了固定尺度,特征图的空间对应关系错位,蒸馏loss怎么调都降不下去。后来统一了两者的输入尺寸和增强策略,问题才解决。
5.3 跨架构蒸馏的“反直觉”体验:别硬上特征匹配
最近做的一个探索性项目里,教师是有监督预训练的CNN,学生是Vision Transformer。理论上两者能力应该可以互相蒸馏,但我第一版直接用特征匹配,效果很差。原因在于CNN特征和Transformer特征所在的语义空间差异太大:CNN低层特征偏向边缘和纹理,Transformer的Patch Embedding已经做了全局的位置编码交互,直接逐通道对齐就像拿米尺量温度,量纲都不对。
后来换成两组方案后效果好很多:一组是只蒸馏logits和注意力图;另一组是把教师特征作为额外的辅助任务,输出经过一个浅层MLP之后用来预测“教师特征的主成分方向”,而不是逐元素对齐。这个探索的实际结论是:跨架构蒸馏,优先选择语义更抽象的层次来匹配,越靠近输出层越安全,越靠近底层越容易出问题。
6. 蒸馏“翻车现场”排查实录
6.1 现象一:蒸馏loss掉得很漂亮,精度纹丝不动
这个现象在叠加了多个蒸馏loss时很容易出现。排查思路是从梯度切入。
第一步,把KD loss和CE loss分别打印出来,看它们的量级。KL散度是两个分布之间差异的度量,数值天然比CE小一两个数量级。如果两个loss直接相加,没有权重修正的haul,优化器绝大部分梯度来自CE,KD只是“陪跑”。第二步,检查两个loss的梯度norm,用tensorboard或者简单打印都行。如果KL那一路gradient norm长期比CE小10倍以上,基本就是alpha和loss量级不匹配。
我的处理办法:不要只看alpha,先手动把KL loss乘以一个scale系数(比如10或100),让两者的初始梯度norm处于同一量级,再让alpha在此基础上调整。很多人卡在这一步,是因为alpha调来调去,没意识到问题的本质是两个loss的度量尺度不同。
6.2 现象二:教师logits“太自信”,蒸了个寂寞
当教师模型在训练集上收敛得非常好时,softmax带温度的分布也可能会非常尖锐。比如正确类别概率0.998,其余类别加起来0.002。这时候温度T即使调到5,分布依然接近one-hot。而一旦soft label退化到近似hard label,蒸馏就退化成“重新标注”,信息增益大打折扣。
这个问题的排查很简单:把教师的logits排序后打印出来,看第二大概率是多少。如果第二大概率长期在0.01以下,就需要调高T,甚至可以把T调到10以上。还有一个替代方案是使用logit标准化:把教师logits先减去均值再除以标准差,人为放大类间差异的可见度,再进行softmax。这种方法在我的细粒度实验里效果不错。
6.3 现象三:蒸馏之后比直接训练学生还差
这是最打击人的情况。排查顺序建议如下:
- 先确认教师模型本身在验证集上确实比学生强。很多时候我们默认“大模型一定更好”,但验证集一测发现教师和学生差不多,蒸馏自然没有收益空间。
- 从最简单的logits蒸馏开始,逐步把feature蒸馏、注意力蒸馏关掉。确认是哪个loss导致精度倒退,再单独调优那一路。
- 检查学生的学习率是否需要降低。蒸馏等于给学生增加了额外监督,梯度信息密度更高,如果还用原来的学习率,很容易过冲。
这个排查过程可能很枯燥,但它能帮你建立对每一路loss作用的体感。不要同时改三个变量,一次只关一个,记下精度变化。
6.4 现象四:混合精度训练下,多loss梯度溢出和NaN
高级蒸馏经常在同一个step里叠加五六个不同的loss(CE + KD + feature MSE + NST + aux)。此时如果开着混合精度,个别loss的梯度特别大或特别小,很容易触发上溢/下溢,表现为loss突然变为NaN。
我建议给每个loss单独设置梯度累积的scale策略,或者干脆在混合精度开关里对某些蒸馏loss使用fp32计算;更简单粗暴的做法是给每个loss设一个clamp范围。排查的时候,直接把所有loss的项打印出来,看哪个先变成NaN,基本上就是哪一路的问题。不要默认是“训练不稳定”就去降学习率,因为根因很可能是某一小路的loss本身数值异常。
7. 我现在蒸馏训练的固定习惯
最后分享几个我现在做蒸馏项目的默认动作。这些东西不一定是理论最优,但都是我踩过坑之后沉淀下来的习惯,节省了我大量实验时间。
我现在的默认策略是:先只做logits蒸馏,温度取任务混淆度对应的经验值,alpha做线性衰减,跑一版baseline。如果baseline满意,就到此为止;如果不满意,再叠加特征蒸馏,且特征匹配层尽量轻量。每一次增加loss,都会单独保存一个checkpoint用来对比每一步的增益,而不是一口气全部加上。
监控方面,我会额外保存教师模型每类别的平均置信度曲线,以及学生每类别的平均准确率。这两个曲线对齐之后就能快速看出,蒸馏没有涨点的原因是“教师自己也没把握的类别”,还是“学生学不到教师的把握程度”。靠这个手段,我能在两天内判断一个蒸馏方案有没有继续调下去的价值,而不是无脑跑一周。
蒸馏技术的上限很高,但它不是一个可以“开箱即用”的模块。你选择的每一个超参数、每一个匹配层设计,本质上都是在定义“学生应该从教师那里继承什么”。想把模型做得又小又准,值得在这些细节上多花时间。