Math, Statistics, and Data › Probability and Statistics › Day 113
Hands-on lab — Day 113: Probability: Events, Rules, and Intuition
- ← Back to the Day 113 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/math-statistics-and-data/day-113-probability-events-rules-and-intuition/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-113-probability-events-rules-and-intuition
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_sample_space_and_events.py && cd ..
cd examples && ../.venv/bin/python3 02_addition_rule.py && cd ..
cd examples && ../.venv/bin/python3 03_de_mere.py && cd ..
cd examples && ../.venv/bin/python3 04_independence_vs_dependence.py && cd ..
cd examples && ../.venv/bin/python3 05_mutual_exclusivity_implies_dependence.py && cd ..
cd examples && ../.venv/bin/python3 06_conditioning_by_restriction.py && cd ..
cd examples && ../.venv/bin/python3 07_law_of_total_probability.py && cd ..
cd examples && ../.venv/bin/python3 08_monte_carlo_error_scaling.py && cd ..
cd examples && ../.venv/bin/python3 09_reproducibility.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_sample_space_and_events.py examples/02_addition_rule.py examples/03_de_mere.py examples/04_independence_vs_dependence.py examples/05_mutual_exclusivity_implies_dependence.py examples/06_conditioning_by_restriction.py examples/07_law_of_total_probability.py examples/08_monte_carlo_error_scaling.py examples/09_reproducibility.py examples/conftest.py examples/dataset.py examples/probability.py examples/simulate.py examples/test_reference.py expected-output/01-sample-space-and-events.txt expected-output/02-addition-rule.txt expected-output/03-de-mere.txt expected-output/04-independence-vs-dependence.txt expected-output/05-mutual-exclusivity-implies-dependence.txt expected-output/06-conditioning-by-restriction.txt expected-output/07-law-of-total-probability.txt expected-output/08-monte-carlo-error-scaling.txt expected-output/09-reproducibility.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/conftest.py starter/dataset.py starter/probability.py starter/simulate.py starter/test_starter.py tests/run_tests.sh troubleshooting.md
Lab README
Day 113 lab — Probability You Can Count
Lesson
- Lesson title: Probability: Events, Rules, and Intuition
- Day number: 113 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-113-probability-events-rules-and-intuition
- 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-113-probability-events-rules-and-intuitionwhen the site is running.
Purpose
Probability is bookkeeping about sets, and almost every "paradox" in this subject is a bookkeeping error you can find. This lab's whole strategy is one sentence: when intuition and arithmetic disagree, enumerate — and when the space is too large to enumerate, simulate.
Two fair dice give a sample space of 36 equally likely outcomes, small
enough to write out by hand and small enough that a computer can enumerate
it in a fraction of a second. Every exercise in this lab computes something
two independent ways — exact enumeration against a formula, or exact
arithmetic against a simulation — and asserts that they agree. Where the
answer is rational, it is computed with fractions.Fraction so the
assertion is exact rather than "close enough".
The opening failure is the one that founded the subject. In the 1650s
the Chevalier de Méré believed two bets were equally good: at least one 6 in
4 rolls of one die, and at least one double-six in 24 rolls of two dice. The
reasoning was seductive — a double six is 1/6 as likely, so roll 6x as many
times and it evens out — and it is wrong. The exact answers are
1 - (5/6)^4 = 0.5177... and 1 - (35/36)^24 = 0.4914.... One bet is
favourable to the player; the other is not. Exercise 3 derives both by hand
with the complement rule, then confirms both by simulation.
From there the lab builds outward through the addition rule (and exactly
how the naive shortcut lies), independence versus mutual exclusivity (the
most common conflation in the subject), conditioning as literally throwing
away rows of a table, the law of total probability (which Day 115's Bayes'
theorem runs backwards), and Monte Carlo error scaling — a hundred times the
samples buys a tenth of the error, not a hundredth, because the error falls
like 1/sqrt(n).
Learning objectives
By the end you will be able to:
- Build a sample space by enumeration and read a probability off the exact
ratio of two counts, using
fractions.Fractionso the answer has no floating-point noise. - State and apply the addition rule, and show precisely how much a naive sum overstates the truth when two events overlap.
- Collapse an "at least one" question to one line with the complement rule, and use it to resolve de Méré's paradox exactly.
- Confirm an exact probability by simulation, with a tolerance derived from the standard error of a proportion rather than guessed.
- Distinguish independence from mutual exclusivity, and explain why mutually exclusive events with non-zero probability are necessarily dependent.
- Compute a conditional probability by formula and by restricting the sample space, and see that these are the same operation.
- Apply the law of total probability across a partition of the sample space, verified against a direct enumeration of the combined experiment.
- Demonstrate that Monte Carlo error shrinks like
1/sqrt(n), not1/n, and say why a hundredfold increase in samples buys only a tenfold reduction in error. - Use
numpy.random.default_rng(seed)correctly, and explain why it is preferred over the legacynumpy.random.seedglobal state. - Explain the gambler's fallacy, the confusion between
P(A|B)andP(B|A), base-rate neglect, and the conjunction fallacy well enough to spot each one outside a textbook.
Prerequisites
- Comfort with Python sets,
itertools.product, and basic arithmetic on fractions. - No calculus and no statistics beyond counting. This is the first day of probability in the course.
- Days 99–112 — the mathematics arc that precedes it, though nothing here depends on linear algebra or calculus directly.
- Days 71–74 — running pytest and reading its output.
- Day 43 —
python3 -m venvand installing a package withpip.
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.exein place of.venv/bin/python3. Not run here;troubleshooting.mdsays so plainly.
Hardware requirements
Anything that runs Python. The largest computation this lab performs is a Monte Carlo sweep of 100,000 simulated dice rolls, repeated across 20 seeds at four sample sizes — a few hundred thousand random draws in total, 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.numpy2.5.2 andpytest9.1.1, installed into a lab-local virtual environment fromrequirements/requirements.txt.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.
The six exact-probability exercises (1, 2, 4, 5, 6, 7) need only
itertools and fractions from the standard library and do not touch
NumPy at all. Only the three simulation exercises (3, 8, 9) need
numpy.random.Generator, and requirements/README.md shows the
standard-library substitution using random.Random if NumPy is
unavailable.
scipy.stats does related work — and more, once distributions arrive on
Day 114 — and is not installed here, so no output from it is reproduced
anywhere in this lab or its lesson. The lesson's Tools section describes
it from its documentation.
Installation
From the repository root:
cd labs/sections/math-statistics-and-data/day-113-probability-events-rules-and-intuition
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 sample space, events, urns and tolerances — read it, do not change it
│ ├── probability.py exercises 1, 2, 4, 5, 6, 7 — exact probability functions to write
│ ├── simulate.py exercises 3, 8, 9 — simulation functions to write
│ ├── answers.py eighteen 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
│ ├── probability.py the finished exact-probability functions
│ ├── simulate.py the finished simulation functions
│ ├── 01_sample_space_and_events.py the sample space, events as sets, probability as counting
│ ├── 02_addition_rule.py the addition rule and exactly how the naive sum lies
│ ├── 03_de_mere.py de Méré's two bets, exact and simulated — the centrepiece
│ ├── 04_independence_vs_dependence.py one independent pair, one dependent pair
│ ├── 05_mutual_exclusivity_implies_dependence.py mutually exclusive events are necessarily dependent
│ ├── 06_conditioning_by_restriction.py conditioning as throwing away rows
│ ├── 07_law_of_total_probability.py two urns, weighted total vs. direct enumeration
│ ├── 08_monte_carlo_error_scaling.py error shrinks like 1/sqrt(n), not 1/n
│ ├── 09_reproducibility.py same seed, byte-identical results
│ └── test_reference.py 93 tests over real values and real exceptions
├── tests/
│ └── run_tests.sh the bash harness: 57 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-sample-space-and-events.txt
│ ├── 02-addition-rule.txt
│ ├── 03-de-mere.txt
│ ├── 04-independence-vs-dependence.txt
│ ├── 05-mutual-exclusivity-implies-dependence.txt
│ ├── 06-conditioning-by-restriction.txt
│ ├── 07-law-of-total-probability.txt
│ ├── 08-monte-carlo-error-scaling.txt
│ ├── 09-reproducibility.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 3 passed, 43 skipped. A skip means
"not attempted"; a failure means "attempted and wrong", and prints both your
answer and the real one. When it prints 46 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_sample_space_and_events.py
../.venv/bin/python3 02_addition_rule.py
../.venv/bin/python3 03_de_mere.py
../.venv/bin/python3 04_independence_vs_dependence.py
../.venv/bin/python3 05_mutual_exclusivity_implies_dependence.py
../.venv/bin/python3 06_conditioning_by_restriction.py
../.venv/bin/python3 07_law_of_total_probability.py
../.venv/bin/python3 08_monte_carlo_error_scaling.py
../.venv/bin/python3 09_reproducibility.py
cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
Run them from inside examples/, because they import probability.py,
simulate.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_sample_space_and_events.py |
Builds the 36-outcome sample space, defines an event as a filtered subset, and reads P(sum == 7) = Fraction(1, 6) off the ratio of two counts. |
02_addition_rule.py |
A = "sum is 7", B = "first die is 6". Shows the naive sum overstating the truth by exactly P(A and B), then confirms the true union by counting it directly. |
03_de_mere.py |
Both of de Méré's bets, derived exactly with the complement rule and confirmed by 200,000-trial simulations, each within three standard errors. |
04_independence_vs_dependence.py |
One pair of dice events that is genuinely independent ("sum is 7" and "first die is 3") and one that is genuinely dependent ("sum is 2" and "first die is 1"). |
05_mutual_exclusivity_implies_dependence.py |
A mutually exclusive pair, showing P(A | B) = 0 != P(A) — the sharpest possible form of dependence. |
06_conditioning_by_restriction.py |
P(sum = 8 | first die is even) computed by formula and by filtering the sample space, shown to agree exactly. |
07_law_of_total_probability.py |
Two urns of different composition, drawn from with a fair coin; the weighted total checked against a direct enumeration of the combined 20-outcome experiment. |
08_monte_carlo_error_scaling.py |
Estimates P(sum == 7) at four sample sizes four decades apart, averaged over 20 seeds, and shows the error shrinking like 1/sqrt(n). |
09_reproducibility.py |
The same default_rng(seed) gives byte-identical results across two calls; a different seed gives a different, still-close result. |
.venv/bin/pytest examples -q -p no:cacheprovider |
The 93 reference tests. -p no:cacheprovider stops pytest writing a .pytest_cache directory. |
bash tests/run_tests.sh |
The 57-check harness: versions, every script, both suites, twenty-three individual values, a deliberate self-failure, and a clean-disk check. |
Expected output
The captured files live in expected-output/. The harness ends with:
57 checks, 0 failure(s).
and exits 0. The reference suite ends with 93 passed, and an untouched
starter with 3 passed, 43 skipped.
De Méré's two bets, the block worth recognising before you meet it:
bet 1 (one die, 4 rolls): 0.517747 -- above 0.5, favours the player
bet 2 (two dice, 24 rolls): 0.491404 -- below 0.5, favours the house
expected-output/FIELDS.md records exactly which parts of the captured
output may legitimately differ on your machine — the simulated values, not
the exact Fraction results — and tabulates both tolerances against the
error bounds they were derived from.
Validation steps
bash tests/run_tests.sh; echo "exit=$?"prints57 checks, 0 failure(s).andexit=0..venv/bin/pytest examples -q -p no:cacheproviderprints93 passed..venv/bin/pytest starter -q -p no:cacheproviderprints46 passedonce you have finished, and never prints a failure you have not been shown.- Each of the nine reference scripts ends with
every assertion held. find . -path ./.venv -prune -o -type d -name '__pycache__' -printprints nothing after a full run.
Tests
tests/run_tests.sh runs 57 checks in seven sections:
- Versions — reads the installed numpy and compares it against
requirements/requirements.txt, and confirms it is NumPy 2 or later. - The nine reference scripts — each must exit 0 and print that every one of its internal assertions held.
- The reference pytest suite — must exit 0, report no failures, and have collected at least 80 tests, so a collection error cannot pass as success.
- 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
probability,simulate,datasetandanswers. - Twenty-three individual values — the sample space size, the addition-rule error and its exact size, both de Méré bets rounded to the historical figures and confirmed favourable or not, both simulations within tolerance, the independent and dependent pair checks, the mutual exclusivity result, the two conditioning methods agreeing exactly, the urn total matching a direct enumeration, the Monte Carlo error trend, and the reproducibility guarantees.
- A deliberate failure — the harness temporarily swaps one reference assertion for a wrong one, 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.
- A clean disk — no
__pycache__and no.pytest_cacheoutside.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.
.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
starter tests that keep skipping because a raise NotImplementedError
survived below your code, Fraction-versus-float return-type mistakes,
de Méré's exponents swapped between the two bets, mutual exclusivity
confused with independence, the __pycache__ search that must prune
.venv, 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 probability claim is a number with an error bar, and
reporting one without an error bar is a form of overclaiming; randomness
that is not reproducible (the legacy numpy.random.seed global) is a
debugging liability rather than a convenience; 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
- Compute the birthday problem. With 23 people in a room, what is the
probability that two share a birthday? Derive it with the complement
rule — 1 minus the probability that all 23 birthdays are distinct — using
Fraction, then confirm it by simulation. The answer is over 50%, and most people guess far lower; explain why, in terms of how many pairs of people there are. - Add a third urn with a non-uniform prior. Extend exercise 7 so the coin is biased 70/30 rather than fair, and add a third urn. Recompute the law of total probability and design a combined enumeration that still checks it exactly, being careful about what "equally likely" means once the prior is not uniform.
- Find your own gambler's-fallacy trap. Simulate 10,000 sequences of 20 fair-coin flips and, for every flip that follows a run of 4 or more heads, record whether the next flip is heads. Confirm the proportion is still statistically indistinguishable from 0.5 — the coin has no memory — and write one paragraph on why a real gambler at a real table finds this so hard to believe.
- Build a Monty Hall simulator from these primitives. Using only
randomornumpy.random.default_rng, simulate the classic three-door problem 100,000 times for both the "stay" and "switch" strategies, and confirm the exact 1/3 versus 2/3 split with a standard-error tolerance in the same style as exercise 3. - Measure the conjunction fallacy's actual gap. Construct two events where one implies the other (for example, "rolls a 6" and "rolls a 6 and the sum with a second die exceeds 8"), and confirm by enumeration that the more specific event can never have higher probability than the general one — the fact the conjunction fallacy violates in human judgement, however natural it feels to violate it.
Navigation
- Previous day: Day 112 — Visualizing Optimization
- Next day: Day 114 — Random Variables and Distributions
- Week 17: Probability and Statistics
- Section: Mathematics, Statistics and Data
Expected output
01-sample-space-and-events.txt
The sample space of two dice
------------------------------------------------------------
built with itertools.product: 36 outcomes
first five: ((1, 1), (1, 2), (1, 3), (1, 4), (1, 5))
ok: the space has 36 outcomes
ok: it matches the outcomes dataset.py enumerates
An event is just a subset of the space
------------------------------------------------------------
'sum == 7': [(1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1)]
ok: 6 outcomes sum to 7
P(sum == 7) = 1/6 = 0.166667
ok: P(sum == 7) is exactly Fraction(1, 6)
ok: probability() returns a Fraction, not a float
P(both dice match) = 1/6 = 0.166667
ok: P(double) is exactly Fraction(1, 6)
P(whole space) = 1, P(empty set) = 0
ok: the three axioms hold here: P(space) = 1
ok: and P(empty) = 0
ok: and every probability is non-negative
01_sample_space_and_events.py: every assertion held. (9 checks)
02-addition-rule.txt
A = 'sum is 7', B = 'first die is 6'
------------------------------------------------------------
A = [(1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1)]
B = [(6, 1), (6, 2), (6, 3), (6, 4), (6, 5), (6, 6)]
A and B = [(6, 1)] <- exactly one outcome, double-counted
P(A) = 1/6, P(B) = 1/6, P(A and B) = 1/36
naive P(A) + P(B) = 1/3 = 0.333333 WRONG
true P(A) + P(B) - P(A and B) = 11/36 = 0.305556 correct
counting |A union B| directly = 11/36 <- agrees
ok: A has 6 outcomes
ok: B has 6 outcomes
ok: A and B overlap in exactly one outcome
ok: the naive sum is 1/3
ok: the true union is 11/36
ok: counting the union directly agrees with the formula
ok: the naive sum overstates the truth by exactly P(A and B)
ok: the naive sum is measurably wrong
02_addition_rule.py: every assertion held. (8 checks)
03-de-mere.txt
Bet 1: at least one 6 in 4 rolls of one die
------------------------------------------------------------
P(no 6 in one roll) = 5/6
P(no 6 in all 4 rolls) = 5/6^4 = 625/1296
P(at least one 6) = 1 - 625/1296 = 671/1296
= 0.5177469136 ~ 0.5177
ok: at_least_one() agrees with the step-by-step derivation
ok: bet 1 rounds to 0.5177
ok: bet 1 matches dataset.py's exact value
Bet 2: at least one double-six in 24 rolls of two dice
------------------------------------------------------------
P(no double-six in one roll) = 35/36
P(no double-six in all 24 rolls) = 35/36^24
P(at least one double-six) = 0.4914038761 ~ 0.4914
ok: at_least_one() agrees with the step-by-step derivation
ok: bet 2 rounds to 0.4914
ok: bet 2 matches dataset.py's exact value
The two bets are NOT equally good
------------------------------------------------------------
bet 1 (one die, 4 rolls): 0.517747 -- above 0.5, favours the player
bet 2 (two dice, 24 rolls): 0.491404 -- below 0.5, favours the house
ok: bet 1 is favourable to the player
ok: bet 2 is NOT favourable to the player
ok: the two bets differ, contrary to the naive '6x the rolls' reasoning
Simulated at n = 200,000 trials per bet, seed 42
------------------------------------------------------------
bet 1: simulated 0.515865 vs exact 0.517747 (gap 0.001882, tolerance 0.003352)
bet 2: simulated 0.492350 vs exact 0.491404 (gap 0.000946, tolerance 0.003354)
ok: bet 1's simulation lands within 3 standard errors of the exact value
ok: bet 2's simulation lands within 3 standard errors of the exact value
03_de_mere.py: every assertion held. (11 checks)
04-independence-vs-dependence.txt
A genuinely independent pair
------------------------------------------------------------
A = 'sum is 7', B = 'first die is 3'
Reasoning: whatever the first die shows, exactly one value of the
second die makes the sum 7 -- so P(sum=7 | first die=v) = 1/6 for
EVERY v. Conditioning on the first die changes nothing.
sum=7: P = 1/6 first=3: P = 1/6
P(both) = 1/36 P(A) x P(B) = 1/36 independent? True
ok: P(sum=7) is 1/6
ok: P(first=3) is 1/6
ok: P(A and B) equals P(A) x P(B) exactly
ok: is_independent() reports True
A genuinely dependent pair
------------------------------------------------------------
A = 'sum is 2', B = 'first die is 1'
Reasoning: sum=2 is ONLY possible when both dice show 1 -- so
P(sum=2 | first die=1) = 1/6, but P(sum=2 | first die != 1) = 0.
Conditioning on the first die changes everything.
sum=2: P = 1/36 first=1: P = 1/6
P(both) = 1/36 P(A) x P(B) = 1/216 independent? False
ok: P(A and B) does NOT equal P(A) x P(B)
ok: is_independent() reports False
The conflation this exercise exists to prevent
------------------------------------------------------------
| Property | Independent | Mutually exclusive |
| --------------------- | --------------------- | -------------------------- |
| P(A and B) | P(A) x P(B), generally > 0 | exactly 0 |
| knowing A happened | tells you nothing about B | tells you B did NOT happen |
| can both have P > 0? | yes | yes, but never together |
independent events with non-zero probability CAN both happen at once.
mutually exclusive events with non-zero probability NEVER can --
which, as exercise 5 shows next, makes them dependent.
04_independence_vs_dependence.py: every assertion held. (6 checks)
05-mutual-exclusivity-implies-dependence.txt
A = 'sum is 2' (only (1,1)), B = 'sum is 12' (only (6,6))
------------------------------------------------------------
A = [(1, 1)]
B = [(6, 6)]
A and B = [] <- empty. The dice cannot sum to both 2 and 12.
P(A) = 1/36, P(B) = 1/36, P(A and B) = 0
P(A | B) = P(A and B) / P(B) = 0 / 1/36 = 0
but P(A) on its own is 1/36
P(A | B) != P(A) -> knowing B happened changed what you believe about A
-> A and B are DEPENDENT, despite being unable to co-occur
ok: A and B are mutually exclusive: their intersection is empty
ok: P(A) is non-zero
ok: P(B) is non-zero
ok: P(A | B) is exactly zero
ok: P(A | B) does not equal P(A) -- the events are dependent
Contrast this with the independent pair from exercise 4
------------------------------------------------------------
independent pair: P(sum=7 | first=3) = 1/6 = P(sum=7) = 1/6
ok: for a genuinely independent pair, conditioning changes nothing
ok: mutual exclusivity and independence pull P(A | B) in opposite directions here: one collapses it to 0, the other leaves it unchanged
05_mutual_exclusivity_implies_dependence.py: every assertion held. (7 checks)
06-conditioning-by-restriction.txt
Method 1: the formula, P(A | B) = P(A and B) / P(B)
------------------------------------------------------------
A and B = [(2, 6), (4, 4), (6, 2)] P(A and B) = 1/12
B = [(2, 1), (2, 2), (2, 3), (2, 4), (2, 5), (2, 6), (4, 1), (4, 2), (4, 3), (4, 4), (4, 5), (4, 6), (6, 1), (6, 2), (6, 3), (6, 4), (6, 5), (6, 6)] P(B) = 1/2
P(A | B) = 1/12 / 1/2 = 1/6
Method 2: restriction -- throw away every row where B is false
------------------------------------------------------------
restricted sample space (first die even): 18 outcomes
of those, sum == 8: [(2, 6), (4, 4), (6, 2)]
P(A | B) by filtering = 3/18 = 1/6
ok: both methods give exactly 1/6
ok: the two methods agree exactly, not approximately
Compare against the UNCONDITIONED probability
------------------------------------------------------------
P(sum == 8), no conditioning: 5/36
P(sum == 8 | first die even): 1/6
ok: conditioning on 'first die even' changed the probability of sum=8
06_conditioning_by_restriction.py: every assertion held. (3 checks)
07-law-of-total-probability.txt
The setup
------------------------------------------------------------
urn 1: 3 red, 7 blue
urn 2: 6 red, 4 blue
P(urn 1) = P(urn 2) = 1/2, chosen by a fair coin
Method 1: the law of total probability
------------------------------------------------------------
P(urn 1) x P(red | urn 1) = 1/2 x 3/10 = 3/20
P(urn 2) x P(red | urn 2) = 1/2 x 3/5 = 3/10
P(red) = sum of those = 9/20 = 0.45
Method 2: enumerate the combined 20-outcome experiment directly
------------------------------------------------------------
combined space: 20 equally likely (urn, ball) pairs
9 of them are red: P(red) = 9/20 = 9/20
ok: the weighted total is exactly 9/20
ok: the enumeration agrees exactly with the weighted total
ok: both equal 0.45
A structural note for Day 115
------------------------------------------------------------
P(red) was built by summing P(urn) x P(red | urn) over every urn.
Bayes' theorem asks the reverse question -- given that the ball WAS
red, how likely is it that it came from urn 2? -- and its denominator
is exactly this rule, computed for the event that was observed.
07_law_of_total_probability.py: every assertion held. (3 checks)
08-monte-carlo-error-scaling.txt
True probability: P(sum == 7) = 1/6 = 0.166667
Averaging over 20 seeds at each sample size
------------------------------------------------------------
n = 100 mean |error| = 0.023000 predicted standard error = 0.037268
n = 1,000 mean |error| = 0.011300 predicted standard error = 0.011785
n = 10,000 mean |error| = 0.003260 predicted standard error = 0.003727
n = 100,000 mean |error| = 0.000977 predicted standard error = 0.001179
Does the error shrink, and does it shrink like 1/sqrt(n)?
------------------------------------------------------------
n grew by a factor of 1000 (from 100 to 100,000)
the mean error shrank by a factor of 23.55
a 1/sqrt(n) law predicts a shrink of 31.62x
a 1/n law predicts a shrink of 1000x
observed shrink (23.55x) sits far closer to the sqrt(n) prediction than the 1/n one
ok: the error is monotonically smaller at every larger n
ok: the error at the largest n is well below the error at the smallest n
ok: the observed shrink lands within a factor of 3 of the sqrt(n) prediction
ok: the observed shrink is nowhere near the 1/n prediction
08_monte_carlo_error_scaling.py: every assertion held. (4 checks)
09-reproducibility.txt
Two independent Generators, same seed (42)
------------------------------------------------------------
run 1: 0.1656
run 2: 0.1656
ok: the same seed gives byte-identical results
A different seed (43)
------------------------------------------------------------
seed 42: 0.1656
seed 43: 0.1711
ok: a different seed gives a different result
Both still estimate the true probability, 0.166667, within tolerance
(4 standard errors at n = 10,000: 0.014907)
seed 42: gap 0.001067
seed 43: gap 0.004433
ok: seed A's estimate is within tolerance of the true value
ok: seed B's estimate is within tolerance of the true value
Why this matters, and why numpy.random.seed is the wrong tool
------------------------------------------------------------
numpy.random.seed(n) mutates ONE GLOBAL state shared by every piece
of code that calls numpy.random.* -- importing a library that seeds
it, or calling a function twice, silently changes your results.
default_rng(seed) hands back an independent object: two Generators
never interfere with each other, and reproducibility does not
depend on what else your program happened to do first.
09_reproducibility.py: every assertion held. (4 checks)
FIELDS.md
# What may legitimately differ on your machine
Captured from a real run on 2026-08-17: macOS 26.5.2 (Apple Silicon, arm64),
Python 3.14.0, numpy 2.5.2, pytest 9.1.1, bash 3.2.57.
## Will not differ
- Every exact-probability figure computed with `fractions.Fraction`:
`P(sum == 7) = 1/6`, the addition-rule naive sum `1/3` and true union
`11/36`, de Méré's two exact bets, both independence checks, the mutual
exclusivity result, the conditioning result, and the urn total `9/20`.
These are rational arithmetic over a fixed 36-outcome or 20-outcome space
and are identical on every machine and every Python version that
implements `Fraction` correctly.
- The count of reference tests collected (93) and the starter suite's
pass/skip counts on an untouched checkout (3 passed, 43 skipped).
- The check count in the harness (57) and the failure count (0) on a clean
run.
## Will differ, and by how much
- **The simulated values in `03_de_mere.py`, `08_monte_carlo_error_scaling.py`
and `09_reproducibility.py`.** These come from `numpy.random.default_rng`,
and while a *given* seed on a *given* NumPy version produces the same
sequence everywhere, the exact figures printed in these captured files
(e.g. `0.515865` for de Méré's bet 1) are not asserted anywhere in the test
suite — the suite asserts that they land within a stated tolerance of the
exact value, and that tolerance is what travels, not the figure.
- **Wall-clock timings** are not printed or compared anywhere; the whole
suite finishes in well under a second and nothing here is a benchmark.
- **`platform` and `exe`** in section 1's output reflect the machine running
the harness, not this one.
## The two tolerances, and where they come from
| Comparison | Tolerance | Derivation |
| --- | --- | --- |
| A simulated de Méré probability against its exact `Fraction` value | `3 x sqrt(p(1-p) / 200,000)` — about `0.00335` for both bets | Three standard errors of a proportion estimated from 200,000 independent trials. About 99.7% of honest simulation runs land inside this band; the harness runs once and accepts the small chance of landing outside it, same as any single Monte Carlo check would. |
| A reproducibility-seed estimate against the true `1/6` | `4 x sqrt(p(1-p) / 10,000)` — about `0.0149` | Four standard errors at 10,000 trials, a wider margin because this check runs on only two seeds rather than being averaged, and a false failure here would be a distracting false alarm rather than a meaningful one. |
Both are derived from the formula, not chosen by running the suite and
loosening a number until it passed — the arithmetic is written out in
`examples/dataset.py` beside each constant.
## The Monte Carlo error-scaling numbers
`08_monte_carlo_error_scaling.py` reports the *shape* of a trend averaged
over 20 seeds at each of four sample sizes, and only the shape is asserted:
monotonically decreasing error, a decrease of more than 5x from n=100 to
n=100,000, and a shrink factor within 3x of the `sqrt(n)` prediction rather
than anywhere near the `n` prediction. The exact mean-error figures — in the
captured run, `0.023000` at n=100 falling to `0.000977` at n=100,000, a
23.55x shrink against a `sqrt(1000) = 31.62x` prediction — will differ
slightly on another NumPy version or another CPU's random-number stream, but
the shape claims are what the tests check and they are robust across seeds
by construction.
reference-tests.txt
........................................................................ [ 77%]
..................... [100%]
93 passed in 0.26s
starter-progress.txt
.sssssssssssss.ssssssssssssssssssssssssssssss. [100%]
3 passed, 43 skipped in 0.06s
test-run.txt
Day 113 — Probability You Can Count
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_sample_space_and_events.py exits 0
ok: 01_sample_space_and_events.py reports every assertion held
ok: 02_addition_rule.py exits 0
ok: 02_addition_rule.py reports every assertion held
ok: 03_de_mere.py exits 0
ok: 03_de_mere.py reports every assertion held
ok: 04_independence_vs_dependence.py exits 0
ok: 04_independence_vs_dependence.py reports every assertion held
ok: 05_mutual_exclusivity_implies_dependence.py exits 0
ok: 05_mutual_exclusivity_implies_dependence.py reports every assertion held
ok: 06_conditioning_by_restriction.py exits 0
ok: 06_conditioning_by_restriction.py reports every assertion held
ok: 07_law_of_total_probability.py exits 0
ok: 07_law_of_total_probability.py reports every assertion held
ok: 08_monte_carlo_error_scaling.py exits 0
ok: 08_monte_carlo_error_scaling.py reports every assertion held
ok: 09_reproducibility.py exits 0
ok: 09_reproducibility.py reports every assertion held
3. The reference pytest suite: real values, real exceptions
........................................................................ [ 77%]
..................... [100%]
93 passed in 0.25s
ok: pytest examples exits 0
ok: no test in the reference suite failed
ok: the reference suite ran at least 80 tests (ran 93)
4. The starter suite skips unattempted work instead of failing it
.sssssssssssss.ssssssssssssssssssssssssssssss. [100%]
3 passed, 43 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 sample space has 36 outcomes
ok: the naive addition-rule sum is 1/3
ok: the true union is 11/36
ok: the naive sum's error is exactly 1/36
ok: and that error equals P(A and B) exactly
ok: de Méré's single-die bet rounds to 0.5177
ok: de Méré's double-dice bet rounds to 0.4914
ok: the single-die bet favours the player
ok: the double-dice bet does NOT favour the player
ok: the single-die simulation lands within 3 standard errors
ok: the double-dice simulation lands within 3 standard errors
ok: the independent pair satisfies P(A and B) == P(A) x P(B)
ok: the dependent pair does NOT satisfy it
ok: a mutually exclusive pair has P(A | B) exactly 0
ok: so mutual exclusivity implies dependence
ok: conditioning by formula and by filtering agree exactly at 1/6
ok: the urns' weighted total probability of red is 9/20
ok: and it matches the direct enumeration of the combined experiment
ok: Monte Carlo error falls monotonically across four decades of n
ok: the observed shrink is close to the sqrt(n) prediction
ok: and far from the false 1/n prediction
ok: the same seed gives byte-identical simulated results
ok: a different seed gives a different result
ok: and both seeds still land within tolerance of the truth
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
57 checks, 0 failure(s).
Source files
examples/01_sample_space_and_events.py (2211 bytes)
"""Exercise 1 -- the sample space, events as sets, and probability as counting.
Two fair dice. Enumerate every outcome, define an event as the subset of
outcomes where something is true, and read a probability off the ratio of
two counts -- exactly, with a Fraction, never a float that might be
0.16666666666666663 instead of 1/6.
"""
from fractions import Fraction
import dataset as D
import probability as P
checks_held = []
def check(label: str, condition: bool) -> None:
checks_held.append((label, condition))
print(f" {'ok' if condition else 'FAIL'}: {label}")
print("The sample space of two dice")
print("-" * 60)
space = P.sample_space_two_dice()
print(f" built with itertools.product: {len(space)} outcomes")
print(f" first five: {space[:5]}")
check("the space has 36 outcomes", len(space) == 36)
check("it matches the outcomes dataset.py enumerates", set(space) == set(D.TWO_DICE_SPACE))
print()
print("An event is just a subset of the space")
print("-" * 60)
sum_seven = P.event(space, D.is_sum(7))
print(f" 'sum == 7': {sorted(sum_seven)}")
check("6 outcomes sum to 7", len(sum_seven) == 6)
p_sum_seven = P.probability(sum_seven, space)
print(f" P(sum == 7) = {p_sum_seven} = {float(p_sum_seven):.6f}")
check("P(sum == 7) is exactly Fraction(1, 6)", p_sum_seven == Fraction(1, 6))
check("probability() returns a Fraction, not a float", isinstance(p_sum_seven, Fraction))
double = P.event(space, D.is_double)
p_double = P.probability(double, space)
print(f" P(both dice match) = {p_double} = {float(p_double):.6f}")
check("P(double) is exactly Fraction(1, 6)", p_double == Fraction(1, 6))
p_whole = P.probability(space, space)
p_empty = P.probability(frozenset(), space)
print(f" P(whole space) = {p_whole}, P(empty set) = {p_empty}")
check("the three axioms hold here: P(space) = 1", p_whole == 1)
check("and P(empty) = 0", p_empty == 0)
check("and every probability is non-negative", p_sum_seven >= 0 and p_double >= 0)
print()
if all(ok for _, ok in checks_held):
print(f"01_sample_space_and_events.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_addition_rule.py (2395 bytes)
"""Exercise 2 -- the addition rule, and exactly how the naive sum lies.
A = "the dice sum to 7" (6 outcomes). B = "the first die shows 6" (6
outcomes). Someone in a hurry adds P(A) + P(B) and gets 1/3. The true answer
is 11/36, and the gap is exactly P(A and B) -- the one outcome, (6, 1), that
belongs to both events and got counted twice.
"""
from fractions import Fraction
import dataset as D
import probability as P
checks_held = []
def check(label: str, condition: bool) -> None:
checks_held.append((label, condition))
print(f" {'ok' if condition else 'FAIL'}: {label}")
space = P.sample_space_two_dice()
a = P.event(space, D.ADDITION_EVENT_A)
b = P.event(space, D.ADDITION_EVENT_B)
a_and_b = a & b
print("A = 'sum is 7', B = 'first die is 6'")
print("-" * 60)
print(f" A = {sorted(a)}")
print(f" B = {sorted(b)}")
print(f" A and B = {sorted(a_and_b)} <- exactly one outcome, double-counted")
p_a = P.probability(a, space)
p_b = P.probability(b, space)
p_a_and_b = P.probability(a_and_b, space)
print(f" P(A) = {p_a}, P(B) = {p_b}, P(A and B) = {p_a_and_b}")
naive = P.naive_sum(p_a, p_b)
true_union = P.addition_rule(p_a, p_b, p_a_and_b)
print()
print(f" naive P(A) + P(B) = {naive} = {float(naive):.6f} WRONG")
print(f" true P(A) + P(B) - P(A and B) = {true_union} = {float(true_union):.6f} correct")
# Verify the true union directly by counting the union set, independent of
# the formula -- a second, unrelated route to the same number.
union_by_counting = P.probability(a | b, space)
print(f" counting |A union B| directly = {union_by_counting} <- agrees")
check("A has 6 outcomes", len(a) == 6)
check("B has 6 outcomes", len(b) == 6)
check("A and B overlap in exactly one outcome", len(a_and_b) == 1)
check("the naive sum is 1/3", naive == Fraction(1, 3))
check("the true union is 11/36", true_union == Fraction(11, 36))
check("counting the union directly agrees with the formula", union_by_counting == true_union)
check(
"the naive sum overstates the truth by exactly P(A and B)",
naive - true_union == p_a_and_b,
)
check("the naive sum is measurably wrong", naive != true_union)
print()
if all(ok for _, ok in checks_held):
print(f"02_addition_rule.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_de_mere.py (4326 bytes)
"""Exercise 3 -- the Chevalier de Méré's two bets, exact and simulated.
In the 1650s de Méré believed these two bets were equally good:
bet 1: at least one 6 in 4 rolls of one die
bet 2: at least one double-six in 24 rolls of two dice
The reasoning was seductive: a double six is 1/6 as likely as a six, so
rolling 6x as many times should even things out. It does not. This script
derives both exactly with the complement rule, then confirms both by
simulation -- the exact arithmetic and the measurement have to agree, or one
of them is wrong.
"""
from fractions import Fraction
import numpy as np
import dataset as D
import probability as P
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("Bet 1: at least one 6 in 4 rolls of one die")
print("-" * 60)
# The complement rule collapses "at least one" to one line: 1 minus the
# probability that every single roll fails.
p_no_six_one_roll = P.complement(Fraction(1, 6))
p_no_six_all_four = p_no_six_one_roll**D.DE_MERE_SINGLE_ROLLS
p_bet_one = P.complement(p_no_six_all_four)
print(f" P(no 6 in one roll) = {p_no_six_one_roll}")
print(f" P(no 6 in all 4 rolls) = {p_no_six_one_roll}^4 = {p_no_six_all_four}")
print(f" P(at least one 6) = 1 - {p_no_six_all_four} = {p_bet_one}")
print(f" = {float(p_bet_one):.10f} ~ {round(float(p_bet_one), 4)}")
via_at_least_one = P.at_least_one(Fraction(1, 6), D.DE_MERE_SINGLE_ROLLS)
check("at_least_one() agrees with the step-by-step derivation", via_at_least_one == p_bet_one)
check("bet 1 rounds to 0.5177", round(float(p_bet_one), 4) == 0.5177)
check("bet 1 matches dataset.py's exact value", p_bet_one == D.DE_MERE_SINGLE_EXACT)
print()
print("Bet 2: at least one double-six in 24 rolls of two dice")
print("-" * 60)
p_no_double_one_roll = P.complement(Fraction(1, 36))
p_no_double_all_24 = p_no_double_one_roll**D.DE_MERE_DOUBLE_ROLLS
p_bet_two = P.complement(p_no_double_all_24)
print(f" P(no double-six in one roll) = {p_no_double_one_roll}")
print(f" P(no double-six in all 24 rolls) = {p_no_double_one_roll}^24")
print(f" P(at least one double-six) = {float(p_bet_two):.10f} ~ {round(float(p_bet_two), 4)}")
via_at_least_one_2 = P.at_least_one(Fraction(1, 36), D.DE_MERE_DOUBLE_ROLLS)
check("at_least_one() agrees with the step-by-step derivation", via_at_least_one_2 == p_bet_two)
check("bet 2 rounds to 0.4914", round(float(p_bet_two), 4) == 0.4914)
check("bet 2 matches dataset.py's exact value", p_bet_two == D.DE_MERE_DOUBLE_EXACT)
print()
print("The two bets are NOT equally good")
print("-" * 60)
print(f" bet 1 (one die, 4 rolls): {float(p_bet_one):.6f} -- above 0.5, favours the player")
print(f" bet 2 (two dice, 24 rolls): {float(p_bet_two):.6f} -- below 0.5, favours the house")
check("bet 1 is favourable to the player", p_bet_one > Fraction(1, 2))
check("bet 2 is NOT favourable to the player", p_bet_two < Fraction(1, 2))
check("the two bets differ, contrary to the naive '6x the rolls' reasoning", p_bet_one != p_bet_two)
print()
print(f"Simulated at n = {D.DE_MERE_SIM_TRIALS:,} trials per bet, seed 42")
print("-" * 60)
rng = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
sim_one = S.simulate_at_least_one_six(rng, D.DE_MERE_SIM_TRIALS)
sim_two = S.simulate_at_least_one_double_six(rng, D.DE_MERE_SIM_TRIALS)
print(f" bet 1: simulated {sim_one:.6f} vs exact {float(p_bet_one):.6f}"
f" (gap {abs(sim_one - float(p_bet_one)):.6f}, tolerance {D.DE_MERE_SINGLE_TOL:.6f})")
print(f" bet 2: simulated {sim_two:.6f} vs exact {float(p_bet_two):.6f}"
f" (gap {abs(sim_two - float(p_bet_two)):.6f}, tolerance {D.DE_MERE_DOUBLE_TOL:.6f})")
check(
"bet 1's simulation lands within 3 standard errors of the exact value",
abs(sim_one - float(p_bet_one)) < D.DE_MERE_SINGLE_TOL,
)
check(
"bet 2's simulation lands within 3 standard errors of the exact value",
abs(sim_two - float(p_bet_two)) < D.DE_MERE_DOUBLE_TOL,
)
print()
if all(ok for _, ok in checks_held):
print(f"03_de_mere.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_independence_vs_dependence.py (3292 bytes)
"""Exercise 4 -- independence versus dependence, the most common conflation
in the subject.
Two events are independent when P(A and B) == P(A) x P(B) -- knowing one
happened tells you nothing about the other. This script finds one genuinely
independent pair and one genuinely dependent pair inside the same sample
space, so the difference is not abstract.
"""
from fractions import Fraction
import dataset as D
import probability as P
checks_held = []
def check(label: str, condition: bool) -> None:
checks_held.append((label, condition))
print(f" {'ok' if condition else 'FAIL'}: {label}")
space = P.sample_space_two_dice()
def summarize(name_a, pred_a, name_b, pred_b):
a = P.event(space, pred_a)
b = P.event(space, pred_b)
p_a = P.probability(a, space)
p_b = P.probability(b, space)
p_ab = P.probability(a & b, space)
independent = P.is_independent(p_a, p_b, p_ab)
print(f" {name_a}: P = {p_a} {name_b}: P = {p_b}")
print(f" P(both) = {p_ab} P(A) x P(B) = {p_a * p_b} independent? {independent}")
return p_a, p_b, p_ab, independent
print("A genuinely independent pair")
print("-" * 60)
print(" A = 'sum is 7', B = 'first die is 3'")
print(" Reasoning: whatever the first die shows, exactly one value of the")
print(" second die makes the sum 7 -- so P(sum=7 | first die=v) = 1/6 for")
print(" EVERY v. Conditioning on the first die changes nothing.")
p_a1, p_b1, p_ab1, indep1 = summarize("sum=7", D.is_sum(7), "first=3", D.is_first_die(3))
check("P(sum=7) is 1/6", p_a1 == Fraction(1, 6))
check("P(first=3) is 1/6", p_b1 == Fraction(1, 6))
check("P(A and B) equals P(A) x P(B) exactly", p_ab1 == p_a1 * p_b1)
check("is_independent() reports True", indep1 is True)
print()
print("A genuinely dependent pair")
print("-" * 60)
print(" A = 'sum is 2', B = 'first die is 1'")
print(" Reasoning: sum=2 is ONLY possible when both dice show 1 -- so")
print(" P(sum=2 | first die=1) = 1/6, but P(sum=2 | first die != 1) = 0.")
print(" Conditioning on the first die changes everything.")
p_a2, p_b2, p_ab2, indep2 = summarize("sum=2", D.is_sum(2), "first=1", D.is_first_die(1))
check("P(A and B) does NOT equal P(A) x P(B)", p_ab2 != p_a2 * p_b2)
check("is_independent() reports False", indep2 is False)
print()
print("The conflation this exercise exists to prevent")
print("-" * 60)
print(" | Property | Independent | Mutually exclusive |")
print(" | --------------------- | --------------------- | -------------------------- |")
print(" | P(A and B) | P(A) x P(B), generally > 0 | exactly 0 |")
print(" | knowing A happened | tells you nothing about B | tells you B did NOT happen |")
print(" | can both have P > 0? | yes | yes, but never together |")
print(" independent events with non-zero probability CAN both happen at once.")
print(" mutually exclusive events with non-zero probability NEVER can --")
print(" which, as exercise 5 shows next, makes them dependent.")
print()
if all(ok for _, ok in checks_held):
print(f"04_independence_vs_dependence.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_mutual_exclusivity_implies_dependence.py (2861 bytes)
"""Exercise 5 -- mutually exclusive events with non-zero probability are
NECESSARILY dependent.
This is the sharpest possible case of dependence, and it is the one most
people get backwards: "mutually exclusive" sounds like it should mean
"unrelated", and it means the opposite. If A and B cannot both happen, then
knowing B happened tells you everything about A -- specifically, that A did
not happen, even though A was possible before you knew anything.
"""
from fractions import Fraction
import dataset as D
import probability as P
checks_held = []
def check(label: str, condition: bool) -> None:
checks_held.append((label, condition))
print(f" {'ok' if condition else 'FAIL'}: {label}")
space = P.sample_space_two_dice()
pred_a, pred_b = D.MUTUALLY_EXCLUSIVE_PAIR
a = P.event(space, pred_a)
b = P.event(space, pred_b)
print("A = 'sum is 2' (only (1,1)), B = 'sum is 12' (only (6,6))")
print("-" * 60)
print(f" A = {sorted(a)}")
print(f" B = {sorted(b)}")
print(f" A and B = {sorted(a & b)} <- empty. The dice cannot sum to both 2 and 12.")
p_a = P.probability(a, space)
p_b = P.probability(b, space)
p_a_and_b = P.probability(a & b, space)
print(f" P(A) = {p_a}, P(B) = {p_b}, P(A and B) = {p_a_and_b}")
p_a_given_b = P.conditional(p_a_and_b, p_b)
print()
print(f" P(A | B) = P(A and B) / P(B) = {p_a_and_b} / {p_b} = {p_a_given_b}")
print(f" but P(A) on its own is {p_a}")
print(f" P(A | B) != P(A) -> knowing B happened changed what you believe about A")
print(f" -> A and B are DEPENDENT, despite being unable to co-occur")
check("A and B are mutually exclusive: their intersection is empty", len(a & b) == 0)
check("P(A) is non-zero", p_a != 0)
check("P(B) is non-zero", p_b != 0)
check("P(A | B) is exactly zero", p_a_given_b == 0)
check("P(A | B) does not equal P(A) -- the events are dependent", p_a_given_b != p_a)
print()
print("Contrast this with the independent pair from exercise 4")
print("-" * 60)
indep_a, indep_b = D.INDEPENDENT_PAIR
ia = P.event(space, indep_a)
ib = P.event(space, indep_b)
p_ia = P.probability(ia, space)
p_ib = P.probability(ib, space)
p_ia_given_ib = P.conditional(P.probability(ia & ib, space), p_ib)
print(f" independent pair: P(sum=7 | first=3) = {p_ia_given_ib} = P(sum=7) = {p_ia}")
check("for a genuinely independent pair, conditioning changes nothing", p_ia_given_ib == p_ia)
check(
"mutual exclusivity and independence pull P(A | B) in opposite "
"directions here: one collapses it to 0, the other leaves it unchanged",
p_a_given_b == 0 and p_ia_given_ib == p_ia and p_a_given_b != p_ia_given_ib,
)
print()
if all(ok for _, ok in checks_held):
print(f"05_mutual_exclusivity_implies_dependence.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_conditioning_by_restriction.py (2502 bytes)
"""Exercise 6 -- conditioning as restriction: throwing away rows.
P(sum = 8 | first die is even), computed two ways that have to agree: the
formula P(A and B) / P(B), and literally filtering the sample space down to
the rows where B is true and asking what fraction of THOSE rows satisfy A.
That second method is what conditioning IS -- not a formula to apply, but a
smaller table to look at.
"""
from fractions import Fraction
import dataset as D
import probability as P
checks_held = []
def check(label: str, condition: bool) -> None:
checks_held.append((label, condition))
print(f" {'ok' if condition else 'FAIL'}: {label}")
space = P.sample_space_two_dice()
a = P.event(space, D.CONDITIONING_EVENT_A) # sum == 8
b = P.event(space, D.CONDITIONING_EVENT_B) # first die even
print("Method 1: the formula, P(A | B) = P(A and B) / P(B)")
print("-" * 60)
p_a_and_b = P.probability(a & b, space)
p_b = P.probability(b, space)
by_formula = P.conditional(p_a_and_b, p_b)
print(f" A and B = {sorted(a & b)} P(A and B) = {p_a_and_b}")
print(f" B = {sorted(b)} P(B) = {p_b}")
print(f" P(A | B) = {p_a_and_b} / {p_b} = {by_formula}")
print()
print("Method 2: restriction -- throw away every row where B is false")
print("-" * 60)
restricted_space = b # the 18 outcomes where the first die is even
restricted_event = P.event(restricted_space, D.CONDITIONING_EVENT_A)
by_filtering = P.probability(restricted_event, restricted_space)
print(f" restricted sample space (first die even): {len(restricted_space)} outcomes")
print(f" of those, sum == 8: {sorted(restricted_event)}")
print(f" P(A | B) by filtering = {len(restricted_event)}/{len(restricted_space)} = {by_filtering}")
check("both methods give exactly 1/6", by_formula == Fraction(1, 6) and by_filtering == Fraction(1, 6))
check("the two methods agree exactly, not approximately", by_formula == by_filtering)
print()
print("Compare against the UNCONDITIONED probability")
print("-" * 60)
p_a_unconditioned = P.probability(a, space)
print(f" P(sum == 8), no conditioning: {p_a_unconditioned}")
print(f" P(sum == 8 | first die even): {by_formula}")
check(
"conditioning on 'first die even' changed the probability of sum=8",
p_a_unconditioned != by_formula,
)
print()
if all(ok for _, ok in checks_held):
print(f"06_conditioning_by_restriction.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_law_of_total_probability.py (2728 bytes)
"""Exercise 7 -- the law of total probability, two urns.
Urn 1 has 3 red and 7 blue balls. Urn 2 has 6 red and 4 blue. A fair coin
picks the urn, then a ball is drawn. What is P(red), overall? Weight each
urn's conditional probability of red by the probability of picking that urn,
and sum. Then check the answer a completely different way: enumerate the
combined 20-outcome experiment directly and count.
This rule is the one Day 115's Bayes' theorem runs backwards.
"""
from fractions import Fraction
import dataset as D
import probability as P
checks_held = []
def check(label: str, condition: bool) -> None:
checks_held.append((label, condition))
print(f" {'ok' if condition else 'FAIL'}: {label}")
print("The setup")
print("-" * 60)
print(f" urn 1: {D.URN_1_RED} red, {D.URN_1_BLUE} blue")
print(f" urn 2: {D.URN_2_RED} red, {D.URN_2_BLUE} blue")
print(f" P(urn 1) = P(urn 2) = 1/2, chosen by a fair coin")
print()
print("Method 1: the law of total probability")
print("-" * 60)
total = P.total_probability(D.URN_PRIOR, D.URN_CONDITIONAL_RED)
for i, (prior, cond) in enumerate(zip(D.URN_PRIOR, D.URN_CONDITIONAL_RED), start=1):
print(f" P(urn {i}) x P(red | urn {i}) = {prior} x {cond} = {prior * cond}")
print(f" P(red) = sum of those = {total} = {float(total)}")
print()
print("Method 2: enumerate the combined 20-outcome experiment directly")
print("-" * 60)
urn1 = ["red"] * D.URN_1_RED + ["blue"] * D.URN_1_BLUE
urn2 = ["red"] * D.URN_2_RED + ["blue"] * D.URN_2_BLUE
combined = [("urn1", ball) for ball in urn1] + [("urn2", ball) for ball in urn2]
print(f" combined space: {len(combined)} equally likely (urn, ball) pairs")
reds = [outcome for outcome in combined if outcome[1] == "red"]
enumerated = Fraction(len(reds), len(combined))
print(f" {len(reds)} of them are red: P(red) = {len(reds)}/{len(combined)} = {enumerated}")
check("the weighted total is exactly 9/20", total == Fraction(9, 20))
check("the enumeration agrees exactly with the weighted total", enumerated == total)
check("both equal 0.45", float(total) == 0.45 and float(enumerated) == 0.45)
print()
print("A structural note for Day 115")
print("-" * 60)
print(" P(red) was built by summing P(urn) x P(red | urn) over every urn.")
print(" Bayes' theorem asks the reverse question -- given that the ball WAS")
print(" red, how likely is it that it came from urn 2? -- and its denominator")
print(" is exactly this rule, computed for the event that was observed.")
print()
if all(ok for _, ok in checks_held):
print(f"07_law_of_total_probability.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_monte_carlo_error_scaling.py (3066 bytes)
"""Exercise 8 -- Monte Carlo error scaling: a hundred times the samples buys
a tenth of the error, not a hundredth.
Estimate P(two dice sum to 7) -- exactly 1/6 -- by simulation, at four sample
sizes four decades apart. The error shrinks like 1/sqrt(n): multiplying n by
10 should shrink the average error by about sqrt(10), roughly 3.16x, not by
10x. Day 117 explains why with the central limit theorem; this script only
measures that it is true.
The assertion is about the SHAPE of the trend, averaged over many seeds --
never a single sampled value, which would be flaky on someone else's machine.
"""
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}")
target = float(D.MONTE_CARLO_TARGET)
print(f"True probability: P(sum == 7) = {D.MONTE_CARLO_TARGET} = {target:.6f}")
print(f"Averaging over {len(D.MONTE_CARLO_SEEDS)} seeds at each sample size")
print("-" * 60)
mean_errors = []
for n in D.MONTE_CARLO_SAMPLE_SIZES:
errors = []
for seed in D.MONTE_CARLO_SEEDS:
rng = np.random.default_rng(seed)
estimate = S.simulate_sum_seven(rng, n)
errors.append(abs(estimate - target))
mean_error = sum(errors) / len(errors)
mean_errors.append(mean_error)
predicted_se = D.standard_error(target, n)
print(
f" n = {n:>7,} mean |error| = {mean_error:.6f} "
f"predicted standard error = {predicted_se:.6f}"
)
print()
print("Does the error shrink, and does it shrink like 1/sqrt(n)?")
print("-" * 60)
first, last = mean_errors[0], mean_errors[-1]
n_first, n_last = D.MONTE_CARLO_SAMPLE_SIZES[0], D.MONTE_CARLO_SAMPLE_SIZES[-1]
n_ratio = n_last / n_first
error_ratio = first / last
sqrt_prediction = n_ratio**0.5
linear_prediction = n_ratio
print(f" n grew by a factor of {n_ratio:.0f} (from {n_first:,} to {n_last:,})")
print(f" the mean error shrank by a factor of {error_ratio:.2f}")
print(f" a 1/sqrt(n) law predicts a shrink of {sqrt_prediction:.2f}x")
print(f" a 1/n law predicts a shrink of {linear_prediction:.0f}x")
print(f" observed shrink ({error_ratio:.2f}x) sits far closer to the sqrt(n) "
"prediction than the 1/n one")
check("the error is monotonically smaller at every larger n", all(
mean_errors[i + 1] < mean_errors[i] for i in range(len(mean_errors) - 1)
))
check("the error at the largest n is well below the error at the smallest n", last < first / 5.0)
check(
"the observed shrink lands within a factor of 3 of the sqrt(n) prediction",
0.33 * sqrt_prediction < error_ratio < 3.0 * sqrt_prediction,
)
check(
"the observed shrink is nowhere near the 1/n prediction",
error_ratio < linear_prediction / 10.0,
)
print()
if all(ok for _, ok in checks_held):
print(f"08_monte_carlo_error_scaling.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_reproducibility.py (2842 bytes)
"""Exercise 9 -- reproducibility: same seed, byte-identical results.
`numpy.random.default_rng(seed)` builds an independent, stateful Generator.
Two Generators built from the same seed produce identical sequences, so a
whole simulation reproduces exactly. Two Generators built from different
seeds diverge -- but both still land close to the true probability, because
they are both honest estimates of the same thing.
"""
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}")
target = float(D.MONTE_CARLO_TARGET)
n = D.REPRODUCIBILITY_TRIALS
print(f"Two independent Generators, same seed ({D.REPRODUCIBILITY_SEED_A})")
print("-" * 60)
rng_a1 = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
rng_a2 = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
result_a1 = S.simulate_sum_seven(rng_a1, n)
result_a2 = S.simulate_sum_seven(rng_a2, n)
print(f" run 1: {result_a1}")
print(f" run 2: {result_a2}")
check("the same seed gives byte-identical results", result_a1 == result_a2)
print()
print(f"A different seed ({D.REPRODUCIBILITY_SEED_B})")
print("-" * 60)
rng_b = np.random.default_rng(D.REPRODUCIBILITY_SEED_B)
result_b = S.simulate_sum_seven(rng_b, n)
print(f" seed {D.REPRODUCIBILITY_SEED_A}: {result_a1}")
print(f" seed {D.REPRODUCIBILITY_SEED_B}: {result_b}")
check("a different seed gives a different result", result_a1 != result_b)
tol = 4.0 * D.standard_error(target, n)
print()
print(f"Both still estimate the true probability, {target:.6f}, within tolerance")
print(f"(4 standard errors at n = {n:,}: {tol:.6f})")
print(f" seed {D.REPRODUCIBILITY_SEED_A}: gap {abs(result_a1 - target):.6f}")
print(f" seed {D.REPRODUCIBILITY_SEED_B}: gap {abs(result_b - target):.6f}")
check("seed A's estimate is within tolerance of the true value", abs(result_a1 - target) < tol)
check("seed B's estimate is within tolerance of the true value", abs(result_b - target) < tol)
print()
print("Why this matters, and why numpy.random.seed is the wrong tool")
print("-" * 60)
print(" numpy.random.seed(n) mutates ONE GLOBAL state shared by every piece")
print(" of code that calls numpy.random.* -- importing a library that seeds")
print(" it, or calling a function twice, silently changes your results.")
print(" default_rng(seed) hands back an independent object: two Generators")
print(" never interfere with each other, and reproducibility does not")
print(" depend on what else your program happened to do first.")
print()
if all(ok for _, ok in checks_held):
print(f"09_reproducibility.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/conftest.py (1104 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain modules called `probability`,
`simulate`, `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 `probability` 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 ("probability", "simulate", "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 (8380 bytes)
"""The sample space, the events, the urns, and every tolerance this lab
compares against.
Read this file. Nothing here is tuned: every probability below is either
computed by enumeration (via `itertools.product` and `fractions.Fraction`) or
derived algebraically and then checked against the enumeration in the tests.
The simulation tolerance is derived from the standard error of a proportion,
`sqrt(p(1-p)/n)`, with the arithmetic written out beside it -- not chosen by
running a test and loosening the number until it passed.
"""
import itertools
import math
from fractions import Fraction
from typing import Callable
# --------------------------------------------------------------------------
# The sample space: two fair six-sided dice, 36 equally likely outcomes
# --------------------------------------------------------------------------
#: Every ordered pair (first die, second die), each equally likely. Built by
#: enumeration, not written down as a literal list.
TWO_DICE_SPACE: tuple[tuple[int, int], ...] = tuple(
itertools.product(range(1, 7), range(1, 7))
)
assert len(TWO_DICE_SPACE) == 36
# --------------------------------------------------------------------------
# Events, as predicates over one outcome -- the enumerated set is built by
# filtering the space with these, in probability.py and in the scripts below.
# --------------------------------------------------------------------------
def is_sum(target: int) -> Callable[[tuple[int, int]], bool]:
"""An event predicate: the two dice sum to `target`."""
def predicate(outcome: tuple[int, int]) -> bool:
return outcome[0] + outcome[1] == target
return predicate
def is_first_die(value: int) -> Callable[[tuple[int, int]], bool]:
"""An event predicate: the first die shows `value`."""
def predicate(outcome: tuple[int, int]) -> bool:
return outcome[0] == value
return predicate
def first_die_even(outcome: tuple[int, int]) -> bool:
"""An event predicate: the first die shows an even number."""
return outcome[0] % 2 == 0
def is_double(outcome: tuple[int, int]) -> bool:
"""An event predicate: both dice show the same value."""
return outcome[0] == outcome[1]
# --------------------------------------------------------------------------
# The addition rule, on a concrete pair of events
# --------------------------------------------------------------------------
#: A = "the dice sum to 7" (6 outcomes). B = "the first die shows 6" (6
#: outcomes). Their overlap is exactly one outcome, (6, 1), because a first
#: die of 6 needs a second die of 1 to sum to 7. The naive sum P(A) + P(B)
#: double-counts that one outcome, so it overstates P(A union B) by exactly
#: P(A intersect B) = 1/36.
ADDITION_EVENT_A = is_sum(7)
ADDITION_EVENT_B = is_first_die(6)
# --------------------------------------------------------------------------
# The Chevalier de Méré's two bets
# --------------------------------------------------------------------------
#: Bet 1: at least one six in four rolls of one die.
DE_MERE_SINGLE_ROLLS: int = 4
DE_MERE_SINGLE_FACES: int = 6
#: Bet 2: at least one double-six in twenty-four rolls of two dice.
DE_MERE_DOUBLE_ROLLS: int = 24
DE_MERE_DOUBLE_FACES: int = 36 # 6 x 6 possible pairs, one of which is (6, 6)
#: Exact answers, by the complement rule: 1 - P(none of the trials succeed).
#: Fraction keeps every digit; the lesson rounds these to four places.
DE_MERE_SINGLE_EXACT: Fraction = 1 - Fraction(5, 6) ** DE_MERE_SINGLE_ROLLS
DE_MERE_DOUBLE_EXACT: Fraction = 1 - Fraction(35, 36) ** DE_MERE_DOUBLE_ROLLS
assert round(float(DE_MERE_SINGLE_EXACT), 4) == 0.5177
assert round(float(DE_MERE_DOUBLE_EXACT), 4) == 0.4914
# --------------------------------------------------------------------------
# Independence and dependence: two named pairs of events in TWO_DICE_SPACE
# --------------------------------------------------------------------------
#: A genuinely independent pair. "Sum is 7" is independent of "first die is
#: 3" -- for ANY value the first die shows, exactly one value of the second
#: die makes the sum 7, so P(sum = 7 | first die = v) = 1/6 for every v, which
#: is P(sum = 7) itself. That is what independence means.
INDEPENDENT_PAIR = (is_sum(7), is_first_die(3))
#: A genuinely dependent pair. "Sum is 2" requires BOTH dice to show 1, so it
#: is only possible when the first die shows 1 -- P(sum = 2 | first die = 1)
#: = 1/6, but P(sum = 2 | first die != 1) = 0. Those are not equal, so the
#: events are dependent.
DEPENDENT_PAIR = (is_sum(2), is_first_die(1))
# --------------------------------------------------------------------------
# Mutual exclusivity implies dependence
# --------------------------------------------------------------------------
#: "Sum is 2" and "sum is 12" cannot both happen (2 needs (1,1), 12 needs
#: (6,6)), so they are mutually exclusive. Knowing one occurred makes the
#: other impossible -- the sharpest form of dependence there is.
MUTUALLY_EXCLUSIVE_PAIR = (is_sum(2), is_sum(12))
# --------------------------------------------------------------------------
# Conditioning by restriction
# --------------------------------------------------------------------------
#: P(sum = 8 | first die is even), computed two ways in the lab: by the
#: formula P(A and B) / P(B), and by filtering TWO_DICE_SPACE down to the
#: rows where the first die is even and asking what fraction of THOSE rows
#: sum to 8.
CONDITIONING_EVENT_A = is_sum(8)
CONDITIONING_EVENT_B = first_die_even
# --------------------------------------------------------------------------
# The law of total probability: two urns, drawn from with a fair coin
# --------------------------------------------------------------------------
#: Urn 1: 3 red, 7 blue, out of 10 balls.
URN_1_RED: int = 3
URN_1_BLUE: int = 7
#: Urn 2: 6 red, 4 blue, out of 10 balls.
URN_2_RED: int = 6
URN_2_BLUE: int = 4
assert URN_1_RED + URN_1_BLUE == 10
assert URN_2_RED + URN_2_BLUE == 10
#: A fair coin decides which urn to draw from -- P(urn 1) = P(urn 2) = 1/2.
URN_PRIOR: tuple[Fraction, Fraction] = (Fraction(1, 2), Fraction(1, 2))
#: P(red | urn 1) and P(red | urn 2), read straight off each urn's contents.
URN_CONDITIONAL_RED: tuple[Fraction, Fraction] = (
Fraction(URN_1_RED, URN_1_RED + URN_1_BLUE),
Fraction(URN_2_RED, URN_2_RED + URN_2_BLUE),
)
# --------------------------------------------------------------------------
# Monte Carlo error scaling
# --------------------------------------------------------------------------
#: The event whose probability the Monte Carlo experiment estimates: two
#: fair dice sum to 7. The exact answer is 1/6, established in exercise 1.
MONTE_CARLO_TARGET: Fraction = Fraction(1, 6)
#: Sample sizes to sweep, four decades apart at the ends.
MONTE_CARLO_SAMPLE_SIZES: tuple[int, ...] = (100, 1_000, 10_000, 100_000)
#: Independent seeds to average over at each sample size, so the assertion is
#: about the SHAPE of the error trend across many runs rather than about one
#: sampled value, which would be flaky on someone else's machine.
MONTE_CARLO_SEEDS: tuple[int, ...] = tuple(range(20))
def standard_error(p: float, n: int) -> float:
"""The standard error of a proportion estimated from n trials.
This is the quantity that governs how far a Monte Carlo estimate can be
expected to land from the true probability it estimates: about 68% of
estimates land within one standard error, and about 99.7% within three.
"""
return math.sqrt(p * (1.0 - p) / n)
#: Three standard errors, at the sample sizes de Méré's simulations use in
#: exercise 3. This is not a guessed tolerance: it is the width inside which
#: 99.7% of repeated simulations should land, derived from the formula above.
DE_MERE_SIM_TRIALS: int = 200_000
DE_MERE_SINGLE_SE: float = standard_error(float(DE_MERE_SINGLE_EXACT), DE_MERE_SIM_TRIALS)
DE_MERE_DOUBLE_SE: float = standard_error(float(DE_MERE_DOUBLE_EXACT), DE_MERE_SIM_TRIALS)
DE_MERE_SINGLE_TOL: float = 3.0 * DE_MERE_SINGLE_SE
DE_MERE_DOUBLE_TOL: float = 3.0 * DE_MERE_DOUBLE_SE
# --------------------------------------------------------------------------
# Reproducibility
# --------------------------------------------------------------------------
REPRODUCIBILITY_SEED_A: int = 42
REPRODUCIBILITY_SEED_B: int = 43
REPRODUCIBILITY_TRIALS: int = 10_000
examples/probability.py (3731 bytes)
"""Exercise 1, 2, 4, 5, 6 and 7: exact probability, computed two ways.
Every function here returns a `fractions.Fraction` wherever the answer is
rational, so an assertion against it is exact rather than "close enough".
"""
import itertools
from fractions import Fraction
from typing import Callable, Iterable
# ---------------------------------------------------------------------------
# Exercise 1: the sample space, and probability as counting
# ---------------------------------------------------------------------------
def sample_space_two_dice() -> tuple[tuple[int, int], ...]:
"""Every ordered pair (first die, second die), 36 outcomes."""
return tuple(itertools.product(range(1, 7), range(1, 7)))
def event(
space: Iterable[tuple[int, int]], predicate: Callable[[tuple[int, int]], bool]
) -> frozenset[tuple[int, int]]:
"""The subset of `space` for which `predicate` is true."""
return frozenset(outcome for outcome in space if predicate(outcome))
def probability(
outcome_set: Iterable[tuple[int, int]], space: Iterable[tuple[int, int]]
) -> Fraction:
"""P(event), for equally likely outcomes: |event| / |space|, exactly."""
outcome_set = frozenset(outcome_set)
space = tuple(space)
return Fraction(len(outcome_set), len(space))
# ---------------------------------------------------------------------------
# Exercise 2: the addition rule
# ---------------------------------------------------------------------------
def addition_rule(p_a: Fraction, p_b: Fraction, p_a_and_b: Fraction) -> Fraction:
"""P(A or B) = P(A) + P(B) - P(A and B)."""
return p_a + p_b - p_a_and_b
def naive_sum(p_a: Fraction, p_b: Fraction) -> Fraction:
"""The wrong shortcut: P(A) + P(B), which double-counts the overlap."""
return p_a + p_b
# ---------------------------------------------------------------------------
# Exercise 3 helper: the complement rule
# ---------------------------------------------------------------------------
def complement(p: Fraction) -> Fraction:
"""P(not A) = 1 - P(A)."""
return 1 - p
def at_least_one(p_single_success: Fraction, trials: int) -> Fraction:
"""P(at least one success in `trials` independent tries).
Collapsed to one line with the complement rule: 1 minus the probability
that every single trial fails.
"""
p_single_failure = complement(p_single_success)
return complement(p_single_failure**trials)
# ---------------------------------------------------------------------------
# Exercise 4: independence
# ---------------------------------------------------------------------------
def is_independent(p_a: Fraction, p_b: Fraction, p_a_and_b: Fraction) -> bool:
"""True exactly when P(A and B) == P(A) * P(B)."""
return p_a_and_b == p_a * p_b
# ---------------------------------------------------------------------------
# Exercise 5 and 6: conditional probability
# ---------------------------------------------------------------------------
def conditional(p_a_and_b: Fraction, p_b: Fraction) -> Fraction:
"""P(A | B) = P(A and B) / P(B)."""
if p_b == 0:
raise ValueError("conditional probability given a zero-probability event")
return p_a_and_b / p_b
# ---------------------------------------------------------------------------
# Exercise 7: the law of total probability
# ---------------------------------------------------------------------------
def total_probability(
priors: Iterable[Fraction], conditionals: Iterable[Fraction]
) -> Fraction:
"""P(A) = sum over i of P(A | condition_i) * P(condition_i)."""
total = Fraction(0)
for prior, cond in zip(priors, conditionals):
total += prior * cond
return total
examples/simulate.py (1828 bytes)
"""Exercise 3, 8 and 9: simulation, and the error that comes with it.
Every simulation here takes an explicit `numpy.random.Generator` -- built by
`numpy.random.default_rng(seed)` -- rather than touching global random state.
Two calls with the same seed return byte-identical arrays; that is
`default_rng`'s whole advantage over the legacy `numpy.random.seed` global,
and exercise 9 asserts it directly.
"""
import numpy as np
# ---------------------------------------------------------------------------
# Exercise 3: de Méré's two bets, simulated
# ---------------------------------------------------------------------------
def simulate_at_least_one_six(rng: np.random.Generator, trials: int) -> float:
"""Simulate de Méré's first bet: at least one 6 in 4 rolls of one die."""
rolls = rng.integers(1, 7, size=(trials, 4))
at_least_one = (rolls == 6).any(axis=1)
return float(np.mean(at_least_one))
def simulate_at_least_one_double_six(rng: np.random.Generator, trials: int) -> float:
"""Simulate de Méré's second bet: at least one double-six in 24 rolls of
two dice."""
first = rng.integers(1, 7, size=(trials, 24))
second = rng.integers(1, 7, size=(trials, 24))
double_six = (first == 6) & (second == 6)
at_least_one = double_six.any(axis=1)
return float(np.mean(at_least_one))
# ---------------------------------------------------------------------------
# Exercise 8: Monte Carlo error scaling
# ---------------------------------------------------------------------------
def simulate_sum_seven(rng: np.random.Generator, trials: int) -> float:
"""Simulate P(two fair dice sum to 7) by rolling `trials` pairs of dice."""
first = rng.integers(1, 7, size=trials)
second = rng.integers(1, 7, size=trials)
return float(np.mean(first + second == 7))
examples/test_reference.py (16527 bytes)
"""The reference suite: real values, real exceptions, real simulations.
Run from the lab directory:
.venv/bin/pytest examples -q -p no:cacheprovider
"""
import itertools
from fractions import Fraction
import numpy as np
import pytest
import dataset as D
import probability as P
import simulate as S
SPACE = P.sample_space_two_dice()
# ---------------------------------------------------------------------------
# Exercise 1 -- the sample space and probability as counting
# ---------------------------------------------------------------------------
def test_sample_space_has_36_outcomes():
assert len(SPACE) == 36
def test_sample_space_has_no_duplicates():
assert len(set(SPACE)) == len(SPACE)
def test_sample_space_matches_direct_itertools_product():
assert set(SPACE) == set(itertools.product(range(1, 7), range(1, 7)))
def test_event_of_sum_seven_has_six_outcomes():
ev = P.event(SPACE, D.is_sum(7))
assert ev == frozenset({(1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1)})
def test_event_of_sum_two_has_one_outcome():
ev = P.event(SPACE, D.is_sum(2))
assert ev == frozenset({(1, 1)})
def test_event_of_sum_twelve_has_one_outcome():
ev = P.event(SPACE, D.is_sum(12))
assert ev == frozenset({(6, 6)})
@pytest.mark.parametrize(
"target,count",
[(2, 1), (3, 2), (4, 3), (5, 4), (6, 5), (7, 6), (8, 5), (9, 4), (10, 3), (11, 2), (12, 1)],
)
def test_event_counts_for_every_possible_sum(target, count):
assert len(P.event(SPACE, D.is_sum(target))) == count
def test_probability_of_sum_seven_is_exactly_one_sixth():
ev = P.event(SPACE, D.is_sum(7))
assert P.probability(ev, SPACE) == Fraction(1, 6)
def test_probability_returns_a_fraction_type():
ev = P.event(SPACE, D.is_sum(7))
assert isinstance(P.probability(ev, SPACE), Fraction)
def test_probability_of_the_whole_space_is_one():
assert P.probability(SPACE, SPACE) == 1
def test_probability_of_the_empty_event_is_zero():
assert P.probability(frozenset(), SPACE) == 0
def test_probability_of_a_double_is_one_sixth():
ev = P.event(SPACE, D.is_double)
assert P.probability(ev, SPACE) == Fraction(1, 6)
# ---------------------------------------------------------------------------
# Exercise 2 -- the addition rule
# ---------------------------------------------------------------------------
def test_addition_rule_matches_enumerated_union():
a = P.event(SPACE, D.ADDITION_EVENT_A)
b = P.event(SPACE, D.ADDITION_EVENT_B)
p_a, p_b = P.probability(a, SPACE), P.probability(b, SPACE)
p_ab = P.probability(a & b, SPACE)
formula = P.addition_rule(p_a, p_b, p_ab)
enumerated = P.probability(a | b, SPACE)
assert formula == enumerated == Fraction(11, 36)
def test_naive_sum_overstates_by_exactly_the_intersection():
a = P.event(SPACE, D.ADDITION_EVENT_A)
b = P.event(SPACE, D.ADDITION_EVENT_B)
p_a, p_b = P.probability(a, SPACE), P.probability(b, SPACE)
p_ab = P.probability(a & b, SPACE)
naive = P.naive_sum(p_a, p_b)
true_union = P.addition_rule(p_a, p_b, p_ab)
assert naive - true_union == p_ab
def test_addition_rule_reduces_to_naive_sum_for_disjoint_events():
a = P.event(SPACE, D.is_sum(2))
b = P.event(SPACE, D.is_sum(12))
assert len(a & b) == 0
p_a, p_b = P.probability(a, SPACE), P.probability(b, SPACE)
p_ab = P.probability(a & b, SPACE)
assert P.addition_rule(p_a, p_b, p_ab) == P.naive_sum(p_a, p_b)
@pytest.mark.parametrize(
"target_a,target_b",
[(4, 6), (5, 9), (7, 11)],
)
def test_addition_rule_on_further_pairs_matches_enumeration(target_a, target_b):
a = P.event(SPACE, D.is_sum(target_a))
b = P.event(SPACE, D.is_sum(target_b))
p_a, p_b = P.probability(a, SPACE), P.probability(b, SPACE)
p_ab = P.probability(a & b, SPACE)
assert P.addition_rule(p_a, p_b, p_ab) == P.probability(a | b, SPACE)
# ---------------------------------------------------------------------------
# Exercise 3 -- the complement rule and de Méré, exact
# ---------------------------------------------------------------------------
def test_complement_of_a_sixth_is_five_sixths():
assert P.complement(Fraction(1, 6)) == Fraction(5, 6)
def test_complement_is_its_own_inverse():
p = Fraction(7, 20)
assert P.complement(P.complement(p)) == p
def test_complement_of_zero_is_one():
assert P.complement(Fraction(0)) == 1
def test_complement_of_one_is_zero():
assert P.complement(Fraction(1)) == 0
def test_at_least_one_of_zero_trials_is_zero():
assert P.at_least_one(Fraction(1, 6), 0) == 0
def test_at_least_one_of_a_certain_event_is_certain():
assert P.at_least_one(Fraction(1, 1), 5) == 1
def test_de_mere_bet_one_matches_dataset_exact_value():
assert P.at_least_one(Fraction(1, 6), D.DE_MERE_SINGLE_ROLLS) == D.DE_MERE_SINGLE_EXACT
def test_de_mere_bet_two_matches_dataset_exact_value():
assert P.at_least_one(Fraction(1, 36), D.DE_MERE_DOUBLE_ROLLS) == D.DE_MERE_DOUBLE_EXACT
def test_de_mere_bet_one_rounds_to_the_historical_figure():
assert round(float(D.DE_MERE_SINGLE_EXACT), 4) == 0.5177
def test_de_mere_bet_two_rounds_to_the_historical_figure():
assert round(float(D.DE_MERE_DOUBLE_EXACT), 4) == 0.4914
def test_de_mere_bet_one_favours_the_player():
assert D.DE_MERE_SINGLE_EXACT > Fraction(1, 2)
def test_de_mere_bet_two_does_not_favour_the_player():
assert D.DE_MERE_DOUBLE_EXACT < Fraction(1, 2)
def test_de_mere_bets_are_not_equal_despite_the_six_to_one_scaling():
# 24 = 6 x 4, matching the 6x smaller probability of a double six --
# and the two bets are still not equal. That gap IS the lesson.
assert D.DE_MERE_SINGLE_EXACT != D.DE_MERE_DOUBLE_EXACT
def test_de_mere_simulation_bet_one_within_three_standard_errors():
rng = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
got = S.simulate_at_least_one_six(rng, D.DE_MERE_SIM_TRIALS)
assert abs(got - float(D.DE_MERE_SINGLE_EXACT)) < D.DE_MERE_SINGLE_TOL
def test_de_mere_simulation_bet_two_within_three_standard_errors():
rng = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
got = S.simulate_at_least_one_double_six(rng, D.DE_MERE_SIM_TRIALS)
assert abs(got - float(D.DE_MERE_DOUBLE_EXACT)) < D.DE_MERE_DOUBLE_TOL
@pytest.mark.parametrize("seed", [1, 2, 3, 4, 5])
def test_de_mere_simulation_bet_one_is_stable_across_seeds(seed):
rng = np.random.default_rng(seed)
got = S.simulate_at_least_one_six(rng, D.DE_MERE_SIM_TRIALS)
assert abs(got - float(D.DE_MERE_SINGLE_EXACT)) < D.DE_MERE_SINGLE_TOL
@pytest.mark.parametrize("seed", [1, 2, 3, 4, 5])
def test_de_mere_simulation_bet_two_is_stable_across_seeds(seed):
rng = np.random.default_rng(seed)
got = S.simulate_at_least_one_double_six(rng, D.DE_MERE_SIM_TRIALS)
assert abs(got - float(D.DE_MERE_DOUBLE_EXACT)) < D.DE_MERE_DOUBLE_TOL
def test_simulate_at_least_one_six_returns_a_plain_float():
rng = np.random.default_rng(0)
got = S.simulate_at_least_one_six(rng, 1000)
assert isinstance(got, float)
assert 0.0 <= got <= 1.0
def test_simulate_at_least_one_double_six_returns_a_plain_float():
rng = np.random.default_rng(0)
got = S.simulate_at_least_one_double_six(rng, 1000)
assert isinstance(got, float)
assert 0.0 <= got <= 1.0
# ---------------------------------------------------------------------------
# Exercise 4 -- independence
# ---------------------------------------------------------------------------
def _pair_stats(pred_a, pred_b):
a = P.event(SPACE, pred_a)
b = P.event(SPACE, pred_b)
p_a, p_b = P.probability(a, SPACE), P.probability(b, SPACE)
p_ab = P.probability(a & b, SPACE)
return p_a, p_b, p_ab
def test_the_independent_pair_satisfies_the_product_rule_exactly():
p_a, p_b, p_ab = _pair_stats(*D.INDEPENDENT_PAIR)
assert p_ab == p_a * p_b
assert P.is_independent(p_a, p_b, p_ab) is True
def test_the_dependent_pair_fails_the_product_rule():
p_a, p_b, p_ab = _pair_stats(*D.DEPENDENT_PAIR)
assert p_ab != p_a * p_b
assert P.is_independent(p_a, p_b, p_ab) is False
def test_is_independent_returns_a_python_bool():
p_a, p_b, p_ab = _pair_stats(*D.INDEPENDENT_PAIR)
assert isinstance(P.is_independent(p_a, p_b, p_ab), bool)
@pytest.mark.parametrize("first_die_value", [1, 2, 4, 5, 6])
def test_sum_seven_is_independent_of_every_value_of_the_first_die(first_die_value):
# The independence of "sum = 7" from the first die is not special to the
# value 3 -- it holds for every value, because exactly one second-die
# value completes the sum to 7 regardless.
p_a, p_b, p_ab = _pair_stats(D.is_sum(7), D.is_first_die(first_die_value))
assert p_ab == p_a * p_b
# ---------------------------------------------------------------------------
# Exercise 5 -- mutual exclusivity implies dependence
# ---------------------------------------------------------------------------
def test_mutually_exclusive_pair_has_empty_intersection():
pred_a, pred_b = D.MUTUALLY_EXCLUSIVE_PAIR
a, b = P.event(SPACE, pred_a), P.event(SPACE, pred_b)
assert (a & b) == frozenset()
def test_mutually_exclusive_pair_has_zero_conditional_probability():
pred_a, pred_b = D.MUTUALLY_EXCLUSIVE_PAIR
a, b = P.event(SPACE, pred_a), P.event(SPACE, pred_b)
p_ab = P.probability(a & b, SPACE)
p_b = P.probability(b, SPACE)
assert P.conditional(p_ab, p_b) == 0
def test_mutually_exclusive_pair_is_therefore_dependent():
pred_a, pred_b = D.MUTUALLY_EXCLUSIVE_PAIR
a, b = P.event(SPACE, pred_a), P.event(SPACE, pred_b)
p_a = P.probability(a, SPACE)
p_ab = P.probability(a & b, SPACE)
p_b = P.probability(b, SPACE)
p_a_given_b = P.conditional(p_ab, p_b)
assert p_a != 0
assert p_a_given_b != p_a
def test_conditional_refuses_to_divide_by_a_zero_probability_event():
with pytest.raises(ValueError):
P.conditional(Fraction(0), Fraction(0))
# ---------------------------------------------------------------------------
# Exercise 6 -- conditioning by restriction
# ---------------------------------------------------------------------------
def test_conditional_by_formula_matches_filtering_the_space():
a = P.event(SPACE, D.CONDITIONING_EVENT_A)
b = P.event(SPACE, D.CONDITIONING_EVENT_B)
p_ab = P.probability(a & b, SPACE)
p_b = P.probability(b, SPACE)
by_formula = P.conditional(p_ab, p_b)
restricted_event = P.event(b, D.CONDITIONING_EVENT_A)
by_filtering = P.probability(restricted_event, b)
assert by_formula == by_filtering == Fraction(1, 6)
@pytest.mark.parametrize("sum_target", [4, 6, 8, 10])
def test_conditioning_on_first_die_even_matches_filtering_for_several_sums(sum_target):
b = P.event(SPACE, D.first_die_even)
a = P.event(SPACE, D.is_sum(sum_target))
p_ab = P.probability(a & b, SPACE)
p_b = P.probability(b, SPACE)
by_formula = P.conditional(p_ab, p_b)
by_filtering = P.probability(P.event(b, D.is_sum(sum_target)), b)
assert by_formula == by_filtering
def test_conditioning_on_the_whole_space_changes_nothing():
a = P.event(SPACE, D.is_sum(7))
p_a = P.probability(a, SPACE)
p_a_and_space = P.probability(a & frozenset(SPACE), SPACE)
p_space = P.probability(SPACE, SPACE)
assert P.conditional(p_a_and_space, p_space) == p_a
# ---------------------------------------------------------------------------
# Exercise 7 -- the law of total probability
# ---------------------------------------------------------------------------
def test_total_probability_matches_the_documented_answer():
assert P.total_probability(D.URN_PRIOR, D.URN_CONDITIONAL_RED) == Fraction(9, 20)
def test_total_probability_matches_the_combined_enumeration():
urn1 = ["red"] * D.URN_1_RED + ["blue"] * D.URN_1_BLUE
urn2 = ["red"] * D.URN_2_RED + ["blue"] * D.URN_2_BLUE
combined = [("urn1", b) for b in urn1] + [("urn2", b) for b in urn2]
reds = [o for o in combined if o[1] == "red"]
enumerated = Fraction(len(reds), len(combined))
assert P.total_probability(D.URN_PRIOR, D.URN_CONDITIONAL_RED) == enumerated
def test_total_probability_with_a_certain_prior_reduces_to_one_conditional():
priors = (Fraction(1), Fraction(0))
conditionals = (Fraction(3, 10), Fraction(9, 10))
assert P.total_probability(priors, conditionals) == Fraction(3, 10)
def test_total_probability_of_blue_plus_red_is_one():
urn_conditional_blue = (
Fraction(D.URN_1_BLUE, 10),
Fraction(D.URN_2_BLUE, 10),
)
p_red = P.total_probability(D.URN_PRIOR, D.URN_CONDITIONAL_RED)
p_blue = P.total_probability(D.URN_PRIOR, urn_conditional_blue)
assert p_red + p_blue == 1
# ---------------------------------------------------------------------------
# Exercise 8 -- Monte Carlo error scaling
# ---------------------------------------------------------------------------
def test_standard_error_shrinks_as_n_grows():
small = D.standard_error(0.5, 100)
large = D.standard_error(0.5, 100_000)
assert large < small
def test_standard_error_of_a_certain_event_is_zero():
assert D.standard_error(1.0, 1000) == 0.0
assert D.standard_error(0.0, 1000) == 0.0
def test_standard_error_is_maximised_at_p_one_half():
assert D.standard_error(0.5, 1000) > D.standard_error(0.1, 1000)
assert D.standard_error(0.5, 1000) > D.standard_error(0.9, 1000)
def test_monte_carlo_mean_error_decreases_across_the_four_sample_sizes():
target = float(D.MONTE_CARLO_TARGET)
means = []
for n in D.MONTE_CARLO_SAMPLE_SIZES:
errors = [
abs(S.simulate_sum_seven(np.random.default_rng(seed), n) - target)
for seed in D.MONTE_CARLO_SEEDS
]
means.append(sum(errors) / len(errors))
assert all(means[i + 1] < means[i] for i in range(len(means) - 1))
def test_monte_carlo_error_shrink_is_closer_to_sqrt_n_than_to_n():
target = float(D.MONTE_CARLO_TARGET)
n_small, n_large = D.MONTE_CARLO_SAMPLE_SIZES[0], D.MONTE_CARLO_SAMPLE_SIZES[-1]
def mean_error(n):
errors = [
abs(S.simulate_sum_seven(np.random.default_rng(seed), n) - target)
for seed in D.MONTE_CARLO_SEEDS
]
return sum(errors) / len(errors)
ratio = mean_error(n_small) / mean_error(n_large)
n_ratio = n_large / n_small
sqrt_prediction = n_ratio**0.5
assert 0.33 * sqrt_prediction < ratio < 3.0 * sqrt_prediction
assert ratio < n_ratio / 10.0
def test_simulate_sum_seven_is_close_to_one_sixth_at_a_large_sample():
rng = np.random.default_rng(123)
got = S.simulate_sum_seven(rng, 500_000)
assert abs(got - 1.0 / 6.0) < 4.0 * D.standard_error(1.0 / 6.0, 500_000)
# ---------------------------------------------------------------------------
# Exercise 9 -- reproducibility
# ---------------------------------------------------------------------------
def test_same_seed_gives_byte_identical_results():
a1 = S.simulate_sum_seven(np.random.default_rng(D.REPRODUCIBILITY_SEED_A), 10_000)
a2 = S.simulate_sum_seven(np.random.default_rng(D.REPRODUCIBILITY_SEED_A), 10_000)
assert a1 == a2
def test_different_seeds_give_different_results():
a = S.simulate_sum_seven(np.random.default_rng(D.REPRODUCIBILITY_SEED_A), 10_000)
b = S.simulate_sum_seven(np.random.default_rng(D.REPRODUCIBILITY_SEED_B), 10_000)
assert a != b
def test_different_seeds_both_still_estimate_the_true_value():
target = 1.0 / 6.0
tol = 4.0 * D.standard_error(target, D.REPRODUCIBILITY_TRIALS)
for seed in (D.REPRODUCIBILITY_SEED_A, D.REPRODUCIBILITY_SEED_B):
got = S.simulate_sum_seven(np.random.default_rng(seed), D.REPRODUCIBILITY_TRIALS)
assert abs(got - target) < tol
@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
def test_reproducibility_holds_across_several_seeds(seed):
a1 = S.simulate_at_least_one_six(np.random.default_rng(seed), 5_000)
a2 = S.simulate_at_least_one_six(np.random.default_rng(seed), 5_000)
assert a1 == a2
def test_two_generators_from_the_same_seed_produce_identical_raw_draws():
rng1 = np.random.default_rng(7)
rng2 = np.random.default_rng(7)
draws1 = rng1.integers(1, 7, size=100)
draws2 = rng2.integers(1, 7, size=100)
assert np.array_equal(draws1, draws2)
def test_a_single_generator_does_not_repeat_its_own_draws():
rng = np.random.default_rng(7)
first = rng.integers(1, 7, size=100)
second = rng.integers(1, 7, size=100)
assert not np.array_equal(first, second)
metadata.yml (4180 bytes)
lesson_id: D113
day: 113
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/math-statistics-and-data/day-113-probability-events-rules-and-intuition
- 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_sample_space_and_events.py && cd ..'
- 'cd examples && ../.venv/bin/python3 02_addition_rule.py && cd ..'
- 'cd examples && ../.venv/bin/python3 03_de_mere.py && cd ..'
- 'cd examples && ../.venv/bin/python3 04_independence_vs_dependence.py && cd ..'
- 'cd examples && ../.venv/bin/python3 05_mutual_exclusivity_implies_dependence.py && cd ..'
- 'cd examples && ../.venv/bin/python3 06_conditioning_by_restriction.py && cd ..'
- 'cd examples && ../.venv/bin/python3 07_law_of_total_probability.py && cd ..'
- 'cd examples && ../.venv/bin/python3 08_monte_carlo_error_scaling.py && cd ..'
- 'cd examples && ../.venv/bin/python3 09_reproducibility.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: 30
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 -> 57 checks, 0 failure(s), exit 0; pytest examples -> 93 passed; pytest starter -> 3 passed, 43 skipped on an untouched checkout, and 46 passed against a fully solved copy of starter/ (verified by temporarily copying the reference probability.py and simulate.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). 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 addition-rule error-amount check) temporarily replaced by a deliberately wrong one, confirms the run exits non-zero with exactly one failure named in the output, and restores the original file -- so the suite is demonstrated to be capable of failing rather than merely claimed to be. Two honesty notes from this run. FIRST: scipy and pandas are not installed in this environment. scipy.stats is described from its public documentation in the lesson''s Tools section and explicitly marked as not run here; no output attributed to it anywhere in this lab or its lesson was actually produced by it. SECOND: the Monte Carlo error-scaling exercise (08) reports real, freshly measured numbers rather than fixed literals -- on this run the mean absolute error across 20 seeds fell from 0.023000 at n=100 to 0.000977 at n=100,000, a 23.55x shrink against a sqrt(1000)=31.62x prediction, comfortably within the suite''s stated band of 0.33x to 3.0x the sqrt(n) prediction and nowhere near the false 1/n prediction of 1000x; these exact figures will differ slightly on another machine or NumPy version, and the tests assert the trend shape and the tolerance band rather than these specific digits, as documented in expected-output/FIELDS.md. De Méré''s two exact probabilities, 671/1296 and the double-six figure, are exact rational arithmetic via fractions.Fraction and are therefore identical on any correct Python implementation; only the simulated confirmations of them carry machine-dependent noise, and both landed within their three-standard-error tolerance (0.00335 at 200,000 trials) on this run: bet 1 simulated at 0.515865 against an exact 0.517747, bet 2 at 0.492350 against an exact 0.491404.'
requirements/README.md (2791 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 every simulation in exercises 3, 8 and 9 — de Méré's two bets, the Monte Carlo error-scaling sweep, and the reproducibility checks. |
| `pytest` | 9.1.1 | MIT | The reference suite (93 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 5 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
Every exact-probability exercise — 1, 2, 4, 5, 6 and 7 — needs only
`itertools`, `fractions` and `statistics` from the standard library, and
none of them touch NumPy. Only the three simulation exercises (3, 8, 9) need
`numpy.random.Generator`. If NumPy is unavailable, Python's own `random`
module can stand in for the vectorised roll:
```python
import random
def simulate_sum_seven_stdlib(seed: int, trials: int) -> float:
rng = random.Random(seed)
hits = sum(
1 for _ in range(trials)
if rng.randint(1, 6) + rng.randint(1, 6) == 7
)
return hits / trials
```
It is slower — a Python-level loop instead of a vectorised NumPy call — but
it is the same statistics. `troubleshooting.md` shows the substitution for
the other two simulation functions. 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` does this job too — and considerably more, once random
variables and distributions arrive on Day 114. It is **not installed in this
environment, and no output from it is reproduced anywhere** in this lab or
its lesson. The lesson's Tools section describes it from its documentation
and marks it 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 probability this lab
computes is either counted exactly from an enumerated sample space or
estimated by a simulation you wrote yourself with the standard library and
NumPy's random module — 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 (2462 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
addition-rule error amount and de Méré's favourable bet) 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 sample space (`probability.py`)
Write `sample_space_two_dice()` with `itertools.product`, `event()` to
filter a space by a predicate, and `probability()` to return the exact
`Fraction` of an event. Assert `P(sum == 7) == Fraction(1, 6)` — not
`0.16666...`, the exact fraction.
## 2. The addition rule (`probability.py`)
`addition_rule(p_a, p_b, p_a_and_b)` and `naive_sum(p_a, p_b)`. Verify on
`A = "sum is 7"`, `B = "first die is 6"` that the naive sum is wrong by
exactly `P(A and B)`.
## 3. De Méré, exact and simulated (`probability.py`, `simulate.py`)
`complement()` and `at_least_one()` collapse both of de Méré's bets to one
line each. Then `simulate_at_least_one_six()` and
`simulate_at_least_one_double_six()` confirm both by simulation, within a
tolerance derived from the standard error of a proportion.
## 4. Independence (`probability.py`)
`is_independent(p_a, p_b, p_a_and_b)`. Confirmed against one genuinely
independent pair of dice events and one genuinely dependent pair.
## 5. Mutual exclusivity implies dependence
Uses `conditional()` from exercise 6 on a pair of mutually exclusive events.
No new function — the point is what the existing tools reveal.
## 6. Conditioning by restriction (`probability.py`)
`conditional(p_a_and_b, p_b)`. Computed by formula and by literally filtering
the sample space down to the rows where the condition holds; both must agree
exactly.
## 7. The law of total probability (`probability.py`)
`total_probability(priors, conditionals)`, checked against a direct
enumeration of the combined two-urn experiment.
## 8. Monte Carlo error scaling (`simulate.py`)
`simulate_sum_seven()`, called at four sample sizes four decades apart,
averaged over twenty seeds at each. The error should shrink like
`1/sqrt(n)`, not `1/n`.
## 9. Reproducibility (`simulate.py`)
The same `numpy.random.default_rng(seed)` gives byte-identical results
across two calls; a different seed gives a different but still-close result.
starter/answers.py (4899 bytes)
"""Exercises 1 through 9 -- eighteen 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 are the
addition-rule error amount (exercise 2) and de Méré's favourable bet
(exercise 3) -- and they only catch you if you commit first.
Every answer is a number, a Python bool, or (for one question) an int naming
a bet.
"""
ANSWERS: dict[str, object] = {
# ----------------------------------------------------------------------
# Exercise 1 -- the sample space
# ----------------------------------------------------------------------
# 1.1 How many outcomes are in the sample space of two dice?
"sample_space_size": None,
# 1.2 P(the two dice sum to 7), as a decimal.
"p_sum_seven": None,
# ----------------------------------------------------------------------
# Exercise 2 -- the addition rule
# ----------------------------------------------------------------------
# 2.1 A = "sum is 7" (6 outcomes), B = "first die is 6" (6 outcomes).
# The WRONG naive sum P(A) + P(B), as a decimal.
"addition_naive_sum": None,
# 2.2 The TRUE P(A or B), as a decimal.
"addition_true_union": None,
# 2.3 By exactly how much does the naive sum overstate the truth?
"addition_error_amount": None,
# ----------------------------------------------------------------------
# Exercise 3 -- de Méré's two bets
# ----------------------------------------------------------------------
# 3.1 P(at least one 6 in 4 rolls of one die), as a decimal.
"de_mere_single_bet_probability": None,
# 3.2 P(at least one double-six in 24 rolls of two dice), as a decimal.
"de_mere_double_bet_probability": None,
# 3.3 Which bet is favourable to the player (probability above 0.5)?
# Answer 1 or 2.
"de_mere_favorable_bet": None,
# ----------------------------------------------------------------------
# Exercise 4 -- independence
# ----------------------------------------------------------------------
# 4.1 Does P(A and B) == P(A) * P(B) hold for the INDEPENDENT_PAIR?
"independent_pair_holds": None,
# 4.2 Does it hold for the DEPENDENT_PAIR?
"dependent_pair_holds": None,
# ----------------------------------------------------------------------
# Exercise 5 -- mutual exclusivity implies dependence
# ----------------------------------------------------------------------
# 5.1 For the mutually exclusive pair (sum = 2, sum = 12), is
# P(sum=2 | sum=12) == 0 while P(sum=2) != 0 -- i.e. are they
# dependent?
"mutually_exclusive_implies_dependent": None,
# ----------------------------------------------------------------------
# Exercise 6 -- conditioning by restriction
# ----------------------------------------------------------------------
# 6.1 P(sum = 8 | first die is even), as a decimal.
"conditional_p_sum8_given_first_even": None,
# 6.2 Does the formula method and the filter-the-space method agree
# exactly?
"conditional_formula_matches_filter": None,
# ----------------------------------------------------------------------
# Exercise 7 -- the law of total probability
# ----------------------------------------------------------------------
# 7.1 P(red), across both urns, weighted by the fair coin. As a decimal.
"urn_total_probability_red": None,
# 7.2 Does the weighted-total answer match the answer from enumerating
# the combined 20-outcome experiment directly?
"urn_enumeration_matches_formula": None,
# ----------------------------------------------------------------------
# Exercise 8 -- Monte Carlo error scaling
# ----------------------------------------------------------------------
# 8.1 Does the average simulation error shrink as the sample size grows
# from 100 to 100,000?
"monte_carlo_error_shrinks_with_n": None,
# 8.2 Multiplying n by 10 should shrink the error by roughly sqrt(10),
# not by 10. Is the OBSERVED shrink much closer to sqrt(10) than
# to 10?
"monte_carlo_error_ratio_near_sqrt10": None,
# ----------------------------------------------------------------------
# Exercise 9 -- reproducibility
# ----------------------------------------------------------------------
# 9.1 Two simulations with the SAME seed: byte-identical results?
"reproducibility_same_seed_identical": None,
# 9.2 Two simulations with DIFFERENT seeds: different results that both
# still land within tolerance of the true probability?
"reproducibility_different_seed_differs": None,
}
starter/conftest.py (1104 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain modules called `probability`,
`simulate`, `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 `probability` 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 ("probability", "simulate", "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 (8380 bytes)
"""The sample space, the events, the urns, and every tolerance this lab
compares against.
Read this file. Nothing here is tuned: every probability below is either
computed by enumeration (via `itertools.product` and `fractions.Fraction`) or
derived algebraically and then checked against the enumeration in the tests.
The simulation tolerance is derived from the standard error of a proportion,
`sqrt(p(1-p)/n)`, with the arithmetic written out beside it -- not chosen by
running a test and loosening the number until it passed.
"""
import itertools
import math
from fractions import Fraction
from typing import Callable
# --------------------------------------------------------------------------
# The sample space: two fair six-sided dice, 36 equally likely outcomes
# --------------------------------------------------------------------------
#: Every ordered pair (first die, second die), each equally likely. Built by
#: enumeration, not written down as a literal list.
TWO_DICE_SPACE: tuple[tuple[int, int], ...] = tuple(
itertools.product(range(1, 7), range(1, 7))
)
assert len(TWO_DICE_SPACE) == 36
# --------------------------------------------------------------------------
# Events, as predicates over one outcome -- the enumerated set is built by
# filtering the space with these, in probability.py and in the scripts below.
# --------------------------------------------------------------------------
def is_sum(target: int) -> Callable[[tuple[int, int]], bool]:
"""An event predicate: the two dice sum to `target`."""
def predicate(outcome: tuple[int, int]) -> bool:
return outcome[0] + outcome[1] == target
return predicate
def is_first_die(value: int) -> Callable[[tuple[int, int]], bool]:
"""An event predicate: the first die shows `value`."""
def predicate(outcome: tuple[int, int]) -> bool:
return outcome[0] == value
return predicate
def first_die_even(outcome: tuple[int, int]) -> bool:
"""An event predicate: the first die shows an even number."""
return outcome[0] % 2 == 0
def is_double(outcome: tuple[int, int]) -> bool:
"""An event predicate: both dice show the same value."""
return outcome[0] == outcome[1]
# --------------------------------------------------------------------------
# The addition rule, on a concrete pair of events
# --------------------------------------------------------------------------
#: A = "the dice sum to 7" (6 outcomes). B = "the first die shows 6" (6
#: outcomes). Their overlap is exactly one outcome, (6, 1), because a first
#: die of 6 needs a second die of 1 to sum to 7. The naive sum P(A) + P(B)
#: double-counts that one outcome, so it overstates P(A union B) by exactly
#: P(A intersect B) = 1/36.
ADDITION_EVENT_A = is_sum(7)
ADDITION_EVENT_B = is_first_die(6)
# --------------------------------------------------------------------------
# The Chevalier de Méré's two bets
# --------------------------------------------------------------------------
#: Bet 1: at least one six in four rolls of one die.
DE_MERE_SINGLE_ROLLS: int = 4
DE_MERE_SINGLE_FACES: int = 6
#: Bet 2: at least one double-six in twenty-four rolls of two dice.
DE_MERE_DOUBLE_ROLLS: int = 24
DE_MERE_DOUBLE_FACES: int = 36 # 6 x 6 possible pairs, one of which is (6, 6)
#: Exact answers, by the complement rule: 1 - P(none of the trials succeed).
#: Fraction keeps every digit; the lesson rounds these to four places.
DE_MERE_SINGLE_EXACT: Fraction = 1 - Fraction(5, 6) ** DE_MERE_SINGLE_ROLLS
DE_MERE_DOUBLE_EXACT: Fraction = 1 - Fraction(35, 36) ** DE_MERE_DOUBLE_ROLLS
assert round(float(DE_MERE_SINGLE_EXACT), 4) == 0.5177
assert round(float(DE_MERE_DOUBLE_EXACT), 4) == 0.4914
# --------------------------------------------------------------------------
# Independence and dependence: two named pairs of events in TWO_DICE_SPACE
# --------------------------------------------------------------------------
#: A genuinely independent pair. "Sum is 7" is independent of "first die is
#: 3" -- for ANY value the first die shows, exactly one value of the second
#: die makes the sum 7, so P(sum = 7 | first die = v) = 1/6 for every v, which
#: is P(sum = 7) itself. That is what independence means.
INDEPENDENT_PAIR = (is_sum(7), is_first_die(3))
#: A genuinely dependent pair. "Sum is 2" requires BOTH dice to show 1, so it
#: is only possible when the first die shows 1 -- P(sum = 2 | first die = 1)
#: = 1/6, but P(sum = 2 | first die != 1) = 0. Those are not equal, so the
#: events are dependent.
DEPENDENT_PAIR = (is_sum(2), is_first_die(1))
# --------------------------------------------------------------------------
# Mutual exclusivity implies dependence
# --------------------------------------------------------------------------
#: "Sum is 2" and "sum is 12" cannot both happen (2 needs (1,1), 12 needs
#: (6,6)), so they are mutually exclusive. Knowing one occurred makes the
#: other impossible -- the sharpest form of dependence there is.
MUTUALLY_EXCLUSIVE_PAIR = (is_sum(2), is_sum(12))
# --------------------------------------------------------------------------
# Conditioning by restriction
# --------------------------------------------------------------------------
#: P(sum = 8 | first die is even), computed two ways in the lab: by the
#: formula P(A and B) / P(B), and by filtering TWO_DICE_SPACE down to the
#: rows where the first die is even and asking what fraction of THOSE rows
#: sum to 8.
CONDITIONING_EVENT_A = is_sum(8)
CONDITIONING_EVENT_B = first_die_even
# --------------------------------------------------------------------------
# The law of total probability: two urns, drawn from with a fair coin
# --------------------------------------------------------------------------
#: Urn 1: 3 red, 7 blue, out of 10 balls.
URN_1_RED: int = 3
URN_1_BLUE: int = 7
#: Urn 2: 6 red, 4 blue, out of 10 balls.
URN_2_RED: int = 6
URN_2_BLUE: int = 4
assert URN_1_RED + URN_1_BLUE == 10
assert URN_2_RED + URN_2_BLUE == 10
#: A fair coin decides which urn to draw from -- P(urn 1) = P(urn 2) = 1/2.
URN_PRIOR: tuple[Fraction, Fraction] = (Fraction(1, 2), Fraction(1, 2))
#: P(red | urn 1) and P(red | urn 2), read straight off each urn's contents.
URN_CONDITIONAL_RED: tuple[Fraction, Fraction] = (
Fraction(URN_1_RED, URN_1_RED + URN_1_BLUE),
Fraction(URN_2_RED, URN_2_RED + URN_2_BLUE),
)
# --------------------------------------------------------------------------
# Monte Carlo error scaling
# --------------------------------------------------------------------------
#: The event whose probability the Monte Carlo experiment estimates: two
#: fair dice sum to 7. The exact answer is 1/6, established in exercise 1.
MONTE_CARLO_TARGET: Fraction = Fraction(1, 6)
#: Sample sizes to sweep, four decades apart at the ends.
MONTE_CARLO_SAMPLE_SIZES: tuple[int, ...] = (100, 1_000, 10_000, 100_000)
#: Independent seeds to average over at each sample size, so the assertion is
#: about the SHAPE of the error trend across many runs rather than about one
#: sampled value, which would be flaky on someone else's machine.
MONTE_CARLO_SEEDS: tuple[int, ...] = tuple(range(20))
def standard_error(p: float, n: int) -> float:
"""The standard error of a proportion estimated from n trials.
This is the quantity that governs how far a Monte Carlo estimate can be
expected to land from the true probability it estimates: about 68% of
estimates land within one standard error, and about 99.7% within three.
"""
return math.sqrt(p * (1.0 - p) / n)
#: Three standard errors, at the sample sizes de Méré's simulations use in
#: exercise 3. This is not a guessed tolerance: it is the width inside which
#: 99.7% of repeated simulations should land, derived from the formula above.
DE_MERE_SIM_TRIALS: int = 200_000
DE_MERE_SINGLE_SE: float = standard_error(float(DE_MERE_SINGLE_EXACT), DE_MERE_SIM_TRIALS)
DE_MERE_DOUBLE_SE: float = standard_error(float(DE_MERE_DOUBLE_EXACT), DE_MERE_SIM_TRIALS)
DE_MERE_SINGLE_TOL: float = 3.0 * DE_MERE_SINGLE_SE
DE_MERE_DOUBLE_TOL: float = 3.0 * DE_MERE_DOUBLE_SE
# --------------------------------------------------------------------------
# Reproducibility
# --------------------------------------------------------------------------
REPRODUCIBILITY_SEED_A: int = 42
REPRODUCIBILITY_SEED_B: int = 43
REPRODUCIBILITY_TRIALS: int = 10_000
starter/probability.py (3940 bytes)
"""Exercise 1, 2, 4, 5, 6 and 7: exact probability, computed two ways.
Every function here returns a `fractions.Fraction` wherever the answer is
rational, so an assertion against it is exact rather than "close enough".
Fill in the bodies marked `# YOUR CODE HERE`. `dataset.py` has everything you
need: the sample space, the event predicates, and the urn compositions.
"""
import itertools
from fractions import Fraction
from typing import Callable, Iterable
# ---------------------------------------------------------------------------
# Exercise 1: the sample space, and probability as counting
# ---------------------------------------------------------------------------
def sample_space_two_dice() -> tuple[tuple[int, int], ...]:
"""Every ordered pair (first die, second die), 36 outcomes.
Build it with `itertools.product(range(1, 7), range(1, 7))` -- do not
write the 36 pairs out by hand.
"""
# YOUR CODE HERE
raise NotImplementedError
def event(
space: Iterable[tuple[int, int]], predicate: Callable[[tuple[int, int]], bool]
) -> frozenset[tuple[int, int]]:
"""The subset of `space` for which `predicate` is true."""
# YOUR CODE HERE
raise NotImplementedError
def probability(
outcome_set: Iterable[tuple[int, int]], space: Iterable[tuple[int, int]]
) -> Fraction:
"""P(event), for equally likely outcomes: |event| / |space|, exactly."""
# YOUR CODE HERE
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 2: the addition rule
# ---------------------------------------------------------------------------
def addition_rule(p_a: Fraction, p_b: Fraction, p_a_and_b: Fraction) -> Fraction:
"""P(A or B) = P(A) + P(B) - P(A and B)."""
# YOUR CODE HERE
raise NotImplementedError
def naive_sum(p_a: Fraction, p_b: Fraction) -> Fraction:
"""The wrong shortcut: P(A) + P(B), which double-counts the overlap."""
# YOUR CODE HERE
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 3 helper: the complement rule
# ---------------------------------------------------------------------------
def complement(p: Fraction) -> Fraction:
"""P(not A) = 1 - P(A)."""
# YOUR CODE HERE
raise NotImplementedError
def at_least_one(p_single_success: Fraction, trials: int) -> Fraction:
"""P(at least one success in `trials` independent tries).
Collapse it to one line with the complement rule: 1 minus the
probability that every single trial fails.
"""
# YOUR CODE HERE
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 4: independence
# ---------------------------------------------------------------------------
def is_independent(p_a: Fraction, p_b: Fraction, p_a_and_b: Fraction) -> bool:
"""True exactly when P(A and B) == P(A) * P(B)."""
# YOUR CODE HERE
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 5 and 6: conditional probability
# ---------------------------------------------------------------------------
def conditional(p_a_and_b: Fraction, p_b: Fraction) -> Fraction:
"""P(A | B) = P(A and B) / P(B)."""
# YOUR CODE HERE
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 7: the law of total probability
# ---------------------------------------------------------------------------
def total_probability(
priors: Iterable[Fraction], conditionals: Iterable[Fraction]
) -> Fraction:
"""P(A) = sum over i of P(A | condition_i) * P(condition_i).
`priors` and `conditionals` are matched by position: priors[i] is
P(condition_i), conditionals[i] is P(A | condition_i).
"""
# YOUR CODE HERE
raise NotImplementedError
starter/simulate.py (1908 bytes)
"""Exercise 3, 8 and 9: simulation, and the error that comes with it.
Every simulation here takes an explicit `numpy.random.Generator` -- built by
`numpy.random.default_rng(seed)` -- rather than touching global random state.
Two calls with the same seed must return byte-identical arrays; that is
`default_rng`'s whole advantage over the legacy `numpy.random.seed` global,
and exercise 9 asserts it directly.
"""
import numpy as np
# ---------------------------------------------------------------------------
# Exercise 3: de Méré's two bets, simulated
# ---------------------------------------------------------------------------
def simulate_at_least_one_six(rng: np.random.Generator, trials: int) -> float:
"""Simulate de Méré's first bet: at least one 6 in 4 rolls of one die.
Roll a (trials, 4) array of dice, and return the fraction of rows that
contain at least one 6. Vectorised: no Python-level loop over trials.
"""
# YOUR CODE HERE
raise NotImplementedError
def simulate_at_least_one_double_six(rng: np.random.Generator, trials: int) -> float:
"""Simulate de Méré's second bet: at least one double-six in 24 rolls of
two dice.
Roll two (trials, 24) arrays -- one per die -- and return the fraction of
rows in which some roll shows 6 on both dice at once.
"""
# YOUR CODE HERE
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 8: Monte Carlo error scaling
# ---------------------------------------------------------------------------
def simulate_sum_seven(rng: np.random.Generator, trials: int) -> float:
"""Simulate P(two fair dice sum to 7) by rolling `trials` pairs of dice.
Returns the fraction of pairs that summed to 7. The exact answer,
established in exercise 1, is exactly 1/6.
"""
# YOUR CODE HERE
raise NotImplementedError
starter/test_starter.py (16278 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.
"""
import itertools
from fractions import Fraction
import numpy as np
import pytest
import answers
import dataset as D
import probability as P
import simulate as S
# --------------------------------------------------------------------------
# The skip machinery
# --------------------------------------------------------------------------
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 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 close(got, want, tol, what):
assert abs(float(got) - float(want)) < tol, (
f"{what}: your answer {got!r}, expected {want!r} "
f"(difference {abs(float(got) - float(want)):.3e}, tolerance {tol:g})"
)
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 len(D.TWO_DICE_SPACE) == 36
# --------------------------------------------------------------------------
# Exercise 1 -- the sample space and probability as counting
# --------------------------------------------------------------------------
def test_1_sample_space_has_36_outcomes():
space = attempt(P.sample_space_two_dice, "sample_space_two_dice")
assert len(space) == 36
def test_1_sample_space_matches_the_dataset_space():
space = attempt(P.sample_space_two_dice, "sample_space_two_dice")
assert set(space) == set(D.TWO_DICE_SPACE)
def test_1_event_filters_by_predicate():
space = attempt(P.sample_space_two_dice, "sample_space_two_dice")
ev = attempt(lambda: P.event(space, D.is_sum(7)), "event")
assert len(ev) == 6
assert all(a + b == 7 for a, b in ev)
def test_1_probability_of_sum_seven_is_exactly_one_sixth():
space = attempt(P.sample_space_two_dice, "sample_space_two_dice")
ev = attempt(lambda: P.event(space, D.is_sum(7)), "event")
p = attempt(lambda: P.probability(ev, space), "probability")
assert p == Fraction(1, 6), "P(sum == 7) must be exactly Fraction(1, 6)"
def test_1_probability_returns_a_fraction():
space = attempt(P.sample_space_two_dice, "sample_space_two_dice")
ev = attempt(lambda: P.event(space, D.is_sum(7)), "event")
p = attempt(lambda: P.probability(ev, space), "probability")
assert isinstance(p, Fraction), (
"probability() must return a Fraction, not a float -- that is the "
"whole point of using it here"
)
def test_1_probability_of_the_whole_space_is_one():
space = attempt(P.sample_space_two_dice, "sample_space_two_dice")
p = attempt(lambda: P.probability(space, space), "probability")
assert p == 1
def test_1_probability_of_the_empty_set_is_zero():
space = attempt(P.sample_space_two_dice, "sample_space_two_dice")
p = attempt(lambda: P.probability(frozenset(), space), "probability")
assert p == 0
# --------------------------------------------------------------------------
# Exercise 2 -- the addition rule
# --------------------------------------------------------------------------
def _addition_setup():
space = D.TWO_DICE_SPACE
a = frozenset(o for o in space if D.ADDITION_EVENT_A(o))
b = frozenset(o for o in space if D.ADDITION_EVENT_B(o))
p_a = Fraction(len(a), len(space))
p_b = Fraction(len(b), len(space))
p_ab = Fraction(len(a & b), len(space))
return p_a, p_b, p_ab
def test_2_addition_rule_gives_the_true_union():
p_a, p_b, p_ab = _addition_setup()
got = attempt(lambda: P.addition_rule(p_a, p_b, p_ab), "addition_rule")
assert got == Fraction(11, 36)
def test_2_naive_sum_double_counts_the_overlap():
p_a, p_b, p_ab = _addition_setup()
naive = attempt(lambda: P.naive_sum(p_a, p_b), "naive_sum")
assert naive == Fraction(1, 3), "P(A) + P(B) should be 6/36 + 6/36 = 1/3"
def test_2_the_naive_sum_overstates_by_exactly_the_intersection():
p_a, p_b, p_ab = _addition_setup()
naive = attempt(lambda: P.naive_sum(p_a, p_b), "naive_sum")
true_union = attempt(lambda: P.addition_rule(p_a, p_b, p_ab), "addition_rule")
assert naive - true_union == p_ab, (
"the naive sum's error should be exactly P(A and B) = 1/36"
)
# --------------------------------------------------------------------------
# Exercise 3 -- de Méré, exact and simulated
# --------------------------------------------------------------------------
def test_3a_complement_of_one_sixth_is_five_sixths():
got = attempt(lambda: P.complement(Fraction(1, 6)), "complement")
assert got == Fraction(5, 6)
def test_3b_at_least_one_six_matches_the_exact_de_mere_answer():
got = attempt(
lambda: P.at_least_one(Fraction(1, 6), D.DE_MERE_SINGLE_ROLLS), "at_least_one"
)
assert got == D.DE_MERE_SINGLE_EXACT
def test_3b_at_least_one_double_six_matches_the_exact_de_mere_answer():
got = attempt(
lambda: P.at_least_one(Fraction(1, 36), D.DE_MERE_DOUBLE_ROLLS),
"at_least_one",
)
assert got == D.DE_MERE_DOUBLE_EXACT
def test_3c_bet_one_is_favourable_and_bet_two_is_not():
assert D.DE_MERE_SINGLE_EXACT > Fraction(1, 2)
assert D.DE_MERE_DOUBLE_EXACT < Fraction(1, 2)
def test_3d_simulation_of_bet_one_lands_within_three_standard_errors():
rng = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
got = attempt(
lambda: S.simulate_at_least_one_six(rng, D.DE_MERE_SIM_TRIALS),
"simulate_at_least_one_six",
)
close(got, float(D.DE_MERE_SINGLE_EXACT), D.DE_MERE_SINGLE_TOL, "de Méré bet 1")
def test_3d_simulation_of_bet_two_lands_within_three_standard_errors():
rng = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
got = attempt(
lambda: S.simulate_at_least_one_double_six(rng, D.DE_MERE_SIM_TRIALS),
"simulate_at_least_one_double_six",
)
close(got, float(D.DE_MERE_DOUBLE_EXACT), D.DE_MERE_DOUBLE_TOL, "de Méré bet 2")
# --------------------------------------------------------------------------
# Exercise 4 -- independence
# --------------------------------------------------------------------------
def test_4_the_independent_pair_is_reported_independent():
a_pred, b_pred = D.INDEPENDENT_PAIR
space = D.TWO_DICE_SPACE
a = frozenset(o for o in space if a_pred(o))
b = frozenset(o for o in space if b_pred(o))
p_a, p_b = Fraction(len(a), 36), Fraction(len(b), 36)
p_ab = Fraction(len(a & b), 36)
got = attempt(lambda: P.is_independent(p_a, p_b, p_ab), "is_independent")
assert got is True
def test_4_the_dependent_pair_is_reported_dependent():
a_pred, b_pred = D.DEPENDENT_PAIR
space = D.TWO_DICE_SPACE
a = frozenset(o for o in space if a_pred(o))
b = frozenset(o for o in space if b_pred(o))
p_a, p_b = Fraction(len(a), 36), Fraction(len(b), 36)
p_ab = Fraction(len(a & b), 36)
got = attempt(lambda: P.is_independent(p_a, p_b, p_ab), "is_independent")
assert got is False
# --------------------------------------------------------------------------
# Exercise 5 -- mutual exclusivity implies dependence
# --------------------------------------------------------------------------
def test_5_mutually_exclusive_events_have_zero_conditional():
a_pred, b_pred = D.MUTUALLY_EXCLUSIVE_PAIR
space = D.TWO_DICE_SPACE
a = frozenset(o for o in space if a_pred(o))
b = frozenset(o for o in space if b_pred(o))
p_a = Fraction(len(a), 36)
p_ab = Fraction(len(a & b), 36)
p_b = Fraction(len(b), 36)
got = attempt(lambda: P.conditional(p_ab, p_b), "conditional")
assert got == 0
assert p_a != 0, "P(A) must be non-zero for this to demonstrate dependence"
assert got != p_a, "P(A | B) == 0 while P(A) != 0 -- the events are dependent"
# --------------------------------------------------------------------------
# Exercise 6 -- conditioning by restriction
# --------------------------------------------------------------------------
def test_6_conditional_by_formula_matches_conditional_by_filtering():
space = D.TWO_DICE_SPACE
a = frozenset(o for o in space if D.CONDITIONING_EVENT_A(o))
b = frozenset(o for o in space if D.CONDITIONING_EVENT_B(o))
p_ab = Fraction(len(a & b), 36)
p_b = Fraction(len(b), 36)
by_formula = attempt(lambda: P.conditional(p_ab, p_b), "conditional")
restricted = frozenset(o for o in b if D.CONDITIONING_EVENT_A(o))
by_filtering = Fraction(len(restricted), len(b))
assert by_formula == by_filtering == Fraction(1, 6)
# --------------------------------------------------------------------------
# Exercise 7 -- the law of total probability
# --------------------------------------------------------------------------
def test_7_total_probability_matches_the_combined_enumeration():
got = attempt(
lambda: P.total_probability(D.URN_PRIOR, D.URN_CONDITIONAL_RED),
"total_probability",
)
assert got == Fraction(9, 20)
# Enumerate the combined 20-outcome experiment directly: 10 balls in
# each of 2 urns, chosen with equal prior, so all 20 (urn, ball) pairs
# are equally likely.
urn1 = ["red"] * D.URN_1_RED + ["blue"] * D.URN_1_BLUE
urn2 = ["red"] * D.URN_2_RED + ["blue"] * D.URN_2_BLUE
combined = [("urn1", b) for b in urn1] + [("urn2", b) for b in urn2]
reds = [o for o in combined if o[1] == "red"]
enumerated = Fraction(len(reds), len(combined))
assert got == enumerated
# --------------------------------------------------------------------------
# Exercise 8 -- Monte Carlo error scaling
# --------------------------------------------------------------------------
def test_8_error_shrinks_across_four_decades_of_sample_size():
target = float(D.MONTE_CARLO_TARGET)
mean_errors = []
for n in D.MONTE_CARLO_SAMPLE_SIZES:
errors = []
for seed in D.MONTE_CARLO_SEEDS:
rng = np.random.default_rng(seed)
got = attempt(lambda: S.simulate_sum_seven(rng, n), "simulate_sum_seven")
errors.append(abs(got - target))
mean_errors.append(sum(errors) / len(errors))
assert mean_errors[-1] < mean_errors[0], (
"the average error at the largest n must be smaller than at the "
"smallest n"
)
assert mean_errors[-1] < mean_errors[0] / 5.0, (
"a thousandfold increase in n should shrink the error by roughly "
"sqrt(1000) = ~31x, well below a factor of 5"
)
def test_8_the_shrink_looks_like_one_over_sqrt_n_not_one_over_n():
target = float(D.MONTE_CARLO_TARGET)
small_n, large_n = D.MONTE_CARLO_SAMPLE_SIZES[0], D.MONTE_CARLO_SAMPLE_SIZES[-1]
ratio_n = large_n / small_n # 1000
def mean_error(n):
errors = []
for seed in D.MONTE_CARLO_SEEDS:
rng = np.random.default_rng(seed)
got = attempt(lambda: S.simulate_sum_seven(rng, n), "simulate_sum_seven")
errors.append(abs(got - target))
return sum(errors) / len(errors)
error_ratio = mean_error(small_n) / mean_error(large_n)
sqrt_prediction = ratio_n**0.5 # ~31.6
linear_prediction = ratio_n # 1000
# The observed shrink must land far closer to the sqrt(n) prediction
# than to the 1/n prediction -- checked as a wide band, not a point, so
# the test is not flaky on a different machine.
assert 0.3 * sqrt_prediction < error_ratio < 3.0 * sqrt_prediction
assert error_ratio < linear_prediction / 10.0
# --------------------------------------------------------------------------
# Exercise 9 -- reproducibility
# --------------------------------------------------------------------------
def test_9_the_same_seed_gives_byte_identical_results():
rng_a = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
rng_b = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
result_a = attempt(
lambda: S.simulate_sum_seven(rng_a, D.REPRODUCIBILITY_TRIALS),
"simulate_sum_seven",
)
result_b = attempt(
lambda: S.simulate_sum_seven(rng_b, D.REPRODUCIBILITY_TRIALS),
"simulate_sum_seven",
)
assert result_a == result_b, (
"two Generators built from the same seed must produce identical "
"results -- that is the entire point of default_rng(seed)"
)
def test_9_a_different_seed_gives_a_different_but_still_close_result():
rng_a = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
rng_b = np.random.default_rng(D.REPRODUCIBILITY_SEED_B)
result_a = attempt(
lambda: S.simulate_sum_seven(rng_a, D.REPRODUCIBILITY_TRIALS),
"simulate_sum_seven",
)
result_b = attempt(
lambda: S.simulate_sum_seven(rng_b, D.REPRODUCIBILITY_TRIALS),
"simulate_sum_seven",
)
assert result_a != result_b, "different seeds should not coincide exactly"
target = float(D.MONTE_CARLO_TARGET)
tol = 4.0 * D.standard_error(target, D.REPRODUCIBILITY_TRIALS)
close(result_a, target, tol, "seed A")
close(result_b, target, tol, "seed B")
# --------------------------------------------------------------------------
# The eighteen predictions
# --------------------------------------------------------------------------
EXPECTED: dict[str, object] = {
"sample_space_size": 36,
"p_sum_seven": float(Fraction(1, 6)),
"addition_naive_sum": float(Fraction(1, 3)),
"addition_true_union": float(Fraction(11, 36)),
"addition_error_amount": float(Fraction(1, 36)),
"de_mere_single_bet_probability": float(D.DE_MERE_SINGLE_EXACT),
"de_mere_double_bet_probability": float(D.DE_MERE_DOUBLE_EXACT),
"de_mere_favorable_bet": 1,
"independent_pair_holds": True,
"dependent_pair_holds": False,
"mutually_exclusive_implies_dependent": True,
"conditional_p_sum8_given_first_even": float(Fraction(1, 6)),
"conditional_formula_matches_filter": True,
"urn_total_probability_red": 0.45,
"urn_enumeration_matches_formula": True,
"monte_carlo_error_shrinks_with_n": True,
"monte_carlo_error_ratio_near_sqrt10": True,
"reproducibility_same_seed_identical": True,
"reproducibility_different_seed_differs": True,
}
HINTS: dict[str, str] = {
"addition_error_amount": (
"This is exactly P(A and B), the region the naive sum counted twice."
),
"de_mere_favorable_bet": (
"A bet is favourable to the player when its probability is above "
"0.5. Only one of the two is."
),
"mutually_exclusive_implies_dependent": (
"P(A | B) collapses to 0 for a mutually exclusive pair, but P(A) is "
"not 0 -- so knowing B happened changed what you believe about A."
),
"monte_carlo_error_ratio_near_sqrt10": (
"Multiplying n by 10 should shrink the error by about sqrt(10), "
"roughly 3.16x -- not by 10x."
),
}
@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) or isinstance(want, int):
assert got == want, f"{key}: your answer {got!r}, expected {want!r}. {hint}"
else:
assert abs(float(got) - want) < 1e-9, (
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 (19265 bytes)
#!/usr/bin/env bash
# Tests for the Day 113 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:
#
# * P(two dice sum to 7) is exactly Fraction(1, 6), by enumeration;
# * the naive addition-rule sum overstates the true union by exactly
# P(A and B), and the exact size of that error is checked;
# * de Méré's two bets -- 1 - (5/6)^4 and 1 - (35/36)^24 -- are computed
# exactly and both round to the historical figures, 0.5177 and 0.4914,
# and both are confirmed by simulation within three standard errors;
# * a genuinely independent pair of dice events satisfies P(A and B) ==
# P(A) x P(B) exactly, and a genuinely dependent pair does not;
# * a mutually exclusive pair with non-zero probabilities has P(A | B) ==
# 0 while P(A) != 0, proving mutual exclusivity implies dependence;
# * conditioning by formula and conditioning by filtering the sample space
# agree exactly;
# * the law of total probability over two urns matches a direct
# enumeration of the combined experiment;
# * Monte Carlo error shrinks like 1/sqrt(n), asserted as a trend averaged
# over twenty seeds, never a single sampled value;
# * the same seed gives byte-identical simulated results, and a different
# seed gives a different but still-close one;
# * 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 113 — Probability You Can Count"
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_sample_space_and_events 02_addition_rule 03_de_mere \
04_independence_vs_dependence 05_mutual_exclusivity_implies_dependence \
06_conditioning_by_restriction 07_law_of_total_probability \
08_monte_carlo_error_scaling 09_reproducibility; 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 80 ]; then
check "the reference suite ran at least 80 tests (ran ${ref_passed})" "yes"
else
check "the reference suite ran at least 80 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 `probability`,
# `simulate`, `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'
from fractions import Fraction
import numpy as np
import dataset as D
import probability as P
import simulate as S
space = P.sample_space_two_dice()
print("space_size", len(space))
a = P.event(space, D.ADDITION_EVENT_A)
b = P.event(space, D.ADDITION_EVENT_B)
p_a, p_b = P.probability(a, space), P.probability(b, space)
p_ab = P.probability(a & b, space)
naive = P.naive_sum(p_a, p_b)
true_union = P.addition_rule(p_a, p_b, p_ab)
print("addition_naive", naive)
print("addition_true", true_union)
print("addition_error", naive - true_union)
print("addition_error_equals_intersection", (naive - true_union) == p_ab)
single = P.at_least_one(Fraction(1, 6), D.DE_MERE_SINGLE_ROLLS)
double = P.at_least_one(Fraction(1, 36), D.DE_MERE_DOUBLE_ROLLS)
print("de_mere_single_rounded", round(float(single), 4))
print("de_mere_double_rounded", round(float(double), 4))
print("de_mere_single_favourable", single > Fraction(1, 2))
print("de_mere_double_favourable", double > Fraction(1, 2))
rng = np.random.default_rng(D.REPRODUCIBILITY_SEED_A)
sim_single = S.simulate_at_least_one_six(rng, D.DE_MERE_SIM_TRIALS)
sim_double = S.simulate_at_least_one_double_six(rng, D.DE_MERE_SIM_TRIALS)
print("de_mere_single_sim_within_tol", abs(sim_single - float(single)) < D.DE_MERE_SINGLE_TOL)
print("de_mere_double_sim_within_tol", abs(sim_double - float(double)) < D.DE_MERE_DOUBLE_TOL)
ip_a, ip_b = D.INDEPENDENT_PAIR
ia, ib = P.event(space, ip_a), P.event(space, ip_b)
p_ia, p_ib = P.probability(ia, space), P.probability(ib, space)
p_iab = P.probability(ia & ib, space)
print("independent_holds", P.is_independent(p_ia, p_ib, p_iab))
dp_a, dp_b = D.DEPENDENT_PAIR
da, db = P.event(space, dp_a), P.event(space, dp_b)
p_da, p_db = P.probability(da, space), P.probability(db, space)
p_dab = P.probability(da & db, space)
print("dependent_holds", P.is_independent(p_da, p_db, p_dab))
me_a, me_b = D.MUTUALLY_EXCLUSIVE_PAIR
ma, mb = P.event(space, me_a), P.event(space, me_b)
p_ma = P.probability(ma, space)
p_mab = P.probability(ma & mb, space)
p_mb = P.probability(mb, space)
p_ma_given_mb = P.conditional(p_mab, p_mb)
print("mutually_exclusive_conditional_is_zero", p_ma_given_mb == 0)
print("mutually_exclusive_implies_dependent", p_ma_given_mb != p_ma)
ca = P.event(space, D.CONDITIONING_EVENT_A)
cb = P.event(space, D.CONDITIONING_EVENT_B)
p_ca_cb = P.probability(ca & cb, space)
p_cb = P.probability(cb, space)
by_formula = P.conditional(p_ca_cb, p_cb)
by_filtering = P.probability(P.event(cb, D.CONDITIONING_EVENT_A), cb)
print("conditioning_agrees", by_formula == by_filtering == Fraction(1, 6))
total = P.total_probability(D.URN_PRIOR, D.URN_CONDITIONAL_RED)
urn1 = ["red"] * D.URN_1_RED + ["blue"] * D.URN_1_BLUE
urn2 = ["red"] * D.URN_2_RED + ["blue"] * D.URN_2_BLUE
combined = [("u1", x) for x in urn1] + [("u2", x) for x in urn2]
reds = [o for o in combined if o[1] == "red"]
enumerated = Fraction(len(reds), len(combined))
print("urn_total", total)
print("urn_matches_enumeration", total == enumerated)
target = float(D.MONTE_CARLO_TARGET)
means = []
for n in D.MONTE_CARLO_SAMPLE_SIZES:
errors = [
abs(S.simulate_sum_seven(np.random.default_rng(seed), n) - target)
for seed in D.MONTE_CARLO_SEEDS
]
means.append(sum(errors) / len(errors))
print("mc_error_monotone_decreasing", all(means[i + 1] < means[i] for i in range(len(means) - 1)))
n_ratio = D.MONTE_CARLO_SAMPLE_SIZES[-1] / D.MONTE_CARLO_SAMPLE_SIZES[0]
error_ratio = means[0] / means[-1]
sqrt_pred = n_ratio ** 0.5
print("mc_error_ratio_near_sqrt_n", 0.3 * sqrt_pred < error_ratio < 3.0 * sqrt_pred)
print("mc_error_ratio_far_from_linear", error_ratio < n_ratio / 10.0)
a1 = S.simulate_sum_seven(np.random.default_rng(D.REPRODUCIBILITY_SEED_A), D.REPRODUCIBILITY_TRIALS)
a2 = S.simulate_sum_seven(np.random.default_rng(D.REPRODUCIBILITY_SEED_A), D.REPRODUCIBILITY_TRIALS)
b1 = S.simulate_sum_seven(np.random.default_rng(D.REPRODUCIBILITY_SEED_B), D.REPRODUCIBILITY_TRIALS)
print("same_seed_identical", a1 == a2)
print("different_seed_differs", a1 != b1)
tol = 4.0 * D.standard_error(target, D.REPRODUCIBILITY_TRIALS)
print("both_seeds_within_tolerance", abs(a1 - target) < tol and abs(b1 - target) < tol)
PY
)"
get() { printf '%s\n' "${facts}" | grep "^$1 " | cut -d' ' -f2-; }
check_eq "the sample space has 36 outcomes" "36" "$(get space_size)"
check_eq "the naive addition-rule sum is 1/3" "1/3" "$(get addition_naive)"
check_eq "the true union is 11/36" "11/36" "$(get addition_true)"
check_eq "the naive sum's error is exactly 1/36" "1/36" "$(get addition_error)"
check_eq "and that error equals P(A and B) exactly" "True" "$(get addition_error_equals_intersection)"
check_eq "de Méré's single-die bet rounds to 0.5177" "0.5177" "$(get de_mere_single_rounded)"
check_eq "de Méré's double-dice bet rounds to 0.4914" "0.4914" "$(get de_mere_double_rounded)"
check_eq "the single-die bet favours the player" "True" "$(get de_mere_single_favourable)"
check_eq "the double-dice bet does NOT favour the player" "False" "$(get de_mere_double_favourable)"
check_eq "the single-die simulation lands within 3 standard errors" "True" "$(get de_mere_single_sim_within_tol)"
check_eq "the double-dice simulation lands within 3 standard errors" "True" "$(get de_mere_double_sim_within_tol)"
check_eq "the independent pair satisfies P(A and B) == P(A) x P(B)" "True" "$(get independent_holds)"
check_eq "the dependent pair does NOT satisfy it" "False" "$(get dependent_holds)"
check_eq "a mutually exclusive pair has P(A | B) exactly 0" "True" "$(get mutually_exclusive_conditional_is_zero)"
check_eq "so mutual exclusivity implies dependence" "True" "$(get mutually_exclusive_implies_dependent)"
check_eq "conditioning by formula and by filtering agree exactly at 1/6" "True" "$(get conditioning_agrees)"
check_eq "the urns' weighted total probability of red is 9/20" "9/20" "$(get urn_total)"
check_eq "and it matches the direct enumeration of the combined experiment" "True" "$(get urn_matches_enumeration)"
check_eq "Monte Carlo error falls monotonically across four decades of n" "True" "$(get mc_error_monotone_decreasing)"
check_eq "the observed shrink is close to the sqrt(n) prediction" "True" "$(get mc_error_ratio_near_sqrt_n)"
check_eq "and far from the false 1/n prediction" "True" "$(get mc_error_ratio_far_from_linear)"
check_eq "the same seed gives byte-identical simulated results" "True" "$(get same_seed_identical)"
check_eq "a different seed gives a different result" "True" "$(get different_seed_differs)"
check_eq "and both seeds still land within tolerance of the truth" "True" "$(get both_seeds_within_tolerance)"
# --------------------------------------------------------------------------
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 addition-rule error amount replaced with the wrong value --
# 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_sum_overstates_by_exactly_the_intersection" "${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 - true_union == p_ab"
replacement = "assert naive - true_union == p_ab * 2 # DELIBERATELY WRONG: self-test only"
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_sum_overstates_by_exactly_the_intersection"*"failed"*|*"FAILED"*"test_naive_sum_overstates_by_exactly_the_intersection"*)
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 'probability'
You ran a reference script from the lab directory instead of from inside
examples/. The scripts import probability, simulate and dataset from
beside themselves.
cd examples
../.venv/bin/python3 01_sample_space_and_events.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.
probability() fails a test even though the count looks right
Check the return type. probability() must return a fractions.Fraction,
not a float. Fraction(1, 6) == 0.16666666666666666 compares False for
most fractions because a float cannot represent one exactly, and one of the
day's own points is that this comparison should be exact rather than
"close enough".
My naive addition-rule sum matches the true union
Then your two events do not overlap. The lesson's worked example uses A = "sum is 7" and B = "first die is 6" specifically because their
intersection is a single outcome, (6, 1), which is exactly what makes the
naive sum wrong. If you swap in disjoint events, the naive sum and the true
union are the same number, correctly — the addition rule reduces to plain
addition whenever P(A and B) = 0, and test_addition_rule_reduces_to_naive_sum_for_disjoint_events
in the reference suite checks exactly that case.
My at_least_one gives a probability greater than 1 or less than 0
You applied the complement rule to the wrong quantity. It is 1 - P(single failure) ** trials, not 1 - P(single success) ** trials. Compute
the failure probability first with complement(), raise that to the power
of the trial count, and complement the result once more.
de Méré's two bets look equal to me
They are not, and the gap is the entire point of the exercise. 24 = 6 x 4
matches the six-times-smaller probability of a double six exactly, and it is
still wrong — 0.5177... against 0.4914..., a difference well outside
either simulation's tolerance at 200,000 trials. If your two numbers come out
equal, you probably computed (5/6)**4 for both bets instead of (5/6)**4
for the first and (35/36)**24 for the second — check which fraction and
which exponent belong to which bet.
My simulated de Méré probability is outside the stated tolerance
First check the exponent: 4 rolls for the single-die bet, 24 for the
double-dice bet, not the other way round. If the exponents are right and the
gap is still outside DE_MERE_SINGLE_TOL or DE_MERE_DOUBLE_TOL, print the
tolerance itself — those are three standard errors, 3 x sqrt(p(1-p)/n), and at 200,000 trials they run to about 0.0034. A
simulation landing just past that is not automatically a bug: about 0.3% of
honest runs will, by construction, land outside a three-standard-error band.
Re-run with a different seed before assuming the code is wrong.
is_independent reports the wrong answer for a pair I believe is independent
Independence is about the numbers, not about how the events sound. "Sum is 7"
and "first die is 3" are independent because every value of the first die
leaves the conditional probability of summing to 7 at exactly 1/6 — try
test_sum_seven_is_independent_of_every_value_of_the_first_die in the
reference suite, which checks all six values. Most other pairs of dice events
are not independent; "sum is 2" and "first die is 1" fail because sum=2 is
only reachable when the first die shows exactly 1.
I conflated mutual exclusivity with independence
This is the single most common mistake in the subject, and the lab is built
to make it visible rather than to warn about it in prose. Run
05_mutual_exclusivity_implies_dependence.py and watch P(A | B) collapse
to exactly 0 for the mutually exclusive pair while it stays unchanged for the
independent pair from exercise 4. If the two events can happen together with
non-zero probability, they might be independent. If they cannot ever happen
together, they are necessarily dependent — knowing one occurred tells you the
other definitely did not.
My two methods of conditioning (formula vs. filtering) disagree
They should be identical, not approximately equal — both are exact
Fraction arithmetic over the same finite space. If they disagree, the
usual cause is restricting the wrong set: probability(restricted_event, restricted_space) needs the restricted space as the denominator (the
outcomes where B is true), not the full 36-outcome space.
My urn's weighted total does not match the enumeration
Check that both urns have the same total number of balls (10 each here).
The direct-enumeration method in this lab treats every (urn, ball) pair as
equally likely, which is only valid when the urns are the same size and
the prior over urns is uniform. With urns of different sizes or an unequal
prior, the enumeration would need to be weighted, and the two methods would
need a more careful combined space to agree.
My Monte Carlo error does not shrink smoothly
A single seed at each sample size is not enough — Monte Carlo error is
itself a random variable, so a single run can go the "wrong" way by chance.
This lab averages over twenty seeds at every sample size specifically to
smooth that noise out; if you loosen MONTE_CARLO_SEEDS down to one or two
seeds, the trend can look flat or even reversed on an unlucky run. The
reference tests never assert on a single seed's error for this reason.
Two runs with the same seed give different results
You are calling numpy.random.seed(n) somewhere instead of building a
Generator with numpy.random.default_rng(n). The legacy seed() function
mutates one global state shared across your whole process — importing a
library that seeds it, or calling any other function that also draws from
the global generator, changes what your "same seed" produces next. Pass the
Generator object itself into every function that needs randomness, as
simulate.py does, and reproducibility stops depending on what else ran
first.
__pycache__ or .pytest_cache appears and section 5 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 probability, simulate, 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 urn
compositions, the dice events and the sample sizes are all written out in
examples/dataset.py.
Section 5 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 probability claim is a number with an error bar, and reporting it
without one is a form of overclaiming. Every simulation in this lab is
compared against a tolerance derived from sqrt(p(1-p)/n), never against a
number chosen because it happened to make the test pass. When a system
downstream of you reports "the model is 92% confident", that number came
from some estimation process, and the honest question is always the one
this lab asks of its own simulations: estimated from how many samples, and
with what standard error?
Randomness that is not reproducible is a debugging liability, not a
convenience. numpy.random.seed() mutates global state shared by every
piece of code in the process; two runs that should be identical for
debugging purposes can silently diverge because an unrelated import called
seed() first, or because two functions drew from the shared generator in a
different order. default_rng(seed) hands back an independent object with
no shared state, which is what makes exercise 9's reproducibility guarantee
possible at all. In any system where a random decision needs to be
explainable after the fact — which includes most machine learning pipelines
— this is not a stylistic preference.
A wrong probability calculation looks exactly like a right one until you check it against an independent method. The lab's structure — exact enumeration against a closed-form probability, or a simulation against an exact fraction — exists 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. De Méré himself had exactly this experience: his reasoning was internally consistent and produced two numbers that both looked like probabilities, and only comparing them against real outcomes at the gaming table revealed the error. The discipline this lab teaches — compute it two ways and assert they agree — is the general defence against that entire class of mistake, in this lab and in any code that reports a probability.
What this lab deliberately does not claim
scipy.stats and pandas are not installed here and no output from
either is reproduced anywhere in this lab or its lesson. scipy.stats is
described from its documentation in the lesson's Tools section and marked
as not run here.