Skip to content

Commit 53880c9

Browse files
TobyBoynejduerholtclaude
authored
Support Multi-Output, Multi-Fidelity optimization (#705)
* Create base classes; split TaskInput into Categorical and Continuous * Create separate MFKGStrategy * Initial sketch of fidelity kernel and pydantic checks * Create default downsampling kernel for MFHVKG strategy * Add MOMF benchmark, and minor fixes to data model * Fix some typing issues * Fix testing bugs with (Cat)TaskInputs * Fix (Cat)TaskInput in botorch data model * Add MFHVKG specs * Add NotImplementedError for cat task inputs * Create MFHVKG test case * Fix init points function not taking `generator` arg * Create simple test to evaluate qMFHVKG strategy * Use deterministic surrogate as fidelity cost model * Revert fidelity cost to Input * Add fidelity costs to CategoricalTaskInput * DiscreteTaskInput and related changes * Default and validation of the cost task model * Create some test specs * Update test to reflect changes * Fix sampler and objective causing bugs with output shapes * Subset model outputs so that fantasize works * Minor tweaks to tests * Better handling of fixing task fidelities * Create test for objective in fidelity output * Improve fixing inputs and filtering outputs * First draft of tutorial * Remove discrete task input * Separate cost model from surrogate * Fix tests * Fix example notebook, and give info about cost model * Clean up spec tests * Explore benchmark in notebook * Fix more tests * Add `d` argument for botorch v0.17 * Add WedgeKernel to aggregation (allowing for conditional+mfkvkg) * Remove Fidelity kernel from kernel types * Refactor `_generate_specs_surrogates` * Rename `MultiFidelityStrategy` * Remove unused code * Add `current_value` docstring; replace `project` with partial * Minor cleanups * Fix bug in default specs * Tighten registration of MF data models and add missing specs - Mark `TaskInput` and `FidelityKernel` as abstract via `type: Any` with docstrings, so they no longer pretend to be concrete classes outside the `AnyFeature`/`AnyKernel` unions. - Add a valid spec for `ContinuousTaskInput` so the serialization roundtrip test actually exercises it. - Wire `MultiFidelityVarianceBasedStrategy` and `MultiFidelityHVKGStrategy` into the `AnyPredictive` union. - Add a `validate_ref_point` validator to `MultiFidelityHVKGStrategy` matching `MoboStrategy`, so `dict[str, float]` and `None` reference points are normalized to `ExplicitReferencePoint` before the functional class consumes them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * `MFHVKGStrategy` now inherits from `MoboStrategy` * Remove `validate_ref_point` from `MFHVKG` * Revert PEP585 type annotation --------- Co-authored-by: Johannes P. Dürholt <johannespeter.duerholt@evonik.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4653868 commit 53880c9

46 files changed

Lines changed: 1456 additions & 214 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

_quarto.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ quartodoc:
6262
- BotorchStrategy
6363
- EntingStrategy
6464
- MoboStrategy
65-
- MultiFidelityStrategy
65+
- MultiFidelityVarianceBasedStrategy
6666
- MultiobjectiveStrategy
6767
- AdditiveSoboStrategy
6868
- CustomSoboStrategy
@@ -116,7 +116,7 @@ quartodoc:
116116
- MoboStrategy
117117
- BotorchStrategy
118118
- EntingStrategy
119-
- MultiFidelityStrategy
119+
- MultiFidelityVarianceBasedStrategy
120120
- ActiveLearningStrategy
121121
- ShortestPathStrategy
122122
- StepwiseStrategy

bofire/benchmarks/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
TNK,
1717
ZDT1,
1818
CrossCoupling,
19+
MOMFBraninCurrin,
1920
SnarBenchmark,
2021
)
2122
from bofire.benchmarks.single import (
@@ -39,6 +40,7 @@
3940
SnarBenchmark,
4041
BNH,
4142
TNK,
43+
MOMFBraninCurrin,
4244
]
4345
AnySingleBenchmark = Union[
4446
Ackley,

bofire/benchmarks/multi.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
CategoricalDescriptorInput,
2323
ContinuousInput,
2424
ContinuousOutput,
25+
ContinuousTaskInput,
2526
Input,
2627
)
2728
from bofire.data_models.objectives.api import (
@@ -700,3 +701,63 @@ def _get_base_cost(self, base, mmol_base):
700701
"TEA": 0.01,
701702
}
702703
return float(base_prices[base] * mmol_base)
704+
705+
706+
class MOMFBraninCurrin(Benchmark):
707+
def __init__(self, **kwargs):
708+
inputs = Inputs(
709+
features=[
710+
*(ContinuousInput(key=f"x{i}", bounds=(0.0, 1.0)) for i in range(2)),
711+
ContinuousTaskInput(key="fidelity", bounds=(0.0, 1.0)),
712+
]
713+
)
714+
715+
outputs = Outputs(
716+
features=[
717+
ContinuousOutput(key="branin", objective=MinimizeObjective()),
718+
ContinuousOutput(key="currin", objective=MinimizeObjective()),
719+
]
720+
)
721+
722+
self._domain = Domain(
723+
inputs=inputs,
724+
outputs=outputs,
725+
)
726+
727+
super().__init__(**kwargs)
728+
729+
def _branin(self, X: np.ndarray) -> np.ndarray:
730+
x1 = X[..., 0]
731+
x2 = X[..., 1]
732+
s = X[..., 2]
733+
734+
x11 = 15 * x1 - 5
735+
x22 = 15 * x2
736+
b = 5.1 / (4 * math.pi**2) - 0.01 * (1 - s)
737+
c = 5 / math.pi - 0.1 * (1 - s)
738+
r = 6
739+
t = 1 / (8 * math.pi) + 0.05 * (1 - s)
740+
y = (x22 - b * x11**2 + c * x11 - r) ** 2 + 10 * (1 - t) * np.cos(x11) + 10
741+
B = 21 - y
742+
return B / 22
743+
744+
def _currin(self, X: np.ndarray) -> np.ndarray:
745+
x1 = X[..., 0]
746+
x2 = X[..., 1]
747+
s = X[..., 2]
748+
A = 2300 * x1**3 + 1900 * x1**2 + 2092 * x1 + 60
749+
B = 100 * x1**3 + 500 * x1**2 + 4 * x1 + 20
750+
y = (1 - 0.1 * (1 - s) * np.exp(-1 / (2 * x2))) * A / B
751+
C = -y + 14
752+
return C / 15
753+
754+
def _f(self, candidates: pd.DataFrame) -> pd.DataFrame:
755+
X = candidates.to_numpy()
756+
return pd.DataFrame(
757+
{
758+
"branin": self._branin(X),
759+
"valid_branin": 1,
760+
"currin": self._currin(X),
761+
"valid_currin": 1,
762+
}
763+
)

