第 2 章 数据收集与预处理:训练数据来源、数据清洗、标准化

做 ADMET 预测模型,头一件事就是搞数据。模型再花哨,数据烂了全都白搭。我在这个坑里栽过好几次,今天把经验摊开讲讲。

2.1 训练数据从哪来

数据的来源,说白了就三条路:公开库、自家测的合作搞的、以及抄文献拼出来的。我个人的建议是优先用公开数据库打底,再用私有数据微调。为什么?公开库量大,能覆盖更多化学空间。

常用的公开数据集我列在下面:

数据库 数据类型 典型规模 注意事项
ChEMBL 活性、ADMET 200万+化合物 标准值参差不齐,需要滤除不明确 assays
PubChem 化合物性质、生物活性 1亿+ 条目 存在大量重复和噪声,QC 很关键
DrugBank 已上市/临床药物 1万+ 药物 偏少,适合做验证集
Tox21 / ToxCast 毒性端点 1万+ 化合物 端点覆盖面窄,但质量较高
ADMETlab / ADMETsar 预计算 ADMET 属性 几万~几十万 属于衍生数据,可能引入偏差
我建议按这个顺序组装数据集:先用 ChEMBL + PubChem 拉大基数和多样性,再用 Tox21 补充毒性端点,最后用自家的实验数据做 validation 集,别混进 training set 做乾坤大挪移。

2.2 数据清洗——模型成败的隐形关卡

你会发现,拿到的原始数据没一个干净的。缺失值、重复项、单位不一致、活性值写反……这些坑我来回踩了三四年。

2.2.1 处理缺失值

最简单的做法是整行删掉。但如果你数据本身不多,很浪费。我更喜欢用“可容缺失”的方法:

  • 数值型端点(如 LogD、溶解度):用中位数填充,或者用同一骨架的化合物均值填充。
  • 分类端点(如是否 hERG 阳性):填unknown,然后在建模时设为单独类别。
  • 结构列绝对不能缺失,这没商量。如果 SMILES 为空,直接丢弃——你总不能凭空猜结构。
关于缺失率的取舍:我个人习惯把缺失率超过 70% 的端点直接拿掉。因为靠那 30% 数据训练的模型,泛化能力堪比盲猜。我在一个项目里硬着头皮用它预测血浆蛋白结合率,结果往外推时炸得一塌糊涂。

2.2.2 识别并剔除异常值

异常值怎么定?先画一张分布图是最快的。如果觉得麻烦,用 IQR 法(四分位距)也行。比值型数据(比如 Caco-2 渗透率)注意用 log 变换后再判断。我曾经很傻地直接对原始值做 3σ 过滤,结果你把高渗透性的好化合物全筛掉了……

# 伪代码示例:IQR 过滤
import numpy as np
def filter_iqr(values, factor=1.5):
    q1, q3 = np.percentile(values, [25, 75])
    iqr = q3 - q1
    lower = q1 - factor * iqr
    upper = q3 + factor * iqr
    return [v for v in values if lower <= v <= upper]

2.2.3 重复项与不一致处理

同一个化合物出现多次,取的实验值还不一样——这种事太常见了。我的做法是:

  • 同一个文献来源:取平均值
  • 不同来源存在冲突:保留高质量来源(优先选专利数据或权威机构数据)
  • 如果数据来源质量不明:先标记为冲突,直接扔掉。我宁可少一些样本,也别让模型学错。

注意 SMILES 的标准化。同一化合物可以写成 [O]C(=O)c1ccccc1 或者 C1(=CC(=C(C=C1)O)C(=O)O),清洗时必须用标准化工具统一。推荐用 RDKit 的 MolFromSmiles + MolToSmiles 重新输出 canonical SMILES。

2.3 标准化——让数据可复用

标准化不仅是指归一化数值,还包括命名、单位、结构、标签格式的统一。我见过团队花了三个月训练模型,结果预测时发现自家数据单位是 mg/mL,而训练集是 µM,差异上百倍——模型直接废掉。

