R/train.R

Defines functions device_label describe_device msg_resources get_n_workers normalize_fold_result make_outer_fold_runner train restore_weights_column resolve_weights_column

Documented in train

# train.R
# ::rtemis::
# 2025- EDG rtemis.org

# %% resolve_weights_column ----
#' Split a config's named weights column out of the data
#'
#' A config is a recipe over a data *path*, so it names the column holding the
#' case weights. Training takes the values as a
#' vector and expects the frame to hold features and the outcome only, so the
#' column is read out here and dropped -- from the validation and test sets too,
#' whose columns have to match the training features. Only the training set's
#' values are returned: weights apply to fitting.
#'
#' @param datasets Named list of tabular data or NULL: the training, validation
#'   and test sets, training first.
#' @param column Character or NULL: Name of the weights column.
#'
#' @return Named list with `datasets` (the same list, column removed) and
#'   `weights` (numeric vector, or NULL when `column` is NULL).
#'
#' @author EDG
#' @keywords internal
#' @noRd
resolve_weights_column <- function(datasets, column) {
  if (is.null(column)) {
    return(list(datasets = datasets, weights = NULL))
  }
  training <- datasets[[1L]]
  if (!column %in% names(training)) {
    rtemis.core::abort(
      "`weights` names column '",
      column,
      "', which the training data does not have. Columns: ",
      paste(names(training), collapse = ", "),
      ".",
      class = c("rtemis_value_error", "rtemis_input_error")
    )
  }
  if (identical(column, names(training)[[NCOL(training)]])) {
    rtemis.core::abort(
      "`weights` names column '",
      column,
      "', which is the outcome (the last column).",
      class = c("rtemis_value_error", "rtemis_input_error")
    )
  }
  weights <- training[[column]]
  if (!is.numeric(weights)) {
    rtemis.core::abort(
      "Weights column '",
      column,
      "' must be numeric, not ",
      class(weights)[[1L]],
      ".",
      class = c("rtemis_type_error", "rtemis_data_error")
    )
  }
  datasets <- lapply(datasets, function(dataset) {
    if (is.null(dataset) || !column %in% names(dataset)) {
      return(dataset)
    }
    exc(dataset, column)
  })
  list(datasets = datasets, weights = as.numeric(weights))
} # /rtemis::resolve_weights_column


# %% restore_weights_column ----
#' Put a config's weights column name back on a fitted model's input config
#'
#' A config dispatch resolves the named column to a vector before recursing, and
#' the recursive call records what it was given -- a vector, which no config can
#' express, so it records nothing. This restores the name, so the model's input
#' config says what the user actually asked for.
#'
#' @param model `Supervised` or `SupervisedRes` object.
#' @param column Character or NULL: Name of the weights column.
#'
#' @return `model`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
restore_weights_column <- function(model, column) {
  if (!is.null(column) && !is.null(model@config)) {
    model@config@weights <- column
  }
  model
} # /rtemis::restore_weights_column


