增量学习(Incremental Learning / Continual Learning)近年来在AI落地中几乎是绕不开的话题。模型不能只学一次,业务一来,新任务、新类别、新数据分布总是源源不断。而“基于模型结构增扩的增量学习方法”是三大主流范式里最符合工程直觉的一种——新任务来了,我不去动旧参数,而是给模型“加房间”。这篇文章我会把这类方法的原理、代表性做法、实际落地代码、评估套路和坑一次性讲透,尽量用我实际项目里验证过的经验来说话,少讲空泛的理论。
本文适合谁看?如果你是做算法落地、模型持续迭代、多任务系统设计的工程师,或者刚接触增量学习、想搞明白“结构扩增”和“正则化”“数据回放”到底差在哪里的研究者,这篇内容应该能帮你节省不少摸索时间。我会把核心机制拆成“共享骨架 + 专用分支 + 路由选择”三个层面来讲,最后还会给一个跑得通的简化实现,你可以直接改改拿去做实验。
1. 结构增扩到底解决了什么问题
1.1 先理解“灾难性遗忘”为什么难解
神经网络在连续学习多个任务时会出现一个非常反直觉的现象:学新任务时,用来拟合旧任务的重要权重会被大幅调整,导致旧任务的精度断崖式下降,甚至连刚学会不久的知识都保不住。这个现象叫做灾难性遗忘(Catastrophic Forgetting),本质是梯度更新在参数空间中“踩踏”了旧知识所在的区域。
你可能想问,能不能靠多任务联合训练一次性解决?能,但前提是所有任务的数据你得同时拿到,可是在真实业务里这几乎做不到。用户行为会漂移、商品品类会新增、图像数据会一批批标注出来,系统必须边收集边学习。增量学习的核心矛盾,就是要用一个不能完整访问历史数据的模型,把新知识“接”进来,同时尽量不破坏旧知识。
1.2 三大范式的取舍:为什么结构增扩值得单独讲
学术界目前把主流增量学习方法粗略分成三类:
- 基于回放(Replay):把旧样本或旧样本的生成特征存下来,训练新任务时混合着一起训。这类方法效果好,但存储和隐私往往是硬伤,尤其在工业场景里旧数据可能根本不允许保留。
- 基于正则化(Regularization):通过约束重要权重的更新幅度来缓解遗忘,典型代表是EWC、SI等。这类方法不需要额外存储,但任务一多、类别差异一大,约束力就会迅速衰减。
- 基于结构增扩(Architectural / Structural Expansion):旧任务的参数固定不变,新任务通过增加新的参数结构来学习。不依赖旧数据,遗忘基本被“物理隔离”,天然适合任务数稳定增长、隐私要求高的场景。
结构增扩最大的优势就是“不纠缠”。它不跟旧参数较劲,而是为每个新任务开辟独立空间,旧知识和新知识各住各的屋子。代价则是模型体积会随着任务增加而增长,所以它更适合任务数量可控、单任务数据量不太大的场景。
提示:三类方法并不互斥,我实际项目里就经常用“结构扩增 + 小规模回放(只存每类少量原型)+ 正则化”的混合策略,效果比单用任何一种都稳。下面讲的原理和代码,你也可以按这个思路去扩展。
1.3 适合用结构增扩的典型场景
结构增扩不是银弹,但它有几个非常典型的适用场景:
- 多租户/多域定制模型:每个客户或每个业务域一个分支,互不干扰,模型可以统一部署。
- 隐私受限的增量场景:旧数据不能保留,但任务边界清晰(比如不同批次的标注任务)。
- 任务边界已知的流水线:比如视觉质检里不断新增缺陷类别,每个批次的新增缺陷作为新任务,在旧模型上扩一个分支即可上线。
- 联邦学习下游:各客户端本地数据分布差异大,中心端用结构扩增方式吸收各客户端的新增能力。
如果任务边界模糊、任务数量无限增长,或者单任务的数据量小到连新分支都训不动,那结构增扩的性价比就会降低,这时候你可能要往回靠,重新考虑回放式方法。
2. 核心原理解析:共享骨架、专用分支与路由
2.1 结构增扩的三层架构
我习惯把结构增扩方法拆成三个模块来看:共享特征提取器(骨架)、任务专用扩展(分支)、路由决策(门控)。这三个模块各管一段,组合方式决定了具体方法的风格。
- 共享骨架:通常是网络的前几层卷积或底层Transformer块,负责提取通用特征。第一任务训练后就不动或者只微调很少一部分,它是所有任务共用的“地基”。
- 任务专用分支:每个新任务到达时,在骨架上新增一个分支或一组参数。这个分支可以是一个分类头、一个适配器模块、甚至是一整条子网络。新任务训练时只更新这个分支,旧任务分支冻结。
- 路由决策:推理时,模型需要决定一个样本应该走哪个分支。有的场景任务ID是已知的(比如系统告诉你当前是任务3),这时路由退化为一个索引;有的场景任务ID未知,就需要用置信度、特征距离、或者一个额外的门控网络来判断。
用生活化的类比来解释:共享骨架像一个酒店的公共大堂和电梯,所有客人共用;任务专用分支是客房,每个新任务进来就开一间新房;路由是前台,判断来的人应该去哪间房。大堂不能随便拆,客房互不打扰,前台决定了客人的去处。
2.2 各类结构增扩方法是怎么“扩”的
结构增扩在论文里的表现形式五花八门,但归纳下来不外乎下面几种:
- 头部扩增:只增加分类头。共享backbone不变,每个任务一个独立分类头。实现最简单,适合类别划分明确的分类任务。我之前做商品图片的增量识别,就是用的这种方案,后面代码示例也会以它为主。
- 分支网络扩增:为每个任务增加独立的子网络分支,每个分支拥有自己的特征处理模块,最终输出通过门控组合。这是最接近“模型集成”的做法,表达能力最强,但体积增长也最快。
- 参数掩码扩增:不增加网络结构,而是为每个任务学习一个二进制或软掩码,固定旧任务的掩码,只在新任务对应的子网络上更新。代表工作有PackNet、HAT等。这类方法的优点是显存和FLOPs相对可控,缺点是掩码的粒度设计直接决定了效果。
- 特征空间扩增:在高维特征空间中增加新的子空间,新任务只在新子空间里做变换,典型如一些基于子空间的增量方法。这类方法理论优美,但工程落地时对特征维度和算子设计要求较高。
想快速选型的话,我给的参考是:任务多但每个任务类别少,优先“头部扩增+轻量适配器”;任务少但每个任务复杂度高,优先“分支网络扩增”;设备资源紧张,可以看看“掩码扩增”这类稀疏化路子。
2.3 为什么“冻结旧参数”不等于万事大吉
结构增扩有个看起来很美的假设:只要冻结旧参数,旧知识就不会被破坏。但实际训练中你会发现:共享骨架虽然冻结了,可是新分支的梯度依然会通过共享部分回传——如果你不设置梯度隔离,事实上旧参数还是被动了。很多初学者第一次踩的坑就是这个。
即使你严格冻结了共享骨架,还有另一个问题:共享特征提取器是在早期任务上训练的,它提取的特征对后来任务的适配性可能不够。新分支只能基于一个“过时的特征表示”去做分类,表现会打折扣。这也是为什么很多结构增扩方法会在分支里额外加适配器层,用可训练的小网络去调整共享特征,而不是直接用原始特征。
我在实际项目中试过,面对特征分布漂移比较大的新任务,单纯加分类头的效果远不如“分类头+轻量适配器”,新任务的准确率能提升10个百分点左右。这个细节建议你第一版就考虑进去。
3. 实操过程:一个可跑通的结构增扩实现
3.1 实验设定与代码框架
为了让概念落地,我们做一个最简单的结构增扩Demo:Split MNIST。把MNIST的10个类别分成两个任务,任务0学类别0-4,任务1学类别5-9。模型在任务0上先正常训练一个“共享backbone + 分类头”,任务1到来时冻结backbone,新增一个分类头,只训练新头。这里为了演示适配器思路,我再加一个输入为共享特征的轻量MLP适配器。
先说清楚,这个Demo不追求刷SOTA,目的是让你看清结构增扩的运行机制和代码骨架,理解了之后你可以把backbone换成ResNet或Transformer,把分类头换成更复杂的结构。
import torch import torch.nn as nn import torch.nn.functional as F from torch.utils.data import DataLoader, Subset from torchvision import datasets, transforms # 这个网络演示了结构增扩的“共享骨架 + 每个任务一个头”的设计 class StructuralExpansionNet(nn.Module): def __init__(self, backbone, feat_dim=128, num_classes_per_head=5): super().__init__() # backbone是共享特征提取器,任务1之后会冻结 self.backbone = backbone # 每个任务对应一个分类头,头之间完全独立 self.heads = nn.ModuleList() # 再给每个任务配一个适配器,用于缓解特征漂移 self.adapters = nn.ModuleList() # 初始任务使用的头 self._add_head(feat_dim, num_classes_per_head) def _add_head(self, feat_dim, num_classes): self.adapters.append(nn.Sequential( nn.Linear(feat_dim, feat_dim), nn.ReLU(inplace=True), )) self.heads.append(nn.Linear(feat_dim, num_classes)) def add_new_task(self, num_classes): # 新任务来了:在保留旧头的同时,增加一个新头 feat_dim = self.heads[0].in_features self._add_head(feat_dim, num_classes) def forward(self, x, task_id=None): # 先提取共享特征 with torch.no_grad(): # 推理时backbone可以看成只读的 feat_base = self.backbone.features(x) feat_base = feat_base.flatten(start_dim=1) # 如果给定了task_id,就走指定分支 if task_id is not None: feat = self.adapters[task_id](feat_base) return self.heads[task_id](feat) # 如果没给task_id,则逐个分支计算置信度,选择最高分 logits_list = [] for adapter, head in zip(self.adapters, self.heads): feat = adapter(feat_base) logits_list.append(torch.softmax(head(feat), dim=1)) # 这里简单起见,argmax所有头里最大概率对应的类 # 更稳妥的方式会在后面说 return logits_list这个类有两个关键点:
- backbone的feature提取部分被包装成了只读操作(推理时无论哪个头调用,都不会改变共享参数)。
- 每次新增任务,只是往
ModuleList里追加一个适配器和分类头,旧头部完全保留。PyTorch的ModuleList会自动把新增参数注册到模型里,保存/加载时新头也会被正常序列化。
3.2 训练流程:什么时候冻结共享网络
我这里用一个小型CNN充当backbone,方便你直接跑。注意训练的第一个任务和后续任务逻辑不一样。
class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Flatten(), nn.Linear(32 * 7 * 7, 128), nn.ReLU(inplace=True), ) # 为了演示,我们再挂一个不用的空输出,避免forward不一致 # 实际使用时,特征提取用features部分即可 def forward(self, x): return self.features(x) def prepare_dataloaders(batch_size=128): transform = transforms.Compose([ transforms.ToTensor(), ]) full_train = datasets.MNIST(root="./data", train=True, download=True, transform=transform) full_test = datasets.MNIST(root="./data", train=False, download=True, transform=transform) task0_train = Subset(full_train, [i for i, (_, y) in enumerate(full_train) if y < 5]) task0_test = Subset(full_test, [i for i, (_, y) in enumerate(full_test) if y < 5]) task1_train = Subset(full_train, [i for i, (_, y) in enumerate(full_train) if y >= 5]) task1_test = Subset(full_test, [i for i, (_, y) in enumerate(full_test) if y >= 5]) return ( DataLoader(task0_train, batch_size=batch_size, shuffle=True), DataLoader(task0_test, batch_size=batch_size), DataLoader(task1_train, batch_size=batch_size, shuffle=True), DataLoader(task1_test, batch_size=batch_size), )训练时第一轮正常更新全网络,第二轮开始把backbone冻住。这里有个细节:要求backbone的requires_grad_(False),同时把优化器参数只设置为model.adapters[-1].parameters()和model.heads[-1].parameters()。为什么连requires_grad都要关?因为即使优化器不更新backbone参数,BN层在forward时仍会更新running statistics,如果backbone里有BN层,这也会导致旧分支的推理分布被悄悄改变。踩过一次这个坑之后,我建议任何结构增扩项目统一使用GroupNorm,或者在冻结阶段把BN设成eval模式。
def train_model(model, trainer, num_epochs=5, lr=1e-3): optimizer = torch.optim.Adam(trainer.parameters(), lr=lr) criterion = nn.CrossEntropyLoss() for epoch in range(num_epochs): for x, y in trainer: optimizer.zero_grad() logits = model(x, task_id=0) if len(model.heads) == 1 else model(x, len(model.heads)-1) loss = criterion(logits, y) loss.backward() optimizer.step() # 这里简单打印一下损失 print(f"epoch {epoch+1}, loss={loss.item():.4f}")这段代码里,task_id直接取新任务的索引。这意味着我们假设训练时任务ID是已知的。对应到实际业务,就是每次做数据标注时,你都知道这批数据属于哪个新增任务。
3.3 推理时的两种路由策略
结构增扩最容易被忽略的是推理阶段的“路由”。很多论文实验里假设测试时给定任务ID,但业务里往往不给你这个ID。用户传入一张图,你怎么知道它属于任务0还是任务1?
我常用的两个方案:
- 置信度路由:把所有分支的输出都算一遍,取最高softmax概率对应的类别作为最终输出。这是最简单的方案,代码里也是这么做的。优点是零额外成本;缺点是任务之间类别相似度较高时容易误判。
- 原型距离路由:用每个任务训练集的样本特征中心作为prototype,推理时算输入特征和哪个任务的原型最近,就进哪个分支。这个比纯置信度稳一些,尤其是当模型在不同任务上的置信度存在系统性偏差时。
如果你做得更讲究,还可以训练一个专门的小型分类器来判断“该走哪个门”,但那样就引入了额外模块的复杂度,我一般只在任务数超过5个时才推荐这么做。
注意:如果你用的是“置信度路由”,新任务的头在训练初期输出置信度往往会偏高,导致测试样本几乎全部误判到新分支。一个实用的缓解技巧是给路由加一个温度系数,或者在路由计算前先对每个头做一次校准(比如用验证集统计每个头的平均置信度再归一化)。
3.4 保存模型和管理分支
结构增扩模型的保存有个小技巧:一定要用save整个模型结构或至少保存一份配置元信息,比如每个头对应哪些类别、每个头的类数。否则推理时光有权重文件,你怎么知道head[0]是0-4还是5-9?
import json def save_checkpoint(model, task_info, path="model.pt"): torch.save({ "model_state_dict": model.state_dict(), "task_info": task_info, # 例如 [{"classes": [0,1,2,3,4]}, {"classes": [5,6,7,8,9]}] }, path) def load_checkpoint(path): ckpt = torch.load(path) model = StructuralExpansionNet(SimpleCNN()) model.load_state_dict(ckpt["model_state_dict"]) return model, ckpt["task_info"]我建议把这个元信息和模型权重放在同一个checkpoint里,别单独存一个配置文件。因为实际部署时,模型文件会被拷贝来拷贝去,配置文件一旦丢失,分支和类别对应关系就再也对不上了。
4. 工程落地的评估方法与容量控制
4.1 评估指标不能只看最后一轮
增量学习里最忌讳的评估方式,就是最后只测一次“合并后的测试集准确率”。因为这个指标无法反映遗忘情况:你可能新任务100分、旧任务掉到40分,平均起来还是70分,看起来不错,实际旧任务已经废了。
我建议至少同时看三个指标:
- 平均准确率(ACC):所有已经学过的任务测试准确率的平均值,衡量整体性能。
- 遗忘率(Forgetting):每个旧任务在学完新任务后的精度下降幅度,下降越大说明遗忘越严重。
- 后向迁移(Backward Transfer):新任务的学习是否提升了旧任务的表现,这个指标为负说明有遗忘,为正说明有正向迁移。
画一个按任务顺序排列的“精度矩阵”表格是最直观的,横轴是当前已经学到的任务数,纵轴是每个历史任务的精度。
| 评估时刻 | Task0 Acc | Task1 Acc | Task2 Acc | 平均 |
|---|---|---|---|---|
| 学完Task0 | 91.2% | - | - | 91.2% |
| 学完Task1 | 90.5% | 88.6% | - | 89.6% |
| 学完Task2 | 89.8% | 86.3% | 87.1% | 87.7% |
从这张表能直观看出来,Task0从91.2%掉到89.8%,Task1从88.6%掉到86.3%,说明结构增扩虽然隔离了大部分遗忘,但仍有微小的性能下滑。这往往是共享特征层适应性问题导致的,而不是参数被覆盖了。
4.2 模型体积增长的控制手段
结构增扩被诟病最多的一点:模型会随着任务数线性膨胀。一个4任务的模型体积可能是单任务模型的2-3倍,如果每个分支都带完整backbone,那更是灾难。
我在实际工程里控制体积的经验有三条:
- 共享backbone尽量大,专用分支尽量小。能做到只加一个分类头,就绝不加一个完整子网络。
- 分支内部用低秩结构。比如把适配器的线性层拆成两个小矩阵,参数量可以压缩到原来的十分之一以下,性能却几乎不掉。
- 阶段性合并。如果某些任务的特征分布已经高度重合,可以学完后做一次知识蒸馏,把多个小分支合并进backbone,再重置分支。这需要额外的一次离线训练,但能有效阻止模型无限制膨胀。
提示:部署时别忘了做量化。结构增扩模型的分支都是小模块,用普通的INT8量化基本不掉点,体积可以直接砍掉一大半。
4.3 与回放和正则化的组合策略
单独用结构增扩在上面的Demo里效果不错,但复杂场景下我强烈建议做组合:
- 结构增扩为主:保证任务间隔离,避免灾难性遗忘。
- 小样本回放为辅:每个任务只保留几个有代表性的样本,混合到新任务的训练集里,用来给路由和新分支提供参照。
- 正则化兜底:如果新任务和旧任务共享了同一个头(比如类别有重叠),就在损失函数里加一点EWC约束,防止新任务的梯度在共享参数上猛踩。
举个例子,在类别有重叠的增量场景里,比如任务0学“猫、狗”,任务1学“狗、鸟”,如果你硬生生开两个独立分支,猫和狗在两个分支里分别存在,推理时就会出现“同一个狗样本在任务0和任务1都输出高置信度”的冲突。这时候要么在路由层做冲突消解,要么干脆把这个场景分解为“新类别增量”而不是“新任务增量”,采用另一种更合适的增量学习范式。选型的时候一定要想清楚任务边界是不是真的清晰。
5. 常见问题与排查技巧实录
5.1 新分支学不动怎么办
症状:新增任务的分支怎么训准确率都上不去,甚至比随机略好一点。
排查思路:先确认共享backbone是不是被冻结得太狠了。如果backbone是在任务0上过拟合训练出来的,它的特征可能对任务1完全不友好。这时候光加适配器是不够的,我遇到过只能靠降低backbone冻结程度(比如解冻最后两层,学习率设成主分支的十分之一)才能救回来。当然这会让旧任务有轻微遗忘风险,建议配合EWC或者回放少量旧类原型。
还有一种情况是任务0的数据量太少,backbone本身就没训练好。这种时候别指望结构增扩能逆天,第一关就得过:第一个任务的模型性能必须足够好。如果底子太差,后面所有增扩都是在沙地上盖楼。
5.2 路由误判导致推理错乱
症状:测试时任务ID未知,置信度路由在类别相似的任务上频繁选错分支,整体准确率比按已知任务ID推理低了十五个百分点以上。
我实践中比较有效的排查和修复顺序:
- 先看不同分支的置信度分布是否合理。如果新分支普遍比旧分支置信度高,就需要做置信度校准。
- 换成“原型距离路由”,用每个分支训练样本的特征中心做最近邻判断。
- 如果还不够,给路由加一个“拒识门”:当最高置信度低于某个阈值时,把样本判定为“未知”,而不是强行塞进某个分支。
有一条经验送给你:在类别数量不平衡的多任务场景,不要直接用softmax概率做路由。softmax概率天然对样本数量多的类别更自信,我有一次排查了整整两天才发现,新任务的类别数比旧任务少很多,置信度路由其实一直在偏向旧分支。后来我在路由前先对每个分支的概率按类别频率做了标准化,问题直接消失。
5.3 BN层的running stats被污染
症状:旧分支在训练新任务后,测试精度缓慢下降,但参数检查发现旧头部和backbone的权重都没变。
这个问题的真凶通常是BatchNorm。即便你把backbone的requires_grad设为False,只要模型整体还在train模式下跑,BN层就会用当前batch的统计量实时更新running_mean和running_var。新任务的数据分布一旦和旧任务差异很大,running stats就会被污染,旧分支的特征分布自然偏移了。
解决办法很粗暴但有效:冻结backbone后,把所有BN层切换成eval模式,或者直接改用GroupNorm。GroupNorm不依赖batch统计量,在增量学习里省心得多。我现在的习惯是,只要做结构增扩,backbone里的归一化层一律换成GroupNorm,省得后续排查。
5.4 分支越来越多,系统越来越慢
症状:模型在增加5个任务之后,推理延迟变成原来的4倍,内存占用也线性上升。
排查和优化方向:
- 先分析耗时在哪。一般情况下,如果共享backbone占了90%的计算量,分支只占10%,那么增加分支对推理延迟的影响并不大,瓶颈往往是内存带宽和模型加载时间。
- 如果各分支都串行执行(比如代码里循环遍历所有头计算置信度),可以改成并行计算。在PyTorch里把分支整合成一个大矩阵乘,或者用
torch.jit脚本化,延迟能压下去不少。 - 更彻底的优化是提前把分支做“稀疏化”。很多分支的线性层权重在训练后大部分接近0,剪掉后基本不影响精度。
我的一个真实调优案例:三任务模型剪枝后体积从120MB压到38MB,推理延迟降了40%,精度反而从89.1%微升到了89.4%。剪枝有时候还能顺带改善过拟合,这算是结构增扩额外的好处。
5.5 任务边界不清晰,怎么用结构增扩
如果你面临的问题不是“明确的多个任务”,而是“数据分布连续漂移”,那结构增扩并不合适,你要么回归正则化方法,要么引入“在线聚类+自动增扩”机制:对特征做聚类,每发现一个新簇就给模型自动分配一个新分支。这个方向已经有论文在做了,但工程成熟度还不高。我的建议是:先想清楚你有没有一个可用的任务ID。如果完全没有任务ID,结构增扩的收益会大打折扣,别硬上。
写在最后的实际体会
从第一次在某跨平台系统的多分类增量需求里尝试结构增扩,到现在把它作为团队里处理多任务模型迭代的默认方案之一,我的总体感受是:结构增扩是最容易让业务方理解、也最容易控制风险的增量学习思路。它不像正则化那样玄学,也不像回放那样受制于数据存储,它的代价非常透明——你接受了模型体积的增长,换来了几乎没有遗忘的可控模型。
但这里有一个经常被忽略的前提:结构增扩适合的是“任务边界清晰且任务数不会无限膨胀”的场景。如果哪天你发现自己需要维护上千个分支,那说明一开始的架构选型就出了问题,长期来看还是要回归到统一的共享表示上去。
如果你打算在自己的项目里尝试,我建议从最简单的头部扩增开始,用两周时间把流程跑通,再逐步引入适配器、路由优化和剪枝。这比一上来就搭一个复杂的多分支网络要务实得多。踩过几次坑之后你会发现,增量学习的难点其实不在模型结构本身,而在工程上如何把“新旧共存”这件事管理得井井有条。