Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Categorical trust regions #865

Merged
merged 68 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
c82a549
CategoricalSearchSpace
Jul 26, 2024
260fff7
Fix AutoGraph error
Jul 26, 2024
976953f
EncoderFunction
Jul 29, 2024
2a863b5
Move one hot encoder to space.py
Jul 29, 2024
5e3cae0
DiscreteSearchSpaceABC
Jul 29, 2024
33aa239
Support one-hot encoding mixed search spaces
Jul 29, 2024
5e135eb
Not yet using latest gpflow
Jul 29, 2024
8a541a1
mypy
Jul 29, 2024
6bc5d4d
Refactor to allow categorical TR spaces
Jul 29, 2024
74122e5
Add more tests
Jul 30, 2024
d0f9376
More tests
Jul 30, 2024
6d2a4e9
Test to_tags
Jul 30, 2024
c0cfd42
has_bounds property
Jul 30, 2024
9c588a9
encode_query_points decorator
Jul 30, 2024
e6a0692
Encode some more query points
Jul 30, 2024
003d8a7
Categorical Trust Regions
Jul 31, 2024
25d20ca
Tweaks
Jul 31, 2024
69e7c0b
Categorical search spaces
Jul 31, 2024
c071112
Remove superfluous encodings
Jul 31, 2024
563e765
Migrate to encode method
Aug 1, 2024
6acd6b7
Experiment with encoded model approaches
Aug 2, 2024
d864468
Merge branch 'uri/categorical_search_spaces' into uri/experiment_with…
Aug 2, 2024
d8be1b7
Fix typing
Aug 2, 2024
cd9b89e
Better name
Aug 2, 2024
7a0dde0
Make encoded methods final
Aug 2, 2024
5867830
Docstrings
Aug 2, 2024
110fc28
EncodedFastUpdateModel
Aug 2, 2024
b05d413
Missed finals
Aug 2, 2024
485e284
inherit_check_shapes
Aug 3, 2024
1209ff4
Review comments
Aug 7, 2024
a6a9545
Merge branch 'uri/categorical_search_spaces' into uri/experiment_with…
Aug 7, 2024
ef78736
Remove trust region rule changes
Aug 7, 2024
f97d062
Revert "Remove trust region rule changes"
Aug 7, 2024
3612083
Remove internal check shapes (external ones are still there)
Aug 7, 2024
14e2710
Merge branch 'uri/experiment_with_encoded_models' into uri/categorica…
Aug 7, 2024
eee39cc
Optimize GeneralDiscreteSearchSpaces
Aug 7, 2024
ee04910
Merge branch 'uri/experiment_with_encoded_models' into uri/categorica…
Aug 7, 2024
b13c147
Use float indices to support product search spaces
Aug 7, 2024
63b62b1
Test non-integer indices
Aug 7, 2024
d162eff
Merge branch 'uri/categorical_search_spaces' into uri/experiment_with…
Aug 7, 2024
ad3e5ca
Merge branch 'uri/categorical_search_spaces' into uri/categorical_tru…
Aug 7, 2024
fc8b5df
Docstring example
Aug 7, 2024
ed45943
Merge branch 'uri/categorical_search_spaces' into uri/experiment_with…
Aug 8, 2024
df56c13
Merge branch 'uri/experiment_with_encoded_models' into uri/categorica…
Aug 8, 2024
3d2b286
Merge remote-tracking branch 'origin/develop' into uri/experiment_wit…
Aug 8, 2024
78ca4ed
Add a few unit tests
Aug 8, 2024
d3254f4
mypy
Aug 8, 2024
355d9e1
Check we can use Embedding layer as an encoder
Aug 12, 2024
ab37c52
Start writing integration test (and fix one_hot_encoder dtype issue)
Aug 14, 2024
8a8dcd8
Encode initial model data too
Aug 14, 2024
1ea01df
Custom gpr kernel
Aug 14, 2024
073c95e
Merge remote-tracking branch 'origin/develop' into uri/experiment_wit…
Aug 14, 2024
09c727e
Consistent dtype in encoder unit test
Aug 14, 2024
1546cce
Merge branch 'uri/experiment_with_encoded_models' into uri/categorica…
Aug 15, 2024
cc018c8
Eps
Aug 15, 2024
d39679b
one_hot_encoded_space
Aug 16, 2024
b682395
Couple of unit tests
Aug 19, 2024
d4949ec
Adress review comments
Aug 20, 2024
4833999
Merge branch 'uri/experiment_with_encoded_models' into uri/categorica…
Aug 20, 2024
bd6ed69
Unit test
Aug 20, 2024
bba0f1b
Fix typo and hidden optimizer issue
Aug 20, 2024
4b77fd8
Merge branch 'uri/experiment_with_encoded_models' into uri/categorica…
Aug 20, 2024
d1cb88f
Integration test and fix thompson sampling
Aug 20, 2024
e91ce75
Merge remote-tracking branch 'origin/develop' into uri/categorical_tr…
Aug 21, 2024
c48a40a
See whether increasing steps fixes test_old
Aug 21, 2024
31fb555
Review comments
Aug 22, 2024
e2e1a06
Switch num_steps
Aug 23, 2024
2b64c8f
Revert to 8
Aug 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 69 additions & 36 deletions tests/integration/test_mixed_space_bayesian_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.
from __future__ import annotations

