v0.3.0 step 7: AttentionHistory (KV-cached) + scheduled/never teacher forcing

AttentionHistory (giant/model/network.py) adds causal self-attention over
the emitted-secondary prefix as the alternative to MarkovHistory, with a
parallel forward() for training and an init_cache()/step() KV-cache path
for sample.py's per-slot AR inference loop, wired into
Stage2Autoregressive via history="attention".

giant/train.py adds _stage2_tf_prob and _assemble_stage2_ar_inputs_scheduled,
mixing ground-truth history with a detached sample_secondaries_ar self-sample
per slot so teacher_forcing="scheduled"/"never" close the train/inference gap
teacher_forcing="always" always avoided; wired into both stage-2 AR trainers.

config.py's validate_config no longer rejects these two previously
unimplemented schema values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 13:15:56 +02:00
parent 93b19911f8
commit 200c6d243b
8 changed files with 654 additions and 53 deletions
+6 -7
View File
@@ -735,18 +735,17 @@ def validate_config(cfg: dict) -> None:
if _get_path(cfg, "stage2_model.decoder") == "autoregressive":
history = _get_path(cfg, "stage2_model.autoregressive.history")
if history != "markov":
if history not in ("markov", "attention"):
raise ValueError(
f"stage2_model.autoregressive.history = {history!r} is "
"accepted by the schema but not implemented until v0.3.0 "
"step 7 — use 'markov'"
f"stage2_model.autoregressive.history = {history!r} — must "
"be 'markov' or 'attention'"
)
teacher_forcing = _get_path(cfg, "stage2_model.autoregressive.teacher_forcing")
if teacher_forcing != "always":
if teacher_forcing not in ("always", "scheduled", "never"):
raise ValueError(
"stage2_model.autoregressive.teacher_forcing = "
f"{teacher_forcing!r} is accepted by the schema but not "
"implemented until v0.3.0 step 7 — use 'always'"
f"{teacher_forcing!r} — must be 'always', 'scheduled' or "
"'never'"
)
+188 -11
View File
@@ -789,9 +789,16 @@ def build_trunk(
class HistoryEncoder(nn.Module):
"""Interface for stage-2 autoregressive per-token history summaries:
`forward(feat, has_prev) -> (B, K, out_dim)`. `MarkovHistory` is the only
implementation until v0.3.0 step 7 (`AttentionHistory`,
`history = "attention"`)."""
`forward(feat, has_prev) -> (B, K, out_dim)`, a single parallel pass over
a full (teacher-forced) token sequence — used by training. `MarkovHistory`
and `AttentionHistory` (docs/v0.3.0-design.md §6.2) are the two
implementations. Inference (`giant/sample.py`) generates one token at a
time and cannot afford `forward`'s per-step cost to be O(K) (attention
would then be O(K^2) over a rollout's k_max loop); encoders that need
incremental state for that path additionally implement `init_cache`/
`step` (see `AttentionHistory`) — `MarkovHistory` doesn't need to, since
its per-step cost is already O(1) (it only ever looks at the previous
token, not the full prefix)."""
def forward(self, feat: torch.Tensor, has_prev: torch.Tensor) -> torch.Tensor:
raise NotImplementedError
@@ -819,6 +826,126 @@ class MarkovHistory(HistoryEncoder):
return self.mlp(x)
class _CausalAttnBlock(nn.Module):
"""One pre-norm causal self-attention block for `AttentionHistory`.
Exposes two forward paths that must agree (see
`test_attention_history_step_matches_forward` in `tests/test_network.py`):
`forward` — the full-sequence, causally-masked pass used for training;
`step` — an incremental pass for inference, given the *pre-attention*
normalized hidden states of every earlier position (`kv_cache`, i.e.
`norm1(x)` for positions `< t`, not `x` itself). Caching `norm1(x)` rather
than raw `x` is what makes `step` correct: this block's attention needs
exactly that quantity as keys/values, and `LayerNorm` has no cross-position
interaction, so recomputing it per position instead of caching it would
still be correct but pointlessly repeat work. The *next* block's cache is
built from a different sequence (this block's output), so each block owns
an independent cache entry.
"""
def __init__(self, dim: int, n_heads: int, dropout: float = 0.0) -> None:
super().__init__()
self.norm1 = nn.LayerNorm(dim)
self.attn = nn.MultiheadAttention(
dim, n_heads, dropout=dropout, batch_first=True
)
self.norm2 = nn.LayerNorm(dim)
self.mlp = nn.Sequential(
nn.Linear(dim, 4 * dim), nn.GELU(), nn.Linear(4 * dim, dim)
)
def forward(self, x: torch.Tensor, causal_mask: torch.Tensor) -> torch.Tensor:
h = self.norm1(x)
attn_out, _ = self.attn(h, h, h, attn_mask=causal_mask, need_weights=False)
x = x + attn_out
x = x + self.mlp(self.norm2(x))
return x
def step(
self, x_new: torch.Tensor, kv_cache: torch.Tensor | None
) -> tuple[torch.Tensor, torch.Tensor]:
"""`x_new`: `(B, 1, dim)`, this position's input. `kv_cache`: `None`
(first position) or `(B, T, dim)` — `norm1(x)` of every earlier
position at this same block. Returns `(out, new_kv_cache)`, `out`
being this position's block output (`(B, 1, dim)`, to feed the next
block's `step`), `new_kv_cache` the same cache extended by this
position (to reuse at this block's *next* `step` call)."""
h_new = self.norm1(x_new)
kv = h_new if kv_cache is None else torch.cat([kv_cache, h_new], dim=1)
attn_out, _ = self.attn(h_new, kv, kv, need_weights=False)
x = x_new + attn_out
x = x + self.mlp(self.norm2(x))
return x, kv
class AttentionHistory(HistoryEncoder):
"""Causal self-attention over the emitted-token prefix
(docs/v0.3.0-design.md §6.2) — the more expressive alternative to
`MarkovHistory`'s fixed previous-token-only summary. `feat`/`has_prev`
follow the same shifted-by-one convention `MarkovHistory` and
`Stage2Autoregressive._token_cond` use: `feat[:, i]` is token `i - 1`'s
own `(energy_fraction, direction, type_representation)`, with a learned
start vector substituted at `has_prev == False` positions (only slot 0 in
practice — see `giant.train._ar_has_prev`). Causal masking then makes
position `i`'s output a function of `feat[:, 1:i+1]` — i.e. tokens
`0..i-1` — exactly the prefix available when predicting token `i`.
`forward` is the parallel training path (one pass over the whole
teacher-forced sequence); `init_cache`/`step` are the incremental
inference path `giant/sample.py` uses, one new token per call, to avoid
re-encoding the whole prefix from scratch every slot (docs/v0.3.0-design.md
§10's "KV cache" note) — `step` must be called exactly once per slot (its
cache-extension is not idempotent), so a slot's output must be reused for
every model call within that slot (`forward`'s ODE substeps, or a separate
`predict_type` call) rather than re-derived — see
`Stage2Autoregressive.history_step`.
"""
def __init__(
self, in_dim: int, out_dim: int, n_heads: int = 4, n_layers: int = 2
) -> None:
super().__init__()
self.start = nn.Parameter(torch.zeros(in_dim))
self.in_proj = nn.Linear(in_dim, out_dim)
self.blocks = nn.ModuleList(
[_CausalAttnBlock(out_dim, n_heads) for _ in range(n_layers)]
)
def _embed(self, feat: torch.Tensor, has_prev: torch.Tensor) -> torch.Tensor:
start = self.start.view(1, 1, -1).expand_as(feat)
x = torch.where(has_prev.unsqueeze(-1), feat, start)
return self.in_proj(x)
def forward(self, feat: torch.Tensor, has_prev: torch.Tensor) -> torch.Tensor:
B, K, _ = feat.shape
x = self._embed(feat, has_prev)
mask = nn.Transformer.generate_square_subsequent_mask(K, device=feat.device)
for block in self.blocks:
x = block(x, mask)
return x
def init_cache(self) -> list[torch.Tensor | None]:
return [None for _ in self.blocks]
def step(
self,
token_feat: torch.Tensor,
has_prev: torch.Tensor,
cache: list[torch.Tensor | None],
) -> tuple[torch.Tensor, list[torch.Tensor | None]]:
"""`token_feat`/`has_prev`: `(B, 1, in_dim)`/`(B, 1)` — the newest
token's own features (what would be `feat[:, k]` in `forward`).
Advances every block's cache by this position and returns this
position's output (`(B, 1, out_dim)`, the correct history summary for
the NEXT slot) plus the updated cache."""
x = self._embed(token_feat, has_prev)
new_cache: list[torch.Tensor | None] = []
for block, kv in zip(self.blocks, cache):
x, kv_new = block.step(x, kv)
new_cache.append(kv_new)
return x, new_cache
# ---------------------------------------------------------------------------
# Stage models (docs/v0.3.0-design.md §5.3)
# ---------------------------------------------------------------------------
@@ -1082,8 +1209,8 @@ class Stage2OneShot(nn.Module):
class Stage2Autoregressive(nn.Module):
"""Emits secondaries one at a time in descending-energy order
(docs/v0.3.0-design.md §6), instead of `Stage2OneShot`'s simultaneous
k_max-slot prediction. Only `history = "markov"` is implemented (v0.3.0
step 5) — `history = "attention"` raises immediately at construction.
k_max-slot prediction. `history` selects `MarkovHistory` or
`AttentionHistory` (`attn_n_heads`/`attn_n_layers`, attention only).
`teacher_forcing` handling lives entirely in the trainer
(`giant/train.py`), since it only affects how training inputs are
assembled, not this module's architecture.
@@ -1122,13 +1249,16 @@ class Stage2Autoregressive(nn.Module):
build_n_sec_head: bool = True,
particle_type_cfg: dict | None = None,
history: str = "markov",
attn_n_heads: int = 4,
attn_n_layers: int = 2,
) -> None:
super().__init__()
if history != "markov":
raise NotImplementedError(
f"stage2_model.autoregressive.history={history!r} is not "
"implemented until v0.3.0 step 7 — use 'markov'"
if history not in ("markov", "attention"):
raise ValueError(
f"stage2_model.autoregressive.history={history!r} — must be "
"'markov' or 'attention'"
)
self.history_kind = history
self.generator_kind = generator
self.noise_dim = noise_dim
self.k_max = k_max
@@ -1149,7 +1279,14 @@ class Stage2Autoregressive(nn.Module):
# width — there's no dedicated stage2_model.autoregressive key for
# this, a reasonable default rather than a design-doc-specified value.
history_dim = cond_out_dim
self.history_encoder = MarkovHistory(CONT_SLOT_DIM + self.type_dim, history_dim)
hist_in_dim = CONT_SLOT_DIM + self.type_dim
self.history_encoder: HistoryEncoder = (
AttentionHistory(
hist_in_dim, history_dim, n_heads=attn_n_heads, n_layers=attn_n_layers
)
if history == "attention"
else MarkovHistory(hist_in_dim, history_dim)
)
token_fuse_in = (
cond_out_dim + context_dim + history_dim + 2
) # +2: remaining_frac, slot_idx
@@ -1205,14 +1342,48 @@ class Stage2Autoregressive(nn.Module):
has_prev: torch.Tensor,
remaining_frac: torch.Tensor,
slot_idx: torch.Tensor,
hist: torch.Tensor | None = None,
) -> torch.Tensor:
"""`hist`, if given, overrides recomputing `self.history_encoder`
from `history_feat`/`has_prev` — the inference-time KV-cache path
(`Stage2Autoregressive.history_step`) precomputes it once per slot and
passes it in here so a slot's (possibly several) model calls — an ODE
loop's substeps, or a separate `predict_type` call — read the same
cached history instead of each re-deriving (and, under attention,
re-appending to the cache — see `AttentionHistory.step`'s docstring)."""
K = history_feat.size(1)
base = self.cond_enc(cond_cont, cond_cat).unsqueeze(1).expand(-1, K, -1)
ctx = self.context_adapter(stage1_out).unsqueeze(1).expand(-1, K, -1)
hist = self.history_encoder(history_feat, has_prev)
if hist is None:
hist = self.history_encoder(history_feat, has_prev)
scalars = torch.stack([remaining_frac, slot_idx], dim=-1)
return self.token_fuse(torch.cat([base, ctx, hist, scalars], dim=-1))
def init_history_cache(self):
"""Inference-only incremental-decoding state for `self.history_encoder`
(`giant/sample.py`'s AR loop): `None` under `history="markov"` (its
per-step cost is already O(1) — see `HistoryEncoder`'s docstring), or
`AttentionHistory.init_cache()` under `history="attention"`."""
if isinstance(self.history_encoder, AttentionHistory):
return self.history_encoder.init_cache()
return None
def history_step(
self, token_feat: torch.Tensor, has_prev: torch.Tensor, cache
) -> tuple[torch.Tensor, object]:
"""One inference slot's worth of history encoding: advances `cache`
(from `init_history_cache`, or a previous `history_step` call) by
`token_feat`/`has_prev` (`(B, 1, ...)` — the just-emitted previous
token, same convention `giant.sample.sample_secondaries_ar` already
threads as `prev_repr`), and returns `(hist, new_cache)` — `hist` is
this slot's history summary (pass it as `_token_cond`'s `hist=` to
every model call made for this slot), `new_cache` is what to pass into
the *next* slot's `history_step`. Must be called exactly once per
slot — see `AttentionHistory.step`'s docstring."""
if isinstance(self.history_encoder, AttentionHistory):
return self.history_encoder.step(token_feat, has_prev, cache)
return self.history_encoder(token_feat, has_prev), cache
def forward(
self,
x_t: torch.Tensor,
@@ -1224,6 +1395,7 @@ class Stage2Autoregressive(nn.Module):
remaining_frac: torch.Tensor,
slot_idx: torch.Tensor,
t: torch.Tensor | None = None,
hist: torch.Tensor | None = None,
) -> torch.Tensor:
B, K = x_t.shape[0], x_t.shape[1]
c_emb = self._token_cond(
@@ -1234,6 +1406,7 @@ class Stage2Autoregressive(nn.Module):
has_prev,
remaining_frac,
slot_idx,
hist=hist,
)
if self.time_emb is not None:
assert t is not None
@@ -1268,6 +1441,7 @@ class Stage2Autoregressive(nn.Module):
has_prev: torch.Tensor,
remaining_frac: torch.Tensor,
slot_idx: torch.Tensor,
hist: torch.Tensor | None = None,
) -> torch.Tensor:
if self.type_head is None:
raise RuntimeError(
@@ -1285,6 +1459,7 @@ class Stage2Autoregressive(nn.Module):
has_prev,
remaining_frac,
slot_idx,
hist=hist,
)
B, K, _ = c_emb.shape
return self.type_head(c_emb.reshape(B * K, -1)).view(B, K, self.type_dim)
@@ -1571,6 +1746,8 @@ def build_models(model_config: dict) -> dict[str, nn.Module | None]:
build_n_sec_head=legacy_owner != "stage1",
particle_type_cfg=particle_type_cfg,
history=ar_cfg.get("history", "markov"),
attn_n_heads=ar_cfg.get("attn_n_heads", 4),
attn_n_layers=ar_cfg.get("attn_n_layers", 2),
)
else:
sec_dim = stage2_trunk_sec_dim(
+23 -5
View File
@@ -256,17 +256,28 @@ def sample_secondaries_ar(
once, so a flow/ddpm AR run costs ~`k_max * steps` model calls per
physics step.
Under `history="attention"` the history encoding is computed once per
slot via `Stage2Autoregressive.history_step` (a KV-cache append, §10)
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` was 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.
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
@@ -291,6 +302,7 @@ def sample_secondaries_ar(
# Running per-token state, threaded from one slot to the next.
prev_repr = torch.zeros(B, CONT_SLOT_DIM + type_dim, device=device)
remaining = torch.ones(B, device=device)
history_cache = sec_decoder.init_history_cache()
for k in range(k_max):
has_prev = torch.full((B, 1), k >= 1, dtype=torch.bool, device=device)
@@ -299,6 +311,9 @@ def sample_secondaries_ar(
slot_idx = torch.full(
(B, 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 generator == "wgan":
z = torch.randn(B, 1, sec_decoder.noise_dim, device=device)
@@ -311,6 +326,7 @@ def sample_secondaries_ar(
has_prev,
remaining_frac,
slot_idx,
hist=hist,
)
else:
x = torch.randn(B, 1, token_dim, device=device)
@@ -327,6 +343,7 @@ def sample_secondaries_ar(
remaining_frac,
slot_idx,
t=t,
hist=hist,
)
x = x + v * dt
token = x
@@ -344,6 +361,7 @@ def sample_secondaries_ar(
has_prev,
remaining_frac,
slot_idx,
hist=hist,
).squeeze(1)
sec_cont[:, k] = cont_k
+195 -14
View File
@@ -27,6 +27,7 @@ from giant.model.schedule import (
flow_matching_loss_secondary_ar,
)
from giant.model.wgan import gradient_penalty, generator_loss
from giant.sample import sample_secondaries_ar
from giant.validate import validate_marginals
_CATCHABLE_SIGNALS = (signal.SIGINT, signal.SIGTERM)
@@ -246,6 +247,119 @@ def _assemble_stage2_ar_inputs(
}
def _stage2_tf_prob(
mode: str, p_start: float, p_end: float, epoch: int, total_epochs: int
) -> float:
"""P(condition slot k+1 on the TRUE token k rather than the model's own
prediction), for the current epoch (docs/v0.3.0-design.md §3.3
`stage2_model.autoregressive.teacher_forcing`). `"always"`/`"never"` are
the two degenerate constants; `"scheduled"` linearly interpolates
`p_start` (epoch 0) to `p_end` (the final epoch) standard scheduled
sampling (Bengio et al. 2015)."""
if mode == "always":
return 1.0
if mode == "never":
return 0.0
frac = epoch / max(total_epochs - 1, 1)
frac = min(max(frac, 0.0), 1.0)
return p_start + (p_end - p_start) * frac
def _history_repr_from_ar_sample(
sec_cont_pred: torch.Tensor,
sec_type_pred: torch.Tensor,
particle_type_cfg: dict,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""`(fraction, direction, type_repr)` — the same triple `_type_repr` /
`_stick_fraction` derive from ground truth, but from a free-running
`sample_secondaries_ar` self-sample instead, so the two can be mixed
slot-by-slot under scheduled sampling (`_assemble_stage2_ar_inputs_scheduled`).
`target="onehot"` collapses the raw per-slot type logits to a hard
one-hot of `argmax` `sample_secondaries_ar`'s own history convention
(see its docstring), matching what `MarkovHistory`/`AttentionHistory`
were trained on; the other two targets are already the right
representation."""
fraction = torch.sigmoid(sec_cont_pred[..., 0])
direction = sec_cont_pred[..., 1:4]
if particle_type_cfg.get("target", "physical") == "onehot":
type_dim = sec_type_pred.size(-1)
type_repr = F.one_hot(sec_type_pred.argmax(-1), num_classes=type_dim).float()
else:
type_repr = sec_type_pred
return fraction, direction, type_repr
def _assemble_stage2_ar_inputs_scheduled(
model: torch.nn.Module,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_ctx: torch.Tensor,
sec_cont: torch.Tensor,
sec_type_idx: torch.Tensor,
n_sec: torch.Tensor,
particle_type_cfg: dict,
cond_enc: torch.nn.Module,
emb_dim: int,
p_tf: float,
sample_steps: int,
) -> dict[str, torch.Tensor]:
"""Scheduled-sampling counterpart of `_assemble_stage2_ar_inputs`
(docs/v0.3.0-design.md §3.3 `teacher_forcing` = "scheduled"/"never"):
each slot's history is the TRUE previous token with probability `p_tf`
(an independent per-example, per-slot Bernoulli draw) and the model's own
free-running prediction otherwise closing the train/inference gap that
`teacher_forcing="always"` (ground truth throughout training) never sees.
`p_tf >= 1.0` degenerates exactly to `_assemble_stage2_ar_inputs` (and
skips self-sampling entirely), so callers can call this unconditionally.
The free-running estimate is a REAL autoregressive self-sample
`giant.sample.sample_secondaries_ar` under `torch.no_grad()` not a
cheap one-step proxy, so building it costs the same `k_max` (`* steps`
for flow) sequential forwards `sample.py` pays at inference, EVERY batch
this is called on (§6.4's cost note, paid at train time too whenever
teacher_forcing != "always"). Fully detached: gradient only ever flows
through the "real" target path each stage trainer already uses
(`_assemble_stage2_ar_target`), never through this self-sample.
"""
device = sec_cont.device
B, K = sec_cont.shape[0], sec_cont.shape[1]
if p_tf >= 1.0:
return _assemble_stage2_ar_inputs(
sec_cont, sec_type_idx, particle_type_cfg, cond_enc, emb_dim
)
was_training = model.training
sec_cont_pred, sec_type_pred, _ = sample_secondaries_ar(
model, cond_cont, cond_cat, stage1_ctx, n_sec, steps=sample_steps
)
if was_training:
model.train()
fraction_gt = _stick_fraction(sec_cont)
dir_gt = sec_cont[..., 1:4]
type_repr_gt = _type_repr(
sec_type_idx, sec_cont, particle_type_cfg, cond_enc, emb_dim
)
fraction_pred, dir_pred, type_repr_pred = _history_repr_from_ar_sample(
sec_cont_pred, sec_type_pred, particle_type_cfg
)
use_gt = torch.rand(B, K, device=device) < p_tf
fraction = torch.where(use_gt, fraction_gt, fraction_pred)
direction = torch.where(use_gt.unsqueeze(-1), dir_gt, dir_pred)
type_repr = torch.where(use_gt.unsqueeze(-1), type_repr_gt, type_repr_pred)
own_feat = torch.cat([fraction.unsqueeze(-1), direction, type_repr], dim=-1)
return {
"history_feat": _shift_prev(own_feat),
"has_prev": _ar_has_prev(K, device).expand(B, -1),
"remaining_frac": _remaining_energy_fraction(fraction),
"slot_idx": (torch.arange(K, device=device).float() / max(K - 1, 1))
.unsqueeze(0)
.expand(B, -1),
}
def _relax_onehot_type_slice(
x_flat: torch.Tensor,
k_max: int,
@@ -362,6 +476,10 @@ class FlowDDPMStageTrainer(StageTrainer):
particle_type_cfg: dict | None = None,
particle_type_emb_dim: int = 16,
decoder: str = "one_shot",
teacher_forcing: str = "always",
tf_p_start: float = 1.0,
tf_p_end: float = 1.0,
ar_sample_steps: int = 10,
) -> None:
if is_stage2 and generator not in ("flow",):
raise NotImplementedError(
@@ -373,6 +491,12 @@ class FlowDDPMStageTrainer(StageTrainer):
self.is_stage2 = is_stage2
self.generator = generator
self.decoder = decoder
self.teacher_forcing = teacher_forcing
self.tf_p_start = tf_p_start
self.tf_p_end = tf_p_end
self.ar_sample_steps = ar_sample_steps
self.total_epochs = epochs
self.steps_per_epoch = max(steps_per_epoch, 1)
self.device = device
self.model = model.to(device)
self.lambda_weight = lambda_weight
@@ -513,7 +637,13 @@ class FlowDDPMStageTrainer(StageTrainer):
l_type = (se * mask).sum() / denom
return l_type, type_acc
def _compute(self, batch: tuple, device: torch.device) -> dict:
def _compute(
self, batch: tuple, device: torch.device, epoch: int | None = None
) -> dict:
"""`epoch=None` (the `val_loss` path) always uses full teacher
forcing (`p_tf=1.0`) regardless of `self.teacher_forcing` validation
should stay a stable, non-stochastic ground-truth comparison; only
the training `step` path schedules `p_tf` by epoch."""
(
cond_cont,
cond_cat,
@@ -529,12 +659,30 @@ class FlowDDPMStageTrainer(StageTrainer):
x1_s2 = None
ar_inputs = None
if self.is_stage2 and self.decoder == "autoregressive":
ar_inputs = _assemble_stage2_ar_inputs(
p_tf = (
1.0
if epoch is None
else _stage2_tf_prob(
self.teacher_forcing,
self.tf_p_start,
self.tf_p_end,
epoch,
self.total_epochs,
)
)
ar_inputs = _assemble_stage2_ar_inputs_scheduled(
self.model,
cond_cont,
cond_cat,
stage1_ctx,
sec_cont,
sec_type_idx,
n_sec,
self.particle_type_cfg,
self.model.cond_enc,
self.particle_type_emb_dim,
p_tf,
self.ar_sample_steps,
)
x1_s2 = _assemble_stage2_ar_target(
sec_cont,
@@ -613,7 +761,8 @@ class FlowDDPMStageTrainer(StageTrainer):
self.gumbel_tau_start,
self.gumbel_tau_end,
)
out = self._compute(batch, device)
epoch = global_step // self.steps_per_epoch
out = self._compute(batch, device, epoch=epoch)
self.optimizer.zero_grad()
out["total"].backward()
grad_norm = torch.nn.utils.clip_grad_norm_(self.params, 1.0)
@@ -719,10 +868,20 @@ class WGANStageTrainer(StageTrainer):
type_gumbel_tau_start: float = 1.0,
type_gumbel_tau_end: float = 0.1,
decoder: str = "one_shot",
teacher_forcing: str = "always",
tf_p_start: float = 1.0,
tf_p_end: float = 1.0,
ar_sample_steps: int = 10,
) -> None:
self.name = name
self.is_stage2 = is_stage2
self.decoder = decoder
self.teacher_forcing = teacher_forcing
self.tf_p_start = tf_p_start
self.tf_p_end = tf_p_end
self.ar_sample_steps = ar_sample_steps
self.total_epochs = epochs
self.steps_per_epoch = max(steps_per_epoch, 1)
self.device = device
self.model = model.to(device)
self.critic = critic.to(device)
@@ -810,12 +969,27 @@ class WGANStageTrainer(StageTrainer):
return self.critic(x, cond_cont, cond_cat, stage1_ctx)
if self.decoder == "autoregressive":
ar = _assemble_stage2_ar_inputs(
epoch = global_step // self.steps_per_epoch
p_tf = _stage2_tf_prob(
self.teacher_forcing,
self.tf_p_start,
self.tf_p_end,
epoch,
self.total_epochs,
)
ar = _assemble_stage2_ar_inputs_scheduled(
self.model,
cond_cont,
cond_cat,
stage1_ctx,
sec_cont,
sec_type_idx,
n_sec,
self.particle_type_cfg,
self.model.cond_enc,
self.particle_type_emb_dim,
p_tf,
self.ar_sample_steps,
)
real = (
_assemble_stage2_ar_target(
@@ -1051,16 +1225,15 @@ def _build_stage_trainers(
}
particle_type_emb_dim = cfg["conditioning"]["particle"]["emb_dim"]
decoder = stage_cfg.get("decoder", "one_shot") if is_stage2 else "one_shot"
if is_stage2 and decoder == "autoregressive":
teacher_forcing = (stage_cfg.get("autoregressive") or {}).get(
"teacher_forcing", "always"
)
if teacher_forcing != "always":
raise NotImplementedError(
"stage2_model.autoregressive.teacher_forcing="
f"{teacher_forcing!r} is not implemented until v0.3.0 "
"step 7 — use 'always'"
)
ar_cfg = (stage_cfg.get("autoregressive") or {}) if is_stage2 else {}
teacher_forcing = ar_cfg.get("teacher_forcing", "always")
tf_p_start = ar_cfg.get("tf_p_start", 1.0)
tf_p_end = ar_cfg.get("tf_p_end", 1.0)
# AR self-sampling under scheduled/never teacher forcing reuses
# train.validate_steps as its flow-matching ODE step count — no
# dedicated config key for this (docs/v0.3.0-design.md §3.3 lists
# tf_p_start/tf_p_end/attn_n_heads/attn_n_layers only).
ar_sample_steps = t.get("validate_steps", 10)
if generator == "wgan":
critic = critics.get(name)
@@ -1090,6 +1263,10 @@ def _build_stage_trainers(
type_gumbel_tau_start=wgan_cfg.get("gumbel_tau_start", 1.0),
type_gumbel_tau_end=wgan_cfg.get("gumbel_tau_end", 0.1),
decoder=decoder,
teacher_forcing=teacher_forcing,
tf_p_start=tf_p_start,
tf_p_end=tf_p_end,
ar_sample_steps=ar_sample_steps,
)
else:
ddpm_n_steps = stage_cfg.get("ddpm", {}).get("n_steps", 1000)
@@ -1116,6 +1293,10 @@ def _build_stage_trainers(
particle_type_cfg=particle_type_cfg,
particle_type_emb_dim=particle_type_emb_dim,
decoder=decoder,
teacher_forcing=teacher_forcing,
tf_p_start=tf_p_start,
tf_p_end=tf_p_end,
ar_sample_steps=ar_sample_steps,
)
return trainers
+29 -3
View File
@@ -1,6 +1,8 @@
from datetime import datetime
from pathlib import Path
import pytest
from giant import config as gconfig
_CONFIGS_DIR = Path(__file__).resolve().parents[1] / "configs"
@@ -645,13 +647,37 @@ def test_validate_config_ar_default_markov_always_passes():
gconfig.validate_config(cfg) # must not raise
def test_validate_config_ar_history_attention_not_implemented():
def test_validate_config_ar_history_attention_passes():
"""v0.3.0 step 7 implements history='attention' — must not raise."""
cfg = _cfg_with(
**{
"stage2_model.decoder": "autoregressive",
"stage2_model.autoregressive.history": "attention",
}
)
gconfig.validate_config(cfg) # must not raise
@pytest.mark.parametrize("teacher_forcing", ["scheduled", "never"])
def test_validate_config_ar_teacher_forcing_scheduled_or_never_passes(teacher_forcing):
"""v0.3.0 step 7 implements teacher_forcing in {'scheduled', 'never'} —
must not raise."""
cfg = _cfg_with(
**{
"stage2_model.decoder": "autoregressive",
"stage2_model.autoregressive.teacher_forcing": teacher_forcing,
}
)
gconfig.validate_config(cfg) # must not raise
def test_validate_config_ar_history_invalid_value_rejected():
cfg = _cfg_with(
**{
"stage2_model.decoder": "autoregressive",
"stage2_model.autoregressive.history": "bogus",
}
)
try:
gconfig.validate_config(cfg)
assert False, "expected ValueError"
@@ -659,11 +685,11 @@ def test_validate_config_ar_history_attention_not_implemented():
assert "history" in str(e)
def test_validate_config_ar_teacher_forcing_scheduled_not_implemented():
def test_validate_config_ar_teacher_forcing_invalid_value_rejected():
cfg = _cfg_with(
**{
"stage2_model.decoder": "autoregressive",
"stage2_model.autoregressive.teacher_forcing": "scheduled",
"stage2_model.autoregressive.teacher_forcing": "bogus",
}
)
try:
+115 -5
View File
@@ -2,6 +2,7 @@ import pytest
import torch
from giant.constants import CONT_SLOT_DIM, COND_DIM, PARTICLE_PHYS_DIM, SEC_SLOT_DIM
from giant.model.network import (
AttentionHistory,
ConditionEncoder,
MarkovHistory,
SinusoidalEmbedding,
@@ -323,6 +324,70 @@ def test_markov_history_uses_start_vector_when_no_prev():
assert torch.allclose(out_a[:, 1:], out_b[:, 1:])
# --- AttentionHistory (docs/v0.3.0-design.md §6.2, v0.3.0 step 7) ----------
def test_attention_history_shape():
hist = AttentionHistory(in_dim=7, out_dim=12, n_heads=2, n_layers=2)
B, K = 3, 5
feat = torch.randn(B, K, 7)
has_prev = (torch.arange(K) >= 1).unsqueeze(0).expand(B, -1)
out = hist(feat, has_prev)
assert out.shape == (B, K, 12)
def test_attention_history_uses_start_vector_when_no_prev():
hist = AttentionHistory(in_dim=4, out_dim=6, n_heads=2, n_layers=1)
B, K = 2, 3
has_prev = (torch.arange(K) >= 1).unsqueeze(0).expand(B, -1)
feat_a = torch.randn(B, K, 4)
feat_b = feat_a.clone()
feat_b[:, 0] = torch.randn(B, 4) * 100
out_a = hist(feat_a, has_prev)
out_b = hist(feat_b, has_prev)
assert torch.allclose(out_a[:, 0], out_b[:, 0], atol=1e-5)
def test_attention_history_is_causal():
"""Position i's output must not depend on feat at positions > i — unlike
MarkovHistory (which only ever looks at position i itself, already
trivially "causal"), this is AttentionHistory's actual contribution:
seeing the full prefix 0..i-1, never anything later."""
hist = AttentionHistory(in_dim=4, out_dim=6, n_heads=2, n_layers=2)
hist.eval()
B, K = 2, 5
has_prev = (torch.arange(K) >= 1).unsqueeze(0).expand(B, -1)
feat_a = torch.randn(B, K, 4)
feat_b = feat_a.clone()
feat_b[:, 3:] = torch.randn(B, K - 3, 4) * 100
with torch.no_grad():
out_a = hist(feat_a, has_prev)
out_b = hist(feat_b, has_prev)
assert torch.allclose(out_a[:, :3], out_b[:, :3], atol=1e-5)
def test_attention_history_step_matches_forward():
"""The incremental KV-cache path (`init_cache`/`step`,
`giant/sample.py`'s AR loop) must reproduce `forward`'s parallel-pass
output exactly, one position at a time."""
hist = AttentionHistory(in_dim=4, out_dim=6, n_heads=2, n_layers=2)
hist.eval()
B, K = 3, 6
has_prev = (torch.arange(K) >= 1).unsqueeze(0).expand(B, -1)
feat = torch.randn(B, K, 4)
with torch.no_grad():
expected = hist(feat, has_prev)
cache = hist.init_cache()
outs = []
for k in range(K):
out_k, cache = hist.step(feat[:, k : k + 1], has_prev[:, k : k + 1], cache)
outs.append(out_k)
stepped = torch.cat(outs, dim=1)
assert torch.allclose(stepped, expected, atol=1e-5)
# --- Stage2Autoregressive (docs/v0.3.0-design.md §6, v0.3.0 step 5) ---------
@@ -361,16 +426,19 @@ def _ar_inputs(B: int, K: int, hist_dim: int):
return history_feat, has_prev, remaining_frac, slot_idx
def test_stage2_autoregressive_history_attention_raises():
with pytest.raises(NotImplementedError):
_build_stage2_ar("onehot", "wgan", history="attention")
def test_stage2_autoregressive_history_invalid_raises():
with pytest.raises(ValueError):
_build_stage2_ar("onehot", "wgan", history="bogus")
@pytest.mark.parametrize("target", ["physical", "onehot", "embedding"])
@pytest.mark.parametrize("generator", ["wgan", "flow"])
def test_stage2_autoregressive_forward_shape(target, generator):
@pytest.mark.parametrize("history", ["markov", "attention"])
def test_stage2_autoregressive_forward_shape(target, generator, history):
B, K, emb_dim = 4, 5, 6
model = _build_stage2_ar(target, generator, emb_dim=emb_dim, k_max=K)
model = _build_stage2_ar(
target, generator, emb_dim=emb_dim, k_max=K, history=history
)
cond_cont = torch.randn(B, COND_DIM)
cond_cat = torch.zeros(B, 2, dtype=torch.long)
stage1_out = torch.randn(B, 9)
@@ -518,3 +586,45 @@ def test_stage2_autoregressive_gradients_flow_onehot():
(flow_out + nsec_out + type_out).backward()
for name, p in model.named_parameters():
assert p.grad is not None, f"no grad for {name}"
def test_stage2_autoregressive_history_step_matches_parallel_history_encoder():
"""`init_history_cache`/`history_step` (the incremental path
`giant/sample.py`'s AR loop drives, one slot per call) must reproduce
exactly what one parallel `self.history_encoder(history_feat, has_prev)`
call over the whole shifted sequence would give at each position the
KV-cache correctness guarantee, exercised through `Stage2Autoregressive`
itself rather than `AttentionHistory` in isolation
(`test_attention_history_step_matches_forward` covers that lower layer)."""
B, K, emb_dim = 3, 6, 6
model = _build_stage2_ar(
"physical", "wgan", emb_dim=emb_dim, k_max=K, history="attention"
)
model.eval()
type_dim = stage2_type_dim({"target": "physical"}, emb_dim)
hist_in_dim = CONT_SLOT_DIM + type_dim
own_feat = torch.randn(B, K, hist_in_dim) # token i's own raw feature
has_prev_full = (torch.arange(K) >= 1).unsqueeze(0).expand(B, -1)
history_feat = torch.cat(
[torch.zeros_like(own_feat[:, :1]), own_feat[:, :-1]], dim=1
)
with torch.no_grad():
expected = model.history_encoder(history_feat, has_prev_full)
cache = model.init_history_cache()
outs = []
prev = torch.zeros(B, 1, hist_in_dim)
for k in range(K):
has_prev_k = torch.full((B, 1), k >= 1, dtype=torch.bool)
hist_k, cache = model.history_step(prev, has_prev_k, cache)
outs.append(hist_k)
prev = own_feat[:, k : k + 1]
stepped = torch.cat(outs, dim=1)
assert torch.allclose(stepped, expected, atol=1e-5)
def test_stage2_autoregressive_init_history_cache_is_none_for_markov():
model = _build_stage2_ar("physical", "wgan", history="markov")
assert model.init_history_cache() is None
+9 -2
View File
@@ -76,6 +76,7 @@ def _stage2_ar(
pdg: int = 3,
mat: int = 2,
k_max: int = 5,
history: str = "markov",
) -> Stage2Autoregressive:
particle_cfg, material_cfg = _particle_material_cfg(
_conditioning_for(target), emb_dim
@@ -92,6 +93,9 @@ def _stage2_ar(
noise_dim=8,
k_max=k_max,
particle_type_cfg={"target": target},
history=history,
attn_n_heads=2,
attn_n_layers=1,
).eval()
@@ -187,11 +191,14 @@ def test_sample_secondaries_wgan_shapes_by_target(target):
# ── Stage2Autoregressive ─────────────────────────────────────────────────────
@pytest.mark.parametrize("history", ["markov", "attention"])
@pytest.mark.parametrize("generator", ["flow", "wgan"])
@pytest.mark.parametrize("target", ["physical", "onehot", "embedding"])
def test_sample_secondaries_ar_shapes(target, generator):
def test_sample_secondaries_ar_shapes(target, generator, history):
B, k_max, emb_dim = 4, 5, 6
decoder = _stage2_ar(target, generator, emb_dim=emb_dim, k_max=k_max)
decoder = _stage2_ar(
target, generator, emb_dim=emb_dim, k_max=k_max, history=history
)
cond_cont, cond_cat = _cond(B)
stage1_out = torch.randn(B, X_DIM)
n_sec_pred = torch.randint(0, k_max + 1, (B,))
+89 -6
View File
@@ -2,6 +2,7 @@
import copy
import csv
import math
import tempfile
from pathlib import Path
@@ -29,6 +30,7 @@ from giant.train import (
_relax_onehot_type_slice,
_remaining_energy_fraction,
_shift_prev,
_stage2_tf_prob,
_stick_fraction,
_type_repr,
_wandb_run_config,
@@ -114,6 +116,37 @@ def test_ar_has_prev_false_only_at_slot_zero():
assert has_prev.tolist() == [[False, True, True, True, True]]
# --- _stage2_tf_prob (docs/v0.3.0-design.md §3.3, v0.3.0 step 7) -----------
def test_stage2_tf_prob_always_is_constant_one():
assert _stage2_tf_prob("always", 1.0, 0.0, 0, 10) == 1.0
assert _stage2_tf_prob("always", 1.0, 0.0, 9, 10) == 1.0
def test_stage2_tf_prob_never_is_constant_zero():
assert _stage2_tf_prob("never", 1.0, 1.0, 0, 10) == 0.0
assert _stage2_tf_prob("never", 1.0, 1.0, 9, 10) == 0.0
def test_stage2_tf_prob_scheduled_interpolates_linearly():
assert _stage2_tf_prob("scheduled", 1.0, 0.0, 0, 11) == 1.0
assert abs(_stage2_tf_prob("scheduled", 1.0, 0.0, 5, 11) - 0.5) < 1e-9
assert _stage2_tf_prob("scheduled", 1.0, 0.0, 10, 11) == 0.0
def test_stage2_tf_prob_scheduled_clamps_beyond_total_epochs():
end = _stage2_tf_prob("scheduled", 1.0, 0.0, 10, 11)
beyond = _stage2_tf_prob("scheduled", 1.0, 0.0, 50, 11)
assert beyond == end
def test_stage2_tf_prob_scheduled_handles_single_epoch():
# total_epochs=1 is guarded to a denominator of 1 internally (like
# _gumbel_tau's total_steps=0 guard) — epoch=0 gives zero progress.
assert _stage2_tf_prob("scheduled", 1.0, 0.0, 0, 1) == 1.0
@pytest.mark.parametrize("target", ["physical", "onehot", "embedding"])
def test_type_repr_shapes_and_values(target):
B, K, emb_dim = 3, 4, 6
@@ -592,20 +625,70 @@ def test_flow_stage_trainer_ddpm_not_implemented_for_stage2():
# --- AR trainer wiring (v0.3.0 step 5) --------------------------------------
def test_build_stage_trainers_rejects_scheduled_teacher_forcing():
@pytest.mark.parametrize("teacher_forcing", ["always", "scheduled", "never"])
@pytest.mark.parametrize("history", ["markov", "attention"])
@pytest.mark.parametrize("stage2_generator", ["wgan", "flow"])
def test_build_stage_trainers_ar_scheduled_and_attention_step_runs(
teacher_forcing, history, stage2_generator
):
"""v0.3.0 step 7: history='attention' and teacher_forcing in
{'scheduled', 'never'} must actually train a stage-2 AR trainer.step()
must run and produce a finite loss, for every {history} x
{teacher_forcing} x {generator} combination."""
cfg = _base_cfg()
cfg["stage2_model"]["decoder"] = "autoregressive"
cfg["stage2_model"]["generator"] = stage2_generator
cfg["stage2_model"]["autoregressive"] = {
"history": "markov",
"teacher_forcing": "scheduled",
"history": history,
"teacher_forcing": teacher_forcing,
"tf_p_start": 1.0,
"tf_p_end": 0.0,
"attn_n_heads": 2,
"attn_n_layers": 1,
}
model_config = _model_config(cfg)
models = build_models(model_config)
critics = build_critics(model_config)
with pytest.raises(NotImplementedError):
_build_stage_trainers(
cfg, models, critics, torch.device("cpu"), total_train_batches=4
trainers = _build_stage_trainers(
cfg, models, critics, torch.device("cpu"), total_train_batches=4
)
trainer = trainers["stage2"]
batch = _fake_batches(1, 4)[0]
stats = trainer.step(batch, torch.device("cpu"), global_step=1)
loss_key = "g_loss" if stage2_generator == "wgan" else "loss"
assert math.isfinite(stats[loss_key])
@pytest.mark.parametrize("stage2_generator", ["wgan", "flow"])
def test_train_end_to_end_ar_attention_history_scheduled_teacher_forcing(
stage2_generator,
):
"""Full `train()` run (not just one `trainer.step()` call) with
history='attention' AND teacher_forcing='scheduled' together the
combination v0.3.0 step 7 exists to land must complete and write a
checkpoint + metrics.csv with finite losses throughout."""
cfg = _base_cfg()
cfg["stage2_model"]["decoder"] = "autoregressive"
cfg["stage2_model"]["generator"] = stage2_generator
cfg["stage2_model"]["autoregressive"] = {
"history": "attention",
"teacher_forcing": "scheduled",
"tf_p_start": 1.0,
"tf_p_end": 0.0,
"attn_n_heads": 2,
"attn_n_layers": 1,
}
with tempfile.TemporaryDirectory() as tmp:
out_dir = Path(tmp) / "run"
_run_train(cfg, out_dir)
assert (out_dir / "last.pt").exists()
with open(out_dir / "metrics.csv", newline="") as f:
rows = list(csv.DictReader(f))
assert len(rows) == cfg["train"]["epochs"]
loss_col = (
"stage2_train_g_loss" if stage2_generator == "wgan" else "stage2_train_loss"
)
assert all(math.isfinite(float(r[loss_col])) for r in rows)
def test_ar_wgan_onehot_grad_norm_instrumentation_populates_metrics():