검색어 파이토치에 대한 글을 관련성을 기준으로 정렬하여 표시합니다. 날짜순 정렬 모든 글 표시
검색어 파이토치에 대한 글을 관련성을 기준으로 정렬하여 표시합니다. 날짜순 정렬 모든 글 표시

2023년 3월 9일 목요일

가장 간단한 파이토치 도커 이미지 빌드 및 확장하기

이 글은 가장 간단한 파이토치 도커 이미지 생성하는 방법을 남긴다. 

도커 이미지를 생성하기 전에 고려사항이 있다.
1. 생성할 도커 이미지 내 설치할 패키지, 라이브러리 간 버전 호환성 및 의존성 확인
2. 기존 이미지를 기반으로 확장해 생성할 경우, 기존 이미지의 패키지 버전 호환성 확인
3. 생성할 도커 이미지의 크기 만큼 디스크 용량 확인

이 글은 2번 방식으로 도커 이미지를 생성하는 방법을 간략히 정리한다. 기타, 도커에 대한 설치 등 상세 내용은 다음 링크를 참고한다. 

도커 이미지 확장 빌드 예제
이 경우는 기존 이미지를 이용해 패키지를 설치, 확장 후 새 이미지를 생성한다. 이 경우는 보통 딥러닝 개발 환경 등 설치하기 까다롭고 버전 호환성 문제가 자주 발생하는 경우에 사용한다.

파이토치, CUDA의 특정 버전으로 생성된 이미지를 도커 허브에서 검색한다. 

터미널에서 도커 이미지를 생성할 폴더를 만든다. 그리고 Dockerfile을 편집한다.
mkdir docker
gedit Dockerfile

다음과 같이 도커 명령을 입력한 후 저장한다. 
FROM pytorch/pytorch:1.12.1-cuda11.3-cudnn8-devel
MAINTAINER taewook

RUN apt-get update
RUN apt-get install -y libx11-6 libgl1-mesa-glx
RUN pip install torch tensorboard numpy

이때 도커의 레이어 빌드 메커니즘을 고려해, RUN 을 잘 조합해 정의한다. 

참고로, 도커는 레이어 구조로 설치 패키지를 관리하기 때문에, 이미 빌드된 레이어를 재활용해, 도커 빌드 속도를 크게 개선할 수 있다. 아울러, 빌드된 레이어를 재활용해, 빌드될 이미지를 조합할 수 있다.
도커 레이어 구조

터미널에서 다음과 같이 도커 이미지를 빌드한다.
docker bulid --tag docker_image .


레퍼런스

부록: 자주 사용하는 도커 명령어
docker build -t app_program_docker_image .
docker save -o app_program_docker_image.tar cbc
docker system prune -a -f
docker ps -a
docker image rm -f
docker rmi <image id> --force
docker run -v c:/dep_build/input:/app_program/input -v c:/dep_build/output:/app_program/output app_program_docker_image_name conda run -n venv_app python ./bin/user_app.py --input ./input --output ./output

부록: 도커 이미지 빌드 명령어 예시
FROM image_name
ENV PIP_ROOT_USER_ACTION=ignore
ENV PATH /usr/
RUN apt-get install python3-pip -y
RUN apt-get install python3.9
RUN pip install --upgrade pip 
CMD ["bash"]
CMD tail -f /dev/null

2021년 7월 13일 화요일

Ubuntu 20.04, DOCKER, CUDA 11.0 기반 NVIDIA-DOCKER, 텐서플로우, 파이토치 설치 및 사용기

이 글은 우분투 20.04, 도커, CUDA 11.0 기반 NVIDIA-DOCKER, Tensorflow, Pytorch 설치 및 사용 방법을 간단히 설명한다. NVIDIA-DOCKER를 이용하면, 딥러닝 프레임웍 설치 시 많은 문제를 일으키는 패키지 의존 에러 없이, 미리 설치된 딥러닝 관련 패키지를 이용해 편리하게 딥러닝 모델을 개발할 수 있다. 또한, 아마존, 구글 같이 비싼 기계학습 딥러닝용 클라우드를 사용하지 않고도, 직접 여러사람이 네트워크로 접속할 수 있는 서버를 개발할 수 있다.

NVIDIA DOCKER 아키텍처 구조

여기서는 엔비디아 CUDA및 도커는 알고 있다는 전제하에 진행한다. 관련 내용은 다음 링크를 참고한다.

NVIDIA-DOCKER 설치

