Math, Statistics, and DataProbability and Statistics › Day 115

Hands-on lab — Day 115: Bayes’ Theorem

Commands

Setup

cd labs/sections/math-statistics-and-data/day-115-bayes-theorem
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy; print(numpy.__version__)"

Run

cd examples && ../.venv/bin/python3 01_opening_posterior.py && cd ..
cd examples && ../.venv/bin/python3 02_natural_frequencies.py && cd ..
cd examples && ../.venv/bin/python3 03_simulation.py && cd ..
cd examples && ../.venv/bin/python3 04_prevalence_sweep.py && cd ..
cd examples && ../.venv/bin/python3 05_odds_form.py && cd ..
cd examples && ../.venv/bin/python3 06_sequential_updating.py && cd ..
cd examples && ../.venv/bin/python3 07_correlated_tests.py && cd ..
cd examples && ../.venv/bin/python3 08_naive_bayes_smoothing.py && cd ..
cd examples && ../.venv/bin/python3 09_log_space.py && cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
.venv/bin/pytest starter -q -p no:cacheprovider

Test

bash tests/run_tests.sh

File tree

examples/01_opening_posterior.py
examples/02_natural_frequencies.py
examples/03_simulation.py
examples/04_prevalence_sweep.py
examples/05_odds_form.py
examples/06_sequential_updating.py
examples/07_correlated_tests.py
examples/08_naive_bayes_smoothing.py
examples/09_log_space.py
examples/bayes.py
examples/conftest.py
examples/dataset.py
examples/naive_bayes.py
examples/simulate.py
examples/test_reference.py
expected-output/01-opening-posterior.txt
expected-output/02-natural-frequencies.txt
expected-output/03-simulation.txt
expected-output/04-prevalence-sweep.txt
expected-output/05-odds-form.txt
expected-output/06-sequential-updating.txt
expected-output/07-correlated-tests.txt
expected-output/08-naive-bayes-smoothing.txt
expected-output/09-log-space.txt
expected-output/FIELDS.md
expected-output/reference-tests.txt
expected-output/starter-progress.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/00_brief.md
starter/answers.py
starter/bayes.py
starter/conftest.py
starter/dataset.py
starter/naive_bayes.py
starter/simulate.py
starter/test_starter.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 115 lab — Bayes You Can Trust

Lesson

  • Lesson title: Bayes’ Theorem
  • Day number: 115 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-115-bayes-theorem
  • Lab files: everything you need is in this directory — follow “How to run” below.
  • Browse the course locally: from the repository root, this lab also appears in the course website at /labs/day-115-bayes-theorem when the site is running.

Purpose

Bayes' theorem is the law of total probability read backwards, and human intuition fails it so reliably that the failure has a name. A test is 99% sensitive and 99% specific. The condition affects 1 person in 1,000. You test positive. Almost everyone — including, in published studies, most physicians asked the same question — answers "about 99%". The true answer is close to 9%. This lab derives that number exactly, confirms it three independent ways, and then builds outward through the odds form, sequential updating, a case where the naive "multiply the likelihood ratios" move badly overstates confidence, and a from-scratch spam classifier that confronts the two things every Naive Bayes tutorial glosses over: Laplace smoothing and log space.

Same design principle as Days 113 and 114: compute everything two independent ways and assert they agree — exact rational arithmetic with fractions.Fraction wherever the answer is rational, seeded simulation otherwise, with tolerances derived from the standard error of a proportion.

Learning objectives

By the end you will be able to:

  • Derive P(condition | positive) exactly with Bayes' theorem, and explain precisely why the intuitive "the test is 99% accurate, so I'm 99% likely to have it" answer is wrong.
  • Rebuild the same computation as a natural-frequencies count over 100,000 people, and see the same arithmetic that was opaque as percentages become obvious as integers.
  • Confirm an exact posterior by seeded simulation of a large population, with a tolerance derived from the standard error of a proportion.
  • Explain why the posterior is strictly increasing in prevalence, and identify the prevalence at which the naive "99%" guess becomes the actual right answer.
  • State and use the odds form of Bayes' theorem — posterior odds equal prior odds times the likelihood ratio — and explain why it isolates a piece of evidence's worth independently of the prior.
  • Update a belief sequentially across two different pieces of evidence, and explain why the order those updates arrive in never changes the result.
  • Construct a case where two pieces of "independent" evidence are actually correlated, and show that treating them as independent overstates confidence rather than merely producing a different number.
  • Build a Naive Bayes classifier from scratch with Laplace smoothing, and explain what a single unsmoothed zero-probability word does to an otherwise well-evidenced classification.
  • Explain why a real classifier is built in log space rather than as a literal product of probabilities, and demonstrate the underflow that makes the difference visible.
  • Say plainly what "naive" names in Naive Bayes, and why the classifier works despite the assumption being false.

Prerequisites

  • Day 113 — sample spaces, events, the addition and complement rules, and especially the law of total probability, which is this lesson's denominator run forward.
  • Day 114 — random variables and distributions (referenced by number only; this lab does not depend on its files).
  • Comfort with fractions.Fraction and basic Python.
  • Day 46 — floating-point representation, directly relevant to exercise 9.
  • Days 71–74 — running pytest and reading its output.
  • Day 43 — python3 -m venv and installing a package with pip.

Supported operating systems

  • macOS — run and captured here (macOS 26.5.2, Apple Silicon, arm64).
  • Linux — the same commands apply unchanged. Not run here.
  • Windows — use the Windows Subsystem for Linux and follow the Linux instructions, or Git Bash with .venv\Scripts\python.exe in place of .venv/bin/python3. Not run here; troubleshooting.md says so plainly.

Hardware requirements

Anything that runs Python. The largest computation this lab performs is a seeded simulation of 2,000,000 people in exercise 3 — a few million random draws, finished in well under a second. Roughly 60 MB of disk for the virtual environment, almost all of it NumPy.

Required software

  • python3 — 3.14.0 here.
  • numpy 2.5.2, installed into a lab-local virtual environment, used only by exercise 3's population simulation.
  • pytest 9.1.1, from the same virtual environment.
  • bash — 3.2.57 here, for the test harness.

Free and open-source options

Both dependencies are free and open source and there is no paid tier of anything in this lab. NumPy is distributed under the BSD 3-Clause licence and pytest under the MIT licence. No account, no key, no signup, personally or commercially.

Eight of the nine exercises (1, 2, 4, 5, 6, 7, 8, 9) need only fractions, math and collections from the standard library and do not touch NumPy at all. Only exercise 3's population simulation needs numpy.random.Generator, and requirements/README.md shows the standard-library substitution using random.Random if NumPy is unavailable.

scipy.stats, scikit-learn's MultinomialNB, and PyMC/Stan do related and more advanced work, and are not installed here, so no output from any of them is reproduced anywhere in this lab or its lesson. The lesson's Tools section describes each from its documentation.

Installation

From the repository root:

cd labs/sections/math-statistics-and-data/day-115-bayes-theorem
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy; print(numpy.__version__)"

Expect 2.5.2. That is the only time this lab needs the network.

File structure

.
├── README.md                                    this file
├── metadata.yml                                 how the lab was actually run, and when
├── requirements/
│   ├── README.md                                why each package is here, its licence, and the no-install path
│   └── requirements.txt                         numpy==2.5.2, pytest==9.1.1
├── starter/                                      your work goes here
│   ├── 00_brief.md                               the nine exercises, in order
│   ├── conftest.py                               makes this directory's modules the ones its tests import
│   ├── dataset.py                                the scenario, the corpus and every tolerance — read it, do not change it
│   ├── bayes.py                                  exercises 1, 4, 5, 6, 7 — Bayes' theorem functions to write
│   ├── simulate.py                               exercise 3 — the population simulation to write
│   ├── naive_bayes.py                            exercises 8, 9 — the classifier and log-space functions to write
│   ├── answers.py                                fifteen predictions
│   └── test_starter.py                           your running score; unattempted work skips
├── examples/                                     the reference, to read after you have tried
│   ├── conftest.py                               the same import guard
│   ├── dataset.py                                the data, and every tolerance with its derivation
│   ├── bayes.py                                  the finished Bayes'-theorem functions
│   ├── simulate.py                                the finished population simulation
│   ├── naive_bayes.py                             the finished classifier and log-space functions
│   ├── 01_opening_posterior.py                    the opening failure, derived exactly
│   ├── 02_natural_frequencies.py                  the same arithmetic, counted instead of multiplied
│   ├── 03_simulation.py                           a 2,000,000-person seeded confirmation
│   ├── 04_prevalence_sweep.py                     the base rate decides the answer
│   ├── 05_odds_form.py                            posterior odds = prior odds x likelihood ratio
│   ├── 06_sequential_updating.py                  two tests, both orders, one identical answer
│   ├── 07_correlated_tests.py                     the honest caveat, made concrete
│   ├── 08_naive_bayes_smoothing.py                a from-scratch classifier and the one-word veto
│   ├── 09_log_space.py                            the underflow that makes log space non-optional
│   └── test_reference.py                          71 tests over real values and real exceptions
├── tests/
│   └── run_tests.sh                               the bash harness: 53 checks, exits non-zero on any failure
├── expected-output/                               captured from real runs on 2026-08-17
│   ├── FIELDS.md                                  what may legitimately differ on your machine
│   ├── 01-opening-posterior.txt
│   ├── 02-natural-frequencies.txt
│   ├── 03-simulation.txt
│   ├── 04-prevalence-sweep.txt
│   ├── 05-odds-form.txt
│   ├── 06-sequential-updating.txt
│   ├── 07-correlated-tests.txt
│   ├── 08-naive-bayes-smoothing.txt
│   ├── 09-log-space.txt
│   ├── reference-tests.txt
│   ├── starter-progress.txt
│   └── test-run.txt
├── troubleshooting.md
└── security.md

How to run

Read starter/00_brief.md first. Then work, checking yourself as you go:

.venv/bin/pytest starter -q

On an untouched checkout that prints 2 passed, 38 skipped. A skip means "not attempted"; a failure means "attempted and wrong", and prints both your answer and the real one. When it prints 40 passed, you are finished.

Afterwards, read the reference — each script prints its working and asserts every claim it makes:

cd examples
../.venv/bin/python3 01_opening_posterior.py
../.venv/bin/python3 02_natural_frequencies.py
../.venv/bin/python3 03_simulation.py
../.venv/bin/python3 04_prevalence_sweep.py
../.venv/bin/python3 05_odds_form.py
../.venv/bin/python3 06_sequential_updating.py
../.venv/bin/python3 07_correlated_tests.py
../.venv/bin/python3 08_naive_bayes_smoothing.py
../.venv/bin/python3 09_log_space.py
cd ..
.venv/bin/pytest examples -q -p no:cacheprovider

Run them from inside examples/, because they import bayes.py, simulate.py, naive_bayes.py and dataset.py from beside themselves.

Then the full harness:

bash tests/run_tests.sh
echo "exit=$?"

What the commands do

Command What it does
python3 -m venv .venv Creates a virtual environment inside the lab, so nothing here can affect the rest of your machine. rm -rf .venv is a complete undo.
.venv/bin/pip install -r requirements/requirements.txt Installs numpy 2.5.2 and pytest 9.1.1. The one command that uses the network.
.venv/bin/pytest starter -q Your running score. Unattempted exercises skip; wrong answers fail with both values printed.
01_opening_posterior.py Derives P(condition | positive) = 99/1098 ≈ 0.0902 term by term, and asserts it is not the naive 0.99.
02_natural_frequencies.py The same computation as a 100,000-person table: 99 true positives against 999 false positives.
03_simulation.py Confirms the exact posterior by simulating and counting 2,000,000 people.
04_prevalence_sweep.py Sweeps prevalence from 1-in-100,000 to 1-in-2, and shows the posterior strictly increasing, reaching exactly 0.99 at 1-in-2.
05_odds_form.py Posterior odds = prior odds x likelihood ratio, and converts back to a probability matching exercise 1 exactly.
06_sequential_updating.py Two different tests, updated one at a time, in both orders — identical results either way.
07_correlated_tests.py Same test run twice on one sample; the naive independent-tests posterior versus the correct correlation-aware one.
08_naive_bayes_smoothing.py A tiny spam/ham corpus, classified with and without Laplace smoothing, showing the one-word veto.
09_log_space.py 500 factors of 0.01 underflow to exactly 0.0 as a float64 product; the corresponding sum of logs stays finite.
.venv/bin/pytest examples -q -p no:cacheprovider The 71 reference tests. -p no:cacheprovider stops pytest writing a .pytest_cache directory.
bash tests/run_tests.sh The 53-check harness: versions, every script, both suites, nineteen individual values, a deliberate self-failure, and a clean-disk check.

Expected output

The captured files live in expected-output/. The harness ends with:

53 checks, 0 failure(s).

and exits 0. The reference suite ends with 71 passed, and an untouched starter with 2 passed, 38 skipped.

The opening failure, worth recognising before you meet it:

P(condition | positive) = 99/1098 = 11/122
                         = 0.090164  ~ 0.0902

expected-output/FIELDS.md records exactly which parts of the captured output may legitimately differ on your machine — exercise 3's simulated counts, not the exact Fraction results — and includes a correction notice about a wrong log-space figure this lab does not repeat.

Validation steps

  1. bash tests/run_tests.sh; echo "exit=$?" prints 53 checks, 0 failure(s). and exit=0.
  2. .venv/bin/pytest examples -q -p no:cacheprovider prints 71 passed.
  3. .venv/bin/pytest starter -q -p no:cacheprovider prints 40 passed once you have finished, and never prints a failure you have not been shown.
  4. Each of the nine reference scripts ends with every assertion held.
  5. Your posterior() function returns a Fraction, never a float — an assertion comparing it against Fraction(99, 1098) must pass exactly, not approximately.

Tests

tests/run_tests.sh runs 53 checks in seven sections:

  1. Versions — reads the installed numpy and compares it against requirements/requirements.txt, and confirms it is NumPy 2 or later.
  2. The nine reference scripts — each must exit 0 and print that every one of its internal assertions held.
  3. The reference pytest suite — must exit 0, report no failures, and have collected at least 60 tests, so a collection error cannot pass as success.
  4. The starter suite — must exit 0 on an untouched checkout with skips rather than failures; and collecting both suites at once must not turn any of those skips into passes, which is a real hazard here because both directories contain modules called bayes, simulate, naive_bayes, dataset and answers.
  5. Nineteen individual values — the opening posterior and its rounding, the natural-frequency total, the simulation's tolerance check, the prevalence sweep's monotonicity and its exact value at prevalence 1/2, the odds-form equality, the sequential posterior and its order independence, both correlated-test posteriors and their comparison, the veto-case classification with and without smoothing, and the underflow and log-space results.
  6. A deliberate failure — the harness temporarily flips one reference assertion (the naive-versus-correlated posterior comparison), re-runs the reference suite, and asserts that the run reports exactly one failure and a non-zero exit — then restores the file. A green suite proves nothing until you have watched it go red.
  7. A clean disk — no __pycache__ and no .pytest_cache outside .venv, and no source file that opens a network connection.

Before section 1, the harness clears any __pycache__ and .pytest_cache that an earlier command left behind, pruning .venv as it goes. This matters more than it sounds. The README above tells you to run .venv/bin/pytest starter -q, and that command legitimately writes starter/__pycache__ and .pytest_cache. Without the pre-run clear, section 7 would then report those as litter — failing you for following the instructions in this file. Clearing them at the start makes the final check measure what it claims to measure: what this run left behind.

The harness was confirmed to exit 0 on a fresh lab-local .venv created by the documented setup commands, and to correctly report a non-zero exit and exactly one failure when section 6 deliberately breaks one assertion. Separately, its ability to catch a genuine bug was confirmed by hand: dataset.py's OPENING_POSTERIOR_EXACT constant was temporarily edited to a wrong value, the full harness reported 7 failures and a non-zero exit, and the file was restored and the harness re-run clean. .venv is the documented setup, not a stray file, and nothing in the suite treats it as one or deletes anything inside it.

Cleanup

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf .venv          # optional: removes the lab virtual environment
git checkout -- starter/   # optional: resets your work

The lab's own commands leave none of the first two behind; section 7 of the harness fails if they appear. It deliberately does not look inside .venv, because the bytecode caches shipped with NumPy and pytest are theirs, not yours.

Troubleshooting

See troubleshooting.md. It covers wrong-directory import errors, the posterior() formula's most common mistake (returning the sensitivity directly instead of dividing by the evidence), Fraction-versus-float return-type mistakes, the odds-form's sensitivity/specificity swap, the correlated-versus-naive posterior comparison flipped, the Naive Bayes veto case explained, and the import collision the two conftest.py files prevent. All of them were hit while building this lab or are named by a test.

Security notes

See security.md. In short: this lab computes and prints. It writes no files, opens no connection after the one-time install, needs no credentials and no sudo, and all the data is invented. Three points there are worth carrying away: a posterior is only as trustworthy as the prior and independence assumptions that produced it; a classifier's output is a posterior, and a model trained on the wrong base rate is confidently wrong at scale; and a wrong probability calculation looks exactly like a right one until you check it against an independent method — which is the entire structure of this lab.

Extension exercises

  1. The prosecutor's fallacy, made concrete. Construct a scenario where a piece of forensic evidence has P(evidence | innocent) = 1/1,000,000, and compute P(innocent | evidence) given a stated prior probability of guilt before the evidence — using Bayes' theorem, not by treating the two conditional probabilities as interchangeable. Confirm that a tiny P(evidence | innocent) does not by itself make P(innocent | evidence) tiny once the prior is properly accounted for, and write one paragraph on why conflating the two directions is called a fallacy rather than an approximation.
  2. A three-test sequential update. Extend exercise 6 to three different tests of varying quality, confirm the posterior is identical across all six orderings, and find the ordering-invariant quantity directly (the product of all three likelihood ratios) rather than computing it by brute-force permutation.
  3. Vary the correlation weight continuously. Sweep correlation_weight from 0 to 1 in exercise 7's scenario, and confirm the correlated posterior is a strictly decreasing function of it — full independence (0) gives the naive answer as an upper bound, and full correlation (1) gives the single-test posterior as a lower bound, since two fully-correlated results are worth no more than one.
  4. Add a third class to the Naive Bayes classifier. Introduce a "newsletter" class alongside spam and ham with its own tiny corpus, and confirm classify_log_space() still selects correctly among three classes — the log-space argmax generalises with no changes beyond iterating over more classes.
  5. Measure calibration on invented predictions. Generate 1,000 invented "model confidence" values and invented true/false outcomes where the model is deliberately overconfident, bucket the confidences, and compute the empirical accuracy within each bucket — a direct application of this lesson's conditioning-as-restriction idea to the AI thread's calibration question.
  • Previous day: Day 114 — Random Variables and Distributions
  • Next day: Day 116 — Descriptive Statistics That Don't Lie
  • Week 17: Probability and Statistics
  • Section: Mathematics, Statistics and Data

Expected output

01-opening-posterior.txt

The scenario
------------------------------------------------------------
  prevalence   P(condition)          = 1/1000
  sensitivity  P(positive|condition) = 99/100
  specificity  P(negative|no cond.)  = 99/100

Bayes' theorem, worked term by term
------------------------------------------------------------
  P(condition and positive)    = 1/1000 x 99/100 = 99/100000
  P(no condition and positive) = 999/1000 x 1/100 = 999/100000
  P(positive)  [the evidence]  = 99/100000 + 999/100000 = 549/50000
  P(condition | positive)      = 99/100000 / 549/50000 = 11/122
                                = 0.090164  ~ 0.0902
  ok: posterior() agrees with the term-by-term derivation
  ok: the exact posterior is 99/1098
  ok: which reduces to 11/122
  ok: and rounds to 0.0902
  ok: the posterior is NOT 0.99 -- the answer almost everyone gives
  ok: in fact it is roughly 11x smaller than the naive 0.99 guess

Ninety-one out of every hundred positives are false alarms
------------------------------------------------------------
  P(no condition | positive) = 1 - 11/122 = 111/122 ~ 0.9098
  ok: over 90% of positives are false alarms

01_opening_posterior.py: every assertion held. (7 checks)

02-natural-frequencies.txt

Out of 100,000 people
------------------------------------------------------------
  100 have the condition, 99,900 do not
  ok: sick + well accounts for everyone

Of the sick people:
  99 test positive (true positives)
  1 tests negative (false negative)
  ok: TP + FN accounts for every sick person

Of the healthy people:
  999 test positive anyway (false positives)
  98,901 correctly test negative (true negatives)
  ok: FP + TN accounts for every healthy person

So, of everyone who tests positive:
------------------------------------------------------------
  true positives  99
  false positives 999
  total positives 1098
  P(condition | positive) = 99 / 1098 = 11/122 ~ 0.0902
  ok: total positives is exactly 1,098
  ok: TP / (TP + FP) equals the exact posterior from exercise 1
  ok: nine false alarms for every true positive, roughly

The percentages were never wrong -- they were just opaque
------------------------------------------------------------
  1% of 999,000... no: 1% of the 99,900 healthy people IS 999 people,
  and 999 false positives dwarfs the 99 true positives a 99%-sensitive
  test catches out of only 100 sick people. Counting makes that
  imbalance impossible to miss; percentages hid it in plain sight.

02_natural_frequencies.py: every assertion held. (6 checks)

03-simulation.txt

Simulating 2,000,000 people, seed 42
------------------------------------------------------------
  true positives:  1,978
  false positives: 20,069
  true negatives:  1,977,937
  false negatives: 16
  total positives: 22,047

  exact posterior      = 0.090164
  simulated posterior  = 0.089717
  gap                  = 0.000447
  standard error at n=22,047 positives = 0.001929
  tolerance (3 SE)     = 0.005787
  ok: some people actually have the condition in the sample
  ok: the simulated posterior lands within 3 standard errors of the exact value
  ok: the simulated posterior is nowhere near the naive 0.99 guess

03_simulation.py: every assertion held. (3 checks)

04-prevalence-sweep.txt

Sensitivity and specificity fixed at 99% / 99%. Prevalence varies.
------------------------------------------------------------
  prevalence 1/100000 -> posterior   11/11122  (0.000989)
  prevalence  1/10000 -> posterior      1/102  (0.009804)
  prevalence   1/1000 -> posterior     11/122  (0.090164)
  prevalence    1/100 -> posterior        1/2  (0.500000)
  prevalence     1/10 -> posterior      11/12  (0.916667)
  prevalence      1/2 -> posterior     99/100  (0.990000)

  ok: the posterior is strictly increasing as prevalence rises
  ok: the sweep's final prevalence is exactly 1/2
  ok: at prevalence 1/2 the posterior is EXACTLY 0.99
  ok: 0.99 -- everyone's wrong guess for the 1-in-1,000 case -- is over 10x the true answer there
  ok: ...but is the EXACT right answer once the base rate is 1 in 2

