基于GCN和DGL实现的图上 node 分类, 值得一看!!!

基于GCN和DGL实现的图上 node 分类, 值得一看!!!


书接上文,我们在 GraphSage与DGL实现同构图 Link 预测,通俗易懂好文强推 这篇文章中,初步讲解了 图上机器任务 的大致分类以及图上 有无监督任务loss 的区别,并基于作者自己的理解分析了 图上消息传播 的过程。在上文中,我们使用 GraphSage与DGL技术实现了无监督情境下的 链接预测 ,本文我们将继续使用DGL和GCN网络来实现图上的有监督学习之一的 节点分类 ,下面让我我们开始吧~


(1) 图上Node分类任务基础

从上篇文章文章中我们知道:在图上去预测两个节点之间的关系(或边)是否存在的任务,是 典型的链接(关系)预测 任务。在图上 基于图上结构之间的关系对某个节点进行定性性质的分析 ,例如判断一个用户是否是异常用户等,这种任务则是典型的 节点(Node)分类 任务,对每个节点我们是需要 打上label数据 的,二分类就是 0或1 。

从这里我们也可以 推断 出: 和 传统机器学习任务 类似,如果要进行回归任务的话,我们只需要对节点的label数据赋予需要回归出的浮点数即可。

这里我们需要明确的一点就是:我们无论是进行图上节点的 分类还是回归 任务,我们在最后一层DNN的前一层得到logit的时候,该 logit的数据其实携带了该节点周围的邻居节点的关系信息 ,我们是融合了 该节点 以及其 邻居节点 的信息(通常以Embeding的形式存在) 来 对当前节点 进行的 定性形式 的判断。

并且这里所说的节点以及邻居节点的信息,是指节点的 各个属性 ,比较简单的任务可能就是对每个节点就是一个综合的embeding ,而对于复杂的任务,每个节点可能有很多个向量,则我们就要对 各个向量 分别写 适合该属性数据特性 的聚合处理逻辑以及后面接入全链接DNN,也可以把得到各个属性的embeding用一个简单的网络 融合 之后在进行 信息传播 均是可以的。

这里和传统机器学习的不同就是: 传统机器学习做决断用到的信息 都是 独立同分布 的,而这里做决断用到的部分信息是 依赖图的空间结构上处于邻居位置的信息 的,非独立同分布 的。

历史图相关文章链接如下:

(1)一文揭开图机器学习的面纱,你确定不来看看吗

(2)graphSage还是HAN ?吐血力作综述Graph Embeding 经典好文

(3) 看这里,使用docker部署图深度学习框架GraphLearn使用说明

(4) GraphSage与DGL实现同构图 Link 预测,通俗易懂好文强推

本文这里主要讲解的是 基于GCN和DGL实现的图上 Node 分类任务 ,对图上消息传播与各种任务不太熟悉的同学,可以先去看看上面列出的几篇历史文章,其中对图的知识有了大概的讲解。

下面让我们结合代码开始今天的学习吧~


(2) 采样与算子适用性说明

从前一篇文章 GraphSage与DGL实现同构图 Link 预测,通俗易懂好文强推 中,我们可以知道:虽然我们使用了dgl 官方实现的Graph Sage 算子,但是Sage算子的提出最初主要是为了解决GCN任务理论上每次均把所有节点以及他们的邻居节点均 load进内存 导致内存放不下或则 训练速度缓慢 的问题,以及对 没见过的节点进行预测 的问题。

对于上面第二点,我们要引入一个概念就是 直推式学习 (transductive learning)归纳式学习 (Inductive learning) 。传统的GCN就是属于直推式学习, 训练 节点embedding 的时候要看到全图的节点, 其根本原因是因为它使用了 拉普拉斯矩阵 。而 Sage算子 ,则是归纳式学习,因为它的基本逻辑是可以 用“你“的邻居信息归纳出没见过的”你“的信息 ,可以处理未见过你但知道你邻居这种类似的问题。
例如:你在北京的朋友都是程序员或则从事互联网的职业,那你在很大程度上也是程序员或从事互联网的职业…

在前一篇文章中,我们虽然使用了 GraphSage算子,但是我们并没有使用Sage算子与底层原理相契合的 邻居采样 的方式。因为链接预测需要对构造没出现过的边作为负样本,文章中选择了对每个种子节点进行 全局负采样 ,这里的采样就是 必须 的。 而对于节点分类这种任务,属于有监督学习任务,节点的正负由外界赋予的label来确定,所以节点采样是 非必须 的,我们 进行采样必然是为了解决某一类问题

