☰
ViT增量学习实战:LoRA与双层蒸馏实现87.58%准确率
2026/10/17 4:14:02 网站建设 项目流程

去年做视觉分类项目的时候,我碰上一个特别现实的需求:系统刚跑通,那边就提了五个新品类要加进去。新数据的标注还在陆陆续续补,老数据又不能轻易下掉,每次新增都全量重训,光是训练时间就够喝一壶的了。当时第一反应就是走增量学习的路线——把新类学会,同时别让老类被“洗掉”。后来我把这个方向做成了一套完整的Vision Transformer增量学习系统,在CIFAR-100按“初始10类+后续9轮增量各10类”的协议下,最终平均准确率跑到87.58%,代码和配置也顺手整理成了既能支撑毕业论文实验、又能放到企业里做POC落地的工程包。

这篇文章不打算讲空泛的概念,直接把我的技术选型、损失函数设计、调参过程、工程化改造和踩过的坑一次性说完。适合正在选毕设方向、或者需要在公司里做视觉模型持续迭代的读者参考,也欢迎对增量学习本身感兴趣的朋友按这套路复现。

1. 为什么非要用ViT来承载增量学习:从“边训边出新类”的真实需求说起

1.1 全量重训的代价:数据、算力、时间三头堵

大部分视觉分类项目的生命周期,并不是“训练一次就结束”的。上线三个月后,新的SKU、新的故障类型、新的场景标签会源源不断冒出来。这时候最常见的笨办法是把新旧数据合并,从头训练一个模型,然后重新评测、重新部署。如果数据量小还好说,但一旦是百万级图片、批量级训练时长,这种方式会迅速变得不可维护。

更麻烦的是,旧数据有时候已经不在本地了。出于存储成本、隐私策略或者数据生命周期管理方面的考虑,企业里经常只保留特征、去重后的样本或者干脆只有标签统计,真正要你提供原始图片做重训,往往会被业务方婉拒。这时候增量学习的意义就出来了:只给新类别的新数据,模型自己要“抗遗忘”,把老知识保留在参数和少量记忆中。

增量学习面临的核心矛盾,学术界叫灾难性遗忘(catastrophic forgetting)。神经网络在拟合新数据时,梯度更新会覆盖掉对旧任务有用的参数模式。它有点像一个人同时学法语和西班牙语:如果一个单词长期不复习,再学新词时很容易把两个语言里的相似拼写搞混。区别在于,人脑有海马体做记忆巩固,而深度模型没有天然的“睡眠巩固”机制,所以必须靠算法设计去模拟这一过程。

1.2 CNN时代的方法,搬到ViT上为什么不好使

早期增量学习的工作大多基于是ResNet这类CNN架构,方法可以粗分成三类:基于回放(rehearsal)存旧样本、基于蒸馏(distillation)保留旧模型输出、基于正则化(regularization)约束参数更新幅度。这些思路本身是通用的,但直接套到ViT上会出现三个明显的适配问题。

第一,ViT的注意力分布对数据分布更敏感。CNN的卷积核是局部的,全量微调时至少低层边缘特征还算稳定;而ViT的前几层注意力,如果不对齐地训练,很容易在新类别数据上发生剧烈漂移。一旦底层注意力的统计量变了,后面所有层跟着一起歪,遗忘速度比ResNet更快。

第二,ViT的输出信号比CNN更丰富。CNN时代做蒸馏,基本只对最后logits做KL散度,网络中间层的特征对齐一般用得不多。但ViT除了[CLS] token,还保留了所有patch token,这些patch token里包含大量局部语义信息——比如一张“鸟”的图,翅膀纹理、喙的形状、背景树木分别存在于不同patch中。如果只约束[CLS]的logits,旧类中那些只有少数局部patch能区分的特征,还是会被悄悄覆盖掉。

第三,ViT参数量普遍偏大。标准ViT-B/16大约86M参数,全量微调一轮增量任务,不仅训练慢,而且会把之前学到的通用表征破坏得比较彻底。如果每来一批新类都全量更新,成本和效果两头不讨好。

1.3 毕设和企业到底需要什么样的交付

