December 2, 2022

图神经网络代码实现

安装工具

不得不说本人是把能踩的坑都踩了一遍。

有啥坑?

首先是CUDA版本不够,去更新CUDA,按照官网教程就彳亍,没什么好说的,就8记了,注意C盘空间要留大。
我第一次安装的时候就是C盘空间不够,后来只能重装系统清理,把D盘空间给C盘分了200个G,顺便清了好多垃圾 = =

如果直接去搜怎么安装PyTorch,大家会告诉你去[PyTorch官网]下载PyTorch,选好自己的版本,用python pip下,我得到的命令是
pip3 install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu116,这样装的PyTorch版本是1.13.0

下好之后,装pytorch-geometric,按照教程换好命令之后死活下不下来,最开始是pip报错:error: subprocess-exited-with-error
根据报错加一个参数--use-pep517,然后又报错HTTP 403,搜了一下发现是没有权限访问。
我干脆打开pytorch-geometric,进wheel,发现里面根!本!没!有1.13.0版本的!!!

之前装ruby也是这个问题 = =

安装PyTorch

去PyTorch的github找怎么安装旧版本PyTorch(我直接手动在python包里删新版本了,懒得找卸载命令),发现是在https://pytorch.org/get-started/previous-versions/,找到Windows CUDA11.6的安装命令是conda install pytorch==1.12.1 torchvision==0.13.1 torchaudio==0.12.1 cudatoolkit=11.6 -c pytorch -c conda-forge

python -c "import torch; print(torch.__version__)"测一下,是1.12.1没错,泪目

安装pytorch-geometric

1
2
3
4
5
pip install torch-scatter -f https://pytorch-geometric.com/whl/torch-1.12.1+cu116.html
pip install torch-sparse -f https://pytorch-geometric.com/whl/torch-1.12.1+cu116.html
pip install torch-cluster -f https://pytorch-geometric.com/whl/torch-1.12.1+cu116.html
pip install torch-spline-conv -f https://pytorch-geometric.com/whl/torch-1.12.1+cu116.html
pip install torch-geometric

终于success了,泪目

测试一下

1
2
3
4
5
6
7
import torch
from torch_geometric.data import Data
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])
bingo~

快速上手

官方教程

图数据处理

图被用来训练一对对象(点)之间的关系(边)。PyG中一个单独的图是用实例torch_geometric.data.Data描述的,它默认有以下属性:

COO

这是一种存储稀疏矩阵的方式。将图写成邻接矩阵的形式后,用三组数据来存储图,这三组数据分别为行、列、权,此处只需要两组数据,即行、列。

GCN

model.py

双层GCN公式:

图卷积:forward计算了一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class GraphConvolution(Module):
def __init__(self, in_features, out_features, bias=True):
super(GraphConvolution, self).__init__()
self.in_features = in_features
self.out_features = out_features # 注意:torch.FloatTensor生成的元素数值非常接近0;torch.Long生成的元素数值非常大
self.weight = Parameter(torch.FloatTensor(in_features, out_features))
# self.weight2 = Parameter(torch.FloatTensor(in_features, out_features))
if bias:
self.bias = Parameter(torch.FloatTensor(out_features))
else:
self.register_parameter("bias", None)
self.reset_parameters() # 此处表示生成变量后(即上面的语句运行后),将会进行变量初始化(即执行该语句)

def reset_parameters(self): # 经测试,重写可覆盖
stdv = 1/math.sqrt(self.weight.size(1))
self.weight.data.uniform_(-stdv, stdv)
# self.weight2.data.uniform_(-stdv, stdv)
if self.bias is not None:
self.bias.data.uniform_(-stdv, stdv)

def forward(self, input, adj):
# torch.mm(a, b)是矩阵a和b矩阵相乘
# torch.mul(a, b)是矩阵a和b对应位相乘
support = torch.mm(adj, input) # 返回的adj为dense A*input
output = torch.mm(support, self.weight) # A*input*W
if self.bias is not None: # adj是稀疏矩阵
return output +self.bias
else:
return output

def __repr__(self):
return self.__class__.__name__+"("+str(self.in_features)+"->"+str(self.out_features)+")"

双层GCN:下面的代码计算,其中是第一层图卷积(gc1),把它当成第二层的输入进行第二次图卷积,log_softmax之后得到输出。

1
2
3
4
5
6
7
8
9
10
11
12
class GCN(nn.Module):
def __init__(self, nfeat, nhid, nclass, dropout):
super(GCN, self).__init__()
self.gc1 = GraphConvolution(nfeat, nhid)
self.gc2 = GraphConvolution(nhid, nclass)
self.dropout = dropout

def forward(self, x, adj):
x = F.relu(self.gc1(x, adj))
x = F.dropout(x, self.dropout, training=self.training)
x = self.gc2(x, adj)
return F.log_softmax(x, dim=1)

load_data.py

one-hot编码

什么是one-hot编码
简而言之,能够将现实生活中的变量类别转为机器学习算法易于利用的矩阵形式。

1
2
3
4
5
6
7
8
9
10
11
'''
先将所有由字符串表示的标签数组用set保存,set的重要特征就是元素没有重复,
因此表示成set后可以直接得到所有标签的总数,随后为每个标签分配一个编号,创建一个单位矩阵,
单位矩阵的每一行对应一个one-hot向量,也就是np.identity(len(classes))[i, :],
再将每个数据对应的标签表示成的one-hot向量,类型为numpy数组
'''
def encode_onehot(labels):
classes = sorted(list(set(labels))) # 去重
classes_dict = {c:np.identity(len(classes))[i, :] for i, c in enumerate(classes)}
labels_onehot = np.array(list(map(classes_dict.get, labels)), dtype=np.int32) # 按照label值排序,构建one-hot向量
return labels_onehot
加载数据

