calibrated-bo 0.2.0__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 (32) hide show
  1. calibrated_bo-0.2.0/LICENSE +21 -0
  2. calibrated_bo-0.2.0/PKG-INFO +108 -0
  3. calibrated_bo-0.2.0/README.md +88 -0
  4. calibrated_bo-0.2.0/calibrated_bo/__init__.py +50 -0
  5. calibrated_bo-0.2.0/calibrated_bo/acquisition/__init__.py +12 -0
  6. calibrated_bo-0.2.0/calibrated_bo/acquisition/_pareto.py +149 -0
  7. calibrated_bo-0.2.0/calibrated_bo/acquisition/batch.py +91 -0
  8. calibrated_bo-0.2.0/calibrated_bo/acquisition/multi_obj.py +107 -0
  9. calibrated_bo-0.2.0/calibrated_bo/acquisition/single_obj.py +91 -0
  10. calibrated_bo-0.2.0/calibrated_bo/calibration/__init__.py +19 -0
  11. calibrated_bo-0.2.0/calibrated_bo/calibration/_oof.py +111 -0
  12. calibrated_bo-0.2.0/calibrated_bo/calibration/adaptive.py +111 -0
  13. calibrated_bo-0.2.0/calibrated_bo/calibration/base.py +106 -0
  14. calibrated_bo-0.2.0/calibrated_bo/calibration/composite.py +337 -0
  15. calibrated_bo-0.2.0/calibrated_bo/calibration/multi_output.py +99 -0
  16. calibrated_bo-0.2.0/calibrated_bo/calibration/presets.py +60 -0
  17. calibrated_bo-0.2.0/calibrated_bo/diagnostics/__init__.py +4 -0
  18. calibrated_bo-0.2.0/calibrated_bo/diagnostics/coverage.py +50 -0
  19. calibrated_bo-0.2.0/calibrated_bo/diagnostics/reliability.py +88 -0
  20. calibrated_bo-0.2.0/calibrated_bo/loop/__init__.py +3 -0
  21. calibrated_bo-0.2.0/calibrated_bo/loop/bo_loop.py +475 -0
  22. calibrated_bo-0.2.0/calibrated_bo/platform/__init__.py +3 -0
  23. calibrated_bo-0.2.0/calibrated_bo/platform/adapter.py +55 -0
  24. calibrated_bo-0.2.0/calibrated_bo.egg-info/PKG-INFO +108 -0
  25. calibrated_bo-0.2.0/calibrated_bo.egg-info/SOURCES.txt +30 -0
  26. calibrated_bo-0.2.0/calibrated_bo.egg-info/dependency_links.txt +1 -0
  27. calibrated_bo-0.2.0/calibrated_bo.egg-info/requires.txt +4 -0
  28. calibrated_bo-0.2.0/calibrated_bo.egg-info/top_level.txt +1 -0
  29. calibrated_bo-0.2.0/pyproject.toml +32 -0
  30. calibrated_bo-0.2.0/setup.cfg +4 -0
  31. calibrated_bo-0.2.0/tests/test_phase2.py +237 -0
  32. calibrated_bo-0.2.0/tests/test_smoke.py +146 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shifa Zhong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: calibrated-bo
