Nothing
# tune_GridSearch.R
# ::rtemis::
# 2025- EDG rtemis.org
# %% MAXIMIZED_METRICS ----
# Tuning metrics for which a higher value is better. `tune_GridSearch()`
# maximizes these and minimizes every other metric when the tuner config leaves
# `maximize` unset.
MAXIMIZED_METRICS <- c(
"accuracy",
"balanced_accuracy",
"sensitivity",
"specificity",
"ppv",
"npv",
"f1",
"auc",
"rsq",
"Concordance"
)
# %% tune_GridSearch ----
#' \pkg{rtemis} internal: Grid Search for Hyperparameter Tuning of \pkg{rtemis} Learners
#'
#' Train models using a combination of parameter values for model selection
#'
#' @details
#' Note that weights, if defined (and not NULL), should be passed directly to `grid_search`
#' as they need to be resampled along `x` and `y`, and should not be passed along with
#' `grid_params`. `ifw` and `ifw_type` should be passed as part of `grid_params`
#' and will be passed on to the learner.
#' Includes a algorithm-specific extraction of config that are determined internally,
#' such as `lambda` for `GLMNET`, `nrounds` for `LightGBM`, etc.
#'
#' The current implementation allows running sequentially either directly using lapply + cli
#' progress, or using a sequential future plan. The former may give better debugging information.
#' The latter may be helpful to test that the future parallelization setup works correctly.
#'
#' @param x tabular data: Training set.
#' @param hyperparameters `Hyperparameters` object created with a learner's `setup_*` function.
#' @param tuner_config `TunerConfig` object created with [setup_GridSearch].
#' @param preprocessor_config Optional `SupervisedPreprocessorConfig` object: Applied within each tuning
#' fold so hyperparameters are evaluated on preprocessed data.
#' @param decomposition_config Optional `DecompositionConfig` object: Setup using a decomposition
#' `setup_`*` function.
#' @param weights Vector: Class weights.
#' @param save_mods Logical: Save models in tuning results.
#' @param n_workers Integer: Number of workers to use for parallel processing.
#' @param backend Character: Type of parallelization to use. Options are "none", "future",
#' or "mirai".
#' @param future_plan Character: Future plan to use if `backend` is "future".
#' @param verbosity Integer: Verbosity level.
#'
#' @return `GridSearch` object.
#'
#' @author EDG
#'
#' @keywords internal
#' @noRd
tune_GridSearch <- function(
x,
hyperparameters,
tuner_config,
preprocessor_config = NULL,
decomposition_config = NULL,
weights = NULL,
save_mods = FALSE,
n_workers = 1L,
backend = NULL,
future_plan = NULL,
seed = NULL,
shared_memory = "none",
verbosity = 1L,
on_error = "continue"
) {
check_hyperparameters(hyperparameters)
check_is_S7(tuner_config, TunerConfig)
stopifnot(needs_tuning(hyperparameters))
# Intro ----
start_time <- intro(
newline_pre = TRUE,
caller = "tune_GridSearch",
verbosity = verbosity - 1L
)
# Arguments ----
algorithm <- hyperparameters@algorithm
# Backend validation and worker-count handling live in `progress_plapply()`, which
# owns dispatch for both this path and outer resampling.
if (is.null(backend)) {
backend <- "none"
}
# Make Grid ----
# A set searches over its members; a single object is its own only member, and
# `tuning_members()` returns NULL for it so a fit that came from no set leaves
# `variant` unset.
members <- tuning_members(hyperparameters)
# Which hyperparameters were left for the search to determine. The GLMNET and
# LightGBM collection steps below read it to tell "the user fixed this" from
# "the backend chose it". For a set it is the union over members: those steps
# rewrite the whole grid, so the question they ask -- was this left open? --
# is only answerable of the search as a whole.
grid_params <- if (is.null(members)) {
get_hyperparams_need_tuning(hyperparameters)
} else {
Reduce(
function(a, b) utils::modifyList(a, b),
lapply(members, get_hyperparams_need_tuning)
)
}
search_type <- tuner_config[["search_type"]]
grid_summary <- tuning_grid_summary(hyperparameters, tuner_config)
n_resamples <- grid_summary[["n_resamples"]]
param_grid <- tuning_grid(hyperparameters)
n_combinations_gated <- NROW(param_grid)
if (search_type == "randomized") {
# Sampled here, before anything is derived from the grid, so that each
# selected combination is still run on every resample.
param_grid <- param_grid[
sort(sample.int(
n_combinations_gated,
grid_summary[["n_param_combinations"]]
)),
,
drop = FALSE
]
rownames(param_grid) <- NULL
}
n_param_combinations <- NROW(param_grid)
# Resample varies fastest within a combination, which is the order the
# per-combination aggregation below indexes with `each = n_resamples`.
res_param_grid <- cbind(
resample_id = rep(seq_len(n_resamples), times = n_param_combinations),
param_grid[
rep(seq_len(n_param_combinations), each = n_resamples),
,
drop = FALSE
]
)
rownames(res_param_grid) <- NULL
param_grid <- cbind(
param_combo_id = seq_len(n_param_combinations),
param_grid
)
n_res_x_comb <- NROW(res_param_grid)
# Intro pt. 2 ----
tuning_intro(grid_summary, algorithm, tuner_config, verbosity)
# Resamples ----
res <- resample(
x = x,
config = tuner_config[["resampler_config"]],
verbosity = verbosity
)
# Grid cells ----
x_shared <- share_payload(
x,
mode = shared_memory,
backend = backend,
future_plan = future_plan,
n_workers = n_workers
)
weights_shared <- share_payload(
weights,
mode = shared_memory,
backend = backend,
future_plan = future_plan,
n_workers = n_workers
)
run_grid_cell <- make_grid_cell_runner(
x = x_shared,
res_param_grid = res_param_grid,
hyperparameters = hyperparameters,
members = members,
preprocessor_config = preprocessor_config,
decomposition_config = decomposition_config,
weights = weights_shared,
algorithm = algorithm,
save_mods = save_mods,
# Failure policy (specs/observability.md section 7): a grid-cell failure is fatal
# only under "stop". "stop_outer" tolerates cells and is fatal for outer folds alone.
fatal = identical(on_error, "stop"),
verbosity = verbosity
)
# Train Grid ----
# One dispatcher for every backend, shared with outer resampling. Cells receive one RNG
# substream each, keyed by cell index, so a grid search gives the same answer under
# "none", "future" and "mirai" at any worker count.
# Each cell receives its own resample and no other.
cells <- lapply(seq_len(n_res_x_comb), function(index) {
list(
index = index,
resample = res@resamples[[res_param_grid[index, "resample_id"]]]
)
})
grid_run <- progress_plapply(
cells,
run_grid_cell,
backend = backend,
n_workers = n_workers,
future_plan = future_plan,
seeds = rng_substreams(seed, n_res_x_comb),
label = "Tuning",
kind = "tune",
stop_on_error = identical(on_error, "stop"),
verbosity = verbosity
)
grid_run <- Map(
normalize_cell_result,
grid_run,
seq_len(n_res_x_comb),
res_param_grid[, "resample_id"]
)
# Metric ----
type <- supervised_type(x)
metric <- tuner_config@config[["metric"]]
maximize <- tuner_config@config[["maximize"]]
if (is.null(metric)) {
if (type == "Classification") {
metric <- "balanced_accuracy"
} else if (type == "Regression") {
metric <- "mse"
} else {
metric <- "Concordance"
}
tuner_config@config[["metric"]] <- metric
}
if (is.null(maximize)) {
maximize <- metric %in% MAXIMIZED_METRICS
tuner_config@config[["maximize"]] <- maximize
}
select_fn <- if (maximize) which.max else which.min
verb <- if (maximize) "maximize" else "minimize"
# Aggregate ----
# Average test errors.
# Host-synthesize one grid_cell node per cell under the active "tune" node, with status
# and error filled from the returned results. See specs/observability.md section 4.
for (r in grid_run) {
failed_cell <- isTRUE(r[["failed"]])
session_add_node(
"grid_cell",
label = paste0(
"#",
r[["id"]],
" (resample ",
r[["resample_id"]],
")"
),
status = if (failed_cell) "error" else "ok",
meta = list(resample_id = r[["resample_id"]], error = r[["error"]]),
t_start = r[["t_start"]],
t_end = r[["t_end"]]
)
}
node_meta(list(n_combos = n_param_combinations, n_inner = n_resamples))
# Tolerant metric extraction: failed cells (only possible under on_error = "continue")
# become NA rows so the combo aggregation excludes them. With no failures this is
# identical to the previous extraction.
ok_idx <- which(
!vapply(
grid_run,
function(r) isTRUE(r[["failed"]]),
logical(1L)
)
)
if (length(ok_idx) == 0L) {
# Naming the first failure: "all cells failed" is a count, and the cause is
# the same for every cell often enough that the first one is the answer.
first_error <- grid_run[[1L]][["error"]]
rtemis.core::abort(
"All ",
n_res_x_comb,
" tuning grid cells failed; cannot select hyperparameters.",
if (is.null(first_error)) "" else paste0(" First failure: ", first_error),
class = c("rtemis_error", "rtemis_runtime_error")
)
}
extract_row <- function(r, slot) {
m <- r[[slot]]
if (type == "Classification") {
unlist(m@metrics[["overall"]])
} else {
unlist(m@metrics)
}
}
tmpl_tr <- names(extract_row(grid_run[[ok_idx[1L]]], "metrics_training"))
tmpl_va <- names(extract_row(grid_run[[ok_idx[1L]]], "metrics_validation"))
row_or_na <- function(r, slot, tmpl) {
if (isTRUE(r[["failed"]]) || is.null(r[[slot]])) {
stats::setNames(rep(NA_real_, length(tmpl)), tmpl)
} else {
extract_row(r, slot)
}
}
metrics_training_all <- as.data.table(do.call(
rbind,
lapply(grid_run, row_or_na, "metrics_training", tmpl_tr)
))
metrics_validation_all <- as.data.table(do.call(
rbind,
lapply(grid_run, row_or_na, "metrics_validation", tmpl_va)
))
# appease R CMD check
param_combo_id <- NULL
metrics_validation_all[,
param_combo_id := rep(
seq_len(n_param_combinations),
each = n_resamples
)
]
metrics_training_all[,
param_combo_id := rep(
seq_len(n_param_combinations),
each = n_resamples
)
]
metrics_training_by_combo_id <- metrics_training_all[,
lapply(
.SD,
get(tuner_config[["metrics_aggregate_fn"]])
),
by = param_combo_id
]
metrics_validation_by_combo_id <- metrics_validation_all[,
lapply(
.SD,
get(tuner_config[["metrics_aggregate_fn"]])
),
by = param_combo_id
]
tune_results <- list(
param_grid = param_grid,
metrics_training = metrics_training_by_combo_id,
metrics_validation = metrics_validation_by_combo_id
)
# Algorithm-specific collection ----
# N of iterations is the one hyperparameter that may be determined
# automatically, we therefore need to extract it and average it
## GLMNET ----
if (algorithm == "GLMNET") {
if (is.null(grid_params[["lambda"]])) {
# if lambda was NULL, cv.glmnet was run and optimal lambda was estimated
# For each i in grid_run, get grid_run[[i]]$hyperparameters[[grid_run[[i]]$hyperparameters$which_lambda_cv]]
if (verbosity > 1L) {
info("Extracting best lambda from GLMNET models...")
}
lambda_cv2 <- data.table(
lambda = sapply(
grid_run,
function(x) {
x[["hyperparameters"]][[x[["hyperparameters"]][[
"which_lambda_cv"
]]]]
}
)
)
lambda_cv2[,
param_combo_id := rep(
seq_len(n_param_combinations),
each = n_resamples
)
]
lambda_by_param_combo_id <- lambda_cv2[,
lapply(.SD, get(tuner_config[["metrics_aggregate_fn"]])),
by = param_combo_id
]
# Replace NULL lambda in tune_results$param_grid with average value of CV-squared lambda
stopifnot(tune_results[["param_grid"]][["lambda"]] == "null")
param_grid[["lambda"]] <- tune_results[["param_grid"]][[
"lambda"
]] <- lambda_by_param_combo_id[["lambda"]]
}
} # /GLMNET
## LightGBM ----
if (algorithm == "LightGBM") {
if (is.null(grid_params[["nrounds"]])) {
if (verbosity > 1L) {
info("Extracting best N of iterations from LightGBM models...")
}
nrounds_cv <- data.table(
nrounds = sapply(grid_run, \(x) x[["hyperparameters"]][["best_iter"]])
)
nrounds_cv[["param_combo_id"]] <- rep(
seq_len(n_param_combinations),
each = n_resamples
)
nrounds_by_param_combo_id <- nrounds_cv[,
lapply(.SD, get(tuner_config[["metrics_aggregate_fn"]])),
by = param_combo_id
]
# Replace NULL nrounds in tune_results$param_grid with average value of Res nrounds
stopifnot(tune_results[["param_grid"]][["nrounds"]] == "null")
param_grid[["nrounds"]] <- tune_results[["param_grid"]][["nrounds"]] <-
as.integer(round(nrounds_by_param_combo_id[["nrounds"]]))
}
} # /LightGBM
## GBM, H2OGBM ----
# if (algorithm %in% c("H2OGBM", "GBM", "GBM3")) {
# est.n.trees.all <- data.frame(n.trees = plyr::laply(
# grid_run,
# function(x) x$est.n.trees
# ))
# est.n.trees.all$param_combo_id <- rep(seq_len(n_param_combinations), each = n_resamples)
# est.n.trees.by.param_combo_id <- aggregate(
# n.trees ~ param_combo_id, est.n.trees.all,
# metrics_aggregate_fn
# )
# tune_results <- cbind(
# n.trees = round(est.n.trees.by.param_combo_id$n.trees),
# tune_results
# )
# n_params <- n_params + 1
# } # /GBM, H2OGBM
## XGBoost ----
# if (algorithm == "XGBoost") {
# if (verbosity > 1L) {
# msg(highlight("Extracting best N of iterations from XGBoost models..."))
# }
# est.nrounds.all <- data.frame(nrounds = plyr::laply(
# grid_run,
# \(m) m$best_iteration
# ))
# est.nrounds.all$param_combo_id <- rep(seq_len(n_param_combinations),
# each = n_resamples
# )
# est.nrounds.by.param_combo_id <- aggregate(
# nrounds ~ param_combo_id, est.nrounds.all,
# metrics_aggregate_fn
# )
# tune_results <- cbind(
# nrounds = round(est.nrounds.by.param_combo_id$nrounds),
# tune_results
# )
# n_params <- n_params + 1
# } /XGBoost
## LINAD ----
# Each cell selected its own leaf count on its resample's validation fold;
# the final fit has no validation set and would otherwise keep every leaf it
# grew. Carrying the average back means the model trained on all the data is
# the size tuning measured, as `nrounds` is for LightGBM above.
if (algorithm == "LINAD") {
if (verbosity > 1L) {
info("Extracting best N of leaves from LINAD models...")
}
leaves_cv <- data.table(
best_n_leaves = vapply(
grid_run,
function(cell) {
selected <- cell[["hyperparameters"]][["best_n_leaves"]]
if (is.null(selected)) NA_real_ else as.numeric(selected)
},
numeric(1L)
)
)
leaves_cv[["param_combo_id"]] <- rep(
seq_len(n_param_combinations),
each = n_resamples
)
leaves_by_param_combo_id <- leaves_cv[,
lapply(.SD, function(values) {
get(tuner_config[["metrics_aggregate_fn"]])(values, na.rm = TRUE)
}),
by = param_combo_id
]
selected_leaves <- as.integer(round(
leaves_by_param_combo_id[["best_n_leaves"]]
))
param_grid[["best_n_leaves"]] <- selected_leaves
tune_results[["param_grid"]][["best_n_leaves"]] <- selected_leaves
} # /LINAD
## LIHADBoost ----
# if (algorithm == "LIHADBoost") {
# if (verbosity > 1L) {
# msg(highlight("Extracting best N steps from LIHADBoost models..."))
# }
# est.n.steps.all <- data.frame(n.steps = plyr::laply(
# grid_run,
# \(x) x$sel.n.steps
# ))
# est.n.steps.all$param_combo_id <- rep(seq_len(n_param_combinations),
# each = n_resamples
# )
# est.n.steps.by.param_combo_id <- aggregate(
# n.steps ~ param_combo_id, est.n.steps.all,
# metrics_aggregate_fn
# )
# tune_results <- cbind(
# n.steps = round(est.n.steps.by.param_combo_id$n.steps),
# tune_results
# )
# n_params <- n_params + 1
# } # /LIHADBoost
# Consider explicitly sorting hyperparam values in increasing order,
# so that in case of tie, lowest value is chosen -
# if that makes sense, e.g. n.leaves, etc.
best_row <- select_fn(tune_results[["metrics_validation"]][[metric]])
# A combination whose aggregated metric is NA or NaN cannot be ranked, and when that is
# every combination `which.max`/`which.min` select nothing.
#
# An empty selection does not stay empty: `as.integer()` turns the resulting zero-row
# slice into `NA_integer_`, and indexing the grid with that yields a row of NAs. Every
# tuned hyperparameter is then set to NA -- not left at the value it came in with. What
# happens next is algorithm-dependent and none of it is what the user asked for: an
# algorithm that tolerates NA falls back to its own default (LightGBM reverts to
# `max_nrounds` with early stopping), and one that does not fails its validator with a
# message naming NULL, which points nowhere near the real problem.
#
# A cell failure is one way to get here; a degenerate inner resample is another and does
# not require any failure at all. `balanced_accuracy` averages per-class recall, so a
# validation fold missing a class scores NaN, and `mean()` carries that NaN to the
# combination -- reachable with small multiclass data long before anything errors.
#
# The condition is a warning: the run returns a model, and the warning reports that the
# tuned hyperparameters were set to NA.
if (length(best_row) == 0L) {
n_failed <- sum(vapply(
grid_run,
function(r) isTRUE(r[["failed"]]),
logical(1L)
))
rtemis.core::warn(
"No hyperparameter combination could be scored on ",
metric,
" across every inner resample (",
n_failed,
" of ",
n_res_x_comb,
" grid cells failed), so tuning cannot select a winner: every tuned ",
"hyperparameter is left as NA and the algorithm falls back to its own default. ",
"Check for degenerate resamples -- a metric undefined on any one of them, such ",
"as balanced_accuracy where a fold is missing a class, makes its whole ",
"combination unrankable."
)
}
best_param_combo_id <- as.integer(
tune_results[["metrics_validation"]][best_row, 1]
)
best_param_combo <- grid_row_values(
param_grid,
best_param_combo_id,
grid_hyperparameter_columns(param_grid)
)
best_variant <- grid_variant(param_grid, best_param_combo_id)
if (verbosity > 0L) {
msg(
paste0(
"Best config to ",
paste(verb, metric),
if (is.null(best_variant)) "" else paste0(" (", best_variant, ")"),
":"
)
)
# For a set, only what the **winning member** actually searched. The union
# grid holds every member's tuned hyperparameters, so reporting it whole
# presents one member's candidates as another's: `lambda` searched over
# {0.01, 0.1} inside one variant reads as {0.01, 0.1, NULL} once three
# variants that do not set it are folded in, and the NULL is not a candidate
# anyone offered.
shown_columns <- grid_hyperparameter_columns(param_grid)
shown_grid <- param_grid
if (!is.null(best_variant)) {
shown_columns <- intersect(
shown_columns,
names(get_hyperparams_need_tuning(members[[best_variant]]))
)
shown_grid <- param_grid[
param_grid[[VARIANT_COLUMN]] == best_variant,
,
drop = FALSE
]
}
if (length(shown_columns) == 0L) {
# A member of a set can win as a whole configuration, with no value
# search; its name, in the line above, is then the whole combination.
msg(paste0(" ", gray("no hyperparameters searched within this variant")))
} else {
print_tune_finding(
shown_grid[, shown_columns, drop = FALSE],
best_param_combo[shown_columns]
)
}
}
# Outro ----
# Since this is always called from within `train()`, we don't want to print "Completed..."
outro(start_time, verbosity = verbosity - 1L)
if (verbosity > 0L) {
msg(
fmt("</>", col = col_tuner, bold = TRUE),
"Tuning done."
)
}
# => add optional mods field to GridSearch
# if (save_mods) mods <- grid_run
GridSearch(
# The winning member, so this field always holds a concrete
# `Hyperparameters`. `best_variant` says which member it was.
hyperparameters = if (is.null(best_variant)) {
hyperparameters
} else {
members[[best_variant]]
},
tuner_config = tuner_config,
tuning_results = list(
param_grid = param_grid,
training = metrics_training_by_combo_id,
validation = metrics_validation_by_combo_id,
# Combinations eligible before a randomized search sampled from them.
n_combinations = n_combinations_gated,
# The resampler as it ran, with settings resolved from the data, such
# as the number of stratification intervals.
resampler_config = res@config
),
best_hyperparameters = best_param_combo,
best_variant = best_variant,
searched_set = if (is.null(members)) NULL else hyperparameters
)
} # /rtemis::tune_GridSearch
# %% make_grid_cell_runner ----
#' Build the per-grid-cell body
#'
#' Returns the closure `progress_plapply()` dispatches once per (hyperparameter
#' combination x inner resample) cell.
#'
#' @details
#' Built by a factory, and taking only the cell, for two reasons. Serializing a closure
#' walks its enclosing environments, so a body defined in `tune_GridSearch()`'s frame
#' would ship that whole frame to every worker. And a body taking `...` could not be fed
#' through `progress_plapply()`, whose own `verbosity`, `label` and `kind` parameters
#' would swallow same-named arguments meant for the cell.
#'
#' Under a tolerant failure policy a failure is captured and returned as a marker the
#' aggregation excludes; under a fatal one it propagates, and the dispatcher stops the
#' run.
#'
#' @param x Tabular data: Training set; each cell slices its own rows.
#' @param res_param_grid data.frame: One row per cell, `resample_id` plus the
#' hyperparameter values.
#' @param hyperparameters `Hyperparameters` or `HyperparametersSet` object.
#' @param members Optional named list of `Hyperparameters`: the set's members,
#' or NULL when the search is over a single object. A row is applied to the
#' member that produced it, which only the member itself knows -- two members
#' can hold different values for a hyperparameter neither of them tunes.
#' @param preprocessor_config Optional `SupervisedPreprocessorConfig` object.
#' @param decomposition_config Optional `DecompositionConfig` object.
#' @param weights Optional vector of case weights.
#' @param algorithm Character: Algorithm name, for the algorithm-specific collection.
#' @param save_mods Logical: If TRUE, the fitted model rides back with the result.
#' @param fatal Logical: If TRUE, a cell failure is raised; otherwise returned.
#' @param verbosity Integer: Verbosity level.
#'
#' @return Function of `(cell)`, where `cell` is a list with the row number `index` of
#' `res_param_grid` and its training indices `resample`, returning the cell result list.
#'
#' @author EDG
#' @keywords internal
#' @noRd
make_grid_cell_runner <- function(
x,
res_param_grid,
hyperparameters,
members,
preprocessor_config,
decomposition_config,
weights,
algorithm,
save_mods,
fatal,
verbosity
) {
force(x)
force(res_param_grid)
force(hyperparameters)
force(members)
force(preprocessor_config)
force(decomposition_config)
force(weights)
force(algorithm)
force(save_mods)
force(fatal)
force(verbosity)
function(cell) {
index <- cell[["index"]]
res1 <- cell[["resample"]]
if (verbosity > 1L) {
info(
"Running grid line #",
fmt(index, bold = TRUE),
"/",
NROW(res_param_grid),
"...",
caller = "tune_GridSearch"
)
}
dat_train1 <- x[res1, ]
weights1 <- weights[res1]
dat_valid1 <- x[-res1, ]
# A row is applied to the member that produced it, not to "the"
# hyperparameters: two members can hold different values for a
# hyperparameter neither of them tunes, and only the member knows which.
variant1 <- grid_variant(res_param_grid, index)
base1 <- if (is.null(variant1)) hyperparameters else members[[variant1]]
hyperparams1 <- update(
base1,
grid_row_values(
res_param_grid,
index,
grid_hyperparameter_columns(res_param_grid)
),
tuned = TUNED_STATUS_TUNING # Hyperparameters are being tuned
)
if (!is.null(variant1)) {
hyperparams1@variant <- variant1
}
# Detach any active observability session so the inner train() is opaque to the host
# graph; the host host-synthesizes one grid_cell node per cell (uniform across
# backends). In daemons the session is already NULL, so this is a harmless no-op.
saved_session <- live[["session"]]
live[["session"]] <- NULL
on.exit(live[["session"]] <- saved_session, add = TRUE)
run_cell <- function() {
do_call(
"train",
args = list(
x = dat_train1,
dat_validation = dat_valid1,
preprocessor_config = preprocessor_config,
decomposition_config = decomposition_config,
hyperparameters = hyperparams1,
weights = weights1,
verbosity = verbosity - 1L
),
verbosity = verbosity
)
}
# Failure policy (specs/observability.md section 7): under a tolerant policy a
# grid-cell failure is captured and returned as a marker (non-fatal); otherwise it
# propagates. Timestamps bracket the actual cell run so the host can record real
# durations on the synthesized grid_cell nodes.
cell_t_start <- Sys.time()
mod1 <- if (fatal) {
run_cell()
} else {
tryCatch(run_cell(), error = function(e) e)
}
cell_t_end <- Sys.time()
if (inherits(mod1, "condition")) {
return(list(
id = index,
resample_id = res_param_grid[index, "resample_id"],
metrics_training = NULL,
metrics_validation = NULL,
type = NA_character_,
hyperparameters = hyperparams1,
failed = TRUE,
error = conditionMessage(mod1),
t_start = cell_t_start,
t_end = cell_t_end
))
}
out1 <- list(
id = index,
resample_id = res_param_grid[index, "resample_id"],
metrics_training = mod1@metrics_training,
metrics_validation = mod1@metrics_validation,
type = mod1@type,
hyperparameters = hyperparams1,
failed = FALSE,
t_start = cell_t_start,
t_end = cell_t_end
)
# Algorithm-specific params ----
# => add to hyperparameters
if (algorithm == "GLMNET") {
out1[["hyperparameters"]]@hyperparameters[["lambda.min"]] <- mod1@model[[
"lambda.min"
]]
out1[["hyperparameters"]]@hyperparameters[["lambda.1se"]] <- mod1@model[[
"lambda.1se"
]]
}
if (algorithm == "LightGBM") {
# Check best_iter is meaningful, otherwise issue message and set to 100L
best_iter <- mod1@model[["best_iter"]]
if (is.null(best_iter) || best_iter == -1 || best_iter == 0) {
info(
paste(
"best_iter returned from lightgbm:",
best_iter,
"- setting to 100L"
)
)
best_iter <- 100L
}
out1[["hyperparameters"]]@hyperparameters[["best_iter"]] <- best_iter
}
if (algorithm == "LINAD") {
# The size this cell's validation fold selected. Without capturing it the
# final fit -- which gets no validation set -- keeps every leaf it grew.
out1[["hyperparameters"]]@hyperparameters[["best_n_leaves"]] <-
as.integer(mod1@model@n_leaves)
}
if (save_mods) {
out1[["mod1"]] <- mod1
}
out1
}
} # /rtemis::make_grid_cell_runner
# %% normalize_cell_result ----
#' Normalize one grid cell result
#'
#' The cell body returns a structured result, failure included. A condition arrives
#' instead only when the dispatch itself failed -- a payload that could not be serialized,
#' or a worker that died -- which is outside the body's own `tryCatch`. Both shapes are
#' flattened here so the node synthesis and metric aggregation downstream have one thing
#' to read.
#'
#' @param res List or condition: One element of the `progress_plapply()` result.
#' @param index Integer: Cell index, used when the result carries no id of its own.
#' @param resample_id Integer: Inner resample the cell belonged to.
#'
#' @return List in the cell result shape.
#'
#' @author EDG
#' @keywords internal
#' @noRd
normalize_cell_result <- function(res, index, resample_id) {
if (!inherits(res, "condition")) {
return(res)
}
now <- Sys.time()
list(
id = index,
resample_id = resample_id,
metrics_training = NULL,
metrics_validation = NULL,
type = NA_character_,
hyperparameters = NULL,
failed = TRUE,
error = conditionMessage(res),
t_start = now,
t_end = now
)
} # /rtemis::normalize_cell_result
# %% print_tune_finding ----
#' Print tuning results
#'
#' Prints set of search values and best value in the form {1, 3, 5} => 3
#' for each hyperparameter that was tuned.
#'
#' Reads the searched values off the grid, so that a hyperparameter a gate added
#' to it -- one held at a single value, but dropped from the combinations that
#' cannot use it -- is reported too. Both grid markers for an unset value print
#' as the NULL they become: NA from a closed gate, and `expand_grid()`'s "null"
#' sentinel from a hyperparameter left to be determined by tuning.
#'
#' @param param_grid data.frame: The tuning grid, without its `param_combo_id`
#' column.
#' @param best_param_combo Named list: The winning combination.
#' @param pad Integer: Label column width.
#'
#' @author EDG
#' @keywords internal
#' @noRd
print_tune_finding <- function(param_grid, best_param_combo, pad = 22L) {
show <- function(x) {
# A container tunable's candidate is a whole vector -- one architecture, one
# per grid cell -- so it is parenthesized to stay legible beside the scalar
# hyperparameters printed on the same line.
if (length(x) == 0L) {
return("NULL")
}
if (length(x) > 1L) {
return(paste0("(", paste(x, collapse = ", "), ")"))
}
if (is.na(x) || identical(x, "null")) "NULL" else as.character(x)
}
# Make list of search values and best value
tfl <- lapply(names(param_grid), function(nm) {
paste0(
"{",
paste(
vapply(unique(param_grid[[nm]]), show, character(1L)),
collapse = ", "
),
"}",
" => ",
bold(show(best_param_combo[[nm]]))
)
})
names(tfl) <- names(param_grid)
# Capture output to sync with msg stream (stderr)
out <- utils::capture.output(printls(tfl, print_class = FALSE, pad = pad))
message(paste(out, collapse = "\n"))
} # /rtemis::print_tune_finding
# %% tuning_grid_summary ----
#' What a grid search will fit, before it fits anything
#'
#' The counts a run announces, derived without building the grid a second time
#' or drawing the randomized sample. `train()` reads this to announce a search
#' it is about to run inside outer resampling folds, where each fold's own
#' announcement is silenced; `tune_GridSearch()` reads the same values, so the
#' two can never describe different searches.
#'
#' @param hyperparameters Hyperparameters or HyperparametersSet object.
#' @param tuner_config TunerConfig object.
#'
#' @return List with `n_members`, `n_combinations_expanded`,
#' `n_combinations_gated`, `n_param_combinations`, `gated_params`,
#' `n_resamples` and `n_models`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
tuning_grid_summary <- function(hyperparameters, tuner_config) {
members <- tuning_members(hyperparameters)
# A set's expansion is the sum of its members'.
n_combinations_expanded <- if (is.null(members)) {
prod(pmax(lengths(get_hyperparams_need_tuning(hyperparameters)), 1L))
} else {
sum(vapply(
members,
function(member) {
prod(pmax(lengths(get_hyperparams_need_tuning(member)), 1L))
},
numeric(1L)
))
}
grid <- tuning_grid(hyperparameters)
n_combinations_gated <- NROW(grid)
# Rounding can reach 0 on a small grid.
n_param_combinations <- if (
identical(tuner_config[["search_type"]], "randomized")
) {
max(1L, round(tuner_config[["randomize_p"]] * n_combinations_gated))
} else {
n_combinations_gated
}
n_resamples <- tuner_config[["resampler_config"]][["n_resamples"]]
list(
n_members = if (is.null(members)) NULL else length(members),
n_combinations_expanded = n_combinations_expanded,
n_combinations_gated = n_combinations_gated,
n_param_combinations = n_param_combinations,
# NA marks a combination a gate excluded the hyperparameter from, so the
# columns holding one are exactly those the grid made conditional.
gated_params = names(grid)[vapply(grid, anyNA, logical(1L))],
n_resamples = n_resamples,
n_models = n_param_combinations * n_resamples
)
} # /rtemis::tuning_grid_summary
# %% tuning_intro ----
#' Announce a grid search
#'
#' @param summary List: `tuning_grid_summary()` output.
#' @param algorithm Character: Algorithm name.
#' @param tuner_config TunerConfig object.
#' @param verbosity Integer: Verbosity level.
#'
#' @return NULL, invisibly.
#'
#' @author EDG
#' @keywords internal
#' @noRd
tuning_intro <- function(summary, algorithm, tuner_config, verbosity = 1L) {
if (verbosity < 1L) {
return(invisible(NULL))
}
n_members <- summary[["n_members"]]
n_combinations <- summary[["n_param_combinations"]]
gated_params <- summary[["gated_params"]]
msg0(
fmt("<> ", col = col_tuner, bold = TRUE),
"Tuning ",
algorithm,
" by ",
tuner_config[["search_type"]],
" grid search with ",
desc(tuner_config@config[["resampler_config"]]),
"..."
)
msg0(
# A set's combinations are spread across its members, and the count alone
# does not say so: two members that differ only in a hyperparameter neither
# tunes produce grid rows identical in every column but `.variant`, so six
# combinations can look like fewer.
if (is.null(n_members)) {
""
} else {
paste0(
fmt(n_members, col = col_tuner, bold = TRUE),
ngettext(n_members, " variant, ", " variants, ")
)
},
fmt(n_combinations, col = col_tuner, bold = TRUE),
ngettext(
n_combinations,
" parameter combination x ",
" parameter combinations x "
),
fmt(summary[["n_resamples"]], col = col_tuner, bold = TRUE),
" resamples: ",
fmt(summary[["n_models"]], col = col_tuner, bold = TRUE),
" models total",
" (",
Sys.getenv("R_PLATFORM"),
")."
)
# Reported against the gated count, which isolates the gate's reduction from
# a randomized search's sampling.
if (
summary[["n_combinations_gated"]] < summary[["n_combinations_expanded"]]
) {
msg0(
"Conditional grid: ",
fmt(summary[["n_combinations_expanded"]], col = col_tuner, bold = TRUE),
" combinations reduced to ",
fmt(summary[["n_combinations_gated"]], col = col_tuner, bold = TRUE),
"; ",
oxfordcomma(gated_params),
ngettext(
length(gated_params),
" does not apply to every combination.",
" do not apply to every combination."
)
)
}
invisible(NULL)
} # /rtemis::tuning_intro
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.