数据预处理与标准化:打好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
去重这块也要小心。同一个分子可能以多种形式出现——比如「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)
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)
对回归类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画了一下整体流程图,方便你把握全貌:
说到底,数据预处理不是什么高大上的技术活,但却是最磨练耐心的环节。我常用一句话和团队里的人讲:「数据预处理做到位,模型训练就成功了一半」。这话一点也不夸张。