目录
消息传递
这部分内容主要是了解"消息传递网络"(Message passing networks)的创建;
将卷积操作推广到不规则域后被称为邻域聚合(neighborhood aggregation)或消息传递(message passing)。我们定义:
- x i ( k − 1 ) ∈ R F x_{i}^{(k-1)}\in R^{F} xi(k−1)∈RF代表第 k − 1 k-1 k−1层Graph中节点 i i i的特征;
- e j , i ∈ R D e_{j,i}\in R^{D} ej,i∈RD代表节点 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(k−1),Funcj∈N(i)ϕ(k)(xi(k−1),xj(k−1),ej,i))其中,
F
u
n
c
Func
Func表示一个可微分,并具有可置换性的函数,比如mean
,sum
,max
;
γ
\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更新所需的任何附加数据,**kwargs
与message()
的参数相关;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_i
和x_j
),从而将传递给propagate()
的张量映射到对应的节点 i i i和节点 j j j,x_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(k−1),Funcj∈N(i)ϕ(k)(xi(k−1),xj(k−1),ej,i))我们可以发现,MessagePassing.__init__()
用于定义
F
u
n
c
j
∈
N
(
i
)
Func_{j\in N(i)}
Funcj∈N(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(k−1),xj(k−1),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)=j∈N(i)∪{i}∑deg(i)⋅deg(j)1⋅(Θ⋅xj(k−1))其中, 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)}
Funcj∈N(i)的计算对象不包括当前节点本身,但GCN层需要考虑节点自身信息(
j
∈
N
(
i
)
∪
{
i
}
j\in N(i)\cup \left\{i\right\}
j∈N(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(k−1),而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的矩阵成立。
定义一个图,输入模型:
"""
创建图, 每个节点的特征维数为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)=maxj∈N(i)hΘ(xi(k−1),xj(k−1)−xi(k−1))其中, 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Θ(m⊕n);
类比 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.Dataset
和torch_geometric.data.InMemoryDataset
;
torch_geometric.data.InMemoryDataset
继承自 torch_geometric.data.Dataset
并且应该在整个数据集可以被 CPU 完全加载到内存时使用。
遵循 torchvision
的约定,每个数据集都会传递一个根文件夹(root folder),该文件夹指示数据集应该存储的位置。我们把根文件夹分成两个文件夹:
raw_dir
,数据集下载后的保存位置;processed_dir
,经过一些简单整理后的数据集保存位置。
此外,每个数据集都可以传递到transform
、pre_transform
和pre_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.data
和self.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
。回顾前面的内容,每个数据实例包含两部分:data
和slices
。
torch.load()
可以加载使用torch.save()
保存的对象,所以可以看到torch.save((data,slices),self.processed_paths[0])
,我们保存了两个对象data
和slices
到dir+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
对象。
并且得到以下形式的目录:
创建 “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)