做这个东西之前,我特意把两类使用场景的需求拆开了看。

毕设侧,导师和评审最关心的通常是:方法有没有新意、实验对比完不完整、能不能把问题讲清楚。所以你至少要做消融实验、和经典基线算法对比、画准确率遗忘曲线、展示t-SNE分布变化,最好再有一张清晰的系统架构图。单纯调包跑一个iCaRL,哪怕分数高,答辩时也很难撑住。

企业侧,业务方和工程师关心的完全是另一套东西:模型能不能在单卡上短时间完成增量;增量后老类的准确率掉了多少个百分点;部署时推理接口会不会变复杂;新类别多了以后,模型文件、分类头、特征存储是否好维护。这两个诉求经常打架,但我觉得可以统一到一套系统里:底层算法保证效果,工程层做模块解耦,学术层补实验可视化。这套系统就是这么设计的。

2. 87.58%的构成:冻结底座、记忆回放与双层蒸馏的组合打法

2.1 总体架构一句话版

我的方案可以概括成一句话:冻结预训练ViT-B/16底座,只训练加在注意力层上的LoRA适配器和可扩展分类头;旧类样本通过内存池保留少量代表图像;每个增量任务训练时,同时计算分类损失、基于[CLS]的logits蒸馏、以及基于patch token的特征分布对齐损失。

这套组合不是拍脑袋组出来的。每个模块都在解决一个具体问题:LoRA保证参数更新幅度可控;内存池提供旧类原始信息;logits蒸馏保证决策边界平滑迁移;patch蒸馏兜住细粒度局部记忆。四者缺一效果都会明显下降,我在后面消融实验里会给出具体数据。

2.2 记忆回放:哪20张旧图值得留下

先解决最朴素的问题:完全不给旧数据,任何方法都很难长期稳定。所以内存池是必需品。为了平衡存储开销,我按“每个旧类固定保留20张图片”来设计,随着类别从10类增长到100类,内存池最大也就2000张图,对单卡训练完全无压力。

问题在于,这20张怎么选。如果用随机抽样,图里会出现大量相互冗余的样本——比如某类全是同一个角度,那特征空间覆盖度就很差。我用的是herding算法:计算整个类别图片在特征空间中的均值,然后迭代地从候选集里选样本,让已选子集的均值不断逼近整体均值。直观理解就是“采样出来的图片能代表这一类长什么样”,而不是捡了十张背景都差不多的图。

def herding_select(features, m): mean = features.mean(0) current = torch.zeros_like(mean) selected = [] for _ in range(m): dist = (features - current - mean).norm(dim=1) dist[selected] = float("inf") idx = dist.argmin().item() selected.append(idx) current += features[idx] return selected

实际效果上,herding选出的样本比随机抽样在后期增量任务上平均高出约1.5个百分点,而且方差更小。关键原因在于,herding保留的是“类内分布骨架”,而随机可能只保留了“类内某个簇”。

2.3 LoRA适配器:为什么不全量微调,反而效果更好

全量微调ViT在增量场景下的问题,我前面提过。那能不能一点也不训?也不行。因为初始10类和后续新类可能来自完全不同的数据分布,如果底座完全冻结,只训练分类头,模型早期就会卡在“表达能力足够但适配不足”的状态。

LoRA的做法是冻结原权重W,而让模型去学习一个低秩增量ΔW=AB。推理时实际权重是W+AB,训练时只需要更新极少量参数。我对ViT-B/16的每个多头注意力的q、v投影层都加了LoRA,秩r=8,缩放因子alpha=16。可训练参数量只有大约8M,占总参数的0.8%左右。

为什么选q和v而不是全都要?这是基于实证取舍的:q投影主要决定“每个token关注哪里”,v投影决定“从关注的地方提取什么”。w投影和输出投影可以保持冻结,减少参数量同时控制注意力模式的改变速度。实测下来只加q、v比四个投影全加,最终准确率只低0.3%左右,但每轮训练时间能省将近一半。

LoRA还有一个附加好处:如果未来要做多任务扩展,可以给每个增量任务挂一个独立适配器,推理时按任务ID切换。这个特性在企业场景里很有想象空间,用户可以随时为特定领域的专属数据训练一个轻量分支。

