R/005_HyperparameterCandidates.R

Defines functions is_wire_candidates candidate_values clean_posint clean_int clean_each is_candidates tune_over

Documented in tune_over

# 005_HyperparameterCandidates.R
# ::rtemis::
# 2026- EDG rtemis.org

# The values a tuner may choose among for one hyperparameter. Four levels of
# vocabulary, each with exactly one home:
#
#   domain      what one hyperparameter may take    -- the general idea
#   candidates  a domain given by enumeration       -- this class
#   space       the product of the domains          -- implicit
#   set         a union of spaces                   -- `HyperparametersSet`
#   grid        the space enumerated, one row each  -- `tuning_grid()`
#
# A *set* is the level above a space: several whole configurations searched as
# one, for the combinations a product cannot express. `gate_tuning_grid()`
# removes the cells that are *invalid*; a set is for the ones that are valid and
# simply not the combinations meant -- LINAD's CART point is a conjunction of
# four properties that gate nothing, so no product over them can name it.
#
# The class represents an enumerated domain. A domain a tuner samples -- a
# log-uniform range, say -- shares no structure with a list of values and
# would be a sibling class, so the class name names the enumeration.
#
# A domain is a value like any other: it is stored in the property it was
# assigned to, serialized by `to_json()`, and read back by the `.list_to_*()`
# reconstructors. `tune_over()` is the only way to build one, which is what
# makes a search space unambiguous in R -- a bare vector is always a value.
#
# On the wire it is tagged -- `{"candidates": [...]}` -- within a property
# whose declaration permits tuning. Ordinary maps may use the same key. The tag
# names the *kind* of domain, leaving a sampled one to arrive beside it rather
# than in place of it. Both forms are generated from one `PropertySpec` (see
# `make_prop()` and `spec_to_schema()`).

# %% HyperparameterCandidates ----
#' The values a tuner may choose among for one hyperparameter
#'
#' Built by [tune_over()] and stored in the hyperparameter it is assigned to, so
#' that a search space is a distinct type, never inferred from a vector's
#' length.
#'
#' @param candidates List: The values to search over, one per element.
#' @param from_vector Logical: TRUE when the candidates were read out of a
#' single bare vector. A vector is also how one value of a vector-valued
#' hyperparameter is written, so that one combination cannot be inferred; this
#' records it so the hyperparameter can reject it with a correction.
#'
#' @return `HyperparameterCandidates` object.
#'
#' @author EDG
#' @export
#' @examples
#' # Built by tune_over(), not directly.
#' d <- tune_over(3L, 4L, 5L)
#' d@candidates
HyperparameterCandidates <- new_class(
  name = "HyperparameterCandidates",
  package = "rtemis",
  properties = list(
    candidates = class_list,
    from_vector = new_property(class_logical, default = FALSE)
  ),
  validator = function(self) {
    if (length(self@candidates) < 2L) {
      return(
        "must hold at least two candidates: a one-value search costs a full resampling pass and can only return the value it was given."
      )
    }
    NULL
  }
) # /rtemis::HyperparameterCandidates


