7  图神经网络

欢迎来到非欧几里得空间。CNN 处理的是整齐的像素网格,而 GNN 处理的是像潘多拉森林一样复杂的图结构(Graph)。在这里,数据不再孤立,重点在于关系。就像纳美人的神经连接一样,让一个节点能够聚合邻居的信息,从而感知到整个网络的智慧。

7.1 SVD 到图卷积网络

在 1970 年代初期,美国社会学家 Wayne Zachary 在美国一所大学发起了一项跆拳道社团成员社交网络研究。在观察期间,俱乐部内部爆发了激烈的政治冲突。

  • 一方是教练(Mr. Hi):即数据集中的 Node 1。他希望提高收费,改善教学设施。
  • 一方是主管(John A):即数据集中的 Node 34。作为 俱乐部的行政管理者,他强烈反对涨价,并试图解雇教练。

Zachary 在空手道俱乐部分裂发生之前,完整的记录了成员之间的社交关系。

  • 节点 (Nodes):34 个。代表俱乐部的 34 名成员。
  • 边 (Edges):78 条。代表成员之间的友谊或互动关系。

这些关系不仅仅是“谁和谁一起练空手道”,而是俱乐部之外的互动(如一起吃饭、去对方家里做客等)。通过社员们的关系来预测他们最终选择的阵营,是 Zachary 当时主要研究的方向之一。

随着矛盾激化,俱乐部最终分裂成了两个独立的组织。支持教练 Mr. Hi 的人跟随他成立了新俱乐部,支持主管 John A 的人留在了原俱乐部。

library(torch)
library(igraph)
library(showtext)
library(torch)
showtext_auto()
set.seed(42)
torch_manual_seed(42)

# --- 1. Zachary's Karate Club ---
g <- make_graph("Zachary")
manual_labels <- c(
  1, 1, 1, 1, 1, 1, 1, 1, 2, 2, # 1-10号
  1, 1, 1, 1, 2, 2, 1, 1, 2, 1, # 11-20号
  2, 1, 2, 2, 2, 2, 2, 2, 2, 2, # 21-30号
  2, 2, 2, 2                    # 31-34号
)
# 转换为 torch 张量
labels <- torch_tensor(manual_labels, dtype = torch_long())
# 我们复用之前的 labels 向量 (如果不在这里,请重新运行上一段代码的 labels 定义)
node_colors <- ifelse(as.numeric(labels) == 1, "gray20", "tomato")
# 设置颜色
V(g)$color <- node_colors
# 设置标签颜色 (为了在深色点上能看清,设为白色或浅色)
V(g)$label.color <- "white"
# 把 1号 (Mr. Hi) 和 34号 (John A) 搞得大一点,或者换个形状
# 这样一眼就能看出这两个“种子节点”
V(g)$size <- 12 # 默认大小
V(g)$size[c(1, 34)] <- 15 # 首领变大
V(g)$shape <- "circle"
V(g)$shape[c(1, 34)] <- "square" # 首领变成方形
# 使用 Fruchterman-Reingold 布局算法 (最经典的力导向布局)
plot(g, 
     layout = layout_with_fr(g), 
     vertex.label.font = 2,      # 字体加粗
     vertex.label.cex = 0.8      # 字体大小
)

# 添加图例
legend("bottomleft", 
       legend = c("Mr. Hi 派系", "John A 派系"), 
       col = c("gray20", "tomato"), 
       pch = 15, # 方块图标
       pt.cex = 2,
       bty = "n") # 不画边框

在网络中,节点间的连边关系通常被记录为边表(Edge List),例如 (1, 2), (1, 3), (1, 4)...,将其转化为矩阵形式就是判断两两关系是否存在,存在关系则记为 1,这个矩阵也被称为邻接矩阵 (Adjacency Matrix)。

