【图神经网络:原理与实战】第一章 图与矩阵表示 代码仓库

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

和上一节一样,我们将使用 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

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

1.2 图的代数性质 (深度实现版)

# 导入核心库
import networkx as nx
import numpy as np
import matplotlib.pyplot as plt
import scipy.sparse as sp
from scipy.sparse.linalg import eigsh, ArpackNoConvergence
import warnings

# --- Matplotlib 可视化设置 (确保中文显示) ---
plt.rcParams['font.sans-serif'] = ['SimHei', 'Heiti TC', 'Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['figure.dpi'] = 110 # 提高分辨率
warnings.filterwarnings("ignore", category=UserWarning)

# --- 1. 准备工作:杠铃图和 L_sym ---
N1 = 10 # 左侧团簇 10 个节点
N2 = 0  # 两个团簇间 0 条边(只有1条边连接两个团簇)
G = nx.barbell_graph(N1, N2)
N = G.number_of_nodes() # 总节点数 N = 20
nodelist = list(G.nodes()) # 固定节点顺序 [0, 1, ..., 19]
pos = nx.spring_layout(G, seed=42) # 固定可视化布局

# 我们将使用归一化拉普拉斯 L_sym = I - D^(-1/2) * A * D^(-1/2)
# 它是 GCN 和谱分析的标准选择
L_sym_sparse = nx.normalized_laplacian_matrix(G, nodelist=nodelist).asformat('csr')
# L_sym 是对称且半正定的,其特征值 λ 范围为 [0, 2]
print(f"示例图:杠铃图 (N={N})")
print(f"L_sym 稀疏矩阵 (CSR 格式):\n{L_sym_sparse.toarray()[:5, :5]}...")

1.2.1 拉普拉斯谱分解与特征向量

1.2.1.A: 精确谱分解 (Dense, 教学用)

def get_full_spectrum(L_sparse):
    """
    (教学用) 对稀疏拉普拉斯矩阵进行完全谱分解。
    
    挑战:L.toarray() 在 N > ~10k 时会 OOM (内存溢出)
          np.linalg.eigh 是 O(N^3) 计算
    """
    print("--- (教学) 执行完全谱分解 (Dense)... ---")
    
    # 1. 转换为稠密矩阵 (昂贵!)
    L_dense = L_sparse.toarray()
    
    # 2. 使用 eigh (专为对称矩阵优化,保证实数结果)
    # eigenvalues 按升序排列
    # U 的第 k 列 U[:, k] 是对应 eigenvalues[k] 的特征向量
    eigenvalues, U = np.linalg.eigh(L_dense)
    
    print(f"  特征值 (Λ) 形状: {eigenvalues.shape}")
    print(f"  特征向量 (U) 形状: {U.shape}")
    
    # 验证分解: L_sym = U Λ U^T
    Lambda = np.diag(eigenvalues)
    L_reconstructed = U @ Lambda @ U.T
    
    # np.allclose 用于浮点数比较
    assert np.allclose(L_dense, L_reconstructed), "谱分解重构失败"
    print("  谱分解验证成功: L = U Λ U^T")
    
    return eigenvalues, U

# 执行完全分解
eigenvalues, U = get_full_spectrum(L_sym_sparse)

print(f"最小的 3 个特征值 (频率): {eigenvalues[:3]}")
print(f"最大的 3 个特征值 (频率): {eigenvalues[-3:]}")

1.2.1.B: 近似谱分解 (Sparse, 实践用)

def get_sparse_spectrum(L_sparse, k=5):
    """
    (实践用) 高效计算 L_sparse 的 k 个最小特征值/向量。
    
    使用 ARPACK 迭代求解器 (通过 eigsh)
    """
    print(f"\n--- (实践) 执行稀疏谱分解 (Sparse, k={k})... ---")
    
    # 挑战:k 必须 < N-1
    if k >= L_sparse.shape[0] - 1:
        print(f"  警告: k={k} 太大, 调整为 k={L_sparse.shape[0] - 2}")
        k = L_sparse.shape[0] - 2

    try:
        # L_sparse 必须是 float 类型
        L_sparse_float = L_sparse.astype(np.float64)
        
        # which='SM' -> Smallest Magnitude (最小的幅度)
        # 这对于 L_sym (特征值 >= 0) 意味着最小的特征值
        # 返回值默认按升序排列
        lambda_k, U_k = eigsh(L_sparse_float, k=k, which='SM')
        
        print(f"  稀疏-特征值 (λ_k) 形状: {lambda_k.shape}")
        print(f"  稀疏-特征向量 (U_k) 形状: {U_k.shape}")
        print(f"  计算出的 {k} 个最小特征值: {lambda_k}")
        
        return lambda_k, U_k

    except ArpackNoConvergence:
        print(f"  错误: eigsh (ARPACK) 未能收敛。")
        return None, None
    except Exception as e:
        print(f"  错误: {e}")
        return None, None

# 执行稀疏分解
lambda_k, U_k = get_sparse_spectrum(L_sym_sparse, k=5)

# 验证稀疏结果与完全分解结果一致
assert np.allclose(eigenvalues[:5], lambda_k), "稀疏与稠密特征值不匹配"
print("  稀疏谱分解验证成功 (前 k 个值匹配)")

1.2.1.C: 可视化特征向量 (图的振动模式)

我们将使用完全分解的 $U$ 来可视化,因为它包含了所有频率。

Python

def plot_eigenvector(ax, G, pos, vec, title):
    """辅助函数:在图上绘制特征向量信号"""
    # 标准化 vec (例如 -1 到 1) 以便 cmap 映射
    # u_k 和 -u_k 都是有效特征向量,我们标准化方向
    if np.sum(vec) < 0:
        vec = -vec
        
    vmin, vmax = np.min(vec), np.max(vec)
    
    nx.draw(G, pos, ax=ax, node_color=vec, cmap='coolwarm', 
            vmin=-np.abs(vmax), vmax=np.abs(vmax), # 对称色条
            with_labels=False, node_size=200)
    ax.set_title(title, fontsize=10)

fig, axes = plt.subplots(1, 4, figsize=(20, 4))
fig.suptitle("1.2.1: 图的“振动模式” (L_sym 特征向量)", fontsize=16)

# 1. 模式 0 (k=0): 最低频 "DC 信号"
vec_0 = U[:, 0]
plot_eigenvector(axes[0], G, pos, vec_0, 
                 f"u_0 (k=0, DC 信号)\nλ_0 = {eigenvalues[0]:.2e}")

# 2. 模式 1 (k=1): Fiedler 向量 "社区信号"
vec_1 = U[:, 1]
plot_eigenvector(axes[1], G, pos, vec_1, 
                 f"u_1 (Fiedler 向量)\nλ_1 = {eigenvalues[1]:.3f} (低频)")

# 3. 模式 5 (k=5): 中频
vec_5 = U[:, 5]
plot_eigenvector(axes[2], G, pos, vec_5, 
                 f"u_5 (k=5, 中频)\nλ_5 = {eigenvalues[5]:.3f}")

# 4. 模式 N-1 (k=19): 最高频 "噪声信号"
vec_N_1 = U[:, N-1]
plot_eigenvector(axes[3], G, pos, vec_N_1, 
                 f"u_{N-1} (k={N-1}, 最高频)\nλ_{N-1} = {eigenvalues[N-1]:.3f}")

plt.tight_layout(rect=[0, 0.03, 1, 0.90])
plt.savefig("1.2.1_eigenvectors_deep.png")
# plt.show()

可视化解读:


1.2.2 谱域滤波与频谱解释

1.2.2.A: 频谱解释 (量化平滑度)
def calculate_smoothness(x, L_sym):
    """计算拉普拉斯二次型 (总变异/平滑度)"""
    # x 必须是 (N,) 向量
    # L_sym 必须是 (N, N) 矩阵 (稀疏或稠密)
    return x.T @ L_sym @ x

print("\n--- 1.2.2.A: 量化信号平滑度 ---")
# 信号 1: 高频 (随机噪声)
x_high_freq = np.random.randn(N)
x_high_freq /= np.linalg.norm(x_high_freq) # 归一化能量

# 信号 2: 低频 (u_1, Fiedler 向量)
x_low_freq = U[:, 1] # 已经归一化

# 信号 3: 社区信号 (手动创建)
x_community = np.ones(N)
x_community[:N1] = -1.0
x_community /= np.linalg.norm(x_community)

# L_sym_sparse 是 SciPy 稀疏矩阵,它能正确处理 @ 运算符
smooth_high = calculate_smoothness(x_high_freq, L_sym_sparse)
smooth_low_fiedler = calculate_smoothness(x_low_freq, L_sym_sparse)
smooth_low_community = calculate_smoothness(x_community, L_sym_sparse)

print(f"  高频 (随机) 信号平滑度 (x^T L x): {smooth_high:.4f}")
print(f"  低频 (Fiedler) 信号平滑度 (x^T L x): {smooth_low_fiedler:.4f}")
print(f"  低频 (社区) 信号平滑度 (x^T L x): {smooth_low_community:.4f}")

# 关键洞察:
# x_low_freq (u_1) 是 L_sym 的特征向量。
# 根据线性代数: L_sym @ u_1 = λ_1 * u_1
# 所以: u_1^T @ L_sym @ u_1 = u_1^T @ (λ_1 * u_1) = λ_1 * (u_1^T @ u_1)
# 因为 u_1 归一化了 (u_1^T u_1 = 1), 所以 u_1^T L_sym u_1 = λ_1
assert np.allclose(smooth_low_fiedler, eigenvalues[1])
print(f"  (验证成功:Fiedler 向量的平滑度 == λ_1)")

1.2.2.B: 谱域滤波 (精确,教学用)

def spectral_filter(x, U, eigenvalues, filter_func):
    """
    (教学用) 使用完全分解在谱域中过滤信号 x。
    
    实现: x_filt = U * h(Λ) * (U^T * x)
    """
    # 1. GFT (分析): x -> x_hat
    # (U.T @ x) 将 x 投影到 N 个特征向量基上
    x_hat = U.T @ x
    
    # 2. 滤波: h(λ) * x_hat
    # h(Λ) 是一个对角矩阵,其 (k,k) 元素是 filter_func(λ_k)
    # 我们可以用逐元素乘法高效实现
    h_lambda = filter_func(eigenvalues)
    x_hat_filtered = h_lambda * x_hat
    
    # 3. IGFT (合成): x_hat_filt -> x_filt
    x_filtered = U @ x_hat_filtered
    
    return x_filtered

# 定义低通滤波器函数
tau = 5.0 # 滤波强度 (τ 越大,平滑越强)
low_pass_kernel = lambda l: np.exp(-tau * l)

# 定义高通滤波器函数 (仅为对比)
high_pass_kernel = lambda l: 1.0 - np.exp(-tau * l)

print(f"\n--- 1.2.2.B: 执行谱域滤波 (τ={tau}) ---")
# 1. 对高频随机信号进行低通滤波
x_low_passed = spectral_filter(x_high_freq, U, eigenvalues, low_pass_kernel)
# 2. 对高频随机信号进行高通滤波
x_high_passed = spectral_filter(x_high_freq, U, eigenvalues, high_pass_kernel)

# --- 可视化滤波效果 ---
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
fig.suptitle(f"1.2.2: 谱域滤波 (τ={tau})", fontsize=16)

plot_eigenvector(axes[0], G, pos, x_high_freq, "原始信号 (高频/随机)")
plot_eigenvector(axes[1], G, pos, x_low_passed, "低通滤波 (平滑)")
plot_eigenvector(axes[2], G, pos, x_high_passed, "高通滤波 (振荡)")

plt.tight_layout(rect=[0, 0.03, 1, 0.90])
plt.savefig("1.2.2_filtering_deep.png")
# plt.show()

可视化解读 (关键):

  • 原始信号: 完全随机的噪声。

  • 低通滤波: 噪声被“抹平”了。剩下的信号清晰地揭示了图的社区结构(左红右蓝)。我们从噪声中恢复了结构!

  • 高通滤波: 结构被移除了,只剩下高频振荡。

1.2.2.C: 谱域滤波 (近似,实践用)

def approximate_low_pass_filter(x, U_k):
    """
    (实践用) 使用 k 个最小特征向量 U_k 近似低通滤波。
    
    实现: x_approx = U_k @ (U_k.T @ x)
    这会将 x 投影到 U_k 张成的子空间上。
    """
    # U_k: (N, k)
    # x: (N,)
    
    # 1. 分析 (GFT): (k, 1) = (k, N) @ (N, 1)
    x_hat_k = U_k.T @ x
    
    # 2. 合成 (IGFT): (N, 1) = (N, k) @ (k, 1)
    x_approx = U_k @ x_hat_k
    
    return x_approx

print(f"\n--- 1.2.2.C: 执行近似低通滤波 (k={lambda_k.shape[0]}) ---")
# U_k 是 1.2.1.B 中计算的 (N, 5) 矩阵
x_approx_filtered = approximate_low_pass_filter(x_high_freq, U_k)

# 可视化
plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
plot_eigenvector(plt.gca(), G, pos, x_high_freq, "原始信号 (高频/随机)")
plt.subplot(1, 2, 2)
plot_eigenvector(plt.gca(), G, pos, x_approx_filtered, f"近似低通滤波 (仅 k={lambda_k.shape[0]} 个基)")
plt.suptitle("1.2.2.C: 实践中的近似滤波 (k-NN)", fontsize=16)
plt.tight_layout(rect=[0, 0.03, 1, 0.90])
plt.savefig("1.2.2_filtering_approx.png")
# plt.show()

可视化解读:

结果与“精确”低通滤波非常相似!这证明了我们不需要 $O(N^3)$ 的完全分解。我们只需 $k$ 个基向量,就能捕捉到图的大部分“结构化”信息。


1.2.3 图傅里叶变换与卷积定义

Python

print("\n--- 1.2.3 GFT 与卷积定义 ---")

def gft(x, U):
    """(教学用) GFT: (N, F) -> (N, F)"""
    return U.T @ x

def igft(x_hat, U):
    """(教学用) IGFT: (N, F) -> (N, F)"""
    return U @ x_hat

def spectral_convolution(x, h, U):
    """(教学用) 经典谱卷积定义"""
    # x: (N, 1)
    # h: (N, 1) (空间域的滤波器核)
    # U: (N, N)
    
    # 1. GFT
    x_hat = gft(x, U) # (N, 1)
    h_hat = gft(h, U) # (N, 1)
    
    # 2. 谱域乘积
    convolved_hat = x_hat * h_hat # 逐元素乘积
    
    # 3. IGFT
    convolved = igft(convolved_hat, U)
    return convolved

# --- 演示 ---
# 1. 验证 GFT/IGFT 可逆性
x_test = x_high_freq
x_reconstructed = igft(gft(x_test, U), U)
print(f"GFT/IGFT 可逆性验证: {np.allclose(x_test, x_reconstructed)}")

# 2. 演示 GNN 的 "滤波" 与 "卷积" 的等价性
# GNN 的方法 (滤波)
h_lambda = low_pass_kernel(eigenvalues) # h(λ)
x_filtered = U @ (np.diag(h_lambda) @ (U.T @ x_test))

# 经典卷积的方法
# 我们需要找到一个空间信号 h, 使得 h_hat = h(λ)
# h = IGFT(h(λ))
h_spatial_kernel = igft(h_lambda, U)
x_convolved = spectral_convolution(x_test, h_spatial_kernel, U)

print(f"GNN滤波 与 经典卷积 是否等价: {np.allclose(x_filtered, x_convolved)}")
print("  (成功!GNN 的谱卷积是一种高效的、可学习的谱域滤波。)")

1.2.4 图谱不变性与对称性分析

  • 挑战: 仅凭谱相同不能 100% 保证图同构(存在“同谱图”),但它是一个非常强的必要条件。

Python

print("\n--- 1.2.4 图谱不变性 (同构测试) ---")
# G 是我们的原始杠铃图 (节点 0-19)
# 1. 创建一个 G 的同构图 G_iso
# 我们将节点标签随机打乱 (置换)
permutation = np.random.permutation(N)
# 映射: {0: p_0, 1: p_1, ...}
relabel_map = {i: permutation[i] for i in range(N)}
G_iso = nx.relabel_nodes(G, relabel_map)

# 2. 计算 G_iso 的谱
L_sym_iso_sparse = nx.normalized_laplacian_matrix(G_iso, nodelist=list(G_iso.nodes()))
eigenvalues_iso, U_iso = get_full_spectrum(L_sym_iso_sparse) # (教学用)

# 3. 比较谱
print(f"原始图 G 的前 5 个特征值:\n  {eigenvalues[:5]}")
print(f"同构图 G_iso 的前 5 个特征值:\n  {eigenvalues_iso[:5]}")

are_spectra_identical = np.allclose(eigenvalues, eigenvalues_iso)
print(f"\n[结论] 同构图的特征值谱是否完全相同: {are_spectra_identical}")
assert are_spectra_identical

# 4. 特征向量并不同!它们被置换了。
are_vectors_identical = np.allclose(U, U_iso)
print(f"  同构图的特征向量矩阵是否相同: {are_vectors_identical}")
assert not are_vectors_identical

2. 对称性 (自同构)

图的几何对称性(例如杠铃图的左右对称)会直接导致谱的代数对称性(重叠的特征值,即特征值简并)。

  • 杠铃图是左右对称的。这意味着如果我们交换左团簇 (0-9) 和右团簇 (10-19),图的结构不变。

  • 这种对称性导致很多特征值是成对出现的(简并度=2)。

  • Fiedler 向量 u_1 必须是反对称的:$u_1[i] = -u_1[i+10]$。

  • DC 向量 u_0 必须是对称的:$u_0[i] = u_0[i+10]$。

Python

print("\n--- 1.2.4 图的对称性 (自同构) ---")
# 1. 检查重叠的特征值
# 我们打印相邻特征值之间的差异
eigenvalue_diffs = np.diff(eigenvalues)
print(f"特征值之间的差异 (前 10 个):\n{eigenvalue_diffs[:10]}")
# 观察:很多差异接近 0 (例如 e-16),这意味着特征值重叠了
overlapping_eigenvalues = np.sum(eigenvalue_diffs < 1e-10)
print(f"\n检测到 {overlapping_eigenvalues} 对重叠/简并的特征值。")

# 2. 验证 Fiedler 向量 u_1 的反对称性
vec_1 = U[:, 1]
# 标准化方向
if np.mean(vec_1[:N1]) > 0:
    vec_1 = -vec_1

left_cluster_mean = np.mean(vec_1[:N1]) # 节点 0-9
right_cluster_mean = np.mean(vec_1[N1:]) # 节点 10-19
print(f"\nFiedler 向量 u_1 (反对称):")
print(f"  左团簇均值: {left_cluster_mean:.4f} (应为负)")
print(f"  右团簇均值: {right_cluster_mean:.4f} (应为正)")
assert np.allclose(left_cluster_mean, -right_cluster_mean)
print("  (验证成功:u_1 是反对称的)")

# 3. 验证 u_0 的对称性
vec_0 = U[:, 0]
left_cluster_mean_u0 = np.mean(vec_0[:N1])
right_cluster_mean_u0 = np.mean(vec_0[N1:])
print(f"\nDC 向量 u_0 (对称):")
print(f"  左团簇均值: {left_cluster_mean_u0:.4f}")
print(f"  右团簇均值: {right_cluster_mean_u0:.4f}")
assert np.allclose(left_cluster_mean_u0, right_cluster_mean_u0)
print("  (验证成功:u_0 是对称的)")

所有代码都将是完整且可执行的。我们将使用 networkxnumpyscipymatplotlib

# --- 核心导入 ---
import networkx as nx
import numpy as np
import matplotlib.pyplot as plt
import scipy.sparse as sp
import random
from collections import Counter

# --- Matplotlib 可视化设置 (确保中文显示) ---
plt.rcParams['font.sans-serif'] = ['SimHei', 'Heiti TC', 'Arial Unicode MS']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['figure.dpi'] = 110

1.3 图的概率模型与随机过程

1.3.1 随机游走与马尔可夫链

理论阐述:

图上的随机游走 (Random Walk) 是一种随机过程,描述了一个“游走者”在节点间移动。当下一步的转移概率只依赖于当前节点(而与历史路径无关)时,该过程就是一个马尔可夫链 (Markov Chain)。

def get_transition_matrix(G):
    """
    计算图 G 的马尔可夫转移概率矩阵 P = D^{-1}A。
    
    参数:
    G (nx.Graph or nx.DiGraph): NetworkX 图对象。
    
    返回:
    P (np.ndarray): 转移概率矩阵 (N x N, 稠密)。
    nodelist (list): P 中行/列 对应的节点列表。
    """
    # 1. 获取邻接矩阵 A (使用 networkx 的高效实现)
    #    指定 nodelist 确保矩阵顺序
    nodelist = list(G.nodes())
    N = len(nodelist)
    if N == 0:
        return np.array([]), nodelist
    
    # 获取带权的邻接矩阵 (CSR 稀疏格式)
    # 对于无权图,权重默认为 1
    A_sparse = nx.to_scipy_sparse_array(G, nodelist=nodelist, weight='weight', format='csr')
    
    # 2. 计算出度/强度 (D)
    # A.sum(axis=1) 计算每行的和,返回一个 (N, 1) 的矩阵
    out_strength = np.asarray(A_sparse.sum(axis=1)).flatten()
    
    # 3. 挑战:处理零度节点 (Sinks)
    zero_strength_mask = (out_strength == 0)
    
    # 4. 构建 D^{-1}
    # 初始化 D_inv 为 0,避免除零
    inv_strength = np.zeros_like(out_strength)
    # 只在非零强度的位置计算 1/strength
    inv_strength[~zero_strength_mask] = 1.0 / out_strength[~zero_strength_mask]
    
    # D_inv_sparse 是 D^{-1} 的稀疏对角矩阵
    D_inv_sparse = sp.diags(inv_strength, format='csr')
    
    # 5. 计算 P = D^{-1}A
    P_sparse = D_inv_sparse @ A_sparse
    
    # 6. (关键) 处理零度节点的"陷入"规范
    # 将 P[i, i] 设为 1 (对于度为 0 的节点 i)
    P_sparse = P_sparse.tolil() # LIL 格式支持高效的对角线赋值
    P_sparse[zero_strength_mask, zero_strength_mask] = 1.0
    P_sparse = P_sparse.tocsr()
    
    # 返回稠密矩阵以便于教学和 numpy 操作
    return P_sparse.toarray(), nodelist

def simulate_random_walk(G, start_node, walk_length):
    """
    在图 G 上模拟一次随机游走。
    
    参数:
    G (nx.Graph): NetworkX 图对象 (带权或无权)
    start_node: 起始节点
    walk_length (int): 游走步数
    
    返回:
    path (list): 包含游走路径的节点列表
    """
    if start_node not in G:
        raise ValueError(f"起始节点 {start_node} 不在图中。")
        
    path = [start_node]
    current_node = start_node
    
    for _ in range(walk_length - 1):
        # 获取所有邻居
        neighbors = list(G.neighbors(current_node))
        
        if not neighbors:
            # 这是一个 "sink" (没有出边),游走被困住
            # 我们让它停在原地以匹配我们的 P 矩阵
            path.append(current_node)
            continue
            
        # 检查图是否带权
        if nx.is_weighted(G):
            # 提取权重
            weights = [G[current_node][neighbor].get('weight', 1.0) for neighbor in neighbors]
            # 根据权重随机选择
            next_node = random.choices(neighbors, weights=weights, k=1)[0]
        else:
            # 无权图:均匀选择
            next_node = random.choice(neighbors)
            
        path.append(next_node)
        current_node = next_node
        
    return path

# --- 完整可执行示例 (1.3.1) ---
if __name__ == "__main__":
    print("--- 1.3.1 随机游走与马尔可夫链 ---")
    
    # 1. 创建一个带权有向图
    G_walk = nx.DiGraph()
    G_walk.add_edges_from([(1, 2, {'weight': 2.0}), (1, 3, {'weight': 1.0}),
                           (2, 3, {'weight': 1.0}), (3, 1, {'weight': 3.0}),
                           (3, 4, {'weight': 1.0}), (4, 4, {'weight': 1.0})]) # 4 是一个 sink/陷阱
    G_walk.add_node(5) # 5 是一个孤立节点

    # 2. 计算转移矩阵 P
    P, nodelist = get_transition_matrix(G_walk)
    node_map = {node: i for i, node in enumerate(nodelist)} # 节点到索引的映射
    
    print(f"节点列表 (P 的顺序): {nodelist}")
    print("转移矩阵 P (P_ij = P(i -> j)):")
    print(np.round(P, 3))
    
    # 验证 P[i,i] = 1 对于 sinks (节点4) 和 isolates (节点5)
    print(f"  P[4, 4] (Sink): {P[node_map[4], node_map[4]]}")
    print(f"  P[5, 5] (Isolate): {P[node_map[5], node_map[5]]}")
    # 验证 P[1,:] 的和
    print(f"  P[1, 2]: {P[node_map[1], node_map[2]]:.3f} (预期 2.0 / (2.0+1.0) = 0.667)")
    print(f"  P[1, 3]: {P[node_map[1], node_map[3]]:.3f} (预期 1.0 / (2.0+1.0) = 0.333)")

    # 3. 模拟一次随机游走
    start_node = 1
    steps = 10
    path = simulate_random_walk(G_walk, start_node, steps)
    print(f"\n从 {start_node} 开始 {steps} 步的随机游走:\n{' -> '.join(map(str, path))}")
    
    # 4. 验证 P^k
    # (P^k)[i, j] 是从 i 到 j 恰好 k 步的概率
    k = 3
    P_k = np.linalg.matrix_power(P, k)
    idx_start, idx_end = node_map[1], node_map[3]
    print(f"\n(P^{k})[1, 3] (3 步内 1 -> 3 的概率): {P_k[idx_start, idx_end]:.4f}")

    # 5. 可视化
    plt.figure(figsize=(10, 6))
    plt.title(f"1.3.1: 随机游走路径 (k={steps})")
    pos = nx.spring_layout(G_walk, seed=42)
    nx.draw(G_walk, pos, with_labels=True, node_color='lightblue', node_size=600,
            connectionstyle='arc3,rad=0.1')
    
    # 绘制游走路径
    path_edges = list(zip(path[:-1], path[1:]))
    nx.draw_networkx_edges(G_walk, pos, edgelist=path_edges, width=2.5, 
                           edge_color='red', arrowsize=20, 
                           connectionstyle='arc3,rad=0.1')
    plt.savefig("1.3.1_random_walk.png")
    # plt.show()

1.3.2 图生成模型与随机图谱模型(ER、BA、WS)

理论阐述:

深度实现:

networkx 提供了这些模型的生成器。本节的“深度”在于分析和可视化它们的关键区别——度分布,特别是 BA 模型的幂律特性(必须在log-log尺度上查看)。

代码实现:

def plot_degree_distribution(G, ax, title, log_log_scale=False):
    """
    在给定的 matplotlib 轴上绘制图 G 的度分布。
    """
    # 1. 获取度序列
    degrees = [d for n, d in G.degree()]
    if not degrees:
        return # 空图
        
    # 2. 计算度的频次 (count)
    degree_counts = Counter(degrees)
    k, count = zip(*sorted(degree_counts.items()))
    
    # 3. 归一化 (概率)
    N = G.number_of_nodes()
    probabilities = [c / N for c in count]
    
    # 4. 绘图
    if log_log_scale:
        # log-log 尺度,用于检查幂律
        ax.scatter(k, probabilities, alpha=0.7, s=20)
        ax.set_xscale('log')
        ax.set_yscale('log')
        ax.set_title(title + " (Log-Log 尺度)")
        ax.set_xlabel("Log(度 k)")
        ax.set_ylabel("Log(P(k))")
    else:
        # 线性尺度
        ax.bar(k, probabilities, width=0.8, alpha=0.7)
        ax.set_title(title + " (线性尺度)")
        ax.set_xlabel("度 k")
        ax.set_ylabel("P(k)")
    
    # 计算平均度
    avg_degree = np.mean(degrees)
    ax.axvline(avg_degree, color='r', linestyle='--', linewidth=2, 
               label=f'平均度 ≈ {avg_degree:.2f}')
    ax.legend(fontsize='small')

# --- 完整可执行示例 (1.3.2) ---
if __name__ == "__main__":
    print("\n--- 1.3.2 图生成模型 (ER, WS, BA) ---")
    
    # 1. 定义参数
    N = 1000 # 节点数
    AVG_DEGREE = 8 # 期望的平均度
    
    # 2. ER 模型: G(n, p)
    # E[k] = p * (N - 1) => p = E[k] / (N - 1)
    p_er = AVG_DEGREE / (N - 1)
    G_er = nx.erdos_renyi_graph(N, p_er, seed=42)
    
    # 3. WS 模型: G(n, k, p)
    # k 必须是偶数
    k_ws = AVG_DEGREE
    p_ws = 0.1 # 重连概率 (0.1 是典型值)
    G_ws = nx.watts_strogatz_graph(N, k=k_ws, p=p_ws, seed=42)
    
    # 4. BA 模型: G(n, m)
    # m = 新节点连接的边数
    # E[k] ≈ 2m => m = E[k] / 2
    m_ba = int(AVG_DEGREE / 2)
    G_ba = nx.barabasi_albert_graph(N, m=m_ba, seed=42)

    # 5. 可视化度分布 (关键对比)
    # 我们创建 2x3 的子图
    fig, axes = plt.subplots(2, 3, figsize=(18, 10))
    fig.suptitle(f"1.3.2: 三种图生成模型的度分布 (N={N})", fontsize=16)

    # 线性尺度 (第 1 行)
    plot_degree_distribution(G_er, axes[0, 0], f"ER (p={p_er:.3f})", log_log_scale=False)
    plot_degree_distribution(G_ws, axes[0, 1], f"WS (k={k_ws}, p={p_ws})", log_log_scale=False)
    plot_degree_distribution(G_ba, axes[0, 2], f"BA (m={m_ba})", log_log_scale=False)
    
    # Log-Log 尺度 (第 2 行)
    plot_degree_distribution(G_er, axes[1, 0], f"ER (p={p_er:.3f})", log_log_scale=True)
    plot_degree_distribution(G_ws, axes[1, 1], f"WS (k={k_ws}, p={p_ws})", log_log_scale=True)
    plot_degree_distribution(G_ba, axes[1, 2], f"BA (m={m_ba})", log_log_scale=True)
    
    plt.tight_layout(rect=[0, 0.03, 1, 0.95])
    plt.savefig("1.3.2_model_distributions.png")
    # plt.show()
    
    print("  [观察] ER 呈泊松分布 (钟形)。")
    print("  [观察] WS 呈尖峰分布 (更规则)。")
    print("  [观察] BA 在 Log-Log 尺度上呈一条直线,这正是'幂律'分布的特征。")

1.3.3 图的稳态分布与吸收概率

1.3.3.A 稳态分布 (Stationary Distribution)

Python

def get_stationary_distribution_fast(G):
    """
    (优化) 仅适用于 *无向* 且 *连通* 的图。
    π_i = deg(i) / (2|E|)
    """
    if not G.is_directed() and nx.is_connected(G):
        N = G.number_of_nodes()
        if N == 0:
            return {}, []
        
        # 1. 获取度 (带权)
        # G.degree(weight='weight') 返回一个迭代器
        degrees = dict(G.degree(weight='weight'))
        nodelist = list(degrees.keys())
        degree_vec = np.array([degrees[n] for n in nodelist])
        
        # 2. 计算 2|E| (或 sum(deg))
        total_degree = np.sum(degree_vec)
        
        if total_degree == 0:
            # 图中没有边
            pi_vec = np.ones(N) / N
        else:
            pi_vec = degree_vec / total_degree
            
        return {nodelist[i]: pi_vec[i] for i in range(N)}, nodelist
    else:
        raise ValueError("快速方法仅适用于无向连通图。请使用迭代法。")

def get_stationary_distribution_iterative(P, nodelist, max_iter=1000, tol=1e-6):
    """
    (通用) 使用幂迭代法 (Power Iteration) 计算稳态分布。
    π_k+1 = π_k * P
    """
    N = P.shape[0]
    if N == 0:
        return {}, []
        
    # 1. 从一个均匀分布开始
    pi_vec = np.ones(N) / N
    
    for i in range(max_iter):
        pi_vec_new = pi_vec @ P
        
        # 检查收敛 (L1 范数)
        diff = np.linalg.norm(pi_vec_new - pi_vec, ord=1)
        if diff < tol:
            # print(f"  在 {i+1} 次迭代后收敛。")
            pi_vec = pi_vec_new
            break
        pi_vec = pi_vec_new
    
    # 归一化 (确保和为 1)
    pi_vec = pi_vec / np.sum(pi_vec)
    return {nodelist[i]: pi_vec[i] for i in range(N)}, nodelist

# --- 完整可执行示例 (1.3.3.A) ---
if __name__ == "__main__":
    print("\n--- 1.3.3.A 稳态分布 ---")
    
    # 1. 使用 Zachary's Karate Club (无向, 连通)
    G_karate = nx.karate_club_graph()
    
    # 2. 方法 1: 快速 (无向)
    pi_fast, nodelist_fast = get_stationary_distribution_fast(G_karate)
    print(f"节点列表 (Fast): {nodelist_fast[:5]}...")
    print(f"  π(0) [Fast]: {pi_fast[0]:.4f} (节点0度数高)")
    print(f"  π(16) [Fast]: {pi_fast[16]:.4f} (节点16度数低)")

    # 3. 方法 2: 通用 (迭代)
    P_karate, nodelist_iter = get_transition_matrix(G_karate)
    pi_iter, _ = get_stationary_distribution_iterative(P_karate, nodelist_iter)
    
    print(f"\n节点列表 (Iter): {nodelist_iter[:5]}...")
    print(f"  π(0) [Iter]: {pi_iter[0]:.4f}")
    print(f"  π(16) [Iter]: {pi_iter[16]:.4f}")
    
    # 4. 验证两者是否一致
    assert np.allclose(pi_fast[0], pi_iter[0])
    print("\n  (验证成功:两种方法结果一致)")

1.3.3.B 吸收概率 (Absorption Probabilities)

def get_absorption_probabilities(G, absorbing_nodes):
    """
    计算从所有瞬态节点到所有吸收态节点的吸收概率。
    
    参数:
    G (nx.Graph): 图
    absorbing_nodes (list): 吸收态节点的列表
    
    返回:
    B (np.ndarray): 吸收概率矩阵 (t x r)
    t_map (list): 瞬态节点列表 (B 的行索引)
    a_map (list): 吸收态节点列表 (B 的列索引)
    """
    nodelist = list(G.nodes())
    node_to_idx = {node: i for i, node in enumerate(nodelist)}
    
    # 1. 识别瞬态 (t) 和吸收态 (r)
    a_map = sorted(list(absorbing_nodes)) # 吸收态 (r)
    t_map = sorted([n for n in nodelist if n not in absorbing_nodes]) # 瞬态 (t)
    
    t = len(t_map) # 瞬态数量
    r = len(a_map) # 吸收态数量
    
    if t == 0 or r == 0:
        print("  警告:图中没有瞬态或吸收态。")
        return np.array([]), t_map, a_map
        
    # 2. 获取 *标准* 转移矩阵 P
    P, _ = get_transition_matrix(G)
    
    # 3. 挑战:重构 P,使吸收态 *真正* 吸收 (P_ii = 1)
    for a_node in a_map:
        a_idx = node_to_idx[a_node]
        P[a_idx, :] = 0.0 # 清空所有出边
        P[a_idx, a_idx] = 1.0 # 设置自环为 1
        
    # 4. 构建 Q 和 R
    Q = np.zeros((t, t))
    R = np.zeros((t, r))
    
    t_node_to_idx = {node: i for i, node in enumerate(t_map)}
    a_node_to_idx = {node: i for i, node in enumerate(a_map)}
    
    for t_node in t_map:
        t_idx_local = t_node_to_idx[t_node] # Q/R 中的行索引 (0..t-1)
        t_idx_global = node_to_idx[t_node]  # P 中的行索引 (0..N-1)
        
        # 填充 Q (瞬态 -> 瞬态)
        for t_node_j in t_map:
            t_idx_j_local = t_node_to_idx[t_node_j]
            t_idx_j_global = node_to_idx[t_node_j]
            Q[t_idx_local, t_idx_j_local] = P[t_idx_global, t_idx_j_global]
            
        # 填充 R (瞬态 -> 吸收态)
        for a_node_k in a_map:
            a_idx_k_local = a_node_to_idx[a_node_k]
            a_idx_k_global = node_to_idx[a_node_k]
            R[t_idx_local, a_idx_k_local] = P[t_idx_global, a_idx_k_global]
            
    # 5. 计算 N = (I - Q)^-1
    try:
        I_t = np.identity(t)
        N_matrix = np.linalg.inv(I_t - Q)
    except np.linalg.LinAlgError:
        print("  错误:(I - Q) 矩阵是奇异的,无法计算吸收概率。")
        return None, t_map, a_map
        
    # 6. 计算 B = N * R
    B = N_matrix @ R
    
    return B, t_map, a_map

# --- 完整可执行示例 (1.3.3.B) ---
if __name__ == "__main__":
    print("\n--- 1.3.3.B 吸收概率 ---")
    
    # 1. 创建 "赌徒破产" 图 (1D 随机游走)
    # 0 -- 1 -- 2 -- 3 -- 4
    # 0 = 破产 (吸收), 4 = 赢 (吸收)
    G_gambler = nx.path_graph(5) 
    
    absorbing_states = [0, 4]
    
    # 2. 计算吸收概率
    B, t_nodes, a_nodes = get_absorption_probabilities(G_gambler, absorbing_states)
    
    print(f"吸收态 (B 的列): {a_nodes}")
    print(f"瞬态 (B 的行): {t_nodes}")
    print("吸收概率矩阵 B (行=瞬态, 列=吸收态):")
    print(np.round(B, 3))
    
    # 3. 解读
    # B[1, 0] = P(从 2 开始, 在 0 结束) (t_nodes[1]=2, a_nodes[0]=0)
    idx_start_2 = t_nodes.index(2) # B 中的行索引
    idx_end_0 = a_nodes.index(0)   # B 中的列索引
    idx_end_4 = a_nodes.index(4)   # B 中的列索引
    
    print(f"\n  P(从 1 开始, 在 0 结束): {B[t_nodes.index(1), idx_end_0]:.2f} (预期 0.75)")
    print(f"  P(从 2 开始, 在 0 结束): {B[idx_start_2, idx_end_0]:.2f} (预期 0.5)")
    print(f"  P(从 2 开始, 在 4 结束): {B[idx_start_2, idx_end_4]:.2f} (预期 0.5)")

1.3.4 基于随机过程的结构相似性度量

def get_rwr_similarity_vector(G, source_node, restart_prob=0.15):
    """
    计算从 source_node 开始的随机游走与重启 (RWR) 的稳态分布。
    
    参数:
    G: 图
    source_node: 源节点 (重启向量将集中在此)
    restart_prob (float): 重启概率 (RWR 定义中的 α)
    
    返回:
    rwr_scores (dict): {node: score} 的字典
    """
    # 1. 关键:转换 alpha
    # nx.pagerank 的 alpha 是 "damping factor" (继续游走的概率)
    nx_alpha = 1.0 - restart_prob
    
    # 2. 创建个性化向量 (重启向量)
    personalization = {node: 0 for node in G.nodes()}
    personalization[source_node] = 1.0
    
    # 3. 计算 PageRank
    rwr_scores = nx.pagerank(G, 
                             alpha=nx_alpha, 
                             personalization=personalization,
                             weight='weight')
    
    return rwr_scores

def get_symmetric_rwr_similarity(G, u, v, restart_prob=0.15):
    """
    计算 u 和 v 之间对称的 RWR 相似性。
    Sim(u, v) = π_u(v) + π_v(u)
    """
    # 1. 计算 π_u
    pi_u = get_rwr_similarity_vector(G, u, restart_prob)
    sim_u_v = pi_u.get(v, 0) # π_u(v)
    
    # 2. 计算 π_v
    pi_v = get_rwr_similarity_vector(G, v, restart_prob)
    sim_v_u = pi_v.get(u, 0) # π_v(u)
    
    # 3. 对称相似性
    return sim_u_v + sim_v_u

# --- 完整可执行示例 (1.3.4) ---
if __name__ == "__main__":
    print("\n--- 1.3.4 基于 RWR 的结构相似性 ---")
    
    # 1. 使用 Karate Club 图
    G_karate = nx.karate_club_graph()
    
    # 2. 选取比较对
    node_a = 0  # 社区 0 的核心 (教练)
    node_b = 2  # 社区 0 的成员
    node_c = 33 # 社区 1 的核心 (管理员)
    node_d = 32 # 社区 1 的成员
    
    restart_prob = 0.15 # 重启概率
    
    # 3. 比较
    # 比较 A 和 B (同一社区)
    sim_ab = get_symmetric_rwr_similarity(G_karate, node_a, node_b, restart_prob)
    
    # 比较 A 和 D (不同社区)
    sim_ad = get_symmetric_rwr_similarity(G_karate, node_a, node_d, restart_prob)
    
    # 比较 A 和 C (不同社区的核心)
    sim_ac = get_symmetric_rwr_similarity(G_karate, node_a, node_c, restart_prob)
    
    print(f"RWR 重启概率: {restart_prob}")
    print(f"  Sim({node_a}, {node_b}) (同社区): {sim_ab:.6f}")
    print(f"  Sim({node_a}, {node_d}) (跨社区): {sim_ad:.6f}")
    print(f"  Sim({node_a}, {node_c}) (跨社区, 核心): {sim_ac:.6f}")
    
    assert sim_ab > sim_ad
    print("\n  (验证成功:同社区的 RWR 相似性 > 跨社区的 RWR 相似性)")

    # 4. 可视化:π_0 (从节点 0 出发的 RWR 向量)
    pi_0 = get_rwr_similarity_vector(G_karate, node_a, restart_prob)
    
    # 将 RWR 分数转换为颜色列表
    color_map = [pi_0.get(node, 0) for node in G_karate.nodes()]
    
    plt.figure(figsize=(10, 7))
    plt.title(f"1.3.4: 从节点 {node_a} (教练) 出发的 RWR 相似性")
    pos_karate = nx.spring_layout(G_karate, seed=42)
    
    # 绘制
    nodes = nx.draw_networkx_nodes(G_karate, pos_karate, node_color=color_map, 
                                   cmap=plt.cm.viridis, node_size=500)
    nx.draw_networkx_edges(G_karate, pos_karate, alpha=0.3)
    nx.draw_networkx_labels(G_karate, pos_karate, font_color='white')
    
    # 添加颜色条
    plt.colorbar(nodes, label="RWR 相似性 (π_0(v))")
    plt.savefig("1.3.4_rwr_similarity.png")
    # plt.show()
    
    print("\n  [观察] 可视化显示,从节点 0 出发的 RWR 分数(颜色亮度)")
    print("    ... 在其自己的社区(左侧)中普遍较高,")
    print("    ... 在另一个社区(右侧)中则低得多。")

准备工作:安装依赖

本节中的代码需要 scikit-learn (用于标准分类/回归指标) 和 torch_geometric (用于加载数据集)。

# 安装 scikit-learn
pip install scikit-learn

# --- 安装 PyTorch Geometric ---
# 1. 首先,确保你已安装 PyTorch (https://pytorch.org/)
# pip install torch

# 2. 然后,安装 PyTorch Geometric 及其依赖
# (这会根据你的 PyTorch 和 CUDA 版本自动选择正确的包)
pip install torch-geometric
pip install torch-scatter torch-sparse -f https://data.pyg.org/whl/torch-$(torch.__version__).split('+')[0].html

Python

# --- 核心导入 ---
import numpy as np
from sklearn.metrics import (
    accuracy_score, 
    f1_score, 
    roc_auc_score, 
    average_precision_score
)
import networkx as nx
import matplotlib.pyplot as plt

# PyTorch Geometric (PyG)
try:
    import torch
    from torch_geometric.datasets import Planetoid, TUDataset
    from torch_geometric.utils import to_networkx
    PYG_AVAILABLE = True
    print("PyTorch Geometric 已成功导入。")
except ImportError:
    PYG_AVAILABLE = False
    print("警告:未找到 PyTorch Geometric。1.4.4 节将无法运行。")
    print("请按照教程顶部的指南安装 torch 和 torch_geometric。")

# 设置 Numpy 打印精度
np.set_printoptions(precision=4)

1.4.1 节点分类、图分类、链路预测指标定义

这些任务本质上是机器学习中的分类任务,因此我们可以复用 scikit-learn 中的标准指标。

1.4.1.A: 节点/图分类 (Accuracy, F1-Score)

  • Accuracy (准确率): 最直观的指标,即“预测正确的样本数 / 总样本数”。

  • F1-Score (F1分数): Precision (精确率) 和 Recall (召回率) 的调和平均数。它在数据不平衡时比 Accuracy 更具参考价值。

    • macro (宏平均): 计算每个类别的 F1,然后取算术平均。平等对待所有类别。

    • weighted (加权平均): 按每个类别的样本数(support)对 F1 进行加权平均。

Python

def evaluate_classification_metrics(y_true, y_pred_labels):
    """
    计算并打印多分类任务的核心指标。
    
    参数:
    y_true (array-like): 真实的标签 (N,)
    y_pred_labels (array-like): 模型预测的标签 (N,)
    """
    print(f"  y_true: {y_true}")
    print(f"  y_pred: {y_pred_labels}")
    
    # 1. 准确率 (Accuracy)
    acc = accuracy_score(y_true, y_pred_labels)
    print(f"  Accuracy: {acc:.4f}")
    
    # 2. F1-Score (Macro)
    # 宏平均:不考虑样本不平衡,平等对待所有类别
    f1_macro = f1_score(y_true, y_pred_labels, average='macro', zero_division=0)
    print(f"  F1-Score (Macro): {f1_macro:.4f}")
    
    # 3. F1-Score (Weighted)
    # 加权平均:考虑样本不平衡,按类别支持度加权
    f1_weighted = f1_score(y_true, y_pred_labels, average='weighted', zero_division=0)
    print(f"  F1-Score (Weighted): {f1_weighted:.4f}")

# --- 完整可执行示例 (1.4.1.A) ---
if __name__ == "__main__":
    print("\n--- 1.4.1.A: 节点/图分类指标 ---")
    
    # 假设我们有 10 个节点/图的预测结果
    # 类别 0: 5个, 类别 1: 3个, 类别 2: 2个 (数据不平衡)
    y_true_labels = np.array([0, 0, 0, 0, 0, 1, 1, 1, 2, 2])
    
    # 模型的预测
    y_pred_labels = np.array([0, 0, 1, 0, 0, 1, 1, 2, 2, 2])
    
    evaluate_classification_metrics(y_true_labels, y_pred_labels)

1.4.1.B: 链路预测 (AUC, AP)

def evaluate_link_prediction_metrics(y_true_links, y_pred_scores):
    """
    计算链路预测任务的核心指标。
    
    参数:
    y_true_links (array-like): 真实的边是否存在 (N_pairs,),1=存在, 0=不存在
    y_pred_scores (array-like): 模型预测的边存在的 *概率* (N_pairs,)
    """
    print(f"  y_true (前10): {y_true_links[:10]}")
    print(f"  y_scores (前10): {y_pred_scores[:10]}")
    
    # 1. ROC AUC
    auc_score = roc_auc_score(y_true_links, y_pred_scores)
    print(f"  ROC AUC Score: {auc_score:.4f}")
    
    # 2. Average Precision (AP)
    ap_score = average_precision_score(y_true_links, y_pred_scores)
    print(f"  Average Precision (AP): {ap_score:.4f}")

# --- 完整可执行示例 (1.4.1.B) ---
if __name__ == "__main__":
    print("\n--- 1.4.1.B: 链路预测指标 ---")
    
    # 假设我们测试了 1000 对节点
    # 只有 50 条是真实存在的边 (正样本)
    y_true_links = np.zeros(1000)
    y_true_links[:50] = 1.0
    np.random.shuffle(y_true_links) # 打乱
    
    # 一个 "还不错" 的模型给出的预测分数 (概率)
    # 它给正样本的分数普遍偏高 (0.7 ± 0.2)
    # 它给负样本的分数普遍偏低 (0.3 ± 0.2)
    positive_scores = np.random.normal(0.7, 0.2, 50)
    negative_scores = np.random.normal(0.3, 0.2, 950)
    
    # 组合分数 (必须与 y_true_links 的顺序一致)
    y_pred_scores = np.zeros(1000)
    y_pred_scores[y_true_links == 1] = positive_scores
    y_pred_scores[y_true_links == 0] = negative_scores
    # 确保分数在 [0, 1] 区间
    y_pred_scores = np.clip(y_pred_scores, 0, 1)

    evaluate_link_prediction_metrics(y_true_links, y_pred_scores)

1.4.2 排序与检索指标 (MRR, Recall@K, Precision@K)

def precision_at_k(ranked_list, ground_truth, k):
    """
    计算 Precision@K (P@K)
    
    参数:
    ranked_list (list): 模型排序的推荐列表 [item1, item2, ...]
    ground_truth (set): 真实的答案集合 {item_A, item_B, ...}
    k (int): 评估的排名阈值
    """
    # 截断前 k 个
    top_k_items = ranked_list[:k]
    # 计算交集
    relevant_items_in_top_k = [item for item in top_k_items if item in ground_truth]
    # P@K
    return len(relevant_items_in_top_k) / k

def recall_at_k(ranked_list, ground_truth, k):
    """
    计算 Recall@K (R@K)
    """
    if not ground_truth: # 避免除零
        return 0.0
        
    top_k_items = ranked_list[:k]
    relevant_items_in_top_k = [item for item in top_k_items if item in ground_truth]
    # R@K
    return len(relevant_items_in_top_k) / len(ground_truth)

def reciprocal_rank(ranked_list, ground_truth):
    """
    计算单个查询的 Reciprocal Rank (RR)
    """
    for rank, item in enumerate(ranked_list, 1): # 排名从 1 开始
        if item in ground_truth:
            return 1.0 / rank
    return 0.0 # 没有找到

def mean_reciprocal_rank(all_ranked_lists, all_ground_truths):
    """
    计算 Mean Reciprocal Rank (MRR)
    """
    total_rr = 0.0
    query_count = len(all_ranked_lists)
    if query_count == 0:
        return 0.0
        
    for ranked_list, ground_truth in zip(all_ranked_lists, all_ground_truths):
        total_rr += reciprocal_rank(ranked_list, ground_truth)
        
    return total_rr / query_count

# --- 完整可执行示例 (1.4.2) ---
if __name__ == "__main__":
    print("\n--- 1.4.2: 排序与检索指标 ---")
    
    # 假设我们对 3 个用户 (查询) 进行了推荐
    K = 3 # 我们关心 Top-3 结果
    
    # --- 查询 1 ---
    query_1_ranked = ['A', 'B', 'C', 'D', 'E'] # 模型的推荐
    query_1_truth = {'B', 'D'}                 # 用户的真实喜好
    
    # --- 查询 2 ---
    query_2_ranked = ['X', 'Y', 'Z', 'A']
    query_2_truth = {'A'}
    
    # --- 查询 3 ---
    query_3_ranked = ['M', 'N', 'O']
    query_3_truth = {'P', 'Q'} # 模型完全没猜中
    
    # 评估 P@K 和 R@K (通常也是取平均)
    p_at_3_q1 = precision_at_k(query_1_ranked, query_1_truth, K)
    r_at_3_q1 = recall_at_k(query_1_ranked, query_1_truth, K)
    print(f"查询 1 (Truth={'B','D'}, K={K}):")
    print(f"  P@{K}: {p_at_3_q1:.4f} (Top 3 'A,B,C' 中 'B' 命中, 1/3)")
    print(f"  R@{K}: {r_at_3_q1:.4f} (Top 3 'A,B,C' 中 'B' 命中, 总共 2 个, 1/2)")
    
    p_at_3_q2 = precision_at_k(query_2_ranked, query_2_truth, K)
    r_at_3_q2 = recall_at_k(query_2_ranked, query_2_truth, K)
    print(f"\n查询 2 (Truth={'A'}, K={K}):")
    print(f"  P@{K}: {p_at_3_q2:.4f} (Top 3 'X,Y,Z' 均未命中, 0/3)")
    print(f"  R@{K}: {r_at_3_q2:.4f} (Top 3 'X,Y,Z' 均未命中, 0/1)")
    
    # 评估 MRR
    all_ranks = [query_1_ranked, query_2_ranked, query_3_ranked]
    all_truths = [query_1_truth, query_2_truth, query_3_truth]
    
    rr_q1 = reciprocal_rank(query_1_ranked, query_1_truth)
    rr_q2 = reciprocal_rank(query_2_ranked, query_2_truth)
    rr_q3 = reciprocal_rank(query_3_ranked, query_3_truth)
    print("\nMRR 计算:")
    print(f"  RR (查询 1): {rr_q1:.4f} (第一个命中 'B' 在第 2 位, 1/2)")
    print(f"  RR (查询 2): {rr_q2:.4f} (第一个命中 'A' 在第 4 位, 1/4)")
    print(f"  RR (查询 3): {rr_q3:.4f} (未命中, 0.0)")
    
    mrr_score = mean_reciprocal_rank(all_ranks, all_truths)
    print(f"  MRR (平均): {mrr_score:.4f} ( (0.5 + 0.25 + 0.0) / 3 )")

1.4.3 生成任务评价 (Validity, Novelty, Uniqueness)

def get_graph_hash(G):
    """
    (优化) 使用 Weisfeiler-Lehman 哈希计算图的唯一签名。
    这比 nx.is_isomorphic() 快得多。
    """
    # edge_attr=None 意味着哈希只基于图的结构
    return nx.weisfeiler_lehman_graph_hash(G, edge_attr=None, node_attr=None)

def check_validity(graph_list, rule_func):
    """
    计算有效性。
    rule_func 是一个接受 G 并返回 True/False 的函数。
    """
    valid_count = sum(1 for G in graph_list if rule_func(G))
    return valid_count / len(graph_list) if graph_list else 0.0

def check_uniqueness(graph_list, hash_func=get_graph_hash):
    """
    计算独特性 (基于图哈希)。
    """
    if not graph_list:
        return 0.0
    hashes = [hash_func(G) for G in graph_list]
    unique_hashes = set(hashes)
    return len(unique_hashes) / len(hashes)

def check_novelty(generated_list, training_list, hash_func=get_graph_hash):
    """
    计算新颖性 (基于图哈希)。
    """
    if not generated_list:
        return 0.0
    
    gen_hashes = set([hash_func(G) for G in generated_list])
    train_hashes = set([hash_func(G) for G in training_list])
    
    # 计算不在训练集中的哈希数量
    novel_hashes = gen_hashes - train_hashes
    
    return len(novel_hashes) / len(gen_hashes)

# --- 完整可执行示例 (1.4.3) ---
if __name__ == "__main__":
    print("\n--- 1.4.3: 图生成任务指标 ---")
    
    # 1. 创建 "训练集" (2个图)
    G_train_1 = nx.path_graph(4) # 路径图
    G_train_2 = nx.cycle_graph(5) # 环形图
    training_set = [G_train_1, G_train_2]
    
    # 2. 创建 "模型生成集" (5个图)
    G_gen_1 = nx.path_graph(4)      # (与训练集相同)
    G_gen_2 = nx.star_graph(3)      # (新颖)
    G_gen_3 = nx.star_graph(3)      # (重复生成)
    G_gen_4 = nx.cycle_graph(6)     # (新颖)
    G_gen_5 = nx.Graph()            # (无效图)
    G_gen_5.add_nodes_from([1, 2, 3])
    
    generated_set = [G_gen_1, G_gen_2, G_gen_3, G_gen_4, G_gen_5]

    # 3. 评估 Validity
    # 规则:图必须是连通的
    validity_rule = nx.is_connected
    validity_score = check_validity(generated_set, validity_rule)
    print(f"  Validity (是否连通): {validity_score:.4f} (4/5)")
    
    # 4. 评估 Uniqueness (在生成的 5 个图中)
    # G_gen_2 和 G_gen_3 是同构的 (哈希相同)
    uniqueness_score = check_uniqueness(generated_set)
    print(f"  Uniqueness (内部独特性): {uniqueness_score:.4f} (4/5)")
    
    # 5. 评估 Novelty (与训练集比较)
    # G_gen_1 (path_graph(4)) 在训练集中
    # G_gen_2/3 (star_graph(3)) 不在
    # G_gen_4 (cycle_graph(6)) 不在
    # G_gen_5 (3个孤立点) 不在
    # 独特的生成图哈希有 4 个,其中 3 个是新颖的
    novelty_score = check_novelty(generated_set, training_set)
    print(f"  Novelty (新颖性): {novelty_score:.4f} (3/4)")

1.4.4 常用基准数据集清单与元数据说明

我们将使用 PyTorch Geometric (PyG) 来加载标准数据集。

  • Planetoid (Cora, CiteSeer, PubMed): 节点分类 (Node Classification) 的基准。

    • Cora: 论文引用网络。

      • 任务: 预测论文的学科类别(7类)。

      • 节点: 论文 (2708)。

      • 边: 引用关系 (5429)。

      • 节点特征: 1433 维的 0/1 词袋 (BoW) 向量。

  • TUDataset (MUTAG, ENZYMES, PROTEINS): 图分类 (Graph Classification) 的基准。

    • MUTAG: 188 个分子图。

      • 任务: 预测分子是否具有诱变性(2类)。

      • 节点: 原子(7种原子类型)。

      • 图标签: 0 或 1。

def inspect_pyg_node_dataset(dataset_name):
    """
    加载并检查一个 PyG 节点分类数据集 (例如 Cora)。
    """
    print(f"\n--- 正在加载 {dataset_name} (节点分类) ---")
    try:
        # root='.' 表示下载到当前目录下的 'data' 文件夹
        dataset = Planetoid(root='./data', name=dataset_name)
    except Exception as e:
        print(f"  错误:无法加载 {dataset_name}。{e}")
        print("  请确保 PyG 已正确安装。")
        return None
    
    # PyG 中,Cora, CiteSeer, PubMed 只有一个图
    data = dataset[0] 
    
    print(f"数据集: {dataset.name}")
    print(f"  图的数量: {len(dataset)}")
    print(f"  节点总数 (data.num_nodes): {data.num_nodes}")
    print(f"  边总数 (data.num_edges): {data.num_edges}")
    print(f"  节点特征维度 (data.num_node_features): {dataset.num_node_features}")
    print(f"  类别数 (data.num_classes): {dataset.num_classes}")
    
    print("\n  关键属性 (Data 对象):")
    print(f"  data.x (节点特征): {data.x.shape}")
    print(f"  data.y (节点标签): {data.y.shape}")
    print(f"  data.edge_index (边列表, COO 格式): {data.edge_index.shape}")
    print(f"  data.train_mask (训练掩码): {data.train_mask.sum()} 个节点")
    print(f"  data.val_mask (验证掩码): {data.val_mask.sum()} 个节点")
    print(f"  data.test_mask (测试掩码): {data.test_mask.sum()} 个节点")
    
    return dataset

def inspect_pyg_graph_dataset(dataset_name):
    """
    加载并检查一个 PyG 图分类数据集 (例如 MUTAG)。
    """
    print(f"\n--- 正在加载 {dataset_name} (图分类) ---")
    try:
        dataset = TUDataset(root='./data', name=dataset_name, use_node_attr=True)
    except Exception as e:
        print(f"  错误:无法加载 {dataset_name}。{e}")
        print("  请确保 PyG 已正确安装。")
        return None

    print(f"数据集: {dataset.name}")
    print(f"  图的数量: {len(dataset)}")
    print(f"  类别数 (data.num_classes): {dataset.num_classes}")
    print(f"  节点特征维度 (data.num_node_features): {dataset.num_node_features}")
    
    # 检查第 1 个图
    data_0 = dataset[0]
    print("\n  示例:第 0 个图 (data_0):")
    print(f"  data_0.x (节点特征): {data_0.x.shape}")
    print(f"  data_0.y (图的标签): {data_0.y}")
    print(f"  data_0.edge_index (边列表): {data_0.edge_index.shape}")
    
    return dataset

def visualize_pyg_graph(data, title="PyG Graph"):
    """
    (附加功能) 将一个 PyG Data 对象转换为 NetworkX 并可视化。
    """
    # G = to_networkx(data, to_undirected=True)
    # to_networkx 在新版 PyG 中可能需要 node_attrs 和 edge_attrs
    node_attrs = ['x', 'y'] if data.x is not None else []
    G = to_networkx(data, node_attrs=node_attrs, to_undirected=True)

    plt.figure(figsize=(8, 6))
    plt.title(title)
    pos = nx.spring_layout(G, seed=42)
    nx.draw(G, pos, with_labels=True, node_color='lightblue', 
            node_size=300, font_size=10)
    plt.savefig(f"{title.replace(' ', '_')}.png")
    # plt.show()
    print(f"\n  已保存图的可视化: '{title.replace(' ', '_')}.png'")

# --- 完整可执行示例 (1.4.4) ---
if __name__ == "__main__":
    print("\n--- 1.4.4: 常用基准数据集 ---")
    
    if PYG_AVAILABLE:
        # 1. 节点分类: Cora
        cora_dataset = inspect_pyg_node_dataset("Cora")
        
        # 2. 图分类: MUTAG
        mutag_dataset = inspect_pyg_graph_dataset("MUTAG")
        
        # 3. 可视化 MUTAG 的第 0 个图
        if mutag_dataset:
            visualize_pyg_graph(mutag_dataset[0], title="MUTAG Graph 0")
            
        # 4. 可视化 Cora (的子图,Cora太大了)
        if cora_dataset:
            # 创建 Cora 的一个 K-Hop 子图进行可视化
            data_cora = cora_dataset[0]
            # 获取节点 0 的 2 跳邻居子图
            subgraph_nodes = nx.ego_graph(
                to_networkx(data_cora, to_undirected=True), n=0, radius=2
            ).nodes()
            
            # data.subgraph() 在新版 PyG 中不推荐
            # k_hop_subgraph 更高效
            from torch_geometric.utils import k_hop_subgraph
            subset, sub_edge_index, mapping, edge_mask = k_hop_subgraph(
                node_idx=0, num_hops=2, edge_index=data_cora.edge_index, 
                relabel_nodes=True
            )
            
            sub_data = data_cora.clone()
            sub_data.x = data_cora.x[subset]
            sub_data.y = data_cora.y[subset]
            sub_data.edge_index = sub_edge_index
            
            visualize_pyg_graph(sub_data, title="Cora Subgraph (2-hop from node 0)")
            
    else:
        print("\n[跳过 1.4.4] PyTorch Geometric 未安装,无法加载数据集。")
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值