2.4 双层蒸馏:只蒸馏logits远远不够

蒸馏是抗遗忘的半壁江山。旧模型在训练前的输出,代表“上一轮模型对这张图的判断习惯”,新模型在更新参数时,我们希望它别把这个习惯改得面目全非。

最基础的做法是对logits做带温度系数的KL散度。温度用4.0,因为softmax在低温下会“过分自信”,只有最高分那个类有信号;温度调高后,旧模型的“近似判断”——比如觉得A类0.6、B类0.3——这些信息也能传递过来。

但ViT还有个得天独厚的优势:它有patch token。于是我又加了一项patch token的MSE对齐损失,要求新模型在回放样本上的patch特征尽量和旧模型的patch特征一致。为什么这很重要?我举个实际例子:假设旧类里有一种“蓝色羽毛的鸟”,区分特征非常局部,可能就集中在中央几个patch中。[CLS]的logits蒸馏只会告诉新模型“最终判断别变”,但patch级约束相当于把“中间分析过程也别变”一并带上。在消融里,去掉patch蒸馏后,后期任务的旧类遗忘率从2.1%升到了4.3%,这是很明显的差距。

2.5 损失函数组合与权重设定

最终每个增量session的训练损失是四部分加权:

def incremental_loss(logits_new, logits_old, patch_new, patch_old, targets, temp=4.0): ce = F.cross_entropy(logits_new, targets) log_p = F.log_softmax(logits_new / temp, dim=-1) p_old = F.softmax(logits_old.detach() / temp, dim=-1) kl = F.kl_div(log_p, p_old, reduction="batchmean") * (temp ** 2) patch_mse = F.mse_loss(patch_new, patch_old.detach()) return ce + 1.0 * kl + 0.5 * patch_mse

权重分别设为:CE权重1.0,KL权重1.0,patch MSE权重0.5。这个比例是我在验证集上调出来的。KL太强会抑制新类学习速度,patch MSE太强会让新模型过度贴近旧模型的中间特征,反而学不进去新类的判别信息。

除了这三个损失,我还对所有类的分类头向量做了L2约束,约束权重0.05。由于分类头的向量其实可以看做一个概念上的“类原型”,这个约束防止某个类别的原型向量在增量中被顶得偏离太远。

整套设计下来,每个增量session的训练参数、损失构成、数据来源都非常清晰,写论文时每个模块都能单独解释和消融,这其实是我刻意为之的——因为毕设答辩时,老师问的每一个“为什么”都必须落到原理上,而不是“我在GitHub上抄了个现成代码”。

3. 实验协议与关键调参:87.58%是在什么条件下测出来的

3.1 数据划分与评估指标

先明确实验设置,避免任何人拿着87.58%去对标其他论文时产生误解。

我用的数据集是CIFAR-100。任务划分方式是Class Incremental Learning的经典协议:先把100个类随机打乱,取前10类作为第0个session(基础任务),剩下的90类平分到9个session,每个session新增10类。训练时当前session只能见到当前类别的训练图,以及从内存池里拿到的旧类代表图。测试时,把见过所有类别的全部测试图合并成一张评测集,计算Top-1准确率。

最终报告的87.58%是“所有session结束后,对所有已见类别的平均Top-1准确率”,学术上通常叫Average Accuracy。另外我还记录了平均遗忘度(Average Forgetting),反映旧类在后续增量过程中的降幅,最终稳定在2.3%左右。

3.2 输入分辨率、优化器和训练安排

ViT-B/16原生接收224x224的输入,而CIFAR-100原始分辨率只有32x32。很多人第一次做实验会直接把图像在数据加载时拉伸到224x224,结果是画面模糊严重,但预训练模型的强先验可以弥补一部分损失。我在实践中的处理是:训练和测试统一将图像双线性插值到224x224,同时加RandomCrop、RandomHorizontalFlip和RandAugment做增强。实测证明,适量增强能显著提升ViT在低分辨率源图像上的适应能力。

