第十七课.Pytorch-geometric入门(二)

消息传递

这部分内容主要是了解"消息传递网络"(Message passing networks)的创建;

将卷积操作推广到不规则域后被称为邻域聚合(neighborhood aggregation)或消息传递(message passing)。我们定义:

  • x i ( k − 1 ) ∈ R F x_{i}^{(k-1)}\in R^{F} xi(k1)RF代表第 k − 1 k-1 k1层Graph中节点 i i i的特征;
  • e j , i ∈ R D e_{j,i}\in R^{D} ej,iRD代表节点 j j j连接到节点 i i i的边特征;

而消息传递网络可以被描述为: x i ( k ) = γ ( k ) ( x i ( k − 1 ) , F u n c j ∈ N ( i ) ϕ ( k ) ( x i ( k − 1 ) , x j ( k − 1 ) , e j , i ) ) x_{i}^{(k)}=\gamma^{(k)}(x^{(k-1)}_{i},Func_{j\in N(i)}\phi^{(k)}(x^{(k-1)}_{i},x^{(k-1)}_{j},e_{j,i})) xi(k)=γ(k)(xi(k1),FuncjN(i)ϕ(k)(xi(k1),xj(k1),ej,i))其中, F u n c Func Func表示一个可微分,并具有可置换性的函数,比如meansummax γ \gamma γ(更新函数)和 ϕ \phi ϕ(消息传递函数)表示可微分的函数。

MessagePassing Base Class

PyG中提供了实现消息传递机制的类torch_geometric.nn.MessagePassing,我们只需要重新定义以下函数就能实现自己的消息传递模型: ϕ \phi ϕmessage(),函数 γ \gamma γupdate(),aggregation 机制(aggr="add"aggr="mean"aggr="max");

在消息传递模型的构建中,我们需要认识以下四个方法:

  • torch_geometric.nn.MessagePassing(aggr="add",flow="source_to_target"):用于初始化,用于定义聚合方式,以及信息传递的方向;
  • torch_geometric.nn.MessagePassing.propagate(edge_index,size=None,**kwargs):函数用于传播消息的初始化调用,更新节点的embedding表达;
    其中,**kwargs是不定长关键字参数,用于构建与聚合消息以及节点embedding更新所需的任何附加数据,**kwargsmessage()的参数相关;
  • torch_geometric.nn.MessagePassing.message(x_j:'torch.Tensor')->'torch.Tensor':构建消息从节点 j j j向节点 i i i传递消息,代表函数 ϕ \phi ϕ,即在edge_index中的每条边上调用 ϕ \phi ϕ
    另外,对于边的表示有以下规则:如果flow="source_to_target" ( j , i ) (j,i) (j,i),如果flow="target_to_source" ( i , j ) (i,j) (i,j)
    此函数可以将最初传递给propagate()的初始输入作为它的参数;
    最后注意一个关于该函数的特点,我们可以通过把_i_j附加到变量名称x后(比如x_ix_j),从而将传递给propagate()的张量映射到对应的节点 i i i和节点 j j jx_i代表当前节点,x_j代表当前节点的邻居节点;
  • torch_geometric.nn.MessagePassing.update(inputs:'torch.Tensor')->'torch.Tensor':更新节点的embedding,代表函数 γ \gamma γ,即对每个节点都调用 γ \gamma γ

调用propagate(), 内部会自动调用message()update()


通过以上内容,结合: x i ( k ) = γ ( k ) ( x i ( k − 1 ) , F u n c j ∈ N ( i ) ϕ ( k ) ( x i ( k − 1 ) , x j ( k − 1 ) , e j , i ) ) x_{i}^{(k)}=\gamma^{(k)}(x^{(k-1)}_{i},Func_{j\in N(i)}\phi^{(k)}(x^{(k-1)}_{i},x^{(k-1)}_{j},e_{j,i})) xi(k)=γ(k)(xi(k1),FuncjN(i)ϕ(k)(xi(k1),xj(k1),ej,i))我们可以发现,MessagePassing.__init__()用于定义 F u n c j ∈ N ( i ) Func_{j\in N(i)} FuncjN(i)message()用于定义 ϕ ( k ) ( x i ( k − 1 ) , x j ( k − 1 ) , e j , i ) \phi^{(k)}(x^{(k-1)}_{i},x^{(k-1)}_{j},e_{j,i}) ϕ(k)(xi(k1),xj(k1),ej,i)update()用于定义 γ ( k ) \gamma^{(k)} γ(k)

另外,当我们在实现自定义MessagePassing模型时,我们只需设计当前节点 i i i的消息聚合过程,因为调用update后,程序会自动处理到每个节点。

