1. 程式人生 > 其它 >SEMI-SUPERVISED CLASSIFICATION WITH GRAPH CONVOLUTIONAL NETWORKS 論文筆記

SEMI-SUPERVISED CLASSIFICATION WITH GRAPH CONVOLUTIONAL NETWORKS 論文筆記

SEMI-SUPERVISED CLASSIFICATION WITH GRAPH CONVOLUTIONAL NETWORKS

Thomas N. Kipf、MaxWelling
Published as a conference paper at ICLR 2017

論文筆記:2021-10-20

若有侵權,請在第一時間通知博主,博主會及時處理!

背景與結論

在圖節點分類任務中(一張圖有邊、節點、節點特徵和節點標籤等等),只有一小部分的節點有標籤,這個問題可以被建模成基於圖的半監督學習,本文用的傳播模組為卷積運算,並介紹瞭如何設計圖卷積神經網路。
在計算機視覺,卷積的應用使得深度學習有了巨大突破。由於圖片的平移不變性,CNN能夠通過卷積運算來提取多尺度區域性空間特徵,但是很難定義區域性卷積濾波器和池化運算元,這阻礙了CNN從歐幾里德域到非歐幾里德域的轉換。如何定義在圖上的高效卷積是本篇論文研究的重點。

第一個問題:關於GCN損失函式的設計

在傳統的圖半監督學習,是通過監督損失+圖拉普拉斯正則化項來計算loss,原理是想利用相鄰的頂點傾向於擁有相同的標籤這一假設來完成半監督學習過程。公式需要滿足假設:圖中的相鄰節點可能具有相同的標籤。

注意:這裡的解釋參考自部落格2
但是有時圖的邊未必能夠很好地表示頂點的相似性,比如在論文分類任務中,論文的引用關係作為圖的邊,一篇物理論文與數學論文和計算機論文相連,如果使用上述損失函式訓練學習器, 則學習器很可能會把一篇物理類論文預測成一篇數學類論文或者計算機類論文。相鄰的頂點傾向於擁有相同的標籤這一假設太過嚴格, 會限制學習器的預測能力。

該論文的損失函式為交叉熵,只用有標籤的結點。

YL表示有標籤的節點集。公式中的Y為one-hot向量,該損失函式保障了對於帶有標籤的頂點, 其預測類別和真實類別儘量相同。而神經網路中蘊藏的圖結構保障了在圖上相近的頂點具有相同或相近的預測值(通過卷積來提取一個頂點及其相鄰頂點的特徵, 從而直接把圖的結構用GCN來表示), 但又不會像傳統的方法一樣因過於嚴格的假設而降低模型的預測能力。

第二個問題:關於圖卷積核的設計

我們知道傅立葉變換可以將時域轉換到頻域分析。在圖訊號分析中,可以用拉普拉斯譜分解的正交矩陣U,將圖從空間域變換到譜域,將空域中的拓撲圖結構通過傅立葉變換對映到譜域中並相乘,然後利用逆變換返回空域,從而完成了圖卷積操作。並且可以實現引數全域性共享。
第一代卷積核公式:

但是U的計算複雜度為N^2,利用K階切比雪夫多項式去近似gθ,,將其帶入第一代卷積,又根據和,可以將公式中最難計算的U變成放到T中,避免計算。
第二代卷積核公式:

論文中對卷積核進行了簡化,文章對上式進行了進一步近似: 只取切比雪夫多項式的前兩項:
這裡的一階近似, 相當於提取取了圖中每個頂點的一階相鄰頂點的特徵.
並且假設前兩項的引數:
此時有:
根據進一步化簡
第三代卷積核公式:
傳播的規則:

第三個問題:為什麼是卷積公式用的是Ã而不是A

我們知道圖卷積可以提取圖的特徵,聚合節點資訊。當我們用A的時候,只聚合了鄰居特徵,卻忽視了自身的節點資訊,所以需要對鄰接矩陣做進一步處理,引入自身節點資訊。公式為:

第四個問題:怎麼解釋卷積公式的歸一化

如果我們疊加了多層,經過多次AH(l),其結果H(l+1)也會越來越大,和輸入X的差距也會越來越大。度的大節點特徵值會越來越大,度小的節點特徵值會越來越小,傳播過程對特徵的尺度敏感。因此我們需要對其進行歸一化,最簡單的做法是將結果除以節點的度。在這篇論文中,使用的歸一化方式是對稱的歸一化。

