State space models and the Kalman filter

statsmodels · Expert · 15 min

SARIMAX, ETS and unobserved components all run on one state space engine you can extend.

Much of statsmodels.tsa.statespace is one general model: an unobserved state evolves by a linear transition equation, and observations are noisy linear functions of it. The Kalman filter computes the likelihood and filtered states; the smoother gives the best estimate of each state using all data. SARIMAX, UnobservedComponents, DynamicFactor, VARMAX and ExponentialSmoothing in that module are all built on it.

Practical benefits:

  • Missing values are handled natively: the filter simply skips the update step, and you still get smoothed estimates for the gaps.
  • Interpretable components: UnobservedComponents(y, level="local linear trend", freq_seasonal=[{"period": 12}]) estimates trend and seasonality as states; res.smoothed_state or res.level.smoothed give them.
  • Diagnostics and forecasts with intervals come for free, and res.simulate draws scenarios.

To build a custom model, subclass sm.tsa.statespace.MLEModel: declare k_states, fill system matrices (design, transition, selection, state_cov, obs_cov) in __init__, define start_params and param_names, and write update(params) to put parameters into the matrices. Optional transform_params keeps variances positive during optimisation. Then fit(), smooth() and get_forecast() work as for built-in models.

Large or long models can be slow; options such as concentrate_scale=True and good starting parameters help convergence.

Example

import numpy as np
import pandas as pd
import statsmodels.api as sm

rng = np.random.default_rng(13)
idx = pd.date_range("2018-01-01", periods=72, freq="MS")
level = 100 + np.cumsum(rng.normal(0.5, 1.0, 72))            # slowly drifting level
y = pd.Series(level + 8 * np.sin(2 * np.pi * np.arange(72) / 12) + rng.normal(0, 1.5, 72), index=idx)
y.iloc[30:34] = np.nan                                      # a gap in the data

mod = sm.tsa.UnobservedComponents(y, level="local linear trend",
                                  freq_seasonal=[{"period": 12, "harmonics": 2}])
res = mod.fit(disp=False)

smoothed_level = res.level.smoothed
print(np.isnan(smoothed_level).sum())         # 0 (gap filled by the smoother)
print(round(np.abs(smoothed_level - level).mean(), 2))     # 0.77 (close to the true level)
print(res.get_forecast(6).predicted_mean.round(1).iloc[:2].tolist())  # [147.9, 151.4]

Remember

  • SARIMAX, UnobservedComponents, VARMAX and more share one Kalman filter engine
  • Missing observations are handled natively by the filter and smoother
  • Subclass MLEModel to build custom state space models

Watch out for

  • Interpolating gaps before fitting a state space model, which understates uncertainty.

Check yourself

  1. How does the Kalman filter treat a missing observation?

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

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

Builds on

Unlocks

Nothing builds on this yet.