GCN(图卷积神经网络)详解
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′=Θ⊤j∈N(i)∪{i}∑d^jd^iej,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+∑j∈N(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^jej,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 相连。
-
每个节点都包含自环。
接下来看图的信息
- 节点的邻居集合 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 的特征: 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
- 度数(包含自环): d i ^ = 1 + ∑ j ∈ N ( i ) e j , i \hat{d_i}=1+\sum_{j\in\mathcal{N}(i)}e_{j,i} di^=1+∑j∈N(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′=Θ⊤j∈N(i)∪{i}∑d^jd^iej,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^11x1+d^2d^11x2
代入数据:
-
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; 2⋅21x1=21⋅1=0.5;
-
邻居节点贡献: 1 3 ⋅ 2 x 2 = 1 6 ⋅ 2 ≈ 0.816 \frac1{\sqrt{3\cdot2}}x_2=\frac1{\sqrt{6}}\cdot2\approx0.816 3⋅21x2=61⋅2≈0.816。
最终:
x 1 ′ = 0.5 + 0.816 ≈ 1.316 x_1^{\prime}=0.5+0.816\approx1.316 x1′=0.5+0.816≈1.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️⃣ 参考
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐


所有评论(0)