설치는 다음 링크를 참고한다.

다 설치한 후 다음 명령을 입력해 정상 출력되면 성공한 것이다.
sudo docker run --rm --gpus all nvidia/cuda:11.0-base nvidia-smi

NVIDIA 도커 이용하기
다음과 같이 NVIDIA 이미지 내 명령을 실행한다.
sudo nvidia-docker run --rm hello-world

apt-cache madison nvidia-docker2 nvidia-container-runtime

sudo docker run -it nvidia/cuda:11.0-base

도커 텐서플로우 설치 및 실행
이제 다음 명령으로 도커 텐서플로우 이미지를 설치하고, 실행한다. 
sudo docker run -it --rm --gpus all tensorflow/tensorflow:latest-gpu python -c "import tensorflow as tf; tf.config.list_physical_devices('GPU')"

텐서플로우 이미지 내 우분투를 실행해 본다. 
sudo docker run -it tensorflow/tensorflow:latest-gpu
exit

텐서플로우 소스코드 예제를 다음과 같이 실행해 본다. 화면처럼 출력되면 성공한 것이다.
cd ~
git clone https://github.com/tensorflow/benchmarks
sudo docker run --gpus all -it --rm -v /home/ktw/benchmarks:/benchmarks tensorflow/tensorflow:latest-gpu python benchmarks/scripts/tf_cnn_benchmarks/tf_cnn_benchmarks.py --num_gpus=1 --model resnet50 --batch_size 64

파치토치 도커 이미지 설치 및 실행
다음과 같이 파이토치 도커 이미지를 설치하고, 실행한다. 참고로, NVIDIA DRIVER가 서로 호환되지 않은 도커 이미지라면 드라이버 호환 에러가 발생한다. 이 경우, 여기 링크에 드라이버 버전을 확인하고, 해당 도커를 설치해 실행하기를 바란다.
sudo docker pull nvcr.io/nvidia/pytorch:21.02-py3
sudo docker run --gpus all -it -p 8888:8888 nvcr.io/nvidia/pytorch:21.02-py3

아래와 같이 파이썬 코드를 입력해 실행해 본다.
python
import torch
print(torch.cuda.device_count())
print(torch.cuda.current_device())
print(torch.cuda.get_device_name(torch.cuda.current_device()))
exit()

쥬피터 노트북 실행
다음과 같이 쥬피터 노트북을 도커에서 실행해 본다.
jupyter notebook

크롬을 실행하고, http://localhost:8888/ 주소를 입력한다.

노트북에 파이썬 모듈을 하나 생성하고 다음 코드를 입력해 실행한다.
Input the below code
import torch import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() # 1 input image channel, 6 output channels, 3x3 square convolution # kernel self.conv1 = nn.Conv2d(1, 6, 3) self.conv2 = nn.Conv2d(6, 16, 3) # an affine operation: y = Wx + b self.fc1 = nn.Linear(16 * 6 * 6, 120) # 6*6 from image dimension self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, 10) def forward(self, x): # Max pooling over a (2, 2) window x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2)) # If the size is a square you can only specify a single number x = F.max_pool2d(F.relu(self.conv2(x)), 2) x = x.view(-1, self.num_flat_features(x)) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) x = self.fc3(x) return x def num_flat_features(self, x): size = x.size()[1:] # all dimensions except the batch dimension num_features = 1 for s in size: num_features *= s return num_features net = Net() print(net)

아래와 같이 출력되면 딥러닝 모델이 정상 실행된 것이다.

이제, 복잡한 딥러닝 패키지를 설치하고 설정할 필요없어, 도커 기반으로 딥러닝 모델을 손쉽게 개발할 수 있다.

참고

2024년 2월 6일 화요일

파이토치 트랜스포머 모델 사용자화를 통한 간단한 자연어 번역기 개발 방법 소개

이 글은 앞서 소개한 트랜스포머 모델을 사용자화하여, 간단한 자연어 번역기를 개발하는 방법을 보여준다. 자연어 번역기 개발을 위한 개념과 사용방법을 간략히 설명한다. 사용된 코드는 Github를 통해 제공된다. 참고로, 이 글에 있는 예제를 실행하기 위해서는 파이토치 라이브러리, GPU 드라이버가 컴퓨터에 설치되어 있어야 한다.
트랜스포머 기반 자연어 번역 개념도

