시퀀스-투-시퀀스 모델(Sequence-to-Sequence Models)
번역기인 척하는 두 개의 순환 신경망(RNN)입니다. 이 두 신경망이 부딪히는 병목(bottleneck)이야말로 어텐션(attention)이 존재하는 이유입니다.
유형: Build
언어: Python
선수 강의: Phase 5 · 08 (CNNs + RNNs for Text), Phase 3 · 11 (PyTorch Intro)
예상 시간: 약 75분
학습 목표
- 인코더-디코더(encoder-decoder) 구조와 문맥 벡터(context vector) 병목을 설명합니다.
- 교사 강요(teacher forcing), 노출 편향(exposure bias), 점진적 샘플링(scheduled sampling)의 의미를 이해합니다.
- 그리디 디코딩(greedy decoding)과 빔 서치(beam search)의 차이를 설명합니다.
- 현대의 인코더-디코더 트랜스포머(transformer)가 2014년 seq2seq 구조와 무엇이 같고 무엇이 다른지 파악합니다.
문제
분류(classification)는 가변 길이(variable-length) 시퀀스를 하나의 라벨(label)로 매핑합니다. 번역(translation)은 가변 길이 시퀀스를 또 다른 가변 길이 시퀀스로 매핑합니다. 입력과 출력은 서로 다른 어휘(vocabulary)에 속할 수 있고, 서로 다른 언어일 수 있으며, 길이가 같다는 보장도 없습니다.
seq2seq 구조(Sutskever, Vinyals, Le, 2014)는 의도적으로 단순한 레시피로 이 문제를 풀었습니다. 두 개의 RNN을 사용합니다. 하나는 원문 문장(source sentence)을 읽어 고정 크기(fixed-size) 문맥 벡터를 만듭니다. 다른 하나는 그 벡터를 받아 목표 문장(target sentence)을 토큰 단위로(token by token) 생성합니다. lesson 08에서 작성한 코드와 동일한 구성요소를 다르게 이어붙인 셈입니다.
이 구조를 공부할 가치가 있는 이유는 두 가지입니다. 첫째, 문맥 벡터 병목은 자연어 처리(NLP) 분야에서 교육적으로 가장 유용한 실패 사례입니다. 어텐션과 트랜스포머가 잘 해내는 모든 것을 동기 부여해 주기 때문입니다. 둘째, 학습 레시피(교사 강요, 점진적 샘플링, 추론 시점의 빔 서치)는 LLM을 포함한 모든 현대 생성 시스템에 여전히 적용됩니다.
개념

