Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

持续学习与知识更新:学了新的,旧的还在吗

工坊搬家了。

昨天,助手回答“去东街 3 号取货”还完全正确;今天的新地址是西街 8 号,这句话就会让人白跑一趟。但我们也不希望它学会新地址以后,把报价方法、退货规则和以前学过的编程知识一起忘掉。

这件事有两面:该改的要改,该留的要留。 再往前一步,模型还要有能力继续学,而不是每次更新都变得更难适应下一件事。

本页先做一个真实训练实验:让同一个分类器先学数字 0~4,再学 5~9,观察它怎样获得新能力,又怎样丢失旧能力。随后用两个可以手算的实验理解知识编辑和在线适应,再把这些问题接到语言模型研究中。

前置内容是训练一个可靠的神经网络、指令微调、数据与学习效率和长上下文与记忆。资料核查截至 2026 年 10 月 20 日;本页选择六篇研究,不把它们当作覆盖全部方法的排行榜。

1. “有了新信息”,究竟改了哪里

先别急着重新训练。工坊地址变了,可以有几种处理方式:

更新发生在哪里一个具体做法仍需解决的问题
当前上下文在本次对话里说明新地址下次请求是否还能拿到这条信息
外部资料修改资料库,回答前检索是否检索到新版本,是否理解适用时间
模型参数用新数据继续训练或定向编辑是否改对,是否影响其他能力

前两种可以改变系统的回答,却不一定改变语言模型权重。这并不降低它们的实用价值:有来源、经常变化、需要追溯时间的信息,放在可更新的资料中往往更方便检查。

反过来,把新地址写进参数,也不自动保证所有问法都会答对。模型可能只记住训练时那句话;再次训练后又可能忘记。参数更新解决的是另一部分问题,不能替代检索、版本管理和任务评估。

还要分清“过去错了”和“过去已经过时”。问“工坊现在在哪里”,应当用新地址;问“去年在哪里取货”,旧地址仍然有用。好的更新应该保留时间条件,而不是把所有含旧地址的句子都判错。

持续学习(continual learning)研究模型按顺序接触新任务、新数据时,怎样继续获得能力。如果在学习新任务时,原本应该保留的旧能力显著下降,就出现了灾难性遗忘。这里的“遗忘”描述行为变化,不是在断言某一条知识已经从某个神经元里被彻底删除。

2. 先学半套数字,再学另外半套

数据和任务都先固定

图片来自 UCI 的 Optical Recognition of Handwritten Digits,使用 scikit-learn 随包的 1,797 张 8×8 图片,像素除以 16。作者为 E. Alpaydin 与 C. Kaynak,数据按 CC BY 4.0 提供。UCI 原始数据页

我们按类别固定划分:

集合每个数字的图片数总数
训练1001,000
开发20200
测试30300
未使用各类剩余图片297

图片 ID 在集合之间不重叠,但没有按书写者隔离;这是教学划分,不是对全新书写者的严格评价。数字 0~4 称为旧类,5~9 称为新类,各自在训练、开发、测试中占一半。

模型是 64 输入、48 个 Tanh 隐藏单元、10 个输出的小网络,共 3,610 个参数。它从一开始就有十个输出,测试时也始终从十个数字中选答案。我们不会提前告诉它“这张只可能属于前五类”,也不会屏蔽某些输出。

这属于类别增量问题:随着学习推进,需要区分的类别增加了。如果每次测试都告诉模型所属任务,再只让它在五类中选择,难度与本实验不同。

第一阶段只用旧类的 500 张图,训练 240 次,每次抽 32 张。第二阶段从同一份第一阶段参数出发,再训练 240 次。每阶段重新建立 Adam 优化器,学习率都是 0.003。

随机种子为 11、22、33。同一种子下,后面的五个方案共享第一阶段模型。划分、回放样本和全部超参数都在正式训练前固定;开发集只画学习曲线,使用末次参数,没有按开发或测试成绩选择方案。

五种办法,先看它们实际使用什么

第二阶段方案每次更新的数据或约束
仅学新类32 张新图
回放 50 张24 张新图,8 张从 50 张旧图中抽
回放全部 500 张24 张新图,8 张从全部旧训练图中抽
EWC,λ=10032 张新图,加较弱的参数约束
EWC,λ=100032 张新图,加较强的参数约束

50 张回放图在训练前选定,每个旧数字 10 张,全部来自旧训练集。回放(replay)就是让模型学新内容时,也继续练习一部分旧内容。

EWC 是 Elastic Weight Consolidation,通常译作弹性权重巩固。它把第一阶段的参数当作参照,对某些参数的变化施加更大惩罚。下一节会把这项约束算清楚。

五组第二阶段都访问 240×32=7680240\times32=7680 个样本,但回放组其中有 1,920 次旧样本访问,只有 5,760 次新样本访问;其他组有 7,680 次新样本访问。相同更新次数,不等于相同的新数据学习量。

同一种子下,各组共享每次抽取的新样本序列;回放组取其中前 24 张,再补 8 张旧图。EWC 还需要预先估计参数重要性,其额外计算另算,不能声称五组总计算完全一样。

总分几乎不变,能力却换了一半

第一阶段结束后,三个种子在旧类测试中分别答对 146、148、147/150,均值为 98.0%;新类都是 0/150。模型学会了前五个数字,还没有学会后五个。

第二阶段结束后的测试均值如下,表中数值单位为 %:

方案旧类新类总体
仅学新类0.996.748.8
回放 50 张87.896.091.9
回放全部 500 张92.794.793.7
EWC,λ=10030.794.962.8
EWC,λ=100065.388.777.0

