Source code for statsmodels.tsa.seasonal._seasonal

"""Seasonal Decomposition by Moving Averages"""

import numpy as np
import pandas as pd
from pandas.core.nanops import nanmean as pd_nanmean

from statsmodels.tools.validation import PandasWrapper, array_like
from statsmodels.tsa.filters.filtertools import convolution_filter
from statsmodels.tsa.tsatools import freq_to_period

__all__ = [
    "DecomposeResult",
    "seasonal_decompose",
    "seasonal_mean",
]


def _extrapolate_trend(trend, npoints):
    """
    Replace nan values on trend's end-points with least-squares extrapolated
    values with regression considering npoints closest defined points.

    Parameters
    ----------
    trend : ndarray
        The trend series, which may contain NaN values at the start and/or
        end that will be replaced.
    npoints : int
        The number of closest defined points on each end to use in the
        extrapolating least-squares regression.

    Returns
    -------
    ndarray
        The trend array with NaN values at the endpoints replaced by
        extrapolated values.
    """
    front = next(i for i, vals in enumerate(trend) if not np.any(np.isnan(vals)))
    back = (
        trend.shape[0]
        - 1
        - next(i for i, vals in enumerate(trend[::-1]) if not np.any(np.isnan(vals)))
    )
    front_last = min(front + npoints, back)
    back_first = max(front, back - npoints)

    k, n = np.linalg.lstsq(
        np.c_[np.arange(front, front_last), np.ones(front_last - front)],
        trend[front:front_last],
        rcond=-1,
    )[0]
    extra = (np.arange(0, front) * np.c_[k] + np.c_[n]).T
    if trend.ndim == 1:
        extra = extra.squeeze()
    trend[:front] = extra

    k, n = np.linalg.lstsq(
        np.c_[np.arange(back_first, back), np.ones(back - back_first)],
        trend[back_first:back],
        rcond=-1,
    )[0]
    extra = (np.arange(back + 1, trend.shape[0]) * np.c_[k] + np.c_[n]).T
    if trend.ndim == 1:
        extra = extra.squeeze()
    trend[back + 1 :] = extra

    return trend


def seasonal_mean(x, period):
    """
    Return means for each period in x

    Parameters
    ----------
    x : array_like
        Time series values.
    period : int
        The number of periods per cycle, e.g., 12 for monthly data.

    Returns
    -------
    ndarray
        The mean value for each period, ignoring NaNs.
    """
    return np.array([pd_nanmean(x[i::period], axis=0) for i in range(period)])


