Skip to content

blackboxopt.optimizers.botorch_base

SingleObjectiveBOTorchOptimizer (SingleObjectiveOptimizer)

Source code in blackboxopt/optimizers/botorch_base.py
class SingleObjectiveBOTorchOptimizer(SingleObjectiveOptimizer):
    def __init__(
        self,
        search_space: ps.ParameterSpace,
        objective: Objective,
        model: Model,
        acquisition_function_factory: Callable[[Model], AcquisitionFunction],
        af_optimizer_kwargs=None,
        num_initial_random_samples: int = 1,
        max_pending_evaluations: Optional[int] = 1,
        batch_shape: torch.Size = torch.Size(),
        logger: Optional[logging.Logger] = None,
        seed: Optional[int] = None,
        torch_dtype: torch.dtype = torch.float64,
    ):
        """Single objective BO optimizer that uses as a surrogate model the `model`
        object provided by user.

        The `model` is expected to be extended from BoTorch base model `Model` class,
        and does not require to be a GP model.

        Args:
            search_space: The space in which to optimize.
            objective: The objective to optimize.
            model: Surrogate model of `Model` type.
            acquisition_function_factory: Callable that produces an acquisition function
                instance, could also be a compatible acquisition function class.
                Only acquisition functions to be minimized are supported.
                Providing a partially initialized class is possible with, e.g.
                `functools.partial(UpperConfidenceBound, beta=6.0, maximize=False)`.
            af_optimizer_kwargs: Settings for acquisition function optimizer,
                see `botorch.optim.optimize_acqf` and in case the whole search space
                is discrete: `botorch.optim.optimize_acqf_discrete`. The former can be
                enforced by providing `raw_samples` or `num_restarts`, the latter by
                providing `num_random_choices`.
            num_initial_random_samples: Size of the initial space-filling design that
                is used before starting BO. The points are sampled randomly in the
                search space. If no random sampling is required, set it to 0.
                When random sampling is enabled, but evaluations with missing objective
                values are reported, more specifications are sampled until
                `num_initial_random_samples` many valid evaluations were reported.
            max_pending_evaluations: Maximum number of parallel evaluations. For
                sequential BO use the default value of 1. If no limit is required,
                set it to None.
            batch_shape: Batch dimension(s) used for batched models.
            logger: Custom logger.
            seed: A seed to make the optimization reproducible.
            torch_dtype: Torch data type used for storing the data. This needs to match
                the dtype of the model used
        """
        super().__init__(search_space=search_space, objective=objective, seed=seed)
        self.num_initial_random = num_initial_random_samples
        self.max_pending_evaluations = max_pending_evaluations
        self.batch_shape = batch_shape
        self.logger = logger or logging.getLogger("blackboxopt")

        self.torch_dtype = torch_dtype
        self.X = torch.empty(
            (*self.batch_shape, 0, len(search_space)), dtype=torch_dtype
        )
        self.losses = torch.empty((*self.batch_shape, 0, 1), dtype=torch_dtype)
        self.pending_specifications: Dict[int, EvaluationSpecification] = {}
        if seed is not None:
            torch.manual_seed(seed=seed)

        self.model = model
        self.acquisition_function_factory = acquisition_function_factory
        self.af_optimizer_kwargs = af_optimizer_kwargs

    def _create_fantasy_model(self, model: Model) -> Model:
        """Create model with the pending specifications and model based
        outcomes added to the training data."""

        if not self.pending_specifications:
            # nothing to do when there are no pending specs
            return model

        pending_X = torch.tensor(
            np.array(
                [
                    self.search_space.to_numerical(e.configuration)
                    for e in self.pending_specifications.values()
                ]
            ),
            dtype=self.torch_dtype,
        )

        model = model.fantasize(pending_X, IIDNormalSampler(1), observation_noise=False)

        if isinstance(model, ExactGP):
            # ExactGP.fantasize extends model's X and Y with batch_size, even if
            # originally not given -> need to reshape these to their original
            # representation
            n_samples = model.train_targets.size(-1)
            n_features = len(self.search_space)
            model.train_inputs[0] = model.train_inputs[0].reshape(
                torch.Size((*self.batch_shape, n_samples, n_features))
            )
            model.train_targets = model.train_targets.reshape(
                torch.Size((*self.batch_shape, n_samples, 1))
            )
        return model

    def _generate_evaluation_specification(self):
        """Optimize acquisition on fantasy model to pick next point."""
        fantasy_model = self._create_fantasy_model(self.model)
        fantasy_model.eval()

        af = self.acquisition_function_factory(fantasy_model)
        if getattr(af, "maximize", False):
            raise ValueError(
                "Only acquisition functions that need to be minimized are supported. "
                f"The given {af.__class__.__name__} has maximize=True. "
                "One potential fix is using functools.partial("
                f"{af.__class__.__name__}, maximize=False) as the "
                "acquisition_function_factory init argument."
            )

        acquisition_function_optimizer = _acquisition_function_optimizer_factory(
            search_space=self.search_space,
            af_opt_kwargs=self.af_optimizer_kwargs,
            torch_dtype=self.torch_dtype,
        )
        configuration, _ = acquisition_function_optimizer(af)

        return EvaluationSpecification(
            configuration=self.search_space.from_numerical(configuration[0]),
        )

    def generate_evaluation_specification(self) -> EvaluationSpecification:
        """Call the optimizer specific function and append a unique integer id
        to the specification.

        Please refer to the docstring of
        `blackboxopt.base.SingleObjectiveOptimizer.generate_evaluation_specification`
        for a description of the method.
        """
        if (
            self.max_pending_evaluations
            and len(self.pending_specifications) == self.max_pending_evaluations
        ):
            raise OptimizerNotReady

        # Generate random samples until there are enough samples where at least one of
        # the objective values is available
        if self.num_initial_random > 0 and (
            sum(~torch.any(self.losses.isnan(), dim=1)) < self.num_initial_random
        ):
            eval_spec = EvaluationSpecification(
                configuration=self.search_space.sample(),
                optimizer_info={"model_based_pick": False},
            )
        else:
            eval_spec = self._generate_evaluation_specification()
            eval_spec.optimizer_info["model_based_pick"] = True

        eval_id = self.X.size(-2) + len(self.pending_specifications)
        eval_spec.optimizer_info["evaluation_id"] = eval_id
        self.pending_specifications[eval_id] = eval_spec
        return eval_spec

    def _remove_pending_specifications(
        self, evaluations: Union[Evaluation, Iterable[Evaluation]]
    ):
        """Find and remove the corresponding entries in `self.pending_specifications`.

        Args:
            evaluations: List of completed evaluations.
        Raises:
            ValueError: If an evaluation is reported with an ID that was not issued
            by the optimizer, the method will fail.
        """
        _evals = [evaluations] if isinstance(evaluations, Evaluation) else evaluations

        for e in _evals:
            if "evaluation_id" not in e.optimizer_info:
                self.logger.debug("User provided EvaluationSpecification received.")
                continue

            if e.optimizer_info["evaluation_id"] not in self.pending_specifications:
                msg = (
                    "Unknown evaluation_id reported. This could indicate that the "
                    "evaluation has been reported before!"
                )
                self.logger.error(msg)
                raise ValueError(msg)

            del self.pending_specifications[e.optimizer_info["evaluation_id"]]

    def _append_evaluations_to_data(
        self, evaluations: Union[Evaluation, Iterable[Evaluation]]
    ):
        """Convert the reported evaluation into its numerical representation
        and append it to the training data.

        Args:
            evaluations: List of completed evaluations.
        """
        _evals = [evaluations] if isinstance(evaluations, Evaluation) else evaluations

        X, Y = to_numerical(
            _evals,
            self.search_space,
            objectives=[self.objective],
            batch_shape=self.batch_shape,
            torch_dtype=self.torch_dtype,
        )

        # fill in NaNs originating from inactive parameters (conditional spaces support)
        # botorch expect numerical representation of inputs to be within the unit
        # hypercube, thus we can't use the default c=-1.0
        X = impute_nans_with_constant(X, c=0.0)

        self.logger.debug(f"Next training configuration(s):{X}, {Y}")

        self.X = torch.cat([self.X, X], dim=-2)
        self.losses = torch.cat([self.losses, Y], dim=-2)

    def _update_internal_evaluation_data(
        self, evaluations: Iterable[Evaluation]
    ) -> None:
        """Check validity of the evaluations and do optimizer agnostic bookkeeping."""
        call_functions_with_evaluations_and_collect_errors(
            [
                functools.partial(validate_objectives, objectives=[self.objective]),
                self._remove_pending_specifications,
                self._append_evaluations_to_data,
            ],
            sort_evaluations(evaluations),
        )

    def report(self, evaluations: Union[Evaluation, Iterable[Evaluation]]) -> None:
        """A simple report method that conditions the model on data.
        This likely needs to be overridden for more specific BO implementations.
        """
        _evals = [evaluations] if isinstance(evaluations, Evaluation) else evaluations
        self._update_internal_evaluation_data(_evals)
        # Just for populating all relevant caches
        self.model.posterior(self.X)

        x_filtered, y_filtered = filter_y_nans(self.X, self.losses)

        # The actual model update
        # Ignore BotorchTensorDimensionWarning which is always reported to make the user
        # aware that they are reponsible for the right input Tensors dimensionality.
        with warnings.catch_warnings():
            warnings.simplefilter(
                action="ignore", category=BotorchTensorDimensionWarning
            )
            self.model = self.model.condition_on_observations(x_filtered, y_filtered)

    def predict_model_based_best(self) -> Optional[Evaluation]:
        """Get the current configuration that is estimated to be the best (in terms of
        optimal objective value) without waiting for a reported evaluation of that
        configuration. Instead, the objective value estimation relies on BO's
        underlying model.

        This might return `None` in case there is no successfully evaluated
        configuration yet (thus, the optimizer has not been given training data yet).

        Returns:
            blackboxopt.evaluation.Evaluation
                The evaluated specification containing the estimated best configuration
                or `None` in case no evaluations have been reported yet.
        """
        return predict_model_based_best(
            model=self.model,
            objective=self.objective,
            search_space=self.search_space,
            torch_dtype=self.torch_dtype,
        )

