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.
- calibrated_bo-0.2.0/LICENSE +21 -0
- calibrated_bo-0.2.0/PKG-INFO +108 -0
- calibrated_bo-0.2.0/README.md +88 -0
- calibrated_bo-0.2.0/calibrated_bo/__init__.py +50 -0
- calibrated_bo-0.2.0/calibrated_bo/acquisition/__init__.py +12 -0
- calibrated_bo-0.2.0/calibrated_bo/acquisition/_pareto.py +149 -0
- calibrated_bo-0.2.0/calibrated_bo/acquisition/batch.py +91 -0
- calibrated_bo-0.2.0/calibrated_bo/acquisition/multi_obj.py +107 -0
- calibrated_bo-0.2.0/calibrated_bo/acquisition/single_obj.py +91 -0
- calibrated_bo-0.2.0/calibrated_bo/calibration/__init__.py +19 -0
- calibrated_bo-0.2.0/calibrated_bo/calibration/_oof.py +111 -0
- calibrated_bo-0.2.0/calibrated_bo/calibration/adaptive.py +111 -0
- calibrated_bo-0.2.0/calibrated_bo/calibration/base.py +106 -0
- calibrated_bo-0.2.0/calibrated_bo/calibration/composite.py +337 -0
- calibrated_bo-0.2.0/calibrated_bo/calibration/multi_output.py +99 -0
- calibrated_bo-0.2.0/calibrated_bo/calibration/presets.py +60 -0
- calibrated_bo-0.2.0/calibrated_bo/diagnostics/__init__.py +4 -0
- calibrated_bo-0.2.0/calibrated_bo/diagnostics/coverage.py +50 -0
- calibrated_bo-0.2.0/calibrated_bo/diagnostics/reliability.py +88 -0
- calibrated_bo-0.2.0/calibrated_bo/loop/__init__.py +3 -0
- calibrated_bo-0.2.0/calibrated_bo/loop/bo_loop.py +475 -0
- calibrated_bo-0.2.0/calibrated_bo/platform/__init__.py +3 -0
- calibrated_bo-0.2.0/calibrated_bo/platform/adapter.py +55 -0
- calibrated_bo-0.2.0/calibrated_bo.egg-info/PKG-INFO +108 -0
- calibrated_bo-0.2.0/calibrated_bo.egg-info/SOURCES.txt +30 -0
- calibrated_bo-0.2.0/calibrated_bo.egg-info/dependency_links.txt +1 -0
- calibrated_bo-0.2.0/calibrated_bo.egg-info/requires.txt +4 -0
- calibrated_bo-0.2.0/calibrated_bo.egg-info/top_level.txt +1 -0
- calibrated_bo-0.2.0/pyproject.toml +32 -0
- calibrated_bo-0.2.0/setup.cfg +4 -0
- calibrated_bo-0.2.0/tests/test_phase2.py +237 -0
- 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
|