Files
giant/giant/training/trainers.py
T
lars fff61ebd61
CI / Format (ruff format) (push) Successful in 29s
CI / Lint (ruff check) (push) Successful in 30s
CI / Sync project version with tag (push) Has been skipped
CI / Lint (ruff check) (pull_request) Successful in 40s
CI / Type check (ty) (push) Successful in 43s
CI / Format (ruff format) (pull_request) Successful in 32s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 31s
CI / Tests (pull_request) Successful in 3m12s
CI / Tests (push) Successful in 3m17s
Deduplicate giant/training/trainers.py shared per-stage logic
Lift repeated per-batch operations into StageTrainer base-class helpers so
each is written once instead of being copy-pasted between FlowDDPMStageTrainer
and WGANStageTrainer:

- _n_sec_loss: the multiplicity classifier (stage1/stage2 predict_n_sec split
  + cross-entropy + accuracy), previously written three times. Gated on
  n_sec_head presence, not n_sec.mode, so a future stop_token model trains its
  EOS signal elsewhere and this stays zero.
- _sec_mask: the arange < n_sec prefix mask, previously in two places.
- _step_optimizer: the zero_grad/backward/clip_grad_norm_(1.0)/step quad,
  previously written three times; now the single home of the clip constant.
- _sec_target: collapses the byte-identical _ar_target/_real wrappers into one
  flatten-parameterized method (they differed only by .flatten(1)).

Also trim StageSpec.from_config to read DEFAULT_CONFIG-guaranteed train.* keys
directly instead of re-defaulting them.

The three particle-type targets (onehot CE, physical/embedding regression) and
_type_loss are intentionally left as separate paths — genuinely different
objectives, not duplication.

stage2_inputs.py: extract the shared _ar_meta helper for the has_prev/
remaining_frac/slot_idx trio used by both AR-input assemblers.

Behavior-preserving: same losses, optimizer order, and RNG draw order. Full
test suite (699) green; ruff + ty clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 10:32:26 +02:00

1014 lines
38 KiB
Python