generate_evaluation_specification(self)

Call the optimizer specific function and append a unique integer id to the specification.

Please refer to the docstring of blackboxopt.base.SingleObjectiveOptimizer.generate_evaluation_specification for a description of the method.

Source code in blackboxopt/optimizers/botorch_base.py
def generate_evaluation_specification(self) -> EvaluationSpecification:
    """Call the optimizer specific function and append a unique integer id
    to the specification.

    Please refer to the docstring of
    `blackboxopt.base.SingleObjectiveOptimizer.generate_evaluation_specification`
    for a description of the method.
    """
    if (
        self.max_pending_evaluations
        and len(self.pending_specifications) == self.max_pending_evaluations
    ):
        raise OptimizerNotReady

    # Generate random samples until there are enough samples where at least one of
    # the objective values is available
    if self.num_initial_random > 0 and (
        sum(~torch.any(self.losses.isnan(), dim=1)) < self.num_initial_random
    ):
        eval_spec = EvaluationSpecification(
            configuration=self.search_space.sample(),
            optimizer_info={"model_based_pick": False},
        )
    else:
        eval_spec = self._generate_evaluation_specification()
        eval_spec.optimizer_info["model_based_pick"] = True

    eval_id = self.X.size(-2) + len(self.pending_specifications)
    eval_spec.optimizer_info["evaluation_id"] = eval_id
    self.pending_specifications[eval_id] = eval_spec
    return eval_spec

