Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 150m read

DL S034 · Seq2Seq — Two LSTMs, One Translation, One Bottleneck

Sutskever, Vinyals, Le 2014 stacked two LSTMs into an encoder-decoder and hit state-of-the-art English→French translation. Build the same architecture from scratch, feel the fixed-context bottleneck with your own eyes, and set up the pain that attention will solve next session.

🧠SoftwareM06 · RNNs, embeddings, and language modeling basics· Session 034 of 130 150 min

🎯 Build an encoder-decoder LSTM in ~120 lines, train it on a toy translation task, then measure how quality degrades as sentence length grows — reproducing the exact pain that motivated Bahdanau et al. to invent attention in September 2014.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 34 / 80 · Module M06 · Enriched pass 2026-07-27

The story


0 · September 2014 — Google Brain vs. the entire history of machine translation

Machine translation had been a slow-progress field for sixty years. IBM's alignment models in the 1990s, phrase-based statistical MT (Moses toolkit, mid-2000s), then Google's own phrase-based system in production since 2007 — each step buying maybe 1–2 BLEU points at a time. BLEU (Papineni et al. 2002) is the standard MT metric, roughly "n-gram overlap with human reference translation", scored 0–100 where 30 is decent and 40 is excellent.

Then in September 2014 Ilya Sutskever, Oriol Vinyals, and Quoc Le at Google Brain uploaded Sequence to Sequence Learning with Neural Networks to arXiv. Their idea was absurdly simple, at least in retrospect:

  1. Take one LSTM. Feed it an English sentence, one word at a time. Ignore its output at every step. Keep only the final hidden state.
  2. Take a second LSTM. Initialise its hidden state from the first LSTM's final state. Ask it to generate the French translation, one word at a time, feeding each generated word back as the next input.

That's it. No alignment model. No phrase table. No manually engineered features. Two LSTMs, glued together at the hip. They trained it on the WMT'14 English-French corpus, hit a BLEU score of 34.8, and matched a heavily engineered phrase-based statistical system that had been years in the making. When they added a re-ranking step, they exceeded it.

The paper had one weird trick: they reversed the source sentence before feeding it to the encoder. Instead of "the cat sat on the mat", the encoder saw "mat the on sat cat the". Sutskever explained in section 3.3: this makes the last few tokens the encoder sees (the first few English words) closer in the unrolled computation graph to the first few tokens the decoder produces (the first few French words). Shorter path = less gradient decay = better learning of the initial alignment. Yes, the paper that revolutionised machine translation depended in part on reversing the input as a gradient-flow hack. That's how narrow the vanishing-gradient tightrope still was in 2014.

Cho et al.'s parallel work at MILA — Learning Phrase Representations using RNN Encoder-Decoder — had introduced the same encoder-decoder pattern (and the GRU) just months earlier. Together, these two papers established seq2seq as a general recipe for mapping any sequence to any other sequence: translation, summarisation, question-answering, code generation, protein structure prediction. Every generative AI system today, from ChatGPT to Copilot to AlphaFold's language head, is a descendant of seq2seq.

But there was a rot at the heart of it. That final encoder hidden state was a single vector of maybe 1000 dimensions. It had to compress the entire meaning of an English sentence — verbs, subjects, tenses, negations, subordinate clauses, all of it — into 1000 numbers, from which the decoder had to reconstruct the French. Sutskever's paper reported that BLEU degraded sharply as sentence length increased past ~30 words. Cho's paper noted the same pattern. Something more expressive was needed to route information from the encoder to the decoder. Within twelve months, Bahdanau, Cho, Bengio would provide it: attention. That's next session.

Today, you build seq2seq. You feel the bottleneck yourself.

1 · What you'll build today

One sentence: You will implement an LSTM encoder + LSTM decoder, train it on a toy English→pig-latin (or English→number-name) translation task, generate translations with greedy and beam search, and plot the quality-vs-length curve to see the bottleneck with your own eyes.

By the end you will:

  • Explain the encoder-decoder pattern in one sentence and draw its computation graph.
  • Implement encoder LSTM, decoder LSTM, teacher forcing, greedy decoding, and beam search — all in <200 lines total.
  • Understand the <sos> / <eos> / <pad> token conventions and why they exist.
  • Measure BLEU-like metrics on your model and see the length-degradation curve reproduced.
  • Trace the direct line from seq2seq → attention → transformer → GPT.
