QSAR建模流程:从数据到解释的完整路径

聊完多任务学习的基础,咱们得先搞定最核心的事——QSAR建模到底怎么走。我刚开始带团队做项目的时候,发现很多新人一上来就急着调模型,结果数据没处理好,模型跑出来完全是虚的。

说白了,QSAR建模就像盖房子。地基没打好,后面装修再漂亮也白搭。今天我就把整个流程拆开揉碎了讲给你听。

2.1 数据收集:建模的命根子

数据是什么?就是你模型的口粮。没数据,啥都干不了。

我见过太多项目,模型效果差,一查原因——训练集和测试集有重叠的分子。你想想看,这跟考试前偷看答案有什么区别?

数据来源主要有这么几个渠道:

数据源类型 典型代表 特点
公共数据库 ChEMBL, PubChem, BindingDB 数据量大,但噪声多,需要仔细清洗
文献数据 专利、期刊论文 质量高,但格式不统一,提取费劲
内部实验数据 企业HTS筛选结果 最靠谱,但往往样本量有限
计算预测数据 对接打分、分子动力学结果 量大覆盖广,但误差累积严重

数据收回来以后,第一件事就是清洗。我习惯按这个顺序走:

  1. 去重——同一分子结构只保留一条记录
  2. 标准化——SMILES格式统一,价态调整
  3. 异常值处理——活性值明显偏离三个标准差的,先标记出来
  4. 缺失值处理——要么删掉,要么用同类分子的均值填补
一个实用的小技巧:对于多任务学习,你需要确保每个任务(比如不同靶点的活性)的数据量不要相差太多。我遇到过最头疼的情况,一个任务有5万条数据,另一个只有50条,模型直接偏向数据多的那个任务去了。

数据清洗完了,记得做分层抽样。就是把数据按活性高低分成几层,每层再随机抽。这样做出来的训练集和测试集,分布才一致。

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位可能更合适。为什么?因为位越多特征越稀疏,模型容易过拟合。

我曾经踩过的坑:有一次我同时用了二维描述符和ECFP4指纹,直接把所有特征拼在一起,总共3000多维度。结果模型训练巨慢,而且效果还不如只用ECFP4。后来我才意识到,描述符之间高度相关,有很多冗余信息。现在我做特征融合时,一定会先做相关性分析。

另外,所有描述符计算完成后,记得做标准化。尤其是数值范围差异大的描述符——分子量是几百,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会主导整个训练过程。我一般会设成反比于数据量:数据量大的任务权重小一点,数据量小的任务权重大一点。

核心经验:多任务学习不是简单地把多个任务堆在一起。你要确保这些任务之间是相关的。我在一个项目中试着把ADMET预测和激酶活性预测放在一起训练,结果效果反而下降了。因为这两个任务的本质差异太大,共享表征学不到对两者都有用的特征。

2.4 模型验证:别看训练集上的假象

模型训练好了,怎么知道它到底行不行?

验证方法有两种:

  1. 内部验证:交叉验证、留一法。训练集内部搞。
  2. 外部验证:拿全新的、模型没见过的数据来测。这才是真本事。

评估指标要分场景:

任务类型 常用指标 说明
分类(活性/非活性) AUC-ROC, 准确率, 精确率, 召回率 AUC > 0.8 算可用,> 0.9 算优秀
回归(pIC50值) R², RMSE, MAE R² > 0.6 算可接受,> 0.8 算很好

对于多任务模型,我会额外关注:每个任务各自的指标表现。模型整体看起来不错,但某个任务特别差——这种情况经常发生。

一个小建议:外部验证集最好来自不同的化学空间。比如训练集是PubMed文献的数据,验证集换成商业专利的数据。这样测试出来的结果才真实反映模型的泛化能力。

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数据集,把上面这个流程走一遍。纸上得来终觉浅。