4、数据集划分与交叉验证

大家好,我是老张,在QSAR这行摸爬滚打了十来年。今天聊一个看着基础、实则坑最深的话题——数据集怎么划分、交叉验证怎么做。

你想想看,一个QSAR模型如果没见过新结构,怎么可能预测准?但很多新手一上来就用随机划分,结果模型在验证集上表现不错,一到外部数据就翻车。我最初做模型时就吃过这个亏,后来才知道,数据划分的门道,比你想象的要多得多。

4.1 划分的核心原则:别让你的模型“作弊”

我们先理清一个逻辑。模型预测的是新分子,那验证集就要模拟“新分子”的样子。如果训练集和验证集长得太像,那还验个啥?

所以,划分的核心原则就三个字:不泄露。具体分两层意思:

  • 结构不重叠:相似骨架的分子,要么全进训练集,要么全进验证集。
  • 性质不掺杂:活性分布要在两个集合里都有,不能训练集全是高活性,验证集全是低活性。

一句话总结:划分不是分堆,是创造一个有代表性的“盲测”。

我习惯把划分分成两类:静态划分和交叉验证。前者用于快速评估,后者用于稳定建模。下面我详细说说。

4.2 三种主流静态划分方法

很多人做QSAR,第一步就是调 sklearn 的 train_test_split。但你真的用对了吗?

方法 适用场景 我的个人经验
留出法 (Hold-out) 数据集足够大,快速验证 默认7:3或8:2,但我建议至少保留8:2,别太贪训练集
自助法 (Bootstrap) 数据量小,需要多次采样 有放回采样,约63%的数据进训练集,适合小样本
分层采样 (Stratified Split) 分类任务,类别不平衡 强烈推荐!保持训练集和验证集的活性比例一致

以分层采样为例,它的关键是按活性类别或活性范围分组,然后按比例抽取。这样做的好处是,模型不会因为某个类样本太少就看不见。我曾经有一个任务,数据里高活性分子只占10%,用随机划分后验证集里一个高活性都没有,模型直接废了。后来换成分层采样,才解决了问题。

代码实现也不复杂,给你看个例子:

from sklearn.model_selection import StratifiedShuffleSplit

# 假设 y 是活性标签,0表示低活性,1表示高活性
splitter = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
for train_idx, val_idx in splitter.split(X, y):
    X_train, X_val = X[train_idx], X[val_idx]
    y_train, y_val = y[train_idx], y[val_idx]

# 检查比例
print("训练集活性比例:", sum(y_train)/len(y_train))
print("验证集活性比例:", sum(y_val)/len(y_val))

这样你的训练集和验证集,活性比例就很接近了。我用这个技巧,在很多不平衡数据上都避免了模型跑偏。

4.3 K折交叉验证:稳定才是硬道理

光靠一次划分,波动太大了。我建议你在模型调参和最终评估时,用K折交叉验证。

说白了,就是把数据切成K份,每次拿一份当验证集,剩下K-1份当训练集,重复K次。最后算平均性能。这样既能充分利用数据,又能降低单次划分的偶然性。

我个人习惯用5折或10折。数据量少于500时用5折,多于1000时用10折。这个经验值我试了很多次,比较稳。

这里有一个我没少踩的坑——K折时一定要加shuffle。否则数据是按原始顺序排列的,如果原始数据里前一半全是低活性,后一半全是高活性,那模型就倒霉了。

from sklearn.model_selection import KFold

# 正确的做法:打乱+分层
kf = KFold(n_splits=5, shuffle=True, random_state=42)
for train_idx, val_idx in kf.split(X, y):
    X_train, X_val = X[train_idx], X[val_idx]
    y_train, y_val = y[train_idx], y[val_idx]

但这里还要注意:shuffle只打乱顺序,不保证分层。如果你的数据类别极端不平衡,建议用StratifiedKFold,它能保证每折里各类比例都和原始数据一样。

from sklearn.model_selection import StratifiedKFold

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for train_idx, val_idx in skf.split(X, y):
    # 每一折的低活性/高活性比例基本一致
    print("验证集活性比例:", sum(y[val_idx])/len(y[val_idx]))

4.4 留一法:穷举式验证

如果说K折是抽样,那留一法就是穷举。每轮只拿一个样本做验证,其余全做训练。这样模型几乎用到了所有数据,评估偏差很小。

但我为什么不常用?因为它计算量太大。如果有2000个样本,就要训练2000次。而且留一法方差很大,结果波动明显。

我建议:只在数据量小于100,或者单个样本极其重要(比如罕见的活性分子)时,才用留一法。否则,老老实实用K折。

4.5 特殊场景:时间序列与批量效应

QSAR领域里,还有两个特殊场景不能用常规划分。一个是以时间为序的建模,另一个是考虑批量效应。

  • 时间序列划分:比如用2015-2018年数据训练,预测2019年分子。那你必须按时间顺序划分,不能随机。我习惯把最老的数据当训练集,最新的当验证集。
  • 批量划分:比如同一个批次测试的分子活性相似,如果把一个批次的分子分到训练集和验证集,模型可能学的是批次特征,而不是结构-活性关系。这时候必须按批次分组划分,确保一个批次全进一个集合。

实现方法也很简单,自己做分组索引就行了:

from sklearn.model_selection import GroupKFold

# 假设 batch_labels 是每个样本的批次编号
# 同一批次的所有样本要么全在训练集,要么全在验证集
gkf = GroupKFold(n_splits=5)
for train_idx, val_idx in gkf.split(X, y, groups=batch_labels):
    # 这里训练集和验证集不会出现来自同一批次的样本
    pass

这个技巧我曾经在一个毒性预测项目里用过,那批数据有10个批次,批次间存在明显偏差。如果不用分组划分,模型精度虚高了20%。

4.6 我的个人策略建议

做了这么多项目,我总结了一套比较实用的策略,分享给你:

  1. 初探数据:先用80/20留出法+分层采样,快速判断模型有没有希望。
  2. 调参优化:换5折StratifiedKFold,打乱后评估稳定性。
  3. 最终发布:用3次5折交叉验证取均值,再在外部独立测试集上验证一次。
  4. 特殊场景:时间序列或批次效应,一律用GroupKFold或按时间划分。

记住:数据划分是整个QSAR建模的底座。底座不稳,上面再漂亮的模型都是空中楼阁。

下面这张图是我自己画的划分策略流程图,看着简单,但每一步都有讲究。

QSAR数据集划分策略流程图 结构化数据集 静态划分策略 交叉验证策略 留出法 自助法 分层采样 K折交叉验证 留一法 分组划分 特殊场景:时间序列/批量效应 必须使用分组划分或时间戳划分,禁用随机划分

嗯,今天的内容就到这里。数据划分和交叉验证,说白了就是帮模型“自我检验”的过程。分得对,模型好坏一目了然;分不对,那后面的质检数据再漂亮也是假的。下一章我们聊聊特征工程,这个坑也不少,到时候再给你细讲。