Skip to content

Validation and early stopping

run_supervised trains for a fixed number of epochs unless you give it two optional collaborators: a ValidationStrategy that measures the model after each epoch, and an EarlyStopper that decides from a monitored metric when to stop. This guide explains how the two fit together and why an early stopper that monitors validation metrics cannot run without a strategy. It assumes the setup from Training a supervised model.

Validation is a strategy you inject

The runner does not know what "validation" means for your problem. It might be a held-out loader, a fold of a cross-validation, a rolling window over a time series, a grouped split, or an external evaluator. Rather than build any of these into the training loop, run_supervised takes a strategy: a callable validation(state, *, epoch) that returns a ValidationResult whose metrics are keyed by bare metric names.

The runner calls it once after every training epoch, records each metric in state.metric_history under the validation split (for example supervised/validation/loss), and emits a validation_end training event. Where the data comes from and how it is scored stay inside the strategy.

For the conventional case, HeldOutValidation evaluates the model on a loader called with split="validation", using the same kind of SupervisedLoss as training and, optionally, its own MetricSuite. It holds a reference to the live model, so it always scores the parameters the last epoch produced.

Stopping on a validation metric

An EarlyStopper monitors one metric by its bare name, in a chosen direction (mode), measured on a chosen split (source). Its source defaults to validation, so the stopper below watches the validation loss:

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 (
    HeldOutValidation,
    create_optimizer,
    initialize_training_state,
    make_supervised_loss,
    run_supervised,
)

inputs = jnp.linspace(-1.0, 1.0, 32).reshape(-1, 1)
targets = 3.0 * inputs + 2.0


def loader(*, split, epoch):
    half = slice(0, None, 2) if split == "train" else slice(1, None, 2)
    return [Batch(inputs=inputs[half], targets=targets[half])]


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

result = run_supervised(
    model=model,
    optimizer=optimizer,
    train_loader=loader,
    loss=loss,
    state=state,
    epochs=3,
    validation=HeldOutValidation(model=model, loader=loader, loss=loss),
    early_stopper=EarlyStopper(metric="loss", mode="min", patience=1),
    checkpoint_store=store,
)

assert len(state.metric_history["supervised/validation/loss"]) == 3
assert store.exists("supervised/best")

The metric is "loss", not "validation_loss": a metric name is the same on every split, and the stopper's source is what says which split to read it from. If the monitored name is not among the metrics the chosen split produced, the runner raises a ValueError at the end of the first epoch.

After each epoch the stopper observes the monitored value. The first value is always an improvement; after that, a value improves only if it beats the best so far by more than min_delta. Training stops once the number of consecutive non-improving epochs exceeds patience, so patience=0 stops at the first epoch that fails to improve. epochs remains the upper bound either way.

Why the stopper needs a strategy

A stopper whose source is validation reads its metric from the ValidationResult of the same epoch. Without a strategy no such result exists, so there is nothing to observe. Rather than silently fall back to the training metric of the same name, which would stop on a quantity you did not ask for, run_supervised refuses the combination before it touches the model or the training state:

import jax
import optax
from flax import nnx

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

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

try:
    run_supervised(
        model=model,
        optimizer=optimizer,
        train_loader=lambda *, split, epoch: [],
        loss=make_supervised_loss(SquaredErrorLoss()),
        state=state,
        epochs=3,
        early_stopper=EarlyStopper(metric="loss", mode="min", patience=1),
    )
except ValueError as error:
    print(error)  # validation metric monitoring requires a validation strategy.
else:
    raise AssertionError("expected a ValueError")

EarlyStopper.expects_validation is the check the runner makes.

The training-metric alternative

When you have no validation data, or want to stop on convergence of the fit itself, monitor a training metric by setting source to train explicitly. No strategy is needed, and the stopper observes the epoch's training metrics:

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

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

inputs = jnp.linspace(-1.0, 1.0, 32).reshape(-1, 1)
targets = 3.0 * inputs + 2.0


def train_loader(*, split, epoch):
    return [Batch(inputs=inputs, targets=targets)]


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

result = run_supervised(
    model=model,
    optimizer=optimizer,
    train_loader=train_loader,
    loss=make_supervised_loss(SquaredErrorLoss()),
    state=state,
    epochs=3,
    early_stopper=EarlyStopper(
        metric="loss", mode="min", patience=1, source=Split.TRAIN, min_delta=1e-4
    ),
)

assert "supervised/validation/loss" not in state.metric_history

The default is validation because that is what early stopping is usually for: stopping when the model stops generalising. A training metric measures only the fit to the data being trained on, so choosing it is a decision the code states rather than one the runner makes for you.

Best models and checkpoints

Whenever the stopper reports an improvement, the runner emits a best_model decision event, and when it stops, an early_stop event. Both carry the Decision they judged, the monitored metric and its value, and no metrics of their own.

If you pass a CheckpointStore, each improvement also saves a Checkpoint under checkpoint_key, replacing the previous one. The key defaults to stage/best, supervised/best for the runner's default stage. The runner does not restore it: when training ends, the live model holds the last epoch's parameters, not the best ones. To continue from the best model, load the checkpoint and pass it to restore_checkpoint; the Checkpoints guide shows how.