---
title: "Training and freezing calibrated workflows"
output:
  rmarkdown::html_vignette:
    toc: true
vignette: >
  %\VignetteIndexEntry{Training and freezing calibrated workflows}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
# The code needs optional packages from Suggests; without them it is shown
# but not run.
required <- c("parsnip", "workflows", "hardhat", "rsample", "recipes", "rlang",
              "tibble", "withr", "generics", "tidyselect", "rpart")
unavailable <- required[!vapply(required, requireNamespace, logical(1),
                                quietly = TRUE)]
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", error = FALSE,
                      eval = length(unavailable) == 0)
```

```{r missing-packages, eval=length(unavailable) > 0, echo=FALSE, results="asis"}
cat("> **Note:** The code in this vignette was not run because these",
    "suggested packages are not installed:",
    paste0("`", unavailable, "`", collapse = ", "), "\n")
```

The workflow interfaces retain preprocessing, the predictive model and the
Venn-Abers calibrator together. Choose the entry point based on whether the
model has already been trained:

| Starting point | Function | What it learns |
|:---------------|:---------|:---------------|
| Unfitted tidymodels workflow | `calibrated_workflow()` followed by `generics::fit()` | Local recipes, models and Inductive or Cross calibrators |
| Already fitted workflow | `calibrate_workflow()` | An Inductive calibrator on new calibration data |

These optional interfaces require `parsnip`, `workflows`, `hardhat`, `rsample`,
`recipes`, `rlang`, `tibble`, `withr`, `generics` and `tidyselect`, at the minimum
versions in `DESCRIPTION` or newer supported versions. The multiclass examples
also use `rpart`. The manual functions in
`vignette("manual-calibration", package = "vennabers")` need none of these.
All examples here use local data and fixed seeds.

## Choose a learning path

Start with the [complete binary workflow](#a-complete-binary-workflow): prepare
development data, define the base workflow, fit, predict and inspect the result.
Then continue with the task you need:

- [Cross calibration and prediction models](#cross-matched-models-and-full-model-transfer)
- [Multiclass training](#multiclass-calibration-method-versus-model-training) or
  [regression](#regression-workflows)
- [Calibrating an already fitted model](#frozen-workflows-calibrate-without-retraining)
- [Learning a postprocessor](#learning-a-postprocessor-before-venn-abers)
- [Fixed engine options and early stopping](#engine-options-and-model-internal-validation)
- Your own fit and predict functions as the model: `?model_adapter` and the
  installed example `model-adapter.R`
- Parameter selection and final assessment in
  `vignette("tuning", package = "vennabers")`

Keep `?calibrated_workflow` beside this guide for argument defaults.
`?workflow_training`, `?workflow_postprocessing`, `?venn_abers_details` and
`?venn_abers_tuning` hold the detailed contracts for each topic.

## A complete binary workflow

Hold out assessment data before constructing the procedure. Workflow
classification needs factor truth; the first level is the binary event by
default. Set `event_level = "second"` if the second level is your event.

```{r data}
library(vennabers)
set.seed(12)
data <- data.frame(x = rnorm(240))
data$truth <- factor(ifelse(runif(240) < plogis(data$x), "yes", "no"),
                     levels = c("yes", "no"))
