☰
贝叶斯参数高效微调对抗灾难性遗忘:LoRA持续学习实践
2026/10/27 11:49:46 网站建设 项目流程

持续学习这条线写到第十个话题,贝叶斯参数高效微调用来对抗灾难性遗忘是我一直想早点写、又不敢草率动笔的一篇。原因很实际:市面上讲持续学习的材料,大多停留在全参数微调时代的正则化套路,而真正落到 LoRA、Adapter 这类参数高效微调结构上时,遗忘的表现形式、可用的贝叶斯工具、以及调参的敏感点,全都换了一套逻辑。我第一次把变分后验套到 LoRA 的低秩因子上时,损失曲线好看得离谱,结果一评测旧任务精度掉了十几个点,才发现问题根本不在贝叶斯本身,而在我对"哪些参数该有后验"这件事的判断从一开始就偏了。

这篇内容面向的是已经跑过 LoRA 微调、想进一步处理多任务顺序训练的人,也适合正在做持续学习实验、被"新任务学会了旧任务忘了"折磨的同学。我会把贝叶斯视角为什么能减缓遗忘讲透,把三条可落地的技术路线拆开对比,再给一套能直接跑起来的最小实现,最后重点讲那些只有在真实训练里才会暴露的坑——KL 权重怎么配、后验方差为什么会塌、评测协议怎么设计才不算自欺欺人。

1. 灾难性遗忘在参数高效微调里换了副面孔

很多人对灾难性遗忘的印象还停留在"全参数微调,学完新任务旧任务的权重被彻底改写"。这个画面没错,但把它直接搬到参数高效微调上会产生严重误判。LoRA 只更新一对低秩矩阵,Adapter 只更新插入的小瓶颈层,Prompt 类方法连模型本体都不碰。参数量少了两个数量级,遗忘的形态也随之变了:它不再是全局权重漂移,而是低秩子空间内的方向冲突和任务间共享模块的覆盖写入。

1.1 全参数微调时代的遗忘结论为什么不能直接照搬

全参数微调下,遗忘的根源是模型容量被新任务的数据分布整体拉走,旧任务的最优解区域被覆盖。EWC 那一类方法的思路是给重要参数加二次惩罚,用 Fisher 信息矩阵衡量"这个参数对旧任务有多重要"。这个思路在 LoRA 上需要重新审视:LoRA 的可训练参数只有A和B两个低秩因子,它们共同定义了一个增量方向ΔW = BA,真正影响模型行为的是这个乘积,而不是单独某个元素的取值。

这里有个容易被忽略的细节。假设 rank 取 8,隐藏维度 4096,那么A是 8×4096,B是 4096×8,总参数量约 6.5 万。对这个乘积做二阶近似,Hessian 的规模是 6.5 万乘 6.5 万,无论如何都放不下。所以实践中只能做对角近似,而对角近似作用在A、B各自的元素上,和"乘积的重要性"并不是一回事。我在早期实验里直接照搬 EWC 的对角 Fisher 惩罚到 LoRA 参数上,效果比不加惩罚还差,原因就在这里:惩罚把A和B的元素各自钉住,但乘积BA仍然可以整体旋转,旧任务的方向照样被破坏。

另一个差异是初始化。LoRA 常把B初始化为零,此时ΔW = 0,模型等价于原始预训练模型。第一轮梯度只会流经B,A几乎不动。这意味着在训练早期,A的后验几乎是先验本身,B的后验才开始偏离。如果一上来就对两者施加同样强度的 KL 约束,会人为压制B的学习速度,表现为新任务欠拟合。这个现象我在三组不同规模的实验里都复现过,不是偶然。

1.2 低秩子空间的"隐性覆盖":LoRA 里真正被改掉的是什么

把 LoRA 想象成在一面大墙上贴一张小海报。全参数微调是把整面墙重新刷一遍,LoRA 只是换海报。如果每个任务都贴一张新海报、彼此不重叠,那根本不存在遗忘问题——这也正是"每个任务独立一个 LoRA 模块"这种朴素做法能work的原因。问题出在两种现实约束下:

第一种是共享 LoRA 模块的顺序训练。为了控制部署成本,很多场景只维护一份 LoRA,任务 A 训完继续训任务 B。此时任务 B 的梯度会直接改写同一组A、B,任务 A 学到的增量方向被覆盖。这种覆盖往往不是均匀的,而是集中在几个主奇异向量方向上——低秩矩阵的能量本来就集中,新任务一旦占据主导方向,旧任务的贡献会被压到很小的奇异值上,表现为"精度断崖式下跌"。

第二种是任务数量超过秩容量。rank 取 8,理论上能表达 8 个正交方向。当任务数达到十几个,各任务的最优增量方向必然在子空间里挤在一起,互相干扰。这时候哪怕用任务隔离的模块,路由器选错模块也会导致精度崩塌。我做过一个粗略统计:rank 8 的 LoRA 在 10 个任务的序列上,如果完全共享模块且不加任何约束,最后一个任务训完后第一个任务的精度平均下降 40% 以上;rank 提到 32 能缓解到 20% 左右,但参数量翻了四倍,部署成本也跟着涨。

这也解释了为什么贝叶斯方法在 PEFT 场景下的价值点和全参数微调不完全一样。全参数微调里,贝叶斯主要是"防止权重跑太远";在 PEFT 里,贝叶斯还多了一层作用:它给出的后验分布天然带有"这个方向我有多确定"的信息,可以用来做无任务 ID 的推理路由。这一点后面会展开。

1.3 一个能复现的小实验:顺序学两个任务后 LoRA 权重漂移到哪里

说理论太空,给一个我常用的诊断实验,几分钟就能跑完,能直观看到漂移发生在哪。做法是:取一个中等规模的语言模型,准备两个差异较大的分类任务(比如一个情感分类、一个主题分类),共用同一份 LoRA,顺序训练。训完任务 A 后保存A、B的快照,训完任务 B 后再对比。

诊断的核心是看奇异值谱的变化。对ΔW_A = B_A A_A和ΔW_B = B_B A_B分别做奇异值分解,比较前几个奇异向量的夹角。如果新任务的增量方向和旧任务的主方向夹角很小(余弦相似度高),说明两者在抢同一个子空间,遗忘必然严重;如果夹角接近正交,理论上可以共存。我实测下来,同类任务(比如两个都是情感相关的数据集)夹角常在 0.7 以上,异类任务反而更容易正交。这个结论对设计任务顺序有直接指导意义:把相似任务排在一起,用贝叶斯约束保护主方向;把差异大的任务交叉排列,让它们自然占用不同子空间。

还有一个更朴素的观察:记录每个训练 step 的‖ΔW‖_F范数。如果训任务 B 时这个范数突然跳变,说明新任务在强行扩大增量幅度,旧任务的相对贡献被稀释。正常情况应该是平滑上升。我在一次实验里看到范数在第二个 epoch 突然翻了三倍,最后定位到是学习率没做 warmup,前几个 step 的梯度直接把B推到了很远的区域,后面再也拉不回来。

2. 把权重从"点"变成"分布":贝叶斯视角到底改变了什么

贝叶斯在这件事上的核心动作只有一句话:不再把参数当成一个确定的点,而是当成一个分布。听起来像学术黑话,但落到工程上非常具体——你多了两组数:均值 μ 和方差 σ²。均值决定"参数大概在哪",方差决定"这个位置我有多确信"。学新任务时,高置信度区域被 KL 项拉住,低置信度区域放开让新任务去改。这就从"一刀切的惩罚"变成了"按置信度分配的弹性"。

2.1 后验不是玄学:从最大似然到最大马后炮的那一步

普通微调优化的是log p(D|θ),即给定参数时数据的对数似然。贝叶斯加了一项先验log p(θ),优化目标变成log p(D|θ) + log p(θ),这就是最大后验。在持续学习里,先验不是随便选的,而是上一个任务的后验:

p(θ | D_1, ..., D_t) ∝ p(D_t | θ) · p(θ | D_1, ..., D_{t-1})

