11  Encoder–Decoder, Teacher Forcing, Beam Search

Chapter 10 ended with a promise: if one recurrent cell can read a sequence into a state, two cells can translate: one reads, one writes. This chapter builds that machine and the craft around it: how to train it fast (teacher forcing), what poisonous bookkeeping to avoid (padding), and how to get answers back out of it (greedy versus beam search). It ends where Part III must: staring at the fixed-size recurrent state in the middle.

The lecture frames the stakes well. Every model so far has lived on a fixed-size contract: 784 pixels in, 10 logits out; \(28 \times 28\) garments in, one verdict out. The interesting world breaks that contract on both ends at once: translation maps a sentence of any length to a sentence of some other length, in a different vocabulary. The recurrent cell broke the contract on the input side; today we break it on both.

11.1 Two RNNs and a handoff

The architecture is one idea long. An encoder RNN reads the source sequence and keeps nothing but its final state, the entire input compressed into fixed-size memory, the context. A decoder RNN starts from that state and generates the output token by token, feeding each token back in, exactly like Chapter 10’s character model. A language model, in other words, but one with something to say.

Our laboratory task keeps the core mechanics of translation and none of the downloads: date normalization. The source language is human-written dates in several dialects; the target language is ISO 8601:

march 5, 2021        -> 2021-03-05
26 september 2024    -> 2024-09-26
12/15/1950           -> 1950-12-15

Variable-length input, a different output alphabet, and real long-range structure (the year arrives last in most sources but must be emitted first). The target is deliberately fixed at ten characters, so this laboratory does not test variable-length target padding. It does include a dialect ambiguity that gives beam search something genuine to reveal: numeric dates are written mm/dd/yyyy by 70% of our imaginary writers (the US convention) and dd/mm/yyyy by 30%, and nothing in 03/05/2021 says which. Machine translation in miniature.

Code: the two date languages, generated on the fly
import random, torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib.pyplot as plt

MONTHS = ["january", "february", "march", "april", "may", "june", "july",
          "august", "september", "october", "november", "december"]
DAYS = dict(zip(MONTHS, [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]))

def make_date(rng: random.Random) -> tuple[str, str]:
    mo = rng.randrange(12)
    d = rng.randrange(1, DAYS[MONTHS[mo]] + 1)
    y = rng.randrange(1950, 2026)
    iso = f"{y:04d}-{mo+1:02d}-{d:02d}"
    style = rng.random()
    if style < 0.35:
        src = f"{MONTHS[mo]} {d}, {y}"
    elif style < 0.6:
        src = f"{d} {MONTHS[mo]} {y}"
    elif rng.random() < 0.7:
        src = f"{mo+1:02d}/{d:02d}/{y}"            # US convention: mm/dd
    else:
        src = f"{d:02d}/{mo+1:02d}/{y}"            # EU convention: dd/mm
    return src, iso

rng = random.Random(6050)
pairs, seen_sources = [], set()
while len(pairs) < 9000:
    pair = make_date(rng)
    if pair[0] not in seen_sources:
        seen_sources.add(pair[0])
        pairs.append(pair)
train_pairs = pairs[:8000]
valid_pairs = pairs[8000:8500]
test_pairs = pairs[8500:]
print(*pairs[:4], sep="\n")
train_sources = {s for s, _ in train_pairs}
valid_sources = {s for s, _ in valid_pairs}
test_sources = {s for s, _ in test_pairs}
print("exact-source overlaps: "
      f"train/valid {len(train_sources & valid_sources)}, "
      f"train/test {len(train_sources & test_sources)}, "
      f"valid/test {len(valid_sources & test_sources)}")