读取文件cora.content。
genfromtxt运行两个主循环:第一个循环以字符串序列转换文件的每一行。第二个循环将(按照delimiter划分行得到的字符串(默认情况下,任何连续的空格充当分隔符))每个字符串转换为适当的数据类型。
sp.csr_matrix对数据进行压缩处理。

1
2
3
4
5
6
7
8
9
def load_data(path="./cora/", dataset="cora"):
print("Loading {} dataset...".format(dataset))
idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset), dtype=np.dtype(str))
features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32)
labels = encode_onehot(idx_features_labels[:, -1])
# 这里的label为onthot格式,如第一类代表[1,0,0,0,0,0,0]
# content file的每一行的格式为 : <paper_id> <word_attributes>+ <class_label>
# 分别对应 0, 1:-1, -1
# feature为第二列到倒数第二列,labels为最后一列

其中[:,1:-1]表示取矩阵第2列到倒数第2列,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
In [1]: import numpy

In [2]: matrix = [[0, 1, 2, 3], [4, 5, 6, 7]]

In [3]: matrix = numpy.array(matrix)

In [4]: matrix[:,1]
Out[4]: array([1, 5])

In [5]: matrix[:,1:-1]
Out[5]:
array([[1, 2],
[5, 6]])

取矩阵第一列建立图。
由于文件中节点并非是按顺序排列的,因此建立一个编号为0-(node_size-1)的哈希表idx_map,idx_map哈希表中每一项为id: number,即节点id对应的编号为number。如

1
2
3
4
5
6
In [6]: idx = ['a','b','c']

In [7]: idx_map = {j: i for i, j in enumerate(idx)}

In [8]: idx_map
Out[8]: {'a': 0, 'b': 1, 'c': 2}
1
2
idx = np.array(idx_features_labels[:, 0], dtype=np.int32)
idx_map = {j:i for i, j in enumerate(idx)}

看不懂了

读取文件cora.cities
edges_unordered为直接从边表文件中直接读取的结果,是一个(edge_num, 2)的数组,每一行表示一条边两个端点的idx (被引用论文,引用论文)。
边的edges_unordered中存储的是端点id,要将每一项的id换成编号。
在idx_map中以idx作为键查找得到对应节点的编号,reshape成与edges_unordered形状一样的数组。
获得邻接矩阵,sp.coo_matrix((data,(row,col)),shape) 返回一个压缩后的matrix
根据coo矩阵性质,这一段的作用就是,网络有多少条边,邻接矩阵就有多少个1,
所以先创建一个长度为edge_num的全1数组,每个1的填充位置就是一条边中两个端点的编号,
即edges[:, 0], edges[:, 1],矩阵的形状为(node_size, node_size)

1
2
3
edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset), dtype=np.int32)
edges = np.array(list(map(idx_map.get, edges_unordered.flatten())), dtype=np.int32).reshape(edges_unordered.shape)
adj = sp.coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])), shape=(labels.shape[0], labels.shape[0]), dtype=np.float32)

构建对称邻接矩阵(把构建的有向图邻接矩阵转换为无向图邻接矩阵)。
对feature进行规范化。
normalize():
adj + sp.eye(adj.shape[0])

1
2
3
adj = adj + adj.T.multiply(adj.T>adj) - adj.multiply(adj.T>adj) # 最后减的那一项目的是去除负边
features = normalize_features(features)
adj = normalize_adj(adj + sp.eye(adj.shape[0]))

分别构建训练集、验证集、测试集的范围

1
2
3
idx_train = range(140)
idx_val = range(200, 500)
idx_test = range(500, 1500)

features.todense():将稀疏矩阵转为稠密矩阵(即将压缩的矩阵转为未压缩的矩阵),类型是matrix

1
2
adj = torch.FloatTensor(np.array(adj.todense()))
features = torch.FloatTensor(np.array(features.todense()))

np.where(labels)[1]取labels中非零元素的列索引构成一维元组,此时labels变为一维的LongTensor,每个元素为对应<class_label>在onehot后的索引。
邻接矩阵转为tensor处理。

1
2
3
4
5
labels = torch.LongTensor(np.where(labels)[1])
idx_train = torch.LongTensor(idx_train)
idx_val = torch.LongTensor(idx_val)
idx_test = torch.LongTensor(idx_test)
return adj, features, labels, idx_train, idx_val, idx_test

将矩阵进行行规范化,即每个元素除以这一行的和

1
2
3
4
5
6
7
8
def normalize_features(mx):
rowsum = np.array(mx.sum(1)) # 求每行和
r_inv = np.power(rowsum, -1).flatten() # 获得每行和对应倒数构成的一维np数组
r_inv[np.isinf(r_inv)] = 0. # 将分母为零(即行和为0)的数据设为0
r_mat_inv = sp.diags(r_inv) # 以r_inv为对角线上数据绘制对角矩阵
mx = r_mat_inv.dot(mx) # 注意.dot为矩阵乘法,不是对应元素相乘
# 对输入矩阵进行按行规范化
return mx
1
2
3
4
5
6
def sparse_mx_to_torch_sparse_tensor(sparse_mx):
sparse_mx = sparse_mx.tocoo().astype(np.float32) #矩阵转为COO格式
indices = torch.from_numpy(np.vstack((sparse_mx.row, sparse_mx.col)).astype(np.int64)) # np.vstack(a,b)将a和b按行堆叠
values = torch.from_numpy(sparse_mx.data) # numpy中的ndarray转化成pytorch中的tensor : torch.from_numpy()
shape = sparse_mx.shape
return torch.sparse.FloatTensor(indices, values, shape)

后面看不动了

关于本文

本文作者 云之君, 许可由 CC BY-NC 4.0.