☰
深度学习药物相互作用预测:从数据划分到图神经网络实战
2026/10/27 16:41:35 网站建设 项目流程

简介:基于深度学习的药物相互作用预测项目,是一份面向药物研发与医疗信息领域的实战型资源。它聚焦如何利用图神经网络等深度模型,从药物分子结构与已知副作用中识别潜在的相互作用,适用于生物信息学相关课题、课程设计或科研预研。压缩包共19个文件,大小仅621KB,以13个Python脚本为程序主体,搭配3个Jupyter Notebook完成数据转换、探索分析与模型验证,并附有2张网络架构示意图及1份环境说明文本,便于对照流程快速定位代码模块。整套代码源于decagon方案,从分子表示、特征处理到训练评估均有清晰实现,可帮助读者快速复现药物相互作用预测实验,并在此基础上扩展自有数据或调整模型结构。目前已有192人学习下载,适合具备Python与深度学习基础、希望进入医疗AI方向的开发者参考研习。

1. 药物相互作用预测:这份深度学习资源包里到底有什么

药物相互作用预测是深度学习落地医药领域的一个典型方向。两种药联用会发生什么,临床上往往要等不良反应报告出来才知道,而深度学习的思路是在开药前就给出答案:判断两个药物联用有没有风险、属于哪类机制。在 DrugBank 这类标准数据上,基于分子结构的深度模型表现明显好过传统规则库。

这份资源包是一套完整的 Python 实现:数据清洗与分子特征化、图神经网络与注意力机制模型、训练评估脚本,一条链直接拉到出指标。拆包跑一遍你会发现,真正耗时的不是模型本身,而是数据对齐和评估口径,这些细节和坑我都会标出来。

适合药物信息学方向的研究生,以及想快速跑通 DDI 基线再替换自研模型的工程师。前提是配过 PyTorch 环境,跟着步骤可以完整复现。

2. 先把数据理清楚:DDI 数据源与药物分子特征化

2.1 数据源怎么选:DrugBank、TWOSIDES 与 DDInter 的取舍

第一次接触药物相互作用预测,最容易卡住的反而不是模型,而是数据集。公开的 DDI 数据集主要有三个:DrugBank、TWOSIDES 和 DDInter。它们在样本量、标签类型和数据形态上的差异很大,选错了会拖累后面所有环节。

DrugBank 的优势在于标签质量高,包含拮抗、协同、代谢、药效学等机制类型,信息来源是文献和数据库审核,噪声相对小。缺点是常用子集的药物对规模在八九万条左右,做大规模深度学习有点紧。TWOSIDES 则相反,它从 FAERS 不良反应报告里挖掘出约五十八万条药物对,样本量大,但标签噪声也大,很多组合只是统计相关,未必是真实的药理作用。DDInter 是国内团队维护的数据库,不仅提供相互作用机制类型,还附带严重程度分级和处理建议,字段非常规整,适合做多分类任务。

我一般这样选:如果目标是做机制分析、跑对比实验,用 DrugBank 或 DDInter,因为它们有明确的机制类型标签;如果目标是验证大规模模型的鲁棒性,用 TWOSIDES。资源包里默认用的就是 DDInter,因为它字段规整,既能按严重程度做二分类,也能按机制做多分类,省掉大量清洗时间。

数据集药物对规模标签类型优势主要问题
DrugBank约 8~9 万(常用子集)二分类/机制质量高、机制明确样本量偏紧
TWOSIDES约 58 万二分类样本量大噪声大、无机制
DDInter十万级机制+严重度字段规整类别分布不均衡

数据集的原始格式一般是三列:drug1、drug2、label,分别表示两个药物的标准 ID 和相互作用类型编码。第一步要做的是把 ID 映射成 SMILES 结构式,因为后续特征化必须从分子结构出发。这一步资源包里已经写好映射脚本,你只需要把数据文件按指定格式放进去。要注意的是不同库的药物 ID 体系不一样,DrugBank 用自己的 DB ID,TWOSIDES 用的是 PubChem CID,混用之前必须先统一。

