Use mlrMBO to optimize via command line

Tutorial on using mlrMBO from the command line
R
r-bloggers
Author

Jakob Richter

Published

March 22, 2017

Many people who want to apply Bayesian optimization want to use it to optimize an algorithm that is not implemented in R but runs on the command line as a shell script or an executable.

We recently published mlrMBO on CRAN. As a normal package it normally operates inside of R, but with this post I want to demonstrate how mlrMBO can be used to optimize an external application. At the same time I will highlight some issues you can likely run into.

First of all we need a bash script that we want to optimize. This tutorial will only run on Unix systems (Linux, OSX etc.) but should also be informative for windows users. The following code will write a tiny bash script that uses bc to calculate \(sin(x_1-1) + (x_1^2 + x_2^2)\) and write the result “hidden” in a sentence (The result is 12.34!) in a result.txt text file.

The bash script

# write bash script
lines = '#!/bin/bash
fun ()
{
  x1=$1
  x2=$2
  command="(s($x1-1) + ($x1^2 + $x2^2))"
  result=$(bc -l <<< $command)
}
echo "Start calculation."
fun $1 $2
echo "The result is $result!" > "result.txt"
echo "Finish calculation."
'
writeLines(lines, "fun.sh")
# make it executable:
system("chmod +x fun.sh")

Running the script from R

Now we need a R function that starts the script, reads the result from the text file and returns it.

library(stringi)
runScript = function(x) {
  command = sprintf("./fun.sh %f %f", x[['x1']], x[['x2']])
  error.code = system(command)
  if (error.code != 0) {
    stop("Simulation had error.code != 0!")
  }
  result = readLines("result.txt")
  # the pattern matches 12 as well as 12.34 and .34
  # the ?: makes the decimals a non-capturing group.
  result = stri_match_first_regex(result,
    pattern = "\\d*(?:\\.\\d+)?(?=\\!)")
  as.numeric(result)
}

This function uses stringi and regular expressions to match the result within the sentence. Depending on the output different strategies to read the result make sense. XML files can usually be accessed with XML::xmlParse, XML::getNodeSet, XML::xmlAttrs etc. using XPath queries. Sometimes the good old read.table() is also sufficient. If, for example, the output is written in a file like this:

value1 = 23.45
value2 = 13.82

You can easily use source() like that:

EV = new.env()
eval(expr = {a = 1}, envir = EV)
as.list(EV)
source(file = "result.txt", local = EV)
res = as.list(EV)
rm(EV)

which will return a list with the entries $value1 and $value2.

Define bounds, wrap function.

To evaluate the function from within mlrMBO it has to be wrapped in smoof function. The smoof function also contains information about the bounds and scales of the domain of the objective function defined in a ParameterSet.

library(mlrMBO)
## Loading required package: mlr
## Loading required package: ParamHelpers
## Warning message: 'mlr' is in 'maintenance-only' mode since July 2019. Future development will only happen
## in 'mlr3' (<https://mlr3.mlr-org.com>). Due to the focus on 'mlr3' there might be uncaught bugs meanwhile
## in {mlr} - please consider switching.
## Loading required package: smoof
## Loading required package: checkmate
## Warning: no DISPLAY variable so Tk is not available
# Defining the bounds of the parameters:
par.set = makeParamSet(
  makeNumericParam("x1", lower = -3, upper = 3),
  makeNumericParam("x2", lower = -2.5, upper = 2.5)
)
# Wrapping everything in a smoof function:
fn = makeSingleObjectiveFunction(
  id = "fun.sh",
  fn = runScript,
  par.set = par.set,
  has.simple.signature = FALSE
)
# let's see if the function is working
des = generateGridDesign(par.set, resolution = 3)
des$y = apply(des, 1, fn)
des
##   x1   x2         y
## 1 -3 -2.5 16.006802
## 2  0 -2.5  5.408529
## 3  3 -2.5 16.159297
## 4 -3  0.0  9.756802
## 5  0  0.0  0.841471
## 6  3  0.0  9.909297
## 7 -3  2.5 16.006802
## 8  0  2.5  5.408529
## 9  3  2.5 16.159297

If you run this locally, you will see that the console output generated by our shell script directly appears in the R-console. This can be helpful but also annoying.

Redirecting output