这个递推式是整个方法的地基。它的含义是:学第 t 个任务时,我对参数的"事前看法"就是学完前 t-1 个任务后的"事后看法"。只要这个后验能被准确表示,理论上旧任务的信息一点都不会丢——这就是所谓"贝叶斯持续学习不存在灾难性遗忘"的来源。

但理论归理论,工程上后验没法精确算。真实的高维后验是个形状极其复杂的分布,我们只能用简单分布去近似它:要么用高斯在某个点附近做泰勒展开(拉普拉斯近似),要么用变分分布去最小化与真实后验的 KL 距离(变分推断)。两条路线的工程代价差别很大,后面第 3 节会详细对比。

2.2 拉普拉斯近似与 Fisher 信息:一次性算完的便宜做法

拉普拉斯近似的做法是:先找到后验的众数(也就是普通训练收敛的那个点),然后在这个点做二阶展开,用高斯去近似。协方差矩阵的逆就是 Hessian。实际计算里用 Fisher 信息矩阵替代 Hessian,因为 Fisher 是半正定的,数值上更稳,而且可以用一阶梯度外积估计,不需要真的算二阶导。

对角近似下,每个参数只存一个方差值,存储开销和参数量同阶。对 LoRA 的 6.5 万参数来说,多存 6.5 万个浮点数,完全可接受。更新公式也很简单:

Σ_new = (F_new + Σ_prior^{-1})^{-1} μ_new ≈ μ_prior + Σ_new · ∇log p(D_new | μ_prior)

对角情形下这些矩阵运算全部退化成逐元素操作,几行代码就能写完。这也是为什么很多"在线 EWC"其实就是对角拉普拉斯贝叶斯的另一种写法——两者数学上同源,只是表述不同。EWC 的惩罚系数 λ 对应先验精度,Fisher 累积对应后验精度的累积。

拉普拉斯路线的优点是便宜、稳定、可以直接接在现成的训练脚本后面;缺点是它是局部近似,只在众数附近可信。如果新任务把参数拉得很远,高斯近似就失效了。我在任务差异特别大的序列上见过这种情况:拉普拉斯近似的方差估计严重偏小,导致 KL 项过强,新任务完全学不动,最后精度比不加任何约束还低。

2.3 变分推断路线:ELBO 里每一项对应的工程含义

变分推断换了个思路:不近似后验的形状,而是直接参数化一个分布q(θ) = N(μ, σ²),然后最小化KL(q || p(θ|D))。等价于最大化证据下界:

ELBO = E_{q(θ)}[log p(D|θ)] - KL(q(θ) || p(θ))

这个式子里每一项都有明确的工程对应。第一项是期望似然,实际实现时用 Monte Carlo 采样估计,训练时通常只采一次(相当于给前向加噪声),推理时采多次取平均。第二项是 KL 正则,它把当前分布往先验拉。在持续学习里,先验就是上一个任务的后验,于是 KL 变成了"别离上一个任务的后验太远"。

把两个高斯之间的 KL 展开,能看清每一项在干什么:

KL = Σ_i [ log(σ_prior,i / σ_i) + (σ_i² + (μ_i - μ_prior,i)²) / (2σ_prior,i²) - 0.5 ]

(μ_i - μ_prior,i)²这一项是均值漂移惩罚,作用类似 EWC 的二次惩罚;σ_i² / (2σ_prior,i²)是方差惩罚,防止新任务的方差无节制变大;log(σ_prior,i / σ_i)是熵项,它其实在鼓励方差变大(σ 越大,这一项越小)。两个方向相反的力互相拉扯,最终平衡点就是后验方差。理解了这一点,后面调参时"方差为什么会塌"就有了解释。

变分路线的优势是全局近似、可以采样、能给不确定性;代价是训练时多了一组方差参数要学,梯度噪声更大,收敛更慢。我在实际项目里通常先用拉普拉斯跑通 baseline,确认增益存在后,再决定要不要换成变分。

3. 三条可落地的贝叶斯 PEFT 技术路线与取舍

把贝叶斯套到 PEFT 上,理论上可以对任何可训练参数做后验。但选哪部分参数做后验,直接决定了显存开销、训练稳定性和最终效果。我按落地难度从低到高排了三条路线,每条的适用场景差别很大。

