Source code for nvalchemi.training._validation

# 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.
"""Validation configuration, shared helpers, and the :class:`ValidationLoop` orchestrator.

This module contains :class:`ValidationConfig`, :class:`ValidationLoop`,
and the low-level utilities used by
:meth:`~nvalchemi.training.TrainingStrategy.validate` validation passes.
"""

from __future__ import annotations

import contextlib
import dataclasses
from collections.abc import Callable, Iterable, Mapping
from contextlib import AbstractContextManager
from types import TracebackType
from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, runtime_checkable

import torch
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    PlainValidator,
    field_validator,
    model_validator,
)
from torch import nn

from nvalchemi.data import Batch
from nvalchemi.training._stages import TrainingStage
from nvalchemi.training.distributed import (
    all_reduce as distributed_all_reduce,
)
from nvalchemi.training.distributed import (
    is_distributed_initialized,
)
from nvalchemi.training.losses.composition import (
    ComposedLossFunction,
    ComposedLossOutput,
    LossTargetAssemblyProtocol,
    as_composed_loss,
    assemble_loss_targets,
    compute_supervised_loss,
)

if TYPE_CHECKING:
    from nvalchemi.training.strategy import TrainingStrategy

__all__ = ["BatchValidationCallback", "ValidationConfig", "ValidationLoop"]


