eb-optimization 0.1.0__py3-none-any.whl

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,144 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Offline tuning for the Readiness Adjustment Layer (RAL).
5
+
6
+ This module contains optimization logic for selecting RAL policy parameters
7
+ by minimizing Electric Barometer objectives (primarily Cost-Weighted Service
8
+ Loss) over historical data.
9
+
10
+ Responsibilities:
11
+ - Search bounded uplift grids to select optimal RAL parameters
12
+ - Produce portable RALPolicy artifacts
13
+ - Emit audit-ready diagnostics for governance and analysis
14
+
15
+ Non-responsibilities:
16
+ - Applying policies to forecasts
17
+ - Defining metric math (delegated to `eb-metrics`)
18
+ - Production-time inference or real-time decisioning
19
+ """
20
+
21
+ from typing import List, Dict, Optional, Sequence, Tuple
22
+ import numpy as np
23
+ import pandas as pd
24
+ from eb_metrics.metrics import cwsl, frs, nsl
25
+ from eb_optimization.policies.ral_policy import RALPolicy
26
+ from eb_optimization.search.grid import make_float_grid
27
+
28
+ def tune_ral_policy(
29
+ df: pd.DataFrame,
30
+ *,
31
+ forecast_col: str,
32
+ actual_col: str,
33
+ cu: float = 2.0,
34
+ co: float = 1.0,
35
+ uplift_min: float = 1.0,
36
+ uplift_max: float = 1.15,
37
+ grid_step: float = 0.01,
38
+ segment_cols: Optional[Sequence[str]] = None,
39
+ sample_weight_col: Optional[str] = None,
40
+ ) -> Tuple[RALPolicy, pd.DataFrame]:
41
+ """Tune a Readiness Adjustment Layer (RAL) policy via discrete grid search.
42
+
43
+ This function performs *offline* tuning to select multiplicative uplift factors
44
+ that convert a baseline forecast into an operationally conservative readiness forecast.
45
+
46
+ The optimization objective is **Cost-Weighted Service Loss (CWSL)**.
47
+ """
48
+
49
+ # Ensure necessary columns are present
50
+ if forecast_col not in df.columns or actual_col not in df.columns:
51
+ raise ValueError("Required columns missing in the DataFrame.")
52
+
53
+ # Initialize grid for candidate ratios
54
+ uplift_grid = make_float_grid(uplift_min, uplift_max, grid_step)
55
+
56
+ # Prepare the DataFrame for tuning
57
+ y_true_all = df[actual_col].to_numpy(dtype=float)
58
+ y_pred_all = df[forecast_col].to_numpy(dtype=float)
59
+
60
+ if sample_weight_col is not None:
61
+ sample_weights = df[sample_weight_col].to_numpy(dtype=float)
62
+ else:
63
+ sample_weights = np.ones_like(y_true_all)
64
+
65
+ # Initialize variables for best uplift and diagnostics
66
+ best_uplift = None
67
+ best_cu = None
68
+ best_over_cost = None
69
+ best_under_cost = None
70
+ best_diff = None
71
+
72
+ diagnostics = []
73
+
74
+ # Tune the policy globally (and by segment if needed)
75
+ if segment_cols:
76
+ grouped = df.groupby(segment_cols)
77
+ else:
78
+ grouped = [('', df)] # No segmentation, use the entire DataFrame
79
+
80
+ for segment_key, group in grouped:
81
+ y_true = group[actual_col].to_numpy(dtype=float)
82
+ y_pred = group[forecast_col].to_numpy(dtype=float)
83
+ weights = sample_weights[:len(y_true)]
84
+
85
+ # Calculate shortfall and overbuild
86
+ shortfall = np.maximum(0, y_true - y_pred)
87
+ overbuild = np.maximum(0, y_pred - y_true)
88
+
89
+ # Perform grid search to find the best uplift for the segment
90
+ best_segment_uplift, best_segment_costs = _find_best_uplift(
91
+ uplift_grid, shortfall, overbuild, weights, cu, co
92
+ )
93
+
94
+ # Store diagnostics
95
+ diagnostics.append({
96
+ "segment": segment_key,
97
+ "uplift": best_segment_uplift,
98
+ "under_cost": best_segment_costs['under_cost'],
99
+ "over_cost": best_segment_costs['over_cost'],
100
+ "diff": best_segment_costs['diff'],
101
+ })
102
+
103
+ # Track the overall best uplift
104
+ if best_diff is None or best_segment_costs['diff'] < best_diff:
105
+ best_diff = best_segment_costs['diff']
106
+ best_uplift = best_segment_uplift
107
+ best_cu = best_segment_costs['under_cost']
108
+ best_over_cost = best_segment_costs['over_cost']
109
+
110
+ # Create the RALPolicy object
111
+ policy = RALPolicy(
112
+ global_uplift=best_uplift,
113
+ uplift_table=pd.DataFrame(diagnostics),
114
+ segment_cols=segment_cols or [],
115
+ )
116
+
117
+ # Return the policy and diagnostics
118
+ return policy, pd.DataFrame(diagnostics)
119
+
120
+ def _find_best_uplift(uplift_grid, shortfall, overbuild, weights, cu, co):
121
+ """Helper function to find the best uplift for a single segment."""
122
+ best_uplift = None
123
+ best_cost = None
124
+ best_under_cost = None
125
+ best_over_cost = None
126
+
127
+ for uplift in uplift_grid:
128
+ # Calculate underbuild and overbuild costs for this uplift
129
+ cu_val = uplift * cu
130
+ under_cost = np.sum(weights * cu_val * shortfall)
131
+ over_cost = np.sum(weights * co * overbuild)
132
+ diff = abs(under_cost - over_cost)
133
+
134
+ if best_cost is None or diff < best_cost:
135
+ best_cost = diff
136
+ best_uplift = uplift
137
+ best_under_cost = under_cost
138
+ best_over_cost = over_cost
139
+
140
+ return best_uplift, {
141
+ "under_cost": best_under_cost,
142
+ "over_cost": best_over_cost,
143
+ "diff": best_cost,
144
+ }
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ r"""
4
+ CWSL cost-ratio sensitivity utilities.
5
+
6
+ This module provides helpers for computing a *sensitivity curve* of
7
+ Cost-Weighted Service Loss (CWSL) across a grid of cost ratios:
8
+
9
+ $$
10
+ R = \frac{c_u}{c_o}
11
+ $$
12
+
13
+ Given an overbuild cost coefficient $c_o$ and ratio $R$, the implied underbuild cost is:
14
+
15
+ $$
16
+ c_u = R \cdot c_o
17
+ $$
18
+
19
+ Why this lives in eb-optimization
20
+ --------------------------------
21
+ Computing a metric across a candidate grid of hyperparameters (like a cost ratio R)
22
+ is an *analysis / calibration workflow* rather than a metric primitive.
23
+
24
+ - **eb-metrics** remains the source of truth for *metric math* (e.g., ``cwsl``).
25
+ - **eb-optimization** owns grid-based evaluation, diagnostics, and tuning utilities.
26
+
27
+ This module therefore contains:
28
+ - ``cwsl_sensitivity``: array-level sweep (grid evaluation)
29
+ - ``compute_cwsl_sensitivity_df``: DataFrame-oriented wrapper (tidy long-form output)
30
+ """
31
+
32
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Union
33
+
34
+ import numpy as np
35
+ import pandas as pd
36
+
37
+ from eb_metrics.metrics.loss import cwsl
38
+
39
+ __all__ = ["cwsl_sensitivity", "compute_cwsl_sensitivity_df"]
40
+
41
+
42
+ def _as_1d_float_array(x: Sequence[float] | np.ndarray | Iterable[float]) -> np.ndarray:
43
+ """Convert input to a 1D float NumPy array."""
44
+ return np.asarray(list(x), dtype=float).reshape(-1)
45
+
46
+
47
+ def _normalize_R_list(R_list: Sequence[float] | np.ndarray | Iterable[float]) -> np.ndarray:
48
+ """
49
+ Normalize and validate a candidate R grid.
50
+
51
+ Backward-compatible behavior:
52
+ - Non-finite values are dropped.
53
+ - Non-positive values (R <= 0) are dropped.
54
+ - If no valid values remain, raises ValueError.
55
+ - De-duplicates and sorts for stable outputs.
56
+
57
+ Parameters
58
+ ----------
59
+ R_list
60
+ Candidate ratios to evaluate.
61
+
62
+ Returns
63
+ -------
64
+ numpy.ndarray
65
+ 1D array of finite, strictly positive ratios.
66
+
67
+ Raises
68
+ ------
69
+ ValueError
70
+ If the candidate list is empty or contains no valid ratios after filtering.
71
+ """
72
+ R_arr = _as_1d_float_array(R_list)
73
+ if R_arr.ndim != 1 or R_arr.size == 0:
74
+ raise ValueError("R_list must be a non-empty 1D sequence of floats.")
75
+
76
+ R_arr = R_arr[np.isfinite(R_arr)]
77
+ R_arr = R_arr[R_arr > 0]
78
+
79
+ if R_arr.size == 0:
80
+ raise ValueError(
81
+ "R_list contains no valid ratios after filtering. Provide at least one R > 0."
82
+ )
83
+
84
+ return np.unique(R_arr)
85
+
86
+
87
+ def cwsl_sensitivity(
88
+ y_true: np.ndarray | Sequence[float],
89
+ y_pred: np.ndarray | Sequence[float],
90
+ *,
91
+ R_list: Sequence[float] | np.ndarray | Iterable[float] = (0.5, 1.0, 2.0, 3.0),
92
+ co: Union[float, np.ndarray] = 1.0,
93
+ sample_weight: Optional[np.ndarray | Sequence[float]] = None,
94
+ ) -> Dict[float, float]:
95
+ r"""
96
+ Evaluate CWSL across a grid of cost ratios (cost sensitivity analysis).
97
+
98
+ For each candidate ratio:
99
+
100
+ $$ R = \frac{c_u}{c_o} $$
101
+
102
+ holding ``co`` fixed and setting:
103
+
104
+ $$ c_u = R \cdot c_o $$
105
+
106
+ Parameters
107
+ ----------
108
+ y_true
109
+ Realized demand values (non-negative).
110
+ y_pred
111
+ Forecast values (non-negative).
112
+ R_list
113
+ Candidate cost ratios to evaluate. Non-finite and non-positive values are ignored.
114
+ co
115
+ Overbuild cost coefficient. Can be scalar or per-interval array.
116
+ sample_weight
117
+ Optional non-negative weights per interval.
118
+
119
+ Returns
120
+ -------
121
+ dict[float, float]
122
+ Mapping ``{R: cwsl_value}`` for each valid ``R``.
123
+
124
+ Raises
125
+ ------
126
+ ValueError
127
+ If no valid ratios remain after filtering, if inputs are invalid, or if
128
+ sample_weight contains negatives.
129
+ """
130
+ y_true_arr = np.asarray(y_true, dtype=float).reshape(-1)
131
+ y_pred_arr = np.asarray(y_pred, dtype=float).reshape(-1)
132
+
133
+ if y_true_arr.ndim != 1 or y_pred_arr.ndim != 1:
134
+ raise ValueError("y_true and y_pred must be 1D arrays.")
135
+ if y_true_arr.shape != y_pred_arr.shape:
136
+ raise ValueError(
137
+ "y_true and y_pred must have the same shape; "
138
+ f"got {y_true_arr.shape} and {y_pred_arr.shape}"
139
+ )
140
+ if np.any(y_true_arr < 0) or np.any(y_pred_arr < 0):
141
+ raise ValueError("y_true and y_pred must be non-negative.")
142
+
143
+ if sample_weight is not None:
144
+ w = np.asarray(sample_weight, dtype=float).reshape(-1)
145
+ if w.shape != y_true_arr.shape:
146
+ raise ValueError(
147
+ f"sample_weight must have shape {y_true_arr.shape}; got {w.shape}"
148
+ )
149
+ if np.any(w < 0):
150
+ raise ValueError("sample_weight must be non-negative.")
151
+ else:
152
+ w = None
153
+
154
+ # normalize candidate grid
155
+ R_arr = _normalize_R_list(R_list)
156
+
157
+ # validate co
158
+ if isinstance(co, np.ndarray):
159
+ co_arr = np.asarray(co, dtype=float).reshape(-1)
160
+ if co_arr.shape != y_true_arr.shape:
161
+ raise ValueError(f"co must have shape {y_true_arr.shape}; got {co_arr.shape}")
162
+ if np.any(co_arr <= 0):
163
+ raise ValueError("co must be strictly positive.")
164
+ co_val: Union[float, np.ndarray] = co_arr
165
+ else:
166
+ co_float = float(co)
167
+ if co_float <= 0:
168
+ raise ValueError("co must be strictly positive.")
169
+ co_val = co_float
170
+
171
+ results: Dict[float, float] = {}
172
+
173
+ for R in R_arr:
174
+ # cu = R * co (supports scalar or per-interval array)
175
+ cu_val = float(R) * co_val # type: ignore[operator]
176
+
177
+ value = cwsl(
178
+ y_true=y_true_arr,
179
+ y_pred=y_pred_arr,
180
+ cu=cu_val,
181
+ co=co_val,
182
+ sample_weight=w,
183
+ )
184
+ results[float(R)] = float(value)
185
+
186
+ # R_arr is guaranteed non-empty; results should be non-empty
187
+ return results
188
+
189
+
190
+ def compute_cwsl_sensitivity_df(
191
+ df: pd.DataFrame,
192
+ *,
193
+ actual_col: str = "actual_qty",
194
+ forecast_col: str = "forecast_qty",
195
+ R_list: Sequence[float] = (0.5, 1.0, 2.0, 3.0),
196
+ co: Union[float, str] = 1.0,
197
+ group_cols: Optional[Sequence[str]] = None,
198
+ sample_weight_col: Optional[str] = None,
199
+ ) -> pd.DataFrame:
200
+ r"""
201
+ Compute CWSL sensitivity curves from a DataFrame.
202
+
203
+ Evaluates CWSL over a grid of cost ratios:
204
+
205
+ $$ R = \frac{c_u}{c_o} $$
206
+
207
+ For each ratio value $R$ in ``R_list``, the implied underbuild cost is:
208
+
209
+ $$ c_u = R \cdot c_o $$
210
+
211
+ where ``co`` may be a scalar (global) or a per-row column name.
212
+
213
+ Parameters
214
+ ----------
215
+ df
216
+ Input data containing actuals, forecasts, optional groups, and optional weights.
217
+ actual_col
218
+ Column containing realized demand values.
219
+ forecast_col
220
+ Column containing forecast values.
221
+ R_list
222
+ Candidate ratios to evaluate. Non-finite and non-positive values are ignored.
223
+ co
224
+ Overbuild cost specification:
225
+ - If ``float``: constant $c_o$ applied to all rows and groups.
226
+ - If ``str``: name of a column in ``df`` containing per-row $c_o(i)$ values.
227
+ group_cols
228
+ Optional grouping columns. If ``None`` or empty, the entire DataFrame is treated
229
+ as a single group.
230
+ sample_weight_col
231
+ Optional column name containing non-negative sample weights per row.
232
+
233
+ Returns
234
+ -------
235
+ pandas.DataFrame
236
+ Long-form table of sensitivity results with columns:
237
+ - if not grouped: ``["R", "CWSL"]``
238
+ - if grouped: ``group_cols + ["R", "CWSL"]``
239
+
240
+ Raises
241
+ ------
242
+ KeyError
243
+ If required columns are missing from ``df``.
244
+ ValueError
245
+ If no valid ratios remain after filtering, or if sample weights are negative.
246
+ """
247
+ gcols = [] if group_cols is None else list(group_cols)
248
+
249
+ # ---- validation: columns ----
250
+ required_cols: list[str] = [actual_col, forecast_col]
251
+ if isinstance(co, str):
252
+ required_cols.append(co)
253
+ if sample_weight_col is not None:
254
+ required_cols.append(sample_weight_col)
255
+ if gcols:
256
+ required_cols.extend(gcols)
257
+
258
+ missing = [c for c in required_cols if c not in df.columns]
259
+ if missing:
260
+ raise KeyError(f"Missing required columns in df: {missing}")
261
+
262
+ # ---- validation: R grid (for early failure and stable ordering) ----
263
+ R_arr = _normalize_R_list(R_list)
264
+
265
+ # ---- validation: weights ----
266
+ if sample_weight_col is not None:
267
+ w_all = df[sample_weight_col].to_numpy(dtype=float)
268
+ if np.any(w_all < 0):
269
+ raise ValueError("sample weights must be non-negative.")
270
+
271
+ # ---- compute ----
272
+ results: List[Dict[str, Any]] = []
273
+
274
+ if len(gcols) == 0:
275
+ iter_groups = [((None,), df)]
276
+ else:
277
+ iter_groups = df.groupby(gcols, dropna=False, sort=False)
278
+
279
+ for keys, g in iter_groups:
280
+ if not isinstance(keys, tuple):
281
+ keys = (keys,)
282
+
283
+ y_true = g[actual_col].to_numpy(dtype=float)
284
+ y_pred = g[forecast_col].to_numpy(dtype=float)
285
+
286
+ co_value: Union[float, np.ndarray]
287
+ if isinstance(co, str):
288
+ co_value = g[co].to_numpy(dtype=float)
289
+ else:
290
+ co_value = float(co)
291
+
292
+ sample_weight = (
293
+ g[sample_weight_col].to_numpy(dtype=float)
294
+ if sample_weight_col is not None
295
+ else None
296
+ )
297
+
298
+ sensitivity_map = cwsl_sensitivity(
299
+ y_true=y_true,
300
+ y_pred=y_pred,
301
+ R_list=R_arr,
302
+ co=co_value,
303
+ sample_weight=sample_weight,
304
+ )
305
+
306
+ for R_val, cwsl_val in sensitivity_map.items():
307
+ row: Dict[str, Any] = {"R": float(R_val), "CWSL": float(cwsl_val)}
308
+ for col, value in zip(gcols, keys):
309
+ row[col] = value
310
+ results.append(row)
311
+
312
+ result_df = pd.DataFrame(results)
313
+
314
+ if len(gcols) > 0:
315
+ result_df = result_df[gcols + ["R", "CWSL"]]
316
+ else:
317
+ result_df = result_df[["R", "CWSL"]]
318
+
319
+ return result_df