Repository: statsmodels/statsmodels

Issue:
FR: Add an equivalent to R's forecast.stlm
I saw that you just added the long awaited for `STL` - yay!
question: how would you go about using its result for forecasting, the same way `forecast.stlm` is used in R?



Discussion:
This has not been implemented.

Resolving patch:
diff --git a/statsmodels/tsa/api.py b/statsmodels/tsa/api.py
--- a/statsmodels/tsa/api.py
+++ b/statsmodels/tsa/api.py
@@ -17,7 +17,7 @@
            'SARIMAX', 'UnobservedComponents', 'VARMAX', 'DynamicFactor',
            'MarkovRegression', 'MarkovAutoregression',
            'ExponentialSmoothing', 'SimpleExpSmoothing', 'Holt',
-           'arma_generate_sample', 'ArmaProcess', 'STL',
+           'arma_generate_sample', 'ArmaProcess', 'STL', 'STLForecast',
            'bk_filter', 'cf_filter', 'hp_filter']
 
 from .ar_model import AR, AutoReg
@@ -52,4 +52,5 @@
 from .holtwinters import ExponentialSmoothing, SimpleExpSmoothing, Holt
 from .innovations import api as innovations
 from .seasonal import STL
+from .forecasting.stl import STLForecast
 from .filters import bk_filter, cf_filter, hp_filter
diff --git a/statsmodels/tsa/base/tsa_model.py b/statsmodels/tsa/base/tsa_model.py
--- a/statsmodels/tsa/base/tsa_model.py
+++ b/statsmodels/tsa/base/tsa_model.py
@@ -9,7 +9,7 @@
                     Float64Index, date_range, period_range)
 from pandas.tseries.frequencies import to_offset
 
-from statsmodels.base import data
+from statsmodels.base.data import PandasData
 import statsmodels.base.model as base
 import statsmodels.base.wrapper as wrap
 from statsmodels.tools.sm_exceptions import ValueWarning
@@ -257,6 +257,150 @@ def get_index_label_loc(key, index, row_labels):
     return loc, index, index_was_expanded
 
 
