File size: 14,935 Bytes
476c25f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
"""
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()
# ^ 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, <bos>/<eos> 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 <bos> — 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 <eos>.
    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 <eos> we keep appending <pad>, 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 <pad>
        #   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 <eos>

    # _strip() (below) removes the leading <bos>, trailing <pad>s, and
    # everything from the first <eos> 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 <bos>.
    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 <eos>, 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 <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        # skip the leading <bos> specifically at position 0
        if t == eos_id:
            break            # stop entirely — anything after <eos> is not part of the sentence
        if t == pad_id:
            continue        # defensive: skip any stray <pad> (shouldn't normally appear before <eos>)
        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