只学新类时,旧类正确数变成 4、0、0/150。新数字几乎都学会了,旧数字却几乎都认不出来。这不是数据被删除了,而是后续更新改变了同一个网络,原来的分类结果没有保住。

更有意思的是总体分数:第一阶段约为 49.0%,第二阶段约为 48.8%。只盯着这个平均数,会以为“没什么变化”;把类别拆开,才能看见能力已经换了一半。

回放 50 张图时,旧类保留到约 87.8%,但并没有回到原来的 98.0%。保留全部 500 张时旧类更好,新类却略低。三次训练和这份测试集只能说明本次设置的结果,不能据此给所有回放策略排出稳定次序。

图 1:曲线为三个种子的均值,第二阶段阴影表示最小值至最大值,并非置信区间。灰线是共同的第一阶段;完整五组结果均在表格和下载记录中。图中使用开发集,表格使用独立测试集。

可以用一张小表记录“学到哪一步,对哪些任务还行”。若 Ri,jR_{i,j} 表示学完第 ii 阶段后,在第 jj 个任务上的正确率,那么“仅学新类”的结果约为:

R=[0.98000.0090.967] R= \begin{bmatrix} 0.980 & 0\\ 0.009 & 0.967 \end{bmatrix}

行表示训练阶段,列依次是旧类与新类。旧类从第一行到第二行的下降为:

Fold=R1,1−R2,1≈0.971 F_{\text{old}}=R_{1,1}-R_{2,1} \approx 0.971

也就是约 97.1 个百分点。只比较对角线,会看到两次都学得很好,却漏掉右下角那次训练对旧任务的影响。若后续学习反而帮助了旧任务,这个差值也可以为负。

3. 旧样本留下来,还是把参数拴住

回放让旧任务继续参与梯度

如果一批中四分之一来自旧数据,可以把平均训练目标写成:

Lmix=0.75Lnew+0.25Lold L_{\text{mix}} =0.75L_{\text{new}}+0.25L_{\text{old}}

这里两个损失分别在本批的新样本与旧样本上取平均。旧任务重新参与参数更新,模型就有机会纠正“为了新类而破坏旧类”的变化。

不过,50 张图不等于旧任务的全部变化。某种少见写法可能一张都没留下,也可能被抽中很多次。因此,回放缓存怎样选、每一类分多少、多久更新,与缓存大小同样重要。可以回看数据与学习效率中的筛选偏差。

回放还有一种容易忽略的风险:如果旧数据中的无时间限定地址已经失效,继续大量回放它,可能妨碍学会新地址。旧数字仍然是旧数字,而过期事实不一定应该原样保留。这两种更新任务不能混为一谈。

EWC 把变化的代价写进损失

设第一阶段的参数为 θ∗\theta^*,第二阶段正在训练的参数为 θ\theta。EWC 的目标为:

L(θ)=Lnew(θ)+λ2∑i=1PFi(θi−θi∗)2 L(\theta) =L_{\text{new}}(\theta) +\frac{\lambda}{2} \sum_{i=1}^{P}F_i(\theta_i-\theta_i^*)^2

PP 是参数数量,λ\lambda 控制约束强度,Fi≥0F_i\geq0 表示第 ii 个参数在旧模型中的局部敏感程度。偏离旧值越远,惩罚越大;FiF_i 越大,相同偏离受到的惩罚越重。

这种二次惩罚像把参数用不同松紧的弹簧连到旧位置。它允许移动,只是要付出代价。原始 EWC 工作从旧任务附近的概率近似出发构造这一约束,并在置乱 MNIST 和 Atari 任务上实验;本书的 8×8 类别增量实验与原论文设置不同。EWC 原文第 2 节

惩罚项对一个参数的导数很简单:

∂Lpenalty∂θi=λFi(θi−θi∗) \frac{\partial L_{\text{penalty}}}{\partial\theta_i} =\lambda F_i(\theta_i-\theta_i^*)

参数比旧值大时,这一项倾向于把它往回拉;比旧值小时则相反。但新任务的梯度也在发挥作用,最终更新由两者共同决定。

“重要性”怎样估计

本实验使用 Fisher 信息矩阵的对角项。先不必被名字吓住:我们检查的是,轻轻改变一个参数时,旧模型给各种答案的概率会有多敏感。

对旧输入 xx 和类别 cc,记这种敏感程度为:

gi(x,c)=∂log⁡pθ(c∣x)∂θi∣θ=θ∗ g_i(x,c)= \left. \frac{\partial\log p_\theta(c\mid x)} {\partial\theta_i} \right|_{\theta=\theta^*}

代码在 50 张旧输入上,对十个可能类别逐一计算这个导数,再按旧模型给该类别的概率加权:

Fi=150∑x∈M∑c=09pθ∗(c∣x) gi(x,c)2 F_i=\frac{1}{50} \sum_{x\in M}\sum_{c=0}^{9} p_{\theta^*}(c\mid x)\,g_i(x,c)^2

MM 是那 50 张旧图的集合。这里按模型分布对类别求期望,不是只拿真实标签计算梯度平方;也不是先把一批梯度平均,再平方。后一种做法可能让相反方向互相抵消,估计的是不同的东西。

对每个旧模型,代码做 50 次输入前向,再计算 50×10=50050\times10=500 组类别对数概率梯度。它不更新参数,只生成与参数形状相同的非负权重。

这个估计只描述旧参数附近、这些输入上的局部变化。它忽略不同参数之间的相互关系,也不保证模型走远后仍然准确。把 FiF_i 当作永久有效的“每条知识的位置”,就超出了它提供的证据。