머리말
앞의 글에서 보았듯이 트랜스포머는 자연어 번역을 위해 개발된 것이다. 이 글은 자연어 번역을 위해, 미리 잘 만들어진 파이토치 트랜스포머를 이용하기로 한다. 이 글은 파이토치 관련 레퍼런스와 예제를 참고하였다. 이 글에서 구현되는 코드는 다음 github를 확인한다.
본문
TorchText 라이브러리는 자연어 데이터 세트를 만들기 위한 유틸리티가 있다. 이 예는 torchtext의 내장 데이터 세트를 사용하는 방법을 보여준다. 원시 텍스트 문장을 토큰화하고, 어휘를 구축하고, 토큰을 임베딩하여 텐서로 변환한다. 학습을 위해 Multi30k 데이터셋를 이용해 한 쌍의 원본-대상 문장을 준비한다. 

torchtext 데이터 세트를 다운로드하려면 https://github.com/pytorch/data 에 따라 torchdata를 설치한다. 코드는 다음과 같다.
multi30k.URL["train"] = "https://raw.githubusercontent.com/neychev/small_DL_repo/master/datasets/Multi30k/training.tar.gz"
multi30k.URL["valid"] = "https://raw.githubusercontent.com/neychev/small_DL_repo/master/datasets/Multi30k/validation.tar.gz"

SRC_LANGUAGE = 'de'
TGT_LANGUAGE = 'en'

# Place-holders
token_transform = {}
vocab_transform = {}

token_transform[SRC_LANGUAGE] = get_tokenizer('spacy', language='de_core_news_sm')
token_transform[TGT_LANGUAGE] = get_tokenizer('spacy', language='en_core_web_sm')

print(token_transform[SRC_LANGUAGE]("Eine Gruppe von Menschen steht vor einem Iglu ."))
print(token_transform[TGT_LANGUAGE]("A group of people are standing in front of an igloo ."))  

토큰화 결과

소스, 대상 언어 토크나이저를 위해, 관련된 라이브러리 종속을 설치한다.
pip install -U torchdata
pip install -U spacy
python -m spacy download en_core_web_sm
python -m spacy download de_core_news_sm

학습 데이터를 다음과 같이 준비한다.
token_transform[SRC_LANGUAGE] = get_tokenizer('spacy', language='de_core_news_sm')
token_transform[TGT_LANGUAGE] = get_tokenizer('spacy', language='en_core_web_sm')

# helper function to yield list of tokens
def yield_tokens(data_iter: Iterable, language: str) -> List[str]:
    language_index = {SRC_LANGUAGE: 0, TGT_LANGUAGE: 1}

    for data_sample in data_iter:
        yield token_transform[language](data_sample[language_index[language]])

# Define special symbols and indices
UNK_IDX, PAD_IDX, BOS_IDX, EOS_IDX = 0, 1, 2, 3
# Make sure the tokens are in order of their indices to properly insert them in vocab
special_symbols = ['<unk>', '<pad>', '<bos>', '<eos>']

for ln in [SRC_LANGUAGE, TGT_LANGUAGE]:
    # Training data Iterator
    train_iter = Multi30k(split='train', language_pair=(SRC_LANGUAGE, TGT_LANGUAGE))
    # Create torchtext's Vocab object
    vocab_transform[ln] = build_vocab_from_iterator(yield_tokens(train_iter, ln),
                                                    min_freq=1,
                                                    specials=special_symbols,
                                                    special_first=True)

# Set ``UNK_IDX`` as the default index. This index is returned when the token is not found.
# If not set, it throws ``RuntimeError`` when the queried token is not found in the Vocabulary.
for ln in [SRC_LANGUAGE, TGT_LANGUAGE]:
  vocab_transform[ln].set_default_index(UNK_IDX)

트랜스포머 기반 Seq2Seq 신경망 구현
이제, Transformer를 사용하는 Seq2Seq 네트워크를 생성한다. 네트워크 세 부분으로 구성되어 있다. 

첫 번째, 임베딩 레이어이다. 이 레이어는 입력 문장들을 텐서로 변환한다. 이 임베딩은 위치 인코딩을 사용하여 입력 토큰의 위치 정보를 모델에 제공한다. 
두 번째, 트랜스포머를 구현한다.
세 번째, 트랜스포머 모델의 출력은 선형 레이어를 통과해, 대상 언어의 각 토큰에 대한 생성 확률을 제공한다.

