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 classOptimInstance
. 
OptimInstance
: Defines the optimization problem, consisting of anObjective
, thesearch_space
and aTerminator
. All evaluations on theOptimInstance
will be automatically stored in its ownArchive
. 
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 7 stored values
#> Keys: cmaes, design_points, 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 xvalues that the function accepts as an input. Optionally, we can define the codomain, 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>
#> * Parameters: n_evals=20, k=0
#>
#> $run_time
#> <TerminatorRunTime>
#> * 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 = OptimInstanceSingleCrit$new(objective = obfun, terminator = term_combo)
instance
#> <OptimInstanceSingleCrit>
#> * State: Not optimized
#> * Objective: <ObjectiveRFun:function>
#> * Search Space:
#> <ParamSet>
#> id class lower upper nlevels default value
#> 1: x1 ParamDbl 10 10 Inf <NoDefault[3]>
#> 2: x2 ParamDbl 5 5 Inf <NoDefault[3]>
#> * Terminator: <TerminatorCombo>
#> * Terminated: FALSE
#> * Archive:
#> <Archive>
#> Null data.table (0 rows and 0 cols)
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
#> <OptimizerGenSA>
#> * Parameters: list()
#> * Parameter classes: ParamDbl
#> * Properties: singlecrit
#> * Packages: bbotk, GenSA
To start the optimization we have to call the Optimizer
on the OptimInstance
.
optimizer$optimize(instance)
#> x1 x2 x_domain y
#> 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
#> 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
#> 1: 4.689827 1.278761 37.716445 20220120 04:11:55 1 4.689827
#> 2: 5.930364 4.400474 54.851999 20220120 04:11:55 2 5.930364
#> 3: 7.170817 1.519948 18.927907 20220120 04:11:55 3 7.170817
#> 4: 2.045200 1.519948 7.807403 20220120 04:11:55 4 2.045200
#> 5: 2.045200 2.064742 9.123250 20220120 04:11:55 5 2.045200
#> 6: 2.045200 2.064742 9.123250 20220120 04:11:55 6 2.045200
#> 7: 2.045201 2.064742 9.123250 20220120 04:11:55 7 2.045201
#> 8: 2.045199 2.064742 9.123250 20220120 04:11:55 8 2.045199
#> 9: 2.045200 2.064741 9.123248 20220120 04:11:55 9 2.045200
#> 10: 2.045200 2.064743 9.123252 20220120 04:11:55 10 2.045200
#> 11: 1.954800 3.935258 9.123250 20220120 04:11:55 11 1.954800
#> 12: 1.954801 3.935258 9.123250 20220120 04:11:55 12 1.954801
#> 13: 1.954799 3.935258 9.123250 20220120 04:11:55 13 1.954799
#> 14: 1.954800 3.935257 9.123252 20220120 04:11:55 14 1.954800
#> 15: 1.954800 3.935259 9.123248 20220120 04:11:55 15 1.954800
#> 16: 2.000000 3.000000 10.000000 20220120 04:11:55 16 2.000000
#> 17: 2.000001 3.000000 10.000000 20220120 04:11:55 17 2.000001
#> 18: 1.999999 3.000000 10.000000 20220120 04:11:55 18 1.999999
#> 19: 2.000000 2.999999 10.000000 20220120 04:11:55 19 2.000000
#> 20: 2.000000 3.000001 10.000000 20220120 04:11:55 20 2.000000
#> x_domain_x2
#> 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
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:
 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 from0.1
to0.2
has a similar effect as the change of100
to200
.  Certain restrictions are known, i.e. the sum of three parameters is supposed to be 1.
 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 = OptimInstanceSingleCrit$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
#> 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
#> 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 [04:11:56.798] [bbotk] Evaluating 2 configuration(s)
#> INFO [04:11:56.834] [bbotk] Result of batch 31:
#> INFO [04:11:56.846] [bbotk] h w y
#> INFO [04:11:56.846] [bbotk] 0.6666 0.6666 0.03703704
#> INFO [04:11:56.846] [bbotk] 0.6667 0.6667 0.03703704
tail(as.data.table(instance$archive))
#> x1 x2 y timestamp batch_nr x_domain_x1
#> 1: 1.954800 3.935259 9.123248 20220120 04:11:55 15 1.954800
#> 2: 2.000000 3.000000 10.000000 20220120 04:11:55 16 2.000000
#> 3: 2.000001 3.000000 10.000000 20220120 04:11:55 17 2.000001
#> 4: 1.999999 3.000000 10.000000 20220120 04:11:55 18 1.999999
#> 5: 2.000000 2.999999 10.000000 20220120 04:11:55 19 2.000000
#> 6: 2.000000 3.000001 10.000000 20220120 04:11:55 20 2.000000
#> x_domain_x2
#> 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
#> 1: 0.6667 0.6667 0.03703704 <list[3]> 20220120 04:11:56 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.