Skip to content

Commit 33a2053

Browse files
authored
Universal constraint sampler (#328)
1 parent 591401c commit 33a2053

File tree

7 files changed

+178
-1
lines changed

7 files changed

+178
-1
lines changed

bofire/data_models/strategies/api.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
from bofire.data_models.strategies.samplers.polytope import PolytopeSampler
2222
from bofire.data_models.strategies.samplers.rejection import RejectionSampler
2323
from bofire.data_models.strategies.samplers.sampler import SamplerStrategy
24+
from bofire.data_models.strategies.samplers.universal_constraint import (
25+
UniversalConstraintSampler,
26+
)
2427
from bofire.data_models.strategies.stepwise.conditions import ( # noqa: F401
2528
AlwaysTrueCondition,
2629
CombiCondition,
@@ -50,6 +53,7 @@
5053
QparegoStrategy,
5154
PolytopeSampler,
5255
RejectionSampler,
56+
UniversalConstraintSampler,
5357
RandomStrategy,
5458
DoEStrategy,
5559
StepwiseStrategy,
@@ -68,7 +72,7 @@
6872
MoboStrategy,
6973
]
7074

71-
AnySampler = Union[PolytopeSampler, RejectionSampler]
75+
AnySampler = Union[PolytopeSampler, RejectionSampler, UniversalConstraintSampler]
7276

7377

7478
AnyCondition = Union[NumberOfExperimentsCondition, CombiCondition, AlwaysTrueCondition]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from typing import Annotated, Literal, Type
2+
3+
from pydantic import Field
4+
5+
from bofire.data_models.constraints.api import (
6+
LinearEqualityConstraint,
7+
LinearInequalityConstraint,
8+
NChooseKConstraint,
9+
NonlinearEqualityConstraint,
10+
NonlinearInequalityConstraint,
11+
)
12+
from bofire.data_models.features.api import (
13+
ContinuousInput,
14+
ContinuousOutput,
15+
Feature,
16+
)
17+
from bofire.data_models.strategies.strategy import Strategy
18+
19+
20+
class UniversalConstraintSampler(Strategy):
21+
"""Sampler that generates samples by optimization in IPOPT.
22+
23+
Attributes:
24+
domain (Domain): Domain defining the constrained input space
25+
sampling_fraction (float, optional): Fraction of sampled points to total points generated in
26+
the sampling process. Defaults to 0.3.
27+
ipopt_options (dict, optional): Dictionary containing options for the IPOPT solver. Defaults to {"maxiter":200, "disp"=0}.
28+
"""
29+
30+
type: Literal["UniversalConstraintSampler"] = "UniversalConstraintSampler"
31+
sampling_fraction: Annotated[float, Field(gt=0, lt=1)] = 0.3
32+
ipopt_options: dict = {"maxiter": 200, "disp": 0}
33+
34+
@classmethod
35+
def is_constraint_implemented(cls, my_type: Type[Feature]) -> bool:
36+
return my_type in [
37+
LinearEqualityConstraint,
38+
LinearInequalityConstraint,
39+
NonlinearInequalityConstraint,
40+
NonlinearEqualityConstraint,
41+
NChooseKConstraint,
42+
]
43+
44+
@classmethod
45+
def is_feature_implemented(cls, my_type: Type[Feature]) -> bool:
46+
return my_type in [
47+
ContinuousInput,
48+
ContinuousOutput,
49+
]

bofire/strategies/api.py

+3
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,8 @@
1515
from bofire.strategies.samplers.polytope import PolytopeSampler # noqa: F401
1616
from bofire.strategies.samplers.rejection import RejectionSampler # noqa: F401
1717
from bofire.strategies.samplers.sampler import SamplerStrategy # noqa: F401
18+
from bofire.strategies.samplers.universal_constraint import ( # noqa: F401
19+
UniversalConstraintSampler,
20+
)
1821
from bofire.strategies.stepwise.stepwise import StepwiseStrategy # noqa: F401
1922
from bofire.strategies.strategy import Strategy # noqa: F401

bofire/strategies/mapper.py

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
from bofire.strategies.samplers.polytope import PolytopeSampler # noqa: F401
2020
from bofire.strategies.samplers.rejection import RejectionSampler # noqa: F401
2121
from bofire.strategies.samplers.sampler import SamplerStrategy # noqa: F401
22+
from bofire.strategies.samplers.universal_constraint import ( # noqa: F401
23+
UniversalConstraintSampler,
24+
)
2225
from bofire.strategies.stepwise.stepwise import StepwiseStrategy
2326
from bofire.strategies.strategy import Strategy # noqa: F401
2427

@@ -33,6 +36,7 @@
3336
data_models.QparegoStrategy: QparegoStrategy,
3437
data_models.PolytopeSampler: PolytopeSampler,
3538
data_models.RejectionSampler: RejectionSampler,
39+
data_models.UniversalConstraintSampler: UniversalConstraintSampler,
3640
data_models.DoEStrategy: DoEStrategy,
3741
data_models.StepwiseStrategy: StepwiseStrategy,
3842
data_models.FactorialStrategy: FactorialStrategy,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import pandas as pd
2+
3+
from bofire.data_models.strategies.api import UniversalConstraintSampler as DataModel
4+
from bofire.strategies.doe.design import find_local_max_ipopt
5+
from bofire.strategies.enum import OptimalityCriterionEnum
6+
from bofire.strategies.strategy import Strategy
7+
8+
9+
class UniversalConstraintSampler(Strategy):
10+
"""Sampler that generates samples by optimization in IPOPT.
11+
12+
Attributes:
13+
domain (Domain): Domain defining the constrained input space
14+
sampling_fraction (float, optional): Fraction of sampled points to total points generated in
15+
the sampling process. Defaults to 0.3.
16+
ipopt_options (dict, optional): Dictionary containing options for the IPOPT solver. Defaults to {"maxiter":200, "disp"=0}.
17+
"""
18+
19+
def __init__(
20+
self,
21+
data_model: DataModel,
22+
**kwargs,
23+
):
24+
super().__init__(data_model=data_model, **kwargs)
25+
assert data_model.sampling_fraction > 0 and data_model.sampling_fraction <= 1
26+
self.sampling_fraction = data_model.sampling_fraction
27+
self.ipopt_options = data_model.ipopt_options
28+
29+
def _ask(self, candidate_count: int) -> pd.DataFrame:
30+
samples = find_local_max_ipopt(
31+
domain=self.domain,
32+
model_type="linear", # dummy model
33+
n_experiments=self.num_candidates
34+
+ int(candidate_count / self.sampling_fraction),
35+
ipopt_options=self.ipopt_options,
36+
objective=OptimalityCriterionEnum.SPACE_FILLING,
37+
fixed_experiments=self.candidates,
38+
)
39+
40+
samples = samples.iloc[
41+
self.num_candidates :,
42+
]
43+
samples = samples.sample(
44+
n=candidate_count,
45+
replace=False,
46+
ignore_index=True,
47+
random_state=self._get_seed(),
48+
)
49+
50+
self.domain.validate_experiments(samples)
51+
52+
return samples
53+
54+
def has_sufficient_experiments(self) -> bool:
55+
return True

tests/bofire/data_models/specs/strategies.py

+9
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@
149149
"seed": 42,
150150
},
151151
)
152+
specs.add_valid(
153+
strategies.UniversalConstraintSampler,
154+
lambda: {
155+
"domain": domain.valid().obj().dict(),
156+
"sampling_fraction": 0.3,
157+
"ipopt_options": {"maxiter": 200, "disp": 0},
158+
"seed": 42,
159+
},
160+
)
152161

