ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MAS:用参数重要性正则化缓解灾难性遗忘的PyTorch实践

MAS:用参数重要性正则化缓解灾难性遗忘的PyTorch实践

一个人工智能模型如果只学会了一个任务,转头去学第二个任务,往往会把第一个任务忘得干干净净。这种情况在业内叫灾难性遗忘,是连续学习(Continual Learning)领域最核心、也最让人头疼的问题。今天要拆解的方法是 Memory Aware Synapses(MAS),一篇思路清奇、实现简单、效果又很稳的经典老工作。它不需要保存旧数据、不需要额外网络、不需要任务专属结构,只要计算每个参数对输出的重要性,在后续训练里“重点保护”这些重要参数,就能让旧知识尽量不被冲掉。这篇文章不打算只讲论文复述,我会从连续学习的技术路线说起,把 MAS 的原理掰开揉碎,给出可直接操作的 PyTorch 实现细节、超参数调节经验,以及我实际项目里踩过的坑。

只要你在做增量学习、模型微调、多任务训练,或者只是想让自己手里的模型别那么“脆”,这篇文章都值得往下看。MAS 这种“按参数重要性保护知识”的思路,到现在依然是很多新方法的底层组件,搞懂它对理解后续的 EWC、SI、PackNet 也都有帮助。

1. 先把“灾难性遗忘”这件事看明白

1.1 为什么模型会“学了新的就忘旧的”

不管是分类网络还是生成模型,本质上都是在调整网络里的几百万、几千万个参数,让输出尽可能匹配目标。

当你训练数据来自任务 A 时,梯度下降会沿着让任务 A 损失下降的方向更新参数。等切换到任务 B,数据分布变了、损失形态变了,继续用梯度下降更新参数,网络就会倾向满足任务 B 的目标。问题在于,任务 A 和任务 B 可能对同一批参数有不同甚至矛盾的更新方向,于是那些本来承载着任务 A 信息的参数值就被改写,任务 A 的能力自然就蒸发了。这个“改写”本质上是参数共享副作用,除非你把每个任务完全隔离开,否则无法彻底避免。

很多人天真地觉得,在训练任务 B 时把任务 A 的损失也一起算进去不就行了。想把这个思路落地,首先你得存着任务 A 的数据,这在很多真实场景下根本不现实,旧数据可能涉及隐私、可能因为存储限制被清理、可能已经不存在了。就算数据还在,每加一个新任务就要把历史所有数据重新训练一遍,计算开销也会随时间线性膨胀,这就不叫连续学习了,那叫频繁的全量重训。

MAS 这类正则化方法不存旧数据,而是在任务 A 训练完以后,用很小的代价计算一组参数重要性权重。之后学任务 B 时,给重要的参数加上一个“锁”,让它们尽量不变,不重要的参数则可以自由更新。这个思路本质上是告诉优化器:这里有块地雷,你踩的时候轻一点。

1.2 三条主流技术路线,MAS 站在哪一边

连续学习的方法大致可以分成三类:

  • 参数隔离法:每个任务使用独立的参数子网络。典型代表有 PackNet、Progressive Networks、Hard Attention。这类方法不容易遗忘,但参数开销大,而且要在网络里找“空闲参数”,结构设计受限制。
  • 回放法:显式保存一小部分旧样本或旧特征,训练新任务时混着一起用。代表有 Experience Replay、A-GEM、LwF(特征蒸馏也算半个回放)。效果好,但涉及存储、隐私、样本选择策略,工程上没那么干净。
  • 正则化法:不保存旧数据,而是在损失函数里加一项约束,限制参数更新的自由度。EWC 是最早出名的一个,SI 是后续改进,MAS 也是这一支里的经典。

MAS 在正则化法里又有一点特殊,很多正则化方法需要知道任务边界,比如 EWC 在每个任务之后都要算一次 Fisher 信息矩阵,然后基于新任务和旧任务构建惩罚项。MAS 的办法更简单,你在任务 A 训练完以后,只需要跑一次前向和反向传播,对网络里的每个参数算一个“重要性分数”,然后把这个分数积累下来。之后学任务 B 时,在通常的损失函数后面加一个 L2 惩罚项,惩罚内容就是“所有参数的变化量乘以重要性”。

这个方法的好处是计算成本低、实现难度小,而且不依赖任务之间的明确分界,可以在线持续更新。我后来在好几个项目里测过,作为 baseline,MAS 的稳定性和可调性都相当不错。

2. MAS 的核心思想与重要性权重

2.1 从“哪些参数不能乱动”这个问题切入

