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个典型问题
- 随机划分导致高估: 我做过对比,随机5折平均比骨架划分的AUC高0.08~0.15。所以不要过度相信随机划分的验证结果。
- 测试集太小: 有次数据只有200条,我按7:1.5:1.5划分,测试集只有30条。结果每次跑都不一样,方差大到没法看。后来我改成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折交叉验证做内部评估,最后用一次测试集跑最终成绩。这个流程帮我绕过了很多坑。
你在做划分时,不妨多想一步:“如果这个划分方式是我老板定的,我会不会觉得它合理?” 想清楚了再动手写代码。