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; - autoregressive component
a_t(autoregressive=p): an AR(p) block after the cycle,a_t = ar.L1 a_{t-1} + ... + ar.Lp a_{t-p} + eta_t, in statsmodels' row-companion form, with the disturbance variancesigma2.ar; it is the stationary, serially correlated deviation that a random-walk level and white noise cannot express; - regressors
exog(T x k) with time-invariant coefficientsbeta.x1, ...estimated jointly by MLE (statsmodelsmle_regression=True, the default), or — withmle_regression=False— carried in the state vector askexactly diffuse constant states with the design rowx_t'.
Under the defaults 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, k_diffuse counting the diffuse
states only) and every state moment agree with it to the digit. An AR block
is the one exception statsmodels makes too: it starts from its stationary
distribution (the Lyapunov solution of its companion block), and so does it
here, so AR specifications are directly comparable with statsmodels as
well. 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.
Two switches change what the log-likelihood is
cycle_init="stationary" and mle_regression=False are not tuning
knobs: each changes the definition of the likelihood, so loglik,
aic and bic are not comparable across either switch, not even at
the same parameters. On 100 log US GDP the local level + damped cycle at
the same five parameters scores −454.98 with the stationary cycle and
−444.16 with the diffuse one (one diffuse element instead of three). The
state-regression likelihood is the marginal likelihood, the MLE form
the profile one, and at the GLS coefficients they differ by exactly
ln det(regression_coefficients_cov) / 2 (−4.34 to −5.09 on the six
specifications in the fixture). And under mle_regression=False the
likelihood also depends on the units of the regressors: multiplying
one column by 100 moves it by exactly −ln 100 (−186.5505 → −191.1557)
while no estimate moves; the MLE form does not care. Compare models
within one setting of both switches, with the regressors in fixed
units.
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,
−178.95 — ln 100 = 4.6 log-likelihood points of pure singularity for every
two decades of frequency, and a free optimizer walks straight into it: given
(2, 1e9) it stops at a "cycle" of period 2.7e7, 1.4 points above the fit
inside the default bounds (scripts/uc_long_diffuse_evidence.py).
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 / cycle_init without
cycle=True, mle_regression — even True — without exog,
forecast_exog without exog or forecast_steps).
autoregressive=p (None by default; 0 is refused with "pass None",
and p may not exceed len(y)) adds the AR(p) component. Its parameters
follow statsmodels' order — sigma2.ar after the other state variances,
ar.L1…ar.Lp after damping.cycle — and the coefficients are searched
inside the stationarity region (the partial autocorrelations through tanh
and the Levinson-Durbin recursion; statsmodels uses x/sqrt(1+x^2) for the
same partial autocorrelations, which changes the path, not the optimum). A
fixed non-stationary polynomial is refused, naming the partial
autocorrelation that leaves (−1, 1); statsmodels has no such check and
returns a number there (on Nile, local level + AR(1) at [8000, 1400, 6000,
phi]: −88.02 at φ = 1.2 and −1403.49 at φ = 1.0), whereas at φ = 0.9999,
still stationary, both agree to 1.6e-15. The AR-UC likelihood is multimodal
— the AR block can trade places with the irregular, absorb a trend near the
unit root, or die out into a piled-up zero variance — so the start ladder
walks the first partial autocorrelation over 0.5, 0.9, 0, −0.5, 0.5, and
the second rung of an AR(p ≥ 2) starts hump-shaped at φ = (1.35, −0.5),
the autocorrelation of a business cycle around a trend (see failure modes).
A likelihood evaluation costs O(len(y) k_states^3), and an estimated
AR(p) needs O(p) evaluations per search step and O(p^2) for its
standard errors, so the cost of a fit climbs steeply with p: a few lags
is the intended use, and a long order is slow rather than refused (only the
working-set bound below refuses it).
cycle_init="stationary" ("diffuse" when None, statsmodels'
convention; parsed trimmed and case-insensitive) starts the two cycle
states from the damped cycle's unconditional distribution,
sigma2.cycle / (1 - damping^2) on each, instead of a diffuse prior. A
damped stochastic cycle is a stationary process, so this is the prior
the model itself implies, and it removes the low-frequency singularity
above at the root: no cycle state is diffuse, so there is no
-ln F_inf term to run away with. At the US-GDP optimum (smooth trend plus
a stationary cycle of 6–32 quarters) moving only the frequency to 1e-2,
1e-4 and 1e-6 gives −266.559, −266.657, −266.657 — flat — where the diffuse
cycle at the same parameters climbs −258.509, −240.094, −238.025 in tsecon
(and −258.509, −253.642, −253.059 in statsmodels, whose absolute 1e-10
tolerance on F_inf clips it). It needs damped_cycle=True (an undamped
rotation has no stationary distribution) and stochastic_cycle=True (a
deterministic damped cycle has stationary variance zero, so it would be
identically zero), and a fixed damping of exactly 1 is refused. statsmodels
has no keyword for it; the reference is statsmodels' own block
Initialization (graded below).
mle_regression=False puts the regression coefficients in the state
vector — statsmodels' mle_regression=False — as k exactly diffuse
constant states after the AR block (state_names end with beta.x1…),
with design row x_t' and no beta.x* parameters. The variance parameters
are then estimated from the marginal (diffuse, REML-like) likelihood,
which accounts for the degrees of freedom spent on the coefficients, and
the coefficients come back as states: regression_coefficients (smoothed,
nobs x k, constant in t and equal to the GLS estimate given the
variances), filtered_regression_coefficients (the recursive GLS path),
their _vars, and regression_coefficients_cov, the k x k covariance
Var(beta_gls | theta). With white noise only this is exactly OLS:
coefficients, sigma2 (X'X)^-1, forecasts x' beta_hat with variance
sigma2 (1 + x'(X'X)^-1 x), pinned in closed form. forecast_exog is
still required for a forecast.
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,
autoregressive — the first AR state — each with _var; the regression
keys above under mle_regression=False; every key present, None when its
component is absent) 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, each freq_seasonal period and autoregressive at
most len(y), the working set len(y) * k_states^2 at most 2^26 (the
filter and smoother keep four sequences of that many covariance cells, so a
seasonal period or an AR order near the length of a long series would ask
for terabytes; the refusal names the counts that cost the states),
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.
The AR component brings failure modes of its own. Multimodality: on US
inflation (local level + AR(2)) statsmodels' own L-BFGS stops at −455.6236,
1.89 log-likelihood points short of the optimum (−453.7374, a
near-unit-circle AR(2) at φ = (1.858, −0.939)) that SciPy's re-optimization
and this fit both reach at the default n_starts=3. The AR block can die
out: on 100 log US GDP (local linear trend + AR(2)) every start of the
first-PACF ladder alone lands on a local optimum at −259.87 where
sigma2.ar piles up at zero and the level variance absorbs everything; the
hump-shaped second rung reaches the genuine −250.18. When sigma2.ar is
flagged like that, a zero-variance stationary block is identically zero, so
ar.L1… carry no information and get NaN standard errors (the same rule
drops frequency.cycle and damping.cycle when sigma2.cycle piles up
under the stationary cycle) instead of every standard error going NaN — or
coming back as a huge number read off a near-singular pivot. Near the unit
root: the Hessian probe of an AR coefficient is halved until every probe
point is stationary, so an estimate within 1e-4 of the boundary still
leaves the other standard errors finite; and a stationary block whose root
sits within roundoff of the unit circle — where tanh or the logistic map
saturate — passes the Lyapunov solve but has an unconditional variance some
1e15 times its one-step variance, at which point the filter can only skip
the later observations as numerically singular and the "log-likelihood"
collapses to its diffuse terms (on Nile, local level + AR(1) at φ = 1 −
1e-15, the raw filter returns exactly one diffuse term, −ln(2π)/2 = −0.919,
against statsmodels' own −637.04 at φ = 0.9999). Such a likelihood is
undefined in the search and refused at fixed parameters.
Under mle_regression=False, collinear regressors leave one diffuse
direction (the difference of the collinear coefficients) that the data can
never resolve. Nothing is refused — the likelihood and every forecast that
does not load on that direction stay defined, exactly as for the Nyquist
harmonic above — but the diffuse period never ends: nobs_diffuse equals
the sample length and std_resid is NaN throughout. Drop the redundant
column. Badly scaled regressors are the floating-point version of the
same problem: the coefficient states are loaded by x_t, so a column many
orders of magnitude from the others, or from the unit loading of the level,
trend and seasonal states, leaves diffuse directions the exact-diffuse
filter cannot resolve in roundoff — the diffuse period then runs on to the
end of the sample and the coefficients and log-likelihood are wrong
(statsmodels, filtering the raw columns, breaks the same way). The diffuse
period does not depend on the units, so every state-regression fit is
checked against the same model on columns divided by their largest
absolute values, and when the two disagree the call is refused, naming
exog. The refusal blames the units only when the columns' largest
absolute values are more than a factor 100 from each other or from 1;
inside that band the units are not the cause, and it says instead that the
observations of the diffuse period see some combination of the
coefficients and the level, trend and seasonal states only weakly (a
column nearly collinear with the others or with those states there), and
points to dropping that column or to mle_regression=True, which has no
diffuse coefficient states. Accuracy fades before that point, so keep the
regressors in comparable units near 1. Nearly collinear columns are the
same problem short of exact collinearity: the last diffuse direction
resolves with an F_inf close to the filter's rank tolerance, the
exact-diffuse log-likelihood is then ill-conditioned in double precision
(statsmodels' as much as this crate's), and the twin check catches some of
these fits but not all. With columns close to collinear prefer
mle_regression=True: its log-likelihood has no diffuse coefficient
direction to resolve and stays well conditioned, while the coefficients
themselves are as poorly determined as the collinearity makes them. (Up
to the 0.11.0 filter fix described under Validated against, about 1% of
well-scaled N(0, 1) designs were refused here as badly scaled: 2 of 400
seeds of a local level + seasonal 12 + one regressor. None is now, and all
400 end the diffuse period where statsmodels does.)
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, −178.95 at
λ = 1e-2, 1e-4, 1e-6: ln 100 per two decades, the −ln(ρ sin λ) of the
one weakly resolved diffuse element) 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.
Validated against — the 0.11.0 options (fixtures/uc_extensions.json,
generator fixtures/generate_uc_extensions_fixtures.py;
crates/tsecon-ssm/tests/uc_extensions_golden.rs, the in-module unit tests
of uc.rs and mle.rs, uc_properties.rs,
bindings/python/tests/test_uc_extensions.py). The fixed-parameter
tolerance is the same 1e-8 with the same recorded smoother_spread
relaxation inside the diffuse period — and it engages once: on the
combined specification below statsmodels' two smoothers disagree by 6.3e-8
there. The Python leg, which runs on every CI platform, scales that
relaxation by 10, because the spread measures how strongly those rows
amplify rounding and another platform's rounding is amplified the same
way: there the macOS arm64 wheel lands 4.9e-7 relative from statsmodels,
while the Linux x86_64 build stays within one spread.
- Autoregressive component — an independent-package golden: statsmodels'
own
autoregressive=at fixed parameters on nine specifications (Nile local level + AR(1) and AR(2), random walk + AR(1), inflation local level - AR(2), GDP local linear trend + AR(2), GDP growth white noise + AR(3)
with every state stationary and
nobs_diffuse = 0, and simulated fixed intercept + AR(1), local level + seasonal + diffuse cycle + AR(1), local linear trend + seasonal + AR(2) + regressors), with the AR component path; the initial covariance against the closed formssigma2.ar / (1 - phi^2)and the AR(2) autocovariances; the MLE on Nile (−631.3802544693), inflation (−453.7373687669) and GDP (−250.1813180960) reaching the better of two optimizers at the defaultn_starts=3, parameters 2e-3, pile-up flags exact, standard errors 2e-2 against statsmodels' complex-step Hessian over the free set (which mirrors the dropped-coefficient rule). - Stationary cycle — graded as statsmodels machinery under a documented
initialization, since statsmodels has no keyword for it: a subclass whose
initialize_defaultbuilds statsmodels' own blockInitialization("stationary" on the cycle pair and any AR block, "diffuse" elsewhere; the cycle located through the publicstate_names), so the Lyapunov solve, the exact-diffuse filter and the smoother are statsmodels'. Fixed parameters on GDP (−454.978475273841), Nile (−631.619075075044), a simulated local linear trend + seasonal + regressors, and GDP smooth trend - stationary cycle + AR(1) as one block-diagonal Lyapunov system
(−265.424367261473), at 1e-8; the closed form
P_* = sigma2.cycle / (1 - damping^2)(10.526315789474 at 2 and 0.9) in the crate's unit test; the GDP MLE (smooth trend, 6–32 quarters) reaching SciPy's −252.1147178886 (statsmodels −252.1147179444) with the irregular variance piled up; the flatlambda -> 0sweep at 1e-8. - Regressors in the state — statsmodels'
mle_regression=Falseat fixed parameters on four specifications, with the coefficient paths and their covariance: a local level, white noise (the closed-form case), a local linear trend + AR(2) with NaN at t = 5, 6, 50 and 119 (the forecast origin missing), and one specification with every new option at once (smooth trend, a 12-period two-harmonic trigonometric seasonal, the stationary cycle, an AR(1) and the regressors). Closed forms at 1e-10: white noise plus regressors is OLS — coefficients,sigma2 (X'X)^-1, the diffuse log-likelihood (−479.125183355077), forecasts and their variances — and it equalstvp_regression(constant=False)at zero coefficient variances to 1e-12; the marginal/profile identity on six specifications and the regressor-units identity (shift exactly −ln 100) at 1e-10; the MLE reaching the better of two optimizers (−185.1819788397). - Long exact-diffuse periods — the exact-diffuse filter every model here
shares, against statsmodels at fixed parameters (
long_diffuse): a monthly local level + dummy seasonal 12 with the same calendar month missing at t = 2 and 14 (diffuse for 27 observations) or at 2, 14 and 26 (39), with the seasonal fixed and stochastic, with an AR(1) and with a regressor in the state; a local linear trend + seasonal 12 + two N(0, 1) regressors in the state with nothing missing (15); a local level + seasonal 12 + one N(0, 1) regressor in the state (13); and the first series with a year or more missing at the start (the first 12 under a local level + fixed seasonal 12, 24; the first 16 under a local linear trend + seasonal 12, 29). Everynobs_diffuseand log-likelihood matches (−184.180678 and −186.501844 for the fixed and stochastic [2, 14] cases, −193.974529 with the AR(1), −206.983808 and −157.071129 for the two regressor designs), and the paths hold 1e-8 except on those two regressor designs, which resolve a diffuse direction only weakly: there statsmodels' own paths move by 2.5e-8 and 1.0e-7 when an input moves by one ulp (rounding_spread, measured by the generator), and ten times that is the tolerance. The MLE of the stochastic [2, 14] case reaches the better reference optimum (−178.844311, the seasonal variance piled up at zero). Before the 0.11.0 fix the filter's roundoff test refused the genuine diffuse element that ends each of these periods (TOLERANCE_CANCELinfilter.rs; the replay isscripts/uc_long_diffuse_evidence.py):nobs_diffusecame back as 120 on the seven missing-month cases with log-likelihoods 0.64 to 2.87 too low and on the two leading-gap cases (0.99 too high, 1.76 too low), as 16 instead of 15 on the first regressor design, the second was refused as badly scaled, and the MLE reported −180.97. - Seeded Monte Carlo and identities (
uc_properties.rs): the stationary cycle recovers a period-12, damping-0.9 cycle — over 400 seeds at T = 200 (scripts/mc_uc_stationary_cycle_recovery.py, the test's own series) the median |period error| is 0.45 (bootstrap se 0.03) and the median |damping error| 0.022 (se 0.001), and 2 of the 400 fits miss: one does not converge, its damping driven to the bound 1 and its period to 6.85, and one lands at period 15.1; the test itself is an 8-seed smoke check (seeds 0–7, all inside its band); local level + AR(1) recovers φ = 0.8 (10 seeds, T = 200: median |error| 0.0556); at the true variances the state-regression intervals cover the coefficients at 0.9475 and 0.9515 (2000 seeds, nominal 0.95); scale equivariance holds with an AR component (estimates 7.3e-8, log-likelihood 2.5e-16) and under state regression (4.5e-8, 6.1e-16); the stationary cycle's log-likelihood moves by 3.6e-3 over frequencies 1e-2 to 1e-8, where the diffuse cycle on the same series climbs 9.2 points between 1e-2 and 1e-6; and the default specifications reproduce 0.10.0 bit for bit — the exact IEEE-754 patterns of threeuc.jsonMLE fits and thetvp_regressionMLE, recorded before the shared likelihood layer was touched.
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; secs. 5.7 and 7.2 for regression coefficients in the state); 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; de Jong, P. (1991), "The Diffuse Kalman Filter", Annals of Statistics 19; Monahan, J. F. (1984), "A Note on Enforcing Stationarity in Autoregressive-Moving Average Models", Biometrika 71.
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))
The 0.11.0 options. An AR(1) beside the Nile level — the optimum both reference optimizers reach (−631.3802544693), every variance interior:
import numpy as np, statsmodels.api as sm, tsecon
nile = sm.datasets.nile.load_pandas().data["volume"].values.astype(float)
ar = tsecon.unobserved_components(nile, autoregressive=1)
print({k: round(float(v), 4 if k == "ar.L1" else 1) for k, v in zip(ar["param_names"], ar["params"])})
# {'sigma2.irregular': 7873.5, 'sigma2.level': 521.4, 'sigma2.ar': 8519.3, 'ar.L1': 0.4733}
print({"loglik": round(ar["loglik"], 4), "k_diffuse": ar["k_diffuse"], "at_boundary": ar["at_boundary"]})
# {'loglik': -631.3803, 'k_diffuse': 1, 'at_boundary': [False, False, False, False]}
A smooth trend plus a stationary business cycle on 100 log US real GDP, periods confined to 6–32 quarters. The irregular variance piles up at zero (flagged, NaN standard error), the cycle runs about seven years with damping 0.94, and only the two trend states are diffuse:
import numpy as np, statsmodels.api as sm, tsecon
md = sm.datasets.macrodata.load_pandas().data
lgdp = 100 * np.log(md["realgdp"].values.astype(float))
gdp = tsecon.unobserved_components(lgdp, level="strend", cycle=True, damped_cycle=True,
stochastic_cycle=True, cycle_period_bounds=[6, 32],
cycle_init="stationary")
p = dict(zip(gdp["param_names"], gdp["params"]))
print({"period": round(float(2 * np.pi / p["frequency.cycle"]), 2), "damping": round(float(p["damping.cycle"]), 4),
"loglik": round(gdp["loglik"], 4), "k_diffuse": gdp["k_diffuse"]})
# {'period': 28.86, 'damping': 0.9401, 'loglik': -252.1147, 'k_diffuse': 2}
print({k: b for k, b in zip(gdp["param_names"], gdp["at_boundary"]) if b})
# {'sigma2.irregular': True}
Regressors in the state: the coefficients come back as smoothed states with their covariance, and the log-likelihood is the marginal one — 6.3 points below the MLE form's profile likelihood here, which is a difference of definition, not of fit:
import numpy as np, tsecon
rng = np.random.default_rng(7)
T = 200
X = rng.normal(size=(T, 2))
y = np.cumsum(rng.normal(0, 0.3, T)) + X @ [1.0, -0.5] + rng.normal(0, 0.5, T)
st = tsecon.unobserved_components(y, exog=X, mle_regression=False)
beta = np.asarray(st["regression_coefficients"])[-1] # GLS, constant over t
se = np.sqrt(np.diag(np.asarray(st["regression_coefficients_cov"])))
print({"beta": np.round(beta, 3).tolist(), "se": np.round(se, 3).tolist()})
# {'beta': [0.963, -0.525], 'se': [0.044, 0.042]}
ml = tsecon.unobserved_components(y, exog=X) # the MLE form, for contrast
print({"beta_mle": np.round(ml["params"][2:], 3).tolist(), "loglik_gap": round(st["loglik"] - ml["loglik"], 3)})
# {'beta_mle': [0.963, -0.525], 'loglik_gap': -6.309}
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).