我记得第一次看 MAS 论文时,最先记住的就是它的问题框架:一个参数重要不重要,不看它的值有多大,也不看它被更新了多少次,而是看改动它以后,网络输出会不会发生剧烈变化。

这个思路很直觉。网络最终的输出是决策的载体,如果某个参数稍微一动,输出就大幅改变,说明这个参数承载了关键的模式信息。反过来,如果参数怎么改输出都不怎么变,说明它对当前任务贡献很小,后面学新任务时尽管改就行。

怎么量化“输出对参数的变化敏感程度”:

  • 常规正则化方法 EWC 用的是 Fisher 信息矩阵,本质是“损失对参数的二阶导近似”,它回答的是:改这个参数,会让旧任务损失增加多少。
  • MAS 的视角不同,它不直接算损失,而是算“网络输出的范数对参数的敏感程度”。

具体来说,给定输入 x,网络输出记为 (F(x, \theta)),那么参数 (\theta_i) 的重要性定义为:

[ \Omega_i = \frac{1}{N} \sum_{k=1}^{N} \left| \frac{\partial F(x_k, \theta)}{\partial \theta_i} \right| ]

这里乘上了一个与输入有关的加权项,实际实现时通常直接取输出向量对参数的梯度绝对值再求和。

这种做法的潜台词是:网络输出是任务能力的最直接体现,只要输出不变,说明旧任务的行为模式保留住了,至于内部特征怎么重排、特征提取器怎么变化,都无所谓。

2.2 “经验”积累:得到重要性权重的完整过程

在具体实现 MAS 时,重要性权重是通过一次前向、一次反向拿到的。分几步来看:

  • 任务 A 正常训练,训练结束以后固定住模型参数。
  • 随机抽取一批当前任务的输入样本(可以来自任务 A 的训练集,如果没有训练集,用无标签数据也行,这是 MAS 很友好的地方)。
  • 把这些输入依次传入网络,得到输出 (F(x))。大多数分类网络会输出一个 logits 向量,我们取这个向量对网络参数的梯度。
  • 对每个参数,将梯度张量的绝对值累加起来,就得到这个参数在任务 A 上的重要性权重。

注意这里没说用任务 A 的标签。MAS 计算重要性时不需要标签,只要有输入数据就行。这一点在无标签数据、自监督场景下特别好用,我是实际用过的,总觉得在标签稀少的业务场景里,MAS 这种不依赖标签的敏感性估计方式很有战斗力。

为什么取“输出的范数对参数的梯度”通常比“损失对参数的梯度”更稳。因为损失函数往往带有特定任务的信息,比如交叉熵会让梯度集中在最后一层 softmax 的输出变化上,而中间层参数梯度被任务目标“淹没”了。MAS 直接对网络原始输出求梯度,能更均衡地反映所有参数对表达空间的贡献。这也是 MAS 在不少迁移学习任务上比 EWC 表现更好的原因之一。

2.3 和 EWC、SI 放一起对比,MAS 的差异在哪

  • EWC:需要保存旧任务的 Fisher 信息,本质是梯度平方的期望,计算时用训练数据的标签,而且需要走一遍完整数据来累计。Fisher 信息矩阵是对角的近似,EWC 解释性更强,但计算量更大,对任务边界依赖强。
  • SI(Synaptic Intelligence):在训练过程中记录每个参数的历史路径和梯度贡献,在线累积重要性。SI 不单独做一次“重要性计算”,更偏在线模式,MAS 则可以非常干净地在训练后一次性完成。
  • MAS:用输出对参数的梯度模长来衡量重要性,不需要标签,可以一次性算完,也可以在线累积。三个方法放在一起,MAS 最简单、最不依赖任务边界。

我在实际项目中经常把 MAS 当作速度最快的 baseline,主要是因为它的计算逻辑太简单了,只需要一次反向传播,重要性权重算完以后直接存成一个字典,后面训练随时取用。相比 EWC 每次都要扫一遍旧任务数据算 Fisher,MAS 的工程成本低了一个量级。

3. 代码实战:给任意 PyTorch 模型接上 MAS

3.1 网络准备与任务划分

假设你现在要在一个分类任务序列上做连续学习,比如先学 CIFAR-10 的前 5 类,再学 CIFAR-10 的后 5 类,然后是 CIFAR-100 的子集。没有特殊要求的网络结构,简单 ConvNet 或者 ResNet 都能跑。

下面是一段“把网络参数按名字收集好”的代码,这里面的named_parameters()是关键,后续算重要性时要按参数名对应,而不是用parameters()的索引,否则一旦模型结构有变化,名字就乱了。

