为了账号安全,请及时绑定邮箱和手机立即绑定

图神经网络入门:用PyTorch实现图形数据处理与回归分析

了解图神经网络的数学背景以及如何在PyTorch中实现回归问题的方法
引言

相互连接的图形数据无处不在,从分子结构到社交网络,再到城市的设计。图神经网络(GNNs)正逐渐成为一种强大的工具,用来建模和学习此类数据的空间和图形结构。它已被应用于蛋白质结构和其他分子应用,如药物发现,在社交网络等系统建模上也有所应用。最近,标准的GNN与其它机器学习模型的思想结合,开发了一些令人兴奋的创新应用。其中一项发展是将GNN与序列模型相结合——即时空GNN,能够捕捉数据的时间和空间依赖性(因此得名)。这本身可以用于解决工业和研究中的许多问题。

尽管在GNN领域取得了令人兴奋的发展,但关于这一主题的资源仍然非常稀少,这使得很多人难以接触。在这篇简短的文章中,我想提供一个对GNN的简要介绍,包括数学描述和使用pytorch库解决的回归问题。通过揭开GNN背后的原理,我们能够更深入地理解其能力和应用。

图神经网络的数学描述

图 G 可以定义为 G = (V, E),其中 V 是节点的集合,E 是节点之间的边。图通常通过一个邻接矩阵 A 来表示,该矩阵表示节点之间是否存在边,即 aij 的值为 1 表示在节点 i 和 j 之间有一条边(连接),否则为 0,表示 i 和 j 之间没有连接。如果一个图有 n 个节点,A 的维度为 (n × n)。邻接矩阵如图 1 所示。

图1. 三个不同图的邻接矩阵图

或更简洁地:

图1. 三个图的邻接矩阵图

每个节点(以及边,但我们将稍后再讨论边)将具有一组特征(例如,如果这个节点代表一个人,它的特征可能包括年龄、性别、身高和职业等)。如果每个节点有 f 个特征,那么特征矩阵 X 就是 (n × f) 的形式。在某些情况下,每个节点可能还带有一个目标标签,该标签可能是类别标签的集合或数值(如图 2 所示)。

单节点的计算

为了了解任意节点与其邻居之间的相互依存关系,我们需要考虑邻居们的特点。这能让GNN通过图来学习数据结构。假设一个节点j有Nj个邻居,GNN会从每个邻居那里转换特征,聚合这些特征并更新节点i的特征空间。这些步骤分别如下。

图2展示了具有特征xj和标签yj的节点j及其邻居节点(i, 2, 3),每个邻居节点也有它们自己的特征嵌入和对应的标签。

邻域特征变换可以通过多种方法实现,例如通过MLP网络进行或如线性变换。

其中 w 和 b 分别表示变换的权重和偏置。信息聚合过程:然后,来自每个相邻节点的信息会被聚合。

聚合步骤可以采用多种不同方法,例如求和、平均、最大值/最小值池化和拼接。

在完成聚合步骤之后,接下来要做的就是给名为节点 j 的节点更新。

这个更新可以通过使用MLP来完成,该MLP结合了节点特征和邻居信息聚合(mj)的过程,或者我们也可以选择线性变换。

其中U是一个可学习的权重矩阵,该矩阵通过非线性激活函数(例如ReLU)将原始节点特征(xj)与聚合的邻居特征(mj)结合起来。这就是更新单层中单个节点的过程,同样的过程会应用于图中的所有其他节点,从数学的角度来看,这可以用邻接矩阵来表示。

图计算

对于一个有 n 个节点的图,每个节点有 f 个特征,我们可以把这些特征拼接成一个矩阵。

邻近特征变换和聚合步骤可以表示为:

当 I 是单位矩阵时,这有助于包含每个节点自身的特征,否则,我们只考虑来自节点 j 的邻居节点转换后的特征,而没有考虑它自身的特征。最后一步是根据每个节点的连接数进行归一化,即对于具有 Nj 个连接的节点 j,特征转换可以如下进行:

方程可以调整如下:

其中 D 是度矩阵,也就是每个节点连接数量对应的对角矩阵。然而,通常这样做规范化步骤。

这是图卷积网络(GCN)方法,它使GNN能够学习节点的结构和它们之间的关系。然而,GCN的一个问题是邻居特征转换的权重向量在所有邻居之间共享,即所有邻居被视为等同,但这通常不是实际情况,因此不能很好地代表真实世界的系统。我们可以使用图注意力网络(GATs)来解决这个问题,计算邻居特征对于目标节点的重要性,从而在目标节点的特征更新中根据相关性以不同的方式做出贡献。注意力系数是通过一个可学习的矩阵来确定的,如下所示:

其中 W 是可学习的特征变换矩阵,Wa 是可学习的权重向量,eij 是节点 i 特征对节点 j 的原始注意力得分,表示其重要性。因此,注意力得分通过 SoftMax 函数进行标准化。

现在可以利用注意力系数来计算特征聚合。

单层的情况就是这样,我们可以构建多层以增加模型的复杂性,如图3所示。通过增加层数,模型可以学习到更多的全局特征并捕捉到更复杂的关联,但这也会导致过拟合,因此,始终需要使用正则化技术来避免过拟合。

图3展示了多层GNN模型

最后,一旦从网络中获得了所有节点的最终特征向量,就可以形成特征矩阵H。

此特征矩阵可以用于完成多种任务,如节点分类或图分类。这结束了我们对GCN和GAT数学描述的介绍。

GCN 回归示例教程

让我们实现一个回归示例,目的是训练网络以根据所有其他节点的值来预测某个节点的值,即每个节点只有一个特征(这是一个标量值)。此示例的目的是利用图中固有的关系信息来准确预测每个节点的数值。需要注意的是,我们将除了目标节点外的所有节点的数值输入网络(将目标节点的值设为0),然后预测目标节点的值。对于每个数据点,我们对每个节点重复这个过程。这任务乍看之下可能有些奇怪,但让我们看看能否根据其他节点的值来预测任何节点的预期值。示例中所用的图结构基于实际流程结构。我在代码中添加了注释,以便更容易理解。你可以在这里找到数据集(注意:这些数据是从模拟中生成的)(here)。

此代码和训练过程远未优化,但其目的是展示GNN的实现并理解其工作原理。目前我这样做的一个问题是,掩盖节点特征值并从邻居的特征中预测被掩盖的特征值,这在学习之外不应该这样做。目前,你必须为每个节点进行循环(效率不高),一个更好的方法是让模型在聚合步骤时排除自身的特征,因此不需要逐个处理节点,但我认为使用这种方法更容易理解模型的工作原理。

数据预处理阶段

导入所需的库和传感器数据(来自CSV文件)。将所有数据归一化到0到1之间。

import pandas as pd  
import torch  
from torch_geometric.data import Data, Batch  
from sklearn.preprocessing import StandardScaler, MinMaxScaler  
from sklearn.model_selection import train_test_split  
import numpy as np  
from torch_geometric.data import DataLoader  

# 读取并缩放数据集  
df = pd.read_csv('SensorDataSynthetic.csv').dropna()  
scaler = MinMaxScaler()  
# 创建新的DataFrame,其中包含缩放后的数据并保留原始列名  
df_scaled = pd.DataFrame(scaler.fit_transform(df), columns=df.columns)

用 PyTorch 张量定义图中节点之间的连接性(边的索引),即这定义了系统的图形结构。

nodes_order = [  
    'Sensor1', 'Sensor2', 'Sensor3', 'Sensor4',   
    'Sensor5', 'Sensor6', 'Sensor7', 'Sensor8'  
]  

# 定义数据图的连接  
edges = torch.tensor([  
    [0, 1, 2, 2, 3, 3, 6, 2],  # 源点  
    [1, 2, 3, 4, 5, 6, 2, 7]   # 终点  
], dtype=torch.long)

从 csv 文件导入的数据虽然具有表格结构,但要在 GNN 中使用,每行数据(即一个观测值)需要转换为一个图。通过遍历每一行,我们来创建这些数据的图表示。

