GraphSAGE算法 和 代码解析

聚合邻居
GraphSAGE研究了聚合邻居操作所需的性质,并且提出了几种新的聚合操作(aggregator),需满足如下条件:
(1)聚合操作必须要对聚合节点的数量做到自适应。不管节点的邻居数量怎么变化,进行聚合操作后输出的维度必须是一致的,一般是一个统一长度的向量。
(2)聚合操作对聚合节点具有排列不变性。对于我们熟知的2D图像数据与1D序列数据,前者包含着空间顺序,后者则包含着时序顺序,但图数据本身是一种无序的数据结构,对于聚合操作而言,这就要求不管邻居节点的排列顺序如何,输出的结果总是一样的。比如Agg(v_{1},v_{2})=Agg(v_{2},v_{1})。

当然,从模型优化的层面来看,该种聚合操作还必须是可导的。有了上述性质的保证,聚合操作就能对任意输入的节点集合做到自适应。比较简单的符合这些性质的操作算子有:

(1)平均/加和(mean/sum)聚合算子。逐元素的求和与取均值是最直接的一种聚合算子,这类操作是GCN中图卷积操作的线性近似,下面给出了求和的聚合公式,W和b是聚合操作的学习参数:

(1)平均/加和(mean/sum)聚合算子。逐元素的求和与取均值是最直接的一种聚合算子,这类操作是GCN中图卷积操作的线性近似,下面给出了求和的聚合公式,W和b是聚合操作的学习参数:

在了解了上述两个机制之后,我们来看看GraphSAGE实现小批量训练形式的具体过程。
输入:图G=(V,E);输入特征{x_{v},∀v∈B};层数K;权重矩阵W^{(k)},∀k∈{1,…,K};非线性函数σ;聚合操作Agg^{(k)},∀k∈{1,…,K};邻居采样函数N^{(k)}:v→2^{v},∀k∈{1,…,K}。

输出:所有节点的向量表示z_{v},v∈B。
GraphSAGE 小批量训练的过程

代码清单7-1所示算法的基本思路是先将小批集合B内的中心节点聚合操作所要涉及的k阶子图一次性全部遍历出来,然后在这些节点上进行K次聚合操作的迭代式计算。算法的第1~7行就是描述遍历操作的。我们可以这样来理解这个过程:要想得到某个中心节点第k层的特征,就需要采样其在第(k–1)层的邻居,然后对第(k–1)层的每个节点采样其第(k–2)层的邻居,依此类推,直到采样完第1层的所有邻居为止。需要注意的是,每层的采样函数可以单独设置,具体可以参考本节采样邻居部分的内容。
代码清单7-1的第9~15行是第二步—聚合操作,其核心体现在第11~13行的3个公式上面。第11行的式子是调用聚合操作完成对每个节点邻 居特征的整合输出,第12行是将聚合后的邻居特征与中心节点上一层的特征进行拼接,然后送到一个单层网络里面得到中心节点新的特征向量,第13行对节点的特征向量进行归一化处理,将所有节点的向量都统一到单位尺度上。对这3行操作迭代K次就完成了对B内所有中心节点特征向量的提取。
值得一提的是,GraphSAGE算法的计算过程完全没有拉普拉斯矩阵的参与,每个节点的特征学习过程仅仅只与其k阶邻居相关,而不需要考虑全图的结构信息,这样的方法适合做归纳学习(Inductive Learning)。归纳学习是指可以对在训练阶段见不到的数据(在图数据中,可以指新的节点,也可以指新的图)直接进行预测而不需要重新训练的 的学习方法,与之相对的是转导学习(Transductive Learning),指所有的数据在训练阶段都可以拿到,学习过程是作用在这个固定的数据上的,一旦数据发生改变,需要重新进行学习训练,典型的比如图上的随机游走算法,一旦图数据发生变动,所有节点的表示学习都需要重新进行。对于GraphSAGE算法而言,对于新出现的节点数据,只需要遍历得到k阶子图,就可以代入模型进行相关预测。这种特性使得该算法具有十分巨大的应用价值。

首先数据处理 cora数据集

