Source code for waxmorph.jax.train

"""JAX/Equinox training primitives for shape assembly with a fixed agent count."""

from __future__ import annotations

import dataclasses
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any, NamedTuple

import equinox as eqx
import jax
import jax.numpy as jnp
import numpy as np
import optax
from tqdm import trange

from waxmorph._train_core import _prepare_training_inputs
from waxmorph.constants import EPS_POLARITY
from waxmorph.jax.gnn import GNS as JaxGNS
from waxmorph.jax.graph import (
    build_edge_features,
    build_edge_index,
    build_node_features,
)

_CAPACITY_HEADROOM = 1.15
_CAPACITY_BUCKET = 1024


[docs] @dataclasses.dataclass(frozen=True) class TrainConfig: r"""JAX rollout and optimizer settings for a fixed agent count. ``n_epochs`` counts optimizer updates. Histories evaluate every post-update model, so training performs one additional rollout. Zero ``mech_steps`` or ``diff_steps`` selects the identity path for that prescribed update. ``dt_mech`` and ``dt_diff`` are explicit-Euler steps; diffusion stability depends on ``D_emu * dt_diff`` and graph degree. ``dt_gns`` scales the raw ``dX``, ``dP``, and ``dc`` heads before state updates. The total loss is .. math:: L=L_{shape}+\lambda_{reg}\sum_t \lVert \Delta t_{GNS}\,GNS_{X,t}\rVert_F^2, where the displacement term is measured before mechanics. ``grad_clip_norm=None`` keeps the computed gradients. JAX adds ``max_edges_factor``: both directed-edge and neighbor-pair buffers are bounded by ``N * max_edges_factor``; overflow raises while preserving the configured static shape. 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 max_edges_factor: int = 10
[docs] @dataclasses.dataclass class TrainResult: """Best post-update model and its run diagnostics. ``losses_total``, ``losses_shape``, and unweighted ``losses_l2`` each have length ``n_epochs`` and describe post-update rollouts. The best trajectory includes the source frame and has shapes ``[t_rollout+1, N, 3]`` for ``best_traj_pos`` and ``best_traj_pol``, and ``[t_rollout+1, N, num_molecules]`` for ``best_traj_c``. ``best_epoch`` is zero-based; ``best_loss`` matches that model and trajectory. ``target_frames`` is sorted, and every config field appears as ``config_<field>``. JAX therefore adds ``config_max_edges_factor`` to the common Torch log schema. """ model: Any log: dict
@dataclasses.dataclass(frozen=True) class _PairTopology: pair_i: jax.Array pair_j: jax.Array num_pairs: jax.Array @dataclasses.dataclass(frozen=True) class _StepTopology: edge_index: jax.Array num_edges: jax.Array mech_pairs: tuple[_PairTopology, ...] diff_pairs: tuple[_PairTopology, ...] class _TopologyBatch(NamedTuple): edge_index: jax.Array num_edges: jax.Array mech_pair_i: jax.Array mech_pair_j: jax.Array mech_num_pairs: jax.Array diff_pair_i: jax.Array diff_pair_j: jax.Array diff_num_pairs: jax.Array @dataclasses.dataclass(frozen=True) class _WarpCollectionContext: R_wp: Any f_net: Any lap_c: Any grid: Any query_radius: float particle_count: int max_pairs: int device: str def _uses_warp_bridge(config: TrainConfig) -> bool: return config.mech_steps > 0 or config.diff_steps > 0 def _resolve_jax_device(device: str, *, require_cuda: bool) -> jax.Device: device_name = str(device) if device_name.startswith("cuda"): try: gpu_devices = jax.devices("gpu") except Exception as exc: if require_cuda: raise RuntimeError( "JAX/Warp differentiable physics requires a JAX GPU backend, " "but JAX could not initialize one." ) from exc return jax.devices("cpu")[0] if gpu_devices: ordinal = 0 if ":" in device_name: try: ordinal = int(device_name.split(":", 1)[1]) except ValueError as exc: raise ValueError(f"Invalid CUDA device string: {device_name!r}") from exc if ordinal >= len(gpu_devices): if require_cuda: raise RuntimeError( f"Requested {device_name!r}, but JAX sees only {len(gpu_devices)} GPU(s)." ) return jax.devices("cpu")[0] return gpu_devices[ordinal] if require_cuda: raise RuntimeError("JAX/Warp differentiable physics requires a JAX GPU backend.") return jax.devices("cpu")[0] def _validate_finite_array(name: str, array: jax.Array, *, rollout_step: int, phase: str) -> None: arr = np.asarray(jax.device_get(array)) bad_indices = np.argwhere(~np.isfinite(arr)) if len(bad_indices) == 0: return first_bad = tuple(int(i) for i in bad_indices[0]) bad_value = arr[first_bad] raise ValueError( f"Non-finite values detected in {name} during {phase} at rollout step {rollout_step}: " f"total_bad={len(bad_indices)}, first_bad_index={first_bad}, " f"first_bad_value={bad_value!r}" ) def _array_leaves(tree) -> list[jax.Array]: return [leaf for leaf in jax.tree_util.tree_leaves(tree) if isinstance(leaf, jax.Array)] def _device_put_arrays(tree, device: jax.Device): return jax.tree.map( lambda x: jax.device_put(x, device) if isinstance(x, jax.Array) else x, tree, ) def _validate_checkpoint_model(model: JaxGNS, checkpoint_model: JaxGNS) -> None: if model._config != checkpoint_model._config: config_names = dict.fromkeys((*model._config, *checkpoint_model._config)) mismatches = [ name for name in config_names if name not in model._config or name not in checkpoint_model._config or model._config[name] != checkpoint_model._config[name] ] raise ValueError( f"Incompatible checkpoint GNS configuration fields: {', '.join(mismatches)}." ) model_tree = eqx.filter(model, eqx.is_array) checkpoint_tree = eqx.filter(checkpoint_model, eqx.is_array) if jax.tree_util.tree_structure(model_tree) != jax.tree_util.tree_structure(checkpoint_tree): raise ValueError("Incompatible checkpoint GNS PyTree structure.") model_leaves = _array_leaves(model_tree) checkpoint_leaves = _array_leaves(checkpoint_tree) for index, (model_leaf, checkpoint_leaf) in enumerate( zip(model_leaves, checkpoint_leaves, strict=True) ): if model_leaf.shape != checkpoint_leaf.shape: raise ValueError( f"Incompatible checkpoint GNS leaf {index} shape: " f"model={model_leaf.shape}, checkpoint={checkpoint_leaf.shape}." ) if model_leaf.dtype != checkpoint_leaf.dtype: raise ValueError( f"Incompatible checkpoint GNS leaf {index} dtype: " f"model={model_leaf.dtype}, checkpoint={checkpoint_leaf.dtype}." ) def _tree_all_finite(tree) -> jax.Array: leaves = _array_leaves(tree) if not leaves: return jnp.asarray(True) finite = jnp.asarray(True) for leaf in leaves: finite = jnp.logical_and(finite, jnp.all(jnp.isfinite(leaf))) return finite def _tree_global_norm(tree) -> jax.Array: """Compute the global L2 norm with scale-stable float32 squares. ``||g|| = m * sqrt(sum (g/m)^2)`` with ``m=max|g|`` keeps all rescaled magnitudes in ``[0, 1]`` and retains the configured JAX precision mode. """ leaves = _array_leaves(tree) if not leaves: return jnp.asarray(0.0, dtype=jnp.float32) abs_max = jnp.max(jnp.stack([jnp.max(jnp.abs(leaf)) for leaf in leaves])) # Guard the all-zero gradient case so the rescaling divisor is never 0. scale = jnp.where(abs_max > 0, abs_max, jnp.asarray(1.0, dtype=abs_max.dtype)) sq_sum = sum(jnp.sum(jnp.square(leaf / scale)) for leaf in leaves) return abs_max * jnp.sqrt(sq_sum) def _clip_grads(grads, max_norm: float): grad_norm = _tree_global_norm(grads) scale = jnp.minimum(1.0, jnp.asarray(max_norm, dtype=grad_norm.dtype) / (grad_norm + 1e-6)) return jax.tree.map(lambda g: g * scale if isinstance(g, jax.Array) else g, grads), grad_norm def _as_numpy(array: jax.Array) -> np.ndarray: return np.asarray(jax.device_get(array)).copy() def _checked_loss_values( loss: jax.Array, loss_shape: jax.Array, loss_l2: jax.Array, *, epoch: int, phase: str, ) -> tuple[float, float, float]: values = tuple(float(jax.device_get(value)) for value in (loss, loss_shape, loss_l2)) for name, value in zip(("loss", "shape loss", "L2 loss"), values, strict=True): if not np.isfinite(value): raise ValueError(f"Non-finite {name} during {phase} at epoch {epoch}: value={value!r}") return values def _max_edges_for_config(config: TrainConfig, particle_count: int) -> int: if config.max_edges_factor <= 0: raise ValueError("TrainConfig.max_edges_factor must be positive.") return max(int(particle_count) * int(config.max_edges_factor), 1) def _scalar_int(value: jax.Array | int) -> int: return int(np.asarray(jax.device_get(value))) def _bucketed_capacity(observed_max: int, max_capacity: int, *, name: str) -> int: """Inflate and bucket a static buffer size to reduce XLA recompilations. The result lies in ``[1, max_capacity]``. Counts above the configured capacity raise, preserving complete topology. """ if max_capacity <= 0: raise ValueError(f"{name} capacity must be positive.") if observed_max > max_capacity: raise ValueError( f"{name} has {observed_max} entries but max_edges_factor allows only " f"{max_capacity}. Increase TrainConfig.max_edges_factor." ) bucketed = int(np.ceil((observed_max * _CAPACITY_HEADROOM) / _CAPACITY_BUCKET)) return min(max(bucketed * _CAPACITY_BUCKET, 1), max_capacity) def _build_pair_topology( X: jax.Array, R: jax.Array, particle_count: int, max_pairs: int | None = None, ) -> _PairTopology: edge_index, _num_edges = build_edge_index(X, R, particle_count=particle_count) edges = np.asarray(jax.device_get(edge_index)) mask = edges[0] < edges[1] pair_i = jnp.asarray(edges[0, mask], dtype=jnp.int32) pair_j = jnp.asarray(edges[1, mask], dtype=jnp.int32) if max_pairs is not None and pair_i.shape[0] > max_pairs: raise ValueError( f"Neighbor pair list has {pair_i.shape[0]} entries but max_edges_factor allows only " f"{max_pairs}. Increase TrainConfig.max_edges_factor." ) return _PairTopology( pair_i=pair_i, pair_j=pair_j, num_pairs=jnp.asarray(pair_i.shape[0], dtype=jnp.int32), ) def _make_warp_collection_context( R: jax.Array, c: jax.Array, *, particle_count: int, max_pairs: int, device: str, ) -> _WarpCollectionContext: import warp as wp from waxmorph.constants import EPS_DIST, HASH_GRID_DIM R_wp = wp.from_jax(R, dtype=wp.float32) r_max = float(np.asarray(jax.device_get(R[:particle_count])).max()) query_radius = 2.0 * r_max + EPS_DIST max_particles = int(R.shape[0]) num_molecules = int(c.shape[1]) if c.ndim > 1 else 1 return _WarpCollectionContext( R_wp=R_wp, f_net=wp.zeros(max_particles, dtype=wp.vec3f, device=device), lap_c=wp.zeros((max_particles, num_molecules), dtype=wp.float32, device=device), grid=wp.HashGrid(HASH_GRID_DIM, HASH_GRID_DIM, HASH_GRID_DIM, device=device), query_radius=query_radius, particle_count=particle_count, max_pairs=max_pairs, device=device, ) def _jax_positions_to_warp(X: jax.Array): import warp as wp return wp.from_jax(X, dtype=wp.vec3f) def _jax_c_to_warp(c: jax.Array): import warp as wp c_wp = wp.from_jax(c, dtype=wp.float32) if c.ndim == 2: c_wp = c_wp.reshape((int(c.shape[0]), int(c.shape[1]))) return c_wp def _warp_to_jax(array, dtype) -> jax.Array: import warp as wp return jnp.asarray(wp.to_jax(array), dtype=dtype) def _build_warp_collection_pairs( X_wp, ctx: _WarpCollectionContext, ) -> tuple[_PairTopology, Any, Any, int]: import warp as wp from waxmorph.emulator import _build_neighbor_pairs_dynamic ctx.grid.build(X_wp[: ctx.particle_count], ctx.query_radius) edges_i, edges_j, num_pairs = _build_neighbor_pairs_dynamic( X_wp, ctx.R_wp, ctx.particle_count, ctx.query_radius, ctx.grid, ctx.device, ) if num_pairs > ctx.max_pairs: raise ValueError( f"Neighbor pair list has {num_pairs} entries but max_edges_factor allows only " f"{ctx.max_pairs}. Increase TrainConfig.max_edges_factor." ) if num_pairs == 0: empty = jnp.zeros((0,), dtype=jnp.int32) topology = _PairTopology(empty, empty, jnp.asarray(0, dtype=jnp.int32)) else: topology = _PairTopology( pair_i=jnp.asarray(wp.to_jax(edges_i[:num_pairs]), dtype=jnp.int32), pair_j=jnp.asarray(wp.to_jax(edges_j[:num_pairs]), dtype=jnp.int32), num_pairs=jnp.asarray(num_pairs, dtype=jnp.int32), ) return topology, edges_i, edges_j, num_pairs def _native_warp_mech_step( X_wp, pair_i_wp, pair_j_wp, num_pairs: int, dt: float, ctx: _WarpCollectionContext, ): import warp as wp from waxmorph.emulator import _gd_update, _sticky_sphere_grads_from_pairs ctx.f_net.zero_() X_out = wp.zeros_like(X_wp, device=ctx.device) if num_pairs > 0: wp.launch( _sticky_sphere_grads_from_pairs, dim=num_pairs, inputs=[X_wp, ctx.R_wp, pair_i_wp[:num_pairs], pair_j_wp[:num_pairs]], outputs=[ctx.f_net], device=ctx.device, ) wp.launch( _gd_update, dim=ctx.particle_count, inputs=[X_wp, ctx.f_net, float(dt)], outputs=[X_out], device=ctx.device, ) return X_out def _native_warp_diffusion_step( c_wp, pair_i_wp, pair_j_wp, num_pairs: int, D_emu: float, dt: float, ctx: _WarpCollectionContext, ): import warp as wp from waxmorph.emulator import ( _molecule_diffusion_laplacian_from_pairs, _molecule_diffusion_step_out, ) ctx.lap_c.zero_() c_out = wp.zeros_like(c_wp, device=ctx.device) if num_pairs > 0: wp.launch( _molecule_diffusion_laplacian_from_pairs, dim=num_pairs, inputs=[c_wp, pair_i_wp[:num_pairs], pair_j_wp[:num_pairs]], outputs=[ctx.lap_c], device=ctx.device, ) wp.launch( _molecule_diffusion_step_out, dim=(int(c_wp.shape[0]), int(c_wp.shape[1])), inputs=[c_wp, ctx.lap_c, float(D_emu), float(dt), ctx.particle_count], outputs=[c_out], device=ctx.device, ) return c_out def _apply_gns_step( model, X: jax.Array, P: jax.Array, c: jax.Array, edge_index: jax.Array, num_edges: jax.Array, particle_count: int, dt_gns: float, ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array, jax.Array, jax.Array]: node_feats = build_node_features(c, particle_count) edge_feats = build_edge_features(X, P, edge_index, particle_count) out = model(node_feats, edge_index, edge_feats, num_edges=num_edges) dX = out["dX"] * dt_gns dP = out["dP"] * dt_gns dc = out["dc"] * dt_gns X_next = X + dX P_next = P + dP P_next = P_next / jnp.maximum(jnp.linalg.norm(P_next, axis=-1, keepdims=True), EPS_POLARITY) c_next = jnp.maximum(c + dc, jnp.asarray(0.0, dtype=c.dtype)) return X_next, P_next, c_next, dX, dP, dc _collection_gns_step = eqx.filter_jit(_apply_gns_step) def _collect_topologies_and_trajectory( *, model, config: TrainConfig, X: jax.Array, P: jax.Array, c: jax.Array, R: jax.Array, particle_count: int, device: str, ) -> tuple[tuple[_StepTopology, ...], list[dict[str, np.ndarray]]]: """Collect each step's frozen topology through a detached rollout. Collection rebuilds directed GNS edges and mechanics/diffusion pairs from the evolving positions. :func:`_stack_topologies` pads them to bucketed static shapes, then :func:`_epoch_loss_with_topology_batch` replays state values differentiably through ``jax.lax.scan``; adjacency and pair selection become fixed inputs to autodiff. CUDA collection uses native Warp pair and physics kernels when prescribed physics is enabled. The trajectory contains ``t_rollout + 1`` detached host snapshots, including the source; the topology tuple contains one entry per update step. """ topologies: list[_StepTopology] = [] trajectory = [{"pos": _as_numpy(X), "pol": _as_numpy(P), "c": _as_numpy(c)}] max_edges = _max_edges_for_config(config, particle_count) use_native_warp = str(device).startswith("cuda") and _uses_warp_bridge(config) warp_ctx = ( _make_warp_collection_context( R, c, particle_count=particle_count, max_pairs=max_edges, device=device, ) if use_native_warp else None ) for t in range(config.t_rollout): for name, array in (("X", X), ("P", P), ("c", c)): _validate_finite_array(name, array, rollout_step=t, phase="pre-graph build") edge_index, num_edges = build_edge_index( X, R, particle_count=particle_count, max_edges=max_edges, ) X_next, P_next, c_next, dX, dP, dc = _collection_gns_step( model, X, P, c, edge_index, num_edges, particle_count, config.dt_gns, ) edge_index_for_replay = edge_index[:, : _scalar_int(num_edges)] for name, array in (("dX", dX), ("dP", dP), ("dc", dc)): _validate_finite_array(name, array, rollout_step=t, phase="gns output") X, P, c = map(jax.lax.stop_gradient, (X_next, P_next, c_next)) for name, array in (("X", X), ("P", P), ("c", c)): _validate_finite_array(name, array, rollout_step=t, phase="post-gns update") mech_pairs: list[_PairTopology] = [] if warp_ctx is not None and config.mech_steps > 0: X_wp = _jax_positions_to_warp(X) for _ in range(config.mech_steps): pairs, pair_i_wp, pair_j_wp, num_pairs = _build_warp_collection_pairs( X_wp, warp_ctx, ) mech_pairs.append(pairs) X_wp = _native_warp_mech_step( X_wp, pair_i_wp, pair_j_wp, num_pairs, config.dt_mech, warp_ctx, ) X = jax.lax.stop_gradient(_warp_to_jax(X_wp, X.dtype)) else: for _ in range(config.mech_steps): pairs = _build_pair_topology( X, R, particle_count, max_pairs=max_edges, ) mech_pairs.append(pairs) from waxmorph.jax.warp_autograd import warp_mech_step X = jax.lax.stop_gradient( warp_mech_step( X, R, pairs.pair_i, pairs.pair_j, config.dt_mech, num_pairs=pairs.num_pairs, device=device, ) ) _validate_finite_array("X", X, rollout_step=t, phase="post-mechanics") diff_pairs: list[_PairTopology] = [] if warp_ctx is not None and config.diff_steps > 0: X_wp = _jax_positions_to_warp(X) c_wp = _jax_c_to_warp(c) pairs, pair_i_wp, pair_j_wp, num_pairs = _build_warp_collection_pairs( X_wp, warp_ctx, ) for _ in range(config.diff_steps): diff_pairs.append(pairs) c_wp = _native_warp_diffusion_step( c_wp, pair_i_wp, pair_j_wp, num_pairs, config.D_emu, config.dt_diff, warp_ctx, ) c = jax.lax.stop_gradient(_warp_to_jax(c_wp, c.dtype)) else: diff_pairs_for_step = None for _ in range(config.diff_steps): if diff_pairs_for_step is None: diff_pairs_for_step = _build_pair_topology( X, R, particle_count, max_pairs=max_edges, ) diff_pairs.append(diff_pairs_for_step) from waxmorph.jax.warp_autograd import warp_diffusion_step c = jax.lax.stop_gradient( warp_diffusion_step( c, diff_pairs_for_step.pair_i, diff_pairs_for_step.pair_j, config.D_emu, config.dt_diff, num_pairs=diff_pairs_for_step.num_pairs, device=device, ) ) _validate_finite_array("c", c, rollout_step=t, phase="post-diffusion") topologies.append( _StepTopology( edge_index=edge_index_for_replay, num_edges=num_edges, mech_pairs=tuple(mech_pairs), diff_pairs=tuple(diff_pairs), ) ) trajectory.append({"pos": _as_numpy(X), "pol": _as_numpy(P), "c": _as_numpy(c)}) return tuple(topologies), trajectory def _max_edge_count(topologies: tuple[_StepTopology, ...]) -> int: return max((_scalar_int(topology.num_edges) for topology in topologies), default=0) def _max_pair_count(topologies: tuple[_StepTopology, ...]) -> int: counts = (_scalar_int(p.num_pairs) for t in topologies for p in (*t.mech_pairs, *t.diff_pairs)) return max(counts, default=0) def _stack_pair_topologies( topologies: tuple[_StepTopology, ...], *, attr: str, num_steps: int, max_pairs: int, ) -> tuple[jax.Array, jax.Array, jax.Array]: t_rollout = len(topologies) if num_steps == 0: empty_pairs = jnp.zeros((t_rollout, 0, max_pairs), dtype=jnp.int32) empty_counts = jnp.zeros((t_rollout, 0), dtype=jnp.int32) return empty_pairs, empty_pairs, empty_counts pair_i_array = np.zeros((t_rollout, num_steps, max_pairs), dtype=np.int32) pair_j_array = np.zeros((t_rollout, num_steps, max_pairs), dtype=np.int32) num_pairs_array = np.zeros((t_rollout, num_steps), dtype=np.int32) for t, topology in enumerate(topologies): pairs_for_step = getattr(topology, attr) if len(pairs_for_step) != num_steps: raise ValueError( f"Internal topology error: expected {num_steps} {attr} entries, " f"got {len(pairs_for_step)}." ) for step, pairs in enumerate(pairs_for_step): count = _scalar_int(pairs.num_pairs) if count > max_pairs: raise ValueError( f"Neighbor pair list has {count} entries but max_edges_factor allows only " f"{max_pairs}. Increase TrainConfig.max_edges_factor." ) if pairs.pair_i.shape[0] < count or pairs.pair_j.shape[0] < count: raise ValueError( f"Neighbor pair list count is {count}, but fewer entries were provided." ) num_pairs_array[t, step] = count if count > 0: for buffer, values in ( (pair_i_array, pairs.pair_i), (pair_j_array, pairs.pair_j), ): buffer[t, step, :count] = np.asarray( jax.device_get(values[:count]), dtype=np.int32 ) return ( jnp.asarray(pair_i_array, dtype=jnp.int32), jnp.asarray(pair_j_array, dtype=jnp.int32), jnp.asarray(num_pairs_array, dtype=jnp.int32), ) def _stack_topologies( topologies: tuple[_StepTopology, ...], *, config: TrainConfig, max_pairs: int, ) -> _TopologyBatch: """Pad variable topologies to bucketed shapes for ``jax.lax.scan`` replay. ``num_edges`` and ``num_pairs`` preserve real counts so the replay masks zero-filled slots. ``max_pairs`` enforces the ``max_edges_factor`` budget. """ edge_capacity = _bucketed_capacity( _max_edge_count(topologies), max_pairs, name="Graph", ) pair_capacity = _bucketed_capacity( _max_pair_count(topologies), max_pairs, name="Neighbor pair list", ) mech_pair_i, mech_pair_j, mech_num_pairs = _stack_pair_topologies( topologies, attr="mech_pairs", num_steps=config.mech_steps, max_pairs=pair_capacity, ) diff_pair_i, diff_pair_j, diff_num_pairs = _stack_pair_topologies( topologies, attr="diff_pairs", num_steps=config.diff_steps, max_pairs=pair_capacity, ) edge_index_array = np.zeros((len(topologies), 2, edge_capacity), dtype=np.int32) num_edges_array = np.zeros((len(topologies),), dtype=np.int32) for t, topology in enumerate(topologies): count = _scalar_int(topology.num_edges) if topology.edge_index.shape[1] < count: raise ValueError( f"Graph edge count is {count}, but only {topology.edge_index.shape[1]} " "entries were provided." ) num_edges_array[t] = count if count > 0: edge_index_array[t, :, :count] = np.asarray( jax.device_get(topology.edge_index[:, :count]), dtype=np.int32, ) return _TopologyBatch( edge_index=jnp.asarray(edge_index_array, dtype=jnp.int32), num_edges=jnp.asarray(num_edges_array, dtype=jnp.int32), mech_pair_i=mech_pair_i, mech_pair_j=mech_pair_j, mech_num_pairs=mech_num_pairs, diff_pair_i=diff_pair_i, diff_pair_j=diff_pair_j, diff_num_pairs=diff_num_pairs, ) def _apply_rollout_step_from_batch( *, model, config: TrainConfig, X: jax.Array, P: jax.Array, c: jax.Array, R: jax.Array, topology: _TopologyBatch, particle_count: int, device: str, ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]: X, P, c, dX, _dP, _dc = _apply_gns_step( model, X, P, c, topology.edge_index, topology.num_edges, particle_count, config.dt_gns, ) if config.mech_steps > 0 or config.diff_steps > 0: from waxmorph.jax.warp_autograd import warp_diffusion_step, warp_mech_step for step in range(config.mech_steps): X = warp_mech_step( X, R, topology.mech_pair_i[step], topology.mech_pair_j[step], config.dt_mech, num_pairs=topology.mech_num_pairs[step], device=device, ) for step in range(config.diff_steps): c = warp_diffusion_step( c, topology.diff_pair_i[step], topology.diff_pair_j[step], config.D_emu, config.dt_diff, num_pairs=topology.diff_num_pairs[step], device=device, ) return X, P, c, jnp.sum(dX**2) def _epoch_loss_with_topology_batch( *, model, config: TrainConfig, X_source: jax.Array, P_source: jax.Array, c_source: jax.Array, R: jax.Array, particle_count: int, targets_by_frame: dict[int, jax.Array], topology_batch: _TopologyBatch, loss_fn: Callable[[jax.Array, jax.Array], jax.Array], device: str, ) -> tuple[jax.Array, jax.Array]: def scan_step(carry, topology): X, P, c = carry X, P, c, step_l2 = _apply_rollout_step_from_batch( model=model, config=config, X=X, P=P, c=c, R=R, topology=topology, particle_count=particle_count, device=device, ) return (X, P, c), (X, step_l2) _, (positions, step_l2) = jax.lax.scan( scan_step, (X_source, P_source, c_source), topology_batch, ) loss_shape = jnp.asarray(0.0, dtype=X_source.dtype) for frame, target in targets_by_frame.items(): loss_shape = loss_shape + loss_fn(positions[int(frame)], target) return loss_shape, jnp.sum(step_l2) def _zero_grads_if_nonfinite(grads, finite: jax.Array): return jax.tree.map( lambda g: jnp.where(finite, g, jnp.zeros_like(g)) if isinstance(g, jax.Array) else g, grads, ) def _make_train_step( *, config: TrainConfig, optimizer: optax.GradientTransformation, targets_by_frame: dict[int, jax.Array], loss_fn: Callable[[jax.Array, jax.Array], jax.Array], particle_count: int, device: str, ): @eqx.filter_jit def train_step( model, opt_state: optax.OptState, X_source: jax.Array, P_source: jax.Array, c_source: jax.Array, R: jax.Array, topology_batch: _TopologyBatch, ): def loss_fn_inner(candidate_model): loss_shape, loss_l2 = _epoch_loss_with_topology_batch( model=candidate_model, config=config, X_source=X_source, P_source=P_source, c_source=c_source, R=R, particle_count=particle_count, targets_by_frame=targets_by_frame, topology_batch=topology_batch, loss_fn=loss_fn, device=device, ) return loss_shape + (loss_l2 * config.lambda_reg), (loss_shape, loss_l2) (loss, (loss_shape, loss_l2)), grads = eqx.filter_value_and_grad( loss_fn_inner, has_aux=True )(model) grads_finite = _tree_all_finite(grads) grad_norm = _tree_global_norm(grads) grads_for_update = _zero_grads_if_nonfinite(grads, grads_finite) if config.grad_clip_norm is not None: grads_for_update, grad_norm = _clip_grads(grads_for_update, config.grad_clip_norm) updates, opt_state = optimizer.update( grads_for_update, opt_state, eqx.filter(model, eqx.is_array), ) model = eqx.apply_updates(model, updates) params_finite = _tree_all_finite(model) return model, opt_state, loss, loss_shape, loss_l2, grad_norm, grads_finite, params_finite return train_step
[docs] def train( model: JaxGNS, optimizer: optax.GradientTransformation, opt_state: optax.OptState, loss_fn: Callable[[jnp.ndarray, jnp.ndarray], jnp.ndarray], *, 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 a GNS model for shape assembly with a fixed agent count (JAX backend). Each epoch performs one update. Histories, the selected model, and its trajectory all describe post-update states; evaluating ``n_epochs`` updates requires ``n_epochs + 1`` collect-and-replay rollouts. Collection freezes data-dependent topology outside autodiff; replay differentiates features and state updates through fixed, padded buffers. Inputs are validated before conversion to contiguous float32. State arrays share a nonzero particle axis. Source polarities are used as supplied for the first graph, whose angle feature assumes unit vectors; later learned updates apply normalization. Target frame ``0`` means the state after the first rollout update, and valid unique frames lie in ``[0, t_rollout)``. ``TrainResult`` contains the selected model and log; callers manage Optax state separately. A new ``save_path`` receives the best model's two-file checkpoint and log. Use checkpoints from trusted sources; config, PyTree, leaf shapes, and dtypes must match. Saved weights replace the supplied model, Optax state is reinitialized, and exactly one refinement update preserves existing files. Warp mechanics or diffusion uses custom VJPs on a CUDA-backed JAX device. CPU training uses zero for both prescribed step counts. See :class:`TrainResult` for the log schema. Raises: TypeError: If config types, array dtypes, or target frames are invalid. RuntimeError: If requested Warp physics fails CUDA-backed JAX/Warp validation. ValueError: If config bounds, state arrays, radii, targets, or checkpoint structure are invalid. """ config = TrainConfig() if config is None else config source_pos, polarities, c, radii, targets = _prepare_training_inputs( config, source_pos, polarities, c, radii, targets ) N = len(source_pos) jax_device = _resolve_jax_device(device, require_cuda=_uses_warp_bridge(config)) if save_path is not None and os.path.exists(save_path): with jax.default_device(jax_device): checkpoint_model = JaxGNS.load(save_path) _validate_checkpoint_model(model, checkpoint_model) model = _device_put_arrays(checkpoint_model, jax_device) with jax.default_device(jax_device): opt_state = optimizer.init(eqx.filter(model, eqx.is_array)) opt_state = _device_put_arrays(opt_state, jax_device) config = dataclasses.replace(config, n_epochs=1) else: model = _device_put_arrays(model, jax_device) opt_state = _device_put_arrays(opt_state, jax_device) with jax.default_device(jax_device): X_source = jnp.asarray(source_pos, dtype=jnp.float32) P_source = jnp.asarray(polarities, dtype=jnp.float32) c_source = jnp.asarray(c, dtype=jnp.float32) R = jnp.asarray(radii, dtype=jnp.float32) targets_by_frame = {frame: jnp.asarray(pos, dtype=jnp.float32) for frame, pos in targets} losses_total, losses_shape, losses_l2 = [], [], [] best_loss, best_model, best_trajectory, best_epoch = float("inf"), None, None, 0 best_shape_loss = best_l2_loss = 0.0 max_pairs = _max_edges_for_config(config, N) train_step = _make_train_step( config=config, optimizer=optimizer, targets_by_frame=targets_by_frame, loss_fn=loss_fn, particle_count=N, device=device, ) def collect_model(candidate_model): with jax.default_device(jax_device): topologies, trajectory = _collect_topologies_and_trajectory( model=candidate_model, config=config, X=X_source, P=P_source, c=c_source, R=R, particle_count=N, device=device, ) batch = _stack_topologies(topologies, config=config, max_pairs=max_pairs) return batch, trajectory def record_evaluation(epoch, candidate_model, trajectory, values): nonlocal best_loss, best_model, best_trajectory, best_epoch, best_shape_loss, best_l2_loss epoch_loss, epoch_shape_loss, epoch_l2_loss = values losses_total.append(epoch_loss) losses_shape.append(epoch_shape_loss) losses_l2.append(epoch_l2_loss) if epoch_loss < best_loss: best_loss, best_shape_loss, best_l2_loss = values best_model, best_trajectory, best_epoch = candidate_model, trajectory, 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}" ) topology_batch, epoch_trajectory = collect_model(model) for epoch in trange(config.n_epochs): pre_update_model = model ( model, opt_state, loss, loss_shape, loss_l2, grad_norm, grads_finite, params_finite, ) = train_step( model, opt_state, X_source, P_source, c_source, R, topology_batch, ) loss_values = _checked_loss_values( loss, loss_shape, loss_l2, epoch=0 if epoch == 0 else epoch - 1, phase="pre-update evaluation" if epoch == 0 else "post-update evaluation", ) if not bool(jax.device_get(grads_finite)): raise ValueError( f"Non-finite values detected in model gradients during post-backward " f"at epoch {epoch}." ) if config.grad_clip_norm is not None and not bool(jnp.isfinite(grad_norm)): raise ValueError( f"Non-finite gradient norm after clipping at epoch {epoch}: " f"grad_norm={float(grad_norm)!r}" ) if not bool(jax.device_get(params_finite)): raise ValueError( f"Non-finite values detected in model parameters during post-optimizer step " f"at epoch {epoch}." ) if epoch > 0: record_evaluation( epoch - 1, pre_update_model, epoch_trajectory, loss_values, ) if epoch + 1 < config.n_epochs: topology_batch, epoch_trajectory = collect_model(model) topology_batch, final_trajectory = collect_model(model) with jax.default_device(jax_device): final_shape_loss, final_l2_loss = _epoch_loss_with_topology_batch( model=model, config=config, X_source=X_source, P_source=P_source, c_source=c_source, R=R, particle_count=N, targets_by_frame=targets_by_frame, topology_batch=topology_batch, loss_fn=loss_fn, device=device, ) final_loss = final_shape_loss + (final_l2_loss * config.lambda_reg) final_values = _checked_loss_values( final_loss, final_shape_loss, final_l2_loss, epoch=config.n_epochs - 1, phase="post-update evaluation", ) record_evaluation(config.n_epochs - 1, model, final_trajectory, final_values) log = { "losses_total": np.array(losses_total), "losses_shape": np.array(losses_shape), "losses_l2": np.array(losses_l2), "best_traj_pos": np.stack([f["pos"] for f in best_trajectory]), "best_traj_pol": np.stack([f["pol"] for f in best_trajectory]), "best_traj_c": np.stack([f["c"] for f in best_trajectory]), "best_epoch": best_epoch, "best_loss": best_loss, "target_frames": np.array(sorted(targets_by_frame), 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) best_model.save(save_path) np.savez_compressed(f"{save_path}.log.npz", **log) return TrainResult(model=best_model, log=log)