Files
giant/giant/sample.py
T
lars 5c576fa8f3
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 1m2s
CI / Lint (ruff check) (pull_request) Successful in 1m6s
CI / Format (ruff format) (pull_request) Successful in 1m6s
CI / Tests (pull_request) Successful in 2m46s
CI / Bump version, tag, and update changelog on merge to master (pull_request) Has been skipped
CI / Publish package to Gitea package registry (pull_request) Has been skipped
CI / Update README badges (version, test count) (pull_request) Has been skipped
perf: compact Stage-2 AR inference loop to active rows only
sample_secondaries_ar ran all k_max=15 slots for every row regardless of
each row's own predicted secondary count, even though the baseline
checkpoint's rollout measured only 0.382 secondaries/step — so ~97% of
stage-2 model calls generated tokens sec_valid then masked away.

Compact the loop to the still-active row set at each slot: drop a row the
moment its n_sec_pred is exhausted (or, under n_sec.mode="stop_token", the
moment its own stop logit fires), so slot k's model calls cost O(active
rows) instead of O(B). Exact — rows are independent given their own
history — verified by comparing the compacted path against a new
full_length=True escape hatch that reproduces the original uncompacted
behavior bit-for-bit under deterministic noise.

full_length=True is required by
_assemble_stage2_ar_inputs_scheduled's scheduled-sampling self-sample,
whose training contract needs a real prediction at every slot up to
k_max regardless of a row's own count, so training behavior is
unchanged.

AttentionHistory's KV cache and MarkovHistory's O(1) state are kept
aligned to the shrinking active set via a new
HistoryEncoder.select_cache / Stage2Autoregressive.select_history_cache.

Also fixes a latent bug the refactor surfaced: derived_n_sec (stop-token
mode) could be overwritten by a later spurious re-fire of the stop logit
on a row that had already stopped; now tracked via an explicit `finished`
mask so only the first stop slot is recorded, matching the documented
contract.

No architecture or checkpoint-format change — every existing v0.3.0
Stage2Autoregressive checkpoint (flow/wgan, markov/attention,
head/stop_token) picks up the speedup automatically on its next
`giant rollout`/`giant predict`, no retraining needed.

