eb-optimization 0.2.4__tar.gz → 0.2.5__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.
Files changed (28) hide show
  1. {eb_optimization-0.2.4/src/eb_optimization.egg-info → eb_optimization-0.2.5}/PKG-INFO +2 -1
  2. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/pyproject.toml +4 -4
  3. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/policies/__init__.py +25 -3
  4. eb_optimization-0.2.5/src/eb_optimization/policies/dqc_policy.py +402 -0
  5. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/policies/evaluation.py +59 -19
  6. {eb_optimization-0.2.4 → eb_optimization-0.2.5/src/eb_optimization.egg-info}/PKG-INFO +2 -1
  7. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization.egg-info/requires.txt +1 -0
  8. eb_optimization-0.2.4/src/eb_optimization/policies/dqc_policy.py +0 -331
  9. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/LICENSE +0 -0
  10. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/README.md +0 -0
  11. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/setup.cfg +0 -0
  12. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/__init__.py +0 -0
  13. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/_utils.py +0 -0
  14. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/policies/cost_ratio_policy.py +0 -0
  15. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/policies/ral_policy.py +0 -0
  16. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/policies/tau_policy.py +0 -0
  17. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/search/__init__.py +0 -0
  18. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/search/grid.py +0 -0
  19. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/search/kernels.py +0 -0
  20. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/tuning/__init__.py +0 -0
  21. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/tuning/cost_ratio.py +0 -0
  22. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/tuning/ral.py +0 -0
  23. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/tuning/sensitivity.py +0 -0
  24. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization/tuning/tau.py +0 -0
  25. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization.egg-info/SOURCES.txt +0 -0
  26. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization.egg-info/dependency_links.txt +0 -0
  27. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/src/eb_optimization.egg-info/top_level.txt +0 -0
  28. {eb_optimization-0.2.4 → eb_optimization-0.2.5}/tests/test_public_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eb-optimization
3
- Version: 0.2.4
3
+ Version: 0.2.5
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
@@ -21,6 +21,7 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: numpy>=1.24
23
23
  Requires-Dist: pandas>=2.0
24
+ Requires-Dist: eb-metrics<0.3,>=0.2
24
25
  Provides-Extra: eb
25
26
  Requires-Dist: eb-metrics<0.3,>=0.2; extra == "eb"
26
27
  Requires-Dist: eb-evaluation<0.3,>=0.2; extra == "eb"
@@ -3,7 +3,7 @@
3
3
  ######################################
4
4
  [project]
5
5
  name = "eb-optimization"
