[PyTorch 실습] 텍스트 전처리부터 모델 학습과 평가까지

개굴이·어제

[PyTorch 실습] 텍스트 전처리부터 모델 학습과 평가까지

오늘은 NLP 태스크의 입력과 출력을 살펴보고, PyTorch로 데이터를 준비하고 모델을 학습하는 기본 흐름을 실습했다.

전체 과정은 다음과 같다.

데이터 준비
→ 전처리
→ Dataset과 DataLoader 구성
→ 모델 예측
→ 손실 계산
→ 역전파
→ 가중치 업데이트
→ 성능 평가

1. 태스크에 따른 입력 X와 정답 Y

모델을 구현하기 전에 입력 X와 정답 Y가 어떤 형태인지 먼저 생각해야 한다.

태스크입력 X정답 Y
이미지 캡셔닝이미지이미지를 설명하는 문장
추출형 QA문맥과 질문문맥 안의 정답 구간
생성형 QA문맥과 질문문맥을 근거로 생성한 답변

이미지 캡셔닝

이미지를 보고 내용을 설명하는 문장을 생성하는 작업이다.

Y에는 다음과 같은 정보가 담길 수 있다.

  • 사람의 수와 배치
  • 옷차림
  • 포즈와 행동
  • 배경과 주변 사물
  • 사람과 사물 사이의 관계

모든 정보를 나열하기보다는 이미지에서 중요한 내용을 정확하고 자연스럽게 설명해야 한다.

이미지 입력
→ 시각적 특징 추출
→ 대상·속성·행동·관계에 관한 정보 활용
→ 설명 문장 생성

이미지 한 장에 정답 문장 5개가 연결되어 있어도, 모델이 반드시 5개를 출력해야 하는 것은 아니다. 여러 문장은 같은 장면을 설명하는 다양한 정답 표현이다.

추출형 QA

주어진 문맥에서 질문의 답을 그대로 추출하는 작업이다.

문맥: 민수는 2023년 부산에서 열린 대회에 참가했다.
질문: 대회가 열린 장소는 어디인가?
정답: 부산

일반적인 추출형 QA에서 모델의 Y는 정답의 시작 토큰 위치와 끝 토큰 위치다.

문맥과 질문 입력
→ 질문과 관련된 내용 파악
→ 정답 시작·끝 위치 예측
→ 해당 구간의 텍스트 추출

생성형 QA

문맥을 근거로 답변을 새롭게 생성하는 작업이다.

문맥: 오늘은 버스 운행이 중단되어 지수가 지하철을 이용했다.
질문: 지수는 왜 지하철을 이용했는가?
정답: 버스 운행이 중단되었기 때문이다.

문맥의 표현을 바꿔 쓰거나 여러 정보를 종합할 수 있지만, 문맥에서 뒷받침되지 않는 내용을 임의로 추가해서는 안 된다.

문맥과 질문 입력
→ 관련 근거 파악
→ 필요한 정보 종합
→ 답변을 토큰 단위로 생성

2. 텍스트 전처리: 토큰화와 패딩

모델이 텍스트를 처리하려면 문자열을 숫자로 변환해야 한다.

import torch
from torch.nn.utils.rnn import pad_sequence

sentences = [
    "I love banana.",
    "I also love NLP",
    "NLP is interesting",
    "I am studying NLP",
    "NLP is fun"
]

# 공백을 기준으로 문장을 나누기
tokenized_sentences = [
    sentence.split() for sentence in sentences
]

# 단어별 ID 부여: 0은 패딩용으로 예약
vocab = {}

for sentence in tokenized_sentences:
    for word in sentence:
        if word not in vocab:
            vocab[word] = len(vocab) + 1

# 단어를 ID로 변환
indexed_sentences = [
    [vocab[word] for word in sentence]
    for sentence in tokenized_sentences
]

# 정수 텐서로 변환
tensor_sentences = [
    torch.tensor(sentence, dtype=torch.long)
    for sentence in indexed_sentences
]

