Skip to content

Commit 7d86f6e

Browse files
committed
✨ feat(nn): AbstractScanNNTrainer for scan-over-epoch NN training
Signed-off-by: nstarman <nstarman@users.noreply.github.com>
1 parent b778ada commit 7d86f6e

14 files changed

Lines changed: 1761 additions & 102 deletions

File tree

README.md

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,171 @@ result = bounded_while_loop(
148148
)
149149
print(result) # (Array(3, dtype=int32), Array(8, dtype=int32))
150150
```
151+
152+
### `jaxmore.nn` — efficient neural network training with JAX scan
153+
154+
The `AbstractScanNNTrainer` class provides a foundation for building efficient
155+
training loops using
156+
[`jax.lax.scan`](https://jax.readthedocs.io/en/latest/_autosummary/jax.lax.scan.html)
157+
to scan over batches and epochs. It handles shuffling, batching, and loss
158+
aggregation automatically, while you focus on defining `make_step()`.
159+
160+
> **Note**: The neural network training utilities in `jaxmore.nn` are more
161+
> experimental than other components of this library. The code is adapted from
162+
> [phasecurvefit](https://phasecurvefit.readthedocs.io/en/latest/). If you have
163+
> interestingly shaped data or encounter errors, please
164+
> [submit an issue](https://github.com/GalacticDynamics/jaxmore/issues) so we
165+
> can continue to generalize this training code.
166+
167+
Here's a complete example of training a simple feed-forward network:
168+
169+
```python
170+
import equinox as eqx
171+
import jax
172+
import jax.numpy as jnp
173+
import jax.random as jr
174+
import optax
175+
from jaxmore.nn import AbstractScanNNTrainer, masked_mean
176+
177+
178+
# Define a simple neural network using eqx.nn.MLP
179+
model = eqx.nn.MLP(
180+
in_size=2,
181+
out_size=1,
182+
width_size=32,
183+
depth=2,
184+
activation=jax.nn.relu,
185+
key=jr.key(0),
186+
)
187+
188+
189+
# Create a trainer subclass
190+
class NNTrainer(AbstractScanNNTrainer):
191+
"""Concrete trainer implementation for SimpleNN."""
192+
193+
def init(self, *, key, X, y, learning_rate=1e-2):
194+
"""Initialize model and training data."""
195+
model_key, data_key = jr.split(key)
196+
model = eqx.nn.MLP(
197+
in_size=X.shape[1],
198+
out_size=1,
199+
width_size=32,
200+
depth=2,
201+
activation=jax.nn.relu,
202+
key=model_key,
203+
)
204+
205+
optimizer = optax.adam(learning_rate)
206+
opt_state = optimizer.init(eqx.filter(model, eqx.is_array))
207+
208+
# Create training carry: (model, optimizer_state, rng_key)
209+
carry_key = jr.fold_in(data_key, 0)
210+
initial_carry = (model, opt_state, carry_key)
211+
212+
# All samples are usable (True), none are padding (False)
213+
mask = jnp.ones(len(X), dtype=bool)
214+
epoch_data = (mask, (X, y))
215+
216+
return initial_carry, epoch_data
217+
218+
def pack_carry_state(self, carry):
219+
"""Partition model into arrays and static structure."""
220+
model, opt_state, key = carry
221+
model_dyn, model_static = eqx.partition(model, eqx.is_array)
222+
return (model_dyn, opt_state, key), {"model_static": model_static}
223+
224+
def unpack_carry_state(self, carry, static):
225+
"""Reconstruct full model from partitioned state."""
226+
model_dyn, opt_state, key = carry
227+
model_static = static["model_static"]
228+
model = eqx.combine(model_dyn, model_static)
229+
return (model, opt_state, key)
230+
231+
232+
# Define optimizer
233+
optimizer = optax.adam(1e-2)
234+
235+
236+
# Define the per-batch training step
237+
def make_step(carry, batch_inputs):
238+
"""Execute one batch of training."""
239+
model, opt_state, key = carry
240+
batch_mask, (X_batch, y_batch) = batch_inputs
241+
242+
def loss_fn(model):
243+
preds = jax.vmap(model)(X_batch)
244+
mse = jnp.mean((preds.squeeze() - y_batch) ** 2)
245+
return mse
246+
247+
loss, grads = eqx.filter_value_and_grad(loss_fn)(model)
248+
updates, opt_state = optimizer.update(grads, opt_state)
249+
model = eqx.apply_updates(model, updates)
250+
251+
return loss, (model, opt_state, key)
252+
253+
254+
# Generate synthetic training data
255+
key = jr.key(0)
256+
X = jr.normal(key, (100, 2))
257+
y = 2 * X[:, 0] + X[:, 1] + 0.1 * jr.normal(jr.fold_in(key, 1), (100,))
258+
259+
# Create trainer and initialize
260+
trainer = NNTrainer(make_step=make_step, loss_agg_fn=masked_mean)
261+
carry, epoch_data = trainer.init(key=key, X=X, y=y, learning_rate=1e-2)
262+
263+
# Train for 10 epochs with batch size 16
264+
final_carry, losses = trainer.run(
265+
carry,
266+
epoch_data,
267+
num_epochs=10,
268+
batch_size=16,
269+
key=key,
270+
show_pbar=False,
271+
)
272+
273+
print(f"Final epoch loss: {losses[-1]:.6f}") # doctest: +SKIP
274+
```
275+
276+
Key features:
277+
278+
- **Automatic batching**`shuffle_and_batch()` handles shuffling and padding
279+
- **Efficient scanning** — Uses `jax.lax.scan` for epochs and batches
280+
(JAX-friendly)
281+
- **Model partitioning** — Separate dynamic arrays (model weights) from static
282+
structure for efficient JIT compilation
283+
- **Loss aggregation** — Customize how per-batch losses combine into epoch
284+
losses
285+
286+
#### ⚠️ Sharp Edge: Equinox Models Require `eqx.partition`
287+
288+
**CRITICAL**: When using Equinox models (`eqx.nn.MLP`, `eqx.nn.Linear`, etc.),
289+
you **MUST** implement `pack_carry_state()` and `unpack_carry_state()` using
290+
`eqx.partition` and `eqx.combine`. This is not optional!
291+
292+
**Why?** Equinox modules contain methods decorated with `@jax.custom_jvp`, which
293+
JAX cannot scan over directly. Without partitioning, you'll encounter:
294+
295+
```
296+
TypeError: Argument '...' is not a valid JAX type
297+
```
298+
299+
**Correct pattern** (as shown above):
300+
301+
```python
302+
def pack_carry_state(self, carry):
303+
model, opt_state, key = carry
304+
# Separate arrays from static structure (methods, activations, etc.)
305+
model_dyn, model_static = eqx.partition(model, eqx.is_array)
306+
return (model_dyn, opt_state, key), {"model_static": model_static}
307+
308+
309+
def unpack_carry_state(self, carry, static):
310+
model_dyn, opt_state, key = carry
311+
model_static = static["model_static"]
312+
# Reconstruct full model
313+
model = eqx.combine(model_dyn, model_static)
314+
return (model, opt_state, key)
315+
```
316+
317+
This separates trainable arrays (which JAX can scan over) from static Python
318+
objects (which it cannot), enabling the training loop to work correctly.

pyproject.toml

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ classifiers = [
1919
"Topic :: Scientific/Engineering",
2020
"Typing :: Typed",
2121
]
22-
dependencies = ["equinox>=0.11.0", "jax>=0.4.20"]
22+
dependencies = ["jax>=0.4.20", "jaxtyping>=0.3.7", "optional_dependencies>=0.3"]
2323
description = "There's more to JAX."
2424
dynamic = ["version"]
2525
license.file = "LICENSE"
@@ -33,6 +33,10 @@ Changelog = "https://github.com/GalacticDynamics/jaxmore/releases"
3333
Discussions = "https://github.com/GalacticDynamics/jaxmore/discussions"
3434
Homepage = "https://github.com/GalacticDynamics/jaxmore"
3535

36+
[project.optional-dependencies]
37+
equinox = ["equinox>=0.11.0"]
38+
tqdm = ["jax-tqdm>=0.1.0"]
39+
3640

3741
[build-system]
3842
build-backend = "hatchling.build"
@@ -61,12 +65,14 @@ lint = ["mypy>=1.19.0", "pre-commit>=4.1.0", "pylint>=3.3.8"]
6165
nox = ["nox>=2024.10.9", "nox-uv>=0.6.3"]
6266
test = [
6367
"beartype>=0.19.0",
68+
"equinox>=0.11.0",
69+
"jax-tqdm>=0.1.0",
6470
"jaxtyping>=0.2.34",
65-
"optional_dependencies>=0.3",
71+
"optax>=0.2.0",
6672
"pytest>=8.4.2",
6773
"pytest-cov>=6.2.1",
6874
"pytest-github-actions-annotate-failures>=0.3.0",
69-
"sybil[pytest]>=9.2.0",
75+
"sybil>=9.2.0",
7076
]
7177

7278

@@ -97,7 +103,10 @@ addopts = [
97103
"-p no:doctest", # using sybil
98104
"-ra",
99105
]
100-
filterwarnings = ["error"]
106+
filterwarnings = [
107+
"error",
108+
"ignore:Setting `jax_pmap_shmap_merge` is deprecated:DeprecationWarning",
109+
]
101110
log_cli_level = "INFO"
102111
minversion = "8.3"
103112
testpaths = ["src/", "tests/", "README", "docs"]
@@ -140,11 +149,22 @@ ignore = [
140149
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
141150
"TD002", # Missing author in TODO
142151
"TD003", # Missing issue link on the line following this TODO
152+
"PYI041", # Use `X | Y` instead of `Union[X, Y]` in function signatures
143153
]
144154

145155
[tool.ruff.lint.per-file-ignores]
146156
"noxfile.py" = ["T20"]
147-
"tests/**" = ["ANN", "E731", "INP001", "S101", "T20"]
157+
"tests/**" = [
158+
"ANN",
159+
"E731",
160+
"FBT003",
161+
"INP001",
162+
"N803",
163+
"N806",
164+
"RET504",
165+
"S101",
166+
"T20",
167+
]
148168

149169
[tool.ruff.lint.isort]
150170
combine-as-imports = true
@@ -158,6 +178,10 @@ ignore-paths = [".*/_version.py"]
158178
messages_control.disable = [
159179
"design",
160180
"fixme",
181+
"import-error",
182+
"import-outside-toplevel",
183+
"invalid-enum-extension",
184+
"invalid-name",
161185
"line-too-long",
162186
"missing-function-docstring",
163187
"missing-module-docstring",

src/jaxmore/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Copyright (c) 2026 Nathaniel Starkman. All rights reserved."""
22

3-
__all__ = ("bounded_while_loop", "vmap")
3+
__all__ = ("bounded_while_loop", "nn", "vmap")
44

5-
from ._vmap import vmap
6-
from ._while import bounded_while_loop
5+
from . import nn
6+
from ._src import bounded_while_loop, vmap

src/jaxmore/_src/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Copyright (c) 2026 Nathaniel Starkman. All rights reserved."""
2+
3+
from .error import * # noqa: F403
4+
from .vmap import * # noqa: F403
5+
from .while_loop import * # noqa: F403

0 commit comments

Comments
 (0)