图神经网络——超大图上的节点表征学习
图卷积网络(GCN)已经成功地应用于许多基于图形的应用,然而大规模的GCN的训练仍然具有挑战性。目前基于SGD的算法要么面临着随GCN层数呈指数增长的高计算成本,要么面临着保存整个图形和每个节点的embedding到内存的巨大空间(显存)需求。于是论文Cluster-GCN: An Efficient Algorithm for Training Deep and Large Graph Convolutional Network提出了Cluster-GCN方法来解决超大图的训练问题。
1.Cluster-GCN方法简单概括
- 利用图节点聚类算法将一个图的节点划分为$c$个簇,每一次选择几个簇的节点和这些节点对应的边构成一个子图,然后对子图做训练。
- 由于是利用图节点聚类算法将节点划分为多个簇,所以簇内边的数量要比簇间边的数量多得多,所以可以提高表征利用率,并提高图神经网络的训练效率。
- 每一次随机选择多个簇来组成一个batch,这样不会丢失簇间的边,同时也不会有batch内类别分布偏差过大的问题。
- 基于小图进行训练,不会消耗很多内存空间,于是我们可以训练更深的神经网络,进而可以达到更高的精度。
2.GCN回顾
论文2017. Semi-supervised classification with graph convolutional networks中提出了GCN。作者定义了一种类似卷积的运算,称为谱图卷积。这使得CNN可以直接在图形上操作。GCN中的每一层通过考虑相邻节点的embedding,来更新Graph中的每个节点的特征向量表示。GCN的逐层正向传播可以总结为:
$$
X^{\left( l+1 \right)}=f\left( X^l,A \right) =\sigma \left( \tilde{D}^{-\frac{1}{2}}\tilde{A}D^{-\frac{1}{2}}X^{\left( l \right)}W^{\left( l \right)} \right)
$$
- $X$是所有节点的特征向量构成的特征矩阵。
- $X^l$和$X^{l+1}$分别是$l$层的输入和输出矩阵,$X^l$也是第$l$层对于所有节点的embedding。
- A是图的邻接矩阵。
- ${A}=A+I_N$是带有自环的无向图的邻接矩阵。
- $I_N$是单位矩阵。
- $\tilde{D}{ii}=\sum{\tilde{A}{ij}}$是带有自环的无向图的度矩阵,是一个对角矩阵。
- $W^{(l)}$是一个可训练权重矩阵或参数矩阵。
- $\sigma(\cdot)$是激活函数,通常被设为
ReLU。
为了简化计算,假设所有层的表征维度都是F,上式写为:
$$
Z^{(l+1)}=A^{\prime} X^{(l)} W^{(l)}, X^{(l+1)}=\sigma\left(Z^{(l+1)}\right)
$$
- $A^{\prime}$是归一化和规范化后的邻接矩阵
将GCN用于半监督节点分类时,目标是通过最小化损失函数来学习上式中的权重矩阵:
$$
\mathcal{L}=\frac{1}{\left|\mathcal{Y}{L}\right|} \sum{i \in \mathcal{Y}{L}} \operatorname{loss}\left(y{i}, z_{i}^{L}\right)
$$
$\mathcal{Y}_{\mathcal{L}}$是节点类别。
$z_{i}^{(L)}$是$Z^{(L)}$的第$i$行,表示节点$i$的最终层预测。
$y_{i}$为节点$i$的真实类别。
3.Cluster-GCN的速度
原始的GCN的训练使用的是全批量梯度下降(Full Gradient Descent),它的计算和内存成本很高:
在内存方面,通过反向传播来计算full gradient需要存储所有的embedding矩阵,这需要O(NFL)的内存空间(F是特征数量,N是结点数量,L是网络层数)。
在收敛速度方面,由于在每个epoch模型才更新一次,所以需要很多个epoch才会使模型达到收敛。
mini-batch SGD可以提高GCN的训练速度,其不需要计算整个梯度,只需要计算每次更新的mini-batch的梯度。使用大小为$b=|\mathcal{B}|$的$\mathcal{B} \subseteq[N]$来表示一个batch的节点索引。SGD的每一步都将计算梯度估计值$\frac{1}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \nabla \operatorname{loss}\left(y_{i}, z_{i}^{(L)}\right)$来进行参数更新。
尽管在每个epoch收敛得更快,但SGD在GCN训练上引入了另一个计算开销(邻域扩展),这使得SGD的每个epoch时间内比全梯度下降(full gradient descent)要慢得多——计算一个节点$i$的梯度,需要节点$i$的embedding,而节点$i$的embedding的依赖于前面的层里的它的邻居的embeddings,为了提取前面的层中它的邻居节点embeddings,还需要进一步聚合每个邻居节点的邻居节点的embeddings。假设一个图神经网络有$L+1$层,节点的平均的度为$d$。为了得到节点$i$的梯度,需要聚合图上$O\left(d^{L}\right)$的节点的表征,与权重矩阵$W^{(l)}$相乘,所以计算任意节点表征的时间开销是$O\left(F^{2}\right)$。所以SGD一个节点的梯度的计算需要$O\left(d^{L} F^{2}\right)$的时间。
4.Cluster-GCN详解
在mini-batch SGD更新中,设计一个batch和相应的计算sub graph来最大限度地提高embedding utilization。Cluster-GCN通过将embedding utilization连接到一个聚类目标上来实现。
对于一个Graph$G$,我们将其节点划分为$c$个簇:$\mathcal{V}=\left[\mathcal{V}{1}, \cdots \mathcal{V}{c}\right]$,其中$\mathcal{V}{t}$由第$t$个簇中的节点组成,得到$c$个子图:
$$
\bar{G}=\left[G{1}, \cdots, G_{c}\right]=\left[\left{\mathcal{V}{1}, \mathcal{E}{1}\right}, \cdots,\left{\mathcal{V}{c}, \mathcal{E}{c}\right}\right]
\notag
$$
其中$\mathcal{E}{t}$只由$\mathcal{V}{t}$中的节点之间的边组成。经过节点重组,邻接矩阵被划分为大小为$c^{2}$的块矩阵,如下所示
$$
A=\bar{A}+\Delta=\left[\begin{array}{ccc}
A_{11} & \cdots & A_{1 c} \
\vdots & \ddots & \vdots \
A_{c 1} & \cdots & A_{c c}
\end{array}\right]
$$
其中
$$
\bar{A}=\left[\begin{array}{ccc}
A_{11} & \cdots & 0 \
\vdots & \ddots & \vdots \
0 & \cdots & A_{c c}
\end{array}\right], \Delta=\left[\begin{array}{ccc}
0 & \cdots & A_{1 c} \
\vdots & \ddots & \vdots \
A_{c 1} & \cdots & 0
\end{array}\right]
$$
- 对角线上的块$A_{t t}$是大小为$\left|\mathcal{V}{t}\right| \times\left|\mathcal{V}{t}\right|$的邻接矩阵,它由$G_{t}$内部的边构成。
- $\bar{A}$是图$\bar{G}$的邻接矩阵。
- $A_{s t}$由两个簇$\mathcal{V}{s}$和$\mathcal{V}{t}$之间的边构成。
- $\Delta$是由$A$的所有非对角线块组成的矩阵。
用块对角线邻接矩阵$\bar{A}$去近似邻接矩阵$A$之后,完整的损失函数可以根据batch分解成多个部分之和:
Cluster-GCN使用了Graph聚类算法来划分Graph。Graph聚类的方法,如metis和graclus等,旨在在Graph中的顶点上构建分区,使得簇内连接远大于簇间连接,从而更好的捕捉聚类和区分结构。

