1️⃣ GCN介绍

回忆一下,在我之前的博客已经介绍CNN架构(Alexnet,VGG,Unet,Resnet)和RNN。

  • CNN为处理图像等二维数据而设计,基于卷积操作实现特征的提取

  • RNN针对序列数据,通过循环结构和门机制捕捉序列中的依赖关系。

那为什么还要提出GCN呢?

实际上图像和序列数据都是欧式空间的数据,结构简单,使用CNN和RNN处理它们的时候需要保证输入格式是固定的 ①所有图像resize成固定大小,然后进行卷积操作得到特征 ②文本固定长度和词向量大小。然而,现实生活中有很多数据是没有固定结构的,其中一种就是图数据,比如社交网络,化学分子结构等。图数据结构不规则,CNN和RNN不好使了,研究人员在上世纪就开始研究如何处理这类数据,有很多方法,例如GNN、DeepWalk 和 node2vec 。而这篇文章介绍的图卷积神经网络是GNN中的一种技术。

图卷积神经网络(Graph Convolutional Networks, GCN)在2017年被提出,为了处理“图类型”的数据,GCN不要求输入格式固定。说白了,这个和网络CNN一样,都是用于提取特征,只不过提取的是图数据的特征。我们可以利用提取到的特征对图进行节点分类(node classification)、图分类(graph classification)、边预测(link prediction),还可以顺便得到图的嵌入表示(graph embedding)

注意哦,文本也可以表示成图的形式:
在这里插入图片描述


2️⃣ 原理

2.1 简述

GCN通过定义图卷积来处理图数据,现在分析一下图卷积的原理,看看它到底是个什么样子?

瞅瞅下面这个图,假设有一个图数据,咱先定义一些变量。

  • A A A表示图的邻接矩阵,表示的是节点之间的连接关系,要是节点相连,那元素就是1
  • 图的度矩阵表示为 D D D,这东西图论学过,本质是一个对角矩阵,其中对角元素表示每个节点的度(度就是与该节点相连的边的数量)
  • N N N个节点,每个节点上有一个 C C C维的特征,因此所有节点的特征组成一个大小为 N × C N×C N×C的特征矩阵 X X X
    在这里插入图片描述

GCN说白了就是对照CNN的卷积,定义了一个图卷积操作。CNN输入图像,图卷积的输入是前面定义的邻接矩阵 A A A,特征矩阵 X X X,咦,等等,为什么度矩阵 D D D不作为输入,因为 D D D可以通过A推导出来,所以就不输入了。咱先看看GCN的公式是啥:
H ( l + 1 ) = σ ( D ~ − 1 2 A ~ D ~ − 1 2 H ( l ) W ( l ) ) H^{(l+1)}=\sigma\Big(\tilde{D}^{-\frac{1}{2}}\tilde{A}\tilde{D}^{-\frac{1}{2}}H^{(l)}W^{(l)}\Big) H(l+1)=σ(D~21A~D~21H(l)W(l))

一看这公式头大了,这都啥东西,听我逐一拆解。

  • D ~ − 1 2 \tilde{D}^{-\frac{1}{2}} D~21:这个东西你可以理解为度矩阵 D D D经过一系列操作得到的结果,后面会对其进行分析
  • A ~ \tilde{A} A~:这是对邻接矩阵 A A A经过操作后的结果
  • H ( l ) H^{(l)} H(l):第 l l l层GCN的输入,对于输入层,就是我们的特征矩阵 X X X,形状为 N × C N×C N×C,N是节点数,C是特征数
  • H ( l + 1 ) H^{(l+1)} H(l+1),输出,形状为 N × C ′ N×C' N×C C ′ C' C是输出特征的维度
  • W ( l ) W^{(l)} W(l):第 l l l层GCN的训练参数,大小为 C × C ′ C×C' C×C
  • σ ( ) \sigma() σ():激活函数

其实到这图卷积就结束了,简单来说就是,有一个图结构,我们得到它的邻接矩阵 A A A,特征矩阵 X X X,输入到这个公式里,得到一个输出,over

2.2 GCN的具体流程

看到这肯定会有人问,为什么图卷积是这样定义的,具体流程是什么,我们来进一步分析。

图卷积的主要思想是让每个节点聚合自身和邻居节点的信息,从而捕捉图结构中的局部和全局特征。那么如何实现这一点呢?

还是下面这个图结构:
在这里插入图片描述
首先,我们让邻接矩阵 A A A与特征矩阵 X X X相乘即可得到邻居节点的信息,看看怎么个回事?

在这里插入图片描述

