资讯详情

资讯详情

知识蒸馏实战:MNIST踩坑与填坑指南

做小模型优化绕不开模型蒸馏这四个字。最近我在 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_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 256), nn.ReLU(inplaceTrue), 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(inplaceTrue), nn.Linear(256, 128), nn.ReLU(inplaceTrue), nn.Linear(128, 10), ) def forward(self, x): return self.net(x)针对 MNIST 这类灰度小图MLP 已经能学得不错因为它不需要太强的局部特征提取能力。真正考验蒸馏的是学生没有卷积结构全靠全连接层拟合教师 CNN 的分布输出。这个结构差距也是后面第四个坑的伏笔。1.3 训练流程与代码骨架整个实验流程分成三步先把教师训练好并冻结然后直接独立训练一个学生作为基线最后用蒸馏方式训练另一个相同结构的学生比较两者差异。我用的是 PyTorch优化器统一选 Adam学习率 0.001batch size 128。教师训 10 轮学生独立训练和蒸馏训练都跑 20 轮。为了公平所有实验固定随机种子同一份数据划分。蒸馏训练的核心循环长这样import torch.nn.functional as F def train_with_distillation(student, teacher, train_loader, T4.0, alpha0.7): teacher.eval() optimizer torch.optim.Adam(student.parameters(), lr0.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. 坑 1torchvision 下载 MNIST 报了 404数据都没了还谈什么蒸馏2.1 现象与原因第一个坑甚至不在模型上而在数据加载。我用 torchvision 的标准接口拉 MNIST 时终端直接抛出一个 404 错误。代码写得很常规from torchvision import datasets, transforms train_dataset datasets.MNIST( root./data, trainTrue, transformtransforms.ToTensor(), downloadTrue, )报错信息是 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 默认 trainTrue 拿 60000 张训练图片trainFalse 拿 10000 张测试图片两者不重叠。不要自己从 70000 张里乱切否则对比别人结果时会差很多。2.3 数据校验训练前先看分布好不容易把数据下下来别急着开训。我习惯先打印一批样本标签确认图片方向、灰度范围、标签对齐都没问题。MNIST 的像素值在 0 到 255ToTensor()会自动归一化到 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 再做 softmaxT 越小分布越尖锐接近于硬标签 one-hotT 越大分布越平滑小概率类别之间的相对关系会被放大。蒸馏里我们希望学生学到的不是这张图是 4而是这张图像 4但又有点像 9顶部还有点像 1。这种类间相似性的知识就藏在 softmax 平滑之后的分布里。3.2 我的失败记录我一开始想当然地把 T 设成了 10理由是越大平滑越多知识越丰富。结果训练结束后一测蒸馏学生的精度 97.12%比独立训练的还低了 0.6 个百分点。我赶紧把教师对同一批 9 的 logits 输出来看发现 T10 之后 softmax 分布几乎变成了均匀分布每个类别的概率都落在 0.08 到 0.15 之间9 和 4 的差异从原来的 3.2 被压到了 0.2。这就等于教师给学生的软标签是一团浆糊学生不仅没学到类间关系反而被均匀分布干扰了正常学习。T 过大还有个连带问题梯度会被削得很小。蒸馏损失的梯度跟温度平方有关温度越高梯度幅度越小学生收敛变慢。我当时 20 轮训完loss 曲线还在高位徘徊就是被过大的 T 拖住了。3.3 温度的合理取值范围与判断方法MNIST 这种分类任务T 在 3 到 5 之间通常都不会出错。我后来用 T4蒸馏效果立竿见影学生精度直接拉到了 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, T4.0, alpha0.7): 知识蒸馏损失 alpha * KL散度(学生软输出, 教师软输出) (1 - alpha) * 交叉熵(学生输出, 硬标签) 注意 KL 里要去掉教师梯度温度缩放后的 KL 要乘 T*T 恢复尺度。 soft_student F.log_softmax(student_logits / T, dim1) soft_teacher F.softmax(teacher_logits.detach() / T, dim1) kd_loss F.kl_div(soft_student, soft_teacher, reductionbatchmean) * (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 的权重配合 T4刚好让软标签信息成为辅助而不是主导。如果你的任务更难、教师更强可以考虑动态调整 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 最终精度与损失对比所有坑都填平后我重新跑了一轮完整实验。配置文件是 T4、alpha0.7、学生结构 256-128、训练 20 轮、warmup 3 轮。最终结果模型测试集精度教师 CNN冻结99.21%学生独立训练基线97.11%学生蒸馏踩坑版T10, alpha0.9, 128-6495.7%学生蒸馏修复版T4, alpha0.7, 256-12898.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 上交的学费。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →