news 2026/10/26 15:49:31

知识图谱+推荐系统:药物重定位中DTI预测的最小实现与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
知识图谱+推荐系统:药物重定位中DTI预测的最小实现与避坑指南

简介:这是一套基于知识图谱与推荐系统预测药物靶点相互作用的Python源码,附带详细操作指南,主要面向计算机相关专业学生用于课程设计、期末大作业或项目实战。项目覆盖数据预处理、知识图谱构建、推荐算法实现与模型评估等完整流程,代码中整合了药物、疾病、基因等语义关系,并内置深度模型、随机森林与神经因子分解机等多种可运行算法模块,方便对照不同建模思路。资源共40个文件,以.py源码、.md说明文档、Pipfile依赖清单等类型为主,并包含少量辅助脚本与配置文件,压缩包整体仅56KB,轻量易用,目录结构清晰。目前已有95人学习下载。借助操作指南,使用者可快速完成环境配置、数据准备、模型训练与结果解读等关键步骤;既能深入理解知识图谱与推荐系统结合的实际应用,也可将其作为高分课程设计的起点,在现有代码上扩展药物靶点预测实验。

1. 当药物研发遇到推荐系统:知识图谱如何把“老药新用”变成可计算的排序问题

药物研发最贵的不只是临床试验,还有“猜靶点”。一种药可能已经在降血压,但它的某个靶点蛋白还参与炎症通路,能不能顺手治关节炎?如果只靠专家阅读文献,一年看不了几个候选。把药物、靶点、疾病、通路之间的关系整理成知识图谱,再用推荐系统里给用户推荐物品那套协同过滤思路,为“未知的药物-靶点交互”打分排序,实验室就能把验证资源集中到最有希望的候选上。这是我见过最实际的落地方式。这套方案会用 Python 从零搭一套最小流程:建图、特征、训练 TransE、评估与排错,适合刚开始做药物重定位的学生,也给生信算法岗的读者提供一份可直接修改的操作指南。

2. 先把知识图谱建起来:从药物、靶点、疾病的原始关系表到 Python 邻接矩阵

2.1 选数据源:为什么我推荐先用 CTD 而不是一上来就拼全库

知识图谱的质量决定推荐系统上限。很多人一开口就是 UniProt、DrugBank、DisGeNET 全上,结果数据对齐就耗掉两周。我建议原型阶段用 CTD(Comparative Toxicogenomics Database)的 chemical-gene 和 chemical-disease 关系表。CTD 是公开下载的制表符文件,每条记录几乎都是清洗好的三元组:化学物 ID、基因 ID、相互作用类型。它不像 DrugBank 需要申请审批,也不像 UniProt 的 GOA 文件那样需要你自己推断关系,上手最快。

选它的第二个理由:CTD 自带物种标识。比如基因一列是9606:1956这种格式,冒号前是 NCBI Taxonomy ID,9606 就是人。做药物靶点预测最怕人和鼠的数据混在一起,CTD 把物种写死在 ID 里,过滤非常方便。等流程跑通,再去补 DrugBank 的药物-靶点条目和 Reactome 的通路关系,把图谱扩大。

数据下载之后不要直接训练,先做一次“关系计数”。我一般会输出每个关系类型的分布,看数据是不是偏到个别 hub 节点上。比如某个基因同时出现在一万条记录里,后面计算邻居特征时它会主导相似度,模型会学成“跟大节点有关就预测正例”。这个后面会在避坑章专门讲。

下面这段是读取与过滤的常见做法,注意 CTD 表头前 27 行到 30 行是统计信息,skiprows 要看实际文件重新调,我不会写死为永远 27:

import pandas as pd # 第一次打开文件先看前 40 行,确认注释头占多少行 with open("CTD_chemical_gene.tsv") as f: for i, line in enumerate(f): if i > 40: break print(i, line[:100]) # 确认后读取,skiprows 跳过注释 chem_gene = pd.read_csv( "CTD_chemical_gene.tsv", sep="\t", skiprows=27, low_memory=False, ) # 只要人和有相互作用注释的行 chem_gene = chem_gene[chem_gene["GeneID"].str.startswith("9606")].copy() chem_gene = chem_gene.dropna(subset=["ChemicalID", "GeneID", "InteractionActions"]) print("raw triples:", chem_gene.shape[0])

