Skip to content

Tracking a run

A runner reports what happens during training as training events, and anything that wants to watch a run receives them as an event sink. To record a run in TensorBoard, MLflow or any other experiment tracker, you implement the three methods of ExperimentTracker and pass TrackerEventSink, which wraps it, to the runner. This page covers those three types, why the library stops there, and how the tag for each metric and hyperparameter is built. Terms in bold are defined on the Glossary page.

Events, sinks and trackers

A training event is a structured observation from a named point in a stage's lifecycle. run_supervised emits four of them:

Event name Split Carries
epoch_end train the epoch's training metrics
validation_end validation the epoch's validation metrics, when a ValidationStrategy is given
best_model the early stopper's split a Decision, no metrics
early_stop the early stopper's split a Decision, no metrics

Every event carries its stage, its split, its step (the epoch) and the live training state. An event sink is anything with an on_event method. Sinks are intended to observe only, but the live state means the API cannot prevent them from mutating the run. Pass any number of sinks in event_sinks, so adding a tracker displaces nothing else that watches the run.

An experiment tracker is a destination that records parameters, metrics and artifacts for one run. It knows nothing about events. It is told what to record, never when a run reaches a point of interest. TrackerEventSink is the adapter between the two: for each event it calls log_metrics with the event's metrics under their metric tags, at the event's step. It never calls log_params or log_artifact, since no training event carries hyperparameters or figures. Your script calls those itself, before and after the run.

The tracker below keeps everything in memory, which is enough to see what a real one would receive:

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

from stagereg.core import EarlyStopper, TrackerEventSink, flatten_parameters
from stagereg.core.types import Batch
from stagereg.jax import (
    HeldOutValidation,
    create_optimizer,
    initialize_training_state,
    run_supervised,
)


class InMemoryTracker:
    """An ExperimentTracker that keeps what it is told in memory."""

    def __init__(self):
        self.params = {}
        self.scalars = {}
        self.artifacts = {}

    def log_params(self, values):
        self.params.update(flatten_parameters(values))

    def log_metrics(self, values, *, step):
        for tag, value in values.items():
            self.scalars.setdefault(tag, []).append((step, value))

    def log_artifact(self, name, value):
        self.artifacts[name] = value


def squared_error(model, inputs, targets, sample_weight, key, training):
    return jnp.mean(jnp.square(model(inputs) - targets))


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


def 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))

tracker = InMemoryTracker()
tracker.log_params({"optimizer": {"name": "sgd", "learning_rate": 0.1}})

run_supervised(
    model=model,
    optimizer=optimizer,
    train_loader=loader,
    loss=squared_error,
    state=state,
    epochs=3,
    validation=HeldOutValidation(model=model, loader=loader, loss=squared_error),
    early_stopper=EarlyStopper(metric="loss", mode="min", patience=1),
    event_sinks=[TrackerEventSink(tracker)],
)
tracker.log_artifact("notes", "three epochs of SGD")

print(sorted(tracker.params))
# ['optimizer/learning_rate', 'optimizer/name']
print(sorted(tracker.scalars))
# ['supervised/train/loss', 'supervised/validation/loss']
print([step for step, _ in tracker.scalars["supervised/validation/loss"]])
# [0, 1, 2]

The tags supervised/train/loss and supervised/validation/loss share their bare metric name, so a tracker such as TensorBoard can draw them as two series on one chart. The tracking example does the same with a real TensorBoard tracker in tensorboard_tracker.py, the file to copy into your own project.

Why core ships the bridge but never a tracker

stagereg.core declares the event and tracker protocols and ships TrackerEventSink, and that is all of its tracking support. Every concrete tracker lives outside the library, in an example you copy.

The split follows what can be reused. Turning an event into a tracker record is the same for every destination, and a mistake there costs you without showing itself: a tag without the stage lets one stage's curve silently overwrite another's. So the library owns that translation. Writing a scalar to a given tracker, on the other hand, takes a handful of vendor-specific lines, and those are most useful where you can see and edit them. A stagereg.trackers package or a stagereg[tensorboard] extra would turn each of those few lines into a dependency to pin and support. TrackerEventSink imports only core modules, so stagereg depends on no tracker by construction.

The cost is that a tracker the repository has no example for starts from the TensorBoard one. That file is kept small and self-contained so it is easy to rewrite. The decision is recorded in ADR 0005, and ADR 0006 amends it.

The naming scheme

Every value a tracker receives is recorded under a name that the rules below determine, so you can predict each tag before you open the tracker. The scheme lives in one module, stagereg.core.naming, and no other code joins or splits these strings.

Metric names are bare

A metric name is the one name a metric has, chosen by whatever computes it: bare snake_case such as loss, rmse or point_crps. No layer that passes the metric along adds to it. The training loss and the validation loss are both loss, in every stage. An early stopper names the metric it monitors the same way, and picks the split with its source argument:

from stagereg.core import EarlyStopper, Split

stopper = EarlyStopper(metric="loss", mode="min", patience=5, source=Split.TRAIN)
print(stopper.monitored_metric_name())
# loss

Metric tags are built where the run is observed

