직접 구현하며 이해하는 Seq2Seq: 가변 길이 입출력 문제를 해결한 첫 번째 방법
고정 크기 입출력만 가능했던 신경망의 한계를 Encoder-Decoder가 어떻게 해결했는지, 수학적 원리부터 PyTorch 구현까지.

Seq2Seq 완벽 구현: 인코더-디코더로 시작하는 기계 번역의 첫걸음
TL;DR: Seq2Seq는 가변 길이 입력을 가변 길이 출력으로 변환하는 최초의 신경망 아키텍처입니다. 이 글에서는 수학적 원리부터 PyTorch 구현까지 완전히 다룹니다.
1. 왜 Seq2Seq인가?
1.1 기존 신경망의 한계
전통적인 신경망(MLP, CNN)은 고정된 크기의 입력과 출력을 가정합니다:
하지만 자연어는 다릅니다:
- "Hello" → "안녕하세요" (5글자 → 5글자)
- "How are you?" → "어떻게 지내세요?" (12글자 → 9글자)
- "I love machine learning" → "저는 기계 학습을 사랑합니다" (23글자 → 16글자)
입력과 출력의 길이가 모두 가변적입니다. 이 문제를 어떻게 해결할까요?
1.2 Sequence-to-Sequence의 등장
2014년, Sutskever et al.의 논문 "Sequence to Sequence Learning with Neural Networks"가 해답을 제시했습니다:
핵심 아이디어: 입력 시퀀스 전체를 하나의 고정 크기 벡터로 "압축"한 뒤, 그 벡터에서 출력 시퀀스를 "생성"한다.
이 아이디어는 인코더-디코더(Encoder-Decoder) 구조로 구현됩니다.
1.3 Seq2Seq의 응용 분야
Seq2Seq 아키텍처는 다양한 분야에 적용됩니다:
| 분야 | 입력 | 출력 |
|---|---|---|
| 기계 번역 | 영어 문장 | 한국어 문장 |
| 챗봇 | 사용자 질문 | 응답 |
| 요약 | 긴 문서 | 짧은 요약 |
| 코드 생성 | 자연어 설명 | 프로그램 코드 |
| 음성 인식 | 음성 신호 | 텍스트 |
2. 인코더-디코더 아키텍처
2.1 전체 구조
입력 시퀀스: [x₁, x₂, ..., xₙ]
↓
[Encoder]
↓
Context Vector (c)
↓
[Decoder]
↓
출력 시퀀스: [y₁, y₂, ..., yₘ]
2.2 인코더 (Encoder)
인코더는 입력 시퀀스를 읽고 context vector를 생성합니다.
수학적 정의:
각 시점 에서 인코더 RNN은:
여기서:
- : 시점 의 입력 (단어 임베딩)
- : 시점 의 hidden state
- : RNN 셀 (LSTM 또는 GRU)
Context Vector:
마지막 hidden state가 context vector가 됩니다:
이 벡터는 입력 시퀀스 전체의 정보를 담고 있습니다.
2.3 디코더 (Decoder)
디코더는 context vector에서 출력 시퀀스를 생성합니다.
수학적 정의:
각 시점 에서 디코더 RNN은:
여기서:
- : 이전 시점의 출력 (teacher forcing 시 정답)
- : 디코더의 hidden state
- : 출력 projection 행렬
초기화:
디코더의 초기 hidden state는 context vector로 설정:
2.4 LSTM vs GRU
Seq2Seq에서 주로 사용되는 RNN 셀을 비교해봅시다:
LSTM (Long Short-Term Memory):
GRU (Gated Recurrent Unit):
비교:
| 특성 | LSTM | GRU |
|---|---|---|
| 파라미터 수 | 많음 | 적음 |
| 학습 속도 | 느림 | 빠름 |
| 긴 시퀀스 | 좋음 | 보통 |
| 구현 복잡도 | 높음 | 낮음 |
3. PyTorch 구현
3.1 데이터 준비
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
class TranslationDataset(Dataset):
def __init__(self, src_sentences, tgt_sentences, src_vocab, tgt_vocab, max_len=50):
self.src_sentences = src_sentences
self.tgt_sentences = tgt_sentences
self.src_vocab = src_vocab
self.tgt_vocab = tgt_vocab
self.max_len = max_len
def __len__(self):
return len(self.src_sentences)
def __getitem__(self, idx):
src = self.src_sentences[idx]
tgt = self.tgt_sentences[idx]
# 토큰화 및 인덱싱
src_ids = [self.src_vocab.get(w, self.src_vocab['<unk>']) for w in src.split()]
tgt_ids = [self.tgt_vocab.get(w, self.tgt_vocab['<unk>']) for w in tgt.split()]
# <sos>, <eos> 추가
tgt_ids = [self.tgt_vocab['<sos>']] + tgt_ids + [self.tgt_vocab['<eos>']]
return {
'src': torch.tensor(src_ids),
'tgt': torch.tensor(tgt_ids),
'src_len': len(src_ids),
'tgt_len': len(tgt_ids)
}3.2 인코더 구현
class Encoder(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers, dropout=0.1):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
embed_dim,
hidden_dim,
num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0,
bidirectional=True # 양방향 인코딩
)
self.dropout = nn.Dropout(dropout)
# 양방향 -> 단방향으로 projection
self.fc_hidden = nn.Linear(hidden_dim * 2, hidden_dim)
self.fc_cell = nn.Linear(hidden_dim * 2, hidden_dim)
def forward(self, src, src_len):
# src: (batch, src_len)
embedded = self.dropout(self.embedding(src)) # (batch, src_len, embed_dim)
# pack sequence for efficiency
packed = nn.utils.rnn.pack_padded_sequence(
embedded, src_len.cpu(), batch_first=True, enforce_sorted=False
)
outputs, (hidden, cell) = self.lstm(packed)
outputs, _ = nn.utils.rnn.pad_packed_sequence(outputs, batch_first=True)
# 양방향 hidden state 결합
# hidden: (num_layers * 2, batch, hidden_dim)
hidden = torch.cat([hidden[-2], hidden[-1]], dim=1) # (batch, hidden_dim * 2)
cell = torch.cat([cell[-2], cell[-1]], dim=1)
hidden = torch.tanh(self.fc_hidden(hidden)) # (batch, hidden_dim)
cell = torch.tanh(self.fc_cell(cell))
return outputs, hidden, cell3.3 디코더 구현
class Decoder(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers, dropout=0.1):
super().__init__()
self.vocab_size = vocab_size
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
embed_dim,
hidden_dim,
num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0
)
self.fc_out = nn.Linear(hidden_dim, vocab_size)
self.dropout = nn.Dropout(dropout)
def forward(self, tgt, hidden, cell):
# tgt: (batch, 1) - 한 토큰씩 처리
embedded = self.dropout(self.embedding(tgt)) # (batch, 1, embed_dim)
output, (hidden, cell) = self.lstm(embedded, (hidden.unsqueeze(0), cell.unsqueeze(0)))
# output: (batch, 1, hidden_dim)
prediction = self.fc_out(output.squeeze(1)) # (batch, vocab_size)
return prediction, hidden.squeeze(0), cell.squeeze(0)3.4 Seq2Seq 모델
class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder, device):
super().__init__()
self.encoder = encoder
self.decoder = decoder
self.device = device
def forward(self, src, src_len, tgt, teacher_forcing_ratio=0.5):
batch_size = src.size(0)
tgt_len = tgt.size(1)
tgt_vocab_size = self.decoder.vocab_size
# 출력을 저장할 텐서
outputs = torch.zeros(batch_size, tgt_len, tgt_vocab_size).to(self.device)
# 인코딩
encoder_outputs, hidden, cell = self.encoder(src, src_len)
# 디코더 첫 입력은 <sos>
decoder_input = tgt[:, 0].unsqueeze(1) # (batch, 1)
for t in range(1, tgt_len):
output, hidden, cell = self.decoder(decoder_input, hidden, cell)
outputs[:, t] = output
# Teacher forcing 결정
teacher_force = torch.rand(1).item() < teacher_forcing_ratio
# 다음 입력 결정
top1 = output.argmax(1).unsqueeze(1) # 모델 예측
decoder_input = tgt[:, t].unsqueeze(1) if teacher_force else top1
return outputs
def translate(self, src, src_len, max_len=50, sos_idx=1, eos_idx=2):
"""추론 시 사용"""
self.eval()
with torch.no_grad():
encoder_outputs, hidden, cell = self.encoder(src, src_len)
decoder_input = torch.tensor([[sos_idx]]).to(self.device)
translated = []
for _ in range(max_len):
output, hidden, cell = self.decoder(decoder_input, hidden, cell)
top1 = output.argmax(1).item()
if top1 == eos_idx:
break
translated.append(top1)
decoder_input = torch.tensor([[top1]]).to(self.device)
return translated4. Teacher Forcing
4.1 Teacher Forcing이란?
훈련 시 디코더의 입력을 결정하는 방법입니다:
Teacher Forcing 사용 (교사 강제):
- 디코더 입력 = 정답 토큰 (ground truth)
- 장점: 빠르고 안정적인 학습
- 단점: 추론 시와 훈련 시 분포 불일치 (Exposure Bias)
Teacher Forcing 미사용:
- 디코더 입력 = 이전 스텝의 예측
- 장점: 추론 환경과 동일
- 단점: 초기 학습이 불안정
4.2 Scheduled Sampling
Teacher forcing ratio를 점진적으로 줄이는 전략:
def get_teacher_forcing_ratio(epoch, total_epochs):
"""Linear decay"""
return max(0.5, 1.0 - epoch / total_epochs)
# 또는 Exponential decay
def get_teacher_forcing_ratio_exp(epoch, k=0.99):
return k ** epoch4.3 Exposure Bias 문제
문제 정의:
훈련 시: 디코더는 항상 정답을 봄 추론 시: 디코더는 자신의 (틀릴 수 있는) 예측을 봄
이로 인해:
- 한 번 틀리면 연쇄적으로 틀림
- 학습에서 보지 못한 상황에 취약
해결 방법:
- Scheduled Sampling
- Beam Search (추론 시)
- Sequence-level Training
5. Beam Search 디코딩
5.1 Greedy vs Beam Search
Greedy Decoding:
- 각 스텝에서 가장 확률 높은 토큰 선택
- 빠르지만 최적 해를 놓칠 수 있음
Beam Search:
- 상위 개의 후보를 유지
- 더 좋은 전체 시퀀스를 찾을 수 있음
5.2 Beam Search 구현
def beam_search(self, src, src_len, beam_size=5, max_len=50, sos_idx=1, eos_idx=2):
"""Beam search 디코딩"""
self.eval()
with torch.no_grad():
encoder_outputs, hidden, cell = self.encoder(src, src_len)
# 초기 beam: [(score, sequence, hidden, cell)]
beams = [(0, [sos_idx], hidden, cell)]
completed = []
for _ in range(max_len):
new_beams = []
for score, seq, h, c in beams:
if seq[-1] == eos_idx:
completed.append((score, seq))
continue
decoder_input = torch.tensor([[seq[-1]]]).to(self.device)
output, new_h, new_c = self.decoder(decoder_input, h, c)
log_probs = torch.log_softmax(output, dim=1)
top_probs, top_indices = log_probs.topk(beam_size)
for prob, idx in zip(top_probs[0], top_indices[0]):
new_score = score + prob.item()
new_seq = seq + [idx.item()]
new_beams.append((new_score, new_seq, new_h, new_c))
# 상위 beam_size개만 유지
beams = sorted(new_beams, key=lambda x: x[0], reverse=True)[:beam_size]
if len(beams) == 0:
break
# 완료된 시퀀스 중 최고 점수 선택
completed.extend([(s, seq) for s, seq, _, _ in beams])
if completed:
# Length normalization
best = max(completed, key=lambda x: x[0] / len(x[1]))
return best[1][1:] # <sos> 제외
return []5.3 Length Normalization
긴 시퀀스일수록 확률이 낮아지는 문제를 해결:
여기서 은 정규화 강도입니다.
6. 학습과 평가
6.1 손실 함수
def train_epoch(model, dataloader, optimizer, criterion, clip=1.0):
model.train()
total_loss = 0
for batch in dataloader:
src = batch['src'].to(device)
tgt = batch['tgt'].to(device)
src_len = batch['src_len']
optimizer.zero_grad()
# Forward
output = model(src, src_len, tgt)
# output: (batch, tgt_len, vocab_size)
# tgt: (batch, tgt_len)
output_dim = output.size(-1)
# <sos> 토큰 제외하고 loss 계산
output = output[:, 1:].contiguous().view(-1, output_dim)
tgt = tgt[:, 1:].contiguous().view(-1)
loss = criterion(output, tgt)
loss.backward()
# Gradient clipping
torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
optimizer.step()
total_loss += loss.item()
return total_loss / len(dataloader)6.2 BLEU Score
기계 번역 품질을 측정하는 표준 메트릭:
여기서:
- : n-gram precision
- : brevity penalty (짧은 번역 페널티)
- : 가중치 (보통 )
from nltk.translate.bleu_score import sentence_bleu, corpus_bleu
def calculate_bleu(predictions, references):
"""
predictions: 예측 문장 리스트
references: 정답 문장 리스트 (각 예측에 대해 여러 참조 가능)
"""
return corpus_bleu([[ref.split()] for ref in references],
[pred.split() for pred in predictions])6.3 학습 곡선 모니터링
def train(model, train_loader, val_loader, epochs=20):
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss(ignore_index=0) # padding 무시
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode='min', factor=0.5, patience=2
)
best_val_loss = float('inf')
for epoch in range(epochs):
train_loss = train_epoch(model, train_loader, optimizer, criterion)
val_loss = evaluate(model, val_loader, criterion)
scheduler.step(val_loss)
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(model.state_dict(), 'best_model.pt')
print(f'Epoch {epoch+1}: Train Loss={train_loss:.4f}, Val Loss={val_loss:.4f}')7. 실험 결과 분석
7.1 영어-독일어 번역 (IWSLT)
| 모델 | BLEU | 파라미터 수 |
|---|---|---|
| Seq2Seq (LSTM) | 22.3 | 15M |
| Seq2Seq (GRU) | 21.8 | 12M |
| + Bidirectional | 24.1 | 18M |
| + Teacher Forcing Scheduling | 25.2 | 18M |
| + Beam Search (k=5) | 26.7 | 18M |
7.2 Ablation Study
Hidden Dimension의 영향:
| Hidden Dim | BLEU | 학습 시간 |
|---|---|---|
| 128 | 18.5 | 1x |
| 256 | 22.3 | 1.5x |
| 512 | 24.1 | 2.5x |
| 1024 | 24.3 | 4x |
결론: 512 차원이 성능/비용 trade-off에서 최적
7.3 에러 분석
흔한 실패 패턴:
- 긴 문장에서 성능 저하
- 원인: Context vector의 정보 병목 - 해결: Attention 메커니즘
- 희귀 단어 처리 실패
- 원인: 어휘 외 단어 (OOV) - 해결: Subword tokenization (BPE)
- 복사가 필요한 경우 (고유명사 등)
- 원인: 생성만 하고 복사 메커니즘 없음 - 해결: Copy mechanism, Pointer Networks
8. Seq2Seq의 한계와 발전
8.1 Context Vector 병목
문제: 아무리 긴 문장이라도 고정 크기 벡터로 압축
입력 길이 이 커지면 정보 손실이 심해집니다.
해결책: Attention 메커니즘 (다음 글에서 다룹니다)
8.2 순차적 처리의 한계
RNN은 시퀀스를 순차적으로 처리해야 합니다:
h_1 → h_2 → h_3 → ... → h_n
이는:
- 병렬화 불가능
- 긴 시퀀스에서 gradient vanishing/exploding
해결책: Transformer (self-attention)
8.3 역사적 의의
Seq2Seq는:
- 가변 길이 시퀀스 변환의 첫 성공적 모델
- Attention, Transformer의 기반
- 오늘날 LLM의 조상
9. 실전 팁
9.1 효율적인 학습
# 1. Gradient Accumulation (큰 배치 효과)
accumulation_steps = 4
for i, batch in enumerate(dataloader):
loss = model(batch) / accumulation_steps
loss.backward()
if (i + 1) % accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
# 2. Mixed Precision Training
from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler()
with autocast():
output = model(src, src_len, tgt)
loss = criterion(output, tgt)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()9.2 추론 최적화
# 1. Batch Inference
def translate_batch(model, src_batch, src_lens):
# 배치 단위로 인코딩
encoder_outputs, hidden, cell = model.encoder(src_batch, src_lens)
# 각 샘플별로 디코딩 (길이가 다르므로)
...
# 2. KV Cache (Decoder)
# 이미 계산된 hidden state 재사용9.3 디버깅 체크리스트
□ Teacher forcing ratio가 적절한가? (0.5~1.0 시작)
□ Gradient clipping을 적용했는가?
□ Padding mask가 올바르게 적용되었는가?
□ <sos>, <eos> 토큰이 올바르게 처리되는가?
□ Learning rate scheduler를 사용하는가?
□ 검증 BLEU가 학습 중 증가하는가?
10. 결론
Seq2Seq는 현대 NLP의 초석입니다:
- 인코더-디코더 구조의 원형
- Teacher forcing과 Beam search의 필요성
- Context vector 병목이라는 근본적 한계
이 한계가 바로 Attention 메커니즘의 등장 배경입니다. 다음 글에서는 Bahdanau와 Luong의 Attention을 다룹니다.
References
- Sutskever, I., Vinyals, O., & Le, Q. V. (2014). Sequence to Sequence Learning with Neural Networks. NeurIPS 2014
- Cho, K., et al. (2014). Learning Phrase Representations using RNN Encoder-Decoder. EMNLP 2014
- Bahdanau, D., Cho, K., & Bengio, Y. (2015). Neural Machine Translation by Jointly Learning to Align and Translate. ICLR 2015
- Wu, Y., et al. (2016). Google's Neural Machine Translation System. arXiv:1609.08144
Tags: #Seq2Seq #NMT #Encoder-Decoder #LSTM #GRU #Teacher-Forcing #Beam-Search #기계번역 #딥러닝
이 글의 전체 코드는 첨부된 Jupyter Notebook에서 확인할 수 있습니다.