这里low_memory=False是因为混合类型列很多,不设置的话 pandas 可能把某些列读成 object,后面 ID 对齐会翻车。skiprows是这个文件常见的注释行数,换版本后要用head命令重新确认。我不建议把全表直接塞进内存再 groupby,先按物种过滤能省掉一大半无关记录。

得到原始表之后,要把 ID 统一。CTD 里基因是9606:1956,化学物是 MESH ID 如D0001。如果想在知识图谱里把“基因”和“靶点”混用,建议保留 GeneID 并转成 int:

def norm_gene(gid: str) -> int: """CTD 的 GeneID 形如 '9606:1956',只取冒号后面的 EntrezGene ID""" return int(gid.split(":")[1]) kg = chem_gene[["ChemicalID", "GeneID", "InteractionActions"]].copy() kg["target"] = kg["GeneID"].map(norm_gene) kg["relation"] = "interacts_with" km = kg[["ChemicalID", "target", "relation"]].drop_duplicates()

这段逻辑看起来简单,但有两个极易踩的坑。第一,map(norm_gene)遇到非 9606 的行会先被外层过滤掉,所以不用 try;但若混着9606:1956和10090:...而外层过滤写错,int 转换会直接抛异常。我会把过滤和转换写在一起用loc而不是链式赋值,避免 SettingWithCopyWarning。第二,InteractionActions 有的是increases,有的是decreases|increases,这里统一成interacts_with是故意丢信息。原型阶段做“是否交互”二分类足够,跑通之后再回来把细粒度关系加为边类型。

2.2 把三元组变成图对象:节点类型、边类型、重复边

数据变成三元组,但推荐算法需要的是图结构。我用networkx.Graph搭无向图。注意我不用 DiGraph,因为 DTI 是“药物-靶点”双向相关,没有严格的上下行关系;虽然 CTD 里有的 interaction 是化学物“增加”基因表达,有方向性,但二分类不需要方向。如果后面做因果推断,再换 DiGraph。

构造图时,把节点和边分开添加是有价值的:节点上保存node_type,后面做路径特征时能区分“药物→通路→靶点”和“药物→疾病→靶点”,这两种路径的语义完全不同。代码:

import networkx as nx G = nx.Graph() G.add_nodes_from(kg["ChemicalID"].drop_duplicates(), node_type="drug") G.add_nodes_from(kg["target"].astype(str).drop_duplicates(), node_type="target") G.add_edges_from( zip(kg["ChemicalID"], kg["target"].astype(str)), rel="interacts_with", ) print(nx.info(G))

nx.info会打印节点数、边数和平均度。我一般要求平均度在 2 以上才继续,否则图太稀疏,协同过滤也救不了。这里把 target 转成str是防止 ChemicalID(字符串)和 target(int)在 networkx 内部被当成不等的节点类型,导致建图后同一个节点出现两种 ID 表示。不少初学者在这翻车,你查G.nodes会发现1956和"1956"同时存在。

2.3 用 scipy.sparse 构建邻接矩阵,别听教程里的 numpy dense

推荐系统里大量操作是矩阵乘法和特征广播,所以我习惯同时保留 networkx 图和稀疏邻接矩阵。邻接矩阵的维数是“全部实体数量”,几千到几万都很正常;如果用np.zeros((n,n)),1 万节点就要 800MB float,后面 TransE 训练再一比组合就爆了。正确做法是 coo_matrix 再转 csr:

import numpy as np from scipy.sparse import coo_matrix nodes = list(G.nodes()) node2idx = {n: i for i, n in enumerate(nodes)} row, col = [], [] for u, v in G.edges(): row.append(node2idx[u]) col.append(node2idx[v]) adj = coo_matrix( (np.ones(len(row)), (row, col)), shape=(len(nodes), len(nodes)), ).tocsr()

coo_matrix只记录非零位置,内存随边数而不是节点平方增长。转成csr是因为后面做adj@vector这类稀疏矩阵运算时 CSR 格式更快。node2idx这个字典在后面提取嵌入、生成训练样本时必须复用,否则对齐错位不会报错,只会默默给你一个错误的 AUC。

为了验证邻接矩阵和 networkx 图一致,我通常会抽查几个节点,对比adj[node2idx[drug]]和G.neighbors(drug):

test_drug = nodes[0] row_idx = node2idx[test_drug] neigh_in_adj = adj[row_idx].indices neigh_in_G = [node2idx[n] for n in G.neighbors(test_drug)] assert set(neigh_in_adj) == set(neigh_in_G), "graph and adj mismatch"