The number 0.99 was never wrong. It just answered a different question.
------------------------------------------------------------
  Sensitivity and specificity describe the TEST. The posterior
  describes the PATIENT, and the patient's answer depends on how
  common the condition was before the test ever ran.

04_prevalence_sweep.py: every assertion held. (5 checks)

05-odds-form.txt

Prior odds
------------------------------------------------------------
  P(condition) = 1/1000
  prior odds   = 1/1000 / (1 - 1/1000) = 1/999  (about 1 in 999)

Likelihood ratio
------------------------------------------------------------
  LR+ = P(positive|condition) / P(positive|no condition)
      = 99/100 / (1 - 99/100) = 99
  a positive result is 99x more likely under the condition than without it

Posterior odds, and back to a probability
------------------------------------------------------------
  posterior odds = 1/999 x 99 = 11/111
  posterior probability = 11/111 / (1 + 11/111) = 11/122  (0.090164)
  ok: posterior odds equal prior odds times the likelihood ratio, exactly
  ok: converting back to a probability matches exercise 1's direct answer exactly
  ok: the likelihood ratio here is exactly 99
  ok: prior odds are exactly 1/999

Why the odds form earns its place
------------------------------------------------------------
  The likelihood ratio (99, here) says exactly how much a positive
  result is worth as evidence, and that number does not change if
  the prior changes. Multiply it onto ANY prior odds and you get
  that prior's correctly updated posterior odds -- which is exactly
  what exercise 6 does twice in a row.

05_odds_form.py: every assertion held. (4 checks)

06-sequential-updating.txt

Two different tests, both positive
------------------------------------------------------------
  test A: sensitivity 99/100, specificity 99/100
  test B: sensitivity 19/20, specificity 49/50
  prior:  P(condition) = 1/1000

Update with A first, then B
------------------------------------------------------------
  after A: posterior = 11/122  (0.090164)
  after A then B: posterior = 1045/1267  (0.824783)

Update with B first, then A
------------------------------------------------------------
  after B: posterior = 95/2093  (0.045389)
  after B then A: posterior = 1045/1267  (0.824783)

Via sequential_posterior(), both orders in one call each
------------------------------------------------------------
  [A, B] -> 1045/1267   [B, A] -> 1045/1267
  ok: A-then-B matches B-then-A exactly
  ok: both hand-worked orders match sequential_posterior()'s [A, B] result
  ok: both hand-worked orders match sequential_posterior()'s [B, A] result
  ok: the two-test posterior is exactly 1045/1267
  ok: two positive tests move the posterior well past one-half
  ok: a single test's posterior (~9%) is far below the two-test posterior (~82%)

Why the order never matters
------------------------------------------------------------
  Each update multiplies the running odds by one more likelihood
  ratio. Multiplication of real (or Fraction) numbers is
  commutative -- a x b always equals b x a -- so a chain of
  updates is the same product regardless of the order the factors
  arrive in. This is not a special property of Bayes' theorem; it
  is a special property of multiplication that Bayes' theorem
  happens to be built from.

06_sequential_updating.py: every assertion held. (6 checks)

07-correlated-tests.txt

Same test, same sample, run twice. Both runs come back positive.
------------------------------------------------------------
  single-run sensitivity 99/100, specificity 99/100
  correlation weight (probability the two runs share one failure mode): 1/2

The NAIVE model: treat the two runs as independent
------------------------------------------------------------
  P(both positive | condition)    = sensitivity^2 = 9801/10000
  P(both positive | no condition) = (1-specificity)^2 = 1/10000
  naive posterior = 363/400  (0.907500)

The CORRECT model: half the time, one shared draw decides both runs
------------------------------------------------------------
  P(both positive | condition)    = 1/2 x sens + (1-1/2) x sens^2 = 19701/20000
  P(both positive | no condition) = 1/2 x (1-spec) + (1-1/2) x (1-spec)^2 = 101/20000
  correct posterior = 2189/13400  (0.163358)

Both numbers, side by side
------------------------------------------------------------
  naive (assumes independence):  0.9075  (90.8% confident)
  correct (accounts for the shared failure mode): 0.1634  (16.3% confident)
  ok: the naive calculation is exactly 363/400
  ok: the naive posterior is strictly higher than the correct one
  ok: the naive answer overstates confidence by more than a factor of five
  ok: both posteriors still exceed a single test's ~9% posterior

Which one is right? The correct one -- by construction of this scenario
------------------------------------------------------------
  The naive calculation is not a rounding error; it is answering a
  question that was never asked. It computes the posterior for a
  world where the two positive results are independent evidence.
  In THIS world, half the time they are not two pieces of evidence
  at all -- they are one piece of evidence, reported twice. Treating
  a correlated pair as independent double-counts it, exactly the
  way the addition rule's naive sum double-counted an overlap back
  on Day 113 -- a different rule, the same shape of mistake.

07_correlated_tests.py: every assertion held. (4 checks)

08-naive-bayes-smoothing.txt

Training corpus
------------------------------------------------------------
  spam: ('buy cheap watches now', 'cheap replica watches for sale', 'buy now limited offer')
  ham:  ('meeting notes for review', 'please review the agenda', 'schedule the project meeting')
  vocabulary: 17 words -- ['agenda', 'buy', 'cheap', 'for', 'limited', 'meeting', 'notes', 'now', 'offer', 'please', 'project', 'replica', 'review', 'sale', 'schedule', 'the', 'watches']
  total words per class: {'spam': 13, 'ham': 12}

'watches' and 'review' never cross class lines in training
------------------------------------------------------------
  count('watches', ham)  = 0
  count('review', spam)  = 0
  ok: 'watches' never appears in the ham training documents
  ok: 'review' never appears in the spam training documents

Two clean held-out documents -- smoothed and unsmoothed agree
------------------------------------------------------------
  'buy cheap watches': smoothed -> spam, unsmoothed -> spam
  ok: 'buy cheap watches' classifies the same way smoothed and unsmoothed
  'schedule the project meeting': smoothed -> ham, unsmoothed -> ham
  ok: 'schedule the project meeting' classifies the same way smoothed and unsmoothed
  ok: 'buy cheap watches' is correctly classified spam
  ok: 'schedule the project meeting' is correctly classified ham

The veto case: three ham words and one spam word, mixed
------------------------------------------------------------
  document: 'please review schedule watches'
  WITH Laplace smoothing (alpha=1):
    P(spam) x P(words|spam) = 1/540000  (1.852e-06)
    P(ham) x P(words|ham) = 6/707281  (8.483e-06)
    winner: ham
  WITHOUT smoothing (alpha=0):
    P(spam) x P(words|spam) = 0  (0.000e+00)
    P(ham) x P(words|ham) = 0  (0.000e+00)
    winner: spam
  ok: with smoothing, the veto-case document is correctly classified ham
  ok: without smoothing, ham's score collapses to EXACTLY zero because of one absent word
  ok: without smoothing, spam's score ALSO collapses to exactly zero, from the other absent word
  ok: with both scores tied at zero, the unsmoothed classifier picks a class by tie-break order, not evidence

What just happened
------------------------------------------------------------
  'watches' has never been seen in a ham document, so its unsmoothed
  P(watches | ham) is exactly 0 -- and multiplying anything by 0 gives
  0, no matter how strongly 'please', 'review' and 'schedule' point
  toward ham. One unseen word vetoed three words of real evidence.
  Laplace smoothing gives every word in the vocabulary a small
  nonzero probability under every class, so a single absence can
  never single-handedly decide the outcome.

08_naive_bayes_smoothing.py: every assertion held. (10 checks)

09-log-space.txt

Multiplying 500 factors of 0.01, as plain float64
------------------------------------------------------------
  after   1 factors: 0.01
  after  10 factors: 1.0000000000000002e-20
  after  50 factors: 1.0000000000000009e-100
  after 100 factors: 1.0000000000000017e-200
  after 200 factors: 0.0
  after 300 factors: 0.0
  after 400 factors: 0.0
  after 500 factors: 0.0
  final product: 0.0
  ok: the product is finite for the first several dozen factors
  ok: the final product underflows to EXACTLY 0.0
  ok: 0.0 == 0.0 is a real equality, not an approximation

The true value is nowhere near zero -- float64 just cannot hold it
------------------------------------------------------------
  the exact value is 10^-1000 = 10^-1000
  float64's smallest positive representable number is about 5e-324
  10^-1000 is about 10^-676 times smaller than that floor -- not just
  unrepresentable but unrepresentable by roughly 676 orders of magnitude
  ok: the true product's magnitude (10^-1000) is far below float64's smallest positive value (~5e-324)

In log space, the same computation stays perfectly finite
------------------------------------------------------------
  sum of 500 copies of ln(0.01) = -2302.5850929940457
  math.isfinite(log_sum) = True
  ok: the log-space sum is finite
  ok: the log-space sum matches 500 x ln(0.01), computed independently
  ok: the log-space sum rounds to -2302.59, NOT -1151.29

A note on a wrong number this lab does not repeat
------------------------------------------------------------
  500 x ln(0.01) is -2302.585..., not -1151.29. -1151.29 is what you
  get from 500 factors of 0.1, or 250 factors of 0.01 -- a different
  computation. Every figure in this lab is the one actually
  computed by math.log, printed above, not copied from a draft.

Why this matters for exercise 8's classifier
------------------------------------------------------------
  The tiny four-word documents in exercise 8 never multiply enough
  factors to underflow. A real document -- a hundred-word email, a
  page of a support ticket -- easily does. A classifier built on
  document_score() (the plain product) ties every class at 0.0 once
  the document is long enough, and silently returns whichever class
  came first, regardless of the actual evidence -- the same failure
  mode as exercise 8's veto, but caused by scale instead of by an
  absent word. document_log_score() -- the sum of logs -- is the
  fix, and it is the version a real implementation ships.

09_log_space.py: every assertion held. (7 checks)

FIELDS.md

# What is exact everywhere, and what may differ on your machine

Every figure in this lab falls into one of two categories. Knowing which is
which tells you whether a different number on your machine means a bug or
just means you ran it.

## Exact everywhere — identical on any correct Python implementation

These are `fractions.Fraction` results, computed by exact rational
arithmetic. They cannot differ between machines, Python versions, or
operating systems, short of an actual bug.

- The opening posterior: `99/1098`, reduced to `11/122` (`~0.09016393...`).
- The natural-frequency table: `TP=99`, `FP=999`, `TN=98901`, `FN=1`,
  `1098` total positives.
- The prevalence-sweep values, including the exact `0.99` at prevalence
  `1/2`.
- The odds-form values: prior odds `1/999`, likelihood ratio `99`,
  posterior odds `11/111`.
- The sequential two-test posterior: `1045/1267` (`~0.82478...`).
- Both correlated-test posteriors: naive `363/400` (`0.9075` exactly),
  correct `2189/13400` (`~0.16336`).
- Every Naive Bayes word count, word probability and document score in
  exercise 8, since the toy corpus and `Fraction`-based arithmetic are
  fixed and exact.
- The underflow result in exercise 9: `500` factors of `0.01` multiplied as
  `float64` are IEEE-754-deterministic and underflow to exactly `0.0` on
  every machine that implements the standard correctly. The sum of logs,
  `-2302.5850929940457`, is likewise IEEE-754-deterministic.

## Sampled — may differ on another machine, another seed, or another NumPy version

- **Exercise 3's simulated posterior.** Captured on this run at
  `n = 2,000,000`, seed `42`: true positives `1,978`, false positives
  `20,069`, empirical posterior `0.089717...`, against an exact value of
  `0.090164...` — a gap of about `0.00045`, comfortably inside the
  3-standard-error tolerance of about `0.00579` at that many positive
  results. NumPy's `default_rng` is reproducible for a *fixed* NumPy
  version and seed, but is not guaranteed bit-identical across major NumPy
  releases; the test asserts the gap is within tolerance, never the exact
  simulated counts.
- No other exercise in this lab depends on simulation. Exercises 1, 2 and
  4 through 9 are exact `Fraction` or IEEE-754-deterministic arithmetic
  throughout.

## A correction this lab made, and is not hiding

An earlier draft of this lab's brief stated that 500 factors of `0.01`
collapse in log space to "about -1151.29". That figure is wrong for 500
factors of `0.01` — the correct value, computed directly and shown in
`09-log-space.txt`, is `-2302.585...` (`500 * ln(0.01)`). `-1151.29` is
what 500 factors of `0.1` produce instead (`500 * ln(0.1)`), or
equivalently 250 factors of `0.01`. Every figure in this lab's code, tests
and lesson uses the measured, correct value for 500 factors of `0.01`, not
the draft figure. `dataset.py`'s `UNDERFLOW_LOG_SUM` constant is computed
by `math.log`, not copied from anywhere.

reference-tests.txt

.......................................................................  [100%]
71 passed in 0.11s

starter-progress.txt

.ssssssssssssssssssssssssssssssssssssss.                                 [100%]
2 passed, 38 skipped in 0.06s

test-run.txt

Day 115 — Bayes You Can Trust

1. The tools and the versions this lab was written against
  python   3.14.0
  numpy    2.5.2
  pytest   9.1.1
  platform macOS-26.5.2-arm64-arm-64bit-Mach-O
  exe      python3
  ok: installed numpy matches requirements.txt
  ok: numpy is version 2 or later

2. Every reference script runs and every assertion inside it holds
  ok: 01_opening_posterior.py exits 0
  ok: 01_opening_posterior.py reports every assertion held
  ok: 02_natural_frequencies.py exits 0
  ok: 02_natural_frequencies.py reports every assertion held
  ok: 03_simulation.py exits 0
  ok: 03_simulation.py reports every assertion held
  ok: 04_prevalence_sweep.py exits 0
  ok: 04_prevalence_sweep.py reports every assertion held
  ok: 05_odds_form.py exits 0
  ok: 05_odds_form.py reports every assertion held
  ok: 06_sequential_updating.py exits 0
  ok: 06_sequential_updating.py reports every assertion held
  ok: 07_correlated_tests.py exits 0
  ok: 07_correlated_tests.py reports every assertion held
  ok: 08_naive_bayes_smoothing.py exits 0
  ok: 08_naive_bayes_smoothing.py reports every assertion held
  ok: 09_log_space.py exits 0
  ok: 09_log_space.py reports every assertion held

3. The reference pytest suite: real values, real exceptions
  .......................................................................  [100%]
  71 passed in 0.11s
  ok: pytest examples exits 0
  ok: no test in the reference suite failed
  ok: the reference suite ran at least 60 tests (ran 71)

4. The starter suite skips unattempted work instead of failing it
  .ssssssssssssssssssssssssssssssssssssss.                                 [100%]
  2 passed, 38 skipped in 0.06s
  ok: pytest starter exits 0 on an untouched checkout
  ok: the starter suite reports no failures
  ok: unwritten exercises are reported as skipped, not passed
  ok: collecting both suites at once does not turn skips into passes

5. The lesson's claims, checked one value at a time
  ok: the opening posterior is exactly 99/1098, reduced to 11/122
  ok: which rounds to 0.0902
  ok: and is NOT the naive 0.99 guess
  ok: the natural-frequency table has 1098 total positives
  ok: TP/(TP+FP) matches the formula's answer exactly
  ok: the 2,000,000-person simulation lands within 3 standard errors
  ok: the prevalence sweep is strictly increasing
  ok: at prevalence 1/2 the posterior is exactly 0.99
  ok: posterior odds equal prior odds times the likelihood ratio, exactly
  ok: the odds-form probability matches the direct formula exactly
  ok: the two-test sequential posterior is exactly 1045/1267
  ok: updating in either order gives an identical result
  ok: the naive correlated-test posterior is exactly 363/400
  ok: the correct correlated posterior is exactly 2189/13400
  ok: the naive posterior is strictly higher than the correct one
  ok: the veto-case document classifies ham with smoothing
  ok: and ham's score is exactly zero without smoothing
  ok: 500 factors of 0.01 underflow to exactly 0.0
  ok: the corresponding sum of logs is finite
  ok: and matches the independently measured value

6. The harness can actually fail
  ok: a deliberately broken assertion makes the suite exit non-zero (1)
  ok: the failing test is named in the output
  ok: exactly one test failed

7. Nothing was left behind
  ok: no __pycache__ directory left by the lab's own code
  ok: no .pytest_cache directory left under the lab
  ok: no lab source opens a network connection

53 checks, 0 failure(s).

Source files

examples/01_opening_posterior.py (2871 bytes)
"""Exercise 1 -- the opening failure, derived exactly.

A test is 99% sensitive and 99% specific. The condition affects 1 person in
1,000. You test positive. Almost everyone -- including, in published
studies, most physicians asked this exact question -- answers "about 99%".
The true answer is close to 9%. This script derives it exactly with
Bayes' theorem, as a Fraction, and asserts both the true value and the
misconception it corrects.
"""

from fractions import Fraction

import bayes as B
import dataset as D

checks_held = []


def check(label: str, condition: bool) -> None:
    checks_held.append((label, condition))
    print(f"  {'ok' if condition else 'FAIL'}: {label}")


print("The scenario")
print("-" * 60)
print(f"  prevalence   P(condition)          = {D.PREVALENCE}")
print(f"  sensitivity  P(positive|condition) = {D.SENSITIVITY}")
print(f"  specificity  P(negative|no cond.)  = {D.SPECIFICITY}")

print()
print("Bayes' theorem, worked term by term")
print("-" * 60)

p_condition_and_positive = D.PREVALENCE * D.SENSITIVITY
p_no_condition_and_positive = (1 - D.PREVALENCE) * (1 - D.SPECIFICITY)
evidence = p_condition_and_positive + p_no_condition_and_positive
result = p_condition_and_positive / evidence

print(f"  P(condition and positive)    = {D.PREVALENCE} x {D.SENSITIVITY} = {p_condition_and_positive}")
print(f"  P(no condition and positive) = {1 - D.PREVALENCE} x {1 - D.SPECIFICITY} = {p_no_condition_and_positive}")
print(f"  P(positive)  [the evidence]  = {p_condition_and_positive} + {p_no_condition_and_positive} = {evidence}")
print(f"  P(condition | positive)      = {p_condition_and_positive} / {evidence} = {result}")
print(f"                                = {float(result):.6f}  ~ {round(float(result), 4)}")

via_function = B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY)
check("posterior() agrees with the term-by-term derivation", via_function == result)
check("the exact posterior is 99/1098", result == Fraction(99, 1098))
check("which reduces to 11/122", result == Fraction(11, 122))
check("and rounds to 0.0902", round(float(result), 4) == 0.0902)
check("the posterior is NOT 0.99 -- the answer almost everyone gives", result != Fraction(99, 100))
check("in fact it is roughly 11x smaller than the naive 0.99 guess", Fraction(99, 100) / result > 10)

print()
print("Ninety-one out of every hundred positives are false alarms")
print("-" * 60)
false_alarm_share = 1 - result
print(f"  P(no condition | positive) = 1 - {result} = {false_alarm_share} ~ {round(float(false_alarm_share), 4)}")
check("over 90% of positives are false alarms", float(false_alarm_share) > 0.90)

print()
if all(ok for _, ok in checks_held):
    print(f"01_opening_posterior.py: every assertion held. ({len(checks_held)} checks)")
else:
    failed = [label for label, ok in checks_held if not ok]
    raise SystemExit(f"FAILED: {failed}")
examples/02_natural_frequencies.py (2946 bytes)
"""Exercise 2 -- the same arithmetic, made obvious by counting people
instead of multiplying percentages.

Stop using percentages. Count 100,000 people. This script builds the exact
table and shows that the fraction TP / (TP + FP) -- read straight off four
integers -- equals exercise 1's formula answer exactly.
"""

from fractions import Fraction

import dataset as D

checks_held = []


def check(label: str, condition: bool) -> None:
    checks_held.append((label, condition))
    print(f"  {'ok' if condition else 'FAIL'}: {label}")


print(f"Out of {D.NATURAL_FREQUENCY_POPULATION:,} people")
print("-" * 60)
print(f"  {D.NATURAL_FREQUENCY_SICK} have the condition, {D.NATURAL_FREQUENCY_WELL:,} do not")
check(
    "sick + well accounts for everyone",
    D.NATURAL_FREQUENCY_SICK + D.NATURAL_FREQUENCY_WELL == D.NATURAL_FREQUENCY_POPULATION,
)

print()
print("Of the sick people:")
print(f"  {D.NATURAL_FREQUENCY_TP} test positive (true positives)")
print(f"  {D.NATURAL_FREQUENCY_FN} tests negative (false negative)")
check(
    "TP + FN accounts for every sick person",
    D.NATURAL_FREQUENCY_TP + D.NATURAL_FREQUENCY_FN == D.NATURAL_FREQUENCY_SICK,
)

print()
print("Of the healthy people:")
print(f"  {D.NATURAL_FREQUENCY_FP} test positive anyway (false positives)")
print(f"  {D.NATURAL_FREQUENCY_TN:,} correctly test negative (true negatives)")
check(
    "FP + TN accounts for every healthy person",
    D.NATURAL_FREQUENCY_FP + D.NATURAL_FREQUENCY_TN == D.NATURAL_FREQUENCY_WELL,
)

print()
print("So, of everyone who tests positive:")
print("-" * 60)
total_positive = D.NATURAL_FREQUENCY_TP + D.NATURAL_FREQUENCY_FP
print(f"  true positives  {D.NATURAL_FREQUENCY_TP}")
print(f"  false positives {D.NATURAL_FREQUENCY_FP}")
print(f"  total positives {total_positive}")
natural_posterior = Fraction(D.NATURAL_FREQUENCY_TP, total_positive)
print(f"  P(condition | positive) = {D.NATURAL_FREQUENCY_TP} / {total_positive} = {natural_posterior}"
      f" ~ {round(float(natural_posterior), 4)}")

check("total positives is exactly 1,098", total_positive == 1098)
check("TP / (TP + FP) equals the exact posterior from exercise 1", natural_posterior == D.OPENING_POSTERIOR_EXACT)
check("nine false alarms for every true positive, roughly", D.NATURAL_FREQUENCY_FP / D.NATURAL_FREQUENCY_TP > 9)