第五個問題:推導過程中的意義

注意:這裡的解釋參考自部落格2和知乎3
的特徵值範圍在 [0, 2] 之間,所以如果在很深的網路中會引起梯度爆炸的問題,這樣很可能會導致無法穩定收斂!原文也稱renormalization trick

第六個問題:為什麼是切比雪夫近似

注意:這裡的觀點來自於參考的部落格2

根據作者得到的公式,既然只取前兩項,為什麼不可以看作是從普通多項式中取出的兩項?
在作者的推導過程中,因為切比雪夫多項式的定義域在[-1, 1]之內,作者將L進行了放縮,以及在第五個問題中提到的替換。而普通的多項式方法對應的則是Table 3中的First-order term only,效果並不好。所以推到過程中利用切比雪夫多項式的條件對公式進行的變換是必不可少的,

第七個問題:關於模型的優勢和缺陷

優勢1:即使不訓練,直接隨機初始化引數也可以獲得不錯的效果

優勢2:即使只有少量標註的樣本,GCN也能得到很好的嵌入效果。

優勢3:GCN的設計流程相對通用簡單,基於skip-gram的方法需要隨機遊走生成和半監督訓練的多步驟管道,其中每個步驟都必須單獨優化。

缺陷1:記憶體限制,一次訓練一整個圖,記憶體需求隨資料集大小呈線性增加

缺陷2:有向邊和邊特徵,GCN不支援邊的特徵,並且僅限於無向圖

缺陷3:過度平滑問題,在圖神經網路的訓練過程中,隨著網路層數的增加和迭代次數的增加,每個節點的隱層表徵會趨向於收斂到同一個值。

第八個問題:關於資料集

該論文提到了一些圖神經網路常用的資料集,比如Citeseer、Cora、Pubmed還有NELL。
以Cora為例子來解釋GCN的輸入:X、A、Y。
Cora:“Collective classification in network data,” AI magazine,2008
Cora資料集是與論文相關的圖,整個語料庫共有2708篇論文。節點代表論文,邊代表論文之間的引用關係。
X:表示節點的特徵矩陣(1708,1433),參與訓練的節點有1708個。在所有論文的單詞中,將文件頻率小於10的所有單詞都刪除,並刪除詞尾等等,只剩下1433個獨特單詞,所以特徵是1433維。0和1描述的是每個單詞在paper中是否存在。(注意:訓練節點有1433個,而計算損失時只用有label的標籤,該資料集有label的標籤有140個)
A:表示圖的鄰接矩陣。
Y:表示節點的標籤,(140,7),有140個節點有標籤,共7有種label。

第九個問題:關於模型程式碼(pytorch實現)

注意:程式碼來自https://github.com/tkipf/pygcn/tree/master/data/cora

─pygcn # 模型資料夾
 │ .gitignore
 │ figure.png
 │ LICENCE
 │ README.md
 │ setup.py
 │
 ├─data # 資料
 │ └─cora
 │ cora.cites
 │ cora.content
 │ README
 │
 └─pygcn # 模型程式碼
  layers.py # 卷積層定義
  models.py # 模型定義
  train.py # 配置和啟動訓練
  utils.py # 相關工具,如one-hot編碼、載入資料集等
  __init__.py # 將資料夾變為一個Python模組,批量引入

__init__.py:

from __future__ import print_function
from __future__ import division

from .layers import *
from .models import *
from .utils import *

