Source code for nvalchemi.training.losses.composition

# 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.
"""Composable :class:`torch.nn.Module`-based loss-function abstractions.

Leaf loss terms are tensor-to-tensor :class:`BaseLossFunction` instances
whose :meth:`~BaseLossFunction.forward` returns the raw, unweighted loss
tensor. :class:`ComposedLossFunction` owns the per-component weighting
(either floats or :class:`LossWeightSchedule` instances) and, by default,
normalizes the resolved weights so they sum to ``1.0`` at every call.
This keeps weight scheduling a *relative* knob and leaves the learning
rate as the sole *absolute* magnitude control.
"""

from __future__ import annotations

import abc
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast

import torch
from torch import nn

from nvalchemi._serialization import _extract_init_kwargs_from_attrs
from nvalchemi.training._spec import BaseSpec, create_model_spec
from nvalchemi.training.losses.base import LossWeightSchedule

if TYPE_CHECKING:
    from nvalchemi.data import Batch


DTypePolicy = Literal["strict", "prediction_to_target", "target_to_prediction"]


def _validate_dtype_policy(value: DTypePolicy) -> DTypePolicy:
    """Return a supported dtype-alignment policy or raise ``ValueError``."""
    if value in {"strict", "prediction_to_target", "target_to_prediction"}:
        return value
    raise ValueError(
        "dtype_policy must be one of 'strict', 'prediction_to_target', "
        f"or 'target_to_prediction'; got {value!r}."
    )


def _validate_optional_dtype_policy(value: DTypePolicy | None) -> DTypePolicy | None:
    """Return ``None`` or a supported dtype-alignment policy."""
    if value is None:
        return None
    return _validate_dtype_policy(value)


