图神经网络 Task 5
作者:互联网
超大图上的节点表征学习
普通的基于SGD(随机梯度下降方法)的图神经网络:
随着图神经网络层数增加,计算成本呈指数增长;
保存整个图的信息和每一层每个节点的表征到内存(显存)而消耗巨大内存(显存)空间。
针对这些问题,论文Cluster-GCN: An Efficient Algorithm for Training Deep and Large Graph Convolutional Network提出了一种新的图神经网络的训练方法。
Cluster-GCN方法简单概括
-
利用图节点聚类算法将一个图的节点划分为c个簇,每一次选择几个簇的节点和这些节点对应的边构成一个子图,然后对子图做训练。
-
由于是利用图节点聚类算法将节点划分为多个簇,所以簇内边的数量要比簇件边的数量多得多,所以可以提高表征利用率,并提高图神经网络的训练效率。
-
每一次随机选择多个簇来组成一个batch,这样不会丢失簇间的边,同时也不会有batch内类别分布偏差过大的问题。
-
基于小图进行训练,不会消耗很多内存空间,于是我们可以训练更深的神经网络,进而可以达到更高的精度。
Cluster-GCN方法详细分析
以往训练方法的瓶颈
以往的训练方法需要同时计算所有节点的表征以及训练集中所有节点的损失产生的梯度(后文我们直接称为完整梯度),需要非常巨大的计算开销和内存(显存)开销。
内存(显存)方面:计算完整梯度需要存储所有的节点表征矩阵,需要O(NFL)的空间;
收敛方面:神经网络在每个epoch中只更新一次,所以训练需要更多的epoch才能达到收敛。
采用mini-batch SGD的方式训练,可以提高图神经网络的训练速度并减少内存(显存)需求,在参数更新中,SGD不需要计算完整梯度,而只需要基于mini-batch计算部分梯度。尽管在epoches数量相同的情况下,采用SGD方式进行训练,收敛速度可以更快,但此种训练方式会引入额外的时间开销,这使得相比于全梯度下降的训练方式,此种训练方式每个epoch的时间开销要大得多。
为什么?
将节点i的梯度计算依赖于节点i的L层的表征,而节点i的非第0层的表征都依赖于各自邻接节点的前一层的表征,这被称为邻域扩展。节点i的L层的表征,而节点i的非第0层的表征都依赖于各自邻接节点的前一层的表征,这被称为邻域扩展。假设一个图神经网络有L+1层,节点的平均的度为d。为了得到节点i的梯度,平均我们需要聚合图上 O ( d L ) O(d^L) O(dL)的节点的表征。由于要与权重矩阵W(l)相乘,所以计算任意节点表征的时间开销是 O ( F 2 ) O(F^2) O(F2)。所以平均来说,一个节点的梯度的计算需要 O ( d L F 2 ) O(d^LF^2) O(dLF2)的时间。
节点表征的利用率可以反映出计算的效率。考虑到一个batch有多个节点,时间与空间复杂度的计算就不是上面那样简单了,因为不同的节点同样距离远的邻接节点可以是重叠的,于是计算表征的次数可以小于最坏的情况 O ( b d L ) O(bd^L) O(bdL)。
为了反映mini-batch SGD的计算效率,Cluster-GCN论文提出了"表征利用率"的概念来描述计算效率。
在训练过程中,如果节点i在l层的表征z(l)i被计算并在l+1层的表征计算中被重复使用u次,那么我们说z(l)i的表征利用率为u。对于随机抽样的mini-batch SGD,u非常小,因为图通常是大且稀疏的。假设u是一个小常数(节点间同样距离的邻接节点重叠率小),那么mini-batch SGD的训练方式对每个batch需要计算 O ( b d L ) O(bd^L) O(bdL)的表征,于是每次参数更新需要O(bdLF2)的时间,每个epoch需要 O ( N d L F 2 ) O(Nd^LF^2) O(NdLF2)的时间,这被称为邻域扩展问题。
相反的是,全梯度下降训练具有最大的表征利用率——每个节点表征将在上一层被重复使用平均节点度次。因此,全梯度下降法在每个epoch中只需要计算O(NL)的表征,这意味着平均下来只需要O(L)的表征计算就可以获得一个节点的梯度。
邻域扩展问题说明:过去的方法和Cluster-GCN方法之间的邻域扩展差异。红色节点是邻域扩展的起始节点。过去的方法需要做指数级的邻域扩展(图左),而Cluster-GCN的方法可以避免巨大范围的邻域扩展(图右)。
简单的Cluster-GCN方法
Cluster-GCN方法是由这样的问题驱动的:我们能否找到一种将节点分成多个batch的方式,对应地将图划分成多个子图,使得表征利用率最大?我们通过将表征利用率的概念与图节点聚类的目标联系起来来回答这个问题。
为了最大限度地提高表征利用率,理想的划分batch的结果是,batch内的边尽可能多,batch之间的边尽可能少。基于这一点,我们将SGD图神经网络训练的效率与图聚类算法联系起来。
对于一个图G,我们将其节点划分为c个簇:
V
=
[
V
1
,
⋯
V
c
]
\mathcal{V}=\left[\mathcal{V}{1}, \cdots \mathcal{V}{c}\right]
V=[V1,⋯Vc],其中
V
t
\mathcal{V}{t}
Vt由第t个簇中的节点组成,对应的我们有c个子图:
KaTeX parse error: Undefined control sequence: \notag at position 108: …athcal{E}{c}}] \̲n̲o̲t̲a̲g̲, 其中
E
t
\mathcal{E}{t}
Et只由
V
t
\mathcal{V}{t}
Vt中的节点之间的边组成。经过节点重组,邻接矩阵被划分为大小为
c
2
c^{2}
c2的块矩阵,如下所示:
KaTeX parse error: Undefined control sequence: \ at position 69: …dots & A_{1 c} \̲ ̲\vdots & \ddots…
其中,
KaTeX parse error: Undefined control sequence: \ at position 54: …} & \cdots & 0 \̲ ̲\vdots & \ddots…
其中,对角线上的块 A t t A_{t t} Att是大小为 ∣ V t ∣ × ∣ V t ∣ |\mathcal{V}{t}| \times|\mathcal{V}{t}| ∣Vt∣×∣Vt∣的邻接矩阵,它由 G t G_{t} Gt内部的边构成。 A ˉ \bar{A} Aˉ是图 G ˉ \bar{G} Gˉ的邻接矩阵。 A s t A_{s t} Ast由两个簇 V s \mathcal{V}{s} Vs和 V t \mathcal{V}{t} Vt之间的边构成。 Δ \Delta Δ是由A的所有非对角线块组成的矩阵。同样,我们可以根据 [ V 1 , ⋯ , V c ] \left[\mathcal{V}{1}, \cdots, \mathcal{V}{c}\right] [V1,⋯,Vc]划分节点表征矩阵X和类别向量Y,得到 [ X 1 , ⋯ , X c ] \left[X_{1}, \cdots, X_{c}\right] [X1,⋯,Xc]和 [ Y 1 , ⋯ , Y c ] \left[Y_{1}, \cdots, Y_{c}\right] [Y1,⋯,Yc],其中 X t X_{t} Xt和 Y t Y_{t} Yt分别由 V t V_{t} Vt中节点的表征和类别组成。
接下来我们用块对角线邻接矩阵A¯去近似邻接矩阵A,这样做的好处是,完整的损失函数可以根据batch分解成多个部分之和。
我们使用图节点聚类算法来划分图。图节点聚类算法将图节点分成多个簇,划分结果是簇内边的数量远多于簇间边的数量。如前所述,每个batch的表征利用率相当于簇内边的数量。直观地说,每个节点和它的邻接节点大部分情况下都位于同一个簇中,因此L跳(L-hop)远的邻接节点大概率仍然在同一个簇中。由于我们用块对角线近似邻接矩阵A¯代替邻接矩阵A,产生的误差与簇间的边的数量Δ成正比,所以簇间的边越少越好。综上所述,使用图节点聚类算法对图节点划分多个簇的结果,正是我们希望得到的。
再次来看上图,我们可以看到,Cluster-GCN方法可以避免巨大范围的邻域扩展(图右),因为Cluster-GCN方法将邻域扩展限制在簇内。
表1显示了两种不同的节点划分策略:随机划分与聚类划分。两者都使用一个分区作为一个batch来进行神经网络训练。我们可以看到,在相同的epoches下,使用聚类分区可以达到更高的精度。
表1:随机分区与聚类分区的对比(采用mini-batch SGD训练)。聚类分区得到更好的性能(就测试F1集得分而言),因为它删除的分区间的边较少。
时间与空间复杂度分析
由于簇 V t \mathcal{V}{t} Vt中每个节点只连接到该簇内部的节点,节点的邻域扩展不需要在簇外进行。每个batch的计算将纯粹是矩阵乘积运算( A ˉ t t ′ X t ( l ) W ( l ) \bar{A}{t t}^{\prime} X_{t}^{(l)} W^{(l)} Aˉtt′Xt(l)W(l))和一些对元素的操作(ReLU),因此每个batch的总体时间复杂度为 O ( ∣ A t t ∣ 0 F + b F 2 ) O\left(\left|A_{t t}\right|{0} F+ b F^{2}\right) O(∣Att∣0F+bF2)。因此,每个epoch的总体时间复杂度为 O ( ∣ A ∣ 0 F + N F 2 ) O\left(|A|{0} F+N F^{2}\right) O(∣A∣0F+NF2)。平均来说,每个batch只需要计算 O ( b L ) O(b L) O(bL)的表征,这是线性的,而不是指数级的。在空间复杂度方面,在每个batch中,我们只需要在每一层中存储b个节点的表征,产生用于存储表征的内存(显存)开销为 O ( b L F ) O(b L F) O(bLF)。因此,此算法也比之前所有的算法的内存效率更高。此外,我们的算法只需加载子图到内存(显存)中,而不是完整的图(尽管图的存储通常不是内存瓶颈)。表2中总结了详细的时间和内存复杂度。
随机多分区
尽管简单Cluster-GCN方法可以做到较其他方法更低的计算和内存复杂度,但它仍存在两个潜在问题:
-
图被分割后,一些边(公式(4)中的Δ部分)被移除,性能可能因此会受到影响。
-
图聚类算法倾向于将相似的节点聚集在一起。因此,单个簇中节点的类别分布可能与原始数据集不同,导致对梯度的估计有偏差。
下图展示了一个类别分布不平衡的例子,该例子使用Reddit数据集,节点聚类由Metis软件包实现。根据各个簇的类别分布来计算熵值。与随机划分相比,采用聚类划分得到的大多数簇熵值都很小,簇熵值小表明簇中节点的标签分布偏向于某一些类别,这意味着不同簇的标签分布有较大的差异,这将影响训练的收敛。
类别分布熵越高意味着簇内类别分布越平衡,反之意味着簇内类别分布越不平衡。此图展示了不同熵值的随机分区和聚类分区的簇的数量,大多数聚类分区的簇具有较低的熵,表明各个簇内节点的类别分布存在偏差。相比之下,随机分区会产生类别分布熵很高的簇,尽管基于随机分区的训练的效率较低。在这个例子中,使用了Reddit数据集,进行了300个簇的分区。
为了解决上述问题,Cluster-GCN论文提出了一种随机多簇方法,此方法首先将图划分为p个簇, V 1 , ⋯ , V p \mathcal{V}{1}, \cdots, \mathcal{V}{p} V1,⋯,Vp,p是一个较大的值,在构建一个batch时,不是只使用一个簇,而是使用随机选择的q个簇,表示为 t 1 , … , t q t_{1}, \ldots, t_{q} t1,…,tq,得到的batch包含节点 V t 1 ∪ ⋯ ∪ V t q {\mathcal{V}{t{1}} \cup \cdots \cup \mathcal{V}{t{q}}} Vt1∪⋯∪Vtq、簇内边 A i i ∣ i ∈ t 1 , … , t q {A_{i i} \mid i \in t_{1}, \ldots, t_{q}} Aii∣i∈t1,…,tq和簇间边 A i j ∣ i , j ∈ t 1 , … , t q {A_{i j} \mid i, j \in t_{1}, \ldots, t_{q}} Aij∣i,j∈t1,…,tq。此方法的好处有,1)不会丢失簇间的边,2)不会有很大的batch内类别分布的偏差,3)以及不同的epoch使用的batch不同,这可以降低梯度估计的偏差。
下图展示了随机多簇方法,在每个epoch中,随机选择一些簇来组成一个batch,不同的epoch的batch不同。在图中,我们可以观察到,使用多个簇来组成一个batch可以提高收敛性。最终的Cluster-GCN算法在算法1中呈现。
图:Cluster-GCN提出的随机多分区方法。在每个epoch中,我们(不放回地)随机抽取q个簇(本例中使用q=2)及其簇间的边,来构成一个batch(相同颜色的块在同一batch中)。
图:选择一个簇与选择多个簇的比较。前者使用300个簇。后者使用1500个簇,并随机选择5个簇来组成一个batch。该图X轴为epoches,Y轴为F1得分。
训练深层GCNs的问题
在原始的GCN的设置里,每个节点都聚合邻接节点在上一层的表征。然而,在深层GCN的设置里,该策略可能不适合,因为它没有考虑到层数的问题。直观地说,近距离的邻接节点应该比远距离的的邻接节点贡献更大。因此,Cluster-GCN提出一种技术来更好地解决这个问题。其主要思想是放大GCN每一层中使用的邻接矩阵A的对角线部分。通过这种方式,我们在GCN的每一层的聚合中对来自上一层的表征赋予更大的权重。这可以通过给A¯加上一个单位矩阵I来实现,如下所示
X
(
l
+
1
)
=
σ
(
(
A
′
+
I
)
X
(
l
)
W
(
l
)
)
X^{(l+1)}=\sigma\left(\left(A^{\prime}+I\right) X^{(l)} W^{(l)}\right)
X(l+1)=σ((A′+I)X(l)W(l))
但对所有节点使用相同的权重而不考虑其邻居的数量可能不合适。此外,它可能会受到数值不稳定的影响,因为当使用更多的层时,数值会呈指数级增长。因此,Cluster-GCN方法提出了一个修改版的公式(9),以更好地保持邻接节点信息和数值范围。首先给原始的A添加一个单位矩阵I,并进行归一化处理
A ~ = ( D + I ) − 1 ( A + I ) \tilde{A}=(D+I)^{-1}(A+I) A~=(D+I)−1(A+I)
然后考虑,
KaTeX parse error: \tag works only in display equations
Cluster-GCN实践
PyG为Cluster-GCN提出的训练方式和神经网络的构建提供了良好的支持。我们无需在意图节点是如何被划分成多个簇的,PyG提供的接口允许我们像训练普通神经网络一样在超大图上训练图神经网络。
数据集分析
from torch_geometric.datasets import Reddit
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。
图节点聚类与数据加载器生成
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)
CLASSClusterData(data, num_parts: int, recursive: bool = False, save_dir: Optional[str] = None, log: bool = True)
将一个图分区成多个子图
parameters:
data (torch_geometric.data.Data) – The graph data object.
num_parts (int) – The number of partitions.
recursive (bool, optional) – If set to True, will use multilevel recursive bisection instead of multilevel k-way partitioning.
(default: False)save_dir (string, optional) – If set, will save the partitioned data to the save_dir directory for faster re-use. (default: None)
log (bool, optional) – If set to False, will not log any progress. (default: True)
CLASSClusterLoader(cluster_data, **kwargs)
图节点被聚类划分成多个簇,此数据加载器返回的一个batch由多个簇组成
parameters:
cluster_data (torch_geometric.data.ClusterData) – The already partioned data object.
**kwargs (optional) – Additional arguments of torch.utils.data.DataLoader, such as batch_size, shuffle, drop_last or
num_workers.
CLASSNeighborSampler(edge_index: Union[torch.Tensor, torch_sparse.tensor.SparseTensor], sizes: List[int], node_idx: Optional[torch.Tensor] = None, num_nodes: Optional[int] = None, return_e_id: bool = True, transform: Optional[Callable] = None, **kwargs)
使用此数据加载器不对图节点聚类,计算一个batch中的节点的表征需要计算该batch中的所有节点的距离从0到L的邻居节点
图神经网络的构建
import torch
from torch.nn import Linear
import torch.nn.functional as F
from torch_geometric.nn import SAGEConv
class Net(torch.nn.Module):
def __init__(self, in_channels, out_channels):
super().__init__()
self.convs = ModuleList(
[SAGEConv(in_channels, 128),
SAGEConv(128, hidden_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)
def inference(self, x_all):
pbar = tqdm(total=x_all.size(0) * len(self.convs))
pbar.set_description('Evaluating')
# Compute representations of nodes layer by layer, using *all* available edges.
# this leads to faster computation in contrast to immediately computing the final representations of each batch.
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。
Pytorch中Softmax和LogSoftmax的使用:
1.Softmax函数:
它接受了一个实数向量并返回一个概率分布,每个元素都是非负的, 并且所有元素的总和都是1。
(1)dim=0:对每一列的所有元素进行softmax运算,并使得每一列所有元素和为1。
(2)dim=1:对每一行的所有元素进行softmax运算,并使得每一行所有元素和为1。
2.log_softmax函数:
在softmax的结果上再做多一次log运算
训练、验证与测试
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 % 5 == 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}')
使用train_loader获取batch,每次根据多个簇组成的batch进行神经网络的训练。但在验证阶段,我们使用subgraph_loader,在计算一个节点的表征时会计算该节点的距离从0到L的邻接节点,这么做可以更好地测试神经网络的性能。
标签:Task,torch,batch,GCN,神经网络,表征,data,节点 来源: https://blog.csdn.net/chiyuhan89/article/details/118339911