layers.py:
在進行卷積的時候,因為adj是稀疏矩陣,所以先矩陣乘法得到XW,再用稀疏矩陣乘法計算adjXW,效率會更高。

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
    """

    def __init__(self, in_features, out_features, bias=True):
        super(GraphConvolution, self).__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.weight = Parameter(torch.FloatTensor(in_features, out_features))
        if bias:
            self.bias = Parameter(torch.FloatTensor(out_features))
        else:
            self.register_parameter('bias', None)
        self.reset_parameters()

    def reset_parameters(self):
        stdv = 1. / math.sqrt(self.weight.size(1))
        self.weight.data.uniform_(-stdv, stdv)
        if self.bias is not None:
            self.bias.data.uniform_(-stdv, stdv)

    def forward(self, input, adj):
        support = torch.mm(input, self.weight)
        output = torch.spmm(adj, support)
        if self.bias is not None:
            return output + self.bias
        else:
            return output

    def __repr__(self):
        return self.__class__.__name__ + ' (' \
               + str(self.in_features) + ' -> ' \
               + str(self.out_features) + ')'

models.py:
兩層的GCN就可以取得很好的效果,過深的GCN因為過度平滑的問題會導致準確率下降,每個節點的隱層表徵會趨向於收斂到同一個值,是模型的缺陷之一。
注意在前向傳播的時候dropout防止過擬合。

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


class GCN(nn.Module):
    def __init__(self, nfeat, nhid, nclass, dropout):
        super(GCN, self).__init__()

        self.gc1 = GraphConvolution(nfeat, nhid)
        self.gc2 = GraphConvolution(nhid, nclass)
        self.dropout = dropout

    def forward(self, x, adj):
        x = F.relu(self.gc1(x, adj))
        x = F.dropout(x, self.dropout, training=self.training)
        x = self.gc2(x, adj)
        return F.log_softmax(x, dim=1)

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()
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.')
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.')
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.')
parser.add_argument('--dropout', type=float, default=0.5,
                    help='Dropout rate (1 - keep probability).')

args = parser.parse_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
adj, features, labels, idx_train, idx_val, idx_test = load_data()

# Model and optimizer
model = GCN(nfeat=features.shape[1],
            nhid=args.hidden,
            nclass=labels.max().item() + 1,
            dropout=args.dropout)
optimizer = optim.Adam(model.parameters(),
                       lr=args.lr, weight_decay=args.weight_decay)

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()
    output = model(features, adj) # 前向傳播
    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()
        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))


def test():
    model.eval()
    output = model(features, adj)
    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
t_total = time.time()
for epoch in range(args.epochs):
    train(epoch)
print("Optimization Finished!")
print("Total time elapsed: {:.4f}s".format(time.time() - t_total))

# Testing
test()

utils.py:

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


def encode_onehot(labels):
    classes = set(labels)
    classes_dict = {c: np.identity(len(classes))[i, :] for i, c in
                    enumerate(classes)}
    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))

    idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset),
                                        dtype=np.dtype(str))
    features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32)
    labels = encode_onehot(idx_features_labels[:, -1])

    # build graph
    idx = np.array(idx_features_labels[:, 0], dtype=np.int32)
    idx_map = {j: i for i, j in enumerate(idx)}
    edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset),
                                    dtype=np.int32)
    edges = np.array(list(map(idx_map.get, edges_unordered.flatten())),
                     dtype=np.int32).reshape(edges_unordered.shape)
    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 = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)

    features = normalize(features)
    adj = normalize(adj + sp.eye(adj.shape[0]))

    idx_train = range(140)
    idx_val = range(200, 500)
    idx_test = range(500, 1500)

    features = torch.FloatTensor(np.array(features.todense()))
    labels = torch.LongTensor(np.where(labels)[1])
    adj = sparse_mx_to_torch_sparse_tensor(adj)

    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"""
    rowsum = np.array(mx.sum(1))
    r_inv = np.power(rowsum, -1).flatten()
    r_inv[np.isinf(r_inv)] = 0.
    r_mat_inv = sp.diags(r_inv)
    mx = r_mat_inv.dot(mx)
    return mx


def accuracy(output, labels):
    preds = output.max(1)[1].type_as(labels)
    correct = preds.eq(labels).double()
    correct = correct.sum()
    return correct / len(labels)


def sparse_mx_to_torch_sparse_tensor(sparse_mx):
    """Convert a scipy sparse matrix to a torch sparse tensor."""
    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)

筆記參考的部落格

1.https://aistudio.baidu.com/aistudio/projectdetail/1782074?channelType=0&channel=0
2.https://blog.csdn.net/qq_42013492/article/details/96462630
3.https://zhuanlan.zhihu.com/p/120311352
4.https://www.cnblogs.com/BlairGrowing/p/15323995.html
5.https://www.cnblogs.com/daztricky/p/15010350.html
6.https://github.com/tkipf/pygcn/