# %% tune_over ----
#' Mark the values to tune a hyperparameter over
#'
#' Declares that a hyperparameter is *searched over*. It
#' is the only way to express a search space: a bare vector is always a value,
#' so nothing has to be inferred from how many elements it happens to have.
#'
#' @details
#' Give it the candidates. Several arguments are one candidate each; a single
#' argument holding several values *is* the candidates, so a grid you computed
#' can be passed straight in:
#'
#' ```r
#' setup_LightRF(max_depth = tune_over(3L, 4L, 5L))
#' setup_LightRF(lambda_l2 = tune_over(10^seq(-4, 0, length.out = 5)))
#' setup_MLP(hidden_units = tune_over(c(12L, 6L, 2L), c(14L, 12L, 6L, 2L)))
#' ```
#'
#' For a **vector-valued** hyperparameter one candidate is itself a vector, so
#' pass the candidates as separate arguments or as a list --
#' `tune_over(list(c(12L, 6L), c(24L, 12L)))`. A single bare vector there would
#' be one architecture, and is an error saying so.
#'
#' Passing it to a hyperparameter that is not tunable is an error: that
#' hyperparameter accepts a value only.
#'
#' @param ... The candidates to search over: one per argument, or a single list
#' or vector read against the type the hyperparameter declares.
#'
#' @return `HyperparameterCandidates` object.
#'
#' @author EDG
#' @export
#' @examples
#' hp <- setup_LightRF(max_depth = tune_over(3L, 4L, 5L))
#' hp@max_depth
#' # A bare vector is a value, never a search space.
#' setup_LightRF(max_depth = 3L)@max_depth
tune_over <- function(...) {
  args <- list(...)
  if (length(args) == 0L) {
    rtemis.core::abort(
      "`tune_over()` was given no candidates, so there is nothing to search.\n",
      "It marks the values a tuner should choose among, so it needs at least two.\n",
      "Set the hyperparameter to a value directly if it should not be tuned.",
      class = c("rtemis_value_error", "rtemis_input_error")
    )
  }
  if (length(args) > 1L) {
    return(HyperparameterCandidates(candidates = args))
  }
  # One argument holding the candidates. A list is unambiguous. A bare vector is
  # not, because that is also how a single value of a vector-valued
  # hyperparameter is written, so the reading is recorded and checked against
  # the hyperparameter.
  if (is.list(args[[1L]])) {
    return(HyperparameterCandidates(candidates = args[[1L]]))
  }
  HyperparameterCandidates(
    candidates = as.list(args[[1L]]),
    from_vector = TRUE
  )
} # /rtemis::tune_over


# %% is_candidates ----
#' Is a value a hyperparameter domain?
#'
#' @param x Value to test.
#'
#' @return Logical.
#'
#' @author EDG
#' @keywords internal
#' @noRd
is_candidates <- function(x) {
  S7_inherits(x, HyperparameterCandidates)
} # /rtemis::is_candidates


# %% clean_each ----
#' Clean each element of a list, naming the element that fails
#'
#' `lapply()` would do the work, but a `clean_*()` reports the value it rejected
#' by deparsing its own argument -- which under `lapply()` is the literal
#' `X[[i]]`, naming nothing a user could act on. Passing the position through
#' `arg_name` keeps the message pointing at their input.
#'
#' @param x List to clean.
#' @param cleaner Function: The `rtemis.core::clean_*()` to apply per element.
#' @param ... Passed to `cleaner`.
#'
#' @return List of the same length and names, each element cleaned.
#'
#' @author EDG
#' @keywords internal
#' @noRd
clean_each <- function(x, cleaner, ...) {
  out <- lapply(
    seq_along(x),
    function(i) cleaner(x[[i]], ..., arg_name = paste0("Element ", i))
  )
  names(out) <- names(x)
  out
} # /rtemis::clean_each


# %% clean_int ----
#' Coerce to integer, reaching inside a hyperparameter domain
#'
#' Shadows `rtemis.core::clean_int()` within this package. A `setup_*()` cleans
#' its arguments before constructing its class, and by then a value may be
#' wrapped, and the values inside the wrapper are cleaned. Two wrappers are reached into:
#'
#' - a domain, so `tune_over(3, 4, 5)` reaches an integer hyperparameter exactly
#'   as `3L` does;
#' - a list, so one element per resample or per tree is cleaned element by
#'   element, `list(c(1, 2, 3))` reaching a nested integer property as
#'   `list(1:3)` does.
#'
#' Both matter because `10` is a double in R: writing `10L` everywhere is not
#' something to ask of a user, and the property's declared type is integer, so
#' the coercion happens at this boundary.
#'
#' @param x Value, `HyperparameterCandidates` object, or list to coerce.
#' @param ... Passed to `rtemis.core::clean_int()`.
#'
#' @return `x` coerced, or the wrapper with its contents coerced.
#'
#' @author EDG
#' @keywords internal
#' @noRd
clean_int <- function(x, ...) {
  if (is_candidates(x)) {
    x@candidates <- lapply(x@candidates, rtemis.core::clean_int, ...)
    return(x)
  }
  # A data.frame is a list, and cleaning it column by column is not what any
  # caller means by it.
  if (is.list(x) && !is.data.frame(x)) {
    return(clean_each(x, rtemis.core::clean_int, ...))
  }
  rtemis.core::clean_int(x, ...)
} # /rtemis::clean_int


