代码笔记《深入浅出图神经网络》——5.6 GCN模型

目录

  • 1. 数据预处理
  • 2. GCN层定义
  • 3. 两层GCN层模型
  • 4. 模型构建与数据准备
  • 5. 模型训练与测试

  • 使用的是Cora数据集,该数据集由2708篇论文,及它们之间的引用关系构成的5429条边组成
  • 论文根据主题划分为7类,每篇论文的特征是通过词袋模型得到的,维度为1433
import itertools
import os
import os.path as osp
import pickle
import urllib
from collections import namedtuple

import numpy as np
import scipy.sparse as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.nn.init as init
import torch.optim as optim

1. 数据预处理

  • 定义CoreData类对数据预处理
  • 包含:下载数据、规范化数据并进行缓存以备重复使用

最终得到的数据形式包括:

  • x 节点特征(2708,1433)
  • y 节点对应的标签,包含7个类别
  • adjacency 邻接矩阵(2708,2708)
# 用于保存处理好的数据
# namedtuple对象可以采用item的index以及item的name进行访问
# 定义一个namedtuple类型Data,并包含['x','y','adjacency','train_mask','val_mask','test_mask']属性
Data = namedtuple('Data', ['x','y','adjacency','train_mask','val_mask','test_mask'])
class CoraData(object):
    download_url = 'https://github.com/kimiyoung/planetoid/raw/master/data'
    filenames = ['ind.cora.{}'.format(name) for name in ['x','tx,','allx','y','ty','ally','graph','test.index']]

    def __init__(self,data_root='cora', rebuild=False):
        """
        数据下载、处理、加载等功能,当数据的缓存文件存在时,将使用缓存文件,否则将下载、处理,并缓存到磁盘
        :param data_root: 存放数据的目录,
        原始数据路径:{data_root}/raw
        缓存数据路径:{data_root}/processed_cora.pkl
        :param rebuild: 是否需要重新构建数据集,True 如果缓存数据存在也会重新数据
        """
        self.data_root = data_root
        save_file = osp.join(self.data_root, 'processed_cora.pkl')
        if osp.exists(save_file) and not rebuild:
            print('using cached file: {}'.format(save_file))
            self._data = pickle.load(open(save_file,'rb'))
        else:
            self.maybe_download()
            self._data = self.process_data()
            with open(save_file,'wb') as f:
                pickle.dump(self.data, f)
            print('cached file: {}'.format(save_file))

    @property
    def data(self):
        """返回Data数据对象,包括x, y, adjacency, train_mask, val_mask, test_mask"""
        return self._data

    def maybe_download(self):
        save_path = osp.join(self.data_root, 'raw')
        for name in self.filenames:
            if not osp.exists(osp.join(save_path, name)):
                self.download_data('{}/{}'.format(self.download_url, name), save_path)

    @staticmethod
    # 当某个方法不需要用到对象中的任何资源, 将这个方法改为一个静态方法, 加一个 @staticmethod
    # 加上之后这个方法就和普通函数没什么区别了,只不过写在类里
    def download_data(url, save_path):
        """数据下载工具,当原始数据不存在时,将会下载数据"""
        if not osp.exists(save_path):
            os.makedirs(save_path)
        data = urllib.request.urlopen(url)
        filename = osp.basename(url)

        with open(osp.join(save_path, filename), 'wb') as f:
            f.write(data.read())
        return True

    # 数据处理,得到节点特征和标签、邻接矩阵、训练集、验证集以及测试集
    def process_data(self):
        print('process data ...')
        _, tx, allx, y, ty, ally, graph, test_index = [self.read_data(osp.join(self.data_root, 'raw', name)) for name in self.filenames]
        train_index = np.arange(y.shape[0])
        val_index = np.arange(y.shape[0], y.shape[0]+500)
        sorted_test_index = sorted(test_index)

        x = np.concatenate((allx, tx), axis=0)
        y = np.concatenate((ally, ty), axis=0).argmax(axis=1)

        x[test_index] = x[sorted_test_index]
        y[test_index] = y[sorted_test_index]
        num_nodes = x.shape[0]

        train_mask = np.zeros(num_nodes, dtype=np.bool)
        val_mask = np.zeros(num_nodes, dtype=np.bool)
        test_mask = np.zeros(num_nodes, dtype=np.bool)
        train_mask[train_index] = True
        val_mask[val_index] = True
        test_mask[test_index] = True
        adjacency = self.build_adjacency(graph)
        print("node's feature shape: ", x.shape)
        print("node's label shape: ", y.shape)
        print("adjacency's shape: ", adjacency.shape)
        print("number of training nodes: ", train_mask.sum())
        print("number of validation nodes: ", val_mask.sum())
        print("number of test nodes: ", test_mask.sum())

        return Data(x=x, y=y, adjacency=adjacency, train_mask=train_mask, val_mask=val_mask, test_mask=test_mask)

    @staticmethod
    def build_adjacency(adj_dict):
        """根据邻接表创建邻接矩阵"""
        edge_index = []
        num_nodes = len(adj_dict)
        for src, dst in adj_dict.items():
            # 对角矩阵所以extend两次
            edge_index.extend([src, v] for v in dst)
            edge_index.extend([v, src] for v in dst)

        # 上面的过程边会存在重复添加的情况
        # 比如节点1和节点2邻接,那么当节点1为头节点时,其与节点2的关系会被记录一次
        # 当节点2为头节点时,其与节点1的关系又会被记录一次。所以删除这部分的重复
        edge_index = list(k for k,_ in itertools.groupby(sorted(edge_index)))
        edge_index = np.asarray(edge_index)
        adjacency = sp.coo_matrix((np.ones(len(edge_index)), (edge_index[:,0],edge_index[:,1])), shape=(num_nodes, num_nodes), dtype='float32')
        return adjacency

    @staticmethod
    def read_data(path):
        """使用不同的方法读取原始数据以进一步处理"""
        name = osp.basename(path)
        if name == 'ind.cora.test.index':
            out = np.genfromtxt(path, dtype='int64')
            return out
        else:
            out = pickle.load(open(path, 'rb'), encoding='latin1')
            out = out.toarray() if hasattr(out, 'toarray') else out
            return out
        