import torch import torch.nn as nn def collect_params(model): params_dict = {} for name, param in model.named_parameters(): if param.requires_grad: params_dict[name] = param return params_dict

任务序列用最简单的方式实现,每个任务对应一个分类头。如果你的任务共享同一个num_classes,那其实不用改网络结构,直接按不同数据分布来分任务就行。

3.2 核心:重要性权重计算与在线更新

训练完任务 A 后,跑这样一段代码得到omega_dict,也就是每个参数的重要性权重:

def compute_mas_importance(model, data_loader, device='cuda'): model.train() omega_dict = {} grad_dict = {} for name, param in model.named_parameters(): if param.requires_grad: omega_dict[name] = torch.zeros_like(param) grad_dict[name] = 0.0 for batch in data_loader: if isinstance(batch, (list, tuple)): inputs = batch[0] else: inputs = batch inputs = inputs.to(device) outputs = model(inputs) # 取输出向量,计算其范数 if isinstance(outputs, (list, tuple)): outputs = outputs[0] loss = torch.norm(outputs, p=2) ** 2 model.zero_grad() loss.backward() for name, param in model.named_parameters(): if param.grad is not None: # 用梯度的一阶矩来近似,注意这里梯度可能是均值后的, # 我们直接用 abs 累加,效果最好 omega_dict[name] += param.grad.abs().detach() * inputs.size(0) grad_dict[name] += inputs.size(0) # 归一化 for name in omega_dict: if grad_dict[name] > 0: omega_dict[name] = omega_dict[name] / grad_dict[name] return omega_dict

这段代码里有几个关键选择,我单独说明一下。

  • torch.norm(outputs, p=2) ** 2:这是对输出向量做 L2 范数平方。论文里用的是 L2 范数,实际实现时平方与否会改变重要性数值的尺度。我自己的经验是取平方后梯度量级更平稳,尤其是输出向量维度很高的时候,直接取范数会让所有参数的梯度都偏小。

  • outputs如果是 logits 向量,那么它是整个 batch 所有样本的输出拼在一起,形状是[batch_size, num_classes]。对outputs整体做范数意味着所有样本的梯度都混在一起,实际上隐式地把 batch 内样本的贡献平均了。这种简化在 batch size 稳定时问题不大。

  • 归一化时用的是inputs.size(0)去做加权平均,相当于每次梯度除以该 batch 的样本数。严格来说梯度的均值已经做了这一步,这里再做一次会使得重要性量级跟 batch 大小无关,减小了超参调优的难度。

计算完之后可以立刻把重要性权重存成.pth文件,下次训练新任务时直接加载。

3.3 训练新任务时如何把 MAS 惩罚加进损失

训练任务 B 时,损失函数要改写成:

[ L = L_{CE} + \lambda \sum_i \Omega_i (\theta_i - \theta_i^A)^2 ]

其中 (\theta_i^A) 是任务 A 训练完后的参数值。实现时不能直接改模型结构,而是在 backward 之后手动叠加梯度,或者更推荐直接在损失张量上做加法。下面这个代码片段是我用着最顺的写法:

def mas_loss(model, omega_dict, old_params_dict, lambda_reg=100.0): reg_loss = 0.0 for name, param in model.named_parameters(): if not param.requires_grad: continue if name not in omega_dict or name not in old_params_dict: continue # (theta - theta_A).pow(2) 与 omega 逐元素相乘,再求和 reg_loss += (omega_dict[name] * (param - old_params_dict[name]).pow(2)).sum() return lambda_reg * reg_loss

然后在你的训练循环里这么用:

for batch_idx, (inputs, targets) in enumerate(train_loader_b): inputs, targets = inputs.to(device), targets.to(device) outputs = model(inputs) ce_loss = criterion(outputs, targets) reg_loss = mas_loss(model, omega_dict, old_params_dict, lambda_reg) loss = ce_loss + reg_loss optimizer.zero_grad() loss.backward() optimizer.step()

有个细节值得注意:old_params_dict必须在任务 A 训练完以后立刻深拷贝一份,而且不能用param.clone()然后随手丢,要存成 CPU 上的 tensor,否则你后来把模型搬到 GPU 时会遇到设备不一致的问题。最好这样做:

old_params_dict = {} for name, param in model.named_parameters(): old_params_dict[name] = param.detach().cpu().clone()

3.4 超参数 λ 怎么选

MAS 的实践效果对lambda_reg非常敏感。如果这个值太小,相当于没加保护,旧任务照样忘光;如果太大,任务 B 根本学不进去,因为所有参数都在原地锁死。最稳妥的办法是先用一个小验证集做任务 B 的快速测试,分别试[10, 50, 100, 300, 1000]这几个量级。

