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,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
`eb_optimization` — optimization and tuning layer for the Electric Barometer ecosystem.
|
|
5
|
+
|
|
6
|
+
This package contains the **optimization layer** of Electric Barometer:
|
|
7
|
+
|
|
8
|
+
- **search**: generic, reusable search mechanics (grids, tie-breaking kernels)
|
|
9
|
+
- **tuning**: calibration and selection utilities (e.g., cost-ratio tuning, sensitivity sweeps)
|
|
10
|
+
- **policies**: frozen, declarative policy artifacts for downstream execution
|
|
11
|
+
|
|
12
|
+
It intentionally does **not** define metric primitives or evaluation math.
|
|
13
|
+
Those live in `eb-metrics` (and orchestration lives in `eb-evaluation`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_version() -> str:
|
|
20
|
+
"""
|
|
21
|
+
Resolve the installed distribution version.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
str
|
|
26
|
+
Installed version string. If the distribution is not installed (e.g., running
|
|
27
|
+
from source), returns ``"0.0.0"``.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
# Must match the distribution name in pyproject.toml ([project].name)
|
|
31
|
+
return version("eb-optimization")
|
|
32
|
+
except PackageNotFoundError:
|
|
33
|
+
return "0.0.0"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__version__ = _resolve_version()
|
|
37
|
+
|
|
38
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from numpy.typing import ArrayLike
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"to_1d_array",
|
|
8
|
+
"broadcast_param",
|
|
9
|
+
"handle_sample_weight",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_1d_array(x: ArrayLike, name: str) -> np.ndarray:
|
|
14
|
+
"""
|
|
15
|
+
Convert input to a 1D numpy float array.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
x
|
|
20
|
+
Array-like input.
|
|
21
|
+
name
|
|
22
|
+
Name used in error messages.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
numpy.ndarray
|
|
27
|
+
1D float array.
|
|
28
|
+
|
|
29
|
+
Raises
|
|
30
|
+
------
|
|
31
|
+
ValueError
|
|
32
|
+
If the input is not 1-dimensional.
|
|
33
|
+
"""
|
|
34
|
+
arr = np.asarray(x, dtype=float)
|
|
35
|
+
|
|
36
|
+
if arr.ndim != 1:
|
|
37
|
+
raise ValueError(f"{name} must be a 1D array; got shape {arr.shape}")
|
|
38
|
+
|
|
39
|
+
return arr
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def broadcast_param(x: ArrayLike, shape: tuple[int, ...], name: str) -> np.ndarray:
|
|
43
|
+
"""
|
|
44
|
+
Broadcast a scalar or 1D array parameter to a target shape.
|
|
45
|
+
|
|
46
|
+
Rules
|
|
47
|
+
-----
|
|
48
|
+
- Scalars are expanded to the given shape
|
|
49
|
+
- 1D arrays must exactly match the target shape
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
x
|
|
54
|
+
Scalar or 1D array parameter.
|
|
55
|
+
shape
|
|
56
|
+
Target shape.
|
|
57
|
+
name
|
|
58
|
+
Name used in error messages.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
numpy.ndarray
|
|
63
|
+
Float array of shape ``shape``.
|
|
64
|
+
|
|
65
|
+
Raises
|
|
66
|
+
------
|
|
67
|
+
ValueError
|
|
68
|
+
If ``x`` is neither scalar nor matches the target shape.
|
|
69
|
+
"""
|
|
70
|
+
arr = np.asarray(x, dtype=float)
|
|
71
|
+
|
|
72
|
+
if arr.ndim == 0:
|
|
73
|
+
return np.full(shape, float(arr), dtype=float)
|
|
74
|
+
|
|
75
|
+
if arr.shape != shape:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"{name} must be scalar or have shape {shape}; got shape {arr.shape}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return arr
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def handle_sample_weight(sample_weight: ArrayLike | None, n: int) -> np.ndarray:
|
|
84
|
+
"""
|
|
85
|
+
Normalize sample weights to a non-negative 1D float array of length n.
|
|
86
|
+
|
|
87
|
+
If ``sample_weight`` is None, returns an array of ones.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
sample_weight
|
|
92
|
+
None or a 1D array of non-negative weights.
|
|
93
|
+
n
|
|
94
|
+
Expected length.
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
numpy.ndarray
|
|
99
|
+
1D float array of length n.
|
|
100
|
+
|
|
101
|
+
Raises
|
|
102
|
+
------
|
|
103
|
+
ValueError
|
|
104
|
+
If weights are not length-n, not 1D, or contain negative values.
|
|
105
|
+
"""
|
|
106
|
+
if n <= 0:
|
|
107
|
+
raise ValueError(f"n must be a positive integer; got {n}")
|
|
108
|
+
|
|
109
|
+
if sample_weight is None:
|
|
110
|
+
return np.ones(n, dtype=float)
|
|
111
|
+
|
|
112
|
+
w = np.asarray(sample_weight, dtype=float)
|
|
113
|
+
|
|
114
|
+
if w.ndim != 1 or w.shape[0] != n:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"sample_weight must be a 1D array of length {n}; got shape {w.shape}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if np.any(w < 0):
|
|
120
|
+
raise ValueError("sample_weight must be non-negative.")
|
|
121
|
+
|
|
122
|
+
return w
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Frozen policy artifacts for the Electric Barometer optimization layer.
|
|
5
|
+
|
|
6
|
+
The `eb_optimization.policies` package contains **governance-level, immutable
|
|
7
|
+
configuration objects** that define how tuned parameters are selected and applied
|
|
8
|
+
at runtime.
|
|
9
|
+
|
|
10
|
+
Design principles
|
|
11
|
+
-----------------
|
|
12
|
+
- Policies are **frozen** (dataclass(frozen=True)) and versionable
|
|
13
|
+
- Policies contain **no learning or tuning logic**
|
|
14
|
+
- Policies wrap tuning utilities with deterministic application semantics
|
|
15
|
+
- Policies are safe to ship to production systems
|
|
16
|
+
|
|
17
|
+
Layering
|
|
18
|
+
--------
|
|
19
|
+
- tuning/ : derives parameters from data (calibration, grid search)
|
|
20
|
+
- policies/ : freezes configuration + applies tuning deterministically
|
|
21
|
+
- runtime : consumes policy outputs only (no re-tuning)
|
|
22
|
+
|
|
23
|
+
Exported policies
|
|
24
|
+
-----------------
|
|
25
|
+
- Tau (τ) tolerance governance for HR@τ
|
|
26
|
+
- Cost-ratio (R = c_u / c_o) governance for asymmetric loss
|
|
27
|
+
- RAL policy governance (readiness adjustment layer)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------
|
|
31
|
+
# Tau (tolerance) policies
|
|
32
|
+
# ---------------------------------------------------------------------
|
|
33
|
+
from .tau_policy import (
|
|
34
|
+
TauPolicy,
|
|
35
|
+
apply_tau_policy,
|
|
36
|
+
apply_tau_policy_hr,
|
|
37
|
+
apply_entity_tau_policy,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------
|
|
41
|
+
# Cost-ratio (R) policies
|
|
42
|
+
# ---------------------------------------------------------------------
|
|
43
|
+
from .cost_ratio_policy import (
|
|
44
|
+
CostRatioPolicy,
|
|
45
|
+
DEFAULT_COST_RATIO_POLICY,
|
|
46
|
+
apply_cost_ratio_policy,
|
|
47
|
+
apply_entity_cost_ratio_policy,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------
|
|
51
|
+
# RAL policies
|
|
52
|
+
# ---------------------------------------------------------------------
|
|
53
|
+
from .ral_policy import (
|
|
54
|
+
RALPolicy,
|
|
55
|
+
DEFAULT_RAL_POLICY,
|
|
56
|
+
apply_ral_policy,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
# Tau policies
|
|
61
|
+
"TauPolicy",
|
|
62
|
+
"apply_tau_policy",
|
|
63
|
+
"apply_tau_policy_hr",
|
|
64
|
+
"apply_entity_tau_policy",
|
|
65
|
+
# Cost ratio policies
|
|
66
|
+
"CostRatioPolicy",
|
|
67
|
+
"DEFAULT_COST_RATIO_POLICY",
|
|
68
|
+
"apply_cost_ratio_policy",
|
|
69
|
+
"apply_entity_cost_ratio_policy",
|
|
70
|
+
# RAL policies
|
|
71
|
+
"RALPolicy",
|
|
72
|
+
"DEFAULT_RAL_POLICY",
|
|
73
|
+
"apply_ral_policy",
|
|
74
|
+
]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Cost-ratio (R = c_u / c_o) policy artifacts for eb-optimization.
|
|
5
|
+
|
|
6
|
+
This module defines *frozen governance* for selecting and applying a cost ratio `R`
|
|
7
|
+
(and derived underbuild cost `c_u`) used by asymmetric cost metrics like CWSL.
|
|
8
|
+
|
|
9
|
+
Layering & responsibilities
|
|
10
|
+
---------------------------
|
|
11
|
+
- `tuning/cost_ratio.py`:
|
|
12
|
+
Calibration logic (estimating R from residuals / cost balance).
|
|
13
|
+
- `policies/cost_ratio_policy.py`:
|
|
14
|
+
Frozen configuration + deterministic application wrappers.
|
|
15
|
+
|
|
16
|
+
The policy layer exists so downstream consumers can:
|
|
17
|
+
- pin method + governance settings in a versioned, auditable object
|
|
18
|
+
- apply the same selection logic consistently across environments
|
|
19
|
+
- avoid re-encoding configuration details in orchestration code
|
|
20
|
+
|
|
21
|
+
Design intent
|
|
22
|
+
-------------
|
|
23
|
+
Policies should be:
|
|
24
|
+
- stable: schema changes are deliberate and versioned
|
|
25
|
+
- deterministic: same inputs + same policy -> same outputs
|
|
26
|
+
- safe: validated governance parameters + clear failure semantics
|
|
27
|
+
|
|
28
|
+
This module provides:
|
|
29
|
+
- `CostRatioPolicy`: frozen configuration
|
|
30
|
+
- `DEFAULT_COST_RATIO_POLICY`: exported default policy
|
|
31
|
+
- `apply_cost_ratio_policy`: estimate global R from arrays/Series
|
|
32
|
+
- `apply_entity_cost_ratio_policy`: estimate per-entity R from a DataFrame
|
|
33
|
+
|
|
34
|
+
Notes
|
|
35
|
+
-----
|
|
36
|
+
- This policy does *not* compute CWSL. It only governs parameter selection.
|
|
37
|
+
- `co` may be scalar or per-interval array for global estimation; for entity-level
|
|
38
|
+
estimation, `co` is currently modeled as a scalar (consistent with tuning).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from typing import Any, Dict, Mapping, Optional, Sequence, Tuple, Union
|
|
43
|
+
|
|
44
|
+
import numpy as np
|
|
45
|
+
import pandas as pd
|
|
46
|
+
from numpy.typing import ArrayLike
|
|
47
|
+
|
|
48
|
+
from eb_optimization.tuning.cost_ratio import (
|
|
49
|
+
estimate_R_cost_balance,
|
|
50
|
+
estimate_entity_R_from_balance,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class CostRatioPolicy:
|
|
56
|
+
"""
|
|
57
|
+
Frozen cost-ratio (R) policy configuration.
|
|
58
|
+
|
|
59
|
+
Attributes
|
|
60
|
+
----------
|
|
61
|
+
R_grid : Sequence[float]
|
|
62
|
+
Candidate ratios to search. Only strictly positive values are considered.
|
|
63
|
+
Order matters for tie-breaking (first-minimizer behavior).
|
|
64
|
+
co : float
|
|
65
|
+
Default overbuild cost coefficient used for entity-level estimation
|
|
66
|
+
(and as a default for global estimation if `co` is not passed at call time).
|
|
67
|
+
min_n : int
|
|
68
|
+
Minimum number of observations required to estimate an entity-level R.
|
|
69
|
+
Entities with fewer observations are returned with NaN R (and diagnostics).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
R_grid: Sequence[float] = (0.5, 1.0, 2.0, 3.0)
|
|
73
|
+
co: float = 1.0
|
|
74
|
+
min_n: int = 30
|
|
75
|
+
|
|
76
|
+
def __post_init__(self) -> None:
|
|
77
|
+
grid = np.asarray(list(self.R_grid), dtype=float)
|
|
78
|
+
if grid.ndim != 1 or grid.size == 0:
|
|
79
|
+
raise ValueError("R_grid must be a non-empty 1D sequence of floats.")
|
|
80
|
+
if not np.any(grid > 0):
|
|
81
|
+
raise ValueError("R_grid must contain at least one strictly positive value.")
|
|
82
|
+
|
|
83
|
+
if not np.isfinite(self.co) or float(self.co) <= 0:
|
|
84
|
+
raise ValueError(f"co must be finite and strictly positive. Got {self.co}.")
|
|
85
|
+
|
|
86
|
+
if self.min_n < 1:
|
|
87
|
+
raise ValueError(f"min_n must be >= 1. Got {self.min_n}.")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
DEFAULT_COST_RATIO_POLICY = CostRatioPolicy()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def apply_cost_ratio_policy(
|
|
94
|
+
y_true: ArrayLike,
|
|
95
|
+
y_pred: ArrayLike,
|
|
96
|
+
*,
|
|
97
|
+
policy: CostRatioPolicy = DEFAULT_COST_RATIO_POLICY,
|
|
98
|
+
co: Union[float, ArrayLike, None] = None,
|
|
99
|
+
sample_weight: ArrayLike | None = None,
|
|
100
|
+
) -> Tuple[float, Dict[str, Any]]:
|
|
101
|
+
"""
|
|
102
|
+
Apply a frozen cost-ratio policy to estimate a global R.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
y_true, y_pred
|
|
107
|
+
Realized and forecast demand vectors (non-negative, same shape).
|
|
108
|
+
policy
|
|
109
|
+
Frozen configuration controlling the candidate grid and defaults.
|
|
110
|
+
co
|
|
111
|
+
Overbuild cost coefficient. If None, uses `policy.co`.
|
|
112
|
+
Can be scalar or per-interval array (passed through to tuning).
|
|
113
|
+
sample_weight
|
|
114
|
+
Optional non-negative per-interval weights.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
(R, diagnostics)
|
|
119
|
+
R is a float.
|
|
120
|
+
Diagnostics include the chosen R, co summary, and candidate grid.
|
|
121
|
+
|
|
122
|
+
Notes
|
|
123
|
+
-----
|
|
124
|
+
The underlying selection rule is in `tuning.estimate_R_cost_balance`:
|
|
125
|
+
choose R that minimizes |under_cost(R) - over_cost|.
|
|
126
|
+
"""
|
|
127
|
+
co_val = policy.co if co is None else co
|
|
128
|
+
|
|
129
|
+
R = float(
|
|
130
|
+
estimate_R_cost_balance(
|
|
131
|
+
y_true=y_true,
|
|
132
|
+
y_pred=y_pred,
|
|
133
|
+
R_grid=policy.R_grid,
|
|
134
|
+
co=co_val,
|
|
135
|
+
sample_weight=sample_weight,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
diag: Dict[str, Any] = {
|
|
140
|
+
"method": "cost_balance",
|
|
141
|
+
"R_grid": list(map(float, policy.R_grid)),
|
|
142
|
+
"co_is_array": isinstance(co_val, (list, tuple, np.ndarray, pd.Series)),
|
|
143
|
+
"co_default_used": co is None,
|
|
144
|
+
"R": R,
|
|
145
|
+
}
|
|
146
|
+
return (R, diag)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def apply_entity_cost_ratio_policy(
|
|
150
|
+
df: pd.DataFrame,
|
|
151
|
+
*,
|
|
152
|
+
entity_col: str,
|
|
153
|
+
y_true_col: str,
|
|
154
|
+
y_pred_col: str,
|
|
155
|
+
policy: CostRatioPolicy = DEFAULT_COST_RATIO_POLICY,
|
|
156
|
+
co: Optional[float] = None,
|
|
157
|
+
sample_weight_col: Optional[str] = None,
|
|
158
|
+
include_diagnostics: bool = True,
|
|
159
|
+
) -> pd.DataFrame:
|
|
160
|
+
"""
|
|
161
|
+
Apply a frozen cost-ratio policy per entity.
|
|
162
|
+
|
|
163
|
+
This wraps `tuning.estimate_entity_R_from_balance` but adds policy governance:
|
|
164
|
+
- enforces a minimum sample size per entity (min_n)
|
|
165
|
+
- pins the candidate ratio grid (R_grid)
|
|
166
|
+
- provides stable, auditable defaults for co and configuration
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
df
|
|
171
|
+
Input data containing entity ids, actuals, forecasts, and optionally weights.
|
|
172
|
+
entity_col
|
|
173
|
+
Column name identifying the entity to calibrate (e.g., restaurant_id).
|
|
174
|
+
y_true_col, y_pred_col
|
|
175
|
+
Column names for realized demand and forecast.
|
|
176
|
+
policy
|
|
177
|
+
Frozen configuration controlling ratios grid and governance.
|
|
178
|
+
co
|
|
179
|
+
Scalar overbuild cost coefficient. If None, uses `policy.co`.
|
|
180
|
+
sample_weight_col
|
|
181
|
+
Optional column name containing non-negative sample weights.
|
|
182
|
+
include_diagnostics
|
|
183
|
+
If True, returns diagnostics columns (under_cost, over_cost, diff) produced
|
|
184
|
+
by tuning. If False, trims to [entity_col, R, cu, co] plus governance columns.
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
pandas.DataFrame
|
|
189
|
+
One row per entity with chosen R and supporting diagnostics. Entities
|
|
190
|
+
with fewer than `policy.min_n` observations will be returned with NaN R.
|
|
191
|
+
|
|
192
|
+
Raises
|
|
193
|
+
------
|
|
194
|
+
KeyError
|
|
195
|
+
If required columns are missing.
|
|
196
|
+
ValueError
|
|
197
|
+
If invalid governance values or negative weights are present.
|
|
198
|
+
|
|
199
|
+
Notes
|
|
200
|
+
-----
|
|
201
|
+
The tuning implementation currently treats `co` as a scalar for entity-level
|
|
202
|
+
estimation (consistent with the function signature in tuning).
|
|
203
|
+
"""
|
|
204
|
+
# ---- validation: columns ----
|
|
205
|
+
if entity_col not in df.columns:
|
|
206
|
+
raise KeyError(f"entity_col {entity_col!r} not found in df")
|
|
207
|
+
if y_true_col not in df.columns:
|
|
208
|
+
raise KeyError(f"y_true_col {y_true_col!r} not found in df")
|
|
209
|
+
if y_pred_col not in df.columns:
|
|
210
|
+
raise KeyError(f"y_pred_col {y_pred_col!r} not found in df")
|
|
211
|
+
if sample_weight_col is not None and sample_weight_col not in df.columns:
|
|
212
|
+
raise KeyError(f"sample_weight_col {sample_weight_col!r} not found in df")
|
|
213
|
+
|
|
214
|
+
co_val = float(policy.co if co is None else co)
|
|
215
|
+
if not np.isfinite(co_val) or co_val <= 0:
|
|
216
|
+
raise ValueError(f"co must be finite and strictly positive. Got {co_val}.")
|
|
217
|
+
|
|
218
|
+
# ---- governance: min_n (simple deterministic rule: row count per entity) ----
|
|
219
|
+
counts = df.groupby(entity_col, dropna=False, sort=False).size()
|
|
220
|
+
eligible_entities = counts[counts >= policy.min_n].index
|
|
221
|
+
ineligible_entities = counts[counts < policy.min_n].index
|
|
222
|
+
|
|
223
|
+
eligible = df[df[entity_col].isin(eligible_entities)].copy()
|
|
224
|
+
ineligible = df[df[entity_col].isin(ineligible_entities)].copy()
|
|
225
|
+
|
|
226
|
+
# ---- tune eligible entities ----
|
|
227
|
+
if not eligible.empty:
|
|
228
|
+
tuned = estimate_entity_R_from_balance(
|
|
229
|
+
df=eligible,
|
|
230
|
+
entity_col=entity_col,
|
|
231
|
+
y_true_col=y_true_col,
|
|
232
|
+
y_pred_col=y_pred_col,
|
|
233
|
+
ratios=policy.R_grid,
|
|
234
|
+
co=co_val,
|
|
235
|
+
sample_weight_col=sample_weight_col,
|
|
236
|
+
).copy()
|
|
237
|
+
tuned["reason"] = None
|
|
238
|
+
tuned["n"] = tuned[entity_col].map(counts).astype(int)
|
|
239
|
+
else:
|
|
240
|
+
tuned = pd.DataFrame(
|
|
241
|
+
columns=[entity_col, "R", "cu", "co", "under_cost", "over_cost", "diff", "reason", "n"]
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# ---- build rows for ineligible entities (one row per entity) ----
|
|
245
|
+
if not ineligible.empty:
|
|
246
|
+
ineligible_rows = (
|
|
247
|
+
ineligible[[entity_col]]
|
|
248
|
+
.drop_duplicates()
|
|
249
|
+
.assign(
|
|
250
|
+
R=np.nan,
|
|
251
|
+
cu=np.nan,
|
|
252
|
+
co=co_val,
|
|
253
|
+
under_cost=np.nan,
|
|
254
|
+
over_cost=np.nan,
|
|
255
|
+
diff=np.nan,
|
|
256
|
+
reason=f"min_n_not_met(<{policy.min_n})",
|
|
257
|
+
)
|
|
258
|
+
.copy()
|
|
259
|
+
)
|
|
260
|
+
ineligible_rows["n"] = ineligible_rows[entity_col].map(counts).astype(int)
|
|
261
|
+
else:
|
|
262
|
+
ineligible_rows = pd.DataFrame(
|
|
263
|
+
columns=[entity_col, "R", "cu", "co", "under_cost", "over_cost", "diff", "reason", "n"]
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# ---- combine (avoid pandas FutureWarning on concat with empty/all-NA frames) ----
|
|
267
|
+
if ineligible_rows.empty:
|
|
268
|
+
out = tuned
|
|
269
|
+
elif tuned.empty:
|
|
270
|
+
out = ineligible_rows
|
|
271
|
+
else:
|
|
272
|
+
out = pd.concat([tuned, ineligible_rows], ignore_index=True, sort=False)
|
|
273
|
+
|
|
274
|
+
# ---- stable column ordering ----
|
|
275
|
+
base_cols = [entity_col, "R", "cu", "co", "n", "reason"]
|
|
276
|
+
diag_cols = ["under_cost", "over_cost", "diff"]
|
|
277
|
+
remaining = [c for c in out.columns if c not in base_cols + diag_cols]
|
|
278
|
+
|
|
279
|
+
cols = (base_cols + diag_cols + remaining) if include_diagnostics else (base_cols + remaining)
|
|
280
|
+
|
|
281
|
+
# Ensure all expected columns exist (even if empty)
|
|
282
|
+
for c in cols:
|
|
283
|
+
if c not in out.columns:
|
|
284
|
+
out[c] = np.nan
|
|
285
|
+
|
|
286
|
+
return out[cols]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
__all__ = [
|
|
290
|
+
"CostRatioPolicy",
|
|
291
|
+
"DEFAULT_COST_RATIO_POLICY",
|
|
292
|
+
"apply_cost_ratio_policy",
|
|
293
|
+
"apply_entity_cost_ratio_policy",
|
|
294
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Policy artifacts for the Readiness Adjustment Layer (RAL).
|
|
5
|
+
|
|
6
|
+
This module defines portable, immutable policy objects produced by offline
|
|
7
|
+
optimization and consumed by deterministic evaluation and production workflows.
|
|
8
|
+
|
|
9
|
+
Responsibilities:
|
|
10
|
+
- Represent learned RAL parameters (global and optional segment-level uplifts)
|
|
11
|
+
- Provide a stable, serializable contract between optimization and evaluation
|
|
12
|
+
- Support audit and governance workflows
|
|
13
|
+
|
|
14
|
+
Non-responsibilities:
|
|
15
|
+
- Learning or tuning parameters
|
|
16
|
+
- Applying policies to data
|
|
17
|
+
- Defining metric or loss functions
|
|
18
|
+
|
|
19
|
+
Design philosophy:
|
|
20
|
+
Policies are artifacts, not algorithms. They encode *decisions* derived from
|
|
21
|
+
optimization, not the optimization process itself.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Optional, Sequence
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RALPolicy:
|
|
30
|
+
r"""Portable policy artifact for the Readiness Adjustment Layer (RAL).
|
|
31
|
+
|
|
32
|
+
A :class:`~eb_optimization.policies.ral_policy.RALPolicy` is the *output* of an
|
|
33
|
+
offline tuning process (e.g., grid search or evolutionary optimization) and the
|
|
34
|
+
*input* to deterministic evaluation / production application.
|
|
35
|
+
|
|
36
|
+
Conceptually, RAL applies a multiplicative uplift to a baseline forecast:
|
|
37
|
+
|
|
38
|
+
$$ \hat{y}^{(r)} = u \cdot \hat{y} $$
|
|
39
|
+
|
|
40
|
+
where `u` can be either:
|
|
41
|
+
|
|
42
|
+
- a **global uplift** (`global_uplift`), applied to all rows, and/or
|
|
43
|
+
- **segment-level** uplifts stored in `uplift_table`, keyed by `segment_cols`
|
|
44
|
+
|
|
45
|
+
Segment-level uplifts must fall back to the global uplift for unseen segment
|
|
46
|
+
combinations at application time.
|
|
47
|
+
|
|
48
|
+
Attributes
|
|
49
|
+
----------
|
|
50
|
+
global_uplift
|
|
51
|
+
The global multiplicative uplift used as a fallback and baseline readiness adjustment.
|
|
52
|
+
segment_cols
|
|
53
|
+
The segmentation columns used to key `uplift_table`. Empty means "global-only".
|
|
54
|
+
uplift_table
|
|
55
|
+
Optional DataFrame with columns `[*segment_cols, "uplift"]` containing
|
|
56
|
+
segment-level uplifts. If `None` or empty, the policy is global-only.
|
|
57
|
+
|
|
58
|
+
Notes
|
|
59
|
+
-----
|
|
60
|
+
This dataclass is intentionally simple and serializable. It is meant to be:
|
|
61
|
+
|
|
62
|
+
- produced offline in `eb-optimization`
|
|
63
|
+
- applied deterministically in `eb-evaluation`
|
|
64
|
+
- loggable/auditable as part of operational governance
|
|
65
|
+
|
|
66
|
+
The policy does *not* encode metric definitions or optimization state—only the
|
|
67
|
+
artifacts needed to execute the adjustment.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
global_uplift: float
|
|
71
|
+
segment_cols: Sequence[str] = ()
|
|
72
|
+
uplift_table: Optional[pd.DataFrame] = None
|
|
73
|
+
|
|
74
|
+
def is_segmented(self) -> bool:
|
|
75
|
+
"""Return True if the policy contains segment-level uplifts."""
|
|
76
|
+
return bool(self.segment_cols) and self.uplift_table is not None and not self.uplift_table.empty
|
|
77
|
+
|
|
78
|
+
def adjust_forecast(self, df: pd.DataFrame, forecast_col: str) -> pd.Series:
|
|
79
|
+
"""Apply the RAL policy to adjust the forecast values.
|
|
80
|
+
|
|
81
|
+
This method applies the global uplift to all rows, and applies segment-level uplifts
|
|
82
|
+
if the policy is segmented and matching segments exist in the `uplift_table`.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
df : pd.DataFrame
|
|
87
|
+
The input DataFrame containing the forecast to adjust.
|
|
88
|
+
forecast_col : str
|
|
89
|
+
The name of the column in `df` containing the forecast values to adjust.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
pd.Series
|
|
94
|
+
A series with the adjusted forecast values.
|
|
95
|
+
"""
|
|
96
|
+
# Start with the global uplift applied to the forecast column
|
|
97
|
+
adjusted_forecast = df[forecast_col] * self.global_uplift
|
|
98
|
+
|
|
99
|
+
# Apply segment-level uplifts if available
|
|
100
|
+
if self.is_segmented():
|
|
101
|
+
# Merge uplift_table with the DataFrame based on segment columns
|
|
102
|
+
uplift_df = df.merge(self.uplift_table, on=self.segment_cols, how="left")
|
|
103
|
+
# Apply the segment-level uplift (if available) to the forecast
|
|
104
|
+
uplifted_forecast = adjusted_forecast * uplift_df["uplift"].fillna(1.0) # Default to 1.0 if no uplift
|
|
105
|
+
return uplifted_forecast
|
|
106
|
+
return adjusted_forecast
|
|
107
|
+
|
|
108
|
+
def transform(self, df: pd.DataFrame, forecast_col: str) -> pd.DataFrame:
|
|
109
|
+
"""Transform the input DataFrame by applying the forecast adjustment.
|
|
110
|
+
|
|
111
|
+
This method applies the RAL policy to adjust the forecast column and adds a new column
|
|
112
|
+
with the adjusted forecast.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
df : pd.DataFrame
|
|
117
|
+
The input DataFrame containing the forecast to adjust.
|
|
118
|
+
forecast_col : str
|
|
119
|
+
The name of the column in `df` containing the forecast values to adjust.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
pd.DataFrame
|
|
124
|
+
The transformed DataFrame with the adjusted forecast values added.
|
|
125
|
+
"""
|
|
126
|
+
df_copy = df.copy()
|
|
127
|
+
adjusted_forecast = self.adjust_forecast(df_copy, forecast_col)
|
|
128
|
+
df_copy["readiness_forecast"] = adjusted_forecast
|
|
129
|
+
return df_copy
|