A metric tag is the namespaced identity of a recorded metric, stage/split/metric. It is built only where a run is observed, by metric_tag, from the event's stage, the event's split and the bare name. / is the only separator, and no segment may be empty or contain it, so parse_metric_tag always recovers exactly the parts the tag was built from:

from stagereg.core import Split, metric_tag, parse_metric_tag

tag = metric_tag("mean", Split.VALIDATION, "loss")
print(tag)
# mean/validation/loss
print(parse_metric_tag(tag))
# MetricTag(stage='mean', split=<Split.VALIDATION: 'validation'>, metric='loss')

try:
    metric_tag("mean", Split.TRAIN, "nll/sum")
except ValueError as error:
    print(error)
# metric 'nll/sum' may not contain '/'.

The split is data, not guesswork. Each event carries the Split its metrics were measured on, train or validation, set by the runner, which is the only party that knows it. Neither the event's name nor the metric's name is used to infer it.

A run's own history uses the same strings. state.metric_history is keyed by metric tag, so a history key and a tracker tag for the same series are equal:

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

from stagereg.core.types import Batch
from stagereg.jax import create_optimizer, initialize_training_state, run_supervised


def squared_error(model, inputs, targets, sample_weight, key, training):
    return jnp.mean(jnp.square(model(inputs) - targets))


def loader(*, split, epoch):
    inputs = jnp.linspace(-1.0, 1.0, 8).reshape(-1, 1)
    return [Batch(inputs=inputs, targets=2.0 * inputs)]


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))
run_supervised(
    model=model,
    optimizer=optimizer,
    train_loader=loader,
    loss=squared_error,
    state=state,
    epochs=2,
)
print(list(state.metric_history))
# ['supervised/train/loss']

The stage segment is always present

The stage segment, the first part of a tag, is never dropped, even when the run has only one stage. run_supervised names its stage supervised unless you pass stage=, so the run above records supervised/train/loss and not train/loss.

The reason is that each stage counts its own steps from zero. In a two-step run, MeanStage and GammaVarianceStage both report a training loss from epoch 0 onwards. Under the tags mean/train/loss and variance/train/loss they are two curves. Without the stage segment both would write to train/loss at the same steps, and the variance stage's curve would overwrite the mean stage's. Keeping the segment in single-stage runs too means a script's tags do not change when it gains a second stage, and a tag always has exactly three parts.

Parameter paths name hyperparameters

A parameter path is the identity of a recorded hyperparameter: its key path through a nested parameter mapping, joined with the same /. A tracker receives the nested mapping in log_params and, if it stores flat names, flattens it with flatten_parameters. Each leaf key is a bare snake_case name, and a split is a level of nesting of its own, never part of a leaf key: data/train/samples, not data/train_samples.

from stagereg.core import flatten_parameters

parameters = {
    "optimizer": {"name": "adam", "learning_rate": 1e-3},
    "data": {
        "train": {"samples": 256, "batch_size": 32},
        "validation": {"samples": 64, "batch_size": 64},
    },
}
for path, value in flatten_parameters(parameters).items():
    print(path, value)
# optimizer/name adam
# optimizer/learning_rate 0.001
# data/train/samples 256
# data/train/batch_size 32
# data/validation/samples 64
# data/validation/batch_size 64

A key that is empty or contains / raises ValueError, so two different key paths can never collapse onto one parameter path. Converting values the tracker cannot store, such as a tuple for TensorBoard's HParams, stays in the tracker.

Decision events carry no metrics

A decision event reports a judgement about a measurement that was already emitted: best_model when the early stopper sees an improvement, early_stop when it gives up. Its metrics mapping is empty. The measurement it judged travels in its decision field as a decision, the monitored metric's bare name and its value at that step, and its split is the split that metric was measured on.

So TrackerEventSink logs nothing for a decision event. On an improving epoch, the validation loss reaches the tracker once, from validation_end, and not a second time from best_model at the same step. To record decisions as well, write a sink of your own that reads event.decision:

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

from stagereg.core import EarlyStopper, Split
from stagereg.core.types import Batch
from stagereg.jax import create_optimizer, initialize_training_state, run_supervised


class DecisionPrinter:
    """An event sink that prints decision events and ignores the rest."""

    def on_event(self, event):
        if event.decision is not None:
            print(event.name, event.step, event.split, event.metrics, event.decision.metric)


def squared_error(model, inputs, targets, sample_weight, key, training):
    return jnp.mean(jnp.square(model(inputs) - targets))


def loader(*, split, epoch):
    inputs = jnp.linspace(-1.0, 1.0, 8).reshape(-1, 1)
    return [Batch(inputs=inputs, targets=2.0 * inputs)]


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))
run_supervised(
    model=model,
    optimizer=optimizer,
    train_loader=loader,
    loss=squared_error,
    state=state,
    epochs=2,
    early_stopper=EarlyStopper(metric="loss", mode="min", patience=0, source=Split.TRAIN),
    event_sinks=[DecisionPrinter()],
)
# best_model 0 train {} loss
# best_model 1 train {} loss

The design decisions behind this section are recorded in ADR 0006.