본문 바로가기

Graph(Graph Neural Network)

Semi-Supervised Classification With Graph Convolutional Networks

이번엔, 논문의 내용을 기반으로 데이터를 이용해 코드 구현한 거에 대해 공부를 해보았다. 느낀 점은 데이터를 구축하는 과정이 많이 어려웠지만 모델을 생성하는 부분은 다른 딥러닝 모델을 형성하는 방식과 똑같았다. 다음은 GCN을 text classification에 적용한 코드를 공부해 정리해 볼 것이다. 앞으로 GCN에 대한 나의 최종 목표는 Spatio Temporal Graph Convolutional Networks에 대해 개념 및 코드를 완벽하게 이해해서 처음으로 맡게 된 프로젝트(행동분야 -> STGCN 적용 / 실제 데이터를 통해 내가 직접 코드를 구현하는 프로젝트)를 뿌듯하게 마무리 하는 것이다. 

코드는 다음 자료를 참고하였다.

https://github.com/zhulf0804/GCN.PyTorch

 

GitHub - zhulf0804/GCN.PyTorch: Graph Convolutional Networks for Text Classification.

Graph Convolutional Networks for Text Classification. - GitHub - zhulf0804/GCN.PyTorch: Graph Convolutional Networks for Text Classification.

github.com

 

 

3개의 Dataset : citeseer, cora, depbum

Dataset

'Pubmed' dataset을 그림으로 표현하면, 다음과 같다.

Pubmed dataset structure

 

 

 1. Load Data

- dataset 종류에는 x, y, allx, ally, tx, ty, graph가 있었다. 이 dataset을 가지고 데이터를 구축해 나아갔다.

[dataset에 대한 설명]

  • ind.dataset_str.x => the feature vectors of the training instances as scipy.sparse.csr.csr_matrix object
  • ind.dataset_str.tx => the feature vectors of the test instances as scipy.sparse.csr.csr_matrix.object
  • ind.dataset_str.allx => thes feature vectors of both labeled and unlabeled training instances
    • (a superset of ind.dataset_str.x) as scipy.sparse.csr.csr_matrix object
  • ind.dataset_str.y => the one-hot labels of the labeled training instances as numpy.ndarray object
  • ind.dataset_str.ty => the one-hot labels of the test instances as numpy.ndarray object
  • int.dataset_str.ally => the labels for instances in ind.dataset_str.allx as numpy.ndarray object
  • ind.dataset_str.graph => a dict in the format {index: [index_of_neighbor_node]} as collections.defaultdict object
  • ind.dataset_str.test.index => the indices of test instances in graph, for the inductive setting as list obje

- load_data 함수를 통해 최종적으로 data에서 adjacency matrix, features, y_train, y_val, y_test, train_mask, val_mask, test_mask를 구축하였다.

## datasets.py

def process_features(features):
      row_sum_diag = np.sum(features, axis=1) #행 기준 합  # n*1
      row_sum_diag_inv = np.power(row_sum_diag, -1) #제곱함수 #n*1
      row_sum_diag_inv[np.isinf(row_sum_diag_inv)] = 0. #(만약, 값이 양의 무한대 혹은 음의 무한대로 간다면 0으로 처리)
      row_sum_inv = np.diag(row_sum_diag_inv) # 대각행렬 생성 -> n*n matrix
      return np.dot(row_sum_inv, features) # 행렬곱 -> n*n matrix



def sample_mask(idx, l):
      mask = np.zeros(l)
      mask[idx] = 1
      return np.array(mask, dtype=np.bool)


