准备工作:确保环境可重现
和上一节一样,我们将使用
torchtorch_geometric。# 确保 PyTorch 已安装
# pip install torch
#
# 安装 PyTorch Geometric 相关库
# pip install torch_geometric
# pip install torch-scatter torch-sparse -f https://data.pyg.org/whl/torch-$(torch.__version__).split('+')[0].html
GAT 的核心理念是:
“你的邻居并不都同样重要。”
GCN 对所有邻居(根据度)进行固定的加权平均。GAT 则引入了
自注意机制 (Self-Attention)。
对于一个节点 $i$,它会为它的每一个邻居 $j$ 计算一个
注意力系数 $\alpha_{ij}$。
这个 $\alpha_{ij}$ 是
可调整的
,它取决于节点 $i$ 的特征和节点 $j$ 的特征。这意味着模型可以学会“当我在对这篇‘机器学习’论文(节点 $i$)进行分类时,我应该更多地关注引用的‘概率论’论文(节点 $j$),而较少关注引用的‘数据库’论文(节点 $k$)。”
GAT 完整可重现代码 (Cora 数据集)
我们将使用与 GCN 完全相同的数据集 (
Coratorch_geometric.nn.GATConv。
我们还将采用
多头注意 (Multi-Head Attention),
这是 GAT 的一个标准实践。模型会并行学习(例如)8 个独立的注意力机制,然后将它们的结果拼接起来,这使得学习过程更加稳定。
Python
import torch
import torch.nn.functional as F
from torch_geometric.datasets import Planetoid
# 导入 GATConv
from torch_geometric.nn import GATConv
import time
import numpy as np
# ----------------------------------------------------
# 1. 定义 GAT 模型
# ----------------------------------------------------
class GAT(torch.nn.Module):
def __init__(self, num_node_features, num_hidden, num_classes, heads=8, dropout=0.6):
"""
定义 GAT 模型
参数:
num_node_features (int): 输入特征维度
num_hidden (int): 隐藏层中 *每个头* 的维度 (例如 8)
num_classes (int): 输出类别数
heads (int): 多头注意力的头数 (例如 8)
dropout (float): GAT 论文中使用的标准 dropout 率
"""
super(GAT, self).__init__()
self.dropout = dropout
# GAT 论文的标准架构是两层
# --- 第一层 ---
# (输入特征, 每个头的输出维度, 头数)
# 注意:PyG 的 GATConv 会自动将多头结果拼接 (concat)
# 所以输出维度是 num_hidden * heads (即 8 * 8 = 64)
self.conv1 = GATConv(num_node_features,
num_hidden,
heads=heads,
dropout=dropout)
# --- 第二层 (输出层) ---
# 输入维度是上一层的输出 (num_hidden * heads)
# 输出维度是类别数
# 在最后一层,我们通常不再拼接,而是 *平均* 所有的头
# PyG 通过设置 heads=1 和 concat=False (默认) 来实现
# 但 GAT 论文的实现是:
# heads=1, concat=False
# 或者
# heads=N, concat=False (它会对 N 个头的输出求平均)
# 我们使用后者,与论文保持一致
self.conv2 = GATConv(num_hidden * heads,
num_classes,
heads=1, # 最终输出时使用 1 个头
concat=False, # 或者 concat=False 来平均
dropout=dropout)
def forward(self, data):
"""
定义前向传播
GAT 论文使用 ELU 作为激活函数,并大量使用 Dropout
"""
x, edge_index = data.x, data.edge_index
# --- 输入层 Dropout ---
# GAT 论文在输入特征上也应用了 dropout
x = F.dropout(x, p=self.dropout, training=self.training)
# --- GAT 层 1 ---
x = self.conv1(x, edge_index)
# 论文中使用 ELU 激活函数
x = F.elu(x)
# --- 中间层 Dropout ---
x = F.dropout(x, p=self.dropout, training=self.training)
# --- GAT 层 2 (输出) ---
x = self.conv2(x, edge_index)
# --- 输出 LogSoftmax ---
return F.log_softmax(x, dim=1)
# ----------------------------------------------------
# 2. 训练和测试函数 (与上一节 GCN 代码完全相同)
# ----------------------------------------------------
def train(model, optimizer, criterion, data):
model.train()
optimizer.zero_grad()
out = model(data)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
return loss.item()
def test(model, data):
model.eval()
with torch.no_grad():
out = model(data)
pred = out.argmax(dim=1)
# 训练集准确率
train_correct = pred[data.train_mask] == data.y[data.train_mask]
train_acc = int(train_correct.sum()) / int(data.train_mask.sum())
# 测试集准确率
test_correct = pred[data.test_mask] == data.y[data.test_mask]
test_acc = int(test_correct.sum()) / int(data.test_mask.sum())
return train_acc, test_acc
# ----------------------------------------------------
# 3. 主执行函数
# ----------------------------------------------------
if __name__ == "__main__":
print("--- 1. GAT (图注意力网络) 可复现代码 ---")
# 1. 设置设备
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"使用设备: {device}")
# 2. 加载 Cora 数据集
print("正在加载 Cora 数据集...")
dataset = Planetoid(root='/tmp/Cora_GAT', name='Cora')
data = dataset[0].to(device)
print("\nCora 数据集概览:")
print(f" 特征维度: {dataset.num_node_features}")
print(f" 类别数: {dataset.num_classes}")
print(f" 训练节点数: {data.train_mask.sum()}")
print(f" 测试节点数: {data.test_mask.sum()}")
# 3. 实例化模型、优化器和损失函数
# GAT 论文中的超参数
HIDDEN_DIM = 8 # 每个注意力头的维度
HEADS = 8 # 注意力头数
DROPOUT = 0.6 # GAT 使用的高 dropout 率
LEARNING_RATE = 0.005
WEIGHT_DECAY = 5e-4 # L2 正则化
EPOCHS = 200
model = GAT(
num_node_features=dataset.num_node_features,
num_hidden=HIDDEN_DIM,
num_classes=dataset.num_classes,
heads=HEADS,
dropout=DROPOUT
).to(device)
# GAT 论文使用 Adam 优化器
optimizer = torch.optim.Adam(model.parameters(),
lr=LEARNING_RATE,
weight_decay=WEIGHT_DECAY)
criterion = torch.nn.NLLLoss()
# 4. 训练循环
print(f"\n开始训练 {EPOCHS} 个 epochs... (使用 GAT 论文超参数)")
start_time = time.time()
for epoch in range(1, EPOCHS + 1):
loss = train(model, optimizer, criterion, data)
if epoch % 20 == 0 or epoch == 1 or epoch == EPOCHS:
train_acc, test_acc = test(model, data)
print(f'Epoch: {epoch:03d}, 损失: {loss:.4f}, '
f'训练集 Acc: {train_acc:.4f}, 测试集 Acc: {test_acc:.4f}')
end_time = time.time()
print(f"训练完成,耗时: {end_time - start_time:.2f} 秒")
# 5. 最终评估
train_acc, test_acc = test(model, data)
print("--- 最终 GAT 结果 ---")
print(f"最终训练集准确率: {train_acc:.4f}")
print(f"最终测试集准确率: {test_acc:.4f}")
# 观察:
# GAT 的性能通常与 GCN 相当或略好 (在 Cora 上约 82%-84%)。
# 它的真正优势在于对邻居的“可解释性”(可以检查注意力权重)
# 以及在异构图(不同类型的节点和边)上的强大表现力。
这是一个在 GNN 实践中
非常关键
的问题。
问题:
GCN 效果很好,为什么我们不堆叠 10 层、20 层甚至 50 层来构建一个“深度”GCN 呢?(就像在 CNN 中使用 ResNet-50 一样)
答案:过平滑 (Oversmoothing)。
核心概念:
GCN 的每一层本质上都是一次邻域平均。
在 1 层 GCN 后,节点 $i$ 的特征是其 1 跳邻居的平均值。
在 2 层 GCN 后,节点 $i$ 的特征是其 2 跳邻居的平均值。
在
$k$ 层
GCN 后,节点 $i$ 的特征是其
$k$ 跳邻居
的平均值。
如果图的
直径
(图中任意两点间的最长最短路径)是 10,那么在一个 10 层的 GCN 之后,节点 $i$ 的特征在某种程度上是
图中所有其他节点
特征的平均值。
当 $k$ 变得非常大时,
所有节点
的特征向量都会收敛到
同一个值
(与图的稳态分布相关)。当所有节点的特征都相同时,分类器就无法区分它们了。
过平滑的可重现代码演示
我们将构建一个“深度” GCN,并
在不进行任何训练
的情况下,仅通过一次前向传播来展示这种效应。我们将测量两个不同节点的输出特征的
余弦相似度。
如果模型健康,两个不同类别节点的特征向量应该非常不同(余弦相似度接近 0 或为负)。
如果模型“过平滑”,两个节点的特征向量将几乎相同(余弦相似度接近 1.0)。
Python
import torch
import torch.nn.functional as F
from torch_geometric.datasets import Planetoid
from torch_geometric.nn import GCNConv
import time
# ----------------------------------------------------
# 1. 定义一个 "深度" GCN
# ----------------------------------------------------
class DeepGCN(torch.nn.Module):
def __init__(self, num_features, num_hidden, num_classes, num_layers):
super(DeepGCN, self).__init__()
self.num_layers = num_layers
self.convs = torch.nn.ModuleList()
# 输入层
self.convs.append(GCNConv(num_features, num_hidden))
# 隐藏层
for _ in range(num_layers - 2):
self.convs.append(GCNConv(num_hidden, num_hidden))
# 输出层
self.convs.append(GCNConv(num_hidden, num_classes))
def forward(self, data):
x, edge_index = data.x, data.edge_index
for i in range(self.num_layers):
x = self.convs[i](x, edge_index)
# 我们只在隐藏层使用激活
if i < self.num_layers - 1:
x = F.relu(x)
# 注意:为了演示纯粹的过平滑,我们甚至可以去掉激活
# x = F.dropout(x, p=0.5, training=self.training)
return x
# ----------------------------------------------------
# 2. 主执行函数:演示过平滑
# ----------------------------------------------------
if __name__ == "__main__":
print("\n--- 2. 深入 GCN: 演示'过平滑'问题 ---")
# 1. 设置设备和加载数据
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
dataset = Planetoid(root='/tmp/Cora_Oversmooth', name='Cora')
data = dataset[0].to(device)
# 2. 选择两个节点进行比较
# 节点 10 和 节点 2000
# 检查它们是否属于不同类别 (大概率)
node_a_idx = 10
node_b_idx = 2000
print(f"节点 {node_a_idx} 的类别: {data.y[node_a_idx]}")
print(f"节点 {node_b_idx} 的类别: {data.y[node_b_idx]}")
print(f"设备: {device}")
# 3. 实例化两个模型:一个浅层,一个深层
# (注意:我们只实例化,不训练)
# 浅层模型 (2层)
shallow_gcn = DeepGCN(
num_features=dataset.num_node_features,
num_hidden=64,
num_classes=64, # 输出维度设为 64 以便比较
num_layers=2
).to(device)
# 深层模型 (32层)
deep_gcn = DeepGCN(
num_features=dataset.num_node_features,
num_hidden=64,
num_classes=64,
num_layers=32
).to(device)
# 4. 执行一次前向传播 (不训练)
shallow_gcn.eval()
deep_gcn.eval()
with torch.no_grad():
out_shallow = shallow_gcn(data)
out_deep = deep_gcn(data)
print(f"\n输出特征向量的形状 (N, F): {out_shallow.shape}")
# 5. 提取两个节点的特征向量
feat_shallow_a = out_shallow[node_a_idx]
feat_shallow_b = out_shallow[node_b_idx]
feat_deep_a = out_deep[node_a_idx]
feat_deep_b = out_deep[node_b_idx]
# 6. 计算余弦相似度
# cos_sim(A, B) = (A · B) / (||A|| * ||B||)
cos_sim_shallow = F.cosine_similarity(feat_shallow_a.unsqueeze(0),
feat_shallow_b.unsqueeze(0))
cos_sim_deep = F.cosine_similarity(feat_deep_a.unsqueeze(0),
feat_deep_b.unsqueeze(0))
# 7. 报告结果
print("\n--- 过平滑测试结果 ---")
print(f"2层 GCN: 节点 {node_a_idx} 和 {node_b_idx} 的特征余弦相似度: {cos_sim_shallow.item():.6f}")
print(f"32层 GCN: 节点 {node_a_idx} 和 {node_b_idx} 的特征余弦相似度: {cos_sim_deep.item():.6f}")
# 预期结果:
# 2层 GCN 的相似度会比较低 (例如 0.1 到 0.5 之间,取决于随机初始化)
# 32层 GCN 的相似度会 *极其* 接近 1.0 (例如 0.999999)
if cos_sim_deep.item() > 0.99:
print("\n[!!] 演示成功:在 32 层后,两个节点的特征向量几乎完全相同。")
print("这就是'过平滑':模型失去了区分节点的能力。")
# (可选) 检查整个图的特征方差
# 方差接近 0 意味着所有特征都一样了
var_shallow = out_shallow.var(dim=0).mean().item()
var_deep = out_deep.var(dim=0).mean().item()
print(f"\n2层 GCN 的平均特征方差: {var_shallow:.6e}")
print(f"32层 GCN 的平均特征方差: {var_deep:.6e}")
# 预期结果:深层 GCN 的方差会小几个数量级
扫码加好友,拉您进群



收藏
