图数据结构基础:节点、边、属性、邻居采样

做药物图谱,第一步就是搞清楚图到底长什么样。说白了,图就是个「东西」和「关系」的集合。你想想看,一个药物分子里的原子就是节点,化学键就是边——就是这么直白。

我当年刚接触图神经网络时,总觉得这东西特别玄乎。后来发现,把图拆开了看,无非就是几个核心部件在组合。今天我们就拿 NetworkX 这把瑞士军刀,把这些基础概念一个个扒清楚。

节点与边:图的两个主角

节点(Node)就是图中的「实体」。在药物图谱里,节点可以是:

  • 一个药物分子
  • 某个靶点蛋白
  • 一条生物通路
  • 一个疾病表型

边(Edge)就是实体之间的「联系」。常见的边类型有:

边类型 含义 药物场景举例
无向边 关系是对称的,没有方向 药物A与靶点B结合
有向边 关系有方向性 药物A激活通路C
加权边 关系有强度 结合亲和力 pIC50 值
我个人习惯用 NetworkX 的 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}
属性是图神经网络的「燃料」。没有属性,GNN 学到的只是拓扑结构,药物信息就全浪费了。

邻居采样:图计算的核心操作

做药物图谱预测时,你不可能每次都遍历整个图——那太慢了。我们需要的是 邻居采样(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 的这几个操作练熟。节点怎么加、边怎么带属性、邻居怎么采样——这些是后面几十章课程都在反复用的基本功。

总结一句话: 图 = 节点(实体)+ 边(关系)+ 属性(特征描述)。 邻居采样 = 在图上做「局部视角」的切分,让 GNN 能高效学习。 先学会用 NetworkX 操纵这三个要素,后面调参才有意义。