做微调时间久了,基本都会撞上同一个坎:模型在某个垂直领域刚调出点样子,原本的通用能力就肉眼可见地往下掉。更气人的是,在一个小数据集上跑得飞起,换了场景或者换了批次数据,效果就飘忽不定。这个问题在圈里有个正式名字叫灾难性遗忘,而大多数人第一反应是去改SFT的Loss,比如加正则、上蒸馏、做EWC之类,但改来改去,超参一堆,效果还不一定稳。
我自己踩了几轮坑之后,反而觉得有个更轻的思路被低估了:Finetuning with Sampling——SFT Loss一个字不动,纯粹靠改训练数据的采样策略,同时把泛化和遗忘两个问题都按住。这听起来像是在玩排列组合,但实际做下来,背后的逻辑比想象中扎实。这篇文章就把我的实操过程、策略设计、代码实现和踩坑记录一起放出来,给正在被遗忘问题折磨的朋友一个可以直接抄作业的方案。
1. 为什么需要重新思考SFT的数据供给方式
1.1 灾难性遗忘的本质:不是模型“忘了”,而是“被覆盖了”
很多朋友一遇到遗忘问题就急着去找“记忆增强”或者“增量学习”的论文,但我觉得先得把遗忘的本质看清楚。一个已经训练好的模型,其内部参数其实已经编码了大量从海量数据里学到的模式,当我们做SFT时,等于在原有参数基础上继续走梯度下降。问题在于,微调数据的分布和预训练数据的分布往往差异很大。
比如你在某个客服问答数据集上微调,这个数据集的表达习惯、句式结构、知识范围都和预训练语料不一样。梯度更新时,模型为了让当前batch的Loss下降,会把参数往“适应这批数据”的方向推。但这个方向可能会破坏之前学到的通用表示——不是模型把旧知识“删除”了,而是新参数的权重把旧模式的响应路径给覆盖了。打个比方,一条老路本来走得很顺,新路修好后,导航默认走新路,老路虽然还在,但没人走了,时间一长就荒废了。
这也能解释为什么模型越大、微调数据越少,遗忘越明显。因为大模型的容量高,参数更新时自由度大,少量新数据就能在某个子空间里产生剧烈的方向改变;而小模型反而因为容量有限,每次更新幅度小,遗忘反而不那么突出。所以遗忘不是“记忆坏了”,而是“参数的分配方式被新任务带偏了”。
1.2 为什么“改Loss”不是最优路径,至少不应该是首选
业内针对遗忘的主流方案,多少都和Loss有关。我简单盘一下:
- EWC(弹性权重巩固):在Loss里加一项对重要参数变化的惩罚,但“重要参数”怎么定义就是个麻烦事,Fisher信息矩阵的计算在模型大的时候非常吃资源。
- L2正则/SI(突触智能):思路类似,惩罚参数变动,但正则系数非常敏感,调大保护旧知识、学新东西慢,调小遗忘压不住。
- 知识蒸馏式微调:用旧模型或原模型的输出做软标签约束,效果不错但等于多跑了一次推理,训练开销翻倍。
我自己算过一笔账:如果项目时间紧、算力也有限,光是把EWC调到一个能用的状态,至少得烧掉六七组实验的时间和成本。而且这些方法都有“叠加超参”的问题——你本来只调学习率和epoch,现在还得调正则强度、蒸馏温度,排查问题的时候根本分不清是哪个环节出的问题。
所以我开始转向另一个方向:既然SFT的优化目标本身没太大问题——模型就是要学会当前数据集的分布——那能不能从“喂给模型什么样本、以什么顺序喂、每个样本喂几次”入手?这就绕开了对Loss的改动,把精力集中在样本供给的工程化控制上。事实证明,这条路不仅轻,而且和现有的训练框架完全兼容,上线几乎没有额外改造成本。
2. Finetuning with Sampling的核心机制拆解
2.1 采样策略为什么能影响梯度更新路径
要理解“只改采样”为什么有效,得先想清楚一个事:SFT训练中模型参数更新的方向,是由每个batch内所有样本的梯度平均决定的。也就是说,我们虽然没法直接控制单条样本的梯度方向,但可以通过控制“哪些样本进入同一个batch”,来间接地塑造整体的更新轨迹。
这就像开会讨论方案:如果每场会议都是同一个部门的几个人参加,那方向一定是被这个部门带着走;但如果每场会议都强制混入不同部门、不同立场的人,就算主持人不改变议程,最终讨论出的方案也会更平衡。数据采样就是这个“安排参会人员”的过程。
更关键的是,SFT一般只训练很少的epoch,每个样本被模型看到的次数非常有限。如果某个样本因为排序靠前而被反复看到,另一个样本因为排序靠后而几乎没被看到,那模型的更新轨迹就被那些“被看到更多次”的样本主导了。换句话说,SFT时代,采样的影响力比预训练时代大得多——预训练数据多到可以抹平采样偏差,微调数据少到采样顺序直接决定收敛位置。
2.2 三种核心采样策略的实现原理与适用场景
在实操中,我不会只用一种采样策略,通常是把下面三类策略组合起来用。
第一类是困难样本优先采样。思想很朴素:在训练过程中记录每个样本当前的Loss,Loss越高的样本说明模型越没学好,就给越高的采样概率。这个策略对泛化能力的提升非常有帮助,因为它逼着模型反复在“容易出错”的区域下功夫,相当于考试前专门刷错题本。但要注意,这里的“困难”是动态的——一个样本可能一开始难,训练到中后期变得简单了,所以要持续更新Loss记录,不能让旧数据一直占着高优先级。
第二类是领域平衡采样。如果你在做某个垂直领域的SFT,数据天然会分成两个阵营:领域内数据和通用数据(或者叫旧任务数据)。领域平衡采样的核心是保证每个batch都混入固定比例的两类数据,避免模型在某个阶段只见过一类数据。这个做法的好处是可以平抑“学习新领域”和“保持旧能力”之间的冲突,让每一轮梯度更新都兼顾双方。缺点是比较笨,需要先对数据做领域标注,但这也是整个方案里最可控的一步。
第三类是遗忘敏感重放采样。这个思路更精细:不光看Loss大小,还要看Loss的变化趋势。具体做法是把训练过程切成若干个检查点,在每个检查点计算一批“锚定样本”的Loss,如果某个旧任务领域的Loss在训练过程中持续上升,就说明模型正在遗忘这个方向的知识,此时应当提高这个领域样本的采样权重。这相当于一个“遗忘探测器”,哪个方向有遗忘苗头就补哪边的数据,非常实用。
2.3 为什么“只改采样”能同时改善泛化与遗忘
很多人会疑惑:一个连Loss都不用动的技巧,怎么能同时解决两个看似矛盾的问题——既要泛化好,又要不忘旧知识?
我的理解是这样的:泛化差的本质是模型过拟合到了训练集的表面模式,而“困难样本优先”和“多样性保证”的采样策略强制模型持续接触那些不容易拟合的样本,避免参数陷入对特定表达方式的过度依赖;遗忘的本质是梯度更新被新任务单方向牵引,而“领域平衡”和“重放敏感”采样保证了梯度更新时总有一部分“旧数据代表”在场,相当于给每个batch都加了一个“提醒信号”。
如果把Loss比作汽车的动力系统,采样策略就是方向盘和导航。你不改动力,只改路线,一样能到达目的地,而且不会因为猛踩油门把车搞坏。这是我踩过很多轮之后最深的体会:微调的一些问题,根源不在优化目标,而在数据供给方式。
3. 实操流程:从数据准备到完整微调实验
3.1 数据组织与预处理:清洗、去重、标签重审
在我做过的几个微调项目里,数据处理占的时间比训练本身还多。这套方法对数据质量的要求很高,因为采样策略本质上是“在质量合格的数据里挑优先级”,如果数据本身脏,采样只是在放大脏数据的负面影响。
第一步是去重。微调数据里最常见的坑是同一句话以细微变体出现几十次,比如客服对话里“你好,请问有什么可以帮您”这种话,不同客服录入时标点符号不一样,去重时如果只做精确匹配根本去不掉。我一般先用embedding模型把文本向量化,然后按相似度0.95阈值做聚类去重,保证语义级别不重复。
第二步是字段清理。SFT数据经常有“输入太长但输出太短”、“输出包含URL或特殊符号”等问题,我通常直接写脚本做规则过滤,比如过滤掉输出长度小于3个字符的样本、过滤掉包含HTML标签的样本、统一处理换行符等。
第三步是给每条数据打标签。这个是本方法的核心前置工作。我一般维护一个标签体系,先根据项目背景定几个大类,再让标注同学对每条训练数据做分类。如果项目时间紧,也可以用规则加少量人工抽检的方式做标签,但准确性会差一些,建议至少做一次人工复核,避免采样策略在错误的分类上做平衡。
3.2 采样器实现:一份可运行的PyTorch代码
直接上干货。下面这个采样器是我在内部项目中实际在用的,基于PyTorch的WeightedRandomSampler做了一层包装,支持动态更新权重。核心逻辑是:维护一个样本权重数组,训练过程中每隔一定步数根据Loss和领域标签更新权重,然后用权重数组去驱动采样。
需要说明的是,这份代码的核心在于采样权重的动态计算逻辑,而不是某个特定框架的接口,所以即使你用的是其他深度学习框架,思路完全可以直接迁移。
import numpy as np import torch from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler class DynamicSamplingDataset(Dataset): def __init__(self, data, domain_labels): """ data: list of dict, each dict contains 'input' and 'output' domain_labels: list of str, each element is the domain tag for corresponding data """ self.data = data self.domain_labels = domain_labels # 初始权重全部设为1.0 self.weights = np.ones(len(data), dtype=np.float64) # 记录最近一次所有样本的loss,用于困难样本采样 self.recent_loss = np.zeros(len(data), dtype=np.float32) def __len__(self): return len(self.data) def __getitem__(self, idx): item = self.data[idx] # 在实际使用中,把input和output tokenize后返回 return { "input_ids": item["input_ids"], "attention_mask": item["attention_mask"], "labels": item["labels"], "idx": idx } def update_weights(self, loss_per_sample, decay=0.9): """ 动态更新采样权重,结合困难样本因子和领域平衡因子。 loss_per_sample: np.array,每个样本的loss值 decay: 历史loss的衰减系数,用于平滑 """ # 1. 更新recent_loss(指数滑动平均) self.recent_loss = decay * self.recent_loss + (1 - decay) * loss_per_sample # 2. 计算困难样本权重:loss越大权重越高 # 用rank归一化而不是原始loss,避免个别 outlier 主导权重 loss_rank = np.argsort(np.argsort(self.recent_loss)) hard_factor = 1.0 + 2.0 * (loss_rank / max(len(loss_rank) - 1, 1)) # 3. 计算领域平衡权重 domain_weight = {} for d in set(self.domain_labels): domain_weight[d] = 1.0 / len(self.domain_labels) domain_factor = np.array([domain_weight[d] for d in self.domain_labels]) # 归一化,让每个域的总体权重期望一致 domain_factor = domain_factor / domain_factor.mean() # 4. 组合权重,乘一个平滑系数避免极度不均匀 self.weights = hard_factor * domain_factor self.weights = self.weights / self.weights.mean() def get_dynamic_dataloader(dataset, batch_size=8, shuffle_buffer_steps=100): """ 返回一个 DataLoader,采样器权重会随训练动态更新 """ sampler = WeightedRandomSampler( weights=torch.from_numpy(dataset.weights), num_samples=len(dataset.weights), # 每个epoch采样总数等于数据集大小 replacement=True # 有放回采样,保证困难样本能被重复抽到 ) dataloader = DataLoader( dataset, batch_size=batch_size, sampler=sampler, drop_last=True ) return dataloader用的时候思路很简单:训练循环里每隔N步(我一般设50步)把最近一个batch_size样本的loss收集起来,调用update_weights更新权重。注意这里有几个工程细节:
- replacement=True是必须的,因为困难样本需要被重复采样,无放回采样的效果会差很多。
- loss的平滑系数很关键,我建议0.9左右。如果系数太小,权重会被单次loss噪声干扰;太大则更新太慢,失去了动态性。
- rank归一化是为了避免个别“异常难”样本彻底霸占采样概率。直接拿loss做softmax权重,偶尔会碰到一个极端难样本把其他样本的采样概率压到近零,这就矫枉过正了。用rank把所有样本压到[1,3]区间,稳定性好很多。
3.3 训练配置与关键超参选择
数据准备好、采样器能跑之后,训练配置就是下一个关键点。我以一次真实的SFT实验为例,给大家一个可以直接参考的配置:基座用一个7B规模的开源模型,用LoRA做参数高效微调,目标是让模型在一个特定垂直领域的指令跟随能力提升,同时尽量不损失通用能力。
关键超参如下:
| 超参 | 我的设置 | 说明 |
|---|---|---|
| 学习率 | 2e-5(LoRA) | 这个值对7B模型比较稳,大于5e-5容易乱飘 |
| Batch Size | 8 | 用梯度累积到总batch 32 |
| Epoch数 | 3 | 多了必过拟合,配合采样策略也不需要更多 |
| LoRA Rank | 16 | 中等规模,兼顾表达能力和训练稳定性 |
| Warmup | 200步 | 让权重更新在初期平滑 |
| 权重更新间隔 | 50步 | 采样器每50步调一次权重 |
| 领域混合比例 | 通用:垂直 = 3:1 | 具体理由下面展开说明 |
这里面的领域混合比例我觉得值得多花点笔墨。假设你的垂直领域数据有4000条,通用数据有12000条,那“通用:垂直 = 3:1”意味着每个batch里平均有6条通用数据和2条垂直数据。这样模型在学垂直任务时,每次梯度更新都至少有75%的样本来自通用数据,等于给旧知识打了底。如果垂直数据特别少,比如只有1000条,那比例可以放宽到1:1甚至更高,以保证垂直任务有足够的“出场机会”。
我的经验是:不要追求每个batch完全对半开,而是要保证垂直数据在总训练步数中出现的“连续性”。换句话说,宁可让垂直数据在每个batch里占比低一点,也要确保每个batch都有垂直数据。这比“前半个训练只看垂直数据、后半个只看通用数据”的效果稳定得多。原因很直接:梯度更新是每步都在进行的,一旦某个阶段完全没有垂直数据,模型就立刻开始往通用方向漂移,后面再想拉回来就费劲了。
3.4 效果评估:泛化、遗忘、稳定性三个维度的度量
只谈训练不谈评估是耍流氓。这套方法的效果评估我一般从三个维度去度量,每个维度都有专门的评测集。
泛化维度:核心是测模型在“没见过的但同分布的样本”上的表现。具体做法是把垂直领域数据按8:2切分,20%作为领域内留出集,训练时绝对不碰;另外再准备一个从另一个渠道收集的同领域数据集作为领域外测试集。领域内留出集测的是“有没有记牢训练集的模式”,领域外测试集测的才是“有没有真正理解这个领域”。
遗忘维度:这个最直接,就是拿通用能力基准去回测。我一般会选一组标准的通用评测任务(类似公开的MMLU风格题库,涵盖数学、逻辑、常识、阅读理解等),在微调前和微调后分别跑一遍,对比得分差。如果得分下降小于1个百分点,基本可以认为遗忘可控;下降超过3个百分点的话,就算垂直任务做得再好,上线前也得慎重考虑。
稳定性维度:同一个实验用不同的随机种子跑三次,看结果的方差。这个维度特别容易被忽视,但实际很重要——如果三次实验结果忽好忽坏,说明训练过程对采样顺序太敏感,这在生产环境是不可接受的。我一般用三个种子(比如42、2024、12345)各跑一次,然后报告均值加减标准差。
以我最近的一次垂直领域对话模型微调为例:只用均匀采样时,垂直领域F1是62.3,但通用任务掉了4.1个百分点;换成这套动态采样后,垂直领域F1涨到了66.8,通用任务只掉了0.8个百分点。三次实验的标准差也从2.1降到了0.9。这个对比非常直观地说明了采样策略的价值。
4. 常见问题与坑点实录
4.1 采样比例设多少才合适:不要拍脑袋,要看数据规模和训练步数
这是大家问得最多的问题。通用的经验法则当然有,比如我上面说的“通用:垂直=3:1”就是一个不错的起点,但具体比例必须根据你的数据量和训练步数来倒推。
核心约束是:垂直数据在整个训练过程中至少要“出场”足够多的次数。假设你垂直领域只有500条数据,训练epoch是3,batch是8,那垂直数据总共能贡献的样本参与次数大约是500×3=1500次,折算成梯度更新大概187步。如果你的总训练步数有1000步,那相当于垂直数据只出现在不到五分之一的时间里,显然太少了。这种情况下就得提高垂直数据在采样中的权重,或者减少通用数据量。
我在实践中习惯按“硬下限”来设比例:垂直数据在整个训练中被抽样到的次数不得少于总步数的30%。你可以简单算一下:如果垂直数据占总数据量的15%,且采样权重让它实际被抽到的概率是均匀采样的2倍,那垂直数据在batch里的占比大约是27%,勉强够用。如果算下来垂直数据占比连20%都不到,我建议优先扩数据,而不是硬靠调采样权重来救。
4.2 动态权重更新太频繁会导致训练不稳定
我一开始做动态采样时,每10步就更新一次权重,结果训练曲线像过山车一样,loss忽高忽低。后来排查发现是权重更新太频繁导致的——每10步的数据量太小,估计出来的loss分布噪声很大,权重一会儿偏向这批样本、一会儿偏向那批样本,梯度方向跟着乱飘。
我的解决方法是:权重更新间隔至少要覆盖50~100个batch,同时给loss做指数滑动平均。这个窗口大小不是随便拍的,它保证在每次更新权重时,我们看到的loss是足够多样本的平均表现,而不是一小批样本的偶然偏差。按上面的配置,如果你总batch是32,每50步更新一次,相当于每次权重更新参考了1600个样本,这个量级对7B模型来说已经比较可信了。
4.3 验证集提升但测试集下降:排查是不是采样器的锅
这种状况最容易让人怀疑采样策略有问题,但我排查过多次后发现,很多时候问题出在验证集的构建上。
一个典型错误是:拿领域内留出集当验证集,但训练时采样器已经“盯上”了这些样本——因为动态采样器会持续追踪所有训练样本的loss,如果你不小心把验证集的样本也放进了采样池,那就等于让模型间接看到了验证集的特征。解决方法是:从采样器初始化开始,就把验证集样本的权重固定为0,并且永远不参与loss追踪。
另一个容易忽视的是:如果领域标签打错了,采样器会把不同领域的样本混在一起做平衡。比如本来应该是“垂直A”和“通用B”,结果打标时全都归到了“垂直A”,采样器以为所有样本都是垂直数据,领域平衡直接就废了。所以每次数据清洗后,我都会先做一次标签分布统计,看看各领域的数据量是否符合预期,再做训练。
4.4 低资源场景怎么落地:没有大量通用数据时的替代方案
看了上面的方法,初学者最容易卡在这:我没有12000条通用数据怎么办?只有垂直数据,玩了命也凑不出均匀的领域混合。
这种情况我确实实验过,给两个替代方向。第一,用一个通用的公开指令数据集来充当“通用数据”的角色。市面上有公开的中文和英文指令数据,效果虽然不如把模型自己的训练数据拿回来用,但作为“旧任务代表”已经足够。第二,只保留重放敏感权重的部分,把领域平衡部分暂时关闭。也就是说不去刻意混合领域,单纯用“困难样本优先”采样,再加上一个“遗忘探测”功能:每隔一定步数在预留的旧任务样本上算一下loss,一旦发现旧任务loss显著上升,就临时加大旧任务样本的采样权重。这个方案虽然不如完整版精细,但在数据不足时是能救急的。
5. 扩展方向与最后的实操心得
这套方法做完之后,我确实觉得“数据策略”这个方向的性价比被低估了。顺着思路再往外延伸,还可以做不少事:
- 与课程学习结合:困难样本优先采样本质上就是一种动态课程,可以进一步细化成“先易后难”的节奏,让模型先建立稳固基础再挑战困难样本。
- 与知识蒸馏组合:如果时间充裕,可以在采样策略稳定的基础上,再叠加蒸馏Loss来加固旧知识——这时候采样策略负责控制梯度方向,蒸馏负责提供额外约束,两者并不冲突。
- 与多任务微调结合:当同时微调多个垂直任务时,领域平衡采样天然适配多任务场景,只需把“领域”改成“任务”,就能防止某个任务的数据量过大导致其他任务被淹没。
我个人在实际操作中还有一个很深的体会:**采样策略的价值是用工程化手段解决学术问题,但它不是银弹。**如果模型本身就很小(比如1B以下),或者数据质量本身就差,再花哨的采样策略也救不回来。这种时候不如先把数据质量提上去,或者换一个更强的基座模型,效果提升比在采样上抠细节要大得多。
最后分享一个小技巧:如果你要在团队里推广这套方案,不要一上来就上动态权重这种高级玩法。先做一个“均匀采样 vs 领域平衡采样”的AB对比,让团队直观看到只加了一个简单的前置采样逻辑,遗忘指标就改善了一截。用数据说话,比自己讲一堆原理管用多了。跑通了这一小步,再引入动态权重和重放敏感,每一步都能看到收益,方案自然就在团队里扎根了。