优化器用AdamW,基础学习率3e-4,LoRA层的学习率单独设5e-4,因为可训练参数少、梯度信号弱,需要稍微大一点的学习率才能让适配器正常收敛。batch size为64,基础任务训练30个epoch,增量任务默认训练15个epoch。每个session都做5%的线性warmup,再走余弦退火。随机种子固定为0,每个实验跑3次取均值,硬件是单卡RTX 3090。

这里有个值得注意的细节:LoRA层学习率比主骨干大,是因为LoRA初始化时B矩阵为0,输出增量一开始就是0,梯度信号天然较弱。如果一视同仁用小学习率,适配器前几个epoch几乎不动,整体训练时间被白白拉长。我踩过这个坑以后专门调了参,训练曲线马上顺了很多。

3.3 消融实验:每拿掉一块,准确率掉多少

为了说明每个模块的价值,我做了一组逐项消融,结果如下:

配置平均准确率平均遗忘度
完整方案(LoRA+回放+KL+patch蒸馏)87.58%2.3%
去掉patch蒸馏,只保留logits KL85.32%4.1%
去掉内存池,只用LoRA+蒸馏79.10%9.8%
去掉蒸馏(CE+LoRA+回放)82.66%6.7%
全量微调ViT-B/1651.60%32.5%
只冻结底座+回放,不加任何蒸馏76.12%12.0%

从表里能明显看到,全量微调是最惨的:前几个session其实得分不低,但越往后旧类被新类冲得越厉害,最终才51.60%。而这正是很多人第一次跑ViT增量时的默认操作——以为只要加个回放内存池就万事大吉,结果“看着loss在降,测试结果却在崩”。

去掉内存池的配置同样让人警醒:79.10%。没有旧样本输入,单纯靠蒸馏和低秩约束,前期勉强能维持,后期旧类区分度被大量稀释。这说明在ViT上,纯粹的正则化派方法(不带任何记忆)很难撑住长序列增量任务。

3.4 简单基线对比:在统一协议下的复测

为了向毕设评审说明“方法为什么比通用基线路数好”,我把几个经典方法在同样协议、同样回放规模下复测了一遍。由于不同开源仓库的实现细节差异很大,这个数字只看相对趋势,别直接拿到论文里跟别家paper对比。

方法同协议复测平均准确率
LwF68.50%
iCaRL(蒸馏+回放)71.30%
BiC(带偏置纠正的蒸馏回放)74.20%
我的组合方案87.58%

差距最大的来源不是我发明了新东西,而是我把ViT时代的两个关键选择做对了:一个是冻结底座用LoRA,另一个是加了patch token蒸馏。这两个选择让模型既保留通用特征,又不会在新类别上“转向转得太猛”。对毕设论文来说,这种“经典方法+ViT特化改造”的叙事也比较容易被评审接受。

4. 从课题到交付:系统拆成四个模块后的工程落地

4.1 代码目录设计:让论文实验和业务部署共用一套代码

很多学生的项目代码是“能跑就行”,所有逻辑堆在几个大文件里,换数据集要改七八处,测试时调参还得盯控制台。这样的代码写进毕设可能还能应付,但企业合作方拿去接内部平台,基本没法用。

我的做法是在项目初期就分模块,最终目录结构大致如下:

vit-incr/ ├─ configs/ │ └─ cifar100_incr.yaml # 数据集、模型、训练超参全部走配置 ├─ data/ │ ├─ cifar100.py # 数据集加载与任务划分 │ └─ memory_buffer.py # 内存池:herding抽样、更新、存取 ├─ models/ │ ├─ vit_lora.py # 冻结底座+LoRA适配的ViT封装 │ ├─ prototype_head.py # 可动态扩展的分类头 │ └─ losses.py # 增量损失函数 ├─ trainers/ │ └─ incremental_trainer.py # 增量训练循环、蒸馏、评测 ├─ serve/ │ └─ export_onnx.py # 推理模型导出脚本 └─ run_incremental_cifar100.py # 主入口