这里的断言很便宜但值得做。数据管道写好之后,改过一次 ID 清洗就可能导致两套结构不一致,模型训练还看不出问题。我每次换数据源都会跑一遍这个断言,作为回归测试。

2.4 建完图先看三样东西:连通分量、节点度、路径数量

别急着调模型。我先打印三样检查结果:

  1. 孤立节点数:如果某药物没有和任何靶点相连,它在图模型里永远预测不出分数,要么回退到内容特征,要么在构建阶段用相似关系连到已有节点。
  2. 度最大的 10 个节点:是否有类似“万金油”的 hub 节点,比如 TP53、EGF 这种,后面随机游走很容易都游走到它们身上。
  3. 随机抽 100 对“无直接边”的节点,统计 3 跳内可达的比例,作为图连通性的粗略指标。如果大多数不可达,说明图谱碎片化,推荐算法的协同信号很弱。

这三个检查不需要单独的代码文件,用 Python 交互式环境几行就能完成。但这些检查结果直接决定你后续选 PRA 还是 TransE:如果图严重碎片化,TransE 嵌入很难学到远程依赖,基于局部路径的方法反而是更稳的起点。

3. 把 DTI 当推荐问题:为什么药物靶点交互可以套用推荐系统

3.1 把药物想象成用户,靶点想象成物品

在推荐系统里,协同过滤的基本假设是“相似用户喜欢相似物品”。放到药物重定位场景:如果药物 A 和药物 B 在已知交互的靶点集合上高度重叠(share 很多靶点),那么药物 A 很可能和药物 B 的其余靶点也有交互。靶点同理。这就是最朴素的基于召回和排序的 DTI 预测。这里的“交互”是隐式反馈,不像评分有 1-5 星,只有“有/无”,所以我们要用 implicit feedback 的思路,不能直接套皮尔逊相关系数之类依赖评分值的方法。

这时知识图谱的价值就出来了:传统协同过滤只看药物-靶点二部图,容易冷启动;而知识图谱给每个药物关联了适应症、机制、ATC 分类,给每个靶点关联了通路、疾病、基因本体,这些边信息一方面增加路径特征,一方面降低对“评分数据”的依赖。这正是推荐系统里把知识图谱作为 side information 的标准用法。

选型理由提一下:基于平移距离的模型(TransE)和基于路径的模型(PRA)都是“知识图谱 + 推荐”的常见呈现。前者用嵌入表示实体,后者用显式路径特征。两者不冲突,可以 ensemble。我先在原型里用 PRA 做可解释基线,再用 TransE 做训练。

3.2 先跑一个“普通邻居重合度”基线:零训练、可解释、稳

做机器学习前,先做一个合理的启发式基线。它不依赖随机种子,也能帮你检查图谱质量。我们要预测候选药物-靶点对是否真有交互,一个非常强的特征是它们在图上的普通邻居重合度。如果药物 D 和靶点 T 没有直接边,但它们都连到基因 X,那么 X 作为共同邻居是有信号的。代码:

def jaccard_neighbors(G, drug, target): neigh_drug = set(G.neighbors(drug)) neigh_target = set(G.neighbors(target)) if not neigh_drug or not neigh_target: return 0.0 union = neigh_drug | neigh_target inter = neigh_drug & neigh_target return len(inter) / len(union)

一样地,还可以算 Dice 系数或共同邻居数。jaccard隐含的意义是:两个节点共享的邻居占整体邻居的比。共享邻居太少,说明它们在网络中处于不同区域,预测为正例的可信度低。我用它作为特征,而不是直接当分数,是因为单独用它的精度有限,但在样本量小的时候足以区分大部分明显负例。

在所有候选对上跑这个函数,然后用sklearn的roc_auc_score评估,你会得到一个“不用任何训练”的基线。如果这个基线 AUC 低于 0.6,说明图谱质量有问题,后面再上深度学习也是白搭。我会把这个当作数据质检关卡。

3.3 把路径变成特征:PRA 思路与随机游走实现

普通邻居只走一跳。更长路径,比如“药物 → 疾病 → 靶点”,虽然没有共享的靶点节点,但它们在同一个疾病分支里,也有语义信号。基于路径的排序就是这样:对每种“节点类型序列”统计路径数量。实现可以用nx.all_simple_paths,但深度不要超过 3;也可以随机游走统计命中,这两种我都用,它们对应不同的稳定性。

先看简单路径版:

from collections import Counter def path_type_hist(G, drug, target, max_depth=3): hist = Counter() for path in nx.all_simple_paths(G, drug, target, cutoff=max_depth): type_seq = tuple(G.nodes[n].get("node_type", "other") for n in path) hist[type_seq] += 1 return hist

cutoff=3表示最多走 3 条边。路径再长,组合数爆炸,而且很容易绕圈绕到一个 hub 节点,带来大量假信号。这个函数返回类似{('drug','target','target'): 2}的字典,但注意路径中可能经过多个 target——如果图谱里有二部图的边,那drug -> target -> other_drug -> target这种会很多。可以把这种路径类型看成特征名字,逻辑回归就能学出哪些路径类型更代表正例。

问题在于all_simple_paths在大图上非常贵,因为它是生成器,会枚举所有简单路径。如果图有 5000 节点、平均度 5,深度 3 的路径数量可能十几万条,乘以候选对就是灾难。因此我更多使用随机游走:

import random def random_walk_hit_rate(G, start, target, walk_len=4, n_walks=50): hits = 0 for _ in range(n_walks): node = start for _ in range(walk_len): neighbors = list(G.neighbors(node)) if not neighbors: break node = random.choice(neighbors) if node == target: hits += 1 break return hits / n_walks

这里有参数n_walks和walk_len。n_walks越多,估计越稳,但耗时线性涨;walk_len越长,越能探索远程关系,但也容易跑偏。我一般从n_walks=50, walk_len=3起步,后面加到 200 和 4,看指标有没有变化,没有就停下来。这个 hit rate 可以当一个标量特征,和普通邻居重合度、直接路径数并起来。它本质上是 DeepWalk/Node2Vec 的简化:把图的局部结构压缩成一个概率值,只不过我们只关心起点到终点的命中率,而不是全图嵌入。

随机游走比 all_simple_paths 快的另一个原因是它不保证“无重复节点”,允许走回头路,等于在采样带噪路径。这对小样本 DTI 反而有正则效果。推荐系统里很多图算法也用这种带噪采样,因为它更能抵抗图噪音。

3.4 参数设置与优先级:先调哪些不玄学

这里给一个“先动谁”的顺序表:

参数建议范围影响优先级
路径最大长度 max_step/cutoff2~3太长特征爆炸且信号变杂先固定2或3
随机游走次数 n_walks50~200稳定性,200后收益低第二个调
随机游走长度 walk_len3~5语义半径,太长容易游走到 hub第三个调
负采样比例1:1~1:2类别平衡,影响阈值必须监控
嵌入维度32~128太小欠拟合,太大过拟合看数据量

参数不是越多越好。我踩过的坑就是同时调五个参数,最后根本不知道谁提升的指标。建议按这个优先级:先把 max_step 固定为 3,n_walks 设为 100,然后把特征归一化,用逻辑回归跑一遍再说。逻辑回归本身有 L2 正则,参数 C 可以搜,但对原型够用。

把上述三个特征拼成一个向量,标准化后用 LogisticRegression。示例:

from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_val_score # X 是所有候选药物-靶点对的 [jaccard, path_type_count, walk_hit_rate] clf = make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=1000)) scores = cross_val_score(clf, X, y, cv=5, scoring="roc_auc") print(scores.mean())

先别上 GNN,一个逻辑回归加路径特征通常就能到 0.7~0.85 的 AUC,而且完全可解释。这个阶段对理解数据非常有帮助。

4. 从二部图嵌入到完整模型:最小 TransE 训练、评估与操作指南

4.1 负采样:这是二分类模型的地基,请务必用随机种子

在知识图谱中我们只有正例边,没有“不交互”的边。推荐系统里常见的做法是把未知当成负样本,但在 DTI 里这是有风险的:很多未知只是“未被验证”,不是真的没有交互。所以负样本也叫“软负例”。采样方法有三种:随机替换头实体、随机替换尾实体、随机替换两头。我常用随机替换头或尾,但必须保证替换后不在已知正样本集合里。

下面是一个负采样函数,它接受正样本边列表和已知边集合。关键参数neg_ratio建议从 1.0 开始,也就是每个正样本配一个负样本。你会发现训练更快,指标也更稳定:

import numpy as np def negative_sampling(edges, all_nodes, known_set, neg_ratio=1.0, seed=42): rng = np.random.default_rng(seed) all_nodes = np.asarray(all_nodes) pos_size = len(edges) neg_size = int(pos_size * neg_ratio) negs = [] while len(negs) < neg_size: idx = rng.integers(0, pos_size) h, t = edges[idx] # 从正样本中取一个真边 if rng.integers(0, 2) == 0: h2 = rng.choice(all_nodes) pair = (h2, t) else: t2 = rng.choice(all_nodes) pair = (h, t2) if pair not in known_set: negs.append(pair) return negs

其中edges是正样本列表,all_nodes是全部实体列表,known_set是“任何已知但当前不在训练集里的关系”集合。有一项必须提醒:known_set里要把所有正样本(包括测试集)都放进去。最安全的方式是先把所有正样本分成 train_pos 和 test_pos,然后负采样时known_set = all_pos,这样训练负样本不会误伤 train 里未出现的正样本,但训练时模型可能把 test_pos 当作负例?严格来说,负采样阶段要把 test_pos 也排除在外,否则会造成“测试集正例被模型当作负例训练”这不算泄漏,但会让评估标签不一致。更干净的做法是先在全部已知边上采样负样本,然后按分边划分到 train/test 的负样本集,但划分后必须保证同一个 pair 不出现在两侧。这里提供的是基础版,足够复现。

4.2 用 PyTorch 写一个最小 TransE:20 行核心代码

TransE 是知识图谱嵌入最简单也最稳定的模型。它把每个实体映射到一个 d 维向量,每个关系也映射为一个 d 维“平移向量”,要求对于一个正确三元组(h, r, t),满足h + r ≈ t。这正好适合 DTI:药物 + 交互关系 ≈ 靶点。关系interacts_with只有一个,所以模型会退化成“药物向量 + 关系向量 = 靶点向量”,再通过内积或距离打分。这在信息学上也等价于一种基于平移的矩阵分解。

代码如下:

import torch import torch.nn as nn class TransE(nn.Module): def __init__(self, n_ent, n_rel, dim=50, margin=1.0): super().__init__() self.emb_e = nn.Embedding(n_ent, dim) self.emb_r = nn.Embedding(n_rel, dim) self.margin = margin def distance(self, h, r, t): # L1 距离:h + r 应该靠近 t return torch.norm(self.emb_e(h) + self.emb_r(r) - self.emb_e(t), p=1, dim=1) def forward(self, ph, pr, pt, nh, nr, nt): d_pos = self.distance(ph, pr, pt) d_neg = self.distance(nh, nr, nt) loss = torch.relu(self.margin + d_pos - d_neg).mean() return loss

margin的意义是正样本距离至少要比负样本距离小 margin 这个余量。margin太小,正负样本区分不明显;太大,模型只关注难分样本,训练震荡。我常用 1.0 起步,数据量小的时候可以放到 2.0。dim默认 50,当节点只有几千时,50 维已经够用;盲目上 200 维只会过拟合。p=1是曼哈顿距离,比欧氏距离更稳定,这也是 TransE 论文里常用的。

训练循环:

model = TransE(n_ent=len(nodes), n_rel=len(relations), dim=50) opt = torch.optim.Adam(model.parameters(), lr=0.001) for epoch in range(100): opt.zero_grad() loss = model(pos_h, pos_r, pos_t, neg_h, neg_r, neg_t) loss.backward() opt.step() if epoch % 10 == 0: print(f"epoch {epoch}: loss {loss.item():.4f}")

这里pos_h, pos_t是从训练正样本对映射到整数索引的 LongTensor,pos_r全为关系interacts_with的索引 0。负样本同理。注意每次迭代都用同一份负样本可能让模型记住它们,所以理论上每个 epoch 要重新采样。为了代码简洁,我一般每 10 个 epoch 重新采样一次。如果你要复现论文指标,必须固定随机种子固定负样本序列,这两者矛盾,我的取舍是:调参时固定负样本;正式训练时每 10 epoch 重采样。

4.3 把嵌入内积当作推荐分数:评估的时候不看 loss,看 AUC 和 AP

TransE 训练完成后,我们要给“候选药物-靶点对”打分。两种方式:一是用distance的负数,距离越近分数越高;二是直接把药物向量和靶点向量做内积。内积没有经过 margin 约束,但实现最简单,和推荐系统的矩阵分解很像。我一般用内积,再单独做一次标准化。

import numpy as np from sklearn.metrics import roc_auc_score, average_precision_score with torch.no_grad(): emb_e = model.emb_e.weight.numpy() score_test = np.sum(emb_e[test_drug_idx] * emb_e[test_target_idx], axis=1) # score_test 高代表预测交互 print("AUC", roc_auc_score(test_label, score_test)) print("AP", average_precision_score(test_label, score_test))