对于A节点,其邻居只有E节点,我们让邻接矩阵 A A A与特征矩阵 X X X相乘后,得到的结果的第一行就是A节点获取了E节点的信息,如图中黑框所示。

但是这样做还存在一个问题,我们没有把节点自身的特征考虑进来

因此我们对邻接矩阵 A A A进行一定的操作,得到新的邻接矩阵 A ~ \tilde{A} A~
A ~ = A + I N \tilde{A}=A+I_N A~=A+IN
其中 I N I_N IN为单位矩阵

在这里插入图片描述
那么 A ~ \tilde{A} A~与特征矩阵 X X X相乘,就可以把节点自身的特征考虑进来了,例如下图,对于节点A,一开始只考虑了邻居节点E的特征,为4.5,现在把自身节点特征也考虑进来4.2,总特征为8.7。
在这里插入图片描述

但现在依然存在问题,考虑自身节点和其他节点时,我们对特征进行的是求和运算,例如上图中的8.7,那么如何求平均呢

首先通过新的邻接矩阵 A ~ \tilde{A} A~,得到新的度矩阵 D ~ \tilde{D} D~,然后求其逆矩阵得到 D ~ − 1 \tilde{D}^{-1} D~1
在这里插入图片描述

通过以下操作,首先,通过 A ~ X \tilde{A}X A~X得到自身节点和其他节点的求和特征,再与 D ~ − 1 \tilde{D}^{-1} D~1相乘,可以得到自身节点和其他节点的平均特征:

D ~ − 1 ( A ~ X ) \tilde{D}^{-1}(\tilde{A}X) D~1(A~X)
在这里插入图片描述
分析到现在,我们已经完成了两点的考虑:

  • 综合自身节点和其他节点的特征
  • 对所有特征求平均操作

那么,问题接着又来了,如何区分对待高度节点和低度节点呢?进行了如下操作,【说实话,没看太懂,后面需要再来看吧】:
D ~ − 1 A ~ D ~ − 1 X \tilde{D}^{-1}\tilde{A}\tilde{D}^{-1}X D~1A~D~1X
在这里插入图片描述
反正这个操作的目的就是,给度数低的节点赋予更多的权重,给度数高的节点更低的权重。 因为低度节点对其邻居有更大的影响,而高度节点产生的影响较小,因为它们将影响分散在太多邻居。

然后论文中又提到用的不是 D ~ − 1 \tilde{D}^{-1} D~1,而是 D ~ − 1 / 2 \tilde{D}^{-1/2} D~1/2,【没看太懂,后面需要再来看吧】,因此最终的图卷积公式为:
σ ( D ~ − 1 2 A ~ D ~ − 1 2 X ( l ) W ( l ) ) \sigma\Big(\tilde{D}^{-\frac{1}{2}}\tilde{A}\tilde{D}^{-\frac{1}{2}}X^{(l)}W^{(l)}\Big) σ(D~21A~D~21X(l)W(l))
在这里插入图片描述

我们分析一个两层的GCN,激活函数分别采用ReLU和Softmax,我们把 D ~ − 1 / 2 A ~ D ~ − 1 / 2 X \tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}X D~1/2A~D~1/2X记作 A ^ \hat A A^,前向传播过程为:

f ( X , A ) = s o f t m a x ( A ^ R e L U ( A ^ X W ( 0 ) ) W ( 1 ) ) f(X,A)=\mathrm{softmax}\Big(\hat{A} \mathrm{ReLU}\Big(\hat{A}XW^{(0)}\Big)W^{(1)}\Big) f(X,A)=softmax(A^ReLU(A^XW(0))W(1))

上述介绍的是公式的矩阵形式,下面从节点级别分析一下公式:

x i ′ = Θ ⊤ ∑ j ∈ N ( i ) ∪ { i } e j , i d ^ j d ^ i x j \mathbf{x}_i^{\prime}=\mathbf{\Theta}^\top\sum_{j\in\mathcal{N}(i)\cup\{i\}}\frac{e_{j,i}}{\sqrt{\hat{d}_j\hat{d}_i}}\mathbf{x}_j xi=ΘjN(i){i}d^jd^i ej,ixj

这是矩阵公式的节点级别展开,描述每个节点 i i i的特征更新。