3.1 贝叶斯 LoRA:只对低秩因子做后验

最直接的做法是给 LoRA 的A、B各配一组方差参数,训练时用重参数化采样,损失里加 KL 项。参数量翻倍(均值加方差),但绝对量仍然很小。rank 8、维度 4096 的配置下,从 6.5 万涨到 13 万,对显存几乎无感。

这里有个关键选择:A和B都要后验,还是只给B?我的经验是,如果沿用B = 0的初始化,训练初期只给B后验更稳,因为A此时确实是"没被数据影响过"的状态,给它一个宽先验、后面再放开,比一上来就学两组方差要稳定得多。另一个技巧是给A固定一个随机初始化后冻结,只对B做贝叶斯。这样参数量减半,而且在多数任务上效果损失很小——因为低秩分解里B承担了主要的任务特定信息。

推理时的采样策略也值得说。训练时采一次是为了省算力,推理时如果只取均值,等于丢掉了贝叶斯带来的全部不确定性信息。我一般用 4 次采样做平均,再单独记录预测熵。实测下来,采样次数从 1 增到 4,分类任务的精度提升约 1 个百分点,从 4 增到 16 提升不到 0.3 个百分点,收益递减很明显。所以4 次是个性价比不错的工作点。

3.2 贝叶斯 Adapter / 贝叶斯 Prompt:参数更少但先验更难选

Adapter 的参数量比 LoRA 还小,通常只有瓶颈层的几万参数。好处是贝叶斯的开销可以忽略;坏处是它的表达能力受限,先验的选择影响被放大。我在一个 5 任务的序列上试过,先验方差选 1e-4 和选 1e-2,最终平均精度差了将近 8 个点。原因很直白:Adapter 参数少,KL 项稍微强一点就把整个瓶颈层压死了,新任务根本没有足够的自由度去适应。

Prompt 类方法(软提示)的问题更微妙。提示向量本身是连续空间里的点,没有明确的"物理含义",你很难凭直觉判断哪个维度重要。用对角高斯去近似,方差估计的可靠性不如 LoRA。我倾向的做法是给提示向量配一个共享的标量方差而不是逐元素方差,相当于假设所有提示维度重要性相同。这个假设很粗糙,但实测反而比逐元素方差更稳,因为逐元素方差在数据量少的时候方差估计噪声极大,容易把某些维度误判成"极重要"从而锁死。

3.3 三条路线横向对比

把关键维度列成表,方便按场景挑:

维度贝叶斯 LoRA贝叶斯 Adapter贝叶斯 Prompt
可训练参数量(相对)中小极小
贝叶斯额外开销与参数量同阶,可接受几乎可忽略几乎可忽略
先验敏感度中高很高
后验估计可靠性较高中低
无任务 ID 推理能力强(熵区分度好)中弱
实现难度低中(要改模型结构)低
适合的任务规模中到大小到中小

如果只让我推荐一条,我会选贝叶斯 LoRA,理由是它的参数量、先验敏感度、后验可靠性三者平衡得最好,而且改动量最小——不用动模型结构,只在现有 LoRA 层上加一组方差参数就行。Adapter 和 Prompt 更适合参数量被严格限制的边缘场景。

4. 手搓一个最小可跑的 Bayesian LoRA 持续学习流程

理论讲完,进入能落地的部分。这一节给一套最小实现,代码基于通用深度学习框架,不依赖任何特定训练库,你可以直接嵌到自己的脚本里。为了便于理解,我把流程拆成"结构定义—前向采样—损失计算—任务切换"四块,每块都说明为什么这么写。

4.1 环境与数据流的准备

先说数据组织。持续学习的实验最容易出错的地方不是模型,而是数据划分。我的做法是把任务序列显式定义成一个列表,每个任务包含训练集和测试集,测试集要覆盖所有已见过的任务,因为最终评测需要在全部任务上跑。很多人只留当前任务的测试集,训完就没法算 backward transfer 了,这个坑我踩过不止一次。

