Skip to contents

Train complete tidymodels workflows, preserve names with explicit schemas, or calibrate already fitted workflows. Install the optional workflow dependencies listed in DESCRIPTION; individual examples also identify their engine and assessment dependencies.

Start with vignette("workflows", package = "vennabers") for the step-by-step guide. ?vennabers_examples indexes all four example collections.

Running an example

Each heading below names an installed script. To inspect and run one:

script <- system.file("examples", "calibrated-workflow.R", package = "vennabers")
file.show(script)
source(script)

Replace the filename to run another script. The listings below show the complete code without executing it during documentation builds. Sourcing a script runs its fits and checks; resampling, tuning and composition matrices can take longer than the introductory guides.

Training binary and shared multiclass workflows

calibrated-workflow.R: Retain local preprocessing, models and calibration together, then inspect predictions and learning diagnostics.

# Installed example: optional integration; failures propagate when available.
source(system.file("examples", "assertions.R", package = "vennabers"))
packages <- c("parsnip", "workflows", "recipes", "rsample", "hardhat", "generics",
              "rlang", "tibble", "tidyselect", "withr")
if (all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) local({
  library(vennabers)
  # Development data are available for training and calibration. Assessment
  # observations below are untouched by either learning step or parameter choice.
  set.seed(12)
  data <- data.frame(x = stats::rnorm(250))
  data$truth <- factor(ifelse(stats::runif(250) < stats::plogis(data$x), "yes", "no"),
                       levels = c("yes", "no"))
  development <- data[1:200, ]
  assessment <- data[201:250, ]
  recipe <- recipes::recipe(truth ~ x, development) |>
    recipes::step_impute_median(recipes::all_numeric_predictors()) |>
    recipes::step_normalize(recipes::all_numeric_predictors())
  base <- workflows::workflow() |>
    workflows::add_recipe(recipe) |>
    workflows::add_model(parsnip::logistic_reg(engine = "glm"))
  calibrated <- calibrated_workflow(base, outcome = "truth", seed = 42)
  fitted <- generics::fit(calibrated, development)
  # Prediction needs predictors only; assessment truth is used later for scoring.
  new_data <- assessment["x"]
  probability <- predict(fitted, new_data, type = "prob")
  class <- predict(fitted, new_data, type = "class")
  details <- venn_abers_details(fitted, new_data)
  state <- workflows::extract_fit_engine(fitted)
  assert_conditions(identical(probability, details$prediction), nrow(class) == 50L,
            length(state$models) == 1L, state$event_class == "yes",
            state$folds$Inductive1$training_size + state$folds$Inductive1$calibration_size == 200L)
  # Prediction-time loss changes neither the fitted model nor its stored default.
  brier <- predict(fitted, new_data, type = "prob", loss = "brier")
  stopifnot(identical(brier, venn_abers_details(fitted, new_data, loss = "brier")$prediction),
            workflows::extract_fit_engine(fitted)$loss == "log")
  print(head(probability))
  print(head(details$details))
  print(brier_score(assessment$truth == "yes", probability$.pred_yes))
  # Local recipe statistics are inspectable through the full stored workflow.
  print(workflows::extract_recipe(state$models$Inductive1))
  # This split cannot undo earlier tuning or transformations that used calibration
  # or assessment observations. IID data and a model fixed independently of its
  # calibration observations remain assumptions; VA bounds are not confidence
  # intervals and calibration need not improve scores on a particular test set.
})

if (all(vapply(c(packages, "nnet"), requireNamespace, logical(1), quietly = TRUE))) local({
  # A shared multiclass workflow is trained once per fold complement. This R
  # plan does not establish parity with Python's separate pair-model training.
  data <- iris
  names(data)[5L] <- "truth"
  split <- withr::with_seed(33, rsample::initial_split(data, .8, strata = truth))
  development <- rsample::analysis(split)
  assessment <- rsample::assessment(split)
  base <- workflows::workflow() |>
    workflows::add_recipe(recipes::recipe(truth ~ ., development) |>
      recipes::step_normalize(recipes::all_numeric_predictors())) |>
    workflows::add_model(parsnip::multinom_reg(engine = "nnet"))
  for (classification in c("ovo", "ovr")) {
    fitted <- generics::fit(vennabers::calibrated_workflow(base, "truth", method = "cross",
      classification = classification, seed = 33, precision = 2, loss = "brier"), development)
    p <- predict(fitted, assessment, type = "prob")
    cls <- predict(fitted, assessment, type = "class")
    details <- vennabers::venn_abers_details(fitted, assessment)
    state <- workflows::extract_fit_engine(fitted)
    stopifnot(identical(p, details$prediction), nrow(cls) == nrow(assessment),
              length(state$models) == 5L, setequal(details$details$fold, names(state$models)))
    # Each diagnostic row is a binary fold result: aggregate across folds first,
    # then couple OvO pairs or normalize OvR classes. No multiclass intervals.
    print(head(details$details))
    matrix_p <- as.matrix(p)
    colnames(matrix_p) <- state$class_levels
    print(c(brier = vennabers::brier_score(assessment$truth, matrix_p),
            log_loss = vennabers::log_loss(assessment$truth, matrix_p)))
  }
})

Encoding binary names

encoded-binary.R: Preserve reserved predictor and class names through encoding, native resampling and decoded predictions.

source(system.file("examples", "check-encoded-evaluation.R", package = "vennabers"), local = TRUE)

if (all(vapply(c("parsnip", "workflows", "hardhat", "rsample", "recipes", "rlang", "tibble",
                 "withr", "generics", "tidyselect", "tune", "yardstick"),
               requireNamespace, logical(1), quietly = TRUE))) {
  development <- data.frame(x = sin(1:80), y = factor(rep(c("class", ".pred_yes", ".pred_yes", "class"), 20),
                                                    levels = c("class", ".pred_yes")))
  names(development) <- c("vaV0001", ".pred_class")
  schema <- vennabers::venn_abers_schema(development, outcome = ".pred_class", event_level = "second")
  encoded <- vennabers::venn_abers_encode(development, schema)
  # Build formulas/recipes explicitly in the encoded namespace.
  formula <- reformulate(setdiff(names(encoded), schema$encoded_outcome), schema$encoded_outcome)
  base <- workflows::workflow() |>
    workflows::add_recipe(recipes::recipe(formula, encoded) |>
                            recipes::step_normalize(recipes::all_numeric_predictors())) |>
    workflows::add_model(parsnip::logistic_reg(engine = "glm"))
  model <- vennabers::calibrated_workflow(base, schema$encoded_outcome, schema = schema, seed = 42)
  fitted <- generics::fit(model, encoded)
  new_data <- vennabers::venn_abers_encode(development[1:3, "vaV0001", drop = FALSE], schema)
  probability <- vennabers::venn_abers_decode(predict(fitted, new_data, type = "prob"), schema)$probability
  class <- vennabers::venn_abers_decode(predict(fitted, new_data, type = "class"), schema)$class
  details <- vennabers::venn_abers_decode(vennabers::venn_abers_details(fitted, new_data), schema)$details
  stopifnot(identical(colnames(probability), c("class", ".pred_yes")), length(class) == 3L)

  # Preserve prescribed memberships and repeat/fold IDs when rebuilding on encoded data.
  original_outer <- withr::with_seed(41, rsample::vfold_cv(development, v = 2, repeats = 2))
  outer <- rsample::manual_rset(lapply(original_outer$splits, function(s) {
    rsample::make_splits(list(analysis = as.integer(s, data = "analysis"),
                             assessment = as.integer(s, data = "assessment")), encoded)
  }), original_outer[c("id", "id2")])
  control <- tune::control_resamples(save_pred = TRUE, extract = workflows::extract_fit_engine,
    event_level = schema$event_level, parallel_over = "resamples", allow_par = FALSE, pkgs = c("vennabers", "stats"))
  evaluation <- withr::with_seed(43, tune::fit_resamples(model, outer,
    metrics = yardstick::metric_set(yardstick::brier_class, yardstick::mn_log_loss), control = control))
  checked <- check_encoded_evaluation(evaluation, outer, schema)
  # Truth and original outcome never overwrite .pred_class or the outer IDs.
  stopifnot(identical(checked$decoded$outcome, ".pred_class"), nrow(checked$decoded$identity) == 160L,
            all(vapply(tune::collect_extracts(evaluation)$.extracts, function(s) identical(s$schema, schema), logical(1))))
}

Encoding multiclass and regression names

encoded-multiclass-regression.R: Use explicit schemas for shared OvO/OvR and regression with Inductive/Cross calibration and native evaluation.

