Contents
1. Heterogeneous graphs란?
2. Heterogeneous graphs 구현하기
3. HAN 모델
이전까지 포스팅에서는 노드와 엣지가 같은 유형으로 구성된 그래프들을 대상으로 GNN을 적용했습니다. 본 포스팅에서 다룰 heterogeneous graphs는 다양한 유형의 노드와 엣지로 구성된 그래프입니다. 일반적으로 그래프 데이터는 homogeneous graphs를 기본으로 합니다. Homogeneous graph는 하나의 유형의 노드와 엣지로 이루어져 있습니다. 예를 들어, 소셜 네트워크에서 모든 노드가 사용자를 나타내고 엣지는 사용자 간의 관계를 나타내는 것과 같습니다.
반면에 heterogeneous graph는 여러 종류의 노드와 엣지로 구성됩니다. 각 노드와 엣지는 서로 다른 속성과 관계를 가질 수 있습니다. 예를 들어, 추천 시스템에서 heterogeneous graph는 사용자와, 사용자가 구입한 아이템을 노드로 표현한 구매 기록 그래프로 표현할 수 있습니다. 참고로, 이렇게 두 개의 유형의 노드 타입으로 이루어진 그래프를 bipartite graph라고 합니다. 본 포스팅에서는 homogeneous graph와 heterogeneous graph의 차이를 알아보고 두 그래프 간의 전환하는 방법을 알아보겠습니다. 또한 hierarchical self-attention network으로 불리는 HAN 모델을 코드로 구현해보겠습니다.
1. Heterogeneous graphs란?
Heterogeneous graph는 서로 다른 개체 간의 관계를 표현하기 위한 그래프입니다. 다양한 유형의 노드와 엣지를 갖는 그래프는 보다 현실과 가까운 형태로 그래프를 표현할 수 있지만 그래프 구조가 더욱 복잡해지고 학습하기 어렵습니다. 특히, heterogeneous 네트워크의 주요 문제는 다른 유형의 노드나 엣지에서 가져온 특성이 반드시 동일한 의미나 차원을 갖지 않는다는 것입니다. 이전 포스팅까지 다루었던 그래프들은 모두 노드와 엣지가 같은 의미를 갖는 homogeneous graph 구조이기 때문에 이러한 문제를 고려하지 않아도 되었습니다.
예를 들어 아래 그림에서 왼쪽 그래프는 모든 노드가 사용자를 나타내고, 엣지는 사용자 간의 관계를 의미하는 소셜 네트워크 그래프의 예시입니다. 그러나 오른쪽 그래프에서 노드는 배우, 영화, 감독 각각을 나타내고 있고, 서로 다른 의미를 갖습니다. 또한 이러한 노드들이 가진 feature의 차원이 각각 다르다는 것이 GNN 연산을 더욱 어렵게 만들 수 있습니다.
그렇다면 이러한 그래프 구조를 가진 데이터를 PyG를 통해 코드로 구현해 보겠습니다.
먼저 필요한 라이브러리를 불러옵니다.
import torch
!pip install -q torch-scatter~=2.1.0 torch-sparse~=0.6.16 torch-cluster~=1.6.0 torch-spline-conv~=1.2.1 torch-geometric==2.2.0 -f https://data.pyg.org/whl/torch-{torch.__version__}.html
torch.manual_seed(0)
torch.cuda.manual_seed(0)
torch.cuda.manual_seed_all(0)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
PyG의 HeterData를 통해서 데이터 객체를 만듭니다.
from torch_geometric.data import HeteroData
data = HeteroData()
각 유저 노드를 나타내는 'user'의 feature는 4차원이고 각각 다른 표현을 갖습니다.
user.x 텐서는 (num_users, num_features_users)의 차원을 갖습니다.
data['user'].x = torch.Tensor([[1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3]])
게임과 개발자를 의미하는 game, dev노드는 각각 2차원, 1차원으로 표현됩니다.
즉 user, game, dev 노드들은 각각 다른 크기로 표현되고 있습니다.
data['game'].x = torch.Tensor([[1, 1], [2, 2]])
data['dev'].x = torch.Tensor([[1], [2]])
각 노드별로 엣지를 연결해 줍니다.
각 엣지는 다른 의미를 갖습니다. 엣지의 의미는 다음과 같은 triple (출발 노드 타입, 엣지 타입, 도착 노드 타입)로 표현합니다.
각 edge_index는 (2, num_connection) 차원을 갖습니다.
data['user', 'follows', 'user'].edge_index = torch.Tensor([[0, 1], [1, 2]]
data['user', 'plays', 'game'].edge_index = torch.Tensor([[0, 1, 1, 2], [0, 0, 1, 1]])
data['dev', 'develops', 'game'].edge_index = torch.Tensor([[0, 1], [0, 1]])
각 엣지에 특성을 표현할 수도 있습니다.
user -> play -> game이라는 엣지는 위에서 4개로 만들었기 때문에, 각각 play 시간을 의미하는 가중치를 부여합니다.
data['user', 'plays', 'game'].edge_attr = torch.Tensor([[2], [0.5], [10], [12]])
data
최종 데이터 객체는 다음과 같습니다.
### result ###
HeteroData(
user={ x=[3, 4] }, # 3명의 유저와 4차원 표현
game={ x=[2, 2] }, # 2개의 게임과 2차원 표현
dev={ x=[2, 1] }, # 2명의 개발자와 1차원 표현
(user, follows, user)={ edge_index=[2, 2] },
(user, plays, game)={
edge_index=[2, 4],
edge_attr=[4, 1]
},
(dev, develops, game)={ edge_index=[2, 2] }
)
2. Heterogeneous graphs 구현하기
위에서 만든 heterogeneous graphs에서 서로 다른 유형의 노드와 엣지는 동일한 텐서 차원을 갖지 않습니다. 그렇다면 입력 데이터가 서로 다른 차원을 갖는 경우 GNN을 구현하기 위한 방법론이 필요합니다.
실제 데이터셋을 예시로 문제를 표현해 보겠습니다. DBLP computer science 논문 인용 그래프는 14,328개의 논문과 7,723개의 용어, 4,057명의 저자, 20개의 학회 유형의 노드로 구성된 데이터셋을 제공합니다. 이 데이터셋의 목표는 저자를 database, data mining, artificial intelligence, information retrieval의 네 가지 클래스로 분류하는 것입니다. 저자 노드의 특성은 해당 저자가 출판물에서 사용한 334개의 bag-of-words 모델(0 or 1)입니다. 다음 그림은 서로 다른 노드 간의 관계를 나타냅니다.
이러한 노드들은 동일한 차원과 의미를 갖고 있지 않습니다. heterogeneous graph에서는 노드 간의 관계가 중요하기 때문에 노드 쌍을 고려하고자 합니다. 예를 들어, 저자 노드를 GNN 레이어에 입력하는 대신 (저자, 논문)과 같은 쌍을 고려합니다. 이는 connection 마다 GNN 레이어가 필요하다는 것을 의미합니다. 이 데이터 셋의 경우에는 총 여섯 개의 레이어가 필요합니다. 이러한 새로운 레이어는 각 노드 유형에 맞는 독립적인 가중치 행렬을 갖습니다. 그러나 이러한 방식은 실제로는 다른 유형의 관계를 고려하지 않는 여섯 개의 구분된 레이어를 표현한 것입니다. 일단 클래식한 GAT를 통해 이러한 방식으로 모델링을 해보고, 더 나아가 heterogeneous GNN으로 전환해 보겠습니다.
먼저 필요한 라이브러리를 불러옵니다.
DBLP 데이터에서 엣지간의 특정한 연결 관계를 불러오기 위해 metapaths를 가져옵니다.
데이터셋을 불러올 때 transform을 적용합니다. drop_orig_edge_types=True는, 지정한 메타 패스 외에 다른 관계를 지워줍니다.
from torch import nn
import torch.nn.functional as F
import torch_geometric.transforms as T
from torch_geometric.datasets import DBLP
from torch_geometric.nn import GAT
metapaths = [[('author', 'paper'), ('paper', 'author')]]
transform = T.AddMetaPaths(metapaths=metapaths, drop_orig_edge_types=True)
dataset = DBLP('.', transform=transform)
data = dataset[0]
print(data)
불러온 데이터는 다음과 같습니다.
author의 x는 4057명의 저자와 334차원의 표현으로 이루어집니다.
y는 저자들에 대한 라벨 값으로, 앞에서 언급했듯 4가지 클래스를 갖습니다.
불러온 meto path의 author -> paper -> author 엣지는 11,113개로 확인됩니다.
### result ###
HeteroData(
metapath_dict={ (author, metapath_0, author)=[2] },
author={
x=[4057, 334],
y=[4057],
train_mask=[4057],
val_mask=[4057],
test_mask=[4057]
},
paper={ x=[14328, 4231] },
term={ x=[7723, 50] },
conference={ num_nodes=20 },
(author, metapath_0, author)={ edge_index=[2, 11113] }
)
간단하게 이전 포스팅에서 구현해 본 GAT 모델로 노드 분류를 진행합니다.
in_channels는 모델이 자동적으로 차원 값을 찾도록 -1로 지정합니다.
out_channels는 모델이 분류하고자 하는 4개의 클래스입니다.
model = GAT(in_channels=-1, hidden_channels=64, out_channels=4, num_layers=1)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.001)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
data, model = data.to(device), model.to(device)
@torch.no_grad()
def test(mask):
model.eval()
pred = model(data.x_dict['author'], data.edge_index_dict[('author', 'metapath_0', 'author')]).argmax(dim=-1)
acc = (pred[mask] == data['author'].y[mask]).sum() / mask.sum()
return float(acc)
for epoch in range(101):
model.train()
optimizer.zero_grad()
out = model(data.x_dict['author'], data.edge_index_dict[('author', 'metapath_0', 'author')])
mask = data['author'].train_mask
loss = F.cross_entropy(out[mask], data['author'].y[mask])
loss.backward()
optimizer.step()
if epoch % 20 == 0:
train_acc = test(data['author'].train_mask)
val_acc = test(data['author'].val_mask)
print(f'Epoch: {epoch:>3} | Train Loss: {loss:.4f} | Train Acc: {train_acc*100:.2f}% | Val Acc: {val_acc*100:.2f}%')
test_acc = test(data['author'].test_mask)
print(f'Test accuracy: {test_acc*100:.2f}%')
72.43%의 정확도를 얻었습니다.
### result ###
Epoch: 0 | Train Loss: 1.4351 | Train Acc: 25.25% | Val Acc: 22.00%
Epoch: 20 | Train Loss: 1.2815 | Train Acc: 46.50% | Val Acc: 37.50%
Epoch: 40 | Train Loss: 1.1641 | Train Acc: 63.75% | Val Acc: 53.25%
Epoch: 60 | Train Loss: 1.0628 | Train Acc: 76.50% | Val Acc: 63.25%
Epoch: 80 | Train Loss: 0.9771 | Train Acc: 81.00% | Val Acc: 66.25%
Epoch: 100 | Train Loss: 0.9040 | Train Acc: 83.50% | Val Acc: 67.75%
Test accuracy: 72.43%
지금까지는 meta path를 사용하여 heterogeneous graphs을 homogeneous graph으로 줄이고 전통적인 GAT을 적용했습니다. 이로써 73.29%의 테스트 정확도를 얻은 것을 확인했습니다.
이제 이 GAT 모델의 heterogeneous 버전을 만들어보겠습니다. 이전에 설명한 대로, 한 개의 GAT 레이어 대신 여섯 개의 독립적인 GAT 레이어가 필요합니다. 모든 GAT 레이어를 수동으로 만들 필요는 없고, PyG의 to_hetero()와 to_hetero_bases()를 사용하여 자동으로 수행할 수 있습니다. 먼저 to_hetero()에서 세 가지 매개 변수를 알아보겠습니다.
- module: 변환하려는 homogeneous 모델
- metadata: 그래프의 heterogeneous 특성을 나타내는 정보로, (node_type, edge_type)으로 표시합니다.
- aggr: 서로 다른 관계로부터 생성된 노드 임베딩을 결합하는 방법(sum, max or mean)
아래 그림은 homogeneous graph를 to_hetero() 함수를 통해 heterogeneous graphs로 전환한 그림입니다. 총 여섯 개의 관계가 존재하므로, 6개의 GATConv layer가 생성됩니다.
homogeneous 모델을 heterogeneous 모델로 변환하고 예측을 수행하는 네트워크를 코드로 구현해 보겠습니다.
data['conference'].x = torch.zeros(20, 1)는, 20개의 학회로 구성된 conference 데이터가 아무 차원을 갖지 않기 때문에, 1차원으로 만들어 줍니다.
model = to_hetero(model, data.metadata(), aggr='sum')을 통해 heterogeneous 네트워크로 전환합니다.
from torch_geometric.nn import GATConv, Linear, to_hetero
dataset = DBLP(root='.')
data = dataset[0]
data['conference'].x = torch.zeros(20, 1)
class GAT(torch.nn.Module):
def __init__(self, dim_h, dim_out):
super().__init__()
self.conv = GATConv((-1, -1), dim_h, add_self_loops=False)
self.linear = nn.Linear(dim_h, dim_out)
def forward(self, x, edge_index):
h = self.conv(x, edge_index).relu()
h = self.linear(h)
return h
model = GAT(dim_h=64, dim_out=4)
model = to_hetero(model, data.metadata(), aggr='sum')
print(model)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.001)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
data, model = data.to(device), model.to(device)
@torch.no_grad()
def test(mask):
model.eval()
pred = model(data.x_dict, data.edge_index_dict)['author'].argmax(dim=-1)
acc = (pred[mask] == data['author'].y[mask]).sum() / mask.sum()
return float(acc)
for epoch in range(101):
model.train()
optimizer.zero_grad()
out = model(data.x_dict, data.edge_index_dict)['author']
mask = data['author'].train_mask
loss = F.cross_entropy(out[mask], data['author'].y[mask])
loss.backward()
optimizer.step()
if epoch % 20 == 0:
train_acc = test(data['author'].train_mask)
val_acc = test(data['author'].val_mask)
print(f'Epoch: {epoch:>3} | Train Loss: {loss:.4f} | Train Acc: {train_acc*100:.2f}% | Val Acc: {val_acc*100:.2f}%')
test_acc = test(data['author'].test_mask)
print(f'Test accuracy: {test_acc*100:.2f}%')
이전 모델에 비해 더욱 향상된 결과를 얻었습니다.
### reuslt ###
Epoch: 0 | Train Loss: 1.3974 | Train Acc: 20.75% | Val Acc: 23.00%
Epoch: 20 | Train Loss: 1.2047 | Train Acc: 95.25% | Val Acc: 68.00%
Epoch: 40 | Train Loss: 0.8654 | Train Acc: 96.75% | Val Acc: 67.50%
Epoch: 60 | Train Loss: 0.5061 | Train Acc: 98.75% | Val Acc: 73.50%
Epoch: 80 | Train Loss: 0.2580 | Train Acc: 99.50% | Val Acc: 73.50%
Epoch: 100 | Train Loss: 0.1384 | Train Acc: 100.00% | Val Acc: 74.00%
Test accuracy: 78.63%
3. HAN 모델
그렇다면, 더욱 심화적으로 HAN 모델(Hierarchical Self-Attention Network)을 알아보겠습니다. HAN는 heterogeneous graph를 다루기 위해 설계된 GNN 모델입니다. HAN은 2021년에 제안된 모델로, 아키텍처는 두 가지의 self-attention을 사용합니다.
- Node-level attention: 주어진 meta path에서 이웃 노드의 중요성을 학습하기 위해 사용합니다.
- Semantic-level attention: 각 메타 패스의 중요성을 학습합니다 이 부분이 HAN의 주요 기능입니다. 이 attention을 통해 최적의 메타 패스를 자동으로 선택할 수 있게 해 줍니다. 예를 들어, (게임-사용자-게임)과 (게임-개발자-게임) 중에서 게임 플레이어 수를 예측하는 작업에서는 (게임-사용자-게임)이 더 관련성이 높을 수 있습니다.
논문의 아키텍처는 다음과 같습니다.
그렇다면 두 개의 self-attention을 적용한 HAN 모델을 코드로 구현해 보고, 데이터셋에 적용해 보겠습니다.
HAN class는 두 개의 layer를 사용합니다.
HANConv를 통해 연산하고, linear를 통해 최종 분류를 수행합니다.
import torch
import torch.nn.functional as F
from torch import nn
import torch_geometric.transforms as T
from torch_geometric.datasets import DBLP
from torch_geometric.nn import HANConv, Linear
dataset = DBLP('.')
data = dataset[0]
print(data)
data['conference'].x = torch.zeros(20, 1)
class HAN(nn.Module):
def __init__(self, dim_in, dim_out, dim_h=128, heads=8):
super().__init__()
self.han = HANConv(dim_in, dim_h, heads=heads, dropout=0.6, metadata=data.metadata())
self.linear = nn.Linear(dim_h, dim_out)
def forward(self, x_dict, edge_index_dict):
out = self.han(x_dict, edge_index_dict)
out = self.linear(out['author'])
return out
모델을 initialize하고 optimizer를 불러옵니다.
모델과 데이터를 cuda로 보냅니다.
테스트 함수를 정의한 후, 학습을 진행합니다.
model = HAN(dim_in=-1, dim_out=4)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.001)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
data, model = data.to(device), model.to(device)
@torch.no_grad()
def test(mask):
model.eval()
pred = model(data.x_dict, data.edge_index_dict).argmax(dim=-1)
acc = (pred[mask] == data['author'].y[mask]).sum() / mask.sum()
return float(acc)
for epoch in range(101):
model.train()
optimizer.zero_grad()
out = model(data.x_dict, data.edge_index_dict)
mask = data['author'].train_mask
loss = F.cross_entropy(out[mask], data['author'].y[mask])
loss.backward()
optimizer.step()
if epoch % 20 == 0:
train_acc = test(data['author'].train_mask)
val_acc = test(data['author'].val_mask)
print(f'Epoch: {epoch:>3} | Train Loss: {loss:.4f} | Train Acc: {train_acc*100:.2f}% | Val Acc: {val_acc*100:.2f}%')
test_acc = test(data['author'].test_mask)
print(f'Test accuracy: {test_acc*100:.2f}%')
최종적으로, HAN이 GAT를 여러 개 사용한 모델보다 향상된 결과를 보였습니다.
### result ###
HeteroData(
author={
x=[4057, 334],
y=[4057],
train_mask=[4057],
val_mask=[4057],
test_mask=[4057]
},
paper={ x=[14328, 4231] },
term={ x=[7723, 50] },
conference={ num_nodes=20 },
(author, to, paper)={ edge_index=[2, 19645] },
(paper, to, author)={ edge_index=[2, 19645] },
(paper, to, term)={ edge_index=[2, 85810] },
(paper, to, conference)={ edge_index=[2, 14328] },
(term, to, paper)={ edge_index=[2, 85810] },
(conference, to, paper)={ edge_index=[2, 14328] }
)
Epoch: 0 | Train Loss: 1.3867 | Train Acc: 32.75% | Val Acc: 26.25%
Epoch: 20 | Train Loss: 1.1576 | Train Acc: 94.75% | Val Acc: 69.25%
Epoch: 40 | Train Loss: 0.7842 | Train Acc: 96.75% | Val Acc: 74.00%
Epoch: 60 | Train Loss: 0.4900 | Train Acc: 98.50% | Val Acc: 78.00%
Epoch: 80 | Train Loss: 0.2945 | Train Acc: 99.25% | Val Acc: 80.00%
Epoch: 100 | Train Loss: 0.2175 | Train Acc: 100.00% | Val Acc: 79.25%
Test accuracy: 81.52%
이번 포스팅에서는 heterogeneous graph를 알아보고, 다양한 데이터를 그래프로 표현하는 방법을 확장했습니다. 또한 PyG를 통해 homogeneous GNN을 heterogeneous GNN으로 변환하는 실습을 진행했습니다. 추가적으로 heterogeneous GAT를 구성하기 위해 여러 개의 레이어가 필요하다는 것을 설명하고, 노드 쌍을 입력으로 사용하여 관계를 모델링했습니다. 마지막으로, HAN을 사용하여 heterogeneous 그래프에 특화된 아키텍처를 구현하고 DBLP 데이터셋에 적용하였습니다. 이상으로 포스팅 마치겠습니다. 감사합니다.
Reference
- Hands-On Graph Neural Networks Using Python, published by Packt.
- J. Gilmer, S. S. Schoenholz, P. F. Riley, O. Vinyals, and G. E. Dahl. Neural Message Passing for Quantum Chemistry. arXiv, 2017. DOI: 10.48550/ARXIV.1704.01212. Available: https:// arxiv.org/abs/1704.01212.
- J. Liu, Y. Wang, S. Xiang, and C. Pan. HAN: An Efficient Hierarchical Self-Attention Network for Skeleton-Based Gesture Recognition. arXiv, 2021. DOI: 10.48550/ARXIV.2106.13391.
'Graph > Graph with Code' 카테고리의 다른 글
12. Temporal graph 코드로 알아보기 (0) | 2023.07.12 |
---|---|
10. GNN을 활용한 그래프 생성 코드 구현하기 (0) | 2023.07.10 |
9. 그래프를 활용한 링크 예측(Link Prediction): 기초 방법론부터 GAE까지 (0) | 2023.07.07 |
8. GIN (Graph isomorphism network) 기초 및 코드 구현 (0) | 2023.07.03 |
7. GraphSAGE 이해 및 코드 구현 (7) | 2023.06.30 |