[docs] def seasonal_decompose( x, model="additive", filt=None, period=None, two_sided=True, extrapolate_trend=0, ): """ Seasonal decomposition using moving averages. Parameters ---------- x : array_like Time series. If 2d, individual series are in columns. x must contain 2 complete cycles. model : {"additive", "multiplicative"}, optional Type of seasonal component. Abbreviations are accepted. filt : array_like, optional The filter coefficients for filtering out the seasonal component. The concrete moving average method used in filtering is determined by two_sided. period : int, optional Period of the series (eg, 1 for annual, 4 for quarterly, etc). Must be used if x is not a pandas object or if the index of x does not have a frequency. Overrides default periodicity of x if x is a pandas object with a timeseries index. two_sided : bool, optional The moving average method used in filtering. If True (default), a centered moving average is computed using the filt. If False, the filter coefficients are for past values only. extrapolate_trend : int or 'freq', optional If set to > 0, the trend resulting from the convolution is linear least-squares extrapolated on both ends (or the single one if two_sided is False) considering this many (+1) closest points. If set to 'freq', use `freq` closest points. Setting this parameter results in no NaN values in trend or resid components. Returns ------- DecomposeResult An object with seasonal, trend, and resid attributes. See Also -------- statsmodels.tsa.filters.bk_filter.bkfilter Baxter-King filter. statsmodels.tsa.filters.cf_filter.cffilter Christiano-Fitzgerald asymmetric, random walk filter. statsmodels.tsa.filters.hp_filter.hpfilter Hodrick-Prescott filter. statsmodels.tsa.filters.convolution_filter Linear filtering via convolution. statsmodels.tsa.seasonal.STL Season-Trend decomposition using LOESS. Notes ----- This is a naive decomposition. More sophisticated methods should be preferred. The additive model is Y[t] = T[t] + S[t] + e[t] The multiplicative model is Y[t] = T[t] * S[t] * e[t] The results are obtained by first estimating the trend by applying a convolution filter to the data. The trend is then removed from the series and the average of this de-trended series for each period is the returned seasonal component. """ pfreq = period pw = PandasWrapper(x) if period is None: if isinstance(x, (pd.Series, pd.DataFrame)): index = x.index if isinstance(index, pd.PeriodIndex): pfreq = index.freq else: pfreq = getattr(index, "freq", None) or getattr( index, "inferred_freq", None ) x = array_like(x, "x", maxdim=2) nobs = len(x) if not np.all(np.isfinite(x)): raise ValueError("This function does not handle missing values") if model.startswith("m"): if np.any(x <= 0): raise ValueError( "Multiplicative seasonality is not appropriate " "for zero and negative values" ) if period is None: if pfreq is not None: pfreq = freq_to_period(pfreq) period = pfreq else: raise ValueError( "You must specify a period or x must be a pandas object with " "a PeriodIndex or a DatetimeIndex with a freq not set to None" ) if x.shape[0] < 2 * pfreq: raise ValueError( f"x must have 2 complete cycles requires {2 * pfreq} " f"observations. x only has {x.shape[0]} observation(s)" ) if filt is None: if period % 2 == 0: # split weights at ends filt = np.array([0.5] + [1] * (period - 1) + [0.5]) / period else: filt = np.repeat(1.0 / period, period) nsides = int(two_sided) + 1 trend = convolution_filter(x, filt, nsides) if extrapolate_trend == "freq": extrapolate_trend = period - 1 if extrapolate_trend > 0: trend = _extrapolate_trend(trend, extrapolate_trend + 1) if model.startswith("m"): detrended = x / trend else: detrended = x - trend period_averages = seasonal_mean(detrended, period) if model.startswith("m"): period_averages /= np.mean(period_averages, axis=0) else: period_averages -= np.mean(period_averages, axis=0) seasonal = np.tile(period_averages.T, nobs // period + 1).T[:nobs] if model.startswith("m"): resid = x / seasonal / trend else: resid = detrended - seasonal results = [] for s, name in zip( (seasonal, trend, resid, x), ("seasonal", "trend", "resid", None), strict=True, ): results.append(pw.wrap(s.squeeze(), columns=name)) return DecomposeResult( seasonal=results[0], trend=results[1], resid=results[2], observed=results[3], )
[docs] class DecomposeResult: """ Results class for seasonal decompositions Parameters ---------- observed : array_like The data series that has been decomposed. seasonal : array_like The seasonal component of the data series. trend : array_like The trend component of the data series. resid : array_like The residual component of the data series. weights : array_like, optional The weights used to reduce outlier influence. """ def __init__(self, observed, seasonal, trend, resid, weights=None): self._seasonal = seasonal self._trend = trend if weights is None: weights = np.ones_like(observed) if isinstance(observed, pd.Series): weights = pd.Series(weights, index=observed.index, name="weights") self._weights = weights self._resid = resid self._observed = observed @property def observed(self): """Observed data""" return self._observed @property def seasonal(self): """The estimated seasonal component""" return self._seasonal @property def trend(self): """The estimated trend component""" return self._trend @property def resid(self): """The estimated residuals""" return self._resid @property def weights(self): """The weights used in the robust estimation""" return self._weights @property def nobs(self): """Number of observations""" return self._observed.shape
[docs] def plot( self, observed=True, seasonal=True, trend=True, resid=True, weights=False, ): """ Plot estimated components Parameters ---------- observed : bool Include the observed series in the plot seasonal : bool Include the seasonal component in the plot trend : bool Include the trend component in the plot resid : bool Include the residual in the plot weights : bool Include the weights in the plot (if any) Returns ------- matplotlib.figure.Figure The figure instance containing the plot. """ from pandas.plotting import register_matplotlib_converters from statsmodels.graphics.utils import _import_mpl plt = _import_mpl() register_matplotlib_converters() series = [(self._observed, "Observed")] if observed else [] series += [(self.trend, "trend")] if trend else [] if self.seasonal.ndim == 1: series += [(self.seasonal, "seasonal")] if seasonal else [] elif self.seasonal.ndim > 1: if isinstance(self.seasonal, pd.DataFrame): for col in self.seasonal.columns: series += [(self.seasonal[col], "seasonal")] if seasonal else [] else: for i in range(self.seasonal.shape[1]): series += [(self.seasonal[:, i], "seasonal")] if seasonal else [] series += [(self.resid, "residual")] if resid else [] series += [(self.weights, "weights")] if weights else [] if isinstance(self._observed, (pd.DataFrame, pd.Series)): nobs = self._observed.shape[0] xlim = self._observed.index[0], self._observed.index[nobs - 1] else: xlim = (0, self._observed.shape[0] - 1) fig, axs = plt.subplots(len(series), 1, sharex=True) for i, (ax, (series, def_name)) in enumerate(zip(axs, series, strict=True)): # noqa: B020 if def_name != "residual": ax.plot(series) else: ax.plot(series, marker="o", linestyle="none") ax.plot(xlim, (0, 0), color="#000000", zorder=-3) name = getattr(series, "name", def_name) if def_name != "Observed": name = name.capitalize() title = ax.set_title if i == 0 and observed else ax.set_ylabel title(name) ax.set_xlim(xlim) fig.tight_layout() return fig