# Installed examples: shared OvO/OvR and regression, Inductive and Cross.
source(system.file("examples", "check-encoded-evaluation.R", package = "vennabers"), local = TRUE)
if (all(vapply(c("parsnip", "workflows", "hardhat", "rsample", "recipes", "rlang", "tibble",
                 "withr", "generics", "tidyselect", "tune", "yardstick", "nnet"),
               requireNamespace, logical(1), quietly = TRUE))) {
  examples <- list()
  for (classification in c("ovo", "ovr", "regression")) {
    regression <- classification == "regression"
    development <- data.frame(x = sin(1:120), y = if (regression) 3 + sin(1:120) + cos(1:120) else
      factor(rep(c("class", ".pred_yes", "ümlaut"), 40), levels = c("ümlaut", "class", ".pred_yes")))
    names(development) <- c("vaV0001", if (regression) ".pred" else ".pred_class")
    schema <- if (regression) vennabers::venn_abers_schema(development, ".pred") else
      vennabers::venn_abers_schema(development, ".pred_class", classification = classification)
    encoded <- vennabers::venn_abers_encode(development, schema)
    formula <- reformulate(names(encoded)[1], schema$encoded_outcome)
    base <- workflows::workflow() |>
      workflows::add_recipe(recipes::recipe(formula, encoded) |>
                              recipes::step_normalize(recipes::all_numeric_predictors())) |>
      workflows::add_model(if (regression) parsnip::linear_reg(engine = "lm") else
        parsnip::multinom_reg(engine = "nnet", penalty = .01))
    # Rebuild prescribed outer memberships and IDs on the encoded data.
    original_outer <- withr::with_seed(41, rsample::vfold_cv(development, v = 2, repeats = 2))
    outer <- rsample::manual_rset(lapply(original_outer$splits, function(s) {
      rsample::make_splits(list(analysis = as.integer(s, data = "analysis"),
                               assessment = as.integer(s, data = "assessment")), encoded)
    }), original_outer[c("id", "id2")])
    scoring <- if (regression) yardstick::metric_set(yardstick::rmse, yardstick::mae) else
      yardstick::metric_set(yardstick::brier_class, yardstick::mn_log_loss)
    control <- tune::control_resamples(save_pred = TRUE, extract = identity, allow_par = FALSE,
      parallel_over = "resamples", pkgs = c("vennabers", "stats", "nnet"))
    for (method in c("inductive", "cross")) {
      model <- vennabers::calibrated_workflow(base, schema$encoded_outcome, schema = schema,
                                             method = method, seed = 42)
      fitted <- generics::fit(model, encoded)
      new <- vennabers::venn_abers_encode(development[1:3, "vaV0001", drop = FALSE], schema)
      native <- predict(fitted, new, type = if (regression) "numeric" else "prob")
      decoded <- vennabers::venn_abers_decode(native, schema)
      details <- vennabers::venn_abers_decode(vennabers::venn_abers_details(fitted, new), schema)
      if (!regression) {
        class <- vennabers::venn_abers_decode(predict(fitted, new, type = "class"), schema)$class
        stopifnot(identical(colnames(decoded$probability), schema$class_levels), identical(levels(class), schema$class_levels))
      } else stopifnot(is.double(decoded$prediction), is.null(decoded$probability), is.null(decoded$class))
      evaluation <- withr::with_seed(43, tune::fit_resamples(model, outer, metrics = scoring, control = control))
      checked <- check_encoded_evaluation(evaluation, outer, schema)
      stopifnot(nrow(checked$decoded$identity) == 2L * nrow(development), is.null(checked$decoded$details))
      examples[[paste(classification, method)]] <- list(schema = schema, data = encoded, base = base,
        model = model, fit = fitted, outer = outer, evaluation = evaluation, decoded = decoded, details = details)
    }
  }
}

Reference-style data roles

reference-splits.R: Illustrate ordered holdouts and explicit fold complements on synthetic data, without claiming paper benchmark reproduction.

# Reference-style row partitions; no downloads or new splitter API.
# Inspired by the binary, multiclass and regression applications of the
# Venn-Abers papers. These small synthetic examples illustrate roles, not
# reproductions of paper benchmarks.
library(vennabers)
source(system.file("examples", "assertions.R", package = "vennabers"))

# Calibration gets ceiling(n * calibration_prop) rows; training gets the
# preceding n - ceiling(n * calibration_prop). Never sort, shuffle or stratify.
ordered_holdout <- function(data, calibration_prop = .25) {
  assert_conditions(is.numeric(calibration_prop), length(calibration_prop) == 1L,
            is.finite(calibration_prop), calibration_prop > 0, calibration_prop < 1)
  n <- nrow(data)
  cut <- n - ceiling(n * calibration_prop)
  stopifnot(cut >= 1L, cut < n)
  rsample::make_splits(list(analysis = seq_len(cut),
                           assessment = seq.int(cut + 1L, n)), data)
}

if (all(vapply(c("parsnip", "workflows", "rsample", "recipes", "nnet", "withr"),
               requireNamespace, logical(1), quietly = TRUE))) {
  # A small known membership, including rounding of 11 * .25 to 3 held rows.
  small <- data.frame(x = 11:1)
  stopifnot(identical(as.integer(ordered_holdout(small), data = "analysis"), 1:8),
            identical(as.integer(ordered_holdout(small), data = "assessment"), 9:11))

  # Non-shuffled, stratified three-fold reference assignment. Class order is
  # first appearance (b, a, c); class counts are 3, 4, 4. Remainders are spread
  # across folds (sizes 4, 4, 3). Positions within each class keep their order.
  small$truth <- factor(c("b", "b", "a", "a", "a", "c", "c", "c", "c", "b", "a"))
  stratified_held <- list(c(1L, 3L, 4L, 6L), c(2L, 5L, 7L, 8L), 9:11)
  stratified <- rsample::manual_rset(lapply(stratified_held, function(h) {
    rsample::make_splits(list(analysis = setdiff(1:11, h), assessment = h), small)
  }), c("First", "Second", "Third"))
  stopifnot(identical(sort(unlist(stratified_held)), 1:11))

  # All eight trainable paths: binary, shared OvO/OvR, and moderated regression,
  # each with an ordered holdout and ordinary non-shuffled Cross folds.
  reference_results <- list()
  for (kind in c("binary", "ovo", "ovr", "regression")) {
    data <- data.frame(x = sin(1:72), z = cos(1:72))
    data$truth <- if (kind == "regression") 10 + 2 * data$x + data$z else
      factor(rep(if (kind == "binary") c("working", "fault") else c("soil", "road", "water"),
                 length.out = nrow(data)))
    # Final assessment is excluded from model learning AND calibration.
    development <- data[1:60, ]
    assessment <- data[61:72, ]
    spec <- switch(kind, regression = parsnip::linear_reg(engine = "lm"),
                   binary = parsnip::logistic_reg(engine = "glm"),
                   parsnip::multinom_reg(engine = "nnet", penalty = .1))
    base <- workflows::workflow() |>
      workflows::add_recipe(recipes::recipe(truth ~ ., development) |>
                              recipes::step_normalize(recipes::all_numeric_predictors())) |>
      workflows::add_model(spec)
    # These are literal local positions, not forward validation. In particular,
    # the first Cross model trains on later rows 21:60.
    cross <- rsample::manual_rset(lapply(list(1:20, 21:40, 41:60), function(h) {
      rsample::make_splits(list(analysis = setdiff(1:60, h), assessment = h), development)
    }), c("Early", "Middle", "Late"))
    for (method in c("inductive", "cross")) {
      plan <- if (method == "inductive") ordered_holdout(development) else cross
      resolve <- local_calibration_splits(list(plan), names(development))
      options <- if (kind == "regression") list(m = 1L) else list(classification = kind)
      model <- do.call(calibrated_workflow, c(list(workflow = base, outcome = "truth",
        method = method, splits = resolve, seed = 42), options))
      fit <- generics::fit(model, development)
      p <- predict(fit, assessment)
      score <- if (kind == "regression") {
        c(rmse = sqrt(mean((p$.pred - assessment$truth)^2)))
      } else {
        p <- predict(fit, assessment, type = "prob")
        probability <- as.matrix(p)
        colnames(probability) <- sub("^\\.pred_", "", colnames(probability))
        c(brier = brier_score(assessment$truth, probability),
          log_loss = log_loss(assessment$truth, probability))
      }
      state <- workflows::extract_fit_engine(fit)
      reference_results[[paste(kind, method, sep = "_")]] <- list(
        model = model, fit = fit, development = development, assessment = assessment,
        prediction = p, score = score, folds = state$folds)
      stopifnot(all(is.finite(score)), identical(names(state$folds), names(state$models)))
    }
  }
  # For mixed plans, use splits = NULL with seed, or a seeded initial_split()/
  # vfold_cv() callback. Automatic classification stratifies by outcome unless
  # strata = FALSE; regression does not stratify. Same seed requires the same
  # R RNGkind, package/engine versions and model settings for reproducibility.
  # R/Python seeds alone do not prove equal assignments or model outputs.
  #
  # Machine-failure detection is classification, not a failure-time forecast.
  # Temperature forecast correction is regression; its subject does not make
  # these partitions time-series validation. No rolling window, horizon, embargo,
  # grouping or online update is introduced. Cross uses exact fold complements.
  # Scores may get worse. VA bounds are not individual confidence/prediction
  # intervals; neither dependent data nor Cross aggregates gain universal coverage.
}

Independent Inductive training and calibration sizes

inductive-sizes.R: Request 50 training and 20 calibration rows out of 100, leaving 30 unassigned rows that only a full-model refit learns from, then resolve the same sizes on every class pair of a pairwise multiclass fit.

# Independent Inductive training and calibration sizes, as in sklearn's
# train_size/test_size. Rows in neither set form the unassigned set U: the
# matched source model and the Venn-Abers calibrator never see them, while a
# full-model refit (Full1) learns on all local rows, including C and U.
library(vennabers)
source(system.file("examples", "assertions.R", package = "vennabers"))

