Skip to main content

13  Encoder–Decoder, Teacher Forcing, Beam Search

Chapter 12 trained a language model: a recurrent network that predicts the next token of one sequence. We now condition that prediction on a second sequence, the source: an encoder processes the source, and a decoder uses the encoder’s final state to generate the target sequence. The architecture is simple, but training and evaluation require several choices: how to handle padding, which previous tokens to supply during training, and how to select a complete output at inference.

The components are not new. In the autoencoder interlude, an encoder compressed a fixed object and a decoder reconstructed it; here both maps are recurrent, and every source detail the decoder uses still crosses one fixed-size context.

Before recurrence, every model we built used a fixed-size contract: 784 pixels in, 10 logits out; \(28 \times 28\) garments in, one verdict out. Many real tasks break 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 six design goals of the previous chapter concerned reading a sequence. The sequence-to-sequence setting adds three goals for writing one:

  1. Output of any length. The model decides where the output ends.
  2. Reordering. An output token may depend on input that arrives after it: the year comes last in march 5, 2021 and first in 2021-03-05.
  3. Conditioning on both sides. Each output token depends on the whole input and on every token already written.

The smallest design that meets all three reuses two recurrent machines already built: one that reads a sequence into a state, and the character model that writes one token at a time from a state.

Four earlier ideas return in new roles: the one-hot linear map of Chapter 1 becomes an embedding table; the estimator cases of Section 4.6.1 fix which tokens a loss averages over; the masked update of Chapter 12 becomes the framework’s packed sequences; and the hard max of Chapter 2 returns as greedy decoding, which commits to the most likely token one step at a time. The rules for training and decoding do not depend on recurrence: targets shifted by one token, a token-level log-likelihood, separate loss and input masks, and greedy or beam search carry over to the attention-based decoders of Parts IV and V.

13.1 Two RNNs and a handoff

An encoder reads the source sequence into a context, and a decoder generates the output from that context token by token, feeding each token back in, as the character model did. The encoder may be any network that maps the source to a context. Here both networks are RNNs: the context is the encoder’s final state, the entire input compressed into fixed-size memory, and the decoder starts from that state. The pair is a language model conditioned on the source.

Our laboratory task keeps the core mechanics of translation: 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

The task has variable-length input, a different output alphabet, and long-range structure: the year arrives last in most sources but must be emitted first. Every target has ten characters, so target padding is not tested. A dialect ambiguity gives beam search two readings to rank: 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.

  1. List the twelve MONTHS and their day counts in DAYS, with February fixed at 28.
  2. Define the reusable make_date helper.
  3. Implement the two date languages, generated on the fly.
  4. Print four pairs and the split overlaps, then build both vocabularies and print their sizes.
import random, torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib.pyplot as plt
torch.set_num_threads(4)  # pinned: results depend on the CPU thread count

# [1]
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]))

# [2]
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()
# [3]
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:]
# [4]
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: it removes duplicate source strings before the 8,000/500/500 train/validation/test split, so no source string occurs in two splits. 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.

We use three special tokens, the same conventions as a full translation pipeline: <pad> fills the short sequences of a batch out to a rectangle, <bos> is the decoder’s first input, and <eos> ends every target, so the model decides where its output ends by emitting it. A token here is one character; words or subword pieces would change sequence lengths and vocabulary sizes, not the rules for training and decoding.

The character model fed characters in as one-hot vectors; here each token gets a learned vector from nn.Embedding, a table with one trainable row per vocabulary entry. An embedding is exactly a one-hot vector times a weight matrix, computed as a table lookup: the address is hard, the content is learned. Row 17 is fetched by its index, rigidly; the vector in row 17 is trained like any other weight.

  1. Encoder, decoder, and the handoff.
# [1]
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 whatever the source length (Figure 13.1). All the source information the decoder can use crosses it.

An encoder reads source characters with one state per character. The earlier states are drawn in gray and labeled discarded, and two faded padding steps are skipped by an arc. The final state becomes an (h, c) box labeled 256 scalars that initializes a decoder chain. The decoder reads <bos>, 2, 0, 2 and writes 2, 0, 2, 1, with dashed arrows feeding each output back as the next input.
Figure 13.1: The encoder–decoder handoff. The encoder computes a state at every source position, but only the final \((h,c)\) pair crosses to initialize the decoder; the earlier states are discarded, and packing stops the encoder at the last real token. The decoder feeds each output back as its next input.

