Layer 3 — Models (The Training Loop)¶
TrainingLoop is the headline composable. It is a
pipekit.StatefulOperator carrying (model, opt_state, step, epoch,
metrics, rng). Running it produces (trained_model_op,
TrainingArtifact).
TrainerCarryState¶
The state threaded through the loop, subclass of pipekit.CarryState.
class TrainerCarryState(CarryState):
"""Carry-state for TrainingLoop.
Attributes:
model: The current model operator (possibly partially trained).
Round-trips through ModelRegistry on checkpoint.
opt_state: Backend-specific optimizer state. The Equinox
adapter stores an optax.OptState; Lightning stores a
torch.optim.Optimizer.state_dict(); Keras stores a
dict of variable names → arrays.
step: Global step counter.
epoch: Derived from step // steps_per_epoch (0 if
steps_per_epoch is None).
metrics: Latest per-step metric snapshot — what `on_step_end`
saw most recently. Cleared on `on_epoch_end`.
rng: Backend-agnostic PRNG state. A (seed, counter) tuple
for backends without native PRNG state, or the backend's
native key for those that have one (JAX).
"""
def __init__(
self,
model: Operator,
opt_state: Any,
step: int = 0,
epoch: int = 0,
metrics: dict[str, float] | None = None,
rng: Any = None,
) -> None:
self.model = model
self.opt_state = opt_state
self.step = step
self.epoch = epoch
self.metrics = metrics or {}
self.rng = rng
to_dict / from_dict are inherited from CarryState; the
opt_state and rng fields are encoded by the adapter (which knows
their structure). Checkpoint round-trip therefore goes
carry_state → dict → JSON file + per-adapter weight blob.
TrainingLoop¶
class TrainingLoop(StatefulOperator):
"""Train a model_op on a dataset, producing a trained model_op.
Delegates the actual training to a backend adapter
(`pipekit_train.adapters.<backend>.run`). The adapter sees the
full loop config and is responsible for the per-step gradient
update and the per-eval validation pass.
Args:
model_op: Untrained model Operator. Typically wraps an
equinox.Module, lightning.LightningModule, or
keras.Model. Must be picklable / serializable through its
backend-specific channel.
dataset: A pipekit_train.TrainingDataset.
val_dataset: Optional held-out TrainingDataset for eval.
loss: A pipekit_train.Loss (or any callable satisfying the
Protocol). Ignored if the user supplies a backend-native
task interface via task=...
task: Optional backend-native task interface — for Equinox,
an instance of adapters.equinox.TrainTask. Lets power
users bypass the auto-synthesised loss path.
optimizer_config: Flat dict, e.g.
{"name": "adamw", "lr": 3e-4, "weight_decay": 0.01}.
Translated by the adapter into the backend's optimizer.
max_steps: Total optimisation steps. (Per-step is the unit;
see ADR D10.)
steps_per_epoch: Optional. Controls when on_epoch_end fires.
None means epoch boundaries are never reported.
batch_size: Per-step minibatch size. The dataset's loader is
wrapped with the appropriate Batch op.
backend: One of "equinox", "lightning", "keras", and the planned
Bayesian backends "numpyro-svi", "numpyro-mcmc", "blackjax".
Maps to the adapter module.
callbacks: Tuple of pipekit_train.Callback instances.
Default: empty tuple. (Note: tuple, not list, to avoid
mutable default + to preserve operator hashability.)
metric_writer: Optional pipekit_train.MetricWriter. Falls
back to a no-op writer when None.
eval_every_n_steps: Validation cadence.
log_every_n_steps: Metric logging cadence.
checkpoint_dir: Optional. When set, the adapter wires up
its native checkpoint machinery to this directory.
seed: Master PRNG seed.
"""
model_op: Operator
dataset: TrainingDataset
val_dataset: TrainingDataset | None = None
loss: Loss | None = None
task: Any | None = None
optimizer_config: dict[str, Any] = field(default_factory=lambda: {"name": "adamw", "lr": 3e-4})
max_steps: int = 10_000
steps_per_epoch: int | None = None
batch_size: int = 32
backend: Literal[
"equinox", "lightning", "keras",
"numpyro-svi", "numpyro-mcmc", "blackjax", # planned (Bayesian)
] = "equinox"
callbacks: tuple[Callback, ...] = ()
metric_writer: MetricWriter | None = None
eval_every_n_steps: int = 500
log_every_n_steps: int = 100
checkpoint_dir: str | None = None
seed: int = 0
def _apply(self, carrier: Any, state: TrainerCarryState | None) -> tuple[Any, TrainerCarryState]:
"""StatefulOperator hook — delegates to adapter.run + advances state."""
...
def run(self) -> tuple[Operator, TrainingArtifact | dict[str, Any]]:
"""Run the loop end-to-end, returning the trained model and artifact.
Equivalent to `_apply(None, initial_state)` followed by an
artifact build pass. Most users call this directly rather than
composing TrainingLoop into a Sequential.
"""
...
The outer loop (adapter-driven)¶
TrainingLoop does not itself call train_step. It delegates to:
_BACKEND_MODULES = {
"equinox": "pipekit_train.adapters.equinox",
"lightning": "pipekit_train.adapters.lightning",
"keras": "pipekit_train.adapters.keras",
# planned Bayesian backends (see design/numpyro_adapter.md, blackjax_adapter.md):
"numpyro-svi": "pipekit_train.adapters.numpyro_svi",
"numpyro-mcmc": "pipekit_train.adapters.numpyro_mcmc",
"blackjax": "pipekit_train.adapters.blackjax",
}
def run(self):
adapter = import_module(_BACKEND_MODULES[self.backend])
trained_model_op, backend_info = adapter.run(self)
artifact = self._build_artifact(trained_model_op, backend_info)
return trained_model_op, artifact
The adapter receives the full TrainingLoop and is responsible for:
- Constructing the optimizer from
optimizer_config. - Building the backend-native loader from
dataset(andval_datasetif set). - Initialising the carry-state.
- Running the per-step loop up to
max_steps, calling callbacks at the documented hooks, evaluating everyeval_every_n_steps. - Returning
(trained_model_op, backend_info_dict).
Artifact assembly (_build_artifact)¶
TrainingLoop itself fills these fields:
training_pipeline_yaml←pipekit.dumps(self)dataset_hash←self.dataset.content_hash()backend_info←backend_infofrom adapterdeps_lock← best-effort capture ofuv.lockorpip freeze
These fields come from callbacks:
tracker_run_id←LogToExperimentcallback, if attachedmodel_registry_uri←Checkpoint(registry=...), if attachedtrained_model_hash← same callback path
If pipekit-experiment is not installed (no [experiment] extra),
_build_artifact returns a dict with the same field names instead
of a TrainingArtifact.
Sequential composition example¶
Because TrainingLoop is a StatefulOperator, multi-stage training
is a single Sequential:
pretrain = TrainingLoop(model_op=base_model, dataset=large_unlabeled, ...)
finetune = TrainingLoop(model_op=..., dataset=labeled, ...)
# Sequential threads carry-state (including model) through both stages
full = pipekit.Sequential([pretrain, finetune])
trained_model_op, artifact = full.run()
This is the deepest reason TrainingLoop is a StatefulOperator
and not a plain Operator — it lets pretrain → fine-tune ship as
one YAML file with one carry-state thread.