符号说明:

  • Θ ⊤ : \Theta^{\top}: Θ:通过权重矩阵调整特征维度。
  • ∑ j \sum_j j:累加所有邻居节点及自身的贡献。
  • N ( i ) \mathcal{N}(i) N(i):节点 i i i的邻居节点集合。
  • N ( i ) ∪ { i } \mathcal{N}(i)\cup\{i\} N(i){i}:邻居节点和自身 (由于加入了自环)。
  • e j , i e_{j,i} ej,i:从节点 j j j到节点 i i i的边权重(默认值为 1.0) 。
  • d i ^ = 1 + ∑ j ∈ N ( i ) e j , i \hat{d_i}=1+\sum_{j\in\mathcal{N}(i)}e_{j,i} di^=1+jN(i)ej,i:节点 i i i的归一化度数,包括自环的贡献。
  • e j , i d ^ j d ^ j : \frac {e_{j, i}}{\sqrt {\hat{d} _j\hat{d} _j}}: d^jd^j ej,i:边权重的归一化因子,用于平衡度较大的节点对结果的影响
  • x i \mathbf{x}_i xi:节点 i i i的特征向量。

对于节点级别的公式举个例子:

我们假设一个包含 3 个节点的图,边权重 e j , i e_{j,i} ej,i均为默认值 1.0,图结构如下:

  • 节点1与节点2相连;

  • 节点 2与节点 1、节点 3 相连;

  • 节点 3 与 节点 2 相连。

  • 每个节点都包含自环。

接下来看图的信息

  1. 节点的邻居集合 N ( i ) \mathcal{N}(i) N(i):
  • N ( 1 ) = { 2 } \mathcal{N}(1)=\{2\} N(1)={2}

  • N ( 2 ) = { 1 , 3 } \mathcal{N}(2)=\{1,3\} N(2)={1,3}

  • N ( 3 ) = { 2 } \mathcal{N}(3)=\{2\} N(3)={2}

  1. 节点的初始特征 (每个节点有一维特征):
  • 节点 1 的特征: x 1 = 1 x_{1}=1 x1=1

  • 节点 2 的特征: x 2 = 2 x_{2}=2 x2=2

  • 节点 3 的特征: x 3 = 3 x_{3}=3 x3=3

  1. 度数(包含自环): d i ^ = 1 + ∑ j ∈ N ( i ) e j , i \hat{d_i}=1+\sum_{j\in\mathcal{N}(i)}e_{j,i} di^=1+jN(i)ej,i,其中边权重 e j , i e_{j,i} ej,i均为默认值 1.0
  • 节点 1 的度数: d ^ 1 = 1 + 1 = 2 \hat{d}_1=1+1=2 d^1=1+1=2

  • 节点 2 的度数: d ^ 2 = 2 + 1 = 3 \hat{d}_2=2+1=3 d^2=2+1=3

  • 节点 3 的度数: d ^ 3 = 1 + 1 = 2 \hat{d}_3=1+1=2 d^3=1+1=2

然后我们来对节点特征更新计算,公式为:
x i ′ = Θ ⊤ ∑ j ∈ N ( i ) ∪ { i } e j , i d ^ j d ^ i x j \mathbf{x}_i^{\prime}=\mathbf{\Theta}^\top\sum_{j\in\mathcal{N}(i)\cup\{i\}}\frac{e_{j,i}}{\sqrt{\hat{d}_j\hat{d}_i}}\mathbf{x}_j xi=ΘjN(i){i}d^jd^i ej,ixj

对于节点1,邻居节点是 N ( 1 ) = { 2 } \mathcal{N}(1)=\{2\} N(1)={2},加上自身 { 1 } \{1\} {1},更新公式为:

x 1 ′ = 1 d ^ 1 d ^ 1 x 1 + 1 d ^ 2 d ^ 1 x 2 x_1^{\prime}=\frac1{\sqrt{\hat{d}_1\hat{d}_1}}x_1+\frac1{\sqrt{\hat{d}_2\hat{d}_1}}x_2 x1=d^1d^1 1x1+d^2d^1 1x2

代入数据:

  • d ^ 1 = 2 , d ^ 2 = 3 ; \hat{d}_1=2,\hat{d}_2=3; d^1=2,d^2=3;

  • x 1 = 1 , x 2 = 2 x_1=1,x_2=2 x1=1,x2=2

计算每一项:

  • 自环贡献: 1 2 ⋅ 2 x 1 = 1 2 ⋅ 1 = 0.5 ; \frac1{\sqrt{2\cdot2}}x_1=\frac12\cdot1=0.5; 22 1x1=211=0.5;

  • 邻居节点贡献: 1 3 ⋅ 2 x 2 = 1 6 ⋅ 2 ≈ 0.816 \frac1{\sqrt{3\cdot2}}x_2=\frac1{\sqrt{6}}\cdot2\approx0.816 32 1x2=6 120.816

最终:
x 1 ′ = 0.5 + 0.816 ≈ 1.316 x_1^{\prime}=0.5+0.816\approx1.316 x1=0.5+0.8161.316
节点2和节点3是同理的

