Models & Algorithms

직접 구현하며 이해하는 Seq2Seq: 가변 길이 입출력 문제를 해결한 첫 번째 방법

고정 크기 입출력만 가능했던 신경망의 한계를 Encoder-Decoder가 어떻게 해결했는지, 수학적 원리부터 PyTorch 구현까지.

직접 구현하며 이해하는 Seq2Seq: 가변 길이 입출력 문제를 해결한 첫 번째 방법

Seq2Seq 완벽 구현: 인코더-디코더로 시작하는 기계 번역의 첫걸음

TL;DR: Seq2Seq는 가변 길이 입력을 가변 길이 출력으로 변환하는 최초의 신경망 아키텍처입니다. 이 글에서는 수학적 원리부터 PyTorch 구현까지 완전히 다룹니다.

1. 왜 Seq2Seq인가?

1.1 기존 신경망의 한계

전통적인 신경망(MLP, CNN)은 고정된 크기의 입력과 출력을 가정합니다:

f:RnRmf: \mathbb{R}^n \rightarrow \mathbb{R}^m

하지만 자연어는 다릅니다:

  • "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를 생성합니다.

수학적 정의:

각 시점 tt에서 인코더 RNN은:

htenc=fenc(xt,ht1enc)h_t^{enc} = f_{enc}(x_t, h_{t-1}^{enc})

여기서:

  • xtx_t: 시점 tt의 입력 (단어 임베딩)
  • htench_t^{enc}: 시점 tt의 hidden state
  • fencf_{enc}: RNN 셀 (LSTM 또는 GRU)

Context Vector:

마지막 hidden state가 context vector가 됩니다:

c=hnencc = h_n^{enc}

이 벡터는 입력 시퀀스 전체의 정보를 담고 있습니다.

2.3 디코더 (Decoder)

디코더는 context vector에서 출력 시퀀스를 생성합니다.

수학적 정의:

각 시점 tt에서 디코더 RNN은:

htdec=fdec(yt1,ht1dec)p(yty<t,x)=softmax(Wohtdec)\begin{aligned}h_t^{dec} &= f_{dec}(y_{t-1}, h_{t-1}^{dec}) \\p(y_t | y_{<t}, x) &= \text{softmax}(W_o h_t^{dec})\end{aligned}

여기서:

  • yt1y_{t-1}: 이전 시점의 출력 (teacher forcing 시 정답)
  • htdech_t^{dec}: 디코더의 hidden state
  • WoW_o: 출력 projection 행렬

초기화:

디코더의 초기 hidden state는 context vector로 설정:

h0dec=c=hnench_0^{dec} = c = h_n^{enc}

2.4 LSTM vs GRU

Seq2Seq에서 주로 사용되는 RNN 셀을 비교해봅시다:

LSTM (Long Short-Term Memory):

ft=σ(Wf[ht1,xt]+bf)(forget gate)it=σ(Wi[ht1,xt]+bi)(input gate)C~t=tanh(WC[ht1,xt]+bC)(candidate)Ct=ftCt1+itC~t(cell state)ot=σ(Wo[ht1,xt]+bo)(output gate)ht=ottanh(Ct)(hidden state)\begin{aligned}f_t &= \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) & \text{(forget gate)} \\i_t &= \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) & \text{(input gate)} \\\tilde{C}_t &= \tanh(W_C \cdot [h_{t-1}, x_t] + b_C) & \text{(candidate)} \\C_t &= f_t * C_{t-1} + i_t * \tilde{C}_t & \text{(cell state)} \\o_t &= \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) & \text{(output gate)} \\h_t &= o_t * \tanh(C_t) & \text{(hidden state)}\end{aligned}

GRU (Gated Recurrent Unit):

zt=σ(Wz[ht1,xt])(update gate)rt=σ(Wr[ht1,xt])(reset gate)h~t=tanh(W[rtht1,xt])(candidate)ht=(1zt)ht1+zth~t(hidden state)\begin{aligned}z_t &= \sigma(W_z \cdot [h_{t-1}, x_t]) & \text{(update gate)} \\r_t &= \sigma(W_r \cdot [h_{t-1}, x_t]) & \text{(reset gate)} \\\tilde{h}_t &= \tanh(W \cdot [r_t * h_{t-1}, x_t]) & \text{(candidate)} \\h_t &= (1 - z_t) * h_{t-1} + z_t * \tilde{h}_t & \text{(hidden state)}\end{aligned}

비교:

특성LSTMGRU
파라미터 수많음적음
학습 속도느림빠름
긴 시퀀스좋음보통
구현 복잡도높음낮음

3. PyTorch 구현

3.1 데이터 준비

python
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 인코더 구현

