""" decoding.py — inference-time generation: greedy and beam search. Why this is a separate module: train.py needs greedy decoding for validation BLEU, evaluate.py needs both. Keeping them here guarantees the two scripts generate text identically. The train/inference mismatch ---------------------------- During training the decoder is fed the GROUND TRUTH prefix (teacher forcing). At inference there is no ground truth, so it is fed its OWN previous outputs. One bad token early therefore poisons everything after it — the model has never been trained on its own mistakes. That asymmetry is exactly why beam search helps: keeping several candidate prefixes alive gives the model a chance to recover from a locally-attractive but globally-wrong first choice. Stopping: both decoders stop per-sequence at . Running for a fixed number of steps and truncating later would append garbage after the real sentence and tank BLEU (which is precision-based — every junk token is counted against you). Efficiency note: both functions re-run the whole decoder stack over the whole prefix at every step, so generating T tokens costs O(T^2) decoder passes. A KV cache would make it O(T) by storing each layer's past keys/values. It is left out on purpose — it adds state-management complexity that has nothing to do with understanding encoder-decoder attention, and at this scale it is fast enough. ============================================================================ THIS FILE'S PLACE IN THE CHAIN ============================================================================ Depends on: model.py only (calls model.encode()/model.decode() directly — see model.py's docstrings on those two methods for why generation bypasses forward()). Called from: - train.py's main(): once per epoch, via translate_corpus(..., method="greedy"), to compute validation BLEU for model-selection / early stopping. - evaluate.py's main(): via translate_corpus(..., method=), to produce the final scored hypotheses reported to the user and appended to experiments/results.csv. Internal call chain: translate_corpus(model, dataloader, tokenizer, ...) for each batch from the dataloader (built by dataset.py's make_dataloader): -> greedy_decode(model, batch["src"], ...) [if method == "greedy"] or, once per sentence in the batch: -> beam_search_decode(model, one_sentence, ...) [if method == "beam"] -> tokenizer.decode(ids, skip_special_tokens=True) to turn ids back into text => returns (hypotheses, references, sources) as plain strings, ready for sacrebleu (in train.py/evaluate.py) to score directly. """ import torch import torch.nn.functional as F @torch.no_grad() # ^ disables gradient tracking for this whole function — generation is # inference-only, so autograd bookkeeping would just waste memory/time. def greedy_decode(model, src, src_key_padding_mask, bos_id, eos_id, pad_id, max_new_tokens=128): """Batched greedy decoding: always take the argmax token. src: (B, S) -> returns a list of B token-id lists, / stripped. Called from translate_corpus() below, once per batch, when method="greedy". """ model.eval() # disables dropout — deterministic behavior required for reproducible generation device = src.device B = src.size(0) # The encoder runs ONCE. `memory` is then reused at every decoding step — # this is the practical payoff of the encoder-decoder split. memory = model.encode(src, src_key_padding_mask) # model.py: TransformerTranslator.encode() # Start every sequence in the batch with just — shape (B, 1). ys = torch.full((B, 1), bos_id, dtype=torch.long, device=device) # Tracks, per batch element, whether that sequence has already produced . finished = torch.zeros(B, dtype=torch.bool, device=device) for _ in range(max_new_tokens): # Re-run the WHOLE decoder stack over `ys` as it stands so far (this # is the O(T^2) cost the module docstring's "Efficiency note" warns about). logits = model.decode(ys, memory, memory_key_padding_mask=src_key_padding_mask, tgt_key_padding_mask=None) # no pads in a generated prefix next_tok = logits[:, -1].argmax(dim=-1) # (B,) — last position only # ^ logits shape is (B, current_len, vocab); [:, -1] takes only the # prediction for the NEXT token (position current_len), argmax # picks the single highest-scoring vocab id — this is "greedy": # no exploration of alternatives, unlike beam_search_decode below. # Once a sequence has emitted we keep appending , so the # tensor stays rectangular but nothing further is really generated. next_tok = torch.where(finished, torch.full_like(next_tok, pad_id), next_tok) # ^ torch.where(cond, a, b): elementwise "if cond then a else b" — # for already-finished sequences, force the next token to # regardless of what argmax picked, so they don't keep "generating" # nonsense after their real answer ended. ys = torch.cat([ys, next_tok.unsqueeze(1)], dim=1) # append this step's tokens: (B, len) -> (B, len+1) finished |= next_tok.eq(eos_id) # mark newly-finished sequences if bool(finished.all()): break # early exit once every sequence in the batch has hit # _strip() (below) removes the leading , trailing s, and # everything from the first onward, for each sequence independently. return [_strip(seq.tolist(), bos_id, eos_id, pad_id) for seq in ys] @torch.no_grad() def beam_search_decode(model, src, src_key_padding_mask, bos_id, eos_id, pad_id, beam_size=5, max_new_tokens=128, length_penalty=0.6): """Beam search, one source sentence at a time (src must be (1, S)). Kept to batch-size 1 for readability: the beams themselves are batched, so it is still a single forward pass per step over `beam_size` rows. Length penalty (GNMT): score / ((5 + len) / 6)^alpha. Raw log-probabilities are sums of negative numbers, so without normalization the shortest hypothesis almost always wins and the model produces truncated translations. Called from translate_corpus() below, once PER SENTENCE (a Python list comprehension over the batch), when method="beam" — note this is why beam search is noticeably slower than greedy_decode's batched version. """ model.eval() device = src.device assert src.size(0) == 1, "beam_search_decode expects one sentence at a time" memory = model.encode(src, src_key_padding_mask) # (1, S, d) — encode the ONE sentence # Expand memory to `beam_size` copies so every beam can be processed as # one batched decode() call. .expand() is a view (no data copy) until # .contiguous() forces actual memory layout, which nn.MultiheadAttention needs. memory = memory.expand(beam_size, -1, -1).contiguous() # (k, S, d) mem_mask = (src_key_padding_mask.expand(beam_size, -1).contiguous() if src_key_padding_mask is not None else None) # beams: (k, current_len) — k candidate token sequences, all starting at . beams = torch.full((beam_size, 1), bos_id, dtype=torch.long, device=device) # beam_scores: cumulative log-probability of each beam so far. beam_scores = torch.full((beam_size,), float("-inf"), device=device) beam_scores[0] = 0.0 # only beam 0 is live at step 0; the others are # clones of it, and without this they would all # expand to the same tokens and waste the beam. finished = [] # list of (normalized_score, token_id_list) for step in range(max_new_tokens): logits = model.decode(beams, memory, memory_key_padding_mask=mem_mask) # log_softmax turns raw logits into log-probabilities — needed # because beam scores accumulate ADDITIVELY across steps (summing # log-probs == multiplying probabilities, in log space). log_probs = F.log_softmax(logits[:, -1], dim=-1) # (k, V) # Total score of each candidate continuation = beam score + token logprob # Broadcasting: (k, 1) + (k, V) -> (k, V), every beam's running score # added to every possible next-token log-prob for that beam. cand = beam_scores.unsqueeze(1) + log_probs # (k, V) flat = cand.view(-1) # flatten to (k*V,) to do ONE global top-k top_scores, top_idx = flat.topk(beam_size) # ^ picks the best `beam_size` (beam, token) COMBINATIONS across ALL # k beams at once — this is what lets beam search prune to a fixed # width while still considering every beam's every possible next token. # Undo the flattening: recover which original beam and which token # each of the top_idx entries came from. beam_idx = torch.div(top_idx, log_probs.size(-1), rounding_mode="floor") # ^ integer division by vocab size V: which beam (row) this came from token_idx = top_idx % log_probs.size(-1) # ^ remainder: which token (column) within that beam's row # Build the new set of k beams: take each surviving beam's PAST # tokens (indexed by beam_idx, which may repeat or drop the original # 0..k-1 ordering) and append its newly chosen token. beams = torch.cat([beams[beam_idx], token_idx.unsqueeze(1)], dim=1) beam_scores = top_scores # Retire any beam that just produced , and mark its slot dead so # it is not extended again. for b in range(beam_size): if token_idx[b].item() == eos_id: lp = ((5 + beams.size(1)) / 6) ** length_penalty # GNMT length penalty (see docstring) finished.append((beam_scores[b].item() / lp, _strip(beams[b].tolist(), bos_id, eos_id, pad_id))) beam_scores[b] = float("-inf") # ^ -inf score means this slot can never win future topk # comparisons, effectively freezing it out without shrinking # the tensor (keeping shapes fixed at (beam_size, ...) is # simpler than dynamically resizing). if len(finished) >= beam_size or bool(torch.isinf(beam_scores).all()): break # stop once we have enough finished candidates, or every beam has died if not finished: # Hit the length limit with nothing finished — take the best live beam. best = int(beam_scores.argmax()) lp = ((5 + beams.size(1)) / 6) ** length_penalty finished.append((beam_scores[best].item() / lp, _strip(beams[best].tolist(), bos_id, eos_id, pad_id))) finished.sort(key=lambda x: x[0], reverse=True) # highest length-normalized score first return finished[0][1] # return only the single best hypothesis's token ids def _strip(ids, bos_id, eos_id, pad_id): """Drop the leading and everything from the first onward. Called from both greedy_decode() and beam_search_decode() to turn raw generated-id sequences (which still contain //) into the "real" translated token ids that tokenizer.decode() should convert to text. """ out = [] for i, t in enumerate(ids): if i == 0 and t == bos_id: continue # skip the leading specifically at position 0 if t == eos_id: break # stop entirely — anything after is not part of the sentence if t == pad_id: continue # defensive: skip any stray (shouldn't normally appear before ) out.append(t) return out @torch.no_grad() def translate_corpus(model, dataloader, tokenizer, bos_id, eos_id, pad_id, device, method="greedy", beam_size=5, max_new_tokens=128, length_penalty=0.6, progress_every=0): """Decode a whole DataLoader. Returns (hypotheses, references, sources) as lists of DETOKENIZED strings — sacrebleu scores plain text, never subword ids. tokenizer.decode with skip_special_tokens=True plus the byte-level decoder gives back normal spacing automatically. This is the ONE function train.py and evaluate.py both call — see this file's module docstring for the exact call sites. `dataloader` is whatever dataset.py's make_dataloader() produced; `tokenizer` is the same `tokenizers.Tokenizer` object dataset.py's load_tokenizer() returned. """ hyps, refs, srcs = [], [], [] for i, batch in enumerate(dataloader): # batch is one of dataset.py's collate() output dicts — see # dataset.py's make_collate_fn() for exactly what keys exist. src = batch["src"].to(device) src_mask = batch["src_key_padding_mask"].to(device) if method == "greedy": out_ids = greedy_decode(model, src, src_mask, bos_id, eos_id, pad_id, max_new_tokens=max_new_tokens) # ^ returns a LIST of token-id lists, one per sentence in the batch, already elif method == "beam": # No batched beam-search here: loop over each sentence in this # batch individually (src[j:j+1] keeps the batch dim as size 1, # which is what beam_search_decode's assert requires). out_ids = [ beam_search_decode(model, src[j:j + 1], src_mask[j:j + 1], bos_id, eos_id, pad_id, beam_size=beam_size, max_new_tokens=max_new_tokens, length_penalty=length_penalty) for j in range(src.size(0)) ] else: raise ValueError(f"unknown decoding method: {method}") # tokenizer.decode(): the exact inverse of the encode_batch() call in # dataset.py's TranslationDataset.__init__ — turns integer ids back # into normal, correctly-spaced text (see tokenizer.py's ByteLevel # decoder comment for why this "just works"). hyps.extend(tokenizer.decode(ids, skip_special_tokens=True).strip() for ids in out_ids) refs.extend(batch["tgt_text"]) # ground-truth English strings, straight from dataset.py srcs.extend(batch["src_text"]) # original Russian strings, for printing/inspection if progress_every and (i + 1) % progress_every == 0: print(f" decoded {len(hyps)} sentences...", flush=True) return hyps, refs, srcs