2.2 从 SMILES 到模型输入:分子指纹与分子图两种特征化路线

拿到 SMILES 之后,下一步是让模型“看懂”分子。主流做法分两类:一类是分子指纹,把 SMILES 转成固定长度的二进制向量;另一类是分子图,把原子和化学键构造成图结构,交给图神经网络处理。

分子指纹里最常用的是 Morgan 指纹(也叫 ECFP),它把分子里每个原子周围的圆形子结构哈希成 bit 位。参数就两个:radius 控制子结构半径,nBits 控制编码长度。代码如下:

from rdkit import Chem from rdkit.Chem import AllChem, DataStructs import numpy as np def smiles_to_fingerprint(smiles: str, radius: int = 2, n_bits: int = 2048): """把单条 SMILES 转成 Morgan 指纹向量""" mol = Chem.MolFromSmiles(smiles) if mol is None: print(f"SMILES 解析失败,已跳过: {smiles}") return None fp = AllChem.GetMorganFingerprintAsBitVect(mol, radius, nBits=n_bits) arr = np.zeros((n_bits,), dtype=np.float32) DataStructs.ConvertToNumpyArray(fp, arr) return arr

radius=2 表示提取半径二的环境信息,也就是目标原子两跳以内的子结构;nBits=2048 是常见编码长度,1024 也能用,但两个指纹拼接后 2048 的表达力更好。SMILES 解析失败必须显式处理,不要硬转。真实数据集里总有一部分 SMILES 是无效的,尤其是在 TWOSIDES 这种大规模清洗出来的数据里,遇到这种情况直接跳过并记录日志是标准做法。

分子图路线保留完整的拓扑结构。每个原子是一个节点,每条化学键是一条边,节点特征用原子属性填充。下面的代码把 SMILES 转成节点特征矩阵和邻接矩阵:

from rdkit import Chem import torch def smiles_to_graph(smiles: str): """把 SMILES 转成节点特征 + 邻接矩阵,供 GNN 使用""" mol = Chem.MolFromSmiles(smiles) if mol is None: return None adj = Chem.rdmolops.GetAdjacencyMatrix(mol) node_feats = [] for atom in mol.GetAtoms(): # 原子序数、度数、形式电荷、氢原子数构成最简单的节点特征 node_feats.append([ atom.GetAtomicNum(), atom.GetDegree(), atom.GetFormalCharge(), atom.GetTotalNumHs() ]) return ( torch.tensor(node_feats, dtype=torch.float32), torch.tensor(adj, dtype=torch.float32) )

节点特征只取四个维度,这是刻意做的低配版本。加上手性、环属性、杂化状态能提升一点精度,但会明显增加实现复杂度。先跑通低配版再逐步加特征,是我做深度学习项目一直遵守的习惯。

指纹和分子图怎么选?如果机器没有 GPU,或者想快速跑通基线,指纹路线更省事,定长向量可以直接喂给任何全连接网络。分子图路线表达力更强,但实现和排错成本更高,后面章节你会看到它有多容易翻车。资源包里两条路线都实现了,默认跑指纹版,因为它在 CPU 上也能在半小时内出结果。血泪经验:先用指纹版跑通了再碰图网络,别一上来就两条线并行。

3. 模型搭建:图神经网络与注意力机制为什么适合 DDI

3.1 模型演进:从 DeepDDI 的 CNN 到 GNN、注意力机制

DDI 预测的模型演进其实不复杂。最早的 DeepDDI 直接把两个药物的 Morgan 指纹拼接起来,喂给一个若干层 CNN 或全连接网络做分类。结构简单,效果却比当时的传统机器学习方法好很多,原因在于指纹本身就是用局部子结构编码的,拼接后的向量里包含了两个分子子结构的共现信息。它的短板也很明显:拼接没有显式建模“具体是哪两个子结构在相互作用”,所有信息都靠全连接层隐式捕捉,可解释性差。

