4. GNN 기초 (직접 코드로 구현하기)

 

 

 

Contents
1. 그래프 기반 데이터 세트
2. GNN 직접 구현하기
3. 마무리

 

이전 포스팅에서 그래프에 대한 기본 이론과 노드를 임베딩하는 개념에 대해 알아보았습니다. 이번 포스팅부터는 본격적으로 그래프 기반 데이터를 neural network를 통해 학습하는 방법을 배우겠습니다. 먼저 가장 기초적인 GNN (graph neural network)를 직접 코드로 구현해보고, 실제 데이터 세트에 적용해보겠습니다.

 

1. 그래프 기반 데이터 세트

GNN (graph neural network) 기초를 살펴보기 전에, PyTorch Geomatric을 사용하여 대표적인 그래프 기반 데이터 세트를 살펴보겠다.

  • 1) The Cora dataset
    그래프 학습과 노드 분류에 주로 사용되는 데이터 세트이다. 각 노드는 논문을 나타내고, 엣지는 논문 간의 인용 관계를 나타내는 인용 네트워크이다. 데이터 세트는 총 2,708개의 노드 (논문)으로 구성되어 있다.
    • 노드: 논문
    • 엣지: 논문 간 인용 관계
    • 노드 feature: 논문 title 또는 abstract에 대한 1,433차원의 binary vector

 

  • 2) The Facebook Page-Page dataset
    페이스북 페이지 간의 상호작용을 나타내는 소셜 네트워크 데이터 세트이다. 페이지 간의 상호작용 (좋아요, 댓글, 공유 등)을 나타내고, directred graph인 것이 특징이다. 총 22,470개의 노드로 구성되어 있다.
    • 노드: 인물, 조직, 기업 등의 페이스북 페이지
    • 엣지: 페이지 간 상호작용 (좋아요, 댓글 등)
    • 노드 feature: 페이지에 나타난 텍스트에 대한 128차원 벡터

 

# Cora dataset

from torch_geometric.datasets import Planetoid

# Import dataset from PyTorch Geometric
dataset = Planetoid(root=".", name="Cora")
# Cora()

data = dataset[0]
# Data(x=[2708, 1433], edge_index=[2, 10556], y=[2708], train_mask=[2708], val_mask=[2708], test_mask=[2708])

# Print information about the dataset
print(f'Dataset: {dataset}')
print('---------------')
print(f'Number of graphs: {len(dataset)}')
print(f'Number of nodes: {data.x.shape[0]}')
print(f'Number of features: {dataset.num_features}')
print(f'Number of classes: {dataset.num_classes}')

# Print information about the graph
print(f'\nGraph:')
print('------')
print(f'Edges are directed: {data.is_directed()}')
print(f'Graph has isolated nodes: {data.has_isolated_nodes()}')
print(f'Graph has loops: {data.has_self_loops()}')
### result ###

Dataset: Cora()
---------------
Number of graphs: 1
Number of nodes: 2708
Number of features: 1433
Number of classes: 7

Graph:
------
Edges are directed: False
Graph has isolated nodes: False
Graph has loops: False

 

2. GNN 직접 구현하기

두 데이터 세트를 사용하여, 기초적인 GNN을 구현해 본다. 기본적인 neural network는 테이블 형식의 데이터를 사용하여, 입력 데이터에 가중치를 곱하는 선형 변환(linear transformation)을 수행한다. 즉, \(x_A\)가 입력 데이터 \(A\)의 input vector이고, \(W\)가 가중치 행렬일때 히든 벡터는 다음 식과 같다.  $$h_A = x_A W^T$$.

 

위는 테이블 데이터의 예시로, 모든 데이터가 완전히 분리 된 것을 가정한다. 그러나 그래프 데이터는 입력 노드의 피처뿐만 아니라, 노드 간의 관계도 표현된다. 따라서 각 노드의 이웃 정보를 neural network에 적용해야 한다. 이때 입력 노드 \(A\)의 이웃 집합을 \(N_A\)라고 할 때, graph linear layer는 다음 식과 같다.