인코더(Encoder). 원문 문장을 읽는 RNN입니다. 마지막 은닉 상태(hidden state)가 바로 문맥 벡터(context vector) 이며, 전체 입력의 고정 크기 요약이라고 가정합니다. 이상적으로는 원문에서 잃는 것이 없어야 합니다.
디코더(Decoder). 문맥 벡터로 초기화된 또 다른 RNN입니다. 각 단계(step)에서 직전에 생성한 토큰을 입력으로 받고, 목표 어휘 전체에 대한 분포(distribution)를 출력합니다. 그 분포에서 샘플링하거나 최댓값(argmax)을 골라 다음 토큰을 결정한 뒤 다시 입력으로 넣습니다. <EOS> 토큰이 나오거나 최대 길이(max length)에 도달할 때까지 반복합니다.
학습(Training). 디코더 매 단계에서 교차 엔트로피 손실(cross-entropy loss)을 계산하고 시퀀스 전체에 대해 합산합니다. 두 신경망을 모두 통과하는 표준 시간 역전파(backpropagation through time)를 수행합니다.
교사 강요(Teacher forcing). 학습 중 디코더의 단계 t 입력으로는 모델 자신의 직전 예측이 아니라, 위치 t-1의 정답(ground-truth) 토큰을 넣습니다. 이렇게 하면 학습이 안정됩니다. 교사 강요 없이는 초반의 작은 실수가 연쇄적으로 커져 모델이 끝내 학습하지 못합니다. 다만 추론(inference) 시에는 모델 자신의 예측을 그대로 사용할 수밖에 없으므로, 학습과 추론 사이에는 분포 차이가 항상 존재합니다. 이 차이를 노출 편향(exposure bias) 이라고 부릅니다.
병목(Bottleneck). 인코더가 원문에서 학습한 모든 정보는 단 하나의 문맥 벡터로 압축되어야 합니다. 긴 문장은 세부 정보를 잃고, 희귀 단어(rare word)는 흐려지며, 어순 재배치(예: 프랑스어 chat noir vs. 영어 black cat)는 계산이 아닌 암기에 의존하게 됩니다.
어텐션(lesson 10)은 디코더가 마지막 은닉 상태 하나가 아니라 모든 인코더 은닉 상태를 참조할 수 있게 하여 이 문제를 직접 해결합니다. 한 줄로 요약하면 그것이 어텐션의 전부입니다.
직접 만들기
Step 1: 인코더(Encoder)
import torch
import torch.nn as nn
class Encoder(nn.Module):
def __init__(self, src_vocab_size, embed_dim, hidden_dim):
super().__init__()
self.embed = nn.Embedding(src_vocab_size, embed_dim, padding_idx=0)
self.gru = nn.GRU(embed_dim, hidden_dim, batch_first=True)
def forward(self, src):
e = self.embed(src)
outputs, hidden = self.gru(e)
return outputs, hidden
outputs의 형태(shape)는 [batch, seq_len, hidden_dim]이며 입력 위치마다 은닉 상태가 하나씩 들어 있습니다. hidden의 형태는 [1, batch, hidden_dim]이며 마지막 단계의 은닉 상태입니다. lesson 08에서는 "분류를 위해 outputs에 대해 풀링(pooling)하라"고 했지만, 여기서는 마지막 은닉 상태를 문맥 벡터로 사용하고 단계별 출력은 무시합니다.
Step 2: 디코더(Decoder)
class Decoder(nn.Module):
def __init__(self, tgt_vocab_size, embed_dim, hidden_dim):
super().__init__()
self.embed = nn.Embedding(tgt_vocab_size, embed_dim, padding_idx=0)
self.gru = nn.GRU(embed_dim, hidden_dim, batch_first=True)
self.fc = nn.Linear(hidden_dim, tgt_vocab_size)
def forward(self, token, hidden):
e = self.embed(token)
out, hidden = self.gru(e, hidden)
logits = self.fc(out)
return logits, hidden
디코더는 한 단계씩 호출됩니다. 입력은 단일 토큰들로 구성된 배치(batch)와 현재 은닉 상태이고, 출력은 다음 토큰에 대한 어휘 로짓(vocabulary logits)과 갱신된 은닉 상태입니다.
Step 3: 교사 강요가 적용된 학습 루프
def train_batch(encoder, decoder, src, tgt, bos_id, optimizer, teacher_forcing_ratio=0.9):
optimizer.zero_grad()
_, hidden = encoder(src)
batch_size, tgt_len = tgt.shape
input_token = torch.full((batch_size, 1), bos_id, dtype=torch.long)
loss = 0.0
loss_fn = nn.CrossEntropyLoss(ignore_index=0)
for t in range(tgt_len):
logits, hidden = decoder(input_token, hidden)
step_loss = loss_fn(logits.squeeze(1), tgt[:, t])
loss += step_loss
use_teacher = torch.rand(1).item() < teacher_forcing_ratio
if use_teacher:
input_token = tgt[:, t].unsqueeze(1)
else:
input_token = logits.argmax(dim=-1)
loss.backward()
optimizer.step()
return loss.item() / tgt_len
이름을 붙여 둘 만한 손잡이가 두 개 있습니다. ignore_index=0은 패딩 토큰(padding token)에 대한 손실을 건너뜁니다. teacher_forcing_ratio는 각 단계에서 정답 토큰을 입력으로 쓸지, 모델 자신의 예측을 쓸지를 결정하는 확률입니다. 보통 1.0(완전 교사 강요)에서 시작해 학습이 진행되는 동안 0.5 부근까지 점진적으로 줄여, 노출 편향으로 인한 학습-추론 간극을 좁힙니다.
Step 4: 추론 루프 — 그리디 디코딩
@torch.no_grad()
def greedy_decode(encoder, decoder, src, bos_id, eos_id, max_len=50):
_, hidden = encoder(src)
batch_size = src.shape[0]
input_token = torch.full((batch_size, 1), bos_id, dtype=torch.long)
output_ids = []
for _ in range(max_len):
logits, hidden = decoder(input_token, hidden)
next_token = logits.argmax(dim=-1)
output_ids.append(next_token)
input_token = next_token
if (next_token == eos_id).all():
break
return torch.cat(output_ids, dim=1)
그리디 디코딩은 매 단계에서 확률이 가장 높은 토큰을 고릅니다. 한번 어떤 토큰을 선택하면 다시 되돌릴 수 없기 때문에 엉뚱한 방향으로 흘러갈 수 있습니다. 빔 서치(beam search) 는 상위 k개의 부분 시퀀스(partial sequence)를 함께 유지하다가, 마지막에 가장 높은 점수를 가진 완성된 시퀀스를 선택합니다. 빔 폭(beam width) 3~5가 일반적입니다.
Step 5: 병목을 직접 확인하기
장난감 복사 과제(toy copy task)로 모델을 학습시켜 봅니다. 입력 [a, b, c, d, e]에 대해 목표 출력도 [a, b, c, d, e]로 같게 두고, 시퀀스 길이를 늘리며 정확도를 관찰합니다.
seq_len=5 copy accuracy: 98%
seq_len=10 copy accuracy: 91%
seq_len=20 copy accuracy: 62%
seq_len=40 copy accuracy: 23%
단일 GRU 은닉 상태로는 40개 토큰짜리 입력을 손실 없이 기억할 수 없습니다. 정보는 인코더의 매 단계마다 분명히 존재하지만, 디코더는 마지막 상태 하나만 보기 때문입니다. 어텐션은 바로 이 한계를 직접 해결합니다.
사용해보기
PyTorch에는 nn.Transformer와 nn.LSTM 기반의 seq2seq 템플릿이 있습니다. Hugging Face의 transformers 라이브러리에는 수십억 토큰으로 학습된 완전한 인코더-디코더 모델(BART, T5, mBART, NLLB)이 포함되어 있습니다.
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
tok = AutoTokenizer.from_pretrained("facebook/bart-base")
model = AutoModelForSeq2SeqLM.from_pretrained("facebook/bart-base")
src = tok("Translate this to French: Hello, how are you?", return_tensors="pt")
out = model.generate(**src, max_new_tokens=50, num_beams=4)
print(tok.decode(out[0], skip_special_tokens=True))
현대의 인코더-디코더 모델은 RNN을 트랜스포머로 교체했습니다. 그러나 큰 틀(인코더, 디코더, 토큰 단위 생성)은 2014년 seq2seq 논문과 동일합니다. 달라진 것은 각 블록 내부의 메커니즘입니다.
그래도 RNN 기반 seq2seq가 필요한 경우
새 프로젝트에서는 거의 사용하지 않습니다. 다만 다음과 같은 특수한 예외가 있습니다.
- 입력을 한 토큰씩 소비하며 메모리 한도를 지켜야 하는 스트리밍 번역(streaming translation).
- 트랜스포머의 메모리 비용이 부담스러운 온디바이스(on-device) 텍스트 생성.
- 교육 목적. 인코더-디코더 병목을 이해하는 것이 트랜스포머가 승리한 이유를 가장 빠르게 이해하는 길입니다.
노출 편향과 그 완화책
- 점진적 샘플링(Scheduled sampling). 학습 중 교사 강요 비율을 점차 낮춰, 모델이 자기 실수에서 회복하는 법을 배우게 합니다.
- 최소 위험 학습(Minimum risk training). 토큰 단위 교차 엔트로피 대신 문장 단위 BLEU 점수를 기준으로 학습합니다. 실제로 우리가 원하는 목표에 더 가깝습니다.
- 강화 학습 기반 미세 조정(Reinforcement learning fine-tuning). 시퀀스 생성기에 평가 지표(metric) 기반의 보상(reward)을 줍니다. 현대 LLM의 인간 피드백 강화 학습(RLHF)에서도 사용되는 계열입니다.
세 방법 모두 트랜스포머 기반 생성 모델에도 그대로 적용됩니다.
산출물 만들기
outputs/prompt-seq2seq-design.md로 저장합니다.
---
name: seq2seq-design
description: Design a sequence-to-sequence pipeline for a given task.
phase: 5
lesson: 09
---
Given a task (translation, summarization, paraphrase, question rewrite), output:
Guide the student in Korean.
1. Architecture. Pretrained transformer encoder-decoder (BART, T5, mBART, NLLB) is the default. RNN-based seq2seq only for specific constraints.
2. Starting checkpoint. Name it (`facebook/bart-base`, `google/flan-t5-base`, `facebook/nllb-200-distilled-600M`). Match the checkpoint to task and language coverage.
3. Decoding strategy. Greedy for deterministic output, beam search (width 4-5) for quality, sampling with temperature for diversity. One sentence justification.
4. One failure mode to verify before shipping. Exposure bias manifests as generation drift on longer outputs; sample 20 outputs at the 90th-percentile length and eyeball.
Refuse to recommend training a seq2seq from scratch for under a million parallel examples. Flag any pipeline that uses greedy decoding for user-facing content as fragile (greedy repeats and loops).
연습문제
- 쉬움. 장난감 복사 과제를 구현합니다. 목표 출력이 입력과 동일한 쌍으로 GRU 기반 seq2seq를 학습한 뒤, 길이 5, 10, 20에서 정확도를 측정해 병목 현상을 재현합니다.
- 중간. 빔 폭 3의 빔 서치 디코딩을 추가합니다. 작은 병렬 코퍼스(parallel corpus)에서 그리디 디코딩과 BLEU 점수를 비교합니다. 빔 서치가 우세한 위치(주로 마지막 토큰 부근)와 차이가 없는 경우를 정리해 둡니다.
- 어려움.
facebook/bart-base를 1만 쌍 규모의 패러프레이즈(paraphrase) 데이터셋으로 미세 조정합니다. 미세 조정한 모델의 빔 폭 4 출력과 베이스 모델의 출력을 보류 입력(held-out input) 위에서 비교하고, BLEU 점수와 정성적 예시 10건을 보고합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 인코더(Encoder) | 입력 RNN | 원문을 읽어 단계별 은닉 상태와 마지막 문맥 벡터를 만듭니다. |
| 디코더(Decoder) | 출력 RNN | 문맥 벡터로 초기화된 뒤 목표 토큰을 하나씩 생성합니다. |
| 문맥 벡터(Context vector) | 요약 | 인코더의 마지막 은닉 상태. 고정 크기이며, 어텐션이 해결하는 병목입니다. |
| 교사 강요(Teacher forcing) | 정답 토큰을 사용 | 학습 시 직전 정답 토큰을 디코더 입력으로 넣어 학습을 안정화합니다. |
| 노출 편향(Exposure bias) | 학습/추론 간극 | 정답 토큰만 보며 학습한 모델은 자기 실수에서 회복하는 연습을 하지 못합니다. |
| 빔 서치(Beam search) | 더 나은 디코딩 | 각 단계마다 상위 k개의 부분 시퀀스를 유지해 그리디한 결정을 피합니다. |
더 읽을거리