eb-optimization 0.2.0__tar.gz → 0.2.3__tar.gz
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-0.2.0/src/eb_optimization.egg-info → eb_optimization-0.2.3}/PKG-INFO +14 -4
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/README.md +2 -2
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/pyproject.toml +24 -7
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/__init__.py +18 -3
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/_utils.py +4 -8
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/policies/__init__.py +33 -36
- eb_optimization-0.2.3/src/eb_optimization/policies/cost_ratio_policy.py +409 -0
- eb_optimization-0.2.3/src/eb_optimization/policies/dqc_policy.py +331 -0
- eb_optimization-0.2.3/src/eb_optimization/policies/ral_policy.py +600 -0
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/policies/tau_policy.py +16 -19
- eb_optimization-0.2.3/src/eb_optimization/search/__init__.py +12 -0
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/search/grid.py +6 -4
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/search/kernels.py +14 -14
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/tuning/__init__.py +5 -8
- eb_optimization-0.2.3/src/eb_optimization/tuning/cost_ratio.py +911 -0
- eb_optimization-0.2.3/src/eb_optimization/tuning/ral.py +175 -0
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/tuning/sensitivity.py +31 -79
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization/tuning/tau.py +39 -110
- {eb_optimization-0.2.0 → eb_optimization-0.2.3/src/eb_optimization.egg-info}/PKG-INFO +14 -4
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization.egg-info/SOURCES.txt +1 -1
- eb_optimization-0.2.3/src/eb_optimization.egg-info/requires.txt +29 -0
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/tests/test_public_api.py +21 -9
- eb_optimization-0.2.0/src/eb_optimization/policies/cost_ratio_policy.py +0 -294
- eb_optimization-0.2.0/src/eb_optimization/policies/ral_policy.py +0 -162
- eb_optimization-0.2.0/src/eb_optimization/search/__init__.py +0 -32
- eb_optimization-0.2.0/src/eb_optimization/search/results.py +0 -0
- eb_optimization-0.2.0/src/eb_optimization/tuning/cost_ratio.py +0 -270
- eb_optimization-0.2.0/src/eb_optimization/tuning/ral.py +0 -144
- eb_optimization-0.2.0/src/eb_optimization.egg-info/requires.txt +0 -18
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/LICENSE +0 -0
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/setup.cfg +0 -0
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization.egg-info/dependency_links.txt +0 -0
- {eb_optimization-0.2.0 → eb_optimization-0.2.3}/src/eb_optimization.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: eb-optimization
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Electric Barometer: Optimization and tuning utilities for EB objectives and policy parameters.
|
|
5
5
|
Author-email: "Kyle Corrie (Economistician)" <kcorrie@economistician.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -28,11 +28,21 @@ Provides-Extra: opt
|
|
|
28
28
|
Provides-Extra: test
|
|
29
29
|
Requires-Dist: pytest>=8.0; extra == "test"
|
|
30
30
|
Requires-Dist: scikit-learn>=1.3; extra == "test"
|
|
31
|
-
Requires-Dist: eb-optimization[eb]; extra == "test"
|
|
32
31
|
Provides-Extra: dev
|
|
33
32
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
34
33
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
35
|
-
Requires-Dist:
|
|
34
|
+
Requires-Dist: ruff>=0.0.1; extra == "dev"
|
|
35
|
+
Requires-Dist: pyright>=1.1; extra == "dev"
|
|
36
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
37
|
+
Provides-Extra: all
|
|
38
|
+
Requires-Dist: eb-metrics<0.3,>=0.2; extra == "all"
|
|
39
|
+
Requires-Dist: eb-evaluation<0.3,>=0.2; extra == "all"
|
|
40
|
+
Requires-Dist: pytest>=8.0; extra == "all"
|
|
41
|
+
Requires-Dist: scikit-learn>=1.3; extra == "all"
|
|
42
|
+
Requires-Dist: pytest-cov>=5.0; extra == "all"
|
|
43
|
+
Requires-Dist: ruff>=0.0.1; extra == "all"
|
|
44
|
+
Requires-Dist: pyright>=1.1; extra == "all"
|
|
45
|
+
Requires-Dist: twine>=4.0; extra == "all"
|
|
36
46
|
Dynamic: license-file
|
|
37
47
|
|
|
38
48
|
# Electric Barometer · Optimization (`eb-optimization`)
|
|
@@ -120,5 +130,5 @@ print(R)
|
|
|
120
130
|
|
|
121
131
|
## License
|
|
122
132
|
|
|
123
|
-
BSD 3-Clause License.
|
|
133
|
+
BSD 3-Clause License.
|
|
124
134
|
© 2025 Kyle Corrie.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
######################################
|
|
4
4
|
[project]
|
|
5
5
|
name = "eb-optimization"
|
|
6
|
-
version = "0.2.
|
|
6
|
+
version = "0.2.3"
|
|
7
7
|
description = "Electric Barometer: Optimization and tuning utilities for EB objectives and policy parameters."
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
requires-python = ">=3.10"
|
|
@@ -67,22 +67,39 @@ eb = [
|
|
|
67
67
|
]
|
|
68
68
|
|
|
69
69
|
# Optional optimization backends (future-proofing)
|
|
70
|
-
|
|
71
|
-
opt = [
|
|
72
|
-
]
|
|
70
|
+
opt = []
|
|
73
71
|
|
|
74
72
|
# CI / test-only dependencies
|
|
75
73
|
test = [
|
|
76
74
|
"pytest>=8.0",
|
|
77
75
|
"scikit-learn>=1.3",
|
|
78
|
-
"eb-optimization[eb]",
|
|
79
76
|
]
|
|
80
77
|
|
|
81
78
|
# Local developer tooling
|
|
82
79
|
dev = [
|
|
83
80
|
"pytest>=8.0",
|
|
84
81
|
"pytest-cov>=5.0",
|
|
85
|
-
"
|
|
82
|
+
"ruff>=0.0.1",
|
|
83
|
+
"pyright>=1.1",
|
|
84
|
+
"twine>=4.0",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
# Combined extra to satisfy the "extras: all" CI contract.
|
|
88
|
+
# Aggregates EB ecosystem dependencies and test tooling.
|
|
89
|
+
all = [
|
|
90
|
+
# EB ecosystem deps
|
|
91
|
+
"eb-metrics>=0.2,<0.3",
|
|
92
|
+
"eb-evaluation>=0.2,<0.3",
|
|
93
|
+
|
|
94
|
+
# test deps
|
|
95
|
+
"pytest>=8.0",
|
|
96
|
+
"scikit-learn>=1.3",
|
|
97
|
+
|
|
98
|
+
# dev tooling
|
|
99
|
+
"pytest-cov>=5.0",
|
|
100
|
+
"ruff>=0.0.1",
|
|
101
|
+
"pyright>=1.1",
|
|
102
|
+
"twine>=4.0",
|
|
86
103
|
]
|
|
87
104
|
|
|
88
105
|
######################################
|
|
@@ -103,4 +120,4 @@ where = ["src"]
|
|
|
103
120
|
######################################
|
|
104
121
|
[tool.pytest.ini_options]
|
|
105
122
|
pythonpath = ["src"]
|
|
106
|
-
addopts = "-ra"
|
|
123
|
+
addopts = "-ra"
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
"""
|
|
4
2
|
`eb_optimization` — optimization and tuning layer for the Electric Barometer ecosystem.
|
|
5
3
|
|
|
@@ -13,8 +11,18 @@ It intentionally does **not** define metric primitives or evaluation math.
|
|
|
13
11
|
Those live in `eb-metrics` (and orchestration lives in `eb-evaluation`).
|
|
14
12
|
"""
|
|
15
13
|
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
16
|
from importlib.metadata import PackageNotFoundError, version
|
|
17
17
|
|
|
18
|
+
from .policies.ral_policy import (
|
|
19
|
+
DEFAULT_RAL_POLICY,
|
|
20
|
+
RALDeltas,
|
|
21
|
+
RALTwoBandPolicy,
|
|
22
|
+
apply_ral_policy,
|
|
23
|
+
)
|
|
24
|
+
from .tuning.cost_ratio import EntityCostRatioEstimate
|
|
25
|
+
|
|
18
26
|
|
|
19
27
|
def _resolve_version() -> str:
|
|
20
28
|
"""
|
|
@@ -35,4 +43,11 @@ def _resolve_version() -> str:
|
|
|
35
43
|
|
|
36
44
|
__version__ = _resolve_version()
|
|
37
45
|
|
|
38
|
-
__all__ = [
|
|
46
|
+
__all__ = [
|
|
47
|
+
"DEFAULT_RAL_POLICY",
|
|
48
|
+
"EntityCostRatioEstimate",
|
|
49
|
+
"RALDeltas",
|
|
50
|
+
"RALTwoBandPolicy",
|
|
51
|
+
"__version__",
|
|
52
|
+
"apply_ral_policy",
|
|
53
|
+
]
|
|
@@ -4,9 +4,9 @@ import numpy as np
|
|
|
4
4
|
from numpy.typing import ArrayLike
|
|
5
5
|
|
|
6
6
|
__all__ = [
|
|
7
|
-
"to_1d_array",
|
|
8
7
|
"broadcast_param",
|
|
9
8
|
"handle_sample_weight",
|
|
9
|
+
"to_1d_array",
|
|
10
10
|
]
|
|
11
11
|
|
|
12
12
|
|
|
@@ -73,9 +73,7 @@ def broadcast_param(x: ArrayLike, shape: tuple[int, ...], name: str) -> np.ndarr
|
|
|
73
73
|
return np.full(shape, float(arr), dtype=float)
|
|
74
74
|
|
|
75
75
|
if arr.shape != shape:
|
|
76
|
-
raise ValueError(
|
|
77
|
-
f"{name} must be scalar or have shape {shape}; got shape {arr.shape}"
|
|
78
|
-
)
|
|
76
|
+
raise ValueError(f"{name} must be scalar or have shape {shape}; got shape {arr.shape}")
|
|
79
77
|
|
|
80
78
|
return arr
|
|
81
79
|
|
|
@@ -112,11 +110,9 @@ def handle_sample_weight(sample_weight: ArrayLike | None, n: int) -> np.ndarray:
|
|
|
112
110
|
w = np.asarray(sample_weight, dtype=float)
|
|
113
111
|
|
|
114
112
|
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
|
-
)
|
|
113
|
+
raise ValueError(f"sample_weight must be a 1D array of length {n}; got shape {w.shape}")
|
|
118
114
|
|
|
119
115
|
if np.any(w < 0):
|
|
120
116
|
raise ValueError("sample_weight must be non-negative.")
|
|
121
117
|
|
|
122
|
-
return w
|
|
118
|
+
return w
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
"""
|
|
4
2
|
Frozen policy artifacts for the Electric Barometer optimization layer.
|
|
5
3
|
|
|
@@ -25,52 +23,51 @@ Exported policies
|
|
|
25
23
|
- Tau (τ) tolerance governance for HR@τ
|
|
26
24
|
- Cost-ratio (R = c_u / c_o) governance for asymmetric loss
|
|
27
25
|
- RAL policy governance (readiness adjustment layer)
|
|
26
|
+
- DQC (Δ*) governance for packed / quantized demand snapping and evaluation
|
|
28
27
|
"""
|
|
29
28
|
|
|
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
|
-
)
|
|
29
|
+
from __future__ import annotations
|
|
39
30
|
|
|
40
|
-
# ---------------------------------------------------------------------
|
|
41
|
-
# Cost-ratio (R) policies
|
|
42
|
-
# ---------------------------------------------------------------------
|
|
43
31
|
from .cost_ratio_policy import (
|
|
44
|
-
CostRatioPolicy,
|
|
45
32
|
DEFAULT_COST_RATIO_POLICY,
|
|
33
|
+
CostRatioPolicy,
|
|
46
34
|
apply_cost_ratio_policy,
|
|
47
35
|
apply_entity_cost_ratio_policy,
|
|
48
36
|
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
37
|
+
from .dqc_policy import (
|
|
38
|
+
DEFAULT_DQC_POLICY,
|
|
39
|
+
DQCPolicy,
|
|
40
|
+
DQCResult,
|
|
41
|
+
compute_dqc,
|
|
42
|
+
enforce_snapping,
|
|
43
|
+
hr_at_tau_grid_units,
|
|
44
|
+
snap_to_grid,
|
|
45
|
+
)
|
|
46
|
+
from .ral_policy import DEFAULT_RAL_POLICY, RALPolicy, apply_ral_policy
|
|
47
|
+
from .tau_policy import (
|
|
48
|
+
TauPolicy,
|
|
49
|
+
apply_entity_tau_policy,
|
|
50
|
+
apply_tau_policy,
|
|
51
|
+
apply_tau_policy_hr,
|
|
57
52
|
)
|
|
58
53
|
|
|
59
54
|
__all__ = [
|
|
60
|
-
# Tau policies
|
|
61
|
-
"TauPolicy",
|
|
62
|
-
"apply_tau_policy",
|
|
63
|
-
"apply_tau_policy_hr",
|
|
64
|
-
"apply_entity_tau_policy",
|
|
65
|
-
|
|
66
|
-
# Cost ratio policies
|
|
67
|
-
"CostRatioPolicy",
|
|
68
55
|
"DEFAULT_COST_RATIO_POLICY",
|
|
56
|
+
"DEFAULT_DQC_POLICY",
|
|
57
|
+
"DEFAULT_RAL_POLICY",
|
|
58
|
+
"CostRatioPolicy",
|
|
59
|
+
"DQCPolicy",
|
|
60
|
+
"DQCResult",
|
|
61
|
+
"RALPolicy",
|
|
62
|
+
"TauPolicy",
|
|
69
63
|
"apply_cost_ratio_policy",
|
|
70
64
|
"apply_entity_cost_ratio_policy",
|
|
71
|
-
|
|
72
|
-
# RAL policies
|
|
73
|
-
"RALPolicy",
|
|
74
|
-
"DEFAULT_RAL_POLICY",
|
|
65
|
+
"apply_entity_tau_policy",
|
|
75
66
|
"apply_ral_policy",
|
|
76
|
-
|
|
67
|
+
"apply_tau_policy",
|
|
68
|
+
"apply_tau_policy_hr",
|
|
69
|
+
"compute_dqc",
|
|
70
|
+
"enforce_snapping",
|
|
71
|
+
"hr_at_tau_grid_units",
|
|
72
|
+
"snap_to_grid",
|
|
73
|
+
]
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cost-ratio (R = c_u / c_o) policy artifacts for eb-optimization.
|
|
3
|
+
|
|
4
|
+
This module defines *frozen governance* for selecting and applying a cost ratio `R`
|
|
5
|
+
(and derived underbuild cost `c_u`) used by asymmetric cost metrics like CWSL.
|
|
6
|
+
|
|
7
|
+
Layering & responsibilities
|
|
8
|
+
---------------------------
|
|
9
|
+
- `tuning/cost_ratio.py`:
|
|
10
|
+
Calibration logic (estimating R from residuals / cost balance).
|
|
11
|
+
- `policies/cost_ratio_policy.py`:
|
|
12
|
+
Frozen configuration + deterministic application wrappers.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Literal, cast
|
|
20
|
+
import warnings
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from numpy.typing import ArrayLike
|
|
24
|
+
import pandas as pd
|
|
25
|
+
|
|
26
|
+
from eb_optimization.tuning.cost_ratio import (
|
|
27
|
+
CostRatioEstimate,
|
|
28
|
+
EntityCostRatioEstimate,
|
|
29
|
+
estimate_entity_R_from_balance,
|
|
30
|
+
estimate_R_cost_balance,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
GateMode = Literal["off", "warn", "raise"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CostRatioPolicy:
|
|
38
|
+
"""
|
|
39
|
+
Frozen cost-ratio (R) policy configuration.
|
|
40
|
+
|
|
41
|
+
Attributes
|
|
42
|
+
----------
|
|
43
|
+
R_grid : Sequence[float]
|
|
44
|
+
Candidate ratios to search. Only strictly positive values are considered.
|
|
45
|
+
co : float
|
|
46
|
+
Default overbuild cost coefficient used for entity-level estimation.
|
|
47
|
+
min_n : int
|
|
48
|
+
Minimum number of observations required to estimate an entity-level R.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
R_grid: Sequence[float] = (0.5, 1.0, 2.0, 3.0)
|
|
52
|
+
co: float = 1.0
|
|
53
|
+
min_n: int = 30
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
grid = np.asarray(list(self.R_grid), dtype=float)
|
|
57
|
+
if grid.ndim != 1 or grid.size == 0:
|
|
58
|
+
raise ValueError("R_grid must be a non-empty 1D sequence of floats.")
|
|
59
|
+
if not np.any(grid > 0):
|
|
60
|
+
raise ValueError("R_grid must contain at least one strictly positive value.")
|
|
61
|
+
|
|
62
|
+
if not np.isfinite(self.co) or float(self.co) <= 0:
|
|
63
|
+
raise ValueError(f"co must be finite and strictly positive. Got {self.co}.")
|
|
64
|
+
|
|
65
|
+
if self.min_n < 1:
|
|
66
|
+
raise ValueError(f"min_n must be >= 1. Got {self.min_n}.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
DEFAULT_COST_RATIO_POLICY = CostRatioPolicy()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _handle_identifiability_gate(
|
|
73
|
+
*,
|
|
74
|
+
gate: GateMode,
|
|
75
|
+
ok: bool,
|
|
76
|
+
message: str,
|
|
77
|
+
override_reason: str | None,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Warn-only / gateable hook shared by global + entity policy applications.
|
|
81
|
+
|
|
82
|
+
Returns a JSON-serializable dict with the gating decision metadata that can be
|
|
83
|
+
merged into diagnostics payloads.
|
|
84
|
+
"""
|
|
85
|
+
gate = cast(GateMode, gate)
|
|
86
|
+
if gate not in ("off", "warn", "raise"):
|
|
87
|
+
raise ValueError("gate must be one of: 'off', 'warn', 'raise'")
|
|
88
|
+
|
|
89
|
+
meta: dict[str, Any] = {
|
|
90
|
+
"gate_mode": gate,
|
|
91
|
+
"gate_triggered": bool(not ok),
|
|
92
|
+
"gate_overridden": bool((not ok) and (override_reason is not None)),
|
|
93
|
+
"gate_override_reason": override_reason,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if ok or gate == "off":
|
|
97
|
+
return meta
|
|
98
|
+
|
|
99
|
+
# If not ok:
|
|
100
|
+
if override_reason is not None:
|
|
101
|
+
# Override: do not warn/raise; record reason.
|
|
102
|
+
return meta
|
|
103
|
+
|
|
104
|
+
if gate == "warn":
|
|
105
|
+
warnings.warn(message, RuntimeWarning, stacklevel=2)
|
|
106
|
+
return meta
|
|
107
|
+
|
|
108
|
+
# gate == "raise"
|
|
109
|
+
raise ValueError(message)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def apply_cost_ratio_policy(
|
|
113
|
+
y_true: ArrayLike,
|
|
114
|
+
y_pred: ArrayLike,
|
|
115
|
+
*,
|
|
116
|
+
policy: CostRatioPolicy = DEFAULT_COST_RATIO_POLICY,
|
|
117
|
+
co: float | ArrayLike | None = None,
|
|
118
|
+
sample_weight: ArrayLike | None = None,
|
|
119
|
+
gate: GateMode = "warn",
|
|
120
|
+
identifiability_override_reason: str | None = None,
|
|
121
|
+
) -> tuple[float, dict[str, Any]]:
|
|
122
|
+
"""
|
|
123
|
+
Apply a frozen cost-ratio policy to estimate a global R.
|
|
124
|
+
|
|
125
|
+
Notes
|
|
126
|
+
-----
|
|
127
|
+
This policy boundary surfaces identifiability / stability diagnostics when available.
|
|
128
|
+
It does NOT change the selection behavior; it only enriches the returned diagnostics.
|
|
129
|
+
|
|
130
|
+
Gating
|
|
131
|
+
------
|
|
132
|
+
`gate` controls what happens when tuning reports `is_identifiable=False`:
|
|
133
|
+
|
|
134
|
+
- gate="off" : no action (still reports diagnostics)
|
|
135
|
+
- gate="warn" : emit a RuntimeWarning (default)
|
|
136
|
+
- gate="raise": raise ValueError
|
|
137
|
+
|
|
138
|
+
Overrides
|
|
139
|
+
---------
|
|
140
|
+
If `identifiability_override_reason` is provided, the gate will not warn/raise,
|
|
141
|
+
and the reason is recorded in diagnostics for auditability.
|
|
142
|
+
"""
|
|
143
|
+
co_val = policy.co if co is None else co
|
|
144
|
+
|
|
145
|
+
# Request the richer artifact so we can surface identifiability at the policy boundary.
|
|
146
|
+
est_any: Any = estimate_R_cost_balance(
|
|
147
|
+
y_true=y_true,
|
|
148
|
+
y_pred=y_pred,
|
|
149
|
+
R_grid=policy.R_grid,
|
|
150
|
+
co=co_val,
|
|
151
|
+
sample_weight=sample_weight,
|
|
152
|
+
return_curve=True,
|
|
153
|
+
selection="curve",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
stability_diag: dict[str, Any]
|
|
157
|
+
gate_meta: dict[str, Any] = {}
|
|
158
|
+
|
|
159
|
+
if isinstance(est_any, CostRatioEstimate):
|
|
160
|
+
est = est_any
|
|
161
|
+
R = float(est.R_star)
|
|
162
|
+
|
|
163
|
+
ok = bool(est.is_identifiable)
|
|
164
|
+
msg = (
|
|
165
|
+
"Cost ratio calibration is not identifiable/stable under configured diagnostics. "
|
|
166
|
+
f"(rel_min_gap={float(est.rel_min_gap):.6g}, "
|
|
167
|
+
f"grid_instability_log={float(est.grid_instability_log):.6g}, "
|
|
168
|
+
f"R_star={float(est.R_star):.6g}, R_range=[{float(est.R_min):.6g}, {float(est.R_max):.6g}])."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
gate_meta = _handle_identifiability_gate(
|
|
172
|
+
gate=gate,
|
|
173
|
+
ok=ok,
|
|
174
|
+
message=msg,
|
|
175
|
+
override_reason=identifiability_override_reason,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
stability_diag = {
|
|
179
|
+
# Policy-surfaced stability fields (governance/reporting only)
|
|
180
|
+
"rel_min_gap": float(est.rel_min_gap),
|
|
181
|
+
"R_min": float(est.R_min),
|
|
182
|
+
"R_max": float(est.R_max),
|
|
183
|
+
"grid_instability_log": float(est.grid_instability_log),
|
|
184
|
+
"is_identifiable": bool(est.is_identifiable),
|
|
185
|
+
# Full tuning diagnostics
|
|
186
|
+
"calibration_diagnostics": dict(est.diagnostics),
|
|
187
|
+
}
|
|
188
|
+
else:
|
|
189
|
+
R = float(est_any)
|
|
190
|
+
stability_diag = {
|
|
191
|
+
"calibration_diagnostics": {
|
|
192
|
+
"note": "Upstream returned a scalar R; stability diagnostics unavailable.",
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
diag: dict[str, Any] = {
|
|
197
|
+
"method": "cost_balance",
|
|
198
|
+
"R_grid": [float(x) for x in policy.R_grid],
|
|
199
|
+
"co_is_array": isinstance(co_val, list | tuple | np.ndarray | pd.Series),
|
|
200
|
+
"co_default_used": co is None,
|
|
201
|
+
"R": float(R),
|
|
202
|
+
**stability_diag,
|
|
203
|
+
**({"identifiability_gate": gate_meta} if gate_meta else {}),
|
|
204
|
+
}
|
|
205
|
+
return (float(R), diag)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def apply_entity_cost_ratio_policy(
|
|
209
|
+
df: pd.DataFrame,
|
|
210
|
+
*,
|
|
211
|
+
entity_col: str,
|
|
212
|
+
y_true_col: str,
|
|
213
|
+
y_pred_col: str,
|
|
214
|
+
policy: CostRatioPolicy = DEFAULT_COST_RATIO_POLICY,
|
|
215
|
+
co: float | None = None,
|
|
216
|
+
sample_weight_col: str | None = None,
|
|
217
|
+
include_diagnostics: bool = True,
|
|
218
|
+
gate: GateMode = "warn",
|
|
219
|
+
identifiability_override_reason: str | None = None,
|
|
220
|
+
) -> pd.DataFrame:
|
|
221
|
+
"""
|
|
222
|
+
Apply a frozen cost-ratio policy per entity.
|
|
223
|
+
|
|
224
|
+
Notes
|
|
225
|
+
-----
|
|
226
|
+
This policy boundary surfaces per-entity calibration diagnostics (in the
|
|
227
|
+
`diagnostics` column) for eligible entities when `include_diagnostics=True`.
|
|
228
|
+
|
|
229
|
+
Entity-level identifiability
|
|
230
|
+
----------------------------
|
|
231
|
+
The tuning artifact returns per-entity `diagnostics` dicts. If those dicts contain
|
|
232
|
+
an `is_identifiable` field, this function will:
|
|
233
|
+
- surface a convenience `is_identifiable` column, and
|
|
234
|
+
- optionally warn/raise based on `gate`.
|
|
235
|
+
|
|
236
|
+
If no such field exists (older tuning versions), gating is a no-op.
|
|
237
|
+
"""
|
|
238
|
+
# ---- validation: columns ----
|
|
239
|
+
required_cols = {entity_col, y_true_col, y_pred_col}
|
|
240
|
+
if sample_weight_col is not None:
|
|
241
|
+
required_cols.add(sample_weight_col)
|
|
242
|
+
|
|
243
|
+
missing = [c for c in required_cols if c not in df.columns]
|
|
244
|
+
if missing:
|
|
245
|
+
raise KeyError(f"Missing required columns: {missing}")
|
|
246
|
+
|
|
247
|
+
co_val = float(policy.co if co is None else co)
|
|
248
|
+
if not np.isfinite(co_val) or co_val <= 0:
|
|
249
|
+
raise ValueError(f"co must be finite and strictly positive. Got {co_val}.")
|
|
250
|
+
|
|
251
|
+
# ---- governance: identify eligible entities ----
|
|
252
|
+
counts_ser = cast(pd.Series, df.groupby(entity_col, dropna=False, sort=False).size())
|
|
253
|
+
eligible_counts = cast(pd.Series, counts_ser[counts_ser >= policy.min_n])
|
|
254
|
+
eligible_list = cast(list[Any], eligible_counts.index.tolist())
|
|
255
|
+
|
|
256
|
+
mask = df[entity_col].isin(eligible_list)
|
|
257
|
+
|
|
258
|
+
eligible_df = cast(pd.DataFrame, df[mask]).copy()
|
|
259
|
+
ineligible_df = cast(pd.DataFrame, df[~mask]).copy()
|
|
260
|
+
|
|
261
|
+
results_list: list[pd.DataFrame] = []
|
|
262
|
+
|
|
263
|
+
gate_meta: dict[str, Any] = {}
|
|
264
|
+
failed_entities: list[Any] = []
|
|
265
|
+
|
|
266
|
+
# Track whether we can gate at all (only if the diagnostics dicts contain is_identifiable)
|
|
267
|
+
identifiability_available = False
|
|
268
|
+
|
|
269
|
+
if not eligible_df.empty:
|
|
270
|
+
tuned_any: Any = estimate_entity_R_from_balance(
|
|
271
|
+
df=eligible_df,
|
|
272
|
+
entity_col=entity_col,
|
|
273
|
+
y_true_col=y_true_col,
|
|
274
|
+
y_pred_col=y_pred_col,
|
|
275
|
+
ratios=policy.R_grid,
|
|
276
|
+
co=co_val,
|
|
277
|
+
sample_weight_col=sample_weight_col,
|
|
278
|
+
return_result=True,
|
|
279
|
+
selection="curve",
|
|
280
|
+
)
|
|
281
|
+
tuned_art = cast(EntityCostRatioEstimate, tuned_any)
|
|
282
|
+
|
|
283
|
+
tuned_table = cast(pd.DataFrame, tuned_art.table).copy()
|
|
284
|
+
|
|
285
|
+
tuned = pd.DataFrame(
|
|
286
|
+
{
|
|
287
|
+
entity_col: tuned_table[entity_col],
|
|
288
|
+
"R": tuned_table["R_star"].astype(float),
|
|
289
|
+
"cu": (tuned_table["R_star"].astype(float) * float(co_val)).astype(float),
|
|
290
|
+
"co": float(co_val),
|
|
291
|
+
"under_cost": tuned_table["under_cost"].astype(float),
|
|
292
|
+
"over_cost": tuned_table["over_cost"].astype(float),
|
|
293
|
+
"diff": tuned_table["gap"].astype(float),
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Always attach diagnostics internally (for gating), drop later if include_diagnostics=False
|
|
298
|
+
if "diagnostics" in tuned_table.columns:
|
|
299
|
+
tuned["diagnostics"] = tuned_table["diagnostics"]
|
|
300
|
+
else:
|
|
301
|
+
tuned["diagnostics"] = None
|
|
302
|
+
|
|
303
|
+
tuned["reason"] = None
|
|
304
|
+
|
|
305
|
+
mapper: Any = counts_ser
|
|
306
|
+
tuned["n"] = tuned[entity_col].map(mapper).astype(int)
|
|
307
|
+
|
|
308
|
+
# Surface an `is_identifiable` column if diagnostics provides it.
|
|
309
|
+
# This is a convenience field for users/tests and lets gating be explicit/inspectable.
|
|
310
|
+
def _extract_is_identifiable(v: Any) -> Any:
|
|
311
|
+
if isinstance(v, dict) and ("is_identifiable" in v):
|
|
312
|
+
return bool(v.get("is_identifiable"))
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
tuned["is_identifiable"] = tuned["diagnostics"].map(_extract_is_identifiable)
|
|
316
|
+
|
|
317
|
+
# ---- FIX: make the conditional unambiguously bool for Pyright ----
|
|
318
|
+
has_ident = bool(tuned["is_identifiable"].notna().to_numpy().any())
|
|
319
|
+
if has_ident:
|
|
320
|
+
identifiability_available = True
|
|
321
|
+
failed_mask = tuned["is_identifiable"] == False # noqa: E712
|
|
322
|
+
failed_entities = cast(list[Any], tuned.loc[failed_mask, entity_col].tolist())
|
|
323
|
+
|
|
324
|
+
if identifiability_available and failed_entities:
|
|
325
|
+
msg = (
|
|
326
|
+
"One or more entities have non-identifiable cost ratio calibration. "
|
|
327
|
+
f"Failed entities (first 10): {failed_entities[:10]!r}. "
|
|
328
|
+
"You may override with identifiability_override_reason."
|
|
329
|
+
)
|
|
330
|
+
gate_meta = _handle_identifiability_gate(
|
|
331
|
+
gate=gate,
|
|
332
|
+
ok=False,
|
|
333
|
+
message=msg,
|
|
334
|
+
override_reason=identifiability_override_reason,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
results_list.append(tuned)
|
|
338
|
+
|
|
339
|
+
if not ineligible_df.empty:
|
|
340
|
+
ineligible_rows = cast(pd.DataFrame, ineligible_df[[entity_col]]).drop_duplicates()
|
|
341
|
+
ineligible_rows = ineligible_rows.assign(
|
|
342
|
+
R=np.nan,
|
|
343
|
+
cu=np.nan,
|
|
344
|
+
co=co_val,
|
|
345
|
+
under_cost=np.nan,
|
|
346
|
+
over_cost=np.nan,
|
|
347
|
+
diff=np.nan,
|
|
348
|
+
reason=f"min_n_not_met(<{policy.min_n})",
|
|
349
|
+
diagnostics=None,
|
|
350
|
+
is_identifiable=None,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
mapper_ineligible: Any = counts_ser
|
|
354
|
+
ineligible_rows["n"] = ineligible_rows[entity_col].map(mapper_ineligible).astype(int)
|
|
355
|
+
results_list.append(ineligible_rows)
|
|
356
|
+
|
|
357
|
+
if not results_list:
|
|
358
|
+
schema_cols = [
|
|
359
|
+
entity_col,
|
|
360
|
+
"R",
|
|
361
|
+
"cu",
|
|
362
|
+
"co",
|
|
363
|
+
"n",
|
|
364
|
+
"reason",
|
|
365
|
+
"under_cost",
|
|
366
|
+
"over_cost",
|
|
367
|
+
"diff",
|
|
368
|
+
]
|
|
369
|
+
if include_diagnostics:
|
|
370
|
+
schema_cols.append("diagnostics")
|
|
371
|
+
# Include is_identifiable if we can ever surface it (safe default: include it anyway)
|
|
372
|
+
schema_cols.append("is_identifiable")
|
|
373
|
+
if gate_meta:
|
|
374
|
+
schema_cols.append("identifiability_gate")
|
|
375
|
+
|
|
376
|
+
return pd.DataFrame(columns=pd.Index(schema_cols))
|
|
377
|
+
|
|
378
|
+
out = pd.concat(results_list, ignore_index=True, sort=False)
|
|
379
|
+
|
|
380
|
+
# Attach gate metadata as a repeated column (JSON-serializable) if it exists
|
|
381
|
+
if gate_meta:
|
|
382
|
+
out["identifiability_gate"] = [gate_meta] * int(out.shape[0])
|
|
383
|
+
|
|
384
|
+
base_cols = [entity_col, "R", "cu", "co", "n", "reason", "is_identifiable"]
|
|
385
|
+
diag_cols = ["under_cost", "over_cost", "diff"]
|
|
386
|
+
|
|
387
|
+
if include_diagnostics:
|
|
388
|
+
base_cols.append("diagnostics")
|
|
389
|
+
|
|
390
|
+
if not include_diagnostics and "diagnostics" in out.columns:
|
|
391
|
+
out = out.drop(columns=["diagnostics"])
|
|
392
|
+
|
|
393
|
+
if "identifiability_gate" in out.columns:
|
|
394
|
+
base_cols.append("identifiability_gate")
|
|
395
|
+
|
|
396
|
+
remaining = [str(c) for c in out.columns if c not in base_cols + diag_cols]
|
|
397
|
+
target_cols = (
|
|
398
|
+
(base_cols + diag_cols + remaining) if include_diagnostics else (base_cols + remaining)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return cast(pd.DataFrame, out[pd.Index(target_cols)])
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
__all__ = [
|
|
405
|
+
"DEFAULT_COST_RATIO_POLICY",
|
|
406
|
+
"CostRatioPolicy",
|
|
407
|
+
"apply_cost_ratio_policy",
|
|
408
|
+
"apply_entity_cost_ratio_policy",
|
|
409
|
+
]
|