if (all(vapply(c("parsnip", "workflows", "rsample", "withr"),
               requireNamespace, logical(1), quietly = TRUE))) {
  data <- data.frame(x = sin(1:120) + rep(c(1, 0, 0, 1), 30),
                     truth = factor(rep(c("yes", "no", "no", "yes"), 30)))
  development <- data[1:100, ]
  assessment <- data[101:120, ]
  base <- workflows::workflow() |>
    workflows::add_formula(truth ~ x) |>
    workflows::add_model(parsnip::logistic_reg(engine = "glm"))

  # 100 local rows: 50 train the source model, 20 calibrate, 30 stay unassigned.
  # Whole numbers are exact row counts; a value in (0, 1) is a proportion
  # (training rounds down, calibration rounds up). Classification draws are
  # stratified by outcome with exact totals unless strata = FALSE.
  fits <- list()
  for (mode in c("matched", "full")) {
    fits[[mode]] <- generics::fit(calibrated_workflow(base, "truth", seed = 42,
      training_size = 50, calibration_size = 20, prediction_model = mode), development)
    state <- workflows::extract_fit_engine(fits[[mode]])
    print(state)
    roles <- state$folds$Inductive1
    assert_conditions(length(roles$analysis) == 50L, length(roles$assessment) == 20L,
      length(roles$unassigned) == 30L, identical(state$calibration$Inductive1$row, roles$assessment))
    stopifnot(identical(sort(c(roles$analysis, roles$assessment, roles$unassigned)), 1:100),
      identical(unname(state$sources), if (mode == "full") "Full1" else "Inductive1"))
  }
  # Both modes calibrate on the same 20 rows; only the prediction model differs.
  stopifnot(identical(workflows::extract_fit_engine(fits$matched)$folds,
                      workflows::extract_fit_engine(fits$full)$folds))
  for (mode in names(fits)) {
    p <- predict(fits[[mode]], assessment, type = "prob")
    probability <- as.matrix(p)
    colnames(probability) <- sub("^\\.pred_", "", colnames(probability))
    print(c(mode = mode, brier = round(brier_score(assessment$truth, probability), 4)))
  }

  # A single size takes the complement, so U is empty: 70 training rows, 30 C.
  complement <- workflows::extract_fit_engine(generics::fit(calibrated_workflow(base, "truth",
    seed = 42, training_size = .7), development))
  stopifnot(lengths(complement$folds$Inductive1[c("analysis", "assessment", "unassigned")]) == c(70L, 30L, 0L))

  # Sizes are checked strictly against the local rows and never shrunk.
  message <- tryCatch(generics::fit(calibrated_workflow(base, "truth", seed = 42,
    training_size = 80, calibration_size = 30), development), error = conditionMessage)
  stopifnot(grepl("request 110 rows, but only the 100 local rows are available", message, fixed = TRUE))

  # Pairwise multiclass resolves the sizes on each class pair's rows, with U
  # per pair. Classes of 40, 30 and 20 rows give pairs of 70, 60 and 50 rows:
  # half of each pair trains, 10 rows calibrate and the rest stay unassigned.
  three <- data.frame(x = sin(1:90) + rep(c(0, .5, 1), c(40, 30, 20)),
                      truth = factor(rep(c("a", "b", "c"), c(40, 30, 20))))
  pairwise <- workflows::extract_fit_engine(generics::fit(calibrated_workflow(base, "truth",
    classification = "ovo", model_training = "pairwise", seed = 42,
    training_size = .5, calibration_size = 10), three))
  print(pairwise)
  stopifnot(identical(pairwise$parameters$effective$unassigned_size,
                      c(pair_1 = 25L, pair_2 = 20L, pair_3 = 15L)))
  # Searches whose candidates are calibrated workflows (calibration or joint
  # model tuning) resolve the sizes again on resample rows and therefore
  # accept proportions only; two-stage model searches also take row counts.
}

Own fit and predict functions

model-adapter.R: Wrap GLM, multinomial and linear regression fit/predict functions with model_adapter(), calibrate them like native engines and keep their settings bound for saved fits. The GLM callback also carries pairwise OvO, Sigmoid postprocessing and a grid search over a recipe degree and the VA loss.

# model_adapter(): your own fit(x, y) and predict(object, new_data) functions in
# a calibrated workflow, without registering a parsnip engine. The workflow keeps
# preprocessing, splits and calibration: every local fit trains a fresh model on
# its own prepared rows, and the calibrator never sees those training rows.
library(vennabers)
source(system.file("examples", "assertions.R", package = "vennabers"))

examples <- list()
if (all(vapply(c("parsnip", "workflows", "recipes", "rsample", "withr", "nnet"),
               requireNamespace, logical(1), quietly = TRUE))) {
  # Each factory binds its helpers and fixed settings locally, so a saved fit
  # carries the actual functions into a fresh R process. Package functions use
  # pkg::fun; nothing is looked up in the global environment later.
  glm_adapter <- function() model_adapter("classification",
    fit = function(x, y) list(model = stats::glm(y ~ ., data = cbind(x, y = y), family = stats::binomial()),
                              classes = levels(y)),
    predict = function(object, new_data) {
      # A binomial GLM models the second level. Return both named columns.
      p <- stats::predict(object$model, new_data, type = "response")
      matrix(c(1 - p, p), ncol = 2L, dimnames = list(NULL, object$classes))
    })
  multinom_adapter <- function(decay = 0) {
    force(decay)  # bind the value now, not a promise to the caller's variable
    model_adapter("classification",
      fit = function(x, y) nnet::multinom(y ~ ., data = cbind(x, y = y), decay = decay, trace = FALSE),
      predict = function(object, new_data) {
        # multinom drops a single row to a vector; keep one column per class.
        p <- stats::predict(object, new_data, type = "probs")
        matrix(p, nrow(new_data), length(object$lev), dimnames = list(NULL, object$lev))
      })
  }
  lm_adapter <- function() model_adapter("regression",
    fit = function(x, y) stats::lm(y ~ ., data = cbind(x, y = y)),
    predict = function(object, new_data) unname(stats::predict(object, new_data)))

  base <- function(spec, data) workflows::workflow() |>
    workflows::add_recipe(recipes::recipe(truth ~ ., data) |>
      recipes::step_normalize(recipes::all_numeric_predictors())) |>
    workflows::add_model(spec)

  # Binary: the same GLM as the native engine, so both calibrate identically.
  binary <- data.frame(x = sin(1:200) + rep(c(1, 0, 0, 1), 50), z = cos(1:200),
                       truth = factor(rep(c("yes", "no", "no", "yes"), 50), levels = c("yes", "no")))
  development <- binary[1:160, ]
  assessment <- binary[161:200, c("x", "z")]
  adapter <- calibrated_workflow(base(glm_adapter(), development), "truth", seed = 42)
  native <- calibrated_workflow(base(parsnip::logistic_reg(engine = "glm"), development), "truth", seed = 42)
  fit <- generics::fit(adapter, development)
  p <- predict(fit, assessment, type = "prob")
  print(head(p))
  stopifnot(all.equal(p, predict(generics::fit(native, development), assessment, type = "prob")))

  # Shared multiclass: the callback learns and predicts all local classes.
  classes <- factor(rep(c("a", "b", "c"), 70))
  multiclass <- data.frame(x = sin(1:210) + as.integer(classes), z = cos(1:210), truth = classes)
  shared <- calibrated_workflow(base(multinom_adapter(decay = .1), multiclass[1:180, ]), "truth",
                                classification = "ovr", method = "cross", v = 3, seed = 42)
  shared_fit <- generics::fit(shared, multiclass[1:180, ])
  print(head(predict(shared_fit, multiclass[181:210, c("x", "z")], type = "prob")))

  # Regression: one finite number per row; Full1 refits on all local rows.
  regression <- data.frame(x = sin(1:150), z = cos(1:150))
  regression$truth <- 10 + 3 * regression$x + regression$z^2
  numeric <- calibrated_workflow(base(lm_adapter(), regression[1:120, ]), "truth",
                                 prediction_model = "full", seed = 42)
  numeric_fit <- generics::fit(numeric, regression[1:120, ])
  print(head(venn_abers_details(numeric_fit, regression[121:150, c("x", "z")])$details))

  # The stored state is the object your fit() returned, inside the workflow.
  source_model <- workflows::extract_fit_engine(workflows::extract_fit_engine(fit)$models$Inductive1)
  assert_conditions(inherits(source_model, "vennabers_adapter_fit"),
    inherits(source_model$state$model, "glm"),
    length(source_model$state$model$y) == length(workflows::extract_fit_engine(fit)$folds$Inductive1$analysis))

  # Class pairs: the binary GLM callback carries pairwise OvO. Each call sees
  # one pair's rows, with its two original levels. Overlapping classes keep
  # every pair's GLM away from complete separation.
  overlap <- transform(multiclass, x = x + .5 * sin(1:210))
  pairwise <- calibrated_workflow(base(glm_adapter(), overlap[1:180, ]), "truth",
                                  classification = "ovo", model_training = "pairwise", seed = 42)
  pairwise_fit <- generics::fit(pairwise, overlap[1:180, ])
  print(head(predict(pairwise_fit, overlap[181:210, c("x", "z")], type = "prob")))

  # Postprocessing: B fits the callback, disjoint P learns Sigmoid on its
  # probabilities, C learns Venn-Abers. The glm_link input needs the native GLM.
  sigmoid <- list(method = "sigmoid", sigmoid_input = "probability", strategy = "holdout", prop = .3)
  post_fit <- generics::fit(calibrated_workflow(base(glm_adapter(), development), "truth", seed = 42,
                                                postprocessing = sigmoid), development)
  stopifnot(all.equal(predict(post_fit, assessment, type = "prob"),
    predict(generics::fit(calibrated_workflow(base(parsnip::logistic_reg(engine = "glm"), development), "truth",
                                              seed = 42, postprocessing = sigmoid), development), assessment, type = "prob")))

  examples <- list(
    binary = list(data = binary, model = adapter, fit = fit, new = assessment),
    multiclass = list(data = multiclass, model = shared, fit = shared_fit, new = multiclass[181:210, c("x", "z")]),
    regression = list(data = regression, model = numeric, fit = numeric_fit, new = regression[121:150, c("x", "z")]))

  # Searches tune calibration and recipe parameters. Callback settings are fixed
  # in the functions, so the adapter itself declares no tune() parameters.
  if (all(vapply(c("tune", "yardstick"), requireNamespace, logical(1), quietly = TRUE))) {
    poly <- workflows::workflow() |>
      workflows::add_recipe(recipes::recipe(truth ~ x + z, development) |>
        recipes::step_poly(x, degree = tune::tune("degree"))) |>
      workflows::add_model(glm_adapter())
    folds <- function(data) rsample::vfold_cv(data, v = 2)
    searched <- calibrated_workflow(poly, "truth", loss = tune::tune("va_loss"), seed = 42,
      tuning = list(strategy = "two_stage", selection_prop = .5,
        model = list(method = "grid", grid = data.frame(degree = 1:2), resamples = folds),
        calibration = list(method = "grid", grid = data.frame(va_loss = c("log", "brier")), resamples = folds)))
    searched_fit <- generics::fit(searched, development)
    print(venn_abers_tuning(searched_fit)$calibration$selected)
    examples$search <- list(data = binary, model = searched, fit = searched_fit, new = assessment)
  }
}

