Guide

The two estimators in detail.

LaplaceFeatures

A river Transformer. Each numeric feature key gets its own Laplace forecaster. On every sample, the value v of key k is replaced by two features: mu_k, the forecaster's one-step predictive mean for this tick, and z_k, the probability integral transform of v under that predictive pushed through the standard normal quantile. When the forecaster is calibrated, z is close to N(0,1) and reads directly as how surprising the value was. The PIT is clamped so |z| never exceeds about 7.03.

LaplaceFeatures(emit="both", prefix=("mu_", "z_"))

Non-numeric values pass through untouched, so route them around any downstream StandardScaler exactly as you would with raw features. Booleans count as non-numeric. A non-finite numeric value is imputed by the forecast itself: the model receives the predictive mean and z = 0, and the forecaster state is not advanced. Keys may appear and disappear mid-stream; each key's forecaster starts on first sight.

Timing and alignment

Bodies advance exactly once per sample, inside learn_one. transform_one on its own is pure, so calling predict_one repeatedly does not change state. One river subtlety is handled for you: river's Pipeline.learn_one updates unsupervised transformers before transforming for the downstream steps, which would hand the model fresher features at learn time than it predicted with. LaplaceFeatures caches the pre-update feature dict during learn_one and serves it to the immediately following transform_one for the same sample, so the predict path and the learn path see identical features.

LaplaceTarget

A regressor wrapper in the style of river's TargetStandardScaler. Transformers never see y, so the target's own history features need a wrapper. At prediction time the inner regressor receives, alongside x, the forecaster's prediction of the y it is about to predict as mu_y, and the surprise of the previous y as zy. The same pre-update pair is used at learn time, then the forecaster consumes the new y. The target itself stays raw. This wrapper never transforms y, it only adds features.

LaplaceTarget(regressor, keys=("mu_y", "zy"))

In the ablation, on river's own datasets, LaplaceTarget alone with raw features beat river's recommended pipeline on three of four datasets clean, tied the fourth, and won 10/10 under feature contamination. Add LaplaceFeatures when you distrust the features themselves.

The full recipe

from river import linear_model, preprocessing
from ice_skaters import LaplaceFeatures, LaplaceTarget

model = LaplaceTarget(
    regressor=preprocessing.TargetStandardScaler(
        regressor=LaplaceFeatures()
        | preprocessing.StandardScaler()
        | linear_model.LinearRegression()))

The front-end composes with river's scalers rather than replacing them: the mu features live on the original scale, so the StandardScaler still applies, and TargetStandardScaler handles the target's scale for the inner learner as usual. LaplaceTarget(..., mus=2) also exposes the previous tick's forecast, which cut the clean-data cost roughly in half in the audit, improved every contaminated case, and costs no extra compute. Do not add a lagged copy of the target as an extra feature stream instead: that duplicates the surprise column exactly, doubling the gradient on it, and measured strictly worse.

Robustness and serialization

Observations are winsorized before a forecaster consumes them, at 1e60 absolute plus a window twelve orders of magnitude above the current predictive level. The window is magnitude-relative rather than sigma-relative on purpose: after a stretch of missing-data zeros a legitimate value sits billions of sigmas out and must pass. The gate is exact identity on any stream doubles can represent comfortably, and z saturates near 7 long before it engages. Both estimators hold only plain state, no closures, so they pickle and deep-copy like any river estimator.

Cost

About 390 microseconds per numeric stream per sample, single-threaded: roughly 900x river's StandardScaler, or 2,500 samples per second per stream. That is what running a distributional forecaster on every stream costs. Fine for polls, sensors, market bars and anything at human timescales. Too slow inside a hot path at hundreds of thousands of ticks per second.

Which streams to convert

Decide one stream at a time. From the benchmark studies:

When not to use it

Distance-based learners such as KNN do not benefit: neighbour averaging already handles spikes, and the extra dimensions make the metric worse. Streams that interleave many entities under one key give a single forecaster an impossible job; give each entity its own key, or wait for per-entity bodies. And if the big jumps in your data are signal rather than noise, damping them costs accuracy. Numbers for all three are on the papers page.