# %% train ----
#' Train Supervised Learning Models
#'
#' @description
#' Preprocess, tune, train, and test supervised learning models using nested resampling in a single
#' call.
#'
#' @param x Tabular data, i.e. data.frame, data.table, or tbl_df (tibble): Training set data.
#' @param dat_validation Tabular data: Validation set data.
#' @param dat_test Tabular data: Test set data.
#' @param weights Optional vector of case weights.
#' @param preprocessor_config Optional SupervisedPreprocessorConfig object: Setup using [setup_SupervisedPreprocessor].
#' @param decomposition_config Optional DecompositionConfig object: Setup using a decomposition
#'  `setup_*` function.
#' @param hyperparameters `Hyperparameters` object: Setup using one of `setup_*` functions.
#' @param tuner_config TunerConfig object: Setup using [setup_GridSearch].
#' @param outer_resampling_config Optional ResamplerConfig object: Setup using
#' one of the `setup_*` resampler functions, e.g. [setup_KFold].
#' This defines the outer resampling method, i.e. the splitting into training and test sets for the
#' purpose of assessing model performance. If NULL, no outer resampling is performed, in which case
#' you might want to use a `dat_test` dataset to assess model performance on a single test set.
#' @param execution_config `ExecutionConfig` object: Setup using
#' [setup_SerialExecution], [setup_FutureExecution] or [setup_MiraiExecution].
#' The function you call selects the backend; each sets the number of workers
#' per dispatch level, and [setup_FutureExecution] additionally sets the future
#' plan.
#' @param question Optional character string defining the question that the model is trying to
#' answer.
#' @param outdir Character, optional: String defining the output directory. The
#' fitted model is saved there as `train_<algorithm>.rds`, alongside a run
#' record, `train_<algorithm>.record.json`, stating what the run actually did:
#' every value resolved, where each came from, and what produced it. See
#' [write_record].
#' @param preflight Logical: If TRUE, check the configuration against `x` with
#' [validate_config] before training and stop on any finding of severity
#' "error". Findings of severity "warning" are reported and training continues.
#' Defaults to FALSE, except for a `SuperConfigTabular`, where it defaults to TRUE:
#' that type binds its data in memory and is what a server hands a run it
#' accepted on a client's behalf, so the check is that type's default. Pass
#' FALSE explicitly to skip it.
#' @param verbosity Integer: Verbosity level.
#' @param ... Not used.
#'
#' @details
#' **Online book & documentation**
#'
#' See [docs.rtemis.org/r](https://docs.rtemis.org/r/) for detailed documentation.
#'
#' **Preprocessing**
#'
#' There are many different stages at which preprocessing could be applied, when running a
#' supervised learning pipeline with nested resampling. Some operations are best done before
#' passing data to `train()`:
#'
#' - Duplicate rows should be removed before resampling, so that duplicates don't end up in
#' different resamples, e.g. one in training and one in test.
#' - Constant columns should be removed before resampling. A column may appear constant in a small
#' resample, even if it is not constant in the full dataset. Removing it inconsistently will
#' throw an error during prediction.
#' - All data-dependent preprocessing steps need to be performed on training data only and applied
#' on validation and test data, e.g. scaling, centering, imputation.
#'
#' User-defined preprocessing through `preprocessor_config` is applied on training set data,
#' the learned parameters are stored in the returned Supervised or SupervisedRes object, and the
#' preprocessing is applied on validation and test data.
#'
#' For missing values, prefer an algorithm that accepts them, such as LightGBM, LightRF, Ranger
#' or CART. For an algorithm that requires complete data, impute with
#' `setup_SupervisedPreprocessor(impute = TRUE)`, which uses mean and mode imputation by
#' default. missRanger imputation is fitted on every training set, including every tuning
#' resample, and multiplies the memory and computation of a run; see the Imputation section of
#' [setup_SupervisedPreprocessor].
#'
#' **Binary Classification**
#'
#' For binary classification, the outcome should be a factor where *the 2nd level
#' corresponds to the positive class*.
#'
#' **Resampling**
#'
#' Note that you should not use an outer resampling method with
#' replacement if you will also be using an inner resampling (for tuning).
#' The duplicated cases from the outer resampling may appear both in the
#' training and test sets of the inner resamples, leading to underestimated
#' test error.
#'
#' **Reproducibility**
#'
#' If using ***outer resampling***, you can set a seed when defining `outer_resampling_config`, e.g.
#' ```r
#' outer_resampling_config = setup_KFold(n_resamples = 10L, seed = 2026L)
#' ```
#' If using ***tuning with inner resampling***, you can set a seed when defining `tuner_config`,
#' e.g.
#' ```r
#' tuner_config = setup_GridSearch(
#'   resampler_config = setup_KFold(n_resamples = 5L, seed = 2027L)
#' )
#' ```
#'
#' **Parallelization**
#'
#' There are three levels of parallelization that may be used during training:
#'
#' 1. Algorithm training (e.g. a parallelized learner like LightGBM)
#' 2. Tuning (where the full hyperparameter combination x inner resample cross product is
#' processed in parallel, not just the resamples)
#' 3. Outer resampling (where multiple outer resamples can be processed in parallel)
#'
#' `train()` assigns all workers to exactly one of these levels, so that no two levels
#' compete for the same cores. The level is chosen by the first of these that applies:
#' the algorithm parallelizes itself, tuning is needed, outer resampling is set. A run
#' that needs none of them is sequential.
#'
#' The workers, backend, and future plan all come from `execution_config`; see
#' [setup_FutureExecution].
#'
#' **Reproducibility under parallelization**
#'
#' `execution_config` carries the master seed for the run's computation RNG, from which
#' one independent substream per outer resample is derived and assigned by fold index.
#' Results therefore do not depend on the backend, the number of workers, or the order
#' folds happened to finish in: a parallel run reproduces its sequential counterpart
#' exactly.
#'
#' @return Object of class `Regression(Supervised)`, `RegressionRes(SupervisedRes)`,
#' `Classification(Supervised)`, or `ClassificationRes(SupervisedRes)`.
#'
#' @author EDG
#' @export
#' @examples
#' \donttest{
#' iris_c_lightRF <- train(
#'    iris,
#'    hyperparameters = setup_LightRF(),
#'    outer_resampling_config = setup_KFold(n_resamples = 3L)
#' )
#' }
train <- function(
  x,
  dat_validation = NULL,
  dat_test = NULL,
  weights = NULL,
  preprocessor_config = NULL, # SupervisedPreprocessorConfig
  decomposition_config = NULL, # DecompositionConfig
  hyperparameters = NULL, # Hyperparameters
  tuner_config = NULL, # TunerConfig
  outer_resampling_config = NULL, # ResamplerConfig
  execution_config = setup_FutureExecution(), # ExecutionConfig
  question = NULL,
  outdir = NULL,
  preflight = FALSE,
  verbosity = 1L,
  ...
) {
  # SuperConfigTabular dispatch ----
  if (S7_inherits(x, SuperConfigTabular)) {
    # Checked unless the caller said otherwise. A `SuperConfigTabular` carries its
    # training data, the shape in which a run submitted over the wire arrives;
    # its submitter does not read the run's output, so a run that cannot answer
    # the question asked is refused here. `missing()` sets the default because
    # every dispatch shares one signature.
    if (missing(preflight)) {
      preflight <- TRUE
    }
    # What the run predicts, and from what -- the same projection the path-based
    # dispatch applies, so a config means the same thing whether its data
    # arrived as a file or over the wire. With both properties NULL this is a
    # no-op and rtemis's last-column convention applies exactly as before.
    live_frames <- lapply(
      list(x@dat_training, x@dat_validation, x@dat_test),
      function(dat) {
        if (is.null(dat)) {
          NULL
        } else {
          project_frame(dat, x@outcome, x@features, x@weights)
        }
      }
    )
    resolved <- resolve_weights_column(live_frames, x@weights)
    train_args <- list(
      x = resolved[["datasets"]][[1L]],
      dat_validation = resolved[["datasets"]][[2L]],
      dat_test = resolved[["datasets"]][[3L]],
      weights = resolved[["weights"]],
      preprocessor_config = x@preprocessor_config,
      decomposition_config = x@decomposition_config,
      hyperparameters = x@hyperparameters,
      tuner_config = x@tuner_config,
      outer_resampling_config = x@outer_resampling_config,
      execution_config = x@execution_config,
      question = x@question,
      outdir = x@outdir,
      preflight = preflight,
      verbosity = x@verbosity
    )
    # `positive_class` is handled via `...` (not a formal arg) and aborts if
    # passed as NULL, so include it only when set.
    if (!is.null(x@positive_class)) {
      train_args[["positive_class"]] <- x@positive_class
    }
    return(restore_weights_column(do.call(train, train_args), x@weights))
  } # / train.SuperConfigTabular

  # SuperConfigPaths dispatch ----
  if (S7_inherits(x, SuperConfigPaths)) {
    # `SuperConfigPaths` is a recipe: `dat_training_path` may be unbound. Require it
    # at train time (the CLI sets it from its data argument before calling).
    if (is.null(x@dat_training_path)) {
      rtemis.core::abort(
        "This `SuperConfigPaths` has no `dat_training_path`; set it before training ",
        '(e.g. `x@dat_training_path <- "data.parquet"`) or bind in-memory ',
        "data via `SuperConfigTabular`.",
        class = c("rtemis_null_input", "rtemis_input_error")
      )
    }
    # The config decides how its files are read, and the same way for all
    # three: a validation set whose labels stayed character while training's
    # became factors is a set `check_supervised()` rejects for a difference the
    # caller never asked for.
    c2f <- x@character2factor
    dat_training <- read(x@dat_training_path, character2factor = c2f)
    dat_validation <- if (!is.null(x@dat_validation_path)) {
      read(x@dat_validation_path, character2factor = c2f)
    } else {
      NULL
    }
    dat_test <- if (!is.null(x@dat_test_path)) {
      read(x@dat_test_path, character2factor = c2f)
    } else {
      NULL
    }
    # What the run predicts, and from what. Applied to all three datasets for
    # the reason `character2factor` is: sets whose columns differ from each
    # other are rejected downstream for a difference the caller never asked for.
    # With both properties NULL this is a no-op and rtemis's convention applies
    # exactly as it did.
    dat_training <- project_frame(
      dat_training,
      x@outcome,
      x@features,
      x@weights
    )
    if (!is.null(dat_validation)) {
      dat_validation <- project_frame(
        dat_validation,
        x@outcome,
        x@features,
        x@weights
      )
    }
    if (!is.null(dat_test)) {
      dat_test <- project_frame(dat_test, x@outcome, x@features, x@weights)
    }
    # Call train() with data and other parameters from config
    resolved <- resolve_weights_column(
      list(dat_training, dat_validation, dat_test),
      x@weights
    )
    train_args <- list(
      x = resolved[["datasets"]][[1L]],
      dat_validation = resolved[["datasets"]][[2L]],
      dat_test = resolved[["datasets"]][[3L]],
      weights = resolved[["weights"]],
      preprocessor_config = x@preprocessor_config,
      decomposition_config = x@decomposition_config,
      hyperparameters = x@hyperparameters,
      tuner_config = x@tuner_config,
      outer_resampling_config = x@outer_resampling_config,
      execution_config = x@execution_config,
      question = x@question,
      outdir = x@outdir,
      preflight = preflight,
      verbosity = x@verbosity
    )
    # `positive_class` is handled via `...` (not a formal arg) and aborts if
    # passed as NULL, so include it only when set.
    if (!is.null(x@positive_class)) {
      train_args[["positive_class"]] <- x@positive_class
    }
    return(restore_weights_column(do.call(train, train_args), x@weights))
  } # / train.SuperConfigPaths

  # Checks ----

  # Defense against invalid args
  extra_args <- list(...)
  positive_class <- extra_args[["positive_class"]]
  if (!is.null(positive_class)) {
    x <- set_positive_class(x, positive_class)
    extra_args[["positive_class"]] <- NULL
  }
  if (length(extra_args) > 0L) {
    rtemis.core::abort(
      "Unused extra arguments were provided: ",
      paste(names(extra_args), collapse = ", "),
      ". Please check your function call.",
      class = c("rtemis_value_error", "rtemis_input_error")
    )
  }

  if (is.null(hyperparameters)) {
    hyperparameters <- setup_Ranger()
  }
  # A plain list of `Hyperparameters` is the user-facing form of a set, coerced
  # here so nothing downstream sees a bare list. Coercing at the boundary rather
  # than teaching the internals to accept a list is what keeps `needs_tuning()`
  # and its siblings from needing a `class_list` method, which would claim the
  # bare list type across the package for one meaning.
  if (
    is.list(hyperparameters) && !S7_inherits(hyperparameters, Hyperparameters)
  ) {
    hyperparameters <- as_HyperparametersSet(hyperparameters)
  }
  check_hyperparameters(hyperparameters)
  # Kept as given, because `input_config` below records what was *asked for* and
  # a set is a different ask from the configuration it collapses to. A list of
  # one is a legitimate and useful input -- it is how a run names its
  # configuration -- and that name is only in the recipe if the recipe holds the
  # set.
  asked_hyperparameters <- hyperparameters
  algorithm <- hyperparameters@algorithm

  # The run's input, captured before anything resolves it. Tuning narrows a
  # search space and `train_()` fills in what the data decides, so by the time
  # the model is built `hyperparameters` says what *ran*; only this says what
  # was *asked for*. `record()` needs both to report where each value came from.
  # Before the recipe is built: `SuperConfigPaths` types this block, so an S7 error
  # would otherwise reach the caller in place of the message that names the fix.
  if (!is.null(preprocessor_config)) {
    check_preprocessor_for_train(preprocessor_config)
  }

  # `dat_training_path` stays unset for an in-memory run -- data identity is the
  # provenance block's `DataFingerprint`, not a path.
  input_config <- setup_SuperConfig(
    # `SuperConfigPaths` is a portable recipe over a data *path*, so it names a
    # weights column; `weights` here is the vector itself, which such a
    # document cannot hold. The field is therefore left unset, and a weighted
    # run is identifiable from its `DataFingerprint`.
    weights = NULL,
    # Relevelling the outcome is all `positive_class` does to the run, so
    # nothing downstream would otherwise say it was asked for: two runs
    # differing only in which class is positive would produce identical input
    # blocks, and their metrics are not comparable without it.
    positive_class = positive_class,
    preprocessor_config = preprocessor_config,
    decomposition_config = decomposition_config,
    hyperparameters = asked_hyperparameters,
    tuner_config = tuner_config,
    outer_resampling_config = outer_resampling_config,
    execution_config = execution_config,
    question = question,
    # Passed explicitly, so a record names the directory the run wrote to; the
    # `SuperConfigPaths` default ("results/") does not apply, and a live run
    # writes nothing to disk.
    outdir = outdir,
    # A nested call passes `verbosity - 1L`, so this can be negative -- an
    # internal "quieter than silent" convention a config cannot express, its
    # `verbosity` being bounded at 0. Clamped at 0: it affects messages, never
    # results.
    verbosity = max(0L, verbosity)
  )

  # A column named by `id_strat` identifies cases, so the learner must not see
  # it: left in, it reaches the model as a
  # high-cardinality feature, and `check_supervised()` rejects the run outright
  # when the IDs are strings. Only `resample()` needs it, and only to read the
  # grouping off, so the frame carrying it is kept for that one call.
  x_resampling <- x
  x <- drop_id_strat_column(x, outer_resampling_config)

  # Initial check targetting non-numeric or factor columns
  # Will be checked again by individual learners;
  # this stops sending to all resamples and failing, which fits our stop early design.
  check_supervised(
    x = x,
    dat_validation = dat_validation,
    dat_test = dat_test,
    allow_missing = TRUE,
    verbosity = verbosity
  )
  type <- supervised_type(x)
  ncols <- ncol(x)

  # Set default tuner_config if tuning is needed but none specified
  if (needs_tuning(hyperparameters) && is.null(tuner_config)) {
    tuner_config <- setup_GridSearch()
  }

  if (!is.null(tuner_config)) {
    check_is_S7(tuner_config, TunerConfig)
  }

  # Can only use algorithms whose output can be applied on new data: we need to apply the
  # transformation learned on the training data to validation and test sets.
  if (!is.null(decomposition_config)) {
    check_decom_applicable(decomposition_config)
  }

  # execution_config must always be set
  check_is_S7(execution_config, ExecutionConfig)

  # Pre-flight ----
  # Every config object is validated by now, so this is the first point the
  # recipe and the data can be checked against each other -- and the last point
  # before the run starts spending.
  if (preflight) {
    preflight_config(
      x = x,
      preprocessor_config = preprocessor_config,
      decomposition_config = decomposition_config,
      hyperparameters = hyperparameters,
      tuner_config = tuner_config,
      outer_resampling_config = outer_resampling_config,
      positive_class = positive_class,
      verbosity = verbosity
    )
  }
  # Override parallelization parameters with those from execution_config
  backend <- execution_config@backend
  n_workers <- execution_n_workers(execution_config)
  future_plan <- execution_future_plan(execution_config)

  # A set with nothing to choose between is the configuration it holds, and from
  # here on it is that configuration: only a one-member set of fixed values
  # reaches this, since more than one member is a choice and so needs tuning.
  # Collapsing means a set survives past this point only when tuning will
  # resolve it, so nothing downstream has to know about sets. The name is
  # carried onto the member, and the set itself is already recorded above.
  if (
    S7_inherits(hyperparameters, HyperparametersSet) &&
      !needs_tuning(hyperparameters)
  ) {
    label <- names(hyperparameters@variants)[[1L]]
    hyperparameters <- hyperparameters@variants[[1L]]
    hyperparameters@variant <- label
  }

  # If outer_resampling_config is set, dat_validation and dat_test must be NULL
  if (!is.null(outer_resampling_config)) {
    if (!is.null(dat_validation) || !is.null(dat_test)) {
      rtemis.core::abort(
        "If `outer_resampling_config` is set, `dat_validation` and `dat_test` must be NULL.",
        class = c("rtemis_value_error", "rtemis_input_error")
      )
    }
  }

  future_plan <- resolve_future_plan(backend, future_plan)
  if (!is.null(outer_resampling_config)) {
    check_is_S7(outer_resampling_config, ResamplerConfig)
  }

  if (!is.null(outdir)) {
    outdir <- make_path(outdir)
    if (!dir.exists(outdir)) {
      dir.create(outdir, showWarnings = FALSE, recursive = TRUE)
    }
    if (verbosity > 1L) {
      info("Output directory set to ", outdir, ".")
    }
  }

  logfile <- if (!is.null(outdir)) {
    paste0(
      outdir,
      "/",
      "train_",
      algorithm,
      "_",
      format(Sys.time(), "%Y%m%d.%H%M%S"),
      ".log"
    )
  } else {
    NULL
  }

  # Start timer & logfile ----
  start_time <- intro(verbosity = verbosity, logfile = logfile)

  # Observability session ----
  # A sequential outer fold reuses the ambient session, so its step nodes nest under the
  # fold node. A fold dispatched in parallel runs with no session attached and creates one;
  # that session is the sub-log the host grafts back under the fold node, which is what
  # keeps the graph of a parallel run identical to a sequential one.
  # See specs/observability.md sections 3 and 4.
  on_error <- execution_config@on_error
  session_created <- session_start(verbosity = verbosity)
  # Safety net: on any exit (incl. error) the top-level call clears the ambient slot.
  on.exit(if (session_created) session_clear(), add = TRUE)
  # Because a fold in a daemon does create a session, `session_created` cannot answer
  # "am I the run the user asked for?". The fold worker marks itself in `live[["fold"]]`,
  # and everything that must happen exactly once per run keys off `is_root` instead.
  is_root <- session_created && is.null(live[["fold"]])
  # Data provenance ----
  # Only the top-level call fingerprints: nested (per outer-fold) calls would
  # each hash their own fold, which is both wasteful and not what anyone wants
  # to compare. Same rule as the session above -- the top-level object carries
  # the identity of the data the user actually supplied.
  training_fingerprint <- if (is_root) {
    data_fingerprint(x)
  } else {
    NULL
  }
  root_node <- if (session_created) {
    node_enter("train", label = paste(algorithm, type))
  } else {
    NULL
  }

  # Data ----
  if (type == "Classification") {
    classes <- levels(outcome(x))
  }

  ## Validate hyperparameters against data ----
  # Algorithm-specific constraints that depend on the data (e.g. Ranger's mtry
  # cannot exceed the number of features). Runs before tuning and outer
  # resampling, so an invalid search space fails here, before any grid cell
  # runs; under `on_error = "continue"` per-cell failures would be skipped. Tunable
  # hyperparameters still hold their full search space at this point.
  validate_hyperparameters(hyperparameters, x)

  ## Print data summary ----
  if (verbosity > 0L) {
    summarize_supervised(
      x = x,
      dat_validation = dat_validation,
      dat_test = dat_test
    )
  }

  # Init ----
  workers <- get_n_workers(
    algorithm = algorithm,
    hyperparameters = hyperparameters,
    outer_resampling_config = outer_resampling_config,
    n_workers = n_workers,
    n_workers_outer = execution_dispatch_level(
      execution_config,
      "n_workers_outer"
    ),
    n_workers_tuning = execution_dispatch_level(
      execution_config,
      "n_workers_tuning"
    ),
    n_workers_algorithm = execution_config@n_workers_algorithm,
    backend = execution_backend_label(execution_config),
    device = describe_device(hyperparameters, execution_config@device),
    verbosity = verbosity
  )
  hyperparameters@n_workers <- workers[["algorithm"]]
  tuner <- NULL

  # A separate name: the outer resampling loop further down needs the backend the user
  # asked for, which `backend` keeps.
  tuning_backend <- if (workers[["tuning"]] == 1L) {
    "none"
  } else {
    backend
  }

  # Worker pool ----
  # Started once for the whole run, so every dispatch beneath this frame reuses the same
  # workers. The level that will dispatch is whichever the ladder gave workers to, and
  # only one ever does, so its share is the pool size; a run that spent them on the
  # algorithm dispatches nothing and starts no pool.
  # Sized here, once: tuning dispatches *inside* the outer fold loop, so a pool owned by
  # each dispatch would be built and torn down once per fold, which on a short grid costs
  # more than the parallelism saves.
  # A sequential fold recurses into `train()` with the run's own config and reaches this
  # again; `worker_pool_start()` finds the pool standing and hands back FALSE, so the fold
  # uses the standing pool.
  pool_started <- worker_pool_start(
    backend = backend,
    n_workers = max(workers[["tuning"]], workers[["outer_resampling"]]),
    future_plan = future_plan,
    warm = execution_warm_workers(execution_config),
    envir = environment(),
    verbosity = verbosity
  )
  on.exit(if (pool_started) worker_pool_stop(), add = TRUE)

  # Reported here, once, and only by the run the user started: the individual
  # `share_payload()` calls are silent, and there are two of them per dispatch site
  # with tuning dispatching once per outer fold.
  if (is_root) {
    # One warning per run if a share fails; this is where a run begins.
    live[["share_warned"]] <- NULL
    report_shared_memory(
      x,
      mode = execution_config@shared_memory,
      backend = backend,
      future_plan = future_plan,
      n_workers = max(workers[["tuning"]], workers[["outer_resampling"]]),
      verbosity = verbosity
    )
  }

  # Preprocessors ----
  # `preprocessor`: User-level preprocessing (Preprocessor object created from
  #   `setup_Preprocessor`). Handles scaling, imputation, encoding, etc.
  # `preprocessor_internal`: Algorithm-level preprocessing (Preprocessor object
  #   returned by each train_*() method). Handles transformations the algorithm
  #   requires internally (e.g. factor-to-integer conversion for LightGBM).
  # Both are stored on the trained model so predict() can re-apply them in order:
  # user-level first, then algorithm-level.
  # Initialized to NULL here; set in the single-model path below.
  # In the outer resampling path, each sub-model carries its own pair.
  preprocessor <- preprocessor_internal <- NULL
  # `decomposition`: fitted Decomposition (from `decomposition_config`) learned on
  # the training features and re-applied to validation/test here, and to new data
  # at predict() time. Stored on the returned model. NULL when no decomposition.
  decomposition <- NULL

  # === Outer Resampling === ----
  # Splits data into multiple training-test folds and calls train() recursively
  # on each. Each recursive call enters the Single Model path below (which may
  # itself tune via inner resampling). After all folds complete, execution falls
  # through to the Outer Aggregation path.
  # Folds run in parallel when the worker ladder assigned workers to this level,
  # which happens only when there is neither a parallelized algorithm nor tuning
  # to spend them on; otherwise the folds run one at a time and each spends the
  # workers inside itself. spec: rtemis/parallelization.
  if (!is.null(outer_resampling_config)) {
    msg0(
      fmt("<> ", col = col_outer, bold = TRUE),
      "Training ",
      highlight(paste(algorithm, type)),
      " using ",
      desc(outer_resampling_config),
      "...",
      verbosity = verbosity
    )
    # Each fold runs at `verbosity - 1L`, so the search's own announcement is
    # silenced inside them. Say once, here, what all of them are about to do.
    if (needs_tuning(hyperparameters) && !is.null(tuner_config)) {
      tuning_intro(
        tuning_grid_summary(hyperparameters, tuner_config),
        algorithm,
        tuner_config,
        verbosity = verbosity
      )
    }
    outer_resampler <- resample(
      x_resampling,
      config = outer_resampling_config,
      verbosity = verbosity
    )
    n_outer <- outer_resampler@config@n_resamples
    outer_workers <- workers[["outer_resampling"]]
    # `execution_config@backend`, not `tuning_backend`: the latter has already been
    # narrowed to "none" for the very case that reaches outer parallelization.
    parallel_folds <- outer_workers > 1L && execution_config@backend != "none"
    # The ladder assigns the run's workers to exactly one level, and which one decides what
    # a fold may claim. Folds got them: each fold is one of n_workers already running, so it
    # must claim nothing, or the run oversubscribes n_workers x n_workers. Folds did not get
    # them precisely because a level *inside* a fold did -- a parallelized algorithm, or
    # tuning -- so the fold inherits the run's config and runs the ladder again over it,
    # this time with no outer level to consider. Forcing the sequential config on both
    # branches is what silently serialized every tuned or self-parallelizing run.
    fold_execution_config <- if (parallel_folds) {
      SerialExecutionConfig(
        # Threads, not processes: the fold is already occupying one worker and must not
        # dispatch, but a self-parallelizing algorithm still runs multi-threaded inside
        # it. This is what makes `n_workers_outer = 4L, n_workers_algorithm = 2L` mean
        # four folds of two threads rather than four folds of one.
        n_workers_algorithm = execution_config@n_workers_algorithm,
        device = execution_config@device,
        on_error = on_error,
        seed = execution_config@seed
      )
    } else {
      execution_config
    }
    x_shared <- share_payload(
      x,
      mode = execution_config@shared_memory,
      backend = execution_config@backend,
      future_plan = future_plan,
      n_workers = outer_workers
    )
    weights_shared <- share_payload(
      weights,
      mode = execution_config@shared_memory,
      backend = execution_config@backend,
      future_plan = future_plan,
      n_workers = outer_workers
    )
    run_outer_fold <- make_outer_fold_runner(
      x = x_shared,
      n_outer = n_outer,
      preprocessor_config = preprocessor_config,
      decomposition_config = decomposition_config,
      hyperparameters = hyperparameters,
      tuner_config = tuner_config,
      execution_config = fold_execution_config,
      weights = weights_shared,
      question = question,
      # Failure policy (specs/observability.md section 7): under "stop"/"stop_outer" an
      # outer fold failure is fatal, so the fold re-raises and the dispatcher stops the
      # run.
      fatal = !identical(on_error, "continue"),
      parallel = parallel_folds,
      verbosity = verbosity - 1L
    )
    # Each fold receives its own resample and no other.
    folds <- lapply(seq_len(n_outer), function(i) {
      list(fold = i, resample = outer_resampler@resamples[[i]])
    })
    fold_results <- progress_plapply(
      folds,
      run_outer_fold,
      backend = if (parallel_folds) execution_config@backend else "none",
      n_workers = outer_workers,
      future_plan = future_plan,
      # One independent RNG substream per fold, assigned by fold index, so a resampled run
      # gives the same answer sequentially and in parallel, at any worker count.
      seeds = rng_substreams(execution_config@seed, n_outer),
      label = "Outer resamples",
      kind = "outer_resampling",
      stop_on_error = !identical(on_error, "continue"),
      verbosity = verbosity
    )
    # Collect ----
    # Folds never raise out of `progress_plapply()` under "continue", so the policy is
    # applied here, on the host, where the session and the user's console are.
    models <- vector("list", n_outer)
    for (i in seq_len(n_outer)) {
      res <- normalize_fold_result(fold_results[[i]])
      # A fold dispatched in parallel ran with no session attached, so its `node_enter()`
      # was inert and the host synthesizes the fold node now, grafting the sub-log the
      # worker brought home beneath it. A sequential fold already recorded its own node
      # and carries no session. See specs/observability.md section 4.
      if (parallel_folds) {
        fold_node <- session_add_node(
          "outer_fold",
          label = paste0(i, "/", n_outer),
          status = if (res[["failed"]]) "error" else "ok",
          meta = list(fold = i, error = res[["error_message"]]),
          t_start = res[["t_start"]],
          t_end = res[["t_end"]]
        )
        if (!is.null(res[["model"]]) && !is.null(res[["model"]]@session)) {
          session_graft(
            res[["model"]]@session@events,
            fold_node,
            paste0("f", i)
          )
          # The events now live in the root session; leaving a copy on the sub-model would
          # store the same graph twice on the returned object.
          res[["model"]]@session <- NULL
        }
      }
      if (res[["failed"]]) {
        rtemis.core::warn(
          "Outer fold ",
          i,
          "/",
          n_outer,
          " failed: ",
          res[["error_message"]]
        )
      } else {
        models[[i]] <- res[["model"]]
      }
    }
    names(models) <- names(outer_resampler@resamples)
    # Drop failed folds (only possible under on_error = "continue").
    failed_folds <- vapply(models, is.null, logical(1L))
    if (all(failed_folds)) {
      rtemis.core::abort(
        "All ",
        n_outer,
        " outer folds failed.",
        class = c("rtemis_error", "rtemis_runtime_error")
      )
    }
    if (any(failed_folds)) {
      rtemis.core::warn(
        sum(failed_folds),
        " of ",
        n_outer,
        " outer folds failed; aggregating over ",
        sum(!failed_folds),
        " surviving folds."
      )
      models <- models[!failed_folds]
    }
    hyperparameters@resampled <- 1L
    msg(
      fmt("</>", col = col_outer, bold = TRUE),
      "Outer resampling done.",
      verbosity = verbosity
    )
  } # /Outer Resampling

  if (hyperparameters@resampled == 0L) {
    # === Inner path === ----
    # Trains one model: optionally tune (inner resampling) → preprocess →
    # train algorithm → predict → returns Supervised.
    # Skipped when outer resampling was performed (resampled == 1L).

    # Tune ----
    # Inner resampling for hyperparameter optimization.
    if (needs_tuning(hyperparameters)) {
      # The "tune" node is the parent under which tune_GridSearch() host-synthesizes one
      # grid_cell node per (combo x inner resample). See specs/observability.md section 4.
      tune_node <- node_enter("tune")
      tuner <- tune(
        x = x,
        hyperparameters = hyperparameters,
        tuner_config = tuner_config,
        preprocessor_config = preprocessor_config,
        decomposition_config = decomposition_config,
        weights = weights,
        backend = tuning_backend,
        future_plan = future_plan,
        n_workers = workers[["tuning"]],
        seed = execution_config@seed,
        shared_memory = execution_config@shared_memory,
        verbosity = verbosity,
        on_error = on_error
      )
      # Update hyperparameters
      # A set collapses here: from this point on `hyperparameters` is the one
      # winning member, and every path below it is unchanged.
      hyperparameters <- update(
        tuner@hyperparameters,
        tuner@best_hyperparameters,
        tuned = 1L
      )
      # Which member won, so the fitted model reports it.
      hyperparameters@variant <- tuner@best_variant
      node_exit(tune_node, status = "ok")
    } # /Tune

    # User-level preprocessing ----
    # Features only, and no step that drops cases. The outcome is the quantity
    # the model learns: scaling, one-hot encoding or imputing it would change
    # what is being predicted and leave `predict()` returning values in
    # transformed units, while a case-removing step cannot be replayed on new
    # data at all. Same layout as the decomposition block below -- transform
    # the features, re-attach the outcome last.
    if (!is.null(preprocessor_config)) {
      prep_node <- node_enter("preprocess")
      if (verbosity == 1L) {
        msg("Preprocessing...")
      }
      prep_outcome_nm <- names(x)[ncols]
      preprocessor <- preprocess(
        x = as.data.frame(features(x)),
        config = preprocessor_config,
        dat_validation = if (!is.null(dat_validation)) {
          as.data.frame(features(dat_validation))
        },
        dat_test = if (!is.null(dat_test)) as.data.frame(features(dat_test)),
        verbosity = verbosity - 1L
      )
      prep_training <- if (is.null(dat_validation) && is.null(dat_test)) {
        preprocessor@preprocessed
      } else {
        preprocessor@preprocessed[["training"]]
      }
      x <- attach_outcome(prep_training, x[[ncols]], prep_outcome_nm)
      if (!is.null(dat_validation)) {
        dat_validation <- attach_outcome(
          preprocessor@preprocessed[["validation"]],
          dat_validation[[NCOL(dat_validation)]],
          prep_outcome_nm
        )
      }
      if (!is.null(dat_test)) {
        dat_test <- attach_outcome(
          preprocessor@preprocessed[["test"]],
          dat_test[[NCOL(dat_test)]],
          prep_outcome_nm
        )
      }
      # Preprocessing may add columns (one-hot) or drop them (constants).
      ncols <- ncol(x)
      node_exit(prep_node, status = "ok")
    } else {
      preprocessor <- NULL
    } # /User-level preprocessing

    # Decomposition ----
    # Learn the decomposition on the selected training features, then apply the
    # learned transformation to the validation and test features. Features not
    # selected for decomposition are kept as-is, in front of the components, and
    # the outcome column is re-attached last: layout `[kept features, components,
    # outcome]`. The fitted Decomposition (carrying the resolved feature names)
    # is stored on the returned model so predict() can re-apply it to new data.
    if (!is.null(decomposition_config)) {
      decomp_node <- node_enter(
        "decompose",
        label = decomposition_config@algorithm
      )
      outcome_nm <- names(x)[ncols]
      feat <- as.data.frame(features(x))
      # Resolve the columns to decompose: NULL -> all numeric features.
      # Binds the *resolved* selection, which for an unset `features` is
      # whatever the data happen to supply, so no declaration can express it.
      # An explicit selection is validated by `decomp()` against `feat`.
      decomp_features <- decomposition_config@features %||%
        resolve_unsupervised_features(feat, "Decomposition")
      # Persist the resolved names so apply_decomp() replays the same selection
      # on validation/test here and on new data at predict() time. `decomp()`
      # subsets `feat` by them, so the fit and the replay cannot disagree.
      decomposition_config@features <- decomp_features
      # The decomposition runs in this process before the learner. It may use
      # every worker as threads only when this call dispatches nothing; when
      # tuning or folds claim the workers it runs on one thread, so the two
      # never oversubscribe. Seeded from this call's seed, which within a fold
      # is the fold's substream.
      decomposition_threads <- execution_config@n_workers_algorithm %||%
        if (workers[["tuning"]] == 1L && workers[["outer_resampling"]] == 1L) {
          n_workers
        } else {
          1L
        }
      decomposition_execution <- algorithm_execution_config(
        execution_config,
        decomposition_threads
      )
      decomposition <- decomp(
        x = feat,
        algorithm = decomposition_config@algorithm,
        config = decomposition_config,
        execution_config = decomposition_execution,
        verbosity = verbosity
      )
      # Columns not decomposed are kept as-is, in front of the components.
      kept_features <- setdiff(names(feat), decomp_features)
      # Decomposed training data: [kept features, components, outcome].
      x_outcome <- x[[ncols]]
      components <- as.data.frame(decomposition@transformed)
      x <- if (length(kept_features) > 0L) {
        cbind(feat[, kept_features, drop = FALSE], components)
      } else {
        components
      }
      x[[outcome_nm]] <- x_outcome
      # Apply decomposition to validation data
      if (!is.null(dat_validation)) {
        val_outcome <- dat_validation[[ncols]]
        dat_validation <- as.data.frame(
          apply_decomposition(
            decomposition,
            features(dat_validation),
            execution_config = decomposition_execution,
            verbosity = 0L
          )
        )
        dat_validation[[outcome_nm]] <- val_outcome
      }
      # Apply decomposition to test data
      if (!is.null(dat_test)) {
        test_outcome <- dat_test[[ncols]]
        dat_test <- as.data.frame(
          apply_decomposition(
            decomposition,
            features(dat_test),
            execution_config = decomposition_execution,
            verbosity = 0L
          )
        )
        dat_test[[outcome_nm]] <- test_outcome
      }
      # Number of columns changes after decomposition.
      ncols <- ncol(x)
      node_exit(decomp_node, status = "ok")
    }

    # IFW ----
    # Weight calculation must follow preprocessing since N cases may change.
    if (type == "Classification" && hyperparameters[["ifw"]]) {
      if (!is.null(weights)) {
        rtemis.core::abort(
          "Custom weights are defined, but IFW is set to TRUE.",
          class = c("rtemis_value_error", "rtemis_input_error")
        )
      } else {
        weights <- ifw(x[[ncols]], type = "case_weights", verbosity = verbosity)
      }
    } # /IFW
    # Whatever produced them, the weights reach every algorithm from here, so
    # their assumptions are checked here, once for every backend.
    check_case_weights(weights, NROW(x))

    # Train algorithm ----
    # The device is named once per run, in the resources line.
    if (is_tuned(hyperparameters)) {
      msg0(
        "Training ",
        highlight(paste(algorithm, type)),
        " with tuned hyperparameters...",
        verbosity = verbosity
      )
    } else {
      msg0(
        "Training ",
        highlight(paste(algorithm, type)),
        "...",
        verbosity = verbosity
      )
    }
    # Validation data is only passed to learners that use early stopping.
    # For other learners, validation metrics are collected during tuning.
    dat_validation_for_training <- if (algorithm %in% early_stopping_algs) {
      dat_validation
    } else {
      NULL
    }

    # Re-check now that hyperparameters are resolved and the feature count
    # reflects preprocessing and decomposition.
    validate_hyperparameters(hyperparameters, x)

    algo_node <- node_enter(
      "train_alg",
      label = algorithm,
      meta = list(algorithm = algorithm, n = NROW(x))
    )
    trained <- train_(
      hyperparameters = hyperparameters,
      x = x,
      weights = weights,
      dat_validation = dat_validation_for_training,
      execution_config = execution_config, # used by LightRuleFit
      verbosity = verbosity
    )
    node_exit(algo_node, status = "ok")

    model <- trained[["model"]]
    # Values the algorithm resolved at train time (LightGBM's `objective` from
    # the outcome type, GLMNET's `lambda`): R copied `hyperparameters` into
    # `train_()`, so without this the fitted model would report NULL for
    # settings it demonstrably used.
    if (!is.null(trained[["hyperparameters"]])) {
      hyperparameters <- trained[["hyperparameters"]]
    }
    # Algorithm-level preprocessing (e.g. factor-to-integer for LightGBM),
    # returned by train_*() if needed.
    preprocessor_internal <- trained[["preprocessor"]]

    # Predictions ----
    predict_node <- node_enter("predict")
    predicted_prob_training <- predicted_prob_validation <- predicted_prob_test <- NULL

    # Re-apply algorithm-level preprocessing before predicting on each dataset.
    x_features <- features(x)
    if (!is.null(preprocessor_internal)) {
      x_features <- apply_preprocessor(
        preprocessor_internal,
        x_features,
        verbosity = verbosity - 1L
      )
    }

    # The fit's own predictions run in its process, on the algorithm workers the
    # ladder gave it, never dispatching.
    fit_execution <- algorithm_execution_config(
      execution_config,
      hyperparameters@n_workers
    )
    predicted_training <- predict_super(
      model = model,
      newdata = x_features,
      type = type,
      execution_config = fit_execution
    )

    if (type == "Classification") {
      # `predict_super()` returns probabilities for a classification model;
      # normalized to a matrix so the property has one shape whatever the
      # backend and the class count (see `prob_matrix()`).
      predicted_prob_training <- prob_matrix(predicted_training, classes)
      predicted_training <- prob2categorical(
        predicted_prob_training,
        levels = classes
      )
    }

    predicted_validation <- predicted_test <- NULL
    if (!is.null(dat_validation)) {
      dat_validation_features <- features(dat_validation)
      if (!is.null(preprocessor_internal)) {
        dat_validation_features <- apply_preprocessor(
          preprocessor_internal,
          dat_validation_features,
          verbosity = verbosity - 1L
        )
      }

      predicted_validation <- predict_super(
        model = model,
        newdata = dat_validation_features,
        type = type,
        execution_config = fit_execution
      )

      if (type == "Classification") {
        predicted_prob_validation <- prob_matrix(predicted_validation, classes)
        predicted_validation <- prob2categorical(
          predicted_prob_validation,
          levels = classes
        )
      }
    }

    if (!is.null(dat_test)) {
      dat_test_features <- features(dat_test)
      if (!is.null(preprocessor_internal)) {
        dat_test_features <- apply_preprocessor(
          preprocessor_internal,
          dat_test_features,
          verbosity = verbosity - 1L
        )
      }

      predicted_test <- predict_super(
        model = model,
        newdata = dat_test_features,
        type = type,
        execution_config = fit_execution
      )

      if (type == "Classification") {
        predicted_prob_test <- prob_matrix(predicted_test, classes)
        predicted_test <- prob2categorical(
          predicted_prob_test,
          levels = classes
        )
      }
    }

    node_exit(predict_node, status = "ok")

    # Variable importance ----
    # Skipped during tuning (inner resampling): those models are discarded and only
    # the outer folds' / final model's varimp is used. For kept models it can be a
    # major cost (e.g. LightGBM tree -> data.table extraction), so record a node.
    varimp <- NULL
    if (hyperparameters@tuned != TUNED_STATUS_TUNING) {
      varimp_node <- node_enter("varimp", label = algorithm)
      varimp <- varimp_super(model = model)
      node_exit(varimp_node, status = "ok")
    }

    # Inner path: Return Supervised ----
    # The Classification/Regression constructor computes train/validation/test
    # metrics (e.g. AUC, which can dominate on large data), so wrap it in a node.
    metrics_node <- node_enter("metrics")
    mod <- make_Supervised(
      algorithm = algorithm,
      model = model,
      preprocessor = preprocessor,
      preprocessor_internal = preprocessor_internal,
      decomposition = decomposition,
      hyperparameters = hyperparameters,
      tuner = tuner,
      execution_config = execution_config,
      y_training = x[[ncols]],
      y_validation = if (!is.null(dat_validation)) dat_validation[[ncols]],
      y_test = if (!is.null(dat_test)) dat_test[[ncols]],
      predicted_training = predicted_training,
      predicted_validation = predicted_validation,
      predicted_test = predicted_test,
      predicted_prob_training = predicted_prob_training,
      predicted_prob_validation = predicted_prob_validation,
      predicted_prob_test = predicted_prob_test,
      xnames = names(x)[-ncols],
      varimp = varimp,
      question = question,
      data_fingerprint = training_fingerprint
    )
    node_exit(metrics_node, status = "ok")
  } else {
    # === Outer Aggregation path === ----
    # Reached after outer resampling. Each sub-model (Supervised) in `models`
    # carries its own preprocessor pair. Aggregate results → SupervisedRes.
    y_training <- lapply(models, function(mod) mod@y_training)
    y_test <- lapply(models, function(mod) mod@y_test)
    predicted_training <- lapply(models, function(mod) mod@predicted_training)
    predicted_test <- lapply(models, function(mod) mod@predicted_test)
    if (type == "Classification") {
      predicted_prob_training <- lapply(
        models,
        function(mod) mod@predicted_prob_training
      )
      predicted_prob_test <- lapply(
        models,
        function(mod) mod@predicted_prob_test
      )
    } else {
      predicted_prob_training <- predicted_prob_test <- NULL
    }
    # Return SupervisedRes ----
    mod <- make_SupervisedRes(
      algorithm = algorithm,
      type = type,
      models = models,
      preprocessor_config = preprocessor_config,
      decomposition_config = decomposition_config,
      hyperparameters = hyperparameters,
      tuner_config = tuner_config,
      outer_resampler = outer_resampler,
      execution_config = execution_config,
      y_training = y_training,
      y_test = y_test,
      predicted_training = predicted_training,
      predicted_test = predicted_test,
      predicted_prob_training = predicted_prob_training,
      predicted_prob_test = predicted_prob_test,
      xnames = names(x)[-ncols],
      varimp = lapply(models, \(mod) mod@varimp),
      question = question,
      data_fingerprint = training_fingerprint
    )
  }

  # Attach the run's input ----
  # Only the top-level call: a per-outer-fold sub-model shares the same input,
  # and one record per `train()` call is the design (see config-artifacts.md).
  if (is_root) {
    mod@config <- input_config
  }

  # Finalize observability session ----
  # Keyed off `session_created`, not `is_root`: an in-process fold leaves its sub-model's
  # `session` NULL because the root session already holds its nodes, while a fold in a
  # daemon attaches the session it built, which is how those nodes travel home to be
  # grafted. See specs/observability.md sections 3, 4, and 10.
  if (session_created) {
    node_exit(root_node, status = "ok")
    mod@session <- session_finalize()
  }

  # Outro ----
  if (verbosity > 0L) {
    message()
    print(mod)
    message()
  }
  if (is_root && verbosity >= 2L) {
    session_report(mod@session, verbosity = verbosity)
  }
  if (!is.null(outdir)) {
    rt_save(mod, outdir = outdir, file_prefix = paste0("train_", algorithm))
    # The record sits beside the model and shares its name, so a directory of
    # runs pairs up by inspection. No opt-in: "all runs are auditable" does not
    # hold if writing the record is something a caller can forget. Only a
    # top-level call has the input a record needs.
    # A resampled run records one entry per fold, so folds that resolved
    # different values (early stopping settles on a different `nrounds` each
    # time) are each stated.
    if (is_root) {
      write_record(
        mod,
        file.path(outdir, paste0("train_", algorithm, ".record.json")),
        overwrite = TRUE,
        verbosity = verbosity
      )
    }
  }
  outro(
    start_time,
    logfile = logfile,
    verbosity = verbosity
  )
  # Print object to logfile
  if (!is.null(logfile)) {
    cat(
      "\n",
      repr(mod, output_type = "plain"),
      file = logfile,
      append = TRUE,
      sep = ""
    )
  }
  mod
} # /rtemis::train


