diff --git a/R/AcqOptimizer.R b/R/AcqOptimizer.R index 1802ff85..511515d4 100644 --- a/R/AcqOptimizer.R +++ b/R/AcqOptimizer.R @@ -7,7 +7,7 @@ #' @section Parameters: #' \describe{ #' \item{`n_candidates`}{`integer(1)`\cr -#' Number of candidate points to propose. +#' Number of candidates to propose. #' Note that this does not affect how the acquisition function itself is calculated (e.g., setting `n_candidates > 1` will not #' result in computing the q- or multi-Expected Improvement) but rather the top `n_candidates` are selected from the #' [bbotk::ArchiveBatch] of the acquisition function [bbotk::OptimInstanceBatch]. @@ -27,10 +27,11 @@ #' This is sensible when using a population based acquisition function optimizer, e.g., local search or mutation. #' Default is `FALSE`. #' Note that in the case of the [bbotk::OptimInstance] being multi-criteria, selection of the best point(s) is performed via non-dominated-sorting. +#' Note that this warm-starting step cannot be influenced by callbacks. #' } #' \item{`warmstart_size`}{`integer(1) | "all"`\cr -#' Number of best points selected from the [bbotk::Archive] of the actual [bbotk::OptimInstance] that are to be used for warm starting. -#' Can either be an integer or "all" to use all available points. +#' Number of best points selected from the [bbotk::Archive] of the actual [bbotk::OptimInstance] that are to be used for warm-starting. +#' Can either be an integer or "all" to use all available points currently logged into the [bbotk::Archive] of the [bbotk::OptimInstance]. #' Only relevant if `warmstart = TRUE`. #' Default is `1`. #' } @@ -39,9 +40,19 @@ #' Should such candidate proposals be ignored and only candidates that were yet not evaluated be considered? #' Default is `TRUE`. #' } +#' \item{`refine_on_numeric_subspace`}{`logical(1)`\cr +#' After the acquisition function optimization has been performed, should the optimization result be refined on the purely numeric subspace of the acquisition function domain? +#' If `TRUE`, L-BFGS-B will be run on the subspace of the acquisition function domain containing only numeric parameters, keeping all other parameters constant at the value of the best solution found yet. +#' As a starting value, the current best solution will be used. +#' Only sensible for single-criteria acquisition functions with a `ydim` of `1`. +#' Uses [stats::optim]'s L-BFGS-B implementation. +#' Note that this is currently considered an experimental feature. +#' Default is `FALSE`. +#' Note that this refinement step cannot be influenced by callbacks. +#' } #' \item{`catch_errors`}{`logical(1)`\cr -#' Should errors during the acquisition function optimization be caught and propagated to the `loop_function` which can then handle -#' the failed acquisition function optimization appropriately by, e.g., proposing a randomly sampled point for evaluation? +#' Should errors during the acquisition function optimization be caught and propagated to the `loop_function` so that +#' the failed acquisition function optimization can be handled appropriately by, e.g., proposing a randomly sampled point for evaluation? #' Setting this to `FALSE` can be helpful for debugging. #' Default is `TRUE`. #' } @@ -119,9 +130,10 @@ AcqOptimizer = R6Class("AcqOptimizer", warmstart = p_lgl(default = FALSE), warmstart_size = p_int(lower = 1L, special_vals = list("all")), skip_already_evaluated = p_lgl(default = TRUE), + refine_on_numeric_subspace = p_lgl(default = FALSE), catch_errors = p_lgl(default = TRUE) ) - ps$values = list(n_candidates = 1, logging_level = "warn", warmstart = FALSE, skip_already_evaluated = TRUE, catch_errors = TRUE) + ps$values = list(n_candidates = 1, logging_level = "warn", warmstart = FALSE, skip_already_evaluated = TRUE, refine_on_numeric_subspace = FALSE, catch_errors = TRUE) ps$add_dep("warmstart_size", on = "warmstart", cond = CondEqual$new(TRUE)) private$.param_set = ps }, @@ -207,6 +219,29 @@ AcqOptimizer = R6Class("AcqOptimizer", get_best(instance, is_multi_acq_function = is_multi_acq_function, evaluated = self$acq_function$archive$data, n_select = self$param_set$values$n_candidates, not_already_evaluated = FALSE) } } + + # refine + if (self$param_set$values$refine_on_numeric_subspace && !is_multi_acq_function && any(self$acq_function$domain$class == "ParamDbl")) { + lg$info("Refining the acquisition function optimization result on the purely numeric subspace of the acquisition function domain via L-BFGS-B") + instance$terminator = trm("none") # allow for additionally running L-BFGS-B converging on its own + current_best = as.list(xdt[1L, instance$search_space$ids(), with = FALSE]) # not x_domain because acquisition functions are optimized on the search space and trafos have been nulled + ids = instance$search_space$ids() + assert_true(all(ids == names(current_best))) # order matters below + params_to_refine = intersect(instance$search_space$ids(class = "ParamDbl"), ids[!map_lgl(current_best, is.na)]) + params_constant = setdiff(ids, params_to_refine) + constants = current_best[params_constant] + lower = instance$search_space$lower[params_to_refine] + upper = instance$search_space$upper[params_to_refine] + # L-BFGS-B evaluations are logged as usual into the archive + lbfgsb = stats::optim(par = unlist(current_best[params_to_refine]), + fn = wrap_acq_function_lbfgsb, + acquisition_function_instance = instance, + constants = constants, + method = "L-BFGS-B", + lower = lower, + upper = upper) + xdt = get_best(instance, is_multi_acq_function = is_multi_acq_function, evaluated = self$acq_function$archive$data, n_select = self$param_set$values$n_candidates, not_already_evaluated = FALSE) + } #if (is_multi_acq_function) { # set(xdt, j = instance$objective$id, value = apply(xdt[, instance$objective$acq_function_ids, with = FALSE], MARGIN = 1L, FUN = c, simplify = FALSE)) # for (acq_function_id in instance$objective$acq_function_ids) { @@ -222,7 +257,6 @@ AcqOptimizer = R6Class("AcqOptimizer", #' #' Currently not used. reset = function() { - } ), diff --git a/R/helper.R b/R/helper.R index 78eecfc8..2e1c6be6 100644 --- a/R/helper.R +++ b/R/helper.R @@ -172,7 +172,13 @@ assert_xdt = function(xdt) { assert_learner_surrogate = function(x, .var.name = vname(x)) { # NOTE: this is buggy in checkmate; assert should always return x invisible not TRUE as is the case here assert(check_learner_surrogate(x), .var.name = .var.name) - x } +wrap_acq_function_lbfgsb = function(x, acquisition_function_instance, constants) { + xs = insert_named(as.list(x), constants) + xdt = as.data.table(xs) + res = acquisition_function_instance$eval_batch(xdt) + y = as.numeric(res[, acquisition_function_instance$objective$codomain$target_ids, with = FALSE]) + y * mult_max_to_min(acquisition_function_instance$objective$codomain)[[acquisition_function_instance$objective$codomain$target_ids]] +} diff --git a/man/AcqOptimizer.Rd b/man/AcqOptimizer.Rd index ed484834..467802f9 100644 --- a/man/AcqOptimizer.Rd +++ b/man/AcqOptimizer.Rd @@ -11,7 +11,7 @@ Wraps an \link[bbotk:OptimizerBatch]{bbotk::OptimizerBatch} and \link[bbotk:Term \describe{ \item{\code{n_candidates}}{\code{integer(1)}\cr -Number of candidate points to propose. +Number of candidates to propose. Note that this does not affect how the acquisition function itself is calculated (e.g., setting \code{n_candidates > 1} will not result in computing the q- or multi-Expected Improvement) but rather the top \code{n_candidates} are selected from the \link[bbotk:ArchiveBatch]{bbotk::ArchiveBatch} of the acquisition function \link[bbotk:OptimInstanceBatch]{bbotk::OptimInstanceBatch}. @@ -31,10 +31,11 @@ the actual \link[bbotk:OptimInstance]{bbotk::OptimInstance} (which is contained This is sensible when using a population based acquisition function optimizer, e.g., local search or mutation. Default is \code{FALSE}. Note that in the case of the \link[bbotk:OptimInstance]{bbotk::OptimInstance} being multi-criteria, selection of the best point(s) is performed via non-dominated-sorting. +Note that this warm-starting step cannot be influenced by callbacks. } \item{\code{warmstart_size}}{\code{integer(1) | "all"}\cr -Number of best points selected from the \link[bbotk:Archive]{bbotk::Archive} of the actual \link[bbotk:OptimInstance]{bbotk::OptimInstance} that are to be used for warm starting. -Can either be an integer or "all" to use all available points. +Number of best points selected from the \link[bbotk:Archive]{bbotk::Archive} of the actual \link[bbotk:OptimInstance]{bbotk::OptimInstance} that are to be used for warm-starting. +Can either be an integer or "all" to use all available points currently logged into the \link[bbotk:Archive]{bbotk::Archive} of the \link[bbotk:OptimInstance]{bbotk::OptimInstance}. Only relevant if \code{warmstart = TRUE}. Default is \code{1}. } @@ -43,9 +44,19 @@ It can happen that the candidate(s) resulting of the acquisition function optimi Should such candidate proposals be ignored and only candidates that were yet not evaluated be considered? Default is \code{TRUE}. } +\item{\code{refine_on_numeric_subspace}}{\code{logical(1)}\cr +After the acquisition function optimization has been performed, should the optimization result be refined on the purely numeric subspace of the acquisition function domain? +If \code{TRUE}, L-BFGS-B will be run on the subspace of the acquisition function domain containing only numeric parameters, keeping all other parameters constant at the value of the best solution found yet. +As a starting value, the current best solution will be used. +Only sensible for single-criteria acquisition functions with a \code{ydim} of \code{1}. +Uses \link[stats:optim]{stats::optim}'s L-BFGS-B implementation. +Note that this is currently considered an experimental feature. +Default is \code{FALSE}. +Note that this refinement step cannot be influenced by callbacks. +} \item{\code{catch_errors}}{\code{logical(1)}\cr -Should errors during the acquisition function optimization be caught and propagated to the \code{loop_function} which can then handle -the failed acquisition function optimization appropriately by, e.g., proposing a randomly sampled point for evaluation? +Should errors during the acquisition function optimization be caught and propagated to the \code{loop_function} so that +the failed acquisition function optimization can be handled appropriately by, e.g., proposing a randomly sampled point for evaluation? Setting this to \code{FALSE} can be helpful for debugging. Default is \code{TRUE}. } diff --git a/tests/testthat/test_AcqOptimizer.R b/tests/testthat/test_AcqOptimizer.R index 78e82858..e6ca0d32 100644 --- a/tests/testthat/test_AcqOptimizer.R +++ b/tests/testthat/test_AcqOptimizer.R @@ -83,12 +83,13 @@ test_that("AcqOptimizer API works", { test_that("AcqOptimizer param_set", { acqopt = AcqOptimizer$new(opt("random_search", batch_size = 1L), trm("evals", n_evals = 1L)) expect_r6(acqopt$param_set, "ParamSet") - expect_setequal(acqopt$param_set$ids(), c("n_candidates", "logging_level", "warmstart", "warmstart_size", "skip_already_evaluated", "catch_errors")) + expect_setequal(acqopt$param_set$ids(), c("n_candidates", "logging_level", "warmstart", "warmstart_size", "skip_already_evaluated", "refine_on_numeric_subspace", "catch_errors")) expect_equal(acqopt$param_set$class[["n_candidates"]], "ParamInt") expect_equal(acqopt$param_set$class[["logging_level"]], "ParamFct") expect_equal(acqopt$param_set$class[["warmstart"]], "ParamLgl") expect_equal(acqopt$param_set$class[["warmstart_size"]], "ParamInt") expect_equal(acqopt$param_set$class[["skip_already_evaluated"]], "ParamLgl") + expect_equal(acqopt$param_set$class[["refine_on_numeric_subspace"]], "ParamLgl") expect_equal(acqopt$param_set$class[["catch_errors"]], "ParamLgl") expect_error({acqopt$param_set = list()}, regexp = "param_set is read-only.") }) @@ -142,3 +143,34 @@ test_that("AcqOptimizer callbacks", { expect_number(attr(instance, "acq_opt_runtime")) }) +test_that("AcqOptimizer refinement", { + instance = OptimInstanceBatchSingleCrit$new(OBJ_1D_MIXED_DEPS, terminator = trm("evals", n_evals = 5L)) + design = MAKE_DESIGN(instance) + instance$eval_batch(design) + acqfun = AcqFunctionEI$new(SurrogateLearner$new(REGR_FEATURELESS, archive = instance$archive)) + acqopt = AcqOptimizer$new(opt("random_search", batch_size = 10L), trm("evals", n_evals = 10L), acq_function = acqfun) + acqopt$param_set$values$refine_on_numeric_subspace = TRUE + acqfun$surrogate$update() + acqfun$update() + + # logging_level + console_appender = if (packageVersion("lgr") >= "0.4.0") lg$inherited_appenders$console else lg$inherited_appenders$appenders.console + f = tempfile("bbotklog_", fileext = "log") + th1 = lg$threshold + th2 = console_appender$threshold + + lg$set_threshold("debug") + lg$add_appender(lgr::AppenderFile$new(f, threshold = "debug"), name = "testappender") + console_appender$set_threshold("warn") + + on.exit({ + lg$remove_appender("testappender") + lg$set_threshold(th1) + console_appender$set_threshold(th2) + }) + acqopt$param_set$values$logging_level = "info" + res = acqopt$optimize() + lines = readLines(f) + expect_true(any(grepl("Refining the acquisition function optimization result", x = lines))) +}) +