# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""STL, Season-Trend decomposition by Loess, following
``statsmodels.tsa.seasonal.STL``. Series are decomposed on the GPU."""
from . import _portable_math as math
from ._optional_numpy import require_numpy
np = require_numpy('_x_sequence_stl')
from . import _backend
class DecomposeResult:
"""Result of :meth:`STL.fit`.
Attributes
----------
observed, seasonal, trend, resid, weights : ndarray, float32
The input series, its seasonal, trend and residual components, and
the robustness weights, each shaped like the input.
"""
def __init__(self, observed, seasonal, trend, resid, weights):
self.observed = observed
self.seasonal = seasonal
self.trend = trend
self.resid = resid
self.weights = weights
# The arithmetic is `sequence/stl.mojo`, statsmodels' `_stl.pyx` loops in
# float32, one series per GPU thread (or host loop iteration).
[docs]
class STL:
"""Season-Trend decomposition using Loess.
Splits each series into seasonal, trend and residual components with
statsmodels' STL algorithm and parameters. ``endog`` may be one series
of shape ``(n_obs,)`` or a batch of shape ``(batch_size, n_obs)``, one
series per row, all sharing the configuration.
Unlike statsmodels, ``period`` is required: statsmodels can infer it from
a pandas index, which is not supported here.
Parameters
----------
endog : array-like of shape (n_obs,) or (batch_size, n_obs)
The series, float32. float64 raises TypeError.
period : int
Periodicity of the season, at least 2. Required.
seasonal : int, default 7
Length of the seasonal smoother (odd).
trend : int or None, default None
Length of the trend smoother. None uses statsmodels' default, the
smallest odd integer at least ``1.5 * period / (1 - 1.5 / seasonal)``.
low_pass : int or None, default None
Length of the low-pass filter. None uses the smallest odd integer
greater than ``period``.
seasonal_deg, trend_deg, low_pass_deg : {0, 1}, default 1
Degree of each Loess fit (0 constant, 1 linear).
robust : bool, default False
Use robust fitting with outer iterations and robustness weights.
seasonal_jump, trend_jump, low_pass_jump : int, default 1
Evaluate each smoother every ``jump`` points and interpolate between.
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`).
Raises
------
TypeError
If ``endog`` is float64.
ValueError
If ``period`` is missing or below 2, a degree is not 0 or 1, or
``endog`` is not 1-D or 2-D.
Examples
--------
>>> import numpy as np, mojolearn
>>> t = np.arange(144, dtype=np.float32)
>>> y = 0.05 * t + np.sin(2 * np.pi * t / 12).astype(np.float32)
>>> res = mojolearn.STL(y, period=12).fit()
>>> res.seasonal.shape
(144,)
"""
[docs]
def __init__(self, endog, period=None, seasonal=7, trend=None, low_pass=None, seasonal_deg=1,
trend_deg=1, low_pass_deg=1, robust=False, seasonal_jump=1, trend_jump=1,
low_pass_jump=1, numeric_mode=None):
y = np.asarray(endog)
if y.dtype == np.float64:
raise TypeError("STL: float64 is refused; pass float32 (no float64 on the device)")
self._one = y.ndim == 1
y = np.ascontiguousarray(y, dtype=np.float32).reshape(1, -1) if self._one else \
np.ascontiguousarray(y, dtype=np.float32)
if y.ndim != 2 or y.shape[1] < 1:
raise ValueError("STL: endog must be (n_obs,) or (batch_size, n_obs)")
if period is None:
raise ValueError("STL: period is required (the reference infers it from a pandas index only)")
period = int(period)
if period < 2:
raise ValueError("period must be a positive integer >= 2")
if trend is None:
trend = int(math.ceil(1.5 * period / (1 - 1.5 / seasonal)))
trend += (trend % 2) == 0
if low_pass is None:
low_pass = period + 1
low_pass += (low_pass % 2) == 0
for name, v in (("seasonal_deg", seasonal_deg), ("trend_deg", trend_deg), ("low_pass_deg", low_pass_deg)): # glue: the three named degree arguments
if v not in (0, 1):
raise ValueError(f"{name} must be 0 or 1")
self.endog = y
self.period = period
self.seasonal = int(seasonal)
self.trend = int(trend)
self.low_pass = int(low_pass)
self.seasonal_deg, self.trend_deg, self.low_pass_deg = int(seasonal_deg), int(trend_deg), int(low_pass_deg)
self.robust = bool(robust)
self.seasonal_jump, self.trend_jump, self.low_pass_jump = int(seasonal_jump), int(trend_jump), int(low_pass_jump)
self.numeric_mode = numeric_mode
@property
def config(self):
"""The decomposition settings as a dict."""
return dict(period=self.period, seasonal=self.seasonal, seasonal_deg=self.seasonal_deg,
seasonal_jump=self.seasonal_jump, trend=self.trend, trend_deg=self.trend_deg,
trend_jump=self.trend_jump, low_pass=self.low_pass, low_pass_deg=self.low_pass_deg,
low_pass_jump=self.low_pass_jump, robust=self.robust)
[docs]
def fit(self, inner_iter=None, outer_iter=None):
"""Run the decomposition.
Parameters
----------
inner_iter : int or None, default None
Inner loop iterations. None uses 2 when ``robust`` and 5
otherwise, as statsmodels does.
outer_iter : int or None, default None
Outer (robustness) iterations. None uses 15 when ``robust`` and 0
otherwise.
Returns
-------
DecomposeResult
``observed``, ``seasonal``, ``trend``, ``resid`` and ``weights``,
each shaped like ``endog``.
"""
if inner_iter is None:
inner_iter = 2 if self.robust else 5
if outer_iter is None:
outer_iter = 15 if self.robust else 0
B, n = self.endog.shape
outs = [np.zeros((B, n), dtype=np.float32) for _ in range(4)] # glue: the four output buffers
b = _backend.binding("_mojolearn_x_sequence", self.numeric_mode)
b.stl([self.endog.ctypes.data] + [o.ctypes.data for o in outs], # glue: the four output buffer addresses
[B, n, self.period, self.seasonal, self.trend, self.low_pass,
self.seasonal_deg + 2 * self.trend_deg + 4 * self.low_pass_deg,
self.seasonal_jump, self.trend_jump, self.low_pass_jump, int(inner_iter), int(outer_iter)])
season, trend, weights, resid = (o[0] if self._one else o for o in outs) # glue: the four output buffers
observed = self.endog[0] if self._one else self.endog
return DecomposeResult(observed, season, trend, resid, weights)