Sage算子 具有 采样聚合 两种的特性,我们分别在采样和聚合的阶段去利用这种特性,当然这两种都用也是可以的。我们如果不采样,则只是利用来它多种的聚合方式的特性,去聚合所有的邻居节点。当然我们也可以用采样的方式,对每个节点去采样一定数目的邻居,参与Sage算子的聚合阶段。

当然,除了对每个节点的 邻居进行采样 ,我们通常也会对大数据集合进行 分batch训练 ,这两种方式都可以 减小数据对内存 的压力。在DGL中,我们可以采用
dgl.dataloading.NodeDataLoader
接口进行针对 节点 的采样操作,而对 边采样 则可以使用 dgl.dataloading.EdgeDataLoader 接口,可以同时对上述说的两个方面进行工程上的实现,**dgl是我心目中处理图机器学习的 yyds ** 这两个采样算子的接口说明,作者将在后续的文章中将继续写一篇介绍比较复杂的任务的文章,在其中将对算子的使用进行说明~

这里主要拿Sage算子来阐述,主要是想说明:针对不同的任务,例如节点和边的任务,采用不同的算子,用这个算子的什么特性我们都是可以根据自己的数据流程灵活定制,我们在拿到代码的时候,可以去深入思考一下,看使用了什么特性以及怎么搭配能更好的处理我们的任务。这里不光针对Sage算子的采样与聚合,以及后面的同异构图的Attention也是如此。


(3) 代码时光

注意:这里的代码是 基于 gcn 和 dgl 实现的痛构图上有监督的节点分类任务 .

开篇吼一嗓子 , talk is cheap , show me the code !!! 下面,就让我们开始coding 吧~

(3.1) 导包

@ 欢迎关注微信公众号:算法全栈之路

import argparse
import torch
import torch.nn as nn
import torch.nn.functional as F
import dgl.nn as dglnn
from dgl import AddSelfLoop
from dgl.data import CiteseerGraphDataset, CoraGraphDataset, PubmedGraphDataset

老规矩,先导包。跑起来这个任务,仅仅需要这些包就可以。


(3.2) 模型结构定义

这里我们采用 dgl 官方实现的 graphConv 算子进行邻居节点信息的聚合,不进行邻居节点的采样。

@ 欢迎关注微信公众号:算法全栈之路

class GCN(nn.Module):
    def __init__(self, in_size, hid_size, out_size):
        super().__init__()
        self.layers = nn.ModuleList()
        # two-layer GCN
        self.layers.append(
            dglnn.GraphConv(in_size, hid_size, activation=F.relu)
        )
        self.layers.append(dglnn.GraphConv(hid_size, out_size))
        self.dropout = nn.Dropout(0.5)

    def forward(self, g, features):
        h = features
        for i, layer in enumerate(self.layers):
            if i != 0:
                h = self.dropout(h)
            h = layer(g, h)
        return h

我们可以看到:这里的网络结构选择的是gcn 。在上面的网络结构中,nn.ModuleList中放有2层的GraphConv卷积层,并在其中加入了dropout层。图卷基层之间也是可以加入dropout层的,和传统的深度学习DNN无任何区别

为了 加深理解 ,我们可以重点关注下gcn模型的初始化参数以及输入输出参数。可以看到:初始化参数包括了模型的输入参数,这里就是节点初始embeding的维度,隐藏维度以及输出维度,h的维度和out_size相同 。在本文,h维度也是和 logit维度相同,等于输出类别数


(3.3) 模型评估代码

@ 欢迎关注微信公众号:算法全栈之路

# 模型评估,返回准确率 
def evaluate(g, features, labels, mask, model):
    model.eval()
    with torch.no_grad():
        logits = model(g, features)
        logits = logits[mask]
        labels = labels[mask]
        _, indices = torch.max(logits, dim=1)
        correct = torch.sum(indices == labels)
        return correct.item() * 1.0 / len(labels)

这里的评估指标是 准确率,衡量的正负样本预测的准确与否。函数的输入是 G图, feature 是节点的原始属性feature,经过gcn后得到logit, 和标签进行比较。

