da5f54ea1c
CI / Lint (ruff check) (push) Successful in 28s
CI / Format (ruff format) (push) Successful in 29s
CI / Sync project version with tag (push) Has been skipped
CI / Type check (ty) (push) Successful in 27s
CI / Tests (push) Successful in 1m1s
CI / Lint (ruff check) (pull_request) Successful in 30s
CI / Format (ruff format) (pull_request) Successful in 27s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 27s
CI / Tests (pull_request) Successful in 59s
EnergyRouter's gate sharpness was a single fixed temperature shared by every expert, with no way for an expert to independently learn how much of the energy axis it covers. Adds two mutually exclusive, default-off modes: learn_width (per-expert learnable width) and learn_temperature (single learnable shared scalar), both bounded via a sigmoid interpolation warm-started to reproduce today's fixed-temperature gate exactly at init, to compare against each other without risking the unbounded-width collapse failure mode. Also promotes gate_stats's entropy into a generic, optional Router.entropy_loss (lambda_entropy) as a secondary guard against all experts' widths co-inflating together. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1036 lines
42 KiB
Python
1036 lines
42 KiB
Python
import copy
|
|
import csv
|
|
import math
|
|
import os
|
|
import signal
|
|
import time
|
|
from pathlib import Path
|
|
from types import FrameType
|
|
from typing import Callable
|
|
|
|
import numpy as np
|
|
import torch
|
|
import torch.nn.functional as F
|
|
import torch.optim as optim
|
|
from torch.utils.data import DataLoader
|
|
from tqdm import tqdm
|
|
|
|
from giant.constants import K_MAX, SEC_SLOT_DIM
|
|
from giant.model.schedule import (
|
|
CosineSchedule,
|
|
flow_matching_loss,
|
|
flow_matching_loss_secondary,
|
|
)
|
|
from giant.model.wgan import gradient_penalty, generator_loss
|
|
from giant.validate import validate_marginals
|
|
|
|
_METRICS_FIELDS = [
|
|
"epoch",
|
|
"train_loss",
|
|
"train_loss_s1",
|
|
"train_loss_nsec",
|
|
"train_loss_s2",
|
|
"train_loss_balance",
|
|
"train_loss_proc",
|
|
"train_loss_entropy",
|
|
"train_nsec_acc",
|
|
"d_loss",
|
|
"g_loss",
|
|
"wasserstein_estimate",
|
|
"gp_loss",
|
|
"val_loss",
|
|
"val_loss_s1",
|
|
"val_loss_nsec",
|
|
"val_loss_s2",
|
|
"val_loss_balance",
|
|
"val_loss_proc",
|
|
"val_loss_entropy",
|
|
"val_nsec_acc",
|
|
"val_marginal_kl",
|
|
"router_s1_entropy",
|
|
"router_s1_util_min",
|
|
"router_s1_util_max",
|
|
"router_s1_util_std",
|
|
"router_s2_entropy",
|
|
"router_s2_util_min",
|
|
"router_s2_util_max",
|
|
"router_s2_util_std",
|
|
"lr",
|
|
"critic_lr",
|
|
"grad_norm",
|
|
"grad_norm_d",
|
|
"grad_norm_g",
|
|
"gpu_mem_mb",
|
|
"samples_per_sec",
|
|
"is_best",
|
|
"epoch_time_s",
|
|
]
|
|
|
|
_CATCHABLE_SIGNALS = (signal.SIGINT, signal.SIGTERM)
|
|
|
|
|
|
class _GracefulShutdown:
|
|
"""Turns SIGINT/SIGTERM into a flag check instead of an immediate crash.
|
|
|
|
A second signal while already shutting down restores the default
|
|
handler and re-sends the signal, so an unresponsive run can still be
|
|
force-killed.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.requested = False
|
|
self._previous: dict[
|
|
int,
|
|
Callable[[int, FrameType | None], object] | signal.Handlers | int | None,
|
|
] = {}
|
|
|
|
def __enter__(self) -> "_GracefulShutdown":
|
|
for sig in _CATCHABLE_SIGNALS:
|
|
self._previous[sig] = signal.getsignal(sig)
|
|
signal.signal(sig, self._handle)
|
|
return self
|
|
|
|
def __exit__(self, *exc_info) -> None:
|
|
for sig, handler in self._previous.items():
|
|
signal.signal(sig, handler)
|
|
|
|
def _handle(self, signum: int, frame) -> None:
|
|
if self.requested:
|
|
signal.signal(signum, self._previous[signum])
|
|
os.kill(os.getpid(), signum)
|
|
return
|
|
self.requested = True
|
|
print(
|
|
f"\nreceived {signal.Signals(signum).name} — finishing the current "
|
|
"batch, then saving a checkpoint and exiting (send again to force-quit)"
|
|
)
|
|
|
|
|
|
@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 _compute_losses(
|
|
stage1_model: torch.nn.Module,
|
|
sec_decoder: torch.nn.Module,
|
|
batch: tuple,
|
|
mode: str,
|
|
ddpm_schedule,
|
|
device: torch.device,
|
|
lambda_nsec: float,
|
|
lambda_s2: float,
|
|
lambda_balance: float = 0.0,
|
|
lambda_proc: float = 0.0,
|
|
lambda_entropy: float = 0.0,
|
|
) -> tuple[
|
|
torch.Tensor,
|
|
torch.Tensor,
|
|
torch.Tensor,
|
|
torch.Tensor,
|
|
torch.Tensor,
|
|
torch.Tensor,
|
|
torch.Tensor,
|
|
torch.Tensor,
|
|
]:
|
|
"""Compute (total_loss, L_s1, L_nsec, L_s2, L_balance, L_proc, L_entropy, nsec_acc) for one batch."""
|
|
cond_cont, cond_cat, x1_s1, n_sec, sec_cont, proc_idx = batch
|
|
cond_cont = cond_cont.to(device)
|
|
cond_cat = cond_cat.to(device)
|
|
x1_s1 = x1_s1.to(device)
|
|
n_sec = n_sec.to(device)
|
|
sec_cont = sec_cont.to(device)
|
|
proc_idx = proc_idx.to(device)
|
|
|
|
# Stage-1 flow loss
|
|
if mode == "flow":
|
|
l_s1 = flow_matching_loss(stage1_model, x1_s1, cond_cont, cond_cat)
|
|
else:
|
|
assert ddpm_schedule is not None
|
|
l_s1 = ddpm_schedule.loss(stage1_model, x1_s1, cond_cont, cond_cat)
|
|
|
|
# n_sec classification loss
|
|
n_sec_logits = stage1_model.predict_n_sec(cond_cont, cond_cat)
|
|
l_nsec = F.cross_entropy(n_sec_logits, n_sec)
|
|
nsec_acc = (n_sec_logits.argmax(dim=-1) == n_sec).float().mean()
|
|
|
|
# Stage-2 secondary flow loss
|
|
# Use a noiseless Stage-1 target as context (detach to avoid back-prop
|
|
# coupling between the two flow paths). sec_cont's log_mass/charge
|
|
# columns are already a fixed physics-derived regression target (see
|
|
# giant.data.transforms.encode_secondaries) rather than a learned/moving
|
|
# one, so — unlike the embedding-table target this replaced — no
|
|
# detaching is needed to keep the target from chasing the decoder.
|
|
x1_s2 = sec_cont.flatten(1) # (B, SEC_DIM)
|
|
|
|
sec_mask = torch.arange(K_MAX, device=device).unsqueeze(0) < n_sec.unsqueeze(1)
|
|
l_s2 = flow_matching_loss_secondary(
|
|
sec_decoder,
|
|
x1_s2,
|
|
cond_cont,
|
|
cond_cat,
|
|
x1_s1.detach(),
|
|
sec_mask,
|
|
)
|
|
|
|
# Optional MoE load-balance auxiliary loss: only present when both stages
|
|
# are routed (RoutedDenoisingMLP/RoutedSecondaryDecoder carry `.router`,
|
|
# the monolith models don't), computed on cond_cont alone (cheap — no
|
|
# trunk compute) so it's reported even when lambda_balance == 0.
|
|
if hasattr(stage1_model, "router") and hasattr(sec_decoder, "router"):
|
|
l_balance = stage1_model.router.balance_loss(
|
|
cond_cont, cond_cat
|
|
) + sec_decoder.router.balance_loss(cond_cont, cond_cat)
|
|
# Supervised router auxiliary loss (e.g. ProcessRouter's process
|
|
# classifier); a scalar 0 for routers with no such loss (EnergyRouter).
|
|
l_proc = stage1_model.router.classify_loss(
|
|
cond_cont, cond_cat, proc_idx
|
|
) + sec_decoder.router.classify_loss(cond_cont, cond_cat, proc_idx)
|
|
# Optional entropy-regularization aux loss (see Router.entropy_loss):
|
|
# penalizes uniform/collapsed gating, a secondary guard against
|
|
# gate-sharpness collapse that lambda_balance alone can't see.
|
|
l_entropy = stage1_model.router.entropy_loss(
|
|
cond_cont, cond_cat
|
|
) + sec_decoder.router.entropy_loss(cond_cont, cond_cat)
|
|
else:
|
|
l_balance = torch.zeros((), device=device)
|
|
l_proc = torch.zeros((), device=device)
|
|
l_entropy = torch.zeros((), device=device)
|
|
|
|
total = l_s1 + lambda_nsec * l_nsec + lambda_s2 * l_s2
|
|
if lambda_balance > 0:
|
|
total = total + lambda_balance * l_balance
|
|
if lambda_proc > 0:
|
|
total = total + lambda_proc * l_proc
|
|
if lambda_entropy > 0:
|
|
total = total + lambda_entropy * l_entropy
|
|
return total, l_s1, l_nsec, l_s2, l_balance, l_proc, l_entropy, nsec_acc
|
|
|
|
|
|
def _wgan_train_step(
|
|
generator: torch.nn.Module,
|
|
sec_generator: torch.nn.Module,
|
|
critic: torch.nn.Module,
|
|
sec_critic: torch.nn.Module,
|
|
batch: tuple,
|
|
device: torch.device,
|
|
optimizer_g: optim.Optimizer,
|
|
optimizer_d: optim.Optimizer,
|
|
g_params: list,
|
|
d_params: list,
|
|
step_count: int,
|
|
n_critic: int,
|
|
gp_weight: float,
|
|
lambda_nsec: float,
|
|
lambda_s2: float,
|
|
) -> dict:
|
|
"""One WGAN-GP training step, both stages (see giant/model/wgan.py for the losses).
|
|
|
|
Both critics update every batch. Every `n_critic`-th batch additionally
|
|
updates both generators. The (non-adversarial) n_sec classifier updates
|
|
every batch regardless — folded into whichever `optimizer_g` step happens
|
|
this batch (full adversarial g_loss on generator batches, n_sec-only in
|
|
between) rather than throttled to the generator's cadence, since n_sec
|
|
accuracy is a headline flow-vs-wgan comparison metric and shares the
|
|
generator's ConditionEncoder.
|
|
|
|
Stage 2's real/fake target is a flattened (B, SEC_DIM) vector with
|
|
`K_MAX - n_sec` padded slots per row; both critic's input and its
|
|
gradient-penalty gradient are masked to the valid slots (see
|
|
`giant.model.wgan.gradient_penalty`) so the critic can't key on padding
|
|
instead of genuine content. Stage 2 is conditioned on the *real*
|
|
ground-truth Stage-1 target (`x1_s1`, detached) rather than the
|
|
generator's own fake Stage-1 output — same precedent as the flow-matching
|
|
path's `flow_matching_loss_secondary` call, avoiding compounding errors
|
|
during training.
|
|
"""
|
|
cond_cont, cond_cat, x1_s1, n_sec, sec_cont, _proc_idx = batch
|
|
cond_cont = cond_cont.to(device)
|
|
cond_cat = cond_cat.to(device)
|
|
x1_s1 = x1_s1.to(device)
|
|
n_sec = n_sec.to(device)
|
|
sec_cont = sec_cont.to(device)
|
|
|
|
B = x1_s1.size(0)
|
|
x1_s2 = sec_cont.flatten(1) # (B, SEC_DIM)
|
|
sec_mask = torch.arange(K_MAX, device=device).unsqueeze(0) < n_sec.unsqueeze(1)
|
|
mask_flat = (
|
|
sec_mask.unsqueeze(-1).expand(-1, -1, SEC_SLOT_DIM).reshape(B, -1).float()
|
|
)
|
|
stage1_ctx = x1_s1.detach()
|
|
|
|
def critic_fn1(x: torch.Tensor) -> torch.Tensor:
|
|
return critic(x, cond_cont, cond_cat)
|
|
|
|
def critic_fn2(x: torch.Tensor) -> torch.Tensor:
|
|
return sec_critic(x, cond_cont, cond_cat, stage1_ctx)
|
|
|
|
z1 = torch.randn(B, generator.noise_dim, device=device)
|
|
fake1 = generator(z1, cond_cont, cond_cat)
|
|
z2 = torch.randn(B, sec_generator.noise_dim, device=device)
|
|
fake2 = sec_generator(z2, cond_cont, cond_cat, stage1_ctx)
|
|
fake2_masked = fake2 * mask_flat
|
|
real2_masked = x1_s2 * mask_flat
|
|
|
|
# --- Critic step (every batch) ---
|
|
fake1_detached = fake1.detach()
|
|
real1_score = critic_fn1(x1_s1)
|
|
fake1_score = critic_fn1(fake1_detached)
|
|
gp1 = gradient_penalty(critic_fn1, x1_s1, fake1_detached)
|
|
d1 = fake1_score.mean() - real1_score.mean() + gp_weight * gp1
|
|
wasserstein_estimate = (real1_score.mean() - fake1_score.mean()).detach()
|
|
|
|
fake2_detached_masked = fake2_masked.detach()
|
|
real2_score = critic_fn2(real2_masked)
|
|
fake2_score = critic_fn2(fake2_detached_masked)
|
|
gp2 = gradient_penalty(
|
|
critic_fn2, real2_masked, fake2_detached_masked, mask=mask_flat
|
|
)
|
|
d2 = fake2_score.mean() - real2_score.mean() + gp_weight * gp2
|
|
|
|
d_loss = d1 + lambda_s2 * d2
|
|
optimizer_d.zero_grad()
|
|
d_loss.backward()
|
|
grad_norm_d = torch.nn.utils.clip_grad_norm_(d_params, 1.0)
|
|
optimizer_d.step()
|
|
|
|
# --- Generator (+ n_sec) step ---
|
|
did_g_step = step_count % n_critic == 0
|
|
n_sec_logits = generator.predict_n_sec(cond_cont, cond_cat)
|
|
l_nsec = F.cross_entropy(n_sec_logits, n_sec)
|
|
nsec_acc = (n_sec_logits.argmax(dim=-1) == n_sec).float().mean()
|
|
optimizer_g.zero_grad()
|
|
if did_g_step:
|
|
g1 = generator_loss(critic_fn1, fake1)
|
|
g2 = generator_loss(critic_fn2, fake2_masked)
|
|
g_loss = g1 + lambda_nsec * l_nsec + lambda_s2 * g2
|
|
else:
|
|
g1 = torch.zeros((), device=device)
|
|
g2 = torch.zeros((), device=device)
|
|
g_loss = lambda_nsec * l_nsec
|
|
g_loss.backward()
|
|
grad_norm_g = torch.nn.utils.clip_grad_norm_(g_params, 1.0)
|
|
optimizer_g.step()
|
|
|
|
return {
|
|
"d_loss": d_loss.detach(),
|
|
"g_loss": (g1 + lambda_s2 * g2).detach(),
|
|
"wasserstein_estimate": wasserstein_estimate,
|
|
"gp_loss": (gp1 + lambda_s2 * gp2).detach(),
|
|
"l_nsec": l_nsec.detach(),
|
|
"nsec_acc": nsec_acc.detach(),
|
|
"did_g_step": did_g_step,
|
|
"grad_norm": grad_norm_d.item() + grad_norm_g.item(),
|
|
"grad_norm_d": grad_norm_d.item(),
|
|
"grad_norm_g": grad_norm_g.item(),
|
|
}
|
|
|
|
|
|
def train(
|
|
stage1_model: torch.nn.Module,
|
|
sec_decoder: torch.nn.Module,
|
|
train_loader: DataLoader,
|
|
val_loader: DataLoader,
|
|
mode: str,
|
|
epochs: int,
|
|
lr: float,
|
|
warmup_epochs: int,
|
|
device: torch.device,
|
|
out_dir: str | Path,
|
|
weight_decay: float = 0.01,
|
|
ema_decay: float = 0.9999,
|
|
lambda_nsec: float = 0.1,
|
|
lambda_s2: float = 1.0,
|
|
lambda_balance: float = 0.0,
|
|
lambda_proc: float = 0.0,
|
|
lambda_entropy: float = 0.0,
|
|
normalizer_dict: dict | None = None,
|
|
pdg_map: dict | None = None,
|
|
mat_map: dict | None = None,
|
|
proc_map: dict | None = None,
|
|
model_config: dict | None = None,
|
|
resume_path: str | Path | None = None,
|
|
validate_every: int = 0,
|
|
validate_steps: int = 10,
|
|
max_val_batches: int = 0,
|
|
total_train_batches: int = 0,
|
|
critic: torch.nn.Module | None = None,
|
|
sec_critic: torch.nn.Module | None = None,
|
|
n_critic: int = 5,
|
|
gp_weight: float = 10.0,
|
|
critic_lr: float | None = None,
|
|
use_wandb: bool = False,
|
|
wandb_project: str = "giant",
|
|
wandb_run_name: str = "",
|
|
wandb_log_every: int = 50,
|
|
) -> None:
|
|
out_dir = Path(out_dir)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
stage1_params = sum(p.numel() for p in stage1_model.parameters())
|
|
sec_decoder_params = sum(p.numel() for p in sec_decoder.parameters())
|
|
critic_params = (
|
|
sum(p.numel() for p in critic.parameters()) if critic is not None else 0
|
|
)
|
|
sec_critic_params = (
|
|
sum(p.numel() for p in sec_critic.parameters()) if sec_critic is not None else 0
|
|
)
|
|
total_params = (
|
|
stage1_params + sec_decoder_params + critic_params + sec_critic_params
|
|
)
|
|
|
|
wandb_run = None
|
|
if use_wandb:
|
|
try:
|
|
import wandb
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"train.wandb = true (--wandb) requires the 'wandb' package — "
|
|
"install it via `uv sync --extra wandb`"
|
|
) from exc
|
|
# `id` is derived from out_dir so resuming a run (--resume) reattaches
|
|
# to the same wandb run instead of starting a new one.
|
|
wandb_run = wandb.init(
|
|
project=wandb_project,
|
|
name=wandb_run_name or out_dir.name,
|
|
id=out_dir.name,
|
|
resume="allow",
|
|
config={
|
|
"mode": mode,
|
|
"epochs": epochs,
|
|
"lr": lr,
|
|
"warmup_epochs": warmup_epochs,
|
|
"weight_decay": weight_decay,
|
|
"ema_decay": ema_decay,
|
|
"lambda_nsec": lambda_nsec,
|
|
"lambda_s2": lambda_s2,
|
|
"lambda_balance": lambda_balance,
|
|
"lambda_proc": lambda_proc,
|
|
"lambda_entropy": lambda_entropy,
|
|
"n_critic": n_critic,
|
|
"gp_weight": gp_weight,
|
|
"model": model_config or {},
|
|
"stage1_params": stage1_params,
|
|
"sec_decoder_params": sec_decoder_params,
|
|
"critic_params": critic_params,
|
|
"sec_critic_params": sec_critic_params,
|
|
"total_params": total_params,
|
|
},
|
|
)
|
|
|
|
stage1_model = stage1_model.to(device)
|
|
sec_decoder = sec_decoder.to(device)
|
|
if mode == "wgan":
|
|
assert critic is not None and sec_critic is not None, (
|
|
"mode='wgan' requires critic/sec_critic (see giant.model.network.build_critics)"
|
|
)
|
|
critic = critic.to(device)
|
|
sec_critic = sec_critic.to(device)
|
|
|
|
# MoE routing trunk (RoutedDenoisingMLP/RoutedSecondaryDecoder) is
|
|
# optional and orthogonal to `mode` — both stages carry a `.router`
|
|
# when enabled. Each router is an independent instance (their
|
|
# `n_experts` need not match), used both for the batch-level gate
|
|
# entropy snapshot below and the val-level gate stats further down.
|
|
has_router = hasattr(stage1_model, "router") and hasattr(sec_decoder, "router")
|
|
|
|
# Flow-matching/diffusion models sample noticeably better from an EMA of
|
|
# the weights than from the raw SGD-noisy ones — buffers (e.g. the fixed
|
|
# sinusoidal-embedding freqs, or non-learned router centers) never change
|
|
# after this initial copy, so only parameters need the running average.
|
|
ema_stage1_model: torch.nn.Module | None = None
|
|
ema_sec_decoder: torch.nn.Module | None = None
|
|
if ema_decay > 0:
|
|
ema_stage1_model = copy.deepcopy(stage1_model).eval()
|
|
ema_sec_decoder = copy.deepcopy(sec_decoder).eval()
|
|
for p in ema_stage1_model.parameters():
|
|
p.requires_grad_(False)
|
|
for p in ema_sec_decoder.parameters():
|
|
p.requires_grad_(False)
|
|
|
|
all_params = list(stage1_model.parameters()) + list(sec_decoder.parameters())
|
|
all_params_d: list = []
|
|
optimizer_d: optim.Optimizer | None = None
|
|
if mode == "wgan":
|
|
assert critic is not None and sec_critic is not None
|
|
# Standard WGAN-GP recipe (Gulrajani et al. 2017): Adam with
|
|
# beta1=0 (momentum destabilizes critic training) and no weight
|
|
# decay, rather than the AdamW(weight_decay=...) used for flow/ddpm.
|
|
optimizer = optim.Adam(all_params, lr=lr, betas=(0.0, 0.9))
|
|
all_params_d = list(critic.parameters()) + list(sec_critic.parameters())
|
|
optimizer_d = optim.Adam(
|
|
all_params_d,
|
|
lr=critic_lr if critic_lr is not None else lr,
|
|
betas=(0.0, 0.9),
|
|
)
|
|
else:
|
|
optimizer = optim.AdamW(all_params, lr=lr, weight_decay=weight_decay)
|
|
|
|
# Warmup/decay in units of optimizer steps rather than epochs: at large
|
|
# dataset sizes a single epoch can be tens of thousands of steps, and an
|
|
# epoch-granularity schedule would leave warmup/cosine decay unable to
|
|
# move within it. Requires an accurate `total_train_batches` (steps per
|
|
# epoch); the only caller, run_train_job, always supplies one.
|
|
#
|
|
# In wgan mode, `lr_sched.step()`/EMA only fire on generator steps (see
|
|
# the per-batch loop below) — 1 in every `n_critic` batches — so the
|
|
# schedule's own step-counting must be in those same units, or warmup
|
|
# would never finish and cosine decay would barely move.
|
|
steps_per_epoch = max(total_train_batches, 1)
|
|
if mode == "wgan":
|
|
steps_per_epoch = max(total_train_batches // (n_critic + 1), 1)
|
|
warmup_steps = warmup_epochs * steps_per_epoch
|
|
total_steps = max(epochs * steps_per_epoch, 1)
|
|
|
|
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))
|
|
|
|
lr_sched = optim.lr_scheduler.LambdaLR(optimizer, _lr_lambda)
|
|
|
|
ddpm_schedule = CosineSchedule().to(device) if mode == "ddpm" else None
|
|
|
|
start_epoch = 1
|
|
best_val_loss = float("inf")
|
|
resumed_global_step = 0
|
|
if resume_path is not None:
|
|
ckpt = torch.load(resume_path, map_location=device, weights_only=False)
|
|
stage1_model.load_state_dict(ckpt["model"])
|
|
sec_decoder.load_state_dict(ckpt["sec_decoder"])
|
|
if ema_decay > 0:
|
|
assert ema_stage1_model is not None and ema_sec_decoder is not None
|
|
ema_stage1_model.load_state_dict(ckpt.get("model_ema", ckpt["model"]))
|
|
ema_sec_decoder.load_state_dict(
|
|
ckpt.get("sec_decoder_ema", ckpt["sec_decoder"])
|
|
)
|
|
if mode == "wgan":
|
|
assert (
|
|
critic is not None
|
|
and sec_critic is not None
|
|
and optimizer_d is not None
|
|
)
|
|
critic.load_state_dict(ckpt["critic"])
|
|
sec_critic.load_state_dict(ckpt["sec_critic"])
|
|
optimizer_d.load_state_dict(ckpt["optimizer_d"])
|
|
optimizer.load_state_dict(ckpt["optimizer"])
|
|
lr_sched.load_state_dict(ckpt["lr_sched"])
|
|
start_epoch = ckpt.get("epoch", 0) + 1
|
|
best_val_loss = ckpt.get("best_val_loss", float("inf"))
|
|
resumed_global_step = ckpt.get("global_step", 0)
|
|
|
|
# optimizer/lr_sched.load_state_dict() above restore the checkpoint's
|
|
# own base LR, which would otherwise silently override an explicit
|
|
# `lr` argument. Make `lr` authoritative again, applied at whatever
|
|
# point the cosine/warmup schedule has already reached.
|
|
lr_sched.base_lrs = [lr for _ in lr_sched.base_lrs]
|
|
resumed_lr = lr * _lr_lambda(lr_sched.last_epoch)
|
|
for group in optimizer.param_groups:
|
|
group["lr"] = resumed_lr
|
|
|
|
if start_epoch > epochs:
|
|
print(
|
|
f"checkpoint already completed epoch {start_epoch - 1} "
|
|
f"(>= --epochs {epochs}) — nothing to train"
|
|
)
|
|
return
|
|
|
|
metrics_path = out_dir / "metrics.csv"
|
|
resuming_existing_metrics = resume_path is not None and metrics_path.exists()
|
|
write_header = not resuming_existing_metrics
|
|
metrics_file = open(
|
|
metrics_path, "a" if resuming_existing_metrics else "w", newline=""
|
|
)
|
|
metrics_writer = csv.DictWriter(metrics_file, fieldnames=_METRICS_FIELDS)
|
|
if write_header:
|
|
metrics_writer.writeheader()
|
|
|
|
epoch_w = len(str(epochs))
|
|
last_completed_epoch = start_epoch - 1
|
|
# Restored from the checkpoint on --resume so wandb_run.log(..., step=...)
|
|
# keeps advancing monotonically instead of restarting at 0 mid-run (a
|
|
# reattached wandb run — see wandb.init(id=..., resume="allow") below —
|
|
# would otherwise silently drop every post-resume point).
|
|
global_step = resumed_global_step
|
|
with _GracefulShutdown() as shutdown:
|
|
for epoch in range(start_epoch, epochs + 1):
|
|
epoch_start = time.monotonic()
|
|
if device.type == "cuda":
|
|
torch.cuda.reset_peak_memory_stats(device)
|
|
stage1_model.train()
|
|
sec_decoder.train()
|
|
if mode == "wgan":
|
|
assert critic is not None and sec_critic is not None
|
|
critic.train()
|
|
sec_critic.train()
|
|
train_loss_sum = 0.0
|
|
train_s1_sum = 0.0
|
|
train_nsec_sum = 0.0
|
|
train_s2_sum = 0.0
|
|
train_balance_sum = 0.0
|
|
train_proc_sum = 0.0
|
|
train_entropy_sum = 0.0
|
|
train_d_sum = 0.0
|
|
train_g_sum = 0.0
|
|
train_wasserstein_sum = 0.0
|
|
train_gp_sum = 0.0
|
|
train_nsec_acc_sum = 0.0
|
|
train_grad_norm_d_sum = 0.0
|
|
train_grad_norm_g_sum = 0.0
|
|
train_n = 0
|
|
train_batches = 0
|
|
grad_norm_sum = 0.0
|
|
ema_loss = 0.0
|
|
ema_grad_norm = 0.0
|
|
bar = tqdm(
|
|
train_loader,
|
|
desc=f" epoch {epoch:{epoch_w}d}/{epochs}",
|
|
total=total_train_batches or None,
|
|
leave=False,
|
|
unit="batch",
|
|
dynamic_ncols=True,
|
|
)
|
|
for batch in bar:
|
|
if mode == "wgan":
|
|
assert (
|
|
critic is not None
|
|
and sec_critic is not None
|
|
and optimizer_d is not None
|
|
)
|
|
stats = _wgan_train_step(
|
|
stage1_model,
|
|
sec_decoder,
|
|
critic,
|
|
sec_critic,
|
|
batch,
|
|
device,
|
|
optimizer,
|
|
optimizer_d,
|
|
all_params,
|
|
all_params_d,
|
|
global_step,
|
|
n_critic,
|
|
gp_weight,
|
|
lambda_nsec,
|
|
lambda_s2,
|
|
)
|
|
if stats["did_g_step"]:
|
|
lr_sched.step()
|
|
if ema_decay > 0:
|
|
assert (
|
|
ema_stage1_model is not None
|
|
and ema_sec_decoder is not None
|
|
)
|
|
_update_ema(ema_stage1_model, stage1_model, ema_decay)
|
|
_update_ema(ema_sec_decoder, sec_decoder, ema_decay)
|
|
|
|
B = batch[0].size(0)
|
|
batch_loss = stats["d_loss"].item() + stats["g_loss"].item()
|
|
batch_grad_norm = stats["grad_norm"]
|
|
train_loss_sum += batch_loss * B
|
|
train_nsec_sum += stats["l_nsec"].item() * B
|
|
train_d_sum += stats["d_loss"].item() * B
|
|
train_g_sum += stats["g_loss"].item() * B
|
|
train_wasserstein_sum += stats["wasserstein_estimate"].item() * B
|
|
train_gp_sum += stats["gp_loss"].item() * B
|
|
train_nsec_acc_sum += stats["nsec_acc"].item() * B
|
|
train_grad_norm_d_sum += stats["grad_norm_d"] * B
|
|
train_grad_norm_g_sum += stats["grad_norm_g"] * B
|
|
else:
|
|
loss, l_s1, l_nsec, l_s2, l_balance, l_proc, l_entropy, nsec_acc = (
|
|
_compute_losses(
|
|
stage1_model,
|
|
sec_decoder,
|
|
batch,
|
|
mode,
|
|
ddpm_schedule,
|
|
device,
|
|
lambda_nsec,
|
|
lambda_s2,
|
|
lambda_balance,
|
|
lambda_proc,
|
|
lambda_entropy,
|
|
)
|
|
)
|
|
optimizer.zero_grad()
|
|
loss.backward()
|
|
grad_norm = torch.nn.utils.clip_grad_norm_(all_params, 1.0)
|
|
optimizer.step()
|
|
lr_sched.step()
|
|
if ema_decay > 0:
|
|
assert (
|
|
ema_stage1_model is not None and ema_sec_decoder is not None
|
|
)
|
|
_update_ema(ema_stage1_model, stage1_model, ema_decay)
|
|
_update_ema(ema_sec_decoder, sec_decoder, ema_decay)
|
|
|
|
B = batch[0].size(0)
|
|
batch_loss = loss.item()
|
|
batch_grad_norm = grad_norm.item()
|
|
train_loss_sum += batch_loss * B
|
|
train_s1_sum += l_s1.item() * B
|
|
train_nsec_sum += l_nsec.item() * B
|
|
train_s2_sum += l_s2.item() * B
|
|
train_balance_sum += l_balance.item() * B
|
|
train_proc_sum += l_proc.item() * B
|
|
train_entropy_sum += l_entropy.item() * B
|
|
train_nsec_acc_sum += nsec_acc.item() * B
|
|
|
|
train_n += B
|
|
train_batches += 1
|
|
grad_norm_sum += batch_grad_norm
|
|
ema_loss = (
|
|
batch_loss if train_n == B else 0.95 * ema_loss + 0.05 * batch_loss
|
|
)
|
|
ema_grad_norm = (
|
|
batch_grad_norm
|
|
if train_batches == 1
|
|
else 0.95 * ema_grad_norm + 0.05 * batch_grad_norm
|
|
)
|
|
bar.set_postfix_str(
|
|
f"loss={ema_loss:.4f} gnorm={ema_grad_norm:.3f}", refresh=False
|
|
)
|
|
|
|
global_step += 1
|
|
if (
|
|
wandb_run is not None
|
|
and wandb_log_every > 0
|
|
and global_step % wandb_log_every == 0
|
|
):
|
|
log_payload = {
|
|
"batch/epoch": epoch,
|
|
"batch/loss": batch_loss,
|
|
"batch/loss_ema": ema_loss,
|
|
"batch/grad_norm": batch_grad_norm,
|
|
"batch/lr": optimizer.param_groups[0]["lr"],
|
|
"batch/critic_lr": (
|
|
optimizer_d.param_groups[0]["lr"]
|
|
if optimizer_d is not None
|
|
else 0.0
|
|
),
|
|
}
|
|
if has_router:
|
|
# Cheap re-use of the batch already in hand — no
|
|
# extra data loading, just a small forward through
|
|
# each router's own gate function. Only entropy is
|
|
# logged at this granularity (not per-expert
|
|
# utilization): a single batch's importance sum is
|
|
# too noisy as a "global share" estimate, whereas
|
|
# the val-loop aggregate (below) sums over the
|
|
# whole val set for that. Batch-level entropy alone
|
|
# is still enough to see a router collapsing in
|
|
# real time, mid-epoch, rather than only at the
|
|
# next validation pass.
|
|
with torch.no_grad():
|
|
cond_cont_b = batch[0].to(device)
|
|
cond_cat_b = batch[1].to(device)
|
|
s1_entropy, _ = stage1_model.router.gate_stats(
|
|
cond_cont_b, cond_cat_b
|
|
)
|
|
s2_entropy, _ = sec_decoder.router.gate_stats(
|
|
cond_cont_b, cond_cat_b
|
|
)
|
|
log_payload["batch/router_s1_entropy"] = s1_entropy.item()
|
|
log_payload["batch/router_s2_entropy"] = s2_entropy.item()
|
|
wandb_run.log(log_payload, step=global_step)
|
|
|
|
if shutdown.requested:
|
|
break
|
|
bar.close()
|
|
|
|
if shutdown.requested:
|
|
break
|
|
|
|
train_loss = train_loss_sum / max(train_n, 1)
|
|
train_grad_norm = grad_norm_sum / max(train_batches, 1)
|
|
train_nsec_acc = train_nsec_acc_sum / max(train_n, 1)
|
|
train_grad_norm_d = train_grad_norm_d_sum / max(train_n, 1)
|
|
train_grad_norm_g = train_grad_norm_g_sum / max(train_n, 1)
|
|
current_lr = optimizer.param_groups[0]["lr"]
|
|
critic_lr_value = (
|
|
optimizer_d.param_groups[0]["lr"] if optimizer_d is not None else 0.0
|
|
)
|
|
|
|
stage1_model.eval()
|
|
sec_decoder.eval()
|
|
if mode == "wgan":
|
|
assert critic is not None and sec_critic is not None
|
|
critic.eval()
|
|
sec_critic.eval()
|
|
|
|
val_marginal_kl = float("nan")
|
|
if mode == "wgan":
|
|
# WGANGenerator.forward(z, cond_cont, cond_cat) has no
|
|
# diffusion/flow `t` argument, so the usual _compute_losses
|
|
# val loop below (which calls flow_matching_loss ->
|
|
# stage1_model(x_t, t, ...)) doesn't apply — and a WGAN
|
|
# critic loss isn't a monotone quality signal fit for
|
|
# best-checkpoint selection anyway. Select on marginal KL
|
|
# against the EMA generators instead (matches what
|
|
# predict/rollout sample from by default, --weights ema).
|
|
eval_stage1 = (
|
|
ema_stage1_model if ema_stage1_model is not None else stage1_model
|
|
)
|
|
eval_sec_decoder = (
|
|
ema_sec_decoder if ema_sec_decoder is not None else sec_decoder
|
|
)
|
|
marginal_result = validate_marginals(
|
|
eval_stage1,
|
|
val_loader,
|
|
mode=mode,
|
|
device=device,
|
|
sec_decoder=eval_sec_decoder,
|
|
)
|
|
val_marginal_kl = float(np.mean(marginal_result["kl_divergence"]))
|
|
val_loss = val_marginal_kl
|
|
val_s1_sum = val_nsec_sum = val_s2_sum = val_balance_sum = (
|
|
val_proc_sum
|
|
) = val_entropy_sum = val_nsec_acc_sum = 0.0
|
|
val_n = 1
|
|
val_nsec_acc = 0.0
|
|
router_s1_entropy = router_s2_entropy = 0.0
|
|
router_s1_util_min = router_s1_util_max = router_s1_util_std = 0.0
|
|
router_s2_util_min = router_s2_util_max = router_s2_util_std = 0.0
|
|
else:
|
|
val_loss_sum = 0.0
|
|
val_s1_sum = 0.0
|
|
val_nsec_sum = 0.0
|
|
val_s2_sum = 0.0
|
|
val_balance_sum = 0.0
|
|
val_proc_sum = 0.0
|
|
val_entropy_sum = 0.0
|
|
val_nsec_acc_sum = 0.0
|
|
val_n = 0
|
|
if has_router:
|
|
n_experts_s1 = stage1_model.router.n_experts
|
|
n_experts_s2 = sec_decoder.router.n_experts
|
|
val_router_s1_entropy_sum = 0.0
|
|
val_router_s2_entropy_sum = 0.0
|
|
val_router_s1_importance_sum = torch.zeros(
|
|
n_experts_s1, device=device
|
|
)
|
|
val_router_s2_importance_sum = torch.zeros(
|
|
n_experts_s2, device=device
|
|
)
|
|
with torch.no_grad():
|
|
for val_batch_idx, batch in enumerate(val_loader):
|
|
if max_val_batches > 0 and val_batch_idx >= max_val_batches:
|
|
break
|
|
(
|
|
loss,
|
|
l_s1,
|
|
l_nsec,
|
|
l_s2,
|
|
l_balance,
|
|
l_proc,
|
|
l_entropy,
|
|
nsec_acc,
|
|
) = _compute_losses(
|
|
stage1_model,
|
|
sec_decoder,
|
|
batch,
|
|
mode,
|
|
ddpm_schedule,
|
|
device,
|
|
lambda_nsec,
|
|
lambda_s2,
|
|
lambda_balance,
|
|
lambda_proc,
|
|
lambda_entropy,
|
|
)
|
|
B = batch[0].size(0)
|
|
val_loss_sum += loss.item() * B
|
|
val_s1_sum += l_s1.item() * B
|
|
val_nsec_sum += l_nsec.item() * B
|
|
val_s2_sum += l_s2.item() * B
|
|
val_balance_sum += l_balance.item() * B
|
|
val_proc_sum += l_proc.item() * B
|
|
val_entropy_sum += l_entropy.item() * B
|
|
val_nsec_acc_sum += nsec_acc.item() * B
|
|
if has_router:
|
|
cond_cont = batch[0].to(device)
|
|
cond_cat = batch[1].to(device)
|
|
s1_entropy, s1_importance = stage1_model.router.gate_stats(
|
|
cond_cont, cond_cat
|
|
)
|
|
s2_entropy, s2_importance = sec_decoder.router.gate_stats(
|
|
cond_cont, cond_cat
|
|
)
|
|
val_router_s1_entropy_sum += s1_entropy.item() * B
|
|
val_router_s2_entropy_sum += s2_entropy.item() * B
|
|
val_router_s1_importance_sum += s1_importance
|
|
val_router_s2_importance_sum += s2_importance
|
|
val_n += B
|
|
val_loss = val_loss_sum / max(val_n, 1)
|
|
val_nsec_acc = val_nsec_acc_sum / max(val_n, 1)
|
|
|
|
if has_router:
|
|
router_s1_entropy = val_router_s1_entropy_sum / max(val_n, 1)
|
|
router_s2_entropy = val_router_s2_entropy_sum / max(val_n, 1)
|
|
s1_util = val_router_s1_importance_sum / (
|
|
val_router_s1_importance_sum.sum().clamp_min(1e-8)
|
|
)
|
|
s2_util = val_router_s2_importance_sum / (
|
|
val_router_s2_importance_sum.sum().clamp_min(1e-8)
|
|
)
|
|
router_s1_util_min = s1_util.min().item()
|
|
router_s1_util_max = s1_util.max().item()
|
|
router_s1_util_std = (
|
|
s1_util.std().item() if n_experts_s1 > 1 else 0.0
|
|
)
|
|
router_s2_util_min = s2_util.min().item()
|
|
router_s2_util_max = s2_util.max().item()
|
|
router_s2_util_std = (
|
|
s2_util.std().item() if n_experts_s2 > 1 else 0.0
|
|
)
|
|
else:
|
|
router_s1_entropy = router_s2_entropy = 0.0
|
|
router_s1_util_min = router_s1_util_max = router_s1_util_std = 0.0
|
|
router_s2_util_min = router_s2_util_max = router_s2_util_std = 0.0
|
|
|
|
if validate_every > 0 and epoch % validate_every == 0:
|
|
print(f"[epoch {epoch}] marginal validation:")
|
|
marginal_result = validate_marginals(
|
|
stage1_model,
|
|
val_loader,
|
|
mode=mode,
|
|
schedule=ddpm_schedule,
|
|
device=device,
|
|
steps=validate_steps,
|
|
sec_decoder=sec_decoder,
|
|
)
|
|
val_marginal_kl = float(np.mean(marginal_result["kl_divergence"]))
|
|
|
|
epoch_time = time.monotonic() - epoch_start
|
|
gpu_mem_mb = (
|
|
torch.cuda.max_memory_allocated(device) / (1024 * 1024)
|
|
if device.type == "cuda"
|
|
else 0.0
|
|
)
|
|
|
|
is_best = val_loss < best_val_loss
|
|
marker = " [best]" if is_best else ""
|
|
print(
|
|
f"epoch {epoch:{epoch_w}d}/{epochs}"
|
|
f" train {train_loss:.4f}"
|
|
f" (s1={train_s1_sum / max(train_n, 1):.3f}"
|
|
f" nsec={train_nsec_sum / max(train_n, 1):.3f}"
|
|
f" s2={train_s2_sum / max(train_n, 1):.3f}"
|
|
f" bal={train_balance_sum / max(train_n, 1):.3f}"
|
|
f" proc={train_proc_sum / max(train_n, 1):.3f}"
|
|
f" entropy={train_entropy_sum / max(train_n, 1):.3f}"
|
|
f" d={train_d_sum / max(train_n, 1):.3f}"
|
|
f" g={train_g_sum / max(train_n, 1):.3f})"
|
|
f" val {val_loss:.4f}"
|
|
f" lr {current_lr:.2e} gnorm {train_grad_norm:.3f}"
|
|
f" {epoch_time:.1f}s{marker}"
|
|
)
|
|
metrics_row = {
|
|
"epoch": epoch,
|
|
"train_loss": train_loss,
|
|
"train_loss_s1": train_s1_sum / max(train_n, 1),
|
|
"train_loss_nsec": train_nsec_sum / max(train_n, 1),
|
|
"train_loss_s2": train_s2_sum / max(train_n, 1),
|
|
"train_loss_balance": train_balance_sum / max(train_n, 1),
|
|
"train_loss_proc": train_proc_sum / max(train_n, 1),
|
|
"train_loss_entropy": train_entropy_sum / max(train_n, 1),
|
|
"train_nsec_acc": train_nsec_acc,
|
|
"d_loss": train_d_sum / max(train_n, 1),
|
|
"g_loss": train_g_sum / max(train_n, 1),
|
|
"wasserstein_estimate": train_wasserstein_sum / max(train_n, 1),
|
|
"gp_loss": train_gp_sum / max(train_n, 1),
|
|
"val_loss": val_loss,
|
|
"val_loss_s1": val_s1_sum / max(val_n, 1),
|
|
"val_loss_nsec": val_nsec_sum / max(val_n, 1),
|
|
"val_loss_s2": val_s2_sum / max(val_n, 1),
|
|
"val_loss_balance": val_balance_sum / max(val_n, 1),
|
|
"val_loss_proc": val_proc_sum / max(val_n, 1),
|
|
"val_loss_entropy": val_entropy_sum / max(val_n, 1),
|
|
"val_nsec_acc": val_nsec_acc,
|
|
"val_marginal_kl": val_marginal_kl,
|
|
"router_s1_entropy": router_s1_entropy,
|
|
"router_s1_util_min": router_s1_util_min,
|
|
"router_s1_util_max": router_s1_util_max,
|
|
"router_s1_util_std": router_s1_util_std,
|
|
"router_s2_entropy": router_s2_entropy,
|
|
"router_s2_util_min": router_s2_util_min,
|
|
"router_s2_util_max": router_s2_util_max,
|
|
"router_s2_util_std": router_s2_util_std,
|
|
"lr": current_lr,
|
|
"critic_lr": critic_lr_value,
|
|
"grad_norm": train_grad_norm,
|
|
"grad_norm_d": train_grad_norm_d,
|
|
"grad_norm_g": train_grad_norm_g,
|
|
"gpu_mem_mb": gpu_mem_mb,
|
|
"samples_per_sec": train_n / max(epoch_time, 1e-8),
|
|
"is_best": int(is_best),
|
|
"epoch_time_s": epoch_time,
|
|
}
|
|
metrics_writer.writerow(metrics_row)
|
|
metrics_file.flush()
|
|
if wandb_run is not None:
|
|
# Shares the same monotonic step axis as the per-batch
|
|
# `batch/*` logs above (global_step) rather than `epoch`,
|
|
# since a wandb run's `step` argument across `log()` calls
|
|
# must never decrease.
|
|
wandb_run.log(metrics_row, step=global_step)
|
|
|
|
ckpt: dict = {
|
|
"model": stage1_model.state_dict(),
|
|
"sec_decoder": sec_decoder.state_dict(),
|
|
"optimizer": optimizer.state_dict(),
|
|
"lr_sched": lr_sched.state_dict(),
|
|
"epoch": epoch,
|
|
"best_val_loss": best_val_loss,
|
|
"global_step": global_step,
|
|
}
|
|
if mode == "wgan":
|
|
assert (
|
|
critic is not None
|
|
and sec_critic is not None
|
|
and optimizer_d is not None
|
|
)
|
|
ckpt["critic"] = critic.state_dict()
|
|
ckpt["sec_critic"] = sec_critic.state_dict()
|
|
ckpt["optimizer_d"] = optimizer_d.state_dict()
|
|
if ema_decay > 0:
|
|
assert ema_stage1_model is not None and ema_sec_decoder is not None
|
|
ckpt["model_ema"] = ema_stage1_model.state_dict()
|
|
ckpt["sec_decoder_ema"] = ema_sec_decoder.state_dict()
|
|
if normalizer_dict is not None:
|
|
ckpt["normalizer"] = normalizer_dict
|
|
if pdg_map is not None:
|
|
ckpt["pdg_map"] = pdg_map
|
|
if mat_map is not None:
|
|
ckpt["mat_map"] = mat_map
|
|
if proc_map is not None:
|
|
ckpt["proc_map"] = proc_map
|
|
if model_config is not None:
|
|
ckpt["model_config"] = model_config
|
|
|
|
if val_loss < best_val_loss:
|
|
best_val_loss = val_loss
|
|
ckpt["best_val_loss"] = best_val_loss
|
|
torch.save(ckpt, out_dir / "best.pt")
|
|
|
|
torch.save(ckpt, out_dir / "last.pt")
|
|
last_completed_epoch = epoch
|
|
|
|
if shutdown.requested:
|
|
break
|
|
|
|
metrics_file.close()
|
|
if wandb_run is not None:
|
|
wandb_run.finish()
|
|
|
|
if shutdown.requested:
|
|
print(
|
|
f"stopped after epoch {last_completed_epoch} due to shutdown signal — "
|
|
f"resume with --resume {out_dir / 'last.pt'}"
|
|
)
|