3
+ Version: 0.2.0
4
+ Summary: Calibrated Bayesian optimization with composable conformal prediction (Weighted + Localized + Adaptive) on top of bayesian-gp-cvloss.
5
+ Author-email: Shifa Zhong <sfzhong@tongji.edu.cn>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/Shifa-Zhong/calibrated-bo
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: bayesian-gp-cvloss>=0.3.1
16
+ Requires-Dist: numpy>=1.18.0
17
+ Requires-Dist: scipy>=1.5.0
18
+ Requires-Dist: scikit-learn>=0.23.0
19
+ Dynamic: license-file
20
+
21
+ # calibrated-bo
22
+
23
+ Calibrated Bayesian optimization with composable conformal prediction, built
24
+ on top of [`bayesian-gp-cvloss`](https://github.com/Shifa-Zhong/bayesian-gp-cvloss).
25
+
26
+ **Phase 1 (this release)** — core single-objective stack:
27
+
28
+ - `CompositeConformalCalibrator` with three orthogonal switches:
29
+ - `localized=True` — pick only x*'s k-NN from the calibration set
30
+ - `weighted=True` — RBF weights against x* on the surviving subset
31
+ - `adaptive=True` — ACI controller updates α from rolling coverage
32
+ - `CalibratedUCB`, `CalibratedEI` — drop-in calibrated acquisitions
33
+ - `CalibratedBOLoop` — single-objective loop (`suggest()` / `observe()`)
34
+ - Platform adapter (`create_bo_session(config)`)
35
+
36
+ All three calibrator switches are independent: turn them all off and the
37
+ calibrator is numerically equivalent to standard split CP.
38
+
39
+ **Phase 2 (planned)** — multi-objective EHVI, q-batch with fantasies,
40
+ diagnostics dashboard, native gp-cv platform integration.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install -e D:/mypackage/calibrated-bo
46
+ ```
47
+
48
+ Requires `bayesian-gp-cvloss>=0.3.1`.
49
+
50
+ ## Quickstart
51
+
52
+ ```python
53
+ import numpy as np
54
+ from calibrated_bo import CalibratedBOLoop
55
+
56
+ # 2D toy: minimise (x-0.3)^2 + (y-0.7)^2
57
+ def f(x):
58
+ return np.sum((x - np.array([0.3, 0.7]))**2)
59
+
60
+ bo = CalibratedBOLoop(
61
+ bounds=[(0.0, 1.0), (0.0, 1.0)],
62
+ objective="minimize",
63
+ calibrator_config={
64
+ "alpha": 0.1,
65
+ "localized": True, "k": 20,
66
+ "weighted": True,
67
+ "adaptive": True, "gamma": 0.05,
68
+ },
69
+ acquisition="cUCB",
70
+ acquisition_kwargs={"beta": 2.0},
71
+ batch_size=1,
72
+ initial_random=5,
73
+ gp_max_evals=30,
74
+ )
75
+
76
+ for step in range(20):
77
+ X_next = bo.suggest()
78
+ y_next = np.array([f(x) for x in X_next])
79
+ bo.observe(X_next, y_next)
80
+
81
+ print("best so far:", bo.best)
82
+ print("diagnostics:", bo.diagnostics())
83
+ ```
84
+
85
+ ## Calibrator-only usage
86
+
87
+ If you only want the calibrator (no BO loop), drop it on top of any fitted
88
+ `GPCrossValidatedOptimizer`:
89
+
90
+ ```python
91
+ from bayesian_gp_cvloss import GPCrossValidatedOptimizer
92
+ from calibrated_bo import CompositeConformalCalibrator
93
+
94
+ opt = GPCrossValidatedOptimizer(X_train, y_train, scoring="cv_rmse")
95
+ opt.optimize(max_evals=50)
96
+
97
+ cal = CompositeConformalCalibrator(
98
+ alpha=0.1, localized=True, weighted=True, adaptive=False
99
+ ).fit_cv(opt)
100
+
101
+ mean, lower, upper = cal.predict_interval(X_new)
102
+ ```
103
+
104
+ ## Design
105
+
106
+ See the project spec (calibrated-bo design doc) for the math behind
107
+ Localized + Weighted + Adaptive coordination and the joint half-width
108
+ formula.
@@ -0,0 +1,88 @@
1
+ # calibrated-bo
2
+
3
+ Calibrated Bayesian optimization with composable conformal prediction, built
4
+ on top of [`bayesian-gp-cvloss`](https://github.com/Shifa-Zhong/bayesian-gp-cvloss).
5
+
6
+ **Phase 1 (this release)** — core single-objective stack:
7
+
8
+ - `CompositeConformalCalibrator` with three orthogonal switches:
9
+ - `localized=True` — pick only x*'s k-NN from the calibration set
10
+ - `weighted=True` — RBF weights against x* on the surviving subset
11
+ - `adaptive=True` — ACI controller updates α from rolling coverage
12
+ - `CalibratedUCB`, `CalibratedEI` — drop-in calibrated acquisitions
13
+ - `CalibratedBOLoop` — single-objective loop (`suggest()` / `observe()`)
14
+ - Platform adapter (`create_bo_session(config)`)
15
+
16
+ All three calibrator switches are independent: turn them all off and the
17
+ calibrator is numerically equivalent to standard split CP.
18
+
19
+ **Phase 2 (planned)** — multi-objective EHVI, q-batch with fantasies,
20
+ diagnostics dashboard, native gp-cv platform integration.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install -e D:/mypackage/calibrated-bo
26
+ ```
27
+
28
+ Requires `bayesian-gp-cvloss>=0.3.1`.
29
+
30
+ ## Quickstart
31
+
32
+ ```python
33
+ import numpy as np
34
+ from calibrated_bo import CalibratedBOLoop
35
+
36
+ # 2D toy: minimise (x-0.3)^2 + (y-0.7)^2
37
+ def f(x):
38
+ return np.sum((x - np.array([0.3, 0.7]))**2)
39
+
40
+ bo = CalibratedBOLoop(
41
+ bounds=[(0.0, 1.0), (0.0, 1.0)],
42
+ objective="minimize",
43
+ calibrator_config={
44
+ "alpha": 0.1,
45
+ "localized": True, "k": 20,
46
+ "weighted": True,
47
+ "adaptive": True, "gamma": 0.05,
48
+ },
49
+ acquisition="cUCB",
50
+ acquisition_kwargs={"beta": 2.0},
51
+ batch_size=1,
52
+ initial_random=5,
53
+ gp_max_evals=30,
54
+ )
55
+
56
+ for step in range(20):
57
+ X_next = bo.suggest()
58
+ y_next = np.array([f(x) for x in X_next])
59
+ bo.observe(X_next, y_next)
60
+
61
+ print("best so far:", bo.best)
62
+ print("diagnostics:", bo.diagnostics())
63
+ ```
64
+
65
+ ## Calibrator-only usage
66
+
67
+ If you only want the calibrator (no BO loop), drop it on top of any fitted
68
+ `GPCrossValidatedOptimizer`:
69
+
70
+ ```python
71
+ from bayesian_gp_cvloss import GPCrossValidatedOptimizer
72
+ from calibrated_bo import CompositeConformalCalibrator
73
+
74
+ opt = GPCrossValidatedOptimizer(X_train, y_train, scoring="cv_rmse")
75
+ opt.optimize(max_evals=50)
76
+
77
+ cal = CompositeConformalCalibrator(
78
+ alpha=0.1, localized=True, weighted=True, adaptive=False
79
+ ).fit_cv(opt)
80
+
81
+ mean, lower, upper = cal.predict_interval(X_new)
82
+ ```
83
+
84
+ ## Design
85
+
86
+ See the project spec (calibrated-bo design doc) for the math behind
87
+ Localized + Weighted + Adaptive coordination and the joint half-width
88
+ formula.
@@ -0,0 +1,50 @@
1
+ """
2
+ calibrated-bo: Composable conformal calibration + calibrated acquisition + BO loop
3
+ on top of bayesian-gp-cvloss.
4
+
5
+ Quick imports:
6
+ from calibrated_bo import (
7
+ CompositeConformalCalibrator,
8
+ AdaptiveAlphaController,
9
+ CalibratedUCB,
10
+ CalibratedEI,
11
+ CalibratedBOLoop,
12
+ )
13
+ """
14
+ from .calibration.base import BaseConformalCalibrator
15
+ from .calibration.adaptive import AdaptiveAlphaController
16
+ from .calibration.composite import CompositeConformalCalibrator
17
+ from .calibration.multi_output import MultiOutputCalibrator
18
+ from .calibration.presets import (
19
+ AdaptiveConformalCalibrator,
20
+ LocalizedConformalCalibrator,
21
+ WeightedConformalCalibrator,
22
+ )
23
+ from .acquisition.single_obj import CalibratedAcquisition, CalibratedUCB, CalibratedEI
24
+ from .acquisition.multi_obj import CalibratedMultiAcquisition, CalibratedEHVI
25
+ from .acquisition.batch import greedy_q_batch
26
+ from .loop.bo_loop import CalibratedBOLoop
27
+ from .platform.adapter import create_bo_session
28
+
29
+ __version__ = "0.2.0"
30
+
31
+ __all__ = [
32
+ # Calibration
33
+ "BaseConformalCalibrator",
34
+ "AdaptiveAlphaController",
35
+ "CompositeConformalCalibrator",
36
+ "MultiOutputCalibrator",
37
+ "WeightedConformalCalibrator",
38
+ "LocalizedConformalCalibrator",
39
+ "AdaptiveConformalCalibrator",
40
+ # Acquisition
41
+ "CalibratedAcquisition",
42
+ "CalibratedUCB",
43
+ "CalibratedEI",
44
+ "CalibratedMultiAcquisition",
45
+ "CalibratedEHVI",
46
+ "greedy_q_batch",
47
+ # Loop
48
+ "CalibratedBOLoop",
49
+ "create_bo_session",
50
+ ]
@@ -0,0 +1,12 @@
1
+ from .single_obj import CalibratedAcquisition, CalibratedUCB, CalibratedEI
2
+ from .multi_obj import CalibratedMultiAcquisition, CalibratedEHVI
3
+ from .batch import greedy_q_batch
4
+
5
+ __all__ = [
6
+ "CalibratedAcquisition",
7
+ "CalibratedUCB",
8
+ "CalibratedEI",
9
+ "CalibratedMultiAcquisition",
10
+ "CalibratedEHVI",
11
+ "greedy_q_batch",
12
+ ]
@@ -0,0 +1,149 @@
1
+ """Pareto and hypervolume helpers (multi-objective).
2
+
3
+ Convention used everywhere in this package:
4
+ - Internal multi-objective vectors are oriented for MAXIMISATION on every
5
+ component. The BO loop flips signs externally for any minimisation
6
+ objective so the acquisition / Pareto / HV code can stay sign-agnostic.
7
+
8
+ Only depends on numpy. Two algorithms inside:
9
+ - ``pareto_front_mask(Y)``: O(N^2) non-dominance scan -- fine for the small N
10
+ typical in materials / chemistry BO (N < a few thousand).
11
+ - ``dominated_hypervolume(Y, ref)``: 2D closed form + 3D/higher inclusion-
12
+ exclusion via WFG-style decomposition is overkill here -- we use a simple
13
+ recursive box-difference scheme that is O(N^M) but exact, plus a fast 2D
14
+ case. Adequate for M <= 3 with N < 200.
15
+
16
+ For the typical calibrated-BO settings (M = 2 or 3, |Pareto| < 30), this is
17
+ fast enough; if we ever need M >= 4 with large fronts, swap in pymoo's WFG.
18
+ """
19
+ from typing import Tuple
20
+
21
+ import numpy as np
22
+
23
+
24
+ def pareto_front_mask(Y: np.ndarray) -> np.ndarray:
25
+ """Boolean mask of non-dominated rows under MAXIMISATION on all columns.
26
+
27
+ Y: (N, M). Returns: (N,) bool.
28
+ A point is non-dominated if no other point strictly dominates it.
29
+ """
30
+ Y = np.atleast_2d(np.asarray(Y, dtype=float))
31
+ n = Y.shape[0]
32
+ keep = np.ones(n, dtype=bool)
33
+ for i in range(n):
34
+ if not keep[i]:
35
+ continue
36
+ # Any other point j weakly >= Y[i] on all coords AND strictly > on at least one.
37
+ dominates_i = np.all(Y >= Y[i], axis=1) & np.any(Y > Y[i], axis=1)
38
+ dominates_i[i] = False
39
+ if dominates_i.any():
40
+ keep[i] = False
41
+ return keep
42
+
43
+
44
+ def pareto_front(Y: np.ndarray) -> np.ndarray:
45
+ """Return the non-dominated subset of rows (maximisation)."""
46
+ return Y[pareto_front_mask(Y)]
47
+
48
+
49
+ def infer_reference_point(Y: np.ndarray, margin: float = 0.1) -> np.ndarray:
50
+ """Reference point for hypervolume: a bit *below* the worst observed value
51
+ on each objective (maximisation convention). ``margin`` is fraction of
52
+ objective range.
53
+ """
54
+ Y = np.atleast_2d(np.asarray(Y, dtype=float))
55
+ lo = Y.min(axis=0)
56
+ hi = Y.max(axis=0)
57
+ span = np.where(hi > lo, hi - lo, 1.0)
58
+ return lo - margin * span
59
+
60
+
61
+ def dominated_hypervolume(Y: np.ndarray, ref: np.ndarray) -> float:
62
+ """Hypervolume of the region dominated by Y w.r.t. ref point (maximisation).
63
+
64
+ Supports any M >= 1, but optimised for M=2 (closed form). M=1 returns
65
+ ``max(0, max(Y) - ref)``. M=3+ uses inclusion-exclusion in axis-aligned
66
+ boxes -- correct but O(N^M); fine for the small-N small-M cases this
67
+ library targets.
68
+ """
69
+ Y = np.atleast_2d(np.asarray(Y, dtype=float))
70
+ ref = np.asarray(ref, dtype=float).flatten()
71
+ if Y.shape[1] != ref.size:
72
+ raise ValueError(f"Y has {Y.shape[1]} cols but ref has {ref.size}")
73
+ # Clip to ref: only the > ref portion contributes.
74
+ Y_clip = np.maximum(Y, ref[None, :])
75
+ # Keep only non-dominated points (everything else contributes nothing new).
76
+ mask = pareto_front_mask(Y_clip)
77
+ P = Y_clip[mask]
78
+ if P.size == 0:
79
+ return 0.0
80
+ M = P.shape[1]
81
+ if M == 1:
82
+ return float(P[:, 0].max() - ref[0])
83
+ if M == 2:
84
+ return _hv_2d(P, ref)
85
+ return _hv_recursive(P, ref)
86
+
87
+
88
+ def _hv_2d(P: np.ndarray, ref: np.ndarray) -> float:
89
+ """2D hypervolume (maximisation). P is already non-dominated."""
90
+ # Sort by first objective descending; second objective then strictly increases.
91
+ order = np.argsort(-P[:, 0], kind="mergesort")
92
+ P_sorted = P[order]
93
+ hv = 0.0
94
+ prev_y2 = ref[1]
95
+ with np.errstate(over="ignore", invalid="ignore"):
96
+ for x1, x2 in P_sorted:
97
+ if x2 <= prev_y2:
98
+ continue # dominated under maximisation (shouldn't happen if filtered)
99
+ hv += float((x1 - ref[0]) * (x2 - prev_y2))
100
+ prev_y2 = x2
101
+ return float(hv)
102
+
103
+
104
+ def _hv_recursive(P: np.ndarray, ref: np.ndarray) -> float:
105
+ """Generic M-dim hypervolume via inclusion-exclusion. O(2^N) worst case
106
+ but the calls in EHVI happen on the current Pareto front (typically
107
+ |P| <= 30) so this stays cheap."""
108
+ n = P.shape[0]
109
+ if n == 0:
110
+ return 0.0
111
+ if n == 1:
112
+ return float(np.prod(P[0] - ref))
113
+ total = 0.0
114
+ # Use Walking-Box / inclusion-exclusion: HV(P) = sum_{S subset, S nonempty}
115
+ # (-1)^(|S|+1) * prod(min_{i in S}(P_i) - ref).
116
+ # Practical only for n up to ~20. For larger n, fall back to a greedy
117
+ # decomposition: project on last axis, recurse on slices.
118
+ if n > 18:
119
+ return _hv_slice(P, ref)
120
+ for mask in range(1, 1 << n):
121
+ bits = bin(mask).count("1")
122
+ rows = []
123
+ for i in range(n):
124
+ if mask & (1 << i):
125
+ rows.append(P[i])
126
+ sub = np.array(rows)
127
+ mins = sub.min(axis=0)
128
+ box = np.prod(np.maximum(mins - ref, 0.0))
129
+ total += ((-1) ** (bits + 1)) * box
130
+ return float(total)
131
+
132
+
133
+ def _hv_slice(P: np.ndarray, ref: np.ndarray) -> float:
134
+ """Slice-based HV for larger fronts: project onto the last axis, recurse."""
135
+ M = P.shape[1]
136
+ order = np.argsort(P[:, -1], kind="mergesort")
137
+ P_sorted = P[order]
138
+ hv = 0.0
139
+ prev = ref[-1]
140
+ for i, row in enumerate(P_sorted):
141
+ if row[-1] <= prev:
142
+ continue
143
+ upper_slice = P_sorted[i:, :-1]
144
+ # Only non-dominated points in the slice contribute to the slab volume.
145
+ keep = pareto_front_mask(upper_slice)
146
+ slab = dominated_hypervolume(upper_slice[keep], ref[:-1])
147
+ hv += slab * (row[-1] - prev)
148
+ prev = row[-1]
149
+ return float(hv)
@@ -0,0 +1,91 @@
1
+ """q-batch acquisition via sequential greedy + kriging-believer fantasies.
2
+
3
+ Strategy: pick the candidate that maximises the acquisition, "pretend" we
4
+ observed it at the calibrator's predicted mean (kriging believer), rebuild
5
+ the acquisition on the augmented Pareto / y_best, and repeat ``q`` times.
6
+
7
+ Why not the closed-form q-EI / q-EHVI? Because those require differentiable
8
+ MC samplers (BoTorch territory). Greedy + kriging-believer is the standard
9
+ practitioner shortcut, gives ~80-90% of the gain with a fraction of the
10
+ code, and is the recommended Phase 2 default per the spec.
11
+
12
+ This module is intentionally generic: it works with any callable acquisition
13
+ ``acq(X) -> scores`` and a *rebuild* function that the loop knows how to
14
+ write (because it knows whether single-obj y_best or multi-obj Pareto is
15
+ the relevant state).
16
+ """
17
+ from typing import Callable, Iterable, List, Optional
18
+
19
+ import numpy as np
20
+
21
+
22
+ def greedy_q_batch(
23
+ candidate_pool: np.ndarray,
24
+ acq_fn: Callable[[np.ndarray], np.ndarray],
25
+ q: int,
26
+ rebuild_after_pick: Optional[Callable[[np.ndarray, np.ndarray], Callable]] = None,
27
+ predict_fn: Optional[Callable[[np.ndarray], np.ndarray]] = None,
28
+ dedupe_round: int = 8,
29
+ ) -> np.ndarray:
30
+ """Pick ``q`` distinct candidates by sequential greedy on ``acq_fn``.
31
+
32
+ Parameters
33
+ ----------
34
+ candidate_pool : (N, d) ndarray
35
+ acq_fn : callable
36
+ Initial acquisition. Returns shape (N,).
37
+ q : int
38
+ Batch size.
39
+ rebuild_after_pick : optional callable (x_picked, y_fantasy) -> new acq_fn
40
+ If provided, after each pick the acquisition is rebuilt with the
41
+ fantasy point appended to the calibrator's observed set. If None,
42
+ we just blacklist picked points and use the original acq_fn.
43
+ predict_fn : optional callable (X (1, d)) -> y (M,) ndarray of *means*
44
+ Required when ``rebuild_after_pick`` is provided. Produces the fantasy
45
+ y at the just-picked x.
46
+ dedupe_round : int
47
+ Round candidate coords to this many decimals when building the
48
+ already-picked set (handles float identity issues).
49
+
50
+ Returns
51
+ -------
52
+ picks : (q_actual, d) ndarray
53
+ """
54
+ if q < 1:
55
+ raise ValueError("q must be >= 1")
56
+ pool = np.atleast_2d(np.asarray(candidate_pool, dtype=float))
57
+ if pool.shape[0] == 0:
58
+ return np.empty((0, pool.shape[1] if pool.ndim == 2 else 0))
59
+
60
+ picked: List[np.ndarray] = []
61
+ chosen_keys: set = set()
62
+ current_acq = acq_fn
63
+
64
+ while len(picked) < q:
65
+ scores = current_acq(pool)
66
+ # Mask out already picked rows.
67
+ scores = np.asarray(scores, dtype=float).copy()
68
+ if np.all(~np.isfinite(scores)):
69
+ break
70
+ order = np.argsort(scores)[::-1]
71
+ chosen = None
72
+ for idx in order:
73
+ x = pool[idx]
74
+ key = tuple(np.round(x, dedupe_round).tolist())
75
+ if key in chosen_keys:
76
+ continue
77
+ chosen = x
78
+ chosen_keys.add(key)
79
+ break
80
+ if chosen is None:
81
+ break
82
+ picked.append(chosen)
83
+ if rebuild_after_pick is None or len(picked) >= q:
84
+ continue
85
+ if predict_fn is None:
86
+ raise ValueError("predict_fn must be provided when rebuild_after_pick is set")
87
+ # Build the fantasy y_hat at the just-picked x.
88
+ y_fantasy = predict_fn(chosen[None, :])
89
+ current_acq = rebuild_after_pick(chosen[None, :], y_fantasy)
90
+
91
+ return np.asarray(picked)
@@ -0,0 +1,107 @@
1
+ """Multi-objective calibrated acquisitions.
2
+
3
+ Currently exposes:
4
+
5
+ * ``CalibratedEHVI`` -- MC-based Expected Hypervolume Improvement using
6
+ calibrated (mean, sigma_cal) per objective.
7
+
8
+ Convention
9
+ ----------
10
+ Internally all objectives are oriented for MAXIMISATION (the BO loop flips
11
+ signs externally for any minimisation direction so the multi-objective code
12
+ stays sign-agnostic).
13
+ """
14
+ from typing import Optional
15
+
16
+ import numpy as np
17
+
18
+ from ..calibration.multi_output import MultiOutputCalibrator
19
+ from ._pareto import (
20
+ dominated_hypervolume,
21
+ infer_reference_point,
22
+ pareto_front_mask,
23
+ )
24
+
25
+
26
+ class CalibratedMultiAcquisition:
27
+ """Abstract base for multi-objective acquisitions over MultiOutputCalibrator."""
28
+
29
+ def __init__(self, calibrator: MultiOutputCalibrator):
30
+ if not isinstance(calibrator, MultiOutputCalibrator):
31
+ raise TypeError("calibrator must be a MultiOutputCalibrator")
32
+ self.calibrator = calibrator
33
+
34
+ def __call__(self, X: np.ndarray) -> np.ndarray:
35
+ raise NotImplementedError
36
+
37
+
38
+ class CalibratedEHVI(CalibratedMultiAcquisition):
39
+ """MC EHVI with calibrated per-objective uncertainty.
40
+
41
+ Treats the calibrated half-width ``sigma_cal`` as a Gaussian std-dev
42
+ (deliberate approximation -- see Phase 1 cUCB/cEI for the same trade).
43
+ Samples ``n_samples`` from the per-objective independent normals, computes
44
+ HV improvement vs the current Pareto front for each MC sample, returns
45
+ the mean.
46
+
47
+ Parameters
48
+ ----------
49
+ calibrator : MultiOutputCalibrator
50
+ Y_observed : (n_obs, M) ndarray
51
+ Already in maximisation orientation. The loop transforms before passing.
52
+ ref_point : (M,) ndarray, optional
53
+ Hypervolume reference point. If None, inferred from Y_observed.
54
+ n_samples : int
55
+ MC sample count.
56
+ random_state : int
57
+ Seed for the MC draws (reseeded each call so acquisition is
58
+ deterministic given the data state).
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ calibrator: MultiOutputCalibrator,
64
+ Y_observed: np.ndarray,
65
+ ref_point: Optional[np.ndarray] = None,
66
+ n_samples: int = 64,
67
+ random_state: int = 0,
68
+ ):
69
+ super().__init__(calibrator)
70
+ Y_observed = np.atleast_2d(np.asarray(Y_observed, dtype=float))
71
+ if Y_observed.shape[1] != calibrator.n_objectives:
72
+ raise ValueError(
73
+ f"Y_observed has {Y_observed.shape[1]} cols but calibrator has "
74
+ f"{calibrator.n_objectives} objectives"
75
+ )
76
+ self.Y_observed = Y_observed
77
+ self.ref_point = (np.asarray(ref_point, dtype=float).flatten()
78
+ if ref_point is not None
79
+ else infer_reference_point(Y_observed))
80
+ self.n_samples = int(n_samples)
81
+ self.random_state = int(random_state)
82
+ # Precompute current Pareto + current HV.
83
+ self._pareto = Y_observed[pareto_front_mask(Y_observed)]
84
+ self._hv_baseline = dominated_hypervolume(self._pareto, self.ref_point)
85
+
86
+ def __call__(self, X: np.ndarray) -> np.ndarray:
87
+ X = np.atleast_2d(np.asarray(X, dtype=float))
88
+ means, sigmas = self.calibrator.predict_calibrated(X)
89
+ # means / sigmas: (n_candidates, M)
90
+ rng = np.random.default_rng(self.random_state)
91
+ n, M = means.shape
92
+ # Draw (n_samples, n, M) Gaussian samples.
93
+ z = rng.standard_normal((self.n_samples, n, M))
94
+ samples = means[None, :, :] + sigmas[None, :, :] * z # broadcast OK
95
+ # For each candidate i, compute mean HV improvement over MC samples.
96
+ out = np.zeros(n)
97
+ for i in range(n):
98
+ improvements = np.empty(self.n_samples)
99
+ for s in range(self.n_samples):
100
+ y_new = samples[s, i, :]
101
+ # Append y_new to Pareto, recompute non-dominated set, then HV.
102
+ combined = np.vstack([self._pareto, y_new[None, :]])
103
+ pf = combined[pareto_front_mask(combined)]
104
+ hv_new = dominated_hypervolume(pf, self.ref_point)
105
+ improvements[s] = max(0.0, hv_new - self._hv_baseline)
106
+ out[i] = float(np.mean(improvements))
107
+ return out