Files
giant/giant/model/schedule.py
T
lars 8019a80563
CI / Format (ruff format) (push) Successful in 27s
CI / Lint (ruff check) (push) Successful in 27s
CI / Sync project version with tag (push) Has been skipped
CI / Lint (ruff check) (pull_request) Successful in 39s
CI / Type check (ty) (push) Successful in 43s
CI / Format (ruff format) (pull_request) Successful in 31s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 31s
CI / Tests (push) Successful in 2m35s
CI / Tests (pull_request) Successful in 2m36s
Refactor train.py into giant/training/ around a metrics collector
Every metric name used to exist in four places: the dict keys each
StageTrainer returned, the hardcoded _metrics_fields() column list, the
~110-line metrics_row assembly in train(), and the tqdm/summary
formatting. The two had to be kept in exact correspondence by hand or
csv.DictWriter would raise.

Each metric is now declared once, as a MetricSpec on the trainer that
computes it. MetricsCollector derives the CSV header and W&B payload from
those declarations and owns all accumulation, so train() no longer carries
a running sum, and every isinstance(tr, WGANStageTrainer) branch is gone —
replaced by four trainer hooks (batch_loss, summary, val_objective,
supports_val_loss).

giant/train.py (1875 lines) becomes giant/training/:
  trainers.py       StageSpec + shared StageTrainer base + the two subclasses
  metrics.py        MetricSpec, MetricsCollector
  stage2_inputs.py  the pure AR/teacher-forcing tensor helpers, moved verbatim
  loop.py           train() (225 lines, was ~514) + graceful shutdown
  checkpoint.py     build/load, lifted out of train()'s closures

The trainers shared ~15 identical constructor arguments and copy-pasted
their cosine-warmup lambda, EMA setup, state_dict/load_state_dict,
resume_lr and train_mode/eval_mode. StageSpec resolves one stage's config
once (constructors go from 24 and 22 keyword arguments to (spec, model,
device)), the base class holds the rest, and build_stage_trainers drops
from ~100 lines to 15.

Metric columns are renamed to a uniform stage/split/metric scheme
(stage1/train/loss, stage2/train/d_loss, stage1/lr, stage1/router/entropy,
val/loss, ...). Old metrics.csv files and W&B history are not comparable.
The checkpoint format is unchanged.

BEHAVIOR CHANGE — WGAN best-checkpoint selection. The old code meant to
score a WGAN stage on its marginal KL, but the 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 KL was written to metrics.csv
without ever influencing best.pt. val_objective now returns it as
intended. On the test harness's default flow+wgan config val_loss went
from 2.182 (stage 1 only) to 15.137 (stage 1 + KL 12.954), and which epoch
won changed. Runs before this commit picked their best checkpoint on the
non-adversarial stages alone. Written up in docs/v0.3.0-followups.md.

Verified: 699 tests pass; ruff, ruff format and ty clean. Baseline-vs-
refactor metrics.csv compared across five configs (flow+wgan, AR+onehot,
routed, both-flow, AR-flow) — every comparable value bit-identical except
val/loss where the fix applies. Resume appends without a duplicate header
and reproduces a HEAD worktree's per-epoch losses and LRs exactly across
the resume boundary. A refactored last.pt loads through
cli.py:_load_model_weights in both raw and ema modes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 17:03:20 +02:00

203 lines
7.4 KiB
Python

