Bayes’ Theorem
The formula
\[P(H \mid D) = \frac{P(D \mid H) \cdot P(H)}{P(D)}\]
In words:
\[\text{Posterior} = \frac{\text{Likelihood} \times \text{Prior}}{\text{Evidence}}\]
That looks abstract. Let’s make it concrete.
The medical test example
Imagine a disease that affects 1 in 1,000 people. A test for it is 99% accurate — if you have the disease it says positive 99% of the time, and if you don’t have it, it says negative 99% of the time.
You test positive. What’s the probability you actually have the disease?
Most people say 99%. The real answer is about 9%. This is not a trick — it’s Bayes’ theorem. The disease is so rare that even a good test produces more false positives than true positives.
The simulator below lets you adjust the base rate and test accuracy and watch how the posterior probability changes.
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 580
library(shiny)
ui <- fluidPage(
tags$head(tags$style(HTML("
.result-box {
background: #f0f4f8; border-radius: 6px; padding: 16px;
margin-top: 14px; font-size: 15px; line-height: 2;
text-align: center;
}
.result-box .big {
font-size: 32px; color: #e74c3c; font-weight: bold;
}
"))),
sidebarLayout(
sidebarPanel(
width = 3,
sliderInput("prev", "Base rate (prevalence):",
min = 0.001, max = 0.20, value = 0.001, step = 0.001),
sliderInput("sens", "Sensitivity (true positive rate):",
min = 0.50, max = 1.00, value = 0.99, step = 0.01),
sliderInput("spec", "Specificity (true negative rate):",
min = 0.50, max = 1.00, value = 0.99, step = 0.01),
uiOutput("result_box")
),
mainPanel(
width = 9,
fluidRow(
column(6, plotOutput("tree_plot", height = "420px")),
column(6, plotOutput("icon_plot", height = "420px"))
)
)
)
)
server <- function(input, output, session) {
vals <- reactive({
prev <- input$prev
sens <- input$sens
spec <- input$spec
# Out of 10,000 people
N <- 10000
sick <- round(N * prev)
healthy <- N - sick
true_pos <- round(sick * sens)
false_neg <- sick - true_pos
false_pos <- round(healthy * (1 - spec))
true_neg <- healthy - false_pos
total_pos <- true_pos + false_pos
ppv <- if (total_pos > 0) true_pos / total_pos else 0
list(N = N, sick = sick, healthy = healthy,
true_pos = true_pos, false_neg = false_neg,
false_pos = false_pos, true_neg = true_neg,
total_pos = total_pos, ppv = ppv)
})
output$tree_plot <- renderPlot({
v <- vals()
par(mar = c(1, 1, 3, 1))
plot(NULL, xlim = c(0, 10), ylim = c(0, 10), axes = FALSE,
xlab = "", ylab = "", main = "What happens to 10,000 people?")
# Population
text(5, 9.5, paste0("Population: ", v$N), cex = 1.2, font = 2)
# Sick vs Healthy
text(2.5, 7.5, paste0("Sick: ", v$sick), cex = 1.1, col = "#e74c3c")
text(7.5, 7.5, paste0("Healthy: ", v$healthy), cex = 1.1, col = "#3498db")
segments(5, 9, 2.5, 8, lwd = 2)
segments(5, 9, 7.5, 8, lwd = 2)
# Test results for sick
text(1.2, 5.2, paste0("Test +\n", v$true_pos), cex = 1, col = "#27ae60", font = 2)
text(3.8, 5.2, paste0("Test -\n", v$false_neg), cex = 1, col = "#7f8c8d")
segments(2.5, 7, 1.2, 5.8, lwd = 1.5)
segments(2.5, 7, 3.8, 5.8, lwd = 1.5)
# Test results for healthy
text(6.2, 5.2, paste0("Test +\n", v$false_pos), cex = 1, col = "#e74c3c", font = 2)
text(8.8, 5.2, paste0("Test -\n", v$true_neg), cex = 1, col = "#7f8c8d")
segments(7.5, 7, 6.2, 5.8, lwd = 1.5)
segments(7.5, 7, 8.8, 5.8, lwd = 1.5)
# Total positives
text(3.7, 3, paste0("Total positive tests: ", v$total_pos), cex = 1.1, font = 2)
text(3.7, 2, paste0("Of these, truly sick: ", v$true_pos), cex = 1.1,
col = "#27ae60", font = 2)
text(3.7, 1, paste0("P(sick | test+) = ",
v$true_pos, "/", v$total_pos, " = ",
round(v$ppv * 100, 1), "%"), cex = 1.2, font = 2, col = "#e74c3c")
})
output$icon_plot <- renderPlot({
v <- vals()
par(mar = c(1, 1, 3, 1))
# Show total positive tests as dots
n_show <- min(v$total_pos, 200)
n_true <- round(n_show * v$ppv)
n_false <- n_show - n_true
cols <- c(rep("#27ae60", n_true), rep("#e74c3c", n_false))
cols <- sample(cols)
ncol <- ceiling(sqrt(n_show))
nrow <- ceiling(n_show / ncol)
plot(NULL, xlim = c(0, ncol + 1), ylim = c(0, nrow + 1),
axes = FALSE, xlab = "", ylab = "",
main = paste0("All ", v$total_pos, " positive tests"))
if (n_show > 0) {
x <- rep(seq_len(ncol), times = nrow)[seq_len(n_show)]
y <- rep(seq(nrow, 1), each = ncol)[seq_len(n_show)]
points(x, y, pch = 15, cex = max(0.5, 3 - n_show / 50), col = cols)
}
legend("bottom", bty = "n", horiz = TRUE, cex = 0.95,
legend = c(paste0("Truly sick (", n_true, ")"),
paste0("False alarm (", n_false, ")")),
col = c("#27ae60", "#e74c3c"), pch = 15, pt.cex = 1.5)
})
output$result_box <- renderUI({
v <- vals()
tags$div(class = "result-box",
HTML(paste0(
"If you test positive,<br>",
"the probability you're sick is:<br>",
"<span class='big'>", round(v$ppv * 100, 1), "%</span><br>",
"<small>not ", round(input$sens * 100), "%!</small>"
))
)
})
}
shinyApp(ui, server)
Things to try
- Default settings (prevalence 0.1%, test 99% accurate): only ~9% of positive tests are truly sick. The base rate dominates.
- Slide prevalence up to 5%: now ~84% of positives are real. The prior matters!
- Slide prevalence to 50%: the posterior is ~99%. When the disease is common, a positive test is very informative.
- Lower specificity to 90%: false positives explode. Watch the right plot fill with red dots.
The lesson
Bayes’ theorem tells you: don’t just look at the test accuracy — look at how common the thing is. A 99% accurate test is nearly useless for a 1-in-1,000 disease because most positives are false alarms. This is the base rate fallacy, and Bayes’ theorem is the cure.