图卷积神经网络(GCN)实战

文章目录

  • 前言
  • 一、准备工作
  • 二、代码演示

前言

本篇文章的资料和代码来自深度之眼,自己学习的笔记。建议自己把论文好好看看,在理解论文的前提之下再来看这篇文章,因为代码中有一些公式的实现是和论文一一对应的。代码部分是有详细的注释,每一行都有解释,对应论文方便理解。

一、准备工作

论文:SEMI-SUPERVISED CLASSIFICATION WITH GRAPH CONVOLUTIONAL NETWORKS

(基于图卷积网络的半监督分类)

数据集:Cora

该数据集由2708篇论文,以及它们之间的引用关系构成的5429条边构成。这些论文根据主题划分为7类,分别是神经网络、强化学习、规则学习、概率方法、遗传算法、理论研究、案例相关。每篇论文的特征(向量)通过词袋模型得到,维度为1433(词典大小),每一维表示一个词,1表示该词在该论文中出现,0表示未出现。

数据集包含cora.cites和cora.content两个部分,下面分别给出部分截图:

cora.cites
图卷积神经网络(GCN)实战_第1张图片

每一行的两个数字分别表示两个点,表示他们之间有关系。

cora.content:这里展示某一个id对应的特征。前面的数字表示的是点的id,后面的Neural_Networks表示的是这个点的label,表示这个点是关于Neural_Networks的文章。中间部分(第二列到倒数第二列)表示的是这个点的特征。比如说某一列中有一个1,说明1对用的这一列的特征这个点是包括的,0这个部分不包含这个点的特征。数据集其他部分(没截图)类似。

图卷积神经网络(GCN)实战_第2张图片
首先定义类CoraData来对数据进行预处理,主要包括下载数据、规范化数据并进行缓存以备重复使用。最终得到的数据形式包括如下几个部分:

  • X:图中节点的特征,维度为N*D,即2708*1433(每个节点表示一条数据/一篇论文)

  • label:节点对应的标签,包括7个类别。

  • adj:邻接矩阵,维度N*N(2708*2708),类型为scipy.sparse.coo_matrix

  • idx_train、idx_val、idx_test:与节点数相同的掩码,用于划分训练集、验证集、 测试集。

二、代码演示

utils.py:读图和数据预处理部分

import numpy as np
import scipy.sparse as sp
import torch

'''
    先将所有由字符串表示的标签数组用set保存,set的重要特征就是元素没有重复,
    因此表示成set后可以直接得到所有标签的总数,随后为每个标签分配一个编号,创建一个单位矩阵,
    单位矩阵的每一行对应一个one-hot向量,也就是np.identity(len(classes))[i, :],
    再将每个数据对应的标签表示成的one-hot向量,类型为numpy数组
'''
def encode_onehot(labels):
    classes = set(labels)  # set()函数创建一个无序不重复元素集
    # identity创建一个n*n的矩阵
    classes_dict = {c: np.identity(len(classes))[i, :] for i, c in enumerate(classes)}

    # get函数得到字典key对应的value值,字典key为label的值,value为矩阵的每一行,enumerate函数用于将一个可遍历的数据对象组合为一个索引序列
    # map() 会根据提供的函数对指定序列做映射,第一个参数 function 以参数序列中的每一个元素调用 function 函数,返回包含每次 function 函数返回值的新列表
    labels_onehot = np.array(list(map(classes_dict.get, labels)), dtype=np.int32)
    return labels_onehot


