"""PyTorch training primitives for shape assembly with a fixed agent count."""
from __future__ import annotations
import copy
import dataclasses
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any
import numpy as np
import torch
import warp as wp
from tqdm import trange
from waxmorph._train_core import _prepare_training_inputs
from waxmorph.constants import EPS_POLARITY, HASH_GRID_DIM
from waxmorph.torch.gnn import GNS
from waxmorph.torch.graph import build_graph
from waxmorph.torch.warp_autograd import WarpDiffusionStep, WarpMechStep
[docs]
@dataclasses.dataclass(frozen=True)
class TrainConfig:
r"""Rollout and optimizer settings for a fixed agent count.
``n_epochs`` counts optimizer updates; histories evaluate each resulting model, requiring
one extra rollout. Each rollout has ``t_rollout`` learned steps. ``mech_steps`` and
``diff_steps`` are prescribed substep counts; zero selects the identity path for that
correction.
``dt_gns`` scales all learned deltas. ``dt_mech`` and ``dt_diff`` are explicit step sizes
whose stable range depends on the active forces and graph; nonfinite states are rejected.
Diffusion applies :math:`c\leftarrow\max(c-D_{emu}L_Gc\,\Delta t_{diff},0)`.
The optimized loss is :math:`L=L_{shape}+\lambda_{reg}L_{reg}`, where
.. math::
L_{reg}=\sum_t\lVert\Delta t_{GNS}\,GNS_{X,t}\rVert_F^2.
The regularizer measures learned displacement before the mechanics correction.
``grad_clip_norm`` caps the global L2 gradient norm; ``None`` keeps the computed gradients.
``log_every`` controls progress output. JAX adds ``max_edges_factor`` for its static edge
capacity.
Examples:
>>> cfg = TrainConfig(n_epochs=3, t_rollout=2)
>>> print(cfg.n_epochs, cfg.t_rollout, cfg.grad_clip_norm)
3 2 1.0
"""
n_epochs: int = 2000
t_rollout: int = 100
mech_steps: int = 5
diff_steps: int = 5
dt_mech: float = 1e-2
dt_diff: float = 1e-2
dt_gns: float = 1e-2
D_emu: float = 0.1
lambda_reg: float = 1e-3
grad_clip_norm: float | None = 1.0
log_every: int = 10
[docs]
@dataclasses.dataclass
class TrainResult:
"""Best post-update model and its training log.
The supplied optimizer state is restored alongside ``model``. ``losses_total``,
``losses_shape``, and ``losses_l2`` are length-``n_epochs`` post-update histories; shape
loss sums supervised frames and L2 is the unweighted learned-displacement regularizer.
``best_traj_pos`` and
``best_traj_pol`` have shape ``[t_rollout + 1, N, 3]``; ``best_traj_c`` has shape
``[t_rollout + 1, N, C]``. ``best_epoch`` is zero-based; other metadata keys are
``best_loss``, sorted ``target_frames``, and one ``config_<field>`` per
:class:`TrainConfig` field. JAX adds ``config_max_edges_factor``.
"""
model: Any
log: dict
def _nonfinite_details(tensor: torch.Tensor) -> str | None:
finite_mask = torch.isfinite(tensor)
if bool(finite_mask.all()):
return None
nonfinite_mask = ~finite_mask
bad_indices = nonfinite_mask.nonzero(as_tuple=False)
first_bad = tuple(int(i) for i in bad_indices[0].tolist())
bad_value = tensor[first_bad].detach().cpu().item()
return (
f"total_bad={int(nonfinite_mask.sum().item())}, first_bad_index={first_bad}, "
f"first_bad_value={bad_value!r}"
)
def _validate_finite_tensor(
name: str, tensor: torch.Tensor, *, rollout_step: int, phase: str
) -> None:
if (details := _nonfinite_details(tensor)) is not None:
raise ValueError(
f"Non-finite values detected in {name} during {phase} "
f"at rollout step {rollout_step}: {details}"
)
def _raise_on_nonfinite_named_tensors(
kind: str,
named_tensors,
*,
epoch: int,
phase: str,
) -> None:
for name, tensor in named_tensors:
if tensor is None:
continue
if (details := _nonfinite_details(tensor)) is not None:
raise ValueError(
f"Non-finite values detected in model {kind} during {phase} at epoch {epoch}: "
f"parameter={name!r}, {details}"
)
def _format_gradient_stats(stats, *, limit: int = 5) -> str:
if not stats:
return "none"
return "; ".join(
"{}: norm={:.6g}, max_abs={:.6g}, shape={}, dtype={}, device={}".format(*item)
for item in sorted(stats, key=lambda item: item[1], reverse=True)[:limit]
)
def _clip_grad_norm_stable(named_parameters, max_norm: float, *, epoch: int) -> torch.Tensor:
"""Clip gradients using float64 norm accumulation to avoid fp32 overflow."""
grad_entries = [
(name, param.grad) for name, param in list(named_parameters) if param.grad is not None
]
if not grad_entries:
return torch.tensor(0.0, dtype=torch.float64)
norm_sq_total = torch.zeros((), dtype=torch.float64)
stats = []
for name, grad in grad_entries:
if (details := _nonfinite_details(grad)) is not None:
raise ValueError(
"Non-finite gradient detected during clipping at epoch "
f"{epoch}: parameter={name!r}, {details}"
)
grad_detached = grad.detach()
grad64 = grad_detached.to(dtype=torch.float64)
norm_sq = grad64.square().sum().cpu()
grad_norm = torch.sqrt(norm_sq).item()
max_abs = grad_detached.abs().max().detach().cpu().item() if grad.numel() else 0.0
norm_sq_total = norm_sq_total + norm_sq
stats.append(
(name, grad_norm, max_abs, tuple(grad.shape), str(grad.dtype), str(grad.device))
)
total_norm = torch.sqrt(norm_sq_total)
if not torch.isfinite(total_norm):
raise ValueError(
"Non-finite stable gradient norm during clipping at epoch "
f"{epoch}: grad_norm={total_norm.item()!r}; "
f"top_gradients={_format_gradient_stats(stats)}"
)
clip_coef = float(max_norm) / (total_norm.item() + 1e-6)
if clip_coef < 1.0:
with torch.no_grad():
for _name, grad in grad_entries:
grad.mul_(clip_coef)
return total_norm
def _run_epoch(
*,
model,
config,
source_pos,
polarities,
c,
R_t,
R_wp,
f_net,
grid,
N,
targets_by_frame,
X_source_t,
loss_fn,
torch_device,
epoch_trajectory,
):
"""Roll out one differentiable epoch on ``torch_device``.
Features remain attached to the Torch graph, while each step's COO topology comes from a
detached state snapshot. Each Warp bridge call owns a tape; Torch autograd composes the
calls through the rollout. ``targets_by_frame`` uses post-step indices, so frame 0 follows
the first learned and prescribed updates. Returns the summed supervised shape loss, the
unweighted ``sum_t ||dt_gns * GNS_X,t||^2`` measured before mechanics, and the detached
source-plus-``t_rollout`` trajectory.
"""
X_t = X_source_t.clone().requires_grad_(True)
c_t = torch.from_numpy(c.copy()).to(torch_device).requires_grad_(True)
P_t = torch.from_numpy(polarities.copy()).to(torch_device)
loss_l2 = torch.tensor(0.0, device=torch_device)
loss_shape = torch.tensor(0.0, device=torch_device)
for name, tensor in (("X_t", X_t), ("P_t", P_t), ("c_t", c_t)):
_validate_finite_tensor(name, tensor, rollout_step=0, phase="epoch start")
for _t in range(config.t_rollout):
for name, tensor in (("X_t", X_t), ("P_t", P_t), ("c_t", c_t)):
_validate_finite_tensor(name, tensor, rollout_step=_t, phase="pre-graph build")
node_feats, edge_index, edge_feats = build_graph(
X_t,
P_t,
R_t,
particle_count=N,
c=c_t,
)
out = model(node_feats, edge_index, edge_feats)
dX = out["dX"] * config.dt_gns
dP = out["dP"] * config.dt_gns
dc = out["dc"] * config.dt_gns
for name, tensor in (("dX", dX), ("dP", dP), ("dc", dc)):
_validate_finite_tensor(name, tensor, rollout_step=_t, phase="gns output")
loss_l2 = loss_l2 + dX.square().sum()
X_t = X_t + dX
P_t = torch.nn.functional.normalize(P_t + dP, dim=-1, eps=EPS_POLARITY)
c_t = torch.clamp_min(c_t + dc, 0.0)
for name, tensor in (("X_t", X_t), ("P_t", P_t), ("c_t", c_t)):
_validate_finite_tensor(name, tensor, rollout_step=_t, phase="post-gns update")
for _ in range(config.mech_steps):
X_t = WarpMechStep.apply(X_t, R_wp, N, config.dt_mech, f_net, grid)
_validate_finite_tensor("X_t", X_t, rollout_step=_t, phase="post-mechanics")
for _ in range(config.diff_steps):
X_wp_diff = wp.from_torch(X_t.detach().contiguous(), dtype=wp.vec3f)
c_t = WarpDiffusionStep.apply(
c_t, X_wp_diff, R_wp, N, config.D_emu, config.dt_diff, grid
)
_validate_finite_tensor("c_t", c_t, rollout_step=_t, phase="post-diffusion")
epoch_trajectory.append(
{
"pos": X_t.detach().cpu().numpy().copy(),
"pol": P_t.detach().cpu().numpy().copy(),
"c": c_t.detach().cpu().numpy().copy(),
}
)
if _t in targets_by_frame:
loss_shape = loss_shape + loss_fn(X_t, targets_by_frame[_t])
return loss_shape, loss_l2, epoch_trajectory
[docs]
def train(
model: GNS,
optimizer: torch.optim.Optimizer,
loss_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
*,
source_pos: np.ndarray,
polarities: np.ndarray,
c: np.ndarray,
radii: np.ndarray,
targets: list[tuple[int, np.ndarray]] | None = None,
config: TrainConfig | None = None,
save_path: str | Path | None = None,
device: str = "cuda",
) -> TrainResult:
"""Train through learned, mechanics, and diffusion rollout steps.
Optimizes ``L_shape + lambda_reg * sum_t ||dt_gns * GNS_X,t||^2``. Histories and
selection use post-update rollouts; the supplied optimizer is restored to the selected
model. Feature gradients remain live; each graph build and Warp substep freezes its own
contact or pair topology.
State and target arrays become C-contiguous ``float32``. Positions and polarities have
shape ``[N, 3]``, concentrations ``[N, C]``, and radii ``[N]`` with common nonzero ``N``;
input polarities are used as supplied for the first graph, whose angle feature assumes
unit vectors; later learned updates apply normalization. ``loss_fn`` maps predicted
``[N, 3]`` and target ``[M, 3]`` positions to a scalar. Targets are unique
``(frame, [M, 3])`` pairs with ``0 <= frame < t_rollout``. The source precedes indexed
frames; frame 0 supervises the state after the first complete rollout step.
``device`` must be accepted by Torch and Warp. A new ``save_path`` receives the best GNS
checkpoint and compressed log. An existing trusted checkpoint must match the supplied
model; its weights load into that object, optimizer state starts fresh, and one refinement
update preserves both existing files. See :class:`TrainResult` for the log schema.
The JAX counterpart requires CUDA for its Warp custom VJPs and adds a static edge-capacity
setting.
"""
if config is None:
config = TrainConfig()
source_pos, polarities, c, radii, targets = _prepare_training_inputs(
config, source_pos, polarities, c, radii, targets
)
wp_device = device
torch_device = torch.device(device)
N = len(source_pos)
if save_path is not None and os.path.exists(save_path):
checkpoint_model = GNS.load(save_path, map_location=torch_device).to(torch_device)
model_config = model._constructor_config()
checkpoint_config = checkpoint_model._constructor_config()
mismatches = [
name for name, value in model_config.items() if checkpoint_config[name] != value
]
if mismatches:
details = ", ".join(
f"{name} (model={model_config[name]!r}, checkpoint={checkpoint_config[name]!r})"
for name in mismatches
)
raise ValueError(f"Incompatible checkpoint GNS configuration: {details}.")
model = model.to(torch_device)
model.load_state_dict(checkpoint_model.state_dict())
optimizer.state.clear()
del checkpoint_model
config = dataclasses.replace(config, n_epochs=1)
else:
model = model.to(torch_device)
f_net = wp.zeros(N, dtype=wp.vec3f, device=wp_device)
R_t = torch.from_numpy(radii.copy()).to(torch_device)
R_wp = wp.from_numpy(radii.copy(), dtype=wp.float32, device=wp_device)
targets_by_frame: dict[int, torch.Tensor] = {}
for frame, pos in targets:
targets_by_frame[frame] = torch.from_numpy(pos).to(torch_device)
X_source_t = torch.from_numpy(source_pos).to(torch_device)
losses_total = []
losses_shape = []
losses_l2 = []
best_loss = float("inf")
best_model_state = None
best_optimizer_state = None
best_trajectory = None
best_epoch = 0
best_shape_loss = 0.0
best_l2_loss = 0.0
grid = wp.HashGrid(HASH_GRID_DIM, HASH_GRID_DIM, HASH_GRID_DIM, device=wp_device)
def evaluate_model(*, epoch, phase):
epoch_trajectory = [{"pos": source_pos.copy(), "pol": polarities.copy(), "c": c.copy()}]
loss_shape, loss_l2, epoch_trajectory = _run_epoch(
model=model,
config=config,
source_pos=source_pos,
polarities=polarities,
c=c,
R_t=R_t,
R_wp=R_wp,
f_net=f_net,
grid=grid,
N=N,
targets_by_frame=targets_by_frame,
X_source_t=X_source_t,
loss_fn=loss_fn,
torch_device=torch_device,
epoch_trajectory=epoch_trajectory,
)
loss = loss_shape + (loss_l2 * config.lambda_reg)
for name, value in (("loss", loss), ("shape loss", loss_shape), ("L2 loss", loss_l2)):
if not bool(torch.isfinite(value).all()):
raise ValueError(
f"Non-finite {name} during {phase} at epoch {epoch}: "
f"value={value.detach().cpu().item()!r}"
)
return loss, loss_shape, loss_l2, epoch_trajectory
optimizer.zero_grad(set_to_none=True)
loss, loss_shape, loss_l2, epoch_trajectory = evaluate_model(
epoch=0, phase="pre-update evaluation"
)
for epoch in trange(config.n_epochs):
loss.backward()
_raise_on_nonfinite_named_tensors(
"gradients",
((name, param.grad) for name, param in model.named_parameters()),
epoch=epoch,
phase="post-backward",
)
if config.grad_clip_norm is not None:
_clip_grad_norm_stable(model.named_parameters(), config.grad_clip_norm, epoch=epoch)
optimizer.step()
_raise_on_nonfinite_named_tensors(
"parameters",
model.named_parameters(),
epoch=epoch,
phase="post-optimizer step",
)
optimizer.zero_grad(set_to_none=True)
with torch.set_grad_enabled(epoch + 1 < config.n_epochs):
loss, loss_shape, loss_l2, epoch_trajectory = evaluate_model(
epoch=epoch, phase="post-update evaluation"
)
epoch_loss = loss.item()
epoch_shape_loss = loss_shape.item()
epoch_l2_loss = loss_l2.item()
losses_total.append(epoch_loss)
losses_shape.append(epoch_shape_loss)
losses_l2.append(epoch_l2_loss)
if epoch_loss < best_loss:
best_loss = epoch_loss
best_shape_loss = epoch_shape_loss
best_l2_loss = epoch_l2_loss
best_model_state = copy.deepcopy(model.state_dict())
best_optimizer_state = copy.deepcopy(optimizer.state_dict())
best_trajectory = epoch_trajectory
best_epoch = epoch
if (epoch + 1) % config.log_every == 0:
print(
f"Epoch {epoch + 1:4d}/{config.n_epochs} "
f"best={best_loss:.4f} shape={best_shape_loss:.4f} l2={best_l2_loss:.4f}"
)
model.load_state_dict(best_model_state)
optimizer.load_state_dict(best_optimizer_state)
traj_pos = np.stack([f["pos"] for f in best_trajectory])
traj_pol = np.stack([f["pol"] for f in best_trajectory])
traj_c = np.stack([f["c"] for f in best_trajectory])
log = {
"losses_total": np.array(losses_total),
"losses_shape": np.array(losses_shape),
"losses_l2": np.array(losses_l2),
"best_traj_pos": traj_pos,
"best_traj_pol": traj_pol,
"best_traj_c": traj_c,
"best_epoch": best_epoch,
"best_loss": best_loss,
"target_frames": np.array(sorted(targets_by_frame.keys()), dtype=np.int64),
}
for field in dataclasses.fields(config):
log[f"config_{field.name}"] = getattr(config, field.name)
if save_path is not None and not os.path.exists(save_path):
save_path = Path(save_path)
save_path.parent.mkdir(parents=True, exist_ok=True)
model.save(save_path)
np.savez_compressed(f"{save_path}.log.npz", **log)
return TrainResult(model=model, log=log)