3. K折交叉验证原理与实现

这一章我们聊点硬核的东西——K折交叉验证。说白了,这是ADMET预测里评估模型最靠谱的手段之一,没有它,你拿到的泛化性能指标可能就是假的。

我记得刚入行时做过一个Caco-2渗透性模型,训练集R²=0.92,兴奋得不行。结果换了一批外部数据,R²直接掉到0.54,那脸打得我现在都记得。后来才明白,简单的训练/验证集拆分根本不够,必须上交叉验证。

为什么ADMET必须用K折交叉验证?

ADMET数据有几个让人头疼的特点:

  • 样本量小——很多实验数据只有几百到几千个分子
  • 分布偏斜——毒性、稳定性数据往往严重不平衡
  • 噪声大——不同实验室、不同批次的数据误差明显

单次固定拆分很容易出现“运气好分到简单样本”或“运气差分到难样本”的情况。K折就不一样了,它让每个样本都有机会当一次验证集,评估更稳定。

核心共识:用于ADMET模型发布和论文报告时,K折交叉验证是基本要求。建议至少5折,样本少于500时用10折或留一法(LOO)。

K折交叉验证的数学原理与实现细节

先看形式化定义:

给定数据集 D={xᵢ, yᵢ}_{i=1}^N,K折交叉验证将其随机划分为K个互斥子集 D₁, D₂, ..., D_K,每个子集大小基本相等。循环K次,第j次用D\D_j训练模型,D_j验证,记录评估指标M_j。最终模型性能为:

M_final = (1/K) Σ_{j=1}^K M_j

这个均值是关键。但你想想,如果只报告均值不报告方差,那同样不靠谱。我曾经看到一篇论文说模型AUC=0.91,但没给标准差,结果复现时发现10折中有一折AUC只有0.67。所以无论报告还是内部评审,均值和标准差都要给。

下面是我团队现在用的标准Python实现框架:

from sklearn.model_selection import KFold, cross_val_score
import numpy as np

def admet_cv_evaluation(X, y, model, n_splits=5, scoring='r2'):
    """
    ADMET专用K折交叉验证评估
    """
    kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
    
    scores = cross_val_score(model, X, y, cv=kf, scoring=scoring)
    
    # 我个人习惯同时返回原始分数方便排查
    return {
        'mean': np.mean(scores),
        'std': np.std(scores),
        'all_scores': scores,
        'fold_ids': list(range(1, n_splits+1))
    }

注意代码里的 shuffle=True,我见过有人忘了开这个,结果数据天然按活性排序,第一折全是高活性,最后一折全是低活性……那评估结果没法看。

踩过的坑:K折交叉验证必须“互斥”且“充分搅乱”。对于ADMET数据,强烈建议在分子骨架层面分组(scaffold split),而不是随机拆分,否则会严重高估泛化能力。这一点后面在数据拆分策略章节会专门展开。

K值的实验选择:5、10还是留一?

K值怎么定?我做ADMET这些年总结了一套经验规则:

数据规模 推荐K值 说明
N < 100 LOO (N折) 留一法,最大化训练数据量,但方差大
100 ≤ N < 500 10折 折中偏差与方差,适合小样本
500 ≤ N < 5000 5折或10折 5折计算更快,10折更稳定
N ≥ 5000 5折或3折 计算成本优先,3折也可接受
经验之谈:对hERG毒性、肝毒性这类严重不平衡任务,建议在分层K折基础上再观察少数类的召回率变化。我一般会在每一折里都打印混淆矩阵。

K折交叉验证在ADMET中的实战避坑指南

这些年我踩过的坑不少,写几个典型的给大家参考:

  1. 特征泄露。K折中如果特征工程用到全量数据(比如归一化用的全是全局均值),那每一折其实都看到过验证集的信息。正确的做法是在每一折内单独做特征预处理。
  2. 时间序列问题。有些ADMET数据有时间戳,比如公开发表的文献数据逐年累积。这时随机K折不合适,要改用时间序列交叉验证。
  3. 重复分子。同一个SMILES结构在不同实验中可能有不同活性值。拆分时必须保证同骨架的分子不跨折,否则结果会虚高。
  4. 高度不平衡。比如阳性样本只有5%,随机K折可能导致某一折一个阳性都没有。分层K折可以缓解,但最好还是用分层+多次重复。
# 分层K折示例(保持每折类别比例一致)
from sklearn.model_selection import StratifiedKFold

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for fold, (train_idx, val_idx) in enumerate(skf.split(X, y_binary)):
    X_train, X_val = X[train_idx], X[val_idx]
    y_train, y_val = y[train_idx], y[val_idx]
    # 检查每折阳性比例
    pos_ratio = np.mean(y_val)
    print(f"Fold {fold+1}: positive ratio = {pos_ratio:.3f}")

K折交叉验证流程的知识图谱

最后画一张流程图,把K折交叉验证的完整逻辑串起来:

K折交叉验证在ADMET中的完整流程 ADMET数据集 D (N个分子) Step 1: 骨架层面随机打乱 Step 2: 均分为K个互斥子集 D₁...D_K Step 3: 循环 j = 1 → K 训练: D \ Dⱼ | 验证: Dⱼ 记录 AUC/R²/Q² … Step 4: 汇总 μ ± σ 说明:K折交叉验证核心是“每个样本有且仅有一次做验证集的机会”,从而得到对模型泛化能力的无偏估计。
最后再强调一次:K折交叉验证不是银弹。对于ADMET任务,必须结合骨架拆分、分层采样和特征隔离才能拿到可信评估。我见过不少团队辛辛苦苦做了10折,结果特征泄露导致全部无效,白跑一遍。