简单GCN层的实现

模型来自"Semi-Supervised Classification with Graph Convolutional Networks";

一般GCN层被定义为: x i ( k ) = ∑ j ∈ N ( i ) ∪ { i } 1 d e g ( i ) ⋅ d e g ( j ) ⋅ ( Θ ⋅ x j ( k − 1 ) ) x^{(k)}_{i}=\sum_{j\in N(i)\cup \left\{i\right\}}\frac{1}{\sqrt{deg(i)}\cdot\sqrt{deg(j)}}\cdot(\Theta\cdot x^{(k-1)}_{j}) xi(k)=jN(i){i}deg(i) deg(j) 1(Θxj(k1))其中, x i ( k ) x^{(k)}_{i} xi(k)代表第 k k k层GCN输出graph的第 i i i个节点的特征,节点与邻居节点的特征由权重矩阵 Θ \Theta Θ转换,按照节点的度进行归一化, 1 d e g ( i ) ⋅ d e g ( j ) \frac{1}{\sqrt{deg(i)}\cdot\sqrt{deg(j)}} deg(i) deg(j) 1是节点 i i i的归一化系数,最后聚合信息,具体步骤如下:

  • 在邻接矩阵上添加自循环;
  • 线性变换节点特征矩阵;
  • 计算归一化系数;
  • ϕ \phi ϕ中归一化节点特征;
  • 聚合节点与邻居节点的信息;

在Base Class中, F u n c j ∈ N ( i ) Func_{j\in N(i)} FuncjN(i)的计算对象不包括当前节点本身,但GCN层需要考虑节点自身信息( j ∈ N ( i ) ∪ { i } j\in N(i)\cup \left\{i\right\} jN(i){i}),因此我们需要增加节点自环的计算过程(add_self_loops);

GCN层的实现如下:

import torch
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops,degree
from torch_geometric.data import Data

class GCNConv(MessagePassing):
    def __init__(self,in_channels,out_channels):
        super().__init__(aggr='add') # Add aggregation
        self.lin=torch.nn.Linear(in_channels,out_channels)

    def forward(self,x,edge_index):
        # x的shape [N,in_channels] N:节点数
        # edge_index的shape [2,E] E:边的数量

        # 在邻接矩阵上添加节点自环
        edge_index,_=add_self_loops(edge_index,num_nodes=x.size(0)) # [2,E]

        # 线性变换特征矩阵
        x=self.lin(x) # [N,out_channels]

        # 归一化计算
        row,col=edge_index # 解包 row的shape[E], col的shape[E]
        """
        degree(index,num_nodes=None,dtype=None)
        index是data.edge_index中的任意一行, 作用是计算每个节点出现的次数
        """
        deg=degree(index=col,num_nodes=x.size(0),dtype=x.dtype) # 计算每个节点的度, deg shape [N]
        
        deg_inv_sqrt=deg.pow(-0.5) # 为每个节点的度开根并求倒数 deg_inv_sqrt shape [N]
        
        deg_inv_sqrt[deg_inv_sqrt==float('inf')]=0
        
        norm=deg_inv_sqrt[row]*deg_inv_sqrt[col] # row和col存储的是边索引, 对于第m条边, 可用节点row[m]到节点col[m]表达
        
        # norm shape [E]

        """
        调用propagate(), 内部会自动调用message()和update(), 传递的参数是x
        """
        # 聚合节点与邻居节点的信息
        return self.propagate(edge_index,x=x,norm=norm) # [N,out_channels]

    def message(self,x_j,norm):
        # 归一化邻域内节点的特征
        # x_j的shape [E,out_channels]
        # norm.view(-1,1) shape [E,1]
        # norm.view(-1,1)*x_j 将每条边的norm系数缩放到特征的所有通道上

        return norm.view(-1,1)*x_j

注意message的定义:

def message(self,x_j,norm):
    # 归一化邻域内节点的特征
    # x_j的shape [E,out_channels]
    # norm.view(-1,1) shape [E,1]
    # norm.view(-1,1)*x_j 将每条边的norm系数缩放到特征的所有通道上

    return norm.view(-1,1)*x_j

由于在forward中,x已经被线性变换过,所以参数x_j中的元素即为数学定义下的 Θ ⋅ x j ( k − 1 ) \Theta\cdot x_{j}^{(k-1)} Θxj(k1),而norm中保存的就是归一化系数 1 d e g ( i ) ⋅ d e g ( j ) \frac{1}{\sqrt{deg(i)}\cdot\sqrt{deg(j)}} deg(i) deg(j) 1