Fixed engine options and internal validation

engine-options.R: Set fixed rpart options with parsnip::set_engine(), check that the full-model refit carries them, see extra fit arguments rejected, and read the not-run XGBoost early-stopping call.

# Fixed engine options and model-internal validation. Options set with
# parsnip::set_engine() travel with the base workflow, so every local fit
# (source models, class pairs, Full1 and postprocessing B) uses them on its own
# rows only. calibrated_workflow() and fit() take no extra fit arguments.
library(vennabers)
source(system.file("examples", "assertions.R", package = "vennabers"))

if (all(vapply(c("parsnip", "workflows", "rsample", "withr", "rpart"),
               requireNamespace, logical(1), quietly = TRUE))) {
  data <- data.frame(x = sin(1:200) + rep(c(1, 0, 0, 1), 50), z = cos(1:200),
                     truth = factor(rep(c("yes", "no", "no", "yes"), 50)))
  development <- data[1:160, ]
  assessment <- data[161:200, ]
  # xval = 0 skips rpart's internal cross-validation; maxdepth is a fixed
  # main argument of the model specification.
  tree <- parsnip::decision_tree(mode = "classification", tree_depth = 3L) |>
    parsnip::set_engine("rpart", xval = 0L, maxsurrogate = 0L)
  base <- workflows::workflow() |>
    workflows::add_formula(truth ~ .) |>
    workflows::add_model(tree)

  fit <- generics::fit(calibrated_workflow(base, "truth", method = "cross", v = 3L,
    prediction_model = "full", seed = 42), development)
  state <- workflows::extract_fit_engine(fit)
  print(state)
  # Full mode keeps one fresh model on all 160 rows; it carries the options.
  engine <- workflows::extract_fit_engine(state$models$Full1)
  assert_conditions(engine$control$xval == 0L, engine$control$maxsurrogate == 0L,
    engine$control$maxdepth == 3L, length(engine$where) == 160L)
  print(head(predict(fit, assessment, type = "prob")))

  # Fit options belong in set_engine(); extra arguments are rejected.
  message <- tryCatch(calibrated_workflow(base, "truth", xval = 0L), error = conditionMessage)
  stopifnot(grepl("unsupported argument: xval", message, fixed = TRUE))

  # Native early stopping works the same way. Each local fit splits its
  # validation share from its own prepared rows; calibration rows never reach
  # it. Not run here because xgboost is not a vennabers dependency:
  if (FALSE) {
    spec <- parsnip::boost_tree(mode = "classification", trees = 100L, stop_iter = 10L) |>
      parsnip::set_engine("xgboost", validation = 0.2)
    boosted <- workflows::workflow() |>
      workflows::add_formula(truth ~ .) |>
      workflows::add_model(spec)
    fitted <- generics::fit(calibrated_workflow(boosted, "truth", seed = 42), development)
  }
}

Binary full-model transfer

full-model-workflow.R: Compare matched and full prediction models while retaining the same held-out calibration state.

# Fixed binary settings; the same permitted splits compare two procedures.
source(system.file("examples", "assertions.R", package = "vennabers"))
packages <- c("parsnip", "workflows", "hardhat", "rsample", "recipes", "rlang",
              "tibble", "withr", "generics", "tidyselect")
if (all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) local({
  library(vennabers)
  set.seed(12)
  data <- data.frame(x = rnorm(160))
  data$truth <- factor(ifelse(runif(160) < plogis(data$x), "yes", "no"), levels = c("yes", "no"))
  final_split <- rsample::make_splits(list(analysis = 1:120, assessment = 121:160), data)
  development <- rsample::analysis(final_split)
  assessment <- rsample::assessment(final_split)
  base <- workflows::workflow() |>
    workflows::add_recipe(recipes::recipe(truth ~ x, development) |> recipes::step_normalize(x)) |>
    workflows::add_model(parsnip::logistic_reg(engine = "glm"))
  for (method in c("inductive", "cross")) {
    # This function receives only the current local fit domain, also in last_fit.
    plan <- function(data) {
      if (method == "cross") rsample::vfold_cv(data, v = 3, strata = truth) else
        rsample::make_splits(list(analysis = 1:90, assessment = 91:120), data)
    }
    shared_plan <- withr::with_seed(42, plan(development))
    fits <- lapply(c("matched", "full"), function(mode) generics::fit(
      calibrated_workflow(base, "truth", method = method, prediction_model = mode,
                          splits = shared_plan, seed = 43), development))
    matched <- workflows::extract_fit_engine(fits[[1L]])
    full <- workflows::extract_fit_engine(fits[[2L]])
    stopifnot(identical(matched$calibrator, full$calibrator),
              identical(matched$calibration, full$calibration),
              identical(names(full$models), "Full1"))
    p <- lapply(fits, predict, new_data = assessment, type = "prob")
    print(data.frame(method, prediction_model = c("matched", "full"),
      brier = vapply(p, function(x) brier_score(assessment$truth == "yes", x$.pred_yes), 0)))
    print(full)
    print(head(venn_abers_details(fits[[2L]], assessment)$details))
    # All source calibration rows/values remain inspectable after source models
    # are discarded. The full recipe is learned anew on all 120 local rows.
    print(head(full$calibration[[1L]]))
    print(workflows::extract_recipe(full$models$Full1))
    if (all(vapply(c("tune", "yardstick"), requireNamespace, logical(1), quietly = TRUE))) {
      model <- calibrated_workflow(base, "truth", method = method, prediction_model = "full",
                                    splits = shared_plan, seed = 43)
      final <- tune::last_fit(model, final_split,
        metrics = yardstick::metric_set(yardstick::brier_class, yardstick::mn_log_loss))
      prediction <- tune::collect_predictions(final)
      assert_conditions(nrow(tune::collect_notes(final)) == 0L,
                identical(prediction$.row, 121:160), !anyDuplicated(prediction$.row),
                all(prediction$.config == "pre0_mod0_post0"),
                identical(as.matrix(prediction[names(p[[2L]])]), as.matrix(p[[2L]])))
    }
  }
  # Full transfer costs 2 (Inductive) or K+1 (Cross) base fits. Prediction uses
  # one full model per batch; every held-out calibrator still participates.
  # This model change does not transfer calibration/coverage guarantees and need
  # not improve quality. Comparing procedures is selection: use fresh untouched
  # data if the comparison above determines the procedure eventually deployed.
})

Shared multiclass full-model transfer

full-multiclass-workflow.R: Use an additional full shared model with held-out OvO/OvR calibrators and inspect the retained state.

