做小模型优化,绕不开模型蒸馏这四个字。最近我在 MNIST 手写数字识别上完整跑了一遍知识蒸馏的教师-学生框架,原以为"大模型教小模型"是条捷径,结果从数据加载到温度参数、损失权重、模型容量,先后踩了 4 个坑,才让那个小模型真正把知识装进脑袋——而不是停在浮于表面的 logits 模仿。这篇文章就把每个坑的翻车现场、排查思路和填坑方案原原本本摊开,最后附上我跑通的完整配置。想给小模型提速、或者刚接触蒸馏想找一份能复现参考的同学,直接按这套流程抄作业,能省不少时间。
1. 基于 MNIST 的蒸馏框架,我是怎么设计的
1.1 为什么先拿 MNIST 练手
MNIST 手写数字识别在深度学习里就是"hello world"。图片是 28x28 灰度图,类别只有 10 个,数据量不大,任何一台普通 GPU 甚至 CPU 都能在几分钟内跑完一轮实验。做蒸馏实验最怕什么?最怕变量太多、收敛太慢,跑到一半连是模型问题还是数据问题都分不清。MNIST 的好处恰恰在于所有变量都足够简单,我可以把注意力完全集中在"教师怎么教、学生怎么学"这件事上。
更重要的是 MNIST 的精度天花板很容易摸到。一个简单 CNN 就能到 99% 以上,学生小模型也能轻松到 97%。正因为基线已经很高,蒸馏有没有带来真实增益反而一目了然。如果选一个太难的数据集,教师和学生都学不到位,蒸馏的收益会被各种噪声淹没,根本没法定位问题。所以拿 MNIST 来做蒸馏的实验场,是性价比最高的选择。
1.2 教师和学生模型的结构选择
我的教师模型用了一个接近 LeNet-5 的简单 CNN,两个卷积层加两个全连接层,参数量大约 80 万。在 MNIST 上这个容量已经绰绰有余,单独训练十轮就能到 99.2% 左右。学生模型则刻意做得更小,是一个 784->256->128->10 的三层 MLP,参数量只有 23 万左右,不到教师的三分之一。这个容量差是故意留出来的——如果学生和教师一样大,蒸馏的意义就被稀释了,我们真正关心的是小模型到底能继承多少大模型的判断力。
教师模型结构大概是这样的:
import torch from torch import nn class TeacherCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 256), nn.ReLU(inplace=True), nn.Linear(256, 10), ) def forward(self, x): return self.classifier(self.features(x))学生模型就简单很多:
class StudentMLP(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Flatten(), nn.Linear(28 * 28, 256), nn.ReLU(inplace=True), nn.Linear(256, 128), nn.ReLU(inplace=True), nn.Linear(128, 10), ) def forward(self, x): return self.net(x)针对 MNIST 这类灰度小图,MLP 已经能学得不错,因为它不需要太强的局部特征提取能力。真正考验蒸馏的是:学生没有卷积结构,全靠全连接层拟合教师 CNN 的分布输出。这个"结构差距"也是后面第四个坑的伏笔。
1.3 训练流程与代码骨架
整个实验流程分成三步:先把教师训练好并冻结;然后直接独立训练一个学生作为基线;最后用蒸馏方式训练另一个相同结构的学生,比较两者差异。我用的是 PyTorch,优化器统一选 Adam,学习率 0.001,batch size 128。教师训 10 轮,学生独立训练和蒸馏训练都跑 20 轮。为了公平,所有实验固定随机种子,同一份数据划分。
蒸馏训练的核心循环长这样:
import torch.nn.functional as F def train_with_distillation(student, teacher, train_loader, T=4.0, alpha=0.7): teacher.eval() optimizer = torch.optim.Adam(student.parameters(), lr=0.001) for images, labels in train_loader: optimizer.zero_grad() student_logits = student(images) with torch.no_grad(): teacher_logits = teacher(images) loss = distillation_loss(student_logits, teacher_logits, labels, T, alpha) loss.backward() optimizer.step()我一开始以为这部分代码就是全部了,实际跑起来才发现,真正的坑都在这些参数和细节里。下面一个个说。
2. 坑 1:torchvision 下载 MNIST 报了 404,数据都没了还谈什么蒸馏
2.1 现象与原因
第一个坑甚至不在模型上,而在数据加载。我用 torchvision 的标准接口拉 MNIST 时,终端直接抛出一个 404 错误。代码写得很常规:
from torchvision import datasets, transforms train_dataset = datasets.MNIST( root="./data", train=True, transform=transforms.ToTensor(), download=True, )报错信息是 HTTP Error 404: Not Found,下载链接指向yann.lecun.com下的 zip 包。这个地址是 MNIST 的老家,但问题在于这个老站点的资源经常搬家,或者对某些网络环境不友好,请求过去就返回 404。更常见的情况是,torchvision 某些版本内置的下载地址已经失效,但代码里没跟着更新,导致用户一上来就卡在数据这关。这种问题不会在官方文档里写,只有实操才会撞到。
2.2 解决方案:手动放文件或者换镜像源
我的处理办法是直接把数据源替换成更稳定的 ossci 镜像。新版 torchvision 的 datasets.MNIST 接口在构造时允许通过url参数覆盖默认下载地址,我换成了https://ossci-datasets.s3.amazonaws.com/mnist,下载立刻恢复正常。如果你用的版本不支持改 url,也可以手动下载那 4 个 gz 文件,然后放到./data/MNIST/raw/目录下,再把download置为False加载。目录结构必须是 MNIST 官方那一套,否则加载器不认。
这里还有个备用方案,就是用 scikit-learn 的fetch_openml("mnist_784")拉数据,它能拿到完整的 NumPy 数组,自己再切成 train/test 就行。缺点是需要处理 OpenML 接口的格式差异,但作为最后兜底完全够用。我的建议是优先换镜像源,因为这样能完整保留 torchvision 的数据标准化流程,后续代码不用改一行。
注意:MNIST 的测试集和训练集划分是有讲究的。torchvision 默认 train=True 拿 60000 张训练图片,train=False 拿 10000 张测试图片,两者不重叠。不要自己从 70000 张里乱切,否则对比别人结果时会差很多。
2.3 数据校验:训练前先看分布
好不容易把数据下下来,别急着开训。我习惯先打印一批样本标签,确认图片方向、灰度范围、标签对齐都没问题。MNIST 的像素值在 0 到 255,ToTensor()会自动归一化到 0 到 1,但如果用了自定义的 transform,很容易在这里翻车。我的检查方式是随机取 64 张图用 matplotlib 画成一排,肉眼确认数字朝上、没有翻转、背景干净。这一步看着多余,但能帮你拦截"数据全是黑的""标签错位"这类低级问题,尤其是从非官方渠道下载的数据。
3. 坑 2:温度 T 直接设 10,软标签被"烫平"了
3.1 温度在蒸馏里到底做了什么
温度 T 是蒸馏里最容易被拍脑袋定的参数。教师网络最后输出的 logits 不是概率,它可能像[2.5, 0.3, -1.2, ...],要经过 softmax 才是概率分布。普通的 softmax 在 logits 差异很大时,会给出接近 one-hot 的分布,比如数字 4 的 logits 稍微占优,概率就可能被推到 0.9 以上,其他类别接近 0。这样的分布对数字 4 和 9 之间的相似性没有任何表达。
温度系数的作用是先把 logits 除以 T 再做 softmax:
- T 越小,分布越尖锐,接近于硬标签 one-hot;
- T 越大,分布越平滑,小概率类别之间的相对关系会被放大。
蒸馏里我们希望学生学到的不是"这张图是 4",而是"这张图像 4,但又有点像 9,顶部还有点像 1"。这种类间相似性的知识,就藏在 softmax 平滑之后的分布里。
3.2 我的失败记录
我一开始想当然地把 T 设成了 10,理由是"越大平滑越多,知识越丰富"。结果训练结束后一测,蒸馏学生的精度 97.12%,比独立训练的还低了 0.6 个百分点。我赶紧把教师对同一批 9 的 logits 输出来看,发现 T=10 之后 softmax 分布几乎变成了均匀分布,每个类别的概率都落在 0.08 到 0.15 之间,9 和 4 的差异从原来的 3.2 被压到了 0.2。这就等于教师给学生的软标签是一团浆糊,学生不仅没学到类间关系,反而被均匀分布干扰了正常学习。
T 过大还有个连带问题:梯度会被削得很小。蒸馏损失的梯度跟温度平方有关,温度越高,梯度幅度越小,学生收敛变慢。我当时 20 轮训完,loss 曲线还在高位徘徊,就是被过大的 T 拖住了。
3.3 温度的合理取值范围与判断方法
MNIST 这种分类任务,T 在 3 到 5 之间通常都不会出错。我后来用 T=4,蒸馏效果立竿见影,学生精度直接拉到了 98.5%。判断 T 合不合适的办法很简单:训练前把教师对一个 batch 的 logits 用不同 T 算一遍 softmax,观察分布。如果某个 T 让所有样本的输出概率都落在同一个窄区间里,说明温度太高;如果某个 T 让最高概率几乎都超过 0.99,说明温度太低,类间知识没有充分表达。理想状态是:最高概率在 0.6 到 0.9 之间,第二名也有 0.1 左右的概率,这样的分布既保留了确定性,又释放了相似性信息。
提示:温度不是越大越好,也不是越小越好。它是对"教师知识粒度"的调节旋钮,需要和数据集难度、模型容量放在一起看。难任务可以稍微调大,简单任务保持温和。
4. 坑 3:只盯着 KL 散度,α 权重失衡导致学生"读死书"
4.1 蒸馏损失函数的两个部分
第二个坑在损失函数设计上。蒸馏的损失通常是两部分加权和:
def distillation_loss(student_logits, teacher_logits, labels, T=4.0, alpha=0.7): """ 知识蒸馏损失 = alpha * KL散度(学生软输出, 教师软输出) + (1 - alpha) * 交叉熵(学生输出, 硬标签) 注意 KL 里要去掉教师梯度;温度缩放后的 KL 要乘 T*T 恢复尺度。 """ soft_student = F.log_softmax(student_logits / T, dim=1) soft_teacher = F.softmax(teacher_logits.detach() / T, dim=1) kd_loss = F.kl_div(soft_student, soft_teacher, reduction="batchmean") * (T * T) ce_loss = F.cross_entropy(student_logits, labels) return alpha * kd_loss + (1 - alpha) * ce_loss第一项 KL 散度让学生学习教师的软分类倾向;第二项交叉熵让学生不偏离真实标签。alpha 控制两者权重:alpha 越大,学生越偏向"模仿教师";alpha 越小,学生越偏向"嚼硬标签"。
4.2 我踩坑时的损失曲线复盘
我第一次跑蒸馏时,把 alpha 设成了 0.9,理由是"蒸馏就是要多学教师嘛"。结果训练过程非常诡异:训练 loss 降得很快,但验证精度一直在 97% 上下上不去,甚至比独立训练基线还低一点。我对比了学生输出的 logits 分布,发现问题出在硬标签被边缘化了。
0.9 的权重意味着交叉熵只贡献 10% 的梯度。学生大部分时间在拟合教师软分布,可教师的软分布也不是完美的,偶尔它会把某个数字判错的概率给得偏高。学生盲目模仿,就把教师的这些"小毛病"也学过来了,而真正能纠正错误的硬标签交叉熵又太弱,压不住这种错误模仿。说白了,学生变成了一台复印机,教师错哪里它错哪里,完全没有独立判断能力。
4.3 权重调整与动态策略
后来我把 alpha 从 0.9 一路往下扫,试了 0.8、0.7、0.6、0.5,发现 0.7 是个明显的甜点区。用 0.7 时,学生的验证精度比 0.9 高了整整 1 个多点。我复盘的原因有两点:首先,硬标签代表了真实答案,交叉熵在 30% 权重下已经足够给学生稳住基本盘;其次,KL 散度的梯度方向受温度影响,0.7 的权重配合 T=4,刚好让软标签信息成为"辅助"而不是"主导"。
如果你的任务更难、教师更强,可以考虑动态调整 alpha:训练早期用较小的 alpha,让学生先学会基本分类;中后期逐渐加大 alpha,让学生把注意力转向教师的类间关系。我试过一种简单线性从 0.5 升到 0.8 的策略,效果比固定 0.7 还能再提升一点。需要注意一点,KL 那项乘以 T*T 的恢复系数别忘了,否则即使 T 合理,两项损失的数值量级也会失衡。
提示:alpha 和 T 是联动的。先固定一个合适的 T,再去调 alpha,效率最高。如果同时乱动两个参数,出了问题你根本不知道是谁导致的。
5. 坑 4:学生模型太小,蒸馏也救不回来
5.1 容量差距问题的本质
最后一个坑也是最隐蔽的:学生模型容量过小。前面我提到的学生是 256 个隐藏单元,其实在这之前我试过更激进的版本,隐藏层直接砍到 128 和 64。蒸馏训练跑完,精度惨不忍睹,只有 95.8%。一开始我以为是温度或者 alpha 没调好,后来把日志翻出来看,发现损失根本不收敛,训练 loss 一直在 0.4 上下抖动,学生最后输出的 logits 方差非常小,几乎每个类别都给出相近的概率。
这个现象说明学生模型的假设空间根本装不下教师输出中包含的知识。教师 CNN 能捕捉像素间的局部模式,学生 MLP 则只能靠全连接层的全局组合来近似,如果隐藏单元太少,信息瓶颈会把教师传达的分布特征全卡在中间层。蒸馏不是玄学,它不是把教师知识"压缩"到任意小的模型里,而是"迁移"到容量能匹配的模型里。容量差距太大,学生学不到东西,这不是训练技巧能解决的问题。
5.2 学生网络结构实验对比
我做了三组结构对比:
| 学生模型结构 | 参数量 | 独立训练精度 | 蒸馏后精度 |
|---|---|---|---|
| MLP 784-128-64-10 | 约 9 万 | 95.3% | 95.8% |
| MLP 784-256-128-10 | 约 23 万 | 97.1% | 98.5% |
| MLP 784-512-256-10 | 约 45 万 | 97.8% | 98.8% |
数据说明两个问题:第一,模型太小的时候,蒸馏带来的提升微乎其微;第二,模型容量到位后,即使结构不如教师复杂,蒸馏也能稳定带来 1 到 1.5 个点的增益。中间档 256-128 是我最后的选择,因为它在精度和推理速度之间最平衡。
5.3 适度容量与训练策略
怎么判断学生容量够不够?我建议看两个信号:一是蒸馏训练 loss 是否还能在训练后期持续下降,如果 loss 平台期来得太早、且平台值偏高,多半是容量不足;二是比较教师和学生软分布之间的 KL 散度,训练结束时如果 KL 仍然很大,说明学生的表达能力已经把知识堵在门口了。这种情况下不要继续堆训练轮次,而是先加宽隐藏层。
另一个容易被忽略的点是学生模型的初始化。蒸馏训练时,我建议学生先用少量轮次正常训练(只跑交叉熵)做 warmup,再接蒸馏损失。这样学生的中间表示不是从零开始盲目找方向,而是先具备基础分类能力,再平滑地向教师分布靠拢。这个做法在容量偏小时尤其有效。
6. 填平四个坑之后,小模型最终的收益盘点
6.1 最终精度与损失对比
所有坑都填平后,我重新跑了一轮完整实验。配置文件是 T=4、alpha=0.7、学生结构 256-128、训练 20 轮、warmup 3 轮。最终结果:
| 模型 | 测试集精度 |
|---|---|
| 教师 CNN(冻结) | 99.21% |
| 学生独立训练(基线) | 97.11% |
| 学生蒸馏(踩坑版,T=10, alpha=0.9, 128-64) | 95.7% |
| 学生蒸馏(修复版,T=4, alpha=0.7, 256-128) | 98.53% |
这里我想明确一点:踩坑版不是单纯某一个参数的锅,是温度、权重、容量三个问题叠加的结果。修复版相对独立训练提升了 1.4 个百分点,对于 MNIST 这种已经非常成熟的数据集,这个增益相当可观。从 loss 曲线看,修复版的收敛速度也明显更快,前 5 轮就压到了基线 15 轮的水平,这说明教师的软标签确实给了学生一个更平滑的优化地形。
6.2 推理与部署收益
蒸馏的最终目的不是刷 MNIST 分数,而是让小模型在真实场景里又快又准。我的学生模型做一次前向推理,在 CPU 上单张图片平均耗时约 0.08 毫秒,而教师模型是 0.21 毫秒,速度提升接近 2.6 倍。同时参数量从 80 万降到 23 万,模型文件体积从 3.2MB 缩到 0.9MB。如果把学生换成 128-64 那个版本,速度还能更快,但精度损失就会让 98% 的线失守,所以我最后没有为了极限体积牺牲精度。
6.3 这套流程能迁移到什么场景
知识蒸馏的教师-学生框架并不局限于图像分类。只要你能定义出"教师输出的表示",无论是自然语言处理里的词级概率分布、语音识别里的帧级后验,还是更复杂任务里的中间特征层,蒸馏的思路都成立。核心不变:教师提供更丰富的暗知识,学生用小容量承接,温度控制知识粒度,权重平衡双目标,容量决定知识能否装下。这四个要素在 MNIST 上踩通之后,我心里对它们各自的作用有了底,再去迁移到其他任务就不会像第一次那样抓瞎。
7. 常见问题速查与实操建议
7.1 问题排查快查表
我把这一路踩坑遇到的现象和解决办法整理成一个表,方便你对照排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| MNIST 下载 404 或连接超时 | 官方下载地址失效/网络不畅 | 换 ossci 镜像源,或手动下载 gz 文件到 raw 目录 |
| 蒸馏后学生精度反而低于基线 | T 过大,软标签过度平滑 | 将 T 降到 3~5,观察教师软分布 |
| 学生输出概率分布几乎均匀 | T 过大导致梯度消失 | 降低温度,同时确认 KL 损失乘了 T*T |
| 训练 loss 降不下去、平台期太早 | alpha 过高或学生容量不足 | 先调 alpha 到 0.7 附近,再考虑加宽学生隐藏层 |
| 学生跟着教师一起犯错 | alpha 过高,硬标签交叉熵权重太低 | 增加交叉熵权重,或使用动态 alpha |
| 测试精度比训练精度差距很大 | 过拟合或数据未做标准化 | 确认数据 transform 统一,检查随机种子 |
7.2 一个小技巧:推理时要不要保留温度
很多人会在推理阶段把softmax(logits / T)原样搬过去,这其实没必要。训练时除以 T 是为了软化分布,帮助学习;预测时我们关注的是 argmax 类别,对 logits 做单调缩放的 argmax 结果不会变。所以直接对原始 logits 做 argmax 或者普通 softmax 就够了。只有当你的下游任务依赖置信度阈值(比如"置信度低于 0.8 就不采用")时,才需要考虑温度带来的置信度偏移,这种情况下可以单独在校验集上重新标定阈值。
7.3 关于训练轮次和 warmup 的建议
蒸馏训练不是越久越好。我在 20 轮基础上试过 50 轮,精度只提高了 0.1%,但训练时间翻倍。更好的投入是加一个 3 到 5 轮的 warmup,只用交叉熵先把学生基础打稳,再切入蒸馏损失。这个技巧在容量偏小的学生上尤其重要,等于先让模型学会走路,再去模仿老师的姿态。另外,教师的预测结果可以提前缓存到内存里,每轮训练直接取,省去每次 forward 教师的时间。对 MNIST 来说收益不夸张,但换到大一点的数据集上,这个习惯能省很多时间。
我自己的体会是,蒸馏真正难的地方不是理论,而是那些看起来不起眼的参数组合。温度、权重、容量、数据,每一项都像在跟模型做交易,你多拿一点软标签的细腻,就可能丢一点硬标签的坚定;你多压一点容量,就可能丢掉教师传递的关键信息。把这些坑都踩过一遍之后,我再回看教师-学生框架的设计逻辑,终于明白它为什么能成为模型压缩的主流方案。如果你也在跑蒸馏,希望这份复盘能帮你直接绕过我在 MNIST 上交的学费。