Source code for nvalchemi.training.strategy

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Training strategy lifecycle and default forward-pass helper.

``TrainingStrategy`` wires one named model (``"main"``) or a dictionary-like
collection of named models through a user-supplied ``training_fn``.
Single-model strategies call ``training_fn(model, batch)``; named-model
strategies call ``training_fn(models, batch)`` for distillation or multi-model
workflows.
Models omitted from optimizer configs are temporarily set to eval mode and
frozen during ``run``. Named-model training functions that use omitted models as
teacher/auxiliary networks must run those forward passes under
``torch.no_grad()`` or detach returned tensors unless autograd through those
outputs is intentionally required.

Loss hooks see live autograd-connected losses from ``AFTER_LOSS`` through
``BEFORE_BACKWARD``. From ``AFTER_BACKWARD`` onward the hook context carries
detached loss tensors so logging hooks do not accidentally retain graphs.
"""

from __future__ import annotations

import dataclasses
import itertools
import math
import warnings
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from contextlib import AbstractContextManager, nullcontext
from pathlib import Path
from types import TracebackType
from typing import TYPE_CHECKING, Annotated, Any

import torch
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    PrivateAttr,
    SkipValidation,
    field_validator,
    model_validator,
)
from torch import nn
from torch.optim.lr_scheduler import LRScheduler

from nvalchemi._serialization import _import_cls
from nvalchemi._typing import ModelOutputs
from nvalchemi.distributed import DistributedManager
from nvalchemi.hooks._context import TrainContext
from nvalchemi.hooks._protocol import Hook
from nvalchemi.hooks._registry import HookRegistryMixin
from nvalchemi.models.base import BaseModelMixin
from nvalchemi.training import _spec_utils as strategy_spec
from nvalchemi.training import _strategy_validation as strategy_validation
from nvalchemi.training import _validation
from nvalchemi.training._spec import BaseSpec, create_model_spec
from nvalchemi.training._stages import TrainingStage
from nvalchemi.training._validation import ValidationConfig
from nvalchemi.training.distributed import get_rank as get_distributed_rank
from nvalchemi.training.distributed import get_world_size
from nvalchemi.training.hooks import TrainingUpdateHook, TrainingUpdateOrchestrator
from nvalchemi.training.hooks.mixed_precision import MixedPrecisionHook
from nvalchemi.training.hooks.update import (
    _fold_training_update_hooks,
    _hook_claims_stage,
)
from nvalchemi.training.losses.base import LossWeightSchedule
from nvalchemi.training.losses.composition import (
    ComposedLossFunction,
    ComposedLossOutput,
    LossTargetAssemblyProtocol,
    as_composed_loss,
    assemble_loss_targets,
    compute_supervised_loss,
    loss_component_to_spec,
    loss_target_keys,
)
from nvalchemi.training.optimizers import (
    OptimizerConfig,
    SchedulerMetricAdapter,
    _normalize_optimizer_configs,
    iter_qualified_named_parameters,
    setup_optimizers,
    step_lr_schedulers,
    step_metric_schedulers,
    step_optimizers,
    zero_gradients,
)
from nvalchemi.training.runtime import (
    freeze_unconfigured_models,
    move_to_devices,
    train_configured_models,
)

if TYPE_CHECKING:
    from nvalchemi.data.batch import Batch
    from nvalchemi.training._checkpoint import CheckpointValidator

__all__ = ["TrainingStrategy", "default_training_fn"]

_RESTART_COUNTER_FIELDS = (
    "step_count",
    "global_step_count",
    "batch_count",
    "epoch_count",
    "epoch_step_count",
)


@dataclasses.dataclass(frozen=True)
class _RuntimeOptimizer:
    """Bind an optimizer to its scheduler and metric adapter as one unit.

    Users pass aligned ``optimizer_configs`` and the strategy keeps the
    derived optimizer, scheduler, and scheduler-metric adapter together
    in a single record so the three can never drift out of positional
    correspondence internally.

    Attributes
    ----------
    optimizer : torch.optim.Optimizer
        The built optimizer.
    scheduler : LRScheduler | None
        The built LR scheduler, or ``None`` when the config declared no
        scheduler.
    adapter : SchedulerMetricAdapter
        The metric adapter (callable, summary-key string, or ``None``)
        used to extract a scalar for a metric-driven scheduler.
    """

    optimizer: torch.optim.Optimizer
    scheduler: LRScheduler | None
    adapter: SchedulerMetricAdapter


def _loss_weight_to_spec(weight: Any) -> Any:
    """Serialize a composed-loss weight schedule while leaving scalars unchanged."""
    if not isinstance(weight, LossWeightSchedule):
        # Plain scalar weights are already JSON-safe values.
        return weight

    # LossWeightSchedule requires a config-style serialization hook.
    spec = weight.to_spec()
    if not isinstance(spec, BaseSpec):
        raise ValueError(
            f"Loss weight schedule {type(weight).__name__}.to_spec() must "
            "return a BaseSpec-derived spec, got "
            f"{type(spec).__name__}."
        )
    return spec


def _validate_single_do_claimants(
    hooks: Sequence[Hook],
    *,
    extra_hook: Hook | None = None,
    extra_stage: TrainingStage | None = None,
) -> None:
    """Raise if more than one hook claims a DO update stage."""
    candidates: list[Hook] = list(hooks)
    if extra_hook is not None and all(h is not extra_hook for h in candidates):
        candidates.append(extra_hook)
    for do_stage in (TrainingStage.DO_BACKWARD, TrainingStage.DO_OPTIMIZER_STEP):
        claimants = [
            h
            for h in candidates
            if _hook_claims_stage(h, do_stage)
            or (h is extra_hook and extra_stage == do_stage)
        ]
        if len(claimants) > 1:
            names = ", ".join(type(h).__name__ for h in claimants)
            migration_hint = (
                " If one claimant is a plain DO-stage hook that should compose "
                "with update policies, implement it as TrainingUpdateHook so it "
                "runs inside the TrainingUpdateOrchestrator."
                if any(isinstance(h, TrainingUpdateOrchestrator) for h in claimants)
                else " Compose claim semantics are reserved for a future feature."
            )
            raise ValueError(
                f"At most one hook may claim {do_stage.name}; got "
                f"{len(claimants)}: {names}.{migration_hint}"
            )


def _hook_needs_prior_update_orchestrator(hook: Hook, stage: TrainingStage) -> bool:
    """Return whether ``hook`` requires the update orchestrator before ``stage``."""
    check = getattr(hook, "_requires_update_orchestrator_before_stage", None)
    return bool(check is not None and check(stage))


def _order_update_orchestrator_before_dependent_hooks(
    hooks: Sequence[Hook | TrainingUpdateOrchestrator],
) -> list[Hook | TrainingUpdateOrchestrator]:
    """Move the update orchestrator before hooks that observe its post-step state."""
    result = list(hooks)
    orchestrator_index = next(
        (
            index
            for index, hook in enumerate(result)
            if isinstance(hook, TrainingUpdateOrchestrator)
        ),
        None,
    )
    if orchestrator_index is None:
        return result
    first_dependent_index = next(
        (
            index
            for index, hook in enumerate(result[:orchestrator_index])
            if _hook_needs_prior_update_orchestrator(
                hook, TrainingStage.AFTER_OPTIMIZER_STEP
            )
        ),
        None,
    )
    if first_dependent_index is None:
        return result
    orchestrator = result.pop(orchestrator_index)
    result.insert(first_dependent_index, orchestrator)
    return result


def _validate_hook_dependencies(
    hooks: Sequence[Hook | TrainingUpdateOrchestrator],
) -> None:
    """Ask hooks to validate dependencies against the full registered set."""
    for hook in hooks:
        validate = getattr(hook, "_validate_registered_hooks", None)
        if validate is not None:
            validate(hooks)


def _iter_registered_hooks(
    hooks: Iterable[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator],
) -> Iterator[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator]:
    """Yield registered hooks and children nested in update orchestrators."""
    for hook in hooks:
        yield hook
        if isinstance(hook, TrainingUpdateOrchestrator):
            yield from _iter_registered_hooks(hook.iter_hooks())


[docs] def default_training_fn(model: BaseModelMixin, batch: Batch) -> dict[str, torch.Tensor]: """Run a forward pass and prefix output keys with ``predicted_``. Parameters ---------- model : BaseModelMixin A wrapped MLIP whose ``__call__`` returns model outputs. batch : Batch Input batch of atomic graphs. Returns ------- dict[str, torch.Tensor] Predictions keyed by ``predicted_<output_name>`` with ``None`` outputs omitted. """ outputs: ModelOutputs = model(batch) return { f"predicted_{key}": value for key, value in outputs.items() if value is not None }
[docs] class TrainingStrategy(BaseModel, HookRegistryMixin): """Pydantic-driven supervised training loop for MLIP models. ``TrainingStrategy`` is the top-level object that owns a supervised training run. You construct it declaratively with the models to train, the optimizer/scheduler recipe, a duration, a loss, and a forward-pass function, then call :meth:`run` with a dataloader to execute the loop. Because the strategy is a :class:`pydantic.BaseModel`, construction validates the whole configuration up front — mismatched optimizer keys, an invalid duration, or an ill-typed ``training_fn`` surface as a :class:`pydantic.ValidationError` before any training starts. The strategy accepts either a single wrapped model (a :class:`~nvalchemi.models.base.BaseModelMixin`), which is stored internally under the key ``"main"``, or a ``{name: model}`` mapping for distillation and multi-model workflows. That choice drives the ``training_fn`` calling convention: single-model strategies call ``training_fn(model, batch)`` and named-model strategies call ``training_fn(models, batch)``. Most workflows use the provided :func:`default_training_fn`, which runs a forward pass and prefixes output keys with ``predicted_`` for loss-target assembly. The loss may be a leaf loss (auto-normalized to a one-component :class:`~nvalchemi.training.losses.composition.ComposedLossFunction`) or an explicit composition such as ``EnergyMSELoss() + ForceMSELoss(...)``. ``optimizer_configs`` accepts a single :class:`OptimizerConfig`, a list, or a ``{model_name: [OptimizerConfig, ...]}`` mapping; unkeyed forms require a single-model input. Keys may target a subset of ``models`` — any model without a config is temporarily set to eval mode and frozen during :meth:`run`, which is what makes teacher/auxiliary networks in distillation work. Named-model training functions that consume those frozen models must still run their forward passes under ``torch.no_grad()`` or detach the outputs unless autograd through them is intentional. Duration is set by exactly one of ``num_epochs`` or ``num_steps`` (both the default, or setting both, is rejected). Internally the target is always an optimizer-step count; ``num_epochs`` is converted from the dataloader length scaled by ``epoch_step_modifier``, so epoch-based runs require a sized dataloader. Behavior is customized through ``hooks`` (checkpointing, logging, gradient clipping, EMA, DDP, mixed precision, ...) and validation is enabled by attaching a :class:`~nvalchemi.training._validation.ValidationConfig` via ``validation_config``. Runs are restartable: :meth:`save_checkpoint` and :meth:`restore_checkpoint` persist the recipe plus runtime counters, while :meth:`to_spec_dict` / :meth:`from_spec_dict` handle JSON-based recipe-only save/load. Examples -------- Single-model supervised training for a fixed number of epochs: >>> import torch # doctest: +SKIP >>> from nvalchemi.training import ( # doctest: +SKIP ... EnergyMSELoss, ... ForceMSELoss, ... OptimizerConfig, ... TrainingStrategy, ... default_training_fn, ... ) >>> strategy = TrainingStrategy( # doctest: +SKIP ... models=model, ... optimizer_configs=OptimizerConfig( ... optimizer_cls=torch.optim.Adam, ... optimizer_kwargs={"lr": 1e-3}, ... ), ... num_epochs=10, ... training_fn=default_training_fn, ... loss_fn=EnergyMSELoss() + ForceMSELoss(normalize_by_atom_count=True), ... devices=[torch.device("cuda")], ... ) >>> strategy.run(train_loader) # doctest: +SKIP Step-based training with periodic validation and a checkpoint hook: >>> from nvalchemi.training import ValidationConfig # doctest: +SKIP >>> strategy = TrainingStrategy( # doctest: +SKIP ... models=model, ... optimizer_configs=OptimizerConfig(optimizer_cls=torch.optim.AdamW), ... num_steps=50_000, ... training_fn=default_training_fn, ... loss_fn=EnergyMSELoss(), ... validation_config=ValidationConfig( ... validation_data=val_batches, ... every_n_steps=1_000, ... ), ... hooks=[CheckpointHook(checkpoint_dir="runs/exp")], ... ) >>> strategy.run(train_loader) # doctest: +SKIP Named-model (distillation) setup optimizing only the student while the teacher stays frozen because it is absent from ``optimizer_configs``: >>> strategy = TrainingStrategy( # doctest: +SKIP ... models={"student": student, "teacher": teacher}, ... optimizer_configs={ ... "student": [OptimizerConfig(optimizer_cls=torch.optim.Adam)] ... }, ... num_steps=10_000, ... training_fn=distillation_training_fn, ... loss_fn=EnergyMSELoss(), ... ) Notes ----- Exactly one of ``num_epochs`` and ``num_steps`` must be set; ``num_epochs`` additionally requires a sized dataloader so it can be converted to a step target. Every ``optimizer_configs`` key must name a model present in ``models``, and each entry must contain at least one :class:`OptimizerConfig`. ``devices`` must have length ``1`` or ``len(models)``; named-model :meth:`run` currently supports a single shared device only. Use :meth:`to_spec_dict` / :meth:`from_spec_dict` for JSON-based save/load. Optimizer configs, loss specs, devices, importable training functions, and best-effort model specs are serialized. Runtime ``models`` and ``training_fn`` overrides passed to :meth:`from_spec_dict` take precedence; the serialized model call mode is used only when no runtime model override is supplied. ``hooks``, ``step_count``, ``global_step_count``, ``batch_count``, ``epoch_count``, and ``epoch_step_count`` remain runtime-only. Bare :class:`TrainingUpdateHook` instances are auto-wrapped into a single :class:`TrainingUpdateOrchestrator` on registration; the orchestrator owns the ``zero_gradients`` / ``backward`` / ``optimizer.step`` / ``scheduler.step`` calls that the strategy otherwise issues by default. Construction-time hook validation errors surface as :class:`pydantic.ValidationError`; :meth:`register_hook` raises :class:`ValueError` directly. """ models: Annotated[ dict[str, BaseModelMixin], Field( description=( "Named models visible to ``training_fn`` and hooks. Single-model " 'inputs are stored under ``"main"``; :class:`torch.nn.ModuleDict` ' "inputs are accepted and normalized to a plain ``dict``." ) ), ] optimizer_configs: dict[str, list[OptimizerConfig]] = Field( default_factory=dict, description=( "Optimizer/scheduler configs keyed by model name. Keys may target a " "subset of ``models``; omitted models are frozen/eval during ``run``." ), ) num_epochs: int | None = Field( default=None, ge=1, description=( "Epoch count; mutually exclusive with ``num_steps``. At runtime, " "epochs are converted into a target step count from the dataloader " "length and ``epoch_step_modifier``." ), ) num_steps: int | None = Field( default=None, ge=1, description="Target step count; mutually exclusive with ``num_epochs``.", ) epoch_step_modifier: float = Field( default=1.0, gt=0, allow_inf_nan=False, description=( "Positive multiplier applied when converting ``num_epochs`` to a " "target step count. Hooks may inspect this value through " "``ctx.workflow``." ), ) hooks: list[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator] = Field( default_factory=list, description=( "Hooks to run at training stages. Accepts ``Hook`` Protocol " "instances, bare ``TrainingUpdateHook`` instances (auto-wrapped " "into a single ``TrainingUpdateOrchestrator``), or an explicit " "``TrainingUpdateOrchestrator``. Example: " "``hooks=[CheckpointHook(...), MyClipGradHook()]``." ), ) training_fn: Annotated[ Callable[..., Mapping[str, torch.Tensor]] | None, Field( description=( "Explicit forward-pass callable. Single-model strategies call " "``(model, batch)``; named-model strategies call " "``(models, batch)``." ) ), ] = None loss_fn: Annotated[ ComposedLossFunction, Field( description=( "Composed loss whose components drive target collection. Leaf " "losses are accepted and normalized to one-component composed " "losses." ) ), ] loss_target_assembler: Annotated[LossTargetAssemblyProtocol, SkipValidation()] = ( Field( default=assemble_loss_targets, exclude=True, description=( "Callable that assembles loss targets from the loss function, " "training predictions, current batch, and optional workflow." ), ) ) devices: list[torch.device] = Field( default_factory=lambda: [torch.device("cpu")], description=( "One device shared by all models, or one device per model for helper " "placement. Named-model ``run`` currently supports one device only." ), ) distributed_manager: Annotated[DistributedManager | None, SkipValidation()] = Field( default=None, exclude=True, description=( "Optional external distributed manager. The strategy passes this " "through hook contexts for distributed-aware hooks." ), ) step_count: int = Field( default=0, ge=0, exclude=True, description=( "Runtime optimizer-step counter, excluded from specs. Batches whose " "optimizer step is skipped by update hooks do not advance this " "counter." ), ) global_step_count: int = Field( default=0, ge=0, exclude=True, description=( "Runtime optimizer-step counter across all data-parallel workers, " "excluded from specs. This advances by the distributed world size " "when an optimizer step runs, so checkpoint restarts can recover " "sampler progress without assuming the same world size." ), ) batch_count: int = Field( default=0, ge=0, exclude=True, description=( "Runtime batch counter, excluded from specs. This advances for every " "completed batch, including batches whose optimizer step is skipped." ), ) epoch_count: int = Field( default=0, ge=0, exclude=True, description="Runtime epoch counter, excluded from specs.", ) epoch_step_count: int = Field( default=0, ge=0, exclude=True, description=( "Runtime counter for batches consumed within the current epoch, " "excluded from specs." ), ) single_model_input: bool = Field( default=False, exclude=True, description=( "Runtime flag recording whether a single model was supplied (stored " 'under ``"main"``) rather than a named mapping. Set automatically ' "during validation and used to pick the ``training_fn`` call convention." ), ) last_validation: dict[str, Any] | None = Field( default=None, exclude=True, description=( "Most recent validation summary dict, or ``None`` before the first " "validation pass. Exposed to hooks via ``ctx.validation``." ), ) inference_model: nn.Module | nn.ModuleDict | None = Field( default=None, exclude=True, description=( "Optional inference-time model (e.g. EMA weights) used in place of the " "live training model for validation when ``ValidationConfig.use_ema`` " "is set." ), ) validation_config: ValidationConfig | None = Field( default=None, exclude=True, description=( "Validation configuration controlling when and how validation runs. " "``None`` disables validation." ), ) _context_depth: int = PrivateAttr(default=0) _ctx: TrainContext | None = PrivateAttr(default=None) _has_do_backward_claim: bool = PrivateAttr(default=False) _has_do_optimizer_step_claim: bool = PrivateAttr(default=False) _has_update_orchestrator: bool = PrivateAttr(default=False) _resume_optimizer_state: bool = PrivateAttr(default=False) _runtime_optimizers: list[_RuntimeOptimizer] = PrivateAttr(default_factory=list) _active_dataloader: Any = PrivateAttr(default=None) _optimizer_parameter_names: set[str] | None = PrivateAttr(default=None) _requires_grad_parameter_names: set[str] | None = PrivateAttr(default=None) _force_trainable_parameter_names: set[str] | None = PrivateAttr(default=None) _original_requires_grad: dict[str, bool] = PrivateAttr(default_factory=dict) model_config = ConfigDict( arbitrary_types_allowed=True, extra="forbid", # To minimize overhead, validation is only performed at the # initial construction validate_assignment=False, revalidate_instances="never", ) _stage_type = TrainingStage @property def epoch(self) -> int: """Backward-compatible alias for :attr:`epoch_count`.""" return self.epoch_count @epoch.setter def epoch(self, value: int) -> None: self.epoch_count = value @property def active_dataloader(self) -> Any: """Return the dataloader currently owned by the training workflow.""" return self._active_dataloader @active_dataloader.setter def active_dataloader(self, dataloader: Any) -> None: """Set the dataloader currently owned by the training workflow.""" self._active_dataloader = dataloader @model_validator(mode="before") @classmethod def _normalize_inputs(cls, data: Any) -> Any: """Normalize model and optimizer input shapes before field validation.""" if not isinstance(data, dict): return data normalized = dict(data) raw_models = normalized.get("models") single_model_input = isinstance(raw_models, BaseModelMixin) if "models" in normalized: normalized["models"] = strategy_validation._normalize_models(raw_models) if "optimizer_configs" in normalized: normalized["optimizer_configs"] = _normalize_optimizer_configs( normalized["optimizer_configs"], single_model_input=single_model_input ) if "epoch" in normalized and "epoch_count" not in normalized: normalized["epoch_count"] = normalized.pop("epoch") normalized["single_model_input"] = single_model_input return normalized @field_validator("loss_fn", mode="before") @classmethod def _normalize_loss_fn(cls, value: Any) -> Any: """Normalize a leaf loss into a one-component composed loss.""" try: return as_composed_loss(value) except TypeError as exc: raise RuntimeError( "Only loss functions that inherit `BaseLossFunction` or" " a composition of loss functions is accepted." ) from exc @field_validator("training_fn", mode="before") @classmethod def _resolve_training_fn(cls, value: Any) -> Any: """Resolve a dotted-path string to a callable, or accept a callable as-is.""" if isinstance(value, str): value = strategy_spec._resolve_dotted_callable(value) if value is None: raise ValueError(strategy_validation._TRAINING_FN_REQUIRED_MESSAGE) if not callable(value): raise ValueError( f"training_fn must be callable or a dotted path string, got " f"{type(value).__name__}." ) return value @field_validator("hooks", mode="before") @classmethod def _autowrap_update_hooks(cls, value: Any) -> Any: """Fold bare ``TrainingUpdateHook`` instances into a single orchestrator.""" if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): return value return _order_update_orchestrator_before_dependent_hooks( _fold_training_update_hooks(value) ) @model_validator(mode="after") def _validate_strategy(self) -> TrainingStrategy: """Enforce model, duration, optimizer, and device consistency.""" have_epochs = self.num_epochs is not None have_steps = self.num_steps is not None if have_epochs == have_steps: raise ValueError( "Exactly one of num_epochs or num_steps must be set; " f"got num_epochs={self.num_epochs!r}, num_steps={self.num_steps!r}." ) if not self.models: raise ValueError("models must contain at least one BaseModelMixin.") if not self.optimizer_configs: raise ValueError( "optimizer_configs must configure at least one model; " "got an empty mapping." ) for idx, cfgs in self.optimizer_configs.items(): if idx not in self.models: raise ValueError( f"optimizer_configs key {idx!r} is not present in models; " f"available model keys: {sorted(self.models)}." ) if not cfgs: raise ValueError( f"optimizer_configs[{idx!r}] must contain at least one " "OptimizerConfig." ) if not self.devices: raise ValueError("devices must contain at least one torch.device.") n_devices = len(self.devices) if n_devices not in (1, len(self.models)): raise ValueError( f"devices must have length 1 or len(models)={len(self.models)}; " f"got {n_devices}." ) if self.training_fn is None: raise ValueError(strategy_validation._TRAINING_FN_REQUIRED_MESSAGE) strategy_validation._validate_training_fn_call_shape( self.training_fn, single_model_input=self.single_model_input ) for idx, weight in enumerate(self.loss_fn._weights): try: _loss_weight_to_spec(weight) except ValueError as exc: raise ValueError(f"loss_fn weights[{idx}]: {exc}") from exc hook_ids = [id(hook) for hook in self.hooks] if len(hook_ids) != len(set(hook_ids)): raise ValueError( "hooks must not contain duplicate hook instances; pass distinct " "hook objects instead." ) _validate_single_do_claimants(self.hooks) _validate_hook_dependencies(self.hooks) if self.global_step_count == 0 and self.step_count > 0: self.global_step_count = self.step_count * get_world_size( self.distributed_manager ) return self def model_post_init(self, __context: Any) -> None: """Initialize hook storage, per-run counters, and cached target keys.""" self._init_hooks(list(self.hooks)) self._refresh_hook_claim_flags() self._last_batch: Batch | None = None self._last_losses: ComposedLossOutput | None = None self._last_loss: torch.Tensor | None = None self._optimizers: list[torch.optim.Optimizer] = [] self._lr_schedulers: list[LRScheduler | None] = [] self._runtime_optimizers = [] self._context_depth = 0 self._ctx = None self._target_keys: tuple[str, ...] = loss_target_keys(self.loss_fn) def _refresh_hook_claim_flags(self) -> None: """Recompute cached DO-stage claim and orchestrator-presence flags.""" self._has_do_backward_claim = ( sum( 1 for hook in self.hooks if _hook_claims_stage(hook, TrainingStage.DO_BACKWARD) ) == 1 ) self._has_do_optimizer_step_claim = ( sum( 1 for hook in self.hooks if _hook_claims_stage(hook, TrainingStage.DO_OPTIMIZER_STEP) ) == 1 ) self._has_update_orchestrator = any( isinstance(hook, TrainingUpdateOrchestrator) for hook in self.hooks ) def _replace_hooks_with_registry_validation(self, hooks: Sequence[Hook]) -> None: """Replace hook storage after validating each hook through the base registry.""" previous_hooks = self.hooks self.hooks = [] try: for hook in hooks: HookRegistryMixin.register_hook(self, hook) except Exception: self.hooks = previous_hooks raise def set_optimizer_parameter_filter(self, names: set[str] | None) -> None: """Set fully-qualified parameter names eligible for optimizer setup. Parameters ---------- names : set[str] | None Fully-qualified names like ``"main.model.projection.weight"``. ``None`` clears the filter. """ self._optimizer_parameter_names = None if names is None else set(names) def set_trainable_parameter_filter(self, names: set[str] | None) -> None: """Set fully-qualified parameter names kept trainable during ``run``. Parameters ---------- names : set[str] | None Fully-qualified names whose existing ``requires_grad`` state is preserved. Parameters not in the set are temporarily marked ``requires_grad=False`` for ``run`` and restored afterward. ``None`` clears the filter. """ self._requires_grad_parameter_names = None if names is None else set(names) def set_force_trainable_parameter_filter(self, names: set[str] | None) -> None: """Set fully-qualified parameter names temporarily marked trainable. Parameters ---------- names : set[str] | None Fully-qualified names whose ``requires_grad`` state is temporarily set to ``True`` during training setup. ``None`` clears the filter. """ self._force_trainable_parameter_names = None if names is None else set(names) def _apply_requires_grad_filter(self) -> None: """Temporarily disable gradients outside the trainable allow-list.""" if ( self._requires_grad_parameter_names is None and self._force_trainable_parameter_names is None ): return self._original_requires_grad = {} force_trainable = self._force_trainable_parameter_names or set() for name, parameter in iter_qualified_named_parameters(self.models): self._original_requires_grad[name] = parameter.requires_grad if name in force_trainable: parameter.requires_grad_(True) elif ( self._requires_grad_parameter_names is not None and name not in self._requires_grad_parameter_names ): parameter.requires_grad_(False) def _restore_requires_grad_filter(self) -> None: """Restore parameter ``requires_grad`` states saved before ``run``.""" if not self._original_requires_grad: return named_parameters = dict(iter_qualified_named_parameters(self.models)) for name, requires_grad in self._original_requires_grad.items(): parameter = named_parameters.get(name) if parameter is not None: parameter.requires_grad_(requires_grad) self._original_requires_grad = {} def _zero_optimizer_filtered_gradients( self, opts: Iterable[torch.optim.Optimizer] ) -> None: """Clear gradients for trainable parameters excluded from optimizers.""" if ( self._optimizer_parameter_names is None or self._requires_grad_parameter_names is not None ): return optimizer_param_ids = { id(parameter) for optimizer in opts for group in optimizer.param_groups for parameter in group["params"] } for _, parameter in iter_qualified_named_parameters(self.models): if parameter.requires_grad and id(parameter) not in optimizer_param_ids: parameter.grad = None def register_hook( self, hook: Hook | TrainingUpdateHook | TrainingUpdateOrchestrator, stage: TrainingStage | None = None, ) -> None: """Register a hook, auto-wrapping bare update hooks when needed.""" is_update = isinstance(hook, (TrainingUpdateHook, TrainingUpdateOrchestrator)) if is_update and stage is not None: raise ValueError( "stage= is not supported for TrainingUpdateHook or " "TrainingUpdateOrchestrator registration. Update hooks declare " "their stages through _runs_on_stage and are auto-wrapped into " "one TrainingUpdateOrchestrator." ) if not is_update: _validate_single_do_claimants( self.hooks, extra_hook=hook, extra_stage=stage ) previous_hooks = list(self.hooks) try: super().register_hook(hook, stage=stage) _validate_hook_dependencies(self.hooks) except Exception: self.hooks = previous_hooks raise self._refresh_hook_claim_flags() return folded = _order_update_orchestrator_before_dependent_hooks( _fold_training_update_hooks([*self.hooks, hook]) ) _validate_single_do_claimants(folded) _validate_hook_dependencies(folded) self._replace_hooks_with_registry_validation(folded) self._refresh_hook_claim_flags() def _new_train_context(self, batch: Batch | None) -> TrainContext: """Build a fresh TrainContext snapshot for hooks or loss assembly.""" global_rank = get_distributed_rank(self.distributed_manager) return TrainContext( batch=batch, model=self.models.get("main"), global_rank=global_rank, workflow=self, step_count=self.step_count, global_step_count=self.global_step_count, batch_count=self.batch_count, epoch_step_count=self.epoch_step_count, models=self.models, epoch=self.epoch_count, loss=self._last_loss, losses=self._last_losses, optimizers=self._optimizers, lr_schedulers=self._lr_schedulers, validation=self.last_validation, ) def _build_context(self, batch: Batch | None) -> TrainContext: """Build a TrainContext, reusing the per-batch cache when populated.""" if self._ctx is not None: return self._ctx return self._new_train_context(batch) def _run_hooks(self, stage: TrainingStage, batch: Batch) -> None: """Dispatch hooks for ``stage`` with an early-return fast path.""" if not self.hooks: return self._call_hooks(stage, batch) def _refresh_hook_counters(self) -> None: """Mirror current strategy counters into the cached hook context.""" if self._ctx is None: return self._ctx.step_count = self.step_count self._ctx.global_step_count = self.global_step_count self._ctx.batch_count = self.batch_count self._ctx.epoch_step_count = self.epoch_step_count self._ctx.epoch = self.epoch_count self._ctx.validation = self.last_validation def __enter__(self) -> TrainingStrategy: """Enter hook context managers registered on this strategy.""" if self._context_depth > 0: self._context_depth += 1 return self for hook in self.hooks: if hasattr(hook, "__enter__"): hook.__enter__() self._context_depth = 1 return self def __exit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None, ) -> None: """Exit or close hook contexts registered on this strategy.""" if self._context_depth == 0: return self._context_depth -= 1 if self._context_depth > 0: return for hook in reversed(self.hooks): if hasattr(hook, "__exit__"): hook.__exit__(exc_type, exc, tb) elif hasattr(hook, "close"): hook.close() def _prepare_setup_hooks(self) -> None: """Allow hooks to prepare runtime state before device placement.""" for hook in self.hooks: prepare = getattr(hook, "prepare_strategy", None) if callable(prepare): prepare(self) def _run_setup_hooks(self, dataloader: Any = None) -> Any: """Run setup-stage hooks and return the active dataloader.""" if not self.hooks: return dataloader self.active_dataloader = dataloader ctx = self._build_context(None) for hook in self.hooks: should_run_setup = _hook_claims_stage( hook, TrainingStage.SETUP ) or isinstance(hook, TrainingUpdateOrchestrator) if not should_run_setup: continue if self.step_count % hook.frequency != 0: continue hook(ctx, TrainingStage.SETUP) return self.active_dataloader def _validate_runtime_devices(self) -> None: """Raise for runtime device layouts that cannot be executed.""" if not self.single_model_input and len(self.devices) > 1: raise ValueError( "Named-model training with multiple devices is unsupported: " "training_fn(models, batch) receives one batch on one device. " "Use a single shared device or pass models=model for " "single-model behavior." ) def _setup_runtime_optimizers( self, *, rebuild: bool = False ) -> tuple[list[torch.optim.Optimizer], list[LRScheduler | None]]: """Build or reuse flattened runtime optimizer/scheduler lists.""" if not rebuild and self._runtime_optimizers: return self._optimizers, self._lr_schedulers records: list[_RuntimeOptimizer] = [] built = setup_optimizers( self.models, self.optimizer_configs, allowed_parameter_names=self._optimizer_parameter_names, ) for key, cfgs in _normalize_optimizer_configs( self.optimizer_configs, single_model_input=self.single_model_input ).items(): pairs = built[key] for cfg, (opt, sched) in zip(cfgs, pairs, strict=True): records.append( _RuntimeOptimizer( optimizer=opt, scheduler=sched, adapter=cfg.scheduler_metric_adapter, ) ) self._runtime_optimizers = records self._optimizers = [record.optimizer for record in records] self._lr_schedulers = [record.scheduler for record in records] return self._optimizers, self._lr_schedulers def _restore_runtime_optimizers_from_loaded_state(self) -> None: """Rebuild runtime optimizer records from checkpoint-loaded objects.""" records: list[_RuntimeOptimizer] = [] opt_cursor = 0 normalized = _normalize_optimizer_configs( self.optimizer_configs, single_model_input=self.single_model_input, ) for cfgs in normalized.values(): for cfg in cfgs: if opt_cursor >= len(self._optimizers): raise ValueError( "checkpoint did not restore enough optimizers for " "the strategy optimizer configuration." ) scheduler = self._lr_schedulers[opt_cursor] if cfg.scheduler_cls is not None and scheduler is None: raise ValueError( "checkpoint did not restore a scheduler required by " "the strategy optimizer configuration." ) records.append( _RuntimeOptimizer( optimizer=self._optimizers[opt_cursor], scheduler=scheduler, adapter=cfg.scheduler_metric_adapter, ) ) opt_cursor += 1 if opt_cursor != len(self._optimizers): raise ValueError( "checkpoint restored more optimizers than the strategy " "optimizer configuration expects." ) self._runtime_optimizers = records def train_batch(self, batch: Batch) -> None: """Train on a single batch using the configured training flow. This public one-batch API is intended for interactive workflows and tests where the caller already has a batch in hand. It runs the per-batch stages from ``BEFORE_BATCH`` through ``AFTER_BATCH``, but it does not run the outer ``BEFORE_TRAINING``/``AFTER_TRAINING`` or epoch-level hooks and does not enforce ``num_epochs``/``num_steps``. It still advances runtime counters: ``batch_count`` and ``epoch_step_count`` advance for every completed batch, while ``step_count`` advances only when the optimizer step executes. Optimizers and schedulers are built from ``optimizer_configs`` on first use and then reused by subsequent ``train_batch`` calls. Full :meth:`run` calls continue to rebuild optimizer state at the start of the run. Parameters ---------- batch : Batch Batch to train on. """ strategy_context = nullcontext(self) if self._context_depth > 0 else self with strategy_context: self._prepare_setup_hooks() self._validate_runtime_devices() self.models = move_to_devices(self.models, self.devices) self._run_setup_hooks() self._apply_requires_grad_filter() try: flat_opts, flat_scheds = self._setup_runtime_optimizers() batch = batch.to(self.devices[0], non_blocking=True) self._update_hook_snapshot(batch=batch, loss_out=None) with ( train_configured_models(self.models, self.optimizer_configs), freeze_unconfigured_models(self.models, self.optimizer_configs), ): self._train_batch_with_optimizers(batch, flat_opts, flat_scheds) finally: self._restore_requires_grad_filter() def _train_batch_with_optimizers( self, batch: Batch, flat_opts: list[torch.optim.Optimizer], flat_scheds: list[LRScheduler | None], ) -> None: """Forward-backward-optimize a single batch with hook dispatch.""" self._optimizers = flat_opts self._lr_schedulers = flat_scheds self._ctx = self._build_context(batch) if self.hooks else None try: self._run_hooks(TrainingStage.BEFORE_BATCH, batch) if not self._has_update_orchestrator: zero_gradients(flat_opts) self._zero_optimizer_filtered_gradients(flat_opts) self._run_hooks(TrainingStage.BEFORE_FORWARD, batch) model_arg = self.models["main"] if self.single_model_input else self.models predictions = self.training_fn(model_arg, batch) self._run_hooks(TrainingStage.AFTER_FORWARD, batch) self._run_hooks(TrainingStage.BEFORE_LOSS, batch) loss_out = self._compute_losses( predictions, batch, step=self.step_count, epoch=self.epoch_count, ) self._update_hook_snapshot(loss_out=loss_out) self._run_hooks(TrainingStage.AFTER_LOSS, batch) self._run_hooks(TrainingStage.BEFORE_BACKWARD, batch) if self._has_do_backward_claim: self._run_hooks(TrainingStage.DO_BACKWARD, batch) elif self._ctx is not None and self._ctx.loss is not None: self._ctx.loss.backward() else: loss_out["total_loss"].backward() self._run_backward_completion(batch, loss_out) optimizer_step_ran = self._run_optimizer_step_phase( batch, flat_opts, flat_scheds ) self.batch_count += 1 self.epoch_step_count += 1 if optimizer_step_ran: self.step_count += 1 self.global_step_count += get_world_size(self.distributed_manager) self._refresh_hook_counters() self._run_hooks(TrainingStage.AFTER_BATCH, batch) finally: self._ctx = None def _run_backward_completion( self, batch: Batch, loss_out: ComposedLossOutput ) -> None: """Publish detached losses, then fire the gradient-available stage.""" if self.hooks: self._update_hook_snapshot(loss_out=loss_out, detach=True) self._run_hooks(TrainingStage.AFTER_BACKWARD, batch) def _run_optimizer_step_phase( self, batch: Batch, flat_opts: list[torch.optim.Optimizer], flat_scheds: list[LRScheduler | None], ) -> bool: """Run the last pre-step hook, step owner, and step-aware post hook.""" self._run_hooks(TrainingStage.BEFORE_OPTIMIZER_STEP, batch) if self._has_do_optimizer_step_claim: self._run_hooks(TrainingStage.DO_OPTIMIZER_STEP, batch) optimizer_step_ran = self._optimizer_step_ran_after_do_stage() else: step_optimizers(flat_opts) step_lr_schedulers(flat_scheds) optimizer_step_ran = True self._run_hooks(TrainingStage.AFTER_OPTIMIZER_STEP, batch) return optimizer_step_ran def _optimizer_step_ran_after_do_stage(self) -> bool: """Return whether the DO optimizer-step owner reported an executed step.""" for hook in self.hooks: if isinstance(hook, TrainingUpdateOrchestrator): return not hook.optimizer_step_skipped return True def _compute_losses( self, predictions: Mapping[str, torch.Tensor], batch: Batch, *, step: int, epoch: int, ) -> ComposedLossOutput: """Run ``loss_fn`` with graph metadata threaded as keyword kwargs.""" return compute_supervised_loss( self.loss_fn, predictions, batch, step=step, epoch=epoch, workflow=self, target_assembler=self.loss_target_assembler, target_keys=self._target_keys, ) def _update_hook_snapshot( self, *, batch: Batch | None = None, loss_out: ComposedLossOutput | None = None, detach: bool = False, ) -> None: """Single mutation point for hook-visible transient state.""" if batch is not None: self._last_batch = batch if loss_out is None: self._last_loss = None self._last_losses = None elif detach: self._last_loss = loss_out["total_loss"].detach() self._last_losses = { "total_loss": loss_out["total_loss"].detach(), "per_component_unweighted": { k: v.detach() for k, v in loss_out["per_component_unweighted"].items() }, "per_component_weight": dict(loss_out["per_component_weight"]), "per_component_raw_weight": dict(loss_out["per_component_raw_weight"]), "per_component_sample": { k: v.detach() for k, v in loss_out["per_component_sample"].items() }, } else: self._last_loss = loss_out["total_loss"] self._last_losses = loss_out if self._ctx is not None: if batch is not None: self._ctx.batch = batch self._ctx.loss = self._last_loss self._ctx.losses = self._last_losses self._refresh_hook_counters() def _dataloader_length(self, dataloader: Iterable[Batch]) -> int | None: """Return ``len(dataloader)`` when available without iterating it.""" try: return len(dataloader) # type: ignore[arg-type] except TypeError: return None def _resolve_target_step_count(self, batches_per_epoch: int | None) -> int: """Resolve ``num_steps``/``num_epochs`` to an absolute step target.""" if self.num_steps is not None: return self.num_steps if batches_per_epoch is None: raise ValueError( "num_epochs requires a sized dataloader so epochs can be " "converted to a target step count. Use num_steps for unsized " "iterables." ) if batches_per_epoch <= 0: raise ValueError( "dataloader must contain at least one batch when num_epochs " "is configured." ) if self.num_epochs is None: raise RuntimeError("TrainingStrategy has neither num_epochs nor num_steps.") return math.ceil(self.num_epochs * batches_per_epoch * self.epoch_step_modifier) def _set_sampler_epoch(self, dataloader: Iterable[Batch]) -> None: """Set distributed/data-parallel sampler epoch when supported.""" batch_sampler = getattr(dataloader, "batch_sampler", None) candidates = ( getattr(dataloader, "sampler", None), batch_sampler, getattr(batch_sampler, "sampler", None), ) seen: set[int] = set() for sampler in candidates: if sampler is None or id(sampler) in seen: continue seen.add(id(sampler)) set_epoch = getattr(sampler, "set_epoch", None) if callable(set_epoch): set_epoch(self.epoch_count) return def _set_dataloader_epoch_step(self, dataloader: Iterable[Batch]) -> bool: """Seek dataloader intra-epoch position when supported. Returns ------- bool ``True`` when the dataloader accepted the current ``epoch_step_count`` and the training loop does not need to materialize skipped batches. """ if self.epoch_step_count <= 0: return False set_epoch_step = getattr(dataloader, "set_epoch_step", None) if not callable(set_epoch_step): return False set_epoch_step(self.epoch_step_count) return True def _prepare_epoch_step_count(self, batches_per_epoch: int | None) -> None: """Infer or normalize intra-epoch progress for restartable runs.""" if batches_per_epoch is None or batches_per_epoch <= 0: return if self.epoch_step_count >= batches_per_epoch: extra_epochs, self.epoch_step_count = divmod( self.epoch_step_count, batches_per_epoch ) self.epoch_count += extra_epochs completed_epoch_batches = self.epoch_count * batches_per_epoch raw_progress = self.batch_count or self.step_count if self.epoch_step_count: expected_progress = completed_epoch_batches + self.epoch_step_count if raw_progress and raw_progress != expected_progress: raise ValueError( "restart counters are inconsistent: batch_count or " "step_count does not match epoch_count * len(dataloader) " "+ epoch_step_count." ) self.batch_count = max(self.batch_count, expected_progress) return if raw_progress < completed_epoch_batches: raise ValueError( "restart counters are inconsistent: batch_count or step_count " "is smaller " "than epoch_count * len(dataloader)." ) elapsed_epoch_steps = raw_progress - completed_epoch_batches extra_epochs, self.epoch_step_count = divmod( elapsed_epoch_steps, batches_per_epoch ) self.epoch_count += extra_epochs self.batch_count = max(self.batch_count, raw_progress) def run( self, dataloader: Iterable[Batch], ) -> None: """Execute the training loop over ``dataloader``. Parameters ---------- dataloader : Iterable[Batch] Any iterable of batches; need not be a ``DataLoader``. The configured duration targets effective optimizer/scheduler steps. Batches whose optimizer step is skipped still advance the dataloader-position counters. Raises ------ ValueError If named-model training is configured with multiple devices, or if the dataloader produces no batches before the configured target step count is reached. """ training_started = False strategy_context = nullcontext(self) if self._context_depth > 0 else self with strategy_context: # --- Setup phase: prepare hooks, devices, dataloader, targets --- self._prepare_setup_hooks() self._validate_runtime_devices() self.models = move_to_devices(self.models, self.devices) dataloader = self._run_setup_hooks(dataloader) batches_per_epoch = self._dataloader_length(dataloader) target_step_count = self._resolve_target_step_count(batches_per_epoch) if self.step_count >= target_step_count: return self._prepare_epoch_step_count(batches_per_epoch) self._apply_requires_grad_filter() try: primary_device = self.devices[0] flat_opts, flat_scheds = self._setup_runtime_optimizers( rebuild=not self._resume_optimizer_state ) with ( train_configured_models(self.models, self.optimizer_configs), freeze_unconfigured_models(self.models, self.optimizer_configs), ): for _epoch_idx in itertools.count(): self._set_sampler_epoch(dataloader) dataloader_positioned = self._set_dataloader_epoch_step( dataloader ) processed_epoch_batch = False exhausted_dataloader = True for batch_idx, batch in enumerate(dataloader): if ( not dataloader_positioned and batch_idx < self.epoch_step_count ): continue if self.step_count >= target_step_count: exhausted_dataloader = False break batch = batch.to(primary_device, non_blocking=True) self._update_hook_snapshot(batch=batch, loss_out=None) if not training_started: self._run_hooks(TrainingStage.BEFORE_TRAINING, batch) training_started = True if self.epoch_step_count == 0: self._run_hooks(TrainingStage.BEFORE_EPOCH, batch) self._train_batch_with_optimizers( batch, flat_opts, flat_scheds ) self._validation_checkpoint( TrainingStage.AFTER_OPTIMIZER_STEP ) processed_epoch_batch = True if ( batches_per_epoch is not None and self.epoch_step_count >= batches_per_epoch ): exhausted_dataloader = True break if self.step_count >= target_step_count: exhausted_dataloader = False break if ( not processed_epoch_batch and self.step_count < target_step_count ): raise ValueError( "dataloader produced no batches before reaching " "the target step count; ensure the dataloader is " "non-empty, re-iterable, and compatible with the " "restored epoch_step_count." ) if exhausted_dataloader: self.epoch_count += 1 self.epoch_step_count = 0 self._refresh_hook_counters() self._run_hooks(TrainingStage.AFTER_EPOCH, self._last_batch) self._validation_checkpoint(TrainingStage.AFTER_EPOCH) if self.step_count >= target_step_count: break if self._last_batch is not None: self._update_hook_snapshot(loss_out=None) self._run_hooks(TrainingStage.AFTER_TRAINING, self._last_batch) if self.validation_config is not None: self.validate() self._step_metric_schedulers() finally: self._restore_requires_grad_filter() def to_spec_dict(self) -> dict[str, Any]: """Serialize declarative training knobs to a JSON-ready dict. Returns ------- dict[str, Any] JSON-ready bundle suitable for :func:`json.dumps`. """ component_specs = [ loss_component_to_spec(comp) for comp in self.loss_fn.components ] loss_fn_spec = create_model_spec( type(self.loss_fn), components=component_specs, weights=[_loss_weight_to_spec(weight) for weight in self.loss_fn._weights], normalize_weights=self.loss_fn.normalize_weights, dtype_policy=self.loss_fn.dtype_policy, ) spec = { "optimizer_configs": { key: [cfg.to_spec().model_dump() for cfg in cfgs] for key, cfgs in self.optimizer_configs.items() }, "num_epochs": self.num_epochs, "num_steps": self.num_steps, "epoch_step_modifier": self.epoch_step_modifier, "devices": [str(device) for device in self.devices], "loss_fn_spec": loss_fn_spec.model_dump(), "model_specs": strategy_spec._model_specs_from_models(self.models), "single_model_input": self.single_model_input, } try: spec["training_fn"] = strategy_spec._callable_dotted_path(self.training_fn) except ValueError as exc: warnings.warn( f"Omitting non-importable training_fn from spec: {exc}", UserWarning, stacklevel=2, ) return spec def to_checkpoint_dict(self) -> dict[str, Any]: """Serialize strategy recipe and restart counters for checkpoints. Returns ------- dict[str, Any] JSON-ready checkpoint metadata. Model weights and optimizer state remain outside this payload in checkpoint ``state_dict`` files. """ runtime_state = {key: getattr(self, key) for key in _RESTART_COUNTER_FIELDS} return { **self.to_spec_dict(), "strategy_cls": f"{type(self).__module__}.{type(self).__qualname__}", "runtime_state": runtime_state, }
[docs] def save_checkpoint( self, root_folder: Path | str, *, checkpoint_index: int = -1, ) -> int: """Save this strategy as a restartable checkpoint. Rather than pickling the strategy, this writes a spec-based checkpoint: model weights and architecture, optimizer and scheduler state, the training counters (``step_count``, ``epoch_count``, ``batch_count``, ``global_step_count``), and the state of any :class:`~nvalchemi.hooks.CheckpointableHook` are each serialized through their Pydantic specs, so a restart reconstructs the objects without executing arbitrary pickled code. The non-serializable pieces -- ``training_fn`` and ``loss_target_assembler`` -- are intentionally excluded and must be supplied again at load time. Checkpoints are indexed within ``root_folder`` and tracked by a manifest. ``checkpoint_index=-1`` (the default) auto-increments from the latest manifest entry, so repeated calls accumulate ``0, 1, 2, ...``, while an explicit index overwrites that slot in place. Parameters ---------- root_folder : Path | str Root directory for checkpoint files. checkpoint_index : int, optional Checkpoint index to write. ``-1`` auto-increments from the latest manifest index, or starts at ``0`` when no manifest exists. Returns ------- int The checkpoint index that was written. See Also -------- restore_checkpoint : Restore saved state into this strategy instance. Notes ----- See :ref:`checkpoint-semantics` in the training guide for the four categories of state a checkpoint captures and the developer requirements for custom models, schedules, and hooks. """ from nvalchemi.training._checkpoint import save_checkpoint return save_checkpoint( root_folder, checkpoint_index=checkpoint_index, strategy=self, )
[docs] def restore_checkpoint( self, root_folder: Path | str, checkpoint_index: int = -1, map_location: str | torch.device | None = None, *, validators: Sequence[CheckpointValidator] | None = None, ) -> Mapping[str, Any]: """Restore checkpoint state into this already-constructed strategy. Parameters ---------- root_folder : Path | str Root directory containing checkpoint files. checkpoint_index : int, optional Checkpoint index to load. ``-1`` loads the latest manifest index. map_location : str | torch.device | None, optional Device override passed through to :func:`torch.load`. validators : Sequence[CheckpointValidator] | None, optional Optional loaded-checkpoint validators forwarded to the lower-level loader. Returns ------- Mapping[str, Any] Loaded checkpoint payload from :func:`nvalchemi.training.load_checkpoint`. """ from nvalchemi.training._checkpoint import load_checkpoint loaded = load_checkpoint( root_folder, checkpoint_index=checkpoint_index, map_location=map_location, validators=validators, strategy=self, ) if not isinstance(loaded, Mapping) or loaded.get("strategy") is not self: raise ValueError( "TrainingStrategy.restore_checkpoint could not restore into " "this strategy." ) return loaded
[docs] @classmethod def load_checkpoint( cls, root_folder: Path | str, checkpoint_index: int = -1, map_location: str | torch.device | None = None, *, hooks: Sequence[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator] | None = None, training_fn: Callable[..., Mapping[str, torch.Tensor]] | str | None = None, validators: Sequence[CheckpointValidator] | None = None, ) -> TrainingStrategy: """Load a restartable strategy checkpoint. This is the strategy-focused convenience wrapper around :func:`nvalchemi.training.load_checkpoint`. Use the module-level function when callers need the full manifest, component dictionaries, partial component loads, or foreign checkpoint adapters. Parameters ---------- root_folder : Path | str Root directory containing checkpoint files. checkpoint_index : int, optional Checkpoint index to load. ``-1`` loads the latest manifest index. map_location : str | torch.device | None, optional Device override passed through to :func:`torch.load` and the restored strategy metadata. hooks : Sequence[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator] | None, optional Runtime hooks to attach to the restored strategy. training_fn : Callable[..., Mapping[str, torch.Tensor]] | str | None, optional Runtime training function override. This is required when the saved strategy used a local or otherwise non-importable training function. validators : Sequence[CheckpointValidator] | None, optional Optional loaded-checkpoint validators forwarded to the lower-level loader. Returns ------- TrainingStrategy Restored strategy with model, optimizer, scheduler, and runtime counters loaded. Raises ------ ValueError If the checkpoint does not contain restartable strategy metadata. TypeError If the restored strategy is not an instance of ``cls``. """ from nvalchemi.training._checkpoint import load_checkpoint loaded = load_checkpoint( root_folder, checkpoint_index=checkpoint_index, map_location=map_location, hooks=hooks, training_fn=training_fn, validators=validators, ) if not isinstance(loaded, Mapping) or loaded.get("strategy") is None: raise ValueError( "TrainingStrategy.load_checkpoint requires a checkpoint saved " "from a TrainingStrategy. Use nvalchemi.training.load_checkpoint " "for component-only checkpoints." ) strategy = loaded["strategy"] if not isinstance(strategy, cls): raise TypeError( f"Loaded strategy has type {type(strategy).__name__}, expected " f"{cls.__name__}." ) return strategy
@classmethod def from_spec_dict( cls, spec: Mapping[str, Any], *, models: strategy_validation.ModelInput | None = None, hooks: Sequence[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator] | None = None, training_fn: Callable[..., Mapping[str, torch.Tensor]] | str | None = None, ) -> TrainingStrategy: """Rebuild a :class:`TrainingStrategy` from a :meth:`to_spec_dict` bundle. Parameters ---------- spec : Mapping[str, Any] A dict produced by :meth:`to_spec_dict`, optionally after a JSON round-trip. models : BaseModelMixin | dict[str, BaseModelMixin] | torch.nn.ModuleDict | None, optional Runtime model override(s). hooks : Sequence[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator] | None, optional Runtime hooks; defaults to an empty list. Bare update hooks are auto-wrapped into a single orchestrator. training_fn : Callable[..., Mapping[str, torch.Tensor]] | str | None, optional Runtime callable or dotted-path override. Returns ------- TrainingStrategy A freshly validated strategy ready to :meth:`run`. """ required = ("optimizer_configs", "devices", "loss_fn_spec") missing = [k for k in required if k not in spec] if missing: raise ValueError( f"from_spec_dict: spec is missing required key(s) {missing}. " f"Expected keys: {list(required)}." ) model_input = strategy_spec._models_from_spec_and_overrides( spec.get("model_specs", {}), models, single_model_input=strategy_spec._single_model_input_from_spec( spec.get("single_model_input") ), ) return cls( models=model_input, optimizer_configs=strategy_spec._optimizer_configs_from_spec( spec["optimizer_configs"] ), num_epochs=spec.get("num_epochs"), num_steps=spec.get("num_steps"), epoch_step_modifier=spec.get("epoch_step_modifier", 1.0), hooks=list(hooks) if hooks is not None else [], training_fn=strategy_spec._training_fn_from_spec(spec, training_fn), loss_fn=strategy_spec._loss_fn_from_spec(spec["loss_fn_spec"]), devices=strategy_spec._devices_from_spec(spec["devices"]), ) @classmethod def from_checkpoint_dict( cls, spec: Mapping[str, Any], *, models: strategy_validation.ModelInput | None = None, hooks: Sequence[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator] | None = None, training_fn: Callable[..., Mapping[str, torch.Tensor]] | str | None = None, ) -> TrainingStrategy: """Rebuild a strategy from checkpoint metadata. Parameters ---------- spec : Mapping[str, Any] A dict produced by :meth:`to_checkpoint_dict`. models : BaseModelMixin | dict[str, BaseModelMixin] | torch.nn.ModuleDict | None, optional Runtime model override(s), normally the models loaded from the checkpoint weight files. hooks : Sequence[Hook | TrainingUpdateHook | TrainingUpdateOrchestrator] | None, optional Runtime hooks appended by the caller. training_fn : Callable[..., Mapping[str, torch.Tensor]] | str | None, optional Runtime callable or dotted-path override. Returns ------- TrainingStrategy A strategy with declarative fields and restart counters restored. """ strategy_cls = cls raw_strategy_cls = spec.get("strategy_cls") if raw_strategy_cls is not None: if not isinstance(raw_strategy_cls, str): raise ValueError( "from_checkpoint_dict: 'strategy_cls' must be a dotted " f"class path string; got {type(raw_strategy_cls).__name__}." ) imported = _import_cls(raw_strategy_cls) if not issubclass(imported, cls): raise ValueError( f"from_checkpoint_dict: {raw_strategy_cls!r} must resolve " f"to a {cls.__name__} subclass." ) strategy_cls = imported strategy = strategy_cls.from_spec_dict( spec, models=models, hooks=hooks, training_fn=training_fn, ) runtime_state = spec.get("runtime_state", {}) if runtime_state is None: runtime_state = {} if not isinstance(runtime_state, Mapping): raise ValueError( "from_checkpoint_dict: 'runtime_state' must be a mapping when " f"present; got {type(runtime_state).__name__}." ) for key in _RESTART_COUNTER_FIELDS: if key in runtime_state: value = int(runtime_state[key]) if value < 0: raise ValueError( "from_checkpoint_dict: runtime counter " f"{key!r} must be non-negative; got {value}." ) setattr(strategy, key, value) if "global_step_count" not in runtime_state and strategy.step_count > 0: strategy.global_step_count = strategy.step_count * get_world_size( strategy.distributed_manager ) return strategy def _inference_autocast( self, device: torch.device ) -> tuple[Callable[[], AbstractContextManager[None]], str]: """Return validation autocast context factory and precision label. Scans registered hooks for a :class:`MixedPrecisionHook` and returns an autocast context factory and a precision label string. Parameters ---------- device : torch.device Primary workflow device for the validation pass. Returns ------- tuple[Callable[[], AbstractContextManager[None]], str] A ``(context_factory, precision_label)`` pair. The factory is called once per validation pass to enter/exit the autocast region. Raises ------ RuntimeError When ``use_mixed_precision='always'`` but no :class:`MixedPrecisionHook` is registered. """ use_mixed_precision = ( self.validation_config.use_mixed_precision if self.validation_config is not None else "auto" ) if use_mixed_precision == "never": return nullcontext, "float32" for hook in _iter_registered_hooks(self.hooks): if isinstance(hook, MixedPrecisionHook): precision = str(hook.precision).removeprefix("torch.") return lambda: hook.inference_autocast(device), precision if use_mixed_precision == "always": raise RuntimeError( "ValidationConfig use_mixed_precision='always' requires a " "registered MixedPrecisionHook." ) return nullcontext, "float32" # ------------------------------------------------------------------ # Inference-model write interface (Phase C) # ------------------------------------------------------------------ def set_inference_model( self, module: nn.Module, *, model_key: str | None = None ) -> None: """Publish a module into the strategy's inference-model slot. EMA hooks (and future SWA/distillation hooks) call this after updating their averaged weights so that :meth:`validate` reads current inference weights. Parameters ---------- module : nn.Module The averaged / inference-ready module to publish. model_key : str | None Identifies the target model in named-model strategies. Ignored for single-model strategies, which always store a bare :class:`nn.Module`. Notes ----- The published module is moved to the strategy's primary device before it is stored so validation can safely pair it with batches moved to the same device. For single-model strategies (``single_model_input=True``), ``model_key`` is ignored and the slot stores a bare :class:`nn.Module`. For named-model strategies with a ``model_key``, the slot is promoted to an :class:`nn.ModuleDict` so that multiple hooks can each write their own key. """ module.to(self.devices[0], non_blocking=True) if model_key is None or self.single_model_input: self.inference_model = module return if not isinstance(self.inference_model, nn.ModuleDict): self.inference_model = nn.ModuleDict() self.inference_model[model_key] = module # ------------------------------------------------------------------ # Validation schedule predicates (Phase C) # ------------------------------------------------------------------ def _should_validate(self, stage: TrainingStage) -> bool: """Return whether a schedule-triggered validation should fire now. Parameters ---------- stage : TrainingStage The lifecycle stage being evaluated. Returns ------- bool ``True`` when the current counters match the configured ``every_n_steps`` or ``every_n_epochs`` cadence. """ if self.validation_config is None: return False cfg = self.validation_config if cfg.every_n_steps is not None: # Vetoed optimizer steps (accumulation, spike skipping) leave # step_count parked on a multiple; fire only when the step ran. return ( stage is TrainingStage.AFTER_OPTIMIZER_STEP and self.step_count > 0 and self.step_count % cfg.every_n_steps == 0 and self._optimizer_step_ran_after_do_stage() ) if cfg.every_n_epochs is not None: return ( stage is TrainingStage.AFTER_EPOCH and self.epoch_count % cfg.every_n_epochs == 0 ) return False def _validation_checkpoint(self, stage: TrainingStage) -> bool: """Run validation if scheduled and return whether it fired. Centralizes the validation-trigger logic for both step and epoch cadences. After a successful validation pass, any metric-driven LR schedulers are stepped with the fresh validation summary and the gate is consumed. Parameters ---------- stage : TrainingStage The lifecycle stage that triggered this checkpoint. Returns ------- bool ``True`` if a validation pass ran at this checkpoint, ``False`` otherwise. """ if self.validation_config is None: return False if not self._should_validate(stage): return False self.validate() self._step_metric_schedulers() return True def _step_metric_schedulers(self) -> None: """Step metric-driven schedulers with the last validation summary. Consumes :attr:`last_validation` after stepping so that subsequent non-validation iterations do not re-step the metric-driven schedulers. This implements the ``last_validation`` gate/consume pattern: the field is set by :meth:`validate` and cleared here after metric schedulers have consumed the summary. The gate is only consumed when at least one metric-driven scheduler is present; time-based-only workflows preserve the summary for downstream consumers. """ if self.last_validation is None: return from nvalchemi.training.optimizers import _is_metric_driven has_metric = any( _is_metric_driven(record.scheduler) for record in self._runtime_optimizers ) if not has_metric: return step_metric_schedulers( [record.scheduler for record in self._runtime_optimizers], [record.adapter for record in self._runtime_optimizers], self.last_validation, ) self.last_validation = None # ------------------------------------------------------------------ # Validation execution (Phase B) # ------------------------------------------------------------------ def validate(self) -> dict[str, Any] | None: """Run a validation pass using the strategy's :attr:`validation_config`. Delegates to :class:`~nvalchemi.training._validation.ValidationLoop` to evaluate the model on the configured validation data and loss function. Uses the strategy's own counters (``step_count``, ``epoch_count``) for loss-schedule evaluation and sink metadata. Returns ------- dict[str, Any] | None The validation summary dictionary. In distributed runs, the reduced summary is returned on every rank. The summary is also stored on :attr:`last_validation`. Raises ------ RuntimeError When ``validation_config`` is ``None`` or when required hooks (e.g. :class:`MixedPrecisionHook`) are missing. """ if self.validation_config is None: raise RuntimeError( "TrainingStrategy.validate() requires a validation_config." ) with _validation.ValidationLoop.from_training_strategy(self) as loop: self.last_validation = loop.execute() # Fire AFTER_VALIDATION while the summary is still live, before any # metric-driven LR schedulers consume (and clear) last_validation. if self._last_batch is not None: self._refresh_hook_counters() self._run_hooks(TrainingStage.AFTER_VALIDATION, self._last_batch) return self.last_validation