6
- version = "0.2.4"
6
+ version = "0.2.5"
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"
@@ -38,11 +38,10 @@ classifiers = [
38
38
  ######################################
39
39
  # Core runtime dependencies
40
40
  ######################################
41
- # Keep this intentionally lean. eb-optimization should be installable even
42
- # without pulling the rest of the EB stack unless users opt-in via extras.
43
41
  dependencies = [
44
42
  "numpy>=1.24",
45
43
  "pandas>=2.0",
44
+ "eb-metrics>=0.2,<0.3",
46
45
  ]
47
46
 
48
47
  ######################################
@@ -59,7 +58,8 @@ Documentation = "https://github.com/Economistician/eb-docs"
59
58
  ######################################
60
59
  [project.optional-dependencies]
61
60
 
62
- # Pull in EB ecosystem deps when tuning objectives/policies that rely on them
61
+ # EB ecosystem deps (metrics primitives, diagnostics contracts)
62
+ # Use this when you want DQC diagnostics from eb-evaluation and HR@τ primitive from eb-metrics.
63
63
  eb = [
64
64
  # Electric Barometer compatibility band (0.2.x)
65
65
  "eb-metrics>=0.2,<0.3",
@@ -23,8 +23,18 @@ Exported policies
23
23
  - Tau (τ) tolerance governance for HR@τ
24
24
  - Cost-ratio (R = c_u / c_o) governance for asymmetric loss
25
25
  - RAL policy governance (readiness adjustment layer)
26
- - DQC (Δ*) governance for packed / quantized demand snapping and evaluation
27
- - Policy-composed evaluation entrypoints (DQC-aware evaluation wrappers)
26
+ - DQC (Δ*) enforcement for packed / quantized demand snapping and evaluation
27
+
28
+ DQC note
29
+ --------
30
+ DQC *detection/classification* lives in `eb-evaluation` diagnostics.
31
+ Preferred usage:
32
+ - run `eb_evaluation.diagnostics.validate_dqc(y=...)`
33
+ - pass that DQCResult into `enforce_snapping` / `hr_at_tau_grid_units`
34
+ (or policy-composed `evaluate_with_dqc_hr`)
35
+
36
+ The `compute_dqc` helper exported here is kept for backwards compatibility and
37
+ delegates to eb-evaluation when available.
28
38
  """
29
39
 
30
40
  from __future__ import annotations
@@ -45,8 +55,16 @@ from .dqc_policy import (
45
55
  snap_to_grid,
46
56
  )
47
57
  from .evaluation import DQCEvaluation, evaluate_with_dqc_hr
48
- from .ral_policy import DEFAULT_RAL_POLICY, RALPolicy, apply_ral_policy
58
+ from .ral_policy import (
59
+ DEFAULT_RAL_POLICY,
60
+ RALBands,
61
+ RALBandThresholds,
62
+ RALPolicy,
63
+ RALThresholdTwoBandPolicy,
64
+ apply_ral_policy,
65
+ )
49
66
  from .tau_policy import (
67
+ DEFAULT_TAU_POLICY,
50
68
  TauPolicy,
51
69
  apply_entity_tau_policy,
52
70
  apply_tau_policy,
@@ -57,11 +75,15 @@ __all__ = [
57
75
  "DEFAULT_COST_RATIO_POLICY",
58
76
  "DEFAULT_DQC_POLICY",
59
77
  "DEFAULT_RAL_POLICY",
78
+ "DEFAULT_TAU_POLICY",
60
79
  "CostRatioPolicy",
61
80
  "DQCEvaluation",
62
81
  "DQCPolicy",
63
82
  "DQCResult",
83
+ "RALBandThresholds",
84
+ "RALBands",
64
85
  "RALPolicy",
86
+ "RALThresholdTwoBandPolicy",
65
87
  "TauPolicy",
66
88
  "apply_cost_ratio_policy",
67
89
  "apply_entity_cost_ratio_policy",
@@ -0,0 +1,402 @@
1
+ """Demand Quantization Compatibility (DQC) policy and snapping enforcement.
2
+
3
+ This module defines the governance logic required to correctly evaluate forecasts
4
+ when realized demand is quantized or unit-packed.
5
+
6
+ Important
7
+ ---------
8
+ In EB, DQC itself is a *diagnostic* (classification + signals) and lives in
9
+ `eb-evaluation`. This module is *policy*: it consumes a DQC result and enforces
10
+ unit-compatibility (snapping) and grid-unit tolerance interpretation.
11
+
12
+ Key responsibilities:
13
+ - Enforce unit compatibility by snapping forecasts to the DQC-inferred grid.
14
+ - Interpret evaluation tolerances (τ) in grid units rather than raw numeric units.
15
+ - Provide a small set of enforcement modes suitable for governance/policy layers.
16
+
17
+ This module does not define forecasting models or metric primitives. Instead, it
18
+ provides policy-level wrappers that ensure evaluation metrics operate in a valid
19
+ unit space. When demand is packed or quantized, unsnapped evaluation can be made
20
+ invalid; policy decides whether to correct (snap), reject (raise), or ignore.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Sequence
26
+ from dataclasses import dataclass
27
+ from typing import Any, Literal
28
+
29
+ import numpy as np
30
+
31
+ try:
32
+ # eb-optimization does not define metric primitives; it delegates to eb-metrics.
33
+ from eb_metrics.metrics.service import hr_at_tau as _hr_at_tau
34
+ except Exception: # pragma: no cover
35
+ _hr_at_tau = None
36
+
37
+ # Optional dependency on eb-evaluation (preferred DQC source)
38
+ try: # pragma: no cover - import guard
39
+ from eb_evaluation.diagnostics.dqc import classify_dqc as _classify_dqc
40
+ except Exception: # pragma: no cover - import guard
41
+ _classify_dqc = None
42
+
43
+
44
+ DQCClass = Literal["CONTINUOUS", "QUANTIZED", "PACKED"]
45
+ SnapMode = Literal["nearest", "floor", "ceil"]
46
+ EnforcementMode = Literal["snap", "raise", "ignore"]
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class DQCPolicy:
51
+ """Legacy DQCPolicy container.
52
+
53
+ This policy previously owned DQC *detection* thresholds. DQC detection now lives
54
+ in `eb-evaluation` (diagnostics). This object remains for backwards compatibility
55
+ with callers that may still pass `policy=` into `compute_dqc`.
56
+
57
+ New code should:
58
+ - run DQC in `eb-evaluation` (e.g., `validate_dqc(y=...)`)
59
+ - pass the resulting DQCResult into `enforce_snapping` / `hr_at_tau_grid_units`
60
+ """
61
+
62
+ # Minimum positive support required to consider a DQC result meaningful.
63
+ min_n_pos: int = 50
64
+
65
+
66
+ DEFAULT_DQC_POLICY = DQCPolicy()
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class DQCResult:
71
+ """Output of DQC computation (policy-facing summary).
72
+
73
+ Notes
74
+ -----
75
+ - `delta_star` is the inferred grid (Δ*) in y-units.
76
+ - This is a lightweight summary shape used by eb-optimization policy code.
77
+ - When using eb-evaluation DQC, `delta_star` maps to `signals.granularity`.
78
+ """
79
+
80
+ dqc_class: DQCClass
81
+ delta_star: float | None
82
+ rho_star: float | None
83
+
84
+ # Evidence / diagnostics
85
+ n_pos: int
86
+ support_size: int
87
+ offgrid_mad_over_delta: float | None
88
+
89
+
90
+ def snap_to_grid(
91
+ x: np.ndarray,
92
+ delta: float,
93
+ *,
94
+ mode: SnapMode = "nearest",
95
+ nonneg: bool = True,
96
+ ) -> np.ndarray:
97
+ """Project values onto multiples of delta.
98
+
99
+ Args:
100
+ x: Array of values to snap (may include NaNs).
101
+ delta: Grid size (Δ). Must be > 0.
102
+ mode: Nearest, floor, or ceil snapping.
103
+ nonneg: If True, clamps to >= 0 after snapping.
104
+
105
+ Returns:
106
+ Snapped array (float dtype), preserving NaNs.
107
+ """
108
+ if not (isinstance(delta, int | float) and delta > 0.0):
109
+ raise ValueError(f"delta must be > 0; got {delta!r}")
110
+
111
+ x = np.asarray(x, dtype=float)
112
+ out = np.full_like(x, np.nan, dtype=float)
113
+
114
+ m = np.isfinite(x)
115
+ if not np.any(m):
116
+ return out
117
+
118
+ q = x[m] / delta
119
+
120
+ if mode == "nearest":
121
+ snapped = np.round(q) * delta
122
+ elif mode == "floor":
123
+ snapped = np.floor(q) * delta
124
+ elif mode == "ceil":
125
+ snapped = np.ceil(q) * delta
126
+ else:
127
+ raise ValueError(f"Unsupported mode: {mode!r}")
128
+
129
+ if nonneg:
130
+ snapped = np.maximum(snapped, 0.0)
131
+
132
+ out[m] = snapped
133
+ return out
134
+
135
+
136
+ def _type_label(obj: Any) -> str:
137
+ try:
138
+ return f"{obj.__class__.__module__}.{obj.__class__.__name__}"
139
+ except Exception: # pragma: no cover
140
+ return str(type(obj))
141
+
142
+
143
+ def _get_eval_granularity(dqc: Any) -> float | None:
144
+ """Extract Δ* (granularity) from an eb-evaluation DQCResult-like object."""
145
+ try:
146
+ signals = dqc.signals
147
+ g = signals.granularity
148
+ if g is None:
149
+ return None
150
+ fg = float(g)
151
+ return fg if fg > 0 else None
152
+ except Exception:
153
+ return None
154
+
155
+
156
+ def _get_eval_class_value(dqc: Any) -> str:
157
+ """Return a normalized eb-evaluation DQC class value (lowercase) if possible."""
158
+ try:
159
+ cls = dqc.dqc_class
160
+ except Exception:
161
+ return ""
162
+
163
+ # Enum-like (preferred)
164
+ if hasattr(cls, "value"):
165
+ try:
166
+ return str(cls.value).lower()
167
+ except Exception:
168
+ return ""
169
+
170
+ # Fallback stringification
171
+ try:
172
+ return str(cls).lower()
173
+ except Exception:
174
+ return ""
175
+
176
+
177
+ def _map_eval_dqc_to_policy_dqc(dqc: Any) -> DQCResult:
178
+ """Map eb-evaluation DQCResult -> eb-optimization DQCResult summary."""
179
+ granularity = _get_eval_granularity(dqc)
180
+
181
+ # Class mapping (eb-evaluation -> policy)
182
+ dqc_class: DQCClass = "CONTINUOUS"
183
+ cls_val = _get_eval_class_value(dqc)
184
+
185
+ if cls_val in ("quantized",):
186
+ dqc_class = "QUANTIZED"
187
+ elif cls_val in ("piecewise_packed",):
188
+ dqc_class = "PACKED"
189
+ elif cls_val in ("continuous_like", "unknown", ""):
190
+ dqc_class = "CONTINUOUS"
191
+ else:
192
+ # Last resort heuristics
193
+ if "quant" in cls_val:
194
+ dqc_class = "QUANTIZED"
195
+ elif "pack" in cls_val:
196
+ dqc_class = "PACKED"
197
+ else:
198
+ dqc_class = "CONTINUOUS"
199
+
200
+ rho_star: float | None = None
201
+ support_size: int = 0
202
+ offgrid_mad_over_delta: float | None = None
203
+
204
+ try:
205
+ signals = dqc.signals
206
+ rho_star = float(signals.multiple_rate)
207
+ support_size = int(signals.support_size)
208
+ offgrid_mad = float(signals.offgrid_mad)
209
+ if granularity is not None and granularity > 0:
210
+ offgrid_mad_over_delta = offgrid_mad / granularity
211
+ except Exception:
212
+ pass
213
+
214
+ # `n_pos` is not carried explicitly by eb-evaluation DQC; callers can compute it
215
+ # from their realized series if needed. We set it to 0 here and optionally fill
216
+ # it in compute_dqc().
217
+ return DQCResult(
218
+ dqc_class=dqc_class,
219
+ delta_star=granularity,
220
+ rho_star=rho_star,
221
+ n_pos=0,
222
+ support_size=support_size,
223
+ offgrid_mad_over_delta=offgrid_mad_over_delta,
224
+ )
225
+
226
+
227
+ def compute_dqc(
228
+ y: Any,
229
+ *,
230
+ policy: DQCPolicy = DEFAULT_DQC_POLICY,
231
+ use_positive_only: bool = True,
232
+ ) -> DQCResult:
233
+ """Compute DQC over a realized demand series.
234
+
235
+ Preferred behavior:
236
+ - If `eb-evaluation` is available, this delegates to its DQC diagnostic and
237
+ maps the result into this module's lightweight DQCResult shape.
238
+
239
+ Backwards compatibility:
240
+ - This function remains so existing eb-optimization call sites keep working.
241
+ - New code should call `eb_evaluation.diagnostics.validate_dqc(y=...)` directly
242
+ and pass that DQCResult into policy functions here.
243
+
244
+ Args:
245
+ y: Realized demand sequence.
246
+ policy: Legacy DQCPolicy (only min_n_pos is used here as a conservative guard).
247
+ use_positive_only: If True, only y>0 are used to compute `n_pos` for the guard.
248
+
249
+ Returns:
250
+ DQCResult summary.
251
+ """
252
+ # Compute n_pos for the legacy "insufficient signal" guard.
253
+ y_arr = np.asarray(y, dtype=float)
254
+ y_arr = y_arr[np.isfinite(y_arr)]
255
+ y_pos = y_arr[y_arr > 0.0] if use_positive_only else y_arr
256
+
257
+ n_pos = int(y_pos.size)
258
+ support_size = int(np.unique(np.round(y_pos, 6)).size) if n_pos else 0
259
+
260
+ if n_pos < policy.min_n_pos:
261
+ return DQCResult(
262
+ dqc_class="CONTINUOUS",
263
+ delta_star=None,
264
+ rho_star=None,
265
+ n_pos=n_pos,
266
+ support_size=support_size,
267
+ offgrid_mad_over_delta=None,
268
+ )
269
+
270
+ if _classify_dqc is None:
271
+ raise ImportError(
272
+ "DQC diagnostics are not available. Install/enable `eb-evaluation` to compute DQC, "
273
+ "or run DQC in the evaluation layer and pass the result into policy enforcement."
274
+ )
275
+
276
+ # Delegate to eb-evaluation (diagnostic)
277
+ eval_result = _classify_dqc(y=y_pos.tolist(), thresholds=None)
278
+ mapped = _map_eval_dqc_to_policy_dqc(eval_result)
279
+
280
+ # Fill in n_pos from the series guard (more informative than 0)
281
+ return DQCResult(
282
+ dqc_class=mapped.dqc_class,
283
+ delta_star=mapped.delta_star,
284
+ rho_star=mapped.rho_star,
285
+ n_pos=n_pos,
286
+ support_size=mapped.support_size,
287
+ offgrid_mad_over_delta=mapped.offgrid_mad_over_delta,
288
+ )
289
+
290
+
291
+ def _resolve_policy_class_and_delta(dqc: Any) -> tuple[DQCClass, float | None]:
292
+ """
293
+ Resolve a DQCResult-like input into a policy class and Δ*.
294
+
295
+ Supports:
296
+ - this module's DQCResult (dqc_class, delta_star)
297
+ - eb-evaluation DQCResult (dqc_class.value, signals.granularity)
298
+ """
299
+ if isinstance(dqc, DQCResult):
300
+ return dqc.dqc_class, dqc.delta_star
301
+
302
+ delta = _get_eval_granularity(dqc)
303
+ cls_val = _get_eval_class_value(dqc)
304
+
305
+ # Treat eb-evaluation UNKNOWN as CONTINUOUS for enforcement (no snapping by default).
306
+ if cls_val == "quantized":
307
+ return "QUANTIZED", delta
308
+ if cls_val == "piecewise_packed":
309
+ return "PACKED", delta
310
+ return "CONTINUOUS", delta
311
+
312
+
313
+ def enforce_snapping(
314
+ y_hat: Sequence[float] | np.ndarray,
315
+ *,
316
+ dqc: Any,
317
+ enforce: EnforcementMode = "snap",
318
+ mode: SnapMode = "nearest",
319
+ tol: float = 1e-6,
320
+ ) -> np.ndarray:
321
+ """Apply DQC snapping enforcement to forecasts.
322
+
323
+ Policy intent:
324
+ - PACKED / QUANTIZED demand => snapping is required (unit compatibility).
325
+ - CONTINUOUS-like demand => no snapping.
326
+ - UNKNOWN => no snapping unless a granularity is available (in which case
327
+ snapping is applied, since Δ* exists).
328
+
329
+ Args:
330
+ y_hat: Forecast values.
331
+ dqc: DQCResult-like object (preferred: eb-evaluation DQCResult) OR this module's DQCResult.
332
+ enforce: "snap" (default), "raise" (error if off-grid), "ignore".
333
+ mode: Snapping mode (if enforce == "snap").
334
+ tol: Absolute tolerance for off-grid checks (used when enforce == "raise").
335
+
336
+ Returns:
337
+ Forecast array, snapped or unchanged depending on class and enforcement.
338
+ """
339
+ y_hat_arr = np.asarray(y_hat, dtype=float)
340
+
341
+ dqc_class, delta = _resolve_policy_class_and_delta(dqc)
342
+
343
+ if dqc_class == "CONTINUOUS" or delta is None:
344
+ return y_hat_arr
345
+
346
+ if enforce == "ignore":
347
+ return y_hat_arr
348
+
349
+ if enforce == "raise":
350
+ snapped = snap_to_grid(y_hat_arr, float(delta), mode="nearest", nonneg=True)
351
+ offgrid = np.isfinite(y_hat_arr) & (np.abs(y_hat_arr - snapped) > tol)
352
+ if bool(np.any(offgrid)):
353
+ raise ValueError(
354
+ "Forecast contains off-grid values under PACKED/QUANTIZED DQC policy. "
355
+ "Either snap forecasts before evaluation or use enforce='snap'."
356
+ )
357
+ return y_hat_arr
358
+
359
+ if enforce == "snap":
360
+ return snap_to_grid(y_hat_arr, float(delta), mode=mode, nonneg=True)
361
+
362
+ raise ValueError(f"Unsupported enforce mode: {enforce!r}")
363
+
364
+
365
+ def hr_at_tau_grid_units(
366
+ y_true: Sequence[float] | np.ndarray,
367
+ y_hat: Sequence[float] | np.ndarray,
368
+ *,
369
+ dqc: Any,
370
+ tau_units: float,
371
+ enforce: EnforcementMode = "snap",
372
+ snap_mode: SnapMode = "nearest",
373
+ ) -> float:
374
+ """Compute HR@τ where τ is measured in grid units (Δ*).
375
+
376
+ For PACKED / QUANTIZED demand:
377
+ - Forecasts are snapped per enforcement policy (default: snap).
378
+ - Error is evaluated in grid units: |y - yhat| / Δ* <= τ_units.
379
+ - We convert tau_units to y-units via tau = tau_units * Δ*.
380
+
381
+ For CONTINUOUS-like demand:
382
+ - tau_units is interpreted as y-units directly (caller responsibility).
383
+
384
+ Delegates to eb-metrics `hr_at_tau` after converting τ into y-units.
385
+ """
386
+ if _hr_at_tau is None: # pragma: no cover
387
+ raise ImportError(
388
+ "eb-metrics is required to compute HR@τ (missing eb_metrics.metrics.service.hr_at_tau)."
389
+ )
390
+
391
+ y_true_arr = np.asarray(y_true, dtype=float)
392
+ y_hat_arr = np.asarray(y_hat, dtype=float)
393
+
394
+ dqc_class, delta = _resolve_policy_class_and_delta(dqc)
395
+
396
+ if dqc_class in ("PACKED", "QUANTIZED") and delta is not None:
397
+ y_hat_arr = enforce_snapping(y_hat_arr, dqc=dqc, enforce=enforce, mode=snap_mode)
398
+ tau = float(tau_units) * float(delta)
399
+ else:
400
+ tau = float(tau_units)
401
+
402
+ return float(_hr_at_tau(y_true_arr, y_hat_arr, tau=tau))
@@ -12,12 +12,20 @@ Primary responsibilities:
12
12
 
13
13
  This module is intentionally small. It does not implement forecasting models,
14
14
  data adapters, or tuning routines.
15
+
16
+ DQC note
17
+ --------
18
+ DQC *detection/classification* lives in `eb-evaluation` diagnostics.
19
+ This module consumes a DQCResult-like object (either eb-evaluation or the
20
+ eb-optimization summary shape) and applies enforcement + unit semantics via
21
+ `eb_optimization.policies.dqc_policy`.
15
22
  """
16
23
 
17
24
  from __future__ import annotations
18
25
 
19
26
  from collections.abc import Sequence
20
27
  from dataclasses import dataclass
28
+ from typing import Any
21
29
 
22
30
  import numpy as np
23
31
 
@@ -28,7 +36,7 @@ from eb_optimization.policies.dqc_policy import (
28
36
  EnforcementMode,
29
37
  SnapMode,
30
38
  compute_dqc,
31
- enforce_snapping,
39
+ hr_at_tau_grid_units,
32
40
  )
33
41
 
34
42
  try:
@@ -41,7 +49,7 @@ except Exception: # pragma: no cover
41
49
  class DQCEvaluation:
42
50
  """Result container for evaluations performed under DQC governance."""
43
51
 
44
- dqc: DQCResult
52
+ dqc: Any
45
53
  tau_units: float
46
54
  tau_y_units: float
47
55
  hr_at_tau: float
@@ -52,7 +60,7 @@ def evaluate_with_dqc_hr(
52
60
  y_hat: Sequence[float] | np.ndarray,
53
61
  *,
54
62
  tau_units: float,
55
- dqc: DQCResult | None = None,
63
+ dqc: Any | None = None,
56
64
  y_for_dqc: Sequence[float] | np.ndarray | None = None,
57
65
  policy: DQCPolicy = DEFAULT_DQC_POLICY,
58
66
  enforce: EnforcementMode = "snap",
@@ -62,29 +70,39 @@ def evaluate_with_dqc_hr(
62
70
  """Evaluate HR@τ under DQC governance with τ expressed in grid units.
63
71
 
64
72
  This entrypoint enforces Demand Quantization Compatibility (DQC) rules:
65
- - If demand is PACKED or QUANTIZED (per DQCResult), forecasts are snapped
66
- to the governing grid size Δ* (subject to enforcement mode).
67
- - Tolerance τ is interpreted in grid units. The numeric τ used for the
68
- metric primitive is τ_y_units = tau_units * Δ*.
73
+ - If demand is PACKED/QUANTIZED (policy taxonomy) or PIECEWISE_PACKED/QUANTIZED
74
+ (eb-evaluation taxonomy), forecasts are snapped to the governing grid size Δ*
75
+ (subject to enforcement mode).
76
+ - Tolerance τ is interpreted in grid units; we convert to y-units internally.
69
77
 
70
- Callers may supply a precomputed DQCResult (recommended in production), or
78
+ Callers may supply a precomputed DQC result (recommended in production), or
71
79
  allow this function to compute DQC from realized demand via `y_for_dqc` (or
72
80
  `y_true` if `y_for_dqc` is not provided).
73
81
 
82
+ Notes
83
+ -----
84
+ This function intentionally supports *duck-typed* DQC results so that an
85
+ upstream diagnostic layer (e.g., eb-evaluation) can supply DQC without
86
+ forcing eb-optimization to hard-depend on it.
87
+
74
88
  Args:
75
89
  y_true: Realized demand values.
76
90
  y_hat: Forecast demand values.
77
91
  tau_units: Tolerance in grid units (multiples of Δ*).
78
- dqc: Optional precomputed DQCResult. If provided, DQC is not recomputed.
92
+ dqc: Optional precomputed DQC result. If provided, DQC is not recomputed.
93
+ May be:
94
+ - `eb_optimization.policies.dqc_policy.DQCResult` (summary shape), or
95
+ - `eb_evaluation.diagnostics.dqc.DQCResult` (diagnostic shape).
79
96
  y_for_dqc: Optional series to compute DQC from (e.g., entity-level
80
97
  historical realized demand). Ignored if `dqc` is provided.
81
- policy: DQCPolicy thresholds and candidate grids used if computing DQC.
98
+ policy: Legacy DQCPolicy (used only when computing DQC locally for
99
+ backwards compatibility).
82
100
  enforce: Snapping enforcement behavior ("snap", "raise", "ignore").
83
101
  snap_mode: Snapping mode applied when enforce == "snap".
84
102
  use_positive_only_for_dqc: If True, DQC detection uses y>0 values.
85
103
 
86
104
  Returns:
87
- DQCEvaluation including the DQCResult, τ in grid units, τ in y-units,
105
+ DQCEvaluation including the DQC result, τ in grid units, τ in y-units,
88
106
  and HR@τ.
89
107
 
90
108
  Raises:
@@ -101,18 +119,40 @@ def evaluate_with_dqc_hr(
101
119
  y_src = y_true_arr if y_for_dqc is None else np.asarray(y_for_dqc, dtype=float)
102
120
  dqc = compute_dqc(y_src, policy=policy, use_positive_only=use_positive_only_for_dqc)
103
121
 
104
- # Default: treat tau_units as y-units unless DQC indicates a grid.
105
- tau_y_units = float(tau_units)
106
-
107
- if dqc.dqc_class in ("PACKED", "QUANTIZED") and dqc.delta_star is not None:
108
- y_hat_arr = enforce_snapping(y_hat_arr, dqc=dqc, enforce=enforce, mode=snap_mode)
109
- tau_y_units = float(tau_units) * float(dqc.delta_star)
122
+ # Compute HR via the policy helper (handles snapping + tau scaling).
123
+ hr = hr_at_tau_grid_units(
124
+ y_true_arr,
125
+ y_hat_arr,
126
+ dqc=dqc,
127
+ tau_units=float(tau_units),
128
+ enforce=enforce,
129
+ snap_mode=snap_mode,
130
+ )
110
131
 
111
- hr = float(_hr_at_tau(y_true_arr, y_hat_arr, tau=tau_y_units))
132
+ # Derive tau_y_units for reporting:
133
+ # - if `dqc` is our summary type, delta_star is explicit
134
+ # - otherwise, try to read eb-evaluation signals.granularity
135
+ tau_y_units = float(tau_units)
136
+ delta_star: float | None = None
137
+
138
+ if isinstance(dqc, DQCResult):
139
+ delta_star = dqc.delta_star
140
+ else:
141
+ try:
142
+ signals = dqc.signals
143
+ g = signals.granularity
144
+ delta_star = float(g) if g is not None else None
145
+ except Exception:
146
+ delta_star = None
147
+
148
+ if delta_star is not None and delta_star > 0:
149
+ # Only scale when the diagnostic implies a meaningful grid; the policy helper
150
+ # already handled correctness — this is just for reporting.
151
+ tau_y_units = float(tau_units) * float(delta_star)
112
152
 
113
153
  return DQCEvaluation(
114
154
  dqc=dqc,
115
155
  tau_units=float(tau_units),
116
156
  tau_y_units=tau_y_units,
117
- hr_at_tau=hr,
157
+ hr_at_tau=float(hr),
118
158
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eb-optimization
3
- Version: 0.2.4
3
+ Version: 0.2.5
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
@@ -21,6 +21,7 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: numpy>=1.24
23
23
  Requires-Dist: pandas>=2.0
24
+ Requires-Dist: eb-metrics<0.3,>=0.2
24
25
  Provides-Extra: eb
25
26
  Requires-Dist: eb-metrics<0.3,>=0.2; extra == "eb"
26
27
  Requires-Dist: eb-evaluation<0.3,>=0.2; extra == "eb"
@@ -1,5 +1,6 @@
1
1
  numpy>=1.24
2
2
  pandas>=2.0
3
+ eb-metrics<0.3,>=0.2
3
4
 
4
5
  [all]
5
6
  eb-metrics<0.3,>=0.2
@@ -1,331 +0,0 @@
1
- """Demand Quantization Compatibility (DQC) policy and snapping enforcement.
2
-
3
- This module defines the governance logic required to correctly evaluate forecasts
4
- when realized demand is quantized or unit-packed. It implements Demand Quantization
5
- Compatibility (DQC) as a diagnostic and enforcement layer that detects whether demand
6
- lies on a discrete grid and, if so, enforces projection ("snapping") of forecasts
7
- onto that grid prior to evaluation.
8
-
9
- Key responsibilities:
10
- - Detect demand quantization structure and infer the governing grid size (Δ*).
11
- - Classify demand as CONTINUOUS, QUANTIZED, or PACKED based on alignment evidence.
12
- - Enforce unit compatibility by snapping forecasts to Δ* when required.
13
- - Interpret evaluation tolerances (τ) in grid units rather than raw numeric units.
14
-
15
- This module does not define forecasting models or metric primitives. Instead, it
16
- provides policy-level wrappers that ensure evaluation metrics operate in a valid
17
- unit space. When demand is PACKED or QUANTIZED, unsnapped evaluation is considered
18
- invalid and must be corrected or rejected according to policy.
19
-
20
- DQC is a structural compatibility gate, not a performance metric. Its purpose is to
21
- prevent mathematically invalid evaluation and model comparison when demand outcomes
22
- are intrinsically discrete.
23
- """
24
-
25
- from __future__ import annotations
26
-
27
- from collections.abc import Sequence
28
- from dataclasses import dataclass
29
- import math
30
- from typing import Literal
31
-
32
- import numpy as np
33
-
34
- try:
35
- # eb-optimization does not define metric primitives; it delegates to eb-metrics.
36
- from eb_metrics.metrics.service import hr_at_tau as _hr_at_tau
37
- except Exception: # pragma: no cover
38
- _hr_at_tau = None
39
-
40
-
41
- DQCClass = Literal["CONTINUOUS", "QUANTIZED", "PACKED"]
42
- SnapMode = Literal["nearest", "floor", "ceil"]
43
- EnforcementMode = Literal["snap", "raise", "ignore"]
44
-
45
-
46
- @dataclass(frozen=True, slots=True)
47
- class DQCPolicy:
48
- """Governance thresholds and candidate grids for DQC."""
49
-
50
- # Candidate grids (Δ) to test for alignment. Keep small and auditable.
51
- candidates: tuple[float, ...] = (0.25, 0.5, 1.0, 2.0, 4.0, 6.0, 8.0, 12.0, 16.0)
52
-
53
- # Numerical tolerance for "on-grid" checks in y-units.
54
- tol: float = 1e-6
55
-
56
- # Classification thresholds.
57
- rho_packed: float = 0.99
58
- rho_quantized: float = 0.90
59
-
60
- # Packed-ness evidence thresholds.
61
- support_packed_max: int = 100
62
- offgrid_mad_over_delta_max: float = 0.05
63
-
64
- # Minimum positive support required to consider the DQC result meaningful.
65
- min_n_pos: int = 50
66
-
67
- # Tie-break rule for selecting delta_star among candidates with equal rho.
68
- # "max_delta" prefers the coarsest grid among ties (more conservative packing).
69
- tie_break: Literal["max_delta", "min_delta"] = "max_delta"
70
-
71
-
72
- DEFAULT_DQC_POLICY = DQCPolicy()
73
-
74
-
75
- @dataclass(frozen=True, slots=True)
76
- class DQCResult:
77
- """Output of DQC computation."""
78
-
79
- dqc_class: DQCClass
80
- delta_star: float | None
81
- rho_star: float | None
82
-
83
- # Evidence / diagnostics
84
- n_pos: int
85
- support_size: int
86
- offgrid_mad_over_delta: float | None
87
-
88
-
89
- def snap_to_grid(
90
- x: np.ndarray,
91
- delta: float,
92
- *,
93
- mode: SnapMode = "nearest",
94
- nonneg: bool = True,
95
- ) -> np.ndarray:
96
- """Project values onto multiples of delta.
97
-
98
- Args:
99
- x: Array of values to snap (may include NaNs).
100
- delta: Grid size (Δ). Must be > 0.
101
- mode: Nearest, floor, or ceil snapping.
102
- nonneg: If True, clamps to >= 0 after snapping.
103
-
104
- Returns:
105
- Snapped array (float dtype), preserving NaNs.
106
- """
107
- if not (isinstance(delta, int | float) and delta > 0.0):
108
- raise ValueError(f"delta must be > 0; got {delta!r}")
109
-
110
- x = np.asarray(x, dtype=float)
111
- out = np.full_like(x, np.nan, dtype=float)
112
-
113
- m = np.isfinite(x)
114
- if not np.any(m):
115
- return out
116
-
117
- q = x[m] / delta
118
-
119
- if mode == "nearest":
120
- snapped = np.round(q) * delta
121
- elif mode == "floor":
122
- snapped = np.floor(q) * delta
123
- elif mode == "ceil":
124
- snapped = np.ceil(q) * delta
125
- else:
126
- raise ValueError(f"Unsupported mode: {mode!r}")
127
-
128
- if nonneg:
129
- snapped = np.maximum(snapped, 0.0)
130
-
131
- out[m] = snapped
132
- return out
133
-
134
-
135
- def _mad(a: np.ndarray) -> float:
136
- """Median absolute deviation around the median (robust)."""
137
- med = float(np.median(a))
138
- return float(np.median(np.abs(a - med)))
139
-
140
-
141
- def compute_dqc(
142
- y: Sequence[float] | np.ndarray,
143
- *,
144
- policy: DQCPolicy = DEFAULT_DQC_POLICY,
145
- use_positive_only: bool = True,
146
- ) -> DQCResult:
147
- """Compute DQC over a realized demand series.
148
-
149
- Notes:
150
- - For grid detection, positive demand values carry the most information.
151
- Zeros can dominate alignment trivially, so default behavior uses positives only.
152
- - Missing values (NaN) are ignored.
153
-
154
- Args:
155
- y: Realized demand sequence.
156
- policy: DQCPolicy thresholds and candidate grids.
157
- use_positive_only: If True, only y>0 are used for detection.
158
-
159
- Returns:
160
- DQCResult with class + delta_star + diagnostics.
161
- """
162
- y_arr = np.asarray(y, dtype=float)
163
- y_arr = y_arr[np.isfinite(y_arr)]
164
-
165
- if use_positive_only:
166
- y_arr = y_arr[y_arr > 0.0]
167
-
168
- n_pos = int(y_arr.size)
169
- if n_pos < policy.min_n_pos:
170
- # Not enough signal to conclude; treat as continuous-like by default.
171
- return DQCResult(
172
- dqc_class="CONTINUOUS",
173
- delta_star=None,
174
- rho_star=None,
175
- n_pos=n_pos,
176
- support_size=int(np.unique(np.round(y_arr, 4)).size) if n_pos else 0,
177
- offgrid_mad_over_delta=None,
178
- )
179
-
180
- # Support size (rounded to label precision)
181
- support_size = int(np.unique(np.round(y_arr, 4)).size)
182
-
183
- candidates = np.asarray(policy.candidates, dtype=float)
184
- if candidates.ndim != 1 or candidates.size == 0:
185
- raise ValueError("policy.candidates must be a non-empty 1D sequence")
186
-
187
- if np.any(candidates <= 0.0):
188
- raise ValueError("All candidate deltas must be > 0")
189
-
190
- # Residual to nearest multiple: |y - round(y/Δ)*Δ|
191
- y_col = y_arr.reshape(-1, 1) # (n,1)
192
- deltas = candidates.reshape(1, -1) # (1,k)
193
-
194
- nearest = np.round(y_col / deltas) * deltas
195
- resid = np.abs(y_col - nearest) # (n,k)
196
-
197
- rho = (resid <= policy.tol).mean(axis=0) # (k,)
198
-
199
- # Choose delta_star: maximize rho, break ties by delta direction.
200
- max_rho = float(np.max(rho))
201
- tie_idx = np.flatnonzero(rho == max_rho)
202
-
203
- if tie_idx.size == 1:
204
- best_idx = int(tie_idx[0])
205
- else:
206
- if policy.tie_break == "max_delta":
207
- best_idx = int(tie_idx[np.argmax(candidates[tie_idx])])
208
- elif policy.tie_break == "min_delta":
209
- best_idx = int(tie_idx[np.argmin(candidates[tie_idx])])
210
- else:
211
- raise ValueError(f"Unsupported tie_break: {policy.tie_break!r}")
212
-
213
- delta_star = float(candidates[best_idx])
214
- rho_star = float(rho[best_idx])
215
-
216
- resid_star = resid[:, best_idx]
217
- mad = _mad(resid_star)
218
- offgrid_mad_over_delta = float(mad / delta_star) if delta_star > 0 else math.nan
219
-
220
- # Classification
221
- if (
222
- rho_star >= policy.rho_packed
223
- and offgrid_mad_over_delta <= policy.offgrid_mad_over_delta_max
224
- and support_size <= policy.support_packed_max
225
- ):
226
- dqc_class: DQCClass = "PACKED"
227
- elif rho_star >= policy.rho_quantized:
228
- dqc_class = "QUANTIZED"
229
- else:
230
- dqc_class = "CONTINUOUS"
231
-
232
- return DQCResult(
233
- dqc_class=dqc_class,
234
- delta_star=delta_star,
235
- rho_star=rho_star,
236
- n_pos=n_pos,
237
- support_size=support_size,
238
- offgrid_mad_over_delta=offgrid_mad_over_delta,
239
- )
240
-
241
-
242
- def enforce_snapping(
243
- y_hat: Sequence[float] | np.ndarray,
244
- *,
245
- dqc: DQCResult,
246
- enforce: EnforcementMode = "snap",
247
- mode: SnapMode = "nearest",
248
- ) -> np.ndarray:
249
- """Apply DQC snapping enforcement to forecasts.
250
-
251
- Policy intent:
252
- - PACKED demand => snapping is required (unit compatibility).
253
- - QUANTIZED demand => snapping is strongly recommended; default behavior snaps.
254
- - CONTINUOUS => no snapping.
255
-
256
- Args:
257
- y_hat: Forecast values.
258
- dqc: DQCResult for the relevant entity/window.
259
- enforce: "snap" (default), "raise" (error if off-grid), "ignore".
260
- mode: Snapping mode (if enforce == "snap").
261
-
262
- Returns:
263
- Forecast array, snapped or unchanged depending on class and enforcement.
264
- """
265
- y_hat_arr = np.asarray(y_hat, dtype=float)
266
-
267
- if dqc.dqc_class == "CONTINUOUS" or dqc.delta_star is None:
268
- return y_hat_arr
269
-
270
- if enforce == "ignore":
271
- return y_hat_arr
272
-
273
- delta = float(dqc.delta_star)
274
-
275
- if enforce == "raise":
276
- snapped = snap_to_grid(y_hat_arr, delta, mode="nearest", nonneg=True)
277
- offgrid = np.isfinite(y_hat_arr) & (np.abs(y_hat_arr - snapped) > DEFAULT_DQC_POLICY.tol)
278
- if bool(np.any(offgrid)):
279
- raise ValueError("Forecast contains off-grid values under PACKED/QUANTIZED DQC policy.")
280
- return y_hat_arr
281
-
282
- if enforce == "snap":
283
- return snap_to_grid(y_hat_arr, delta, mode=mode, nonneg=True)
284
-
285
- raise ValueError(f"Unsupported enforce mode: {enforce!r}")
286
-
287
-
288
- def hr_at_tau_grid_units(
289
- y_true: Sequence[float] | np.ndarray,
290
- y_hat: Sequence[float] | np.ndarray,
291
- *,
292
- dqc: DQCResult,
293
- tau_units: float,
294
- enforce: EnforcementMode = "snap",
295
- snap_mode: SnapMode = "nearest",
296
- ) -> float:
297
- """Compute HR@τ where τ is measured in grid units (Δ*).
298
-
299
- For PACKED / QUANTIZED demand:
300
- - Forecasts are snapped per enforcement policy (default: snap).
301
- - Error is evaluated in grid units: |y - yhat| / Δ* <= τ_units.
302
-
303
- Delegates to eb-metrics `hr_at_tau` after converting τ into y-units.
304
-
305
- Args:
306
- y_true: Realized demand.
307
- y_hat: Forecast demand.
308
- dqc: DQCResult for the relevant entity/window.
309
- tau_units: Tolerance in grid units (Δ* multiples).
310
- enforce: Snapping enforcement behavior.
311
- snap_mode: Snapping mode for y_hat when enforce == "snap".
312
-
313
- Returns:
314
- Hit rate in [0,1].
315
- """
316
- if _hr_at_tau is None: # pragma: no cover
317
- raise ImportError(
318
- "eb-metrics is required to compute HR@τ (missing eb_metrics.metrics.service.hr_at_tau)."
319
- )
320
-
321
- y_true_arr = np.asarray(y_true, dtype=float)
322
- y_hat_arr = np.asarray(y_hat, dtype=float)
323
-
324
- if dqc.dqc_class in ("PACKED", "QUANTIZED") and dqc.delta_star is not None:
325
- y_hat_arr = enforce_snapping(y_hat_arr, dqc=dqc, enforce=enforce, mode=snap_mode)
326
- tau = float(tau_units) * float(dqc.delta_star)
327
- else:
328
- # Continuous-like: interpret tau_units as y-units directly (caller responsibility).
329
- tau = float(tau_units)
330
-
331
- return float(_hr_at_tau(y_true_arr, y_hat_arr, tau=tau))
File without changes