abbot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abbot/__init__.py +159 -0
- abbot/acq_opt.py +206 -0
- abbot/analysis.py +256 -0
- abbot/composite.py +114 -0
- abbot/config.py +47 -0
- abbot/cost.py +265 -0
- abbot/diagnostics.py +374 -0
- abbot/experiment.py +365 -0
- abbot/feasibility.py +158 -0
- abbot/goal.py +261 -0
- abbot/history.py +255 -0
- abbot/interop/__init__.py +2 -0
- abbot/interop/sbi.py +78 -0
- abbot/interop/xopt.py +419 -0
- abbot/io.py +282 -0
- abbot/loop.py +180 -0
- abbot/model.py +851 -0
- abbot/models/__init__.py +1 -0
- abbot/models/ensemble.py +51 -0
- abbot/policy.py +1344 -0
- abbot/problem.py +1015 -0
- abbot/session.py +1669 -0
- abbot/supervision.py +593 -0
- abbot/ui/__init__.py +10 -0
- abbot/ui/__main__.py +5 -0
- abbot/ui/app.py +1020 -0
- abbot/ui/builder.py +140 -0
- abbot/ui/controller.py +197 -0
- abbot/ui/figures.py +158 -0
- abbot/usability.py +93 -0
- abbot-0.1.0.dist-info/METADATA +291 -0
- abbot-0.1.0.dist-info/RECORD +34 -0
- abbot-0.1.0.dist-info/WHEEL +4 -0
- abbot-0.1.0.dist-info/licenses/LICENSE +201 -0
abbot/__init__.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""abbot — an active-learning-for-labs layer on top of BoTorch.
|
|
2
|
+
|
|
3
|
+
From A to B: a Bayesian guide for lab experiments. abbot is NOT a competing
|
|
4
|
+
optimization framework; it is the composite-SBI + lab-experiment-orchestration
|
|
5
|
+
layer on top of botorch (which already provides the surrogate-agnostic
|
|
6
|
+
Model/Posterior layer, the BO/US loop primitives, async/batch, and
|
|
7
|
+
serialization). If it drifts into "framework", stop.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from abbot.composite import (
|
|
11
|
+
ObjectiveReducer,
|
|
12
|
+
Rescore,
|
|
13
|
+
composite_evaluate,
|
|
14
|
+
)
|
|
15
|
+
from abbot.cost import (
|
|
16
|
+
CallableCost,
|
|
17
|
+
CostModel,
|
|
18
|
+
FittedCost,
|
|
19
|
+
FixedCost,
|
|
20
|
+
PowerLawCost,
|
|
21
|
+
SourceCost,
|
|
22
|
+
)
|
|
23
|
+
from abbot.experiment import (
|
|
24
|
+
BatchExperiment,
|
|
25
|
+
Experiment,
|
|
26
|
+
ManualExperiment,
|
|
27
|
+
MockAnalyticExperiment,
|
|
28
|
+
MockDatasetExperiment,
|
|
29
|
+
TensorExperiment,
|
|
30
|
+
Verdict,
|
|
31
|
+
)
|
|
32
|
+
from abbot.goal import (
|
|
33
|
+
BandIntersection,
|
|
34
|
+
FunctionGoal,
|
|
35
|
+
Goal,
|
|
36
|
+
LevelSet,
|
|
37
|
+
PercentileSet,
|
|
38
|
+
TopK,
|
|
39
|
+
Wishlist,
|
|
40
|
+
goals_from_spec,
|
|
41
|
+
)
|
|
42
|
+
from abbot.history import DataStore, History, Record, ResumeMismatchError
|
|
43
|
+
from abbot.interop.sbi import PosteriorSampler, TrainedPosterior, sbi_evaluate
|
|
44
|
+
from abbot.loop import drive, run_campaign
|
|
45
|
+
from abbot.model import (
|
|
46
|
+
EnsembleConstructor,
|
|
47
|
+
ModelConstructor,
|
|
48
|
+
ModelListConstructor,
|
|
49
|
+
MultiFidelityGPConstructor,
|
|
50
|
+
MultiSourceGPConstructor,
|
|
51
|
+
MultiTaskGPConstructor,
|
|
52
|
+
SingleTaskGPConstructor,
|
|
53
|
+
default_constructor,
|
|
54
|
+
)
|
|
55
|
+
from abbot.policy import (
|
|
56
|
+
BAXPolicy,
|
|
57
|
+
ParetoBO,
|
|
58
|
+
Policy,
|
|
59
|
+
SimpleBO,
|
|
60
|
+
UncertaintySampling,
|
|
61
|
+
default_policy,
|
|
62
|
+
)
|
|
63
|
+
from abbot.problem import (
|
|
64
|
+
Box,
|
|
65
|
+
ContinuousDomain,
|
|
66
|
+
EnumeratedDomain,
|
|
67
|
+
InequalitySpec,
|
|
68
|
+
IntegerDomain,
|
|
69
|
+
LinearConstraint,
|
|
70
|
+
ObjectiveSpec,
|
|
71
|
+
OutputSpec,
|
|
72
|
+
ProblemSpec,
|
|
73
|
+
Source,
|
|
74
|
+
StructuredDomain,
|
|
75
|
+
)
|
|
76
|
+
from abbot.session import (
|
|
77
|
+
Campaign,
|
|
78
|
+
CampaignResult,
|
|
79
|
+
CampaignState,
|
|
80
|
+
Candidate,
|
|
81
|
+
PersistentFaultError,
|
|
82
|
+
Targets,
|
|
83
|
+
apply_targets,
|
|
84
|
+
default_initial_designs,
|
|
85
|
+
)
|
|
86
|
+
from abbot.supervision import Supervisor
|
|
87
|
+
|
|
88
|
+
__version__ = "0.1.0"
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"BAXPolicy",
|
|
92
|
+
"BandIntersection",
|
|
93
|
+
"BatchExperiment",
|
|
94
|
+
"Box",
|
|
95
|
+
"CallableCost",
|
|
96
|
+
"Campaign",
|
|
97
|
+
"CampaignResult",
|
|
98
|
+
"CampaignState",
|
|
99
|
+
"Candidate",
|
|
100
|
+
"ContinuousDomain",
|
|
101
|
+
"CostModel",
|
|
102
|
+
"DataStore",
|
|
103
|
+
"EnsembleConstructor",
|
|
104
|
+
"EnumeratedDomain",
|
|
105
|
+
"Experiment",
|
|
106
|
+
"FittedCost",
|
|
107
|
+
"FixedCost",
|
|
108
|
+
"FunctionGoal",
|
|
109
|
+
"Goal",
|
|
110
|
+
"History",
|
|
111
|
+
"InequalitySpec",
|
|
112
|
+
"IntegerDomain",
|
|
113
|
+
"LevelSet",
|
|
114
|
+
"LinearConstraint",
|
|
115
|
+
"ManualExperiment",
|
|
116
|
+
"MockAnalyticExperiment",
|
|
117
|
+
"MockDatasetExperiment",
|
|
118
|
+
"ModelConstructor",
|
|
119
|
+
"ModelListConstructor",
|
|
120
|
+
"MultiFidelityGPConstructor",
|
|
121
|
+
"MultiSourceGPConstructor",
|
|
122
|
+
"MultiTaskGPConstructor",
|
|
123
|
+
"ObjectiveReducer",
|
|
124
|
+
"ObjectiveSpec",
|
|
125
|
+
"OutputSpec",
|
|
126
|
+
"ParetoBO",
|
|
127
|
+
"PercentileSet",
|
|
128
|
+
"PersistentFaultError",
|
|
129
|
+
"Policy",
|
|
130
|
+
"PosteriorSampler",
|
|
131
|
+
"PowerLawCost",
|
|
132
|
+
"ProblemSpec",
|
|
133
|
+
"Record",
|
|
134
|
+
"Rescore",
|
|
135
|
+
"ResumeMismatchError",
|
|
136
|
+
"SimpleBO",
|
|
137
|
+
"SingleTaskGPConstructor",
|
|
138
|
+
"Source",
|
|
139
|
+
"SourceCost",
|
|
140
|
+
"StructuredDomain",
|
|
141
|
+
"Supervisor",
|
|
142
|
+
"Targets",
|
|
143
|
+
"TensorExperiment",
|
|
144
|
+
"TopK",
|
|
145
|
+
"TrainedPosterior",
|
|
146
|
+
"UncertaintySampling",
|
|
147
|
+
"Verdict",
|
|
148
|
+
"Wishlist",
|
|
149
|
+
"__version__",
|
|
150
|
+
"apply_targets",
|
|
151
|
+
"composite_evaluate",
|
|
152
|
+
"default_constructor",
|
|
153
|
+
"default_initial_designs",
|
|
154
|
+
"default_policy",
|
|
155
|
+
"drive",
|
|
156
|
+
"goals_from_spec",
|
|
157
|
+
"run_campaign",
|
|
158
|
+
"sbi_evaluate",
|
|
159
|
+
]
|
abbot/acq_opt.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The CandidateOptimizer seam: candidate proposal decoupled from the surrogate.
|
|
2
|
+
|
|
3
|
+
``Continuous`` covers any space with a continuous or
|
|
4
|
+
integer axis (``optimize_acqf``; integer axes relaxed then rounded; enumerated axes
|
|
5
|
+
alongside them enumerated by ``optimize_acqf_mixed``). ``DiscreteArgmax`` covers
|
|
6
|
+
all-enumerated spaces (``optimize_acqf_discrete``). True combinatorial
|
|
7
|
+
(non-enumerable) search is an extension point, not solved speculatively.
|
|
8
|
+
|
|
9
|
+
``fixed_features={position: value}`` pins inputs, which is how a policy that doesn't
|
|
10
|
+
choose sources proposes at their target (``spec.target_features()``).
|
|
11
|
+
|
|
12
|
+
Decoupling proposal from the surrogate is what lets a discrete materials problem
|
|
13
|
+
and a continuous accelerator problem share one policy.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
from botorch.acquisition import AcquisitionFunction
|
|
22
|
+
from botorch.optim import optimize_acqf, optimize_acqf_discrete, optimize_acqf_mixed
|
|
23
|
+
|
|
24
|
+
from abbot.problem import ContinuousDomain, EnumeratedDomain, IntegerDomain, ProblemSpec
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class CandidateOptimizer(Protocol):
|
|
29
|
+
"""Maximize an acquisition function over the inputs, return (n, d).
|
|
30
|
+
|
|
31
|
+
``fixed_features`` pins inputs ``{position: value}``; optimizers that predate it
|
|
32
|
+
may not accept it, and policies only pass it when the spec has sources.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def optimize(
|
|
36
|
+
self,
|
|
37
|
+
acq: AcquisitionFunction,
|
|
38
|
+
spec: ProblemSpec,
|
|
39
|
+
n: int,
|
|
40
|
+
*,
|
|
41
|
+
fixed_features: dict[int, float] | None = None,
|
|
42
|
+
) -> torch.Tensor: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Above this many combinations of enumerated values, a mixed space is too big to
|
|
46
|
+
# enumerate per proposal; pass the combinations you care about to DiscreteArgmax.
|
|
47
|
+
MAX_MIXED_COMBINATIONS = 256
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Continuous:
|
|
51
|
+
"""Proposal via botorch ``optimize_acqf`` for any space with a continuous or
|
|
52
|
+
integer input. Integer inputs are optimized as continuous and rounded;
|
|
53
|
+
enumerated inputs alongside them are enumerated (``optimize_acqf_mixed``, one
|
|
54
|
+
sub-problem per combination of their values, up to
|
|
55
|
+
``MAX_MIXED_COMBINATIONS``). With ``log=True`` inputs (and no enumerated ones
|
|
56
|
+
to enumerate), the restarts start from the best of ``raw_samples`` log-uniform
|
|
57
|
+
designs, so the search covers every decade rather than mostly the top one."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, num_restarts: int = 10, raw_samples: int = 256) -> None:
|
|
60
|
+
self.num_restarts = num_restarts
|
|
61
|
+
self.raw_samples = raw_samples
|
|
62
|
+
|
|
63
|
+
def optimize(
|
|
64
|
+
self,
|
|
65
|
+
acq: AcquisitionFunction,
|
|
66
|
+
spec: ProblemSpec,
|
|
67
|
+
n: int,
|
|
68
|
+
*,
|
|
69
|
+
fixed_features: dict[int, float] | None = None,
|
|
70
|
+
) -> torch.Tensor:
|
|
71
|
+
from itertools import product
|
|
72
|
+
|
|
73
|
+
inputs = spec.inputs
|
|
74
|
+
unsupported = [
|
|
75
|
+
d.name for d in inputs
|
|
76
|
+
if not isinstance(d, (ContinuousDomain, IntegerDomain, EnumeratedDomain))
|
|
77
|
+
]
|
|
78
|
+
if unsupported:
|
|
79
|
+
raise NotImplementedError(
|
|
80
|
+
f"Continuous optimizer can't propose structured inputs {unsupported}."
|
|
81
|
+
)
|
|
82
|
+
fixed = dict(fixed_features or {})
|
|
83
|
+
integer = [j for j, d in enumerate(inputs) if isinstance(d, IntegerDomain)]
|
|
84
|
+
|
|
85
|
+
def round_integers(X: torch.Tensor) -> torch.Tensor:
|
|
86
|
+
if integer:
|
|
87
|
+
X = X.clone()
|
|
88
|
+
X[..., integer] = X[..., integer].round()
|
|
89
|
+
return X
|
|
90
|
+
|
|
91
|
+
enum = [
|
|
92
|
+
j for j, d in enumerate(inputs)
|
|
93
|
+
if isinstance(d, EnumeratedDomain) and j not in fixed
|
|
94
|
+
]
|
|
95
|
+
kwargs = {
|
|
96
|
+
"acq_function": acq,
|
|
97
|
+
"bounds": spec.bounds_tensor(),
|
|
98
|
+
"q": n,
|
|
99
|
+
"num_restarts": self.num_restarts,
|
|
100
|
+
"raw_samples": self.raw_samples,
|
|
101
|
+
"inequality_constraints": spec.inequality_constraints(),
|
|
102
|
+
"post_processing_func": round_integers,
|
|
103
|
+
}
|
|
104
|
+
if not enum:
|
|
105
|
+
if set(spec.log_indices()) - set(fixed):
|
|
106
|
+
kwargs["batch_initial_conditions"] = self._log_uniform_starts(acq, spec, n, fixed)
|
|
107
|
+
kwargs.pop("raw_samples")
|
|
108
|
+
candidates, _ = optimize_acqf(fixed_features=fixed or None, **kwargs)
|
|
109
|
+
return candidates.detach()
|
|
110
|
+
combos = list(product(*(inputs[j].codes for j in enum)))
|
|
111
|
+
if len(combos) > MAX_MIXED_COMBINATIONS:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"{len(combos)} combinations of enumerated values "
|
|
114
|
+
f"({[inputs[j].name for j in enum]}) is too many to enumerate per "
|
|
115
|
+
f"proposal (limit {MAX_MIXED_COMBINATIONS}); make some of them "
|
|
116
|
+
"continuous or integer, or use DiscreteArgmax(choices=...)."
|
|
117
|
+
)
|
|
118
|
+
fixed_list = [{**fixed, **dict(zip(enum, map(float, c)))} for c in combos]
|
|
119
|
+
candidates, _ = optimize_acqf_mixed(fixed_features_list=fixed_list, **kwargs)
|
|
120
|
+
return candidates.detach()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _log_uniform_starts(self, acq, spec: ProblemSpec, n: int, fixed: dict[int, float]):
|
|
124
|
+
"""``(num_restarts, n, d)`` starting batches: the best of ``raw_samples``
|
|
125
|
+
batches drawn by ``spec.sample_designs`` (log-uniform on log axes)."""
|
|
126
|
+
X = spec._sample(self.raw_samples * n, None, fixed).reshape(self.raw_samples, n, -1)
|
|
127
|
+
with torch.no_grad():
|
|
128
|
+
value = acq(X).reshape(-1)
|
|
129
|
+
best = value.nan_to_num(float("-inf")).topk(min(self.num_restarts, len(value))).indices
|
|
130
|
+
return X[best]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class DiscreteArgmax:
|
|
134
|
+
"""Enumerated proposal via botorch ``optimize_acqf_discrete``.
|
|
135
|
+
|
|
136
|
+
``choices`` is the (N, d) tensor of enumerable candidates (every input). When
|
|
137
|
+
omitted it is built as the Cartesian product of each enumerated or integer
|
|
138
|
+
input's values (small spaces only — large products should pass ``choices``
|
|
139
|
+
explicitly). ``fixed_features`` keeps only the choices that match.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(self, choices: torch.Tensor | None = None) -> None:
|
|
143
|
+
self.choices = choices
|
|
144
|
+
|
|
145
|
+
def _enumerate(self, spec: ProblemSpec) -> torch.Tensor:
|
|
146
|
+
from itertools import product
|
|
147
|
+
|
|
148
|
+
axes = []
|
|
149
|
+
for d in spec.inputs:
|
|
150
|
+
if isinstance(d, EnumeratedDomain):
|
|
151
|
+
axes.append(d.codes)
|
|
152
|
+
elif isinstance(d, IntegerDomain):
|
|
153
|
+
axes.append(list(range(d.lower, d.upper + 1)))
|
|
154
|
+
else:
|
|
155
|
+
raise TypeError(
|
|
156
|
+
"DiscreteArgmax without explicit `choices` requires every "
|
|
157
|
+
f"input to be enumerated or integer; {d.name!r} is not."
|
|
158
|
+
)
|
|
159
|
+
return torch.tensor(list(product(*axes)), dtype=torch.double)
|
|
160
|
+
|
|
161
|
+
def optimize(
|
|
162
|
+
self,
|
|
163
|
+
acq: AcquisitionFunction,
|
|
164
|
+
spec: ProblemSpec,
|
|
165
|
+
n: int,
|
|
166
|
+
*,
|
|
167
|
+
fixed_features: dict[int, float] | None = None,
|
|
168
|
+
) -> torch.Tensor:
|
|
169
|
+
if self.choices is None:
|
|
170
|
+
choices = self._enumerate(spec)
|
|
171
|
+
else: # only those the spec allows (with travel limits, the ones in reach)
|
|
172
|
+
choices = self.choices.double()
|
|
173
|
+
bounds = spec.bounds_tensor()
|
|
174
|
+
choices = choices[((choices >= bounds[0] - 1e-9) & (choices <= bounds[1] + 1e-9)).all(-1)]
|
|
175
|
+
if not len(choices):
|
|
176
|
+
raise ValueError(
|
|
177
|
+
"no choices lie within the bounds (with travel limits: within "
|
|
178
|
+
"max_step of the last design)"
|
|
179
|
+
)
|
|
180
|
+
choices = choices.double()
|
|
181
|
+
for j, value in (fixed_features or {}).items():
|
|
182
|
+
choices = choices[torch.isclose(choices[:, j], torch.tensor(value).double())]
|
|
183
|
+
if not len(choices):
|
|
184
|
+
raise ValueError(f"no choices match the fixed inputs {fixed_features}")
|
|
185
|
+
candidates, _ = optimize_acqf_discrete(acq, q=n, choices=choices)
|
|
186
|
+
return candidates.detach()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def optimize(optimizer, acq, spec: ProblemSpec, n: int, fixed_features=None) -> torch.Tensor:
|
|
190
|
+
"""``optimizer.optimize``, passing ``fixed_features`` only when there are some,
|
|
191
|
+
so custom optimizers written before it existed keep working without sources."""
|
|
192
|
+
if fixed_features:
|
|
193
|
+
return optimizer.optimize(acq, spec, n, fixed_features=fixed_features)
|
|
194
|
+
return optimizer.optimize(acq, spec, n)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def default_optimizer(spec: ProblemSpec) -> CandidateOptimizer:
|
|
198
|
+
"""Pick the CandidateOptimizer the spec supports.
|
|
199
|
+
|
|
200
|
+
- all-enumerated inputs -> ``DiscreteArgmax`` (``optimize_acqf_discrete``)
|
|
201
|
+
- otherwise -> ``Continuous`` (``optimize_acqf``; integers rounded,
|
|
202
|
+
enumerated inputs alongside continuous ones enumerated)
|
|
203
|
+
"""
|
|
204
|
+
if all(isinstance(d, EnumeratedDomain) for d in spec.inputs):
|
|
205
|
+
return DiscreteArgmax()
|
|
206
|
+
return Continuous()
|
abbot/analysis.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Live-analysis built-ins, computed from a campaign's history + fitted model.
|
|
2
|
+
|
|
3
|
+
These are pure functions of ``(History, spec[, model])`` so they are unit-testable
|
|
4
|
+
without a UI and reusable by a notebook or an agent. The UI registers them through
|
|
5
|
+
``Campaign``'s ``on_step`` hook and renders the results; callers can register their
|
|
6
|
+
own analyses the same way. Kept deliberately small — the general-purpose "any
|
|
7
|
+
analysis" surface is the hook itself; these are just the common basics.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import torch
|
|
15
|
+
|
|
16
|
+
from abbot.history import History
|
|
17
|
+
from abbot.problem import ProblemSpec
|
|
18
|
+
from abbot.session import canonical_Y, observed_front
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from abbot.policy import BAXPolicy
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _counts(r, spec: ProblemSpec) -> bool:
|
|
25
|
+
"""A record counts toward the best so far: clean, measured, and satisfying
|
|
26
|
+
every output constraint."""
|
|
27
|
+
if not r.usable or r.outputs is None:
|
|
28
|
+
return False
|
|
29
|
+
if not spec.has_output_constraints:
|
|
30
|
+
return True
|
|
31
|
+
from abbot.feasibility import feasible
|
|
32
|
+
|
|
33
|
+
return bool(feasible(spec, r.outputs.double().reshape(1, -1))[0])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def convergence(history: History, spec: ProblemSpec) -> list[float]:
|
|
37
|
+
"""Running best objective value vs. step, in RAW units (single-objective only).
|
|
38
|
+
|
|
39
|
+
Element ``i`` is the best clean observed objective among the first ``i+1``
|
|
40
|
+
records, respecting direction (max of raw for MAXIMIZE, min for MINIMIZE).
|
|
41
|
+
Faulted records carry forward the previous best, and so do records that break
|
|
42
|
+
an output constraint (NaN until one satisfies them).
|
|
43
|
+
"""
|
|
44
|
+
if spec.n_objectives != 1:
|
|
45
|
+
raise ValueError("convergence is single-objective; use hypervolume for MO.")
|
|
46
|
+
col = spec.objective_indices()[0] # objective may not be column 0 (OBSERVE outputs)
|
|
47
|
+
maximize = spec.objectives[0].direction == "MAXIMIZE"
|
|
48
|
+
best = None
|
|
49
|
+
out: list[float] = []
|
|
50
|
+
for r in history.records:
|
|
51
|
+
if _counts(r, spec):
|
|
52
|
+
v = float(r.outputs.reshape(-1)[col])
|
|
53
|
+
if best is None:
|
|
54
|
+
best = v
|
|
55
|
+
else:
|
|
56
|
+
best = max(best, v) if maximize else min(best, v)
|
|
57
|
+
out.append(best if best is not None else float("nan"))
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def hypervolume(history: History, spec: ProblemSpec) -> list[float]:
|
|
62
|
+
"""Dominated hypervolume of the observed canonical outputs vs. step (MO).
|
|
63
|
+
|
|
64
|
+
Uses botorch's exact ``Hypervolume`` against an inferred reference point (from
|
|
65
|
+
the final canonical set, so the series is comparable across steps). Returns a
|
|
66
|
+
per-step list; steps before any clean point are 0.0. With output
|
|
67
|
+
constraints, only records that satisfy them count.
|
|
68
|
+
"""
|
|
69
|
+
if spec.n_objectives < 2:
|
|
70
|
+
raise ValueError("hypervolume needs >=2 objectives; use convergence for 1.")
|
|
71
|
+
from botorch.utils.multi_objective.hypervolume import (
|
|
72
|
+
Hypervolume,
|
|
73
|
+
infer_reference_point,
|
|
74
|
+
)
|
|
75
|
+
from botorch.utils.multi_objective.pareto import is_non_dominated
|
|
76
|
+
|
|
77
|
+
clean = [r for r in history.records if _counts(r, spec)]
|
|
78
|
+
if not clean:
|
|
79
|
+
return [0.0] * len(history.records)
|
|
80
|
+
# Objective columns in canonical space, one row per clean record (same rule
|
|
81
|
+
# everywhere via spec.objective_view).
|
|
82
|
+
all_Y = spec.objective_view(torch.stack([r.outputs.reshape(-1) for r in clean]))
|
|
83
|
+
ref = infer_reference_point(all_Y[is_non_dominated(all_Y)])
|
|
84
|
+
hv = Hypervolume(ref_point=ref)
|
|
85
|
+
|
|
86
|
+
out: list[float] = []
|
|
87
|
+
acc: list[torch.Tensor] = []
|
|
88
|
+
for r in history.records:
|
|
89
|
+
if _counts(r, spec):
|
|
90
|
+
acc.append(spec.objective_view(r.outputs.reshape(1, -1))[0])
|
|
91
|
+
if acc:
|
|
92
|
+
Y = torch.stack(acc)
|
|
93
|
+
front = Y[is_non_dominated(Y)]
|
|
94
|
+
try:
|
|
95
|
+
out.append(float(hv.compute(front)))
|
|
96
|
+
except Exception: # noqa: BLE001 - a degenerate front -> HV 0, keep the series going
|
|
97
|
+
out.append(0.0)
|
|
98
|
+
else:
|
|
99
|
+
out.append(0.0)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def recommended_set(history: History, spec: ProblemSpec, model=None) -> list[int]:
|
|
104
|
+
"""Current recommended handover indices.
|
|
105
|
+
|
|
106
|
+
If a fitted ``model`` is given, uses the denoised posterior-mean recommendation
|
|
107
|
+
(via the policy path is the caller's job); here we expose the model-free
|
|
108
|
+
observed non-dominated set, which is also the loop's fallback.
|
|
109
|
+
"""
|
|
110
|
+
return observed_front(history, spec)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- model visualization ------------------------------------------------------
|
|
114
|
+
# The surrogate is fit in maximize-canonical space (see session.canonical_Y), so
|
|
115
|
+
# these un-apply the objective signs to report posterior values in RAW units.
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _posterior_raw(model, X: torch.Tensor, spec: ProblemSpec):
|
|
119
|
+
"""Posterior (mean, std) at X over ALL outputs, converted back to RAW units.
|
|
120
|
+
|
|
121
|
+
Returns tensors shaped (n, n_outputs). The sign flip maps canonical -> raw for
|
|
122
|
+
the mean (OBSERVE columns pass through); std is sign-invariant.
|
|
123
|
+
"""
|
|
124
|
+
with torch.no_grad():
|
|
125
|
+
post = model.posterior(X.double())
|
|
126
|
+
mean = spec.to_raw(post.mean.reshape(X.shape[0], -1)) # canonical -> raw
|
|
127
|
+
std = post.variance.clamp_min(1e-12).sqrt().reshape(X.shape[0], -1)
|
|
128
|
+
return mean, std
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parity(history: History, spec: ProblemSpec, model) -> dict:
|
|
132
|
+
"""Predicted (posterior mean ± std) vs. observed, per OUTPUT — dimension-agnostic.
|
|
133
|
+
|
|
134
|
+
Returns ``{output_name: {"observed": [...], "predicted": [...], "std": [...]}}``
|
|
135
|
+
for every modeled output (incl. OBSERVE). The go-to "is the model any good?"
|
|
136
|
+
view when the design space is too high-D to plot.
|
|
137
|
+
"""
|
|
138
|
+
clean = history.clean
|
|
139
|
+
if not len(clean):
|
|
140
|
+
return {}
|
|
141
|
+
X = clean.designs.double()
|
|
142
|
+
Y = clean.outputs.double() # raw observed, full width
|
|
143
|
+
mean, std = _posterior_raw(model, X, spec)
|
|
144
|
+
out = {}
|
|
145
|
+
for j, o in enumerate(spec.outputs):
|
|
146
|
+
out[o.name] = {
|
|
147
|
+
"observed": Y[:, j].tolist(),
|
|
148
|
+
"predicted": mean[:, j].tolist(),
|
|
149
|
+
"std": std[:, j].tolist(),
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _anchor_point(history: History, spec: ProblemSpec) -> torch.Tensor:
|
|
155
|
+
"""The design to hold the non-varied dimensions at: the recommended/best point."""
|
|
156
|
+
rec = observed_front(history, spec)
|
|
157
|
+
idx = rec[0] if rec else 0
|
|
158
|
+
return history.records[idx].candidate.reshape(-1).double()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def slice_1d(
|
|
162
|
+
history: History,
|
|
163
|
+
spec: ProblemSpec,
|
|
164
|
+
model,
|
|
165
|
+
var: int = 0,
|
|
166
|
+
output: int = 0,
|
|
167
|
+
n: int = 80,
|
|
168
|
+
) -> dict:
|
|
169
|
+
"""1-D posterior slice: vary one variable over its range, others fixed at the
|
|
170
|
+
recommended point. ``output`` indexes ``spec.outputs``. Returns grid ``x``, raw
|
|
171
|
+
``mean``, and ``std`` for a band.
|
|
172
|
+
"""
|
|
173
|
+
bounds = spec.bounds_tensor()
|
|
174
|
+
lo, hi = float(bounds[0, var]), float(bounds[1, var])
|
|
175
|
+
anchor = _anchor_point(history, spec)
|
|
176
|
+
xs = torch.linspace(lo, hi, n, dtype=torch.double)
|
|
177
|
+
grid = anchor.repeat(n, 1)
|
|
178
|
+
grid[:, var] = xs
|
|
179
|
+
mean, std = _posterior_raw(model, grid, spec)
|
|
180
|
+
return {
|
|
181
|
+
"x": xs.tolist(),
|
|
182
|
+
"mean": mean[:, output].tolist(),
|
|
183
|
+
"std": std[:, output].tolist(),
|
|
184
|
+
"var_name": spec.variable_names[var],
|
|
185
|
+
"objective_name": spec.outputs[output].name,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def slice_2d(
|
|
190
|
+
history: History,
|
|
191
|
+
spec: ProblemSpec,
|
|
192
|
+
model,
|
|
193
|
+
var_x: int = 0,
|
|
194
|
+
var_y: int = 1,
|
|
195
|
+
output: int = 0,
|
|
196
|
+
n: int = 40,
|
|
197
|
+
) -> dict:
|
|
198
|
+
"""2-D posterior-mean slice: vary two variables over a grid, others fixed at the
|
|
199
|
+
recommended point. ``output`` indexes ``spec.outputs``. Returns ``x``, ``y``
|
|
200
|
+
axes and a ``z`` matrix (raw mean).
|
|
201
|
+
"""
|
|
202
|
+
bounds = spec.bounds_tensor()
|
|
203
|
+
xs = torch.linspace(float(bounds[0, var_x]), float(bounds[1, var_x]), n, dtype=torch.double)
|
|
204
|
+
ys = torch.linspace(float(bounds[0, var_y]), float(bounds[1, var_y]), n, dtype=torch.double)
|
|
205
|
+
anchor = _anchor_point(history, spec)
|
|
206
|
+
gx, gy = torch.meshgrid(xs, ys, indexing="xy")
|
|
207
|
+
grid = anchor.repeat(n * n, 1)
|
|
208
|
+
grid[:, var_x] = gx.reshape(-1)
|
|
209
|
+
grid[:, var_y] = gy.reshape(-1)
|
|
210
|
+
mean, _ = _posterior_raw(model, grid, spec)
|
|
211
|
+
z = mean[:, output].reshape(n, n)
|
|
212
|
+
return {
|
|
213
|
+
"x": xs.tolist(),
|
|
214
|
+
"y": ys.tolist(),
|
|
215
|
+
"z": z.tolist(),
|
|
216
|
+
"var_x_name": spec.variable_names[var_x],
|
|
217
|
+
"var_y_name": spec.variable_names[var_y],
|
|
218
|
+
"objective_name": spec.outputs[output].name,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def bax_target_region(
|
|
223
|
+
policy: BAXPolicy, model, spec: ProblemSpec
|
|
224
|
+
) -> dict[str, int]:
|
|
225
|
+
"""Snapshot of the BAX discovered-target region on the current posterior mean.
|
|
226
|
+
|
|
227
|
+
Returns ``{"identified": int, "grid_size": int}``: how many candidate-grid
|
|
228
|
+
points the policy's goal selects from the posterior-MEAN prediction (the size
|
|
229
|
+
of the region found so far — should grow then stabilize), and the grid size.
|
|
230
|
+
When the policy applies output constraints, ``identified`` counts what the goal
|
|
231
|
+
selects among points predicted to satisfy them, and ``"before_constraints"``
|
|
232
|
+
what it selects from every point, so the narrowing is visible.
|
|
233
|
+
Pure function of ``(policy, model, spec)``; encapsulates grid + raw-posterior +
|
|
234
|
+
``goal.identify_subspace`` so no BAX internals leak to the caller.
|
|
235
|
+
"""
|
|
236
|
+
grid = policy.candidate_grid(spec)
|
|
237
|
+
mean, _ = _posterior_raw(model, grid, spec)
|
|
238
|
+
out = {
|
|
239
|
+
"identified": len(policy._identify(spec, mean, grid)),
|
|
240
|
+
"grid_size": int(grid.shape[0]),
|
|
241
|
+
}
|
|
242
|
+
if policy._constrains(spec):
|
|
243
|
+
out["before_constraints"] = len(policy._goal_for(spec).identify_subspace(mean, grid))
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
__all__ = [
|
|
248
|
+
"bax_target_region",
|
|
249
|
+
"canonical_Y",
|
|
250
|
+
"convergence",
|
|
251
|
+
"hypervolume",
|
|
252
|
+
"parity",
|
|
253
|
+
"recommended_set",
|
|
254
|
+
"slice_1d",
|
|
255
|
+
"slice_2d",
|
|
256
|
+
]
|