结构增扩式增量学习:隔离遗忘,按任务扩展模型
增量学习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 实验设定与代码框架为了让概念落地我们做一个最简单的结构增扩DemoSplit 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_dim128, num_classes_per_head5): 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(inplaceTrue), )) 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_idNone): # 先提取共享特征 with torch.no_grad(): # 推理时backbone可以看成只读的 feat_base self.backbone.features(x) feat_base feat_base.flatten(start_dim1) # 如果给定了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), dim1)) # 这里简单起见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, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Flatten(), nn.Linear(32 * 7 * 7, 128), nn.ReLU(inplaceTrue), ) # 为了演示我们再挂一个不用的空输出避免forward不一致 # 实际使用时特征提取用features部分即可 def forward(self, x): return self.features(x) def prepare_dataloaders(batch_size128): transform transforms.Compose([ transforms.ToTensor(), ]) full_train datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) full_test datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) 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_sizebatch_size, shuffleTrue), DataLoader(task0_test, batch_sizebatch_size), DataLoader(task1_train, batch_sizebatch_size, shuffleTrue), DataLoader(task1_test, batch_sizebatch_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_epochs5, lr1e-3): optimizer torch.optim.Adam(trainer.parameters(), lrlr) criterion nn.CrossEntropyLoss() for epoch in range(num_epochs): for x, y in trainer: optimizer.zero_grad() logits model(x, task_id0) if len(model.heads) 1 else model(x, len(model.heads)-1) loss criterion(logits, y) loss.backward() optimizer.step() # 这里简单打印一下损失 print(fepoch {epoch1}, loss{loss.item():.4f})这段代码里task_id直接取新任务的索引。这意味着我们假设训练时任务ID是已知的。对应到实际业务就是每次做数据标注时你都知道这批数据属于哪个新增任务。3.3 推理时的两种路由策略结构增扩最容易被忽略的是推理阶段的“路由”。很多论文实验里假设测试时给定任务ID但业务里往往不给你这个ID。用户传入一张图你怎么知道它属于任务0还是任务1我常用的两个方案置信度路由把所有分支的输出都算一遍取最高softmax概率对应的类别作为最终输出。这是最简单的方案代码里也是这么做的。优点是零额外成本缺点是任务之间类别相似度较高时容易误判。原型距离路由用每个任务训练集的样本特征中心作为prototype推理时算输入特征和哪个任务的原型最近就进哪个分支。这个比纯置信度稳一些尤其是当模型在不同任务上的置信度存在系统性偏差时。如果你做得更讲究还可以训练一个专门的小型分类器来判断“该走哪个门”但那样就引入了额外模块的复杂度我一般只在任务数超过5个时才推荐这么做。注意如果你用的是“置信度路由”新任务的头在训练初期输出置信度往往会偏高导致测试样本几乎全部误判到新分支。一个实用的缓解技巧是给路由加一个温度系数或者在路由计算前先对每个头做一次校准比如用验证集统计每个头的平均置信度再归一化。3.4 保存模型和管理分支结构增扩模型的保存有个小技巧一定要用save整个模型结构或至少保存一份配置元信息比如每个头对应哪些类别、每个头的类数。否则推理时光有权重文件你怎么知道head[0]是0-4还是5-9import json def save_checkpoint(model, task_info, pathmodel.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 AccTask1 AccTask2 Acc平均学完Task091.2%--91.2%学完Task190.5%88.6%-89.6%学完Task289.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结构增扩的收益会大打折扣别硬上。写在最后的实际体会从第一次在某跨平台系统的多分类增量需求里尝试结构增扩到现在把它作为团队里处理多任务模型迭代的默认方案之一我的总体感受是结构增扩是最容易让业务方理解、也最容易控制风险的增量学习思路。它不像正则化那样玄学也不像回放那样受制于数据存储它的代价非常透明——你接受了模型体积的增长换来了几乎没有遗忘的可控模型。但这里有一个经常被忽略的前提结构增扩适合的是“任务边界清晰且任务数不会无限膨胀”的场景。如果哪天你发现自己需要维护上千个分支那说明一开始的架构选型就出了问题长期来看还是要回归到统一的共享表示上去。如果你打算在自己的项目里尝试我建议从最简单的头部扩增开始用两周时间把流程跑通再逐步引入适配器、路由优化和剪枝。这比一上来就搭一个复杂的多分支网络要务实得多。踩过几次坑之后你会发现增量学习的难点其实不在模型结构本身而在工程上如何把“新旧共存”这件事管理得井井有条。