# 가장 긴 문장에 맞춰 패딩
padded_sentences = pad_sequence(
    tensor_sentences,
    batch_first=True,
    padding_value=0
)

print(padded_sentences)

출력은 다음과 같다.

tensor([[ 1,  2,  3,  0],
        [ 1,  4,  2,  5],
        [ 5,  6,  7,  0],
        [ 1,  8,  9,  5],
        [ 5,  6, 10,  0]])

핵심 개념

  • 토큰화: 문장을 작은 단위로 나누는 과정
  • 단어 사전: 단어와 숫자 ID의 대응 관계
  • 패딩: 길이가 다른 문장들을 동일한 길이로 맞추는 작업
  • batch_first=True: 배치 차원을 맨 앞에 배치

결과 텐서의 크기는 다음과 같다.

(문장 수, 최대 토큰 수) = (5, 4)

단어 ID는 식별자이므로 숫자의 크기에 의미나 중요도가 담겨 있지는 않다.

또한 split()은 공백만 기준으로 나누므로 "banana."처럼 문장부호가 붙은 표현도 하나의 토큰으로 처리된다.

3. 임베딩: 단어 ID를 벡터로 변환하기

임베딩은 단어 ID를 학습 가능한 실수 벡터로 바꾼다.

import torch.nn as nn

embedding_dim = 8
vocab_size = len(vocab) + 1

embedding_layer = nn.Embedding(
    vocab_size,
    embedding_dim,
    padding_idx=0
)

embedded_sentences = embedding_layer(padded_sentences)

print(embedded_sentences.shape)

출력:

torch.Size([5, 4, 8])
차원의미
5문장 수
4패딩을 포함한 문장당 토큰 수
8토큰 하나의 임베딩 차원
단어 ID: (5, 4)
→ 임베딩
단어 벡터: (5, 4, 8)

nn.Embedding은 단어 ID에 해당하는 행을 찾아 벡터를 꺼내는 표처럼 동작한다. 같은 ID는 같은 벡터를 받는다.

padding_idx=0을 지정하면 기본 초기화에서 패딩 벡터가 0으로 채워지고, 임베딩의 역전파에서 해당 행의 기울기가 발생하지 않는다.

다만 이것만으로 이후 모델이 패딩을 자동으로 무시하는 것은 아니다. 모델에 따라 별도의 마스크가 필요할 수 있다.

처음 생성한 임베딩은 무작위 값이다. 학습 과정에서 손실을 줄이는 방향으로 조정된다.

4. 경사하강법: 손실을 줄이는 방향으로 이동하기

경사하강법은 기울기의 반대 방향으로 이동하면서 함수값을 줄이는 방법이다.

실습에서 사용한 함수는 다음과 같다.

def f(x):
    return x**4 - 3 * x**3

def f_prime(x):
    return 4 * x**3 - 9 * x**2

업데이트는 다음 코드로 이루어진다.

x_new = x_old - learning_rate * f_prime(x_old)
  • 기울기가 양수이면 왼쪽으로 이동한다.
  • 기울기가 음수이면 오른쪽으로 이동한다.
  • 학습률은 기울기를 이동량으로 바꾸는 비율이다.

시작점이 6, 학습률이 0.01이면 다음과 같다.

f_prime(6) = 540
x_new = 6 - 0.01 × 540 = 0.6

첫 이동은 6 → 0.6이며, 이후 업데이트를 반복하면 최솟값을 갖는 위치인 x=2.25 근처로 이동한다.

신경망에서도 같은 원리를 사용한다. 숫자 하나 대신 여러 가중치를 업데이트하고, 함수값 대신 손실을 줄인다.

이동량이 작아졌다는 것이 항상 최솟값에 도달했다는 뜻은 아니다. 실제 최적화에서는 최대 반복 횟수 등의 종료 조건도 함께 고려한다.

5. 손실 함수

손실 함수는 모델의 예측과 정답 사이의 차이를 수치로 나타내는 함수다.

MSELoss

평균제곱오차는 예측과 정답의 차이를 제곱한 뒤 평균을 구한다.