import dataclasses
from typing import cast

import numpy as np
Expand Down Expand Up @@ -47,6 +48,7 @@
Box,
CategoricalSearchSpace,
DiscreteSearchSpace,
EncoderFunction,
TaggedProductSearchSpace,
one_hot_encoder,
)
Expand Down Expand Up @@ -167,15 +169,32 @@ def test_optimizer_finds_minima_of_the_scaled_branin_function(
TensorType, TaggedProductSearchSpace, TrainableProbabilisticModel
],
) -> None:
initial_query_points = mixed_search_space.sample(5)
observer = mk_observer(ScaledBranin.objective)
mixed_branin = cast(SingleObjectiveTestProblem[TaggedProductSearchSpace], ScaledBranin)
_test_optimizer_finds_problem_minima(
dataclasses.replace(mixed_branin, search_space=mixed_search_space),
num_steps,
acquisition_rule,
)


def _test_optimizer_finds_problem_minima(
problem: SingleObjectiveTestProblem[TaggedProductSearchSpace],
num_steps: int,
acquisition_rule: AcquisitionRule[
TensorType, TaggedProductSearchSpace, TrainableProbabilisticModel
],
encoder: EncoderFunction | None = None,
) -> None:
initial_query_points = problem.search_space.sample(5)
observer = mk_observer(problem.objective)
initial_data = observer(initial_query_points)
model = GaussianProcessRegression(
build_gpr(initial_data, mixed_search_space, likelihood_variance=1e-8)
build_gpr(initial_data, problem.search_space, likelihood_variance=1e-8),
encoder=encoder,
)

dataset = (
BayesianOptimizer(observer, mixed_search_space)
BayesianOptimizer(observer, problem.search_space)
.optimize(num_steps, initial_data, model, acquisition_rule)
.try_get_final_dataset()
)
Expand All @@ -185,7 +204,7 @@ def test_optimizer_finds_minima_of_the_scaled_branin_function(
best_y = dataset.observations[arg_min_idx]
best_x = dataset.query_points[arg_min_idx]

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

def objective(x: TensorType) -> TensorType:
Expand All @@ -234,11 +253,50 @@ def objective(x: TensorType) -> TensorType:
)


def _get_categorical_problem() -> SingleObjectiveTestProblem[TaggedProductSearchSpace]:
# a categorical scaled branin problem with 6 categories mapping to 3 random points
# plus the 3 minimizer points (to guarantee that the minimum is present)
points = tf.concat(
[tf.random.uniform([3], dtype=tf.float64), ScaledBranin.minimizers[..., 0]], 0
)
return categorical_scaled_branin(tf.random.shuffle(points))


cat_problem = _get_categorical_problem()


@random_seed
@pytest.mark.parametrize(
"num_steps, acquisition_rule",
[
pytest.param(25, EfficientGlobalOptimization(), id="EfficientGlobalOptimization"),
pytest.param(
khurram-ghani marked this conversation as resolved.
Show resolved Hide resolved
8,
BatchTrustRegionProduct(
[
UpdatableTrustRegionProduct(
[
SingleObjectiveTrustRegionDiscrete(
cast(
CategoricalSearchSpace,
cat_problem.search_space.get_subspace("categorical"),
)
),
SingleObjectiveTrustRegionBox(
cast(Box, cat_problem.search_space.get_subspace("continuous"))
),
],
tags=cat_problem.search_space.subspace_tags,
)
for _ in range(3)
],
EfficientGlobalOptimization(
ParallelContinuousThompsonSampling(),
num_query_points=3,
),
),
id="TrustRegionSingleObjective",
),
],
)
def test_optimizer_finds_minima_of_the_categorical_scaled_branin_function(
Expand All @@ -247,35 +305,10 @@ def test_optimizer_finds_minima_of_the_categorical_scaled_branin_function(
TensorType, TaggedProductSearchSpace, TrainableProbabilisticModel
],
) -> None:
# 6 categories mapping to 3 random points plus the 3 minimizer points
points = tf.concat(
[tf.random.uniform([3], dtype=tf.float64), ScaledBranin.minimizers[..., 0]], 0
)
problem = categorical_scaled_branin(tf.random.shuffle(points))
initial_query_points = problem.search_space.sample(5)
observer = mk_observer(problem.objective)
initial_data = observer(initial_query_points)

