---
title: "The model bench: open and closed boxes"
author: "Raban Heller, Gerhard Achatz"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{The model bench: open and closed boxes}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
resource_files:
- figures/logo.svg
- figures/logo-dark.svg
---
```{r, include = FALSE}
knitr::opts_chunk$set(
collapse = TRUE, comment = "#>",
fig.width = 7, fig.height = 4, dpi = 96
)
has <- function(...) {
all(vapply(c(...), requireNamespace, logical(1), quietly = TRUE))
}
core <- has("parsnip", "recipes", "workflows", "rsample", "tune",
"yardstick", "dials", "hardhat")
bench_ready <- core && has("rpart", "glmnet", "ranger")
explain_ready <- bench_ready && has("DALEX", "DALEXtra")
```
```{r setup}
library(sandtablR)
```
An interpretable model is worth having only if you know what it costs. This
article measures that: the same data, the same preprocessing, the same
held-out split, scored across engines you can read and engines you cannot.
The modelling chunks require `parsnip`, `recipes`, `workflows`, `rsample`,
`tune`, `yardstick`, `dials`, `hardhat`, `rpart`, `glmnet` and `ranger`.
The explanation chunks additionally require `DALEX` and `DALEXtra`. Sections
with missing optional packages are not executed; the table below records which
parts are available in this build.
```{r capabilities}
data.frame(section = c("Model comparison", "Explanations"),
available = c(bench_ready, explain_ready))
```
## Define the target before comparing models
The first thing to get right is what to predict.
The card's true sighting category is a deterministic function of life points
by construction: red is 1–4, yellow 5–8, green 9–12. Any engine recovers that
rule exactly, so a leaderboard on it is a row of ones.
```{r trivial, eval = bench_ready}
co <- sdt_synthetic_cohort(n = 400, seed = 1)
trivial <- sdt_fit(co, target = "triage", engine = "tree",
tune = FALSE, folds = 3, seed = 1, verbose = FALSE)
sdt_metrics(trivial)
```
What a *participant* calls is a different matter. The published evaluation
reports how often participants agreed with the card: 88% for red, 59% for
yellow, 71% for green. `sdt_add_decisions()` generates calls with those hit
rates, weighting miscalls toward neighbouring categories, since red is
mistaken for yellow far more often than for green.
```{r decisions}
labelled <- sdt_add_decisions(sdt_synthetic_cohort(n = 800, seed = 5),
seed = 6)
table(true = labelled$triage, called = labelled$called_triage)
```
Agreement between the two is well above chance and well short of perfect,
which is what makes it a real prediction problem:
```{r kappa}
sdt_consistency_score(labelled$triage, labelled$called_triage)
```
Kappa accounts for agreement expected from the marginal category frequencies.
Raw agreement alone can look high when one category dominates. If both raters
always choose the same category, agreement is 100% but kappa is undefined;
there is no variation with which to assess discrimination.
## The bench
`sdt_workflowset()` fits every installed engine to the same data through the
same recipe and returns one leaderboard. Engines whose package is missing are
skipped and named, not silently dropped.
```{r bench, eval = bench_ready}
board <- sdt_workflowset(
labelled, target = "called_triage",
engines = c("tree", "glmnet", "ranger"),
tune = FALSE, folds = 3, seed = 1, verbose = FALSE
)
board
```
`class_box` is the column the article is about. `glass` marks models whose
decision rule can be read off directly: a single decision tree, a penalised
multinomial regression. `black` marks models that cannot be, however good.
```{r skipped, eval = bench_ready}
attr(board, "skipped")
```
The fitted models come back attached, so the winner can be interrogated
without refitting:
```{r models, eval = bench_ready}
models <- attr(board, "models")
names(models)
```
## Read the comparison
The question is not which engine wins. It is how much is given up by choosing
one you can read.
```{r gap, eval = bench_ready}
auc <- board[board$.metric == "roc_auc", ]
glass <- max(auc$.estimate[auc$class_box == "glass"])
black <- max(auc$.estimate[auc$class_box == "black"])
c(best_glass_box = glass, best_black_box = black, difference = glass - black)
```
The printed gap describes this synthetic sample, split and set of fixed
hyperparameters. Its sign and size are outcomes of the example, not a general
claim that one model family is superior. Selecting the best engine using this
comparison also requires a separate final evaluation for an unbiased assessment.
```{r roc, eval = bench_ready, fig.alt = "ROC curves, one per class, for the leading model", fig.cap = "ROC curves, one per class, for the leading model. Synthetic demonstration."}
best <- auc$engine[which.max(auc$.estimate)]
sdt_roc(models[[best]])
```
## Explaining both the same way
A glass-box model can be read directly, but a black-box one cannot, so the
only way to put them side by side is to explain both from the outside.
Permutation importance does that: it asks how much worse a model gets when a
predictor is shuffled, which is defined identically for every engine.
```{r importance, eval = explain_ready, fig.alt = "Permutation variable importance for the leading model", fig.cap = "Permutation variable importance for the leading model. Synthetic demonstration."}
sdt_importance(models[[best]], n_permutations = 5)
```
Agreement between importance profiles does not establish a causal explanation.
Correlated predictors, model instability and the evaluation sample can affect
these summaries. Inspect the predictive results and the data-generating
assumptions alongside each explanation.
```{r profile, eval = explain_ready, fig.alt = "Partial dependence of predicted probability on life points", fig.cap = "Partial dependence of predicted probability on life points. Synthetic demonstration."}
sdt_profile(models[[best]], variables = "life_points")
```
For a single casualty, `sdt_shap()` decomposes one prediction into
per-predictor contributions: not "what does this model use", but "why this
casualty".
## Caveats
Everything above runs on synthetic data whose structure is known, because it
was generated. That is useful for demonstrating the bench and useless as
evidence about real mass-casualty decision-making. With a real seed cohort,
`sdt_synthesise()` produces a synthetic candidate and `sdt_synthesis_utility()`
assesses utility. Neither establishes anonymity or permission to release data;
privacy assessment and disclosure review are separate tasks.
## Software environment
```{r session-information}
sessionInfo()
```