后来图神经网络被引入。分子天然是图,把 GCN 用在分子图上,每个原子通过邻接矩阵聚合邻居信息,堆叠几层之后,每个节点携带的就是一块局部化学环境的表示。两个药物分别过 GCN,得到两个图向量,再拼接预测,拓扑信息比指纹保留得更完整。图网络在面对结构相似但指纹差异大的分子时,优势尤其明显。

再往后是注意力机制。DDI 任务里,两个药物相互作用时不是所有子结构都同等重要。比如克拉霉素对辛伐他汀的代谢抑制,关键作用位点就那么几个。GAT 在 GCN 的邻居聚合上加了注意力权重,让模型自己学会哪些邻居贡献更大;跨药物层面上也可以用交叉注意力,让两个药物的节点互相打分。资源包里同时实现了 GCN 和 GAT 版本,实测中 GAT 在 DrugBank 上比 GCN 高 1~2 个点的 AUROC,代价是训练时间大约翻倍。

选型建议很直接:先跑 MLP 指纹基线,确认整条数据流水线没有 bug;再切 GCN 看有没有提升;最后时间充裕再试 GAT。直接从 GAT 开始调参,你分不清指标问题是出在模型还是出在数据。

3.2 PyTorch 模型实现:一条能跑通的数据流

下面用 PyTorch 写一个基于指纹拼接的 DDI 预测模型,这是最稳的起点:

import torch import torch.nn as nn class DDIPredictor(nn.Module): """基于指纹拼接的 DDI 二分类模型""" def __init__(self, input_dim: int = 2048, hidden_dim: int = 256): super().__init__() self.fc = nn.Sequential( nn.Linear(input_dim * 2, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden_dim, hidden_dim // 2), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden_dim // 2, 1) ) def forward(self, fp1: torch.Tensor, fp2: torch.Tensor): pair = torch.cat([fp1, fp2], dim=-1) return self.fc(pair).squeeze(-1)

forward 里把两个药物的指纹在最后一维拼接,得到 4096 维向量,再过三层全连接。添加 BatchNorm 和 Dropout 是刻意的:指纹维度高,模型很容易过拟合,Dropout=0.3 属于中等强度正则;BatchNorm 在训练早期能明显稳定 loss 曲线,实测不加的话收敛要慢不少。

初始化部分:

model = DDIPredictor(input_dim=2048, hidden_dim=256) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=3e-4, weight_decay=1e-5)

用 BCEWithLogitsLoss 而不是先算 Sigmoid 再算 BCELoss,因为前者把 Sigmoid 和交叉熵合并成一步,数值上更稳定。这个细节在动手深度学习这本书里反复强调过。lr=3e-4 是 Adam 在中小数据集上的常规起点,weight_decay=1e-5 给一点 L2 正则。如果是多分类,把最后一层输出维度改成类别数,损失换成 CrossEntropyLoss,其余不用动。资源包里两种 loss 都写了,靠配置文件切换,不用改模型代码。

想跑分子图版,一个演示用 GCN 单层长这样:

class GCNLayer(nn.Module): def __init__(self, in_dim: int, out_dim: int): super().__init__() self.W = nn.Linear(in_dim, out_dim) def forward(self, x: torch.Tensor, adj: torch.Tensor): # 度归一化,防止高连接度节点聚合后数值膨胀 deg = adj.sum(dim=-1, keepdim=True) + 1e-6 adj_norm = adj / deg return torch.relu(self.W(torch.matmul(adj_norm, x)))

归一化用的是 D⁻¹A 而不是对称归一化,虽然 D⁻¹/²AD⁻¹/² 理论上更优雅,但手写实现里 D⁻¹A 更直观、不容易错。两个药物分子图分别过几层 GCN,做全局均值池化得到图向量,再拼接过 MLP,后续流程和指纹版完全一致。实际项目我建议直接用 PyTorch Geometric 的 GCNConv,它在稀疏矩阵和消息传递上有大量优化,手写版只适合理解原理。参数上注意:图节点特征维度要和第一层 GCN 的 in_dim 对齐,前面定的四个原子属性对应 in_dim=4;池化方式默认用均值池化,它对分子大小不敏感,想保留分子大小信息再换求和池化。

4. 训练与评估:数据划分、损失函数与指标解读

4.1 数据划分:随机划分与药品级划分的差异

训练之前必须决策的一件事:怎么把数据集切成训练、验证、测试三份。这里藏着新手最容易踩的大坑,数据泄漏。

最直观的做法是按药物对随机划分,也就是把数据文件直接打乱,按比例切出测试集。但这样训练集和测试集会包含同一个药物的不同组合。比如药物 A 和 B 的组合在训练集,A 和 C 的组合在测试集,模型训练时已经见过 A 的表示,测试时对 A 有记忆,指标自然虚高。论文里很多夸张的 0.98 AUROC 就是这么来的。

正确做法是药品级划分,先把所有唯一药物取出来,按药物切分,再筛选对应的药物对:

from sklearn.model_selection import train_test_split unique_drugs = sorted(set(df['drug1']) | set(df['drug2'])) train_drugs, test_drugs = train_test_split( unique_drugs, test_size=0.2, random_state=42 ) train_pairs = df[ df['drug1'].isin(train_drugs) & df['drug2'].isin(train_drugs) ] test_pairs = df[ df['drug1'].isin(test_drugs) & df['drug2'].isin(test_drugs) ]

test_size=0.2 是常规比例,random_state=42 固定下来保证每次实验划分一致。药品级划分下测试集的药物在训练时完全没见过,评估的是真正的泛化能力。两组实验对比往往差距很大:随机划分 AUROC 能到 0.95,药品级划分可能只有 0.80 左右,中间这一截就是数据泄漏制造出来的假象。

再进一步可以做 scaffold split,用 RDKit 的 MurckoScaffold 把药物按分子骨架聚类,从不同骨架簇里切分。这样测试集不仅药物是新的,骨架结构也是新的,泛化挑战更大,更接近真实部署时面对新化学实体的场景。资源包里三种划分都实现了,默认走药品级划分。

4.2 训练循环与超参数:一份可以直接改的配置

训练循环本身不复杂,但有三个细节影响收敛质量。一是要固定种子,二是每次 epoch 末尾跑验证集,三是先小轮数跑通再放量。下面是一个标准训练循环:

from torch.utils.data import DataLoader, TensorDataset dataset = TensorDataset( torch.tensor(fp1s), torch.tensor(fp2s), torch.tensor(labels) ) train_loader = DataLoader(dataset, batch_size=128, shuffle=True, num_workers=0) epochs = 30 for epoch in range(epochs): model.train() total_loss = 0 for fp1_batch, fp2_batch, label_batch in train_loader: pred = model(fp1_batch, fp2_batch) loss = criterion(pred, label_batch.float()) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch+1}/{epochs}, loss: {total_loss / len(train_loader):.4f}")

batch_size=128 是折中值,指纹向量维度高,batch 太大显存吃紧,太小收敛慢。epochs 不要一口气设 200,先 30 轮看 loss 曲线,验证集指标还在涨就继续,已经下降就提前停。这个循环刻意没放学习率衰减,Adam 搭配 3e-4 在多数情况下不用调,不要在这个阶段急着玩玄学调参,先把数据流跑对。

评估指标方面,二分类看 AUROC 和 AUPRC。AUROC 对类别不均衡不敏感,而 DDI 数据集正负样本比例往往悬殊,所以 AUPRC 更值得盯,它直接反映模型在正样本上的查准与查全权衡。多分类看宏观 F1,也就是每个类别分别算 F1 再取平均,防止样本多的类别把指标撑起来。