评估要在独立测试样本上做。测试样本包括两部分:一部分是训练图中去除部分边的“半冷启动”链路预测,一部分是“完全冷门”的新药物或新靶点。如果只有前者,模型很可能靠记忆节点 ID 来得分,不能证明泛化能力。所以我在分割数据时保留了一组“new target”样本,测试时这些靶点不出现在图里。不过 TransE 在这种情况下无法直接训练,需要冷启动回退,这点在下一节和避坑章细说。

4.4 操作指南:从建好图到跑通的最小命令与常见环境问题

到了落地环节,给出可复制的最小命令。首先,项目目录建议这样组织:

dti_project/ data/CTD_chemical_gene.tsv src/build_kg.py src/train_transe.py src/evaluate.py output/

然后环境安装。Python 版本建议 3.8~3.10,PyTorch 在 3.11 上也可以装,但有些旧版本 SciPy 不兼容,没必要给自己增加不确定性。在命令行执行:

python -m venv dti_env # Windows PowerShell 激活: dti_env\Scripts\activate # macOS/Linux 激活: source dti_env/bin/activate pip install --upgrade pip pip install pandas numpy networkx scipy scikit-learn pip install torch --index-url https://download.pytorch.org/whl/cpu

最后一行是指定 CPU 版 PyTorch;如果你的机器有 Nvidia GPU,去掉--index-url,直接pip install torch会自动装 CUDA 版。这里有个小经验:首次学习不需要 GPU,CPU 版跑这两千条边和两万条负样本,100 epoch 也就是几分钟。

接着把三段脚本串起来。我通常会写一个run_all.sh,但在 Windows 上更顺手的是直接在命令行里依次执行:

python src/build_kg.py python src/train_transe.py python src/evaluate.py

提示:CTD 文件的注释行数会随版本变化,skiprows 请用第 2.1 节的查看方法确认后填写。

如果build_kg.py报ImportError: No module named 'sklearn',按上面 pip 命令重新安装即可。还有一个高频错误是skiprows=27不匹配,因为 CTD 的表头统计行数会变。最简单的排查方法是在脚本里用第 2.1 节里的读取方式,先打印前 40 行,确认实际行数再填。装好环境之后,torch显示 CPU 版不像 GPU 版那样有设备名,但训练速度没有大差别。

到这里,整个“推荐系统 + 知识图谱 + DTI”的最小闭环就跑通了。但是,别急着高兴,真正会让你翻车的是第 5 章那些细节。

5. 避坑 / 常见问题 / 排查:药物靶点交互预测最容易翻车的五个细节

5.1 数据泄漏:训练集和测试集剪不断理还乱

现象:roc_auc_score给出 0.94,你在心里已经把论文题目都想好了。但把模型放到一个从未见过的新药物上,预测结果和随机猜差不多。

原因:最常见的错误是在构建测试集时,直接从全部三元组中随机抽取 10% 的边。这样同一个药物在训练集中出现,在测试集中也有它的另一条边;图是全局共享的,TransE 的实体向量自然记住这个药物。测试时它只是“还记得这个节点”,而不是“真的推理”。这就造成了数据泄漏。

解决:用“按药物分组”的留出法。先把所有药物 ID 划分成训练药物和测试药物两个集合,确保测试药物在整个图的构建过程中不出现在任何一条训练边里。在构建图的时候,用训练药物包含的所有边建图;测试药物本身虽然可作为新节点加入,但它的所有边都进测试集,不参与训练。代码上就是分组unique_drugs = train_drugs | test_drugs,然后建图前过滤。不要等到建完图再删边,否则图结构仍然包含测试实体的间接信息。

5.2 负采样方向:随机替换头实体和替换尾实体,结果完全不一样

现象:loss 收敛到 0.1 以下,但 AUC 只有 0.65。另一个模型用同样的参数,只改了替换方式,AUC 到了 0.82。

原因:在 DTI 这个场景里,药物实体和靶点实体的数量差异很大。如果靶点只有几百个,随机替换尾实体(靶点)时很容易撞到另一个已知交互靶点,负例虽然不在 known_set 里,但它本质上仍是潜在正例,类别噪声大;而随机替换头实体(药物)因为药物多,负例更可靠。反向则相反。

