2023년 7월 11일 화요일

엔트와인 EPT 포인트 클라우드 타일 데이터 포맷 분석

EPT(Entwine Point Tile)는 포인트 클라우드 데이터를 위한 간단하고 유연한 옥트리 기반 저장 형식이다. 이 글은 포인트 클라우드 타일 데이터 포맷(EPT)을 분석한다.

EPT 이용한 포인트 클라우드 처리 예시

구조
EPT는 JSON 메타데이터와 바이너리 PCD(Point Cloud Data)를 포함한 파일 형식이다. 구조는 다음과 같다. 
├── ept.json
├── ept-data
│   └── 0-0-0-0.laz
├── ept-hierarchy
│   └── 0-0-0-0.json
└── ept-sources
    ├── list.json
    └── 0.json

ept.json은 메타데이터를 정의한다. 구성요소는 다음과 같다.

bounds는 옥트리의 경계를 표현한다. 
boundsConforming은 데이터의 경계를 표현하는 최소 영역을 정의한다.
dataType은 PCD파일 형식을 지정한다.
points는 포인트 개수를 의미한다.
schema는 포인트의 형식을 정의한다. x, y, z, 반사강도와 같은 데이터 유형을 정의한다. 
span은 옥트리의 각 x, y, z차원의 복셀 큐브 갯수를 정의한다. 이 값이 높을수록, 좀 더 정밀한 옥트리를 표현하지만, 사용 용량은 커진다. 
srs는 좌표계를 정의한다. 

다음은 메타데이터 예시이다. 
{
    "bounds": [634962.0, 848881.0, -1818.0, 639620.0, 853539.0, 2840.0],
    "boundsConforming": [635577.0, 848882.0, 406.0, 639004.0, 853538.0, 616.0],
    "dataType": "laszip",
    "hierarchyType": "json",
    "points": 10653336,
    "schema": [
        { "name": "X", "type": "signed", "size": 4, "scale": 0.01, "offset": 637291.0 },
        { "name": "Y", "type": "signed", "size": 4, "scale": 0.01, "offset": 851210.0 },
        { "name": "Z", "type": "signed", "size": 4, "scale": 0.01, "offset": 511.0 },
        { "name": "Intensity", "type": "unsigned", "size": 2 },
        { "name": "ReturnNumber", "type": "unsigned", "size": 1 },
        { "name": "NumberOfReturns", "type": "unsigned", "size": 1 },
        { "name": "ScanDirectionFlag", "type": "unsigned", "size": 1 },
        { "name": "EdgeOfFlightLine", "type": "unsigned", "size": 1 },
        { "name": "Classification", "type": "unsigned", "size": 1 },
        { "name": "ScanAngleRank", "type": "float", "size": 4 },
        { "name": "UserData", "type": "unsigned", "size": 1 },
        { "name": "PointSourceId", "type": "unsigned", "size": 2 },
        { "name": "GpsTime", "type": "float", "size": 8 },
        { "name": "Red", "type": "unsigned", "size": 2 },
        { "name": "Green", "type": "unsigned", "size": 2 },
        { "name": "Blue", "type": "unsigned", "size": 2 },
        { "name": "OriginId", "type": "unsigned", "size": 4 }
    ],
    "span" : 256,
    "srs": {
        "authority": "EPSG",
        "horizontal": "3857",
        "vertical": "5703",
        "wkt": "PROJCS[\"WGS 84 ... AUTHORITY[\"EPSG\",\"3857\"]]"
    },
    "version" : "1.1.0"
}

ept파일의 계층은 파일이름 형식에 표현된다. 
D-X-Y-Z 파일 이름 형식은 옥트리의 특정 큐브를 의미한다. 예를 들어, 0-0-0-0.json 파일 내용은 다음과 같다. 여기서, 2-0-1-0 옥트리 큐브는 322 포인트들이 포함된다.

{
    "0-0-0-0": 65341,
        "1-0-0-0": 438,
            "2-0-1-0": 322,
        "1-0-0-1": 56209,
            "2-0-1-2": 4332,
            "2-1-1-2": 20300,
            "2-1-1-3": 64020,
                "3-2-3-6": 32004,
                    "4-4-6-12": -1,
                    "4-5-6-13": -1,
                "3-3-3-7": 542,
        "1-0-1-0": 30390,
            "2-1-2-0": 2300,
        "1-1-1-1": 2303
}

포인트갯수 값이 -1일 경우, 별도의 해당 json파일 안에 옥트리 큐브 점 갯수가 정의되어 있다는 것을 의미한다 (예. 4-4-6-12.json).

PCD 파일 소스는 ept-sources/manifest.json 파일에 다음과 같이 저장된다. 
[
    {
        "path": "autzen-low.laz",
        "bounds": [635577.0, 848882.0, 406.0, 639004.0, 853538.0, 511.0],
        "inserted": true,
        "points": 6000 ,
        "metadataPath": "autzen-low.json"
    },
    {
        "path": "autzen-high.laz",
        "bounds": [635577.0, 848882.0, 511.0, 639004.0, 853538.0, 616.0],
        "inserted": true,
        "points": 4000,
        "metadataPath": "autzen-high.json"
    }
]

ept-sources 에 지정된 PCD 소스 파일 경로는 URL이 될 수 있다.

마무리
EPT 파일을 이용하면, 대용량 PCD를 표현하고 서비스하기 좋다. 확장성있는 구조를 가지고 있어, 다양한 스캔 분야에 활용할 수 있다.

레퍼런스

QGIS 포인트 클라우드 사용 방법

이 글은 QGIS에 추가된 최신 기능 중 하나인 포인트 클라우드 사용 방법을 간단히 정리한다.

점군 가져오기
포인트 클라우드는 포인트 집합이다. 각 포인트에는 x, y 및 z 좌표가 있다. 캡처 방법에 따라 포인트 클라우드에는 추가 속성도 포함된다. 

QGIS는 Entwine Point Tile (EPT) 및 LAS/LAZ 데이터 포맷을 지원한다. 포인트 클라우드로 작업하면, QGIS는 항상 EPT로 데이터를 저장한다. EPT는 공간 인덱싱을 사용한다. 데이터가 LAS 또는 LAZ 형식이라면, QGIS는 EPT 형식으로 변환할 것이다.

QGIS 에서 왼쪽 상단의 DATA SOURCE TOOLBAT 메뉴를 선택한다. 그리고, LAS파일을 다음 그림과 같이 선택한다.  그럼, 점군을 로딩해, QGIS에서 가시화 할 것이다.  

