# Basic tour of the Bayesian Optimization package

This is a constrained global optimization package built upon bayesian inference and gaussian process, that attempts to find the maximum value of an unknown function in as few iterations as possible. This technique is particularly suited for optimization of high cost functions, situations where the balance between exploration and exploitation is important.

Bayesian optimization works by constructing a posterior distribution of functions (gaussian process) that best describes the function you want to optimize. As the number of observations grows, the posterior distribution improves, and the algorithm becomes more certain of which regions in parameter space are worth exploring and which are not, as seen in the picture below.

As you iterate over and over, the algorithm balances its needs of exploration and exploitation taking into account what it knows about the target function. At each step a Gaussian Process is fitted to the known samples (points previously explored), and the posterior distribution, combined with a exploration strategy (such as UCB (Upper Confidence Bound), or EI (Expected Improvement)), are used to determine the next point that should be explored (see the gif below).

This process is designed to minimize the number of steps required to find a combination of parameters that are close to the optimal combination. To do so, this method uses a proxy optimization problem (finding the maximum of the acquisition function) that, albeit still a hard problem, is cheaper (in the computational sense) and common tools can be employed. Therefore Bayesian Optimization is most adequate for situations where sampling the function to be optimized is a very expensive endeavor. See the references for a proper discussion of this method.

## 1. Specifying the function to be optimized

This is a function optimization package, therefore the first and most important ingredient is, of course, the function to be optimized.

DISCLAIMER: We know exactly how the output of the function below depends on its parameter. Obviously this is just an example, and you shouldn’t expect to know it in a real scenario. However, it should be clear that you don’t need to. All you need in order to use this package (and more generally, this technique) is a function `f` that takes a known set of parameters and outputs a real number.

    [1]:

    def black_box_function(x, y):
        """Function with unknown internals we wish to maximize.

        This is just serving as an example, for all intents and
        purposes think of the internals of this function, i.e.: the process
        which generates its output values, as unknown.
        """
        return -x ** 2 - (y - 1) ** 2 + 1

## 2. Getting Started

All we need to get started is to instantiate a `BayesianOptimization` object specifying a function to be optimized `f`, and its parameters with their corresponding bounds, `pbounds`. This is a constrained optimization technique, so you must specify the minimum and maximum values that can be probed for each parameter in order for it to work

    [2]:

    from bayes_opt import BayesianOptimization

    [3]:

    # Bounded region of parameter space
    pbounds = {'x': (2, 4), 'y': (-3, 3)}

    [4]:

    optimizer = BayesianOptimization(
        f=black_box_function,
        pbounds=pbounds,
        verbose=2, # verbose = 1 prints only when a maximum is observed, verbose = 0 is silent
        random_state=1,
    )

The BayesianOptimization object will work out of the box without much tuning needed. The main method you should be aware of is `maximize`, which does exactly what you think it does.

There are many parameters you can pass to maximize, nonetheless, the most important ones are: - `n_iter`: How many steps of bayesian optimization you want to perform. The more steps the more likely to find a good maximum you are. - `init_points`: How many steps of random exploration you want to perform. Random exploration can help by diversifying the exploration space.

    [5]:

    optimizer.maximize(
        init_points=2,
        n_iter=3,
    )

    |   iter    |  target   |     x     |     y     |
    -------------------------------------------------
    | 1         | -7.135    | 2.834     | 1.322     |
    | 2         | -7.78     | 2.0       | -1.186    |
    | 3         | -6.967    | 2.582     | -0.1396   |
    | 4         | -12.24    | 2.015     | -2.029    |
    | 5         | -18.0     | 3.302     | -1.846    |
    =================================================

The best combination of parameters and target value found can be accessed via the property `bo.max`.

    [6]:

    print(optimizer.max)

    {'target': -6.966506881014352, 'params': {'x': 2.5822074982517598, 'y': -0.13961016009280103}}

While the list of all parameters probed and their corresponding target values is available via the property `bo.res`.

    [7]:

    for i, res in enumerate(optimizer.res):
        print("Iteration {}: \n\t{}".format(i, res))

    Iteration 0:
            {'target': -7.135455292718879, 'params': {'x': 2.8340440094051482, 'y': 1.3219469606529486}}
    Iteration 1:
            {'target': -7.779531005607566, 'params': {'x': 2.0002287496346898, 'y': -1.1860045642089614}}
    Iteration 2:
            {'target': -6.966506881014352, 'params': {'x': 2.5822074982517598, 'y': -0.13961016009280103}}
    Iteration 3:
            {'target': -12.235835023240657, 'params': {'x': 2.0154397119682423, 'y': -2.0288343947238228}}
    Iteration 4:
            {'target': -17.99963711795217, 'params': {'x': 3.301617813728339, 'y': -1.8458666395359906}}

### 2.1 Changing bounds

