# bbotk: A brief introduction

#### Jakob Richter

#### 2024-07-24

# Overview

The main goal of the black box optimization toolkit (`bbotk`) is to provide a common framework for optimization for other packages. Therefore `bbotk` includes the following R6 classes that can be used in a variety of optimization scenarios.

- `Optimizer`: Objects of this class allow you to optimize an object of the class `OptimInstance`.
- `OptimInstance`: Defines the optimization problem, consisting of an `Objective`, the `search_space` and a `Terminator`. All evaluations on the `OptimInstance` will be automatically stored in its own `Archive`.
- `Objective`: Objects of this class contain the objective function. The class ensures that the objective function is called in the right way and defines, whether the function should be minimized or maximized.
- `Terminator`: Objects of this class control the termination of the optimization independent of the optimizer.

As `bbotk` also includes some basic optimizers and can be used on its own. The registered optimizers can be queried as follows:

    library(bbotk)
    #> Loading required package: paradox

    opts()
    #> <DictionaryOptimizer> with 11 stored values
    #> Keys: async_design_points, async_grid_search, async_random_search,
    #>   cmaes, design_points, focus_search, gensa, grid_search, irace,
    #>   nloptr, random_search

This Vignette will show you how to use the `bbotk`-classes to solve a simple optimization problem. Furthermore, you will learn how to

- construct your `Objective`.
- define your optimization problem in an `OptimInstance`
- define a restricted `search_space`.
- define the logging threshold.
- access the `Archive` of evaluated function calls.

## Use `bbotk` to optimize a function

In the following we will use `bbotk` to minimize this function:

    fun = function(xs) {
      c(y = - (xs[[1]] - 2)^2 - (xs[[2]] + 3)^2 + 10)
    }

First we need to wrap `fun` inside an `Objective` object. For functions that expect a list as input we can use the `ObjectiveRFun` class. Additionally, we need to specify the domain, i.e. the space of x-values that the function accepts as an input. Optionally, we can define the co-domain, i.e. the output space of our objective function. This is only necessary if we want to deviate from the default which would define the output to be named y and be minimized. Such spaces are defined using the package `paradox`.

    library(paradox)

    domain = ps(
      x1 = p_dbl(-10, 10),
      x2 = p_dbl(-5, 5)
    )
    codomain = ps(
      y = p_dbl(tags = "maximize")
    )
    obfun = ObjectiveRFun$new(
      fun = fun,
      domain = domain,
      codomain = codomain,
      properties = "deterministic" # i.e. the result always returns the same result for the same input.
    )

In the next step we decide when the optimization should stop. We can list all available terminators as follows:

    trms()
    #> <DictionaryTerminator> with 8 stored values
    #> Keys: clock_time, combo, evals, none, perf_reached, run_time,
    #>   stagnation, stagnation_batch

The termination should stop, when it takes longer then 10 seconds or when 20 evaluations are reached.

    terminators = list(
      evals = trm("evals", n_evals = 20),
      run_time = trm("run_time")
    )
    terminators
    #> $evals
    #> <TerminatorEvals>: Number of Evaluation
    #> * Parameters: n_evals=20, k=0
    #>
    #> $run_time
    #> <TerminatorRunTime>: Run Time
    #> * Parameters: secs=30

We have to correct the default of `secs=30` by setting the `values` in the `param_set` of the terminator.

    terminators$run_time$param_set$values$secs = 10

We have created `Terminator` objects for both of our criteria. To combine them we use the combo `Terminator`.

    term_combo = TerminatorCombo$new(terminators = terminators)

Before we finally start the optimization, we have to create an `OptimInstance` that contains also the `Objective` and the `Terminator`.

    instance = OptimInstanceBatchSingleCrit$new(objective = obfun, terminator = term_combo)
    instance
    #> <OptimInstanceBatchSingleCrit>
    #> * State:  Not optimized
    #> * Objective: <ObjectiveRFun:function>
    #> * Search Space:
    #>        id    class lower upper nlevels
    #>    <char>   <char> <num> <num>   <num>
    #> 1:     x1 ParamDbl   -10    10     Inf
    #> 2:     x2 ParamDbl    -5     5     Inf
    #> * Terminator: <TerminatorCombo>

