Layer 4 — Backend Adapters¶
One module per backend under pipekit_train.adapters.*. Each
exports a single run(loop) → (trained_model_op, backend_info)
function. v0.1 ships the Equinox adapter as the reference; Keras
is scaffolded.
Equinox (v0.1 reference)¶
pipekit_train.adapters.equinox. Built on the eqx_trainer design:
Grain DataLoader, Orbax CheckpointManager, Optax optimiser,
eqx.filter_jit train step, eqx.partition checkpoint bridge.
Extras: pipekit-train[equinox].
Module surface¶
# pipekit_train/adapters/equinox.py
class TrainState(eqx.Module):
"""Equinox-side training state. Stored inside TrainerCarryState.opt_state.
A nested-state design: TrainerCarryState (the pipekit-side carry
that everyone sees) holds an opaque opt_state field; for the
Equinox adapter, that field is itself a TrainState. The split
keeps the JAX-specific PyTree concerns inside the adapter and
the carrier-agnostic concerns inside the loop.
"""
model: eqx.Module
opt_state: optax.OptState
step: int
@staticmethod
def create(
model: eqx.Module, optimizer: optax.GradientTransformation
) -> "TrainState":
opt_state = optimizer.init(eqx.filter(model, eqx.is_array))
return TrainState(model=model, opt_state=opt_state, step=0)
@runtime_checkable
class TrainTask(Protocol):
"""User-defined Equinox training target. Mirrors eqx_trainer D4.
Provide `loss_fn`. The adapter synthesises a TrainTask from
`loop.loss` if none is supplied. Evaluation compiles `loss_fn` and
averages its aux metrics per sample, unless the task defines the
optional `eval_fn(model, batch, key, valid) -> metrics_dict`.
"""
def loss_fn(
self,
model: eqx.Module,
batch: Any,
key: jax.Array,
) -> tuple[jax.Array, dict[str, jax.Array]]: ...
@eqx.filter_jit
def train_step(
state: TrainState,
batch: Any,
key: jax.Array,
task: TrainTask,
optimizer: optax.GradientTransformation,
) -> tuple[TrainState, dict[str, jax.Array]]:
"""One Equinox optimisation step (see api/primitives.md)."""
def save_state(mngr: ocp.CheckpointManager, state: TrainState, step: int) -> None: ...
def restore_state(
mngr: ocp.CheckpointManager, template: TrainState, step: int
) -> TrainState: ...
def run(loop: TrainingLoop) -> tuple[Operator, dict[str, Any]]:
"""Train `loop` end-to-end with the Equinox+Optax+Grain+Orbax stack.
Returns:
trained_model_op: A pipekit_jax.JaxModelOp (or domain
subclass) wrapping the trained eqx.Module.
backend_info: Dict with keys: backend ("equinox"), jax_version,
equinox_version, optax_version, devices, total_seconds.
"""
Padded evaluation with eval_fn¶
Defining eval_fn switches evaluation to padded batches, where every batch has
batch_size rows and compiles once. Padded rows are copies of the last real row,
and valid is True for real rows.
class Regression:
def loss_fn(self, model, batch, key):
x, y = batch
err = (jax.vmap(model)(x) - y) ** 2
return err.mean(), {"mse": err.mean()}
def eval_fn(self, model, batch, key, valid):
x, y = batch
err = ((jax.vmap(model)(x) - y) ** 2).mean(axis=-1) # (batch_size,)
return {"mse": jnp.where(valid, err, 0.0).sum() / valid.sum()}
Each batch's metrics are weighted by valid.sum(), so a masked per-sample mean
is exact across the validation set. A metric that isn't a mean of per-sample
terms, such as a quantile, is only exact when the whole set fits in one batch.
What run does¶
- Resolve the optimizer.
optimizer_config = {"name": "adamw", "lr": 3e-4, ...}→optax.chain(optax.adamw(**kwargs), ...). Schedule support: iflris a dict like{"schedule": "cosine_decay", "init_value": 3e-4, "decay_steps": loop.max_steps}, build anoptax.cosine_decay_scheduleand nest the optimiser insideoptax.inject_hyperparams. - Resolve the task. If
loop.taskis set, use it. Otherwise synthesise:class SynthTask: def loss_fn(self, model, batch, key): pred = jax.vmap(model)(batch["x"]); l = loop.loss(pred, batch["y"]); return l, {"loss": l}. - Build the data iterator. Wrap
loop.datasetas agrain.MapDatasetsource if it isn't already one; add agrain.Batch(loop.batch_size)operation; wrap in agrain.DataLoader(worker_count=2, ...). Same forloop.val_dataset. - Set up the checkpoint manager. If
loop.checkpoint_dir, build anocp.CheckpointManagerwithsave_interval_steps=loop.callbacks[Checkpoint].every_n_stepsandmax_to_keep=loop.callbacks[Checkpoint].keep_last, using aCompositeCheckpointHandlerso the Grain iterator state checkpoints alongside the model. - Initialise
TrainStateandTrainerCarryState. Pull the model fromloop.model_op(it must be aJaxModelOpor a subclass exposing.modulereturning the bareeqx.Module). Build the carry-state with the right RNG split. - Resume. If checkpoint manager has a
latest_step, restoreTrainState+ Grain iterator state. - Run the per-step loop. For each step up to
loop.max_steps: calltrain_step; periodically log (log_every_n_steps), evaluate (eval_every_n_steps), checkpoint, run callbacks. - Build the trained
model_op. Wrap the finaleqx.Moduleback into aJaxModelOp(or the originalmodel_op's class) and return it together withbackend_info.
Sharding (multi-device / multi-host) — v0.2¶
Pass an optional ShardingSpec to enable data-parallel (and uniform
model-parallel) training (issue #15, ADR D12):
import jax
from jax.sharding import Mesh, PartitionSpec
from pipekit_train import TrainingLoop
from pipekit_train.adapters.equinox import ShardingSpec
mesh = Mesh(jax.devices(), axis_names=("data",))
spec = ShardingSpec(
mesh=mesh,
model_pspec=PartitionSpec(), # replicate the model on every device
data_pspec=PartitionSpec("data"), # shard each batch along its leading axis
)
loop = TrainingLoop(
model_op=model_op,
dataset=dataset,
loss=loss,
batch_size=256, # must be divisible by the "data" axis size
sharding=spec, # None => single-device (the default)
)
trained_op, artifact = loop.run()
How it threads through run:
TrainState— model + optimiser array leaves are placed onspec.model_sharding; the step counter is replicated.train_step— unchanged;eqx.filter_jitcompiles sharding-aware once its inputs are sharded (replicated model + adata-sharded batch give data-parallel SPMD, XLA inserting the collectives).- Data iterator — each batch is placed on
spec.data_sharding. For multi-host (an explicit spec andprocess_count > 1), each process reads a disjoint shard of thegrainMapDataset(map_ds[process_index::process_count], the modern equivalent ofgrain.ShardByJaxProcess) and batches the local sizebatch_size // process_count, so the per-process batches assemble viajax.make_array_from_process_local_datainto a global batch of exactlybatch_size. Without a spec the single-device path is preserved even under a distributed-initialised JAX. (batch_sizemust divideprocess_count; multi-host needs an indexable dataset, not a streaming one.) - Checkpointing — Orbax restore is sharding-aware automatically: the
restore template is the already-sharded
TrainState. backend_info["sharding"]records the mesh shape and partition specs for the artifact.
Verified on a CPU-simulated 2-device mesh (tests/adapters/
test_equinox_sharding.py) and a scripted 2-process jax.distributed smoke
(scripts/multihost_sharding_smoke.py).
Notes¶
sharding=None(the default) is the original single-host, single-device path, unchanged.- The adapter does not require the user to install
pipekit-jax. If it isn't installed, the adapter returns a minimalJaxModelOp-like wrapper defined inline (the wrapper just calls theeqx.Module's__call__). The fullpipekit-jax.JaxModelOpadds shape inference and weight-blob round-trip. - The Grain iterator is the only JAX-side data-loading dependency. PyTorch DataLoader is explicitly not used.
Keras (v0.3 scaffold)¶
pipekit_train.adapters.keras. Extras: pipekit-train[keras].
v0.1 ships with the same NotImplementedError stub.
Planned design¶
When v0.3 lands, the adapter will:
- Translate
loop.lossinto akeras.losses.Losssubclass (or accept the user's pre-builttf.keras.lossesinstance vialoop.task). - Configure the optimiser via
keras.optimizers.getusingoptimizer_config. - Wrap
loop.datasetas atf.data.Dataset.from_generator(...). - Call
model.compile(...)thenmodel.fit(...). - Wrap the trained
keras.Modelinpipekit-array.ModelOpand return.
Keras 3's multi-backend (TF / JAX / torch) means the Keras adapter gives a second JAX path orthogonal to the Equinox one — useful for projects that already standardise on Keras layers.
NumPyro (implemented) — two Bayesian backends: numpyro-svi + numpyro-mcmc¶
pipekit_train.adapters.numpyro_svi and …numpyro_mcmc. Extras:
pipekit-train[numpyro] (numpyro>=0.15, optax, grain; JAX via
NumPyro). See ADR D13 and the full design in
../numpyro_adapter.md.
"Training" here is Bayesian inference. NumPyro is split into two backends by paradigm (the per-step vs blocking loop shapes are too different to method-dispatch):
numpyro-svi(variational, per-step) —svi.updatereturns(state, loss)per step, so it is the per-step loop: callbacks /metric_writer/ eval / checkpoint /max_steps/_BatchSourceminibatching all apply, andSVI(optim=...)takes an optax transformation sooptimizer_configis reused.numpyro-mcmc(sampling, single call) — onemcmc.run(NUTS) over the full dataset (begin/end callbacks only; the per-step fields don't apply).
Both are task-first (D9): loop.task is one shared NumpyroTask (no
method field — the backend selects the paradigm, so the same task works
under either); the carrier-agnostic Loss (D4) does not apply. The artifact
is a NumpyroPredictiveOp (posterior-predictive Operator) from the shared
adapters.bayes seam (D7). They fill the benchmark ladder's rung-2
(numpyro-mcmc) and rung-4 (numpyro-svi); numpyro-mcmc is a sibling of
the BlackJAX sampler backend (below). Full design — user story, math, CS
background, API, examples, build order, references:
../numpyro_adapter.md.
Unsupervised fits, kernel settings and full posterior draws:
def model(data=None, *, prior_scale=10.0):
mu = numpyro.sample("mu", dist.Normal(0.0, prior_scale))
sigma = numpyro.sample("sigma", dist.HalfNormal(5.0))
with numpyro.plate("n", 32 if data is None else data.shape[0]):
numpyro.sample("obs", dist.Normal(mu, sigma), obs=data)
task = NumpyroTask(
model,
supervised=False, # a dataset of single items
model_kwargs={"prior_scale": 10.0},
kernel_kwargs={"target_accept_prob": 0.995}, # NUTS(model, **kernel_kwargs)
)
op, artifact = TrainingLoop(..., task=task, backend="numpyro-mcmc").run()
op.posterior_samples()["mu"] # (num_samples,)
op.predictive_draws(None)["obs"] # (num_samples, 32): None draws new data
op(None) # the posterior-predictive mean, as before
# after Checkpoint(registry=registry); the model is a closure, so it isn't stored
op = NumpyroPredictiveOp.from_registry(
registry, artifact.trained_model_hash, model=model
)
BlackJAX (planned) — sampler-library backend¶
pipekit_train.adapters.blackjax. Extras: pipekit-train[blackjax]
(blackjax>=1.2, optax, grain; numpyro only for the NumPyro-model
task path). See ADR D14 and the full design in
../blackjax_adapter.md.
Separate from NumPyro, by design. BlackJAX is a sampler library, not a
PPL: it operates on a logdensity_fn, and is PPL-agnostic. So it is its own
backend (different tool / extra / contract) — but it interoperates: a
BlackjaxTask accepts a raw logdensity_fn or a NumpyroTask (bridged
via numpyro.infer.util.initialize_model), and both adapters share the
model→logdensity + Predictive seam (adapters.bayes). Net UX: write a
NumPyro model once, flip backend="numpyro-mcmc" ↔ "blackjax".
Why a separate adapter pays off: BlackJAX's kernel.step(rng, state) ->
(state, info) is one sample per step, so it reuses the per-step loop
(per-sample diagnostics / checkpoint / early-stop) — a cleaner fit than
NumPyro's blocking mcmc.run. It also unlocks an algorithm zoo NumPyro
lacks: MCLMC, stochastic-gradient MCMC (sgld/sghmc — minibatch MCMC
via _BatchSource), tempered SMC, and pathfinder/svgd VI. Full design:
../blackjax_adapter.md.
Adapter selection¶
TrainingLoop.run() selects the adapter by string:
loop = TrainingLoop(..., backend="equinox") # uses adapters.equinox
loop = TrainingLoop(..., backend="keras") # uses adapters.keras
loop = TrainingLoop(..., backend="numpyro-svi") # adapters.numpyro_svi (planned)
loop = TrainingLoop(..., backend="numpyro-mcmc") # adapters.numpyro_mcmc (planned)
loop = TrainingLoop(..., backend="blackjax") # adapters.blackjax (planned)
The selection happens at run() time, not at construction, so a
TrainingLoop YAML can be authored with backend="keras" even
on a machine that hasn't installed [keras] — the missing extra
is reported at run time only.