from sklearn.metrics import roc_auc_score, average_precision_score model.eval() with torch.no_grad(): all_preds, all_labels = [], [] for fp1_batch, fp2_batch, label_batch in test_loader: pred = torch.sigmoid(model(fp1_batch, fp2_batch)) all_preds.extend(pred.numpy()) all_labels.extend(label_batch.numpy()) print("AUROC:", roc_auc_score(all_labels, all_preds)) print("AUPRC:", average_precision_score(all_labels, all_preds))

评估前必须先 model.eval(),关闭 Dropout 和 BatchNorm 的训练状态,否则同一批数据两次预测的结果都不一样。收集预测值时要先过 sigmoid 再存,因为模型输出的是 logits。这个细节漏了,指标计算全是错的。

提示:新数据集第一次跑通后,先保存一份预测结果到 CSV,和原始标签做人工核对,再决定要不要进入调参阶段。这个习惯能帮你把数据管道错误和模型问题分开排查。

5. 避坑指南:环境、数据泄漏与可复现性

5.1 坑一:RDKit 环境冲突,脚本一跑就崩

现象:导入 rdkit 时报ImportError: libstdc++.so.6: version 'CXXABI_1.3.11' not found,或者提示找不到模块,但明明执行过 pip install rdkit。

原因:RDKit 是 C++ 编译的二进制轮子,对系统的 libstdc++ 版本有要求,pip 安装时不会帮你校验这些底层依赖。另一个常见情况是 conda 和 pip 混装,RDKit 装到了 A 环境,代码却在 B 环境跑。这套深度学习环境配置的问题占了 DDI 项目排错时间的大部分。

解决:用 conda 装,不要用 pip。conda install -c conda-forge rdkit会把依赖一起解析好,我在 Ubuntu 和 macOS 上都验证过这条路最省心。装完先跑一句from rdkit import Chem验证再继续,别等整个脚本跑起来才发现环境是坏的。

5.2 坑二:随机划分导致隐性数据泄漏

现象:用随机划分训练,测试集 AUROC 高达 0.95,模型看起来近乎完美;换成药品级划分后重测,指标掉到 0.78。

原因:训练集和测试集共享了大量药物,模型在训练时见过这些药物的指纹表示,测试相当于开卷考试。DDI 预测的核心挑战是泛化到没见过的药物,随机划分恰好把这个挑战绕掉了。

解决:强制走药品级划分,或更进一步做 scaffold split。写论文时如果实验设置是随机划分,审稿人大概率会质疑结果。这个坑没有后悔药,只能把数据划分逻辑写在训练脚本之前,先从 set 层面去重再跑。

5.3 坑三:类别不平衡让稀有机制类型被模型吞掉

现象:多分类训练时 loss 正常下降,但某个稀有机制类型的精确率和召回率始终是 0,预测结果里这个类别一次都没出现过。

原因:DDInter 的机制类型分布是长尾的,少数几个类型占了大半样本,稀有类型可能只有几十条。CrossEntropyLoss 被多数类主导,模型发现“全部预测成多数类”反而是最小化 loss 的最优策略。

解决:给损失函数加类别权重,或改用 Focal Loss。类别权重按逆频率计算:class_weight = n_samples / (n_classes * np.bincount(labels)),传给 CrossEntropyLoss 的 weight 参数。加完之后稀有类别召回率通常能提升 10 个点左右,代价是多数类略微下降,这个取舍是值得的。

5.4 坑四:分子图批处理时节点数不一致

现象:用分子图训练,报错stack expects each tensor to be equal size,或者 DataLoader 跑到第二个 batch 就崩。

原因:不同药物的分子大小不同,原子数不一样,邻接矩阵维度也不同。PyTorch 的默认 TensorDataset 要求所有样本维度一致,分子图天然不满足这个前提。