更紧的弹簧,不是免费的保护

本实验把 λ\lambda 从 100 增加到 1000,旧类均值从 30.7% 提高到 65.3%,新类却从 94.9% 降到 88.7%。约束确实让旧能力保留得更多,同时也阻碍了新任务的学习;即使较强一组,旧类仍远低于原先的 98.0%。

这两个强度在运行前选定,没有搜索最优值。它们是观察取舍的两个点,不足以说明 EWC 的最佳成绩,更不能把本次不如回放推广成普遍结论。

存储成本也可以算一笔小账。只按方法需要保留的张量计算:

保留内容本实验的字节数
50 张 float32 图片与 int64 标签13,200,约 12.9 KiB
500 张图片与标签132,000,约 128.9 KiB
旧参数与 Fisher 对角项,各一份 float3228,880,约 28.2 KiB

所以,“不回放图片”并不必然意味着占用更少。这里没有计算训练中的当前参数、优化器、文件元数据或运行缓冲;实验程序为了复核还加载了完整数据集,这张表不是进程峰值内存。

4. 能不能只改一条关联

继续训练通常处理一批材料。知识编辑更关心定向修改:例如让某个对象对应的新答案生效,同时尽量保留不相关回答。

我们先用一个二维线性映射看清困难。这里没有文字、词元或语言模型,只把输入向量当作“键”,输出向量当作关联的“值”。

原来的矩阵是单位矩阵:

W=[1001],k=[11] W= \begin{bmatrix}1&0\\0&1\end{bmatrix}, \qquad k= \begin{bmatrix}1\\1\end{bmatrix}

原输出为 Wk=(1,1)TWk=(1,1)^T。现在想把这个键的输出改成 v=(2,0)Tv=(2,0)^T,需要补上差值 δ=v−Wk=(1,−1)T\delta=v-Wk=(1,-1)^T。

一个满足要求、且矩阵元素平方改变量之和最小的更新是:

ΔW=δkTkTk,W′=W+ΔW \Delta W=\frac{\delta k^T}{k^Tk}, \qquad W'=W+\Delta W

kTkk^Tk 是键向量的长度平方,此处为 2,要求 kk 不是零向量。分子是一个列向量乘一个行向量,结果为 2×22\times2 矩阵。这种外积改动的秩至多为一,称为秩一更新。

代入得到:

ΔW=[0.50.5−0.5−0.5]W′=[1.50.5−0.50.5] \begin{aligned} \Delta W&= \begin{bmatrix}0.5&0.5\\-0.5&-0.5\end{bmatrix}\\ W'&= \begin{bmatrix}1.5&0.5\\-0.5&0.5\end{bmatrix} \end{aligned}

可以直接算出 W′k=(2,0)TW'k=(2,0)^T,目标改对了。不过,其他输入会怎样?

对于另一个键 qq,输出变化为:

ΔWq=δ kTqkTk \Delta Wq =\delta\,\frac{k^Tq}{k^Tk}

如果 q=(1,−1)Tq=(1,-1)^T,它与 kk 的点积为 0,输出保持不变。如果 q=(1,0)Tq=(1,0)^T,点积为 1,输出就从 (1,0)T(1,0)^T 变成 (1.5,−0.5)T(1.5,-0.5)^T。改动一条关联,也会影响与它重叠的方向。

邻近输入 (1,0.8)T(1,0.8)^T 的新输出是 (1.9,−0.1)T(1.9,-0.1)^T,并非精确的 (2,0)T(2,0)^T。在这个线性算例里,它只是邻近向量;不能直接把它当成真实语言中的“同义问法”。

现在做第二次编辑:把键 (1,0)T(1,0)^T 的输出改为 (0,1)T(0,1)^T。同样应用公式后,矩阵变成:

W′′=[00.510.5] W''= \begin{bmatrix}0&0.5\\1&0.5\end{bmatrix}

新目标改对了,但第一次的键 (1,1)T(1,1)^T 又变成了 (0.5,1.5)T(0.5,1.5)^T。一次编辑成功,不等于连续编辑后仍然有效。

图 2:标准库程序实际计算的二维关联变化,不是语言模型事实编辑的效果图。

ROME 把 Transformer 的一部分前馈计算看作关联映射,定位编辑位置、构造键与目标值,并考虑其他键的统计来求权重更新。它还检查改写问法与邻近事实,而不只看原句是否改变。我们的二维公式只是帮助理解“写入会干扰其他方向”,没有实现这些定位、取值和保持机制。ROME 原文第 3.1~3.3 节

5. 新事实、旧事实,应该怎样一起考

假设已经把工坊新地址写进系统。现在至少有几种不同的考法:

检查的问题想确认什么
原来的问句能否答对修改是否生效
换一种说法还能否答对是否超出原句记忆
报价与营业时间是否仍正确不相关内容是否保持
从新地址推算配送区域是否一致新信息能否参与其他推理
再做许多次更新后还能否答对早期更新是否被覆盖

这些检查不能互相替代。回答新地址的概率上升,不代表模型已经修改了所有相关后果;保持旧任务总分,也不代表没有伤到某个小类别。

WikiBigEdit 从 Wikidata 的连续快照中提取变化,构造更新、改写、邻近事实和多跳问题,检验大量顺序编辑。作者在五个约 7B~8B 模型上的研究发现,某些定向编辑方法在连续更新时明显退化;检索与持续微调提供了有竞争力的对照。检索会增加推理成本,任务构造也依赖过滤与模型生成,因此结果需要连同设置一起读。WikiBigEdit 原文第 3、4 节