def load_data(path="/data/cora/", dataset="cora"):
    """Load citation network dataset (cora only for now)"""
    print('Loading {} dataset...'.format(dataset))

    # content file的每一行的格式为:   ,分别对应 0, 1:-1, -1
    # feature为第二列到倒数第二列,labels为最后一列
    # idx_features_labels将cora数据集中的数据读取出来
    idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset),dtype=np.dtype(str))
    # 存储csr型稀疏矩阵,idx_features_labels[:, 1:-1]表示取所有行,第一列到倒数第二列
    features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32)
    # idx_features_labels[:, -1]表示取所有行,最后一列
    labels = encode_onehot(idx_features_labels[:, -1])

    # build graph
    # cites file的每一行格式为:   
    # 根据前面的contents与这里的cites创建图,算出edges矩阵与adj矩阵,这里是取出数据集中的第一列,也就是
    idx = np.array(idx_features_labels[:, 0], dtype=np.int32)

    # 由于文件中节点并非是按顺序排列的,因此建立一个编号为0-(node_size-1)的哈希表idx_map,
    # 哈希表中每一项为old id: number,即节点id对应的编号为number
    # 这行代码将第一列数据做了这样的操作:j表示的是值,i表示的是值对应的索引,以形式存储数据,然后根据j的值进行从小到大为排序
    idx_map = {j: i for i, j in enumerate(idx)}

    # edges_unordered为直接从cora.cites文件中直接读取的结果,是一个(edge_num, 2)的数组,每一行表示一条边两个端点的idx
    edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset), dtype=np.int32)

    # flatten:降维,返回一维数组,这里就是将edges_unordered这个(edge_num, 2)维的矩阵变成一个一维数组
    # 边的edges_unordered中存储的是端点id,要将每一项的old id换成编号number
    # 在idx_map中以idx作为键查找得到对应节点的编号(或者说对应的索引),reshape生成与edges_unordered形状一样的数组
    edges = np.array(list(map(idx_map.get, edges_unordered.flatten())), dtype=np.int32).reshape(edges_unordered.shape)

    # 根据coo矩阵性质,这一段的作用就是,网络有多少条边,邻接矩阵就有多少个1,
    # 所以先创建一个长度为edge_num的全1数组,每个1的填充位置就是一条边中两个端点的编号,即edges[:, 0], edges[:, 1],矩阵的形状为(node_size, node_size)
    # 逻辑是这样实现的:先创建一个全0的矩阵,然后根据
    adj = sp.coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])),
                        shape=(labels.shape[0], labels.shape[0]),
                        dtype=np.float32)

    # build symmetric adjacency matrix
    # 对于无向图,邻接矩阵是对称的。上一步得到的adj是按有向图构建的,转换成无向图的邻接矩阵需要扩充成对称矩阵将i->j与j->i中权重最大的那个, 作为无向图的节点i与节点j的边权.
    adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)

    features = normalize(features)
    # 对应公式A~ = A+IN
    adj = normalize(adj + sp.eye(adj.shape[0]))  # eye创建单位矩阵,第一个参数为行数,第二个为列数

    # 分别构建训练集、验证集、测试集,并创建特征矩阵、标签向量和邻接矩阵的tensor,用来做模型的输入
    idx_train = range(140)  # 训练集
    idx_val = range(200, 500)   # 验证集
    idx_test = range(500, 1500)  # 测试集

    # todense()将矩阵显示出来,np.array创建数组,torch.FloatTensor(同torch.Tensor一样)创建单精度浮点型的张量
    features = torch.FloatTensor(np.array(features.todense()))  # tensor为pytorch常用的数据结构
    labels = torch.LongTensor(np.where(labels)[1])  # np.where()[0]表示行索引,np.where()[0]表示列索引
    adj = sparse_mx_to_torch_sparse_tensor(adj)  # tensor为pytorch常用的数据结构,将邻接矩阵转为tensor处理

    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"""
    # 论文里A^=(D~)^-1 A~这个公式
    rowsum = np.array(mx.sum(1))  # 对每一行求和

    # (D~)^-1
    r_inv = np.power(rowsum, -1).flatten()

    # 如果某一行全为0,则r_inv算出来会等于无穷大,将这些行的r_inv置为0
    r_inv[np.isinf(r_inv)] = 0.

    # 构建对角元素为r_inv的对角矩阵
    r_mat_inv = sp.diags(r_inv)

    # 论文里A^=(D~)^-1 A~这个公式
    mx = r_mat_inv.dot(mx)
    return mx


def accuracy(output, labels):
    # 使用type_as(tesnor)将张量转换为给定类型的张量
    preds = output.max(1)[1].type_as(labels)
    # 记录等于preds的label eq:equal
    correct = preds.eq(labels).double()
    correct = correct.sum()
    return correct / len(labels)


def sparse_mx_to_torch_sparse_tensor(sparse_mx):  # 把一个sparse matrix转为torch稀疏张量
    """Convert a scipy sparse matrix to a torch sparse tensor."""
    '''
        numpy中的ndarray转化成pytorch中的tensor : torch.from_numpy()
        pytorch中的tensor转化成numpy中的ndarray : numpy()
    '''
    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)

model.py:搭建卷积模型

import torch.nn as nn
import torch.nn.functional as F
from .layers import GraphConvolution


class GCN(nn.Module):
    # 底层节点的参数,feature的个数;隐层节点个数;最终的分类数
    def __init__(self, nfeat, nhid, nclass, dropout):
        '''
            :param nfeat: 底层节点的参数,feature的个数
            :param nhid: 隐层节点个数
            :param nclass: 最终的分类数
            :param dropout: dropout参数
        '''
        # super()._init_()在利用父类里的对象构造函数
        super(GCN, self).__init__()
        # self.gc1代表GraphConvolution(),GraphConvolution()这个方法类在layer里面,gc1输入尺寸nfeat,输出尺寸nhid
        self.gc1 = GraphConvolution(nfeat, nhid)
        # self.gc2代表GraphConvolution(),gc2输入尺寸nhid,输出尺寸ncalss
        self.gc2 = GraphConvolution(nhid, nclass)
        # dropout参数
        self.dropout = dropout

    # 前向传播,按照个人理解,F.log_softmax(x, dim=1)中的参数x就是每一个节点的embedding
    def forward(self, x, adj):
        # 括号里面x是输入特征,adj是邻接矩阵。self.gc1(x, adj)执行GraphConvolution中forward函数
        x = F.relu(self.gc1(x, adj))

        # 输入x,dropout参数是self.dropout。training=self.training表示将模型整体的training状态参数传入dropout函数,没有此参数无法进行dropout
        x = F.dropout(x, self.dropout, training=self.training)

        # gc2层
        x = self.gc2(x, adj)

        # 输出为输出层做log_softmax变换的结果,dim表示log_softmax将计算的维度
        return F.log_softmax(x, dim=1)

layer.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
 2/+11   """

    # 初始化层:输入feature,输出feature,权重,偏移
    def __init__(self, in_features, out_features, bias=True):
        super(GraphConvolution, self).__init__()
        # 输入特征,每个输入样本的大小
        self.in_features = in_features
        # 输出特征,每个输出样本的大小
        self.out_features = out_features
        '''
            首先可以把这个函数理解为类型转换函数,将一个不可训练的类型Tensor转换成可以训练的类型parameter并将这个parameter绑定到这个module里面,所以经过类型转换这个
            self.v变成了模型的一部分,成为了模型中根据训练可以改动的参数了。使用这个函数的目的也是想让某些变量在学习的过程中不断的修改其m值以达到最优化。
        '''
        self.weight = Parameter(torch.FloatTensor(in_features, out_features))

        # 偏置,如果设为False,则层将不会学习加法偏差,默认值:True
        if bias:
            self.bias = Parameter(torch.FloatTensor(out_features))  # Parameters与register_parameter都会向parameters写入参数,但是后者可以支持字符串命名
        else:
            self.register_parameter('bias', None)
        self.reset_parameters()  # 参数重置函数

    # 初始化权重
    def reset_parameters(self):
        # size()函数主要是用来统计矩阵元素个数,或矩阵某一维上的元素个数的函数  size(1)为行,size(1)是指out_features, stdv=1/根号(out_features)
        stdv = 1. / math.sqrt(self.weight.size(1))
        # weight在区间(-stdv, stdv)之间均匀分布随机初始化
        self.weight.data.uniform_(-stdv, stdv)  # uniform()方法将随机生成下一个实数
        # bias均匀分布随机初始化
        if self.bias is not None:
            self.bias.data.uniform_(-stdv, stdv)

    '''
        前馈运算 即计算A~ X W(0) input X与权重W相乘,然后adj矩阵与他们的积稀疏乘直接输入与权重之间进行torch.mm操作,得到support,即XW,
        support与adj进行torch.spmm操作,得到output,即AXW选择是否加bias
    '''
    def forward(self, input, adj):
        # 是矩阵a和b矩阵相乘,torch.mul(a, b)是矩阵a和b对应位相乘,a和b的维度必须相等
        support = torch.mm(input, self.weight)
        # spmm是稀疏矩阵乘法,减小运算复杂度
        output = torch.spmm(adj, support)
        if self.bias is not None:
            # 返回:系数*输入*权重+偏置
            return output + self.bias
        else:
            # 返回:系数*输入*权重,无偏置
            return output

    def __repr__(self):
        # 打印形式是:GraphConvolution (输入特征 -> 输出特征)
        return self.__class__.__name__ + ' (' \
               + str(self.in_features) + ' -> ' \
               + str(self.out_features) + ')'