知识点1:邻接表 邻接矩阵

数据结构与算法 - 图的邻接表 (思想以及实现方式)

上述代码中,build_adjacency() 函数功能时根据邻接表创建邻接矩阵

  • 首先要明确邻接表的结构
    代码笔记《深入浅出图神经网络》——5.6 GCN模型_第1张图片
  • src 表示头节点,dst 表示表节点,因为是对角矩阵,所以索引互换添加两次
  • 上面的过程边会存在重复添加的情况,比如节点1和节点2邻接,那么当节点1为头节点时,其与节点2的关系会被记录一次,当节点2为头节点时,其与节点1的关系又会被记录一次。所以删除这部分的重复

知识点2:coo_matrix() 坐标格式的稀疏矩阵 coo_matrix((data, (i, j)), [shape=(M, N)])

  • data[:] 矩阵的条目
  • i[:] 矩阵条目的行索引
  • j[:] 矩阵条目的列索引
  • A[i[k],j[k]] = data[k]
# 举例
row  = np.array([0, 3, 1, 0])
col  = np.array([0, 3, 1, 2])
data = np.array([4, 5, 7, 9])
coo_matrix((data, (row, col)), shape=(4, 4)).toarray()

# 结果
array([[4, 0, 9, 0],
       [0, 7, 0, 0],
       [0, 0, 0, 0],
       [0, 0, 0, 5]])

2. GCN层定义

本文根据 X ′ = σ ( L ~ s y m X W ) X'=\sigma (\widetilde{L}_{sym}XW) X=σ(L symXW) 来定义GCN层,代码直接根据定义来实现。

# GCN层定义
class GraphConvolution(nn.Module):
    def __init__(self, input_dim, output_dim, use_bias=True):
        """
        图卷积=L*X*θ
        :param input_dim: int,节点输入特征的维度
        :param output_dim: int, 输出特征维度
        :param use_bias: 是否使用偏置
        """
        super(GraphConvolution, self).__init__()
        self.input_dim = input_dim
        self.output_dim = output_dim
        self.use_bias = use_bias
        self.weight = nn.Parameter(torch.Tensor(input_dim, output_dim))
        if self.use_bias:
            self.bias = nn.Parameter(torch.Tensor(output_dim))
        else:
            self.register_parameter('bias', None)
        self.reset_parameters()

    def reset_parameters(self):
        init.kaiming_uniform_(self.weight)  # kaiming均匀分布
        if self.use_bias:
            init.zeros_(self.bias)

    def forward(self, adjacency, input_feature):
        """
        邻接矩阵是稀疏矩阵,所以在计算时使用稀疏矩阵乘法
        :param adjacency: torch.sparse.FloatTensor 邻接矩阵
        :param input_feature: torch.Tensor 输入特征
        :return:
        """
        support = torch.mm(input_feature, self.weight)  # 注意区分torch.mul(a, b)是矩阵a和b对应位相乘
        output = torch.sparse.mm(adjacency, support)  # 稀疏矩阵相乘
        if self.use_bias:
            output += self.bias
        return output

3. 两层GCN层模型

这节内容是构建模型进行训练,定义一个两层的GCN,输入维度是1433,隐藏层维度设为16,最后一层GCN的输出维度为类别数7,激活函数使用 ReLU

