← Time Series & State Space

Structural time series: writing trend plus season as a state space model

How to describe a series as trend + seasonal + noise, write each part as a small linear state space model, and glue the parts together with block-diagonal matrices.

First explored September 2020·5 min read

University lab From my coursework in the MSc in Statistics and Machine Learning at Linköping University, rewritten as a note to review later.

time-seriesstate-space-modelsseasonalitytrendstructural-models

With an AR model I had to remove the trend by hand before fitting anything. A structural time series model goes the other way: it models the trend and the seasonal pattern directly, as hidden components that can drift slowly over time. The trick that makes this practical is writing everything as one linear Gaussian state space model. After that, the Kalman filter and smoother do all the work. I built one of these in a course lab for the sea-level data.

The decomposition

The idea is to say the observation is a sum of interpretable parts:

yt=μt+γt+εt,y_t = \mu_t + \gamma_t + \varepsilon_t,

where μt\mu_t is the trend (the slow level), γt\gamma_t is the seasonal component (a pattern that repeats every ss steps), and εt∼N(0,σε2)\varepsilon_t \sim N(0, \sigma_\varepsilon^2) is observation noise. None of μt\mu_t or γt\gamma_t is observed. We only see their noisy sum.

The state space form

A linear Gaussian state space model has two equations:

αt+1=Tαt+Rηt,ηt∼N(0,Q),\alpha_{t+1} = T\alpha_t + R\eta_t, \qquad \eta_t \sim N(0, Q), yt=Zαt+εt,εt∼N(0,σε2).y_t = Z\alpha_t + \varepsilon_t, \qquad \varepsilon_t \sim N(0, \sigma_\varepsilon^2).
  • αt\alpha_t is the hidden state, a vector of dimension dd.
  • TT (d×dd \times d) moves the state one step forward.
  • ηt\eta_t is the state noise, of dimension dηd_\eta, with covariance QQ (dη×dηd_\eta \times d_\eta).
  • RR (d×dηd \times d_\eta) decides which states the noise enters. Often dη<dd_\eta < d, because some states are just copies of old values and get no noise.
  • ZZ (1×d1 \times d) picks out the combination of states we observe.

The job is to find a TT, RR, ZZ for each component.

Trend component

A simple smooth trend says the trend’s second difference is small noise:

μt+1=2μt−μt−1+ηtμ.\mu_{t+1} = 2\mu_t - \mu_{t-1} + \eta^{\mu}_t.

Without noise, this is a straight line: μt+1−μt=μt−μt−1\mu_{t+1} - \mu_t = \mu_t - \mu_{t-1}, so the slope stays the same. The noise lets the slope wander slowly. To make it first-order, keep two values in the state, αtμ=(μt,μt−1)⊤\alpha^{\mu}_t = (\mu_t, \mu_{t-1})^\top:

Tμ=[2−110],Rμ=[10],Zμ=[10].T_\mu = \begin{bmatrix} 2 & -1 \\ 1 & 0 \end{bmatrix}, \quad R_\mu = \begin{bmatrix} 1 \\ 0 \end{bmatrix}, \quad Z_\mu = \begin{bmatrix} 1 & 0 \end{bmatrix}.

The second row of TμT_\mu just copies μt\mu_t down so it’s available as “the previous value” next time. The variance σμ2\sigma_\mu^2 of ηtμ\eta^\mu_t controls how bendy the trend is.

Seasonal component

The seasonal effects over one full period should add up to roughly zero (otherwise part of the season is really trend). So:

γt+1=−∑j=0s−2γt−j+ηtγ.\gamma_{t+1} = -\sum_{j=0}^{s-2} \gamma_{t-j} + \eta^{\gamma}_t.

If the noise is zero, the ss most recent seasonal values sum to exactly zero, and the pattern repeats every ss steps. The state holds the last s−1s-1 values, (γt,γt−1,…,γt−s+2)⊤(\gamma_t, \gamma_{t-1}, \dots, \gamma_{t-s+2})^\top, so its dimension is s−1s-1. The matrices are

