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,156 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Tau (τ) policy artifacts for eb-optimization.
5
+
6
+ This module defines *frozen governance* for selecting a tolerance τ used by HR@τ.
7
+
8
+ - tuning/tau.py: calibration logic (estimating τ from residuals)
9
+ - policies/tau_policy.py: frozen configuration + deterministic application wrappers
10
+
11
+ Policies should be stable, auditable, and safe to apply at runtime.
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any, Dict, Iterable, Mapping, Tuple, Union
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from eb_optimization.tuning.tau import (
21
+ TauMethod,
22
+ estimate_tau,
23
+ estimate_entity_tau,
24
+ hr_at_tau,
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class TauPolicy:
30
+ """
31
+ Frozen τ policy configuration.
32
+
33
+ This is the governance object you can persist, version, and ship to downstream
34
+ consumers.
35
+
36
+ Notes
37
+ -----
38
+ - `estimate_kwargs` are passed through to `estimate_tau`.
39
+ - If `cap_with_global` is True, entity τ values are capped by a global cap
40
+ derived from the full residual distribution at `global_cap_quantile`.
41
+ """
42
+
43
+ method: TauMethod = "target_hit_rate"
44
+ min_n: int = 30
45
+
46
+ # Passed to estimate_tau(...)
47
+ estimate_kwargs: Mapping[str, Any] = None # type: ignore[assignment]
48
+
49
+ # Governance
50
+ cap_with_global: bool = False
51
+ global_cap_quantile: float = 0.99
52
+
53
+ def __post_init__(self) -> None:
54
+ # dataclasses + Mapping default guard
55
+ if self.estimate_kwargs is None: # type: ignore[truthy-bool]
56
+ object.__setattr__(self, "estimate_kwargs", {})
57
+
58
+ if self.min_n < 1:
59
+ raise ValueError(f"min_n must be >= 1. Got {self.min_n}.")
60
+ if not (0.0 < self.global_cap_quantile <= 1.0):
61
+ raise ValueError(
62
+ f"global_cap_quantile must be in (0, 1]. Got {self.global_cap_quantile}."
63
+ )
64
+
65
+
66
+ # ----------------------------------------------------------------------
67
+ # Default, exported policy
68
+ # ----------------------------------------------------------------------
69
+ DEFAULT_TAU_POLICY = TauPolicy(
70
+ method="target_hit_rate",
71
+ min_n=30,
72
+ estimate_kwargs={
73
+ "target_hit_rate": 0.90,
74
+ "tau_floor": 0.0,
75
+ "tau_cap": None,
76
+ },
77
+ cap_with_global=False,
78
+ global_cap_quantile=0.99,
79
+ )
80
+
81
+
82
+ def apply_tau_policy(
83
+ y: Union[pd.Series, np.ndarray, Iterable[float]],
84
+ yhat: Union[pd.Series, np.ndarray, Iterable[float]],
85
+ policy: TauPolicy = DEFAULT_TAU_POLICY,
86
+ ) -> Tuple[float, Dict[str, Any]]:
87
+ """
88
+ Apply a frozen τ policy to produce τ (global).
89
+
90
+ Returns
91
+ -------
92
+ (tau, diagnostics)
93
+ """
94
+ est = estimate_tau(
95
+ y=y,
96
+ yhat=yhat,
97
+ method=policy.method,
98
+ **dict(policy.estimate_kwargs),
99
+ )
100
+ return (float(est.tau), dict(est.diagnostics or {}))
101
+
102
+
103
+ def apply_tau_policy_hr(
104
+ y: Union[pd.Series, np.ndarray, Iterable[float]],
105
+ yhat: Union[pd.Series, np.ndarray, Iterable[float]],
106
+ policy: TauPolicy = DEFAULT_TAU_POLICY,
107
+ ) -> Tuple[float, float, Dict[str, Any]]:
108
+ """
109
+ Apply τ policy, then compute HR@τ.
110
+
111
+ Returns
112
+ -------
113
+ (hr, tau, diagnostics)
114
+ """
115
+ tau, diag = apply_tau_policy(y=y, yhat=yhat, policy=policy)
116
+ if not np.isfinite(tau):
117
+ return (np.nan, np.nan, diag)
118
+ hr = hr_at_tau(y=y, yhat=yhat, tau=tau)
119
+ return (float(hr), float(tau), diag)
120
+
121
+
122
+ def apply_entity_tau_policy(
123
+ df: pd.DataFrame,
124
+ *,
125
+ entity_col: str,
126
+ y_col: str,
127
+ yhat_col: str,
128
+ policy: TauPolicy = DEFAULT_TAU_POLICY,
129
+ include_diagnostics: bool = True,
130
+ ) -> pd.DataFrame:
131
+ """
132
+ Apply a frozen τ policy per entity (with optional global cap governance).
133
+
134
+ This wraps tuning.estimate_entity_tau but pins governance via TauPolicy.
135
+ """
136
+ return estimate_entity_tau(
137
+ df=df,
138
+ entity_col=entity_col,
139
+ y_col=y_col,
140
+ yhat_col=yhat_col,
141
+ method=policy.method,
142
+ min_n=policy.min_n,
143
+ estimate_kwargs=policy.estimate_kwargs,
144
+ cap_with_global=policy.cap_with_global,
145
+ global_cap_quantile=policy.global_cap_quantile,
146
+ include_diagnostics=include_diagnostics,
147
+ )
148
+
149
+
150
+ __all__ = [
151
+ "TauPolicy",
152
+ "DEFAULT_TAU_POLICY",
153
+ "apply_tau_policy",
154
+ "apply_tau_policy_hr",
155
+ "apply_entity_tau_policy",
156
+ ]
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Search primitives for the Electric Barometer optimization layer.
5
+
6
+ The `eb_optimization.search` package contains **generic, reusable search kernels**
7
+ that implement *how* to search over a discrete candidate space, independent of
8
+ any specific metric, policy, or business objective.
9
+
10
+ Design intent
11
+ -------------
12
+ - **search/**: mechanics of search (argmin/argmax, tie-breaking, grid iteration)
13
+ - **tuning/**: what to search + objective definition + returned artifacts
14
+ - **policies/**: frozen, declarative outputs of tuning (no search at runtime)
15
+
16
+ Key rules
17
+ ---------
18
+ - No domain-specific policy logic
19
+ - No metric semantics
20
+ - No pandas-heavy workflows
21
+ - Pure, deterministic search utilities
22
+ """
23
+
24
+ __all__ = [
25
+ "grid",
26
+ "kernels",
27
+ ]
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Grid construction utilities for optimization search spaces.
5
+
6
+ This module provides small, deterministic helpers for constructing bounded,
7
+ interpretable parameter grids used by offline optimization routines.
8
+
9
+ Responsibilities:
10
+ - Create numerically stable, reproducible grids for scalar parameters
11
+ - Enforce positivity and boundary constraints
12
+ - Standardize grid behavior across tuners
13
+
14
+ Non-responsibilities:
15
+ - Evaluating objectives
16
+ - Selecting optimal parameters
17
+ - Performing any optimization logic
18
+
19
+ Design philosophy:
20
+ This utility favors bounded, discrete search spaces for interpretability, auditability,
21
+ and deployability of learned policies.
22
+ """
23
+
24
+ import numpy as np
25
+ import math
26
+
27
+ def make_float_grid(x_min: float, x_max: float, step: float, decimals: int = 10) -> np.ndarray:
28
+ r"""Create a numerically robust 1D grid over a closed interval.
29
+
30
+ This utility is used throughout optimization to create bounded, interpretable
31
+ candidate sets for discrete parameter search (e.g., uplift multipliers, thresholds).
32
+
33
+ The returned grid:
34
+
35
+ - starts at `x_min`
36
+ - increments by `step`
37
+ - includes `x_max` (to the extent permitted by floating-point arithmetic)
38
+ - is clipped and de-duplicated for numerical stability
39
+
40
+ Parameters
41
+ ----------
42
+ x_min
43
+ Lower bound for the grid (inclusive). Must be strictly positive.
44
+ x_max
45
+ Upper bound for the grid (inclusive). Must be greater than or equal to `x_min`.
46
+ step
47
+ Step size between candidates. Must be strictly positive.
48
+ decimals
49
+ Rounding precision used to stabilize floats and de-duplicate.
50
+
51
+ Returns
52
+ -------
53
+ numpy.ndarray
54
+ A 1D array of unique grid values in ascending order.
55
+
56
+ Raises
57
+ ------
58
+ ValueError
59
+ If `step` is not strictly positive, if `x_min` is not strictly positive,
60
+ or if `x_max < x_min`.
61
+
62
+ Notes
63
+ -----
64
+ This utility ensures reproducible and stable grid construction for parameter tuning
65
+ and optimization purposes, while favoring discrete, bounded search spaces for
66
+ interpretability and deployability.
67
+ """
68
+ if step <= 0.0:
69
+ raise ValueError("step must be strictly positive.")
70
+ if x_min <= 0.0:
71
+ raise ValueError("x_min must be strictly positive.")
72
+ if x_max < x_min:
73
+ raise ValueError("x_max must be >= x_min.")
74
+
75
+ # Step-aligned grid starts at the first multiple of `step` that is >= x_min.
76
+ start = math.ceil(x_min / step) * step
77
+
78
+ # Generate core grid points [start, start + step, ..., x_max]
79
+ # Add a tiny epsilon to ensure inclusion when we're right on the boundary.
80
+ eps = 10 ** (-(decimals + 2))
81
+ core = np.arange(start, x_max + eps, step, dtype=float)
82
+
83
+ # Always include x_min and x_max explicitly
84
+ vals = np.concatenate(([float(x_min)], core, [float(x_max)]))
85
+
86
+ # Stabilize: round then unique then sort
87
+ vals = np.round(vals, decimals=decimals)
88
+ vals = np.unique(vals)
89
+ vals.sort()
90
+
91
+ return vals
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Generic discrete-search kernels for eb-optimization.
5
+
6
+ This module defines *mechanical* optimization primitives for selecting an
7
+ argmin or argmax over a finite candidate set.
8
+
9
+ Responsibilities
10
+ ----------------
11
+ - Iterate over a discrete candidate set
12
+ - Evaluate a scalar objective function
13
+ - Apply deterministic tie-breaking rules
14
+ - Return the selected candidate and its score
15
+
16
+ Non-responsibilities
17
+ --------------------
18
+ - Defining candidate grids (handled by ``search.grid``)
19
+ - Computing domain-specific objectives (e.g., cost, HR@τ, utility)
20
+ - Inspecting data distributions or residuals
21
+ - Returning diagnostics, plots, or policies
22
+
23
+ Design philosophy
24
+ -----------------
25
+ These kernels are intentionally simple, deterministic, and domain-agnostic.
26
+ They serve as reusable building blocks for higher-level tuning logic
27
+ (``tuning`` modules), enabling consistent and auditable optimization behavior
28
+ across the Electric Barometer ecosystem.
29
+ """
30
+
31
+ from typing import Callable, Iterable, Literal, TypeVar
32
+ import numpy as np
33
+
34
+ T = TypeVar("T")
35
+
36
+ __all__ = [
37
+ "argmin_over_candidates",
38
+ "argmax_over_candidates",
39
+ ]
40
+
41
+
42
+ def argmin_over_candidates(
43
+ candidates: Iterable[T],
44
+ score_fn: Callable[[T], float],
45
+ *,
46
+ tie_break: Literal["first", "last", "closest_to_zero"] = "first",
47
+ ) -> tuple[T, float]:
48
+ """
49
+ Select the candidate that minimizes a scalar score.
50
+
51
+ Parameters
52
+ ----------
53
+ candidates
54
+ Iterable of candidate values (e.g., floats, ints, tuples).
55
+ score_fn
56
+ Function mapping a candidate to a scalar score.
57
+ tie_break
58
+ Deterministic tie-breaking rule:
59
+ - ``"first"``: first candidate with minimal score
60
+ - ``"last"``: last candidate with minimal score
61
+ - ``"closest_to_zero"``: among ties, choose candidate with smallest
62
+ absolute value
63
+
64
+ Returns
65
+ -------
66
+ (best_candidate, best_score)
67
+
68
+ Raises
69
+ ------
70
+ ValueError
71
+ If ``candidates`` is empty or if ``score_fn`` returns a non-finite value.
72
+ """
73
+ best_candidate: T | None = None
74
+ best_score: float | None = None
75
+
76
+ for cand in candidates:
77
+ score = float(score_fn(cand))
78
+
79
+ if not np.isfinite(score):
80
+ raise ValueError(f"Non-finite score returned for candidate {cand!r}")
81
+
82
+ if best_score is None or score < best_score:
83
+ best_candidate = cand
84
+ best_score = score
85
+ continue
86
+
87
+ if score == best_score:
88
+ if tie_break == "last":
89
+ best_candidate = cand
90
+ elif tie_break == "closest_to_zero":
91
+ if abs(float(cand)) < abs(float(best_candidate)): # type: ignore[arg-type]
92
+ best_candidate = cand
93
+
94
+ if best_candidate is None or best_score is None:
95
+ raise ValueError("candidates must be a non-empty iterable")
96
+
97
+ return best_candidate, best_score
98
+
99
+
100
+ def argmax_over_candidates(
101
+ candidates: Iterable[T],
102
+ score_fn: Callable[[T], float],
103
+ *,
104
+ tie_break: Literal["first", "last", "closest_to_zero"] = "first",
105
+ ) -> tuple[T, float]:
106
+ """
107
+ Select the candidate that maximizes a scalar score.
108
+
109
+ This is the argmax analogue of ``argmin_over_candidates``.
110
+ """
111
+ return argmin_over_candidates(
112
+ candidates=candidates,
113
+ score_fn=lambda c: -float(score_fn(c)),
114
+ tie_break=tie_break,
115
+ )
File without changes
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Tuning utilities for the Electric Barometer ecosystem.
5
+
6
+ The `eb_optimization.tuning` package contains grid-search and calibration helpers
7
+ used to *select* hyperparameters and operating points for evaluation workflows.
8
+
9
+ Design intent
10
+ -------------
11
+ - **eb-metrics**: metric math (single-source-of-truth implementations)
12
+ - **eb-evaluation**: deterministic evaluation plumbing / orchestration
13
+ - **eb-optimization**: tuning, search, calibration, and sensitivity sweeps
14
+
15
+ Public API philosophy
16
+ ---------------------
17
+ Keep the package import surface stable by exporting *modules* instead of
18
+ re-exporting function symbols. This avoids import-time breakage when internals
19
+ are renamed during refactors.
20
+ """
21
+
22
+ __all__ = [
23
+ "cost_ratio",
24
+ "sensitivity",
25
+ "tau",
26
+ "ral",
27
+ ]
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ r"""
4
+ Cost ratio (R) tuning utilities.
5
+
6
+ This module provides calibration helpers for selecting the underbuild-to-overbuild
7
+ cost ratio:
8
+
9
+ $$
10
+ R = \frac{c_u}{c_o}
11
+ $$
12
+
13
+ These routines belong in **eb-optimization** because they *choose/govern* parameters
14
+ from data over a candidate set (grid search + calibration diagnostics). They are not
15
+ metric primitives (eb-metrics) and are not runtime policies (eb-optimization/policies).
16
+
17
+ Layering:
18
+ - search/ : reusable candidate-space utilities (grids, kernels)
19
+ - tuning/ : define candidate grids + objectives + return calibration artifacts
20
+ - policies/ : frozen artifacts that apply parameters deterministically at runtime
21
+ """
22
+
23
+ from typing import Optional, Sequence, Union
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+ from numpy.typing import ArrayLike
28
+
29
+ from .._utils import broadcast_param, handle_sample_weight, to_1d_array
30
+ from ..search.kernels import argmin_over_candidates
31
+
32
+ __all__ = [
33
+ "estimate_R_cost_balance",
34
+ "estimate_entity_R_from_balance",
35
+ ]
36
+
37
+
38
+ # ---------------------------------------------------------------------
39
+ # Global calibration (array-like)
40
+ # ---------------------------------------------------------------------
41
+ def estimate_R_cost_balance(
42
+ y_true: ArrayLike,
43
+ y_pred: ArrayLike,
44
+ R_grid: Sequence[float] = (0.5, 1.0, 2.0, 3.0),
45
+ co: Union[float, ArrayLike] = 1.0,
46
+ sample_weight: ArrayLike | None = None,
47
+ ) -> float:
48
+ r"""
49
+ Estimate a global cost ratio $R = c_u / c_o$ via cost balance.
50
+
51
+ This routine selects a single, global cost ratio $R$ by searching a
52
+ candidate grid and choosing the value where the total weighted underbuild
53
+ cost is closest to the total weighted overbuild cost.
54
+
55
+ For each candidate $R$ in ``R_grid``:
56
+
57
+ $$
58
+ \begin{aligned}
59
+ c_{u,i} &= R \cdot c_{o,i} \\
60
+ s_i &= \max(0, y_i - \hat{y}_i) \\
61
+ e_i &= \max(0, \hat{y}_i - y_i) \\
62
+ C_u(R) &= \sum_i w_i \; c_{u,i} \; s_i \\
63
+ C_o(R) &= \sum_i w_i \; c_{o,i} \; e_i
64
+ \end{aligned}
65
+ $$
66
+
67
+ and the selected value is:
68
+
69
+ $$
70
+ R^* = \arg\min_R \; \left| C_u(R) - C_o(R) \right|.
71
+ $$
72
+
73
+ Parameters
74
+ ----------
75
+ y_true
76
+ Realized demand (non-negative), shape (n_samples,).
77
+ y_pred
78
+ Forecast demand (non-negative), shape (n_samples,). Must match ``y_true``.
79
+ R_grid
80
+ Candidate ratios to search. Only strictly positive values are considered.
81
+ co
82
+ Overbuild cost coefficient $c_o$. May be scalar or 1D array of shape (n_samples,).
83
+ Underbuild cost is implied as $c_{u,i} = R \cdot c_{o,i}$.
84
+ sample_weight
85
+ Optional non-negative weights. If None, all intervals receive weight 1.0.
86
+
87
+ Returns
88
+ -------
89
+ float
90
+ Selected cost ratio in ``R_grid`` minimizing |under_cost - over_cost|.
91
+
92
+ Tie-breaking:
93
+ - In the degenerate perfect-forecast case (zero error everywhere), returns
94
+ the candidate closest to 1.0.
95
+ - Otherwise, if multiple candidates yield the same minimal gap, the first
96
+ encountered candidate (in filtered grid order) is returned.
97
+ """
98
+ y_true_arr = to_1d_array(y_true, "y_true")
99
+ y_pred_arr = to_1d_array(y_pred, "y_pred")
100
+
101
+ if y_true_arr.shape != y_pred_arr.shape:
102
+ raise ValueError(
103
+ "y_true and y_pred must have the same shape; "
104
+ f"got {y_true_arr.shape} and {y_pred_arr.shape}"
105
+ )
106
+
107
+ if np.any(y_true_arr < 0) or np.any(y_pred_arr < 0):
108
+ raise ValueError("y_true and y_pred must be non-negative.")
109
+
110
+ co_arr = broadcast_param(co, y_true_arr.shape, "co")
111
+ if np.any(co_arr <= 0):
112
+ raise ValueError("co must be strictly positive.")
113
+
114
+ w = handle_sample_weight(sample_weight, y_true_arr.shape[0])
115
+
116
+ shortfall = np.maximum(0.0, y_true_arr - y_pred_arr)
117
+ overbuild = np.maximum(0.0, y_pred_arr - y_true_arr)
118
+
119
+ R_grid_arr = np.asarray(R_grid, dtype=float)
120
+ if R_grid_arr.ndim != 1 or R_grid_arr.size == 0:
121
+ raise ValueError("R_grid must be a non-empty 1D sequence of floats.")
122
+
123
+ positive_R = R_grid_arr[R_grid_arr > 0]
124
+ if positive_R.size == 0:
125
+ raise ValueError("R_grid must contain at least one positive value.")
126
+
127
+ # Degenerate case: perfect forecast (no error anywhere)
128
+ if np.all(shortfall == 0.0) and np.all(overbuild == 0.0):
129
+ idx = int(np.argmin(np.abs(positive_R - 1.0)))
130
+ return float(positive_R[idx])
131
+
132
+ co_arr_f = co_arr.astype(float, copy=False)
133
+ w_f = w.astype(float, copy=False)
134
+
135
+ def _gap_for_R(R: float) -> float:
136
+ cu_arr = float(R) * co_arr_f
137
+ under_cost = float(np.sum(w_f * cu_arr * shortfall))
138
+ over_cost = float(np.sum(w_f * co_arr_f * overbuild))
139
+ return abs(under_cost - over_cost)
140
+
141
+ best_R, _best_gap = argmin_over_candidates(
142
+ candidates=positive_R,
143
+ score_fn=_gap_for_R,
144
+ tie_break="first", # preserves prior behavior
145
+ )
146
+
147
+ return float(best_R)
148
+
149
+
150
+ # ---------------------------------------------------------------------
151
+ # Entity-level calibration (DataFrame)
152
+ # ---------------------------------------------------------------------
153
+ def estimate_entity_R_from_balance(
154
+ df: pd.DataFrame,
155
+ entity_col: str,
156
+ y_true_col: str,
157
+ y_pred_col: str,
158
+ ratios: Sequence[float] = (0.5, 1.0, 2.0, 3.0),
159
+ co: float = 1.0,
160
+ sample_weight_col: Optional[str] = None,
161
+ ) -> pd.DataFrame:
162
+ r"""
163
+ Estimate an entity-level cost ratio via a cost-balance grid search.
164
+
165
+ This function estimates a per-entity underbuild-to-overbuild cost ratio:
166
+
167
+ $$
168
+ R_e = \frac{c_{u,e}}{c_o}
169
+ $$
170
+
171
+ by searching over a user-provided grid of candidate ratios.
172
+
173
+ Returns one row per entity with chosen R and supporting diagnostics.
174
+ """
175
+ required = {entity_col, y_true_col, y_pred_col}
176
+ missing = required - set(df.columns)
177
+ if missing:
178
+ raise KeyError(f"Missing required columns in df: {sorted(missing)}")
179
+
180
+ if sample_weight_col is not None and sample_weight_col not in df.columns:
181
+ raise KeyError(f"sample_weight_col {sample_weight_col!r} not found in df")
182
+
183
+ ratios_arr = np.asarray(list(ratios), dtype=float)
184
+ if ratios_arr.ndim != 1 or ratios_arr.size == 0 or np.any(ratios_arr <= 0):
185
+ raise ValueError("ratios must be a non-empty 1D sequence of positive floats.")
186
+
187
+ if co <= 0:
188
+ raise ValueError("co must be strictly positive.")
189
+
190
+ results: list[dict] = []
191
+ grouped = df.groupby(entity_col, sort=False)
192
+
193
+ for entity_id, g in grouped:
194
+ y_true = g[y_true_col].to_numpy(dtype=float)
195
+ y_pred = g[y_pred_col].to_numpy(dtype=float)
196
+
197
+ if sample_weight_col is not None:
198
+ w = g[sample_weight_col].to_numpy(dtype=float)
199
+ else:
200
+ w = np.ones_like(y_true, dtype=float)
201
+
202
+ if y_true.shape != y_pred.shape:
203
+ raise ValueError(
204
+ f"For entity {entity_id!r}, y_true and y_pred have different shapes: "
205
+ f"{y_true.shape} vs {y_pred.shape}"
206
+ )
207
+ if np.any(y_true < 0) or np.any(y_pred < 0):
208
+ raise ValueError(
209
+ f"For entity {entity_id!r}, y_true and y_pred must be non-negative."
210
+ )
211
+ if np.any(w < 0):
212
+ raise ValueError(
213
+ f"For entity {entity_id!r}, sample weights must be non-negative."
214
+ )
215
+
216
+ shortfall = np.maximum(0.0, y_true - y_pred)
217
+ overbuild = np.maximum(0.0, y_pred - y_true)
218
+
219
+ # Degenerate case: no error at all for this entity
220
+ if np.all(shortfall == 0.0) and np.all(overbuild == 0.0):
221
+ idx = int(np.argmin(np.abs(ratios_arr - 1.0)))
222
+ R_e = float(ratios_arr[idx])
223
+ cu_e = R_e * float(co)
224
+ results.append(
225
+ {
226
+ entity_col: entity_id,
227
+ "R": R_e,
228
+ "cu": cu_e,
229
+ "co": float(co),
230
+ "under_cost": 0.0,
231
+ "over_cost": 0.0,
232
+ "diff": 0.0,
233
+ }
234
+ )
235
+ continue
236
+
237
+ w_f = w.astype(float, copy=False)
238
+ co_f = float(co)
239
+
240
+ def _diff_for_R(R: float) -> float:
241
+ cu_val = float(R) * co_f
242
+ under_cost = float(np.sum(w_f * cu_val * shortfall))
243
+ over_cost = float(np.sum(w_f * co_f * overbuild))
244
+ return abs(under_cost - over_cost)
245
+
246
+ best_R, best_diff = argmin_over_candidates(
247
+ candidates=ratios_arr,
248
+ score_fn=_diff_for_R,
249
+ tie_break="first", # preserves prior behavior
250
+ )
251
+
252
+ # Recompute diagnostics for the chosen R (single pass)
253
+ best_R_f = float(best_R)
254
+ best_cu = best_R_f * co_f
255
+ best_under_cost = float(np.sum(w_f * best_cu * shortfall))
256
+ best_over_cost = float(np.sum(w_f * co_f * overbuild))
257
+
258
+ results.append(
259
+ {
260
+ entity_col: entity_id,
261
+ "R": best_R_f,
262
+ "cu": float(best_cu),
263
+ "co": float(co_f),
264
+ "under_cost": float(best_under_cost),
265
+ "over_cost": float(best_over_cost),
266
+ "diff": float(best_diff),
267
+ }
268
+ )
269
+
270
+ return pd.DataFrame(results)