| """ |
| 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 <eos>. 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=<greedy|beam>), 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() |
| |
| |
| 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, <bos>/<eos> stripped. |
| |
| Called from translate_corpus() below, once per batch, when method="greedy". |
| """ |
| model.eval() |
| device = src.device |
| B = src.size(0) |
|
|
| |
| |
| memory = model.encode(src, src_key_padding_mask) |
|
|
| |
| ys = torch.full((B, 1), bos_id, dtype=torch.long, device=device) |
| |
| finished = torch.zeros(B, dtype=torch.bool, device=device) |
|
|
| for _ in range(max_new_tokens): |
| |
| |
| logits = model.decode(ys, memory, |
| memory_key_padding_mask=src_key_padding_mask, |
| tgt_key_padding_mask=None) |
| next_tok = logits[:, -1].argmax(dim=-1) |
| |
| |
| |
| |
|
|
| |
| |
| next_tok = torch.where(finished, torch.full_like(next_tok, pad_id), next_tok) |
| |
| |
| |
| |
| ys = torch.cat([ys, next_tok.unsqueeze(1)], dim=1) |
| finished |= next_tok.eq(eos_id) |
| if bool(finished.all()): |
| break |
|
|
| |
| |
| 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) |
| |
| |
| |
| memory = memory.expand(beam_size, -1, -1).contiguous() |
| mem_mask = (src_key_padding_mask.expand(beam_size, -1).contiguous() |
| if src_key_padding_mask is not None else None) |
|
|
| |
| beams = torch.full((beam_size, 1), bos_id, dtype=torch.long, device=device) |
| |
| beam_scores = torch.full((beam_size,), float("-inf"), device=device) |
| beam_scores[0] = 0.0 |
| |
| |
| finished = [] |
|
|
| for step in range(max_new_tokens): |
| logits = model.decode(beams, memory, memory_key_padding_mask=mem_mask) |
| |
| |
| |
| log_probs = F.log_softmax(logits[:, -1], dim=-1) |
|
|
| |
| |
| |
| cand = beam_scores.unsqueeze(1) + log_probs |
| flat = cand.view(-1) |
| top_scores, top_idx = flat.topk(beam_size) |
| |
| |
| |
|
|
| |
| |
| beam_idx = torch.div(top_idx, log_probs.size(-1), rounding_mode="floor") |
| |
| token_idx = top_idx % log_probs.size(-1) |
| |
|
|
| |
| |
| |
| beams = torch.cat([beams[beam_idx], token_idx.unsqueeze(1)], dim=1) |
| beam_scores = top_scores |
|
|
| |
| |
| for b in range(beam_size): |
| if token_idx[b].item() == eos_id: |
| lp = ((5 + beams.size(1)) / 6) ** length_penalty |
| finished.append((beam_scores[b].item() / lp, |
| _strip(beams[b].tolist(), bos_id, eos_id, pad_id))) |
| beam_scores[b] = float("-inf") |
| |
| |
| |
| |
|
|
| if len(finished) >= beam_size or bool(torch.isinf(beam_scores).all()): |
| break |
|
|
| if not finished: |
| |
| 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) |
| return finished[0][1] |
|
|
|
|
| def _strip(ids, bos_id, eos_id, pad_id): |
| """Drop the leading <bos> and everything from the first <eos> onward. |
| |
| Called from both greedy_decode() and beam_search_decode() to turn raw |
| generated-id sequences (which still contain <bos>/<eos>/<pad>) 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 |
| if t == eos_id: |
| break |
| if t == pad_id: |
| continue |
| 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): |
| |
| |
| 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) |
| |
| elif method == "beam": |
| |
| |
| |
| 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}") |
|
|
| |
| |
| |
| |
| hyps.extend(tokenizer.decode(ids, skip_special_tokens=True).strip() |
| for ids in out_ids) |
| refs.extend(batch["tgt_text"]) |
| srcs.extend(batch["src_text"]) |
|
|
| if progress_every and (i + 1) % progress_every == 0: |
| print(f" decoded {len(hyps)} sentences...", flush=True) |
|
|
| return hyps, refs, srcs |
|
|