这套结构里最常见的误区是:分类头直接用nn.Linear(embed_dim, num_classes),每来一个新session就重新建一个更大的线性层。这样设计在代码层面能跑,但工程上有两个隐患:一是导出模型时维度总在变,部署端难处理;二是旧类权重可能在训练中被大幅改动,没有显式的约束载体。我在代码里单独写了一个PrototypeHead,用每个类别的特征向量均值作为分类向量,推理阶段对特征和所有类向量做余弦相似度。用户只看接口的话,它和普通分类层用法一致。

4.2 核心训练流程与接口设计

增量训练主循环的骨架,关键思路是先做herding样本补充,再训练当前session,最后用全量旧类测试集评估。顺序很重要:如果先训再抽,抽出的是“已经被新模型处理过的特征”,抽样标准会被污染;必须用旧模型的特征来抽旧样本。

# 伪代码:一个增量session的完整动作 old_model = build_model() queries, targets = build_current_session_data() # 1. 用旧模型特征对当前任务新类做herding抽样,并更新内存池 mems = MemoryBuffer.from_old_pool(args.memory_per_class) mems.merge(herding_select(current_features, args.memory_per_class)) # 2. 构造可扩展分类头,新类向量加入 head.expand(new_class_ids) # 3. 增量训练,损失来自 CE + KL + PatchMSE trainer.fit(old_model, model, combined_loader) # 4. 重新计算所有类别的原型向量 model.recompute_prototypes(all_ref_features)

第四步是很多人会漏掉的关键动作。因为在增量训练之后,特征空间虽然变化不大,但并不是完全不变。分类向量如果还停留在上一轮session结束时的值,和新特征之间就会产生系统性偏差。每轮增量结束我都对所有类别重新抽一次特征并算一次均值,这个小操作能让最终准确率稳定提高1-2个百分点。

4.3 从PyTorch到ONNX的导出,以及动态分类头的处理

企业同事最关心的通常是“你训练的东西,我能不能用标准工具链部署”。PyTorch模型虽然在推理侧也能跑,但接入生产环境时,ONNX Runtime和TensorRT是更常见的选项。增量学习系统在导出时有一个麻烦点:分类头的类别数量是动态增长的,如果每次增量后都要改计算图,部署方会崩溃。

我的处理方式是把“分类头”从计算图里拆出来。ViT骨干和LoRA合并权重后导出为的ONNX模型只输出特征向量,输出维度固定为768。分类逻辑放在ONNX之外,用一个独立的类别向量表存储,查询阶段做矩阵乘法+softmax。这样每次新增类别,只需要往类别向量表里追加一行,ONNX模型完全不用重新导出。

ONNX导出要注意LoRA权重合并的细节。训练时模型的权重是W + alpha/r * AB,导出前要把LoRA的增量合并进原权重,并清理掉低秩分支,否则ONNX里会多出一些不必要的节点,增加推理延迟。

python export_onnx.py --checkpoint latest.pt --output vit_encoder.onnx --opset 12 --dynamic-batch

推理阶段用ONNX Runtime加载,实测在CPU上单张224x224图片推理时间约18ms,用TensorRT在T4 GPU上可以压到2ms以内。这个性能对大多数业务分类场景完全够用。

4.4 毕业论文展示和企业POC的侧重点差异

同一个系统,面对两类受众,展示方法可以完全不同,但底层代码不用改。

写毕业论文时,我建议把多个session的分类准确率画成曲线图,每个session结束以后记录一个点,观察曲线是否平滑下降,而不是在某一步突然断崖。同时把内存池里herding选出的图可视化,和随机抽样选出的图做对比,评委看完能直观理解“选样本”这个模块是在做什么。另外,t-SNE图建议在最后一个session结束后画,把所有已见类别的特征投影到二维平面,观察新老类之间的边界是否清晰、有没有明显混叠。这张图几乎是答辩时的亮点图。

企业POC演示时,重点则是现场“增量训练”的流程:准备一个包含10个新类的小数据包,当场在单卡上跑一到两个epoch,展示训练脚本自动更新类别向量表和模型文件,随后立即用新老混合测试集验证,实拍效果远比PPT说服力强。企业方看到“30分钟训练完成、老类准确率基本不动”,通常很快就能理解这套系统的价值边界。

