图的基本概念与 DGL 中的图表示
做图神经网络,首先要搞明白图是什么。说白了就是一组节点和它们之间的连接。我第一次接触这概念时也觉得抽象,但一联想到药物分子,瞬间就通了——每个原子就是一个节点,化学键就是边。
嗯,这一章我们会从最基础的图论概念讲起,然后落到 DGL 的代码实现上。我会尽量少讲废话,直接上干货。
图的基本要素:节点与边
你想想看,生活中到处都是图。社交网络里每个人是一个节点,好友关系是边。电商网站里商品是节点,共同购买记录是边。药物分子里原子是节点,化学键就是边。
图可以分为两类:有向图和无向图。有向图就像微博的关注关系——你关注了别人,别人不一定关注你。无向图就像微信好友——是双向的。药物分子里的化学键也是无向的,不分方向。
| 图的类型 | 边是否有方向 | 现实例子 | DGL 默认处理 |
|---|---|---|---|
| 有向图 | 是 | 微博关注、流程控制 | 支持,用 directed=True |
| 无向图 | 否 | 分子结构、火车路线 | 支持,用 directed=False |
| 加权图 | 边带权重 | 信号强度、距离成本 | 通过边特征实现 |
| 异构图 | 节点/边分类型 | 知识图谱、推荐系统 | DGL 有专门支持 |
邻接矩阵:图的数学表示
图要喂给计算机,总得有个数学形式吧?最直观的就是邻接矩阵。假设有 N 个节点,那就用一个 N×N 的矩阵,A[i][j] 表示节点 i 和节点 j 之间有没有边。有边就是 1,没边就是 0。
我之前做项目时就踩过这个坑——全用稠密邻接矩阵存大图。一张十万节点的图,矩阵就有 100 亿个元素,内存直接炸了。所以实际场景里,图大多是稀疏的,我们通常用稀疏矩阵来存。
# 一个简单的稠密邻接矩阵
# 3 个节点,边:0→1, 1→2, 2→0
A = [
[0, 1, 0],
[0, 0, 1],
[1, 0, 0]
]
但说实话,药物分子图比这复杂多了。一个分子可能几十个原子,但每个原子只连接少数几个邻居,邻接矩阵里 90% 以上都是 0。所以我们更常用的是另一种格式——COO 格式,只存有边的那些位置。
# COO 格式:只存边的起点和终点
# 边:0→1, 1→2, 2→0
src = [0, 1, 2]
dst = [1, 2, 0]
DGL 中的 Graph 对象
好了,理论说够了,我们直接上代码。DGL 的核心就是 dgl.graph() 这个函数。你给它传两个列表——边的起点和终点,它就帮你建好图了。
import dgl
import torch
# 创建一个简单的有向图
src = torch.tensor([0, 1, 2, 3])
dst = torch.tensor([1, 2, 3, 0])
g = dgl.graph((src, dst))
print(g)
# 输出:Graph(num_nodes=4, num_edges=4,
# ndata_schemes={}
# edata_schemes={})
我个人习惯创建完图后立刻确认一下节点数和边数,免得后面跑模型时才发现数据对不上。
图的属性和基本操作
DGL 的 Graph 对象提供了很多实用方法,我挑几个最常用的说说。
# 获取图的属性
num_nodes = g.num_nodes() # 4
num_edges = g.num_edges() # 4
# 查看所有边
edges = g.edges()
print(edges) # (tensor([0,1,2,3]), tensor([1,2,3,0]))
# 查看某个节点的邻居
neighbors = g.successors(0) # 节点0的邻居:tensor([1])
# 无向图的两种情况
ug = dgl.graph((src, dst), num_nodes=4)
# 默认是有向图,需要手动转为无向
bg = dgl.to_bidirected(ug)
嗯,这里要注意一个容易忽略的点:DGL 默认创建的图是有向图。如果你处理药物分子这样的无向图,记得调用 dgl.to_bidirected() 把它变成双向的,否则信息只能在单方向传播。
节点特征和边特征
光有图的结构还不够——做 GNN 最关键的还是特征。DGL 用 ndata 和 edata 来管理节点和边的特征,说白了就是往节点和边上挂数据。
# 给节点添加特征:每个节点一个128维的向量
g.ndata['h'] = torch.randn(4, 128)
# 给边添加特征:每条边一个64维的向量
g.edata['w'] = torch.randn(4, 64)
# 访问特征
print(g.ndata['h'].shape) # torch.Size([4, 128])
print(g.edata['w'].shape) # torch.Size([4, 64])
举个例子,在药物分子图里,ndata['h'] 可以存原子的类型、电荷、杂化方式等,edata['w'] 可以存键的类型(单键、双键、芳香键等)。
degrees = g.in_degrees()
isolated = (degrees == 0).nonzero().squeeze(1)
print(f"孤立节点数量: {len(isolated)}")
从零构建一个图:实战演示
咱们来点实战。假设我们有一个药物分子,它有 5 个原子,化学键如下:
- 原子 0—原子 1(键类型:单键)
- 原子 1—原子 2(键类型:双键)
- 原子 2—原子 3(键类型:单键)
- 原子 3—原子 4(键类型:单键)
我们来构建这个图的完整代码:
import dgl
import torch
# 1. 创建边列表
# 无向图需要双向边
src = [0, 1, 2, 3, 1, 0, 2, 1, 3, 2, 4, 3]
dst = [1, 0, 1, 2, 2, 1, 3, 2, 2, 3, 3, 4]
# 2. 边类型特征(0=单键,1=双键)
bond_types = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# 键对 1-2 是双键,对应的两条边都是类型 1
bond_types[2] = 1
bond_types[3] = 1
# 3. 创建图
g = dgl.graph((src, dst), num_nodes=5)
# 4. 添加边特征
g.edata['bond_type'] = torch.tensor(bond_types).float()
# 5. 添加节点特征(原子类型:0=C, 1=N, 2=O)
atom_types = [0, 0, 1, 0, 2]
g.ndata['atom_type'] = torch.tensor(atom_types).float()
print(g)
print("节点特征形状:", g.ndata['atom_type'].shape)
print("边特征形状:", g.edata['bond_type'].shape)
这样做的好处是什么?你想想看,后续不管是做图分类还是节点分类,这些特征可以直接喂给 GNN 模型,完全不用额外处理。
图中还有哪些东西?
除了节点和边,DGL 还支持一些更高级的概念:
- 批处理图(Batch Graph):把多个小图拼成一个大图,用于批量训练。DGL 用
dgl.batch()实现。 - 异构图(Heterogeneous Graph):不同类型的节点和边。比如药物分子里可以有原子、官能团两种节点,和共价键、氢键两种边。
- 子图:从大图中切出子图,用于训练采样。DGL 提供
dgl.sampling.sample_neighbors()。
这些概念在后面章节会逐个展开,现在先有个印象就好。
- 图由节点和边组成,可以用邻接矩阵或 COO 格式表示
- DGL 的核心是
dgl.graph(),传 (src, dst) 即可创建 ndata和edata管理特征,类似 Python 字典- 无向图要调用
dgl.to_bidirected()转换 - 特征形状要统一,小心孤立节点和维度不匹配
好了,这就是图的基本概念和 DGL 里的 Graph 对象。接下来看一张图来梳理整个知识体系。