---
title: "Diagnostic accuracy and classifier metrics"
author: "Raban Heller"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Diagnostic accuracy and classifier metrics}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
resource_files:
- figures/logo.svg
- figures/logo-dark.svg
---
```{r setup, include=FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
library(infercalcR)
```
A binary classifier, such as a diagnostic test or a risk score with a cutoff,
sorts units into alerts and non-alerts. Compared with the true class, this gives
four counts: true positives (TP), false negatives (FN), false positives (FP) and
true negatives (TN). infercalcR calculates the usual accuracy metrics from these
counts, keeps track of their denominators and reports an explicit status when a
metric is undefined. With scored observations it also evaluates cutoffs, ROC
summaries and cutoff selection.
All data in this tutorial are synthetic. Calculations from counts need only base
R. Specifications built from scored observations record a canonical fingerprint
of their inputs and therefore use the optional packages digest and jsonlite; the
chunks that need them are evaluated only when both are installed.
```{r optional-packages}
has_fingerprints <- requireNamespace("digest", quietly = TRUE) &&
requireNamespace("jsonlite", quietly = TRUE)
has_fingerprints
```
## Metrics from four counts
```{r counts}
counts <- c(tp = 40, fn = 10, fp = 20, tn = 130)
metrics <- classification_metrics(counts)
metrics[metrics$metric_id %in% c("prevalence", "tpr", "tnr", "ppv", "npv",
"lr_pos", "lr_neg", "youden_j"),
c("metric_id", "value", "status", "denominator", "denominator_value")]
```
Sensitivity (`tpr`) divides TP by all events and specificity (`tnr`) divides TN
by all non-events. The positive predictive value (`ppv`) divides TP by all
alerts, and the negative predictive value (`npv`) divides TN by all non-alerts.
The likelihood ratios combine sensitivity and specificity, and Youden's J is
their sum minus one. The `denominator` column states which counts each
proportion uses.
When a denominator is zero, the metric is not set to zero or one. Its value is
`NA`, its status is `"undefined"` and the reason is given.
```{r undefined}
no_alerts <- classification_metrics(c(tp = 0, fn = 5, fp = 0, tn = 95))
no_alerts[no_alerts$metric_id %in% c("tpr", "ppv", "npv", "lr_pos"),
c("metric_id", "value", "status", "reason")]
```
`metric_dictionary()` documents every metric, including its formula and whether
it depends on prevalence.
```{r dictionary}
dictionary <- metric_dictionary()
dictionary[dictionary$id %in% c("tpr", "ppv", "lr_pos"),
c("id", "name", "formula", "prevalence_dependent")]
```
## Prevalence and predictive values
Sensitivity and specificity describe the test within each class. Predictive
values also depend on how common events are. If sensitivity and specificity
carry over unchanged to another setting, Bayes' theorem gives the predictive
values at an assumed prevalence. These are scenario values, not new
observations.
```{r prevalence}
tpr <- 40 / 50
fpr <- 20 / 150
predictive_values_at_prevalence(tpr, fpr, prevalence = c(0.01, 0.05, 0.25))
prevalence_for_ppv_target(tpr, fpr, target = 0.5)
```
At a prevalence of 1 %, only about 6 % of alerts are events, although the test
is the same. A prevalence of about 14 % is needed for half of the alerts to be
events.
## Intervals for a fixed-cutoff table
The `diagnostic_2x2_fixed_cutoff` method works through the common interface
described in the [getting-started tutorial](getting-started.html). It requires
the sampling design and the origin of the cutoff to be declared, and reports
Clopper-Pearson intervals that are conditional on the fixed rule.
```{r diagnostic-table, eval = has_fingerprints}
dx_spec <- method_spec("diagnostic_2x2_fixed_cutoff", conf.level = 0.95)
dx <- run_inference(dx_spec, tp = 40, fn = 10, fp = 20, tn = 130,
sampling = "cross_sectional",
cutoff_origin = "predeclared_rule",
evaluation_split = "held_out_test")
dx_rows <- result_data(dx)
print(dx_rows[dx_rows$estimand %in% c("tpr", "tnr", "ppv", "npv"),
c("estimand", "estimate", "conf.low", "conf.high",
"interval.method", "status")],
row.names = FALSE)
```
Four counts identify a single operating point. They cannot reproduce a ROC
curve, an AUC or a sweep over cutoffs; those need the scores.
## Scored observations
The next example simulates a continuous marker. Events have a mean marker value
1.2 standard deviations above non-events, and about 30 % of units are events.
Two independent samples are drawn: one to tune a cutoff and one to evaluate it.
```{r simulate}
simulate_marker <- function(n, first_id) {
event <- rbinom(n, size = 1, prob = 0.3)
data.frame(id = first_id + seq_len(n) - 1L,
event = event,
marker = round(rnorm(n, mean = 1.2 * event, sd = 1), 2))
}
set.seed(2026)
tuning <- simulate_marker(200, first_id = 1L)
evaluation <- simulate_marker(200, first_id = 1001L)
c(tuning = mean(tuning$event), evaluation = mean(evaluation$event))
```
A specification names the truth column, the positive class, the score column,
the score direction and the comparison at the cutoff. None of these are
guessed. It also records where the data come from relative to the cutoff and
how the units were sampled. Here the cutoff 0.5 is fixed in advance.
```{r specification, eval = has_fingerprints}
marker_spec <- function(data, split) {
classification_spec(
data = data, truth = "event", positive_label = 1, negative_label = 0,
scores = "marker", id = "id", cutoff = 0.5,
direction = "higher_is_positive", comparator = ">=",
evaluation_split = split, sampling = "cross_sectional"
)
}
tuning_spec <- marker_spec(tuning, split = "same_data")
fixed <- evaluate_cutoff(tuning_spec)
fixed
```
The printed result lists the counts, the main metrics, the ROC AUC and the
limitations that apply to this evaluation. `classification_table()` returns the
metrics and their intervals as data frames.
```{r tables, eval = has_fingerprints}
intervals <- classification_table(fixed, "intervals")
intervals[intervals$metric_id %in% c("tpr", "fpr"),
c("metric_id", "method", "level", "lower", "upper")]
classification_auc(tuning_spec)[, c("metric_id", "value", "ci_lower",
"ci_upper", "setting")]
```
`cutoff_sweep()` evaluates every distinct decision set of the observed scores,
from alerting no unit to alerting all units. Its rates trace the empirical ROC
curve.
```{r sweep, eval = has_fingerprints, fig.width = 4.5, fig.height = 4.5, fig.alt = "Empirical ROC curve of the simulated marker on the tuning sample, with the point for the prespecified cutoff 0.5 marked."}
sweep <- cutoff_sweep(tuning_spec)
nrow(sweep)
head(sweep[, c("cutoff", "alerts", "tp", "fp", "tpr", "fpr")])
plot(sweep$fpr, sweep$tpr, type = "l", xlim = c(0, 1), ylim = c(0, 1),
xlab = "False positive rate", ylab = "True positive rate",
main = "Tuning sample")
abline(0, 1, lty = 3)
point <- fixed$models[[1]]$metrics
points(point$value[point$metric_id == "fpr"],
point$value[point$metric_id == "tpr"], pch = 19)
```
## Select a cutoff, then evaluate it on new data
`select_cutoff()` searches all distinct decision sets for the cutoff that
optimizes a declared objective. Here the objective is Youden's J.
```{r select, eval = has_fingerprints}
selected <- select_cutoff(tuning_spec, "max_youden")
selected$models[[1]]$operating_point$cutoff
classification_table(selected, "selection")[, c("procedure", "objective_value",
"n_candidates", "n_tied",
"tie_break")]
```
A cutoff chosen on the same data that are used to describe it gives in-sample
values. To evaluate it, pass the tuning result as the cutoff for a
specification of the independent sample.
```{r held-out, eval = has_fingerprints}
evaluation_spec <- marker_spec(evaluation, split = "held_out_test")
held_out <- evaluate_cutoff(evaluation_spec, cutoff = selected)
held_out$evaluation$label
compare <- function(result, ids = c("tpr", "fpr", "youden_j")) {
m <- result$models[[1]]$metrics
setNames(m$value[match(ids, m$metric_id)], ids)
}
rbind(in_sample = compare(selected), held_out = compare(held_out))
```
Because the data were simulated, the true operating characteristics are known.
`binormal_rates()` computes them for a binormal model with a given AUC, with the
cutoff on the scale of the non-event distribution. With a mean difference of 1.2
standard deviations and equal variances, the AUC is `pnorm(1.2 / sqrt(2))` and
Youden's J is largest at the midpoint 0.6.
```{r theory}
true_auc <- pnorm(1.2 / sqrt(2))
truth <- binormal_rates(cutoff = c(0.5, 0.6), auc = true_auc)
truth$youden_j <- truth$tpr - truth$fpr
truth
```
In this simulated split, the in-sample Youden's J of the selected cutoff is
higher than the true value of about 0.45 and the held-out value is lower. One
split illustrates the variability of these estimates; it does not quantify the
optimism of in-sample selection.
## Common result contract
`as_inference()` converts a classification result to the common result
contract. The returned object keeps aggregate values and intervals but not the
individual observations or identifiers.
```{r common, eval = has_fingerprints}
common <- as_inference(held_out)
rows <- result_data(common)
print(rows[rows$estimand %in% c("tpr", "fpr", "ppv", "roc_auc"),
c("estimand", "estimate", "conf.low", "conf.high",
"interval.method")],
row.names = FALSE)
result_metadata(common)$evaluation$evaluation_split
```
## Notes on interpretation
- The positive class, score direction and comparator are declarations. The
package does not infer them from the data.
- The `sampling` and `evaluation_split` arguments document the design; they do
not verify it. Independent tuning and evaluation samples must be ensured by
the study design.
- Proportion intervals are conditional on the cutoff. They do not account for
the selection of that cutoff or for clustered observations.
- In a case-control sample, the share of events is fixed by design, so
predictive values from those counts do not estimate the population values.
The [exact tests tutorial](exact-tests.html) covers comparisons of two
independent proportions.
```{r acknowledgments, child = "children/_acknowledgments.Rmd"}
```
```{r session-information}
sessionInfo()
```