eb-evaluation 0.2.4__tar.gz → 0.2.7__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.
- {eb_evaluation-0.2.4/src/eb_evaluation.egg-info → eb_evaluation-0.2.7}/PKG-INFO +13 -3
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/README.md +2 -2
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/pyproject.toml +21 -3
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/__init__.py +9 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/adjustment/__init__.py +3 -1
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/adjustment/_utils.py +1 -1
- eb_evaluation-0.2.7/src/eb_evaluation/adjustment/ral.py +534 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/__init__.py +23 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/entity.py +4 -7
- eb_evaluation-0.2.7/src/eb_evaluation/dataframe/governance_panel.py +234 -0
- eb_evaluation-0.2.7/src/eb_evaluation/dataframe/governance_workflow.py +202 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/group.py +3 -10
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/hierarchy.py +7 -11
- eb_evaluation-0.2.7/src/eb_evaluation/dataframe/levels.py +141 -0
- eb_evaluation-0.2.7/src/eb_evaluation/dataframe/panel.py +255 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/single.py +1 -3
- eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/__init__.py +203 -0
- eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/dqc.py +440 -0
- eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/fas.py +410 -0
- eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/fpc.py +323 -0
- eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/governance.py +353 -0
- eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/presets.py +250 -0
- eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/results.py +340 -0
- eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/run.py +327 -0
- eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/validate.py +384 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/model_selection/auto_engine.py +86 -4
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/model_selection/compare.py +36 -21
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/model_selection/cwsl_regressor.py +2 -6
- eb_evaluation-0.2.7/src/eb_evaluation/model_selection/electric_barometer.py +918 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/utils/validation.py +1 -3
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7/src/eb_evaluation.egg-info}/PKG-INFO +13 -3
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation.egg-info/SOURCES.txt +12 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation.egg-info/requires.txt +13 -1
- eb_evaluation-0.2.4/src/eb_evaluation/adjustment/ral.py +0 -290
- eb_evaluation-0.2.4/src/eb_evaluation/dataframe/panel.py +0 -140
- eb_evaluation-0.2.4/src/eb_evaluation/model_selection/electric_barometer.py +0 -430
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/LICENSE +0 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/setup.cfg +0 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/model_selection/__init__.py +0 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/utils/__init__.py +0 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation.egg-info/dependency_links.txt +0 -0
- {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: eb-evaluation
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Electric Barometer: DataFrame-based evaluation utilities for CWSL and related metrics.
|
|
5
5
|
Author-email: "Kyle Corrie (Economistician)" <kcorrie@economistician.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -21,14 +21,24 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
Requires-Dist: numpy>=1.24
|
|
23
23
|
Requires-Dist: pandas>=2.0
|
|
24
|
+
Requires-Dist: scikit-learn>=1.3
|
|
24
25
|
Requires-Dist: eb-metrics<0.3,>=0.2
|
|
25
26
|
Requires-Dist: eb-adapters<0.3,>=0.2
|
|
26
27
|
Provides-Extra: test
|
|
27
28
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
28
|
-
Requires-Dist: scikit-learn>=1.3; extra == "test"
|
|
29
29
|
Provides-Extra: dev
|
|
30
30
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
31
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
32
|
+
Provides-Extra: boosting
|
|
33
|
+
Requires-Dist: xgboost>=2.0; extra == "boosting"
|
|
34
|
+
Requires-Dist: lightgbm>=4.0; extra == "boosting"
|
|
35
|
+
Requires-Dist: catboost>=1.2; extra == "boosting"
|
|
36
|
+
Provides-Extra: all
|
|
37
|
+
Requires-Dist: pytest>=8.0; extra == "all"
|
|
38
|
+
Requires-Dist: pytest-cov>=5.0; extra == "all"
|
|
39
|
+
Requires-Dist: xgboost>=2.0; extra == "all"
|
|
40
|
+
Requires-Dist: lightgbm>=4.0; extra == "all"
|
|
41
|
+
Requires-Dist: catboost>=1.2; extra == "all"
|
|
32
42
|
Dynamic: license-file
|
|
33
43
|
|
|
34
44
|
# Electric Barometer · Evaluation (`eb-evaluation`)
|
|
@@ -115,5 +125,5 @@ print(results)
|
|
|
115
125
|
|
|
116
126
|
## License
|
|
117
127
|
|
|
118
|
-
BSD 3-Clause License.
|
|
128
|
+
BSD 3-Clause License.
|
|
119
129
|
© 2025 Kyle Corrie.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
######################################
|
|
4
4
|
[project]
|
|
5
5
|
name = "eb-evaluation"
|
|
6
|
-
version = "0.2.
|
|
6
|
+
version = "0.2.7"
|
|
7
7
|
description = "Electric Barometer: DataFrame-based evaluation utilities for CWSL and related metrics."
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
requires-python = ">=3.10"
|
|
@@ -39,6 +39,7 @@ classifiers = [
|
|
|
39
39
|
dependencies = [
|
|
40
40
|
"numpy>=1.24",
|
|
41
41
|
"pandas>=2.0",
|
|
42
|
+
"scikit-learn>=1.3",
|
|
42
43
|
|
|
43
44
|
# Electric Barometer compatibility band (0.2.x)
|
|
44
45
|
"eb-metrics>=0.2,<0.3",
|
|
@@ -62,7 +63,6 @@ Documentation = "https://github.com/Economistician/eb-docs"
|
|
|
62
63
|
# CI / test-only dependencies
|
|
63
64
|
test = [
|
|
64
65
|
"pytest>=8.0",
|
|
65
|
-
"scikit-learn>=1.3",
|
|
66
66
|
]
|
|
67
67
|
|
|
68
68
|
# Local developer tooling
|
|
@@ -71,6 +71,24 @@ dev = [
|
|
|
71
71
|
"pytest-cov>=5.0",
|
|
72
72
|
]
|
|
73
73
|
|
|
74
|
+
# Optional "boosting engines" commonly used in the AutoEngine zoo.
|
|
75
|
+
# These are intentionally extras because they can be heavy / platform-specific.
|
|
76
|
+
boosting = [
|
|
77
|
+
"xgboost>=2.0",
|
|
78
|
+
"lightgbm>=4.0",
|
|
79
|
+
"catboost>=1.2",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
# Combined extra to satisfy the "extras: all" CI contract.
|
|
83
|
+
# Aggregates optional test + dev tooling + optional engines.
|
|
84
|
+
all = [
|
|
85
|
+
"pytest>=8.0",
|
|
86
|
+
"pytest-cov>=5.0",
|
|
87
|
+
"xgboost>=2.0",
|
|
88
|
+
"lightgbm>=4.0",
|
|
89
|
+
"catboost>=1.2",
|
|
90
|
+
]
|
|
91
|
+
|
|
74
92
|
######################################
|
|
75
93
|
# Build system
|
|
76
94
|
######################################
|
|
@@ -89,4 +107,4 @@ where = ["src"]
|
|
|
89
107
|
######################################
|
|
90
108
|
[tool.pytest.ini_options]
|
|
91
109
|
pythonpath = ["src"]
|
|
92
|
-
addopts = "-ra"
|
|
110
|
+
addopts = "-ra"
|
|
@@ -21,6 +21,7 @@ DataFrame evaluation
|
|
|
21
21
|
- Hierarchical evaluation across multiple aggregation levels
|
|
22
22
|
- Long-form (tidy) panel outputs for plotting and reporting
|
|
23
23
|
- Entity-aware evaluation using externally supplied entity-specific cost ratios
|
|
24
|
+
- Governance-oriented evaluation and workflow orchestration
|
|
24
25
|
|
|
25
26
|
Model selection
|
|
26
27
|
- Cost-aware comparison of forecast models
|
|
@@ -43,18 +44,26 @@ This package is intended to be used alongside ``eb_metrics`` and
|
|
|
43
44
|
``eb-adapters`` as part of the broader Electric Barometer ecosystem.
|
|
44
45
|
"""
|
|
45
46
|
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
46
49
|
from .dataframe import (
|
|
47
50
|
compute_cwsl_df,
|
|
51
|
+
evaluate_governance_panel_df,
|
|
48
52
|
evaluate_groups_df,
|
|
49
53
|
evaluate_hierarchy_df,
|
|
50
54
|
evaluate_panel_df,
|
|
51
55
|
evaluate_panel_with_entity_R,
|
|
56
|
+
run_governance_workflow_df,
|
|
57
|
+
run_governance_workflow_df_dict,
|
|
52
58
|
)
|
|
53
59
|
|
|
54
60
|
__all__ = [
|
|
55
61
|
"compute_cwsl_df",
|
|
62
|
+
"evaluate_governance_panel_df",
|
|
56
63
|
"evaluate_groups_df",
|
|
57
64
|
"evaluate_hierarchy_df",
|
|
58
65
|
"evaluate_panel_df",
|
|
59
66
|
"evaluate_panel_with_entity_R",
|
|
67
|
+
"run_governance_workflow_df",
|
|
68
|
+
"run_governance_workflow_df_dict",
|
|
60
69
|
]
|
|
@@ -16,10 +16,12 @@ Key ideas
|
|
|
16
16
|
Public API
|
|
17
17
|
----------
|
|
18
18
|
- `ReadinessAdjustmentLayer`
|
|
19
|
+
- `apply_ral`
|
|
19
20
|
"""
|
|
20
21
|
|
|
21
|
-
from .ral import ReadinessAdjustmentLayer
|
|
22
|
+
from .ral import ReadinessAdjustmentLayer, apply_ral
|
|
22
23
|
|
|
23
24
|
__all__ = [
|
|
24
25
|
"ReadinessAdjustmentLayer",
|
|
26
|
+
"apply_ral",
|
|
25
27
|
]
|
|
@@ -55,7 +55,7 @@ def validate_numeric_array(arr: ArrayLike, name: str = "array") -> np.ndarray:
|
|
|
55
55
|
- This helper does *not* drop missing values. If you need filtering behavior, perform
|
|
56
56
|
it upstream and call this only once the array should be clean.
|
|
57
57
|
"""
|
|
58
|
-
if isinstance(arr,
|
|
58
|
+
if isinstance(arr, pd.Series | pd.DataFrame):
|
|
59
59
|
arr = arr.to_numpy()
|
|
60
60
|
|
|
61
61
|
out = np.asarray(arr, dtype=float)
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Readiness Adjustment Layer (RAL): deterministic fit + apply in eb-evaluation.
|
|
3
|
+
|
|
4
|
+
This module implements a transparent post-processing step that converts a baseline forecast
|
|
5
|
+
into an operationally conservative readiness forecast via a learned uplift.
|
|
6
|
+
|
|
7
|
+
Responsibilities
|
|
8
|
+
---------------
|
|
9
|
+
- Fit a simple uplift policy via grid search that minimizes CWSL.
|
|
10
|
+
- Apply learned uplift factors to new data (global or segmented).
|
|
11
|
+
- Provide before/after diagnostics for auditability.
|
|
12
|
+
- Provide a canonical apply_ral(...) utility that joins governance decisions and applies
|
|
13
|
+
governed post-processing (nonneg + snap-to-grid) to prediction columns.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from eb_evaluation.diagnostics.governance import snap_to_grid
|
|
25
|
+
from eb_metrics.metrics import cwsl
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ReadinessAdjustmentLayer:
|
|
29
|
+
"""Readiness Adjustment Layer (RAL) for operational forecast uplift."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
cu: float | None = None,
|
|
35
|
+
co: float | None = None,
|
|
36
|
+
uplift_min: float = 1.0,
|
|
37
|
+
uplift_max: float = 1.2,
|
|
38
|
+
grid_step: float = 0.01,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.cu = None if cu is None else float(cu)
|
|
41
|
+
self.co = None if co is None else float(co)
|
|
42
|
+
|
|
43
|
+
self.uplift_min = float(uplift_min)
|
|
44
|
+
self.uplift_max = float(uplift_max)
|
|
45
|
+
self.grid_step = float(grid_step)
|
|
46
|
+
|
|
47
|
+
# Learned artifacts (set by fit)
|
|
48
|
+
self.global_uplift_: float | None = None
|
|
49
|
+
self.segment_cols_: list[str] = []
|
|
50
|
+
self.uplift_table_: pd.DataFrame | None = None
|
|
51
|
+
self.diagnostics_: pd.DataFrame = pd.DataFrame()
|
|
52
|
+
|
|
53
|
+
# ----------------------------
|
|
54
|
+
# Internal helpers
|
|
55
|
+
# ----------------------------
|
|
56
|
+
def _require_costs(self) -> tuple[float, float]:
|
|
57
|
+
if self.cu is None or self.co is None:
|
|
58
|
+
raise TypeError("ReadinessAdjustmentLayer requires cu and co to be set (via __init__).")
|
|
59
|
+
return float(self.cu), float(self.co)
|
|
60
|
+
|
|
61
|
+
def _grid(self) -> np.ndarray:
|
|
62
|
+
"""Construct an uplift grid that reliably includes uplift_max."""
|
|
63
|
+
if self.grid_step <= 0:
|
|
64
|
+
raise ValueError("grid_step must be > 0.")
|
|
65
|
+
if self.uplift_max < self.uplift_min:
|
|
66
|
+
raise ValueError("uplift_max must be >= uplift_min.")
|
|
67
|
+
|
|
68
|
+
# Use arange then explicitly ensure endpoint inclusion.
|
|
69
|
+
grid = np.arange(
|
|
70
|
+
self.uplift_min,
|
|
71
|
+
self.uplift_max + (self.grid_step / 2.0),
|
|
72
|
+
self.grid_step,
|
|
73
|
+
dtype=float,
|
|
74
|
+
)
|
|
75
|
+
grid = np.clip(grid, self.uplift_min, self.uplift_max)
|
|
76
|
+
|
|
77
|
+
# Ensure uplift_max is present (avoid floating step drift)
|
|
78
|
+
if not np.isclose(grid[-1], self.uplift_max, rtol=0.0, atol=1e-12):
|
|
79
|
+
grid = np.append(grid, float(self.uplift_max))
|
|
80
|
+
else:
|
|
81
|
+
grid[-1] = float(self.uplift_max)
|
|
82
|
+
|
|
83
|
+
# De-dup and sort (append could create duplicates in rare cases)
|
|
84
|
+
grid = np.unique(grid)
|
|
85
|
+
return grid
|
|
86
|
+
|
|
87
|
+
def _best_uplift(
|
|
88
|
+
self,
|
|
89
|
+
y_true: np.ndarray,
|
|
90
|
+
y_pred: np.ndarray,
|
|
91
|
+
*,
|
|
92
|
+
cu: float,
|
|
93
|
+
co: float,
|
|
94
|
+
sample_weight: np.ndarray | None = None,
|
|
95
|
+
) -> tuple[float, float, float]:
|
|
96
|
+
"""Return (best_uplift, cwsl_before, cwsl_after)."""
|
|
97
|
+
y_true = y_true.astype(float, copy=False)
|
|
98
|
+
y_pred = y_pred.astype(float, copy=False)
|
|
99
|
+
|
|
100
|
+
before = float(
|
|
101
|
+
cwsl(y_true=y_true, y_pred=y_pred, cu=cu, co=co, sample_weight=sample_weight)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
grid = self._grid()
|
|
105
|
+
best_u = float(grid[0])
|
|
106
|
+
best_loss = float("inf")
|
|
107
|
+
|
|
108
|
+
for u in grid:
|
|
109
|
+
loss = float(
|
|
110
|
+
cwsl(
|
|
111
|
+
y_true=y_true,
|
|
112
|
+
y_pred=y_pred * float(u),
|
|
113
|
+
cu=cu,
|
|
114
|
+
co=co,
|
|
115
|
+
sample_weight=sample_weight,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
# Tie-break: prefer the smaller uplift
|
|
119
|
+
if (loss < best_loss) or (abs(loss - best_loss) < 1e-12 and float(u) < best_u):
|
|
120
|
+
best_loss = loss
|
|
121
|
+
best_u = float(u)
|
|
122
|
+
|
|
123
|
+
return best_u, before, best_loss
|
|
124
|
+
|
|
125
|
+
# ----------------------------
|
|
126
|
+
# Public API
|
|
127
|
+
# ----------------------------
|
|
128
|
+
def fit(
|
|
129
|
+
self,
|
|
130
|
+
df: pd.DataFrame,
|
|
131
|
+
*,
|
|
132
|
+
forecast_col: str,
|
|
133
|
+
actual_col: str,
|
|
134
|
+
segment_cols: Sequence[str] | None = None,
|
|
135
|
+
sample_weight_col: str | None = None,
|
|
136
|
+
) -> ReadinessAdjustmentLayer:
|
|
137
|
+
cu, co = self._require_costs()
|
|
138
|
+
|
|
139
|
+
required = [forecast_col, actual_col]
|
|
140
|
+
missing = [c for c in required if c not in df.columns]
|
|
141
|
+
if missing:
|
|
142
|
+
raise KeyError(f"Missing required columns for fit(): {missing}")
|
|
143
|
+
|
|
144
|
+
if sample_weight_col is not None and sample_weight_col not in df.columns:
|
|
145
|
+
raise KeyError(f"sample_weight_col {sample_weight_col!r} not found in DataFrame.")
|
|
146
|
+
|
|
147
|
+
seg_cols = list(segment_cols) if segment_cols is not None else []
|
|
148
|
+
if seg_cols:
|
|
149
|
+
seg_missing = [c for c in seg_cols if c not in df.columns]
|
|
150
|
+
if seg_missing:
|
|
151
|
+
raise KeyError(f"Missing segment columns for fit(): {seg_missing}")
|
|
152
|
+
|
|
153
|
+
y_true_all = df[actual_col].to_numpy(dtype=float)
|
|
154
|
+
y_pred_all = df[forecast_col].to_numpy(dtype=float)
|
|
155
|
+
w_all = df[sample_weight_col].to_numpy(dtype=float) if sample_weight_col else None
|
|
156
|
+
|
|
157
|
+
# Fit global uplift on full data (used as fallback)
|
|
158
|
+
global_u, g_before, g_after = self._best_uplift(
|
|
159
|
+
y_true_all,
|
|
160
|
+
y_pred_all,
|
|
161
|
+
cu=cu,
|
|
162
|
+
co=co,
|
|
163
|
+
sample_weight=w_all,
|
|
164
|
+
)
|
|
165
|
+
self.global_uplift_ = float(global_u)
|
|
166
|
+
|
|
167
|
+
diagnostics_rows: list[dict] = [
|
|
168
|
+
{
|
|
169
|
+
"scope": "global",
|
|
170
|
+
"uplift": float(global_u),
|
|
171
|
+
"cwsl_before": float(g_before),
|
|
172
|
+
"cwsl_after": float(g_after),
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
# Fit per-segment uplifts if requested
|
|
177
|
+
self.segment_cols_ = seg_cols
|
|
178
|
+
self.uplift_table_ = None
|
|
179
|
+
|
|
180
|
+
if seg_cols:
|
|
181
|
+
table_rows: list[dict] = []
|
|
182
|
+
|
|
183
|
+
grouped = df.groupby(seg_cols, dropna=False, sort=False)
|
|
184
|
+
for key, g in grouped:
|
|
185
|
+
key_vals = (key,) if not isinstance(key, tuple) else key
|
|
186
|
+
|
|
187
|
+
y_true = g[actual_col].to_numpy(dtype=float)
|
|
188
|
+
y_pred = g[forecast_col].to_numpy(dtype=float)
|
|
189
|
+
w = g[sample_weight_col].to_numpy(dtype=float) if sample_weight_col else None
|
|
190
|
+
|
|
191
|
+
best_u, before, after = self._best_uplift(
|
|
192
|
+
y_true,
|
|
193
|
+
y_pred,
|
|
194
|
+
cu=cu,
|
|
195
|
+
co=co,
|
|
196
|
+
sample_weight=w,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
best_u = float(best_u)
|
|
200
|
+
|
|
201
|
+
row = dict(zip(seg_cols, key_vals, strict=False))
|
|
202
|
+
row["uplift"] = best_u
|
|
203
|
+
table_rows.append(row)
|
|
204
|
+
|
|
205
|
+
diag = dict(zip(seg_cols, key_vals, strict=False))
|
|
206
|
+
diag.update(
|
|
207
|
+
{
|
|
208
|
+
"scope": "segment",
|
|
209
|
+
"uplift": best_u,
|
|
210
|
+
"cwsl_before": float(before),
|
|
211
|
+
"cwsl_after": float(after),
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
diagnostics_rows.append(diag)
|
|
215
|
+
|
|
216
|
+
self.uplift_table_ = pd.DataFrame(table_rows)
|
|
217
|
+
|
|
218
|
+
self.diagnostics_ = pd.DataFrame(diagnostics_rows)
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def transform(
|
|
222
|
+
self,
|
|
223
|
+
df: pd.DataFrame,
|
|
224
|
+
*,
|
|
225
|
+
forecast_col: str,
|
|
226
|
+
output_col: str = "readiness_forecast",
|
|
227
|
+
segment_cols: Sequence[str] | None = None,
|
|
228
|
+
) -> pd.DataFrame:
|
|
229
|
+
"""Apply learned uplift factors to produce readiness forecasts.
|
|
230
|
+
|
|
231
|
+
Test expectation:
|
|
232
|
+
- If called before explicit fit(), this should still work for global uplift by
|
|
233
|
+
implicitly fitting on the provided dataframe (requires an actual column), but only
|
|
234
|
+
when costs (cu/co) are set.
|
|
235
|
+
"""
|
|
236
|
+
if forecast_col not in df.columns:
|
|
237
|
+
raise KeyError(f"Column {forecast_col!r} not found in DataFrame.")
|
|
238
|
+
|
|
239
|
+
# If not fit yet, optionally implicitly fit globally (ONLY when cu/co are set).
|
|
240
|
+
if self.global_uplift_ is None or self.diagnostics_.empty:
|
|
241
|
+
if self.cu is None or self.co is None:
|
|
242
|
+
# Required by test_transform_raises_if_not_fit
|
|
243
|
+
raise RuntimeError("ReadinessAdjustmentLayer must be fit() before transform().")
|
|
244
|
+
|
|
245
|
+
if "actual" not in df.columns:
|
|
246
|
+
raise RuntimeError(
|
|
247
|
+
"ReadinessAdjustmentLayer must be fit() before transform(), "
|
|
248
|
+
"or provide an 'actual' column to allow implicit global fit."
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
self.fit(df, forecast_col=forecast_col, actual_col="actual", segment_cols=None)
|
|
252
|
+
|
|
253
|
+
# Narrow Optional[float] for type-checkers (runtime behavior unchanged).
|
|
254
|
+
global_uplift = self.global_uplift_
|
|
255
|
+
if global_uplift is None:
|
|
256
|
+
raise RuntimeError("ReadinessAdjustmentLayer must be fit() before transform().")
|
|
257
|
+
|
|
258
|
+
seg_cols = list(segment_cols) if segment_cols is not None else list(self.segment_cols_)
|
|
259
|
+
result_df = df.copy()
|
|
260
|
+
|
|
261
|
+
if seg_cols and self.uplift_table_ is not None and not self.uplift_table_.empty:
|
|
262
|
+
missing = [c for c in seg_cols if c not in result_df.columns]
|
|
263
|
+
if missing:
|
|
264
|
+
raise KeyError(
|
|
265
|
+
f"Missing segment columns for transform(): {missing}. "
|
|
266
|
+
f"Available columns: {list(result_df.columns)}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
merged = result_df.merge(self.uplift_table_, on=seg_cols, how="left")
|
|
270
|
+
uplift = merged["uplift"].to_numpy(dtype=float)
|
|
271
|
+
|
|
272
|
+
mask_nan = ~np.isfinite(uplift)
|
|
273
|
+
if mask_nan.any():
|
|
274
|
+
uplift[mask_nan] = float(global_uplift)
|
|
275
|
+
else:
|
|
276
|
+
uplift = np.full(len(result_df), float(global_uplift), dtype=float)
|
|
277
|
+
|
|
278
|
+
result_df[output_col] = result_df[forecast_col].to_numpy(dtype=float) * uplift
|
|
279
|
+
return result_df
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
NonnegPolicy = Literal["allow", "clip_zero"]
|
|
283
|
+
SnapMode = Literal["ceil", "round", "floor"]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _infer_nonneg_policy_from_recommendations(recs: Sequence[str] | None) -> NonnegPolicy:
|
|
287
|
+
if not recs:
|
|
288
|
+
return "allow"
|
|
289
|
+
for r in recs:
|
|
290
|
+
if r == "forecast_postprocess_nonneg(mode=clip_zero)":
|
|
291
|
+
return "clip_zero"
|
|
292
|
+
return "allow"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _infer_snap_mode_from_recommendations(
|
|
296
|
+
recs: Sequence[str] | None, *, default: SnapMode
|
|
297
|
+
) -> SnapMode:
|
|
298
|
+
if not recs:
|
|
299
|
+
return default
|
|
300
|
+
prefix = "snap_forecasts_to_grid(mode="
|
|
301
|
+
for r in recs:
|
|
302
|
+
if r.startswith(prefix) and r.endswith(")"):
|
|
303
|
+
mode = r[len(prefix) : -1]
|
|
304
|
+
if mode in ("ceil", "round", "floor"):
|
|
305
|
+
return mode # type: ignore[return-value]
|
|
306
|
+
return default
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _apply_nonneg_policy(values: np.ndarray, *, policy: NonnegPolicy) -> np.ndarray:
|
|
310
|
+
if policy == "allow":
|
|
311
|
+
return values
|
|
312
|
+
return np.maximum(values, 0.0)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _apply_snap_policy_series(
|
|
316
|
+
values: pd.Series,
|
|
317
|
+
units: pd.Series,
|
|
318
|
+
*,
|
|
319
|
+
mode: SnapMode,
|
|
320
|
+
) -> pd.Series:
|
|
321
|
+
"""
|
|
322
|
+
Apply snap-to-grid with per-row (possibly varying) units.
|
|
323
|
+
|
|
324
|
+
If the unit is constant (typical), we snap in one pass. If it varies, we
|
|
325
|
+
snap per-unit group to avoid row-wise Python loops.
|
|
326
|
+
"""
|
|
327
|
+
v = values.to_numpy(dtype=float, copy=False)
|
|
328
|
+
u = units.to_numpy(dtype=float, copy=False)
|
|
329
|
+
|
|
330
|
+
mask = np.isfinite(v) & np.isfinite(u) & (u > 0.0)
|
|
331
|
+
if not mask.any():
|
|
332
|
+
return values
|
|
333
|
+
|
|
334
|
+
uniq_units = np.unique(u[mask])
|
|
335
|
+
if uniq_units.size == 1:
|
|
336
|
+
unit = float(uniq_units[0])
|
|
337
|
+
snapped = np.asarray(snap_to_grid(v.tolist(), unit, mode=mode), dtype=float)
|
|
338
|
+
out = v.copy()
|
|
339
|
+
out[:] = snapped
|
|
340
|
+
return pd.Series(out, index=values.index, name=values.name)
|
|
341
|
+
|
|
342
|
+
out = v.copy()
|
|
343
|
+
for unit in uniq_units.tolist():
|
|
344
|
+
unit_f = float(unit)
|
|
345
|
+
idx = mask & (u == unit_f)
|
|
346
|
+
snapped_sub = np.asarray(snap_to_grid(v[idx].tolist(), unit_f, mode=mode), dtype=float)
|
|
347
|
+
out[idx] = snapped_sub
|
|
348
|
+
return pd.Series(out, index=values.index, name=values.name)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def apply_ral(
|
|
352
|
+
df: pd.DataFrame,
|
|
353
|
+
*,
|
|
354
|
+
# --- legacy alias kwargs (kept for compatibility with older tests/callers) ---
|
|
355
|
+
join_keys: Sequence[str] | None = None,
|
|
356
|
+
pred_col: str | None = None,
|
|
357
|
+
output_col: str | None = None,
|
|
358
|
+
nonneg_mode: str | None = None,
|
|
359
|
+
# --- canonical inputs ---
|
|
360
|
+
decisions: pd.DataFrame | None = None,
|
|
361
|
+
key_cols: Sequence[str] = ("forecast_entity_id",),
|
|
362
|
+
yhat_base_col: str = "yhat_base",
|
|
363
|
+
yhat_ral_col: str | None = "yhat_ral",
|
|
364
|
+
uplift_col: str | None = None,
|
|
365
|
+
snap_required_col: str = "snap_required",
|
|
366
|
+
snap_unit_col: str = "snap_unit",
|
|
367
|
+
recommendations_col: str = "recommendations",
|
|
368
|
+
snap_mode: SnapMode = "ceil",
|
|
369
|
+
nonneg_policy: NonnegPolicy | None = None,
|
|
370
|
+
infer_policy_from_recommendations: bool = True,
|
|
371
|
+
out_base_col: str = "yhat_base_governed",
|
|
372
|
+
out_ral_col: str = "yhat_ral_governed",
|
|
373
|
+
out_audit_prefix: str = "ral_apply_",
|
|
374
|
+
) -> pd.DataFrame:
|
|
375
|
+
"""
|
|
376
|
+
Canonical RAL application utility:
|
|
377
|
+
- optionally joins governance decisions onto a panel,
|
|
378
|
+
- produces a raw RAL prediction column (from yhat_ral_col or uplift_col),
|
|
379
|
+
- applies governed nonnegativity + snap-to-grid policies,
|
|
380
|
+
- emits governed prediction columns + audit columns.
|
|
381
|
+
|
|
382
|
+
Notes
|
|
383
|
+
-----
|
|
384
|
+
- This function does *not* fit RAL. It applies already-produced predictions.
|
|
385
|
+
- If yhat_ral_col is None, uplift_col must be provided and we compute:
|
|
386
|
+
yhat_ral_raw = yhat_base * uplift
|
|
387
|
+
- If infer_policy_from_recommendations is True and recommendations exist,
|
|
388
|
+
policy is inferred from stable recommendation strings emitted by run.py.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
# ---- apply legacy aliases (if provided) ----
|
|
392
|
+
if join_keys is not None:
|
|
393
|
+
key_cols = join_keys
|
|
394
|
+
if pred_col is not None:
|
|
395
|
+
# Legacy callers/tests pass the *prediction to govern* (not a baseline).
|
|
396
|
+
yhat_ral_col = pred_col
|
|
397
|
+
# If a baseline column isn't present, reuse the same prediction column so the
|
|
398
|
+
# function can still emit out_base_col deterministically.
|
|
399
|
+
if yhat_base_col not in df.columns:
|
|
400
|
+
yhat_base_col = pred_col
|
|
401
|
+
if output_col is not None:
|
|
402
|
+
out_ral_col = output_col
|
|
403
|
+
if nonneg_mode is not None and nonneg_policy is None:
|
|
404
|
+
# Map legacy run.py-style names -> policy
|
|
405
|
+
if nonneg_mode in ("none", "allow"):
|
|
406
|
+
nonneg_policy = "allow"
|
|
407
|
+
elif nonneg_mode in ("clip", "clip_zero"):
|
|
408
|
+
nonneg_policy = "clip_zero"
|
|
409
|
+
else:
|
|
410
|
+
raise ValueError(
|
|
411
|
+
"apply_ral: nonneg_mode must be one of {'none','clip'} "
|
|
412
|
+
"(or use nonneg_policy={'allow','clip_zero'})."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if yhat_base_col not in df.columns:
|
|
416
|
+
raise KeyError(f"apply_ral: missing required column {yhat_base_col!r} in df.")
|
|
417
|
+
|
|
418
|
+
keys = list(key_cols)
|
|
419
|
+
for k in keys:
|
|
420
|
+
if k not in df.columns:
|
|
421
|
+
raise KeyError(f"apply_ral: missing key column {k!r} in df.")
|
|
422
|
+
|
|
423
|
+
work = df.copy()
|
|
424
|
+
|
|
425
|
+
if decisions is not None:
|
|
426
|
+
for k in keys:
|
|
427
|
+
if k not in decisions.columns:
|
|
428
|
+
raise KeyError(f"apply_ral: missing key column {k!r} in decisions.")
|
|
429
|
+
|
|
430
|
+
merged = work.merge(decisions, on=keys, how="left", indicator=True)
|
|
431
|
+
missing = merged.loc[merged["_merge"] != "both", keys]
|
|
432
|
+
if not missing.empty:
|
|
433
|
+
# Fail loudly: prevent silent "policy missing" behavior.
|
|
434
|
+
missing_keys = missing.drop_duplicates().to_dict(orient="records")[
|
|
435
|
+
:10
|
|
436
|
+
] # cap for readability
|
|
437
|
+
raise ValueError(
|
|
438
|
+
"apply_ral: missing governance decision rows for some join keys. "
|
|
439
|
+
f"Examples: {missing_keys}"
|
|
440
|
+
)
|
|
441
|
+
work = merged.drop(columns=["_merge"])
|
|
442
|
+
|
|
443
|
+
# Build raw RAL prediction stream.
|
|
444
|
+
if yhat_ral_col is not None:
|
|
445
|
+
if yhat_ral_col not in work.columns:
|
|
446
|
+
raise KeyError(f"apply_ral: missing column {yhat_ral_col!r} in df.")
|
|
447
|
+
yhat_ral_raw = work[yhat_ral_col].to_numpy(dtype=float)
|
|
448
|
+
else:
|
|
449
|
+
if uplift_col is None:
|
|
450
|
+
raise ValueError("apply_ral: yhat_ral_col is None, so uplift_col must be provided.")
|
|
451
|
+
if uplift_col not in work.columns:
|
|
452
|
+
raise KeyError(f"apply_ral: missing uplift column {uplift_col!r} in df.")
|
|
453
|
+
yhat_ral_raw = work[yhat_base_col].to_numpy(dtype=float) * work[uplift_col].to_numpy(
|
|
454
|
+
dtype=float
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
yhat_base_raw = work[yhat_base_col].to_numpy(dtype=float)
|
|
458
|
+
|
|
459
|
+
# Determine effective policy:
|
|
460
|
+
# - If nonneg_policy explicitly provided, use it.
|
|
461
|
+
# - Else, optionally infer from recommendations (first-row), otherwise allow.
|
|
462
|
+
if recommendations_col in work.columns and infer_policy_from_recommendations:
|
|
463
|
+
recs_first = work[recommendations_col].iloc[0] if len(work) else None
|
|
464
|
+
else:
|
|
465
|
+
recs_first = None
|
|
466
|
+
|
|
467
|
+
nonneg_eff: NonnegPolicy
|
|
468
|
+
if nonneg_policy is not None:
|
|
469
|
+
nonneg_eff = nonneg_policy
|
|
470
|
+
else:
|
|
471
|
+
nonneg_eff = _infer_nonneg_policy_from_recommendations(recs_first)
|
|
472
|
+
|
|
473
|
+
snap_mode_eff: SnapMode = snap_mode
|
|
474
|
+
if infer_policy_from_recommendations and len(work):
|
|
475
|
+
snap_mode_eff = _infer_snap_mode_from_recommendations(recs_first, default=snap_mode)
|
|
476
|
+
|
|
477
|
+
# Apply nonnegativity first (matches run.py behavior: constrain before diagnostics/snapping).
|
|
478
|
+
yhat_base_g = _apply_nonneg_policy(yhat_base_raw.copy(), policy=nonneg_eff)
|
|
479
|
+
yhat_ral_g = _apply_nonneg_policy(yhat_ral_raw.copy(), policy=nonneg_eff)
|
|
480
|
+
|
|
481
|
+
work[out_base_col] = yhat_base_g
|
|
482
|
+
work[out_ral_col] = yhat_ral_g
|
|
483
|
+
|
|
484
|
+
# Apply snapping if required and unit available.
|
|
485
|
+
if snap_required_col in work.columns and snap_unit_col in work.columns:
|
|
486
|
+
snap_required_s = work[snap_required_col].astype(bool)
|
|
487
|
+
snap_unit_obj = pd.to_numeric(work[snap_unit_col], errors="coerce")
|
|
488
|
+
snap_unit_s = (
|
|
489
|
+
snap_unit_obj
|
|
490
|
+
if isinstance(snap_unit_obj, pd.Series)
|
|
491
|
+
else pd.Series(snap_unit_obj, index=work.index, name=snap_unit_col)
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Only snap rows where required + a finite positive unit exists.
|
|
495
|
+
need = snap_required_s & snap_unit_s.notna() & (snap_unit_s > 0.0)
|
|
496
|
+
if bool(need.any()):
|
|
497
|
+
base_series = work.loc[need, out_base_col]
|
|
498
|
+
ral_series = work.loc[need, out_ral_col]
|
|
499
|
+
unit_series = snap_unit_s.loc[need]
|
|
500
|
+
|
|
501
|
+
work.loc[need, out_base_col] = _apply_snap_policy_series(
|
|
502
|
+
base_series, unit_series, mode=snap_mode_eff
|
|
503
|
+
)
|
|
504
|
+
work.loc[need, out_ral_col] = _apply_snap_policy_series(
|
|
505
|
+
ral_series, unit_series, mode=snap_mode_eff
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# If snapping can reintroduce negatives (e.g., round on negatives), re-apply nonneg.
|
|
509
|
+
if nonneg_eff != "allow":
|
|
510
|
+
idx = work.loc[need, out_base_col].index
|
|
511
|
+
work.loc[need, out_base_col] = pd.Series(
|
|
512
|
+
_apply_nonneg_policy(
|
|
513
|
+
work.loc[need, out_base_col].to_numpy(dtype=float),
|
|
514
|
+
policy=nonneg_eff,
|
|
515
|
+
),
|
|
516
|
+
index=idx,
|
|
517
|
+
name=out_base_col,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
idx2 = work.loc[need, out_ral_col].index
|
|
521
|
+
work.loc[need, out_ral_col] = pd.Series(
|
|
522
|
+
_apply_nonneg_policy(
|
|
523
|
+
work.loc[need, out_ral_col].to_numpy(dtype=float),
|
|
524
|
+
policy=nonneg_eff,
|
|
525
|
+
),
|
|
526
|
+
index=idx2,
|
|
527
|
+
name=out_ral_col,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
# Audit columns (stable, lightweight)
|
|
531
|
+
work[f"{out_audit_prefix}nonneg_policy"] = nonneg_eff
|
|
532
|
+
work[f"{out_audit_prefix}snap_mode"] = snap_mode_eff
|
|
533
|
+
|
|
534
|
+
return work
|