QGIS에서는 포인트 클라우드를 편집할 수 없다. 이 경우 포인트 클라우드, CloudCompare를 사용할 수 있다. 

포인트 클라우드 레이어 속성 확인 및 가시화
포인트 클라우드 레이어에 대한 Layer Properties 대화 상자를 다음처럼 제공한다. 


속성창은 메타데이터, 소스 원본 경로, 좌표계, 포인트 특징값 리스트 등을 표시한다. 

뷰의 3D VIEW 메뉴를 이용하면, 다음과 같이, 3D 보기가 가능하다.

각 속성 및 특징 값을 이용해, 다음과 같이 컬러 스키마를 지정해 가시화하는 방법을 설정할 수 있다. 이는 분석할 때 매우 유용하다.


레퍼런스

파이썬 기반 포인트 클라우드 특징 계산 방법 소개

이 글은 파이썬 기반 포인트 클라우드 특징 계산 방법을 소개한다. 점군은 포인트 간의 관계를 통해, 수직도, 수평도, 법선, 곡률 등 다양한 특징을 수학적으로 계산할 수 있다. 직접 코딩해도 되지만, jakteristics 라이브러리를 이용하면, 쉽게 이 값들을 얻을 수 있다. 이 라이브러리는 다음과 같은 point cloud data 특징을 계산해 준다.
  • Eigenvalue sum
  • Omnivariance
  • Eigenentropy
  • Anisotropy
  • Planarity
  • Linearity
  • PCA1
  • PCA2
  • Surface Variation
  • Sphericity
  • Verticality
  • Nx, Ny, Nz (The normal vector)

점군 특징 중 하나인 법선벡터

설치 
설치 및 테스트는 다음과 같다.
 
    python -m pip install -r requirements-dev.txt
    python setup.py pytest

사용방법
사용법은 다음과 같다. 
jakteristics input/las/file.las output/file.las --search-radius 0.15 --num-threads 4

코딩방법
파이썬을 이용해, 다음과 같이 얻고자 하는 특징을 옵션으로 설정한 후, 함수를 호출해 주면 된다. 
from jakteristics import FEATURE_NAMES, extension, las_utils, utils
def compute_features(input_path, output_path):
'''
FEATURE_NAMES = [
"eigenvalue_sum",
"omnivariance",
"eigenentropy",
"anisotropy",
"planarity",
"linearity",
"PCA1",
"PCA2",
"surface_variation",
"sphericity",
"verticality",
"nx",
"ny",
"nz",
"number_of_neighbors",
"eigenvalue1",
"eigenvalue2",
"eigenvalue3",
"eigenvector1x",
"eigenvector1y",
"eigenvector1z",
"eigenvector2x",
"eigenvector2y",
"eigenvector2z",
"eigenvector3x",
"eigenvector3y",
"eigenvector3z",
]
'''
xyz = las_utils.read_las_xyz(input_path)
features = extension.compute_features(xyz, 0.1, feature_names=FEATURE_NAMES)
print(features)
las_utils.write_with_extra_dims(input_path, output_path, features, FEATURE_NAMES)

이 함수를 이용해, las 파일을 이용해 특징을 계산해 본다. 다음과 같이 호출하면 된다. 
compute_features('input.las', 'output.las') 

클라우드 컴페어를 통해 확인한 결과는 다음과 같다.
출력파일 수직도 확인 결과(청색 - 적색 스키마)

해당 도구에서 scalar 항목을 변경해 보면, 앞서 언급된 점군 특징이 모두 계산되어 있는 것을 확인할 수 있다.

레퍼런스

딥러닝 하드웨어 확장성 고려한 PyTorch Lightning 기반 그래프 딥러닝 지원 PyTorch Geometric 사용방법 및 단백질 기능 등 다양한 모델 학습 예시

이 글은 딥러닝 하드웨어 확장성을 고려한 PyTorch Lightning 기반 그래프 딥러닝 지원 PyTorch Geometric 사용방법을 소개한다.
그래프 데이터셋 예시

PyTorch Lightning 
최신 딥러닝 하드웨어를 활용하려면, 하드웨어 지원 라이브러리를 사용해 딥러닝 모델을 개발해야 한다. 

PyTorch Lightning은 TPU와 같은 새로운 하드웨어에서 실행되도록, 모델을 간단하게 확장할 수 있을 뿐만 아니라, 일반적인 CPU와 GPU 간 전환 프로세스를 단순화할 수 있다. 아울러, 분산 학습도 쉽게 만든다.

PyTorch Lightning 기반 그래프 신경망 벤치마크
PyTorch Geometric을 사용하여 모델을 구축하고, 내장된 데이터 세트인 "PROTEINS"을 이용해 학습해 본다.

이 데이터세트 에는 Ivanov 등 논문에 설명된 데이터 세트 정리된 버전이 포함되어 있다 . 이 데이터세트 이외에 PyTorch Geometric의 데이터 세트 모듈의 일부로 사용할 수 있다. MNIST 의 그래프 버전을 분류 하거나 선택한 그래프 분류 문제로 아래 코드에 사용된 데이터 세트를 대체할 수 있다.

그래프 딥 러닝 라이브러리인 PyTorch Geometric은 에지(인접 행렬), 노드 특성, 에지 특성 및 그래프 인덱스를 나타내는 단일 매트릭스 세트로 묶어진다. 

PyTorch Lightning 및 PyTorch Geometric 가상 환경 설정
PyTorch Geometric을 Lightning과 함께 사용하고 프로젝트를 자체 가상 환경에 보관할 것이다.

virtualenv venv
source venv/bin/activate
pip install pytorch-lightning
pip install torch-scatter 
pip install torch-sparse 
pip install torch-cluster  
pip install  torch-spline-conv  
pip install torch-geometric  


다음으로 프로젝트 구축을 할 것이다. 이 프로젝트는 데이터셋 가져오기, 모델 정의, 교육 루프 정의, 데이터 세트 및 데이터 로더를 인스턴스화하고, 학습 루프를 호출하기 위한 루틴으로 구성된다.

라이브러리 임포트
그래프 분류를 위한 모듈을 작성할 때 가장 먼저 해야 할 일은 라이브러리 임포트이다. 다음과 같이 코딩한다.

