3、数据集准备与预处理:数据收集、清洗、划分训练集/测试集

好,咱们正式开始动手做ADMET预测模型的第一道工序——数据集。说实在的,做了这么多年项目,我发现一个道理:模型性能的上限,其实80%是由数据质量决定的。调参再努力,如果数据本身就是垃圾,那结果只能是“垃圾进,垃圾出”。

核心要点: 数据预处理不是体力活,是决定模型生死的基石。干净、完整、分布合理的训练集,能让你的模型少走太多弯路。

3.1 数据收集:从哪里找ADMET数据?

数据收集是第一步。我个人习惯先看公开数据库,再考虑自建数据集。

常用的公共数据库:

数据库名称 内容特点 适用场景
ChEMBL 大量化合物活性与ADMET数据 分类模型、回归模型的通用底座
PubChem BioAssay 高通量筛选结果,覆盖多种靶点 二分类毒性预测,如AMES毒性
Tox21 / ToxCast 专门针对毒性端点 核受体、应激反应通路预测
DrugBank 已上市/在研药物信息 验证集构建,临床相关性分析

我的个人经验: 做Ames致突变性预测时,我通常会同时拉取ChEMBL和Tox21的数据。前者数据量大但噪声多,后者数据质量高但量少。两者合并,再仔细清洗,效果最好。

如果做自建数据集,我建议从企业内部实验报告、文献补充材料或专利数据中提取。注意一点:不同来源的数值单位要统一。比如IC50有的是nM,有的是µM,一旦混用,模型直接完蛋。

# 一个简易的ChEMBL数据拉取示例,用Python
import pandas as pd
from chembl_webresource_client.new_client import new_client

# 获取人源CYP2D6抑制活性数据
target = new_client.target
chembl_target = target.filter(pref_name__iexact='Cytochrome P450 2D6').only(['target_chembl_id'])
target_id = chembl_target[0]['target_chembl_id']

activity = new_client.activity
results = activity.filter(target_chembl_id=target_id, standard_type='IC50').only([
    'molecule_chembl_id', 'standard_value', 'standard_units'
])

df = pd.DataFrame.from_records(results)
# 数据量大概几千到上万条,看具体查询范围

3.2 数据清洗:去掉脏数据

数据拉回来后,第一件事不是建模,是清洗。我记得刚入行时曾犯过一个错误,直接拿原始数据训练,结果模型莫名其妙地学到了一些“非活性但结构类似”的偏置——其实就是数据里有大量重复或错误标注。

清洗流程通常包括这几步:

  1. 去重: 同样的分子结构对应同一个SMILES,只保留一条。
  2. 规范化: SMILES规范到一种表示(如用RDKit的标准化函数),去掉盐、溶剂等无机部分。
  3. 缺失值处理: 如果目标值缺失,直接删除该样本;如果特征值缺失,需要额外考虑填充策略。
  4. 标注一致性: 检查分类任务的标签是否矛盾(同一个分子在不同批次中被标为活性和非活性)。
  5. 理化过滤: 比如保留分子量、LogP、氢键供体/受体数在一定范围内的合格分子。
# 使用RDKit做数据清洗的片段
from rdkit import Chem
from rdkit.Chem import Descriptors, SaltRemover

def clean_molecule(smiles):
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None  # 无法解析的SMILES,直接剔除
    # 去除盐和溶剂
    remover = SaltRemover.SaltRemover()
    mol = remover.StripMol(mol, dontRemoveEverything=True)
    # 标准化
    mol = Chem.MolFromSmiles(Chem.MolToSmiles(mol))
    # 简单理化过滤:分子量 100-1000,LogP -2到7
    mw = Descriptors.MolWt(mol)
    logp = Descriptors.MolLogP(mol)
    if mw < 100 or mw > 1000 or logp < -2 or logp > 7:
        return None
    return mol

# 应用到DataFrame
df['clean_mol'] = df['smiles'].apply(clean_molecule)
df = df.dropna(subset=['clean_mol'])

我曾经踩过的坑: 有一次我从PubChem拉了10万条数据,自以为清理得很干净,结果数据里混入了大量的“空白对照”成分(即溶剂DMSO本身的活性数据)。这些分子结构简单,活性和非活性随机分布,直接污染了整个训练集。后来在清洗时,我专门添加了一个步骤:去除分子量低于80或原子数少于5的样本。

3.3 特征工程:把分子“翻译”成机器能懂的语言

清洗完数据,下一步是把分子结构转成特征向量。ADMET预测中,最常用的特征有这几种:

  • 分子指纹(Morgan Fingerprints):ECFP/ECFC系列,我个人最喜欢。它们能捕捉官能团和拓扑信息。
  • 理化描述符:分子量、LogP、TPSA、氢键供体/受体数、可旋转键数——这些简单粗暴但有效。
  • 图神经网络特征:使用图卷积时,直接用原子类型、成键关系作为节点和边特征。
