오늘은 NLP 태스크의 입력과 출력을 살펴보고, PyTorch로 데이터를 준비하고 모델을 학습하는 기본 흐름을 실습했다.
전체 과정은 다음과 같다.
데이터 준비
→ 전처리
→ Dataset과 DataLoader 구성
→ 모델 예측
→ 손실 계산
→ 역전파
→ 가중치 업데이트
→ 성능 평가
모델을 구현하기 전에 입력 X와 정답 Y가 어떤 형태인지 먼저 생각해야 한다.
| 태스크 | 입력 X | 정답 Y |
|---|---|---|
| 이미지 캡셔닝 | 이미지 | 이미지를 설명하는 문장 |
| 추출형 QA | 문맥과 질문 | 문맥 안의 정답 구간 |
| 생성형 QA | 문맥과 질문 | 문맥을 근거로 생성한 답변 |
이미지를 보고 내용을 설명하는 문장을 생성하는 작업이다.
Y에는 다음과 같은 정보가 담길 수 있다.
모든 정보를 나열하기보다는 이미지에서 중요한 내용을 정확하고 자연스럽게 설명해야 한다.
이미지 입력
→ 시각적 특징 추출
→ 대상·속성·행동·관계에 관한 정보 활용
→ 설명 문장 생성
이미지 한 장에 정답 문장 5개가 연결되어 있어도, 모델이 반드시 5개를 출력해야 하는 것은 아니다. 여러 문장은 같은 장면을 설명하는 다양한 정답 표현이다.
주어진 문맥에서 질문의 답을 그대로 추출하는 작업이다.
문맥: 민수는 2023년 부산에서 열린 대회에 참가했다.
질문: 대회가 열린 장소는 어디인가?
정답: 부산
일반적인 추출형 QA에서 모델의 Y는 정답의 시작 토큰 위치와 끝 토큰 위치다.
문맥과 질문 입력
→ 질문과 관련된 내용 파악
→ 정답 시작·끝 위치 예측
→ 해당 구간의 텍스트 추출
문맥을 근거로 답변을 새롭게 생성하는 작업이다.
문맥: 오늘은 버스 운행이 중단되어 지수가 지하철을 이용했다.
질문: 지수는 왜 지하철을 이용했는가?
정답: 버스 운행이 중단되었기 때문이다.
문맥의 표현을 바꿔 쓰거나 여러 정보를 종합할 수 있지만, 문맥에서 뒷받침되지 않는 내용을 임의로 추가해서는 안 된다.
문맥과 질문 입력
→ 관련 근거 파악
→ 필요한 정보 종합
→ 답변을 토큰 단위로 생성
모델이 텍스트를 처리하려면 문자열을 숫자로 변환해야 한다.
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]])
batch_first=True: 배치 차원을 맨 앞에 배치결과 텐서의 크기는 다음과 같다.
(문장 수, 최대 토큰 수) = (5, 4)
단어 ID는 식별자이므로 숫자의 크기에 의미나 중요도가 담겨 있지는 않다.
또한 split()은 공백만 기준으로 나누므로 "banana."처럼 문장부호가 붙은 표현도 하나의 토큰으로 처리된다.
임베딩은 단어 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으로 채워지고, 임베딩의 역전파에서 해당 행의 기울기가 발생하지 않는다.
다만 이것만으로 이후 모델이 패딩을 자동으로 무시하는 것은 아니다. 모델에 따라 별도의 마스크가 필요할 수 있다.
처음 생성한 임베딩은 무작위 값이다. 학습 과정에서 손실을 줄이는 방향으로 조정된다.
경사하강법은 기울기의 반대 방향으로 이동하면서 함수값을 줄이는 방법이다.
실습에서 사용한 함수는 다음과 같다.
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 근처로 이동한다.
신경망에서도 같은 원리를 사용한다. 숫자 하나 대신 여러 가중치를 업데이트하고, 함수값 대신 손실을 줄인다.
이동량이 작아졌다는 것이 항상 최솟값에 도달했다는 뜻은 아니다. 실제 최적화에서는 최대 반복 횟수 등의 종료 조건도 함께 고려한다.
손실 함수는 모델의 예측과 정답 사이의 차이를 수치로 나타내는 함수다.
평균제곱오차는 예측과 정답의 차이를 제곱한 뒤 평균을 구한다.
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는 수치 값을 예측하는 회귀 문제에서 사용하는 대표적인 손실 함수다.
교차 엔트로피는 정답 클래스에 얼마나 높은 확률을 부여하는지 평가한다.
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이 아닐 수 있다. 교차 엔트로피는 정답 여부뿐 아니라 정답에 부여한 확률도 반영한다.
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
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은 제외된다.
| 구분 | 일반적인 Dataset | IterableDataset |
|---|---|---|
| 핵심 메서드 | __getitem__, __len__ | __iter__ |
| 접근 방식 | 인덱스로 선택 | 순회하며 다음 데이터 받기 |
| 활용 예 | 저장된 이미지·문장 | 스트리밍·큰 파일의 순차 읽기 |
이 예시에서는 __getitem__과 __len__을 구현하지 않았으므로 dataset[0]과 len(dataset)을 사용할 수 없다.
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개로 나뉜다.
기본 설정에서는 마지막의 작은 배치도 반환한다.
shuffle=True로 순서를 섞을 수 있다.IterableDataset에서는 shuffle=True를 지원하지 않는다.for inputs, labels in train_loader를 사용하려면 데이터셋이 (입력, 정답) 쌍을 반환해야 한다.Dataset은 데이터를 제공하는 방법을 정의하고, DataLoader는 데이터를 배치로 묶어 가져온다.
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의 특징 추출 부분을 이미지 인코더로 활용하고, 추출한 특징을 별도의 문장 생성 모델에 전달할 수 있다.
모델과 입력, 정답은 같은 장치에 있어야 한다.
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
모델과 입력의 장치가 다르면 일반적으로 장치 불일치 오류가 발생한다.
아래 코드는 샘플마다 정답 클래스가 하나인 분류 작업을 위한 예시다.
기본 평균 방식의 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
배치 크기를 반영해 누적하면 마지막 배치가 작아도 샘플 수를 반영한 평균 손실을 구할 수 있다.
| 구분 | 학습 | 평가 |
|---|---|---|
| 모델 모드 | model.train() | model.eval() |
| 기울기 계산 | 필요 | 일반적으로 불필요 |
| 역전파 | 수행 | 수행하지 않음 |
| 가중치 업데이트 | 수행 | 수행하지 않음 |
model.eval()은 Dropout과 BatchNorm 같은 층의 동작을 평가용으로 바꾼다.
하지만 기울기 계산 자체를 끄지는 않는다. 따라서 평가에서는 다음을 함께 사용한다.
model.eval()
with torch.no_grad():
outputs = model(inputs)
분류 정확도는 가장 높은 점수를 가진 클래스와 실제 정답을 비교해서 구한다.
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를 적용할 필요가 없다.
이번 실습을 통해 개별 코드가 데이터 준비에서 학습과 평가까지 이어지는 하나의 흐름이라는 점을 이해했다.