13.2 What the loss grades and what the encoder reads

Batching variable-length sequences means padding them to a rectangle. Unless we account for it, padding affects two computations:

  1. The loss. With the mean reduction used here, ignore_index=PAD excludes padded target positions from both the cross-entropy sum and its denominator. Our targets have equal length, so target padding is not tested here. With variable lengths, the calculation is a token mean within each batch; interpreting it as an estimator of corpus-wide token risk also requires specifying how batches are sampled.
  2. The encoder. A zero padding embedding does not make an LSTM update the identity. Processing padded positions can change the final state, making it depend on the padding length as well as the source. Decoding an unpadded sequence can then supply a different context from the one used during training.

The naive model is Seq2Seq with packed=False, so its encoder reads every source to the end of the batch’s padded rectangle. It trains on the same data and budget as the packed model, so the two runs differ only in whether the encoder is told the true lengths (pack_padded_sequence, which runs the recurrence only through each sequence’s real tokens):

  1. Define the reusable helpers: batchify, train_seq2seq, and translate.
  2. Define the reusable helpers: exact_match and unambiguous.
  3. Keep the unambiguous validation and test pairs and list the checkpoint epochs cps.
  4. Train twice: naive right-padding vs. packed sequences.
  5. Print validation exact match on unambiguous dates for the naive and packed models.
# [1]
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":  # ground-truth prefixes: one fused call
                logits = model(S, L, T[:, :-1])
            else:             # free-running: its own predictions as prefixes
                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

# [2]
@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]

# [3]
valid_unamb = unambiguous(valid_pairs)
test_unamb = unambiguous(test_pairs)

cps = (2, 4, 6, 8, 12, 16, 20, 25)
# [4]
naive, _ = train_seq2seq(packed=False)
packed, packed_curve = train_seq2seq(packed=True, checkpoints=cps)
# [5]
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%

Exact match on all unambiguous validation dates is 37.3% for the naive model and 95.7% for the packed one, a 58.4-point gap. The sealed test set remains untouched until we fix both training-mode designs below.

Take the naive model and one short validation source, and watch its encoder state as the input runs past the last real character into the padding:

  1. Append 8 pad tokens to the shortest validation source and record the naive encoder’s states.
  2. Measure encoder-state drift through the padding tail.
# [1]
short_src = min((s for s, _ in valid_pairs), key=len)
ids = torch.tensor([src_stoi[c] for c in short_src])
n_pads = 8
padded_ids = torch.cat([ids, torch.zeros(n_pads, dtype=torch.long)])
with torch.no_grad():
    states, _ = naive.encoder(naive.src_emb(padded_ids[None]))
states = states.squeeze(0)                        # (len + n_pads, hidden)
T = len(ids)
# [2]
drift = (states[T - 1:] - states[T - 1]).norm(dim=1)
A line plot shows the hidden-state distance from the last real character as right-padding timesteps are processed. The green point marks zero drift at the sentence end; the navy trajectory rises through the orange-shaded padding region, showing that padding continues to move the state.
Figure 13.2: Why naive right-padding hurts: the encoder state does not stop when the sentence does. One short validation source is encoded by the naive model; at its last real character (green) the state is exactly what the decoder should receive. A single step already throws the state most of the way to its final drift, and each further step compounds it (navy); the decoder is handed the endpoint of the shaded excursion, a state the unpadded inference-time encoder never produces. Packing simply stops the clock at the last real character.

The masked update of Equation 12.3 keeps the old state on every padded step, so each sequence ends holding the state of its own last real token. pack_padded_sequence is the framework’s version of that bookkeeping: rather than computing the padded steps and throwing them away, it never runs them. If that reading is right, a hand-written masked loop must reproduce the packed encoder’s final states, and the naive encoder must disagree with them for exactly the sources that carried padding.

  1. Write the masked update as a loop over an nn.LSTMCell that shares the encoder’s weights.
  2. Encode 128 validation sources of mixed length three ways with the naive model’s encoder: packed, naive, and masked.
  3. Check that masking reproduces packing and that naive padding moved exactly the padded sources; print the counts.
