Source code for mojolearn._x_sequence_optim

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""torch.optim-shaped optimizers of the sequence expansion lane, over float32
NumPy arrays updated in place: `opt = RMSprop([w1, w2], lr=1e-2);
opt.step([g1, g2])`. One launch over the packed flat buffer per step, the
element body `sequence/ops.mojo::op_opt` (PyTorch's update rules), the same on
the GPU binding and on the CPU host binding. `lr` is read fresh at every step,
so a schedule is `opt.lr = ...` between steps, or `opt.lr_schedule = <schedule>` (anything
with `lr_at(t)`, t one-based: `StepLR`, `ExponentialLR`, `OneCycleLR`), which
sets `lr` before every step.

Refused (sequence/NOT_IMPLEMENTED.tsv): parameter groups, maximize,
foreach/fused/capturable/differentiable, sparse gradients, float64."""
from ._optional_numpy import require_numpy
np = require_numpy('_x_sequence_optim')

import os

from . import _backend
from ._x_sequence_rnn import OPTIMIZERS, optimizer_arguments
from ._x_sequence_device import SequenceDeviceTensor, is_seq_tensor, resident_binding

#: lane gap-optimizers (2026-10-02): the state slots live on the device
#: binding's context across steps (`sequence/opt_resident.mojo`), so a step
#: moves only the parameters and gradients up and the parameters down, each
#: tensor straight into its place (no host concatenation); the same element
#: statements on the same values, the same bits. `MOJOLEARN_OPTIMIZER_RESIDENT=0`
#: keeps the per-call entries (the before arm of the A/B); the host binding
#: has no resident entries and keeps them too.
_RESIDENT_ENV = "MOJOLEARN_OPTIMIZER_RESIDENT"


def _resident_binding(b, entries):
    if os.environ.get(_RESIDENT_ENV, "1") == "0":
        return False
    return all(callable(getattr(b, n, None)) for n in entries)  # glue: the named binding entry points


_SEQ_RESIDENT = ("optimizer_resident_open", "optimizer_resident_close", "optimizer_resident_move",
                 "optimizer_resident_step")
_AF_RESIDENT = ("adafactor_resident_open", "optimizer_resident_close", "optimizer_resident_move",
                "adafactor_resident_step")
_LAMB_RESIDENT = ("lamb_resident_open", "optimizer_resident_close", "optimizer_resident_move",
                  "lamb_resident_step")

#: lane gap-neural-io (2026-10-08, plan docs/plans/gaps-2026-10-08.md Section
#: 4): params given as `SequenceDeviceTensor`s (`_x_sequence_device.to_device`)
#: are updated where they live by the `_dev` steps (`sequence/opt_resident.mojo`):
#: no upload of P and G and no download of P per step (3 x 67 MB at the
#: board's 16,777,216 floats), the same launches on the same values, the same
#: bits. The state stays resident as above; grads may be device tensors or
#: host arrays (uploaded once per step, here).
_SEQ_DEV = _SEQ_RESIDENT + ("optimizer_resident_step_dev",)
_AF_DEV = _AF_RESIDENT + ("adafactor_resident_step_dev",)
_LAMB_DEV = _LAMB_RESIDENT + ("lamb_resident_step_dev",)


def _device_params(params, who, entries):
    """None when no param is a `SequenceDeviceTensor`; their binding when
    every one is (on one binding that has `entries`); refused otherwise."""
    dev = [is_seq_tensor(p) for p in params]  # glue: per parameter tensor, not elements
    if not any(dev):
        return None
    if not all(dev):
        raise TypeError(f"{who}: params are all device tensors or all NumPy arrays, not a mix")
    b = params[0].b
    if any(p.b is not b for p in params):  # glue: per parameter tensor, not elements
        raise TypeError(f"{who}: every device param must live on one binding")
    if not (resident_binding(b) and all(callable(getattr(b, n, None)) for n in entries)):  # glue: the named binding entry points
        raise RuntimeError(f"{who}: this binding has no device-parameter step; pass NumPy arrays")
    if os.environ.get(_RESIDENT_ENV, "1") == "0":
        raise RuntimeError(f"{who}: device params need the resident state ({_RESIDENT_ENV}=0 is set)")
    return b


def _device_grads(b, grads, params, who):
    """The grads as device tensors of the params' shapes on binding `b`; a
    host float32 array is uploaded once (here)."""
    if isinstance(grads, (np.ndarray, SequenceDeviceTensor)):
        grads = [grads]
    if len(grads) != len(params):
        raise ValueError(f"{who}: {len(grads)} grads, {len(params)} params")
    out = []
    for g, p in zip(grads, params):  # glue: per parameter tensor, not elements
        if is_seq_tensor(g):
            if g.b is not b or tuple(g.shape) != tuple(p.shape):
                raise ValueError(f"{who}: a device grad of shape {g.shape} for a param {p.shape} "
                                 "(or on another binding)")
            out.append(g)
            continue
        a = np.asarray(g)
        if a.shape != tuple(p.shape):
            raise ValueError(f"{who}: a grads of shape {a.shape} for a param {p.shape}")
        if a.dtype != np.float32:
            raise TypeError(f"{who}: grads must be float32 (got {a.dtype})")
        out.append(SequenceDeviceTensor._wrap(b, a))
    return out


class _ResidentState:
    """The state slots of a resident handle, mirrored on the host on access:
    `state` downloads the slots the handle keeps (once per stale read), and
    the next step uploads the host copies first (the caller may have
    written them, `load_state_dict` does)."""

    def _res_init(self, state):
        self._state = state
        self._res = None
        self._res_b = None
        self._res_used = 0
        self._host_fresh = True     # the host copies hold the newest values
        self._host_owned = True     # the next step uploads them first
        # the host copies are still the zeros (and initial values) the
        # constructor built: nobody has read or replaced them
        self._pristine = True

    @property
    def state(self):
        if self._res is not None and not self._host_fresh:
            for k, s in enumerate(self._state):  # glue: the optimizer state slots, at most three
                self._res_b.optimizer_resident_move(self._res, k, s.ctypes.data, 0)
            self._host_fresh = True
        # a reader may write into the arrays it got: upload before the next step
        self._host_owned = True
        self._pristine = False
        return self._state

    @state.setter
    def state(self, value):
        self._state = value
        self._host_fresh = True
        self._host_owned = True
        self._pristine = False

    def _res_opened(self, b, ret, init_zero=True):
        """Keep the handle an open returned ([handle, used] or, from a
        FAST+Apple binding (OPT_ZERO_OPEN, the default there), [handle, used, 1]: the
        device slots are zero filled, so host copies that are still the
        constructor's zeros need no upload)."""
        self._res, self._res_b, self._res_used = int(ret[0]), b, int(ret[1])
        zeroed = len(ret) > 2 and int(ret[2]) == 1
        self._host_owned = not (zeroed and self._pristine and init_zero)

    def _res_upload(self):
        if self._host_owned:
            for k, s in enumerate(self._state):  # glue: the optimizer state slots, at most three
                self._res_b.optimizer_resident_move(self._res, k, s.ctypes.data, 1)
            self._host_owned = False
        self._host_fresh = False

    @property
    def resident_(self):
        """True once the state lives on the device."""
        return self._res is not None

    def __del__(self):
        res, b = getattr(self, "_res", None), getattr(self, "_res_b", None)
        if res is not None and b is not None:
            try:
                b.optimizer_resident_close(res)
            except Exception:
                pass

    def _grad_list(self, grads):
        """The gradients as C-contiguous float32 arrays of the params' shapes
        (copied only when they are not already)."""
        if isinstance(grads, np.ndarray):
            grads = [grads]
        if len(grads) != len(self.params):
            raise ValueError(f"{type(self).__name__}: {len(grads)} grads, {len(self.params)} params")
        out = []
        for a, p in zip(grads, self.params):  # glue: per parameter tensor, not elements
            if np.shape(a) != p.shape:
                raise ValueError(f"{type(self).__name__}: a grads of shape {np.shape(a)} for a param {p.shape}")
            if np.asarray(a).dtype == np.float64:
                raise TypeError(f"{type(self).__name__}: float64 grads are refused")
            out.append(np.ascontiguousarray(a, dtype=np.float32))
        return out

    def _check_params(self):
        for k, p in enumerate(self.params):  # glue: per parameter tensor, not elements
            if not isinstance(p, np.ndarray) or p.dtype != np.float32 or not p.flags.c_contiguous \
                    or not p.flags.writeable:
                raise TypeError(f"{type(self).__name__}: params[{k}] must stay a writable C-contiguous "
                                "float32 NumPy array (it is updated in place)")
        if sum(p.size for p in self.params) != self.n_total:  # glue: sizes per parameter tensor, not elements
            raise ValueError(f"{type(self).__name__}: the params were resized under the optimizer")


class _SeqOptimizer(_ResidentState):
    _NAME = None

    def __init__(self, params, lr, options, numeric_mode=None):
        if isinstance(params, np.ndarray):
            params = [params]
        self.params = list(params)
        self._dev_b = _device_params(self.params, type(self).__name__, _SEQ_DEV)
        for k, p in enumerate(self.params):  # glue: per parameter tensor, not elements
            if self._dev_b is None and (not isinstance(p, np.ndarray) or p.dtype != np.float32
                                        or not p.flags.c_contiguous):
                raise TypeError(f"{type(self).__name__}: params[{k}] must be a C-contiguous float32 "
                                "NumPy array or a SequenceDeviceTensor (it is updated in place)")
        self.lr = float(lr)
        self.options = dict(options)
        self.numeric_mode = numeric_mode
        self._kind, self._flags, self._fp = optimizer_arguments(self._NAME, self.options)
        self.n_total = int(sum(p.size for p in self.params))  # glue: sizes per parameter tensor, not elements
        if self.n_total < 1:
            raise ValueError(f"{type(self).__name__}: no parameters")
        self._res_init([np.zeros(self.n_total, dtype=np.float32) for _ in range(3)])  # glue: the three optimizer state slots
        init = self._fp[5]
        if init:
            self._state[1][:] = np.float32(init)
        self.t = 0
        # the host scalars' running state (beta1^t, beta2^t, NAdam's mu
        # product) after step _sc_t, advanced by the binding each step, so a
        # step is O(1) instead of a replay of steps 1 .. t - 1
        self._sc = np.ones(3, dtype=np.float32)
        self._sc_t = 0

    def _pack(self, arrays, what):
        if isinstance(arrays, np.ndarray):
            arrays = [arrays]
        if len(arrays) != len(self.params):
            raise ValueError(f"{type(self).__name__}: {len(arrays)} {what}, {len(self.params)} params")
        for a, p in zip(arrays, self.params):  # glue: per parameter tensor, not elements
            if np.shape(a) != p.shape:
                raise ValueError(f"{type(self).__name__}: a {what} of shape {np.shape(a)} for a param {p.shape}")
            if np.asarray(a).dtype == np.float64:
                raise TypeError(f"{type(self).__name__}: float64 {what} are refused")
        return np.ascontiguousarray(np.concatenate([np.asarray(a, dtype=np.float32).ravel() for a in arrays]))  # glue: packs tensors into the flat buffer, a copy

    def _flat_view(self, a):
        """`a` itself, flat, when it already IS the packed buffer (one
        C-contiguous float32 array of the param's shape): no copy in or out
        (lane sequence-cpu)."""
        if len(self.params) != 1:
            return None
        if isinstance(a, (list, tuple)):
            if len(a) != 1:
                return None
            a = a[0]
        if (isinstance(a, np.ndarray) and a.dtype == np.float32 and a.flags.c_contiguous
                and a.shape == self.params[0].shape):
            return a.reshape(-1)
        return None

    def _step_resident(self, b, grads):
        """The step with the state on the device (`_ResidentState`)."""
        self._check_params()
        gs = self._grad_list(grads)
        fp = [self.lr] + [float(v) for v in self._fp[:5]]  # glue: the five optimizer float arguments
        if self._res is None:
            self._res_opened(b, b.optimizer_resident_open([self.n_total, self._kind, self._flags], fp),
                             init_zero=not self._fp[5])
        self._res_upload()
        # empty tensors hold nothing to move or update
        keep = [k for k, p in enumerate(self.params) if p.size > 0]  # glue: per parameter tensor, not elements
        ps = [self.params[k] for k in keep]  # glue: per parameter tensor, not elements
        b.optimizer_resident_step(self._res, [p.ctypes.data for p in ps] + [gs[k].ctypes.data for k in keep]  # glue: addresses per parameter tensor, not elements
                                  + [self._sc.ctypes.data],
                                  [len(ps), self._kind, self._flags, self.t, self._sc_t]
                                  + [int(p.size) for p in ps], fp)  # glue: sizes per parameter tensor, not elements
        del gs

    def _step_device(self, b, grads):
        """The step on device params (`optimizer_resident_step_dev`)."""
        if sum(p.size for p in self.params) != self.n_total:  # glue: sizes per parameter tensor, not elements
            raise ValueError(f"{type(self).__name__}: the params were resized under the optimizer")
        gs = _device_grads(b, grads, self.params, type(self).__name__)
        fp = [self.lr] + [float(v) for v in self._fp[:5]]  # glue: the five optimizer float arguments
        if self._res is None:
            self._res_opened(b, b.optimizer_resident_open([self.n_total, self._kind, self._flags], fp),
                             init_zero=not self._fp[5])
        self._res_upload()
        keep = [k for k, p in enumerate(self.params) if p.size > 0]  # glue: per parameter tensor, not elements
        b.optimizer_resident_step_dev(self._res, [self.params[k].h for k in keep] + [gs[k].h for k in keep]  # glue: handles per parameter tensor, not elements
                                      + [self._sc.ctypes.data],
                                      [len(keep), self._kind, self._flags, self.t, self._sc_t]
                                      + [int(self.params[k].size) for k in keep], fp)  # glue: sizes per parameter tensor, not elements
        del gs

    def step(self, grads):
        """One update of every param from `grads` (same shapes, same order)."""
        if self._dev_b is not None:
            self.t += 1
            if getattr(self, "lr_schedule", None) is not None:
                self.lr = float(self.lr_schedule.lr_at(self.t))
            if self._sc_t >= self.t:
                self._sc[:] = 1.0
                self._sc_t = 0
            try:
                self._step_device(self._dev_b, grads)
            except BaseException:
                self.t -= 1
                raise
            self._sc_t = self.t
            return self
        b = _backend.binding("_mojolearn_x_sequence", self.numeric_mode)
        if self._res is not None or _resident_binding(b, _SEQ_RESIDENT):
            self.t += 1
            if getattr(self, "lr_schedule", None) is not None:
                self.lr = float(self.lr_schedule.lr_at(self.t))
            if self._sc_t >= self.t:
                self._sc[:] = 1.0
                self._sc_t = 0
            try:
                self._step_resident(b, grads)
            except BaseException:
                self.t -= 1
                raise
            self._sc_t = self.t
            return self
        flat = self._flat_view(self.params)
        g = self._flat_view(grads)
        if flat is None or g is None or not flat.flags.writeable or np.shares_memory(flat, g):
            flat = None
            g = self._pack(grads, "grads")
        in_place = flat is not None
        if not in_place:
            flat = self._pack(self.params, "params")
        self.t += 1
        if getattr(self, "lr_schedule", None) is not None:
            self.lr = float(self.lr_schedule.lr_at(self.t))
        if self._sc_t >= self.t:
            self._sc[:] = 1.0
            self._sc_t = 0
        b.optimizer_step([flat.ctypes.data, g.ctypes.data] + [s.ctypes.data for s in self._state]  # glue: buffer addresses of the state slots
                         + [self._sc.ctypes.data],
                         [self.n_total, self._kind, self._flags, self.t, self._sc_t],
                         [self.lr] + [float(v) for v in self._fp[:5]])  # glue: the five optimizer float arguments
        self._sc_t = self.t
        if not in_place:
            off = 0
            for p in self.params:  # glue: unpacks the flat buffer per parameter tensor, not elements
                p.ravel()[:] = flat[off:off + p.size]
                off += p.size
        return self

    def state_dict(self):
        return dict(t=self.t, lr=self.lr, state=[s.copy() for s in self.state], options=dict(getattr(self, "options", {})),  # glue: copies of the state slots
                    scalars=(self._sc_t, self._sc.copy()))

    def load_state_dict(self, sd):
        self.t = int(sd["t"])
        self.lr = float(sd["lr"])
        for dst, src in zip(self.state, sd["state"]):  # glue: the optimizer state slots
            dst[:] = np.asarray(src, dtype=np.float32)
        # (reading `state` above marked the host copies for upload before
        # the next resident step)
        # without the scalars (an older state dict) the next step replays
        # them once from step 1: the same bits
        self._sc[:] = 1.0
        self._sc_t = 0
        sc = sd.get("scalars")
        if sc is not None and 0 <= int(sc[0]) <= self.t:
            self._sc_t = int(sc[0])
            self._sc[:] = np.asarray(sc[1], dtype=np.float32)
        return self


[docs] class RMSprop(_SeqOptimizer): """RMSprop optimizer, as `torch.optim.RMSprop`. Keeps a running average of squared gradients, ``v = alpha * v + (1 - alpha) * g**2``, and divides the gradient by ``sqrt(v) + eps``. With ``centered=True`` the squared running mean of the gradient is subtracted from `v`; with ``momentum > 0`` a momentum buffer accumulates the scaled gradient. Defaults are torch's. Parameters ---------- params : ndarray or list of ndarray Parameters to update in place. lr : float, default 1e-2 Learning rate. alpha : float, default 0.99 Smoothing constant of the squared-gradient average. eps : float, default 1e-8 Term added to the denominator. weight_decay : float, default 0.0 L2 penalty added to the gradient. momentum : float, default 0.0 Momentum factor. centered : bool, default False Normalize by the estimated gradient variance. numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None Numeric mode of the update. None uses the library default. Attributes ---------- t : int Number of steps taken. square_avg : ndarray Running average of squared gradients, flat over all parameters. Notes ----- `params` must be C-contiguous float32 NumPy arrays, updated in place; anything else raises `TypeError`. `step(grads)` takes one gradient per parameter, in the same order and shapes; float64 gradients raise `TypeError`. `lr` is read at every step, so you can change ``opt.lr`` between steps, or set ``opt.lr_schedule`` to a schedule (`StepLR`, `ExponentialLR`, `OneCycleLR`, or any object with a one-based `lr_at(t)`), which sets `lr` before each step. `state_dict()` and `load_state_dict()` save and restore the step count, learning rate and state buffers. Not supported: parameter groups, `maximize`, `foreach`/`fused`/`capturable`/ `differentiable`, sparse gradients and float64 parameters. Examples -------- >>> import numpy as np, mojolearn >>> w = np.zeros((64, 8), dtype=np.float32) >>> opt = mojolearn.RMSprop([w], lr=1e-2) >>> opt = opt.step([np.ones_like(w)]) """ _NAME = "rmsprop"
[docs] def __init__(self, params, lr=1e-2, alpha=0.99, eps=1e-8, weight_decay=0.0, momentum=0.0, centered=False, numeric_mode=None): super().__init__(params, lr, dict(alpha=alpha, eps=eps, weight_decay=weight_decay, momentum=momentum, centered=centered), numeric_mode)
@property def square_avg(self): """Running average of squared gradients, flat over all parameters.""" return self.state[1]
[docs] class Adagrad(_SeqOptimizer): """Adagrad optimizer, as `torch.optim.Adagrad`. Accumulates squared gradients, ``sum += g**2``, and updates ``p -= clr * g / (sqrt(sum) + eps)`` with ``clr = lr / (1 + (t - 1) * lr_decay)``. Defaults are torch's. Parameters ---------- params : ndarray or list of ndarray Parameters to update in place. lr : float, default 1e-2 Learning rate. lr_decay : float, default 0.0 Learning-rate decay per step. weight_decay : float, default 0.0 L2 penalty added to the gradient. initial_accumulator_value : float, default 0.0 Starting value of the squared-gradient sum. eps : float, default 1e-10 Term added to the denominator. numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None Numeric mode of the update. None uses the library default. Attributes ---------- t : int Number of steps taken. sum : ndarray Accumulated squared gradients, flat over all parameters. Notes ----- `params` must be C-contiguous float32 NumPy arrays, updated in place; anything else raises `TypeError`. `step(grads)` takes one gradient per parameter, in the same order and shapes; float64 gradients raise `TypeError`. `lr` is read at every step, so you can change ``opt.lr`` between steps, or set ``opt.lr_schedule`` to a schedule (`StepLR`, `ExponentialLR`, `OneCycleLR`, or any object with a one-based `lr_at(t)`), which sets `lr` before each step. `state_dict()` and `load_state_dict()` save and restore the step count, learning rate and state buffers. Not supported: parameter groups, `maximize`, `foreach`/`fused`/`capturable`/ `differentiable`, sparse gradients and float64 parameters. Examples -------- >>> import numpy as np, mojolearn >>> w = np.zeros(128, dtype=np.float32) >>> opt = mojolearn.Adagrad(w, lr=0.1).step(np.ones_like(w)) """ _NAME = "adagrad"
[docs] def __init__(self, params, lr=1e-2, lr_decay=0.0, weight_decay=0.0, initial_accumulator_value=0.0, eps=1e-10, numeric_mode=None): super().__init__(params, lr, dict(lr_decay=lr_decay, weight_decay=weight_decay, initial_accumulator_value=initial_accumulator_value, eps=eps), numeric_mode)
@property def sum(self): """Accumulated squared gradients, flat over all parameters.""" return self.state[1]
[docs] class Lion(_SeqOptimizer): """Lion optimizer (Chen et al., 2023, "Symbolic Discovery of Optimization Algorithms"). Follows the lion-pytorch statement of the update: ``p *= 1 - lr * weight_decay``; ``p -= lr * sign(beta1 * m + (1 - beta1) * g)``; ``m = beta2 * m + (1 - beta2) * g``. Defaults are the paper's. Parameters ---------- params : ndarray or list of ndarray Parameters to update in place. lr : float, default 1e-4 Learning rate. betas : tuple of float, default (0.9, 0.99) Interpolation factor of the update direction and decay of the momentum. weight_decay : float, default 0.0 Decoupled weight decay. numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None Numeric mode of the update. None uses the library default. Attributes ---------- t : int Number of steps taken. exp_avg : ndarray Momentum buffer, flat over all parameters. Notes ----- `params` must be C-contiguous float32 NumPy arrays, updated in place; anything else raises `TypeError`. `step(grads)` takes one gradient per parameter, in the same order and shapes; float64 gradients raise `TypeError`. `lr` is read at every step, so you can change ``opt.lr`` between steps, or set ``opt.lr_schedule`` to a schedule (`StepLR`, `ExponentialLR`, `OneCycleLR`, or any object with a one-based `lr_at(t)`), which sets `lr` before each step. `state_dict()` and `load_state_dict()` save and restore the step count, learning rate and state buffers. Not supported: parameter groups, `maximize`, `foreach`/`fused`/`capturable`/ `differentiable`, sparse gradients and float64 parameters. Examples -------- >>> import numpy as np, mojolearn >>> w = np.zeros(128, dtype=np.float32) >>> opt = mojolearn.Lion([w], lr=1e-4).step([np.ones_like(w)]) """ _NAME = "lion"
[docs] def __init__(self, params, lr=1e-4, betas=(0.9, 0.99), weight_decay=0.0, numeric_mode=None): super().__init__(params, lr, dict(betas=betas, weight_decay=weight_decay), numeric_mode)
@property def exp_avg(self): """Momentum buffer, flat over all parameters.""" return self.state[0]
[docs] class Adamax(_SeqOptimizer): """Adamax optimizer, as `torch.optim.Adamax`. The infinity-norm variant of Adam: ``m = lerp(m, g, 1 - beta1)``, ``u = max(beta2 * u, |g| + eps)``, ``p -= lr / (1 - beta1**t) * m / u``. Weight decay is coupled (added to the gradient). Defaults are torch's. Parameters ---------- params : ndarray or list of ndarray Parameters to update in place. lr : float, default 2e-3 Learning rate. betas : tuple of float, default (0.9, 0.999) Decay rates of the first moment and the infinity norm, each in [0, 1). eps : float, default 1e-8 Term added to the norm. weight_decay : float, default 0.0 L2 penalty added to the gradient. numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None Numeric mode of the update. None uses the library default. Attributes ---------- t : int Number of steps taken. Notes ----- `params` must be C-contiguous float32 NumPy arrays, updated in place; anything else raises `TypeError`. `step(grads)` takes one gradient per parameter, in the same order and shapes; float64 gradients raise `TypeError`. `lr` is read at every step, so you can change ``opt.lr`` between steps, or set ``opt.lr_schedule`` to a schedule (`StepLR`, `ExponentialLR`, `OneCycleLR`, or any object with a one-based `lr_at(t)`), which sets `lr` before each step. `state_dict()` and `load_state_dict()` save and restore the step count, learning rate and state buffers. Not supported: parameter groups, `maximize`, `foreach`/`fused`/`capturable`/ `differentiable`, sparse gradients and float64 parameters. Examples -------- >>> import numpy as np, mojolearn >>> w = np.zeros(128, dtype=np.float32) >>> opt = mojolearn.Adamax([w]).step([np.ones_like(w)]) """ _NAME = "adamax"
[docs] def __init__(self, params, lr=2e-3, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.0, numeric_mode=None): super().__init__(params, lr, dict(betas=betas, eps=eps, weight_decay=weight_decay), numeric_mode)
[docs] class NAdam(_SeqOptimizer): """NAdam optimizer, as `torch.optim.NAdam`. Adam with Nesterov momentum and torch's momentum schedule ``mu_t = beta1 * (1 - 0.5 * 0.96**(t * momentum_decay))``; the denominator is ``sqrt(v / (1 - beta2**t)) + eps``. Weight decay is coupled by default, decoupled (AdamW style) with ``decoupled_weight_decay=True``. Defaults are torch's. Parameters ---------- params : ndarray or list of ndarray Parameters to update in place. lr : float, default 2e-3 Learning rate. betas : tuple of float, default (0.9, 0.999) Decay rates of the first and second moments. eps : float, default 1e-8 Term added to the denominator. weight_decay : float, default 0.0 Weight decay. momentum_decay : float, default 4e-3 Momentum schedule decay. Must be >= 0, otherwise `ValueError`. decoupled_weight_decay : bool, default False Apply weight decay to the parameters directly instead of the gradient. numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None Numeric mode of the update. None uses the library default. Attributes ---------- t : int Number of steps taken. Notes ----- `params` must be C-contiguous float32 NumPy arrays, updated in place; anything else raises `TypeError`. `step(grads)` takes one gradient per parameter, in the same order and shapes; float64 gradients raise `TypeError`. `lr` is read at every step, so you can change ``opt.lr`` between steps, or set ``opt.lr_schedule`` to a schedule (`StepLR`, `ExponentialLR`, `OneCycleLR`, or any object with a one-based `lr_at(t)`), which sets `lr` before each step. `state_dict()` and `load_state_dict()` save and restore the step count, learning rate and state buffers. Not supported: parameter groups, `maximize`, `foreach`/`fused`/`capturable`/ `differentiable`, sparse gradients and float64 parameters. Examples -------- >>> import numpy as np, mojolearn >>> w = np.zeros(128, dtype=np.float32) >>> opt = mojolearn.NAdam([w], lr=2e-3).step([np.ones_like(w)]) """ _NAME = "nadam"
[docs] def __init__(self, params, lr=2e-3, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.0, momentum_decay=4e-3, decoupled_weight_decay=False, numeric_mode=None): super().__init__(params, lr, dict(betas=betas, eps=eps, weight_decay=weight_decay, momentum_decay=momentum_decay, decoupled_weight_decay=decoupled_weight_decay), numeric_mode)
class Adafactor: """`torch.optim.Adafactor` (PyTorch 2.5): relative step size, decoupled weight decay, a factored second moment (row and column means of g^2) for matrices and a full one for vectors, the update clipped by its RMS over d. Parameters are float32 NumPy arrays of 1 or 2 dimensions, updated in place; defaults are torch's. `sequence/adafactor.mojo`.""" def __init__(self, params, lr=1e-2, beta2_decay=-0.8, eps=(None, 1e-3), d=1.0, weight_decay=0.0, maximize=False, numeric_mode=None): if maximize: raise NotImplementedError("Adafactor: maximize is not implemented") if isinstance(params, np.ndarray): params = [params] self.params = list(params) self._dev_b = _device_params(self.params, "Adafactor", _AF_DEV) for k, p in enumerate(self.params): # glue: per parameter tensor, not elements if self._dev_b is None and (not isinstance(p, np.ndarray) or p.dtype != np.float32 or not p.flags.c_contiguous): raise TypeError(f"Adafactor: params[{k}] must be a C-contiguous float32 NumPy array " "or a SequenceDeviceTensor") if p.ndim not in (1, 2): raise NotImplementedError("Adafactor: tensors of more than 2 dimensions are not implemented") if beta2_decay > 0: raise ValueError("Adafactor: beta2_decay must be <= 0") if d < 1.0: raise ValueError(f"Adafactor: clipping threshold d must be >= 1, got {d}") if lr < 0 or weight_decay < 0: raise ValueError("Adafactor: lr and weight_decay must be >= 0") self.lr, self.beta2_decay, self.d, self.weight_decay = float(lr), float(beta2_decay), float(d), float(weight_decay) eps1, eps2 = eps self.eps = (float(np.finfo(np.float32).eps) if eps1 is None else float(eps1), float(eps2)) self.numeric_mode = numeric_mode self._af_state = [] for p in self.params: # glue: per parameter tensor, not elements if p.ndim == 2: self._af_state.append(dict(row_var=np.zeros(p.shape[0], np.float32), col_var=np.zeros(p.shape[1], np.float32))) else: self._af_state.append(dict(variance=np.zeros(p.shape[0], np.float32))) self.t = 0 # lane apple-fast-gap-optim: the second moment on the device # (`adafactor_resident_*`, a FAST + Apple binding, the default since # rab10-afresident; -D MOJOLEARN_AF_FAST_RESIDENT_OFF), mirrored on the host on access as # `_ResidentState` does: `state` downloads once per stale read and the # next step uploads the host copies a reader may have written self._af_res = None self._af_b = None self._af_fresh = True self._af_owned = True self._af_pristine = True @staticmethod def _af_slots(st): return [st["row_var"], st["col_var"]] if "row_var" in st else [st["variance"]] @property def state(self): if self._af_res is not None and not self._af_fresh: for h, st in zip(self._af_res, self._af_state): # glue: per parameter tensor, not elements for k, s in enumerate(self._af_slots(st)): # glue: per moment slot (one or two) self._af_b.optimizer_resident_move(h, k, s.ctypes.data, 0) self._af_fresh = True self._af_owned = True self._af_pristine = False return self._af_state @state.setter def state(self, value): self._af_state = value self._af_fresh = True self._af_owned = True self._af_pristine = False def __del__(self): res, b = getattr(self, "_af_res", None), getattr(self, "_af_b", None) if res is not None and b is not None: for h in res: # glue: closes each tensor handle try: b.optimizer_resident_close(h) except Exception: pass def _step_resident(self, b, grads): if self._af_res is None: res, zeroed = [], True for p in self.params: # glue: per parameter tensor, not elements R, C = (p.shape[0], p.shape[1]) if p.ndim == 2 else (p.shape[0], 0) ret = b.adafactor_resident_open([R, C]) res.append(int(ret[0])) zeroed = zeroed and len(ret) > 2 and int(ret[2]) == 1 self._af_res, self._af_b = res, b if zeroed and self._af_pristine: self._af_owned = False if self._af_owned: for h, st in zip(self._af_res, self._af_state): # glue: per parameter tensor, not elements for k, s in enumerate(self._af_slots(st)): # glue: per moment slot (one or two) b.optimizer_resident_move(h, k, s.ctypes.data, 1) self._af_owned = False self._af_fresh = False fp = [self.lr, self.beta2_decay, self.eps[0], self.eps[1], self.d, self.weight_decay] for h, p, g in zip(self._af_res, self.params, grads): # glue: per parameter tensor, not elements R, C = (p.shape[0], p.shape[1]) if p.ndim == 2 else (p.shape[0], 0) if self._dev_b is not None: b.adafactor_resident_step_dev(h, [p.h, g.h], [R, C, self.t], fp) else: b.adafactor_resident_step(h, [p.ctypes.data, g.ctypes.data], [R, C, self.t], fp) def step(self, grads): if isinstance(grads, np.ndarray): grads = [grads] if len(grads) != len(self.params): raise ValueError(f"Adafactor: {len(grads)} grads, {len(self.params)} params") self.t += 1 if getattr(self, "lr_schedule", None) is not None: self.lr = float(self.lr_schedule.lr_at(self.t)) if self._dev_b is not None: # device params (lane gap-neural-io): updated where they live try: self._step_resident(self._dev_b, _device_grads(self._dev_b, grads, self.params, "Adafactor")) except BaseException: self.t -= 1 raise return self b = _backend.binding("_mojolearn_x_sequence", self.numeric_mode) if self._af_res is not None or _resident_binding(b, _AF_RESIDENT): gs = [] for p, g in zip(self.params, grads): # glue: per parameter tensor, not elements g = np.asarray(g) if g.shape != p.shape or g.dtype == np.float64: self.t -= 1 raise ValueError("Adafactor: a grad must be float32 of its param's shape") gs.append(np.ascontiguousarray(g, dtype=np.float32)) try: self._step_resident(b, gs) except BaseException: self.t -= 1 raise return self for p, g, st in zip(self.params, grads, self._af_state): # glue: per parameter tensor, not elements g = np.asarray(g) if g.shape != p.shape or g.dtype == np.float64: raise ValueError("Adafactor: a grad must be float32 of its param's shape") g = np.ascontiguousarray(g, dtype=np.float32) if p.ndim == 2: s1, s2 = st["row_var"], st["col_var"] R, C = p.shape else: s1 = s2 = st["variance"] R, C = p.shape[0], 0 b.adafactor_step([p.ctypes.data, g.ctypes.data, s1.ctypes.data, s2.ctypes.data], [R, C, self.t], [self.lr, self.beta2_decay, self.eps[0], self.eps[1], self.d, self.weight_decay]) return self class LAMB(_SeqOptimizer): """LAMB (You et al. 2019, "Large Batch Optimization for Deep Learning"), timm's `Lamb` statement: optional global gradient-norm clip (max_grad_norm), Adam moments with bias correction, update m_hat / (sqrt(v_hat) + eps) + weight_decay p, scaled per tensor by the trust ratio ||p|| / ||update|| (1 when either is 0; at most 1 with trust_clip) when weight_decay != 0 or always_adapt. Defaults are timm's.""" def __init__(self, params, lr=1e-3, bias_correction=True, betas=(0.9, 0.999), eps=1e-6, weight_decay=0.01, grad_averaging=True, max_grad_norm=1.0, trust_clip=False, always_adapt=False, numeric_mode=None): if isinstance(params, np.ndarray): params = [params] self.params = list(params) self._dev_b = _device_params(self.params, "LAMB", _LAMB_DEV) for k, p in enumerate(self.params): # glue: per parameter tensor, not elements if p.size == 0 or (self._dev_b is None and (not isinstance(p, np.ndarray) or p.dtype != np.float32 or not p.flags.c_contiguous)): raise TypeError(f"LAMB: params[{k}] must be a non-empty C-contiguous float32 NumPy array " "or SequenceDeviceTensor") b1, b2 = betas if not (0.0 <= b1 < 1.0 and 0.0 <= b2 < 1.0): raise ValueError("LAMB: betas must lie in [0, 1)") self.lr, self.betas, self.eps, self.weight_decay = float(lr), (float(b1), float(b2)), float(eps), float(weight_decay) self.max_grad_norm = None if max_grad_norm is None else float(max_grad_norm) self.flags = (int(bool(trust_clip)) | 2 * int(bool(always_adapt)) | 4 * int(bool(grad_averaging)) | 8 * int(bool(bias_correction)) | 16 * int(self.max_grad_norm is not None)) self.numeric_mode = numeric_mode self.n_total = int(sum(p.size for p in self.params)) # glue: sizes per parameter tensor, not elements self._res_init([np.zeros(self.n_total, dtype=np.float32) for _ in range(2)]) # glue: the two optimizer state slots self.t = 0 self._sc = np.ones(2, dtype=np.float32) # beta1^_sc_t, beta2^_sc_t self._sc_t = 0 def step(self, grads): dev = self._dev_b is not None b = self._dev_b if dev else _backend.binding("_mojolearn_x_sequence", self.numeric_mode) resident = dev or self._res is not None or _resident_binding(b, _LAMB_RESIDENT) if dev: # device params (lane gap-neural-io): updated where they live gs = _device_grads(b, grads, self.params, "LAMB") elif resident: self._check_params() gs = self._grad_list(grads) else: g = self._pack(grads, "grads") flat = self._pack(self.params, "params") self.t += 1 if getattr(self, "lr_schedule", None) is not None: self.lr = float(self.lr_schedule.lr_at(self.t)) offs = [0] for p in self.params: # glue: offsets per parameter tensor, not elements offs.append(offs[-1] + p.size) if self._sc_t >= self.t: self._sc[:] = 1.0 self._sc_t = 0 fp = [self.lr, self.betas[0], self.betas[1], self.eps, self.weight_decay, self.max_grad_norm if self.max_grad_norm is not None else 1.0, float(self._sc_t)] if resident: # the moments and the tensor table on the device (`_ResidentState`, # `sequence/opt_resident.mojo::lamb_resident_step`): each param and # grad straight into its place, the params straight back try: if self._res is None: self._res_opened(b, b.lamb_resident_open([len(self.params)] + offs)) self._res_upload() if dev: b.lamb_resident_step_dev(self._res, [p.h for p in self.params] + [x.h for x in gs] # glue: handles per parameter tensor, not elements + [self._sc.ctypes.data], [len(self.params), self.t, self.flags], fp) else: b.lamb_resident_step(self._res, [p.ctypes.data for p in self.params] + [x.ctypes.data for x in gs] # glue: addresses per parameter tensor, not elements + [self._sc.ctypes.data], [len(self.params), self.t, self.flags], fp) except BaseException: self.t -= 1 raise del gs else: b.lamb_step( [flat.ctypes.data, g.ctypes.data, self._state[0].ctypes.data, self._state[1].ctypes.data, self._sc.ctypes.data], [len(self.params), self.t, self.flags] + offs, fp) if self.flags & 8: self._sc_t = self.t if not resident: off = 0 for p in self.params: # glue: unpacks the flat buffer per parameter tensor, not elements p.ravel()[:] = flat[off:off + p.size] off += p.size return self assert set(OPTIMIZERS) >= {"rmsprop", "adagrad"}