Skip to main content

skaters (demo,docs)

A light, accurate, autonomous distributional forecaster for non-price economic series in particular. Watch here.

Documentation and live demos Python, JavaScript, R, Julia and Rust

Laplace beats (almost) everything.

Accuracy vs. speed on 2,775 non-price FRED series: laplace sits top-right, fastest and tied for the best held-out log-likelihood; only the PyMC-laplace sandwich edges it on accuracy, at about ten times the compute, while AutoARIMA, AutoETS, SARIMAX and GARCH-t trade accuracy for speed.

And it beats them across nearly every regime, giving ground only on price/returns (to GARCH-t) and, narrowly, on the hard, repeating waveforms (to CSP1).

Radar chart of per-regime win-rate against laplace on log-likelihood for CSP, Theta, nnetar and GARCH-t. All sit inside the dashed laplace ring except GARCH-t, which breaks out on the price/returns axis, and CSP, which edges past on the hard waveforms.

Per-regime win-rate against laplace (log-likelihood, ties split, laplace = the dashed ring). Outside the ring the challenger wins that regime; inside, laplace does. GARCH-t breaks out on price/returns; CSP edges past on the hard, repeating waveforms, where it is handed the period and laplace is not. Explore it interactively →

Laplace is fast, dependency-free, online univariate distributional forecasting with parity-locked ports in Python, JavaScript, R, Julia and Rust (all matched to the reference within 1e-6; JavaScript and Pyodide run it in the browser). It's a general-purpose forecaster for non-price economic series: on 5,402 continuous non-price FRED change-series Laplace wins the per-series held-out log-likelihood race against eleven of twelve baselines — AutoARIMA, AutoETS, the reference R forecasters (auto.arima, thetaf, ADAM, nnetar), conformal, and zero-shot foundation models — typically on 82–98% of series. The lone exception is GARCH-t, a 50/50 coin-flip that the paper resolves by martingality: Laplace wins decisively on series with mean structure, GARCH-t on near-random-walks.

Not for price/return series: We recommend GARCH-t there instead.

When CRPS is the target: A scoring rule is a settlement rule — match it to what you actually want. skaters defaults to held-out log-likelihood because it ranks reusable densities, and on CRPS laplace still beats most of the competition. But if your target really is CDF shape or threshold behaviour, CRPS is a reasonable contract and a method like conformal prediction may suit — just know it is a different contract, not a better density. (Why skaters ranks by likelihood, by default →)

Install

pip install skaters

Quick start

from skaters import laplace

f = laplace(k=3)
state = None
for y in observations:
    dists, state = f(y, state)
    dists[0].mean              # point forecast
    dists[0].std               # uncertainty
    dists[0].quantile(0.975)   # 95th percentile
    dists[0].logpdf(y)         # log-likelihood
    dists[0].cdf(y)            # CDF at y

Every skater returns list[Dist] — a weighted Gaussian mixture for each horizon $h = 1, \ldots, k$. Point forecasts, uncertainty, density evaluation, and quantiles are all aspects of the same object.

laplace — the general forecaster

skaters exposes exactly one forecaster, laplace. Everything else is a building block (transforms, leaves, ensembles) you can compose. ("skater" is the concept — any (y, state) -> ([Dist], state) function, borrowed from the old timemachines package.) At multi-step horizons (k>1) it is multi-scale by default (below).

from skaters import laplace

f = laplace(k=1)

A likelihood-weighted Bayesian ensemble over a large candidate population (EMA, differencing, drift, Holt, AR, fractional differencing, seasonal, a Yeo-Johnson coordinate grid, a fast/slow two-systems block, and — at multi-step horizons (k>1) — an Ornstein–Uhlenbeck mean-reversion group). Three things are on by default, each a free or near-free win:

  • model first, conform last — the trunk is weighted by likelihood; the terminal leaf is fit by CRPS (objective="crps"). On a 2,500-series FRED study this matches a CRPS specialist on CRPS and lifts likelihood on real data. Switch back with objective="likelihood".
  • lattice projection (sticky=True) — near-Dirac atoms on the exact values a series revisits. Free on continuous data (it vanishes), a large win on grid/repeating series (policy rates, posted prices).
  • coordinate learning — a Yeo-Johnson λ-grid lets the ensemble learn whether the series is simple in a log/multiplicative, sqrt, or linear coordinate.
f = laplace(k=1)                          # CRPS leaf + lattice, both on
f = laplace(k=1, objective="likelihood")  # pure-likelihood leaf
f = laplace(k=1, sticky=False)            # no lattice projection