A_matrix <- as_adjacency_matrix(g, sparse = FALSE)
A <- torch_tensor(A_matrix, dtype = torch_float())
A_mat <- as.matrix(A) # 转为 R 标准矩阵
A_vis <- t(A_mat)[, ncol(A_mat):1] # image() 默认是从左下角开始画的。
par(mar = c(3, 3, 3, 1), pty = "s") # 设置边距和图框类型
image(1:34, 1:34, A_vis,
      # 颜色设置: 0=白色, 1=海军蓝
      col = c("white", "navyblue"), 
      axes = FALSE, 
      main = "",
      xlab = "Target Node",
      ylab = "Source Node"
)
box()
# 添加网格线 (Grid)
grid(nx = 34, ny = 34, col = "gray90", lty = 1)
# X轴: 1 到 34
axis(1, at = 1:34, labels = 1:34, cex.axis = 0.6, tick = FALSE, line = -0.5)
# Y轴: 34 到 1 (注意:因为我们翻转了矩阵,所以Y轴标签要反着标,才能对应视觉上的"从上到下")
axis(2, at = 1:34, labels = 34:1, cex.axis = 0.6, tick = FALSE, las = 2, line = -0.5)

从传统统计视角看,有了这个矩阵,各个网络节点的空间表征可以通过奇异值分解 1 来计算获得,比如将列信息放在二维空间上,观察其远近关系。

1 注意,因为这里的邻接矩阵是对称阵,所以奇异值分解之后的行向量信息和列向量信息表达一致,分解之后 u,v 也是一样。如果是用户购买商品的协同阵,这个场景下 u,v 的意义不同。

##  A 进行 SVD 分解
library(ggplot2)
library(ggrepel)

svd_res <- svd(as.matrix(A)) # 转为普通 matrix 计算
svd_embeddings <- svd_res$u[, 1:2]
df_svd <- data.frame(
  Dim1 = svd_embeddings[, 1],
  Dim2 = svd_embeddings[, 2],
  Faction = as.factor(as.numeric(labels)), # 复用之前的标签
  NodeID = 1:34
)

ggplot(df_svd, aes(x = Dim1, y = Dim2, color = Faction, label = NodeID)) +
  geom_point(size = 4, alpha = 0.8) + 
  geom_text_repel(
    size = 4, 
    show.legend = FALSE, 
    max.overlaps = Inf,   # 强制显示所有标签,不隐藏任何点
    box.padding = 0.5     # 给标签一点额外的排斥空间
  ) +
scale_color_manual(values = c("1" = "gray20", "2" = "tomato")) +
  theme_minimal() +
  labs(title = "",
       subtitle = "",
       x = "Singular Vector 1", y = "Singular Vector 2")

但使用奇异值分解有两个很大的弊端:

  1. 因为只用到了邻接信息,对于每个节点自己的信息无法使用。例如节点可能包含了肤色、性别、入会时间长短、缴纳会费金额数等2
  2. SVD 是对固定的图结构做全局分解。如果我们新增或遗漏了一个节点,模型无法直接推导出它的表征,只能把整个矩阵拼在一起重新计算。而我们期望的是一个具备泛化能力的预测模型:只要掌握了节点特征和局部连接规律,任意新数据加入都能直接得出预测结果。

2 在 Zachary 最早使用“最大流最小割”算法预测时,模型将 9 划给了 Mr. Hi(因为他在那边朋友多)。但实际上,9 选择了 John A。原因是 9 马上要考黑带了,而只有 John A 才有权颁发证书——这是一种非结构性的因素。

既然 SVD 只能利用结构(邻接矩阵),而传统的神经网络全连接层只能利用属性(特征矩阵),有没有一种方法能将二者无缝融合呢?随着图卷积网络 (Kipf 和 Welling 2017) Graph Convolutional Network 被提出,该方法完美地将“图结构”和“神经网络”结合在了一起。它的设计思想可以拆成三个步骤:特征变换、信息聚合、归一化。

公式通常写作:

\[ H^{(l+1)} = \sigma(\underbrace{\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}}_{\text{结构归一化}} \underbrace{H^{(l)}}_{\text{特征}} \underbrace{W^{(l)}}_{\text{权重}}) \]

在 GCN 的计算中,\(H^{(l)}\) 指的是第 \(l\) 层的节点特征矩阵,而 \(H^{(l+1)}\) 则是经过特征变换、信息聚合、归一化以及非线性激活后,输出的新一层节点特征矩阵。