# Shared multiclass full-model transfer is an R extension. OvO still means
source(system.file("examples", "assertions.R", package = "vennabers"))
# pairwise calibration of one shared class matrix, not pairwise model training.
packages <- c("vennabers", "parsnip", "workflows", "recipes", "rsample", "nnet")
examples <- list()
if (all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) {
  library(vennabers)
  data <- data.frame(x = sin(1:120), z = cos(1:120),
    truth = factor(rep(c("class", ".pred_class", "a b"), 40),
      levels = c("class", ".pred_class", "a b")))
  names(data) <- c("a b", "a.b", ".pred_class")
  for (method in c("inductive", "cross")) for (classification in c("ovo", "ovr")) {
    schema <- venn_abers_schema(data, ".pred_class", classification = classification)
    encoded <- venn_abers_encode(data, schema)
    outer_split <- rsample::make_splits(list(analysis = 1:90, assessment = 91:120), encoded)
    development <- rsample::analysis(outer_split)
    assessment <- rsample::assessment(outer_split)
    base <- workflows::workflow() |>
      workflows::add_recipe(recipes::recipe(reformulate(names(encoded)[1:2], schema$encoded_outcome), development) |>
        recipes::step_normalize(recipes::all_numeric_predictors())) |>
      workflows::add_model(parsnip::multinom_reg(penalty = .1) |> parsnip::set_engine("nnet", trace = FALSE))
    plan <- local({
      cross <- method == "cross"
      function(data) {
        n <- nrow(data)
        held <- if (cross) split(seq_len(n), cut(seq_len(n), 3, labels = FALSE)) else
          list(seq.int(n - ceiling(n * .25) + 1L, n))
        parts <- lapply(held, function(h) rsample::make_splits(
          list(analysis = setdiff(seq_len(n), h), assessment = h), data))
        if (cross) rsample::manual_rset(parts, paste0("Part", seq_along(parts))) else parts[[1L]]
      }
    })
    models <- lapply(c("matched", "full"), function(mode) calibrated_workflow(base,
      schema$encoded_outcome, schema = schema, method = method, model_training = "shared",
      prediction_model = mode, splits = plan, seed = 42, precision = 2))
    fits <- lapply(models, generics::fit, data = development)
    source <- workflows::extract_fit_engine(fits[[1L]])
    state <- workflows::extract_fit_engine(fits[[2L]])
    stopifnot(identical(source$calibrator, state$calibrator), identical(source$calibration, state$calibration))
    native <- venn_abers_details(fits[[2L]], assessment)
    decoded <- venn_abers_decode(native, schema)
    print(state)
    print(head(native$details))
    print(head(decoded$details))
    print(head(decoded$probability))
    print(head(venn_abers_decode(predict(fits[[2L]], assessment, type = "class"), schema)$class))
    case <- list(schema = schema, data = encoded, base = base, model = models[[2L]],
      fit = fits[[2L]], matched = fits[[1L]], assessment = assessment)
    if (all(vapply(c("tune", "yardstick"), requireNamespace, logical(1), quietly = TRUE))) {
      audit <- new.env()
      sys.source(system.file("examples", "check-encoded-evaluation.R", package = "vennabers"), audit)
      outer <- withr::with_seed(41, rsample::vfold_cv(development, v = 2, repeats = 2))
      scoring <- yardstick::metric_set(yardstick::brier_class, yardstick::mn_log_loss)
      result <- tune::fit_resamples(models[[2L]], outer, metrics = scoring,
        control = tune::control_resamples(save_pred = TRUE, extract = identity, allow_par = FALSE))
      checked <- audit$check_encoded_evaluation(result, outer, schema)
      print(checked$metrics)
      final <- tune::last_fit(models[[2L]], outer_split, metrics = scoring)
      final_p <- tune::collect_predictions(final)
      final_m <- tune::collect_metrics(final)
      assert_conditions(nrow(tune::collect_notes(final)) == 0L, identical(final_p$.row, 91:120),
        !anyDuplicated(final_p$.row), all(final_p$.config == "pre0_mod0_post0"),
        identical(as.matrix(final_p[names(native$prediction)]), as.matrix(native$prediction)),
        nrow(final_m) == 2L, all(is.finite(final_m$.estimate)))
      # yardstick uses half the manual multiclass matrix Brier score, including
      # the two-class multiclass contract. Log clipping affects metrics only.
      truth <- venn_abers_decode(final_p, schema)$truth
      stopifnot(abs(final_m$.estimate[final_m$.metric == "brier_class"] -
        brier_score(truth, decoded$probability) / 2) <= 1e-10)
      case$evaluation <- result
      case$outer <- outer
    }
    examples[[paste(method, classification)]] <- case
  }
  # Full transfer costs two Inductive or K+1 Cross fits and one prediction per
  # nonempty batch. The same full matrix reaches all held-out calibrators.
  # This R extension makes no new calibration, coverage or quality guarantee.
}

Regression full-model transfer

full-regression-workflow.R: Compare matched and full IVAR/CVAR workflows at fixed settings.

# Fixed-setting IVAR/CVAR full-model transfer. Python 1.5.4 exposes this option
source(system.file("examples", "assertions.R", package = "vennabers"))
# in the exported lower VennAbersCV class, not its regression facade.
packages <- c("vennabers", "parsnip", "workflows", "recipes", "rsample")
examples <- list()
if (all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) {
  library(vennabers)
  data <- data.frame(x = sin(1:120), z = cos(1:120), truth = 10 + 3 * sin(1:120) + cos(1:120)^2)
  names(data) <- c("a b", "a.b", ".pred")
  schema <- venn_abers_schema(data, ".pred")
  encoded <- venn_abers_encode(data, schema)
  outer_split <- rsample::make_splits(list(analysis = 1:90, assessment = 91:120), encoded)
  development <- rsample::analysis(outer_split)
  assessment <- rsample::assessment(outer_split)
  base <- workflows::workflow() |>
    workflows::add_recipe(recipes::recipe(reformulate(names(encoded)[1:2], schema$encoded_outcome), development) |>
      recipes::step_normalize(recipes::all_numeric_predictors())) |>
    workflows::add_model(parsnip::linear_reg(engine = "lm"))
  for (method in c("inductive", "cross")) {
    # Ordered local partitions are replayed for both modes and rebuilt inside
    # each outer analysis domain. This synthetic example assumes IID data.
    plan <- local({
      cross <- method == "cross"
      function(data) {
        n <- nrow(data)
        held <- if (cross) split(seq_len(n), cut(seq_len(n), 3, labels = FALSE)) else list(seq.int(n - 9L, n))
        parts <- lapply(held, function(h) rsample::make_splits(
          list(analysis = setdiff(seq_len(n), h), assessment = h), data))
        if (cross) rsample::manual_rset(parts, paste0("Part", seq_along(parts))) else parts[[1L]]
      }
    })
    models <- lapply(c("matched", "full"), function(mode) calibrated_workflow(base,
      schema$encoded_outcome, schema = schema, method = method, prediction_model = mode,
      splits = plan, m = 2, seed = 42))
    fits <- lapply(models, generics::fit, data = development)
    native <- venn_abers_details(fits[[2L]], assessment)
    decoded <- venn_abers_decode(native, schema)
    print(data.frame(method = method, matched = predict(fits[[1L]], assessment)$.pred, full = decoded$prediction))
    print(head(decoded$details))
    # Reconstruct the final points and descriptive bounds independently from
    # complete per-fold results. Never merge the averaged bounds into a point.
    reconstructed <- stats::aggregate(cbind(prediction, lower, upper) ~ row, decoded$details, mean)
    stopifnot(max(abs(reconstructed$prediction - decoded$prediction)) <= 1e-10)
    if (method == "cross") {
      stopifnot(max(abs(as.matrix(reconstructed[c("lower", "upper")]) -
        as.matrix(decoded$aggregate[c("lower", "upper")]))) <= 1e-10)
      print(head(decoded$aggregate))
    }
    case <- list(schema = schema, data = encoded, base = base, model = models[[2L]],
      fit = fits[[2L]], matched = fits[[1L]], assessment = assessment)
    if (all(vapply(c("tune", "yardstick"), requireNamespace, logical(1), quietly = TRUE))) {
      audit <- new.env()
      sys.source(system.file("examples", "check-encoded-evaluation.R", package = "vennabers"), audit)
      scoring <- yardstick::metric_set(yardstick::rmse, yardstick::mae)
      truth <- assessment[[schema$encoded_outcome]]
      print(data.frame(method = method, mode = c("matched", "full"),
        rmse = vapply(fits, function(f) yardstick::rmse_vec(truth, predict(f, assessment)$.pred), 0),
        mae = vapply(fits, function(f) yardstick::mae_vec(truth, predict(f, assessment)$.pred), 0)))
      outer <- withr::with_seed(41, rsample::vfold_cv(development, v = 2, repeats = 2))
      result <- tune::fit_resamples(models[[2L]], outer, metrics = scoring,
        control = tune::control_resamples(save_pred = TRUE, extract = identity, allow_par = FALSE))
      checked <- audit$check_encoded_evaluation(result, outer, schema)
      print(checked$metrics)
      final <- tune::last_fit(models[[2L]], outer_split, metrics = scoring)
      p <- tune::collect_predictions(final); metrics <- tune::collect_metrics(final)
      assert_conditions(nrow(tune::collect_notes(final)) == 0L, identical(p$.row, 91:120), !anyDuplicated(p$.row),
        all(p$.config == "pre0_mod0_post0"), identical(p[[schema$encoded_outcome]], truth),
        identical(p$.pred, native$prediction$.pred), nrow(metrics) == 2L,
        setequal(metrics$.metric, c("rmse", "mae")), all(is.finite(metrics$.estimate)),
        all(metrics$.config == "pre0_mod0_post0"))
      stopifnot(abs(metrics$.estimate[metrics$.metric == "rmse"] - yardstick::rmse_vec(truth, decoded$prediction)) <= 1e-10,
        abs(metrics$.estimate[metrics$.metric == "mae"] - yardstick::mae_vec(truth, decoded$prediction)) <= 1e-10)
      case$evaluation <- result; case$outer <- outer
    }
    examples[[method]] <- case
  }
  # Full transfer fits 2 / K+1 models, keeps only the full workflow plus held-out
  # calibration values/calibrators, and predicts once per nonempty batch.
  # No automatic coverage, calibration or quality guarantee follows. Aggregate
  # regression bounds are descriptive, not confidence or prediction intervals.
}