def load_data(dataset): #dataset: citeseer, cora, pubmed
      # get data
      data_path = 'data' # 경로 설정
      suffixs = ['x','y','allx','ally','tx','ty','graph'] # 데이터의 종류: x, y, allx, ally, tx, ty, graph
      objects = []
      for suffix in suffixs:
             file = os.path.join(data_path, 'ind.%s.%s' % (dataset, suffix)) # 데이터 이름이 'ind.citeseer.allx', 'ind.citeseer.x' ...
             objects.append(pickle.load(open(file,'rb'), encoding='latin1')) ##pickle은 binary 형태로 저장됨. 따라서, binary 형태로 read
      x, y, allx, ally, tx, ty, graph = objects
      x, allx, tx = x.toarray(), allx.toarray(), tx.toarray() # 배열 형태로 변환

      # test indices
      test_index_file = os.path.join(data_path, 'ind.%s.test.index'%dataset) # 데이터 이름이 'ind.cora.test.index'..
      with open(test_index_file, 'r') as f:
            lines = f.readlines()
      indices = [int(line.strip()) for line in lines] # test index 추출
      min_index, max_index = min(indices), max(indices) 


      # preprocess test indices and combine all data
      tx_extend = np.zeros((max_index-min_index+1, tx.shape[1]))
      features = np.vstack([allx, tx_extend]) # allx와 tx_extend 배열을 세로를 기준으로 결합할 때 사용.
      features[indices] = tx

      ty_extend = np.zeros((max_index-min_index+1, ty.shape[1]))
      labels = np.vstack([ally, ty_extend])
      labels[indices] = ty

      # get adjacency matrix
			# adjacency matrix는 networks library를 통해 형성.
      adj = nx.adjacency_matrix(nx.from_dict_of_lists(graph)).toarray() # returns a graph from a dictionary of lists

      idx_train = range(len(y)) # train set 개수
      idx_val = range(len(y), len(y)+500) # validation set 개수
      idx_test = indices # test set 개수

      train_mask = sample_mask(idx_train, labels.shape[0])  
      val_mask = sample_mask(idx_val, labels.shape[0])
      test_mask = sample_mask(idx_test, labels.shape[0])

      zeros = np.zeros(labels.shape)

      y_train = zeros.copy()
      y_val = zeros.copy()
      y_test = zeros.copy()

      y_train[train_mask, :] = labels[train_mask, :]
      y_val[val_mask, :] = labels[val_mask, :]
      y_test[test_mask, :] = labels[test_mask, :]

      features = torch.from_numpy(process_features(features)) # feature 형성

			# type을 torch로 변환
      y_train, y_val, y_test, train_mask, val_mask, test_mask = \
            torch.from_numpy(y_train), torch.from_numpy(y_val), torch.from_numpy(y_test), \
            torch.from_numpy(train_mask), torch.from_numpy(val_mask), torch.from_numpy(test_mask)

      return adj, features, y_train, y_val, y_test, train_mask, val_mask, test_mask

 

2. Dataset 형성

- 위에서 만든 함수인 process_features(features), load_data(dataset)를 이용해 3개의 dataset(citeseer, cora, depbum)을 구축하였다.

## data_stat.py

import numpy as np
import os
import sys
from datasets import load_data # 앞서 만들었던 load_data 가져오기

# load_data를 통해 각 dataset의 adjacency matrix, features, train set, validation set, test set 형성
def get_citeseer():
    adj, features, y_train, y_val, y_test, train_mask, val_mask, test_mask = load_data('citeseer')

    y_train, y_val, y_test = y_train.numpy(), y_val.numpy(), y_test.numpy()

    train_num = np.sum(y_train)  ## 1의 값만 존재 따라서 1의 합은 train set의 개수
    val_num = np.sum(y_val)
    test_num = np.sum(y_test)

    print("="*20, "citeseer", "="*20)
    print("train set num is %d, val set num is %d, test set num is %d"%(train_num, val_num, test_num))

    classes = [[]for _ in range(3)] # 빈 리스트 3개 생성 # [[] [] []]

    for i in range(6):
        classes[0].append(np.sum(y_train[:, i]))
        classes[1].append(np.sum(y_val[:, i]))
        classes[2].append(np.sum(y_test[:, i]))

    types = ['train set', 'val_set', 'test set']

    classes = np.array(classes, dtype=int)

    for i in range(3):
        print("each class num in %s" % types[i], classes[i])