注记

如果把 GCN 看作是一个节点在网络中不断“学习”的过程:

在第 0 个输入层时,\(H^{(0)}\) 其实就是原始特征矩阵 \(X\)。而经过一层 GCN 生成的 \(H^{(1)}\),不仅保留了节点自身的关键特征,还浓缩了它所有邻居的特征信息。随着网络层数 \(l\) 的加深,\(H\) 包含的信息视野会越来越大,节点就能感知到更远端邻居的信息,完成在图空间中的更新(Embedding)。

我们从右向左看 GCN 到底在做什么。

第一步,\(H^{(l)}W^{(l)}\) 特征变化(神经网络):

这就是最普通的神经网络全连接层(Linear Layer)。不看图的结构,只看节点自己的特征,也被称为节点特征矩阵(Feature Matrix),矩阵的第 \(i\) 行代表第 \(i\) 个节点的特征向量。

神经网络可以把节点的特征维度进行映射(比如从 34 维降维到 4 维)。

第二步,\(\tilde{A}(H^{(l)}W^{(l)})\) 信息聚合部分:

用邻接矩阵 \(\tilde{A}\) 去乘变换后的特征矩阵。在矩阵代数中,用邻接矩阵乘特征矩阵,等同于把邻居的特征加起来。如果 \(A_{ij} = 1\),说明节点 \(j\) 是节点 \(i\) 的邻居,那么节点 \(j\) 的特征就会被加到节点 \(i\) 身上。

原始的 \(A\) 对角线通常是 0,为了不丢失自身信息,\(\tilde{A} = A + I\)

第三步,\(\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}\) 归一化:

如果一个节点有 1000 个邻居,那么特征值会非常大,我们需要加权平均,而不是简单的加和。拿一个简单的情况举例:如果我的邻居是一个超级大V(度数很高),那么他传递给我的信息权重应该被稀释(因为他的注意力分散了);如果我的邻居只有我这一个朋友,他传递给我的信息就非常重要。

有多少个邻居对应的概念就是度矩阵(degree matrix)\(D\),如果 \(D^{-1}A\) 只乘在左边,只解决了我是接受者的问题,我同时还是一个发送者,权重也要被平均,要右乘。从我这个节点的进出都要被控制。

对于节点 \(i\)\(j\) 之间的边,权重从原本的 1 变成了 \(\frac{1}{\sqrt{d_i}\sqrt{d_j}}\)。又因为 \(A\) 为了不丢失自身信息,加了自环,\(\tilde{A} = A + I\)\(\tilde{D}\)\(\tilde{A}\) 的度矩阵,所以表达为矩阵 \(\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}\)

第四步,非线性激活 \(\sigma\)

前面所有的矩阵乘法(无论是乘以邻接矩阵 \(A\) 还是权重矩阵 \(W\)),本质上都是线性变换。如果我们在图上堆叠了多层 GCN 却不加激活函数,那么根据矩阵乘法的结合律,多层的线性叠加最终在数学上等价于单层网络,网络深度的优势将荡然无存。

套用通常的 ReLU 函数 \(f(x) = \max(0, x)\),非线性变换 \(\sigma\) 会将所有小于 0 的特征值“掐断”置为 0,保留大于 0 的信号。这个非线性操作赋予了模型在高维空间中扭曲和折叠特征的能力。

7.2 代码实现

首先是基础指标的计算,这里大量利用了 igraph(Antonov 等 2023) 包的基本功能。包括获取 Zachary 网络数据,取得邻接矩阵,绘制图形等。

library(igraph)
g <- make_graph("Zachary")
A_matrix <- as_adjacency_matrix(g, sparse = FALSE) # 邻接矩阵
A <- torch_tensor(A_matrix, dtype = torch_float())

num_nodes <- length(V(g))
I <- torch_eye(num_nodes)
A_hat <- A + I # 添加自环,保留自身特征

D_hat_diag <- torch_sum(A_hat, dim = 2) # 计算度矩阵(按行求和)