2.3.1 特征数值标准化

对于连续的 ADMET 属性(如 logP、pKa、半衰期),建议用 Z-score 或者 min-max 归一化。如果你的模型是树模型(XGBoost, LightGBM),其实不归一也行。但如果是深度学习或者 SVM,必须归一化。

# Z-score 标准化
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # X 为特征矩阵

2.3.2 标签统一

分类标签最容易乱。比如“hERG 抑制剂”有的标注成 1/0,有的标成 Yes/No,有的写 "active"/"inactive"。我规定自己的项目一律按 1 = 阳性 / 0 = 阴性,非二元类用 1/2/3 表示等级。不确定的类别单独给 -1 并在训练时 mask 掉。

2.3.3 分子结构标准化流水线

这个环节烦琐但关键。我的标准做法是这样的:

  1. 去除盐:用 RDKit 的 SaltRemover 去掉 counterion
  2. 中和电荷:把 +1 或 -1 的离子变成中性形式(比如 COO⁻ 改成 COOH)
  3. 去除溶剂和水:比如把 "O" 作为单独的分子去掉
  4. 标准化官能团表示:硝基统一写成 [N+](=O)[O-] 而不是 NO₂
  5. 去重后保留最长 SMILES:同一骨架从不同来源可能得到不同长度,取完整度最高的那条
# RDKit 标准清洗流程(关键步骤)
from rdkit import Chem
from rdkit.Chem import SaltRemover

def standardize_smiles(smiles):
    mol = Chem.MolFromSmiles(smiles)
    if mol is None:
        return None
    remover = SaltRemover.SaltRemover()
    mol = remover.StripMol(mol, dontRemoveEverything=True)
    # 中和电荷
    from rdkit.Chem.MolStandardize import rdMolStandardize
    uncharger = rdMolStandardize.Uncharger()
    mol = uncharger.uncharge(mol)
    return Chem.MolToSmiles(mol, canonical=True)

标准化不意味着失真。有些化合物(比如季铵盐)本身就是带电的,中和电荷反而会破坏结构。所以我在流水线里加了一个“保留列表”,把已知的天然带电结构排除在中和步骤之外。

2.4 整体数据收集与预处理流程

下面这张图概括了这一章讲的核心链条。从多来源收集,经过分层清洗,再到标准化,最后产出可训练的数据集。我画了好几个版本,最终用这张结构图展示最清爽。

数据收集与预处理核心流程 公开数据库 私有实验数据 合作/文献数据 数据清洗 缺失值 · 异常值 · 重复项 · 不一致 标准化 特征归一化 · 标签统一 · SMILES标准化 可用训练数据集 ✓

A. 最后再叨叨几个坑

  • 曾经我拿到一个公开数据集,觉得清洗过了就直接用。结果训练到一半发现 Caco-2 的数值单位是 cm/s×10⁻⁶,另一些却是 10⁻⁶ cm/s,我居然没归一直接拼起来。模型收敛速度慢得想哭。
  • 标准化后的 SMILES 一定要做 round-trip 验证:用 MolFromSmiles 再 MolToSmiles 看能不能回来。回不来的结构直接剔除,不解释。
  • 数据清洗阶段一定要保留一个原始版本。我吃过一次亏:清洗代码里不小心把某个列的数值全部置零,又没备份原数据,等于重头洗了一周。

简单总结下我的清洗清单:

  • 缺失率 > 70% 的端点直接扔
  • 数值型异常用 IQR+领域知识双重判断
  • 所有 SMILES 统一为 canonical 并去除盐
  • 标签强制转成 1/0/未知(-1)
  • 数值特征用 StandardScaler 或 MinMaxScaler
  • 数据量 < 5000 时,缺失填充优先用中位数而非均值

数据预处理这一步的确琐碎,但值得你花 70% 的建模时间。模型结构选错了还可以换,数据脏了,神仙也救不了。下一阶段我会顺着这条线讲特征工程——但那就是后话了。