Measured (CPU, hidden_dim=512/6 blocks, k_max=15, batch 512, mean
n_sec≈0.38 matching the baseline checkpoint's own rollout): 17.6-22.9x
fewer wall-clock seconds for the AR loop alone (attention/markov history
respectively). Directional only — baseline.toml's GPU inference-cost
comment is updated accordingly, flagged stale pending a real rollout
re-measurement via eval_cost_per_step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPt7bVLZYFJe5cG6V7ahqC
2026-09-03 17:56:59 +02:00

579 lines
25 KiB
Python

import torch
import torch.nn.functional as F
from giant.constants import CONT_SLOT_DIM, X_DIM
from giant.model.network import DdpmObjective, Stage2Autoregressive, build_objective, stage2_trunk_sec_dim
from giant.model.schedule import CosineSchedule
def _predict_n_sec_if_owned(
model: torch.nn.Module, cond_cont: torch.Tensor, cond_cat: torch.Tensor
) -> torch.Tensor | None:
"""Stage-1 `n_sec_head` is only present on a migrated v0.2 checkpoint
(fresh runs move it to stage 2 — see `Stage1Model`'s docstring). `None`
here means "ask stage 2 instead", which every caller (`giant/rollout.py`,
`giant/cli.py`) must do for a fresh checkpoint."""
if getattr(model, "n_sec_head", None) is None:
return None
logits = model.predict_n_sec(cond_cont, cond_cat)
return logits.argmax(dim=-1)
@torch.no_grad()
def sample_flow(
model: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
steps: int = 10,
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""Euler integration of the Stage-1 vector field from t=0 to t=1.
Returns (primary_sample, n_sec_pred):
primary_sample: (B, X_DIM) — normalised 9D primary post-step output
n_sec_pred: (B,) int64 — predicted secondary count, or `None` if
`model` has no `n_sec_head` (a fresh v0.3.0 Stage1Model — see
`_predict_n_sec_if_owned`).
"""
model.eval()
B = cond_cont.size(0)
device = cond_cont.device
x = torch.randn(B, X_DIM, device=device)
dt = 1.0 / steps
for i in range(steps):
t = torch.full((B,), i * dt, device=device)
v = model(x, cond_cont, cond_cat, t=t)
x = x + v * dt
return x, _predict_n_sec_if_owned(model, cond_cont, cond_cat)
@torch.no_grad()
def sample_ddpm(
model: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
schedule,
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""Full DDPM ancestral sampling (T reverse steps). Returns (sample, n_sec_pred)
— see `sample_flow`'s docstring for the `n_sec_pred` `None` case."""
model.eval()
B = cond_cont.size(0)
device = cond_cont.device
x = torch.randn(B, X_DIM, device=device)
T = schedule.T
for i in reversed(range(T)):
t_norm = torch.full((B,), i / T, device=device)
eps_pred = model(x, cond_cont, cond_cat, t=t_norm)
beta = schedule.betas[i]
alpha = schedule.alphas[i]
alpha_bar = schedule.alpha_bars[i]
z = torch.randn_like(x) if i > 0 else torch.zeros_like(x)
x = (1.0 / alpha.sqrt()) * (x - (1.0 - alpha) / (1.0 - alpha_bar).sqrt() * eps_pred) + beta.sqrt() * z
return x, _predict_n_sec_if_owned(model, cond_cont, cond_cat)
@torch.no_grad()
def sample_ddim(
model: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
schedule,
steps: int = 50,
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""DDIM deterministic sampling (Song et al. 2020). Returns (sample, n_sec_pred)
— see `sample_flow`'s docstring for the `n_sec_pred` `None` case."""
model.eval()
B = cond_cont.size(0)
device = cond_cont.device
T = schedule.T
timesteps = torch.linspace(T - 1, 0, steps, dtype=torch.long, device=device)
x = torch.randn(B, X_DIM, device=device)
for step_idx, ts in enumerate(timesteps):
t_idx = int(ts.item())
t_norm = torch.full((B,), t_idx / T, device=device)
eps_pred = model(x, cond_cont, cond_cat, t=t_norm)
ab_t = schedule.alpha_bars[t_idx]
if step_idx + 1 < len(timesteps):
ab_prev = schedule.alpha_bars[int(timesteps[step_idx + 1].item())]
else:
ab_prev = torch.ones(1, device=device)
x0_pred = (x - (1.0 - ab_t).sqrt() * eps_pred) / ab_t.sqrt()
x = ab_prev.sqrt() * x0_pred + (1.0 - ab_prev).sqrt() * eps_pred
return x, _predict_n_sec_if_owned(model, cond_cont, cond_cat)
@torch.no_grad()
def sample_wgan(
generator: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""Single-pass Stage-1 WGAN generator sample. Returns (sample, n_sec_pred)
— see `sample_flow`'s docstring for the `n_sec_pred` `None` case."""
generator.eval()
B = cond_cont.size(0)
z = torch.randn(B, generator.noise_dim, device=cond_cont.device)
x = generator(z, cond_cont, cond_cat)
return x, _predict_n_sec_if_owned(generator, cond_cont, cond_cat)
def _stage2_flat_width(sec_decoder: torch.nn.Module) -> int:
"""The width of `sec_decoder`'s own trunk in/out vector — folded
(continuous + type) under `particle_type.target = "physical"` or
`generator = "wgan"`, continuous-only otherwise (the type slice then
comes from `predict_type` instead — see `stage2_trunk_sec_dim`'s
docstring)."""
return stage2_trunk_sec_dim(
sec_decoder.particle_type_cfg,
sec_decoder.generator_kind,
sec_decoder.k_max,
sec_decoder.type_dim,
)
def _type_folded(sec_decoder: torch.nn.Module) -> bool:
target = sec_decoder.particle_type_cfg.target
return target == "physical" or build_objective(sec_decoder.generator_kind).folds_type_slice
def _decode_stage2_flat(
sec_decoder: torch.nn.Module,
x: torch.Tensor,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_out: torch.Tensor,
n_sec_pred: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Reshape a flat `(B, flat_width)` `Stage2OneShot` output into per-slot
tensors, generator/`particle_type.target`-agnostic: shared by
`sample_secondaries`/`sample_secondaries_wgan`, which differ only in how
`x` was produced.
Returns (sec_cont, sec_type, sec_valid):
sec_cont: (B, k_max, CONT_SLOT_DIM) — [stick_logit, local_dir]
sec_type: (B, k_max, type_dim) — under `target="physical"` this is
[log_mass, charge] (normalised iff the checkpoint's sec_phys
normalizer was applied at training time — denormalize before
treating as physical units; see
giant.data.transforms.decode_secondaries); under `"onehot"` /
`"embedding"` it is raw class logits / an embedding-space vector
— decode via giant.particles.decode_topn_class /
decode_embedding_nearest (see giant/rollout.py).
sec_valid: (B, k_max) bool — True for slots i < n_sec_pred
"""
B = x.size(0)
device = x.device
k_max = sec_decoder.k_max
type_dim = sec_decoder.type_dim
if _type_folded(sec_decoder):
x_slots = x.view(B, k_max, CONT_SLOT_DIM + type_dim)
sec_cont = x_slots[:, :, :CONT_SLOT_DIM]
sec_type = x_slots[:, :, CONT_SLOT_DIM:]
else:
sec_cont = x.view(B, k_max, CONT_SLOT_DIM)
sec_type = sec_decoder.predict_type(cond_cont, cond_cat, stage1_out)
sec_valid = torch.arange(k_max, device=device).unsqueeze(0) < n_sec_pred.unsqueeze(1)
return sec_cont, sec_type, sec_valid
@torch.no_grad()
def sample_secondaries(
sec_decoder: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_out: torch.Tensor,
n_sec_pred: torch.Tensor,
steps: int = 10,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Euler integration of `Stage2OneShot`'s (flow/ddpm) vector field; return
raw slot outputs — see `_decode_stage2_flat`'s docstring for the returned
(sec_cont, sec_type, sec_valid) shapes/meaning.
n_sec_pred: (B,) int64 — number of valid secondaries per step
"""
sec_decoder.eval()
B = cond_cont.size(0)
device = cond_cont.device
flat_width = _stage2_flat_width(sec_decoder)
x = torch.randn(B, flat_width, device=device)
dt = 1.0 / steps
for i in range(steps):
t = torch.full((B,), i * dt, device=device)
v = sec_decoder(x, cond_cont, cond_cat, stage1_out, t=t)
x = x + v * dt
return _decode_stage2_flat(sec_decoder, x, cond_cont, cond_cat, stage1_out, n_sec_pred)
@torch.no_grad()
def sample_secondaries_wgan(
sec_decoder: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_out: torch.Tensor,
n_sec_pred: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Single-pass `Stage2OneShot` WGAN generator sample; see
`_decode_stage2_flat`'s docstring for the returned (sec_cont, sec_type,
sec_valid) shapes/meaning."""
sec_decoder.eval()
B = cond_cont.size(0)
z = torch.randn(B, sec_decoder.noise_dim, device=cond_cont.device)
x = sec_decoder(z, cond_cont, cond_cat, stage1_out)
return _decode_stage2_flat(sec_decoder, x, cond_cont, cond_cat, stage1_out, n_sec_pred)
@torch.no_grad()
def sample_secondaries_ar(
sec_decoder: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_out: torch.Tensor,
n_sec_pred: torch.Tensor | None,
steps: int = 10,
full_length: bool = False,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""`Stage2Autoregressive` inference loop: one token at a time, in
descending-energy slot order, up to `k_max` sequential calls. Unlike
training (teacher forcing — a single parallel pass over ground-truth
tokens, see `giant.training.stage2_inputs._assemble_stage2_ar_inputs`),
there is no ground truth at inference: each token's conditioning is built
free-running, from the PREVIOUS TOKEN'S OWN just-generated output — the
train/inference gap that is the cost of markov history's
expressiveness.
A `{flow,ddpm}` token costs `steps` ODE substeps; `wgan` costs one pass —
the "K sequential forwards" cost applies per-token here, not once, so a
flow/ddpm AR run costs ~`n_sec * steps` model calls per physics step
(measured on `configs/baseline.toml`: 0.382 secondaries/step at rollout
time), not `k_max * steps` — see the row-compaction paragraph below.
`n_sec_pred`, if given (as resolved by `resolve_n_sec` — `n_sec.mode` in
`("head", "truth")`, or a stop-token decoder driven by
`_assemble_stage2_ar_inputs_scheduled`'s ground-truth `n_sec`, which must
run the *full* `k_max`-length free-running self-sample regardless of the
decoder's own stop head — the scheduled-sampling training contract does
not truncate, see `full_length` below) fixes each row's secondary count
up front.
`n_sec_pred=None` is only valid when `sec_decoder.stop_head` is set
(`n_sec.mode = "stop_token"`): before generating each slot's token, that
slot's own stop logit (`predict_stop`, evaluated on the same prefix
conditioning as the token itself — see `predict_type`'s docstring for
why this needs no extra state) decides whether generation should have
already stopped, per `sec_decoder.n_sec_sampling` ("greedy": threshold at
0; "sample": a Bernoulli draw at `sigmoid(logit)`). A row's own
`n_sec_pred` is the first slot index where this fires. A row that never
fires within `k_max` is capped there (`K_MAX` stays a safety cap, not a
modeling ceiling).
**Row compaction.** A row that has already produced its `n_sec_pred`
tokens (or, under `stop_token`, has already fired its stop logit) has
nothing left to contribute — every later slot of that row is masked out
of `sec_valid` on return, and downstream consumers (`giant/rollout.py`,
`giant/cli.py`) never read it. So unless `full_length=True`, this
function drops such rows from the active set entirely instead of running
the model on them: `active_idx` starts at every row with `n_sec_pred > 0`
(or, under `stop_token`, every row — the first stop decision can fire at
slot 0) and only shrinks as rows finish, so slot `k`'s model calls cost
`O(active rows)` not `O(B)`. Slots a row never reaches keep their `0.0`
zero-init in `sec_cont`/`sec_type` — masked by `sec_valid`, identical to
what a full, uncompacted run would have written there before masking.
`AttentionHistory`'s KV cache is kept aligned to the shrinking active set
via `Stage2Autoregressive.select_history_cache`
(`giant.model.history.HistoryEncoder.select_cache`) every time the set
shrinks; `MarkovHistory`'s O(1) state (`prev_repr`/`remaining`, carried
directly rather than through a cache) is compacted the same way.
`full_length=True` disables all of the above: every row runs the full
`k_max`-iteration loop regardless of `n_sec_pred`/stop decisions, exactly
reproducing the pre-compaction behaviour. Required by
`_assemble_stage2_ar_inputs_scheduled`'s scheduled-sampling self-sample,
whose training contract needs a real prediction at every slot up to
`k_max` (mixed per-slot against ground truth) even past a row's own
`n_sec` — see that function's docstring.
Under `history="attention"` the history encoding is computed once per
slot via `Stage2Autoregressive.history_step` (a KV-cache append)
rather than re-derived by every model call inside that slot — so an ODE
loop's `steps` substeps, and the separate `predict_type` call when the
type slice isn't folded into the trunk output, all reuse the SAME `hist`
tensor for a given `k`. Recomputing per call instead would be merely
wasteful under markov (its per-call cost is already O(1)) but wrong under
attention: `AttentionHistory.step` mutates the cache by appending, so
calling it more than once per slot would double-count that slot's own
(not-yet-existing) predecessor.
The free-running history feature stays UNSNAPPED (mirrors the
established "no snapping" precedent for `particle_type.target =
"physical"` secondaries feeding their own future conditioning):
`"physical"` carries the raw (log_mass, charge) forward as-is;
`"embedding"` carries the raw predicted vector as-is; `"onehot"` is the
one exception — its history slot must be a probability-simplex-shaped
vector (that's what `MarkovHistory`/`AttentionHistory` were trained on,
`_type_repr`'s `F.one_hot` ground truth), so it's the hard one-hot of
`argmax(logits)`, not the raw logits themselves. Discretizing further,
into a concrete PDG code, only ever happens once — at secondary-spawn
time in `giant/rollout.py` — never inside this loop.
Returns (sec_cont, sec_type, sec_valid) — same shapes/meaning as
`sample_secondaries`/`sample_secondaries_wgan`'s (see
`_decode_stage2_flat`'s docstring); `sec_type` is raw per-slot output in
all three `particle_type.target` cases (never one-hot-collapsed), so the
caller decodes it exactly the same way regardless of which decoder
produced it.
"""
sec_decoder.eval()
B = cond_cont.size(0)
device = cond_cont.device
k_max = sec_decoder.k_max
type_dim = sec_decoder.type_dim
objective = build_objective(sec_decoder.generator_kind)
target = sec_decoder.particle_type_cfg.target
type_folded = _type_folded(sec_decoder)
token_dim = CONT_SLOT_DIM + type_dim if type_folded else CONT_SLOT_DIM
sec_cont = torch.zeros(B, k_max, CONT_SLOT_DIM, device=device)
sec_type = torch.zeros(B, k_max, type_dim, device=device)
use_stop_token = n_sec_pred is None
if use_stop_token:
assert getattr(sec_decoder, "stop_head", None) is not None, (
"sample_secondaries_ar called with n_sec_pred=None on a decoder "
"with no stop_head — only valid under stage2_model.n_sec.mode = "
"'stop_token'"
)
derived_n_sec = torch.full((B,), k_max, dtype=torch.long, device=device)
# Tracks which rows have already recorded a stop, globally by
# original batch index — needed even under compaction's own
# never-revisit guarantee, because `full_length=True` keeps every
# row in `active_idx` for the whole loop, so a row whose stop logit
# fires once but flips back below threshold at a later slot (a real
# possibility for an untrained/lightly-trained stop_head) must not
# have `derived_n_sec` overwritten by that later, spurious re-fire.
finished = torch.zeros(B, dtype=torch.bool, device=device)
# `active_idx`: rows still contributing tokens, indexed into the
# original batch. Only ever shrinks (never full_length) or stays fixed
# at arange(B) (full_length) — see the row-compaction docstring section.
active_idx = torch.arange(B, device=device)
if not full_length and not use_stop_token:
active_idx = active_idx[n_sec_pred > 0]
# Running per-token state, already compacted to `active_idx`.
prev_repr = torch.zeros(active_idx.numel(), CONT_SLOT_DIM + type_dim, device=device)
remaining = torch.ones(active_idx.numel(), device=device)
history_cache = sec_decoder.init_history_cache()
for k in range(k_max):
if active_idx.numel() == 0:
break
Bc = active_idx.numel()
cc = cond_cont.index_select(0, active_idx)
ck = cond_cat.index_select(0, active_idx)
s1 = stage1_out.index_select(0, active_idx)
has_prev = torch.full((Bc, 1), k >= 1, dtype=torch.bool, device=device)
history_feat = prev_repr.unsqueeze(1) # (Bc, 1, CONT_SLOT_DIM + type_dim)
remaining_frac = remaining.unsqueeze(1) # (Bc, 1)
slot_idx = torch.full((Bc, 1), k / max(k_max - 1, 1), device=device, dtype=torch.float32)
hist, history_cache = sec_decoder.history_step(history_feat, has_prev, history_cache)
if use_stop_token:
stop_logit = sec_decoder.predict_stop(
cc,
ck,
s1,
history_feat,
has_prev,
remaining_frac,
slot_idx,
hist=hist,
).squeeze(1)
if sec_decoder.n_sec_sampling == "sample":
stop_now = torch.rand(Bc, device=device) < torch.sigmoid(stop_logit)
else:
stop_now = stop_logit >= 0.0
newly_stopped = stop_now & ~finished.index_select(0, active_idx)
derived_n_sec[active_idx[newly_stopped]] = k
finished[active_idx[stop_now]] = True
if not full_length:
keep = ~stop_now
active_idx = active_idx[keep]
cc, ck, s1 = cc[keep], ck[keep], s1[keep]
has_prev, remaining_frac, slot_idx = has_prev[keep], remaining_frac[keep], slot_idx[keep]
history_feat, hist = history_feat[keep], hist[keep]
history_cache = sec_decoder.select_history_cache(history_cache, keep)
prev_repr, remaining = prev_repr[keep], remaining[keep]
if active_idx.numel() == 0:
break
Bc = active_idx.numel()
if objective.is_adversarial:
z = torch.randn(Bc, 1, sec_decoder.noise_dim, device=device)
token = sec_decoder(
z,
cc,
ck,
s1,
history_feat,
has_prev,
remaining_frac,
slot_idx,
hist=hist,
)
else:
x = torch.randn(Bc, 1, token_dim, device=device)
dt = 1.0 / steps
for i in range(steps):
t = torch.full((Bc, 1), i * dt, device=device)
v = sec_decoder(
x,
cc,
ck,
s1,
history_feat,
has_prev,
remaining_frac,
slot_idx,
t=t,
hist=hist,
)
x = x + v * dt
token = x
token = token.squeeze(1) # (Bc, token_dim)
cont_k = token[:, :CONT_SLOT_DIM]
if type_folded:
type_k = token[:, CONT_SLOT_DIM:]
else:
type_k = sec_decoder.predict_type(
cc,
ck,
s1,
history_feat,
has_prev,
remaining_frac,
slot_idx,
hist=hist,
).squeeze(1)
sec_cont[active_idx, k] = cont_k
sec_type[active_idx, k] = type_k
if target == "onehot":
type_for_history = F.one_hot(type_k.argmax(dim=-1), num_classes=type_dim).float()
else:
type_for_history = type_k
stick_fraction = torch.sigmoid(cont_k[:, 0])
prev_repr = torch.cat([stick_fraction.unsqueeze(-1), cont_k[:, 1:4], type_for_history], dim=-1)
remaining = torch.clamp(remaining * (1.0 - stick_fraction), min=0.0)
if not full_length and not use_stop_token:
keep2 = n_sec_pred.index_select(0, active_idx) > (k + 1)
active_idx = active_idx[keep2]
prev_repr, remaining = prev_repr[keep2], remaining[keep2]
history_cache = sec_decoder.select_history_cache(history_cache, keep2)
resolved_n_sec = derived_n_sec if use_stop_token else n_sec_pred
sec_valid = torch.arange(k_max, device=device).unsqueeze(0) < resolved_n_sec.unsqueeze(1)
return sec_cont, sec_type, sec_valid
# ---------------------------------------------------------------------------
# Per-stage dispatch — shared by giant/rollout.py and giant/cli.py's
# `predict` command, since both need "given a stage model, produce a
# sample" without hand-picking the sampler themselves (each stage's
# generative objective is independent, read off the model's own
# `generator_kind`, not a caller-supplied `mode` string).
# ---------------------------------------------------------------------------
def sample_stage1(
stage1_model: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
steps: int,
ddpm_steps: int = 1000,
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""Dispatches on `stage1_model.generator_kind`."""
objective = build_objective(stage1_model.generator_kind)
if objective.is_adversarial:
return sample_wgan(stage1_model, cond_cont, cond_cat)
if isinstance(objective, DdpmObjective):
schedule = CosineSchedule(T=ddpm_steps).to(cond_cont.device)
return sample_ddpm(stage1_model, cond_cont, cond_cat, schedule)
return sample_flow(stage1_model, cond_cont, cond_cat, steps=steps)
def sample_stage2(
sec_decoder: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_out: torch.Tensor,
n_sec_pred: torch.Tensor | None,
steps: int,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Dispatches on `decoder` (one-shot vs autoregressive — the class
itself, via `isinstance`) and `sec_decoder.generator_kind` (flow/ddpm/
wgan). DDPM secondaries aren't supported — no `Stage2*` class was ever
built with `generator="ddpm"` in practice and `flow_matching_loss_secondary*`
is the only stage-2 training path that exists for the non-adversarial
case, so there's nothing to dispatch to here.
`n_sec_pred=None` (from `resolve_n_sec` on a stop-token decoder) is only
meaningful for the autoregressive path — see `sample_secondaries_ar`'s
docstring; the one-shot samplers have no per-token stop mechanism to
derive a count from, so `n_sec_pred` must already be resolved for them.
"""
if isinstance(sec_decoder, Stage2Autoregressive):
return sample_secondaries_ar(sec_decoder, cond_cont, cond_cat, stage1_out, n_sec_pred, steps=steps)
assert n_sec_pred is not None, "one-shot stage-2 decoders need a resolved n_sec_pred"
if build_objective(sec_decoder.generator_kind).is_adversarial:
return sample_secondaries_wgan(sec_decoder, cond_cont, cond_cat, stage1_out, n_sec_pred)
return sample_secondaries(sec_decoder, cond_cont, cond_cat, stage1_out, n_sec_pred, steps=steps)
def resolve_n_sec(
stage1_model: torch.nn.Module,
sec_decoder: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_out: torch.Tensor,
n_sec_pred: torch.Tensor | None,
) -> torch.Tensor | None:
"""`n_sec_pred` is already populated when `stage1_model` owns a legacy
`n_sec_head` (a migrated v0.2 checkpoint — see `Stage1Model`'s
docstring); otherwise ask stage 2, which owns it by default.
Returns `None` when `sec_decoder` owns a `stop_head` (`n_sec.mode =
"stop_token"`) instead of an `n_sec_head` — there is nothing to resolve
up front in that case, since the count only exists once
`sample_secondaries_ar` has actually generated (or stopped generating)
tokens; the caller passes this `None` straight through to `sample_stage2`
and reads the real count back off its returned `sec_valid`
(`sec_valid.sum(-1)`) afterwards.
Raises if neither stage owns any n_sec mechanism at all — the only way
that happens is `stage2_model.n_sec.mode = "truth"`, which is not a valid
rollout-/predict-capable checkpoint.
`n_sec.mode = "head"` resolves the classifier logits per
`sec_decoder.n_sec_sampling`: "greedy" (default) takes the conditional
mode via argmax; "sample" draws a real sample from the learned count
distribution via `torch.multinomial` on the softmax — see gitea #86."""
if n_sec_pred is not None:
return n_sec_pred
if getattr(sec_decoder, "stop_head", None) is not None:
return None
if getattr(sec_decoder, "n_sec_head", None) is None:
raise RuntimeError(
"checkpoint has no n_sec_head/stop_head on either stage — needs "
"stage2_model.n_sec.mode = 'head' (the default) or 'stop_token'; "
"'truth' is standalone-evaluation-only"
)
logits = sec_decoder.predict_n_sec(cond_cont, cond_cat, stage1_out)
if sec_decoder.n_sec_sampling == "sample":
return torch.multinomial(logits.softmax(dim=-1), 1).squeeze(-1)
return logits.argmax(dim=-1)