코드 구현은 다음과 같다. 
from torch import Tensor
import torch
import torch.nn as nn
from torch.nn import Transformer
import math
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# 앞의 글에서 설명한 포지션 인코딩과 동일하다.
class PositionalEncoding(nn.Module):
    def __init__(self,
                 emb_size: int,
                 dropout: float,
                 maxlen: int = 5000):
        super(PositionalEncoding, self).__init__()
        den = torch.exp(- torch.arange(0, emb_size, 2)* math.log(10000) / emb_size)
        pos = torch.arange(0, maxlen).reshape(maxlen, 1)
        pos_embedding = torch.zeros((maxlen, emb_size))
        pos_embedding[:, 0::2] = torch.sin(pos * den)
        pos_embedding[:, 1::2] = torch.cos(pos * den)
        pos_embedding = pos_embedding.unsqueeze(-2)

        self.dropout = nn.Dropout(dropout)
        self.register_buffer('pos_embedding', pos_embedding)

    def forward(self, token_embedding: Tensor):
        return self.dropout(token_embedding + self.pos_embedding[:token_embedding.size(0), :])

# 토큰 임베딩 텐서 변환 모듈
class TokenEmbedding(nn.Module):
    def __init__(self, vocab_size: int, emb_size):
        super(TokenEmbedding, self).__init__()
        self.embedding = nn.Embedding(vocab_size, emb_size)
        self.emb_size = emb_size

    def forward(self, tokens: Tensor):
        return self.embedding(tokens.long()) * math.sqrt(self.emb_size)

# Seq2Seq 모듈
class Seq2SeqTransformer(nn.Module):
    def __init__(self,
                 num_encoder_layers: int,
                 num_decoder_layers: int,
                 emb_size: int,
                 nhead: int,
                 src_vocab_size: int,
                 tgt_vocab_size: int,
                 dim_feedforward: int = 512,
                 dropout: float = 0.1):
        super(Seq2SeqTransformer, self).__init__()
        self.transformer = Transformer(d_model=emb_size,
                                       nhead=nhead,
                                       num_encoder_layers=num_encoder_layers,
                                       num_decoder_layers=num_decoder_layers,
                                       dim_feedforward=dim_feedforward,
                                       dropout=dropout)  # 토치에 내장된 트랜스포머 사용
        self.generator = nn.Linear(emb_size, tgt_vocab_size)
        self.src_tok_emb = TokenEmbedding(src_vocab_size, emb_size)
        self.tgt_tok_emb = TokenEmbedding(tgt_vocab_size, emb_size)
        self.positional_encoding = PositionalEncoding(
            emb_size, dropout=dropout)

    def forward(self,
                src: Tensor,
                trg: Tensor,
                src_mask: Tensor,
                tgt_mask: Tensor,
                src_padding_mask: Tensor,
                tgt_padding_mask: Tensor,
                memory_key_padding_mask: Tensor):
        src_emb = self.positional_encoding(self.src_tok_emb(src))   # 소스 토큰 임베딩
        tgt_emb = self.positional_encoding(self.tgt_tok_emb(trg))   # 대상 토큰 임베딩
        outs = self.transformer(src_emb, tgt_emb, src_mask, tgt_mask, None,
                                src_padding_mask, tgt_padding_mask, memory_key_padding_mask)  # 트랜스포머 호출
        return self.generator(outs)  # 선형 레이어 입력 처리

    def encode(self, src: Tensor, src_mask: Tensor):
        return self.transformer.encoder(self.positional_encoding(
                            self.src_tok_emb(src)), src_mask)

    def decode(self, tgt: Tensor, memory: Tensor, tgt_mask: Tensor):
        return self.transformer.decoder(self.positional_encoding(
                          self.tgt_tok_emb(tgt)), memory,
                          tgt_mask)

훈련하는 동안 모델이 다음을 학습하지 못하도록, 후속 단어 마스크가 필요하다. 예측을 할 때 미래 단어를 숨길 마스크를 준비한다. 소스 및 대상 패딩 토큰 등도 준비한다. 
def generate_square_subsequent_mask(sz): 
    mask = (torch.triu(torch.ones((sz, sz), device=DEVICE)) == 1).transpose(0, 1)
    mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0))
    return mask

def create_mask(src, tgt):
    src_seq_len = src.shape[0]
    tgt_seq_len = tgt.shape[0]

    tgt_mask = generate_square_subsequent_mask(tgt_seq_len)
    src_mask = torch.zeros((src_seq_len, src_seq_len),device=DEVICE).type(torch.bool)

    src_padding_mask = (src == PAD_IDX).transpose(0, 1)
    tgt_padding_mask = (tgt == PAD_IDX).transpose(0, 1)
    return src_mask, tgt_mask, src_padding_mask, tgt_padding_mask

