Getting started
Spektral 是根据 Keras 的指导原则设计的,对初学者来说非常简单,同时对专家来说保持灵活性。
在本教程中,我们将介绍spectral的主要函数,同时创建一个用于图分类的图神经网络。
Graphs
图是表示实体之间关系的数学对象。我们称实体为“节点”,称关系为“边”。
节点和边都可以有向量特征。
在Spektral中,图形用Spektral .data. graph实例来表示。一个图可以有四个主要属性。
a
: adjacency matrixx
: node featurese
: edge featuresy
: labels
一个图可以拥有所有这些属性,也可以一个都没有。由于 Graphs 只是普通的Python对象,如果需要,还可以添加额外的属性。例如 graph.n_nodes
, graph.n_node_features
等等。
Adjacency matrix (graph.a
)
如果存在从节点 i
到节点 j
的边,则邻接矩阵的每一项 a[i, j]
都是非零的,否则为零。
我们可以将 a
表示为一个稠密的 np.array
或者一个形状为 [n_nodes, n_nodes]
的 Scipy 稀疏矩阵。 使用一个 np.array
表示邻接矩阵是昂贵的, 因为我们需要在内存中存储大量的 0s, 因此通常更喜欢使用稀疏矩阵。
稀疏矩阵,我们只需要存储 a
的非零项。在实践中, 我们可以通过只存储实现稀疏矩阵的索引和非零值在一个列表中, 并假设如果列表中一对索引缺失, 则对应的值是0。这被称为 COOrdinate format
,TensorFlow使用这种格式来表示稀疏张量。
例如,一个有4个节点的加权环图的邻接矩阵:
1
2
3
4
[[0, 1, 0, 2],
[3, 0, 4, 0],
[0, 5, 0, 6],
[7, 0, 8, 0]]
可以用 COOrdinate format
表示如下:
1
2
3
4
5
6
7
8
9
R, C, V
0, 1, 1
0, 3, 2
1, 0, 3
1, 2, 4
2, 1, 5
2, 3, 6
3, 0, 7
3, 2, 8
其中 R
表示行,C
表示列,V
表示非零值 a[i, j]
。例如,在第二行中,我们看到有一条从节点0到节点3的边,权值为2。
我们还看到,在这种情况下,所有的边都有一条对应的,方向相反的边。在这个例子中,所有的边都被赋予了不同的权重。然而在实际中,边 i, j
通常和边 j, i
有相同的权值,并且邻接矩阵是对称的。
Spektral中的许多卷积和池化层使用矩阵的稀疏表示来进行计算,有时你会在文档中看到这样的注释:“这一层期望一个稀疏邻接矩阵。
Node features (graph.x
)
当使用图神经网络时,我们通常将一个特征向量与图的每个节点相关联。这与图像中的每个像素都有一个[R, G, B, A]
向量是一样的。
由于我们有 n_nodes
个节点,每个节点都有一个大小为 n_node_features
个节点特征的特征向量,我们可以将所有特征叠加在一个形状为 [n_nodes,n_nodes_features]
的矩阵 x
中。
在Spektral中,x
总是用一个稠密的 np.array
表示(因为在这种情况下,我们不需要冒存储许多无用的零的风险——至少不经常)。
Edge features (graph.e
)
与节点特征相似,我们也可以有与边相关的特征。这些通常不同于我们看到的邻接矩阵的边权值,通常表示两个节点之间的关系(例如,熟人、朋友或伙伴)。
在表示边特征时,我们遇到了与邻接矩阵相同的问题。
如果我们把它们存储在稠密的 np.array
中,那么数组将具有形状 [n_nodes,n_ndoes,n_edge_features]
,并且它的大多数条目将是0。不幸的是,3阶张量不能用Scipy稀疏矩阵表示,所以我们需要聪明一点。
类似于我们将邻接矩阵存储为 r, c, v
的列表,这里我们可以使用 COOrdinate format
来表示我们的边特征。假设,在上面的例子中,每条边有 n_edge_features=3
个特征。我们可以这样做:
1
2
3
4
5
6
7
8
9
R, C, V
0, 1, [ef_1, ef_2, ef_3]
0, 3, [ef_1, ef_2, ef_3]
1, 0, [ef_1, ef_2, ef_3]
1, 2, [ef_1, ef_2, ef_3]
2, 1, [ef_1, ef_2, ef_3]
2, 3, [ef_1, ef_2, ef_3]
3, 0, [ef_1, ef_2, ef_3]
3, 2, [ef_1, ef_2, ef_3]
由于我们在邻接矩阵中已经有了 R
和 C
的信息,我们只需要将 V
列存储为一个形状为 [n_nodes,n_edge_feature]
的矩阵 e
。在这种情况下,n条边表示邻接矩阵中非零项的个数。
注意,由于我们已经将边特征从邻接矩阵的边索引中分离出来,所以我们存储边特征的顺序非常重要。我们不能破坏 a
中的边和 e
中的边之间的对应关系。
在 Spektra l中,我们总是假设边是以行为主排序的(我们首先按行排序,然后按列排序,就像上面的例子一样)。这在构造邻接矩阵时不重要,但在构造 e
时很重要。
你可以使用 spektral.utils.sparse.reorder
来根据边索引给出的正确的行主顺序对边缘特征矩阵进行排序(即,通过 steacking R
和 C
列得到的矩阵)。
Labels (graph.y
)
最后,在许多机器学习任务中,我们希望在给定输入的情况下预测一个标签。使用 GNN 时,标签可以有两种类型:
Graph labels
表示整个图的一些全局属性Node labels
节点标签表示图中每个单独节点的一些属性
Spektral 同时支持两种。
标签是稠密的 np.array
或 scalars,存储在 Graph
对象的 y
属性中。 Graph-level 标签可以是标量,也可以是形状为 [n_labels,]
的一维数组。Node-level
标签可以是形状为 [n_nodes,]
的一维数组(表示每个节点的标量标签),也可以是形状为 [n_nodes,n_labels]
的二维数组。
这种差异只在使用 DisjointLoader
时才有意义。
Dataset
spektral.data.Dataset
容器提供了一些操作 graphs 集合的有用函数。
让我们加载一个用于图分类的流行基准数据集
1
2
3
4
5
6
>>> from spektral.datasets import TUDataset
>>> dataset = TUDataset('PROTEINS')
>>> dataset
TUDataset(n_graphs=1113)
现在我们可以检索单个图
1
2
>>> dataset[0]
Graph(n_nodes=42, n_node_features=4, n_edge_features=None, y=[1. 0.])
或者打乱数据:
1
>>> np.random.shuffle(dataset)
或者将 dataset 切片为 sub-datasets:
1
2
>>> dataset[:100]
TUDataset(n_graphs=100)
数据集还提供了将转换应用到每个数据的方法:
apply(transform)
: 通过对每个图应用transform
就地修改数据集map(transform)
: 返回通过对每个图应用transform
而获得的列表filter(function)
: 从数据集中移除所有function(graph)
为False
的图数据。这也是一种就地操作。
例如,让我们修改数据集,使我们的图只有少于500个节点
1
2
3
4
>>> dataset.filter(lambda g: g.n_nodes < 500)
>>> dataset
TUDataset(n_graphs=1111) # removed 2 graphs
现在我们对图像进行一些变换。例如,我们可以修改每个图,使节点特征也包含节点的独热编码的度。
首先,我们计算数据集的最大的度,这样我们就知道独热向量的大小。
1
2
3
4
>>> max_degree = dataset.map(lambda g: g.a.sum(-1).max(), reduce=max)
>>> max_degree
12
试着检查lambda函数,看看它是做什么的。另外,请注意,我们向该方法传递了一个 reduce
函数。这将在计算的输出列表上运行 map。
现在我们准备用独热编码度来增强我们的节点特征。Spektral有很多我们可以使用的预实现的 transforms
。
1
2
from spektral.transforms import Degree
dataset.apply(Degree(int(max_degree)))
我们可以看到它是有效的,因为现在我们有了额外的 max_degree + 1
节点特征。
1
2
>>> dataset[0]
Graph(n_nodes=42, n_node_features=17, n_edge_features=None, y=[1. 0.])
由于我们将在我们的 GNN 中使用 GCNConv
层,我们也想遵循引入这一层的原始论文,并对邻接矩阵做一些额外的预处理。
因为这是一个相当常见的操作,所以 Spektral 有一个 transform 来完成它:
1
2
3
>>> from spektral.transforms import GCNFilter
>>> dataset.apply(GCNFilter())
许多层都需要进行某种形式的预处理。如果你不想每次都回到文献中,Spektral 中的每一个卷积层都有一个 preprocess(a)
方法,你可以根据需要使用它来变换邻接矩阵。
Creating a GNN
创建 GNNs 是 Spektral 真正的亮点。由于 Spektral 被设计为 Keras 的扩展,你可以将任何 Spektral 层插入 Keras Model
而无需修改。
我们只需要使用 functional API,因为 GNN 层通常需要两个或更多的输入(所以现在没有 Sequential
模型)。
对于我们的第一个GNN,我们将创建一个简单的网络,首先进行一些图卷积,然后将所有节点汇总在一起(称为“global pooling”),最后用一个密集的 softmax 层对结果进行分类。我们也会用 dropout 来进行正则化。
让我们从导入必要的层开始:
1
2
3
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Dense, Dropout
from spektral.layers import GCNConv, GlobalSumPool
现在我们可以使用模型子类化来定义我们的模型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MyFirstGNN(Model):
def __init__(self, n_hidden, n_labels):
super().__init__()
self.graph_conv = GCNConv(n_hidden)
self.pool = GlobalSumPool()
self.dropout = Dropout(0.5)
self.dense = Dense(n_labels, 'softmax')
def call(self, inputs):
out = self.graph_conv(inputs)
out = self.dropout(out)
out = self.pool(out)
out = self.dense(out)
return out
注意我们是如何将 Spektral 和 Keras 的层混合在一起的: 这一切都是基于 tensors underneath 进行计算。
这也意味着,你可以摆脱 Graph
和 Dataset
以及 Spektral 的其他特性。
注意:如果您不想子类化 Model
来实现 GNN,您也可以使用经典的声明风格。您只需要注意 Input
,并保留未指定的“节点”维度(因此使用 None
而不是 n_nodes
)。
Training the GNN
现在我们可以训练 GNN 了。首先,我们实例化并编译我们的模型:
1
2
model = MyFirstGNN(32, dataset.n_labels)
model.compile('adam', 'categorical_crossentropy')
然而,这就是 graph 阻碍我们的地方。与图像或序列等常规数据不同,图不能被拉伸、剪切或重塑,以便我们将它们放入预定义形状的张量中。如果一个图有10个节点,而另一个有4个节点,我们必须保持这种状态。
这意味着对一个数据集进行小批量的迭代并不是 trivial 的,我们不能简单地使用 Keras 的 model.fit()
方法。
Loaders
Loaders 遍历一个图数据集以创建 mini-batches。它们在流程背后隐藏了很多复杂性,这样您就不需要考虑它了。您只需要访问 这个页面 并阅读 data modes,以便知道使用哪个加载器。
每个 loader 都有一个 load()
方法,该方法返回一个 Keras 可以处理的数据生成器。因为我们正在进行 graph-level 分类,所以可以使用 BatchLoader
。它有点慢而且占用内存( DisjointLoader
会更好),但它让我们简化了 MyFirstGNN
的定义。同样,在本教程之后,请阅读有关 data modes 的内容。
1
2
3
from spektral.data import BatchLoader
loader = BatchLoader(dataset_train, batch_size=32)
由于 loaders 本质上是生成器,我们需要为 model.fit()
提供每个 steps_per_epoch
,而不需要指定 batch size
1
model.fit(loader.load(), steps_per_epoch=loader.steps_per_epoch, epochs=10)
Evaluating the GNN
评估我们模型的性能,无论是测试还是验证,都遵循类似的工作流程。
我们创建一个数据加载器
1
2
3
from spektral.data import BatchLoader
loader = BatchLoader(dataset_test, batch_size=32)
并通过调用 load()
将其提供给模型
1
2
3
loss = model.evaluate(loader.load(), steps=loader.steps_per_epoch)
print('Test loss: {}'.format(loss))
Node-level learning
除了学习预测整个图的标签(如本教程所述),GNNs 在学习预测每个节点的标签方面非常有效。这被称为“node-level learning”,我们通常对带有一个大图的数据集(比如社交网络)进行这种学习。
例如,可以使用 GCNConv
层、Citation
数据集和 SingleLoader
来复现 GCN 论文的结果,以对引文网络中的节点进行分类: 查看这个示例。
Go create!
现在可以使用 Spektral 创建自己的 GNNs 了。
如果您希望为特定任务构建GNN,那么Spektral中可能已经包含了所需的所有内容。看看这些例子,了解一些想法和实用技巧。
请记住阅读 data modes 一节,了解如何表示 graphs 和创建 mini-batches。