# 计算 D^{-1/2}
D_inv_sqrt_diag <- torch_pow(D_hat_diag, -0.5)
D_inv_sqrt <- torch_diag(D_inv_sqrt_diag) # 将向量转回对角矩阵

# D^{-1/2} %*% A_hat %*% D^{-1/2}
L_sym <- torch_mm(torch_mm(D_inv_sqrt, A_hat), D_inv_sqrt)

接下来是定义模型,是两层结构的 GCN:

GCN <- nn_module(
  "GCN",
  initialize = function(in_feat, hidden_feat, out_feat) {
    # 两个线性层 (对应公式中的 W)
    self$layer1 <- nn_linear(in_feat, hidden_feat)
    self$layer2 <- nn_linear(hidden_feat, out_feat)
  },
  
  forward = function(x, adj) {
    # 第一层 GCN
    x <- self$layer1(x)   # 线性变换 XW
    x <- torch_mm(adj, x) # 向外走一步,直接的邻居
    x <- torch_relu(x)
    
    # 第二层 GCN
    x <- self$layer2(x)   # 线性变换
    x <- torch_mm(adj, x) # 向外走两步,邻居的邻居
    return(x)             # 输出层直接输出未经归一化的 Logits
  }
)
X <- torch_eye(num_nodes) # 34x34
model <- GCN(in_feat = 34, hidden_feat = 4, out_feat = 2)

# 真实标签 (Ground Truth): 
# 1 = Mr. Hi (教练), 2 = John A (主管)
manual_labels <- c(
  1, 1, 1, 1, 1, 1, 1, 1, 2, 2, # 1-10号
  1, 1, 1, 1, 2, 2, 1, 1, 2, 1, # 11-20号
  2, 1, 2, 2, 2, 2, 2, 2, 2, 2, # 21-30号
  2, 2, 2, 2                    # 31-34号
)
labels <- torch_tensor(manual_labels, dtype = torch_long())

输入特征 (in_feat = 34):

因为这个数据集没有节点的个人特征(如年龄、身高等),代码使用了单位矩阵 (torch_eye) 作为特征 \(X\)。这意味着每个节点的初始特征就是它唯一的 ID(One-Hot 编码)。

第一层 (layer1):

首先对特征进行线性变换(权重矩阵 \(W_0\)),将变换后的特征与预处理好的邻接矩阵相乘。这步操作在数学上等同于:每个节点收集并聚合了它所有直接邻居的信息。最后做 relu 操作。

第二层 (layer2):

再次进行线性变换和邻接矩阵相乘。但此时节点收集了“邻居的邻居”的信息(二阶信息)。

提示

读者也可以考虑注释掉第二层邻居的邻居这个逻辑,看看对最终预测有什么影响。

最终输出维度 out_feat = 2,代表两个阵营(Mr. Hi 和 John A)的预测分数(Logits)。接下来定义损失函数和更新机制:

# Cross Entropy 判断哪个类别的得分更高
criterion <- nn_cross_entropy_loss()
optimizer <- optim_adam(model$parameters, lr = 0.01)

for (epoch in 1:100) {
  optimizer$zero_grad()
  output <- model(x = X, adj = L_sym)
  
  # 关键点:只计算两个节点的 Loss
  # 我们假装只知道 Node 1 (索引 1) 和 Node 34 (索引 34) 的身份
  # Node 1 是教练 (类别 0), Node 34 是主管 (类别 1)
  target_idx <- torch_tensor(c(1, 34), dtype = torch_long())
  
  # 只取出这两个节点的预测结果和真实标签来计算误差
  loss <- criterion(output[target_idx], labels[target_idx])
  
  loss$backward()
  optimizer$step()

  if (epoch %% 10 == 0) {
    cat(sprintf("Epoch %d | Loss: %.4f\n", epoch, loss$item()))
  }
}
Epoch 10 | Loss: 0.8665
Epoch 20 | Loss: 0.6674
Epoch 30 | Loss: 0.5247
Epoch 40 | Loss: 0.4090
Epoch 50 | Loss: 0.2845
Epoch 60 | Loss: 0.1742
Epoch 70 | Loss: 0.1012
Epoch 80 | Loss: 0.0605
Epoch 90 | Loss: 0.0389
Epoch 100 | Loss: 0.0273

