
1. 增量学习的核心痛点与结构增扩的定位1.1 灾难性遗忘是绕不开的坎做实际项目的同学都有体会模型在旧任务上明明已经收敛得挺好用新数据一微调之前的准确率就哗哗往下掉。这个现象有个很形象的名字叫灾难性遗忘Catastrophic Forgetting。我遇到过最夸张的场景是在某个视觉分拣项目中为了追加一个新品类的识别把旧任务的准确率从 92% 直接干到了 61%前后只训练了不到两个 epoch。从那之后我再也不敢直接对生产模型做全量微调。之所以会出现这个现象根源在于神经网络的可塑性plasticity和稳定性stability是天然矛盾的。梯度更新会为了让当前任务 loss 变低把之前任务好不容易学到的决策边界挤到一边去。你可以把训练好的网络想象成一个摆放整齐的书架新任务相当于要把新书塞进同一层格子要么把旧书挤歪要么直接挤掉几本。除非你明确告诉网络哪些格子不能动否则梯度一定会选择改动成本最低的那条路。增量学习Incremental Learning也叫 Lifelong Learning、Continual Learning要解决的就是让模型具备同时“记住过去”和“学会新东西”的能力。一个理想系统应该满足三个条件能持续吸收新任务数据、不会明显遗忘旧任务、而且最好是自动地完成这个过程不需要人为干预。听起来简单做起来却几乎处处踩坑。本文讨论的结构增扩model structure expansion相关方法正是针对第三个条件给出的一个比较硬核的答案——它不靠约束权重、不靠复习旧数据而是靠给模型“加格子”来避免新旧任务互相抢占资源。1.2 三类主流路线正则化、回放与结构增扩当前做增量学习大致分三条技术路线我先给一个简明梳理方便后面讲结构增扩时对照着理解。第一类是正则化方法。核心思路是在目标函数里加惩罚项约束对旧任务重要的参数不要大幅变化。代表工作有 EWCElastic Weight Consolidation、SISynaptic Intelligence等。优点是实现简单、不需要存旧数据但任务数量多、数据分布差异大的时候约束力不够强旧任务性能还是会缓慢下降。你可以把它理解为给书架加了一层“请勿挪动”的提示标签但标签只是提示用力过猛时书照样会倒。第二类是回放方法。字面意思就是把之前的数据片段重新拿出来参与训练要么直接存原始样本要么用生成模型做伪样本。像 iCaRL、DER 这类方法实际效果在分类任务上很能打但需要额外的存储空间还涉及数据隐私和样本选择策略。在真实业务里旧数据往往拿不到这时候回放路线就受到了很大限制。第三类就是结构增扩也是这篇文章要展开的主题。它的思路更直接既然新旧任务共用一组参数会互相干扰那就给新任务分配新的网络结构、新的参数通道让旧任务对应的参数尽量保持不动。这类方法包括 Progressive Networks、PackNet、各类门控/掩码机制以及近两年很火的 Prompt-based 方法。思想用一句话概括以结构换稳定以参数量换记忆力。选用结构增扩的最实际理由是它能在不做数据回放、不做复杂约束的前提下最大程度保留旧任务的能力。如果你的场景满足两个条件——任务边界清晰知道什么时候来的是新任务、对模型体积增加有一定容忍度——那么结构增扩几乎是上手最快、效果最稳的方案。当然它也有自己的问题比如参数量会随任务数量增长、推理时可能需要判断任务 ID 等这些我放在后面的章节里详细展开。2. 模型结构增扩的核心设计范式这一章重点拆解结构增扩方法内部是怎么组织的。只有理解了不同范式之间的设计取舍你才不至于在项目里选错方向、越走越偏。2.1 塔状新支路Progressive Networks 的启发最早把“加塔”这件事做得比较系统的是 DeepMind 提出的 Progressive Networks。它的结构很直观每来一个新任务就新建一个独立的子网络塔塔之间用横向连接从前面的塔接收特征所有旧塔的参数完全冻结不再更新。新塔可以同时利用新任务的专属参数和旧塔提取的通用特征实现旧知识被借鉴但不被践踏。它有一个很大的贡献是把“旧知识可以被新任务借鉴但不能被破坏”这个原则落到了实处。不过这个方案有一个明显短板N 个任务就要 N 个塔每个塔还都有完整的前向计算图推理时要遍历多个塔取特征延迟开销会越来越大。我早期在一个小规模 Demo 上测试时觉得挺好用但把任务数推到 10 个以上之后模型体积和显存占用都开始吃不消。所以后来业内开始转向更轻量的结构增扩形式而不是机械地堆整塔。要说清楚这类塔状方案的适用场景其实和管理团队很像如果公司只有三五个产品线每条线配一个独立小组没问题但产品线上到几十个每个小组都要一套完整的基础设施成本就失控了。所以塔状方案适合任务数量不多、任务之间数据分布差异巨大的场景不适合小而密的多任务流。2.2 掩码与稀疏化不建塔也能隔离参数不需要新增整塔的另一种做法是参数掩码。比如 PackNet 的思路训练完旧任务后按照贡献度排序剪掉一部分冗余连接再把腾出来的参数量用于学习新任务新任务权重写在被剪掉的位置上。这样模型整体结构不变靠不同任务占据不同位置的参数来避免冲突。掩码类方法的优点是结构紧凑参数量增长可控缺点是剪枝率怎么定是一门玄学。剪多了新任务学不动剪少了新旧任务参数区域重叠依然会遗忘。实际调参时我通常会把冗余参数比例设在 30%~50% 之间然后观察旧任务精度变化一旦精度明显下跌说明剪得太狠需要回退下调剪枝率。掩码类方法的工程实现还有一个容易被忽略的细节旧任务的掩码要始终在训练中生效也就是说反向传播时被旧任务占据的参数位置梯度必须置零。很多初稿代码只做了前向掩码忘了对梯度做同样处理效果自然不对。PyTorch 里可以用register_buffer保存每个任务的 mask然后在backward之前对参数梯度做一个param.grad * mask的操作这一步不能省。2.3 门控组合与参数隔离从独立到共享的中间态所谓中间态是指参数不完全独立也不完全共享而是通过一个可学习的门控机制让不同任务激活不同的参数子集。比较有代表性的是 XDGeXclusive Dense Gating它给每一层分配一组可学习的门控向量不同任务被要求激活尽可能互不重叠的门控。训练时额外加一个稀疏性正则逼着每个任务的激活区域缩小。这种方案的好处在于不同任务能共享一部分底层特征模型体积不像 Progressive Networks 那样线性爆炸坏处是门控向量本身需要额外维护训练的时候如果正则系数没调好会出现门控全部关闭或者全部打开的极端情况。另外多任务门控之间如果存在冲突优化起来还比较容易震荡。我记得有一次在某个数据量很小的任务上测试稀疏正则权重设成 0.01 的时候几乎不起作用调大到 0.1 之后模型直接“躺平”了——新任务一个类都学不会。后来用学习率升温、门控初始化很接近 1 的方式才救回来。这类问题的核心是要时刻记住门控只是分配器它的初始化状态会极大影响分配结果。合理的做法是把门控参数初始化为接近 1 的值让它们一开始接近“全开”然后通过正则慢慢收敛到稀疏状态而不是一开始就让它们自由竞争。2.4 轻量扩展适配器、侧网络与 Prompt最近几年的趋势是把“增扩”做得越来越轻。侧网络Side Network的做法是在主干旁边加一些 1x1 卷积或线性模块只训侧网络主干完全冻结。适配器Adapter则是在每个 Block 的输出后插入小模块新任务来了就在特定位置新增一个适配器分支。再往后就是 Prompt-based 方法典型如 L2P、DualPrompt通过在输入前拼一组可学习的 Prompt 向量来引导预训练模型完成不同任务新任务只需学习自己的 Prompt 向量。这类方法最吸引人的一点是它们搭上了预训练模型这趟快车。主干参数巨大但完全不更新模型的基础视觉/语言能力完全保留每一个新任务只增加极小量的可学习参数精度和效率平衡得很不错。如果你在业务里已经有现成的 ResNet、BERT 这类预训练骨干我建议可以直接从适配器和 Prompt 方案入手落地成本比从零搭塔低很多。有一个朋友在某个业务里做多语种意图识别就是用冻结的多语种编码器加轻量Prompt向量不到两周就出了可上线版本旧语种的效果一条没掉。3. 从零动手一个结构增扩增量学习 Demo光讲概念没用这一章我完整拆一个可以跑起来的小示例用最简单的塔状扩展实现增量分类。这个示例用 PyTorch 写代码量不大但设计思路可以直接迁移到复杂项目里。3.1 场景设定与整体设计我拿一个常见的实验配置举例把 CIFAR-10 按类别拆成 5 个任务每个任务包含 2 个类别按顺序依次学习。任务 0 学类别 0 和 1任务 1 学类别 2 和 3依此类推。每个任务训练时完全看不到其他任务的数据这就构成一个标准的 Task-Incremental任务增量场景。模型设计成一个小型 CNN 作为共享特征提取器每个任务在其输出之上接一个新的分类头新任务训练时共享提取器也做微调但旧任务的分类头全部冻结——这就是最朴素的塔状扩展。为了对比效果我后面还会提到一个“全量微调不加隔离”的对照组让大家直观看到结构增扩的收益。说明一下任务增量场景和类增量场景的区别任务增量在训练和推理时都明确知道当前样本属于哪个任务所以可以为每个任务设置独立的分类头类增量则不给任务 ID模型需要在全类别范围内做分类。本文 Demo 是任务增量类增量要做更多设计第 5 章会单独聊。3.2 模型代码实现下面给出 PyTorch 实现的主要网络部分。我用了一个 4 层卷积的基础编码器任务头用分别的线性层通过 ModuleList 管理每来一个新任务就 append 一个头。import torch import torch.nn as nn import torch.nn.functional as F class ConvEncoder(nn.Module): def __init__(self, out_dim256): super().__init__() self.out_dim out_dim self.features nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), nn.Flatten(), nn.Linear(128 * 4 * 4, out_dim) ) def forward(self, x): return self.features(x) class TaskHead(nn.Module): def __init__(self, in_dim256, num_classes2): super().__init__() self.fc nn.Linear(in_dim, num_classes) def forward(self, x): return self.fc(x) class IncrementalNet(nn.Module): def __init__(self, encoder, task_classes): super().__init__() self.encoder encoder self.heads nn.ModuleList() self.task_classes task_classes def add_task(self, num_classes): self.heads.append(TaskHead(self.encoder.out_dim, num_classes)) def forward(self, x, task_id): feat self.encoder(x) return self.heads[task_id](feat)代码里的IncrementalNet维护一个ModuleList每来一个新任务就add_task一次。推理时喂入任务 ID选择对应的头输出。这个写法已经体现结构增扩最关键的一点旧任务的分类头只要不进入梯度计算它的参数就不会被破坏。实际训练新任务时requires_grad要按头编号动态控制下面这段是训练一个任务的完整逻辑。3.3 训练流程与隔离策略训练第 t 个任务时最重要的是把参数隔离策略落到优化器上。我把旧任务头全部冻结编码器可以更新新任务的损失也只使用新任务的数据。def train_task(model, train_loader, task_id, epochs20, lr1e-3): for param in model.encoder.parameters(): param.requires_grad True for i, head in enumerate(model.heads): for param in head.parameters(): param.requires_grad (i task_id) optimizer torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lrlr) loss_fn nn.CrossEntropyLoss() model.train() for epoch in range(epochs): total_loss 0.0 for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() logits model(x, task_id) loss loss_fn(logits, y) loss.backward() optimizer.step() total_loss loss.item() print(ftask {task_id}, epoch {epoch}, loss {total_loss / len(train_loader):.4f})实际跑下来这个分支隔离策略配合较低的编码器学习率我常用 1e-4 到 5e-4效果比较稳定。编码器学习率如果给高了共享特征会漂移旧任务头的输入分布变了精度照样掉只不过掉得慢一点而已。这一点是新手最容易忽略的结构增扩不是“冻结了旧头就万事大吉”共享主干一漂移所有旧头都会跟着受牵连。如果你希望旧任务精度几乎不下跌更保守的做法是连编码器也冻结只训新任务的头和侧模块这在下一节会进一步讨论。训练结束后需要在所有已学任务上做一次全量回归评估而不能只看新任务的测试准确率。一个更严谨的评估方式是维护一个“全任务测试表”每训练完一个新任务就用当前模型把所有历史任务的测试集都跑一遍记录每个任务的单独准确率和平均准确率。只有旧任务准确率随着新任务加入基本保持稳定方案才算合格。3.4 评估指标与整体实验配置评估增量学习效果一般同时看两个数新任务的平均准确率和旧任务的平均准确率。更高阶一点可以画出所有任务的逐阶段累计准确率曲线。比如任务 1 训练完成后测试任务 0 和任务 1任务 2 训练完成后测试任务 0、任务 1、任务 2这样能看到遗忘趋势。我拿 CIFAR-10 任务增量配置跑过塔状扩展冻结旧头的方案5 个任务结束后平均准确率大概能到 86% 左右而对照组如果不做任何隔离共享最后几层全量微调同样配置最后卡在 54% 上下。差距说明结构隔离对防止灾难性遗忘的作用非常直接。具体数值会因网络大小、随机种子有波动但趋势稳定。实验里还有一个细节值得提每个任务的训练 epoch 数不宜太少小网络在类别数少时 15 到 25 个 epoch 一般可以收敛epoch 太少会导致新任务欠拟合从而放大旧任务对共享特征的干扰。很多复现文章跑不出基线效果恰恰是因为训练步数不足而不是方法本身有问题。4. 实操中的常见问题与排查心得这一章是整篇里我认为价值密度最高的部分全部来自真实踩坑记录。表面看都是小细节但在增量学习项目里这些细节决定了方案能不能落地。4.1 共享主干漂移问题上面提到的编码器梯度更新是结构增扩方法里一个非常隐蔽的坑。理论上旧任务的分类头没被更新看起来是安全的但旧头下一层接的是编码器特征编码器只要变了旧头的决策面就会失效。我试过几组对比编码器学习率从 1e-3 降到 1e-4旧任务准确率能提升 5 到 8 个百分点。应对思路有两个方向。保守型新任务训练时直接冻结整个骨干只训练新头和轻量侧模块最稳妥、但新任务拟合速度慢如果新任务和旧任务的特征差异很大可能学不到位。激进型骨干照常更新但给骨干部分的梯度乘一个很小的缩放系数同时对骨干参数和初始值之间的偏离加一个软约束。折中方向通常更适合实际使用。我自己在多数情况下会选“轻微更新骨干”并把软约束系数定在 0.1 到 0.3 之间这样既保证了新任务有足够表达能力又不会让共享特征发生剧烈漂移。4.2 推理时任务 ID 的判定很多 Demo 假设知道当前样本属于哪个任务这在论文和竞赛里很常见。真实业务里样本到达时经常没有任务 ID。怎么判定比较朴素的做法是用每个分类头的置信度哪个头的最大 softmax 值高就交给哪个头处理。但置信度策略在新旧类别比较接近时非常不可靠因为 softmax 本身会过度自信经常给错误头打出 0.9 以上的分数。我见过一些生产级系统用不确定性估计来做任务分派比如给每个样本算一个预测熵熵低于某个阈值的直接走对应头部熵高的样本再交给一个单独训练的“路由网络”来判断。路由网络会额外增加训练负担但换来的稳定性可接受。如果你的业务流本来就有批次边界比如一批数据就是一个新任务那直接用批次 ID 做任务 ID 就够了完全不需要再折腾路由。这个经验看起来很直白但很多人在设计系统时就是宁可造复杂路由网络也不肯先确认上游数据是否自带任务分段。4.3 参数增长与容量饱和结构增扩的隐形成本是存储。每加一个任务就多一个头或者多加一个 Prompt 向量任务数达到上百之后再小的增量乘以任务数也会变得难以承受。更麻烦的是容量饱和主干表达空间有限新任务特征和旧任务特征会在特征层面互相纠缠加再多头也学不好新任务精度曲线会出现明显的天花板。面对这类现象有效的办法之一是定期对共享主干做一次扩展扩展方式可以采用类似网络结构搜索的手段来决定加宽哪几层。如果你不想引入这么重的机制那就得从一开始就给主干留足够容量比如直接用更大规模的骨干替换小模型。这条听起来像废话但很多人就是先选了个很小的主干跑完才发现容量不足技术债极大。增量学习项目里容量预测这件事可以放在系统设计的前期先估算你未来两三年可能遇到的任务总数按这个数倒推主干宽度宁可前期多花点训练成本也不要在上线后被容量卡死。4.4 训练动力学与正则的坑还有几个小细节值得记录。第一新头的权重初始化建议用偏小值比如 0.05 到 0.1 范围防止刚接入的新头在初期给编码器一个剧烈的假梯度。第二BatchNorm 层面对任务切换比较脆弱如果任务数据分布差异大建议在旧任务分支上也保留独立统计量否则旧头输入分布会随着新任务的 BN 统计量偏移而改变。第三如果用了蒸馏损失辅助保持旧任务能力要注意温度系数的设置一般 4 左右比较常见但需要结合任务类别数微调类别数多时温度可以适当提高。另外不同任务的数据量差异悬殊时我会对新任务做类别均衡采样防止大类样本完全主导新头训练。结构增扩方法本身不要求数据回放不意味着不需要数据处理数据层面的细节同样会直接决定项目成败。比如某个任务只有 200 张样本另一个任务有 2 万张新任务头的收敛速度和质量必然不一样如果不做均衡结果就是小样本任务被大样本任务的特征分布彻底覆盖。5. 不同方法的选型建议与扩展思路5.1 按场景选型而不是按名气选型我会建议按下面几个关键条件去排优先级而不是只看方法在论文里刷了多少分。场景特征推荐路线理由任务边界清晰能接受模型体积线性增长塔状扩展/独立子网实现简单遗忘控制最直接有预训练骨干新任务数量多且频繁到来Prompt/Adapter 类方法新增参数量小主干不用动存储和推理延迟压力大掩码/稀疏化不增加显式分支结构紧致任务分布差异大且类别高度相关门控蒸馏组合门控隔离参数蒸馏辅助旧知识维持选型最重要的一件事是先明确你的约束边界。同样是增量学习离线批处理和在线流式场景对模型体积、推理延迟的要求截然不同。不考虑业务约束谈方法优劣没有意义。我见过一个团队用塔状扩展做在线推荐场景任务头增加到 30 个以后单次推理时延从 10 毫秒涨到 60 毫秒最后只能改成掩码方案才把延迟压回来。这就是典型的不先做约束分析带来的返工。5.2 从任务增量到类增量的进阶最后说一句扩展思路。文章里 Demo 属于任务增量每个任务边界已知、分类头数量固定。如果你想往类增量所有类别共用一个分类头不断有新类别插入方向走结构增扩需要配合类别级原型或者嵌入空间约束不能直接套上面的实现。很多做产品落地的人最终会走到这里因为真实产品很少给模型准备好“任务边界”。从任务增量变成类增量核心是把“头部”隔离降级为“特征级”隔离新类别会复用旧类别的底层表示这更接近人的学习方式。你可以用增量聚类的方式为每个新类别维护一个可更新的类原型把分类从“走哪个头”改成“离哪个原型近”。这一步虽然会增加工程复杂度但换来的是更接近真实业务的能力。原型可以周期性地用少量新样本做更新也可以在特征分布发生偏移时做全局校准具体看业务对时效性的要求。6. 结语结构增扩并非银弹但值得稳定落地说实话我不太喜欢把增量学习包装成什么神奇解决方案。它更像是一套“账本思维”每一步扩展都记录清楚哪些参数属于哪个任务哪些特征可以被共享哪些资源要预留。结构增扩之所以在很多场景下比正则化和回放更稳本质原因是它把“保护旧知识”从软性的约束变成硬性的结构隔离——这是它的核心竞争力。在过去一两个真实项目里我最终落地用到的组合都比较朴素共享蒸馏损失下的多头塔 轻量门控旁路既能保旧也能学新工程复杂度控制在一个可接受的范围内。关键动作是每次扩展新任务后我都会单独跑一遍全任务回归看每个任务各自的准确率曲线而不是只盯着新任务指标。这个习惯帮我躲过了很多次“看起来在涨、实际在跌”的陷阱。最后再分享一个压箱底的小技巧如果你打算在现有系统里引入结构增扩建议先找最小可复现的单元做实验用旧任务精度曲线作为核心验收指标。这个指标看起来简单却能一票否决掉大量看似热闹的方案。增量学习做得越久越会明白“不遗忘”比“学得快”重要得多。希望这篇文章能把结构增扩的脉络讲清楚帮你少走一段弯路。