最近在梳理持续学习(Continual Learning,CL)相关方法时,我注意到一篇非常有代表性的工作:One Adapter, Many Tasks: Task-Conditioned Feature Transformations for Continual Learning。这个题目非常直白:一个 Adapter,处理多个任务,核心手段是“任务条件化的特征变换”。
这个方向让我很感兴趣。因为很多入门者接触持续学习时,第一反应是“我把所有任务数据放在一起重训不就行了吗?”或者是“给每个任务保存一个完整模型不就好了?”但实际落地时,这两种思路都会遇到现实问题。前者受限于数据隐私与存储压力,后者则意味着模型规模随任务数量线性膨胀。Adapter 方案的出现,正好是想在“参数共享”和“任务自适应”之间找一个平衡点。
本文不会只是简单复述论文摘要,而是会把持续学习的背景、Adapter 的结构原理、任务条件特征变换的动机、PyTorch 实现思路以及工程落地中的注意事项完整梳理一遍。适合准备入门持续学习的同学,也适合已经在做参数高效微调(PEFT)或想把大模型改造成“能持续学新任务”的开发者。文中代码以示例为主,目的是让大家理解核心思想,具体网络层数和超参需要根据你手上的任务和数据集自行调整。
1. 背景:持续学习为什么这么难?
1.1 灾难性遗忘是首要矛盾
持续学习希望模型能按顺序学习一系列任务。例如任务 1 是识别猫和狗,任务 2 是识别汽车和飞机,任务 3 是识别花和树。我们希望模型学完所有任务后,既能在新任务上表现好,也不要忘记旧任务上的能力。
但深度学习模型在标准梯度训练下很容易出现灾难性遗忘。原因并不难理解:当模型在新任务上做反向传播时,梯度更新会覆盖掉那些对旧任务至关重要的特征提取参数。表现为旧任务准确率断崖式下跌。
早期的解决思路分成几类:
- 增加正则项,约束重要参数不要变化过大,例如 EWC。
- 引入知识蒸馏,让旧模型指导新模型,例如 LwF。
- 保留一部分旧样本,即 Rehearsal 或 Replay 方法。
- 给每个任务单独分配参数,例如 Progressive Networks。
这些方法各有缺陷。正则和蒸馏往往只能减缓遗忘,无法完全避免;Replay 方法在数据隐私受限的场景下难以使用;独立参数的方法则让模型体积膨胀。
1.2 参数高效微调与持续学习的结合
随着预训练模型和 Transformer 架构普及,研究者发现不需要微调全部参数,也可以通过小规模可学习模块把模型适配到新任务上。常见的做法包括 LoRA、Prefix-Tuning、Adapter 等。这类方法统称为参数高效微调。
Adapter 的特点是:主模型参数冻结,额外插入少量参数,训练时只更新这些参数。这样做不仅节省显存和存储,还天然削弱了“任务间冲突”的强度,因为主模型共享部分没有被大幅改动。
从持续学习的角度来看,冻结主模型意味着模型内部表达能力相对稳定,不同任务的学习被限制在 Adapter 等轻量模块中。这就把“跨任务影响”限制在了一个很小的参数空间里,遗忘概率大大降低。作者正是从这一点出发,提出了任务条件化的特征变换机制,试图用一个统一的 Adapter 结构承载多个任务的适配需求。
1.3 本文要解决的问题
如果一个任务用一个 Adapter,确实可以避免遗忘,但任务多了以后仍然是线性扩展。更优雅的方式是:多个任务共享同一个 Adapter 模块,但是 Adapter 内部的特征变换过程会根据当前任务 id 产生不同行为。这一思路正好呼应标题里的 One Adapter, Many Tasks。
要做到这一点,需要解决三个问题:
- 共享 Adapter 如何区分不同任务?
- 任务信息应该如何注入特征变换过程?
- 训练完旧任务后,新任务加入会不会破坏旧任务的特征变换规律?
下文会分别从概念、结构、代码实现三个角度来拆解。
2. 核心概念拆解
2.1 Adapter 是什么?
Adapter 最早在 NLP 领域被提出,后来被广泛用于视觉和跨模态任务。它的基本结构非常精简:先通过一个降维线性层把高维特征压缩到较低维度,经过非线性激活函数,再通过一个升维线性层恢复原特征维度。整体通常接在主干网络某一层之后,并使用残差连接方式防止信息损失。
下面是一个通用 Adapter 的 PyTorch 示意图,便于理解它和主干网络的位置关系。
import torch import torch.nn as nn class BasicAdapter(nn.Module): """ 标准 Bottleneck Adapter 结构。 输入输出特征维度一致,方便以残差形式接入主干网络。 """ def __init__(self, d_model: int = 768, bottleneck: int = 64, dropout: float = 0.1): super().__init__() self.down_proj = nn.Linear(d_model, bottleneck) self.act = nn.GELU() self.up_proj = nn.Linear(bottleneck, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x: torch.Tensor) -> torch.Tensor: # 假设 x 形状为 [batch, seq_len, d_model] 或 [batch, d_model] h = self.dropout(self.act(self.down_proj(x))) h = self.up_proj(h) return x + h这里的关键点在于:
- d_model 是主干网络特征的维度。
- bottleneck 通常远小于 d_model,例如 64 或 128,从而控制参数量。
- 残差连接保证了如果 Adapter 未生效,模型仍能保持原有表达能力。
2.2 Task-Conditioned Feature Transformation 的含义
任务条件特征变换,简单说就是让 Adapter 在做特征变换时,不仅依赖输入特征本身,还依赖一个“任务标识向量”。不同任务 id 会改变 Adapter 内部的变换参数,或者改变中间特征的缩放与平移。
常见做法有两种:
- 第一种是任务级超网络:维护一个任务 embedding 表,每次根据任务 embedding 动态生成 Adapter 参数。
- 第二种是特征调制:同一个 Adapter 网络共享权重,但任务 embedding 经过任意小网络生成一组特征调制向量,对 Adapter 的中间结果实施逐维度的缩放和平移。
这两种做法都能实现“一个 Adapter 结构,多个任务行为”的效果。前者生成参数更灵活,但计算量更大;后者参数量更可控,更常见于视觉任务的实践。
需要注意:任务条件特征变换不是给每个任务单独训练一套分类头这么简单。分类头通常只在最后改变输出维度,任务条件特征变换想要改变的是模型内部对特征的“解读方式”。同一个输入特征,如果来自任务 A,模型应按任务 A 的分类边界处理;如果来自任务 B,模型就应按任务 B 的分类边界处理。
2.3 为什么一个 Adapter 能处理多个任务
直观理解是:主干网络负责提取通用的底层特征,比如轮廓、纹理、颜色过渡等;Adapter 负责在通用特征之上,注入当前任务特有的特征变换规则。由于每个任务都拥有自己的任务嵌入向量,Adapter 可以按照任务嵌入向量“切换”到合适的变换模式。
这样做有三大好处:
- 存储开销小。不管训练了多少个任务,整个模型只需要额外保存每类任务的一小段任务嵌入向量,而不需要保存独立的一整套 Adapter。
- 遗忘控制好。主干网络基本冻结,Adapter 又是所有任务共享的主体结构,旧任务和新任务在同一个功能空间里做调制,不会出现旧任务专属模块被覆盖的问题。
- 可扩展性强。新任务到来时,只需要增加一个新的任务嵌入向量并重新训练 Adapter,就可以复用历史任务学到的通用特征变换模式。
3. 方法架构原理
3.1 整体框架:冻结主干 + 任务嵌入 + 条件 Adapter
基于题目描述和持续学习的主流做法,本文方法可以用下面这条流程概括。
原始图片或文本输入先经过冻结的主干网络,得到多层中间特征。在主干网络的不同位置插入任务条件 Adapter。训练任务 t 时,模型接收输入样本以及任务 id t。任务 id 会映射到一个可学习的 task embedding。这个 task embedding 被用于控制 Adapter 内部的中间特征调制。最终分类时,通常使用 Task id 对应的分类器头。
为什么把主干冻结?因为持续学习的难点在于新任务更新会干扰旧任务的表征。如果主干网络被大规模更新,即使有 Adapter 存在,仍然可能通过共享的底层表征破坏旧任务。冻结主干可以把任务差异全部限制到轻量模块中,这也是众多持续学习工作中验证过的有效策略。
3.2 任务嵌入如何发挥作用
任务嵌入向量的维度一般很低,例如 32 维、64 维。它不会直接替代输入特征,而是通过一个小型映射网络生成调制系数。假设 Adapter 在降维后得到中间特征 h,h 的形状是 [batch, bottleneck]。任务嵌入先生成缩放向量 gamma 和偏移向量 beta,然后执行如下计算:
[ h' = \gamma(task_id) \odot h + \beta(task_id) ]
这种运算在视觉特征调制里很常见。它可以改变中间特征在任务方向上的响应强度,相当于每个任务学会了如何强调对自身有利的特征、抑制对自身不利的特征。
这种机制比直接修改 Adapter 权重更稳妥。因为如果新任务直接去改整个 Adapter 的权重,可能把旧任务已经形成的特征变换规律破坏掉。而 gamma 和 beta 是通过 task embedding 生成的,不同任务之间的 task embedding 本身是独立可区分的,冲突概率显著降低。
3.3 放在模型哪些位置更合适
Adapter 的插入位置会显著影响效果。如果只放在最后一个分类层之前,Adapter 只能对高层语义特征做变换,无法修正底层特征上的任务差异;如果在每一层都插入 Adapter,又会增加计算成本。
工程经验上更推荐在主干网络较深的多个阶段插入 Adapter。浅层网络负责通用边缘纹理特征,跨任务差异较小,可以少放甚至不放;深层网络负责语义特征,任务差异大,需要更精细的调制。这种做法和迁移学习中的经验也一致:底层特征更通用,高层特征更任务相关。
4. PyTorch 核心实现思路
4.1 定义 Task-Conditioned Adapter 模块
下面给出一份可运行的示例代码。这里做的是特征调制版本,结构清晰,参数量也比较容易控制在较小范围内。
import torch import torch.nn as nn class TaskConditionedAdapter(nn.Module): """ 任务条件化 Adapter: 共享 down/up projection,但通过 task embedding 生成中间特征调制系数。 """ def __init__( self, d_model: int = 768, bottleneck: int = 128, task_embed_dim: int = 64, num_tasks: int = 10, dropout: float = 0.1, ): super().__init__() self.down_proj = nn.Linear(d_model, bottleneck) self.up_proj = nn.Linear(bottleneck, d_model) self.act = nn.GELU() self.dropout = nn.Dropout(dropout) # 任务嵌入表,每个任务一个可学习向量 self.task_embedding = nn.Embedding(num_tasks, task_embed_dim) # 从任务嵌入生成缩放与偏移 self.gamma_gen = nn.Linear(task_embed_dim, bottleneck) self.beta_gen = nn.Linear(task_embed_dim, bottleneck) def forward( self, x: torch.Tensor, task_id: torch.Tensor, ) -> torch.Tensor: # x: [batch, d_model] hidden = self.act(self.down_proj(x)) hidden = self.dropout(hidden) # task_id: [batch] 或 [1],根据任务嵌入生成调制系数 task_emb = self.task_embedding(task_id) # [batch, task_embed_dim] gamma = self.gamma_gen(task_emb) # [batch, bottleneck] beta = self.beta_gen(task_emb) # [batch, bottleneck] hidden = hidden * gamma + beta out = self.up_proj(hidden) return x + out说明几个关键点:
- 这里为了演示方便,把 task_id 做成 batch 级别。如果同一次批量训练只包含一个任务,可以传入 shape 为 [batch] 的 task_id 张量。
- gamma 和 beta 由线性层直接生成,实际项目中也可以换成两层 MLP,增强表达能力。
- 如果输入 x 是带序列长度的特征,即 [batch, seq_len, d_model],需要在生成 gamma/beta 后做维度扩展,例如在倒数第二维插入 singleton 维度。
由于原论文的精确模块设计不一定与上面完全一致,所以这份代码是“思路演示版本”。做自己的实验时,应该根据主干网络的输出尺寸和任务特点调整维度关系。
4.2 将 Adapter 接入主干网络
在实际使用中,我们通常会在主干网络编码层的某个输出后面插入 TaskConditionedAdapter。以下代码展示了如何用一个简单包装类把 Adapter 集成到骨干网络中,实际使用时需要对照你选择的主干网络内部结构做插入。
import torch import torch.nn as nn import torchvision.models as models class BackboneWithAdapter(nn.Module): """ 示例:以 ResNet 的 layer4 输出作为特征来源, 再接一个 TaskConditionedAdapter,最后进入分类头。 实际项目可根据需要把 Adapter 插入多个 stage 之后。 """ def __init__(self, num_tasks: int, num_classes_per_task: int = 10): super().__init__() resnet = models.resnet18(pretrained=False) # 去掉原始分类层,保留特征提取部分 self.features = nn.Sequential(*list(resnet.children())[:-2]) d_model = 512 # resnet18 layer4 输出通道数 self.adapter = TaskConditionedAdapter( d_model=d_model, bottleneck=128, num_tasks=num_tasks, ) # 每个任务一个分类头 self.classifiers = nn.ModuleList([ nn.Linear(d_model, num_classes_per_task) for _ in range(num_tasks) ]) def forward(self, x: torch.Tensor, task_id: torch.Tensor): feat = self.features(x) # 假设 ResNet 输出 [batch, channel, 7, 7],做全局平均池化 feat = feat.mean(dim=[2, 3]) # [batch, d_model] feat = self.adapter(feat, task_id) # [batch, d_model] logits = self.classifiers[task_id.item()](feat) return logits这份代码里做了一个重要设计:每个任务保留一个独立的分类头。这样做不是必须的,但非常常见。因为不同任务定义的类别集合不同,强制让所有任务共享同一个分类空间会让优化目标互相干扰。持续学习评测中,常见做法是每个任务 10 类或 100 类互不重叠,所以多分类头是一种标准配置。
4.3 训练与评测流程
持续学习训练代码和普通训练最大的不同在于:任务数据不是混在一起随机训练的,而是按任务顺序逐个出现。以下代码展示了一个基础的持续学习训练循环。
def train_one_task(model, task_id, train_loader, epochs=10, lr=1e-4): """ 训练单个任务。此时只会看到当前任务的数据。 """ device = next(model.parameters()).device optimizer = torch.optim.Adam(model.parameters(), lr=lr) criterion = 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) task_tensor = torch.full( (x.size(0),), task_id, dtype=torch.long, device=device, ) logits = model(x, task_tensor) loss = criterion(logits, y) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"Task {task_id}, Epoch {epoch + 1}, Loss: {total_loss / len(train_loader):.4f}")训练完所有任务后,我们会在每个任务各自的测试集上评估模型,并计算整体平均准确率。理想情况下,模型不仅要在当前任务上表现好,还要保持历史任务上的准确率不下降。
4.4 推理时需要知道任务 id 吗
这取决于部署场景。如果应用系统明确知道自己当前处理的是哪个任务,例如“先识别验证码,再识别票据类型”,那推理时可以直接把 task_id 传进模型。如果任务 id 未知,则需要通过预测手段来判断当前样本属于哪个任务。
在方法评估阶段,持续学习的常见设定是已知任务边界,因此可以直接使用任务 id。若要在完全没有任务 id 的开放场景下部署,通常需要额外训练一个任务判别器,或者通过原型匹配来进行任务路由。这个方向本身也是一个值得深入了解的研究点。
5. 实验设计:如何判断方法好不好
5.1 典型评测基准
持续学习论文常用的评测基准包括 CIFAR-100 的 Split 版、TinyImageNet 的 Split 版、ImageNet 子集以及 DomainNet 等。常见的设置是将整个类别集合按照固定规则分成若干任务,例如把 100 个类别分成 10 个任务,每个任务包含互不重叠的 10 个类别。
这类评测关注的核心指标有三个:
- 平均准确率:所有任务训练完后,模型在所有任务测试集上的平均分类准确率。
- 前向迁移:模型学习新任务时,能否利用旧任务中学到的知识加快收敛。
- 后向迁移:学习新任务后,旧任务性能是否下降。负值越大说明遗忘越严重。
对于 Adapter 类方法,平均准确率和遗忘率是大家最关心的两个指标。如果方法在多个随机种子和多种任务划分下都能保持稳定,说服力会更强。
5.2 应该和哪些方法对比
评估这类方法时,至少要对比以下几组代表性的方法。
| 方法类型 | 代表性思路 | 与本文关系 |
|---|---|---|
| 微调基线 | 直接在全部任务上顺序微调 | 展示灾难性遗忘有多严重 |
| 正则方法 | EWC、SI 等 | 说明轻量约束方法的基准线 |
| Replay 方法 | 数据回放、特征回放 | 展示有样本记忆时的上限 |
| 参数分配方法 | 每个任务独立 Adapter | 说明共享 Adapter 的存储优势 |
| Prompt 方法 | L2P、DualPrompt | 同属参数高效路线,对比价值高 |
具体实验中,表格里通常还会加上可学习参数量、存储开销、运行时间等维度。这个工作最重要的贡献更多体现在“能否共享同一个 Adapter 结构完成多个任务的特征变换”,而非单纯刷高准确率。所以评估时建议同时记录参数量和内存占用。
5.3 怎么理解论文中的性能结果
由于论文的精确数值依赖实验环境、骨干网络和任务划分方式,这里不给出虚构数字。但从该类方法的一般规律来看,值得关注几个结论:
第一,在低资源任务数量较多的场景下,共享 Adapter 的存储优势非常明显。虽然每个任务仍然需要一个分类头和一组 task embedding,但相比每任务保存一个完整模型,开销已经大幅降低。
第二,Adapater 的瓶颈维度会影响准确率。太小的瓶颈维度可能无法表达复杂的任务差异,太大的瓶颈维度又会带来过拟合和存储增长。实际使用时可以针对不同任务数量做一次小规模消融实验。
第三,任务条件特征变换的稳定性通常优于“每次新任务直接新增一套 Adapter”的做法。因为多次任务之间共享了 Adapter 主体,新任务可以从旧任务的特征变换经验中获益,这在任务类别相近时前向迁移会更明显。
6. 工程实践与训练建议
6.1 训练时的稳定性问题
持续学习训练中有一类常见问题:任务顺序不同,结果差异很大。如果任务 1 和任务 2 类别相近,后出现的任务可能对前一个任务产生正迁移;如果两个任务差异很大,则更容易出现冲突。因此做方法实验时,最好固定统一的任务顺序,并且跑多个随机种子取平均,否则结论容易被偶然因素干扰。
如果训练过程中发现旧任务准确率大幅度波动,首先需要检查学习率是否过大。Adapter 类方法虽然参数少,但过大的学习率仍然可能让任务嵌入向量发生剧烈变化,导致不同任务之间的调制方向互相干扰。推荐把学习率设置在 1e-4 到 5e-4 区间,并使用余弦退火或线性 warmup。
6.2 任务嵌入向量的维护与扩展
每次来一个新任务,需要做以下工作:
- 扩展模型的 task_embedding 词典大小,并随机初始化新任务的嵌入向量。
- 扩展自注意力或线性层索引,让新任务能够选择到新的分类头。
- 在训练开始前,把旧任务的参数设置为冻结状态,只允许更新 Adapter 主体、新增分类头和新增 task embedding。
这里建议把任务 id 和数据集元数据统一管理。不要把 task embedding 的索引硬编码在代码里,而是维护一张任务字典。这样在做模型部署和增量训练时,新任务注册的流程会更清晰。
6.3 是否使用回放数据
如果业务允许保留少量旧样本,那么结合经验回放会让训练过程稳定很多。回放数据不需要很多,每类保留 10 到 20 张代表性样本就能明显抑制遗忘。原因很简单:在持续学习过程中,均匀混合少量旧任务样本,相当于给旧任务提供了“复习”机会。
如果数据隐私严格受限,则纯 Adapter 方案的优势会更大。因为旧任务样本完全不需要保留,任务知识被压缩在共享的 Adapter 和任务嵌入里。这也意味着初始任务顺序的选择很重要:优先学习那些数据分布覆盖面广的任务,可以让后续任务受益更多。
6.4 推理性能与参数量评估
生产系统中不仅要看准确率,还要关注推理速度。插入 Adapter 后,每个 Transformer Block 或每个卷积阶段会增加一次降维和升维计算。在 CPU 上部署时,这部分开销不能忽视。
建议在发布模型前统计以下指标:
- 总可训练参数量。
- 模型文件大小。
- 单样本平均推理延迟。
- 不同任务间切换的任务嵌入查询耗时。
如果延迟成为瓶颈,可以只在主干网络的最后两个阶段插入 Adapter,或者把 bottleneck 调小。通过以少量准确率为代价换取明显更快的推理速度,在很多业务场景中是值得的。
7. 常见问题排查与解决思路
这类方法在实现过程中通常会遇到一些比较典型的问题。下面整理成表格,方便按图索骥。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 新任务学习正常,但旧任务准确率突然掉得厉害 | Adapter 主体学习率过大,或主干网络被解冻 | 冻结主干,降低学习率,引入少量旧样本回放 |
| 所有任务准确率都不高 | Adapter 瓶颈维度太小,容量不足 | 增加 bottleneck,或增多 Adapter 插入层数 |
| 任务 id 之间的切换结果不稳定 | 任务嵌入初始化不合理 | 统一使用固定随机种子初始化,或改用正态分布小标准差初始化 |
| 训练时出现梯度 NaN | 特征调制中 gamma 过大,导致特征数值发散 | 对 gamma 做归一化约束,或在线性生成层后加 tanh 限制范围 |
| 测试时不知道用哪个分类头 | 部署场景没有任务 id 输入 | 增加任务路由模块,或使用原型分类替代多分类头 |
| 模型文件随任务数量线性增大 | 分类头或 task embedding 过多 | 减少每任务独立分类头,换成共享分类头加任务条件投影 |
这里想多说一点 gamma 的问题。任务条件特征变换在实现中很容易把 gamma 的范围做得过大,导致中间特征数值爆炸。稳妥的做法是在 gamma_gen 输出后使用一个归一化约束层,将 gamma 限制在 0.5 到 1.5 之间,或者让网络学习一个增量值而不是直接从零开始预测大范围参数。
8. 我的实践心得与下一步学习建议
对于理解这篇工作,最重要的不是背住某个网络结构,而是理解“任务条件化”为什么能解决持续学习冲突。冻结主干网络是降低遗忘的保险丝;任务嵌入是区分任务身份的钥匙;共享 Adapter 主体是跨任务迁移知识的高速公路。这三件事放在一起,才让一个轻量 Adapter 具备同时承载多个任务特征变换的能力。
我自己在项目里验证这套思路时,最大的感受是调试难度比想象中低。因为主干网络权重完全不更新,大部分训练问题都集中在 Adapter 内部,问题定位范围要比全参数微调小得多。另外,这套思路也可以迁移到大语言模型的增量指令微调场景中。比如不同领域的新增指令可以看成不同任务,只需要新增领域专属 task embedding,而不必为每个领域保留一份完整 LoRA 权重,这对多租户或多业务线场景很有价值。
如果你准备从零开始复现或改造这个方向,我的建议是不要一上来就挑战大规模数据集。先从 CIFAR-100 的 Split 任务开始,把骨干网络选小一点,例如 ResNet18,然后逐个加入 TaskConditionedAdapter,观察旧任务准确率变化曲线。只有在小规模实验上把训练循环、评测逻辑和调试手段都跑通后,再逐步迁移到更复杂的骨干网络和更大规模数据上。这个小切入点虽然朴素,却能帮你积累大量一手经验,后面调参和设计变体都会顺手很多。
如果本文对你有帮助,欢迎收藏备用。后续我也会继续整理参数高效持续学习的源码解读和实验对比,你可以关注后保持交流。