def get_cora():
    adj, features, y_train, y_val, y_test, train_mask, val_mask, test_mask = load_data('cora')

    y_train, y_val, y_test = y_train.numpy(), y_val.numpy(), y_test.numpy()

    train_num = np.sum(y_train)  ## 1의 값만 존재
    val_num = np.sum(y_val)
    test_num = np.sum(y_test)

    print("="*20, "cora", "="*20)
    print("train set num is %d, val set num is %d, test set num is %d"%(train_num, val_num, test_num))

    classes = [[]for _ in range(3)] # 빈 리스트 3개 생성

    for i in range(6):
        classes[0].append(np.sum(y_train[:, i]))
        classes[1].append(np.sum(y_val[:, i]))
        classes[2].append(np.sum(y_test[:, i]))

    types = ['train set', 'val_set', 'test set']

    classes = np.array(classes, dtype=int)

    for i in range(3):
        print("each class num in %s" % types[i], classes[i])



def get_pubmed():
    adj, features, y_train, y_val, y_test, train_mask, val_mask, test_mask = load_data("pubmed")
    y_train, y_val, y_test = y_train.numpy(), y_val.numpy(), y_test.numpy()

    train_num = np.sum(y_train)
    val_num = np.sum(y_val)
    test_num = np.sum(y_test)

    print("="*20, "pubmed", "="*20)
    print("train set num is %d, val set num is %d, test set num is %d" % (train_num, val_num, test_num))

    classes = [[] for _ in range(3)]

    for i in range(3):
        classes[0].append(np.sum(y_train[:,i]))
        classes[1].append(np.sum(y_val[:,i]))
        classes[2].append(np.sum(y_test[:,i]))

    types = ['train set', 'val set', 'test set']

    classes = np.array(classes, dtype=int)

    for i in range(3):
        print("each class num in %s" % types[i], classes[i])

father_path = os.path.dirname(sys.path[0])
os.chdir(father_path)


get_pubmed()
get_cora()
get_citeseer()

 

결과값을 확인해보면, dataset이 잘 형성된 것을 알 수 있다. 맨 처음에 제시한 dataset 자료와 비교해 보면 알 수 있다.

 

3. Model 생성

논문에서 제시한 모델 구조는 다음과 같다. (input layer -> hidden layer -> output layer)

- hidden layer를 여러 개 쌓으면 쌓을수록 연결된 node간의 더 많은 정보(풍부한 정보)를 담을 수 있다.

model

## gcn.py

# 모델 형성
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as Func


## Adjacency Matrix 형성
## node간의 연결여부 행렬
## 자기 특성도 반영해 주기 위해 대각성분이 모두 1인 대각행렬 추가 --> 즉, self-loop 추가
## return 값: normalzied symmetric adjacency matrix

def preprocess_adj(A):
     I = np.eye(A.shape[0]) ## n*n 행렬 (n=node의 수)
     A_hat = A + I ## adjacency matrix에 self-loop 추가 ## n*n 행렬
     D_hat_diag = np.sum(A_hat, axis=1) ## D=degree matrix -> node에 연결된 node의 갯수(혹은 edge의 갯수) ## n*1 행렬
     D_hat_diag_inv_sqrt = np.power(D_hat_diag, -0.5) ## Degree Matrix의 역행렬을 통해 normalization 효과 ## n*1 행렬
     D_hat_diag_inv_sqrt[np.isinf(D_hat_diag_inv_sqrt)] = 0. # 값이 양의 무한대 혹은 음의 무한대로 갈 경우, 0으로 변환
     D_hat_inv_sqrt = np.diag(D_hat_diag_inv_sqrt) ## 대각행렬 값만 추출
     return np.dot(np.dot(D_hat_inv_sqrt, A_hat), D_hat_inv_sqrt) # D^-0.5 * (A+I) * D^-0.5 : normalized symmetric adjacency matrix