OK,到这里GCN就具体分析完了,确实很难,慢慢理解!


3️⃣ 总结

  • GCN定义了图卷积运算,类比于CNN的卷积理解即可

  • 设想一个问题:节点A与节点B的距离为3,那么一层GCN,A能学到B的特征?
    答:节点A无法直接学到与它距离大于1的节点的特征。这是因为一层GCN仅仅在图上进行了一次邻居聚合,即每个节点只会接收来自直接邻居节点的信息(距离为1的节点)

  • 多层GCN的本质是更新各个节点的特征,在这个过程中,邻接矩阵不变(图的结构不变);最终的输出,还是那个图,只不过是各个节点的特征变了,然后后面再接网络进行对应的任务。
    在这里插入图片描述

  • GCN训练时不需要全部标签,用少量标签也可以训练,计算损失时只用有标签的节点。GCN是半监督学习


4️⃣ 代码

使用torch_geometric实现GCN,巨简单

import torch
import networkx as nx
import matplotlib.pyplot as plt
import torch.nn as nn
from torch_geometric.datasets import KarateClub
from torch_geometric.utils import to_networkx
from torch_geometric.nn import GCNConv

def visualize_graph(G,color):
    plt.figure(figsize=(7,7))
    plt.xticks([])
    plt.yticks([])
    nx.draw_networkx(G,pos=nx.spring_layout(G,seed=42),with_labels=False,node_color=color,cmap="Set2")
    plt.savefig("D:\code\AI_ladder\\7_STGCN\graph.png")
    

# 1.图的结构
# Karate Clubs数据集包含一个无向图,有34个节点和78条边
# 每个节点代表俱乐部中的一个成员
# 边表示两个成员之间的友谊关系
# 2.节点属性
# 节点具有一个类别标签,0,1,2,3,表示属于哪一类
# 3.任务
# 根据成员之间的关系决定成员的类别
dataset=KarateClub()
print(f"Dataset: {dataset}:")
print('====================')
print(f'Number of graphs: {len(dataset)}')
print(f'Number of features: {dataset.num_features}')
print(f'Number of classes: {dataset.num_classes}')

data=dataset[0]
# 输出Data(x=[34, 34], edge_index=[2, 156], y=[34], train_mask=[34])
# x:形状为 [34, 34] 的特征矩阵,表示有34个节点,每个节点有34个特征
# edge_index:形状为 [2, 156]的邻接矩阵,前面说到图中的78条边是无向边,而PyTorch Geometric的 edge_index默认将无向边视为两条方向相反的有向边
#             start→end两个序列,所以有两行
# y:形状为 [34] 的标签向量,表示每个节点的标签
# train_mask:形状为 [34] 的布尔向量,表示哪些节点用于训练
print(data)

# to_undirected=True无向图
G=to_networkx(data,to_undirected=True)
# 输出一个networkx图
visualize_graph(G,data.y)


class GCN(nn.Module):
    def __init__(self):
        super().__init__()
        # 图卷积中变化的只有节点的特征维度,邻接矩阵永远不变;dataset.num_features=34
        # in_channels节点输入特征维度,out_channels节点输出特征维度
        self.conv1=GCNConv(in_channels=dataset.num_features,out_channels=4)
        self.conv2=GCNConv(in_channels=4,out_channels=4)
        self.conv3=GCNConv(in_channels=4,out_channels=2)
        self.classifier=nn.Linear(2,dataset.num_classes)
        
    def forward(self,x,edge_index):
        y1=self.conv1(x,edge_index)
        y1=torch.relu(y1)
        y2=self.conv2(y1,edge_index)
        y2=torch.relu(y2)
        y3=self.conv3(y2,edge_index)
        y3=torch.relu(y3)
        
        out=self.classifier(y3)
        
        return out,y3

if __name__=='__main__':
    
    model=GCN()
    # 定义损失函数
    loss=nn.CrossEntropyLoss()
    # 优化器
    optimizer=torch.optim.Adam(model.parameters(),lr=0.01)
    epochs=200
    for epoch in range(epochs):
        # 清空优化器的梯度
        optimizer.zero_grad()
        # 设置为训练模式
        model.train()
        # 前向传播
        out, h = model(data.x, data.edge_index)
        # 计算损失
        # 只看mask为True的节点,因此是半监督
        loss_train=loss(out[data.train_mask],data.y[data.train_mask])
        # 反向传播
        loss_train.backward()
        # 更新模型参数
        optimizer.step()
        print(loss_train)
        

5️⃣ 参考


Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