这类评测也提醒我们,修改资料、修改参数和修改上下文应当放在同一任务上比较,并把各自存了什么、训练了多少、回答时查了什么说清楚。不能让一种方法拿到最新资料,另一种只能依赖旧权重,然后把差异全部归结为算法能力。

6. 新网页不断来,怎样继续预训练

语言模型还有一种常见更新:新的网页、文档和代码不断出现,继续使用下一词元预测目标训练。这叫持续预训练。它不要求每条材料都改写成“问题—答案”,也不等同于只编辑少数事实。

从预训练流程的角度看,训练仍需要分词、组成批次、计算损失和更新参数;变化在于数据按时间陆续到达,不能再假定所有材料一开始就齐备。

有几处选择会直接影响结果。旧数据占多少比例?新阶段学习率是否重新升高?是否继承优化器状态?新版本文档与旧版本例子冲突时,怎样保留各自适用的版本?

例如,一个函数在新版库中改了参数名,旧代码仍可能用于维护旧项目。把旧用法全部当成错误,会丢掉有用能力;不区分版本地把两套用法混在一起,又可能在同一段代码中拼出不能运行的组合。

评估因此要按时间和领域拆开:新月份的留出文本、以前月份的留出文本、尚未用于训练的后续月份,以及一般能力任务。未来月份可以在更新前用于一次预先约定的评价,却不能把它的结果反复用于选择更新规则,再称为未见未来的表现。

TiC-LM 按网页抓取批次组织持续预训练,并构造按时间划分的网页、百科、问答和代码文档评价。其对照涉及学习率安排、回放与正则化;旧数据比例的合适选择随领域而变。论文也说明,正则化方法的额外计算与模型副本没有通过减少词元数来完全抵平,因此“相同词元预算”仍需结合实际成本阅读。TiC-LM 原文第 4~6 节

这里还要区分抓取时间与事实发生时间。一篇旧新闻今天重新发布,并不会把历史事件变成今天的新事实。时间标记、近重复内容与来源检查,仍然需要预训练数据工程中的方法。

我们的数字实验只进行了两阶段分类训练,没有执行语言模型持续预训练。它展示旧数据参与更新的机制,不能给出网页回放的最优比例。

7. 在线适应:先作答,再看反馈

持续学习可以按月或按阶段发生。在线适应更贴近连续到达的交互:先面对当前输入,随后取得反馈,再影响下一次预测。

为了看清时间顺序,我们造一条 40 次的单页打印耗时记录。前 20 次以 10 秒为中心,后 20 次以 14 秒为中心,叠加重复的扰动 (−1,1,0,1,−1)(-1,1,0,1,-1) 秒。这是本书自编数据,不是设备测量。

预测器只保存一个估计 mtm_t,从 10 秒开始。第 tt 次先预测,再看到实际耗时 yty_t:

y^t=mtmt+1=mt+α(yt−mt) \begin{aligned} \hat y_t&=m_t\\ m_{t+1}&=m_t+\alpha(y_t-m_t) \end{aligned}

α\alpha 控制每次向新观测移动多少。它为 0 时始终不更新;较小时变化慢,较大时跟得快。这个公式也可以看成对平方误差做一步梯度下降,对应学习率 α/2\alpha/2。

我们比较 α=0\alpha=0、0.1、0.5,并在更新之前的预测上计算均方误差:

方案前 20 次 MSE后 20 次 MSE
不更新0.80016.800
慢更新,α=0.10.8845.031
快更新,α=0.51.2942.357

MSE 的单位是秒的平方。前半段中心值没有变化,更新反而容易跟着扰动摇摆;后半段突然变慢,更新才帮助预测器跟上。快更新在后半段更好,前半段却更差。

图 3:每个预测只使用此前观察,当前真实耗时在预测完成后才提供。灰点是自编观测值,竖线表示数据中心发生变化的位置。

如果先看到 yty_t,直接把预测设成 yty_t,再报告本次误差为零,过程当然很漂亮,却没有预测任何未知量。先测后学的时序能防止这种信息泄漏。

同样,模型生成的答案不是自动可信的标签。在线更新还要考虑反馈是否正确、是否延迟、是否偏向某类用户,以及新反馈能否代表接下来要处理的任务。把不可靠反馈迅速写入参数,也可能迅速学错。

快速改变上下文,慢一些改变参数

这里的快、慢数值更新只有一个标量;语言模型研究中的“快慢学习”可能采用完全不同的载体。

2026 年的 Fast-Slow Training 使用可优化的文本提示作为“快权重”,用强化学习更新模型参数作为“慢权重”。它先根据训练反馈改进提示候选,再用这些提示条件下的生成结果更新参数,循环进行。这里“快权重”指文本,并不是给 Transformer 新增一组普通数值参数;提示搜索和反馈生成也要计算,较少参数更新次数不等于同等比例的总成本节省。Fast-Slow Training 原文第 2~4 节与附录 D

另一项 2026 年研究 When Does Continual Learning Require Learning,在同一个 Qwen3-8B 基础上比较提示、权重与记忆更新,分别考察领域变化、事实更新、噪声时间变化和 Agent 状态积累。作者也明确限制了结论:单一模型与几类环境不能给出通用方法排名。它提供的阅读角度是,先说明世界怎样变化,再讨论哪种更新有效。原文第 3~5 节

这正好回到开头的工坊。新地址需要及时改,新技能需要练习,带噪声的一次反馈可能需要等更多证据。对所有变化都使用同一种更新速度,很难同时做好这些事。

8. 运行代码,追踪每次变化

先看记录与两个小算例