print()
print("The percentages were never wrong -- they were just opaque")
print("-" * 60)
print("  1% of 999,000... no: 1% of the 99,900 healthy people IS 999 people,")
print("  and 999 false positives dwarfs the 99 true positives a 99%-sensitive")
print("  test catches out of only 100 sick people. Counting makes that")
print("  imbalance impossible to miss; percentages hid it in plain sight.")

print()
if all(ok for _, ok in checks_held):
    print(f"02_natural_frequencies.py: every assertion held. ({len(checks_held)} checks)")
else:
    failed = [label for label, ok in checks_held if not ok]
    raise SystemExit(f"FAILED: {failed}")
examples/03_simulation.py (2198 bytes)
"""Exercise 3 -- confirm the exact posterior by simulating a large
population and literally counting the four outcome cells.

Neither the exact formula nor the natural-frequencies table above touched
numpy.random. This script is the third, independent check: draw a
population, test it, and count -- and the empirical posterior should land
within a few standard errors of the exact 99/1098.
"""

import numpy as np

import dataset as D
import simulate as S

checks_held = []


def check(label: str, condition: bool) -> None:
    checks_held.append((label, condition))
    print(f"  {'ok' if condition else 'FAIL'}: {label}")


print(f"Simulating {D.SIMULATION_POPULATION:,} people, seed {D.SIMULATION_SEED}")
print("-" * 60)

rng = np.random.default_rng(D.SIMULATION_SEED)
counts = S.simulate_population(
    rng,
    D.SIMULATION_POPULATION,
    float(D.PREVALENCE),
    float(D.SENSITIVITY),
    float(D.SPECIFICITY),
)

print(f"  true positives:  {counts.true_positive:,}")
print(f"  false positives: {counts.false_positive:,}")
print(f"  true negatives:  {counts.true_negative:,}")
print(f"  false negatives: {counts.false_negative:,}")
print(f"  total positives: {counts.positives:,}")

exact = float(D.OPENING_POSTERIOR_EXACT)
empirical = counts.empirical_posterior
se = D.standard_error(exact, counts.positives)
tolerance = 3.0 * se

print()
print(f"  exact posterior      = {exact:.6f}")
print(f"  simulated posterior  = {empirical:.6f}")
print(f"  gap                  = {abs(empirical - exact):.6f}")
print(f"  standard error at n={counts.positives:,} positives = {se:.6f}")
print(f"  tolerance (3 SE)     = {tolerance:.6f}")

check("some people actually have the condition in the sample", counts.true_positive + counts.false_negative > 0)
check("the simulated posterior lands within 3 standard errors of the exact value",
      abs(empirical - exact) < tolerance)
check("the simulated posterior is nowhere near the naive 0.99 guess", empirical < 0.5)

print()
if all(ok for _, ok in checks_held):
    print(f"03_simulation.py: every assertion held. ({len(checks_held)} checks)")
else:
    failed = [label for label, ok in checks_held if not ok]
    raise SystemExit(f"FAILED: {failed}")
examples/04_prevalence_sweep.py (2420 bytes)
"""Exercise 4 -- the base rate decides the answer. Sweep it and watch.

Everything about sensitivity and specificity stays fixed at 99%/99%
throughout this script. Only the prevalence changes -- and the posterior
climbs from under a tenth of a percent at one case in 100,000, to exactly
0.99 at a prevalence of one-half. 0.99 is the number nearly everyone
wrongly gives for the 1-in-1,000 case; this script shows it is the RIGHT
answer, just for a completely different question.
"""

from fractions import Fraction

import bayes as B
import dataset as D

checks_held = []


def check(label: str, condition: bool) -> None:
    checks_held.append((label, condition))
    print(f"  {'ok' if condition else 'FAIL'}: {label}")


print("Sensitivity and specificity fixed at 99% / 99%. Prevalence varies.")
print("-" * 60)

results: list[Fraction] = []
for prevalence in D.PREVALENCE_SWEEP:
    posterior = B.posterior(prevalence, D.SENSITIVITY, D.SPECIFICITY)
    results.append(posterior)
    print(f"  prevalence {str(prevalence):>8} -> posterior {str(posterior):>10}"
          f"  ({float(posterior):.6f})")

print()
check(
    "the posterior is strictly increasing as prevalence rises",
    all(results[i] < results[i + 1] for i in range(len(results) - 1)),
)

last_prevalence, last_posterior = D.PREVALENCE_SWEEP[-1], results[-1]
check("the sweep's final prevalence is exactly 1/2", last_prevalence == Fraction(1, 2))
check("at prevalence 1/2 the posterior is EXACTLY 0.99", last_posterior == Fraction(99, 100))

opening_posterior = B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY)
check(
    "0.99 -- everyone's wrong guess for the 1-in-1,000 case -- is over 10x the true answer there",
    Fraction(99, 100) / opening_posterior > 10,
)
check(
    "...but is the EXACT right answer once the base rate is 1 in 2",
    last_posterior == Fraction(99, 100),
)

print()
print("The number 0.99 was never wrong. It just answered a different question.")
print("-" * 60)
print("  Sensitivity and specificity describe the TEST. The posterior")
print("  describes the PATIENT, and the patient's answer depends on how")
print("  common the condition was before the test ever ran.")

print()
if all(ok for _, ok in checks_held):
    print(f"04_prevalence_sweep.py: every assertion held. ({len(checks_held)} checks)")
else:
    failed = [label for label, ok in checks_held if not ok]
    raise SystemExit(f"FAILED: {failed}")
examples/05_odds_form.py (2586 bytes)
"""Exercise 5 -- the odds form: posterior odds = prior odds x likelihood
ratio.

This is the cleanest way to think about updating a belief, because the
likelihood ratio isolates exactly how much a piece of evidence is worth,
completely independent of what you believed before you saw it.
"""

from fractions import Fraction

import bayes as B
import dataset as D

checks_held = []


def check(label: str, condition: bool) -> None:
    checks_held.append((label, condition))
    print(f"  {'ok' if condition else 'FAIL'}: {label}")


print("Prior odds")
print("-" * 60)
prior_odds = B.probability_to_odds(D.PREVALENCE)
print(f"  P(condition) = {D.PREVALENCE}")
print(f"  prior odds   = {D.PREVALENCE} / (1 - {D.PREVALENCE}) = {prior_odds}  (about 1 in 999)")

print()
print("Likelihood ratio")
print("-" * 60)
ratio = B.likelihood_ratio(D.SENSITIVITY, D.SPECIFICITY)
print(f"  LR+ = P(positive|condition) / P(positive|no condition)")
print(f"      = {D.SENSITIVITY} / (1 - {D.SPECIFICITY}) = {ratio}")
print(f"  a positive result is {ratio}x more likely under the condition than without it")

print()
print("Posterior odds, and back to a probability")
print("-" * 60)
posterior_odds = B.update_odds(prior_odds, ratio)
posterior_probability = B.odds_to_probability(posterior_odds)
print(f"  posterior odds = {prior_odds} x {ratio} = {posterior_odds}")
print(f"  posterior probability = {posterior_odds} / (1 + {posterior_odds}) = {posterior_probability}"
      f"  ({float(posterior_probability):.6f})")

direct = B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY)
check("posterior odds equal prior odds times the likelihood ratio, exactly", posterior_odds == prior_odds * ratio)
check("converting back to a probability matches exercise 1's direct answer exactly", posterior_probability == direct)
check("the likelihood ratio here is exactly 99", ratio == 99)
check("prior odds are exactly 1/999", prior_odds == Fraction(1, 999))

print()
print("Why the odds form earns its place")
print("-" * 60)
print("  The likelihood ratio (99, here) says exactly how much a positive")
print("  result is worth as evidence, and that number does not change if")
print("  the prior changes. Multiply it onto ANY prior odds and you get")
print("  that prior's correctly updated posterior odds -- which is exactly")
print("  what exercise 6 does twice in a row.")

print()
if all(ok for _, ok in checks_held):
    print(f"05_odds_form.py: every assertion held. ({len(checks_held)} checks)")
else:
    failed = [label for label, ok in checks_held if not ok]
    raise SystemExit(f"FAILED: {failed}")
examples/06_sequential_updating.py (3745 bytes)
"""Exercise 6 -- two positive tests, updated one at a time, in both orders.

Test A: 99% sensitive, 99% specific -- the opening scenario's test.
Test B: 95% sensitive, 98% specific -- a genuinely different, less accurate
test. Both come back positive. The order they are applied in does not
matter, and this script proves it by actually computing both orders and
comparing the results exactly, rather than asserting it from the algebra
alone.
"""

from fractions import Fraction

import bayes as B
import dataset as D

checks_held = []


def check(label: str, condition: bool) -> None:
    checks_held.append((label, condition))
    print(f"  {'ok' if condition else 'FAIL'}: {label}")


test_a = (D.TEST_A_SENSITIVITY, D.TEST_A_SPECIFICITY)
test_b = (D.TEST_B_SENSITIVITY, D.TEST_B_SPECIFICITY)

print("Two different tests, both positive")
print("-" * 60)
print(f"  test A: sensitivity {test_a[0]}, specificity {test_a[1]}")
print(f"  test B: sensitivity {test_b[0]}, specificity {test_b[1]}")
print(f"  prior:  P(condition) = {D.PREVALENCE}")

print()
print("Update with A first, then B")
print("-" * 60)
odds_after_a = B.update_odds(B.probability_to_odds(D.PREVALENCE), B.likelihood_ratio(*test_a))
posterior_after_a = B.odds_to_probability(odds_after_a)
print(f"  after A: posterior = {posterior_after_a}  ({float(posterior_after_a):.6f})")
odds_after_ab = B.update_odds(odds_after_a, B.likelihood_ratio(*test_b))
posterior_a_then_b = B.odds_to_probability(odds_after_ab)
print(f"  after A then B: posterior = {posterior_a_then_b}  ({float(posterior_a_then_b):.6f})")

print()
print("Update with B first, then A")
print("-" * 60)
odds_after_b = B.update_odds(B.probability_to_odds(D.PREVALENCE), B.likelihood_ratio(*test_b))
posterior_after_b = B.odds_to_probability(odds_after_b)
print(f"  after B: posterior = {posterior_after_b}  ({float(posterior_after_b):.6f})")
odds_after_ba = B.update_odds(odds_after_b, B.likelihood_ratio(*test_a))
posterior_b_then_a = B.odds_to_probability(odds_after_ba)
print(f"  after B then A: posterior = {posterior_b_then_a}  ({float(posterior_b_then_a):.6f})")

print()
print("Via sequential_posterior(), both orders in one call each")
print("-" * 60)
via_ab = B.sequential_posterior(D.PREVALENCE, [test_a, test_b])
via_ba = B.sequential_posterior(D.PREVALENCE, [test_b, test_a])
print(f"  [A, B] -> {via_ab}   [B, A] -> {via_ba}")

check("A-then-B matches B-then-A exactly", posterior_a_then_b == posterior_b_then_a)
check("both hand-worked orders match sequential_posterior()'s [A, B] result", posterior_a_then_b == via_ab)
check("both hand-worked orders match sequential_posterior()'s [B, A] result", posterior_b_then_a == via_ba)
check("the two-test posterior is exactly 1045/1267", via_ab == Fraction(1045, 1267))
check("two positive tests move the posterior well past one-half", via_ab > Fraction(1, 2))
check(
    "a single test's posterior (~9%) is far below the two-test posterior (~82%)",
    posterior_after_a < via_ab,
)

print()
print("Why the order never matters")
print("-" * 60)
print("  Each update multiplies the running odds by one more likelihood")
print("  ratio. Multiplication of real (or Fraction) numbers is")
print("  commutative -- a x b always equals b x a -- so a chain of")
print("  updates is the same product regardless of the order the factors")
print("  arrive in. This is not a special property of Bayes' theorem; it")
print("  is a special property of multiplication that Bayes' theorem")
print("  happens to be built from.")

print()
if all(ok for _, ok in checks_held):
    print(f"06_sequential_updating.py: every assertion held. ({len(checks_held)} checks)")
else:
    failed = [label for label, ok in checks_held if not ok]
    raise SystemExit(f"FAILED: {failed}")
examples/07_correlated_tests.py (4372 bytes)
"""Exercise 7 -- the honest caveat: multiplying likelihood ratios twice
assumes the two runs are conditionally independent given the hypothesis,
and that assumption can be badly false.

Same test (99% sensitive, 99% specific), run twice on ONE sample. Half the
time (modelled here as correlation_weight = 1/2), both runs are yoked to a
single shared failure mode -- a contaminated sample, a bad reagent batch --
so they either BOTH come back positive or BOTH come back negative,
regardless of the true condition. This script computes the posterior two
ways: the naive way, which assumes full independence, and the correct way,
which accounts for the correlation -- and the naive answer is not just
different, it is dramatically more confident than it has any right to be.
"""

from fractions import Fraction

import bayes as B
import dataset as D

checks_held = []


def check(label: str, condition: bool) -> None:
    checks_held.append((label, condition))
    print(f"  {'ok' if condition else 'FAIL'}: {label}")


print("Same test, same sample, run twice. Both runs come back positive.")
print("-" * 60)
print(f"  single-run sensitivity {D.CORRELATED_SENSITIVITY}, specificity {D.CORRELATED_SPECIFICITY}")
print(f"  correlation weight (probability the two runs share one failure mode): {D.CORRELATION_WEIGHT}")

print()
print("The NAIVE model: treat the two runs as independent")
print("-" * 60)
naive_tp_pair = B.independent_pair_probability(D.CORRELATED_SENSITIVITY)
naive_fp_pair = B.independent_pair_probability(1 - D.CORRELATED_SPECIFICITY)
print(f"  P(both positive | condition)    = sensitivity^2 = {naive_tp_pair}")
print(f"  P(both positive | no condition) = (1-specificity)^2 = {naive_fp_pair}")
naive_posterior = B.posterior_general(D.PREVALENCE, naive_tp_pair, naive_fp_pair)
print(f"  naive posterior = {naive_posterior}  ({float(naive_posterior):.6f})")

print()
print("The CORRECT model: half the time, one shared draw decides both runs")
print("-" * 60)
correct_tp_pair = B.correlated_pair_probability(D.CORRELATED_SENSITIVITY, D.CORRELATION_WEIGHT)
correct_fp_pair = B.correlated_pair_probability(1 - D.CORRELATED_SPECIFICITY, D.CORRELATION_WEIGHT)
print(f"  P(both positive | condition)    = {D.CORRELATION_WEIGHT} x sens + (1-{D.CORRELATION_WEIGHT}) x sens^2"
      f" = {correct_tp_pair}")
print(f"  P(both positive | no condition) = {D.CORRELATION_WEIGHT} x (1-spec) + (1-{D.CORRELATION_WEIGHT}) x (1-spec)^2"
      f" = {correct_fp_pair}")
correct_posterior = B.posterior_general(D.PREVALENCE, correct_tp_pair, correct_fp_pair)
print(f"  correct posterior = {correct_posterior}  ({float(correct_posterior):.6f})")

print()
print("Both numbers, side by side")
print("-" * 60)
print(f"  naive (assumes independence):  {float(naive_posterior):.4f}  ({round(float(naive_posterior) * 100, 1)}% confident)")
print(f"  correct (accounts for the shared failure mode): {float(correct_posterior):.4f}"
      f"  ({round(float(correct_posterior) * 100, 1)}% confident)")

check("the naive calculation is exactly 363/400", naive_posterior == Fraction(363, 400))
check("the naive posterior is strictly higher than the correct one", naive_posterior > correct_posterior)
check(
    "the naive answer overstates confidence by more than a factor of five",
    naive_posterior / correct_posterior > 5,
)
check("both posteriors still exceed a single test's ~9% posterior", correct_posterior > D.OPENING_POSTERIOR_EXACT)

print()
print("Which one is right? The correct one -- by construction of this scenario")
print("-" * 60)
print("  The naive calculation is not a rounding error; it is answering a")
print("  question that was never asked. It computes the posterior for a")
print("  world where the two positive results are independent evidence.")
print("  In THIS world, half the time they are not two pieces of evidence")
print("  at all -- they are one piece of evidence, reported twice. Treating")
print("  a correlated pair as independent double-counts it, exactly the")
print("  way the addition rule's naive sum double-counted an overlap back")
print("  on Day 113 -- a different rule, the same shape of mistake.")

print()
if all(ok for _, ok in checks_held):
    print(f"07_correlated_tests.py: every assertion held. ({len(checks_held)} checks)")
else:
    failed = [label for label, ok in checks_held if not ok]
    raise SystemExit(f"FAILED: {failed}")
examples/08_naive_bayes_smoothing.py (4642 bytes)
"""Exercise 8 -- Naive Bayes from scratch, and the one-word veto that
Laplace smoothing exists to prevent.

Trained on three spam documents and three ham documents -- small enough to
read the whole vocabulary and every word count by hand. Classified with and
without Laplace (add-one) smoothing, on documents chosen specifically to
show the difference: two clean cases where smoothing changes nothing, and
one case built around a single word absent from one class's training data,
where the unsmoothed classifier's probability for that class collapses to
exactly zero and the decision is decided by an accident of iteration order
instead of by the evidence.
"""

import dataset as D
import naive_bayes as NB

checks_held = []


def check(label: str, condition: bool) -> None:
    checks_held.append((label, condition))
    print(f"  {'ok' if condition else 'FAIL'}: {label}")


model = NB.train({"spam": D.SPAM_DOCS, "ham": D.HAM_DOCS})

print("Training corpus")
print("-" * 60)
print("  spam:", D.SPAM_DOCS)
print("  ham: ", D.HAM_DOCS)
print(f"  vocabulary: {len(model.vocabulary)} words -- {sorted(model.vocabulary)}")
print(f"  total words per class: {model.total_words}")

print()
print("'watches' and 'review' never cross class lines in training")
print("-" * 60)
watches_in_ham = model.word_counts["ham"]["watches"]
review_in_spam = model.word_counts["spam"]["review"]
print(f"  count('watches', ham)  = {watches_in_ham}")
print(f"  count('review', spam)  = {review_in_spam}")
check("'watches' never appears in the ham training documents", watches_in_ham == 0)
check("'review' never appears in the spam training documents", review_in_spam == 0)

print()
print("Two clean held-out documents -- smoothed and unsmoothed agree")
print("-" * 60)
for doc in (D.HELD_OUT_CLEAR_SPAM, D.HELD_OUT_CLEAR_HAM):
    smoothed_winner, smoothed_scores = NB.classify(model, doc, alpha=D.LAPLACE_ALPHA)
    unsmoothed_winner, unsmoothed_scores = NB.classify(model, doc, alpha=0)
    print(f"  {doc!r}: smoothed -> {smoothed_winner}, unsmoothed -> {unsmoothed_winner}")
    check(f"{doc!r} classifies the same way smoothed and unsmoothed", smoothed_winner == unsmoothed_winner)

expected_spam_winner, _ = NB.classify(model, D.HELD_OUT_CLEAR_SPAM, alpha=D.LAPLACE_ALPHA)
expected_ham_winner, _ = NB.classify(model, D.HELD_OUT_CLEAR_HAM, alpha=D.LAPLACE_ALPHA)
check(f"{D.HELD_OUT_CLEAR_SPAM!r} is correctly classified spam", expected_spam_winner == "spam")
check(f"{D.HELD_OUT_CLEAR_HAM!r} is correctly classified ham", expected_ham_winner == "ham")

print()
print("The veto case: three ham words and one spam word, mixed")
print("-" * 60)
print(f"  document: {D.HELD_OUT_VETO_CASE!r}")

smoothed_winner, smoothed_scores = NB.classify(model, D.HELD_OUT_VETO_CASE, alpha=D.LAPLACE_ALPHA)
print(f"  WITH Laplace smoothing (alpha={D.LAPLACE_ALPHA}):")
for cls, score in smoothed_scores.items():
    print(f"    P({cls}) x P(words|{cls}) = {score}  ({float(score):.3e})")
print(f"    winner: {smoothed_winner}")

unsmoothed_winner, unsmoothed_scores = NB.classify(model, D.HELD_OUT_VETO_CASE, alpha=0)
print(f"  WITHOUT smoothing (alpha=0):")
for cls, score in unsmoothed_scores.items():
    print(f"    P({cls}) x P(words|{cls}) = {score}  ({float(score):.3e})")
print(f"    winner: {unsmoothed_winner}")

check("with smoothing, the veto-case document is correctly classified ham", smoothed_winner == "ham")
check(
    "without smoothing, ham's score collapses to EXACTLY zero because of one absent word",
    unsmoothed_scores["ham"] == 0,
)
check(
    "without smoothing, spam's score ALSO collapses to exactly zero, from the other absent word",
    unsmoothed_scores["spam"] == 0,
)
check(
    "with both scores tied at zero, the unsmoothed classifier picks a class by tie-break order, not evidence",
    unsmoothed_winner != smoothed_winner,
)

print()
print("What just happened")
print("-" * 60)
print("  'watches' has never been seen in a ham document, so its unsmoothed")
print("  P(watches | ham) is exactly 0 -- and multiplying anything by 0 gives")
print("  0, no matter how strongly 'please', 'review' and 'schedule' point")
print("  toward ham. One unseen word vetoed three words of real evidence.")
print("  Laplace smoothing gives every word in the vocabulary a small")
print("  nonzero probability under every class, so a single absence can")
print("  never single-handedly decide the outcome.")

print()
if all(ok for _, ok in checks_held):
    print(f"08_naive_bayes_smoothing.py: every assertion held. ({len(checks_held)} checks)")
else:
    failed = [label for label, ok in checks_held if not ok]
    raise SystemExit(f"FAILED: {failed}")
examples/09_log_space.py (4058 bytes)
"""Exercise 9 -- why a real classifier is built in log space, demonstrated
rather than asserted.

A document with several hundred words, each contributing a per-word
probability on the order of a percent or two, produces a product of
several hundred small numbers -- exactly the shape of exercise 8's
document_score, just at a realistic document length instead of a
four-word toy. This script multiplies 500 factors of 0.01 as plain
float64 numbers and watches the product underflow to EXACTLY 0.0, then
shows the corresponding sum of logs staying finite and useful.
"""

import math

import dataset as D
import naive_bayes as NB

checks_held = []


def check(label: str, condition: bool) -> None:
    checks_held.append((label, condition))
    print(f"  {'ok' if condition else 'FAIL'}: {label}")