这里使用了半监督训练策略 (Semi-Supervised Learning)。虽然空手道俱乐部有 34 个人,但我们只用了 2 个人的标签来训练模型。

  • 所有人都有真实标签,但在训练计算 Loss 时,被“人为隐藏”了。
  • 在计算 loss 时,只传入了这两个节点的预测值(每一类的概率)和真实标签。

虽然 Loss 只由 2 个节点产生,但在计算梯度时,由于图卷积层 torch_mm(adj, x) 的存在,这两个节点的输出依赖于它们的邻居,邻居又依赖于邻居的邻居。因此,梯度会沿着图的连接结构反向传播,更新所有节点的相关权重。

从预测结果上看,仅仅错了一个节点 3。在其他在图神经网络的研究文献中,3号节点是整个空手道俱乐部网络中最难分类的“摇摆人。当然从概率上看也是不太容易区分。

# --- 6. 结果可视化与验证 ---
final_output <- model(x = X, adj = L_sym)
predictions <- torch_argmax(final_output, dim = 2)

# 简单计算准确率 (和全部真实标签对比)
data.frame(
  Predicted = as.numeric(predictions),
  True = as.numeric(labels)
)
   Predicted True
1          1    1
2          1    1
3          2    1
4          1    1
5          1    1
6          1    1
7          1    1
8          1    1
9          2    2
10         2    2
11         1    1
12         1    1
13         1    1
14         1    1
15         2    2
16         2    2
17         1    1
18         1    1
19         2    2
20         1    1
21         2    2
22         1    1
23         2    2
24         2    2
25         2    2
26         2    2
27         2    2
28         2    2
29         2    2
30         2    2
31         2    2
32         2    2
33         2    2
34         2    2

我们将各个节点在 Mr. Hi 和 John A 两个方向上绘制散点图,同时标记实际分类,也能看到 3 很难区分:

nnf_softmax(final_output, dim = 2)[3,]
torch_tensor
 0.4870
 0.5130
[ CPUFloatType{2} ][ grad_fn = <SliceBackward0> ]
# 1. 数据准备
emb_mat <- as.matrix(final_output$detach())
true_labels <- as.numeric(labels)

plot_df <- data.frame(
  Dim1 = emb_mat[, 1], # Mr. Hi 得分
  Dim2 = emb_mat[, 2], # John A 得分
  Faction = factor(true_labels, levels = c(1, 2), labels = c("Mr. Hi", "John A")),
  NodeID = 1:34
)

# 2. 绘图
ggplot(plot_df, aes(x = Dim1, y = Dim2, color = Faction)) +
  geom_point(size = 4, alpha = 0.8) +
  geom_text_repel(
    aes(label = NodeID),
    size = 3.5,
    fontface = "bold",
    box.padding = 0.3,   # 标签周围留白
    point.padding = 0.3, # 标签与点的距离
    max.overlaps = Inf,  # 强制显示所有标签
    show.legend = FALSE
  ) +
  scale_color_manual(values = c("Mr. Hi" = "#F8766D", "John A" = "#00BFC4")) +
  theme_minimal() +
  labs(
    title = "Karate Club GCN Embedding",
    x = "Logits for Mr. Hi (Dim 1)",
    y = "Logits for John A (Dim 2)"
  )

7.3 增加特征矩阵

Zachary 空手道俱乐部数据集非常经典,但它过于简单,且不包含节点自身的属性特征。在真实世界的业务场景中,网络中的节点往往携带着丰富的信息。本节我们将引入图神经网络领域最著名的基准数据集之一——Cora 数据集,来看看当“图拓扑结构”遇上“节点特征矩阵”时,GCN 是如何发挥威力的。

