Source code for waxmorph.jax.gnn

"""Equinox encode-process-decode GNS (Sanchez-Gonzalez et al., ICML 2020).

Static JIT shapes use padded directed contact edges; ``num_edges`` masks padding before
message aggregation. This is the behavioral counterpart of :mod:`waxmorph.torch.gnn`.
"""

from __future__ import annotations

import json
from pathlib import Path

import equinox as eqx
import jax
import jax.numpy as jnp

from .mlp import MLP


[docs] class GraphNetworkBlock(eqx.Module): """Residual edge and node updates over directed COO messages. Each ``sender -> receiver`` row has a separate edge latent. Incoming real-edge messages are summed by receiver; ``num_edges`` masks padding. ``key`` initializes the two MLPs independently. """ edge_mlp: MLP node_mlp: MLP def __init__( self, node_latent_dim: int = 128, edge_latent_dim: int = 128, hidden_dim: int = 128, num_mlp_layers: int = 2, activation: str = "silu", layer_norm: bool = True, *, key: jax.Array, ): k1, k2 = jax.random.split(key) self.edge_mlp = MLP( input_dim=2 * node_latent_dim + edge_latent_dim, output_dim=edge_latent_dim, hidden_dim=hidden_dim, num_layers=num_mlp_layers, activation=activation, layer_norm=layer_norm, key=k1, ) self.node_mlp = MLP( input_dim=node_latent_dim + edge_latent_dim, output_dim=node_latent_dim, hidden_dim=hidden_dim, num_layers=num_mlp_layers, activation=activation, layer_norm=layer_norm, key=k2, )
[docs] def __call__( self, node_latent: jax.Array, edge_latent: jax.Array, edge_index: jax.Array, num_edges: jax.Array | None = None, ) -> tuple[jax.Array, jax.Array]: """Return residual node and edge latents. ``edge_index`` is directed COO ``[2, E]`` (senders, receivers). Entries at indices greater than or equal to ``num_edges`` contribute zero messages. """ senders, receivers = edge_index[0], edge_index[1] edge_input = jnp.concatenate( [node_latent[senders], node_latent[receivers], edge_latent], axis=-1, ) edge_latent_new = edge_latent + self.edge_mlp(edge_input) if num_edges is not None: mask = jnp.arange(edge_latent_new.shape[0]) < num_edges edge_latent_new = jnp.where(mask[:, None], edge_latent_new, 0.0) num_nodes = node_latent.shape[0] agg = jnp.zeros((num_nodes, edge_latent_new.shape[-1]), dtype=node_latent.dtype) agg = agg.at[receivers].add(edge_latent_new) node_input = jnp.concatenate([node_latent, agg], axis=-1) node_latent_new = node_latent + self.node_mlp(node_input) return node_latent_new, edge_latent_new
[docs] class GNS(eqx.Module): """Encode graph features, apply residual message passing, and decode state deltas. Default heads ``dX``, ``dP``, and ``dc`` are raw per-node position, polarity, and concentration deltas; training scales them by ``dt_gns``. ``key`` is split across all submodules. ``checkpoint_processor`` rematerializes processor blocks and decoder heads, trading compute for fewer saved activations. Examples: >>> model = GNS(1, 2, hidden_dim=4, num_mp_steps=1, output_dims={"dX": 3}, key=jax.random.PRNGKey(0)) >>> out = model(jnp.ones((2, 1)), jnp.array([[0, 1], [1, 0]]), jnp.ones((2, 2))) >>> print(sorted(out), tuple(out["dX"].shape)) ['dX'] (2, 3) """ node_encoder: MLP edge_encoder: MLP processor: list[GraphNetworkBlock] decoders: dict[str, MLP] checkpoint_processor: bool = eqx.field(static=True) _config: dict = eqx.field(static=True) def __init__( self, node_feature_dim: int, edge_feature_dim: int, node_latent_dim: int = 128, edge_latent_dim: int = 128, hidden_dim: int = 128, num_mp_steps: int = 10, num_mlp_layers: int = 2, output_dims: dict[str, int] | None = None, activation: str = "relu", layer_norm: bool = True, checkpoint_processor: bool = False, *, key: jax.Array, ): self.checkpoint_processor = checkpoint_processor if output_dims is None: output_dims = {"dX": 3, "dP": 3, "dc": 2} self._config = { "node_feature_dim": node_feature_dim, "edge_feature_dim": edge_feature_dim, "node_latent_dim": node_latent_dim, "edge_latent_dim": edge_latent_dim, "hidden_dim": hidden_dim, "num_mp_steps": num_mp_steps, "num_mlp_layers": num_mlp_layers, "output_dims": output_dims, "activation": activation, "layer_norm": layer_norm, "checkpoint_processor": checkpoint_processor, } keys = jax.random.split(key, 2 + num_mp_steps + len(output_dims)) self.node_encoder = MLP( input_dim=node_feature_dim, output_dim=node_latent_dim, hidden_dim=hidden_dim, num_layers=num_mlp_layers, activation=activation, layer_norm=layer_norm, key=keys[0], ) self.edge_encoder = MLP( input_dim=edge_feature_dim, output_dim=edge_latent_dim, hidden_dim=hidden_dim, num_layers=num_mlp_layers, activation=activation, layer_norm=layer_norm, key=keys[1], ) self.processor = [ GraphNetworkBlock( node_latent_dim=node_latent_dim, edge_latent_dim=edge_latent_dim, hidden_dim=hidden_dim, num_mlp_layers=num_mlp_layers, activation=activation, layer_norm=layer_norm, key=keys[2 + i], ) for i in range(num_mp_steps) ] self.decoders = { name: MLP( input_dim=node_latent_dim, output_dim=dim, hidden_dim=hidden_dim, num_layers=num_mlp_layers, activation=activation, layer_norm=False, key=keys[2 + num_mp_steps + i], ) for i, (name, dim) in enumerate(output_dims.items()) }
[docs] def __call__( self, node_features: jax.Array, edge_index: jax.Array, edge_features: jax.Array, num_edges: jax.Array | None = None, ) -> dict[str, jax.Array]: """Decode per-node heads from a directed COO graph. ``num_edges`` masks padded rows. Each value has shape ``[N, output_dims[head]]``. """ node_latent = self.node_encoder(node_features) edge_latent = self.edge_encoder(edge_features) for block in self.processor: if self.checkpoint_processor: node_latent, edge_latent = eqx.filter_checkpoint(block)( node_latent, edge_latent, edge_index, num_edges, ) else: node_latent, edge_latent = block( node_latent, edge_latent, edge_index, num_edges, ) if self.checkpoint_processor: return { name: eqx.filter_checkpoint(dec)(node_latent) for name, dec in self.decoders.items() } return {name: dec(node_latent) for name, dec in self.decoders.items()}
[docs] def save(self, path: str | Path) -> None: """Write Equinox leaves to ``path`` and constructor config to ``path.json``.""" path = Path(path) eqx.tree_serialise_leaves(path, self) with open(str(path) + ".json", "w") as f: json.dump(self._config, f)
[docs] @classmethod def load(cls, path: str | Path) -> GNS: """Reconstruct from the ``path.json`` config and leaves stored at ``path``.""" path = Path(path) with open(str(path) + ".json") as f: config = json.load(f) skeleton = cls(**config, key=jax.random.PRNGKey(0)) return eqx.tree_deserialise_leaves(path, skeleton)