关于x_j shape [E,out_channels]norm shape [E]可以这样理解,本身消息传递函数的意义就是:构建消息从节点 j j j向节点 i i i传递消息,也就是我们要在edge_index中的每条边上调用 ϕ \phi ϕ,因此x_j是graph中所有要计算的节点 i i i各自对应的邻居节点 j j j组成的特征数组。


另外补充内容:广播

上面计算中出现了[E,1]*[E,out_channels]的形式,这其实是利用了广播的规则;广播可以减少代码量。

简单来说,如果有一个 m × n m\times n m×n的矩阵,让它加减乘除一个 1 × n 1\times n 1×n的矩阵,它会被复制 m m m次,成为一个 m × n m\times n m×n的矩阵,然后再逐元素地进行加减乘除操作。同样地这对 m × 1 m\times 1 m×1的矩阵成立。


定义一个图,输入模型:
fig1

"""
创建图, 每个节点的特征维数为1:
边的邻接矩阵为:
[[0,1,0],
 [1,0,1],
 [0,1,0]]
"""
edge_index=torch.tensor([[0,1,1,2],
                         [1,0,2,1]],dtype=torch.long)

x=torch.tensor([[-1],[0],[1]],dtype=torch.float)

data=Data(x=x,edge_index=edge_index)

print(data) # Data(edge_index=[2, 4], x=[3, 1])

# 初始化与调用
conv=GCNConv(1,3)

x=conv(data.x,data.edge_index)

实现边卷积(Edge Convolution)

模型来自"Dynamic Graph CNN for Learning on Point Clouds",边卷积层通常用于处理graph或者点云数据(point clouds),数学定义为: x i ( k ) = m a x j ∈ N ( i ) h Θ ( x i ( k − 1 ) , x j ( k − 1 ) − x i ( k − 1 ) ) x_{i}^{(k)}=max_{j\in N(i)}h_{\Theta}(x_{i}^{(k-1)},x_{j}^{(k-1)}-x_{i}^{(k-1)}) xi(k)=maxjN(i)hΘ(xi(k1),xj(k1)xi(k1))其中, h Θ h_{\Theta} hΘ代表多层感知机(MLP),注意 h Θ ( m , n ) h_{\Theta}(m,n) hΘ(m,n)是先拼接 m , n m,n m,n再进行变换,即 h Θ ( m ⊕ n ) h_{\Theta}(m\oplus n) hΘ(mn)

类比 GCN 层,我们可以使用 MessagePassing 类来实现这一层,这次使用"max"聚合:

import torch
import torch.nn as nn
from torch_geometric.nn import MessagePassing
from torch_geometric.data import Data

class EdgeConv(MessagePassing):
    def __init__(self,in_channels,out_channels):
        super().__init__(aggr='max') # "max" aggregation
        self.mlp=nn.Sequential(nn.Linear(2*in_channels,out_channels),
                               nn.ReLU(),
                               nn.Linear(out_channels,out_channels))

    def forward(self,x,edge_index):
        # x shape [N,in_channels] N:节点数
        # edge_index shape [2,E] E:边的数量

        return self.propagate(edge_index,x=x) # [N,out_channels]

    def message(self,x_i,x_j):
        # x_i shape [E,in_channels]
        # x_j shape [E,in_channels]

        tmp=torch.cat([x_i,x_j-x_i],dim=-1) # [E,2* in_channles]

        return self.mlp(tmp)

message的参数中,我们用x_i内的元素代表当前节点的特征,x_j内的元素代表邻居节点的特征;

同样的,我们对节点与邻居节点调用 ϕ \phi ϕ,等价于在每条边上调用 ϕ \phi ϕ,所以有x_i shape [E,in_channels]x_j shape [E,in_channels]

创建自定义数据集

尽管 PyG 已经包含很多有用的数据集,但我们希望使用自行记录或非公开可用的数据创建自己的数据集。下面,简要介绍一下设置自己的数据集所需的条件。

关于数据集,存在两个类:torch_geometric.data.Datasettorch_geometric.data.InMemoryDataset

torch_geometric.data.InMemoryDataset 继承自 torch_geometric.data.Dataset 并且应该在整个数据集可以被 CPU 完全加载到内存时使用。

遵循 torchvision 的约定,每个数据集都会传递一个根文件夹(root folder),该文件夹指示数据集应该存储的位置。我们把根文件夹分成两个文件夹:

  • raw_dir,数据集下载后的保存位置;
  • processed_dir,经过一些简单整理后的数据集保存位置。

此外,每个数据集都可以传递到transformpre_transformpre_filter 函数,默认情况下它们是None

