ice-skaters 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ice_skaters-0.1.0/LICENSE +21 -0
- ice_skaters-0.1.0/PKG-INFO +92 -0
- ice_skaters-0.1.0/README.md +78 -0
- ice_skaters-0.1.0/pyproject.toml +24 -0
- ice_skaters-0.1.0/setup.cfg +4 -0
- ice_skaters-0.1.0/src/ice_skaters/__init__.py +205 -0
- ice_skaters-0.1.0/src/ice_skaters.egg-info/PKG-INFO +92 -0
- ice_skaters-0.1.0/src/ice_skaters.egg-info/SOURCES.txt +10 -0
- ice_skaters-0.1.0/src/ice_skaters.egg-info/dependency_links.txt +1 -0
- ice_skaters-0.1.0/src/ice_skaters.egg-info/requires.txt +2 -0
- ice_skaters-0.1.0/src/ice_skaters.egg-info/top_level.txt +1 -0
- ice_skaters-0.1.0/tests/test_ice_skaters.py +113 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021-2026 Peter Cotton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ice-skaters
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: skaters on a river: calibrated forecast features for streaming ML pipelines. Each numeric stream becomes what the forecaster expected and how surprising the value was.
|
|
5
|
+
Author-email: Peter Cotton <peter.cotton@microprediction.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/microprediction/ice-skaters
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: skaters>=0.12.1
|
|
12
|
+
Requires-Dist: river>=0.21
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# ice-skaters
|
|
16
|
+
|
|
17
|
+
[skaters](https://github.com/microprediction/skaters) on a
|
|
18
|
+
[river](https://riverml.xyz): calibrated forecast features for streaming
|
|
19
|
+
ML pipelines.
|
|
20
|
+
|
|
21
|
+
Every numeric stream is replaced by two scalars from its own online
|
|
22
|
+
Laplace forecaster: the predictive mean (what the forecaster expected
|
|
23
|
+
this value to be) and the standardized surprise z (how unexpected the
|
|
24
|
+
actual value was, bounded near |z| = 7 by construction). The mean
|
|
25
|
+
carries the level, the z carries the news. A wild observation can move
|
|
26
|
+
the pair only so far, and that bounded influence is where the robustness
|
|
27
|
+
comes from.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install ice-skaters
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from river import datasets, linear_model, metrics, preprocessing
|
|
35
|
+
from ice_skaters import LaplaceFeatures, LaplaceTarget
|
|
36
|
+
|
|
37
|
+
model = LaplaceTarget(
|
|
38
|
+
regressor=preprocessing.TargetStandardScaler(
|
|
39
|
+
regressor=LaplaceFeatures()
|
|
40
|
+
| preprocessing.StandardScaler()
|
|
41
|
+
| linear_model.LinearRegression()))
|
|
42
|
+
|
|
43
|
+
mae = metrics.MAE()
|
|
44
|
+
for x, y in datasets.TrumpApproval():
|
|
45
|
+
pred = model.predict_one(x)
|
|
46
|
+
mae.update(y, pred if pred is not None else 0.0)
|
|
47
|
+
model.learn_one(x, y)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`LaplaceFeatures` is a river Transformer for the input streams.
|
|
51
|
+
`LaplaceTarget` wraps any regressor, in the style of
|
|
52
|
+
`TargetStandardScaler`, to add the target's own (mean, surprise) pair,
|
|
53
|
+
which a transformer cannot do since it never sees y. The target itself
|
|
54
|
+
stays raw. Both estimators pipe, pickle and deep-copy like any river
|
|
55
|
+
estimator. Non-numeric values pass through untouched, and NaN is imputed
|
|
56
|
+
by the forecast itself with z = 0: the model receives "expected value,
|
|
57
|
+
no news" instead of a poisoned pipeline.
|
|
58
|
+
|
|
59
|
+
## Why
|
|
60
|
+
|
|
61
|
+
On TrumpApproval with river's recommended pipeline (progressive
|
|
62
|
+
validation MAE, burn-in 100, `examples/trump_approval.py`):
|
|
63
|
+
|
|
64
|
+
| | clean | 2% corrupted readings |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `StandardScaler` pipeline | 0.328 | 0.597 |
|
|
67
|
+
| + Laplace front-end | 0.382 | 0.407 |
|
|
68
|
+
|
|
69
|
+
The front-end pays a small toll on clean data and holds its footing when
|
|
70
|
+
the inputs misbehave. In controlled simulation the same substitution
|
|
71
|
+
beats raw features, a running z-score, a median/MAD winsorizer and a
|
|
72
|
+
Huberised loss 30/30 seeds under every contamination type tested, at a
|
|
73
|
+
small clean-data toll. Full protocols, numbers and the study design live
|
|
74
|
+
in the [timemachines](https://github.com/microprediction/timemachines)
|
|
75
|
+
repo, `benchmarks/RESULTS.md` section 6.
|
|
76
|
+
|
|
77
|
+
## Boundaries, stated plainly
|
|
78
|
+
|
|
79
|
+
- Distance-based learners (KNN) do not benefit: neighbour averaging is
|
|
80
|
+
already spike-robust and the extra dimensions degrade the metric.
|
|
81
|
+
- Entity-interleaved streams (many units multiplexed into one key) want
|
|
82
|
+
per-entity bodies; a single body per key is handicapped there.
|
|
83
|
+
- If your heavy tails are signal rather than noise, taming them costs
|
|
84
|
+
accuracy. Whether the extremes are informative decides the coordinates.
|
|
85
|
+
|
|
86
|
+
## Relation to the stack
|
|
87
|
+
|
|
88
|
+
`skaters` does one thing: fast univariate distributional forecasting,
|
|
89
|
+
stdlib-only, and this package is deliberately a thin adapter over it.
|
|
90
|
+
`timemachines` builds anomaly detection on the same calibrated surprise
|
|
91
|
+
streams. ice-skaters is the bridge from those streams to river's
|
|
92
|
+
estimator protocol, and nothing more.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# ice-skaters
|
|
2
|
+
|
|
3
|
+
[skaters](https://github.com/microprediction/skaters) on a
|
|
4
|
+
[river](https://riverml.xyz): calibrated forecast features for streaming
|
|
5
|
+
ML pipelines.
|
|
6
|
+
|
|
7
|
+
Every numeric stream is replaced by two scalars from its own online
|
|
8
|
+
Laplace forecaster: the predictive mean (what the forecaster expected
|
|
9
|
+
this value to be) and the standardized surprise z (how unexpected the
|
|
10
|
+
actual value was, bounded near |z| = 7 by construction). The mean
|
|
11
|
+
carries the level, the z carries the news. A wild observation can move
|
|
12
|
+
the pair only so far, and that bounded influence is where the robustness
|
|
13
|
+
comes from.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pip install ice-skaters
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from river import datasets, linear_model, metrics, preprocessing
|
|
21
|
+
from ice_skaters import LaplaceFeatures, LaplaceTarget
|
|
22
|
+
|
|
23
|
+
model = LaplaceTarget(
|
|
24
|
+
regressor=preprocessing.TargetStandardScaler(
|
|
25
|
+
regressor=LaplaceFeatures()
|
|
26
|
+
| preprocessing.StandardScaler()
|
|
27
|
+
| linear_model.LinearRegression()))
|
|
28
|
+
|
|
29
|
+
mae = metrics.MAE()
|
|
30
|
+
for x, y in datasets.TrumpApproval():
|
|
31
|
+
pred = model.predict_one(x)
|
|
32
|
+
mae.update(y, pred if pred is not None else 0.0)
|
|
33
|
+
model.learn_one(x, y)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`LaplaceFeatures` is a river Transformer for the input streams.
|
|
37
|
+
`LaplaceTarget` wraps any regressor, in the style of
|
|
38
|
+
`TargetStandardScaler`, to add the target's own (mean, surprise) pair,
|
|
39
|
+
which a transformer cannot do since it never sees y. The target itself
|
|
40
|
+
stays raw. Both estimators pipe, pickle and deep-copy like any river
|
|
41
|
+
estimator. Non-numeric values pass through untouched, and NaN is imputed
|
|
42
|
+
by the forecast itself with z = 0: the model receives "expected value,
|
|
43
|
+
no news" instead of a poisoned pipeline.
|
|
44
|
+
|
|
45
|
+
## Why
|
|
46
|
+
|
|
47
|
+
On TrumpApproval with river's recommended pipeline (progressive
|
|
48
|
+
validation MAE, burn-in 100, `examples/trump_approval.py`):
|
|
49
|
+
|
|
50
|
+
| | clean | 2% corrupted readings |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `StandardScaler` pipeline | 0.328 | 0.597 |
|
|
53
|
+
| + Laplace front-end | 0.382 | 0.407 |
|
|
54
|
+
|
|
55
|
+
The front-end pays a small toll on clean data and holds its footing when
|
|
56
|
+
the inputs misbehave. In controlled simulation the same substitution
|
|
57
|
+
beats raw features, a running z-score, a median/MAD winsorizer and a
|
|
58
|
+
Huberised loss 30/30 seeds under every contamination type tested, at a
|
|
59
|
+
small clean-data toll. Full protocols, numbers and the study design live
|
|
60
|
+
in the [timemachines](https://github.com/microprediction/timemachines)
|
|
61
|
+
repo, `benchmarks/RESULTS.md` section 6.
|
|
62
|
+
|
|
63
|
+
## Boundaries, stated plainly
|
|
64
|
+
|
|
65
|
+
- Distance-based learners (KNN) do not benefit: neighbour averaging is
|
|
66
|
+
already spike-robust and the extra dimensions degrade the metric.
|
|
67
|
+
- Entity-interleaved streams (many units multiplexed into one key) want
|
|
68
|
+
per-entity bodies; a single body per key is handicapped there.
|
|
69
|
+
- If your heavy tails are signal rather than noise, taming them costs
|
|
70
|
+
accuracy. Whether the extremes are informative decides the coordinates.
|
|
71
|
+
|
|
72
|
+
## Relation to the stack
|
|
73
|
+
|
|
74
|
+
`skaters` does one thing: fast univariate distributional forecasting,
|
|
75
|
+
stdlib-only, and this package is deliberately a thin adapter over it.
|
|
76
|
+
`timemachines` builds anomaly detection on the same calibrated surprise
|
|
77
|
+
streams. ice-skaters is the bridge from those streams to river's
|
|
78
|
+
estimator protocol, and nothing more.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ice-skaters"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "skaters on a river: calibrated forecast features for streaming ML pipelines. Each numeric stream becomes what the forecaster expected and how surprising the value was."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Peter Cotton", email = "peter.cotton@microprediction.com" }
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"skaters>=0.12.1",
|
|
17
|
+
"river>=0.21",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/microprediction/ice-skaters"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""ice-skaters: skaters on a river.
|
|
2
|
+
|
|
3
|
+
Calibrated forecast features for `river <https://riverml.xyz>`_
|
|
4
|
+
pipelines, built on the online Laplace forecaster of the ``skaters``
|
|
5
|
+
package. ``LaplaceFeatures`` replaces each numeric feature stream with
|
|
6
|
+
two scalars from its own forecaster: the one-step predictive mean (what
|
|
7
|
+
the forecaster expected this value to be) and the standardized surprise
|
|
8
|
+
z (how unexpected the actual value was, bounded by construction near
|
|
9
|
+
|z| = 7). The mean carries the level, the z carries the news, and a wild
|
|
10
|
+
input can move the pair only so far, which is where the robustness comes
|
|
11
|
+
from. Non-numeric values pass through untouched; non-finite numeric
|
|
12
|
+
values are imputed by the forecast itself (mean = the body's prediction,
|
|
13
|
+
z = 0: no news), which is more than ``StandardScaler`` can say for NaN.
|
|
14
|
+
|
|
15
|
+
from river import linear_model, preprocessing
|
|
16
|
+
from ice_skaters import LaplaceFeatures, LaplaceTarget
|
|
17
|
+
|
|
18
|
+
model = LaplaceTarget(
|
|
19
|
+
regressor=preprocessing.TargetStandardScaler(
|
|
20
|
+
regressor=LaplaceFeatures()
|
|
21
|
+
| preprocessing.StandardScaler()
|
|
22
|
+
| linear_model.LinearRegression()))
|
|
23
|
+
# ...then the usual river loop:
|
|
24
|
+
# model.predict_one(x); model.learn_one(x, y)
|
|
25
|
+
|
|
26
|
+
``LaplaceTarget`` adds the same pair for the target's own history, which
|
|
27
|
+
a transformer cannot do since it never sees y; it wraps any regressor in
|
|
28
|
+
the style of ``TargetStandardScaler`` and leaves the target itself raw.
|
|
29
|
+
|
|
30
|
+
Evidence, protocols and boundaries (distance-based learners do not
|
|
31
|
+
benefit; entity-interleaved streams want per-entity bodies; genuinely
|
|
32
|
+
informative heavy tails should not be tamed): the regression front-end
|
|
33
|
+
study in the timemachines repo, ``benchmarks/RESULTS.md`` section 6.
|
|
34
|
+
|
|
35
|
+
Implementation notes:
|
|
36
|
+
|
|
37
|
+
- river's ``Pipeline.learn_one`` updates unsupervised transformers
|
|
38
|
+
*before* calling ``transform_one`` for the downstream steps. A naive
|
|
39
|
+
stateful transformer would therefore hand the model fresher features
|
|
40
|
+
at learn time than it predicted with. ``LaplaceFeatures`` caches the
|
|
41
|
+
pre-update feature dict during ``learn_one`` and serves it to the
|
|
42
|
+
immediately following ``transform_one`` for the same sample, so the
|
|
43
|
+
predict path and the learn path see identical features. Bodies advance
|
|
44
|
+
exactly once per sample, in ``learn_one``; ``transform_one`` alone
|
|
45
|
+
(the predict path) is pure.
|
|
46
|
+
- Instances hold only plain state data (dicts, Dists), never the skater
|
|
47
|
+
closure itself: skaters' state purity lets one module-level ``laplace``
|
|
48
|
+
callable serve every stream with state passed per stream, so both
|
|
49
|
+
estimators pickle and deep-copy like any river estimator.
|
|
50
|
+
- Observations are winsorized before a body consumes them (1e60
|
|
51
|
+
absolute, plus a magnitude-relative window twelve orders above the
|
|
52
|
+
current predictive level). skaters >= 0.13 carries the same gate in
|
|
53
|
+
the parade; keeping it here too protects users on earlier releases.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
import math
|
|
58
|
+
from river import base
|
|
59
|
+
from skaters import laplace
|
|
60
|
+
from skaters.dist import Dist
|
|
61
|
+
|
|
62
|
+
__version__ = "0.1.0"
|
|
63
|
+
__all__ = ["LaplaceFeatures", "LaplaceTarget"]
|
|
64
|
+
|
|
65
|
+
_STD_NORMAL = Dist.gaussian(0.0, 1.0)
|
|
66
|
+
_EPS = 1e-12 # parade's PIT clamp: |z| <= ~7.03, never infinite
|
|
67
|
+
|
|
68
|
+
_SKATER = None # shared, stateless given per-stream state; not pickled
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _skater():
|
|
72
|
+
global _SKATER
|
|
73
|
+
if _SKATER is None:
|
|
74
|
+
_SKATER = laplace(1)
|
|
75
|
+
return _SKATER
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_number(v) -> bool:
|
|
79
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _winsorize(v: float, pending) -> float:
|
|
83
|
+
"""Clamp absurd finite magnitudes before a body consumes them.
|
|
84
|
+
|
|
85
|
+
Magnitude-relative, NOT sigma-relative: after a degenerate-variance
|
|
86
|
+
stretch (missing-data zeros, say) a legitimate value sits billions of
|
|
87
|
+
sigmas out and must pass. Twelve orders above the current level is
|
|
88
|
+
unreachable by data, far below the ~1e77 jump ratio where double
|
|
89
|
+
arithmetic actually dies, so this is exact identity on sane streams.
|
|
90
|
+
"""
|
|
91
|
+
v = min(max(v, -1e60), 1e60)
|
|
92
|
+
if pending is not None:
|
|
93
|
+
m, s = pending.mean, pending.std
|
|
94
|
+
if math.isfinite(m) and math.isfinite(s):
|
|
95
|
+
w = 1e12 * (1.0 + abs(m) + s)
|
|
96
|
+
v = min(max(v, m - w), m + w)
|
|
97
|
+
return v
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class LaplaceFeatures(base.Transformer):
|
|
101
|
+
"""Replace each numeric feature with (predictive mean, surprise z).
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
emit: which scalars to emit per stream, ``"both"`` (default),
|
|
105
|
+
``"mu"`` or ``"z"``.
|
|
106
|
+
prefix: feature-name prefixes, ``(mean_prefix, z_prefix)``.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, emit: str = "both", prefix=("mu_", "z_")):
|
|
110
|
+
if emit not in ("both", "mu", "z"):
|
|
111
|
+
raise ValueError(f"emit must be 'both', 'mu' or 'z', got {emit!r}")
|
|
112
|
+
self.emit = emit
|
|
113
|
+
self.prefix = prefix
|
|
114
|
+
self._bodies = {} # key -> (state dict, pending Dist)
|
|
115
|
+
self._pending_out = None # features cached by learn_one
|
|
116
|
+
self._last_x = None
|
|
117
|
+
|
|
118
|
+
def _features(self, x):
|
|
119
|
+
"""Pure: read the pending predictives, never advance a body."""
|
|
120
|
+
out = {}
|
|
121
|
+
mu_p, z_p = self.prefix
|
|
122
|
+
for k, v in x.items():
|
|
123
|
+
if not _is_number(v):
|
|
124
|
+
out[k] = v
|
|
125
|
+
continue
|
|
126
|
+
entry = self._bodies.get(k)
|
|
127
|
+
pending = entry[1] if entry else None
|
|
128
|
+
if pending is None:
|
|
129
|
+
mu = float(v) if math.isfinite(v) else 0.0
|
|
130
|
+
z = 0.0
|
|
131
|
+
elif not math.isfinite(v):
|
|
132
|
+
mu, z = pending.mean, 0.0 # forecast-imputed, no news
|
|
133
|
+
else:
|
|
134
|
+
mu = pending.mean
|
|
135
|
+
u = pending.cdf(float(v))
|
|
136
|
+
u = min(max(u, _EPS), 1.0 - _EPS)
|
|
137
|
+
z = _STD_NORMAL.quantile(u)
|
|
138
|
+
if self.emit in ("both", "mu"):
|
|
139
|
+
out[mu_p + k] = mu
|
|
140
|
+
if self.emit in ("both", "z"):
|
|
141
|
+
out[z_p + k] = z
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
def learn_one(self, x):
|
|
145
|
+
self._pending_out = self._features(x)
|
|
146
|
+
self._last_x = dict(x)
|
|
147
|
+
f = _skater()
|
|
148
|
+
for k, v in x.items():
|
|
149
|
+
if not _is_number(v) or not math.isfinite(v):
|
|
150
|
+
continue # non-finite: no update
|
|
151
|
+
st, pending = self._bodies.get(k) or (None, None)
|
|
152
|
+
dists, st = f(_winsorize(float(v), pending), st)
|
|
153
|
+
self._bodies[k] = (st, dists[0])
|
|
154
|
+
|
|
155
|
+
def transform_one(self, x):
|
|
156
|
+
if self._pending_out is not None and x == self._last_x:
|
|
157
|
+
out, self._pending_out = self._pending_out, None
|
|
158
|
+
return out
|
|
159
|
+
return self._features(x)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class LaplaceTarget(base.Regressor):
|
|
163
|
+
"""Augment features with the TARGET stream's own (mean, surprise) pair.
|
|
164
|
+
|
|
165
|
+
Transformers never see y, so the target's calibrated history features
|
|
166
|
+
need a regressor wrapper, in the style of ``TargetStandardScaler``:
|
|
167
|
+
|
|
168
|
+
model = LaplaceTarget(
|
|
169
|
+
regressor=LaplaceFeatures()
|
|
170
|
+
| preprocessing.StandardScaler()
|
|
171
|
+
| linear_model.LinearRegression())
|
|
172
|
+
|
|
173
|
+
At prediction time the inner regressor receives, alongside x, the
|
|
174
|
+
body's forecast of the y it is about to predict (``mu_y``) and the
|
|
175
|
+
surprise of the previous y (``zy``). The same pre-update pair is used
|
|
176
|
+
at learn time, then the body consumes the new y. The target itself
|
|
177
|
+
stays raw: this wrapper never transforms y, it only adds features.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self, regressor, keys=("mu_y", "zy")):
|
|
181
|
+
self.regressor = regressor
|
|
182
|
+
self.keys = keys
|
|
183
|
+
self._state = None
|
|
184
|
+
self._pending = None # predictive Dist for the next y
|
|
185
|
+
self._zy = 0.0
|
|
186
|
+
|
|
187
|
+
def _augment(self, x):
|
|
188
|
+
mu_key, z_key = self.keys
|
|
189
|
+
mu = self._pending.mean if self._pending is not None else 0.0
|
|
190
|
+
return {**x, mu_key: mu, z_key: self._zy}
|
|
191
|
+
|
|
192
|
+
def predict_one(self, x):
|
|
193
|
+
return self.regressor.predict_one(self._augment(x))
|
|
194
|
+
|
|
195
|
+
def learn_one(self, x, y):
|
|
196
|
+
self.regressor.learn_one(self._augment(x), y)
|
|
197
|
+
y = float(y)
|
|
198
|
+
if math.isfinite(y):
|
|
199
|
+
# laplace is parade-wrapped: state["z"][0] is the surprise of
|
|
200
|
+
# this y against the predictive issued before it arrived
|
|
201
|
+
dists, self._state = _skater()(
|
|
202
|
+
_winsorize(y, self._pending), self._state)
|
|
203
|
+
self._pending = dists[0]
|
|
204
|
+
z = self._state["z"][0]
|
|
205
|
+
self._zy = z if z is not None else 0.0
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ice-skaters
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: skaters on a river: calibrated forecast features for streaming ML pipelines. Each numeric stream becomes what the forecaster expected and how surprising the value was.
|
|
5
|
+
Author-email: Peter Cotton <peter.cotton@microprediction.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/microprediction/ice-skaters
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: skaters>=0.12.1
|
|
12
|
+
Requires-Dist: river>=0.21
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# ice-skaters
|
|
16
|
+
|
|
17
|
+
[skaters](https://github.com/microprediction/skaters) on a
|
|
18
|
+
[river](https://riverml.xyz): calibrated forecast features for streaming
|
|
19
|
+
ML pipelines.
|
|
20
|
+
|
|
21
|
+
Every numeric stream is replaced by two scalars from its own online
|
|
22
|
+
Laplace forecaster: the predictive mean (what the forecaster expected
|
|
23
|
+
this value to be) and the standardized surprise z (how unexpected the
|
|
24
|
+
actual value was, bounded near |z| = 7 by construction). The mean
|
|
25
|
+
carries the level, the z carries the news. A wild observation can move
|
|
26
|
+
the pair only so far, and that bounded influence is where the robustness
|
|
27
|
+
comes from.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install ice-skaters
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from river import datasets, linear_model, metrics, preprocessing
|
|
35
|
+
from ice_skaters import LaplaceFeatures, LaplaceTarget
|
|
36
|
+
|
|
37
|
+
model = LaplaceTarget(
|
|
38
|
+
regressor=preprocessing.TargetStandardScaler(
|
|
39
|
+
regressor=LaplaceFeatures()
|
|
40
|
+
| preprocessing.StandardScaler()
|
|
41
|
+
| linear_model.LinearRegression()))
|
|
42
|
+
|
|
43
|
+
mae = metrics.MAE()
|
|
44
|
+
for x, y in datasets.TrumpApproval():
|
|
45
|
+
pred = model.predict_one(x)
|
|
46
|
+
mae.update(y, pred if pred is not None else 0.0)
|
|
47
|
+
model.learn_one(x, y)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`LaplaceFeatures` is a river Transformer for the input streams.
|
|
51
|
+
`LaplaceTarget` wraps any regressor, in the style of
|
|
52
|
+
`TargetStandardScaler`, to add the target's own (mean, surprise) pair,
|
|
53
|
+
which a transformer cannot do since it never sees y. The target itself
|
|
54
|
+
stays raw. Both estimators pipe, pickle and deep-copy like any river
|
|
55
|
+
estimator. Non-numeric values pass through untouched, and NaN is imputed
|
|
56
|
+
by the forecast itself with z = 0: the model receives "expected value,
|
|
57
|
+
no news" instead of a poisoned pipeline.
|
|
58
|
+
|
|
59
|
+
## Why
|
|
60
|
+
|
|
61
|
+
On TrumpApproval with river's recommended pipeline (progressive
|
|
62
|
+
validation MAE, burn-in 100, `examples/trump_approval.py`):
|
|
63
|
+
|
|
64
|
+
| | clean | 2% corrupted readings |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `StandardScaler` pipeline | 0.328 | 0.597 |
|
|
67
|
+
| + Laplace front-end | 0.382 | 0.407 |
|
|
68
|
+
|
|
69
|
+
The front-end pays a small toll on clean data and holds its footing when
|
|
70
|
+
the inputs misbehave. In controlled simulation the same substitution
|
|
71
|
+
beats raw features, a running z-score, a median/MAD winsorizer and a
|
|
72
|
+
Huberised loss 30/30 seeds under every contamination type tested, at a
|
|
73
|
+
small clean-data toll. Full protocols, numbers and the study design live
|
|
74
|
+
in the [timemachines](https://github.com/microprediction/timemachines)
|
|
75
|
+
repo, `benchmarks/RESULTS.md` section 6.
|
|
76
|
+
|
|
77
|
+
## Boundaries, stated plainly
|
|
78
|
+
|
|
79
|
+
- Distance-based learners (KNN) do not benefit: neighbour averaging is
|
|
80
|
+
already spike-robust and the extra dimensions degrade the metric.
|
|
81
|
+
- Entity-interleaved streams (many units multiplexed into one key) want
|
|
82
|
+
per-entity bodies; a single body per key is handicapped there.
|
|
83
|
+
- If your heavy tails are signal rather than noise, taming them costs
|
|
84
|
+
accuracy. Whether the extremes are informative decides the coordinates.
|
|
85
|
+
|
|
86
|
+
## Relation to the stack
|
|
87
|
+
|
|
88
|
+
`skaters` does one thing: fast univariate distributional forecasting,
|
|
89
|
+
stdlib-only, and this package is deliberately a thin adapter over it.
|
|
90
|
+
`timemachines` builds anomaly detection on the same calibrated surprise
|
|
91
|
+
streams. ice-skaters is the bridge from those streams to river's
|
|
92
|
+
estimator protocol, and nothing more.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/ice_skaters/__init__.py
|
|
5
|
+
src/ice_skaters.egg-info/PKG-INFO
|
|
6
|
+
src/ice_skaters.egg-info/SOURCES.txt
|
|
7
|
+
src/ice_skaters.egg-info/dependency_links.txt
|
|
8
|
+
src/ice_skaters.egg-info/requires.txt
|
|
9
|
+
src/ice_skaters.egg-info/top_level.txt
|
|
10
|
+
tests/test_ice_skaters.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ice_skaters
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Alignment, robustness, serialization."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import math
|
|
5
|
+
import pickle
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from river import linear_model, preprocessing
|
|
10
|
+
|
|
11
|
+
from ice_skaters import LaplaceFeatures, LaplaceTarget
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _pipeline():
|
|
15
|
+
return LaplaceTarget(
|
|
16
|
+
regressor=preprocessing.TargetStandardScaler(
|
|
17
|
+
regressor=LaplaceFeatures()
|
|
18
|
+
| preprocessing.StandardScaler()
|
|
19
|
+
| linear_model.LinearRegression()))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_predict_and_learn_see_identical_features():
|
|
23
|
+
"""river's Pipeline updates transformers before transforming in
|
|
24
|
+
learn_one; the cache must hand both paths the same features."""
|
|
25
|
+
seen = []
|
|
26
|
+
orig = LaplaceFeatures.transform_one
|
|
27
|
+
|
|
28
|
+
def spy(self, x):
|
|
29
|
+
out = orig(self, x)
|
|
30
|
+
seen.append(dict(out))
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
lf = LaplaceFeatures()
|
|
34
|
+
pipe = lf | preprocessing.StandardScaler() | linear_model.LinearRegression()
|
|
35
|
+
rng = random.Random(0)
|
|
36
|
+
LaplaceFeatures.transform_one = spy
|
|
37
|
+
try:
|
|
38
|
+
for _ in range(30):
|
|
39
|
+
x = {"a": rng.gauss(0, 1), "b": rng.gauss(5, 2)}
|
|
40
|
+
pipe.predict_one(x)
|
|
41
|
+
pipe.learn_one(x, rng.gauss(0, 1))
|
|
42
|
+
finally:
|
|
43
|
+
LaplaceFeatures.transform_one = orig
|
|
44
|
+
predict_path, learn_path = seen[0::2], seen[1::2]
|
|
45
|
+
assert predict_path == learn_path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_surprise_is_bounded_on_wild_spike():
|
|
49
|
+
lf = LaplaceFeatures()
|
|
50
|
+
for t in range(100):
|
|
51
|
+
lf.learn_one({"a": math.sin(t)})
|
|
52
|
+
out = lf.transform_one({"a": 1e9})
|
|
53
|
+
assert abs(out["z_a"]) < 7.1
|
|
54
|
+
assert math.isfinite(out["mu_a"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_non_finite_values_are_forecast_imputed_and_ignored():
|
|
58
|
+
lf = LaplaceFeatures()
|
|
59
|
+
for t in range(50):
|
|
60
|
+
lf.learn_one({"a": float(t % 5)})
|
|
61
|
+
out = lf.transform_one({"a": float("nan")})
|
|
62
|
+
assert math.isfinite(out["mu_a"]) and out["z_a"] == 0.0
|
|
63
|
+
lf.learn_one({"a": float("inf")}) # must not corrupt the body
|
|
64
|
+
out = lf.transform_one({"a": 1.0})
|
|
65
|
+
assert all(math.isfinite(v) for v in out.values())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_non_numeric_passthrough():
|
|
69
|
+
lf = LaplaceFeatures()
|
|
70
|
+
lf.learn_one({"a": 1.0, "tag": "x", "flag": True})
|
|
71
|
+
out = lf.transform_one({"a": 1.1, "tag": "x", "flag": True})
|
|
72
|
+
assert out["tag"] == "x" and out["flag"] is True
|
|
73
|
+
assert set(out) == {"tag", "flag", "mu_a", "z_a"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_emit_modes_and_validation():
|
|
77
|
+
assert set(LaplaceFeatures(emit="mu")._features({"a": 1.0})) == {"mu_a"}
|
|
78
|
+
assert set(LaplaceFeatures(emit="z")._features({"a": 1.0})) == {"z_a"}
|
|
79
|
+
with pytest.raises(ValueError):
|
|
80
|
+
LaplaceFeatures(emit="nope")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_full_model_pickles_and_deepcopies():
|
|
84
|
+
model = _pipeline()
|
|
85
|
+
rng = random.Random(1)
|
|
86
|
+
for _ in range(40):
|
|
87
|
+
x = {"a": rng.gauss(0, 1)}
|
|
88
|
+
model.predict_one(x)
|
|
89
|
+
model.learn_one(x, rng.gauss(0, 1))
|
|
90
|
+
probe = {"a": 0.3}
|
|
91
|
+
expected = model.predict_one(probe)
|
|
92
|
+
assert pickle.loads(pickle.dumps(model)).predict_one(probe) == expected
|
|
93
|
+
assert copy.deepcopy(model).predict_one(probe) == expected
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_keys_may_appear_and_vanish_mid_stream():
|
|
97
|
+
lf = LaplaceFeatures()
|
|
98
|
+
lf.learn_one({"a": 1.0})
|
|
99
|
+
lf.learn_one({"a": 1.1, "c": 9.0})
|
|
100
|
+
out = lf.transform_one({"c": 9.1})
|
|
101
|
+
assert set(out) == {"mu_c", "z_c"}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_target_wrapper_learns_and_predicts_finite():
|
|
105
|
+
model = _pipeline()
|
|
106
|
+
rng = random.Random(2)
|
|
107
|
+
preds = []
|
|
108
|
+
for t in range(120):
|
|
109
|
+
x = {"a": rng.gauss(0, 1)}
|
|
110
|
+
p = model.predict_one(x)
|
|
111
|
+
preds.append(p if p is not None else 0.0)
|
|
112
|
+
model.learn_one(x, 0.5 * x["a"] + rng.gauss(0, 0.1))
|
|
113
|
+
assert all(math.isfinite(p) for p in preds)
|