import time
import numpy as np
import torch
from torch.nn import Dropout, Linear, ReLU
import torch_geometric
from torch_geometric.datasets import TUDataset, GNNBenchmarkDataset
from torch_geometric.loader import DataLoader
from torch_geometric.nn import GCNConv, Sequential, global_mean_pool
# this import is only used in the plain PyTorch+Geometric version
from torch.utils.tensorboard import SummaryWriter
# these imports are only used in the Lighning version
import pytorch_lightning as pl
import torch.nn.functional as F

모델 구축
PyTorch Geometric를 이용해 그래프 컨벌루션 네트워크를 구축한다. PlainGCN모델은 nn.Module 클래스에서 상속하고, PyTorch Geometric의 모델 클래스를 사용하여 순방향 학습 경로를 정의한다.

성능 비교를 위해, 파이토치 버전과 파이토치 라이트닝 버전 두개 클래스 소스를 준비한다. 
class PlainGCN(torch.nn.Module):
    def __init__(self, **kwargs):
        super(PlainGCN, self).__init__()
        self.num_features = kwargs["num_features"] \
            if "num_features" in kwargs.keys() else 3
        self.num_classes = kwargs["num_classes"] \
            if "num_classes" in kwargs.keys() else 2
        # hidden layer node features
        self.hidden = 256
        self.model = Sequential("x, edge_index, batch_index", [
                (GCNConv(self.num_features, self.hidden), 
                    "x, edge_index -> x1"),
                (ReLU(), "x1 -> x1a"),
                (Dropout(p=0.5), "x1a -> x1d"),
                (GCNConv(self.hidden, self.hidden), "x1d, edge_index -> x2"), 
                (ReLU(), "x2 -> x2a"),
                (Dropout(p=0.5), "x2a -> x2d"),
                (GCNConv(self.hidden, self.hidden), "x2d, edge_index -> x3"), 
                (ReLU(), "x3 -> x3a"),
                (Dropout(p=0.5), "x3a -> x3d"),
                (GCNConv(self.hidden, self.hidden), "x3d, edge_index -> x4"), 
                (ReLU(), "x4 -> x4a"),
                (Dropout(p=0.5), "x4a -> x4d"),
                (GCNConv(self.hidden, self.hidden), "x4d, edge_index -> x5"), 
                (ReLU(), "x5 -> x5a"),
                (Dropout(p=0.5), "x5a -> x5d"),
                (global_mean_pool, "x5d, batch_index -> x6"),
                (Linear(self.hidden, self.num_classes), "x6 -> x_out")])    
       
    def forward(self, graph_data):
        x, edge_index, batch = graph_data.x, graph_data.edge_index,\
                    graph_data.batch
        x_out = self.model(x, edge_index, batch)
        return x_out


다음은 라이트닝 라이브러리를 사용한 코드이다. 

class LightningGCN(pl.LightningModule):
    def __init__(self, **kwargs):
        super(LightningGCN, self).__init__()
        self.num_features = kwargs["num_features"] \
                    if "num_features" in kwargs.keys() else 3
        self.num_classes = kwargs["num_classes"] \
                    if "num_classes" in kwargs.keys() else 2
        # hidden layer node features
        self.hidden = 256 
        self.model = Sequential("x, edge_index, batch_index", [\                
                (GCNConv(self.num_features, self.hidden), \
                    "x, edge_index -> x1"),
                (ReLU(), "x1 -> x1a"),\                                         
                (Dropout(p=0.5), "x1a -> x1d"),\                                
                (GCNConv(self.hidden, self.hidden), "x1d, edge_index -> x2"), \ 
                (ReLU(), "x2 -> x2a"),\                                         
                (Dropout(p=0.5), "x2a -> x2d"),\                                
                (GCNConv(self.hidden, self.hidden), "x2d, edge_index -> x3"), \ 
                (ReLU(), "x3 -> x3a"),\                                         
                (Dropout(p=0.5), "x3a -> x3d"),\                                
                (GCNConv(self.hidden, self.hidden), "x3d, edge_index -> x4"), \ 
                (ReLU(), "x4 -> x4a"),\                                         
                (Dropout(p=0.5), "x4a -> x4d"),\                                
                (GCNConv(self.hidden, self.hidden), "x4d, edge_index -> x5"), \ 
                (ReLU(), "x5 -> x5a"),\                                         
                (Dropout(p=0.5), "x5a -> x5d"),\                                
                (global_mean_pool, "x5d, batch_index -> x6"),\                  
                (Linear(self.hidden, self.num_classes), "x6 -> x_out")])        

    def forward(self, x, edge_index, batch_index):
        x_out = self.model(x, edge_index, batch_index)
        return x_out

    def training_step(self, batch, batch_index):
        x, edge_index = batch.x, batch.edge_index
        batch_index = batch.batch
        x_out = self.forward(x, edge_index, batch_index)
        loss = F.cross_entropy(x_out, batch.y)
        # metrics here
        pred = x_out.argmax(-1)
        label = batch.y
        accuracy = (pred == label).sum() / pred.shape[0]
        self.log("loss/train", loss)
        self.log("accuracy/train", accuracy)
        return loss

    def validation_step(self, batch, batch_index):
        x, edge_index = batch.x, batch.edge_index
        batch_index = batch.batch
        x_out = self.forward(x, edge_index, batch_index)
        loss = F.cross_entropy(x_out, batch.y)
        pred = x_out.argmax(-1)
        return x_out, pred, batch.y

    def validation_epoch_end(self, validation_step_outputs):
        val_loss = 0.0
        num_correct = 0
        num_total = 0
        for output, pred, labels in validation_step_outputs:
            val_loss += F.cross_entropy(output, labels, reduction="sum")
            num_correct += (pred == labels).sum()
            num_total += pred.shape[0]
            val_accuracy = num_correct / num_total
            val_loss = val_loss / num_total
        self.log("accuracy/val", val_accuracy)
        self.log("loss/val", val_loss)

    def configure_optimizers(self):
        return torch.optim.Adam(self.parameters(), lr = 3e-4)

성능 평가
텐서보드와 같은 라이브러리를 사용하여 학습 진행 상황과 체크포인트를 기록할 수 있지만, 이 예제에서는 Lightning을 사용한 학습과 PyTorch만을 비교하고 있다.