Note, that `OptimInstance(SingleCrit/MultiCrit)$new()` also has an optional `search_space` argument. It can be used if the `search_space` is only a subset of `obfun$domain` or if you want to apply transformations. More on that later.

Finally, we have to define an `Optimizer`. As we have seen above, that we can call `opts()` to list all available optimizers. We opt for evolutionary optimizer, from the `GenSA` package.

    optimizer = opt("gensa")
    optimizer
    #> <OptimizerBatchGenSA>: Generalized Simulated Annealing
    #> * Parameters: list()
    #> * Parameter classes: ParamDbl
    #> * Properties: single-crit
    #> * Packages: bbotk, GenSA

To start the optimization we have to call the `Optimizer` on the `OptimInstance`.

    optimizer$optimize(instance)
    #> INFO  [13:59:42.109] [bbotk] Starting to optimize 2 parameter(s) with '<OptimizerBatchGenSA>' and '<TerminatorCombo> [any=TRUE]'
    #> INFO  [13:59:42.128] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.139] [bbotk] Result of batch 1:
    #> INFO  [13:59:42.140] [bbotk]         x1        x2         y
    #> INFO  [13:59:42.140] [bbotk]  -4.689827 -1.278761 -37.71645
    #> INFO  [13:59:42.142] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.153] [bbotk] Result of batch 2:
    #> INFO  [13:59:42.154] [bbotk]         x1        x2       y
    #> INFO  [13:59:42.154] [bbotk]  -5.930364 -4.400474 -54.852
    #> INFO  [13:59:42.156] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.162] [bbotk] Result of batch 3:
    #> INFO  [13:59:42.163] [bbotk]        x1        x2         y
    #> INFO  [13:59:42.163] [bbotk]  7.170817 -1.519948 -18.92791
    #> INFO  [13:59:42.166] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.173] [bbotk] Result of batch 4:
    #> INFO  [13:59:42.173] [bbotk]      x1        x2        y
    #> INFO  [13:59:42.173] [bbotk]  2.0452 -1.519948 7.807403
    #> INFO  [13:59:42.175] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.183] [bbotk] Result of batch 5:
    #> INFO  [13:59:42.185] [bbotk]      x1        x2       y
    #> INFO  [13:59:42.185] [bbotk]  2.0452 -2.064742 9.12325
    #> INFO  [13:59:42.187] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.194] [bbotk] Result of batch 6:
    #> INFO  [13:59:42.195] [bbotk]      x1        x2       y
    #> INFO  [13:59:42.195] [bbotk]  2.0452 -2.064742 9.12325
    #> INFO  [13:59:42.197] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.204] [bbotk] Result of batch 7:
    #> INFO  [13:59:42.205] [bbotk]        x1        x2       y
    #> INFO  [13:59:42.205] [bbotk]  2.045201 -2.064742 9.12325
    #> INFO  [13:59:42.206] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.213] [bbotk] Result of batch 8:
    #> INFO  [13:59:42.215] [bbotk]        x1        x2       y
    #> INFO  [13:59:42.215] [bbotk]  2.045199 -2.064742 9.12325
    #> INFO  [13:59:42.217] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.225] [bbotk] Result of batch 9:
    #> INFO  [13:59:42.226] [bbotk]      x1        x2        y
    #> INFO  [13:59:42.226] [bbotk]  2.0452 -2.064741 9.123248
    #> INFO  [13:59:42.228] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.235] [bbotk] Result of batch 10:
    #> INFO  [13:59:42.236] [bbotk]      x1        x2        y
    #> INFO  [13:59:42.236] [bbotk]  2.0452 -2.064743 9.123252
    #> INFO  [13:59:42.238] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.245] [bbotk] Result of batch 11:
    #> INFO  [13:59:42.246] [bbotk]      x1        x2       y
    #> INFO  [13:59:42.246] [bbotk]  1.9548 -3.935258 9.12325
    #> INFO  [13:59:42.248] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.255] [bbotk] Result of batch 12:
    #> INFO  [13:59:42.256] [bbotk]        x1        x2       y
    #> INFO  [13:59:42.256] [bbotk]  1.954801 -3.935258 9.12325
    #> INFO  [13:59:42.258] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.268] [bbotk] Result of batch 13:
    #> INFO  [13:59:42.269] [bbotk]        x1        x2       y
    #> INFO  [13:59:42.269] [bbotk]  1.954799 -3.935258 9.12325
    #> INFO  [13:59:42.272] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.280] [bbotk] Result of batch 14:
    #> INFO  [13:59:42.281] [bbotk]      x1        x2        y
    #> INFO  [13:59:42.281] [bbotk]  1.9548 -3.935257 9.123252
    #> INFO  [13:59:42.284] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.292] [bbotk] Result of batch 15:
    #> INFO  [13:59:42.293] [bbotk]      x1        x2        y
    #> INFO  [13:59:42.293] [bbotk]  1.9548 -3.935259 9.123248
    #> INFO  [13:59:42.295] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.304] [bbotk] Result of batch 16:
    #> INFO  [13:59:42.305] [bbotk]  x1 x2  y
    #> INFO  [13:59:42.305] [bbotk]   2 -3 10
    #> INFO  [13:59:42.308] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.318] [bbotk] Result of batch 17:
    #> INFO  [13:59:42.319] [bbotk]        x1 x2  y
    #> INFO  [13:59:42.319] [bbotk]  2.000001 -3 10
    #> INFO  [13:59:42.321] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.328] [bbotk] Result of batch 18:
    #> INFO  [13:59:42.329] [bbotk]        x1 x2  y
    #> INFO  [13:59:42.329] [bbotk]  1.999999 -3 10
    #> INFO  [13:59:42.331] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.338] [bbotk] Result of batch 19:
    #> INFO  [13:59:42.339] [bbotk]  x1        x2  y
    #> INFO  [13:59:42.339] [bbotk]   2 -2.999999 10
    #> INFO  [13:59:42.341] [bbotk] Evaluating 1 configuration(s)
    #> INFO  [13:59:42.348] [bbotk] Result of batch 20:
    #> INFO  [13:59:42.349] [bbotk]  x1        x2  y
    #> INFO  [13:59:42.349] [bbotk]   2 -3.000001 10
    #> INFO  [13:59:42.353] [bbotk] Finished optimizing after 20 evaluation(s)
    #> INFO  [13:59:42.353] [bbotk] Result:
    #> INFO  [13:59:42.357] [bbotk]     x1    x2  x_domain     y
    #> INFO  [13:59:42.357] [bbotk]  <num> <num>    <list> <num>
    #> INFO  [13:59:42.357] [bbotk]      2    -3 <list[2]>    10
    #>       x1    x2  x_domain     y
    #>    <num> <num>    <list> <num>
    #> 1:     2    -3 <list[2]>    10

