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,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)
|