# %% clean_posint ----
#' Coerce to positive integer, reaching inside a hyperparameter domain
#'
#' Shadows `rtemis.core::clean_posint()` within this package, for the reason
#' given on `clean_int()`.
#'
#' @param x Value, `HyperparameterCandidates` object, or list to coerce.
#' @param ... Passed to `rtemis.core::clean_posint()`.
#'
#' @return `x` coerced, or the wrapper with its contents coerced.
#'
#' @author EDG
#' @keywords internal
#' @noRd
clean_posint <- function(x, ...) {
  if (is_candidates(x)) {
    x@candidates <- lapply(x@candidates, rtemis.core::clean_posint, ...)
    return(x)
  }
  if (is.list(x) && !is.data.frame(x)) {
    return(clean_each(x, rtemis.core::clean_posint, ...))
  }
  rtemis.core::clean_posint(x, ...)
} # /rtemis::clean_posint


# %% candidate_values ----
#' Every value a hyperparameter may take
#'
#' A domain's candidates, or the value itself when there is no domain. A check
#' that must hold for whatever the tuner eventually picks is then written once
#' over the values, whether or not tuning is in play.
#'
#' Candidates are flattened, so this suits a scalar hyperparameter. A
#' vector-valued one needs `@candidates` directly, since flattening would merge
#' its values.
#'
#' @param x Value or `HyperparameterCandidates` object.
#'
#' @return Vector of the values `x` may take.
#'
#' @author EDG
#' @keywords internal
#' @noRd
candidate_values <- function(x) {
  if (is_candidates(x)) {
    unlist(x@candidates, use.names = FALSE)
  } else {
    x
  }
} # /rtemis::candidate_values


# %% is_wire_candidates ----
#' Is this the wire form of a hyperparameter domain?
#'
#' The tag `wire_value()` writes, `{"candidates": [...]}`. Shared so the key is
#' named in one place: `from_wire()` rebuilds the R object from it, and the
#' record writer reads it to tell a hyperparameter the run *searched* from one
#' it derived -- that record works in wire values, never in R objects.
#' Callers interpreting the tag must also check the property's tunability.
#'
#' @param x Value to test, as parsed from JSON or produced by `wire_value()`.
#'
#' @return Logical.
#'
#' @author EDG
#' @keywords internal
#' @noRd
is_wire_candidates <- function(x) {
  identical(names(x), "candidates")
} # /rtemis::is_wire_candidates


# %% repr.HyperparameterCandidates ----
#' Represent a hyperparameter's candidates
#'
#' Plain language, not R syntax: the reader wants to know which values are being
#' tried, and `3L` or `c(48, 24)` puts literal spelling in the way of that. A
#' candidate that is itself a vector is shown parenthesized, so a list of
#' architectures stays legible as a list of architectures.
#'
#' One line, so that `repr_ls()` prints it on the hyperparameter's own line
#' alongside the values, and short, so that it fits there. The `<tune>` tag
#' occupies the slot the type tag does for a value, in the tuner color: the
#' hyperparameter holds a search space.
#'
#' @param x `HyperparameterCandidates` object.
#' @param limit Integer: Most candidates to show before eliding; -1 shows all.
#' @param output_type Character: Passed to `fmt()`.
#' @param ... Not used.
#'
#' @return Character.
#'
#' @author EDG
#' @keywords internal
#' @noRd
method(repr, HyperparameterCandidates) <- function(
  x,
  limit = 6L,
  output_type = NULL,
  ...
) {
  shown <- vapply(
    x@candidates,
    function(v) {
      one <- paste(format(v, trim = TRUE), collapse = ", ")
      if (length(v) > 1L) paste0("(", one, ")") else one
    },
    character(1L)
  )
  elided <- limit > 0L && length(shown) > limit
  if (elided) {
    shown <- shown[seq_len(limit)]
  }
  fmt(
    paste0(
      "<tune> ",
      paste(shown, collapse = ", "),
      # The count only tells the reader something the list does not when the
      # list is cut short.
      if (elided) paste0(", ... (", length(x@candidates), " values)") else ""
    ),
    col = col_tuner,
    output_type = output_type
  )
} # /rtemis::repr.HyperparameterCandidates


# %% print.HyperparameterCandidates ----
#' @author EDG
#' @keywords internal
#' @noRd
method(print, HyperparameterCandidates) <- function(
  x,
  output_type = NULL,
  ...
) {
  cat(repr(x, output_type = output_type), "\n")
  invisible(x)
} # /rtemis::print.HyperparameterCandidates

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.