# [1]
@torch.no_grad()
def masked_encode(encoder: nn.LSTM, e: torch.Tensor,
                  lengths: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
    cell = nn.LSTMCell(e.shape[-1], encoder.hidden_size)
    cell.load_state_dict({k.removesuffix("_l0"): v   # same weights, one step
                          for k, v in encoder.state_dict().items()})
    h = c = torch.zeros(e.shape[0], encoder.hidden_size)
    for t in range(e.shape[1]):
        m = (t < lengths).float()[:, None]            # 1 real token, 0 padding
        h_new, c_new = cell(e[:, t], (h, c))
        h, c = m * h_new + (1 - m) * h, m * c_new + (1 - m) * c
    return h, c

# [2]
S_mix, L_mix, _ = batchify(valid_pairs, list(range(128)))
with torch.no_grad():
    e = naive.src_emb(S_mix)
    _, (h_packed, c_packed) = naive.encoder(nn.utils.rnn.pack_padded_sequence(
        e, L_mix, batch_first=True, enforce_sorted=False))
    _, (h_naive, _) = naive.encoder(e)
h_masked, c_masked = masked_encode(naive.encoder, e, L_mix)
# [3]
gap = torch.cat([h_masked - h_packed[0], c_masked - c_packed[0]]).abs().max()
assert gap < 1e-5
moved = (h_naive[0] - h_packed[0]).norm(dim=1) > 1e-3
padded = L_mix < L_mix.max()
assert torch.equal(moved, padded)
print(f"{len(L_mix)} validation sources, lengths {int(L_mix.min())} to "
      f"{int(L_mix.max())}")
print("masked update vs packing: same final states (max difference below 1e-5)")
print(f"naive padding moved {int(moved.sum())} final states: exactly the sources "
      f"shorter than {int(L_mix.max())} characters")
128 validation sources, lengths 10 to 18
masked update vs packing: same final states (max difference below 1e-5)
naive padding moved 124 final states: exactly the sources shorter than 18 characters

Packing is the masked update, minus the padded steps it never computes.

WarningTrap: padding is read unless it is masked

Nothing in the tensor marks <pad> as meaningless; meaning is something you enforce, in two places: the loss mask (ignore_index) for the loss, and an input mask (packing, for a recurrent encoder) for the encoder. When a sequence model underperforms unexpectedly, audit both masks before changing the architecture. Telling a model which positions it may not look at is called masking; the loss mask applies the same idea to grading. In a transformer, a causal mask is what turns the model into a language model.

13.3 Teacher forcing: conditioning on target prefixes

Teacher forcing evaluates each observed target token conditional on the source and the preceding target tokens. Summing these negative log-probabilities gives the conditional negative log-likelihood of the observed target sequence. With teacher forcing, the loss in train_seq2seq averages these token losses over the nonpadding target positions of the minibatch.

The two mode branches in the training loop above answer one question. When the decoder is trained, what should it receive as its own previous output?

Free-running (“fr”) feeds the decoder its own most likely token from the step before, as at inference. Early in training those predictions are mostly wrong, so the decoder is trained on prefixes that contain its own errors. Every sequence must also be generated token by token in a Python loop, because step \(t\)’s input does not exist until step \(t-1\) is computed.

Teacher forcing (“tf”) conditions each step on the observed previous token instead. It fixes every decoder input before the forward pass, so the whole target goes through the decoder in one fused decoder call (the model(S, L, T[:, :-1]) line) instead of a Python feedback loop. Whether the positions are then computed one after another or all at once is a property of the decoder, not of teacher forcing: the recurrent decoder still computes each state from the previous one inside that call. From the first epoch, every step is nevertheless conditioned on a correct prefix. The comparison below measures how the two modes differ in validation accuracy during training. The computational difference can be read off the training loop above, one decoder call per batch against one per target position; we do not measure its effect on running time.

  1. Reuse the packed model and its learning curve as the teacher-forced run.
  2. Train the free-running comparison.
  3. Audit all three models once on the final test split.
# [1]
tf_model, tf_curve = packed, packed_curve
# [2]
fr_model, fr_curve = train_seq2seq("fr", checkpoints=cps)
# [3]
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%}")
one final test audit (unambiguous sources):
  naive padding             34.3%
  packed / teacher forced   93.1%
  packed / free-running     99.1%
Exact-match accuracy rises with epoch for both training modes. Teacher forcing leads strongly in early and middle epochs, then the free-running curve catches it near the top by epoch twenty. At the last checkpoint the teacher-forcing curve dips while the free-running curve stays near the top.
Figure 13.3: Exact match of the two training modes at eight checkpoint epochs, on the first 400 unambiguous validation sources. Teacher forcing leads from epoch 4 through the middle epochs (53.75% vs. 23.75% at epoch 6; 95.00% vs. 81.25% at epoch 12); by epoch 20 the validation curves meet.