PAD, BOS, EOS = 0, 1, 2                             # special tokens first
SRC = sorted(set("".join(s for s, _ in pairs)))
TGT = sorted(set("".join(t for _, t in pairs)))
src_stoi = {c: i + 3 for i, c in enumerate(SRC)}
tgt_stoi = {c: i + 3 for i, c in enumerate(TGT)}
tgt_itos = {i: c for c, i in tgt_stoi.items()}
V_src, V_tgt = len(SRC) + 3, len(TGT) + 3
print(f"source vocab {V_src}, target vocab {V_tgt}")
('26 september 2024', '2024-09-26')
('april 7, 1962', '1962-04-07')
('12/15/1950', '1950-12-15')
('14 february 1997', '1997-02-14')
exact-source overlaps: train/valid 0, train/test 0, valid/test 0
source vocab 37, target vocab 14

The rejection loop is evaluation hygiene, not decoration. It removes duplicate source strings before the 8,000/500/500 train/validation/test split. We use validation sources for every learning curve and reserve test sources for one final audit after the design is fixed. The same underlying date may appear in another written dialect, which is the structural generalization we want; exact-string memorization is no longer available.

Three special tokens run the show, the same conventions as the course’s full translation pipeline: <pad> fills the short sequences of a batch out to a rectangle, <bos> tells the decoder “begin,” and <eos> lets the model say “I’m done” — remember, the machine must be free to choose its output length.

One more new tool, and it earns a pause. Chapter 10 fed characters in as one-hot vectors. This chapter gives each token a learned vector instead: nn.Embedding, a table with one trainable row per vocabulary entry. There is no new math — an embedding is exactly a one-hot vector times a weight matrix, Chapter 1’s linear map wearing a lookup table’s clothes — but the reading matters: the address is hard, the content is learned. Row 17 is fetched by index, rigidly; what lives in row 17 is whatever training decides that token should mean. Keep the phrase “hard address, learned content” nearby: in Chapter 13 we soften the address too, and that will be the whole story.

