数据预处理与标准化:打好ADMET建模的地基

聊到ADMET预测,很多朋友一上来就急着调模型、改架构。但说实话,我见过太多项目到最后发现效果上不去,回头一查——数据预处理这步就没走扎实。你想想看,模型吃得脏数据,吐出来的能是干净结果吗?

这一章,咱们就把ADMET数据预处理的几个关键环节挨个捋一遍。我敢说,把这步做好了,模型效果至少能涨5到10个点。

1. SMILES去重与清洗

拿到原始数据,第一件事不是看分子多漂亮,而是先把非法SMILES踢出去。什么样的SMILES算非法?我遇到过最常见的情况:

  • 元素符号写错了(比如「C」写成「C1」但没有闭合环)
  • 括号不匹配
  • 连价键数明显不对(五价碳?这化学老师得气哭)

我个人习惯用RDKit做第一步过滤,简单几行代码就能把不合格的分子筛掉:

from rdkit import Chem

def clean_smiles(smiles_list):
    valid_smiles = []
    for smi in smiles_list:
        mol = Chem.MolFromSmiles(smi)
        if mol is not None:
            valid_smiles.append(smi)
    return valid_smiles
注意:Chem.MolFromSmiles返回None不代表一定有问题,有些SMILES是合法的但RDKit解析不了。我建议遇到失败的SMILES顺手记下来,回头检查是不是数据源的问题。

去重这块也要小心。同一个分子可能以多种形式出现——比如「CCO」和「OCC」都是乙醇,但SMILES写法不同。我的做法是先把SMILES转成标准形式再比较:

def canonical_smiles(smiles):
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    return Chem.MolToSmiles(mol, canonical=True)

# 去重时用标准形式作为key
unique_smiles = list({canonical_smiles(s): s for s in raw_smiles}.values())

2. 分子标准化

分子标准化听起来简单,但坑特别多。我曾在ADMET项目里遇到过这么个坑:同一个分子在不同数据库里有的带了counterion(比如盐酸盐),有的不带。模型如果学到的全是盐的形式,到了预测纯分子的时候肯定不好使。

标准化主要做这几件事:

  • 去盐基:保留最大有机片段,扔掉无机反离子
  • 电荷归一化:在pH 7.4条件下,把可电离基团统一成主要形式
  • 立体化学清除:如果数据集里没有立体化学信息,就把手性标记去掉
  • 同位素清除:把[13C]、[2H]等同位素标记转回普通元素
  • 片段化:处理混合分子(比如药物-抗体偶联物),只保留药物部分

我常用的标准化流程长这样:

from rdkit.Chem import SaltRemover

def standardize_molecule(mol):
    # 1. 去盐基
    remover = SaltRemover.SaltRemover()
    mol = remover.StripMol(mol, dontRemoveEverything=True)
    
    # 2. 在中性pH下调节电荷
    mol = Chem.MolFromSmiles(Chem.MolToSmiles(mol, 
                                              allBondsExplicit=True,
                                              allHsExplicit=True))
    # 3. 清除立体化学信息(如果不需要)
    Chem.RemoveStereochemistry(mol)
    
    # 4. 返回标准SMILES
    return Chem.MolToSmiles(mol, canonical=True)
经验之谈:我曾经因为没做去盐基,导致模型在预测Caco-2渗透性时误差巨大。后来加了这步,R²直接从0.4跳到了0.7。一步预处理,效果天差地别。

3. 多重离子处理

多重离子是个头疼事。同一个分子,在酸性条件下可能带正电,碱性条件下带负电。ADMET实验数据常常来自不同实验室,pH条件也不统一。

我的处理策略分三步:

  • 判断多重离子类型:看分子里有没有可电离基团(酸、碱、两性)
  • 选择主导形式:根据实验描述或pH值,确定最可能的形式
  • 标准化标准方法:实在不确定的,统一用「中和」形式或「移除电荷」

代码实现思路:

from rdkit.Chem import MolStandardize

