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_"))
emit:"both"(default),"mu"or"z", choosing which scalars to emit per stream.prefix: the feature-name prefixes for the mean and the surprise.
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:
- You don't trust the data (fat fingers, sensor glitches, gaps): replace the raw value with the pair. Replacing is what protects you. Adding the pair alongside the raw value does not help, because the model can still see the bad value.
- You trust it and it is roughly linear (all the information is in the level and its lags): leave it raw. The pair throws away some of the level and measured about 30% worse on these datasets.
- It has structure (seasonality, a volatility clock, anything beyond lags): use the pair. It wins even on clean data.
- It has big jumps but they are real: leave it raw. The extreme values carry the most information and the clamp on z is what removes them. Convert streams you distrust, not streams that are merely volatile.
- The target: decide separately. The wrapper helped
on smooth single-entity series, and
mus=2costs nothing. - You are not sure: converting costs between zero and eleven percent on clean data, depending on the stream, and won 10 times out of 10 when the data was corrupted. Leaving it raw has no ceiling on how bad it can get. So: one stream you watch closely, leave raw; a thousand streams nobody will look at, convert.
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.