c1c4957e2f
Stage 2's autoregressive decoder still predicted multiplicity the v0.2 way: a one-shot n_sec_head classifier over conditioning alone, run before any secondary token existed, with the AR loop then always executing k_max slots and discarding the tail. This adds a real per-slot EOS mechanism instead: - Stage2Autoregressive gains a stop_head (build_stop_head=True) that predicts P(n_sec == k | prefix) at each slot, mutually exclusive with n_sec_head (n_sec.mode = "stop_token" builds no n_sec_head at all). - sample_secondaries_ar accepts n_sec_pred=None to drive generation off the stop head instead of a pre-resolved count: each row stops the first slot its stop logit fires (stage2_model.n_sec.stop_sampling = "greedy" — the default, threshold at 0 — or "sample", a Bernoulli draw), and the whole batch loop breaks once every row has stopped, so cost scales with the realized n_sec instead of a fixed k_max. Passing n_sec_pred explicitly (the scheduled-sampling self-sample path) is unchanged. - resolve_n_sec returns None for a stop-token decoder instead of raising; rollout.py/cli.py/validate.py now derive the realized count from sample_stage2's returned sec_valid (sec_valid.sum(-1)) after sampling, rather than resolving it up front — a no-op reordering under every other n_sec.mode, where sec_valid was already built from n_sec_pred. - Training: _stop_target_and_mask (giant/training/stage2_inputs.py) builds the per-slot target/mask (one slot wider than the existing token-content sec_mask, since the stop slot itself needs supervision) and StageTrainer._stop_loss trains it with masked BCE, gated on stop_head exactly like _n_sec_loss gates on n_sec_head. Wired into both the flow/ddpm trainer and the WGAN trainer (whose skip_g_step now also checks stop_head), weighted by the existing stage2_model.n_sec.lambda — the stop head replaces n_sec_head under this mode, so no new weight key. - validate_config now accepts stop_token (requires decoder="autoregressive" and n_sec.owner="stage2") instead of always rejecting it. Decisions made during planning: stop_sampling defaults to "greedy" for deterministic rollouts; the stop head reuses stage2_model.heads.n_sec's HeadConfig shape and stage2_model.n_sec.lambda's weight rather than adding new config keys, since the two heads never coexist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1015 lines
41 KiB
Python
1015 lines
41 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
|
|
from typing import NamedTuple
|
|
|
|
import torch
|
|
import torch.nn.functional as F
|
|
import torch.optim as optim
|
|
|
|
from giant.config import ParticleTypeConfig, Stage1ModelConfig, Stage2ModelConfig, TrainConfig
|
|
from giant.constants import CONT_SLOT_DIM
|
|
from giant.data.dataset import StepBatch
|
|
from giant.model.network import Router, build_objective, resolve_type_n_classes, stage2_type_dim
|
|
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,
|
|
_stop_target_and_mask,
|
|
)
|
|
|
|
|
|
@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: StepBatch, device: torch.device) -> StepBatch:
|
|
return type(batch)(*(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
|
|
n_sec_mode: str = "head"
|
|
|
|
# particle-type target (stage 2 only)
|
|
particle_type: ParticleTypeConfig = field(default_factory=ParticleTypeConfig)
|
|
particle_type_n_classes: 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 = TrainConfig.from_dict(cfg["train"])
|
|
# n_sec/particle_type/decoder/autoregressive/wgan's gumbel_tau_* are
|
|
# stage-2-only concepts, always read off s2_spec (guarded by
|
|
# is_stage2 where the stage-1 StageSpec needs a different value) —
|
|
# historically n_sec/particle_type were read from stage2_model
|
|
# unconditionally even for the stage-1 StageSpec, preserved here for
|
|
# behavioral parity. stage_spec covers the fields both stage configs
|
|
# share structurally (generator, lambda, router, ddpm, and wgan's
|
|
# base fields — Stage2ModelConfig's sub-configs all subclass
|
|
# stage 1's).
|
|
s2_spec = Stage2ModelConfig.from_dict(cfg["stage2_model"])
|
|
stage_spec = s2_spec if is_stage2 else Stage1ModelConfig.from_dict(cfg["stage1_model"])
|
|
return cls(
|
|
name=name,
|
|
is_stage2=is_stage2,
|
|
generator=stage_spec.generator,
|
|
decoder=s2_spec.decoder if is_stage2 else "one_shot",
|
|
lambda_weight=stage_spec.lambda_weight,
|
|
n_sec_lambda=s2_spec.n_sec.lambda_weight,
|
|
n_sec_mode=s2_spec.n_sec.mode,
|
|
particle_type=s2_spec.particle_type,
|
|
particle_type_n_classes=resolve_type_n_classes(
|
|
s2_spec.particle_type, cfg["conditioning"]["particle"]["emb_dim"]
|
|
),
|
|
# train.* keys are all guaranteed by DEFAULT_CONFIG's deep-merge
|
|
# (giant/config.py), so TrainConfig.from_dict never has to fall
|
|
# back to a literal here; 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=stage_spec.router.lambda_balance,
|
|
lambda_proc=stage_spec.router.lambda_proc,
|
|
lambda_entropy=stage_spec.router.lambda_entropy,
|
|
gumbel_tau_start=stage_spec.router.gumbel_tau_start,
|
|
gumbel_tau_end=stage_spec.router.gumbel_tau_end,
|
|
teacher_forcing=s2_spec.autoregressive.teacher_forcing if is_stage2 else cls.teacher_forcing,
|
|
tf_p_start=s2_spec.autoregressive.tf_p_start if is_stage2 else cls.tf_p_start,
|
|
tf_p_end=s2_spec.autoregressive.tf_p_end if is_stage2 else cls.tf_p_end,
|
|
# 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 (the autoregressive config lists
|
|
# tf_p_start/tf_p_end/attn_n_heads/attn_n_layers only).
|
|
ar_sample_steps=t.validate_steps,
|
|
ddpm_n_steps=stage_spec.ddpm.n_steps,
|
|
n_critic=stage_spec.wgan.n_critic,
|
|
gp_weight=stage_spec.wgan.gp_weight,
|
|
critic_lr=stage_spec.wgan.critic_lr,
|
|
type_gumbel_tau_start=s2_spec.wgan.gumbel_tau_start if is_stage2 else cls.type_gumbel_tau_start,
|
|
type_gumbel_tau_end=s2_spec.wgan.gumbel_tau_end if is_stage2 else cls.type_gumbel_tau_end,
|
|
)
|
|
|
|
|
|
class StageTrainer:
|
|
"""One active stage's optimizer(s), EMA, and per-batch step.
|
|
|
|
Reads only the shared `StepBatch` (`giant.data.dataset`) — stage 2 always
|
|
conditions on the ground-truth `x1_s1` (`stage2_model.stage1_context =
|
|
"truth"`, stage-level teacher forcing; `"sampled"` is not implemented),
|
|
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" 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 = spec.particle_type
|
|
self.particle_type_n_classes = spec.particle_type_n_classes
|
|
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: StepBatch, device: torch.device, global_step: int) -> dict:
|
|
raise NotImplementedError
|
|
|
|
def val_loss(self, batch: StepBatch, 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_n_classes,
|
|
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_n_classes,
|
|
)
|
|
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 `mode =
|
|
"stop_token"` model carries no head at all (see `_stop_loss` for its
|
|
EOS signal instead), so this correctly stays zero for it.
|
|
"""
|
|
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
|
|
|
|
def _stop_loss(
|
|
self,
|
|
cond_cont: torch.Tensor,
|
|
cond_cat: torch.Tensor,
|
|
stage1_ctx: torch.Tensor,
|
|
n_sec: torch.Tensor,
|
|
device: torch.device,
|
|
ar_inputs: dict[str, torch.Tensor] | None,
|
|
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
"""`(l_stop, stop_acc)` for `n_sec.mode = "stop_token"`'s per-slot EOS
|
|
head (`Stage2Autoregressive.predict_stop`) — zeros when this stage
|
|
owns no `stop_head` (every other `n_sec.mode`), the same gating
|
|
convention `_n_sec_loss` uses for `n_sec_head`. The two heads are
|
|
mutually exclusive (`giant.model.builders`), so exactly one of
|
|
`_n_sec_loss`/`_stop_loss` is ever non-zero for a given stage.
|
|
|
|
Masked BCE against `_stop_target_and_mask`'s per-slot target — one
|
|
slot wider than `sec_mask` (the stop slot itself, `k == n_sec`, needs
|
|
supervision even though it holds no real secondary)."""
|
|
stop_head = getattr(self.model, "stop_head", None)
|
|
if stop_head is None:
|
|
zero = torch.zeros((), device=device)
|
|
return zero, zero
|
|
assert ar_inputs is not None
|
|
logits = self.model.predict_stop(
|
|
cond_cont,
|
|
cond_cat,
|
|
stage1_ctx,
|
|
ar_inputs["history_feat"],
|
|
ar_inputs["has_prev"],
|
|
ar_inputs["remaining_frac"],
|
|
ar_inputs["slot_idx"],
|
|
)
|
|
target, mask = _stop_target_and_mask(n_sec, logits.size(1), device)
|
|
mask_f = mask.float()
|
|
denom = mask_f.sum().clamp(min=1)
|
|
bce = F.binary_cross_entropy_with_logits(logits, target, reduction="none")
|
|
l_stop = (bce * mask_f).sum() / denom
|
|
stop_acc = (((logits >= 0).float() == target).float() * mask_f).sum() / denom
|
|
return l_stop, stop_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:
|
|
objective = build_objective(spec.generator, n_steps=spec.ddpm_n_steps)
|
|
if spec.is_stage2 and not objective.supports_stage2_decoder:
|
|
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)"
|
|
)
|
|
super().__init__(spec, model, device)
|
|
self.objective = objective
|
|
self.particle_type_lambda = self.particle_type_cfg.lambda_weight
|
|
# Width of the type slice actually folded into x1_s2 by _sec_target,
|
|
# under this trainer's objective (flow/ddpm only — see the
|
|
# NotImplementedError above, neither folds the type slice): "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.target == "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 = self.objective.build_schedule(spec.ddpm_n_steps, device)
|
|
|
|
self.train_metrics = [
|
|
train_metric(key)
|
|
for key in (
|
|
"loss",
|
|
"loss_gen",
|
|
"loss_nsec",
|
|
"loss_stop",
|
|
"loss_balance",
|
|
"loss_proc",
|
|
"loss_entropy",
|
|
"nsec_acc",
|
|
"stop_acc",
|
|
"loss_type",
|
|
"type_acc",
|
|
"grad_norm",
|
|
)
|
|
]
|
|
self.val_metrics = [
|
|
val_metric(key)
|
|
for key in (
|
|
"loss",
|
|
"loss_gen",
|
|
"loss_nsec",
|
|
"nsec_acc",
|
|
"loss_stop",
|
|
"stop_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:
|
|
return self.objective.stage1_loss(self.model, x1_s1, cond_cont, cond_cat, schedule=self.ddpm_schedule)
|
|
assert self.decoder != "autoregressive" or ar_inputs is not None
|
|
return self.objective.stage2_loss(
|
|
self.model,
|
|
x1_s2,
|
|
cond_cont,
|
|
cond_cat,
|
|
stage1_ctx,
|
|
sec_mask,
|
|
type_dim=self._flow_type_dim,
|
|
ar_inputs=ar_inputs,
|
|
)
|
|
|
|
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.
|
|
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.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: StepBatch, 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_stop, stop_acc = self._stop_loss(cond_cont, cond_cat, stage1_ctx, n_sec, device, ar_inputs)
|
|
|
|
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:
|
|
if self.spec.lambda_balance > 0:
|
|
l_balance = self.router.balance_loss(cond_cont, cond_cat)
|
|
if self.spec.lambda_proc > 0:
|
|
l_proc = self.router.classify_loss(cond_cont, cond_cat, proc_idx)
|
|
if self.spec.lambda_entropy > 0:
|
|
l_entropy = self.router.entropy_loss(cond_cont, cond_cat)
|
|
|
|
total = (
|
|
self.spec.lambda_weight * l_gen
|
|
+ self.spec.n_sec_lambda * (l_nsec + l_stop)
|
|
+ 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_stop": l_stop,
|
|
"loss_type": l_type,
|
|
"type_acc": type_acc,
|
|
"loss_balance": l_balance,
|
|
"loss_proc": l_proc,
|
|
"loss_entropy": l_entropy,
|
|
"nsec_acc": nsec_acc,
|
|
"stop_acc": stop_acc,
|
|
}
|
|
|
|
def step(self, batch: StepBatch, 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: StepBatch, 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 _Stage2RealFakeBatch(NamedTuple):
|
|
"""Subset of `StepBatch` that `_stage2_real_and_fake` needs."""
|
|
|
|
cond_cont: torch.Tensor
|
|
cond_cat: torch.Tensor
|
|
n_sec: torch.Tensor
|
|
sec_cont: torch.Tensor
|
|
sec_type_idx: torch.Tensor
|
|
|
|
|
|
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",
|
|
"loss_stop",
|
|
"stop_acc",
|
|
"grad_norm_d",
|
|
"grad_norm_g",
|
|
]
|
|
if self.is_stage2 and self.particle_type_cfg.target == "onehot":
|
|
# 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: _Stage2RealFakeBatch, stage1_ctx, global_step, device):
|
|
"""Build `(real, fake_raw, mask, critic_fn, ar_inputs)` 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. `ar_inputs` is `None` under
|
|
`decoder = "one_shot"`; under `"autoregressive"` it's the same dict
|
|
`_ar_inputs` built to condition `self.model` above — returned so the
|
|
caller's `_stop_loss` reuses it instead of paying for a second
|
|
(possibly self-sampling) `_ar_inputs` call."""
|
|
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_n_classes)
|
|
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)
|
|
|
|
ar_inputs = None
|
|
if self.decoder == "autoregressive":
|
|
epoch = global_step // self.spec.steps_per_epoch
|
|
ar_inputs = 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, self.generator, 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_inputs["history_feat"],
|
|
ar_inputs["has_prev"],
|
|
ar_inputs["remaining_frac"],
|
|
ar_inputs["slot_idx"],
|
|
).reshape(B, -1)
|
|
else:
|
|
real = self._sec_target(sec_cont, sec_type_idx, self.generator, 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, ar_inputs
|
|
|
|
def step(self, batch: StepBatch, 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] = {}
|
|
|
|
ar_inputs = None
|
|
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, ar_inputs = self._stage2_real_and_fake(
|
|
_Stage2RealFakeBatch(cond_cont, cond_cat, n_sec, sec_cont, sec_type_idx),
|
|
stage1_ctx,
|
|
global_step,
|
|
device,
|
|
)
|
|
if self.particle_type_cfg.target == "onehot":
|
|
# Straight-through Gumbel-softmax relaxation of the type
|
|
# slice only — 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 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_n_classes),
|
|
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)
|
|
l_stop, stop_acc = self._stop_loss(cond_cont, cond_cat, stage1_ctx, n_sec, device, ar_inputs)
|
|
|
|
# On a non-generator-step batch with no n_sec_head/stop_head on this
|
|
# stage (n_sec now defaults to stage 2), 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 and self.model.stop_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 + l_stop)
|
|
else:
|
|
g_loss_adv = torch.zeros((), device=device)
|
|
g_loss = self.spec.n_sec_lambda * (l_nsec + l_stop)
|
|
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(),
|
|
"loss_stop": l_stop.item(),
|
|
"stop_acc": stop_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} 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 build_objective(spec.generator).is_adversarial:
|
|
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
|