模型压缩这件事,我一直觉得是YOLO系列落地到边缘设备时最值得系统整理的一环。前段时间帮一个嵌入式项目做检测模型迁移,模型在服务器上测试FPS接近七十,换到目标板卡一跑直接掉到个位数,CPU占用拉满,推理一帧要等小几百毫秒。裁输入分辨率、换推理引擎、做动态量化都试了一遍,真正把速度救回来的,还是模型剪枝和知识蒸馏这一套组合拳。这篇文章以YOLO系列为具体对象,把剪枝和蒸馏的原理、落地步骤、超参调节和踩坑记录完整写出来,给打算做模型压缩部署的朋友一份可以直接照抄的作业。
1. 为什么YOLO在端侧跑不动:剪枝和蒸馏各自解决什么问题
YOLO系列本身已经是效率极高的单阶段检测器,但效率和“能在端侧实时跑”是两码事。以一个参数量大约七百万的轻量YOLO为例,推理一次的FLOPs仍然可能高达十几个G,而这只是计算量;真正到了端侧,算力、内存带宽、缓存命中率、算子支持度每一项都会拖后腿。很多边缘芯片的整数算力可能不错,但浮点算力只有服务器的几十分之一,模型推理从几十毫秒变成几百毫秒一点也不奇怪。
很多人第一反应是“换一个小模型”,比如从大型版本换成微型版本,或者从零训练一个窄网络。这个思路没错,但往往踩到一个坑:从零训练的小模型,在同等数据量和训练周期下,精度通常达不到大模型压缩后的水平。原因不复杂——大模型的参数里已经包含了对数据分布更完整的拟合,这些信息很难靠随机初始化的小模型自己重新“悟”出来。这时候就需要模型压缩技术,而剪枝和蒸馏就是两条最常用的路线。
用一句话概括两者的差别:剪枝是做减法,把已经训练好的大模型里冗余的通道、权重删掉,留下一个更瘦但功能几乎不变的结构;蒸馏是做转移,让一个小模型去模仿大模型的行为,把大模型的“经验”迁移到小模型身上。两者可以单独用,但YOLO落地上最常用的方式是把它们串起来——先剪掉冗余结构,再由大模型当老师把精度“喂”回来。
我见过不少团队把这两件事混为一谈,或者只做其中一步就觉得万事大吉。实际上它们解决的问题并不重叠:剪枝解决的是结构冗余,蒸馏解决的是参数信息不足。给一张对比表,应该能把这套逻辑说清楚。
| 方案 | 对结构的影响 | 精度恢复手段 | 适用场景 |
|---|---|---|---|
| 从零训练小模型 | 直接使用紧凑结构 | 依赖训练技巧与大算力 | 时间充足、任务简单 |
| 大模型通道剪枝 | 删除冗余通道 | 剪后微调 | 结构明显冗余、需要激进压缩 |
| 大模型蒸馏小模型 | 结构不变 | 用大模型软标签监督 | 小模型参数有限但想要高精度 |
实际项目里,剪枝之后往往伴随精度掉点,微调能补回来一部分,但补不干净;蒸馏则可以在不改变结构的前提下进一步提升小模型的上限。所以真正工程化的路线是:先剪枝把模型变小,再用原版大模型做蒸馏去补救精度损失。后面几章就围绕这条路线展开。
2. 通道剪枝在YOLO上的落地:从BN稀疏化到依赖层分组
2.1 BN层的gamma系数为什么是通道重要性的最佳度量
通道剪枝的关键问题只有一个:怎么判断哪些通道是冗余的。目前工业界最稳定的做法,是用BN层的gamma系数作为通道重要性的衡量指标。YOLO系列每个卷积层后面几乎都跟着BN层,而BN层在做归一化之后会做一次缩放和平移:
y = gamma * x_hat + beta
其中x_hat是归一化后的特征,gamma是每个通道的缩放系数。如果某个通道的gamma趋近于零,那不管输入是什么,这个通道的输出都接近常数beta,对后续特征几乎没有贡献。这类通道就是典型的冗余通道,删掉它们对整体精度影响很小。
为什么不用卷积核的L2范数来判断?因为卷积核的参数分布受上一层输出尺度影响,不同层的卷积核范数不在同一个量级上,跨层比较没有意义。而BN的gamma直接作用在归一化后的特征上,天然处于同一尺度,可以直接拿来排序。这个细节很重要,很多人一开始用卷积核范数剪枝,结果不同层的保留比例差距极大,精度崩得很厉害。
2.2 稀疏化训练:把冗余通道逼到墙角
直接从预训练模型上按gamma排序剪枝,效果通常不好。原因是预训练模型的gamma分布比较均匀,很难找到一条清晰的“分界线”。你设定剪枝比例后,被剪掉的那部分通道仍有一定的贡献,剪完必然掉点。
解决办法是稀疏化训练:在原有检测损失上,对BN gamma施加L1正则惩罚,让大部分gamma在训练过程中趋近于零。L1范数优化的特点就是产生稀疏解,而不是像L2那样把所有值均匀地缩小。直观理解就是:训练时每条通道都有一个“使用成本”,模型会自动放弃那些不太重要的通道。
稀疏化训练的损失函数大概长这样:
# 在已有检测loss基础上叠加L1稀疏惩罚 bn_gammas = [m.weight for m in model.modules() if isinstance(m, nn.BatchNorm2d)] sparse_term = sum(torch.abs(g).sum() for g in bn_gammas) loss = det_loss + sparse_ratio * sparse_term稀疏系数sparse_ratio不建议一开始就拉满。我习惯从0.0005左右开始,训练二三十个epoch后观察gamma分布直方图,如果大部分gamma仍然集中在1附近,再适当加大系数。理想状态是gamma分布呈现明显的两极分化:一部分接近0,另一部分保持在正常范围。这个过程一般需要五十到一百个epoch,直接使用预训练模型继续训练即可,不需要重新从零开始。
2.3 shortcut与concat带来的索引依赖:YOLO剪枝最关键的坑
YOLO系列的结构远不是简单的卷积堆叠,里面布满了shortcut残差连接和concat拼接操作。残差结构要求两个分支的通道数完全一致;PAN结构的特征融合则把多个分支的特征在通道维度直接拼接。这就带来一个剪枝核心问题:不能单独决定某一层的通道保留情况,必须保证所有关联层使用同一套保留索引。
举个例子,某个C3/C2f模块里的瓶颈结构,主分支经过卷积后与shortcut分支相加。如果你按gamma排序把主分支某个卷积的通道剪掉一批,shortcut分支的通道数没有同步变化,两个分支就无法对齐,模型直接报维度错误。concat操作也一样,拼接后的通道数必须与后续卷积的输入通道保持一致。
所以正规的YOLO剪枝流程要先做“依赖层分组”。简单说就是反向遍历网络结构,找出所有直接或间接共享张量维度的卷积层,把它们归为一组。剪枝时以组为单位,合并组内所有BN的gamma统计量再统一排序,每一层都套用同一个保留索引。分组这一步是YOLO剪枝和普通CNN剪枝最大的区别,也是最容易翻车的地方。
# 伪代码:构建“同一裁剪索引”的层分组 def build_prune_groups(model): groups = [] # 找到所有Concat和shortcut分支,记录它们对应的BN层编号 # 把输入来自同一concat/shortcut的卷积层的BN index放入同一group for node in reversed(model_graph): if node.type in ("concat", "add"): new_group = collect_dependent_bn(node.inputs) groups.append(new_group) # 每个group内部合并gamma统计量,排序后生成统一保留索引 return groups这一块必须写在脚本里自动完成,不要人工判断。YOLO的neck部分层数多、拼接关系复杂,人工数很容易漏。
2.4 剪枝后的模型重建与精度恢复
完成通道筛选之后,需要按照保留索引重建一个更窄的模型。这里有两种实现方式:一种是直接修改网络定义文件,把每层的输出通道数改掉;另一种是动态地从原模型的state_dict里按索引抽取参数,生成新模型。推荐第二种,因为自动化和可复现性更好。
重建时最关键的操作是参数抽取:卷积的权重按输入通道和输出通道两个维度分别筛选,BN的gamma、beta、running_mean、running_var都按输出通道筛选。以torch为例,本质上就是一行index_select的事,但顺序绝对不能错。比如卷积层的weight shape是[out_channels, in_channels, k, k],先按保留索引过滤out维度,再按上一层传来的in维度过滤输入。
剪完之后一定不要直接拿去部署,精度大概率是掉的。需要先做一轮“恢复性微调”,学习率比正常训练小一个数量级,训练二十到三十个epoch。如果稀疏化训练做得充分,微调后精度通常能回到原来的98%以上。之后如果还想进一步压榨,再接入蒸馏阶段。
def rebuild_model_by_mask(old_model, keep_masks): new_model = create_scaled_model(old_model, keep_masks) for (name, old_param), (new_name, new_param) in zip( old_model.named_parameters(), new_model.named_parameters() ): if "weight" in name and "conv" in name: new_param.data = old_param.data[keep_masks[name]].clone()3. 检测任务的蒸馏为什么不能只照抄分类结果
3.1 分类蒸馏的基本逻辑
知识蒸馏最早在图像分类任务上被验证有效。核心思想是:大模型的输出中,除了正确答案之外,那些“错误类别上的概率分布”同样包含了信息。比如一张图被分类为猫,大模型可能给出“猫0.7、狗0.2、老虎0.1”,这些概率反映出“狗和老虎在特征上接近猫”。让一个小模型去学这种软化的概率分布,比只学硬标签能获得更多监督信号。
具体做法是引入温度参数T,先对logits做softmax软化,再计算学生和老师分布的KL散度:
def kd_loss(student_logits, teacher_logits, T): student_soft = F.log_softmax(student_logits / T, dim=-1) teacher_soft = F.softmax(teacher_logits / T, dim=-1) return F.kl_div(student_soft, teacher_soft, reduction="batchmean") * T * TT越大,概率分布越平滑,小模型越能学到类别之间的相似关系。公式最后乘上T的平方,是为了抵消softmax求导时引入的1/T因子,让梯度尺度保持稳定。这个细节很关键,我在项目里见过有人忘记乘T^2,结果蒸馏loss在数值上被压得太小,完全不起作用。
3.2 YOLO蒸馏的三个落点:分类logits、neck特征、解码头
分类蒸馏的直接套用在YOLO上行不通。检测任务的输出由类别概率、边界框坐标、目标置信度三部分组成,其中坐标回归是连续值,根本不存在类别分布;而一张图上正负样本比例可能达到几百比一,大量负样本区域的logits会淹没真实目标的信息。所以YOLO的蒸馏必须拆开设计,分三个位置落点。
第一是分类分支的logits蒸馏,这个和分类任务最接近,但只在检测头认为的“正样本位置”上计算,负样本位置要么忽略,要么用很低的权重。第二是neck层特征的蒸馏,把teacher和student在P3/P4/P5三个尺度的输出特征做空间对齐,用MSE loss拉近。由于teacher的特征通道数通常比student多,一般要先加一个1x1卷积把teacher投影到student的通道维度,而不是反过来硬截断。第三是解码头的box回归蒸馏,让student的预测框坐标逼近teacher的坐标,只在正样本上计算L1或GIoU loss,teacher的目标置信度可以作为权重。
用一个表格快速总结:
| 蒸馏位置 | 对齐对象 | 损失函数 | 备注 |
|---|---|---|---|
| 分类logits | 类别概率分布 | KL散度 | 只在正样本位置生效 |
| Neck特征 | P3/P4/P5特征图 | MSE / L2 | 需要1x1卷积投影对齐通道 |
| 解码头 | 坐标与置信度 | L1 / GIoU + BCE | 只对正样本计算 |
最终的训练总损失可以写成:
total_loss = det_loss + alpha * feat_distill_loss + beta * logits_kd_loss + gamma * box_distill_loss
三个权重不是拍脑袋定的。我在实践中会把feat_distill_loss的权重放在0.1到0.5之间,logits_kd_loss的权重小一些,box_distill_loss的权重再小一个量级。如果特征蒸馏权重太大,student会过度模仿特征的绝对值,反而忽略了自己检测头的定位优化,框的位置容易偏移。
3.3 Teacher模型的用法与容易忽略的细节
蒸馏训练时teacher必须冻结,并且要切到eval模式。这里有个很多人忽略的细节:如果teacher里还有Dropout或者带随机性的模块,必须用eval模式关闭,否则每次前向传播的软标签都不一样,student会学到一个“抖动”的目标。更稳妥的做法是使用teacher的EMA权重版本,也就是训练过程中指数滑动平均产生的权重,通常比最终权重更平滑,蒸馏出来的学生精度更高。
另外teacher和student的输入预处理必须完全一致。YOLO训练时常用的mosaic、mixup增强会让teacher看到拼接后的图像,导致soft label的分布和质量发生变化。如果要做蒸馏,建议在启用mosaic的batch上关闭蒸馏loss,或者在数据加载阶段对teacher使用和student完全一样的增强参数。加大蒸馏温度T也能缓解这个问题,但根子还是数据一致性。
4. 剪枝与蒸馏怎么组合才能把掉点救回来
4.1 先剪枝后蒸馏:最实用的组合路线
剪枝和蒸馏前面分别讲清楚了,但工程上的核心问题是顺序。我自己的推荐是:先稀疏化剪枝,再用原版大模型当teacher做蒸馏微调。整个流程四步:
- 准备一个训练充分的大模型,既当剪枝源,又当后续的teacher。
- 对模型做稀疏化训练,把BN gamma拉开。
- 按剪枝比例完成通道裁剪和模型重建。
- 用未剪枝的原模型冻结做teacher,对剪枝后的student做检测蒸馏微调。
为什么这个顺序最实用?因为剪枝会让模型掉点,而蒸馏恰好是修补掉点最有效的手段。teacher和student来自同一个初始模型,结构上高度相似,蒸馏目标的“迁移距离”短,学生学起来比从随机初始化的小模型容易得多。我在某次安防小目标检测实验里做过对比:剪掉一半FLOPs之后只做普通微调,mAP掉了约4个点;同样剪枝比例,在微调阶段加入蒸馏,掉点控制在1.5个点以内。这个差距在真实项目里非常明显。
4.2 另外两条路线:先蒸馏后剪枝和迭代式剪枝
也有人用“先蒸馏后剪枝”的顺序,先让一个小模型从大模型那里把精度学到手,再对这个已经紧凑的模型做剪枝。这个路线适合目标模型一开始就非常小的情况,但流程更长,中间产物多,剪枝阶段掉点后没有更大的teacher可以依赖。
迭代式剪枝是另一种思路:先剪10%,微调恢复;再剪10%,再微调恢复,每一步的剪枝比例都很小,所以单步精度的损失可控。效果确实好,但训练周期可能拉长到两到三倍。如果项目周期不紧,可以试试;如果追求一次到位,我建议一次性剪到目标比例,配合蒸馏微调,性价比最高。
4.3 调参优先级:先定剪枝率,再定蒸馏权重
这套组合拳里面有大量超参:稀疏化系数、剪枝比例、蒸馏温度、特征蒸馏权重、box蒸馏权重。如果同时乱调,最后出了问题根本不知道是哪个环节造成的。我建议按下面的优先级顺序来调:
- 先确定剪枝比例,这是由目标FLOPs和延迟决定的,属于硬性约束。
- 再调稀疏化系数,保证gamma分布拉开的同时精度不掉太多。
- 最后调蒸馏权重,先用一组保守值跑通,再按mAP曲线调整。
蒸馏权重也不要从头到尾固定不变。我常用的一个技巧是前十个epoch只做普通微调,后二十个epoch再逐步打开蒸馏loss,让模型先稳定自身结构,再跟着teacher走。这样比全程开启蒸馏更稳定。
5. 踩坑清单:模型变“瘦”之后才暴露出来的问题
5.1 剪枝后精度崩盘:先查BN统计量
有一次剪完模型后mAP直接掉了十几个点,比预计严重得多。排查到最后,问题出在BN上。稀疏化训练中L1正则会把gamma往零推,这个过程中BN的running_mean和running_var也会跟着变化。剪枝重建后,新模型里这些统计量是从旧模型直接抄过来的,但它们对应的通道已经被换掉了,分布完全对不上。解决办法是在剪枝重建后,先用少量训练数据重新前向走一遍,只更新BN统计量、不更新梯度,让新的mean和var适配新的通道结构。这一步有时比微调本身还重要。
5.2 shape mismatch:shortcut组没对齐
这个是YOLO剪枝最常见的报错,RuntimeError提示某个cat或add操作维度不一致。原因几乎都是依赖层分组写漏了,只剪了主干分支的卷积,没把shortcut分支的卷积纳入同一组。排查方法是打印每个concat/add节点的输入shape,找到是哪一层出现的分叉,然后把对应的BN层index补进同一个prune group。遇到这种情况就写一个更全面的依赖分析脚本,把同组识别做成自动化,不要靠人肉看结构图。
5.3 Detect head被剪出“畸形”输出
YOLO的检测头输出通道数和类别数、锚框数强绑定,解码逻辑对输出张量的shape有硬性要求。如果你对检测头里的卷积也做了通道剪枝,输出shape就变了,轻则训练报错,重则模型能训练但推理阶段解码彻底乱掉。最稳妥的方案是:剪枝范围只覆盖backbone和neck部分,Detect head完整保留。检测头本身节点很少,剪它对总计算量帮助有限,但破坏输出的可能性极高。只要剪掉backbone侧的输入通道,处理好neck到head之间的衔接层即可。
5.4 蒸馏权重过大,检测框开始漂移
某次实验里加了特征蒸馏后分类准确率明显变好,但mAP反而下降了。观察预测结果发现框的位置偏移变大了。原因是特征蒸馏鼓励student的特征图向teacher靠拢,但并没有约束这些特征解码出来的框一定要准。特征蒸馏和检测任务之间存在“目标竞争”。解决办法是把box回归的蒸馏权重提上来,或者在训练后期衰减特征蒸馏的权重,把主导权还给检测loss。
5.5 mosaic增强让teacher的软标签不稳定
如果蒸馏loss曲线一直上下乱跳,可能是mosaic增强在捣乱。mosaic会在一张图里拼接四张不同来源的图片,teacher看到的目标分布和学生看到的不一样,特别是小目标被拼接边界切割后,teacher输出的置信度不可靠。我当时的处理是在数据加载器里加一个开关:如果当前batch启用了mosaic,这一批就不计算蒸馏loss,只算普通检测loss。同时把mixup概率从0.5降到0.1,软标签的稳定性立刻好很多。
5.6 稀疏化系数过大,模型变“植物人”
L1稀疏强度不是越大越好。有次我把稀疏系数调到0.01,训练几十轮后检查gamma分布,发现几乎所有通道的gamma都被压到接近零,模型输出直接变成一个常数,怎么微调都救不回来。稀疏化训练过程中要定期看gamma直方图,如果发现绝大多数gamma聚集在0附近,说明稀疏过头了,必须回退到更小的系数重新训练。理想状态是大概一半以上通道保持正常值,一小部分被压到接近零。
6. 一套可以直接复制的YOLO压缩流程模板
说了这么多,最后给一套可以直接用的流程模板,涵盖稀疏化、剪枝、蒸馏三个阶段,具体超参可以按项目调整。
第一阶段:稀疏化训练。
- 加载预训练权重,沿用原训练配置
- 在loss中加入BN gamma的L1正则,系数从0.0005开始
- 训练50到100个epoch,每隔10个epoch打印一次gamma分布直方图
- 目标:gamma出现明显两极分化,且mAP不显著下降
第二阶段:通道剪枝。
- 扫描模型结构,构建依赖层分组
- 统一统计每组gamma,按保留比例生成每层的keep mask
- 重建模型,按mask抽取权重和BN参数
- 用少量数据重估BN统计量,再开始微调
第三阶段:蒸馏微调。
- 加载原版大模型作为teacher,冻结并切换eval模式
- student加载剪枝后的模型,先做10个epoch普通微调
- 逐步加入分类logits蒸馏、neck特征蒸馏、box回归蒸馏
- 最终模型验证FLOPs、参数量、mAP和端侧延迟
下面是推荐参数区间,标好调整方向:
| 参数 | 推荐取值 | 调整方向 |
|---|---|---|
| 稀疏化系数 | 0.0005 ~ 0.001 | 过大则gamma整体趋零,过小则分布不拉开 |
| 剪枝比例 | 20% ~ 50% | 超过50%需要非常充分的蒸馏配合 |
| 蒸馏温度T | 4 ~ 6 | T过小近似硬标签,T过大会丢失细节 |
| 特征蒸馏权重 | 0.1 ~ 0.5 | 权重过大会导致检测框偏移 |
| box蒸馏权重 | 0.05 ~ 0.2 | 只对正样本计算 |
| 微调epoch数 | 30 ~ 50 | 前10轮过渡,后20轮稳定 |
最后说一个个人习惯:在稀疏化训练阶段我会先把稀疏系数设得很小,比如0.0002,然后观察一轮mAP掉点是否在0.3以内,再慢慢往上加。不要直接上高系数,否则万一训练已经跑了几十个epoch才发现模型被压坏,回退成本很高。剪枝比例也建议做成可配置参数,先剪一个保守比例跑通整个流程,确认链路没问题后再逐步加大。模型压缩这件事,最怕的不是剪得不够多,而是流程跑不通的时候你根本分不清是哪个环节出了问题。