# 任务序列定义,每个任务独立训练集,测试集保留全集用于最终评测 task_sequence = [ {"name": "task_0", "train": loader_0, "test": loader_0_test}, {"name": "task_1", "train": loader_1, "test": loader_1_test}, # ... ] seen_tests = [] # 每学完一个任务就 append 对应的 test loader for t, task in enumerate(task_sequence): train_on_task(model, task["train"]) seen_tests.append(task["test"]) evaluate_all(model, seen_tests) # 关键:每次都在所有已见任务上评

评测频率也值得说。每个任务训完评一次是底线,如果想看遗忘的动态过程,可以在任务内每隔若干 step 评一次。代价是评测开销变大,我的折中做法是每个 epoch 末评一次。

4.2 前向:LoRA 上的重参数化采样

LoRA 层加后验的核心是重参数化技巧:不直接从N(μ, σ²)采样,而是采一个标准正态噪声ε,再算θ = μ + σ ⊙ ε。这样做的好处是梯度可以穿过采样操作传回μ和σ,否则采样是个随机节点,梯度断流。

import torch import torch.nn as nn import torch.nn.functional as F class BayesianLoRALayer(nn.Module): def __init__(self, in_dim, out_dim, rank=8, prior_std=1e-2): super().__init__() self.rank = rank # 均值参数,B 零初始化保持训练初期等价于原模型 self.A_mu = nn.Parameter(torch.randn(rank, in_dim) * 0.01) self.B_mu = nn.Parameter(torch.zeros(out_dim, rank)) # 方差用 log 参数化,保证恒正且数值稳定 self.A_logvar = nn.Parameter(torch.full((rank, in_dim), -9.0)) self.B_logvar = nn.Parameter(torch.full((out_dim, rank), -9.0)) # 先验:来自上一个任务的后验,首个任务用固定宽先验 self.register_buffer("A_prior_mu", torch.zeros(rank, in_dim)) self.register_buffer("A_prior_logvar", torch.full((rank, in_dim), 2 * torch.log(torch.tensor(prior_std)))) self.register_buffer("B_prior_mu", torch.zeros(out_dim, rank)) self.register_buffer("B_prior_logvar", torch.full((out_dim, rank), 2 * torch.log(torch.tensor(prior_std)))) def sample_weight(self, mu, logvar, n_samples=1): std = torch.exp(0.5 * logvar) eps = torch.randn(n_samples, *mu.shape, device=mu.device) return mu.unsqueeze(0) + std.unsqueeze(0) * eps def forward(self, x, n_samples=1): A = self.sample_weight(self.A_mu, self.A_logvar, n_samples) B = self.sample_weight(self.B_mu, self.B_logvar, n_samples) # 逐样本计算低秩增量 out = torch.einsum("n o r, n r i, b i -> n b o", B, A, x) return out.mean(0) if n_samples > 1 else out.squeeze(0)

这段代码里有几个刻意的设计。logvar初始值取 -9,对应标准差约 0.01,这个量级是试出来的:太大(比如 -4,标准差 0.13)会让训练初期噪声淹没信号,损失震荡得厉害;太小(比如 -14,标准差 0.001)则方差梯度几乎为零,学不动。A的均值用小随机数初始化而不是零,是为了打破对称性,否则A的各个秩分量会一直保持一致。

4.3 损失函数:CE + KL 的配比与数值稳定

损失由两部分组成:任务损失和 KL 正则。KL 的计算要小心数值稳定,尤其是logvar差异较大时,直接算exp容易溢出。我习惯用log1p和对数域运算重写。

def gaussian_kl(mu_q, logvar_q, mu_p, logvar_p): """逐元素计算两个对角高斯之间的 KL,返回标量和""" var_q = torch.exp(logvar_q) var_p = torch.exp(logvar_p) kl = 0.5 * ( logvar_p - logvar_q + (var_q + (mu_q - mu_p) ** 2) / var_p - 1.0 ) return kl.sum() def total_loss(logits, labels, lora_layers, beta=1.0): ce = F.cross_entropy(logits, labels) kl = 0.0 for layer in lora_layers: kl = kl + gaussian_kl(layer.A_mu, layer.A_logvar, layer.A_prior_mu, layer.A_prior_logvar) kl = kl + gaussian_kl(layer.B_mu, layer.B_logvar, layer.B_prior_mu, layer.B_prior_logvar) return ce + beta * kl, ce.item(), kl.item()

