Skip to content

Commit 2c725f9

Browse files
authored
Categorical trust regions (#865)
1 parent f07f2ea commit 2c725f9

File tree

5 files changed

+197
-75
lines changed

5 files changed

+197
-75
lines changed

tests/integration/test_mixed_space_bayesian_optimization.py

Lines changed: 69 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414
from __future__ import annotations
1515

16+
import dataclasses
1617
from typing import cast
1718

1819
import numpy as np
@@ -47,6 +48,7 @@
4748
Box,
4849
CategoricalSearchSpace,
4950
DiscreteSearchSpace,
51+
EncoderFunction,
5052
TaggedProductSearchSpace,
5153
one_hot_encoder,
5254
)
@@ -167,15 +169,32 @@ def test_optimizer_finds_minima_of_the_scaled_branin_function(
167169
TensorType, TaggedProductSearchSpace, TrainableProbabilisticModel
168170
],
169171
) -> None:
170-
initial_query_points = mixed_search_space.sample(5)
171-
observer = mk_observer(ScaledBranin.objective)
172+
mixed_branin = cast(SingleObjectiveTestProblem[TaggedProductSearchSpace], ScaledBranin)
173+
_test_optimizer_finds_problem_minima(
174+
dataclasses.replace(mixed_branin, search_space=mixed_search_space),
175+
num_steps,
176+
acquisition_rule,
177+
)
178+
179+
180+
def _test_optimizer_finds_problem_minima(
181+
problem: SingleObjectiveTestProblem[TaggedProductSearchSpace],
182+
num_steps: int,
183+
acquisition_rule: AcquisitionRule[
184+
TensorType, TaggedProductSearchSpace, TrainableProbabilisticModel
185+
],
186+
encoder: EncoderFunction | None = None,
187+
) -> None:
188+
initial_query_points = problem.search_space.sample(5)
189+
observer = mk_observer(problem.objective)
172190
initial_data = observer(initial_query_points)
173191
model = GaussianProcessRegression(
174-
build_gpr(initial_data, mixed_search_space, likelihood_variance=1e-8)
192+
build_gpr(initial_data, problem.search_space, likelihood_variance=1e-8),
193+
encoder=encoder,
175194
)
176195