train.py:主函数,设置相关的参数

from __future__ import division
from __future__ import print_function

import time
import argparse
import numpy as np

import torch
import torch.nn.functional as F
import torch.optim as optim

from pygcn.utils import load_data, accuracy
from pygcn.models import GCN
# Training settings
parser = argparse.ArgumentParser()
# 禁用CUDA训练
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.')
# 要训练的epoch数
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.')
# 权重衰减(参数L2损失)
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.')
# dropout率(1-保持概率)
parser.add_argument('--dropout', type=float, default=0.5,
                    help='Dropout rate (1 - keep probability).')

# args = parser.parse_args()
args = parser.parse_args(args=[])
args.cuda = not args.no_cuda and torch.cuda.is_available()

# 产生随机种子,以使得结果是确定的
np.random.seed(args.seed)
torch.manual_seed(args.seed)
if args.cuda:
    torch.cuda.manual_seed(args.seed)
# Load data 这里可以在jupyter中看到结果
adj, features, labels, idx_train, idx_val, idx_test = load_data()

# Model and optimizer 调用模型:下面几个参数在models.py中声明
'''
    第一个参数为底层节点的参数,feature的个数
    nhid,隐层节点个数
    nclass,最终的分类数
    dropout正则化化放置过拟合,在每一层的神经网层中以一定概率丢弃神经元
'''