Code: encoder, decoder, and the handoff
class Seq2Seq(nn.Module):
    def __init__(self, v_src: int, v_tgt: int, embed: int = 32,
                 hidden: int = 128, packed: bool = True):
        super().__init__()
        self.packed = packed
        self.src_emb = nn.Embedding(v_src, embed, padding_idx=PAD)
        self.tgt_emb = nn.Embedding(v_tgt, embed, padding_idx=PAD)
        self.encoder = nn.LSTM(embed, hidden, batch_first=True)
        self.decoder = nn.LSTM(embed, hidden, batch_first=True)
        self.out = nn.Linear(hidden, v_tgt)

    def encode(
        self, S: torch.Tensor, lengths: torch.Tensor,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        e = self.src_emb(S)                          # (B, T_src, embed)
        if self.packed:                              # stop at each true length
            e = nn.utils.rnn.pack_padded_sequence(
                e, lengths, batch_first=True, enforce_sorted=False)
        _, state = self.encoder(e)
        return state                                 # (h, c): the context

    def forward(self, S: torch.Tensor, lengths: torch.Tensor,
                T_in: torch.Tensor) -> torch.Tensor:  # teacher-forced pass
        o, _ = self.decoder(self.tgt_emb(T_in), self.encode(S, lengths))
        return self.out(o)                           # (B, T_tgt, V_tgt) logits

The whole architecture is that encode return value: the LSTM’s final \((\vect{h}, \vect{c})\), handed to the decoder as its initial state. With hidden width 128, that bridge carries two 128-dimensional vectors, or 256 scalars. Everything the decoder will ever know about the source crosses it.

Code: draw the packed encoder, fixed-state bridge, and decoder
from matplotlib.patches import FancyBboxPatch

fig, ax = plt.subplots(figsize=(7.6, 3.1))
ax.set_xlim(0, 12); ax.set_ylim(0, 5); ax.axis("off")

def token_box(x: float, y: float, label: str, color: str,
              alpha: float = 1.0) -> None:
    patch = FancyBboxPatch((x - 0.42, y - 0.35), 0.84, 0.7,
                           boxstyle="round,pad=0.04", facecolor=color,
                           edgecolor="#232D4B", linewidth=1.2, alpha=alpha)
    ax.add_patch(patch)
    ax.text(x, y, label, ha="center", va="center", fontsize=9, alpha=alpha)

source_labels = ["m", "a", "r", "5", "<pad>", "<pad>"]
for j, label in enumerate(source_labels):
    faded = label == "<pad>"
    token_box(0.75 + 1.05 * j, 3.15, label,
              "#F4F4EF" if faded else "#E8F0F8", 0.4 if faded else 1.0)
    if j < 3:
        ax.annotate("", (1.25 + 1.05 * j, 3.15), (1.05 + 1.05 * j, 3.15),
                    arrowprops=dict(arrowstyle="->", color="#5379AA", lw=1.2))
ax.annotate("", (6.55, 3.15), (4.32, 3.15),
            arrowprops=dict(arrowstyle="->", color="#E57200", lw=2,
                            connectionstyle="arc3,rad=-0.45"))
ax.text(5.4, 4.25, "packing bypasses padding", color="#B45309",
        ha="center", fontsize=9)

token_box(7.0, 3.15, "$(h,c)$", "#F9DCBF")
ax.text(7.0, 3.9, "256 scalars", color="#B45309", ha="center", fontsize=9)

for j, label in enumerate(["<bos>", "2", "0", "2", "1", "…"]):
    token_box(7.9 + 0.72 * j, 1.35, label, "#F4F4EF")
    if j < 5:
        ax.annotate("", (8.25 + 0.72 * j, 1.35), (8.15 + 0.72 * j, 1.35),
                    arrowprops=dict(arrowstyle="->", color="#232D4B", lw=1.0))
ax.annotate("", (7.9, 1.72), (7.15, 2.8),
            arrowprops=dict(arrowstyle="->", color="#E57200", lw=1.8))
ax.text(2.7, 2.25, "encoder", ha="center", color="#232D4B", fontsize=10)
ax.text(9.7, 0.55, "decoder", ha="center", color="#232D4B", fontsize=10)
plt.tight_layout(); plt.show()
Figure 11.1: The encoder–decoder handoff. Packing stops each encoder at its last real source token, rather than marching through right-padding. Only the final \((h,c)\) pair crosses the bridge to initialize the decoder; the earlier encoder states are discarded. Part IV will keep those states and let the decoder look back.

11.2 The padding trap

Before the machinery works, an honest detour through the way it fails, because the seed notes devote a full section to this and experience says everyone hits it once. Batching variable-length sequences means padding them to a rectangle, and padding poisons two places:

  1. The loss. Positions holding <pad> are not predictions to be graded. F.cross_entropy(..., ignore_index=PAD) excludes them. Our ISO targets all have the same length, so this branch is dormant here, but it makes the loop correct for genuinely variable-length targets.
  2. The encoder. Subtler and far worse: an LSTM does not know your zeros are padding. It keeps stepping through them, and every pad step smears the final state, the context pair, for every sequence shorter than the batch’s longest. Worse still, at inference you feed single sequences with no padding, so the model is tested in a regime it never trained in.

Watch what that costs. Same model, same data, same budget; the only difference is whether the encoder is told the true lengths (pack_padded_sequence, which runs the recurrence only through each sequence’s real tokens):

Code: train twice — naive right-padding vs. packed sequences
def batchify(prs: list[tuple[str, str]], idx: list[int]) -> tuple[
    torch.Tensor, torch.Tensor, torch.Tensor
]:
    srcs = [torch.tensor([src_stoi[c] for c in prs[i][0]]) for i in idx]
    lengths = torch.tensor([len(s) for s in srcs])
    tgts = [torch.tensor([BOS] + [tgt_stoi[c] for c in prs[i][1]] + [EOS])
            for i in idx]
    S = nn.utils.rnn.pad_sequence(srcs, batch_first=True, padding_value=PAD)
    T = nn.utils.rnn.pad_sequence(tgts, batch_first=True, padding_value=PAD)
    return S, lengths, T

def train_seq2seq(mode: str = "tf", packed: bool = True, epochs: int = 25,
                  seed: int = 6050, batch: int = 128,
                  checkpoints: tuple[int, ...] = ()) -> tuple[
                      Seq2Seq, dict[int, float]
                  ]:
    torch.manual_seed(seed)
    model = Seq2Seq(V_src, V_tgt, packed=packed)
    opt = torch.optim.Adam(model.parameters(), lr=1e-3)
    n, curve = len(train_pairs), {}
    for ep in range(1, epochs + 1):
        model.train()
        perm = torch.randperm(n)
        for i in range(0, n, batch):
            S, L, T = batchify(train_pairs, perm[i:i + batch].tolist())
            if mode == "tf":                         # gold rail: one fused call
                logits = model(S, L, T[:, :-1])
            else:                                    # free-running: its own rail
                state = model.encode(S, L)
                tok, outs = T[:, :1], []
                for t in range(T.shape[1] - 1):
                    o, state = model.decoder(model.tgt_emb(tok), state)
                    logit = model.out(o)
                    outs.append(logit)
                    tok = logit.argmax(-1)           # feed its own prediction
                logits = torch.cat(outs, 1)
            loss = F.cross_entropy(logits.reshape(-1, V_tgt),
                                   T[:, 1:].reshape(-1), ignore_index=PAD)
            opt.zero_grad(); loss.backward()
            nn.utils.clip_grad_norm_(model.parameters(), 5.0)
            opt.step()
        if ep in checkpoints:
            curve[ep] = exact_match(model, valid_unamb[:400])
    return model, curve

@torch.no_grad()
def translate(model: Seq2Seq, src: str, maxlen: int = 12) -> str:
    model.eval()
    S = torch.tensor([[src_stoi[c] for c in src]])
    state = model.encode(S, torch.tensor([len(src)]))
    tok, out = torch.tensor([[BOS]]), ""
    for _ in range(maxlen):
        o, state = model.decoder(model.tgt_emb(tok), state)
        tok = model.out(o).argmax(-1)                # greedy: best next char
        if tok.item() == EOS:
            break
        out += tgt_itos.get(tok.item(), "?")
    return out

@torch.no_grad()
def exact_match(model: Seq2Seq, prs: list[tuple[str, str]]) -> float:
    return sum(translate(model, s) == t for s, t in prs) / len(prs)

def unambiguous(prs: list[tuple[str, str]]) -> list[tuple[str, str]]:
    return [(s, t) for s, t in prs
            if "/" not in s or int(s[:2]) > 12 or int(s[3:5]) > 12]

valid_unamb = unambiguous(valid_pairs)
test_unamb = unambiguous(test_pairs)

cps = (2, 4, 6, 8, 12, 16, 20, 25)
naive, _ = train_seq2seq(packed=False)
packed, packed_curve = train_seq2seq(packed=True, checkpoints=cps)
print(f"naive right-padding:   validation exact match "
      f"{exact_match(naive, valid_unamb):.1%}")
print(f"packed (true lengths): validation exact match "
      f"{exact_match(packed, valid_unamb):.1%}")
naive right-padding:   validation exact match 37.3%
packed (true lengths): validation exact match 95.7%

Validation exact match jumps from 37.3% to 95.7%, a 58.4-point gap. Not from a better architecture, optimizer, or dataset; from telling the encoder where the sentences actually end. Let the gap sink in, because this is the cheapest lesson in the book: the two runs are identical except for bookkeeping, and the bookkeeping was worth more than every design decision in Part III so far. The sealed test set remains untouched until both training-mode designs below are fixed.

WarningThe model will happily read your padding

Nothing in the tensor marks <pad> as meaningless; meaning is something you enforce, in two places: ignore_index for the loss, pack_padded_sequence (or an explicit mask) for the encoder. When a sequence model underperforms mysteriously, audit the padding before touching the architecture. And file the general shape of this tool away: telling a model which positions it may not look at is called masking, and it returns center-stage in Chapter 14, where a causal mask is what turns a transformer into a language model.

11.3 Teacher forcing: training on the gold rail

Look back at the two mode branches in the training loop; they are this section. When the decoder is trained, what should it see as its own previous output?

Free-running (“fr”) is the honest answer: feed the model’s own predictions back in, exactly as at inference. But its flaws are structural. Early in training those predictions are garbage \(\rightarrow\) the decoder learns conditioned on garbage \(\rightarrow\) every sequence must be generated token-by-token in a Python loop, because step \(t\)’s input does not exist until step \(t-1\) is computed. Slow, and unstable exactly when training is young.

Teacher forcing (“tf”) conditions each step on the ground-truth previous token instead, the gold rail. Every conditioning input is then known in advance \(\rightarrow\) the whole target can be submitted in one fused decoder call (the model(S, L, T[:, :-1]) line), rather than a Python feedback loop. The LSTM still processes time recurrently inside that call; teacher forcing does not make its timesteps mathematically parallel. Every step nevertheless learns from a sensible context from epoch one. Watch both effects:

Code: teacher forcing vs. free-running, learning curves
tf_model, tf_curve = packed, packed_curve
fr_model, fr_curve = train_seq2seq("fr", checkpoints=cps)
print("one final test audit (unambiguous sources):")
for label, candidate in [("naive padding", naive),
                         ("packed / teacher forced", tf_model),
                         ("packed / free-running", fr_model)]:
    print(f"  {label:25s} {exact_match(candidate, test_unamb):.1%}")

plt.figure(figsize=(6, 3.2))
plt.plot(cps, [tf_curve[c] for c in cps], "o-", color="#232D4B", lw=2,
         label="teacher forcing")
plt.plot(cps, [fr_curve[c] for c in cps], "s-", color="#E57200", lw=2,
         label="free-running")
plt.xlabel("epoch"); plt.ylabel("exact match")
plt.legend(); plt.tight_layout(); plt.show()
one final test audit (unambiguous sources):
  naive padding             34.3%
  packed / teacher forced   93.1%
  packed / free-running     99.1%
Figure 11.2: Learning curves on a fixed subset of 400 unambiguous validation source strings, disjoint from both training and test. Teacher forcing leads through the early and middle epochs (53.8% vs. 23.8% at epoch 6; 95.0% vs. 81.2% at epoch 12) and uses one fused decoder call per batch where free-running needs a Python token loop. By epoch 20 the validation curves meet; the single final test audit favors free-running.

So teacher forcing is the pragmatic default: fused, stable, and fast out of the gate. What it costs has a name. A teacher-forced decoder has only ever seen perfect histories. At inference it must condition on its own imperfect predictions — a distribution it never trained on. This mismatch is exposure bias: the model never learned to recover from its own mistakes, so one early slip can send the rest of the sequence somewhere strange. You can see the shape of this failure in our model’s rare residual errors:

The errors are plausible-looking digit substitutions and order slips. The decoder has learned a date-shaped language, but no hard mechanism forces each output field to copy the corresponding source fact. Off the gold rail, one wrong character can alter the context for every character after it. Hold that thought two sections.

NoteScheduled sampling: renting the rail

The engineered compromise (Bengio et al., 2015): during training, at each timestep flip a biased coin — probability \(\epsilon\) of the gold token, \(1 - \epsilon\) of the model’s own prediction — and anneal \(\epsilon\) from 1 toward 0 as training matures. Early epochs enjoy the gold rail; late epochs practice recovery. One design detail from the original paper worth repeating: flip per token, not per sequence — committing a whole sequence to model predictions early in training lets consecutive errors amplify. Exercise 3 has you build it.

11.5 The fixed-size state in the middle

Time to stare at the handoff. Every property of the source that the decoder will ever use crossed one bridge: \((\vect{h},\vect{c})\), two 128-dimensional vectors and therefore 256 scalars. Our task fits because a date is three facts, yet its residual digit and order errors are consistent with a decoder reconstructing from a compressed summary rather than consulting source positions directly. They do not, by themselves, prove that compression caused each error. Now scale the ambition: a forty-word sentence, clause structure, names, negations, all crammed through the same fixed pipe, regardless of length. Chapter 10 called this the price of the Markov claim; here the bill arrives itemized.

And notice what the architecture throws away: the encoder had a state at every step, a whole trajectory of summaries, one per source position, and we kept only the last. The fix that defines Part IV is almost embarrassingly direct: keep them all, and let the decoder look back at whichever ones matter for the token it is writing right now. Choosing where to look, by similarity, on the fly: you have owned the primitive since Chapter 1’s dot product, and Chapter 2 promised you the “soft lookup” that makes it differentiable. Chapter 12 builds the classical version; Chapter 13 makes it learnable and comes back for this very date task with an alignment map to show where the decoder looks.

11.6 Okay, so —

  1. Encoder–decoder = two RNNs and a handoff: read the source into a final state, hand it over, generate from it. <bos> starts the decoder, <eos> lets it choose its length, embeddings replace one-hots (hard address, learned content).
  2. Padding poisons twice: the loss (ignore_index) and the encoder (pack_padded_sequence). On never-seen source strings, the same model and budget score 34.3% naive versus 93.1% packed. Target padding is included for reuse but not exercised by these fixed-length ISO targets. Audit bookkeeping before architecture.
  3. Teacher forcing trains the decoder on the gold rail: one fused call with no Python feedback loop and stable early learning, at the cost of exposure bias, the train/inference mismatch that free-running avoids by paying with speed and early instability. Scheduled sampling anneals between them, flipping per token.
  4. Decoding is search. Greedy maximizes steps, not sequences; beam search carries \(k\) rails for \(O(k V T)\) and surfaces high-scoring alternatives. Its raw joint log scores are rankings, not a normalized posterior, and a beam can contain invalid hybrids. Length normalization (\(L^{\alpha}\)) keeps long outputs competitive.
  5. The bottleneck is now the story. One fixed-size \((\vect{h},\vect{c})\) pair, 256 scalars here, connects reader and writer regardless of input length. The encoder’s per-step states were there all along. Part IV is what happens when we stop throwing them away.

Sources and further reading

Exercises

  1. (Pencil.) Count this chapter’s parameters: two embeddings, two LSTMs, one output layer (\(V_{\text{src}} = 37\), \(V_{\text{tgt}} = 14\), embed 32, hidden 128; an LSTM holds \(4\left[(e + h)h + 2h\right]\) — derive that first). Where do most parameters live, and why is the answer different from Chapter 8’s LeNet audit?
  2. (Pencil.) With vocabulary \(10^4\) and output length 10, compare the number of sequences examined by exhaustive search, greedy, and beam-5. Then explain why beam search with \(k = V^{T}\) is exact and with \(k = 1\) is greedy — and why neither endpoint is usable.
  3. (Code.) Implement scheduled sampling: per token, use the gold input with probability \(\epsilon\), the model’s own prediction otherwise, annealing \(\epsilon\) linearly \(1 \to 0\) over 25 epochs. Compare its learning curve with both curves in Figure 11.2. Does it inherit teacher forcing’s fast start and free-running’s finish?
  4. (Code.) Add length normalization (\(/L^{\alpha}\)) to beam_search and sweep \(\alpha \in \{0, 0.7, 1\}\) on the date task. Explain why correct ten-character outputs are unaffected, inspect whether any erroneous beams change, and design the smallest change to the task that would make \(\alpha\) matter.
  5. (Code.) Sutskever et al. (2014) famously improved translation by feeding the source sentence reversed. Try it here. Whatever you observe, explain it with this chapter’s tools: which source-target dependencies does reversal shorten in their setting, and which does it shorten — or lengthen — for dates, where the year sits at the end of the source but the start of the target?