我们可以看到其中用到了 model.eval() 代码。 它的作用是:在评估过程中不启用 Batch Normalization 和 Dropout,一般在测试的代码之前添加,使得在测试时保证BN层能够用全部训练数据的均值和方差,即测试过程中要保证BN层的均值和方差不变。
对于Dropout则是用到了所有网络连接,即不进行随机舍弃神经元

最后这里,我们可以看到并没有进行交叉熵计算的代码,是因为:加上了交叉熵并不会影响计算出的max logit 的 indeces ,也就不会影响模型评估的准确性了。


(3.4) 训练模块

@ 欢迎关注微信公众号:算法全栈之路

def train(g, features, labels, masks, model):
    # define train/val samples, loss function and optimizer
    train_mask = masks[0]
    val_mask = masks[1]
    loss_fcn = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-2, weight_decay=5e-4)

    # training loop
    for epoch in range(20):
        model.train()
        logits = model(g, features)
        loss = loss_fcn(logits[train_mask], labels[train_mask])
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        acc = evaluate(g, features, labels, val_mask, model)
        print(
            "Epoch {:05d} | Loss {:.4f} | Accuracy {:.4f} ".format(
                epoch, loss.item(), acc
            )
        )
        

注意: 这里的损失函数采用的是交叉熵损失,而其实 CrossEntropyLoss相当于softmax + log + nllloss , 这也就和我们上面的代码是相互映证的。

优化器选择的是 adam, 当我们不知道选择什么优化器好的时候,无脑选择adam 总是能给我们一个不错的结果。

中间train 模块的输入输出参数,可以在下面的主函数中看到,这里就不再多做说明了。
model.train()和 model.eval() 同理,只不过作用相反。


(3.5) 模型输入参数等主函数

我们这里的测试数据,直接采用了dgl官方提供的数据集, 这里提供了三个数据集。

闲言少叙,看代码吧~

@ 欢迎关注微信公众号:算法全栈之路

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--dataset",
        type=str,
        default="cora",
        help="Dataset name ('cora', 'citeseer', 'pubmed').",
    )
    args = parser.parse_args()
    print(f"Training with DGL built-in GraphConv module.")

    # load and preprocess dataset
    # 添加自环
    transform = (
        AddSelfLoop()
    )  # by default, it will first remove self-loops to prevent duplication
    if args.dataset == "cora":
        data = CoraGraphDataset(transform=transform)
    elif args.dataset == "citeseer":
        data = CiteseerGraphDataset(transform=transform)
    elif args.dataset == "pubmed":
        data = PubmedGraphDataset(transform=transform)
    else:
        raise ValueError("Unknown dataset: {}".format(args.dataset))
    g = data[0]
    
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    
    g = g.int().to(device)
    features = g.ndata["feat"]
    labels = g.ndata["label"]
    masks = g.ndata["train_mask"], g.ndata["val_mask"], g.ndata["test_mask"]

    # create GCN model
    in_size = features.shape[1]
    out_size = data.num_classes
    model = GCN(in_size, 16, out_size).to(device)

    # model training
    print("Training...")
    train(g, features, labels, masks, model)

    # test the model
    print("Testing...")
    acc = evaluate(g, features, labels, masks[2], model)
    print("Test accuracy {:.4f}".format(acc))

我们可以看到: 上述的代码中,有给 图添加自环 的过程 AddSelfLoop ,是因为添加自环可以 有效缓解图的稀疏性 ,能够提高模型的训练效果。

中间的 g = g.int().to(device) 是把图数据转入到device 中,如果训练model的机器有gpu的话,是可以把 数据复制到gpu的显存 里去的。

把上面的代码复制到一个python文件中是可以完美运行的,我这里的 dgl版本选择的是0.9 。代码本身是非常通俗易懂的,望文可以知其意 ,我就不在过多赘述了哈。如果有任何疑问,欢迎关注公众号讨论~

最后在强调一点: 这里的代码和上一篇文章中的代码用了一样的处理方式:节点 feature 不随着网络的更新而更新,如果要随着网络更新,可以去看 GraphSage与DGL实现同构图 Link 预测,通俗易懂好文强推 文章最后介绍的处理方式,或则去关注作者的后面一篇文章,会选择让特征随着网络更新的方式进行更新的代码实现。


宅男民工码字不易,你的关注是我持续输出的最大动力。

接下来作者会继续分享学习与工作中一些有用的、有意思的内容,点点手指头支持一下吧~

了解更多更全内容 : 算法全栈之路

你可能感兴趣的:(机器学习模型之理论与实践,分类,算法,深度学习,人工智能)