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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ *.zip
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.
@@ -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.
@@ -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