Calibration state, free. Every arriving point is resolved against the forecasts previously made for it: state["pit"][m-1] is its probability integral transform under the m-step-ahead predictive issued m steps ago (roughly Uniform(0,1) when calibrated) and state["z"][m-1] the same through the standard-normal quantile (roughly N(0,1); z is clamped to ±7.03, never infinite). That calibrated-surprise signal is the null model streaming anomaly detection is built on, and that work lives in its own project: timemachines — calibrated online anomaly detection with honest false-alarm rates, built on skaters.

Tails that keep their stated rates (v0.13.0). Every predictive carries censored-ML generalized-Pareto tails beyond the body's ~2% region (the conditional tail fit; tails="gaussian" opts out). Measured prequentially on FRED: +0.03 nats/tick held-out log-likelihood (96%+ of series, price and non-price, all horizons), and the tail probabilities keep their stated rates. The empirical alarm rate at nominal 1e-3 is ~1.4e-3, and the nominal 99.9% interval covers 99.85% (the Gaussian read covers 99.13%). An alarm budget converts directly: alarm when erfc(|z|/sqrt(2)) < alpha. Essay: The 99.9% interval that is actually 99.9%; long form: When to alarm.

Price/return series (garch_leaf). The default terminal leaf tracks its scale with an EWMA (RiskMetrics/IGARCH — no variance mean-reversion). For series with volatility clustering and reversion (equity/fx/commodity returns), swap in a GARCH(1,1)-t terminal leaf:

from skaters import laplace, garch_leaf
f = laplace(k=1, leaf=garch_leaf)         # GARCH(1,1) conditional variance + Student-t tails

Not for price/returns, though — there a fitted GARCH-t still wins

Multi-step horizons are multi-scale by default

Natively, laplace(k) reaches horizon h by fanning its one-step model out, and on long horizons the fan-out can inflate the predictive variance. A model on a decimated clock (every s-th observation) reaches the same horizon in round(h/s) of its own steps and stays tight — but is blind to the skipped points. At k>1, laplace runs a full instance per scale (default strides {1, ceil(sqrt(k)), k}) and mixes their predictive Dists per horizon with likelihood softmax weights, so each horizon selects its effective granularity online. At k=1 it is laplace(1); on a 150-series multi-step pilot it is the only variant to win the likelihood race against AutoARIMA, AutoETS, GARCH-t and Prophet at every horizon in {1, 5, 20}.

f = laplace(k=20)                      # scales {1, 5, 20}, mixed by likelihood
f = laplace(k=20, scales=[1, 4, 20])   # or pick the clock grid yourself
f = laplace(k=20, scales=[1])          # opt out: single-scale native fan-out

Specialist behaviour by composition

The named forecasters are thin wrappers; the building blocks compose into specialists when you have a strong prior. Mean reversion (e.g. pairs-trading spreads): the ou_transform reverts to a running mean and its edge grows with the horizon, so feed it k>1 —

from skaters.conjugate import conjugate
from skaters.leaf import leaf
from skaters.transform import ou_transform, yeo_johnson

f = conjugate(leaf(k=10), ou_transform(kappa=0.1), k=10)                       # linear (spreads)
f = conjugate(conjugate(leaf(k=10), ou_transform(0.1), k=10), yeo_johnson(0.5, exact=True), k=10)  # positive (vol/rates)

exact=True maps the predictive back through the exact change of variables instead of the component-wise delta method, which cannot carry the skew of the coordinate change. The difference grows with horizon: at h=10 on strictly positive FRED levels it is worth a median +0.015 to +0.018 nats of held-out log-likelihood (72–78% of series), and at h=1 the two agree. The candidate pool keeps the default (its one-step spreads are where they agree).

laplace(k>1) already carries an OU group in its pool, so the general forecaster picks up reversion automatically at multi-step horizons. The OU-on-a-coordinate math (the CIR reading) is in papers/tweedie-note.md.

Architecture

Everything is transforms all the way down, with a distributional leaf at the bottom:

$$y ;\xrightarrow{T_1}; y' ;\xrightarrow{T_2}; y'' ;\xrightarrow{\cdots}; \text{leaf} ;\rightarrow; \hat{D}$$

The leaf estimates $\hat{D} = \mathcal{N}(0, \hat\sigma^2)$ from residuals via Welford's algorithm. The prediction in the original space is obtained by inverting the transform chain:

$$\hat{D}_{\text{original}} = T_1^{-1}\bigl(T_2^{-1}\bigl(\cdots\bigl(\hat{D}\bigr)\bigr)\bigr)$$

Every node returns list[Dist]. There is no separate "point forecast" vs "uncertainty" — both are aspects of the same $\hat{D}$.

The key insight

Every "model" is really a transform. An EMA doesn't "predict" — it subtracts a running level $\ell_t$, leaving simpler residuals $\varepsilon_t = y_t - \ell_t$. The prediction comes from inverting the transform chain applied to the leaf's distributional estimate.

The Dist type

A weighted mixture of Gaussians $\sum_{i} w_i ,\mathcal{N}(\mu_i, \sigma_i^2)$. Pure Python (math.erf, math.exp).

from skaters import Dist

d = Dist.gaussian(5.0, 2.0)
d.mean                  # 5.0
d.std                   # 2.0
d.pdf(5.0)              # density at x
d.cdf(3.0)              # P(X <= 3)
d.logpdf(5.0)           # log-likelihood
d.quantile(0.975)       # inverse CDF

# Exact mixture combination (for ensembles)
mix = Dist.combine([d1, d2, d3], weights=[0.5, 0.3, 0.2])

# Propagate through transform inverses
d.shift(10.0)           # translate: mu -> mu + 10
d.scale(2.0)            # scale: mu -> 2*mu, sigma -> 2*sigma
d.affine(2.0, 3.0)      # x -> 2x + 3

# Bound component growth
d.prune(max_components=10)

Transforms

Online bijective maps. Each has a forward (scalar in, scalar out) and an inverse_k that propagates $\text{Dist}$ objects back through the inverse.

Transform Forward Inverse Use case
ema_transform($\alpha$) $y'_t = y_t - \ell_t$ $D \mapsto D + \ell_t$ Remove level
difference() $y't = y_t - y{t-1}$ Cumsum with $\text{Var}$ growing as $\sum \sigma_h^2$ Random walk
drift($\alpha, \lambda$) $y'_t = \Delta y_t - \hat\mu_t$ $y_t + h\hat\mu + \sum\varepsilon$ Random walk + drift
holt_linear($\alpha, \beta$) $y'_t = y_t - (\ell_t + b_t)$ $\ell_t + h \cdot b_t + \varepsilon$ Level + trend (Holt 1957)
ar($p$) $y't = y_t - \sum \hat\phi_j y{t-j}$ AR reconstruction with variance propagation Autoregression (online RLS)
grouped_ar($L$) Same, grouped coefficients Same Long-lag AR with $O(\log L)$ params
fractional_difference($d$) $y'_t = (1-B)^d , y_t$ $(1-B)^{-d}$ Long memory
standardize($\alpha$) $y'_t = (y_t - \hat\mu_t) / \hat\sigma_t$ $D \mapsto \hat\sigma_t \cdot D + \hat\mu_t$ Remove scale
garch($\omega, \alpha, \beta$) $y'_t = y_t / \hat\sigma_t$ $D \mapsto \hat\sigma_t \cdot D$ Volatility clustering
seasonal_difference($s$) $y't = y_t - y{t-s}$ Shift by lagged value Periodicity
power_transform($p$) $y'_t = \text{sign}(y_t)|y_t|^p$ Delta method (exact=True for the pushforward) Tail compression
theta($\alpha$) $y'_t = y_t - \text{SES}_t$ Shift by smoothed level + drift Theta method (M3 winner)
yeo_johnson($\lambda$) Signed Box–Cox to coordinate $\lambda$ Delta method (exact=True for the pushforward) Coordinate learning (log/root/linear)
ou_transform($\kappa$) Deviation from running mean, OU speed $\kappa$ Exact OU moments (scale + shift) Mean reversion

Conjugation

Transforms compose via conjugation. Given a transform $T$ and a skater $f$:

$$f_{\text{conjugated}}(y) = T^{-1}!\bigl(f\bigl(T(y)\bigr)\bigr)$$

The pipe | notation reads left-to-right (outermost transform first):

from skaters import conjugate, ema, difference, standardize

# diff removes trend, EMA predicts the differenced series
f = conjugate(ema(alpha=0.1, k=3), difference(), k=3)

# Chain: standardize, then difference, then EMA
f = conjugate(
    conjugate(ema(alpha=0.1, k=3), difference(), k=3),
    standardize(),
    k=3,
)
# canonical name: std|diff|ema_t|leaf