bofire/benchmarks/single.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@
1515
from bofire.data_models.features.api import (
1616
CategoricalDescriptorInput,
1717
CategoricalInput,
18+
CategoricalTaskInput,
1819
ContinuousInput,
1920
ContinuousOutput,
2021
DiscreteInput,
21-
TaskInput,
2222
)
2323
from bofire.data_models.objectives.api import MaximizeObjective, MinimizeObjective
2424
from bofire.utils.torch_tools import tkwargs
@@ -487,7 +487,9 @@ def __init__(self, use_constraints: bool = False, **kwargs):
487487
self.use_constraints = use_constraints
488488
inputs = []
489489

490-
inputs.append(TaskInput(key="task_id", categories=["task_1", "task_2"]))
490+
inputs.append(
491+
CategoricalTaskInput(key="task_id", categories=["task_1", "task_2"])
492+
)
491493
inputs.append(ContinuousInput(key="x_1", bounds=[-6, 6]))
492494
inputs.append(ContinuousInput(key="x_2", bounds=[-6, 6]))
493495

bofire/data_models/acquisition_functions/acquisition_function.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ class qLogNEHVI(MultiObjectiveAcquisitionFunction):
8383
n_mc_samples: IntPowerOfTwo = 512
8484

8585

86+
class qMFHVKG(MultiObjectiveAcquisitionFunction):
87+
type: Literal["qMFHVKG"] = "qMFHVKG"
88+
alpha: Annotated[float, Field(ge=0)] = 0.0
89+
num_fantasies: int = 8
90+
num_pareto: int = 10
91+
n_mc_samples: IntPowerOfTwo = 32
92+
93+
8694
class qNegIntPosVar(SingleObjectiveAcquisitionFunction):
8795
type: Literal["qNegIntPosVar"] = "qNegIntPosVar"
8896
n_mc_samples: IntPowerOfTwo = 512