If a lot of output is generated during a single call of system() it might even crash R. To avoid that I suggest to redirect the output into a file. This way no output is lost and the R console does not get flooded. We can simply achieve that by replacing the command in the function runScript from above with the following code:

  # console output file output_1490030005_1.1_2.4.txt
  output_file = sprintf("output_%i_%.1f_%.1f.txt",
    as.integer(Sys.time()), x[['x1']], x[['x2']])
  # redirect output with ./fun.sh 1.1 2.4 > output.txt
  # alternative: ./fun.sh 1.1 2.4 > /dev/null to drop it
  command = sprintf("./fun.sh %f %f > %s", x[['x1']], x[['x2']], output_file)

Start the Optimization

Now everything is set so we can proceed with the usual MBO setup:

ctrl = makeMBOControl()
ctrl = setMBOControlInfill(ctrl, crit = crit.ei)
ctrl = setMBOControlTermination(ctrl, iters = 10)
configureMlr(show.info = FALSE, show.learner.output = FALSE)
run = mbo(fun = fn, control = ctrl)
## Computing y column(s) for design. Not provided.
## [mbo] 0: x1=-1.35; x2=0.815 : y = 1.77 : 0.0 secs : initdesign
## [mbo] 0: x1=1.93; x2=-0.485 : y = 4.76 : 0.0 secs : initdesign
## [mbo] 0: x1=-2.61; x2=-1.66 : y = 9.98 : 0.0 secs : initdesign
## [mbo] 0: x1=-0.223; x2=0.239 : y = 0.833 : 0.0 secs : initdesign
## [mbo] 0: x1=0.373; x2=2.22 : y = 4.48 : 0.0 secs : initdesign
## [mbo] 0: x1=0.763; x2=-0.825 : y = 1.03 : 0.0 secs : initdesign
## [mbo] 0: x1=2.38; x2=1.31 : y = 8.39 : 0.0 secs : initdesign
## [mbo] 0: x1=-1.8; x2=-2.42 : y = 8.77 : 0.0 secs : initdesign
## [mbo] 1: x1=0.18; x2=-0.478 : y = 0.47 : 0.0 secs : infill_ei
## [mbo] 2: x1=-0.118; x2=-0.806 : y = 0.236 : 0.0 secs : infill_ei
## [mbo] 3: x1=0.242; x2=-1.39 : y = 1.31 : 0.0 secs : infill_ei
## [mbo] 4: x1=-0.5; x2=-0.462 : y = 0.535 : 0.0 secs : infill_ei
## [mbo] 5: x1=-0.109; x2=-0.64 : y = 0.474 : 0.0 secs : infill_ei
## [mbo] 6: x1=0.161; x2=-0.893 : y = 0.0784 : 0.0 secs : infill_ei
## [mbo] 7: x1=-3; x2=2.5 : y = 16 : 0.0 secs : infill_ei
## [mbo] 8: x1=-0.866; x2=-0.878 : y = 0.564 : 0.0 secs : infill_ei
## [mbo] 9: x1=-0.555; x2=0.939 : y = 0.189 : 0.0 secs : infill_ei
## [mbo] 10: x1=0.15; x2=0.806 : y = 0.0787 : 0.0 secs : infill_ei
# The resulting optimal configuration:
run$x
## $x1
## [1] 0.1609242
## 
## $x2
## [1] -0.8925002
# The best reached value:
run$y
## [1] 0.07842672

Execute the R script from a shell

Also you might not want to bothered having to start R and run this script manually so what I would recommend is saving all above as an R-script plus some lines that write the output in a JSON file like this:

library(jsonlite)
write_json(run[c("x","y")], "mbo_res.json")

Let’s assume we saved all of that above as an R-script under the name runMBO.R (actually it is available as a gist).

Then you can simply run it from the command line:

Rscript runMBO.R

As an extra the script in the gist also contains a simple handler for command line arguments. In this case you can define the number of optimization iterations and the maximal allowed time in seconds for the optimization. You can also define the seed to make runs reproducible:

Rscript runMBO.R iters=20 time=10 seed=3

If you want to build a more advanced command line interface you might want to have a look at docopt.

Clean up

To clean up all the files generated by this script you can run:

file.remove("result.txt")
file.remove("fun.sh")
file.remove("mbo_res.json")
output.files = list.files(pattern = "output_\\d+_[0-9_.-]+\\.txt")
file.remove(output.files)