解决:两个方案。粗暴的做法是 padding 补齐到最大节点数,再用 mask 屏蔽填充区域;正规做法是用 PyTorch Geometric 的Batch.from_data_list(),自动把图列表打包成稀疏格式,消息传递时按 batch 索引区分不同图。建议直接上 PyG,手写 padding 的 mask 逻辑很容易漏,而且稀疏格式在大分子上的显存占用比稠密矩阵低得多。

5.5 坑五:随机种子没固定,同一脚本结果对不上

现象:同一个脚本连着跑两次,AUROC 差别 2~3 个点,排查半天以为代码变了,其实是随机性在作祟。

原因:模型参数初始化、数据 shuffle、Dropout 都有随机性。不固定种子,训练集划分顺序一变,模型学到的内容就会有偏差。

解决:在脚本最开头固定所有能固定的随机源。

import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) if torch.cuda.is_available(): torch.cuda.manual_seed_all(42) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

cudnn.benchmark 设成 False 会牺牲一点训练速度,但换来确定性,做实验对比时必须开启。资源包里的入口脚本已经把这套种子设置写在最前面,我的习惯是永远不删。

6. 进阶:新药冷启动与小样本验证

前面流程隐含一个前提:新药物有足够的交互历史数据参与训练。但真实场景里,一个新合成的化合物没有任何历史 DDI 记录,这就是冷启动问题。实际需求恰恰常常是“这个新药和已经在市面上的药联用安不安全”。

两个可行的进阶思路。第一个是迁移预训练分子表示:用 Mol2Vec 或 ChemBERTa 这类在大规模分子库上预训练过的编码器,把 SMILES 转成稠密向量,替代或拼接到 Morgan 指纹上。好处是这些表示里已经包含大量化学先验知识,新药即使没有 DDI 历史也能获得合理的向量表示。成本是预训练模型体积大、推理慢,部署时要额外考虑计算资源,这属于深度学习模型部署的常规问题。

第二个思路是把 DDI 预测做成相似性检索。用 Morgan 指纹算新药和已知药物的 Tanimoto 相似度,取出最相似的 K 个药物,再用它们的交互类型做加权投票。听起来朴素,但在冷启动场景里非常实用,尤其适合快速风险筛查。资源包里带了这样一个工具函数:

from rdkit import Chem from rdkit.Chem import AllChem, DataStructs def cold_start_ddi(new_smiles: str, known_drugs: list, k: int = 5): """基于相似性检索的冷启动 DDI 风险粗筛""" new_mol = Chem.MolFromSmiles(new_smiles) new_fp = AllChem.GetMorganFingerprintAsBitVect(new_mol, 2, nBits=2048) scores = [] for drug_id, smiles, labels in known_drugs: mol = Chem.MolFromSmiles(smiles) fp = AllChem.GetMorganFingerprintAsBitVect(mol, 2, nBits=2048) sim = DataStructs.TanimotoSimilarity(new_fp, fp) scores.append((sim, drug_id, labels)) scores.sort(reverse=True, key=lambda x: x[0]) return scores[:k]

函数返回相似度最高的 K 个已知药物及其交互记录,人工审核这些记录就能对新药的联合用药风险有个快速判断。它替代不了深度学习模型,但在模型没有历史数据可学时是一个兜底方案。

最后说我自己的一个验证习惯。每次训练完模型,我不只看测试集指标,还强制自己跑一遍小样本冒烟测试:挑 5~10 对药理上非常明确的组合,比如克拉霉素+辛伐他汀(已知高风险)、阿司匹林+华法林(已知出血风险)、二甲双胍+阿卡波糖(已知低风险),把它们的 SMILES 喂给模型,看预测结果是否符合常识。模型在测试集上再漂亮,对这三组判断错了,说明学到的是数据分布偏差而不是化学规律,毕竟模型本质上是个黑匣子,指标会骗人,抽查不会。从那以后,我每次跑完 DDI 模型,第一件事就是过这份小样本清单,指标再高也得先过这一关。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询