def evaluate(model, test_loader, save_results=True, tag="_default", verbose=False):
    # get test accuracy score
    num_correct = 0.
    num_total = 0.
    my_device = "cuda" if torch.cuda.is_available() else "cpu"
    criterion = torch.nn.CrossEntropyLoss(reduction="sum")
    model.eval()
    total_loss = 0
    total_batches = 0
    for batch in test_loader:
        pred = model(batch.to(my_device))
        loss = criterion(pred, batch.y.to(my_device))
        num_correct += (pred.argmax(dim=1) == batch.y).sum()
        num_total += pred.shape[0]
        total_loss += loss.detach()
        total_batches += batch.batch.max()
    test_loss = total_loss / total_batches
    test_accuracy = num_correct / num_total
    if verbose:
        print(f"accuracy = {test_accuracy:.4f}")
    results = {"accuracy": test_accuracy, \
        "loss": test_loss, \
        "tag": tag }
    return results

학습 방법
학습 루프를 정의해 보겠다. 이것을 함수로 정의하고 평가 호출을 수행한다. 학습 데이터 로더의 배치를 통해 하나의 큰 루프 내에서 진행 상황을 저장한다.


def train_model(model, train_loader, criterion, optimizer, num_epochs=1000, \
verbose=True, val_loader=None, save_tag="default_run_"):
## call validation function and print progress at each epoch end
display_every = 1 #num_epochs // 10
my_device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(my_device)
# we'll log progress to tensorboard
log_dir = f"lightning_logs/plain_model_{str(int(time.time()))[-8:]}/"
writer = SummaryWriter(log_dir=log_dir)
t0 = time.time()
for epoch in range(num_epochs):
total_loss = 0.0
batch_count = 0
for batch in train_loader:
optimizer.zero_grad()
pred = model(batch.to(my_device))
loss = criterion(pred, batch.y.to(my_device))
loss.backward()
optimizer.step()
total_loss += loss.detach()
batch_count += 1
mean_loss = total_loss / batch_count
writer.add_scalar("loss/train", mean_loss, epoch)

if epoch % display_every == 0:
train_results = evaluate(model, train_loader, \
tag=f"train_ckpt_{epoch}_", verbose=False)
train_loss = train_results["loss"]
train_accuracy = train_results["accuracy"]

if verbose:
print(f"training loss & accuracy at epoch {epoch} = "\
f"{train_loss:.4f} & {train_accuracy:.4f}")

if val_loader is not None:
val_results = evaluate(model, val_loader, \
tag=f"val_ckpt_{epoch}_", verbose=False)
val_loss = val_results["loss"]
val_accuracy = val_results["accuracy"]

if verbose:
print(f"val. loss & accuracy at epoch {epoch} = "\
f"{val_loss:.4f} & {val_accuracy:.4f}")
else:
val_loss = float("Inf")
val_acc = - float("Inf")

writer.add_scalar("loss/train_eval", train_loss, epoch)
writer.add_scalar("loss/val", val_loss, epoch)
writer.add_scalar("accuracy/train", train_accuracy, epoch)
writer.add_scalar("accuracy/val", val_accuracy, epoch)

데이터 세트, 데이터 로더 및 통합
TUDataset PROTEINs 데이터 세트, GNNBenchmarkDataset의 MNIST 그래프 버전에서 훈련할지 여부를 선택할 수 있다. 

if __name__ == "__main__":
# choose the TUDataset or MNIST, 
# or another graph classification problem if preferred
dataset = TUDataset(root="./tmp", name="PROTEINS")
#dataset = GNNBenchmarkDataset(root="./tmp", name="MNIST")
# shuffle dataset and get train/validation/test splits
dataset = dataset.shuffle()
num_samples = len(dataset)
batch_size = 32
num_val = num_samples // 10
val_dataset = dataset[:num_val]
test_dataset = dataset[num_val:2 * num_val]
train_dataset = dataset[2 * num_val:]
train_loader = DataLoader(train_dataset, batch_size=batch_size)
test_loader = DataLoader(test_dataset, batch_size=batch_size)
val_loader = DataLoader(val_dataset, batch_size=batch_size)

for batch in train_loader:
num_features = batch.x.shape[1]
num_classes = dataset.num_classes

# TODO

앞의 코드의 # TODO 밑에 다음 코드를 추가하고 실행해 본다. 
일반적인 버전 코드는 다음과 같다. 

plain_model = PlainGCN(num_features=num_features, num_classes=num_classes)
lr = 1e-5
num_epochs = 2500
criterion = torch.nn.CrossEntropyLoss(reduction="sum")
optimizer = torch.optim.Adam(plain_model.parameters(), lr=lr)
train_model(plain_model, train_loader, criterion, optimizer,\
num_epochs=num_epochs, verbose=True, \
val_loader=val_loader)

Lightning 버전에서는 클래스를 사용하여 Trainer을 호출하고 학습을 조정한다. 

lightning_model = LightningGCN(num_features=num_features, num_classes=num_classes)
num_epochs = 2500
val_check_interval = len(train_loader)
trainer = pl.Trainer(max_epochs = num_epochs, \
val_check_interval=val_check_interval, gpus=[0])
trainer.fit(lightning_model, train_loader, val_loader)

코드 실행 결과는 다음과 같다. 

테스트 결과는 다음과 같다. 


마무리
PyTorch Lightning에서 Trainer 개체는 GPU, TPU 및 IPU와 같은 다양한 하드웨어 가속기에 대한 할당을 처리한다. 이를 통해 Google의 TPU 또는 Graphcore의 IPU와 같은 ASIC 하드웨어 가속기로 확장하는 것도 훨씬 쉽다.

Lightning은 각각의 새 프로젝트에 대한 상용구 코드의 양을 줄이고 하드웨어 가속을 쉽게 적용하는 좋은 방법이다.

부록: GNN 개발 역사
그래프 신경망(Graph Neural Network, GNN)은 그래프(Graph) 구조의 데이터를 직접 처리하고 학습하기 위해 설계된 딥러닝 모델이다. 노드(Node)와 그 노드를 연결하는 엣지(Edge)로 구성된 데이터를 입력받아, 노드 간의 관계 및 그래프 전체의 구조적 특성을 학습하는 데 특화되어 있다.