Tγ=[−1−1⋯−1−110⋯0001⋯00⋮⋱⋮00⋯10],Rγ=[10⋮0],Zγ=[10⋯0].T_\gamma = \begin{bmatrix} -1 & -1 & \cdots & -1 & -1 \\ 1 & 0 & \cdots & 0 & 0 \\ 0 & 1 & \cdots & 0 & 0 \\ \vdots & & \ddots & & \vdots \\ 0 & 0 & \cdots & 1 & 0 \end{bmatrix}, \quad R_\gamma = \begin{bmatrix} 1 \\ 0 \\ \vdots \\ 0 \end{bmatrix}, \quad Z_\gamma = \begin{bmatrix} 1 & 0 & \cdots & 0 \end{bmatrix}.

Same pattern as before: the first row computes the new value, the rest shift old values down.

Choosing the period ss

The sea-level data has one observation about every 10 days, so ss should be the number of observations per year. Here I made a small mistake in the lab. I counted observations per calendar year and averaged, but I included the last year, which only had 2 observations. That pulled the mean down to about 35.6, and I rounded to s=36s = 36. The complete years had 36 or 37 observations each, averaging about 36.85, so the right choice is s=37s = 37. The time step in the data (about 0.0271 years) gives the same answer: 1/0.0271≈36.81/0.0271 \approx 36.8. The lesson: when you count “per year”, leave out partial years at the ends.

Gluing components together

Independent components combine by stacking their states. Then TT and RR are block-diagonal, and the ZZ rows are put side by side:

T=[Tμ00Tγ],R=[Rμ00Rγ],Z=[ZμZγ],Q=[σμ200σγ2].T = \begin{bmatrix} T_\mu & 0 \\ 0 & T_\gamma \end{bmatrix}, \quad R = \begin{bmatrix} R_\mu & 0 \\ 0 & R_\gamma \end{bmatrix}, \quad Z = \begin{bmatrix} Z_\mu & Z_\gamma \end{bmatrix}, \quad Q = \begin{bmatrix} \sigma_\mu^2 & 0 \\ 0 & \sigma_\gamma^2 \end{bmatrix}.

So Zαt=μt+γtZ\alpha_t = \mu_t + \gamma_t, which is what we wanted. This modularity is the nice part: adding another component (say, a stationary AR term to soak up short-term wiggles) is just one more block.

import numpy as np
from scipy.linalg import block_diag

def trend_block():
    return np.array([[2., -1.], [1., 0.]]), np.array([[1.], [0.]]), np.array([[1., 0.]])

def seasonal_block(s):
    T = np.zeros((s - 1, s - 1))
    T[0, :] = -1.0                       # new value = minus sum of the last s-1
    T[1:, :-1] = np.eye(s - 2)           # shift old values down
    R = np.zeros((s - 1, 1)); R[0, 0] = 1.0
    return T, R, R.T.copy()

T_mu, R_mu, Z_mu = trend_block()
T_g, R_g, Z_g = seasonal_block(s=37)
T = block_diag(T_mu, T_g)                # (38, 38)
R = block_diag(R_mu, R_g)                # (38, 2)
Z = np.hstack([Z_mu, Z_g])               # (1, 38)

The initial state

The model also needs a starting distribution α1∼N(a1,P1)\alpha_1 \sim N(a_1, P_1). A sensible choice is to start the trend at the first observation with zero slope (so μ1=μ0=y1\mu_1 = \mu_0 = y_1 in the mean), set the seasonal states to zero, and use a large diagonal P1P_1 to say “I’m not sure”. The filter quickly overrides a vague start.

Key takeaways

  • A structural model writes yty_t as trend + season + noise, each a hidden component that can drift.
  • Each component is a tiny state space model; the “copy old values down” rows turn higher-order recursions into first-order ones.
  • A seasonal component of period ss needs s−1s-1 states, because the last value is fixed by the sum-to-zero rule.
  • Components combine with block-diagonal TT, RR, QQ and a concatenated ZZ.
  • Count the period from complete cycles only.

Resources

  • 732A80 Time Series and Sequence Learning, Linköping University (course lab 2, autumn 2020).
  • Durbin & Koopman, Time Series Analysis by State Space Methods, 2nd ed., Oxford University Press, 2012, Chapter 3.
  • Kitagawa, Introduction to Time Series Modeling, CRC Press, 2010, Chapters 11–12 (trend and seasonal models).
  • NASA Global Mean Sea Level data. https://climate.nasa.gov/vital-signs/sea-level/
  • Next steps: the Kalman filter and the Kalman smoother.