今天学习下GCN,是图神经网络里面比较经典和基础的一个模型架构。

首先从他起源开始,图像识别对象是图片,是一个二维的结构,于是人们发明了CNN这种神奇的模型来提取图片的特征。CNN的核心在于它的kernel,kernel是一个个小窗口,在图片上平移,通过卷积的方式来提取特征。这里的关键在于图片结构上的平移不变性:一个小窗口无论移动到图片的哪一个位置,其内部的结构都是一模一样的,因此CNN可以实现参数共享。这就是CNN的精髓所在。

RNN系列,它的对象是自然语言这样的序列信息,是一个一维的结构,RNN就是专门针对这些序列的结构而设计的,通过各种门的操作,使得序列前后的信息互相影响,从而很好地捕捉序列的特征。

图片或者语言,都属于欧式空间的数据,因此才有维度的概念,欧式空间的数据的特点就是结构很规则。但是现实生活中,其实有很多很多不规则的数据结构,典型的就是图结构,或称拓扑结构,如社交网络、化学分子结构、知识图谱等等;即使是语言,实际上其内部也是复杂的树形结构,也是一种图结构;而像图片,在做目标识别的时候,我们关注的实际上只是二维图片上的部分关键点,这些点组成的也是一个图的结构。GCN,图卷积神经网络,实际上跟CNN的作用一样,就是一个特征提取器,只不过它的对象是图数据。本文侧重于快速理解掌握GCN,对于其深层次的算法原理,网上讲解的资料也比较多,我理解也不算很透彻,本文资料参考自网络。

假设我们手头有一批图数据,其中有N个节点(node),每个节点都有自己的特征,我们设这些节点的特征组成一个N×D维的矩阵X,然后各个节点之间的关系也会形成一个N×N维的矩阵A,也称为邻接矩阵(adjacency matrix)。X和A便是我们模型的输入。也就是说我们需要拿到整张图每个节点自己的信息以及他相关联的节点,整体作为模型的输入。

上面就是GCN的核心公式,一步步分析,H和W,H是特征,W是学习参数,理解成权重。这部分就是全连接网络的公式。重点是前面部分:

我们以这个图为例:

A~就是A矩阵加上一个单位阵I,A矩阵是邻接矩阵,如上,相当于是考虑这个节点周围连接的节点这个信息,如果只是A,在和特征矩阵H相乘的时候,只会计算一个node的所有邻居的特征的加权和,该node自己的特征却被忽略了。因此,可以做一个小小的改动,给A加上一个单位矩阵 I ,这样就让对角线元素变成1了。

D指的是,把A按照行求和。就叫做 度矩阵,指的每个节点对应的度数。

对于操作: A*H,表示对每个节点,其特征等于其链接的节点的特征之和,相当于将周围节点的特征聚合到一起,如果是A~就相当于对每个节点,加了一条指向自己的链接。

这里再看左右各乘一个D~的-1/2次方的作用,相当于对整个节点进行归一化操作。

最后再接一个MLP,从而实现所需的分类操作。

import torch
import torch.nn as nn
import dgl.function as fn
import torch.nn.functional as F
from matplotlib import pyplot as plt
import dgl
import torch
import torch.optim as optim
import torch.nn as nn

class GCNLayer(nn.Module):
    def __init__(self, in_feats, out_feats, bias=True):
        super(GCNLayer, self).__init__()
        self.weight = nn.Parameter(torch.Tensor(in_feats, out_feats))
        if bias:
            self.bias = nn.Parameter(torch.zeros(out_feats))
        else:
            self.bias = None

        self.reset_parameter()

    def reset_parameter(self):
        nn.init.xavier_uniform_(self.weight)

    def forward(self, g, h):
        with g.local_scope():
            h = torch.matmul(h, self.weight)
            g.ndata['h'] = h * g.ndata['norm']
            g.update_all(message_func=fn.copy_u('h', 'm'),
                         reduce_func=fn.sum('m', 'h'))
            h = g.ndata['h']
            h = h * g.ndata['norm']
            if self.bias is not None:
                h = h + self.bias
            return h


