第3章 数据集准备:训练集与外部测试集的划分原则、如何避免数据泄露

做ADMET建模这么多年,我一直认为:模型好不好,三分靠算法,七分靠数据。而数据怎么划分,直接决定了你最后得出的结论靠不靠谱。外部验证的核心,说白了就是测试集要真的"外部"——它不能和训练集有任何暧昧关系。否则你辛辛苦苦调的模型,评估结果全是假的。

这一章我们就来聊聊怎么划分数据集,以及怎么避开那些容易踩的数据泄露坑。

3.1 为什么划分这么重要?

你想想看,如果测试集里混进了和训练集高度相似的分子,模型的"好性能"其实是靠记忆而不是靠泛化。这在ADMET项目中尤其致命——我们预测的是候选化合物的吸收、分布、代谢、排泄和毒性,一旦高估了模型能力,后续的合成和实验验证全是白费功夫。

数据泄露不是小问题,它能让整个项目失去意义。我曾经见过一个团队,模型在外部测试集上AUC超过0.95,结果一到真实筛选中完全失灵。后来一查,测试集里有超过30%的分子和训练集共享同一个骨架。嗯,这就是典型的泄露。

3.2 四大核心划分原则

划分数据集不能拍脑袋,得守住几个基本原则:

  • 代表性:测试集要能代表真实应用场景中的分子多样性。不能只挑"好分"的分子,得覆盖各种化学空间。
  • 独立性:测试集绝不能参与任何模型训练过程。包括特征选择、超参数调优、数据预处理——全部要基于训练集做,再应用到测试集。
  • 分布一致性:训练集和测试集应该来自同一个数据分布。如果训练集全是类药分子,测试集却大量包含天然产物,那评估结果就失真了。
  • 充分性:测试集样本量要足够。太少的话,评估指标的置信区间会很大,说服力不足。

关键指标参考:对于ADMET分类模型,外部测试集建议至少500-1000个样本;回归模型则建议不少于200个。太少的话,统计意义不大。

3.3 常用划分方法

不同场景适合不同的划分方法。我个人的习惯是:能不用随机划分就不用,除非你的数据量特别大且分布非常均匀。下面这个表格对比了四种主流方法:

划分方法 适用场景 优点 缺点
随机划分 数据量大且分布均匀 实现简单,速度快 可能造成结构相似分子分布在两边
骨架划分 化学结构数据,ADMET常用 保证骨架级别不重叠 计算开销大,极端骨架可能单独成类
时间划分 有时间戳的数据 模拟真实应用场景(用过去预测未来) 依赖时间标签的准确性
聚类划分 数据分布复杂,聚类结构明显 保持数据簇结构,减少分布偏移 聚类结果对方法敏感,不够稳定

骨架划分是我在ADMET项目中用得最多的方法。为什么?因为它直接针对化学结构的核心——分子骨架。两个分子即使骨架相同、取代基不同,在ADMET性质上也可能非常接近。如果它们分别落在训练集和测试集里,模型其实是在"作弊"。

实用技巧:用RDKit的MurckoScaffold做骨架划分时,建议先按骨架大小排序,大的骨架优先分到训练集,这样可以减少训练集的稀疏性。我一般设置80%的分子进训练集,20%进测试集。

3.4 数据泄露的常见来源

数据泄露是外部验证的头号杀手。我在项目中遇到过好几次,都是看似不起眼的小细节引发的。归纳下来主要有四类:

  • 结构相似性泄露:训练集和测试集有共享骨架或高度相似的子结构。这是ADMET项目中最常见的泄露类型。
  • 时间泄露:用未来的数据预测过去的事件。比如你的数据采集时间跨了几年,随机划分可能让早期和晚期的分子混在一起,这在时间序列场景下是大忌。
  • 重复样本泄露:同一个分子或盐形式在数据集中出现多次,被分到了不同的集合。我见过一个数据集里同一个化合物出现了三次,只是记录ID不同。
  • 预处理泄露:在做标准化、缺失值填充、特征选择时,用了整个数据集的统计量(比如均值和方差),而不是只基于训练集计算。这个坑特别隐蔽,我早期也踩过。

高度警惕:预处理泄露是最容易被忽视的。很多人会习惯性地对整个数据集做标准化,然后再划分。这是错的!标准化参数必须只从训练集计算,再应用到测试集。否则测试集的信息已经"偷偷"流入了模型。

数据集划分核心流程与数据泄露防范 原始数据集 划分策略 随机划分 骨架划分 时间划分 聚类划分 训练集 外部测试集 数据泄露防范(四个关键检查点) 结构相似性泄露 检查共享骨架 时间泄露 按时间戳划分 重复样本泄露 去重再划分 预处理泄露 只基于训练集计算参数 核心目标:确保测试集完全独立,模型评估真实可信

3.5 代码示例:骨架划分 vs 随机划分

