Skip to content
The Lumenary

Math laboratory

Bayes' theorem with natural frequencies and base rates

Builds the exact two-way table for a test with 95% sensitivity and 95% specificity on 100,000 people with a 1% base rate: only 19/118 (about 16%) of positives have the condition. Recomputes P(condition | positive) for base rates from 0.1% to 50%, updates after a second positive test, and shows the prosecutor's fallacy with a one-in-a-million DNA match in a city of two million. Exact arithmetic.

Back to the labExact computation
Bayes' theorem with natural frequencies and base ratesPythonExact computationComputationally tested
"""Bayes' theorem with natural frequencies: tests, base rates and courtrooms.

A diagnostic test is imagined on 100,000 people and every count is exact.
The two-way table shows why P(condition | positive) and P(positive |
condition) are different numbers, and the same test is re-run with base
rates from 0.1% to 50% to show how strongly the answer depends on them. A
second independent positive test is handled by using the first posterior as
the new prior. Finally the prosecutor's fallacy: a DNA profile shared by one
person in a million, in a city of two million.
"""

from fractions import Fraction

def two_way_table(population, prevalence, sensitivity, specificity):
    """Counts (true pos, false neg, false pos, true neg) as exact Fractions."""
    sick = population * prevalence
    well = population - sick
    return (sick * sensitivity, sick * (1 - sensitivity),
            well * (1 - specificity), well * specificity)

def posterior(prior, p_pos_given_d, p_pos_given_not_d):
    """P(D | positive) by Bayes' theorem with total probability below."""
    top = p_pos_given_d * prior
    return top / (top + p_pos_given_not_d * (1 - prior))

def fmt(q):
    """An exact count as a whole number with thousands separators."""
    assert q.denominator == 1
    return f"{q.numerator:,}"

def main():
    sens, spec = Fraction(95, 100), Fraction(95, 100)
    tp, fn, fp, tn = two_way_table(Fraction(100000), Fraction(1, 100),
                                   sens, spec)
    print("Test: sensitivity 95%, specificity 95%; base rate 1%.")
    print("Imagine 100,000 people:")
    print("                  test +     test -      total")
    print(f"  condition    {fmt(tp):>9}  {fmt(fn):>9}  {fmt(tp + fn):>9}")
    print(f"  no condition {fmt(fp):>9}  {fmt(tn):>9}  {fmt(fp + tn):>9}")
    print(f"  total        {fmt(tp + fp):>9}  {fmt(fn + tn):>9}  "
          f"{fmt(tp + fn + fp + tn):>9}")
    ppv = tp / (tp + fp)
    print(f"P(condition | +) = {fmt(tp)}/{fmt(tp + fp)} = {ppv} "
          f"= {float(ppv):.3f}")
    print(f"P(+ | condition) = {fmt(tp)}/{fmt(tp + fn)} = "
          f"{float(tp / (tp + fn)):.3f}   (a different question!)")
    print()
    print("Same test, different base rates:")
    print("  base rate   P(condition | +)   P(no condition | -)")
    for rate in ("1/1000", "1/100", "1/10", "1/2"):
        prior = Fraction(rate)
        ppv_r = posterior(prior, sens, 1 - spec)
        npv_r = posterior(1 - prior, spec, 1 - sens)
        print(f"  {float(prior):9.1%}   {float(ppv_r):16.4f}"
              f"   {float(npv_r):19.5f}")
    second = posterior(ppv, sens, 1 - spec)
    print(f"After a second, independent positive test: {float(ppv):.3f} -> "
          f"{float(second):.3f}")
    print()
    match = posterior(Fraction(1, 2_000_000), Fraction(1), Fraction(1, 10**6))
    print("DNA profile shared by 1 person in 1,000,000; city of 2,000,000;")
    print("no other evidence. P(match | innocent) = 0.000001, but")
    print(f"P(innocent | match) = {float(1 - match):.4f}")
    print()
    print("Epistemic status: exact arithmetic in a model whose inputs (base")
    print("rate, sensitivity, specificity, independence of the two tests)")
    print("are assumptions. Change them and the answers change; the")
    print("structure of the calculation (Bayes' theorem) does not.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in