On the 400 validation sources of the curves, the teacher-forced model peaks at 99.50% at epoch 20 and ends at 95.75% at epoch 25, so the comparison depends on which checkpoint each model keeps. That choice is part of the design and must be fixed before the test split is opened; here every model is kept as it stands after its last epoch. Free-running, ahead on validation only at the last checkpoint, also finishes ahead in the test audit above; with one seed for each mode, these runs do not show whether that lead would hold on other seeds.

Teacher forcing is the common default, and in this comparison it gained validation accuracy faster early in training. It also creates a mismatch called exposure bias: at inference the decoder conditions on its own predictions, which can form prefixes unlike any seen in training. Exposure bias comes from maximum likelihood on observed prefixes, not from recurrence, so it affects every autoregressive decoder (one that feeds its own outputs back as inputs) trained by teacher forcing. Training never presents the decoder with a prefix that contains its own error, so an early error at inference may also affect the tokens after it. We can look for this effect in the teacher-forced model’s residual errors:

  1. Collect the unambiguous test dates tf_model gets wrong and print how many there are.
  2. Print the first three errors, each beside its truth.

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. Without the observed prefix, as at inference, one wrong character changes the prefix for every character after it.

NoteScheduled sampling

Scheduled sampling (Bengio et al., 2015) mixes the two training modes. At each timestep, the decoder receives the observed previous token with probability \(\epsilon\) and its own prediction with probability \(1 - \epsilon\), and \(\epsilon\) is annealed from 1 toward 0 as training proceeds. The choice is made per token, not per sequence; committing a whole sequence to model predictions early in training can let consecutive errors amplify. Each step still scores the observed next token, but once some inputs are model predictions, the summed loss is no longer the conditional negative log-likelihood of the observed target. Mixing also has a cost in any architecture: a mixed-in prediction must be generated before it is read, so not all decoder inputs are known before the forward pass. In Exercise 3 you implement it and compare its learning curve with both training modes.

13.5 The fixed-size state in the middle

Every source fact the decoder uses crosses one bridge: the encoder’s final \((\vect{h},\vect{c})\) pair, the 256 scalars of Figure 13.1. Our task fits because a date is three facts. The teacher-forced model’s residual digit and order errors are consistent with reconstruction from a compressed summary rather than direct access to source positions; by themselves, they do not prove that compression caused each error. A forty-word sentence would have to cross the same pair, and a wider state may help but gives no direct access to earlier source positions. States for those positions already exist: the encoder computes one at every source position, and this model keeps only the last. A decoder that could consult them at each output step would no longer need one state to carry the whole source.

NoteCheck yourself

Close the book for one minute and reconstruct the sequence pipeline.

  • Which target positions does the loss grade, which source positions may the encoder read, and what enforces each?
  • What mismatch does teacher forcing create between training and inference?
  • What does beam search change, and what part of the learned model does it leave unchanged?
  • Why does packing leave every final state exactly where the masked update would?

13.6 Okay, so: one training contract, one fixed-size handoff

  1. A sequence-to-sequence model is a conditional language model, here two RNNs and a handoff: <bos> starts the decoder, <eos> lets it choose its length, and embeddings replace one-hots (hard address, learned content).
  2. Padding affects two computations: the loss, where ignore_index removes padded targets from the sum and its denominator, and the encoder, where packing (pack_padded_sequence, the framework’s form of the masked update) keeps padding out of the final state. On the test set, the same model and budget score 34.3% naive versus 93.1% packed. Audit bookkeeping before architecture.
  3. Teacher forcing conditions the decoder on observed prefixes; its token losses sum to the conditional negative log-likelihood of the observed target, and our loop averages them over nonpadding positions. The decoder’s inputs are known in advance, so one fused call replaces the Python feedback loop, but each step still waits for the state of the previous step. Exposure bias is the mismatch between training on observed prefixes and generating from the model’s own. Teacher forcing gained validation accuracy faster early in training; free-running finished ahead on the test set, with one seed per mode and last-epoch checkpoints. Scheduled sampling mixes the two per token, so its decoder inputs are no longer all known before the forward pass.
  4. Decoding is approximate search. Greedy maximizes steps, not sequences, and can miss the highest-probability sequence; beam search carries \(k\) partial sequences for \(O(k V T)\) and surfaces high-scoring alternatives, but a finite beam does not guarantee the best sequence in every tree. Its raw joint log scores are log-probabilities of single sequences, not a posterior over the beam, and a beam can contain invalid hybrids. Length normalization (\(L^{\alpha}\)) changes the decoding objective.
  5. The handoff is the bottleneck. In this model, one fixed-size \((\vect{h},\vect{c})\) pair, 256 scalars, carries all the source information the decoder can use, regardless of input length; the encoder’s per-position states exist and are discarded.

