Structural time-series models (unobserved components) & TVP regression¶
unobserved_components · tvp_regression
Harvey's structural time-series models and the random-walk-coefficient
regression, both on the crate's exact-diffuse Kalman filter and smoother
(tsecon-ssm, the engine that also carries local_level_smooth,
ar_loglik and the ARIMA exact likelihood). The two callables share one
maximum-likelihood layer — the square-root / logistic working space of
statsmodels' UnobservedComponents, BFGS + Nelder-Mead from a deterministic
ladder of starts, scale-adaptive estimation, observed-information standard
errors, and the pile-up check that reports a variance the likelihood cannot
tell from zero as a boundary flag rather than as a tiny number. The
validation story is the same for both: every fixed-parameter quantity is
pinned to statsmodels at 1e-8, the optimum to the better of two independent
optimizers, and the statistical claims are measured by seeded Monte Carlo.
unobserved_components — Harvey's structural models by exact-diffuse MLE¶
What it estimates. The decomposition
with the blocks assembled exactly as statsmodels' UnobservedComponents
enumerates them (Harvey 1989, ch. 2; Durbin & Koopman 2012, ch. 3):
- level / trend
mu_t, chosen bylevel=in statsmodels' vocabulary —"llevel"(random-walk level + irregular),"lltrend"(random-walk level and slope),"strend"(integrated random walk: smooth trend),"rwdrift","dtrend"(deterministic line + noise),"lldtrend","rwalk","rtrend","dconstant","ntrend"(white noise only), and the noise-free"fixed intercept"/"fixed slope"(which need another stochastic component); the long names parse too; - dummy seasonal
gamma_tof periods(seasonal=s,s-1states): the dummies sum to a disturbance of variancesigma2.seasonal, or exactly to zero whenstochastic_seasonal=False; - trigonometric seasonal (
freq_seasonal=[p, ...]): for each periodpandhharmonics (defaultfloor(p/2)),hrotation pairs at frequencies2 pi j / pdriven by two disturbances of a common variancesigma2.freq_seasonal_p(h)(or deterministic); - stochastic cycle
c_t(cycle=True): the rotation by the cycle frequencylambda— a free parameter confined to(2 pi/max, 2 pi/min)bycycle_period_bounds=[min, max], default[2, len(y)]— scaled by a dampingrho in (0, 1)whendamped_cycle=True, with a common disturbance variance whenstochastic_cycle=True; - regressors
exog(T x k) with time-invariant coefficientsbeta.x1, ...estimated jointly by MLE (statsmodelsmle_regression=True).
Every state is initialized exactly diffuse (Koopman 1997), the cycle
included, which is statsmodels' own convention under
use_exact_diffuse=True — so the log-likelihoods, AIC/BIC (its
k_params + k_diffuse degrees of freedom) and every state moment agree with
it to the digit. NaN in y is a missing period (the filter skips the
update, the smoother bridges the gap and the variance widens there);
regressors must be finite.
Assumptions. Gaussian disturbances, time-invariant variances, a
linear-Gaussian state space — no regime switching, no stochastic
volatility, no outlier robustness (see dcs_local_level for a level that
discounts outliers). The trigonometric seasonal assumes a fixed period; the
cycle a single frequency. Regression coefficients are constant (for drifting
coefficients use tvp_regression).
When to use (and when not). Use it when the question is what are the
components — trend extraction with honest uncertainty, a seasonal that is
allowed to evolve, a business-cycle component with an estimated period and
damping, an intervention effect measured against a structural background
(the seat-belt example below), or a forecast that carries the state
uncertainty through the horizon. It is the model-based alternative to
hp_filter/stl: the components come with variances, missing data costs
nothing, and the signal-to-noise ratios are estimated rather than fixed
by a smoothing constant. Do not use it for pure short-horizon point
forecasting of a stationary series (arima_fit/auto_arima are cheaper and
the ARIMA reduced form is equivalent), for series shorter than roughly three
times the state dimension (the diffuse period eats k_states
observations), or when you need the components to be robust to additive
outliers.
Key arguments and defaults (and why). level="llevel" because the
local level is the "hello world" of the family and the safe default for a
noisy level; add "lltrend" when the series drifts, "strend" when you
want a smooth trend (the HP-filter-like case). stochastic_seasonal=True,
stochastic_cycle=False, damped_cycle=False follow statsmodels so a
spec written for it fits here unchanged. cycle_period_bounds defaults
to [2, len(y)], and this is a deliberate divergence from statsmodels,
which leaves the upper period at infinity when the series carries no
frequency information. Under exact-diffuse initialization the
log-likelihood of a stochastic cycle diverges as the frequency goes to
zero: at lambda = 0 the second cycle state is unobservable and stays
diffuse for free, but at a small lambda > 0 it is weakly observable
through rho sin lambda, so its diffuse direction resolves with
F_inf ~ (rho sin lambda)^2 and contributes -(ln 2 pi + ln F_inf)/2,
which grows without bound. Measured in
crates/tsecon-ssm/tests/uc_properties.rs: on a seeded local-level series
the same model at lambda = 1e-2, 1e-4, 1e-6 scores −188.15, −183.55,
−162.53 — 25 log-likelihood points of pure singularity, and a free
optimizer walks straight into it and reports a "cycle" of period 10^6.
statsmodels does not meet the singularity because its diffuse tolerance is
an absolute 1e-10 on F_inf, which clips it. A cycle longer than the
sample is not identified by the sample in any case, so the sample length is
the honest bound; pass the plausible period range (e.g. [6, 32] quarters
for a business cycle) to identify the cycle properly and keep the optimizer
off the seasonal frequencies. n_starts=3 runs the start ladder (the
heuristic start, then the state variances scaled by 0.1 and 10; for a
cycle, the periodogram peak first, then the bound midpoint and quarter
points) — the cycle likelihood is multimodal in the frequency, and the
periodogram start is what finds the right mode. fixed_params= evaluates
the model at given
parameters (statsmodels' order, listed in param_names) — the
fixed-parameter goldens, and the way to run a Durbin-Koopman example at the
book's values. Options that act only under a component raise when
passed without it (stochastic_seasonal without seasonal, the
freq_seasonal_* companions without freq_seasonal, damped_cycle /
stochastic_cycle / cycle_period_bounds without cycle=True,
forecast_exog without exog or forecast_steps).
How to read the output. params/se/param_names are the MLE with
observed-information standard errors — conditional on the flagged
parameters sitting exactly at their boundary, i.e. the information matrix
is inverted over the free parameters only. statsmodels' cov_type="approx"
inverts the full matrix instead; the two coincide exactly when nothing is
flagged and differ by definition when something is (on the seat-belt model
below its full Hessian is indefinite — its own bse for sigma2.trend
comes back NaN — and its sigma2.level standard error is 26 times smaller
than the conditional one). at_boundary[i] is the pile-up flag
— setting that variance to exactly zero (everything else at the estimate)
lowers the log-likelihood by less than 1e-4, so the data cannot tell the
estimate from zero, and its se is NaN because a boundary has no
curvature (Shephard & Harvey 1990). The component keys without a prefix
(level, slope, seasonal, freq_seasonal[i], cycle, each with
_var) are the smoothed two-sided estimates — what you want for
historical decomposition; filtered_* are the real-time one-sided ones.
smoothed_state/filtered_state hold the full state (state_names).
fitted is the one-step-ahead prediction of y_t, resid the prediction
error, std_resid its standardized version — NaN inside the diffuse
period (nobs_diffuse periods, where no finite prediction variance exists)
and at missing periods; use it for the usual Ljung-Box / normality checks.
forecast/forecast_var are the forecast_steps out-of-sample means and
variances (state uncertainty plus the irregular); 1.96 * sqrt(forecast_var)
is the nominal 95% band. converged is the optimizer's certificate, not a
fit grade: read at_boundary first.
Failure modes. Pile-up is the family's signature: with a true small
variance the MLE lands on exactly zero with substantial probability, and a
short series will do it to the slope or seasonal variance routinely (the
seat-belt example below does it to both) — the flag tells you, and the
right response is to accept the deterministic component, not to read the
tiny estimate. Unidentified combinations (a dummy seasonal and a
trigonometric seasonal of the same period; a cycle whose period range
overlaps a seasonal frequency) give flat likelihood directions, a
non-converged flag or NaN standard errors. A constant series is refused
(the likelihood is unbounded as the variances go to zero), as is a
specification with no stochastic component at all, and a sample with fewer
than k_states + 2 observed values. Counts that size an allocation are
bounded so that a mistyped integer is a teaching error and not an allocator
abort: seasonal and each freq_seasonal period at most len(y),
forecast_steps at most 100000, n_starts at most 64. A forecast whose variance is still
diffuse — infinite — at the forecast origin is refused rather than
returned; the sample-size check makes that hard to reach, and it is a
guard, not a routine outcome.
Validated against. statsmodels UnobservedComponents(...,
use_exact_diffuse=True) — an independent package — at fixed parameters:
loglike, filtered and smoothed states and their variances, one-step
predictions and residuals, standardized residuals (after the diffuse
period), 8-step forecasts and forecast variances, AIC/BIC and the component
paths, for 26 component combinations (every level/trend specification,
dummy seasonals stochastic and deterministic, trigonometric seasonals with
one, two and three harmonics and with two blocks, undamped/damped and
deterministic/stochastic cycles, regressors, and the combinations of them)
on a seeded simulated series and on three NaN-inserted variants, all at
1e-8 relative (fixtures/uc.json,
crates/tsecon-ssm/tests/uc_golden.rs) — with one measured exception,
stated rather than papered over. The exact-diffuse smoother is the
ill-conditioned part of this family, and only inside the diffuse period:
on the eight-diffuse-state combination (smooth trend + two harmonics +
damped stochastic cycle + two regressors) statsmodels' own univariate and
conventional smoothers disagree with each other by up to 4.5e-3
relative on the smoothed state variances there, while their filters agree
to 2.9e-11. The fixture records that internal spread per case
(smoother_spread, computed by the generator) and the tests use it as the
tolerance for smoothed variances over the diffuse period and the two rows
after it: tsecon must be at least as close to the reference as the
reference is to itself. On 19 of the 26 combinations the two reference
paths agree bit for bit and the tolerance is exactly 1e-8; the largest
tsecon-vs-statsmodels gap anywhere in that window is 8.8e-4 on the same
eight-state case (against its 4.5e-3 reference spread), and every smoothed
variance from nobs_diffuse + 3 onwards holds 1e-8. Filtered variances,
means, residuals, forecasts, log-likelihood and AIC/BIC are at 1e-8
throughout. The MLE is pinned to the better
of two optimizers — statsmodels' own fit and a SciPy Nelder-Mead +
L-BFGS-B re-optimization of the identical criterion — on six cases: the
Rust optimum is never below the better reference by more than 1e-5, and at
a shared interior optimum the parameters agree to 2e-3, the pile-up flags
agree exactly with an independent implementation of the same documented
criterion in the fixture generator, and the standard errors agree to 2e-2
with statsmodels' own complex-step Hessian inverted over the non-boundary
parameters (se_conditional in the fixture; the full-Hessian
cov_type="approx" numbers are recorded beside it so the difference stays
visible). On the bounded cycle case the two reference optimizers land
0.0031 log-likelihood apart (statsmodels −232.2477, SciPy −232.2446) and
the Rust fit reaches the better of them. The Nile
local level reproduces Durbin & Koopman (2012, §2.2) as printed —
15098.5 / 1469.18 against their 15099 / 1469.1. The Harvey-Durbin (1986)
basic structural model on the UK Seatbelts data (log(drivers) on a
local linear trend, a stochastic monthly dummy seasonal, log petrol price,
log kms and the January-1983 law dummy) — fetched from Rdatasets by the
fixture generator and again by the Python test, never redistributed (R's
datasets is GPL), so the fixture holds only the derived optimum — is
re-estimated from scratch and reaches the statsmodels/SciPy optimum: the
slope and seasonal variances pile up at zero (statsmodels' optimum has them
at 3e-21 and 4e-19; both flagged, NaN standard errors) and the law
coefficient is −0.240 (se 0.047) — a 21% reduction in drivers killed or
seriously injured — with the fixed-parameter evaluation at that optimum
matching a live statsmodels run at 1e-8; no published number is asserted,
only what the committed generator reproduces. Seeded Monte Carlo
(crates/tsecon-ssm/tests/uc_properties.rs, ten tests, every number below
reproducible at the seeds in that file): local-level variance recovery at
T = 1200 over 12 seeds — median |relative error| 0.036 on
sigma2.irregular and 0.096 on sigma2.level, mean relative bias
−0.018 and +0.015, and no spurious boundary flag in any of the 12;
95% forecast-interval coverage on a local-linear-trend DGP (T = 150, 12
steps ahead, 80 seeds) — [0.925, 0.963, 0.975, 0.975, 0.938, 0.950,
0.963, 0.963, 0.950, 0.938, 0.925, 0.938] at horizons 1…12, pooled
0.950 against a nominal 0.95 (a Monte-Carlo standard error of 0.024 per
horizon at 80 replications, so these are all within one standard error);
scale equivariance of the whole fit under y -> 1000 y — estimates
2.9e-10, log-likelihood 2.2e-15 (against the exact-diffuse shift
-(n - d) ln c, not -n ln c), level and forecast paths 3.1e-11,
standardized residuals 3.3e-10, and 2.3e-8 on the observed-information
standard errors, which are a numerical Hessian and amplify the last ulp of
the standardization scale; the deterministic dummy seasonal is exactly
4-periodic and sums to zero over every period (8.9e-16 and 5.6e-16 against
an amplitude of 1.4); a frequency-π (Nyquist) harmonic state cannot move
the log-likelihood (difference exactly 0.0 against a hand-built model with
the state removed); the cycle log-likelihood's divergence as the frequency
goes to zero is measured rather than asserted (−188.15, −183.55, −162.53 at
λ = 1e-2, 1e-4, 1e-6) and the default period bound is shown to keep the fit
out of it; the cycle frequency lands inside its bounds and recovers a
period-8 cycle within 1.5 periods. Base-R StructTS was not used as a second reference:
it fits by a different (non-diffuse, optim) likelihood and would only
have added a lower-graded number.
References. Harvey, A. C. (1989), Forecasting, Structural Time Series Models and the Kalman Filter, CUP; Harvey & Durbin (1986), "The Effects of Seat Belt Legislation on British Road Casualties", JRSS A 149; Durbin & Koopman (2012), Time Series Analysis by State Space Methods, 2nd ed., OUP (ch. 2–3 and the exact-diffuse initialization of ch. 5); Koopman (1997), "Exact Initial Kalman Filtering and Smoothing for Nonstationary Time Series Models", JASA 92; Shephard & Harvey (1990), "On the Probability of Estimating a Deterministic Component in the Local Level Model", J. Time Series Analysis 11.
import numpy as np, statsmodels.api as sm, tsecon
nile = sm.datasets.nile.load_pandas().data["volume"].values.astype(float)
fit = tsecon.unobserved_components(nile, forecast_steps=5)
print({k: round(float(v), 1) for k, v in zip(fit["param_names"], fit["params"])},
fit["at_boundary"])
# {'sigma2.irregular': 15098.5, 'sigma2.level': 1469.2} [False, False]
band = 1.96 * np.sqrt(fit["level_var"]) # smoothed level ± band
print(np.round(fit["forecast"], 1), np.round(np.sqrt(fit["forecast_var"]), 1))
tvp_regression — random-walk coefficients with the pile-up check¶
What it estimates. The time-varying-parameter regression
y_t = x_t' beta_t + eps_t, beta_{t+1} = beta_t + eta_t,
eps_t ~ N(0, sigma2_eps), eta_t ~ N(0, diag(sigma2_beta)), beta_1 diffuse
(Harvey 1989, §8.3; Durbin & Koopman 2012, §3.6) — the state-space form
with the per-period design row x_t', identity transition, and one
innovation variance per coefficient, all k coefficients exactly diffuse.
constant=True (default) prepends a random-walk intercept. The k + 1
variances are estimated by MLE; beta_filtered/beta_smoothed (T x k,
with _var) are the real-time and two-sided coefficient paths. With every
state variance at zero the filter is recursive least squares: the
filtered path is the expanding-window OLS estimate (statsmodels
RecursiveLS), which fixed_params=[sigma2_eps, 0, ..., 0] reproduces.
Assumptions. Gaussian errors, regressors that are finite and predetermined, coefficients that drift as independent random walks (no mean reversion — for a coefficient that returns to a mean the random walk overstates long-run drift), constant observation variance.
When to use (and when not). Use it when a relationship is suspected to
drift — a pass-through, a beta, a Phillips-curve slope — and you want the
path with a variance rather than a rolling window's arbitrary width; the
smoothed path is the object for description, the filtered path for
real-time questions. The estimator's known weakness is what the pile-up
check is for: with modest true variation the MLE of a state variance is
exactly zero with high probability (Stock & Watson 1998), so a flagged
coefficient means the data cannot show it moving, not that it is
constant. Do not read a flagged coefficient's se (it is NaN on
purpose), do not use it for regressors with a unit root without thinking
about spurious drift, and do not expect it to separate coefficient drift
from omitted-variable bias.
Key arguments and defaults (and why). constant=True because a
regression without an intercept is rarely intended; n_starts=3 (state
variances at 1%, 0.01% and 100% of the OLS residual variance) because the
likelihood is often flat near zero in a state variance and the ladder
brackets the pile-up. fixed_params runs the filter at given variances —
the RLS limit above, or a variance ratio taken from the literature (the
Stock-Watson median-unbiased estimator is a roadmap follow-up).
How to read the output. sigma2_eps, sigma2_beta (and the same in
params/se/param_names); pile_up[j] (= at_boundary[j+1]) flags a
coefficient variance the likelihood cannot tell from zero (zeroing it
costs < 1e-4 log-likelihood), with a NaN se; beta_*_var are the
diagonal coefficient variances (finite part inside the nobs_diffuse-long
diffuse period for the filtered ones, exact for the smoothed ones);
fitted/resid/std_resid are the one-step quantities as for
unobserved_components; aic/bic follow statsmodels'
k_params + k_diffuse convention.
Failure modes. Collinear regressors give a slow, badly conditioned
diffuse period and NaN standard errors; a column that is identically zero
is refused. With fewer than k + 2 observed values the diffuse period
cannot end and the fit is refused. Pile-up at zero is the expected
behaviour on constant coefficients (measured below), so a study that
needs an unbiased variance should use the median-unbiased approach; this
callable reports the MLE honestly.
Validated against. A statsmodels MLEModel transcription of the
documented state-space form (per-period design, exact diffuse) at fixed
parameters — log-likelihood, filtered and smoothed coefficient paths and
variances, one-step predictions, residuals and standardized residuals,
AIC/BIC — at 1e-8, on a seeded series and on a NaN-inserted variant;
statsmodels RecursiveLS in the zero-state-variance limit — its filtered
coefficients at 1e-8 and its concentrated log-likelihood equal to the TVP
log-likelihood at sigma2_eps = scale, with the last filtered coefficient
equal to full-sample OLS; the MLE pinned to the better of statsmodels'
fit and a SciPy re-optimization (1e-5 in log-likelihood, 2e-3 in the
interior parameters, 2e-2 in their standard errors) with the true-zero
third variance flagged (statsmodels' optimum has it at 3e-17). Seeded Monte
Carlo (uc_properties.rs): with constant true coefficients (T = 200, 24
seeds, 48 coefficient variances) the share flagged at zero is 0.604;
with clearly moving coefficients (sigma2_beta = 0.05) it is 0.000,
and those 48 estimates run from 0.0029 through a median of 0.0417 to
0.109 against a truth of 0.05 — the median is close, the individual draws
are not, which is the attenuation the pile-up section warns about and the
reason the path is the deliverable. The zero-variance filter equals
expanding-window OLS at every checked date (closed form, 1e-8).
References. Harvey (1989, §8.3); Durbin & Koopman (2012, §3.6); Stock, J. H. and M. W. Watson (1998), "Median Unbiased Estimation of Coefficient Variance in a Time-Varying Parameter Model", JASA 93; Shephard & Harvey (1990).
import numpy as np, tsecon
rng = np.random.default_rng(0)
T = 300
x = rng.normal(size=(T, 1))
beta = 0.5 + np.cumsum(rng.normal(0, 0.05, T)) # a drifting slope, true var 0.0025
y = 1.0 + beta * x[:, 0] + rng.normal(0, 0.3, T)
r = tsecon.tvp_regression(y, x)
print(np.round(r["sigma2_beta"], 4), r["pile_up"])
# [0. 0.0012] [True, False] -> the intercept cannot be shown to move; the slope can
path = np.asarray(r["beta_smoothed"])[:, 1] # smoothed slope, with r["beta_smoothed_var"]
np.corrcoef(path, beta)[0, 1] # 0.882
Read that output the way the failure-mode section says to. The intercept
really is constant and its variance is flagged, which is the right answer.
The slope variance really is 0.0025 and the MLE says 0.0012: the
random-walk-variance MLE is severely attenuated at this sample size, and
the smoothed path still tracks the truth at a correlation of 0.88. With the
same slope drift against a unit noise standard deviation instead of 0.3,
the same call returns 3.3e-5 — a seventy-fold understatement, verified
against statsmodels to be the true maximum of the likelihood, not an
optimizer failure. The path is the deliverable; the variance is a nuisance
parameter with a badly behaved estimator (Stock & Watson 1998).