我个人的经验是,lambda_reg的范围跟你的任务 B 数据量有强关系。如果任务 B 每类样本很少,比如只有几十张,那么主导损失很容易被 MAS 正则项压住,导致学不到新东西,这时反而要把lambda_reg调小。反过来,任务 B 数据量大,自己就能学到足够强的特征,lambda_reg可以稍微大一些,防止旧任务被冲太狠。

初始值可以定 100,然后观察两个指标:任务 B 的验证集准确率不能比不用 MAS 时低太多,任务 A 的验证集准确率不能掉得太快。这两个指标是跷跷板,不可能同时完美,你要按照业务需求把平衡点找出来。

4. 项目实操中的踩坑记录与问题排查

4.1 算重要性权重时到底取哪个输出

很多第一次实现 MAS 的人会直接把model(inputs)当作outputs,如果模型结构简单,这没问题。但如果你用了带辅助输出的网络,比如带中间监督的分类网络,outputs往往是一个 tuple,这时必须取主分类头对应的输出。我当时在某跨平台系统里用过一个带多个辅助头的网络,忘了取主输出,重要性权重算出来完全不准,任务 A 的准确率在训练任务 B 后直接崩到接近随机,排查了很久才发现是这个细节。

解决方案也很简单,在compute_mas_importance里加一段判断,把 tuple 拆开:

if isinstance(outputs, (list, tuple)): outputs = outputs[0]

4.2 BN 层对梯度估计的干扰

BatchNorm 层在训练模式和评估模式下行为差异非常大。训练模式时,BN 会使用当前 batch 的均值和方差做归一化,并且会更新 running 统计量。如果你在计算 MAS 重要性权重时把模型保持在train()状态,那么每过一个 batch,BN 的 running mean 和 running variance 都在变化,这会影响后续任务的网络状态。

我的做法是计算重要性权重之前,把模型先切到eval()模式,这样 BN 使用固定的 running 统计量,输出对参数的梯度更能反映当前模型的真实状态。等到真正训练任务 B 时再切回train()。如果你用的是 LayerNorm 或者没有 BN 的网络,这一步影响不大,但保持统一更稳。

还有一个更隐蔽的问题:BN 层自身的weight和bias也是参数,它们在 MAS 里也会被纳入named_parameters()。这本身没问题,但 BN 参数的梯度经常很奇怪,因为 BN 的 affine 变换很短小,重要性权重通常很大。如果你发现lambda_reg加在哪里都不对,试试把 BN 层的参数从重要性计算里排除掉,只保留卷积层和全连接层。我在几个实验里这样做之后,旧任务准确率稳定了不少。

4.3 任务边界不明确时怎么在线更新

标准的 MAS 是在每个任务结束后一次性计算重要性权重,但有些项目场景下你根本不知道数据什么时候换了一批。比如一个推荐系统,用户口味是逐渐漂移的,没有明确的“任务 B 开始”这样的信号。

这时可以用滑动窗口式的 MAS 更新。设定一个固定间隔,每隔 K 个 batch 就当前模型计算一次新的重要性权重,然后和旧的重要性权重做衰减合并:

decay = 0.9 for name in omega_dict: omega_dict[name] = decay * omega_dict[name] + (1 - decay) * new_omega[name]

这里的decay是人为设定的,它控制着历史重要性和当前重要性的平衡。衰减越小,模型越能适应新数据;衰减越大,模型越保守。通常我会选0.9~0.99之间,具体数值可以用一个小规模离线模拟来确定。

不过要提醒一句,在线更新模式下 MAS 的稳定性比离线一次性计算要差一些,因为窗口式的重要性权重更新可能带来震荡。如果你发现任务 A 的指标出现周期性波动,优先降低decay值。

4.4 常见问题速查表

这个问题集是从各种连续学习任务里整理出来的,碰到类似现象可以按表排查:

现象可能原因解决办法
旧任务准确率骤降正则项权重 lambda 太小调大 lambda,对比多个候选值
新任务学不进去lambda 太大压住了梯度调小 lambda,或增大新任务学习率
重要性权重全是 NaN输出向量出现 inf 或 NaN检查输入归一化,给输出加 clamp
加了 BN 后效果差异大BN 层 running stats 在重要性计算时变化计算重要性时切 eval 模式
旧任务稳定但新任务很差重要性权重没排除 BN 层将bn.weight、bn.bias排除 in compute
权重投影不对齐参数名变了固定参数收集顺序,统一用名字索引
多个任务累积时保护不足只用了最后一个任务的重要性使用衰减合并,保留历史累计权重

4.5 实际项目的“避坑”补充说明