$$h_A = \sum_{i\in N_A} x_i W^T$$

 그러나 위 식은 이웃 노드 별로 \(W^T\)가 각각의 가중치를 가져야 한다. 각 노드들은 서로 다른 이웃을 가질 수 있기 때문에, 이러한 방식은 조금 보완이 필요하다. 따라서 앞선 포스팅에서 살펴본, 인접 행렬(adjacency matrix)를 통해, 이웃 노드의 정보를 반영할 수 있도록 식을 변형한다. 또한 딥러닝 연산에서 효율적인 학습을 위해 행렬곱의 식으로 나타낸다. 인접 행렬은 그래프 내의 모든 노드의 연결을 나타낸다. 따라서 입력 행렬 \{X\)를 인접 행렬과 곱하면 이웃 노드의 특징이 반영된다. 여기서 \(\tilde{A}\)는 인접 행렬 \(A\)에 자기 자신에 대한 self loop를 나타내는 단위행렬 \(I\)를 더한 것이다.

$$H = \tilde{A}^T X W^T$$

 

이러한 식을 구현하여 기초적인 GNN 레이어를 구축하는 코드이다.

 

# Basic GNN

class GNNLayer(torch.nn.Module):
    def __init__(self, dim_in, dim_out):
        super().__init__()
        
        # bias를 포함하지 않는 basic linear transformation
        self.linear = Linear(dim_in, dim_out, bias=False)

    def forward(self, x, adjacency):
        
        # (1) Linear trasnformation
        x = self.linear(x)
        
        # (2) Multiplication with the adjacency matrix A
        x = torch.sparse.mm(adjacency, x)
        return x

 

여기서 forward 함수에 입력되는 adjacency 값은 위의 언급과 같이, 인접 행렬에 단위행렬을 더한 행렬이다.

from torch_geometric.utils import to_dense_adj

adjacency = to_dense_adj(data.edge_index)[0]
adjacency += torch.eye(len(adjacency))
adjacency
### result ###
tensor([[1., 0., 0.,  ..., 0., 0., 0.],
        [0., 1., 1.,  ..., 0., 0., 0.],
        [0., 1., 1.,  ..., 0., 0., 0.],
        ...,
        [0., 0., 0.,  ..., 1., 0., 0.],
        [0., 0., 0.,  ..., 0., 1., 1.],
        [0., 0., 0.,  ..., 0., 1., 1.]])

 

최종적인 모델 아키텍처는 다음과 코드와 같다.

  • __init__: 입력 데이터에 대한 feature의 차원을 dim_in / 선형 변환을 위한 hidden layer의 차원을 dim_h / 출력의 차원을 dim_out으로 입력받는다. 이후 앞서 구축한 GNN 클래스에 대한 인스턴스를 레이어로 생성한다. 
  • __forward__: 입력 데이터 x와 위에서 구한 이웃 정보 행렬을 입력받는다. 노드 클래스 분류를 위해 최종 출력에 대한 log scale의 softmax를 수행한다.
  • __fit__: 손실 함수, 옵티마이저를 불러오고 훈련을 진행한다 ('out='에서 내부적으로 forward 함수를 불러온다.). 이때 데이터 세트에서 제공하는 인덱스 mask를 통해, 훈련, 검증 세트를 분리한다.
class GNN(torch.nn.Module):
    """Basic Graph Neural Network"""
    def __init__(self, dim_in, dim_h, dim_out):
        super().__init__()
        self.gnn1 = GNNLayer(dim_in, dim_h)
        self.gnn2 = GNNLayer(dim_h, dim_out)

    def forward(self, x, adjacency):
        h = self.gnn1(x, adjacency)
        h = torch.relu(h)
        h = self.gnn2(h, adjacency)
        return F.log_softmax(h, dim=1)

    def fit(self, data, epochs):
        criterion = torch.nn.CrossEntropyLoss()
        optimizer = torch.optim.Adam(self.parameters(),
                                      lr=0.01,
                                      weight_decay=5e-4)

        self.train()
        for epoch in range(epochs+1):
            optimizer.zero_grad()
            out = self(data.x, adjacency)
            loss = criterion(out[data.train_mask], data.y[data.train_mask])
            acc = accuracy(out[data.train_mask].argmax(dim=1),
                          data.y[data.train_mask])
            loss.backward()
            optimizer.step()

            if(epoch % 20 == 0):
                val_loss = criterion(out[data.val_mask], data.y[data.val_mask])
                val_acc = accuracy(out[data.val_mask].argmax(dim=1),
                                  data.y[data.val_mask])
                print(f'Epoch {epoch:>3} | Train Loss: {loss:.3f} | Train Acc:'
                      f' {acc*100:>5.2f}% | Val Loss: {val_loss:.2f} | '
                      f'Val Acc: {val_acc*100:.2f}%')

    @torch.no_grad()
    def test(self, data):
        self.eval()
        out = self(data.x, adjacency)
        acc = accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
        return acc

 

