diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e0ee21dbb..84636fffb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -39,7 +39,7 @@ jobs: shell: bash -l {0} run: | conda install -c conda-forge cyipopt - pip install ".[optimization,tests,cheminfo,xgb]" + pip install ".[optimization,tests,cheminfo,xgb,entmoot]" - name: Run tests, Python ${{ matrix.python-version }} shell: bash -l {0} run: pytest -ra --cov=bofire --cov-report term-missing tests @@ -64,7 +64,7 @@ jobs: pip install --upgrade git+https://github.com/cornellius-gp/gpytorch.git export ALLOW_LATEST_GPYTORCH_LINOP=true pip install --upgrade git+https://github.com/pytorch/botorch.git - pip install ".[optimization,tests,cheminfo,xgb]" + pip install ".[optimization,tests,cheminfo,xgb,entmoot]" - name: Run tests, Python ${{ matrix.python-version }} shell: bash -l {0} run: pytest -ra --cov=bofire --cov-report term-missing tests @@ -86,7 +86,7 @@ jobs: shell: bash -l {0} run: | conda install -c conda-forge cyipopt - pip install ".[optimization,tests,cheminfo,xgb]" + pip install ".[optimization,tests,cheminfo,xgb,entmoot]" - name: Run notebooks shell: bash -l {0} run: python scripts/run_tutorials.py -p "$(pwd)" diff --git a/bofire/data_models/strategies/actual_strategy_type.py b/bofire/data_models/strategies/actual_strategy_type.py index 73289ba3a..faced3958 100644 --- a/bofire/data_models/strategies/actual_strategy_type.py +++ b/bofire/data_models/strategies/actual_strategy_type.py @@ -2,6 +2,7 @@ from bofire.data_models.strategies.doe import DoEStrategy from bofire.data_models.strategies.factorial import FactorialStrategy +from bofire.data_models.strategies.predictives.enting import EntingStrategy from bofire.data_models.strategies.predictives.mobo import MoboStrategy from bofire.data_models.strategies.predictives.qehvi import QehviStrategy from bofire.data_models.strategies.predictives.qnehvi import QnehviStrategy @@ -24,6 +25,7 @@ QehviStrategy, QnehviStrategy, QparegoStrategy, + EntingStrategy, SpaceFillingStrategy, RandomStrategy, DoEStrategy, diff --git a/bofire/data_models/strategies/api.py b/bofire/data_models/strategies/api.py index bdec7a847..68215bc2a 100644 --- a/bofire/data_models/strategies/api.py +++ b/bofire/data_models/strategies/api.py @@ -5,6 +5,7 @@ from bofire.data_models.strategies.factorial import FactorialStrategy from bofire.data_models.strategies.meta_strategy_type import MetaStrategy from bofire.data_models.strategies.predictives.botorch import LSRBO, BotorchStrategy +from bofire.data_models.strategies.predictives.enting import EntingStrategy from bofire.data_models.strategies.predictives.mobo import MoboStrategy from bofire.data_models.strategies.predictives.multiobjective import ( MultiobjectiveStrategy, @@ -55,6 +56,7 @@ QehviStrategy, QnehviStrategy, QparegoStrategy, + EntingStrategy, MoboStrategy, ] diff --git a/bofire/data_models/strategies/predictives/enting.py b/bofire/data_models/strategies/predictives/enting.py new file mode 100644 index 000000000..fe1817060 --- /dev/null +++ b/bofire/data_models/strategies/predictives/enting.py @@ -0,0 +1,78 @@ +from typing import Any, Dict, Literal, Type + +from pydantic import PositiveFloat, PositiveInt + +from bofire.data_models.constraints.api import ( + Constraint, + LinearEqualityConstraint, + LinearInequalityConstraint, + NChooseKConstraint, +) +from bofire.data_models.features.api import ( + CategoricalDescriptorInput, + CategoricalInput, + ContinuousInput, + ContinuousOutput, + DiscreteInput, + Feature, +) +from bofire.data_models.objectives.api import ( + MaximizeObjective, + MinimizeObjective, + Objective, +) +from bofire.data_models.strategies.predictives.predictive import PredictiveStrategy + + +class EntingStrategy(PredictiveStrategy): + type: Literal["EntingStrategy"] = "EntingStrategy" + + # uncertainty model parameters + beta: PositiveFloat = 1.96 + bound_coeff: PositiveFloat = 0.5 + acq_sense: Literal["exploration", "penalty"] = "exploration" + dist_trafo: Literal["normal", "standard"] = "normal" + dist_metric: Literal["euclidean_squared", "l1", "l2"] = "euclidean_squared" + cat_metric: Literal["overlap", "of", "goodall4"] = "overlap" + + # lightgbm training hyperparameters + # see https://lightgbm.readthedocs.io/en/latest/Parameters.html + num_boost_round: PositiveInt = 100 + max_depth: PositiveInt = 3 + min_data_in_leaf: PositiveInt = 1 + min_data_per_group: PositiveInt = 1 + verbose: Literal[-1, 0, 1, 2] = -1 + + # pyomo parameters + solver_name: str = "gurobi" + solver_verbose: bool = False + solver_params: Dict[str, Any] = {} + + # kappa_fantasy determines a bound on the predicted value of an unseen point + # used for making batch predictions, y* = mean + kappa_fantasy * std + # for a both min and max problems, a positive value is 'pesimistic' + # and a negative value is 'optimistic' + # a value of zero implies future observations will be exactly the mean + kappa_fantasy: float = 1.96 + + @classmethod + def is_constraint_implemented(cls, my_type: Type[Constraint]) -> bool: + return my_type in [ + LinearEqualityConstraint, + LinearInequalityConstraint, + NChooseKConstraint, + ] + + @classmethod + def is_feature_implemented(cls, my_type: Type[Feature]) -> bool: + return my_type in [ + CategoricalInput, + DiscreteInput, + CategoricalDescriptorInput, + ContinuousInput, + ContinuousOutput, + ] + + @classmethod + def is_objective_implemented(cls, my_type: Type[Objective]) -> bool: + return my_type in [MinimizeObjective, MaximizeObjective] diff --git a/bofire/strategies/api.py b/bofire/strategies/api.py index 5a7d2a621..9b1b73f67 100644 --- a/bofire/strategies/api.py +++ b/bofire/strategies/api.py @@ -1,6 +1,7 @@ from bofire.strategies.doe_strategy import DoEStrategy from bofire.strategies.mapper import map from bofire.strategies.predictives.botorch import BotorchStrategy +from bofire.strategies.predictives.enting import EntingStrategy from bofire.strategies.predictives.predictive import PredictiveStrategy from bofire.strategies.predictives.qehvi import QehviStrategy from bofire.strategies.predictives.qnehvi import QnehviStrategy diff --git a/bofire/strategies/mapper_actual.py b/bofire/strategies/mapper_actual.py index 79e8f20bb..6db2ee7bc 100644 --- a/bofire/strategies/mapper_actual.py +++ b/bofire/strategies/mapper_actual.py @@ -3,6 +3,7 @@ import bofire.data_models.strategies.api as data_models from bofire.strategies.doe_strategy import DoEStrategy from bofire.strategies.factorial import FactorialStrategy +from bofire.strategies.predictives.enting import EntingStrategy from bofire.strategies.predictives.mobo import MoboStrategy from bofire.strategies.predictives.qehvi import QehviStrategy from bofire.strategies.predictives.qnehvi import QnehviStrategy @@ -27,6 +28,7 @@ data_models.QehviStrategy: QehviStrategy, data_models.QnehviStrategy: QnehviStrategy, data_models.QparegoStrategy: QparegoStrategy, + data_models.EntingStrategy: EntingStrategy, data_models.SpaceFillingStrategy: SpaceFillingStrategy, data_models.DoEStrategy: DoEStrategy, data_models.FactorialStrategy: FactorialStrategy, diff --git a/bofire/strategies/predictives/enting.py b/bofire/strategies/predictives/enting.py new file mode 100644 index 000000000..7ace3331f --- /dev/null +++ b/bofire/strategies/predictives/enting.py @@ -0,0 +1,375 @@ +import warnings +from typing import List, Optional, Tuple + +import numpy as np +import pandas as pd + +try: + import entmoot.constraints as entconstr # type: ignore + import pyomo.environ as pyo # type: ignore + from entmoot.models.enting import Enting # type: ignore + from entmoot.optimizers.pyomo_opt import PyomoOptimizer # type: ignore + from entmoot.problem_config import ProblemConfig # type: ignore +except ImportError: + warnings.warn("entmoot not installed, BoFire's `EntingStrategy` cannot be used.") + +from typing import Union + +from pydantic import PositiveInt + +import bofire.data_models.strategies.api as data_models +from bofire.data_models.constraints.api import ( + LinearEqualityConstraint, + LinearInequalityConstraint, + NChooseKConstraint, +) +from bofire.data_models.domain.api import Domain +from bofire.data_models.features.api import ( + AnyInput, + AnyOutput, + CategoricalInput, + ContinuousInput, + DiscreteInput, +) +from bofire.data_models.objectives.api import MaximizeObjective, MinimizeObjective +from bofire.strategies.predictives.predictive import PredictiveStrategy + + +def domain_to_problem_config( + domain: Domain, seed: Optional[int] = None +) -> Tuple["ProblemConfig", "pyo.ConcreteModel"]: + """Convert a set of features and constraints from BoFire to ENTMOOT. + + Problems in BoFire are defined as `Domain`s. Before running an ENTMOOT strategy, + the problem must be converted to an `entmoot.ProblemConfig`. + + Args: + domain (Domain): the definition of the optimization problem. + seed (int, optional): random seed for ENTMOOT problem config. + + Returns: + A tuple (problem_config, model_pyo), where problem_config is the problem definition + in an ENTMOOT format, and model_pyo is the Pyomo model containing constraints. + """ + # entmoot expects int, not np.int64 + seed = int(seed) if not (isinstance(seed, int) or seed is None) else seed + problem_config = ProblemConfig(seed) + + for input_feature in domain.inputs.get(): + _bofire_feat_to_entmoot(problem_config, input_feature) # type: ignore + + for output_feature in domain.outputs.get_by_objective( + includes=[MinimizeObjective, MaximizeObjective] + ): + _bofire_output_to_entmoot(problem_config, output_feature) # type: ignore + + constraints = [] + for constraint in domain.constraints.get(): + constraints.append(_bofire_constraint_to_entmoot(problem_config, constraint)) # type: ignore + + # apply constraints to model + model_pyo = problem_config.get_pyomo_model_core() + model_pyo.problem_constraints = pyo.ConstraintList() + entconstr.ConstraintList(constraints).apply_pyomo_constraints( + model_pyo, problem_config.feat_list, model_pyo.problem_constraints + ) + + return problem_config, model_pyo + + +def _bofire_feat_to_entmoot( + problem_config: "ProblemConfig", + feature: AnyInput, +) -> None: + """Given a Bofire `Input`, create an ENTMOOT `FeatureType`. + + Args: + problem_config (ProblemConfig): An ENTMOOT problem definition, modified in-place. + feature (AnyInput): An input feature to be added to the problem_config object. + """ + feat_type = None + bounds = None + name = feature.key + + if isinstance(feature, ContinuousInput): + feat_type = "real" + bounds = (feature.lower_bound, feature.upper_bound) + + elif isinstance(feature, DiscreteInput): + x = feature.values + assert ( + np.all(np.diff(x) == 1) and x[0] % 1 == 0 + ), "Discrete values must be consecutive integers" + feat_type = "binary" if np.array_equal(x, np.array([0, 1])) else "integer" + bounds = (int(feature.lower_bound), int(feature.upper_bound)) + + elif isinstance(feature, CategoricalInput): + feat_type = "categorical" + bounds = tuple(feature.categories) + + else: + raise NotImplementedError(f"Did not recognise input {feature}") + + problem_config.add_feature(feat_type, bounds, name) + + +def _bofire_output_to_entmoot( + problem_config: "ProblemConfig", feature: AnyOutput +) -> None: + """Given a Bofire `Output`, create an ENTMOOT `MinObjective`. + + If the output feature has a maximise objective, this is added to the problem config as a + `MinObjective`, and a factor of -1 is introduced in `EntingStrategy`. + + Args: + problem_config (ProblemConfig): An ENTMOOT problem definition, modified in-place. + feature (AnyOutput): An output feature to be added to the problem_config object. + """ + if isinstance(feature.objective, MinimizeObjective): # type: ignore + problem_config.add_min_objective(name=feature.key) + + elif isinstance(feature.objective, MaximizeObjective): # type: ignore + problem_config.add_max_objective(name=feature.key) + + else: + raise NotImplementedError(f"Did not recognise output {feature}") + + +def _bofire_constraint_to_entmoot( + problem_config: "ProblemConfig", + constraint: Union[ + LinearEqualityConstraint, LinearInequalityConstraint, NChooseKConstraint + ], +) -> None: + """Convert a Bofire `Constraint` to an ENTMOOT `Constraint`. + + Args: + problem_config (ProblemConfig): An ENTMOOT problem definition. + constraint (Union[LinearEqualityConstraint, LinearInequalityConstraint, NChooseKConstraint]): A constraint to be applied to the Pyomo model. + """ + + if isinstance(constraint, LinearEqualityConstraint): + ent_constraint = entconstr.LinearEqualityConstraint( + feature_keys=constraint.features, + coefficients=constraint.coefficients, + rhs=constraint.rhs, + ) + + elif isinstance(constraint, LinearInequalityConstraint): + ent_constraint = entconstr.LinearInequalityConstraint( + feature_keys=constraint.features, + coefficients=constraint.coefficients, + rhs=constraint.rhs, + ) + + elif isinstance(constraint, NChooseKConstraint): + ent_constraint = entconstr.NChooseKConstraint( + feature_keys=constraint.features, + min_count=constraint.min_count, + max_count=constraint.max_count, + none_also_valid=constraint.none_also_valid, + ) + + else: + raise NotImplementedError("Only linear and nchoosek constraints are supported.") + + return ent_constraint + + +def _dump_enting_params(data_model: data_models.EntingStrategy) -> dict: + """Dump the model in the nested structure required for ENTMOOT. + + Returns: + dict: the nested dictionary of entmoot params. + """ + return { + "unc_params": { + "beta": data_model.beta, + "bound_coeff": data_model.bound_coeff, + "acq_sense": data_model.acq_sense, + "dist_trafo": data_model.dist_trafo, + "dist_metric": data_model.dist_metric, + "cat_metric": data_model.cat_metric, + }, + "tree_train_params": { + "train_params": { + "num_boost_round": data_model.num_boost_round, + "max_depth": data_model.max_depth, + "min_data_in_leaf": data_model.min_data_in_leaf, + "min_data_per_group": data_model.min_data_per_group, + "verbose": data_model.verbose, + }, + }, + } + + +def _dump_solver_params(data_model: data_models.EntingStrategy) -> dict: + """Dump the solver parameters for pyomo. + + Returns: + dict: the nested dictionary of solver params. + """ + return { + "solver_name": data_model.solver_name, + "verbose": data_model.solver_verbose, + **data_model.solver_params, + } + + +class EntingStrategy(PredictiveStrategy): + """Strategy for selecting new candidates using ENTMOOT""" + + def __init__( + self, + data_model: data_models.EntingStrategy, + **kwargs, + ): + super().__init__(data_model=data_model, **kwargs) + self._init_problem_config() + self._enting = Enting(self._problem_config, _dump_enting_params(data_model)) + self._solver_params = _dump_solver_params(data_model) + self._kappa_fantasy = data_model.kappa_fantasy + + def _init_problem_config(self) -> None: + cfg = domain_to_problem_config(self.domain, self.seed) + self._problem_config: ProblemConfig = cfg[0] + self._model_pyo: pyo.ConcreteModel = cfg[1] + + @property + def input_preprocessing_specs(self): + return {} + + def _postprocess_candidate(self, candidate: List) -> pd.DataFrame: + """Converts a single candidate to a pandas Dataframe with prediction. + + Args: + candidate (List): List containing the features of the candidate. + + Returns: + pd.DataFrame: Dataframe with candidate. + """ + keys = [feat.name for feat in self._problem_config.feat_list] + df_candidate = pd.DataFrame( + data=[candidate], + columns=keys, + ) + + preds = self.predict(df_candidate) + + return pd.concat((df_candidate, preds), axis=1) + + def _fantasy_as_experiment(self, candidates: pd.DataFrame): + """Fit the model with fantasy candidates. + + The Enting strategy generates a globally optimal candidate. Therefore, + to generate batch proposals, we sequentially generate 'fantasy' observations + of the candidate, by adding a multiple of the standard deviation to the + mean prediction. This behaviour is defined by the `kappa_fantasy` parameter. + + Args: + candidates (pd.DataFrame): The candidate(s) to make a fantasy observation for. + """ + kappa = self._kappa_fantasy + # overestimate for minimisation, underestimate for maximisation + signs = { + output.key: -1 if isinstance(output.objective, MaximizeObjective) else 1 # type: ignore + for output in self.domain.outputs.get_by_objective() + } + as_experiment = candidates.assign( + **{ + key: candidates[f"{key}_pred"] + + kappa * signs[key] * candidates[f"{key}_sd"] + for key in self.domain.outputs.get_keys() + }, + valid_y=True, + ) + + return as_experiment + + def _ask(self, candidate_count: PositiveInt = 1) -> pd.DataFrame: + """Generates candidates. + + If `candidate_count == 1`, then the globally optimal solution is returned. + If `candidate_count > 1`, then we use fantasy observations to make sequential + proposals. Note that since this sequentially generates candidates, it is + much faster to generate a batch in a single function call, such that each candidate + is only predicted once. + + If you are using subsequent calls to `EntingStrategy.ask()`, then you must add the candidates to the pending list of candidates, by calling `.ask(pending=True)`. + + Args: + candidate_count (PositiveInt, optional): Number of candidates to be generated. Defaults to 1. + + Returns: + pd.DataFrame: DataFrame with a candidates. + """ + # First, fit the model on fantasies generated for any pending candidates + # This ensures that new points are far from pending candidates + experiments_plus_fantasy = ( + self.experiments.copy() if self.experiments is not None else pd.DataFrame() + ) + if self.candidates is not None: + for i in range(len(self.candidates)): + # iterate using indices so that each `candidate` is a DataFrame + candidate = self.candidates[i : i + 1] + # add prediction from model + preds = self.predict(candidate) + candidate = pd.concat((candidate, preds), axis=1) + as_experiment = self._fantasy_as_experiment(candidate) + experiments_plus_fantasies = pd.concat( + (experiments_plus_fantasy, as_experiment) + ) + self._fit(experiments_plus_fantasies) + + new_candidates = [] + # Subsequently generate candidates, using fantasies if appropriate + for i in range(candidate_count): + opt_pyo = PyomoOptimizer(self._problem_config, params=self._solver_params) + res = opt_pyo.solve(tree_model=self._enting, model_core=self._model_pyo) + candidate = self._postprocess_candidate(res.opt_point) + new_candidates.append(candidate) + # only retrain with fantasy if not last candidate in batch + if i < candidate_count - 1: + as_experiment = self._fantasy_as_experiment(candidate) + experiments_plus_fantasies = pd.concat( + (experiments_plus_fantasy, as_experiment) + ) + self._fit(experiments_plus_fantasies) + + self._fit(self.experiments) + return pd.concat(new_candidates) + + def _fit(self, experiments: pd.DataFrame): + input_keys = self.domain.inputs.get_keys() + output_keys = self.domain.outputs.get_keys() + + experiments = self.domain.outputs.preprocess_experiments_all_valid_outputs( + experiments + ) + + X = experiments[input_keys].to_numpy() + y = experiments[output_keys].to_numpy() + self._enting.fit(X, y) + + def _predict(self, transformed: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray]: + X = transformed.to_numpy() + pred = self._enting.predict(X) + # pred has shape [([mu1], std1), ([mu2], std2), ... ] + m, v = zip(*pred) + mean = np.array(m) + std = np.sqrt(np.array(v)).reshape(-1, 1) + # std is given combined - copy for each objective + std = np.tile(std, mean.shape[1]) + return mean, std + + def has_sufficient_experiments(self) -> bool: + if self.experiments is None: + return False + return ( + len( + self.domain.outputs.preprocess_experiments_all_valid_outputs( + experiments=self.experiments + ) + ) + > 1 + ) diff --git a/setup.py b/setup.py index c9e0e9e4c..d0a88124c 100644 --- a/setup.py +++ b/setup.py @@ -51,6 +51,7 @@ "cvxpy[CLARABEL]", sklearn_dependency, ], + "entmoot": ["entmoot>=2.0", "lightgbm==4.0.0", "pyomo==6.7.1", "gurobipy"], "xgb": ["xgboost>=1.7.5"], "cheminfo": ["rdkit>=2023.3.2", sklearn_dependency, "mordred"], "tests": [ diff --git a/tests/bofire/data_models/specs/strategies.py b/tests/bofire/data_models/specs/strategies.py index 0172ae7d6..d8f4eac7b 100644 --- a/tests/bofire/data_models/specs/strategies.py +++ b/tests/bofire/data_models/specs/strategies.py @@ -105,6 +105,27 @@ "use_output_constraints": True, }, ) +specs.add_valid( + strategies.EntingStrategy, + lambda: { + "domain": domain.valid().obj().model_dump(), + "beta": 1.0, + "bound_coeff": 0.5, + "acq_sense": "exploration", + "dist_trafo": "normal", + "dist_metric": "euclidean_squared", + "cat_metric": "overlap", + "num_boost_round": 100, + "max_depth": 3, + "min_data_in_leaf": 1, + "min_data_per_group": 1, + "verbose": -1, + "solver_name": "gurobi", + "solver_verbose": False, + "solver_params": {}, + "kappa_fantasy": 10.0, + }, +) specs.add_valid( strategies.RandomStrategy, lambda: { diff --git a/tests/bofire/strategies/test_enting.py b/tests/bofire/strategies/test_enting.py new file mode 100644 index 000000000..ede934ba6 --- /dev/null +++ b/tests/bofire/strategies/test_enting.py @@ -0,0 +1,246 @@ +import importlib +import warnings + +import numpy as np +import pytest + +try: + import gurobipy + from entmoot.problem_config import FeatureType, ProblemConfig +except ImportError: + warnings.warn("entmoot not installed, BoFire's `EntingStrategy` cannot be used.") + + +import bofire.data_models.strategies.api as data_models +from bofire.benchmarks.api import Hartmann +from bofire.data_models.constraints.api import ( + LinearEqualityConstraint, + LinearInequalityConstraint, +) +from bofire.data_models.domain.api import Domain +from bofire.data_models.features.api import ( + CategoricalInput, + ContinuousInput, + ContinuousOutput, + DiscreteInput, + Input, +) +from bofire.data_models.objectives.api import MaximizeObjective, MinimizeObjective +from bofire.strategies.api import EntingStrategy +from bofire.strategies.predictives.enting import domain_to_problem_config +from tests.bofire.strategies.test_base import domains + +ENTMOOT_AVAILABLE = importlib.util.find_spec("entmoot") is not None +if ENTMOOT_AVAILABLE: + try: + # this is the recommended way to check precense of gurobi license file + gurobipy.Model() + GUROBI_AVAILABLE = True + except gurobipy.GurobiError: + GUROBI_AVAILABLE = False + +else: + GUROBI_AVAILABLE = False + + +@pytest.fixture +def common_args(): + return { + "dist_metric": "l1", + "acq_sense": "exploration", + "solver_name": "gurobi", + "solver_verbose": False, + "seed": 42, + } + + +@pytest.mark.skipif(not ENTMOOT_AVAILABLE, reason="requires entmoot") +def test_enting_not_fitted(common_args): + data_model = data_models.EntingStrategy(domain=domains[0], **common_args) + strategy = EntingStrategy(data_model=data_model) + + msg = "Uncertainty model needs fit function call before it can predict." + with pytest.raises(AssertionError, match=msg): + strategy._ask(1) + + +@pytest.mark.skipif(not ENTMOOT_AVAILABLE, reason="requires entmoot") +@pytest.mark.parametrize( + "params", + [ + {"acq_sense": "penalty", "beta": 0.1, "dist_metric": "l2", "max_depth": 3}, + ], +) +def test_enting_param_consistency(common_args, params): + # compare EntingParams objects between entmoot and bofire + data_model = data_models.EntingStrategy( + domain=domains[0], **{**common_args, **params} + ) + strategy = EntingStrategy(data_model=data_model) + + # check that the parameters propagate to the model correctly + assert strategy._enting._acq_sense == data_model.acq_sense + assert strategy._enting._beta == data_model.beta + + +@pytest.mark.skipif(not GUROBI_AVAILABLE, reason="requires entmoot+gurobi") +@pytest.mark.parametrize( + "allowed_k", + [1, 3, 5, 6], +) +@pytest.mark.slow +def test_nchoosek_constraint_with_enting(common_args, allowed_k): + benchmark = Hartmann(6, allowed_k=allowed_k) + samples = benchmark.domain.inputs.sample(10, seed=43) + experiments = benchmark.f(samples, return_complete=True) + + data_model = data_models.EntingStrategy(domain=benchmark.domain, **common_args) + strategy = EntingStrategy(data_model) + + strategy.tell(experiments) + proposal = strategy.ask(1) + + input_values = proposal[benchmark.domain.get_feature_keys(Input)] + assert (input_values != 0).sum().sum() <= allowed_k + + +@pytest.mark.skipif(not GUROBI_AVAILABLE, reason="requires entmoot+gurobi") +@pytest.mark.slow +def test_propose_optimal_point(common_args): + # regression test, ensure that a good point is proposed + benchmark = Hartmann(6) + samples = benchmark.domain.inputs.sample(50, seed=43) + experiments = benchmark.f(samples, return_complete=True) + + data_model = data_models.EntingStrategy(domain=benchmark.domain, **common_args) + strategy = EntingStrategy(data_model) + + # filter experiments to remove those in a box surrounding optimum + radius = 0.5 + X = experiments[benchmark.domain.get_feature_keys(Input)].values + X_opt = benchmark.get_optima()[benchmark.domain.get_feature_keys(Input)].values + sq_dist_to_optimum = ((X - X_opt) ** 2).sum(axis=1) + include = sq_dist_to_optimum > radius + + strategy.tell(experiments[include]) + proposal = strategy.ask(1) + + assert np.allclose( + proposal.loc[0, benchmark.domain.get_feature_keys(Input)].tolist(), + [0.0, 0.79439, 0.6124835, 0.0, 1.0, 0.0], + atol=1e-6, + ) + + +@pytest.mark.skipif(not GUROBI_AVAILABLE, reason="requires entmoot+gurobi") +@pytest.mark.slow +def test_propose_unique_points(common_args): + # ensure that the strategy does not repeat candidates + benchmark = Hartmann(6) + samples = benchmark.domain.inputs.sample(10) + experiments = benchmark.f(samples, return_complete=True) + + data_model = data_models.EntingStrategy(domain=benchmark.domain, **common_args) + strategy = EntingStrategy(data_model) + + strategy.tell(experiments) + + a = strategy.ask(candidate_count=5) + b = strategy.ask(candidate_count=5, add_pending=True) + c = strategy.ask(candidate_count=5) + + # without adding points to pending, a and b should propose the same points + assert a.equals(b) + # after adding points to pending, b and c should propose different points + assert not b.equals(c) + + +# Test utils for converting from bofire problem definition to entmoot +def feat_equal(a: "FeatureType", b: "FeatureType") -> bool: + """Check if entmoot.FeatureTypes are equal. + + Args: + a: First feature. + b: Second feature. + """ + # no __eq__ method is implemented for FeatureType, hence the need for this function + assert a is not None and b is not None + return all( + ( + a.name == b.name, + a.get_enc_bnds() == b.get_enc_bnds(), + a.is_real() == b.is_real(), + a.is_cat() == b.is_cat(), + a.is_int() == b.is_int(), + a.is_bin() == b.is_bin(), + ) + ) + + +if1 = CategoricalInput(key="if1", categories=("blue", "orange", "gray")) +if1_ent = { + "feat_type": "categorical", + "bounds": ("blue", "orange", "gray"), + "name": "if1", +} + +if2 = DiscreteInput(key="if2", values=[5, 6, 7]) +if2_ent = {"feat_type": "integer", "bounds": (5, 7), "name": "if2"} + +if3 = DiscreteInput(key="if3", values=[0, 1]) +if3_ent = {"feat_type": "binary", "name": "if3"} + +if4 = ContinuousInput(key="if4", bounds=[5.0, 6.0]) +if4_ent = {"feat_type": "real", "bounds": (5.0, 6.0), "name": "if4"} + +if5 = ContinuousInput(key="if5", bounds=[0.0, 10.0]) + +of1 = ContinuousOutput(key="of1", objective=MinimizeObjective(w=1.0)) +of1_ent = {"name": "of1"} + +of2 = ContinuousOutput(key="of2", objective=MaximizeObjective(w=1.0)) +of2_ent = {"name": "of2"} + +constr1 = LinearInequalityConstraint( + features=["if4", "if5"], coefficients=[1, 1], rhs=12 +) +constr2 = LinearEqualityConstraint(features=["if4", "if5"], coefficients=[1, 5], rhs=38) + + +def build_problem_config(inputs, outputs) -> "ProblemConfig": + problem_config = ProblemConfig() + for feature in inputs: + problem_config.add_feature(**feature) + + for objective in outputs: + problem_config.add_min_objective(**objective) + + return problem_config + + +@pytest.mark.skipif(not ENTMOOT_AVAILABLE, reason="requires entmoot") +def test_domain_to_problem_config(): + domain = Domain.from_lists(inputs=[if1, if2, if3, if4], outputs=[of1, of2]) + ent_problem_config = build_problem_config( + inputs=[if1_ent, if2_ent, if3_ent, if4_ent], outputs=[of1_ent, of2_ent] + ) + bof_problem_config, _ = domain_to_problem_config(domain) + for feat_ent in ent_problem_config.feat_list: + # get bofire feature with same name + feat_bof = next( + (f for f in bof_problem_config.feat_list if f.name == feat_ent.name), None + ) + assert feat_equal(feat_ent, feat_bof) + + assert len(ent_problem_config.obj_list) == len(bof_problem_config.obj_list) + + +@pytest.mark.skipif(not ENTMOOT_AVAILABLE, reason="requires entmoot") +def test_convert_constraint_to_entmoot(): + constraints = [constr1, constr2] + domain = Domain.from_lists( + inputs=[if1, if2, if3, if4, if5], outputs=[of1, of2], constraints=constraints + ) + _, model = domain_to_problem_config(domain) + + assert len(constraints) == len(model.problem_constraints)