为每个节点/传感器建立一个掩码,以表示数据存在(1)或不存在(0),从而灵活应对缺失的数据。因为在大多数系统中可能存在缺少数据的项目,因此需要灵活处理缺失的数据。把数据分成训练集和测试集。

    graphs = []    

    # 遍历每行数据以创建每个观测值的图
    # 尽管这里没有这种情况,但我们还是创建了一个掩码来处理可能没有数据的节点
    for _, row in df_scaled.iterrows():  
        node_features = []  
        node_data_mask = []  
        for node in nodes_order:  
            if node in df_scaled.columns:  
                node_features.append([row[node]])  
                node_data_mask.append(1) # 掩码值表示数据存在  
            else:  
                # 如果节点缺失,则处理缺失的节点特征
                node_features.append(2)  
                node_data_mask.append(0) # 数据不存在  

        node_features_tensor = torch.tensor(node_features, dtype=torch.float)  
        node_data_mask_tensor = torch.tensor(node_data_mask, dtype=torch.float)  

        # 为该行和图创建一个Data对象
        graph_data = Data(x=node_features_tensor, edge_index=edges.t().contiguous(), mask=node_data_mask_tensor) # (将边索引转换为连续的二维张量)
        graphs.append(graph_data)  

    #### 将数据拆分成训练和测试观测值
    # 分割索引  
    observation_indices = df_scaled.index.tolist()  
    train_indices, test_indices = train_test_split(observation_indices, test_size=0.05, random_state=42)  

    # 创建训练和测试用的图  
    train_graphs = [graphs[i] for i in train_indices]  
    test_graphs = [graphs[i] for i in test_indices]

图形的可视化

上述使用边索引创建的图结构可以使用networkx来可视化。

导入 networkx as nx  
导入 matplotlib.pyplot as plt  

# 创建一个大小为10x8的绘图窗口
plt.figure(figsize=(10, 8))  

# 使用弹簧布局算法来布局图
pos = nx.spring_layout(G)  

G = nx.Graph()  
for src, dst in edges.t().numpy():  
    G.add_edge(nodes_order[src], nodes_order[dst])  

nx.draw(G, pos, with_labels=True, node_color='lightblue', edge_color='gray', node_size=2000, font_weight='bold')  
# 设置字体加粗
# 设置节点大小为2000
# 设置图形标题
plt.title('图形可视化')  
# 显示图形窗口
plt.show()

我们来定义这个模型

我们来定义这个模型。该模型包含两个GAT卷积层。第一层将节点特征转换为8维,第二层GAT则进一步将其缩减至8维表示。

GNN很容易过拟合,因此我们会通过在每个GAT层后添加用户自定义概率的dropout来防止过拟合。dropout实际上会在训练过程中随机将输入张量中的一些元素置零。

GAT卷积层的输出结果会通过一个全连接(线性)层进行转换,将8维输出转换为最终的节点特征,在这种情况下,每个节点的最终特征都是一个标量值。

隐藏目标节点的值;正如之前提到的,此任务的目标是根据邻居节点的值来预测目标节点的值。这就是为什么我们要把目标节点的值替换为零。

from torch_geometric.nn import GATConv  
import torch.nn.functional as F  
import torch.nn as nn  

class GNNModel(nn.Module):  
    def __init__(self, num_node_features):  
        super(GNNModel, self).__init__()  
        self.conv1 = GATConv(num_node_features, 16)  
        self.conv2 = GATConv(16, 8)  
        self.fc = nn.Linear(8, 1)  # 输出每个节点的单值  

    def forward(self, data, target_node_idx=None):  
        x, edge_index = data.x, data.edge_index  
        edge_index = edge_index.T  
        x = x.clone()  

        # 将目标节点的特征用零值屏蔽!  
        # 目的是从邻居的特征预测这个值  
        # 注意:conv3 应该是 conv2 的误输入,这里要保持一致  
        if target_node_idx is not None:  
            x[target_node_idx] = torch.zeros_like(x[target_node_idx])  

        x = F.relu(self.conv1(x, edge_index))  
        x = F.dropout(x, p=0.05, training=self.training)  
        x = F.relu(self.conv2(x, edge_index))  
        x = F.dropout(x, p=0.05, training=self.training)  
        x = self.fc(x)  

        return x

让模型接受训练

初始化模型并定义优化器、损失函数,以及超参数,如学习率、权重衰减(用于正则化)、批大小和 epoch 数。

我们定义了一个GNN模型 model = GNNModel(num_node_features=1)   
我们设置了批次大小为8 batch_size = 8  
我们设置了一个优化器 optimizer = torch.optim.Adam(model.parameters(), lr=0.0002, weight_decay=1e-6)  
我们定义了一个损失函数 criterion = torch.nn.MSELoss()  
我们设定了训练的轮数为200 num_epochs = 200    
我们创建了一个数据加载器来加载训练图,批次大小为1,随机打乱 train_loader = DataLoader(train_graphs, batch_size=1, shuffle=True)   
我们开始训练模型 model.train()