Pairwise Inductive OvO training

pairwise-inductive.R: Train a separate binary workflow for each class pair with matching Inductive calibration.

# Separate binary training with matching Inductive calibration, at fixed settings.
source(system.file("examples", "assertions.R", package = "vennabers"))
packages <- c("vennabers", "parsnip", "workflows", "recipes", "rsample")
if (all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) {
  library(vennabers)
  data <- data.frame(x = sin(1:120), z = cos(1:120),
    truth = factor(rep(c("class", "a b", "a.b"), 40), levels = c("class", "a b", "a.b")))
  names(data) <- c("a b", "a.b", ".pred_class")
  schema <- venn_abers_schema(data, ".pred_class", classification = "ovo")
  encoded <- venn_abers_encode(data, schema)
  outer_split <- rsample::make_splits(list(analysis = 1:90, assessment = 91:120), encoded)
  development <- rsample::analysis(outer_split)
  assessment <- rsample::assessment(outer_split)
  formula <- reformulate(names(encoded)[1:2], schema$encoded_outcome)
  base <- workflows::workflow() |> workflows::add_recipe(
    recipes::recipe(formula, development) |> recipes::step_normalize(recipes::all_numeric_predictors())) |>
    workflows::add_model(parsnip::logistic_reg(engine = "glm"))
  # Receives only this pair's current local raw data, with exactly two levels.
  # This is local row order, not a shared global time cutoff. Both classes must
  # occur in training and calibration. Mixed holdouts are also supported.
  pair_plan <- function(data) {
    n <- nrow(data)
    held <- seq.int(n - ceiling(n / 4) + 1L, n)
    rsample::make_splits(list(analysis = setdiff(seq_len(n), held), assessment = held), data)
  }
  model <- calibrated_workflow(base, schema$encoded_outcome, schema = schema,
    model_training = "pairwise", splits = pair_plan, seed = 42)
  fit <- generics::fit(model, development)
  state <- workflows::extract_fit_engine(fit)
  print(state)
  pair_table <- data.frame(pair_id = names(state$pair_models),
    class_1 = vapply(state$pair_models, function(s) s$class_levels[1L], ""),
    class_2 = vapply(state$pair_models, function(s) s$class_levels[2L], ""))
  print(pair_table)
  native <- venn_abers_details(fit, assessment)
  decoded <- venn_abers_decode(native, schema)
  print(head(decoded$probability))
  print(head(decoded$details))
  # Direct fits may instead supply exactly pair_1, pair_2, ... as a plan list.
  # The table above maps these stable IDs to classes; class names are never joined.
  plans <- lapply(state$pair_models, function(component) {
    local <- development[component$rows, , drop = FALSE]
    local[[schema$encoded_outcome]] <- factor(local[[schema$encoded_outcome]], levels = component$class_levels)
    pair_plan(local)
  })
  explicit <- generics::fit(calibrated_workflow(base, schema$encoded_outcome, schema = schema,
    model_training = "pairwise", splits = plans, seed = 42), development)
  stopifnot(identical(native, venn_abers_details(explicit, assessment)))
  # Independent complete binary workflows: preprocessing sees only each pair's
  # training rows, and held-out model probabilities fit the existing binary core.
  q <- matrix(0, nrow(assessment), nrow(pair_table))
  for (j in seq_along(plans)) {
    training <- rsample::analysis(plans[[j]])
    held <- rsample::assessment(plans[[j]])
    reference <- workflows::workflow() |> workflows::add_recipe(
      recipes::recipe(formula, training) |> recipes::step_normalize(recipes::all_numeric_predictors())) |>
      workflows::add_model(parsnip::logistic_reg(engine = "glm"))
    binary <- generics::fit(reference, training)
    event <- pair_table$class_2[j]
    column <- paste0(".pred_", event)
    cal <- venn_abers(held[[schema$encoded_outcome]] == event, predict(binary, held, type = "prob")[[column]])
    expected <- predict(cal, predict(binary, assessment, type = "prob")[[column]])
    parts <- native$details[native$details$pair_id == pair_table$pair_id[j], c("lower", "upper", "probability")]
    stopifnot(all(abs(as.matrix(parts) - as.matrix(expected)) <= 1e-10))
    q[, j] <- expected$probability
  }
  # Reciprocal OvO coupling, written independently as sums of binary odds.
  p <- vapply(state$class_levels, function(cl) {
    relevant <- which(pair_table$class_1 == cl | pair_table$class_2 == cl)
    odds <- lapply(relevant, function(j) {
      probability <- if (pair_table$class_2[j] == cl) q[, j] else 1 - q[, j]
      (1 - probability) / probability
    })
    1 / (1 + Reduce(`+`, odds))
  }, numeric(nrow(assessment)))
  stopifnot(all(abs(p / rowSums(p) - as.matrix(native$prediction)) <= 1e-10))
  if (all(vapply(c("tune", "yardstick"), requireNamespace, logical(1), quietly = TRUE))) {
    scoring <- yardstick::metric_set(yardstick::brier_class, yardstick::mn_log_loss)
    outer <- withr::with_seed(41, rsample::vfold_cv(development, v = 2, repeats = 2))
    result <- tune::fit_resamples(model, outer, metrics = scoring,
      control = tune::control_resamples(save_pred = TRUE, extract = identity, allow_par = FALSE))
    checker <- new.env()
    sys.source(system.file("examples", "check-encoded-evaluation.R", package = "vennabers"), checker)
    checked <- checker$check_encoded_evaluation(result, outer, schema)
    print(checked$metrics)
    final <- tune::last_fit(model, outer_split, metrics = scoring)
    predictions <- tune::collect_predictions(final)
    metrics <- tune::collect_metrics(final)
    assert_conditions(nrow(tune::collect_notes(final)) == 0L, identical(predictions$.row, 91:120),
      !anyDuplicated(predictions$.row), all(predictions$.config == "pre0_mod0_post0"),
      identical(predictions[[schema$encoded_outcome]], assessment[[schema$encoded_outcome]]),
      nrow(metrics) == 2L, all(is.finite(metrics$.estimate)),
      all(abs(as.matrix(predictions[names(native$prediction)]) - as.matrix(native$prediction)) <= 1e-10))
  }
  # Matched: P = K*(K-1)/2 pairs cost P base fits and P predictions per nonempty
  # batch. No extra shared model or OvR ensemble is trained. All P recipes and
  # calibrators remain available; predict never learns. A row may belong to
  # several pairs.
  # Outer assessment labels never reach these local fits or calibration plans.
  # Prepared outer plans can use local_calibration_splits() over all local pairs;
  # a development-wide plan cannot be reused on a smaller outer analysis domain.
  # Matching-model assumptions still apply; no universal accuracy gain is promised.
  # Full transfer keeps the held-out calibrators but fits one extra workflow
  # on each complete local pair, including its calibration rows.
  full_model <- calibrated_workflow(base, schema$encoded_outcome, schema = schema,
    model_training = "pairwise", method = "inductive", prediction_model = "full",
    splits = pair_plan, seed = 42)
  full_fit <- generics::fit(full_model, development)
  full_state <- workflows::extract_fit_engine(full_fit)
  for (id in names(state$pair_models)) stopifnot(
    identical(state$pair_models[[id]]$calibration, full_state$pair_models[[id]]$calibration),
    identical(state$pair_models[[id]]$calibrator, full_state$pair_models[[id]]$calibrator),
    identical(names(full_state$pair_models[[id]]$models), "Full1"))
  full_native <- venn_abers_details(full_fit, assessment)
  full_decoded <- venn_abers_decode(full_native, schema)
  print(head(full_decoded$details))
  for (mode in c("matched", "full")) {
    probability <- if (mode == "full") full_decoded$probability else decoded$probability
    truth <- venn_abers_decode(assessment[schema$encoded_outcome], schema)$truth
    cat(mode, "Brier:", brier_score(truth, probability),
        "log loss:", log_loss(truth, probability), "\n")
  }
  # Six base fits (three sources, three Full1); only the Full1 workflows remain,
  # each predicting once per nonempty batch. The printed slot map sends every
  # held-out calibrator to its pair's Full1.
  print(full_state)
  # Each pair source is named pair_N/Full1; pair_id + fold identifies its
  # calibrator. Source workflows are discarded after all full fits succeed.
  # No in-sample recalibration or pooling occurs. Outer assessment stays excluded.
  # Matching-model guarantees do not automatically transfer to the new model;
  # neither coverage nor improved quality is promised. Settings are fixed here;
  # pairwise-grid.R and pairwise-bayes.R show automatic local selection.
}

Pairwise Cross OvO training

pairwise-cross.R: Retain the matching binary workflows and calibrators for every class pair and calibration fold.

