图的基本概念与 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={})

我个人习惯创建完图后立刻确认一下节点数和边数,免得后面跑模型时才发现数据对不上。

小提示:如果 src 和 dst 长度不一致,DGL 会报错,所以创建图前最好检查一下两个列表的长度是否相等。

图的属性和基本操作

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'] 可以存键的类型(单键、双键、芳香键等)。

避坑指南:我曾经在项目里忘记给孤立节点设置特征,结果模型训练时那些节点直接产生 NaN。所以创建图后,建议先查一下有没有孤立节点:
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 对象。接下来看一张图来梳理整个知识体系。

图的基本概念与 DGL 图表示 - 知识体系 图的基本概念 有向 / 无向图 节点 · 边 · 邻接矩阵 DGL Graph 对象 dgl.graph() 创建 属性: num_nodes/edges 特征管理 ndata · edata 原子特征 · 键特征 有向图 directed=True 无向图 to_bidirected() 批处理 dgl.batch() 异构图 多类型节点/边 关键要点 COO 格式存储稀疏图 · 特征维度要统一 · 注意孤立节点 药物分子中原子是节点,化学键是边,特征用 ndata/edata 管理