import os
import os.path as osp
import pickle
import numpy as np
import itertools
import scipy.sparse as sp
import urllib
from collections import namedtuple

Data = namedtuple('Data',['x','y','adjacency_dict',
                         '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):
        """Cora数据,包括数据下载,处理,加载等功能
                当数据的缓存文件存在时,将使用缓存文件,否则将下载、进行处理,并缓存到磁盘

                处理之后的数据可以通过属性 .data 获得,它将返回一个数据对象,包括如下几部分:
                    * x: 节点的特征,维度为 2708 * 1433,类型为 np.ndarray
                    * y: 节点的标签,总共包括7个类别,类型为 np.ndarray
                    * adjacency_dict: 邻接信息,,类型为 dict
                    * train_mask: 训练集掩码向量,维度为 2708,当节点属于训练集时,相应位置为True,否则False
                    * val_mask: 验证集掩码向量,维度为 2708,当节点属于验证集时,相应位置为True,否则False
                    * test_mask: 测试集掩码向量,维度为 2708,当节点属于测试集时,相应位置为True,否则False

                Args:
                -------
                    data_root: string, optional
                        存放数据的目录,原始数据路径: {data_root}/raw
                        缓存数据路径: {data_root}/processed_cora.pkl
                    rebuild: boolean, optional
                        是否需要重新构建数据集,当设为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 process_data(self):
        """
        处理数据,得到节点特征和标签,邻接矩阵,训练集、验证集以及测试集
        引用自:https://github.com/rusty1s/pytorch_geometric
        """
        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)#argmax 0:按列计算,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_dict = graph
        print("Node's feature shape: ", x.shape)

        print("Node's label shape: ", y.shape)
        print("Adjacency's shape: ", len(adjacency_dict))
        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_dict = adjacency_dict,train_mask = train_mask,
                    val_mask=val_mask,test_mask=test_mask)

    def maybe_download(self):
        save_path = os.path.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
    def build_adjacency(adj_dict):
        """根据邻接表创建邻接矩阵"""
        edge_index = []
        num_nodes = len(adj_dict)
        for src, dst in adj_dict.items():
            edge_index.extend([src,v] for v in dst)
            edge_index.extend([v,src] for v in dst)

        #去除重复的边
        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

    @staticmethod
    def download_data(url,save_path):
        """数据下载工具,当原始数据不存在时将会进行下载"""
        if not os.path.exists(save_path):
            os.makedirs(save_path)
        data = urllib.request.urlopen(url)
        filename = os.path.split(url)[-1]

        with open(os.path.join(save_path,filename), 'wb') as f:
            f.write(data.read())

        return True




然后是 取样数据集 取两层 每层邻接点数量都是10 sampling.py

import numpy as np

def sampling(src_nodes, sample_num,neighbor_table):
    """根据源节点采样指定数量的邻居节点,注意使用的是有放回的采样;
    某个节点的邻居节点数量少于采样数量时,采样结果出现重复的节点

    Arguments:
        src_nodes {list, ndarray} -- 源节点列表
        sample_num {int} -- 需要采样的节点数
        neighbor_table {dict} -- 节点到其邻居节点的映射表

    Returns:
        np.ndarray -- 采样结果构成的列表
    """
    results = []
    for sid in src_nodes:
        # 从节点的邻居中进行有放回地进行采样
        res = np.random.choice(neighbor_table[sid], size = (sample_num,))
        results.append(res)
    return np.asarray(results).flatten() #返回的形状是(160,)


def multihop_sampling(src_nodes, sample_nums, neighbor_table):
    """根据源节点进行多阶采样

    Arguments:
        src_nodes {list, np.ndarray} -- 源节点id
        sample_nums {list of int} -- 每一阶需要采样的个数
        neighbor_table {dict} -- 节点到其邻居节点的映射

    Returns:
        [list of ndarray] -- 每一阶采样的结果
    """
    #sampling_nums = 2

    sampling_result = [src_nodes]#src_nodes 是随机的16个点的 np数组
    #src_nodes = [10,10]
    # >> > seasons = ['Spring', 'Summer', 'Fall', 'Winter']
    # >> > list(enumerate(seasons))
    # [(0, 'Spring'), (1, 'Summer'), (2, 'Fall'), (3, 'Winter')]

    for k, hopk_num in enumerate(sample_nums):
        #k,hopk_num = (0,10) (1,10)
        hopk_result = sampling(sampling_result[k], hopk_num,neighbor_table) #sampling_result[0] 为16个点,得到返回 np.array为160个点
        sampling_result.append(hopk_result)#然后result[1]为160个点序号

    return sampling_result

然后就是网络层,嵌套了三个网络

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.nn.init as init

class NeighborAggregator(nn.Module): #邻居聚类模型
    def __init__(self, input_dim, output_dim,
                 use_bias = False,aggr_method = "mean"):
        """聚合节点邻居

        Args:
            input_dim: 输入特征的维度
            output_dim: 输出特征的维度
            use_bias: 是否使用偏置 (default: {False})
            aggr_method: 邻居聚合方式 (default: {mean})
        """
        super(NeighborAggregator,self).__init__()
        self.input_dim = input_dim
        self.output_dim = output_dim
        self.use_bias = use_bias
        self.aggr_method = aggr_method
        self.weight = nn.Parameter(torch.Tensor(input_dim,output_dim))#将矩阵向量 改为可以训练改变的参数形式 一个点为一维,一个特征为一列
        if self.use_bias:
            self.bias = nn.Parameter(torch.Tensor(self.output_dim))#偏置值 b 为输出的 维度
        self.reset_parameters()

    def reset_parameters(self):
        init.kaiming_uniform_(self.weight)
        if self.use_bias:
            init.zeros_(self.bias)


    def forward(self,neighbor_feature):
        # print(neighbor_feature.shape) # [16,10,1433]
        # m = input()
        if self.aggr_method == "mean":
            aggr_neighbor = neighbor_feature.mean(dim = 1) #根据第二维求 平均值的得到[16,1433]
        elif self.aggr_method == "sum":
            aggr_neighbor = neighbor_feature.sum(dim = 1)
        elif self.aggr_method == "max":
            aggr_neighbor = neighbor_feature.max(dim = 1)
        else:
             raise ValueError("Unknown aggr type, expected sum, max, or mean, but got {}"
                         .format(self.aggr_method))

        # print(aggr_neighbor.shape)  #[16,1433]
        # m = input()

        neighbor_hidden = torch.matmul(aggr_neighbor,self.weight)
        if self.use_bias:
            neighbor_hidden += self.bias

        return neighbor_hidden

    def extra_repr(self):
        return 'in_features = {}, out_feature = {},aggr_method = {}'.format(self.input_dim,self.output_dim,self.aggr_method)



class SageGCN(nn.Module): #和自身结合加上调用邻居聚类模型
    def __init__(self,input_dim,hidden_dim,
                 activation = F.relu,
                 aggr_neighbor_method = "mean",
                 aggr_hidden_method = "sum"):
        """SageGCN层定义

        Args:
            input_dim: 输入特征的维度
            hidden_dim: 隐层特征的维度,
                当aggr_hidden_method=sum, 输出维度为hidden_dim
                当aggr_hidden_method=concat, 输出维度为hidden_dim*2
            activation: 激活函数
            aggr_neighbor_method: 邻居特征聚合方法,["mean", "sum", "max"]
            aggr_hidden_method: 节点特征的更新方法,["sum", "concat"]
        """
        super(SageGCN,self).__init__()
        assert aggr_neighbor_method in ["mean","sum","max"]
        assert aggr_hidden_method in ["sum","concat"]
        self.input_dim = input_dim
        self.hidden_dim = hidden_dim
        self.aggr_neighbor_method = aggr_neighbor_method
        self.aggr_hidden_method = aggr_hidden_method
        self.activation = activation
        self.aggregator = NeighborAggregator(input_dim,hidden_dim,
                                             aggr_method=aggr_neighbor_method)

        self.weight = nn.Parameter(torch.Tensor(input_dim,hidden_dim))
        self.reset_parameters()


    def reset_parameters(self):
        init.kaiming_uniform_(self.weight)# 初始化参数 成为线性参数的初始化

    def forward(self, src_node_features, neighbor_node_features):
        neighbor_hidden = self.aggregator(neighbor_node_features) #1.先将邻居聚类
        self_hidden = torch.matmul(src_node_features,self.weight)#2.将自身节点*上系数矩阵

        if self.aggr_hidden_method == "sum":
            hidden = self_hidden + neighbor_hidden
        elif self.aggr_hidden_method == "concat":
            hidden = torch.cat([self_hidden,neighbor_hidden], dim = 1) # dim =1  按列拼接 即[[1,1][1,1]] cat 为 [1,1,1,1]
        else:
            raise ValueError("Expected sum or concat, got {}".format(self.aggr_hidden))


        if self.activation:
            return self.activation(hidden)
        else:
            return hidden

    def extra_repr(self):
        output_dim = self.hidden_dim if self.aggr_hidden_method == "sum" else self.hidden_dim * 2
        return 'in_features = {}, out_features = {}, aggr_hidden_method = {}'.format(
            self.input_dim,output_dim,self.aggr_hidden_method)


class GraphSage(nn.Module):
    def __init__(self,input_dim,hidden_dim,num_neighbors_list):
        super(GraphSage,self).__init__()
        self.input_dim = input_dim
        self.hidden_dim = hidden_dim
        self.num_neighbors_list = num_neighbors_list # num_neighbors_list 为[10,10] 即 每层的相邻结点都是10
        self.num_layers = len(num_neighbors_list)# =2
        self.gcn = nn.ModuleList()#https://blog.csdn.net/qq_38863413/article/details/104118055 说直接点就是套个板子,空的,然后利用append 加网络层
        self.gcn.append(SageGCN(input_dim,hidden_dim[0]))# input_dim = 1433 hidden_dim[0] = 128  # hidden_dim[0] = 128 hidden_dim = [128,7] for参数是传进了模型的init中
        for index in range(0,len(hidden_dim) - 2): # 没执行 用于多于两层的情况,把中间每层的参数 长宽 输入进去
            self.gcn.append(SageGCN(hidden_dim[index], hidden_dim[index+1]))

        self.gcn.append(SageGCN(hidden_dim[-2],hidden_dim[-1],activation = None))#最后一层参数的长宽


    def forward(self,node_features_list):
        hidden = node_features_list
        for l in range(self.num_layers):
            next_hidden = []
            gcn = self.gcn[l]#两层 第二层的weight 是 128* 7
            for hop in range(self.num_layers - l): #指该层参数的大小,和特征大小相同

                src_node_features = hidden[hop]
                src_node_num = len(src_node_features)# 第一层为16 第二层为 160
                neighbor_node_features = hidden[hop+1]\
                    .view((src_node_num,self.num_neighbors_list[hop], -1))#view的作用是将 一个向量切成自己想要的形状
                # hidden[hop+1].shape = torch.Size([160, 1433]) src_node_num = 16,self.num_neighbors_list[hop] =10, -1 的意思
                # 是让计算机自己算剩下的一维 执行完后得到的 第一次 neighbor_node_features.shape = torch.Size([16, 10, 1433]),第一维被切了 160 变 [16,10] 第二次就变成了 [160,10,1433]
                h = gcn(src_node_features,neighbor_node_features)#src_node_features.shape torch.Size([16, 1433]) 这参数传进了SageGCN 的 forward中
                next_hidden.append(h)
            hidden = next_hidden
        return hidden[0]

    def extra_repr(self):
        return 'in_features = {}, num_neighbors_list={}'.format(self.input_dim,self.num_neighbors_list)








主程序,每一步都写了解析 main.py

"""
基于Cora的GraphSage示例
"""
import torch

import numpy as np
import torch.nn as nn
import torch.optim as optim
from net import GraphSage
from data import CoraData
from sampling import multihop_sampling

from collections import namedtuple
INPUT_DIM = 1433 #输入维度
#Note: 采样的邻居阶数需要与GCN的层数保持一致
HIDDEN_DIM = [128,7] #隐藏单元节点数 即1433 ->128 ->7
NUM_NEIGHBORS_LIST = [10,10]# 每阶采样邻居的节点数
assert len(HIDDEN_DIM) == len(NUM_NEIGHBORS_LIST)
BTACH_SIZE = 16  # 批处理大小
EPOCHS = 20
NUM_BATCH_PER_EPOCH = 20 # 每个epoch循环的批次数
LEARNING_RATE = 0.01

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"

Data = namedtuple('Data',['x','y','adjacency_dict','train_mask','val_mask','test_mask'])

data = CoraData().data
x = data.x / data.x.sum(1,keepdims = True) # 归一化数据,使得每一行和为1
# sum(x,axis=1,keepdims=True)  keepdims=True    用途:保持原数组的维度 https://blog.csdn.net/maple05/article/details/107943144

train_index = np.where(data.train_mask)[0] #data.train_mask 是 boole类型的数组,np.where()为true的输出下标,不加[0]的话是 数组的 数组 即(数组,) 所以要得到数组 取[0]

train_label = data.y[train_index]
test_index = np.where(data.test_mask)[0]
model = GraphSage(input_dim=INPUT_DIM, hidden_dim = HIDDEN_DIM,
                  num_neighbors_list = NUM_NEIGHBORS_LIST).to(DEVICE)#model 要 to(DEVICE) 这里是给这个模型和里面的两个模型都初始化了

print(model)
criterion = nn.CrossEntropyLoss().to(DEVICE)
optimizer = optim.Adam(model.parameters(), lr = LEARNING_RATE, weight_decay = 5e-4)

def train():
    model.train()
    for e in range(EPOCHS):
        for batch in range(NUM_BATCH_PER_EPOCH):
            batch_src_index = np.random.choice(train_index,size=(BTACH_SIZE,))#随机选取初始点
            batch_src_label = torch.from_numpy(train_label[batch_src_index]).long().to(DEVICE)#构造的numpy要放到cpu上 所以用 to(DEVICE)
            batch_sampling_result = multihop_sampling(batch_src_index,NUM_NEIGHBORS_LIST,data.adjacency_dict)#返回的 result[0]是 16个点,result[1]是160个点,result[2]是1600个点
            batch_sampling_x = [torch.from_numpy(x[idx]).float().to(DEVICE) for idx in batch_sampling_result] #https://blog.csdn.net/qq_40178291/article/details/99847180
            # print(len(batch_sampling_x)) # 结果是3
            # print(len(batch_sampling_x[0]))#结果是16
            # m = input()
            batch_train_logits = model(batch_sampling_x)#直接送到 forward中了
            loss = criterion(batch_train_logits, batch_src_label)
            optimizer.zero_grad()#https://blog.csdn.net/scut_samon/article/details/82414730 其实是梯度导数的初始化
            loss.backward() #反向传播计算参数的梯度
            optimizer.step() #使用优化方法进行梯度更新
            print("Epoch {:03d} Batch {:03d} Loss: {:.4f}".format(e, batch, loss.item()))
        tt()

def tt():
    model.eval()
    with torch.no_grad(): # https://zhuanlan.zhihu.com/p/106960078 不能追踪梯度

        test_sampling_result = multihop_sampling(test_index,NUM_NEIGHBORS_LIST,data.adjacency_dict)
        test_x = [torch.from_numpy(x[idx]).float().to(DEVICE) for idx in test_sampling_result]
        test_logits = model(test_x)
        # print(test_logits.shape) #[1000,7]
        # m = input()
        #data.y[test_index] 是np类型的
        test_label = torch.from_numpy(data.y[test_index]).long().to(DEVICE)#将np类型的放到cpu上
        predict_y = test_logits.max(1)[1] #https://blog.csdn.net/qq_41800366/article/details/86313052
        accuarcy = torch.eq(predict_y,test_label).float().mean().item()
        print("Test Accuracy: ",accuarcy)

if __name__ == '__main__':
    train()

 

  • 4
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值