神经网络的权重是一份全局共享的可变状态(shared mutable state)。学新任务 B 时做的是原地更新(in-place update)——直接覆写这份状态。没有版本隔离、没有事务,任务 A 依赖的那些权重被 B 的梯度悄悄改写,A 就灾难性地全忘了。这不是"记忆容量满了",而是并发写冲突(write-write conflict)没有隔离机制。
灾难性遗忘(catastrophic forgetting)指:网络顺序学多个任务时,学后面的会急剧抹掉前面学到的能力。McCloskey & Cohen 1989 年就发现了它。根因在于:任务 A 的知识分布式地编码在所有权重的一组特定取值里,而 SGD 学任务 B 时只优化 B 的损失——它不知道也不在乎哪些权重是 A 的"承重墙",只要能降低 B 的 loss 就往哪个方向推。
这引出持续学习的核心矛盾——稳定性-可塑性困境(stability-plasticity dilemma):权重太稳定(不许改)→ 学不进新任务(没有可塑性);权重太可塑(随便改)→ 一学新的就忘旧的。这跟你熟悉的系统权衡同构:一个只读副本永远一致但没法写入;一个人人可写的缓存吞吐高但一致性全无。持续学习就是要在这条谱系上找折中:
下面 3 个概念,就是这条谱系上三种主流对策:约束权重(正则)、复习旧数据(重放)、给每任务独立参数(隔离)。
# 用最小实验"看见"灾难性遗忘:先学任务A,再学任务B,回头测A import torch, torch.nn as nn net = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 2)) opt = torch.optim.Adam(net.parameters(), lr=1e-3) loss_fn = nn.CrossEntropyLoss() def make_task(seed): # 两个不同的二分类任务 g = torch.Generator().manual_seed(seed) X = torch.randn(400, 20, generator=g) y = (X @ torch.randn(20, generator=g) > 0).long() return X, y def train(X, y): for _ in range(200): opt.zero_grad(); loss_fn(net(X), y).backward(); opt.step() def acc(X, y): return (net(X).argmax(1) == y).float().mean().item() Xa, ya = make_task(1); Xb, yb = make_task(2) train(Xa, ya); print("学完A,A准确率:", acc(Xa, ya)) # ~0.95 train(Xb, yb); print("再学B,A准确率:", acc(Xa, ya)) # 掉到 ~0.55 → 遗忘
EWC 相当于给每个权重装一把软写锁(soft write-lock),锁的力度按"这个权重对旧任务有多重要"来定。像数据库里给热点行、承重索引加高优先级保护:不是禁止改,而是"你想改这些关键权重?可以,但代价很大"。改无关权重几乎免费,改承重权重要付高额惩罚——学习被弹性地拉回。
重放需要存旧数据,隔离要额外参数。EWC(Kirkpatrick 等 2017)想做到零旧数据、零新增参数,纯靠约束权重防遗忘。关键问题是:怎么知道哪些权重是任务 A 的"承重墙"?答案是 Fisher 信息(Fisher information)——衡量"轻微扰动权重 i,任务 A 的输出会变多剧烈"。变化剧烈 = 这个权重承重 = 锁紧;变化几乎为零 = 无关紧要 = 放开。
学任务 B 时,EWC 在 B 的损失上加一个二次惩罚项:
L(θ) = L_B(θ) + Σ_i (λ/2) · F_i · (θ_i − θ*_A,i)²
逐符号拆解:L_B(θ) 是新任务 B 的损失(要降低它);θ*_A,i 是学完 A 后权重 i 的取值("旧记忆锚点");F_i 是权重 i 的 Fisher 信息(重要度);λ 调节"多在乎不忘 A"。直觉:每个权重上挂一根弹簧,把它往"任务 A 时的老位置"θ*_A,i 拉;弹簧劲度就是 F_i·λ——承重权重的弹簧很硬(几乎拉不动),无关权重弹簧很软(自由让 B 用)。于是 B 只能在"不惊动 A 的方向"上优化。这正是稳定性-可塑性谱系上的一个显式旋钮:λ 越大越稳定、越小越可塑。
# EWC 核心:学完A后估计 Fisher,学B时加二次惩罚 def estimate_fisher(net, X, y, loss_fn): fisher = {n: torch.zeros_like(p) for n, p in net.named_parameters()} for i in range(len(X)): net.zero_grad() loss_fn(net(X[i:i+1]), y[i:i+1]).backward() for n, p in net.named_parameters(): fisher[n] += p.grad.pow(2) / len(X) # 梯度平方 ≈ Fisher 对角 return fisher # 学完A后:star = 权重快照(θ*_A),fisher = 重要度 star = {n: p.clone().detach() for n, p in net.named_parameters()} fisher = estimate_fisher(net, Xa, ya, loss_fn) def ewc_penalty(net, lam=1000.): # 学B时加进总loss return lam * sum((fisher[n] * (p - star[n]).pow(2)).sum() for n, p in net.named_parameters()) # total = loss_fn(net(Xb), yb) + ewc_penalty(net) → A 的准确率被保住
重放就像数据库的 WAL(write-ahead log)重演:为了让新旧一致,把旧事务混进来一起重跑。经验重放存一小份旧样本,训练时新旧混着喂。生成重放(generative replay)更聪明——不存原始旧数据,而是训一个生成器去合成"假的旧数据",相当于用一个压缩快照生成器替代全量备份,兼顾隐私与存储。
遗忘的根源是"学 B 时看不到 A 的数据"。最直接的解法:学 B 时顺便复习 A。经验重放(experience replay)留一个小 记忆缓冲区(memory buffer)存 A 的少量样本,每个 batch 混入若干旧样本,梯度就同时压低新旧任务的 loss,权重不会跑偏。简单粗暴但极其有效——它几乎是持续学习里最强的基线。
但存旧数据有两个硬约束:存储爆炸(任务无限增长)和隐私/合规(医疗、用户数据不能留档)。生成重放(Shin 等 2017,Deep Generative Replay)的解法优雅:训一对协作模型——生成器(generator)学会产出"像旧任务的数据",求解器(solver)是真正干活的分类/预测模型。学新任务时,生成器先吐出一批合成旧样本、并由旧求解器打上标签,再和新数据混合训练。灵感来自海马体在睡眠中"回放"经历以巩固记忆。流程:
# 经验重放:最小、最强的基线。留一个缓冲区,混着训 import random buffer = [] # 存 (x, y) 旧样本 def reservoir_add(buf, sample, cap=200): # 蓄水池采样:等概率保留 if len(buf) < cap: buf.append(sample) else: j = random.randint(0, len(buf)) # 随机替换,保持代表性 if j < cap: buf[j] = sample def train_with_replay(net, opt, loss_fn, Xb, yb, k=32): for i in range(200): xb, yb_ = Xb, yb # 当前任务 batch if buffer: # 混入旧样本一起算 loss old = random.sample(buffer, min(k, len(buffer))) ox = torch.stack([o[0] for o in old]) oy = torch.tensor([o[1] for o in old]) xb = torch.cat([xb, ox]); yb_ = torch.cat([yb_, oy]) opt.zero_grad(); loss_fn(net(xb), yb_).backward(); opt.step() # 学A时把样本 reservoir_add 进 buffer;学B时自动复习A → 遗忘大幅缓解
参数隔离 = 分区/分片(partitioning / sharding)思路:既然共享可变状态会写冲突,那就给每个任务划一块专属参数,学完就把它设成只读(read-only)。别的任务用别的分片,物理上没有写冲突,旧任务被结构性地保护——就像微服务各自独立部署,互不覆盖。
正则和重放都是"软"保护——旧知识仍可能被慢慢侵蚀。参数隔离给出"硬"保证:凡是分配给旧任务的权重,一律冻结不再更新,遗忘从根上被杜绝。两个代表思路:
① PackNet(Mallya & Lazebnik 2018)——固定容量里"打包"多任务。 大网络高度冗余,很多权重可裁剪而几乎不掉精度。做法:学完任务 A → 剪枝掉一批不重要权重 → 剩下的"A 权重"冻结锁死 → 用刚释放出的空闲权重去学任务 B → 再剪枝、再冻结……像在一块固定磁盘上不断"划新分区"给新任务,直到空间用尽。存储零增长,但任务数受容量硬上限约束。
② 渐进网络(Progressive Networks,Rusu 等 2016)——每任务加一列新网络。 每来一个任务就新增一列(column)参数,旧列全部冻结;新列通过横向连接(lateral connections)读取旧列学到的特征(能正向迁移),但反过来改不动旧列。彻底零遗忘、且能复用旧知识——代价是参数随任务数线性膨胀,像每来一个任务就起一个新副本、只允许读旧副本。
# PackNet 风格:学完A后剪枝+冻结,把"关掉的"权重留给B import torch def prune_and_freeze(weight, keep=0.5): # 按幅度保留 top-keep 的权重作为"任务A的锁定分区" flat = weight.abs().flatten() thresh = flat.kthvalue(int(len(flat) * (1 - keep))).values taskA_mask = weight.abs() >= thresh # True = 属于A、冻结 weight.data *= taskA_mask # 剪掉不重要的(置0,留给B) return taskA_mask def masked_grad_hook(mask): # 反向时把"A分区"的梯度清零 → A权重永不被B更新 def hook(grad): return grad * (~mask) return hook W = net[0].weight maskA = prune_and_freeze(W, keep=0.5) # 学完A调用 W.register_hook(masked_grad_hook(maskA)) # 此后学B,A分区被硬保护 # 训练B时:只有 ~50% 空闲权重会更新,A的准确率精确保持不变