모델을 구축한 후, GNN 인스턴스를 저장한다. 이때 은닉층의 차원은 16으로 지정하였다.

이후 에포크 100회 트레이닝과 테스트를 진행한다.

# Create theGNN model
gnn = GNN(dataset.num_features, 16, dataset.num_classes)
print(gnn)

# Train
gnn.fit(data, epochs=100)

# Test
acc = gnn.test(data)
print(f'\nGNN test accuracy: {acc*100:.2f}%')
### result ###

GNN(
  (gnn1): GNNLayer(
    (linear): Linear(in_features=1433, out_features=16, bias=False)
  )
  (gnn2): GNNLayer(
    (linear): Linear(in_features=16, out_features=7, bias=False)
  )
)
Epoch   0 | Train Loss: 2.285 | Train Acc: 18.57% | Val Loss: 2.20 | Val Acc: 14.40%
Epoch  20 | Train Loss: 0.110 | Train Acc: 98.57% | Val Loss: 1.49 | Val Acc: 72.40%
Epoch  40 | Train Loss: 0.012 | Train Acc: 100.00% | Val Loss: 1.97 | Val Acc: 74.60%
Epoch  60 | Train Loss: 0.004 | Train Acc: 100.00% | Val Loss: 2.21 | Val Acc: 74.60%
Epoch  80 | Train Loss: 0.003 | Train Acc: 100.00% | Val Loss: 2.24 | Val Acc: 74.60%
Epoch 100 | Train Loss: 0.002 | Train Acc: 100.00% | Val Loss: 2.24 | Val Acc: 74.80%

 

다음으로 Facebook Page-Page 데이터 세트에 적용한 코드이다.

이 데이터 세트는, 내부적으로 train_mask가 존재하지 않기 때문에, 임의로 만들어주었다.

# Dataset
dataset = FacebookPagePage(root=".")
data = dataset[0]
data.train_mask = range(18000)
data.val_mask = range(18001, 20000)
data.test_mask = range(20001, 22470)

# Adjacency matrix
adjacency = to_dense_adj(data.edge_index)[0]
adjacency += torch.eye(len(adjacency))
adjacency


# GNN
gnn = GNN(dataset.num_features, 16, dataset.num_classes)
print(gnn)
gnn.fit(data, epochs=100)
acc = gnn.test(data)
print(f'\nGNN test accuracy: {acc*100:.2f}%')
### result ###

GNN(
  (gnn1): GNNLayer(
    (linear): Linear(in_features=128, out_features=16, bias=False)
  )
  (gnn2): GNNLayer(
    (linear): Linear(in_features=16, out_features=4, bias=False)
  )
)
Epoch   0 | Train Loss: 65.674 | Train Acc: 20.94% | Val Loss: 64.68 | Val Acc: 21.11%
Epoch  20 | Train Loss: 3.652 | Train Acc: 79.13% | Val Loss: 2.67 | Val Acc: 79.89%
Epoch  40 | Train Loss: 1.425 | Train Acc: 82.02% | Val Loss: 1.15 | Val Acc: 82.69%
Epoch  60 | Train Loss: 0.832 | Train Acc: 83.57% | Val Loss: 0.76 | Val Acc: 84.44%
Epoch  80 | Train Loss: 0.611 | Train Acc: 84.31% | Val Loss: 0.60 | Val Acc: 84.94%
Epoch 100 | Train Loss: 1.046 | Train Acc: 80.99% | Val Loss: 0.91 | Val Acc: 82.04%

GNN test accuracy: 81.13%

 

3. 마무리

본 포스팅에서는 기초적인 GNN 레이어를 구축해보고, 실제 데이터에 적용하여 Node classification을 수행했다. 딥러닝에서 사용되는 행렬 기반 곱을 바탕으로 간단한 GNN 아키텍처를 구축했다. 이는 노드의 특징인 node feature만 학습하는 단순 neural network와 달리, 이웃 노드와의 관계를 학습하는 인접행렬을 통해 더욱 노드 표현을 학습한 것이다.

 

이후 포스팅에서는 GCN을 소개하고, 기초적인 GNN 아키텍처에서 발전하여, 각 노드를 정규화하는 방법을 소개할 계획이다.

 

Reference 

Hands-On Graph Neural Networks Using Python, published by Packt.

 

 

반응형