class GCNLayer(nn.Module):
       def __init__(self, in_dim, out_dim, acti=True):
              super(GCNLayer, self).__init__()

              self.linear = nn.Linear(in_dim, out_dim)

              if acti:
                   self.acti = nn.ReLU(inplace=True)
              else:
                   self.acti = None

       def forward(self, F):
             output = self.linear(F)

             if not self.acti:
                  return output

             return self.acti(output)


## 논문에서는 input layer -> hidden layer -> output layer 생성
## 식을 확인해 보면, W(0), W(1) 두 개 존재
class GCN(nn.Module):
       def __init__(self, input_dim, hidden_dim, num_classes, p):
              super(GCN, self).__init__()
              self.gcn_layer1 = GCNLayer(input_dim, hidden_dim)
              self.gcn_layer2 = GCNLayer(hidden_dim, num_classes, acti=False) ## output layer 전에는 activation function 사용 안 함.
              self.dropout = nn.Dropout(p) ## overfitting 방지

       ## softmax(A*relu(A*X*W(0)*W(1)
       def forward(self, A, X):
              A = torch.from_numpy(preprocess_adj(A)).float() # normalized symmetric adjacency matrix 형성
              X = self.dropout(X.float()) # overffing을 방지하기 위해 dropout
              F = torch.mm(A,X) ## matrix multiplication -> matrix A ** matrix X # (A*X*W(0)) # input data
              F = self.gcn_layer1(F) 
              F = self.dropout(F)
              F = torch.mm(A,F) # A*relu(A*X*W(0))*W(1)
              output = self.gcn_layer2(F)
              return output

 

4. Loss Function & Opitmizer

classification data로 loss function은 CrossEntropyLoss Function 사용 그리고  Adam Optimizer를 사용하였다.

Loss Function -> Cross-Entropy Loss

## utils.py

import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim

class Loss(nn.Module):
      def __init__(self):
            super(Loss, self).__init__()
            self.loss = nn.CrossEntropyLoss(reduction='None')
    
      def forward(self, output, labels, mask):
            labels = torch.argmax(labels, dim=1)
            loss = self.loss(output, labels)
            mask = mask.float()
            mask /= torch.mean(mask)
            loss *= mask
            return torch.mean(loss)

def build_optimizer(model, lr, weight_decay):
  
     t = model.state_dict()
     gcn1 = (t['gcn_layer1.linear.weight']).numpy()
     gcn1 = torch.Tensor(np.append(gcn1, t['gcn_layer1.linear.bias'].numpy()))
     
     gcn2 = (t['gcn_layer2.linear.weight']).numpy()
     gcn2 = torch.Tensor(np.append(gcn2, t['gcn_layer2.linear.bias'].numpy()))
     opt = torch.optim.Adam([{'params': gcn1, 'weight_decay': weight_decay},
                      {'params': gcn2}
                      ], lr=lr)
     return opt

def get_lr():
     pass

def get_loss(output, labels, mask):
     loss = Loss()
     return loss(output, labels, mask)


def get_accuracy(outputs, labels, mask):
     outputs = torch.argmax(outputs, dim=1)
     labels = torch.argmax(labels, dim=1)
     outputs = outputs.numpy()
     labels = labels.numpy()
     correct = outputs==labels
     mask = mask.float().numpy()
     tp = np.sum(correct*mask)
     tp = np.sum(correct*mask)
     return tp/np.sum(mask)

 

5. Train

import argparse
import os
os.chdir('/content/drive/MyDrive/Colab Notebooks/Deep_Learning/Graph Convolutional Network/Semi Supervised Graph Convolutional Network')
import torch
from datasets import load_data
from gcn import GCN
from utils import build_optimizer, get_loss, get_accuracy
from tensorboardX import SummaryWriter