전통적인 딥러닝 모델인 CNN(Convolutional Neural Network)이나 RNN(Recurrent Neural Network)은 이미지나 텍스트, 시계열 데이터와 같이 격자(Grid) 형태의 정형화된 데이터 구조에서 뛰어난 성능을 보인다. 하지만 소셜 네트워크, 분자 구조, 인용 네트워크, 지식 그래프 등 현실 세계의 많은 데이터는 비정형적인 관계를 표현하는 그래프 구조로 이루어져 있다. 이러한 비유클리드(Non-Euclidean) 공간의 데이터를 기존 모델에 적용하기는 매우 어렵다. GNN은 이러한 한계를 극복하고, 불규칙적인 노드 연결과 다양한 이웃 관계를 효과적으로 학습하기 위한 필요성에서 개발되었다.

GNN에 대한 초기 아이디어는 2000년대 중반부터 제시되었다. Marco Gori, Franco Scarselli 등의 연구자들이 2009년 논문 "The Graph Neural Network Model"을 통해 GNN의 개념을 공식적으로 정립하며 재귀적인 방식으로 노드 정보를 업데이트하는 프레임워크를 제안했다.

이후 2010년대 중반, 딥러닝의 발전과 함께 GNN 연구는 급물살을 탔다. 특히 2017년 Thomas Kipf와 Max Welling이 발표한 "Semi-Supervised Classification with Graph Convolutional Networks" 논문은 GCN(Graph Convolutional Network)이라는 효율적이고 확장 가능한 모델을 제시하며 GNN의 대중화를 이끌었다. GCN은 스펙트럼(Spectral) 방식의 그래프 이론을 간소화하여 CNN의 합성곱 연산을 그래프에 효과적으로 적용했으며, 이후 GAT(Graph Attention Networks), GraphSAGE 등 수많은 GNN 변형 모델의 기반이 되었다.

대부분의 현대 GNN 모델은 메시지 전달이라는 핵심적인 프레임워크를 따른다. 이는 각 노드가 자신의 이웃 노드들과 정보를 교환하여 자신의 상태(특징 벡터)를 점진적으로 갱신하는 과정이다. 이 과정은 3단계로 구성된다.
  • 메시지 생성 (Message Generation): 각 노드는 자신의 이웃에게 전달할 메시지를 생성한다. 이는 주로 노드의 현재 특징 벡터를 활용한다.
  • 정보 취합 (Aggregation): 각 노드는 이웃들로부터 받은 메시지들을 하나의 벡터로 취합한다. 이웃의 순서가 결과에 영향을 주지 않아야 하므로, 주로 합(Sum), 평균(Mean), 최댓값(Max)과 같은 순서 불변(Permutation Invariant) 연산을 사용한다.
  • 업데이트 (Update): 각 노드는 취합된 이웃의 정보와 자기 자신의 이전 상태 정보를 결합하여 새로운 특징 벡터를 계산한다. 이 과정에 신경망 레이어와 활성화 함수가 사용된다.
이 메시지 전달 과정을 GNN 레이어 수만큼 반복한다. 레이어를 한 번 통과하면 1-홉(hop) 이웃 정보가 취합되며, 더 많은 레이어를 통과해 더 넓은 범위의 그래프 구조를 학습하게 된다.

PyTorch Geometric (PyG)은 PyTorch를 기반으로 GNN 모델을 쉽고 효율적으로 구현할 수 있도록 돕는 라이브러리이다. 그래프 데이터 처리를 위한 표준화된 데이터 객체, 다양한 GNN 레이어, 벤치마크 데이터셋 등을 제공하여 연구 및 개발 생산성을 크게 향상시킨다.

PyG는 하나의 그래프를 torch_geometric.data.Data 객체로 표현한다. 이 객체는 다음과 같은 핵심 속성을 포함한다.
  • data.x: 노드 특징 행렬. [노드 개수, 특징 벡터 차원] 형태를 가진다. 각 노드의 고유한 속성을 나타낸다.
  • data.edge_index: 엣지 연결성 정보. [2, 엣지 개수] 형태를 가지며, 어떤 노드들이 연결되어 있는지를 COO(Coordinate) 형식으로 저장한다. 첫 번째 행은 출발 노드, 두 번째 행은 도착 노드의 인덱스이다.
  • data.y: 레이블 정보. 노드 또는 그래프 단위의 정답 값이다.
  • data.edge_attr: 엣지 특징 행렬. [엣지 개수, 엣지 특징 차원] 형태를 가지며, 각 연결 관계가 갖는 고유한 속성을 나타낸다.
부록: 추리소설에서 주변 정보 입력 시 사건발생 장소 탐지하는 GNN 모델 학습 예시
소설 텍스트에서 NER(Named Entity Recognition) 기술을 통해 추출한 개체(인물, 조직)와 관계(동사) 데이터를 그래프로 구성한다. 이후 GNN 모델을 학습시켜, 각 개체가 주로 활동하는 장소를 예측하는 것을 목표로 한다. 이는 텍스트 속 개체들의 관계성을 바탕으로 숨겨진 문맥 정보를 추론하는 문제이다.

노드 정의: 텍스트에 등장하는 명사 개체(사람, 조직)를 그래프의 노드로 정의한다.
엣지 정의: 두 노드(개체) 사이의 관계를 나타내는 동사를 그래프의 엣지로 정의한다.
엣지 속성 정의: 동사의 종류(e.g., 'investigated', 'met')가 다양하므로, 이를 엣지의 고유한 속성(edge_attr)으로 지정하여 관계의 의미를 GNN이 학습하도록 한다.
레이블 정의: 각 노드(개체)가 언급된 문장의 장소 정보를 예측해야 할 정답 레이블(y)로 지정한다.

단순한 연결 여부뿐만 아니라 엣지의 종류(동사)까지 고려해야 하므로, 일반적인 GCN 대신 엣지 속성을 처리할 수 있는 관계형 GNN 모델을 구현한다. 이는 PyG의 MessagePassing 클래스를 상속받아 직접 정의할 수 있다. 모델의 메시지 생성 단계에서 이웃 노드의 특징뿐만 아니라 관계(동사)의 임베딩 벡터를 함께 고려하여 더 정교한 정보 전파를 수행한다.

코드 구현은 다음과 같다. 

# 1-1. 예시 추리 소설 텍스트
novel_text = """
Detective Harding investigated Blackwood Corp at the old library.
Mr. Blackwood, the CEO, met Harding in the dusty archive room.
Later, Harding accused Mr. Blackwood inside the main hall.
The security team from Blackwood Corp observed the detective carefully from the security office.
"""