Note, that we did not specify the termination inside the optimizer. `bbotk` generally sets the termination of the optimizers to never terminate and instead breaks the code internally as soon as a termination criterion is fulfilled.

The results can be queried from the `OptimInstance`.

    # result as a data.table
    instance$result
    #>       x1    x2  x_domain     y
    #>    <num> <num>    <list> <num>
    #> 1:     2    -3 <list[2]>    10

    # result as a list that can be passed to the Objective
    instance$result_x_domain
    #> $x1
    #> [1] 2
    #>
    #> $x2
    #> [1] -3

    # result outcome
    instance$result_y
    #>  y
    #> 10

You can also access the whole history of evaluated points.

    as.data.table(instance$archive)
    #>            x1        x2          y           timestamp batch_nr x_domain_x1
    #>         <num>     <num>      <num>              <POSc>    <int>       <num>
    #>  1: -4.689827 -1.278761 -37.716445 2024-07-24 13:59:42        1   -4.689827
    #>  2: -5.930364 -4.400474 -54.851999 2024-07-24 13:59:42        2   -5.930364
    #>  3:  7.170817 -1.519948 -18.927907 2024-07-24 13:59:42        3    7.170817
    #>  4:  2.045200 -1.519948   7.807403 2024-07-24 13:59:42        4    2.045200
    #>  5:  2.045200 -2.064742   9.123250 2024-07-24 13:59:42        5    2.045200
    #>  6:  2.045200 -2.064742   9.123250 2024-07-24 13:59:42        6    2.045200
    #>  7:  2.045201 -2.064742   9.123250 2024-07-24 13:59:42        7    2.045201
    #>  8:  2.045199 -2.064742   9.123250 2024-07-24 13:59:42        8    2.045199
    #>  9:  2.045200 -2.064741   9.123248 2024-07-24 13:59:42        9    2.045200
    #> 10:  2.045200 -2.064743   9.123252 2024-07-24 13:59:42       10    2.045200
    #> 11:  1.954800 -3.935258   9.123250 2024-07-24 13:59:42       11    1.954800
    #> 12:  1.954801 -3.935258   9.123250 2024-07-24 13:59:42       12    1.954801
    #> 13:  1.954799 -3.935258   9.123250 2024-07-24 13:59:42       13    1.954799
    #> 14:  1.954800 -3.935257   9.123252 2024-07-24 13:59:42       14    1.954800
    #> 15:  1.954800 -3.935259   9.123248 2024-07-24 13:59:42       15    1.954800
    #> 16:  2.000000 -3.000000  10.000000 2024-07-24 13:59:42       16    2.000000
    #> 17:  2.000001 -3.000000  10.000000 2024-07-24 13:59:42       17    2.000001
    #> 18:  1.999999 -3.000000  10.000000 2024-07-24 13:59:42       18    1.999999
    #> 19:  2.000000 -2.999999  10.000000 2024-07-24 13:59:42       19    2.000000
    #> 20:  2.000000 -3.000001  10.000000 2024-07-24 13:59:42       20    2.000000
    #>            x1        x2          y           timestamp batch_nr x_domain_x1
    #>     x_domain_x2
    #>           <num>
    #>  1:   -1.278761
    #>  2:   -4.400474
    #>  3:   -1.519948
    #>  4:   -1.519948
    #>  5:   -2.064742
    #>  6:   -2.064742
    #>  7:   -2.064742
    #>  8:   -2.064742
    #>  9:   -2.064741
    #> 10:   -2.064743
    #> 11:   -3.935258
    #> 12:   -3.935258
    #> 13:   -3.935258
    #> 14:   -3.935257
    #> 15:   -3.935259
    #> 16:   -3.000000
    #> 17:   -3.000000
    #> 18:   -3.000000
    #> 19:   -2.999999
    #> 20:   -3.000001
    #>     x_domain_x2

