Sensitivity, specificity, PPV and NPV in R — and why prevalence changes everything

clinical
biostatistics
diagnostics
Diagnostic accuracy from a 2×2 table in base R: sensitivity, specificity, predictive values and likelihood ratios — plus the reason the same test looks brilliant in one clinic and useless in another.
Author

Rverse Analytics

Published

July 6, 2026

When you evaluate a diagnostic or screening test, four numbers do most of the work — and two of them quietly depend on something the test can’t control: how common the disease is. Here’s the full calculation in base R, no packages required.

From a 2×2 table to the metrics

Start with the counts of test result against true disease status:

tp <- 90; fp <- 10    # test positive: disease present / absent
fn <- 20; tn <- 180   # test negative: disease present / absent

metrics <- function(tp, fp, fn, tn) {
  sens <- tp / (tp + fn)
  spec <- tn / (tn + fp)
  c(
    Sensitivity = sens,
    Specificity = spec,
    PPV = tp / (tp + fp),
    NPV = tn / (tn + fn),
    `LR+` = sens / (1 - spec),
    `LR-` = (1 - sens) / spec
  )
}

round(metrics(tp, fp, fn, tn), 3)
Sensitivity Specificity         PPV         NPV         LR+         LR- 
      0.818       0.947       0.900       0.900      15.545       0.192 

Sensitivity and specificity are properties of the test itself. PPV and NPV — the questions patients actually care about (“I tested positive, do I have it?”) — are not: they shift with prevalence. Likelihood ratios neatly combine sensitivity and specificity and feed directly into Bayesian updating of pre-test odds.

Watch prevalence move the predictive values

Hold sensitivity and specificity fixed and sweep prevalence from rare to common:

library(ggplot2)

sens <- 0.90; spec <- 0.95
prev <- seq(0.001, 0.5, length.out = 300)
ppv <- (sens * prev) / (sens * prev + (1 - spec) * (1 - prev))
npv <- (spec * (1 - prev)) / (spec * (1 - prev) + (1 - sens) * prev)

d <- data.frame(
  prevalence = rep(prev, 2),
  value = c(ppv, npv),
  metric = rep(c("PPV", "NPV"), each = length(prev))
)

ggplot(d, aes(prevalence, value, colour = metric)) +
  geom_line(linewidth = 1.2) +
  scale_colour_manual(values = c(PPV = "#2f6fed", NPV = "#17a2b8")) +
  scale_y_continuous(labels = scales::percent, limits = c(0, 1)) +
  scale_x_continuous(labels = scales::percent) +
  labs(
    title = "Predictive values depend on prevalence (sens = 90%, spec = 95%)",
    x = "Disease prevalence", y = "Predictive value", colour = NULL
  ) +
  theme_minimal(base_family = "sans") +
  theme(legend.position = "bottom",
        plot.title = element_text(face = "bold", colour = "#1b2a4a"))
Figure 1

At 1% prevalence a “positive” from this excellent test is more likely to be a false alarm than a true case — the PPV is low even though sensitivity and specificity are high. Move to a specialist clinic where a third of patients truly have the disease and the same test’s PPV soars.

The takeaway

Quote sensitivity and specificity to describe the test; quote PPV and NPV with the prevalence they assume, or you’ll mislead. Better still, present predictive values across the plausible prevalence range — exactly what the diagnostic-test calculator in our live demos does interactively.


Part of our Clinical R toolkit. Evaluating a test on your own data? Let’s talk.