[docs] @runtime_checkable class BatchValidationCallback(Protocol): """Protocol for an optional per-batch validation callback. A user-supplied object implementing this protocol is invoked once per validation batch inside :meth:`ValidationLoop.execute`, immediately after predictions and the per-batch loss are computed. It is the extension point for streaming per-batch outputs (e.g. predictions or diagnostics) to a custom logging or storage system. Summary-level logging does not require this callback: register a hook on :attr:`~nvalchemi.training.TrainingStage.AFTER_VALIDATION` and read the validation summary from ``ctx.validation``. Notes ----- No concrete implementation is provided. Users supply their own. """ def __call__( self, *, batch: Batch, predictions: Mapping[str, torch.Tensor], loss: ComposedLossOutput, batch_count: int, step_count: int, epoch: int, ) -> None: """Consume one validation batch's predictions and loss. Parameters ---------- batch : Batch The validation batch that was evaluated. predictions : Mapping[str, torch.Tensor] The output of the validation function for this batch. loss : ComposedLossOutput The per-batch composed loss output. batch_count : int Zero-based index of this batch within the validation pass. step_count : int Training step count at which this validation pass runs. epoch : int Training epoch at which this validation pass runs. """ ...
def _ensure_reiterable_validation_data(value: Any) -> Any: """Reject one-shot iterators so validation can restart each pass. Parameters ---------- value : Any Candidate ``validation_data``. Must be a re-iterable container (e.g. ``list``, ``DataLoader``, ``Dataset``) whose ``__iter__`` returns a fresh iterator each call. Returns ------- Any The value unchanged when it is re-iterable. Raises ------ ValueError When ``value`` is not iterable at all, or when it is a one-shot iterator (e.g. a generator) that cannot be re-iterated across repeated validation passes. """ try: iterator = iter(value) except TypeError as exc: raise ValueError( "validation_data must be iterable (e.g. a list, DataLoader, or " f"Dataset of Batch); got {type(value).__name__}." ) from exc if iterator is value: raise ValueError( "validation_data must be a re-iterable container, not a one-shot " "iterator/generator. Validation runs multiple times and must " "restart from the beginning each pass; pass a list (or a " "re-iterable DataLoader/Dataset) instead of a generator." ) return value
[docs] class ValidationConfig(BaseModel): """Configuration for strategy-owned validation passes. ``ValidationConfig`` is a plain, declarative data object that tells a :class:`~nvalchemi.training.TrainingStrategy` *what* validation data to evaluate, *how often* to evaluate it, and *how* to run the forward pass. You pass an instance to the strategy's ``validation_config`` argument; leaving that argument ``None`` disables validation entirely. The strategy then drives validation itself — reading this config directly and running passes through :class:`ValidationLoop` at :meth:`TrainingStrategy.validate` — rather than dispatching validation through the hook system. To react to validation results, register a hook on the ``AFTER_VALIDATION`` stage and read the summary from ``ctx.validation``. ``validation_data`` must be a re-iterable container (``list``, ``DataLoader``, ``Dataset``, ...); a fresh iterator is drawn for every pass, so one-shot generators and bare iterators are rejected at construction time (see ``_ensure_reiterable_validation_data``). The scheduling fields ``every_n_epochs`` and ``every_n_steps`` are mutually exclusive: set at most one to control cadence, or leave both unset to run validation only at the end of :meth:`TrainingStrategy.run`. When ``validation_fn`` or ``loss_fn`` is ``None``, the strategy reuses its own ``training_fn`` / ``loss_fn`` with the matching single-model or named-model call convention; a leaf loss passed here is normalized to a :class:`~nvalchemi.training.losses.composition.ComposedLossFunction`. The remaining fields tune inference behavior: ``grad_mode`` selects the autograd policy (``"auto"`` enables gradients only when a loss component reports ``requires_eval_grad=True``), ``set_eval`` toggles eval mode with restore, ``use_ema`` decides whether the strategy's ``inference_model`` (EMA) weights replace live weights, ``use_mixed_precision`` reuses a registered :class:`~nvalchemi.training.hooks.mixed_precision.MixedPrecisionHook` autocast context, and ``batch_callback`` streams per-batch predictions and losses to a custom sink. Examples -------- Validate on a held-out list of batches after every epoch, reusing the strategy's own ``training_fn`` and ``loss_fn``: >>> from nvalchemi.training import ValidationConfig # doctest: +SKIP >>> config = ValidationConfig( # doctest: +SKIP ... validation_data=val_batches, ... every_n_epochs=1, ... ) Validate every 500 optimizer steps with an explicit validation loss and gradients enabled (e.g. force/stress evaluation that needs autograd): >>> config = ValidationConfig( # doctest: +SKIP ... validation_data=val_loader, ... loss_fn=EnergyMSELoss() + ForceMSELoss(normalize_by_atom_count=True), ... every_n_steps=500, ... grad_mode="enabled", ... ) Evaluate EMA weights instead of the live model, then wire the config into a strategy: >>> config = ValidationConfig( # doctest: +SKIP ... validation_data=[val_batch], ... loss_fn=EnergyMSELoss(), ... use_ema="always", ... grad_mode="enabled", ... ) >>> strategy = TrainingStrategy( # doctest: +SKIP ... models=model, ... optimizer_configs=optimizer_config, ... loss_fn=EnergyMSELoss(), ... num_steps=1000, ... training_fn=default_training_fn, ... validation_config=config, ... hooks=[EMAHook(model_key="main", decay=0.999)], ... ) Notes ----- - ``every_n_epochs`` and ``every_n_steps`` are mutually exclusive; setting both raises at construction time. Both may be omitted, in which case validation runs once at the end of training. - ``validation_data`` must be re-iterable: generators/iterators are rejected because each pass restarts from the beginning. - ``ValidationConfig`` does not participate in hook dispatch. It is read directly by the strategy; use an ``AFTER_VALIDATION`` hook (reading ``ctx.validation``) for summary-level logging. """ validation_data: Annotated[ Iterable[Batch], PlainValidator(_ensure_reiterable_validation_data), Field( description=( "Re-iterable container (e.g. ``list``, ``DataLoader``, ``Dataset``) " "yielding :class:`~nvalchemi.data.Batch` instances. The strategy " "re-iterates this on every validation pass; one-shot generators " "and bare iterators are rejected at construction time." ) ), ] validation_fn: Annotated[ Callable[..., Any] | None, Field( description=( "Validation forward callable. ``None`` means use the strategy's " "``training_fn`` with the same single-model or named-model call " "convention." ) ), ] = None loss_fn: Annotated[ ComposedLossFunction | None, Field( description=( "Validation loss function. ``None`` means use the strategy's " "``loss_fn``. Leaf losses are auto-normalized to a " ":class:`ComposedLossFunction` via :func:`as_composed_loss`." ) ), ] = None every_n_epochs: int | None = Field( default=None, ge=1, description=( "Run validation after every *n*-th completed epoch. Mutually " "exclusive with ``every_n_steps``." ), ) every_n_steps: int | None = Field( default=None, ge=1, description=( "Run validation after every *n*-th completed optimizer step. " "Mutually exclusive with ``every_n_epochs``." ), ) grad_mode: Annotated[ Literal["auto", "enabled", "disabled"], Field( description=( 'Autograd policy during validation. ``"auto"`` enables gradients ' "when any loss component has ``requires_eval_grad=True`` and " "disables them when all components report ``False``." ) ), ] = "auto" set_eval: Annotated[ bool, Field( description=( "If ``True``, set validation modules to eval mode and restore " "their original training modes afterward." ) ), ] = True use_ema: Annotated[ Literal["auto", "always", "never"], Field( description=( "Whether the strategy's ``inference_model`` slot (populated by " "EMA) should replace live training weights for validation." ) ), ] = "auto" use_mixed_precision: Annotated[ Literal["auto", "always", "never"], Field( description=( "Whether to reuse a registered :class:`MixedPrecisionHook` " "autocast context for validation inference." ) ), ] = "auto" batch_callback: Annotated[ BatchValidationCallback | None, Field( description=( "Optional user-supplied callable invoked once per validation " "batch with the batch, predictions, and per-batch loss output. " "Use it to stream per-sample diagnostics to a custom logging or " "storage backend. ``None`` disables per-batch callbacks. For " "epoch-level (summary) logging, register a hook on the " "``AFTER_VALIDATION`` stage and read ``ctx.validation`` instead." ) ), ] = None name: str = Field( default="validation", min_length=1, description="Name stored in the validation summary dictionary.", ) model_config = ConfigDict( arbitrary_types_allowed=True, extra="forbid", ) @field_validator("loss_fn", mode="before") @classmethod def _normalize_loss_fn(cls, value: Any) -> ComposedLossFunction | None: """Normalize a leaf loss into a one-component composed loss.""" return None if value is None else as_composed_loss(value) @model_validator(mode="after") def _validate_schedule(self) -> ValidationConfig: """Enforce mutual exclusion of ``every_n_epochs`` and ``every_n_steps``.""" if self.every_n_epochs is not None and self.every_n_steps is not None: raise ValueError("Only one of every_n_epochs or every_n_steps may be set.") return self
# ------------------------------------------------------------------ # Shared validation utilities # ------------------------------------------------------------------ def _unique_modules(modules: Iterable[nn.Module]) -> tuple[nn.Module, ...]: """Return unique modules while preserving first-seen order.""" seen: set[int] = set() unique: list[nn.Module] = [] for module in modules: if id(module) in seen: continue seen.add(id(module)) unique.append(module) return tuple(unique) def _module_training_modes( modules: Iterable[nn.Module], ) -> dict[int, tuple[nn.Module, bool]]: """Snapshot unique module training modes for later restoration.""" modes: dict[int, tuple[nn.Module, bool]] = {} for module in modules: if id(module) not in modes: modes[id(module)] = (module, module.training) return modes def _snapshot_parameter_grads( modules: Iterable[nn.Module], ) -> dict[int, tuple[nn.Parameter, torch.Tensor | None]]: """Clone current parameter gradients so validation can restore them.""" snapshot: dict[int, tuple[nn.Parameter, torch.Tensor | None]] = {} for module in modules: for parameter in module.parameters(): if id(parameter) in snapshot: continue grad = parameter.grad snapshot[id(parameter)] = ( parameter, None if grad is None else grad.detach().clone(), ) return snapshot def _clear_parameter_grads(modules: Iterable[nn.Module]) -> None: """Clear parameter gradients on validation modules.""" for module in modules: for parameter in module.parameters(): parameter.grad = None def _restore_parameter_grads( snapshot: Mapping[int, tuple[nn.Parameter, torch.Tensor | None]], ) -> None: """Restore parameter gradients captured by :func:`_snapshot_parameter_grads`.""" for parameter, grad in snapshot.values(): parameter.grad = grad def _tensor_to_cpu(value: torch.Tensor) -> torch.Tensor: """Detach a scalar summary tensor and move it to CPU.""" return value.detach().cpu() def _as_float64_scalar(value: torch.Tensor, device: torch.device) -> torch.Tensor: """Detach ``value`` and return a scalar float64 tensor on ``device``.""" return value.detach().to(device=device, dtype=torch.float64).reshape(-1).sum() class _LossAccumulator: """Accumulate composed-loss diagnostics over validation batches.""" def __init__(self, device: torch.device) -> None: self.device = device self.batch_count = 0 self.total_sum: torch.Tensor | None = None self.per_component_unweighted_sum: dict[str, torch.Tensor] = {} self.per_component_sample_sum: dict[str, torch.Tensor] = {} self.per_component_sample_count: dict[str, int] = {} self.per_component_weight: dict[str, float] = {} self.per_component_raw_weight: dict[str, float] = {} def update(self, loss_out: ComposedLossOutput) -> None: """Add one batch's loss output to the running totals.""" self.batch_count += 1 total = loss_out["total_loss"].detach() self.total_sum = total if self.total_sum is None else self.total_sum + total for name, value in loss_out["per_component_unweighted"].items(): detached = value.detach() previous = self.per_component_unweighted_sum.get(name) self.per_component_unweighted_sum[name] = ( detached if previous is None else previous + detached ) for name, sample in loss_out["per_component_sample"].items(): detached_sum = sample.detach().sum() previous = self.per_component_sample_sum.get(name) self.per_component_sample_sum[name] = ( detached_sum if previous is None else previous + detached_sum ) self.per_component_sample_count[name] = ( self.per_component_sample_count.get(name, 0) + sample.numel() ) self.per_component_weight = dict(loss_out["per_component_weight"]) self.per_component_raw_weight = dict(loss_out["per_component_raw_weight"]) def summary( self, *, name: str, model_source: str, ema_model_keys: tuple[str, ...], precision: str, distributed_manager: Any | None = None, ) -> dict[str, Any]: """Return the local or distributed-reduced validation summary.""" if self.batch_count == 0 or self.total_sum is None: raise ValueError("validation_data produced no batches.") component_keys = tuple(sorted(self.per_component_unweighted_sum)) sample_keys = tuple(sorted(self.per_component_sample_sum)) values = [ _as_float64_scalar(self.total_sum, self.device), torch.tensor( float(self.batch_count), device=self.device, dtype=torch.float64 ), ] values.extend( _as_float64_scalar(self.per_component_unweighted_sum[key], self.device) for key in component_keys ) for key in sample_keys: values.append( _as_float64_scalar(self.per_component_sample_sum[key], self.device) ) values.append( torch.tensor( float(self.per_component_sample_count[key]), device=self.device, dtype=torch.float64, ) ) packed = torch.stack(values) distributed_reduced = _distributed_sum_in_place(packed, distributed_manager) index = 0 total_sum = packed[index] index += 1 batch_count = packed[index] index += 1 reduced_batch_count = int(batch_count.item()) per_component_unweighted: dict[str, torch.Tensor] = {} for key in component_keys: per_component_unweighted[key] = _tensor_to_cpu(packed[index] / batch_count) index += 1 per_component_sample: dict[str, torch.Tensor] = {} sample_counts: dict[str, int] = {} for key in sample_keys: sample_sum = packed[index] index += 1 sample_count = packed[index] index += 1 sample_counts[key] = int(sample_count.item()) per_component_sample[key] = _tensor_to_cpu(sample_sum / sample_count) return { "name": name, "total_loss": _tensor_to_cpu(total_sum / batch_count), "per_component_unweighted": per_component_unweighted, "per_component_weight": dict(self.per_component_weight), "per_component_raw_weight": dict(self.per_component_raw_weight), "per_component_sample": per_component_sample, "num_batches": reduced_batch_count, "per_component_sample_count": sample_counts, "model_source": model_source, "ema_model_keys": list(ema_model_keys), "precision": precision, "distributed_reduced": distributed_reduced, } def _distributed_sum_in_place( value: torch.Tensor, distributed_manager: Any | None ) -> bool: """All-reduce ``value`` when distributed communication is active.""" if not is_distributed_initialized(distributed_manager): return False distributed_all_reduce(value, distributed_manager) return True # ------------------------------------------------------------------ # Internal context accessor for ValidationLoop # ------------------------------------------------------------------ @dataclasses.dataclass class _LoopContext: """Snapshot of counters and handles consumed by :class:`ValidationLoop`. Attributes ---------- step_count : int Current optimizer step count. epoch : int Current epoch count. distributed_manager : Any | None Distributed manager handle. num_models : int Total number of models in the workflow. """ step_count: int epoch: int distributed_manager: Any | None num_models: int def _resolve_grad_from_config( config: ValidationConfig, loss_fn: ComposedLossFunction, ) -> bool: """Resolve the autograd policy from a :class:`ValidationConfig`. Parameters ---------- config : ValidationConfig Validation configuration containing the ``grad_mode`` policy. loss_fn : ComposedLossFunction The resolved validation loss function used to infer gradient requirements when ``grad_mode='auto'``. Returns ------- bool ``True`` when validation should run with gradients enabled. """ if config.grad_mode == "enabled": return True if config.grad_mode == "disabled": return False return loss_fn.requires_eval_grad() def _resolve_model_arg( strategy: TrainingStrategy, config: ValidationConfig, ) -> tuple[Any, tuple[nn.Module, ...], tuple[str, ...]]: """Resolve the model argument for a strategy-integrated validation pass. Reads the strategy-owned ``inference_model`` slot and falls back to live training models for keys not covered by the slot. Parameters ---------- strategy : TrainingStrategy The training strategy owning the validation pass. config : ValidationConfig The resolved validation configuration. Returns ------- tuple[Any, tuple[nn.Module, ...], tuple[str, ...]] A three-element tuple: * **model_arg** -- The value passed to the validation forward callable. A single :class:`nn.Module` for single-model strategies, or a ``dict[str, ...]`` for named-model strategies. * **modules** -- All unique :class:`nn.Module` instances participating in the forward pass (for training-mode management). * **ema_keys** -- Sorted tuple of model keys that were sourced from the ``inference_model`` slot rather than live training weights. Raises ------ RuntimeError When ``use_ema='always'`` and the ``inference_model`` slot cannot satisfy the requirement (empty slot or missing keys). """ use_ema = config.use_ema slot = strategy.inference_model if use_ema == "never": slot = None if use_ema == "always" and slot is None: raise RuntimeError( "ValidationConfig use_ema='always' requires a populated " "inference_model slot (e.g. via EMAHook)." ) if strategy.single_model_input: live = strategy.models["main"] if isinstance(slot, nn.Module) and not isinstance(slot, nn.ModuleDict): model = slot ema_keys: tuple[str, ...] = ("main",) else: model = live ema_keys = () return model, (model,), ema_keys # Named-model path resolved: dict[str, Any] = dict(strategy.models) used_ema_keys: list[str] = [] if isinstance(slot, nn.ModuleDict): for key in list(slot.keys()): if key in resolved: resolved[key] = slot[key] used_ema_keys.append(key) elif isinstance(slot, nn.Module): if "main" in resolved: resolved["main"] = slot used_ema_keys.append("main") if use_ema == "always": missing = sorted(set(resolved) - set(used_ema_keys)) if missing: raise RuntimeError( "ValidationConfig use_ema='always' requires the " "inference_model slot to cover every model key; " f"missing: {missing}." ) modules = tuple( value for value in resolved.values() if isinstance(value, nn.Module) ) return resolved, _unique_modules(modules), tuple(sorted(used_ema_keys)) # ------------------------------------------------------------------ # ValidationLoop — public context-manager orchestrator # ------------------------------------------------------------------
[docs] class ValidationLoop: """Context-manager orchestrator for a single validation pass. ``ValidationLoop`` encapsulates the full validation lifecycle — setup, per-batch forward + loss accumulation, distributed summary reduction, sink writes, and teardown — in a single reusable object. Two construction paths are supported: * **Standalone** via :meth:`__init__`: caller provides all dependencies explicitly. No strategy or hook scanning. * **Strategy-integrated** via :meth:`from_training_strategy`: reads capabilities through strategy introspection and holds a live reference for counter/model access during ``execute()``. Usage:: with ValidationLoop.from_training_strategy(strategy) as loop: summary = loop.execute() Parameters ---------- validation_data : Iterable[Batch] Re-iterable object yielding validation batches. config : ValidationConfig Validation configuration. device : torch.device Primary device for the validation pass. model : nn.Module | None Single model for single-model validation. Mutually exclusive with ``models``. models : dict[str, nn.Module] | None Named models for named-model validation. Mutually exclusive with ``model``. loss_fn : ComposedLossFunction | None Validation loss function. Falls back to ``config.loss_fn`` when ``None``. validation_fn : Callable[..., Any] | None Validation forward callable. Required in standalone mode. inference_model : nn.Module | nn.ModuleDict | None Optional EMA/inference model to swap in during validation. autocast : Callable[[], AbstractContextManager[None]] | None Precision context factory. ``None`` uses :func:`contextlib.nullcontext` and precision label ``"float32"``. grad_enabled : bool | None Autograd policy. ``None`` infers from ``config.grad_mode`` and ``loss_fn.requires_eval_grad()``. distributed_manager : Any | None Optional distributed manager for all-reduce and barrier ops. step_count : int Optimizer step counter for sink metadata. epoch : int Epoch counter for sink metadata. Raises ------ ValueError When both or neither of ``model``/``models`` are supplied, or when required arguments (``loss_fn``, ``validation_fn``) are missing. """ def __init__( self, *, validation_data: Iterable[Batch], config: ValidationConfig, device: torch.device, model: nn.Module | None = None, models: dict[str, nn.Module] | None = None, loss_fn: ComposedLossFunction | None = None, loss_target_assembler: LossTargetAssemblyProtocol = assemble_loss_targets, validation_fn: Callable[..., Any] | None = None, inference_model: nn.Module | nn.ModuleDict | None = None, autocast: Callable[[], AbstractContextManager[None]] | None = None, grad_enabled: bool | None = None, distributed_manager: Any | None = None, step_count: int = 0, epoch: int = 0, ) -> None: have_model = model is not None have_models = models is not None if have_model == have_models: raise ValueError("Exactly one of 'model' or 'models' must be provided.") resolved_loss_fn = loss_fn if loss_fn is not None else config.loss_fn if resolved_loss_fn is None: raise ValueError( "loss_fn must be provided either directly or via " "config.loss_fn in standalone mode." ) resolved_loss_fn = as_composed_loss(resolved_loss_fn) if validation_fn is None: raise ValueError("validation_fn is required in standalone mode.") if autocast is not None: self._precision_context = autocast self._precision = "mixed" else: self._precision_context: Callable[[], AbstractContextManager[None]] = ( contextlib.nullcontext ) self._precision = "float32" if grad_enabled is None: grad_enabled = _resolve_grad_from_config(config, resolved_loss_fn) self._validation_data = validation_data self._config = config self._device = device self._loss_fn = resolved_loss_fn self._loss_target_assembler = loss_target_assembler self._validation_fn = validation_fn self._grad_enabled = grad_enabled # Resolve model_arg, modules, ema_model_keys for standalone path if have_model: assert model is not None # noqa: S101 # narrowing self._single_model_input = True ema_keys: tuple[str, ...] = () if ( inference_model is not None and isinstance(inference_model, nn.Module) and not isinstance(inference_model, nn.ModuleDict) ): effective_model = inference_model ema_keys = ("main",) else: effective_model = model self._model_arg: Any = effective_model self._modules = _unique_modules((effective_model,)) self._ema_model_keys = ema_keys self._num_models = 1 else: assert models is not None # noqa: S101 # narrowing self._single_model_input = False resolved: dict[str, Any] = dict(models) used_ema_keys: list[str] = [] if isinstance(inference_model, nn.ModuleDict): for key in list(inference_model.keys()): if key in resolved: resolved[key] = inference_model[key] used_ema_keys.append(key) elif isinstance(inference_model, nn.Module): if "main" in resolved: resolved["main"] = inference_model used_ema_keys.append("main") mods = tuple(v for v in resolved.values() if isinstance(v, nn.Module)) self._model_arg = resolved self._modules = _unique_modules(mods) self._ema_model_keys = tuple(sorted(used_ema_keys)) self._num_models = len(models) # Standalone context: fixed values self._strategy: TrainingStrategy | None = None self._standalone_context = _LoopContext( step_count=step_count, epoch=epoch, distributed_manager=distributed_manager, num_models=self._num_models, ) self._successful = False self._entered = False self._modes: dict[int, tuple[nn.Module, bool]] = {} self._grad_snapshot: dict[int, tuple[nn.Parameter, torch.Tensor | None]] = {}
[docs] @classmethod def from_training_strategy( cls, strategy: TrainingStrategy, config: ValidationConfig | None = None, ) -> ValidationLoop: """Build a :class:`ValidationLoop` from a :class:`TrainingStrategy`. Reads capabilities through the strategy's introspection methods and holds a live reference for counter/model access during :meth:`execute`. Parameters ---------- strategy : TrainingStrategy The training strategy owning the validation pass. config : ValidationConfig | None Override validation config. ``None`` uses ``strategy.validation_config``. Returns ------- ValidationLoop A loop instance ready to be used as a context manager. Raises ------ RuntimeError When ``strategy.validation_config`` is ``None`` and no ``config`` override is provided. """ resolved_config = config if config is not None else strategy.validation_config if resolved_config is None: raise RuntimeError( "ValidationLoop.from_training_strategy() requires a " "validation_config on the strategy or as an argument." ) device = strategy.devices[0] # -- loss resolution (was _resolve_validation_loss_fn) -- if resolved_config.loss_fn is not None: loss_fn = resolved_config.loss_fn else: loss_fn = as_composed_loss(strategy.loss_fn) validation_fn = resolved_config.validation_fn or strategy.training_fn # -- grad resolution (was _resolve_validation_grad) -- grad_enabled = _resolve_grad_from_config(resolved_config, loss_fn) # -- model resolution (was _validation_model_arg) -- model_arg, modules, ema_model_keys = _resolve_model_arg( strategy, resolved_config ) precision_context, precision = strategy._inference_autocast(device) loop = cls.__new__(cls) loop._validation_data = resolved_config.validation_data loop._config = resolved_config loop._device = device loop._loss_fn = loss_fn loop._loss_target_assembler = strategy.loss_target_assembler loop._validation_fn = validation_fn loop._grad_enabled = grad_enabled loop._precision_context = precision_context loop._precision = precision loop._model_arg = model_arg loop._modules = _unique_modules(modules) loop._ema_model_keys = ema_model_keys loop._single_model_input = strategy.single_model_input loop._num_models = len(strategy.models) loop._strategy = strategy loop._standalone_context = None loop._successful = False loop._entered = False loop._modes = {} loop._grad_snapshot = {} return loop
def _context(self) -> _LoopContext: """Return live counters and handles for the current execution. Returns ------- _LoopContext Context snapshot. Strategy-integrated loops read live values from the held strategy reference; standalone loops return stored values. """ if self._strategy is not None: return _LoopContext( step_count=self._strategy.step_count, epoch=self._strategy.epoch_count, distributed_manager=self._strategy.distributed_manager, num_models=len(self._strategy.models), ) assert self._standalone_context is not None # noqa: S101 # narrowing return self._standalone_context def __enter__(self) -> ValidationLoop: """Set up the validation pass. Snapshots training modes, sets eval mode (if configured), and snapshots and clears parameter gradients (if grad-enabled). Returns ------- ValidationLoop The loop handle. """ # Snapshot + set eval self._modes = _module_training_modes(self._modules) if self._config.set_eval: for module, _training in self._modes.values(): module.eval() # Snapshot + clear grads if self._grad_enabled: self._grad_snapshot = _snapshot_parameter_grads(self._modules) _clear_parameter_grads(self._modules) self._entered = True self._successful = False return self def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> bool: """Tear down the validation pass. Restores parameter gradients (if grad-enabled) and restores module training modes (if ``set_eval``). Returns ``False`` so exceptions are not suppressed. Parameters ---------- exc_type : type[BaseException] | None Exception type, if any. exc_val : BaseException | None Exception instance, if any. exc_tb : TracebackType | None Exception traceback, if any. Returns ------- bool Always ``False``. """ try: # Grad restore if self._grad_enabled: _clear_parameter_grads(self._modules) _restore_parameter_grads(self._grad_snapshot) # Training mode restore if self._config.set_eval: for module, training in self._modes.values(): module.train(training) finally: self._entered = False return False
[docs] def execute(self) -> dict[str, Any]: """Run the validation loop over all batches and return the summary. Iterates ``validation_data``, runs the forward pass and loss computation per batch, invokes the optional per-batch callback, accumulates results, computes the distributed-reduced summary, and returns the summary dictionary. Returns ------- dict[str, Any] The local validation summary outside distributed execution, or the distributed-reduced summary on every distributed rank. Raises ------ RuntimeError When called outside the context manager. ValueError When ``validation_data`` produces no batches. """ if not self._entered: raise RuntimeError( "ValidationLoop.execute() must be called inside a 'with' block." ) ctx = self._context() device = self._device accumulator = _LossAccumulator(device) # Per-batch loop for batch_count, batch in enumerate(self._validation_data): validation_batch = batch.to(device, non_blocking=True) previous_hook_context = None if self._strategy is not None and self._strategy.hooks: previous_hook_context = self._strategy._ctx self._strategy._ctx = self._strategy._new_train_context( validation_batch ) try: if self._grad_enabled: _clear_parameter_grads(self._modules) grad_ctx = ( torch.enable_grad() if self._grad_enabled else torch.no_grad() ) with grad_ctx, self._precision_context(): if self._strategy is not None: self._strategy._run_hooks( TrainingStage.BEFORE_FORWARD, validation_batch ) predictions = self._validation_fn(self._model_arg, validation_batch) if self._strategy is not None: self._strategy._run_hooks( TrainingStage.AFTER_FORWARD, validation_batch ) loss_out = compute_supervised_loss( self._loss_fn, predictions, validation_batch, step=ctx.step_count, epoch=ctx.epoch, workflow=self._strategy if self._strategy is not None else self, target_assembler=self._loss_target_assembler, batch_label="Validation batch", ) finally: if self._strategy is not None and self._strategy.hooks: self._strategy._ctx = previous_hook_context accumulator.update(loss_out) # call the per-batch callback; this allows for user-defined operations # on the scope, e.g. log as much as you'd like if self._config.batch_callback is not None: self._config.batch_callback( batch=validation_batch, predictions=predictions, loss=loss_out, batch_count=batch_count, step_count=ctx.step_count, epoch=ctx.epoch, ) # Build summary num_models = ctx.num_models model_source = ( "ema" if (self._ema_model_keys and len(self._ema_model_keys) == num_models) else "mixed" if self._ema_model_keys else "live" ) summary = accumulator.summary( name=self._config.name, model_source=model_source, ema_model_keys=self._ema_model_keys, precision=self._precision, distributed_manager=ctx.distributed_manager, ) self._successful = True return summary