【深度学习】Spektral

Posted by ShawnD on May 15, 2022

Getting started

Spektral 是根据 Keras 的指导原则设计的,对初学者来说非常简单,同时对专家来说保持灵活性。

在本教程中,我们将介绍spectral的主要函数,同时创建一个用于图分类的图神经网络。

Graphs

图是表示实体之间关系的数学对象。我们称实体为“节点”,称关系为“边”。

节点和边都可以有向量特征。

在Spektral中,图形用Spektral .data. graph实例来表示。一个图可以有四个主要属性。

  • a: adjacency matrix
  • x: node features
  • e: edge features
  • y: 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]

由于我们在邻接矩阵中已经有了 RC 的信息,我们只需要将 V 列存储为一个形状为 [n_nodes,n_edge_feature] 的矩阵 e。在这种情况下,n条边表示邻接矩阵中非零项的个数。

注意,由于我们已经将边特征从邻接矩阵的边索引中分离出来,所以我们存储边特征的顺序非常重要。我们不能破坏 a 中的边和 e 中的边之间的对应关系。

在 Spektra l中,我们总是假设边是以行为主排序的(我们首先按行排序,然后按列排序,就像上面的例子一样)。这在构造邻接矩阵时不重要,但在构造 e 时很重要。

你可以使用 spektral.utils.sparse.reorder 来根据边索引给出的正确的行主顺序对边缘特征矩阵进行排序(即,通过 steacking RC 列得到的矩阵)。

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 进行计算。

这也意味着,你可以摆脱 GraphDataset 以及 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。