Source code for emerging_optimizers.scalar_optimizers.base

# 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.
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, ClassVar, override


if TYPE_CHECKING:
    from typing import overload

import torch
from torch.optim.optimizer import ParamsT

from emerging_optimizers.mixin import WeightDecayMixin, WeightDecayT


__all__ = [
    "SingleMomentumOptimizer",
    "TwoMomentsOptimizer",
]


def _validate_common_hparams(
    *,
    lr: float | None = None,
    betas: tuple[float, ...] | None = None,
    eps: float | None = None,
    weight_decay: float | None = None,
) -> None:
    """Validates the hyperparameters shared by most scalar optimizers."""
    if lr is not None and lr < 0.0:
        raise ValueError(f"Invalid learning rate: {lr}")
    if betas is not None:
        for i, b in enumerate(betas):
            if not 0.0 <= b < 1.0:
                raise ValueError(f"Invalid beta at index {i}: {b}")
    if eps is not None and eps < 0.0:
        raise ValueError(f"Invalid epsilon value: {eps}")
    if weight_decay is not None and weight_decay < 0.0:
        raise ValueError(f"Invalid weight_decay value: {weight_decay}")


class _ScalarOptimizerBase(WeightDecayMixin, torch.optim.Optimizer):
    """Shared implementation for scalar optimizers grouped by state shape.

    Subclasses set ``state_keys`` as a ``ClassVar``. The base lazily allocates one
    zero-initialized buffer per name plus a per-parameter ``step`` counter, then
    dispatches each step to a constructor-supplied ``update_fn`` whose signature is
    ``update_fn(grad, *buffers, **kwargs) -> Tensor``.

    Hyperparameters forwarded into ``update_fn`` are selected from the parameter
    group via ``update_kwarg_names`` (a tuple of dict keys present in the
    ``defaults`` mapping). The per-parameter ``step`` is always forwarded as
    ``step=state["step"]``, so every update function must accept a ``step`` kwarg.

    Subclasses can additionally override :meth:`pre_step_inplace` /
    :meth:`post_step_inplace` to bracket the per-parameter update with custom
    logic (e.g. norm preservation).
    """

    state_keys: ClassVar[tuple[str, ...]]

    def __init__(
        self,
        params: ParamsT,
        defaults: dict[str, Any],
        *,
        update_fn: Callable[..., torch.Tensor],
        update_kwarg_names: tuple[str, ...],
        weight_decay_method: WeightDecayT = "decoupled",
    ) -> None:
        missing = set(update_kwarg_names) - set(defaults.keys())
        if missing:
            raise ValueError(
                f"update_kwarg_names {sorted(missing)} not present in defaults (keys: {sorted(defaults.keys())})"
            )
        self.update_fn = update_fn
        self.update_kwarg_names = update_kwarg_names
        self.weight_decay_method = weight_decay_method
        super().__init__(params, defaults)

    @torch.no_grad()
    def _init_group(
        self,
        group: dict,
        skip_non_grad_params: bool = True,
    ) -> None:
        """Performs lazy state initialization for parameters."""
        for p in group["params"]:
            if skip_non_grad_params and p.grad is None:
                continue
            state = self.state[p]
            if len(state) == 0:
                for key in self.state_keys:
                    state[key] = torch.zeros_like(p.data)
                state["step"] = 0

    def pre_step_inplace(self, p: torch.Tensor, group: dict) -> Any:
        """Hook called before weight decay and the update. Return value is forwarded to ``post_step_inplace``."""
        return None

    def post_step_inplace(self, p: torch.Tensor, group: dict, ctx: Any) -> None:
        """Hook called after the update. Receives the value returned by ``pre_step_inplace``."""
        return None

    if TYPE_CHECKING:

        @overload
        def step(self, closure: None = ...) -> None: ...

        @overload
        def step(self, closure: Callable[[], float]) -> float: ...

    @torch.no_grad()  # type: ignore[misc]
    @override
    def step(self, closure: Callable[[], float] | None = None) -> float | None:
        """Perform a single optimization step.

        Note:
            When ``weight_decay_method="l2"``, ``p.grad`` is modified in-place
            (the L2 penalty ``weight_decay * p`` is added to the gradient).
            If you need the original gradient after this call, clone it beforehand.

        Args:
            closure: Unsupported; must be ``None``.
        """
        if closure is not None:
            raise ValueError("closure is not supported")

        for group in self.param_groups:
            self._init_group(group)

            lr = group["lr"]
            weight_decay = group["weight_decay"]
            update_kwargs = {key: group[key] for key in self.update_kwarg_names}

            for p in group["params"]:
                if p.grad is None:
                    continue  # pragma: no cover

                state = self.state[p]
                state["step"] += 1
                update_kwargs["step"] = state["step"]

                ctx = self.pre_step_inplace(p, group)
                self._apply_weight_decay_inplace(p.data, p.grad, lr, weight_decay)

                buffers = tuple(state[key] for key in self.state_keys)
                update = self.update_fn(p.grad, *buffers, **update_kwargs)
                p.data.add_(update, alpha=-lr)

                self.post_step_inplace(p, group, ctx)

        return None


[docs] class SingleMomentumOptimizer(_ScalarOptimizerBase): """Base for scalar optimizers tracking a single first-moment EMA buffer.""" state_keys: ClassVar[tuple[str, ...]] = ("exp_avg",)
[docs] class TwoMomentsOptimizer(_ScalarOptimizerBase): """Base for Adam-style scalar optimizers tracking first + second moment buffers.""" state_keys: ClassVar[tuple[str, ...]] = ("exp_avg", "exp_avg_sq")