在仓库根目录运行:

python3 src/examples/continual_learning.py

输出为:

第二阶段结束:旧类 / 新类 / 总体测试正确率
仅学新类:0.9% / 96.7% / 48.8%
回放 50 张:87.8% / 96.0% / 91.9%
回放全部 500 张:92.7% / 94.7% / 93.7%
EWC λ=100:30.7% / 94.9% / 62.8%
EWC λ=1000:65.3% / 88.7% / 77.0%
第一次编辑后的目标值: [2.0, 0.0]
第二次编辑后,旧目标变成: [0.5, 1.5]
在线预测:变化前 / 变化后 MSE
frozen:0.800 / 16.800
slow:0.884 / 5.031
fast:1.294 / 2.357

这条命令汇总已有训练记录,并重新计算线性编辑与在线预测。要将两个小实验的完整过程另存下来,使用:

python3 src/examples/continual_learning.py --small continual-learning-small-new.json

它只需要 Python 标准库,输出文件不能已存在。也可以下载汇总与数值实验脚本,在旁边的 data 文件夹放入冻结方案和完整训练记录。本书另提供数值实验记录供比对。

阅读代码时,先看 edit 中的外积更新,再看 online_experiment 中预测与更新的先后顺序。

展开汇总与数值实验代码
"""持续学习:训练记录汇总、线性关联编辑与先预测后更新的数值实验。"""

from argparse import ArgumentParser
from hashlib import sha256
import json
from pathlib import Path
import statistics

DATA = Path(__file__).with_name("data")
PLAN = DATA/"continual-learning-plan.json"
RECORD = DATA/"continual-learning-training.json"
SMALL = DATA/"continual-learning-small.json"
ARMS = ["sequential", "replay50", "replay500", "ewc100", "ewc1000"]
LABELS = {"sequential": "仅学新类", "replay50": "回放 50 张", "replay500": "回放全部 500 张",
          "ewc100": "EWC λ=100", "ewc1000": "EWC λ=1000"}


def digest(path):
    return sha256(Path(path).read_bytes()).hexdigest()


def save_new(path, value):
    with Path(path).open("x") as f:
        json.dump(value, f, ensure_ascii=False, separators=(",", ":"))
        f.write("\n")


def matvec(matrix, vector):
    return [sum(a*b for a, b in zip(row, vector)) for row in matrix]


def edit(matrix, key, desired):
    """满足 W_new @ key = desired 的最小 Frobenius 范数单次改动。"""
    norm2 = sum(x*x for x in key)
    if norm2 == 0:
        raise ValueError("编辑键不能为零向量")
    residual = [v-old for v, old in zip(desired, matvec(matrix, key))]
    return [[weight+error*k/norm2 for weight, k in zip(row, key)]
            for row, error in zip(matrix, residual)]


def editing_experiment():
    original = [[1., 0.], [0., 1.]]
    first = edit(original, [1., 1.], [2., 0.])
    second = edit(first, [1., 0.], [0., 1.])
    keys = {"edited": [1., 1.], "orthogonal": [1., -1.],
            "overlapping": [1., 0.], "nearby": [1., .8]}
    return dict(keys=keys, matrices=[original, first, second],
                values=[{name: matvec(matrix, key) for name, key in keys.items()}
                        for matrix in [original, first, second]])


def online_experiment():
    noise = [-1., 1., 0., 1., -1.]*8
    observations = [(10. if t < 20 else 14.)+n for t, n in enumerate(noise)]
    result = {}
    for name, alpha in [("frozen", 0.), ("slow", .1), ("fast", .5)]:
        estimate, rows = 10., []
        for t, observed in enumerate(observations):
            prediction = estimate  # 先记预测;此时不使用 observed。
            error = (prediction-observed)**2
            estimate += alpha*(observed-estimate)  # 得到反馈后,才更新下一步的状态。
            rows.append(dict(t=t, prediction=prediction, observed=observed,
                             squared_error=error, next_estimate=estimate))
        result[name] = dict(alpha=alpha, rows=rows,
                            before_mse=statistics.mean(r["squared_error"] for r in rows[:20]),
                            after_mse=statistics.mean(r["squared_error"] for r in rows[20:]))
    return result


def small_experiments():
    return dict(source_sha256=digest(__file__), editing=editing_experiment(), online=online_experiment())


def summary(record):
    result = {}
    for arm in ARMS:
        rows = [r for r in record["runs"] if r["arm"] == arm]
        result[arm] = {key: statistics.mean(r["evaluations"]["test"]["metrics"][key] for r in rows)
                       for key in ["old_accuracy", "new_accuracy", "accuracy"]}
        result[arm]["forgetting"] = statistics.mean(
            r["old_test_before"]-r["evaluations"]["test"]["metrics"]["old_accuracy"] for r in rows)
    return result


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--small", type=Path)
    args = parser.parse_args()
    if args.small:
        save_new(args.small, small_experiments())
        print(f"已保存线性编辑与 40 步在线预测记录:{args.small}")
        return
    record = json.loads(RECORD.read_text())
    assert record["complete"] and record["plan_sha256"] == digest(PLAN)
    print("第二阶段结束:旧类 / 新类 / 总体测试正确率")
    for arm, row in summary(record).items():
        print(f"{LABELS[arm]}:{100*row['old_accuracy']:.1f}% / "
              f"{100*row['new_accuracy']:.1f}% / {100*row['accuracy']:.1f}%")
    small = small_experiments()
    print("第一次编辑后的目标值:", small["editing"]["values"][1]["edited"])
    print("第二次编辑后,旧目标变成:", small["editing"]["values"][2]["edited"])
    print("在线预测:变化前 / 变化后 MSE")
    for name, row in small["online"].items():
        print(f"{name}:{row['before_mse']:.3f} / {row['after_mse']:.3f}")