import numpy as np
import torch
import torch.nn.functional as F
class CosineSchedule:
"""DDPM cosine noise schedule (Nichol & Dhariwal 2021)."""
def __init__(self, T: int = 1000, s: float = 0.008) -> None:
self.T = T
steps = np.arange(T + 1, dtype=np.float64)
f = np.cos(((steps / T + s) / (1.0 + s)) * np.pi / 2.0) ** 2
alpha_bars = (f / f[0]).astype(np.float32)
betas = np.clip(1.0 - alpha_bars[1:] / alpha_bars[:-1], 0.0, 0.999).astype(
np.float32
)
self.betas = torch.from_numpy(betas)
self.alphas = torch.from_numpy((1.0 - betas))
self.alpha_bars = torch.from_numpy(alpha_bars[1:])
def to(self, device: torch.device) -> "CosineSchedule":
self.betas = self.betas.to(device)
self.alphas = self.alphas.to(device)
self.alpha_bars = self.alpha_bars.to(device)
return self
def q_sample(
self,
x0: torch.Tensor,
t: torch.Tensor,
noise: torch.Tensor | None = None,
) -> torch.Tensor:
if noise is None:
noise = torch.randn_like(x0)
ab = self.alpha_bars[t].view(-1, 1)
return ab.sqrt() * x0 + (1.0 - ab).sqrt() * noise
def loss(
self,
model: torch.nn.Module,
x0: torch.Tensor,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
) -> torch.Tensor:
B = x0.size(0)
t = torch.randint(0, self.T, (B,), device=x0.device)
noise = torch.randn_like(x0)
x_t = self.q_sample(x0, t, noise)
t_norm = t.float() / self.T
pred = model(x_t, cond_cont, cond_cat, t=t_norm)
return F.mse_loss(pred, noise)
def flow_matching_loss(
model: torch.nn.Module,
x1: torch.Tensor,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
) -> torch.Tensor:
"""Conditional flow matching loss (Lipman et al. 2022).
Straight-line ODE path: x_t = (1-t)*x0 + t*x1, target field u_t = x1-x0.
"""
B = x1.size(0)
t = torch.rand(B, device=x1.device)
x0 = torch.randn_like(x1)
x_t = (1.0 - t.view(-1, 1)) * x0 + t.view(-1, 1) * x1
u_t = x1 - x0
v_t = model(x_t, cond_cont, cond_cat, t=t)
return F.mse_loss(v_t, u_t)
def flow_matching_loss_secondary(
model: torch.nn.Module,
x1: torch.Tensor,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_out: torch.Tensor,
sec_mask: torch.Tensor,
type_dim: int | None = None,
) -> torch.Tensor:
"""Flow matching loss for the secondary decoder with per-slot masking.
x1: (B, K_MAX * (CONT_SLOT_DIM + type_dim)) — flattened secondary target
(stick_logit, dir, then a `type_dim`-wide type slice)
sec_mask: (B, K_MAX) bool — True for valid secondary slots
type_dim: width of the per-slot type slice folded into `x1` — defaults to
`PARTICLE_PHYS_DIM` (log_mass, charge), `particle_type.target =
"physical"`'s width and the only case this function handled before
v0.3.0 step 4. `0` means no type slice is in `x1` at all (`target`
in `("onehot", "embedding")` under `generator in ("flow", "ddpm")` —
see docs/v0.3.0-design.md decision 2, `Stage2OneShot.type_head`
handles the type loss separately in that case).
Only valid-slot dimensions contribute to the loss; padded slots are zeroed
before averaging, so the loss is not diluted by empty slots.
Each slot packs CONT_SLOT_DIM continuous dims (stick_logit, dir) followed
by the `type_dim`-wide type slice — under `target = "physical"` (the
default) that's the secondary's predicted physical identity, a fixed
regression target (see giant.data.transforms.encode_secondaries); under
`target = "embedding"` (folded in only for `generator = "wgan"`, so
`type_dim > 0` here only ever means "physical") it would be the detached
embedding-table row. Even though the two blocks are the same order of
magnitude now (unlike the 16-wide learned embedding block "physical"
replaced), they're still on different physical scales, so they're each
averaged over their own width first and then combined with equal weight
— this stays correct if type_dim/CONT_SLOT_DIM change.
"""
from giant.constants import CONT_SLOT_DIM, PARTICLE_PHYS_DIM
if type_dim is None:
type_dim = PARTICLE_PHYS_DIM
B = x1.size(0)
k_max = sec_mask.size(1)
t = torch.rand(B, device=x1.device)
x0 = torch.randn_like(x1)
x_t = (1.0 - t.view(-1, 1)) * x0 + t.view(-1, 1) * x1
u_t = x1 - x0
v_t = model(x_t, cond_cont, cond_cat, stage1_out, t=t)
slot_dim = CONT_SLOT_DIM + type_dim
err = ((v_t - u_t) ** 2).view(B, k_max, slot_dim)
cont_err = err[..., :CONT_SLOT_DIM].mean(dim=-1) # (B, k_max)
mask = sec_mask.float()
denom = mask.sum().clamp(min=1)
cont_loss = (cont_err * mask).sum() / denom
if type_dim == 0:
return cont_loss
phys_err = err[..., CONT_SLOT_DIM : CONT_SLOT_DIM + type_dim].mean(dim=-1)
phys_loss = (phys_err * mask).sum() / denom
return cont_loss + phys_loss
def flow_matching_loss_secondary_ar(
model: torch.nn.Module,
x1: torch.Tensor,
cond_cont: torch.Tensor,
cond_cat: torch.Tensor,
stage1_out: torch.Tensor,
history_feat: torch.Tensor,
has_prev: torch.Tensor,
remaining_frac: torch.Tensor,
slot_idx: torch.Tensor,
sec_mask: torch.Tensor,
type_dim: int | None = None,
) -> torch.Tensor:
"""`Stage2Autoregressive` analogue of `flow_matching_loss_secondary`, same
masked, per-block (continuous vs. type) loss recipe — but native to
`Stage2Autoregressive`'s `(B, K_MAX, token_dim)` I/O and its extra
per-token conditioning args, rather than a flattened `(B, K_MAX*token_dim)`
vector. Kept as a sibling rather than unified with the flat version: the
model call signature differs enough (four extra per-token conditioning
tensors) that merging would need an awkward shape-flag + closure.
Under teacher forcing (docs/v0.3.0-design.md §6.2 point 3) this is still a
single parallel pass over all K_MAX tokens — `x1`/`history_feat`/etc. are
already built from ground truth for every slot by the caller
(`giant.training.stage2_inputs._assemble_stage2_ar_inputs`/`_assemble_stage2_ar_target`).
x1: (B, K_MAX, CONT_SLOT_DIM + type_dim) — per-token flattened target
(stick_logit, dir, then a `type_dim`-wide type slice)
sec_mask: (B, K_MAX) bool — True for valid secondary slots
type_dim: as `flow_matching_loss_secondary` — defaults to
`PARTICLE_PHYS_DIM`, `0` means no type slice is in `x1` at all.
"""
from giant.constants import CONT_SLOT_DIM, PARTICLE_PHYS_DIM
if type_dim is None:
type_dim = PARTICLE_PHYS_DIM
B, K, _ = x1.shape
t = torch.rand(B, K, device=x1.device)
x0 = torch.randn_like(x1)
x_t = (1.0 - t.unsqueeze(-1)) * x0 + t.unsqueeze(-1) * x1
u_t = x1 - x0
v_t = model(
x_t,
cond_cont,
cond_cat,
stage1_out,
history_feat,
has_prev,
remaining_frac,
slot_idx,
t=t,
)
err = (v_t - u_t) ** 2
cont_err = err[..., :CONT_SLOT_DIM].mean(dim=-1) # (B, K_MAX)
mask = sec_mask.float()
denom = mask.sum().clamp(min=1)
cont_loss = (cont_err * mask).sum() / denom
if type_dim == 0:
return cont_loss
phys_err = err[..., CONT_SLOT_DIM : CONT_SLOT_DIM + type_dim].mean(dim=-1)
phys_loss = (phys_err * mask).sum() / denom
return cont_loss + phys_loss