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/
─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/