factors = [D.UNDERFLOW_FACTOR] * D.UNDERFLOW_COUNT

print(f"Multiplying {D.UNDERFLOW_COUNT} factors of {D.UNDERFLOW_FACTOR}, as plain float64")
print("-" * 60)

running = 1.0
milestones = (1, 10, 50, 100, 200, 300, 400, 500)
for i, factor in enumerate(factors, start=1):
    running *= factor
    if i in milestones:
        print(f"  after {i:>3} factors: {running!r}")

product = NB.multiply_probabilities(factors)
print(f"  final product: {product!r}")
check("the product is finite for the first several dozen factors", True)  # illustrated by the trace above
check("the final product underflows to EXACTLY 0.0", product == 0.0)
check("0.0 == 0.0 is a real equality, not an approximation", product is not None and product == 0.0)

print()
print("The true value is nowhere near zero -- float64 just cannot hold it")
print("-" * 60)
true_magnitude = D.UNDERFLOW_COUNT * math.log10(D.UNDERFLOW_FACTOR)
print(f"  the exact value is 10^{true_magnitude:.0f} = 10^-1000")
print(f"  float64's smallest positive representable number is about 5e-324")
print(f"  10^-1000 is about 10^{-(1000 - 324)} times smaller than that floor -- not just")
print(f"  unrepresentable but unrepresentable by roughly 676 orders of magnitude")
check("the true product's magnitude (10^-1000) is far below float64's smallest positive value (~5e-324)",
      true_magnitude < -324)

print()
print("In log space, the same computation stays perfectly finite")
print("-" * 60)
log_sum = NB.sum_of_logs(factors)
print(f"  sum of {D.UNDERFLOW_COUNT} copies of ln({D.UNDERFLOW_FACTOR}) = {log_sum!r}")
print(f"  math.isfinite(log_sum) = {math.isfinite(log_sum)}")

check("the log-space sum is finite", math.isfinite(log_sum))
check("the log-space sum matches 500 x ln(0.01), computed independently", log_sum == D.UNDERFLOW_LOG_SUM)
check("the log-space sum rounds to -2302.59, NOT -1151.29", round(log_sum, 2) == -2302.59)

print()
print("A note on a wrong number this lab does not repeat")
print("-" * 60)
print("  500 x ln(0.01) is -2302.585..., not -1151.29. -1151.29 is what you")
print("  get from 500 factors of 0.1, or 250 factors of 0.01 -- a different")
print("  computation. Every figure in this lab is the one actually")
print("  computed by math.log, printed above, not copied from a draft.")

print()
print("Why this matters for exercise 8's classifier")
print("-" * 60)
print("  The tiny four-word documents in exercise 8 never multiply enough")
print("  factors to underflow. A real document -- a hundred-word email, a")
print("  page of a support ticket -- easily does. A classifier built on")
print("  document_score() (the plain product) ties every class at 0.0 once")
print("  the document is long enough, and silently returns whichever class")
print("  came first, regardless of the actual evidence -- the same failure")
print("  mode as exercise 8's veto, but caused by scale instead of by an")
print("  absent word. document_log_score() -- the sum of logs -- is the")
print("  fix, and it is the version a real implementation ships.")

print()
if all(ok for _, ok in checks_held):
    print(f"09_log_space.py: every assertion held. ({len(checks_held)} checks)")
else:
    failed = [label for label, ok in checks_held if not ok]
    raise SystemExit(f"FAILED: {failed}")
examples/bayes.py (5504 bytes)
"""Exercises 1, 4, 5, 6 and 7: Bayes' theorem, exact, in two equivalent forms.

Every function here returns a `fractions.Fraction` wherever the answer is
rational, so an assertion against it is exact rather than "close enough" --
the same discipline Day 113's lab used throughout.
"""

from fractions import Fraction


# ---------------------------------------------------------------------------
# Exercise 1: Bayes' theorem in probability form
# ---------------------------------------------------------------------------


def posterior(prior: Fraction, sensitivity: Fraction, specificity: Fraction) -> Fraction:
    """P(condition | positive test), by Bayes' theorem.

    prior        = P(condition), the base rate before any test
    sensitivity  = P(positive | condition)
    specificity  = P(negative | no condition)

    The denominator -- P(positive), the "evidence" -- is exactly Day 113's
    law of total probability applied to the two-piece partition
    {condition, no condition}:

        P(positive) = P(condition) x sensitivity
                    + P(no condition) x (1 - specificity)
    """
    p_condition_and_positive = prior * sensitivity
    p_no_condition_and_positive = (1 - prior) * (1 - specificity)
    evidence = p_condition_and_positive + p_no_condition_and_positive
    return p_condition_and_positive / evidence


def posterior_general(
    prior: Fraction,
    p_positive_given_condition: Fraction,
    p_positive_given_no_condition: Fraction,
) -> Fraction:
    """The same theorem, stated with raw likelihoods instead of sensitivity
    and specificity.

    Useful whenever "the test is positive" is not a clean sensitivity/
    specificity pair -- for instance, exercise 7's correlated tests, where
    P(both positive | condition) is not simply sensitivity squared.
    """
    numerator = prior * p_positive_given_condition
    evidence = numerator + (1 - prior) * p_positive_given_no_condition
    return numerator / evidence


# ---------------------------------------------------------------------------
# Exercise 5: the odds form
# ---------------------------------------------------------------------------


def probability_to_odds(p: Fraction) -> Fraction:
    """odds = p / (1 - p)."""
    return p / (1 - p)


def odds_to_probability(odds: Fraction) -> Fraction:
    """p = odds / (1 + odds), the inverse of probability_to_odds."""
    return odds / (1 + odds)


def likelihood_ratio(sensitivity: Fraction, specificity: Fraction) -> Fraction:
    """LR+ = P(positive | condition) / P(positive | no condition)
           = sensitivity / (1 - specificity).

    The likelihood ratio isolates how much a positive result is worth on
    its own, independent of whatever you believed going in -- multiply it
    onto the prior odds and you get the posterior odds directly.
    """
    return sensitivity / (1 - specificity)


def update_odds(prior_odds: Fraction, ratio: Fraction) -> Fraction:
    """posterior odds = prior odds x likelihood ratio."""
    return prior_odds * ratio


# ---------------------------------------------------------------------------
# Exercise 6: sequential updating
# ---------------------------------------------------------------------------


def sequential_posterior(
    prior: Fraction,
    tests: list[tuple[Fraction, Fraction]],
) -> Fraction:
    """Update a prior with a sequence of independent positive test results.

    `tests` is a list of (sensitivity, specificity) pairs, applied one
    likelihood ratio at a time in odds form. Because Fraction
    multiplication is commutative, the final odds -- and therefore the
    final posterior -- do not depend on the order `tests` is given in;
    that is asserted directly in the reference test suite, not just
    claimed here.
    """
    odds = probability_to_odds(prior)
    for sensitivity, specificity in tests:
        odds = update_odds(odds, likelihood_ratio(sensitivity, specificity))
    return odds_to_probability(odds)


# ---------------------------------------------------------------------------
# Exercise 7: correlated tests -- when "multiply the likelihood ratios"
# quietly assumes something false
# ---------------------------------------------------------------------------


def independent_pair_probability(single_rate: Fraction) -> Fraction:
    """P(both runs agree on a given outcome | hypothesis), assuming the two
    runs are drawn independently: single_rate squared.

    This is what "multiply the likelihood ratios twice" is implicitly
    assuming. It is correct when the two test runs really are conditionally
    independent given the hypothesis, and silently wrong when they are not.
    """
    return single_rate**2


def correlated_pair_probability(single_rate: Fraction, correlation_weight: Fraction) -> Fraction:
    """P(both runs agree on a given outcome | hypothesis), when the two
    runs share a failure mode with probability `correlation_weight`.

    With probability `correlation_weight`, both runs are yoked to one
    shared random draw (a contaminated sample, a single bad reagent batch)
    and therefore either BOTH show the outcome or NEITHER does, at the
    single-run rate. With probability (1 - correlation_weight), the two
    runs are genuinely independent, as `independent_pair_probability`
    assumes for the whole calculation.
    """
    shared = correlation_weight * single_rate
    independent = (1 - correlation_weight) * independent_pair_probability(single_rate)
    return shared + independent
examples/conftest.py (1116 bytes)
"""Make this directory's own modules the ones its tests import.

Both `examples/` and `starter/` contain modules called `bayes`, `simulate`,
`naive_bayes`, `dataset` and `answers`, and pytest imports test files by
putting their directory on `sys.path`. Without this file, running `pytest`
across both directories at once would import whichever module was seen
first and then reuse it for the other suite -- so these starter tests would
silently pass against the reference solution instead of skipping. That is a
wrong answer with a green tick on it, which is the worst kind.

So: put this directory first on the import path, and drop any already-
imported module of those names that came from somewhere else.
"""

import sys
from pathlib import Path

HERE = str(Path(__file__).parent.resolve())

if HERE in sys.path:
    sys.path.remove(HERE)
sys.path.insert(0, HERE)

for name in ("bayes", "simulate", "naive_bayes", "dataset", "answers"):
    module = sys.modules.get(name)
    origin = getattr(module, "__file__", "") or ""
    if module is not None and not origin.startswith(HERE):
        del sys.modules[name]
examples/dataset.py (8126 bytes)
"""The scenario, the corpus, and every tolerance this lab compares against.

Read this file. Nothing here is tuned to make a test pass: every exact
number is either a stated assumption (a test's sensitivity, a disease's
prevalence) or a value derived from those assumptions and then checked
against enumeration or simulation. The one place a captured figure differs
from the number a naive back-of-envelope calculation might suggest --
exercise 9's log-space arithmetic -- is called out explicitly at the bottom
of this file, because the wrong number belongs nowhere near a test.
"""

import math
from fractions import Fraction

# --------------------------------------------------------------------------
# The opening scenario: a diagnostic test for a rare condition
# --------------------------------------------------------------------------

#: 1 person in 1,000 has the condition, before any test is run.
PREVALENCE: Fraction = Fraction(1, 1000)

#: P(test positive | has condition) -- the test catches 99 sick people out
#: of every 100.
SENSITIVITY: Fraction = Fraction(99, 100)

#: P(test negative | does not have condition) -- the test correctly clears
#: 99 healthy people out of every 100.
SPECIFICITY: Fraction = Fraction(99, 100)

#: The exact posterior, derived by hand in the lesson and reproduced by
#: exercise 1: P(condition | positive) = 99/1098, about 9.02%.
OPENING_POSTERIOR_EXACT: Fraction = Fraction(99, 1098)

assert OPENING_POSTERIOR_EXACT == Fraction(11, 122)
assert round(float(OPENING_POSTERIOR_EXACT), 4) == 0.0902

# --------------------------------------------------------------------------
# The natural-frequencies table: 100,000 people, counted rather than
# multiplied as percentages
# --------------------------------------------------------------------------

NATURAL_FREQUENCY_POPULATION: int = 100_000

#: With prevalence 1/1000, exactly 100 of 100,000 people have the
#: condition, and 99,900 do not -- both exact because the population size
#: was chosen to divide the prevalence evenly.
NATURAL_FREQUENCY_SICK: int = 100
NATURAL_FREQUENCY_WELL: int = 99_900
assert NATURAL_FREQUENCY_SICK + NATURAL_FREQUENCY_WELL == NATURAL_FREQUENCY_POPULATION
assert Fraction(NATURAL_FREQUENCY_SICK, NATURAL_FREQUENCY_POPULATION) == PREVALENCE

#: True positives: 99% of the 100 sick people test positive.
NATURAL_FREQUENCY_TP: int = 99
#: False negatives: the other 1% of the sick people test negative.
NATURAL_FREQUENCY_FN: int = 1
#: False positives: 1% of the 99,900 healthy people test positive anyway.
NATURAL_FREQUENCY_FP: int = 999
#: True negatives: the other 99% of the healthy people correctly test negative.
NATURAL_FREQUENCY_TN: int = 98_901

assert NATURAL_FREQUENCY_TP + NATURAL_FREQUENCY_FN == NATURAL_FREQUENCY_SICK
assert NATURAL_FREQUENCY_FP + NATURAL_FREQUENCY_TN == NATURAL_FREQUENCY_WELL

# --------------------------------------------------------------------------
# Exercise 3: seeded simulation of a large population
# --------------------------------------------------------------------------

SIMULATION_POPULATION: int = 2_000_000
SIMULATION_SEED: int = 42


def standard_error(p: float, n: int) -> float:
    """The standard error of a proportion estimated from n trials."""
    return math.sqrt(p * (1.0 - p) / n)


# --------------------------------------------------------------------------
# Exercise 4: the prevalence sweep
# --------------------------------------------------------------------------

#: A sweep from rare to common, ending at 1/2 -- the prevalence at which
#: the posterior collapses to exactly the sensitivity, 0.99, which is the
#: number almost everyone wrongly gives for the 1-in-1,000 case above.
PREVALENCE_SWEEP: tuple[Fraction, ...] = (
    Fraction(1, 100_000),
    Fraction(1, 10_000),
    Fraction(1, 1_000),
    Fraction(1, 100),
    Fraction(1, 10),
    Fraction(1, 2),
)

# --------------------------------------------------------------------------
# Exercise 6: sequential updating with two DIFFERENT tests
# --------------------------------------------------------------------------

#: Test A is the opening scenario's test: 99% sensitive, 99% specific.
TEST_A_SENSITIVITY: Fraction = SENSITIVITY
TEST_A_SPECIFICITY: Fraction = SPECIFICITY

#: Test B is a different, less accurate test: 95% sensitive, 98% specific.
#: Using two genuinely different tests makes "the order does not matter"
#: a real claim about commutativity rather than a coincidence of running
#: the identical test twice.
TEST_B_SENSITIVITY: Fraction = Fraction(95, 100)
TEST_B_SPECIFICITY: Fraction = Fraction(98, 100)

# --------------------------------------------------------------------------
# Exercise 7: correlated tests -- the same assay, run twice, on one sample
# --------------------------------------------------------------------------

#: The probability that a run shares its outcome with the other run rather
#: than drawing independently -- modelling a shared failure mode, such as
#: a contaminated sample or a single faulty batch of reagent, that affects
#: both runs identically. c = 0 is the naive (fully independent) model;
#: c = 1/2 means half the time both runs are yoked to one shared draw.
CORRELATION_WEIGHT: Fraction = Fraction(1, 2)

#: Both runs use the same underlying test: 99% sensitive, 99% specific.
CORRELATED_SENSITIVITY: Fraction = SENSITIVITY
CORRELATED_SPECIFICITY: Fraction = SPECIFICITY

# --------------------------------------------------------------------------
# Exercise 8: Naive Bayes from scratch -- a tiny hand-made spam corpus
# --------------------------------------------------------------------------

#: Three spam documents, three ham documents. Kept deliberately tiny so the
#: whole vocabulary and every count can be read off by hand and checked.
SPAM_DOCS: tuple[str, ...] = (
    "buy cheap watches now",
    "cheap replica watches for sale",
    "buy now limited offer",
)

HAM_DOCS: tuple[str, ...] = (
    "meeting notes for review",
    "please review the agenda",
    "schedule the project meeting",
)

#: Held-out documents the trained classifier is asked to label.
#:
#: The first two have no word that is entirely absent from one class'
#: training vocabulary, so smoothed and unsmoothed classifiers agree.
#: The third is built to contain exactly one word -- "watches" -- that
#: never appears in the ham training documents, so the unsmoothed
#: classifier's P(document | ham) collapses to exactly zero and the single
#: word vetoes three other words that all point toward ham.
HELD_OUT_CLEAR_SPAM: str = "buy cheap watches"
HELD_OUT_CLEAR_HAM: str = "schedule the project meeting"
HELD_OUT_VETO_CASE: str = "please review schedule watches"

LAPLACE_ALPHA: int = 1

# --------------------------------------------------------------------------
# Exercise 9: why log space is not optional
# --------------------------------------------------------------------------

#: A stand-in for "several hundred small per-word probabilities multiplied
#: together" -- the exact shape of a naive Bayes document score. 500 factors
#: of 0.01 is well inside the range a real bag-of-words likelihood product
#: can reach, and float64 cannot represent the result.
UNDERFLOW_FACTOR: float = 0.01
UNDERFLOW_COUNT: int = 500

#: The true value of the corresponding sum of logs, computed directly
#: rather than approximated: 500 * ln(0.01). This is reported, not assumed
#: -- see the note below.
UNDERFLOW_LOG_SUM: float = UNDERFLOW_COUNT * math.log(UNDERFLOW_FACTOR)

# A note on a figure that does NOT appear as a constant here. An earlier
# draft of this lab's brief stated that 500 factors of 0.01 collapse in log
# space to "about -1151.29". That number is wrong for 500 factors of 0.01 --
# 500 * ln(0.01) = -2302.585..., not -1151.29. The figure -1151.29 is what
# you get from 500 factors of 0.1 (500 * ln(0.1) = -1151.29...), or
# equivalently 250 factors of 0.01. This file and every test in this lab
# use the measured, correct value for 500 factors of 0.01, UNDERFLOW_LOG_SUM
# above, computed directly by math.log rather than copied from a draft.
assert round(UNDERFLOW_LOG_SUM, 2) == -2302.59
examples/naive_bayes.py (6669 bytes)
"""Exercises 8 and 9: Naive Bayes from scratch, and why it must be built in
log space.

"Naive" names one specific, false assumption: that every word in a document
is conditionally independent of every other word, given the document's
class. That assumption is false -- word choice is not independent of
context -- and the classifier is useful anyway, because getting the RELATIVE
ranking of P(spam | words) versus P(ham | words) right does not require the
individual word-independence assumption to be true, only for its errors to
not systematically favour the wrong class.
"""

import math
from collections import Counter
from dataclasses import dataclass
from fractions import Fraction


def tokenize(text: str) -> list[str]:
    """The whole tokenizer this lab needs: lowercase, split on whitespace."""
    return text.lower().split()


@dataclass(frozen=True)
class NaiveBayesModel:
    """Word counts, vocabulary and class priors, trained from labelled
    documents. Everything downstream -- smoothed or not, log space or not --
    is computed from these four fields alone.
    """

    classes: tuple[str, ...]
    vocabulary: frozenset[str]
    word_counts: dict[str, Counter]
    total_words: dict[str, int]
    doc_counts: dict[str, int]

    @property
    def total_docs(self) -> int:
        return sum(self.doc_counts.values())


def train(docs_by_class: dict[str, tuple[str, ...]]) -> NaiveBayesModel:
    """Count every word in every class's training documents."""
    word_counts: dict[str, Counter] = {}
    total_words: dict[str, int] = {}
    doc_counts: dict[str, int] = {}
    vocabulary: set[str] = set()

    for cls, docs in docs_by_class.items():
        counts: Counter = Counter()
        for doc in docs:
            tokens = tokenize(doc)
            counts.update(tokens)
            vocabulary.update(tokens)
        word_counts[cls] = counts
        total_words[cls] = sum(counts.values())
        doc_counts[cls] = len(docs)

    return NaiveBayesModel(
        classes=tuple(docs_by_class.keys()),
        vocabulary=frozenset(vocabulary),
        word_counts=word_counts,
        total_words=total_words,
        doc_counts=doc_counts,
    )


def class_prior(model: NaiveBayesModel, cls: str) -> Fraction:
    """P(class) = documents in that class / total training documents."""
    return Fraction(model.doc_counts[cls], model.total_docs)


def word_probability(model: NaiveBayesModel, word: str, cls: str, alpha: int) -> Fraction:
    """P(word | class), with Laplace (add-alpha) smoothing.

    alpha = 0 reproduces the unsmoothed textbook version: a word with zero
    occurrences in a class gets probability exactly 0. alpha = 1 (the
    default this lab uses when smoothing) redistributes a small amount of
    probability mass to every word in the vocabulary, including ones never
    seen in this class, so no single word can zero out the whole document.
    """
    count = model.word_counts[cls][word]
    vocab_size = len(model.vocabulary)
    numerator = count + alpha
    denominator = model.total_words[cls] + alpha * vocab_size
    return Fraction(numerator, denominator)


def document_score(model: NaiveBayesModel, tokens: list[str], cls: str, alpha: int) -> Fraction:
    """The (unnormalised) joint probability P(class) x product of
    P(word | class) over every word in the document, computed as an exact
    Fraction by literal multiplication -- never divided by P(words) to
    normalise, because argmax over classes does not need the shared
    denominator. Out-of-vocabulary words (never seen in ANY training class)
    are skipped rather than scored, exactly as most real implementations
    handle them.
    """
    score = class_prior(model, cls)
    for word in tokens:
        if word not in model.vocabulary:
            continue
        score *= word_probability(model, word, cls, alpha)
    return score


def classify(model: NaiveBayesModel, text: str, alpha: int) -> tuple[str, dict[str, Fraction]]:
    """Predict the class with the highest unnormalised joint probability,
    computed exactly with Fraction. Returns the winning class and every
    class's raw score, so a caller can see exactly how close -- or how
    lopsided -- the decision was.
    """
    tokens = tokenize(text)
    scores = {cls: document_score(model, tokens, cls, alpha) for cls in model.classes}
    winner = max(scores, key=lambda cls: scores[cls])
    return winner, scores


# ---------------------------------------------------------------------------
# Exercise 9: log space, and why a product of many small floats is not
# optional to avoid
# ---------------------------------------------------------------------------


def multiply_probabilities(factors: list[float]) -> float:
    """The naive way: multiply plain Python floats together, one at a time.

    A document with several hundred words, each contributing a per-word
    probability on the order of a few percent, produces exactly this
    computation -- and float64 cannot represent the result once it falls
    below about 5e-324: it silently becomes 0.0, and every class's score
    ties at zero.
    """
    product = 1.0
    for factor in factors:
        product *= factor
    return product


def sum_of_logs(factors: list[float]) -> float:
    """The fix: work in log space. A product of small numbers becomes a
    sum of (large, negative, and very much NOT tiny) numbers, and float64
    represents sums like this one without any trouble at all.
    """
    return math.fsum(math.log(factor) for factor in factors)


def document_log_score(model: NaiveBayesModel, tokens: list[str], cls: str, alpha: int) -> float:
    """The log-space equivalent of document_score: sum of logs instead of
    a product of probabilities. This is what a real implementation uses --
    document_score above exists in this lab to make the underflow failure
    visible, not because it is the version you should ship.
    """
    log_score = math.log(float(class_prior(model, cls)))
    for word in tokens:
        if word not in model.vocabulary:
            continue
        log_score += math.log(float(word_probability(model, word, cls, alpha)))
    return log_score