You should already know
  • LSTM cell mechanics (S033).
  • Token embeddings, softmax + cross-entropy, autoregressive generation with `torch.multinomial` (S018, S032).
  • Padding and masking in PyTorch (`pack_padded_sequence`, attention masks) — we'll review as we go.
  • Character-level generation loop from S032 §6.3 — the same idea, scaled to word-level with an encoder.


2 · The encoder-decoder pattern in one picture

ENCODER LSTM DECODER LSTM "the cat sat" <sos> [h1][h2][h3] context vector [h1'] (= h3) "le" [h2'] "chat" [h3'] "s'est" [h4'] "assis" [h5'] <eos>

The encoder reads the source, ignoring its outputs, keeping only the final hidden state cc — the context vector. The decoder is initialised with cc and generates one token at a time until it emits <eos> (end-of-sentence).

Two subtle points:

  1. The context vector is a single fixed-size vector. In Sutskever et al. it was the concatenation of (hT,cT)(h_T, c_T) from a 4-layer LSTM with 1000 units — roughly 8000 numbers. All of the source sentence's meaning has to pass through this bottleneck. For a 10-word sentence it's fine; for a 50-word one it's a disaster.
  2. The decoder is autoregressive. At each step, the token it produced last step is fed back as input this step. During training, this is complicated by the choice between using the true previous token (teacher forcing) or the model's own prediction (scheduled sampling / free running). More on this in §5.
The analogy
🌍 Real world
💻 Code world

3 · Data — a toy translation task

Full WMT training is a weekend on a GPU cluster. For a laptop-scale build we'll use a synthetic parallel corpus: English number words → digits. Trivial to generate, easy to verify, and long enough (up to ~15 tokens for numbers like "one million two hundred and fifty three thousand four hundred and eleven") to reveal the length bottleneck.

from num2words import num2words   # pip install num2words
import random, torch
 
def make_pair(n_max=10_000):
    n = random.randint(0, n_max)
    src = num2words(n)                            # "twelve thousand three hundred and forty five"
    tgt = " ".join(list(str(n)))                  # "1 2 3 4 5"
    return src, tgt
 
random.seed(0)
pairs = [make_pair() for _ in range(20_000)]
print(pairs[0])
# ('four hundred and thirty-seven', '4 3 7')

If you don't want to install num2words, an even simpler task is reverse the input: source = "a b c d e", target = "e d c b a". This exposes the same encoder-decoder mechanics with zero data engineering.

3.1 Tokenisation and vocab

def tokenize(s):
    return s.replace('-', ' ').replace(',', '').lower().split()
 
SRC_PAD, SRC_UNK = '<pad>', '<unk>'
TGT_PAD, TGT_SOS, TGT_EOS, TGT_UNK = '<pad>', '<sos>', '<eos>', '<unk>'
 
def build_vocab(sequences, specials):
    itos = list(specials)
    seen = set(itos)
    for seq in sequences:
        for tok in seq:
            if tok not in seen:
                seen.add(tok); itos.append(tok)
    stoi = {w: i for i, w in enumerate(itos)}
    return itos, stoi
 
src_tokens = [tokenize(s) for s, _ in pairs]
tgt_tokens = [t.split()   for _, t in pairs]
 
src_itos, src_stoi = build_vocab(src_tokens, [SRC_PAD, SRC_UNK])
tgt_itos, tgt_stoi = build_vocab(tgt_tokens, [TGT_PAD, TGT_SOS, TGT_EOS, TGT_UNK])

Why the special tokens?

  • <pad> — sequences in a batch have different lengths. We pad the shorter ones so we can stack them into a rectangular tensor. Loss on <pad> positions is masked.
  • <sos> — start-of-sequence, fed as the first input to the decoder. Without it the decoder has nothing to condition on.
  • <eos> — end-of-sequence, the target's last token. The model learns to emit it to signal "I'm done". At inference time, we stop generation when <eos> is sampled.
  • <unk> — unknown token, replaces vocabulary items unseen during training.

These four conventions are universal in NMT and text generation. Same tokens exist in every modern LLM tokenizer, sometimes renamed (<|begin_of_text|>, <|eot_id|>, etc. in the Llama-3 family) but semantically identical.

3.2 Padding + batching

def encode(seq, stoi, add_eos=False, add_sos=False):
    ids = [stoi.get(t, stoi[TGT_UNK if add_sos else SRC_UNK]) for t in seq]
    if add_sos: ids = [stoi[TGT_SOS]] + ids
    if add_eos: ids = ids + [stoi[TGT_EOS]]
    return ids
 
def make_batch(pairs_batch):
    src = [encode(tokenize(s), src_stoi) for s, _ in pairs_batch]
    tgt = [encode(t.split(),  tgt_stoi, add_sos=True, add_eos=True) for _, t in pairs_batch]
    src_lens = torch.tensor([len(x) for x in src])
    tgt_lens = torch.tensor([len(x) for x in tgt])
    src_max = src_lens.max().item()
    tgt_max = tgt_lens.max().item()
    src_pad = torch.full((len(src), src_max), src_stoi[SRC_PAD], dtype=torch.long)
    tgt_pad = torch.full((len(tgt), tgt_max), tgt_stoi[TGT_PAD], dtype=torch.long)
    for i, s in enumerate(src): src_pad[i, :len(s)] = torch.tensor(s)
    for i, t in enumerate(tgt): tgt_pad[i, :len(t)] = torch.tensor(t)
    return src_pad, src_lens, tgt_pad, tgt_lens

4 · The model — encoder LSTM + decoder LSTM

import torch
import torch.nn as nn
import torch.nn.functional as F
 
class Encoder(nn.Module):
    def __init__(self, src_vocab, embed_dim=128, hidden_dim=256, n_layers=1):
        super().__init__()
        self.embed = nn.Embedding(src_vocab, embed_dim, padding_idx=0)
        self.rnn = nn.LSTM(embed_dim, hidden_dim, num_layers=n_layers, batch_first=True)
 
    def forward(self, src):
        e = self.embed(src)                     # (B, T_src, E)
        _, (h, c) = self.rnn(e)                 # (n_layers, B, H) each
        return h, c
 
class Decoder(nn.Module):
    def __init__(self, tgt_vocab, embed_dim=128, hidden_dim=256, n_layers=1):
        super().__init__()
        self.embed = nn.Embedding(tgt_vocab, embed_dim, padding_idx=0)
        self.rnn = nn.LSTM(embed_dim, hidden_dim, num_layers=n_layers, batch_first=True)
        self.head = nn.Linear(hidden_dim, tgt_vocab)
 
    def forward_step(self, y_prev, state):
        # y_prev: (B, 1)  state: (h, c)
        e = self.embed(y_prev)                  # (B, 1, E)
        out, state = self.rnn(e, state)         # (B, 1, H)
        logits = self.head(out.squeeze(1))      # (B, V)
        return logits, state
 
class Seq2Seq(nn.Module):
    def __init__(self, src_vocab, tgt_vocab, embed_dim=128, hidden_dim=256, n_layers=1):
        super().__init__()
        self.encoder = Encoder(src_vocab, embed_dim, hidden_dim, n_layers)
        self.decoder = Decoder(tgt_vocab, embed_dim, hidden_dim, n_layers)
        self.tgt_vocab = tgt_vocab
 
    def forward(self, src, tgt, teacher_forcing_ratio=1.0):
        B, T_tgt = tgt.shape
        state = self.encoder(src)                             # (h, c)
        y_prev = tgt[:, :1]                                   # <sos>
        logits_all = torch.zeros(B, T_tgt-1, self.tgt_vocab, device=src.device)
        for t in range(1, T_tgt):
            logits, state = self.decoder.forward_step(y_prev, state)
            logits_all[:, t-1] = logits
            use_teacher = torch.rand(1).item() < teacher_forcing_ratio
            y_prev = tgt[:, t:t+1] if use_teacher else logits.argmax(-1, keepdim=True)
        return logits_all

Total: ~40 lines of model code for a state-of-the-art-in-2014 translation system.

4.1 Why encoder returns (h, c) not outputs

Notice Encoder.forward throws away the per-step outputs (_) and returns only the final hidden and cell states. That is exactly Sutskever's design: the decoder is initialised from the encoder's final state, not from the sequence of encoder outputs. In S035 (attention), we'll keep the encoder outputs and let the decoder attend to all of them — that's the fix for the bottleneck.

4.2 Reverse-source hack

You can reproduce Sutskever's reversed-source trick by simply flipping the source tensor before feeding it:

src_reversed = torch.flip(src, dims=[1])

On the number-word task this is a small effect (sentences are short). On WMT English-French, Sutskever reported it improved BLEU by ~3 points — a huge margin — purely by shortening the average distance between source token kk and target token kk in the unrolled graph. A pure gradient-flow hack.


5 · Teacher forcing — the crutch that also cripples

At training time, the decoder takes as input at step tt either:

  • Teacher forcing (TF): the true target token yt1y_{t-1} from the ground-truth translation.
  • Free running (FR): the model's own predicted token from step t1t-1 (argmax or sample).

Pure teacher forcing is fast and stable: gradient flows cleanly at every step because you always condition on correct history. Pure free running is high-variance and slow: early in training, the model's own predictions are noise, so the decoder never sees meaningful conditioning and struggles to learn.

The exposure bias problem (Ranzato et al. 2016, Sequence Level Training with Recurrent Neural Networks): a model trained with pure teacher forcing has never seen its own errors during training, so at inference time, one bad prediction cascades into further errors it has no experience recovering from.

Standard practice in 2014-2018 NMT: scheduled sampling (Bengio et al. 2015, arXiv:1506.03099) — start with teacher_forcing_ratio = 1.0 and linearly decay to something like 0.5 over training. The teacher_forcing_ratio argument in the Seq2Seq.forward above implements this.

Modern LLM training essentially uses pure teacher forcing (all pretraining is next-token prediction with the true previous tokens). Exposure bias is one reason RLHF/DPO alignment is done afterward — to teach the model to recover from its own imperfect generations.


6 · Training loop

model = Seq2Seq(len(src_itos), len(tgt_itos)).cuda()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
pad_id = tgt_stoi[TGT_PAD]
 
for epoch in range(20):
    random.shuffle(pairs)
    total = 0.0; n = 0
    for i in range(0, len(pairs), 64):
        batch = pairs[i:i+64]
        src, _, tgt, _ = make_batch(batch)
        src, tgt = src.cuda(), tgt.cuda()
 
        logits = model(src, tgt, teacher_forcing_ratio=max(0.5, 1.0 - epoch*0.05))
        # target for step t is tgt[:, t] (skip <sos> at t=0)
        loss = F.cross_entropy(
            logits.reshape(-1, len(tgt_itos)),
            tgt[:, 1:].reshape(-1),
            ignore_index=pad_id
        )
        opt.zero_grad(); loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
        total += loss.item() * src.size(0); n += src.size(0)
    print(f"epoch {epoch}  loss {total/n:.3f}")

Two subtle points:

  • ignore_index=pad_id masks the loss on padded positions so the model isn't penalised for what it says about padding tokens.
  • tgt[:, 1:] — targets are the sequence shifted by 1 (skip <sos>). Standard next-token loss.

On the number-word task this converges to ~99% token accuracy in about 15 epochs (a few minutes on a laptop GPU).


7 · Inference — greedy decoding

@torch.no_grad()
def greedy_decode(model, src_ids, max_len=30):
    state = model.encoder(src_ids.unsqueeze(0))
    y_prev = torch.tensor([[tgt_stoi[TGT_SOS]]], device=src_ids.device)
    out = []
    for _ in range(max_len):
        logits, state = model.decoder.forward_step(y_prev, state)
        y_prev = logits.argmax(-1, keepdim=True)
        tok_id = y_prev.item()
        if tok_id == tgt_stoi[TGT_EOS]: break
        out.append(tgt_itos[tok_id])
    return ' '.join(out)

Simple. Take the argmax at each step, stop at <eos>. Works fine for narrow tasks; often mediocre for open-ended translation, because a locally-optimal token can lead to a globally-suboptimal sequence.


8 · Beam search — the quality lever every NMT paper reported

Instead of committing to the argmax at each step, keep the top-kk partial sequences by cumulative log probability, expand each one at the next step, and keep the top-kk again.

@torch.no_grad()
def beam_decode(model, src_ids, beam_size=5, max_len=30, alpha=0.7):
    device = src_ids.device
    state = model.encoder(src_ids.unsqueeze(0))
    # beam: list of (score, sequence, state, finished)
    beams = [(0.0, [tgt_stoi[TGT_SOS]], state, False)]
    finished = []
    for _ in range(max_len):
        new_beams = []
        for score, seq, st, fin in beams:
            if fin:
                new_beams.append((score, seq, st, True)); continue
            y_prev = torch.tensor([[seq[-1]]], device=device)
            logits, st_new = model.decoder.forward_step(y_prev, st)
            log_probs = F.log_softmax(logits.squeeze(0), dim=-1)
            top_lp, top_ids = log_probs.topk(beam_size)
            for lp, tid in zip(top_lp.tolist(), top_ids.tolist()):
                fin_new = (tid == tgt_stoi[TGT_EOS])
                new_beams.append((score + lp, seq + [tid], st_new, fin_new))
        # length normalisation to avoid short-sequence bias:
        # divide log prob by len^alpha before ranking
        new_beams.sort(key=lambda b: b[0] / ((len(b[1])) ** alpha), reverse=True)
        beams = new_beams[:beam_size]
        if all(b[3] for b in beams): break
    best = max(beams, key=lambda b: b[0] / (len(b[1]) ** alpha))
    return ' '.join(tgt_itos[i] for i in best[1] if i not in
                    (tgt_stoi[TGT_SOS], tgt_stoi[TGT_EOS], tgt_stoi[TGT_PAD]))

Key implementation details every beam search codebase gets wrong at least once:

  • Length normalisation. Longer sequences have lower total log probability just because more terms are summed. Without normalisation, beam search always prefers the shortest completion — including immediate <eos>. Google's NMT paper (Wu et al. 2016) proposed dividing by lengthα\text{length}^\alpha with α[0.6,0.8]\alpha \in [0.6, 0.8]. Empirically α=0.7\alpha = 0.7 works well.
  • Handle finished beams. Once a beam produces <eos>, it's done — don't expand it further. Keep it in the beam pool with its final score for comparison.
  • beam_size = 5 is a standard sweet spot. Larger beams sometimes hurt quality (Cohen & Beck 2019, Empirical Analysis of Beam Search Performance Degradation) — a counterintuitive result caused by exposure bias amplifying at larger search widths.

Beam search typically buys 1–2 BLEU points over greedy on real NMT. On the number-word task the improvement is tiny because the task is nearly deterministic; but implement it once so you never have to again.


9 · Measuring the bottleneck — quality vs length

Now the payoff: reproduce the plot from Sutskever et al. that shows BLEU dropping with source length.

from collections import defaultdict
 
def token_accuracy(pred, gold):
    ptoks, gtoks = pred.split(), gold.split()
    L = min(len(ptoks), len(gtoks))
    if L == 0: return 0.0
    return sum(p == g for p, g in zip(ptoks[:L], gtoks[:L])) / max(len(gtoks), 1)
 
bins = defaultdict(list)
for src, tgt in pairs[:2000]:
    src_ids = torch.tensor(encode(tokenize(src), src_stoi)).cuda()
    pred = greedy_decode(model, src_ids)
    src_len = len(tokenize(src))
    bins[src_len].append(token_accuracy(pred, tgt))
 
for L in sorted(bins):
    scores = bins[L]
    print(f"src_len={L:2d}  n={len(scores):4d}  acc={sum(scores)/len(scores):.3f}")

Typical output:

src_len= 1  n= 210  acc=1.000
src_len= 2  n= 384  acc=0.998
src_len= 3  n= 267  acc=0.995
src_len= 4  n= 189  acc=0.982
src_len= 5  n= 178  acc=0.951
src_len= 6  n= 155  acc=0.897
src_len= 7  n= 143  acc=0.812
src_len= 8  n= 124  acc=0.723
src_len= 9  n= 108  acc=0.611
src_len=10  n=  93  acc=0.503
src_len=11+ n= 149  acc=0.312

There it is. Perfect on short inputs, deteriorating steadily, catastrophic beyond ~10 tokens. This is the exact curve Sutskever showed in Figure 3 of the 2014 paper, and Cho showed in Figure 2 of the parallel paper. On a translation task where the source is a novel excerpt or a long chat turn, the model loses information halfway through the sentence because that single context vector cannot hold it all.

Every attention mechanism, every transformer, every LLM you've ever heard of exists because of this curve.


10 · What was the SOTA before, and after

YearSystemWMT'14 En-Fr BLEUNotes
2013Moses (phrase-based SMT)~33.3Years of feature engineering.
2014Sutskever seq2seq (LSTM 4-layer)34.8One paper.
2014Sutskever + reranking36.5+ reversed source.
2015Bahdanau attention37.5Global attention.
2016GNMT (Google prod)38.98-layer LSTM + attention + WPM.
2017Transformer (Vaswani et al.)41.0No recurrence.
2024NLLB / Google's MADLAD45+Frontier multilingual models.

Each row on this table represents one paradigm shift. Rows 2 through 4 all happen in a 12-month window in 2014-2015. That is how fast the field moved once seq2seq unlocked the encoder-decoder abstraction.


11 · Beyond translation — what seq2seq unlocked

Once "map any sequence to any sequence" was a solved recipe, the same architecture found dozens of applications:

  • Abstractive summarization — Rush, Chopra, Weston 2015 (arXiv:1509.00685). Encoder reads article, decoder generates headline.
  • Conversational agents — Vinyals & Le 2015 (arXiv:1506.05869) A Neural Conversational Model. Trained on IT helpdesk logs and movie subtitles; produced eerie almost-coherent dialogue.
  • Code generation — encoder reads natural language description, decoder emits code. This lineage runs directly through to GitHub Copilot and modern code LLMs.
  • Protein structure prediction — AlphaFold 2's evoformer is spiritually a seq2seq that maps multiple-sequence-alignments to atom coordinates (Jumper et al. 2021).
  • Speech recognition + synthesis — Listen-Attend-Spell (Chan et al. 2016) for ASR; Tacotron 1/2 (Wang et al. 2017) for TTS. Encoder-decoder architectures, both.
  • Vision-language — Show-and-Tell image captioning (Vinyals et al. 2015): encoder is a CNN over the image, decoder is an LSTM emitting words. Progenitor of every modern VLM.

Understanding seq2seq is understanding the abstraction that made "sequence-to-anything" a solved problem-shape.


12 · War stories

War story Forgetting to shift targets by one during teacher forcing

Painful bug: I trained a seq2seq for 30 epochs where the target sequence was NOT shifted — the decoder was being asked to predict token tt from an input that already included token tt. Loss went to essentially 0 (the model learned to copy input to output). BLEU on held-out data was 0.0. Fix: tgt_input = tgt[:, :-1]; tgt_target = tgt[:, 1:]. This is the same "shift by one" that autoregressive LMs use. Once you internalise it once, you never forget.

War story Padding leaking into the encoder's final state

If you naïvely nn.LSTM(src) where src has padding at the right, the LSTM keeps processing the padding tokens (embedding of <pad> is not zero — it's a random 128-dim vector after init!), and the "final state" is actually the state after chewing on 20 padding tokens. Fix: use pack_padded_sequence(..., enforce_sorted=False) to skip padding, or set padding_idx=0 in nn.Embedding to guarantee the pad vector stays zero (still eats compute but at least doesn't corrupt). Or, cleanest, extract the final state at the correct index using src_lens.

War story Beam search returns identical outputs across the beam

On a poorly-trained model, all beams often collapse to the same sequence — the top-k next-token candidates at each step are variations of the same word (the, a, an, some, any), so beam expansion produces near-duplicates. Diverse beam search (Vijayakumar et al. 2016) adds a diversity penalty. For most production NMT it's not worth the complexity; for open-ended generation (dialogue, poetry) it matters a lot.

War story LSTM state has TWO tensors and you passed only one

nn.LSTM returns (output, (h_n, c_n)). nn.GRU returns (output, h_n). When I refactored a codebase from GRU to LSTM, I forgot the state was now a tuple and passed only h_n back into the decoder. No error — the second call happily initialised c_n to zeros. Loss trained fine, translations were garbage. The type checker doesn't help because state is duck-typed. Write explicit (h, c) = state unpacking in your decoder step.


13 · Try it yourself

Try it
  1. Task: reverse a random string of digits, e.g. src 1 2 3 4 5, tgt 5 4 3 2 1. Generate 20k training examples with lengths uniformly in [3, 12].
  2. Build the encoder-decoder from §4, train for 15 epochs. You should get ~99% exact-sequence accuracy on lengths in [3, 12].
  3. Now evaluate on lengths 20, 30, 50 (extrapolation). Expect quality to fall off a cliff — the model has never seen a context vector encoding a 50-token sequence.
  4. Try Sutskever's reverse-source trick (flip the input) and see if it changes anything on this task. (Hint: not much — the task is symmetric.)
  5. Stretch: implement beam search with beam_size=5 and length normalisation with alpha=0.7. Compare per-example accuracy against greedy. Is the improvement worth the 5× inference cost?

14 · Recap in five sentences

Seq2seq (Sutskever et al. 2014, Cho et al. 2014) is the encoder-decoder pattern: two RNNs glued at the hip, where the encoder compresses the source sequence into a single fixed-size context vector and the decoder autoregressively generates the target from it. That single fixed-size vector is a bottleneck: quality degrades sharply as source length grows, as you can measure yourself in ~30 lines. Training uses teacher forcing (feeding true previous tokens to the decoder), inference uses greedy or beam search with length normalisation. The architecture is a general-purpose recipe that unlocked machine translation, summarisation, dialogue, code generation, protein prediction, and image captioning — all in the two years after 2014. Attention (next session) fixes the bottleneck by letting the decoder look at every encoder output; the transformer takes this further by removing recurrence entirely.

Seq2seq — what actually stuck

    Common misconception
    ✗ What most people think

    "Seq2seq fails on long sentences because the LSTM forgets the beginning. Fix the forgetting — bigger hidden state, better gates, more layers — and the bottleneck goes away."

    ✓ What is actually true

    Forgetting is a symptom; the bottleneck is dimensional. The encoder must compress a sequence of arbitrary length into one fixed vector of size d. That vector holds d numbers whatever the input length, so the budget available per source token shrinks as the source grows. A cell with perfect infinite memory could not escape it, because the problem is not that information decayed — it is that there was nowhere to put it. That is why enlarging the hidden state buys a shallow, quickly-saturating improvement, while attention, which deletes the fixed-size constraint by letting the decoder read all encoder states, changes the shape of the curve rather than its offset.

    Why the myth is so sticky

    Because the forgetting story is exactly right for the vanilla RNN you met one session earlier, and it is partly right here too — long inputs really do stress memory. Every intervention you try nudges the metric in the right direction, so the hypothesis is never falsified, merely underwhelming, and an underwhelming confirmation still feels like a confirmation. It also survives because the failure mode looks like forgetting from the outside: the model handles the end of a long sentence and mangles the start, precisely what decay would produce. The tell is what happens when you reverse the source. Pure memory decay would just move the damage to the other end, leaving quality flat. It does not — reversal improves overall quality, which only makes sense if what limits you is how much survives the compression, not which end decayed.

    Prove it to yourself

    Hold the memory mechanism constant and vary only the channel width, then bucket the metric by source length:

    # Same architecture, same data, same steps -- only d changes.
    for d in (64, 128, 256, 512, 1024):
        model = Seq2Seq(hidden=d)
        train(model, steps=N)
        for bucket in ((1,5), (6,10), (11,20), (21,40)):
            print(d, bucket, score(model, subset(test, bucket)))
    
    # The shape to look for:
    #   short buckets -> nearly flat in d: the vector was never the limit there
    #   long buckets  -> improves with d, with clearly diminishing returns
    #   the gap between short and long buckets narrows but does not close

    A memory problem closes the gap once memory is sufficient. A capacity problem keeps a residual gap at every width — and that is what you will see.

    From first principles
    Start with the question

    Why did Sutskever et al. reverse the words of the source sentence? A permutation adds no information, so a real quality gain from it means something non-obvious is going on.

    1. 1
      In the forward direction, source token s_1 is read first and its aligned target token t_1 is emitted first — but the encoder consumes the entire source before the decoder emits anything, so the computational path between them is roughly n steps long.
      forced by · the fixed context vector sits between encoder and decoder, and every path must route through it
    2. 2
      The same is true for every aligned pair: averaged over the sentence each is about n steps apart, and crucially no pair is close.
      forced by · with a strictly sequential encoder, the distance from source position i to target position i is n - i + i, which is n regardless of i
    3. 3
      Reversing the source makes s_1 the last thing the encoder reads, so it sits adjacent to the context vector and only a couple of steps from t_1. Early pairs become very short; late pairs become correspondingly longer. The sum of distances is unchanged.
      forced by · reversal is a permutation, so it redistributes distances without altering their total
    4. 4
      But the effect of distance on learning is not linear — the gradient across k steps is a product of k Jacobians, so it decays geometrically. A few very short paths carry usable signal; many medium ones carry essentially none.
      forced by · under geometric decay the total usable signal is dominated by the smallest exponents, so redistribution changes the total even at fixed mean
    5. 5
      So reversal converts a flat distribution of uniformly unusable distances into a skewed one containing several usable ones. Training gets a foothold on the early alignments, and in an autoregressive decoder a correct early token conditions everything after it.
      forced by · each decoder step is conditioned on its predecessors, so early correctness has outsized downstream value
    ⇒ Therefore

    Therefore reversal is not about memory or information at all — it is a curriculum imposed by geometry, buying short gradient paths for exactly the tokens whose correctness propagates furthest.

    And note the two predictions this makes, both checkable. First, reversal should help most on long sentences and be irrelevant on short ones, since geometric decay is negligible over a handful of steps. Second and more decisively: reversal should become entirely pointless once attention exists, because attention gives every source-target pair a direct connection of constant length, leaving no long paths to redistribute. That is precisely why you will not find source reversal anywhere in a transformer codebase, despite it being a headline trick in 2014.

    Mental modelThe funnel and the straw

    Picture the encoder as a funnel: n tokens go in the wide end, and everything must squeeze through a straw of fixed diameter d — the final hidden state. The decoder stands on the far side and reconstructs the whole meaning from whatever came through, one token at a time, each conditioned on what it already said.

    The straw does not widen when the sentence gets longer. That one sentence is the architecture and its failure mode. Everything after seq2seq — attention, then the transformer — is a single idea: stop making the decoder drink through a straw, and let it reach back into the funnel directly.

    • Encoder: consume everything, emit one vector. Decoder: a conditional language model whose initial state is that vector. Two parameter sets, one handoff.
    • Quality degrades with source length because the per-token budget through the straw shrinks as the source grows. Widening d shifts the curve; it does not change its shape.
    • Teacher forcing feeds ground-truth previous tokens in training but not at inference, so the two regimes see different input distributions. One bad sampled token pushes the state off-manifold and the errors compound — exposure bias.
    • Beam search attacks search error, never model error. If the model scores the wrong sequence higher, a wider beam just finds that wrong sequence more reliably.
    🔔 Fires when you see

    Fire this model the moment you see: an encoder-decoder handing off through a single pooled vector · a metric that falls off a cliff past some input length · sentence embeddings treated as a lossless representation · a start-of-sequence token and a decoder loop · beam width being proposed as a quality fix · exposure bias, scheduled sampling, or "it degenerates after a few tokens".

    The tradeoff

    Your seq2seq model is trained. How do you turn its per-step distributions into an output sequence?

    Greedy decoding
    + you gain one forward pass per output token, no extra memory, trivially batchable, lowest achievable latency for a given model; deterministic, therefore reproducible and cacheable
    − you pay commits irrevocably at every step, so a locally attractive token can foreclose the globally best sequence, and there is no mechanism to recover — the damage compounds because every later token is conditioned on the mistake
    pick when latency is the binding constraint, or the output distribution is sharp — structured extraction and classification-shaped generation, where the top token carries almost all the mass and a beam would return the same string
    Beam search (width k)
    + you gain keeps k hypotheses alive, so a token that looks poor now but unlocks a much better continuation can survive; reliably improves reference-matching metrics on translation-shaped tasks
    − you pay multiplies compute and cache memory by k; systematically favours short outputs unless you add length normalisation, since each extra token multiplies in another factor below 1; and it yields low-diversity, notably bland text as the beams collapse onto near-identical prefixes
    pick when there is one correct answer and the metric rewards matching it — translation, transcription, summarisation against a reference — and you can afford roughly k times the inference cost
    Sampling (temperature / top-k / nucleus)
    + you gain diversity, which is the entire point for open-ended generation; and it stays on the model's own distribution instead of hunting its extreme mode, avoiding the degenerate repetition that likelihood search falls into
    − you pay non-deterministic, so evaluation needs multiple samples and bugs are harder to reproduce; and any sampled token can derail the sequence with no search to recover
    pick when many outputs are acceptable — dialogue, story continuation, offering several code completions — or you are generating candidates to be ranked by a separate scorer
    What a senior engineer actually does

    Match the decoder to the shape of the target distribution rather than to a general belief that one is better. One right answer ⇒ search. Many right answers ⇒ sample. The classic failure is applying beam search to open-ended generation and being surprised the output is repetitive and lifeless — that is beam search working correctly, finding the highest-likelihood string, which for an open-ended model is a bland one.

    Before widening the beam, run the diagnostic that separates the two error sources: score the reference sequence under your model and compare it against the score of your greedy output. If the reference scores higher, your decoder failed to find what the model already knew, and a beam will help. If it scores lower, the model is wrong and no amount of search will rescue you — go fix the model.


    🧠 Retention scaffold

    Quick recall · click to reveal
    ★ = stretch question

    One-line summary (write it in your own words): ________________________________________________

    Spaced review: re-read §2 (the picture) and §9 (the length-degradation curve) in 24 hours. Revisit the full session on day 7.

    Next session (S035): we introduce attention — the mechanism that lets the decoder peek at every encoder output at every decoding step, not just the final one. This kills the bottleneck, buys 3+ BLEU points on WMT En-Fr, and prepares you for the transformer in Module M07.

    Sticky note (keep on your desk): Encoder squishes → context vector → decoder expands. That squish is a bottleneck. Everything after seq2seq is a way to widen the pipe.


    Further reading


    Previous: ← DL S033 · Next: DL S035 →