# %% make_outer_fold_runner ----
#' Build the per-outer-fold body
#'
#' Returns the closure `progress_plapply()` dispatches once per outer resample. One body
#' serves every backend, switching on `parallel`: under sequential dispatch it records its
#' fold node in the ambient session and the nested `train()` nests beneath it; under
#' parallel dispatch it detaches the ambient session, so `node_enter()` is inert and the
#' nested `train()` builds the session that travels home as the fold's sub-log.
#'
#' @details
#' Built by a factory because serializing a closure walks
#' its enclosing environments: a body defined in `train()`'s frame would ship that entire
#' frame to every worker -- every dataset, config and intermediate it happens to hold.
#' This frame holds only what a fold actually needs.
#'
#' `live[["fold"]]` marks the call as a fold for the `train()` that runs inside it, which
#' is how a fold in a daemon knows not to fingerprint its slice of the data, attach an
#' input config, or report a graph of its own.
#'
#' The session is detached explicitly, because "dispatched
#' in parallel" does not imply "no session here": a forked worker
#' (`future_plan = "multicore"`) inherits the host's `live` env wholesale, and a
#' `multicore` plan on a host where forking is unavailable runs the body in process. In
#' both cases the fold would nest into the host's session, return no sub-log to graft, and
#' -- in the second -- leave a fold node the host then duplicates when it synthesizes its
#' own. Detaching makes every parallel worker build the per-fold session the host expects.
#'
#' Under a tolerant failure policy a failure is captured and returned, so the host can
#' warn about it and aggregate over the folds that survived. Under a fatal one it is
#' recorded on the fold node and then re-raised, which lets the dispatcher stop the run.
#'
#' @param x Tabular data: Full training set; each fold slices its own rows.
#' @param n_outer Integer [1, Inf): Number of outer resamples.
#' @param preprocessor_config Optional `SupervisedPreprocessorConfig` object.
#' @param decomposition_config Optional `DecompositionConfig` object.
#' @param hyperparameters `Hyperparameters` object.
#' @param tuner_config Optional `TunerConfig` object.
#' @param execution_config `ExecutionConfig` object: Config the nested `train()` runs under.
#' Sequential when folds are dispatched in parallel; otherwise the run's own, so the fold
#' can spend the workers on whichever inner level the ladder assigned them to.
#' @param weights Optional vector of case weights.
#' @param question Optional Character: The question the model answers.
#' @param fatal Logical: If TRUE, a fold failure is re-raised rather than returned.
#' @param parallel Logical: If TRUE, folds are dispatched in parallel and each fold builds
#' its own session for the host to graft.
#' @param verbosity Integer: Verbosity level.
#'
#' @return Function of `(task)`, where `task` is a list with the fold number `fold` and its
#' training indices `resample`, returning a list with `model`, `failed`, `error`,
#' `t_start`, `t_end`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
make_outer_fold_runner <- function(
  x,
  n_outer,
  preprocessor_config,
  decomposition_config,
  hyperparameters,
  tuner_config,
  execution_config,
  weights,
  question,
  fatal,
  parallel,
  verbosity
) {
  force(x)
  force(n_outer)
  force(preprocessor_config)
  force(decomposition_config)
  force(hyperparameters)
  force(tuner_config)
  force(execution_config)
  force(weights)
  force(question)
  force(fatal)
  force(parallel)
  force(verbosity)
  function(task) {
    i <- task[["fold"]]
    res_i <- task[["resample"]]
    if (parallel) {
      saved_session <- live[["session"]]
      live[["session"]] <- NULL
      on.exit(live[["session"]] <- saved_session, add = TRUE)
    }
    live[["fold"]] <- i
    on.exit(live[["fold"]] <- NULL, add = TRUE)
    fold_node <- node_enter(
      "outer_fold",
      label = paste0(i, "/", n_outer),
      meta = list(fold = i)
    )
    t_start <- Sys.time()
    model <- tryCatch(
      train(
        x = x[res_i, ],
        dat_test = x[-res_i, ],
        preprocessor_config = preprocessor_config,
        decomposition_config = decomposition_config,
        hyperparameters = hyperparameters,
        tuner_config = tuner_config,
        outer_resampling_config = NULL, # This model is one of the outer resamples.
        execution_config = execution_config,
        weights = if (!is.null(weights)) weights[res_i] else NULL,
        question = question,
        verbosity = verbosity
      ),
      error = function(e) e
    )
    t_end <- Sys.time()
    failed <- inherits(model, "condition")
    node_exit(
      fold_node,
      status = if (failed) "error" else "ok",
      error = if (failed) model else NULL
    )
    if (failed && fatal) {
      rtemis.core::abort(
        "Outer fold ",
        i,
        "/",
        n_outer,
        " failed: ",
        conditionMessage(model),
        class = c("rtemis_error", "rtemis_runtime_error")
      )
    }
    list(
      model = if (failed) NULL else model,
      failed = failed,
      error = if (failed) model else NULL,
      t_start = t_start,
      t_end = t_end
    )
  }
} # /rtemis::make_outer_fold_runner


