forelint 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.
- forelint-0.1.0/.gitignore +7 -0
- forelint-0.1.0/LICENSE +21 -0
- forelint-0.1.0/PKG-INFO +125 -0
- forelint-0.1.0/README.md +97 -0
- forelint-0.1.0/benchmarks/m5_benchmark.py +96 -0
- forelint-0.1.0/pyproject.toml +47 -0
- forelint-0.1.0/src/forelint/__init__.py +23 -0
- forelint-0.1.0/src/forelint/audit.py +122 -0
- forelint-0.1.0/src/forelint/compare.py +151 -0
- forelint-0.1.0/src/forelint/noise.py +58 -0
- forelint-0.1.0/src/forelint/optimism.py +73 -0
- forelint-0.1.0/src/forelint/splits.py +108 -0
- forelint-0.1.0/tests/test_forelint.py +176 -0
forelint-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Mashiah
|
|
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.
|
forelint-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: forelint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lint your forecast backtests: leakage between related series, the run-to-run noise floor, model comparison tests, and per-item bias before you trust a score.
|
|
5
|
+
Project-URL: Homepage, https://github.com/davidmashiah/forelint
|
|
6
|
+
Project-URL: Source, https://github.com/davidmashiah/forelint
|
|
7
|
+
Project-URL: Author, https://davidmashiah.com/
|
|
8
|
+
Author: David Mashiah
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: backtesting,data leakage,demand forecasting,diebold-mariano,forecast bias,forecast evaluation,forecast value added,forecasting,group k-fold,model confidence set,time series cross-validation,tracking signal
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: numpy>=1.22
|
|
21
|
+
Requires-Dist: pandas>=1.5
|
|
22
|
+
Requires-Dist: scikit-learn>=1.2
|
|
23
|
+
Requires-Dist: scipy>=1.9
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: arch>=6; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# forelint
|
|
30
|
+
|
|
31
|
+
**Lint your forecast backtests before you trust a score.**
|
|
32
|
+
|
|
33
|
+
forelint checks the things a backtest score hides: leakage between related
|
|
34
|
+
series, the run-to-run noise floor, whether model A is really better than
|
|
35
|
+
model B, and which items carry the bias and the error. It works on the
|
|
36
|
+
long-format tables that statsforecast, mlforecast and most forecasting code
|
|
37
|
+
already produce, and on any scikit-learn estimator.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install forelint
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## What it checks
|
|
44
|
+
|
|
45
|
+
| Question | Function |
|
|
46
|
+
|---|---|
|
|
47
|
+
| Does my validation leak through related stores, products or regions? | `split_optimism`, `GroupTimeSplit` |
|
|
48
|
+
| Is this improvement bigger than seed-to-seed noise? | `noise_floor` |
|
|
49
|
+
| Is model A really better than B across many series? | `dm_test`, `diebold_mariano`, `model_confidence_set` |
|
|
50
|
+
| Which items are always over or under forecast? Does the model beat a naive forecast? Where does the error live? | `audit` |
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import forelint as fl
|
|
56
|
+
|
|
57
|
+
# df: one row per item and date, the actual in "y", one column per model,
|
|
58
|
+
# e.g. the output of statsforecast's cross_validation()
|
|
59
|
+
report = fl.audit(df, models=["LightGBM", "ETS"], baseline="SeasonalNaive")
|
|
60
|
+
print(report)
|
|
61
|
+
|
|
62
|
+
# Is LightGBM really better than the seasonal naive, or is it noise?
|
|
63
|
+
print(fl.dm_test(df, "LightGBM", "SeasonalNaive"))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
# How much does random K-fold flatter the model, compared with holding out
|
|
68
|
+
# the future of entities the model never saw?
|
|
69
|
+
fl.split_optimism(model, X, y, groups=store_ids, time=dates)
|
|
70
|
+
|
|
71
|
+
# What difference between two runs is just noise?
|
|
72
|
+
nf = fl.noise_floor(lambda seed: train_and_score(seed), seeds=range(10))
|
|
73
|
+
nf.is_meaningful(new_mae - old_mae)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## On real data: one Walmart store in M5
|
|
77
|
+
|
|
78
|
+
`benchmarks/m5_benchmark.py` runs every check on the public M5 data: store
|
|
79
|
+
CA_1, department FOODS_3, 823 items, the last 365 days, a direct 28-day-ahead
|
|
80
|
+
gradient-boosting model from scikit-learn, scored on the final 28 days.
|
|
81
|
+
Measured, not assumed:
|
|
82
|
+
|
|
83
|
+
- **The model added nothing over a 28-day moving average.** Forecast value
|
|
84
|
+
added was **−0.1%** on MAE, the model was worse than the moving average on
|
|
85
|
+
**49% of items**, and Diebold-Mariano gives **p = 0.31**. The model
|
|
86
|
+
confidence set keeps both. An average-MAE table alone would not show this.
|
|
87
|
+
- **It under-forecasts by 9.3%** of actual sales.
|
|
88
|
+
- **10% of items hold 73% of the squared error.**
|
|
89
|
+
- **The noise floor is small here:** over 10 seeds the MAE sd is 0.0014, so
|
|
90
|
+
any change under 0.0045 is noise.
|
|
91
|
+
- **Splitting barely mattered for this data:** random K-fold was 1% optimistic
|
|
92
|
+
against a time split. Leakage depends on the features; forelint measures it
|
|
93
|
+
rather than assuming it.
|
|
94
|
+
|
|
95
|
+
The model is deliberately plain (six features, no tuning). The point is the
|
|
96
|
+
audit, not the model.
|
|
97
|
+
|
|
98
|
+
## Why
|
|
99
|
+
|
|
100
|
+
In an international machine-learning competition on well-geology prediction,
|
|
101
|
+
validation ranked five builds against the hidden leaderboard with a
|
|
102
|
+
correlation of −0.745: the build it liked most scored worst, and identical code scored 6.241, 6.259 and
|
|
103
|
+
6.368 on three runs. Both failures were measurable before the deadline with
|
|
104
|
+
the right checks. forelint is those checks, packaged.
|
|
105
|
+
|
|
106
|
+
Background: [My validation ranked five models backwards](https://davidmashiah.com/notes/validation-ranked-my-models-backwards).
|
|
107
|
+
|
|
108
|
+
## Conventions
|
|
109
|
+
|
|
110
|
+
- Error is **forecast − actual**. Positive bias means over-forecasting.
|
|
111
|
+
- Tracking signal is the sum of errors divided by the mean absolute error;
|
|
112
|
+
the default alarm level is ±4.
|
|
113
|
+
- `dm_test` averages the loss difference across series at each time stamp,
|
|
114
|
+
then applies Diebold-Mariano with the Harvey-Leybourne-Newbold correction.
|
|
115
|
+
- `model_confidence_set` follows Hansen, Lunde and Nason (2011) with the
|
|
116
|
+
T_max statistic and a moving-block bootstrap. Its kept set matches
|
|
117
|
+
`arch.bootstrap.MCS` in the test suite.
|
|
118
|
+
|
|
119
|
+
## Status
|
|
120
|
+
|
|
121
|
+
Alpha. The API may change before 1.0. Issues and pull requests are welcome.
|
|
122
|
+
|
|
123
|
+
## Author
|
|
124
|
+
|
|
125
|
+
[David Mashiah](https://davidmashiah.com). MIT licensed.
|
forelint-0.1.0/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# forelint
|
|
2
|
+
|
|
3
|
+
**Lint your forecast backtests before you trust a score.**
|
|
4
|
+
|
|
5
|
+
forelint checks the things a backtest score hides: leakage between related
|
|
6
|
+
series, the run-to-run noise floor, whether model A is really better than
|
|
7
|
+
model B, and which items carry the bias and the error. It works on the
|
|
8
|
+
long-format tables that statsforecast, mlforecast and most forecasting code
|
|
9
|
+
already produce, and on any scikit-learn estimator.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install forelint
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What it checks
|
|
16
|
+
|
|
17
|
+
| Question | Function |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Does my validation leak through related stores, products or regions? | `split_optimism`, `GroupTimeSplit` |
|
|
20
|
+
| Is this improvement bigger than seed-to-seed noise? | `noise_floor` |
|
|
21
|
+
| Is model A really better than B across many series? | `dm_test`, `diebold_mariano`, `model_confidence_set` |
|
|
22
|
+
| Which items are always over or under forecast? Does the model beat a naive forecast? Where does the error live? | `audit` |
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import forelint as fl
|
|
28
|
+
|
|
29
|
+
# df: one row per item and date, the actual in "y", one column per model,
|
|
30
|
+
# e.g. the output of statsforecast's cross_validation()
|
|
31
|
+
report = fl.audit(df, models=["LightGBM", "ETS"], baseline="SeasonalNaive")
|
|
32
|
+
print(report)
|
|
33
|
+
|
|
34
|
+
# Is LightGBM really better than the seasonal naive, or is it noise?
|
|
35
|
+
print(fl.dm_test(df, "LightGBM", "SeasonalNaive"))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# How much does random K-fold flatter the model, compared with holding out
|
|
40
|
+
# the future of entities the model never saw?
|
|
41
|
+
fl.split_optimism(model, X, y, groups=store_ids, time=dates)
|
|
42
|
+
|
|
43
|
+
# What difference between two runs is just noise?
|
|
44
|
+
nf = fl.noise_floor(lambda seed: train_and_score(seed), seeds=range(10))
|
|
45
|
+
nf.is_meaningful(new_mae - old_mae)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## On real data: one Walmart store in M5
|
|
49
|
+
|
|
50
|
+
`benchmarks/m5_benchmark.py` runs every check on the public M5 data: store
|
|
51
|
+
CA_1, department FOODS_3, 823 items, the last 365 days, a direct 28-day-ahead
|
|
52
|
+
gradient-boosting model from scikit-learn, scored on the final 28 days.
|
|
53
|
+
Measured, not assumed:
|
|
54
|
+
|
|
55
|
+
- **The model added nothing over a 28-day moving average.** Forecast value
|
|
56
|
+
added was **−0.1%** on MAE, the model was worse than the moving average on
|
|
57
|
+
**49% of items**, and Diebold-Mariano gives **p = 0.31**. The model
|
|
58
|
+
confidence set keeps both. An average-MAE table alone would not show this.
|
|
59
|
+
- **It under-forecasts by 9.3%** of actual sales.
|
|
60
|
+
- **10% of items hold 73% of the squared error.**
|
|
61
|
+
- **The noise floor is small here:** over 10 seeds the MAE sd is 0.0014, so
|
|
62
|
+
any change under 0.0045 is noise.
|
|
63
|
+
- **Splitting barely mattered for this data:** random K-fold was 1% optimistic
|
|
64
|
+
against a time split. Leakage depends on the features; forelint measures it
|
|
65
|
+
rather than assuming it.
|
|
66
|
+
|
|
67
|
+
The model is deliberately plain (six features, no tuning). The point is the
|
|
68
|
+
audit, not the model.
|
|
69
|
+
|
|
70
|
+
## Why
|
|
71
|
+
|
|
72
|
+
In an international machine-learning competition on well-geology prediction,
|
|
73
|
+
validation ranked five builds against the hidden leaderboard with a
|
|
74
|
+
correlation of −0.745: the build it liked most scored worst, and identical code scored 6.241, 6.259 and
|
|
75
|
+
6.368 on three runs. Both failures were measurable before the deadline with
|
|
76
|
+
the right checks. forelint is those checks, packaged.
|
|
77
|
+
|
|
78
|
+
Background: [My validation ranked five models backwards](https://davidmashiah.com/notes/validation-ranked-my-models-backwards).
|
|
79
|
+
|
|
80
|
+
## Conventions
|
|
81
|
+
|
|
82
|
+
- Error is **forecast − actual**. Positive bias means over-forecasting.
|
|
83
|
+
- Tracking signal is the sum of errors divided by the mean absolute error;
|
|
84
|
+
the default alarm level is ±4.
|
|
85
|
+
- `dm_test` averages the loss difference across series at each time stamp,
|
|
86
|
+
then applies Diebold-Mariano with the Harvey-Leybourne-Newbold correction.
|
|
87
|
+
- `model_confidence_set` follows Hansen, Lunde and Nason (2011) with the
|
|
88
|
+
T_max statistic and a moving-block bootstrap. Its kept set matches
|
|
89
|
+
`arch.bootstrap.MCS` in the test suite.
|
|
90
|
+
|
|
91
|
+
## Status
|
|
92
|
+
|
|
93
|
+
Alpha. The API may change before 1.0. Issues and pull requests are welcome.
|
|
94
|
+
|
|
95
|
+
## Author
|
|
96
|
+
|
|
97
|
+
[David Mashiah](https://davidmashiah.com). MIT licensed.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""forelint on real retail data: the M5 competition, one Walmart store.
|
|
2
|
+
|
|
3
|
+
Usage: python benchmarks/m5_benchmark.py path/to/m5.zip [STORE] [DEPT]
|
|
4
|
+
|
|
5
|
+
Reads the public M5 sales file (from the Kaggle competition or Nixtla's
|
|
6
|
+
mirror), keeps one store and department, and builds a direct 28-day-ahead
|
|
7
|
+
global model with scikit-learn. Every number this prints is measured on that
|
|
8
|
+
data; nothing is assumed.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import zipfile
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
from sklearn.ensemble import HistGradientBoostingRegressor
|
|
19
|
+
|
|
20
|
+
import forelint as fl
|
|
21
|
+
|
|
22
|
+
H = 28 # forecast horizon, as in M5
|
|
23
|
+
HISTORY = 420 # days of history kept
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load(path, store, dept):
|
|
27
|
+
with zipfile.ZipFile(path) as z, z.open("sales_train_evaluation.csv") as f:
|
|
28
|
+
wide = pd.read_csv(f)
|
|
29
|
+
wide = wide[(wide.store_id == store) & (wide.dept_id == dept)]
|
|
30
|
+
days = [c for c in wide.columns if c.startswith("d_")][-HISTORY:]
|
|
31
|
+
long = wide.melt(id_vars=["item_id"], value_vars=days, var_name="d", value_name="y")
|
|
32
|
+
long["ds"] = long["d"].str[2:].astype(int)
|
|
33
|
+
return long[["item_id", "ds", "y"]].sort_values(["item_id", "ds"]).reset_index(drop=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def features(df):
|
|
37
|
+
g = df.groupby("item_id")["y"]
|
|
38
|
+
out = df.copy()
|
|
39
|
+
out["lag28"] = g.shift(H)
|
|
40
|
+
out["lag35"] = g.shift(H + 7)
|
|
41
|
+
out["rmean7"] = g.transform(lambda s: s.shift(H).rolling(7).mean())
|
|
42
|
+
out["rmean28"] = g.transform(lambda s: s.shift(H).rolling(28).mean())
|
|
43
|
+
out["dow"] = out["ds"] % 7
|
|
44
|
+
out["item"] = out["item_id"].astype("category").cat.codes
|
|
45
|
+
return out.dropna().reset_index(drop=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
FEATS = ["item", "dow", "lag28", "lag35", "rmean7", "rmean28"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(path, store="CA_1", dept="FOODS_3"):
|
|
52
|
+
t0 = time.time()
|
|
53
|
+
df = features(load(path, store, dept))
|
|
54
|
+
print(f"M5 {store} {dept}: {df.item_id.nunique()} items, {df.ds.nunique()} days, {len(df):,} rows\n")
|
|
55
|
+
X, y = df[FEATS].to_numpy(float), df["y"].to_numpy(float)
|
|
56
|
+
model = HistGradientBoostingRegressor(max_iter=200, random_state=0)
|
|
57
|
+
|
|
58
|
+
# 1. How much each split strategy flatters the model.
|
|
59
|
+
opt = fl.split_optimism(model, X, y, groups=df["item"].to_numpy(), time=df["ds"].to_numpy(),
|
|
60
|
+
reference="time", n_splits=5, gap=0, random_state=0)
|
|
61
|
+
print("1. split optimism (MAE; reference = time split, the way the model is used)")
|
|
62
|
+
print(opt.round(4).to_string(), "\n")
|
|
63
|
+
|
|
64
|
+
# Final holdout: the last 28 days.
|
|
65
|
+
cut = df.ds.max() - H
|
|
66
|
+
tr, te = df[df.ds <= cut], df[df.ds > cut].copy()
|
|
67
|
+
|
|
68
|
+
# 2. Run-to-run noise floor: same data, same settings, different seeds.
|
|
69
|
+
def run(seed):
|
|
70
|
+
m = HistGradientBoostingRegressor(max_iter=200, random_state=seed, early_stopping=True)
|
|
71
|
+
m.fit(tr[FEATS], tr["y"])
|
|
72
|
+
return float(np.abs(m.predict(te[FEATS]) - te["y"]).mean())
|
|
73
|
+
nf = fl.noise_floor(run, seeds=range(10))
|
|
74
|
+
print("2.", nf)
|
|
75
|
+
print(f" run MAEs: {np.round(nf.scores, 4).tolist()}\n")
|
|
76
|
+
|
|
77
|
+
# 3. Audit against two naive baselines.
|
|
78
|
+
m = HistGradientBoostingRegressor(max_iter=200, random_state=0).fit(tr[FEATS], tr["y"])
|
|
79
|
+
te["GBM"] = m.predict(te[FEATS])
|
|
80
|
+
te["Naive28"] = te["lag28"]
|
|
81
|
+
last = tr.groupby("item_id")["y"].apply(lambda s: s.iloc[-28:].mean())
|
|
82
|
+
te["MA28"] = te["item_id"].map(last)
|
|
83
|
+
rep = fl.audit(te, ["GBM", "Naive28"], baseline="MA28", y="y", id_col="item_id", time_col="ds")
|
|
84
|
+
print("3.", rep, "\n")
|
|
85
|
+
|
|
86
|
+
# 4. Is the GBM really better than the moving average?
|
|
87
|
+
print("4. Diebold-Mariano, GBM vs MA28 (squared loss, h = 1 on the per-day mean):")
|
|
88
|
+
print(" ", fl.dm_test(te, "GBM", "MA28", id_col="item_id"))
|
|
89
|
+
losses = pd.DataFrame({c: ((te[c] - te["y"]) ** 2).groupby(te["ds"]).mean() for c in ["GBM", "Naive28", "MA28"]})
|
|
90
|
+
print(" model confidence set (10%):")
|
|
91
|
+
print(fl.model_confidence_set(losses, size=0.10, random_state=0).round(4).to_string())
|
|
92
|
+
print(f"\nran in {time.time() - t0:.0f} s")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main(*sys.argv[1:])
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "forelint"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lint your forecast backtests: leakage between related series, the run-to-run noise floor, model comparison tests, and per-item bias before you trust a score."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "David Mashiah" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"numpy>=1.22",
|
|
16
|
+
"pandas>=1.5",
|
|
17
|
+
"scipy>=1.9",
|
|
18
|
+
"scikit-learn>=1.2",
|
|
19
|
+
]
|
|
20
|
+
keywords = [
|
|
21
|
+
"forecasting", "forecast evaluation", "time series cross-validation", "backtesting",
|
|
22
|
+
"data leakage", "group k-fold", "diebold-mariano", "model confidence set",
|
|
23
|
+
"tracking signal", "forecast bias", "forecast value added", "demand forecasting",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 3 - Alpha",
|
|
27
|
+
"Intended Audience :: Science/Research",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
32
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
test = ["pytest>=7", "arch>=6"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/davidmashiah/forelint"
|
|
40
|
+
Source = "https://github.com/davidmashiah/forelint"
|
|
41
|
+
Author = "https://davidmashiah.com/"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/forelint"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""forelint: lint your forecast backtests before you trust a score.
|
|
2
|
+
|
|
3
|
+
- `GroupTimeSplit`: hold out the future of entities the model never saw.
|
|
4
|
+
- `split_optimism`: how much each split strategy flatters the model.
|
|
5
|
+
- `noise_floor`: the run-to-run spread, below which a difference is noise.
|
|
6
|
+
- `diebold_mariano`, `dm_test`, `model_confidence_set`: is A really better than B.
|
|
7
|
+
- `audit`: per-item bias, tracking signal, error concentration, forecast value added.
|
|
8
|
+
"""
|
|
9
|
+
from .audit import AuditReport, audit
|
|
10
|
+
from .compare import DMResult, diebold_mariano, dm_test, model_confidence_set
|
|
11
|
+
from .noise import NoiseFloor, noise_floor
|
|
12
|
+
from .optimism import split_optimism
|
|
13
|
+
from .splits import GroupTimeSplit
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AuditReport", "audit",
|
|
19
|
+
"DMResult", "diebold_mariano", "dm_test", "model_confidence_set",
|
|
20
|
+
"NoiseFloor", "noise_floor",
|
|
21
|
+
"split_optimism",
|
|
22
|
+
"GroupTimeSplit",
|
|
23
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""One report on a backtest: bias, tracking signal, error concentration and
|
|
2
|
+
forecast value added, per item and overall.
|
|
3
|
+
|
|
4
|
+
Average error hides the two things that cost money: items whose forecast is
|
|
5
|
+
always too high or always too low, and the few items that carry most of the
|
|
6
|
+
error. The audit surfaces both, and compares every model with a baseline the
|
|
7
|
+
way forecast value added does, because a model that loses to a naive forecast
|
|
8
|
+
on most items should not ship whatever its average says.
|
|
9
|
+
|
|
10
|
+
Sign convention: error = forecast − actual, so positive bias is over-forecasting.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
__all__ = ["AuditReport", "audit"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AuditReport:
|
|
24
|
+
per_item: pd.DataFrame
|
|
25
|
+
summary: pd.DataFrame
|
|
26
|
+
baseline: str | None
|
|
27
|
+
top_frac: float
|
|
28
|
+
ts_limit: float
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
lines = ["forelint audit", ""]
|
|
32
|
+
for model, r in self.summary.iterrows():
|
|
33
|
+
lines.append(f"{model}")
|
|
34
|
+
lines.append(f" MAE {r['mae']:.4g} RMSE {r['rmse']:.4g} bias {r['bias']:+.4g} "
|
|
35
|
+
f"({r['pct_bias']:+.1%} of actuals)")
|
|
36
|
+
lines.append(f" items with |tracking signal| > {self.ts_limit:g}: "
|
|
37
|
+
f"{int(r['n_biased_items'])} of {int(r['n_items'])} ({r['share_biased_items']:.0%})")
|
|
38
|
+
lines.append(f" top {self.top_frac:.0%} of items hold {r['error_concentration']:.0%} of squared error")
|
|
39
|
+
if self.baseline is not None and model != self.baseline:
|
|
40
|
+
lines.append(f" forecast value added vs {self.baseline}: {r['fva']:+.1%} on MAE; "
|
|
41
|
+
f"worse than {self.baseline} on {r['share_items_worse']:.0%} of items")
|
|
42
|
+
lines.append("")
|
|
43
|
+
return "\n".join(lines).rstrip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tracking_signal(e):
|
|
47
|
+
mad = np.abs(e).mean()
|
|
48
|
+
return np.nan if mad == 0 else e.sum() / mad
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def audit(df, models, *, baseline=None, y="y", id_col="unique_id", time_col="ds",
|
|
52
|
+
top_frac=0.10, ts_limit=4.0) -> AuditReport:
|
|
53
|
+
"""Audit a long-format backtest.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
df : DataFrame with one row per item and time stamp, the actual in ``y``
|
|
58
|
+
and one column per model. statsforecast / mlforecast
|
|
59
|
+
``cross_validation`` output works as is.
|
|
60
|
+
models : list of model column names.
|
|
61
|
+
baseline : optional model column to measure forecast value added against,
|
|
62
|
+
typically a naive or seasonal-naive forecast.
|
|
63
|
+
top_frac : share of items used for the error-concentration figure.
|
|
64
|
+
ts_limit : tracking-signal alarm level; 4 is a common choice.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
AuditReport with ``per_item`` (one row per item and model) and ``summary``
|
|
69
|
+
(one row per model). ``print(report)`` gives a readable summary.
|
|
70
|
+
"""
|
|
71
|
+
models = list(models)
|
|
72
|
+
cols = models + ([baseline] if baseline and baseline not in models else [])
|
|
73
|
+
missing = [c for c in [y, id_col, time_col, *cols] if c not in df.columns]
|
|
74
|
+
if missing:
|
|
75
|
+
raise KeyError(f"columns not found: {missing}")
|
|
76
|
+
d = df.sort_values([id_col, time_col])
|
|
77
|
+
items = []
|
|
78
|
+
for model in cols:
|
|
79
|
+
e = d[model] - d[y]
|
|
80
|
+
g = pd.DataFrame({id_col: d[id_col], "e": e, "ae": e.abs(), "se": e ** 2, "y": d[y]}).groupby(id_col)
|
|
81
|
+
part = pd.DataFrame({
|
|
82
|
+
"n": g["e"].size(),
|
|
83
|
+
"mae": g["ae"].mean(),
|
|
84
|
+
"rmse": np.sqrt(g["se"].mean()),
|
|
85
|
+
"bias": g["e"].mean(),
|
|
86
|
+
"sse": g["se"].sum(),
|
|
87
|
+
"sum_actual": g["y"].sum(),
|
|
88
|
+
"tracking_signal": g["e"].apply(lambda s: _tracking_signal(s.to_numpy())),
|
|
89
|
+
})
|
|
90
|
+
part["model"] = model
|
|
91
|
+
items.append(part.reset_index())
|
|
92
|
+
per_item = pd.concat(items, ignore_index=True)
|
|
93
|
+
if baseline:
|
|
94
|
+
base_mae = per_item[per_item["model"] == baseline].set_index(id_col)["mae"]
|
|
95
|
+
per_item["fva"] = 1 - per_item["mae"] / per_item[id_col].map(base_mae)
|
|
96
|
+
|
|
97
|
+
rows = {}
|
|
98
|
+
for model in cols:
|
|
99
|
+
p = per_item[per_item["model"] == model]
|
|
100
|
+
e = d[model] - d[y]
|
|
101
|
+
sse_sorted = np.sort(p["sse"].to_numpy())[::-1]
|
|
102
|
+
k = max(1, int(round(top_frac * len(sse_sorted))))
|
|
103
|
+
biased = (p["tracking_signal"].abs() > ts_limit).sum()
|
|
104
|
+
r = {
|
|
105
|
+
"n_items": len(p),
|
|
106
|
+
"mae": e.abs().mean(),
|
|
107
|
+
"rmse": float(np.sqrt((e ** 2).mean())),
|
|
108
|
+
"bias": e.mean(),
|
|
109
|
+
"pct_bias": e.sum() / d[y].sum() if d[y].sum() != 0 else np.nan,
|
|
110
|
+
"n_biased_items": int(biased),
|
|
111
|
+
"share_biased_items": biased / len(p),
|
|
112
|
+
"error_concentration": sse_sorted[:k].sum() / sse_sorted.sum() if sse_sorted.sum() else np.nan,
|
|
113
|
+
}
|
|
114
|
+
if baseline:
|
|
115
|
+
base_e = d[baseline] - d[y]
|
|
116
|
+
r["fva"] = 1 - e.abs().mean() / base_e.abs().mean()
|
|
117
|
+
r["share_items_worse"] = float((p["fva"] < 0).mean())
|
|
118
|
+
rows[model] = r
|
|
119
|
+
summary = pd.DataFrame.from_dict(rows, orient="index")
|
|
120
|
+
summary.index.name = "model"
|
|
121
|
+
return AuditReport(per_item=per_item, summary=summary, baseline=baseline,
|
|
122
|
+
top_frac=top_frac, ts_limit=ts_limit)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Is model A really better than model B, or is it noise?
|
|
2
|
+
|
|
3
|
+
`diebold_mariano` tests one pair on one loss series, with the Harvey,
|
|
4
|
+
Leybourne and Newbold (1997) small-sample correction. `dm_test` does the same
|
|
5
|
+
for a long-format forecast table with many series, by averaging the loss
|
|
6
|
+
difference across series at each time stamp. `model_confidence_set` keeps the
|
|
7
|
+
set of models that cannot be told apart from the best (Hansen, Lunde and
|
|
8
|
+
Nason 2011), using a moving-block bootstrap and the T_max statistic.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from scipy import stats
|
|
17
|
+
|
|
18
|
+
__all__ = ["DMResult", "diebold_mariano", "dm_test", "model_confidence_set"]
|
|
19
|
+
|
|
20
|
+
_LOSSES = {
|
|
21
|
+
"squared": lambda e: e ** 2,
|
|
22
|
+
"absolute": lambda e: np.abs(e),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class DMResult:
|
|
28
|
+
statistic: float
|
|
29
|
+
p_value: float
|
|
30
|
+
mean_loss_diff: float
|
|
31
|
+
n: int
|
|
32
|
+
|
|
33
|
+
def __str__(self):
|
|
34
|
+
side = "A worse" if self.mean_loss_diff > 0 else "A better"
|
|
35
|
+
return (f"DM = {self.statistic:.3f}, p = {self.p_value:.4f} over {self.n} points "
|
|
36
|
+
f"(mean loss difference {self.mean_loss_diff:.4g}, {side})")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dm_from_diff(d, h):
|
|
40
|
+
d = np.asarray(d, dtype=float)
|
|
41
|
+
d = d[~np.isnan(d)]
|
|
42
|
+
n = d.size
|
|
43
|
+
if n < 3:
|
|
44
|
+
raise ValueError("need at least 3 loss differences")
|
|
45
|
+
mean = d.mean()
|
|
46
|
+
dc = d - mean
|
|
47
|
+
var = dc @ dc / n
|
|
48
|
+
for k in range(1, h):
|
|
49
|
+
var += 2.0 * (dc[k:] @ dc[:-k]) / n
|
|
50
|
+
if var <= 0:
|
|
51
|
+
return DMResult(np.nan, np.nan, float(mean), n)
|
|
52
|
+
stat = mean / np.sqrt(var / n)
|
|
53
|
+
stat *= np.sqrt((n + 1 - 2 * h + h * (h - 1) / n) / n) # HLN correction
|
|
54
|
+
p = 2.0 * stats.t.sf(abs(stat), df=n - 1)
|
|
55
|
+
return DMResult(float(stat), float(p), float(mean), n)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def diebold_mariano(errors_a, errors_b, h=1, loss="squared") -> DMResult:
|
|
59
|
+
"""Diebold-Mariano test with the HLN correction.
|
|
60
|
+
|
|
61
|
+
A positive statistic means model A has the larger loss. ``h`` is the
|
|
62
|
+
forecast horizon; losses up to lag ``h - 1`` are allowed to be correlated.
|
|
63
|
+
"""
|
|
64
|
+
if loss not in _LOSSES:
|
|
65
|
+
raise ValueError(f"loss must be one of {list(_LOSSES)}")
|
|
66
|
+
ea, eb = np.asarray(errors_a, dtype=float), np.asarray(errors_b, dtype=float)
|
|
67
|
+
if ea.shape != eb.shape:
|
|
68
|
+
raise ValueError("error arrays must have the same shape")
|
|
69
|
+
return _dm_from_diff(_LOSSES[loss](ea) - _LOSSES[loss](eb), h)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dm_test(df, model_a, model_b, *, y="y", id_col="unique_id", time_col="ds", h=1,
|
|
73
|
+
loss="squared") -> DMResult:
|
|
74
|
+
"""Diebold-Mariano on a long-format table of many series.
|
|
75
|
+
|
|
76
|
+
``df`` has one row per series and time stamp, with the actual in ``y`` and
|
|
77
|
+
one column per model (the layout of statsforecast/mlforecast
|
|
78
|
+
``cross_validation`` output). The loss difference is averaged across
|
|
79
|
+
series at each time stamp, then tested as one series.
|
|
80
|
+
"""
|
|
81
|
+
if loss not in _LOSSES:
|
|
82
|
+
raise ValueError(f"loss must be one of {list(_LOSSES)}")
|
|
83
|
+
f = _LOSSES[loss]
|
|
84
|
+
d = f(df[model_a] - df[y]) - f(df[model_b] - df[y])
|
|
85
|
+
per_time = d.groupby(df[time_col]).mean().sort_index()
|
|
86
|
+
return _dm_from_diff(per_time.to_numpy(), h)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _block_indices(n, block, reps, rng):
|
|
90
|
+
n_blocks = int(np.ceil(n / block))
|
|
91
|
+
starts = rng.integers(0, n, size=(reps, n_blocks))
|
|
92
|
+
idx = (starts[:, :, None] + np.arange(block)[None, None, :]) % n
|
|
93
|
+
return idx.reshape(reps, -1)[:, :n]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def model_confidence_set(losses, size=0.10, reps=2000, block_size=None, random_state=None):
|
|
97
|
+
"""Hansen's model confidence set with the T_max statistic.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
losses : DataFrame of shape (n_times, n_models), one column per model,
|
|
102
|
+
each cell that model's loss at that time. For many series, average the
|
|
103
|
+
loss across series per time stamp first.
|
|
104
|
+
size : the test size; models whose MCS p-value is at least ``size`` stay
|
|
105
|
+
in the set.
|
|
106
|
+
block_size : moving-block length for the bootstrap. Defaults to
|
|
107
|
+
``ceil(n_times ** (1/3))``.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
DataFrame indexed by model with ``pvalue`` and ``in_set``, ordered from
|
|
112
|
+
first eliminated to last.
|
|
113
|
+
"""
|
|
114
|
+
L = pd.DataFrame(losses).astype(float)
|
|
115
|
+
if L.isna().any().any():
|
|
116
|
+
raise ValueError("losses contain missing values")
|
|
117
|
+
n, m = L.shape
|
|
118
|
+
if m < 2:
|
|
119
|
+
raise ValueError("need at least two models")
|
|
120
|
+
block = block_size or int(np.ceil(n ** (1 / 3)))
|
|
121
|
+
rng = np.random.default_rng(random_state)
|
|
122
|
+
idx = _block_indices(n, block, reps, rng)
|
|
123
|
+
vals = L.to_numpy()
|
|
124
|
+
lbar = vals.mean(axis=0) # (m,)
|
|
125
|
+
lboot = vals[idx].mean(axis=1) # (reps, m)
|
|
126
|
+
|
|
127
|
+
included = list(range(m))
|
|
128
|
+
order, pvals = [], []
|
|
129
|
+
running = 0.0
|
|
130
|
+
while len(included) > 1:
|
|
131
|
+
s = np.array(included)
|
|
132
|
+
dbar = lbar[s] - lbar[s].mean()
|
|
133
|
+
dboot = lboot[:, s] - lboot[:, s].mean(axis=1, keepdims=True)
|
|
134
|
+
dev = dboot - dbar
|
|
135
|
+
var = (dev ** 2).mean(axis=0)
|
|
136
|
+
var[var <= 0] = np.finfo(float).tiny
|
|
137
|
+
t = dbar / np.sqrt(var)
|
|
138
|
+
tmax = t.max()
|
|
139
|
+
tboot = (dev / np.sqrt(var)).max(axis=1)
|
|
140
|
+
p = float((tboot >= tmax).mean())
|
|
141
|
+
running = max(running, p)
|
|
142
|
+
worst = int(s[np.argmax(t)])
|
|
143
|
+
order.append(worst)
|
|
144
|
+
pvals.append(running)
|
|
145
|
+
included.remove(worst)
|
|
146
|
+
order.append(included[0])
|
|
147
|
+
pvals.append(1.0)
|
|
148
|
+
out = pd.DataFrame({"pvalue": pvals}, index=L.columns[order])
|
|
149
|
+
out["in_set"] = out["pvalue"] >= size
|
|
150
|
+
out.index.name = "model"
|
|
151
|
+
return out
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The run-to-run noise floor.
|
|
2
|
+
|
|
3
|
+
Seeds, bootstrap draws, GPU nondeterminism and data loading order all move a
|
|
4
|
+
score. Two runs cannot estimate that spread: in one recorded case the first
|
|
5
|
+
two runs of identical code differed by 0.018 and the third moved the spread to
|
|
6
|
+
a standard deviation of 0.069. Measure the floor first; any difference smaller
|
|
7
|
+
than it is not a result.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from scipy import stats
|
|
15
|
+
|
|
16
|
+
__all__ = ["NoiseFloor", "noise_floor"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class NoiseFloor:
|
|
21
|
+
"""Scores from repeated runs of one unchanged setup."""
|
|
22
|
+
|
|
23
|
+
scores: np.ndarray
|
|
24
|
+
confidence: float = 0.95
|
|
25
|
+
mean: float = field(init=False)
|
|
26
|
+
sd: float = field(init=False)
|
|
27
|
+
mde: float = field(init=False)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
self.scores = np.asarray(self.scores, dtype=float)
|
|
31
|
+
if self.scores.size < 3:
|
|
32
|
+
raise ValueError("a noise floor needs at least 3 runs; two cannot estimate a spread")
|
|
33
|
+
self.mean = float(self.scores.mean())
|
|
34
|
+
self.sd = float(self.scores.std(ddof=1))
|
|
35
|
+
# Smallest difference between two single runs that exceeds noise at
|
|
36
|
+
# this confidence, using the t quantile for the runs we actually have.
|
|
37
|
+
q = stats.t.ppf(0.5 + self.confidence / 2, df=self.scores.size - 1)
|
|
38
|
+
self.mde = float(q * np.sqrt(2.0) * self.sd)
|
|
39
|
+
|
|
40
|
+
def is_meaningful(self, difference: float) -> bool:
|
|
41
|
+
"""True if a single-run difference is larger than the noise floor."""
|
|
42
|
+
return abs(difference) > self.mde
|
|
43
|
+
|
|
44
|
+
def __str__(self):
|
|
45
|
+
return (f"noise floor over {self.scores.size} runs: mean {self.mean:.4g}, "
|
|
46
|
+
f"sd {self.sd:.4g}; differences under {self.mde:.4g} are noise "
|
|
47
|
+
f"at {self.confidence:.0%}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def noise_floor(run, seeds=(0, 1, 2, 3, 4), confidence=0.95) -> NoiseFloor:
|
|
51
|
+
"""Call ``run(seed) -> float`` once per seed and summarise the spread.
|
|
52
|
+
|
|
53
|
+
Example
|
|
54
|
+
-------
|
|
55
|
+
>>> nf = noise_floor(lambda s: train_and_score(seed=s), seeds=range(5))
|
|
56
|
+
>>> nf.is_meaningful(new_score - old_score)
|
|
57
|
+
"""
|
|
58
|
+
return NoiseFloor(np.array([float(run(s)) for s in seeds]), confidence=confidence)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""How much does the way you split flatter the model?
|
|
2
|
+
|
|
3
|
+
Run one estimator under several validation schemes and report each scheme's
|
|
4
|
+
error next to the strictest one. If random K-fold reports half the error of a
|
|
5
|
+
split that holds out the future of unseen entities, the random score is not a
|
|
6
|
+
measurement of the deployed model; it is a measurement of the leak.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from sklearn.model_selection import GroupKFold, KFold, cross_validate
|
|
13
|
+
|
|
14
|
+
from .splits import GroupTimeSplit
|
|
15
|
+
|
|
16
|
+
__all__ = ["split_optimism"]
|
|
17
|
+
|
|
18
|
+
_STRATEGIES = ("random", "group", "time", "group_time")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _splitter(name, *, time, n_splits, test_group_frac, gap, random_state):
|
|
22
|
+
if name == "random":
|
|
23
|
+
return KFold(n_splits=n_splits, shuffle=True, random_state=random_state)
|
|
24
|
+
if name == "group":
|
|
25
|
+
return GroupKFold(n_splits=n_splits)
|
|
26
|
+
if name == "time":
|
|
27
|
+
return GroupTimeSplit(time, n_splits=n_splits, gap=gap, holdout="none")
|
|
28
|
+
if name == "group_time":
|
|
29
|
+
return GroupTimeSplit(time, n_splits=n_splits, gap=gap, holdout="groups",
|
|
30
|
+
test_group_frac=test_group_frac, random_state=random_state)
|
|
31
|
+
raise ValueError(f"unknown strategy {name!r}; choose from {_STRATEGIES}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def split_optimism(estimator, X, y, *, groups, time, scoring="neg_mean_absolute_error",
|
|
35
|
+
strategies=_STRATEGIES, reference="group_time", n_splits=5,
|
|
36
|
+
test_group_frac=0.2, gap=0, random_state=0):
|
|
37
|
+
"""Score one estimator under several split strategies.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
estimator : scikit-learn estimator
|
|
42
|
+
X, y : training data
|
|
43
|
+
groups : array-like, the entity of each row (store, SKU, well, region)
|
|
44
|
+
time : array-like, the time stamp of each row
|
|
45
|
+
scoring : str, a scikit-learn scorer. ``neg_*`` scorers are turned back
|
|
46
|
+
into positive errors.
|
|
47
|
+
strategies : subset of ``("random", "group", "time", "group_time")``
|
|
48
|
+
reference : the strategy to compare against, normally the one that
|
|
49
|
+
matches how the model will be used.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
pandas.DataFrame indexed by strategy with ``error`` (mean over folds),
|
|
54
|
+
``error_sd``, ``n_folds`` and ``vs_reference``: the strategy's error as a
|
|
55
|
+
share of the reference error. ``0.6`` means that strategy reports 40%
|
|
56
|
+
less error than the reference, i.e. it is 40% optimistic.
|
|
57
|
+
"""
|
|
58
|
+
if reference not in strategies:
|
|
59
|
+
raise ValueError("reference must be one of the strategies")
|
|
60
|
+
sign = -1.0 if scoring.startswith("neg_") else 1.0
|
|
61
|
+
rows = {}
|
|
62
|
+
for name in strategies:
|
|
63
|
+
cv = _splitter(name, time=time, n_splits=n_splits, test_group_frac=test_group_frac,
|
|
64
|
+
gap=gap, random_state=random_state)
|
|
65
|
+
res = cross_validate(estimator, X, y, groups=None if name == "random" else groups,
|
|
66
|
+
cv=cv, scoring=scoring)
|
|
67
|
+
err = sign * np.asarray(res["test_score"], dtype=float)
|
|
68
|
+
rows[name] = {"error": err.mean(), "error_sd": err.std(ddof=1) if len(err) > 1 else np.nan,
|
|
69
|
+
"n_folds": len(err)}
|
|
70
|
+
out = pd.DataFrame.from_dict(rows, orient="index")
|
|
71
|
+
out["vs_reference"] = out["error"] / out.loc[reference, "error"]
|
|
72
|
+
out.index.name = "strategy"
|
|
73
|
+
return out
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Splitters that hold out the future *and* unseen entities.
|
|
2
|
+
|
|
3
|
+
scikit-learn ships `GroupKFold` (unseen entities, any time) and
|
|
4
|
+
`TimeSeriesSplit` (future rows, every entity seen). Forecasting new stores,
|
|
5
|
+
new products or new wells needs both at once: train on the past of some
|
|
6
|
+
entities, test on the future of entities the model never saw. Without that,
|
|
7
|
+
related series leak through validation and the score flatters the model.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from sklearn.model_selection import BaseCrossValidator
|
|
13
|
+
|
|
14
|
+
__all__ = ["GroupTimeSplit"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GroupTimeSplit(BaseCrossValidator):
|
|
18
|
+
"""Expanding-window splits that can also hold out whole groups.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
time : array-like of shape (n_samples,)
|
|
23
|
+
The time stamp of every row, aligned with ``X``. Any sortable type.
|
|
24
|
+
n_splits : int, default 5
|
|
25
|
+
Number of test windows, taken from the end of the time range.
|
|
26
|
+
test_size : int, optional
|
|
27
|
+
Unique time points per test window. Defaults to
|
|
28
|
+
``n_unique_times // (n_splits + 1)``.
|
|
29
|
+
gap : int, default 0
|
|
30
|
+
Unique time points dropped between the end of training and the
|
|
31
|
+
start of each test window, to mimic a forecast lead time.
|
|
32
|
+
holdout : {"groups", "none"}, default "groups"
|
|
33
|
+
``"groups"`` tests only on groups that are absent from training
|
|
34
|
+
(new entities). ``"none"`` keeps every group on both sides, which is
|
|
35
|
+
an ordinary time-series split.
|
|
36
|
+
test_group_frac : float, default 0.2
|
|
37
|
+
Share of groups held out in each fold when ``holdout="groups"``.
|
|
38
|
+
random_state : int, optional
|
|
39
|
+
Seed for which groups are held out.
|
|
40
|
+
|
|
41
|
+
Notes
|
|
42
|
+
-----
|
|
43
|
+
``split`` needs ``groups`` when ``holdout="groups"``. Works with
|
|
44
|
+
``sklearn.model_selection.cross_validate(..., groups=groups)``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, time, n_splits=5, test_size=None, gap=0, holdout="groups",
|
|
48
|
+
test_group_frac=0.2, random_state=None):
|
|
49
|
+
if holdout not in ("groups", "none"):
|
|
50
|
+
raise ValueError("holdout must be 'groups' or 'none'")
|
|
51
|
+
if not 0 < test_group_frac < 1:
|
|
52
|
+
raise ValueError("test_group_frac must be between 0 and 1")
|
|
53
|
+
if n_splits < 1:
|
|
54
|
+
raise ValueError("n_splits must be at least 1")
|
|
55
|
+
self.time = time
|
|
56
|
+
self.n_splits = n_splits
|
|
57
|
+
self.test_size = test_size
|
|
58
|
+
self.gap = gap
|
|
59
|
+
self.holdout = holdout
|
|
60
|
+
self.test_group_frac = test_group_frac
|
|
61
|
+
self.random_state = random_state
|
|
62
|
+
|
|
63
|
+
def get_n_splits(self, X=None, y=None, groups=None):
|
|
64
|
+
return self.n_splits
|
|
65
|
+
|
|
66
|
+
def _windows(self, time):
|
|
67
|
+
uniq = np.unique(time)
|
|
68
|
+
size = self.test_size or len(uniq) // (self.n_splits + 1)
|
|
69
|
+
if size < 1:
|
|
70
|
+
raise ValueError("not enough unique time points for this many splits")
|
|
71
|
+
first_test = len(uniq) - self.n_splits * size
|
|
72
|
+
if first_test - self.gap < 1:
|
|
73
|
+
raise ValueError("no training time left before the first test window")
|
|
74
|
+
for k in range(self.n_splits):
|
|
75
|
+
start = first_test + k * size
|
|
76
|
+
yield uniq[: start - self.gap], uniq[start: start + size]
|
|
77
|
+
|
|
78
|
+
def split(self, X, y=None, groups=None):
|
|
79
|
+
time = np.asarray(self.time)
|
|
80
|
+
n = len(time) if X is None else len(X)
|
|
81
|
+
if len(time) != n:
|
|
82
|
+
raise ValueError(f"time has {len(time)} rows but X has {n}")
|
|
83
|
+
idx = np.arange(n)
|
|
84
|
+
if self.holdout == "groups":
|
|
85
|
+
if groups is None:
|
|
86
|
+
raise ValueError("holdout='groups' needs the groups argument")
|
|
87
|
+
groups = np.asarray(groups)
|
|
88
|
+
uniq_groups = np.unique(groups)
|
|
89
|
+
n_test = max(1, int(round(self.test_group_frac * len(uniq_groups))))
|
|
90
|
+
if n_test >= len(uniq_groups):
|
|
91
|
+
raise ValueError("test_group_frac leaves no groups for training")
|
|
92
|
+
rng = np.random.default_rng(self.random_state)
|
|
93
|
+
for train_times, test_times in self._windows(time):
|
|
94
|
+
in_train_time = np.isin(time, train_times)
|
|
95
|
+
in_test_time = np.isin(time, test_times)
|
|
96
|
+
if self.holdout == "groups":
|
|
97
|
+
held = rng.choice(uniq_groups, size=n_test, replace=False)
|
|
98
|
+
is_held = np.isin(groups, held)
|
|
99
|
+
train, test = idx[in_train_time & ~is_held], idx[in_test_time & is_held]
|
|
100
|
+
else:
|
|
101
|
+
train, test = idx[in_train_time], idx[in_test_time]
|
|
102
|
+
if len(train) == 0 or len(test) == 0:
|
|
103
|
+
raise ValueError("a fold came out empty; use fewer splits or a larger test_group_frac")
|
|
104
|
+
yield train, test
|
|
105
|
+
|
|
106
|
+
def _iter_test_indices(self, X=None, y=None, groups=None): # pragma: no cover
|
|
107
|
+
for _, test in self.split(X, y, groups):
|
|
108
|
+
yield test
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import pytest
|
|
4
|
+
from scipy import stats
|
|
5
|
+
from sklearn.ensemble import RandomForestRegressor
|
|
6
|
+
|
|
7
|
+
import forelint as fl
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ── audit: hand-checked against the eight-day forecast-bias example ─────────
|
|
11
|
+
def _eight_days():
|
|
12
|
+
demand = [12, 9, 15, 10, 14, 11, 13, 10]
|
|
13
|
+
eh = [3, 1, 2, 3, 1, 2, 3, 1]
|
|
14
|
+
en = [3, -2, -3, 2, 1, -1, -3, 3]
|
|
15
|
+
return pd.DataFrame({
|
|
16
|
+
"unique_id": "sku1", "ds": range(8), "y": demand,
|
|
17
|
+
"H": np.add(demand, eh), "N": np.add(demand, en),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_audit_bias_tracking_signal_and_fva():
|
|
22
|
+
rep = fl.audit(_eight_days(), ["H"], baseline="N")
|
|
23
|
+
s = rep.summary
|
|
24
|
+
assert s.loc["H", "bias"] == pytest.approx(2.0)
|
|
25
|
+
assert s.loc["H", "mae"] == pytest.approx(2.0)
|
|
26
|
+
assert s.loc["H", "rmse"] == pytest.approx(np.sqrt(38 / 8))
|
|
27
|
+
assert s.loc["H", "pct_bias"] == pytest.approx(16 / 94)
|
|
28
|
+
assert s.loc["N", "bias"] == pytest.approx(0.0)
|
|
29
|
+
assert s.loc["N", "mae"] == pytest.approx(2.25)
|
|
30
|
+
# tracking signal = sum of errors / MAD = 16 / 2 = 8 for H, 0 for N
|
|
31
|
+
pi = rep.per_item.set_index("model")
|
|
32
|
+
assert pi.loc["H", "tracking_signal"] == pytest.approx(8.0)
|
|
33
|
+
assert pi.loc["N", "tracking_signal"] == pytest.approx(0.0)
|
|
34
|
+
assert s.loc["H", "n_biased_items"] == 1
|
|
35
|
+
# forecast value added of H over N: 1 - 2.00 / 2.25
|
|
36
|
+
assert s.loc["H", "fva"] == pytest.approx(1 - 2.0 / 2.25)
|
|
37
|
+
assert "forecast value added" in str(rep)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_audit_error_concentration():
|
|
41
|
+
rows = []
|
|
42
|
+
for i in range(10):
|
|
43
|
+
err = 10.0 if i == 0 else 1.0
|
|
44
|
+
rows += [{"unique_id": i, "ds": t, "y": 0.0, "m": err} for t in range(4)]
|
|
45
|
+
rep = fl.audit(pd.DataFrame(rows), ["m"], top_frac=0.1)
|
|
46
|
+
# item 0: SSE 400; others: 4 each -> 400 / (400 + 36)
|
|
47
|
+
assert rep.summary.loc["m", "error_concentration"] == pytest.approx(400 / 436)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_audit_missing_column():
|
|
51
|
+
with pytest.raises(KeyError):
|
|
52
|
+
fl.audit(_eight_days(), ["nope"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── noise floor ─────────────────────────────────────────────────────────────
|
|
56
|
+
def test_noise_floor_values():
|
|
57
|
+
nf = fl.noise_floor(lambda s: [1.0, 2.0, 3.0][s], seeds=[0, 1, 2])
|
|
58
|
+
assert nf.mean == pytest.approx(2.0)
|
|
59
|
+
assert nf.sd == pytest.approx(1.0)
|
|
60
|
+
assert nf.mde == pytest.approx(stats.t.ppf(0.975, 2) * np.sqrt(2))
|
|
61
|
+
assert not nf.is_meaningful(1.0)
|
|
62
|
+
assert nf.is_meaningful(7.0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_noise_floor_needs_three_runs():
|
|
66
|
+
with pytest.raises(ValueError, match="at least 3"):
|
|
67
|
+
fl.noise_floor(lambda s: s, seeds=[0, 1])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Diebold-Mariano ─────────────────────────────────────────────────────────
|
|
71
|
+
def test_dm_hand_computed():
|
|
72
|
+
# loss differences d = 1..5 with squared loss: e_a^2 - e_b^2
|
|
73
|
+
ea = np.sqrt(np.array([1, 2, 3, 4, 5], dtype=float))
|
|
74
|
+
eb = np.zeros(5)
|
|
75
|
+
r = fl.diebold_mariano(ea, eb, h=1, loss="squared")
|
|
76
|
+
raw = 3 / np.sqrt(2 / 5)
|
|
77
|
+
expected = raw * np.sqrt((5 + 1 - 2) / 5)
|
|
78
|
+
assert r.statistic == pytest.approx(expected)
|
|
79
|
+
assert r.p_value == pytest.approx(2 * stats.t.sf(expected, df=4))
|
|
80
|
+
assert r.mean_loss_diff == pytest.approx(3.0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_dm_is_antisymmetric_and_detects_a_better_model():
|
|
84
|
+
rng = np.random.default_rng(1)
|
|
85
|
+
good = rng.normal(0, 1, 300)
|
|
86
|
+
bad = rng.normal(0, 2, 300)
|
|
87
|
+
ab = fl.diebold_mariano(bad, good)
|
|
88
|
+
ba = fl.diebold_mariano(good, bad)
|
|
89
|
+
assert ab.statistic == pytest.approx(-ba.statistic)
|
|
90
|
+
assert ab.statistic > 0 and ab.p_value < 1e-6
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_dm_panel_long_format():
|
|
94
|
+
rng = np.random.default_rng(2)
|
|
95
|
+
rows = []
|
|
96
|
+
for i in range(20):
|
|
97
|
+
for t in range(60):
|
|
98
|
+
y = rng.normal(10, 1)
|
|
99
|
+
rows.append({"unique_id": i, "ds": t, "y": y,
|
|
100
|
+
"good": y + rng.normal(0, 0.5), "bad": y + rng.normal(0, 1.5)})
|
|
101
|
+
df = pd.DataFrame(rows)
|
|
102
|
+
r = fl.dm_test(df, "bad", "good")
|
|
103
|
+
assert r.n == 60 and r.statistic > 0 and r.p_value < 0.01
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── model confidence set ────────────────────────────────────────────────────
|
|
107
|
+
def _losses(seed=3, n=400):
|
|
108
|
+
rng = np.random.default_rng(seed)
|
|
109
|
+
base = rng.normal(0, 1, n)
|
|
110
|
+
return pd.DataFrame({
|
|
111
|
+
"A": 1.00 + base + rng.normal(0, 0.3, n),
|
|
112
|
+
"B": 1.02 + base + rng.normal(0, 0.3, n),
|
|
113
|
+
"C": 1.60 + base + rng.normal(0, 0.3, n),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_mcs_drops_the_clearly_worse_model():
|
|
118
|
+
out = fl.model_confidence_set(_losses(), size=0.10, reps=2000, random_state=0)
|
|
119
|
+
assert not out.loc["C", "in_set"]
|
|
120
|
+
assert out.loc["A", "in_set"] and out.loc["B", "in_set"]
|
|
121
|
+
assert out["pvalue"].is_monotonic_increasing
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_mcs_agrees_with_arch():
|
|
125
|
+
arch = pytest.importorskip("arch.bootstrap")
|
|
126
|
+
L = _losses()
|
|
127
|
+
ours = fl.model_confidence_set(L, size=0.10, reps=2000, block_size=8, random_state=0)
|
|
128
|
+
ref = arch.MCS(L, size=0.10, reps=2000, block_size=8, method="max", bootstrap="mbb", seed=0)
|
|
129
|
+
ref.compute()
|
|
130
|
+
assert set(ours.index[ours["in_set"]]) == set(ref.included)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── splitter ────────────────────────────────────────────────────────────────
|
|
134
|
+
def _panel(n_groups=20, n_times=30):
|
|
135
|
+
g = np.repeat(np.arange(n_groups), n_times)
|
|
136
|
+
t = np.tile(np.arange(n_times), n_groups)
|
|
137
|
+
return g, t
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_group_time_split_holds_out_future_and_new_groups():
|
|
141
|
+
g, t = _panel()
|
|
142
|
+
cv = fl.GroupTimeSplit(t, n_splits=3, gap=2, test_group_frac=0.25, random_state=0)
|
|
143
|
+
folds = list(cv.split(np.zeros(len(g)), groups=g))
|
|
144
|
+
assert len(folds) == 3
|
|
145
|
+
for train, test in folds:
|
|
146
|
+
assert not set(g[train]) & set(g[test])
|
|
147
|
+
assert t[train].max() + 2 < t[test].min()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_time_only_split_keeps_all_groups():
|
|
151
|
+
g, t = _panel()
|
|
152
|
+
cv = fl.GroupTimeSplit(t, n_splits=3, holdout="none")
|
|
153
|
+
for train, test in cv.split(np.zeros(len(g))):
|
|
154
|
+
assert set(g[test]) <= set(g[train])
|
|
155
|
+
assert t[train].max() < t[test].min()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_group_time_split_validates_inputs():
|
|
159
|
+
g, t = _panel()
|
|
160
|
+
with pytest.raises(ValueError):
|
|
161
|
+
fl.GroupTimeSplit(t, holdout="bogus")
|
|
162
|
+
with pytest.raises(ValueError, match="groups"):
|
|
163
|
+
next(fl.GroupTimeSplit(t).split(np.zeros(len(g))))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── split optimism: a leak through the entity shows up ──────────────────────
|
|
167
|
+
def test_split_optimism_exposes_entity_leak():
|
|
168
|
+
rng = np.random.default_rng(4)
|
|
169
|
+
g, t = _panel(n_groups=40, n_times=24)
|
|
170
|
+
level = rng.normal(0, 5, 40)
|
|
171
|
+
y = level[g] + 0.05 * t + rng.normal(0, 0.5, len(g))
|
|
172
|
+
X = np.column_stack([g, t]) # the group code lets a model memorise each entity
|
|
173
|
+
model = RandomForestRegressor(n_estimators=50, random_state=0)
|
|
174
|
+
out = fl.split_optimism(model, X, y, groups=g, time=t, n_splits=3)
|
|
175
|
+
assert out.loc["random", "vs_reference"] < 0.5 # random CV hides most of the error
|
|
176
|
+
assert out.loc["group", "vs_reference"] > 0.7
|