predict_model_based_best(self)

Get the current configuration that is estimated to be the best (in terms of optimal objective value) without waiting for a reported evaluation of that configuration. Instead, the objective value estimation relies on BO's underlying model.

This might return None in case there is no successfully evaluated configuration yet (thus, the optimizer has not been given training data yet).

Returns:

Type Description
Optional[blackboxopt.evaluation.Evaluation]

blackboxopt.evaluation.Evaluation The evaluated specification containing the estimated best configuration or None in case no evaluations have been reported yet.

Source code in blackboxopt/optimizers/botorch_base.py
def predict_model_based_best(self) -> Optional[Evaluation]:
    """Get the current configuration that is estimated to be the best (in terms of
    optimal objective value) without waiting for a reported evaluation of that
    configuration. Instead, the objective value estimation relies on BO's
    underlying model.

    This might return `None` in case there is no successfully evaluated
    configuration yet (thus, the optimizer has not been given training data yet).

    Returns:
        blackboxopt.evaluation.Evaluation
            The evaluated specification containing the estimated best configuration
            or `None` in case no evaluations have been reported yet.
    """
    return predict_model_based_best(
        model=self.model,
        objective=self.objective,
        search_space=self.search_space,
        torch_dtype=self.torch_dtype,
    )

report(self, evaluations)

A simple report method that conditions the model on data. This likely needs to be overridden for more specific BO implementations.

Source code in blackboxopt/optimizers/botorch_base.py
def report(self, evaluations: Union[Evaluation, Iterable[Evaluation]]) -> None:
    """A simple report method that conditions the model on data.
    This likely needs to be overridden for more specific BO implementations.
    """
    _evals = [evaluations] if isinstance(evaluations, Evaluation) else evaluations
    self._update_internal_evaluation_data(_evals)
    # Just for populating all relevant caches
    self.model.posterior(self.X)

    x_filtered, y_filtered = filter_y_nans(self.X, self.losses)

    # The actual model update
    # Ignore BotorchTensorDimensionWarning which is always reported to make the user
    # aware that they are reponsible for the right input Tensors dimensionality.
    with warnings.catch_warnings():
        warnings.simplefilter(
            action="ignore", category=BotorchTensorDimensionWarning
        )
        self.model = self.model.condition_on_observations(x_filtered, y_filtered)