不改Loss只改采样:数据侧四旋钮攻克大模型灾难性遗忘

📅 发布时间:2026/10/17 7:57:25
不改Loss只改采样:数据侧四旋钮攻克大模型灾难性遗忘
做过大模型微调的兄弟应该都有同感SFT 跑起来loss 曲线下得漂亮心里却发虚——因为新任务分数是上去了旧能力往往在悄悄掉血。这就是典型的灾难性遗忘。我这边连续做过几轮 7B 级别模型的指令微调之后越来越确信一件事与其纠结怎么改 SFT Loss不如先把数据采样这一步吃透。本文要聊的正是这套不改 SFT Loss、只改数据采样的微调方案它在我的内部实验里同时带来了更好的泛化表现和明显更轻的遗忘问题。我会把原理、四组采样旋钮、可以直接抄走的实现代码、实测数据以及踩过的三个坑一次性讲清楚。1. 降遗忘先找瓶颈SFT 的交叉熵到底管不住什么1.1 交叉熵只看局部正确性看不到知识结构标准 SFT 用的就是逐 token 的交叉熵损失模型只需要做一件事把给定前缀的下一 token 预测对。这个目标本身没有任何错它保证了模型在训练集分布内收敛得又快又稳。问题恰恰出在训练集分布这几个字上。微调的时候你喂进来的新任务数据比如某医疗问答集它会把绝大部分梯度贡献集中在医疗领域怎么问答这件事上。每个 batch 都是医疗样本模型每步都在往医疗方向刷新参数。而旧任务比如代码补全、通用逻辑推理它们的知识是分散在旧参数里的训练时如果旧数据没来参与这些参数就会被一步步覆盖变成拟合新分布的原料。我把这个过程类比成健身你请了个只教举铁的新教练他每天盯着你练胸你的深蹲重量自然就掉下去了。交叉熵管的是目标动作做对没有它完全不管知识结构里哪些部分正在被悄悄换掉。想靠这种局部目标约束全局知识分布本来就是不太可能的事。1.2 真去改 Loss 的人后来都后悔了一说到灾难性遗忘很多人的第一反应是改 Loss加正则项、加 KL 散度约束、做权重巩固EWC 那种思路。这些方法我在小规模实验里都试过方向没问题但工程上的代价非常不划算。先说 EWC 这类方法。它要算 Fisher 信息矩阵在大模型上存储和计算开销都不小。而且 Fisher 本身是训练初期的静态快照训练过程中模型参数一直在变这个约束项的质量会逐步衰减你需要定期重算整个训练链路变得极其复杂。再说直接加正则项。比如把旧模型输出和新模型输出做 KL 距离约束超参一调就是半天而且这类约束对所有 token 一视同仁容易把新任务的学习能力也一起压住。更关键的是生产环境下 SFT 之后往往还要接对齐环节比如偏好优化、RLHF 链路你改了 SFT 的 loss下游接口和对照实验全部要跟着动。这个连锁反应工程上相当难受。所以只改数据这步棋的价值在于它把解法限制在了数据管线内部不碰模型代码不碰损失函数训练框架完全不用动。哪怕效果只是接近改 Loss 的方案它的可维护性也足够让我优先选它。2. 数据侧采样的四个旋钮比例、难度、时序列、重复2.1 旋钮一领域比例防止新领域喧宾夺主第一个要控制的是数据集的领域占比。很多微调脚本是这样写的把所有数据拼在一起打乱训完。这就埋了一个隐性问题——新任务数据如果有 8 万条旧任务重放数据只有 2 万条模型看到的比例是 4:1它不是学新任务顺便复习旧任务而是几乎只在学新任务。我的做法是直接给每个领域定一个目标占比。比如新任务数据量 8 万重放旧任务代码数据 4 万通用对话数据 2 万让比例从自然状态的 4:2:1 调整为目标占比 5:3:2。这里的关键不是把数量堆到多少而是让训练过程每个 batch 看到的是规划过的领域混合而不是顺其自然的领域混合。这个旋钮解决的其实是重心偏移问题模型在哪个分布上待的时间久参数就会被拉向哪个分布。领域比例就是控制这个时间分配的。2.2 旋钮二难度感知采样用 Loss 分布当体检报告比例解决分配难度解决每一条样本值不值得学。这是整套方案里我觉得信息量最大的一部分。所谓难度感知就是拿模型当前在每条样本上的 loss 值作为这条样本对模型而言难不难的信号。实际操作里我会在训练过程中维护一个长度为 4096 的 loss 缓冲池每隔固定步数统计一下分布然后把样本分成几个区段最低 10% 的样本模型已经学得很好再给它们加权边际收益很低权重降一点。中间 40% 到 70% 的样本处于刚好够得着的可学习区这是训练价值最高的部分权重调高。最高 10% 的样本要么是噪声样本要么是模型当前能力根本学不通的硬样本权重也要降避免训练被带偏。这个思路有点像课程学习但更细一点它不是简单先易后难而是动态识别模型当下最能进步的区间把算力集中在这里。本质上是用 loss 分布当体检报告来决定训练资源该往哪使劲。2.3 旋钮三时序列课程式重放老知识第三个旋钮是数据在时间轴上的排布。很多人重放旧数据是均匀混进每个 epoch 的但我试下来前重后轻的时间安排会更稳。具体做法是把训练过程分成三个阶段前 20% 的训练步数里重放数据占比高一些帮助模型在参数还没被新任务大幅改写之前先把旧知识的锚点稳住中间 60% 正常混合新任务开始占据主导最后 20% 再略微增加重放占比做一次收尾巩固。这背后的逻辑很朴素训练初期模型参数离旧分布最近此时用旧数据约束成本最低训练后期新任务学得差不多了再补一批旧数据相当于做参数合并前的校准。就这么一个简单的时序安排遗忘指标能再往下拉几个点。2.4 旋钮四重复预算一个反直觉的数字最后说重复次数。很多人都以为重放的旧数据反复训练好几遍应该能记住更多。我的结论是别重复越多遗忘反而越奇怪。旧任务数据被反复重放确实稳住了旧能力但代价是新任务的学习变得拖沓。我在实验里对比过 1 遍、2 遍、4 遍重放结果旧任务保留率几乎没区别新任务反而降了一截收敛速度也明显变慢。后来我想通了旧数据的作用是维持一个约束梯度的方向这个约束只需要存在不需要无限强化。一旦约束过强它就从一个刹车变成了路障。所以我的经验值很明确重放数据整体只过 1 到 1.5 遍左右配合上面三个旋钮一起用旧数据的作用会被充分激发又不会压住新任务的学习。这个够用就好的权衡在数据侧要比在 Loss 侧容易把控得多。3. 实验配方与关键实现可直接抄3.1 三套数据配方的完整清单为了让只改采样这个结论站得住我设计了三套严格对照的数据配方训练用的模型、参数量、学习率、评测口径完全一致差别只在数据采样策略上。配方名数据构成采样策略Baseline只加 8 万条医疗问答新数据默认随机 shuffle无任何加权Naive Replay8 万医疗 4 万代码重放 2 万通用对话重放全部数据拼接后随机打乱不加权重FSSFocused Sampling Strategy同样是 8 万医疗 4 万代码 2 万通用对话领域目标比例 难度感知权重 时序重放 1 遍重复预算这里要强调一下Baseline 不是拿来衬托我的它是很多微调项目真实在用的做法——你拿到一个新任务数据直接拼进训练集就开训了。Naive Replay 也是很多团队做防遗忘时第一反应用的方法以为把旧数据混进去就完事。而 FSS 和前两者的差距正是在采样细节上。3.2 权重计算和动态采样器的实现实现部分我给两段可以直接抄进自己代码里的核心逻辑。第一段是采样权重计算输入每一条样本的领域标签、难度分数和目标领域占比输出一个采样权重数组。import numpy as np from collections import Counter from torch.utils.data import WeightedRandomSampler def build_sampling_weights(dataset, domain_target_ratios, alpha0.8, beta0.4): total len(dataset) cnt Counter(item.domain for item in dataset) weights np.ones(total, dtypenp.float64) for idx, item in enumerate(dataset): domain item.domain # 旋钮一领域比例补偿让每个 batch 的领域占比接近目标值 current_ratio cnt[domain] / total target_ratio domain_target_ratios[domain] w_domain (target_ratio / current_ratio) ** alpha # 旋钮二难度感知中间区间加权两极降权 difficulty getattr(item, difficulty_score, 0.5) if difficulty 0.1 or difficulty 0.9: w_diff 0.6 elif 0.4 difficulty 0.7: w_diff 1.3 else: w_diff 0.9 weights[idx] w_domain * w_diff # 归一化避免权重差异过大导致采样震荡 weights weights / weights.mean() sampler WeightedRandomSampler(weightsweights, num_samplestotal, replacementTrue) return sampler, weights第二段是训练过程中动态更新难度分数的逻辑。这里的核心是难度分数不是静态的它要跟着模型能力走。刚开始模型对很多医疗样本都一知半解过两轮之后可能就变成简单样本了。所以我在训练循环里每 100 步做一次更新from collections import deque loss_buffer deque(maxlen4096) for step, batch in enumerate(train_loader): outputs model(**batch) loss outputs.loss loss.backward() optimizer.step() # 记录每条样本的 loss用于更新难度分数 with torch.no_grad(): per_sample_losses compute_per_sample_loss(model, batch) loss_buffer.extend(per_sample_losses.tolist()) if step % 100 0 and len(loss_buffer) 1024: all_loss np.array(loss_buffer) low np.percentile(all_loss, 10) high np.percentile(all_loss, 90) # 把样本 loss 映射到 0~1 的难度值供下一轮采样使用 dataset.update_difficulty_scores(low, high) train_loader.sampler.weights torch.from_numpy( recalculate_weights_from_scores(dataset) )注意这里的坑难度分数更新不是每个 step 都做太频繁权重会抖也不是完全不做否则难度信号就过时了。100 步一次的频率配合 4096 的缓冲池是我试下来最稳的组合。3.3 训练配置与评测口径整套实验跑在一个 7B 参数的开源底座模型上LoRA 微调rank 64学习率 2e-4max length 3072batch size 128训练 3 个 epoch。评测口径分三块新任务医疗能力用选择题准确率旧任务代码能力用补全类任务的 Pass1通用能力用一组涵盖逻辑推理、常识问答和指令遵循的混合评测集折算成一个 0 到 100 的通用对话分。这套配置本身没什么特别重要的是评测集在实验过程中完全冻结。我见过不少项目在调采样权重的时候顺手调评测集结果就是调出来的最优权重只对那一个评测集有效换一个场景立刻拉胯。4. 三组实测泛化与遗忘曲线的真实变化4.1 新任务上限FSS 几乎没有牺牲提升空间很多人第一反应是又是重放旧数据又是难度降权新任务肯定学不好了。实测数据恰恰相反。三组配方的医疗选择题准确率分别是Baseline 78.2%Naive Replay 74.5%FSS 76.7%。也就是说 Naive Replay 那种无脑把旧数据拼进去的做法确实把新任务拖累了 3 个多点。但 FSS 只掉了 1.5 个点而且这 1.5 个点换来了旧任务的大幅保留这个性价比非常高。我一开始也担心过 1.5 个点的损失后来想明白了这 1.5 个点的差距正是在旧知识约束下学习新知识的必要成本。关键是你愿不愿意用这 1.5 个点的上限去换旧任务十几个点的保留。在生产环境里这个取舍几乎不用犹豫。4.2 旧任务遗忘从掉 12 个点到掉 1.5 个点的差距真正拉开差距的是旧任务表现。三组配方的代码补全 Pass1 分别是Baseline 69.3%Naive Replay 75.8%FSS 79.5%。Baseline 在微调前代码 Pass1 是 81.2%也就是说只训医疗新数据的对照组代码能力直接掉了近 12 个点这个量级在业务落地时往往是致命的。Naive Replay 通过简单拼接旧数据把损失收窄到了 5 个点左右但代价是新任务掉了 3.7 个点属于拆东墙补西墙。FSS 的代码 Pass1 只掉了 1.7 个点通用对话分甚至出现了小幅上涨从 81.2 涨到了 82.6。这个我后来分析可能是因为难度感知采样过滤掉了一批质量可疑的样本模型在通用对话场景下的鲁棒性反而更好了。4.3 意外收获难度感知采样本质上做了一次样本质量管理最后发现这个收获纯属意外。我本来以为 FSS 的优势主要来自领域比例和时序重放后来把各旋钮单独拆开做消融实验才发现难度感知采样自己就贡献了旧任务 3 个点的保留率。原因是这样loss 最高的那 10% 样本在新任务数据里往往是标注噪声、重复模板、超长截断样本在旧任务重放数据里则是那些本来就超出模型能力范畴的极端样本。把它们的权重降下来训练过程就少了很多吞咽脏数据的机会。换句话说难度感知采样不只是分配学习精力它把数据清洗的工作也顺便做了一部分。所以我在后续项目里哪怕不搞那么复杂的加权也会尽量保留这一条按 loss 分布给两端样本降权至少它能保证你的训练集中不会有一批毒样本在持续输出干扰梯度。5. 落地过程中踩掉的三个坑5.1 第一个坑比例权重算错分母隐性不平衡比显性更可怕第一次实现领域比例补偿时我直接把目标占比 / 当前占比当权重结果训练出来新任务和旧任务都不对劲。后来排查发现问题出在 WeightedRandomSampler 的 num_samples 参数上。PyTorch 的 WeightedRandomSampler 里num_samples 设成数据集总长度只是决定了每个 epoch 采多少个样本。但当你设置 replacementTrue 时每个 epoch 内的采样分布会遵循权重可跨 epoch 的重复次数却不受控制地叠加了。也就是说你以为自己在控制领域比例实际上一部分样本被反复采样另一个领域样本反而被饿得很厉害。修复方法也很简单把权重做一次归一化校准然后单独记录每个 epoch 里各领域实际被采样的次数用日志检验比例是否符合预期。代码跑起来之后不能只看 loss该看的领域采样日志一眼都不能省。5.2 第二个坑难度分数训练中流动固定权重会导致策略彻底失效难度分数是会变的。模型从第 1 个 epoch 到第 3 个 epoch对同一批医疗数据的 loss 可能从 0.8 掉到 0.3。如果我只在训练开始时算一次难度分数然后权重固定不动那么到训练后期中间难度区间里的样本早就变了我采样的还是一份过期的难度地图。最明显的现象是训练后期采样权重越来越失去区分度loss 曲线看着正常但新任务的收敛速度明显变缓。我在第三轮实验时才发现这个问题加上了滑动窗口动态更新难度分数的逻辑才把训练后期的效果拉回来。其实这也解释了为什么只改数据在某些团队里推广不开——不是思路不对而是他们用的是静态权重方法只执行了一半效果自然只有一半。5.3 第三个坑评测集来回调方差大到你根本分不清哪个配方更优最后这个坑不在方法本身在评估环节。三组配方的最终分数差距新任务不过 3 个点左右旧任务重放策略差距也就 5 个点上下。如果你的评测集只有几百条样本跑一次出来的分数方差可能超过 2 个点那这个差距根本没法置信。我一开始就吃了这个亏头两轮实验的评测分数忽高忽低我都快以为采样策略是玄学了。后来把评测集扩充到千条级别并且每组配方固定随机种子跑 3 次取平均才终于看到稳定可复现的差异。这个经验分享出来是给所有玩数据采样的同行——方案好不好先确保你的尺子足够准否则一切结论都是噪声。最后再分享一个小技巧。在整套方案跑通之后我把难度感知采样里维护 loss 缓冲池的逻辑保留了下来单独抽成了一个训练数据质量看板。每 100 步输出的 loss 分布、最高 loss 样本、最低 loss 样本的分布趋势直接能帮我提前发现数据标注质量问题。这个改动成本很低但带来的数据管线收益比采样策略本身还大。如果你也正在被遗忘问题和数据质量问题同时折磨建议先从这个最小的改动开始试。