Decomposition, ACF and stationarity

statsmodels · Practical · 13 min

Every forecasting model choice starts from understanding trend, seasonality and autocorrelation.

statsmodels' time series tools live in statsmodels.tsa. Give them a pandas Series with a DatetimeIndex and a frequency (series.asfreq("MS")) so dates carry through to results and forecasts.

Decomposition splits a series into trend, seasonal and residual parts. STL(series, period=12).fit() (from statsmodels.tsa.seasonal) is robust and flexible; the older seasonal_decompose uses moving averages. Plot the result with .plot().

Autocorrelation: acf(x, nlags) and pacf(x, nlags) (in statsmodels.tsa.stattools), or plot_acf / plot_pacf from statsmodels.graphics.tsaplots. The ACF shows correlation with each lag; the PACF shows the correlation at a lag after removing shorter lags. Slowly decaying ACF suggests a trend; spikes at lag 12 in monthly data suggest yearly seasonality.

Stationarity: many models assume the series' mean and variance don't drift. The ADF test adfuller(x, result_object=True) (read .pvalue) has the null "a unit root (non-stationary)"; a small p-value suggests stationarity. KPSS kpss(x) has the opposite null, so using both is informative. Differencing (x.diff()) or seasonal differencing (x.diff(12)) usually makes a series stationary; logs stabilise growing variance.

Never shuffle time series for validation: hold out the last part of the series.

Example

import numpy as np
import pandas as pd
from statsmodels.tsa.seasonal import STL
from statsmodels.tsa.stattools import acf, adfuller

rng = np.random.default_rng(9)
idx = pd.date_range("2015-01-01", periods=96, freq="MS")
t = np.arange(96)
y = pd.Series(100 + 0.8 * t + 10 * np.sin(2 * np.pi * t / 12) + rng.normal(0, 2, 96), index=idx)

stl = STL(y, period=12).fit()
print(stl.seasonal.iloc[:3].round(1).tolist())    # [-0.5, 7.6, 7.8] (seasonal pattern)

print(round(adfuller(y, result_object=True).pvalue, 3))   # 0.99 (trend: not stationary)
print(round(adfuller(y.diff(12).dropna(), result_object=True).pvalue, 3))  # 0.0

print(acf(y.diff().dropna(), nlags=12)[[1, 6, 12]].round(2))   # [ 0.3 -0.6  0.6]

Remember

  • Use a Series with a DatetimeIndex and frequency
  • STL separates trend, seasonality and remainder
  • ADF null = non-stationary; difference to remove trend and seasonality

Watch out for

  • Reading a high ADF p-value as proof of stationarity; the null is non-stationarity.

Check yourself

  1. adfuller returns a p-value of 0.70 for your series. What does it suggest?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.statsmodels.org/stable/tsa.html