이제 모델의 매개 변수를 정의하고 동일하게 인스턴스화한다. 여기서는 교차 엔트로피 손실 함수와 옵티마이저를 정의한다.
torch.manual_seed(0)

SRC_VOCAB_SIZE = len(vocab_transform[SRC_LANGUAGE])
TGT_VOCAB_SIZE = len(vocab_transform[TGT_LANGUAGE])
EMB_SIZE = 512
NHEAD = 8
FFN_HID_DIM = 512
BATCH_SIZE = 128
NUM_ENCODER_LAYERS = 3
NUM_DECODER_LAYERS = 3

transformer = Seq2SeqTransformer(NUM_ENCODER_LAYERS, NUM_DECODER_LAYERS, EMB_SIZE,
                                 NHEAD, SRC_VOCAB_SIZE, TGT_VOCAB_SIZE, FFN_HID_DIM)
for p in transformer.parameters():
    if p.dim() > 1:
        nn.init.xavier_uniform_(p)  # 각 레이어의 신경망 가중치를 적절히 초기화함

transformer = transformer.to(DEVICE)

loss_fn = torch.nn.CrossEntropyLoss(ignore_index=PAD_IDX)
optimizer = torch.optim.Adam(transformer.parameters(), lr=0.0001, betas=(0.9, 0.98), eps=1e-9)

데이터 학습
데이터 학습은 한 쌍의 소스-대상 문자열을 이용한다. 이러한 문자열 쌍을 신경망에서 처리할 수 있는 배치 데이터 텐서로 변환한다(collate 함수). 
from torch.nn.utils.rnn import pad_sequence

# 문장을 텐서로 변환하기 위한 헬퍼 함수
def sequential_transforms(*transforms):
    def func(txt_input):
        for transform in transforms:
            txt_input = transform(txt_input)
        return txt_input
    return func

# BOS, EOS 추가. 텐서 변환
def tensor_transform(token_ids: List[int]):
    return torch.cat((torch.tensor([BOS_IDX]),
                      torch.tensor(token_ids),
                      torch.tensor([EOS_IDX])))

# 소스, 타겟 언어 문장을 텐서로 변환
text_transform = {}
for ln in [SRC_LANGUAGE, TGT_LANGUAGE]:
    text_transform[ln] = sequential_transforms(token_transform[ln], # 문장 토큰화
                                               vocab_transform[ln], # 토큰 수치화
                                               tensor_transform) # BOS/EOS 추가. 텐서 생성

# 학습용 배치 데이터셋 생성. 소스-대상 문장 토큰 대상.
def collate_fn(batch):
    src_batch, tgt_batch = [], []
    for src_sample, tgt_sample in batch:
        src_batch.append(text_transform[SRC_LANGUAGE](src_sample.rstrip("\n")))
        tgt_batch.append(text_transform[TGT_LANGUAGE](tgt_sample.rstrip("\n")))

    src_batch = pad_sequence(src_batch, padding_value=PAD_IDX)
    tgt_batch = pad_sequence(tgt_batch, padding_value=PAD_IDX)
    return src_batch, tgt_batch

다음과 같이 학습 함수를 정의한다.
from torch.utils.data import DataLoader

def train_epoch(model, optimizer): # 학습 함수
    model.train()
    losses = 0
    train_iter = Multi30k(split='train', language_pair=(SRC_LANGUAGE, TGT_LANGUAGE))
    train_dataloader = DataLoader(train_iter, batch_size=BATCH_SIZE, collate_fn=collate_fn)

    for src, tgt in train_dataloader:
        src = src.to(DEVICE)
        tgt = tgt.to(DEVICE)
        tgt_input = tgt[:-1, :]

        src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input) # 마스크 
        logits = model(src, tgt_input, src_mask, tgt_mask,src_padding_mask, tgt_padding_mask, src_padding_mask)

        optimizer.zero_grad() 
        tgt_out = tgt[1:, :]
        loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))  # 손실 계산
        loss.backward()   # 역전파

        optimizer.step()
        losses += loss.item()

    return losses / len(list(train_dataloader))