5. 踩坑复盘:ViT增量学习里最不值得重复的五个教训

5.1 新类分类头初始化不当,准确率被拖低2%

第一次跑通的时候,我的可扩展分类头对新增类别的向量是随机初始化的。结果每次新session的前几个epoch,分类损失下降很快,但旧类的logits被新类随机向量干扰得很明显。后来我把新类向量的初始化改成“旧类向量的均值”,新类随机向量均值乘以一个小系数叠加。这个改动看起来不起眼,但实际上让新session初期就有一个大概合理的决策面,旧类的准确率波动小了很多。

更彻底的做法是用“零样本三角”技巧:新类向量初始化为0,推理时用余弦相似度距离,而不是内积。因为内积可以保证“初始时新旧类区分不清但不偏向任何一边”,模型再用梯度慢慢把新类向量推到该在的位置。实测比随机初始化高约2个百分点。

5.2 LoRA学习率设成1e-3,旧类遗忘率翻倍

一开始我图省事,给所有可训练参数用同一个学习率1e-3。训练曲线显示,LoRA层在1e-3下适应得很快,但蒸馏损失上升得也很快——模型为了拟合新类,把注意力分布整体带偏,旧类推理结果迅速退化。把LoRA学习率降到5e-4之后,虽然新类准确率的收敛会慢一点,但遗忘率从9%降到了2.3%。

这里分享一个判断方法:在训练过程中单独记录旧类回放样本上的分类准确率,如果这个数字在下降趋势明显,说明学习率太高或蒸馏权重太弱。把这张图挂到log里,比只看训练loss靠谱得多。

5.3 验证集和回放池“撞车”:论文指标虚高

我在前几版实验里,直接拿测试集评估每个session的结果——看起来很好,但因为回放池里的样本也来自训练集,我训练时其实“见过”这些训练图了,这没问题。真正的坑在于,我后来用测试集的同一批图做“proto校正”参考特征,这就相当于让模型在评估时偷偷看了一眼同类分布。严格来说,原型向量必须只从训练集特征计算,测试集只能用于最终一次性的分类准确率评估。如果两者混用,指标会虚高1%-3%,而且非常容易被评审或同事质疑。

5.4 单卡资源下的训练时间:别把系统做成只有A100才能跑的方案

ViT-B/16在224x224输入下,单个epoch在3090上大约1.2分钟,基础任务30轮约35分钟,增量任务15轮大约18分钟。整套9轮增量加基础任务,跑完不到4小时。这个训练成本对毕设和企业POC都是可以接受的。

如果换成更大的ViT-L或者直接全量微调,训练时间会翻好几倍,而且需要更大显存。企业同事根本不会等你训练一整天再演示,所以“单卡、短训、即可观察效果”本身就是这套系统的设计目标之一。

5.5 别只看平均准确率,还要盯每个类别的“遗忘排行榜”

最后一个教训来自一个看起来“分数不错但实际很危险”的模型状态。某次跑完实验,平均准确率87.3%,看起来还好,但按类别拆分后发现,有三个旧类的准确率从原来的80%多直接跌到50%以下。平均分掩盖了这个结构性遗忘问题。

增量学习的最终检验标准,不是“平均分好看”,而是“最差的旧类也不能崩”。我的处理方式是在每个session结束后,输出一份“类别遗忘排行榜”,按当前准确率-历史最高准确率的降幅排序。任何类别降幅超过15%,就要检查是内存池样本不足、还是该类别和其他新类在特征空间上过度重叠。这招对企业评审时也特别有用——你可以指着榜单说,“我知道哪个类最容易忘,而且我有机制去监控它”。

最后再分享一个可能对你有用的习惯:每完成一个session的增量训练,我会把所有类别的原型向量基于新特征空间重算一遍。这个方法成本极低,但对最终准确率的稳定贡献很明显,而且在公开代码里很容易被漏掉。如果你打算复现这套系统,不妨先加上这一步再跑对比。完整的代码目录、训练配置和导出脚本我整理在一个公开仓库里,按标题里的关键词就能找到,直接pip install依赖后跑主入口即可复现CIFAR-100上的87.58%。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询