Ensembles

Precision-weighted (MSE)

Weights by $w_i \propto 1/\text{MSE}_i$ where $\text{MSE} = \text{bias}^2 + \text{variance}$.

from skaters import precision_weighted_ensemble, ema

f = precision_weighted_ensemble([
    ema(alpha=0.05, k=1),
    ema(alpha=0.2, k=1),
], k=1)

Bayesian (log-likelihood, XGBoost-inspired regularization)

Each model $i$ accumulates a log-weight updated at every observation:

$$\log w_i ;\mathrel{+}=; \eta \cdot \log p_i(y_t) ;-; \lambda \cdot d_i$$

where $\eta$ is the learning rate (shrinkage), $\lambda$ is the complexity penalty, and $d_i$ is the model's depth. Predictions are combined via $\text{Dist.combine}$ with softmax weights.

from skaters import bayesian_ensemble, ema

f = bayesian_ensemble(
    [ema(alpha=0.05, k=1), ema(alpha=0.2, k=1)],
    k=1,
    learning_rate=0.5,       # eta: prevents over-concentrating
    complexity_penalty=0.02, # lambda: penalizes deeper chains
    depths=[1, 1],
)

dantzig — adaptive search (beam search over transform grammar)

Where laplace weights a fixed candidate population, dantzig explores a growing one: expand top performers with new transforms (including seasonal differences at periods its online detector finds), replay recent history to warm-start, prune losers. Named for Dantzig 1947 — the simplex method walks from vertex to better neighbouring vertex without enumerating the polytope, which is exactly this expand-and-prune walk through the model grammar. (search remains exported as the underlying machinery.)

from skaters import dantzig

f = dantzig(
    k=1,
    expand_interval=100,  # expand top performers every 100 obs
    max_depth=3,          # maximum transform chain depth
    replay_buffer=500,    # warm-start new candidates on recent history
    max_pool=30,          # cap active candidates
)

Heavy tails: the scale-mixture leaf

Everything here is judged by predictive log-likelihood. A plain Gaussian leaf gets the location and scale right but the shape wrong on heavy-tailed residuals (returns, macro data), and — crucially — Bayesian model averaging preserves the mean and variance but washes the kurtosis out, so adding heavy leaves to the candidate pool doesn't help.

The fix is the scale-mixture leaf: a fixed dictionary of zero-mean Gaussians N(0, aᵢ·σ) with weights learned online (a Student-t is a Gaussian scale mixture, so this approximates it). It's a plain Dist; the weights are the "discrepancy from N(0,1)" — all on a=1 is Gaussian, mass on larger a is fat tails. It matches the Gaussian leaf on Gaussian data and beats it as tails fatten.

from skaters import scale_mixture_leaf, terminal_leaf_ensemble, leaf

Because mixing washes out shape, the named policies use a terminal-leaf ensemble: the candidates are combined for the mean, then one terminal scale-mixture leaf models the combined residual — so the leaf's shape reaches the output undiluted. On Student-t₃ this takes laplace from a logpdf of ≈ −2.07 (Gaussian-collapsed) to ≈ −1.93, with no cost on Gaussian data.

Dist.crps(y) (closed-form CRPS) is also available as a proper score for benchmarking.

Spec system

Serialize and rebuild any pipeline:

from skaters import (
    build, spec_name, to_json, from_json,
    ema_spec, conjugate_spec, ensemble_spec, diff_spec,
)

spec = ensemble_spec(
    conjugate_spec(ema_spec(0.1, k=1), diff_spec()),
    ema_spec(0.3, k=1),
    k=1,
)

spec_name(spec)     # "ensemble(diff|ema(0.1),ema(0.3))"
j = to_json(spec)   # JSON string
f = build(from_json(j))  # live skater

Writing a custom transform

Any $(T, T^{-1})$ pair where forward is scalar and inverse_k maps list[Dist]:

def my_transform():
    def forward(y, state):
        if state is None:
            return 0.0, {"anchor": y}
        transformed = y - state["anchor"]
        return transformed, {"anchor": y}

    def inverse_k(dists, state):
        return [d.shift(state["anchor"]) for d in dists]

    return forward, inverse_k

JavaScript, R, Julia, Rust & the browser

The whole library is also a zero-dependency JavaScript port (docs/js/skaters/) — every transform, ensemble, and named policy. It is verified against the Python reference by a parity suite that checks roughly 100,000 values to 1e-6 (parity/, run in the test suite via tests/test_js_parity.py).