def evaluate(model):  # 평가 함수
    model.eval()
    losses = 0

    val_iter = Multi30k(split='valid', language_pair=(SRC_LANGUAGE, TGT_LANGUAGE))
    val_dataloader = DataLoader(val_iter, batch_size=BATCH_SIZE, collate_fn=collate_fn)

    for src, tgt in val_dataloader:
        src = src.to(DEVICE)
        tgt = tgt.to(DEVICE)
        tgt_input = tgt[:-1, :]
        src_mask, tgt_mask, src_padding_mask, tgt_padding_mask = create_mask(src, tgt_input)

        logits = model(src, tgt_input, src_mask, tgt_mask,src_padding_mask, tgt_padding_mask, src_padding_mask)

        tgt_out = tgt[1:, :]
        loss = loss_fn(logits.reshape(-1, logits.shape[-1]), tgt_out.reshape(-1))
        losses += loss.item()

    return losses / len(list(val_dataloader))

이제 준비된 데이터로 학습한다.
from timeit import default_timer as timer
NUM_EPOCHS = 18

for epoch in range(1, NUM_EPOCHS+1):
    start_time = timer()
    train_loss = train_epoch(transformer, optimizer)  # 에포크 만큼 학습
    end_time = timer()
    val_loss = evaluate(transformer)
    print((f"Epoch: {epoch}, Train loss: {train_loss:.3f}, Val loss: {val_loss:.3f}, "f"Epoch time = {(end_time - start_time):.3f}s"))

# function to generate output sequence using greedy algorithm
def greedy_decode(model, src, src_mask, max_len, start_symbol):
    src = src.to(DEVICE)
    src_mask = src_mask.to(DEVICE)

    memory = model.encode(src, src_mask)
    ys = torch.ones(1, 1).fill_(start_symbol).type(torch.long).to(DEVICE)
    for i in range(max_len-1):
        memory = memory.to(DEVICE)
        tgt_mask = (generate_square_subsequent_mask(ys.size(0))
                    .type(torch.bool)).to(DEVICE)
        out = model.decode(ys, memory, tgt_mask)
        out = out.transpose(0, 1)
        prob = model.generator(out[:, -1])
        _, next_word = torch.max(prob, dim=1)
        next_word = next_word.item()

        ys = torch.cat([ys,
                        torch.ones(1, 1).type_as(src.data).fill_(next_word)], dim=0)
        if next_word == EOS_IDX:
            break
    return ys

# 실제 입력에 대한 대상으로 번역(변환)
def translate(model: torch.nn.Module, src_sentence: str):
    model.eval()
    src = text_transform[SRC_LANGUAGE](src_sentence).view(-1, 1)
    num_tokens = src.shape[0]
    src_mask = (torch.zeros(num_tokens, num_tokens)).type(torch.bool)
    tgt_tokens = greedy_decode(
        model,  src, src_mask, max_len=num_tokens + 5, start_symbol=BOS_IDX).flatten()
    return " ".join(vocab_transform[TGT_LANGUAGE].lookup_tokens(list(tgt_tokens.cpu().numpy()))).replace("<bos>", "").replace("<eos>", "")

학습 결과 다음과 같이 자연어 번역 기능이 제대로 동작하는 것을 확인할 수 있다. 참고로, 학습에는 NVIDIA GPU RAM 7G 정도가 사용되었으며, 학습 시간은 한 시간 내에 처리된다. 
print(translate(transformer, "Eine Gruppe von Menschen steht vor einem Iglu ."))
> A group of people stand in front of an igloo .

실행 결과

마무리
토치에서 제공되는 트랜스포머 코드를 확인해 보면, 앞서 설명된 트랜스포머 내부 코드 구현과 거의 유사한 것을 알 수 있다. 실행해보면, 계산 속도가 좀 더 빠르고, 좀 더 많은 양의 데이터를 학습해서 결과다 더 나은 것을 알 수 있다.

트랜스포머가 자연어 번역 이외에 멀티모달의 핵심 기술이 된 이유는 비정형 데이터를 연산 가능한 차원으로 수치화할 수 있는 임베딩 기술의 발전과 트랜스포머의 Key, Query, Value 입력을 통한 여러 학습 데이터 조합이 가능한 특징이 크게 작용했다. 
트랜스포머 기반 멀티모달 구현 전략 개념도

멀티모달 시작을 알린 OpenAI의 CLIP 모델(Learning Transferable Visual Models From Natural Language Supervision, 2021)

다음은 트랜스포머와 VAE를 이용한 CLIP 멀티모달 네트웍을 좀 더 깊게 파헤쳐 보도록 한다. 

레퍼런스

2023년 7월 11일 화요일

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


레퍼런스