✨ feat(nn): AbstractScanNNTrainer for scan-over-epoch NN training#11
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
Adds neural-network training utilities built around jax.lax.scan, while making equinox optional and expanding the library’s internal/public API surface.
Changes:
- Introduce
jaxmore.nnwithAbstractScanNNTrainer,shuffle_and_batch, andmasked_meanfor scan-over-epoch training loops. - Add an internal
error_ifhelper and wirebounded_while_loopto useequinox.error_ifwhen available (or fall back when not). - Add a new
jaxmore._src.vmapimplementation and update packaging/docs for optional dependencies (equinox,jax-tqdm) and new dev/test deps (optax, etc.).
Reviewed changes
Copilot reviewed 12 out of 14 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
uv.lock |
Locks new dependencies (optional deps infra, jax-tqdm, optax, etc.) and extras wiring. |
pyproject.toml |
Makes equinox optional via extras; adds test deps; tweaks pytest/ruff config. |
src/jaxmore/__init__.py |
Re-exports bounded_while_loop, vmap, error_if, and exposes nn. |
src/jaxmore/nn.py |
Public re-export module for NN training utilities. |
src/jaxmore/_src/__init__.py |
Internal re-export surface for core primitives. |
src/jaxmore/_src/optional_deps.py |
Central optional dependency gating via OptDeps. |
src/jaxmore/_src/error.py |
Adds internal error_if implemented via jax.debug.callback. |
src/jaxmore/_src/while_loop.py |
Adds check_termination option and conditional error-check backend selection. |
src/jaxmore/_src/vmap.py |
Adds enhanced vmap with static args/kwargs support and kw-axis control. |
src/jaxmore/_src/nn.py |
Implements AbstractScanNNTrainer, masked_mean, and shuffle_and_batch. |
tests/test_unit.py |
Adds tests for bounded_while_loop(check_termination=False) including jit. |
tests/test_error.py |
Adds unit tests for new error_if behavior under jit/vmap. |
tests/test_nn_trainer.py |
Adds tests for NN training utilities, including an end-to-end eqx/optax example. |
README.md |
Documents jaxmore.nn usage and cautions around Equinox partitioning. |
Comments suppressed due to low confidence (1)
src/jaxmore/_src/while_loop.py:159
- Docstring typo: "Uses internal and
error_if" reads like a grammatical error. Suggest "Uses internalerror_if" (or "Uses the internalerror_if").
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
7d86f6e to
99635f5
Compare
Signed-off-by: nstarman <nstarman@users.noreply.github.com>
Follow-up on AbstractScanNNTrainer: correctness bugs, an API gap, and a
docs/test suite that was not actually being run.
Correctness:
- masked_mean: clamp the denominator before dividing. The naive
`where(count > 0, total / count, nan)` evaluates `total / count` on both
branches, so an empty mask computes 0/0 and `where`'s VJP multiplies that
branch's `inf` derivative by zero -> NaN *gradient*. Reachable in practice:
shuffle_and_batch sorts ignorable samples last, so they cluster into
entirely-empty batches. The forward value (NaN) is unchanged.
- run(): actually skip empty batches via `lax.cond`, as the docstring already
claimed. Restores behaviour that exists in phasecurvefit's loop but was lost
in the extraction, and makes the efficiency claim true.
- run(): default show_pbar=False. jax-tqdm is an optional extra, so the default
path of the headline API raised ImportError on a bare `pip install jaxmore`.
- shuffle_and_batch: validate batch_size and the args' leading dimension.
New:
- prepare_step_kw(epoch_idx, num_epochs, epoch_key) -> Mapping, merged over
step_kw each epoch. This is how a scalar is scheduled across training: a
ramped loss weight, an annealed temperature, a curriculum threshold. Not
previously expressible -- run() discarded the epoch index entirely.
- prepare_data_args gains epoch_idx/num_epochs for symmetry. Both hooks are
skipped when not overridden.
- error_if is exported from `jaxmore` rather than advertised from `_src`.
Docs:
- testpaths pointed at "README" and "docs", neither of which exists (it is
README.md; there is no docs/), so pytest silently collected ZERO README
tests. Fixed -- and three README examples that never ran were broken.
- The flagship example ignored batch_mask and so trained on the zero-padded
rows (12 fabricated samples per epoch at N=100, batch_size=16). Now
mask-aware, as is the run() docstring example.
- Drop the "Option 1b is 5-10% faster" claim: it contradicted the benchmark
table on the same page (which ranked 1b slowest) and the test's own printed
conclusion. Measured, the three strategies sit within run-to-run noise.
- New runnable examples: scheduled hyperparameter, per-epoch resampled data,
and freezing a submodule via a hand-built filter_spec.
Tests:
- Restructure into the tests/{unit,usage,benchmark} layout from GalacticDynamics#18.
- Replace the 10s time.perf_counter() loop in the unit suite with proper
pytest-benchmark benchmarks; drop the dead run_timing_comparison().
- Regressions for the empty-mask gradient, empty-batch skipping, the padding
trap, the lambda_p ramp, and frozen-submodule training.
Signed-off-by: nstarman <nstarman@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #11 +/- ##
=======================================
Coverage ? 95.55%
=======================================
Files ? 9
Lines ? 225
Branches ? 0
=======================================
Hits ? 215
Misses ? 10
Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Comment on lines
84
to
+88
| Returns | ||
| ------- | ||
| T | ||
| Final carry value, either when `cond_fn` first returns `False`, or (if | ||
| that never happens) after `max_steps` iterations (but in that case an | ||
| error is raised). | ||
| that never happens) after `max_steps` iterations. |
- Expand the shuffle_and_batch comments to explain why offsetting False keys by +1.0 shuffles within each group without interleaving. - Document bounded_while_loop's Raises section and add an example showing the overflow RuntimeError plus the check_termination=False opt-out. Signed-off-by: nstarman <nstarman@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
jaxmore.nn: a scan-over-epoch training loop for JAX, plus the optional-dependencyplumbing it needs. Also demotes
equinoxfrom a required dependency to an extra.What's new
jaxmore.nnAbstractScanNNTrainer— a base class for training loops built onjax.lax.scan.It owns the epoch loop, shuffling, batching, empty-batch skipping, and loss
aggregation. You supply:
make_stepandloss_agg_fnas constructor arguments;init,pack_carry_state,unpack_carry_stateas subclass hooks;prepare_data_argsandprepare_step_kw, two per-epoch hooks (below).shuffle_and_batch— shuffles, pads to a constant batch shape, and returns a maskdistinguishing real samples from padding. Usable samples are sorted first, so ignorable
ones cluster at the end.
masked_mean— mean over the True entries of a mask. ReturnsNaNon an emptymask, with a well-defined (zero) gradient.
Adapted from phasecurvefit. This part
of the library is more experimental than the rest; if you have interestingly shaped data,
please open an issue.
Per-epoch hooks
Both run once per epoch and receive
epoch_idx(a traced scalar),num_epochs(a staticPython int), and
epoch_key. Both default to no-ops and are skipped entirely when notoverridden.
prepare_step_kwstep_kwprepare_data_argsN: resampled negatives, augmentationsThe split matters: anything
prepare_data_argsreturns goes throughshuffle_and_batch,so it must be
N-long. A scalar can't ride along that path — that's whatprepare_step_kwis for.
num_epochsis passed because it is otherwise unreachable from a subclass —run()ownsit, not the instance — and a linear ramp needs the denominator.
error_ifjaxmore.error_if— a JIT/vmap-compatible runtime assertion built onjax.debug.callback,used as the fallback when
equinoxisn't installed.bounded_while_loopnow takescheck_termination=to turn its post-scan check off.Dependency changes⚠️
equinoxjaxmore[equinox]jax-tqdmjaxmore[tqdm]jaxtypingoptional_dependenciesequinoxis now only used to pick a bettererror_ifinbounded_while_loop; without it,jaxmore falls back to its own.
jaxmore.nnitself has no hard Equinox dependency — itworks with any pytree carry — though the docs use Equinox + Optax because that's the common
case.
Anything currently relying on
equinoxarriving transitively viajaxmorewill need todepend on it directly, or install
jaxmore[equinox].Notes for reviewers
Two sharp edges are worth a look, since both are easy to get wrong and neither errors
loudly:
Padding is real data unless you mask it.
scanneeds a constant batch shape, so thelast batch is zero-padded. A loss that ignores
batch_masktrains on those fabricatedrows — with
N=100, batch_size=16that's 12 phantom(0,0) -> 0samples every epoch.The README now leads with this, and the flagship example uses
masked_mean.Empty batches are reachable. Because
shuffle_and_batchsorts ignorable samples tothe end, they cluster into whole batches — 40 usable of 100 at
batch_size=16yieldsfour batches with no usable rows. So
masked_meanhas to be differentiable at an emptymask (it uses a clamped denominator; the naive
where(count > 0, total / count, nan)gives NaN gradients), and
run()skips such batches vialax.condrather than burning aforward/backward pass on them.
Also worth a second look: the frozen-submodule example. Initializing
opt_stateon apartial partition while taking gradients of the whole model hands optax a tree with slots
it never allocated. The fix — partition, differentiate the trainable half, recombine — is
non-obvious enough that it's now a documented example with a test asserting the frozen
subtree comes out bit-for-bit unchanged.
Also in this PR
testpathspointed atREADMEanddocs, neither of which exists (it'sREADME.md, andthere's no
docs/), so pytest silently collected zero README tests. Fixed — every```pythonblock in the README now executes under Sybil, and several that didn't runhave been repaired.
tests/{unit,usage,benchmark}layout from 🧱 infra: set up unit, usage, and benchmark tests #18. The optimizer-strategycomparison moved from a
time.perf_counter()loop in the unit suite to properpytest-benchmarkbenchmarks.table further down the same page (which ranked 1b slowest) and the test's own printed
conclusion. Measured, the three strategies sit within run-to-run noise (~1.02/1.06/1.03 ms,
±0.12–0.25 ms), so the docs now say to choose on clarity.
PLW0108ruff violations (intests/unit/test_vmap.pyandtests/benchmark/test_structured.py, both from 🧱 infra: set up unit, usage, and benchmark tests #18). They were failingruff checkandtherefore blocking the pre-commit hook on any commit.
Known wart (not blocking)
OptDepshits anEnumaliasing quirk: when no optional dependency is installed, everymember gets the same value (
InstalledState.NOT_INSTALLED), so Python collapses them intoaliases —
OptDeps.JAX_TQDM is OptDeps.EQUINOXbecomes True andlist(OptDeps)drops amember.
.installedstill answers correctly in every install combination (verified: bare /equinox-only / tqdm-only / both), so nothing is broken today, but
.nameand iteration areunreliable in that state. Probably wants an upstream fix in
optional_dependencies.