4、数据集划分原则:训练集、验证集、测试集的合理分配

数据划不好,模型再强也白搭。这句话我这些年是真真切切体会过好几次。

我做ADMET预测这么多年,最怕听到一句话就是:“我这模型训练集精度99%,怎么一上线就不行了?” 你想想看,十有八九都是数据划分出了问题——要么泄露了,要么划分比例严重不合理。

4.1 先搞明白:我们到底为什么要划分?

很多人觉得划分就是个形式,随机切一刀就完事。其实不是的。我带你看看核心逻辑:

  • 训练集: 让模型学会“规律”,就像给小孩看例题。
  • 验证集: 用来调参、早停、选模型。比例不对,容易选到过拟合的模型。
  • 测试集: 最终考卷。一旦用作决策,就绝对不能再碰训练过程。

有一次我在项目里遇到一个常见错误:团队把测试集反复拿来比较不同结构,选完模型后,测试集上的精度看起来漂亮极了。但一上外部数据,性能直接腰斩。为什么?测试集的信息早就通过人工筛选泄露到模型选择过程中了。

致命红线: 测试集在整个建模周期内只能使用一次。仅一次。任何形式的重复使用都构成数据泄露。

4.2 三种典型划分策略与我的经验

策略 典型比例 适用场景 我的经验
留出法(Hold-out) 6:2:2 或 7:1.5:1.5 数据量大、分布较均匀 ADMET数据通常严重不平衡,单次留出容易偏差大
K折交叉验证 K=5 或 10 中等规模、稳定性要求高 我习惯5折,10折计算开销太大,ADMET数据常有重复骨架
分层留出(Stratified) 按任务比例保持 分类任务、分布偏斜严重 必选项。分类不平衡时,这招能救你一命
我的默认配方: 数据 < 5000条,用5折分层交叉验证;数据 ≥ 5000条,用一次分层留出(8:1:1),然后在验证集上做一次时间或骨架聚类划分。

4.3 时间序列与骨架聚类:ADMET专属坑

ADMET数据跟分子结构强相关。很多分子有相似的骨架,如果不做处理,随机划分会把同骨架分子分到两边。模型记住的是“长得像”,不是“性质真的相关”。

我踩过这个坑。有一次做CYP抑制预测,随机划分下测试集AUC达到0.93。但换了骨架划分,直接掉到0.72。说明模型泛化能力其实很差,只是刚好撞上了相似的分子骨架。

所以,好的做法是:

  • 按分子骨架聚类: 用Butina聚类或Murcko骨架拆分,保证同一骨架的分子全部落在一个fold里。
  • 按时间分: 如果数据带有时间标签(比如新药化合物),就必须用时间切分。用过去的数据预测未来的分子。
小技巧: 如果同时存在多个批次数据,最好先按批次分层,然后在每个批次内做骨架聚类划分。这样能兼顾实验批次效应和分子结构多样性。

4.4 实战代码:一个可复用的划分流程

下面是我常用的一个函数,整合了分层、骨架聚类和时间划分。你不用全量复制,理解结构就行。

import numpy as np
from sklearn.model_selection import StratifiedKFold, train_test_split
from rdkit import Chem
from rdkit.Chem.Scaffolds import MurckoScaffold