python
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, cell

3.3 디코더 구현

python
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 모델

python
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 translated

4. Teacher Forcing

4.1 Teacher Forcing이란?

훈련 시 디코더의 입력을 결정하는 방법입니다:

Teacher Forcing 사용 (교사 강제):

  • 디코더 입력 = 정답 토큰 (ground truth)
  • 장점: 빠르고 안정적인 학습
  • 단점: 추론 시와 훈련 시 분포 불일치 (Exposure Bias)

Teacher Forcing 미사용:

  • 디코더 입력 = 이전 스텝의 예측
  • 장점: 추론 환경과 동일
  • 단점: 초기 학습이 불안정

4.2 Scheduled Sampling

Teacher forcing ratio를 점진적으로 줄이는 전략:

python
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 ** epoch

4.3 Exposure Bias 문제

문제 정의:

훈련 시: 디코더는 항상 정답을 봄 추론 시: 디코더는 자신의 (틀릴 수 있는) 예측을 봄

이로 인해:

  • 한 번 틀리면 연쇄적으로 틀림
  • 학습에서 보지 못한 상황에 취약

해결 방법:

  1. Scheduled Sampling
  2. Beam Search (추론 시)
  3. Sequence-level Training

5. Beam Search 디코딩

5.1 Greedy vs Beam Search

Greedy Decoding:

  • 각 스텝에서 가장 확률 높은 토큰 선택
  • 빠르지만 최적 해를 놓칠 수 있음

Beam Search:

  • 상위 kk개의 후보를 유지
  • 더 좋은 전체 시퀀스를 찾을 수 있음

5.2 Beam Search 구현

python
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

긴 시퀀스일수록 확률이 낮아지는 문제를 해결:

scorenormalized=t=1Tlogp(yty<t,x)Tα\text{score}_{normalized} = \frac{\sum_{t=1}^{T} \log p(y_t | y_{<t}, x)}{T^{\alpha}}

여기서 α[0,1]\alpha \in [0, 1]은 정규화 강도입니다.

6. 학습과 평가

6.1 손실 함수

python
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

기계 번역 품질을 측정하는 표준 메트릭:

BLEU=BPexp(n=1Nwnlogpn)\text{BLEU} = BP \cdot \exp\left(\sum_{n=1}^{N} w_n \log p_n\right)

여기서:

  • pnp_n: n-gram precision
  • BPBP: brevity penalty (짧은 번역 페널티)
  • wnw_n: 가중치 (보통 1/N1/N)
python
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 학습 곡선 모니터링

python
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.315M
Seq2Seq (GRU)21.812M
+ Bidirectional24.118M
+ Teacher Forcing Scheduling25.218M
+ Beam Search (k=5)26.718M

7.2 Ablation Study

Hidden Dimension의 영향:

Hidden DimBLEU학습 시간
12818.51x
25622.31.5x
51224.12.5x
102424.34x

결론: 512 차원이 성능/비용 trade-off에서 최적

7.3 에러 분석

흔한 실패 패턴:

  1. 긴 문장에서 성능 저하

- 원인: Context vector의 정보 병목 - 해결: Attention 메커니즘

  1. 희귀 단어 처리 실패

- 원인: 어휘 외 단어 (OOV) - 해결: Subword tokenization (BPE)

  1. 복사가 필요한 경우 (고유명사 등)

- 원인: 생성만 하고 복사 메커니즘 없음 - 해결: Copy mechanism, Pointer Networks

8. Seq2Seq의 한계와 발전

8.1 Context Vector 병목

문제: 아무리 긴 문장이라도 고정 크기 벡터로 압축

c=hnencRdc = h_n^{enc} \in \mathbb{R}^d

입력 길이 nn이 커지면 정보 손실이 심해집니다.

해결책: 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 효율적인 학습

python
# 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 추론 최적화

python
# 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의 초석입니다:

  1. 인코더-디코더 구조의 원형
  2. Teacher forcingBeam search의 필요성
  3. Context vector 병목이라는 근본적 한계

이 한계가 바로 Attention 메커니즘의 등장 배경입니다. 다음 글에서는 Bahdanau와 Luong의 Attention을 다룹니다.

References

  1. Sutskever, I., Vinyals, O., & Le, Q. V. (2014). Sequence to Sequence Learning with Neural Networks. NeurIPS 2014
  2. Cho, K., et al. (2014). Learning Phrase Representations using RNN Encoder-Decoder. EMNLP 2014
  3. Bahdanau, D., Cho, K., & Bengio, Y. (2015). Neural Machine Translation by Jointly Learning to Align and Translate. ICLR 2015
  4. 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에서 확인할 수 있습니다.