A Laplace library of time series building blocks for Stan — the classical ARIMA family, seasonal variants, exponential smoothing, GARCH volatility, smooth-transition regime switching, and count autoregression. Every model ships a mean recursion, a log density, a simulator, and a forecaster. Import it into any .laplace model and call it with namespaced calls (ts::function_name(...)).
Like all Laplace libraries, ts compiles down to plain, readable Stan functions. Nothing about how you use it hides what actually ends up in your .stan file.
Every model in this library is a recursion over time, and the library gives you that recursion in four forms:
-
A primary computes the model's driving quantity from the data — the conditional mean
$\mu_t$ for the linear models, the conditional standard deviation$\sigma_t$ for GARCH, the conditional intensity$\lambda_t$ for INGARCH. Use it when you want the quantity itself, for a custom likelihood or fortransformed parameters. -
An
_lpdf/_lpmfruns the same recursion and scores the observed series. This is what goes inmodel. -
An
_rngruns the recursion forward from explicit seed values, drawing instead of scoring. For prior predictive checks and simulation-based calibration. -
A
_forecast_rngreplays the recursion over the observed series to recover its internal state (residuals, intensities, level and seasonal states), then continues$H$ steps. Forgenerated quantities.
_rng and _forecast_rng are not the same function with different arguments. _rng starts cold; _forecast_rng inherits everything the data implies. For a pure AR model the two bodies coincide, but for anything carrying a residual or state history they do not.
Every model also has a covariate overload: the same function with matrix X, vector beta appended after sigma. There is no separate arimax — you add the regressors to the model you already have:
// same recursion, with a regression on the mean
target += ts::arma_lpdf(y | alpha, phi, theta, sigma, X, beta);
Note this is ARMAX, not regression-with-ARMA-errors: the regressors sit inside the autoregressive loop, so beta is not the long-run effect of X on y. If you want the clean effect, model the residual structure separately.
These are pure data transformations. Run them once in transformed data when their inputs are data, or inline when they aren't.
| Function | Returns | What it does |
|---|---|---|
lag_design(p, y) |
matrix[T-p, p] |
Design matrix whose row |
lag_design(p, y, L) |
matrix[T-L, p] |
Same, but rows start at |
seasonal_lag_design(P, s, y) |
matrix[T-Ps, P] |
Seasonal lags |
seasonal_lag_design(P, s, y, L) |
matrix[T-L, P] |
Same, rows starting at |
difference(y, d) |
vector[T-d] |
|
seasonal_difference(y, s) |
vector[T-s] |
|
seasonal_difference(y, s, D) |
vector[T-Ds] |
|
undifference(y_obs, delta_f, d) |
vector[H] |
Integrates a forecast of |
unseasonal_difference(y_obs, delta_f, s, D) |
vector[H] |
Same for seasonal differences |
The L overloads exist because sar adds an ordinary lag matrix to a seasonal one, and those two have different natural starting rows. Build both with L = max(p, P * s) or they won't line up. The two un* helpers are called for you inside arima_forecast_rng and sarima_forecast_rng; you only need them directly if you're forecasting by hand.
All of these put
| Model | Arguments after y_obs
|
|
|---|---|---|
ar |
alpha, phi, X_lag |
|
ma |
alpha, theta |
|
arma |
alpha, phi, theta |
|
sar |
alpha, phi, Phi, x_lag, X_slag |
|
sma |
alpha, theta, Theta, s |
|
sarma |
alpha, phi, Phi, theta, Theta, s |
see below |
arima |
alpha, d, phi, theta |
the ARMA recursion applied to |
sarima |
alpha, phi, Phi, theta, Theta, d, D, s |
the SARMA recursion applied to |
The seasonal ARMA mean, written out:
This is the additive seasonal form, not the classical multiplicative
ar and sar take design matrices rather than the raw series, so their mean is a single matrix–vector product with no loop. Everything else loops, because the residual history has to be built one step at a time.
#differencing-and-what-it-does-to-the-likelihood
arima_lpdf and sarima_lpdf return the density of the differenced series:
Two models with different lp__ and loo values are not comparable. Pick
arima_forecast_rng and sarima_forecast_rng hide this: they difference, forecast on the differenced scale, and integrate back, so what you get out is on the level scale.
#beyond-the-linear-gaussian-mean
| Model | Arguments after y_obs |
What it models |
|---|---|---|
exponential_smoothing |
alpha, beta, gamma, l0, b0, s0, m |
Additive Holt–Winters: level, trend and seasonal, all updated from the data |
garch |
omega, alpha, beta, mu |
Conditional variance, given any conditional mean |
star |
alpha1, alpha2, phi1, phi2, c, kappa, d, scale |
Two AR regimes with a smooth transition between them |
ingarch |
omega, alpha, beta |
Log-linear count autoregression |
Holt–Winters is a deterministic filter: given the data and the parameters there are no latent states at all, only
The seasonal update uses
GARCH takes the conditional mean as a vector argument, so it composes with any mean model in this library or one you wrote yourself:
STAR is regime switching without any latent states, because the regime weight depends on an observed lag:
where $m^{(r)}_t = \alpha_r + \sum_j \phi^{(r)}j y{t-j}$ is regime
INGARCH is written on the log scale so the intensity can't go negative:
The covariate overload adds
For a model called <name>:
| Form | Shape | Where it goes |
|---|---|---|
<name>(...) |
Returns |
transformed parameters, or model with your own likelihood |
<name>_lpdf(y | ...) |
Returns a real log density |
model, via target +=
|
<name>_rng(H, ...) |
Returns a simulated series of length |
transformed data or generated quantities
|
<name>_forecast_rng(y_obs, H, ...) |
Returns |
generated quantities |
The ARMA-family primaries return array[2] vector: position 1 is _lpmf rather than _lpdf and returns array[] int from its simulators.
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 ts::sarima_lpdf
ts 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 ts --git https://github.com/mlatinov/laplace-ts --tag 0.1.0 --subdir laplace
library(cmdlaplacer)
laplace_install_git(
"ts",
"https://github.com/mlatinov/laplace-ts",
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 ts:: namespace prefix.
#arp-with-covariates-and-a-forecast
The design matrix is built once in transformed data, so the AR mean costs one matrix–vector product per gradient. Note that y is scored from p observations are conditioned on, never modelled.
library {
import ts
}
data {
int<lower=1> T;
int<lower=1> p;
vector[T] y;
int<lower=0> K; // number of covariates
matrix[T, K] X_all; // covariates over the whole series
int<lower=1> H; // forecast horizon
matrix[H, K] X_future; // covariates over the horizon
}
transformed data {
int N = T - p;
matrix[N, p] X_lag = ts::lag_design(p, y);
vector[N] y_obs = y[(p + 1):T];
matrix[N, K] X = X_all[(p + 1):T];
}
parameters {
real alpha;
vector[p] phi;
vector[K] beta;
real<lower=0> sigma;
}
model {
alpha ~ normal(0, 2);
phi ~ normal(0, 0.5); // shrinks toward stationarity without enforcing it
beta ~ normal(0, 1);
sigma ~ exponential(1);
target += ts::ar_lpdf(y_obs | alpha, phi, X_lag, sigma, X, beta);
}
generated quantities {
vector[H] y_forecast = ts::ar_forecast_rng(y, H, alpha, phi, sigma, X_future, beta);
}
Drop X/beta from both calls for the plain AR model — the overload without covariates has the same name.
One ordinary difference for the trend, one seasonal difference for the yearly cycle, and short AR/MA terms at both scales. The forecaster handles the integration back to levels.
library {
import ts
}
data {
int<lower=1> T;
vector[T] y;
int<lower=1> H;
}
transformed data {
int s = 12; // monthly data, yearly season
int d = 1; // one ordinary difference
int D = 1; // one seasonal difference
}
parameters {
real alpha; // drift, on the differenced scale
vector[1] phi; // AR(1)
vector[1] Phi; // seasonal AR(1)
vector[1] theta; // MA(1)
vector[1] Theta; // seasonal MA(1)
real<lower=0> sigma;
}
model {
alpha ~ normal(0, 1);
phi ~ normal(0, 0.5);
Phi ~ normal(0, 0.5);
theta ~ normal(0, 0.5);
Theta ~ normal(0, 0.5);
sigma ~ exponential(1);
target += ts::sarima_lpdf(y | alpha, phi, Phi, theta, Theta, d, D, s, sigma);
}
generated quantities {
vector[H] y_forecast =
ts::sarima_forecast_rng(y, H, alpha, phi, Phi, theta, Theta, d, D, s, sigma);
}
alpha here is a drift on the twice-differenced scale, so it is not the level of the series. With
#arma-mean-with-garch-volatility
garch takes the conditional mean as a vector, so any mean model composes with it. Here the primary supplies
library {
import ts
}
data {
int<lower=1> T;
vector[T] y; // e.g. log returns
int<lower=1> H;
}
parameters {
real alpha_mean;
vector[1] phi;
vector[1] theta;
real<lower=0> omega; // variance floor
real<lower=0, upper=1> a; // reactivity
real<lower=0, upper=1> b; // persistence
}
transformed parameters {
array[2] vector[T] mean_eps = ts::arma(y, alpha_mean, phi, theta);
vector[T] mu = mean_eps[1];
}
model {
alpha_mean ~ normal(0, 1);
phi ~ normal(0, 0.5);
theta ~ normal(0, 0.5);
omega ~ exponential(10);
a ~ beta(2, 8);
b ~ beta(8, 2);
target += ts::garch_lpdf(y | omega, a, b, mu);
}
generated quantities {
vector[H] mu_future = ts::arma_forecast_rng(y, H, alpha_mean, phi, theta, sqrt(omega));
vector[H] y_forecast = ts::garch_forecast_rng(y, H, omega, a, b, mu, mu_future);
}
The stationarity constraint b as real<lower=0, upper=1-a> in your own model, or put a simplex[3] over beta(2,8) / beta(8,2) priors above make violations unlikely rather than impossible, which is the usual applied compromise.
One caveat on that forecast block: arma_forecast_rng draws its own innovations at a constant sqrt(omega), and garch_forecast_rng then draws again with the time-varying generated quantities, or use a constant mean, which is the usual setup for returns anyway.
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("sarima.laplace")
fit <- mod$sample(data = list(T = length(y), y = y, H = 24))
fit$draws("y_forecast")
-
Use
target +=, not~. Laplace rewritests::func(calls, so writetarget += ts::arma_lpdf(y | ...). They ~ ts::arma(...)form won't resolve. - Conditional likelihood only. The first observations are conditioned on rather than modelled, which is what almost all applied Stan code does. The exact likelihood needs the stationary covariance of the initial state, which means solving a Lyapunov equation, and that isn't here.
-
Stationarity and invertibility are not enforced. There is no Monahan transform, no partial-autocorrelation reparameterization. Put informative priors on
phiandtheta—normal(0, 0.5)is a reasonable default — and check the posterior rather than the parameterization. If the sampler wanders into an explosive region your predictions will tell you loudly. -
arima_lpdfandsarima_lpdfscore the differenced series.lp__andlooare not comparable across models with differentdorD. -
sarneeds matching design matrices. Build both withlag_design(p, y, L)andseasonal_lag_design(P, s, y, L)at the sameL = max(p, P * s), or the two products have different row counts. -
Covariate overloads index
Xon the modelled scale. Forar/sarthat means the lag-trimmed rows; forarima/sarimait means the differenced series, soXhasT - D*s - drows. Difference your regressors too, or the alignment is silently wrong. -
_rngstarts cold,_forecast_rnginherits the data. Use_rngfor prior predictive checks and SBC,_forecast_rngfor anything conditioned on a fit. Mixing them up gives forecasts that ignore where the series actually is. -
MA forecasts go flat after
qsteps. The residual inputs run out, so the mean settles atalphaand only the innovation variance remains. This is correct, not a bug. -
_rngfunctions are restricted by Stan. They can only be called intransformed dataorgenerated quantities. -
Holt–Winters seeds must sum to zero.
s0has lengthmand holds$s_{-m+1},\dots,s_0$ . If they don't sum to zero, the seasonal component and the level are not separately identified and the level will drift to absorb the difference. -
STAR's
dis data, never a parameter. The delay indexes into the series, so it can't be sampled. Grid over it and compare, or fix it.kappais divided byscaleto make it dimensionless — pass the standard deviation ofy. Whenkappais large the transition is nearly a step andcbecomes weakly identified. -
INGARCH intensities can overflow.
poisson_rngerrors if$\lambda$ exceeds$2^{30}$ , which a log-linear recursion can reach during warmup. Keepomegamodestly prior-constrained andbetawell below 1. -
These recursions are sequential. MA-type models build the residual history one step at a time, so they cost
$O(T)$ non-vectorised operations per gradient evaluation. That's fine for thousands of points, slow for millions. The AR and SAR paths are the vectorised exception. -
No state-space models in 0.1.0. Local level, local linear trend, stochastic seasonality, dynamic regression and stochastic volatility all need either a Kalman filter (to marginalise) or a per-timepoint parameter vector (to sample directly). Planned, but not here yet. Hidden Markov regime switching is likewise absent;
starcovers the regime-switching use case without the forward algorithm.
See LICENSE.