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.
Files changed (42) hide show
  1. {eb_evaluation-0.2.4/src/eb_evaluation.egg-info → eb_evaluation-0.2.7}/PKG-INFO +13 -3
  2. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/README.md +2 -2
  3. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/pyproject.toml +21 -3
  4. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/__init__.py +9 -0
  5. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/adjustment/__init__.py +3 -1
  6. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/adjustment/_utils.py +1 -1
  7. eb_evaluation-0.2.7/src/eb_evaluation/adjustment/ral.py +534 -0
  8. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/__init__.py +23 -0
  9. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/entity.py +4 -7
  10. eb_evaluation-0.2.7/src/eb_evaluation/dataframe/governance_panel.py +234 -0
  11. eb_evaluation-0.2.7/src/eb_evaluation/dataframe/governance_workflow.py +202 -0
  12. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/group.py +3 -10
  13. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/hierarchy.py +7 -11
  14. eb_evaluation-0.2.7/src/eb_evaluation/dataframe/levels.py +141 -0
  15. eb_evaluation-0.2.7/src/eb_evaluation/dataframe/panel.py +255 -0
  16. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/dataframe/single.py +1 -3
  17. eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/__init__.py +203 -0
  18. eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/dqc.py +440 -0
  19. eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/fas.py +410 -0
  20. eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/fpc.py +323 -0
  21. eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/governance.py +353 -0
  22. eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/presets.py +250 -0
  23. eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/results.py +340 -0
  24. eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/run.py +327 -0
  25. eb_evaluation-0.2.7/src/eb_evaluation/diagnostics/validate.py +384 -0
  26. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/model_selection/auto_engine.py +86 -4
  27. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/model_selection/compare.py +36 -21
  28. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/model_selection/cwsl_regressor.py +2 -6
  29. eb_evaluation-0.2.7/src/eb_evaluation/model_selection/electric_barometer.py +918 -0
  30. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/utils/validation.py +1 -3
  31. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7/src/eb_evaluation.egg-info}/PKG-INFO +13 -3
  32. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation.egg-info/SOURCES.txt +12 -0
  33. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation.egg-info/requires.txt +13 -1
  34. eb_evaluation-0.2.4/src/eb_evaluation/adjustment/ral.py +0 -290
  35. eb_evaluation-0.2.4/src/eb_evaluation/dataframe/panel.py +0 -140
  36. eb_evaluation-0.2.4/src/eb_evaluation/model_selection/electric_barometer.py +0 -430
  37. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/LICENSE +0 -0
  38. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/setup.cfg +0 -0
  39. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/model_selection/__init__.py +0 -0
  40. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation/utils/__init__.py +0 -0
  41. {eb_evaluation-0.2.4 → eb_evaluation-0.2.7}/src/eb_evaluation.egg-info/dependency_links.txt +0 -0
  42. {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.4
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.
@@ -82,5 +82,5 @@ print(results)
82
82
 
83
83
  ## License
84
84
 
85
- BSD 3-Clause License.
86
- © 2025 Kyle Corrie.
85
+ BSD 3-Clause License.
86
+ © 2025 Kyle Corrie.
@@ -3,7 +3,7 @@
3
3
  ######################################
4
4
  [project]
5
5
  name = "eb-evaluation"
6
- version = "0.2.4"
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, (pd.Series, pd.DataFrame)):
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