TAI BUI
Back to curriculum
BuildPython

Sequence-to-Sequence Models

Two RNNs pretending to be a translator. The bottleneck they hit is exactly why attention exists.

TIME: ~75 minutesPREREQUISITES: Phase 5 · 08 (CNNs + RNNs for Text), Phase 3 · 11 (PyTorch Intro)

Learning Objectives

  • Explain why an encoder's fixed-size context vector becomes a bottleneck as the source sequence gets longer.
  • Implement a GRU encoder-decoder pair in PyTorch, train it with teacher forcing, and decode with greedy search.
  • Identify exposure bias and name at least two techniques used to close the train/inference gap.
  • Decide when a pretrained transformer encoder-decoder is the right default and when RNN-based seq2seq is still the better choice.

The Problem

Classification maps a variable-length sequence to a single label. Translation maps a variable-length sequence to another variable-length sequence. The input and output live in different vocabularies, possibly different languages, with no guarantee of length parity.

The seq2seq architecture (Sutskever, Vinyals, Le, 2014) cracked this with a deliberately simple recipe. Two RNNs. One reads the source sentence and produces a fixed-size context vector. The other reads that vector and generates the target sentence token by token. Same code you wrote for lesson 08, glued together differently.

This is worth studying for two reasons. First, the context-vector bottleneck is the most pedagogically useful failure in NLP. It motivates everything attention and transformers are good at. Second, the training recipe (teacher forcing, scheduled sampling, beam search at inference) still applies to every modern generation system including LLMs.

Warm-up0/3
1. Imagine teaching someone to translate a sentence one word at a time. Which practice method would you expect to help them improve fastest: seeing the correct previous word before guessing the next one, or guessing the whole sentence blind and only finding out afterward how many words were wrong?Warm-up
2. An RNN reads a sentence one word at a time and updates a hidden state at each word. What does that hidden state carry after the last word?Warm-up
3. A model must pick one word out of a large vocabulary at each step. Which loss compares its predicted probability distribution with the true word?Warm-up

The Concept

Encoder. An RNN that reads the source sentence. Its final hidden state is the context vector: a fixed-size summary of the entire input. Lose nothing but the source, supposedly.

Decoder. Another RNN initialized from the context vector. At each step it takes the previously generated token as input and produces a distribution over the target vocabulary. Sample or argmax to pick the next token. Feed it back in. Repeat until an <EOS> token is produced or max length is hit.

Training: Cross-entropy loss at each decoder step, summed over the sequence. Standard backprop through time through both networks.

Teacher forcing. During training, the decoder's input at step t is the ground-truth token at position t-1, not the decoder's own previous prediction. This stabilizes training; without it, early mistakes cascade and the model never learns. At inference, you have to use the model's own predictions, so there is always a train/inference distribution gap. That gap is called exposure bias.

The bottleneck. Everything the encoder learned about the source must be squeezed into that one context vector. Long sentences lose detail. Rare words get blurred. Reordering (chat noir vs. black cat) has to be memorized, not computed.

Attention (lesson 10) fixes this by letting the decoder look at every encoder hidden state, not just the last one. That is the whole pitch.

Check: The Concept0/9
1. What is the role of the encoder in a 2014-style seq2seq model?Define
2. What does the decoder do in a seq2seq model?Define
3. In a 2014-style seq2seq model, what is the context vector?Define
4. What is exposure bias?Define
5. What is teacher forcing?Define
6. Why does fixed context-vector seq2seq accuracy fall as input length grows?Explain
7. What does attention change compared with the fixed context vector?Explain
8. Why does the decoder receive the true previous token during training instead of its own prediction?Explain
9. A bug initializes the decoder's hidden state to zeros instead of the encoder's final hidden state. What is the effect?Analyze

Build It

Step 1: an encoder

python
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 has shape [batch, seq_len, hidden_dim]: one hidden state per input position. hidden has shape [1, batch, hidden_dim], just the final step. Lesson 08 said "pool over outputs for classification." Here we keep the last hidden state as the context vector, and ignore the per-step outputs.

Step 2: a decoder

python
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

Decoder is called one step at a time. Input: a batch of single tokens and the current hidden state. Output: vocabulary logits for the next token and the updated hidden state.

Step 3: training loop with teacher forcing

python
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

Two knobs worth naming. ignore_index=0 skips loss on padding tokens. teacher_forcing_ratio is the probability of using the true token vs. the model's prediction at each step. Start at 1.0 (full teacher forcing) and anneal down to ~0.5 over training to close the exposure-bias gap.

Step 4: inference loop (greedy)

python
@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)

Greedy decoding picks the highest-probability token at every step. It can wander off: once you commit to a token, you cannot unsay it. Beam search keeps the top-k partial sequences alive and picks the highest-scoring complete one at the end. Beam width 3-5 is standard.

Step 5: the bottleneck, demonstrated

Train the model on a toy copy task: source [a, b, c, d, e], target [a, b, c, d, e]. Increase sequence length. Observe accuracy.

seq_len=5   copy accuracy: 98%
seq_len=10  copy accuracy: 91%
seq_len=20  copy accuracy: 62%
seq_len=40  copy accuracy: 23%

A single GRU hidden state cannot losslessly memorize a 40-token input. The information is there at every encoder step, but the decoder only sees the last state. Attention fixes this directly.