transform函数在访问之前动态转换数据对象(因此它最好用于数据增强);

pre_transform 函数在将数据对象保存到磁盘之前应用转换(因此它最好用于只需要执行一次的繁重预计算);

pre_filter 函数可以在保存前手动过滤掉数据对象;

创建"In Memory Datasets"

为了创建 torch_geometric.data.InMemoryDataset,需要实现四个基本方法:

  • torch_geometric.data.InMemoryDataset.raw_file_names():返回一个文件列表,包含了raw_dir中的文件目录,可以根据该列表来决定哪些需要下载或者已下载就跳过;
  • torch_geometric.data.InMemoryDataset.processed_file_names():返回一个处理后的文件列表,包含processed_dir中的文件目录,根据列表内容决定哪些数据已经处理过从而可以跳过处理;
  • torch_geometric.data.InMemoryDataset.download():下载原始数据到raw_dir
  • torch_geometric.data.InMemoryDataset.process():处理原始数据并存放到processed_dir

神奇的事情发生在process()方法,在这里,我们读取并创建一个Data对象列表并将其保存到processed_dir中,由于保存一个巨大的python列表很慢,我们在保存之前通过torch_geometric.data.InMemoryDataset.collate()将列表整理成一个巨大的Data对象,整理后的数据对象将所有实例连接成一个大的数据对象,并且返回一个slices字典以便于重建单个实例。最后,我们需要在构造函数中将这两个对象加载到属性self.dataself.slices

下面使用一个简单的格式熟悉这个过程:

import torch
from torch_geometric.data import InMemoryDataset,download_url

class MyOwnDataset(InMemoryDataset):
    def __init__(self,root,transform=None,pre_transform=None):
        super().__init__(root,transform,pre_transform)
        self.data,self.slices=torch.load(self.processed_paths[0])

    @property
    def raw_file_names(self):
        return ['some_file_1','some_file_2',...]

    @property
    def processed_file_names(self):
        return ['data1.pt','data2.pt',...]

    def download(self):
        # 下载到 self.raw_dir
        download_url('url',self.raw_dir)

    def process(self):
        # 将数据整合到一个很大的Data列表里
        data_list=[...]
        
        if self.pre_filter is not None:
            data_list=[data for data in data_list if self.pre_filter(data)]
            
        if self.pre_transform is not None:
            data_list=[self.pre_transform(data) for data in data_list]
            
        data,slices=self.collate(data_list)
        torch.save((data,slices),self.processed_paths[0])

首先注意,property是一个特殊的装饰器该装饰器(也是一个数据描述符)将类中的读写方法变成了一种属性读写的控制,通过property修饰,方法调用被转为属性一样的读写。比如self.data,self.slices=torch.load(self.processed_paths[0])中的self.processed_paths[0],像属性一样的对象processed_paths其实是一个继承自class Dataset的方法。

关于self.processed_paths,其返回的是一个文件列表,返回内容由processed_file_names返回的文件列表决定。self.processed_paths[0]则是第一个已处理数据的位置,即dir+data1.pt。回顾前面的内容,每个数据实例包含两部分:dataslices


torch.load()可以加载使用torch.save()保存的对象,所以可以看到torch.save((data,slices),self.processed_paths[0]),我们保存了两个对象dataslicesdir+data1.pt里;


现在,我们将 Cora 应用到 In Memory Datasets 中:

import torch
from torch_geometric.data import InMemoryDataset
from torch_geometric.data import download_url
import os
from torch_geometric.io import read_planetoid_data

class MyOwnDataset(InMemoryDataset):
    def __init__(self,url= 'https://github.com/kimiyoung/planetoid/raw/master/data',
                 root='MyOwnDataset',
                 transform=None,
                 pre_transform=None,
                 pre_filter=None):

        self.url=url
        self.transform=transform
        self.pre_filter=pre_filter
        self.pre_transform=pre_transform

        self.raw=os.path.join(root,'raw')
        self.processed=os.path.join(root,'processed')

        super().__init__(root=root, transform=transform, pre_transform=pre_transform, pre_filter=pre_filter)

        print(self.processed_paths)
        self.x, self.slices = torch.load(self.processed_paths[0])


    # 返回原始文件列表
    @property
    def raw_file_names(self):
        names = ['x', 'tx', 'allx', 'y', 'ty', 'ally', 'graph', 'test.index']
        return ['ind.cora.{}'.format(name) for name in names]

    # 返回需要跳过的文件列表
    @property
    def processed_file_names(self):
        return ['data.pt','pre_transform.pt','pre_filter.pt']

    # 下载原始数据
    def download(self):
        for name in self.raw_file_names:
            download_url('{}/{}'.format(self.url, name), self.raw)

    def process(self):
        # read_planetoid_data(folder, prefix) 加载数据集并划分训练,验证,测试集
        data=read_planetoid_data(self.raw,'cora')
        data_list = [data]
        print(data_list)

        if self.pre_filter is not None:
            data_list = [data for data in data_list if self.pre_filter(data)]

        if self.pre_transform is not None:
            data_list = [self.pre_transform(data) for data in data_list]

        data, slices = self.collate(data_list)

        torch.save((data, slices), self.processed_paths[0])