if __name__ == "__main__":
    main()

重做真实训练

训练环境为 Python 3.12.12、PyTorch 2.14.1、NumPy 2.5.4、scikit-learn 1.9.1,CPU 单线程。数据由 scikit-learn 随包提供,不下载预训练权重。

在仓库根目录运行:

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/continual_learning_train.py \
  --run --out-dir runs/continual-learning-new

输出目录必须尚不存在。程序核对冻结方案与数据指纹,完成三次旧类训练和十五次后续训练;保存每步样本 ID、损失、开发曲线、测试分数、Fisher 对角项、全部模型及文件指纹,并重新加载参数核对预测。

代码默认不训练:

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/continual_learning_train.py

输出:

默认不训练。使用 --run --out-dir 新目录,才运行全部 18 次训练。

如果要修改回放比例或约束强度,另存自己的脚本,修改 make_plan,先用 --freeze 新方案.json 生成新方案,再用 --plan 新方案.json --run --out-dir 新目录 运行。程序会核对方案与当前设置,避免悄悄拿旧配置解释新结果。

展开完整训练代码
"""真实类别增量学习:冻结方案后训练,保留全部种子、支路和错误。"""

from argparse import ArgumentParser
from copy import deepcopy
import json
from pathlib import Path
import platform
import random
import time

import numpy as np
import sklearn
from sklearn.datasets import load_digits
import torch
from torch import nn
from torch.nn import functional as F

import continual_learning as lab


def data():
    x, y = load_digits(return_X_y=True)
    raw = json.dumps({"pixels": x.astype(int).tolist(), "labels": y.tolist()},
                     separators=(",", ":")).encode()
    return torch.tensor(x/16, dtype=torch.float32), torch.tensor(y), lab.sha256(raw).hexdigest()


def make_plan():
    _, y, digest = data()
    rng = random.Random(20261020)
    groups = {f"{split}_{age}": [] for split in ["train", "dev", "test"] for age in ["old", "new"]}
    memory = []
    for label in range(10):
        ids = [i for i, value in enumerate(y.tolist()) if value == label]
        rng.shuffle(ids)
        age = "old" if label < 5 else "new"
        groups[f"train_{age}"].extend(ids[:100])
        groups[f"dev_{age}"].extend(ids[100:120])
        groups[f"test_{age}"].extend(ids[120:150])
        if label < 5:
            memory.extend(ids[:10])
    return dict(version=1, date="2026-10-20", dataset_sha256=digest,
                ids={k: sorted(v) for k, v in groups.items()}, memory=sorted(memory),
                seeds=[11, 22, 33], arms=lab.ARMS, hidden=48, lr=.003,
                old_steps=240, new_steps=240, batch=32, replay_old_per_batch=8,
                dev_every=20, fisher="predictive expectation over 10 labels on 50 old inputs",
                ewc_lambda={"ewc100": 100., "ewc1000": 1000.},
                selection="all arms, all seeds, last checkpoint; no dev/test parameter search",
                evaluation="one shared 10-class head, no task label or output mask")


def model(plan):
    return nn.Sequential(nn.Linear(64, plan["hidden"]), nn.Tanh(), nn.Linear(plan["hidden"], 10))


def metrics(logits, labels):
    z, y = torch.tensor(logits), torch.tensor(labels)
    correct = z.argmax(-1) == y
    old = y < 5
    return dict(n=len(y), correct=int(correct.sum()), accuracy=correct.float().mean().item(),
                old_n=int(old.sum()), old_correct=int(correct[old].sum()),
                old_accuracy=correct[old].float().mean().item(),
                new_n=int((~old).sum()), new_correct=int(correct[~old].sum()),
                new_accuracy=correct[~old].float().mean().item(),
                nll=F.cross_entropy(z, y).item())


@torch.no_grad()
def evaluate(net, x, y, ids):
    logits, labels = net(x[ids]).tolist(), y[ids].tolist()
    return dict(ids=ids, labels=labels, logits=logits, metrics=metrics(logits, labels))


def fisher_diagonal(net, x):
    """对输入均匀平均,对标签按旧模型分布精确求和;不是批梯度的平方。"""
    params = list(net.parameters())
    importance = [torch.zeros_like(p) for p in params]
    for example in x:
        logp = net(example.unsqueeze(0)).log_softmax(-1)[0]
        probabilities = logp.detach().exp()
        for label in range(len(probabilities)):
            gradients = torch.autograd.grad(logp[label], params, retain_graph=label+1 < len(probabilities))
            for total, gradient in zip(importance, gradients):
                total.add_(probabilities[label]*gradient.square()/len(x))
    return importance


def penalty(net, anchor, importance):
    return sum((f*(p-old).square()).sum() for p, old, f in zip(net.parameters(), anchor, importance))/2


