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.
- eb_optimization/__init__.py +38 -0
- eb_optimization/_utils.py +122 -0
- eb_optimization/policies/__init__.py +74 -0
- eb_optimization/policies/cost_ratio_policy.py +294 -0
- eb_optimization/policies/ral_policy.py +129 -0
- eb_optimization/policies/tau_policy.py +156 -0
- eb_optimization/search/__init__.py +27 -0
- eb_optimization/search/grid.py +91 -0
- eb_optimization/search/kernels.py +115 -0
- eb_optimization/search/results.py +0 -0
- eb_optimization/tuning/__init__.py +27 -0
- eb_optimization/tuning/cost_ratio.py +270 -0
- eb_optimization/tuning/ral.py +144 -0
- eb_optimization/tuning/sensitivity.py +319 -0
- eb_optimization/tuning/tau.py +510 -0
- eb_optimization-0.1.0.dist-info/METADATA +117 -0
- eb_optimization-0.1.0.dist-info/RECORD +20 -0
- eb_optimization-0.1.0.dist-info/WHEEL +5 -0
- eb_optimization-0.1.0.dist-info/licenses/LICENSE +28 -0
- eb_optimization-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
r"""
|
|
4
|
+
Data-driven tolerance (τ) selection utilities for HR@τ.
|
|
5
|
+
|
|
6
|
+
This module provides deterministic, residual-only methods for selecting the tolerance
|
|
7
|
+
parameter τ used by the hit-rate metric HR@τ (hit rate within an absolute-error band).
|
|
8
|
+
|
|
9
|
+
The hit-rate metric is:
|
|
10
|
+
|
|
11
|
+
$$
|
|
12
|
+
\mathrm{HR}@\tau = \frac{1}{n}\sum_{i=1}^{n}\mathbf{1}\left(|y_i-\hat{y}_i|\le \tau\right)
|
|
13
|
+
$$
|
|
14
|
+
|
|
15
|
+
Here, τ defines an *acceptability band*: the maximum absolute error considered operationally
|
|
16
|
+
acceptable.
|
|
17
|
+
|
|
18
|
+
Design notes
|
|
19
|
+
------------
|
|
20
|
+
- τ is estimated from historical residuals only (no exogenous data, no model assumptions).
|
|
21
|
+
- The module supports global τ estimation and entity-level τ estimation.
|
|
22
|
+
- Optional governance controls allow capping entity τ values by a global cap to prevent
|
|
23
|
+
tolerance inflation.
|
|
24
|
+
|
|
25
|
+
Methods
|
|
26
|
+
-------
|
|
27
|
+
The global estimator supports three selection modes:
|
|
28
|
+
|
|
29
|
+
1. ``"target_hit_rate"``:
|
|
30
|
+
choose τ such that a target fraction of residual magnitudes is covered:
|
|
31
|
+
|
|
32
|
+
$$
|
|
33
|
+
\tau = Q_h\left(|e|\right), \quad e_i = y_i-\hat{y}_i
|
|
34
|
+
$$
|
|
35
|
+
|
|
36
|
+
where $Q_h(\cdot)$ is the quantile function at level $h$.
|
|
37
|
+
|
|
38
|
+
2. ``"knee"``:
|
|
39
|
+
select τ at a diminishing-returns point on the monotone curve $\mathrm{HR}@\tau$.
|
|
40
|
+
|
|
41
|
+
3. ``"utility"``:
|
|
42
|
+
maximize a simple tradeoff between coverage and tolerance width:
|
|
43
|
+
|
|
44
|
+
$$
|
|
45
|
+
\tau^\* = \arg\max_{\tau \in \mathcal{T}}
|
|
46
|
+
\left[\mathrm{HR}@\tau - \lambda\left(\frac{\tau}{\tau_{\max}}\right)\right]
|
|
47
|
+
$$
|
|
48
|
+
|
|
49
|
+
The entity-level estimator runs the same procedure per entity, optionally capping each
|
|
50
|
+
entity τ by a global cap derived from the full residual distribution.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from dataclasses import dataclass
|
|
54
|
+
from typing import Any, Dict, Iterable, Literal, Mapping, Optional, Tuple, Union
|
|
55
|
+
|
|
56
|
+
import numpy as np
|
|
57
|
+
import pandas as pd
|
|
58
|
+
|
|
59
|
+
# Single source of truth for the HR@τ metric math
|
|
60
|
+
from eb_metrics.metrics.service import hr_at_tau as _hr_at_tau_core
|
|
61
|
+
|
|
62
|
+
TauMethod = Literal["target_hit_rate", "knee", "utility"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _to_1d_float_array(x: Union[pd.Series, np.ndarray, Iterable[float]]) -> np.ndarray:
|
|
66
|
+
"""Convert input to a 1D float NumPy array."""
|
|
67
|
+
return np.asarray(x, dtype=float).reshape(-1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _nan_safe_abs_errors(
|
|
71
|
+
y: Union[pd.Series, np.ndarray, Iterable[float]],
|
|
72
|
+
yhat: Union[pd.Series, np.ndarray, Iterable[float]],
|
|
73
|
+
) -> np.ndarray:
|
|
74
|
+
r"""
|
|
75
|
+
Compute absolute errors with NaN/inf filtering.
|
|
76
|
+
|
|
77
|
+
Non-finite (y, yhat) pairs are dropped. The returned array contains:
|
|
78
|
+
|
|
79
|
+
$$
|
|
80
|
+
|e_i| = |y_i - \hat{y}_i|
|
|
81
|
+
$$
|
|
82
|
+
|
|
83
|
+
for finite pairs only.
|
|
84
|
+
"""
|
|
85
|
+
y_arr = _to_1d_float_array(y)
|
|
86
|
+
yhat_arr = _to_1d_float_array(yhat)
|
|
87
|
+
if y_arr.shape[0] != yhat_arr.shape[0]:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"y and yhat must have the same length. Got {len(y_arr)} vs {len(yhat_arr)}."
|
|
90
|
+
)
|
|
91
|
+
mask = np.isfinite(y_arr) & np.isfinite(yhat_arr)
|
|
92
|
+
return np.abs(y_arr[mask] - yhat_arr[mask])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _validate_tau(tau: float) -> float:
|
|
96
|
+
"""Validate τ as finite and non-negative."""
|
|
97
|
+
if not np.isfinite(tau):
|
|
98
|
+
raise ValueError(f"tau must be finite. Got {tau}.")
|
|
99
|
+
if tau < 0:
|
|
100
|
+
raise ValueError(f"tau must be >= 0. Got {tau}.")
|
|
101
|
+
return float(tau)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _quantile(x: np.ndarray, q: float) -> float:
|
|
105
|
+
"""Compute a quantile with basic guards; returns NaN for empty input."""
|
|
106
|
+
if not (0.0 <= q <= 1.0):
|
|
107
|
+
raise ValueError(f"Quantile q must be in [0, 1]. Got {q}.")
|
|
108
|
+
if x.size == 0:
|
|
109
|
+
return np.nan
|
|
110
|
+
return float(np.quantile(x, q))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _make_tau_grid(
|
|
114
|
+
abs_errors: np.ndarray,
|
|
115
|
+
grid: Optional[Union[np.ndarray, Iterable[float]]] = None,
|
|
116
|
+
grid_size: int = 101,
|
|
117
|
+
grid_quantiles: Tuple[float, float] = (0.0, 0.99),
|
|
118
|
+
) -> np.ndarray:
|
|
119
|
+
"""
|
|
120
|
+
Construct a non-negative τ grid.
|
|
121
|
+
|
|
122
|
+
If ``grid`` is provided, it is filtered to finite, unique, non-negative values.
|
|
123
|
+
Otherwise, a linear grid is constructed between quantiles of the absolute error
|
|
124
|
+
distribution.
|
|
125
|
+
"""
|
|
126
|
+
if abs_errors.size == 0:
|
|
127
|
+
return np.array([], dtype=float)
|
|
128
|
+
|
|
129
|
+
if grid is not None:
|
|
130
|
+
g = _to_1d_float_array(grid)
|
|
131
|
+
g = g[np.isfinite(g)]
|
|
132
|
+
g = np.unique(g)
|
|
133
|
+
g = g[g >= 0]
|
|
134
|
+
return g
|
|
135
|
+
|
|
136
|
+
q_lo, q_hi = grid_quantiles
|
|
137
|
+
if not (0 <= q_lo <= q_hi <= 1):
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"grid_quantiles must satisfy 0 <= q_lo <= q_hi <= 1. Got {grid_quantiles}."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
lo = _quantile(abs_errors, q_lo)
|
|
143
|
+
hi = _quantile(abs_errors, q_hi)
|
|
144
|
+
|
|
145
|
+
if not np.isfinite(lo) or not np.isfinite(hi):
|
|
146
|
+
return np.array([], dtype=float)
|
|
147
|
+
|
|
148
|
+
if hi < lo:
|
|
149
|
+
hi = lo
|
|
150
|
+
|
|
151
|
+
if grid_size < 2:
|
|
152
|
+
return np.array([lo], dtype=float)
|
|
153
|
+
|
|
154
|
+
return np.linspace(lo, hi, grid_size, dtype=float)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def hr_at_tau(
|
|
158
|
+
y: Union[pd.Series, np.ndarray, Iterable[float]],
|
|
159
|
+
yhat: Union[pd.Series, np.ndarray, Iterable[float]],
|
|
160
|
+
tau: float,
|
|
161
|
+
) -> float:
|
|
162
|
+
r"""
|
|
163
|
+
Compute HR@τ: fraction of observations whose absolute error is within τ.
|
|
164
|
+
|
|
165
|
+
HR@τ is defined as:
|
|
166
|
+
|
|
167
|
+
$$
|
|
168
|
+
\mathrm{HR}@\tau = \frac{1}{n}\sum_{i=1}^{n}\mathbf{1}\left(|y_i-\hat{y}_i|\le \tau\right)
|
|
169
|
+
$$
|
|
170
|
+
|
|
171
|
+
This is an evaluation-friendly wrapper around the core implementation in
|
|
172
|
+
``eb_metrics.metrics.service.hr_at_tau``. Non-finite (y, yhat) pairs are dropped
|
|
173
|
+
prior to delegating. If no finite pairs remain, returns ``np.nan``.
|
|
174
|
+
"""
|
|
175
|
+
tau = _validate_tau(tau)
|
|
176
|
+
|
|
177
|
+
y_arr = _to_1d_float_array(y)
|
|
178
|
+
yhat_arr = _to_1d_float_array(yhat)
|
|
179
|
+
if y_arr.shape[0] != yhat_arr.shape[0]:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"y and yhat must have the same length. Got {len(y_arr)} vs {len(yhat_arr)}."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
mask = np.isfinite(y_arr) & np.isfinite(yhat_arr)
|
|
185
|
+
if not np.any(mask):
|
|
186
|
+
return np.nan
|
|
187
|
+
|
|
188
|
+
return float(_hr_at_tau_core(y_true=y_arr[mask], y_pred=yhat_arr[mask], tau=tau))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class TauEstimate:
|
|
193
|
+
"""
|
|
194
|
+
Result container for τ estimation.
|
|
195
|
+
|
|
196
|
+
Attributes
|
|
197
|
+
----------
|
|
198
|
+
tau : float
|
|
199
|
+
Estimated tolerance τ (may be NaN if estimation failed).
|
|
200
|
+
method : str
|
|
201
|
+
Method identifier used to produce the estimate.
|
|
202
|
+
n : int
|
|
203
|
+
Number of finite (y, yhat) pairs used.
|
|
204
|
+
diagnostics : dict[str, Any]
|
|
205
|
+
Method-specific diagnostics intended for reporting and governance.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
tau: float
|
|
209
|
+
method: str
|
|
210
|
+
n: int
|
|
211
|
+
diagnostics: Dict[str, Any]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def estimate_tau(
|
|
215
|
+
y: Union[pd.Series, np.ndarray, Iterable[float]],
|
|
216
|
+
yhat: Union[pd.Series, np.ndarray, Iterable[float]],
|
|
217
|
+
method: TauMethod = "target_hit_rate",
|
|
218
|
+
*,
|
|
219
|
+
target_hit_rate: float = 0.90,
|
|
220
|
+
grid: Optional[Union[np.ndarray, Iterable[float]]] = None,
|
|
221
|
+
grid_size: int = 101,
|
|
222
|
+
grid_quantiles: Tuple[float, float] = (0.0, 0.99),
|
|
223
|
+
knee_rule: Literal["slope_threshold", "max_distance"] = "slope_threshold",
|
|
224
|
+
slope_threshold: float = 0.0025,
|
|
225
|
+
lambda_: float = 0.10,
|
|
226
|
+
tau_max: Optional[float] = None,
|
|
227
|
+
tau_floor: float = 0.0,
|
|
228
|
+
tau_cap: Optional[float] = None,
|
|
229
|
+
) -> TauEstimate:
|
|
230
|
+
r"""Estimate a global tolerance τ from residuals.
|
|
231
|
+
|
|
232
|
+
See module docstring for method definitions and design notes.
|
|
233
|
+
"""
|
|
234
|
+
abs_errors = _nan_safe_abs_errors(y, yhat)
|
|
235
|
+
n = int(abs_errors.size)
|
|
236
|
+
|
|
237
|
+
if n == 0:
|
|
238
|
+
return TauEstimate(
|
|
239
|
+
tau=np.nan,
|
|
240
|
+
method=str(method),
|
|
241
|
+
n=0,
|
|
242
|
+
diagnostics={"reason": "no_finite_pairs"},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
tau_floor = _validate_tau(tau_floor)
|
|
246
|
+
if tau_cap is not None:
|
|
247
|
+
tau_cap = _validate_tau(tau_cap)
|
|
248
|
+
|
|
249
|
+
if method == "target_hit_rate":
|
|
250
|
+
if not (0.0 < target_hit_rate <= 1.0):
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"target_hit_rate must be in (0, 1]. Got {target_hit_rate}."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
tau = _quantile(abs_errors, target_hit_rate)
|
|
256
|
+
|
|
257
|
+
if np.isfinite(tau):
|
|
258
|
+
tau = max(tau, tau_floor)
|
|
259
|
+
if tau_cap is not None:
|
|
260
|
+
tau = min(tau, tau_cap)
|
|
261
|
+
|
|
262
|
+
diag = {
|
|
263
|
+
"target_hit_rate": float(target_hit_rate),
|
|
264
|
+
"achieved_hr_calibration": float(np.mean(abs_errors <= tau))
|
|
265
|
+
if np.isfinite(tau)
|
|
266
|
+
else np.nan,
|
|
267
|
+
"abs_error_quantile_used": float(target_hit_rate),
|
|
268
|
+
"tau_floor": float(tau_floor),
|
|
269
|
+
"tau_cap": float(tau_cap) if tau_cap is not None else None,
|
|
270
|
+
}
|
|
271
|
+
return TauEstimate(tau=float(tau), method="target_hit_rate", n=n, diagnostics=diag)
|
|
272
|
+
|
|
273
|
+
tau_grid = _make_tau_grid(
|
|
274
|
+
abs_errors,
|
|
275
|
+
grid=grid,
|
|
276
|
+
grid_size=grid_size,
|
|
277
|
+
grid_quantiles=grid_quantiles,
|
|
278
|
+
)
|
|
279
|
+
if tau_grid.size == 0:
|
|
280
|
+
return TauEstimate(
|
|
281
|
+
tau=np.nan,
|
|
282
|
+
method=str(method),
|
|
283
|
+
n=n,
|
|
284
|
+
diagnostics={"reason": "empty_tau_grid"},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# HR curve on grid (monotone non-decreasing)
|
|
288
|
+
e_sorted = np.sort(abs_errors)
|
|
289
|
+
idx = np.searchsorted(e_sorted, tau_grid, side="right")
|
|
290
|
+
hr_curve = idx / float(n)
|
|
291
|
+
|
|
292
|
+
if method == "knee":
|
|
293
|
+
if knee_rule == "slope_threshold":
|
|
294
|
+
d_tau = np.diff(tau_grid)
|
|
295
|
+
d_hr = np.diff(hr_curve)
|
|
296
|
+
slope = np.where(d_tau > 0, d_hr / d_tau, np.inf)
|
|
297
|
+
|
|
298
|
+
candidates = np.where(slope < slope_threshold)[0]
|
|
299
|
+
pick_i = (
|
|
300
|
+
int(candidates[0] + 1) if candidates.size > 0 else int(len(tau_grid) - 1)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
tau = float(tau_grid[pick_i])
|
|
304
|
+
hr_pick = float(hr_curve[pick_i])
|
|
305
|
+
|
|
306
|
+
diag = {
|
|
307
|
+
"knee_rule": knee_rule,
|
|
308
|
+
"slope_threshold": float(slope_threshold),
|
|
309
|
+
"picked_index": pick_i,
|
|
310
|
+
"picked_hr_calibration": hr_pick,
|
|
311
|
+
"grid_size": int(tau_grid.size),
|
|
312
|
+
"tau_grid_min": float(tau_grid.min()),
|
|
313
|
+
"tau_grid_max": float(tau_grid.max()),
|
|
314
|
+
"tau_floor": float(tau_floor),
|
|
315
|
+
"tau_cap": float(tau_cap) if tau_cap is not None else None,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
elif knee_rule == "max_distance":
|
|
319
|
+
t0, t1 = float(tau_grid[0]), float(tau_grid[-1])
|
|
320
|
+
t_norm = (tau_grid - t0) / (t1 - t0) if t1 > t0 else np.zeros_like(tau_grid)
|
|
321
|
+
|
|
322
|
+
x = t_norm
|
|
323
|
+
yv = hr_curve
|
|
324
|
+
x0, y0 = 0.0, float(hr_curve[0])
|
|
325
|
+
x1, y1 = 1.0, float(hr_curve[-1])
|
|
326
|
+
|
|
327
|
+
denom = np.hypot(x1 - x0, y1 - y0)
|
|
328
|
+
if denom == 0:
|
|
329
|
+
pick_i = int(len(tau_grid) // 2)
|
|
330
|
+
else:
|
|
331
|
+
dist = (
|
|
332
|
+
np.abs((y1 - y0) * x - (x1 - x0) * yv + x1 * y0 - y1 * x0) / denom
|
|
333
|
+
)
|
|
334
|
+
pick_i = int(np.argmax(dist))
|
|
335
|
+
|
|
336
|
+
tau = float(tau_grid[pick_i])
|
|
337
|
+
hr_pick = float(hr_curve[pick_i])
|
|
338
|
+
|
|
339
|
+
diag = {
|
|
340
|
+
"knee_rule": knee_rule,
|
|
341
|
+
"picked_index": pick_i,
|
|
342
|
+
"picked_hr_calibration": hr_pick,
|
|
343
|
+
"grid_size": int(tau_grid.size),
|
|
344
|
+
"tau_grid_min": float(tau_grid.min()),
|
|
345
|
+
"tau_grid_max": float(tau_grid.max()),
|
|
346
|
+
"tau_floor": float(tau_floor),
|
|
347
|
+
"tau_cap": float(tau_cap) if tau_cap is not None else None,
|
|
348
|
+
}
|
|
349
|
+
else:
|
|
350
|
+
raise ValueError(f"Unknown knee_rule: {knee_rule}")
|
|
351
|
+
|
|
352
|
+
tau = max(tau, tau_floor)
|
|
353
|
+
if tau_cap is not None:
|
|
354
|
+
tau = min(tau, tau_cap)
|
|
355
|
+
|
|
356
|
+
return TauEstimate(tau=float(tau), method="knee", n=n, diagnostics=diag)
|
|
357
|
+
|
|
358
|
+
if method == "utility":
|
|
359
|
+
if lambda_ < 0:
|
|
360
|
+
raise ValueError(f"lambda_ must be >= 0. Got {lambda_}.")
|
|
361
|
+
|
|
362
|
+
if tau_max is None:
|
|
363
|
+
tau_max_val = _quantile(abs_errors, 0.99)
|
|
364
|
+
else:
|
|
365
|
+
tau_max_val = float(tau_max)
|
|
366
|
+
|
|
367
|
+
if not np.isfinite(tau_max_val) or tau_max_val <= 0:
|
|
368
|
+
tau_max_val = (
|
|
369
|
+
float(tau_grid[-1])
|
|
370
|
+
if np.isfinite(tau_grid[-1]) and tau_grid[-1] > 0
|
|
371
|
+
else 1.0
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
utility = hr_curve - float(lambda_) * (tau_grid / tau_max_val)
|
|
375
|
+
pick_i = int(np.argmax(utility))
|
|
376
|
+
|
|
377
|
+
tau = float(tau_grid[pick_i])
|
|
378
|
+
hr_pick = float(hr_curve[pick_i])
|
|
379
|
+
u_pick = float(utility[pick_i])
|
|
380
|
+
|
|
381
|
+
tau = max(tau, tau_floor)
|
|
382
|
+
if tau_cap is not None:
|
|
383
|
+
tau = min(tau, tau_cap)
|
|
384
|
+
|
|
385
|
+
diag = {
|
|
386
|
+
"lambda_": float(lambda_),
|
|
387
|
+
"tau_max": float(tau_max_val),
|
|
388
|
+
"picked_index": pick_i,
|
|
389
|
+
"picked_hr_calibration": hr_pick,
|
|
390
|
+
"picked_utility": u_pick,
|
|
391
|
+
"grid_size": int(tau_grid.size),
|
|
392
|
+
"tau_grid_min": float(tau_grid.min()),
|
|
393
|
+
"tau_grid_max": float(tau_grid.max()),
|
|
394
|
+
"tau_floor": float(tau_floor),
|
|
395
|
+
"tau_cap": float(tau_cap) if tau_cap is not None else None,
|
|
396
|
+
}
|
|
397
|
+
return TauEstimate(tau=float(tau), method="utility", n=n, diagnostics=diag)
|
|
398
|
+
|
|
399
|
+
raise ValueError(f"Unknown method: {method}")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def estimate_entity_tau(
|
|
403
|
+
df: pd.DataFrame,
|
|
404
|
+
*,
|
|
405
|
+
entity_col: str,
|
|
406
|
+
y_col: str,
|
|
407
|
+
yhat_col: str,
|
|
408
|
+
method: TauMethod = "target_hit_rate",
|
|
409
|
+
min_n: int = 30,
|
|
410
|
+
estimate_kwargs: Optional[Mapping[str, Any]] = None,
|
|
411
|
+
cap_with_global: bool = False,
|
|
412
|
+
global_cap_quantile: float = 0.99,
|
|
413
|
+
include_diagnostics: bool = True,
|
|
414
|
+
) -> pd.DataFrame:
|
|
415
|
+
r"""Estimate τ per entity from residuals. See the original EB docs for details."""
|
|
416
|
+
if estimate_kwargs is None:
|
|
417
|
+
estimate_kwargs = {}
|
|
418
|
+
|
|
419
|
+
required = {entity_col, y_col, yhat_col}
|
|
420
|
+
missing = [c for c in required if c not in df.columns]
|
|
421
|
+
if missing:
|
|
422
|
+
raise KeyError(f"Missing required columns: {missing}")
|
|
423
|
+
|
|
424
|
+
if min_n < 1:
|
|
425
|
+
raise ValueError(f"min_n must be >= 1. Got {min_n}.")
|
|
426
|
+
|
|
427
|
+
global_cap = None
|
|
428
|
+
if cap_with_global:
|
|
429
|
+
abs_errors_all = _nan_safe_abs_errors(df[y_col], df[yhat_col])
|
|
430
|
+
global_cap = _quantile(abs_errors_all, global_cap_quantile)
|
|
431
|
+
if not np.isfinite(global_cap):
|
|
432
|
+
global_cap = None
|
|
433
|
+
|
|
434
|
+
rows: list[dict[str, Any]] = []
|
|
435
|
+
|
|
436
|
+
for ent, g in df.groupby(entity_col, dropna=False):
|
|
437
|
+
abs_errors = _nan_safe_abs_errors(g[y_col], g[yhat_col])
|
|
438
|
+
n = int(abs_errors.size)
|
|
439
|
+
|
|
440
|
+
if n < min_n:
|
|
441
|
+
rows.append(
|
|
442
|
+
{
|
|
443
|
+
entity_col: ent,
|
|
444
|
+
"tau": np.nan,
|
|
445
|
+
"n": n,
|
|
446
|
+
"method": method,
|
|
447
|
+
"reason": f"min_n_not_met(<{min_n})",
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
est = estimate_tau(
|
|
453
|
+
y=g[y_col],
|
|
454
|
+
yhat=g[yhat_col],
|
|
455
|
+
method=method,
|
|
456
|
+
**dict(estimate_kwargs),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
tau_val = est.tau
|
|
460
|
+
if global_cap is not None and np.isfinite(tau_val):
|
|
461
|
+
tau_val = float(min(tau_val, global_cap))
|
|
462
|
+
|
|
463
|
+
row: dict[str, Any] = {
|
|
464
|
+
entity_col: ent,
|
|
465
|
+
"tau": tau_val,
|
|
466
|
+
"n": est.n,
|
|
467
|
+
"method": est.method,
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if include_diagnostics:
|
|
471
|
+
diag = dict(est.diagnostics or {})
|
|
472
|
+
row["diagnostics"] = diag
|
|
473
|
+
row["achieved_hr_calibration"] = diag.get(
|
|
474
|
+
"achieved_hr_calibration", diag.get("picked_hr_calibration")
|
|
475
|
+
)
|
|
476
|
+
row["tau_floor"] = diag.get("tau_floor")
|
|
477
|
+
row["tau_cap"] = diag.get("tau_cap")
|
|
478
|
+
if method == "utility":
|
|
479
|
+
row["lambda_"] = diag.get("lambda_")
|
|
480
|
+
row["tau_max"] = diag.get("tau_max")
|
|
481
|
+
row["picked_utility"] = diag.get("picked_utility")
|
|
482
|
+
if method == "knee":
|
|
483
|
+
row["knee_rule"] = diag.get("knee_rule")
|
|
484
|
+
|
|
485
|
+
if global_cap is not None:
|
|
486
|
+
row["global_cap_tau"] = global_cap
|
|
487
|
+
row["global_cap_quantile"] = float(global_cap_quantile)
|
|
488
|
+
|
|
489
|
+
rows.append(row)
|
|
490
|
+
|
|
491
|
+
out = pd.DataFrame(rows)
|
|
492
|
+
|
|
493
|
+
base_cols = [entity_col, "tau", "n", "method"]
|
|
494
|
+
extra_cols = [c for c in out.columns if c not in base_cols]
|
|
495
|
+
return out[base_cols + extra_cols]
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def hr_auto_tau(
|
|
499
|
+
y: Union[pd.Series, np.ndarray, Iterable[float]],
|
|
500
|
+
yhat: Union[pd.Series, np.ndarray, Iterable[float]],
|
|
501
|
+
method: TauMethod = "target_hit_rate",
|
|
502
|
+
**estimate_kwargs: Any,
|
|
503
|
+
) -> Tuple[float, float, Dict[str, Any]]:
|
|
504
|
+
r"""Estimate τ from residuals, then compute HR@τ. Returns (hr, tau, diagnostics)."""
|
|
505
|
+
est = estimate_tau(y=y, yhat=yhat, method=method, **estimate_kwargs)
|
|
506
|
+
if not np.isfinite(est.tau):
|
|
507
|
+
return (np.nan, np.nan, dict(est.diagnostics or {}))
|
|
508
|
+
|
|
509
|
+
hr = hr_at_tau(y, yhat, est.tau)
|
|
510
|
+
return (float(hr), float(est.tau), dict(est.diagnostics or {}))
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eb-optimization
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Electric Barometer: Optimization and tuning utilities for EB objectives and policy parameters.
|
|
5
|
+
Author-email: "Kyle Corrie (Economistician)" <kcorrie@economistician.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/Economistician/eb-optimization
|
|
8
|
+
Project-URL: Repository, https://github.com/Economistician/eb-optimization
|
|
9
|
+
Project-URL: Issues, https://github.com/Economistician/eb-optimization/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/Economistician/eb-docs
|
|
11
|
+
Keywords: electric-barometer,optimization,tuning,grid-search,calibration,asymmetric-loss,forecasting,pandas
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=1.24
|
|
23
|
+
Requires-Dist: pandas>=2.0
|
|
24
|
+
Provides-Extra: eb
|
|
25
|
+
Requires-Dist: eb-metrics<0.3,>=0.2; extra == "eb"
|
|
26
|
+
Requires-Dist: eb-evaluation<0.3,>=0.2; extra == "eb"
|
|
27
|
+
Provides-Extra: opt
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
30
|
+
Requires-Dist: scikit-learn>=1.3; extra == "test"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# Electric Barometer · Optimization (`eb-optimization`)
|
|
37
|
+
|
|
38
|
+
Decision and policy layer for the Electric Barometer ecosystem, responsible for tuning, calibration, and governed parameter selection.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Overview
|
|
43
|
+
|
|
44
|
+
This repository contains the optimization, tuning, and policy governance layer of the Electric Barometer ecosystem. It defines how key evaluation parameters—such as cost ratios, tolerances, and readiness controls—are selected from data, validated under governance rules, and formalized into deterministic policies that can be reused across systems and environments.
|
|
45
|
+
|
|
46
|
+
Rather than computing metrics or running evaluations, this repository focuses on decision logic: how parameters are calibrated, how tradeoffs are resolved, and how those decisions are frozen into auditable artifacts. It provides the bridge between metric theory and operational deployment, ensuring that forecast evaluation behavior is consistent, explainable, and governed by explicit intent rather than ad-hoc configuration.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Role in the Electric Barometer Ecosystem
|
|
51
|
+
|
|
52
|
+
`eb-optimization` defines the parameter selection, calibration, and governance logic used throughout the Electric Barometer ecosystem. It is responsible for determining how key operational parameters—such as cost ratios, tolerance bands, and readiness controls—are selected from data in a disciplined, reproducible, and decision-aware manner.
|
|
53
|
+
|
|
54
|
+
This repository focuses exclusively on optimization mechanics and policy formation. It does not define metric primitives, perform evaluation orchestration, manage model interfaces, or execute runtime decision logic. Those responsibilities are handled by adjacent layers in the ecosystem that compute metrics, evaluate forecasts, or apply frozen policies in production workflows.
|
|
55
|
+
|
|
56
|
+
By separating parameter selection and governance from metric semantics and execution concerns, eb-optimization provides a stable optimization layer that enables consistent calibration, transparent decision rules, and auditable policy artifacts across heterogeneous forecasting and operational contexts.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
`eb-optimization` is distributed as a standard Python package.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install eb-optimization
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Core Concepts
|
|
71
|
+
|
|
72
|
+
- **Parameter governance** — Operational parameters (e.g., cost ratios, tolerances) should be selected through explicit, reproducible rules rather than ad-hoc tuning or implicit defaults.
|
|
73
|
+
- **Search over candidate spaces** — Optimization is framed as deterministic search over bounded, interpretable candidate sets, enabling transparent tradeoffs and stable outcomes.
|
|
74
|
+
- **Cost balance calibration** — Asymmetric operational costs can be balanced by selecting parameters that equalize or appropriately trade off opposing risk exposures.
|
|
75
|
+
- **Tolerance selection from residuals** — Acceptable error bands can be learned directly from historical performance, reflecting empirical system behavior rather than arbitrary thresholds.
|
|
76
|
+
- **Policy separation** — Calibration logic is separated from frozen policy artifacts so that parameter selection is auditable, versioned, and safely applied in downstream systems.
|
|
77
|
+
- **Decision-aligned optimization** — Optimization is evaluated by operational interpretability and governance fitness, not by abstract numerical optimality alone.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Minimal Example
|
|
82
|
+
|
|
83
|
+
The example below illustrates a typical optimization workflow using `eb-optimization`: calibrating an operational parameter from historical data and applying it via a frozen policy.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import numpy as np
|
|
87
|
+
from eb_optimization.policies import (
|
|
88
|
+
CostRatioPolicy,
|
|
89
|
+
apply_cost_ratio_policy,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Historical actuals and forecasts
|
|
93
|
+
y_true = np.array([10, 12, 15, 20])
|
|
94
|
+
y_pred = np.array([9, 14, 18, 17])
|
|
95
|
+
|
|
96
|
+
# Define a frozen cost-ratio policy
|
|
97
|
+
policy = CostRatioPolicy(
|
|
98
|
+
R_grid=(0.5, 1.0, 2.0, 3.0),
|
|
99
|
+
co=1.0,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Estimate a global cost ratio R
|
|
103
|
+
R, diagnostics = apply_cost_ratio_policy(
|
|
104
|
+
y_true=y_true,
|
|
105
|
+
y_pred=y_pred,
|
|
106
|
+
policy=policy,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
print(R)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
BSD 3-Clause License.
|
|
117
|
+
© 2025 Kyle Corrie.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
eb_optimization/__init__.py,sha256=XtzNvrJkzZ-dR362SsVOYychYWy1Akc3APIob-Vzxg0,1214
|
|
2
|
+
eb_optimization/_utils.py,sha256=TdhPz5t327xah97_1lMluEvjskVrHWpxGDZ8rWzTYUc,2743
|
|
3
|
+
eb_optimization/policies/__init__.py,sha256=WO8cH2a7uDNCiWBKXggazFL_UUYgRMi9d0p3QrPBKug,2285
|
|
4
|
+
eb_optimization/policies/cost_ratio_policy.py,sha256=Nrkc8zWUE_s44Db8BjhciESltbq1Uj4dYNcA7V_DcRQ,10404
|
|
5
|
+
eb_optimization/policies/ral_policy.py,sha256=SWdIieZL__Ho6jep57y8FjOz5NSZnISXZxnYGUYTJhg,5080
|
|
6
|
+
eb_optimization/policies/tau_policy.py,sha256=fvE_3VLut8M9pwla4msTWv5Yn0EJKCGHsn9R4znfPSo,4416
|
|
7
|
+
eb_optimization/search/__init__.py,sha256=uxAPhOaryEM5kM4tOHLH0-z5oaE6Qf_UxcOBYt74jpk,797
|
|
8
|
+
eb_optimization/search/grid.py,sha256=9LEkxaq8tdRPHp6zQzKEnKijTCvisib3mFMhQ_vh0w8,3088
|
|
9
|
+
eb_optimization/search/kernels.py,sha256=EN3N5ZHeI4lx4CYm81r0_e6YBXGB-th181F3Es_IbuU,3481
|
|
10
|
+
eb_optimization/search/results.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
eb_optimization/tuning/__init__.py,sha256=UqNzxcQtq36M4IH2G0i01961P2wIw1HQruMhfqfWMLo,834
|
|
12
|
+
eb_optimization/tuning/cost_ratio.py,sha256=mKxex7Pi322Y1vj_TQMOP3GtXouUdE23Ny51kctt4H8,9451
|
|
13
|
+
eb_optimization/tuning/ral.py,sha256=_1sGi62uowAqauizrZvSssZpjL7vrr1L_wK4pTWziX0,5106
|
|
14
|
+
eb_optimization/tuning/sensitivity.py,sha256=TZyqFuQiXvlpLVmztM54QnVZ-go0HsopoA9VrewXbII,10078
|
|
15
|
+
eb_optimization/tuning/tau.py,sha256=mBvfebwLnwTUegqLinwtYdBxL9-qEL3F2iDQpbbC7V0,16802
|
|
16
|
+
eb_optimization-0.1.0.dist-info/licenses/LICENSE,sha256=qFjBKWIfPVLU4ZK4DgROjINVdZVLuNznTfyVaEgCa9w,1526
|
|
17
|
+
eb_optimization-0.1.0.dist-info/METADATA,sha256=Qmh8lj5DOPr0BIVDHQCdN33hZsRiJFJ80RFzf63Ozuw,5762
|
|
18
|
+
eb_optimization-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
19
|
+
eb_optimization-0.1.0.dist-info/top_level.txt,sha256=hQbl5B7gB8EaF7MiEtsGz6OoU64r38bAPKdaN4M1Aho,16
|
|
20
|
+
eb_optimization-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Kyle Corrie
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
eb_optimization
|