class GCNModel(nn.Module):
    def __init__(self, in_feats, h_feats, num_classes, bias=True):
        super(GCNModel, self).__init__()
        self.conv1 = GCNLayer(in_feats, h_feats, bias)
        self.conv2 = GCNLayer(h_feats, num_classes, bias)

    def forward(self, g, in_feat):
        h = self.conv1(g, in_feat)
        h = F.relu(h)
        h = self.conv2(g, h)
        return h

def drawPlot(heights, fname, ylabel, legends=None):
    """
    fname:save file name
    marker:shape of dot
    """
    plt.figure()
    x = [i for i in range(1, len(heights[0]) + 1)]
    # 绘制训练集和测试集上的loss变化曲线子图
    plt.xlabel("epoch")
    plt.ylabel(ylabel)
    for i in range(len(heights)):
        plt.plot(x, heights[i])
    if legends:
        plt.legend(legends)
    plt.savefig("../data/{}".format(fname))
    plt.show()


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


def train(g, model, optimizer, loss_fn, epochs):
    best_val_acc, best_test_acc = 0, 0
    # gain features and labels of datasets
    features, labels = g.ndata['feat'], g.ndata['label']
    train_mask, val_mask, test_mask = g.ndata['train_mask'], g.ndata[
        'val_mask'], g.ndata['test_mask']
    train_acc_list, val_acc_list, test_acc_list = [], [], []
    for e in range(epochs):
        logits = model(g, features)  # (N, label_nums)
        pred = logits.argmax(1)
        loss = loss_fn(logits[train_mask], labels[train_mask])
        # cal trian acc
        train_acc = (pred[train_mask] == labels[train_mask]).float().mean()
        # cal val acc
        val_acc = (pred[val_mask] == labels[val_mask]).float().mean()
        # cal test acc
        test_acc = (pred[test_mask] == labels[test_mask]).float().mean()

        train_acc_list.append(train_acc.item())
        val_acc_list.append(val_acc.item())
        test_acc_list.append(test_acc.item())

        # save result based on valid dataset
        if best_val_acc < val_acc:
            best_val_acc = val_acc
            best_test_acc = test_acc

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        if (e + 1) % 5 == 0:
            print(
                'Epoch {}, loss: {:.3f}, train acc: {:.3f}, val acc: {:.3f} (best {:.3f}), test acc: {:.3f} (best {:.3f})'
                .format(e + 1, loss, train_acc, val_acc, best_val_acc,
                        test_acc, best_test_acc))
    drawPlot([train_acc_list, val_acc_list, test_acc_list], "accuracy.png",
             "Acc", ["train", "val", "test"])


if __name__ == "__main__":
    epochs = 200
    hidden_size = 32
    lr = 0.01
    weight_decay = 5e-4
    # download and loading dataset
    dataset = dgl.data.CoraGraphDataset(raw_dir="../data/cora/")
    g = dataset[0]
    # add self-loop
    g = dgl.remove_self_loop(g)
    g = dgl.add_self_loop(g)
    # gain degree matrix
    degs = g.out_degrees().float()
    # cal D^{-1/2}
    norm = torch.pow(degs, -0.5)
    norm[torch.isinf(norm)] = 0
    g.ndata['norm'] = norm.unsqueeze(1)

    model = GCNModel(in_feats=g.ndata['feat'].shape[1],
                     h_feats=hidden_size,
                     num_classes=dataset.num_classes)

    if torch.cuda.is_available():
        g = g.to(device)
        model = model.to(device)

    optimizer = optim.Adam(model.parameters(),
                           lr=lr,
                           weight_decay=weight_decay)
    loss_fn = nn.CrossEntropyLoss()
    train(g, model, optimizer, loss_fn, epochs)

带上你的千军万马,虽然终究难免孤军奋战