development <- data[1:200, ]
assessment <- data[201:240, ]
new_data <- assessment["x"]
```

Put learned preprocessing inside the **unprepared** base recipe. Do not
normalize or impute the entire development set first: the calibration rows
must be excluded from learning those quantities too.

```{r inductive}
recipe <- recipes::recipe(truth ~ x, data = 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"))

spec <- calibrated_workflow(base, outcome = "truth",
                            calibration_prop = .3, seed = 42)
fitted <- generics::fit(spec, development)
probability <- predict(fitted, new_data, type = "prob")
head(probability)
head(predict(fitted, new_data, type = "class"))
brier_score(assessment$truth == "yes", probability$.pred_yes)
```

Here 30% of the development data calibrates the model trained on the remaining
70%. The constructor returns a trainable workflow; `fit()` performs the split
and learning. Predictions use raw predictors and include named `.pred_yes`
and `.pred_no` probability columns. For trainable classification workflows,
the default prediction type is class, so request `type = "prob"` explicitly.

```{r details}
details <- venn_abers_details(fitted, new_data)
head(details$details)
stopifnot(identical(probability, details$prediction))
```

`details$prediction` holds final probabilities. `details$details` identifies
each calibration fold, event, source model and binary bounds. Use
`loss = "brier"` in `predict()` or `venn_abers_details()` to override the stored
classification loss without learning again. Choosing the loss from data still
requires separate selection rows.

## Cross, matched models and full-model transfer

```{r cross}
cross_spec <- calibrated_workflow(base, "truth", method = "cross", v = 3,
                                  seed = 42)
cross_fit <- generics::fit(cross_spec, development)
head(predict(cross_fit, new_data, type = "prob"))
head(venn_abers_details(cross_fit, new_data)$details)
```

Cross fits one complete base workflow per fold complement. The default
`prediction_model = "matched"` retains those same workflows for new predictions.
`v` is a Cross option; `calibration_prop` is an Inductive option. For sizes set
independently, as with sklearn's `train_size`/`test_size`, use
`training_size` and `calibration_size`; rows in neither set are learned only by
a full prediction model (installed example `inductive-sizes.R`). Classification
splits are stratified by outcome by default; regression splits are unstratified.

`prediction_model = "full"` fits one additional complete workflow on the
permitted local fit data and uses its predictions with the unchanged held-out
calibrators. Without postprocessing or tuning, matched/full Inductive costs
one/two model fits, and matched/full Cross with K folds costs K/K+1 fits.
Full mode retains only the additional prediction model. This transfer does
not automatically inherit split-calibration guarantees.

Automatic splitting cannot undo leakage from earlier feature engineering or
parameter selection. For prescribed splits, pass a local `rsplit` (Inductive),
an unrepeated `rset` (Cross), or a function `splits = function(data) ...`.
Provider functions must build plans from their argument, because outer
resampling and tuning give them different local rows. Explicit plans cannot be
combined with explicit `calibration_prop`, `v` or `strata`; see
`?local_calibration_splits` for reusable prepared plans.

## Multiclass: calibration method versus model training

OvO/OvR selects how probabilities are calibrated. Separately,
`model_training = "shared"` uses one multiclass model per training split;
`model_training = "pairwise"` trains separate two-class models and is available
only for OvO. Shared OvO does not train pairwise models.

```{r multiclass}
set.seed(33)
iris_split <- rsample::initial_split(iris, prop = .8, strata = Species)
iris_dev <- rsample::analysis(iris_split)
iris_test <- rsample::assessment(iris_split)
tree <- workflows::workflow() |>
  workflows::add_formula(Species ~ .) |>
  workflows::add_model(parsnip::decision_tree(mode = "classification",
                                             engine = "rpart"))
for (classification in c("ovo", "ovr")) {
  multi_fit <- calibrated_workflow(tree, "Species",
    classification = classification, method = "cross", v = 3, seed = 33) |>
    generics::fit(iris_dev)
  print(head(predict(multi_fit, iris_test, type = "prob"), 2))
}

pair_fit <- calibrated_workflow(tree, "Species", classification = "ovo",
  model_training = "pairwise", seed = 33) |>
  generics::fit(iris_dev)
head(venn_abers_details(pair_fit, iris_test)$details)
```

Both paths produce final class probabilities. Diagnostics describe binary
class pairs or classes against the rest; they are not multiclass intervals.
Shared Cross uses a common fold plan; pairwise Cross creates plans within each
class pair. A global multiclass plan is not a valid pair-specific plan.

## Regression workflows

Mode comes from the base model. A numeric outcome, including numeric 0/1,
remains regression. Omit classification-only options such as `classification`,
`event_level`, `precision` and `loss`, including their explicit defaults.

```{r regression}
set.seed(52)
regression <- data.frame(x = rnorm(240))
regression$truth <- 2 * regression$x + rnorm(240, sd = .7)
reg_base <- workflows::workflow() |>
  workflows::add_formula(truth ~ x) |>
  workflows::add_model(parsnip::linear_reg(engine = "lm"))
reg_fit <- calibrated_workflow(reg_base, "truth", method = "cross", v = 3,
                               m = 1, seed = 52) |>
  generics::fit(regression[1:200, ])
head(predict(reg_fit, regression[201:240, "x", drop = FALSE]))
head(venn_abers_details(reg_fit, regression[201:240, ])$aggregate)
```

Use the default `method = "inductive"` for IVAR. Each calibration fold must
have at least three rows for `m = 1`, or enough rows for the requested `m` or
`epsilon`. Cross returns `.pred` points and, through details, descriptive mean
bounds. `epsilon` is moderation, not an interval error rate. Neither regression
mode provides confidence or prediction intervals.

## Frozen workflows: calibrate without retraining

Suppose a workflow was fitted earlier and its complete prediction path must
remain fixed. Supply fresh calibration data to `calibrate_workflow()`:

```{r frozen}
earlier <- generics::fit(base, development[1:120, ])
frozen <- calibrate_workflow(earlier, development[121:200, ], outcome = "truth")
head(predict(frozen, new_data, type = "prob"))
head(venn_abers_details(frozen, new_data)$details)
```

Only the calibrator learns. The retained recipe, engine and any existing
postprocessor are used through the original workflow's complete prediction
path. Give it raw data in the learned format; do not pre-bake a recipe or pass
an extracted engine. Calibration rows must have been excluded from all prior
training, preprocessing, postprocessing and selection. The package cannot
verify that history.

Frozen adoption supports binary, OvO/OvR and regression Inductive calibration.
For a fitted multiclass workflow, choose `classification = "ovo"` or `"ovr"`;
for regression, supply `m` or `epsilon` as needed and omit classification
options. It does not perform Cross calibration, full-model transfer or tuning.
The result is already fitted and is not an input to `tune::fit_resamples()`.

Save the complete predictor for later use:

```{r serialization}
path <- tempfile(fileext = ".rds")
saveRDS(frozen, path)
restored <- readRDS(path)
stopifnot(identical(predict(frozen, new_data, type = "prob"),
                    predict(restored, new_data, type = "prob")))
unlink(path)
```

In a new R process, load `vennabers`, `workflows` and the required engine
packages. Pure R engines such as this GLM support this round trip; engines
holding external resources retain their own serialization requirements.

## Learning a postprocessor before Venn-Abers

Trainable workflows can learn an additional postprocessor. The local model
training domain is split into B for recipe/engine learning and P for
postprocessing, with C still reserved for Venn-Abers calibration:

```{r postprocessing}
sigmoid_fit <- calibrated_workflow(base, "truth", seed = 42,
  postprocessing = list(method = "sigmoid", strategy = "holdout", prop = .3,
                        sigmoid_input = "probability")) |>
  generics::fit(development)
head(predict(sigmoid_fit, new_data, type = "prob"))
```

Here `prop` reserves 30% of the local training domain for P, not 30% of the
entire development data. Sigmoid supports binary and pairwise OvO workflows;
`sigmoid_input = "glm_link"` selects binomial GLM logit inputs instead.

Other fixed methods are `tailor_logistic` (binary, pairwise OvO or shared
two-class models), `tailor_multinomial` (shared OvO/OvR with at least three
classes), and `tailor_linear` or `isotonic_numeric` (regression). Tailor methods
need `tailor` and `probably`; Sigmoid and reference isotonic do not. Tailor's
multinomial P fit has a fixed 100-iteration limit and fails the composition if
it does not converge; changing the B engine's `maxit` does not change that limit.

With `strategy = "ensemble", v = 3`, omit `prop`: three complete B/P pairs are
retained and their finished predictions are averaged before VA calibration.
Full-model mode trains an additional complete B/P composition. See
`?workflow_postprocessing` for supported combinations and diagnostics.

## Engine options and model-internal validation

Fixed model options belong in the base workflow's model specification, as main
arguments or `parsnip::set_engine()` arguments. They travel with the base
workflow, so every local fit uses them on its own rows. `calibrated_workflow()`
and `fit()` take no extra fit arguments, and there is no interface for
separately supplied validation data.

An engine's internal validation, such as native XGBoost early stopping, splits
its share from the prepared rows of each local fit. A recipe in the base
workflow is learned on all of those rows first. xgboost is not a vennabers
dependency, so this chunk is not run:

```{r early-stopping, eval=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 <- calibrated_workflow(boosted, "truth", seed = 42) |>
  generics::fit(development)
```

| Local fit | Rows for learning and internal validation |
|:----------|:------------------------------------------|
| Inductive or Cross source | Its analysis rows, or their B with postprocessing; never C |
| Class pair | The analysis rows of that pair with its two original levels |
| Full1 | All local fit rows (F), or its own B with postprocessing |
| Search candidate | Its local training rows; held-out rows stay out |
| Frozen workflow | None: no base training, only calibration |

P trains only the postprocessor, and outer assessment rows never reach a fit.
An invalid setting fails the whole fit with its fold, pair or B/P context.
Internal validation is not an independent assessment of the procedure. No
support is promised for observation weights, offsets or an external
`eval_set`. The installed example `engine-options.R` runs fixed rpart options.

## Names, assessment and next steps

Reserved outcome or class names can be handled with `venn_abers_schema()`:
create the schema before training, encode data with `venn_abers_encode()`,
build the workflow on encoded names, pass the same schema to the constructor,
and decode results with `venn_abers_decode()`. A new schema cannot rename an
already fitted workflow; frozen adoption must retain its original schema.

For unbiased assessment of the complete trainable procedure, resample the
**outer calibrated workflow**, so preprocessing and calibration are relearned
inside each outer analysis set. The next vignette,
`vignette("tuning", package = "vennabers")`, shows native resampling, joint and
two-stage selection, and an untouched final assessment split.

For the complete standalone scripts with a contents list, open
`vignette("examples-workflows", package = "vennabers")`.
`?vennabers_examples` indexes all four example collections.