# 1-2. BERT NER 모델이 텍스트에서 추출했다고 가정한 데이터
# 구조: {'subject': 주어, 'verb': 관계, 'object': 목적어, 'location': 장소}
ner_results = [
    {'subject': 'Harding', 'verb': 'investigated', 'object': 'Blackwood Corp', 'location': 'library'},
    {'subject': 'Mr. Blackwood', 'verb': 'met', 'object': 'Harding', 'location': 'archive room'},
    {'subject': 'Harding', 'verb': 'accused', 'object': 'Mr. Blackwood', 'location': 'main hall'},
    {'subject': 'Blackwood Corp', 'verb': 'observed', 'object': 'Harding', 'location': 'security office'}
]

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.data import Data
from torch_geometric.nn import MessagePassing
import numpy as np

# 1. 샘플 텍스트에서 추출했다고 가정한 NER 데이터
ner_results = [
    {'subject': 'Harding', 'verb': 'investigated', 'object': 'Blackwood Corp', 'location': 'library'},
    {'subject': 'Mr. Blackwood', 'verb': 'met', 'object': 'Harding', 'location': 'archive room'},
    {'subject': 'Harding', 'verb': 'accused', 'object': 'Mr. Blackwood', 'location': 'main hall'},
    {'subject': 'Blackwood Corp', 'verb': 'observed', 'object': 'Harding', 'location': 'security office'}
]

# 2. NER 데이터를 GNN 학습용 데이터셋으로 변환
# --- 어휘 사전 구축 ---
entities = sorted(list(set([d['subject'] for d in ner_results] + [d['object'] for d in ner_results])))
verbs = sorted(list(set([d['verb'] for d in ner_results])))
locations = sorted(list(set([d['location'] for d in ner_results])))

entity_to_idx = {name: i for i, name in enumerate(entities)}
verb_to_idx = {name: i for i, name in enumerate(verbs)}
location_to_idx = {name: i for i, name in enumerate(locations)}
idx_to_entity = {i: name for name, i in entity_to_idx.items()}
idx_to_location = {i: name for name, i in location_to_idx.items()}

num_nodes = len(entities)
num_locations = len(locations)

# --- 엣지(관계) 정보 생성 ---
edge_starts, edge_ends, edge_attrs = [], [], []
for rel in ner_results:
    start_idx, end_idx = entity_to_idx[rel['subject']], entity_to_idx[rel['object']]
    verb_idx = verb_to_idx[rel['verb']]
    edge_starts.extend([start_idx, end_idx])
    edge_ends.extend([end_idx, start_idx])
    edge_attrs.extend([verb_idx, verb_idx])

edge_index = torch.tensor([edge_starts, edge_ends], dtype=torch.long)
edge_attr = torch.tensor(edge_attrs, dtype=torch.long)

# --- 노드별 레이블(장소) 생성 ---
node_labels = -np.ones(num_nodes, dtype=int)
for rel in ner_results:
    for entity_type in ['subject', 'object']:
        entity_idx = entity_to_idx[rel[entity_type]]
        if node_labels[entity_idx] == -1:
            node_labels[entity_idx] = location_to_idx[rel['location']]
y = torch.tensor(node_labels, dtype=torch.long)

# --- 최종 PyG Data 객체 생성 ---
x = torch.arange(num_nodes, dtype=torch.long)
graph_data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr, y=y)

print("--- 변환된 그래프 데이터 ---")
print(graph_data)

# 3. 관계형 GNN 모델 정의
EMBEDDING_DIM = 16
HIDDEN_DIM = 32

class RelationalGNNLayer(MessagePassing):
    def __init__(self, in_dim, out_dim, num_relations):
        super().__init__(aggr='mean')
        self.relation_embedding = nn.Embedding(num_relations, in_dim)
        self.lin_message = nn.Linear(in_dim * 2, out_dim)
        self.lin_update = nn.Linear(in_dim + out_dim, out_dim)

    def forward(self, x, edge_index, edge_attr):
        return self.propagate(edge_index, x=x, edge_attr=edge_attr)

    def message(self, x_j, edge_attr):
        rel_emb = self.relation_embedding(edge_attr)
        message = torch.cat([x_j, rel_emb], dim=-1)
        return self.lin_message(message)

    def update(self, aggr_out, x):
        update_input = torch.cat([x, aggr_out], dim=-1)
        return self.lin_update(update_input)

class GNN_Model(nn.Module):
    def __init__(self, num_nodes, num_relations, num_locations):
        super().__init__()
        self.node_embedding = nn.Embedding(num_nodes, EMBEDDING_DIM)
        self.conv1 = RelationalGNNLayer(EMBEDDING_DIM, HIDDEN_DIM, num_relations)
        self.conv2 = RelationalGNNLayer(HIDDEN_DIM, HIDDEN_DIM, num_relations)
        self.lin_out = nn.Linear(HIDDEN_DIM, num_locations)

    def forward(self, data):
        x = self.node_embedding(data.x)
        x = F.relu(self.conv1(x, data.edge_index, data.edge_attr))
        x = F.relu(self.conv2(x, data.edge_index, data.edge_attr))
        out = self.lin_out(x)
        return F.log_softmax(out, dim=-1)

# 4. 모델 학습 및 결과 예측
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = GNN_Model(num_nodes, len(verbs), num_locations).to(device)
data = graph_data.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
criterion = nn.CrossEntropyLoss()

model.train()
for epoch in range(200):
    optimizer.zero_grad()
    out = model(data)
    loss = criterion(out, data.y)
    loss.backward()
    optimizer.step()
    if epoch % 20 == 0:
        print(f"Epoch {epoch:03d}, Loss: {loss:.4f}")

model.eval()
with torch.no_grad():
    predictions = model(data).argmax(dim=1)

print("\n--- 최종 예측 결과 ---")
for node_idx, pred_loc_idx in enumerate(predictions):
    entity_name = idx_to_entity[node_idx]
    predicted_location = idx_to_location[pred_loc_idx.item()]
    true_location = idx_to_location[data.y[node_idx].item()]
    print(f"개체: {entity_name:<15} | 예측 장소: {predicted_location:<15} | 실제 장소: {true_location}")

부록: 단백질 분자 정보에서 단백질 기능 추론하는 GNN 모델 학습 예시
단백질은 단독으로 기능하기보다 세포 내에서 서로 복잡한 상호작용 네트워크를 형성하여 다양한 생명 현상을 조절한다. 따라서 개별 단백질의 특성만으로는 그 기능을 완전히 이해하기 어렵다. 단백질-단백질 상호작용(PPI) 네트워크를 그래프로 모델링하고 GNN을 적용하면, 각 단백질이 어떤 이웃 단백질과 상호작용하는지에 대한 구조적 문맥을 학습하여 그 기능을 보다 정확하게 예측할 수 있다.