训练过程相当标准,每个图(即一个数据点)会通过模型的前向传递(遍历每个节点并预测目标节点的过程)。预测损失会在定义的批次中累积,之后通过反向传播来更新GNN。

    for epoch in range(num_epochs):  
        accumulated_loss = 0   
        optimizer.zero_grad()  
        loss = 0    
        for batch_idx, data in enumerate(train_loader):  
            mask = data.mask    
            for i in range(1, data.num_nodes):  
                if mask[i] == 1:  # 仅针对有数据的节点进行训练  
                    output = model(data, i)  # 获取带有掩码的目标节点预测值  
                                             # 检查模型的前向传播  
                    target = data.x[i]   
                    prediction = output[i].view(1)   
                    loss += criterion(prediction, target)  
            # 在每个批次的末尾更新参数  
            if (batch_idx+1) % batch_size == 0 or (batch_idx +1 ) == len(train_loader):  
                loss.backward()   
                optimizer.step()  
                optimizer.zero_grad()  
                accumulated_loss += loss.item()  
                loss = 0  

        average_loss = accumulated_loss / len(train_loader)  
        print(f'第 {epoch+1} 轮, 平均损失: {average_loss}')

测试训练模型

使用测试数据集,让每个图通过训练好的模型进行前向传递,根据每个节点周围节点的值来预测该节点的值。

    test_loader = DataLoader(test_graphs, batch_size=1, shuffle=True)  # 数据加载器 (数据加载器)
    # 模型评估模式
    model.eval()  # 将模型设置为评估模式

    # 保存实际值和预测值的列表
    actual = []  # 实际值列表
    pred = []  # 预测值列表

    for data in test_loader:  # 遍历测试数据
        mask = data.mask  # 获取数据的掩码
        # 从第二个节点开始遍历数据中的所有节点
        for i in range(1, data.num_nodes):  
            output = model(data, i)  # 获取模型输出
            prediction = output[i].view(1)  # 获取预测值
            target = data.x[i]  # 获取目标值

            actual.append(target)  # 将实际值添加到列表中
            pred.append(prediction)  # 将预测值添加到列表中

测试结果可视化

我们可以通过iplot来展示节点预测值和真实值。

    import plotly.graph_objects as go  
    from plotly.offline import iplot  

    actual_values_float = [value.item() for value in actual]  
    pred_values_float = [value.item() for value in pred]  

    scatter_trace = go.Scatter(  
        x=actual_values_float,  
        y=pred_values_float,  
        mode='markers',  
        marker=dict(  
            size=10,  
            opacity=0.5,    
            color='rgba(255,255,255,0)',    
            line=dict(  
                width=2,  
                color='rgba(152, 0, 0, .8)',   
            )  
        ),  
        name='实际值与预测值'  
    )  

    line_trace = go.Scatter(  
        x=[min(actual_values_float), max(actual_values_float)],  
        y=[min(actual_values_float), max(actual_values_float)],  
        mode='lines',  
        marker=dict(color='blue'),  
        name='理想预测'  
    )  

    data = [scatter_trace, line_trace]  

    layout = dict(  
        title='实际值与预测值的对比',  
        xaxis=dict(title='实际值'),  
        yaxis=dict(title='预测值'),  
        autosize=False,  
        width=800,  
        height=600  
    )  

    fig = dict(data=data, layout=layout)  

    iplot(fig)

尽管没有对模型架构或超参数进行微调,它实际上表现得相当不错。我们可以进一步优化模型来提高准确性。

到这里,本文就结束了。相较于其他机器学习分支,GNNs(图神经网络)是一个相对较新的子领域,看到这一领域的发展及其在不同问题上的应用会非常令人兴奋。最后,感谢您花时间读这篇文章,希望能对您理解和掌握GNNs及其数学背景有所帮助。

在你走之前

我个人真的很享受花时间学习新概念,并将这些概念应用于新的问题和挑战中,我相信读这类文章的大多数人都会有同样的感觉。我认为有机会做这样的事情是一种特权,一种每个人都应该拥有的特权,但并非所有人都能享有。我们都有责任为每个人创造一个更美好的未来而努力改变现状。请考虑向UniArk(UniArk.org)捐款,以帮助那些经常被大学和国家忽视的充满才华的学生——受迫害的少数群体(无论是种族、宗教或其他原因)。UniArk深入挖掘,在发展中国家偏远的地区寻找人才和潜力。您的捐款将成为来自压迫社会的某个人的希望之光。我希望你能帮助UniArk继续照亮这束希望之光。

所有图片未特别注明的话,均出自作者之手。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消