# Separate pair/fold workflows with matching Cross calibration at fixed settings.
source(system.file("examples", "assertions.R", package = "vennabers"))
packages <- c("vennabers", "parsnip", "workflows", "recipes", "rsample")
if (all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) {
  library(vennabers)
  data <- data.frame(x = sin(1:120), z = cos(1:120),
    truth = factor(rep(c("class", "a b", "a.b"), 40), levels = c("class", "a b", "a.b")))
  names(data) <- c("a b", "a.b", ".pred_class")
  schema <- venn_abers_schema(data, ".pred_class", classification = "ovo")
  encoded <- venn_abers_encode(data, schema)
  outer_split <- rsample::make_splits(list(analysis = 1:90, assessment = 91:120), encoded)
  development <- rsample::analysis(outer_split)
  assessment <- rsample::assessment(outer_split)
  formula <- reformulate(names(encoded)[1:2], schema$encoded_outcome)
  base <- workflows::workflow() |> workflows::add_recipe(
    recipes::recipe(formula, development) |> recipes::step_normalize(recipes::all_numeric_predictors())) |>
    workflows::add_model(parsnip::logistic_reg(engine = "glm"))
  pairs <- combn(levels(development[[schema$encoded_outcome]]), 2, simplify = FALSE)
  # A local function also works inside fit_resamples()/last_fit(). Here the
  # class pair selects 3, 4 or 5 folds. Fold1 in different pairs is unrelated.
  pair_plan <- function(data) {
    j <- which(vapply(pairs, identical, logical(1), y = levels(data[[schema$encoded_outcome]])))
    v <- 2L + j
    held <- split(seq_len(nrow(data)), cut(seq_len(nrow(data)), v, labels = FALSE))
    rsample::manual_rset(lapply(held, function(h) rsample::make_splits(
      list(analysis = setdiff(seq_len(nrow(data)), h), assessment = h), data)), paste0("Fold", seq_len(v)))
  }
  plans <- setNames(lapply(pairs, function(pair) {
    local <- development[development[[schema$encoded_outcome]] %in% pair, , drop = FALSE]
    local[[schema$encoded_outcome]] <- factor(local[[schema$encoded_outcome]], levels = pair)
    pair_plan(local)
  }), paste0("pair_", seq_along(pairs)))
  explicit <- calibrated_workflow(base, schema$encoded_outcome, schema = schema,
    model_training = "pairwise", method = "cross", splits = plans, seed = 42)
  fit <- generics::fit(explicit, development)
  state <- workflows::extract_fit_engine(fit)
  print(state)
  pair_table <- data.frame(pair_id = names(state$pair_models),
    class_1 = vapply(state$pair_models, function(s) s$class_levels[1L], ""),
    class_2 = vapply(state$pair_models, function(s) s$class_levels[2L], ""))
  print(pair_table)
  native <- venn_abers_details(fit, assessment)
  decoded <- venn_abers_decode(native, schema)
  print(head(native$details))
  print(head(decoded$probability))
  print(head(decoded$details))
  model <- calibrated_workflow(base, schema$encoded_outcome, schema = schema,
    model_training = "pairwise", method = "cross", splits = pair_plan, seed = 42)
  stopifnot(identical(native, venn_abers_details(generics::fit(model, development), assessment)))
  # Reconstruct each pair from its binary fold endpoints, then couple globally.
  # There is no global fold matrix and no averaging of coupled fold predictions.
  for (loss in c("log", "brier")) {
    details <- venn_abers_details(fit, assessment, loss = loss)
    q <- vapply(names(plans), function(id) {
      d <- details$details[details$details$pair_id == id, ]
      lower <- matrix(d$lower, nrow(assessment))
      upper <- matrix(d$upper, nrow(assessment))
      if (loss == "brier") rowMeans(matrix(d$probability, nrow(assessment))) else {
        a <- apply(upper, 1, prod)^(1 / ncol(upper))
        b <- apply(1 - lower, 1, prod)^(1 / ncol(lower))
        a / (a + b)
      }
    }, numeric(nrow(assessment)))
    p <- vapply(state$class_levels, function(cl) {
      relevant <- which(pair_table$class_1 == cl | pair_table$class_2 == cl)
      odds <- lapply(relevant, function(j) {
        probability <- if (pair_table$class_2[j] == cl) q[, j] else 1 - q[, j]
        (1 - probability) / probability
      })
      1 / (1 + Reduce(`+`, odds))
    }, numeric(nrow(assessment)))
    stopifnot(all(abs(p / rowSums(p) - as.matrix(details$prediction)) <= 1e-10))
  }
  cost <- sum(vapply(state$pair_models, function(p) length(p$models), 1L))
  stopifnot(cost == 12L)
  cat("Base fits per complete local fit:", cost, "(3 + 4 + 5)\n")
  if (all(vapply(c("tune", "yardstick"), requireNamespace, logical(1), quietly = TRUE))) {
    scoring <- yardstick::metric_set(yardstick::brier_class, yardstick::mn_log_loss)
    outer <- withr::with_seed(41, rsample::vfold_cv(development, v = 2, repeats = 2))
    result <- tune::fit_resamples(model, outer, metrics = scoring,
      control = tune::control_resamples(save_pred = TRUE, extract = identity, allow_par = FALSE))
    checker <- new.env()
    sys.source(system.file("examples", "check-encoded-evaluation.R", package = "vennabers"), checker)
    checked <- checker$check_encoded_evaluation(result, outer, schema)
    print(checked$metrics)
    final <- tune::last_fit(model, outer_split, metrics = scoring)
    predictions <- tune::collect_predictions(final)
    metrics <- tune::collect_metrics(final)
    assert_conditions(nrow(tune::collect_notes(final)) == 0L, identical(predictions$.row, 91:120),
      !anyDuplicated(predictions$.row), all(predictions$.config == "pre0_mod0_post0"),
      identical(predictions[[schema$encoded_outcome]], assessment[[schema$encoded_outcome]]),
      nrow(metrics) == 2L, all(is.finite(metrics$.estimate)),
      all(abs(as.matrix(predictions[names(native$prediction)]) - as.matrix(native$prediction)) <= 1e-10))
    cat("Outer resampling base fits:", nrow(outer) * cost, "; last_fit base fits:", cost, "\n")
  }
  # Matched: all 12 complete workflows/calibrators remain stored. Nonempty
  # predict calls each model exactly once and never learns; empty inputs call
  # no models.
  # Outer assessment outcomes enter only evaluation, never the local pair plans.
  # One-sided calibration folds are valid when their complements train and return
  # valid two-class probabilities. Every row is assessed once within each pair.
  # Full transfer keeps the held-out calibrators but fits one extra workflow
  # on each complete local pair, including its calibration rows.
  full_model <- calibrated_workflow(base, schema$encoded_outcome, schema = schema,
    model_training = "pairwise", method = "cross", prediction_model = "full",
    splits = pair_plan, seed = 42)
  full_fit <- generics::fit(full_model, development)
  full_state <- workflows::extract_fit_engine(full_fit)
  for (id in names(state$pair_models)) stopifnot(
    identical(state$pair_models[[id]]$calibration, full_state$pair_models[[id]]$calibration),
    identical(state$pair_models[[id]]$calibrator, full_state$pair_models[[id]]$calibrator),
    identical(names(full_state$pair_models[[id]]$models), "Full1"))
  full_native <- venn_abers_details(full_fit, assessment)
  full_decoded <- venn_abers_decode(full_native, schema)
  print(head(full_decoded$details))
  for (mode in c("matched", "full")) {
    probability <- if (mode == "full") full_decoded$probability else decoded$probability
    truth <- venn_abers_decode(assessment[schema$encoded_outcome], schema)$truth
    cat(mode, "Brier:", brier_score(truth, probability),
        "log loss:", log_loss(truth, probability), "\n")
  }
  # Fifteen base fits (3 + 4 + 5 sources, three Full1); only the Full1 workflows
  # remain, each predicting once per nonempty batch. The printed slot map sends
  # every held-out calibrator to its pair's Full1.
  print(full_state)
  # Each pair source is named pair_N/Full1; pair_id + fold identifies its
  # calibrator. Source workflows are discarded after all full fits succeed.
  # No in-sample recalibration or pooling occurs. Outer assessment stays excluded.
  # Matching-model guarantees do not automatically transfer to the new model;
  # neither coverage nor improved quality is promised. Settings are fixed here;
  # pairwise-grid.R and pairwise-bayes.R show automatic local selection.
}

Calibrating a frozen binary workflow

frozen-binary.R: Adopt a previously fitted recipe, model and postprocessor unchanged; compare losses on separate validation rows.

# A previously learned recipe, model and Tailor remain frozen. The user must
# keep new VA calibration data outside ALL earlier training/model selection.
# This adopts the existing workflow unchanged; calibrated_workflow() instead
# trains new base workflows on its own split.
packages <- c("workflows", "parsnip", "recipes", "tailor", "probably", "rsample",
              "hardhat", "rlang", "tibble", "withr", "generics", "tidyselect", "yardstick")
