15  Evaluating Models: Beyond Accuracy

15.1 Introduction

You have built a model. It produces predictions. But how do you know if those predictions are any good? The answer is (often) not as simple as asking “how often is it right?” In clinical medicine, the consequences of different errors are rarely equal. Missing a serious diagnosis is not the same as raising a false alarm. The outcomes we care about are often rare. And the patients we apply a model to may look nothing like the patients we trained it on. This chapter introduces the core concepts and tools for evaluating classification models, with a focus on clinical prediction.

NoteWhy a clinician should care

The metric you choose to judge a model decides which patients it will help and which it may harm. A model that looks excellent on paper can still miss the very patients it was built to find. Knowing how to read these metrics is as important as knowing how to read a lab result.

15.2 The Confusion Matrix

A confusion matrix is simply the scorecard for a classification model: a small table that counts how often the model’s predictions matched the truth, and how often they did not. For a yes/no outcome, it is a 2x2 table that cross-classifies what the model predicted against what was actually true:

Actually Positive Actually Negative
Predicted Positive True Positive (TP) False Positive (FP)
Predicted Negative False Negative (FN) True Negative (TN)
  • The two diagonal cells (TP and TN) are the model’s correct calls
    • A true positive is a correct call: the model says “disease” and the patient is indeed ill.
    • A true negative is also a correct call: the model says “no disease” and the patient is indeed well.
  • The two off-diagonal cells are the model’s mistakes
    • A false negative is a missed case: the model says “no disease” but the patient is in fact ill (for example, a cancer screen that comes back clear when a tumour is present).
    • A false positive is a false alarm: the model says “disease” but the patient is in fact well (a screen that flags a healthy person, leading to anxiety and further tests).

Every metric discussed in this chapter can be calculated from these four counts. The crucial insight is that a single model can produce different confusion matrices depending on the threshold you choose — the cut-off probability above which you call a prediction “positive.”

15.3 The Accuracy Paradox

The simplest metric for evaluating a model is accuracy: the proportion of predictions that are correct. In “confusion matrix” terms, it is the sum of true positives and true negatives divided by the total number of predictions:

\[\text{Accuracy} = \frac{TP + TN}{TP + TN + FP + FN}\]

Despite its simplicity, accuracy can be misleading. Consider the following scenario: you are developing a model to predict a rare but serious condition (e.g., pancreatic cancer) in a primary care population. The prevalence is approximately 0.1%. You build a classifier and proudly report 99.9% accuracy. Your colleague, less impressed, points out that a model which simply predicts “no cancer” for every single patient would also achieve 99.9% accuracy. It would miss every true case, which is the entire point of the exercise, but its accuracy would be nearly perfect.

To see why, imagine 10,000 patients where 10 truly have cancer. The “predict everyone negative” model produces:

Actually Positive Actually Negative
Predicted Positive 0 0
Predicted Negative 10 9,990

\[\text{Accuracy} = \frac{0 + 9{,}990}{0 + 9{,}990 + 10 + 0} = \frac{9{,}990}{10{,}000} = 99.9\%\]

Every cancer case is missed (10 false negatives), yet accuracy is nearly perfect: this is the accuracy paradox. The main underlying issue is that of class imbalance, that is, when one outcome is far more common than the other(s). In these settings, accuracy becomes meaningless because it is dominated by the common group (the “majority class”). In clinical medicine, outcomes are almost always imbalanced:

  • Most people do not have the disease you are screening for
  • Most surgical patients do not die within 30 days
  • Most pregnancies do not result in pre-eclampsia

A metric that ignores which errors the model makes is not useful for clinical decision-making.

NoteBeyond two classes

Confusion matrices generalise to multi-class problems. For example, a model that classifies tumour stage (I, II, III) would produce a 3×3 matrix:

Actually I Actually II Actually III
Pred. I 40 5 1
Pred. II 3 35 7
Pred. III 0 4 30

Each row shows how the model’s predictions break down against the true classes. The diagonal counts correct predictions; off-diagonal entries reveal which classes the model confuses. Here, accuracy = (40 + 35 + 30) / 125 = 84%. This chapter focuses on the binary (2×2) case, which is the most common in clinical prediction.

We will therefore move beyond the seductive simplicity of “accuracy” and learn to ask better questions: Does the model separate sick from well? Are the predicted probabilities trustworthy? Would using this model actually help patients? And does it help all patients equally?

15.4 Sensitivity and Specificity

The most useful first step is to split the model’s behaviour into two groups: those who truly have the condition and those who do not.

Sensitivity (also called recall or true positive rate (TPR)) answers: among everyone who truly has the disease, what proportion did the model correctly identify? In plain terms, how good is the model at catching the sick? A model with low sensitivity misses many real cases.

\[\text{Sensitivity} = \frac{\text{TP}}{\text{TP} + \text{FN}} = \frac{\text{True Positives}}{\text{Actually Positive}}\]

In the example above, the sensitivity of the model is:

\[\text{Sensitivity} = \frac{0}{0 + 10} = 0\%\]

Specificity (the true negative rate) answers: among everyone who is truly disease-free, what proportion did the model correctly clear? In plain terms, how good is the model at leaving the well alone? A model with low specificity raises many false alarms.

\[\text{Specificity} = \frac{\text{TN}}{\text{TN} + \text{FP}} = \frac{\text{True Negatives}}{\text{Actually Negative}}\]

In the example above, the specificity of the model is:

\[\text{Specificity} = \frac{9{,}990}{9{,}990 + 0} = 100\%\]

These two numbers are properties of the test itself. They do not change with how common the disease is in the population. That makes them a clean way to describe how well a test separates the sick from the well.

15.4.1 Clinical Context: Screening vs Confirmation

The relative importance of sensitivity and specificity depends on the clinical purpose:

  • Screening tests (e.g., mammography for breast cancer, the PHQ-2 for depression) prioritise high sensitivity. The goal is to catch as many true cases as possible. We accept more false positives because the next step is a confirmatory test, not treatment. Missing a case (false negative) is the costly error.

  • Confirmatory tests (e.g., biopsy for cancer, or the HIV-1/HIV-2 antibody differentiation immunoassay that has replaced the older Western blot in modern testing algorithms) prioritise high specificity. The goal is to be sure that a positive result is real, because a positive typically triggers treatment, which may carry risks. A false positive leading to unnecessary surgery or chemotherapy is the costly error.

TipMemory Aid

Sensitivity = True Positive Rate = How good is the test at catching the sick? Specificity = True Negative Rate = How good is the test at leaving the well alone? SnNout: a test with high Sensitivity, when Negative, rules out the disease. SpPin: a test with high Specificity, when Positive, rules in the disease.

15.5 Predictive Values: When Prevalence Matters

Sensitivity and specificity describe the test. But in clinic, the patient asks a different and more personal question: given my result, how likely is it that I actually have the disease? This is what predictive values answer, and it is usually what matters most at the bedside.

Positive Predictive Value (PPV) (also called precision): among everyone who tested positive, what proportion truly has the disease? In other words, if your test came back positive, how worried should you be?

\[\text{PPV} = \frac{\text{TP}}{\text{TP} + \text{FP}} = \frac{\text{True Positives}}{\text{Predicted Positive}}\]

Negative Predictive Value (NPV): among everyone who tested negative, what proportion is truly disease-free? In other words, if your test came back negative, how reassured can you be?

\[\text{NPV} = \frac{\text{TN}}{\text{TN} + \text{FN}} = \frac{\text{True Negatives}}{\text{Predicted Negative}}\]

Critically, PPV and NPV depend on the prevalence of the disease — that is, how common it is in the population being tested. A test with 95% sensitivity and 95% specificity will have very different PPVs depending on context:

Table 15.1: PPV and NPV for a test with 95% sensitivity and 95% specificity at different prevalence levels.
Prevalence PPV NPV
50% 95.0% 95.0%
10% 67.9% 99.4%
1% 16.1% 99.9%
0.1% 1.9% 100%

At 0.1% prevalence, even with an excellent test, fewer than 2% of positive results are true positives. This is why mass screening for rare diseases generates so many false alarms, and why testing should be targeted to higher-risk populations whenever possible.

NoteHow to calculate PPV and NPV from prevalence

PPV and NPV can be derived from sensitivity, specificity, and prevalence:

\[\text{PPV} = \frac{\text{Sens} \times \text{Prev}}{\text{Sens} \times \text{Prev} + (1 - \text{Spec}) \times (1 - \text{Prev})}\]

\[\text{NPV} = \frac{\text{Spec} \times (1 - \text{Prev})}{\text{Spec} \times (1 - \text{Prev}) + (1 - \text{Sens}) \times \text{Prev}}\]

To see why, start from PPV = TP / (TP + FP) and note that in a population of size \(N\): TP = Sens \(\times\) Prev \(\times N\) and FP = (1 \(-\) Spec) \(\times\) (1 \(-\) Prev) \(\times N\). The \(N\) cancels, giving the formula above. The same logic applies to NPV.

15.6 Confusion matrices at different thresholds

Code
library(caret)

