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.
University lab From my coursework in the MSc in Statistics and Machine Learning at Linköping University, rewritten as a note to review later.
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:
where is the trend (the slow level), is the seasonal component (a pattern that repeats every steps), and is observation noise. None of or is observed. We only see their noisy sum.
The state space form
A linear Gaussian state space model has two equations:
- is the hidden state, a vector of dimension .
- () moves the state one step forward.
- is the state noise, of dimension , with covariance ().
- () decides which states the noise enters. Often , because some states are just copies of old values and get no noise.
- () picks out the combination of states we observe.
The job is to find a , , for each component.
Trend component
A simple smooth trend says the trend’s second difference is small noise:
Without noise, this is a straight line: , so the slope stays the same. The noise lets the slope wander slowly. To make it first-order, keep two values in the state, :
The second row of just copies down so it’s available as “the previous value” next time. The variance of 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:
If the noise is zero, the most recent seasonal values sum to exactly zero, and the pattern repeats every steps. The state holds the last values, , so its dimension is . The matrices are
Same pattern as before: the first row computes the new value, the rest shift old values down.
Choosing the period
The sea-level data has one observation about every 10 days, so 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 . The complete years had 36 or 37 observations each, averaging about 36.85, so the right choice is . The time step in the data (about 0.0271 years) gives the same answer: . 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 and are block-diagonal, and the rows are put side by side:
So , 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 . A sensible choice is to start the trend at the first observation with zero slope (so in the mean), set the seasonal states to zero, and use a large diagonal to say “I’m not sure”. The filter quickly overrides a vague start.
Key takeaways
- A structural model writes 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 needs states, because the last value is fixed by the sum-to-zero rule.
- Components combine with block-diagonal , , and a concatenated .
- 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.