Sources and further reading

The papers below, listed for Exercise 8’s research bridge, train on a sequence-level score.

Exercises

  1. (Pencil.) Count this chapter’s parameters: two embeddings, two LSTMs, one output layer, with \(V_{\text{src}} = 37\), \(V_{\text{tgt}} = 14\), embedding size \(e = 32\), and hidden size \(h = 128\). First derive that an LSTM holds \(4\left[(e + h)h + 2h\right]\) parameters (PyTorch keeps two bias vectors). Which components hold most of the parameters, and why is the answer different from Chapter 9’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, why exhaustive search is infeasible at this scale, and why greedy decoding can miss the highest-probability sequence.
  3. (Code.) Implement scheduled sampling: per token, use the observed previous token 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 13.3. 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. Confirm that 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?
  6. (Code.) Implement BLEU from clipped \(n\)-gram counts and the brevity penalty, then score the ambiguous-date task’s 70/30 output distribution with a fixed seed. Compute corpus BLEU and the mean of sentence BLEU separately, verify the clipped counts on two hand-worked examples, and state which aggregation the evaluation should report. Named wrong answer: “average the sentence BLEUs”: corpus clipping and the brevity penalty do not commute with that mean.
  7. (Code.) Running translator, v2. Keep version 0’s corpus, split, and evaluation (Exercise 6 of Chapter 11), and replace version 1’s slot decoder (Exercise 7 of Chapter 12) with a recurrent decoder that starts from the encoder’s final state. Add <bos> and <eos> to the target vocabulary, pad targets per batch and exclude that padding from the loss with ignore_index, pack or mask the encoder, and train with teacher forcing. Decode the validation split greedily and with a beam of width 5, score both with your BLEU from Exercise 6, and record them in your results log. (Audit.) Break validation BLEU down by source-length bucket for versions 1 and 2. Which version loses more on the longest bucket, and what does the curve say about asking one fixed-size handoff to carry a whole sentence?
  8. (Pencil.) Research bridge (optional). Teacher forcing minimizes per-token cross-entropy on observed prefixes, but the model is judged by a sequence-level score of its own outputs. For a score \(R(\vect{y})\) of an output sampled from \(p_\theta(\cdot\mid\vect{x})\), show that the gradient of \(\E_{\vect{y}}[R(\vect{y})]\) equals \(\E_{\vect{y}}[R(\vect{y})\,\nabla_\theta\log p_\theta(\vect{y}\mid\vect{x})]\), and that subtracting a baseline \(b(\vect{x})\) from \(R\) leaves it unchanged. (Code.) Starting from the teacher-forced date model, fine-tune on sampled outputs with exact match as \(R\) and a running-average baseline, then compare test exact match and the residual errors with the teacher-forced model’s. What did training on its own outputs fix, and what did it cost? The papers listed for this exercise under Sources develop sequence-level training for translation; in Chapter 21 the same gradient returns with a learned reward in place of exact match.
  9. (Pencil.) Forbidding tokens one step at a time. Suppose a rule forbids every token that other collects in Table 13.1, so the allowed outputs are A x <eos> and B y <eos>. A sampler removes the forbidden tokens at each step and renormalizes the rest before it draws. With what probability does it return each allowed output, and which probabilities of Table 13.1 does it replace by 1? Then condition the model’s distribution over complete outputs on the event that the output is allowed: divide each allowed output’s probability by the total probability of the allowed outputs. Which output is more probable under each distribution? State what the per-step sampler guarantees about its output and what it does not. Finally, return to the date task: could a rule that allows only well-formed ISO dates exclude either reading of 03/05/2021? Chapter 16 returns to this per-step rule in its optional practice bridge on decoding.