The R port at microprediction/skaters-r covers laplace end to end under the same discipline: 96,040 reference parity values at 1e-6, including the transforms, ensembles, lattice projection, calibration state, and the GPD tail splice. Continuous installs via install.packages("skaters", repos = "https://microprediction.r-universe.dev").

The Julia port (microprediction/Skaters.jl) and a portable Rust core (rust/, the fast native backend, with Python bindings in rust/python/) complete five parity-locked implementations. The shared parity vectors make each new language a porting exercise with a built-in referee.

<script type="module">
  import { laplace } from "https://skaters.microprediction.org/js/skaters/index.mjs";
  const f = laplace(1);
  let state = null;
  for (const y of observations) {
    const [dists, st] = f(y, state); state = st;
    dists[0].mean;            // point forecast
    dists[0].quantile(0.975); // 97.5th percentile
  }
</script>

Interactive demos (forecasting playground in native JS, and the real Python package running in Pyodide) live at skaters.microprediction.org/demos.

Design

  • Online only — $O(1)$ per observation, no batch recomputation
  • Distributional — every prediction is a $\text{Dist}$, not a point estimate
  • Composable — transforms chain, ensembles nest, everything returns $\text{Dist}$
  • Pure Python — zero dependencies, only math.erf and math.exp
  • Pyodide compatible — works in the browser via WebAssembly

Theoretical context

The online recursions here are score-driven updates with a Bayesian reading. The EMA level update $\mu_t = \mu_{t-1} + \alpha,(y_t - \mu_{t-1})$ and the GARCH variance update $h_t = h_{t-1} + (1-\delta)(y_t^2 - h_{t-1})$ — the ema_transform and garch/garch_leaf building blocks — are both inverse-Fisher-scaled conditional-score corrections. Via Tweedie's formula, Hansen & Tong (2026, arXiv:2605.15902) show these are the exact Bayesian posterior-mean corrections under a conjugate prior with local precision discounting (with the smoothing factor identified as $\alpha = 1-\delta$, the Gaussian-location case recovering the Kalman filter), and tractable local approximations otherwise. So the volatility transforms are (approximate) Bayesian filters rather than ad-hoc heuristics. See also Creal, Koopman & Lucas (2013) and Harvey (2013) for the score-driven / GAS framework.

The same identity is the backbone of modern denoising / score-based diffusion models: the posterior mean of a clean signal given a noisy observation is "observation $+\ \sigma^2 \times$ score of the marginal density," which is what lets a diffusion denoiser be read as a score estimator (Efron 2011; Vincent 2011; Song & Ermon 2019). Each forecast step here is the time-series analogue — denoising the next observation toward the latent level or variance. A short essay on this — Kalman, empirical Bayes, and diffusion as one identity — is in papers/tweedie-note.md.

Lineage

This package distills ideas from timemachines, which provided a common skater interface for dozens of time series packages. This is a from-scratch rewrite focused on speed, distributional predictions, and browser compatibility.

  1. On the hard-waveform arm (near-deterministic, repeating cycles) CSP is handed the seasonal period, while laplace is not and must infer structure generically. Its edge there reflects that head start, not a like-for-like loss. ↩

Metadata

Release files for skaters 0.16.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for skaters 0.16.0
File Size Uploaded
skaters-0.16.0.tar.gz 94.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for skaters 0.16.0
File Interpreter ABI Platform
skaters-0.16.0-py3-none-any.whl Python 3 none any Details

Total release size: 202.6 kB

Release files / skaters-0.16.0.tar.gz

Download URL skaters-0.16.0.tar.gz
Size 94.3 kB
Tags Source
SHA-256 checksum
How to use checksums
dbf7f01268a16a8ae7037ecee43b7f275ecfa8b784bdc9cfcd4764ec9edb6197
BLAKE2b-256 checksum
How to use checksums
5be9439fed712cc68e1b31e1e003b3ebeb0018244802bf01af151aefa3ba8398
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release files / skaters-0.16.0-py3-none-any.whl

Download URL skaters-0.16.0-py3-none-any.whl
Size 108.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
df7fd23014a5288b380b7c09427d5f338502abccfcc3b41d103cdd3aec512a22
BLAKE2b-256 checksum
How to use checksums
f2a592f217d7cad9c7af355e84832624edde5a7cd11d6869362f7b77088dae91
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release history Release notifications | RSS feed

This release

0.16.0 This release

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.10.0

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.2

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page