说了这么多原则和方法,不如直接看代码。下面这个例子展示了如何用RDKit做骨架划分,同时和随机划分做对比:

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

def scaffold_split(smiles_list, frac=0.8, random_seed=42):
    """基于Murcko骨架划分数据集"""
    mols = [Chem.MolFromSmiles(s) for s in smiles_list]
    scaffolds = {}
    for i, mol in enumerate(mols):
        if mol is None:
            continue
        scaffold = MurckoScaffold.MurckoScaffoldSmiles(mol)
        if scaffold not in scaffolds:
            scaffolds[scaffold] = []
        scaffolds[scaffold].append(i)

    # 按骨架大小降序排列,大骨架优先分入训练集
    scaffold_sorted = sorted(
        scaffolds.items(), key=lambda x: len(x[1]), reverse=True
    )

    train_idx, test_idx = [], []
    n_total = len(smiles_list)
    for scaffold, indices in scaffold_sorted:
        if len(train_idx) < int(n_total * frac):
            train_idx.extend(indices)
        else:
            test_idx.extend(indices)

    return train_idx, test_idx

# 使用示例
smiles = ["CCO", "CCCO", "c1ccccc1", "c1ccc(cc1)O", "CCN", "CCCN"]
train_idx, test_idx = scaffold_split(smiles, frac=0.7)

# 对比随机划分
train_idx_r, test_idx_r = train_test_split(
    range(len(smiles)), test_size=0.3, random_state=42
)

print("骨架划分 - 训练集索引:", train_idx)
print("骨架划分 - 测试集索引:", test_idx)
print("随机划分 - 训练集索引:", train_idx_r)
print("随机划分 - 测试集索引:", test_idx_r)

实际经验:骨架划分能有效避免结构相似性泄露,但代价是训练集和测试集的大小可能不完全符合你设定的比例。大骨架扎堆时,训练集可能偏大,反过来骨架太分散,测试集可能偏大。我一般会接受5%以内的偏差,超出的话就调整排序策略或改用聚类划分。

3.6 验证划分质量

划分完了,不能直接扔进模型就完事。你得确认划分质量是否过关。我通常会做两件事:

  • 分布对比:检查训练集和测试集的关键性质分布是否一致。比如分子量、logP、氢键供体/受体数这些ADMET相关的描述符。如果分布差异显著,说明划分方法不合适,需要调整。
  • 相似性检查:计算训练集和测试集分子之间的最大Tanimoto相似性。如果超过0.7的分子对太多,就要警惕结构泄露。我一般设置阈值为0.6,超过就重新划分或改用更严格的骨架划分。
from rdkit import DataStructs
from rdkit.Chem import AllChem

def check_similarity_leakage(train_mols, test_mols, threshold=0.6):
    """检查训练集和测试集之间的最大相似性"""
    train_fps = [AllChem.GetMorganFingerprintAsBitVect(m, 2) for m in train_mols]
    test_fps = [AllChem.GetMorganFingerprintAsBitVect(m, 2) for m in test_mols]

    max_sim = 0.0
    for tf in train_fps:
        for tef in test_fps:
            sim = DataStructs.TanimotoSimilarity(tf, tef)
            if sim > max_sim:
                max_sim = sim
                if max_sim > threshold:
                    print(f"警告:发现高相似性分子对,Tanimoto={max_sim:.3f}")
    return max_sim

小技巧:如果发现测试集和训练集有高相似性分子,不要直接删除它们。先确认这些分子在化学上是否真的是同一个系列。如果是,考虑用骨架划分重建;如果不是(比如只是局部官能团相似),可以保留但记录在案,作为模型鲁棒性的参考。

3.7 避坑指南

做数据集划分这些年,我踩过不少坑。分享几个印象深刻的:

  • 别在划分前做特征选择:特征选择要用训练集的数据,不能看到全局。我曾经图省事,先对整个数据集做了方差过滤再划分,结果测试集的信息提前泄露了,模型指标虚高了不少。
  • 多任务模型要按样本划分,不是按任务:如果是多任务ADMET模型(同时预测多个性质),划分单位是分子,不是任务。同一个分子的所有标签必须待在同一个集合里。
  • 时间序列数据不要随机划分:如果你的数据带有时间标签(比如不同年份的实验数据),一定要用时间划分。随机划分会让模型"预知未来",这在真实场景中根本不成立。
  • 大数据集也要做结构去重:即使数据量很大,重复样本仍然会造成泄露。我见过一个包含10万分子的数据集,去重后发现其实只有8万个独立分子——那2万个重复项就是潜在的泄露源。

最后提醒一句:数据集划分没有"万能药"。不同的ADMET项目、不同的数据特点,适合的方法都不一样。关键是理解每种方法的原理和局限性,然后根据你的实际问题做选择。千万别为了省事,随便一个随机划分就交差了。