第4章:创建和操作DGL图对象

图神经网络的核心是图。说白了,你得先把数据装进图这个容器里,才能让模型跑起来。DGL提供了两套主力API——dgl.graph和dgl.heterograph,一个管同构图,一个管异构图。我当年刚接触DGL时,觉得这俩不就是换个函数名嘛,结果写出来的代码频频报错,后来才发现……嗯,关键在于搞清楚你手里的数据到底“同质”还是“异质”。

这一章,我们就来把这两个API吃透。我还会分享一些实战中趟过的坑。

DGL图对象创建:知识体系 DGL 图对象 dgl.graph(同构图) 核心参数 • edges: (u, v) 元组或Tensor • num_nodes: 可选,指定节点数 • edge_feats: 边特征 dgl.heterograph(异构图) 核心参数 • edges_dict: dict[str, (u, v)] • node_dict: dict[str, int] • 典型键: ('药物', '相互作用', '药物') 通用操作 • 节点/边特征添加:g.ndata['feat'] = tensor • 子图提取:dgl.node_subgraph, dgl.edge_subgraph • 图序列化:dgl.save_graphs / dgl.load_graphs

4.1 dgl.graph:构建同构图

先讲最简单的。如果你图里所有节点都是同一类,边也是同一类关系——比如蛋白质-蛋白质相互作用网络——那就用dgl.graph。它的核心输入就一个:边的起点和终点列表。

来看个最基础的例子:

import dgl
import torch

# 定义边:节点0→1,节点0→2,节点1→2
u = torch.tensor([0, 0, 1])
v = torch.tensor([1, 2, 2])

g = dgl.graph((u, v))
print(g)
print('节点数:', g.num_nodes())
print('边数:', g.num_edges())

你会看到输出里DGL自动推断出3个节点和3条边。它会取u和v

💡 建议: 尽量让你的节点ID从0开始连续编号。如果实在要处理稀疏ID,记得手动传num_nodes参数,避免浪费内存。
# 手动指定节点数
g = dgl.graph((u, v), num_nodes=5)
print(g)

4.2 给图添加特征

图建好了,下一步就是塞特征。DGL用ndata和edata两个字典来挂载节点特征和边特征,非常直观。

# 节点特征:假设每个节点有一个32维向量
g.ndata['feat'] = torch.randn(3, 32)
g.ndata['label'] = torch.tensor([0, 1, 0])

# 边特征:每条边有一个16维向量
g.edata['weight'] = torch.randn(3, 16)

print(g.ndata['feat'].shape)
print(g.edata['weight'].shape)

有一点我刚开始总搞混:ndata里每个特征的第一维长度必须等于num_nodes(),edata同理。如果你图有3个节点却传了4个特征向量,DGL会直接抛异常。这个设计其实挺好,帮你提前发现数据对齐的问题。

⚠️ 注意: DGL对特征的数据类型和形状检查非常严格。你往ndata里塞特征时,第一维必须严格等于节点数,多一个少一个都不行。我曾经在数据预处理中加了padding,结果图里只有10个节点,特征却传了12行,debug了半小时。

4.3 dgl.heterograph:处理异构图

药物图神经网络里,图很少是同构的。你想,一个药物分子、一个蛋白质靶点、再加上两者之间的“结合”关系——节点类型就两种,边类型也有“化学结合”和“相互作用”之分。这就得上dgl.heterograph了。

它的输入是一个字典,键是三元组('源节点类型', '边类型', '目标节点类型'),值是(u, v)元组。我来给你写个药物-靶点网络的例子:

# 构建异构图
graph_data = {
    ('drug', 'binds', 'protein'): (torch.tensor([0, 1, 2]), torch.tensor([0, 1, 0])),
    ('drug', 'similar', 'drug'): (torch.tensor([0, 1]), torch.tensor([1, 2]))
}

hg = dgl.heterograph(graph_data)
print(hg)
print('节点类型:', hg.ntypes)
print('边类型:', hg.canonical_etypes)

输出里ntypes会列出所有节点类型,canonical_etypes则给出规范化的边类型三元组。DGL内部会给每种节点类型分配独立的ID空间,所以drug的节点0和protein的节点0是两码事。

为了帮你快速对比,我列了个表格:

维度dgl.graphdgl.heterograph
节点类型数1种多种
边类型数1种多种
输入格式(u, v) 元组字典,键为三元组
ID空间全局统一每种节点类型独立
适用场景社交网络、分子图知识图谱、药物-靶点网络

4.4 异构图特征操作

给异构图加特征,需要在ndata和edata前面加上节点/边类型前缀。写法是g.nodes['drug'].data['feat'],而不是g.ndata['feat']。

# 给药物节点加特征
hg.nodes['drug'].data['feat'] = torch.randn(3, 64)
hg.nodes['protein'].data['feat'] = torch.randn(2, 128)

# 给特定边类型加特征
hg.edges['binds'].data['affinity'] = torch.randn(3, 1)

print(hg.nodes['drug'].data['feat'].shape)
print(hg.edges['binds'].data['affinity'].shape)

我记得第一次用异构图时,写了个hg.ndata['feat'],结果报错说ndata找不到。后来才意识到,异构图上每个节点类型的特征维度可能不同,DGL不允许你混着来。这个设计很合理——药物节点和蛋白质节点的特征维度不一样,本来就是常事。

4.5 实用操作与避坑指南

我整理了三个最常用的操作,以及我曾经踩过的坑,希望对你有帮助。

4.5.1 获取近邻节点

不管是同构图还是异构图,g.successors()和g.predecessors()都是一把好手。但对于异构图,要指定边类型:

# 同构图:获取节点0的所有后继
neighbors = g.successors(0)

# 异构图:获取drug节点0通过binds边连接的protein节点
neighbors = hg.successors(0, etype='binds')
print(neighbors)

4.5.2 构建子图

我经常需要根据某些条件筛选节点,然后只在这个子集上做计算。DGL提供了node_subgraph和edge_subgraph:

# 提取节点0和1构成的子图
sub_g = dgl.node_subgraph(g, [0, 1])
print(sub_g)

# 异构图也支持:提取drug类型的[0,1]和protein类型的[0]
sub_hg = dgl.node_subgraph(hg, {'drug': [0, 1], 'protein': [0]})
print(sub_hg)
💡 经验: 子图操作会保留原图的特征,但边只会保留那些两端节点都在子图中的边。如果你只想要特定边类型,可以先通过边类型筛选再做子图,效率更高。

4.5.3 图的序列化

训练好的图数据或模型中间结果,一般用dgl.save_graphs和dgl.load_graphs保存和读取:

# 保存
dgl.save_graphs('./demo_graph.bin', [g, hg])

# 加载
loaded_graphs, _ = dgl.load_graphs('./demo_graph.bin')
loaded_g = loaded_graphs[0]
loaded_hg = loaded_graphs[1]

我曾经在保存图时忘了检查特征的数据类型,结果加载出来发现float64变成了float32。DGL默认会做类型转换,如果你对精度有要求,最好在保存前用.type(torch.float32)统一一下。

⚠️ 避坑: 保存异构图时,如果某类节点或边没有特征,DGL会正常保存。但加载后,你不一定能通过'feat'这个key直接访问到——因为保存时它就不存在。所以加载后最好先检查g.ndata或g.edata有哪些key,再做特征操作。我曾经一上来就写g.ndata['feat'],结果加载时直接KeyError,查了半天才发现是保存时漏了特征。