A Laplace library of latent time series components for Stan — local level, local linear trend, stochastic and trigonometric seasonality, stochastic volatility, and latent-Gaussian counts. These are the building blocks of structural time series models: each one is a path that evolves over time and is never observed directly. Import it into any .laplace model and call it with namespaced calls (lts::function_name(...)).
Like all Laplace libraries, lts compiles down to plain, readable Stan functions. Nothing about how you use it hides what actually ends up in your .stan file.
This is the companion to ts, which covers the observation-driven models — ARIMA, GARCH, exponential smoothing. The split is deliberate: everything in ts computes its recursion from the data, and everything here samples a hidden path.
A structural time series model is a sum of components, and the library keeps them separate:
Each component is an independent function returning a vector[T]. You add them with ordinary Stan arithmetic and write the observation model yourself:
vector[T] mu = lts::local_level(mu1, z_mu, sigma_mu);
vector[T] seas = lts::stochastic_seasonal(s_init, z_s, sigma_s);
y ~ normal(mu + seas + X * beta, sigma_y);
There is no bsts() function that does all of it at once, because the useful thing about structural models is that you choose which components are in them. A trend alone, a trend plus seasonality, a seasonality on its own added to an ARIMA mean from ts — all of these are the same three lines with different terms.
Every component comes in two parameterizations, and you use exactly one of them.
The non-centred primary is the default. It takes standard normal innovations and builds the path from them:
parameters { vector[T - 1] z_mu; real<lower=0> sigma_mu; }
transformed parameters { vector[T] mu = lts::local_level(mu1, z_mu, sigma_mu); }
model { z_mu ~ std_normal(); }
The centred alternative declares the path itself as a parameter and scores it with _state_lpdf:
parameters { vector[T] mu; real<lower=0> sigma_mu; }
model { target += lts::local_level_state_lpdf(mu | sigma_mu); }
Running the primary and then adding the state lpdf counts the state prior twice. It will compile, it will sample, and the answer will be wrong in a way nothing warns you about. Pick one.
Non-centred is the default because the centred version has a funnel: when adapt_delta will fix. Reach for _state_lpdf only when the data pin
For a component called <name>:
| Form | Shape | Where it goes |
|---|---|---|
<name>(...) |
Takes innovations, returns the path | transformed parameters |
<name>_state_lpdf(path | ...) |
Log density of the latent path |
model, as the centred alternative |
<name>_lpdf / _lpmf
|
Observation density, where the component defines one | model |
<name>_rng(..., T) |
A simulated path of length |
transformed data or generated quantities
|
<name>_forecast_rng(state_last, ..., H) |
|
generated quantities |
Only stochastic_volatility and latent_gaussian_counts define observation densities, because only those two carry their own likelihood. The other four are pure components: you add them to a mean and write the likelihood yourself.
Note what _forecast_rng takes: the final state, not the series. A latent component has no residuals to replay, so continuing it needs only where it ended — mu[T] for the local level, mu[T] and delta[T] for the trend, the last
Every function carries @brief, @param, @return, @math, and (where useful) @example documentation, so you can read it from the terminal without leaving your model:
laplace doc lts::trigonometric_seasonal
| Component | Innovations | State equations |
|---|---|---|
local_level |
z, length |
|
local_linear_trend |
zeta_mu, zeta_delta, both length |
see below |
The local level is a random walk observed with noise — the simplest thing that is not a constant. Its one real subtlety is that
The local linear trend adds a slope that itself drifts:
The primary returns only _state_lpdf needs both, so declare delta as a parameter when you use it.
| Component | Arguments | What it does |
|---|---|---|
stochastic_seasonal |
s_init (length zs, sigma_s
|
One effect per period, constrained to sum to zero |
trigonometric_prep |
period, K
|
Precomputes transformed data
|
trigonometric_seasonal |
g1, g1_star, z_g, z_gs, sigma_gamma, cos_sin
|
|
The dummy seasonal encodes its identification constraint in the mean:
which says any
The trigonometric seasonal rotates each harmonic pair by its own frequency
In the library the conjugate g_star and its innovations z_gs.
The difference that matters: the dummy seasonal spends
#components-with-their-own-likelihood
| Component | State | Observation |
|---|---|---|
stochastic_volatility |
||
latent_gaussian_counts |
|
Both are AR(1) latent paths started from their stationary distribution, which for a scalar state is available in closed form and needs no Lyapunov solve:
stochastic_volatility is the latent-state answer to the same question GARCH answers observation-driven. GARCH makes the variance a deterministic function of past returns; this gives the variance its own noise. It fits worse per parameter and forecasts volatility better, and it is the harder of the two to sample — non-centred is not optional here.
latent_gaussian_counts is deliberately zero-mean, so the level lives in the regression. Its no-covariate overload therefore has no intercept at all, which implies counts centred on X unless that's really what you mean.
lts is distributed as a git-hosted Laplace library — there's no published registry entry yet, so it's added by pointing laplace (or cmdlaplacer, if you're working from R) directly at the repository. The package lives in the repository's laplace/ subdirectory, so pass it as the subdir.
From inside a Laplace project (a directory with its own laplace.toml):
laplace add lts --git https://github.com/mlatinov/laplace-lts --tag 0.1.0 --subdir laplace
library(cmdlaplacer)
laplace_install_git(
"lts",
"https://github.com/mlatinov/laplace-lts",
tag = "0.1.0",
subdir = "laplace"
)
Either way, this pins the dependency in your project's laplace.toml/laplace.lock at tag 0.1.0. Check the tags for newer versions as they become available.
Import the library in a library { } block and call its functions with the lts:: namespace prefix.
#a-structural-model-level-seasonality-and-regressors
The canonical BSTS decomposition. Each component is built separately, summed into the mean, and forecast separately from its own final state.
library {
import lts
}
data {
int<lower=1> T;
vector[T] y;
int<lower=2> S; // seasonal period, e.g. 12
int<lower=1> K; // number of covariates
matrix[T, K] X;
int<lower=1> H; // forecast horizon
matrix[H, K] X_future;
}
parameters {
real mu1; // initial level
vector[S - 1] s_init; // initial seasonal effects
vector[T - 1] z_mu; // non-centred level innovations
vector[T - S + 1] z_s; // non-centred seasonal innovations
vector[K] beta;
real<lower=0> sigma_mu; // level drift
real<lower=0> sigma_s; // seasonal drift
real<lower=0> sigma_y; // observation noise
}
transformed parameters {
vector[T] level = lts::local_level(mu1, z_mu, sigma_mu);
vector[T] seasonal = lts::stochastic_seasonal(s_init, z_s, sigma_s);
}
model {
mu1 ~ normal(0, 5);
s_init ~ normal(0, 1);
beta ~ normal(0, 1);
z_mu ~ std_normal();
z_s ~ std_normal();
sigma_mu ~ exponential(2); // prior the drift relative to sigma_y
sigma_s ~ exponential(5); // seasonality usually drifts slower than the level
sigma_y ~ exponential(1);
y ~ normal(level + seasonal + X * beta, sigma_y);
}
generated quantities {
vector[H] level_f = lts::local_level_forecast_rng(level[T], sigma_mu, H);
vector[H] seasonal_f = lts::stochastic_seasonal_forecast_rng(
seasonal[(T - S + 2):T], sigma_s, H);
vector[H] y_forecast = to_vector(
normal_rng(level_f + seasonal_f + X_future * beta, sigma_y)
);
}
Swap local_level for local_linear_trend to add a slope, or stochastic_seasonal for trigonometric_seasonal when the period is long. The observation line doesn't change either way — that's the point of keeping the components separate.
The exponential(2) / exponential(5) / exponential(1) ordering is doing real work here. It says the observation noise is the largest scale, the level drifts more slowly, and the seasonal shape drifts more slowly still. Without that ordering the ridge between
#stochastic-volatility-on-returns
The mean is zero, the variance is the latent path, and the observation density comes from the library.
library {
import lts
}
data {
int<lower=1> T;
vector[T] y; // log returns, already centred
int<lower=1> H;
}
parameters {
real mu_h; // mean log variance
real<lower=-1, upper=1> rho; // persistence, usually near 1
real<lower=0> sigma_h;
vector[T] z; // non-centred innovations
}
transformed parameters {
vector[T] h = lts::stochastic_volatility(mu_h, sigma_h, rho, z);
}
model {
mu_h ~ normal(0, 5);
rho ~ beta(20, 1.5); // shifted to (0,1); see the note below
sigma_h ~ exponential(5);
z ~ std_normal();
target += lts::stochastic_volatility_lpdf(y | h, rep_vector(0, T));
}
generated quantities {
vector[H] h_forecast = lts::stochastic_volatility_forecast_rng(h[T], mu_h, sigma_h, rho, H);
vector[H] y_forecast = to_vector(normal_rng(rep_vector(0, H), exp(h_forecast / 2)));
}
A beta(20, 1.5) prior is on rho as real<lower=0, upper=1> or transform it — rho = 2 * rho_raw - 1 with rho_raw ~ beta(20, 1.5) — if you want to allow negative persistence. In practice volatility persistence is positive and close to 1, and the prior is there to keep the sampler away from the
#counts-with-an-ar1-latent-effect
Overdispersed, autocorrelated counts. X carries the intercept, since the latent path is zero-mean.
library {
import lts
}
data {
int<lower=1> T;
array[T] int<lower=0> y;
int<lower=1> K;
matrix[T, K] X; // FIRST COLUMN SHOULD BE ONES
int<lower=1> H;
matrix[H, K] X_future;
}
parameters {
vector[K] beta;
real<lower=-1, upper=1> rho;
real<lower=0> sigma_u;
vector[T] z;
}
transformed parameters {
vector[T] u = lts::latent_gaussian_counts(rho, sigma_u, z);
}
model {
beta ~ normal(0, 2);
rho ~ normal(0, 0.5);
sigma_u ~ exponential(2);
z ~ std_normal();
target += lts::latent_gaussian_counts_lpmf(y | u, X, beta);
}
generated quantities {
vector[H] u_forecast = lts::latent_gaussian_counts_forecast_rng(u[T], rho, sigma_u, H);
array[H] int y_forecast = poisson_log_rng(X_future * beta + u_forecast);
}
Swapping poisson_log_rng for neg_binomial_2_log_rng and writing the likelihood by hand gives you a negative binomial version; the latent component doesn't change.
With cmdlaplacer, the .laplace file compiles straight to a cmdstanr model, and the generated .stan file stays on disk next to it:
library(cmdlaplacer)
mod <- laplace_model("structural.laplace")
fit <- mod$sample(data = list(T = length(y), y = y, S = 12, K = ncol(X),
X = X, H = 24, X_future = X_future))
fit$draws(c("level", "seasonal", "y_forecast"))
-
Use
target +=, not~. Laplace rewriteslts::func(calls, so writetarget += lts::stochastic_volatility_lpdf(y | h, mu). They ~ lts::stochastic_volatility(...)form won't resolve. -
Never use a primary and its
_state_lpdftogether. One is non-centred, the other centred. Using both counts the state prior twice, compiles cleanly, and gives a silently wrong posterior. -
Non-centred is the default for a reason. The centred parameterization funnels when
$\sigma$ is small. If you see divergences that surviveadapt_delta = 0.99, check which parameterization you're on before touching anything else. - Every component costs one parameter per time point. There is no Kalman filter here, so the latent path is sampled rather than marginalised. A two-component model on 2000 points is roughly 4000 parameters, which HMC handles but not instantly. Marginalisation is planned, not present.
-
Scales are only jointly identified.
$\sigma_\mu$ against$\sigma_y$ ,$\sigma_s$ against both. Put priors on all of them on the data's scale, and prefer an ordering that says what you believe about which component moves fastest. -
Seasonal initial effects need a prior and want to sum to zero.
s_inithas length$S-1$ and the recursion supplies the$S$ -th implicitly. If you leaves_initflat the level will absorb a constant offset. -
Covariates in
local_levelandlocal_linear_trendenter the increment. The covariate overloads accumulate: a constant covariate produces a linear trend, andbetais a drift rather than a level shift. For an ordinary level-shift regression putX * betain the observation mean, as the structural example does, and use the plain overload. -
_forecast_rngtakes the final state, not the series.level[T], ormu[T]anddelta[T]for the trend, or the last$S-1$ seasonal effects. Forecasting a summed model means forecasting each component and adding the results. -
Forecast a damped trend, not an undamped one. With
$\rho = 1$ the local linear trend's predictive intervals grow cubically and stop meaning anything past a season or two. -
_rngfunctions are restricted by Stan. They can only be called intransformed dataorgenerated quantities. -
latent_gaussian_countshas no intercept. Its latent path is zero-mean by construction, so the single-argument_lpmfoverload implies counts centred on$\lambda = 1$ . Put an intercept column inX. -
Storing component paths in
transformed parametersis usually right here. Unlike a covariance matrix, avector[T]per component is what you actually want in the output — the decomposition into level, seasonality and regression is the reason to fit a structural model at all. -
Composes with
ts. A latent seasonal added to an ARMA mean, or a local level with GARCH errors, is just two imports and one sum. The two libraries share no state and don't need to know about each other.
See LICENSE.