167

模型外推性:药物发现真正考验的是新骨架和未来数据

药物发现真正考验模型的是新骨架与未来数据。这篇讲清各种划分方式的严格程度与正确的评测设计。

模型在测试集上表现好,不代表在真实项目中有用。因为真实项目中面对的是「新骨架」与「未来的数据」,而随机划分的测试集两者都不是。评测设计的严格程度,直接决定了你对模型能力的判断是否准确。

划分方式的严格程度

划分方式 严格度 对应的真实场景
随机划分 最松 几乎不对应任何真实场景
骨架划分 遇到新的化学系列
严格骨架划分 较严 骨架 + 相似度双重约束
时间划分 最接近真实 用历史数据预测未来实验
聚类划分 较严 整个化学子空间未见过
跨项目划分 最严 模型迁移到新项目

时间划分是最诚实的评测:它完全模拟了模型在生产中的处境——用截至某个日期的数据训练,预测之后产生的实验结果。如果只能做一种评测,就做这个。

为什么随机划分严重高估性能

# 药物发现数据的特点:
#   分子不是独立采样的,而是【成系列产生的】
#
#   一个项目中,化学家围绕一个骨架
#   合成几十到几百个类似物
#   → 这些分子彼此高度相似
#
# 随机划分的后果:
#   同一系列的分子被分散到训练集与测试集
#   → 测试集中的每个分子,
#     训练集中都有它的「近亲」
#   → 模型只需要「查表 + 插值」就能得高分
#   → 【这不是泛化,是记忆】
#
# 典型的性能差距:
#   同一个模型,同一份数据:
#     随机划分   R² = 0.85
#     骨架划分   R² = 0.55
#     时间划分   R² = 0.40
#   → 【差距巨大,且随机划分的数字毫无参考价值】
#
# 【一个诊断方法】:
#   计算测试集每个分子与训练集的最大相似度,
#   画分布图
#   → 如果大量测试分子的最大相似度 > 0.7,
#     说明划分太松

import numpy as np
from rdkit import Chem, DataStructs
from rdkit.Chem import rdFingerprintGenerator

gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)

def split_leakage_check(train_smiles, test_smiles):
    tr_fps = [gen.GetFingerprint(Chem.MolFromSmiles(s))
              for s in train_smiles if Chem.MolFromSmiles(s)]
    max_sims = []
    for s in test_smiles:
        m = Chem.MolFromSmiles(s)
        if m is None:
            continue
        sims = DataStructs.BulkTanimotoSimilarity(gen.GetFingerprint(m), tr_fps)
        max_sims.append(max(sims))
    max_sims = np.array(max_sims)
    print(f"测试集与训练集的最大相似度:")
    print(f"  中位数 {np.median(max_sims):.3f}")
    print(f"  > 0.7 的比例: {(max_sims > 0.7).mean():.1%}  ← 【应该很低】")
    print(f"  > 0.5 的比例: {(max_sims > 0.5).mean():.1%}")
    print(f"  < 0.3 的比例: {(max_sims < 0.3).mean():.1%}  ← 真正的外推")
    return max_sims

各种划分的实现

from rdkit.Chem.Scaffolds import MurckoScaffold
from collections import defaultdict
import numpy as np

# ---- 骨架划分 ----
def scaffold_split(smiles, frac_train=0.8, generic=False):
    groups = defaultdict(list)
    for i, s in enumerate(smiles):
        m = Chem.MolFromSmiles(s)
        if m is None:
            continue
        scaf = MurckoScaffold.GetScaffoldForMol(m)
        if generic:
            # 【通用骨架】:忽略原子类型,只看拓扑
            #   更严格 —— 苯环与吡啶环视为同一骨架
            scaf = MurckoScaffold.MakeScaffoldGeneric(scaf)
        groups[Chem.MolToSmiles(scaf)].append(i)

    # 大骨架优先进训练集
    order = sorted(groups.values(), key=len, reverse=True)
    n_train = frac_train * len(smiles)
    train, test = [], []
    for g in order:
        (train if len(train) + len(g) <= n_train else test).extend(g)
    return train, test

# ---- 时间划分 ----
def temporal_split(df, date_col="date", cutoff="2024-01-01"):
    train = df[df[date_col] < cutoff]
    test = df[df[date_col] >= cutoff]
    print(f"训练 {len(train)} (至 {cutoff}), 测试 {len(test)}")
    return train, test
# 【最推荐】:需要数据有时间戳
#   如果内部数据没有记录合成/测定日期,
#   【现在就开始记录】—— 这是低成本高回报的数据治理

# ---- 聚类划分 ----
def cluster_split(smiles, frac_train=0.8, cutoff=0.6):
    from rdkit.ML.Cluster import Butina
    fps = [gen.GetFingerprint(Chem.MolFromSmiles(s)) for s in smiles]
    dists = []
    for i in range(1, len(fps)):
        sims = DataStructs.BulkTanimotoSimilarity(fps[i], fps[:i])
        dists.extend([1 - x for x in sims])
    clusters = Butina.ClusterData(dists, len(fps), cutoff, isDistData=True)
    clusters = sorted(clusters, key=len, reverse=True)
    n_train = frac_train * len(smiles)
    train, test = [], []
    for c in clusters:
        (train if len(train) + len(c) <= n_train else test).extend(c)
    return train, test
# 【比骨架划分更严格】:即使骨架不同,
#   只要整体相似就分在同一边

外推的三个维度

  • 结构外推:新的化学骨架——用骨架/聚类划分测试
  • 性质外推:预测超出训练集活性范围的值。模型几乎从不能外推到训练范围之外——如果训练集最高 pIC50 是 8,模型基本不会预测出 9.5。这个限制在先导优化后期尤其重要
  • 时间外推:化学空间随项目推进而漂移——用时间划分测试
  • 三者可以叠加:真实的部署场景通常同时面临这三种外推。

让模型更能外推的做法

# 1) 【更强的归纳偏置】
#    用图表示(保证价键规则)
#    用等变架构(保证对称性,见 160)
#    加入物化描述符(提供全局信息)
#    → 【减少模型「记忆」的空间,迫使它学规律】
#
# 2) 【数据增强】
#    SMILES 随机化(见 145)
#    构象采样
#    → 让模型对表面变化不敏感
#
# 3) 【正则化】
#    dropout、权重衰减、早停
#    集成
#
# 4) 【明确适用域】
#    与其追求「在所有分子上都准」,
#    不如【诚实地标注哪些分子不该预测】(见 166)
#    → 这在实践中更有价值
#
# 5) 【持续更新】
#    定期用新数据重训练
#    监控性能漂移
#
# 6) 【降低期望】
#    对全新骨架,模型的作用是
#    「粗略排序」而非「精确预测」
#    → 【承认能力边界,比夸大能力更有用】

# 【一个务实的评测报告应该包含】:
#   - 随机划分的结果(作为上界参考)
#   - 骨架划分的结果
#   - 时间划分的结果(如果有时间戳)
#   - 测试集与训练集的相似度分布
#   - 分相似度区间的性能(相似度 > 0.6 / 0.4~0.6 / < 0.4)
#   → 【最后一项最有信息量】:
#     它直接告诉你模型在什么距离上还可信

关键要点

  • 随机划分几乎不对应任何真实场景——药物数据是成系列产生的,不是独立采样的;
  • 时间划分最接近生产处境;如果内部数据没有时间戳,现在就开始记录;
  • 模型几乎从不能外推到训练集的活性范围之外——先导优化后期尤其要注意;
  • 最有信息量的报告是分相似度区间的性能,它告诉你模型在什么距离上还可信。

延伸资源