# %% normalize_fold_result ----
#' Normalize one outer fold result
#'
#' A fold body returns a structured result. A condition arrives instead only when the
#' dispatch itself failed -- a task 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
#' collection loop has one thing to read.
#'
#' @param res List or condition: One element of the `progress_plapply()` result.
#'
#' @return List with `model`, `failed`, `error_message`, `t_start`, `t_end`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
normalize_fold_result <- function(res) {
  if (inherits(res, "condition")) {
    now <- Sys.time()
    return(list(
      model = NULL,
      failed = TRUE,
      error_message = conditionMessage(res),
      t_start = now,
      t_end = now
    ))
  }
  list(
    model = res[["model"]],
    failed = isTRUE(res[["failed"]]),
    error_message = if (is.null(res[["error"]])) {
      NULL
    } else {
      conditionMessage(res[["error"]])
    },
    t_start = res[["t_start"]],
    t_end = res[["t_end"]]
  )
} # /rtemis::normalize_fold_result


# %% get_n_workers ----
# Function to assign number of workers to algorithm, tuning, or outer resampling
# based on whether algorithm is parallelized, tuning is needed, and outer resampling is set.

#' Get Number of Workers
#'
#' Distribute workers across different parallelization levels: algorithm training,
#' tuning (hyperparameter combinations x inner resamples), and outer resampling. Assigns workers
#' to the innermost available parallelization level to avoid over-subscription.
#'
#' @param algorithm Character: Algorithm name.
#' @param hyperparameters `Hyperparameters` object: Setup using one of `setup_*` functions.
#' @param outer_resampling_config Optional ResamplerConfig object: Setup using
#' one of the `setup_*` resampler functions, e.g. [setup_KFold].
#' @param n_workers Integer: Total number of workers you want to use.
#' @param n_workers_outer Optional Integer: Workers for outer resampling, named by the
#' caller. Any named level switches off the priority assignment described below.
#' @param n_workers_tuning Optional Integer: Workers for tuning, named by the caller.
#' @param n_workers_algorithm Optional Integer: Threads for a self-parallelizing
#' algorithm, named by the caller.
#' @param backend Character: Execution backend label for the resources line.
#' @param device Character: Compute device label for the resources line, from
#' `describe_device()`.
#' @param verbosity Integer: Verbosity level.
#'
#' @details
#' Prints one line stating every resource the run may use: the compute device,
#' the backend, the worker ceiling and each level's share, and whether the
#' shares were set by the caller.
#'
#' With no level named, the function prioritizes parallelization levels as follows:
#' 1. If algorithm is parallelized (e.g., LightGBM, Ranger): all workers go to algorithm
#' 2. Else if tuning is needed: all workers go to tuning (inner resampling)
#' 3. Else if outer resampling is set: all workers go to outer resampling
#' 4. Else: sequential execution (1 worker each)
#'
#' @return Named list with the number of workers for each level:
#' - `algorithm`: Number of workers for algorithm training.
#' - `tuning`: Number of workers for tuning (if applicable).
#' - `outer_resampling`: Number of workers for outer resampling (if applicable).
#'
#' @keywords internal
#' @noRd
get_n_workers <- function(
  algorithm,
  hyperparameters,
  outer_resampling_config,
  n_workers,
  n_workers_outer = NULL,
  n_workers_tuning = NULL,
  n_workers_algorithm = NULL,
  backend = "serial",
  device = "CPU",
  verbosity = 1L
) {
  # Input validation
  stopifnot(
    is.character(algorithm),
    length(algorithm) == 1L,
    is.numeric(n_workers),
    n_workers >= 1L,
    n_workers == as.integer(n_workers)
  )

  # Check parallelization conditions
  is_parallelized <- algorithm %in% live[["parallelized_learners"]]
  requires_tuning <- needs_tuning(hyperparameters)
  requires_resampling <- !is.null(outer_resampling_config)

  # Named levels ----
  # Any override switches the ladder off for every level, so the assignment is either
  # entirely the caller's or entirely inferred, never half of each. A level that cannot
  # run -- tuning with nothing to tune, folds with no outer resampler, threads for an
  # algorithm that has none -- is clamped to 1 and said so: the config outlives any one
  # run, and the same one is reasonably pointed at data that does and does not tune.
  named <- list(
    algorithm = n_workers_algorithm,
    tuning = n_workers_tuning,
    outer_resampling = n_workers_outer
  )
  if (!all(vapply(named, is.null, logical(1L)))) {
    applies <- c(
      algorithm = is_parallelized,
      tuning = requires_tuning,
      outer_resampling = requires_resampling
    )
    out <- lapply(names(named), function(level) {
      asked <- named[[level]] %||% 1L
      if (asked > 1L && !applies[[level]]) {
        msg0(
          "Ignoring n_workers_",
          if (level == "outer_resampling") "outer" else level,
          " = ",
          asked,
          ": this run has no ",
          switch(
            level,
            algorithm = "self-parallelizing algorithm",
            tuning = "hyperparameters to tune",
            outer_resampling = "outer resampling"
          ),
          ".",
          verbosity = verbosity
        )
        return(1L)
      }
      asked
    })
    names(out) <- names(named)
    msg_resources(
      backend = backend,
      n_workers = n_workers,
      workers = out,
      explicit = TRUE,
      device = device,
      verbosity = verbosity
    )
    return(out)
  }

  # Assign workers to innermost parallelization level to avoid over-subscription
  if (is_parallelized) {
    # Parallelized algorithms get all workers, disable other parallelization
    workers_algorithm <- n_workers
    workers_tuning <- 1L
    workers_outer_resampling <- 1L
    if (verbosity > 1L && (requires_tuning || requires_resampling)) {
      msg(
        bold(algorithm),
        "is parallelized. Disabling tuning parallelization."
      )
    }
  } else if (requires_tuning) {
    # Tuning gets all workers if algorithm is not parallelized
    workers_algorithm <- 1L
    workers_tuning <- n_workers
    workers_outer_resampling <- 1L
    if (requires_resampling) {
      msg(
        "Tuning parallelization enabled.",
        verbosity = verbosity
      )
    }
  } else if (requires_resampling) {
    # Outer resampling gets all workers if no tuning needed
    workers_algorithm <- 1L
    workers_tuning <- 1L
    workers_outer_resampling <- n_workers
  } else {
    # Sequential execution
    workers_algorithm <- 1L
    workers_tuning <- 1L
    workers_outer_resampling <- 1L
  }

  out <- list(
    algorithm = workers_algorithm,
    tuning = workers_tuning,
    outer_resampling = workers_outer_resampling
  )
  msg_resources(
    backend = backend,
    n_workers = n_workers,
    workers = out,
    explicit = FALSE,
    device = device,
    verbosity = verbosity
  )
  out
} # /rtemis::get_n_workers