Cora 数据集是一个机器学习领域的论文引用网络。在这个网络中,每一篇论文就是一个“节点”,论文之间的引用关系构成了“边”。除了引用结构,每篇论文还包含了一个 1433 维的词袋模型(Bag-of-Words)向量,用来表示特定的 1433 个关键词是否在这篇论文中出现过(出现为 1,不出现为 0),这就是我们要用到的特征矩阵 \(X\)

统计项 数值 说明
节点数 (Nodes) 2,708 数据集中共有 2708 篇机器学习论文。
边数 (Edges) 5,429 共有 5429 条引用记录。
特征维度 (Features) 1,433 每个节点是一个 1433 维的 0/1 向量(词汇表大小为 1433)。
类别数 (Classes) 7 论文被分为 7 个研究子领域。分别是:基于案例的推理、遗传算法、神经网络、概率方法、强化学习、规则学习、理论。
图类型 有向图 但在 GCN 等算法中通常被退化为无向图处理。

我们的目标是:基于这 2708 篇论文的特征和它们之间的引用关系,预测它们分别属于哪一个子领域。

7.3.1 加工图数据

首先,我们需要将原始的文本数据转化为 R torch 能够处理的张量。这其中涉及到邻接矩阵的构建和归一化的实现。

#| filename: 10_GCN_Cora.R
library(torch)
library(tidyverse)

# 1. 读取 Content (节点特征与标签)
# 格式: <paper_id> <word_attributes>+ <class_label>
content <- read.table("cora.content", stringsAsFactors = FALSE)

paper_ids <- content$V1
id_map <- setNames(1:length(paper_ids), paper_ids) # 将原始ID映射为 1..N 的连续索引

# 提取特征 (X) 与标签 (Y)
features <- as.matrix(content[, 2:(ncol(content) - 1)])
labels_raw <- content[, ncol(content)]
labels <- as.numeric(as.factor(labels_raw))
num_classes <- length(unique(labels))
num_nodes <- nrow(features)

# 2. 读取 Cites (引用关系/边)
cites <- read.table("cora.cites", stringsAsFactors = FALSE)
edges_source <- id_map[as.character(cites$V1)]
edges_target <- id_map[as.character(cites$V2)]

# 过滤悬空引用
valid_idx <- !is.na(edges_source) & !is.na(edges_target)
edges_source <- edges_source[valid_idx]
edges_target <- edges_target[valid_idx]

在处理大规模图数据时,传统的循环遍历非常低效。在这里,我们利用 R 语言矩阵索引的强大特性,通过 cbind 将起点和终点拼接为坐标矩阵,直接对邻接矩阵 adj 进行向量化赋值:

# 3. 高效构建邻接矩阵 (Adjacency Matrix)
adj <- matrix(0, nrow = num_nodes, ncol = num_nodes)
# 填充边 (无向图通常设为双向)
adj[cbind(edges_source, edges_target)] <- 1
adj[cbind(edges_target, edges_source)] <- 1

接下来是整个数据预处理的核心:结构归一化。我们将上一节推导的公式 \(\tilde{D}^{-1/2}\tilde{A}\tilde{D}^{-1/2}\) 完美地映射为基础的 R 矩阵运算:

# 4. 归一化处理 (Renormalization Trick)
# A_tilde = A + I (添加自环)
adj_tilde <- adj + diag(num_nodes)

# 计算度矩阵 D 的逆平方根
row_sum <- rowSums(adj_tilde)
d_inv_sqrt <- diag(1 / sqrt(row_sum))

# 矩阵乘法计算最终的对称归一化邻接矩阵
adj_norm <- d_inv_sqrt %*% adj_tilde %*% d_inv_sqrt

# 转换为 Torch Tensors
features <- torch_tensor(features, dtype = torch_float())
adj <- torch_tensor(adj_norm, dtype = torch_float())
labels <- torch_tensor(labels, dtype = torch_long())
num_features <- ncol(features)

7.3.2 带特征的 GCN

有了处理好的特征矩阵 features 和归一化邻接矩阵 adj,我们就可以搭建图卷积网络了。这里我们显式地定义了图卷积层 GCNLayer

