全部版块 我的主页
论坛 新商科论坛 四区(原工商管理论坛) 商学院 运营管理(物流与供应链管理)
68 0
2025-11-14

准备工作:确保环境可重现

和上一节一样,我们将使用

torch


torch_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

1. 更深入的模型:图注意力网络 (GAT)

GAT 的核心理念是:
“你的邻居并不都同样重要。”
GCN 对所有邻居(根据度)进行固定的加权平均。GAT 则引入了
自注意机制 (Self-Attention)。
对于一个节点 $i$,它会为它的每一个邻居 $j$ 计算一个
注意力系数 $\alpha_{ij}$。
这个 $\alpha_{ij}$ 是
可调整的
,它取决于节点 $i$ 的特征和节点 $j$ 的特征。这意味着模型可以学会“当我在对这篇‘机器学习’论文(节点 $i$)进行分类时,我应该更多地关注引用的‘概率论’论文(节点 $j$),而较少关注引用的‘数据库’论文(节点 $k$)。”

GAT 完整可重现代码 (Cora 数据集)
我们将使用与 GCN 完全相同的数据集 (

Cora

) 和训练流程。唯一的区别在于模型定义。我们将使用
torch_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%)。
    # 它的真正优势在于对邻居的“可解释性”(可以检查注意力权重)
    # 以及在异构图(不同类型的节点和边)上的强大表现力。

2. 深入探讨:GCN 的“过平滑”陷阱

这是一个在 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 的方差会小几个数量级

二维码

扫码加我 拉你入群

请注明:姓名-公司-职位

以便审核进群资格,未注明则拒绝

栏目导航
热门文章
推荐文章

说点什么

分享

扫码加好友,拉您进群
各岗位、行业、专业交流群