### Search Space Transformations

If the domain of the `Objective` is complex, it is often necessary to define a simpler search space that can be handled by the `Optimizer` and to define a transformation that transforms the value suggested by the optimizer to a value of the domain of the `Objective`.

Reasons for transformations can be:

1. The objective is more sensitive to changes of small values than to changes of bigger values of a certain parameter. E.g. we could suspect that for a parameter `x3` the change from `0.1` to `0.2` has a similar effect as the change of `100` to `200`.
2. Certain restrictions are known, i.e. the sum of three parameters is supposed to be 1.
3. many more…

In the following we will look at an example for 2.)

We want to construct a box with the maximal volume, with the restriction that `h+w+d = 1`. For simplicity we define our problem as a minimization problem.

    fun_volume = function(xs) {
      c(y = - (xs$h * xs$w * xs$d))
    }
    domain = ps(
      h = p_dbl(lower = 0),
      w = p_dbl(lower = 0),
      d = p_dbl(lower = 0)
    )
    obj = ObjectiveRFun$new(
      fun = fun_volume,
      domain = domain
    )

We notice, that our optimization problem has three parameters but due to the restriction it only the degree of freedom 2. Therefore we only need to optimize two parameters and calculate `h`, `w`, `d` accordingly.

    search_space = ps(
      h = p_dbl(lower = 0, upper = 1),
      w = p_dbl(lower = 0, upper = 1),
      .extra_trafo = function(x, param_set){
        x = unlist(x)
        x["d"] = 2 - sum(x) # model d in dependence of h, w
        x = x/sum(x) # ensure that h+w+d = 1
        as.list(x)
      }
    )