"""Per-stage trainers: optimizer(s), EMA, LR schedule, and the per-batch step.
`StageSpec` resolves one stage's slice of the config once, so the two
concrete trainers share a single constructor shape instead of ~24 keyword
arguments each, and `StageTrainer` carries every piece that used to be
copy-pasted between them (cosine warmup, EMA, checkpoint state, LR resume,
train/eval toggling).
Each trainer also *declares* the metrics it emits, as `MetricSpec` lists —
that declaration is the single source of truth for `metrics.csv` and W&B
columns (see `giant.training.metrics`) — and exposes the three small hooks
(`batch_loss`, `summary`, `val_objective`) that let the epoch loop treat
adversarial and non-adversarial stages identically.
"""
import copy
import math
from dataclasses import dataclass, field
import torch
import torch.nn.functional as F
import torch.optim as optim
from giant.constants import CONT_SLOT_DIM
from giant.model.network import Router, stage2_type_dim
from giant.model.schedule import (
CosineSchedule,
flow_matching_loss,
flow_matching_loss_secondary,
flow_matching_loss_secondary_ar,
)
from giant.model.wgan import generator_loss, gradient_penalty
from giant.training.metrics import MetricSpec, stage_metric, train_metric, val_metric
from giant.training.stage2_inputs import (
_assemble_stage2_ar_inputs_scheduled,
_assemble_stage2_ar_target,
_gumbel_tau,
_relax_onehot_type_slice,
_stage2_tf_prob,
)
@torch.no_grad()
def _update_ema(
ema_model: torch.nn.Module, model: torch.nn.Module, decay: float
) -> None:
for ema_p, p in zip(ema_model.parameters(), model.parameters()):
ema_p.mul_(decay).add_(p, alpha=1 - decay)
def _stage_router(model: torch.nn.Module) -> Router | None:
"""A stage model's Router, if its trunk is routed — else None.
Post-step-2 refactor the router lives at `model.trunk.router`
(`giant.model.network.RoutedTrunk`), not `model.router` directly.
"""
trunk = getattr(model, "trunk", None)
return getattr(trunk, "router", None)
def _cosine_warmup_lambda(warmup_steps: int, total_steps: int):
"""Linear warmup for `warmup_steps`, then cosine decay to zero over the
remainder — the LR schedule both trainers use, in their own step units
(optimizer steps for flow/ddpm, generator steps for WGAN)."""
def _lr_lambda(step: int) -> float:
if warmup_steps > 0 and step < warmup_steps:
return (step + 1) / warmup_steps
t = step - warmup_steps
T = max(total_steps - warmup_steps, 1)
return 0.5 * (1.0 + math.cos(math.pi * min(t, T) / T))
return _lr_lambda
def _batch_to_device(batch: tuple, device: torch.device) -> tuple:
return tuple(t.to(device) for t in batch)
@dataclass(frozen=True)
class StageSpec:
"""One stage's resolved training configuration.
Built once by `StageSpec.from_config`, which is the only place that reads
the `cfg` dict — so a new config key means one new field and one new read,
not another argument threaded through two constructors.
"""
name: str
is_stage2: bool
generator: str
decoder: str = "one_shot"
# loss weights
lambda_weight: float = 1.0
n_sec_lambda: float = 0.1
# particle-type target (stage 2 only)
particle_type: dict = field(default_factory=lambda: {"target": "physical"})
particle_type_emb_dim: int = 16
# optimization
lr: float = 3e-4
weight_decay: float = 0.01
ema_decay: float = 0.9999
warmup_epochs: int = 0
epochs: int = 1
steps_per_epoch: int = 1
# routing auxiliaries
lambda_balance: float = 0.0
lambda_proc: float = 0.0
lambda_entropy: float = 0.0
gumbel_tau_start: float = 1.0
gumbel_tau_end: float = 0.1
# autoregressive stage 2
teacher_forcing: str = "always"
tf_p_start: float = 1.0
tf_p_end: float = 1.0
ar_sample_steps: int = 10
# generator-specific
ddpm_n_steps: int = 1000
n_critic: int = 5
gp_weight: float = 10.0
critic_lr: float = 0.0
type_gumbel_tau_start: float = 1.0
type_gumbel_tau_end: float = 0.1
@classmethod
def from_config(
cls, cfg: dict, name: str, is_stage2: bool, steps_per_epoch: int
) -> "StageSpec":
t = cfg["train"]
stage_cfg = cfg[f"{name}_model"]
router_cfg = stage_cfg.get("router") or {}
wgan_cfg = stage_cfg.get("wgan") or {}
ar_cfg = (stage_cfg.get("autoregressive") or {}) if is_stage2 else {}
return cls(
name=name,
is_stage2=is_stage2,
generator=stage_cfg["generator"],
decoder=stage_cfg.get("decoder", "one_shot") if is_stage2 else "one_shot",
lambda_weight=stage_cfg.get("lambda", 1.0),
n_sec_lambda=cfg["stage2_model"].get("n_sec", {}).get("lambda", 0.1),
particle_type=cfg["stage2_model"].get("particle_type")
or {"target": "physical"},
particle_type_emb_dim=cfg["conditioning"]["particle"]["emb_dim"],
# train.* keys are all guaranteed by DEFAULT_CONFIG's deep-merge
# (giant/config.py), so they read directly; the field defaults
# below exist only for tests that construct StageSpec by hand.
lr=t["lr"],
weight_decay=t["weight_decay"],
ema_decay=t["ema_decay"],
warmup_epochs=t["warmup_epochs"],
epochs=t["epochs"],
steps_per_epoch=max(steps_per_epoch, 1),
lambda_balance=router_cfg.get("lambda_balance", 0.0),
lambda_proc=router_cfg.get("lambda_proc", 0.0),
lambda_entropy=router_cfg.get("lambda_entropy", 0.0),
gumbel_tau_start=router_cfg.get("gumbel_tau_start", 1.0),
gumbel_tau_end=router_cfg.get("gumbel_tau_end", 0.1),
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["validate_steps"],
ddpm_n_steps=stage_cfg.get("ddpm", {}).get("n_steps", 1000),
n_critic=wgan_cfg.get("n_critic", 5),
gp_weight=wgan_cfg.get("gp_weight", 10.0),
critic_lr=wgan_cfg.get("critic_lr", 0.0),
type_gumbel_tau_start=wgan_cfg.get("gumbel_tau_start", 1.0),
type_gumbel_tau_end=wgan_cfg.get("gumbel_tau_end", 0.1),
)
class StageTrainer:
"""One active stage's optimizer(s), EMA, and per-batch step.
Reads only the shared batch tuple `(cond_cont, cond_cat, x1_s1, n_sec,
sec_cont, proc_idx, sec_type_idx)` — stage 2 always conditions on the
ground-truth `x1_s1` (`stage2_model.stage1_context = "truth"`,
stage-level teacher forcing; `"sampled"` is not implemented — see
docs/v0.3.0-design.md §3.3), so stage trainers never need each other's
output at train time. This means "stage-2-only training is a cheap
ablation, not new plumbing" (design doc §7) falls out for free: a trainer
only exists for active stages, and inactive stages are simply never
constructed.
Grad-norm clipping is per-stage here — v0.2's single shared optimizer
clipped both stages' gradients jointly; splitting per stage is a small,
disclosed behavior change. It doesn't affect Adam's per-parameter update
math itself (no cross-parameter coupling), only the clip threshold's
scope.
"""
#: Metrics this trainer emits, declared once — `giant.training.metrics`
#: derives every CSV/W&B column from these. Instance attributes rather
#: than class constants because some are conditional on the stage's own
#: configuration (see `WGANStageTrainer.__init__`).
train_metrics: list[MetricSpec]
val_metrics: list[MetricSpec]
stage_metrics: list[MetricSpec]
#: False for adversarial stages, which have no monotone per-batch
#: validation loss worth averaging (see `val_objective`).
supports_val_loss: bool = True
#: Built by the subclass (the optimizer flavour differs) and wired to the
#: schedule via `_init_lr_schedule`.
optimizer: optim.Optimizer
lr_sched: optim.lr_scheduler.LambdaLR
total_steps: int
def __init__(
self,
spec: StageSpec,
model: torch.nn.Module,
device: torch.device,
extra_modules: tuple[torch.nn.Module, ...] = (),
) -> None:
self.spec = spec
self.name = spec.name
self.is_stage2 = spec.is_stage2
self.generator = spec.generator
self.decoder = spec.decoder
self.device = device
self.model = model.to(device)
self.router = _stage_router(self.model)
self._modules = (self.model, *extra_modules)
self.particle_type_cfg = dict(spec.particle_type or {"target": "physical"})
self.particle_type_emb_dim = spec.particle_type_emb_dim
self.ema_decay = spec.ema_decay
self.ema_model: torch.nn.Module | None = None
if spec.ema_decay > 0:
self.ema_model = copy.deepcopy(self.model).eval()
for p in self.ema_model.parameters():
p.requires_grad_(False)
# --- schedule -------------------------------------------------------
def _init_lr_schedule(
self, optimizer: optim.Optimizer, warmup_steps: int, total_steps: int
) -> None:
self._lr_lambda = _cosine_warmup_lambda(warmup_steps, total_steps)
self.total_steps = total_steps
self.lr_sched = optim.lr_scheduler.LambdaLR(optimizer, self._lr_lambda)
# --- per-batch (subclass responsibility) ----------------------------
def step(self, batch: tuple, device: torch.device, global_step: int) -> dict:
raise NotImplementedError
def val_loss(self, batch: tuple, device: torch.device) -> dict:
raise NotImplementedError
# --- reporting hooks ------------------------------------------------
def batch_loss(self, stats: dict) -> float:
"""The single number this stage contributes to the progress bar's
smoothed loss."""
raise NotImplementedError
def summary(self, means: dict) -> str:
"""This stage's fragment of the end-of-epoch console line."""
raise NotImplementedError
def val_objective(
self, train_means: dict, val_means: dict, marginal_kl: float
) -> float:
"""This stage's contribution to the best-checkpoint selection score."""
raise NotImplementedError
# --- mode / state ---------------------------------------------------
def sampling_model(self) -> torch.nn.Module:
return self.ema_model if self.ema_model is not None else self.model
def train_mode(self) -> None:
for module in self._modules:
module.train()
def eval_mode(self) -> None:
for module in self._modules:
module.eval()
# --- stage-2 secondary assembly (shared by both trainer subclasses) ---
def _ar_inputs(
self,
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,
epoch: int | None,
) -> dict[str, torch.Tensor]:
"""Per-token AR conditioning for this stage's secondary decoder.
`epoch=None` means full teacher forcing (`p_tf=1.0`) regardless of
`spec.teacher_forcing` — the val-loss convention, kept in this one
place so both trainer subclasses honor it identically.
"""
p_tf = (
1.0
if epoch is None
else _stage2_tf_prob(
self.spec.teacher_forcing,
self.spec.tf_p_start,
self.spec.tf_p_end,
epoch,
self.spec.epochs,
)
)
return _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.spec.ar_sample_steps,
)
def _sec_target(
self,
sec_cont: torch.Tensor,
sec_type_idx: torch.Tensor,
generator: str,
*,
flatten: bool,
) -> torch.Tensor:
"""Ground-truth stage-2 target for this stage's secondary decoder,
per the (particle-type target, generator) width rules in
`_assemble_stage2_ar_target`. `flatten=True` gives `Stage2OneShot`'s
flattened `(B, K*token_dim)` form (the old `_real`); `flatten=False`
gives `Stage2Autoregressive`'s per-token `(B, K, token_dim)` form (the
old `_ar_target`) — the two are the same tensor modulo `.flatten(1)`,
so the width rules live in one place (`stage2_inputs.py`)."""
target = _assemble_stage2_ar_target(
sec_cont,
sec_type_idx,
self.particle_type_cfg,
generator,
self.model.cond_enc,
self.particle_type_emb_dim,
)
return target.flatten(1) if flatten else target
@staticmethod
def _sec_mask(
n_sec: torch.Tensor, k_max: int, device: torch.device
) -> torch.Tensor:
"""`(B, K_MAX)` bool prefix mask: slot k is valid iff `k < n_sec`."""
return torch.arange(k_max, device=device).unsqueeze(0) < n_sec.unsqueeze(1)
def _n_sec_loss(
self,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_ctx: torch.Tensor,
n_sec: torch.Tensor,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor]:
"""`(l_nsec, nsec_acc)` for this stage's multiplicity classifier —
zeros when the stage owns no `n_sec_head` (stage 1 now that n_sec
defaults to stage 2, or any stage without the head). Owns the only
stage1-vs-stage2 `predict_n_sec` signature split, shared by the
non-adversarial and WGAN trainers.
Gated on `n_sec_head is None`, not on `n_sec.mode`: a future
`mode="stop_token"` model (design doc §11.2, currently rejected in
`validate_config`) carries no head and would train its EOS signal in
the generator/AR loss path instead, so this correctly stays zero.
"""
if self.model.n_sec_head is None:
zero = torch.zeros((), device=device)
return zero, zero
logits = (
self.model.predict_n_sec(cond_cont, cond_cat, stage1_ctx)
if self.is_stage2
else self.model.predict_n_sec(cond_cont, cond_cat)
)
l_nsec = F.cross_entropy(logits, n_sec)
nsec_acc = (logits.argmax(dim=-1) == n_sec).float().mean()
return l_nsec, nsec_acc
@staticmethod
def _step_optimizer(
optimizer: optim.Optimizer, loss: torch.Tensor, params: list
) -> float:
"""`zero_grad -> backward -> clip_grad_norm_(1.0) -> step`, returning
the pre-clip grad norm. The one place the grad-clip constant lives."""
optimizer.zero_grad()
loss.backward()
grad_norm = torch.nn.utils.clip_grad_norm_(params, 1.0)
optimizer.step()
return grad_norm.item()
def _extra_state(self) -> dict:
"""Subclass state beyond model/optimizer/lr_sched/EMA."""
return {}
def _load_extra_state(self, sd: dict) -> None:
return None
def state_dict(self) -> dict:
sd = {
"model": self.model.state_dict(),
"optimizer": self.optimizer.state_dict(),
"lr_sched": self.lr_sched.state_dict(),
}
if self.ema_model is not None:
sd["model_ema"] = self.ema_model.state_dict()
sd.update(self._extra_state())
return sd
def load_state_dict(self, sd: dict) -> None:
self.model.load_state_dict(sd["model"])
self.optimizer.load_state_dict(sd["optimizer"])
self.lr_sched.load_state_dict(sd["lr_sched"])
if self.ema_model is not None:
self.ema_model.load_state_dict(sd.get("model_ema", sd["model"]))
self._load_extra_state(sd)
def _resume_extra_lr(self, lr: float) -> None:
return None
def resume_lr(self, lr: float) -> None:
"""Restore the configured `lr`'s authority after `load_state_dict`
restored the checkpoint's own base LR."""
self.lr_sched.base_lrs = [lr for _ in self.lr_sched.base_lrs]
resumed_lr = lr * self._lr_lambda(self.lr_sched.last_epoch)
for group in self.optimizer.param_groups:
group["lr"] = resumed_lr
self._resume_extra_lr(lr)
class FlowDDPMStageTrainer(StageTrainer):
"""flow or ddpm generator for a single stage."""
def __init__(
self, spec: StageSpec, model: torch.nn.Module, device: torch.device
) -> None:
if spec.is_stage2 and spec.generator not in ("flow",):
raise NotImplementedError(
f"stage2_model.generator={spec.generator!r} is accepted by the "
"schema but not implemented in v0.3.0 for stage 2 (only "
"'flow' and 'wgan' have a stage-2 secondary-decoder loss — "
"see docs/v0.3.0-design.md §11.2)"
)
super().__init__(spec, model, device)
self.particle_type_lambda = self.particle_type_cfg.get("lambda", 1.0)
# Width of the type slice actually folded into x1_s2 by _sec_target,
# under this trainer's generator (flow/ddpm only — see the
# NotImplementedError above): "physical" keeps it folded in
# (PARTICLE_PHYS_DIM wide, unchanged from v0.2); "onehot"/"embedding"
# pull it out into model.type_head instead (0 here).
self._flow_type_dim = (
None
if self.particle_type_cfg.get("target", "physical") == "physical"
else 0
)
self.params = list(self.model.parameters())
self.optimizer = optim.AdamW(
self.params, lr=spec.lr, weight_decay=spec.weight_decay
)
self._init_lr_schedule(
self.optimizer,
warmup_steps=spec.warmup_epochs * spec.steps_per_epoch,
total_steps=max(spec.epochs * spec.steps_per_epoch, 1),
)
self.ddpm_schedule = (
CosineSchedule(T=spec.ddpm_n_steps).to(device)
if spec.generator == "ddpm"
else None
)
self.train_metrics = [
train_metric(key)
for key in (
"loss",
"loss_gen",
"loss_nsec",
"loss_balance",
"loss_proc",
"loss_entropy",
"nsec_acc",
"loss_type",
"type_acc",
"grad_norm",
)
]
self.val_metrics = [
val_metric(key)
for key in (
"loss",
"loss_gen",
"loss_nsec",
"nsec_acc",
"loss_type",
"type_acc",
)
]
self.stage_metrics = [stage_metric("lr")]
def _generator_loss(
self, cond_cont, cond_cat, x1_s1, x1_s2, sec_mask, stage1_ctx, ar_inputs=None
):
if not self.is_stage2:
if self.generator == "flow":
return flow_matching_loss(self.model, x1_s1, cond_cont, cond_cat)
assert self.ddpm_schedule is not None
return self.ddpm_schedule.loss(self.model, x1_s1, cond_cont, cond_cat)
if self.decoder == "autoregressive":
assert ar_inputs is not None
return flow_matching_loss_secondary_ar(
self.model,
x1_s2,
cond_cont,
cond_cat,
stage1_ctx,
ar_inputs["history_feat"],
ar_inputs["has_prev"],
ar_inputs["remaining_frac"],
ar_inputs["slot_idx"],
sec_mask,
type_dim=self._flow_type_dim,
)
return flow_matching_loss_secondary(
self.model,
x1_s2,
cond_cont,
cond_cat,
stage1_ctx,
sec_mask,
type_dim=self._flow_type_dim,
)
def _type_loss(
self,
cond_cont,
cond_cat,
stage1_ctx,
sec_type_idx,
sec_mask,
device,
ar_inputs=None,
):
"""CE (`target="onehot"`) or MSE (`target="embedding"`) loss for the
stage-2 model's `type_head` — the non-adversarial counterpart to
WGANStageTrainer's ST-Gumbel-into-the-critic path (decision 2/5).
Zero when this stage has no `type_head` (stage 1, or
`particle_type.target = "physical"`)."""
l_type = torch.zeros((), device=device)
type_acc = torch.zeros((), device=device)
type_head = getattr(self.model, "type_head", None)
if not self.is_stage2 or type_head is None:
return l_type, type_acc
if self.decoder == "autoregressive":
assert ar_inputs is not None
type_out = self.model.predict_type(
cond_cont,
cond_cat,
stage1_ctx,
ar_inputs["history_feat"],
ar_inputs["has_prev"],
ar_inputs["remaining_frac"],
ar_inputs["slot_idx"],
)
else:
type_out = self.model.predict_type(cond_cont, cond_cat, stage1_ctx)
mask = sec_mask.float()
denom = mask.sum().clamp(min=1)
if self.particle_type_cfg.get("target") == "onehot":
ce = F.cross_entropy(
type_out.transpose(1, 2), sec_type_idx, reduction="none"
)
l_type = (ce * mask).sum() / denom
type_acc = (
(type_out.argmax(-1) == sec_type_idx).float() * mask
).sum() / denom
else: # "embedding"
target_vec = self.model.cond_enc.pdg_emb(sec_type_idx).detach()
se = ((type_out - target_vec) ** 2).mean(-1)
l_type = (se * mask).sum() / denom
return l_type, type_acc
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 `spec.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,
x1_s1,
n_sec,
sec_cont,
proc_idx,
sec_type_idx,
) = _batch_to_device(batch, device)
sec_mask = self._sec_mask(n_sec, sec_cont.size(1), device)
stage1_ctx = x1_s1.detach()
x1_s2 = None
ar_inputs = None
if self.is_stage2 and self.decoder == "autoregressive":
ar_inputs = self._ar_inputs(
cond_cont, cond_cat, stage1_ctx, sec_cont, sec_type_idx, n_sec, epoch
)
x1_s2 = self._sec_target(
sec_cont, sec_type_idx, self.generator, flatten=False
)
elif self.is_stage2:
x1_s2 = self._sec_target(
sec_cont, sec_type_idx, self.generator, flatten=True
)
l_gen = self._generator_loss(
cond_cont, cond_cat, x1_s1, x1_s2, sec_mask, stage1_ctx, ar_inputs=ar_inputs
)
l_nsec, nsec_acc = self._n_sec_loss(
cond_cont, cond_cat, stage1_ctx, n_sec, device
)
l_type, type_acc = self._type_loss(
cond_cont,
cond_cat,
stage1_ctx,
sec_type_idx,
sec_mask,
device,
ar_inputs=ar_inputs,
)
l_balance = l_proc = l_entropy = torch.zeros((), device=device)
if self.router is not None:
l_balance = self.router.balance_loss(cond_cont, cond_cat)
l_proc = self.router.classify_loss(cond_cont, cond_cat, proc_idx)
l_entropy = self.router.entropy_loss(cond_cont, cond_cat)
total = (
self.spec.lambda_weight * l_gen
+ self.spec.n_sec_lambda * l_nsec
+ self.particle_type_lambda * l_type
)
if self.spec.lambda_balance > 0:
total = total + self.spec.lambda_balance * l_balance
if self.spec.lambda_proc > 0:
total = total + self.spec.lambda_proc * l_proc
if self.spec.lambda_entropy > 0:
total = total + self.spec.lambda_entropy * l_entropy
return {
"loss": total,
"loss_gen": l_gen,
"loss_nsec": l_nsec,
"loss_type": l_type,
"type_acc": type_acc,
"loss_balance": l_balance,
"loss_proc": l_proc,
"loss_entropy": l_entropy,
"nsec_acc": nsec_acc,
}
def step(self, batch: tuple, device: torch.device, global_step: int) -> dict:
if self.router is not None:
self.router.gumbel_tau = _gumbel_tau(
global_step,
self.total_steps,
self.spec.gumbel_tau_start,
self.spec.gumbel_tau_end,
)
epoch = global_step // self.spec.steps_per_epoch
out = self._compute(batch, device, epoch=epoch)
grad_norm = self._step_optimizer(self.optimizer, out["loss"], self.params)
self.lr_sched.step()
if self.ema_model is not None:
_update_ema(self.ema_model, self.model, self.ema_decay)
stats = {key: value.item() for key, value in out.items()}
stats["grad_norm"] = grad_norm
stats["lr"] = self.optimizer.param_groups[0]["lr"]
return stats
@torch.no_grad()
def val_loss(self, batch: tuple, device: torch.device) -> dict:
return {
key: value.item() for key, value in self._compute(batch, device).items()
}
# --- reporting ------------------------------------------------------
def batch_loss(self, stats: dict) -> float:
return stats["loss"]
def summary(self, means: dict) -> str:
return f"{self.name}[loss={means.get('loss', 0.0):.3f}]"
def val_objective(
self, train_means: dict, val_means: dict, marginal_kl: float
) -> float:
return val_means.get("loss", 0.0)
class WGANStageTrainer(StageTrainer):
"""WGAN-GP generator+critic for a single stage (see giant/model/wgan.py).
Ports `_wgan_train_step` to operate on one stage instead of two fused
together — the critic updates every batch; every `n_critic`-th batch
additionally updates the generator (`did_g_step`). The (non-adversarial)
n_sec classifier, when this stage's model owns it, updates every batch
regardless — folded into whichever generator optimizer step happens this
batch, same precedent as v0.2.
"""
supports_val_loss = False
def __init__(
self,
spec: StageSpec,
model: torch.nn.Module,
critic: torch.nn.Module,
device: torch.device,
) -> None:
self.critic = critic.to(device)
super().__init__(spec, model, device, extra_modules=(self.critic,))
self.n_critic = max(spec.n_critic, 1)
self.gp_weight = spec.gp_weight
self.critic_lr = spec.critic_lr
self.g_params = list(self.model.parameters())
self.d_params = list(self.critic.parameters())
# WGAN-GP recipe (Gulrajani et al. 2017): Adam, beta1=0, no weight decay.
self.optimizer = optim.Adam(self.g_params, lr=spec.lr, betas=(0.0, 0.9))
self.optimizer_d = optim.Adam(
self.d_params,
lr=spec.critic_lr if spec.critic_lr > 0 else spec.lr,
betas=(0.0, 0.9),
)
# Generator steps fire every n_critic-th batch, so warmup/decay must
# be counted in those units, matching v0.2.
gen_steps_per_epoch = max(spec.steps_per_epoch // self.n_critic, 1)
self._init_lr_schedule(
self.optimizer,
warmup_steps=spec.warmup_epochs * gen_steps_per_epoch,
total_steps=max(spec.epochs * gen_steps_per_epoch, 1),
)
train_keys = [
"d_loss",
"g_loss",
"wasserstein",
"gp_loss",
"loss_nsec",
"nsec_acc",
"grad_norm_d",
"grad_norm_g",
]
if self.is_stage2 and self.particle_type_cfg.get("target") == "onehot":
# §11.4 differentiability instrumentation — only meaningful when
# the type slice is a straight-through Gumbel relaxation.
train_keys += ["grad_norm_type_slice", "grad_norm_cont_slice"]
self.train_metrics = [train_metric(key) for key in train_keys]
self.val_metrics = []
self.stage_metrics = [stage_metric("lr"), stage_metric("critic_lr")]
def _stage2_real_and_fake(self, batch_tensors, stage1_ctx, global_step, device):
"""Build `(real, fake_raw, mask, critic_fn)` for stage 2, covering
both decoders and all three particle-type targets. `fake_raw` still
needs the caller's straight-through relaxation under
`particle_type.target = "onehot"`, and neither tensor is masked-and-
multiplied on the fake side yet."""
cond_cont, cond_cat, n_sec, sec_cont, sec_type_idx = batch_tensors
B = cond_cont.size(0)
type_dim = stage2_type_dim(self.particle_type_cfg, self.particle_type_emb_dim)
slot_width = CONT_SLOT_DIM + type_dim
k_max = sec_cont.size(1)
sec_mask = self._sec_mask(n_sec, k_max, device)
mask = sec_mask.unsqueeze(-1).expand(-1, -1, slot_width).reshape(B, -1).float()
def critic_fn(x):
return self.critic(x, cond_cont, cond_cat, stage1_ctx)
if self.decoder == "autoregressive":
epoch = global_step // self.spec.steps_per_epoch
ar = self._ar_inputs(
cond_cont, cond_cat, stage1_ctx, sec_cont, sec_type_idx, n_sec, epoch
)
real = (
self._sec_target(sec_cont, sec_type_idx, "wgan", flatten=False).reshape(
B, -1
)
* mask
)
z = torch.randn(B, k_max, self.model.noise_dim, device=device)
fake_raw = self.model(
z,
cond_cont,
cond_cat,
stage1_ctx,
ar["history_feat"],
ar["has_prev"],
ar["remaining_frac"],
ar["slot_idx"],
).reshape(B, -1)
else:
real = self._sec_target(sec_cont, sec_type_idx, "wgan", flatten=True) * mask
z = torch.randn(B, self.model.noise_dim, device=device)
fake_raw = self.model(z, cond_cont, cond_cat, stage1_ctx)
return real, fake_raw, mask, critic_fn
def step(self, batch: tuple, device: torch.device, global_step: int) -> dict:
(
cond_cont,
cond_cat,
x1_s1,
n_sec,
sec_cont,
_proc_idx,
sec_type_idx,
) = _batch_to_device(batch, device)
B = cond_cont.size(0)
stage1_ctx = x1_s1.detach()
grad_probe: dict[str, float] = {}
if not self.is_stage2:
real = x1_s1
def critic_fn(x):
return self.critic(x, cond_cont, cond_cat)
z = torch.randn(B, self.model.noise_dim, device=device)
fake = self.model(z, cond_cont, cond_cat)
mask = None
else:
real, fake_raw, mask, critic_fn = self._stage2_real_and_fake(
(cond_cont, cond_cat, n_sec, sec_cont, sec_type_idx),
stage1_ctx,
global_step,
device,
)
if self.particle_type_cfg.get("target", "physical") == "onehot":
# Straight-through Gumbel-softmax relaxation of the type
# slice only (decision 5) — the critic must see a hard
# one-hot forward (matching what "real" data looks like)
# while gradient still flows smoothly to the generator.
# grad_probe captures the §11.4 gradient-magnitude
# instrumentation — see _relax_onehot_type_slice's docstring.
tau = _gumbel_tau(
global_step,
self.total_steps,
self.spec.type_gumbel_tau_start,
self.spec.type_gumbel_tau_end,
)
fake_raw = _relax_onehot_type_slice(
fake_raw,
sec_cont.size(1),
CONT_SLOT_DIM,
stage2_type_dim(self.particle_type_cfg, self.particle_type_emb_dim),
tau,
grad_probe=grad_probe,
)
fake = fake_raw * mask
# --- critic step (every batch) ---
fake_detached = fake.detach()
real_score = critic_fn(real)
fake_score = critic_fn(fake_detached)
gp = gradient_penalty(critic_fn, real, fake_detached, mask=mask)
d_loss = fake_score.mean() - real_score.mean() + self.gp_weight * gp
wasserstein = (real_score.mean() - fake_score.mean()).detach()
grad_norm_d = self._step_optimizer(self.optimizer_d, d_loss, self.d_params)
# --- generator (+ n_sec) step ---
did_g_step = global_step % self.n_critic == 0
l_nsec, nsec_acc = self._n_sec_loss(
cond_cont, cond_cat, stage1_ctx, n_sec, device
)
# On a non-generator-step batch with no n_sec_head on this stage
# (n_sec now defaults to stage 2, decision 1), there's nothing for
# the generator optimizer to do this batch — g_loss would otherwise
# be a graph-less zero tensor, which .backward() rejects outright.
skip_g_step = not did_g_step and self.model.n_sec_head is None
if did_g_step:
g_loss_adv = generator_loss(critic_fn, fake)
g_loss = (
self.spec.lambda_weight * g_loss_adv + self.spec.n_sec_lambda * l_nsec
)
else:
g_loss_adv = torch.zeros((), device=device)
g_loss = self.spec.n_sec_lambda * l_nsec
if skip_g_step:
grad_norm_g = 0.0
else:
grad_norm_g = self._step_optimizer(self.optimizer, g_loss, self.g_params)
if did_g_step:
self.lr_sched.step()
if self.ema_model is not None:
_update_ema(self.ema_model, self.model, self.ema_decay)
return {
"d_loss": d_loss.item(),
"g_loss": g_loss_adv.item(),
"wasserstein": wasserstein.item(),
"gp_loss": gp.item(),
"loss_nsec": l_nsec.item(),
"nsec_acc": nsec_acc.item(),
"did_g_step": did_g_step,
"grad_norm": grad_norm_d + grad_norm_g,
"grad_norm_d": grad_norm_d,
"grad_norm_g": grad_norm_g,
"grad_norm_type_slice": grad_probe.get("type", 0.0),
"grad_norm_cont_slice": grad_probe.get("cont", 0.0),
"lr": self.optimizer.param_groups[0]["lr"],
"critic_lr": self.optimizer_d.param_groups[0]["lr"],
}
# --- reporting ------------------------------------------------------
def batch_loss(self, stats: dict) -> float:
return stats["d_loss"] + stats["g_loss"]
def summary(self, means: dict) -> str:
return (
f"{self.name}[d={means.get('d_loss', 0.0):.3f} "
f"g={means.get('g_loss', 0.0):.3f}]"
)
def val_objective(
self, train_means: dict, val_means: dict, marginal_kl: float
) -> float:
"""No monotone per-batch WGAN loss fit for averaging, so
best-checkpoint selection uses the real marginal-KL signal when
`validate_marginals` produced one, and falls back to this epoch's own
Wasserstein-distance magnitude otherwise.
Behavior change vs. every run up to v0.3.0: the pre-refactor code
meant to do exactly this, but its guard
(`{n: kl for n in wgan_names if n not in val_loss_per_stage}`) could
never fire — `val_loss_per_stage` was pre-seeded with `0.0` for every
stage, so a WGAN stage contributed a flat `0.0` and the marginal KL
was recorded in the metrics row without ever influencing `best.pt`.
Runs from before this commit therefore selected their best checkpoint
on the non-adversarial stages alone."""
if math.isfinite(marginal_kl):
return marginal_kl
return abs(train_means.get("wasserstein", 0.0))
# --- state ----------------------------------------------------------
def _extra_state(self) -> dict:
return {
"critic": self.critic.state_dict(),
"optimizer_d": self.optimizer_d.state_dict(),
}
def _load_extra_state(self, sd: dict) -> None:
self.critic.load_state_dict(sd["critic"])
self.optimizer_d.load_state_dict(sd["optimizer_d"])
def _resume_extra_lr(self, lr: float) -> None:
resumed_critic_lr = self.critic_lr if self.critic_lr > 0 else lr
for group in self.optimizer_d.param_groups:
group["lr"] = resumed_critic_lr
def build_stage_trainers(
cfg: dict,
models: dict[str, torch.nn.Module | None],
critics: dict[str, torch.nn.Module | None],
device: torch.device,
total_train_batches: int,
) -> dict[str, StageTrainer]:
"""One trainer per active stage — `models[name] is None` means that stage
is `active = false` and is simply never constructed."""
trainers: dict[str, StageTrainer] = {}
for name, is_stage2 in (("stage1", False), ("stage2", True)):
model = models.get(name)
if model is None:
continue
spec = StageSpec.from_config(cfg, name, is_stage2, max(total_train_batches, 1))
if spec.generator == "wgan":
critic = critics.get(name)
assert critic is not None, (
f"{name}_model.generator='wgan' requires a critic (see "
"giant.model.network.build_critics)"
)
trainers[name] = WGANStageTrainer(spec, model, critic, device)
else:
trainers[name] = FlowDDPMStageTrainer(spec, model, device)
return trainers