+def get_prediction_index(start, end, nobs, base_index, index=None, silent=False, index_none=False,
+                         index_generated=None, data=None):
+    """
+    Get the location of a specific key in an index or model row labels
+
+    Parameters
+    ----------
+    start : label
+        The key at which to start prediction. Depending on the underlying
+        model's index, may be an integer, a date (string, datetime object,
+        pd.Timestamp, or pd.Period object), or some other object in the
+        model's row labels.
+    end : label
+        The key at which to end prediction (note that this key will be
+        *included* in prediction). Depending on the underlying
+        model's index, may be an integer, a date (string, datetime object,
+        pd.Timestamp, or pd.Period object), or some other object in the
+        model's row labels.
+    nobs : int
+    base_index : pd.Index
+
+    index : pd.Index, optional
+        Optionally an index to associate the predicted results to. If None,
+        an attempt is made to create an index for the predicted results
+        from the model's index or model's row labels.
+    silent : bool, optional
+        Argument to silence warnings.
+
+    Returns
+    -------
+    start : int
+        The index / observation location at which to begin prediction.
+    end : int
+        The index / observation location at which to end in-sample
+        prediction. The maximum value for this is nobs-1.
+    out_of_sample : int
+        The number of observations to forecast after the end of the sample.
+    prediction_index : pd.Index or None
+        The index associated with the prediction results. This index covers
+        the range [start, end + out_of_sample]. If the model has no given
+        index and no given row labels (i.e. endog/exog is not Pandas), then
+        this will be None.
+
+    Notes
+    -----
+    The arguments `start` and `end` behave differently, depending on if
+    they are integer or not. If either is an integer, then it is assumed
+    to refer to a *location* in the index, not to an index value. On the
+    other hand, if it is a date string or some other type of object, then
+    it is assumed to refer to an index *value*. In all cases, the returned
+    `start` and `end` values refer to index *locations* (so in the former
+    case, the given location is validated and returned whereas in the
+    latter case a location is found that corresponds to the given index
+    value).
+
+    This difference in behavior is necessary to support `RangeIndex`. This
+    is because integers for a RangeIndex could refer either to index values
+    or to index locations in an ambiguous way (while for `Int64Index`,
+    since we have required them to be full indexes, there is no ambiguity).
+    """
+
+    # Convert index keys (start, end) to index locations and get associated
+    # indexes.
+    try:
+        start, _, start_oos = get_index_label_loc(start, base_index, data.row_labels)
+    except KeyError:
+        raise KeyError('The `start` argument could not be matched to a'
+                       ' location related to the index of the data.')
+    if end is None:
+        end = max(start, len(base_index) - 1)
+    try:
+        end, end_index, end_oos = get_index_label_loc(end, base_index, data.row_labels)
+    except KeyError:
+        raise KeyError('The `end` argument could not be matched to a'
+                       ' location related to the index of the data.')
+
+    # Handle slices (if the given index keys cover more than one date)
+    if isinstance(start, slice):
+        start = start.start
+    if isinstance(end, slice):
+        end = end.stop - 1
+
+    # Get the actual index for the prediction
+    prediction_index = end_index[start:]
+
+    # Validate prediction options
+    if end < start:
+        raise ValueError('Prediction must have `end` after `start`.')
+
+    # Handle custom prediction index
+    # First, if we were given an index, check that it's the right size and
+    # use it if so
+    if index is not None:
+        if not len(prediction_index) == len(index):
+            raise ValueError('Invalid `index` provided in prediction.'
+                             ' Must have length consistent with `start`'
+                             ' and `end` arguments.')
+        # But if we weren't given Pandas input, this index will not be
+        # used because the data will not be wrapped; in that case, issue
+        # a warning
+        if not isinstance(data, PandasData) and not silent:
+            warnings.warn('Because the model data (`endog`, `exog`) were'
+                          ' not given as Pandas objects, the prediction'
+                          ' output will be Numpy arrays, and the given'
+                          ' `index` argument will only be used'
+                          ' internally.', ValueWarning)
+        prediction_index = Index(index)
+    # Now, if we *do not* have a supported index, but we were given some
+    # kind of index...
+    elif index_generated and not index_none:
+        # If we are in sample, and have row labels, use them
+        if data.row_labels is not None and not (start_oos or end_oos):
+            prediction_index = data.row_labels[start:end + 1]
+        # Otherwise, warn the user that they will get an Int64Index
+        else:
+            if not silent:
+                warnings.warn('No supported index is available.'
+                              ' Prediction results will be given with'
+                              ' an integer index beginning at `start`.',
+                              ValueWarning)
+            warnings.warn('No supported index is available. In the next'
+                          ' version, calling this method in a model'
+                          ' without a supported index will result in an'
+                          ' exception.', DeprecationWarning)
+    elif index_none:
+        prediction_index = None
+
+    # For backwards compatibility, set `predict_*` values
+    if prediction_index is not None:
+        data.predict_start = prediction_index[0]
+        data.predict_end = prediction_index[-1]
+        data.predict_dates = prediction_index
+    else:
+        data.predict_start = None
+        data.predict_end = None
+        data.predict_dates = None
+
+    # Compute out-of-sample observations
+    out_of_sample = max(end - (nobs - 1), 0)
+    end -= out_of_sample
+
+    return start, end, out_of_sample, prediction_index
+
+
 class TimeSeriesModel(base.LikelihoodModel):
     __doc__ = _tsa_doc % {"model": _model_doc, "params": _generic_params,
                           "extra_params": _missing_param_doc,
@@ -590,89 +734,16 @@ def _get_prediction_index(self, start, end, index=None, silent=False):
         or to index locations in an ambiguous way (while for `Int64Index`,
         since we have required them to be full indexes, there is no ambiguity).
         """
-
-        # Convert index keys (start, end) to index locations and get associated
-        # indexes.
-        try:
-            start, start_index, start_oos = self._get_index_label_loc(start)
-        except KeyError:
-            raise KeyError('The `start` argument could not be matched to a'
-                           ' location related to the index of the data.')
-        if end is None:
-            end = max(start, len(self._index) - 1)
-        try:
-            end, end_index, end_oos = self._get_index_label_loc(end)
-        except KeyError:
-            raise KeyError('The `end` argument could not be matched to a'
-                           ' location related to the index of the data.')
-
-        # Handle slices (if the given index keys cover more than one date)
-        if isinstance(start, slice):
-            start = start.start
-        if isinstance(end, slice):
-            end = end.stop - 1
-
-        # Get the actual index for the prediction
-        prediction_index = end_index[start:]
-
-        # Validate prediction options
-        if end < start:
-            raise ValueError('Prediction must have `end` after `start`.')
-
-        # Handle custom prediction index
-        # First, if we were given an index, check that it's the right size and
-        # use it if so
-        if index is not None:
-            if not len(prediction_index) == len(index):
-                raise ValueError('Invalid `index` provided in prediction.'
-                                 ' Must have length consistent with `start`'
-                                 ' and `end` arguments.')
-            # But if we weren't given Pandas input, this index will not be
-            # used because the data will not be wrapped; in that case, issue
-            # a warning
-            if not isinstance(self.data, data.PandasData) and not silent:
-                warnings.warn('Because the model data (`endog`, `exog`) were'
-                              ' not given as Pandas objects, the prediction'
-                              ' output will be Numpy arrays, and the given'
-                              ' `index` argument will only be used'
-                              ' internally.', ValueWarning)
-            prediction_index = Index(index)
-        # Now, if we *do not* have a supported index, but we were given some
-        # kind of index...
-        elif self._index_generated and not self._index_none:
-            # If we are in sample, and have row labels, use them
-            if self.data.row_labels is not None and not (start_oos or end_oos):
-                prediction_index = self.data.row_labels[start:end + 1]
-            # Otherwise, warn the user that they will get an Int64Index
-            else:
-                if not silent:
-                    warnings.warn('No supported index is available.'
-                                  ' Prediction results will be given with'
-                                  ' an integer index beginning at `start`.',
-                                  ValueWarning)
-                warnings.warn('No supported index is available. In the next'
-                              ' version, calling this method in a model'
-                              ' without a supported index will result in an'
-                              ' exception.', DeprecationWarning)
-        elif self._index_none:
-            prediction_index = None
-
-        # For backwards compatibility, set `predict_*` values
-        if prediction_index is not None:
-            self.data.predict_start = prediction_index[0]
-            self.data.predict_end = prediction_index[-1]
-            self.data.predict_dates = prediction_index
-        else:
-            self.data.predict_start = None
-            self.data.predict_end = None
-            self.data.predict_dates = None
-
-        # Compute out-of-sample observations
         nobs = len(self.endog)
-        out_of_sample = max(end - (nobs - 1), 0)
-        end -= out_of_sample
-
-        return start, end, out_of_sample, prediction_index
+        return get_prediction_index(start,
+                                    end,
+                                    nobs,
+                                    base_index=self._index,
+                                    index=index,
+                                    silent=silent,
+                                    index_none=self._index_none,
+                                    index_generated=self._index_generated,
+                                    data=self.data)
 
     def _get_exog_names(self):
         return self.data.xnames
diff --git a/statsmodels/tsa/forecasting/stl.py b/statsmodels/tsa/forecasting/stl.py
new file mode 100644
--- /dev/null
+++ b/statsmodels/tsa/forecasting/stl.py
@@ -0,0 +1,505 @@
+from statsmodels.compat.pandas import Substitution
+
+import datetime as dt
+from textwrap import indent
+from typing import Any, Dict, Optional, Union
+
+import numpy as np
+import pandas as pd
+
+from statsmodels.base.data import PandasData
+from statsmodels.iolib.summary import SimpleTable, Summary
+from statsmodels.tools.docstring import Docstring, Parameter
+from statsmodels.tsa.base.prediction import PredictionResults
+from statsmodels.tsa.base.tsa_model import get_index_loc, get_prediction_index
+from statsmodels.tsa.seasonal import STL, DecomposeResult
+from statsmodels.tsa.statespace.kalman_filter import _check_dynamic
+
+DateLike = Union[int, str, dt.datetime, pd.Timestamp, np.datetime64]
+
+ds = Docstring(STL.__doc__)
+ds.insert_parameters(
+    "endog",
+    Parameter(
+        "model",
+        "Model",
+        [
+            "The model used to forecast endog after the seasonality has been "
+            "removed using STL"
+        ],
+    ),
+)
+ds.insert_parameters(
+    "model",
+    Parameter(
+        "model_kwargs",
+        "Dict[str, Any]",
+        [
+            "Any additional arguments needed to initialized the model using "
+            "the residuals produced by subtracting the seasonality."
+        ],
+    ),
+)
+_stl_forecast_params = ds.extract_parameters(
+    [
+        "endog",
+        "model",
+        "model_kwargs",
+        "period",
+        "seasonal",
+        "trend",
+        "low_pass",
+        "seasonal_deg",
+        "trend_deg",
+        "low_pass_deg",
+        "robust",
+        "seasonal_jump",
+        "trend_jump",
+        "low_pass_jump",
+    ]
+)
+
+ds = Docstring(STL.fit.__doc__)
+_fit_params = ds.extract_parameters(["inner_iter", "outer_iter"])
+
+
+@Substitution(stl_forecast_params=indent(_stl_forecast_params, "    "))
+class STLForecast:
+    r"""
+    Model-based forecasting using STL to remove seasonality
+
+    Forecasts are produced by first subtracting the seasonality
+    estimated using STL, then forecasting the deseasonalized
+    data using a time-series model, for example, ARIMA.
+
+    Parameters
+    ----------
+%(stl_forecast_params)s
+
+    See Also
+    --------
+    statsmodels.tsa.arima.model.ARIMA
+        ARIMA modeling.
+    statsmodels.tsa.ar_model.AutoReg
+        Autoregressive modeling supporting complext deterministics.
+    statsmodels.tsa.exponential_smoothing.ets.ETSModel
+        Additive and multiplicative exponential smoothing with trend.
+    statsmodels.tsa.statespace.exponential_smoothing.ExponentialSmoothing
+        Additive exponential smoothing with trend.
+
+    Notes
+    -----
+    If :math:`\hat{S}_t` is the seasonal component, then the deseasonalize
+    series is constructed as
+
+    .. math::
+
+        Y_t - \hat{S}_t
+
+    The trend component is not removed, and so the time series model should
+    be capable of adequately fitting and foreasting the trend if present. The
+    out-of-sample forecasts of the seasonal component are produced as
+
+    .. math::
+
+        \hat{S}_{T + h} = \hat{S}_{T - k}
+
+    where :math:`k = m - h + m \lfloor (h-1)/m \rfloor` tracks the period
+    offset in the full cycle of 1, 2, ..., m where m is the period length.
+
+    This class is mostly a convenience wrapper around ``STL`` and a
+    user-specified model. The model is assumed to follow the standard
+    statsmodels pattern:
+
+    * ``fit`` is used to estimate parameters and returns a results instance,
+      ``results``.
+    * ``results`` must exposes a method ``forecast(steps, **kwargs)`` that
+      produces out-of-sample forecasts.
+    * ``results`` may also exposes a method ``get_prediction`` that produces
+      both in- and out-of-sample predictions.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import pandas as pd
+    >>> from statsmodels.tsa.api import STLForecast
+    >>> from statsmodels.tsa.arima.model import ARIMA
+    >>> from statsmodels.datasets import macrodata
+    >>> ds = macrodata.load_pandas()
+    >>> data = np.log(ds.data.m1)
+    >>> base_date = f"{int(ds.data.year[0])}-{3*int(ds.data.quarter[0])+1}-1"
+    >>> data.index = pd.date_range(base_date, periods=data.shape[0], freq="QS")
+
+    Generate forecasts from an ARIMA
+
+    >>> stlf = STLForecast(data, ARIMA, model_kwargs={"order": (2, 1, 0)})
+    >>> res = stlf.fit()
+    >>> forecasts = res.forecast(12)
+
+    Generate forecasts from an Exponential Smoothing model with trend
+    >>> from statsmodels.tsa.statespace import exponential_smoothing
+    >>> ES = exponential_smoothing.ExponentialSmoothing
+    >>> config = {"trend": True}
+    >>> stlf = STLForecast(data, ES, model_kwargs=config)
+    >>> res = stlf.fit()
+    >>> forecasts = res.forecast(12)
+    """
+
+    def __init__(
+        self,
+        endog,
+        model,
+        *,
+        model_kwargs=None,
+        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,
+    ):
+        self._endog = endog
+        self._stl_kwargs = dict(
+            period=period,
+            seasonal=seasonal,
+            trend=trend,
+            low_pass=low_pass,
+            seasonal_deg=seasonal_deg,
+            trend_deg=trend_deg,
+            low_pass_deg=low_pass_deg,
+            robust=robust,
+            seasonal_jump=seasonal_jump,
+            trend_jump=trend_jump,
+            low_pass_jump=low_pass_jump,
+        )
+        self._model = model
+        self._model_kwargs = {} if model_kwargs is None else model_kwargs
+        if not hasattr(model, "fit"):
+            raise AttributeError("model must expose a ``fit``  method.")
+
+    @Substitution(fit_params=indent(_fit_params, " " * 8))
+    def fit(self, *, inner_iter=None, outer_iter=None, fit_kwargs=None):
+        """
+        Estimate STL and forecasting model parameters.
+
+        Parameters
+        ----------
+%(fit_params)s
+        fit_kwargs : Dict[str, Any]
+            Any additional keyword arguments to pass to ``model``'s ``fit``
+            method when estimating the model on the decomposed residuals.
+
+        Returns
+        -------
+        STLForecastResults
+            Results with forecasting methods.
+        """
+        fit_kwargs = {} if fit_kwargs is None else fit_kwargs
+        stl = STL(self._endog, **self._stl_kwargs)
+        stl_fit: DecomposeResult = stl.fit(
+            inner_iter=inner_iter, outer_iter=outer_iter
+        )
+        model_endog = stl_fit.trend + stl_fit.resid
+        mod = self._model(model_endog, **self._model_kwargs)
+        res = mod.fit(**fit_kwargs)
+        if not hasattr(res, "forecast"):
+            raise AttributeError(
+                "The model's result must expose a ``forecast`` method."
+            )
+        return STLForecastResults(stl, stl_fit, mod, res, self._endog)
+
+
+class STLForecastResults:
+    """
+    Results for forecasting using STL to remove seasonality
+
+    Parameters
+    ----------
+    stl : STL
+        The STL instance used to decompose the data.
+    result : DecomposeResult
+        The result of applying STL to the data.
+    model : Model
+        The time series model used to model the non-seasonal dynamics.
+    model_result : Results
+        Model results instance supporting, at a minimum, ``forecast``.
+    """
+
+    def __init__(
+        self, stl: STL, result: DecomposeResult, model, model_result, endog
+    ) -> None:
+        self._stl = stl
+        self._result = result
+        self._model = model
+        self._model_result = model_result
+        self._endog = np.asarray(endog)
+        self._nobs = self._endog.shape[0]
+        self._index = getattr(endog, "index", pd.RangeIndex(self._nobs))
+        if not isinstance(
+            self._index, (pd.Int64Index, pd.DatetimeIndex, pd.PeriodIndex)
+        ):
+            try:
+                self._index = pd.to_datetime(self._index)
+            except ValueError:
+                self._index = pd.RangeIndex(self._nobs)
+
+    @property
+    def period(self) -> int:
+        """The period of the seasonal component"""
+        return self._stl.period
+
+    @property
+    def stl(self) -> STL:
+        """The STL instance used to decompose the time series"""
+        return self._stl
+
+    @property
+    def result(self) -> DecomposeResult:
+        """The result of applying STL to the data"""
+        return self._result
+
+    @property
+    def model(self) -> Any:
+        """The model fit to the additively deseasonalized data"""
+        return self._model
+
+    @property
+    def model_result(self) -> Any:
+        """The result class from the estimated model"""
+        return self._model_result
+
+    def summary(self) -> Summary:
+        """
+        Summary of both the STL decomposition and the model fit.
+
+        Returns
+        -------
+        Summary
+            The summary of the model fit and the STL decomposition.
+
+        Notes
+        -----
+        Requires that the model's result class supports ``summary`` and
+        returns a ``Summary`` object.
+        """
+        if not hasattr(self._model_result, "summary"):
+            raise AttributeError(
+                "The model result does not have a summary attribute."
+            )
+        summary: Summary = self._model_result.summary()
+        if not isinstance(summary, Summary):
+            raise TypeError(
+                "The model result's summary is not a Summary object."
+            )
+        summary.tables[0].title = (
+            "STL Decomposition and " + summary.tables[0].title
+        )
+        config = self._stl.config
+        left_keys = ("period", "seasonal", "robust")
+        left_data = []
+        left_stubs = []
+        right_data = []
+        right_stubs = []
+        for key in config:
+            new = key.capitalize()
+            new = new.replace("_", " ")
+            if new in ("Trend", "Low Pass"):
+                new += " Length"
+            is_left = any(key.startswith(val) for val in left_keys)
+            new += ":"
+            stub = f"{new:<23s}"
+            val = f"{str(config[key]):>13s}"
+            if is_left:
+                left_stubs.append(stub)
+                left_data.append([val])
+            else:
+                right_stubs.append(" " * 6 + stub)
+                right_data.append([val])
+        tab = SimpleTable(
+            left_data, stubs=tuple(left_stubs), title="STL Configuration"
+        )
+        tab.extend_right(SimpleTable(right_data, stubs=right_stubs))
+        summary.tables.append(tab)
+        return summary
+
+    def _get_seasonal_prediction(
+        self,
+        start: Optional[DateLike],
+        end: Optional[DateLike],
+        dynamic: Union[bool, DateLike],
+    ) -> np.ndarray:
+        """
+        Get STLs seasonal in- and out-of-sample predictions
+
+        Parameters
+        ----------
+        start : int, str, or datetime, optional
+            Zero-indexed observation number at which to start forecasting,
+            i.e., the first forecast is start. Can also be a date string to
+            parse or a datetime type. Default is the the zeroth observation.
+        end : int, str, or datetime, optional
+            Zero-indexed observation number at which to end forecasting, i.e.,
+            the last forecast is end. Can also be a date string to
+            parse or a datetime type. However, if the dates index does not
+            have a fixed frequency, end must be an integer index if you
+            want out of sample prediction. Default is the last observation in
+            the sample.
+        dynamic : bool, int, str, or datetime, optional
+            Integer offset relative to `start` at which to begin dynamic
+            prediction. Can also be an absolute date string to parse or a
+            datetime type (these are not interpreted as offsets).
+            Prior to this observation, true endogenous values will be used for
+            prediction; starting with this observation and continuing through
+            the end of prediction, forecasted endogenous values will be used
+            instead.
+
+        Returns
+        -------
+        ndarray
+            Array containing the seasibak predictions.
+        """
+        data = PandasData(pd.Series(self._endog), index=self._index)
+        (start, end, out_of_sample, prediction_index,) = get_prediction_index(
+            start, end, self._nobs, self._index, data=data
+        )
+
+        if isinstance(dynamic, (str, dt.datetime, pd.Timestamp)):
+            dynamic, _, _ = get_index_loc(dynamic, self._index)
+            dynamic = dynamic - start
+        elif dynamic is True:
+            dynamic = 0
+        elif dynamic is False:
+            # If `dynamic=False`, then no dynamic predictions
+            dynamic = None
+        nobs = self._nobs
+        dynamic, _ = _check_dynamic(dynamic, start, end, nobs)
+        in_sample_end = end + 1 if dynamic is None else dynamic
+        seasonal = np.asarray(self._result.seasonal)
+        predictions = seasonal[start:in_sample_end]
+        oos = np.empty((0,))
+        if dynamic is not None:
+            num = out_of_sample + end + 1 - dynamic
+            oos = self._seasonal_forecast(num, None, offset=dynamic)
+        elif out_of_sample:
+            oos = self._seasonal_forecast(out_of_sample, None)
+            oos_start = max(start - nobs, 0)
+            oos = oos[oos_start:]
+        predictions = np.r_[predictions, oos]
+        return predictions
+
+    def _seasonal_forecast(
+        self, steps: int, index: Optional[pd.Index], offset=None
+    ) -> Union[pd.Series, np.ndarray]:
+        """
+        Get the seasonal component of the forecast
+
+        Parameters
+        ----------
+        steps : int
+            The number of steps required.
+        index : pd.Index
+            A pandas index to use. If None, returns an ndarray.
+        offset : int
+            The index of the first out-of-sample observation. If None, uses
+            nobs.
+
+        Returns
+        -------
+        seasonal : {ndarray, Series}
+            The seasonal component.
+        """
+
+        period = self.period
+        seasonal = np.asarray(self._result.seasonal)
+        offset = self._nobs if offset is None else offset
+        seasonal = seasonal[offset - period : offset]
+        seasonal = np.tile(seasonal, steps // period + ((steps % period) != 0))
+        seasonal = seasonal[:steps]
+        if index is not None:
+            seasonal = pd.Series(seasonal, index=index)
+        return seasonal
+
+    def forecast(
+        self, steps: int = 1, **kwargs: Dict[str, Any]
+    ) -> Union[np.ndarray, pd.Series]:
+        """
+        Out-of-sample forecasts
+
+        Parameters
+        ----------
+        steps : int, str, or datetime, optional
+            If an integer, the number of steps to forecast from the end of the
+            sample. Can also be a date string to parse or a datetime type.
+            However, if the dates index does not have a fixed frequency, steps
+            must be an integer. Default
+        **kwargs
+            Additional arguments may required for forecasting beyond the end
+            of the sample. These arguments are passed into the time series
+            model results' ``forecast`` method.
+
+        Returns
+        -------
+        forecast : {ndarray, Series}
+            Out of sample forecasts
+        """
+        forecast = self._model_result.forecast(steps=steps, **kwargs)
+        index = forecast.index if isinstance(forecast, pd.Series) else None
+        return forecast + self._seasonal_forecast(steps, index)
+
+    def get_prediction(
+        self,
+        start: Optional[DateLike] = None,
+        end: Optional[DateLike] = None,
+        dynamic: Union[bool, DateLike] = False,
+        **kwargs: Dict[str, Any],
+    ):
+        """
+        In-sample prediction and out-of-sample forecasting
+
+        Parameters
+        ----------
+        start : int, str, or datetime, optional
+            Zero-indexed observation number at which to start forecasting,
+            i.e., the first forecast is start. Can also be a date string to
+            parse or a datetime type. Default is the the zeroth observation.
+        end : int, str, or datetime, optional
+            Zero-indexed observation number at which to end forecasting, i.e.,
+            the last forecast is end. Can also be a date string to
+            parse or a datetime type. However, if the dates index does not
+            have a fixed frequency, end must be an integer index if you
+            want out of sample prediction. Default is the last observation in
+            the sample.
+        dynamic : bool, int, str, or datetime, optional
+            Integer offset relative to `start` at which to begin dynamic
+            prediction. Can also be an absolute date string to parse or a
+            datetime type (these are not interpreted as offsets).
+            Prior to this observation, true endogenous values will be used for
+            prediction; starting with this observation and continuing through
+            the end of prediction, forecasted endogenous values will be used
+            instead.
+        **kwargs
+            Additional arguments may required for forecasting beyond the end
+            of the sample. These arguments are passed into the time series
+            model results' ``get_prediction`` method.
+
+        Returns
+        -------
+        PredictionResults
+            PredictionResults instance containing in-sample predictions,
+            out-of-sample forecasts, and prediction intervals.
+        """
+        pred = self._model_result.get_prediction(
+            start=start, end=end, dynamic=dynamic, **kwargs
+        )
+        seasonal_prediction = self._get_seasonal_prediction(
+            start, end, dynamic
+        )
+        mean = pred.predicted_mean + seasonal_prediction
+        return PredictionResults(
+            mean, pred.var_pred_mean, dist="norm", row_labels=pred.row_labels,
+        )


Test patch:
diff --git a/statsmodels/tsa/forecasting/tests/test_stl.py b/statsmodels/tsa/forecasting/tests/test_stl.py
new file mode 100644
--- /dev/null
+++ b/statsmodels/tsa/forecasting/tests/test_stl.py
@@ -0,0 +1,126 @@
+import numpy as np
+from numpy.testing import assert_allclose
+import pandas as pd
+import pytest
+
+from statsmodels.tsa.ar_model import AutoReg
+from statsmodels.tsa.arima.model import ARIMA
+from statsmodels.tsa.base.prediction import PredictionResults
+from statsmodels.tsa.deterministic import Fourier
+from statsmodels.tsa.exponential_smoothing.ets import ETSModel
+from statsmodels.tsa.forecasting.stl import STLForecast
+from statsmodels.tsa.seasonal import STL, DecomposeResult
+from statsmodels.tsa.statespace.exponential_smoothing import (
+    ExponentialSmoothing,
+)
+
+
+@pytest.fixture(scope="module")
+def data(request):
+    rs = np.random.RandomState(987654321)
+    err = rs.standard_normal(500)
+    index = pd.date_range("1980-1-1", freq="M", periods=500)
+    fourier = Fourier(12, 1)
+    terms = fourier.in_sample(index)
+    det = np.squeeze(np.asarray(terms @ np.array([[2], [1]])))
+    for i in range(1, 500):
+        err[i] += 0.9 * err[i - 1] + det[i]
+    return pd.Series(err, index=index)
+
+
+def test_smoke(data):
+    stlf = STLForecast(data, ARIMA, model_kwargs={"order": (2, 0, 0)})
+    res = stlf.fit(fit_kwargs={})
+    res.forecast(37)
+    assert isinstance(res.summary().as_text(), str)
+    assert isinstance(res.stl, STL)
+    assert isinstance(res.result, DecomposeResult)
+    assert isinstance(res.model, ARIMA)
+    assert hasattr(res.model_result, "forecast")
+
+
+MODELS = [
+    (ARIMA, {"order": (2, 0, 0), "trend": "c"}),
+    (ExponentialSmoothing, {"trend": True}),
+    (AutoReg, {"lags": 2, "old_names": False}),
+    (ETSModel, {}),
+]
+IDS = [str(c[0]).split(".")[-1][:-2] for c in MODELS]
+
+
+@pytest.mark.parametrize("config", MODELS, ids=IDS)
+@pytest.mark.parametrize("horizon", [1, 7, 23])
+def test_equivalence_forecast(data, config, horizon):
+    model, kwargs = config
+
+    stl = STL(data)
+    stl_fit = stl.fit()
+    resids = data - stl_fit.seasonal
+    mod = model(resids, **kwargs)
+    res = mod.fit()
+    stlf = STLForecast(data, model, model_kwargs=kwargs).fit()
+
+    seasonal = np.asarray(stl_fit.seasonal)[-12:]
+    seasonal = np.tile(seasonal, 1 + horizon // 12)
+    fcast = res.forecast(horizon) + seasonal[:horizon]
+    actual = stlf.forecast(horizon)
+    assert_allclose(actual, fcast, rtol=1e-4)
+    if not hasattr(res, "get_prediction"):
+        return
+    pred = stlf.get_prediction(data.shape[0], data.shape[0] + horizon - 1)
+    assert isinstance(pred, PredictionResults)
+    assert_allclose(pred.predicted_mean, fcast)
+
+    half = data.shape[0] // 2
+    stlf.get_prediction(half, data.shape[0] + horizon - 1)
+    stlf.get_prediction(half, data.shape[0] + horizon - 1, dynamic=True)
+    stlf.get_prediction(half, data.shape[0] + horizon - 1, dynamic=half // 2)
+    if hasattr(data, "index"):
+        loc = data.index[half + half // 2]
+        a = stlf.get_prediction(
+            half, data.shape[0] + horizon - 1, dynamic=loc.strftime("%Y-%m-%d")
+        )
+        b = stlf.get_prediction(
+            half, data.shape[0] + horizon - 1, dynamic=loc.to_pydatetime()
+        )
+        c = stlf.get_prediction(half, data.shape[0] + horizon - 1, dynamic=loc)
+        assert_allclose(a.predicted_mean, b.predicted_mean)
+        assert_allclose(a.predicted_mean, c.predicted_mean)
+
+
+def test_exceptions(data):
+    class BadModel:
+        def __init__(self, *args, **kwargs):
+            pass
+
+    with pytest.raises(AttributeError, match="model must expose"):
+        STLForecast(data, BadModel)
+
+    class NoForecast(BadModel):
+        def fit(self, *args, **kwargs):
+            return BadModel()
+
+    with pytest.raises(AttributeError, match="The model's result"):
+        STLForecast(data, NoForecast).fit()
+
+    class BadResult:
+        def forecast(self, *args, **kwargs):
+            pass
+
+    class FakeModel(BadModel):
+        def fit(self, *args, **kwargs):
+            return BadResult()
+
+    with pytest.raises(AttributeError, match="The model result does not"):
+        STLForecast(data, FakeModel).fit().summary()
+
+    class BadResultSummary(BadResult):
+        def summary(self, *args, **kwargs):
+            return object()
+
+    class FakeModelSummary(BadModel):
+        def fit(self, *args, **kwargs):
+            return BadResultSummary()
+
+    with pytest.raises(TypeError, match="The model result's summary"):
+        STLForecast(data, FakeModelSummary).fit().summary()