# Simulate predicted probabilities and true outcomes
set.seed(42)
n <- 1000
true_outcome <- rbinom(n, 1, 0.15) # 15% prevalence
# Simulate a moderately good model
pred_prob <- plogis(rnorm(n, mean = -1 + 2 * true_outcome, sd = 1.2))

# Confusion matrix at threshold = 0.5
pred_class_50 <- ifelse(pred_prob >= 0.5, 1, 0)
cm_50 <- confusionMatrix(
  factor(pred_class_50),
  factor(true_outcome),
  positive = "1"
)
print(cm_50)

# Confusion matrix at threshold = 0.2 (more sensitive)
pred_class_20 <- ifelse(pred_prob >= 0.2, 1, 0)
cm_20 <- confusionMatrix(
  factor(pred_class_20),
  factor(true_outcome),
  positive = "1"
)
print(cm_20)

cat("\nAt threshold 0.5:\n")
cat("  Sensitivity:", round(cm_50$byClass["Sensitivity"], 3), "\n")
cat("  Specificity:", round(cm_50$byClass["Specificity"], 3), "\n")

cat("\nAt threshold 0.2:\n")
cat("  Sensitivity:", round(cm_20$byClass["Sensitivity"], 3), "\n")
cat("  Specificity:", round(cm_20$byClass["Specificity"], 3), "\n")
Code
import numpy as np
from sklearn.metrics import confusion_matrix, classification_report
from scipy.special import expit

np.random.seed(42)
n = 1000
true_outcome = np.random.binomial(1, 0.15, n)  # 15% prevalence
# Simulate a moderately good model
pred_prob = expit(np.random.normal(-1 + 2 * true_outcome, 1.2))

# Confusion matrix at threshold = 0.5
pred_class_50 = (pred_prob >= 0.5).astype(int)
print("=== Threshold = 0.5 ===")
print(confusion_matrix(true_outcome, pred_class_50))
print(classification_report(true_outcome, pred_class_50, target_names=["Negative", "Positive"]))

# Confusion matrix at threshold = 0.2 (more sensitive)
pred_class_20 = (pred_prob >= 0.2).astype(int)
print("=== Threshold = 0.2 ===")
print(confusion_matrix(true_outcome, pred_class_20))
print(classification_report(true_outcome, pred_class_20, target_names=["Negative", "Positive"]))

What the code is showing. Here, we simulate a dataset of 1,000 patients with a 15% prevalence of disease and a moderately good model for predicting disease status. We then build two confusion matrices from the same predicted probabilities: one using a threshold of 0.5 (the default) and another using a lower threshold of 0.2. At the strict 0.5 threshold the model is cautious, so specificity is high but it misses many true cases (low sensitivity). At the lower 0.2 threshold, the model catches far more true cases (sensitivity rises) at the cost of more false alarms (specificity falls). The printed tables and the sensitivity/specificity lines tell the story. This demonstrates that there is no single “right” confusion matrix, as it changes when you move the threshold, even though the model itself never changes. The choice of threshold should be guided by the clinical context and the relative costs of false negatives versus false positives.

15.7 Receiver Operating Characteristic (ROC) Curves

Receiver Operating Characteristic (ROC) curves are widely used graphical tools for evaluating classification models. For every possible threshold, ROC curves plot sensitivity on the y-axis against 1 minus specificity (the false positive rate) on the x-axis, sweeping the threshold from strict to lenient. In other words, they depict the trade-off between catching the sick (sensitivity) and falsely alarming the well (1 - specificity).

15.7.1 How to Read a ROC Curve

  • The two axes each run from 0 to 1. Reading the corners in plain terms: the bottom-left point (0, 0) is a model so cautious it never flags anyone as positive (no false alarms, but it misses every case); the top-right point (1, 1) is a model so lenient it flags everyone (it catches every case, but every healthy person is a false alarm). A real model traces a curve between these two extremes as the threshold is swept.
  • The diagonal line joining those two corners represents a model with no ability to tell sick from well — no better than flipping a coin.
  • A curve that bows toward the upper-left corner is good. It means the model achieves high sensitivity without raising too many false alarms.
  • From ROC curves, two metrics are often reported:
    • The Area Under the ROC Curve (AUC, also called AUROC, AUC-ROC or the C-statistic) measures the integral of the ROC curve, i.e., the area of the region between the curve and the x-axis. It has an intuitive probabilistic meaning: pick one patient with the disease and one without, at random; the AUC is the probability the model gives the sick patient a higher risk score.
      • An AUC of 0.5 represents chance (the diagonal)
      • An AUC of 1.0 represents perfect (the curve hugs the top-left corner, covering the entire square).
      • An AUC below 0.5 means the model’s predictions are inverted (it ranks the well above the sick) which usually signals poor fitting (or an implementation error).
    • Youden’s J statistic (also called Youden’s index) identifies the point on the ROC curve that is farthest from the diagonal: \(J = \text{Sensitivity} + \text{Specificity} - 1\). The threshold at which \(J\) is maximised is often reported as the “optimal” threshold. However, this implicitly assumes that a false positive and a false negative are equally costly — an assumption that is rarely true in medicine. Use Youden’s index as a starting point, but always adjust based on clinical reasoning (see Section 15.9 below).
Table 15.2: Rough guide to AUC interpretation. Note that these benchmarks are extremely context-dependent.
AUC Range Interpretation
0.90–1.00 Excellent discrimination
0.80–0.90 Good discrimination
0.70–0.80 Acceptable
0.60–0.70 Poor
0.50–0.60 Fail (near chance)
WarningWhen the AUC Is Not Enough

A model can have a high AUC but still produce poorly calibrated probabilities. Calibration asks whether the numbers can be taken at face value: when the model says “20% risk,” do about 20 in 100 such patients actually have the event? A model can rank patients perfectly (high AUC) yet systematically over- or under-state the risk, which would mislead any decision based on the actual percentage. AUC tells you about ranking (discrimination), not about whether the predicted probabilities are truthful. Examining model calibration can provide useful supplementary information to AUC-ROC measurements (see Chapter 18).

15.7.2 Plotting ROC Curves

Code
library(pROC)

set.seed(42)
n <- 1000
true_outcome <- rbinom(n, 1, 0.15)

# Model A: low signal
pred_a <- plogis(rnorm(n, mean = -1 + 0.8 * true_outcome, sd = 1.2))
# Model B: stronger signal
pred_b <- plogis(rnorm(n, mean = -1 + 2 * true_outcome, sd = 1.2))

roc_a <- roc(true_outcome, pred_a, quiet = TRUE)
roc_b <- roc(true_outcome, pred_b, quiet = TRUE)

# Plot both ROC curves
plot(
  roc_a,
  col = "darkorange",
  lwd = 2,
  legacy.axes = TRUE,
  main = "Comparing Two Models"
)
plot(roc_b, col = "steelblue", lwd = 2, add = TRUE)
abline(0, 1, lty = 2, col = "grey50")
legend(
  "bottomright",
  legend = c(
    paste("Model A (AUC =", round(auc(roc_a), 3), ")"),
    paste("Model B (AUC =", round(auc(roc_b), 3), ")")
  ),
  col = c("darkorange", "steelblue"),
  lwd = 2
)

ROC curves for two simulated models on the same data. Model B (stronger signal) bows further toward the top-left corner and has the higher AUC; Model A sits closer to the chance diagonal.
Code
# Youden-optimal threshold for the strong model
coords_best <- coords(
  roc_b,
  "best",
  ret = c("threshold", "sensitivity", "specificity")
)
cat(
  "Model B — optimal threshold (Youden):",
  round(coords_best$threshold, 3),
  "\n"
)
cat("Sensitivity:", round(coords_best$sensitivity, 3), "\n")
cat("Specificity:", round(coords_best$specificity, 3), "\n")
Model B — optimal threshold (Youden): 0.561 
Sensitivity: 0.785 
Specificity: 0.851 
Code
from sklearn.metrics import roc_curve, roc_auc_score
import matplotlib.pyplot as plt
import numpy as np
from scipy.special import expit

np.random.seed(42)
n = 1000
true_outcome = np.random.binomial(1, 0.15, n)

# Model A: low signal
pred_a = expit(np.random.normal(-1 + 0.8 * true_outcome, 1.2))
# Model B: stronger signal
pred_b = expit(np.random.normal(-1 + 2 * true_outcome, 1.2))

fpr_a, tpr_a, _ = roc_curve(true_outcome, pred_a)
auc_a = roc_auc_score(true_outcome, pred_a)

fpr_b, tpr_b, thresholds_b = roc_curve(true_outcome, pred_b)
auc_b = roc_auc_score(true_outcome, pred_b)

plt.figure(figsize=(7, 7))
plt.plot(fpr_a, tpr_a, color="darkorange", lw=2,
         label=f"Model A (AUC = {auc_a:.3f})")
plt.plot(fpr_b, tpr_b, color="steelblue", lw=2,
         label=f"Model B (AUC = {auc_b:.3f})")
plt.plot([0, 1], [0, 1], color="grey", linestyle="--", label="Chance")
plt.xlabel("False Positive Rate (1 - Specificity)")
plt.ylabel("True Positive Rate (Sensitivity)")
plt.title("Comparing Two Models")
plt.legend(loc="lower right")
plt.tight_layout()
plt.show()