def smart_admet_split(smiles, y, test_size=0.2, valid_size=0.1, 
                      scaffold=True, stratify=True, random_state=42):
    """
    智能划分函数,支持骨架聚类和分层策略。
    smiles: 分子SMILES列表
    y: 标签(分类任务需为整数类别)
    """
    n = len(smiles)
    indices = np.arange(n)
    
    # 第一步:分层训练+测试
    if stratify:
        train_idx, test_idx = train_test_split(
            indices, test_size=test_size, 
            stratify=y, random_state=random_state
        )
    else:
        train_idx, test_idx = train_test_split(
            indices, test_size=test_size, random_state=random_state
        )
    
    # 第二步:从训练集中分出验证集
    # 如果开启骨架聚类,则按骨架划分
    if scaffold:
        train_smiles = [smiles[i] for i in train_idx]
        train_mols = [Chem.MolFromSmiles(s) for s in train_smiles]
        scaffolds = [MurckoScaffold.MurckoScaffoldSmiles(mol) for mol in train_mols]
        
        unique_scaffolds = list(set(scaffolds))
        np.random.seed(random_state)
        np.random.shuffle(unique_scaffolds)
        
        scaffold_to_idx = {s: [] for s in unique_scaffolds}
        for i, sc in enumerate(scaffolds):
            scaffold_to_idx[sc].append(train_idx[i])
        
        # 按唯一骨架数量排序,保证验证集骨架多样性
        sorted_scaffolds = sorted(unique_scaffolds, 
                                  key=lambda s: len(scaffold_to_idx[s]),
                                  reverse=True)
        
        cumulative = 0
        valid_size_abs = int(len(train_idx) * valid_size)
        valid_idx = []
        train_final_idx = []
        
        for sc in sorted_scaffolds:
            group = scaffold_to_idx[sc]
            if cumulative < valid_size_abs:
                valid_idx.extend(group)
                cumulative += len(group)
            else:
                train_final_idx.extend(group)
    else:
        # 不带骨架划分,直接随机切
        train_final_idx, valid_idx = train_test_split(
            train_idx, test_size=valid_size/0.8, 
            stratify=[y[i] for i in train_idx] if stratify else None,
            random_state=random_state
        )
    
    return train_final_idx, valid_idx, test_idx
注意: 上面这个函数在验证集大小上用了近似估算。如果数据量特别小(< 200条),建议改用留一交叉验证或者增加外折次数。我的底线是:验证集最少要有50个样本,否则调参数意义不大。

4.5 避坑指南:我遇到过的4个典型问题

  1. 随机划分导致高估: 我做过对比,随机5折平均比骨架划分的AUC高0.08~0.15。所以不要过度相信随机划分的验证结果。
  2. 测试集太小: 有次数据只有200条,我按7:1.5:1.5划分,测试集只有30条。结果每次跑都不一样,方差大到没法看。后来我改成10×5折交叉验证,取平均结果报告。
  3. 忽略了批次效应: 不同批次实验条件不同。如果没有按批次分层,模型会学到“这个批次的分子活性高”,而不是真正的结构-活性关系。
  4. 验证集和测试集混用: 这是最低级但最常见的错误。验证集调参后,测试集必须保持一次性的“陌生感”。我曾经建议团队专门建一个“盲测集”,只有项目负责人能访问,其他人做调参只能看到验证集结果。
数据集划分原则:核心流程与策略选择 原始数据 训练集 (60-70%) 验证集 (10-20%) 测试集 (10-20%) 策略选择:分层、骨架聚类、时间序列切分 常见问题:随机高估、批次效应、测试集不足、多次使用测试集 最终目标:训练集学规律,验证集调参数,测试集只测一次 数据量 < 200 条时,考虑留一交叉验证或10×5折替代

4.6 比例分配的实战建议

别死磕教科书上6:2:2的比例。实际项目里要根据任务调整:

任务类型 建议比例(训练:验证:测试) 适用逻辑
二分类(平衡) 6:2:2 标准场景,分层后直接使用
多分类(不平衡) 7:1.5:1.5 验证集要分层,保证每个类别至少20条
回归(小样本) 7:2:1 验证集需要足够多样本以评估偏差
时间序列预测 按时间窗口(前70%:中15%:后15%) 绝对不能随机切,必须保持时间顺序
骨架聚类后的分类 6:2:2(但确保每个fold骨架覆盖度一致) 建一个骨架覆盖度指标,验证集骨架覆盖度要接近训练集
一个常见陷阱: 我做ADME预测时发现,如果数据做过数据增强(比如生成不同的互变异构体),一定要把同一分子的所有异构体划到同一个集合里。不然模型会通过结构细节记住训练集,产生严重的过拟合。这个坑我踩过,损失了两周项目进度。

4.7 一句话总结

数据集划分不是简单切一刀的事。它直接决定了你的模型泛化能力可信度。我个人的工作流是:先做骨架聚类或时间切分,然后分层随机划分,再用K折交叉验证做内部评估,最后用一次测试集跑最终成绩。这个流程帮我绕过了很多坑。

你在做划分时,不妨多想一步:“如果这个划分方式是我老板定的,我会不会觉得它合理?” 想清楚了再动手写代码。