prediction = torch.tensor([[2.0], [3.0], [4.0]])
ground_truth = torch.tensor([[2.5], [3.5], [5.0]])

criterion = nn.MSELoss()
loss = criterion(prediction, ground_truth)

print(f"손실 값: {loss.item()}")

출력:

손실 값: 0.5

계산 과정은 다음과 같다.

예측과 정답의 차이: -0.5, -0.5, -1.0
차이의 제곱:         0.25, 0.25, 1.00
평균:               (0.25 + 0.25 + 1.00) / 3 = 0.5

MSE는 수치 값을 예측하는 회귀 문제에서 사용하는 대표적인 손실 함수다.

CrossEntropyLoss

교차 엔트로피는 정답 클래스에 얼마나 높은 확률을 부여하는지 평가한다.

logits = torch.tensor([
    [0.8, 0.2],
    [0.4, 0.6],
    [0.9, 0.1]
])

# 강아지: 0, 고양이: 1
labels = torch.tensor([0, 1, 0])

criterion = nn.CrossEntropyLoss()
loss = criterion(logits, labels)

print(f"손실 값: {loss.item():.4f}")

출력:

손실 값: 0.4689

CrossEntropyLoss에는 Softmax를 적용하기 전의 점수인 로짓을 입력해야 한다.

위 코드의 [0.8, 0.2]도 80%, 20%의 확률이 아니라 두 클래스의 점수로 해석된다. 내부적으로 log_softmax를 적용해 손실을 계산한다.

정답은 0, 1 같은 클래스 번호로 전달한다.

가장 높은 점수의 클래스를 기준으로 모두 맞혔더라도 손실은 0이 아닐 수 있다. 교차 엔트로피는 정답 여부뿐 아니라 정답에 부여한 확률도 반영한다.

6. Dataset: 데이터를 제공하는 규칙

Dataset은 데이터의 개수와 특정 데이터를 가져오는 방법을 정의한다.

from torch.utils.data import Dataset

class CustomDataset(Dataset):
    def __init__(self, data):
        self.data = data

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        return self.data[idx]

dataset = CustomDataset([1, 2, 3, 4, 5])

print(dataset[0])
print(len(dataset))

출력:

1
5
메서드역할
__init__데이터와 설정 저장
__len__데이터 개수 반환
__getitem__해당 인덱스의 데이터 반환

실제 지도학습에서는 보통 __getitem__이 입력과 정답을 함께 반환한다.

return inputs, labels

7. IterableDataset: 데이터를 순서대로 제공하기

IterableDataset은 인덱스로 데이터를 선택하기보다 순회하면서 데이터를 제공하는 방식이다.

from torch.utils.data import IterableDataset

class CustomIterableDataset(IterableDataset):
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __iter__(self):
        return iter(range(self.start, self.end))

dataset = CustomIterableDataset(start=1, end=6)

for data in dataset:
    print(data)

출력:

1
2
3
4
5

range(1, 6)에서 끝값 6은 제외된다.

구분일반적인 DatasetIterableDataset
핵심 메서드__getitem__, __len____iter__
접근 방식인덱스로 선택순회하며 다음 데이터 받기
활용 예저장된 이미지·문장스트리밍·큰 파일의 순차 읽기

이 예시에서는 __getitem__과 __len__을 구현하지 않았으므로 dataset[0]과 len(dataset)을 사용할 수 없다.

8. DataLoader: 데이터를 배치로 묶기

DataLoader는 데이터셋의 샘플들을 여러 개씩 묶어 제공한다. 이 묶음을 배치(batch)라고 한다.

앞서 만든 IterableDataset에는 다음처럼 사용할 수 있다.

from torch.utils.data import DataLoader

train_loader = DataLoader(
    dataset,
    batch_size=2
)

for batch_idx, data in enumerate(train_loader):
    print(f"Batch {batch_idx + 1}: {data}")

출력:

Batch 1: tensor([1, 2])
Batch 2: tensor([3, 4])
Batch 3: tensor([5])