beta是核心超参,第 5 节专门讲。这里先说一个数值细节:var_q / var_p在var_p很小时会爆掉。如果先验方差设得很小(比如 1e-6),这一项能到 1e6 量级,梯度直接 NaN。我的处理是给var_p加一个下限clamp(min=1e-8),同时监控 KL 项的绝对值,一旦超过任务损失的 100 倍就报警——通常意味着先验设错了。

4.4 任务切换时的后验继承与快照管理

这是整个流程里最容易写错的一步。学完任务 t 后,当前后验要成为任务 t+1 的先验。具体操作是把mu和logvar的值拷到prior_mu和prior_logvar缓冲区里。听起来简单,但有个陷阱:拷贝的是训练结束时的值,还是某个滑动平均?我最初直接拷最后一 step 的值,结果发现最后一个 batch 的噪声会污染先验,导致下一个任务一开始就被带偏。后来改成用最后若干 step 的指数滑动平均,稳定性明显改善。

def update_prior(lora_layers, ema_decay=0.99, ema_state=None): """用滑动平均更新先验,避免最后一批噪声污染""" if ema_state is None: ema_state = {} for i, layer in enumerate(lora_layers): for name in ["A_mu", "B_mu", "A_logvar", "B_logvar"]: key = f"{i}_{name}" cur = getattr(layer, name).detach() if key in ema_state: ema_state[key] = ema_decay * ema_state[key] + (1 - ema_decay) * cur else: ema_state[key] = cur.clone() getattr(layer, f"{name[:-3] if name.endswith('_mu') else name}_prior" + ("_mu" if name.endswith("_mu") else "_logvar")).copy_(ema_state[key]) return ema_state

这段的字符串拼接写得有点绕,实际项目里建议直接用字典维护,可读性更好。核心逻辑是:先验不能取瞬时值,要做平滑。另外,快照管理要注意显存,每存一份先验就多一份参数量的显存,任务多了会累积。我的做法是只保留最近一份先验,更早的通过 KL 的链式累积隐式保留——虽然理论上不严格,但实测和完整保留差别很小,显存省了一大截。

5. 调参时才暴露的问题:KL 权重、方差塌缩与采样噪声

前面都是"应该怎样",这一节讲"实际会怎样"。贝叶斯 PEFT 的调参难度比普通 LoRA 高一个档次,因为它多了方差这一组参数,而方差的行为很不直观。我按踩坑频率排序,把最常遇到的三个问题拆开讲。

5.1 KL 权重不是越大越抗遗忘

直觉上,KL 权重 β 越大,参数越被拉住,遗忘越少。这个直觉在 β 较小时成立,超过某个阈值后完全反转。原因是:β 太大时,新任务学不动,模型停在旧任务的后验附近,看起来"没遗忘",但新任务精度极低,平均精度反而崩了。更隐蔽的问题是,β 过大时方差会被压向先验方差,后验失去区分度,最终退化成"所有参数同等重要"的均匀惩罚,效果和随机正则差不多。

我通常的取值策略是按任务序号做退火。第一个任务没有旧知识要保护,β 取 0 或极小;随着任务累积,β 逐步上升,但设一个上限。实测在 5 到 10 个任务的序列上,β 从 0 线性升到 1 左右比较稳。如果任务间差异特别大,上限可以压到 0.3;如果任务高度相似,可以放宽到 3。这个范围是我在多组实验里反复试出来的经验值,不是理论推导。

还有一个更细的点:KL 项的量级会随参数量线性增长。LoRA 参数量 6.5 万,KL 天然就比参数量 1000 的 Adapter 大几十倍。所以 β 不能跨结构直接比较,换结构必须重新扫。我第一次从 LoRA 换到 Adapter 时忘了这茬,沿用了原来的 β,结果模型完全不学新任务,排查了半天才反应过来。

