Source code for mojolearn._x_sequence_prophet

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""ProphetForecaster: a Prophet-style forecaster (linear growth, Fourier
seasonalities, holiday regressors) fitted on the GPU."""
from . import _portable_math as math

from ._optional_numpy import require_numpy
np = require_numpy('_x_sequence_prophet')

from . import _backend


def _bind(mode):
    return _backend.binding("_mojolearn_x_sequence", mode)


def _days(t, mode):
    """(days float64 (N,), info [status, span, least spacing]): the time axis
    in days, converted and checked in Mojo (`prophet_days`; numpy datetime64
    is handed over as int64 nanoseconds since 1970)."""
    t = np.asarray(t)
    if np.issubdtype(t.dtype, np.datetime64):
        src, kind = np.ascontiguousarray(t.astype("datetime64[ns]").view(np.int64)), 1
    else:
        src, kind = np.ascontiguousarray(t, dtype=np.float64).reshape(-1), 0
    src = src.reshape(-1)
    days = np.empty(len(src), dtype=np.float64)
    info = np.zeros(3, dtype=np.float64)
    if len(src):
        _bind(mode).prophet_days([src.ctypes.data, days.ctypes.data, info.ctypes.data], [len(src), kind])
    return days, info


# The model of `prophet/forecaster.py` and `stan/prophet.stan` (linear growth),
# fitted by MAP on the GPU (`sequence/prophet.mojo`, one series per thread for a
# batch sharing t). The fit is our float32 L-BFGS, not Stan's; parity with the
# prophet package is at a tolerance. Refusals are listed by name in
# sequence/NOT_IMPLEMENTED.tsv.
[docs] class ProphetForecaster: """Prophet-style additive or multiplicative forecaster with linear growth. Models a series as a piecewise-linear trend with changepoints, plus Fourier seasonalities and optional holiday indicator regressors, and fits the maximum a posteriori parameters with float32 L-BFGS (Prophet uses Stan, so results agree with the prophet package to a tolerance, not exactly). Prophet's conventions are kept: ``y`` is scaled by ``max|y|`` and time to [0, 1] over the history; ``n_changepoints`` are placed evenly over the first ``changepoint_range`` of the history (the first dropped); per-feature prior scales; Prophet's linear initialization. Automatic seasonalities follow Prophet: yearly (period 365.25 days, order 10) when the history spans two years; weekly (7 days, order 3) when it spans two weeks at sub-weekly spacing; daily (1 day, order 4) when it spans two days at sub-daily spacing. Add others with :meth:`add_seasonality`. Time ``t`` is given in days (floats) or as numpy ``datetime64`` (converted to days since 1970). ``y`` may be one series of shape ``(n_obs,)`` or a batch of shape ``(batch_size, n_obs)`` sharing ``t``. Not supported: logistic and flat growth, uncertainty intervals and sampling (``mcmc_samples``, ``interval_width``), extra regressors beyond holiday indicator columns, conditional seasonalities, float64 ``y``. Parameters ---------- growth : {"linear"}, default "linear" Trend type; other values raise NotImplementedError. changepoints : array-like or None, default None Explicit changepoint times (same units as ``t``); they must fall within the training data. None places ``n_changepoints`` automatically. n_changepoints : int, default 25 Number of automatic changepoints. changepoint_range : float, default 0.8 Fraction of the history in which automatic changepoints are placed. yearly_seasonality, weekly_seasonality, daily_seasonality : "auto", bool or int, default "auto" ``"auto"`` applies Prophet's rule above, True forces the seasonality on with its default order, False turns it off, and an int turns it on with that Fourier order. seasonality_mode : {"additive", "multiplicative"}, default "additive" How seasonalities and holidays combine with the trend. seasonality_prior_scale : float, default 10.0 Prior scale of the seasonal coefficients. holidays_prior_scale : float, default 10.0 Prior scale of the holiday coefficients. changepoint_prior_scale : float, default 0.05 Prior scale of the trend changes (smaller is smoother). max_iter : int, default 2000 Maximum L-BFGS iterations. 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 ---------- seasonalities : dict Seasonalities in use, by name: ``period``, ``fourier_order`` and ``prior_scale``. changepoints_t_ : ndarray, float32 Changepoint times on the scaled [0, 1] time axis. params_ : ndarray of shape (batch_size, 3 + n_changepoints + n_features), float32 Fitted parameters per series. objective_ : ndarray of shape (batch_size,), float32 Final objective value per series. n_iter_ : ndarray of shape (batch_size,), int64 L-BFGS iterations per series. trend_ : ndarray Trend component at the times of the last :meth:`predict` call. Examples -------- >>> import numpy as np, mojolearn >>> t = np.arange(730, dtype=np.float64) >>> y = (10 + 0.01 * t + np.sin(2 * np.pi * t / 7)).astype(np.float32) >>> model = mojolearn.ProphetForecaster().fit(t, y) >>> model.predict(np.arange(730, 760, dtype=np.float64)).shape (30,) """
[docs] def __init__(self, growth="linear", changepoints=None, n_changepoints=25, changepoint_range=0.8, yearly_seasonality="auto", weekly_seasonality="auto", daily_seasonality="auto", seasonality_mode="additive", seasonality_prior_scale=10.0, holidays_prior_scale=10.0, changepoint_prior_scale=0.05, max_iter=2000, numeric_mode=None): if growth != "linear": raise NotImplementedError(f"ProphetForecaster: growth={growth!r} is not implemented ('linear')") if seasonality_mode not in ("additive", "multiplicative"): raise ValueError("seasonality_mode must be 'additive' or 'multiplicative'") self.growth = growth self.changepoints = changepoints self.n_changepoints = int(n_changepoints) self.changepoint_range = float(changepoint_range) self.yearly_seasonality = yearly_seasonality self.weekly_seasonality = weekly_seasonality self.daily_seasonality = daily_seasonality self.seasonality_mode = seasonality_mode self.seasonality_prior_scale = float(seasonality_prior_scale) self.holidays_prior_scale = float(holidays_prior_scale) self.changepoint_prior_scale = float(changepoint_prior_scale) self.max_iter = int(max_iter) self.numeric_mode = numeric_mode self.seasonalities = {}
[docs] def add_seasonality(self, name, period, fourier_order, prior_scale=None): """Add a custom seasonality before :meth:`fit`. Parameters ---------- name : str Seasonality name. Using ``"yearly"``, ``"weekly"`` or ``"daily"`` replaces the automatic one. period : float Period in days, greater than 0. fourier_order : int Number of Fourier terms, at least 1. prior_scale : float or None, default None Prior scale; None uses ``seasonality_prior_scale``. Returns ------- self """ if int(fourier_order) < 1 or float(period) <= 0: raise ValueError("add_seasonality: period > 0 and fourier_order >= 1") self.seasonalities[name] = dict(period=float(period), fourier_order=int(fourier_order), prior_scale=self.seasonality_prior_scale if prior_scale is None else float(prior_scale)) return self
def _auto(self, name, arg, default_order, ok): if name in self.seasonalities: return if arg == "auto": use, order = ok, default_order elif arg is True: use, order = True, default_order elif arg is False: use, order = False, 0 else: use, order = True, int(arg) if use: self.seasonalities[name] = dict(period={"yearly": 365.25, "weekly": 7.0, "daily": 1.0}[name], fourier_order=order, prior_scale=self.seasonality_prior_scale) def _features(self, days): """(t scaled float32 (N,), frac float32 (N, max(ns, 1))): Prophet's time scaling and (t mod P) / P per seasonality, in Mojo (`prophet_features`: exact float64 fmod, one rounding to float32).""" periods = np.ascontiguousarray([s["period"] for s in self.seasonalities.values()] or [1.0], # glue: one period per user seasonality dtype=np.float64) ns = len(self.seasonalities) tsc = np.empty(len(days), dtype=np.float32) frac = np.empty((len(days), max(ns, 1)), dtype=np.float32) _bind(self.numeric_mode).prophet_features( [days.ctypes.data, periods.ctypes.data, tsc.ctypes.data, frac.ctypes.data], [len(days), ns], [float(self.start_), float(self.t_scale_)]) return tsc, frac
[docs] def fit(self, t, y, holidays=None): """Fit the model. Parameters ---------- t : array-like of shape (n_obs,) Times in days (floats) or numpy ``datetime64``, sorted ascending, no NaN. y : array-like of shape (n_obs,) or (batch_size, n_obs) Observations, float32 and finite; at least 2 points. holidays : array-like of shape (n_obs, n_holidays) or None, default None Holiday indicator columns. Returns ------- self Raises ------ TypeError If ``y`` is float64. ValueError If ``t`` holds NaN or is not sorted, ``y`` is not finite, the lengths differ, there are fewer than 2 points, or explicit changepoints fall outside the training data. """ days, info = _days(t, self.numeric_mode) status, span, dmin = int(info[0]), float(info[1]), float(info[2]) if status == 1: raise ValueError("ProphetForecaster: t must not hold NaN") if status == 2: raise ValueError("ProphetForecaster: t must be sorted ascending") y = np.asarray(y) if y.dtype == np.float64: raise TypeError("ProphetForecaster: float64 y is refused; pass float32") 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) B, N = Y.shape if N != len(days) or N < 2: raise ValueError("ProphetForecaster: y and t lengths differ, or fewer than 2 points") from ._buffer import _native if int(_native("all_finite_f32")(Y.ctypes.data, Y.size)) != 1: raise ValueError("ProphetForecaster: y must be finite") self._auto("yearly", self.yearly_seasonality, 10, span >= 730) self._auto("weekly", self.weekly_seasonality, 3, span >= 14 and dmin < 7) self._auto("daily", self.daily_seasonality, 4, span >= 2 and dmin < 1) self.start_, self.t_scale_ = float(days[0]), (span if span > 0 else 1.0) tsc, frac_ = self._features(days) if self.changepoints is not None: user, _ = _days(self.changepoints, self.numeric_mode) n_user, hist, n_cp = len(user), 0, 0 else: user, n_user = np.zeros(1, dtype=np.float64), -1 hist = int(math.floor(N * self.changepoint_range)) n_cp = max(min(self.n_changepoints, hist - 1), 0) cpt = np.zeros(max(n_user, n_cp, 1), dtype=np.float32) S = _bind(self.numeric_mode).prophet_changepoints( # cpu-route: changepoint plan over a handful of changepoints [days.ctypes.data, user.ctypes.data, cpt.ctypes.data], [N, n_user, hist, n_cp], [self.start_, self.t_scale_]) if S < 0: raise ValueError("Changepoints must fall within training data.") self.changepoints_t_ = np.ascontiguousarray(cpt[:S]) self._nh = 0 if holidays is None else np.asarray(holidays).reshape(N, -1).shape[1] H = np.ascontiguousarray(np.asarray(holidays, dtype=np.float32).reshape(N, -1)) if self._nh else \ np.zeros((N, 1), np.float32) orders = np.asarray([s["fourier_order"] for s in self.seasonalities.values()], dtype=np.float32) # glue: one order per user seasonality sig = [s["prior_scale"] for s in self.seasonalities.values() for _ in range(2 * s["fourier_order"])] # glue: prior scale per Fourier parameter, model parameter count sig += [self.holidays_prior_scale] * self._nh self._K = len(sig) sig = np.ascontiguousarray(sig if sig else [1.0], dtype=np.float32) S = len(self.changepoints_t_) P = 3 + S + self._K self.params_ = np.zeros((B, P), dtype=np.float32) self.info_ = np.zeros((B, 4), dtype=np.float32) orders_ = orders if len(orders) else np.zeros(1, np.float32) _bind(self.numeric_mode).prophet_fit( [Y.ctypes.data, tsc.ctypes.data, frac_.ctypes.data, orders_.ctypes.data, H.ctypes.data, self.changepoints_t_.ctypes.data, sig.ctypes.data, self.params_.ctypes.data, self.info_.ctypes.data], [B, N, len(orders), self._nh, self._K, S, int(self.seasonality_mode == "multiplicative"), self.max_iter], [self.changepoint_prior_scale]) # diagnostics from info_ (y_scale, objective, iterations, 0): views only self.objective_ = self.info_[:, 1] self.n_iter_ = self.info_[:, 2].astype(np.int64) return self
[docs] def predict(self, t, holidays=None): """Predict at the given times. Parameters ---------- t : array-like of shape (n_times,) Times in days or numpy ``datetime64``, as for :meth:`fit`. holidays : array-like of shape (n_times, n_holidays) or None, default None Holiday indicator columns for ``t``; required when the model was fitted with holidays. Returns ------- ndarray of shape (n_times,) or (batch_size, n_times), float32 The forecast ``yhat``. The trend component is stored in ``trend_``. """ days, _ = _days(t, self.numeric_mode) M = len(days) tsc, frac_ = self._features(days) if self._nh: if holidays is None: raise ValueError("ProphetForecaster: the model has holiday columns; pass them for t") H = np.ascontiguousarray(np.asarray(holidays, dtype=np.float32).reshape(M, -1)) else: H = np.zeros((M, 1), np.float32) orders = np.asarray([s["fourier_order"] for s in self.seasonalities.values()], dtype=np.float32) # glue: one order per user seasonality orders_ = orders if len(orders) else np.zeros(1, np.float32) B = self.params_.shape[0] yhat = np.zeros((B, M), dtype=np.float32) trend = np.zeros((B, M), dtype=np.float32) S = len(self.changepoints_t_) _bind(self.numeric_mode).prophet_predict( [self.params_.ctypes.data, self.info_.ctypes.data, tsc.ctypes.data, frac_.ctypes.data, orders_.ctypes.data, H.ctypes.data, self.changepoints_t_.ctypes.data, yhat.ctypes.data, trend.ctypes.data], [B, M, len(orders), self._nh, self._K, S, int(self.seasonality_mode == "multiplicative")]) self.trend_ = trend[0] if self._one else trend return yhat[0] if self._one else yhat