Skip to content

Commit 1a49eb1

Browse files
committed
Use scipy NonLinearConstraint, change the way ConstraintModel is constructed and stored
1 parent e1fad9e commit 1a49eb1

File tree

5 files changed

+168
-85
lines changed

5 files changed

+168
-85
lines changed

bayes_opt/bayesian_optimization.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import warnings
22
from queue import Queue, Empty
33

4+
from bayes_opt.constraint import ConstraintModel
5+
46
from .target_space import TargetSpace, ConstrainedTargetSpace
57
from .event import Events, DEFAULT_EVENTS
68
from .logger import _get_default_logger
@@ -106,10 +108,21 @@ def __init__(self,
106108
# bounds of its domain, and a record of the evaluations we have
107109
# done so far
108110
self._space = TargetSpace(f, pbounds, random_state)
111+
self.is_constrained = False
109112
else:
110-
self._space = ConstrainedTargetSpace(f, constraint, pbounds,
111-
random_state)
112-
self.constraint = constraint
113+
constraint_ = ConstraintModel(
114+
constraint.fun,
115+
constraint.lb,
116+
constraint.ub,
117+
random_state=random_state
118+
)
119+
self._space = ConstrainedTargetSpace(
120+
f,
121+
constraint_,
122+
pbounds,
123+
random_state
124+
)
125+
self.is_constrained = True
113126

114127
self._verbose = verbose
115128
self._bounds_transformer = bounds_transformer
@@ -126,6 +139,12 @@ def __init__(self,
126139
def space(self):
127140
return self._space
128141

142+
@property
143+
def constraint(self):
144+
if self.is_constrained:
145+
return self._space.constraint
146+
return None
147+
129148
@property
130149
def max(self):
131150
return self._space.max()
@@ -169,7 +188,7 @@ def suggest(self, utility_function):
169188
with warnings.catch_warnings():
170189
warnings.simplefilter("ignore")
171190
self._gp.fit(self._space.params, self._space.target)
172-
if self.constraint is not None:
191+
if self.is_constrained:
173192
self.constraint.fit(self._space.params,
174193
self._space._constraint_values)
175194

bayes_opt/constraint.py

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,16 @@ class ConstraintModel():
1212
1313
Parameters
1414
----------
15-
func: function
15+
fun: function
1616
Constraint function. If multiple constraints are handled, this should
1717
return a numpy.ndarray of appropriate size.
1818
19-
limits: numeric or numpy.ndarray
20-
Upper limit(s) for the constraints. The return value of `func` should
19+
lb: numeric or numpy.ndarray
20+
Upper limit(s) for the constraints. The return value of `fun` should
21+
have exactly this shape.
22+
23+
ub: numeric or numpy.ndarray
24+
Upper limit(s) for the constraints. The return value of `fun` should
2125
have exactly this shape.
2226
2327
random_state: int or numpy.random.RandomState, optional(default=None)
@@ -33,41 +37,56 @@ class ConstraintModel():
3337
is a simply the product of the individual probabilities.
3438
"""
3539

36-
def __init__(self, func, limits, random_state=None):
37-
self.func = func
40+
def __init__(self, fun, lb, ub, random_state=None):
41+
self.fun = fun
3842

39-
if isinstance(limits, float):
40-
self._limits = np.array([limits])
43+
if isinstance(lb, float):
44+
self._lb = np.array([lb])
4145
else:
42-
self._limits = limits
43-
46+
self._lb = lb
47+
48+
if isinstance(ub, float):
49+
self._ub = np.array([ub])
50+
else:
51+
self._ub = ub
52+
53+
4454
basis = lambda: GaussianProcessRegressor(
4555
kernel=Matern(nu=2.5),
4656
alpha=1e-6,
4757
normalize_y=True,
4858
n_restarts_optimizer=5,
4959
random_state=random_state,
5060
)
51-
self._model = [basis() for _ in range(len(self._limits))]
61+
self._model = [basis() for _ in range(len(self._lb))]
5262

5363
@property
54-
def limits(self):
55-
return self._limits
64+
def lb(self):
65+
return self._lb
66+
67+
@property
68+
def ub(self):
69+
return self._ub
70+
71+
@property
72+
def model(self):
73+
return self._model
5674

5775
def eval(self, **kwargs):
5876
"""
5977
Evaluates the constraint function.
6078
"""
6179
try:
62-
return self.func(**kwargs)
80+
return self.fun(**kwargs)
6381
except TypeError as e:
6482
msg = (
6583
"Encountered TypeError when evaluating constraint " +
6684
"function. This could be because your constraint function " +
6785
"doesn't use the same keyword arguments as the target " +
6886
f"function. Original error message:\n\n{e}"
6987
)
70-
raise TypeError(msg)
88+
e.args = (msg,)
89+
raise
7190

7291
def fit(self, X, Y):
7392
"""
@@ -92,14 +111,22 @@ def predict(self, X):
92111
X = X.reshape((-1, self._model[0].n_features_in_))
93112
if len(self._model) == 1:
94113
y_mean, y_std = self._model[0].predict(X, return_std=True)
95-
result = norm(loc=y_mean, scale=y_std).cdf(self._limits[0])
114+
115+
p_lower = (norm(loc=y_mean, scale=y_std).cdf(self._lb[0])
116+
if self._lb[0] != -np.inf else np.array([0]))
117+
p_upper = (norm(loc=y_mean, scale=y_std).cdf(self._ub[0])
118+
if self._lb[0] != np.inf else np.array([1]))
119+
result = p_upper - p_lower
96120
return result.reshape(X_shape[:-1])
97121
else:
98122
result = np.ones(X.shape[0])
99123
for j, gp in enumerate(self._model):
100124
y_mean, y_std = gp.predict(X, return_std=True)
101-
result = result * norm(loc=y_mean, scale=y_std).cdf(
102-
self._limits[j])
125+
p_lower = (norm(loc=y_mean, scale=y_std).cdf(self._lb[j])
126+
if self._lb[j] != -np.inf else np.array([0]))
127+
p_upper = (norm(loc=y_mean, scale=y_std).cdf(self._ub[j])
128+
if self._lb[j] != np.inf else np.array([1]))
129+
result = result * (p_upper - p_lower)
103130
return result.reshape(X_shape[:-1])
104131

105132
def approx(self, X):
@@ -113,13 +140,15 @@ def approx(self, X):
113140
return self._model[0].predict(X).reshape(X_shape[:-1])
114141
else:
115142
result = np.column_stack([gp.predict(X) for gp in self._model])
116-
return result.reshape(X_shape[:-1] + (len(self._limits), ))
143+
return result.reshape(X_shape[:-1] + (len(self._lb), ))
117144

118145
def allowed(self, constraint_values):
119146
"""
120147
Checks whether `constraint_values` are below the specified limits.
121148
"""
122-
if self._limits.size == 1:
123-
return np.less_equal(constraint_values, self._limits)
149+
if self._lb.size == 1:
150+
return (np.less_equal(self._lb, constraint_values)
151+
& np.less_equal(constraint_values, self._ub))
124152

125-
return np.all(constraint_values <= self._limits, axis=-1)
153+
return (np.all(constraint_values <= self._ub, axis=-1)
154+
& np.all(constraint_values >= self._lb, axis=-1))

bayes_opt/target_space.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,14 @@ def __init__(self,
267267
self._constraint = constraint
268268

269269
# preallocated memory for constraint fulfillement
270-
if constraint.limits.size == 1:
270+
if constraint.lb.size == 1:
271271
self._constraint_values = np.empty(shape=(0), dtype=float)
272272
else:
273-
self._constraint_values = np.empty(shape=(0, constraint.limits.size), dtype=float)
273+
self._constraint_values = np.empty(shape=(0, constraint.lb.size), dtype=float)
274+
275+
@property
276+
def constraint(self):
277+
return self._constraint
274278

275279
@property
276280
def constraint_values(self):

examples/constraints.ipynb

Lines changed: 51 additions & 33 deletions
Large diffs are not rendered by default.

tests/test_constraint.py

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
11
import numpy as np
22
from bayes_opt import BayesianOptimization, ConstraintModel
33
from pytest import approx, raises
4+
from scipy.optimize import NonlinearConstraint
45

56
np.random.seed(42)
67

78

8-
def test_single_constraint():
9+
def test_single_constraint_upper():
910

1011
def target_function(x, y):
1112
return np.cos(2 * x) * np.cos(y) + np.sin(x)
1213

1314
def constraint_function(x, y):
1415
return np.cos(x) * np.cos(y) - np.sin(x) * np.sin(y)
1516

16-
constraint_limit = 0.5
17+
constraint_limit_upper = 0.5
1718

18-
conmod = ConstraintModel(constraint_function, constraint_limit)
19+
constraint = NonlinearConstraint(constraint_function, -np.inf, constraint_limit_upper)
1920
pbounds = {'x': (0, 6), 'y': (0, 6)}
2021

2122
optimizer = BayesianOptimization(
2223
f=target_function,
23-
constraint=conmod,
24+
constraint=constraint,
2425
pbounds=pbounds,
2526
verbose=0,
2627
random_state=1,
@@ -31,23 +32,25 @@ def constraint_function(x, y):
3132
n_iter=10,
3233
)
3334

35+
assert constraint_function(**optimizer.max["params"]) <= constraint_limit_upper
36+
3437

35-
def test_single_constraint_max_is_allowed():
38+
def test_single_constraint_lower():
3639

3740
def target_function(x, y):
3841
return np.cos(2 * x) * np.cos(y) + np.sin(x)
3942

4043
def constraint_function(x, y):
4144
return np.cos(x) * np.cos(y) - np.sin(x) * np.sin(y)
4245

43-
constraint_limit = 0.5
46+
constraint_limit_lower = -0.5
4447

45-
conmod = ConstraintModel(constraint_function, constraint_limit)
48+
constraint = NonlinearConstraint(constraint_function, constraint_limit_lower, np.inf)
4649
pbounds = {'x': (0, 6), 'y': (0, 6)}
4750

4851
optimizer = BayesianOptimization(
4952
f=target_function,
50-
constraint=conmod,
53+
constraint=constraint,
5154
pbounds=pbounds,
5255
verbose=0,
5356
random_state=1,
@@ -58,45 +61,55 @@ def constraint_function(x, y):
5861
n_iter=10,
5962
)
6063

61-
assert constraint_function(**optimizer.max["params"]) <= constraint_limit
64+
assert constraint_function(**optimizer.max["params"]) >= constraint_limit_lower
6265

6366

64-
def test_accurate_approximation_when_known():
67+
def test_single_constraint_lower_upper():
6568

6669
def target_function(x, y):
6770
return np.cos(2 * x) * np.cos(y) + np.sin(x)
6871

6972
def constraint_function(x, y):
7073
return np.cos(x) * np.cos(y) - np.sin(x) * np.sin(y)
7174

72-
constraint_limit = 0.5
75+
constraint_limit_lower = -0.5
76+
constraint_limit_upper = 0.5
7377

74-
conmod = ConstraintModel(constraint_function, constraint_limit)
78+
constraint = NonlinearConstraint(constraint_function, constraint_limit_lower, constraint_limit_upper)
7579
pbounds = {'x': (0, 6), 'y': (0, 6)}
7680

7781
optimizer = BayesianOptimization(
7882
f=target_function,
79-
constraint=conmod,
83+
constraint=constraint,
8084
pbounds=pbounds,
8185
verbose=0,
8286
random_state=1,
8387
)
8488

89+
assert optimizer.constraint.lb == constraint.lb
90+
assert optimizer.constraint.ub == constraint.ub
91+
8592
optimizer.maximize(
8693
init_points=2,
8794
n_iter=10,
8895
)
8996

97+
# Check limits
98+
assert constraint_function(**optimizer.max["params"]) <= constraint_limit_upper
99+
assert constraint_function(**optimizer.max["params"]) >= constraint_limit_lower
100+
101+
90102
# Exclude the last sampled point, because the constraint is not fitted on that.
91103
res = np.array([[r['target'], r['constraint'], r['params']['x'], r['params']['y']] for r in optimizer.res[:-1]])
92104

93105
xy = res[:, [2, 3]]
94106
x = res[:, 2]
95107
y = res[:, 3]
96108

97-
assert constraint_function(x, y) == approx(conmod.approx(xy), rel=1e-5, abs=1e-5)
109+
# Check accuracy of approximation for sampled points
110+
assert constraint_function(x, y) == approx(optimizer.constraint.approx(xy), rel=1e-5, abs=1e-5)
98111
assert constraint_function(x, y) == approx(optimizer.space.constraint_values[:-1], rel=1e-5, abs=1e-5)
99-
112+
100113

101114
def test_multiple_constraints():
102115

@@ -109,9 +122,10 @@ def constraint_function_2_dim(x, y):
109122
-np.cos(x) * np.cos(-y) + np.sin(x) * np.sin(-y)
110123
])
111124

112-
constraint_limit = np.array([0.6, 0.6])
125+
constraint_limit_lower = np.array([-np.inf, -np.inf])
126+
constraint_limit_upper = np.array([0.6, 0.6])
113127

114-
conmod = ConstraintModel(constraint_function_2_dim, constraint_limit)
128+
conmod = NonlinearConstraint(constraint_function_2_dim, constraint_limit_lower, constraint_limit_upper)
115129
pbounds = {'x': (0, 6), 'y': (0, 6)}
116130

117131
optimizer = BayesianOptimization(
@@ -127,14 +141,13 @@ def constraint_function_2_dim(x, y):
127141
n_iter=10,
128142
)
129143

130-
assert np.all(
131-
constraint_function_2_dim(
132-
**optimizer.max["params"]) <= constraint_limit)
144+
constraint_at_max = constraint_function_2_dim(**optimizer.max["params"])
145+
assert np.all((constraint_at_max <= constraint_limit_upper) & (constraint_at_max >= constraint_limit_lower))
133146

134147
params = optimizer.res[0]["params"]
135148
x, y = params['x'], params['y']
136149

137-
assert constraint_function_2_dim(x, y) == approx(conmod.approx(np.array([x, y])), rel=1e-5, abs=1e-5)
150+
assert constraint_function_2_dim(x, y) == approx(optimizer.constraint.approx(np.array([x, y])), rel=1e-5, abs=1e-5)
138151

139152

140153
def test_kwargs_not_the_same():
@@ -145,14 +158,14 @@ def target_function(x, y):
145158
def constraint_function(a, b):
146159
return np.cos(a) * np.cos(b) - np.sin(a) * np.sin(b)
147160

148-
constraint_limit = 0.5
161+
constraint_limit_upper = 0.5
149162

150-
conmod = ConstraintModel(constraint_function, constraint_limit)
163+
constraint = NonlinearConstraint(constraint_function, -np.inf, constraint_limit_upper)
151164
pbounds = {'x': (0, 6), 'y': (0, 6)}
152165

153166
optimizer = BayesianOptimization(
154167
f=target_function,
155-
constraint=conmod,
168+
constraint=constraint,
156169
pbounds=pbounds,
157170
verbose=0,
158171
random_state=1,
@@ -161,4 +174,4 @@ def constraint_function(a, b):
161174
optimizer.maximize(
162175
init_points=2,
163176
n_iter=10,
164-
)
177+
)

0 commit comments

Comments
 (0)