def train(net, x, y, pool, plan, seed, steps, dev_ids, replay=None, importance=None, strength=0.):
    generator = torch.Generator().manual_seed(seed+10000)
    old_generator = torch.Generator().manual_seed(seed+20000)
    anchor = [p.detach().clone() for p in net.parameters()]
    optimizer = torch.optim.Adam(net.parameters(), lr=plan["lr"])  # 每个阶段重新建立优化器。
    history = []
    curve = [dict(step=0, **evaluate(net, x, y, dev_ids)["metrics"])]
    for step in range(steps):
        # 先为各支路抽同样的 32 个新样本;回放支路只取前 24 个。
        positions = torch.randint(len(pool), (plan["batch"],), generator=generator)
        ids = [pool[i] for i in positions.tolist()]
        if replay is not None:
            n_old = plan["replay_old_per_batch"]
            old_positions = torch.randint(len(replay), (n_old,), generator=old_generator)
            ids = ids[:-n_old]+[replay[i] for i in old_positions.tolist()]
        batch = torch.tensor(ids)
        ce = F.cross_entropy(net(x[batch]), y[batch])
        regularizer = penalty(net, anchor, importance) if importance is not None else torch.tensor(0.)
        loss = ce+strength*regularizer
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        history.append(dict(step=step+1, ids=ids, cross_entropy=ce.item(),
                            penalty=regularizer.item(), loss=loss.item()))
        if (step+1) % plan["dev_every"] == 0:
            curve.append(dict(step=step+1, **evaluate(net, x, y, dev_ids)["metrics"]))
    return history, curve


def run(plan_path, out):
    out.mkdir(parents=True, exist_ok=False)
    plan = json.loads(plan_path.read_text())
    assert plan == make_plan()
    torch.set_num_threads(1)
    torch.use_deterministic_algorithms(True)
    x, y, raw_hash = data()
    assert raw_hash == plan["dataset_sha256"]
    split_ids = {s: sorted(plan["ids"][f"{s}_old"]+plan["ids"][f"{s}_new"]) for s in ["dev", "test"]}
    record = dict(complete=False, plan_sha256=lab.digest(plan_path),
                  source_sha256={p.name: lab.digest(p) for p in [Path(__file__), Path(lab.__file__)]},
                  environment=dict(python=platform.python_version(), torch=torch.__version__,
                                   numpy=np.__version__, sklearn=sklearn.__version__,
                                   platform=platform.platform(), threads=1, device="cpu"),
                  parameters=sum(p.numel() for p in model(plan).parameters()), bases=[], runs=[])
    started = time.monotonic()

    def checkpoint():
        temporary = out/"record.pending.json"
        temporary.write_text(json.dumps(record, ensure_ascii=False, separators=(",", ":"))+"\n")
        temporary.replace(out/"record.json")

    def save_model(net, name, evaluations):
        path = out/f"{name}.pt"
        torch.save({"plan": plan, "state_dict": net.state_dict()}, path)
        restored = model(plan)
        restored.load_state_dict(torch.load(path, weights_only=True)["state_dict"])
        assert all(evaluate(restored, x, y, split_ids[s]) == ev for s, ev in evaluations.items())
        return dict(file=path.name, sha256=lab.digest(path), reload_equal=True)

    for seed in plan["seeds"]:
        torch.manual_seed(seed)
        old_model = model(plan)
        history, curve = train(old_model, x, y, plan["ids"]["train_old"], plan, seed,
                               plan["old_steps"], split_ids["dev"])
        evaluations = {s: evaluate(old_model, x, y, ids) for s, ids in split_ids.items()}
        fisher_started = time.monotonic()
        importance = fisher_diagonal(old_model, x[plan["memory"]])
        fisher_seconds = time.monotonic()-fisher_started
        base = dict(seed=seed, history=history, curve=curve, evaluations=evaluations,
                    fisher=[f.tolist() for f in importance], fisher_seconds=fisher_seconds,
                    checkpoint=save_model(old_model, f"seed-{seed}-old", evaluations))
        record["bases"].append(base)
        checkpoint()
        for arm in plan["arms"]:
            net = deepcopy(old_model)
            replay = plan["memory"] if arm == "replay50" else plan["ids"]["train_old"] if arm == "replay500" else None
            strength = plan["ewc_lambda"].get(arm, 0.)
            history, curve = train(net, x, y, plan["ids"]["train_new"], plan, seed+30000,
                                   plan["new_steps"], split_ids["dev"], replay,
                                   importance if strength else None, strength)
            evaluations = {s: evaluate(net, x, y, ids) for s, ids in split_ids.items()}
            record["runs"].append(dict(seed=seed, arm=arm, history=history, curve=curve,
                                       old_test_before=base["evaluations"]["test"]["metrics"]["old_accuracy"],
                                       evaluations=evaluations,
                                       checkpoint=save_model(net, f"seed-{seed}-{arm}", evaluations)))
            checkpoint()
            m = evaluations["test"]["metrics"]
            print(f"seed={seed} arm={arm} old={m['old_correct']}/{m['old_n']} "
                  f"new={m['new_correct']}/{m['new_n']}", flush=True)
    record["wall_seconds"] = time.monotonic()-started
    record["complete"] = True
    checkpoint()
    print(f"完成 3 次旧类训练与 15 次后续训练:{out}")


def main():
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--freeze", type=Path)
    parser.add_argument("--run", action="store_true")
    parser.add_argument("--plan", type=Path, default=lab.PLAN)
    parser.add_argument("--out-dir", type=Path)
    args = parser.parse_args()
    if args.freeze:
        lab.save_new(args.freeze, make_plan())
        print(f"已冻结划分、回放样本与全部训练配置:{args.freeze}")
        return
    if not args.run:
        print("默认不训练。使用 --run --out-dir 新目录,才运行全部 18 次训练。")
        return
    if args.out_dir is None:
        parser.error("需要尚不存在的 --out-dir")
    run(args.plan, args.out_dir)


if __name__ == "__main__":
    main()

也可下载训练脚本,与汇总脚本放在一起。依赖安装方法见工具附录。

