简介:本资源为基于知识蒸馏的目标检测模型增量深度学习方法的Python源码,面向人工智能、计算机视觉方向的学生与开发者,尤其适合正在准备毕设、课程设计或希望深入理解增量学习与知识蒸馏结合方案的学习者。压缩包共476个文件,约5.99MB,以189个py源码文件与192个pyc编译文件为核心,辅以xml标注、jpg图像、so与o等编译产物、c与pyx扩展源码及docx运行说明文档,覆盖从模型训练到推理部署的完整链路。资源围绕知识蒸馏与剪枝在VGG16等骨干网络上的增量学习展开,包含多阶段特征蒸馏、模型剪枝及Faster R-CNN相关实现,并配有运行说明文档,便于读者理解各模块的调用关系与实验配置。目前已有263人学习,代码经测试可运行,适合在此基础上修改扩展,完成目标检测与增量学习方向的课题实践。
1. 知识蒸馏遇上增量学习:目标检测模型为什么越训越"忘本"
你辛辛苦苦在 COCO 上训好一个 YOLO 检测器,业务方突然说"再加一个安全帽类别"。你想着不就是多标几百张图、多跑几十个 epoch 的事,结果重训完发现:新类别是学会了,可原来的人、车、口罩全开始漏检。这不是玄学,这是目标检测增量学习里最经典的灾难性遗忘。
把知识蒸馏塞进增量学习流程,是目前工业界最常用的一条缓解路径:用旧模型当"老师",在学新类时约束新模型的输出分布别跑偏。再配上 Python 源码级别的实现,你就能把这套方法直接搬进自己的检测流水线。这篇笔记面向已经跑通过 YOLO 或 SSD 训练、想解决"加类不丢旧类"的算法工程师和 CV 方向研究生,从蒸馏损失怎么设计、旧样本怎么留、参数怎么调,一路讲到我在实际项目里翻过的车。知识蒸馏、目标检测、增量深度学习、Python 这四个词,会在后面每一章里落到具体的代码和参数上。
2. 增量蒸馏的整体框架:老师、学生、旧样本三者怎么摆
2.1 为什么不能只做微调
最朴素的做法是拿旧权重当初始化,只用新类数据继续训。问题出在检测头的分类分支:新类别的梯度会把原本分配给旧类的 logits 压下去,几轮迭代后旧类置信度断崖式下跌。回归分支同样会漂移,旧类框的位置精度跟着退化。
增量学习的核心矛盾是:模型容量固定,新知识进来必然挤压旧知识的参数空间。知识蒸馏的作用不是阻止挤压,而是给参数更新加一个"锚"——让新模型在旧类上的输出分布尽量贴近旧模型。检测任务比分类复杂,因为蒸馏对象不止分类 logits,还有框回归和 objectness。
常见做法是把蒸馏拆成三路:分类蒸馏(KL 散度)、回归蒸馏(L2 或 GIoU 对齐)、特征蒸馏(中间层 feature map 的 MSE)。三路加权求和,权重就是你要调的关键参数。
2.2 旧样本回放:留多少、怎么留
纯蒸馏不存旧样本,在类别数多的时候会逐渐失效,因为老师模型对旧类的"记忆"本身也在被稀释。工业界更稳的方案是蒸馏 + 少量旧样本回放。我一般按新类样本量的 10%~20% 保留旧类样本,类别间尽量均衡。
如果数据合规不允许存原图,可以存特征原型(每类在检测头前的平均 feature),蒸馏时对齐原型距离。这条路精度会掉 2~4 个点,但省存储、避隐私,很多落地场景只能这么干。
# 旧样本缓冲区:按类别均衡采样,控制总容量 import random from collections import defaultdict class ReplayBuffer: def __init__(self, capacity_per_class=200): self.capacity = capacity_per_class self.buffer = defaultdict(list) # {cls_id: [sample, ...]} def add(self, cls_id, sample): """sample 是 (image_tensor, target_dict) 元组""" if len(self.buffer[cls_id]) < self.capacity: self.buffer[cls_id].append(sample) else: # 蓄水池采样,保证旧样本分布稳定 idx = random.randint(0, self.capacity - 1) if idx < self.capacity: self.buffer[cls_id][idx] = sample def sample(self, batch_size): all_samples = [s for v in self.buffer.values() for s in v] return random.sample(all_samples, min(batch_size, len(all_samples)))这段代码的关键在capacity_per_class:每个旧类留多少张。设太小(比如 20)蒸馏信号不足,设太大(比如 1000)训练时旧类占比过高、新类学不动。我的经验值是每类 100~300 张,配合蒸馏损失能把旧类 mAP 衰减控制在 3 个点以内。sample方法每次随机抽,避免固定子集导致的过拟合。
2.3 训练流程的四个阶段
整个增量蒸馏训练分四步走,顺序不能乱:
- 冻结老师模型,前向跑一遍旧样本,缓存旧类的 soft label(分类分布 + 框偏移)。
- 学生模型加载旧权重,接上新类分类头(旧类头参数保留)。
- 每个 batch 混合新类数据和回放旧样本,同时算检测损失和蒸馏损失。
- 用验证集分别测旧类 mAP 和新类 mAP,任一掉太多就回调蒸馏权重。
第 3 步的 loss 组合是核心,下一章展开。
3. 蒸馏损失函数怎么写:三路损失的代码实现与权重调法
3.1 分类蒸馏:温度参数 T 决定软标签的"软"程度
分类蒸馏用 KL 散度对齐学生和老师在旧类上的输出分布。老师输出先除以温度 T 再 softmax,T 越大分布越平滑,携带的类间关系信息越多。检测任务里 T 一般取 2~4,比分类任务的 4~20 小,因为检测的分类分支本身已经够平滑。
import torch import torch.nn as nn import torch.nn.functional as F def classification_kd_loss(student_logits, teacher_logits, T=3.0): """ student_logits / teacher_logits: [B, num_old_classes] 只对旧类做蒸馏,新类不参与 """ # 温度缩放后的软分布 soft_teacher = F.softmax(teacher_logits / T, dim=-1) soft_student = F.log_softmax(student_logits / T, dim=-1) # KL 散度,乘 T^2 保持梯度量级与原始损失一致 kd = F.kl_div(soft_student, soft_teacher, reduction='batchmean') * (T * T) return kdT=3.0是起点,旧类多(>20)可以调到 4,旧类少就降到 2。乘T*T是标准操作,否则温度一变梯度尺度就乱,蒸馏损失会被检测损失淹没。注意teacher_logits要 detach,别让梯度回传到老师。
3.2 回归蒸馏:为什么用 GIoU 而不是 L2
框回归蒸馏如果直接对坐标做 L2,小框的误差会被大框稀释,而且坐标空间不连续。更稳的做法是对齐学生和老师在旧类 anchor 上的 GIoU 分布,或者直接对回归分支的输出做 L2 但按框面积加权。
def regression_kd_loss(student_reg, teacher_reg, box_weights=None): """ student_reg / teacher_reg: [B, num_anchors, 4] box_weights: 按框面积归一化的权重,小框权重大 """ diff = (student_reg - teacher_reg) ** 2 # [B, A, 4] diff = diff.sum(dim=-1) # [B, A] if box_weights is not None: diff = diff * box_weights return diff.mean()box_weights用1 / (box_area + eps)归一化后再标准化到均值 1,避免小框主导。如果嫌麻烦,直接用 GIoU 对齐也行,但计算量大一倍,看你的 GPU 预算。
3.3 特征蒸馏:中间层对齐的层选择
特征蒸馏对齐 backbone 或 neck 的中间 feature map,让学生保留老师提取旧类特征的能力。层选多了显存爆炸,选少了效果不明显。我一般只蒸馏 neck 输出的那一层,通道数用 1x1 卷积对齐。
class FeatureKD(nn.Module): def __init__(self, student_ch, teacher_ch): super().__init__() # 学生通道对齐到老师通道 self.align = nn.Conv2d(student_ch, teacher_ch, 1) if student_ch != teacher_ch else nn.Identity() def forward(self, student_feat, teacher_feat): s = self.align(student_feat) # 空间尺寸不一致就自适应池化 if s.shape[-2:] != teacher_feat.shape[-2:]: s = F.interpolate(s, size=teacher_feat.shape[-2:], mode='bilinear') return F.mse_loss(s, teacher_feat.detach())align那层 1x1 卷积是可学习参数,训练时一起更新。teacher_feat.detach()同样必须加,否则老师也被带着跑。
3.4 总损失权重:三个 lambda 怎么定
总损失是L_det + λ_cls * L_cls_kd + λ_reg * L_reg_kd + λ_feat * L_feat_kd。我的起始配置:
| 损失项 | 权重 λ | 调整方向 |
|---|---|---|
| 分类蒸馏 | 1.0 | 旧类掉点就加大到 2.0 |
| 回归蒸馏 | 0.5 | 旧类框漂移就加到 1.0 |
| 特征蒸馏 | 0.1 | 新类学不动就降到 0.05 |
调参顺序:先固定 λ_feat=0,调分类和回归;旧类稳住后再加特征蒸馏。三个一起上容易互相干扰,排查起来就是黑匣子。
4. 用 Python 跑通增量训练:数据、模型、训练循环的落地细节
4.1 数据集组织:新旧类怎么分文件
增量学习的数据集要显式区分旧类和新类。我一般用两个目录加一个 json 描述:
dataset/ old_classes/ # 旧类图片和标注 images/ labels/ new_classes/ # 新类图片和标注 images/ labels/ class_split.json # {"old": [0,1,2], "new": [3,4]}class_split.json里 old 和 new 的类别 id 不能重叠,训练时按这个文件决定哪些类走蒸馏、哪些类走正常检测损失。标注格式沿用 YOLO 的 txt(cls cx cy w h归一化),转换脚本用现成的 VOC2YOLO 或 COCO2YOLO 工具即可。
4.2 模型改造:给检测头加旧类保护
以 YOLOv8 为例,检测头分类分支的输出维度是num_classes。增量时把num_classes扩到num_old + num_new,旧类那部分的权重从旧 checkpoint 加载并冻结前几个 epoch,新类部分随机初始化。
def expand_detection_head(model, num_old, num_new): """扩展分类头,旧类权重保留,新类随机初始化""" head = model.model[-1] # Detect head old_conv = head.cv3[0].conv # 分类分支第一层 in_ch = old_conv.in_channels new_conv = nn.Conv2d(in_ch, num_old + num_new, 1) # 拷贝旧类权重 with torch.no_grad(): new_conv.weight[:num_old] = old_conv.weight[:num_old] new_conv.bias[:num_old] = old_conv.bias[:num_old] head.cv3[0].conv = new_conv return modelnum_old和num_new必须和class_split.json对齐。冻结旧类权重的 epoch 数一般设总 epoch 的 1/5,让新类先"站稳",再解冻一起微调。
4.3 训练循环:一个 batch 里混合新旧数据
for epoch in range(total_epochs): for new_imgs, new_targets in new_loader: # 抽等量旧样本 old_batch = replay_buffer.sample(batch_size=len(new_imgs)) old_imgs = torch.stack([s[0] for s in old_batch]).to(device) old_targets = [s[1] for s in old_batch] # 学生前向 student_out_new = model(new_imgs) student_out_old = model(old_imgs) # 老师前向(no_grad) with torch.no_grad(): teacher_out_old = teacher_model(old_imgs) # 检测损失只在新数据上算 loss_det = detection_loss(student_out_new, new_targets) # 蒸馏损失在旧数据上算 loss_cls_kd = classification_kd_loss( student_out_old['cls'][:, :num_old], teacher_out_old['cls'][:, :num_old], T=3.0) loss_reg_kd = regression_kd_loss( student_out_old['reg'], teacher_out_old['reg']) loss_feat_kd = feat_kd(student_out_old['feat'], teacher_out_old['feat']) total_loss = loss_det + 1.0 * loss_cls_kd + 0.5 * loss_reg_kd + 0.1 * loss_feat_kd total_loss.backward() optimizer.step() optimizer.zero_grad()关键点:检测损失只在新数据上算,蒸馏损失只在旧数据上算,两者不混。teacher_model全程eval()且requires_grad=False。num_old用来切分类输出,只对旧类做蒸馏。
4.4 验证:新旧类 mAP 分开测
每个 epoch 结束分别在旧类验证集和新类验证集上测 mAP50。旧类 mAP 掉超过 5 个点就加大 λ_cls,新类 mAP 低于基线就减小 λ_feat。两个指标都记进日志,别只看总 mAP,总 mAP 会把问题平均掉。
5. 避坑与排查:增量蒸馏里最容易翻车的五个地方
5.1 旧类 mAP 断崖下跌,蒸馏损失却不降
现象:训练日志里loss_cls_kd一直在 0.5 以上不收敛,旧类 mAP 从 45 掉到 20。
原因:老师模型的输出没 detach,梯度回传把老师也更新了,老师"记忆"被污染,蒸馏目标本身在漂移。
解决:检查teacher_logits是否.detach(),老师模型是否eval()且所有参数requires_grad=False。这两条缺一个都会翻车。
5.2 新类学不动,mAP 卡在个位数
现象:新类训练 20 个 epoch 后 mAP 还是个位数,损失下降极慢。
原因:蒸馏权重过大,旧类的锚把参数锁死,新类梯度被压制。或者旧样本回放比例过高,新类 batch 占比太低。
解决:先把 λ_feat 降到 0,λ_cls 从 1.0 降到 0.5 试;回放比例从 20% 降到 10%。如果还不行,检查新类分类头是否真的解冻了。
5.3 显存爆炸,batch size 只能设 2
现象:加了特征蒸馏后 OOM,只能把 batch 降到 2,训练慢到无法接受。
原因:特征蒸馏缓存了老师和学生的中间 feature map,通道数大时显存翻倍。
解决:只蒸馏 neck 最后一层,别蒸馏 backbone 多层;用torch.cuda.amp混合精度;特征蒸馏的 batch 单独抽小一点(比如 4),不和主 batch 绑死。
5.4 旧类框位置漂移,分类对但框偏
现象:旧类分类置信度正常,但框的 IoU 从 0.7 掉到 0.5。
原因:回归蒸馏权重太低或没开,回归分支被新类数据带偏。
解决:λ_reg 从 0.5 加到 1.0,或者改用 GIoU 对齐。检查box_weights是否归一化,没归一化的话大框会主导损失。
5.5 多轮增量后模型彻底崩掉
现象:连续做 3 轮增量(每轮加 2 个类),第 3 轮后旧类 mAP 掉到 10 以下。
原因:误差累积,每轮蒸馏的老师都是上一轮已经衰减的模型,蒸馏信号越来越弱。
解决:每轮增量保留第一轮的原始模型作为"锚老师",和上一轮老师一起做双老师蒸馏;或者每轮结束后用全部旧类数据做一次小学习率回炉。前者省数据,后者费算力但更稳。
6. 进阶技巧:把蒸馏权重做成动态调度
固定 λ 的问题是:训练前期新类需要大梯度,后期旧类需要强约束,一个常数满足不了两个阶段。我现在的做法是给蒸馏权重加一个 warmup + cosine 调度。
def get_kd_weight(epoch, total_epochs, base_weight, warmup_epochs=5): """蒸馏权重:前 warmup 轮线性升到 base,之后 cosine 衰减""" if epoch < warmup_epochs: return base_weight * (epoch + 1) / warmup_epochs progress = (epoch - warmup_epochs) / max(1, total_epochs - warmup_epochs) return base_weight * 0.5 * (1 + math.cos(math.pi * progress))前 5 个 epoch 蒸馏权重从 0 升到 base,让新类先学;之后 cosine 衰减,后期给新类更多自由度。实测比固定权重旧类 mAP 高 2~3 个点。
另一个技巧是分层蒸馏:backbone 用强蒸馏(λ=1.0),neck 用中等(λ=0.5),检测头用弱蒸馏(λ=0.2)。因为 backbone 提取的是通用特征,最该保护;检测头要适应新类,约束太强反而学不动。
验证这套方法是否真的有效,别只看最终 mAP。我习惯画三条曲线:旧类 mAP 随 epoch 变化、新类 mAP 随 epoch 变化、蒸馏损失随 epoch 变化。旧类曲线应该是缓慢下降后企稳,如果一路下滑说明蒸馏没起作用;新类曲线应该是稳步上升,如果平台期太早说明约束过强。这三条曲线比任何单个数字都能说明问题。
最后说个血泪教训:增量蒸馏的代码里,老师模型的eval()和detach()我踩过至少三次坑,每次都是训练半天发现旧类崩了才回头查。现在我的习惯是在forward里第一行就写assert not teacher_model.training,让它在训练模式直接报错,省得事后吃后悔药。这套方案值不值得做,取决于你的业务是不是持续加类——如果类别固定,老老实实全量重训最省心;如果类别每月都在变,增量蒸馏加回放是目前性价比最高的一条路。希望帮到你。
本文还有配套的精品资源,点击获取