第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项目、不同的数据特点,适合的方法都不一样。关键是理解每种方法的原理和局限性,然后根据你的实际问题做选择。千万别为了省事,随便一个随机划分就交差了。