基础知识
图
图是由顶点和顶点之间的边组成的一种数据结构,它通常表示为,其中表示为一个图,是图中顶点的集合,是图中边的集合。如下图所示
图结构研究的是数据元素之间的多对多关系。在这种结构中,任意两个元素之间都可能存在关系,即顶点之间的关系可以是任意的,图中任意元素之间都可能相关。
在图结构中,不允许没有顶点。任意两个顶点之间都可能有关系,顶点之间的逻辑关系用边来表示。边可以是有向的也可以是无向的,边集合可以为空。并且在图结构中的每个顶点都有自己的特征信息。顶点间的关系可以反映出图结构的特征信息。在实际应用中,可以根据图顶点特征或图结构特征进行分类。
常用术语:
- 有向图或无向图:根据图顶点之间的边是否带有方向来确定。
- 权:图中边或弧上附加的数量信息,这种反映边或弧的某种特征的数量称为权。
- 网:图上的边或弧带权则称为网,可分为有向网和无向网。
- 度:在无向图中,与顶点关联的边的条数称为度。在有向图中,则以顶点为弧尾的弧称为顶点的出度,以顶点为弧头的弧称为顶点的入度,而顶点的度即为其出度和入度之和。图中各顶点度数之和是边(弧)的数量的2倍。
哈达马积
哈达马积是指两个矩阵对应位置上的元素进行相乘的结果。
a = np.array(range(4)).reshape(2, 2)
b = np.array(range(4, 8)).reshape(2, 2)
print(type(a))
print(a,'\n')
print(b,'\n')
print(a * b)
'''
<class 'numpy.ndarray'>
[[0 1]
[2 3]]
[[4 5]
[6 7]]
[[ 0 5]
[12 21]]
'''
点积
点积是指两个矩阵相乘的结果,矩阵相乘的方法是行列。
# 方法1
c1 = a@b
print(c1,'\n')
# 方法2
c2 = np.dot(a, b)
print(c2,'\n')
# 方法3:先转化为矩阵类型,再进行相乘
ma = np.asmatrix(a)
mb = np.asmatrix(b)
print(type(ma))
print(ma * mb)
'''
[[ 6 7]
[26 31]]
[[ 6 7]
[26 31]]
<class 'numpy.matrix'>
[[ 6 7]
[26 31]]
'''
对角矩阵的特性与操作方法
由于对角矩阵具有只有对角线有值的特殊性,因此在运算过程中,可以利用其自身的特性实现一些特殊功能。
- 对角矩阵与向量的互转
a = np.diag([1, 2, 3]) # define a diagonal matrix
print(a,'\n')
v, e = np.linalg.eig(a) # vector & diagonal matrix
print(v,'\n')
print(e)
'''
[[1 0 0]
[0 2 0]
[0 0 3]]
[1. 2. 3.]
[[1. 0. 0.]
[0. 1. 0.]
[0. 0. 1.]]
'''
- 对角矩阵幂运算等于对角线上各个值的幂运算
- 幂运算
print(a*a*a,'\n')
print(a**3,'\n')
print((a**2)*2,'\n')
print(a@a@a)
'''
[[ 1 0 0]
[ 0 8 0]
[ 0 0 27]]
[[ 1 0 0]
[ 0 8 0]
[ 0 0 27]]
[[ 2 0 0]
[ 0 8 0]
[ 0 0 18]]
[[ 1 0 0]
[ 0 8 0]
[ 0 0 27]]
'''
- 逆运算
# method 1
print(np.linalg.inv(a),'\n')
# method 2
A = np.asmatrix(a)
print(A.I)
'''
[[1. 0. 0. ]
[0. 0.5 0. ]
[0. 0. 0.33333333]]
[[1. 0. 0. ]
[0. 0.5 0. ]
[0. 0. 0.33333333]]
'''
- 将一个对角矩阵与其倒数(逆)相乘便可以得到单位矩阵
print(np.linalg.inv(a)@a)
'''
[[1. 0. 0.]
[0. 1. 0.]
[0. 0. 1.]]
'''
- 对角矩阵左乘其他矩阵,相当于其对角元素分别乘以其他矩阵对应的各行
- 对角矩阵右乘其他矩阵,相当于其对角元素分别乘以其他矩阵对应的各列
度矩阵和邻接矩阵
图神经网络常用度矩阵和邻接矩阵来描述图的结构:
- 图的度矩阵用来描述图中每个节点所连接的变数。
- 图的邻接矩阵用来描述图中每个节点之间的相邻关系。
上图的度矩阵和邻接矩阵分别为:
和
邻接矩阵的几种操作
邻接矩阵的行数和列数一定是相等的(即为方形矩阵)。无向图的邻接矩阵一定是对称的,而有向图的邻接矩阵不一定对称。
- 获取有向图的短边
假设图的邻接矩阵为,则获取有向图的短边公式为:
其中,“”代表哈达马积。公式中的部分用于计算掩码,矩阵可以理解为矩阵中任意两点间反方向的边。若的意思是当前方向的边小于反方向的边,那么返回True,否则返回False。用该掩码对邻接矩阵执行哈达马积运算,即可得到所有短边的矩阵。完整计算过程如下图:
- 获取有向图的长边
获取有向图的场边矩阵,只需要将短边的掩码规则取反,假设图的邻接矩阵为,则获取有向图长边公式为:
还可以用邻接矩阵直接减去短边矩阵,即
将有向图的邻接矩阵转向无向图的邻接矩阵
在图计算过程中,常会有将有向图的邻接矩阵转成无向图的邻接矩阵,即保留图中的长边矩阵,并将其中的连接变成双向连接。
无向图的邻接矩阵属于对称矩阵,在图关系顶点的分析中,它可以更加灵活地参与运算。
实现有向图地邻接矩阵向无向图的邻接矩阵转化的方法是将长边矩阵加上长边矩阵的转置:
实例:用图卷积神经网络为论文分类
图神经网络与深度学习模型的不同之处在于,图神经网络会利用论文本身特征和论文间的关系特征进行处理,这种模型仅需要少量样本即可达到很好的效果。
CORA数据集
CORA数据集是由机器学习的论文整理而来的。在该数据集中,记录了每篇论文用到的关键词,以及论文之间互相引用的关系。
- 数据集内容
- CORA数据集中的论文共分为7类:基于案例、遗传算法、神经网络、概率方法、强化学习、规则学习、理论。
- 数据集中共有2708篇论文,每一篇论文都引用或至少被一篇其他论文所引用。整个语料库共有2708篇论文。同时,又将所有论文中的词干、停止词、低频词删除,留下1433个关键词,作为论文的个体特征。
- 数据集的组成:CORA数据集中有两个文件,具体说明如下:
- content文件包含一下格式的论文说明:
<paper-id><word-attributes><class_label>
,每行的第一个条目包含论文的唯一字符串ID,随后用一个二进制指示词汇表中的每个单词在纸张中存在或不存在。行中的最后一行包含纸张的类标签。 - cite文件包含了语料库的引文图,每一行用以下格式描述一个连接:
<id of reference paper><id of reference paper>
,每行包含两个纸张ID。第一个条目是被引用论文的ID,第二个ID代表包含引用的论文。如"paper1 paper2",则“paper2→paper1"。
- content文件包含一下格式的论文说明:
代码实现
from pathlib import Path # 提升路径的兼容性
# 引入矩阵运算相关库
import numpy as np
import pandas as pd
from scipy.sparse import coo_matrix, csr_matrix, diags, eye
# 引入深度学习框架库
import torch
from torch import nn
import torch.nn.functional as F
# 引入绘图库
import matplotlib.pyplot as plt
# 输出运算资源情况
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(device)
# 输出样本路径
path = Path('data/cora')
print(path)
# 读取论文内容数据,并将其转化为数组
paper_features_label = np.genfromtxt(path/'cora.content', dtype=np.str)
print(paper_features_label, np.shape(paper_features_label))
# 取出数据的第一列:论文ID
papers = paper_features_label[:, 0].astype(np.int32)
print(papers)
# 为论文重新编号, {31336: 0, 1061127: 1,……}
# v的长度应该是0-2707
paper2idx = {k:v for v, k in enumerate(papers)} # k是论文ID, v是批次数
# 将数据中间部分的字标签取出,转为成矩阵
features = csr_matrix(paper_features_label[:, 1:-1], dtype=np.float32)
print(np.shape(features))
# 将最后一项的论文分类属性取出,并转化为分类索引
labels = paper_features_label[:, -1]
lbl2idx = {k:v for v,k in enumerate(sorted(np.unique(labels)))}
labels = [lbl2idx[e] for e in labels]
print(lbl2idx,labels[:5])
# 读取论文关系数据,并将其转化为数组
edges = np.genfromtxt(path/'cora.cites', dtype=np.int32)
print(edges, np.shape(edges))
# 转化为新编号节点间的关系
edges = np.asarray([paper2idx[e] for e in edges.flatten()], np.int32).reshape(edges.shape)
print(edges, edges.shape)
# 计算邻接矩阵(Adjacency matrix),行列都是论文个数
adj = coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])),
shape=(len(labels), len(labels)), dtype=np.float32)
# Symmetric adjacency matrix
# 生成无向图对称矩阵
adj_long = adj.multiply(adj.T < adj)
adj = adj_long + adj_long.T
# print(adj)
def normalize(mx): # 定义函数,对矩阵数据进行归一化
'Row-normalize sparse matrix'
rowsum = np.array(mx.sum(1)) # 每一篇论文的字数
r_inv = (rowsum ** -1).flatten() # 取总字数的倒数
r_inv[np.isinf(r_inv)] = 0. # 将Nan值设为0
r_mat_inv = diags(r_inv) # 将总字数的倒数做成对角矩阵
mx = r_mat_inv.dot(mx) # 左乘一个矩阵,相当于每个元素除以总数(行)
return mx
# 对 features矩阵进行归一化(每行的总和为1)
features = normalize(features)
# 对邻接矩阵对角线添加1,将其变成自循环图,同时再对其进行归一化
adj = normalize(adj + eye(adj.shape[0]))
# Data as tensor
adj = torch.FloatTensor(adj.todense()) # 节点间的关系
features = torch.FloatTensor(features.todense()) # 节点自身的特征
labels = torch.LongTensor(labels) # 每个节点的分类标签
# 划分数据集
n_train = 200
n_val = 300
n_test = len(features) - n_train - n_val # 2708-200-300
np.random.seed(34)
idxs = np.random.permutation(len(features)) # 将原有索引打乱顺序
# 计算每个数据集的索引
idx_train = torch.LongTensor(idxs[: n_train])
idx_val = torch.LongTensor(idxs[n_train: n_train + n_val])
idx_test = torch.LongTensor(idxs[n_train+n_val:])
# 分配运算资源
adj = adj.to(device)
features = features.to(device)
labels = labels.to(device)
idx_train = idx_train.to(device)
idx_val = idx_val.to(device)
idx_test = idx_test.to(device)
def mish(x): # Mish激活函数
return x * (torch.tanh(F.softplus(x)))
# 图卷积类
class GraphConvolution(nn.Module):
def __init__(self, f_in, f_out, use_bias=True, activation=mish):
super().__init__()
self.f_in = f_in
self.f_out = f_out
self.use_bias = use_bias
self.activation = activation
self.weight = nn.Parameter(torch.FloatTensor(f_in, f_out))
self.bias = nn.Parameter(torch.FloatTensor(f_out)) if use_bias else None
self.initialize_weights()
def initialize_weights(self):
if self.activation is None:
nn.init.xavier_uniform_(self.weight)
else:
nn.init.kaiming_uniform_(self.weight, nonlinearity='leaky_relu')
if self.use_bias:
nn.init.zeros_(self.bias)
def forward(self, input, adj):
suport = torch.mm(input, self.weight)
output = torch.mm(adj, suport)
if self.use_bias:
output.add_(self.bias)
if self.activation is not None:
output = self.activation(output)
return output
class GCN(nn.Module):
def __init__(self, f_in, n_classes, hidden=[16], dropout_p=0.5):
super().__init__()
layers = []
for f_in, f_out in zip([f_in]+hidden[:-1], hidden):
layers += [GraphConvolution(f_in, f_out)]
self.layers = nn.Sequential(*layers)
self.dropout_p = dropout_p
self.out_layer = GraphConvolution(f_out, n_classes, activation=None)
def forward(self, x, adj):
for layer in self.layers:
x = layer(x, adj)
# 函数方式调用dropout必须用training标志
F.dropout(x, self.dropout_p, training=self.training, inplace=True)
return self.out_layer(x, adj)
n_labels = labels.max().item() + 1 # 分类个数 7
n_features = features.shape[1] # 节点个数 1433
print(n_labels, n_features)
def accuracy(output, y):
return (output.argmax(1) == y).type(torch.float32).mean().item()
def step():
model.train()
optimizer.zero_grad()
output = model(features, adj)
loss = F.cross_entropy(output[idx_train], labels[idx_train])
acc = accuracy(output[idx_train], labels[idx_train])
loss.backward()
optimizer.step()
return loss.item(), acc
def evaluate(idx):
model.eval()
output = model(features, adj)
loss = F.cross_entropy(output[idx], labels[idx]).item()
return loss, accuracy(output[idx], labels[idx])
model = GCN(n_features, n_labels, hidden=[16, 32, 16]).to(device)
from ranger import *
optimizer = Ranger(model.parameters())
from tqdm import tqdm
# 训练模型
epochs = 1000
print_steps = 50
train_loss, train_acc = [], []
val_loss, val_acc = [], []
for i in tqdm(range(epochs)):
tl, ta = step()
train_loss += [tl]
train_acc += [ta]
if (i+1) % print_steps == 0 or i == 0:
tl, ta = evaluate(idx_train)
vl, va = evaluate(idx_val)
val_loss += [vl]
val_acc += [va]
print(f'{i+1:6d}/{epochs}: train_loss={tl:.4f}, train_acc={ta:.4f}'+
f', val_loss={vl:.4f}, val_acc={va:.4f}')
# 输出最终结果
final_train, final_val, final_test = evaluate(idx_train), evaluate(idx_val), evaluate(idx_test)
print(f'Train : loss={final_train[0]:.4f}, accuracy={final_train[1]:.4f}')
print(f'Validation: loss={final_val[0]:.4f}, accuracy={final_val[1]:.4f}')
print(f'Test : loss={final_test[0]:.4f}, accuracy={final_test[1]:.4f}')
#可视化训练过程
fig, axes = plt.subplots(1, 2, figsize=(15,5))
ax = axes[0]
axes[0].plot(train_loss[::print_steps] + [train_loss[-1]], label='Train')
axes[0].plot(val_loss, label='Validation')
axes[1].plot(train_acc[::print_steps] + [train_acc[-1]], label='Train')
axes[1].plot(val_acc, label='Validation')
for ax,t in zip(axes, ['Loss', 'Accuracy']): ax.legend(), ax.set_title(t, size=15)
#输出模型预测结果
output = model(features, adj)
samples = 10
idx_sample = idx_test[torch.randperm(len(idx_test))[:samples]]
idx2lbl = {v:k for k,v in lbl2idx.items()}
df = pd.DataFrame({'Real': [idx2lbl[e] for e in labels[idx_sample].tolist()],
'Pred': [idx2lbl[e] for e in output[idx_sample].argmax(1).tolist()]})
print(df)