图数据结构基础:节点、边、属性、邻居采样
做药物图谱,第一步就是搞清楚图到底长什么样。说白了,图就是个「东西」和「关系」的集合。你想想看,一个药物分子里的原子就是节点,化学键就是边——就是这么直白。
我当年刚接触图神经网络时,总觉得这东西特别玄乎。后来发现,把图拆开了看,无非就是几个核心部件在组合。今天我们就拿 NetworkX 这把瑞士军刀,把这些基础概念一个个扒清楚。
节点与边:图的两个主角
节点(Node)就是图中的「实体」。在药物图谱里,节点可以是:
- 一个药物分子
- 某个靶点蛋白
- 一条生物通路
- 一个疾病表型
边(Edge)就是实体之间的「联系」。常见的边类型有:
| 边类型 | 含义 | 药物场景举例 |
|---|---|---|
| 无向边 | 关系是对称的,没有方向 | 药物A与靶点B结合 |
| 有向边 | 关系有方向性 | 药物A激活通路C |
| 加权边 | 关系有强度 | 结合亲和力 pIC50 值 |
DiGraph 来处理药物相互作用——虽然是双向的,但有向图能保留更多的生物关系信息,比如「激活」和「抑制」的区别。
属性:给节点和边加上「衣服」
光有节点和边是不够的。你想想,一个药物分子不止是个名字,它还有分子量、LogP、SMILES 结构等等。这些就是 属性(Attribute)。
NetworkX 里加属性特别简单:
import networkx as nx
# 创建一个药物-靶点图
G = nx.Graph()
# 添加节点,带上属性
G.add_node('Drug_A',
name='阿司匹林',
mol_weight=180.16,
logP=1.19)
# 添加边,也带属性
G.add_edge('Drug_A', 'Target_COX1',
interaction_type='inhibition',
pIC50=5.8,
assay_type='binding')
# 查看节点的属性字典
print(G.nodes['Drug_A'])
# 输出:{'name': '阿司匹林', 'mol_weight': 180.16, 'logP': 1.19}
邻居采样:图计算的核心操作
做药物图谱预测时,你不可能每次都遍历整个图——那太慢了。我们需要的是 邻居采样(Neighbor Sampling)。
什么是邻居采样?说白了,就是给定一个中心节点,只取它周围一定范围内的邻居。我当年做化合物性质预测时,整张图有上百万个节点,全部加载根本跑不动。后来改用邻居采样,训练时间从小时级降到了分钟级。
NetworkX 里的邻居访问
先看看最基础的操作:
# 直接访问一阶邻居
neighbors = list(G.neighbors('Drug_A'))
print(f"Drug_A 的邻居: {neighbors}")
# 访问带权重的邻居
for neighbor, edge_data in G['Drug_A'].items():
weight = edge_data.get('pIC50', 0)
print(f" → {neighbor} (pIC50: {weight})")
# 批量采样多个节点的邻居
def sample_neighbors(graph, nodes, k=2):
sampled = {}
for node in nodes:
neighbors = list(graph.neighbors(node))
# 如果邻居太多,随机采样 k 个
if len(neighbors) > k:
import random
neighbors = random.sample(neighbors, k)
sampled[node] = neighbors
return sampled
G.neighbors() 遍历所有邻居,结果内存直接爆了。记住:生产环境里一定要做采样,别想着全量加载。
多跳采样:二阶甚至三阶邻居
药物作用往往不是直接的——药物A通过靶点B影响通路C,再影响疾病D。这时候就需要 多跳采样(Multi-hop Sampling)。
def multi_hop_sampling(graph, seed_node, hops=2, sample_size=3):
"""
对指定节点进行多跳邻居采样
Args:
graph: NetworkX 图
seed_node: 起始节点
hops: 采样跳数
sample_size: 每跳采样的邻居数
"""
sampled_nodes = {seed_node: []}
current_nodes = [seed_node]
for hop in range(1, hops + 1):
next_nodes = []
for node in current_nodes:
neighbors = list(graph.neighbors(node))
if len(neighbors) > sample_size:
import random
neighbors = random.sample(neighbors, sample_size)
next_nodes.extend(neighbors)
if node not in sampled_nodes:
sampled_nodes[node] = neighbors
else:
sampled_nodes[node].extend(neighbors)
current_nodes = list(set(next_nodes))
return sampled_nodes
图的核心属性与度量
除了基本的结构,图本身还有一些「度量指标」,这些在药物网络分析里非常实用:
| 指标 | 含义 | 药物场景用法 |
|---|---|---|
| 度(Degree) | 节点的邻居数量 | 度高的药物往往是「广谱药」,作用靶点多 |
| 连通分量 | 图中有多少个独立的子图 | 不同疾病模块往往在不同的连通分量里 |
| 最短路径 | 两个节点间的最短距离 | 药物从靶点到表型的信号通路长度 |
| 聚类系数 | 节点的邻居之间互相连接的密度 | 功能模块的聚集程度,提示协同作用 |
NetworkX 里算这些指标就是一两行的事:
# 计算每个节点的度
degrees = dict(G.degree())
print("节点度排名:", sorted(degrees.items(), key=lambda x: -x[1])[:5])
# 找出所有连通分量
components = list(nx.connected_components(G))
print(f"图中有 {len(components)} 个连通分量")
# 最短路径
path = nx.shortest_path(G, 'Drug_A', 'Disease_X')
print(f"最短路径: {' → '.join(path)}")
# 聚类系数
clustering = nx.clustering(G)
print(f"Drug_A 的聚类系数: {clustering['Drug_A']:.3f}")
动手搭一个药物小网络
光说不练假把式。我们来搭一个真实的药物-靶点网络看看:
# 构建一个药物-靶点二分图
def build_drug_target_network():
G = nx.Graph()
# 药物节点
drugs = ['Aspirin', 'Ibuprofen', 'Atorvastatin', 'Metformin']
for d in drugs:
G.add_node(d, type='drug')
# 靶点节点
targets = ['COX1', 'COX2', 'HMGCR', 'AMPK']
for t in targets:
G.add_node(t, type='target')
# 药物-靶点相互作用(边带属性)
interactions = [
('Aspirin', 'COX1', {'pIC50': 5.8, 'mechanism': 'inhibition'}),
('Aspirin', 'COX2', {'pIC50': 4.2, 'mechanism': 'inhibition'}),
('Ibuprofen', 'COX1', {'pIC50': 6.1, 'mechanism': 'inhibition'}),
('Ibuprofen', 'COX2', {'pIC50': 6.5, 'mechanism': 'inhibition'}),
('Atorvastatin', 'HMGCR', {'pIC50': 8.2, 'mechanism': 'inhibition'}),
('Metformin', 'AMPK', {'pIC50': 3.5, 'mechanism': 'activation'}),
('AMPK', 'HMGCR', {'pIC50': 2.1, 'mechanism': 'regulation'}), # 靶点间的联系
]
for d, t, attr in interactions:
G.add_edge(d, t, **attr)
return G
# 创建网络并查看结构
drug_net = build_drug_target_network()
print(f"节点数: {drug_net.number_of_nodes()}")
print(f"边数: {drug_net.number_of_edges()}")
print(f"平均度: {sum(dict(drug_net.degree()).values()) / drug_net.number_of_nodes():.2f}")
nx.Graph.to_directed() 把无向图转为有向图。这样就能区分「药物抑制靶点」和「靶点激活通路」这两种不同的关系语义。
嗯,图数据结构基础就是这些。你可能会觉得这些概念简单,但它们是所有高级 GNN 模型的地基。我见过太多人跳过了这些基础,直接去调 GAT 或 GraphSAGE 的超参数,结果数据预处理就出了问题——节点属性没对齐,边权重含义搞反了,一夜的白费功夫。
所以,老老实实把 NetworkX 的这几个操作练熟。节点怎么加、边怎么带属性、邻居怎么采样——这些是后面几十章课程都在反复用的基本功。