# 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.
r"""Concrete weight schedules for loss functions.
Four Pydantic-validated schedules are provided: :class:`ConstantWeight`,
:class:`LinearWeight`, :class:`CosineWeight`, and :class:`PiecewiseWeight`.
Each satisfies the runtime-checkable
:class:`~nvalchemi.training.losses.base.LossWeightSchedule` protocol and
can be supplied inside :class:`ComposedLossFunction`'s ``weights``
sequence or on the left of ``schedule * leaf``.
The concrete schedules always receive both the global step and epoch.
When ``per_epoch=False`` (the default), schedule windows and boundaries
advance by global step. When ``per_epoch=True``, they advance by epoch,
which lets loss weights follow optimizers or learning-rate schedulers
that update once per epoch.
Notation
--------
Each schedule maps a scalar schedule index :math:`t` to a weight
:math:`w(t)`. :math:`t` is the global step when ``per_epoch=False`` (the
default) and the epoch when ``per_epoch=True``, and :math:`T` denotes the
``num_steps`` window length of the ramp schedules.
Serialization note
------------------
Schedules live in :class:`ComposedLossFunction`'s ``weights`` argument
rather than on leaves, and are reconstructed by the upstream
``TrainingStrategy`` from their ``(instance, spec)`` pair — the same
pattern used for models and optimizers (see
:mod:`nvalchemi.training._checkpoint`). A concrete schedule class still
round-trips standalone via :func:`~nvalchemi.training.create_model_spec`.
Adding a new schedule
---------------------
You can write any callable ``(step: int, epoch: int) -> float`` with a
``per_epoch`` attribute and it will satisfy the
:class:`~nvalchemi.training.losses.base.LossWeightSchedule` protocol.
To participate in :class:`~nvalchemi.training.strategy.TrainingStrategy`
checkpointing, custom schedule classes must also be spec-serializable.
Custom schedule classes must implement ``to_spec()`` returning a
:class:`~nvalchemi.training._spec.BaseSpec` so strategy checkpoints can
rebuild them. The built-in Pydantic schedule base provides this method
from ``model_dump()``.
Alternatively, subclass
:class:`~nvalchemi.training.losses.base._BaseWeightSchedule`:
1. Inherit to pick up ``per_epoch`` and the frozen Pydantic config.
2. Implement ``__call__(step: int, epoch: int) -> float``; use
``self._map_schedule_index(step, epoch)`` for schedules that advance
over a single training counter.
"""
from __future__ import annotations
import bisect
import math
from typing import Annotated, TypeAlias
from pydantic import Field, model_validator
from nvalchemi.training.losses.base import _BaseWeightSchedule
_PositiveSteps: TypeAlias = Annotated[
int,
Field(
gt=0,
description="Positive length of the schedule window in steps or epochs.",
),
]
[docs]
class ConstantWeight(_BaseWeightSchedule):
"""Time-invariant loss weight that returns ``value`` at every update.
``ConstantWeight`` is the simplest :class:`LossWeightSchedule`: it ignores
both the global step and the epoch and always yields :attr:`value`. Reach
for it when a component's contribution is fixed but you still want to
express the weight as a schedule object -- for instance to keep a uniform
type across a :class:`ComposedLossFunction`'s ``weights`` sequence, or to
scale a leaf loss with the ``schedule * leaf`` operator. A bare ``float``
weight behaves identically; the schedule form is mostly for symmetry and
serialization.
Examples
--------
>>> from nvalchemi.training.losses import ConstantWeight
>>> w = ConstantWeight(value=2.5)
>>> w(step=0, epoch=0), w(step=1000, epoch=9)
(2.5, 2.5)
Multiply a leaf loss to build a weighted component::
>>> weighted = ConstantWeight(value=10.0) * ForceMSELoss()
Notes
-----
Instances are frozen (immutable) per the shared ``_BaseWeightSchedule``
config, so a schedule can be safely reused across components.
"""
value: Annotated[float, Field(description="Constant weight value.")]
def __call__(self, step: int, epoch: int) -> float: # noqa: ARG002
"""Return :attr:`value`, ignoring ``step`` and ``epoch``."""
return float(self.value)
class _RampSchedule(_BaseWeightSchedule):
"""Shared base for linear / cosine ramps from ``start`` to ``end``.
Subclasses only differ in the curve applied to the clamped fraction
``t in [0, 1]``. The index is the global step when ``per_epoch=False``
and the epoch when ``per_epoch=True``.
"""
start: Annotated[float, Field(description="Weight at schedule index 0.")]
end: Annotated[float, Field(description="Weight at schedule index `num_steps`.")]
num_steps: _PositiveSteps
def _ramp_fraction(self, step: int, epoch: int) -> float | None:
"""Return the clamped fraction ``t in [0, 1]`` or ``None`` outside the window.
``None`` means the caller should return the boundary value
(``start`` for ``idx <= 0``; ``end`` for ``idx >= num_steps``).
Otherwise the return is the raw linear fraction; subclasses apply
their own curve to it.
"""
idx = self._map_schedule_index(step, epoch)
if idx <= 0 or idx >= self.num_steps:
return None
return idx / self.num_steps
[docs]
class LinearWeight(_RampSchedule):
"""Loss weight that ramps linearly from ``start`` to ``end``.
``LinearWeight`` interpolates a component's weight along a straight line:
it returns :attr:`start` at schedule index ``0`` and :attr:`end` at index
:attr:`num_steps`, moving proportionally in between. Use it to phase a loss
term in or out gradually -- for example warming a force or stress term up
from ``0`` over the first few thousand updates, or annealing an auxiliary
term down toward the end of training. The schedule index is the global step
when ``per_epoch=False`` (default) and the epoch when ``per_epoch=True``,
and the value is clamped to :attr:`start` for index ``<= 0`` and to
:attr:`end` for index ``>= num_steps``.
Examples
--------
>>> from nvalchemi.training.losses import LinearWeight
>>> w = LinearWeight(start=0.0, end=1.0, num_steps=10)
>>> w(step=0, epoch=0), w(step=5, epoch=0), w(step=100, epoch=0)
(0.0, 0.5, 1.0)
Advance the ramp once per epoch instead of per step::
>>> w = LinearWeight(start=0.2, end=1.0, num_steps=10, per_epoch=True)
Notes
-----
``num_steps`` must be strictly positive. Instances are frozen (immutable)
per the shared ``_BaseWeightSchedule`` config.
"""
def __call__(self, step: int, epoch: int) -> float:
"""Linear ramp from ``start`` to ``end``, clamped at both ends."""
frac = self._ramp_fraction(step, epoch)
if frac is None:
return float(
self.start if self._map_schedule_index(step, epoch) <= 0 else self.end
)
return float(self.start + (self.end - self.start) * frac)
[docs]
class CosineWeight(_RampSchedule):
r"""Loss weight that eases from ``start`` to ``end`` on a half-cosine curve.
``CosineWeight`` interpolates like :class:`LinearWeight` but follows a
half-cosine (smooth ``ease-in/ease-out``) path: it starts and ends nearly
flat and changes fastest near the midpoint. Writing :math:`s` for
``start``, :math:`e` for ``end``, and :math:`T` for ``num_steps``, the
weight at schedule index :math:`t` is
.. math::
w(t) = s + (e - s)\,\frac{1 - \cos(\pi \tau)}{2},
\qquad \tau = \operatorname{clamp}\!\left(\frac{t}{T},\, 0,\, 1\right).
The clamp on :math:`\tau` yields :math:`w = s` for :math:`t \le 0` (where
:math:`\cos 0 = 1`) and :math:`w = e` for :math:`t \ge T` (where
:math:`\cos \pi = -1`), with the fastest change at the midpoint
:math:`t = T/2`. Prefer it over a linear ramp when you want a gentler onset
and settle for a term, which can avoid the abrupt gradient shifts a sharp
linear turn-on causes. The schedule index :math:`t` is the global step when
``per_epoch=False`` (default) and the epoch when ``per_epoch=True``.
Examples
--------
>>> from nvalchemi.training.losses import CosineWeight
>>> w = CosineWeight(start=0.0, end=1.0, num_steps=10)
>>> w(step=0, epoch=0), round(w(step=5, epoch=0), 3), w(step=100, epoch=0)
(0.0, 0.5, 1.0)
Anneal a weight downward on the cosine curve::
>>> w = CosineWeight(start=1.0, end=0.1, num_steps=5000)
Notes
-----
``num_steps`` must be strictly positive. Instances are frozen (immutable)
per the shared ``_BaseWeightSchedule`` config.
"""
def __call__(self, step: int, epoch: int) -> float:
"""Half-cosine interpolation, clamped at both ends."""
frac = self._ramp_fraction(step, epoch)
if frac is None:
return float(
self.start if self._map_schedule_index(step, epoch) <= 0 else self.end
)
# Half-cosine: cos(0)=1 at index=0 -> start; cos(pi)=-1 at num_steps -> end.
curve = 0.5 * (1.0 - math.cos(math.pi * frac))
return float(self.start + (self.end - self.start) * curve)
[docs]
class PiecewiseWeight(_BaseWeightSchedule):
r"""Step-function loss weight that switches value at fixed boundaries.
``PiecewiseWeight`` holds a constant weight within each interval and jumps
to the next value once the schedule index crosses a boundary. Given
boundaries :math:`b_0 < b_1 < \dots < b_{k-1}` and values
:math:`v_0, \dots, v_k`, the weight at schedule index :math:`t` is the value
of the interval that contains :math:`t`:
.. math::
w(t) =
\begin{cases}
v_0 & t < b_0, \\
v_j & b_{j-1} \le t < b_j \quad (1 \le j \le k-1), \\
v_k & t \ge b_{k-1}.
\end{cases}
Equivalently :math:`w(t) = v_j` with
:math:`j = \bigl|\{\, m : b_m \le t \,\}\bigr|`, the number of boundaries the
index has reached or passed (each interval is closed on the left). Use it
for stage-wise or curriculum-style training where a term should be on/off or
held at discrete levels rather than ramped continuously -- for example
enabling a stress term only after a warm-up phase. The schedule index
:math:`t` is the global step when ``per_epoch=False`` (default) and the
epoch when ``per_epoch=True``.
Examples
--------
>>> from nvalchemi.training.losses import PiecewiseWeight
>>> w = PiecewiseWeight(boundaries=(10, 20), values=(0.1, 0.5, 0.9))
>>> w(step=5, epoch=0), w(step=15, epoch=0), w(step=25, epoch=0)
(0.1, 0.5, 0.9)
Switch weights per epoch instead of per step::
>>> w = PiecewiseWeight(
... boundaries=(5,), values=(0.0, 1.0), per_epoch=True
... )
Notes
-----
``values`` must have exactly ``len(boundaries) + 1`` entries and
``boundaries`` must be strictly increasing and non-negative; an ``after``
validator raises ``ValueError`` otherwise. Fields are tuples (not lists) so
instances stay hashable under the frozen model config.
"""
boundaries: Annotated[
tuple[int, ...],
Field(
description=(
"Strictly increasing, non-negative schedule-index boundaries."
),
),
]
values: Annotated[
tuple[float, ...],
Field(description="Values for each interval; length len(boundaries) + 1."),
]
@model_validator(mode="after")
def _check_boundaries_and_values(self) -> PiecewiseWeight:
"""Enforce strictly-increasing non-negative boundaries and correct length."""
if len(self.values) != len(self.boundaries) + 1:
raise ValueError(
f"values must have length len(boundaries) + 1; got "
f"len(values)={len(self.values)}, "
f"len(boundaries)={len(self.boundaries)}"
)
prev = -1
for b in self.boundaries:
if b < 0:
raise ValueError(
f"boundaries must be non-negative; got {self.boundaries}"
)
if b <= prev:
raise ValueError(
f"boundaries must be strictly increasing; got {self.boundaries}"
)
prev = b
return self
def __call__(self, step: int, epoch: int) -> float:
"""Return the value of the interval containing the schedule index.
``bisect_right`` gives the count of boundaries that the index has
reached or passed, which is the index into :attr:`values`.
"""
idx = bisect.bisect_right(
self.boundaries, self._map_schedule_index(step, epoch)
)
return float(self.values[idx])