解决:不要固定一种替换策略。在负采样时同时生成两类负样本:药物替换组和靶点替换组,然后合并,比例可以按照 1:1。此外,known_set必须包含所有“当前已知能排除”的边。我一般还维护一个“已标注但不在训练集中”的关系集合,用来做召回过滤,保证测试正例不会被当成负例输出。具体做法:负采样函数加一个参数exclude_set,传入全部正样本的并集,而不是训练集正样本。

5.3 孤立节点与冷启动:新药一进图就成了无依无靠的孤儿

现象:你新增了 100 个候选药物,它们在知识图谱里一个邻居都没有(比如还没有任何实验记录)。模型对这些药物输出的分数全部在一个很窄的区间波动,基本等于随机。

原因:TransE 和基于路径的模型都是局部结构方法,没有邻居就没有信息。你不可能要求一个“纯图模型”在零边情况下做出有意义的推断。这是知识图谱方法的天花板,不是工程 bug。

解决:采用“冷启动策略分离”。如果新药有分子结构,就计算 Morgan 指纹(半径 2,长度 1024 位),用 Tanimoto 相似和已有药物做最近邻;如果新靶点有蛋白序列,就用氨基酸 k-mer 的 comp 计数和已有靶点做相似度。然后把这些内容相似度作为另一个特征和 TransE 分数融合。换句话说,把问题拆成两块:有邻居的节点走图模型,没有邻居的节点走内容相似度模型。这个融合不要等模型训练完再做,应该在验证时就用 holdout 的孤立节点评估效果。

5.4 图谱太稠密:万金油 hub 节点让随机游走变成“景点打卡”

现象:随机游走特征几乎全是 0.3 左右的均值,药物和靶点之间的分数差异被抹平。

原因:图中有极少数节点,比如 TP53、EGF、TNF,几乎和所有疾病、药物都有关联。随机游走只要走到这些节点,就很容易走到目标,而不管这个候选对在机制上是否相关。结果是每个候选靶点都有很高的 hit rate,模型学不到区分度。

解决:做“hub 惩罚”。计算每个节点度,随机游走在选择下一步时对大于某个阈值的节点降权,或者直接去掉一些“万金油”节点。还有一个简单做法是每次游走记录是否经过 hub,把“经过 hub 的次数”作为额外特征,让逻辑回归学一个负系数。这个惩罚权重需要实验调,我通常从度分布的 99% 分位数作为阈值开始,把度高于它的节点概率乘以 0.3。

节点度分布如何看?用nx.degree(G)直接画个直方图,在 hub 处会断崖。如果不做处理,TransE 的嵌入也能学到“只要向量朝向 hub 就加分”,这和药物机制无关。

5.5 训练评估拆分方式:别把“旧药新靶点”和“新药新靶点”混在一起报指标

现象:你报告一个 AUC 0.90;换一种数据切分,同一步代码报 0.70;审稿人或领导只会有一种反应——“你对结果负责吗?”

原因:DTI 预测有多种实际场景:已知药物、新靶点;新药物、已知靶点;两者都新。每种场景难度完全不一样。随机的边级切分因为存在重叠,相当于开卷考试,AUC 虚高;按实体切分才像闭卷考试。

解决:在验证时把测试集划分为四个子集:旧药-旧靶点(但边从图中去掉)、旧药-新靶点、新药-旧靶点、新药-新靶点,分别汇报 AUC/AP。我在代码里用一个dataset_version参数标记切分方式,模型评估直接复用同一个函数。这样评委问起来,你拿出来的每个数字都有对应的任务定义,不会翻车。我见过的成熟团队都会在 README 里写一句“本模型的指标是基于实体(药物/靶点)独立切分,并非随机边切分”,这句话能挡掉一大半质疑。

注意:这里说的“新药-新靶点”不是指分子序列从未出现,而是指该实体的所有边都不在训练图内;如果只是在测试集里删掉对应边,仍然会泄漏。

6. 验证与进阶:让预测结果不再是黑匣子,用路径回溯把 Top-N 变成可解释证据

模型跑完,指标报完,工作其实只做了一半。如果领导问了“这个预测二甲双胍和 EGFR 相互作用的依据是什么?”你不能只说“模型输出 0.87”。我一般会把 Top-N 预测对重新映射回知识图谱,用最短路径给每条预测补一段人工可读的证据链。比如:

import networkx as nx for drug, target, score in top_n: try: shortest = nx.shortest_path(G, source=drug, target=target) evidence = " -> ".join(shortest) except nx.NetworkXNoPath: evidence = "no connectable path" print(f"{drug}\t{target}\t{score:.3f}\t{evidence}")