def handle_multiple_ionization(mol, target_ph=7.4):
    # 使用RDKit的MolStandardize模块
    # 首选:中和到目标pH下的主导形式
    try:
        standardized = MolStandardize.charge.MolStandardize(mol, 
                                                            target_ph=target_ph)
        return standardized
    except:
        # 回退:直接移除所有正式电荷
        mol = MolStandardize.charge.Uncharger().uncharge(mol)
        return mol

一句话总结:对于多重离子,我倾向选择「最保守」的处理方式——要么统一到pH 7.4形式,要么干脆用中性形式。模型的一致性比极致的精确性更重要。

4. 数据集划分策略

训练/验证/测试集的划分,比你想的重要得多。为什么?因为ADMET数据的分布通常不平衡——比如pXR激活实验,90%的分子都是不激活的。如果随机划分,测试集里很可能全是负样本。

我常用的几种划分策略:

策略 适用场景 优点 缺点
随机划分 数据量大且分布均匀 实现简单 容易数据泄露
基于骨架的时间/结构划分 时间序列数据或相似结构预测 更贴近真实应用 划分后类别分布可能偏
分层划分 类别不平衡 保持各集合分布一致 对回归任务不太适用
基于ADMET标签的分层划分 需要确保测试集覆盖全部标签范围 评估结果更可靠 实现稍复杂

我个人最推荐做分层划分。尤其是对于二分类任务(比如hERG毒性:高/低风险),确保训练集、验证集、测试集里高低风险的比例差不多。代码示例:

from sklearn.model_selection import StratifiedKFold

def stratified_split(X, y, test_size=0.2, val_size=0.1):
    # 先把数据按标签分层
    skf = StratifiedKFold(n_splits=int(1/(test_size+val_size)), 
                          shuffle=True, random_state=42)
    
    for train_idx, temp_idx in skf.split(X, y):
        X_train, X_temp = X[train_idx], X[temp_idx]
        y_train, y_temp = y[train_idx], y[temp_idx]
        break
    
    # 再从temp里切出验证和测试
    val_ratio = val_size / (test_size + val_size)
    skf2 = StratifiedKFold(n_splits=int(1/val_ratio), 
                           shuffle=True, random_state=42)
    for val_idx, test_idx in skf2.split(X_temp, y_temp):
        X_val, X_test = X_temp[val_idx], X_temp[test_idx]
        y_val, y_test = y_temp[val_idx], y_temp[test_idx]
        break
    
    return (X_train, y_train), (X_val, y_val), (X_test, y_test)
千万别等模型训练完了才想划分。我曾经吃过一次亏:用随机划分跑出来AUC 0.95,开心得不得了。后来换成基于骨架的时间划分,结果掉到0.75。原来模型不过是记住了测试集里和训练集非常相似的分子。这哪叫预测?这叫记忆。

对回归类ADMET任务(比如logD、溶解度),分层划分不太好用——标签是连续的。我一般用分位数划分,让每个集合覆盖的数值范围差不多:

import numpy as np

def quantile_split(y, test_size=0.2, val_size=0.1):
    # 按数值分桶,确保每个桶的样本量一致
    n_bins = int(1 / (test_size + val_size))
    y_binned = np.digitize(y, bins=np.percentile(y, 
                           np.linspace(0, 100, n_bins+1)[1:-1]))
    return stratified_split(range(len(y)), y_binned, 
                            test_size, val_size)

整个数据预处理流程

把这些步骤串起来,就是一套完整的数据预处理管线。我用SVG画了一下整体流程图,方便你把握全貌:

数据预处理与标准化全流程 原始SMILES数据 步骤1:去除非法SMILES + 去重 步骤2:标准化 - 去盐基 / 电荷归一 / 立体清除 步骤3:多重离子处理(pH 7.4统一) 步骤4:计算分子描述符 / 指纹 步骤5:分层划分 → 训练 / 验证 / 测试集 训练集(70%) 验证集(15%) 测试集(15%) 关键提醒:每一步都直接影响最终模型效果,别图省事跳着走

说到底,数据预处理不是什么高大上的技术活,但却是最磨练耐心的环节。我常用一句话和团队里的人讲:「数据预处理做到位,模型训练就成功了一半」。这话一点也不夸张。