"""
:mod:`orion.algo.bohb` -- BOHB
==============================
Module for the wrapper around HpBandSter.
"""
import copy
import numpy as np
from orion.algo.base import BaseAlgorithm
from orion.algo.parallel_strategy import strategy_factory
from orion.algo.space import Fidelity
from orion.core.utils.format_trials import dict_to_trial
from orion.core.utils.module_import import ImportOptional
with ImportOptional("BOHB") as import_optional:
from hpbandster.optimizers.config_generators.bohb import BOHB as CG_BOHB
from hpbandster.optimizers.iterations import SuccessiveHalving
from sspace.convert import convert_space, reverse, transform
if import_optional.failed:
CG_BOHB = None # noqa: F811
SuccessiveHalving = None # noqa: F811
SPACE_ERROR = """
BOHB cannot be used if space does not contain a fidelity dimension.
For more information on the configuration and usage of BOHB, see
https://orion.readthedocs.io/en/develop/user/algorithms.html#bohb-algorithm
"""
# SuccessiveHalving gives us tuples of stuff to run but expects the results
# to be packaged up in jobs so this is filling in for those jobs.
[docs]class FakeJob: # pylint: disable=too-few-public-methods
"""
Minimal HpBandSter Job mock.
This mimics enough of the HpBandSter Job interface to report results.
"""
def __init__(self, run, trial):
self.id = run[0] # pylint: disable=invalid-name
self.kwargs = dict(config=reverse(run[1]), budget=run[2])
self.timestamps = {}
self.result = {}
if trial.objective is not None:
self.result["loss"] = trial.objective.value
self.exception = None
[docs]class BOHB(BaseAlgorithm):
"""Bayesian Optimization with HyperBand
This class is a wrapper around the library HpBandSter:
https://github.com/automl/HpBandSter.
For more information on the algorithm,
see original paper at https://arxiv.org/abs/1807.01774.
Falkner, Stefan, Aaron Klein, and Frank Hutter. "BOHB: Robust and efficient hyperparameter
optimization at scale." In International Conference on Machine Learning, pp. 1437-1446. PMLR,
2018.
Parameters
----------
space: `orion.algo.space.Space`
Optimisation space with priors for each dimension.
seed: None, int or sequence of int
Seed for the random number generator used to sample new trials.
Default: ``None``
min_points_in_model: int
Number of observations to start building a KDE. If ``None``, uses number
of dimensions in the search space + 1. Default: ``None``
top_n_percent: int
Percentage ( between 1 and 99) of the observations that are considered good. Default: 15
num_samples: int
Number of samples to optimize Expected Improvement. Default: 64
random_fraction: float
Fraction of purely random configurations that are sampled from the
prior without the model. Default: 1/3
bandwidth_factor: float
To encourage diversity, the points proposed to optimize EI, are sampled
from a 'widened' KDE where the bandwidth is multiplied by this factor. Default: 3
min_bandwidth: float
To keep diversity, even when all (good) samples have the same value
for one of the parameters, a minimum bandwidth is used instead of
zero. Default: 1e-3
parallel_strategy: dict or None, optional
The configuration of a parallel strategy to use for pending trials or broken trials.
Default is a MaxParallelStrategy for broken trials and NoParallelStrategy for pending
trials.
"""
requires_type = None
requires_dist = None
requires_shape = "flattened"
def __init__(
self,
space,
seed=None,
min_points_in_model=None,
top_n_percent=15,
num_samples=64,
random_fraction=1 / 3,
bandwidth_factor=3,
min_bandwidth=1e-3,
parallel_strategy=None,
): # pylint: disable=too-many-arguments
import_optional.ensure()
if parallel_strategy is None:
parallel_strategy = {
"of_type": "StatusBasedParallelStrategy",
"strategy_configs": {
"broken": {
"of_type": "MaxParallelStrategy",
},
},
}
self.strategy = strategy_factory.create(**parallel_strategy)
super().__init__(
space,
seed=seed,
min_points_in_model=min_points_in_model,
top_n_percent=top_n_percent,
num_samples=num_samples,
random_fraction=random_fraction,
bandwidth_factor=bandwidth_factor,
min_bandwidth=min_bandwidth,
parallel_strategy=parallel_strategy,
)
self.trial_meta = {}
self.trial_results = {}
self.iteration = 0
self.iterations = []
fidelity_index = self.fidelity_index
if fidelity_index is None:
raise RuntimeError(SPACE_ERROR)
fidelity_dim = self.space[fidelity_index]
fidelity_dim: Fidelity = space[self.fidelity_index]
# NOTE: This isn't a Fidelity, it's a TransformedDimension<Fidelity>
from orion.core.worker.transformer import TransformedDimension
# NOTE: Currently bypassing (possibly more than one) `TransformedDimension` wrappers to get
# the 'low', 'high' and 'base' attributes.
while isinstance(fidelity_dim, TransformedDimension):
fidelity_dim = fidelity_dim.original_dimension
assert isinstance(fidelity_dim, Fidelity)
self.min_budget = fidelity_dim.low
self.max_budget = fidelity_dim.high
self.eta = fidelity_dim.base
self._setup()
def _setup(self):
self.max_sh_iter = (
-int(np.log(self.min_budget / self.max_budget) / np.log(self.eta)) + 1
)
self.budgets = self.max_budget * np.power(
self.eta, -np.linspace(self.max_sh_iter - 1, 0, self.max_sh_iter)
)
self.bohb = CG_BOHB( # pylint: disable=attribute-defined-outside-init
configspace=convert_space(self.space),
min_points_in_model=self.min_points_in_model,
top_n_percent=self.top_n_percent,
num_samples=self.num_samples,
random_fraction=self.random_fraction,
bandwidth_factor=self.bandwidth_factor,
min_bandwidth=self.min_bandwidth,
)
self.bohb.configspace.seed(self.seed)
def _make_iteration(self, iteration):
ss = self.max_sh_iter - 1 - (iteration % self.max_sh_iter)
# number of configurations in that bracket
n0 = int(np.floor((self.max_sh_iter) / (ss + 1)) * self.eta**ss)
ns = [max(int(n0 * (self.eta ** (-i))), 1) for i in range(ss + 1)]
return SuccessiveHalving(
HPB_iter=iteration,
num_configs=ns,
budgets=self.budgets[(-ss - 1) :],
config_sampler=self.bohb.get_config,
)
[docs] def seed_rng(self, seed):
"""Seed the state of the random number generator.
Parameters
----------
seed: int
Integer seed for the random number generator.
"""
np.random.seed(seed)
if hasattr(self, "bohb"):
self.bohb.configspace.seed(seed)
@property
def state_dict(self):
"""Return a state dict that can be used to reset the state of the algorithm."""
state_dict = super().state_dict
state_dict["rng_state"] = np.random.get_state()
state_dict["eta"] = self.eta
state_dict["min_budget"] = self.min_budget
state_dict["max_budget"] = self.max_budget
state_dict["iteration"] = self.iteration
state_dict["iterations"] = copy.deepcopy(self.iterations)
state_dict["trial_meta"] = dict(self.trial_meta)
state_dict["trial_results"] = dict(self.trial_results)
state_dict["bohb"] = copy.deepcopy(self.bohb)
state_dict["strategy"] = self.strategy.state_dict
return state_dict
[docs] def set_state(self, state_dict):
"""Reset the state of the algorithm based on the given state_dict
:param state_dict: Dictionary representing state of an algorithm
"""
super().set_state(state_dict)
np.random.set_state(state_dict["rng_state"])
self.eta = state_dict["eta"]
self.min_budget = state_dict["min_budget"]
self.max_budget = state_dict["max_budget"]
self.iteration = state_dict["iteration"]
self.iterations = state_dict["iterations"]
self.trial_meta = state_dict["trial_meta"]
self.trial_results = state_dict["trial_results"]
self.bohb = state_dict["bohb"] # pylint: disable=attribute-defined-outside-init
self.strategy.set_state(state_dict["strategy"])
self._setup()
[docs] def suggest(self, num):
"""Suggest a number of new sets of parameters.
Parameters
----------
num: int, optional
Number of trials to suggest. The algorithm may return less than the number of trials
requested.
Returns
-------
list of trials or None
A list of trials representing values suggested by the algorithm. The algorithm may opt
out if it cannot make a good suggestion at the moment (it may be waiting for other
trials to complete), in which case it will return None.
Notes
-----
New parameters must be compliant with the problem's domain `orion.algo.space.Space`.
"""
def run_to_trial(run):
params = transform(run[1])
params[self.fidelity_index] = run[2]
return dict_to_trial(params, self.space)
def sample_iteration(iteration, trials):
while len(trials) < num and not iteration.is_finished:
run = iteration.get_next_run()
if run is None:
break
new_trial = run_to_trial(run)
# This means the job was already suggested and we have a result
result = self.trial_results.get(self.get_id(new_trial), None)
if result is not None:
job = FakeJob(run, new_trial)
job.result["loss"] = result
iteration.register_result(job)
self.bohb.new_result(job)
continue
self.trial_meta.setdefault(self.get_id(new_trial), []).append(run)
self.register(new_trial)
trials.append(new_trial)
trials = []
for it in self.iterations:
sample_iteration(it, trials)
# If we don't have enough trials and there are still
# some iterations left
if self.iteration < len(self.budgets):
self.iterations.append(self._make_iteration(self.iteration))
self.iteration += 1
sample_iteration(self.iterations[-1], trials)
return trials
[docs] def observe(self, trials):
"""Observe the `trials` new state of result.
Parameters
----------
trials: list of ``orion.core.worker.trial.Trial``
Trials from a `orion.algo.space.Space`.
"""
super().observe(trials)
for trial in trials:
if trial.status == "broken":
trial = self.strategy.infer(trial)
if trial.objective is not None:
self.trial_results[self.get_id(trial)] = trial.objective.value
runs = self.trial_meta.get(self.get_id(trial), [])
for run in runs:
job = FakeJob(run, trial)
self.iterations[job.id[0]].register_result(job)
self.bohb.new_result(job)
@property
def is_done(self):
"""Return True, if an algorithm holds that there can be no further improvement."""
return self.iteration == len(self.budgets) and all(
it.is_finished for it in self.iterations
)