有一类问题是结构性的,很难靠调参解决。当任务 B 的数据分布和任务 A 差得特别远时,比如一个学猫狗分类,另一个学黑白街景分割,参数共享本身就很牵强。MAS 只能做到“尽量别破坏旧参数”,但不可能给模型凭空增加容量来容纳新知识。这个认知很重要,不要指望一个固定容量的小网络通过 MAS 就能无限学下去。真到了那种场景,要么加参数隔离,要么用回放方法,要么就把模型容量做大。

另外值得一提的是,MAS 的方法很依赖网络输出的连续变化,如果网络是极端稀疏的,比如某些剪枝后的网络大量参数为 0,梯度在稀疏处不稳定,MAS 重要性权重的计算也会不可靠。如果要在剪枝网络上做连续学习,我建议先恢复稠密再算重要性。

5. 用 MAS 搭建一个完整的连续学习实验

5.1 实验配置和评估流程

实话说,只把 MAS 嵌入训练循环还不够,你得有一整套实验流程,这样才能确认 MAS 确实在起作用。下面是我在模拟项目 X 里用过的评估脚本结构:

# 流程示意 task_list = [...] model = YourModel() old_params_dict = None omega_dict = None for t, (task_name, train_loader, val_loader) in enumerate(task_list): # 训练当前任务 train_one_task(model, train_loader) # 如果是第一个任务,初始化重要性权重 if t == 0: omega_dict = compute_mas_importance(model, train_loader) old_params_dict = deep_copy_params(model) else: # 合并当前重要性到历史重要性 current_omega = compute_mas_importance(model, train_loader) omega_dict = merge_omega(omega_dict, current_omega, decay=0.99) old_params_dict = deep_copy_params(model) # 每学完一个任务,评估所有历史任务 eval_all_tasks(model, task_list[: t+1])

这里的关键点是每训练完一个任务,先计算当前任务的重要性,再与历史重要性合并,而不是只保留最后一次。

5.2 我在实践中的经验:评估口径比你想象中更重要

连续学习的公开评估口径有很多种,常见的是“对所有已学任务的平均准确率”。但如果你只汇报平均准确率,会掩盖一个重要事实:模型可能为了保住旧任务,把新任务完全牺牲了。均衡的评估方式是同时记录旧任务遗忘率(Backward Transfer,BWT)和新任务学习能力(Forward Transfer,FWT)。

有些论文对 BWT 的定义是“训练完所有任务后再测一遍旧任务”,这个指标能反映多次推进后旧知识保持得如何。我自己做项目时只看两个数字:

  • 所有任务训练完成后的平均准确率
  • 每个任务训练完新任务后的即时旧任务准确率

后者更贴近真实业务,因为生产中没人会等你把所有任务都训完再上线。

5.3 和回放方法结合使用的更优解

纯正则化方法在数据分布漂移比较温和时很好用,但在剧烈漂移场景,表现会明显弱于回放方法。我试过把 MAS 和一个小型样本回放池结合,效果出乎意料地好。做法是在每个任务训练完后,随机保留该任务 5% 的样本放到缓冲区里,训练新任务时每批次混合 10% 的回放样本,同时再加上 MAS 正则项。

这种情况下 MAS 负责“参数级别”的保护,回放样本负责“数据级别”的纠偏,两个机制互补。比单独使用其中任何一个都稳定,尤其适合任务类别跨度大的场景。代价是你要额外管理一个回放缓冲区,但只存 5% 样本的话,存储开销很小。

写在最后的一点个人体会

用了一阵子 MAS 以后,我最明显的感受是:这个方法的门槛低到让人觉得不像学术工作,但它给你留出来的调参空间又非常宽。lambda_reg一改,整套系统的性格就变了,从“极度保守”到“极度激进”都能呈现。这种灵活性既是优点也是坑,你必须为自己的场景定好一套评估脚本,否则你根本不知道自己调到的是“更稳了”还是“更蠢了”。

我自己平时做连续学习相关项目,MAS 始终是我默认的第一个 baseline。不是因为它在所有 benchmark 上都是第一,而是因为它足够简单、足够透明,出了任何问题都能很快定位。想在工程里快速验证“参数保护”思路是否可行时,写一个 MAS 出来往往只需要半小时,这比上任何花哨框架都来得实际。

如果你也是刚接触连续学习,我建议先把 MAS 亲手复现一遍,然后拿它和 EWC、SI 做对比,感受一下“输出敏感度”和“损失敏感度”这两种视角的差别。等你把这几个经典方法都玩明白,再看模型扩容、动态结构这些更高阶的手段时,思路会清楚很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进