if (all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) local({
  set.seed(613)
  data <- data.frame(x = rnorm(450), z = rnorm(450))
  data$truth <- factor(ifelse(runif(450) < plogis(data$x - .5 * data$z), "yes", "no"),
                       levels = c("yes", "no"))
  data$x[c(2, 155, 255, 355)] <- NA_real_
  training <- data[1:150, ]
  earlier_postprocessing <- data[151:250, ]
  calibration <- data[251:350, ]
  validation <- data[351:400, ]
  assessment <- data[401:450, ]

  base <- workflows::workflow() |>
    workflows::add_recipe(recipes::recipe(truth ~ ., training) |>
      recipes::step_impute_median(x) |>
      recipes::step_normalize(recipes::all_numeric_predictors())) |>
    workflows::add_model(parsnip::logistic_reg(engine = "glm")) |>
    workflows::add_tailor(tailor::tailor() |>
      tailor::adjust_probability_calibration(method = "logistic", smooth = FALSE))
  # These fits happened earlier, on their own data.
  frozen <- generics::fit(base, training, data_calibration = earlier_postprocessing)

  # Only the VA calibrator learns now. No bake() and no subsequent fit().
  calibrated <- vennabers::calibrate_workflow(frozen, calibration, "truth")
  print(calibrated)  # Base fits: 0; the adopted workflow is the only model

  # Narrow Log/Brier choice on separate validation rows with existing predict()
  # and yardstick calls. Nothing is refitted; pass the chosen rule explicitly.
  # Validation rows are selection data, so assess on still other rows.
  losses <- vapply(c(log = "log", brier = "brier"), function(loss) {
    p <- predict(calibrated, validation[c("x", "z")], type = "prob", loss = loss)
    yardstick::mn_log_loss_vec(validation$truth, p$.pred_yes)
  }, numeric(1))
  chosen <- names(losses)[which.min(losses)]
  print(losses)

  new_data <- assessment[c("x", "z")]
  probability <- predict(calibrated, new_data, type = "prob", loss = chosen)
  classes <- predict(calibrated, new_data, type = "class", loss = chosen)
  details <- vennabers::venn_abers_details(calibrated, new_data, loss = chosen)
  print(c(log_loss = vennabers::log_loss(assessment$truth == "yes", probability$.pred_yes),
          brier = vennabers::brier_score(assessment$truth == "yes", probability$.pred_yes)))
  stopifnot(identical(levels(classes$.pred_class), levels(training$truth)),
            identical(details$prediction, probability),
            identical(calibrated$models[[1L]], frozen), calibrated$loss == "log")

  path <- tempfile(fileext = ".rds")
  saveRDS(calibrated, path)
  stopifnot(identical(probability, predict(readRDS(path), new_data, loss = chosen)))
  unlink(path)
  # Retain the required packages. Engines using external resources may need
  # their own persistence procedure. RDS is not a universal portability promise.
})

Calibrating frozen multiclass and regression workflows

frozen-multiclass-regression.R: Separate earlier training and postprocessing from new VA calibration and independent assessment.

# Separate earlier model learning, earlier postprocessing, new VA calibration,
# and independent assessment. All calls use the complete learned workflow.
packages <- c("workflows", "parsnip", "recipes", "tailor", "probably", "nnet", "mgcv",
              "rsample", "hardhat", "rlang", "tibble", "withr", "generics", "tidyselect")
if (all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) local({
  set.seed(614)
  data <- data.frame(x = rnorm(400), z = rnorm(400))
  data$truth <- factor(rep(c("third", "first", "second"), length.out = 400),
                       levels = c("third", "first", "second"))
  training <- data[1:150, ]
  earlier_postprocessing <- data[151:250, ]
  calibration <- data[251:350, ]
  assessment <- data[351:400, ]
  base <- workflows::workflow() |>
    workflows::add_recipe(recipes::recipe(truth ~ ., training) |>
      recipes::step_normalize(recipes::all_numeric_predictors())) |>
    workflows::add_model(parsnip::multinom_reg(penalty = .1) |>
      parsnip::set_engine("nnet", trace = FALSE)) |>
    workflows::add_tailor(tailor::tailor() |>
      tailor::adjust_probability_calibration(method = "multinomial", smooth = TRUE))
  frozen <- generics::fit(base, training, data_calibration = earlier_postprocessing)
  for (method in c("ovo", "ovr")) {
    calibrated <- vennabers::calibrate_workflow(frozen, calibration, "truth", classification = method, precision = 2)
    new <- assessment[c("x", "z")]
    probability <- predict(calibrated, new)
    classes <- predict(calibrated, new, type = "class")
    details <- vennabers::venn_abers_details(calibrated, new, loss = "brier")
    stopifnot(identical(levels(classes$.pred_class), levels(training$truth)),
              identical(details$prediction, predict(calibrated, new, loss = "brier")),
              identical(calibrated$models[[1L]], frozen))
    print(head(probability))
  }

  # Numeric truth (including 0/1) stays regression because the workflow says so.
  data$truth <- 2 + data$x + cos(1:400)
  base <- workflows::workflow() |>
    workflows::add_recipe(recipes::recipe(truth ~ ., data[1:150, ]) |>
      recipes::step_normalize(recipes::all_numeric_predictors())) |>
    workflows::add_model(parsnip::linear_reg(engine = "lm")) |>
    workflows::add_tailor(tailor::tailor() |>
      tailor::adjust_numeric_calibration(method = "linear", smooth = FALSE))
  frozen <- generics::fit(base, data[1:150, ], data_calibration = data[151:250, ])
  calibrated <- vennabers::calibrate_workflow(frozen, data[251:350, ], "truth", epsilon = .1)
  points <- predict(calibrated, data[351:400, ], type = "numeric")
  details <- vennabers::venn_abers_details(calibrated, data[351:400, ])
  stopifnot(identical(points, details$prediction), identical(calibrated$models[[1L]], frozen),
            calibrated$parameters$effective$m == 5)
  print(c(rmse = sqrt(mean((points$.pred - data$truth[351:400])^2))))
  # details$details contains row, fold, prediction, lower and upper. These IVAR
  # bounds describe regularized conditional expectations, not prediction intervals.
})

Keeping the original schema for frozen adoption

frozen-schema.R: Encode new calibration and assessment data using the schema retained before the original workflow training.

# Keep the schema from BEFORE training. Later data are explicitly encoded with it.
source(system.file("examples", "assertions.R", package = "vennabers"))
# Separate earlier model/recipe learning, earlier postprocessing, new VA
# calibration, and independent assessment. Never rebuild a schema from calibration.
packages <- c("workflows", "parsnip", "recipes", "tailor", "probably", "nnet", "mgcv",
              "rsample", "hardhat", "rlang", "tibble", "withr", "generics", "tidyselect")
if (all(vapply(packages, requireNamespace, logical(1), quietly = TRUE))) local({
  set.seed(615)
  for (method in c("binary", "ovo", "ovr", "regression")) {
    original <- data.frame(x = rnorm(400), z = rnorm(400))
    original$y <- if (method == "regression") 2 + original$x + cos(1:400) else {
      classes <- if (method == "binary") c("class", "vaL0001") else c("class", "a b", "vaL0001")
      factor(rep(classes, length.out = 400), levels = classes)
    }
    names(original) <- c("vaV0002", "a-b", ".pred_class")
    schema <- if (method == "regression") vennabers::venn_abers_schema(original[1:150, ], ".pred_class") else
      if (method == "binary") vennabers::venn_abers_schema(original[1:150, ], ".pred_class", event_level = "second") else
        vennabers::venn_abers_schema(original[1:150, ], ".pred_class", classification = method)
    training <- vennabers::venn_abers_encode(original[1:150, ], schema)
    earlier_postprocessing <- vennabers::venn_abers_encode(original[151:250, ], schema)
    spec <- if (method == "regression") parsnip::linear_reg(engine = "lm") else
      if (method == "binary") parsnip::logistic_reg(engine = "glm") else
        parsnip::multinom_reg(penalty = .1) |> parsnip::set_engine("nnet", trace = FALSE)
    base <- workflows::workflow() |>
      workflows::add_recipe(recipes::recipe(vaV0003 ~ ., training) |>
        recipes::step_normalize(recipes::all_numeric_predictors())) |>
      workflows::add_model(spec) |>
      workflows::add_tailor(if (method == "regression") tailor::tailor() |>
        tailor::adjust_numeric_calibration(method = "linear", smooth = FALSE) else tailor::tailor() |>
          tailor::adjust_probability_calibration(method = if (method == "binary") "logistic" else "multinomial",
                                                smooth = (method != "binary")))
    earlier <- generics::fit(base, training, data_calibration = earlier_postprocessing)
    # Store earlier and schema together if calibration will happen in a later session.
    calibration <- vennabers::venn_abers_encode(original[251:350, ], schema)
    calibrated <- vennabers::calibrate_workflow(earlier, calibration, schema$encoded_outcome, schema = schema)
    new <- vennabers::venn_abers_encode(original[351:400, 1:2], schema)
    native <- predict(calibrated, new)
    decoded <- vennabers::venn_abers_decode(vennabers::venn_abers_details(calibrated, new), schema)
    assert_conditions(identical(calibrated$models[[1L]], earlier), identical(calibrated$schema, schema),
              decoded$outcome == ".pred_class", is.null(decoded$identity), is.null(decoded$aggregate))
    if (method != "regression") {
      decisions <- vennabers::venn_abers_decode(predict(calibrated, new, type = "class"), schema)
      stopifnot(identical(colnames(decoded$probability), schema$class_levels),
                identical(levels(decisions$class), schema$class_levels))
    }
    print(head(native))
  }
  # A workflow trained on original names continues to use those names, with
  # schema = NULL. Retrofitting a schema cannot rename learned state or repair
  # an old full-predict failure. Errors preserve names, stage and cause; there
  # is no automatic raw-response/engine fallback that bypasses postprocessing.
  # Retained bindings and learned metadata are checked, but detached matching
  # codes cannot prove provenance: callers must supply the actual training schema.
})