3. Concepts¶
This document explains the design decisions behind NNx: the architecture and the foundational patterns every other feature builds on. It then covers the specialization subpackages and the decoder-only LM path on top:
- Tier-1 (§7–§11):
finetune,peft,diffusion,paradigms,trainer. - Tier-2 (§12, §15–§16):
quantize,prune,surgery,embeddings,interop,viz,generation. - The inference branch:
decisions(§17). - Evaluation and experiment tooling (§18–§29). It covers calibration, abstention, experiment plans, streaming prediction and run bundles. It also covers multi-seed comparison, ranking, graph and link tasks,
Resultvalues, compilation and benchmarks, and budgeted search.
Sections are ordered from most fundamental to most specialized. Read top-to-bottom on a first pass; jump by anchor for reference.
1. Architecture¶
The architecture diagram summarizes NNx's public surface, internal orchestration, callback bus and on-disk persistence. The main README and the Architecture page embed it too:
The diagram has eight layers, top-to-bottom:
- User code + PyTorch (slate) — the consumer surface.
NNModel/Trainer(cyan) — the two public entry classes.train_step_fn/eval_step_fn/trainer_step_fn(orange bus) — the training-loop extension hooks.- Specialization subpackages (amber) —
nnx.diffusion,nnx.paradigms,nnx.quantize, andnnx.embeddingsprovide hook-compatible factories. The diffusion, paradigm and embeddings steps share their finalization innnx._step_helpers. Other subpackages compose around the loop without claiming a hook relationship. These are the model transforms (nnx.finetune,nnx.peft,nnx.prune,nnx.surgery), exchange formats (nnx.interop), inference utilities (nnx.generation) and diagnostics (nnx.viz). -
Training-loop internals (emerald) — the epoch × batch dispatch and the inline NaN guard + grad-clip in
default_train_step. They also include_step_schedulerand_save_checkpoints(FIRST/Q1/Q2/Q3/LAST/BEST cadence)._step_schedulersteps an epoch-clock scheduler once per epoch. Anoptimizer_update-clock scheduler is stepped by its clock after each committed update (§2.5).Note: the shared
finalize_stephelper lives under the Specialization subpackages layer (Layer 4, innnx._step_helpers). Only paradigm, diffusion and embeddings step-fn factories invoke it — not the supervised loop, which has its own inline NaN+clip path. -
Callback bus (orange) —
on_train_begin / on_epoch_begin / on_epoch_end / on_train_end, pluson_optimizer_updatein objective runs (§5). - Callback listeners (orange) —
EarlyStopping,LRMonitor,ModelCheckpoint,TensorBoardCallback,WandbCallback. - Persistence (violet) —
NNRunwritesrun.yaml + idps.csv + metadata.yamlandNNCheckpointwrites*.pt + *.opt.ptintoruns/<id>/.
2. Params as the source of truth¶
Every config in NNx is a frozen, kw-only, slotted dataclass with a state() / from_state() pair:
NNParams # network shape: dims, dropout, activation, n_heads
NNModelParams # device + loss + net kind + precision policy (FP32 / FP16 / BF16)
NNOptimParams # SGD / Adam + LR / momentum / grad clipping / accumulation / param_groups
NNSchedulerParams # ReduceLROnPlateau / Step / Cosine / OneCycle / LinearWarmup
NNTrainParams # epochs + loaders + optim + scheduler + seed + ...
NNTrainerParams # multi-optim version: dict of optims + dict of schedulers
2.1. The round-trip contract¶
ParamsClass.from_state(obj.state()).state() == obj.state() for every params class, so the rebuilt object has the same run id. This is the persistence contract: anything that survives NNRun.save → run.yaml → NNRun.load flows through it.
The rebuilt object need not equal the original. Runtime-only fields are not carried: loaders, extra_metrics and resume_mode, for example. Nor are omitted defaults, such as an activations list that repeats the net-wide activation. A resume request comes back as parent_run_id lineage (§8.4).
2.2. The omit-when-default invariant¶
New fields added to an existing params class must omit themselves from state() when at their default. Otherwise every existing run.id (md5 of state()) shifts and on-disk runs become unfindable. Every params class follows the pattern. Regression tests pin it per field:
mixed_precision/precision/kind/trainer:tests/test_params_round_trip.py.param_groups:tests/test_finetune_param_groups.py.seed:tests/test_trainer_params.pyandtests/test_pass2_v_series.py.save_phase_checkpoints:tests/test_trainer_params.pyandtests/test_pass2_ou_series.py.schedulers:tests/test_trainer_params.py.
2.3. Finite numeric domains¶
Every public real-valued hyperparameter is validated in __post_init__ (or the direct constructor). It is checked as a finite real number first and against its domain second. NaN and ±infinity are rejected, and so are booleans and numeric strings, which are never coerced. The ValueError names the field and its accepted domain, and it is raised before any optimizer, run directory or RoPE table exists. The inventory:
NNOptimParams.max_lrandweight_decay≥ 0 (zero is meaningful: frozen updates / no decay).eps> 0.grad_clip_norm> 0 when set (Nonedisables). SGDmomentum≥ 0, Adam betas in[0, 1).NNParamGroupSpec.lr/lr_multiplier> 0 (freeze a group withnnx.finetune.freeze, notlr=0) and groupweight_decay≥ 0. The resolved per-group learning rate must also be finite before the optimizer is built.NNSchedulerParams.min_lr/threshold≥ 0,factor> 0 (and < 1 for a plateau scheduler), optionalmax_lr> 0.NNTransformerParams.rope_base> 0 (also enforced by a directRoPE(base=...)).RMSNorm(eps)finite and ≥ 0;EarlyStopping(min_delta)finite and ≥ 0; LoRA / DoRAalphafinite and > 0.
Probability fields keep their existing [0, 1] domains, and integer counts are validated separately. Validation never touches state(), so a valid configuration hashes to the same run identity as before.
Integer counts get the same treatment through a sibling validator. These fields must be non-boolean integers:
NNParams.input_dim/output_dim/ everyhidden_dimsentry /n_heads(when set).NNTransformerParams.vocab_size/n_layers/d_model/max_seq_len/ffn_mult.NNConvParams.conv_channelsentries /in_channels/kernel_size/stride/pool_size.NNViTParams.image_size/patch_size/in_channels/d_model/n_layers/ffn_mult.NNMoEParams.num_experts(≥ 2) /top_k(1 …num_experts).NNOptimParams.accumulate_grad_batches(≥ 1);NNTrainParams.n_epochs/NNTrainerParams.n_epochs(≥ 1).NNSchedulerParams.step_size/T_max/total_steps/warmup_steps(> 0 when set;Nonestays the variant sentinel).- Direct
RoPE(dim, max_seq_len).
Any numbers.Integral is accepted, NumPy integers included. It is normalized to a plain int before immutable lists, _dims, modulo / isqrt / range arithmetic or state() see it. So np.int64(2) serializes and hashes exactly like 2. Booleans (True == 1 is not a count), every float (2.0 is not rounded), numeric strings and NaN/±infinity raise a ValueError naming the field.
Zero keeps its distinct meaning per field. NNConvParams.padding, NNSchedulerParams.patience / cooldown and EarlyStopping(patience) accept 0 (no padding; react on the first non-improving epoch). Epochs, accumulation and dimensions require ≥ 1.
A transformer additionally needs d_model % n_heads == 0 and an even head width, because RoPE rotates channel pairs. This is enforced at construction and in .builder().layers(...) before the divisibility arithmetic. So the builder and the direct constructor reject the same inputs.
Dataset batch_sizes tuples follow the same count rule, with None as the only automatic full-split sentinel. Zero never means "full split" or "no split". They are validated before any dataset factory or tokenizer runs (see Quickstart → Batch sizes).
2.4. Variant-gated construction via .builder()¶
Params dataclasses with tagged-union shape expose a .builder() classmethod as an alternative to the direct kwarg constructor. Tagged-union shape means a kind field whose value gates which other fields are meaningful. The Builder methods are named after the variants. Each writes exactly the fields its variant uses, so the user can't construct an invalid combination by accident.
NNSchedulerParams.builder() has five variant methods:
reduce_on_plateau, step, cosine_annealing, one_cycle and
linear_warmup_decay. Each sets the variant's knobs and leaves the rest at
the dataclass defaults. Each also sets kind, except reduce_on_plateau,
which leaves it None so legacy run ids stay unchanged. State() and
from_state() round-trip unchanged, and the direct-kwarg ctor still works.
from nnx import NNSchedulerParams
scheduler = NNSchedulerParams.builder().one_cycle(
max_lr=1e-3, total_steps=10_000,
min_lr=1e-7, factor=0.5, patience=10, cooldown=2, threshold=1e-3,
).build()
NNOptimParams.builder() extends the pattern with five optimizer-variant methods:
adam, adam_amsgrad, adamw, sgd and sgd_nesterov. It adds three optional chained
modifiers: grad_clip(norm), accumulate_grad(batches) and param_groups(specs).
The Adam-family variants take the PyTorch-native betas: tuple[float, float] kwarg.
The Builder maps it onto the underlying NNOptimParams.momentum field; the field name
stays momentum for on-disk back-compat. They also take an optional
eps, which is torch's 1e-8 when omitted and then left out of state(). SGD
variants keep the float momentum= kwarg and reject a non-default eps.
adamw defaults weight_decay to torch's 1e-2; see §3.1 for how its decay
differs from Adam's.
from nnx import NNOptimParams
# Adam with PyTorch-native spelling
opt = NNOptimParams.builder().adam(max_lr=1e-3, betas=(0.9, 0.999), weight_decay=0.0).build()
# SGD with grad-clip and gradient accumulation
opt = (
NNOptimParams.builder()
.sgd(max_lr=1e-2, momentum=0.9, weight_decay=5e-5)
.grad_clip(1.0)
.accumulate_grad(4)
.build()
)
Three more Builders follow the same pattern:
NNTransformerParams.builder()— LM-path config. Six fluent methods (vocab,layers,ffn,context,dropout,tied_embeddings) hide the dead parent-NNParams kwargs (hidden_dims/activation/dropout_prob) the transformer net doesn't read..layers(n=..., heads=..., d_model=...)(keyword-only) enforcesd_model % heads == 0at call-time. Full walkthrough indocs/lm.md.NNTrainerParams.builder()— composite, wraps the optimizer and scheduler Builders..optimizer(name, NNOptimParams)+.scheduler(name, NNSchedulerParams)register entries under user-chosen names..build()enforcesschedulers.keys() ⊆ optims.keys()with an actionable error. Used for the GAN-recipe pattern in §8.LogitsChain.builder()— LM-decoding power-user surface. Chain.repetition_penalty(p)/.top_k(k)/.top_p(p)/.temperature(t)/.custom(processor)in any order..build()sorts the standard processors into NNx's canonical order and appends custom processors after them. The order isRepetitionPenalty → TopKFilter → TopPFilter → TemperatureScaling, with temperature deliberately last (seedocs/lm.md§5). Pass the result viaGenerativeNNModel.generate(logits_chain=...). Full walkthrough indocs/lm.md.
Branching a builder (copy() / from_params()). The four params builders above (optimizer, scheduler, transformer, trainer) are mutable. Without branching, a shared base would be retyped per variant. builder.copy() branches a complete or partial builder. Afterwards, setters on either one never affect the other, and neither touches a value already built.
Builder.from_params(params) goes the other way: it pre-loads a builder from an existing params value. Then from_params(params).build() == params, with the same state() (key order, omitted defaults, hence the same run id). The result can be varied like any hand-written chain.
base = NNOptimParams.builder().grad_clip(1.0) # partial: no variant yet
adamw = base.copy().adamw(max_lr=1e-3).build()
sgd = base.copy().sgd(max_lr=0.05).build() # base itself is unchanged
trainer = NNTrainerParams.builder().n_epochs(3).train_loader(loader).optimizer("head", adamw)
plain = trainer.copy().data_id("split-a").build()
scheduled = trainer.copy().data_id("split-b").scheduler("head", plateau).build()
# .context() sets max_seq_len AND rope_base: pass a carried rope_base again to keep it
longer = NNTransformerParamsBuilder.from_params(lm_params).context(max_seq_len=2048, rope_base=lm_params.rope_base).build()
Ownership follows the built values. Configuration containers are copied one level deep: the param_groups list, the per-layer lists, the optims / schedulers maps and the extra_metrics mapping. The immutable params values, loaders (any iterable), metric callables and registered-factory specs are shared by identity. Neither method iterates data, builds a model, advances an RNG or does I/O. Validation still happens at build(), per branch; a branch that schedules a name absent from its optims fails there.
Setter semantics are the same on a branch. Setters mutate and return the same builder, the last variant wins, and modifiers survive a variant change. .vocab(size) still resets input_dim / output_dim, and a group setter still replaces its whole group. .context() resets an omitted rope_base and .dropout() an omitted rate, so pass a carried value again to keep it.
from_params accepts exactly its own params class. A subclass or foreign value raises TypeError naming the type, and a field the builder cannot carry raises ValueError. An NNOptimFactoryParams has no builder; pass it straight to NNTrainerParamsBuilder.optimizer. NNTransformerParamsBuilder.from_params also carries the inherited NNParams fields no setter exposes (hidden_dims, activation(s), dropout_prob(s), custom dims). NNTrainerParamsBuilder has data_id(...) / overwrite_existing(...) setters, so sibling branches over different data get distinct run ids. Runnable walkthrough: examples/builder_branching.py.
2.5. Scheduler clocks¶
NNSchedulerParams.clock says what one scheduler step counts.
"epoch"is the default. It steps once per completed epoch and is omitted fromstate(), so every existing run id is unchanged."optimizer_update"steps once per committed update of the scheduler's optimizer. It never steps per microbatch, per all-masked window or per skipped AMP step. In the builder, call.clock("optimizer_update")before or after the variant; a variant call keeps the clock, and so does a loaded builder.
On the update clock, step_size, T_max, total_steps and warmup_steps count updates. For example, two epochs of five microbatches with accumulate_grad_batches=2 form windows of 2 + 2 + 1 microbatches. That is three updates per epoch, six in all.
Updates come from an explicit source:
- the update engine, for an objective;
default_train_step, after each optimizer step it takes;finalize_step, so every built-in paradigm step;- a custom step calling
ctx.report_update()(NNModel.train, no name) orctx.report_update(name)(Trainer).
NNx never infers updates around an opaque step, and it warns when an epoch reports none. Whoever calls optimizer.step() reports it, and every report counts. default_train_step and finalize_step report their own steps, so a step function that delegates to them does not report again. default_train_step does not report a step the AMP scaler skipped, fused optimizers included.
Budgets. NNx may own the windows (the default step, passed explicitly or not, or an objective) while the loader has a length. Then the default horizon is the planned updates, n_epochs × ceil(batches / accumulate_grad_batches). Otherwise the budget (total_steps, T_max or step_size) must be explicit.
A one-cycle / warmup-decay schedule stops the run with an error at the first committed update past its total_steps. That update is never scheduled, and no checkpoint is written after it. This applies to a default budget too. So a loader that yields more batches than its len() reports needs an explicit total_steps; an IterableDataset read by several workers can do this.
A cosine schedule warns once when it passes its T_max, since its rate then climbs back up. It does not warn when that is the period the run chose: an explicit T_max, or a subclass's own. Torch lets a schedule run past it on purpose. A stateful resume keeps the checkpoint's T_max, so set it in the run that starts the schedule.
The warm-up multiplier keeps its (step + 1) / warmup_steps form. With a base LR of 0.12, warmup_steps=3 and total_steps=8, the first four updates train at 0.04, 0.08, 0.12, 0.12.
A plateau scheduler reads a monitored metric at the epoch boundary and refuses the update clock. So does a scheduler without a last_epoch position, such as torch's ChainedScheduler from a _build_scheduler override.
Checkpointing. Each update-clock scheduler is checkpointed as a required component (nnx.scheduler_clock, or nnx.scheduler_clock.<name> per Trainer optimizer). The component holds its clock, owner and budget, beside the scheduler's own state and position. A stateful resume restores them before anything steps, which reproduces the uninterrupted run's LR sequence.
The resume refuses a mismatched clock or budget, or a budget the resumed updates would pass. A default budget does not resume: see the shared horizon in §14.2. It also refuses a checkpoint without component state, whose scheduler position counts epochs. Runnable walkthrough: examples/scheduler_clocks.py.
3. Enums-as-factories¶
Every enum's __call__ constructs the underlying object:
Optims.ADAM(net=net, lr_start=1e-3, ...) # → torch.optim.Adam
Losses.CROSS_ENTROPY() # → nn.CrossEntropyLoss
Nets.GRAPH_CONV(params=NNParams(...)) # → GraphConvNN
Nets.VIT(params=NNViTParams(...)) # → ViTNN (rebuilt from a stored run)
Schedulers.COSINE_ANNEALING(opt, params, n_epochs) # → CosineAnnealingLR
NoiseSchedulers.LINEAR(T=1000, beta_min=1e-4, beta_max=2e-2) # → NoiseSchedule
Adding a new option extends the enum and its match block. An Optims member also joins its optimizer family (NNOptimParams) and gains a builder method. A Nets member with its own params class also joins the params map and NNParams.resolve_from_state.
3.1. Optimizers: AdamW, registered factories and the shared build hook¶
Adam vs AdamW decay. Optims.ADAM / ADAM_AMSGRAD apply weight_decay as a coupled L2 term. The term is added to the gradient and then rescaled by Adam's adaptive denominator, so heavily-updated weights are barely decayed. Optims.ADAMW builds torch.optim.AdamW, which decays decoupled. Each step first scales every weight by 1 - lr * weight_decay and then applies the Adam update.
With lr=0.1, weight_decay=0.2, betas=(0, 0) and a zero gradient, a weight of 2.0 becomes 1.96 under AdamW but 1.9 under Adam. Per-group NNParamGroupSpec overrides apply to both exactly as for the other variants.
Registered optimizer factories. An optimizer NNx does not ship joins a run through nnx.optimizers without borrowing a built-in name:
from nnx import NNOptimFactoryParams, OptimizerFactorySpec, register_optimizer_factory
def lion(param_groups, config): # resolved groups + read-only JSON-like config
return MyLion(param_groups, betas=tuple(config["betas"]))
register_optimizer_factory("acme.lion", 1, lion)
optim = NNOptimFactoryParams(
factory=OptimizerFactorySpec(id="acme.lion", version=1, config={"betas": [0.9, 0.99]}),
max_lr=3e-4, weight_decay=0.1,
)
The factory lifecycle:
- Resolve —
train()looks the spec's(id, version)up in the process-local registry before any run directory exists. An unknown id or version fails up front; resolution never imports a module or evaluates a string. -
Construct — the factory is called once per optimizer with two arguments. The first is the resolved parameter groups, each carrying explicit
params,lrandweight_decay. The second is the spec's read-onlyconfig(lists arrive as tuples, NumPy scalars as plain numbers).The groups are exactly what a built-in optimizer would receive: every parameter in one group when
param_groupsisNone. Otherwise they are theNNParamGroupSpecbuckets, with frozen parameters dropped and the Trainer's strict ownership applied.The factory must return a
torch.optim.Optimizerholding exactly those parameters. A missing, duplicated or foreign parameter, or any other return type, raises before the first epoch. Construction happens before the run is reserved, so keep factories side-effect-free constructors. A factory may be called for a run that is then refused, for example an existing run withoutoverwrite_existing. -
Persist —
run.yamlstores only the spec (factory: {id, version, config}), never the callable. A lambda or other callable passed as the factory or insideconfigis rejected at construction. Thefactoryentry is also howNNRun.loadtells a registered variant from a built-inNNOptimParams. Built-in configurations serialize exactly as before, so their run ids are unchanged.
Metadata reload vs state resume. Reloading a run's metadata needs nothing registered and runs no factory code. This covers NNRun.load(id), str(run) and the notebook view. The optimizer is shown by its id@vN, never with fabricated name / momentum fields.
Warm resume (resume_from_run_id, §14) rebuilds the optimizer, so the same (id, version) must be registered. The checkpoint's recorded factory identity (id, version and config) must match the new configuration exactly. So must its ordered named-parameter topology. Switching between a built-in and a factory, or changing any of those, raises. Bump version whenever a factory's behaviour changes.
One build hook. NNModel.train() and Trainer.train() both construct optimizers through nnx.build_optimizer(net, optim_params, strict_param_groups=...). It is non-strict for NNModel (unmatched trainable parameters join a default group) and strict for the Trainer (§8.1). Both build before the run is reserved, for built-ins and factories alike. examples/optimizer_factories.py runs a Trainer update with a registered factory and a built-in AdamW over disjoint named groups. It then reloads the run offline.
Built-in nets return raw logits; predict().logits is always that raw output. Native torch.nn.NLLLoss (Losses.NEGATIVE_LOG_LIKELIHOOD) requires log-probabilities. So some NNx-owned paths apply log_softmax over the class axis internally before calling the exact native loss. These are the supervised loop, evaluate() and the NNx-owned classifier step factories (KD, feature-KD, MoE, Mixup, CutMix, and Born-Again through KD). The reported loss is the normalized NLL. The backpropagated gradient carries the competing-class term, matching cross-entropy from the same weights.
The loss object's class weights, ignore_index and reduction remain authoritative. Any other loss module receives the raw output unchanged and owns its own normalization. That includes an NLLLoss subclass with its own forward, a custom train_step_fn, or the standalone lr_finder callable.
Probability-aware prediction. predict() and PredictResult stay logits + classes. For a model with a task (§6.3), classes holds the task's decoded values. NNModel.predict_proba(X, spec) is the opt-in alternative. spec may be omitted when the model declares a task. A regression task returns probabilities=None, spec=None with its continuous values in decoded.
A ProbabilitySpec(kind, class_axis, labels) declares the task rather than inferring it from the loss or the output shape. The kinds are:
"categorical": softmax overclass_axis, rows sum to 1, argmax decoding."bernoulli": independent element-wise sigmoid, rows not normalized,logit >= 0indicators.
The returned PredictionResult carries the same raw logits as predict(), probabilities, decoded values and the spec (class axis and ordered labels). It also carries sample_ids:
- the input row index for arrays and tensors;
- the iteration position for an ordinary loader (a shuffling loader warns);
- the global node index for graph seed rows;
- the graph id for a graph classifier's rows (§25).
It runs through the same inference path as predict(): the same inputs, graph seed-row slicing, no_grad, and non-destructive mode handling (§13.2). It never touches parameters or gradients. class_indices exists only for categorical results. So Bernoulli indicators cannot be fed to class-index consumers such as VisUtils.confusion_matrix by mistake. prediction_from_logits(logits, spec) exposes the same computation for logits obtained elsewhere.
3.2. Arbitrary modules and registered model factories¶
NNModelParams.net is not limited to the Nets enum. It holds one of three descriptors (nnx.models), and every artifact records the descriptor, never code:
| Descriptor | Construction | net_params |
Rebuildable from artifacts |
|---|---|---|---|
Nets member (default) |
Nets.X(params=net_params) |
NNParams |
yes |
ModelSpec(id, version, config, seed) |
the registered factory, factory(config) |
None |
yes, through the registry |
RuntimeModule (filled in by NNModel(module=...)) |
the caller's instance, as is | None |
no — reconstructible=False |
from nnx import KeywordInputs, Losses, ModelSpec, NNModel, NNModelParams, register_model_factory
register_model_factory("acme.encoder", 1, lambda config: Encoder(**config)) # factory(config) -> nn.Module
model = NNModel(params=NNModelParams(net=ModelSpec("acme.encoder", 1, {"width": 64}, seed=0), loss=Losses.CROSS_ENTROPY))
wrapped = NNModel(module=my_encoder, params=NNModelParams(loss=Losses.CROSS_ENTROPY),
batch_adapter=KeywordInputs(("input_ids", "attention_mask"), target="labels"))
assert wrapped.net is my_encoder # never cloned or re-initialized
Registered factories. A ModelSpec references a factory registered for this process under (id, version), exactly like optimizer factories (§3.1). Its config must be JSON-like, and bumping version is how a changed architecture is told apart. Construction resolves the factory first. An unknown id or version raises MissingModelFactoryError naming the registered versions before anything runs.
Construction then calls the factory with Python's random, NumPy and torch all seeded from seed. The ambient RNG states are captured and restored. So the same spec always initializes the same weights and never shifts the caller's random streams. The low-rank-surgery reconstruction guard compares against the layout recorded when the factory built the module, so train() never builds it twice.
NNModel.from_checkpoint, NNModel.from_pretrained and the checkpoint t-SNE rebuild the module through the registry. They compare its parameter names and shapes with the saved weights before loading any of them. A factory that now builds another topology fails with ValueError. Built-in nets are rebuilt from their own params instead; a mismatch there surfaces as the RuntimeError of load_state_dict.
Runtime modules. NNModel(module=m, params=NNModelParams(...)) wraps the instance itself: model.net is m, and no tensor or RNG state changes. It fills params.net with a RuntimeModule descriptor. The descriptor holds the class's qualified name (module="pkg.Class") and a topology fingerprint of the parameter names, shapes and dtypes and the module's repr. So identical architectures share a run id and different ones do not, layer hyperparameters such as dropout rates included.
As with built-in nets, the id reflects the architecture, not the weights. Give differently-initialized instances of one architecture distinct data_id / salt values. Lazy layers (nn.LazyLinear) may be wrapped and trained uninitialized. Materialize them (one forward) before a warm resume, whose parameter-topology check needs shapes.
A runtime module trains, evaluates, predicts, checkpoints and resumes like any model, but no factory can rebuild it:
- checkpoints mark it
reconstructible=False(NNCheckpoint.reconstructible); - reloading needs the module again (
NNModel.from_checkpoint(ckpt, module=fresh_instance), whose topology must match); - portable saves (a safetensors checkpoint or
save_pretrained) fail withMissingModelFactoryErrorbefore any file or directory is written; - a transformation recipe (
nnx.transforms) is refused on it for the same reason.
Batch adapters. Built-in nets keep their own unpack_batch. Any other module sees batches through a runtime-only batch_adapter, never serialized and never part of the run id:
PositionalInputs(n_inputs=1)callsmodule(x1, ..., xn)on(x1, ..., xn, y)batches.KeywordInputs(inputs, target="labels")callsmodule(**{name: batch[name]})on mapping batches.- A module that defines
unpack_batchkeeps using it (the default).
predict() accepts a mapping of keyword inputs as well as tensors, tuples and loaders. Some supervised paradigm steps transform the input: Mixup / CutMix, KD and feature KD, MoE, and kd_objective. They split batches through the same adapter and need exactly one positional input. Other layouts raise ValueError and need a custom step.
Subclass BatchAdapter for other layouts. Implement split(batch) -> (args, kwargs, target), and output(raw) to extract the tensor from a non-tensor return value (the default requires a tensor). nnx.viz.summary(model, batch=...) and VisUtils.two_dim_tsne_checkpoint_logits(..., batch_adapter=..., module=...) take the same adapters. A keyword-input model given a synthetic input_size, or a runtime checkpoint without module=, fails before the module or any data is touched. A borrowed module keeps its identity, state keys and every submodule's training mode.
What renders. A registered or runtime run has no built-in NNParams. run.yaml omits net, and model.net holds the descriptor mapping (kind: registered | runtime). str(run) and the notebook table show the descriptor instead of dims, dropout and activation. The descriptor fields are kind, factory (id@vN), config and seed, or module and reconstructible=False. NNRun.load restores the same descriptor.
Built-in runs serialize exactly as before. to_onnx exports any tensor-output module whose forward takes positional tensors, from its example_input. It refuses a mapping example (a keyword-input module): wrap such a module in one with a positional forward. Task adapters (§6.3) check the loss up front and the output width on the first batch. GenerativeNNModel stays built-in only. examples/custom_module.py trains, reconstructs and prints a registered encoder and a wrapped keyword-input module. examples/04_onnx_export.py::registered_module_variant exports one.
4. What lands on disk¶
Every model.train(params) creates a run directory under runs/<id>/. The id is the md5 of the pre-id {model[, net], train[, trainer][, salt][, transforms]} partial dict, where transforms is a recorded nnx.transforms recipe:
runs/<id>/
├── run.yaml # NNRun.state() — config-only, hashes to <id>
├── metadata.yaml # env snapshot (nnx/torch/python/git) — NOT in hash
├── idps.csv # per-iteration metrics, flushed every epoch
│ # (history/ instead with a HistoryJournal — §4.5)
├── .history-committed-by-last # history protocol marker: LAST commits the history
├── .run.lock # NNRun.save's lock
├── checkpoints/
│ ├── first.pt # NNCheckpoint at epoch 0
│ ├── q1.pt q2.pt q3.pt # at 1/4, 2/4, 3/4 of n_epochs (every quartile needs n_epochs >= 8)
│ ├── last.pt # most recent committed epoch
│ ├── last.pt.manifest.json # its SHA-256 manifest (§14.3)
│ ├── last.pt.opt.<generation>.pt # immutable matching training state
│ ├── last.pt.opt.pt # compatibility copy of the current bundle
│ ├── last.pt.lock # serializes this checkpoint's writers
│ └── best.pt # lowest-error checkpoint so far
Every checkpoint tag <tag>.pt (first, q1–q3, last, best) has its own .manifest.json and .lock. A checkpoint that carries training state also has .opt.<generation>.pt and .opt.pt.
The runs root holds shared locks too: runs/.admission.lock, runs/.best.lock and runs/.leases/<id>.lock. §4.1 describes their order.
Both NNModel.train() and Trainer.train() finish with one line such as Run saved to runs/5c741de80116c9858a7e2a56256cb917 (relative to the working directory). It is a display path, not artifact provenance. runs/<id> is relative to the working directory the run was saved from. No absolute prefix (home directory, worktree, temporary directory or container mount) is printed. So notebook output that captures the line is identical wherever it executes.
Artifacts are still written under <cwd>/runs/<id>/. Use os.path.abspath(f"runs/{run.id}") when you need the absolute location. Separators are always /, so the line reads the same on every platform.
4.1. The runs/best pointer¶
The runs/best symlink points at the lowest-error run across all runs in the directory. For paradigm runs whose steps don't produce a supervised error, it uses the lowest loss. In a runs root mixing both kinds, the comparison is between unlike metrics. That is an accepted trade-off against a best pointer paradigm runs could never claim. On Windows without developer mode, runs/best is a directory that holds a POINTER.txt file with the run's path.
Only a run with a committed BEST checkpoint can own the pointer. That means a real run directory with run.yaml, loadable history and a parseable BEST. Under the history protocol, the BEST epoch must also be no later than the committed LAST. best, .leases, bare reservations and malformed artifacts are never candidates.
Overwriting a run (overwrite_existing=True) deletes its artifacts and reuses its ID. If that run owned runs/best, the best surviving committed run is elected before the old artifacts are removed. Ties keep the incumbent, then the lexicographically smallest ID. So the pointer never dangles and an emptied replacement is never advertised. The replacement becomes a candidate again only once its own BEST commits; saving its history earlier cannot claim the slot.
When no eligible run remains, runs/best is absent. Readers should treat a missing pointer as "no winner yet", not as an error. Runs that select BEST with a named monitor (§6.4) are never candidates: their ranking is not comparable with other runs'. Lock order is lease → admission → .best.lock on overwrite and lease → run → .best.lock on save. Election reads other runs without taking their locks.
Both the per-run BEST checkpoint and the cross-run pointer score a checkpoint by the first finite value. The order is validation error → validation loss → training error → training loss. The training values come from the epoch's last batch record; declare a monitor (§6.4) to select on a whole-epoch training summary instead.
NaN and ±inf are never used as a comparison baseline. A non-finite validation error falls through to that same epoch's finite validation loss before training data is consulted. A checkpoint with no finite signal at all scores as unavailable (+inf), so it is replaced by the next finite candidate.
The same rule recovers legacy artifacts whose BEST was ranked on a NaN. The stale checkpoint payload is left untouched, and the next finite save takes the pointer.
4.2. Atomicity + incremental writes¶
Every write inside runs/<id>/ (run.yaml, metadata.yaml, idps.csv, every *.pt) goes through a destination-local tmp-then-rename helper. A KeyboardInterrupt leaves either the previous file or the new file at a destination, never a half-written file.
The text writer creates its temp next to the resolved destination, also for a bare filename, so the rename never crosses filesystems. It owns the temp until the rename. A failure while opening, writing, flushing or closing it, or in the rename itself, releases the descriptor and unlinks that temp. The writer then re-raises the original exception; a cleanup error never masks it.
An fsync error is tolerated, and after a successful rename the temp name is never touched.
This is per-file cleanup for ordinary exceptions, not a multi-file transaction. run.yaml, metadata.yaml and idps.csv are still written one after another. A SIGKILL or power loss can still strand a .<name>.XXXXXX temp, which later writes neither reuse nor delete. A failed write of the run's history marker also releases the still-empty runs/<id>/ reservation, so the same run can be retried.
Each checkpoint names an immutable generation-addressed training-state sidecar. The sidecar is committed first and the checkpoint last. An interrupted replacement therefore leaves the previous generation resumable instead of pairing new weights with stale optimizer state.
The epoch transaction is history → LAST → phase/BEST → deferred callback checkpoints. If LAST fails, history rolls back to the preceding epoch. Once LAST commits, a later ancillary failure retains that history because the epoch is durable.
NNRun.load() treats LAST as the commit marker and truncates any history newer than it after a process kill. It rejects an empty or corrupt LAST instead of erasing otherwise valid history. A run with a history journal (§4.5) follows the same transaction: its chunks and manifest are the "history" step.
Checking for saved state is observational. NNCheckpoint.load_training_state, load_optimizer_state and load_with_training_state handle a run whose directory does not exist. They return None / (None, None) without creating runs/<id>/; the run ID is still validated first. So probing a prospective run ID before the first fit never reserves it and never trips the overwrite guard.
Inside an existing run, the original checkpoint lock and generation validation apply unchanged. A checkpoint whose referenced training-state generation is missing or malformed is corruption and raises an actionable error. It is not a signal to start fresh. A probe can race a concurrent first creation and legitimately observe absence; retry if that matters.
The versioned sidecar restores:
- optimizer type and parameter topology/state;
- scheduler identity/state (a plateau state saved under another improvement rule than the run builds is refused before the model is touched — §6.4);
- the FP16 loss scaler and the run's resolved precision record. A stateful resume into another effective precision is refused before the model is touched. A weights-only warm start may switch (Quickstart §2.2);
- completed epoch, Python and NumPy state, PyTorch CPU/CUDA/MPS state;
- loader/sampler generators matched by stable seed identity.
The fixed .opt.pt compatibility copy and legacy optimizer-only sidecars remain readable. Exact continuation requires num_workers=0; worker-local RNG state is outside the recoverable boundary.
Resume accepts every batch source ordinary training accepts. These are a DataLoader, a plain re-iterable list of (X, Y) batches, or the one-element full-batch list NNGraphDataset(sampler="full") produces. Worker capability is read safely (absent metadata means no worker RNG). The worker warning only fires for a real loader with num_workers > 0. A consumed one-shot iterator cannot be replayed: only the supplied re-iterable source is restored.
A training loader may define set_epoch(epoch) (PyTorch's DistributedSampler convention). NNModel.train and Trainer.train alike tell it each epoch's index before iterating it. So per-epoch randomness drawn from that index is identical in an uninterrupted and a resumed run. nnx.link_tasks training negatives (§26) are one example.
NNCheckpoint also carries an ordered tuple of versioned topology-transform recipes. It is empty for ordinary and legacy checkpoints. A recipe can be materialized before training: nnx.transforms.TransformRecipe, for LoRA on explicit Linear targets or SVD low-rank replacement (Surgery §8). Such a recipe is recorded on the model and carried by every checkpoint tag, ModelCheckpoint snapshots and Hub saves. It is part of the run id of a run that trains it; runs without one keep theirs.
Replay rebuilds the recipe's topology on a fresh base without rerunning SVD. A resume needs the resuming model to have materialized the same recipe. A lifecycle callback that replaces modules after training can declare the recipe needed to reproduce that topology. NNModel.from_checkpoint() replays recognized transforms before loading weights. Both pickle and safetensors preserve this metadata. Unknown transforms fail with a compatibility error instead of partially loading the wrong network.
Checkpoint readers default to CPU placement and accept an explicit device for cross-device restores:
checkpoint = NNCheckpoint.from_file("runs/<id>/checkpoints/best.pt", map_location="cpu")
checkpoint = NNCheckpoint.load(run="<id>", type=Checkpoints.BEST, map_location="cuda:0")
Pickle checkpoints forward any torch.load-compatible map_location value.
Safetensors checkpoints accept a device string or torch.device. Callable or
mapping forms are rejected, because safetensors exposes device placement rather
than PyTorch's storage-remapping callback contract.
4.3. Config vs environment¶
run.yaml is the configuration; metadata.yaml is the environment. Two runs
with identical config but different environments share an ID by design. Use an
explicit salt when identical modeled inputs should produce separate experiment
records without changing the seed or data:
replicate = model.train(params=train_params, salt="replicate-2")
salt=None adds nothing to the ID hash, so every existing run ID stays exactly the same. A non-empty string is
stored with the run configuration and included in the ID hash, so save/load and
resume retain the distinction. Non-string salt values are rejected.
4.4. Provenance: fingerprint and attempts¶
A run has four separate identities, and none of them stands in for another:
| Identity | What it names | Where |
|---|---|---|
run.id |
the configuration (md5 of NNRun.state()) |
the runs/<id>/ directory name |
| experiment fingerprint | the declared intent — task, label order, model descriptor, data and split identities, objective version, config | runs/<id>/provenance.json |
| attempt id | one execution: a fresh id for every train() call |
runs/<id>/attempt.json |
| environment snapshot | where it ran (library versions, GPU, OS, git commit) | runs/<id>/metadata.yaml, rewritten on every save |
Provenance is opt-in (nnx.provenance) and never changes a run id:
from nnx.provenance import ExperimentManifest, compare, hash_file
plan = ExperimentManifest.for_model(model, train=train_params,
data={"train": hash_file("train.csv")}, # verified digest
splits={"val": "val-2024-06"}, # declared: recorded, not verified
objective={"id": "supervised", "version": 1})
run = model.train(params=train_params, provenance=plan)
run.provenance.fingerprint, run.provenance.attempt.attempt_id, run.provenance.attempt.status
- The manifest (
ExperimentManifest) serializes to canonical bytes, and its fingerprint is their SHA-256. The bytes are UTF-8 JSON with sorted keys, arrays in order and the format version (nnx.provenance/1) inside the hashed document. Changing the task, the label order, a split's digest or the objective's version changes the fingerprint. - Serializable values. Only JSON-like values serialize: non-finite
floats, sets, bytes, callables and other objects are rejected by path,
never hashed through their
repr. A manifest copies what it is given into immutable form, and equality follows the canonical bytes. ExperimentManifest.for_model(...)collects the model's task and labels (§6.3) and its model descriptor (§3.2). When given the training configuration, it records it withoutn_epochs,resume_epochsand the resume lineage, which describe an attempt. So a resume of the same plan keeps the fingerprint. It builds no model and reads no loader.- Identity references are
declared,digestorunknown. Adeclaredreference is an id you supply (data_id, a dataset name), recorded but never reported as verified. Adigestcomes from the explicithash_file/hash_bytescalls, the only ones that read data. ASplitManifestpassed undersplitsis recorded as its digest (§13.3). - Attempts. Every fit writes a fresh
attempt.json. It records the fingerprint andrunning→completed,failedorcancelled(KeyboardInterrupt). It also records the last committed checkpoint (tag, epoch and generation) and the error type and message. A failure to record a failed attempt is a warning; it never replaces the training error.Trainer.train(..., provenance=...)records attempts the same way. - Resumed attempts. A resume is a new attempt of the same plan. It links
its parent's attempt and checkpoint generation, and leaves the parent's
files untouched.
NNRun.savenever rewrites them, and both files are written atomically, so an interrupted write leaves the previous record. An attempt that fails before writing any run file releases its reservation. A failed first LAST commit keeps the run, with afailedattempt and no committed checkpoint. - Comparing.
compare(a, b)takes manifests, runs or run ids. It reads only the provenance files: no model is loaded, no loader iterated and no file hashed. It reports each field by a stable path (data.train,labels[1],config.lr). The status isequal,different,missing,unknown,declared(equal but unverified),verified(equal digests) orabsent(one side has no provenance). Values compare by their canonical bytes, so1and1.0differ exactly as their fingerprints do. - Comparison results.
verified_equalholds only when both sides have provenance and every field is equal or verified. A run without a manifest (run.provenance is None) is absent, never equal. Provenance files are verified on read (format and fingerprint). Unreadable ones are ignored with a warning, so they never stop a run from loading or resuming.
4.5. Bounded history: the history journal¶
By default both loops keep every per-batch NNIterationDataPoint in memory and rewrite the whole idps.csv each epoch. So memory and bytes written grow with the run. history=HistoryJournal(retention=..., chunk_size=...) switches one run to a bounded history journal. It lives in nnx.history, is accepted by NNModel.train and Trainer.train, and is never part of the run id:
from nnx.history import HistoryJournal, export_history_csv, iter_history
run = model.train(params, history=HistoryJournal(retention=1000, chunk_size=250))
len(run.idps) # ≤ 1000: the window, not the whole run
for record in iter_history(run.id): ... # the committed history, chunk by chunk
export_history_csv(run.id, "idps.csv") # the legacy CSV layout, on request
runs/<id>/history/
├── chunk-00000000.jsonl # immutable: up to chunk_size records, one JSON line each
├── index.jsonl # appended: one hash-chained line per chunk (counts, epochs, SHA-256)
├── epochs.jsonl # appended: one summary row per epoch (the notebook chart's series)
└── journal.json # small manifest (counts, byte lengths, index chain), replaced atomically
- Memory and bytes. Only the last
retentionrecords stay in memory (ctx.idps,NNRun.idps).chunk_size ≤ retention(defaultmin(250, retention), resolved asHistoryJournal.chunk), so records waiting for their chunk are always inside the window. Every record is written once, so bytes written grow linearly with the records;idps.csvis not written. - Commit protocol. Each epoch's records are written as chunks and indexed, and its summary row is appended. Every file the epoch wrote is flushed to disk once; chunks written mid-epoch are not fsynced one by one. The manifest is then published before LAST. The journal is the epoch transaction's "history" step (§4.2), and LAST stays the commit marker.
- Crashes. A crash while a chunk is written, or before the manifest is replaced, leaves the previous manifest. A crash before LAST is replaced leaves an uncommitted tail on disk that no reader shows. Records newer than LAST's epoch are filtered, as for
idps.csv. - Failures and corruption. If LAST fails, the previous manifest is republished. A committed chunk whose bytes no longer match its SHA-256, a missing chunk and an altered index raise
HistoryCorruptionError(aValueError). - Lazy reads.
NNRun.loadreads only the committed tail: the lastretentionrecords, from the chunks holding them. It returns a run whosehistoryis the journal directory. Other readers read no records. The notebook chart (_repr_html_) reads the per-epoch rows. BEST and theruns/bestelection read checkpoints and the manifest and index. A resume reads only the source's checkpoint. - Listing and saving.
NNRun.alllists CSV and journal runs side by side.save()never rewrites the journal;idpsis only its window. A journal run saved under another runs root is refused, because the copy would have no history. Useexport_history_csvto take the history elsewhere. - Built-in callbacks. The built-in callbacks read
ctx.idp(the epoch's last record) and are unaffected.LRMonitor.historyis bounded too, keeping the LRs of the lastretentionepochs;LRMonitor(bounded=False)keeps every epoch. - Full-history callbacks. A
Callbackthat needs every record declareshistory_access = "full". It receives the materialised history, read-only like the window, asctx.idpsaton_epoch_endandon_train_end. The history is read back from the journal once per dispatch (each epoch, and once at the end). It is released with the dispatch, so its cost grows with the run. If the final read-back fails, the callback gets the window, with a warning. - Window callbacks. Every other callback sees
ctx.idpsas a live, read-only sequence over the window. It grows with each record as the eager list does (trimmed toretention). It supportslen, indexing, iteration,reversedand slicing (a new list), but no in-place change. Item assignment raisesTypeError, andappendand the other list mutators do not exist. - Reassigning the window. The journal, LAST and
NNRun.idpskeep the records as recorded. A callback that reassignsctx.idpsis seen by the next. - Refused callbacks. A plain function callback (
callbacks=[lambda idps: ...]) by contract receives the full list. With a journal, such a callback or an unknownhistory_accessis refused with aValueErrorbefore any run is reserved. Without a journal,ctx.idpsis the plain running list. - Continuations. A resumed run owns its own run directory and chunk files and never writes to the source run, which stays byte-for-byte unchanged.
iter_history(run_id, lineage=True)andexport_history_csv(..., lineage=True)first yield the committed records of the run the child resumed from. They stop at the epoch the child resumed from, then yield the child's records, recursively. Each epoch appears once, never the parent's later epochs. - The branch epoch. The child's resume status records the epoch it resumed from (
ResumeStatus.source_epoch, inmetadata.yaml). For runs recorded without it, NNx uses the epoch of that checkpoint now. The cut also holds when the child resumed from an earlier checkpoint than the parent's last, or has no committed record yet. The cut never passes the child's first record. - Lineage fallbacks. The branch epoch may be unknowable, because the checkpoint or the parent's LAST is unreadable. Then the parent's records before the child's first record are used, with a
RuntimeWarning. The lineage starts at the child, with aRuntimeWarning, in three cases. Neither is known, the parent run is gone, or the child resumed through theruns/bestpointer (which may name another run by now). - Lineage follows resumes. A run that started fresh starts its own epochs, and its lineage is its own history. One example is a born-again generation naming its teacher's run as
parent_run_id. - Export.
export_history_csvwrites the same columns, order and indexNNRun.savewrites for an eager run. It holds the records in memory while it builds the table; streamiter_historyfor larger histories. - Migration.
migrate_history(run_id, spec=...)moves an existing run'sidps.csvinto a journal explicitly, keepingrun.yamland the run id. Floats are parsed round-trip. The journal is read back and checked before it is published and the CSV removed. An unpublished leftover of an interrupted migration is replaced, and one interrupted after publishing is finished. - Migration refusals.
migrate_historyrefuses a run being trained (its lease is held) and theruns/bestpointer (migrate a run by its own id). Unlessdiscard_uncommitted=True, it also refuses a CSV holding records past LAST's epoch, which readers hide but a migration would delete. Nothing is migrated or deleted by default.
Out of scope: concurrent writers to one run, mid-epoch recovery (an epoch is the unit of commit) and remote tracking. An epoch's records must be on disk when its LAST lands, so every epoch ends its own chunk. Full-batch training therefore writes one small chunk per epoch, and reading the tail opens up to retention of them.
examples/history_journal.py trains with a journal, resumes lazily, renders the summary and exports the CSV with and without the parent's prefix.
5. Callbacks¶
Callback has five hooks, each receiving a _CallbackContext: on_train_begin / on_epoch_begin / on_epoch_end / on_train_end, plus on_optimizer_update in objective runs (§6.5):
ctx.model # NNModel
ctx.run # NNRun (in-progress)
ctx.optimizer # torch.optim.Optimizer (primary, sorted-first in Trainer mode)
ctx.optimizers # dict[str, Optimizer] (Trainer mode only)
ctx.trainer # Trainer (Trainer mode only)
ctx.epoch # int
ctx.idp # current NNIterationDataPoint
ctx.idps # running list of all idps so far (with a HistoryJournal: the last `retention` — §4.5)
ctx.should_stop # writable — set True to break out of training
Built-in callbacks: EarlyStopping, LRMonitor, ModelCheckpoint, TensorBoardCallback, WandbCallback. Custom callbacks subclass Callback and override whichever hooks they need. Objective runs (§6.5) also call on_optimizer_update(ctx, event) once per committed optimizer update. Every optimizer's event of a commit fires before any optimizer_update-clock scheduler steps.
EarlyStopping monitors one of four keys: val_edp.error, val_edp.loss, train_edp.error, train_edp.loss (or the automatic default, see §6). mode only sets the improvement direction for that key; "min" (lower is better) is the natural reading of a loss or error. Accuracy / F1 string keys are not accepted; a declared accuracy metric is monitored through MonitorSpec (§6.4). For example: EarlyStopping(monitor="val_edp.loss", mode="min", patience=5).
LRMonitor.history keeps one primary-optimizer learning rate per epoch whatever the scheduler's clock. On the optimizer_update clock (§2.5) it also records LRMonitor.update_history. This is a separate trace of the steps NNx takes, as (update index, LR after that update's scheduler step) pairs. It stays empty under Trainer's auto_step_schedulers=False, where the step function steps the schedule.
EarlyStopping(monitor=MonitorSpec(...)) tracks a named monitor instead. That is a declared metric or loss / error on either split, with the rule BEST and plateau scheduling share (§6.4).
EarlyStopping is also a checkpointable component (§14.1) named early_stopping. When a run has several, they are numbered early_stopping.2, early_stopping.3, … in callback order. You can also give an explicit unique name with EarlyStopping(name=...).
Its best value, patience counter and selected monitor are written into every checkpoint's training state. A stateful warm resume restores them after on_train_begin has reset the callback. So a run split at an epoch boundary stops at the same epoch as the uninterrupted run.
The component is optional: resuming from a checkpoint without it keeps the fresh patience. A resumed callback configured with a different monitor or mode is reported before anything is restored. ModelCheckpoint files are weights-only and record training_state_present: false. Pass a file's <tag>_e<epoch> stem (e.g. "custom_e3") as resume_from_checkpoint to warm-start from its weights. resume_mode="stateful" rejects such a file before anything is restored.
6. Custom training paradigms¶
NNModel.train() runs a supervised loop by default: for every batch, it does loss_fn(net(X), Y) → backward → step. Some tasks don't fit that shape, such as autoencoders, VAEs, recommendation pairwise losses and diffusion noise prediction. For those, pass a train_step_fn. Link prediction with leakage-checked edge splits and negatives is built in (nnx.link_tasks, §26).
from nnx import TrainStepContext, NNEvaluationDataPoint
def my_step(ctx: TrainStepContext) -> NNEvaluationDataPoint:
ctx.model.net.zero_grad() # the hook owns the full step — see §6.1
X, _ = ctx.model.net.unpack_batch(ctx.batch)
X = tuple(x.to(ctx.model.device) for x in X)
recon, mu, logvar = ctx.model.net(*X)
recon_loss = F.mse_loss(recon, X[0])
kl = -0.5 * torch.sum(1 + logvar - mu**2 - logvar.exp())
loss = recon_loss + kl
loss.backward()
ctx.optimizer.step()
loss_val = float(loss.detach())
return NNEvaluationDataPoint(
loss=loss_val,
error=loss_val, # the loss as "error" keeps BEST tracking meaningful
extra={"recon": float(recon_loss), "kl": float(kl)},
)
model.train(params=train_params, train_step_fn=my_step)
6.1. Hook contract¶
The hook is one optional kwarg on train(). The rest of the loop (scheduler, callbacks, checkpoint cadence, val loop, incremental save) stays exactly the same. Your function is responsible for zero_grad / forward / loss / backward / optimizer.step / NaN guard / gradient accumulation / AMP.
ctx carries the relevant knobs: grad_clip_norm, accumulate_grad_batches, scaler, and the run's resolved precision. ctx.precision.autocast() is the forward context, and scaler is set for FP16 only. Honoring these knobs is on you.
An optimizer_update-clock scheduler (§2.5) steps only on the updates your function reports. Call ctx.report_update() (no name) after each optimizer.step() your function takes itself. default_train_step and finalize_step report their own steps, so do not report a step you delegate to them. To layer logging on top of the standard supervised step instead of replacing it, call default_train_step(ctx) from inside your hook.
Several step-fn factories share an internal helper, nnx._step_helpers.finalize_step. They are the paradigm factories in nnx.paradigms (kd, feature_kd, simclr, mixup, cutmix, moe, jepa, dpo), nnx.diffusion.diffusion_train_step_factory and nnx.embeddings.text_contrastive_train_step_factory. The helper runs the NaN guard before backward and honors ctx.grad_clip_norm. It reports each optimizer step with ctx.report_update(), so an optimizer_update-clock scheduler steps once per batch (§2.5).
These imperative paradigm steps run in full precision and do not handle reduced precision or gradient accumulation. They are marked full-precision-only, so a run whose precision resolves to FP16 or BF16 refuses them before any run is reserved. finalize_step itself raises a clear ValueError for a scaler, a reduced ctx.precision or accumulation, rather than silently dropping them.
To get both from the shared update engine, express the loss as an objective (§6.5). kd_objective, diffusion_objective and jepa_objective ship for distillation, diffusion and I-JEPA. The legacy mixed_precision=True resolves to full precision on CPU/MPS, so these steps still run there.
See examples/05_custom_train_step_autoencoder.py for an end-to-end autoencoder example.
Stopping at an update boundary. A step reports a committed optimizer
update with ctx.report_update(), or the shared update engine reports it.
Every such update increments the callback context's committed_updates. It
also calls each function in ctx.update_listeners; a callback appends one in
on_train_begin. Setting ctx.stop_at_update = True from a listener stops
training right after that batch. Set in on_epoch_end, it stops at that epoch
boundary.
This is an NNModel.train feature. Trainer.train refuses a callback that
registers an update listener; use on_optimizer_update there. Trainer.train
dispatches on_optimizer_update only in an objective run (§6.5). In a
step-function run, report_update only advances the scheduler clocks.
On an epoch's last batch, the epoch completes and commits as usual. Inside an
epoch, the epoch in progress is discarded, never committed. LAST, its
tensors and the history keep the previous epoch; there is no LAST at all
before a first completed epoch. The final LAST refresh is skipped, so live
weights are never relabelled as a committed epoch's. The in-memory
model.net keeps the partial epoch's weights; reload LAST for the committed
ones.
Under DDP, a mid-epoch request is agreed across ranks when a listener was
registered in on_train_begin on any rank. Otherwise the request takes
effect at the epoch boundary, agreed there. committed_updates is exact for
fp16 (a skipped scaler step is not counted) while a listener or an update
clock is attached. nnx.search uses it for its update caps and deadlines
(§29).
6.2. Custom evaluation¶
NNModel.train(..., eval_step_fn=...) accepts the validation-side equivalent of
train_step_fn. NNx calls it once per epoch under torch.no_grad(), with the
net in eval mode. Every submodule's training mode is restored after the call. The hook
receives an EvalStepContext containing the model, complete val_loader,
extra_metrics, epoch_idx and the run's declared metrics (§6.4). It owns iteration and aggregation across the
loader and returns one NNEvaluationDataPoint for the epoch. NNx persists that
result in idp.val_edp, including custom values in extra.
The returned data point is also the epoch's control signal. BEST selection,
runs/best and ReduceLROnPlateau read the first finite value in the order
validation error → validation loss → training error → training loss. None
fields are simply absent. NaN and ±inf are rejected with one
RuntimeWarning per epoch that names the rejected field/split and the
value actually used.
An epoch with no finite signal anywhere skips the plateau step, with a distinct "no metric available" warning. It compares as an unavailable BEST baseline.
A validation task record can be unavailable: every target masked
(status="empty"), or no error and no loss. Such a record ends the walk
instead. The epoch has no signal, the plateau step is skipped, and the
training metrics are never compared in its place.
The raw observations are retained unchanged in the live history and checkpoint payloads. A CSV cannot
tell NaN from absent, so CSV readback maps NaN cells to None. A NaN extra
or metrics value reads back as an absent key, and a record whose values were
all NaN reads back as no record. A history journal (§4.5) keeps NaN.
EarlyStopping is separate and never falls back from validation to training
metrics. Its default (monitor=None) picks one validation field per train()
call: the first finite value in the order val_edp.error → val_edp.loss.
This is like BEST selection but without the training fallback. It keeps that
field for the rest of the run. selected_monitor reports the choice, and
mode="max" needs an explicit monitor.
An explicit monitor ("val_edp.error", "val_edp.loss",
"train_edp.error" or "train_edp.loss") reads exactly that field. The
tracked field or its whole data point may be missing: no validation loader,
or an evaluator that never sets it. Then the epoch does not count toward
patience. One RuntimeWarning per run names the monitor and what is missing,
rather than the callback going silently inactive.
A NaN/±inf value never becomes the best; it counts as an epoch without improvement, so a diverged run still stops.
To expose a loss-only control signal from a regression evaluator, set loss
and leave error as None. The default monitor then tracks the loss.
Alternatively, mirror the loss into error as example 26 does. Never report a
regression error as a classification accuracy.
A run that declares a named monitor (§6.4) replaces these rules with the monitor's single decision. That decision drives BEST, ReduceLROnPlateau and every EarlyStopping given the same spec.
This supports regression and other non-classification validation without
replacing the training loop. See
examples/26_custom_eval_step.py.
Standalone evaluate() and predict() use the built-in supervised
classification path unless the model declares a task (§6.3); there is no
predict_fn hook yet.
6.3. Task adapters (categorical, multilabel, regression)¶
Without further declaration the default training step, evaluate() and
predict() assume categorical classification. They decode by argmax (or the
BCEWithLogitsLoss threshold), and every record carries accuracy / f1 /
recall / precision. Plain regression and multilabel problems do not need
the custom hooks above. Declare the task on the model instead:
from nnx import Losses, Nets, NNModelParams, TaskSpec
NNModelParams(net=Nets.FEED_FWD, loss=Losses.MEAN_SQUARED_ERROR, task=TaskSpec.regression(2))
NNModelParams(net=Nets.FEED_FWD, loss=Losses.BINARY_CROSS_ENTROPY, task=TaskSpec.multilabel(5, threshold=0.4))
NNModelParams(net=Nets.FEED_FWD, loss=Losses.CROSS_ENTROPY, task=TaskSpec.categorical(3, ignore_index=-100))
The task's adapter (model.task_adapter) then owns these concerns for the
default step, evaluate(), predict() and predict_proba():
- Validation. Each batch's outputs and targets are checked for the
declared shape, dtype and value range before any backward pass or
optimizer update. Categorical targets must be integer class indices in
[0, C)(orignore_index). Multilabel targets must be 0/1 and shaped like the output. Regression targets must be floating and shaped like the output; an(N,)target is accepted only for an(N, 1)output, and nothing broadcasts. Failures raiseTaskValidationError(aValueError). - Model checks. The model itself is checked at construction. The loss
must suit the task (
CROSS_ENTROPY/NEGATIVE_LOG_LIKELIHOOD,BINARY_CROSS_ENTROPY,MEAN_SQUARED_ERROR), and a declared width must equaloutput_dim.Nets.TRANSFORMERandNets.VITare out of scope; language-model, ranking, link and structured-prediction tasks are separate work.NNModel.trainandTrainer.trainre-check the liveloss_fnbefore touching any loader. - Masking and loss units. Categorical targets equal to
ignore_indexand NaN multilabel / regression targets are excluded from the loss and every metric alike. The loss is averaged over the valid targets with the same normalization weights the default step uses for gradient accumulation. So uneven batches and an accumulated window reduce exactly like one full batch. - Masked windows and dtypes. A window whose every target is masked takes no optimizer update. Targets keep their own dtype (no cast to a reduced-precision output).
- Masked losses. Masked multilabel / regression losses are evaluated
element-wise at full shape, so a
BCEWithLogitsLosspos_weightstill lines up with its labels. They need an elementwise loss (MSELoss,L1Loss,SmoothL1Loss,HuberLoss,BCEWithLogitsLoss). Any other loss module scores complete batches unchanged and rejects a masked one. - Decoding. Argmax over axis 1 (categorical),
sigmoid >= threshold(multilabel), or the values themselves (regression).predict().classesholds these decoded values.predict_proba(X)may omit its spec (the task supplies it). A regression result carriesprobabilities=Noneandspec=Nonewith the values indecoded. - Records. Metrics are computed over the whole loader, not averaged per
batch. Every task record carries its
kind, thecountof valid targets and astatus. The status is"ok", or"empty"when every target was masked. An empty record has no loss and no metrics, andevaluate()returns it instead of raising. - Metrics per task. Categorical records keep the legacy accuracy / f1 /
recall / precision (macro) and
error = 1 - accuracy. Multilabel records report subset accuracy asaccuracyand macro f1 / recall / precision over labels. They also report bothsubset_accuracyandelement_accuracyinedp.metrics. Regression records carrymse/maeinedp.metricsand leave the classification fields anderrorasNone. So BEST selection,ReduceLROnPlateauandEarlyStoppingtrack the loss. - Loggers and user metrics. TensorBoard / W&B receive
mse,mae, … directly and never a fabricated or zero-filled classification field. Userextra_metricsare called asfn(y_true, y_pred)on the valid targets only, never on a masked position. For categorical tasks these are the class indices of the unmasked rows, as for models without a task. For multilabel and regression they are the unmasked values flattened to 1-D.
The spec is a versioned mapping on NNModelParams.state() (task:
{version: 1, kind: ..., ...}). So it persists in run.yaml, checkpoints and
Hub configs and participates in the run id. Models without a task omit the
key and keep their existing run ids.
Task records add kind / count /
status / metrics to NNEvaluationDataPoint.state() only when set, so
legacy idps.csv columns are unchanged. Custom training steps can call
model.task_adapter.record(output, target, loss=...) to write the same
record the default step writes. Runnable walkthrough:
examples/regression_task.py.
6.4. Named metrics and monitors¶
extra_metrics callables see only decoded labels, and BEST selection,
ReduceLROnPlateau and EarlyStopping each pick their control signal their
own way. Two opt-in declarations (nnx.monitors) make both explicit:
from nnx import EarlyStopping, MetricSpec, MonitorSpec, NNTrainParams
monitor = MonitorSpec(metric="nll", split="val", min_delta=0.01)
params = NNTrainParams(
n_epochs=20, train_loader=train, val_loader=val,
metrics=[MetricSpec("nll"), MetricSpec("brier"), MetricSpec("accuracy")],
monitor=monitor, # BEST + ReduceLROnPlateau
)
model.train(params=params, callbacks=[EarlyStopping(monitor=monitor, patience=5)])
Metrics. A MetricSpec(id, version=1, config={}, name=None) names a
registered metric and reports it under name (the id by default). A name
equal to a task's own metric (mse, subset_accuracy) replaces that value in
the record's metrics. Loggers report a name equal to a record field
(accuracy, f1) as metrics/<name>, beside the field. Each registration
declares the prediction input it receives:
| id | input | value over the full sample |
|---|---|---|
accuracy, f1 (config={"average": "macro"}) |
labels — decoded predictions |
fraction correct / F1 |
nll, brier |
probabilities — softmax over the class axis (categorical), element-wise sigmoid (multilabel) |
mean negative log-likelihood / mean Brier score (summed over classes when categorical) |
mae, mse |
continuous — raw outputs |
mean absolute / squared error |
Values are accumulated over every valid sample: additive metrics as running sums, F1 once over all labels. So a short last batch weighs exactly its samples, and a metric is never an unweighted mean of batch values.
ignore_index and NaN-masked targets are excluded. Multilabel and
continuous metrics count each valid output as a sample. Multilabel labels
are decoded with the task's threshold. Multilabel F1 needs
config={"average": "binary"}, the positive-decision F1 pooled over every
output; per-label averaging is not available.
evaluate(loader, metrics=...) computes them over the whole loader, and the
default training step over the whole training epoch. The values land in the
records' metrics under each name. The input must be one the model can
provide, and a mismatch fails before any batch is read. Probabilities need a
categorical (cross-entropy / NLL) or multilabel (BCE-with-logits) model.
Continuous inputs need a regression loss or task.
Custom metrics register with
register_metric(id, version, factory, input=..., mode=..., check_config=None).
factory(config) returns an accumulator with update(target, prediction)
and result(). A spec never holds code: it round-trips through run.yaml,
and NNRun.load works without the registration. Training with an
unregistered (id, version) fails before any run is reserved and without any
metric code running.
Monitors. MonitorSpec(metric="loss", split="val", mode=None,
min_delta=0.0, on_missing="skip", on_nonfinite="skip") names five things:
- the split:
"val"(the whole validation set) or"train"(the whole-epoch training summary); - the metric:
"loss","error"or a declared metric's name (an undeclared name fails whenNNTrainParams/NNTrainerParamsis constructed); - the direction (
Nonetakes the metric's natural one); - the minimum improvement;
- the policies.
Its improved(current, best) is the one rule. The first finite value
improves, and later ones must beat the best by more than min_delta. Ties
never improve, and a non-finite value never improves.
on_missing="skip" makes an epoch without a value a non-decision: no BEST,
no plateau step, not counted toward patience. on_nonfinite="skip" makes a
NaN / ±inf value an epoch without improvement. "error" raises
MonitorUnavailableError instead. A monitor on the validation split needs a
val_loader. A training-split monitor of a declared metric needs the default
training step. Both are checked before training.
With NNTrainParams(monitor=...) the epoch's MonitorRecord decides:
- BEST — written exactly on the epochs the monitor marks as improved;
ReduceLROnPlateau— rebuilt with the monitor's direction and an absolutemin_deltathreshold. It is stepped on the monitored value, treating a non-finite value as a worse one, with no step when missing. So its improvement decisions are the monitor's, andNNSchedulerParams.thresholdis then unused;EarlyStopping(monitor=spec)— given the same spec, it applies the same rule to the same epoch summary.
For validation NLL [0.8, 0.7, 0.695, 0.71] with min_delta=0.01, all
three see two improvements (epochs 0 and 1).
Whole-epoch training summary. A run that declares metrics or a monitor
records a train_summary on each epoch's last idp. It holds the training
loss and error averaged with each batch's own denominator, plus the declared
metrics over the full epoch, never the last batch.
From the default step, the
denominators are the loss normalization weight and the number of scored
targets. For custom steps, each record is weighted by its count or the
batch's sample count.
The run also records selection, the epoch's MonitorRecord. It holds the
monitor identity, direction, configuration, value, status (ok / missing /
nonfinite) and whether it improved. Both persist through idps.csv and the
checkpoint metadata (pickle and safetensors). Neither is written for runs
without metrics or a monitor.
- Callbacks see them on
ctx.idp. - The progress bar shows the monitored value.
- TensorBoard / W&B add
train_epoch/*,monitor/<split>.<metric>andmonitor/improved. - The run chart plots the whole-epoch training loss and the monitor series, with its improving epochs marked.
A named-monitor run ranks BEST by its own monitor. That ranking is not
comparable with other runs' error / loss, so the run is never elected into
runs/best (§4.1).
A resumed session is a new run with its own id and checkpoints. But a
stateful resume continues the monitor: its best value is component state
(nnx.monitor, §14.1), as EarlyStopping's patience is. So the resumed run
writes BEST only on an epoch that beats the best the source checkpoint
carried. These are exactly the epochs on which the uninterrupted run would.
When no resumed epoch does, the resumed run ends without a BEST checkpoint.
The best epoch stays the source run's BEST, and
resume_from_checkpoint="best" on the resumed run fails as for any run
without one. runs/best is never affected. A weights_only resume starts
the monitor afresh, so its first epoch with a finite value writes BEST.
A stateful resume also checks the ReduceLROnPlateau state before anything
is restored. It refuses a state saved under another improvement rule than the
one the run builds. That covers a different direction, threshold mode or
threshold, with or without a monitor. Loading it would replace the run's rule. For example, a
run that monitored accuracy and resumes without a monitor would keep
mode='max'. It would then cut the LR whenever the validation loss improves.
The error names the differing fields. Resume with the original monitor and
NNSchedulerParams.threshold, or with resume_mode="weights_only" for a
fresh scheduler.
Trainer (NNTrainerParams.builder().metrics(...).monitor(...)) supports
declared validation metrics and training loss / error monitors. Its steps
are custom, so training-split declared metrics are unavailable. Without
metrics / monitor, serialization, run ids and every legacy selection
rule are unchanged.
6.5. Objectives and the shared update engine¶
A train_step_fn is fully responsible for its update: forward, backward,
accumulation, mixed precision, clipping and optimizer.step. So every
paradigm re-implements those mechanics, and the factories built on
finalize_step refuse reduced precision and gradient accumulation outright.
An objective (nnx.objectives) only describes the loss and hands the
update to one shared engine. NNModel.train and Trainer.train use the same engine:
from nnx import kd_objective, supervised_objective
student.train(params=train_params, objective=kd_objective(teacher, alpha=0.5, temperature=4.0))
model.train(params=train_params, objective=supervised_objective())
Trainer(model).train(params=trainer_params, objective=supervised_objective())
For each microbatch an objective returns an ObjectiveResult of
LossTerms. Each term has a differentiable numerator: the sum of the term
over its samples. It has either an explicit denominator
(reduction="mean") or none (reduction="sum"). The denominator is how many
samples, or how much total weight, the numerator sums over; it is 0 when
every sample is masked.
The engine never infers a reduction from a scalar. A term that switches between summed and normalized within a window fails.
Each term is back-propagated into its own gradient buffer. A window of one
microbatch (the default) takes a single backward pass through its combined
loss, and a term of weight 0 is never back-propagated.
The update window is
accumulate_grad_batches microbatches, or fewer when the epoch ends. At its
end, a normalized term becomes Σ numerators / Σ denominators and a summed
one Σ numerators. So uneven microbatches, a short final window and ignored
targets give exactly the full-batch update. For example, numerators 8 and 1
over counts 2 and 1 average to 3; as sums they total 9.
The engine then runs the mixed-precision protocol in order: the objective's
forward under autocast, unscale, clipping, step, update. It applies the
objective's non-finite policy. nonfinite="fail" (default) raises
FloatingPointError before anything is stepped; "skip" drops the window's
gradients.
A window whose every term is masked takes no update, and a
GradScaler-skipped step is always a quiet skip. Only optimizer-owned
parameters that require gradients when a microbatch runs are differentiated.
So freezing or unfreezing them between epochs (gradual unfreezing) trains
exactly as the default step does.
An optimizer built with param_groups (every multi-optimizer Trainer)
leaves out parameters frozen at build time. Unfreezing one of those later
trains nothing: build the optimizer without param_groups, or include the
parameter before freezing it. Both loops warn once per run about a parameter
that requires gradients but that no optimizer owns.
Each committed optimizer update fires one detached UpdateEvent to
Callback.on_optimizer_update(ctx, event). A microbatch, a masked window or
a skipped step never fires one. The event holds the optimizer name, that
optimizer's update index, the epoch and batch where the window closed, the
microbatches and the term values. ctx.update_count and each record's
update_count carry the run's committed-update counter. It is distinct from
the microbatch counter iter_idx and the epoch.
The counters are
checkpointed component state (nnx.update_engine, §14.1). A stateful resume
continues them; a weights_only resume starts them at zero.
The whole-epoch training loss (§6.4) of an objective run applies the same
rule to the whole epoch. Each term's summed weighted numerators are divided
by its summed denominators. A window dropped under nonfinite="skip" is left out;
an epoch that drops every window has a non-finite loss. As with step
functions, declared metrics are recorded on validation only.
SupervisedObjective is the model's loss_fn normalized by its own
denominator (non-ignored targets, class weights; summed for a
reduction="sum" loss; a TaskSpec's masked loss when declared).
KDObjective splits distillation into two terms (only "supervised" at
alpha=0). The "distillation" term
is the softened KL summed over rows, normalized by their count, with weight
alpha. The "supervised" term has weight 1 − alpha. Together they are
exactly alpha · KL_batchmean + (1 − alpha) · L_hard of the full batch,
however the rows are split. Any callable
(ObjectiveContext) -> ObjectiveResult is an objective; subclass Objective
to declare nonfinite.
Paradigm objectives. diffusion_objective(schedule) (DDPM noise
prediction, §9) and jepa_objective(target_encoder, predictor, mask_fn,
ema_momentum=...) (I-JEPA §5) are objectives.
They are the counterparts of diffusion_train_step_factory and
jepa_train_step_factory, which stay available and unchanged.
Each reads one input per batch. When the model has a batch adapter, the
batch is split by it, and the adapter must then yield exactly one input.
Each returns one normalized term, "noise_mse" / "latent_mse": the
squared-error sum over every valid element, over the element count. It
also returns detached metrics (metrics={term: value}, no classification
fields). It never zeroes, back-propagates, scales or steps.
So uneven
microbatches give the full-batch update, and unequal target masks combine as
(sum_1 + sum_2) / (count_1 + count_2). Records, on_optimizer_update
events, train monitors (MonitorSpec(metric="loss", split="train")) and
BEST all see that denominator-weighted loss.
An objective may also define two hooks:
check_run(model, *, optimizers, callbacks)—NNModel.trainandTrainer.traincall it with the built optimizers before any run is reserved. It refuses a combination the objective cannot train.after_update(events)— called once per committed update, after the window's step and before callbacks see the events. An optimizer without gradients in the window is not stepped. The JEPA EMA target advances there, never per microbatch or after a skipped window.
Both adapters are checkpointable components, so a stateful resume continues where an uninterrupted run would be:
"diffusion.objective": the spec and the objective's own generator."jepa.objective": the spec, the predictor's reference insidemodel.net, the EMA target and its update counter.
Pass an objective itself, not a wrapper. A lambda or functools.partial
around it hides its check_run, after_update and component state from the
run.
A run has exactly one update owner. Passing both train_step_fn (or
trainer_step_fn) and objective fails before any callback runs, any loader
is read or any parameter changes. So does passing the imperative diffusion or
JEPA step as objective=: it steps the optimizer itself, so the engine
would step it twice. So does passing an Objective as the step function.
In Trainer the engine steps every named optimizer that holds gradients
once per committed update. Each is clipped with its own grad_clip_norm, and all share one
accumulate_grad_batches. The engine emits one event per named optimizer.
An explicit NNModelParams.precision applies to Trainer objectives too.
It brings autocast around the objective, an FP16 scaler checkpointed with the
run, and an all-or-nothing window. When any optimizer's unscaled gradients
overflow, none of them steps.
In an objective run the named optimizers must not share a parameter. Every committed window steps each optimizer, so a shared parameter would update twice. Under FP16 the scaler also unscales per optimizer. Sharing is refused before any run is reserved.
Trainer step functions, by contrast, own every update in full precision.
They refuse a reduced policy before any run is reserved. The legacy
mixed_precision flag is not applied by Trainer. It runs objectives in
full precision and warns when the flag asks for it on CUDA. NNModel.train
applies it.
An objective runs under the run's autocast, so a reduced run's forward
outputs are float16 / bfloat16. ObjectiveContext.precision is the resolved
precision. ctx.full_precision(output) returns an output as float32
(differentiable). Use it before task preparation, the loss's bookkeeping or a
record converts the output to NumPy. The built-in objectives (and the default
step) do so.
kd_objective's frozen teacher runs in its own inference precision (its
explicit policy, or full precision), never in the student's. That holds even
under the legacy mixed_precision flag, which never governs inference. As
with the default step, NNModel.train rejects an objective run on a
low-rank-surgery topology it cannot reconstruct. Imperative step functions
and finalize_step are unchanged by objectives.
7. Fine-tuning (transfer learning)¶
The standard transfer-learning recipe is "load pretrained weights, freeze most of the model, train only the head". It has three moving parts in NNx, all under nnx.finetune:
from nnx import NNModel, load_pretrained, NNParamGroupSpec, NNOptimParams, Optims
model = NNModel(net_params=..., params=...)
# 1. Load weights from an external state-dict / .pt file / other nn.Module.
# Pass key_map= to rewrite foreign prefixes onto model.net's own names
# (e.g., {"backbone.": "layers."}).
result = load_pretrained(model.net, "resnet18.pt", strict=False)
print(f"loaded {len(result.loaded_keys)}, missing {len(result.missing_keys)}")
# 2. Freeze whatever shouldn't train. Glob patterns match the dotted
# parameter name. `model.freeze` is a shortcut for nnx.finetune.freeze.
model.freeze("layers.0.*") # freeze the first block
# `model.unfreeze("*")` would reverse it; `frozen(model.net)` lists what's frozen.
# 3. (Optional) Run the remaining pretrained block with a smaller LR than a
# fresh head. NNOptimParams.param_groups takes a list of NNParamGroupSpec;
# each matches parameters by glob and overrides lr / lr_multiplier /
# weight_decay. The FIRST matching spec wins and specs never merge, so the
# specific bias rule for that block must precede both broader rules.
# (FeedFwdNN names its layers `layers.0`, `layers.1`, ...; list the real
# names with `[n for n, _ in model.net.named_parameters()]`.)
optim = NNOptimParams(
name=Optims.ADAM, max_lr=1e-3, momentum=(0.9, 0.999), weight_decay=5e-4,
param_groups=[
NNParamGroupSpec(name_pattern="layers.1.*bias", lr_multiplier=0.01, weight_decay=0.0),
NNParamGroupSpec(name_pattern="layers.1.*", lr_multiplier=0.01),
NNParamGroupSpec(name_pattern="*.bias", weight_decay=0.0),
],
)
Rules are evaluated in list order and the first match wins; rules never merge. Take a model whose submodules are named encoder and head. The same intent (encoder at 1/100th of the LR, no weight decay on any bias) must be written as three rules, in this order:
encoder.*bias(lr_multiplier=0.01,weight_decay=0.0);encoder.*(lr_multiplier=0.01);*.bias(weight_decay=0.0).
This gives encoder.bias lr 1e-5 / wd 0, encoder.weight 1e-5 / 5e-4, head.bias 1e-3 / 0 and head.weight 1e-3 / 5e-4. Listing encoder.* before *.bias would leave every encoder bias with weight decay, because the bias rule is never reached for those parameters. In fine-tuning (non-strict) mode a pattern that matches nothing is silently empty, so check the names against named_parameters().
NNModel.export_state_dict(path) saves the inverse: self.net.state_dict() only, with no NNCheckpoint wrapper. It is for users who want to share weights with non-nnx consumers.
Strict back-compat: NNOptimParams with param_groups=None (the default) omits the field from its state() dict. Existing run.id hashes are unchanged.
See examples/06_finetune_with_layer_freezing.py for the end-to-end recipe.
8. Multi-optimizer training (GANs, actor-critic)¶
Sometimes the per-batch update isn't "one forward → one loss → one optimizer step". It is a coordinated dance between multiple optimizers: GAN G/D alternation, actor-critic policy + value, energy-based models. Then use nnx.trainer.Trainer in place of NNModel.train(). It accepts one NNModel and a name-keyed dict of NNOptimParams, and builds one torch.optim.Optimizer per entry. It hands the dict to a user-supplied trainer_step_fn:
from nnx import Trainer, NNTrainerParams, TrainerStepContext, NNEvaluationDataPoint
from nnx import NNOptimParams, NNParamGroupSpec, Optims
trainer = Trainer(model=model) # `model.net` wraps both G and D as sub-modules
def gan_step(ctx: TrainerStepContext) -> NNEvaluationDataPoint:
opt_G, opt_D = ctx.optimizers["G"], ctx.optimizers["D"]
# ... D step: opt_D.zero_grad() → d_loss.backward() → opt_D.step()
# ... G step: opt_G.zero_grad() → g_loss.backward() → opt_G.step()
return NNEvaluationDataPoint(loss=..., error=..., ...)
run = trainer.train(
params=NNTrainerParams(
n_epochs=10,
train_loader=loader,
optims={
"G": NNOptimParams(
name=Optims.ADAM, max_lr=2e-4, momentum=(0.5, 0.999), weight_decay=0.0,
param_groups=[NNParamGroupSpec(name_pattern="G.*", lr=2e-4)],
),
"D": NNOptimParams(
name=Optims.ADAM, max_lr=2e-4, momentum=(0.5, 0.999), weight_decay=0.0,
param_groups=[NNParamGroupSpec(name_pattern="D.*", lr=2e-4)],
),
},
),
trainer_step_fn=gan_step,
)
Builder shape (composes the optimizer and scheduler Builders):
The same GAN recipe via the composite Builder. The .scheduler("D", ...)
without a prior .optimizer("D", ...) would fail at .build() time
with an actionable error naming the typo:
from nnx import NNTrainerParams, NNOptimParams, NNParamGroupSpec, NNSchedulerParams
g_optim = (
NNOptimParams.builder()
.adam(max_lr=2e-4, betas=(0.5, 0.999), weight_decay=0.0)
.param_groups([NNParamGroupSpec(name_pattern="G.*")])
.build()
)
d_optim = (
NNOptimParams.builder()
.adam(max_lr=2e-4, betas=(0.5, 0.999), weight_decay=0.0)
.param_groups([NNParamGroupSpec(name_pattern="D.*")])
.build()
)
plateau = NNSchedulerParams.builder().reduce_on_plateau(
min_lr=1e-7, factor=0.5, patience=2, cooldown=1, threshold=1e-3
).build()
trainer_params = (
NNTrainerParams.builder()
.n_epochs(50)
.train_loader(loader)
.optimizer("G", g_optim)
.optimizer("D", d_optim)
.scheduler("G", plateau)
.scheduler("D", plateau)
.build()
)
Builders are deliberately mutable and reusable: .optimizer(name, ...) /
.scheduler(name, ...) overwrite in place and the last call wins. But every
build() captures its own read-only snapshot of optims, schedulers and
extra_metrics. So does every direct NNTrainerParams(...), from_state,
dataclasses.replace and with_train_loader / with_val_loader.
Reusing the builder afterwards does not change an already-built
configuration, its state(), or the run.id derived from it. Neither does
mutating the dict you passed in. Item assignment or deletion through the
built mappings raises TypeError.
Only the containers are copied. The NNOptimParams / NNSchedulerParams
values are already immutable, and metric callables and DataLoaders are
shared by identity (never deep-copied). Runtime insertion order is kept for
optimizer construction. state() still emits sorted keys and omits defaults,
so serialized descriptors and existing run IDs are unchanged.
8.1. Strict param-groups semantics¶
The Trainer enforces strict param_groups semantics: each optimizer owns ONLY parameters its specs explicitly match. Without that, opt_G would also pick up D's parameters in a default bucket, and the two optimizers would silently update the same weights. The contract is enforced via build_param_groups(..., strict=True). The same fine-tuning specs from nnx.finetune apply, just with unmatched params dropped instead of bucketed.
Registered optimizer factories (§3.1) get the same strictly-owned groups. A spec set that matches nothing fails instead of falling back to every parameter.
8.2. No default step¶
There is no default_trainer_step. Multi-optim updates are inherently scenario-specific, and silently running the wrong update is worse than requiring an explicit fn. Ownership is split accordingly:
- The
trainer_step_fnowns every optimizer update (zero_grad/backward/stepper named optimizer). - A
trainer_step_fnrun refusesgrad_clip_normand anaccumulate_grad_batchesother than 1 on any named optimizer. The Trainer cannot apply them to a step function's updates. Clip or accumulate inside the step function, or passobjective=instead. - The Trainer owns the epoch loop and steps each registered scheduler once per epoch. On the
optimizer_updateclock (§2.5), it steps a scheduler after each update the step reports withctx.report_update(name). Each optimizer keeps its own count.
auto_step_schedulers=False (NNTrainerParams.builder().auto_step_schedulers(False)) hands scheduler timing to the step function too. Then neither the epoch boundary nor a reported update steps a scheduler, before and after a resume. A schedule's position is exactly the steps the function takes. A later resume with automatic stepping on reattaches an update clock at that position. So keep the function's steps in the clock's unit (one per committed update).
An objective run has no step function to take the timing over, so it refuses auto_step_schedulers=False, which would leave even the default plateau schedulers unstepped.
Trainer-level extra_metrics are callable(y_true, y_pred). The built-in validation calls them on the aggregate, and the step function decides whether to call them on training batches. Alternatively, pass objective= (§6.5) instead of a step function. The shared update engine then steps every named optimizer that holds gradients once per committed update.
8.3. NNRun integration¶
The Trainer writes the same NNRun + NNCheckpoint artifacts NNModel.train() does. It adds one trainer block in run.yaml capturing the multi-optim config, so NNRun.load(id) round-trips. Callbacks work in trainer mode too. ctx.optimizer is the primary (sorted-first) optimizer, for back-compat with LRMonitor / TensorBoardCallback. ctx.optimizers / ctx.trainer are added attributes for trainer-aware callbacks.
Strict back-compat: an NNRun built without a trainer (the standard NNModel.train() path) omits the trainer block from its state(). Existing run.id hashes are unchanged.
See examples/09_gan_with_trainer.py for a tiny GAN on a 1D mixture-of-Gaussians distribution.
8.4. Warm resume¶
Every Trainer checkpoint carries a training-state sidecar. It holds each named optimizer's and scheduler's state, the completed epoch, the RNG streams and the registered components (§14.1). The final LAST checkpoint, written after on_train_end, carries all of it too. Resume with the same controls NNTrainParams has:
first = Trainer(model).train(params=trainer_params, trainer_step_fn=gan_step) # the earlier run
params = (NNTrainerParams.builder()
.n_epochs(2)
.optimizer("G", g_optim).optimizer("D", d_optim)
.train_loader(loader)
.resume_from(first.id, checkpoint="last", mode="auto")
.build())
Trainer(model).train(params=params, trainer_step_fn=gan_step)
The resumed run's epochs continue from the saved epoch. Each named optimizer is validated exactly as NNModel.train validates its one. The checks cover:
- the optimizer and scheduler names;
- each optimizer's type, registered-factory identity (§3.1) and parameter topology;
- each scheduler's type;
- a plateau scheduler's improvement rule (direction, threshold mode and threshold — §6.4);
- a one-cycle / warmup-decay schedule's explicit
total_stepshorizon covering the original and resumed epochs (updates, on theoptimizer_updateclock); - an epoch-clock warmup-decay schedule's explicit
warmup_steps(§14.2).
A mismatch fails before the model, an optimizer or the RNG is touched. Step ownership is unchanged: the step function still owns every optimizer update.
resume_from_run_id / resume_from_checkpoint serialize only as parent_run_id / parent_checkpoint lineage, and resume_mode is runtime-only. So a configuration without them keeps its state() and run id. The controls survive builder(), NNTrainerParamsBuilder.from_params and the with_*_loader copies. from_state() restores the lineage only (parent_run_id and the checkpoint tag), never a resume request or its mode.
A checkpoint written by NNModel.train (one optimizer) resumes only through NNModel.train. A Trainer checkpoint resumes only through Trainer.train. The exception is resume_mode="weights_only", which warm-starts from the weights alone.
9. Diffusion (DDPM)¶
Diffusion lives entirely on top of the train_step_fn hook (§6) — no Trainer, no NNModel changes, no new params dataclass. The pieces are under nnx.diffusion:
from nnx import (
DiffusionMLP, NoiseSchedulers,
diffusion_train_step_factory, sample,
NNModel, NNModelParams, NNTrainParams, Losses,
)
# DiffusionMLP's forward is (x_t, t) -> epsilon, not a built-in net:
# NNModel(module=...) wraps it, and the run id records its topology (§3.2).
# The loss is unused; the diffusion step owns it.
model = NNModel(
module=DiffusionMLP(input_dim=2, hidden_dims=[64, 64], time_embed_dim=16),
params=NNModelParams(loss=Losses.MEAN_SQUARED_ERROR),
)
schedule = NoiseSchedulers.LINEAR(T=200) # or NoiseSchedulers.COSINE
step_fn = diffusion_train_step_factory(schedule)
model.train(params=NNTrainParams(..., train_loader=loader), train_step_fn=step_fn)
# Sample by running the reverse-diffusion loop.
samples = sample(model, schedule, shape=(256, 2))
The same loss is available as an objective (§6.5):
model.train(params=..., objective=diffusion_objective(schedule, seed=0))
hands backward, gradient accumulation, mixed precision and clipping to the
shared update engine.
Its timesteps and noise come from the objective's own
CPU generator. The generator is seeded from seed, or from one draw of the
global RNG at first use. Its state is checkpointed component state
("diffusion.objective"). So a stateful resume continues the stream, and a
resume with another schedule is refused before anything is restored.
diffusion_objective(..., noise_fn=...) takes
noise_fn(x_0, generator) -> (t, eps) to supply the timesteps and noise
yourself.
A sample(..., generator=...) preview with its own generator (say, in a
callback) never perturbs that stream. sample restores the net's
train/eval modes.
9.1. Noise schedules¶
NoiseSchedulers is an enum-as-factory matching the rest of the library (Optims, Schedulers, Nets). NoiseSchedulers.LINEAR(T=1000, beta_min=1e-4, beta_max=2e-2) and NoiseSchedulers.COSINE(T=1000, s=0.008) each return a NoiseSchedule. That is a frozen dataclass of precomputed tensors (betas, alphas, alphas_cumprod, sqrt_alphas_cumprod, sqrt_one_minus_alphas_cumprod, posterior_variance). Pass the arguments after T by keyword.
The factory builds tensors on CPU; the train step and sampler migrate them to model.device on first use. There's no state() / from_state() round-trip. The tensors are recoverable from (kind, T, beta_min, beta_max | s). Nothing serializes the schedule: rebuild it with the same arguments. A diffusion_objective resume compares T and a fingerprint of the betas and refuses a different schedule.
9.2. Denoising network¶
DiffusionMLP is a small conditional MLP: sinusoidal time embed → MLP projection → concat with flattened x → MLP → noise prediction. It's not a U-Net. For image-space diffusion, the same schedule / train step / sampler work against any user-supplied nn.Module with the forward(x, t) signature.
9.3. Training step¶
diffusion_train_step_factory(schedule) returns a TrainStepFn (§6). For each batch, it samples t ~ Uniform[0, T) and ε ~ N(0, I). It computes x_t = √ᾱ_t · x_0 + √(1 - ᾱ_t) · ε. It predicts noise via model.net(x_t, t) and backprops the MSE between predicted and true noise. The returned EDP sets both loss and error to the noise-prediction MSE so BEST checkpoint tracking and ReduceLROnPlateau have a metric.
9.4. Sampling¶
sample(model, schedule, shape, device=, generator=) runs T reverse steps under torch.no_grad() and returns generated samples. The optional generator= argument enables reproducible sampling — useful for notebook visualization.
See examples/08_diffusion_2d_mixture.py for an end-to-end run on a 2D mixture of four Gaussians. After training, samples cluster around all four modes (~25% each).
10. Training paradigms¶
Most paradigms live in nnx.paradigms as TrainStepFn factories; born_again_train runs its generations itself, nt_xent_loss is a bare loss, and offline distillation (§10.1) is an objective. They cover Hinton-style knowledge distillation, FitNets-style feature distillation and SimCLR contrastive learning. They also cover Mixup and CutMix batch augmentation, sparse top-k Mixture-of-Experts, I-JEPA self-supervised pretraining, DPO preference fine-tuning, and Born-Again iterated self-distillation. Each factory plugs into NNModel.train(train_step_fn=...):
from nnx import (
kd_train_step_factory, # Hinton-style knowledge distillation
feature_kd_train_step_factory, # FitNets-style intermediate-feature KD
simclr_train_step_factory, # SimCLR contrastive learning
mixup_train_step_factory, # Mixup batch augmentation
cutmix_train_step_factory, # CutMix batch augmentation (4D images)
moe_train_step_factory, # MoE supervised step + Switch aux loss
jepa_train_step_factory, # I-JEPA self-supervised pretraining
dpo_train_step_factory, # DPO preference fine-tuning (LM)
born_again_train, # Iterated self-distillation wrapper
nt_xent_loss, # SimCLR loss exposed for ad-hoc use
)
§§10.1–10.3 below cover the four foundational paradigms (KD, SimCLR, Mixup, CutMix). The other paradigms are documented in dedicated pages or under §15 (DPO ties into the LM path):
- Feature-KD — extends
kd_train_step_factorywith an MSE term between named teacher/student intermediate activations; full signature in API §14. - MoE —
MoELineardrop-in fornn.Linear+moe_train_step_factory(sums per-layerlast_aux_lossinto the main loss as a Switch-style load-balancing penalty); demo inexamples/14_moe_classifier.py. - I-JEPA — masked-patch → latent-prediction against an EMA target encoder; full walkthrough in
docs/jepa.md. - DPO — preference-pair fine-tuning against a frozen reference policy; see §15 and
docs/dpo.md. - Born-Again —
born_again_train(...)iterates self-distillation across G generations; see API §14.
10.1. Knowledge distillation¶
teacher = NNModel.from_checkpoint(...) # pretrained, larger net
student = NNModel(net_params=..., params=...) # smaller net, same output_dim
step_fn = kd_train_step_factory(teacher, alpha=0.5, temperature=4.0)
student.train(params=train_params, train_step_fn=step_fn)
The factory freezes the teacher's parameters and sets its net to eval mode on call. So teacher weights are guaranteed not to drift during student training. The loss is α · KL(softmax(t/T) || softmax(s/T)) · T² + (1-α) · L_hard. That is the standard Hinton direction (teacher first), implemented via F.kl_div(log_softmax(student/T), softmax(teacher/T)).
The hard-label term uses the student's loss_fn. Native NLL gets the same internal log-softmax adaptation as the supervised loop, so CE and NLL students train identically from the same weights. The soft KL term always works on raw logits. EDP reports the combined loss and student top-1 error.
kd_objective(teacher, alpha=..., temperature=...) is the same loss as an objective (§6.5). Pass it as student.train(..., objective=...). It then gains gradient accumulation (exact for uneven microbatches and short windows), mixed precision and clipping from the shared update engine. See examples/10_knowledge_distillation.py::objective_mode.
Live logits vs offline teacher distributions. Both paths above run a live teacher every step. They soften its logits at a temperature, scaled by T². Stored teacher output is often only probabilities, with no pre-temperature logits to soften. Examples are an export of a model you no longer run, or a decision provider's answers. nnx.paradigms.offline_distillation trains on those directly:
from nnx.paradigms.offline_distillation import TeacherDataset, evaluate_offline, read_teacher_records
records = read_teacher_records("teacher.jsonl") # nnx.teacher-record/1, strict JSONL
data = TeacherDataset(records, inputs, candidates=("cat", "dog", "fox"), schema_digest=question.digest())
student.train(params=NNTrainParams(train_loader=data.loader(32), ...), objective=data.objective(alpha=0.7))
print(evaluate_offline(student, held_out).text()) # agreement, imitation loss, labelled quality
- Records (
TeacherRecord) carry the sample and question ids, the ordered candidate ids and their probabilities. The probabilities are finite, nonnegative and sum to 1 within1e-6. Records also carry the teacher and its revision, the schema digest and the probabilitysemantics("predictive","calibrated"or"empirical"— never logits). - Provenance. Each record's
provenanceholds where the record came from (source). It also holds the declaredtraining_rightsunder which it may train a student. NNx records the declaration; it does not verify entitlement. A missing field fails validation.read_teacher_recordsraisesTeacherRecordErrornaming thepath:lineof a malformed line (a number too large for a float included). - Alignment.
TeacherDatasetjoins records to inputs by sample id. It aligns every distribution to the student's output order by candidate id, never position. Building the dataset refuses bad input before any optimizer exists. That covers a missing, unknown or duplicate candidate, another schema digest, a repeated sample or a sample with no input. - Objective.
alpha · KL(teacher ‖ student) + (1 − alpha) · CE(student, label), both at temperature 1. No logits are invented and noT²is applied. For example,q = [0.75, 0.25]against zero student logits gives0.130812and the gradientp − q = [−0.25, 0.25]. The objective detaches the teacher's probabilities and never steps. - Labels and accumulation. The two terms share one mask and one denominator per microbatch (its rows), so accumulation is exact. A partly labelled dataset or batch is refused, and
alpha < 1needs every record labelled. Every batch carries its candidate order, which the objective and the evaluation check against their own. - Identity. The objective's identity (version, schema digest,
alphaand the candidate alignment) is checkpointed component state. So a stateful resume with any of them changed is refused before the first resumed update. To record it in the run's provenance too, passobjective.identity()toExperimentManifest.for_model(objective=...). - Reports keep three things apart, each with its own denominator. Teacher agreement and imitation loss cover every record. Labelled quality (accuracy, NLL) covers the labelled records only: imitating a teacher is not being right. In the training loop,
dataset.eval_step()'s recordlossis the imitation loss whateveralpha. Monitorlabel_accuracyorlabel_nllby name to select or stop on quality. - Offline. Records, inputs and the student are local: dataset iteration, training and evaluation reach no network. The live factories,
kd_objectiveandborn_again_trainkeep their logit / temperature contract and refuse these records.
See examples/offline_teacher_distillation.py and Typed decisions §9.
See examples/10_knowledge_distillation.py for a teacher→student distillation flow on a tabular toy task. SimCLR (§10.2) and Mixup / CutMix (§10.3) follow the same factory-plus-train_step_fn pattern.
10.2. SimCLR contrastive¶
The training dataloader must yield (view1, view2) pairs — two augmented views of each source sample. model.net forwards each view separately so BatchNorm sees one view at a time:
step_fn = simclr_train_step_factory(temperature=0.5)
model.train(params=..., train_step_fn=step_fn)
nt_xent_loss(z1, z2, temperature=0.5) is exposed as a standalone for users wanting to compose the loss into other pipelines. Pass temperature by keyword. The augmentation that produces (view1, view2) is the caller's responsibility — a paired-view Dataset is the common pattern.
10.3. Mixup / CutMix¶
Both interpolate within the batch and re-weight the loss. They're train_step factories (not collate_fns) because mixing labels with arbitrary loss functions needs label-aware computation that doesn't fit the standard (X, Y) batch contract:
mixup = mixup_train_step_factory(alpha=0.4) # any input shape
cutmix = cutmix_train_step_factory(alpha=1.0) # 4D (B, C, H, W) only
model.train(params=..., train_step_fn=mixup)
λ ~ Beta(α, α) is the mixing coefficient. The loss is λ · L(f(x'), y_a) + (1-λ) · L(f(x'), y_b). The reported accuracy is the λ-weighted correctness (so accuracy + error == 1). It is useful as a signal for EarlyStopping / ReduceLROnPlateau. CutMix raises on lower-rank input — its spatial cut isn't well-defined without H and W.
11. Parameter-efficient fine-tuning (LoRA, DoRA, IA3, Prefix, Prompt, Adapters)¶
Some pretrained models are too large to fine-tune in full. PEFT keeps the original weights frozen and trains a small set of new parameters instead. nnx.peft ships six adapters. They range from rank-decomposed residuals (LoRA) down to a single per-output scaling vector (IA3). §§11.1–11.2 walk through LoRA and AdapterLayer in detail. The others share the same wrap-and-freeze idiom; see API §9 for full signatures.
- DoRA (
DoRALinear/apply_dora_to) — subclass ofLoRALinearadding a trainable per-output-rowmagnitudevector. It recomposes the layer's weight asW = magnitude · V / ||V||_c, following Liu et al. (2024). It adds onlyout_featuresparameters per wrapped layer. Save/load sharessave_lora_weightsfor thelora_A/lora_Bmatrices. Themagnitudeparameter rides along via the fullstate_dict()round-trip. Persist a DoRA adapter with the completestate_dict(), since the LoRA-only helpers omitmagnitude. - DoRA precision. The row normalization runs in FP32 for FP16/BF16 layers (FP64 keeps FP64). The effective weight is cast back to the layer dtype. A row of
Vmay be zero or tiny, from pruning, explicit zero init or a zero row in a loaded base. Such a row gives a finite output equal to the base at initialization instead of NaN. Learned nonzero updates stay differentiable. - DoRA scope. DoRA's DropConnect-style dropout on the composed update is unchanged. The precision handling covers the normalization arithmetic only. It does not promise that every optional mixed-precision kernel is supported.
- IA3 (
IA3Linear/apply_ia3_to) — the smallest adapter in the family: a single learned per-output-dim scaling vector applied to a frozennn.Linear's output. Dedicatedsave_ia3_weights/load_ia3_weightspersist only the scaling tensor. - PrefixTuner (
PrefixTuner/save_prefix_weights/load_prefix_weights) — prepends a learned key/value prefix to every attention layer of a frozenTransformerNN. The wrapped model is mutated in place. Every parameter is frozen, and each targeted block's attention forward is monkey-patched to inject the prefix. (A forward hook fires on outputs, too late to reach the intermediate K/V.) Wrapping an already-tuned net raises;copy.deepcopythe tuner to fork it. - PromptTuner (
PromptTuner/save_prompt_weights/load_prompt_weights) — prepends learned soft-prompt embeddings ahead of the input tokens of a frozenTransformerNN. Cheapest of the LM-targeted PEFT methods; useful when even the rank-decomposed LoRA budget is too large.
Placement. Every adapter allocates its new parameters from the tensor it composes with, so they inherit that tensor's dtype and device:
lora_A/lora_B(and DoRA'smagnitude) from the wrappedLinear's weight, and IA3'sscalinglikewise;- the soft prompt from the wrapped model's token embedding;
- each prefix layer's K/V from that block's attention projection.
When the projection was adapted first, the prefix uses the Linear inside its LoRALinear / DoRALinear / IA3Linear wrapper. That adapter stays in the attention path and is frozen with the rest of the model.
Convert or move the base first, then wrap. A half or double model, a model already resident on an accelerator, or a meta-device skeleton composes immediately. No corrective .to() is needed after wrapping, and the base is never moved, recast or replaced.
Adapter-only load_*_weights into an already-converted destination keeps the destination's placement (load_state_dict converts on copy). Whether a particular kernel exists for a dtype on a device (for example half matmuls on CPU) remains a separate, per-backend validation gate.
Train/eval mode. Wrappers inherit the mode of what they wrap at insertion time, per layer:
LoRALinear/DoRALinearcopy the wrappedLinear'strainingflag onto the wrapper and itslora_dropout.IA3Linearcopies it onto the wrapper.PromptTuner/PrefixTunercopy the wrappedTransformerNN's flag onto the tuner (and its prefix lists).
Injecting a nonzero-dropout LoRA or DoRA adapter into a model that is already in eval() therefore keeps inference deterministic. A parent with mixed child modes keeps each replaced child's own mode. Neither direct construction nor apply_*_to ever calls .train() / .eval() on the surrounding model.
Modes are runtime state, not configuration. Nothing about them is serialized in state_dict() or an adapter file, and loading full or adapter-only state leaves the caller's modes alone. A later model.train() still activates LoRA input dropout and DoRA's update dropout. The inference helpers (predict, evaluate, generate) snapshot and restore every submodule's flag, wrappers included.
Switching into train mode for fine-tuning is the training step's job. The built-in supervised step and every nnx.paradigms factory call net.train(). A custom train_step_fn / trainer_step_fn owns the mode itself. Suppose it never calls train(). Then adapters injected into an eval() model keep their eval behaviour while training, like any other Dropout / BatchNorm in the net.
11.1. LoRA — low-rank adaptation¶
LoRALinear wraps an nn.Linear and freezes the original weight. It adds two trainable matrices A (r × in) and B (out × r) whose product is added as a residual:
y = W·x → y = W·x + (α/r) · B(A(x))
A is Kaiming-uniform initialized; B is zero-initialized, so the layer's output at step 0 is exactly W·x. Fine-tuning starts from the pretrained behavior and diverges only as B picks up gradient. The same initialization story as adapters' zero-init up projection.
apply_lora_to(module, *patterns, r, alpha, dropout) walks a module and replaces every nn.Linear whose dotted name matches any glob with a LoRALinear wrapper. Returns the count. The match patterns are the same fnmatch globs as freeze from §7.
from nnx import NNModel, apply_lora_to, save_lora_weights
model = NNModel(net_params=..., params=...)
model.train(params=pretrain_params) # full pretraining first
n_wrapped = apply_lora_to(model.net, "layers.*", r=4, alpha=8.0)
# Now every wrapped layer's base weight is frozen (requires_grad=False);
# only the lora_A / lora_B matrices train. Optimizer sees all params, but
# the frozen ones don't move.
model.train(params=finetune_params) # fine-tune — only LoRA params update
save_lora_weights(model.net, "lora.pt") # tiny checkpoint, lora_A/B only
The apply_lora_to mutation is idempotent. A second call against patterns that already match LoRA-wrapped layers is a no-op (the inner .base is excluded from the walk).
Shared modules. apply_lora_to, apply_dora_to and apply_ia3_to match patterns against every registration path, not only the first one named_modules() reports. They group paths by registration slot: the (parent, attribute) pair a wrapper replaces. A Linear inside a container that is itself registered under several names has one slot. It is selected when any of its paths matches, wrapped once and counted once, and every path sees the same wrapper.
A Linear that its parent reads directly is refused: the out_proj of nn.MultiheadAttention and the linear1 / linear2 of torch's Transformer layers. Select other projections.
A Linear may be registered directly under more than one name. Examples are m.a = m.b = linear, or a layer that is both the .base of an existing wrapper and registered elsewhere. It cannot be wrapped without splitting it into independent layers. So selecting any of its names raises a ValueError naming every alias.
The whole matched set is validated before the first wrapper is built, because construction freezes the base. So a rejected call leaves every module identity, weight, requires_grad flag and build_param_groups membership unchanged. An unselected alias never blocks an independent target. Register the layer once, or select only unaliased layers and retry.
Tied tensors between distinct modules are not aliases and are outside this check; TransformerNN's tied embedding / output head is an example. But wrapping either module freezes the shared tensor for both. So wrapping the tied output head also stops the token embedding from training. The returned count is the number of new wrappers. To preview what a pattern selects, list every path with named_modules(remove_duplicate=False).
save_lora_weights(module, path) writes only the LoRA parameters, typically a small percentage of a full state_dict for the same model. That is single-digit % for production-scale nets with r=4-8, and closer to ~40% on the tiny demo net where r/dim is large. load_lora_weights(module, source) loads them back into an already-wrapped module via load_state_dict(strict=False). So the frozen base's missing-from-the-checkpoint keys don't raise. Source can be a path or a state-dict dict.
Every adapter-only save/load helper selects tensors by registered ownership, never by a substring of the qualified name. The helpers are:
save_lora_weights/load_lora_weights: thelora_A/lora_Bof eachLoRALinear;save_ia3_weights/load_ia3_weights: thescalingof eachIA3Linear;PromptTuner.prompt_state_dict/load_prompt_weights: the tuner'ssoft_prompt;PrefixTuner.prefix_state_dict/load_prefix_weights: the tuner'sprefix_keys.i/prefix_values.i.
A layer merely named lora_A_projection or scaling_projection therefore never leaks its frozen base.* tensors into an adapter file. A full checkpoint handed to a loader cannot overwrite a frozen base tensor.
The allowlist is built from the destination, and the loaded count reports only accepted adapter tensors. A wrong-shaped owned key still raises the native size-mismatch error. An adapter file is the trainable delta only, not a resumable model checkpoint. DoRA's magnitude, for instance, still needs the full state_dict().
After wrapping, parameter names gain a .base. segment: layers.0.weight becomes layers.0.base.weight. Code that did model.net.layers[0].weight should switch to model.net.layers[0].base.weight or use model.net.layers[0].base for the wrapped Linear.
11.2. Adapter layers¶
AdapterLayer(dim, bottleneck, activation=nn.GELU) is a bottleneck residual: y = x + up(act(down(x))) with up zero-initialized so the layer starts as the identity. Unlike LoRA, adapters are full modules the caller composes into the forward pass. There's no apply_adapters_to(module) helper, because "where to insert" depends on the architecture: after each Linear, after each block, or only at certain depths.
from nnx import AdapterLayer
class AdaptedNet(nn.Module):
def __init__(self, pretrained_layers):
super().__init__()
self.layers = pretrained_layers
self.adapters = nn.ModuleList([
AdapterLayer(dim=64, bottleneck=8) for _ in pretrained_layers
])
def forward(self, x):
for layer, adapter in zip(self.layers, self.adapters):
x = adapter(layer(x))
return x
See examples/07_lora_finetuning.py for an end-to-end LoRA flow that explicitly verifies every base parameter is bit-exactly unchanged across the fine-tuning run.
12. Model-internals visualization¶
nnx.vis_utils covers run-output viz (training curves, confusion matrices, t-SNE of checkpoint logits). Every figure here is Plotly. VisUtils, activation_map, weight_histogram, gradient_flow, attribute and nnx.lr_finder need the plots extra (the viz extra includes it). They say so in an ImportError before any work runs. VisUtils.classification_report (a DataFrame) and summary do not need it.
The companion nnx.viz subpackage covers model-internals viz — the model itself, not what the run produced. Six primitives ship today: summary, weight_histogram, activation_map, attribute, gradient_flow, and netron_export.
nnx.viz.summary(model, input_size=...) returns a torchinfo.ModelStatistics — print it for the Keras-style parameter table; access .total_params / .trainable_params / .total_mult_adds for programmatic regression assertions. Accepts an NNModel (unwrapped to .net) or any torch.nn.Module. Requires the optional viz extra (pip install "thekaveh-nnx[viz]" pulls in torchinfo).
nnx.viz.weight_histogram(model) walks model.named_parameters() and emits one Plotly Histogram trace per tensor in a grid subplot. Useful for spotting dead layers, NaN / Inf weights, or saturation patterns at a glance.
nnx.viz.activation_map(model, x, layer_name) registers a forward hook on the named submodule and runs model(x) under torch.no_grad(). It returns a Plotly heatmap. For 4D conv activations (N, C, H, W) that is a grid of per-channel heatmaps; for 2D dense activations, a single (N, F) heatmap. Pass a dotted name from model.named_modules() ("layers.0", "conv1", etc.). A typo raises ValueError and lists the first available names.
nnx.viz.attribute(model, x, *, method, target, **method_kwargs) is a Captum-backed input-attribution wrapper with single string-keyed dispatch over six methods: integrated_gradients, gradient_shap, deep_lift, saliency, input_x_gradient, occlusion. Returns (attribution_tensor, plotly.Figure). The figure renders the attribution as a Plotly heatmap. A 4-D input shows its first sample, mean-pooled over channels; a 3-D input shows its first sample; a 2-D input shows one row per sample.
Captum is lazy-imported at the call site, so summary and netron_export keep working without it; the missing-dep path raises a clear ImportError. Sensible per-method defaults (baselines=zeros for GradientShap, sliding_window_shapes for Occlusion) preserve the one-call ergonomics. Requires the viz extra (pip install "thekaveh-nnx[viz]" pulls in captum alongside torchinfo).
nnx.viz.gradient_flow(model) is the diagnostic for vanishing / exploding gradients during training. Call after loss.backward() and before optimizer.zero_grad(); returns a Plotly bar chart of per-trainable-parameter L2 gradient norms. Frozen parameters (requires_grad=False) and parameters that weren't reached by the forward pass (gradient is None) are skipped. Raises ValueError with a helpful message if no parameter has a gradient — the usual cause is forgetting loss.backward().
nnx.viz.netron_export(model, "model.onnx", example_input) exports the underlying network via torch.onnx.export so the artifact can be opened in Netron; the export needs the onnx extra. Passing launch=True additionally calls netron.start(path) to open the browser viewer; that path requires the viz-interactive extra (pip install "thekaveh-nnx[viz-interactive]").
import torch
from nnx import NNModel, NNParams, NNModelParams, Activations, Devices, Losses, Nets
from nnx.viz import activation_map, netron_export, summary, weight_histogram
model = NNModel(
net_params=NNParams(input_dim=8, output_dim=3, hidden_dims=[32, 16],
dropout_prob=0.1, activation=Activations.RELU),
params=NNModelParams(net=Nets.FEED_FWD, device=Devices.CPU,
loss=Losses.CROSS_ENTROPY),
)
print(summary(model, input_size=(1, 8))) # Keras-style parameter table
weight_histogram(model).show() # Plotly grid of per-tensor weight distributions
activation_map(model, torch.randn(4, 8), "layers.0").show() # batch x features heatmap
netron_export(model, "model.onnx", torch.randn(1, 8)) # write graph for Netron
13. Reproducibility¶
from nnx import set_seed, dataloader_worker_init_fn
set_seed(42, strict=True) # pins torch / numpy / python / cudnn
loader = DataLoader(..., worker_init_fn=dataloader_worker_init_fn)
NNTrainParams(seed=42, ...) # pins again inside train()
seed reseeds the global streams only. A loader or sampler that owns a
torch.Generator (for example the NeighborLoaders of
NNGraphDataset(seed=...)) continues from its last state across train()
calls. Rebuild it, or reseed its generator, before each fit for the same
shuffle order.
strict=True opts into torch.use_deterministic_algorithms(True). It is slower and may raise on ops without a deterministic CUDA kernel. But it produces bit-for-bit identical training across runs on the same hardware.
13.1. LR finder¶
Before a long training run, run nnx.lr_finder to pick a defensible max_lr for a one-cycle scheduler. The sweep is non-destructive. Model state, every submodule's train/eval mode, loader generators, and Python/NumPy/PyTorch RNG streams are restored on exit. So the real run starts as though the sweep never happened.
from nnx import lr_finder
import torch.nn.functional as F
result = lr_finder(
model.net, train_loader,
loss_fn=F.cross_entropy,
start_lr=1e-7, end_lr=10.0, num_iter=100,
)
print(f"Suggested max_lr: {result.suggested_lr:.2e}")
result.figure.show()
suggested_lr is the LR at the steepest descent point of the EMA-smoothed loss curve — the Smith (2017) heuristic. Plug it into NNOptimParams.max_lr for the real training run.
The sweep stops early when the loss diverges. That happens when the smoothed loss rises more than (diverge_threshold - 1) × |lowest| above the lowest smoothed loss so far. diverge_threshold defaults to 4.0 and must be above 1. For a positive loss, that is above diverge_threshold times the lowest.
lr_finder needs the plots extra (pip install "thekaveh-nnx[plots]"), because its result includes a Plotly figure. Without the extra it raises ImportError before it touches any data, model or RNG state.
13.2. Non-destructive contract for inference and inspection helpers¶
lr_finder isn't the only helper that snapshots and restores caller state. These NNx call sites share the same non-destructive contract. lr_finder forces train() for its sweep; the others put the underlying nn.Module into eval() mode for the duration of the call:
nnx.lr_finderNNModel.predict,NNModel.predict_proba,NNModel.iter_predict,NNModel.evaluateGenerativeNNModel.generatennx.diffusion.samplennx.embeddings.embed_textsnnx.viz.activation_map,nnx.viz.netron_export,nnx.viz.attribute,nnx.viz.summary(batch=...)NNModel.to_onnx
Each restores every module's original mode rather than flattening a mixed-mode tree to the root flag. Restoration runs inside try/finally, so the contract holds even when the body raises mid-call.
A train → evaluate → train-more (or train → predict → train-more) loop therefore never strands the model in .eval() mode after the helper returns. Dropout and BatchNorm pick up exactly where they left off on the next training step.
13.3. Reproducible splits: group, time and stratified¶
By default, NNTabularDataset and NNDataset split with random_split. It
uses the dataset's seed when one is given, else the global torch RNG. That
suits independent rows, but rows of one patient can straddle
train and test, and future rows can leak into training.
nnx.data_splits.plan_split decides membership explicitly and
returns a SplitManifest:
from nnx import NNTabularDataset
from nnx.data_splits import SplitManifest, plan_split
from nnx.provenance import hash_file
source = hash_file("rows.csv")
plan = plan_split(df["row_id"], strategy="group", groups=df["patient"],
proportions=(0.7, 0.15, 0.15), seed=7, source=source)
plan.save("split.json")
dataset = NNTabularDataset(df=df, feature_cols=["age", "income"], target_col="label",
split=SplitManifest.load("split.json"), id_col="row_id",
source_identity=source)
- Sample ids. Memberships are stable sample ids (
strorint, one type per plan), stored sorted and disjoint. Planning and replay depend only on the ids and their groups, times or labels. So rows in another order plan and replay into the same splits.NNTabularDatasetreads the ids from the requiredid_colcolumn, never from the index, which could match the wrong rows. - Positional plans. A plan made without
idsrecords row positions. Soplan_splitrequires asourceidentity for it, and replay checks that identity. A positional-only manifest (one with no identity) raises on replay. - Strategies.
group: every group lands in exactly one split. Groups are visited in a seeded order and each goes to the active split furthest below its row target. An active split that ends up empty takes the smallest group from the split holding the most groups, and fewer groups than active splits raise. Row counts approximateproportions.chronological:cutoffs=(validation_start, test_start). Train ist < validation_start, validationvalidation_start <= t < test_start, testt >= test_start. A timestamp on a cutoff starts the later split, so tied rows never separate.chronologicalgaps:gapexcludes the rows withcutoff - gap <= t < cutoff. They are recorded inexcluded, are in no loader, and are never admitted or counted; a NaN or an extra label there is ignored. A sample-id plan's excluded rows may also be absent from the replayed source.chronologicaltimes: numbers, dates, or naive or tz-aware datetimes (numpy.datetime64included), one kind per plan, with atimedeltagap (whole days for dates). Either cutoff may beNone.stratified: per class, ids are ranked by seed and cut by the largest remainder. The rounding is carried across classes, so the totals followproportions. Every class reaches every active split. That one-row minimum is applied after the carry. So it can add a small class's rows to validation / test but never skews other classes.stratifiedsmall classes: a class with fewer rows than active splits raisesSplitError(insufficient="raise", the default). Withinsufficient="train"it stays in training only, which is recorded in the parameters.
- Seeds, never global RNG. Seeded order is the SHA-256 of the seed and
the id, stable across processes and library versions.
plan_splitreads and advances no torch, NumPy or Python RNG, andseed=Nonedraws a seed from the OS and records it. Chronological plans take no seed. - Source identity.
source=records anIdentityRef: ahash_filedigest, a declared name, or unknown. When a plan records one,SplitManifest.resolveandNNTabularDataset(source_identity=...)require the same identity back. Passing an identity to a plan that records none raises too, since nothing could be checked. Duplicate, missing or unexpected ids, ids of another type and a changed identity raiseSplitErrorbefore any tensor or loader exists. - Empty optional splits. A zero proportion, a
Nonecutoff or a window with no rows leaves validation or test empty.NNTabularDatasetthen publishes aNoneloader, the same contract asval_proportion=0.0. An empty train split always raises, inplan_splitand when a hand-built (strategy="explicit") or edited manifest is replayed. - Identity and serialization. A manifest is JSON (
to_json/save/load, formatnnx.split/1) and reloads equal.digest()covers the memberships, strategy, parameters, seed and source.ExperimentManifest(splits={"main": plan})(§4.4) records that digest as a verified identity, which stays the same after reload and replay. - Default behaviour. Without
split=, datasets callrandom_splitwith the dataset'sseed, or the global torch RNG when it isNone.NNTrainParams.state()and run ids gain nothing, and a dataset'sstate()gainssplit(the digest) only when a manifest is used. - Graph edges are excluded. Plans split samples (rows, images, graph
nodes), never edges.
NNGraphDatasetkeeps its node masks, and link-prediction edge splitting (message-passing vs supervision edges) isnnx.link_tasks(§26). For node classification, plan over node ids and build masks fromresolve(...). For any other map-style dataset,plan.resolve(ids)returnsSplitIndicesfortorch.utils.data.Subset.
See examples/split_replay.py.
13.4. Preprocessing: raw and transformed views¶
Statistics fitted on every row leak held-out values into training.
nnx.preprocessing fits on the training membership only and keeps
the raw data untouched.
- Train-only fit.
Standardizer.fit(source, rows=..., columns=...)requires the training rows as integer positions and reads no other row. DataFrames are read throughiloc, tensors and arrays by index, and any row-indexable source item by item. Statistics are the float64 population mean and standard deviation. A constant column (all training values equal) keeps that exact value as its mean and gets scale 1. - Fit membership. The default
membershipis a digest of the positions; pass aSplitManifestor an identity to name it instead. - Dataset standardization.
NNTabularDataset(standardize=True)fits on its training split after the split and exposesds.standardizer. The split is random orsplit=, with the same membership and RNG draw as without standardization.standardize=<fitted Standardizer>applies one without refitting. Its columns must equalfeature_colsand its dtypefeature_dtype, which is checked before the split. Only the training rows of the feature columns are read; the raw features are never converted. - Raw vs transformed views. The source DataFrame is never modified, and
each loader holds prepared features.
NNModel.predicttakes prepared features, so at inference apply the reloaded standardizer exactly once:model.predict(standardizer.transform(raw_rows)). - Declared schema.
columnsfixes the names and their order,dtypethe output dtype.transformaccepts a DataFrame holding the fitted columns in the fitted order; other columns are ignored. It also accepts a 2-D tensor or array of the fitted width. Missing or reordered columns, a width mismatch, non-numeric values and non-finite values raisePreprocessingErrorbefore a model sees the data. Column labels arestrorint, since the schema is JSON. - Single rows. Calling the standardizer on one row gives exactly the
batch result. A pandas
Seriesrow is checked by name; any other 1-D row is positional. - Schema identity. Statistics, schema and the fit-membership identity
are primitive JSON (
nnx.preprocessing/1) that reloads float64-exact.digest()identifies them, and loading rejects bad lengths, non-finite values and non-positive scales.ExperimentManifest.for_model(..., preprocessing=standardizer)(§4.4) records the fitted state, never recomputed statistics. - Split transforms and target preservation.
NNDataset(train_transform=..., eval_transform=...)wraps each split in aSplitViewover the base dataset. The basetransformstill applies first. The view's transform then applies to the input only. So targets and sample ids (view.indices) pass through unchanged, and tuples, named tuples and lists keep their type. Dict samples are rejected, because which entry is the input is ambiguous. - View attributes.
view.datasetaliases the base, as forSubset, andview.classesforwards the base's class names. AStandardizeralso works as a view transform on single rows. The base dataset's owntransform, labels and order are never modified. So a random training augmentation cannot leak into evaluation. - Worker behaviour. Views hold no mutable state, so alternate training /
evaluation reads and multi-worker
DataLoaders return the same evaluation samples as single-process loading. A random training transform draws from each worker's own torch RNG, seeded by theDataLoaderas usual. - Reconstruction. A
Standardizerrebuilds from its state. Any other transform is a runtime callable.describe_transform/SplitView.state()record it by qualified name withreconstructible: false. So it must be registered (passed) again to rebuild the view. - Defaults. Without these options datasets apply no standardization or
split transforms, and
state()gainsstandardizer/train_transform/eval_transformonly when they are used.
See examples/preprocessing_offline.py.
14. Resuming training¶
# First run
run = model.train(params=NNTrainParams(n_epochs=10, ...))
# Continue from LAST (restores optimizer, scheduler, scaler, epoch, and RNG state)
NNModel(net_params=..., params=...).train(params=NNTrainParams(
n_epochs=10,
resume_from_run_id=run.id,
resume_from_checkpoint="last",
...
))
A checkpoint resumes only when its generation-addressed sidecar matches the checkpoint's recorded generation and optimizer topology. For a registered optimizer factory, the sidecar must also match its recorded id, version and config (§3.1).
Checkpoints written before resume support do not carry a versioned .opt.pt bundle. A checkpoint with no sidecar restores its weights only, and epoch numbering continues after its epoch. The optimizer, scheduler, scaler and RNG state start fresh, with a warning. A legacy optimizer-only sidecar also restores the optimizer state. If applying any resume state fails, NNx restores the model and all RNG streams before re-raising.
14.1. Component state¶
Anything else that carries state across epochs registers as a component (nnx.components). Examples are a callback's patience, the I-JEPA EMA target encoder and a custom step's running statistics. A component has a component_spec() returning ComponentSpec(name, version, required=True) and a component_state() / load_component_state(state, *, version) pair.
from nnx import ComponentSpec
class RunningMean:
def component_spec(self):
return ComponentSpec("my.running_mean", version=1)
def component_state(self):
return {"mean": self.mean, "count": self.count}
def load_component_state(self, state, *, version):
self.mean, self.count = state["mean"], state["count"]
model.train(params=..., callbacks=[EarlyStopping()], components=[running_mean])
NNModel.train and Trainer.train discover components among the callbacks and the step function. They take further ones through components=[...]. An explicit entry that does not implement the protocol raises TypeError; one object passed twice registers once.
Names must be unique. The exception is a spec marked numbered=True (the built-in callbacks' default names), which is numbered name.2, name.3, … in registration order.
A component may also define check_component_state(state, *, version), returning a list of problems. An example problem is a configuration that does not match the saved state. These problems join the pre-restore report below.
Each component's state is written into the same sidecar generation as the checkpoint. A model and component state from different generations are therefore never combined. On resume:
- Every saved component is validated against the registered ones before anything is mutated. One
ComponentRestoreErrorreports all of these problems together:- a missing required component;
- an unknown required component in the checkpoint;
- a saved schema version newer than the component's;
- every
check_component_stateproblem.
- Reset hooks (
Callback.on_train_begin) run once. - The saved states are loaded transactionally. If any load fails, every component already restored returns to its pre-call state, the model and RNG streams are restored, and the original error propagates. A rollback that itself fails (for instance, because a reset hook rewired the network) is reported as a
RuntimeWarninginstead of replacing the original error. - The first resumed epoch runs.
Built-in components include EarlyStopping (early_stopping, optional), the step returned by jepa_train_step_factory (jepa.target_encoder, required) and the update engine's counters (nnx.update_engine). nnx.components documents the component protocol. State must be torch.load(weights_only=True)-safe: tensors, numbers, strings, booleans, None and containers of those.
Sidecars written before component state existed still load. The model, optimizer, scheduler, scaler, epoch and RNG resume as before, and every component starts fresh with a warning.
14.2. Resume modes and status¶
resume_mode (on NNTrainParams and NNTrainerParams, runtime-only) chooses what a resume restores:
"auto"(default) restores the complete training state when the checkpoint has it. Otherwise it restores the weights, with a warning."stateful"requires the training state. It fails before restoring anything when the checkpoint is weights-only (aModelCheckpointfile, for example)."weights_only"loads only the model weights and starts the optimizer, scheduler and every component fresh. Epoch numbering continues after the checkpoint's epoch, and a one-cycle schedule needs no shared horizon.
Any other resume of a one-cycle or warmup-decay schedule needs one shared horizon. That is an explicit total_steps covering the original and the resumed epochs (updates, on the optimizer_update clock). A warmup-decay schedule on the epoch clock also needs an explicit warmup_steps, unless the resume uses resume_epochs="planned". Its default is a tenth of each session's n_epochs. Both are checked before the checkpoint is read.
The returned run reports what happened in run.resume_status, a ResumeStatus. It holds mode ("fresh", "stateful" or "weights_only"), the source run and checkpoint, that checkpoint's epoch (source_epoch), and the restored and freshly started component names. metadata.yaml stores it under resume, so NNRun.load(id).resume_status reports it too. It is never part of the run id.
14.3. Exact resume to the planned horizon¶
A stateful resume restores the model, optimizer, scheduler, scaler, every
random stream and every component. The random streams are Python, NumPy,
torch, CUDA / MPS and the loaders' generators. A run split at an epoch
boundary therefore continues bit for bit, ReduceLROnPlateau included. This
holds for deterministic single-process training with in-process loaders
(num_workers=0).
resume_epochs (on NNTrainParams) chooses what n_epochs means on that
resume:
"additional"(default) trainsn_epochsmore after the checkpoint's epoch."planned"continues the run's own plan and trains up ton_epochsin total. It carries on the logical step counter (iter_idx), the committed updates (ctx.committed_updates) and the phase-checkpoint cadence. A budgeted search's update cap therefore counts the source's updates too. Anoptimizer_updatescheduler keeps its one horizon over the whole plan.
"planned" needs a stateful resume from a checkpoint that records its plan.
NNx refuses these cases before anything is restored:
- a weights-only resume;
- a checkpoint written before resume counters;
- a source that planned a different total;
- a source that has already finished its plan.
resume_epochs is part of the run id (it changes what trains) and omitted
from state() at its default. A Trainer always resumes "additional".
An experiment plan asks for it with
resuming(run_id, mode="stateful", epochs="planned") (§20); epochs
defaults to the training parameters' own resume_epochs.
plan = NNTrainParams(n_epochs=4, train_loader=train_loader, val_loader=val_loader, seed=0)
first = model.train(params=plan, callbacks=[stop_after_epoch_1]) # epochs 0-1, then interrupted
rest = NNModel(...).train(params=replace(
plan, resume_from_run_id=first.id, resume_mode="stateful", resume_epochs="planned"
)) # epochs 2-3: equals 4 straight epochs
Resume points. Every checkpoint is one resume point. It consists of the
checkpoint file, its generation-addressed training-state sidecar and a
manifest (<tag>.pt.manifest.json, written last).
The manifest holds the tag's generation ordinal, the checkpoint_id, the
logical position and the SHA-256 of each file. The logical position is
completed_epoch, global_step, committed_updates and planned_n_epochs.
Each digest is computed as the file is written. A save never reads a file back
to hash it; only a resume's verification reads the files.
Files are flushed to stable storage before they are renamed into place.
The directory is flushed after. macOS uses fcntl(F_FULLFSYNC), because its
fsync only reaches the drive's cache. Linux and other systems use
os.fsync. On both, a point whose manifest says it is complete survives a
power loss, as far as the drive honours the flush.
A filesystem that does not support the flush does not fail the save. The
point is then only as durable as that filesystem. A real I/O error (EIO, a
full disk) does fail the save. On Windows the files are flushed with
os.fsync, but the renames are not, because a directory cannot be flushed
there. These rules cover resume points. The run's other files (run.yaml,
history, bundles) use os.fsync.
The manifest is staged before the checkpoint is published and made live right after. A Ctrl-C in that window is held until the point is complete; a second Ctrl-C stops at once. A writer that dies inside the window leaves a point whose staged manifest matches every file. Verification then reads that staged manifest rather than refusing the point. The run's next save makes the manifest live.
Verification writes no checkpoint data. It only takes the checkpoint's
<tag>.pt.lock file, which it creates when missing.
A resume verifies the point first with NNCheckpoint.verify(run, type). It
refuses with ResumePointError a point that is:
- torn: a listed file is missing, or the manifest names another checkpoint;
- mixed: its files are of different generations;
- corrupted: a digest does not match.
NNCheckpoint.resume_point(run, type) reads the manifest. A checkpoint
written before manifests loads as before. Stopping (should_stop, an
interrupt) leaves the last committed point intact.
Identity and lineage. A resume writes no data to its source; like
verification, it takes the source checkpoint's lock file. The resumed
session's run id hashes its parent (parent_run_id, parent_checkpoint).
run.resume_status records the source run, checkpoint, epoch,
source_generation and source_checkpoint_id. It is stored in
metadata.yaml under resume, never in run.yaml, whose content is the
run id.
A second resume of the same parent with the same configuration maps to the
same child id. This includes a retry after a failed child. To keep both, give
it a salt= (or a data_id). To replace the child, pass
overwrite_existing=True.
Callback and scheduler state. A callback carries state across a resume
by implementing the component protocol (§14.1: component_spec,
component_state, load_component_state). This protocol is NNx's
counterpart of a state_dict / load_state_dict pair. A callback reads the
training scheduler through ctx.scheduler. It is read-only, and it is
None in a Trainer, whose schedulers are named.
15. Generative language modeling (TransformerNN + GenerativeNNModel)¶
The decoder-only LM path adds four abstractions that sit alongside NNModel
rather than replacing it:
TransformerNN— a small decoder-only transformer (RMSNormpre-norm, rotary positional embeddings, SwiGLU FFN, tied input/output embeddings, fused QKV projection). Sits at the same level asFeedFwdNN/GraphConvNN/GraphSageNN/GraphAttNNand is selected viaNets.TRANSFORMER.NNTransformerParams— a params subclass that adds the LM-specific shape knobs (vocab_size,n_layers,d_model,max_seq_len,ffn_mult,rope_base,tie_embeddings,attn_dropout,resid_dropout). Every optional field omits itself fromstate()at default, the invariant covered in §2.2.NNTokenizerParams— a frozen dataclass that holds the tokenizer and the path of its saved JSON file. It has no shape knobs.-
GenerativeNNModel— a thin subclass ofNNModeladdinggenerate(prompt, *, max_new_tokens, temperature, top_k, top_p, repetition_penalty, stop, seed, use_cache, logits_chain, on_token, logits_pipeline). The generation loop runs the prompt throughTransformerNN.forward_with_cachefor a single prefill step. It then decodes token-by-token using the returned KV-cache. The fixed 256-token CPU regression test requires at least a 1.2× improvement over full recomputation; other workloads and hardware vary.Greedy decoding is available via
temperature=0; the defaulttemperature=1.0samples the full softmax. The scalar kwargs build the standard processor chain.logits_chain=LogitsChain.builder()...is the power-user path for customnnx.generation.LogitsProcessors (see §2.4).
The LM path stays optional behind the lm extra (pip install "thekaveh-nnx[lm]" —
pulls tokenizers + datasets); the rest of NNx works without it. See
docs/lm.md for the end-to-end walkthrough and
examples/11_tinystories_lm.py
for a CPU-friendly TinyStories training run.
Training an LM: the causal-LM task. nnx.lm_tasks.CausalLMTask
declares how a batch becomes next-token targets and checks every batch before
the forward pass. It declares:
- the alignment:
"shift_inputs"shifts token ids once;"pre_shifted"takes aligned(inputs, targets); - the vocabulary, the ignore id and an optional padding id;
- objective-only label smoothing and a version. A loss mask, when used, comes with each batch.
batch ──split()──► inputs, targets, loss_mask ──valid()──► one set of valid positions
task.objective(): LossTerm("token_ce", Σ CE over valid, denominator=#valid) # the shared update engine
task.eval_step(): loss = metrics["nll"] = Σ NLL / Σ #valid (whole loader), perplexity = exp(nll)
The objective's denominator, the NLL and the token accuracy share the valid
positions. Accumulation therefore normalizes by valid tokens, an all-masked
window steps nothing, and epoch NLL is independent of batching and right
padding. The model never receives the attention mask. A mapping batch whose
attention_mask has padding before a real token (left padding) is refused.
pad_id alone does not detect left padding, so right-pad the batch.
Reported NLL and perplexity are unsmoothed. An all-masked validation epoch is
unavailable rather than zero, and no classification field is fabricated. The
task's configuration is checkpointed component state ("lm.causal_task").
A resume with another configuration is therefore refused before the first
resumed update. See docs/lm.md §6.
Downstream of the LM path, four follow-ons compose on top of it:
- PEFT for transformers —
PrefixTuner/PromptTuner(see §11) attach learned key/value or input-embedding prefixes to a frozenTransformerNNfor parameter-efficient adaptation. - DPO —
dpo_train_step_factory(seedocs/dpo.md) fine-tunes aTransformerNNagainst(prompt, chosen, rejected)preference triples. It uses the Rafailov et al. 2023 chosen-vs-rejected log-ratio objective, with no reward modeling or RL. - Experimental GGUF export —
nnx.interop.write_ggufwrites a structurally readable.gguftaggednnx_transformer; fused QKV and SwiGLU tensors are split into stable NNx names. Stock llama.cpp, Ollama, and LM Studio do not implement this architecture, so the artifact is for inspection or a runtime patched for NNx.export_ollama_modelfileonly generates the adjacent bundle files; it does not make the architecture executable by stock Ollama. Seedocs/gguf.md. - HuggingFace Hub publish — via the
PyTorchModelHubMixinintegration onNNModelitself (any subclass inherits it). Seedocs/hub.md.
16. Tier-2 subpackage deep-dives¶
Four Tier-2 subpackages are large enough to warrant a dedicated section but small enough that the canonical write-up lives elsewhere. This catalog is the pointer index; open the linked page for the full walkthrough.
nnx.quantize provides PTQ INT8 weight-only (quantize_int8(model)) and QAT 8da4w (qat_train_step_factory + QATLifecycleCallback), both built on torchao. Opt-in via pip install "thekaveh-nnx[quantize]".
- The PTQ path is one call, with no calibration data and no retraining. It returns an instance of the same class it was given, with only
netreplaced by the quantized deep copy. AGenerativeNNModelkeeps its tokenizer andgenerate; user subclasses keep their overrides and attributes. - The QAT path is a paradigm-style
TrainStepFnfactory that fake-quants during training and converts on commit. - A converted
LASTcheckpoint records itsqat_configandgroupsize. The ordinaryNNModel.from_checkpoint()path therefore reconstructs quantized inference without consumer-side prepare/convert calls. Legacy converted checkpoints without that recipe fail with a targeted migration error. - See API §12 for the full surface, and
examples/12_quantize_int8.pyandexamples/15_qat_classifier.pyfor end-to-end runs.
nnx.prune provides magnitude_prune (mask-based unstructured, checkpoint-safe) and semi_structured_24 (2:4 semi-structured via torchao for Ampere+ inference). The bake=True default keeps state_dict keys identical to the un-pruned net, so pruned checkpoints load into stock code under strict=True. See API §10. examples/19_prune_synthetic_classifier.py ships an end-to-end magnitude-prune-and-fine-tune demo on synthetic classification data.
With bake=False the masks stay. NNModel.train refuses a built-in or registered net that still carries them, or a weight_norm / spectral_norm parametrization, because its checkpoints could not be reloaded. Remove masks with torch.nn.utils.prune.remove and parametrizations with torch.nn.utils.parametrize.remove_parametrizations (remove_weight_norm / remove_spectral_norm for the legacy hooks). Alternatively, train the net as NNModel(module=...).
nnx.surgery provides widen / deepen, drop_layer, low_rank_factorize (SVD truncation, exact at max rank), and expand_embedding. Full walkthrough with before/after parameter-count tables in docs/surgery.md.
widen/deepenare function-preserving Net2Net edits (Chen/Goodfellow/Shlens, ICLR 2016) on validated topologies.wideninfers the consumer only insidenn.Sequential/FeedFwdNN.layersthrough elementwise ops. It rejects Softmax/LayerNorm/BatchNorm boundaries, other containers and aliased modules up front.deepenis ReLU-only and validates the effective per-layer activation at the insertion site. It keeps aFeedFwdNN's immutableparams(hidden dims, activation and dropout overrides) aligned with the deeperlayers, aswidendoes with the wider ones.- Every primitive returns a fresh module and composes with
NNModel.train()for the "load checkpoint → surgery → refine" loop. - Some primitives replace existing Linear weights:
widen's target and downstream layers, andlow_rank_factorize's two factors. The replacements keep the replaced tensors'requires_gradflags and the replaced modules' train/eval modes. Frozen content therefore stays frozen through surgery.
nnx.embeddings is the one RAG-adjacent surface NNx ships. train_contrastive reuses the existing NT-Xent machinery for domain-specific text embedders. export_to_faiss writes the trained model's outputs to a FAISS index (Flat / HNSW). Any retrieval framework (LangChain / LlamaIndex / Haystack / raw FAISS) can consume that index. The chunker, reranker, and vector-DB client are deliberately out of scope. See docs/embeddings.md for the full when-to-use guide; examples/13_train_domain_embedder.py is the runnable demo.
nnx.generation (LogitsProcessor chain) is documented in §2.4 and docs/lm.md, since its raison d'être is GenerativeNNModel.generate(...). nnx.interop owns experimental NNx-tagged GGUF and Ollama bundle generation. Safetensors belongs to NNCheckpoint, embeddings export, and Hub integration. See docs/gguf.md and docs/hub.md. nnx.viz is in §12 above.
17. Typed decisions (nnx.decisions)¶
Everything above trains, transforms or exports models; nnx.decisions is the inference branch. It asks provider-neutral typed questions and returns validated, labelled probabilities instead of positional logits. The questions are Choice (2+ options), Boolean (one p_true) and Score (2+ ordered levels):
question (Choice / Boolean / Score, digest) ──► DecisionProvider.capabilities().check(...) # UnsupportedCapability before any model call
└──► provider.decide(question, inputs) # e.g. FixedHeadProvider → NNModel.predict_proba
└──► validate_response(...) # request order; never renormalized
└──► ChoiceResult / BooleanResult / ScoreResult (+ raw)
An option's id is bookkeeping and its description is the model-facing text. A question's digest() changes when options are reordered or reworded.
validate_response is the single validator every provider shares. It reorders keyed output into the question's order. It rejects missing, duplicate, unknown or unlabeled ids and malformed distributions instead of renormalizing them. ScoreResult.expected_index (sum(i * p_i)) is ordinal, and a vendor's own score stays in vendor_score.
FixedHeadProvider turns a trained classifier into a provider for exactly what its head justifies:
Choice(orScorewhen ordinal) from a categorical head over its exact label space or a bijection onto it;Booleanfrom a one-logit head, only for the question it was trained for (question=).
It restores the model's modes and raises typed errors (UnsupportedCapability, InvalidDecisionRequest, InvalidDecisionResponse, ProviderFailure).
JevProvider (the jev extra) asks hosted Jev models through the TypeSafe SDK (docs/decisions.md §6):
- It sends one
system_onerequest per text, holding every question. - The SDK's
RetryPolicyowns retries. - Failures are typed as
JevErrors with the service's request id. - Jev's
confidenceis kept as provider confidence, never a correctness probability. - Caller-injected clients are never closed.
Importing the package starts no backend and needs no hosted-SDK extra. The full guide is docs/decisions.md; examples/decision_fixed_head.py runs it end to end.
Decision jobs describe several questions before asking any. DecisionJob.ask, collect, map and then build an immutable tree that calls no provider, runs no callback and draws no RNG. run(provider, state=..., limits=...) and await arun(...) are the only effect boundaries. They borrow the provider without closing it:
Job.collect({"a": Job.ask(q1, id="a"), "b": Job.ask(q2, id="b").then(follow)}).map(f)
│ run / arun (the only effects)
├── round 1: q1 + q2 over the run's state ──► ceil(2 / cap) provider calls, in order
├── round 2: follow(answer_b) ──► Follow(job, state=…) ──► its questions over that state
└── JobResult(value=f({...}), outcomes={id: answered | failed | skipped | cancelled}, calls)
Independent questions over one state share calls: ceil(count / cap), with cap from the provider's max_questions and Limits.max_questions. A then continuation runs once, after its prerequisite succeeded. The answer is mapped explicitly into follow-up state. Duplicate ids, unsupported questions and an unenforceable token cap are refused before any call.
The job is fail-fast. JobFailed carries the completed outcomes plus the failed and skipped ids, and has no partial value. Cancellation (arun(cancel=event)) stops scheduling and reports requests already sent as sent, never as rolled back. map's identity and composition laws hold only for a deterministic provider. That provider must answer each question the same whatever it is batched with.
The job is neither LogitsChainBuilder (a mutable builder of decoding processors, re-sorted on build()) nor an ExperimentPlan (§20, which compiles a training run). See docs/decisions.md §7 and examples/decision_jobs.py.
18. Fitted calibration (nnx.calibration)¶
A classifier's softmax probabilities are often over- or under-confident.
nnx.calibration fits a scalar temperature T > 0 on a calibration
split and serves softmax(logits / T). The argmax never changes, only the
confidence. It is a fitted artifact bound to one model and label order.
It is not the generation temperature of GenerativeNNModel.generate /
TemperatureScaling (docs/lm.md §4). You
choose that temperature for sampling, and it fits nothing.
calibration split logits + targets ──► fit_temperature(split_id=..., train_split_id=..., test_split_id=...)
└──► CalibrationFit (ok → TemperatureCalibrator | failed → reason)
held-out logits ──► calibrator.transform(..., labels=..., model_id=...) ──► CalibratedPrediction
└──► calibrator.report(..., split_id="test") ──► CalibrationReport (before / after, outcome)
-
Split separation. Fit on a split that is not the training split, which the model already fits. It must not be the final test split either: the report needs that one untouched.
fit_temperaturerejects asplit_idequal totrain_split_idortest_split_id.reportrejects the calibration or training split as held-out data. Different ids do not prove the rows are disjoint, because arrays and loaders carry no such guarantee. The calibrator therefore recordssplit["disjointness"] == "unverified"with that note. -
Fitting. The fit takes categorical
(N, C)logits,C >= 2, integer targets in range, and one label per class. Non-finite logits are rejected, never masked. The NLL ofsoftmax(logits / T)is minimized in float64 on a copy of the logits, by bisection on its slope in1 / T. The search runs over[min_temperature, max_temperature](default[1e-3, 1e3]). - Fit failure. Some splits have no interior optimum: a separable split,
constant logits, or logits no better than uniform. In that case, or when
the search does not converge,
fit_temperaturereturns a failedCalibrationFitwith the reason. It never returns a NaN or boundary temperature;fit.require()raisesCalibrationFitError. - Identity binding. A
TemperatureCalibratorstores the temperature, the orderedlabels, themodel_id, the fit config and result, and the split ids.transformandreportcompare the declared labels and model id with the fitted ones before reading any logits. - Overrides. A reordered or renamed label list or another model raises
CalibrationMismatchError, unless a namedoverride="..."accepts it. The override and each mismatch (expected vs actual) are then recorded in the result and the report. A different class count is never accepted. - Model ids.
model_fingerprint(model)(a SHA-256 of the weights) makes amodel_idthat changes whenever the weights do. A declared id such asf"{run.id}:BEST"also works. - Raw and calibrated views.
transformaccepts arrays, tensors or a categoricalPredictionResult(Quickstart §2.13). A prediction's spec supplies the labels, and itssample_idsare kept.sample_ids=names an array's rows (default0..N-1) and must equal a prediction's own. -
Calibrated output.
transformreturns aCalibratedPredictionwith these separate fields:- the raw
logitsand rawprobabilities(float64 softmax, identical to a temperature-1 calibrator); calibrated_probabilitiesanddecoded(the raw argmax);- the ordered
labels, thesample_idsand thecalibrator_id(the calibrator's digest).
The model,
predict()and the run are never touched. - the raw
-
Metrics. The module provides four metrics:
negative_log_likelihood: mean-log p[target]. The default floorepsilon=1e-12matches the namednllmetric of §6.4.epsilon=Noneis exact, so a zero true-class probability gives+inf.brier_score: the mean over rows of the class-summed squared error, so the range is[0, 2]. It matches the namedbriermetric.reliability_bins: equal-width[lo, hi)top-1 confidence bins, the last closed at 1. A value on an inner edge goes in the upper bin. Empty bins are kept with count 0 and no means.expected_calibration_error.
- Reports.
calibrator.report(logits, targets, split_id="test", ...)puts held-out NLL, Brier, ECE and bins before and after calibration side by side.outcomeis"improved"only when neither NLL nor Brier got worse and one got better. Otherwise it is"worsened","mixed"or"unchanged", andsummary()says so. A worsened report tells you to keep the uncalibrated probabilities. A reloaded calibrator reproduces an equal report. - Serialization. Calibrators and reports are primitive JSON
(
nnx.calibration/1,to_json/save/load, written atomically). The temperature reloads float64-exact, so probabilities are identical after a reload. Loading rejects a non-positive or non-finite temperature, duplicate labels, a missing model id and a calibration split equal to the train or test split. - Scope. Categorical temperature scaling only. Conformal guarantees, vector / matrix scaling, provider-confidence calibration and online adaptation are out of scope.
- Dependencies. The module's own imports are NumPy, the standard library
and NumPy-only internal helpers. It imports none of its consumers
(predictions, monitors, provenance, decisions). The NLL / Brier terms and
the softmax are the same code the named metrics and
predict_probause.
See examples/calibration_offline.py.
19. Abstention and risk-coverage (nnx.abstention)¶
A classifier that must answer every row cannot decline an uncertain one.
nnx.abstention adds policies that accept a row only when its score
reaches a threshold, and report what that costs and buys.
validation probabilities + targets ──► select_threshold(kind, risk_ceiling, split_id="val", test_split_id="test")
└──► ThresholdSelection (candidates + counts; ok → AbstentionPolicy | no_feasible_policy)
test probabilities ──► policy.apply(...) ──► AbstentionResult (accepted / abstained per row)
├──► .report(targets) ──► CoverageReport (coverage, selective risk)
└──► .accepted_rows(targets) ──► hard-label reports (accepted rows only)
- Policies.
AbstentionPolicy(kind="max_probability" | "margin", threshold=..., labels=..., model_id=..., tuning_split_id=...). The score is the top probability, or the gap between the top two (a tied top two gives 0). A row is accepted when its score is at or above the threshold, so 1.0 still accepts a score of exactly 1. - Float scores. Scores are float64, so a margin of
0.7 - 0.2is0.49999999999999994, and a hand-picked threshold of 0.5 abstains on it. Thresholds fromselect_thresholdare actual scores. - Inputs. For arrays, the prediction is the first class of maximal
probability, so ties break by label order. Policies are pure functions
over categorical
[N, C]probabilities (C >= 2) with ordered labels and stable sample ids. As in aPredictionResult, ids may repeat (an oversampled split, for example); each row is decided on its own. - Three kinds of outcome. Accepted: the prediction stands.
Abstained: the policy declined the row, which is a success. The row
keeps its prediction, distribution, score and sample id, and a reason
(
"below_probability_threshold"or"small_margin"). Invalid: malformed probabilities (non-finite, outside[0, 1], rows not summing to 1,C < 2) or another schema raiseAbstentionError, and are never quietly abstained on. The input is never modified. - Declared field and schema. A policy records the probability field it
reads:
"probabilities", or a calibrator's"calibrated_probabilities"together with that calibrator's id (§18). It also records the label order, the model id and the tuning split id. It is JSON (nnx.abstention/1), and loading rejects a threshold outside[0, 1].applyaccepts aCalibratedPrediction(the policy's field picks the raw or calibrated view), a categoricalPredictionResult, or arrays withlabels=/model_id=.- Another label order, model, field or calibrator raises
AbstentionSchemaErrorbefore any probability is read. - For a prediction, the predicted class is its own
decodedargmax of the raw logits (never a rounding tie in the probabilities). It must be a class of maximal probability in every row, elseAbstentionError. A calibration override (§18) travels, ascalibration_override, with the result and with aThresholdSelectiontuned on the calibrated view. decide(choice_result, policy, model_id=...)applies a policy to a typed decision (§17). It returns the provider's result unchanged inside aSelectiveDecision. Abstention is a success, and the distribution and its raw field are kept.
- Two denominators.
CoverageReportgivescoverage = accepted / totalover every row, and selectiverisk = incorrect / acceptedover the accepted rows only. With nothing accepted, risk is unavailable:None, status"no_accepted", never 0. The report keeps every original sample id and the total count. - Hard-label reports. Only
accepted_rows(targets)should reach a hard-label report (accuracy, confusion matrix,VisUtils.classification_report). An abstention never becomes a pseudo class. - Chunked coverage.
CoverageAccumulatoraggregates chunks to exactly the eager report, zero accepted included. Give each chunk its ownsample_ids=: a prediction's when it is made, asTemperatureCalibrator.transformalso accepts. By default, an id seen before, in its chunk or an earlier one, is refused, since the default0..n-1repeats from chunk to chunk. For a split whose ids legitimately repeat, passCoverageAccumulator(allow_repeated_ids=True). - Threshold selection.
select_thresholdruns on a named validation split, never the test split. Asplit_idequal totest_split_idis refused, and aCalibratedPredictionneedsinput_field=to say which view to tune on. It evaluates every candidate threshold and reports each one's counts, coverage and risk. - Candidates. The default candidates are every distinct score. Beyond
MAX_DEFAULT_CANDIDATES(1001) distinct scores, they are that many evenly spaced order statistics of the scores (coverage steps of about 0.1%). Passcandidates=for any other set. The selection records the labels, model id, field and calibrator it was tuned on, even when nothing is feasible. - Choosing a threshold. The selection picks the highest coverage whose
risk is at or below the ceiling, or returns
"no_feasible_policy". The ceiling is empirical: it held on the validation split, which does not guarantee it anywhere else. Split ids are declared, not verified. Tune on rows used neither for training nor for fitting the calibrator (§18), or the risk is optimistic. - Risk-coverage curve.
risk_coverage_curvelists the candidates in ascending order. It reads bare probabilities, or a prediction's owndecodedclasses asselect_thresholddoes (aCalibratedPredictionwithinput_field=). Coverage never rises as the threshold rises; risk is measured at each point, never assumed monotone. The curve ends in an accept-none endpoint (coverage 0, risk unavailable). That endpoint exists only on the curve: it is not deployable, and no policy can hold or load it.
See examples/abstention_offline.py.
20. Experiment plans (nnx.plans)¶
ExperimentPlan branches a configuration without copying dataclasses by
hand. It holds the pieces of one experiment:
NNParams,NNModelParamsandNNTrainParams;- the data;
- the seed;
- the callbacks.
It compiles them to the existing loop, NNModel(net, model).train(train, ...).
There is no third loop and no hidden model cloning, and the mutable builders
are unchanged.
ExperimentPlan ──with_*──► new ExperimentPlan (the source is unchanged)
│
├── validate() ──► PlanValidation (every Diagnostic(path, message); pure)
├── probe(batch) ──► ProbeResult (temporary model, no-grad forward + loss; ambient state restored)
└── fit(attempt=None) ──► FitResult(model, run, metrics, attempt_id)
└── set_seed → factories → NNModel(...) → model.train(..., salt="plan-attempt:<id>")
- Branching. Each
with_*method returns a new plan:with_net,with_model,with_train,with_epochs,with_optim,with_scheduler,with_metrics,with_extra_metrics,with_data,with_seed,with_callbacks,with_callback_factories,with_step_fns,with_objective,with_components,with_provenance,with_batch_adapter(a registered module's inputs) andresuming.- A method keeps what it is not given.
with_data(train)keeps the validation source and identity (passval=Noneoridentity=...to change them), andwith_metrics(monitor=m)keeps the metrics. The plan's seed overrides the training parameters'seed. - The parameter objects are immutable and shared between branches.
- Collections are copied into tuples and read-only mappings, so a sibling or a later edit to the caller's own dict cannot reach another plan.
- A method keeps what it is not given.
- Validate: pure.
validate()returns every problem as aDiagnosticwith a field path. Example paths aremodel,net,model.net,train,data.train,data.val,data.identity,seed,callbacks[i],callback_factories[i],objective,resume.*,train.overwrite_existing,train.metrics[i]andtrain.monitor.- It reports conflicts statically, such as a resume alongside
train.parent_run_id, or a validation monitor with no validation data. - It checks, with no model built, the task against the loss and output
width, and that a
ModelSpecis registered. It also checks that data and callback factories take no arguments, and that components implementStatefulComponent. - It checks a borrowed callback's declared monitor (for example
EarlyStopping(monitor=...)) asNNModel.trainwould, on a copy so the callback is never bound. It also checks that the model can provide every declared metric's input, using the model's parameters alone (loss, task, output width). - It never iterates a loader, calls a factory, builds a model, reads weights or writes a directory.
- A data source must be re-iterable batches. It reports a one-shot
iterator, a mapping (which iterates its keys), a map-style
Datasetand a bare tensor or array. The last two yield single samples; wrap them in aDataLoader. - What factories return is checked the same way when a fit starts, before
a model is built, a factory-made callback's monitor included. A problem
there raises
PlanError. raise_for_errors()raisesPlanErrorlisting them all.fit()calls it first, right after the multi-rank check below. An invalidattemptid is listed with the rest.probe()checks the model, network and seed it needs.
- It reports conflicts statically, such as a resume alongside
- One process.
fit()trains in this process; it never passesdistributed=. Inside atorch.distributedprocess group of more than one rank (atorchrunlaunch), it raisesRuntimeError. It raises before seeding, calling a factory or creating a run, instead of letting every rank train its own copy into the sameruns/. A one-rank group behaves as no group. To train one model across ranks, usemodel.train(..., distributed=DDP())(Architecture §3). - Probe: effectful, restored.
probe(example_batch)seeds, builds a temporary model and runs one forward pass in eval mode undertorch.no_grad(). A second, train-mode pass gives the loss (below).- With a target and the default training step, it reports the loss that step would compute on the batch. It runs in train mode (dropout, batch statistics) with no gradient.
- A plan with its own
train_step_fnor objective getsloss=None, since a probe cannot know that loss. So does an all-masked task batch, which the default step records no loss for.output_shapeis always the network's raw output in eval mode, aspredict()sees it. - A bare tensor or a 1-tuple is inputs only, read as
predict()reads a loader batch. - Afterwards, succeeding or failing, it restores the global seed state.
That is the Python / NumPy / torch RNG streams, the cuDNN
deterministic/benchmarkflags, the deterministic-algorithms setting,PYTHONHASHSEEDandCUBLAS_WORKSPACE_CONFIG. - It touches no callback, factory or loader, and writes nothing.
- A CPU model's probe seeds no CUDA stream, so on a GPU host nothing waits for CUDA's first use either. A CUDA model's probe starts CUDA first when it is not yet in use, running any seed queued for its first use. Its streams are then restored like the others.
- A registered factory's module is built as
NNModelbuilds it, saving and restoring every RNG stream around the factory call. On a GPU host that initializes CUDA even for a CPU model, though its streams are restored.
-
Fit: the imperative loop.
fit()takes the steps an equally seeded script would:- validate;
set_seed(seed), which governs the initial weights (a built-in net draws them from the ambient RNG);- call each data and callback factory once;
- build
NNModel(net, model); - call
model.train(...)with the plan's seed, data, data identity (NNTrainParams.data_id) and resume.
So it matches
set_seed(s); NNModel(...).train(NNTrainParams(seed=s, ...))on weights, history and callback order.NNModel.trainreturns anNNRunas usual, and plans add no field to any params class. -
Attempts. Every fit is a distinct attempt. Its id (random unless
fit(attempt="...")names it) is folded into the run'ssaltas"plan-attempt:<id>".- Repeated fits of one plan get distinct run ids and directories.
- A plan never sets
overwrite_existing, andvalidate()rejects it, so reusing an attempt id for the same configuration raisesFileExistsError. resuming(run_id, checkpoint="last", mode=None, epochs=None)warm-restarts into a new run that records the source asparent_run_id. It leaves the parent's artifacts untouched.without_resume()drops a branched plan's resume.modeandepochsset the fit'sresume_modeandresume_epochs(§14.3); when omitted, the training parameters' own values apply.validate()checks both asNNTrainParamsdoes. It reports a"planned"horizon with a"weights_only"mode or with no run to resume, on the field the caller set.- A plan of epochs 0–3 stopped after epoch 1 (zero-based) and resumed with
resuming(first.run.id, mode="stateful", epochs="planned")trains epochs 2–3. It equals the uninterrupted plan bit for bit.
- Metrics.
FitResult.metrics["train"]and["val"]areSplitMetrics(split, available, epoch, source, values, reason), read from the run's final epoch. No loader is iterated a second time.- A train-only plan reports
valas unavailable, with the reason. It invents no validation. sourceis"epoch"for validation or a declared training summary, and"last_batch"otherwise.valueskeys are the names the callbacks log:loss,error, the classification fields, declared metrics, andextra/<name>for extra metrics. A non-finite value (a diverged loss) is kept, not dropped.- A whole-epoch training summary carries declared metrics but not extra
metrics, which the loop computes per batch (
run.idpshas them).
- A train-only plan reports
Ownership. Everything that is not an immutable parameter object is
borrowed: shared by every branch and every fit, never copied or reset by
the plan. That covers loader instances, callback instances, the
extra_metrics callables, step functions, the objective, components and the
provenance manifest.
- A stateful borrowed object carries its state across fits.
EarlyStoppingresets itself inon_train_begin, butLRMonitor.historyaccumulates. - Pass zero-argument factories for fresh objects per fit:
with_data(train=make_loader, val=make_val_loader)andwith_callback_factories(lambda: LRMonitor()). Factories run after seeding, exactly once per fit. - A callable passed to
with_callbacksis a legacyfn(idps)callback only when its one parameter is required. One that can be called with no arguments reads as a factory, andvalidate()reports it. - Each fit builds its own model;
FitResult.modelis the trained one. - Runs are written under
<cwd>/runs, as byNNModel.train.
See examples/experiment_plan.py.
21. Streaming prediction and mergeable metrics (nnx.streaming)¶
predict(), predict_proba() and evaluate() are eager. They return only
after the whole loader has run, holding every batch's outputs until then.
evaluate() also holds every target and prediction. nnx.streaming
provides bounded counterparts alongside the eager calls.
NNModel.iter_predict(loader) ──► PredictionStream ──► PredictionBatch(logits, classes, sample_ids) per batch
(spec / rich=True) ──────────────► PredictionResult per batch
StreamingMetrics.update(...) ─┐
StreamingMetrics.update(...) ─┴─ merge ──► finalize() ──► MetricSnapshot(count, values, unavailable)
NNModel.train(eval_step_fn=streaming_eval_step) ──► the default validation record from counts and sums
-
A context-managed stream.
iter_predicttakes aDataLoaderor another iterable of batches; in-memory arrays and tensors stay withpredict(). Use the stream in awithblock:with model.iter_predict(loader) as stream: for batch in stream: sink.write(batch.sample_ids, batch.classes)Batches arrive in loader order. Concatenated (
concatenate_predictions), they are exactly the eager result:predict()'s logits and classes andpredict_proba()'s sample ids. The graph seed-row slicing is the same. So is the categorical, multilabel (the task's threshold) and continuous decoding. -
Batch types and edge cases. Each batch is the
PredictionResultthatpredict_proba()would build for it. That holds with aProbabilitySpec, or withrich=Truefor a model with a task. An empty loader yields no batches, where the eager calls raise. Over a shufflingDataLoader, the first batch whose sample ids are iteration positions warns, aspredict_proba()does. Graph seed rows carry global node indices and never warn. - Mode restoration. Each batch's forward pass runs in eval mode under
no_grad. Every submodule's training mode is restored right after it, before the batch is yielded or its error raised. Between batches the network is in its own mode, so a consumer may train or inspect it. After an early close or a failed forward pass, nothing is left in eval mode. - Ownership and reuse. Closing the stream finalizes its generator and
drops its references to the loader's iterator and the model. Leaving the
withblock,close()and an error inside the stream all close it. Like a closed generator, a closed stream is exhausted, soclose()inside aforloop ends the loop. - Reuse errors. Iterating a closed, consumed or partly consumed stream
again (after a
break) raisesStreamClosedError. So does entering a closed one; askiter_predict()for a new one. The loader is never closed: it stays the caller's and can be iterated again. - Consumer-retained memory. The stream holds at most the batch in flight, so its memory does not grow with the dataset. What the consumer keeps is the consumer's memory: keep the sample ids and decisions you need, not the batches. The eager calls are a stream the library concatenates for you, with O(N) memory.
- Mergeable metrics.
StreamingMetrics(metrics, semantics, labels=..., threshold=...), orStreamingMetrics.for_task(metrics, task), accumulates declaredMetricSpecs:update(target, probabilities=..., labels=..., values=..., valid=...)takes a batch's inputs.update_logits(target, logits)derives them from raw outputs asevaluate()does.- Masked entries are not scored. They are
valid=False, a NaN target (including a one-hot / soft row holding a NaN), or a categorical target equal to the task'signore_index. This is the standalone accumulator's own rule.evaluate()records, andstreaming_eval_stepwith them, mask what the task masks. Without a task, they mask the loss'signore_index. - The built-ins keep sufficient statistics: sums and counts for
accuracy,nll,brier,maeandmse, and confusion counts forf1.f1gives the same values as scikit-learn withzero_division=0. Memory is bounded by the number of classes, never by N. - A registered metric is bounded when its accumulator implements
merge()and does not declarestores_scores = True. merge(other)returns a new accumulation over both inputs' samples, whatever the order. It copies its inputs, so later updates to either never reach it. Merging an empty accumulation changes nothing.finalize()returns a read-onlyMetricSnapshot(count, values, unavailable). It is repeatable and never changes the accumulation. An empty or fully masked stream finalizes tocount=0with every metric unavailable.
-
Merge schemas. Two accumulations merge only when they declare all of the following:
- the same metrics (names, ids, versions and configs);
- the same probability semantics (
categorical,bernoulliorcontinuous); - the same task labels and output count (
num_outputs: classes, or outputs along axis 1); - the same
ignore_index, and forbernoullisemantics the same decision threshold.
Anything else raises
MetricMergeError, since the sums would describe different quantities. A custom accumulator'smerge(other)addsotherin place and returnsNone. It keeps nothing ofotherthat a later update could change. -
Stored scores. A rank metric such as AUROC depends on the order of every score and is never additive. A registered metric whose accumulator has no
merge(), or declaresstores_scores = True, is refused in bounded mode before any update.materialize=Truestores its scores instead (O(N) memory) and replays them atfinalize(). -
Bounded validation and loss denominators.
streaming_eval_stepis an opt-ineval_step_fnthat builds the validation recordevaluate()builds, from counts and sums:- the same task kind, count and status, with masked targets not counted;
- the same loss: each batch's loss numerator is summed and divided by the
summed loss denominators (valid targets, or class weights). It is never a
mean of batch means, and a
sum-reduction loss stays a total; - the same classification or task metrics, and the declared metrics that monitors and BEST selection read.
EvalStepContext.metricscarries the run's declared metrics to the step. The step refusesextra_metrics(callables on the full arrays) and a stored-score metric when training starts, before any run is reserved.ExperimentPlan.validate()reports both undertrain.extra_metrics/train.metrics[i].These early checks apply to
streaming_eval_stepitself or afunctools.partialof it. A step that wraps it is a step of its own. The streaming step then refuses at its first call, before reading a validation batch.The default validation step and
evaluate()stay eager, andevaluate()raises on an empty loader. A model without a task scores multi-output (BCEWithLogitsLoss) indicators row by row, asevaluate()does. Accuracy is then the exact-row accuracy, and the averages run over the labels.
See examples/prediction_stream.py.
22. Run bundles (nnx.bundles)¶
A run's checkpoints are pickles (torch.load(weights_only=False)), safe to
read only when you produced them. A safetensors checkpoint holds one weights
dict without the training state. A run bundle holds one run checkpoint
as data only: its weights, its training state and optional calibrators.
<bundle>/bundle.json manifest: format, version, generation id, every payload's SHA-256 and size
<bundle>/g-<generation>/
state.json primitive state, schema-validated JSON
model.safetensors the network's tensors
training.safetensors the training state's tensors ("resume" bundles only)
calibrator-<n>.json TemperatureCalibrator records (§18)
export_bundle(run_id, dir) ──► inspect_bundle(dir) ──► validate_bundle(dir) ──► reconstruct_bundle(dir, factories=...)
└── ReconstructedBundle.resume(params)
- Export reads one of your runs' checkpoints and publishes it as a new
generation. The checkpoint is
checkpoint="last", anyCheckpointstag, or aModelCheckpointstem such as"snap_e3". A checkpoint written bytrain()gives a"resume"bundle. A weights-only one (aModelCheckpointsnapshot) gives an"inference"bundle. - Calibrators ship alongside. They must calibrate a categorical model
with their class count and the task's labels. A fingerprint
model_id(model_fingerprint) must be the bundled weights'. Every validation checks this again. - Format v1. Tensors are stored in the safetensors format, which NNx
writes and reads itself. It needs no optional dependency, and any
safetensors reader opens the payloads. Everything else is strict JSON with
typed encodings:
$tuple, integer-keyed$intdict(optimizer state), non-finite$float(a plateau scheduler'sinf) and$tensorreferences. - Refused at export. Export refuses the following, with no pickle
fallback:
- module extra state that is not a tensor;
- a custom object in optimizer or component state;
- a runtime-only module;
- a recorded topology transform NNx cannot replay from data. That is
anything but an
nnx.transformsrecipe operation or a torchao QAT conversion; the error names its index and id.
- Inspect.
inspect_bundlesummarizes a bundle from the manifest andstate.json. The summary is aBundleInfo: capability, source run and checkpoint, epoch, model, components, calibrators. -
Validate.
validate_bundlechecks every payload before any tensor is read:- size and SHA-256. The size is checked before a file is read, and no file is opened through a symlink or as a FIFO or device;
- one generation id throughout;
- nothing missing, unlisted, symlinked or outside the bundle;
- strict JSON without duplicate keys or runaway nesting;
- calibrator labels matching the task's;
- safetensors headers holding exactly the tensors
state.jsonreferences.
Only then is a calibrator's fingerprint
model_idchecked, against the weights hashed straight from the payload; no tensor is built. An export runs the same checks on its staged generation before publishing it. Neither inspection nor validation unpickles, calls a model factory or downloads, even beside a legacylast.pt. -
Reconstruct.
reconstruct_bundle(path, factories=..., components=...)validates, then rebuilds the model. A registered module is built from the caller-suppliedfactories({(id, version): factory}; the process registry when omitted). Passbatch_adapter=for its inputs as withNNModel(runtime-only, never stored). Passing the resumed run'scomponents(e.g. the same callbacks) checks their saved state too. - Reconstruction errors. One
BundleReconstructionErrornames every missing factory or component before any model is allocated. This includes a model saved on CUDA or MPS on a host without it (passdevice=Devices.CPU). - Serving. The rebuilt model predicts bit-for-bit like the source. To
serve it, keep
.modeland drop theReconstructedBundle, which holds a"resume"bundle's training state forresume(). Bundles holdNNModelruns. Artifacts a subclass keeps outside the checkpoint (aGenerativeNNModeltokenizer) are not bundled. - Excluded submodules.
exclude_submodules=rebuilds without top-level submodules the training run attached to the net (a JEPA predictor, see JEPA). Every other weight is still strict, asNNModel.from_checkpointandfrom_pretraineddo. Such a model serves inference and cannotresume(). - Untrusted bundles. Like
from_pretrainedwith aconfig.json, reconstruction builds the architecture the bundle's parameters describe. Read an untrusted bundle'sinspect_bundle(...).model_paramsand.net_params(a built-in net's dimensions) before reconstructing it. - Resume.
ReconstructedBundle.resume(params, **train_kwargs)runsmodel.trainas a stateful resume of the bundle's run. The optimizer, scheduler, GradScaler, RNG and component state come from the bundle, held in memory; it never becomes a pickle. One resumed epoch equals the uninterrupted run. - Resume lineage. The resumed run records
resume_checkpoint("bundle-<generation[:16]>_e<epoch>", the generation's first 16 hex characters) as its parent checkpoint. Withprovenance=..., its attempt links the bundle's run, epoch and generation without reading anything from disk. - Resume refusals. These raise
BundleCapabilityErrorbefore anything changes. They are an"inference"bundle, aTrainerrun's bundle (named optimizers), or a topology converted at train end (a QAT checkpoint). A recordednnx.transformsrecipe (LoRA, low-rank) is replayed on the rebuilt model, so its bundle resumes. The refused models still rebuild for inference like any other. - Publication. A new generation directory is written first, and
bundle.jsonis replaced atomically. An export interrupted at any point therefore leaves the previous bundle usable. The replaced generation stays until the next export, so a reader that started before an export can finish. Older generations and an interrupted export's leftovers are cleared. - Directory safety. An export never touches a directory that is neither
empty nor a run bundle. It never touches a
bundle.jsonthat is not a readable NNx manifest. - Three formats, three readers. Pickle checkpoints, safetensors
checkpoints / Hub distributions and run bundles each have their own
reader. None opens another's files (see Hub integration
§3 and
SECURITY.md).
23. Multi-seed summaries and paired comparisons (nnx.comparison)¶
One seed is one draw. nnx.comparison reports repeated runs as a
population, read-only. It builds and loads no model, reads no checkpoint,
elects no runs/best pointer and writes nothing into a run.
Four identities are kept apart:
- run — a run directory (
run.id); its configuration includes the seed; - attempt — one training attempt of a run (the provenance
attempt.json), with its completion status. The status iscompleted,failed,cancelled,running, orunknownwhen no attempt record exists; - replicate — what a comparison pairs on, such as
seed=3. Equal keys only pair two runs when you declare what the key means (thepairing=text). An equal seed does not prove the same data order or split; - split — the evaluation split's identity (a split manifest digest),
separate from the evaluation split's name (
validation).
Observation(run_id, attempt_id, metric=Metric(name, direction, unit), value, status,
split, selection, config, data, split_id, replicate, evaluation)
│ (unknown facts stay None)
├── summarize(observations) ──► Summary: one GroupSummary per poolable group
│ n, mean, std (n - 1), n_attempts, n_failed, n_nonfinite; `differing` fields
├── compare(a, b, pairing="...") ──► PairedComparison: per replicate, delta = B - A (signed)
│ unmatched replicates listed; optional Bootstrap(seed) interval = seed variability
└── ComparisonReport.build(...) ──► table() / text() / save() / load() (nnx.comparison/1)
- Pooling. Observations pool only when they share the metric, the
evaluation split, the checkpoint-selection rule, the configuration and
the data and split identity. Anything else is stratified: one group per
combination, with the differing fields named, and
Summary.pooled()refuses. A known identity never pools with an unknown one. - One observation per attempt. Each attempt gives one observation per metric. To compare one attempt's splits or selection rules, summarize each split or rule separately.
- Statistics.
ncounts completed attempts with a finite value. The standard deviation usesn - 1. It is unavailable forn = 1, and mean and deviation are both unavailable forn = 0. Failed and non-finite attempts stay listed and counted (n_failed,n_nonfinite), never silently dropped. A duplicate attempt (per metric) is refused. - Pairing. Each side must be one poolable group with a unique replicate
key per observation. The sides must agree on everything but the
configuration. The delta is
B - Aand is never flipped: for a metric to minimize, a negative delta means B is lower. A replicate on one side only is listed as unmatched, and a pair with a failed side has no delta. - Intervals. A
Bootstrap(seed=...)resamples the replicate pairs (its unit) and records its seed, method and resamples. Its interval describes seed variability over these replicates; it is not a significance claim. - Reports.
table()gives one plain-JSON row per group, observation, pair and unmatched replicate. It adds onecomparisonrow per comparison with itsn, mean, standard deviation and interval.text()gives a concise view with every run and attempt id, status, selection rule and signed delta. Input order never changes either.savewrites strict JSON.loadre-derives the results from the stored observations and refuses a file whose results disagree. - Text view counts. The text view counts attempts that did not complete
as
not_completed. A matched pair whose delta overflows a float is counted (n_nonfinite) rather than averaged.
Reading runs. observations_from_runs(run_ids, metric=..., split=, selection=) reads
run.yaml, idps.csv and the provenance files once each. For a run trained
with a HistoryJournal, it reads the journal's committed records instead of
idps.csv.
Commit status. Train the runs with provenance=. The attempt record
then gives the status, the attempt id and the committed epoch. Without it, a
run whose history is committed by its LAST checkpoint has an unknown value.
A legacy run (no commit marker) keeps its value. Its status is unknown, so it
is never counted in n.
Committed epoch. The recorded epoch is used as is. LAST itself is never opened, so nothing is unpickled from a run directory. A history journal is read only up to that epoch, and not at all while the epoch is unknown. Its uncommitted tail is therefore never read.
Value selection. The value is the epoch record of one of these epochs:
selection="last": the last committed epoch."best": the epoch the run's monitor last elected. The rule names the declared monitor. The value is unknown without a monitor, or when it elected nothing.
The record is the validation record or the whole-epoch training summary. Only runs that declare metrics or a monitor record that summary; a last batch is never used.
Configuration identity. The configuration identity is a digest of the
run's configuration without its salt, seeds and device. The seeds are the
training or Trainer seed and a registered ModelSpec's initialization
seed. This holds for NNModel.train and Trainer runs alike. Alternatively,
pass a label for every run as config=.
Runs with a parent. A run with a parent is a continuation, a fine-tune or a born-again generation. Its configuration identity replaces the parent's id with:
- the parent's own identity, followed back through every ancestor;
- the data and split identities its provenance declares;
- the checkpoint tag (from
run.yaml) and the resume mode (frommetadata.yaml).
A run with a parent therefore pools only with runs descended the same way from identically configured parents. It never pools with fresh runs, with its parent, or with fine-tunes of other pretraining. Only recorded facts decide, and an unknown lineage never pools.
With the default resume_epochs="additional", n_epochs counts the epochs a run adds. Under "planned" it is the plan's total, and both settings are in the identity.
Start epoch. A resumed run carries the epoch it started from, as
recorded in its attempt.json, when that epoch is part of the procedure.
That is the case for a fixed checkpoint tag, or a parent that had not
completed (still training, killed).
From a completed parent's last or
best, the epoch is an outcome of its training (early stopping, the elected
epoch) and is left out. A needed start epoch that was not recorded follows
nothing.
Following a parent. A parent is followed in these cases:
- its current attempt is the one the child recorded;
- with nothing recorded, it had completed before the child started (timestamps compared as instants);
- it has no provenance, and the child recorded the epoch it resumed at.
Otherwise, the parent is not followed. Examples are the moving best
alias, a deleted run, a retrained parent, or a child whose order with its
parent is unknown. The parent is then named by the parent checkpoint the
child recorded, or else by the attempt and start epoch. Only siblings of
that checkpoint pool. Without a record, the lineage is unknown and pools
with nothing.
Lineage limits. Along the parents it follows, NNx refuses a cycle, more
than 64 ancestors or an ancestor's unreadable files. The order in which the
runs are listed does not matter. Lineages are recorded only for runs trained with
provenance=. born_again_train takes none, so its generations are unknown
lineages. To compare them across seeds, train each generation with
NNModel.train(params=NNTrainParams(..., parent_run_id=...), provenance=...).
Replicate key. The replicate key is seed=<seed>, plus
init_seed=<ModelSpec seed> when that differs. It is unknown for a run
without a training seed.
This is about your own runs. The comparison against other toolkits is a
separate page: docs/comparison.md. See
examples/compare_seeds.py.
24. Query-grouped ranking (nnx.ranking)¶
A ranking model is judged per query: its candidates are ranked against
each other. A metric is an average over queries, never over rows or
batches. nnx.ranking.RankingTask declares the task and checks every batch
before any update:
batch = (features, query_ids, candidate_ids, relevance[, mask]) # or a mapping with those keys
task.objective(): LossTerm("pairwise_logistic", Σ softplus(-(s_hi - s_lo)), denominator) # the shared update engine
task.eval_step(): buffer the stream ─► group by query ─► rank by score (ties: candidate id) ─► MRR@k, Recall@k, NDCG@k
-
Declared. The task declares:
- the cutoffs
k; - integer relevance grades
0..max_relevance; - the
relevance_threshold(a candidate is relevant at or above it); - the objective's
weighting("query": each query weighs the same;"pair": each pair does); - whether candidate sets are
"sampled"or"exhaustive"; - the evaluation buffer limit
max_buffered; - an optional
group_size(every query must have exactly that many candidates) and aversion.
Query and candidate ids are integers or strings, kept as host values. A candidate's identity therefore survives sorting, device moves and reloads.
- the cutoffs
-
Rejected before any update:
- mismatched lengths or mixed id types;
- a candidate listed twice for one query;
- grades outside the declared range;
- a mask of the wrong shape or values;
- a scorer that does not return one score per row.
- The objective is the pairwise logistic loss over every pair of a
query's candidates with unequal grades. For relevance
[1, 0]and scores[2, 0], the loss islog(1 + e^-2) = 0.126928. Ties contribute nothing, and a query without an unequal pair is skipped. - Training batches. Pairs are formed within a microbatch, so keep a query's candidates in one batch when training. The scorer takes the features as its one input. Every row goes through it, masked ones too, so pad masked rows with finite features.
- The evaluation buffers the whole validation stream (at most
max_bufferedrows). A query split across batches, in any order, is therefore joined, and the same candidate arriving twice is refused. Each query's candidates are ranked by score, ties broken by candidate id. - Evaluation metrics.
MRR@kis the reciprocal rank of the first relevant candidate in the topk(else 0).Recall@kis the relevant candidates in the topkover all relevant ones.NDCG@kuses gain2^rel - 1and discountlog2(rank + 1). For relevance[2, 0, 1], scores[3, 2, 1]andk=2, the results are MRR 1, Recall 1/2 and NDCG3 / (3 + 1/log2 3). - Excluded queries and cutoffs. A query with no relevant candidate is
excluded and counted, never scored 0. A
kabove a query's candidate count, ork <= 0, is refused. - The record (
kind="ranking",count= scored queries) carriesmrr_at_<k>,recall_at_<k>,ndcg_at_<k>,queries,excluded_queries,candidates,candidates_per_queryandexhaustive_candidates.exhaustive_candidatesis0.0for sampled candidate sets: the metrics then measure ranking within those candidates, not full-corpus retrieval. - Record rules. No classification field is filled in. An epoch whose
every query is excluded is unavailable (
status="empty", with itsexcluded_queries). It is never elected BEST in place of a training metric. The record'slossis the pairwise loss over the scored queries only. - Memory. Pairs are formed in bounded blocks, recomputed in backward during training. Memory therefore stays linear in a query's size in training and evaluation alike.
- Named selection.
task.metric_specs()declares the metrics: registered idsranking.mrr/ranking.recall/ranking.ndcgwithconfig={"k": k}, reported as<metric>_at_<k>.MonitorSpec("ndcg_at_10")therefore drives BEST, early stopping and plateau scheduling. Only the task's evaluation step computes them; the default evaluation path refuses them. - Checkpointed. The task configuration is component state
"ranking.task". A resume with another configuration is refused before the first resumed update.
NNx does not manage retrieval indexes. export_to_faiss writes an index
and returns its path, and the caller maps FAISS positions to its own
document ids. examples/ranking_offline.py
does this before an ID-aligned evaluation.
25. Graph-level classification (nnx.graph_tasks)¶
NNx's graph nets (Nets.GRAPH_CONV / GRAPH_SAGE / GRAPH_ATT) and
NNGraphDataset classify the nodes of one graph. nnx.graph_tasks
classifies whole graphs — one row per graph, keyed by a stable graph
id — through the ordinary training, evaluation, prediction and reload paths:
GraphCollection(graphs, ids, targets | unlabeled=[...]) ──loader()──► Batch(x, edge_index, batch, ptr, y[G], graph_id[G])
GraphClassifier: encoder(x, edge_index) ─► GraphPool("mean" | "sum") over each graph's own nodes ─► head ─► (G, classes)
NNModelParams(net=graph_classifier_spec(...), task=TaskSpec.categorical(n, ignore_index=IGNORE))
- The collection checks every graph once. Ids must be unique
non-negative integers. Each graph needs at least one node, one feature
width and edges inside the graph. Every graph not flagged
unlabeledneeds a target. A flagged graph's target isIGNORE. The collection never splits itself: split by graph id (subset(ids)). Correlated graphs leak across splits whatever their ids. -
The classifier validates every batch before the forward pass:
- graph ids are present and unique, and no graph has zero nodes;
ptrand the batch vector are consistent, and no edge crosses graphs;- each graph has one target.
It pools node rows into graph rows:
"mean"or"sum", graph-local and invariant to node order. -
The recipe.
graph_classifier_spec(input_dim=..., num_classes=..., hidden_dims=..., encoder="graph_conv" | "graph_sage" | "graph_att", pool=...)is a registered recipe. A reload rebuilds the same encoder, pool and head. - Per-graph normalisation. With a categorical task, loss and metrics
are averaged over labeled graphs, never batches or nodes. NLLs
[1, 2, 3]in batches of 2 then 1 give 2, not 2.25. Records, BEST selection and the run chart (a history journal's too) therefore count labeled graphs. - Identity.
predict_probaanditer_predictreport each row's graph id as its sample id, through shuffling, device moves and concatenation. A graph classifier never triggers seed-row slicing. - Refusals. Node-level nets,
NNGraphDataset(a dataset of more than one graph) andtwo_dim_tsne_checkpoint_logitsrefuse collection batches rather than scoring graphs as nodes. ONNX export of a graph classifier (to_onnx,netron_export) is refused before anything is written;export_state_dict()keeps the weights.
See examples/graph_classification_offline.py.
26. Link and edge prediction (nnx.link_tasks)¶
Node masks cannot hold out edges: a held-out edge left in the graph the
model passes messages over is a leaked answer. nnx.link_tasks splits
edges on a static homogeneous graph and checks every batch against the
split before any forward pass:
split_links(edge_index, n, val=, test=, seed=) ─► LinkSplit (nnx.link-split/1)
train / val / test positives (canonical; undirected = (min, max)), fixed val / test negatives, seed, policy
LinkTask(split).loader(name, x, batch_size) ─► Data(x, edge_index = training topology, edge_label_index, edge_label, candidate_id)
objective(): BCE (or CE) over candidates · eval_step(): exact AUROC / AP / BCE over every candidate
- The manifest. Edges are canonicalised and de-duplicated before they
are split. So a duplicate can never sit in two splits. In an undirected
split, neither can a reverse; a directed split keeps
u→vandv→uapart. An explicitLinkSplitrefuses non-canonical, repeated or cross-split edges and barred self-loops. It records directedness, the self-loop policy, the seed and the topology policy.replay(edge_index)re-derives it with a local generator (never the global RNG) and refuses a mismatch. - Topology.
message_edge_index()is the only message graph, for every split. It holds the training edges, plus their reverses when undirected. An edge-label split withlabel_existence="visible"also holds every labelled (validation and test) edge. Otherwise evaluation topology is fixed to the training edges. - Edge-label splits. An edge-label split (
categories=, one category per edge, in declared order) declares whether labelled edges' existence is visible context (label_existence="visible"). Their categories never are. - Negatives come from the static complement. A negative is never a
positive of any split, a reverse (in an undirected split), a duplicate or a
barred self-loop. A
request beyond the complement's capacity fails before sampling.
Validation and test negatives are fixed in the manifest. An edge-label
task samples none: a non-edge is never a category (
LinkTask(split)takes the split's mode). - Training negatives. Every training pass re-draws its negatives and
shuffles its rows from
(seed, epoch).NNModel.trainandTrainer.trainannounce each epoch to a loader that definesset_epoch(epoch). A stateful resume therefore draws exactly what the uninterrupted run would have. An extra iteration of the training loader (a callback scoring it) changes nothing. - Loader wrappers. A wrapper around the training loader must forward
set_epochto it. Otherwise its passes follow the wrapper's own iteration count, and a resume re-draws them. This is the same contract as aDistributedSampler. Materialised batches replay as they are. The negatives' seed is checkpointed, and a resumed loader with another seed is refused before any update. - Checks. These fail before any update:
- a message edge outside the training topology (a held-out positive is named);
- a candidate outside its split, or with the wrong target or id;
- a barred self-loop;
- a training negative that is a fixed validation / test negative.
- Batch roles. Batches are bound to their role. The objective trains on
trainbatches only. The training evaluator reads thevalsplit, every candidate exactly once. Scoretestafter training withprediction = task.predict(...)andlink_metrics(prediction.logits, prediction.targets, from_logits=True). The default train / evaluate / predict paths refuse link batches, since they cannot check them. So do node-level nets and the graph-pooling adapter (§25). - Shapes. Binary candidates give one logit each,
(K,); edge-label candidates give(K, categories).predict()keeps ids, pairs, logits, probabilities and targets aligned, in candidate order (float64). - Exact metrics. Evaluation materialises every candidate (up to
max_candidates) for exact AUROC and AP, never per-batch averages. For a one-class set,link_metricsreports them unavailable with the reason and the evaluator's record omits them, soMonitorSpec("auroc")never elects it BEST. - Metrics from logits. The metrics are computed from the float64 logits, never from probabilities. For a confident model, a probability rounds to exactly 1 beyond a logit of about 16.6 in float32 (36.7 in float64). That rounding ties candidates the logits tell apart and clips their loss. The BCE therefore comes from the logits (the NLL from their log-softmax), and AUROC / AP rank the logits themselves.
- Scoring outside the evaluator.
link_metrics(probabilities, targets)scores bare probabilities;from_logits=Truescores logits as the evaluator does. - Recipe.
link_predictor_spec(...)is rebuilt on reload. Its encoder is GCN / GraphSAGE / GAT with no activation after its last layer. A"dot"decoder's logits can therefore be negative; an"mlp"decoder is the alternative. Both decoders are symmetric, so on a directed splitu → vandv → uscore alike. Bring an asymmetric module for direction-sensitive tasks. - Checkpoints. A
resume_mode="weights_only"warm start does not compare manifests (components start fresh). Warm-start only from a run on the same split. The manifest (topology policy, candidate ids, fixed negatives) is component state"link.task". A resume with another split fails before the first resumed update.
Homogeneous static graphs only: temporal and heterogeneous graphs,
distributed sampling and knowledge-graph ranking are out of scope. See
examples/link_prediction_offline.py.
27. Errors as values (nnx.result)¶
NNx reports failures by raising, and every API keeps doing so. nnx.result
is an opt-in alternative for code that would rather branch on a value:
Ok(value)andErr(error)are frozen.mapchanges only anOk, andmap_erroronly anErr.bind/recoverflatten oneResultreturned by their callback.unwrap()returns the value or raisesUnwrapErrorcarrying the typed error.
Nothing is caught on your behalf. A callback's RuntimeError,
KeyboardInterrupt or cancellation propagates, and a bind / recover
callback that returns a non-Result raises TypeError. A Result has no truth value: Ok(0),
Ok(None) and Ok(False) are successes.
The types are generic (Result[T, E] = Ok[T] | Err[E]). Branch with
isinstance(result, Ok) or match. A type checker narrows each branch on
Python 3.10 with no extra package. .is_ok / .is_err answer the question
without narrowing.
plain data ──► validate_decision_request_result ──bind──► decide_result ──► Ok(results | SelectiveDecisions)
│ InvalidDecisionRequest │ InvalidDecisionResponse / ProviderFailure / UnsupportedCapability
└──► Err(BoundaryError(code, where, context, cause)) ◄──┘ (anything else propagates)
path ──► inspect_bundle_result ──► Ok(BundleInfo) | Err(artifact_missing | bundle_invalid)
Each wrapper converts exactly the exceptions it declares into an
Err(BoundaryError(code, where, context, cause)):
validate_decision_request_resultbuilds the typed question from plain data, so an invalid request never reaches a provider.decide_resultseparates a malformed answer (invalid_decision_response) and a provider failure (provider_failure) from a successful abstention.inspect_bundle_resultdelegates to the safennx.bundles.inspect_bundle. It reads manifest and JSON records only, never a checkpoint unpickle, a factory or a registry.
The exception-style APIs (question_from_state, provider.decide,
inspect_bundle) are unchanged. See Typed decisions §10.1
and examples/result_boundaries.py.
28. Opt-in compilation and benchmarks (nnx.compilation, nnx.benchmarking)¶
model.train(params, compile=CompileSpec()) runs the built-in step's forward
through torch.compile, and the fit's validation too. It is opt-in,
deliberately narrow and guarantees no speedup:
- Scope. Compilation requires built-in non-graph nets, the default train
step (no
train_step_fnorobjective) and full precision (FP32). It also requires no topology-changing callback:QATLifecycleCallback, or anything else that declarescheckpoint_transforms. Anything else is refused before a run is reserved. DDP, quantization and the KV-cache generation path are not covered. - The canonical module stays canonical. The compiled wrapper is built per
train()call besidemodel.net, never assigned to it, and dropped when the fit ends. Optimizers ownmodel.net's parameters. Checkpoints hold its keys (never_orig_mod.). A resume builds a fresh wrapper. - Eager paths. Hub and ONNX export,
summary, weight tying and everypredict/evaluateafter the fit are eager.generateis always eager. Apredict/evaluatethat a callback runs during the fit goes through the wrapper. - Failure policy.
CompileSpec(on_failure="error")(default) raisesCompileFailedwhen a compiled forward fails to compile. Withfullgraph=True, a graph break is such a failure.on_failure="eager"re-runs that forward eagerly, from the RNG state and buffers it started with, and finishes the run eager. - Eager re-run errors. A failure the eager re-run hits too is the
forward's own error, a shape mistake for example. It propagates unchanged.
The model's RNG and buffers are left as the failed call found them. Under
on_failure="error", a compile failure leaves them so too. - Recompile limit. Hitting dynamo's recompile limit is a failure, never a silent eager fallback (torch >= 2.6 reports it). The limit is per code object and process-wide. Each fit therefore gets it afresh, on top of the entries earlier fits of the same net class hold. A sweep of many compiled fits in one process keeps working.
- Very long sweeps.
torch._dynamo.reset()clears the cache. Dynamo's overall cap per code object,accumulated_recompile_limit, still applies to very long sweeps. - Only the forward is retried. These propagate: a backward that fails to
compile, clipping, or the optimizer step. A failed backward compile is
recorded
failed, stagebackward. Conservatively, that covers any error in the first backward after a new graph compiled. A partly applied update is therefore never replayed. - Recorded.
CompileRecordholds the request and what took effect. The request is the backend,mode/fullgraph/dynamic, the policy and the torch version. The effect is one of these:pending: no compiled forward has run yet.compiled, withcapturefullorpartialand thegraph_breakscount.eager, with therestart(phase, epoch, batch, error).failed.
- Where the record lives. The same record is on
NNRun.compile, inmetadata.yamland in each stateful checkpoint's training state. With a provenance manifest, it is also inattempt.json. It is never in the run id. The run and its checkpoints carry it as of their last commit;attempt.jsoncarries it as of the attempt's end. A failure after the last commit therefore shows only inattempt.json. - Graph breaks. The graph breaks are counted when the first call
compiles. A graph served from dynamo's process-wide cache (the same model
or class compiled earlier) leaves the capture unknown (
None). Torch reuses such a graph even underfullgraph=True.
nnx.benchmarking measures instead of assuming. benchmark_forward times
the first call (compilation included) separately from repeats warmed calls
after warmup untimed ones. It synchronizes the device around each timed
call and resets CUDA peak-memory counters before the warmed region. It
reports shapes, dtypes, backend, device and the latencies' mean / median /
stdev.
compare_compile benchmarks eager and compiled copies of identical weights.
profile_forward runs torch.profiler with a finite wait / warmup /
active / repeat schedule into an explicit output_dir. The profiler is
closed on success, error and cancellation, and never runs inside a timed
region. See examples/benchmark_compile.py.
29. Budgeted experiment search (nnx.search)¶
nnx.search.search(plan, space, apply=..., monitor=..., budget=..., study_name=...)
(the optuna extra) runs a sequential Optuna study over an
experiment plan. Each trial's parameters go
through apply(plan, params), which returns a new plan; the base plan is
never changed. ExperimentPlan.fit builds a fresh model, optimizer, loaders
and callbacks, with its own attempt id and run directory. The plan's data and
callbacks must therefore be factories.
refuse a multi-rank process group (torchrun) ── before anything below
validate (space, budget, monitor = plan's val monitor, factories, stored identity)
└─ while trials so far < budget.trials and the deadline has not passed:
Study.optimize(objective, n_trials=1)
params = sampler.ask → plan = apply(base, params) → plan.fit(attempt)
per committed update: update cap / deadline → stop at that update
per validated epoch: report(value, step=epoch) → prune? / deadline?
→ value (completed) | TrialPruned | failure (non-finite, error, no epoch)
- One monitor. Trials are scored by a validation
MonitorSpecequal to the plan's ownNNTrainParams.monitor. The search re-checks this on every planapplyreturns. A trial's value is the run's own monitor decision: the value of the epoch its BEST checkpoint holds,min_deltaand ties included. A training or last-batch value never scores a trial; a missing or non-finite value fails it. - Errors. A fit that raises is a failed trial, recorded with its
message (
TrialOutcome.error), and the search goes on. - Configuration errors. A configuration error fails that trial and stops
the search with the error. The failed trial counts against a stored
study's budget. Configuration errors are
applyraising,applyreturning something other than a plan, or a plan the search cannot score (borrowed callbacks, another monitor).updates_per_trialis refused for a plan with its owntrain_step_fn, which may not report updates. - The budget counts every trial: completed, failed, pruned and orphaned
ones (left running by a crashed process). Only enqueued, never-run trials
do not count. A sampler that runs out of points (an exhausted
GridSampler) ends the search withstop_reason="exhausted", spending no budget. - Stored studies. Reopening a stored study continues it. A study whose space, monitor or plan identity differs is refused before any trial.
- Caps.
SearchBudget(updates_per_trial=N)stops a trial right after its N-th committed optimizer update; a partial accumulation window is not one.deadline_secondsstarts no trial once expired. It stops a running trial at its next update or epoch boundary, recording the overrun. Both caps are non-preemptive. - Stopping inside an epoch. A trial stopped inside an epoch keeps only
its committed epochs. LAST, its tensors and the history stay those of the
last completed epoch (
ctx.stop_at_update, §6.1). There is no LAST before a first completed epoch. - Outcomes (
TrialOutcome) keep the parameters, state and terminal reason, the resources used and the best monitor value. They also keep the pruning observations and the attempt / run / checkpoint links.SearchResult.bestis the completed trial with the best finite value. - Cancellation (
KeyboardInterrupt) runs the fit's callback cleanup, releases its run lease, records the trial as failed and propagates.
NNx's artifacts live under runs/. Optuna's live in the storage URL (in
memory when None). This process is the study's only writer. Inside a
process group of more than one rank (torchrun), search() raises
RuntimeError before loading or creating a study. No rank therefore runs a
study of its own. Samplers keep their own limits. See
examples/search_offline.py.