Skip to content

Checkpoints

A checkpoint is everything needed to pick a staged workflow up again at the point it was taken: the training state, the model and optimizer state, the random key and the early stopper's progress. The runners save one whenever an early stopper reports a new best model, so that the best model can be put back once training stops. Each stage goes on to finalize its best checkpoint as the one it hands on, so that, for example, a variance stage can start from the mean stage's checkpoint later, in a fresh training state.

Checkpoints and stores

A Checkpoint is a frozen value with these fields:

Field Holds
state The TrainingState at that epoch: lifecycle, stage, parameter roles, frozen components and metric history
epoch The epoch the checkpoint was taken at
parameters The model's state; in the JAX backend an NnxSnapshot
optimizer_state The optimizer's state, also an NnxSnapshot
rng_state The random key
early_stopping_state The EarlyStoppingState when it was taken
metadata Free-form annotations, such as the stage that finalized it

The JAX runners build the state field with freeze_training_state, which copies the mutable containers and leaves out the live model and optimizer objects. Those are captured separately, as snapshots, so that a saved checkpoint does not keep changing as training goes on.

Checkpoints are kept in a CheckpointStore, which saves, loads and checks for them under keys the caller chooses. The store gives a key no meaning of its own. stagereg ships one implementation, InMemoryCheckpointStore, which keeps checkpoints in process memory for as long as the store exists:

import pytest

from stagereg.core import Checkpoint, InMemoryCheckpointStore, TrainingState

store = InMemoryCheckpointStore()
assert not store.exists("best")

store.save("best", Checkpoint(state=TrainingState(), epoch=3))
store.save("best", Checkpoint(state=TrainingState(), epoch=7))
assert store.load("best").epoch == 7  # saving under a used key replaces it

with pytest.raises(KeyError):
    store.load("missing")

Keeping the best model