def _align_dtypes_for_policy(
    pred: torch.Tensor,
    target: torch.Tensor,
    policy: DTypePolicy,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Return prediction and target tensors adjusted by ``policy``."""
    match policy:
        case "strict":
            return pred, target
        case "prediction_to_target":
            return pred.to(dtype=target.dtype), target
        case "target_to_prediction":
            return pred, target.to(dtype=pred.dtype)
    raise RuntimeError(f"Unhandled dtype_policy={policy!r}.")


class LossTargetAssemblyProtocol(Protocol):
    """Interface for callables that assemble supervised loss targets.

    Implementations may read from the configured loss, prediction mapping,
    current batch, and optional workflow object. The returned mapping is passed
    as the target mapping to :class:`ComposedLossFunction`.
    """

    def __call__(
        self,
        loss_fn: ComposedLossFunction,
        predictions: Mapping[str, torch.Tensor],
        batch: Batch,
        *,
        workflow: Any | None = None,
        target_keys: Sequence[str] | None = None,
        batch_label: str = "Batch",
    ) -> Mapping[str, torch.Tensor]:
        """Return targets keyed by component ``target_key`` values."""


[docs] class ComposedLossOutput(TypedDict): """Output returned by :class:`ComposedLossFunction`. This is solely used as a type hint, and not as a concrete data structure; it's used to signal to users that the emitted dict from composed losses will always at least contain the keys within this ``TypedDict``. The mapping always contains ``total_loss`` and four per-component sub-mappings keyed by component name. ``per_component_unweighted`` holds each raw component loss before multiplication by its effective weight. ``per_component_weight`` holds the effective (possibly normalized) weight actually applied to each component at this call; ``per_component_raw_weight`` holds the pre-normalization resolved weight — identical to ``per_component_weight`` when ``normalize_weights=False`` and useful for logging the underlying schedule value regardless of normalization. ``per_component_sample`` carries per-component **weighted** per-sample loss tensors of shape ``(B,)``, detached; see :attr:`BaseLossFunction.per_sample_loss` for the per-leaf populate-or-skip contract. """ total_loss: torch.Tensor per_component_unweighted: dict[str, torch.Tensor] per_component_weight: dict[str, float] per_component_raw_weight: dict[str, float] per_component_sample: dict[str, torch.Tensor]
def loss_component_to_spec(component: BaseLossFunction) -> BaseSpec: """Serialize a leaf loss component to a :class:`BaseSpec`. Parameters ---------- component : BaseLossFunction Loss component to serialize. Constructor attributes are recovered by signature introspection, and nested weight schedules are serialized as nested specs when present. Returns ------- BaseSpec JSON-ready spec that rebuilds ``component``. Raises ------ TypeError If ``component`` is a composed loss or is not a leaf :class:`BaseLossFunction`. """ if isinstance(component, ComposedLossFunction): raise TypeError( "loss_component_to_spec accepts only leaf BaseLossFunction objects; " "use ComposedLossFunction spec serialization for composed losses." ) if not isinstance(component, BaseLossFunction): raise TypeError( "loss_component_to_spec accepts only leaf BaseLossFunction objects; " f"got {type(component).__name__}." ) kwargs = _extract_init_kwargs_from_attrs(component) weight = kwargs.get("weight") if weight is not None and hasattr(weight, "model_dump"): kwargs["weight"] = create_model_spec(type(weight), **weight.model_dump()) return create_model_spec(type(component), **kwargs) def assert_same_shape( pred: torch.Tensor, target: torch.Tensor, *, name: str, prediction_key: str | None = None, target_key: str | None = None, strict: bool = False, ) -> None: """Raise :class:`ValueError` when ``pred`` and ``target`` are not compatible. Checks dtype equality first (a dtype mismatch is usually a bug upstream of shape), then the shape compatibility policy selected by ``strict``. Shape policy ------------ ``strict=False`` (default) accepts any pair of shapes that is broadcast-compatible via :func:`torch.broadcast_shapes`. This is convenient for custom losses that legitimately broadcast (e.g. a per-graph scale against a per-component target) but is a trap for elementwise losses: ``(B, 1)`` vs ``(B, 3)`` passes, and the subsequent ``pred - target`` silently broadcasts into a ``(B, 3)`` residual — usually not what you intend. ``strict=True`` requires ``pred.shape == target.shape`` exactly. All built-in leaf losses (:class:`EnergyMSELoss`, :class:`ForceMSELoss`, :class:`StressMSELoss`) pass ``strict=True`` because their elementwise arithmetic would otherwise corrupt the scalar loss under a broadcast-compatible-but-unequal pair. Custom :class:`BaseLossFunction` subclasses that do elementwise arithmetic should also pass ``strict=True``. Parameters ---------- pred : torch.Tensor Prediction tensor. target : torch.Tensor Target tensor whose dtype must equal ``pred``'s and whose shape must be compatible with ``pred``'s under the selected policy. name : str Calling loss-term's class name, used as a prefix in the error message (typically ``type(self).__name__``). prediction_key : str, optional Key the prediction tensor was pulled from in the composed mapping. When provided, included in the error message. target_key : str, optional Key the target tensor was pulled from in the composed mapping. When provided, included in the error message. strict : bool, default False When ``True``, require ``pred.shape == target.shape``. When ``False``, only require broadcast compatibility. Raises ------ ValueError If ``pred.dtype != target.dtype``, or if the shape policy is violated (broadcast-incompatible for ``strict=False``, unequal for ``strict=True``). """ pred_fragment = ( f"prediction_key={prediction_key!r}" if prediction_key is not None else "prediction" ) target_fragment = ( f"target_key={target_key!r}" if target_key is not None else "target" ) if pred.dtype != target.dtype: raise ValueError( f"{name}: prediction and target dtype mismatch; " f"{pred_fragment} has dtype {pred.dtype}, " f"{target_fragment} has dtype {target.dtype}." ) if strict: if pred.shape != target.shape: raise ValueError( f"{name}: prediction and target shape must match exactly " f"for elementwise loss; {pred_fragment} has shape " f"{tuple(pred.shape)}, {target_fragment} has shape " f"{tuple(target.shape)}." ) return try: torch.broadcast_shapes(pred.shape, target.shape) except RuntimeError as exc: raise ValueError( f"{name}: prediction and target shape mismatch; " f"{pred_fragment} has shape {tuple(pred.shape)}, " f"{target_fragment} has shape {tuple(target.shape)}." ) from exc
[docs] class ReductionContext(dict): """Lightweight metadata bag flowing through the loss template pipeline. A plain ``dict`` subclass used to pass metadata between :meth:`BaseLossFunction.normalize`, :meth:`~BaseLossFunction.mask`, and :meth:`~BaseLossFunction.reduce`. Using a bare ``dict`` instead of ``TypedDict(total=False)`` keeps the type ``torch.compile``-safe (Dynamo rejects ``TypedDict`` with optional keys). Conventional keys ----------------- ``"weights"`` : torch.Tensor Per-sample weights for the final reduction. For energy losses with ``per_atom=True`` this carries atom counts ``(B, 1)``; for force losses it may carry per-atom or per-component weights. """
[docs] class BaseLossFunction(nn.Module, abc.ABC): """Abstract :class:`torch.nn.Module` base for ALCHEMI loss functions. ``BaseLossFunction`` implements a **template-method** :meth:`forward` pipeline that orchestrates five overridable hooks: 1. :meth:`validate` — shape / dtype checks. 2. :meth:`normalize` — pre-process ``pred`` and ``target`` (e.g. per-atom energy division) and return a :class:`ReductionContext` for downstream hooks. 3. :meth:`mask` — produce a boolean validity tensor (e.g. ``torch.isfinite``, padding masks). 4. :meth:`compute_residual` — **abstract**; the only method every leaf *must* implement. Receives ``pred``, ``target``, and the validity ``mask`` produced by step 3. 5. :meth:`reduce` — collapse the residual tensor and validity mask into a scalar loss and populate :attr:`per_sample_loss`. Loss authors subclass ``BaseLossFunction`` and override :meth:`compute_residual` at a minimum. Normalization, masking, and reduction come free via the defaults, or can be overridden individually for domain-specific behaviour (e.g. per-atom energy division in :meth:`normalize`, padding-aware force masking in :meth:`mask`, graph-balanced force reduction in :meth:`reduce`). Leaves are weightless — weighting and scheduling live on :class:`ComposedLossFunction`. Operator sugar (``scalar * leaf``, ``leaf + leaf``, ``sum([...])``) produces a composition; see :class:`ComposedLossFunction` for semantics. Attributes ---------- requires_eval_grad : bool | None Whether this loss term requires autograd during evaluation. Losses based on derived outputs such as forces and stress should set this to ``True``; direct scalar-output losses should set it to ``False``. ``None`` means callers cannot infer the policy automatically. dtype_policy : {"strict", "prediction_to_target", "target_to_prediction"} How ``forward`` handles prediction/target dtype mismatches before validation. ``strict`` preserves both tensors and raises on mismatch. The other policies cast one tensor to the other's dtype before the leaf validates shapes and dtypes. per_sample_loss : torch.Tensor | None Detached per-graph loss tensor of shape ``(B,)`` left as a side effect of the most recent :meth:`forward` call, or ``None`` when the loss does not naturally compute a per-graph view (or when ``forward`` has never been called). Intended for logging and diagnostics only — gradients flow through the scalar returned by :meth:`forward`, not through this attribute. """ requires_eval_grad: bool | None = None def __init__(self, *, dtype_policy: DTypePolicy = "strict") -> None: """Initialize the base loss as a stateless :class:`nn.Module`.""" super().__init__() self.per_sample_loss: torch.Tensor | None = None self.dtype_policy = dtype_policy @property def dtype_policy(self) -> DTypePolicy: """Dtype alignment policy applied before validation.""" return self._dtype_policy @dtype_policy.setter def dtype_policy(self, value: DTypePolicy) -> None: self._dtype_policy = _validate_dtype_policy(value)
[docs] def forward( self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any, ) -> torch.Tensor: """Template-method pipeline: validate → normalize → mask → residual → reduce. Subclasses should **not** override this method. Override the individual hooks instead. Extra keyword arguments (``batch``, ``batch_idx``, ``num_nodes_per_graph``, etc.) are forwarded to every hook via ``**kwargs``. """ self.per_sample_loss = None pred, target = self.align_dtypes(pred, target) self.validate(pred, target) pred, target, ctx = self.normalize(pred, target, **kwargs) valid = self.mask(pred, target, ctx, **kwargs) residual = self.compute_residual(pred, target, valid) return self.reduce(residual, valid, ctx, **kwargs)
[docs] def align_dtypes( self, pred: torch.Tensor, target: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: """Return prediction and target tensors adjusted by ``dtype_policy``. ``strict`` preserves both tensors and leaves dtype mismatches to :meth:`validate`. ``prediction_to_target`` and ``target_to_prediction`` cast only when needed and preserve the source tensor otherwise. """ return _align_dtypes_for_policy(pred, target, self.dtype_policy)
[docs] def validate( self, pred: torch.Tensor, target: torch.Tensor, ) -> None: """Check shape and dtype compatibility of ``pred`` and ``target``. Default implementation calls :func:`assert_same_shape` with ``strict=True`` when ``prediction_key`` / ``target_key`` attributes are present on the instance. """ assert_same_shape( pred, target, name=type(self).__name__, prediction_key=getattr(self, "prediction_key", None), target_key=getattr(self, "target_key", None), strict=True, )
[docs] def normalize( self, pred: torch.Tensor, target: torch.Tensor, **kwargs: Any, ) -> tuple[torch.Tensor, torch.Tensor, ReductionContext]: """Pre-process prediction and target before residual computation. Returns a ``(pred, target, ctx)`` triple. The default implementation is the identity — ``ctx`` is empty. Override to inject per-atom energy division, or any other pre-processing that should be available to all loss authors as a composable step. """ return pred, target, ReductionContext()
[docs] def mask( self, pred: torch.Tensor, target: torch.Tensor, ctx: ReductionContext, **kwargs: Any, ) -> torch.Tensor: """Return a boolean validity mask for ``target``. The default implementation returns an all-``True`` mask matching ``target``'s shape. Override to exclude non-finite entries, padding, or any other invalid positions. """ return torch.ones_like(target, dtype=torch.bool)
[docs] @abc.abstractmethod def compute_residual( self, pred: torch.Tensor, target: torch.Tensor, valid: torch.Tensor, ) -> torch.Tensor: """Return the per-element residual tensor. This is the only hook that **must** be overridden. The ``valid`` mask (from :meth:`mask`) is provided so the leaf can zero out invalid positions before computing the residual (important for operations like ``vector_norm`` where masking after the reduction would be incorrect). """
[docs] def reduce( self, residual: torch.Tensor, valid: torch.Tensor, ctx: ReductionContext, **kwargs: Any, ) -> torch.Tensor: """Collapse a residual tensor to a scalar loss. The default implementation computes a validity-weighted mean: ``(residual * valid_float).sum() / valid_float.sum()``, where ``valid_float`` incorporates optional ``ctx["weights"]``. Override for domain-specific reductions (graph-balanced force reduction, RMSD, etc.). Implementations should also populate :attr:`per_sample_loss` with a detached ``(B,)`` tensor when a per-graph decomposition is available. """ valid_weights = valid.to(dtype=residual.dtype) weights = ctx.get("weights") if weights is not None: valid_weights = valid_weights * weights.expand_as(residual) scalar = residual.mul(valid_weights).sum() / valid_weights.sum().clamp_min(1.0) self._populate_per_sample_loss(residual) return scalar
def _populate_per_sample_loss(self, residual: torch.Tensor) -> None: """Set :attr:`per_sample_loss` when the residual has a per-graph shape.""" if residual.ndim == 1: self.per_sample_loss = residual.detach() elif residual.ndim == 2 and residual.shape[-1] == 1: self.per_sample_loss = residual.squeeze(-1).detach() # Arithmetic dunders — return ComposedLossFunction. def __mul__(self, other: Any) -> ComposedLossFunction: """Return ``ComposedLossFunction([self], weights=[other])``. ``other`` may be a :class:`float`/:class:`int` or a :class:`LossWeightSchedule`. """ match other: case bool(): return NotImplemented case int() | float() | LossWeightSchedule(): return ComposedLossFunction([self], weights=[other]) case _: return NotImplemented def __rmul__(self, other: Any) -> ComposedLossFunction: """Mirror of :meth:`__mul__` for ``scalar * loss``.""" return self.__mul__(other) def __add__(self, other: Any) -> ComposedLossFunction: """Return ``self + other`` flattening any existing composition. Both operands get weight ``1.0`` unless they are themselves compositions, in which case their existing weights are preserved. """ if isinstance(other, ComposedLossFunction): return ComposedLossFunction( [self, *other.components], weights=[1.0, *other._weights], normalize_weights=other.normalize_weights, dtype_policy=other.dtype_policy, ) if isinstance(other, BaseLossFunction): return ComposedLossFunction([self, other], weights=[1.0, 1.0]) return NotImplemented def __radd__(self, other: Any) -> BaseLossFunction | ComposedLossFunction: """Return ``self`` when seeded with integer ``0`` (for :func:`sum`).""" if other == 0: return self if isinstance(other, (BaseLossFunction, ComposedLossFunction)): return self.__add__(other) return NotImplemented
def _resolve_weight( weight: LossWeightSchedule | float, step: int, epoch: int | None, *, context: str, ) -> float: """Resolve a single weight (float or schedule) to a finite float. Parameters ---------- weight Either a plain scalar or a :class:`LossWeightSchedule`. step, epoch Training counters forwarded to the schedule. context Caller-supplied name (typically the component's class name) used in error messages. Raises ------ ValueError If a ``per_epoch=True`` schedule is evaluated with ``epoch is None`` or the schedule returns a non-finite value. TypeError If the schedule returns a non-numeric value. """ if not isinstance(weight, LossWeightSchedule): coerced = float(weight) if not math.isfinite(coerced): raise ValueError( f"{context}: weight {weight!r} is not finite; " "weights must be finite floats." ) return coerced if weight.per_epoch and epoch is None: raise ValueError( f"epoch must be provided when the {context} loss weight " "schedule has per_epoch=True. Pass epoch=<current_epoch> to " "the loss, or set per_epoch=False on the schedule." ) try: value = weight(step, epoch or 0) except TypeError as exc: raise TypeError( f"{type(weight).__name__} does not satisfy the " "LossWeightSchedule contract: __call__ must accept " "(step: int, epoch: int) and return a float." ) from exc if not isinstance(value, (int, float)): raise TypeError( f"{type(weight).__name__} returned {type(value).__name__}; " "LossWeightSchedule.__call__ must return float." ) coerced = float(value) if not math.isfinite(coerced): raise ValueError( f"{type(weight).__name__} for {context} returned non-finite " f"weight {coerced!r}; schedules must return finite floats." ) return coerced def _component_names(components: Sequence[BaseLossFunction]) -> tuple[str, ...]: """Return class names with suffixes applied to duplicate component types.""" raw_names = tuple(type(comp).__name__ for comp in components) counts: dict[str, int] = {} for name in raw_names: counts[name] = counts.get(name, 0) + 1 next_index: dict[str, int] = {} names: list[str] = [] for name in raw_names: if counts[name] > 1: idx = next_index.get(name, 0) next_index[name] = idx + 1 names.append(f"{name}_{idx}") else: names.append(name) return tuple(names)
[docs] class ComposedLossFunction(nn.Module): """Weighted sum of :class:`BaseLossFunction` components. This class owns the per-component weighting — leaves are weightless. Weights may be plain floats or :class:`LossWeightSchedule` instances; they are resolved to floats at call time. By default the resolved weights are normalized to sum to ``1.0`` so scheduling controls *relative* contributions while the learning rate controls the absolute loss magnitude. Opt out with ``normalize_weights=False``. Components live in an :class:`torch.nn.ModuleList` for ``.modules()`` / ``.state_dict()`` / nested-``__repr__`` support. When a component is itself a :class:`ComposedLossFunction`, its components and weights are flattened into the parent element-wise so ``(A + B) + C`` is equivalent to ``A + B + C``. Parameters ---------- components Loss terms to combine; must contain at least one element. weights Optional per-component weights. When provided, ``weights`` must have the same length as ``components`` at construction time (i.e. top-level components — child weights inside nested compositions are multiplied element-wise by the parent weight during flattening). A ``None`` entry is shorthand for ``1.0``, so ``weights=[None, 2.0, None]`` means "component 1 gets 2x, others default". Passing ``weights=None`` defaults every component to ``1.0``. normalize_weights When ``True`` (default), resolved weights are divided by their sum at each call so the effective weights sum to ``1.0``. A zero-sum raises :class:`ValueError`. When ``False``, raw weighted sums are returned. dtype_policy Optional composed-level dtype policy applied at call time for components whose own ``dtype_policy`` is still ``"strict"``. This avoids mutating reusable leaf instances while allowing one composed loss to opt into automatic dtype alignment. Attributes ---------- components :class:`torch.nn.ModuleList` of the flattened leaf components. normalize_weights Whether effective weights are renormalized to sum to ``1.0``. dtype_policy Composed-level dtype alignment policy, or ``None`` when each leaf controls dtype handling independently. """ def __init__( self, components: Sequence[BaseLossFunction | ComposedLossFunction], *, weights: Sequence[LossWeightSchedule | float | None] | None = None, normalize_weights: bool = True, dtype_policy: DTypePolicy | None = None, ) -> None: """Store flattened components, their weights, and the normalization flag.""" super().__init__() components = tuple(components) if len(components) == 0: raise ValueError("components must contain at least one loss term") for i, comp in enumerate(components): if not isinstance(comp, (BaseLossFunction, ComposedLossFunction)): raise TypeError( f"components[{i}] must be a BaseLossFunction or " f"ComposedLossFunction, got " f"{type(comp).__name__}" ) if weights is None: raw_weights: list[LossWeightSchedule | float] = [1.0] * len(components) else: raw_weights = [1.0 if w is None else w for w in weights] if len(raw_weights) != len(components): raise ValueError( f"weights has length {len(raw_weights)} but components has " f"length {len(components)}; lengths must match." ) for i, w in enumerate(raw_weights): match w: case bool(): valid = False case int() | float() | LossWeightSchedule(): valid = True case _: valid = False if not valid: raise TypeError( f"weights[{i}] must be a float or LossWeightSchedule, " f"got {type(w).__name__}." ) flat_components: list[BaseLossFunction] = [] flat_weights: list[LossWeightSchedule | float] = [] for comp, parent_w in zip(components, raw_weights, strict=True): if isinstance(comp, ComposedLossFunction): for child_comp, child_w in zip( comp.components, comp._weights, strict=True ): flat_components.append(child_comp) flat_weights.append(_compose_weights(parent_w, child_w)) else: flat_components.append(comp) flat_weights.append(parent_w) if dtype_policy is not None: dtype_policy = _validate_dtype_policy(dtype_policy) self.components: nn.ModuleList = nn.ModuleList(flat_components) self._weights: list[LossWeightSchedule | float] = flat_weights self.normalize_weights: bool = normalize_weights self.dtype_policy = dtype_policy @property def dtype_policy(self) -> DTypePolicy | None: """Composed-level dtype policy applied to strict leaves at call time.""" return self._dtype_policy @dtype_policy.setter def dtype_policy(self, value: DTypePolicy | None) -> None: self._dtype_policy = _validate_optional_dtype_policy(value) def _resolve_raw_and_effective( self, step: int, epoch: int | None ) -> tuple[tuple[str, ...], list[float], list[float]]: """Resolve raw and effective weights in a single pass. Returns a triple ``(names, raw, effective)`` where ``raw`` holds the per-component resolved floats (pre-normalization) and ``effective`` holds the weights that will actually be applied — identical to ``raw`` when :attr:`normalize_weights` is ``False`` and ``raw / sum(raw)`` otherwise. When normalization is enabled the raw weights must sum to a strictly positive float; a sum that is non-positive (negative, zero, or non-finite from cancellation) is rejected with :class:`ValueError` because the resulting normalization either flips every contribution's sign or blows up. Individual raw weights may themselves be negative as long as their sum is positive. """ names = _component_names(tuple(self.components)) raw = [ _resolve_weight(w, step, epoch, context=name) for w, name in zip(self._weights, names, strict=True) ] if not self.normalize_weights: return names, raw, list(raw) total = sum(raw) if not math.isfinite(total) or total <= 0.0: resolved = dict(zip(names, raw, strict=True)) raise ValueError( "ComposedLossFunction: cannot normalize weights whose sum " f"is not strictly positive (sum={total!r}). Resolved " f"weights at step={step}, epoch={epoch}: {resolved}. " "Choose weights whose sum is a finite positive float or " "set normalize_weights=False." ) effective = [w / total for w in raw] return names, raw, effective
[docs] def current_weight(self, step: int = 0, epoch: int | None = None) -> list[float]: """Resolve each component's weight to a float for ``(step, epoch)``. When :attr:`normalize_weights` is ``True`` the returned list sums to ``1.0``; otherwise it is the raw resolved weights. With normalization enabled the raw sum must be a strictly positive float or :class:`ValueError` is raised. Parameters ---------- step Current global training step. epoch Current training epoch, or ``None`` when unused. Returns ------- list[float] One effective weight per component, in order. Raises ------ ValueError If normalization is enabled and the raw weights do not sum to a strictly positive, finite float. """ _, _, effective = self._resolve_raw_and_effective(step, epoch) return effective
[docs] def weight_factors( self, step: int = 0, epoch: int | None = None ) -> dict[str, float]: """Return a flat ``{component_name: effective_weight}`` dict. Duplicate class names get numeric suffixes (``_0``, ``_1``, ...) applied to *all* colliding entries, not only the duplicates. """ names = _component_names(tuple(self.components)) effective = self.current_weight(step=step, epoch=epoch) return dict(zip(names, effective, strict=True))
[docs] def requires_eval_grad(self) -> bool: """Whether evaluating this loss needs autograd enabled. Inspects each leaf component's ``requires_eval_grad`` flag. A component reporting ``True`` (e.g. a force/stress loss that differentiates the energy) forces gradient-enabled evaluation; components reporting ``False`` do not. A component reporting ``None`` is undeclared and cannot be inferred automatically. Returns ------- bool ``True`` when at least one component requires gradients, ``False`` when every component explicitly declares it does not. Raises ------ ValueError When one or more components report ``requires_eval_grad=None`` and none require gradients, so the requirement is ambiguous. """ unknown: list[str] = [] for component in self.components: requires_eval_grad = getattr(component, "requires_eval_grad", None) if requires_eval_grad is True: return True if requires_eval_grad is None: unknown.append(type(component).__name__) if unknown: names = ", ".join(unknown) raise ValueError( "Cannot infer whether evaluating this loss requires " f"gradients for component(s): {names}. Set " "requires_eval_grad on the component(s), or resolve the " "policy explicitly (e.g. ValidationConfig grad_mode=" "'enabled' or 'disabled')." ) return False
[docs] def forward( self, predictions: Mapping[str, torch.Tensor], targets: Mapping[str, torch.Tensor], *, step: int = 0, epoch: int | None = None, **kwargs: Any, ) -> ComposedLossOutput: """Return the weighted total loss and per-component diagnostics. Each component is called with the routed ``pred`` / ``target`` tensors, then its raw loss is scaled by the effective weight for this step. The output's ``per_component_unweighted`` contains each raw component loss before effective weighting; ``per_component_weight`` holds the scalar weights that were applied (after normalization, if enabled); ``per_component_raw_weight`` holds the pre-normalization resolved weights so schedule ramps remain observable on single-component normalized compositions; see :attr:`BaseLossFunction.per_sample_loss` for the ``per_component_sample`` contract. """ names, raw_weights, effective = self._resolve_raw_and_effective(step, epoch) per_component_unweighted: dict[str, torch.Tensor] = {} per_component_sample: dict[str, torch.Tensor] = {} per_component_weight: dict[str, float] = dict( zip(names, effective, strict=True) ) per_component_raw_weight: dict[str, float] = dict( zip(names, raw_weights, strict=True) ) total: torch.Tensor | None = None for name, comp, weight in zip(names, self.components, effective, strict=True): prediction_key = getattr(comp, "prediction_key", None) target_key = getattr(comp, "target_key", None) if prediction_key is None: raise AttributeError( f"{type(comp).__name__} cannot be used in " "ComposedLossFunction without a prediction_key attribute." ) if target_key is None: raise AttributeError( f"{type(comp).__name__} cannot be used in " "ComposedLossFunction without a target_key attribute." ) try: pred = predictions[prediction_key] except KeyError as exc: raise KeyError( f"{type(comp).__name__}: prediction mapping is missing " f"key {prediction_key!r}" ) from exc try: target = targets[target_key] except KeyError as exc: raise KeyError( f"{type(comp).__name__}: target mapping is missing " f"key {target_key!r}" ) from exc if not isinstance(pred, torch.Tensor): raise TypeError( f"{type(comp).__name__}: prediction mapping key " f"{prediction_key!r} must resolve to torch.Tensor, " f"got {type(pred).__name__}." ) if not isinstance(target, torch.Tensor): raise TypeError( f"{type(comp).__name__}: target mapping key " f"{target_key!r} must resolve to torch.Tensor, " f"got {type(target).__name__}." ) if ( self.dtype_policy is not None and getattr(comp, "dtype_policy", "strict") == "strict" ): pred, target = _align_dtypes_for_policy(pred, target, self.dtype_policy) # Guard against stale diagnostics from custom leaves that forget to clear. comp.per_sample_loss = None raw = comp(pred, target, **kwargs) if not isinstance(raw, torch.Tensor): raise TypeError( f"{type(comp).__name__} returned " f"{type(raw).__name__} from forward(); " "BaseLossFunction subclasses must return a torch.Tensor." ) contribution = weight * raw per_component_unweighted[name] = raw sample = comp.per_sample_loss if sample is not None: if not isinstance(sample, torch.Tensor): raise TypeError( f"{type(comp).__name__} (component {name!r}) set " f"per_sample_loss to {type(sample).__name__}; " "must be a torch.Tensor or None." ) if sample.ndim != 1: raise ValueError( f"{type(comp).__name__} (component {name!r}) set " f"per_sample_loss with shape {tuple(sample.shape)}; " "must be a 1-D tensor of shape (B,)." ) per_component_sample[name] = (weight * sample).detach() total = contribution if total is None else total + contribution if total is None: raise RuntimeError("ComposedLossFunction has no components.") return cast( ComposedLossOutput, { "total_loss": total, "per_component_unweighted": per_component_unweighted, "per_component_weight": per_component_weight, "per_component_raw_weight": per_component_raw_weight, "per_component_sample": per_component_sample, }, )
def __mul__(self, other: Any) -> ComposedLossFunction: """Scale every component weight by a float ``other``. Only float/int scalars are accepted. Schedules are rejected with :class:`TypeError`: compose schedules onto the individual components before combining, or multiply the composition by a plain float. """ if isinstance(other, bool) or not isinstance(other, (int, float)): if isinstance(other, LossWeightSchedule): raise TypeError( "Multiplying a ComposedLossFunction by a " "LossWeightSchedule is not supported. Scale each " "component individually (e.g. schedule * EnergyMSELoss()) " "and compose the results, or multiply by a float." ) return NotImplemented scale = float(other) scaled_weights = [_compose_weights(scale, w) for w in self._weights] return ComposedLossFunction( list(self.components), weights=scaled_weights, normalize_weights=self.normalize_weights, dtype_policy=self.dtype_policy, ) def __rmul__(self, other: Any) -> ComposedLossFunction: """Mirror of :meth:`__mul__` for ``scalar * composition``.""" return self.__mul__(other) def __add__(self, other: Any) -> ComposedLossFunction: """Return ``self + other`` flattening any existing composition. The result inherits :attr:`normalize_weights` from ``self``. Adding two compositions with mismatched ``normalize_weights`` raises :class:`ValueError` — combine them explicitly via :class:`ComposedLossFunction` to pick the intended flag. """ if isinstance(other, ComposedLossFunction): if self.normalize_weights != other.normalize_weights: raise ValueError( "Cannot add ComposedLossFunctions with mismatched " f"normalize_weights (self={self.normalize_weights}, " f"other={other.normalize_weights}). Construct the " "combined composition explicitly via " "ComposedLossFunction(..., normalize_weights=...)." ) dtype_policy = self.dtype_policy if dtype_policy is None: dtype_policy = other.dtype_policy return ComposedLossFunction( [*self.components, *other.components], weights=[*self._weights, *other._weights], normalize_weights=self.normalize_weights, dtype_policy=dtype_policy, ) if isinstance(other, BaseLossFunction): return ComposedLossFunction( [*self.components, other], weights=[*self._weights, 1.0], normalize_weights=self.normalize_weights, dtype_policy=self.dtype_policy, ) return NotImplemented def __radd__(self, other: Any) -> ComposedLossFunction: """Return ``self`` when seeded with integer ``0`` (for :func:`sum`).""" if other == 0: return self if isinstance(other, BaseLossFunction): return ComposedLossFunction( [other, *self.components], weights=[1.0, *self._weights], normalize_weights=self.normalize_weights, dtype_policy=self.dtype_policy, ) return NotImplemented
[docs] def extra_repr(self) -> str: """Expose component count and normalization alongside the default repr.""" return ( f"num_components={len(self.components)}, " f"normalize_weights={self.normalize_weights}" )
def as_composed_loss( loss_fn: BaseLossFunction | ComposedLossFunction, ) -> ComposedLossFunction: """Return ``loss_fn`` as a :class:`ComposedLossFunction`. Parameters ---------- loss_fn : BaseLossFunction | ComposedLossFunction Leaf or composed loss to normalize. Returns ------- ComposedLossFunction The original composed loss or a one-component composition. Raises ------ TypeError If ``loss_fn`` is not an ALCHEMI loss function. """ if isinstance(loss_fn, ComposedLossFunction): return loss_fn if isinstance(loss_fn, BaseLossFunction): return ComposedLossFunction([loss_fn]) raise TypeError( "loss_fn must be a BaseLossFunction or ComposedLossFunction; " f"got {type(loss_fn).__name__}." ) def loss_target_keys(loss_fn: ComposedLossFunction) -> tuple[str, ...]: """Return unique target keys required by ``loss_fn`` in component order. Parameters ---------- loss_fn : ComposedLossFunction Loss whose components declare ``target_key`` attributes. Returns ------- tuple[str, ...] Unique target keys to read from a batch. """ seen_keys: set[str] = set() target_keys: list[str] = [] for component in loss_fn.components: key = getattr(component, "target_key", None) if key is None or key in seen_keys: continue seen_keys.add(key) target_keys.append(key) return tuple(target_keys) def assemble_loss_targets( loss_fn: ComposedLossFunction, predictions: Mapping[str, torch.Tensor], batch: Batch, *, workflow: Any | None = None, target_keys: Sequence[str] | None = None, batch_label: str = "Batch", ) -> dict[str, torch.Tensor]: """Collect target tensors required by ``loss_fn`` from ``batch``. This is the default :class:`LossTargetAssemblyProtocol` used by training and validation. Custom assemblers may use the same signature to route targets from ``predictions`` or from fields available on ``workflow``. Parameters ---------- loss_fn : ComposedLossFunction Loss whose component ``target_key`` attributes define required targets. predictions : Mapping[str, torch.Tensor] Model predictions keyed by component ``prediction_key`` values. The default implementation does not read this mapping. batch : Batch Batch exposing target tensors as attributes. workflow : Any | None, optional Workflow object supplied by the caller. Training passes the :class:`~nvalchemi.training.TrainingStrategy`; the default implementation does not read it. target_keys : Sequence[str] | None, optional Precomputed target keys. Defaults to :func:`loss_target_keys`. batch_label : str, default "Batch" Human-readable batch label used in missing-target errors. Returns ------- dict[str, torch.Tensor] Mapping from target key to target tensor. Raises ------ AttributeError If a required target is absent from ``batch``. """ del predictions, workflow component_by_key = { key: type(component).__name__ for component in loss_fn.components if (key := getattr(component, "target_key", None)) is not None } targets: dict[str, torch.Tensor] = {} for key in target_keys if target_keys is not None else loss_target_keys(loss_fn): try: targets[key] = getattr(batch, key) except AttributeError as exc: component_name = component_by_key.get(key, type(loss_fn).__name__) raise AttributeError( f"{batch_label} is missing target attribute {key!r} " f"required by {component_name}." ) from exc return targets def compute_supervised_loss( loss_fn: ComposedLossFunction, predictions: Mapping[str, torch.Tensor], batch: Batch, *, step: int, epoch: int, workflow: Any | None = None, target_assembler: LossTargetAssemblyProtocol = assemble_loss_targets, target_keys: Sequence[str] | None = None, batch_label: str = "Batch", ) -> ComposedLossOutput: """Run ``loss_fn`` with targets and graph metadata from ``batch``. Parameters ---------- loss_fn : ComposedLossFunction Supervised loss to evaluate. predictions : Mapping[str, torch.Tensor] Model predictions keyed by component ``prediction_key`` values. batch : Batch Batch exposing targets and optional graph metadata. step : int Current global optimizer step. epoch : int Current training epoch. workflow : Any | None, optional Workflow object supplied to ``target_assembler``. Training passes the :class:`~nvalchemi.training.TrainingStrategy`. target_assembler : LossTargetAssemblyProtocol, default assemble_loss_targets Callable that builds the target mapping passed to ``loss_fn``. target_keys : Sequence[str] | None, optional Precomputed target keys to avoid repeated component scans. batch_label : str, default "Batch" Human-readable batch label used in missing-target errors. Returns ------- ComposedLossOutput Total and per-component loss diagnostics. """ graph_meta: dict[str, Any] = {} for attr in ("batch_idx", "num_graphs", "num_nodes_per_graph"): value = getattr(batch, attr, None) if value is not None: graph_meta[attr] = value return loss_fn( predictions, target_assembler( loss_fn, predictions, batch, workflow=workflow, target_keys=target_keys, batch_label=batch_label, ), step=step, epoch=epoch, **graph_meta, ) def _compose_weights( outer: LossWeightSchedule | float, inner: LossWeightSchedule | float, ) -> LossWeightSchedule | float: """Return ``outer * inner`` as a weight, keeping floats where possible. If either operand is a schedule, the result is a :class:`_ProductWeight` that resolves ``outer(step, epoch) * inner(step, epoch)`` lazily. Pure float * float collapses to a float. """ outer_is_schedule = isinstance(outer, LossWeightSchedule) inner_is_schedule = isinstance(inner, LossWeightSchedule) if not outer_is_schedule and not inner_is_schedule: return float(outer) * float(inner) return _ProductWeight(outer, inner) @dataclass(frozen=True) class _ProductWeight: """Lazy product of two weights — either operand may be a schedule or a float. Needed for nested composition flattening: when a parent composition has a non-unity weight and a child's weight is a :class:`LossWeightSchedule`, the product cannot be resolved at construction time because the schedule is a callable of ``(step, epoch)``. :class:`_ProductWeight` captures both operands and evaluates the product at call time while structurally satisfying the :class:`LossWeightSchedule` protocol (``per_epoch`` attribute + ``__call__``). """ left: LossWeightSchedule | float right: LossWeightSchedule | float per_epoch: bool = field(init=False) def __post_init__(self) -> None: """Derive ``per_epoch`` from the two operands.""" combined = bool( getattr(self.left, "per_epoch", False) or getattr(self.right, "per_epoch", False) ) # Frozen dataclass → must go through object.__setattr__. object.__setattr__(self, "per_epoch", combined) def to_spec(self) -> BaseSpec: """Return a serializable spec that rebuilds this product schedule.""" left = ( self.left.to_spec() if isinstance(self.left, LossWeightSchedule) else self.left ) right = ( self.right.to_spec() if isinstance(self.right, LossWeightSchedule) else self.right ) return create_model_spec(type(self), left=left, right=right) def __call__(self, step: int, epoch: int) -> float: """Return ``left(step, epoch) * right(step, epoch)``.""" left = self.left(step, epoch) if callable(self.left) else float(self.left) right = self.right(step, epoch) if callable(self.right) else float(self.right) return float(left) * float(right)