"""
初始化实例时, 先调用super().__init__(), 此时中断进入download(), 然后执行process(), 最终完成整个__init__()的执行
其中, download()与process()是否可以跳过与raw_file_names, processed_file_names相关
"""
data=MyOwnDataset()
print(data.x)
print(data.slices)
"""
下载部分省略

Processing...
[Data(edge_index=[2, 10556], test_mask=[2708], train_mask=[2708], val_mask=[2708], x=[2708, 1433], y=[2708])]
Done!

['MyOwnDataset\\processed\\data.pt', 'MyOwnDataset\\processed\\pre_transform.pt', 'MyOwnDataset\\processed\\pre_filter.pt']

Data(edge_index=[2, 10556], test_mask=[2708], train_mask=[2708], val_mask=[2708], x=[2708, 1433], y=[2708])

{'x': tensor([   0, 2708]), 'edge_index': tensor([    0, 10556]), 'y': tensor([   0, 2708]), 'train_mask': tensor([   0, 2708]), 
'val_mask': tensor([   0, 2708]), 'test_mask': tensor([   0, 2708])}
"""

可以看出,data_list的内容为[Data(edge_index=[2, 10556], test_mask=[2708], train_mask=[2708], val_mask=[2708], x=[2708, 1433], y=[2708])],这个巨大的Data对象包含了各种实例数据,当然列表里还可以接着存放其他Data对象。

并且得到以下形式的目录:
fig2

创建 “Larger” Datasets

上面的 In Memory Datasets 可以通过内存完成全部加载,所以通常是较小型的数据集。为了创建内存不能一次性加载的数据集,可以使用torch_geometric.data.Dataset,它遵循torchvision的概念,使用时需要实现以下方法:

  • torch_geometric.data.Dataset.len():返回数据集中的实例数;
  • torch_geometric.data.Dataset.get():实现加载单个图的功能;

在内部,torch_geometric.data.Dataset.__getitem__()torch_geometric.data.Dataset.get() 获取数据对象,并可选择transform对它们进行转换;

下面通过一个简化例子感受:

import os.path as osp

import torch
from torch_geometric.data import Dataset,download_url

class MyOwnDataset(Dataset):
    def __init__(self,root,transform=None,pre_transform=None):
        super().__init__(root,transform,pre_transform)

    @property
    def raw_file_names(self):
        return ['some_file_1','some_file_2',...]

    @property
    def processed_file_names(self):
        return ['data_1.pt','data_2.pt',...]

    def download(self):
        download_url('url',self.raw_dir)
        
    def process(self):
        i=0
        for raw_path in self.raw_paths:
            # 从raw_path读数据
            data=Data(...)
            
            if self.pre_filter is not None and not self.pre_filter(data):
                continue
            
            if self.pre_transform is not None:
                data=self.pre_transform(data)
                
            torch.save(data,osp.join(self.processed_dir,'data_{}.pt'.format(i)))
            i += 1
            
    def len(self):
        return len(self.processed_file_names)
    
    def get(self,idx):
        data=torch.load(osp.join(self.processed_dir,'data_{}.pt'.format(idx)))
        return data

在这里,每个图数据对象都通过process()进行单独保存,我们可以通过get()手动加载某个具体图数据。

常见问题

1.如何完全跳过 download()process() 的执行?

我们可以通过不覆盖(not overriding) download()process() 方法来跳过下载或处理:

class MyOwnDataset(Dataset):
    def __init__(self, transform=None, pre_transform=None):
        super().__init__(None, transform, pre_transform)

2.我真的需要使用这些数据集接口(interfaces)吗?

不用,其实就像在 PyTorch 中一样,例如,当我想动态创建合成数据而不将它们显式保存到磁盘时。 在这种情况下,只需传递一个包含 torch_geometric.data.Data 对象的常规 python 列表并将它们传递给 torch_geometric.loader.DataLoader

from torch_geometric.data import Data
from torch_geometric.loader import DataLoader

data_list = [Data(...), ..., Data(...)]
loader = DataLoader(data_list, batch_size=32)
  • 9
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值