153162
tempdomain = domain.valid().obj().dict()
154163

tests/bofire/strategies/test_samplers.py

+53
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
from pandas import concat
23

34
import bofire.data_models.strategies.api as data_models
45
import bofire.strategies.api as strategies
@@ -7,10 +8,62 @@
78
LinearEqualityConstraint,
89
LinearInequalityConstraint,
910
NChooseKConstraint,
11+
NonlinearEqualityConstraint,
12+
NonlinearInequalityConstraint,
1013
)
1114
from bofire.data_models.domain.api import Constraints, Domain, Inputs
1215
from bofire.data_models.features.api import CategoricalInput, ContinuousInput
1316

17+
inputs = [ContinuousInput(key=f"if{i}", bounds=(0, 1)) for i in range(1, 4)]
18+
c1 = LinearInequalityConstraint(
19+
features=["if1", "if2", "if3"], coefficients=[1, 1, 1], rhs=1
20+
)
21+
c2 = LinearEqualityConstraint(
22+
features=["if1", "if2", "if3"], coefficients=[1, 1, 1], rhs=1
23+
)
24+
c3 = NonlinearEqualityConstraint(
25+
expression="if1**2 + if2**2 - if3", features=["if1", "if2", "if3"]
26+
)
27+
c4 = NonlinearInequalityConstraint(
28+
expression="if1**2 + if2**2 - if3", features=["if1", "if2", "if3"]
29+
)
30+
c5 = NChooseKConstraint(
31+
features=["if1", "if2", "if3"], min_count=0, max_count=1, none_also_valid=True
32+
)
33+
34+
35+
domains = [
36+
Domain.from_lists(inputs=inputs, constraints=[c1]),
37+
Domain.from_lists(inputs=inputs, constraints=[c2]),
38+
Domain.from_lists(inputs=inputs, constraints=[c3]),
39+
Domain.from_lists(inputs=inputs, constraints=[c4]),
40+
Domain.from_lists(inputs=inputs, constraints=[c5]),
41+
]
42+
43+
44+
@pytest.mark.parametrize(
45+
"domain, num_samples",
46+
[(domain, candidate_count) for domain in domains for candidate_count in [1, 16]],
47+
)
48+
def test_UniversalConstraintSampler(domain, num_samples):
49+
data_model = data_models.UniversalConstraintSampler(domain=domain)
50+
sampler = strategies.UniversalConstraintSampler(data_model=data_model)
51+
samples = sampler.ask(num_samples)
52+
assert len(samples) == num_samples
53+
54+
55+
def test_UniversalConstraintSampler_pending_candidates():
56+
data_model = data_models.UniversalConstraintSampler(domain=domains[0])
57+
sampler = strategies.UniversalConstraintSampler(data_model=data_model)
58+
pending_candidates = sampler.ask(2, add_pending=True)
59+
samples = sampler.ask(1)
60+
assert len(samples) == 1
61+
all_samples = concat(
62+
[samples, pending_candidates], axis=0, ignore_index=True
63+
).drop_duplicates()
64+
assert len(all_samples) == 3
65+
66+
1467
inputs = Inputs(
1568
features=[ContinuousInput(key=f"if{i}", bounds=(0, 1)) for i in range(1, 4)]
1669
)

0 commit comments

Comments
 (0)