5.Cluster-GCN实践
PyG库中提供了Cluster-GCN的接口,可以像训练普通神经网络一样在超大图上训练图神经网络。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#导入库和数据集 import torch import torch.nn.functional as F from torch.nn import ModuleList from torch_geometric.nn import SAGEConv from tqdm import tqdm from torch_geometric.datasets import Reddit, Reddit2 from torch_geometric.data import ClusterData, ClusterLoader, NeighborSampler dataset = Reddit('../dataset/Reddit') data = dataset[0] print(dataset.num_classes) print(data.num_nodes) print(data.num_edges) print(data.num_features) |
结果:
41, 232965, 114615892, 602
1 2 3 4 5 6 |
#图节点聚类并生成数据加载器 cluster_data = ClusterData(data, num_parts=1500, recursive=False, save_dir=dataset.processed_dir) train_loader = ClusterLoader(cluster_data, batch_size=20, shuffle=True, num_workers=12) subgraph_loader = NeighborSampler(data.edge_index, sizes=[-1], batch_size=1024, shuffle=False,num_workers=12) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
#构建网络 class Net(torch.nn.Module): def __init__(self, in_channels, out_channels): super(Net, self).__init__() self.convs = ModuleList( [SAGEConv(in_channels, 128), SAGEConv(128, out_channels)]) def forward(self, x, edge_index): for i, conv in enumerate(self.convs): x = conv(x, edge_index) if i != len(self.convs) - 1: x = F.relu(x) x = F.dropout(x, p=0.5, training=self.training) return F.log_softmax(x, dim=-1) def inference(self, x_all): pbar = tqdm(total=x_all.size(0) * len(self.convs)) pbar.set_description('Evaluating') for i, conv in enumerate(self.convs): xs = [] for batch_size, n_id, adj in subgraph_loader: edge_index, _, size = adj.to(device) x = x_all[n_id].to(device) x_target = x[:size[1]] x = conv((x, x_target), edge_index) if i != len(self.convs) - 1: x = F.relu(x) xs.append(x.cpu()) pbar.update(batch_size) x_all = torch.cat(xs, dim=0) pbar.close() return x_all |
此神经网络的forward函数的定义与普通的图神经网络并无区别,而inference方法应用于推理阶段,为了获取更高的预测精度,所以使用subgraph_loader。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
#训练、验证、测试 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = Net(dataset.num_features, dataset.num_classes).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=0.005) def train(): model.train() total_loss = total_nodes = 0 for batch in train_loader: batch = batch.to(device) optimizer.zero_grad() out = model(batch.x, batch.edge_index) loss = F.nll_loss(out[batch.train_mask], batch.y[batch.train_mask]) loss.backward() optimizer.step() nodes = batch.train_mask.sum().item() total_loss += loss.item() * nodes total_nodes += nodes return total_loss / total_nodes @torch.no_grad() def test(): # Inference should be performed on the full graph. model.eval() out = model.inference(data.x) y_pred = out.argmax(dim=-1) accs = [] for mask in [data.train_mask, data.val_mask, data.test_mask]: correct = y_pred[mask].eq(data.y[mask]).sum().item() accs.append(correct / mask.sum().item()) return accs for epoch in range(1, 31): loss = train() if epoch % 10 == 0: train_acc, val_acc, test_acc = test() print(f'Epoch: {epoch:02d}, Loss: {loss:.4f}, Train: {train_acc:.4f}, ' f'Val: {val_acc:.4f}, test: {test_acc:.4f}') else: print(f'Epoch: {epoch:02d}, Loss: {loss:.4f}') |
结果:
Epoch: 10, Loss: 0.2893, Train: 0.9620, Val: 0.9516, test: 0.9496
Epoch: 20, Loss: 0.2478, Train: 0.9664, Val: 0.9546, test: 0.9560
Epoch: 30, Loss: 0.2397, Train: 0.9689, Val: 0.9515, test: 0.9502
在训练过程中,使用train_loader获取batch,每次根据多个簇组成的batch进行训练;在验证阶段,使用subgraph_loader,在计算一个节点的表征时会计算该节点的距离从$0$到$L$的邻接节点,可以更好地测试神经网络的性能。