177196
dataset = (
178-
BayesianOptimizer(observer, mixed_search_space)
197+
BayesianOptimizer(observer, problem.search_space)
179198
.optimize(num_steps, initial_data, model, acquisition_rule)
180199
.try_get_final_dataset()
181200
)
@@ -185,7 +204,7 @@ def test_optimizer_finds_minima_of_the_scaled_branin_function(
185204
best_y = dataset.observations[arg_min_idx]
186205
best_x = dataset.query_points[arg_min_idx]
187206

188-
relative_minimizer_err = tf.abs((best_x - ScaledBranin.minimizers) / ScaledBranin.minimizers)
207+
relative_minimizer_err = tf.abs((best_x - problem.minimizers) / problem.minimizers)
189208
# these accuracies are the current best for the given number of optimization steps, which makes
190209
# this is a regression test
191210
assert tf.reduce_any(tf.reduce_all(relative_minimizer_err < 0.1, axis=-1), axis=0)
@@ -210,7 +229,7 @@ def categorical_scaled_branin(
210229
continuous_space = Box([0], [1])
211230
search_space = TaggedProductSearchSpace(
212231
spaces=[categorical_space, continuous_space],
213-
tags=["discrete", "continuous"],
232+
tags=["categorical", "continuous"],
214233
)
215234

216235
def objective(x: TensorType) -> TensorType:
@@ -234,11 +253,50 @@ def objective(x: TensorType) -> TensorType:
234253
)
235254

236255

256+
def _get_categorical_problem() -> SingleObjectiveTestProblem[TaggedProductSearchSpace]:
257+
# a categorical scaled branin problem with 6 categories mapping to 3 random points
258+
# plus the 3 minimizer points (to guarantee that the minimum is present)
259+
points = tf.concat(
260+
[tf.random.uniform([3], dtype=tf.float64), ScaledBranin.minimizers[..., 0]], 0
261+
)
262+
return categorical_scaled_branin(tf.random.shuffle(points))
263+
264+
265+
cat_problem = _get_categorical_problem()
266+
267+
237268
@random_seed
238269
@pytest.mark.parametrize(
239270
"num_steps, acquisition_rule",
240271
[
241272
pytest.param(25, EfficientGlobalOptimization(), id="EfficientGlobalOptimization"),
273+
pytest.param(
274+
8,
275+
BatchTrustRegionProduct(
276+
[
277+
UpdatableTrustRegionProduct(
278+
[
279+
SingleObjectiveTrustRegionDiscrete(
280+
cast(
281+
CategoricalSearchSpace,
282+
cat_problem.search_space.get_subspace("categorical"),
283+
)
284+
),
285+
SingleObjectiveTrustRegionBox(
286+
cast(Box, cat_problem.search_space.get_subspace("continuous"))
287+
),
288+
],
289+
tags=cat_problem.search_space.subspace_tags,
290+
)
291+
for _ in range(3)
292+
],
293+
EfficientGlobalOptimization(
294+
ParallelContinuousThompsonSampling(),
295+
num_query_points=3,
296+
),
297+
),
298+
id="TrustRegionSingleObjective",
299+
),
242300
],
243301
)
244302
def test_optimizer_finds_minima_of_the_categorical_scaled_branin_function(
@@ -247,35 +305,10 @@ def test_optimizer_finds_minima_of_the_categorical_scaled_branin_function(
247305
TensorType, TaggedProductSearchSpace, TrainableProbabilisticModel
248306
],
249307
) -> None:
250-
# 6 categories mapping to 3 random points plus the 3 minimizer points
251-
points = tf.concat(
252-
[tf.random.uniform([3], dtype=tf.float64), ScaledBranin.minimizers[..., 0]], 0
253-
)
254-
problem = categorical_scaled_branin(tf.random.shuffle(points))
255-
initial_query_points = problem.search_space.sample(5)
256-
observer = mk_observer(problem.objective)
257-
initial_data = observer(initial_query_points)
258-
259308
# model uses one-hot encoding for the categorical inputs
260-
encoder = one_hot_encoder(problem.search_space)
261-
model = GaussianProcessRegression(
262-
build_gpr(initial_data, problem.search_space, likelihood_variance=1e-8),
263-
encoder=encoder,
309+
_test_optimizer_finds_problem_minima(
310+
cat_problem,
311+
num_steps,
312+
acquisition_rule,
313+
encoder=one_hot_encoder(cat_problem.search_space),
264314
)
265-
266-
dataset = (
267-
BayesianOptimizer(observer, problem.search_space)
268-
.optimize(num_steps, initial_data, model, acquisition_rule)
269-
.try_get_final_dataset()
270-
)
271-
272-
arg_min_idx = tf.squeeze(tf.argmin(dataset.observations, axis=0))
273-
274-
best_y = dataset.observations[arg_min_idx]
275-
best_x = dataset.query_points[arg_min_idx]
276-
277-
relative_minimizer_err = tf.abs((best_x - problem.minimizers) / problem.minimizers)
278-
assert tf.reduce_any(
279-
tf.reduce_all(relative_minimizer_err < 0.1, axis=-1), axis=0
280-
), relative_minimizer_err
281-
npt.assert_allclose(best_y, problem.minimum, rtol=0.005)

tests/unit/acquisition/test_rule.py

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
from trieste.observer import OBJECTIVE
7878
from trieste.space import (
7979
Box,
80+
CategoricalSearchSpace,
8081
DiscreteSearchSpace,
8182
SearchSpace,
8283
TaggedMultiSearchSpace,
@@ -2057,29 +2058,41 @@ def discrete_search_space() -> DiscreteSearchSpace:
20572058
return DiscreteSearchSpace(points)
20582059

20592060

2061+
@pytest.fixture
2062+
def categorical_search_space() -> CategoricalSearchSpace:
2063+
return CategoricalSearchSpace([10, 3])
2064+
2065+
20602066
@pytest.fixture
20612067
def continuous_search_space() -> Box:
20622068
return Box([0.0], [1.0])
20632069

20642070

2071+
@pytest.mark.parametrize("space_fixture", ["discrete_search_space", "categorical_search_space"])
20652072
@pytest.mark.parametrize("with_initialize", [True, False])
20662073
def test_fixed_trust_region_discrete_initialize(
2067-
discrete_search_space: DiscreteSearchSpace, with_initialize: bool
2074+
space_fixture: str,
2075+
with_initialize: bool,
2076+
request: Any,
20682077
) -> None:
20692078
"""Check that FixedTrustRegionDiscrete inits correctly by picking a single point from the global
20702079
search space."""
2071-
tr = FixedPointTrustRegionDiscrete(discrete_search_space)
2080+
search_space = request.getfixturevalue(space_fixture)
2081+
tr = FixedPointTrustRegionDiscrete(search_space)
20722082
if with_initialize:
20732083
tr.initialize()
20742084
assert tr.location.shape == (2,)
2075-
assert tr.location in discrete_search_space
2085+
assert tr.location in search_space
20762086

20772087

2088+
@pytest.mark.parametrize("space_fixture", ["discrete_search_space", "categorical_search_space"])
20782089
def test_fixed_trust_region_discrete_update(
2079-
discrete_search_space: DiscreteSearchSpace,
2090+
space_fixture: str,
2091+
request: Any,
20802092
) -> None:
20812093
"""Update call should not change the location of the region."""
2082-
tr = FixedPointTrustRegionDiscrete(discrete_search_space)
2094+
search_space = request.getfixturevalue(space_fixture)
2095+
tr = FixedPointTrustRegionDiscrete(search_space)
20832096
tr.initialize()
20842097
orig_location = tr.location.numpy()
20852098
assert not tr.requires_initialization
@@ -2103,13 +2116,16 @@ def test_trust_region_discrete_get_dataset_min_raises_if_dataset_is_faulty(
21032116
tr.get_dataset_min(datasets)
21042117

21052118

2119+
@pytest.mark.parametrize("space_fixture", ["discrete_search_space", "categorical_search_space"])
21062120
def test_trust_region_discrete_raises_on_location_not_found(
2107-
discrete_search_space: DiscreteSearchSpace,
2121+
space_fixture: str,
2122+
request: Any,
21082123
) -> None:
21092124
"""Check that an error is raised if the location is not found in the global search space."""
2110-
tr = SingleObjectiveTrustRegionDiscrete(discrete_search_space)
2125+
search_space = request.getfixturevalue(space_fixture)
2126+
tr = SingleObjectiveTrustRegionDiscrete(search_space)
21112127
with pytest.raises(ValueError, match="location .* not found in the global search space"):
2112-
tr.location = tf.constant([0.0, 0.0], dtype=tf.float64)
2128+
tr.location = tf.constant([0.1, 0.0], dtype=tf.float64)
21132129

21142130

21152131
def test_trust_region_discrete_get_dataset_min(discrete_search_space: DiscreteSearchSpace) -> None:
@@ -2172,6 +2188,24 @@ def test_trust_region_discrete_initialize(
21722188
npt.assert_array_equal(tr._y_min, tf.constant([np.inf], dtype=tf.float64))
21732189

21742190

2191+
def test_trust_region_categorical_initialize(
2192+
categorical_search_space: CategoricalSearchSpace,
2193+
) -> None:
2194+
"""Check initialize sets the region to a random location, and sets the eps and y_min values."""
2195+
datasets = {
2196+
OBJECTIVE: Dataset( # Points outside the search space should be ignored.
2197+
tf.constant([[0, 1, 2, 0], [4, -4, -5, 3]], dtype=tf.float64),
2198+
tf.constant([[0.7], [0.9]], dtype=tf.float64),
2199+
)
2200+
}
2201+
tr = SingleObjectiveTrustRegionDiscrete(categorical_search_space, input_active_dims=[1, 2])
2202+
tr.initialize(datasets=datasets)
2203+
2204+
npt.assert_array_equal(tr.eps, 1)
2205+
assert tr.location in categorical_search_space
2206+
npt.assert_array_equal(tr._y_min, tf.constant([np.inf], dtype=tf.float64))
2207+
2208+
21752209
def test_trust_region_discrete_requires_initialization(
21762210
discrete_search_space: DiscreteSearchSpace,
21772211
) -> None:
@@ -2223,20 +2257,28 @@ def test_trust_region_discrete_update_no_initialize(
22232257

22242258
@pytest.mark.parametrize("dtype", [tf.float32, tf.float64])
22252259
@pytest.mark.parametrize("success", [True, False])
2260+
@pytest.mark.parametrize("space_fixture", ["discrete_search_space", "categorical_search_space"])
22262261
def test_trust_region_discrete_update_size(
2227-
dtype: tf.DType, success: bool, discrete_search_space: DiscreteSearchSpace
2262+
dtype: tf.DType, success: bool, space_fixture: str, request: Any
22282263
) -> None:
2229-
discrete_search_space = DiscreteSearchSpace( # Convert to the correct dtype.
2230-
tf.cast(discrete_search_space.points, dtype=dtype)
2231-
)
2264+
search_space = request.getfixturevalue(space_fixture)
2265+
categorical = isinstance(search_space, CategoricalSearchSpace)
2266+
2267+
# Convert to the correct dtype.
2268+
if isinstance(search_space, DiscreteSearchSpace):
2269+
search_space = DiscreteSearchSpace(tf.cast(search_space.points, dtype=dtype))
2270+
else:
2271+
assert isinstance(search_space, CategoricalSearchSpace)
2272+
search_space = CategoricalSearchSpace(search_space.tags, dtype=dtype)
2273+
22322274
"""Check that update shrinks/expands region on successful/unsuccessful step."""
22332275
datasets = {
22342276
OBJECTIVE: Dataset(
22352277
tf.constant([[5, 4], [0, 1], [1, 1]], dtype=dtype),
22362278
tf.constant([[0.5], [0.3], [1.0]], dtype=dtype),
22372279
)
22382280
}
2239-
tr = SingleObjectiveTrustRegionDiscrete(discrete_search_space, min_eps=0.1)
2281+
tr = SingleObjectiveTrustRegionDiscrete(search_space, min_eps=0.1)
22402282
tr.initialize(datasets=datasets)
22412283

22422284
# Ensure there is at least one point captured in the region.
@@ -2252,11 +2294,17 @@ def test_trust_region_discrete_update_size(
22522294
eps = tr.eps
22532295

22542296
if success:
2255-
# Sample a point from the region.
2256-
new_point = tr.sample(1)
2297+
# Sample a point from the region. For categorical spaces ensure that
2298+
# it's a different point to tr.location (this must exist)
2299+
for _ in range(10):
2300+
new_point = tr.sample(1)
2301+
if not (categorical and tf.reduce_all(new_point[0] == tr.location)):
2302+
break
2303+
else:
2304+
assert False, "TR contains just one point"
22572305
else:
22582306
# Pick point outside the region.
2259-
new_point = tf.constant([[1, 2]], dtype=dtype)
2307+
new_point = tf.constant([[10, 1]], dtype=dtype)
22602308

22612309
# Add a new min point to the dataset.
22622310
assert not tr.requires_initialization
@@ -2269,28 +2317,33 @@ def test_trust_region_discrete_update_size(
22692317
tr.update(datasets=datasets)
22702318

22712319
assert tr.location.dtype == dtype
2272-
assert tr.eps.dtype == dtype
2320+
assert tr.eps == 1 if categorical else tr.eps.dtype == dtype
22732321
assert tr.points.dtype == dtype
22742322

22752323
if success:
22762324
# Check that the location is the new min point.
22772325
new_point = np.squeeze(new_point)
22782326
npt.assert_array_equal(new_point, tr.location)
22792327
npt.assert_allclose(new_min, tr._y_min)
2280-
# Check that the region is larger by beta.
2281-
npt.assert_allclose(eps / tr._beta, tr.eps)
2328+
# Check that the region is larger by beta (except for categorical)
2329+
npt.assert_allclose(1 if categorical else eps / tr._beta, tr.eps)
22822330
else:
22832331
# Check that the location is the old min point.
22842332
orig_point = np.squeeze(orig_point)
22852333
npt.assert_array_equal(orig_point, tr.location)
22862334
npt.assert_allclose(orig_min, tr._y_min)
2287-
# Check that the region is smaller by beta.
2288-
npt.assert_allclose(eps * tr._beta, tr.eps)
2335+
# Check that the region is smaller by beta (except for categorical)
2336+
npt.assert_allclose(1 if categorical else eps * tr._beta, tr.eps)
22892337

22902338
# Check the new set of neighbors.
2291-
neighbors_mask = tf.abs(discrete_search_space.points - tr.location) <= tr.eps
2292-
neighbors_mask = tf.reduce_all(neighbors_mask, axis=-1)
2293-
neighbors = tf.boolean_mask(discrete_search_space.points, neighbors_mask)
2339+
if categorical:
2340+
# Hamming distance
2341+
neighbors_mask = tf.where(search_space.points != tr.location, 1, 0)
2342+
neighbors_mask = tf.reduce_sum(neighbors_mask, axis=-1) <= tr.eps
2343+
else:
2344+
neighbors_mask = tf.abs(search_space.points - tr.location) <= tr.eps
2345+
neighbors_mask = tf.reduce_all(neighbors_mask, axis=-1)
2346+
neighbors = tf.boolean_mask(search_space.points, neighbors_mask)
22942347
npt.assert_array_equal(tr.points, neighbors)
22952348

22962349

0 commit comments

Comments
 (0)