parser = argparse.ArgumentParser()
parser.add_argument('--dataset', type=str, default='citeseer', help='Dataset to train')
parser.add_argument('--init_lr', type=float, default=0.01, help='Initial learing rate')
parser.add_argument('--epoches', type=int, default=200, help='Number of traing epoches')
parser.add_argument('--hidden_dim', type=list, default=16, help='Dimensions of hidden layers')
parser.add_argument('--dropout', type=float, default=0.5, help='Dropout rate (1 - keep  probability)')
parser.add_argument('--weight_decay', type=float, default=5e-4, help='Weight for l2 loss on embedding matrix')
parser.add_argument('--log_interval', type=int, default=10, help='Print iterval')
parser.add_argument('--log_dir', type=str, default='experiments', help='Train/val loss and accuracy logs')
parser.add_argument('--checkpoint_interval', type=int, default=20, help='Checkpoint saved interval')
parser.add_argument('--checkpoint_dir', type=str, default='checkpoints', help='Directory to save checkpoints')
args = parser.parse_args()

colab이나 jupyter로 code를 돌리는 경우, 다음과 같이 사용해주면 된다.

import argparse
import os
os.chdir('/content/drive/MyDrive/Colab Notebooks/Deep_Learning/Graph Convolutional Network/Semi Supervised Graph Convolutional Network')
import torch
from datasets import load_data
from gcn import GCN
from utils import build_optimizer, get_loss, get_accuracy
from tensorboardX import SummaryWriter

import numpy as np
import easydict

args = easydict.EasyDict({
    "dataset" : 'citeseer',
    "init_lr" : 0.01,
    "epoches" : 200,
    "hidden_dim" : 16,
    "dropout" : 0.5,
    "weight_decay" : 5e-4,
    "log_interval" : 10,
    "log_dir" : 'experiments',
    "checkpoint_interval" : 20,
    "checkpoint_dir" : 'checkpoints'
})
adj, features, y_train, y_val, y_test, train_mask, val_mask, test_mask = load_data(args.dataset)
model = GCN(features.shape[1], args.hidden_dim, y_train.shape[1], args.dropout) ## input_dim, hidden_dim, num_classes, p
optimizer = build_optimizer(model, args.init_lr, args.weight_decay)


def train():
    log_dir = os.path.join(args.log_dir, args.dataset)
    if not os.path.exists(log_dir):
        os.makedirs(log_dir)
    writer = SummaryWriter(log_dir)
    saved_checkpoint_dir = os.path.join(args.checkpoint_dir, args.dataset)
    if not os.path.exists(saved_checkpoint_dir):
        os.makedirs(saved_checkpoint_dir)
    for epoch in range(args.epoches + 1):
        outputs = model(adj, features)
        loss = get_loss(outputs, y_train, train_mask)
        val_loss = get_loss(outputs, y_val, val_mask).detach().numpy()
        model.eval()
        outputs = model(adj, features)
        train_accuracy = get_accuracy(outputs, y_train, train_mask)
        val_accuracy = get_accuracy(outputs, y_val, val_mask)
        model.train()
        writer.add_scalars('loss', {'train_loss': loss.detach().numpy(), 'val_loss': val_loss}, epoch)
        writer.add_scalars('accuracy', {'train_ac': train_accuracy, 'val_ac': val_accuracy}, epoch)
        if epoch % args.log_interval == 0:
            print("Epoch: %d, train loss: %f, val loss: %f, train ac: %f, val ac: %f"
                  %(epoch, loss.detach().numpy(), val_loss, train_accuracy, val_accuracy))
        if epoch % args.checkpoint_interval == 0:
            torch.save(model.state_dict(), os.path.join(saved_checkpoint_dir, "gcn_%d.pth"%epoch))
        optimizer.zero_grad()  # Important
        loss.backward()
        optimizer.step()
    writer.close()


if __name__ == '__main__':
    train()

 

6. Accuracy & Loss

Train/Validation Accuracy & Loss

- 총 200번의 epoch동안 accuracy의 경우, 한 시점의 epoch 이후로는 거의 상승하지 않지만, loss 값은 계속해서 하락하는 것을 볼 수 있다. 

- 또한, accuracy와 loss 값을 통해 학습이 잘 된 것을 알 수 있다.

 

Citeseer - 6개의 Class

 

 

Cora - 7개의 Class

Pubmed - 3개의 Class