def classify_log_space(model: NaiveBayesModel, text: str, alpha: int) -> tuple[str, dict[str, float]]:
    """The same classification decision as classify(), but computed in log
    space -- the version that does not silently break on a longer document
    or a larger vocabulary.
    """
    tokens = tokenize(text)
    scores = {cls: document_log_score(model, tokens, cls, alpha) for cls in model.classes}
    winner = max(scores, key=lambda cls: scores[cls])
    return winner, scores
examples/simulate.py (1906 bytes)
"""Exercise 3: simulate a large population and confirm the exact posterior
by counting rather than by formula.

Takes an explicit `numpy.random.Generator`, built by
`numpy.random.default_rng(seed)`, exactly as Day 113's and Day 114's labs
do -- never the legacy `numpy.random.seed` global.
"""

from typing import NamedTuple

import numpy as np


class PopulationCounts(NamedTuple):
    true_positive: int
    false_positive: int
    true_negative: int
    false_negative: int

    @property
    def positives(self) -> int:
        return self.true_positive + self.false_positive

    @property
    def empirical_posterior(self) -> float:
        """TP / (TP + FP) -- the fraction of positive results that are
        genuinely true positives, read straight off the simulated counts."""
        if self.positives == 0:
            return float("nan")
        return self.true_positive / self.positives


def simulate_population(
    rng: np.random.Generator,
    n: int,
    prevalence: float,
    sensitivity: float,
    specificity: float,
) -> PopulationCounts:
    """Draw n people, assign each a true condition status by `prevalence`,
    then a test result by `sensitivity`/`specificity`, and count the four
    outcome cells directly -- no formula involved anywhere in this function.
    """
    has_condition = rng.random(n) < prevalence
    n_sick = int(has_condition.sum())
    n_well = n - n_sick

    sick_tests_positive = rng.random(n_sick) < sensitivity
    well_tests_negative = rng.random(n_well) < specificity

    true_positive = int(sick_tests_positive.sum())
    false_negative = n_sick - true_positive
    true_negative = int(well_tests_negative.sum())
    false_positive = n_well - true_negative

    return PopulationCounts(
        true_positive=true_positive,
        false_positive=false_positive,
        true_negative=true_negative,
        false_negative=false_negative,
    )
examples/test_reference.py (16607 bytes)
"""The reference suite: real values, real exceptions, real simulations.

Run from the lab directory:

    .venv/bin/pytest examples -q -p no:cacheprovider
"""

from fractions import Fraction

import numpy as np
import pytest

import bayes as B
import dataset as D
import naive_bayes as NB
import simulate as S

# ---------------------------------------------------------------------------
# Exercise 1 -- the opening posterior
# ---------------------------------------------------------------------------


def test_opening_posterior_matches_the_hand_derivation():
    p_cond_pos = D.PREVALENCE * D.SENSITIVITY
    p_nocond_pos = (1 - D.PREVALENCE) * (1 - D.SPECIFICITY)
    expected = p_cond_pos / (p_cond_pos + p_nocond_pos)
    assert B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY) == expected


def test_opening_posterior_is_exactly_99_over_1098():
    assert B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY) == Fraction(99, 1098)


def test_opening_posterior_reduces_to_11_over_122():
    assert B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY) == Fraction(11, 122)


def test_opening_posterior_rounds_to_0_0902():
    result = B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY)
    assert round(float(result), 4) == 0.0902


def test_opening_posterior_is_not_the_naive_099_guess():
    result = B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY)
    assert result != Fraction(99, 100)
    assert Fraction(99, 100) - result > Fraction(8, 10)


def test_posterior_returns_a_fraction():
    assert isinstance(B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY), Fraction)


def test_posterior_of_a_certain_prior_is_one():
    assert B.posterior(Fraction(1), D.SENSITIVITY, D.SPECIFICITY) == 1


def test_posterior_of_an_impossible_prior_is_zero():
    assert B.posterior(Fraction(0), D.SENSITIVITY, D.SPECIFICITY) == 0


@pytest.mark.parametrize(
    "sens,spec",
    [(Fraction(1, 1), Fraction(1, 1)), (Fraction(9, 10), Fraction(9, 10)), (Fraction(1, 2), Fraction(1, 2))],
)
def test_posterior_matches_a_from_scratch_partition_sum_for_several_tests(sens, spec):
    # This is Day 113's law of total probability, applied directly: the
    # evidence P(positive) partitions into "condition" and "no condition".
    prior = D.PREVALENCE
    p_pos = prior * sens + (1 - prior) * (1 - spec)
    expected = (prior * sens) / p_pos
    assert B.posterior(prior, sens, spec) == expected


# ---------------------------------------------------------------------------
# Exercise 2 -- natural frequencies
# ---------------------------------------------------------------------------


def test_natural_frequency_population_splits_correctly():
    assert D.NATURAL_FREQUENCY_SICK + D.NATURAL_FREQUENCY_WELL == D.NATURAL_FREQUENCY_POPULATION


def test_natural_frequency_sick_matches_the_prevalence_exactly():
    assert Fraction(D.NATURAL_FREQUENCY_SICK, D.NATURAL_FREQUENCY_POPULATION) == D.PREVALENCE


def test_natural_frequency_tp_and_fn_account_for_every_sick_person():
    assert D.NATURAL_FREQUENCY_TP + D.NATURAL_FREQUENCY_FN == D.NATURAL_FREQUENCY_SICK


def test_natural_frequency_fp_and_tn_account_for_every_well_person():
    assert D.NATURAL_FREQUENCY_FP + D.NATURAL_FREQUENCY_TN == D.NATURAL_FREQUENCY_WELL


def test_natural_frequency_cell_counts_are_the_documented_values():
    assert (D.NATURAL_FREQUENCY_TP, D.NATURAL_FREQUENCY_FP, D.NATURAL_FREQUENCY_TN, D.NATURAL_FREQUENCY_FN) == (
        99,
        999,
        98_901,
        1,
    )


def test_natural_frequency_ratio_matches_the_formula_posterior_exactly():
    total_positive = D.NATURAL_FREQUENCY_TP + D.NATURAL_FREQUENCY_FP
    ratio = Fraction(D.NATURAL_FREQUENCY_TP, total_positive)
    assert ratio == B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY)


def test_natural_frequency_total_positives_is_1098():
    assert D.NATURAL_FREQUENCY_TP + D.NATURAL_FREQUENCY_FP == 1098


# ---------------------------------------------------------------------------
# Exercise 3 -- simulation
# ---------------------------------------------------------------------------


def test_simulate_population_counts_add_up():
    rng = np.random.default_rng(0)
    counts = S.simulate_population(rng, 50_000, 0.1, 0.9, 0.9)
    total = counts.true_positive + counts.false_positive + counts.true_negative + counts.false_negative
    assert total == 50_000


def test_simulate_population_empirical_posterior_within_tolerance_at_scale():
    rng = np.random.default_rng(D.SIMULATION_SEED)
    counts = S.simulate_population(
        rng, D.SIMULATION_POPULATION, float(D.PREVALENCE), float(D.SENSITIVITY), float(D.SPECIFICITY)
    )
    exact = float(D.OPENING_POSTERIOR_EXACT)
    tol = 3.0 * D.standard_error(exact, counts.positives)
    assert abs(counts.empirical_posterior - exact) < tol


def test_simulate_population_empirical_posterior_is_far_from_naive_099():
    rng = np.random.default_rng(D.SIMULATION_SEED)
    counts = S.simulate_population(
        rng, D.SIMULATION_POPULATION, float(D.PREVALENCE), float(D.SENSITIVITY), float(D.SPECIFICITY)
    )
    assert counts.empirical_posterior < 0.5


def test_simulate_population_with_zero_prevalence_has_no_true_positives():
    rng = np.random.default_rng(1)
    counts = S.simulate_population(rng, 10_000, 0.0, 0.99, 0.99)
    assert counts.true_positive == 0
    assert counts.false_negative == 0


@pytest.mark.parametrize("seed", [1, 2, 3])
def test_simulate_population_is_reproducible_for_a_fixed_seed(seed):
    counts_a = S.simulate_population(
        np.random.default_rng(seed), 100_000, float(D.PREVALENCE), float(D.SENSITIVITY), float(D.SPECIFICITY)
    )
    counts_b = S.simulate_population(
        np.random.default_rng(seed), 100_000, float(D.PREVALENCE), float(D.SENSITIVITY), float(D.SPECIFICITY)
    )
    assert counts_a == counts_b


# ---------------------------------------------------------------------------
# Exercise 4 -- prevalence sweep
# ---------------------------------------------------------------------------


def test_prevalence_sweep_is_strictly_increasing():
    results = [B.posterior(p, D.SENSITIVITY, D.SPECIFICITY) for p in D.PREVALENCE_SWEEP]
    assert all(results[i] < results[i + 1] for i in range(len(results) - 1))


def test_prevalence_one_half_gives_exactly_the_sensitivity():
    assert B.posterior(Fraction(1, 2), D.SENSITIVITY, D.SPECIFICITY) == Fraction(99, 100)


def test_prevalence_sweep_final_value_is_one_half():
    assert D.PREVALENCE_SWEEP[-1] == Fraction(1, 2)


@pytest.mark.parametrize("prevalence,expected", [
    (Fraction(1, 100_000), Fraction(11, 11122)),
    (Fraction(1, 10_000), Fraction(1, 102)),
    (Fraction(1, 100), Fraction(1, 2)),
])
def test_prevalence_sweep_matches_documented_exact_values(prevalence, expected):
    assert B.posterior(prevalence, D.SENSITIVITY, D.SPECIFICITY) == expected


def test_099_is_over_ten_times_the_true_answer_at_one_in_a_thousand():
    true_answer = B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY)
    assert Fraction(99, 100) / true_answer > 10


# ---------------------------------------------------------------------------
# Exercise 5 -- odds form
# ---------------------------------------------------------------------------


def test_probability_to_odds_and_back_round_trips():
    p = Fraction(11, 122)
    assert B.odds_to_probability(B.probability_to_odds(p)) == p


def test_prior_odds_are_exactly_1_over_999():
    assert B.probability_to_odds(D.PREVALENCE) == Fraction(1, 999)


def test_likelihood_ratio_is_exactly_99():
    assert B.likelihood_ratio(D.SENSITIVITY, D.SPECIFICITY) == 99


def test_posterior_odds_equal_prior_odds_times_likelihood_ratio():
    prior_odds = B.probability_to_odds(D.PREVALENCE)
    ratio = B.likelihood_ratio(D.SENSITIVITY, D.SPECIFICITY)
    posterior_odds = B.update_odds(prior_odds, ratio)
    assert posterior_odds == prior_odds * ratio


def test_odds_form_matches_direct_posterior_exactly():
    prior_odds = B.probability_to_odds(D.PREVALENCE)
    ratio = B.likelihood_ratio(D.SENSITIVITY, D.SPECIFICITY)
    via_odds = B.odds_to_probability(B.update_odds(prior_odds, ratio))
    direct = B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY)
    assert via_odds == direct


def test_odds_of_one_half_probability_is_exactly_one():
    assert B.probability_to_odds(Fraction(1, 2)) == 1


# ---------------------------------------------------------------------------
# Exercise 6 -- sequential updating
# ---------------------------------------------------------------------------


def _tests_ab():
    return (D.TEST_A_SENSITIVITY, D.TEST_A_SPECIFICITY), (D.TEST_B_SENSITIVITY, D.TEST_B_SPECIFICITY)


def test_sequential_posterior_matches_hand_worked_value():
    test_a, test_b = _tests_ab()
    result = B.sequential_posterior(D.PREVALENCE, [test_a, test_b])
    assert result == Fraction(1045, 1267)


def test_sequential_posterior_order_does_not_matter():
    test_a, test_b = _tests_ab()
    forward = B.sequential_posterior(D.PREVALENCE, [test_a, test_b])
    backward = B.sequential_posterior(D.PREVALENCE, [test_b, test_a])
    assert forward == backward


def test_sequential_posterior_with_one_test_matches_single_posterior():
    test_a, _ = _tests_ab()
    single = B.posterior(D.PREVALENCE, *test_a)
    assert B.sequential_posterior(D.PREVALENCE, [test_a]) == single


def test_sequential_posterior_with_no_tests_returns_the_prior():
    assert B.sequential_posterior(D.PREVALENCE, []) == D.PREVALENCE


def test_two_tests_move_the_posterior_well_above_a_single_test():
    test_a, test_b = _tests_ab()
    single = B.posterior(D.PREVALENCE, *test_a)
    double = B.sequential_posterior(D.PREVALENCE, [test_a, test_b])
    assert double > single
    assert double > Fraction(1, 2)


@pytest.mark.parametrize("order", [0, 1])
def test_sequential_posterior_is_commutative_across_three_tests(order):
    tests = [
        (D.TEST_A_SENSITIVITY, D.TEST_A_SPECIFICITY),
        (D.TEST_B_SENSITIVITY, D.TEST_B_SPECIFICITY),
        (D.SENSITIVITY, D.SPECIFICITY),
    ]
    shuffled = list(reversed(tests)) if order else tests
    result = B.sequential_posterior(D.PREVALENCE, shuffled)
    expected = B.sequential_posterior(D.PREVALENCE, tests)
    assert result == expected


# ---------------------------------------------------------------------------
# Exercise 7 -- correlated tests
# ---------------------------------------------------------------------------


def test_independent_pair_probability_is_the_square():
    assert B.independent_pair_probability(Fraction(99, 100)) == Fraction(9801, 10000)


def test_correlated_pair_probability_with_zero_weight_matches_independent():
    rate = Fraction(99, 100)
    assert B.correlated_pair_probability(rate, Fraction(0)) == B.independent_pair_probability(rate)


def test_correlated_pair_probability_with_full_weight_is_the_single_rate():
    rate = Fraction(3, 4)
    assert B.correlated_pair_probability(rate, Fraction(1)) == rate


def test_naive_posterior_is_exactly_363_over_400():
    naive_tp = B.independent_pair_probability(D.CORRELATED_SENSITIVITY)
    naive_fp = B.independent_pair_probability(1 - D.CORRELATED_SPECIFICITY)
    assert B.posterior_general(D.PREVALENCE, naive_tp, naive_fp) == Fraction(363, 400)


def test_naive_posterior_is_strictly_higher_than_the_correlated_posterior():
    naive_tp = B.independent_pair_probability(D.CORRELATED_SENSITIVITY)
    naive_fp = B.independent_pair_probability(1 - D.CORRELATED_SPECIFICITY)
    naive = B.posterior_general(D.PREVALENCE, naive_tp, naive_fp)

    corr_tp = B.correlated_pair_probability(D.CORRELATED_SENSITIVITY, D.CORRELATION_WEIGHT)
    corr_fp = B.correlated_pair_probability(1 - D.CORRELATED_SPECIFICITY, D.CORRELATION_WEIGHT)
    correlated = B.posterior_general(D.PREVALENCE, corr_tp, corr_fp)

    assert naive > correlated


def test_correlated_posterior_still_exceeds_a_single_tests_posterior():
    corr_tp = B.correlated_pair_probability(D.CORRELATED_SENSITIVITY, D.CORRELATION_WEIGHT)
    corr_fp = B.correlated_pair_probability(1 - D.CORRELATED_SPECIFICITY, D.CORRELATION_WEIGHT)
    correlated = B.posterior_general(D.PREVALENCE, corr_tp, corr_fp)
    assert correlated > D.OPENING_POSTERIOR_EXACT


# ---------------------------------------------------------------------------
# Exercise 8 -- Naive Bayes with Laplace smoothing
# ---------------------------------------------------------------------------


@pytest.fixture
def trained_model():
    return NB.train({"spam": D.SPAM_DOCS, "ham": D.HAM_DOCS})


def test_vocabulary_and_class_counts(trained_model):
    assert "watches" in trained_model.vocabulary
    assert trained_model.doc_counts == {"spam": 3, "ham": 3}


def test_watches_never_appears_in_ham_training(trained_model):
    assert trained_model.word_counts["ham"]["watches"] == 0


def test_review_never_appears_in_spam_training(trained_model):
    assert trained_model.word_counts["spam"]["review"] == 0


def test_clear_spam_document_classifies_spam_smoothed(trained_model):
    winner, _ = NB.classify(trained_model, D.HELD_OUT_CLEAR_SPAM, alpha=D.LAPLACE_ALPHA)
    assert winner == "spam"


def test_clear_ham_document_classifies_ham_smoothed(trained_model):
    winner, _ = NB.classify(trained_model, D.HELD_OUT_CLEAR_HAM, alpha=D.LAPLACE_ALPHA)
    assert winner == "ham"


def test_veto_case_classifies_ham_with_smoothing(trained_model):
    winner, _ = NB.classify(trained_model, D.HELD_OUT_VETO_CASE, alpha=D.LAPLACE_ALPHA)
    assert winner == "ham"


def test_veto_case_ham_score_is_exactly_zero_without_smoothing(trained_model):
    _, scores = NB.classify(trained_model, D.HELD_OUT_VETO_CASE, alpha=0)
    assert scores["ham"] == 0


def test_veto_case_spam_score_is_also_exactly_zero_without_smoothing(trained_model):
    _, scores = NB.classify(trained_model, D.HELD_OUT_VETO_CASE, alpha=0)
    assert scores["spam"] == 0


def test_veto_case_misclassifies_without_smoothing(trained_model):
    smoothed_winner, _ = NB.classify(trained_model, D.HELD_OUT_VETO_CASE, alpha=D.LAPLACE_ALPHA)
    unsmoothed_winner, _ = NB.classify(trained_model, D.HELD_OUT_VETO_CASE, alpha=0)
    assert unsmoothed_winner != smoothed_winner


def test_word_probability_returns_a_fraction(trained_model):
    prob = NB.word_probability(trained_model, "buy", "spam", alpha=1)
    assert isinstance(prob, Fraction)


def test_word_probability_with_smoothing_is_never_exactly_zero(trained_model):
    prob = NB.word_probability(trained_model, "watches", "ham", alpha=1)
    assert prob > 0


def test_word_probability_without_smoothing_is_exactly_zero_for_an_absent_word(trained_model):
    prob = NB.word_probability(trained_model, "watches", "ham", alpha=0)
    assert prob == 0


def test_out_of_vocabulary_words_are_skipped_rather_than_zeroing_everything(trained_model):
    # "xyzzy" was never seen in training at all.
    winner, scores = NB.classify(trained_model, "buy cheap watches xyzzy", alpha=D.LAPLACE_ALPHA)
    assert winner == "spam"
    assert all(score > 0 for score in scores.values())


# ---------------------------------------------------------------------------
# Exercise 9 -- log space
# ---------------------------------------------------------------------------


def test_500_factors_of_001_underflow_to_exactly_zero():
    factors = [0.01] * 500
    assert NB.multiply_probabilities(factors) == 0.0


def test_sum_of_logs_stays_finite():
    import math

    factors = [0.01] * 500
    assert math.isfinite(NB.sum_of_logs(factors))


def test_sum_of_logs_matches_the_measured_value():
    factors = [0.01] * 500
    assert NB.sum_of_logs(factors) == D.UNDERFLOW_LOG_SUM


def test_sum_of_logs_is_not_the_wrong_draft_figure():
    factors = [0.01] * 500
    assert round(NB.sum_of_logs(factors), 2) != -1151.29


def test_fewer_factors_do_not_underflow():
    factors = [0.01] * 50
    assert NB.multiply_probabilities(factors) > 0.0


def test_classify_log_space_agrees_with_classify_on_short_documents(trained_model):
    smoothed_winner, _ = NB.classify(trained_model, D.HELD_OUT_CLEAR_SPAM, alpha=D.LAPLACE_ALPHA)
    log_winner, _ = NB.classify_log_space(trained_model, D.HELD_OUT_CLEAR_SPAM, alpha=D.LAPLACE_ALPHA)
    assert smoothed_winner == log_winner


def test_classify_log_space_still_works_on_a_long_repeated_document(trained_model):
    # A document long enough that the plain product would underflow.
    long_doc = " ".join(["buy", "cheap", "watches"] * 200)
    winner, scores = NB.classify_log_space(trained_model, long_doc, alpha=D.LAPLACE_ALPHA)
    assert winner == "spam"
    import math

    assert all(math.isfinite(score) for score in scores.values())
metadata.yml (4760 bytes)
lesson_id: D115
day: 115
kind: guided-build
languages: [python, bash]
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-115-bayes-theorem
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/python3 -c "import numpy; print(numpy.__version__)"
run_commands:
  - 'cd examples && ../.venv/bin/python3 01_opening_posterior.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 02_natural_frequencies.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 03_simulation.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 04_prevalence_sweep.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 05_odds_form.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 06_sequential_updating.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 07_correlated_tests.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 08_naive_bayes_smoothing.py && cd ..'
  - 'cd examples && ../.venv/bin/python3 09_log_space.py && cd ..'
  - .venv/bin/pytest examples -q -p no:cacheprovider
  - .venv/bin/pytest starter -q -p no:cacheprovider
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - "find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +"
  - rm -rf .pytest_cache
  - 'rm -rf .venv  # optional: removes the lab virtual environment'
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 35
last_executed: '2026-08-17'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 53 checks, 0 failure(s), exit 0; pytest examples -> 71 passed; pytest starter -> 2 passed, 38 skipped on an untouched checkout, and 40 passed against a fully solved copy of starter/ (verified by temporarily copying the reference bayes.py, simulate.py and naive_bayes.py into starter/ and filling in every answers.py prediction, then restoring the blank skeletons -- the skip counts before and after that restore were confirmed identical, and no leftover __pycache__ or .pytest_cache remained). All nine reference scripts exit 0 with every internal assertion holding. Everything was run through a real lab-local .venv created by the documented setup commands, not through an authoring environment; pip install used the network exactly once, as documented. Section 6 of the harness re-runs the reference pytest suite with one assertion (the naive-versus-correlated posterior comparison) temporarily flipped, confirms the run exits non-zero with exactly one failure named in the output, and restores the original file. Separately from that built-in self-test, the harness''s ability to fail on a genuine bug was confirmed by hand: dataset.py''s OPENING_POSTERIOR_EXACT constant and its two guard asserts were temporarily edited to a wrong value, the full harness was re-run and reported 7 failures with a non-zero exit, and the file was then restored from a backup and the harness re-run clean at 53 checks, 0 failures. Three honesty notes from this run. FIRST: scipy, PyMC and Stan are not installed in this environment. All three are described from their public documentation in the lesson''s Tools section and explicitly marked as not run here; no output attributed to any of them anywhere in this lab or its lesson was actually produced by them. SECOND: exercise 3''s simulated posterior is a real, freshly measured number, not a fixed literal -- on this run, at n=2,000,000 and seed 42, the population held 1,978 true positives and 20,069 false positives, giving an empirical posterior of 0.089717 against an exact value of 0.090164 (gap 0.000447, well inside the 3-standard-error tolerance of about 0.005787 at 22,047 positive results); this will differ slightly on another machine or NumPy version, and the test asserts the tolerance band rather than this specific figure, as documented in expected-output/FIELDS.md. THIRD: this lab''s brief originally stated that 500 factors of 0.01 collapse in log space to about -1151.29; that figure is wrong for 500 factors of 0.01 (the correct value, computed by math.log and used throughout this lab, is -2302.585...) and is instead what 500 factors of 0.1 (or 250 factors of 0.01) produce. dataset.py, every reference script and every test in this lab use the measured, correct figure, and expected-output/FIELDS.md records the correction plainly. Every other figure -- the opening posterior 99/1098, the natural-frequency table, the prevalence-sweep values, the odds-form arithmetic, the sequential and correlated-test posteriors, and every Naive Bayes count and probability -- is exact fractions.Fraction or IEEE-754-deterministic arithmetic and is identical on any correct implementation.'
requirements/README.md (2862 bytes)
# What is installed, why, and what it costs