class GcnNet(nn.Module):
    """一个包含两层GraphConvolution的模型"""
    def __init__(self, input_dim=1433):
        super(GcnNet, self).__init__()
        self.gcn1=GraphConvolution(input_dim, 16)
        self.gcn2=GraphConvolution(16, 7)

    def forward(self, adjacency, feature):
        h = F.relu(self.gcn1(adjacency, feature))
        logits = self.gcn2(adjacency, h)
        return logits

4. 模型构建与数据准备

知识点3:scipy.eye() 是scipy包中的一个创建特殊矩阵(单位矩阵E)的方法 eye(N, M=None, k=0, dtype=float)

Scipy 之eye方法介绍

知识点4:正则化拉普拉斯矩阵

【GNN】万字长文带你入门 GCN

代码笔记《深入浅出图神经网络》——5.6 GCN模型_第2张图片

# 模型构建与数据准备
def normalization(adjacency):
    """正则化拉普拉斯矩阵,能有效防止多层网络优化时出现梯度爆炸/消失"""
    # 添加自连接的邻接矩阵
    adjacency += sp.eye(adjacency.shape[0])
    degree = np.array(adjacency.sum(1))  # 度矩阵
    d_hat = sp.diags(np.power(degree, -0.5).flatten())
    return d_hat.dot(adjacency).dot(d_hat).tocoo()

# 超参数定义
learning_rate = 0.1
weight_decay = 5e-4
epochs = 200
# 模型实例化、损失函数、优化器定义
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = GcnNet().to(device)
# 损失函数使用交叉熵
criterion = nn.CrossEntropyLoss().to(device)
# 优化器Adam
optimizer = optim.Adam(model.parameters(), lr=learning_rate, weight_decay=weight_decay)

# 加载数据,转化为torch.Tensor
dataset = CoraData().data
x = dataset.x / dataset.x.sum(1, keepdims=True)  # 归一化数据,使每一行和为1
tensor_x = torch.from_numpy(x).to(device)
tensor_y = torch.from_numpy(dataset.y).to(device)
tensor_train_mask = torch.from_numpy(dataset.train_mask).to(device)
tensor_val_mask = torch.from_numpy(dataset.val_mask).to(device)
tensor_test_mask = torch.from_numpy(dataset.test_mask).to(device)
normalize_adjacency = normalization(dataset.adjacency)  # 正则化邻接矩阵
indices = torch.from_numpy(np.asarray([normalize_adjacency.row, normalize_adjacency.col]).astype('int64')).long()
values = torch.from_numpy(normalize_adjacency.data.astype(np.float32))
tensor_adjacency = torch.sparse.FloatTensor(indices, values,(2708,2708)).to(device)

5. 模型训练与测试

知识点5:torch.eq() 比较元素是否相等,第二个元素可以是一个数,也可以是和第一个参数同类型形状的张量 torch.eq(input, other, out=None)

Pytorch学习之torch----比较操作(Comparison Ops)

# 举例
a = torch.Tensor([[1, 2], [3, 4]])
b = torch.Tensor([[1, 1], [4, 4]])
torch.eq(a, b)

# 结果
tensor([[1, 0],
        [0, 1]], dtype=torch.uint8)
# 模型训练与测试
def train():
    loss_history = []
    val_acc_history = []
    model.train()  # train模式
    train_y = tensor_y[tensor_train_mask]
    for epoch in range(epochs):
        logits = model(tensor_adjacency, tensor_x)  # 前向传播
        train_mask_logits = logits[tensor_train_mask]  # 只选择训练节点进行监督
        loss = criterion(train_mask_logits, train_y)  # train_mask_logits预测值,train_y标签值
        optimizer.zero_grad()
        loss.backward()  # 反向传播计算参数的梯度
        optimizer.step()  # 使用优化方法进行梯度更新
        train_acc = test(tensor_train_mask)  # 计算当前模型在训练集上的准确率
        val_acc = test(tensor_val_mask)  # 计算当前模型在验证集上的准确率

        # 记录训练过程中损失值和准确率的变化
        loss_history.append(loss.item())
        val_acc_history.append(val_acc.item())
        print('epoch {:03d}: loss {:.4f}, train_acc {:.4f}, val_acc {:.4f}'.format(epoch,loss.item(),train_acc.item(),val_acc.item()))
    return loss_history, val_acc_history

def test(mask):
    model.eval()  # 切换到验证模式,不更新参数
    with torch.no_grad():
        logits = model(tensor_adjacency, tensor_x)
        test_mask_logits = logits[mask]
        predict_y = test_mask_logits.max(1)[1]
        accuracy = torch.eq(predict_y, tensor_y[mask]).float().mean()
    return accuracy

if __name__=='__main__':
    train()

你可能感兴趣的:(实例)