第2章 数据集构建与预处理:从哪里获取公共QSAR数据集、数据清洗、缺失值处理、异常值检测
做QSAR模型,最让人头疼的往往不是选什么算法,而是数据本身。我见过太多人花了一周调模型,最后发现数据源就有问题……那感觉,真的白干。数据集的构建与预处理,说白了就是给模型喂"干净饭"——饭都不干净,就别指望模型能学好。
这一章我们不讲虚的,直接从数据源讲起,一步步走到能用的数据集。你想想看,模型训练80%的时间其实都花在数据准备上,剩下的20%才是调参和跑模型。所以这块内容,值得你沉下心来看。
2.1 公共QSAR数据集去哪里找?
我个人习惯优先用ChEMBL,因为它对化合物活性数据的标准化做得很到位。当然,不同任务适合不同数据库,下面这张表是我常用的几个来源:
| 数据库 | 特点 | 数据规模 | 适合场景 |
|---|---|---|---|
| ChEMBL | 药物活性数据丰富,标准化程度高 | ~200万化合物 | 药物靶点预测、活性回归 |
| PubChem | 涵盖范围最广,数据量大 | ~1亿化合物 | 大规模虚拟筛选 |
| BindingDB | 结合亲和力数据为主 | ~200万数据点 | 亲和力预测(KD/IC50) |
| DrugBank | 已上市和在研药物 | ~1.3万药物 | 药物重定位、ADMET预测 |
| ZINC | 商用/可购买化合物库 | ~2亿化合物 | 虚拟筛选、分子对接 |
小提示:ChEMBL 提供 RESTful API,可以按靶点、活性阈值批量拉数据,比手动下载方便得多。我每次搭新项目都会先看看 ChEMBL 有没有现成数据,省不少力气。
除了这些,像 ChemSpider 和 PDBbind 也值得关注,尤其是你做结构-based QSAR 的时候。数据获取不是越多越好,而是越"对"越好。你想想看,拿 PubChem 的上亿化合物做训练,光清洗就能洗到怀疑人生。
2.2 数据清洗:别让脏数据毁了你的模型
数据从数据库里拉下来,基本都带着"杂质"。我在项目中遇到最典型的问题就是:同一个化合物在不同批次里用了不同的 SMILES 表示——苯环有的用"c1ccccc1",有的用"C1=CC=CC=C1"。模型不认啊!
清洗这一步,核心做三件事:
- 结构去重——标准化后去重,别用原始 SMILES 直接比
- 标准化表示——统一用 RDKit 的 Canonical SMILES
- 结构合法性校验——检查化合价、芳香性、立体化学是否正确
下面是我常用的清洗流水线:
from rdkit import Chem
from rdkit.Chem import MolFromSmiles, MolToSmiles
def clean_smiles(smiles):
mol = MolFromSmiles(smiles)
if mol is None:
return None
# 标准化为 Canonical SMILES
canonical = MolToSmiles(mol, canonical=True)
return canonical
def deduplicate(df, smiles_col='smiles'):
df['clean_smiles'] = df[smiles_col].apply(clean_smiles)
df = df.dropna(subset=['clean_smiles'])
df = df.drop_duplicates(subset=['clean_smiles'])
return df
注意:去重之前一定要做标准化!我见过有人直接用原始 SMILES 去重,结果同一个分子因为写法不同被当成两个样本留在训练集里,模型在测试集上看着"泛化"挺好,实际上就是数据泄露。
2.3 缺失值处理:别盲目填,也别直接扔
QSAR 数据里的缺失值很常见——比如 IC50 值没测、logP 没算、某个描述符为空。处理缺失值没有万能方案,要看缺失比例和数据特点。
下表是我对不同场景的处理建议:
| 缺失比例 | 推荐方法 | 注意事项 |
|---|---|---|
| <5% | 直接删除 | 不影响样本量,最简单 |
| 5% ~ 20% | 均值/中位数填充 | 适合数值型特征,分布较对称时用中位数更好 |
| 20% ~ 40% | KNN 填充或模型预测 | 利用特征相关性,计算成本稍高 |
| >40% | 删除该特征 | 缺失太多,填充反而引入噪声 |
我曾经犯过一个错误——对所有缺失值统一用均值填充,结果活性值分布被"拉平"了,模型预测的方差变得特别小。后来我才意识到:活性值(pIC50 等)的缺失机制如果跟活性本身相关,用均值填充会严重偏差。
这里给一段 KNN 填充的示例:
from sklearn.impute import KNNImputer
import numpy as np
imputer = KNNImputer(n_neighbors=5, weights='distance')
X_filled = imputer.fit_transform(X)
# 注意:填充完建议检查填充值的分布
print("填充前均值:", np.nanmean(X))
print("填充后均值:", np.nanmean(X_filled))
关键原则:缺失值处理没有"最优解",你要做的是记录每一步操作,在模型评估时对比不同方案的影响。我每次都会在实验日志里记下填充方法和参数,方便回溯。
2.4 异常值检测:别把"好数据"当异常扔掉
异常值检测在 QSAR 里特别微妙——有些活性值极高或极低的化合物,可能恰好是你最感兴趣的"明星分子"。所以检测异常值,我的原则是:先识别,再判断,不自动删除。
常用的检测方法:
| 方法 | 原理 | 适用场景 |
|---|---|---|
| Z-score | 基于标准差,|Z|>3 视为异常 | 正态分布数据,比如 logP、分子量 |
| IQR | 基于四分位距,超出 1.5×IQR 视为异常 | 非正态分布,比如活性值 |
| 孤立森林 | 随机分割,孤立路径短的视为异常 | 高维描述符矩阵 |
| DBSCAN | 密度聚类,不在任何簇内的为异常 | 数据有明显簇结构时 |
我记得有一次用 Z-score 检测异常值,把一批 pIC50 > 9 的化合物全标红了——差点就给删了。结果一查文献,那些恰好是已知的强效抑制剂。从那以后我就养成了习惯:异常值一定要可视化确认,不能只看数字。
下面是一段结合 IQR 和可视化的检测代码:
import matplotlib.pyplot as plt
import seaborn as sns
def detect_outliers_iqr(series, factor=1.5):
Q1 = series.quantile(0.25)
Q3 = series.quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - factor * IQR
upper = Q3 + factor * IQR
return (series < lower) | (series > upper)
# 可视化检查
sns.boxplot(x=df['pIC50'])
plt.title('pIC50 分布 - 异常值用 IQR 标记')
plt.show()
# 标记但不删除
df['is_outlier'] = detect_outliers_iqr(df['pIC50'])
避坑指南:异常值检测的"因子"参数别死用默认值。1.5×IQR 在活性数据上往往偏严格,我常用 2.0×IQR 或 2.5×IQR。说白了,QSAR 里的"异常"不一定真的是异常,它可能只是化学空间的稀疏区域——删掉它就丢失了一部分化学多样性。
这张图概括了从原始数据到可用数据集的完整路径。嗯,这里要注意——每个步骤之间是串行的,但实际工作中你经常需要回退。比如做完异常值检测,发现某批数据结构有问题,得回到清洗环节重新处理。这不是线性的,是迭代的。
我的经验:数据预处理阶段,建议用 DVC(Data Version Control)或简单的哈希记录每次数据快照。我习惯在每次清洗、填充、检测后都保存一份中间数据,文件名带上时间戳和操作摘要。别怕麻烦——等你发现某步操作有 bug 时,能直接回退到上个版本,比从头再来快十倍。