이 노트에서는 PyTorch Geometric 라이브러리에 포함된 PPI 벤치마크 데이터셋을 사용하여, 각 단백질의 생물학적 기능을 예측하는 GNN 모델을 구현하는 예시를 다룬다.

PPI 네트워크에 속한 각 단백질이 어떤 생물학적 기능(Biological Functions)을 수행하는지 예측한다. 이는 그래프의 노드 분류(Node Classification) 문제에 해당한다. 한 단백질이 여러 기능을 동시에 수행할 수 있으므로, 정확히는 다중 레이블 분류(Multi-label Classification) 문제가 된다.
  • 노드 (Node): 개별 단백질.
  • 노드 특징 (Node Features): 단백질의 특성을 나타내는 50차원의 벡터. 여기에는 위치 유전자 집합(positional gene sets), 모티프 유전자 집합(motif gene sets), 면역학적 특징(immunological signatures) 등이 포함된다.
  • 엣지 (Edge): 두 단백질 간의 알려진 물리적 상호작용.
레이블 (Label): 각 단백질이 수행하는 121가지의 생물학적 기능(Gene Ontology sets). 각 기능의 수행 여부가 0 또는 1로 표시된 121차원의 멀티-핫(multi-hot) 벡터이다. 원본 데이터는 일반적으로 그래프 구조를 담은 JSON 파일과, 대규모 행렬 데이터(특징, 레이블)를 효율적으로 저장하는 NumPy 배열 파일(.npy)의 조합으로 디스크에 저장된다. 이해를 돕기 위해, 4개의 단백질과 3개의 상호작용으로 구성된 아주 작은 PPI 네트워크가 파일로 저장된 상황을 가정한다.

PyTorch Geometric이 이 그래프를 처리하면, 보통 아래와 같은 파일들이 생성된다.
  • graph.json : 그래프의 연결 구조(엣지) 정보
  • features.npy : 각 단백질(노드)의 특징 벡터
  • labels.npy : 각 단백질의 기능(레이블) 정보

graph.json (그래프 구조 파일): 어떤 단백질이 어떤 단백질과 상호작용하는지에 대한 정보를 담는다. 인간이 읽을 수 있는 텍스트 형식이다.

포맷 예시:
{
"nodes": [
{"id": 0, "test": false, "val": false},
{"id": 1, "test": false, "val": false},
{"id": 2, "test": false, "val": false},
{"id": 3, "test": false, "val": false}
],
"links": [
{"source": 0, "target": 1},
{"source": 0, "target": 2},
{"source": 1, "target": 3}
]
}

  • "nodes": 그래프에 포함된 전체 노드(단백질)의 목록이다. 각 id는 단백질의 고유 인덱스이다.
  • "links" (또는 edges): 노드 간의 연결, 즉 단백질 상호작용을 나타낸다.
  • {"source": 0, "target": 1}은 0번 단백질과 1번 단백질이 상호작용함을 의미한다.
  • Data 객체 매핑: PyG 라이브러리는 이 "links" 목록을 읽어 [2, 엣지 수] 형태의 data.edge_index 텐서로 변환한다. (예: tensor([[0, 0, 1], [1, 2, 3]]))

features.npy (노드 특징 파일): 각 단백질의 50차원 특징 벡터를 저장한다. 대규모 숫자 데이터를 위한 효율적인 이진(binary) 포맷이다.

포맷 예시 (파일 내용을 Numpy 배열로 표현):
# Shape: [4, 50]
array([[0.1, 0.8, ..., 0.2], # 0번 단백질의 50차원 특징 벡터
[0.5, 0.2, ..., 0.9], # 1번 단백질의 50차원 특징 벡터
[0.9, 0.1, ..., 0.4], # 2번 단백질의 50차원 특징 벡터
[0.3, 0.7, ..., 0.6]]) # 3번 단백질의 50차원 특징 벡터

각 행은 graph.json의 id 순서에 해당하는 단백질의 특징 벡터이다. 이 50개의 숫자값으로 이루어진 벡터는 각 단백질의 생물학적 정체성을 요약한 '디지털 지문'과 같다. 여기에는 단백질의 아미노산 서열 패턴(모티프), 유전자의 염색체 위치, 면역 체계와의 상호작용 정보 등이 수치화되어 포함된다. GNN은 이 숫자 지문을 보고 각 단백질의 초기 상태를 파악한다. 이 파일의 내용은 data.x 텐서에 해당한다.

labels.npy (노드 레이블 파일): 각 단백질의 121개 기능에 대한 멀티-핫 인코딩된 레이블을 저장한다. 특징 파일과 마찬가지로 .npy 이진 포맷을 사용한다.

포맷 예시 (파일 내용을 Numpy 배열로 표현):
# Shape: [4, 121]
array([[0, 1, 0, ..., 1, 0], # 0번 단백질의 121개 기능 레이블
[1, 1, 0, ..., 0, 0], # 1번 단백질의 121개 기능 레이블
[0, 0, 0, ..., 1, 1], # 2번 단백질의 121개 기능 레이블
[1, 0, 1, ..., 0, 0]]) # 3번 단백질의 121개 기능 레이블

각 행은 해당 단백질이 미리 정의된 121가지 기능 목록 중 어떤 것을 수행하는지를 나타낸다. 이 기능 목록에는 '세포 주기 조절', '신호 전달 경로', 'DNA 복구', '세포 사멸'과 같은 구체적인 생물학적 프로세스들이 포함된다. 여기서 사용된 멀티-핫 인코딩 방식은 한 단백질이 여러 기능을 동시에 가질 수 있다는 사실을 반영한다. 예를 들어, 기능 목록의 두 번째가 'DNA 복구'이고 세 번째가 '세포 사멸'일 때, 어떤 단백질의 레이블 벡터가 [0, 1, 1, ..., 0]이라면, 이는 해당 단백질이 'DNA 복구'와 '세포 사멸' 기능을 동시에 수행함을 의미한다. 각 자리의 1은 해당 기능의 수행 여부를 표시하며, GNN은 이 정답 벡터와 가장 유사한 예측 벡터를 출력하도록 학습된다. 이 파일의 내용은 data.y 텐서에 해당한다.