这段代码看着简单,价值却很高:它把纯向量内积变成了可复核的因果链。如果路径是“药物 -> 基因A -> 药物B -> 靶点”,你就知道这是个间接证据,权重可能低;如果路径是“药物 -> 疾病 -> 靶点”,更接近临床语义。我把路径类型当成一个后置过滤器,只保留“drug -> disease -> target”这类强证据路径。这一步能在不重训练的情况下显著提高推荐结果的可信度。

进阶做法是把路径证据和嵌入分数做一个极简融合排序:先把路径计数归一化到 0~1,再和 TransE 内积分数各取 0.5 权重相加。相比只用内积,混合排序往往能抑制“hub 节点带来高嵌入相似度但缺乏机制关联”的假阳性。这个融合不需要调复杂模型,一个weight_dict足够。验证时,先看混合排序下 Top-50 里被文献支持的比例,而不是只看 AUC;我在实际项目里用这个比例作为给实验室的交付指标。

最后说一个我自己的教训。很早以前我跑 DTI,用全局随机切分拿到了 0.96 的 AUC,还沾沾自喜发了个内部分享。后来被同事提醒做一次冷启动验证,相同的代码、相同的数据,按新靶点切分后 AUC 直接摔到 0.71。从那以后,我给自己立了个规矩:无论模型多花哨,第一版指标永远先报“新药-新靶点”那栏,那个数字才是实验室愿意投入真金白银的依据。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 15:49:11

OpenMontage:面向视频生产的智能工作流编排引擎

1. OpenMontage 不是另一个 AI Agent 框架&#xff0c;而是一套视频生产流水线的“智能调度中枢”OpenMontage 这个名字乍一听&#xff0c;很容易被归入当前满天飞的“AI Agent”浪潮里——毕竟热搜词里“agent”出现了二十多次&#xff0c;从 Hermes Agent 到 LangChain、Crew…

作者头像 李华
网站建设 2026/10/26 15:48:19

CLI端到端测试实战:解决model not found与安装慢问题

1. 项目概述&#xff1a;e2e 不是缩写游戏&#xff0c;而是软件质量的生命线“e2e”这三个字母在开发者日常沟通里出现频率高得离谱&#xff0c;但很多人张口就来&#xff0c;却未必真清楚它压舱石般的意义。它不是某个新出 CLI 工具的代号&#xff0c;也不是某款模型启动失败时…

作者头像 李华
网站建设 2026/10/26 15:47:33

接口自动化测试的核心价值与实践指南

1. 接口自动化测试的核心价值与挑战在当今快速迭代的软件开发环境中&#xff0c;接口作为系统间通信的桥梁&#xff0c;其稳定性直接影响整个产品的质量。传统手工测试在面对频繁变更的接口时显得力不从心——我曾经历过一个电商项目&#xff0c;每次大促前需要验证300接口&…

作者头像 李华
网站建设 2026/10/26 15:46:59

C#上位机结合YOLO实时检测的性能优化实战

1. 项目概述&#xff1a;C#上位机与YOLO实时检测的优化挑战 在工业检测、安防监控等需要实时目标识别的场景中&#xff0c;C#开发的上位机结合YOLO算法是常见的技术方案。但许多开发者会遇到程序卡顿、闪退等性能问题&#xff0c;特别是在处理高分辨率视频流时。本文将分享我在…

作者头像 李华
网站建设 2026/10/26 15:46:49

OpenMontage项目解析:概念、技术定位与验证式实践指南

我无法基于当前输入生成符合要求的博文。原因如下&#xff1a;输入中仅提供了项目标题"OpenMontage"&#xff0c;但未提供任何实质性的【项目正文】、【关键词】或【摘要描述】&#xff1b;网络搜索内容部分为空&#xff08;内无实际信息&#xff09;&#xff0c;无法…

作者头像 李华
网站建设 2026/10/26 15:45:54

Docker + CentOS 7 离线部署 ElasticSearch 单机版实战

内网机房里那台 CentOS 7 服务器&#xff0c;网线插着但出不了公网&#xff0c;yum 源是本地镜像&#xff0c;连pip install都得走内部源。项目要做全文检索&#xff0c;技术选型会上定的是 ElasticSearch&#xff0c;要求单机部署、数据不能丢、重启后能自己起来。我第一次接这…

作者头像 李华