@@ -148,3 +148,171 @@ result = bounded_while_loop(
148148)
149149print (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.
0 commit comments