run_supervised saves a checkpoint only when it has both an early_stopper and a checkpoint_store, and only on an epoch where the early stopper reports an improvement. It saves under checkpoint_key, which defaults to the checkpoint key stage/best (here supervised/best, the runner's default stage), so the store always holds the best epoch so far. Without an early stopper there is no notion of best, and nothing is saved.

The runner leaves the live model at its last epoch, not its best one. restore_checkpoint puts the best one back, updating the model and optimizer in place and restoring the training state that was saved with them:

import jax
import jax.numpy as jnp
import optax
from flax import nnx

from stagereg.core import Batch, EarlyStopper, InMemoryCheckpointStore, SquaredErrorLoss
from stagereg.jax import (
    create_optimizer,
    initialize_training_state,
    make_supervised_loss,
    restore_checkpoint,
    run_supervised,
)

inputs = jnp.linspace(-1.0, 1.0, 8).reshape(-1, 1)
targets = 2.0 * inputs


def loader(*, split: str, epoch: int) -> list[Batch]:
    return [Batch(inputs=inputs, targets=targets)]


model = nnx.Linear(1, 1, rngs=nnx.Rngs(0))
optimizer = create_optimizer(model, optax.sgd(0.1))
state = initialize_training_state(model, optimizer, rng_key=jax.random.key(0))
store = InMemoryCheckpointStore()

run_supervised(
    model=model,
    optimizer=optimizer,
    train_loader=loader,
    loss=make_supervised_loss(SquaredErrorLoss()),
    state=state,
    epochs=3,
    early_stopper=EarlyStopper(metric="loss", mode="min", patience=1, source="train"),
    checkpoint_store=store,
)

best = store.load("supervised/best")
history = state.metric_history["supervised/train/loss"]
assert history[best.epoch] == min(history)

restore_checkpoint(best, state=state, model=model, optimizer=optimizer)
assert state.metric_history["supervised/train/loss"] == history[: best.epoch + 1]

The early stopper here monitors the training loss to stay self-contained; one that monitors a validation metric needs a validation strategy as well.

A stage's finalized checkpoint

MeanStage and GammaVarianceStage go one step further when their options carry an early stopper and a checkpoint store, and both follow the same rule. At the end of train the stage restores its best checkpoint into its live model, so the model it leaves behind is the best one rather than the last. It then saves that checkpoint again under the same key, finalized: its training state is at the stage's ready lifecycle state, MEAN_READY or VARIANCE_READY, and its metadata records the stage and "stage_complete": True, for example {"stage": "mean", "stage_complete": True}. The returned result reports the restored epoch's training metrics. So the variance stage computes residuals from the best mean model, and a finalized checkpoint is the hand-off point after each stage.

The variance checkpoint snapshots only the variance model and optimizer, since the mean model is frozen and already lives in the mean stage's checkpoint. When the variance stage restores it, it keeps the mean model and its optimizer registered under mean_model_name and mean_optimizer_name, so the state still passes validate. restore_checkpoint on its own restores clean-slate: it registers only the model and optimizer you pass it.

Both stages read the key from their own SupervisedStageOptions. Left unset, it defaults to the stage's own key, mean/best or variance/best, so the two stages can share one store without overwriting each other's checkpoints. An explicit checkpoint_key still wins. select_checkpoint returns a CheckpointRef to the key a stage saves under.

Resuming across stages

The finalized checkpoints are enough to pick a staged run up again in a new training state, with freshly built objects: models and optimizers of the same architecture to restore into, and nothing else from the first run. Each stage restores its own checkpoint with restore (MeanStage.restore and GammaVarianceStage.restore), mean first, then variance. The snippet below trains both stages, then starts over from the store alone, as a later process would:

import jax
import jax.numpy as jnp
import optax
from flax import nnx

from stagereg.core import (
    Batch,
    EarlyStopper,
    InMemoryCheckpointStore,
    StageState,
    TrainingState,
)
from stagereg.jax import (
    GammaHead,
    GammaVarianceStage,
    MeanStage,
    SupervisedStageOptions,
    create_optimizer,
)

inputs = jnp.linspace(-1.0, 1.0, 8).reshape(-1, 1)
targets = 2.0 * inputs + 0.1 * jnp.sin(7.0 * inputs)


def loader(*, split: str, epoch: int) -> list[Batch]:
    return [Batch(inputs=inputs, targets=targets)]


def build_stages(
    store: InMemoryCheckpointStore, seed: int
) -> tuple[MeanStage, GammaVarianceStage]:
    mean_model = nnx.Linear(1, 1, rngs=nnx.Rngs(seed))
    variance_model = GammaHead(1, 1, rngs=nnx.Rngs(seed + 1))
    mean_stage = MeanStage(
        model=mean_model,
        optimizer=create_optimizer(mean_model, optax.adam(0.1)),
        train_loader=loader,
        options=SupervisedStageOptions(
            epochs=3,
            early_stopper=EarlyStopper(
                metric="loss", mode="min", patience=1, source="train"
            ),
            checkpoint_store=store,
        ),
    )
    variance_stage = GammaVarianceStage(
        model=variance_model,
        optimizer=create_optimizer(variance_model, optax.adam(0.1)),
        source_loader=loader,
        options=SupervisedStageOptions(
            epochs=3,
            early_stopper=EarlyStopper(
                metric="loss", mode="min", patience=1, source="train"
            ),
            checkpoint_store=store,
        ),
        splits=("train",),
    )
    return mean_stage, variance_stage


store = InMemoryCheckpointStore()

# The first run trains both stages and keeps their finalized checkpoints.
first_mean, first_variance = build_stages(store, seed=0)
first_state = TrainingState(rng_state=jax.random.key(0))
for stage in (first_mean, first_variance):
    stage.prepare(first_state)
    stage.train(first_state)

# A later run rebuilds the stages and restores them from the store, mean first.
mean_stage, variance_stage = build_stages(store, seed=10)
state = TrainingState()
mean_stage.restore(state, store.load("mean/best"))
variance_stage.restore(state, store.load("variance/best"))

assert state.lifecycle_state is StageState.VARIANCE_READY
assert variance_stage.validate(state).passed
assert state.model_components["mean_model"] is mean_stage.model
assert jnp.allclose(mean_stage.model(inputs), first_mean.model(inputs))
assert jnp.allclose(
    variance_stage.model(inputs).rate, first_variance.model(inputs).rate
)

restore takes only the stage's own finalized checkpoint, and checks it before changing anything: it raises ValueError if the checkpoint's lifecycle state is not the stage's ready state, or its metadata does not name the stage and mark it complete. So the other stage's checkpoint, or a best checkpoint left behind by a run that stopped mid-stage, is refused. The variance stage also refuses while no mean model is registered, which is why the mean stage restores first. The restored models and optimizers must have the same structure as the ones that were saved, or restore raises ValueError, again before changing anything.

The restored state carries the stages' metric history, their parameter roles and the random key. After the mean stage's restore alone, the state is MEAN_READY, so the variance stage can also be prepared and trained from there rather than restored, continuing the run rather than starting a new one.

Calling restore_checkpoint directly on a staged checkpoint still works, but restores clean-slate and leaves the registry names to you; the stage methods are the way to resume a staged run.

The posterior stage

The optional PosteriorStage saves under posterior/best by default, so it can share the store with the other two stages. With an early stopper, every improvement saves a best checkpoint holding the inference method's full state, e.g. variational parameters plus optimizer state. At the end of training the method resumes from the best one, and the stage overwrites it with its finalized checkpoint, which holds the posterior alone: the method's posterior_state, kept as the saved state's posterior_state, with lifecycle state POSTERIOR_READY. Neither the mean and variance weights nor the optimizer state are saved again. A method that does not support early stopping, such as SG-MCMC, writes only the finalized checkpoint, at the end.

A later process resumes all three stages in order:

mean_stage.restore(state, store.load("mean/best"))
variance_stage.restore(state, store.load("variance/best"))
posterior_stage.restore(state, store.load("posterior/best"))

PosteriorStage.restore initializes its inference method on the restored mean and variance models and hands it the saved posterior state through load_posterior. It keeps the mean and variance registrations, and refuses, before changing anything, while either is missing, when the checkpoint is not its finalized checkpoint, or when it was saved under other component names.

Two-step mean/variance training explains the stages themselves.

Keeping checkpoints beyond one process

An InMemoryCheckpointStore is gone when the process ends, so the resume above works only within one process. Keeping checkpoints across processes takes a store of your own. CheckpointStore is a protocol, so any object with matching save, load and exists methods will do, with no base class to inherit. Such a store has to serialize the whole checkpoint, including the backend's snapshots and the random key, and stagereg does not ship one.