# Youden-optimal threshold for the strong model
j_scores = tpr_b - fpr_b
best_idx = np.argmax(j_scores)
print(f"Model B — optimal threshold (Youden): {thresholds_b[best_idx]:.3f}")
print(f"Sensitivity: {tpr_b[best_idx]:.3f}")
print(f"Specificity: {1 - fpr_b[best_idx]:.3f}")
<Figure size 700x700 with 0 Axes>
[<matplotlib.lines.Line2D object at 0x74000b41b9d0>]
[<matplotlib.lines.Line2D object at 0x74000b37fd90>]
[<matplotlib.lines.Line2D object at 0x74000b37d590>]
Text(0.5, 0, 'False Positive Rate (1 - Specificity)')
Text(0, 0.5, 'True Positive Rate (Sensitivity)')
Text(0.5, 1.0, 'Comparing Two Models')
<matplotlib.legend.Legend object at 0x74000b3723c0>

ROC curves for two simulated models on the same data. Model B (stronger signal) bows further toward the top-left corner and has the higher AUC; Model A sits closer to the chance diagonal.
Model B — optimal threshold (Youden): 0.500
Sensitivity: 0.850
Specificity: 0.822

What the code is showing. The snippets simulate two models on the same data and overlay their ROC curves. Model B’s curve bows further toward the top-left corner, producing a higher AUC; Model A’s curve stays closer to the diagonal (chance). Plotting both on the same axes makes the difference in discriminative ability immediately visible: at any given false-positive rate, Model B catches more true cases. The code also finds the threshold that maximises Youden’s J for Model B, reporting the sensitivity and specificity there. Treat that “optimal” threshold as a statistical suggestion only: it assumes a false negative and a false positive cost the same, which is rarely true in medicine (see Section 15.9).

15.8 Precision-Recall Curves

The precision-recall (PR) curve offers a different view of model performance by ignoring the true negatives entirely and focusing on the patients the model flags as positive. It plots two familiar metrics under different names:

  • Precision is the same as PPV: of all the patients the model flagged, how many really had the disease? (How trustworthy is a positive flag?)
  • Recall is the same as sensitivity: of all the patients who really had the disease, how many did the model catch?

15.8.1 How to Read a PR Curve

  • The PR curve plots precision (y-axis) against recall (x-axis). A good model keeps precision high even as recall rises — it catches real cases without drowning in false alarms.
  • The baseline is a flat line at the prevalence (not the diagonal used for ROC). A model that flags patients at random would sit on this line.
  • The Area Under the Precision-Recall Curve (AUPRC) summarises the curve in one number. Unlike AUROC, there is no universal scale — the baseline is the prevalence itself, so an AUPRC of 0.20 may be excellent for a disease with 1% prevalence but poor for one with 15%.

15.8.2 Plotting a Precision-Recall Curve

The code below draws PR curves for the same two simulated models used in the ROC example above, so you can see the identical models through a second lens. Model B (the stronger model) keeps precision high as recall increases — that is what a good PR curve looks like. The dashed line marks the baseline (the prevalence): a useless model that flags patients at random would sit on it, so the vertical gap between a model’s curve and that line is what the model actually buys you.

Code
library(yardstick)
library(dplyr)
library(ggplot2)

set.seed(42)
n <- 1000
true_outcome <- rbinom(n, 1, 0.15)
pred_a <- plogis(rnorm(n, mean = -1 + 0.8 * true_outcome, sd = 1.2)) # weaker model
pred_b <- plogis(rnorm(n, mean = -1 + 2 * true_outcome, sd = 1.2)) # stronger model

eval_df <- tibble(
  truth = factor(
    ifelse(true_outcome == 1, "Yes", "No"),
    levels = c("Yes", "No")
  ),
  `Model A` = pred_a,
  `Model B` = pred_b
)
prevalence <- mean(true_outcome)

pr_a <- pr_curve(eval_df, truth, `Model A`) |> mutate(model = "Model A")
pr_b <- pr_curve(eval_df, truth, `Model B`) |> mutate(model = "Model B")
auprc_a <- pr_auc(eval_df, truth, `Model A`)$.estimate
auprc_b <- pr_auc(eval_df, truth, `Model B`)$.estimate

