QSAR建模流程:从数据到解释的完整路径
聊完多任务学习的基础,咱们得先搞定最核心的事——QSAR建模到底怎么走。我刚开始带团队做项目的时候,发现很多新人一上来就急着调模型,结果数据没处理好,模型跑出来完全是虚的。
说白了,QSAR建模就像盖房子。地基没打好,后面装修再漂亮也白搭。今天我就把整个流程拆开揉碎了讲给你听。
2.1 数据收集:建模的命根子
数据是什么?就是你模型的口粮。没数据,啥都干不了。
我见过太多项目,模型效果差,一查原因——训练集和测试集有重叠的分子。你想想看,这跟考试前偷看答案有什么区别?
数据来源主要有这么几个渠道:
| 数据源类型 | 典型代表 | 特点 |
|---|---|---|
| 公共数据库 | ChEMBL, PubChem, BindingDB | 数据量大,但噪声多,需要仔细清洗 |
| 文献数据 | 专利、期刊论文 | 质量高,但格式不统一,提取费劲 |
| 内部实验数据 | 企业HTS筛选结果 | 最靠谱,但往往样本量有限 |
| 计算预测数据 | 对接打分、分子动力学结果 | 量大覆盖广,但误差累积严重 |
数据收回来以后,第一件事就是清洗。我习惯按这个顺序走:
- 去重——同一分子结构只保留一条记录
- 标准化——SMILES格式统一,价态调整
- 异常值处理——活性值明显偏离三个标准差的,先标记出来
- 缺失值处理——要么删掉,要么用同类分子的均值填补
数据清洗完了,记得做分层抽样。就是把数据按活性高低分成几层,每层再随机抽。这样做出来的训练集和测试集,分布才一致。
2.2 分子描述符计算:把化学结构变成数字
算法不认识分子结构,它只认识数字。所以我们要把分子结构翻译成一组特征向量。这就是分子描述符。
描述符可以分成这么几类:
| 描述符类型 | 例子 | 维度 |
|---|---|---|
| 二维描述符 | 分子量、LogP、氢键供体数、可旋转键数 | 几十到几百 |
| 三维描述符 | 分子体积、表面积、偶极矩 | 几十 |
| 指纹图谱 | ECFP4、MACCS、Morgan指纹 | 几百到几千(通常是稀疏的) |
| 分子图 | 原子类型、键连接矩阵 | 可变 |
我个人最常用的工具是RDKit。它开源、功能强,社区也活跃。算ECFP4指纹的代码很简单:
from rdkit import Chem
from rdkit.Chem import AllChem
mol = Chem.MolFromSmiles('CCO')
fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius=2, nBits=2048)
bit_string = fp.ToBitString()
这里要注意一个坑:指纹的维度选择。2048位是常用的,但如果你数据量小(比如几千条),128或256位可能更合适。为什么?因为位越多特征越稀疏,模型容易过拟合。
另外,所有描述符计算完成后,记得做标准化。尤其是数值范围差异大的描述符——分子量是几百,LogP只有几。不标准化的话,模型会默认把分子量当成最重要的特征。
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_descriptors)
2.3 模型训练:核心环节
描述符准备好了,终于可以开始训练模型了。对于传统QSAR,你可能用随机森林、SVM或者XGBoost。但对于多任务学习,我们的目标是一起预测多个端点。
举个例子,同一个化合物对5个激酶靶点的活性。如果用单任务,你得分别训练5个模型。多任务学习只需要一个模型,输出5个预测值。
这个过程可以概括为:
- 输入层:分子描述符向量
- 共享层:多层全连接网络(所有任务共享权重)
- 任务特定输出层:每个任务有自己的一个输出节点,用Sigmoid做激活(回归任务用线性激活)
一个简单的PyTorch实现框架:
import torch.nn as nn
class MultiTaskQSAR(nn.Module):
def __init__(self, input_dim, hidden_dim, n_tasks):
super().__init__()
self.shared = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(hidden_dim, hidden_dim // 2),
nn.ReLU(),
)
self.task_heads = nn.ModuleList([
nn.Linear(hidden_dim // 2, 1) for _ in range(n_tasks)
])
def forward(self, x):
shared_rep = self.shared(x)
outputs = [head(shared_rep) for head in self.task_heads]
return torch.cat(outputs, dim=1)
训练时,损失函数怎么设计?我建议加权求和:
loss_total = sum(w_i * loss_i for i in range(n_tasks))
权重的选择很关键。如果某个任务的数据量特别大,它的loss会主导整个训练过程。我一般会设成反比于数据量:数据量大的任务权重小一点,数据量小的任务权重大一点。
2.4 模型验证:别看训练集上的假象
模型训练好了,怎么知道它到底行不行?
验证方法有两种:
- 内部验证:交叉验证、留一法。训练集内部搞。
- 外部验证:拿全新的、模型没见过的数据来测。这才是真本事。
评估指标要分场景:
| 任务类型 | 常用指标 | 说明 |
|---|---|---|
| 分类(活性/非活性) | AUC-ROC, 准确率, 精确率, 召回率 | AUC > 0.8 算可用,> 0.9 算优秀 |
| 回归(pIC50值) | R², RMSE, MAE | R² > 0.6 算可接受,> 0.8 算很好 |
对于多任务模型,我会额外关注:每个任务各自的指标表现。模型整体看起来不错,但某个任务特别差——这种情况经常发生。
2.5 模型解释:不是黑箱,是工具箱
最后一步,也是最容易被忽视的一步——解释模型。
药物化学家不会只看一个预测值就决定合成哪个化合物。他们想知道:为什么这个分子活性高?
我常用的解释方法:
- SHAP值:算出每个描述符对预测的贡献。简单直观。
- 注意力机制:如果是基于分子图的模型,看模型关注了分子上的哪些原子。
- 拟和替代模型:用决策树去拟合复杂神经网络的预测,用树的结构来解释。
用SHAP举个简单的例子:
import shap
explainer = shap.DeepExplainer(model, X_train)
shap_values = explainer.shap_values(X_test)
shap.summary_plot(shap_values, X_test)
输出的SHAP值可以直接告诉药物化学家:含氟基团、特定的芳香环位置——这些特征对活性提升贡献最大。
建模走到这步,才算真正走完了。但这只是开始,后续还有模型更新、迁移到新靶点、处理不平衡数据等等问题。嗯,那些我们后面慢慢聊。
现在,先拿一个小的多任务QSAR数据集,把上面这个流程走一遍。纸上得来终觉浅。