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.
- skaters-0.1.0/PKG-INFO +152 -0
- skaters-0.1.0/README.md +140 -0
- skaters-0.1.0/pyproject.toml +23 -0
- skaters-0.1.0/src/skaters/__init__.py +23 -0
- skaters-0.1.0/src/skaters/api.py +43 -0
- skaters-0.1.0/src/skaters/conventions.py +23 -0
- skaters-0.1.0/src/skaters/ema.py +28 -0
- skaters-0.1.0/src/skaters/ensemble.py +74 -0
- skaters-0.1.0/src/skaters/envelope.py +88 -0
- skaters-0.1.0/src/skaters/parade.py +53 -0
- skaters-0.1.0/src/skaters/py.typed +0 -0
- skaters-0.1.0/src/skaters/runstats.py +48 -0
skaters-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skaters
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fast univariate time series models that run in Pyodide
|
|
5
|
+
Author: Peter Cotton
|
|
6
|
+
Author-email: Peter Cotton <peter.cotton@microprediction.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# skaters
|
|
14
|
+
|
|
15
|
+
Fast univariate online time series models. Zero dependencies. Runs in [Pyodide](https://pyodide.org/).
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install skaters
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from skaters import ensemble, envelope
|
|
27
|
+
|
|
28
|
+
# Create a precision-weighted ensemble of EMA models
|
|
29
|
+
f = ensemble(k=3) # predict 3 steps ahead
|
|
30
|
+
|
|
31
|
+
state = None
|
|
32
|
+
for y in observations:
|
|
33
|
+
x, state = f(y, state)
|
|
34
|
+
# x = [float, float, float] (forecasts for horizons 1, 2, 3)
|
|
35
|
+
|
|
36
|
+
# Want uncertainty bands? Wrap any skater with an envelope:
|
|
37
|
+
f = envelope(ensemble(k=3), k=3)
|
|
38
|
+
|
|
39
|
+
state = None
|
|
40
|
+
for y in observations:
|
|
41
|
+
out, state = f(y, state)
|
|
42
|
+
# out["mean"] = point forecasts
|
|
43
|
+
# out["std"] = empirical std of forecast error per horizon
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## The skater convention
|
|
47
|
+
|
|
48
|
+
A skater is any callable with this signature:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
x, state = f(y, state)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Argument | Type | Description |
|
|
55
|
+
|----------|------|-------------|
|
|
56
|
+
| `y` | `float` | New observation |
|
|
57
|
+
| `state` | `dict \| None` | Prior state (`None` on first call) |
|
|
58
|
+
| **Returns** | | |
|
|
59
|
+
| `x` | `list[float]` | Point forecasts for horizons 1..k |
|
|
60
|
+
| `state` | `dict` | Updated state (pass back next call) |
|
|
61
|
+
|
|
62
|
+
Skaters only predict. Uncertainty is handled separately by the [envelope](#envelope).
|
|
63
|
+
|
|
64
|
+
## Built-in skaters
|
|
65
|
+
|
|
66
|
+
### EMA
|
|
67
|
+
|
|
68
|
+
Exponential moving average. O(1) per observation.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from skaters import ema
|
|
72
|
+
|
|
73
|
+
f = ema(alpha=0.1, k=1)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Convenience constructors
|
|
77
|
+
|
|
78
|
+
Pre-configured EMA speeds:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from skaters import rapidly, quickly, slowly, sluggishly
|
|
82
|
+
|
|
83
|
+
f = rapidly(k=1) # alpha=0.3
|
|
84
|
+
f = quickly(k=1) # alpha=0.1
|
|
85
|
+
f = slowly(k=1) # alpha=0.05
|
|
86
|
+
f = sluggishly(k=1) # alpha=0.01
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Precision-weighted ensemble
|
|
90
|
+
|
|
91
|
+
Combines multiple skaters, weighting each by 1/MSE of its forecast errors. Automatically favors models that are both accurate and unbiased.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from skaters import ensemble, precision_weighted_ensemble, ema
|
|
95
|
+
|
|
96
|
+
# Default: ensemble of EMAs at different speeds
|
|
97
|
+
f = ensemble(k=3)
|
|
98
|
+
|
|
99
|
+
# Custom: bring your own skaters
|
|
100
|
+
f = precision_weighted_ensemble(
|
|
101
|
+
skaters=[ema(alpha=0.05, k=3), ema(alpha=0.2, k=3)],
|
|
102
|
+
k=3,
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Envelope
|
|
107
|
+
|
|
108
|
+
The envelope wraps any skater and tracks empirical forecast errors at each horizon. It is model-independent — it doesn't care how the predictions are made.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from skaters import envelope, ema
|
|
112
|
+
|
|
113
|
+
# Welford's (uniform weight over all history)
|
|
114
|
+
f = envelope(ema(alpha=0.1, k=3), k=3)
|
|
115
|
+
|
|
116
|
+
# Exponentially weighted (forgets old errors, adapts to regime changes)
|
|
117
|
+
f = envelope(ema(alpha=0.1, k=3), k=3, decay=0.95)
|
|
118
|
+
|
|
119
|
+
state = None
|
|
120
|
+
for y in observations:
|
|
121
|
+
out, state = f(y, state)
|
|
122
|
+
print(out["mean"], out["std"])
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Writing your own skater
|
|
126
|
+
|
|
127
|
+
Any function matching the convention works:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
def my_skater(y: float, state: dict | None) -> tuple[list[float], dict]:
|
|
131
|
+
if state is None:
|
|
132
|
+
state = {"last": y}
|
|
133
|
+
state["last"] = y
|
|
134
|
+
return [state["last"]], state # predict last value for k=1
|
|
135
|
+
|
|
136
|
+
# Use it standalone or in an ensemble:
|
|
137
|
+
from skaters import envelope, precision_weighted_ensemble
|
|
138
|
+
|
|
139
|
+
f = envelope(my_skater, k=1)
|
|
140
|
+
f = precision_weighted_ensemble([my_skater, ema(alpha=0.1, k=1)], k=1)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Design
|
|
144
|
+
|
|
145
|
+
- **Online only** — O(1) per observation, no batch recomputation
|
|
146
|
+
- **Prediction and uncertainty are separate** — skaters predict, envelopes estimate error
|
|
147
|
+
- **Pure Python** — zero dependencies, runs anywhere Python runs
|
|
148
|
+
- **Pyodide compatible** — works in the browser via WebAssembly
|
|
149
|
+
|
|
150
|
+
## Lineage
|
|
151
|
+
|
|
152
|
+
This package distills ideas from [timemachines](https://github.com/microprediction/timemachines), which provided a common skater interface for dozens of time series packages. This is a from-scratch rewrite focused on speed, simplicity, and browser compatibility.
|
skaters-0.1.0/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# skaters
|
|
2
|
+
|
|
3
|
+
Fast univariate online time series models. Zero dependencies. Runs in [Pyodide](https://pyodide.org/).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install skaters
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from skaters import ensemble, envelope
|
|
15
|
+
|
|
16
|
+
# Create a precision-weighted ensemble of EMA models
|
|
17
|
+
f = ensemble(k=3) # predict 3 steps ahead
|
|
18
|
+
|
|
19
|
+
state = None
|
|
20
|
+
for y in observations:
|
|
21
|
+
x, state = f(y, state)
|
|
22
|
+
# x = [float, float, float] (forecasts for horizons 1, 2, 3)
|
|
23
|
+
|
|
24
|
+
# Want uncertainty bands? Wrap any skater with an envelope:
|
|
25
|
+
f = envelope(ensemble(k=3), k=3)
|
|
26
|
+
|
|
27
|
+
state = None
|
|
28
|
+
for y in observations:
|
|
29
|
+
out, state = f(y, state)
|
|
30
|
+
# out["mean"] = point forecasts
|
|
31
|
+
# out["std"] = empirical std of forecast error per horizon
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## The skater convention
|
|
35
|
+
|
|
36
|
+
A skater is any callable with this signature:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
x, state = f(y, state)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
| Argument | Type | Description |
|
|
43
|
+
|----------|------|-------------|
|
|
44
|
+
| `y` | `float` | New observation |
|
|
45
|
+
| `state` | `dict \| None` | Prior state (`None` on first call) |
|
|
46
|
+
| **Returns** | | |
|
|
47
|
+
| `x` | `list[float]` | Point forecasts for horizons 1..k |
|
|
48
|
+
| `state` | `dict` | Updated state (pass back next call) |
|
|
49
|
+
|
|
50
|
+
Skaters only predict. Uncertainty is handled separately by the [envelope](#envelope).
|
|
51
|
+
|
|
52
|
+
## Built-in skaters
|
|
53
|
+
|
|
54
|
+
### EMA
|
|
55
|
+
|
|
56
|
+
Exponential moving average. O(1) per observation.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from skaters import ema
|
|
60
|
+
|
|
61
|
+
f = ema(alpha=0.1, k=1)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Convenience constructors
|
|
65
|
+
|
|
66
|
+
Pre-configured EMA speeds:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from skaters import rapidly, quickly, slowly, sluggishly
|
|
70
|
+
|
|
71
|
+
f = rapidly(k=1) # alpha=0.3
|
|
72
|
+
f = quickly(k=1) # alpha=0.1
|
|
73
|
+
f = slowly(k=1) # alpha=0.05
|
|
74
|
+
f = sluggishly(k=1) # alpha=0.01
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Precision-weighted ensemble
|
|
78
|
+
|
|
79
|
+
Combines multiple skaters, weighting each by 1/MSE of its forecast errors. Automatically favors models that are both accurate and unbiased.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from skaters import ensemble, precision_weighted_ensemble, ema
|
|
83
|
+
|
|
84
|
+
# Default: ensemble of EMAs at different speeds
|
|
85
|
+
f = ensemble(k=3)
|
|
86
|
+
|
|
87
|
+
# Custom: bring your own skaters
|
|
88
|
+
f = precision_weighted_ensemble(
|
|
89
|
+
skaters=[ema(alpha=0.05, k=3), ema(alpha=0.2, k=3)],
|
|
90
|
+
k=3,
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Envelope
|
|
95
|
+
|
|
96
|
+
The envelope wraps any skater and tracks empirical forecast errors at each horizon. It is model-independent — it doesn't care how the predictions are made.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from skaters import envelope, ema
|
|
100
|
+
|
|
101
|
+
# Welford's (uniform weight over all history)
|
|
102
|
+
f = envelope(ema(alpha=0.1, k=3), k=3)
|
|
103
|
+
|
|
104
|
+
# Exponentially weighted (forgets old errors, adapts to regime changes)
|
|
105
|
+
f = envelope(ema(alpha=0.1, k=3), k=3, decay=0.95)
|
|
106
|
+
|
|
107
|
+
state = None
|
|
108
|
+
for y in observations:
|
|
109
|
+
out, state = f(y, state)
|
|
110
|
+
print(out["mean"], out["std"])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Writing your own skater
|
|
114
|
+
|
|
115
|
+
Any function matching the convention works:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
def my_skater(y: float, state: dict | None) -> tuple[list[float], dict]:
|
|
119
|
+
if state is None:
|
|
120
|
+
state = {"last": y}
|
|
121
|
+
state["last"] = y
|
|
122
|
+
return [state["last"]], state # predict last value for k=1
|
|
123
|
+
|
|
124
|
+
# Use it standalone or in an ensemble:
|
|
125
|
+
from skaters import envelope, precision_weighted_ensemble
|
|
126
|
+
|
|
127
|
+
f = envelope(my_skater, k=1)
|
|
128
|
+
f = precision_weighted_ensemble([my_skater, ema(alpha=0.1, k=1)], k=1)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Design
|
|
132
|
+
|
|
133
|
+
- **Online only** — O(1) per observation, no batch recomputation
|
|
134
|
+
- **Prediction and uncertainty are separate** — skaters predict, envelopes estimate error
|
|
135
|
+
- **Pure Python** — zero dependencies, runs anywhere Python runs
|
|
136
|
+
- **Pyodide compatible** — works in the browser via WebAssembly
|
|
137
|
+
|
|
138
|
+
## Lineage
|
|
139
|
+
|
|
140
|
+
This package distills ideas from [timemachines](https://github.com/microprediction/timemachines), which provided a common skater interface for dozens of time series packages. This is a from-scratch rewrite focused on speed, simplicity, and browser compatibility.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "skaters"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Fast univariate time series models that run in Pyodide"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Peter Cotton", email = "peter.cotton@microprediction.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
dev = ["pytest"]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.10.12,<0.11.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=9.0.2",
|
|
23
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Fast univariate online time series models that run in Pyodide."""
|
|
2
|
+
|
|
3
|
+
from skaters.conventions import Skater
|
|
4
|
+
from skaters.ema import ema
|
|
5
|
+
from skaters.envelope import envelope
|
|
6
|
+
from skaters.ensemble import precision_weighted_ensemble
|
|
7
|
+
from skaters.api import (
|
|
8
|
+
quickly, slowly, sluggishly, rapidly,
|
|
9
|
+
ensemble, ensemble_with_envelope,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Skater",
|
|
14
|
+
"ema",
|
|
15
|
+
"envelope",
|
|
16
|
+
"precision_weighted_ensemble",
|
|
17
|
+
"quickly",
|
|
18
|
+
"slowly",
|
|
19
|
+
"sluggishly",
|
|
20
|
+
"rapidly",
|
|
21
|
+
"ensemble",
|
|
22
|
+
"ensemble_with_envelope",
|
|
23
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Convenience constructors for common skater configurations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from skaters.ema import ema
|
|
5
|
+
from skaters.ensemble import precision_weighted_ensemble
|
|
6
|
+
from skaters.envelope import envelope
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def rapidly(k: int = 1):
|
|
10
|
+
"""EMA with alpha=0.3 — reacts fast to changes."""
|
|
11
|
+
return ema(alpha=0.3, k=k)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def quickly(k: int = 1):
|
|
15
|
+
"""EMA with alpha=0.1 — moderate reactivity."""
|
|
16
|
+
return ema(alpha=0.1, k=k)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def slowly(k: int = 1):
|
|
20
|
+
"""EMA with alpha=0.05 — smooth, slow-moving."""
|
|
21
|
+
return ema(alpha=0.05, k=k)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sluggishly(k: int = 1):
|
|
25
|
+
"""EMA with alpha=0.01 — very slow adaptation."""
|
|
26
|
+
return ema(alpha=0.01, k=k)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensemble(k: int = 1):
|
|
30
|
+
"""Precision-weighted ensemble of EMA models at different speeds."""
|
|
31
|
+
return precision_weighted_ensemble(
|
|
32
|
+
skaters=[rapidly(k), quickly(k), slowly(k), sluggishly(k)],
|
|
33
|
+
k=k,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ensemble_with_envelope(k: int = 1, decay: float | None = None):
|
|
38
|
+
"""Precision-weighted ensemble wrapped with an empirical error envelope.
|
|
39
|
+
|
|
40
|
+
This is the recommended default — good predictions with calibrated
|
|
41
|
+
uncertainty bands.
|
|
42
|
+
"""
|
|
43
|
+
return envelope(ensemble(k=k), k=k, decay=decay)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Skater convention: an online univariate model is a callable:
|
|
2
|
+
|
|
3
|
+
x, state = f(y, state)
|
|
4
|
+
|
|
5
|
+
where:
|
|
6
|
+
y: float - new observation
|
|
7
|
+
state: dict | None - prior state (None on first call)
|
|
8
|
+
x: list[float] - point forecasts for horizons 1..k
|
|
9
|
+
state: dict - updated state (pass back next call)
|
|
10
|
+
|
|
11
|
+
Skaters only predict. Uncertainty estimation is handled separately
|
|
12
|
+
by the Envelope (see envelope.py), which wraps any skater and tracks
|
|
13
|
+
empirical forecast errors per horizon.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
from typing import Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class Skater(Protocol):
|
|
22
|
+
def __call__(self, y: float, state: dict | None) -> tuple[list[float], dict]:
|
|
23
|
+
...
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Exponential moving average skater.
|
|
2
|
+
|
|
3
|
+
Pure online, O(1) per observation. Returns only point forecasts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ema(alpha: float = 0.05, k: int = 1):
|
|
10
|
+
"""Create an EMA skater.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
alpha: smoothing factor in (0,1). Small = slow, large = fast.
|
|
14
|
+
k: forecast horizon (number of steps ahead).
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
A skater callable: (y, state) -> (list[float], state)
|
|
18
|
+
"""
|
|
19
|
+
assert 0 < alpha < 1
|
|
20
|
+
|
|
21
|
+
def _skater(y: float, state: dict | None) -> tuple[list[float], dict]:
|
|
22
|
+
if state is None:
|
|
23
|
+
return [y] * k, {"level": y}
|
|
24
|
+
level = state["level"] + alpha * (y - state["level"])
|
|
25
|
+
return [level] * k, {"level": level}
|
|
26
|
+
|
|
27
|
+
_skater.__name__ = f"ema(alpha={alpha}, k={k})"
|
|
28
|
+
return _skater
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Precision-weighted ensemble of skaters.
|
|
2
|
+
|
|
3
|
+
Combines multiple online skaters by weighting inversely proportional
|
|
4
|
+
to their empirical forecast variance. Each sub-model's errors are
|
|
5
|
+
tracked internally via parade-style queues.
|
|
6
|
+
|
|
7
|
+
The ensemble itself is a skater — it returns list[float] predictions.
|
|
8
|
+
Wrap it with an envelope if you also want uncertainty bands.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import math
|
|
13
|
+
from collections import deque
|
|
14
|
+
from skaters.runstats import running_var_init, running_var_update, running_mse_get
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def precision_weighted_ensemble(skaters: list, k: int = 1, floor: float = 1e-6):
|
|
18
|
+
"""Create a precision-weighted ensemble skater.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
skaters: list of skater callables
|
|
22
|
+
k: forecast horizon
|
|
23
|
+
floor: minimum precision to prevent zero-weight
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A skater callable: (y, state) -> (list[float], state)
|
|
27
|
+
"""
|
|
28
|
+
n = len(skaters)
|
|
29
|
+
assert n > 0
|
|
30
|
+
|
|
31
|
+
def _skater(y: float, state: dict | None) -> tuple[list[float], dict]:
|
|
32
|
+
if state is None:
|
|
33
|
+
state = {
|
|
34
|
+
"sub": [None] * n,
|
|
35
|
+
"queues": [[deque() for _ in range(k)] for _ in range(n)],
|
|
36
|
+
"stats": [[running_var_init() for _ in range(k)] for _ in range(n)],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Run all sub-models and collect predictions
|
|
40
|
+
preds = []
|
|
41
|
+
for i, f in enumerate(skaters):
|
|
42
|
+
x_i, state["sub"][i] = f(y, state["sub"][i])
|
|
43
|
+
preds.append(x_i)
|
|
44
|
+
|
|
45
|
+
# Resolve pending predictions and update error stats
|
|
46
|
+
for i in range(n):
|
|
47
|
+
for h in range(k):
|
|
48
|
+
q = state["queues"][i][h]
|
|
49
|
+
if q:
|
|
50
|
+
error = y - q.popleft()
|
|
51
|
+
state["stats"][i][h] = running_var_update(state["stats"][i][h], error)
|
|
52
|
+
state["queues"][i][h].append(preds[i][h])
|
|
53
|
+
|
|
54
|
+
# Precision-weighted combination per horizon (weight by 1/MSE)
|
|
55
|
+
combined = []
|
|
56
|
+
for h in range(k):
|
|
57
|
+
weights = []
|
|
58
|
+
for i in range(n):
|
|
59
|
+
mse = running_mse_get(state["stats"][i][h])
|
|
60
|
+
if math.isfinite(mse) and mse > 0:
|
|
61
|
+
w = 1.0 / mse
|
|
62
|
+
else:
|
|
63
|
+
w = floor
|
|
64
|
+
weights.append(max(w, floor))
|
|
65
|
+
|
|
66
|
+
w_total = sum(weights)
|
|
67
|
+
combined.append(
|
|
68
|
+
sum(w * preds[i][h] for i, w in enumerate(weights)) / w_total
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return combined, state
|
|
72
|
+
|
|
73
|
+
_skater.__name__ = f"precision_weighted_ensemble(n={n}, k={k})"
|
|
74
|
+
return _skater
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Model-independent empirical prediction envelope.
|
|
2
|
+
|
|
3
|
+
Wraps any skater and tracks forecast errors at each horizon using
|
|
4
|
+
online statistics. Provides regularized std estimates that can be
|
|
5
|
+
used as confidence bands.
|
|
6
|
+
|
|
7
|
+
The envelope is the sole source of uncertainty — skaters just predict.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import math
|
|
12
|
+
from collections import deque
|
|
13
|
+
from skaters.runstats import running_var_init, running_var_update, running_std_get
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def envelope(skater, k: int = 1, decay: float | None = None):
|
|
17
|
+
"""Wrap a skater with an empirical error envelope.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
skater: any skater callable (y, state) -> (list[float], state)
|
|
21
|
+
k: forecast horizon (must match the skater's k)
|
|
22
|
+
decay: if set, use exponentially weighted variance with this
|
|
23
|
+
decay factor (0 < decay < 1, smaller = faster forgetting).
|
|
24
|
+
If None, use Welford's (uniform weighting over all history).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A callable: (y, state) -> (x_dict, state) where x_dict contains
|
|
28
|
+
"mean" (point forecasts) and "std" (empirical error std per horizon).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def _enveloped(y: float, state: dict | None) -> tuple[dict, dict]:
|
|
32
|
+
if state is None:
|
|
33
|
+
state = {
|
|
34
|
+
"inner": None,
|
|
35
|
+
"queues": [deque() for _ in range(k)],
|
|
36
|
+
"error_stats": [running_var_init() for _ in range(k)],
|
|
37
|
+
}
|
|
38
|
+
if decay is not None:
|
|
39
|
+
state["ew_var"] = [{"mean": 0.0, "var": 0.0, "n": 0} for _ in range(k)]
|
|
40
|
+
|
|
41
|
+
# Run the inner skater
|
|
42
|
+
x, state["inner"] = skater(y, state["inner"])
|
|
43
|
+
|
|
44
|
+
# Resolve pending predictions against this observation
|
|
45
|
+
queues = state["queues"]
|
|
46
|
+
error_stats = state["error_stats"]
|
|
47
|
+
for h in range(k):
|
|
48
|
+
if queues[h]:
|
|
49
|
+
predicted = queues[h].popleft()
|
|
50
|
+
error = y - predicted
|
|
51
|
+
if decay is not None:
|
|
52
|
+
_ew_update(state["ew_var"][h], error, decay)
|
|
53
|
+
else:
|
|
54
|
+
error_stats[h] = running_var_update(error_stats[h], error)
|
|
55
|
+
|
|
56
|
+
# Enqueue new predictions
|
|
57
|
+
for h in range(k):
|
|
58
|
+
queues[h].append(x[h])
|
|
59
|
+
|
|
60
|
+
# Compute std
|
|
61
|
+
if decay is not None:
|
|
62
|
+
std = [_ew_std(state["ew_var"][h]) for h in range(k)]
|
|
63
|
+
else:
|
|
64
|
+
std = [running_std_get(s) for s in error_stats]
|
|
65
|
+
|
|
66
|
+
return {"mean": x, "std": std}, state
|
|
67
|
+
|
|
68
|
+
_enveloped.__name__ = f"envelope({getattr(skater, '__name__', '?')})"
|
|
69
|
+
return _enveloped
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ew_update(ew: dict, x: float, decay: float) -> None:
|
|
73
|
+
"""Exponentially weighted online variance update (in-place)."""
|
|
74
|
+
ew["n"] += 1
|
|
75
|
+
if ew["n"] == 1:
|
|
76
|
+
ew["mean"] = x
|
|
77
|
+
ew["var"] = 0.0
|
|
78
|
+
return
|
|
79
|
+
diff = x - ew["mean"]
|
|
80
|
+
ew["mean"] = decay * ew["mean"] + (1 - decay) * x
|
|
81
|
+
ew["var"] = decay * (ew["var"] + (1 - decay) * diff * diff)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _ew_std(ew: dict) -> float:
|
|
85
|
+
"""Get std from exponentially weighted state."""
|
|
86
|
+
if ew["n"] < 2:
|
|
87
|
+
return float("inf")
|
|
88
|
+
return math.sqrt(ew["var"]) if ew["var"] > 0 else float("inf")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Parade: track forecast errors at each horizon incrementally.
|
|
2
|
+
|
|
3
|
+
A "parade" maintains k queues of pending predictions. When a new
|
|
4
|
+
observation arrives, it resolves the oldest prediction at each horizon,
|
|
5
|
+
computes the error, and updates running statistics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from collections import deque
|
|
10
|
+
from skaters.runstats import running_var_init, running_var_update, running_std_get
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parade_init(k: int) -> dict:
|
|
14
|
+
"""Initialize parade state for k horizons."""
|
|
15
|
+
return {
|
|
16
|
+
"k": k,
|
|
17
|
+
"queues": [deque() for _ in range(k)],
|
|
18
|
+
"error_stats": [running_var_init() for _ in range(k)],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parade_update(state: dict, x: list[float], y: float) -> dict:
|
|
23
|
+
"""Record new predictions x[0..k-1] and resolve against observation y.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
state: parade state
|
|
27
|
+
x: predictions for horizons 1..k
|
|
28
|
+
y: the observation that just arrived
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
updated state (std available via parade_std)
|
|
32
|
+
"""
|
|
33
|
+
k = state["k"]
|
|
34
|
+
queues = state["queues"]
|
|
35
|
+
error_stats = state["error_stats"]
|
|
36
|
+
|
|
37
|
+
# Resolve: the observation y was predicted h steps ago by queue[h]
|
|
38
|
+
for h in range(k):
|
|
39
|
+
if queues[h]:
|
|
40
|
+
predicted = queues[h].popleft()
|
|
41
|
+
error = y - predicted
|
|
42
|
+
error_stats[h] = running_var_update(error_stats[h], error)
|
|
43
|
+
|
|
44
|
+
# Enqueue new predictions
|
|
45
|
+
for h in range(k):
|
|
46
|
+
queues[h].append(x[h])
|
|
47
|
+
|
|
48
|
+
return state
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parade_std(state: dict) -> list[float]:
|
|
52
|
+
"""Return current std estimate at each horizon."""
|
|
53
|
+
return [running_std_get(s) for s in state["error_stats"]]
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Lightweight online running statistics (pure Python, no dependencies).
|
|
2
|
+
|
|
3
|
+
Welford's algorithm for mean/variance, plus exponentially weighted variants.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def running_var_init() -> dict:
|
|
11
|
+
"""Initialize state for Welford's online variance."""
|
|
12
|
+
return {"n": 0, "mean": 0.0, "m2": 0.0}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def running_var_update(state: dict, x: float) -> dict:
|
|
16
|
+
"""Update running mean/variance with a new observation (Welford)."""
|
|
17
|
+
n = state["n"] + 1
|
|
18
|
+
delta = x - state["mean"]
|
|
19
|
+
mean = state["mean"] + delta / n
|
|
20
|
+
delta2 = x - mean
|
|
21
|
+
m2 = state["m2"] + delta * delta2
|
|
22
|
+
return {"n": n, "mean": mean, "m2": m2}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def running_var_get(state: dict) -> tuple[float, float]:
|
|
26
|
+
"""Return (mean, variance) from running stats state."""
|
|
27
|
+
if state["n"] < 2:
|
|
28
|
+
return state["mean"], float("inf")
|
|
29
|
+
return state["mean"], state["m2"] / (state["n"] - 1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def running_std_get(state: dict) -> float:
|
|
33
|
+
"""Return standard deviation from running stats state."""
|
|
34
|
+
_, var = running_var_get(state)
|
|
35
|
+
return math.sqrt(var) if math.isfinite(var) else float("inf")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def running_mse_get(state: dict) -> float:
|
|
39
|
+
"""Return mean squared error (second moment) from running stats of errors.
|
|
40
|
+
|
|
41
|
+
MSE = bias² + variance, so it penalizes both systematic and random error.
|
|
42
|
+
"""
|
|
43
|
+
if state["n"] < 1:
|
|
44
|
+
return float("inf")
|
|
45
|
+
mean, var = running_var_get(state)
|
|
46
|
+
if not math.isfinite(var):
|
|
47
|
+
return float("inf")
|
|
48
|
+
return mean * mean + var
|