bind_rows(pr_a, pr_b) |>
  ggplot(aes(recall, precision, colour = model)) +
  geom_path(linewidth = 1) +
  geom_hline(yintercept = prevalence, linetype = "dashed", colour = "grey50") +
  annotate(
    "text",
    x = 0.7,
    y = prevalence + 0.04,
    label = paste0("Baseline (prevalence = ", round(prevalence, 2), ")"),
    colour = "grey40",
    size = 3.3
  ) +
  scale_colour_manual(
    values = c("Model A" = "darkorange", "Model B" = "steelblue"),
    labels = c(
      paste0("Model A (AUPRC = ", round(auprc_a, 2), ")"),
      paste0("Model B (AUPRC = ", round(auprc_b, 2), ")")
    )
  ) +
  coord_equal(xlim = c(0, 1), ylim = c(0, 1)) +
  labs(
    x = "Recall (Sensitivity)",
    y = "Precision (PPV)",
    colour = NULL,
    title = "Precision-Recall Curves"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")

Precision-recall curves for the same two models as the ROC example. Model B keeps precision high as recall rises (a good AUPRC); the dashed line is the prevalence baseline a random model would achieve.
Code
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import precision_recall_curve, average_precision_score
from scipy.special import expit

np.random.seed(42)
n = 1000
true_outcome = np.random.binomial(1, 0.15, n)
pred_a = expit(np.random.normal(-1 + 0.8 * true_outcome, 1.2))  # weaker model
pred_b = expit(np.random.normal(-1 + 2 * true_outcome, 1.2))    # stronger model

prevalence = true_outcome.mean()
prec_a, rec_a, _ = precision_recall_curve(true_outcome, pred_a)
prec_b, rec_b, _ = precision_recall_curve(true_outcome, pred_b)
ap_a = average_precision_score(true_outcome, pred_a)
ap_b = average_precision_score(true_outcome, pred_b)

plt.figure(figsize=(7, 7))
plt.plot(rec_a, prec_a, color="darkorange", lw=2, label=f"Model A (AUPRC = {ap_a:.2f})")
plt.plot(rec_b, prec_b, color="steelblue", lw=2, label=f"Model B (AUPRC = {ap_b:.2f})")
plt.axhline(prevalence, color="grey", linestyle="--",
            label=f"Baseline (prevalence = {prevalence:.2f})")
plt.xlabel("Recall (Sensitivity)")
plt.ylabel("Precision (PPV)")
plt.title("Precision-Recall Curves")
plt.xlim(0, 1)
plt.ylim(0, 1)
plt.legend(loc="upper right")
plt.tight_layout()
plt.show()
<Figure size 700x700 with 0 Axes>
[<matplotlib.lines.Line2D object at 0x74000b281090>]
[<matplotlib.lines.Line2D object at 0x74000b2811d0>]
<matplotlib.lines.Line2D object at 0x74000b281310>
Text(0.5, 0, 'Recall (Sensitivity)')
Text(0, 0.5, 'Precision (PPV)')
Text(0.5, 1.0, 'Precision-Recall Curves')
(0.0, 1.0)
(0.0, 1.0)
<matplotlib.legend.Legend object at 0x74000b281450>

Precision-recall curves for the same two models as the ROC example. Model B keeps precision high as recall rises (a good AUPRC); the dashed line is the prevalence baseline a random model would achieve.

15.8.3 AUROC vs AUPRC: When to Use Which

A widespread claim in machine learning is that AUPRC is a superior metric to AUROC whenever class imbalance is present. Recent work has shown this is an oversimplification (McDermott et al. 2024). The key difference between the two metrics is not about imbalance but about which ranking mistakes they prioritise:

  • AUROC treats all ranking mistakes equally. Correcting a misranked pair improves the AUC by the same amount regardless of where it occurs in the score distribution.
  • AUPRC prioritises mistakes among high-scoring samples. It is more sensitive to whether the model’s top-ranked predictions are correct.

This distinction has practical consequences:

  • For general model comparison (no specific deployment threshold in mind): AUROC is often more appropriate because it does not favour one region of the score distribution over another.
  • For screening (where missing a case is the costly error): AUROC is preferable because the most important mistakes to fix are low-scoring positives that would be missed — exactly the region AUPRC de-emphasises.
  • When false positives are expensive (e.g., selecting drug candidates for costly lab validation): AUPRC is more appropriate because it focuses on precision at the top of the ranked list.
  • For fairness across subgroups: AUPRC can favour higher-prevalence subpopulations, potentially widening disparities. In multi-population settings, AUROC provides a more equitable assessment.
WarningAUPRC is not universally better for imbalanced data

It is common to read that “precision-recall curves should replace ROC curves when outcomes are rare.” McDermott et al. (2024) demonstrated that this blanket recommendation is not well-founded: the choice between AUROC and AUPRC should depend on the use case (which errors matter most), not simply on whether the outcome is common or rare. Furthermore, optimising for AUPRC can amplify algorithmic biases by favouring improvements in higher-prevalence subgroups at the expense of lower-prevalence ones.

15.9 Choosing the Threshold: A Clinical Decision

A prediction model typically outputs a probability, such as “this patient has a 12% risk.” To turn that into an action (treat or don’t treat, refer or don’t refer), you must pick a threshold — the risk level above which you act. This is fundamentally a clinical decision, not a statistical one, because it depends on what each kind of mistake costs the patient.

flowchart LR
    L["LOW threshold<br/>act on small risk"] --> LS["Catches more cases<br/>(high sensitivity)"]
    L --> LF["More false alarms<br/>(low specificity)"]
    H["HIGH threshold<br/>act only on strong evidence"] --> HS["Fewer false alarms<br/>(high specificity)"]
    H --> HF["Misses more cases<br/>(low sensitivity)"]
    style LS fill:#d4edda,stroke:#28a745
    style HS fill:#d4edda,stroke:#28a745
    style LF fill:#f8d7da,stroke:#dc3545
    style HF fill:#f8d7da,stroke:#dc3545
Figure 15.1: Moving the threshold trades one kind of error for the other. There is no universally correct setting — it depends on which mistake is worse for the patient.

Consider a model predicting 30-day mortality after surgery:

  • If the intervention for high-risk patients is simply closer monitoring (low cost, minimal harm), you might choose a low threshold (e.g., 5%), accepting many false positives to catch as many at-risk patients as possible.
  • If the intervention is a risky re-operation, you might choose a high threshold (e.g., 30%), requiring strong evidence before acting.

The optimal threshold depends on the relative costs of false positives and false negatives, which are determined by the clinical context, not by the data.

15.9.1 The Threshold-Performance Trade-off

Code
library(pROC)

set.seed(42)
n <- 1000
true_outcome <- rbinom(n, 1, 0.15)
pred_prob <- plogis(rnorm(n, mean = -1 + 2 * true_outcome, sd = 1.2))

roc_obj <- roc(true_outcome, pred_prob)

# Extract sensitivity and specificity at various thresholds
thresholds <- seq(0.05, 0.95, by = 0.05)
results <- data.frame(
  threshold = thresholds,
  sensitivity = sapply(thresholds, function(t) {
    coords(roc_obj, t, input = "threshold", ret = "sensitivity")$sensitivity
  }),
  specificity = sapply(thresholds, function(t) {
    coords(roc_obj, t, input = "threshold", ret = "specificity")$specificity
  })
)

# Plot the trade-off
plot(
  results$threshold,
  results$sensitivity,
  type = "l",
  lwd = 2,
  col = "red",
  xlab = "Classification Threshold",
  ylab = "Metric Value",
  main = "Sensitivity-Specificity Trade-off Across Thresholds",
  ylim = c(0, 1),
  las = 1
)
lines(results$threshold, results$specificity, lwd = 2, col = "blue")
legend(
  "right",
  legend = c("Sensitivity", "Specificity"),
  col = c("red", "blue"),
  lwd = 2
)

Sensitivity and specificity as the classification threshold is swept. They move in opposite directions; the clinically right operating point depends on the relative cost of a missed case versus a false alarm.
Code
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import roc_curve
from scipy.special import expit

np.random.seed(42)
n = 1000
true_outcome = np.random.binomial(1, 0.15, n)
pred_prob = expit(np.random.normal(-1 + 2 * true_outcome, 1.2))

fpr, tpr, thresholds = roc_curve(true_outcome, pred_prob)
specificity = 1 - fpr

plt.figure(figsize=(8, 5))
plt.plot(thresholds, tpr[:-1] if len(tpr) > len(thresholds) else tpr,
         color="red", lw=2, label="Sensitivity")
plt.plot(thresholds, specificity[:-1] if len(specificity) > len(thresholds) else specificity,
         color="blue", lw=2, label="Specificity")
plt.xlabel("Classification Threshold")
plt.ylabel("Metric Value")
plt.title("Sensitivity-Specificity Trade-off Across Thresholds")
plt.legend()
plt.xlim(0, 1)
plt.ylim(0, 1)
plt.tight_layout()
plt.show()
<Figure size 800x500 with 0 Axes>
[<matplotlib.lines.Line2D object at 0x74000b2f7b10>]
[<matplotlib.lines.Line2D object at 0x74000b2f7c50>]
Text(0.5, 0, 'Classification Threshold')
Text(0, 0.5, 'Metric Value')
Text(0.5, 1.0, 'Sensitivity-Specificity Trade-off Across Thresholds')
<matplotlib.legend.Legend object at 0x74000b2f7d90>
(0.0, 1.0)
(0.0, 1.0)

Sensitivity and specificity as the classification threshold is swept. They move in opposite directions; the clinically right operating point depends on the relative cost of a missed case versus a false alarm.

What the code is showing. This makes the threshold trade-off visible as a single picture. We step the classification threshold across its range and, at each value, record the model’s sensitivity and specificity, then plot both as lines on the same axes. The two curves move in opposite directions: as you raise the threshold (demand stronger evidence before calling a patient positive), sensitivity falls while specificity rises; lower it, and the reverse happens. Where the lines cross is the threshold that balances the two, but — as the surrounding text stresses — the right operating point is wherever the clinical costs of a missed case and a false alarm balance, which may be far from the crossing point. Reading this plot lets you pick a threshold deliberately rather than defaulting to 0.5.

15.10 Fairness: Does the Model Work for Everyone?

A model that looks good on average can perform very differently from one subgroup to another — across age, sex, race, ethnicity, or socioeconomic status. An overall AUC can hide a model that quietly works well for one group and poorly for another. This is not a theoretical worry; it has been documented repeatedly in clinical prediction models, and it matters because a model that underperforms for a subgroup can widen existing health inequalities rather than narrow them.

A well-known example is the pulse oximeter, which systematically overestimates blood oxygen saturation in patients with darker skin pigmentation — meaning dangerously low oxygen can be missed. Likewise, prediction models trained mostly on White populations may have lower sensitivity (catch fewer true cases) in Black or Hispanic patients.

15.10.1 Key Fairness Concepts

  • Equal performance: Does the model have similar sensitivity and specificity across demographic groups?
  • Calibration fairness: Are predicted probabilities equally well-calibrated in each group? A model might predict 20% risk for two groups, but if the actual event rate is 20% in one group and 30% in another, the model is miscalibrated for the latter.
  • Demographic parity: Are positive predictions made at similar rates across groups? (Note: this may conflict with calibration if true prevalence differs.)

15.11 Putting It All Together: A Complete Evaluation

When evaluating a clinical prediction model, no single metric tells the whole story. The following checklist summarises what you should report:

  1. Sample description: How many patients? How many events? What is the prevalence?
  2. Discrimination: AUC-ROC with confidence interval. Consider AUPRC when false positives are costly, but be aware it can favour higher-prevalence subgroups.
  3. Calibration: Are predicted probabilities accurate? (Covered in detail in Chapter 18.)
  4. Confusion matrix: At a clinically relevant threshold, not just the default 0.5.
  5. Sensitivity, specificity, PPV, NPV: At the chosen threshold.
  6. Subgroup performance: Does the model perform equitably across key demographic groups?
  7. Clinical utility: Would using this model lead to better decisions than the alternatives? (Covered in Chapter 18.)
ImportantRemember

A model is not good or bad in isolation. It is good or bad for a specific purpose, in a specific population, at a specific threshold. Always evaluate with the intended clinical use in mind.

15.12 Exercises

TipExercise 1: Bayes’ Theorem in Practice (PPV and NPV)

An HIV rapid test has a sensitivity of 99.7% and a specificity of 99.5%. Using the Bayes’ theorem formulas for PPV and NPV:

  1. Write functions to calculate PPV and NPV from sensitivity, specificity, and prevalence.
  2. Calculate PPV and NPV in three populations: general population (prevalence 0.4%), STI clinic (prevalence 5%), and known exposure (prevalence 30%).
  3. Plot both PPV and NPV across prevalences from 0.1% to 50%. What pattern do you see?
Code
# 1. PPV function
calculate_ppv <- function(sensitivity, specificity, prevalence) {
  # YOUR CODE HERE
}

# 1. NPV function
calculate_npv <- function(sensitivity, specificity, prevalence) {
  # YOUR CODE HERE
}

# 2. Calculate PPV and NPV for three populations
# HIV rapid test: sensitivity = 99.7%, specificity = 99.5%

# General population (prevalence ~ 0.4%)
ppv_general <- # YOUR CODE HERE
npv_general <- # YOUR CODE HERE
cat("General population (prevalence 0.4%):\n")
cat("  PPV:", round(ppv_general * 100, 1), "%\n")
cat("  NPV:", round(npv_general * 100, 1), "%\n")

# STI clinic (prevalence ~ 5%)
# YOUR CODE HERE

# Known exposure (prevalence ~ 30%)
# YOUR CODE HERE

# 3. Plot PPV and NPV across a range of prevalences
prevalences <- seq(0.001, 0.5, by = 0.001)
ppvs <- # YOUR CODE HERE (hint: use sapply)
npvs <- # YOUR CODE HERE

# YOUR PLOT CODE HERE
Code
import numpy as np
import matplotlib.pyplot as plt

# 1. PPV function
def calculate_ppv(sensitivity, specificity, prevalence):
    # YOUR CODE HERE
    pass

# 1. NPV function
def calculate_npv(sensitivity, specificity, prevalence):
    # YOUR CODE HERE
    pass

# 2. Calculate PPV and NPV for three populations
# HIV rapid test: sensitivity = 99.7%, specificity = 99.5%

# General population (prevalence ~ 0.4%)
ppv_general = # YOUR CODE HERE
npv_general = # YOUR CODE HERE
print(f"General population (prevalence 0.4%):")
print(f"  PPV: {ppv_general*100:.1f}%")
print(f"  NPV: {npv_general*100:.1f}%")

# STI clinic (prevalence ~ 5%)
# YOUR CODE HERE

# Known exposure (prevalence ~ 30%)
# YOUR CODE HERE

# 3. Plot PPV and NPV across a range of prevalences
prevalences = np.linspace(0.001, 0.5, 500)
ppvs = # YOUR CODE HERE
npvs = # YOUR CODE HERE

# YOUR PLOT CODE HERE
Code
# =============================================================================
# Chapter 9, Exercise 1: Bayes' Theorem in Practice (PPV Calculations)
# Calculate PPV at different prevalence levels and plot the relationship.
# =============================================================================

library(tidyverse)

# --- PPV function using Bayes' theorem ---
# PPV = (Sensitivity * Prevalence) /
#       (Sensitivity * Prevalence + (1 - Specificity) * (1 - Prevalence))
calculate_ppv <- function(sensitivity, specificity, prevalence) {
  numerator <- sensitivity * prevalence
  denominator <- numerator + (1 - specificity) * (1 - prevalence)
  return(numerator / denominator)
}

# --- Calculate NPV ---
calculate_npv <- function(sensitivity, specificity, prevalence) {
  numerator <- specificity * (1 - prevalence)
  denominator <- numerator + (1 - sensitivity) * prevalence
  return(numerator / denominator)
}

# --- Example: HIV rapid test (sensitivity = 99.7%, specificity = 99.5%) ---

# In a general population (prevalence ~ 0.4%)
ppv_general <- calculate_ppv(0.997, 0.995, 0.004)
npv_general <- calculate_npv(0.997, 0.995, 0.004)
cat("=== HIV Rapid Test (Sens=99.7%, Spec=99.5%) ===\n\n")
cat("General population (prevalence 0.4%):\n")
cat("  PPV:", round(ppv_general * 100, 1), "%\n")
cat("  NPV:", round(npv_general * 100, 1), "%\n")

# In an STI clinic population (prevalence ~ 5%)
ppv_clinic <- calculate_ppv(0.997, 0.995, 0.05)
npv_clinic <- calculate_npv(0.997, 0.995, 0.05)
cat("\nSTI clinic (prevalence 5%):\n")
cat("  PPV:", round(ppv_clinic * 100, 1), "%\n")
cat("  NPV:", round(npv_clinic * 100, 1), "%\n")

# In a population with known exposure (prevalence ~ 30%)
ppv_exposed <- calculate_ppv(0.997, 0.995, 0.30)
npv_exposed <- calculate_npv(0.997, 0.995, 0.30)
cat("\nKnown exposure (prevalence 30%):\n")
cat("  PPV:", round(ppv_exposed * 100, 1), "%\n")
cat("  NPV:", round(npv_exposed * 100, 1), "%\n")

# --- Plot PPV across a range of prevalences ---
prevalences <- seq(0.001, 0.5, by = 0.001)
ppvs <- sapply(prevalences, function(p) calculate_ppv(0.997, 0.995, p))
npvs <- sapply(prevalences, function(p) calculate_npv(0.997, 0.995, p))

plot_df <- tibble(
  prevalence = rep(prevalences, 2),
  value = c(ppvs, npvs),
  metric = rep(c("PPV", "NPV"), each = length(prevalences))
)

ggplot(plot_df, aes(x = prevalence * 100, y = value * 100, color = metric)) +
  geom_line(linewidth = 1.5) +
  geom_hline(yintercept = 50, linetype = "dashed", color = "grey50") +
  scale_color_manual(values = c("PPV" = "steelblue", "NPV" = "darkorange")) +
  labs(x = "Prevalence (%)",
       y = "Predictive Value (%)",
       title = "PPV and NPV Depend Heavily on Prevalence",
       subtitle = "HIV rapid test: Sensitivity=99.7%, Specificity=99.5%",
       color = "Metric") +
  theme_minimal(base_size = 14) +
  theme(legend.position = "top")

# --- Additional: Compare tests with different sensitivity/specificity ---
cat("\n\n=== Comparing Tests at 1% Prevalence ===\n")

tests <- tibble(
  Test = c("High Sens/Low Spec", "Balanced", "Low Sens/High Spec"),
  Sensitivity = c(0.99, 0.95, 0.80),
  Specificity = c(0.90, 0.95, 0.99)
)

for (i in seq_len(nrow(tests))) {
  ppv <- calculate_ppv(tests$Sensitivity[i], tests$Specificity[i], 0.01)
  npv <- calculate_npv(tests$Sensitivity[i], tests$Specificity[i], 0.01)
  cat(sprintf("%s (Sens=%.0f%%, Spec=%.0f%%): PPV=%.1f%%, NPV=%.1f%%\n",
              tests$Test[i], tests$Sensitivity[i]*100, tests$Specificity[i]*100,
              ppv*100, npv*100))
}

cat("\nKey takeaway: Even excellent tests have low PPV when prevalence is low.\n")
cat("This is why screening should target high-risk populations.\n")
Code
# =============================================================================
# Chapter 9, Exercise 1: Bayes' Theorem in Practice (PPV Calculations)
# Calculate PPV at different prevalence levels and plot the relationship.
# =============================================================================

import numpy as np
import matplotlib.pyplot as plt


# --- PPV function using Bayes' theorem ---
def calculate_ppv(sensitivity, specificity, prevalence):
    """PPV = (Sens * Prev) / (Sens * Prev + (1-Spec) * (1-Prev))"""
    numerator = sensitivity * prevalence
    denominator = numerator + (1 - specificity) * (1 - prevalence)
    return numerator / denominator


def calculate_npv(sensitivity, specificity, prevalence):
    """NPV = (Spec * (1-Prev)) / (Spec * (1-Prev) + (1-Sens) * Prev)"""
    numerator = specificity * (1 - prevalence)
    denominator = numerator + (1 - sensitivity) * prevalence
    return numerator / denominator


# --- Example: HIV rapid test (sensitivity = 99.7%, specificity = 99.5%) ---
print("=== HIV Rapid Test (Sens=99.7%, Spec=99.5%) ===\n")

# General population (prevalence ~ 0.4%)
ppv_general = calculate_ppv(0.997, 0.995, 0.004)
npv_general = calculate_npv(0.997, 0.995, 0.004)
print(f"General population (prevalence 0.4%):")
print(f"  PPV: {ppv_general*100:.1f}%")
print(f"  NPV: {npv_general*100:.1f}%")

# STI clinic (prevalence ~ 5%)
ppv_clinic = calculate_ppv(0.997, 0.995, 0.05)
npv_clinic = calculate_npv(0.997, 0.995, 0.05)
print(f"\nSTI clinic (prevalence 5%):")
print(f"  PPV: {ppv_clinic*100:.1f}%")
print(f"  NPV: {npv_clinic*100:.1f}%")

# Known exposure (prevalence ~ 30%)
ppv_exposed = calculate_ppv(0.997, 0.995, 0.30)
npv_exposed = calculate_npv(0.997, 0.995, 0.30)
print(f"\nKnown exposure (prevalence 30%):")
print(f"  PPV: {ppv_exposed*100:.1f}%")
print(f"  NPV: {npv_exposed*100:.1f}%")

# --- Plot PPV and NPV across a range of prevalences ---
prevalences = np.linspace(0.001, 0.5, 500)
ppvs = [calculate_ppv(0.997, 0.995, p) for p in prevalences]
npvs = [calculate_npv(0.997, 0.995, p) for p in prevalences]

fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(prevalences * 100, np.array(ppvs) * 100, lw=2, color="steelblue",
        label="PPV")
ax.plot(prevalences * 100, np.array(npvs) * 100, lw=2, color="darkorange",
        label="NPV")
ax.axhline(y=50, linestyle="--", color="grey", alpha=0.7)
ax.set_xlabel("Prevalence (%)")
ax.set_ylabel("Predictive Value (%)")
ax.set_title("PPV and NPV Depend Heavily on Prevalence\n"
             "HIV rapid test: Sens=99.7%, Spec=99.5%")
ax.legend()
plt.tight_layout()
plt.show()

# --- Additional: Compare tests with different sensitivity/specificity ---
print("\n=== Comparing Tests at 1% Prevalence ===")

tests = [
    ("High Sens/Low Spec", 0.99, 0.90),
    ("Balanced",           0.95, 0.95),
    ("Low Sens/High Spec", 0.80, 0.99),
]

for name, sens, spec in tests:
    ppv = calculate_ppv(sens, spec, 0.01)
    npv = calculate_npv(sens, spec, 0.01)
    print(f"{name} (Sens={sens*100:.0f}%, Spec={spec*100:.0f}%): "
          f"PPV={ppv*100:.1f}%, NPV={npv*100:.1f}%")

print("\nKey takeaway: Even excellent tests have low PPV when prevalence is low.")
print("This is why screening should target high-risk populations.")
TipExercise 2: Comparing ROC and Precision-Recall Curves

Simulate an imbalanced dataset with 2% prevalence (n = 5,000) and a moderately good model. Then:

  1. Plot the ROC curve and the precision-recall curve side by side. Report the AUROC and AUPRC.
  2. Find the optimal classification threshold using Youden’s J statistic. What are the sensitivity and specificity at this threshold?
  3. Calculate the PPV at the optimal threshold. Is it clinically useful?
  4. In a few sentences, discuss what AUROC and AUPRC reveal differently about this model. In what clinical scenario would you prefer one over the other?
Code
library(pROC)
library(PRROC)

# Simulate an imbalanced dataset (2% prevalence)
set.seed(123)
n <- 5000
true_outcome <- rbinom(n, 1, 0.02)
pred_prob <- plogis(rnorm(n, mean = -2 + 3 * true_outcome, sd = 1.5))

# 1. ROC curve
roc_obj <- # YOUR CODE HERE (hint: use roc())
  auroc <- # YOUR CODE HERE
    # 1. PR curve
    pr_obj <- # YOUR CODE HERE (hint: use pr.curve())
      auprc <- # YOUR CODE HERE
        # Plot both (hint: use par(mfrow = c(1, 2)) for side-by-side)
        # YOUR PLOT CODE HERE

        cat("AUROC:", round(auroc, 3), "\n")
cat("AUPRC:", round(auprc, 3), "\n")

# 2. Find optimal threshold (Youden's J)
coords_best <- # YOUR CODE HERE (hint: use coords())
  cat("Optimal threshold:", round(coords_best$threshold, 3), "\n")

# 3. PPV at optimal threshold
pred_class <- ifelse(pred_prob >= coords_best$threshold, 1, 0)
tp <- # YOUR CODE HERE
  fp <- # YOUR CODE HERE
    ppv <- # YOUR CODE HERE
      cat("PPV at optimal threshold:", round(ppv, 3), "\n")
Code
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import (roc_curve, roc_auc_score,
                              precision_recall_curve, average_precision_score)
from scipy.special import expit

# Simulate an imbalanced dataset (2% prevalence)
np.random.seed(123)
n = 5000
true_outcome = np.random.binomial(1, 0.02, n)
pred_prob = expit(np.random.normal(-2 + 3 * true_outcome, 1.5))

# 1. ROC curve
fpr, tpr, roc_thresholds = # YOUR CODE HERE
auroc = # YOUR CODE HERE

# 1. PR curve
precision, recall, pr_thresholds = # YOUR CODE HERE
auprc = # YOUR CODE HERE

# Plot both side by side
# YOUR PLOT CODE HERE

print(f"AUROC: {auroc:.3f}")
print(f"AUPRC: {auprc:.3f}")

# 2. Find optimal threshold (Youden's J = max(TPR - FPR))
j_scores = # YOUR CODE HERE
best_idx = # YOUR CODE HERE
optimal_threshold = roc_thresholds[best_idx]
print(f"Optimal threshold: {optimal_threshold:.3f}")

# 3. PPV at optimal threshold
pred_class = (pred_prob >= optimal_threshold).astype(int)
tp = # YOUR CODE HERE
fp = # YOUR CODE HERE
ppv = # YOUR CODE HERE
print(f"PPV at optimal threshold: {ppv:.3f}")
Code
# =============================================================================
# Chapter 9, Exercise 2: Comparing ROC and Precision-Recall Curves
# Simulate an imbalanced dataset (2% prevalence), plot ROC and PR curves,
# and discuss why PR curves are more informative for rare outcomes.
# =============================================================================

library(tidyverse)
library(pROC)
library(PRROC)

# --- Simulate an imbalanced dataset (2% prevalence) ---
set.seed(123)
n <- 5000
true_outcome <- rbinom(n, 1, 0.02)
# Simulate a moderately good model
pred_prob <- plogis(rnorm(n, mean = -2 + 3 * true_outcome, sd = 1.5))

cat("Number of observations:", n, "\n")
cat("Number of events:", sum(true_outcome), "\n")
cat("Prevalence:", mean(true_outcome), "\n")

# --- ROC Curve ---
roc_obj <- roc(true_outcome, pred_prob, quiet = TRUE)
auroc <- auc(roc_obj)

par(mfrow = c(1, 2))

# Plot ROC
plot(roc_obj,
     main = paste("ROC Curve\nAUROC =", round(auroc, 3)),
     col = "steelblue", lwd = 2,
     legacy.axes = TRUE)
abline(0, 1, lty = 2, col = "grey50")

# --- Precision-Recall Curve ---
pr_obj <- pr.curve(
  scores.class0 = pred_prob[true_outcome == 1],
  scores.class1 = pred_prob[true_outcome == 0],
  curve = TRUE
)
auprc <- pr_obj$auc.integral

# Plot PR curve
plot(pr_obj,
     main = paste("Precision-Recall Curve\nAUPRC =", round(auprc, 3)),
     color = "darkorange", lwd = 2)
abline(h = mean(true_outcome), lty = 2, col = "grey50")

par(mfrow = c(1, 1))

# --- Report metrics ---
cat("\n=== Summary ===\n")
cat("AUROC:", round(auroc, 3), "\n")
cat("AUPRC:", round(auprc, 3), "\n")
cat("Baseline AUPRC (prevalence):", round(mean(true_outcome), 3), "\n")

# --- Find optimal threshold (Youden's J) ---
coords_best <- coords(roc_obj, "best", ret = c("threshold", "sensitivity", "specificity"))
cat("\nOptimal threshold (Youden's J):", round(coords_best$threshold, 3), "\n")
cat("Sensitivity:", round(coords_best$sensitivity, 3), "\n")
cat("Specificity:", round(coords_best$specificity, 3), "\n")

# PPV at this threshold
pred_class <- ifelse(pred_prob >= coords_best$threshold, 1, 0)
tp <- sum(pred_class == 1 & true_outcome == 1)
fp <- sum(pred_class == 1 & true_outcome == 0)
ppv_at_optimal <- tp / (tp + fp)
cat("PPV at optimal threshold:", round(ppv_at_optimal, 3), "\n")

# --- Interpretation ---
cat("\n=== Interpretation ===\n")
cat("The AUROC looks excellent (", round(auroc, 3), "), suggesting the model\n")
cat("discriminates well. However, the AUPRC (", round(auprc, 3), ") reveals\n")
cat("the real challenge: achieving high recall while maintaining reasonable\n")
cat("precision is difficult with a 2% prevalence rate.\n\n")
cat("At the Youden-optimal threshold, the PPV is only", round(ppv_at_optimal * 100, 1), "%.\n")
cat("This means that even at the 'best' threshold, most positive predictions\n")
cat("are false positives.\n\n")
cat("KEY LESSON: For rare outcomes, always examine the PR curve alongside\n")
cat("the ROC curve. AUROC can paint an overly optimistic picture because\n")
cat("specificity is calculated over the large majority class.\n")
Code
# =============================================================================
# Chapter 9, Exercise 2: Comparing ROC and Precision-Recall Curves
# Simulate an imbalanced dataset (2% prevalence), plot ROC and PR curves,
# and discuss why PR curves are more informative for rare outcomes.
# =============================================================================

import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import (roc_curve, roc_auc_score,
                              precision_recall_curve, average_precision_score)
from scipy.special import expit

# --- Simulate an imbalanced dataset (2% prevalence) ---
np.random.seed(123)
n = 5000
true_outcome = np.random.binomial(1, 0.02, n)
# Simulate a moderately good model
pred_prob = expit(np.random.normal(-2 + 3 * true_outcome, 1.5))

print(f"Number of observations: {n}")
print(f"Number of events: {true_outcome.sum()}")
print(f"Prevalence: {true_outcome.mean():.3f}")

# --- ROC Curve ---
fpr, tpr, roc_thresholds = roc_curve(true_outcome, pred_prob)
auroc = roc_auc_score(true_outcome, pred_prob)

# --- Precision-Recall Curve ---
precision, recall, pr_thresholds = precision_recall_curve(true_outcome, pred_prob)
auprc = average_precision_score(true_outcome, pred_prob)
prevalence = true_outcome.mean()

# --- Plot side by side ---
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# ROC curve
axes[0].plot(fpr, tpr, color="steelblue", lw=2)
axes[0].plot([0, 1], [0, 1], "--", color="grey")
axes[0].set_title(f"ROC Curve (AUROC = {auroc:.3f})")
axes[0].set_xlabel("False Positive Rate (1 - Specificity)")
axes[0].set_ylabel("True Positive Rate (Sensitivity)")

# PR curve
axes[1].plot(recall, precision, color="darkorange", lw=2)
axes[1].axhline(y=prevalence, linestyle="--", color="grey",
                label=f"Baseline (prevalence={prevalence:.3f})")
axes[1].set_title(f"Precision-Recall Curve (AUPRC = {auprc:.3f})")
axes[1].set_xlabel("Recall (Sensitivity)")
axes[1].set_ylabel("Precision (PPV)")
axes[1].legend()

plt.tight_layout()
plt.show()

# --- Report metrics ---
print(f"\n=== Summary ===")
print(f"AUROC: {auroc:.3f}")
print(f"AUPRC: {auprc:.3f}")
print(f"Baseline AUPRC (prevalence): {prevalence:.3f}")

# --- Find optimal threshold (Youden's J) ---
j_scores = tpr - fpr
best_idx = np.argmax(j_scores)
optimal_threshold = roc_thresholds[best_idx]
print(f"\nOptimal threshold (Youden's J): {optimal_threshold:.3f}")
print(f"Sensitivity: {tpr[best_idx]:.3f}")
print(f"Specificity: {1 - fpr[best_idx]:.3f}")

# PPV at this threshold
pred_class = (pred_prob >= optimal_threshold).astype(int)
tp = ((pred_class == 1) & (true_outcome == 1)).sum()
fp = ((pred_class == 1) & (true_outcome == 0)).sum()
ppv_at_optimal = tp / (tp + fp) if (tp + fp) > 0 else 0
print(f"PPV at optimal threshold: {ppv_at_optimal:.3f}")

# --- Interpretation ---
print(f"\n=== Interpretation ===")
print(f"The AUROC looks excellent ({auroc:.3f}), suggesting the model")
print(f"discriminates well. However, the AUPRC ({auprc:.3f}) reveals")
print(f"the real challenge: achieving high recall while maintaining")
print(f"reasonable precision is difficult with a 2% prevalence rate.")
print(f"\nAt the Youden-optimal threshold, the PPV is only {ppv_at_optimal*100:.1f}%.")
print(f"This means that even at the 'best' threshold, most positive")
print(f"predictions are false positives.")
print(f"\nKEY LESSON: For rare outcomes, always examine the PR curve")
print(f"alongside the ROC curve. AUROC can paint an overly optimistic")
print(f"picture because specificity is calculated over the large")
print(f"majority class.")
TipExercise 3: Evaluating Fairness

Simulate a prediction model applied to two demographic groups (A and B) where group B has a higher disease prevalence (15% vs 10%) and the model discriminates slightly worse for group B.

  1. Calculate and compare the AUC for each group separately. Does the overall AUC hide a disparity?
  2. At a fixed threshold of 0.3, compare sensitivity, specificity, and PPV across groups.
  3. Calculate the positive prediction rate for each group at the same threshold. Is there demographic parity?
  4. Plot overlaid ROC curves for both groups.
Code
library(pROC)

# Simulate data with two demographic groups
set.seed(42)
n <- 2000

group <- sample(c("A", "B"), n, replace = TRUE)
true_outcome <- ifelse(group == "A", rbinom(n, 1, 0.10), rbinom(n, 1, 0.15))
pred_prob <- ifelse(
  group == "A",
  plogis(rnorm(n, -1.5 + 2.5 * true_outcome, 1.0)),
  plogis(rnorm(n, -1.5 + 1.8 * true_outcome, 1.2))
)

# 1. Calculate AUC by group
for (g in c("A", "B")) {
  idx <- group == g
  roc_g <- # YOUR CODE HERE
  cat(sprintf("Group %s: AUC = %.3f, Prevalence = %.1f%%\n",
              g, # YOUR CODE HERE, mean(true_outcome[idx]) * 100))
}

# 2. Sensitivity, specificity, and PPV at threshold = 0.3
threshold <- 0.3
for (g in c("A", "B")) {
  idx <- group == g
  pred_class <- ifelse(pred_prob[idx] >= threshold, 1, 0)
  # YOUR CODE HERE: calculate tp, fn, tn, fp, then sens, spec, ppv
}

# 3. Positive prediction rate per group
# YOUR CODE HERE

# 4. Plot overlaid ROC curves
# YOUR CODE HERE
Code
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import roc_auc_score, roc_curve
from scipy.special import expit

# Simulate data with two demographic groups
np.random.seed(42)
n = 2000

group = np.random.choice(["A", "B"], n)
true_outcome = np.where(group == "A",
                        np.random.binomial(1, 0.10, n),
                        np.random.binomial(1, 0.15, n))
pred_prob = np.where(group == "A",
                     expit(np.random.normal(-1.5 + 2.5 * true_outcome, 1.0)),
                     expit(np.random.normal(-1.5 + 1.8 * true_outcome, 1.2)))

# 1. Calculate AUC by group
for g in ["A", "B"]:
    mask = group == g
    auc = # YOUR CODE HERE
    prev = # YOUR CODE HERE
    print(f"Group {g}: AUC = {auc:.3f}, Prevalence = {prev*100:.1f}%")

# 2. Sensitivity, specificity, and PPV at threshold = 0.3
threshold = 0.3
for g in ["A", "B"]:
    mask = group == g
    pred_class = (pred_prob[mask] >= threshold).astype(int)
    # YOUR CODE HERE: calculate tp, fn, tn, fp, then sens, spec, ppv

# 3. Positive prediction rate per group
# YOUR CODE HERE

# 4. Plot overlaid ROC curves
# YOUR CODE HERE
Code
# =============================================================================
# Chapter 9, Exercise 3: Evaluating Fairness
# Simulate data with two demographic groups, evaluate whether the model
# performs equitably across groups.
# =============================================================================

library(tidyverse)
library(pROC)

# --- Simulate data with two demographic groups ---
set.seed(42)
n <- 2000

group <- sample(c("A", "B"), n, replace = TRUE)

# Group B has slightly different disease prevalence and predictor distribution
true_outcome <- ifelse(group == "A",
                       rbinom(n, 1, 0.10),
                       rbinom(n, 1, 0.15))

# Model performs slightly worse for Group B (weaker signal)
pred_prob <- ifelse(group == "A",
                    plogis(rnorm(n, -1.5 + 2.5 * true_outcome, 1.0)),
                    plogis(rnorm(n, -1.5 + 1.8 * true_outcome, 1.2)))

df <- tibble(group = group, true_outcome = true_outcome, pred_prob = pred_prob)

# --- 1. Calculate AUC by group ---
cat("=== Discrimination (AUC) by Group ===\n")
for (g in c("A", "B")) {
  idx <- df$group == g
  roc_g <- roc(df$true_outcome[idx], df$pred_prob[idx], quiet = TRUE)
  ci_g <- ci.auc(roc_g)
  cat(sprintf("Group %s: AUC = %.3f (95%% CI: %.3f - %.3f), Prevalence = %.1f%%\n",
              g, auc(roc_g), ci_g[1], ci_g[3],
              mean(df$true_outcome[idx]) * 100))
}

# --- 2. Sensitivity and specificity at various thresholds ---
cat("\n=== Performance at Different Thresholds ===\n")
thresholds <- c(0.15, 0.20, 0.30, 0.40, 0.50)

for (threshold in thresholds) {
  cat(sprintf("\nThreshold = %.2f:\n", threshold))
  for (g in c("A", "B")) {
    idx <- df$group == g
    pred_class <- ifelse(df$pred_prob[idx] >= threshold, 1, 0)
    actual <- df$true_outcome[idx]

    tp <- sum(pred_class == 1 & actual == 1)
    fn <- sum(pred_class == 0 & actual == 1)
    tn <- sum(pred_class == 0 & actual == 0)
    fp <- sum(pred_class == 1 & actual == 0)

    sens <- ifelse(tp + fn > 0, tp / (tp + fn), NA)
    spec <- ifelse(tn + fp > 0, tn / (tn + fp), NA)
    ppv  <- ifelse(tp + fp > 0, tp / (tp + fp), NA)

    cat(sprintf("  Group %s: Sens=%.3f  Spec=%.3f  PPV=%.3f  (TP=%d FP=%d FN=%d TN=%d)\n",
                g, sens, spec, ppv, tp, fp, fn, tn))
  }
}

# --- 3. Positive prediction rate (demographic parity) ---
cat("\n=== Positive Prediction Rate (Demographic Parity) ===\n")
threshold <- 0.30
for (g in c("A", "B")) {
  idx <- df$group == g
  pred_class <- ifelse(df$pred_prob[idx] >= threshold, 1, 0)
  pos_rate <- mean(pred_class)
  cat(sprintf("Group %s: %.1f%% predicted positive at threshold %.2f\n",
              g, pos_rate * 100, threshold))
}

# --- 4. ROC curves overlaid ---
roc_a <- roc(df$true_outcome[df$group == "A"],
             df$pred_prob[df$group == "A"], quiet = TRUE)
roc_b <- roc(df$true_outcome[df$group == "B"],
             df$pred_prob[df$group == "B"], quiet = TRUE)

plot(roc_a, col = "steelblue", lwd = 2, legacy.axes = TRUE,
     main = "ROC Curves by Demographic Group")
plot(roc_b, col = "darkorange", lwd = 2, add = TRUE)
legend("bottomright",
       legend = c(paste("Group A (AUC =", round(auc(roc_a), 3), ")"),
                  paste("Group B (AUC =", round(auc(roc_b), 3), ")")),
       col = c("steelblue", "darkorange"), lwd = 2)

# --- Interpretation ---
cat("\n=== Interpretation ===\n")
cat("1. The model shows different AUC values across groups, indicating\n")
cat("   unequal discrimination. Group B (higher prevalence, noisier data)\n")
cat("   has lower AUC than Group A.\n\n")
cat("2. At any fixed threshold, sensitivity and specificity differ between\n")
cat("   groups. This means a single threshold does not provide equitable\n")
cat("   performance. Group-specific thresholds could equalize sensitivity\n")
cat("   but would raise questions about fairness.\n\n")
cat("3. Different positive prediction rates reflect both different prevalence\n")
cat("   and different model performance. Demographic parity (equal positive\n")
cat("   rates) may conflict with calibration if true prevalence differs.\n\n")
cat("4. CLINICAL IMPLICATION: Before deploying a model, always evaluate\n")
cat("   performance across demographic subgroups. A model that works well\n")
cat("   'on average' may perform poorly for specific populations, potentially\n")
cat("   widening health disparities. Report subgroup-specific metrics.\n")
Code
# =============================================================================
# Chapter 9, Exercise 3: Evaluating Fairness
# Simulate data with two demographic groups, evaluate whether the model
# performs equitably across groups.
# =============================================================================

import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import roc_auc_score, roc_curve
from scipy.special import expit

# --- Simulate data with two demographic groups ---
np.random.seed(42)
n = 2000

group = np.random.choice(["A", "B"], n)

# Group B has slightly different disease prevalence
true_outcome = np.where(group == "A",
                        np.random.binomial(1, 0.10, n),
                        np.random.binomial(1, 0.15, n))

# Model performs slightly worse for Group B (weaker signal, more noise)
pred_prob = np.where(group == "A",
                     expit(np.random.normal(-1.5 + 2.5 * true_outcome, 1.0)),
                     expit(np.random.normal(-1.5 + 1.8 * true_outcome, 1.2)))

# --- 1. Calculate AUC by group ---
print("=== Discrimination (AUC) by Group ===")
for g in ["A", "B"]:
    mask = group == g
    auc = roc_auc_score(true_outcome[mask], pred_prob[mask])
    prev = true_outcome[mask].mean()
    print(f"Group {g}: AUC = {auc:.3f}, Prevalence = {prev*100:.1f}%")

# --- 2. Sensitivity and specificity at various thresholds ---
print("\n=== Performance at Different Thresholds ===")
thresholds = [0.15, 0.20, 0.30, 0.40, 0.50]

for threshold in thresholds:
    print(f"\nThreshold = {threshold:.2f}:")
    for g in ["A", "B"]:
        mask = group == g
        pred_class = (pred_prob[mask] >= threshold).astype(int)
        actual = true_outcome[mask]

        tp = ((pred_class == 1) & (actual == 1)).sum()
        fn = ((pred_class == 0) & (actual == 1)).sum()
        tn = ((pred_class == 0) & (actual == 0)).sum()
        fp = ((pred_class == 1) & (actual == 0)).sum()

        sens = tp / (tp + fn) if (tp + fn) > 0 else 0
        spec = tn / (tn + fp) if (tn + fp) > 0 else 0
        ppv = tp / (tp + fp) if (tp + fp) > 0 else 0

        print(f"  Group {g}: Sens={sens:.3f}  Spec={spec:.3f}  "
              f"PPV={ppv:.3f}  (TP={tp} FP={fp} FN={fn} TN={tn})")

# --- 3. Positive prediction rate (demographic parity) ---
print("\n=== Positive Prediction Rate (Demographic Parity) ===")
threshold = 0.30
for g in ["A", "B"]:
    mask = group == g
    pred_class = (pred_prob[mask] >= threshold).astype(int)
    pos_rate = pred_class.mean()
    print(f"Group {g}: {pos_rate*100:.1f}% predicted positive "
          f"at threshold {threshold:.2f}")

# --- 4. ROC curves overlaid ---
fig, ax = plt.subplots(figsize=(7, 7))

for g, color, label_prefix in [("A", "steelblue", "Group A"),
                                ("B", "darkorange", "Group B")]:
    mask = group == g
    fpr, tpr, _ = roc_curve(true_outcome[mask], pred_prob[mask])
    auc = roc_auc_score(true_outcome[mask], pred_prob[mask])
    ax.plot(fpr, tpr, color=color, lw=2,
            label=f"{label_prefix} (AUC = {auc:.3f})")

ax.plot([0, 1], [0, 1], "--", color="grey")
ax.set_xlabel("False Positive Rate (1 - Specificity)")
ax.set_ylabel("True Positive Rate (Sensitivity)")
ax.set_title("ROC Curves by Demographic Group")
ax.legend(loc="lower right")
plt.tight_layout()
plt.show()

# --- Interpretation ---
print("\n=== Interpretation ===")
print("1. The model shows different AUC values across groups, indicating")
print("   unequal discrimination. Group B (higher prevalence, noisier data)")
print("   has lower AUC than Group A.")
print("\n2. At any fixed threshold, sensitivity and specificity differ between")
print("   groups. A single threshold does not provide equitable performance.")
print("   Group-specific thresholds could equalize sensitivity but raise")
print("   questions about fairness.")
print("\n3. Different positive prediction rates reflect both different prevalence")
print("   and different model performance. Demographic parity (equal positive")
print("   rates) may conflict with calibration if true prevalence differs.")
print("\n4. CLINICAL IMPLICATION: Before deploying a model, always evaluate")
print("   performance across demographic subgroups. A model that works well")
print("   'on average' may perform poorly for specific populations, potentially")
print("   widening health disparities. Report subgroup-specific metrics.")

15.13 Summary

  • Accuracy is misleading for imbalanced outcomes; decompose errors into sensitivity and specificity.
  • Sensitivity matters most for screening; specificity matters most for confirmation.
  • PPV and NPV depend on prevalence, so a test’s clinical usefulness changes with the population.
  • ROC curves display the sensitivity-specificity trade-off across all thresholds; AUC summarises discriminative ability.
  • Precision-recall curves complement ROC curves by focusing on the positive predictions; the choice between AUROC and AUPRC depends on the use case, not simply on class imbalance.
  • The classification threshold should be chosen based on clinical consequences, not statistical optimality.
  • Fairness requires checking that model performance is consistent across demographic groups.

15.14 References and Further Reading

  • For model performance assessment, see Van Calster et al. (2025) (lots on calibration measures and their interpretation).
  • For more on ROC and precision-recall analysis, see Pepe (2003), Saito and Rehmsmeier (2015), and McDermott et al. (2024) (a rigorous theoretical and empirical study on the usage of AUROC and AUPRC).
  • For decision curves, see Vickers and Elkin (2006).
  • For algorithmic fairness, see the Obermeyer et al. (2019) (A landmark paper demonstrating racial bias in a widely used healthcare algorithm. Required reading for anyone working in clinical prediction).
McDermott, Matthew B A, Lasse H Hansen, Haoran Zhang, Giovanni Angelotti, and Beancé Gallego. 2024. “A Closer Look at AUROC and AUPRC Under Class Imbalance.” Advances in Neural Information Processing Systems (NeurIPS) 37. A rigorous theoretical and empirical analysis showing that the choice between AUROC and AUPRC should be guided by the use case rather than class imbalance alone, and that AUPRC can introduce fairness concerns by favouring higher-prevalence subgroups.
Obermeyer, Ziad, Brian Powers, Christine Vogeli, and Sendhil Mullainathan. 2019. “Dissecting Racial Bias in an Algorithm Used to Manage the Health of Populations.” Science 366 (6464): 447–53. https://doi.org/10.1126/science.aax2342. A landmark paper demonstrating racial bias in a widely used healthcare algorithm. Required reading for anyone working in clinical prediction.
Pepe, Margaret Sullivan. 2003. The Statistical Evaluation of Medical Tests for Classification and Prediction. Oxford University Press. The definitive reference on the statistical foundations of ROC analysis.
Saito, Takaya, and Marc Rehmsmeier. 2015. “The Precision-Recall Plot Is More Informative Than the ROC Plot When Evaluating Binary Classifiers on Imbalanced Datasets.” PLOS ONE 10 (3): e0118432. https://doi.org/10.1371/journal.pone.0118432. Argues that the precision-recall plot can be more informative than the ROC plot for imbalanced datasets, though note that this claim about the curves does not automatically extend to the area-under-the-curve summaries.
Van Calster, Ben, Gary S Collins, Andrew J Vickers, et al. 2025. “Evaluation of Performance Measures in Predictive Artificial Intelligence Models to Support Medical Decisions: Overview and Guidance.” The Lancet Digital Health 7: e100916. https://doi.org/10.1016/j.landig.2025.100916.
Vickers, Andrew J, and Elena B Elkin. 2006. “Decision Curve Analysis: A Novel Method for Evaluating Prediction Models.” Medical Decision Making 26 (6): 565–74.