9. 停下来试一试

  1. 为什么本实验不能在测试时先告诉模型“这是旧类”,再只在数字 0~4 中挑答案?
  2. 一个模型旧类正确率从 98% 降到 1%,新类从 0% 升到 97%,两组样本数相同。总体正确率前后分别是多少?这个平均数漏掉了什么?
  3. 每批 32 张,其中 8 张旧图,训练 240 次。旧图与新图分别被访问多少次?50 张缓存中的每张图平均期望被抽多少次?
  4. 两个参数的旧值为 (1,−1)(1,-1),新值为 (1.2,−0.6)(1.2,-0.6),重要性为 (2,0.5)(2,0.5)。不乘 λ\lambda 时,二次惩罚是多少?其梯度是多少?
  5. 如果一个参数的 Fi=0F_i=0,是否可以断言它与旧任务完全无关?结合估计范围回答。
  6. 第一次线性编辑中,计算键 (0,1)T(0,1)^T 的输出变化。它为什么也受影响?
  7. 对第一次的目标键,第二次编辑后欧氏误差是多少?若只检查“最新编辑成功”,会遗漏什么?
  8. 在线更新从 m1=10m_1=10 开始,先后观察到 9 和 11,α=0.5\alpha=0.5。前两次预测和误差分别是什么?第三次预测是多少?
  9. 工坊搬家后,回放大量不带日期的旧地址问答可能带来什么问题?怎样同时保留历史地址和当前地址?
  10. 扩展真实训练:保持每批 32 张,把回放旧图数从 8 改成 16。预先说明新数据访问量怎样变化,再同时报告旧类、新类和总体成绩;不要只留下提高的指标。
参考答案与实验提示
  1. 这等于额外提供任务身份并屏蔽一半输出,改变了测试问题。模型可能仍能在旧类内部区分,却已经把旧图更倾向地判为新类;本实验要检查的正是统一十分类表现。
  2. 前后都是 49%。新能力替换了旧能力,平均数看不出来。
  3. 旧样本 1,920 次,新样本 5,760 次。若在 50 张缓存中均匀有放回抽样,每张期望出现 1920/50=38.41920/50=38.4 次,实际次数会波动。
  4. 惩罚为 [2(0.2)2+0.5(0.4)2]/2=0.08[2(0.2)^2+0.5(0.4)^2]/2=0.08,梯度为 (0.4,0.2)(0.4,0.2)。乘以 λ\lambda 后,两者相应缩放。
  5. 不能。它只反映当前模型、选定旧输入与对角近似下的估计,可能漏掉其他输入或参数之间的共同作用,也可能受到数值精度影响。
  6. 点积为 1,变化为 (0.5,−0.5)T(0.5,-0.5)^T;输出从 (0,1)T(0,1)^T 变成 (0.5,0.5)T(0.5,0.5)^T。两个坐标方向都与目标键有重叠。
  7. 希望得到 (2,0)T(2,0)^T,实际为 (0.5,1.5)T(0.5,1.5)^T,误差长度为 (−1.5)2+1.52=4.5≈2.121\sqrt{(-1.5)^2+1.5^2}=\sqrt{4.5}\approx2.121。只查最新目标会漏掉先前编辑被覆盖。
  8. 前两次预测为 10、9.5,平方误差为 1、2.25;看到第二次结果后,第三次预测为 10.25。不能先用第二次观测更新,再把 10.25 当作第二次预测。
  9. 模型可能继续把过期答案用于“现在”的问题。可显式保留有效时间、问题的时间条件与来源,并分别测试历史查询和当前查询;不必把旧记录一律删除。
  10. 第二阶段新样本与旧样本各 3,840 次,新类学习量进一步减少。旧类可能保留更多,新类可能受影响,但方向不能由公式保证。新方案要保留全部种子和未改善的结果。

10. 带着“该改什么”继续读

  1. 怎样保护旧任务附近的参数? Kirkpatrick 等,Overcoming catastrophic forgetting in neural networks,v2,2017-01-25。读第 2 节与附录 4.1,分清惩罚形式、重要性估计和原始任务设置。
  2. 怎样定向写入一条关联? Meng 等,Locating and Editing Factual Associations in GPT,v5,2023-01-13。对照第 3.1~3.3 节、附录 E.5与作者项目,查看键、目标值与保持条件从哪里来。
  3. 编辑次数增加后还可靠吗? Thede 等,WikiBigEdit: Understanding the Limits of Lifelong Knowledge Editing in LLMs,ICML 2025。阅读第 3、4 节的时间快照、任务构造及连续更新实验。
  4. 新网页怎样接着用于预训练? Li 等,TiC-LM: A Web-Scale Benchmark for Time-Continual LLM Pretraining,v1,2025-04-02。重点看第 4~6 节的时间评价、回放比例和计算口径。
  5. 上下文与参数能否分担适应? Tiwari 等,Learning, Fast and Slow: Towards LLMs That Adapt Continually,v1,2026-05-12。读第 2~4 节、6.1 节与附录 D,区分文本提示优化、参数更新与额外搜索成本。
  6. 不同变化需要同一种学习吗? Harrington 等,When Does Continual Learning Require Learning,v1,2026-07-08。对照第 3~5 节的统一协议与局限,留意单一模型上的比较能支持多大的结论。

两篇 2026 年文献都是近期预印本。本书没有运行它们的语言模型训练,也没有执行 ROME、WikiBigEdit 或 TiC-LM 的完整系统;实际完成的是前面的数字训练、二维编辑和在线数值实验。

回到前沿专题库,接下来还可以追问:当新经验不只是一段文字,而是一段视频、一次动作和它带来的环境变化,模型又该怎样学习?