Source code for zellij.strategies.bayesian_optimization

# @Author: Thomas Firmin <ThomasFirmin>
# @Date:   2022-05-03T15:41:48+02:00
# @Email:  thomas.firmin@univ-lille.fr
# @Project: Zellij
# @Last modified by:   tfirmin
# @Last modified time: 2022-12-23T14:48:07+01:00
# @License: CeCILL-C (http://www.cecill.info/index.fr.html)


from zellij.core.metaheuristic import Metaheuristic

import torch
import numpy as np
from botorch.models import SingleTaskGP
from gpytorch.mlls.sum_marginal_log_likelihood import ExactMarginalLogLikelihood
from botorch.optim import optimize_acqf
from botorch.acquisition.analytic import ExpectedImprovement
from botorch import fit_gpytorch_model
from zellij.core.search_space import ContinuousSearchspace

import logging

logger = logging.getLogger("zellij.BO")


[docs]class Bayesian_optimization(Metaheuristic): """Bayesian_optimization Bayesian optimization (BO) is a surrogate based optimization method which interpolates the actual loss function with a surrogate model, here it is a gaussian process. By sampling into this surrogate, BO determines promising points, which are worth to evaluate with the actual loss function. Once done, the gaussian process is updated using results obtained by evaluating these promising solutions with the loss function. It is based on `BoTorch <https://botorch.org/>`_ and `GPyTorch <https://gpytorch.ai/>`__. Attributes ---------- search_space : Searchspace Search space object containing bounds of the search space f_calls : int Maximum number of :ref:`lf` calls verbose : bool If False, there will be no print and no progress bar. surrogate : botorch.models.model.Model, default=SingleTaskGP Gaussian Process Regressor object from 'botorch'. Determines the surrogate model that Bayesian optimization will use to interpolate the loss function likelihood : gpytorch.mlls, default=ExactMarginalLogLikelihood gpytorch.mlls object it determines which MarginalLogLikelihood to use when optimizing kernel's hyperparameters acquisition : botorch.acquisition.acquisition.AcquisitionFunction, default = ExpectedImprovement An acquisition function or infill criteria, determines how 'promising' a point sampled from the surrogate is. initial_size : int, default=10 Size of the initial set of solution to draw randomly. gpu: bool, default=True Use GPU if available **kwargs Key word arguments linked to the surrogate and the acquisition function. See Also -------- :ref:`meta` : Parent class defining what a Metaheuristic is :ref:`lf` : Describes what a loss function is in Zellij :ref:`sp` : Describes what a loss function is in Zellij Examples -------- >>> from zellij.core import Loss >>> from zellij.core import ContinuousSearchspace >>> from zellij.core import FloatVar, ArrayVar >>> from zellij.utils.benchmark import himmelblau >>> from zellij.strategies.bayesian_optimization import Bayesian_optimization >>> import botorch >>> import gpytorch ... >>> lf = Loss()(himmelblau) >>> sp = ContinuousSearchspace(ArrayVar(FloatVar("a",-5,5), FloatVar("b",-5,5)),lf) >>> bo = Bayesian_optimization(sp, 500, ... acquisition=botorch.acquisition.monte_carlo.qExpectedImprovement, ... q=5) >>> bo.run() """ def __init__( self, search_space, f_calls, verbose=True, surrogate=SingleTaskGP, likelihood=ExactMarginalLogLikelihood, acquisition=ExpectedImprovement, initial_size=10, gpu=False, **kwargs, ): """Short summary. Parameters ---------- search_space : Searchspace Search space object containing bounds of the search space f_calls : int Maximum number of :ref:`lf` calls verbose : bool If False, there will be no print and no progress bar. surrogate : botorch.models.model.Model, default=SingleTaskGP Gaussian Process Regressor object from 'botorch'. Determines the surrogate model that Bayesian optimization will use to interpolate the loss function likelihood : gpytorch.mlls, default=ExactMarginalLogLikelihood gpytorch.mlls object it determines which MarginalLogLikelihood to use when optimizing kernel's hyperparameters acquisition : botorch.acquisition.acquisition.AcquisitionFunction, default = ExpectedImprovement An acquisition function or infill criteria, determines how 'promising' a point sampled from the surrogate is. initial_size : int, default=10 Size of the initial set of solution to draw randomly. gpu: bool, default=True Use GPU if available **kwargs Key word arguments linked to the surrogate and the acquisition function. """ super().__init__(search_space, f_calls, verbose) ############## # PARAMETERS # ############## assert hasattr(search_space, "to_continuous") or isinstance( search_space, ContinuousSearchspace ), logger.error( f"""If the `search_space` is not a `ContinuousSearchspace`, the user must give a `Converter` to the :ref:`sp` object with the kwarg `to_continuous`""" ) self.acquisition = acquisition self.surrogate = surrogate self.likelihood = likelihood self.initial_size = initial_size self.kwargs = kwargs ############# # VARIABLES # ############# if gpu: self.device = torch.device( "cuda" if torch.cuda.is_available() else "cpu" ) else: self.device = "cpu" self.dtype = torch.double if isinstance(self.search_space, ContinuousSearchspace): self.bounds = torch.tensor( [ [v.low_bound for v in self.search_space.values], [v.up_bound for v in self.search_space.values], ], device=self.device, dtype=self.dtype, ) else: self.bounds = torch.tensor( [ [0.0] * self.search_space.size, [1.0] * self.search_space.size, ], device=self.device, dtype=self.dtype, ) self.iterations = int( np.ceil( (self.f_calls - self.initial_size) / self.kwargs.get("q", 1) ) ) def _generate_initial_data(self): # generate training data train_x = torch.rand( self.initial_size, self.search_space.size, device=self.device, dtype=self.dtype, ) if isinstance(self.search_space, ContinuousSearchspace): res = self.search_space.loss( train_x.cpu().numpy(), algorithm="BO", acquisition=0, ) else: res = self.search_space.loss( self.search_space.to_continuous.reverse(train_x.cpu().numpy()), algorithm="BO", acquisition=0, ) # add output dimension train_obj = torch.tensor(res).unsqueeze(-1).double() return train_x, train_obj def _initialize_model(self, train_x, train_obj, state_dict=None): # define models for objective and constraint model = self.surrogate( train_x, train_obj, **{ key: value for key, value in self.kwargs.items() if key in self.surrogate.__init__.__code__.co_varnames }, ).to(train_x) mll = self.likelihood(model.likelihood, model) # load state dict if it is passed if state_dict is not None: model.load_state_dict(state_dict) return mll, model def _optimize_acqf_and_get_observation( self, acq_func, restarts=10, raw=512 ): """Optimizes the acquisition function, and returns a new candidate and a noisy observation.""" # optimize candidates, acqf = optimize_acqf( acq_function=acq_func, bounds=self.bounds, q=self.kwargs.get("q", 1), num_restarts=restarts, raw_samples=raw, # used for intialization heuristic options={"batch_limit": 5, "maxiter": 200}, ) # observe new values new_x = candidates.detach() # progress bar self.pending_pb(len(new_x)) if isinstance(self.search_space, ContinuousSearchspace): res = self.search_space.loss( new_x.cpu().numpy(), algorithm="BO", acquisition=acqf.cpu().item(), ) else: res = self.search_space.loss( self.search_space.to_continuous.reverse(new_x.cpu().numpy()), algorithm="BO", acquisition=acqf.cpu().item(), ) new_obj = torch.tensor(res).unsqueeze(-1) # add output dimension # progress bar self.update_main_pb( len(new_x), explor=True, best=self.search_space.loss.new_best ) return new_x, new_obj def _build_acqf_kwarg(self): self.acqf_kwargs = { key: value for key, value in self.kwargs.items() if key in self.acquisition.__init__.__code__.co_varnames }
[docs] def run(self, H=None, n_process=1): """run(n_process=1) Runs BO. Parameters ---------- H : Fractal, optional When used by :ref:`dba`, a fractal corresponding to the current subspace is given n_process : int, default=1 Determine the number of best solution found to return. Returns ------- best_sol : list[float] Returns a list of the :code:`n_process` best found points to the continuous format. best_scores : list[float] Returns a list of the :code:`n_process` best found scores associated to best_sol. """ if H: sp = H else: sp = self.search_space # progress bar self.build_bar(self.iterations) # call helper functions to generate initial training data and initialize model train_x, train_obj = self._generate_initial_data() # progress bar self.pending_pb(self.initial_size) mll, model = self._initialize_model(train_x, train_obj) # progress bar self.update_main_pb( self.initial_size, explor=True, best=self.search_space.loss.new_best ) self.meta_pb.update() iteration = 1 # run N_BATCH rounds of BayesOpt after the initial random batch while ( iteration < self.iterations and self.search_space.loss.calls < self.f_calls ): # fit the models fit_gpytorch_model(mll) # Add potentially usefull kwargs for acqf kwargs self.kwargs["best_f"] = -self.search_space.loss.best_score self.kwargs["X_baseline"] = (train_x,) # Build acqf kwargs self._build_acqf_kwarg() acqf = self.acquisition(model=model, **self.acqf_kwargs) # optimize and get new observation ( new_x, new_obj, ) = self._optimize_acqf_and_get_observation(acqf) # update training points train_x = torch.cat([train_x, new_x]) train_obj = torch.cat([train_obj, new_obj]) # reinitialize the models so they are ready for fitting on next iteration # use the current state dict to speed up fitting mll, model = self._initialize_model( train_x, train_obj, model.state_dict(), ) iteration += 1 # progress bar self.meta_pb.update() self.close_bar() return self.search_space.loss.get_best(n_process)