Download src/part3/decoding.py from sahoosk/anlp-assignment2: direct link, hf CLI and curl.
- Browser
- Download file 10.4 kB
-
https://huggingface.co/sahoosk/anlp-assignment2/resolve/main/src/part3/decoding.py
- Command line
-
hf download hf://sahoosk/anlp-assignment2/src/part3/decoding.py
-
curl -L -o decoding.py https://huggingface.co/sahoosk/anlp-assignment2/resolve/main/src/part3/decoding.py
10.4 kB
| """Decoding strategies implemented on top of ``model.forward()``. | |
| ``model.generate()`` is never called. Each strategy drives the forward pass | |
| itself and maintains its own KV cache, which is what keeps the cost linear in | |
| the number of generated tokens instead of quadratic: without a cache every new | |
| token would re-encode the entire prefix. | |
| Prompts in a batch have different lengths, so they are **left**-padded. That | |
| puts every real last token at index -1, which is what the decoder needs, but | |
| it also means the absolute positions are wrong unless ``position_ids`` is | |
| derived from the attention mask rather than from ``arange`` -- which is what | |
| ``_initial_forward`` does below. | |
| """ | |
| from __future__ import annotations | |
| import torch | |
| import torch.nn.functional as F | |
| # --------------------------------------------------------------------------- | |
| # Shared plumbing | |
| # --------------------------------------------------------------------------- | |
| def _initial_forward(model, input_ids, attention_mask): | |
| """Encode the prompt once and return (last-step logits, cache, position).""" | |
| pos = attention_mask.long().cumsum(-1) - 1 | |
| pos.clamp_(min=0) | |
| out = model(input_ids=input_ids, attention_mask=attention_mask, | |
| position_ids=pos, use_cache=True) | |
| return out.logits[:, -1, :].float(), out.past_key_values, pos[:, -1] | |
| def _step(model, token, attention_mask, past, pos): | |
| """One incremental decoding step through the cache.""" | |
| out = model(input_ids=token.unsqueeze(-1), attention_mask=attention_mask, | |
| position_ids=pos.unsqueeze(-1), past_key_values=past, | |
| use_cache=True) | |
| return out.logits[:, -1, :].float(), out.past_key_values | |
| def _grow_mask(attention_mask): | |
| ones = attention_mask.new_ones((attention_mask.size(0), 1)) | |
| return torch.cat([attention_mask, ones], dim=1) | |
| def _finish(seqs, eos_id, prompt_len): | |
| """Trim each generated sequence at its first EOS.""" | |
| out = [] | |
| for s in seqs: | |
| if eos_id is not None and eos_id in s: | |
| s = s[:s.index(eos_id)] | |
| out.append(s) | |
| return out | |
| # --------------------------------------------------------------------------- | |
| # 1. Greedy | |
| # --------------------------------------------------------------------------- | |
| def greedy_decode(model, input_ids, attention_mask, max_new=64, eos_id=None): | |
| """Always take the arg-max. Deterministic, and the width-1 limit of beam | |
| search *only* when no length normalisation is applied.""" | |
| B = input_ids.size(0) | |
| logits, past, pos = _initial_forward(model, input_ids, attention_mask) | |
| gen = [[] for _ in range(B)] | |
| done = torch.zeros(B, dtype=torch.bool, device=input_ids.device) | |
| scores = torch.zeros(B, device=input_ids.device) | |
| for _ in range(max_new): | |
| lp = F.log_softmax(logits, dim=-1) | |
| nxt = lp.argmax(-1) | |
| scores += lp.gather(1, nxt.unsqueeze(1)).squeeze(1) * (~done) | |
| for i, t in enumerate(nxt.tolist()): | |
| if not done[i]: | |
| gen[i].append(t) | |
| if eos_id is not None: | |
| done |= nxt == eos_id | |
| if bool(done.all()): | |
| break | |
| attention_mask = _grow_mask(attention_mask) | |
| pos = pos + 1 | |
| logits, past = _step(model, nxt, attention_mask, past, pos) | |
| return _finish(gen, eos_id, input_ids.size(1)), scores.tolist() | |
| # --------------------------------------------------------------------------- | |
| # 2 & 3. Truncated sampling: top-k and top-p | |
| # --------------------------------------------------------------------------- | |
| def _top_k_filter(logits, k): | |
| """Keep the k highest logits, mask the rest to -inf.""" | |
| if k is None or k <= 0 or k >= logits.size(-1): | |
| return logits | |
| kth = logits.topk(k, dim=-1).values[..., -1, None] | |
| return logits.masked_fill(logits < kth, float("-inf")) | |
| def _top_p_filter(logits, p): | |
| """Nucleus filtering: keep the smallest prefix of the sorted distribution | |
| whose cumulative probability first reaches p. | |
| The shift below is what guarantees the top-1 token always survives: a | |
| token is removed only if the mass *strictly before* it already reached p, | |
| so even a single token with probability > p is kept. | |
| """ | |
| if p is None or p >= 1.0: | |
| return logits | |
| srt, idx = logits.sort(descending=True, dim=-1) | |
| cum = srt.softmax(-1).cumsum(-1) | |
| remove = cum - srt.softmax(-1) >= p # mass strictly before token | |
| srt = srt.masked_fill(remove, float("-inf")) | |
| return srt.scatter(-1, idx, srt) | |
| def sample_decode(model, input_ids, attention_mask, max_new=64, eos_id=None, | |
| top_k=None, top_p=None, temperature=1.0, generator=None): | |
| """Ancestral sampling from a truncated, temperature-scaled distribution.""" | |
| B = input_ids.size(0) | |
| logits, past, pos = _initial_forward(model, input_ids, attention_mask) | |
| gen = [[] for _ in range(B)] | |
| done = torch.zeros(B, dtype=torch.bool, device=input_ids.device) | |
| scores = torch.zeros(B, device=input_ids.device) | |
| for _ in range(max_new): | |
| full_lp = F.log_softmax(logits, dim=-1) # score under the *model* | |
| z = logits / max(temperature, 1e-6) | |
| z = _top_k_filter(z, top_k) | |
| z = _top_p_filter(z, top_p) | |
| probs = z.softmax(-1) | |
| nxt = torch.multinomial(probs, 1, generator=generator).squeeze(1) | |
| scores += full_lp.gather(1, nxt.unsqueeze(1)).squeeze(1) * (~done) | |
| for i, t in enumerate(nxt.tolist()): | |
| if not done[i]: | |
| gen[i].append(t) | |
| if eos_id is not None: | |
| done |= nxt == eos_id | |
| if bool(done.all()): | |
| break | |
| attention_mask = _grow_mask(attention_mask) | |
| pos = pos + 1 | |
| logits, past = _step(model, nxt, attention_mask, past, pos) | |
| return _finish(gen, eos_id, input_ids.size(1)), scores.tolist() | |
| # --------------------------------------------------------------------------- | |
| # 4. Beam search | |
| # --------------------------------------------------------------------------- | |
| def beam_search(model, input_ids, attention_mask, beam_width=4, max_new=64, | |
| eos_id=None, length_penalty=1.0): | |
| """Keep the ``beam_width`` highest-scoring prefixes at every step. | |
| Cost per step is ``O(W * |V|)`` for the scoring plus a ``top-W`` over the | |
| ``W * |V|`` candidates, and the whole batch is run as one ``B*W`` forward, | |
| so wall time grows roughly linearly in W (memory does too -- the KV cache | |
| is W copies of the prompt). Finished hypotheses are moved aside and their | |
| beam slot is neutralised with a -inf mask so it cannot be extended again. | |
| Hypotheses are *selected* by summed log-probability divided by | |
| ``len ** length_penalty``; without that normalisation beam search | |
| systematically prefers short outputs, since every extra token adds a | |
| negative number. The score *returned* is the raw summed log-probability, | |
| so that it means the same thing as the score the other strategies return | |
| and the caller can derive a per-token figure the same way for all of them. | |
| """ | |
| B, L = input_ids.shape | |
| dev = input_ids.device | |
| W, V = beam_width, model.config.vocab_size | |
| logits, past, pos = _initial_forward(model, input_ids, attention_mask) | |
| lp = F.log_softmax(logits, dim=-1) # (B, V) | |
| # First expansion: from one prompt to W beams. | |
| top_lp, top_tok = lp.topk(W, dim=-1) # (B, W) | |
| beam_scores = top_lp.reshape(B * W) # (B*W,) | |
| beams = [[int(t)] for t in top_tok.reshape(-1).tolist()] | |
| past.batch_repeat_interleave(W) | |
| attention_mask = attention_mask.repeat_interleave(W, dim=0) | |
| pos = pos.repeat_interleave(W, dim=0) | |
| cur = top_tok.reshape(-1) | |
| # Each entry is (selection score, raw summed logprob, tokens). | |
| finished = [[] for _ in range(B)] | |
| alive = torch.ones(B * W, dtype=torch.bool, device=dev) | |
| for i in range(B * W): | |
| if eos_id is not None and beams[i][0] == eos_id: | |
| finished[i // W].append((beam_scores[i].item(), | |
| beam_scores[i].item(), [])) | |
| alive[i] = False | |
| for step in range(1, max_new): | |
| if not bool(alive.any()): | |
| break | |
| attention_mask = _grow_mask(attention_mask) | |
| pos = pos + 1 | |
| logits, past = _step(model, cur, attention_mask, past, pos) | |
| lp = F.log_softmax(logits, dim=-1) # (B*W, V) | |
| # A dead beam must not spawn children; -inf everywhere keeps it from | |
| # ever winning a top-W slot. | |
| lp = lp.masked_fill(~alive.unsqueeze(1), float("-inf")) | |
| cand = (beam_scores.unsqueeze(1) + lp).view(B, W * V) | |
| top_sc, top_ix = cand.topk(W, dim=-1) # (B, W) | |
| src_beam = top_ix // V # which beam | |
| tok = top_ix % V # which token | |
| flat_src = (torch.arange(B, device=dev).unsqueeze(1) * W | |
| + src_beam).reshape(-1) | |
| past.reorder_cache(flat_src) | |
| new_beams = [beams[i] + [int(t)] | |
| for i, t in zip(flat_src.tolist(), tok.reshape(-1).tolist())] | |
| beams = new_beams | |
| beam_scores = top_sc.reshape(-1) | |
| alive = alive[flat_src].clone() | |
| cur = tok.reshape(-1) | |
| if eos_id is not None: | |
| hit = (cur == eos_id) & alive | |
| for i in hit.nonzero(as_tuple=True)[0].tolist(): | |
| seq = beams[i][:-1] | |
| norm = len(seq) ** length_penalty if seq else 1.0 | |
| raw = beam_scores[i].item() | |
| finished[i // W].append((raw / norm, raw, seq)) | |
| alive[i] = False | |
| beam_scores[i] = float("-inf") | |
| # Anything still alive at the cap counts as a (length-normalised) candidate. | |
| for i in range(B * W): | |
| if alive[i]: | |
| seq = beams[i] | |
| norm = len(seq) ** length_penalty if seq else 1.0 | |
| raw = beam_scores[i].item() | |
| finished[i // W].append((raw / norm, raw, seq)) | |
| out, out_scores = [], [] | |
| for b in range(B): | |
| if finished[b]: | |
| _, raw, seq = max(finished[b], key=lambda t: t[0]) | |
| else: | |
| raw, seq = 0.0, [] | |
| out.append(seq) | |
| out_scores.append(raw) # raw sum, not the selection score | |
| return out, out_scores | |