데이터가 5개이므로 2개 → 2개 → 1개로 나뉜다.

기본 설정에서는 마지막의 작은 배치도 반환한다.

주의할 점

  • 일반적인 인덱스 기반 Dataset에서는 shuffle=True로 순서를 섞을 수 있다.
  • IterableDataset에서는 shuffle=True를 지원하지 않는다.
  • for inputs, labels in train_loader를 사용하려면 데이터셋이 (입력, 정답) 쌍을 반환해야 한다.

Dataset은 데이터를 제공하는 방법을 정의하고, DataLoader는 데이터를 배치로 묶어 가져온다.

9. VGG16: 이미지를 분류하는 모델

VGG16은 이미지에서 특징을 추출해 어떤 클래스에 속하는지 예측하는 CNN 모델이다.

이미지 입력
→ 합성곱으로 특징 추출
→ ReLU
→ 풀링으로 공간 크기 축소
→ 위 과정을 반복
→ 완전연결층
→ 클래스 점수 출력

모델을 통과하면서 가로·세로 크기는 줄어들고 채널 수는 증가한다.

아래 크기는 각 블록의 풀링을 거친 결과다.

단계높이 × 너비 × 채널
입력224 × 224 × 3
블록 1 이후112 × 112 × 64
블록 2 이후56 × 56 × 128
블록 3 이후28 × 28 × 256
블록 4 이후14 × 14 × 512
블록 5 이후7 × 7 × 512

PyTorch 텐서는 보통 다음 순서를 사용한다.

(배치 크기, 채널 수, 높이, 너비)

따라서 마지막 특징 텐서는 (B, 512, 7, 7) 형태다.

이를 펼치면 샘플당 25,088개의 값이 된다.

7 × 7 × 512 = 25,088

완전연결층은 다음 크기로 이어진다.

25,088 → 4,096 → 4,096 → 1,000

마지막 1,000은 원래 VGG16이 분류하는 ImageNet 클래스 수다. 강아지·고양이 분류처럼 다른 작업에서는 마지막 출력 크기를 바꿀 수 있다.

VGG16의 16은 합성곱 층 13개와 완전연결층 3개를 합한 개수다.

이미지 캡셔닝에서는 VGG16의 특징 추출 부분을 이미지 인코더로 활용하고, 추출한 특징을 별도의 문장 생성 모델에 전달할 수 있다.

10. GPU에서 학습하기

모델과 입력, 정답은 같은 장치에 있어야 한다.

device = torch.device(
    "cuda" if torch.cuda.is_available() else "cpu"
)

model = model.to(device)

optimizer = torch.optim.Adam(
    model.parameters(),
    lr=0.001
)

criterion = nn.CrossEntropyLoss()

CUDA GPU를 사용할 수 있으면 GPU를 선택하고, 그렇지 않으면 CPU를 선택한다.

모델을 장치로 옮긴 다음 옵티마이저를 생성하는 것이 좋다.

inputs = inputs.to(device)
labels = labels.to(device)

입력과 정답도 같은 장치로 옮겨야 한다.

모델: GPU
입력: GPU
정답: GPU

모델과 입력의 장치가 다르면 일반적으로 장치 불일치 오류가 발생한다.

11. 모델 학습과 검증

아래 코드는 샘플마다 정답 클래스가 하나인 분류 작업을 위한 예시다.

기본 평균 방식의 CrossEntropyLoss와 비어 있지 않은 DataLoader를 전제로 한다.