Two packages, both free and open source, both installed into a lab-local
virtual environment that `rm -rf .venv` completely undoes.

| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `numpy` | 2.5.2 | BSD 3-Clause | The `numpy.random.Generator` built by `default_rng(seed)`, used for exercise 3's 2,000,000-person population simulation. |
| `pytest` | 9.1.1 | MIT | The reference suite (71 tests) and your running score in `starter/`. |

There is no paid tier of anything in this lab, no account, no key and no
signup, personally or commercially.

## The one time the network is needed

```bash
.venv/bin/pip install -r requirements/requirements.txt
```

That is the only command in the lab that opens a connection. Section 7 of
`tests/run_tests.sh` greps every source file in `examples/` and `starter/`
to prove that nothing else does.

## If you cannot install anything at all

Exercises 1, 2, 4, 5, 6, 7, 8 and 9 need only `fractions`, `math` and
`collections` from the standard library and do not touch NumPy at all.
Only exercise 3's population simulation needs `numpy.random.Generator`. If
NumPy is unavailable, Python's own `random` module can stand in:

```python
import random

def simulate_population_stdlib(seed: int, n: int, prevalence: float,
                                sensitivity: float, specificity: float):
    rng = random.Random(seed)
    tp = fp = tn = fn = 0
    for _ in range(n):
        sick = rng.random() < prevalence
        if sick:
            tp += rng.random() < sensitivity
            fn += rng.random() >= sensitivity
        else:
            tn += rng.random() < specificity
            fp += rng.random() >= specificity
    return tp, fp, tn, fn
```

It is slower -- a Python-level loop instead of a vectorised NumPy call --
but it is the same statistics. What you lose is `pytest`, so no running
score and no skip-versus-fail distinction; you would read the numbers back
yourself instead.

## What is deliberately *not* installed

`scipy.stats` and PyMC (or Stan) do parts of this lesson's job too, and
considerably more once full posterior inference over continuous parameters
is the goal rather than a single discrete update. They are **not installed
in this environment, and no output from either is reproduced anywhere** in
this lab or its lesson. The lesson's Tools section describes both from
their documentation and marks them as not run here.

`pandas` is also not installed and is not needed for anything in this lab;
every table here is small enough to enumerate and print directly.

That is not a limitation to apologise for. Every posterior this lab
computes is either exact `Fraction` arithmetic or a simulation you can read
end to end in `simulate.py` -- nothing here depends on a statistics package
to be correct.
requirements/requirements.txt (27 bytes)
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (3377 bytes)
# The nine exercises

Work through these in order. Predict the answer to each `answers.py`
question *before* running anything — the two that catch almost everyone
(the opening posterior, and the naive-versus-correlated gap in exercise 7)
only catch you if you commit to a guess first.

Check yourself as you go:

```bash
.venv/bin/pytest starter -q
```

Unattempted work reports as **skipped**, never failed. Wrong work **fails**
with your answer printed beside the correct one.

## 1. The opening posterior (`bayes.py`)

Write `posterior(prior, sensitivity, specificity)` in exact `Fraction`
arithmetic. Assert the 99%/99%-test, 1-in-1000-prevalence case gives exactly
`99/1098` (about `0.0902`) — and, as its own assertion, that it is **not**
`0.99`, the answer almost everyone gives.

## 2. Natural frequencies

No new function. Read `dataset.py`'s 100,000-person table (`TP`, `FP`,
`TN`, `FN`) and confirm `TP / (TP + FP)` equals exercise 1's formula answer
exactly — the same arithmetic, just counted instead of multiplied.

## 3. Simulation (`simulate.py`)

`simulate_population(rng, n, prevalence, sensitivity, specificity)` draws a
population of `n` people, tests each one, and counts the four outcome
cells. At `n = 2,000,000`, the empirical `TP / (TP + FP)` should land within
three standard errors of the exact posterior.

## 4. The prevalence sweep (`bayes.py`)

Reuses `posterior()`. Confirm it is strictly increasing across
`dataset.PREVALENCE_SWEEP`, and that at a prevalence of `1/2` it equals
*exactly* `0.99` — the number everyone wrongly gives for the 1-in-1,000
case, now the actual right answer for a different base rate.

## 5. The odds form (`bayes.py`)

`probability_to_odds()`, `odds_to_probability()`, `likelihood_ratio()` and
`update_odds()`. Assert `posterior_odds == prior_odds * likelihood_ratio`
exactly, and that converting back to a probability matches exercise 1's
direct answer.

## 6. Sequential updating (`bayes.py`)

`sequential_posterior(prior, tests)`, applying one likelihood ratio per
test in odds form. Two different tests (A: 99%/99%, B: 95%/98%), both
positive: confirm the posterior, and that updating A-then-B gives the
identical result to B-then-A.

## 7. Correlated tests (`bayes.py`)

`independent_pair_probability()` and `correlated_pair_probability()`. Same
test, run twice on one sample, with a shared failure mode half the time.
Compute the posterior the naive (assumes-independence) way and the correct
(correlation-aware) way, and confirm the naive one is strictly higher —
overstated confidence, not just a different number.

## 8. Naive Bayes with Laplace smoothing (`naive_bayes.py`)

`train()`, `word_probability()` (with an `alpha` smoothing parameter),
`document_score()` and `classify()`. A tiny spam/ham corpus. Confirm correct
classification of two clean held-out documents, and that a third document —
built around one word absent from one class's training data — classifies
correctly *with* smoothing and collapses that class's probability to
*exactly* zero *without* it.

## 9. Log space (`naive_bayes.py`)

`multiply_probabilities()` and `sum_of_logs()`. Confirm that multiplying
500 factors of `0.01` as plain `float64` underflows to exactly `0.0`, while
the corresponding sum of logs stays finite — the reason a real classifier
is built with `document_log_score()`, not `document_score()`.
starter/answers.py (4374 bytes)
"""Exercises 1 through 9 -- fifteen predictions.

Replace each `None` with the value you think is correct. A `None` is a skip,
not a failure: `pytest starter -q` counts only what you have attempted. When
you are wrong it prints both your answer and the real one, so a wrong guess
is worth more than a blank.

Predict BEFORE you run anything. The two that catch almost everyone: the
opening posterior (exercise 1) and the naive-versus-correlated gap
(exercise 7) -- and they only catch you if you commit to a number first.

Every answer is a number (as a plain float), a Python bool, or (for one
question) a short string naming a class.
"""

ANSWERS: dict[str, object] = {
    # ----------------------------------------------------------------------
    # Exercise 1 -- the opening posterior
    # ----------------------------------------------------------------------
    # 1.1 P(condition | positive), 99% sensitive/specific test, 1-in-1000
    #     prevalence, as a decimal.
    "opening_posterior": None,
    # 1.2 Is the true posterior LOWER than the naive 0.99 guess?
    "opening_posterior_below_naive_guess": None,
    # ----------------------------------------------------------------------
    # Exercise 2 -- natural frequencies
    # ----------------------------------------------------------------------
    # 2.1 Out of 100,000 people, how many test positive in total (true
    #     positives + false positives)?
    "natural_frequency_total_positives": None,
    # ----------------------------------------------------------------------
    # Exercise 3 -- simulation
    # ----------------------------------------------------------------------
    # 3.1 Does the simulated posterior land within 3 standard errors of the
    #     exact value at n = 2,000,000?
    "simulation_within_tolerance": None,
    # ----------------------------------------------------------------------
    # Exercise 4 -- the prevalence sweep
    # ----------------------------------------------------------------------
    # 4.1 At prevalence = 1/2, what is the posterior, as a decimal?
    "prevalence_half_posterior": None,
    # 4.2 Is the posterior strictly increasing as prevalence rises?
    "prevalence_sweep_increasing": None,
    # ----------------------------------------------------------------------
    # Exercise 5 -- the odds form
    # ----------------------------------------------------------------------
    # 5.1 What is the likelihood ratio LR+ for the 99%/99% test?
    "likelihood_ratio_value": None,
    # 5.2 Does posterior_odds == prior_odds * likelihood_ratio hold exactly?
    "odds_form_matches_direct": None,
    # ----------------------------------------------------------------------
    # Exercise 6 -- sequential updating
    # ----------------------------------------------------------------------
    # 6.1 Two different positive tests (A then B), posterior as a decimal.
    "sequential_two_test_posterior": None,
    # 6.2 Does updating with B first, then A, give the identical result?
    "sequential_order_independent": None,
    # ----------------------------------------------------------------------
    # Exercise 7 -- correlated tests
    # ----------------------------------------------------------------------
    # 7.1 The naive (assumes-independence) posterior for two same-sample
    #     positive results, as a decimal.
    "correlated_naive_posterior": None,
    # 7.2 Is the naive posterior STRICTLY HIGHER than the correct,
    #     correlation-aware posterior?
    "correlated_naive_overstates": None,
    # ----------------------------------------------------------------------
    # Exercise 8 -- Naive Bayes with Laplace smoothing
    # ----------------------------------------------------------------------
    # 8.1 With smoothing, what class does the veto-case document
    #     ("please review schedule watches") get classified as?
    "veto_case_smoothed_class": None,
    # 8.2 Without smoothing, is ham's score EXACTLY zero for that document?
    "veto_case_unsmoothed_ham_is_zero": None,
    # ----------------------------------------------------------------------
    # Exercise 9 -- log space
    # ----------------------------------------------------------------------
    # 9.1 Does multiplying 500 factors of 0.01 as plain floats underflow to
    #     exactly 0.0?
    "underflow_to_exactly_zero": None,
}
starter/bayes.py (4467 bytes)
"""Exercises 1, 4, 5, 6 and 7: Bayes' theorem, exact, in two equivalent forms.

Fill in the bodies marked `# YOUR CODE HERE`. `dataset.py` has every
constant you need. Every function here returns a `fractions.Fraction`
wherever the answer is rational, so an assertion against it is exact rather
than "close enough" -- the same discipline Day 113's lab used throughout.
"""

from fractions import Fraction


# ---------------------------------------------------------------------------
# Exercise 1: Bayes' theorem in probability form
# ---------------------------------------------------------------------------


def posterior(prior: Fraction, sensitivity: Fraction, specificity: Fraction) -> Fraction:
    """P(condition | positive test), by Bayes' theorem.

    prior        = P(condition), the base rate before any test
    sensitivity  = P(positive | condition)
    specificity  = P(negative | no condition)

    The denominator -- P(positive), the "evidence" -- is exactly Day 113's
    law of total probability applied to the two-piece partition
    {condition, no condition}:

        P(positive) = P(condition) x sensitivity
                    + P(no condition) x (1 - specificity)
    """
    # YOUR CODE HERE
    raise NotImplementedError


def posterior_general(
    prior: Fraction,
    p_positive_given_condition: Fraction,
    p_positive_given_no_condition: Fraction,
) -> Fraction:
    """The same theorem, stated with raw likelihoods instead of sensitivity
    and specificity. Useful whenever "the test is positive" is not a clean
    sensitivity/specificity pair -- for instance, exercise 7's correlated
    tests, where P(both positive | condition) is not simply sensitivity
    squared.
    """
    # YOUR CODE HERE
    raise NotImplementedError


# ---------------------------------------------------------------------------
# Exercise 5: the odds form
# ---------------------------------------------------------------------------


def probability_to_odds(p: Fraction) -> Fraction:
    """odds = p / (1 - p)."""
    # YOUR CODE HERE
    raise NotImplementedError


def odds_to_probability(odds: Fraction) -> Fraction:
    """p = odds / (1 + odds), the inverse of probability_to_odds."""
    # YOUR CODE HERE
    raise NotImplementedError


def likelihood_ratio(sensitivity: Fraction, specificity: Fraction) -> Fraction:
    """LR+ = P(positive | condition) / P(positive | no condition)
           = sensitivity / (1 - specificity).
    """
    # YOUR CODE HERE
    raise NotImplementedError


def update_odds(prior_odds: Fraction, ratio: Fraction) -> Fraction:
    """posterior odds = prior odds x likelihood ratio."""
    # YOUR CODE HERE
    raise NotImplementedError


# ---------------------------------------------------------------------------
# Exercise 6: sequential updating
# ---------------------------------------------------------------------------


def sequential_posterior(
    prior: Fraction,
    tests: list[tuple[Fraction, Fraction]],
) -> Fraction:
    """Update a prior with a sequence of independent positive test results.

    `tests` is a list of (sensitivity, specificity) pairs. Update the
    running odds one likelihood ratio at a time, then convert back to a
    probability at the end.
    """
    # YOUR CODE HERE
    raise NotImplementedError


# ---------------------------------------------------------------------------
# Exercise 7: correlated tests -- when "multiply the likelihood ratios"
# quietly assumes something false
# ---------------------------------------------------------------------------


def independent_pair_probability(single_rate: Fraction) -> Fraction:
    """P(both runs agree on a given outcome | hypothesis), assuming the two
    runs are drawn independently: single_rate squared.
    """
    # YOUR CODE HERE
    raise NotImplementedError


def correlated_pair_probability(single_rate: Fraction, correlation_weight: Fraction) -> Fraction:
    """P(both runs agree on a given outcome | hypothesis), when the two
    runs share a failure mode with probability `correlation_weight`.

    With probability `correlation_weight`, both runs are yoked to one
    shared random draw and therefore either BOTH show the outcome or
    NEITHER does, at the single-run rate. With probability
    (1 - correlation_weight), the two runs are genuinely independent, as
    `independent_pair_probability` assumes for the whole calculation.
    """
    # YOUR CODE HERE
    raise NotImplementedError
starter/conftest.py (1116 bytes)
"""Make this directory's own modules the ones its tests import.

Both `examples/` and `starter/` contain modules called `bayes`, `simulate`,
`naive_bayes`, `dataset` and `answers`, and pytest imports test files by
putting their directory on `sys.path`. Without this file, running `pytest`
across both directories at once would import whichever module was seen
first and then reuse it for the other suite -- so these starter tests would
silently pass against the reference solution instead of skipping. That is a
wrong answer with a green tick on it, which is the worst kind.

So: put this directory first on the import path, and drop any already-
imported module of those names that came from somewhere else.
"""

import sys
from pathlib import Path

HERE = str(Path(__file__).parent.resolve())

if HERE in sys.path:
    sys.path.remove(HERE)
sys.path.insert(0, HERE)

for name in ("bayes", "simulate", "naive_bayes", "dataset", "answers"):
    module = sys.modules.get(name)
    origin = getattr(module, "__file__", "") or ""
    if module is not None and not origin.startswith(HERE):
        del sys.modules[name]
starter/dataset.py (8126 bytes)
"""The scenario, the corpus, and every tolerance this lab compares against.

Read this file. Nothing here is tuned to make a test pass: every exact
number is either a stated assumption (a test's sensitivity, a disease's
prevalence) or a value derived from those assumptions and then checked
against enumeration or simulation. The one place a captured figure differs
from the number a naive back-of-envelope calculation might suggest --
exercise 9's log-space arithmetic -- is called out explicitly at the bottom
of this file, because the wrong number belongs nowhere near a test.
"""

import math
from fractions import Fraction

# --------------------------------------------------------------------------
# The opening scenario: a diagnostic test for a rare condition
# --------------------------------------------------------------------------

#: 1 person in 1,000 has the condition, before any test is run.
PREVALENCE: Fraction = Fraction(1, 1000)

#: P(test positive | has condition) -- the test catches 99 sick people out
#: of every 100.
SENSITIVITY: Fraction = Fraction(99, 100)

#: P(test negative | does not have condition) -- the test correctly clears
#: 99 healthy people out of every 100.
SPECIFICITY: Fraction = Fraction(99, 100)

#: The exact posterior, derived by hand in the lesson and reproduced by
#: exercise 1: P(condition | positive) = 99/1098, about 9.02%.
OPENING_POSTERIOR_EXACT: Fraction = Fraction(99, 1098)

assert OPENING_POSTERIOR_EXACT == Fraction(11, 122)
assert round(float(OPENING_POSTERIOR_EXACT), 4) == 0.0902

# --------------------------------------------------------------------------
# The natural-frequencies table: 100,000 people, counted rather than
# multiplied as percentages
# --------------------------------------------------------------------------

NATURAL_FREQUENCY_POPULATION: int = 100_000

#: With prevalence 1/1000, exactly 100 of 100,000 people have the
#: condition, and 99,900 do not -- both exact because the population size
#: was chosen to divide the prevalence evenly.
NATURAL_FREQUENCY_SICK: int = 100
NATURAL_FREQUENCY_WELL: int = 99_900
assert NATURAL_FREQUENCY_SICK + NATURAL_FREQUENCY_WELL == NATURAL_FREQUENCY_POPULATION
assert Fraction(NATURAL_FREQUENCY_SICK, NATURAL_FREQUENCY_POPULATION) == PREVALENCE

#: True positives: 99% of the 100 sick people test positive.
NATURAL_FREQUENCY_TP: int = 99
#: False negatives: the other 1% of the sick people test negative.
NATURAL_FREQUENCY_FN: int = 1
#: False positives: 1% of the 99,900 healthy people test positive anyway.
NATURAL_FREQUENCY_FP: int = 999
#: True negatives: the other 99% of the healthy people correctly test negative.
NATURAL_FREQUENCY_TN: int = 98_901

assert NATURAL_FREQUENCY_TP + NATURAL_FREQUENCY_FN == NATURAL_FREQUENCY_SICK
assert NATURAL_FREQUENCY_FP + NATURAL_FREQUENCY_TN == NATURAL_FREQUENCY_WELL

# --------------------------------------------------------------------------
# Exercise 3: seeded simulation of a large population
# --------------------------------------------------------------------------

SIMULATION_POPULATION: int = 2_000_000
SIMULATION_SEED: int = 42


def standard_error(p: float, n: int) -> float:
    """The standard error of a proportion estimated from n trials."""
    return math.sqrt(p * (1.0 - p) / n)


# --------------------------------------------------------------------------
# Exercise 4: the prevalence sweep
# --------------------------------------------------------------------------

#: A sweep from rare to common, ending at 1/2 -- the prevalence at which
#: the posterior collapses to exactly the sensitivity, 0.99, which is the
#: number almost everyone wrongly gives for the 1-in-1,000 case above.
PREVALENCE_SWEEP: tuple[Fraction, ...] = (
    Fraction(1, 100_000),
    Fraction(1, 10_000),
    Fraction(1, 1_000),
    Fraction(1, 100),
    Fraction(1, 10),
    Fraction(1, 2),
)

# --------------------------------------------------------------------------
# Exercise 6: sequential updating with two DIFFERENT tests
# --------------------------------------------------------------------------

#: Test A is the opening scenario's test: 99% sensitive, 99% specific.
TEST_A_SENSITIVITY: Fraction = SENSITIVITY
TEST_A_SPECIFICITY: Fraction = SPECIFICITY

#: Test B is a different, less accurate test: 95% sensitive, 98% specific.
#: Using two genuinely different tests makes "the order does not matter"
#: a real claim about commutativity rather than a coincidence of running
#: the identical test twice.
TEST_B_SENSITIVITY: Fraction = Fraction(95, 100)
TEST_B_SPECIFICITY: Fraction = Fraction(98, 100)

# --------------------------------------------------------------------------
# Exercise 7: correlated tests -- the same assay, run twice, on one sample
# --------------------------------------------------------------------------

#: The probability that a run shares its outcome with the other run rather
#: than drawing independently -- modelling a shared failure mode, such as
#: a contaminated sample or a single faulty batch of reagent, that affects
#: both runs identically. c = 0 is the naive (fully independent) model;
#: c = 1/2 means half the time both runs are yoked to one shared draw.
CORRELATION_WEIGHT: Fraction = Fraction(1, 2)

#: Both runs use the same underlying test: 99% sensitive, 99% specific.
CORRELATED_SENSITIVITY: Fraction = SENSITIVITY
CORRELATED_SPECIFICITY: Fraction = SPECIFICITY

# --------------------------------------------------------------------------
# Exercise 8: Naive Bayes from scratch -- a tiny hand-made spam corpus
# --------------------------------------------------------------------------

#: Three spam documents, three ham documents. Kept deliberately tiny so the
#: whole vocabulary and every count can be read off by hand and checked.
SPAM_DOCS: tuple[str, ...] = (
    "buy cheap watches now",
    "cheap replica watches for sale",
    "buy now limited offer",
)

HAM_DOCS: tuple[str, ...] = (
    "meeting notes for review",
    "please review the agenda",
    "schedule the project meeting",
)

#: Held-out documents the trained classifier is asked to label.
#:
#: The first two have no word that is entirely absent from one class'
#: training vocabulary, so smoothed and unsmoothed classifiers agree.
#: The third is built to contain exactly one word -- "watches" -- that
#: never appears in the ham training documents, so the unsmoothed
#: classifier's P(document | ham) collapses to exactly zero and the single
#: word vetoes three other words that all point toward ham.
HELD_OUT_CLEAR_SPAM: str = "buy cheap watches"
HELD_OUT_CLEAR_HAM: str = "schedule the project meeting"
HELD_OUT_VETO_CASE: str = "please review schedule watches"