# GCN模型
# nfeat输入单元数,shape[1]表示特征矩阵的维度数(列数)
# nhid中间层单元数量
# nclass输出单元数,即样本标签数=样本标签最大值+1
# dropout参数
model = GCN(nfeat=features.shape[1],
            nhid=args.hidden,
            nclass=labels.max().item() + 1,
            dropout=args.dropout)

# 构造一个优化器对象Optimizer,用来保存当前的状态,并能够根据计算得到的梯度来更新参数
# Adam优化器
# lr学习率
# weight_decay权重衰减(L2惩罚)
optimizer = optim.Adam(model.parameters(),
                       lr=args.lr, weight_decay=args.weight_decay)

# 将数据写入cuda便于后续加速
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()  # 意思是把梯度置零,也就是把loss关于weight的导数变成0,pytorch中每一轮batch需要设置optimizer.zero_gra
    output = model(features, adj)  # 前向传播
    '''
        由于在算output时已经使用了log_softmax,这里使用的损失函数就是NLLloss,如果前面没有加log运算,这里就要使用CrossEntropyLoss了,损失函数NLLLoss() 的输入是
        一个对数概率向量和一个目标标签. 它不会为我们计算对数概率,适合最后一层是log_softmax()的网络. 损失函数 CrossEntropyLoss() 与 NLLLoss() 类似,
        唯一的不同是它为我们去做 softmax.可以理解为:CrossEntropyLoss()=log_softmax() + NLLLoss()
    '''
    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.
        # model.eval()固定语句,主要针对不启用BatchNormalization和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))

# 定义测试函数,相当于对已有的模型在测试集上运行对应的loss与accuracy
def test():
    # 固定语句,主要针对不启用BatchNormalization和Dropout
    model.eval()
    # 前向传播
    output = model(features, adj)
    # 最大似然/log似然损失函数,idx_test是1000(500~1499)
    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 逐个epoch进行train,最后test
t_total = time.time()
# epoch数
for epoch in range(args.epochs):
    # 训练
    train(epoch)
print("Optimization Finished!")
# 已用总时间
print("Total time elapsed: {:.4f}s".format(time.time() - t_total))

# Testing
test()

这里附上我的GitHub项目链接,代码包含在pycharm上运行的.py文件和在jupyter上运行的.ipynb文件。
链接:https://github.com/han-2059/GCN.git

你可能感兴趣的:(图神经网络,图卷积神经网络)