From 96761897f27e41760aa7cc5821d3b39729749b98 Mon Sep 17 00:00:00 2001 From: dengdifan Date: Wed, 23 Oct 2024 15:46:50 +0200 Subject: [PATCH 01/23] allow encoder to return running configs --- smac/runhistory/encoder/abstract_encoder.py | 68 ++++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/smac/runhistory/encoder/abstract_encoder.py b/smac/runhistory/encoder/abstract_encoder.py index a02bbab77..f4c6a1ba6 100644 --- a/smac/runhistory/encoder/abstract_encoder.py +++ b/smac/runhistory/encoder/abstract_encoder.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any, Mapping +from typing import Any, Mapping, Iterable import numpy as np @@ -188,6 +188,29 @@ def _get_considered_trials( return trials + def _get_running_trials( + self, + budget_subset: list | None = None, + ) -> dict[TrialKey, TrialValue]: + """Returns all trials that are still running.""" + if budget_subset is not None: + trials = { + trial: self.runhistory[trial] + for trial in self.runhistory + if self.runhistory[trial].status == StatusType.RUNNING + # and runhistory.data[run].time >= self._algorithm_walltime_limit # type: ignore + and trial.budget in budget_subset + } + else: + trials = { + trial: self.runhistory[trial] + for trial in self.runhistory + if self.runhistory[trial].status == StatusType.RUNNING + # and runhistory.data[run].time >= self._algorithm_walltime_limit # type: ignore + } + + return trials + def _get_timeout_trials( self, budget_subset: list | None = None, @@ -211,6 +234,13 @@ def _get_timeout_trials( return trials + def _convert_config_ids_to_array(self, + config_ids: Iterable[int]) -> np.ndarray: + """extract the configurations from rh and transform them into np array""" + configurations = [self.runhistory._ids_config[config_id] for config_id in config_ids] + configs_array = convert_configurations_to_array(configurations) + return configs_array + def get_configurations( self, budget_subset: list | None = None, @@ -236,11 +266,31 @@ def get_configurations( t_trials = self._get_timeout_trials(budget_subset) t_config_ids = set(t_trial.config_id for t_trial in t_trials) config_ids = s_config_ids | t_config_ids - configurations = [self.runhistory._ids_config[config_id] for config_id in config_ids] - configs_array = convert_configurations_to_array(configurations) + configs_array = self._convert_config_ids_to_array(config_ids) return configs_array + def get_running_configurations( + self, + budget_subset: list | None = None, + ) -> np.ndarray: + """Returns vector representation of the configurations that are still running. + + Parameters + ---------- + budget_subset : list | None, defaults to none + List of budgets to consider. + + Returns + ------- + X : np.ndarray + Configuration vector and instance features. + """ + r_trials = self._get_running_trials(budget_subset) + r_ids = set(r_trial.config_id for r_trial in r_trials) + configs_array = self._convert_config_ids_to_array(r_ids) + return configs_array + def transform( self, budget_subset: list | None = None, @@ -282,6 +332,18 @@ def transform( logger.debug("Converted %d observations." % (X.shape[0])) return X, Y + def transform_running_configs( + self, + budget_subset: list | None = None, + ) -> np.ndarray: + """Return the running configurations""" + logger.debug("Transforming Running Configurations into X format...") + running_trials = self._get_running_trials(budget_subset) + # Y is not required for running configurations + X, _ = self._build_matrix(trials=running_trials, store_statistics=True) + logger.debug("Converted %d running observations." % (X.shape[0])) + return X + @abstractmethod def transform_response_values( self, From 6b86808a040c88ad82a4101e921d9c31e26ed42b Mon Sep 17 00:00:00 2001 From: dengdifan Date: Wed, 23 Oct 2024 15:50:32 +0200 Subject: [PATCH 02/23] add options for batch sampling --- smac/main/config_selector.py | 69 ++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index 42a72f02b..9ec2d25f4 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -16,6 +16,7 @@ from smac.callback.callback import Callback from smac.initial_design import AbstractInitialDesign from smac.model.abstract_model import AbstractModel +from smac.model.gaussian_process import GaussianProcess from smac.random_design.abstract_random_design import AbstractRandomDesign from smac.runhistory.encoder.abstract_encoder import AbstractRunHistoryEncoder from smac.runhistory.runhistory import RunHistory @@ -53,6 +54,7 @@ def __init__( retrain_after: int = 8, retries: int = 16, min_trials: int = 1, + batch_sampling_estimating_strategy: str = 'CL_mean', ) -> None: # Those are the configs sampled from the passed initial design # Selecting configurations from initial design @@ -82,6 +84,9 @@ def __init__( # Processed configurations should be stored here; this is important to not return the same configuration twice self._processed_configs: list[Configuration] = [] + # for batch sampling setting + self._batch_sampling_estimating_strategy = batch_sampling_estimating_strategy + def _set_components( self, initial_design: AbstractInitialDesign, @@ -284,6 +289,20 @@ def _collect_data(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: # Possible add running configs? configs_array = self._runhistory_encoder.get_configurations(budget_subset=self._considered_budgets) + # add running configurations + # If our batch size is 1, then no running configuration should exist, we could then skip this part. + # Therefore, there is no need to check the number of workers in this case + + X_running = self._runhistory_encoder.transform_running_configs(budget_subset=[b]) + Y_estimated = self.estimate_running_config_costs(X_running, Y, self._batch_sampling_estimating_strategy) + if Y_estimated is not None: + configs_array_running = self._runhistory_encoder.get_running_configurations( + budget_subset=self._considered_budgets + ) + X = np.concatenate([X, X_running], dim=0) + Y = np.concatenate([Y, Y_estimated], dim=0) + configs_array = np.concatenate([configs_array, configs_array_running], dim=0) + return X, Y, configs_array return ( @@ -300,6 +319,56 @@ def _get_evaluated_configs(self) -> list[Configuration]: assert self._runhistory is not None return self._runhistory.get_configs_per_budget(budget_subset=self._considered_budgets) + def estimate_running_config_costs( + self, + X_running: np.ndarray, + Y_evaluated: np.ndarray, + estimate_strategy: str = 'CL_max'): + """ + This function is implemented to estimate the still pending/ running configurations + Parameters + ---------- + X_running : np.ndarray + a np array with size (n_running_configs, D) that represents the array values of the running configurations + Y_evaluated : np.ndarray + a np array with size (n_evaluated_configs, n_obj) that records the costs of all the previous evaluated + configurations + estimate_strategy: str + how do we estimate the target y_running values + + Returns + ------- + Y_running_estimated : np.ndarray + the estimated running y values + """ + n_running_points = len(X_running) + if n_running_points == 0: + return None + if estimate_strategy == 'CL_max': + # constant liar max, we take the maximal values of all the evaluated Y and apply them to the running X + Y_estimated = np.max(Y_evaluated, dim=0, keepdims=True) + return np.repeat(Y_estimated, n_running_points, 0) + elif estimate_strategy == 'CL_min': + # constant liar min, we take the minimal values of all the evaluated Y and apply them to the running X + Y_estimated = np.max(Y_evaluated, dim=0, keepdims=True) + return np.repeat(Y_estimated, n_running_points, 0) + elif estimate_strategy == 'CL_mean': + # constant liar min, we take the minimal values of all the evaluated Y and apply them to the running X + Y_estimated = np.mean(Y_evaluated, dim=0, keepdims=True) + return np.repeat(Y_estimated, n_running_points, 0) + elif estimate_strategy == 'kriging_believer': + # in kriging believer, we apply the predicted means of the surrogate model to estimate the running X + return self._model.predict_marginalized(X_running)[0] + elif estimate_strategy == 'sample': + # https://papers.nips.cc/paper_files/paper/2012/file/05311655a15b75fab86956663e1819cd-Paper.pdf + # since this requires a multi-variant gaussian distribution, we need to restrict the model needs to be a + # gaussian process + assert isinstance(self._model, GaussianProcess), 'Sample based estimate strategy only allows ' \ + 'GP as surrogate model!' + return self._model.sample_functions(X_test=X_running, n_funcs=1) + else: + raise ValueError(f'Unknown estimating strategy: {estimate_strategy}') + def _get_x_best(self, X: np.ndarray) -> tuple[np.ndarray, float]: """Get value, configuration, and array representation of the *best* configuration. From 06719d277f419d1f507a6c6695cd38c3a8556cf5 Mon Sep 17 00:00:00 2001 From: dengdifan Date: Tue, 29 Oct 2024 18:05:03 +0100 Subject: [PATCH 03/23] maint constant liar with nan values --- smac/main/config_selector.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index 9ec2d25f4..9034a7974 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -299,9 +299,9 @@ def _collect_data(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: configs_array_running = self._runhistory_encoder.get_running_configurations( budget_subset=self._considered_budgets ) - X = np.concatenate([X, X_running], dim=0) - Y = np.concatenate([Y, Y_estimated], dim=0) - configs_array = np.concatenate([configs_array, configs_array_running], dim=0) + X = np.concatenate([X, X_running], axis=0) + Y = np.concatenate([Y, Y_estimated], axis=0) + configs_array = np.concatenate([configs_array, configs_array_running], axis=0) return X, Y, configs_array @@ -346,15 +346,15 @@ def estimate_running_config_costs( return None if estimate_strategy == 'CL_max': # constant liar max, we take the maximal values of all the evaluated Y and apply them to the running X - Y_estimated = np.max(Y_evaluated, dim=0, keepdims=True) + Y_estimated = np.nanmax(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) elif estimate_strategy == 'CL_min': # constant liar min, we take the minimal values of all the evaluated Y and apply them to the running X - Y_estimated = np.max(Y_evaluated, dim=0, keepdims=True) + Y_estimated = np.nanmin(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) elif estimate_strategy == 'CL_mean': - # constant liar min, we take the minimal values of all the evaluated Y and apply them to the running X - Y_estimated = np.mean(Y_evaluated, dim=0, keepdims=True) + # constant liar min, we take the mean values of all the evaluated Y and apply them to the running X + Y_estimated = np.nanmean(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) elif estimate_strategy == 'kriging_believer': # in kriging believer, we apply the predicted means of the surrogate model to estimate the running X From 2487bc33b0957a4a362f958cc39c25dcef4b1669 Mon Sep 17 00:00:00 2001 From: dengdifan Date: Mon, 2 Dec 2024 15:46:53 +0100 Subject: [PATCH 04/23] add docs --- smac/main/config_selector.py | 45 ++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index 3efe4cb03..3b717f607 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -45,6 +45,14 @@ class ConfigSelector: the highest budgets are checked first. For example, if min_trials is three, but we find only two trials in the runhistory for the highest budget, we will use trials of a lower budget instead. + batch_sampling_estimation_strategy: str, defaults to no_estimation + Batch sample setting, this is applied for parallel setting. During batch sampling, ConfigSelectors might need + to suggest new samples while some configurations are still running. This argument determines if we want to make + use of this information and fantasize the new estimations. If no_estimate is applied, we do not use the + information from the running configurations. If the strategy is kriging_believer, we use the predicted mean from + our surrogate model as the estimations for the new samples. If the strategy is CL_min/mean/max, we use the + min/mean/max from the existing evaluations as the estimations for the new samples. if the strategy is sample, + we use our surrogate model (in this case, only GP is allowed) to sample new configurations """ def __init__( @@ -54,7 +62,7 @@ def __init__( retrain_after: int = 8, retries: int = 16, min_trials: int = 1, - batch_sampling_estimating_strategy: str = 'CL_mean', + batch_sampling_estimation_strategy: str = "no_estimate", ) -> None: # Those are the configs sampled from the passed initial design # Selecting configurations from initial design @@ -85,7 +93,7 @@ def __init__( self._processed_configs: list[Configuration] = [] # for batch sampling setting - self._batch_sampling_estimating_strategy = batch_sampling_estimating_strategy + self._batch_sampling_estimation_strategy = batch_sampling_estimation_strategy def _set_components( self, @@ -294,14 +302,17 @@ def _collect_data(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: # Therefore, there is no need to check the number of workers in this case X_running = self._runhistory_encoder.transform_running_configs(budget_subset=[b]) - Y_estimated = self.estimate_running_config_costs(X_running, Y, self._batch_sampling_estimating_strategy) - if Y_estimated is not None: - configs_array_running = self._runhistory_encoder.get_running_configurations( - budget_subset=self._considered_budgets + if self._batch_sampling_estimation_strategy != 'no_estimate': + Y_estimated = self.estimate_running_config_costs( + X_running, Y, self._batch_sampling_estimation_strategy ) - X = np.concatenate([X, X_running], axis=0) - Y = np.concatenate([Y, Y_estimated], axis=0) - configs_array = np.concatenate([configs_array, configs_array_running], axis=0) + if Y_estimated is not None: + configs_array_running = self._runhistory_encoder.get_running_configurations( + budget_subset=self._considered_budgets + ) + X = np.concatenate([X, X_running], axis=0) + Y = np.concatenate([Y, Y_estimated], axis=0) + configs_array = np.concatenate([configs_array, configs_array_running], axis=0) return X, Y, configs_array @@ -323,7 +334,7 @@ def estimate_running_config_costs( self, X_running: np.ndarray, Y_evaluated: np.ndarray, - estimate_strategy: str = 'CL_max'): + estimation_strategy: str = 'CL_max'): """ This function is implemented to estimate the still pending/ running configurations Parameters @@ -333,7 +344,7 @@ def estimate_running_config_costs( Y_evaluated : np.ndarray a np array with size (n_evaluated_configs, n_obj) that records the costs of all the previous evaluated configurations - estimate_strategy: str + estimation_strategy: str how do we estimate the target y_running values Returns @@ -344,22 +355,22 @@ def estimate_running_config_costs( n_running_points = len(X_running) if n_running_points == 0: return None - if estimate_strategy == 'CL_max': + if estimation_strategy == 'CL_max': # constant liar max, we take the maximal values of all the evaluated Y and apply them to the running X Y_estimated = np.nanmax(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) - elif estimate_strategy == 'CL_min': + elif estimation_strategy == 'CL_min': # constant liar min, we take the minimal values of all the evaluated Y and apply them to the running X Y_estimated = np.nanmin(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) - elif estimate_strategy == 'CL_mean': + elif estimation_strategy == 'CL_mean': # constant liar min, we take the mean values of all the evaluated Y and apply them to the running X Y_estimated = np.nanmean(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) - elif estimate_strategy == 'kriging_believer': + elif estimation_strategy == 'kriging_believer': # in kriging believer, we apply the predicted means of the surrogate model to estimate the running X return self._model.predict_marginalized(X_running)[0] - elif estimate_strategy == 'sample': + elif estimation_strategy == 'sample': # https://papers.nips.cc/paper_files/paper/2012/file/05311655a15b75fab86956663e1819cd-Paper.pdf # since this requires a multi-variant gaussian distribution, we need to restrict the model needs to be a # gaussian process @@ -367,7 +378,7 @@ def estimate_running_config_costs( 'GP as surrogate model!' return self._model.sample_functions(X_test=X_running, n_funcs=1) else: - raise ValueError(f'Unknown estimating strategy: {estimate_strategy}') + raise ValueError(f'Unknown estimating strategy: {estimation_strategy}') def _get_x_best(self, X: np.ndarray) -> tuple[np.ndarray, float]: """Get value, configuration, and array representation of the *best* configuration. From d3f4f11ad616566efe380dc826dce005a81799ae Mon Sep 17 00:00:00 2001 From: dengdifan Date: Mon, 2 Dec 2024 15:47:10 +0100 Subject: [PATCH 05/23] tests for config selectors --- tests/test_main/test_config_selector.py | 101 ++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/test_main/test_config_selector.py diff --git a/tests/test_main/test_config_selector.py b/tests/test_main/test_config_selector.py new file mode 100644 index 000000000..36b2bf5a8 --- /dev/null +++ b/tests/test_main/test_config_selector.py @@ -0,0 +1,101 @@ +from __future__ import annotations +import pytest + +from ConfigSpace import ConfigurationSpace, Configuration, Float +import numpy as np + +from smac.runhistory.dataclasses import TrialValue +from smac.acquisition.function.confidence_bound import LCB +from smac.initial_design.random_design import RandomInitialDesign +from smac import BlackBoxFacade, HyperparameterOptimizationFacade, Scenario +from smac.main.config_selector import ConfigSelector +from smac.main import config_selector + + +def test_estimated_config_values_are_trained_by_models(rosenbrock): + scenario = Scenario(rosenbrock.configspace, n_trials=100, n_workers=2, deterministic=True) + smac = BlackBoxFacade( + scenario, + rosenbrock.train, # We pass the target function here + overwrite=True, # Overrides any previous results that are found that are inconsistent with the meta-data + config_selector=ConfigSelector( + scenario=scenario, + retrain_after=1, + batch_sampling_estimation_strategy='no_estimate' + ), + initial_design=BlackBoxFacade.get_initial_design(scenario=scenario, n_configs=5), + acquisition_function=LCB() # this ensures that we can record the number of data in the acquisition function + ) + # we first initialize multiple configurations as the starting points + + n_data_in_acq_func = 5 + for _ in range(n_data_in_acq_func): + info = smac.ask() # we need the seed from the configuration + + cost = rosenbrock.train(info.config, seed=info.seed, budget=info.budget, instance=info.instance) + value = TrialValue(cost=cost, time=0.5) + + smac.tell(info, value) + + # for naive approach, no point configuration values is hallucinate + all_asked_infos = [] + for i in range(3): + all_asked_infos.append(smac.ask()) + assert smac._acquisition_function._num_data == n_data_in_acq_func + + # each time when we provide a new running configuration, we can estimate the configuration values for new + # suggestions and use this information to retrain our model. Hence, each time a new point is asked, we should + # have _num_data +1 for LCB model + + n_data_in_acq_func += 3 + for estimate_strategy in ['CL_max', 'CL_min', 'CL_mean', 'kriging_believer', 'sample']: + smac._config_selector._batch_sampling_estimation_strategy = estimate_strategy + for i in range(3): + all_asked_infos.append(smac.ask()) + assert smac._acquisition_function._num_data == n_data_in_acq_func + n_data_in_acq_func += 1 + + for info in all_asked_infos: + value = TrialValue(cost=rosenbrock.train(info.config, instance=info.instance, seed=info.seed), ) + smac.tell(info=info, value=value) + + # now we recover to the vanilla approach, in this case, all the evaluations are exact evaluations, the number of + # data in the runhistory should not increase + _ = smac.ask() + assert smac._acquisition_function._num_data == n_data_in_acq_func + + +@pytest.mark.parametrize("estimation_strategy", ['CL_max', 'CL_min', 'CL_mean', 'kriging_believer', 'sample']) +def test_batch_estimation_methods(rosenbrock, estimation_strategy): + config_space = rosenbrock.configspace + scenario = Scenario(config_space, n_trials=100, n_workers=2, deterministic=True) + config_selector = ConfigSelector( + scenario=scenario, + retrain_after=1, + batch_sampling_estimation_strategy=estimation_strategy + ) + model = BlackBoxFacade.get_model(scenario=scenario) + X_evaluated = config_space.sample_configuration(5) + y_train = np.asarray([rosenbrock.train(x) for x in X_evaluated]) + x_train = np.asarray([x.get_array() for x in X_evaluated]) + + model.train(x_train, y_train) + + X_running = np.asarray([x.get_array() for x in config_space.sample_configuration(3)]) + config_selector._model = model + + estimations = config_selector.estimate_running_config_costs( + X_running, y_train, estimation_strategy=estimation_strategy, + ) + if estimation_strategy == 'CL_max': + assert (estimations == y_train.max()).all() + elif estimation_strategy == 'CL_min': + assert (estimations == y_train.min()).all() + elif estimation_strategy == 'CL_mean': + assert (estimations == y_train.mean()).all() + else: + if estimation_strategy == 'kriging_believer': + assert np.allclose(model.predict_marginalized(X_running)[0], estimations) + else: + # for sampling strategy, we simply check if the shape of the two results are the same + assert np.equal(estimations.shape, (3, 1)).all() From 3c2196ab68009766aa12fb5d0205b8a57837fe52 Mon Sep 17 00:00:00 2001 From: dengdifan Date: Thu, 19 Dec 2024 15:34:10 +0100 Subject: [PATCH 06/23] solve conflict --- smac/main/config_selector.py | 19 ++++++++++++++----- smac/runhistory/encoder/abstract_encoder.py | 16 +++++++++++++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index 3b717f607..ad4abf118 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -306,6 +306,7 @@ def _collect_data(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: Y_estimated = self.estimate_running_config_costs( X_running, Y, self._batch_sampling_estimation_strategy ) + # if there is no running configurations, we directly return X, Y and configs_array if Y_estimated is not None: configs_array_running = self._runhistory_encoder.get_running_configurations( budget_subset=self._considered_budgets @@ -335,8 +336,8 @@ def estimate_running_config_costs( X_running: np.ndarray, Y_evaluated: np.ndarray, estimation_strategy: str = 'CL_max'): - """ - This function is implemented to estimate the still pending/ running configurations + """This function is implemented to estimate the still pending/ running configurations + Parameters ---------- X_running : np.ndarray @@ -344,8 +345,16 @@ def estimate_running_config_costs( Y_evaluated : np.ndarray a np array with size (n_evaluated_configs, n_obj) that records the costs of all the previous evaluated configurations + estimation_strategy: str - how do we estimate the target y_running values + how do we estimate the target y_running values, we have the following strategy: + CL_max: constant liar max, we take the maximal of all the evaluated Y and apply them to the running X + CL_min: constant liar min, we take the minimal of all the evaluated Y and apply them to the running X + CL_mean: constant liar mean, we take the mean of all the evaluated Y and apply them to the running X + kriging_believer: kriging believer, we apply the predicted means from the surrogate model to running X + values + sample: estimations for X are sampled from the surrogate models. Since the samples need to be sampled from a + joint distribution for all X, we only allow sample strategy with GP as surrogate models. Returns ------- @@ -364,11 +373,11 @@ def estimate_running_config_costs( Y_estimated = np.nanmin(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) elif estimation_strategy == 'CL_mean': - # constant liar min, we take the mean values of all the evaluated Y and apply them to the running X + # constant liar mean, we take the mean values of all the evaluated Y and apply them to the running X Y_estimated = np.nanmean(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) elif estimation_strategy == 'kriging_believer': - # in kriging believer, we apply the predicted means of the surrogate model to estimate the running X + # kriging believer, we apply the predicted means of the surrogate model to estimate the running X return self._model.predict_marginalized(X_running)[0] elif estimation_strategy == 'sample': # https://papers.nips.cc/paper_files/paper/2012/file/05311655a15b75fab86956663e1819cd-Paper.pdf diff --git a/smac/runhistory/encoder/abstract_encoder.py b/smac/runhistory/encoder/abstract_encoder.py index f4c6a1ba6..f49b14903 100644 --- a/smac/runhistory/encoder/abstract_encoder.py +++ b/smac/runhistory/encoder/abstract_encoder.py @@ -198,7 +198,6 @@ def _get_running_trials( trial: self.runhistory[trial] for trial in self.runhistory if self.runhistory[trial].status == StatusType.RUNNING - # and runhistory.data[run].time >= self._algorithm_walltime_limit # type: ignore and trial.budget in budget_subset } else: @@ -206,7 +205,6 @@ def _get_running_trials( trial: self.runhistory[trial] for trial in self.runhistory if self.runhistory[trial].status == StatusType.RUNNING - # and runhistory.data[run].time >= self._algorithm_walltime_limit # type: ignore } return trials @@ -236,7 +234,19 @@ def _get_timeout_trials( def _convert_config_ids_to_array(self, config_ids: Iterable[int]) -> np.ndarray: - """extract the configurations from rh and transform them into np array""" + """extract the configurations from runhistory with their ids and transform them into np array + + Parameters + ---------- + config_ids : Iterable[int] + a collections of configuration ids + + Returns + ------- + configs_array : np.ndarray + the corresponding configuration arrays + + """ configurations = [self.runhistory._ids_config[config_id] for config_id in config_ids] configs_array = convert_configurations_to_array(configurations) return configs_array From 5f3ae8e2b1391a5ef7a0dc3840ac1fa9c17eecf8 Mon Sep 17 00:00:00 2001 From: dengdifan Date: Wed, 8 Jan 2025 15:16:00 +0100 Subject: [PATCH 07/23] maint doc --- smac/main/config_selector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index ad4abf118..d52e55a55 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -381,8 +381,8 @@ def estimate_running_config_costs( return self._model.predict_marginalized(X_running)[0] elif estimation_strategy == 'sample': # https://papers.nips.cc/paper_files/paper/2012/file/05311655a15b75fab86956663e1819cd-Paper.pdf - # since this requires a multi-variant gaussian distribution, we need to restrict the model needs to be a - # gaussian process + # since this requires a multi-variant gaussian distribution for the candidates, we need to restrict the + # model to be a gaussian process assert isinstance(self._model, GaussianProcess), 'Sample based estimate strategy only allows ' \ 'GP as surrogate model!' return self._model.sample_functions(X_test=X_running, n_funcs=1) From a1f7c329dafbd748d17683a4e34fe7494b9e2bfb Mon Sep 17 00:00:00 2001 From: benjamc Date: Mon, 13 Jan 2025 09:43:00 +0100 Subject: [PATCH 08/23] style(config_selector) --- smac/main/config_selector.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index d52e55a55..0b1144eac 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -51,7 +51,7 @@ class ConfigSelector: use of this information and fantasize the new estimations. If no_estimate is applied, we do not use the information from the running configurations. If the strategy is kriging_believer, we use the predicted mean from our surrogate model as the estimations for the new samples. If the strategy is CL_min/mean/max, we use the - min/mean/max from the existing evaluations as the estimations for the new samples. if the strategy is sample, + min/mean/max from the existing evaluations as the estimations for the new samples. If the strategy is sample, we use our surrogate model (in this case, only GP is allowed) to sample new configurations """ @@ -302,7 +302,7 @@ def _collect_data(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: # Therefore, there is no need to check the number of workers in this case X_running = self._runhistory_encoder.transform_running_configs(budget_subset=[b]) - if self._batch_sampling_estimation_strategy != 'no_estimate': + if self._batch_sampling_estimation_strategy != "no_estimate": Y_estimated = self.estimate_running_config_costs( X_running, Y, self._batch_sampling_estimation_strategy ) @@ -332,10 +332,8 @@ def _get_evaluated_configs(self) -> list[Configuration]: return self._runhistory.get_configs_per_budget(budget_subset=self._considered_budgets) def estimate_running_config_costs( - self, - X_running: np.ndarray, - Y_evaluated: np.ndarray, - estimation_strategy: str = 'CL_max'): + self, X_running: np.ndarray, Y_evaluated: np.ndarray, estimation_strategy: str = "CL_max" + ) -> np.ndarray: """This function is implemented to estimate the still pending/ running configurations Parameters @@ -364,30 +362,31 @@ def estimate_running_config_costs( n_running_points = len(X_running) if n_running_points == 0: return None - if estimation_strategy == 'CL_max': + if estimation_strategy == "CL_max": # constant liar max, we take the maximal values of all the evaluated Y and apply them to the running X Y_estimated = np.nanmax(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) - elif estimation_strategy == 'CL_min': + elif estimation_strategy == "CL_min": # constant liar min, we take the minimal values of all the evaluated Y and apply them to the running X Y_estimated = np.nanmin(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) - elif estimation_strategy == 'CL_mean': + elif estimation_strategy == "CL_mean": # constant liar mean, we take the mean values of all the evaluated Y and apply them to the running X Y_estimated = np.nanmean(Y_evaluated, axis=0, keepdims=True) return np.repeat(Y_estimated, n_running_points, 0) - elif estimation_strategy == 'kriging_believer': + elif estimation_strategy == "kriging_believer": # kriging believer, we apply the predicted means of the surrogate model to estimate the running X - return self._model.predict_marginalized(X_running)[0] - elif estimation_strategy == 'sample': + return self._model.predict_marginalized(X_running)[0] # type: ignore[union-attr] + elif estimation_strategy == "sample": # https://papers.nips.cc/paper_files/paper/2012/file/05311655a15b75fab86956663e1819cd-Paper.pdf # since this requires a multi-variant gaussian distribution for the candidates, we need to restrict the # model to be a gaussian process - assert isinstance(self._model, GaussianProcess), 'Sample based estimate strategy only allows ' \ - 'GP as surrogate model!' + assert isinstance(self._model, GaussianProcess), ( + "Sample based estimate strategy only allows " "GP as surrogate model!" + ) return self._model.sample_functions(X_test=X_running, n_funcs=1) else: - raise ValueError(f'Unknown estimating strategy: {estimation_strategy}') + raise ValueError(f"Unknown estimating strategy: {estimation_strategy}") def _get_x_best(self, X: np.ndarray) -> tuple[np.ndarray, float]: """Get value, configuration, and array representation of the *best* configuration. From f68fee525e5856f1b659eac1e444dc841d9bb59e Mon Sep 17 00:00:00 2001 From: benjamc Date: Mon, 13 Jan 2025 09:50:14 +0100 Subject: [PATCH 09/23] style(abstract_encoder) --- smac/runhistory/encoder/abstract_encoder.py | 67 +++++++++++++++------ 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/smac/runhistory/encoder/abstract_encoder.py b/smac/runhistory/encoder/abstract_encoder.py index f49b14903..b52f80063 100644 --- a/smac/runhistory/encoder/abstract_encoder.py +++ b/smac/runhistory/encoder/abstract_encoder.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any, Mapping, Iterable +from typing import Any, Iterable, Mapping import numpy as np @@ -189,16 +189,27 @@ def _get_considered_trials( return trials def _get_running_trials( - self, - budget_subset: list | None = None, + self, + budget_subset: list | None = None, ) -> dict[TrialKey, TrialValue]: - """Returns all trials that are still running.""" + """Returns all trials that are still running. + + Parameters + ---------- + budget_subset : list | None + If None, retrieve all running trials. Otherwise, retrieve only running + trials with budgets in budget_subset. + + Returns + ------- + trials : dict[TrialKey, TrialValue] + A dictionary containing the running trials. + """ if budget_subset is not None: trials = { trial: self.runhistory[trial] for trial in self.runhistory - if self.runhistory[trial].status == StatusType.RUNNING - and trial.budget in budget_subset + if self.runhistory[trial].status == StatusType.RUNNING and trial.budget in budget_subset } else: trials = { @@ -213,7 +224,19 @@ def _get_timeout_trials( self, budget_subset: list | None = None, ) -> dict[TrialKey, TrialValue]: - """Returns all trials that did have a timeout.""" + """Returns all trials that did have a timeout. + + Parameters + ---------- + budget_subset : list | None + If None, retrieve all timeout trials. Otherwise, retrieve only timeout + trials with budgets in budget_subset. + + Returns + ------- + trials : dict[TrialKey, TrialValue] + A dictionary containing the timeout trials. + """ if budget_subset is not None: trials = { trial: self.runhistory[trial] @@ -232,19 +255,18 @@ def _get_timeout_trials( return trials - def _convert_config_ids_to_array(self, - config_ids: Iterable[int]) -> np.ndarray: - """extract the configurations from runhistory with their ids and transform them into np array + def _convert_config_ids_to_array(self, config_ids: Iterable[int]) -> np.ndarray: + """Extract the configurations from runhistory from their ids and transform them into np.ndarray. Parameters ---------- config_ids : Iterable[int] - a collections of configuration ids + A collections of configuration ids. Returns ------- configs_array : np.ndarray - the corresponding configuration arrays + The corresponding configuration arrays. """ configurations = [self.runhistory._ids_config[config_id] for config_id in config_ids] @@ -281,8 +303,8 @@ def get_configurations( return configs_array def get_running_configurations( - self, - budget_subset: list | None = None, + self, + budget_subset: list | None = None, ) -> np.ndarray: """Returns vector representation of the configurations that are still running. @@ -343,10 +365,21 @@ def transform( return X, Y def transform_running_configs( - self, - budget_subset: list | None = None, + self, + budget_subset: list | None = None, ) -> np.ndarray: - """Return the running configurations""" + """Transform the running configurations. + + Parameters + ---------- + budget_subset : list | None, defaults to none + List of budgets to consider. + + Returns + ------- + X : np.ndarray + Configuration vector and instance features. + """ logger.debug("Transforming Running Configurations into X format...") running_trials = self._get_running_trials(budget_subset) # Y is not required for running configurations From 691b1a93d22440612f78d5fbf8f98a9c8b598fc2 Mon Sep 17 00:00:00 2001 From: benjamc Date: Mon, 13 Jan 2025 09:53:48 +0100 Subject: [PATCH 10/23] Update CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2e284b4..cbd4f7afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 2.3.0 +## Features +- Improved batch sampling: Fantasize points in batch/parallel mode (#1154). + ## Documentation - Update windows install guide (#952) - Correct intensifier for Algorithm Configuration Facade (#1162, #1165) From 3cf17483fe261b80933880a2cca89d05867b8ef6 Mon Sep 17 00:00:00 2001 From: benjamc Date: Mon, 13 Jan 2025 12:33:38 +0100 Subject: [PATCH 11/23] refactor(config_selector): pass all args in the facades --- smac/facade/abstract_facade.py | 38 ++++++++++++++++++++++++++++++++-- smac/facade/blackbox_facade.py | 36 ++++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/smac/facade/abstract_facade.py b/smac/facade/abstract_facade.py index fd153cdb8..41639248f 100644 --- a/smac/facade/abstract_facade.py +++ b/smac/facade/abstract_facade.py @@ -420,9 +420,43 @@ def get_config_selector( *, retrain_after: int = 8, retries: int = 16, + min_trials: int = 1, + batch_sampling_estimation_strategy: str = "no_estimation", ) -> ConfigSelector: - """Returns the default configuration selector.""" - return ConfigSelector(scenario, retrain_after=retrain_after, retries=retries) + """Returns the default configuration selector. + + Parameters + ---------- + retrain_after : int, defaults to 8 + How many configurations should be returned before the surrogate model is retrained. + retries : int, defaults to 16 + How often to retry receiving a new configuration before giving up. + min_trials: int, defaults to 1 + How many samples are required to train the surrogate model. If budgets are involved, + the highest budgets are checked first. For example, if min_trials is three, but we find only + two trials in the runhistory for the highest budget, we will use trials of a lower budget + instead. + batch_sampling_estimation_strategy: str, defaults to no_estimation + Batch sample setting, this is applied for parallel setting. During batch sampling, ConfigSelectors might + need to suggest new samples while some configurations are still running. This argument determines if we want + to make use of this information and fantasize the new estimations. If no_estimate is applied, we do not use + the information from the running configurations. If the strategy is kriging_believer, we use the predicted + mean from our surrogate model as the estimations for the new samples. If the strategy is CL_min/mean/max, we + use the min/mean/max from the existing evaluations as the estimations for the new samples. If the strategy + is sample, we use our surrogate model (in this case, only GP is allowed) to sample new configurations. + + Returns + ------- + ConfigSelector + The instantiated configuration selector proposing new configurations (optimize acquisition function). + """ + return ConfigSelector( + scenario, + retrain_after=retrain_after, + retries=retries, + min_trials=min_trials, + batch_sampling_estimation_strategy=batch_sampling_estimation_strategy, + ) def _get_optimizer(self) -> SMBO: """Fills the SMBO with all the pre-initialized components.""" diff --git a/smac/facade/blackbox_facade.py b/smac/facade/blackbox_facade.py index a36a44f81..b3378834a 100644 --- a/smac/facade/blackbox_facade.py +++ b/smac/facade/blackbox_facade.py @@ -318,9 +318,41 @@ def get_config_selector( scenario: Scenario, *, retrain_after: int = 1, + min_trials: int = 1, retries: int = 16, + batch_sampling_estimation_strategy: str = "no_estimate", ) -> ConfigSelector: - """Returns the default configuration selector.""" + """Returns the default configuration selector. + + Parameters + ---------- + retrain_after : int, defaults to 1 + How many configurations should be returned before the surrogate model is retrained. + retries : int, defaults to 16 + How often to retry receiving a new configuration before giving up. + min_trials: int, defaults to 1 + How many samples are required to train the surrogate model. If budgets are involved, + the highest budgets are checked first. For example, if min_trials is three, but we find only + two trials in the runhistory for the highest budget, we will use trials of a lower budget + instead. + batch_sampling_estimation_strategy: str, defaults to no_estimation + Batch sample setting, this is applied for parallel setting. During batch sampling, ConfigSelectors might + need to suggest new samples while some configurations are still running. This argument determines if we want + to make use of this information and fantasize the new estimations. If no_estimate is applied, we do not use + the information from the running configurations. If the strategy is kriging_believer, we use the predicted + mean from our surrogate model as the estimations for the new samples. If the strategy is CL_min/mean/max, we + use the min/mean/max from the existing evaluations as the estimations for the new samples. If the strategy + is sample, we use our surrogate model (in this case, only GP is allowed) to sample new configurations. + + Returns + ------- + ConfigSelector + The instantiated configuration selector proposing new configurations (optimize acquisition function). + """ return super(BlackBoxFacade, BlackBoxFacade).get_config_selector( - scenario, retrain_after=retrain_after, retries=retries + scenario, + retrain_after=retrain_after, + min_trials=min_trials, + retries=retries, + batch_sampling_estimation_strategy=batch_sampling_estimation_strategy, ) From 62a9588164260c2b0fcbdccc98c3d432421119a3 Mon Sep 17 00:00:00 2001 From: benjamc Date: Mon, 13 Jan 2025 15:21:03 +0100 Subject: [PATCH 12/23] refactor(abstract_facade): fix default, add warning in docstring --- smac/facade/abstract_facade.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/smac/facade/abstract_facade.py b/smac/facade/abstract_facade.py index 41639248f..021608e92 100644 --- a/smac/facade/abstract_facade.py +++ b/smac/facade/abstract_facade.py @@ -421,7 +421,7 @@ def get_config_selector( retrain_after: int = 8, retries: int = 16, min_trials: int = 1, - batch_sampling_estimation_strategy: str = "no_estimation", + batch_sampling_estimation_strategy: str = "no_estimate", ) -> ConfigSelector: """Returns the default configuration selector. @@ -436,7 +436,11 @@ def get_config_selector( the highest budgets are checked first. For example, if min_trials is three, but we find only two trials in the runhistory for the highest budget, we will use trials of a lower budget instead. - batch_sampling_estimation_strategy: str, defaults to no_estimation + batch_sampling_estimation_strategy: str, defaults to no_estimate + + Warning: This is intended to work in the black box optimization setting with a Gaussian Process and + only works sensibly for non-multifidelity. + Batch sample setting, this is applied for parallel setting. During batch sampling, ConfigSelectors might need to suggest new samples while some configurations are still running. This argument determines if we want to make use of this information and fantasize the new estimations. If no_estimate is applied, we do not use From 522c6716d4cbb2d7fe399433f24059923b899649 Mon Sep 17 00:00:00 2001 From: benjamc Date: Mon, 13 Jan 2025 15:22:20 +0100 Subject: [PATCH 13/23] fix(fantasize): check whether model has been trained --- smac/main/config_selector.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index 0b1144eac..24ffe4db5 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -52,7 +52,7 @@ class ConfigSelector: information from the running configurations. If the strategy is kriging_believer, we use the predicted mean from our surrogate model as the estimations for the new samples. If the strategy is CL_min/mean/max, we use the min/mean/max from the existing evaluations as the estimations for the new samples. If the strategy is sample, - we use our surrogate model (in this case, only GP is allowed) to sample new configurations + we use our surrogate model (in this case, only GP is allowed) to sample new configurations. """ def __init__( @@ -376,14 +376,22 @@ def estimate_running_config_costs( return np.repeat(Y_estimated, n_running_points, 0) elif estimation_strategy == "kriging_believer": # kriging believer, we apply the predicted means of the surrogate model to estimate the running X + # Check whether model has been trained already + if isinstance(self._model, GaussianProcess) and not self._model._is_trained: + logger.debug( + "Model has not been trained yet. Skip estimation and use constant liar mean " + "(mean of all samples)." + ) + Y_estimated = np.nanmean(Y_evaluated, axis=0, keepdims=True) + return np.repeat(Y_estimated, n_running_points, 0) return self._model.predict_marginalized(X_running)[0] # type: ignore[union-attr] elif estimation_strategy == "sample": # https://papers.nips.cc/paper_files/paper/2012/file/05311655a15b75fab86956663e1819cd-Paper.pdf # since this requires a multi-variant gaussian distribution for the candidates, we need to restrict the # model to be a gaussian process - assert isinstance(self._model, GaussianProcess), ( - "Sample based estimate strategy only allows " "GP as surrogate model!" - ) + assert isinstance( + self._model, GaussianProcess + ), "Sample based estimate strategy only allows GP as surrogate model!" return self._model.sample_functions(X_test=X_running, n_funcs=1) else: raise ValueError(f"Unknown estimating strategy: {estimation_strategy}") From 99443b4b96b4b8041051bde10dd22815a67795a7 Mon Sep 17 00:00:00 2001 From: benjamc Date: Mon, 13 Jan 2025 15:31:13 +0100 Subject: [PATCH 14/23] feat(fantasize_example): add --- .../1_basics/7_0_parallelization_fantasize.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 examples/1_basics/7_0_parallelization_fantasize.py diff --git a/examples/1_basics/7_0_parallelization_fantasize.py b/examples/1_basics/7_0_parallelization_fantasize.py new file mode 100644 index 000000000..0c2a4dec1 --- /dev/null +++ b/examples/1_basics/7_0_parallelization_fantasize.py @@ -0,0 +1,112 @@ +"""Example of using SMAC with parallelization and fantasization vs. no estimation for pending evaluations. + +This example will take some time because the target function is artificially slowed down to demonstrate the effect of +fantasization. The example will plot the incumbent found by SMAC with and without fantasization. +""" +from __future__ import annotations + +import numpy as np +from ConfigSpace import Configuration, ConfigurationSpace, Float + +from matplotlib import pyplot as plt + +from smac import BlackBoxFacade, Scenario +from smac.facade import AbstractFacade + +from rich import inspect +import time + +def plot_trajectory(facades: list[AbstractFacade], names: list[str]) -> None: + # Plot incumbent + cmap = plt.get_cmap("tab10") + + fig = plt.figure() + axes = fig.subplots(1, 2) + + for ax_i, x_axis in zip(axes, ["walltime", "trial"]): + for i, facade in enumerate(facades): + X, Y = [], [] + inspect(facade.intensifier.trajectory) + for item in facade.intensifier.trajectory: + # Single-objective optimization + assert len(item.config_ids) == 1 + assert len(item.costs) == 1 + + y = item.costs[0] + x = getattr(item, x_axis) + + X.append(x) + Y.append(y) + + ax_i.plot(X, Y, label=names[i], color=cmap(i)) + ax_i.scatter(X, Y, marker="x", color=cmap(i)) + ax_i.set_xlabel(x_axis) + ax_i.set_ylabel(facades[0].scenario.objectives) + ax_i.set_yscale("log") + ax_i.legend() + + plt.show() + +class Branin(): + @property + def configspace(self) -> ConfigurationSpace: + # Build Configuration Space which defines all parameters and their ranges + cs = ConfigurationSpace(seed=0) + + # First we create our hyperparameters + x1 = Float("x1", (-5, 10), default=0) + x2 = Float("x2", (0, 15), default=0) + + # Add hyperparameters and conditions to our configspace + cs.add([x1, x2]) + + time.sleep(10) + + return cs + + def train(self, config: Configuration, seed: int) -> float: + x1 = config["x1"] + x2 = config["x2"] + a = 1.0 + b = 5.1 / (4.0 * np.pi**2) + c = 5.0 / np.pi + r = 6.0 + s = 10.0 + t = 1.0 / (8.0 * np.pi) + + cost = a * (x2 - b * x1**2 + c * x1 - r) ** 2 + s * (1 - t) * np.cos(x1) + s + regret = cost - 0.397887 + + return regret + +if __name__ == "__main__": + seed = 345455 + scenario = Scenario(n_trials=100, configspace=Branin().configspace, n_workers=4, seed=seed) + facade = BlackBoxFacade + + smac_noestimation = facade( + scenario=scenario, + target_function=Branin().train, + overwrite=True, + ) + smac_fantasize = facade( + scenario=scenario, + target_function=Branin().train, + config_selector=facade.get_config_selector( + scenario=scenario, + batch_sampling_estimation_strategy="kriging_believer" + ), + overwrite=True, + logging_level=0 + ) + + incumbent_noestimation = smac_noestimation.optimize() + incumbent_fantasize = smac_fantasize.optimize() + + plot_trajectory(facades=[ + smac_noestimation, + smac_fantasize, + ], names=["No Estimation", "Fantasize"]) + + del smac_noestimation + del smac_fantasize From 6658b88b0e0b8a6e0588420e8c13317ac9d00866 Mon Sep 17 00:00:00 2001 From: benjamc Date: Mon, 13 Jan 2025 15:42:32 +0100 Subject: [PATCH 15/23] fix(config_selector): properly check whether model is trained --- smac/main/config_selector.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index 24ffe4db5..ce046f94a 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -17,6 +17,7 @@ from smac.initial_design import AbstractInitialDesign from smac.model.abstract_model import AbstractModel from smac.model.gaussian_process import GaussianProcess +from smac.model.random_forest import RandomForest from smac.random_design.abstract_random_design import AbstractRandomDesign from smac.runhistory.encoder.abstract_encoder import AbstractRunHistoryEncoder from smac.runhistory.runhistory import RunHistory @@ -377,7 +378,12 @@ def estimate_running_config_costs( elif estimation_strategy == "kriging_believer": # kriging believer, we apply the predicted means of the surrogate model to estimate the running X # Check whether model has been trained already - if isinstance(self._model, GaussianProcess) and not self._model._is_trained: + if ( + isinstance(self._model, GaussianProcess) + and not self._model._is_trained + or isinstance(self._model, RandomForest) + and self._model._rf is None + ): logger.debug( "Model has not been trained yet. Skip estimation and use constant liar mean " "(mean of all samples)." From 198d9786733cc556bbc923a1fb7a5009397f374d Mon Sep 17 00:00:00 2001 From: Daphne12345 Date: Wed, 4 Jun 2025 14:17:02 +0200 Subject: [PATCH 16/23] batch expected improvement --- .../function/expected_improvement.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/smac/acquisition/function/expected_improvement.py b/smac/acquisition/function/expected_improvement.py index fef30fc5a..2444702e1 100644 --- a/smac/acquisition/function/expected_improvement.py +++ b/smac/acquisition/function/expected_improvement.py @@ -286,3 +286,99 @@ def calculate_f() -> np.ndarray: raise ValueError("Expected Improvement per Second is smaller than 0 " "for at least one sample.") return f.reshape((-1, 1)) + + +class QExpectedImprovement(EI): + r""" + Monte Carlo approximation of q-Expected Improvement. + Approximates joint distribution with independent normals. + + :math:`EI(X) := \mathbb{E}\left[ \max\{0, f(\mathbf{X^+}) - f_{t+1}(\mathbf{X}) - \xi \} \right]`, + with :math:`f(X^+)` as the best location. + + Reference for q-EI + + + Parameters + ---------- + xi : float, defaults to 0.0 + Controls the balance between exploration and exploitation of the + acquisition function. + log : bool, defaults to False + Whether the function values are in log-space. + + + Attributes + ---------- + _xi : float + Exploration-exloitation trade-off parameter. + _log: bool + Function values in log-space or not. + _eta : float + Current incumbent function value (best value observed so far). + + """ + + def __init__(self, xi: float = 0.0, n_samples: int = 128) -> None: + super(QExpectedImprovement, self).__init__(xi=xi) + self.n_samples = n_samples + + @property + def name(self) -> str: # noqa: D102 + return "Batch Expected Improvement" + + def _compute(self, X: np.ndarray) -> np.ndarray: + """ + Compute q-EI acquisition value using Monte Carlo approximation. + + Parameters + ---------- + X : np.ndarray [N, D] + The batch of input points to evaluate. + + Returns + ------- + np.ndarray [1, 1] + The q-EI value for the batch as a whole. + """ + assert self._model is not None + assert self._xi is not None + + if self._eta is None: + raise ValueError( + "No current best specified. Call update(eta=) to inform the acquisition function " + "about the current best value." + ) + + if len(X.shape) == 1: + X = X[np.newaxis, :] + + m, var = self._model.predict_marginalized(X) + std = np.sqrt(var) + + if np.any(std == 0.0): + logger.warning("Predicted std is 0.0 for at least one sample.") + std_copy = np.copy(std) + std[std_copy == 0.0] = 1.0 # prevent division by zero + + # Monte Carlo sampling from log-normal distribution + normal_samples = np.random.normal(loc=m.T, scale=std.T, size=(self.n_samples, X.shape[0])) + + if not self._log: + f_samples = normal_samples # in original (normal) space + f_min_sample = np.min(f_samples, axis=1) + improvement = np.maximum(self._eta - self._xi - f_min_sample, 0.0) + else: + # In log-space, the *actual values* are exp(samples) + f_samples = np.exp(normal_samples) + f_min_sample = np.min(f_samples, axis=1) + + # eta is already in log-space, so we compare to exp(eta - xi) + improvement = np.maximum(np.exp(self._eta - self._xi) - f_min_sample, 0.0) + + qei = np.mean(improvement) + + if qei < 0: + raise ValueError("q-Expected Improvement is smaller than 0. Should not happen.") + + return np.array([[qei]]) From dce1f5415339e4301fb9deefdf4fc18d387e7a8d Mon Sep 17 00:00:00 2001 From: Daphne12345 Date: Wed, 4 Jun 2025 14:21:49 +0200 Subject: [PATCH 17/23] created an example --- examples/1_basics/7_1_parallelization_q_ei.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 examples/1_basics/7_1_parallelization_q_ei.py diff --git a/examples/1_basics/7_1_parallelization_q_ei.py b/examples/1_basics/7_1_parallelization_q_ei.py new file mode 100644 index 000000000..f399183e7 --- /dev/null +++ b/examples/1_basics/7_1_parallelization_q_ei.py @@ -0,0 +1,126 @@ +"""Example of using SMAC with parallelization and fantasization vs. no estimation for pending evaluations. + +This example will take some time because the target function is artificially slowed down to demonstrate the effect of +fantasization. The example will plot the incumbent found by SMAC with and without fantasization. +""" +from __future__ import annotations + +import numpy as np +from ConfigSpace import Configuration, ConfigurationSpace, Float + +from matplotlib import pyplot as plt + +from smac import BlackBoxFacade, Scenario +from smac.facade import AbstractFacade +from smac.acquisition.function.expected_improvement import QExpectedImprovement, EI +from smac.acquisition.maximizer.random_search import RandomSearch + +from rich import inspect +import time + +def plot_trajectory(facades: list[AbstractFacade], names: list[str]) -> None: + # Plot incumbent + cmap = plt.get_cmap("tab10") + + fig = plt.figure() + axes = fig.subplots(1, 2) + + for ax_i, x_axis in zip(axes, ["walltime", "trial"]): + for i, facade in enumerate(facades): + X, Y = [], [] + inspect(facade.intensifier.trajectory) + for item in facade.intensifier.trajectory: + # Single-objective optimization + assert len(item.config_ids) == 1 + assert len(item.costs) == 1 + + y = item.costs[0] + x = getattr(item, x_axis) + + X.append(x) + Y.append(y) + + ax_i.plot(X, Y, label=names[i], color=cmap(i)) + ax_i.scatter(X, Y, marker="x", color=cmap(i)) + ax_i.set_xlabel(x_axis) + ax_i.set_ylabel(facades[0].scenario.objectives) + ax_i.set_yscale("log") + ax_i.legend() + + plt.show() + +class Branin(): + @property + def configspace(self) -> ConfigurationSpace: + # Build Configuration Space which defines all parameters and their ranges + cs = ConfigurationSpace(seed=0) + + # First we create our hyperparameters + x1 = Float("x1", (-5, 10), default=0) + x2 = Float("x2", (0, 15), default=0) + + # Add hyperparameters and conditions to our configspace + cs.add([x1, x2]) + + time.sleep(10) + + return cs + + def train(self, config: Configuration, seed: int) -> float: + x1 = config["x1"] + x2 = config["x2"] + a = 1.0 + b = 5.1 / (4.0 * np.pi**2) + c = 5.0 / np.pi + r = 6.0 + s = 10.0 + t = 1.0 / (8.0 * np.pi) + + cost = a * (x2 - b * x1**2 + c * x1 - r) ** 2 + s * (1 - t) * np.cos(x1) + s + regret = cost - 0.397887 + + return regret + +if __name__ == "__main__": + seed = 345455 + scenario = Scenario(n_trials=100, configspace=Branin().configspace, n_workers=4, seed=seed) + facade = BlackBoxFacade + + acq_function = EI() + acq_maximizer = RandomSearch(scenario.configspace, acq_function) + + smac_noestimation = facade( + scenario=scenario, + target_function=Branin().train, + overwrite=True, + acquisition_function=acq_function, + acquisition_maximizer=acq_maximizer + ) + + acq_function_qei = QExpectedImprovement() + acq_maximizer_qei = RandomSearch(scenario.configspace, acquisition_function=acq_function_qei) + + + smac_q_ei = facade( + scenario=scenario, + target_function=Branin().train, + config_selector=facade.get_config_selector( + scenario=scenario, + batch_sampling_estimation_strategy="q_ei" + ), + acquisition_function = acq_function_qei, + acquisition_maximizer=acq_maximizer_qei, + overwrite=True, + logging_level=0 + ) + + incumbent_noestimation = smac_noestimation.optimize() + incumbent_q_ei= smac_q_ei.optimize() + + plot_trajectory(facades=[ + smac_noestimation, + smac_q_ei, + ], names=["No Estimation", "QEI"]) + + # del smac_noestimation + del smac_q_ei From e2c81f1ad261ec0a523c04a6533df4d0f2b4edbb Mon Sep 17 00:00:00 2001 From: Daphne12345 Date: Wed, 4 Jun 2025 14:38:48 +0200 Subject: [PATCH 18/23] adjusted the config selector to work with q_ei --- .../abstract_acquisition_maximizer.py | 1 + smac/main/config_selector.py | 29 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/smac/acquisition/maximizer/abstract_acquisition_maximizer.py b/smac/acquisition/maximizer/abstract_acquisition_maximizer.py index 6c5a52fb8..39a57b28c 100644 --- a/smac/acquisition/maximizer/abstract_acquisition_maximizer.py +++ b/smac/acquisition/maximizer/abstract_acquisition_maximizer.py @@ -74,6 +74,7 @@ def maximize( previous_configs: list[Configuration], n_points: int | None = None, random_design: AbstractRandomDesign | None = None, + **kwargs: Any, ) -> Iterator[Configuration]: """Maximize acquisition function using `_maximize`, implemented by a subclass. diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index 55f29db38..d7486a7f4 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -13,6 +13,7 @@ from smac.acquisition.maximizer.abstract_acquisition_maximizer import ( AbstractAcquisitionMaximizer, ) +from smac.acquisition.maximizer.random_search import RandomSearch from smac.callback.callback import Callback from smac.initial_design import AbstractInitialDesign from smac.model.abstract_model import AbstractModel @@ -225,10 +226,17 @@ def __iter__(self) -> Iterator[Configuration]: self._previous_entries = Y.shape[0] # Now we maximize the acquisition function - challengers = self._acquisition_maximizer.maximize( - previous_configs, - random_design=self._random_design, - ) + if self._batch_sampling_estimation_strategy == "q_ei": + # sets the number of configurations tor eturn to the number of workers and expects random search as + # maximizer with sorted values, so that the acquistion function is called + assert isinstance( + self._acquisition_maximizer, RandomSearch + ), "qExpectedImprovement estimation requires RandomSearch as acquisition maximizer." + challengers = self._acquisition_maximizer.maximize( + previous_configs, random_design=self._random_design, n_points=self._scenario.n_workers, _sorted=True + ) + else: + challengers = self._acquisition_maximizer.maximize(previous_configs, random_design=self._random_design) counter = 0 failed_counter = 0 @@ -356,6 +364,7 @@ def estimate_running_config_costs( values sample: estimations for X are sampled from the surrogate models. Since the samples need to be sampled from a joint distribution for all X, we only allow sample strategy with GP as surrogate models. + q_ei: uses the Q-EI acquisition function to estimate Y. Returns ------- @@ -401,6 +410,18 @@ def estimate_running_config_costs( self._model, GaussianProcess ), "Sample based estimate strategy only allows GP as surrogate model!" return self._model.sample_functions(X_test=X_running, n_funcs=1) + elif estimation_strategy == "q_ei": + assert isinstance( + self._model, GaussianProcess + ), "qExpectedImprovement estimation requires GaussianProcess as surrogate model." + if not self._model._is_trained: + logger.debug("Model not trained. Falling back to mean of evaluated Y.") + Y_estimated = np.nanmean(Y_evaluated, axis=0, keepdims=True) + return np.repeat(Y_estimated, n_running_points, 0) + assert ( + self._acquisition_function is not None + ), "qExpectedImprovement estimation requires qExpectedImprovement as acquisition function." + return self._acquisition_function._compute(X_running) else: raise ValueError(f"Unknown estimating strategy: {estimation_strategy}") From 07487d044adf09502005b0f6a8eaabdcb1d17258 Mon Sep 17 00:00:00 2001 From: Daphne12345 Date: Thu, 12 Jun 2025 13:02:22 +0200 Subject: [PATCH 19/23] added kwargs to the maximize function --- smac/acquisition/maximizer/abstract_acquisition_maximizer.py | 3 ++- smac/acquisition/maximizer/differential_evolution.py | 3 +++ smac/acquisition/maximizer/local_and_random_search.py | 1 + smac/acquisition/maximizer/local_search.py | 1 + smac/acquisition/maximizer/random_search.py | 3 +++ 5 files changed, 10 insertions(+), 1 deletion(-) diff --git a/smac/acquisition/maximizer/abstract_acquisition_maximizer.py b/smac/acquisition/maximizer/abstract_acquisition_maximizer.py index 39a57b28c..9ed7a0d77 100644 --- a/smac/acquisition/maximizer/abstract_acquisition_maximizer.py +++ b/smac/acquisition/maximizer/abstract_acquisition_maximizer.py @@ -103,7 +103,7 @@ def next_configs_by_acquisition_value() -> list[Configuration]: # since maximize returns a tuple of acquisition value and configuration, # and we only need the configuration, we return the second element of the tuple # for each element in the list - return [t[1] for t in self._maximize(previous_configs, n_points)] + return [t[1] for t in self._maximize(previous_configs, n_points, **kwargs)] challengers = ChallengerList( self._configspace, @@ -121,6 +121,7 @@ def _maximize( self, previous_configs: list[Configuration], n_points: int, + **kwargs: Any, ) -> list[tuple[float, Configuration]]: """Implement acquisition function maximization. diff --git a/smac/acquisition/maximizer/differential_evolution.py b/smac/acquisition/maximizer/differential_evolution.py index d4f44e63b..ad85023cb 100644 --- a/smac/acquisition/maximizer/differential_evolution.py +++ b/smac/acquisition/maximizer/differential_evolution.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + import inspect import numpy as np @@ -97,6 +99,7 @@ def _maximize( self, previous_configs: list[Configuration], n_points: int, + **kwargs: Any, ) -> list[tuple[float, Configuration]]: # n_points is not used here, but is required by the interface diff --git a/smac/acquisition/maximizer/local_and_random_search.py b/smac/acquisition/maximizer/local_and_random_search.py index a24122869..cbe1f67a1 100644 --- a/smac/acquisition/maximizer/local_and_random_search.py +++ b/smac/acquisition/maximizer/local_and_random_search.py @@ -144,6 +144,7 @@ def _maximize( self, previous_configs: list[Configuration], n_points: int, + **kwargs: Any, ) -> list[tuple[float, Configuration]]: if self._uniform_configspace is not None and self._prior_sampling_fraction is not None: # Get configurations sorted by acquisition function value diff --git a/smac/acquisition/maximizer/local_search.py b/smac/acquisition/maximizer/local_search.py index 153cef6b4..7933ce559 100644 --- a/smac/acquisition/maximizer/local_search.py +++ b/smac/acquisition/maximizer/local_search.py @@ -89,6 +89,7 @@ def _maximize( previous_configs: list[Configuration], n_points: int, additional_start_points: list[tuple[float, Configuration]] | None = None, + **kwargs: Any, ) -> list[tuple[float, Configuration]]: """Start a local search from the given start points. Iteratively collect neighbours using Configspace.utils.get_one_exchange_neighbourhood and evaluate them. diff --git a/smac/acquisition/maximizer/random_search.py b/smac/acquisition/maximizer/random_search.py index 3d017b945..b03a47d9f 100644 --- a/smac/acquisition/maximizer/random_search.py +++ b/smac/acquisition/maximizer/random_search.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from ConfigSpace import Configuration from smac.acquisition.maximizer.abstract_acquisition_maximizer import ( @@ -21,6 +23,7 @@ def _maximize( previous_configs: list[Configuration], n_points: int, _sorted: bool = False, + **kwargs: Any, ) -> list[tuple[float, Configuration]]: """Maximize acquisition function with random search From f28479845262f57e0ac7c5dc44b82007170a151a Mon Sep 17 00:00:00 2001 From: Daphne12345 Date: Thu, 12 Jun 2025 13:02:57 +0200 Subject: [PATCH 20/23] removed qei from batch_sampling again --- examples/1_basics/7_1_parallelization_q_ei.py | 3 +-- smac/main/config_selector.py | 17 +++-------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/examples/1_basics/7_1_parallelization_q_ei.py b/examples/1_basics/7_1_parallelization_q_ei.py index f399183e7..c885a696a 100644 --- a/examples/1_basics/7_1_parallelization_q_ei.py +++ b/examples/1_basics/7_1_parallelization_q_ei.py @@ -106,7 +106,6 @@ def train(self, config: Configuration, seed: int) -> float: target_function=Branin().train, config_selector=facade.get_config_selector( scenario=scenario, - batch_sampling_estimation_strategy="q_ei" ), acquisition_function = acq_function_qei, acquisition_maximizer=acq_maximizer_qei, @@ -122,5 +121,5 @@ def train(self, config: Configuration, seed: int) -> float: smac_q_ei, ], names=["No Estimation", "QEI"]) - # del smac_noestimation + del smac_noestimation del smac_q_ei diff --git a/smac/main/config_selector.py b/smac/main/config_selector.py index d7486a7f4..c834e6a9d 100644 --- a/smac/main/config_selector.py +++ b/smac/main/config_selector.py @@ -10,6 +10,7 @@ from smac.acquisition.function.abstract_acquisition_function import ( AbstractAcquisitionFunction, ) +from smac.acquisition.function.expected_improvement import QExpectedImprovement from smac.acquisition.maximizer.abstract_acquisition_maximizer import ( AbstractAcquisitionMaximizer, ) @@ -226,12 +227,12 @@ def __iter__(self) -> Iterator[Configuration]: self._previous_entries = Y.shape[0] # Now we maximize the acquisition function - if self._batch_sampling_estimation_strategy == "q_ei": + if isinstance(self._acquisition_function, QExpectedImprovement): # sets the number of configurations tor eturn to the number of workers and expects random search as # maximizer with sorted values, so that the acquistion function is called assert isinstance( self._acquisition_maximizer, RandomSearch - ), "qExpectedImprovement estimation requires RandomSearch as acquisition maximizer." + ), "qExpectedImprovement requires RandomSearch as acquisition maximizer." challengers = self._acquisition_maximizer.maximize( previous_configs, random_design=self._random_design, n_points=self._scenario.n_workers, _sorted=True ) @@ -410,18 +411,6 @@ def estimate_running_config_costs( self._model, GaussianProcess ), "Sample based estimate strategy only allows GP as surrogate model!" return self._model.sample_functions(X_test=X_running, n_funcs=1) - elif estimation_strategy == "q_ei": - assert isinstance( - self._model, GaussianProcess - ), "qExpectedImprovement estimation requires GaussianProcess as surrogate model." - if not self._model._is_trained: - logger.debug("Model not trained. Falling back to mean of evaluated Y.") - Y_estimated = np.nanmean(Y_evaluated, axis=0, keepdims=True) - return np.repeat(Y_estimated, n_running_points, 0) - assert ( - self._acquisition_function is not None - ), "qExpectedImprovement estimation requires qExpectedImprovement as acquisition function." - return self._acquisition_function._compute(X_running) else: raise ValueError(f"Unknown estimating strategy: {estimation_strategy}") From 6d4a808dfd39c256276c470b8f489df6865ff903 Mon Sep 17 00:00:00 2001 From: Daphne12345 Date: Thu, 12 Jun 2025 13:05:35 +0200 Subject: [PATCH 21/23] Sampling from surrogate model --- smac/acquisition/function/expected_improvement.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/smac/acquisition/function/expected_improvement.py b/smac/acquisition/function/expected_improvement.py index 2444702e1..5f7d2bf71 100644 --- a/smac/acquisition/function/expected_improvement.py +++ b/smac/acquisition/function/expected_improvement.py @@ -8,6 +8,7 @@ from smac.acquisition.function.abstract_acquisition_function import ( AbstractAcquisitionFunction, ) +from smac.model.gaussian_process import GaussianProcess from smac.utils.logging import get_logger __copyright__ = "Copyright 2025, Leibniz University Hanover, Institute of AI" @@ -362,7 +363,10 @@ def _compute(self, X: np.ndarray) -> np.ndarray: std[std_copy == 0.0] = 1.0 # prevent division by zero # Monte Carlo sampling from log-normal distribution - normal_samples = np.random.normal(loc=m.T, scale=std.T, size=(self.n_samples, X.shape[0])) + if isinstance(self.model, GaussianProcess): + normal_samples = self.model.sample_functions(X, n_funcs=self.n_samples) + else: + normal_samples = np.random.normal(loc=m.T, scale=std.T, size=(self.n_samples, X.shape[0])) if not self._log: f_samples = normal_samples # in original (normal) space From e1c13eb0d86b253264832ab2fb731e50638f7e53 Mon Sep 17 00:00:00 2001 From: Daphne12345 Date: Thu, 12 Jun 2025 13:23:14 +0200 Subject: [PATCH 22/23] bug fix --- smac/acquisition/function/expected_improvement.py | 1 + 1 file changed, 1 insertion(+) diff --git a/smac/acquisition/function/expected_improvement.py b/smac/acquisition/function/expected_improvement.py index 5f7d2bf71..6de916d95 100644 --- a/smac/acquisition/function/expected_improvement.py +++ b/smac/acquisition/function/expected_improvement.py @@ -367,6 +367,7 @@ def _compute(self, X: np.ndarray) -> np.ndarray: normal_samples = self.model.sample_functions(X, n_funcs=self.n_samples) else: normal_samples = np.random.normal(loc=m.T, scale=std.T, size=(self.n_samples, X.shape[0])) + normal_samples = normal_samples.T if not self._log: f_samples = normal_samples # in original (normal) space From 14eb30be1d78eaa800df09c306192ab90c1a88ea Mon Sep 17 00:00:00 2001 From: Daphne12345 Date: Fri, 13 Jun 2025 12:25:34 +0200 Subject: [PATCH 23/23] Added tests for qei --- smac/acquisition/function/__init__.py | 7 +- .../function/expected_improvement.py | 6 -- tests/test_acquisition/test_functions.py | 64 +++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/smac/acquisition/function/__init__.py b/smac/acquisition/function/__init__.py index 9a0a4c52c..09b305f38 100644 --- a/smac/acquisition/function/__init__.py +++ b/smac/acquisition/function/__init__.py @@ -2,7 +2,11 @@ AbstractAcquisitionFunction, ) from smac.acquisition.function.confidence_bound import LCB -from smac.acquisition.function.expected_improvement import EI, EIPS +from smac.acquisition.function.expected_improvement import ( + EI, + EIPS, + QExpectedImprovement, +) from smac.acquisition.function.integrated_acquisition_function import ( IntegratedAcquisitionFunction, ) @@ -18,6 +22,7 @@ "PI", "EI", "EIPS", + "QExpectedImprovement", "TS", "PriorAcquisitionFunction", "IntegratedAcquisitionFunction", diff --git a/smac/acquisition/function/expected_improvement.py b/smac/acquisition/function/expected_improvement.py index 6de916d95..2fcb78b0b 100644 --- a/smac/acquisition/function/expected_improvement.py +++ b/smac/acquisition/function/expected_improvement.py @@ -294,12 +294,6 @@ class QExpectedImprovement(EI): Monte Carlo approximation of q-Expected Improvement. Approximates joint distribution with independent normals. - :math:`EI(X) := \mathbb{E}\left[ \max\{0, f(\mathbf{X^+}) - f_{t+1}(\mathbf{X}) - \xi \} \right]`, - with :math:`f(X^+)` as the best location. - - Reference for q-EI - - Parameters ---------- xi : float, defaults to 0.0 diff --git a/tests/test_acquisition/test_functions.py b/tests/test_acquisition/test_functions.py index 7020032c9..a6aae8749 100644 --- a/tests/test_acquisition/test_functions.py +++ b/tests/test_acquisition/test_functions.py @@ -11,6 +11,7 @@ TS, IntegratedAcquisitionFunction, PriorAcquisitionFunction, + QExpectedImprovement ) __copyright__ = "Copyright 2025, Leibniz University Hanover, Institute of AI" @@ -613,6 +614,69 @@ def test_logei_NxD(model, acq_logei): assert np.isclose(acq[2][0], 0.6480973967332011) +# -------------------------------------------------------------- +# Test QEI +# -------------------------------------------------------------- + +@pytest.fixture +def model_qei(): + return MockModelDual() + + +@pytest.fixture +def acq_qei(model_qei): + qei = QExpectedImprovement(n_samples=1000) + qei.model = model_qei + return qei + +def test_qei_1xD(model, acq_qei): + qei = acq_qei + qei.update(model=model, eta=1.0) + configurations = [ConfigurationMock([1.0, 1.0, 1.0])] + acq = qei(configurations) + assert acq.shape == (1, 1) # qEI gives one scalar for whole batch + assert 0.0 <= acq[0][0] <= 5.0 # Loose bound due to MC + +def test_qei_Nx1(model, acq_qei): + qei = acq_qei + qei.update(model=model, eta=1.0) + configurations = [ + ConfigurationMock([0.0001]), + ConfigurationMock([1.0]), + ConfigurationMock([2.0]), + ] + acq = qei(configurations) + assert acq.shape == (1, 1) + assert 0.0 <= acq[0][0] <= 5.0 + +def test_qei_NxD(model, acq_qei): + qei = acq_qei + qei.update(model=model, eta=1.0) + configurations = [ + ConfigurationMock([0.0, 0.0, 0.0]), + ConfigurationMock([0.1, 0.1, 0.1]), + ConfigurationMock([1.0, 1.0, 1.0]), + ] + acq = qei(configurations) + assert acq.shape == (1, 1) + assert 0.0 <= acq[0][0] <= 5.0 + + +def test_log_qei_NxD(model, acq_qei): + qei = acq_qei + qei.update(model=model, eta=1.0) + configurations = [ + ConfigurationMock([0.1, 0.0, 0.0]), + ConfigurationMock([0.1, 0.1, 0.1]), + ConfigurationMock([1.0, 1.0, 1.0]), + ] + acq = qei(configurations) + assert acq.shape == (1, 1) + assert 0.0 <= acq[0][0] <= 5.0 + + + + # -------------------------------------------------------------- # Test PI # --------------------------------------------------------------