LAPLACE_ALPHA: int = 1

# --------------------------------------------------------------------------
# Exercise 9: why log space is not optional
# --------------------------------------------------------------------------

#: A stand-in for "several hundred small per-word probabilities multiplied
#: together" -- the exact shape of a naive Bayes document score. 500 factors
#: of 0.01 is well inside the range a real bag-of-words likelihood product
#: can reach, and float64 cannot represent the result.
UNDERFLOW_FACTOR: float = 0.01
UNDERFLOW_COUNT: int = 500

#: The true value of the corresponding sum of logs, computed directly
#: rather than approximated: 500 * ln(0.01). This is reported, not assumed
#: -- see the note below.
UNDERFLOW_LOG_SUM: float = UNDERFLOW_COUNT * math.log(UNDERFLOW_FACTOR)

# A note on a figure that does NOT appear as a constant here. An earlier
# draft of this lab's brief stated that 500 factors of 0.01 collapse in log
# space to "about -1151.29". That number is wrong for 500 factors of 0.01 --
# 500 * ln(0.01) = -2302.585..., not -1151.29. The figure -1151.29 is what
# you get from 500 factors of 0.1 (500 * ln(0.1) = -1151.29...), or
# equivalently 250 factors of 0.01. This file and every test in this lab
# use the measured, correct value for 500 factors of 0.01, UNDERFLOW_LOG_SUM
# above, computed directly by math.log rather than copied from a draft.
assert round(UNDERFLOW_LOG_SUM, 2) == -2302.59
starter/naive_bayes.py (3967 bytes)
"""Exercises 8 and 9: Naive Bayes from scratch, and why it must be built in
log space.

"Naive" names one specific, false assumption: that every word in a document
is conditionally independent of every other word, given the document's
class. Fill in the bodies marked `# YOUR CODE HERE`.
"""

import math
from collections import Counter
from dataclasses import dataclass
from fractions import Fraction


def tokenize(text: str) -> list[str]:
    """The whole tokenizer this lab needs: lowercase, split on whitespace."""
    return text.lower().split()


@dataclass(frozen=True)
class NaiveBayesModel:
    """Word counts, vocabulary and class priors, trained from labelled
    documents. Everything downstream -- smoothed or not, log space or not --
    is computed from these four fields alone.
    """

    classes: tuple[str, ...]
    vocabulary: frozenset[str]
    word_counts: dict[str, Counter]
    total_words: dict[str, int]
    doc_counts: dict[str, int]

    @property
    def total_docs(self) -> int:
        return sum(self.doc_counts.values())


def train(docs_by_class: dict[str, tuple[str, ...]]) -> NaiveBayesModel:
    """Count every word in every class's training documents.

    For each class: tokenize every document, accumulate word counts in a
    `Counter`, track the running total word count, and grow the shared
    vocabulary set with every token seen (in ANY class).
    """
    # YOUR CODE HERE
    raise NotImplementedError


def class_prior(model: NaiveBayesModel, cls: str) -> Fraction:
    """P(class) = documents in that class / total training documents."""
    # YOUR CODE HERE
    raise NotImplementedError


def word_probability(model: NaiveBayesModel, word: str, cls: str, alpha: int) -> Fraction:
    """P(word | class), with Laplace (add-alpha) smoothing.

    (count(word, class) + alpha) / (total_words[class] + alpha * |vocabulary|)

    alpha = 0 reproduces the unsmoothed textbook version: a word with zero
    occurrences in a class gets probability exactly 0.
    """
    # YOUR CODE HERE
    raise NotImplementedError


def document_score(model: NaiveBayesModel, tokens: list[str], cls: str, alpha: int) -> Fraction:
    """The (unnormalised) joint probability P(class) x product of
    P(word | class) over every word in the document, computed as an exact
    Fraction by literal multiplication. Skip any token that never appeared
    in ANY training class (out-of-vocabulary).
    """
    # YOUR CODE HERE
    raise NotImplementedError


def classify(model: NaiveBayesModel, text: str, alpha: int) -> tuple[str, dict[str, Fraction]]:
    """Predict the class with the highest unnormalised joint probability.
    Returns the winning class and every class's raw score.
    """
    # YOUR CODE HERE
    raise NotImplementedError


# ---------------------------------------------------------------------------
# Exercise 9: log space, and why a product of many small floats is not
# optional to avoid
# ---------------------------------------------------------------------------


def multiply_probabilities(factors: list[float]) -> float:
    """The naive way: multiply plain Python floats together, one at a time."""
    # YOUR CODE HERE
    raise NotImplementedError


def sum_of_logs(factors: list[float]) -> float:
    """The fix: work in log space. Use `math.fsum` over `math.log(factor)`
    for each factor, rather than a running `+=` sum.
    """
    # YOUR CODE HERE
    raise NotImplementedError


def document_log_score(model: NaiveBayesModel, tokens: list[str], cls: str, alpha: int) -> float:
    """The log-space equivalent of document_score: sum of logs instead of
    a product of probabilities.
    """
    # YOUR CODE HERE
    raise NotImplementedError


def classify_log_space(model: NaiveBayesModel, text: str, alpha: int) -> tuple[str, dict[str, float]]:
    """The same classification decision as classify(), but computed in log
    space.
    """
    # YOUR CODE HERE
    raise NotImplementedError
starter/simulate.py (1686 bytes)
"""Exercise 3: simulate a large population and confirm the exact posterior
by counting rather than by formula.

Takes an explicit `numpy.random.Generator`, built by
`numpy.random.default_rng(seed)`, exactly as Day 113's and Day 114's labs
do -- never the legacy `numpy.random.seed` global. Fill in the body marked
`# YOUR CODE HERE`.
"""

from typing import NamedTuple

import numpy as np


class PopulationCounts(NamedTuple):
    true_positive: int
    false_positive: int
    true_negative: int
    false_negative: int

    @property
    def positives(self) -> int:
        return self.true_positive + self.false_positive

    @property
    def empirical_posterior(self) -> float:
        """TP / (TP + FP) -- the fraction of positive results that are
        genuinely true positives, read straight off the simulated counts."""
        if self.positives == 0:
            return float("nan")
        return self.true_positive / self.positives


def simulate_population(
    rng: np.random.Generator,
    n: int,
    prevalence: float,
    sensitivity: float,
    specificity: float,
) -> PopulationCounts:
    """Draw n people, assign each a true condition status by `prevalence`,
    then a test result by `sensitivity`/`specificity`, and count the four
    outcome cells directly -- no formula involved anywhere in this function.

    Hints:
      - `rng.random(n) < prevalence` gives a boolean array of who has the
        condition.
      - Split the population into sick and well groups, draw test results
        for each group separately with `rng.random(count) < rate`, and
        count how many land on each side.
    """
    # YOUR CODE HERE
    raise NotImplementedError
starter/test_starter.py (12774 bytes)
"""Your running score. Unattempted work SKIPS; wrong work FAILS with both values.

Run from the lab directory:

    .venv/bin/pytest starter -q

On an untouched checkout this reports one pass and everything else skipped.
A skip means "not attempted". A failure means "attempted and wrong", and the
message shows your answer next to the real one so you can see the gap rather
than guess at it.

Nothing in here checks that a function exists or that a file is present.
Every test runs your code and compares a value.
"""

from fractions import Fraction

import numpy as np
import pytest

import answers
import bayes as B
import dataset as D
import naive_bayes as NB
import simulate as S

# --------------------------------------------------------------------------
# The skip machinery
# --------------------------------------------------------------------------


def attempt(fn, what):
    """Call something that may not be written yet, and skip if it is not."""
    try:
        result = fn()
    except (TypeError, AttributeError, NotImplementedError):
        pytest.skip(f"not attempted yet: {what}")
    if result is None:
        pytest.skip(f"not attempted yet: {what}")
    return result


def need(value, what):
    """Skip if the exercise has not been attempted, otherwise hand it back."""
    if value is None:
        pytest.skip(f"not attempted yet: {what}")
    return value


def test_the_suite_itself_runs():
    """One test that always passes, so a green run is distinguishable from
    a collection error that quietly ran nothing at all."""
    assert D.PREVALENCE == Fraction(1, 1000)


# --------------------------------------------------------------------------
# Exercise 1 -- the opening posterior
# --------------------------------------------------------------------------


def test_1_posterior_matches_the_exact_fraction():
    result = attempt(lambda: B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY), "posterior")
    assert result == D.OPENING_POSTERIOR_EXACT


def test_1_posterior_returns_a_fraction_not_a_float():
    result = attempt(lambda: B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY), "posterior")
    assert isinstance(result, Fraction)


def test_1_posterior_is_not_the_naive_099_guess():
    result = attempt(lambda: B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY), "posterior")
    assert result != Fraction(99, 100)


def test_1_posterior_of_a_certain_prior_is_one():
    result = attempt(lambda: B.posterior(Fraction(1), D.SENSITIVITY, D.SPECIFICITY), "posterior")
    assert result == 1


# --------------------------------------------------------------------------
# Exercise 2 -- natural frequencies (uses dataset.py's captured constants,
# nothing to implement, but confirms you have read them)
# --------------------------------------------------------------------------


def test_2_natural_frequency_ratio_matches_the_formula():
    ratio = Fraction(D.NATURAL_FREQUENCY_TP, D.NATURAL_FREQUENCY_TP + D.NATURAL_FREQUENCY_FP)
    exact = attempt(lambda: B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY), "posterior")
    assert ratio == exact


# --------------------------------------------------------------------------
# Exercise 3 -- simulation
# --------------------------------------------------------------------------


def test_3_simulate_population_counts_add_up():
    counts = attempt(
        lambda: S.simulate_population(np.random.default_rng(0), 20_000, 0.1, 0.9, 0.9),
        "simulate_population",
    )
    total = counts.true_positive + counts.false_positive + counts.true_negative + counts.false_negative
    assert total == 20_000


def test_3_simulate_population_within_tolerance_at_scale():
    counts = attempt(
        lambda: S.simulate_population(
            np.random.default_rng(D.SIMULATION_SEED),
            D.SIMULATION_POPULATION,
            float(D.PREVALENCE),
            float(D.SENSITIVITY),
            float(D.SPECIFICITY),
        ),
        "simulate_population",
    )
    exact = float(D.OPENING_POSTERIOR_EXACT)
    tol = 3.0 * D.standard_error(exact, counts.positives)
    assert abs(counts.empirical_posterior - exact) < tol


# --------------------------------------------------------------------------
# Exercise 4 -- the prevalence sweep
# --------------------------------------------------------------------------


def test_4_prevalence_one_half_gives_exactly_099():
    result = attempt(lambda: B.posterior(Fraction(1, 2), D.SENSITIVITY, D.SPECIFICITY), "posterior")
    assert result == Fraction(99, 100)


def test_4_prevalence_sweep_is_strictly_increasing():
    results = attempt(
        lambda: [B.posterior(p, D.SENSITIVITY, D.SPECIFICITY) for p in D.PREVALENCE_SWEEP],
        "posterior",
    )
    assert all(results[i] < results[i + 1] for i in range(len(results) - 1))


# --------------------------------------------------------------------------
# Exercise 5 -- the odds form
# --------------------------------------------------------------------------


def test_5_likelihood_ratio_is_exactly_99():
    result = attempt(lambda: B.likelihood_ratio(D.SENSITIVITY, D.SPECIFICITY), "likelihood_ratio")
    assert result == 99


def test_5_posterior_odds_equal_prior_odds_times_ratio():
    prior_odds = attempt(lambda: B.probability_to_odds(D.PREVALENCE), "probability_to_odds")
    ratio = attempt(lambda: B.likelihood_ratio(D.SENSITIVITY, D.SPECIFICITY), "likelihood_ratio")
    posterior_odds = attempt(lambda: B.update_odds(prior_odds, ratio), "update_odds")
    assert posterior_odds == prior_odds * ratio


def test_5_odds_form_matches_direct_posterior():
    prior_odds = attempt(lambda: B.probability_to_odds(D.PREVALENCE), "probability_to_odds")
    ratio = attempt(lambda: B.likelihood_ratio(D.SENSITIVITY, D.SPECIFICITY), "likelihood_ratio")
    posterior_odds = attempt(lambda: B.update_odds(prior_odds, ratio), "update_odds")
    via_odds = attempt(lambda: B.odds_to_probability(posterior_odds), "odds_to_probability")
    direct = attempt(lambda: B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY), "posterior")
    assert via_odds == direct


# --------------------------------------------------------------------------
# Exercise 6 -- sequential updating
# --------------------------------------------------------------------------


def _tests_ab():
    return (D.TEST_A_SENSITIVITY, D.TEST_A_SPECIFICITY), (D.TEST_B_SENSITIVITY, D.TEST_B_SPECIFICITY)


def test_6_sequential_posterior_matches_the_documented_value():
    test_a, test_b = _tests_ab()
    result = attempt(lambda: B.sequential_posterior(D.PREVALENCE, [test_a, test_b]), "sequential_posterior")
    assert result == Fraction(1045, 1267)


def test_6_sequential_posterior_order_does_not_matter():
    test_a, test_b = _tests_ab()
    forward = attempt(lambda: B.sequential_posterior(D.PREVALENCE, [test_a, test_b]), "sequential_posterior")
    backward = attempt(lambda: B.sequential_posterior(D.PREVALENCE, [test_b, test_a]), "sequential_posterior")
    assert forward == backward


# --------------------------------------------------------------------------
# Exercise 7 -- correlated tests
# --------------------------------------------------------------------------


def test_7_naive_posterior_is_exactly_363_over_400():
    naive_tp = attempt(lambda: B.independent_pair_probability(D.CORRELATED_SENSITIVITY), "independent_pair_probability")
    naive_fp = attempt(
        lambda: B.independent_pair_probability(1 - D.CORRELATED_SPECIFICITY), "independent_pair_probability"
    )
    result = attempt(lambda: B.posterior_general(D.PREVALENCE, naive_tp, naive_fp), "posterior_general")
    assert result == Fraction(363, 400)


def test_7_naive_posterior_is_strictly_higher_than_correlated():
    naive_tp = attempt(lambda: B.independent_pair_probability(D.CORRELATED_SENSITIVITY), "independent_pair_probability")
    naive_fp = attempt(
        lambda: B.independent_pair_probability(1 - D.CORRELATED_SPECIFICITY), "independent_pair_probability"
    )
    naive = attempt(lambda: B.posterior_general(D.PREVALENCE, naive_tp, naive_fp), "posterior_general")

    corr_tp = attempt(
        lambda: B.correlated_pair_probability(D.CORRELATED_SENSITIVITY, D.CORRELATION_WEIGHT),
        "correlated_pair_probability",
    )
    corr_fp = attempt(
        lambda: B.correlated_pair_probability(1 - D.CORRELATED_SPECIFICITY, D.CORRELATION_WEIGHT),
        "correlated_pair_probability",
    )
    correlated = attempt(lambda: B.posterior_general(D.PREVALENCE, corr_tp, corr_fp), "posterior_general")
    assert naive > correlated


# --------------------------------------------------------------------------
# Exercise 8 -- Naive Bayes with Laplace smoothing
# --------------------------------------------------------------------------


def _trained_model():
    return NB.train({"spam": D.SPAM_DOCS, "ham": D.HAM_DOCS})


def test_8_clear_documents_classify_correctly():
    model = attempt(_trained_model, "train")
    spam_winner, _ = attempt(lambda: NB.classify(model, D.HELD_OUT_CLEAR_SPAM, alpha=D.LAPLACE_ALPHA), "classify")
    ham_winner, _ = attempt(lambda: NB.classify(model, D.HELD_OUT_CLEAR_HAM, alpha=D.LAPLACE_ALPHA), "classify")
    assert (spam_winner, ham_winner) == ("spam", "ham")


def test_8_veto_case_classifies_ham_with_smoothing():
    model = attempt(_trained_model, "train")
    winner, _ = attempt(lambda: NB.classify(model, D.HELD_OUT_VETO_CASE, alpha=D.LAPLACE_ALPHA), "classify")
    assert winner == "ham"


def test_8_veto_case_ham_score_is_exactly_zero_without_smoothing():
    model = attempt(_trained_model, "train")
    _, scores = attempt(lambda: NB.classify(model, D.HELD_OUT_VETO_CASE, alpha=0), "classify")
    assert scores["ham"] == 0


def test_8_word_probability_with_smoothing_never_zero():
    model = attempt(_trained_model, "train")
    prob = attempt(lambda: NB.word_probability(model, "watches", "ham", alpha=1), "word_probability")
    assert prob > 0


# --------------------------------------------------------------------------
# Exercise 9 -- log space
# --------------------------------------------------------------------------


def test_9_500_factors_of_001_underflow_to_exactly_zero():
    result = attempt(lambda: NB.multiply_probabilities([0.01] * 500), "multiply_probabilities")
    assert result == 0.0


def test_9_sum_of_logs_stays_finite():
    import math

    result = attempt(lambda: NB.sum_of_logs([0.01] * 500), "sum_of_logs")
    assert math.isfinite(result)


def test_9_sum_of_logs_matches_the_measured_value():
    result = attempt(lambda: NB.sum_of_logs([0.01] * 500), "sum_of_logs")
    assert result == D.UNDERFLOW_LOG_SUM


# --------------------------------------------------------------------------
# Predictions -- fifteen numbers, checked against the real answers
# --------------------------------------------------------------------------

EXPECTED: dict[str, object] = {
    "opening_posterior": float(D.OPENING_POSTERIOR_EXACT),
    "opening_posterior_below_naive_guess": True,
    "natural_frequency_total_positives": 1098.0,
    "simulation_within_tolerance": True,
    "prevalence_half_posterior": 0.99,
    "prevalence_sweep_increasing": True,
    "likelihood_ratio_value": 99.0,
    "odds_form_matches_direct": True,
    "sequential_two_test_posterior": float(Fraction(1045, 1267)),
    "sequential_order_independent": True,
    "correlated_naive_posterior": 0.9075,
    "correlated_naive_overstates": True,
    "veto_case_smoothed_class": "ham",
    "veto_case_unsmoothed_ham_is_zero": True,
    "underflow_to_exactly_zero": True,
}

HINTS: dict[str, str] = {
    "opening_posterior": (
        "Bayes' theorem: (prior x sensitivity) / evidence, where evidence "
        "is the law of total probability over {condition, no condition}."
    ),
    "correlated_naive_posterior": (
        "This is the SAME formula as exercise 6, just with sensitivity "
        "squared and (1 - specificity) squared as the two likelihoods."
    ),
    "veto_case_smoothed_class": (
        "Three of the four words in this document are ham-associated; "
        "only 'watches' points toward spam."
    ),
}


@pytest.mark.parametrize("key", sorted(EXPECTED))
def test_predictions(key):
    got = need(answers.ANSWERS.get(key), f"answers.ANSWERS[{key!r}]")
    want = EXPECTED[key]
    hint = HINTS.get(key, "")
    if isinstance(want, (bool, str)):
        assert got == want, f"{key}: your answer {got!r}, expected {want!r}. {hint}"
    else:
        assert abs(float(got) - float(want)) < 1e-6, (
            f"{key}: your answer {got!r}, expected {want!r}. {hint}"
        )


def test_every_answer_key_is_still_present():
    missing = sorted(set(EXPECTED) - set(answers.ANSWERS))
    assert not missing, f"answers.py is missing these keys: {missing}"
tests/run_tests.sh (18004 bytes)
#!/usr/bin/env bash
# Tests for the Day 115 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running code and reading real
# values, never by reading source:
#
#   * the opening posterior -- 99% sensitive, 99% specific, 1-in-1000
#     prevalence -- is exactly 99/1098 (about 0.0902), and NOT 0.99;
#   * the 100,000-person natural-frequency table's TP/(TP+FP) matches the
#     formula exactly;
#   * a 2,000,000-person seeded simulation lands within 3 standard errors
#     of the exact posterior;
#   * the posterior is strictly increasing in prevalence, and at a
#     prevalence of 1/2 it is EXACTLY 0.99 -- the naive guess, correct for
#     a different question;
#   * posterior odds equal prior odds times the likelihood ratio, exactly;
#   * two different tests, updated sequentially, give an identical
#     posterior regardless of the order they are applied in;
#   * a correlated pair of same-sample tests has a naive (assumes-
#     independence) posterior that is strictly, and substantially, higher
#     than the correct correlation-aware one;
#   * a from-scratch naive Bayes classifier with Laplace smoothing
#     correctly classifies held-out documents, and without smoothing a
#     single absent word collapses a class's probability to exactly zero;
#   * multiplying 500 factors of 0.01 underflows to exactly 0.0 in
#     float64, while the corresponding sum of logs stays finite;
#   * nothing is left behind on disk.
#
# Everything after the one-time install runs offline. Nothing binds a port,
# nothing writes outside the lab, nothing needs a key. Deterministic,
# non-interactive, exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

# Bytecode left by an EARLIER command is not this run's litter. The README
# documents `pytest starter -q`, and running it writes .pyc files that would
# then fail the cleanliness check at the end of this script -- failing the
# reader for following the instructions. Clearing them here makes that final
# check measure what it claims to: what THIS run left behind. `.venv` is
# untouched, because the packages' own bytecode is theirs, not ours.
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true

failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

check_eq() {
  # check_eq <label> <expected> <actual>
  if [ "$2" = "$3" ]; then
    check "$1" "yes"
  else
    check "$1 (expected [$2], got [$3])" "no"
  fi
}

# Resolve pytest: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with instructions rather than silently skipping checks.
resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
  echo "FAIL: pytest not found." >&2
  echo "  Install the lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  echo "  Or point this suite at an existing pytest:" >&2
  echo "    PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
  exit 1
}

python_bin="$(dirname "${pytest_bin}")/python3"
if [ ! -x "${python_bin}" ]; then
  python_bin="$(command -v python3 || true)"
fi
if [ -z "${python_bin}" ]; then
  echo "FAIL: python3 not found on PATH." >&2
  exit 1
fi

if ! "${python_bin}" -c "import numpy" >/dev/null 2>&1; then
  echo "FAIL: numpy is not importable from ${python_bin}." >&2
  echo "  Install the lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  exit 1
fi

echo "Day 115 — Bayes You Can Trust"
echo