# model uses one-hot encoding for the categorical inputs
encoder = one_hot_encoder(problem.search_space)
model = GaussianProcessRegression(
build_gpr(initial_data, problem.search_space, likelihood_variance=1e-8),
encoder=encoder,
_test_optimizer_finds_problem_minima(
cat_problem,
num_steps,
acquisition_rule,
encoder=one_hot_encoder(cat_problem.search_space),
)

dataset = (
BayesianOptimizer(observer, problem.search_space)
.optimize(num_steps, initial_data, model, acquisition_rule)
.try_get_final_dataset()
)

arg_min_idx = tf.squeeze(tf.argmin(dataset.observations, axis=0))

best_y = dataset.observations[arg_min_idx]
best_x = dataset.query_points[arg_min_idx]

relative_minimizer_err = tf.abs((best_x - problem.minimizers) / problem.minimizers)
assert tf.reduce_any(
tf.reduce_all(relative_minimizer_err < 0.1, axis=-1), axis=0
), relative_minimizer_err
npt.assert_allclose(best_y, problem.minimum, rtol=0.005)
101 changes: 77 additions & 24 deletions tests/unit/acquisition/test_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
from trieste.observer import OBJECTIVE
from trieste.space import (
Box,
CategoricalSearchSpace,
DiscreteSearchSpace,
SearchSpace,
TaggedMultiSearchSpace,
Expand Down Expand Up @@ -2057,29 +2058,41 @@ def discrete_search_space() -> DiscreteSearchSpace:
return DiscreteSearchSpace(points)


@pytest.fixture
def categorical_search_space() -> CategoricalSearchSpace:
return CategoricalSearchSpace([10, 3])


@pytest.fixture
def continuous_search_space() -> Box:
return Box([0.0], [1.0])


@pytest.mark.parametrize("space_fixture", ["discrete_search_space", "categorical_search_space"])
@pytest.mark.parametrize("with_initialize", [True, False])
def test_fixed_trust_region_discrete_initialize(
discrete_search_space: DiscreteSearchSpace, with_initialize: bool
space_fixture: str,
with_initialize: bool,
request: Any,
) -> None:
"""Check that FixedTrustRegionDiscrete inits correctly by picking a single point from the global
search space."""
tr = FixedPointTrustRegionDiscrete(discrete_search_space)
search_space = request.getfixturevalue(space_fixture)
tr = FixedPointTrustRegionDiscrete(search_space)
if with_initialize:
tr.initialize()
assert tr.location.shape == (2,)
assert tr.location in discrete_search_space
assert tr.location in search_space


@pytest.mark.parametrize("space_fixture", ["discrete_search_space", "categorical_search_space"])
def test_fixed_trust_region_discrete_update(
discrete_search_space: DiscreteSearchSpace,
space_fixture: str,
request: Any,
) -> None:
"""Update call should not change the location of the region."""
tr = FixedPointTrustRegionDiscrete(discrete_search_space)
search_space = request.getfixturevalue(space_fixture)
tr = FixedPointTrustRegionDiscrete(search_space)
tr.initialize()
orig_location = tr.location.numpy()
assert not tr.requires_initialization
Expand All @@ -2103,13 +2116,16 @@ def test_trust_region_discrete_get_dataset_min_raises_if_dataset_is_faulty(
tr.get_dataset_min(datasets)


@pytest.mark.parametrize("space_fixture", ["discrete_search_space", "categorical_search_space"])
def test_trust_region_discrete_raises_on_location_not_found(
discrete_search_space: DiscreteSearchSpace,
space_fixture: str,
request: Any,
) -> None:
"""Check that an error is raised if the location is not found in the global search space."""
tr = SingleObjectiveTrustRegionDiscrete(discrete_search_space)
search_space = request.getfixturevalue(space_fixture)
tr = SingleObjectiveTrustRegionDiscrete(search_space)
with pytest.raises(ValueError, match="location .* not found in the global search space"):
tr.location = tf.constant([0.0, 0.0], dtype=tf.float64)
tr.location = tf.constant([0.1, 0.0], dtype=tf.float64)


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


def test_trust_region_categorical_initialize(
categorical_search_space: CategoricalSearchSpace,
) -> None:
"""Check initialize sets the region to a random location, and sets the eps and y_min values."""
datasets = {
OBJECTIVE: Dataset( # Points outside the search space should be ignored.
tf.constant([[0, 1, 2, 0], [4, -4, -5, 3]], dtype=tf.float64),
tf.constant([[0.7], [0.9]], dtype=tf.float64),
)
}
tr = SingleObjectiveTrustRegionDiscrete(categorical_search_space, input_active_dims=[1, 2])
tr.initialize(datasets=datasets)

npt.assert_array_equal(tr.eps, 1)
assert tr.location in categorical_search_space
npt.assert_array_equal(tr._y_min, tf.constant([np.inf], dtype=tf.float64))


def test_trust_region_discrete_requires_initialization(
discrete_search_space: DiscreteSearchSpace,
) -> None:
Expand Down Expand Up @@ -2223,20 +2257,28 @@ def test_trust_region_discrete_update_no_initialize(

@pytest.mark.parametrize("dtype", [tf.float32, tf.float64])
@pytest.mark.parametrize("success", [True, False])
@pytest.mark.parametrize("space_fixture", ["discrete_search_space", "categorical_search_space"])
def test_trust_region_discrete_update_size(
dtype: tf.DType, success: bool, discrete_search_space: DiscreteSearchSpace
dtype: tf.DType, success: bool, space_fixture: str, request: Any
) -> None:
discrete_search_space = DiscreteSearchSpace( # Convert to the correct dtype.
tf.cast(discrete_search_space.points, dtype=dtype)
)
search_space = request.getfixturevalue(space_fixture)
categorical = isinstance(search_space, CategoricalSearchSpace)

# Convert to the correct dtype.
if isinstance(search_space, DiscreteSearchSpace):
search_space = DiscreteSearchSpace(tf.cast(search_space.points, dtype=dtype))
else:
assert isinstance(search_space, CategoricalSearchSpace)
search_space = CategoricalSearchSpace(search_space.tags, dtype=dtype)

"""Check that update shrinks/expands region on successful/unsuccessful step."""
datasets = {
OBJECTIVE: Dataset(
tf.constant([[5, 4], [0, 1], [1, 1]], dtype=dtype),
tf.constant([[0.5], [0.3], [1.0]], dtype=dtype),
)
}
tr = SingleObjectiveTrustRegionDiscrete(discrete_search_space, min_eps=0.1)
tr = SingleObjectiveTrustRegionDiscrete(search_space, min_eps=0.1)
tr.initialize(datasets=datasets)

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

if success:
# Sample a point from the region.
new_point = tr.sample(1)
# Sample a point from the region. For categorical spaces ensure that
# it's a different point to tr.location (this must exist)
for _ in range(10):
new_point = tr.sample(1)
if not (categorical and tf.reduce_all(new_point[0] == tr.location)):
break
else:
assert False, "TR contains just one point"
else:
# Pick point outside the region.
new_point = tf.constant([[1, 2]], dtype=dtype)
new_point = tf.constant([[10, 1]], dtype=dtype)

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

assert tr.location.dtype == dtype
assert tr.eps.dtype == dtype
assert tr.eps == 1 if categorical else tr.eps.dtype == dtype
assert tr.points.dtype == dtype

if success:
# Check that the location is the new min point.
new_point = np.squeeze(new_point)
npt.assert_array_equal(new_point, tr.location)
npt.assert_allclose(new_min, tr._y_min)
# Check that the region is larger by beta.
npt.assert_allclose(eps / tr._beta, tr.eps)
# Check that the region is larger by beta (except for categorical)
npt.assert_allclose(1 if categorical else eps / tr._beta, tr.eps)
else:
# Check that the location is the old min point.
orig_point = np.squeeze(orig_point)
npt.assert_array_equal(orig_point, tr.location)
npt.assert_allclose(orig_min, tr._y_min)
# Check that the region is smaller by beta.
npt.assert_allclose(eps * tr._beta, tr.eps)
# Check that the region is smaller by beta (except for categorical)
npt.assert_allclose(1 if categorical else eps * tr._beta, tr.eps)

# Check the new set of neighbors.
neighbors_mask = tf.abs(discrete_search_space.points - tr.location) <= tr.eps
neighbors_mask = tf.reduce_all(neighbors_mask, axis=-1)
neighbors = tf.boolean_mask(discrete_search_space.points, neighbors_mask)
if categorical:
# Hamming distance
neighbors_mask = tf.where(search_space.points != tr.location, 1, 0)
neighbors_mask = tf.reduce_sum(neighbors_mask, axis=-1) <= tr.eps
else:
neighbors_mask = tf.abs(search_space.points - tr.location) <= tr.eps
neighbors_mask = tf.reduce_all(neighbors_mask, axis=-1)
neighbors = tf.boolean_mask(search_space.points, neighbors_mask)
npt.assert_array_equal(tr.points, neighbors)


Expand Down
Loading
Loading