Vanilla GNN은 잘 동작한다. 하지만 문제가 있다. 이웃 정보를 그냥 더한다. 이 방식은 단순하지만, 그래프에서는 치명적인 문제가 발생한다. 그래서 등장한 것이 GCN이다.
이번 챕터에서는 Graph Convolutional Network(GCN)을 다룬다. GCN은 그래프 데이터를 다룰 때 가장 널리 사용되는 GNN 구조로, 그래프에서의 Convolution 연산을 효율적으로 근사한 모델이다.
이전 Chapter에서 구현한 Vanilla GNN은 단순히 이웃 정보를 합치는 구조였지만, 한계가 존재한다. 이번 챕터에서는 이러한 한계를 분석하고, 왜 GCN이 더 좋은 성능을 내는지 이해하는것이 목표이다.
이웃이 많은 노드는 더 많은 정보를 받는다. 즉, 정보의 크기가 아니라 "연결 수"에 따라 값이 결정된다. 이것은 잘못된 학습이다.
기존 GNN Layer은 이 차이를 고려하지 않는다.
단순히 이웃 노드들의 feature을 더하는 방식이다.
이방식의 문제는 Embedding을 할때, 이웃이 많은 노드는 embedding 값이 커지고, 이웃이 적은 노드는 embedding 값이 작다.
예를들어, 어떤 노드는 이웃이 1000개 일수 있는데, 어떤 노드는 이웃이 1개다.
이 경우:
이렇게 되면 Embedding 값을 서로 비교하기 어렵다.
이 문제를 해결하기 위해,
이웃 개수로 나누는 방식(Normalization)을 사용한다.
노드 i의 degree를 deg(i)라고 하면,
기존에는 단순 합이었지만,이제 평균으로 하면서 이웃수에 관계 없이 안정적인 표현을 생성할 수 있다.
결국 GCN의 핵심은 하나다. "이웃 정보를 평균낸다"
합(sum)이 아니라, 평균(mean)을 사용한다.
이전 Vanilla GNN Layer는 다음과 같이 표현되었다.
하지만, 이 방식은 Normalization이 적용되지 않은 형태이다.
즉, Normalization 계수가 부족했다.
예시 :
의미 :
D는 각 노드의 deg(i)를 담고 있다.
따라서 :
를 사용하면,
을 한번에 표현할 수 있다.
의미 : 각 노드를 이웃 수로 나누는 효과
의미 :
각 노드 기준으로 정규화
row마다 나눔
Column Normalization
의미:
두 방식의 차이는 각 row의 합이 1이거나 각 Column의 합이 1이냐이다.
-> 이웃 정보를 잘 정규화 한다.
GCN 논문에서는 이를 해결하기 위해 양쪽에서 정규화하는 방법을 제안한다.
최종 GCN 수식
Row Normalization : 받는 사람이 공평하게 받음
Column Normalization : 주는 사람이 공평하게 나눔
GCN은 두가지 정규화 기법을 둘다 사용한다.
이 방식은 "많이 연결된 노드가 정보를 과하게 퍼뜨리는 것"을 막는다.
GCN은 "정보의 양"이 아니라 "정보의 비율"을 학습한다.
import torch
torch.manual_seed(1)
import torch.nn.functional as F
from torch_geometric.nn import GCNConv
dataset = Planetoid(root=".", name="Cora")
data = dataset[0]
def accuracy(y_pred, y_true):
"""Calculate accuracy."""
return torch.sum(y_pred == y_true) / len(y_true)
class GCN(torch.nn.Module):
"""Graph Convolutional Network"""
def __init__(self, dim_in, dim_h, dim_out):
super().__init__()
self.gcn1 = GCNConv(dim_in, dim_h)
self.gcn2 = GCNConv(dim_h, dim_out)
def forward(self, x, edge_index):
h = self.gcn1(x, edge_index)
h = torch.relu(h)
h = self.gcn2(h, edge_index)
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, data.edge_index)
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, data.edge_index)
acc = accuracy(out.argmax(dim=1)[data.test_mask], data.y[data.test_mask])
return acc
# Create the Vanilla GNN model
gcn = GCN(dataset.num_features, 16, dataset.num_classes)
print(gcn)
# Train
gcn.fit(data, epochs=100)
# Test
acc = gcn.test(data)
print(f'\nGCN test accuracy: {acc*100:.2f}%\n')
GCN(
(gcn1): GCNConv(1433, 16)
(gcn2): GCNConv(16, 7)
)
Epoch 0 | Train Loss: 1.932 | Train Acc: 15.71% | Val Loss: 1.94 | Val Acc: 15.20%
Epoch 20 | Train Loss: 0.099 | Train Acc: 100.00% | Val Loss: 0.75 | Val Acc: 77.80%
Epoch 40 | Train Loss: 0.014 | Train Acc: 100.00% | Val Loss: 0.72 | Val Acc: 77.20%
Epoch 60 | Train Loss: 0.015 | Train Acc: 100.00% | Val Loss: 0.71 | Val Acc: 77.80%
Epoch 80 | Train Loss: 0.017 | Train Acc: 100.00% | Val Loss: 0.71 | Val Acc: 77.00%
Epoch 100 | Train Loss: 0.016 | Train Acc: 100.00% | Val Loss: 0.71 | Val Acc: 76.40%
GCN test accuracy: 79.70%
지금까지는 Classification 문제였다. 그렇다면, 숫자를 예측하는 Regression 문제에도 GCN을 쓸 수 있을까?
머신러닝에서 두 가지 주요 문제는 다음과 같다.
그래프 데이터에서도 동일하게 적용된다.
이번에 사용하는 데이터는 Wikipedia Network dataset이다.
각 노드에 대해 다음 값을 예측한다
2018년 12월 평균 월간 웹 트래픽 (log 값)
그래프에서는 연결된 노드들이 서로 비슷한 특성을 가지는 경우가 많다.
따라서,
이웃 노드의 정보를 활용하면 더 정확한 값 예측이 가능하다.
class GCN(torch.nn.Module):
"""Graph Convolutional Network"""
def __init__(self, dim_in, dim_h, dim_out):
super().__init__()
self.gcn1 = GCNConv(dim_in, dim_h*4)
self.gcn2 = GCNConv(dim_h*4, dim_h*2)
self.gcn3 = GCNConv(dim_h*2, dim_h)
self.linear = torch.nn.Linear(dim_h, dim_out)
def forward(self, x, edge_index):
h = self.gcn1(x, edge_index)
h = torch.relu(h)
h = F.dropout(h, p=0.5, training=self.training)
h = self.gcn2(h, edge_index)
h = torch.relu(h)
h = F.dropout(h, p=0.5, training=self.training)
h = self.gcn3(h, edge_index)
h = torch.relu(h)
h = self.linear(h)
return h
def fit(self, data, epochs):
optimizer = torch.optim.Adam(self.parameters(),
lr=0.02,
weight_decay=5e-4)
self.train()
for epoch in range(epochs+1):
optimizer.zero_grad()
out = self(data.x, data.edge_index)
loss = F.mse_loss(out.squeeze()[data.train_mask], data.y[data.train_mask].float())
loss.backward()
optimizer.step()
if epoch % 20 == 0:
val_loss = F.mse_loss(out.squeeze()[data.val_mask], data.y[data.val_mask])
print(f"Epoch {epoch:>3} | Train Loss: {loss:.5f} | Val Loss: {val_loss:.5f}")
def test(self, data):
self.eval()
out = self(data.x, data.edge_index)
return F.mse_loss(out.squeeze()[data.test_mask], data.y[data.test_mask].float())
# Create the Vanilla GNN model
gcn = GCN(dataset.num_features, 128, 1)
print(gcn)
# Train
gcn.fit(data, epochs=200)
# Test
loss = gcn.test(data)
print(f'\nGCN test loss: {loss:.5f}\n')
GCN(
(gcn1): GCNConv(2325, 512)
(gcn2): GCNConv(512, 256)
(gcn3): GCNConv(256, 128)
(linear): Linear(in_features=128, out_features=1, bias=True)
)
Epoch 0 | Train Loss: 11.41758 | Val Loss: 11.05322
Epoch 20 | Train Loss: 11.30262 | Val Loss: 10.95968
Epoch 40 | Train Loss: 5.22662 | Val Loss: 4.69298
Epoch 60 | Train Loss: 1.14852 | Val Loss: 1.33060
Epoch 80 | Train Loss: 0.62642 | Val Loss: 0.81592
Epoch 100 | Train Loss: 0.47657 | Val Loss: 0.69224
Epoch 120 | Train Loss: 0.41453 | Val Loss: 0.65017
Epoch 140 | Train Loss: 0.36823 | Val Loss: 0.62350
Epoch 160 | Train Loss: 0.34970 | Val Loss: 0.66197
Epoch 180 | Train Loss: 0.33044 | Val Loss: 0.62604
Epoch 200 | Train Loss: 0.29097 | Val Loss: 0.60776
GCN test loss: 0.70021