Instead of the domain of the `Objective` we now use our constructed `search_space` that includes the `trafo` for the `OptimInstance`.

    inst = OptimInstanceBatchSingleCrit$new(
      objective = obj,
      search_space = search_space,
      terminator = trm("evals", n_evals = 30)
    )
    optimizer = opt("gensa")
    lg = lgr::get_logger("bbotk")$set_threshold("warn") # turn off console output
    optimizer$optimize(inst)
    #>            h         w  x_domain          y
    #>        <num>     <num>    <list>      <num>
    #> 1: 0.6647984 0.6671394 <list[3]> -0.0370368

    lg = lgr::get_logger("bbotk")$set_threshold("info") # turn on console output

The optimizer only saw the search space during optimization and returns the following result:

    inst$result_x_search_space
    #>            h         w
    #>        <num>     <num>
    #> 1: 0.6647984 0.6671394

Internally, the `OptimInstance` transformed these values to the domain so that the result for the `Objective` looks as follows:

    inst$result_x_domain
    #> $h
    #> [1] 0.3323992
    #>
    #> $w
    #> [1] 0.3335697
    #>
    #> $d
    #> [1] 0.3340311

    obj$eval(inst$result_x_domain)
    #>          y
    #> -0.0370368

### Notes on the optimization archive

The following is just meant for advanced readers. If you want to evaluate the function outside of the optimization but have the result stored in the `Archive` you can do so by resetting the termination and call `$eval_batch()`.

    library(data.table)

    inst$terminator = trm("none")
    xvals = data.table(h = c(0.6666, 0.6667), w = c(0.6666, 0.6667))
    inst$eval_batch(xdt = xvals)
    #> INFO  [13:59:42.797] [bbotk] Evaluating 2 configuration(s)
    #> INFO  [13:59:42.810] [bbotk] Result of batch 31:
    #> INFO  [13:59:42.811] [bbotk]       h      w           y
    #> INFO  [13:59:42.811] [bbotk]  0.6666 0.6666 -0.03703704
    #> INFO  [13:59:42.811] [bbotk]  0.6667 0.6667 -0.03703704

    tail(as.data.table(instance$archive))
    #>          x1        x2         y           timestamp batch_nr x_domain_x1
    #>       <num>     <num>     <num>              <POSc>    <int>       <num>
    #> 1: 1.954800 -3.935259  9.123248 2024-07-24 13:59:42       15    1.954800
    #> 2: 2.000000 -3.000000 10.000000 2024-07-24 13:59:42       16    2.000000
    #> 3: 2.000001 -3.000000 10.000000 2024-07-24 13:59:42       17    2.000001
    #> 4: 1.999999 -3.000000 10.000000 2024-07-24 13:59:42       18    1.999999
    #> 5: 2.000000 -2.999999 10.000000 2024-07-24 13:59:42       19    2.000000
    #> 6: 2.000000 -3.000001 10.000000 2024-07-24 13:59:42       20    2.000000
    #>    x_domain_x2
    #>          <num>
    #> 1:   -3.935259
    #> 2:   -3.000000
    #> 3:   -3.000000
    #> 4:   -3.000000
    #> 5:   -2.999999
    #> 6:   -3.000001

However, this does not update the result. You could set the result by calling `inst$assign_result()` but this should be handled by the optimizer. Another way to get the best point is the following:

    inst$archive$best()
    #>         h      w           y  x_domain           timestamp batch_nr
    #>     <num>  <num>       <num>    <list>              <POSc>    <int>
    #> 1: 0.6667 0.6667 -0.03703704 <list[3]> 2024-07-24 13:59:42       31

Note, that this method just looks for the best outcome and returns the according row from the archive. For stochastic optimization problems this is overly optimistic and leads to biased results. For this reason the optimizer can use advanced methods to determine the result and set it itself.