第 2 章 数据收集与预处理:训练数据来源、数据清洗、标准化
做 ADMET 预测模型,头一件事就是搞数据。模型再花哨,数据烂了全都白搭。我在这个坑里栽过好几次,今天把经验摊开讲讲。
2.1 训练数据从哪来
数据的来源,说白了就三条路:公开库、自家测的合作搞的、以及抄文献拼出来的。我个人的建议是优先用公开数据库打底,再用私有数据微调。为什么?公开库量大,能覆盖更多化学空间。
常用的公开数据集我列在下面:
| 数据库 | 数据类型 | 典型规模 | 注意事项 |
|---|---|---|---|
| ChEMBL | 活性、ADMET | 200万+化合物 | 标准值参差不齐,需要滤除不明确 assays |
| PubChem | 化合物性质、生物活性 | 1亿+ 条目 | 存在大量重复和噪声,QC 很关键 |
| DrugBank | 已上市/临床药物 | 1万+ 药物 | 偏少,适合做验证集 |
| Tox21 / ToxCast | 毒性端点 | 1万+ 化合物 | 端点覆盖面窄,但质量较高 |
| ADMETlab / ADMETsar | 预计算 ADMET 属性 | 几万~几十万 | 属于衍生数据,可能引入偏差 |
2.2 数据清洗——模型成败的隐形关卡
你会发现,拿到的原始数据没一个干净的。缺失值、重复项、单位不一致、活性值写反……这些坑我来回踩了三四年。
2.2.1 处理缺失值
最简单的做法是整行删掉。但如果你数据本身不多,很浪费。我更喜欢用“可容缺失”的方法:
- 数值型端点(如 LogD、溶解度):用中位数填充,或者用同一骨架的化合物均值填充。
- 分类端点(如是否 hERG 阳性):填unknown,然后在建模时设为单独类别。
- 结构列绝对不能缺失,这没商量。如果 SMILES 为空,直接丢弃——你总不能凭空猜结构。
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 分子结构标准化流水线
这个环节烦琐但关键。我的标准做法是这样的:
- 去除盐:用 RDKit 的
SaltRemover去掉 counterion - 中和电荷:把 +1 或 -1 的离子变成中性形式(比如 COO⁻ 改成 COOH)
- 去除溶剂和水:比如把 "O" 作为单独的分子去掉
- 标准化官能团表示:硝基统一写成 [N+](=O)[O-] 而不是 NO₂
- 去重后保留最长 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 整体数据收集与预处理流程
下面这张图概括了这一章讲的核心链条。从多来源收集,经过分层清洗,再到标准化,最后产出可训练的数据集。我画了好几个版本,最终用这张结构图展示最清爽。
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% 的建模时间。模型结构选错了还可以换,数据脏了,神仙也救不了。下一阶段我会顺着这条线讲特征工程——但那就是后话了。