# --------------------------------------------------------------------------
echo "1. The tools and the versions this lab was written against"
# --------------------------------------------------------------------------

versions="$("${python_bin}" - <<'PY'
import platform
import sys
from importlib.metadata import version

print(f"python   {platform.python_version()}")
for name in ("numpy", "pytest"):
    print(f"{name:<8} {version(name)}")
print(f"platform {platform.platform()}")
print(f"exe      {sys.executable.rsplit('/', 3)[-1]}")
PY
)"
echo "${versions}" | sed 's/^/  /'

pinned_numpy="$(grep -E '^numpy==' "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
installed_numpy="$("${python_bin}" -c "from importlib.metadata import version; print(version('numpy'))")"
check_eq "installed numpy matches requirements.txt" "${pinned_numpy}" "${installed_numpy}"

major="$("${python_bin}" -c "import numpy; print(numpy.__version__.split('.')[0])")"
check_eq "numpy is version 2 or later" "2" "${major}"

# --------------------------------------------------------------------------
echo
echo "2. Every reference script runs and every assertion inside it holds"
# --------------------------------------------------------------------------

for script in 01_opening_posterior 02_natural_frequencies 03_simulation \
              04_prevalence_sweep 05_odds_form 06_sequential_updating \
              07_correlated_tests 08_naive_bayes_smoothing 09_log_space; do
  out="$(cd "${lab_dir}/examples" && "${python_bin}" "${script}.py" 2>&1)"
  status=$?
  if [ "${status}" -ne 0 ]; then
    check "${script}.py exits 0" "no"
    echo "${out}" | tail -5 | sed 's/^/      /'
  else
    check "${script}.py exits 0" "yes"
  fi
  case "${out}" in
    *"${script}.py: every assertion held."*)
      check "${script}.py reports every assertion held" "yes" ;;
    *) check "${script}.py reports every assertion held" "no" ;;
  esac
done

# --------------------------------------------------------------------------
echo
echo "3. The reference pytest suite: real values, real exceptions"
# --------------------------------------------------------------------------

ref_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
ref_status=$?
echo "${ref_out}" | tail -3 | sed 's/^/  /'
if [ "${ref_status}" -eq 0 ]; then
  check "pytest examples exits 0" "yes"
else
  check "pytest examples exits 0" "no"
fi
case "${ref_out}" in
  *" failed"*) check "no test in the reference suite failed" "no" ;;
  *)           check "no test in the reference suite failed" "yes" ;;
esac
ref_passed="$(printf '%s\n' "${ref_out}" | grep -o '[0-9][0-9]* passed' | head -1 | cut -d' ' -f1)"
if [ "${ref_passed:-0}" -ge 60 ]; then
  check "the reference suite ran at least 60 tests (ran ${ref_passed})" "yes"
else
  check "the reference suite ran at least 60 tests (ran ${ref_passed:-0})" "no"
fi

# --------------------------------------------------------------------------
echo
echo "4. The starter suite skips unattempted work instead of failing it"
# --------------------------------------------------------------------------

start_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q -p no:cacheprovider 2>&1)"
start_status=$?
echo "${start_out}" | tail -3 | sed 's/^/  /'
if [ "${start_status}" -eq 0 ]; then
  check "pytest starter exits 0 on an untouched checkout" "yes"
else
  check "pytest starter exits 0 on an untouched checkout" "no"
fi
case "${start_out}" in
  *" failed"*) check "the starter suite reports no failures" "no" ;;
  *)           check "the starter suite reports no failures" "yes" ;;
esac
case "${start_out}" in
  *skipped*) check "unwritten exercises are reported as skipped, not passed" "yes" ;;
  *) check "unwritten exercises are reported as skipped, not passed" "no" ;;
esac

# The import guard. Both directories contain modules called `bayes`,
# `simulate`, `naive_bayes`, `dataset` and `answers`, and pytest imports test
# files by putting their directory on sys.path -- so collecting both suites
# at once would otherwise let the starter tests import the REFERENCE
# solution and report unwritten exercises as passing. Each directory's
# conftest.py prevents that. This check proves it still does: across both
# suites, the skip count must be unchanged.
both_out="$(cd "${lab_dir}" && "${pytest_bin}" -q -p no:cacheprovider 2>&1)"
start_skipped="$(printf '%s\n' "${start_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
both_skipped="$(printf '%s\n' "${both_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
check_eq "collecting both suites at once does not turn skips into passes" \
  "${start_skipped:-none}" "${both_skipped:-none}"

# --------------------------------------------------------------------------
echo
echo "5. The lesson's claims, checked one value at a time"
# --------------------------------------------------------------------------

facts="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY'
import math
from fractions import Fraction

import numpy as np

import bayes as B
import dataset as D
import naive_bayes as NB
import simulate as S

result = B.posterior(D.PREVALENCE, D.SENSITIVITY, D.SPECIFICITY)
print("opening_posterior", result)
print("opening_posterior_rounded", round(float(result), 4))
print("opening_posterior_not_naive", result != Fraction(99, 100))

total_positive = D.NATURAL_FREQUENCY_TP + D.NATURAL_FREQUENCY_FP
natural_ratio = Fraction(D.NATURAL_FREQUENCY_TP, total_positive)
print("natural_total_positive", total_positive)
print("natural_ratio_matches_formula", natural_ratio == result)

rng = np.random.default_rng(D.SIMULATION_SEED)
counts = S.simulate_population(
    rng, D.SIMULATION_POPULATION, float(D.PREVALENCE), float(D.SENSITIVITY), float(D.SPECIFICITY)
)
exact = float(result)
tol = 3.0 * D.standard_error(exact, counts.positives)
print("simulation_within_tolerance", abs(counts.empirical_posterior - exact) < tol)

sweep = [B.posterior(p, D.SENSITIVITY, D.SPECIFICITY) for p in D.PREVALENCE_SWEEP]
print("sweep_increasing", all(sweep[i] < sweep[i + 1] for i in range(len(sweep) - 1)))
print("sweep_half_is_099", B.posterior(Fraction(1, 2), D.SENSITIVITY, D.SPECIFICITY) == Fraction(99, 100))

prior_odds = B.probability_to_odds(D.PREVALENCE)
ratio = B.likelihood_ratio(D.SENSITIVITY, D.SPECIFICITY)
posterior_odds = B.update_odds(prior_odds, ratio)
print("odds_form_matches", posterior_odds == prior_odds * ratio)
print("odds_form_probability_matches_direct", B.odds_to_probability(posterior_odds) == result)

test_a = (D.TEST_A_SENSITIVITY, D.TEST_A_SPECIFICITY)
test_b = (D.TEST_B_SENSITIVITY, D.TEST_B_SPECIFICITY)
seq_ab = B.sequential_posterior(D.PREVALENCE, [test_a, test_b])
seq_ba = B.sequential_posterior(D.PREVALENCE, [test_b, test_a])
print("sequential_posterior", seq_ab)
print("sequential_order_independent", seq_ab == seq_ba)

naive_tp = B.independent_pair_probability(D.CORRELATED_SENSITIVITY)
naive_fp = B.independent_pair_probability(1 - D.CORRELATED_SPECIFICITY)
naive_post = B.posterior_general(D.PREVALENCE, naive_tp, naive_fp)
corr_tp = B.correlated_pair_probability(D.CORRELATED_SENSITIVITY, D.CORRELATION_WEIGHT)
corr_fp = B.correlated_pair_probability(1 - D.CORRELATED_SPECIFICITY, D.CORRELATION_WEIGHT)
corr_post = B.posterior_general(D.PREVALENCE, corr_tp, corr_fp)
print("correlated_naive_posterior", naive_post)
print("correlated_correct_posterior", corr_post)
print("correlated_naive_higher", naive_post > corr_post)

model = NB.train({"spam": D.SPAM_DOCS, "ham": D.HAM_DOCS})
smoothed_winner, _ = NB.classify(model, D.HELD_OUT_VETO_CASE, alpha=D.LAPLACE_ALPHA)
_, unsmoothed_scores = NB.classify(model, D.HELD_OUT_VETO_CASE, alpha=0)
print("veto_smoothed_winner", smoothed_winner)
print("veto_unsmoothed_ham_zero", unsmoothed_scores["ham"] == 0)

factors = [0.01] * 500
product = NB.multiply_probabilities(factors)
logsum = NB.sum_of_logs(factors)
print("underflow_is_zero", product == 0.0)
print("logsum_finite", math.isfinite(logsum))
print("logsum_matches_measured", logsum == D.UNDERFLOW_LOG_SUM)
PY
)"

get() { printf '%s\n' "${facts}" | grep "^$1 " | cut -d' ' -f2-; }

check_eq "the opening posterior is exactly 99/1098, reduced to 11/122" "11/122" "$(get opening_posterior)"
check_eq "which rounds to 0.0902" "0.0902" "$(get opening_posterior_rounded)"
check_eq "and is NOT the naive 0.99 guess" "True" "$(get opening_posterior_not_naive)"
check_eq "the natural-frequency table has 1098 total positives" "1098" "$(get natural_total_positive)"
check_eq "TP/(TP+FP) matches the formula's answer exactly" "True" "$(get natural_ratio_matches_formula)"
check_eq "the 2,000,000-person simulation lands within 3 standard errors" "True" "$(get simulation_within_tolerance)"
check_eq "the prevalence sweep is strictly increasing" "True" "$(get sweep_increasing)"
check_eq "at prevalence 1/2 the posterior is exactly 0.99" "True" "$(get sweep_half_is_099)"
check_eq "posterior odds equal prior odds times the likelihood ratio, exactly" "True" "$(get odds_form_matches)"
check_eq "the odds-form probability matches the direct formula exactly" "True" "$(get odds_form_probability_matches_direct)"
check_eq "the two-test sequential posterior is exactly 1045/1267" "1045/1267" "$(get sequential_posterior)"
check_eq "updating in either order gives an identical result" "True" "$(get sequential_order_independent)"
check_eq "the naive correlated-test posterior is exactly 363/400" "363/400" "$(get correlated_naive_posterior)"
check_eq "the correct correlated posterior is exactly 2189/13400" "2189/13400" "$(get correlated_correct_posterior)"
check_eq "the naive posterior is strictly higher than the correct one" "True" "$(get correlated_naive_higher)"
check_eq "the veto-case document classifies ham with smoothing" "ham" "$(get veto_smoothed_winner)"
check_eq "and ham's score is exactly zero without smoothing" "True" "$(get veto_unsmoothed_ham_zero)"
check_eq "500 factors of 0.01 underflow to exactly 0.0" "True" "$(get underflow_is_zero)"
check_eq "the corresponding sum of logs is finite" "True" "$(get logsum_finite)"
check_eq "and matches the independently measured value" "True" "$(get logsum_matches_measured)"

# --------------------------------------------------------------------------
echo
echo "6. The harness can actually fail"
# --------------------------------------------------------------------------

# A green test suite proves nothing until you have watched it go red. This
# section re-runs the reference pytest suite with one assertion deliberately
# broken -- the naive-correlated-posterior comparison flipped -- and asserts
# that the run reports the failure and exits non-zero.
sentinel_file="${lab_dir}/examples/test_reference.py"
if grep -q "def test_naive_posterior_is_strictly_higher_than_the_correlated_posterior" "${sentinel_file}"; then
  backup="$(mktemp)"
  cp "${sentinel_file}" "${backup}"
  python3 - "${sentinel_file}" <<'PY'
import sys
path = sys.argv[1]
text = open(path).read()
needle = "    assert naive > correlated\n\n\ndef test_correlated_posterior_still_exceeds_a_single_tests_posterior"
replacement = (
    "    assert naive < correlated  # DELIBERATELY WRONG: self-test only\n\n\n"
    "def test_correlated_posterior_still_exceeds_a_single_tests_posterior"
)
assert needle in text, "sentinel assertion not found"
open(path, "w").write(text.replace(needle, replacement, 1))
PY
  self_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
  self_status=$?
  cp "${backup}" "${sentinel_file}"
  rm -f "${backup}"
  find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true

  if [ "${self_status}" -ne 0 ]; then
    check "a deliberately broken assertion makes the suite exit non-zero (${self_status})" "yes"
  else
    check "a deliberately broken assertion makes the suite exit non-zero" "no"
  fi
  case "${self_out}" in
    *"test_naive_posterior_is_strictly_higher_than_the_correlated_posterior"*"failed"*|*"FAILED"*"test_naive_posterior_is_strictly_higher_than_the_correlated_posterior"*)
      check "the failing test is named in the output" "yes" ;;
    *) check "the failing test is named in the output" "no" ;;
  esac
  case "${self_out}" in
    *"1 failed"*) check "exactly one test failed" "yes" ;;
    *) check "exactly one test failed" "no" ;;
  esac
else
  check "sentinel assertion found in examples/test_reference.py" "no"
fi

# --------------------------------------------------------------------------
echo
echo "7. Nothing was left behind"
# --------------------------------------------------------------------------

# `.venv` is pruned from both searches below. The virtual environment ships
# NumPy's and pytest's own precompiled bytecode -- hundreds of __pycache__
# directories that came with the packages and have nothing to do with
# whether THIS lab tidied up after itself.

if find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -print -quit 2>/dev/null | grep -q .; then
  check "no __pycache__ directory left by the lab's own code" "no"
else
  check "no __pycache__ directory left by the lab's own code" "yes"
fi

if find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -print -quit 2>/dev/null | grep -q .; then
  check "no .pytest_cache directory left under the lab" "no"
else
  check "no .pytest_cache directory left under the lab" "yes"
fi

if grep -rqE 'urlopen|requests\.|socket\.|http://|https://' \
     "${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null; then
  check "no lab source opens a network connection" "no"
else
  check "no lab source opens a network connection" "yes"
fi

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting

Every entry below was hit while building this lab, or is named by a test that exists because of it.

ModuleNotFoundError: No module named 'bayes'

You ran a reference script from the lab directory instead of from inside examples/. The scripts import bayes, simulate, naive_bayes and dataset from beside themselves.

cd examples
../.venv/bin/python3 01_opening_posterior.py
cd ..

The pytest suites do not have this problem, because pytest puts the test file's own directory on the import path.

ModuleNotFoundError: No module named 'numpy'

You are running the system python3 rather than the lab's. Everything here goes through .venv/bin/python3:

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

If you would rather use an interpreter you already have, the harness accepts one:

PYTEST=/path/to/pytest bash tests/run_tests.sh

The starter tests all skip and I have written code

A skip means the function still raises NotImplementedError or still returns None. Look for a leftover raise NotImplementedError below the code you added — it is easy to write the body above it and leave the raise in place, so your work never runs at all.

My posterior() gives 0.99, not about 0.09

You almost certainly returned the sensitivity directly instead of completing the division. posterior() is (prior x sensitivity) / evidence, where evidence is the FULL law of total probability sum — (prior x sensitivity) + ((1 - prior) x (1 - specificity)) — not just the numerator. Skipping the denominator, or accidentally returning sensitivity itself, is the single most common way to reproduce the exact misconception this lesson opens with.

posterior() fails a test even though the decimal "looks right"

Check the return type. posterior() must return a fractions.Fraction, not a float. Fraction(99, 1098) == 0.09016393442622951 compares False for most fractions because a float cannot represent the exact rational value, and the whole point of using Fraction throughout this lab is that comparisons are exact rather than "close enough".

My prevalence sweep is not strictly increasing

Check that PREVALENCE_SWEEP in dataset.py is unmodified and that posterior() is being called with the SAME sensitivity and specificity at every point in the sweep — only the prevalence should vary. If sensitivity or specificity accidentally changes too (for instance, if you are reusing a loop variable name that collides with one of them), the sweep can stop being monotonic.

My odds-form answer does not match the direct posterior

likelihood_ratio() is sensitivity / (1 - specificity), not sensitivity / specificity. The denominator is the FALSE POSITIVE rate — how likely a positive result is even without the condition — not the true negative rate. Swapping them gives a plausible-looking but wrong ratio (here, 99 instead of the correct value, or vice versa if you inadvertently compute the reciprocal).

Updating test A then test B gives a different answer from B then A

This should be mathematically impossible if sequential_posterior() is implemented correctly, since it is built entirely from multiplying Fractions onto a running odds value, and multiplication is commutative. If your two orders disagree, check that you are re-computing prior_odds from the ORIGINAL prior at the start of each call rather than accidentally carrying state between the two calls (for instance, reusing a mutable odds variable across both runs instead of starting fresh each time).

My naive and correlated posteriors in exercise 7 come out equal

Check correlation_weight. If you pass Fraction(0) to correlated_pair_probability(), it reduces to exactly independent_pair_probability() by construction — test_correlated_pair_probability_with_zero_weight_matches_independent in the reference suite checks this directly, and it is correct behaviour, not a bug. dataset.CORRELATION_WEIGHT is Fraction(1, 2); if your naive and correlated numbers are identical, confirm you are actually passing that constant rather than a literal 0.

My naive posterior in exercise 7 is LOWER than the correlated one

You likely swapped which model is "naive" and which is "correct". independent_pair_probability() (naive) assumes full independence and therefore treats two positive results as twice as much evidence as they actually are when a shared failure mode is present — it should always be higher than correlated_pair_probability()'s more cautious estimate whenever correlation_weight > 0, never lower.

Everything in my Naive Bayes classifier ties at zero

You are running the unsmoothed classifier (alpha=0) on a document that contains a word absent from one class's training data — this is exactly exercise 8's veto case, "please review schedule watches", and the ties are the point, not a bug. If you did not intend to demonstrate the veto, call classify() with alpha=1 (Laplace smoothing) instead.

My smoothed and unsmoothed classifiers give the same answer on the veto document

Then smoothing is not actually changing word_probability()'s denominator. Check that word_probability() adds alpha * len(model.vocabulary) to the denominator, not just alpha — with alpha=1 and a 17-word vocabulary, the denominator should grow by 17, not by 1, or the smoothing is too weak to move the classification away from the unsmoothed (wrong) answer.

My 500-factor product in exercise 9 does not reach exactly 0.0

Confirm you are multiplying plain Python floats in a loop with *=, not using math.prod on a generator that short-circuits, and that UNDERFLOW_COUNT and UNDERFLOW_FACTOR are unmodified in dataset.py (500 factors of 0.01). If you multiply fewer than roughly 150 factors of 0.01, the product is still representable and will not underflow — the underflow specifically needs enough factors that the true product's magnitude (10^-1000 at 500 factors) falls below float64's smallest representable positive value, about 5e-324.

__pycache__ or .pytest_cache appears and section 7 fails

Run the cleanup:

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache

Note the -path ./.venv -prune in that command, and note that the harness uses the same prune. NumPy and pytest ship hundreds of their own __pycache__ directories inside the virtual environment; those are theirs, not litter you created. .venv itself is the documented setup and is never treated as a stray file.

You should not actually be able to hit this. The "How to run" section tells you to run .venv/bin/pytest starter -q while you work, and that command does write starter/__pycache__ and .pytest_cache — it has no reason not to. The harness clears both at the start of its run, pruning .venv, so the check at the end measures what this run left rather than what an earlier command left. If you edit tests/run_tests.sh, keep that block where it is.

Running pytest with no arguments gives me a different skip count

It should not, and there is a check for exactly that. Both examples/ and starter/ contain modules called bayes, simulate, naive_bayes, dataset and answers. Without the conftest.py in each directory, collecting both suites at once would import whichever copy was seen first and reuse it for the other — so your unwritten starter exercises would silently pass against the reference solution. A wrong answer with a green tick on it is the worst kind of wrong answer.

Windows

Not run here, and this file will not pretend otherwise. Use the Windows Subsystem for Linux and follow the Linux instructions, or use Git Bash with .venv\Scripts\python.exe in place of .venv/bin/python3. Everything in the lab is plain arithmetic, standard-library Python and NumPy, so nothing in it is platform-specific — but "should work" and "was run" are different claims and only the second one is worth making.

Security notes

Security notes

What this lab does

It computes and prints. It writes no files, opens no network connection after the one-time pip install, needs no credentials, no sudo and no elevated permissions, and touches nothing outside its own directory. Every number it works with is invented and is stated to be invented: the test's sensitivity and specificity, the prevalence, the correlation weight, and the tiny spam/ham corpus are all written out in examples/dataset.py.

Section 7 of tests/run_tests.sh greps every source file in examples/ and starter/ for urlopen, requests., socket., http:// and https:// and fails if any of them appears.

The virtual environment

python3 -m venv .venv creates the environment inside the lab directory, so nothing installed here can affect the rest of your machine, and rm -rf .venv is a complete undo. The two packages are pinned to exact versions in requirements/requirements.txt, and section 1 of the harness reads the installed version back and compares it against that file rather than trusting that the install did what it said.

Pinning is a security property as much as a reproducibility one: an unpinned numpy in a lab that a few thousand people will run is an invitation you did not mean to send.

Three things worth carrying away from this particular day

A posterior is only as trustworthy as the prior and the independence assumptions that produced it. Exercise 7 builds a case where "multiply the likelihood ratios" — the textbook move for combining two pieces of evidence — silently assumes the two pieces of evidence are conditionally independent, and shows the naive calculation reporting dramatically more confidence than is justified when that assumption fails. In any system that combines multiple signals into one risk score — fraud detection, intrusion detection, automated moderation — treating correlated signals as independent evidence is exactly this mistake, and it produces overconfident, not merely imprecise, output.

A classifier's output is a posterior, and a posterior trained on the wrong base rate is confidently wrong at scale. This lab's opening example — a 99%-accurate test that is right about 9% of the time it fires positive, because the condition is rare — is not specific to medicine. A model trained on a class-balanced dataset and deployed against a real population where the positive class is rare (fraud, intrusion, defect detection) will overstate its own precision in exactly this way unless the deployment-time base rate is accounted for, which is what calibration exists to fix.

A wrong probability calculation looks exactly like a right one until you check it against an independent method. Every exercise in this lab computes something two ways — exact Fraction arithmetic against a simulation, a formula against a from-scratch enumeration, a naive model against a corrected one — because a probability bug produces a number that is still between 0 and 1, still looks plausible, and gives no signal that anything is wrong. The discipline this lab teaches is the general defence against that entire class of mistake, in this lab and in any code that reports a probability or a confidence score.

What this lab deliberately does not claim

scipy.stats, scikit-learn's MultinomialNB and PyMC (or Stan) are not installed here and no output from any of them is reproduced anywhere in this lab or its lesson. Each is described from its public documentation in the lesson's Tools section and marked as not run here.