# 生成Morgan指纹(ECFP4)和常见理化描述符
from rdkit.Chem import AllChem
import numpy as np

def compute_features(mol):
    # 1024位ECFP4指纹
    fp = AllChem.GetMorganFingerprintAsBitVect(mol, 2, nBits=1024)
    fp_arr = np.array(fp)
    # 理化描述符
    desc = [
        Descriptors.MolWt(mol),
        Descriptors.MolLogP(mol),
        Descriptors.TPSA(mol),
        Descriptors.NumHDonors(mol),
        Descriptors.NumHAcceptors(mol),
        Descriptors.NumRotatableBonds(mol)
    ]
    return np.concatenate([fp_arr, desc])

这里有个注意点:你想想看,如果训练集有1000个分子,每个分子生成了1024+6=1030个特征,那特征维度并不过分。但如果分子数只有几百,特征维度却高得多,就很容易过拟合。我建议至少保持 样本数 : 特征数 ≥ 5:1 的比例,不够就降维或用更短的指纹(如512位)。

3.4 数据划分:训练集/测试集

数据划分看似简单,但里面学问很大。很多新手直接拿随机划分,结果模型在测试集上表现很好,一上真实世界数据就翻车。为什么呢?因为你忽略了数据分布的结构性差异。

常见的划分策略:

划分方法 做法 适用场景
随机划分 随机将数据分成训练集(80%)和测试集(20%) 数据量巨大且分布均匀时
分层采样 按分类标签比例分配 分类任务,特别是正负样本不平衡时
时间序列划分 按数据发表日期排序,用早期数据训练,晚期数据测试 模拟模型在实际投放中的预测能力
分子骨架划分 按分子的Bemis-Murcko骨架划分 评估模型对全新骨架的泛化能力

我会这么选: 如果做ADMET分类任务(比如CYP抑制,是/否),我首先用随机+分层采样做基线。对于实战部署,强烈建议增加一个分子骨架划分的测试集——只有这样,你才能知道你的模型是否真的学会了“化学规律”,还是仅仅是记住了已知骨架。

# 分层采样划分示例
from sklearn.model_selection import train_test_split

X = df['features'].tolist()   # 特征矩阵
y = df['label'].values        # 标签
smiles = df['smiles'].values  # 保留SMILES用于溯源

# 80% 训练,20% 测试,保持标签比例
X_train, X_test, y_train, y_test, smi_train, smi_test = train_test_split(
    X, y, smiles, test_size=0.2, random_state=42, stratify=y
)

# 检查划分后的正负比例
print(f"训练集: 正例 {sum(y_train==1)}/{len(y_train)} ({sum(y_train==1)/len(y_train):.1%})")
print(f"测试集: 正例 {sum(y_test==1)}/{len(y_test)} ({sum(y_test==1)/len(y_test):.1%})")

注意! 一旦做了特征工程(比如用PCA降维,或用整个数据集的均值和标准差做标准化),一定要先划分训练集/测试集,再用训练集的数据计算均值和标准差,应用到测试集。如果你先做整个数据集的标准化再划分,数据泄漏就产生了,模型评估结果会虚高。

3.5 数据准备全流程

我把整个流程总结成一张图,你一看就明白了:

ADMET数据准备与预处理全流程 1. 数据收集 2. 数据清洗 3. 特征工程 4. 数据划分 训练集 (80%) 测试集 (20%) 常用划分策略:随机+分层采样、时间序列划分、分子骨架划分

这套流程我每次做项目都会老老实实走一遍,哪怕是只有几百个分子的数据集。说句实在话,从ChEMBL上直接下数据然后训练,不做这些预处理,模型很可能过拟合到数据源本身的偏差上——比如只有特定机构做的实验才被收录,或者特定类型的分子被反复测试。

3.6 避坑指南小清单

最后,我再快速列几个避坑点。都是我用真金白银的算力和时间换来的教训:

  • 不要迷信大型公开数据。我遇到过ChEMBL里有些活性值是通过高通量筛选(HTS)一次跑出来的,单位都是µM,但另一批是低通量验证,单位是nM。两者不统一,合并前必须统一换算并记录转换逻辑。
  • SMILES不是越规范越好。 有时候用isomeric SMILES(保留手性信息)和用canonical SMILES效果不同。做ADMET时,我通常去掉手性信息(除非目标任务是立体选择性预测),否则特征维度会爆炸。
  • 划分后检查数据泄漏。 比如测试集里有没有和训练集完全相同的分子?用Murcko骨架检查。我以前就发现,有些数据集里同一个分子出现多次,而且每次活性标签不同——这直接导致模型在“背诵”答案。

小结一下: 数据集准备这一步,你投入的时间会直接回报在模型训练效率和最终泛化能力上。不要急着调参,先把数据的家底管好。