PyTorch Geometric의 PPI 데이터셋 클래스는 내부적으로 위와 같은 json 및 npy 파일들을 읽어들인 후, 이를 종합하여 GNN 모델이 바로 사용할 수 있는 Data(x, edge_index, y) 객체를 메모리에 생성해준다. 이처럼 데이터 로딩 과정이 자동화되어 있기 때문에, 개발자는 데이터의 물리적 저장 형식보다 GNN 모델의 논리적 구조 설계에 더 집중할 수 있다.

이번 예시에서는 일반적인 GCN보다 대규모 그래프에 더 효율적이라고 알려진 GraphSAGE 모델을 사용한다. GraphSAGE는 이웃 노드의 특징을 모두 사용하는 대신, 일부를 샘플링하여 정보를 취합하므로 확장성이 뛰어나다.

전체 구현 코드는 protein_function_prediction.py 파일에서 확인할 수 있다.
  • 데이터 로딩 및 다운로드: PPI(root='/tmp/PPI', split='train') 코드는 지정된 root 경로에 데이터셋이 있는지 확인하고, 없을 경우 자동으로 다운로드를 진행한다. DataLoader는 여러 그래프로 구성된 데이터셋을 효율적으로 미니배치 처리하는 역할을 한다.
  • 모델 구조: 3개의 GraphSAGE 레이어를 통해 단백질 노드들은 이웃 단백질과 3-홉(hop)까지 정보를 교환하며 자신의 기능적 문맥을 학습한다.
  • 손실 함수 및 평가: 다중 레이블 분류 문제이므로 BCEWithLogitsLoss를 손실 함수로, Micro F1-Score를 평가 지표로 사용하여 모델의 종합적인 성능을 측정한다.
개발 코드는 다음과 같다. 
import torch
import torch.nn.functional as F
from sklearn.metrics import f1_score
from torch_geometric.datasets import PPI
from torch_geometric.loader import DataLoader
from torch_geometric.nn import GraphSAGE

# 1. PPI 데이터셋 로드 및 다운로드
# PyTorch Geometric의 PPI 클래스는 root 디렉토리에 데이터가 없으면 자동으로 다운로드합니다.
# 데이터셋은 이미 학습(20개 그래프), 검증(2개 그래프), 테스트(2개 그래프)용으로 분리되어 있습니다.
train_dataset = PPI(root='/tmp/PPI', split='train')
val_dataset = PPI(root='/tmp/PPI', split='val')
test_dataset = PPI(root='/tmp/PPI', split='test')

print(f"Number of training graphs: {len(train_dataset)}")
print(f"Number of validation graphs: {len(val_dataset)}")
print(f"Number of test graphs: {len(test_dataset)}")

# 여러 그래프로 구성된 데이터셋을 효율적으로 처리하기 위해 DataLoader를 사용합니다.
train_loader = DataLoader(train_dataset, batch_size=1, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=2, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=2, shuffle=False)

# 2. GraphSAGE 모델 정의
class SAGE_Model(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels):
        super().__init__()
        # 3개의 GraphSAGE 레이어를 사용합니다. aggr='mean'은 이웃 노드 정보를 평균내어 취합합니다.
        self.conv1 = GraphSAGE(in_channels, hidden_channels, aggr='mean')
        self.conv2 = GraphSAGE(hidden_channels, hidden_channels, aggr='mean')
        self.conv3 = GraphSAGE(hidden_channels, out_channels, aggr='mean')

    def forward(self, x, edge_index):
        # 메시지 전달 과정
        # Layer 1
        x = F.relu(self.conv1(x, edge_index))
        # Layer 2
        x = F.relu(self.conv2(x, edge_index))
        # Layer 3 (출력 레이어는 활성화 함수를 거치지 않습니다)
        x = self.conv3(x, edge_index)
        return x

# 3. 모델, 옵티마이저, 손실 함수 설정
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SAGE_Model(
    in_channels=train_dataset.num_features,
    hidden_channels=256,
    out_channels=train_dataset.num_classes
).to(device)

# 다중 레이블 분류이므로 BCEWithLogitsLoss를 사용합니다.
# 이 손실 함수는 내부적으로 출력에 시그모이드 함수를 적용합니다.
criterion = torch.nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.005)

# 4. 모델 학습 함수
def train():
    model.train()
    total_loss = 0
    for data in train_loader:
        data = data.to(device)
        optimizer.zero_grad()
        logits = model(data.x, data.edge_index)
        loss = criterion(logits, data.y)
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * data.num_graphs
    return total_loss / len(train_loader.dataset)

# 5. 모델 평가 함수 (F1 Score 사용)
@torch.no_grad()
def test(loader):
    model.eval()
    all_preds = []
    all_labels = []
    for data in loader:
        data = data.to(device)
        logits = model(data.x, data.edge_index)
        # 로짓(logit)이 0보다 크면 1, 아니면 0으로 예측합니다. (시그모이드 결과가 0.5보다 큰 것과 동일)
        preds = (logits > 0).float().cpu()
        all_preds.append(preds)
        all_labels.append(data.y.cpu())
    
    # Micro F1-Score: 전체 클래스의 예측을 종합하여 F1 점수를 계산합니다.
    micro_f1 = f1_score(torch.cat(all_labels), torch.cat(all_preds), average='micro')
    return micro_f1

# 6. 학습 및 평가 실행
print("\n--- 모델 학습 시작 ---")
for epoch in range(1, 201):
    loss = train()
    # 10 에포크마다 검증 데이터셋으로 성능 평가
    if epoch % 10 == 0:
        val_f1 = test(val_loader)
        print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, Val F1: {val_f1:.4f}')

# 최종 테스트 데이터셋으로 성능 평가
test_f1 = test(test_loader)
print(f'\n--- 최종 테스트 성능 ---')
print(f'Final Test F1 Score: {test_f1:.4f}')

이 예시는 GNN이 어떻게 실제 생물학 데이터에 적용될 수 있는지를 명확히 보여준다. 단백질 간의 상호작용이라는 '관계' 데이터를 학습함으로써, 개별 단백질의 특징만으로는 알기 어려운 '기능'을 효과적으로 추론할 수 있다. 이러한 접근 방식은 신약 개발에서 약물 타겟을 발굴하거나, 질병과 관련된 단백질 모듈을 찾는 등 다양한 바이오인포매틱스 분야에 확장 적용될 수 있다.


레퍼런스