# %% msg_resources ----
#' Print the resources a run may use, on one line
#'
#' Device first, since it is what changes most between machines, then the
#' backend, then the worker ceiling and each level's share:
#'
#' `// MPS (auto-selected) | mirai | 2 workers: algorithm 2 threads, tuning 1, outer resampling 1`
#'
#' The algorithm's share is threads inside one worker, so it can exceed the
#' worker ceiling under serial execution. `(as set)` marks shares the caller
#' named; otherwise the worker ladder assigned them.
#'
#' @param backend Character: Execution backend label.
#' @param n_workers Integer: Worker ceiling.
#' @param workers Named list: `algorithm`, and `tuning` and `outer_resampling`
#' for a workflow that has those levels; a level absent from the list is not
#' printed.
#' @param explicit Logical: Whether the caller named the shares.
#' @param device Character: Compute device label.
#' @param verbosity Integer: Verbosity level.
#'
#' @return NULL, invisibly.
#'
#' @author EDG
#' @keywords internal
#' @noRd
msg_resources <- function(
  backend,
  n_workers,
  workers,
  explicit,
  device = "CPU",
  verbosity = 1L
) {
  sep <- gray(" | ")
  msg0(
    bold("//"),
    " ",
    highlight(device),
    sep,
    highlight(backend),
    sep,
    highlight(n_workers),
    ngettext(n_workers, " worker: ", " workers: "),
    "algorithm ",
    highlight(workers[["algorithm"]]),
    ngettext(workers[["algorithm"]], " thread", " threads"),
    if (!is.null(workers[["tuning"]])) {
      paste0(", tuning ", highlight(workers[["tuning"]]))
    },
    if (!is.null(workers[["outer_resampling"]])) {
      paste0(", outer resampling ", highlight(workers[["outer_resampling"]]))
    },
    if (explicit) gray(" (as set)"),
    verbosity = verbosity
  )
  invisible(NULL)
} # /rtemis::msg_resources