# 定义单层图卷积层
GCNLayer <- nn_module(
  "GCNLayer",
  initialize = function(in_features, out_features) {
    # 权重矩阵 W (不使用偏置项)
    self$linear <- nn_linear(in_features, out_features, bias = FALSE)
  },
  
  forward = function(x, adj) {
    # 1. 线性变换 (XW)
    support <- self$linear(x)
    # 2. 邻接矩阵传播 (A_tilde * (XW))
    output <- torch_matmul(adj, support)
    return(output)
  }
)

# 定义完整的 GCN 模型
GCN <- nn_module(
  "GCN",
  initialize = function(n_feat, n_hidden, n_class, dropout) {
    self$gc1 <- GCNLayer(n_feat, n_hidden)
    self$gc2 <- GCNLayer(n_hidden, n_class)
    self$dropout <- nn_dropout(p = dropout)
  },
  
  forward = function(x, adj) {
    # 第一层: GCN -> ReLU -> Dropout
    x <- self$gc1(x, adj)
    x <- torch_relu(x)
    x <- self$dropout(x)
    
    # 第二层: GCN
    x <- self$gc2(x, adj)
    
    # 输出层: 直接输出 Logits,交由 nnf_cross_entropy 处理
    return(x)
  }
)
注记

为什么要加入 Dropout 层?

Cora 数据集的特征维度高达 1433 维,而标准的半监督训练集中通常只划分 1040 个节点作为训练样本。在如此高维且小样本的情况下,模型极其容易陷入过拟合。引入 Dropout 可以强迫模型不去死记硬背某些特定的高频词汇,而是更好地学习图结构的泛化特征。

7.3.3 模型训练

图神经网络的一个巨大优势在于半监督学习(Semi-Supervised Learning)。即使全网 2708 篇论文中,我们只知道其中 1040 篇论文的真实分类,GCN 依然能通过引用关系(边),将梯度沿着图结构反向传播,从而学到所有节点的表征。

set.seed(42)
hidden_units <- 16
dropout_rate <- 0.5
learning_rate <- 0.01
weight_decay <- 5e-4
epochs <- 300

# 随机抽取 1040 个节点作为训练集,其余作为测试集
train_indices <- sample(1:num_nodes, size = 1040) 
test_indices <- setdiff(1:num_nodes, train_indices)

model <- GCN(
  n_feat = num_features,
  n_hidden = hidden_units,
  n_class = num_classes,
  dropout = dropout_rate
)

optimizer <- optim_adamw(model$parameters, 
                lr = learning_rate, weight_decay = weight_decay)

for (epoch in 1:epochs) {
  model$train()
  optimizer$zero_grad()
  
  output <- model(features, adj)
  
  # 关键点:Loss 仅仅基于 1040 个训练集节点的预测结果进行计算
  loss <- nnf_cross_entropy(
    output[train_indices, ], labels[train_indices])
  
  loss$backward()
  optimizer$step()
}

经过 300 轮训练,包含特征矩阵的 GCN 模型在测试集上的准确率为 86.33%(随机数不同会有所差别)。做一个对比实验,把代码中的特征矩阵替换为单位矩阵 features <- torch_eye(nrow(content)),即废弃掉论文的词袋信息,仅仅保留网络拓扑结构,模型准确率为 82.37%,约有 3-4 个点的差异。

图的拓扑结构(引用关系)本身就蕴含了极其巨大的信息量。即便我们不看论文里写了什么词,仅仅顺着论文的引用脉络顺藤摸瓜,模型也能猜对大约 80% 的论文分类。而那 1433 维的文本特征,更多的是起到了锦上添花的作用,帮我们进一步理清了那些处于结构边缘或引用关系复杂的模糊节点。

最后利用所有论文最终训练出来的 embeddings 结果,再做 t-SNE 降维可视化:

# 获取模型对所有节点的输出表征
model$eval()
final_output <- model(features, adj)
embeddings <- as_array(final_output$detach())
# 绘图过程省略

从可视化结果中我们可以清晰地看到,原本在 1433 维空间中混杂一团的节点,在经历了两次图卷积操作后,7 个不同学科门类的论文在二维平面上形成了泾渭分明的簇状聚集。