之前对GCN的理解始终不清不楚,今天根据代码仔细理解了一下,其实这份代码已经有不少人都做过注释,注释也很详细,这里有一篇博客写的非常详细,附上GCN论文源码超级详细注释讲解。原代码来自于Github,链接为:Graph Convolutional Networks in PyTorch。以下为个人理解部分:
GCN代码主体有4个py文件:layers.py models.py train.py utils.py
一、layers.py 定义了一个图卷积的类。这个图卷积只有一层结构,所以比较简单。图卷积的基本结构就是:,其中是图节点的属性信息,是图结构的信息。
import math
import torch
from torch.nn.parameter import Parameter
from torch.nn.modules.module import Module
class GraphConvolution(Module):
"""
Simple GCN layer, similar to https://arxiv.org/abs/1609.02907
"""
def __init__(self, in_features, out_features, bias=True):
super(GraphConvolution, self).__init__()
self.in_features = in_features
self.out_features = out_features
self.weight = Parameter(torch.FloatTensor(in_features, out_features)) # 首先可以把这个函数理解为类型转换函数,将一个不可训练的类型Tensor转换成可以训练的类型parameter并将这个parameter绑定到这个module里面(net.parameter()中就有这个绑定的parameter,所以在参数优化的时候可以进行优化的),所以经过类型转换这个self.v变成了模型的一部分,成为了模型中根据训练可以改动的参数了。使用这个函数的目的也是想让某些变量在学习的过程中不断的修改其值以达到最优化。
if bias:
self.bias = Parameter(torch.FloatTensor(out_features))
else:
self.register_parameter('bias', None) # register_parameter()和Parameter()功能类似
self.reset_parameters()
def reset_parameters(self): # 初始化权重和偏置
stdv = 1. / math.sqrt(self.weight.size(1))
self.weight.data.uniform_(-stdv, stdv) # 将tensor用从均匀分布中抽样得到的值填充。
if self.bias is not None:
self.bias.data.uniform_(-stdv, stdv)
def forward(self, input, adj):
support = torch.mm(input, self.weight)
output = torch.spmm(adj, support)
if self.bias is not None:
return output + self.bias
else:
return output
def __repr__(self): # __repr__() 方法,输出的是该方法的返回值
return self.__class__.__name__ + ' (' \
+ str(self.in_features) + ' -> ' \
+ str(self.out_features) + ')'
二、model.py定义了一个类GCN,其中调用了layer.py中的图卷积结构。
import torch.nn as nn
import torch.nn.functional as F
from pygcn.layers import GraphConvolution
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)
三、utils.py定义了一些公用的方法,包括onehot方法、load_data方法、normalize方法、将一个scipy稀疏矩阵转换为一个torch稀疏张量的方法,其中细节最多的是load_data方法。
import numpy as np
import scipy.sparse as sp #
import torch
import pandas as pd
def encode_onehot(labels):
classes = set(labels)
print("4",classes)
classes_dict = {c: np.identity(len(classes))[i, :] for i, c in enumerate(classes)} # identity() 输入n为行数或列数,返回一个n*n的对角阵,对角线元素为1,其余为0。通过这种方式,就把每个类别标签向量化了
print("5",classes_dict)
labels_onehot = np.array(list(map(classes_dict.get, labels)), dtype=np.int32) # get() 函数返回指定键的值。
print("6",labels_onehot)
return labels_onehot
def load_data(path="../data/cora/", dataset="cora"):
"""Load citation network dataset (cora only for now)"""
print('Loading {} dataset...'.format(dataset))
idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset), # numpy.genformtxt( ) 函数提供了众多的入参,实现不同格式数据的读取,这个函数的读取结果是把cora.content以n维list的形式读取出来
dtype=np.dtype(str))
print("1",idx_features_labels)
print("2",idx_features_labels.shape)
features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32) # 把稀疏矩阵压缩,特征就是判断每个词是否在引文中出现,压缩之后的表现形式不一样了,但内容其实是一样的
print("3",features)
labels = encode_onehot(idx_features_labels[:, -1]) # 把content内容的最后一列提取出来,进行onehot,从而将标签转为数字
print("7",labels)
# build graph
idx = np.array(idx_features_labels[:, 0], dtype=np.int32) # 提取序列号
idx_map = {j: i for i, j in enumerate(idx)}
print("8",idx_map)
edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset),
dtype=np.int32) # 读取cora.cites数据集
print(edges_unordered)
print(edges_unordered.shape[0])
print(edges_unordered.flatten())
edges = np.array(list(map(idx_map.get, edges_unordered.flatten())), # flatten()返回一个一维数组,这个语句的意思是,把edges_unordered中的引文号替换成idx序列号[0,1,2,3,4,5,6...]
dtype=np.int32).reshape(edges_unordered.shape)
print("9",edges)
adj = sp.coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])), # coo_matrix:构造矩阵,指定位置填数据
shape=(labels.shape[0], labels.shape[0]),
dtype=np.float32)
print("10",adj) # 到这一步,引文网络的节点和边就都构造出来了,adj是经过压缩之后的表示
print("10.25", adj.shape)
print("10.45", labels.shape)
# build symmetric adjacency matrix 建立对称邻接矩阵,为什么要构建对称邻接矩阵
adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)
features = normalize(features) # features是节点的属性特征
adj = normalize(adj + sp.eye(adj.shape[0])) # adj是网络的连接特征(是否有边,度数等)
print("10.5",adj)
idx_train = range(140)
idx_val = range(200, 500)
idx_test = range(500, 1500)
features = torch.FloatTensor(np.array(features.todense()))
print("11 labels", labels)
print("12", labels.shape)
print("13", np.where(labels)[1])
labels = torch.LongTensor(np.where(labels)[1])
print("14", labels) # 得到了最终的类别标签
adj = sparse_mx_to_torch_sparse_tensor(adj)
print("15", adj)
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
def normalize(mx):
"""Row-normalize sparse matrix,按行标准化稀疏矩阵"""
rowsum = np.array(mx.sum(1))
r_inv = np.power(rowsum, -1).flatten()
r_inv[np.isinf(r_inv)] = 0.
r_mat_inv = sp.diags(r_inv)
mx = r_mat_inv.dot(mx)
return mx
def accuracy(output, labels):
preds = output.max(1)[1].type_as(labels) # torch.max(1)[1], 只返回每一行最大值的索引
correct = preds.eq(labels).double()
correct = correct.sum()
return correct / len(labels)
def sparse_mx_to_torch_sparse_tensor(sparse_mx):
"""Convert a scipy sparse matrix to a torch sparse tensor.将一个scipy稀疏矩阵转换为一个torch稀疏张量"""
sparse_mx = sparse_mx.tocoo().astype(np.float32) #
indices = torch.from_numpy(
np.vstack((sparse_mx.row, sparse_mx.col)).astype(np.int64))
values = torch.from_numpy(sparse_mx.data)
shape = torch.Size(sparse_mx.shape)
return torch.sparse.FloatTensor(indices, values, shape)
四、train.py 把前面的各个零件组合起来,进行训练和测试
from __future__ import division
from __future__ import print_function
import time
import argparse # argparse模块主要用来为脚本传递命令参数功能,使他们更加灵活。
import numpy as np
import torch
import torch.nn.functional as F
import torch.optim as optim
from utils import load_data, accuracy
from models import GCN
# Training settings
parser = argparse.ArgumentParser()
parser.add_argument('--no-cuda', action='store_true', default=False,
help='Disables CUDA training.')
parser.add_argument('--fastmode', action='store_true', default=False,
help='Validate during training pass.')
parser.add_argument('--seed', type=int, default=42, help='Random seed.')
parser.add_argument('--epochs', type=int, default=200,
help='Number of epochs to train.')
parser.add_argument('--lr', type=float, default=0.01,
help='Initial learning rate.')
parser.add_argument('--weight_decay', type=float, default=5e-4,
help='Weight decay (L2 loss on parameters).')
parser.add_argument('--hidden', type=int, default=16,
help='Number of hidden units.')
parser.add_argument('--dropout', type=float, default=0.5,
help='Dropout rate (1 - keep probability).')
args = parser.parse_args()
args.cuda = not args.no_cuda and torch.cuda.is_available()
np.random.seed(args.seed)
torch.manual_seed(args.seed) # 为当前GPU设置随机种子
if args.cuda:
torch.cuda.manual_seed(args.seed)
# Load data
adj, features, labels, idx_train, idx_val, idx_test = load_data()
# Model and optimizer
model = GCN(nfeat=features.shape[1], # 特征的宽度
nhid=args.hidden, #
nclass=labels.max().item() + 1,
dropout=args.dropout)
optimizer = optim.Adam(model.parameters(),
lr=args.lr, weight_decay=args.weight_decay)
if args.cuda:
model.cuda()
features = features.cuda()
adj = adj.cuda()
labels = labels.cuda()
idx_train = idx_train.cuda()
idx_val = idx_val.cuda()
idx_test = idx_test.cuda()
def train(epoch):
t = time.time()
model.train()
optimizer.zero_grad()
output = model(features, adj)
loss_train = F.nll_loss(output[idx_train], labels[idx_train])
acc_train = accuracy(output[idx_train], labels[idx_train])
loss_train.backward()
optimizer.step()
if not args.fastmode:
# Evaluate validation set performance separately,
# deactivates dropout during validation run. 在验证运行期间停用dropout
model.eval()
output = model(features, adj)
loss_val = F.nll_loss(output[idx_val], labels[idx_val])
acc_val = accuracy(output[idx_val], labels[idx_val])
print('Epoch: {:04d}'.format(epoch+1),
'loss_train: {:.4f}'.format(loss_train.item()),
'acc_train: {:.4f}'.format(acc_train.item()),
'loss_val: {:.4f}'.format(loss_val.item()),
'acc_val: {:.4f}'.format(acc_val.item()),
'time: {:.4f}s'.format(time.time() - t))
def test1():
model.eval()
output = model(features, adj)
loss_test = F.nll_loss(output[idx_test], labels[idx_test])
acc_test = accuracy(output[idx_test], labels[idx_test])
print("Test set results:",
"loss= {:.4f}".format(loss_test.item()),
"accuracy= {:.4f}".format(acc_test.item()))
# Train model
t_total = time.time()
for epoch in range(args.epochs):
train(epoch)
print("Optimization Finished!")
print("Total time elapsed: {:.4f}s".format(time.time() - t_total))
# Testing
test1()
总结起来:
- 定义GCN模型。在定义普通的卷积网络时,可以直接调用torch自带的torch.linear()等函数,但是对于图的卷积并没有现成的,所以需要自己定义图卷积,定义的方式就如layers.py,图卷积的前向传播其实就是,其中是图节点的属性信息,后续input实际就是数据集提取出的features,是图结构的信息。定义好图卷积层之后,就可以定义GCN模型的前向传播过程了,和普通的卷积传播过程相似,卷积——激活——随机失活——卷积——…——归一化。这样GCN模型就定义好了。
- 定义训练的过程。
(1) 加载数据+处理数据:
① 指定数据集路径 path="…/data/cora", dataset=‘cora’
② 读取cora.content数据集
③ 对cora.content数据中的特征部分(剔除索引列和标签列)进行压缩
④ 对cora.content数据中的label列内容进行onehot化
⑤ 对cora.content数据中的文献索引重新排列(原来的索引是杂乱无章的,所以要重新排列成0,1,2,3,4…的样式)
以上是对cora.content内容的处理,实际上是对图中节点属性的处理,接下来对cora.cites进行处理,实际上是对图结构的处理。
⑥ 读取cora.cites数据集
⑦ 根据上面cora.content数据中的文献索引重新排列的结果,对cora.cites中的引文序号进行相应的替换。
⑧ 建一个空矩阵,矩阵的长宽均为引文的数量,然后把有边的地方用1表示,填充到这个矩阵中。
⑨ 对这个矩阵进行数据变化,然后对这个矩阵和前面的features进行normalize,就得到了图的节点属性矩阵和邻接矩阵属性。
⑩ 对label数据进行处理,用np.where()把onehot形式的label转成一个一维数组,这就是最终要用到的label。
(2) 定义模型和优化器
model = GCN()
optimizer = optim.Adam()
(3) 训练
输入模型的参数为数据集提取出来的features 和 adj
(4) 测试