Source code for mojolearn._x_sequence_theta

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""Theta forecasters: Theta, OptimizedTheta, DynamicTheta,
DynamicOptimizedTheta and AutoTheta, following the models of the same names in
``statsforecast.models``. Each series is fitted on the GPU."""
from ._optional_numpy import require_numpy
np = require_numpy('_x_sequence_theta')

from . import _backend

_MODELS = {"STM": 0, "OTM": 1, "DSTM": 2, "DOTM": 3}
_MODEL_NAMES = np.array(list(_MODELS), dtype=object)


# Fitted on the GPU one series per thread (`sequence/theta.mojo`): auto_theta's
# seasonal test and classical decomposition, the theta state-space models, their
# MSE objective minimised by statsforecast's Nelder-Mead, and the point forecast.
# Refusals are listed by name in sequence/NOT_IMPLEMENTED.tsv.
[docs] class AutoTheta: """Automatic theta model selection. Fits the four theta models (standard, optimized, dynamic, dynamic optimized) and keeps, per series, the one with the least in-sample MSE, or fits the single model named by ``model``. Follows statsforecast's ``AutoTheta``: a seasonality test and classical decomposition, the theta state-space models, and Nelder-Mead minimization of the MSE. ``fit`` takes one series of shape ``(n_obs,)`` or a batch of shape ``(batch_size, n_obs)``. The model is estimated when :meth:`predict` is called; repeating the same ``predict`` on the same fitted series returns the stored result. Parameters ---------- season_length : int, default 1 Number of observations per season. 1 means no seasonality. decomposition_type : {"multiplicative", "additive"}, default "multiplicative" Seasonal decomposition used when the series tests as seasonal. model : {"STM", "OTM", "DSTM", "DOTM"} or None, default None The theta model to fit. None tries all four and keeps the one with the least MSE per series. initial_smoothed : float or None, default None Fixes the initial smoothed level; None estimates it. alpha : float or None, default None Fixes the smoothing parameter; None estimates it. theta : float or None, default None Fixes the theta coefficient; None estimates it. numeric_mode : str or None, default None Numeric mode for this model (``'identical'``, ``'deterministic'`` or ``'fast'``); None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- model_ : list of str The theta model used for each series (``"STM"``, ``"OTM"``, ``"DSTM"`` or ``"DOTM"``). Set by :meth:`predict`. info_ : ndarray of shape (batch_size, 8), float32 Per-series fit summary; column 4 is the index of the chosen model in ``("STM", "OTM", "DSTM", "DOTM")``. Set by :meth:`predict`. Notes ----- Input must be float32; float64 raises TypeError. Each series needs more than 3 observations. Prediction intervals (``level``), fitted values, non-normal error distributions and exogenous regressors are not supported and raise NotImplementedError. Examples -------- >>> import numpy as np, mojolearn >>> t = np.arange(96, dtype=np.float32) >>> y = 10 + 0.1 * t + np.sin(2 * np.pi * t / 12).astype(np.float32) >>> mojolearn.AutoTheta(season_length=12).fit(y).predict(12)["mean"].shape (12,) """ _MODEL = None
[docs] def __init__(self, season_length=1, decomposition_type="multiplicative", model=None, initial_smoothed=None, alpha=None, theta=None, numeric_mode=None): if decomposition_type not in ("multiplicative", "additive"): raise ValueError("decomposition_type must be 'multiplicative' or 'additive'") if model is not None and model not in _MODELS: raise ValueError(f"model must be one of {sorted(_MODELS)} or None") self.season_length = int(season_length) self.decomposition_type = decomposition_type self.model = self._MODEL if self._MODEL is not None else model self.initial_smoothed, self.alpha, self.theta = initial_smoothed, alpha, theta self.numeric_mode = numeric_mode
[docs] def fit(self, y, X=None): """Store one series or a batch of series for forecasting. The theta model itself is estimated by :meth:`predict`. Parameters ---------- y : array-like of shape (n_obs,) or (batch_size, n_obs) The series, float32, more than 3 observations each. A private copy is kept. X : None Exogenous regressors are not supported; anything other than None raises NotImplementedError. Returns ------- self Raises ------ TypeError If ``y`` is float64. ValueError If a series has 3 or fewer observations. """ if X is not None: raise NotImplementedError("Theta: exogenous regressors are not implemented") y = np.asarray(y) if y.dtype == np.float64: raise TypeError("Theta: float64 is refused; pass float32") self._one = y.ndim == 1 # a private copy: the fitted series cannot change under a stored forecast self._y = np.array(y, dtype=np.float32, order="C", copy=True).reshape(1, -1) if self._one else \ np.array(y, dtype=np.float32, order="C", copy=True) if self._y.ndim != 2 or self._y.shape[1] <= 3: raise ValueError("Theta: each series needs more than 3 observations") self._fitted_h = None return self
def _key(self, h): return (int(h), self.season_length, self.decomposition_type, self.model, self.initial_smoothed, self.alpha, self.theta, self.numeric_mode) def _run(self, h): # the fit runs inside the forecast call; a repeat of the same call on # the same fitted series returns the stored answer (lane py-sequence) key = self._key(h) cached = getattr(self, "_fitted_h", None) if cached is not None and cached[0] == key: self.info_, self.model_ = cached[2].copy(), list(cached[3]) return cached[1].copy() B, n = self._y.shape f = np.zeros((B, h), dtype=np.float32) info = np.zeros((B, 8), dtype=np.float32) mask = (int(self.initial_smoothed is not None) | 2 * int(self.alpha is not None) | 4 * int(self.theta is not None)) fp = [0.0 if v is None else float(v) for v in (self.initial_smoothed, self.alpha, self.theta)] # glue: the three smoothing parameter arguments _backend.binding("_mojolearn_x_sequence", self.numeric_mode).theta( [self._y.ctypes.data, f.ctypes.data, info.ctypes.data], [B, n, int(h), self.season_length, -1 if self.model is None else _MODELS[self.model], 0 if self.decomposition_type == "multiplicative" else 1, mask], fp) self.info_ = info self.model_ = _MODEL_NAMES[info[:, 4].astype(np.intp)].tolist() self._fitted_h = (key, f.copy(), info.copy(), list(self.model_)) return f
[docs] def predict(self, h, X=None, level=None): """Fit the theta model and forecast h steps ahead. Parameters ---------- h : int Forecast horizon. X : None Unused. level : None Prediction intervals are not supported; anything other than None raises NotImplementedError. Returns ------- dict ``{"mean": forecasts}``, shaped ``(h,)`` for a single series or ``(batch_size, h)`` for a batch. """ if level is not None: raise NotImplementedError("Theta: prediction intervals (level) are not implemented") f = self._run(int(h)) return {"mean": f[0] if self._one else f}
[docs] def forecast(self, y, h, X=None, X_future=None, level=None, fitted=False): """Fit ``y`` and forecast ``h`` steps in one call. Equivalent to ``fit(y).predict(h)``. ``fitted=True`` (in-sample fitted values) raises NotImplementedError. Returns ------- dict ``{"mean": forecasts}``, as returned by :meth:`predict`. """ if fitted: raise NotImplementedError("Theta: fitted values are not implemented") return self.fit(y, X).predict(h, level=level)
[docs] class Theta(AutoTheta): """The standard theta model (STM). Follows statsforecast's ``Theta``. ``fit`` takes one series of shape ``(n_obs,)`` or a batch of shape ``(batch_size, n_obs)``; the model is estimated when :meth:`predict` is called. Parameters ---------- season_length : int, default 1 Number of observations per season. 1 means no seasonality. decomposition_type : {"multiplicative", "additive"}, default "multiplicative" Seasonal decomposition used when the series tests as seasonal. model : ignored This class always uses the STM model; the argument is accepted for signature compatibility with :class:`AutoTheta`. initial_smoothed : float or None, default None Fixes the initial smoothed level; None estimates it. alpha : float or None, default None Fixes the smoothing parameter; None estimates it. theta : float or None, default None Fixes the theta coefficient; None estimates it. numeric_mode : str or None, default None Numeric mode for this model (``'identical'``, ``'deterministic'`` or ``'fast'``); None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- model_ : list of str The theta model used for each series (``"STM"``, ``"OTM"``, ``"DSTM"`` or ``"DOTM"``). Set by :meth:`predict`. info_ : ndarray of shape (batch_size, 8), float32 Per-series fit summary; column 4 is the index of the chosen model in ``("STM", "OTM", "DSTM", "DOTM")``. Set by :meth:`predict`. Notes ----- Input must be float32; float64 raises TypeError. Each series needs more than 3 observations. Prediction intervals (``level``), fitted values, non-normal error distributions and exogenous regressors are not supported and raise NotImplementedError. Examples -------- >>> import numpy as np, mojolearn >>> t = np.arange(96, dtype=np.float32) >>> y = 10 + 0.1 * t + np.sin(2 * np.pi * t / 12).astype(np.float32) >>> mojolearn.Theta(season_length=12).fit(y).predict(12)["mean"].shape (12,) """ _MODEL = "STM"
[docs] class OptimizedTheta(AutoTheta): """The optimized theta model (OTM). Follows statsforecast's ``OptimizedTheta``. ``fit`` takes one series of shape ``(n_obs,)`` or a batch of shape ``(batch_size, n_obs)``; the model is estimated when :meth:`predict` is called. Parameters ---------- season_length : int, default 1 Number of observations per season. 1 means no seasonality. decomposition_type : {"multiplicative", "additive"}, default "multiplicative" Seasonal decomposition used when the series tests as seasonal. model : ignored This class always uses the OTM model; the argument is accepted for signature compatibility with :class:`AutoTheta`. initial_smoothed : float or None, default None Fixes the initial smoothed level; None estimates it. alpha : float or None, default None Fixes the smoothing parameter; None estimates it. theta : float or None, default None Fixes the theta coefficient; None estimates it. numeric_mode : str or None, default None Numeric mode for this model (``'identical'``, ``'deterministic'`` or ``'fast'``); None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- model_ : list of str The theta model used for each series (``"STM"``, ``"OTM"``, ``"DSTM"`` or ``"DOTM"``). Set by :meth:`predict`. info_ : ndarray of shape (batch_size, 8), float32 Per-series fit summary; column 4 is the index of the chosen model in ``("STM", "OTM", "DSTM", "DOTM")``. Set by :meth:`predict`. Notes ----- Input must be float32; float64 raises TypeError. Each series needs more than 3 observations. Prediction intervals (``level``), fitted values, non-normal error distributions and exogenous regressors are not supported and raise NotImplementedError. Examples -------- >>> import numpy as np, mojolearn >>> t = np.arange(96, dtype=np.float32) >>> y = 10 + 0.1 * t + np.sin(2 * np.pi * t / 12).astype(np.float32) >>> mojolearn.OptimizedTheta(season_length=12).fit(y).predict(12)["mean"].shape (12,) """ _MODEL = "OTM"
[docs] class DynamicTheta(AutoTheta): """The dynamic standard theta model (DSTM). Follows statsforecast's ``DynamicTheta``. ``fit`` takes one series of shape ``(n_obs,)`` or a batch of shape ``(batch_size, n_obs)``; the model is estimated when :meth:`predict` is called. Parameters ---------- season_length : int, default 1 Number of observations per season. 1 means no seasonality. decomposition_type : {"multiplicative", "additive"}, default "multiplicative" Seasonal decomposition used when the series tests as seasonal. model : ignored This class always uses the DSTM model; the argument is accepted for signature compatibility with :class:`AutoTheta`. initial_smoothed : float or None, default None Fixes the initial smoothed level; None estimates it. alpha : float or None, default None Fixes the smoothing parameter; None estimates it. theta : float or None, default None Fixes the theta coefficient; None estimates it. numeric_mode : str or None, default None Numeric mode for this model (``'identical'``, ``'deterministic'`` or ``'fast'``); None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- model_ : list of str The theta model used for each series (``"STM"``, ``"OTM"``, ``"DSTM"`` or ``"DOTM"``). Set by :meth:`predict`. info_ : ndarray of shape (batch_size, 8), float32 Per-series fit summary; column 4 is the index of the chosen model in ``("STM", "OTM", "DSTM", "DOTM")``. Set by :meth:`predict`. Notes ----- Input must be float32; float64 raises TypeError. Each series needs more than 3 observations. Prediction intervals (``level``), fitted values, non-normal error distributions and exogenous regressors are not supported and raise NotImplementedError. Examples -------- >>> import numpy as np, mojolearn >>> t = np.arange(96, dtype=np.float32) >>> y = 10 + 0.1 * t + np.sin(2 * np.pi * t / 12).astype(np.float32) >>> mojolearn.DynamicTheta(season_length=12).fit(y).predict(12)["mean"].shape (12,) """ _MODEL = "DSTM"
[docs] class DynamicOptimizedTheta(AutoTheta): """The dynamic optimized theta model (DOTM). Follows statsforecast's ``DynamicOptimizedTheta``. ``fit`` takes one series of shape ``(n_obs,)`` or a batch of shape ``(batch_size, n_obs)``; the model is estimated when :meth:`predict` is called. Parameters ---------- season_length : int, default 1 Number of observations per season. 1 means no seasonality. decomposition_type : {"multiplicative", "additive"}, default "multiplicative" Seasonal decomposition used when the series tests as seasonal. model : ignored This class always uses the DOTM model; the argument is accepted for signature compatibility with :class:`AutoTheta`. initial_smoothed : float or None, default None Fixes the initial smoothed level; None estimates it. alpha : float or None, default None Fixes the smoothing parameter; None estimates it. theta : float or None, default None Fixes the theta coefficient; None estimates it. numeric_mode : str or None, default None Numeric mode for this model (``'identical'``, ``'deterministic'`` or ``'fast'``); None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- model_ : list of str The theta model used for each series (``"STM"``, ``"OTM"``, ``"DSTM"`` or ``"DOTM"``). Set by :meth:`predict`. info_ : ndarray of shape (batch_size, 8), float32 Per-series fit summary; column 4 is the index of the chosen model in ``("STM", "OTM", "DSTM", "DOTM")``. Set by :meth:`predict`. Notes ----- Input must be float32; float64 raises TypeError. Each series needs more than 3 observations. Prediction intervals (``level``), fitted values, non-normal error distributions and exogenous regressors are not supported and raise NotImplementedError. Examples -------- >>> import numpy as np, mojolearn >>> t = np.arange(96, dtype=np.float32) >>> y = 10 + 0.1 * t + np.sin(2 * np.pi * t / 12).astype(np.float32) >>> mojolearn.DynamicOptimizedTheta(season_length=12).fit(y).predict(12)["mean"].shape (12,) """ _MODEL = "DOTM"