bofire/data_models/acquisition_functions/api.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
qLogNEHVI,
1010
qLogNEI,
1111
qLogPF,
12+
qMFHVKG,
1213
qNegIntPosVar,
1314
qNEHVI,
1415
qNEI,
@@ -31,6 +32,7 @@
3132
qLogEHVI,
3233
qNEHVI,
3334
qLogNEHVI,
35+
qMFHVKG,
3436
qNegIntPosVar,
3537
qLogPF,
3638
)
@@ -39,6 +41,8 @@
3941
qNEI, qEI, qSR, qUCB, qPI, qLogEI, qLogNEI, qLogPF
4042
)
4143

42-
AnyMultiObjectiveAcquisitionFunction = tagged_union(qEHVI, qLogEHVI, qNEHVI, qLogNEHVI)
44+
AnyMultiObjectiveAcquisitionFunction = tagged_union(
45+
qEHVI, qLogEHVI, qNEHVI, qLogNEHVI, qMFHVKG
46+
)
4347

4448
AnyActiveLearningAcquisitionFunction = qNegIntPosVar

bofire/data_models/domain/features.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@
3737
CategoricalInput,
3838
CategoricalMolecularInput,
3939
CategoricalOutput,
40+
CategoricalTaskInput,
4041
ContinuousInput,
4142
ContinuousOutput,
4243
DiscreteInput,
4344
Feature,
4445
Input,
4546
Output,
46-
TaskInput,
4747
)
4848
from bofire.data_models.features.feature import get_encoded_name
4949
from bofire.data_models.filters import filter_by_attribute, filter_by_class
@@ -350,12 +350,14 @@ class Inputs(_BaseFeatures[AnyInput]):
350350
def validate_only_one_task_input(cls, features: Sequence[AnyInput]):
351351
filtered = filter_by_class(
352352
features,
353-
includes=TaskInput,
353+
includes=CategoricalTaskInput,
354354
excludes=None,
355355
exact=False,
356356
)
357357
if len(filtered) > 1:
358-
raise ValueError(f"Only one `TaskInput` is allowed, got {len(filtered)}.")
358+
raise ValueError(
359+
f"Only one `CategoricalTaskInput` is allowed, got {len(filtered)}."
360+
)
359361
return features
360362

361363
def get_fixed(self) -> Inputs:

bofire/data_models/features/api.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
ContinuousMolecularInput,
2525
)
2626
from bofire.data_models.features.numerical import NumericalInput
27-
from bofire.data_models.features.task import TaskInput
27+
from bofire.data_models.features.task import (
28+
CategoricalTaskInput,
29+
ContinuousTaskInput,
30+
TaskInput,
31+
)
2832
from bofire.data_models.unions import tagged_union
2933

3034

@@ -37,7 +41,8 @@
3741
ContinuousDescriptorInput,
3842
CategoricalDescriptorInput,
3943
CategoricalMolecularInput,
40-
TaskInput,
44+
CategoricalTaskInput,
45+
ContinuousTaskInput,
4146
SumFeature,
4247
MeanFeature,
4348
WeightedMeanFeature,
@@ -57,7 +62,8 @@
5762
ContinuousDescriptorInput,
5863
CategoricalDescriptorInput,
5964
CategoricalMolecularInput,
60-
TaskInput,
65+
ContinuousTaskInput,
66+
CategoricalTaskInput,
6167
ContinuousMolecularInput,
6268
)
6369

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
1-
from typing import ClassVar, List, Literal
1+
from typing import Any, ClassVar, Literal
22