During the optimization process you may realize the bounds chosen for some parameters are not adequate. For these situations you can invoke the method `set_bounds` to alter them. You can pass any combination of existing parameters and their associated new bounds.

    [8]:

    optimizer.set_bounds(new_bounds={"x": (-2, 3)})

    [9]:

    optimizer.maximize(
        init_points=0,
        n_iter=5,
    )

    |   iter    |  target   |     x     |     y     |
    -------------------------------------------------
    | 6         | -1.378    | 1.229     | 0.06916   |
    | 7         | -0.9471   | -0.9434   | -0.02816  |
    | 8         | -3.651    | -1.168    | 2.813     |
    | 9         | -17.13    | -2.0      | -2.758    |
    | 10        | 0.8427    | -0.193    | 0.6535    |
    =================================================

## 3. Guiding the optimization

It is often the case that we have an idea of regions of the parameter space where the maximum of our function might lie. For these situations the `BayesianOptimization` object allows the user to specify specific points to be probed. By default these will be explored lazily (`lazy=True`), meaning these points will be evaluated only the next time you call `maximize`. This probing process happens before the gaussian process takes over.

Parameters can be passed as dictionaries such as below:

    [10]:

    optimizer.probe(
        params={"x": 0.5, "y": 0.7},
        lazy=True,
    )

Or as an iterable. Beware that the order has to be alphabetical. You can usee `optimizer.space.keys` for guidance

    [11]:

    print(optimizer.space.keys)

    ['x', 'y']

    [12]:

    optimizer.probe(
        params=[-0.3, 0.1],
        lazy=True,
    )

    [13]:

    optimizer.maximize(init_points=0, n_iter=0)

    |   iter    |  target   |     x     |     y     |
    -------------------------------------------------
    | 11        | 0.66      | 0.5       | 0.7       |
    | 12        | 0.1       | -0.3      | 0.1       |
    =================================================

## 4. Saving, loading and restarting

By default you can follow the progress of your optimization by setting `verbose>0` when instantiating the `BayesianOptimization` object. If you need more control over logging/alerting you will need to use an observer. For more information about observers checkout the advanced tour notebook. Here we will only see how to use the native `JSONLogger` object to save to and load progress from files.

### 4.1 Saving progress

    [14]:

    from bayes_opt.logger import JSONLogger
    from bayes_opt.event import Events

The observer paradigm works by: 1. Instantiating an observer object. 2. Tying the observer object to a particular event fired by an optimizer.

The `BayesianOptimization` object fires a number of internal events during optimization, in particular, every time it probes the function and obtains a new parameter-target combination it will fire an `Events.OPTIMIZATION_STEP` event, which our logger will listen to.

Caveat: The logger will not look back at previously probed points.

    [15]:

    logger = JSONLogger(path="./logs.log")
    optimizer.subscribe(Events.OPTIMIZATION_STEP, logger)

    [16]:

    optimizer.maximize(
        init_points=2,
        n_iter=3,
    )

    |   iter    |  target   |     x     |     y     |
    -------------------------------------------------
    | 13        | -12.48    | -1.266    | -2.446    |
    | 14        | -3.854    | -1.069    | -0.9266   |
    | 15        | 0.967     | 0.04749   | 1.175     |
    | 16        | 0.9912    | 0.07374   | 0.9421    |
    | 17        | -3.565    | 0.7821    | 2.988     |
    =================================================

### 4.2 Loading progress

Naturally, if you stored progress you will be able to load that onto a new instance of `BayesianOptimization`. The easiest way to do it is by invoking the `load_logs` function, from the `util` submodule.

    [17]:

    from bayes_opt.util import load_logs

    [18]:

    new_optimizer = BayesianOptimization(
        f=black_box_function,
        pbounds={"x": (-2, 2), "y": (-2, 2)},
        verbose=2,
        random_state=7,
    )
    print(len(new_optimizer.space))

    0

    [19]:

    load_logs(new_optimizer, logs=["./logs.log"]);

    [20]:

    print("New optimizer is now aware of {} points.".format(len(new_optimizer.space)))

    New optimizer is now aware of 5 points.

    [21]:

    new_optimizer.maximize(
        init_points=0,
        n_iter=10,
    )

    |   iter    |  target   |     x     |     y     |
    -------------------------------------------------
    | 1         | -0.6164   | -1.271    | 1.045     |
    | 2         | 0.9988    | 0.005532  | 1.034     |
    | 3         | 0.9757    | 0.1501    | 1.043     |
    | 4         | 0.9819    | -0.07993  | 0.8917    |
    | 5         | -3.35     | 1.976     | 0.3338    |
    | 6         | 0.9778    | -0.1166   | 1.093     |
    | 7         | 0.6605    | -0.02272  | 0.4178    |
    | 8         | -0.5044   | -0.7146   | 1.997     |
    | 9         | 0.9997    | 0.0084    | 1.016     |
    | 10        | -11.83    | 1.995     | -1.974    |
    =================================================

## Next Steps

This tour should be enough to cover most usage scenarios of this package. If, however, you feel like you need to know more, please checkout the `advanced-tour` notebook. There you will be able to find other, more advanced features of this package that could be what you’re looking for. Also, browse the examples folder for implementation tips and ideas.