# %% describe_device ----
#' The compute device label for the resources line
#'
#' The device the algorithm will train on, from the execution config's request.
#' See `device_label()` for the notes it carries.
#'
#' @param hyperparameters `Hyperparameters` or `HyperparametersSet` object.
#' @param requested Optional `DeviceConfig` object: The execution config's
#' device.
#'
#' @return Character, e.g. `"CPU"`, `"CUDA:1"` or `"MPS"`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
describe_device <- function(hyperparameters, requested = NULL) {
  # A set is one algorithm under several configurations; the label lists each
  # member's device once.
  if (S7_inherits(hyperparameters, HyperparametersSet)) {
    return(paste(
      unique(vapply(
        hyperparameters@variants,
        describe_device,
        character(1L),
        requested = requested
      )),
      collapse = ", "
    ))
  }
  device <- training_device(hyperparameters, requested)
  device_label(
    device %||% "cpu",
    requested = requested,
    algorithm = hyperparameters@algorithm,
    chooses = !is.null(device)
  )
} # /rtemis::describe_device


# %% device_label ----
#' Label a resolved device for the resources line
#'
#' Only the device in use is named. `(auto-selected)` marks a device the
#' algorithm chose because none was requested; a request the algorithm cannot
#' honor is named beside the CPU it runs on instead, so the line never implies a
#' device the run did not use.
#'
#' @param device Character: Resolved device type.
#' @param requested Optional `DeviceConfig` object.
#' @param algorithm Character: Algorithm name.
#' @param chooses Logical: Whether the algorithm selects a device itself when
#' none is requested; an algorithm that only runs on the CPU does not.
#'
#' @return Character.
#'
#' @author EDG
#' @keywords internal
#' @noRd
device_label <- function(device, requested, algorithm, chooses) {
  label <- toupper(device)
  if (
    identical(device, "cuda") &&
      S7_inherits(requested, CUDADeviceConfig) &&
      !is.null(requested@ids)
  ) {
    label <- paste0(label, ":", requested@ids[[1L]])
  }
  if (is.null(requested)) {
    return(paste0(label, if (chooses) " (auto-selected)"))
  }
  if (!identical(requested@type, device)) {
    return(paste0(
      label,
      " (",
      toupper(requested@type),
      " requested; ",
      algorithm,
      " cannot use it)"
    ))
  }
  label
} # /rtemis::device_label

Try the rtemis package in your browser

Any scripts or data that you put into this service are public.

rtemis documentation built on Oct. 20, 2026, 1:06 a.m.