def train_model(
    model,
    train_loader,
    val_loader,
    optimizer,
    criterion,
    device,
    num_epochs=10
):
    for epoch in range(num_epochs):
        # 학습
        model.train()
        total_loss = 0.0
        total_samples = 0

        for inputs, labels in train_loader:
            inputs = inputs.to(device)
            labels = labels.to(device)

            optimizer.zero_grad()

            outputs = model(inputs)
            loss = criterion(outputs, labels)

            loss.backward()
            optimizer.step()

            batch_size = labels.size(0)
            total_loss += loss.item() * batch_size
            total_samples += batch_size

        train_loss = total_loss / total_samples

        # 검증
        model.eval()
        val_loss = 0.0
        correct = 0
        total = 0

        with torch.no_grad():
            for inputs, labels in val_loader:
                inputs = inputs.to(device)
                labels = labels.to(device)

                outputs = model(inputs)
                loss = criterion(outputs, labels)

                batch_size = labels.size(0)
                val_loss += loss.item() * batch_size

                predicted = outputs.argmax(dim=1)

                correct += (
                    predicted == labels
                ).sum().item()

                total += batch_size

        print(
            f"Epoch [{epoch + 1}/{num_epochs}] "
            f"Train Loss: {train_loss:.4f}, "
            f"Validation Loss: {val_loss / total:.4f}, "
            f"Accuracy: {100 * correct / total:.2f}%"
        )

이 함수에는 미리 준비한 모델과 (입력, 정답)을 반환하는 학습·검증 DataLoader를 전달해야 한다.

에포크란?

에포크(epoch)는 학습 데이터를 한 번 모두 순회하는 단위다.

num_epochs=10이면 전체 학습 데이터를 10번 반복해서 사용한다.

학습의 핵심 다섯 단계

optimizer.zero_grad()             # 이전 기울기 초기화
outputs = model(inputs)           # 예측
loss = criterion(outputs, labels) # 손실 계산
loss.backward()                  # 기울기 계산
optimizer.step()                 # 가중치 업데이트

특히 다음 차이를 기억해야 한다.

  • loss.backward(): 각 파라미터의 기울기를 계산한다.
  • optimizer.step(): 계산한 기울기로 파라미터를 수정한다.

평균 손실 계산

total_loss += loss.item() * batch_size
train_loss = total_loss / total_samples

배치 크기를 반영해 누적하면 마지막 배치가 작아도 샘플 수를 반영한 평균 손실을 구할 수 있다.

12. 학습 모드와 평가 모드

구분학습평가
모델 모드model.train()model.eval()
기울기 계산필요일반적으로 불필요
역전파수행수행하지 않음
가중치 업데이트수행수행하지 않음

model.eval()은 Dropout과 BatchNorm 같은 층의 동작을 평가용으로 바꾼다.

하지만 기울기 계산 자체를 끄지는 않는다. 따라서 평가에서는 다음을 함께 사용한다.

model.eval()

with torch.no_grad():
    outputs = model(inputs)

13. 정확도 계산하기

분류 정확도는 가장 높은 점수를 가진 클래스와 실제 정답을 비교해서 구한다.

predicted = outputs.argmax(dim=1)

correct = (predicted == labels).sum().item()
accuracy = correct / labels.size(0)

예를 들어 다음과 같다면:

예측: [0, 1, 1]
정답: [0, 1, 0]

3개 중 2개를 맞혔으므로 정확도는 약 0.6667, 즉 66.67%다.

print(f"Accuracy: {accuracy * 100:.2f}%")

가장 높은 점수의 클래스를 선택하는 데는 Softmax를 적용할 필요가 없다.

14. 오늘 실습에서 배운 점

  • 태스크마다 입력 X와 정답 Y의 형태가 다르다.
  • 텍스트는 토큰화, ID 변환, 패딩, 임베딩을 거쳐 모델 입력이 된다.
  • 손실 함수는 예측과 정답 사이의 오차를 수치로 나타낸다.
  • Dataset은 데이터를 제공하고, DataLoader는 데이터를 배치로 묶는다.
  • 학습은 예측, 손실 계산, 역전파, 가중치 업데이트를 반복하는 과정이다.
  • 평가에서는 가중치를 수정하지 않고 성능을 확인한다.
  • GPU를 사용할 때는 모델과 입력, 정답의 장치를 맞춰야 한다.

이번 실습을 통해 개별 코드가 데이터 준비에서 학습과 평가까지 이어지는 하나의 흐름이라는 점을 이해했다.

0개의 댓글