Nothing
# validate_data.R
# ::rtemis::
# 2026- EDG rtemis.org
# The data half of `validate_config()`: the checks a schema cannot express,
# because they concern the dataset.
#
# Each check reads a `DataProfile`, a bounded document any language can compute
# in one pass, so a rule expressed over its fields runs outside R as well. `missing_after_preprocessing()`
# reads the rows in one case -- a threshold on a standalone
# `PreprocessorConfig` -- and says so where it is defined; a supervised config
# cannot reach it.
#
# Each check is written over the *parts* a config carries -- a preprocessor, a
# resampler, an algorithm, an outcome. A
# `SuperConfig` carries all of them and gets every applicable check; a
# standalone `PreprocessorConfig` carries one and gets the checks about it.
# `config_parts()` gives every family one code path and decides a check's
# scope: a check whose parts are absent does not run.
# %% config_parts ----
#' Pull the validatable parts out of any rtemis config
#'
#' A config family decides which parts exist, not which checks are written. A
#' part that a family does not carry comes back NULL and the checks about it are
#' skipped.
#'
#' @param config Resolved rtemis config object.
#'
#' @return Named list with elements `preprocessor_config`,
#' `decomposition_config`, `resamplers` (named list of `ResamplerConfig`,
#' keyed by the JSON Pointer of the property holding each),
#' `hyperparameters`, `positive_class`, and `supervised`. Each is NULL, or
#' empty, where the config does not carry it.
#'
#' @author EDG
#' @keywords internal
#' @noRd
config_parts <- function(config) {
out <- list(
preprocessor_config = NULL,
decomposition_config = NULL,
clustering_config = NULL,
# Whether the run reads a numeric matrix, so that a column it cannot read
# is not a feature of it. True for the clustering and decomposition
# pipeline documents; see below.
numeric_features_only = FALSE,
resamplers = list(),
hyperparameters = NULL,
positive_class = NULL,
# The predictors the config names, or NULL for rtemis's convention: every
# column except the outcome.
features = NULL,
# Whether the config is for a run that designates an outcome, which decides
# whether the last column means anything (see `resolve_outcome()`). The
# `hyperparameters` block is the marker: a decomposition or clustering
# config has none, because those methods model every column jointly.
supervised = "hyperparameters" %in% names(S7_class(config)@properties)
)
# Either preprocessor config standing on its own. They are siblings, so
# neither test implies the other.
if (
S7_inherits(config, PreprocessorConfig) ||
S7_inherits(config, SupervisedPreprocessorConfig)
) {
out[["preprocessor_config"]] <- config
return(out)
}
# Read by property name. `SuperConfigPaths` and
# `SuperConfigTabular` inherit the same blocks under the same names from
# their shared `SuperConfig` parent and differ only in how the data reaches
# them, so a test keyed on the concrete class would validate the portable
# recipe and quietly find nothing in the tabular one.
out[["features"]] <- config_prop(config, "features")
out[["preprocessor_config"]] <- config_prop(config, "preprocessor_config")
out[["decomposition_config"]] <- config_prop(config, "decomposition_config")
out[["hyperparameters"]] <- config_prop(config, "hyperparameters")
out[["positive_class"]] <- config_prop(config, "positive_class")
out[["clustering_config"]] <- config_prop(config, "clustering_config")
# A clustering or decomposition run reads a numeric matrix, and its document
# names the columns inside the algorithm's own config, because there the
# selection is part of what the algorithm is given (`cluster/r/v1`,
# `decompose/r/v1`). Reading only the top level made every excluded column a
# feature of the run: a clustering plan naming 122 numeric columns came back
# with FEATURE_TYPE_UNSUPPORTED for the date column it had left out.
#
# A supervised run's own `decomposition_config` is a step inside it, not the
# run, so the block is read only where there is no `hyperparameters`.
unsupervised_family <- if (out[["supervised"]]) {
NULL
} else {
out[["clustering_config"]] %||% out[["decomposition_config"]]
}
out[["numeric_features_only"]] <- !is.null(unsupervised_family)
if (is.null(out[["features"]]) && !is.null(unsupervised_family)) {
out[["features"]] <- config_prop(unsupervised_family, "features")
}
# How the config's files are read. A delimited file carries no types, so this
# is what decides whether a column of labels reaches the learner as a factor
# or as an unusable string. NULL for a config with no paths -- a
# `SuperConfigTabular` holds a frame whose types are already decided -- and the
# effective dtype below then reads the profile unchanged.
out[["character2factor"]] <- config_prop(config, "character2factor")
# Both resamplers partition the same cases and fail the same way, so both are
# checked. Each is keyed by the JSON Pointer of the property that holds it, so
# a finding's `fix` patches the one it is about.
outer <- config_prop(config, "outer_resampling_config")
if (!is.null(outer)) {
out[["resamplers"]][["/outer_resampling_config"]] <- outer
}
tuner <- config_prop(config, "tuner_config")
if (
!is.null(tuner) &&
S7_inherits(tuner, GridSearchConfig) &&
!is.null(tuner@resampler_config)
) {
out[["resamplers"]][["/tuner_config/resampler_config"]] <-
tuner@resampler_config
}
out
} # /rtemis::config_parts
# %% config_prop ----
#' One property of a config, or NULL if the class does not declare it
#'
#' @param config Resolved rtemis config object.
#' @param name Character: Property name.
#'
#' @return The property's value, or NULL.
#'
#' @author EDG
#' @keywords internal
#' @noRd
config_prop <- function(config, name) {
if (!name %in% names(S7_class(config)@properties)) {
return(NULL)
}
prop(config, name)
} # /rtemis::config_prop
# %% profile_columns ----
#' The profile's column table
#'
#' @param profile `DataProfile` object.
#'
#' @return `data.frame` with `name`, `dtype`, `n_distinct`, `n_missing`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
profile_columns <- function(profile) {
profile@columns
} # /rtemis::profile_columns
# %% profile_field ----
#' One measured field of one column, or NULL
#'
#' @param profile `DataProfile` object.
#' @param column Character: Column name.
#' @param field Character: One of the `columns` table's fields.
#'
#' @return The value, or NULL when the column is not in the profile.
#'
#' @author EDG
#' @keywords internal
#' @noRd
profile_field <- function(profile, column, field) {
if (is.null(column)) {
return(NULL)
}
cols <- profile_columns(profile)
i <- match(column, cols[["name"]])
if (is.na(i)) {
return(NULL)
}
cols[[field]][[i]]
} # /rtemis::profile_field
# %% effective_dtype ----
#' The dtype a column will have when the run reads it
#'
#' The profile describes the dataset as it is; the config says how it will be
#' read. A `string` column under `character2factor` reaches the learner as a
#' factor, so every check that branches on dtype uses the effective one, and the
#' same file validates identically whichever reader measured it.
#'
#' Nothing else is translated. This is the one conversion a config can declare
#' that changes a column's *kind*; the rest change values within a kind.
#'
#' @param profile `DataProfile` object.
#' @param column Character: Column name.
#' @param parts Named list from `config_parts()`.
#'
#' @return Character dtype, or NULL when the column is not in the profile.
#'
#' @author EDG
#' @keywords internal
#' @noRd
effective_dtype <- function(profile, column, parts) {
observed <- profile_field(profile, column, "dtype")
if (is.null(observed)) {
return(NULL)
}
if (identical(observed, "string") && isTRUE(parts[["character2factor"]])) {
return("categorical")
}
observed
} # /rtemis::effective_dtype
# %% profile_level_counts ----
#' Observed level counts for one column
#'
#' Empty where the column is not categorical, and *also* empty where it has more
#' than `PROFILE_MAX_LEVELS` levels -- the profile omits those deliberately. A
#' caller that needs to tell the two apart compares against `n_distinct`.
#'
#' @param profile `DataProfile` object.
#' @param column Character: Column name.
#'
#' @return `data.frame` with `level` and `n`, possibly with no rows.
#'
#' @author EDG
#' @keywords internal
#' @noRd
profile_level_counts <- function(profile, column) {
counts <- profile@level_counts
if (is.null(column) || is.null(counts) || NROW(counts) == 0L) {
return(data.frame(level = character(), n = integer()))
}
counts[counts[["column"]] == column, c("level", "n"), drop = FALSE]
} # /rtemis::profile_level_counts
# %% validate_data ----
#' Run every applicable data check for one config
#'
#' @param config Resolved rtemis config object.
#' @param data tabular data: The dataset the config would run on. Profiled
#' here; only `check_missing_incompatible()` reads the rows themselves.
#' @param outcome Optional Character: Name of the outcome column.
#' @param step Optional Integer [1, Inf): Position in the plan.
#'
#' @return List of `Diagnostic` objects, in the order the checks were made.
#'
#' @author EDG
#' @keywords internal
#' @noRd
validate_data <- function(config, data, outcome = NULL, step = NULL) {
parts <- config_parts(config)
dat <- as.data.table(data)
# Duplicates are the one whole-table scan and no check reads them, so the
# profile a validation makes is the cheap one.
profile <- data_profile(dat, n_duplicates = FALSE)
# Outcome ----
# Resolving the outcome comes first and can end the pass: every check below
# that reads it would otherwise report on a column that is not there.
resolution <- resolve_outcome(profile, outcome, parts[["supervised"]])
if (!is.null(resolution[["diagnostic"]])) {
return(list(set_step(resolution[["diagnostic"]], step)))
}
outcome_name <- resolution[["name"]]
# What the run will train on, which is not the same as what the table holds.
# A config that names its predictors excludes the rest, so a finding about a
# column it excluded would be true of the dataset and false of the run -- and
# a finding the user can see is wrong is one they learn to argue with.
columns <- profile_columns(profile)
feature_names <- setdiff(columns[["name"]], outcome_name)
if (!is.null(parts[["features"]])) {
feature_names <- intersect(feature_names, parts[["features"]])
} else if (isTRUE(parts[["numeric_features_only"]])) {
# Unset on a clustering or decomposition run means every numeric column:
# that is what `cluster()` and `decomp()` resolve it to, so a date column is
# not a feature of the run and a finding about its type would be about the
# table.
feature_names <- intersect(
feature_names,
columns[["name"]][columns[["dtype"]] %in% NUMERIC_DTYPES]
)
}
out <- c(
check_outcome_type(profile, outcome_name, parts),
unlist(
lapply(
names(parts[["resamplers"]]),
function(pointer) {
resampler <- parts[["resamplers"]][[pointer]]
c(
check_resample_min_class(
resampler,
profile,
outcome_name,
pointer,
parts
),
check_resample_n_rows(resampler, profile@n_rows, pointer)
)
}
),
recursive = FALSE
),
check_feature_constant(profile, feature_names, parts),
check_feature_type(profile, feature_names, parts),
check_dim_p_gt_n(profile, feature_names, parts),
check_missing_incompatible(profile, dat, feature_names, outcome_name, parts)
)
out <- Filter(Negate(is.null), out)
lapply(out, set_step, step = step)
} # /rtemis::validate_data
# %% set_step ----
#' Record a finding's position in the plan
#'
#' Set once, where the findings are collected, so that no check has to carry the
#' argument through to every `new_diagnostic()` call it makes.
#'
#' @param x `Diagnostic` object.
#' @param step Optional Integer [1, Inf): Position in the plan.
#'
#' @return `Diagnostic` object.
#'
#' @author EDG
#' @keywords internal
#' @noRd
set_step <- function(x, step) {
if (is.null(step)) {
return(x)
}
x@step <- step
x
} # /rtemis::set_step
# %% resolve_outcome ----
#' Resolve which column of the data is the outcome
#'
#' A named `outcome` must be a column of the data, whatever the config is for.
#' An unnamed one takes rtemis's convention, the last column -- but only for a
#' config that designates an outcome at all: a decomposition or clustering run
#' models every column jointly, and calling the last one an outcome there would
#' silently drop a column from the feature checks.
#'
#' Data with a single column likewise has no outcome to resolve against
#' features, so the outcome is left unresolved and every check that reads it is
#' skipped.
#'
#' @param profile `DataProfile` object for the dataset.
#' @param outcome Optional Character: Name of the outcome column.
#' @param supervised Logical: Whether the config designates an outcome.
#'
#' @return Named list with `name` (Character or NULL) and `diagnostic`
#' (`Diagnostic` or NULL). A non-NULL `diagnostic` means the outcome could not
#' be resolved and the pass should end.
#'
#' @author EDG
#' @keywords internal
#' @noRd
resolve_outcome <- function(profile, outcome, supervised) {
nms <- profile_columns(profile)[["name"]]
if (is.null(outcome)) {
return(list(
name = if (supervised && length(nms) >= 2L) nms[[length(nms)]] else NULL,
diagnostic = NULL
))
}
if (length(outcome) != 1L || !outcome %in% nms) {
return(list(
name = NULL,
diagnostic = new_diagnostic(
code = "OUTCOME_MISSING",
severity = "error",
message = paste0(
"Outcome column '",
paste(outcome, collapse = "', '"),
"' is not in the data. Columns are: ",
paste0("'", nms, "'", collapse = ", "),
"."
),
evidence = list(
outcome = outcome,
columns = nms,
nearest = Filter(nzchar, nearest_hint(outcome, nms))
)
)
))
}
list(name = outcome, diagnostic = NULL)
} # /rtemis::resolve_outcome
# %% SUPERVISED_OUTCOME_DTYPES ----
# Profile dtypes `check_supervised()` accepts as an outcome. The R side states
# the same set as `inherits(x, c("integer", "numeric", "factor"))`; this is that
# set in the profile's vocabulary, and the two must change together.
SUPERVISED_OUTCOME_DTYPES <- c("integer", "number", "categorical")
# Profile dtypes `check_numeric_or_factor()` accepts as a predictor. Narrower
# than the outcome set only in that it is the same set -- both require numeric
# or factor -- but they are separate rules in `check_supervised()` and are kept
# separate here so a change to one does not silently move the other.
SUPERVISED_FEATURE_DTYPES <- c("integer", "number", "categorical")
# %% check_outcome_type ----
#' Is the outcome the kind of column this config predicts?
#'
#' Two independent ways the pair can disagree, and only the first is fatal:
#'
#' - The column is a type rtemis cannot use as an outcome at all. A character
#' column is the common case: it looks categorical but `check_supervised()`
#' rejects it, because a factor's *level order* is what decides the positive
#' class and a character vector has none.
#' - The config declares a task the column cannot serve. `positive_class` is a
#' binary-classification setting, so it names a factor level; on a numeric
#' outcome rtemis infers regression and the setting is a statement about a
#' question the run is not asking. An algorithm that performs only one of the
#' two tasks says the same thing about itself. A warning, because the run
#' completes with the setting ignored.
#'
#' @param profile `DataProfile` object for the dataset.
#' @param outcome_name Character or NULL: The outcome column.
#' @param parts Named list from `config_parts()`.
#'
#' @return List of `Diagnostic` objects, possibly empty.
#'
#' @author EDG
#' @keywords internal
#' @noRd
check_outcome_type <- function(profile, outcome_name, parts) {
if (is.null(outcome_name)) {
return(list())
}
observed <- effective_dtype(profile, outcome_name, parts)
if (!observed %in% SUPERVISED_OUTCOME_DTYPES) {
return(list(new_diagnostic(
code = "OUTCOME_TYPE_MISMATCH",
severity = "error",
message = paste0(
"Outcome '",
outcome_name,
"' holds ",
observed,
" values; rtemis requires an integer, numeric, or factor outcome. ",
"Convert it to a factor to classify, or to a number to regress."
),
evidence = list(outcome = outcome_name, outcome_dtype = observed)
)))
}
data_task <- if (observed == "categorical") "Classification" else "Regression"
declared <- declared_task(parts)
if (is.null(declared) || identical(declared, data_task)) {
return(list())
}
list(new_diagnostic(
code = "OUTCOME_TYPE_MISMATCH",
# A warning: `train()` ignores `positive_class` on a numeric outcome and
# completes. A portable recipe reused across a classification and a
# regression dataset reaches this routinely. The run answers the question
# the *data* poses, which can differ from the one the config states; the
# caller sees the warning and decides.
severity = "warning",
message = paste0(
"Config declares ",
tolower(declared),
" (",
declared_task_reason(parts),
") but outcome '",
outcome_name,
"' holds ",
observed,
" values, which rtemis reads as ",
tolower(data_task),
"."
),
evidence = list(
outcome = outcome_name,
outcome_dtype = observed,
declared_task = declared,
data_task = data_task
)
))
} # /rtemis::check_outcome_type
# %% declared_task ----
#' The task a config states it is for, or NULL if it states none
#'
#' rtemis infers the task from the outcome, so most configs declare nothing and
#' NULL is the common answer. Only a setting that is meaningless under the other
#' task counts as a declaration: `positive_class` names a factor level, and an
#' algorithm that performs one task performs one task. A hyperparameter that
#' merely *implies* a task (an objective, a family) is not read here -- each
#' would need its own per-algorithm mapping, and a wrong reading of one would
#' reject a config that runs.
#'
#' @param parts Named list from `config_parts()`.
#'
#' @return Character \{"Classification", "Regression"\} or NULL.
#'
#' @author EDG
#' @keywords internal
#' @noRd
declared_task <- function(parts) {
if (!is.null(parts[["positive_class"]])) {
return("Classification")
}
algorithm <- config_algorithm(parts)
if (is.null(algorithm)) {
return(NULL)
}
idx <- match(
tolower(algorithm),
tolower(supervised_algorithms[["name"]])
)
if (is.na(idx)) {
return(NULL)
}
does_class <- as.logical(supervised_algorithms[["classification"]][[idx]])
does_reg <- as.logical(supervised_algorithms[["regression"]][[idx]])
if (isTRUE(does_class) && !isTRUE(does_reg)) {
return("Classification")
}
if (isTRUE(does_reg) && !isTRUE(does_class)) {
return("Regression")
}
NULL
} # /rtemis::declared_task
# %% declared_task_reason ----
#' Which setting made the config's task declaration, for the message
#'
#' @param parts Named list from `config_parts()`.
#'
#' @return Character.
#'
#' @author EDG
#' @keywords internal
#' @noRd
declared_task_reason <- function(parts) {
if (!is.null(parts[["positive_class"]])) {
return(paste0("`positive_class` is '", parts[["positive_class"]], "'"))
}
paste0("algorithm ", config_algorithm(parts))
} # /rtemis::declared_task_reason
# %% config_algorithm ----
#' The algorithm a config names, or NULL
#'
#' A hyperparameter *set* is a search over one algorithm, so it names one too.
#'
#' @param parts Named list from `config_parts()`.
#'
#' @return Character or NULL.
#'
#' @author EDG
#' @keywords internal
#' @noRd
config_algorithm <- function(parts) {
hp <- parts[["hyperparameters"]]
if (is.null(hp)) {
return(NULL)
}
# Both classes declare `@algorithm`; a set derives it from its first member,
# every member being a configuration of the same algorithm.
algorithm <- hp@algorithm
if (length(algorithm) != 1L || is.na(algorithm)) {
return(NULL)
}
algorithm
} # /rtemis::config_algorithm
# %% check_resample_min_class ----
#' Can the rarer class fill every resample?
#'
#' A stratified resampler puts each class into every part in the class's own
#' proportion, so a class with fewer cases than there are parts cannot reach all
#' of them. At least one part then scores a class it never saw, and the score
#' for it is not wrong so much as meaningless.
#'
#' Only stratified types are checked. A plain bootstrap draws with replacement
#' and makes no promise about class balance, so there is no count it fails to
#' meet.
#'
#' @param resampler `ResamplerConfig` object.
#' @param profile `DataProfile` object for the dataset.
#' @param outcome_name Character or NULL: The outcome column.
#' @param pointer Character: JSON Pointer of the property holding `resampler`.
#' @param parts Named list from `config_parts()`.
#'
#' @return List of `Diagnostic` objects, possibly empty.
#'
#' @author EDG
#' @keywords internal
#' @noRd
check_resample_min_class <- function(
resampler,
profile,
outcome_name,
pointer,
parts
) {
if (is.null(outcome_name)) {
return(list())
}
if (
!identical(effective_dtype(profile, outcome_name, parts), "categorical")
) {
return(list())
}
if (!resampler@type %in% c("KFold", "StratSub", "StratBoot")) {
return(list())
}
n_resamples <- resampler@n_resamples
counts <- profile_level_counts(profile, outcome_name)
if (NROW(counts) == 0L) {
# The profile omits level counts above `PROFILE_MAX_LEVELS`, so this check
# cannot run, and it reports that, because a silent skip would read as a
# clean result.
return(list(new_diagnostic(
code = "RESAMPLE_MIN_CLASS",
severity = "note",
message = paste0(
"Outcome '",
outcome_name,
"' has ",
profile_field(profile, outcome_name, "n_distinct"),
" levels, more than the ",
PROFILE_MAX_LEVELS,
" a profile carries counts for, so the class balance of the ",
resampler@type,
" resampler at `",
pointer,
"` was not checked."
),
evidence = list(
resampler = pointer,
type = resampler@type,
n_resamples = n_resamples,
n_levels = profile_field(profile, outcome_name, "n_distinct"),
max_levels = PROFILE_MAX_LEVELS
)
)))
}
min_class <- min(counts[["n"]])
if (min_class >= n_resamples) {
return(list())
}
min_level <- counts[["level"]][[which.min(counts[["n"]])]]
list(new_diagnostic(
code = "RESAMPLE_MIN_CLASS",
severity = "error",
message = paste0(
"Class '",
min_level,
"' has ",
min_class,
" ",
ngettext(min_class, "case", "cases"),
" but the ",
resampler@type,
" resampler at `",
pointer,
"` asks for ",
n_resamples,
" resamples."
),
evidence = list(
resampler = pointer,
type = resampler@type,
n_resamples = n_resamples,
min_class = min_level,
min_class_n = min_class,
# Long form, one record per level, as `profile/r/v1` carries them. A
# name -> count map cannot be iterated by an expression language, so the
# rule set could not reproduce it and the two would state the same fact
# in shapes a conformance run could not compare.
class_counts = lapply(
seq_len(NROW(counts)),
function(i) {
list(level = counts[["level"]][[i]], n = counts[["n"]][[i]])
}
)
),
# Fewer parts than the rarest class has cases is the one repair that needs
# no judgment. Below two no fold count works, so no repair is offered.
fix = if (min_class >= 2L) {
list(list(
op = "replace",
path = paste0(pointer, "/n_resamples"),
value = min_class
))
}
))
} # /rtemis::check_resample_min_class
# %% check_resample_n_rows ----
#' Are there enough rows for the resamples asked for?
#'
#' Two failures, one fatal and one merely useless. More parts than rows cannot
#' be made at all. Parts that *can* be made but hold a single case can: a score
#' computed on one case carries no information, and averaging such scores across
#' parts does not create any.
#'
#' Which quantity decides it depends on the type -- a fold count for KFold, a
#' training fraction for the subsampling types, the row count alone for the rest
#' -- so each names its own, and `Custom` names none: its resamples are supplied
#' rather than drawn, and `resample()` checks their indices against the data.
#'
#' @param resampler `ResamplerConfig` object.
#' @param n_rows Integer: Number of rows in the data.
#' @param pointer Character: JSON Pointer of the property holding `resampler`.
#'
#' @return List of `Diagnostic` objects, possibly empty.
#'
#' @author EDG
#' @keywords internal
#' @noRd
check_resample_n_rows <- function(resampler, n_rows, pointer) {
type <- resampler@type
if (type == "Custom") {
return(list())
}
base_evidence <- list(resampler = pointer, type = type, n_rows = n_rows)
if (type %in% c("LOOCV", "Bootstrap")) {
if (n_rows >= 2L) {
return(list())
}
return(list(new_diagnostic(
code = "RESAMPLE_N_ROWS",
severity = "error",
message = paste0(
"The ",
type,
" resampler at `",
pointer,
"` needs at least 2 rows; the data has ",
n_rows,
"."
),
evidence = base_evidence
)))
}
if (type == "KFold") {
n_resamples <- resampler@n_resamples
evidence <- c(base_evidence, list(n_resamples = n_resamples))
if (n_rows < n_resamples) {
return(list(new_diagnostic(
code = "RESAMPLE_N_ROWS",
severity = "error",
message = paste0(
"The KFold resampler at `",
pointer,
"` asks for ",
n_resamples,
" folds but the data has ",
n_rows,
" ",
ngettext(n_rows, "row", "rows"),
"."
),
evidence = evidence,
# The largest fold count that both fits the data and leaves a usable
# test fold: any k above `n %/% 2` gives folds of one case, which is
# what the warning below is about, so repairing to `n_rows` would only
# trade an error for a warning. Below four rows no such k exists and
# nothing is offered.
fix = if (n_rows >= 4L) {
list(list(
op = "replace",
path = paste0(pointer, "/n_resamples"),
value = n_rows %/% 2L
))
}
)))
}
n_test <- n_rows %/% n_resamples
if (n_test >= 2L) {
return(list())
}
return(list(new_diagnostic(
code = "RESAMPLE_N_ROWS",
severity = "warning",
message = paste0(
"The KFold resampler at `",
pointer,
"` leaves ",
n_test,
" ",
ngettext(n_test, "case", "cases"),
" per test fold (",
n_rows,
" rows over ",
n_resamples,
" folds)."
),
evidence = c(evidence, list(n_test = n_test))
)))
}
# StratSub and StratBoot, which split by fraction rather than by count.
train_p <- resampler@train_p
n_train <- round(n_rows * train_p)
n_test <- n_rows - n_train
evidence <- c(
base_evidence,
list(train_p = train_p, n_train = n_train, n_test = n_test)
)
if (n_test < 1L || n_train < 2L) {
return(list(new_diagnostic(
code = "RESAMPLE_N_ROWS",
severity = "error",
message = paste0(
"The ",
type,
" resampler at `",
pointer,
"` splits ",
n_rows,
" ",
ngettext(n_rows, "row", "rows"),
" at train_p ",
train_p,
" into ",
n_train,
" training and ",
n_test,
" test; neither side can be empty and training needs at least 2."
),
evidence = evidence
)))
}
if (n_test >= 2L) {
return(list())
}
list(new_diagnostic(
code = "RESAMPLE_N_ROWS",
severity = "warning",
message = paste0(
"The ",
type,
" resampler at `",
pointer,
"` leaves ",
n_test,
" test case per resample (",
n_rows,
" rows at train_p ",
train_p,
")."
),
evidence = evidence
))
} # /rtemis::check_resample_n_rows
# %% check_feature_constant ----
#' Do any predictors hold one value throughout?
#'
#' A column that never varies cannot explain a column that does. rtemis trains
#' on it regardless -- so this is a warning, not an error -- but the coefficient
#' or split it produces is an artifact of the column being there, not a finding.
#'
#' A column that is entirely missing counts: `is_constant()` reads it as one
#' (there is no varying observed value) and `remove_constants` removes it, so
#' this reports what preprocessing would act on.
#'
#' Constants the config already removes are not reported: `remove_constants`
#' drops all of them and `remove_features` drops the ones it names, so a config
#' that removes them reports clean.
#'
#' @param profile `DataProfile` object for the dataset.
#' @param feature_names Character: The predictor columns.
#' @param parts Named list from `config_parts()`.
#'
#' @return List of `Diagnostic` objects, possibly empty.
#'
#' @author EDG
#' @keywords internal
#' @noRd
check_feature_constant <- function(profile, feature_names, parts) {
if (length(feature_names) == 0L) {
return(list())
}
pp <- parts[["preprocessor_config"]]
if (!is.null(pp) && pp@remove_constants) {
return(list())
}
skip_missing <- if (is.null(pp)) TRUE else pp@remove_constants_skip_missing
# `is_constant()` in the profile's terms. Skipping missing values, a column
# with at most one observed value never varies -- an entirely missing one
# included, which is what `na.exclude()` leaves. Not skipping them, a single
# `NA` makes the comparison undecidable and so not constant, which is exactly
# one observed value and nothing missing.
constant <- feature_names[vapply(
feature_names,
function(nm) {
n_distinct <- profile_field(profile, nm, "n_distinct")
if (skip_missing) {
n_distinct <= 1L
} else {
n_distinct == 1L && profile_field(profile, nm, "n_missing") == 0L
}
},
logical(1L)
)]
already <- if (is.null(pp)) {
character()
} else {
pp@remove_features %||% character()
}
constant <- setdiff(constant, already)
if (length(constant) == 0L) {
return(list())
}
list(new_diagnostic(
code = "FEATURE_CONSTANT",
severity = "warning",
message = paste0(
length(constant),
ngettext(
length(constant),
" predictor never varies",
" predictors never vary"
),
": ",
paste0("'", constant, "'", collapse = ", "),
"."
),
evidence = list(features = constant, n_features = length(constant)),
# Naming the columns is deterministic; the union with what the config
# already removes is what keeps the patch from discarding that list.
#
# With no block to patch into, two operations, since an expression language
# has no object constructor:
# this is the spelling a second implementation can also produce. RFC 6902
# applies them in order, and the result is the same document.
fix = if (is.null(pp)) {
list(
list(
op = "add",
path = "/preprocessor_config",
value = stats::setNames(list(), character())
),
list(
op = "add",
path = "/preprocessor_config/remove_features",
value = as.list(constant)
)
)
} else {
list(list(
op = "add",
path = "/preprocessor_config/remove_features",
value = as.list(union(already, constant))
))
}
))
} # /rtemis::check_feature_constant
# %% check_feature_type ----
#' Are all the predictors a type rtemis can train on?
#'
#' `check_supervised()` requires every predictor to be numeric or a factor and
#' aborts otherwise, so a character or date predictor that reaches it is a
#' guaranteed failure.
#'
#' What decides whether one reaches it is `SuperConfigPaths@character2factor`, which
#' is a *reading* convention: it runs when the file is parsed, before any check.
#' `effective_dtype()` applies it, which is why a string predictor is reported
#' only when the config declines the conversion.
#'
#' The preprocessor's own `character2factor` is a different setting and does not
#' rescue anything here: `preprocess()` is fitted per fold, inside resampling,
#' and `check_supervised()` has already run. Converting the column at that point
#' changes the data, so no config patch applies.
#'
#' @param profile `DataProfile` object for the dataset.
#' @param feature_names Character: The predictor columns.
#' @param parts Named list from `config_parts()`.
#'
#' @return List of `Diagnostic` objects, possibly empty.
#'
#' @author EDG
#' @keywords internal
#' @noRd
check_feature_type <- function(profile, feature_names, parts) {
if (length(feature_names) == 0L) {
return(list())
}
dtypes <- vapply(
feature_names,
function(nm) effective_dtype(profile, nm, parts),
character(1L)
)
unsupported <- feature_names[!dtypes %in% SUPERVISED_FEATURE_DTYPES]
if (length(unsupported) == 0L) {
return(list())
}
classes <- dtypes[unsupported]
list(new_diagnostic(
code = "FEATURE_TYPE_UNSUPPORTED",
severity = "error",
message = paste0(
length(unsupported),
ngettext(length(unsupported), " predictor is", " predictors are"),
" neither numeric nor a factor: ",
paste0("'", unsupported, "' (", classes, ")", collapse = ", "),
"."
),
evidence = list(
features = unname(unsupported),
dtypes = unname(classes),
n_features = length(unsupported)
)
))
} # /rtemis::check_feature_type
# %% check_dim_p_gt_n ----
#' Does the learner see more predictors than there are rows?
#'
#' Counted at the width the model actually fits, which is two steps from the
#' column count:
#'
#' - **Categorical encoding.** A factor contributes one column per level it
#' takes -- rtemis's `one_hot()` encodes every level, dropping no reference --
#' while a numeric or date column contributes one.
#' The profile's `n_distinct` supplies the level counts.
#' - **Decomposition.** `train()` applies `decomposition_config` before fitting,
#' so a pipeline that extracts `k` components hands the learner `k` features
#' however wide the data was. Reporting the encoded width there would state a
#' number the model never sees.
#'
#' Severity is the algorithm's answer, not a judgment: `p_gt_n` is FALSE only
#' where the fit is an unregularized least squares and goes rank-deficient, and
#' those are the runs that complete while producing aliased coefficients -- a
#' warning by the definition of the level. Everything else regularizes, selects,
#' or cannot be rank-deficient, so the situation is worth recording and nothing
#' is wrong: a note. The remedy -- a regularized algorithm, a decomposition
#' step, more rows -- is the user's choice; this reports the numbers it rests
#' on.
#'
#' @param profile `DataProfile` object for the dataset.
#' @param feature_names Character: The predictor columns.
#' @param parts Named list from `config_parts()`.
#'
#' @return List of `Diagnostic` objects, possibly empty.
#'
#' @author EDG
#' @keywords internal
#' @noRd
check_dim_p_gt_n <- function(profile, feature_names, parts) {
if (length(feature_names) == 0L) {
return(list())
}
widths <- vapply(
feature_names,
function(nm) {
if (identical(effective_dtype(profile, nm, parts), "categorical")) {
profile_field(profile, nm, "n_distinct")
} else {
1L
}
},
integer(1L)
)
encoded_p <- sum(widths)
n_rows <- profile@n_rows
decomposition <- parts[["decomposition_config"]]
effective_p <- encoded_p
decomposition_evidence <- NULL
if (!is.null(decomposition)) {
k <- config_prop(decomposition, "k")
if (!is.null(k)) {
effective_p <- k
decomposition_evidence <- list(
decomposition = decomposition@algorithm,
decomposition_k = k
)
}
}
if (effective_p <= n_rows) {
return(list())
}
algorithm <- config_algorithm(parts)
handles <- if (is.null(algorithm)) {
NA
} else {
algorithm_handles_p_gt_n(algorithm)
}
what <- if (is.null(decomposition_evidence)) {
paste0(
length(feature_names),
ngettext(
length(feature_names),
" predictor encodes",
" predictors encode"
),
" to ",
effective_p,
" columns"
)
} else {
paste0(
decomposition_evidence[["decomposition"]],
" extracts ",
effective_p,
ngettext(effective_p, " component", " components")
)
}
list(new_diagnostic(
code = "DIM_P_GT_N",
severity = if (isFALSE(handles)) "warning" else "note",
message = paste0(
what,
", more than the ",
n_rows,
" ",
ngettext(n_rows, "row", "rows"),
" available.",
if (isFALSE(handles)) {
paste0(
" ",
algorithm,
" fits an unregularized least squares, so the fit is rank-deficient."
)
} else if (!is.null(algorithm)) {
paste0(" ", algorithm, " fits in this regime.")
}
),
evidence = c(
list(
n_features = length(feature_names),
encoded_p = encoded_p,
effective_p = effective_p,
n_rows = n_rows,
categorical_features = names(widths)[widths > 1L],
algorithm = algorithm %||% NA_character_,
algorithm_handles_p_gt_n = handles
),
decomposition_evidence
)
))
} # /rtemis::check_dim_p_gt_n
# %% missing_after_preprocessing ----
#' How many gaps are left in the features once preprocessing has run
#'
#' `remove_cases_thres` and `remove_features_thres` drop what is missing *above
#' a threshold*, so whether they resolve a dataset's gaps depends on the
#' dataset. Treating them as no help reports an error on a run that completes;
#' treating them as a remedy misses one that does not. Both are wrong, and the
#' data is in hand, so this simulates them exactly instead.
#'
#' With neither threshold set the profile answers on its own, and that is the
#' only path a supervised config can take: `SupervisedPreprocessorConfig`
#' declares neither, so every check a supervised config receives is readable
#' from a profile alone, and the same rule runs outside R.
#'
#' **The one place this file reads the rows** is a threshold on a standalone
#' `PreprocessorConfig`. The case step changes each feature's missing fraction,
#' so the answer depends on the joint missingness pattern, and carrying that
#' would cost one entry per feature per missing-count bucket -- unbounded
#' exactly where it matters, on wide data. `checks/v1` declares this case
#' unevaluable.
#'
#' Mirrors `preprocess()`: cases first, at a fraction of the *feature* count
#' (`train()` preprocesses `features(x)` and re-attaches the outcome), then
#' features on the cases that remain, each dropping at `>=` the threshold. The
#' two must stay in step -- a change to either rule in `preprocess()` belongs
#' here in the same edit.
#'
#' @param profile `DataProfile` object for the dataset.
#' @param dat data.table: The dataset. Read only where a threshold is set.
#' @param feature_names Character: The predictor columns.
#' @param pp `PreprocessorConfig` or `SupervisedPreprocessorConfig`, or NULL.
#'
#' @return Integer: Missing values remaining in the features.
#'
#' @author EDG
#' @keywords internal
#' @noRd
missing_after_preprocessing <- function(profile, dat, feature_names, pp) {
if (length(feature_names) == 0L) {
return(0L)
}
remove_cases_thres <- if (is.null(pp)) {
NULL
} else {
pp_opt(pp, "remove_cases_thres")
}
remove_features_thres <- if (is.null(pp)) {
NULL
} else {
pp_opt(pp, "remove_features_thres")
}
# Nothing shrinks the gaps, so the profile already holds the answer. This is
# the only path a supervised config can take -- it declares neither threshold
# -- which is what keeps every check it gets readable from a profile alone.
if (is.null(remove_cases_thres) && is.null(remove_features_thres)) {
cols <- profile_columns(profile)
return(sum(cols[["n_missing"]][cols[["name"]] %in% feature_names]))
}
feat <- as.data.frame(dat)[, feature_names, drop = FALSE]
if (!is.null(remove_cases_thres)) {
keep <- rowSums(is.na(feat)) / NCOL(feat) < remove_cases_thres
feat <- feat[keep, , drop = FALSE]
}
if (NROW(feat) == 0L) {
return(0L)
}
if (!is.null(remove_features_thres)) {
fraction <- vapply(
feat,
function(v) sum(is.na(v)) / length(v),
numeric(1L)
)
feat <- feat[, fraction < remove_features_thres, drop = FALSE]
}
sum(is.na(feat))
} # /rtemis::missing_after_preprocessing
# %% check_missing_incompatible ----
#' Will missing values reach a run that cannot take them?
#'
#' Three ways they can, in the order they bite:
#'
#' - **A missing outcome.** No preprocessing option addresses it: `preprocess()`
#' transforms features, and `check_supervised()` rejects a training set whose
#' outcome has gaps. Always an error.
#' - **Preprocessing that cannot run on this pattern.** `complete_cases` on data
#' with no complete row leaves nothing to train on, and imputation has nothing
#' to learn from in a column that is entirely absent.
#' - **Gaps that survive to the learner.** Whether that is fatal is the
#' algorithm's own answer, read from `algorithm_allows_missing()`: an error
#' where the algorithm refuses them, a warning where the answer is not the
#' algorithm's to give (a meta learner defers to its base learners) or where
#' no algorithm is named.
#'
#' `complete_cases` and `impute` resolve the gaps outright.
#' `remove_features_thres` and `remove_cases_thres` drop what is missing above a
#' threshold, so whether they resolve *these* gaps depends on the data --
#' `missing_after_preprocessing()` simulates both.
#'
#' @param profile `DataProfile` object for the dataset.
#' @param dat data.table: The dataset. Read only by
#' `missing_after_preprocessing()`; see the note there.
#' @param feature_names Character: The predictor columns.
#' @param outcome_name Character or NULL: The outcome column.
#' @param parts Named list from `config_parts()`.
#'
#' @return List of `Diagnostic` objects, possibly empty.
#'
#' @author EDG
#' @keywords internal
#' @noRd
check_missing_incompatible <- function(
profile,
dat,
feature_names,
outcome_name,
parts
) {
if (sum(profile_columns(profile)[["n_missing"]]) == 0L) {
return(list())
}
pp <- parts[["preprocessor_config"]]
out <- list()
if (!is.null(outcome_name)) {
n_na_outcome <- profile_field(profile, outcome_name, "n_missing")
if (n_na_outcome > 0L) {
out <- c(
out,
list(new_diagnostic(
code = "MISSING_INCOMPATIBLE",
severity = "error",
message = paste0(
"Outcome '",
outcome_name,
"' has ",
n_na_outcome,
" missing ",
ngettext(n_na_outcome, "value", "values"),
". Preprocessing transforms features, so no setting removes them: ",
"drop those rows before training."
),
evidence = list(
outcome = outcome_name,
n_missing = n_na_outcome,
n_rows = profile@n_rows
)
))
)
}
}
# Whether a step runs is now the config's type: a supervised config carries no
# `PREPROCESSOR_TRAIN_EXCLUDED` property, so `pp_opt()` reports the declared
# default -- off -- and there is nothing to gate.
runs <- function(op) {
!is.null(pp) && isTRUE(pp_opt(pp, op))
}
if (runs("complete_cases")) {
n_complete <- profile@n_complete_cases
if (n_complete < 2L) {
out <- c(
out,
list(new_diagnostic(
code = "MISSING_INCOMPATIBLE",
severity = "error",
message = paste0(
"`complete_cases` keeps rows with no gaps, and only ",
n_complete,
" of ",
profile@n_rows,
" ",
ngettext(profile@n_rows, "row has", "rows have"),
" none."
),
evidence = list(n_complete = n_complete, n_rows = profile@n_rows)
))
)
}
return(out)
}
cols <- profile_columns(profile)
all_missing <- cols[["name"]][
cols[["n_distinct"]] == 0L & cols[["n_missing"]] > 0L
]
if (runs("impute")) {
if (length(all_missing) > 0L) {
out <- c(
out,
list(new_diagnostic(
code = "MISSING_INCOMPATIBLE",
severity = "error",
message = paste0(
"Imputation fills gaps from the values a column does have, and ",
paste0("'", all_missing, "'", collapse = ", "),
ngettext(length(all_missing), " has none.", " have none."),
" Drop ",
ngettext(length(all_missing), "it", "them"),
" before imputing."
),
evidence = list(
features = all_missing,
impute_type = pp@impute_type
)
))
)
}
return(out)
}
# What actually reaches the learner, with the threshold steps simulated: a
# feature that is 90% missing is dropped by `remove_features_thres = 0.5`, and
# a run whose gaps leave with it completes.
# A supervised config carries no threshold property, so nothing is credited
# there without the call site having to say so.
n_remaining <- missing_after_preprocessing(profile, dat, feature_names, pp)
if (n_remaining == 0L) {
return(out)
}
algorithm <- config_algorithm(parts)
allows <- if (is.null(algorithm)) NA else algorithm_allows_missing(algorithm)
# Named, not just counted: told only "2 columns", a reader guesses which,
# and one guessed a column that was not even a feature of the run
# (Haiku 4.5 on a clustering plan, 2026-09-22). Every other column finding
# names its columns.
with_gaps <- feature_names[vapply(
feature_names,
function(nm) profile_field(profile, nm, "n_missing") > 0L,
logical(1L)
)]
evidence <- list(
features = with_gaps,
n_missing = n_remaining,
n_features_missing = length(with_gaps),
algorithm = algorithm %||% NA_character_,
algorithm_allows_missing = allows
)
if (isTRUE(allows)) {
return(out)
}
c(
out,
list(new_diagnostic(
code = "MISSING_INCOMPATIBLE",
severity = if (isFALSE(allows)) "error" else "warning",
message = if (isFALSE(allows)) {
paste0(
algorithm,
" does not accept missing values, and nothing in this config ",
"removes them: ",
n_remaining,
" missing ",
ngettext(n_remaining, "value", "values"),
" across ",
evidence[["n_features_missing"]],
" ",
ngettext(evidence[["n_features_missing"]], "column", "columns"),
". Impute, or drop the incomplete cases before training."
)
} else {
paste0(
n_remaining,
" missing ",
ngettext(n_remaining, "value", "values"),
" across ",
evidence[["n_features_missing"]],
" ",
ngettext(evidence[["n_features_missing"]], "column", "columns"),
" reach the learner unchanged; not every algorithm accepts them."
)
},
evidence = evidence
))
)
} # /rtemis::check_missing_incompatible
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.