模型学完第二件事,就把第一件事忘得一干二净——这个问题在机器学习里有个专门的名字叫灾难性遗忘。我刚接触持续学习(Continual Learning)的那段时间,第一反应是“把旧数据存下来,重新一起训不就行了”,但在真实项目里数据往往不能留存,留存了也会面临隐私、存储和训练效率的问题。后来认真读并复现了Memory Aware Synapses (MAS)这套经典方法,才发现“记住旧任务”其实可以不依赖旧数据回放,而是通过计算网络每个参数对输出的敏感程度,在学新任务时有策略地“锁住”那些关键权重。这篇文章想把我从原理解读到代码落地、再到调参踩坑的完整过程整理下来,适合正在研究持续学习的同学,也适合那些手里已经有一个训练好的模型、接下来要不断接收新任务数据的工程师参考。
1. 灾难性遗忘到底是怎么发生的:从梯度覆盖说起
1.1 梯度下降视角下的“覆盖”
神经网络训练本质上是沿着损失函数的梯度方向不断调整参数。单个任务训练完成时,模型参数收敛到了这个任务的损失曲面上的一个低洼区域。这时候你把新任务的训练数据喂进来,新的梯度会继续推着参数往新任务的低洼区域走。问题在于,旧任务的低洼区域和新任务可能根本不重合,甚至方向相反,于是参数一旦往新任务的方向偏移,旧任务的输出分布就被“挤”走了。
这就是遗忘的根源:不是模型容量不够,也不是网络结构有问题,而是梯度更新在多个任务之间发生了覆盖式冲突。你可以想象一张地形图上挖了两个坑,你先把小球滚进A坑,再想把它滚进B坑,那么球离开A坑回到B坑的过程中,A坑里的“记忆痕迹”自然就被破坏了。
1.2 从“全参数更新”到“部分参数更新”
大多数人第一次面对这个问题,会想到一个笨办法:把学习率调小一点。调小学习率确实能让新任务对参数的改动幅度变小,但那是对所有参数无差别地缩小,旧知识的保护效果非常有限,新任务的学习速度也会显著变慢。真正合理的思路应该是有选择地更新:那些对旧任务至关重要的参数,尽量保持不动;而那些对旧任务无关紧要的参数,则可以大胆地让新任务去改写。
这个思路听起来简单,关键问题变成了:怎么判断一个参数对旧任务“至关重要”?这也是MAS整套方法要回答的核心问题。
1.3 为什么“重放旧数据”不是长久之计
很多人会把持续学习和普通的增量训练混为一谈,认为只要在训练新任务时混入一部分旧数据就能解决问题。这个方法在实验里确实有效,但到了真实场景往往行不通:旧数据可能因为合规要求不能保存,可能因为存储成本被丢弃,甚至可能根本不属于你自己。除此之外,经验回放还有个隐性风险——当旧任务数据量特别大的时候,每次训练都要把旧数据全部或者抽样走一遍,训练时间会越拉越长。
所以这些年持续学习领域的主流方向,是让模型在旧任务训练结束之后,只留下一个“压缩过的记忆痕迹”,比如一组参数重要性向量、一组知识蒸馏的伪标签、或者一组低秩近似。MAS走的就是第一条路线:把旧任务的知识“浓缩”成每个参数的重要性权重,后续任务在更新参数时,按照这份权重表来约束更新幅度。
2. MAS解决遗忘的核心思路:给参数建立重要性账本
2.1 参数重要性是什么意思
想象一台老式收音机,面板上有好几个旋钮。有些旋钮你拧半圈,声音变化就非常剧烈,这些旋钮对“调控声音”这件事是重要的;有些旋钮你拧了两圈,声音几乎没变化,那它就是次要的。神经网络里的参数也是一样:某个参数对网络输出的影响越大,这个参数就越重要。
MAS整套方法的核心,就是用一个可计算的指标来衡量“影响大小”。对每个参数 (\theta_{ij}),我们关心的是:如果这个参数发生一个微小的扰动 (\delta),网络的输出会在多大程度上发生变化。如果输出变化剧烈,说明这个参数相当关键;如果输出几乎不动,说明它可有可无。把这种敏感度用一个数值记录下来,就是所谓的重要性权重,论文里一般用 (\Omega_{ij}) 表示。
2.2 为什么不像损失函数那样算重要性
早一些的持续学习方法,比如弹性权重巩固(EWC),用Fisher信息矩阵来估计参数重要性,它依赖的是损失函数对参数的梯度。MAS和它最大的区别在于:MAS不是看“损失对参数的梯度”,而是看“网络输出对参数的梯度”。
这两者什么差别?损失函数梯度和任务的标签、损失函数的具体形式强绑定。如果你换一个回归任务、换一个自监督任务,或者损失函数里加了新的正则项,Fisher信息表达的含义就会变。但网络输出的敏感性不一样——无论你有标签还是没标签,模型前向传播都会得到一个输出,这个输出对参数的梯度只反映“参数改变会如何影响模型的预测行为”,语义更稳定。这也是为什么MAS非常适合那些没有标签、或者标签很难获取的场景,比如在无监督预训练模型上继续做下游任务时,依然可以估算旧任务的参数重要性。
2.3 给一个直观的动态过程
假设一个模型已经完成了任务A的训练,参数处于 (\theta^)。现在要学任务B,我们希望最终参数 (\theta) 在任务B上表现好,同时又尽量别偏离 (\theta^) 中那些对任务A重要的维度。
于是训练任务B时,损失函数长这样:
[ \mathcal{L} = \mathcal{L}{B}(\theta) + \lambda \sum{ij} \Omega_{ij} (\theta_{ij} - \theta^*_{ij})^2 ]
前一项是任务B的损失,后一项就是MAS施加的“保护性惩罚”。如果某个参数 (\theta_{ij}) 对应的 (\Omega_{ij}) 值很大,说明它对任务A很重要,那么一旦它偏离 (\theta^*{ij}),惩罚就会很大;反过来,如果 (\Omega{ij}) 很小,模型就可以自由调整这个参数来适配任务B,几乎不受惩罚。
这个形式就是大多数正则化持续学习方法的通用框架。EWC、SI、PackNet等方法的差异,主要在于 (\Omega_{ij}) 怎么算、以及保护的是哪些参数。MAS的巧妙之处,全在 (\Omega_{ij}) 的计算方式上。
3. 核心公式拆解:为什么用输出对参数的梯度衡量重要性
3.1 从一阶近似推导敏感度
把网络看成一个函数 (F(x; \theta)),输入是 (x),输出是一个向量。如果我们稍微改变某个参数 (\theta_{ij}),输出变化的一阶近似可以写成:
[ F(x; \theta + \delta e_{ij}) \approx F(x; \theta) + \delta \cdot \frac{\partial F(x; \theta)}{\partial \theta_{ij}} ]
那么,输出变化的幅度大概就是:
[ \left| \delta \cdot \frac{\partial F(x; \theta)}{\partial \theta_{ij}} \right| ]
这里的 (\left|\cdot\right|) 表示对输出向量取范数。从这个式子就能看出来,衡量参数影响大小的核心量,就是 (\left|\frac{\partial F}{\partial \theta_{ij}}\right|) ——也就是输出对参数的梯度向量的范数。
实际计算中,我们不会只用某一个样本,而是对一批样本求平均。于是MAS中重要性权重的基本定义就是:
[ \Omega_{ij} = \frac{1}{N} \sum_{k=1}^{N} \left| \frac{\partial F(x_k; \theta)}{\partial \theta_{ij}} \right|^2 ]
注意这里取了平方。原因很简单:我们只关心参数带来的影响幅度,不关心影响的方向,梯度平方之后还能保证所有项的贡献非负,后续作为正则惩罚的权重也更好处理。
3.2 为什么要对输出取范数平方再反向传播
在具体实现里,你会看到它和我们平时写反向传播有一点区别。设网络对单个样本的输出是 (F(x)),它是一个向量,比如分类任务里的logits。要让代码能够反向传播算出对每个参数的梯度,你需要先把这个输出向量变成一个标量。
常见做法是取整个输出向量的L2范数平方:
[ s(x) = |F(x)|2^2 = \sum{c} F_c(x)^2 ]
然后对这个标量 (s(x)) 反向传播,得到的就是 (\frac{\partial s}{\partial \theta_{ij}})。为什么要用范数平方,而不是直接用范数或者直接用某一维的logits?主要有三个考虑:
- 直接把整个输出向量压缩成一个标量的过程中,所有输出维度都参与了计算,任何一类预测的变化都会被捕捉到,而不是只看模型的某一个输出头。
- L2范数平方是可微且光滑的,避免了开根号带来的数值不稳定。
- 平方会放大“大幅输出”的影响,让那些对模型判定结果贡献显著的维度获得更高的重要性信号,这符合我们对“影响大”的直觉。
3.3 在线估计与离线估计的区别
MAS有两种使用方式。一种是离线估计:任务A训练完成后,重新把任务A的训练数据整个过一遍,前向传播后计算梯度平方,得到一份重要性表。这个方式准确、稳定,但是需要你手里还有旧任务的数据。
另一种是在线估计:任务A训练过程中,每看到一个batch的数据,就顺便算一下当前模型对这个batch的敏感性,然后用一个滑动平均的公式把新信息累积到旧的重要性表里:
[ \Omega = \alpha \cdot \Omega_{old} + (1-\alpha) \cdot \Omega_{new} ]
在线方式的好处是不需要额外遍历旧数据——训练过程中重要性账本一直在持续更新,旧数据用完即弃也可以。实际项目中我比较推荐在线方式,因为它的计算开销几乎可以忽略,而且效果和离线方式差距不大。需要注意的是,滑动系数 (\alpha) 别取得太极端,一般0.85到0.95之间比较合适,太小的 (\alpha) 会让重要性权重很容易被最近的数据带偏,太大则更新太慢。
4. 从公式到可运行代码:一个PyTorch实现骨架
4.1 代码结构设计
我实验时的做法,是把MAS拆成三个模块:
- ImportanceTracker:负责收集并累积每个参数的敏感性;
- sensitivity_forward:给定一个batch的数据,计算模型输出并反向传播,提取每个参数的梯度平方;
- mas_penalty:在正常训练任务B时,把Omega和参数偏离量代入惩罚项。
先看一个能跑起来的最小实现。这里以一个简单的多层感知机分类网络为例,你完全可以把它换成自己的任意网络结构。
import torch import torch.nn as nn from collections import OrderedDict class ImportanceTracker: def __init__(self, model, alpha=0.9): self.model = model self.alpha = alpha # 为每个可训练参数准备一块Omega存储 self.omega = OrderedDict({ name: torch.zeros_like(p, device=p.device) for name, p in model.named_parameters() if p.requires_grad }) self._theta_star = OrderedDict({ name: p.detach().clone() for name, p in model.named_parameters() if p.requires_grad }) def snap_theta_star(self): """记录当前参数作为旧任务的知识锚点""" for name, p in self.model.named_parameters(): if p.requires_grad: self._theta_star[name] = p.detach().clone() def update_omega(self, dataloader, device): """ 估算当前模型在给定数据上的敏感性,并累积进Omega。 这里假设dataloader的数据是(config, no label可用的)。 """ self.model.eval() # 先把所有参数的梯度清零 for p in self.model.parameters(): if p.requires_grad: p.grad = None # 对多个batch做前向传播,backward获取对参数的梯度 for x, _ in dataloader: x = x.to(device) # 关键:输出向量的范数平方 out = self.model(x) sens = (out.norm(dim=1) ** 2).mean() self.model.zero_grad() sens.backward() # 把每个参数的梯度平方累积到omega for name, p in self.model.named_parameters(): if p.grad is not None and name in self.omega: new_omega = p.grad.detach().pow(2) self.omega[name].mul_(self.alpha).add_(new_omega, alpha=1 - self.alpha) self.model.train() def mas_penalty(self, lam=1.0): """返回MAS正则项损失""" loss = 0.0 for name, p in self.model.named_parameters(): if p.requires_grad and name in self.omega: diff = p - self._theta_star[name] loss += (self.omega[name] * diff.pow(2)).sum() return lam * loss4.2 训练循环:一个任务一个任务地推进
下面是一个简单的持续学习训练循环。假设我们有两个任务,第一个任务正常训练并更新Omega,第二个任务训练时加入MAS惩罚。
from torch.utils.data import DataLoader, TensorDataset def train_one_task(model, tracker, dataloader, optimizer, criterion, lam=0.0, num_epochs=10, device="cuda"): model.train() for epoch in range(num_epochs): for x, y in dataloader: x, y = x.to(device), y.to(device) optimizer.zero_grad() out = model(x) loss = criterion(out, y) if lam > 0: loss = loss + lam * tracker.mas_penalty() loss.backward() optimizer.step() # 任务1训练 model = SimpleNet().to(device) optimizer = torch.optim.SGD(model.parameters(), lr=0.01) tracker = ImportanceTracker(model, alpha=0.9) train_one_task(model, tracker, task1_loader, optimizer, criterion, lam=0.0) tracker.snap_theta_star() tracker.update_omega(task1_unlabeled_loader, device) # 用旧任务的数据估计重要性 # 任务2训练,加入MAS optimizer2 = torch.optim.SGD(model.parameters(), lr=0.01) train_one_task(model, tracker, task2_loader, optimizer2, criterion, lam=1.0)这段代码有几个容易忽略的细节:
第一,update_omega的时候模型务必切到eval模式。如果模型里有Dropout和BatchNorm,训练模式下的前向传播带有随机性和统计偏置,敏感性估计会很不稳定。实测中我发现用eval模式算出的Omega通常更平滑,后续正则效果也更好。
第二,敏感性前向的batch size不能太小。重要性估计本质上是求期望,batch太小方差会特别大。我一般至少用64或者128的batch来估计,如果数据不均匀,还要反复跑几个batch再平均。
第三,mas_penalty用的锚点参数必须是旧任务训练结束时的快照。也就是snap_theta_star()要放在任务1结束之后。如果你在训练过程中点了_theta_star,那等于模型一边学新任务一边改锚点,惩罚项就完全失去意义了。
4.3 如果要处理三个以上任务
多任务情况下,一个重要问题是:重要性表是累积的,不是每个任务单独一套。也就是说,任务1训练后有了 (\Omega^{(1)});任务2训练开始时,(\Omega) 要继续累积任务2的敏感性,变成:
[ \Omega = \alpha \Omega^{(1)} + (1-\alpha) \Omega^{(2)} ]
训练任务3时,用这份累积后的Omega来设置惩罚。同时,锚点 (\theta^*) 也要在任务2结束时再快照一次,因为此时模型已经融合了任务1和任务2的知识。你会发现这个累积过程和人在学习中“温故知新”很像——重要性账本不会因为学了一个新任务就把旧账全部抹掉,而是旧账慢慢衰减、新账逐渐补充上去。
这里有个实践经验:如果任务数量特别多(比如10个以上),(\alpha) 可以适当提高一点,比如0.95到0.98,防止后期任务的敏感性信号把早期任务的权重信号冲刷得太干净。
5. 实测经验与调参:MAS真正让人头疼的地方
5.1 在常见基准上的表现
我按照标准的持续学习设定,在Split CIFAR-100这类基准上跑过MAS和几个经典方法的对比。所谓Split CIFAR-100,就是把100类数据切成10个包含10类的任务,模型依次学习这10个任务,最后评测所有任务的分类准确率。在这种设定下,MAS的整体平均准确率通常能落在55%到60%的区间,遗忘程度比不带任何正则的微调要低很多,也比EWC在不少结构上更稳。
当然,具体数值受网络结构、优化器、任务顺序的影响很大,这里我给出的更多是一个“体感区间”。更重要的观察是:MAS在新任务学习速度上会比EWC稍微快一点,因为它不需要计算Fisher信息矩阵的对角近似,训练开销更小。而且数据经过无标签前向就能持续更新重要性,这让它在流式数据场景中很顺手。
5.2 最容易被忽略的调参项
调参阶段我踩过不少坑,挑几个最典型的说一下。
第一个坑:Omega的数值尺度问题。不同层的梯度平方数值差异会很大,深的层梯度往往比浅层小几个数量级。如果你直接把原始平方值当作正则权重,正则项实际上只保护了那些梯度比较大的层,浅层的参数几乎“裸奔”。我常用的处理方式是,对每个参数单独做一次归一化,或者把Omega统一除以整个模型Omega的最大值。
第二个坑:lambda的选择。(\lambda) 太小,保护作用不明显;(\lambda) 太大,新任务学不进去。我一般会先观察每轮参数更新的平均幅度,然后反推一个让正则项和分类损失处在同一个数量级的 (\lambda)。比如任务2初期跑一步,分类损失大概是0.5,MAS惩罚项可能是0.05,那就把 (\lambda) 往10附近调一调。不要直接照搬网上复现的默认值,因为网络结构和数据量都会影响这个平衡点。
第三个坑:输出头的选择。我在代码里用到的是out.norm(dim=1),这里的out是模型最后一层的logits,不是softmax之后的概率。如果你对logits做了softmax,再对softmax的输出计算梯度的范数,会发现很多样本的梯度都被压缩得特别小,尤其是那些置信度已经很高的样本,底层参数的敏感性信号会很弱。保持logits原始数值,敏感性估计会准确很多。
5.3 什么时候MAS会失效
MAS有一个很现实的天花板:当模型总容量本身就不足以容纳两个任务的知识时,再怎么保护参数也没用。这就像一张纸正面已经写满了一页内容,你还要在背面继续写,正面内容总会被背面笔迹浸透透过来。实验中你会发现,如果网络非常小,MAS和没有任何正则的微调其实差不了太多,因为所有参数都是“重要参数”,没有多余空间供新任务改写。
另一个失效场景是新任务和旧任务的特征空间差异极大。比如模型本来在低分辨率灰度图上训练,突然要去学高分辨率彩色图像,这时旧任务的重要性和新任务的更新需求会发生剧烈冲突,单纯保护参数很难挽回。这类情况更适合用特征回放、参数扩充或者模型动态扩展的方案。明确MAS的适用边界,比盲目追求“通用神器”要重要得多。
5.4 我的建议配置
下面是我在多个数据集上跑下来比较稳定的一组配置,供参考。它不一定是绝对最优的,但作为起点很省时间。
| 调参项 | 推荐配置 | 说明 |
|---|---|---|
| 优化器 | SGD(momentum=0.9) | Adam配合MAS容易导致新旧任务优化方向震荡,SGD更稳 |
| 学习率 | 0.01(任务数多时0.005) | 新任务学习率不宜太高,保护项才能起作用 |
| (\alpha) | 0.9 | 任务超过10个可以调到0.95 |
| (\lambda) | 1.0(再根据损失量级调整) | 先看正则项和分类损失数量级是否接近 |
| Omega估计batch size | 128 | 太小会引入噪声 |
| 估计模式 | eval模式 | 关掉Dropout和BN的batch内统计 |
还有一点很关键:优化器换到新任务时,记得把动量缓冲清零。我一开始偷懒,连续两个任务用同一个优化器不重置状态,结果第二任务训练时旧任务的方向惯性非常大,MAS的惩罚几乎被动量冲掉了。清零之后效果立竿见影。
最后再分享一个我个人觉得特别实用的技巧
如果你要从零开始理解MAS,我的建议是先别急着跑大基准。找一个二分类的小任务,训练完之后把Omega可视化出来,看看每一层的保护强度分布,你很快就能建立起“哪些参数在关键路径上”的直觉。我一开始也是这么做的,那时才真正明白为什么有些人说“持续学习不是算法问题,而是对模型内部结构的理解问题”。以后你再去看EWC、SI、PackNet这些方法,会发现它们都是在一个共同框架下回答同一个问题:如何给旧知识写下一份可靠的“账本”,区别只在于记账方式不同而已。MAS用输出梯度记账,这在无标签时代是一个很优雅的选择,也是我一直很欣赏它的原因。