33
import numpy as np
44
from pydantic import model_validator
55

66
from bofire.data_models.features.categorical import CategoricalInput
7+
from bofire.data_models.features.continuous import ContinuousInput
8+
from bofire.data_models.features.feature import Input
79

810

9-
class TaskInput(CategoricalInput):
11+
class TaskInput(Input):
12+
"""Abstract base class for task-encoding inputs.
13+
14+
This class is not directly instantiable and is not part of any
15+
``AnyFeature``/``AnyInput`` union. Use :class:`CategoricalTaskInput` or
16+
:class:`ContinuousTaskInput` instead. It exists solely so that strategies
17+
can use ``isinstance(feat, TaskInput)`` to detect either flavour.
18+
"""
19+
20+
type: Any
21+
22+
23+
class CategoricalTaskInput(TaskInput, CategoricalInput):
1024
order_id: ClassVar[int] = 8
11-
type: Literal["TaskInput"] = "TaskInput"
12-
fidelities: List[int] = []
25+
type: Literal["CategoricalTaskInput"] = "CategoricalTaskInput"
26+
fidelities: list[int] = []
1327

1428
@model_validator(mode="after")
1529
def validate_fidelities(self):
@@ -19,10 +33,15 @@ def validate_fidelities(self):
1933
self.fidelities.append(0)
2034
if len(self.fidelities) != n_tasks:
2135
raise ValueError(
22-
"Length of fidelity lists must be equal to the number of tasks",
36+
"Length of fidelity list must be equal to the number of tasks",
2337
)
2438
if list(set(self.fidelities)) != list(range(np.max(self.fidelities) + 1)):
2539
raise ValueError(
2640
"Fidelities must be a list containing integers, starting from 0 and increasing by 1",
2741
)
2842
return self
43+
44+
45+
class ContinuousTaskInput(TaskInput, ContinuousInput):
46+
order_id: ClassVar[int] = 11
47+
type: Literal["ContinuousTaskInput"] = "ContinuousTaskInput" # type: ignore

bofire/data_models/kernels/aggregation.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
IndexKernel,
77
PositiveIndexKernel,
88
)
9+
from bofire.data_models.kernels.conditional import WedgeKernel
910
from bofire.data_models.kernels.continuous import (
1011
InfiniteWidthBNNKernel,
1112
LinearKernel,
@@ -14,6 +15,7 @@
1415
RBFKernel,
1516
SphericalLinearKernel,
1617
)
18+
from bofire.data_models.kernels.fidelity import DownsamplingKernel
1719
from bofire.data_models.kernels.kernel import AggregationKernel
1820
from bofire.data_models.kernels.molecular import TanimotoKernel
1921
from bofire.data_models.kernels.shape import WassersteinKernel
@@ -32,6 +34,8 @@ class AdditiveKernel(AggregationKernel):
3234
IndexKernel,
3335
PositiveIndexKernel,
3436
TanimotoKernel,
37+
DownsamplingKernel,
38+
WedgeKernel,
3539
"AdditiveKernel",
3640
"MultiplicativeKernel",
3741
"ScaleKernel",
@@ -53,6 +57,8 @@ class MultiplicativeKernel(AggregationKernel):
5357
PositiveIndexKernel,
5458
AdditiveKernel,
5559
TanimotoKernel,
60+
DownsamplingKernel,
61+
WedgeKernel,
5662
"MultiplicativeKernel",
5763
"ScaleKernel",
5864
]
@@ -72,6 +78,8 @@ class ScaleKernel(AggregationKernel):
7278
AdditiveKernel,
7379
MultiplicativeKernel,
7480
TanimotoKernel,
81+
DownsamplingKernel,
82+
WedgeKernel,
7583
"ScaleKernel",
7684
WassersteinKernel,
7785
]

0 commit comments

Comments
 (0)