5.2 后验方差塌缩的识别与缓解

方差塌缩指的是σ一路减小趋近于零,后验退化成点估计,贝叶斯的好处全部消失。这是变分推断的经典病,在持续学习里尤其常见,因为 KL 项里的(μ - μ_prior)² / (2σ_prior²)这一项会随着任务累积越来越大,优化器为了降低它,倾向于把σ调小——注意是调小σ而不是调小均值差,因为改方差比改均值容易。

识别方法很直接:监控所有σ的均值和分位数。正常训练下,σ应该在一个合理区间内波动,不同参数的σ应该有明显差异(重要的参数 σ 小,不重要的 σ 大)。如果发现σ的中位数在几百个 step 内单调下降,并且第 90 分位数也在下降,基本可以判定塌缩。我一般把这条曲线画在训练日志里,一眼就能看出来。

缓解有几种手段,我按有效性排序。最有效的是给logvar加一个下限约束,比如logvar.clamp(min=-10),等于给标准差设了个地板。这个操作很粗暴,但非常管用,代价是限制了模型表达极低不确定性的能力——不过在持续学习场景里,本来也不该让某个参数的不确定性趋近于零。第二种是调整 KL 里熵项的权重,人为放大log(σ_prior/σ)这一项的系数,用熵来对抗塌缩。第三种是给σ单独设一个更小的学习率,让它变化得慢一些。我通常把第一种和第三种组合使用,稳定性和效果都不错。

5.3 训练不稳定与显存开销的取舍

贝叶斯带来的额外开销主要有三块:方差参数的存储、采样带来的计算、以及 KL 的反向传播。存储前面算过,LoRA 场景下可以忽略。计算上,训练时只采一次,前向开销增加不到 10%。真正吃显存的是采样时的中间激活——如果用多次采样做训练(有些人为了降低梯度方差会采 4 次),激活值会翻四倍,大模型上直接 OOM。

我的处理是训练时严格只采一次,梯度方差靠增大 batch size 来降,而不是靠增加采样次数。推理时再采多次,因为推理不需要保存激活,开销小得多。这个取舍的依据是:训练阶段梯度噪声主要影响收敛速度,batch size 增大同样能降噪,而且不增加显存峰值;推理阶段需要不确定性估计,采样是必须的,但可以承受。

另一个不稳定来源是logvar的梯度尺度。logvar出现在exp里面,梯度天然比mu大或者小几个数量级。如果不做梯度裁剪,很容易在某个 step 爆掉。我习惯对logvar单独做梯度裁剪,阈值设得比mu更紧,比如mu裁到 1.0,logvar裁到 0.1。这个比例是试出来的,不同任务上可能需要微调,但量级差不多。

6. 评测:怎么证明"真的没忘"而不是评测集在骗你

持续学习的评测比普通任务评测更容易自欺欺人,因为指标的定义本身就带主观性。我见过太多实验,报告的平均精度很漂亮,仔细一看是任务顺序精心挑过、超参在测试集上调的。这一节讲讲怎么把评测做得经得起推敲。

6.1 评测协议里最容易作弊的三个点

第一个点是只报最终任务精度。这是最典型的自欺欺人,因为最后学的任务精度通常最高,报它能掩盖全部遗忘。正确的做法是报所有任务的精度矩阵,或者至少报平均精度和遗忘率两个指标。

第二个点是超参在测试集上调。持续学习的超参(尤其是 β)对结果影响很大,如果拿最终平均精度去选 β,等于把测试集信息泄露进了训练。规范做法是留一个验证任务序列,或者用每个任务训练时的验证集选超参,绝不碰最终评测用的测试集。

第三个点是任务顺序只跑一次。顺序对结果的影响能到十几个点,单次实验的结论没有统计意义。至少要跑三到五个不同的随机顺序,报均值和标准差。我自己踩过这个坑:某个方法在特定顺序上比 baseline 高 5 个点,换了顺序之后反而低 3 个点,最后发现是顺序的偶然性。

6.2 一套可复用的指标表

常用的三个指标,定义如下:

指标含义计算方式关注点
ACC学完全部任务后的平均精度对每个任务 i,用最终模型在其测试集上评测,取平均整体表现
BWT后向迁移,衡量遗忘程度(1/(T-1)) · Σ_{i<T} (R_{T,i} - R_{i,i})负值越大遗忘越严重
FWT前向迁移,衡量旧任务对新任务的帮助(1/(T-1)) · Σ_{i>1} (R_{i-1,i} - R_{0,i})正值表示有正迁移

其中R_{j,i}表示学完第 j 个任务后在第 i 个任务上的精度。贝叶斯方法通常在 BWT 上优势明显(遗忘少),但 FWT 可能不如回放类方法——因为它没有显式利用旧数据。这个取舍要提前想清楚:如果你的场景更看重"不遗忘",贝叶斯路线合适;如果更看重"新任务学得好",回放路线可能更优。

6.3 与 EWC、Replay、正交子空间方法的对比结果解读

我做过一组横向对比,任务序列是 8 个文本分类任务,基座是中等规模的语言模型,所有方法都用相同的 LoRA 配置。结果大致是这样的:朴素顺序微调的 ACC 在 0.55 左右,BWT 约 -0.35;EWC 能到 0.62,BWT -0.25;简单的经验回放(每个任务留 5% 数据)能到 0.70,BWT -0.12;正交子空间方法在 0.66 左右,BWT -0.18;贝叶斯 LoRA 在 0.68 左右,BWT -0.15。

这组数字最值得说的不是排名,而是贝叶斯 LoRA 的独特价值不在 ACC 本身。它的 ACC 略低于回放,但它有两个回放做不到的能力:一是不需要存旧数据,在数据不能留存或存储受限的场景下是硬优势;二是推理时能给出预测不确定性,用熵可以做任务识别。我单独测过无任务 ID 的推理:用预测熵做路由,贝叶斯 LoRA 的任务识别准确率能到 85% 以上,而普通 LoRA 只有 40% 左右(基本靠猜)。这个能力在多任务部署里非常实用。

还有一点要提醒:贝叶斯方法的方差估计质量高度依赖校准。如果模型本身过度自信(现代大模型普遍如此),后验方差会被系统性低估,熵的区分度下降。我的处理是在验证集上做一次温度缩放,把置信度校准一下,熵路由的准确率能再涨几个点。

7. 一个我认为值得强调的实践细节

写到这里,最后分享一个我在实际项目里反复验证过的做法,可能比前面任何一条都实用:不要一上来就上完整的贝叶斯,先用对角拉普拉斯跑一遍诊断。

具体流程是:先用普通 LoRA 顺序训练,每学完一个任务,用当前任务的验证集估一次对角 Fisher,存下来。然后看这些 Fisher 的分布——如果发现某些参数的 Fisher 在多个任务上都很大,说明它们是被多个任务共享的关键参数,值得用 KL 保护;如果 Fisher 很分散,各任务关心不同参数,那参数隔离(每个任务一个 LoRA)可能比贝叶斯更简单有效。这个诊断花不了多少时间,但能帮你判断"贝叶斯到底值不值得上",避免为了用方法而用方法。

我自己在这个判断上走过弯路。有一个项目,任务之间差异极大,我硬上贝叶斯,调了两周效果都不如简单的模块隔离。后来回头看 Fisher 分布,发现各任务的 Fisher 几乎不重叠,本来就该用隔离方案。贝叶斯不是万能的,它在"任务共享底层表征、需要软性保护"的场景里才真正发光;任务之间泾渭分明的时候,硬隔离反而更干净。

如果后面要继续往下做,我觉得有两个方向值得投入:一是把后验方差用起来做动态秩分配,重要方向给高秩、次要方向给低秩,让 LoRA 的容量自动适配任务复杂度;二是把贝叶斯和轻量回放结合,用回放的数据来校准后验,而不是只靠 KL 约束。第一个方向解决的是容量固定带来的天花板,第二个方向解决的是纯贝叶斯方法在任务差异极大时的失效问题。这两个我都在做初步实验,有结果再单独写一篇。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询