Check: Build It0/12
1. What does beam search do at each decoding step?Define
2. Why does the training loop pass ignore_index=0 to the cross-entropy loss?Explain
3. Why does beam search often outperform greedy decoding for generation?Explain
4. You train the lesson's GRU seq2seq and want to follow its advice on teacher_forcing_ratio. Which schedule do you use?Apply
5. On the toy copy task, which accuracy pattern does the GRU seq2seq show as sequence length grows from 5 to 40?Apply
6. The encoder receives src with batch size 8, source length 12 and hidden_dim 256. What shapes do outputs and hidden have?Apply
7. In train_batch, tgt has shape [4, 7]. How many decoder calls are made per batch, and how is the returned loss scaled?Apply
8. With teacher_forcing_ratio=1.0 in train_batch, which token does the decoder receive as input at step t=2?Apply
9. In greedy_decode with max_len=50, every sequence in the batch emits eos_id on the 8th loop iteration. How many columns does the returned tensor have?Apply
10. A target row is [5, 9, 3, 0, 0] and 0 is the padding id. With ignore_index=0, how many positions in that row contribute to the loss?Apply
11. A greedy-decoded summarizer for users keeps repeating one phrase and never finishes. Which property of greedy decoding best explains it?Analyze
12. At length 40 on the copy task, a probe shows the encoder's per-step outputs still hold every input token, yet copy accuracy is 23%. Where is the information lost?Analyze

Use It

PyTorch has nn.Transformer and nn.LSTM-based seq2seq templates. Hugging Face's transformers library ships full encoder-decoder models (BART, T5, mBART, NLLB) trained on billions of tokens.

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

Modern encoder-decoders dropped RNNs for transformers. The high-level shape (encoder, decoder, generate-token-by-token) is identical to the 2014 seq2seq paper. The mechanism inside each block is different.

When to still reach for RNN-based seq2seq

Almost never, for new projects. Specific exceptions:

  • Streaming translation where you consume input one token at a time with bounded memory.
  • On-device text generation where transformer memory cost is prohibitive.
  • Pedagogy. Understanding the encoder-decoder bottleneck is the fastest path to understanding why transformers won.

Exposure bias and its mitigations

  • Scheduled sampling. Anneal teacher forcing ratio during training so the model learns to recover from its own mistakes.
  • Minimum risk training. Train on sentence-level BLEU (a text-overlap score comparing generated output to a reference translation) instead of token-level cross-entropy. Closer to what you actually want.
  • Reinforcement learning fine-tuning. Reward the sequence generator with a metric. Used in modern LLM RLHF (reinforcement learning from human feedback).

All three still apply to transformer-based generation.

Check: Use It0/9
1. What does the BLEU score measure?Define
2. What does scheduled sampling do?Define
3. Which architectural family replaced RNN seq2seq for general generation tasks?Explain
4. In which situations should you still reach for RNN-based seq2seq instead of a transformer?Explain
5. Besides scheduled sampling, what other two exposure-bias mitigations does the lesson name?Explain
6. A model trained at teacher-forcing ratio 1.0 reaches a low training loss, yet its generations drift off on longer outputs. Which cause fits best?Analyze
7. Team A trains with teacher_forcing_ratio fixed at 1.0. Team B anneals it down to about 0.5. Which outcome should you expect?Analyze
8. You must translate a live token stream on a small device with bounded memory. Which design fits?Design
9. You want training to optimize what you evaluate, sentence-level BLEU, instead of per-token cross-entropy. Which technique does that?Design

Ship It

Save as outputs/prompt-seq2seq-design.md:

markdown
---
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:
 
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).
Check: Ship It0/3
1. The seq2seq-design prompt tells the assistant to refuse which recommendation?Explain
2. You must build a paraphrase system from about 10k sentence pairs. Which starting point fits best?Design
3. Before shipping a seq2seq model, you want one check for exposure bias. Which fits the lesson's advice?Design
Author's Note

The bug I see people hit hardest here isn't in the code. It's in trusting the training loss. A seq2seq model trained with a 0.9 teacher-forcing ratio shows a beautiful, steadily dropping loss curve right up until you run greedy_decode and watch it produce three tokens and then loop forever. That gap between training loss and generation quality is exposure bias showing up as a bug report — not a mnemonic you memorized for this lesson. Before you touch beam search or start second-guessing the architecture, anneal the teacher-forcing ratio down and rerun training. The toy copy task in Step 5 is the fastest diagnostic I know for this entire failure class, and it still works once you swap the GRU for a transformer decoder.

Exercises

  1. Easy. Implement the toy copy task. Train a GRU seq2seq on input-output pairs where the target equals the source. Measure accuracy at lengths 5, 10, 20. Reproduce the bottleneck.
  2. Medium. Add beam search decoding with beam width 3. Measure BLEU on a small parallel corpus against greedy. Document where beam search wins (usually last tokens) and where it makes no difference.
  3. Hard. Fine-tune facebook/bart-base on a 10k-pair paraphrase dataset. Compare the fine-tuned model's beam-4 output to the base model's on held-out inputs. Report BLEU and pick 10 qualitative examples.

Key Terms

TermWhat people sayWhat it actually means
EncoderInput RNNReads source. Produces per-step hidden states and a final context vector.
DecoderOutput RNNInitialized from context vector. Generates target tokens one at a time.
Context vectorThe summaryFinal encoder hidden state. Fixed size. The bottleneck attention solves.
Teacher forcingUse true tokensFeed the ground-truth previous token at training time. Stabilizes learning.
Exposure biasTrain/test gapModel trained on true tokens never practiced recovering from its own mistakes.
Beam searchBetter decodingKeep top-k partial sequences alive at each step instead of committing greedily.
BLEUTranslation scoreText-overlap metric comparing generated output against a reference translation.

Further Reading

Code

Ships With