补全模型微调时训练集混进验证集,指标虚高到上线才发现候选全是错的
做补全模型微调,验证集泄漏的典型后果不是「过拟合」三个字能概括的——上线后你会发现模型在真实用户输入上生成的候选几乎不可用,而训练时的 BLEU、Exact Match、perplexity 全都漂亮得不像话。问题往往出在划分方式上:不是随机划分本身错了,而是补全任务的数据结构和通用分类/生成任务不一样,照搬老经验必踩坑。
泄漏的三种主要来源,随机划分解决不了
补全模型微调的数据,每条样本通常长这样:一个前缀(prompt/context),一个或多个标准补全(gold completion)。泄漏发生在三个层面,我按危害程度从高到低排:
第一,同一上下文切出多个训练样本,随机划分后这些样本横跨训练集和验证集。 这是最隐蔽也最常见的一种。假设你有一段 200 行的代码文件,用滑动窗口或基于 AST 的切分生成了 40 条补全样本。随机按 9:1 划分,这 40 条里大概率有 36 条进训练、4 条进验证。模型在训练时已经见过这段代码的前后文,验证集上那 4 条的「补全」实际上是在重复已见过的内容。指标虚高的程度取决于数据重复度:如果你的语料来自少量大文件,重复度极高,验证集 BLEU 可能比真实场景高出 15-25 个点。
第二,训练集和验证集包含同源但不同片段的数据,比如同一个仓库、同一个函数的不同版本。 这个比第一种更难察觉。你可能按文件做了划分,但忽略了文件的派生关系。一个真实的例子:你做 Java 补全微调,语料里同一个类的两个版本分别在不同 commit 中出现,一个进了训练集、一个进了验证集。模型「背」下了旧版本的代码模式,在新版本验证样本上表现自然好。这种泄漏靠文件哈希去重根本查不出来。
第三,验证集里出现了训练集中见过的 token 模式组合,而这个组合在真实分布里几乎不出现。 这个严格说不算传统意义的泄漏,但效果一样:你的验证集不再代表真实分布。补全任务里,领域术语、API 名称、变量命名习惯的共现频率,在单一数据源里往往高度集中。如果你的验证集和训练集来自同一个数据源的不同切片,验证集实际上衡量的是「模型对训练分布的拟合程度」,不是泛化能力。
正确的划分策略:以「上下文单元」为最小隔离单位
核心原则一句话:验证集和训练集必须在「上下文来源」层面完全隔离,而不是在「样本」层面隔离。 具体操作上,我建议按以下顺序执行:
第一步,确定你的上下文单元是什么。 对代码补全,上下文单元通常是文件或函数;对文本补全,通常是文档或会话;对 SQL 补全,通常是 schema 加查询日志的会话块。单元的选择取决于你的数据是如何产生的——一个单元内部的所有样本共享同一个上下文背景,单元之间才视为独立。
第二步,按单元 ID 做分组划分,不是按样本划分。 用 GroupShuffleSplit 或手动实现:先收集所有唯一的单元 ID,按 ID 随机划分训练/验证组,再把每个 ID 对应的全部样本放进对应集合。scikit-learn 有现成的实现:
from sklearn.model_selection import GroupShuffleSplit
# unit_ids: 每个样本所属上下文单元的 ID,长度与样本数一致
gss = GroupShuffleSplit(n_splits=1, test_size=0.1, random_state=42)
train_idx, val_idx = next(gss.split(X, y, groups=unit_ids))
这一步能解决上面说的第一种和大部分第二种泄漏。但对于第二种里的同源派生问题,还需要额外一步。
第三步,对单元做来源级去重。 如果你用 Git 仓库做代码补全数据,同一个文件在不同 commit 里的版本应该视为同一个「来源」,只保留一个版本,或者确保所有版本都进同一侧。实际操作中,我的做法是:先按文件路径聚合,如果一个路径出现过多次(多版本),要么只留最新版本,要么把这个路径的全部样本强制分到同一组。同样,fork 出来的仓库、复制粘贴的代码块,如果来源追踪不到,保守做法是用 MinHash 或 SimHash 做近似去重,相似度超过阈值的单元强制归入同一组。
第四步,验证集规模足够且独立。 补全模型的验证集如果太小(比如少于 500 个上下文单元),指标本身的方差就大,泄漏的边际效应反而被噪声掩盖,你更难发现问题。我自己的经验是:验证集至少 1000 个独立上下文单元,且这些单元对应的来源(仓库、文档集、用户会话)与训练集完全不重叠。
换一个思路:用「未来数据」做验证
如果你的数据带有时间戳,比随机划分更可靠的方式是按时间切分:训练集用时间较早的数据,验证集用时间较晚的数据。这模拟的是真实上线场景——模型在训练时没见过「未来」的代码风格、API 版本、用户表达习惯。时间切分天然规避了同源派生问题,因为同一个文件的新版本只会出现在验证集里,不会和旧版本混在一起。
代价是验证集分布可能和训练集分布存在系统性偏移(比如代码风格演化、依赖库升级),导致指标偏低。但偏低比虚高好处理得多,而且偏低会逼你去检查数据分布变化,而不是上线后翻车。我的做法是:如果时间戳可用,优先按时间切;时间切分的验证集指标如果明显低于随机分组切分,说明存在时间漂移,这本身就是重要的数据质量信号。
上线前的最后一道防线:留出「污染测试集」
即使训练/验证划分做对了,你仍然需要一个完全没参与任何调参决策的测试集。这组数据的要求比验证集更严格:
- 来源与训练、验证完全隔离,最好来自不同的时间段、不同的用户群体或不同的数据源;
- 在你调整超参、选择 checkpoint、做早停的过程中,测试集一次都不能看;
- 上线前用测试集跑一次完整评估,如果测试集指标和验证集指标差距超过 3-5 个点,回头检查验证集是否仍然存在隐性泄漏。
这条防线对补全模型尤其重要,因为补全任务的评估指标(EM、BLEU、CodeBLEU、执行成功率)对数据分布极其敏感,验证集上的微小泄漏在测试集上会被放大。
几个容易被忽略的操作细节
别用 train_test_split 的默认行为直接切 DataFrame。 很多人的数据管线里,样本是逐行存储的,train_test_split 按行随机切,等于默认了「每行是独立样本」。补全数据里这个假设几乎从不成立。
去重要在划分之前做,但去重要分两层。 第一层是样本级精确去重(完全相同的 prompt + completion 对),第二层是上下文单元级近似去重。如果先去重再分组划分,效果最好;如果反过来,先划分再去重,验证集里可能残留训练集的近似副本。
注意 tokenizer 的边界效应。 如果你的上下文单元切分依赖 tokenizer(比如按 token 数切滑动窗口),同一个原始文本在 tokenizer 更新后可能切出不同的单元边界。固定 tokenizer 版本,并在数据管线的元数据里记录版本号。换 tokenizer 后重新做单元划分和隔离检查。
验证集里出现训练集见过的 prompt 前缀,不等于泄漏。 补全任务里 prompt 相同但 completion 不同是正常现象(比如同一个函数签名有多个实现版本)。关键是 completion 的来源单元是否隔离。别把「prompt 重复」当成泄漏的信号,这会让你误杀大量有效数据。
常见问题
用 GroupShuffleSplit 按文件分组后,验证集指标还是偏高,可能是什么原因?
大概率是文件之间存在内容重叠,比如同一个代码块被复制到多个文件、同一个文档被多处引用。建议对训练集和验证集的上下文单元做一次 MinHash 相似度扫描,找出相似度超过 0.8 的跨组对,人工检查这些对是否构成实质性泄漏。如果数据量太大,可以先按文件大小和语言类型做粗筛,再对可疑子集做精查。
按时间切分后验证集指标下降了很多,是不是模型变差了?
不一定。时间切分引入的是分布偏移,指标下降反映的是模型在「未来数据」上的真实表现。你可以对比一下:用随机分组划分跑一个基线,用时间切分跑另一个,如果两者差距超过 10 个点,说明数据的时间漂移很严重,建议检查训练数据的时效性,考虑加入更新近的数据或者做领域自适应。如果差距在 5 个点以内,时间切分的结果更可信。
补全任务能不能用交叉验证?
可以,但必须用 Group K-Fold,且每个 fold 的验证组要在上下文单元层面独立。不过补全模型微调通常成本不低,K-Fold 的实际收益有限。我更推荐的做法是:一次严格的 holdout 划分(分组隔离 + 来源去重 + 时间切分),配合一个独立的污染测试集,比多折交叉验证更能暴露问题。
验证集指标虚高,上线前有没有快速的补救方法?
有。先在训练集中找和验证集每个样本最相似的 top-3 训练样本(用 embedding 相似度或 n-gram 重叠率),把相似度超过阈值的验证样本标记出来,看剔除这些样本后指标变化多大。如果剔除后指标显著下降,说明泄漏确实存在。上线前至少要做这一步检查,同时把那些被标记的高相似样本从训练集中移除,重新微调一版。