Math, Statistics, and Data › Probability and Statistics › Day 117
Hands-on lab — Day 117: Sampling and the Central Limit Theorem
- ← Back to the Day 117 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-117-sampling-and-the-central-limit-theorem/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-117-sampling-and-the-central-limit-theorem
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy; print(numpy.__version__)" Run
cd examples && ../.venv/bin/python3 01_sampling_distribution.py && cd ..
cd examples && ../.venv/bin/python3 02_the_sqrt_n_law.py && cd ..
cd examples && ../.venv/bin/python3 03_clt_from_a_skewed_population.py && cd ..
cd examples && ../.venv/bin/python3 04_the_cauchy_counterexample.py && cd ..
cd examples && ../.venv/bin/python3 05_bias_does_not_shrink.py && cd ..
cd examples && ../.venv/bin/python3 06_bootstrap_from_scratch.py && cd ..
cd examples && ../.venv/bin/python3 07_dependence_inflates_se.py && cd ..
cd examples && ../.venv/bin/python3 08_the_evaluation_margin.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_sampling_distribution.py examples/02_the_sqrt_n_law.py examples/03_clt_from_a_skewed_population.py examples/04_the_cauchy_counterexample.py examples/05_bias_does_not_shrink.py examples/06_bootstrap_from_scratch.py examples/07_dependence_inflates_se.py examples/08_the_evaluation_margin.py examples/09_reproducibility.py examples/conftest.py examples/dataset.py examples/sampling.py examples/test_reference.py expected-output/01-sampling-distribution.txt expected-output/02-the-sqrt-n-law.txt expected-output/03-clt-from-a-skewed-population.txt expected-output/04-the-cauchy-counterexample.txt expected-output/05-bias-does-not-shrink.txt expected-output/06-bootstrap-from-scratch.txt expected-output/07-dependence-inflates-se.txt expected-output/08-the-evaluation-margin.txt expected-output/09-reproducibility.txt expected-output/FIELDS.md metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/00_brief.md starter/conftest.py starter/dataset.py starter/sampling.py starter/test_starter.py tests/run_tests.sh troubleshooting.md
Lab README
Day 117 lab — Sampling You Can Trust
Lesson
- Lesson title: Sampling and the Central Limit Theorem
- Day number: 117 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-117-sampling-and-the-central-limit-theorem
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-117-sampling-and-the-central-limit-theoremwhen the site is running.
Purpose
A sample statistic is itself a random variable, with its own distribution and its own spread. Measure the average session length across 30 users today and you might get 42 seconds; measure it again from a fresh sample of 30 users from the exact same population and you might get 51 seconds. Neither measurement is wrong. Nothing was done incorrectly. Reporting either one as the answer, without its variability, is the single most common quantitative mistake this lab exists to prevent.
This lab builds, from scratch and checked against real numbers, the
machinery that makes a sample statistic trustworthy: the sampling
distribution itself, the standard error and its 1/sqrt(n) law (the same
law Day 113's Monte Carlo error obeyed, named properly this time), the
central limit theorem's flattening of a skewed population's sampling
distribution, the Cauchy distribution's flat refusal to obey any of it, the
sharp and often-missed difference between sampling bias and sampling error,
the bootstrap built with nothing but resampling and a standard deviation,
and the quiet way dependence between observations makes the textbook
formula for the standard error understate the truth.
Every exercise follows the same design as Days 113-116: compute everything two ways and assert they agree -- exact where a formula exists, seeded simulation otherwise, with tolerances derived from the standard error rather than guessed.
Learning objectives
By the end you will be able to:
- Build the sampling distribution of a statistic directly, by literally repeating an experiment many times, and explain why a sample statistic is itself a random variable with its own mean and spread.
- State and demonstrate the standard error's
1/sqrt(n)law: quadrupling the sample size roughly halves the standard error, not quarters it. - Demonstrate the central limit theorem numerically, by measuring the
skewness of the sampling distribution of the mean fall toward zero as
ngrows, starting from a population that looks nothing like a bell curve. - State the Cauchy distribution's counterexample precisely: the mean of
nCauchy draws is itself Cauchy distributed, with the same spread, for everyn-- and explain why this means the central limit theorem's finite-variance condition is a real constraint, not decoration. - Distinguish sampling bias from sampling error and explain, with a measurement, why more data shrinks the second and not the first.
- Build the bootstrap from scratch -- resample with replacement, recompute a statistic, read its standard error off the spread -- and apply it to a statistic (the median) with no simple closed-form standard error.
- Demonstrate that dependence between observations makes the naive
sample_std / sqrt(n)formula UNDERSTATE the true standard error, and explain why that is a worse failure mode than an honest formula being merely imprecise. - Compute the standard error of a model evaluation accuracy from the binomial formula, and use it to judge whether a leaderboard margin is a real improvement or noise.
Prerequisites
- Day 113 -- probability rules and Monte Carlo error shrinking as
1/sqrt(n). This lab names that law properly and builds on it directly. - Day 114 -- random variables, expectation, variance, the named
distributions, and inverse-CDF sampling with
numpy.random.Generator. - Day 116 -- descriptive statistics, including the bootstrap's general shape, applied here to a specific standard-error question.
- Comfort with NumPy arrays and basic vectorised operations.
- Days 71-74 -- running pytest and reading its skip-versus-fail 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 heaviest single computation is 50,000 trials of a 320-observation sample, drawn and averaged in one vectorised call -- 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.
scipy.stats provides bootstrap and sem (standard error of the mean)
functions that do exercises 6 and part of exercise 1 for you, and is not
installed here, so no output from it is reproduced anywhere in this lab
or its lesson -- it is described from its documentation. pandas'
DataFrame.sample is likewise not installed and not run; the lesson
describes it from documentation only.
Installation
From the repository root:
cd labs/sections/math-statistics-and-data/day-117-sampling-and-the-central-limit-theorem
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy; print(numpy.__version__)"
Expect 2.5.2. That is the only time this lab needs the network.
File structure
.
├── README.md this file
├── metadata.yml how the lab was actually run, and when
├── requirements/
│ ├── README.md why each package is here, its licence, and what scipy would add
│ └── 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 populations, parameters and tolerances — read it, do not change it
│ ├── sampling.py all nine exercises — functions to write
│ └── 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
│ ├── sampling.py the finished sampling, bootstrap and standard-error functions
│ ├── 01_sampling_distribution.py a statistic has its own distribution, its own mean and spread
│ ├── 02_the_sqrt_n_law.py quadrupling n halves the standard error
│ ├── 03_clt_from_a_skewed_population.py the sampling distribution's skewness falls toward zero
│ ├── 04_the_cauchy_counterexample.py Exponential shrinks by ~10x; Cauchy does not shrink at all
│ ├── 05_bias_does_not_shrink.py a biased sampler's error stays flat as n grows 100x
│ ├── 06_bootstrap_from_scratch.py resample, recompute, read off the spread — for the mean and the median
│ ├── 07_dependence_inflates_se.py autocorrelation makes the naive formula understate the truth
│ ├── 08_the_evaluation_margin.py a 0.3-point accuracy gap is well inside one standard error
│ ├── 09_reproducibility.py same seed, identical results; different seed, compatible results
│ └── test_reference.py 19 tests over real values and real exceptions
├── tests/
│ └── run_tests.sh the bash harness: 32 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-sampling-distribution.txt
│ ├── 02-the-sqrt-n-law.txt
│ ├── 03-clt-from-a-skewed-population.txt
│ ├── 04-the-cauchy-counterexample.txt
│ ├── 05-bias-does-not-shrink.txt
│ ├── 06-bootstrap-from-scratch.txt
│ ├── 07-dependence-inflates-se.txt
│ ├── 08-the-evaluation-margin.txt
│ └── 09-reproducibility.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 1 passed, 12 skipped. A skip means
"not attempted"; a failure means "attempted and wrong", and prints both
your answer and the real one.
Afterwards, read the reference -- each script prints its working and asserts every claim it makes:
cd examples
../.venv/bin/python3 01_sampling_distribution.py
../.venv/bin/python3 02_the_sqrt_n_law.py
../.venv/bin/python3 03_clt_from_a_skewed_population.py
../.venv/bin/python3 04_the_cauchy_counterexample.py
../.venv/bin/python3 05_bias_does_not_shrink.py
../.venv/bin/python3 06_bootstrap_from_scratch.py
../.venv/bin/python3 07_dependence_inflates_se.py
../.venv/bin/python3 08_the_evaluation_margin.py
../.venv/bin/python3 09_reproducibility.py
cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
Run them from inside examples/, because they import sampling.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_sampling_distribution.py |
Builds the sampling distribution of the mean and checks its own mean and spread against theory. |
02_the_sqrt_n_law.py |
Four sample sizes, each 4x the last; the standard error ratio should sit near 2.0 at every step. |
03_clt_from_a_skewed_population.py |
The sampling distribution's skewness, measured at five values of n, falling monotonically. |
04_the_cauchy_counterexample.py |
Exponential vs Cauchy sample means, compared by IQR at n=10 and n=1,000. |
05_bias_does_not_shrink.py |
A sampler restricted to the upper half of the population, compared against an unbiased one across a 100x growth in n. |
06_bootstrap_from_scratch.py |
Resample-and-recompute for the mean (checked against a formula) and the median (checked for sanity). |
07_dependence_inflates_se.py |
An AR(1) series' true standard error (by replication) versus the naive formula. |
08_the_evaluation_margin.py |
The binomial standard error for a 91.4%-on-500 accuracy figure, and what it says about a 0.3-point margin. |
09_reproducibility.py |
Same seed twice; a different seed once more, compared for statistical compatibility. |
.venv/bin/pytest examples -q -p no:cacheprovider |
The 19 reference tests. -p no:cacheprovider stops pytest writing a .pytest_cache directory. |
bash tests/run_tests.sh |
The 32-check harness: versions, every script, both suites, a deliberate self-failure, and a clean-disk check. |
Expected output
The captured files live in expected-output/. The harness ends with:
32 checks, 0 failure(s).
and exits 0. The reference suite ends with 19 passed, and an untouched
starter with 1 passed, 12 skipped.
The result worth recognising before you meet it, from exercise 4:
Exponential(scale=1.0): IQR of the mean at n=10 = 0.4212, at n=1000 = 0.0428
ratio = 9.85 (100x more data, expected shrink ~ sqrt(100) = 10x)
standard Cauchy: IQR of the mean at n=10 = 2.0035, at n=1000 = 1.9561
ratio = 1.02 (100x more data, expected shrink: NONE)
expected-output/FIELDS.md records exactly which captured numbers are
sampled and will differ, within their stated tolerance, on your machine.
Validation steps
bash tests/run_tests.sh; echo "exit=$?"prints32 checks, 0 failure(s).andexit=0..venv/bin/pytest examples -q -p no:cacheproviderprints19 passed..venv/bin/pytest starter -q -p no:cacheproviderprints13 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 32 checks in six 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 15 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
samplinganddataset. - A deliberate failure -- the harness re-runs script 08 (the evaluation-margin calculation) with its expected standard error temporarily swapped for a wrong one, and asserts the re-run reports the named failure and exits non-zero. 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 6 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 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
a named failure when section 5 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 6 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 function still returns None,
tolerance failures that mean a seed or a sample size was changed, 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. Two points there
are worth carrying away: a spread measure computed on a heavy-tailed sample
can be meaningless even when it looks like a normal number, and a naive
standard-error formula applied to dependent data does not fail loudly --
it fails by understating exactly the risk you asked it to quantify.
Extension exercises
- Vary the AR(1) autocorrelation strength. Repeat exercise 7 with
phiat 0.0, 0.3, 0.7 and 0.95, and plot (or tabulate) how the true-to-naive standard error ratio grows withphi. Atphi = 0the two should agree closely -- confirm that as a control case. - Build a Student's t-distribution sampler and find where it stops
looking like the Cauchy distribution. The Cauchy distribution is the
t-distribution with 1 degree of freedom. Using
rng.standard_t(df), repeat exercise 4 atdfin{1, 2, 5, 30}and find the smallestdfat which the sample mean's IQR shrinks by at least 5x from n=10 to n=1000. - Bootstrap a ratio statistic. Apply
bootstrap_standard_errorto a dataset of paired values and a statistic that computes the ratio of their sums,sum(a) / sum(b), which has no simple closed-form standard error either. - Measure the standard error of a proportion at the boundary. Compute
binomial_standard_errorforphatapproaching 0 or 1 (say, 0.01, 0.5, 0.99) at a fixedn, and confirm numerically that the standard error is largest atphat = 0.5and shrinks toward the extremes. - Simulate a stratified sample and compare it to the biased sampler. Build a sampler that draws proportionally from sub-populations rather than only above the median, and compare its error-versus-n curve to both the unbiased and biased samplers in exercise 5.
Navigation
- Previous day: Day 116 — Descriptive Statistics That Don't Lie
- Next day: Day 118 — Hypothesis Tests and Confidence Intervals
- Week 17: Probability and Statistics
- Section: Mathematics, Statistics and Data
Expected output
01-sampling-distribution.txt
population mean = 2.9946, population sigma = 2.9831
n = 40, trials = 20000
measured mean of the sampling distribution = 2.9930
measured standard error (std of the 20000 sample means) = 0.4718
theoretical standard error (sigma / sqrt(n)) = 0.4717
gap between measured and population mean = 0.49 standard errors
gap between measured and theoretical SE = 0.08 standard errors
01_sampling_distribution.py: every assertion held.
02-the-sqrt-n-law.txt
n = 10 measured SE = 0.9321
n = 40 measured SE = 0.4696
n = 160 measured SE = 0.2359
n = 640 measured SE = 0.1173
SE(n=10) / SE(n=40) = 1.985 (expected: sqrt(4) = 2.000)
SE(n=40) / SE(n=160) = 1.991 (expected: sqrt(4) = 2.000)
SE(n=160) / SE(n=640) = 2.011 (expected: sqrt(4) = 2.000)
overall SE(n=10) / SE(n=640) = 7.95 (expected: sqrt(64) = 8.00)
02_the_sqrt_n_law.py: every assertion held.
03-clt-from-a-skewed-population.txt
population (Exponential-shaped): mean = 2.995, skewness = 1.975
n = 2 skewness of the sampling distribution = 1.4206
n = 5 skewness of the sampling distribution = 0.8949
n = 20 skewness of the sampling distribution = 0.4529
n = 80 skewness of the sampling distribution = 0.2101
n = 320 skewness of the sampling distribution = 0.1113
skewness fell monotonically across n = (2, 5, 20, 80, 320): 1.421 > 0.895 > 0.453 > 0.210 > 0.111
biased coin: population skewness = 1.499, sampling-distribution skewness at n=5 -> 0.675, at n=320 -> 0.092
two-spike: population skewness = 1.483, sampling-distribution skewness at n=5 -> 0.671, at n=320 -> 0.084
03_clt_from_a_skewed_population.py: every assertion held.
04-the-cauchy-counterexample.txt
Exponential(scale=1.0): IQR of the mean at n=10 = 0.4212, at n=1000 = 0.0428
ratio = 9.85 (100x more data, expected shrink ~ sqrt(100) = 10x)
standard Cauchy: IQR of the mean at n=10 = 2.0035, at n=1000 = 1.9561
ratio = 1.02 (100x more data, expected shrink: NONE)
04_the_cauchy_counterexample.py: every assertion held.
05-bias-does-not-shrink.txt
true population mean = 2.9946
biased pool (values above the population median) mean = 5.0664 -- this is what the biased sampler converges to, not the true mean
UNBIASED sampler: mean abs error at n=30 = 0.4455, at n=3000 = 0.0433 (ratio = 10.28)
BIASED sampler: mean abs error at n=30 = 2.0712, at n=3000 = 2.0722 (ratio = 1.00)
05_bias_does_not_shrink.py: every assertion held.
06-bootstrap-from-scratch.txt
sample size = 200, sigma_hat = 10.0668
theoretical SE of the mean (sigma_hat / sqrt(n)) = 0.7118
bootstrap SE of the mean (5000 resamples) = 0.7195
relative error = 1.080%
bootstrap SE of the MEDIAN (5000 resamples) = 0.5740
SE of the median from 2000 genuinely fresh samples = 0.8824
ratio (bootstrap / fresh) = 0.65
06_bootstrap_from_scratch.py: every assertion held.
07-dependence-inflates-se.txt
AR(1) series: n = 200, phi = 0.7, sigma = 1.0
TRUE standard error (from 3000 independent replications) = 0.1692
NAIVE standard error (sample_std / sqrt(n), averaged over 500 series) = 0.0693
ratio (true / naive) = 2.44
An analyst who trusted the naive formula here would report a confidence interval that is too narrow -- not slightly, but by a factor that a single glance at the ratio above makes obvious.
07_dependence_inflates_se.py: every assertion held.
08-the-evaluation-margin.txt
accuracy = 91.4% on 500 held-out examples
standard error = sqrt(p_hat * (1 - p_hat) / n) = 0.01254 = 1.254 percentage points
a 0.3 percentage-point difference between two models on this set is 0.24 standard errors --
well inside one standard error, i.e. indistinguishable from noise on a set this size.
08_the_evaluation_margin.py: every assertion held.
09-reproducibility.txt
seed 7, run 1: first three means = [2.51865218 2.08408671 2.22719222]
seed 7, run 2: first three means = [2.51865218 2.08408671 2.22719222]
seed 8, run 1: first three means = [2.08941026 3.27226782 2.9951722 ]
same seed produces bit-identical arrays: True
different seed produces a different array: True
gap between seed 7's and seed 8's estimate of the mean = 0.00532 (0.50 standard errors)
09_reproducibility.py: every assertion held.
FIELDS.md
# Which figures are exact, and which will differ on your machine
All nine `.txt` files here were captured from a real run on 2026-08-17
(macOS 26.5.2, Apple Silicon, Python 3.14.0, numpy 2.5.2), through a
real lab-local `.venv` created by the documented setup commands.
## Exact, identical anywhere
- **`08-the-evaluation-margin.txt`** -- every number in this file is
closed-form arithmetic (`sqrt(p_hat * (1 - p_hat) / n)`), not sampled.
`1.254` percentage points and `0.24` standard errors will be identical
bit-for-bit on any correct implementation, anywhere, for the same inputs.
- The **population parameters printed at the top** of files 01, 03 and 05
(`SKEWED_SCALE = 3.0`, `POP_SIZE = 200,000`) are configuration, not
measurements, and will read the same everywhere `dataset.py` is
unmodified -- though the population itself is a random draw from that
configuration (see below).
## Sampled, and will differ within the stated tolerance
Every other number in every other file was measured from a seeded random
draw and will differ, in its last one or two significant figures, on a
different machine, a different NumPy version, or after any edit to
`dataset.py`'s trial counts. The tolerances in `examples/sampling.py`'s
callers and in `dataset.py` were set from measurements taken across six
different seeds (1, 2, 3, 42, 117, 999) during development, specifically
so that a rerun with a different seed still passes:
| File | What is sampled | Tolerance band checked across 6 seeds |
| --- | --- | --- |
| `01-sampling-distribution.txt` | The sampling distribution's own mean and standard error | measured mean within 3 SE of population mean; measured SE within 3 SE of theoretical SE |
| `02-the-sqrt-n-law.txt` | Four measured standard errors and their pairwise ratios | each successive ratio observed 1.98-2.03; asserted within 0.25 of 2.0 |
| `03-clt-from-a-skewed-population.txt` | Five skewness values, plus the coin and two-spike population skewness | strictly monotone decrease at every seed tested |
| `04-the-cauchy-counterexample.txt` | Four IQR values and their two ratios | Exponential ratio observed 9.7-9.96 (floor: 8.0); Cauchy ratio observed 0.98-1.03 (band: 1/3 to 3) |
| `05-bias-does-not-shrink.txt` | The true population mean, biased pool mean, and four mean-absolute-error values | unbiased ratio observed 9.8-10.3 (floor: 7.0); biased ratio observed 0.99-1.00 (band: 0.4 to 2.5) |
| `06-bootstrap-from-scratch.txt` | sigma_hat, both bootstrap standard errors, and the fresh-sample median SE | mean SE relative error observed under 1.1% (tolerance: 15%); median ratio observed 0.57-1.11 (band: 1/3 to 3) |
| `07-dependence-inflates-se.txt` | The true SE (by replication) and the naive SE (averaged over 500 series) | ratio observed 2.34-2.46 (floor: 1.5) |
| `09-reproducibility.txt` | The first three sample means under two seeds, and the gap between seeds | identical-seed check is exact (bit-for-bit); cross-seed gap observed under 1 SE, asserted under 5 |
The **populations themselves** (`SKEWED_POP`, `COIN_POP`, `TWO_SPIKE_POP` in
`dataset.py`) are generated once, deterministically, from
`numpy.random.default_rng(DATASET_SEED)` with `DATASET_SEED = 117`. They
are therefore identical on any machine running the same NumPy major version
-- NumPy's `Generator` bit-stream algorithm (PCG64) is specified and stable
across platforms for a given NumPy version, but is not guaranteed
byte-identical across NumPy's own major version boundaries if the
underlying bit generator implementation changes.
## Reported, never asserted, in the scripts' own printed output
A few lines in the `.txt` files -- for example the exact bootstrap-median
ratio and the exact per-seed skewness values -- are printed for the reader
to see the shape of the result, but the assertions in the corresponding
script and in `tests/run_tests.sh` check a tolerance band or a monotone
trend, never the literal printed digits. If your own run prints
`ratio = 9.79` where this file shows `9.85`, that is expected behaviour,
not a bug.
Source files
examples/01_sampling_distribution.py (2287 bytes)
"""Exercise 1 -- the sampling distribution of the mean.
Draw 20,000 independent samples of size 40 from a skewed population, compute
each sample's mean, and look at the DISTRIBUTION of those 20,000 means -- not
any single one of them. Its own mean should sit close to the population mean,
and its own spread should sit close to sigma / sqrt(n).
"""
import numpy as np
import dataset as D
from sampling import population_mean_std, sampling_distribution, theoretical_standard_error
rng = np.random.default_rng(1)
pop_mean, pop_sigma = population_mean_std(D.SKEWED_POP)
means = sampling_distribution(D.SKEWED_POP, D.EX1_N, D.EX1_TRIALS, rng)
measured_mean = means.mean()
measured_se = means.std(ddof=1)
theoretical_se = theoretical_standard_error(pop_sigma, D.EX1_N)
# The standard error OF the measured mean itself, so "close" below is a
# statement with a real yardstick rather than a guessed number: with 20,000
# trials, the mean of the sampling distribution should sit within a few of
# these of the true population mean almost always.
se_of_measured_mean = measured_se / np.sqrt(D.EX1_TRIALS)
# The sample standard deviation of 20,000 draws has its own standard error,
# approximately sigma / sqrt(2 * trials) for a roughly-normal statistic.
se_of_measured_se = measured_se / np.sqrt(2 * D.EX1_TRIALS)
print(f"population mean = {pop_mean:.4f}, population sigma = {pop_sigma:.4f}")
print(f"n = {D.EX1_N}, trials = {D.EX1_TRIALS}")
print(f"measured mean of the sampling distribution = {measured_mean:.4f}")
print(f"measured standard error (std of the {D.EX1_TRIALS} sample means) = {measured_se:.4f}")
print(f"theoretical standard error (sigma / sqrt(n)) = {theoretical_se:.4f}")
mean_gap_in_ses = abs(measured_mean - pop_mean) / se_of_measured_mean
se_gap_in_ses = abs(measured_se - theoretical_se) / se_of_measured_se
print(f"gap between measured and population mean = {mean_gap_in_ses:.2f} standard errors")
print(f"gap between measured and theoretical SE = {se_gap_in_ses:.2f} standard errors")
assert mean_gap_in_ses < 3.0, "the sampling distribution's own mean drifted too far from the population mean"
assert se_gap_in_ses < 3.0, "the sampling distribution's own spread drifted too far from sigma / sqrt(n)"
print("01_sampling_distribution.py: every assertion held.")
examples/02_the_sqrt_n_law.py (2063 bytes)
"""Exercise 2 -- the sqrt(n) law.
Four sample sizes, each exactly 4x the one before it: 10, 40, 160, 640. Every
time n quadruples, the standard error of the mean should roughly HALVE, not
quarter -- that is the entire economics of measurement in one number. This
asserts the RATIOS between successive standard errors, not four hard-coded
values, so the check is honest about what simulation noise can and cannot
pin down.
"""
import math
import numpy as np
import dataset as D
from sampling import sampling_distribution
rng = np.random.default_rng(2)
standard_errors = {}
for n in D.SQRT_N_LAW_NS:
means = sampling_distribution(D.SKEWED_POP, n, D.SQRT_N_LAW_TRIALS, rng)
standard_errors[n] = means.std(ddof=1)
print(f"n = {n:>4} measured SE = {standard_errors[n]:.4f}")
print()
ratios = []
for smaller, larger in zip(D.SQRT_N_LAW_NS, D.SQRT_N_LAW_NS[1:]):
ratio = standard_errors[smaller] / standard_errors[larger]
ratios.append(ratio)
print(f"SE(n={smaller}) / SE(n={larger}) = {ratio:.3f} (expected: sqrt(4) = {math.sqrt(4):.3f})")
for (smaller, larger), ratio in zip(zip(D.SQRT_N_LAW_NS, D.SQRT_N_LAW_NS[1:]), ratios):
gap = abs(ratio - 2.0)
assert gap < D.SQRT_N_LAW_RATIO_TOLERANCE, (
f"SE(n={smaller})/SE(n={larger}) = {ratio:.3f} strayed more than "
f"{D.SQRT_N_LAW_RATIO_TOLERANCE} from the predicted 2.0"
)
# The compounded law across all three quadruplings: from n=10 to n=640 is a
# 64x growth in sample size, so the standard error should have fallen by
# close to sqrt(64) = 8x, not 64x -- the mistake a 1/n law would predict.
overall_ratio = standard_errors[D.SQRT_N_LAW_NS[0]] / standard_errors[D.SQRT_N_LAW_NS[-1]]
print(f"\noverall SE(n=10) / SE(n=640) = {overall_ratio:.2f} (expected: sqrt(64) = {math.sqrt(64):.2f})")
assert abs(overall_ratio - 8.0) < 1.5, "the compounded sqrt(n) law did not hold across the full range"
assert overall_ratio < 20.0, "a ratio anywhere near 64 would mean the error fell like 1/n, not 1/sqrt(n)"
print("02_the_sqrt_n_law.py: every assertion held.")
examples/03_clt_from_a_skewed_population.py (2529 bytes)
"""Exercise 3 -- the central limit theorem, measured rather than assumed.
The population itself is heavily right-skewed (an Exponential shape). As n
grows, the SKEWNESS of the sampling distribution of the mean should fall
toward zero, monotonically -- the population's lopsidedness washing out of
the statistic even though it never leaves the population itself.
Two more populations that look nothing like a bell -- a biased coin and a
lumpy two-spike distribution -- are reported alongside, to show the same
flattening is not a fact about this one population's shape.
"""
import numpy as np
import dataset as D
from sampling import population_mean_std, sampling_distribution, skewness
rng = np.random.default_rng(3)
pop_mean, pop_sigma = population_mean_std(D.SKEWED_POP)
pop_skew = skewness(D.SKEWED_POP)
print(f"population (Exponential-shaped): mean = {pop_mean:.3f}, skewness = {pop_skew:.3f}")
print()
skews = []
for n in D.SKEW_DEMO_NS:
means = sampling_distribution(D.SKEWED_POP, n, D.SKEW_DEMO_TRIALS, rng)
s = skewness(means)
skews.append(s)
print(f"n = {n:>4} skewness of the sampling distribution = {s:.4f}")
print()
for smaller_n, larger_n, s_small, s_large in zip(
D.SKEW_DEMO_NS, D.SKEW_DEMO_NS[1:], skews, skews[1:]
):
assert s_large < s_small, (
f"skewness did not decrease going from n={smaller_n} ({s_small:.4f}) "
f"to n={larger_n} ({s_large:.4f})"
)
print(f"skewness fell monotonically across n = {D.SKEW_DEMO_NS}: "
+ " > ".join(f"{s:.3f}" for s in skews))
# Two more non-bell populations, reported for the lesson's narrative. Both
# show the same qualitative flattening; neither is asserted to the same
# precision as the primary skewed population above, since a Bernoulli
# population's sampling-distribution skewness is much noisier at small n.
for label, population in (("biased coin", D.COIN_POP), ("two-spike", D.TWO_SPIKE_POP)):
p_skew = skewness(population)
small_n_skew = skewness(sampling_distribution(population, 5, D.SKEW_DEMO_TRIALS, rng))
large_n_skew = skewness(sampling_distribution(population, 320, D.SKEW_DEMO_TRIALS, rng))
print(
f"{label}: population skewness = {p_skew:.3f}, "
f"sampling-distribution skewness at n=5 -> {small_n_skew:.3f}, "
f"at n=320 -> {large_n_skew:.3f}"
)
assert abs(large_n_skew) < abs(small_n_skew), (
f"{label}: skewness at n=320 was not smaller in magnitude than at n=5"
)
print("03_clt_from_a_skewed_population.py: every assertion held.")
examples/04_the_cauchy_counterexample.py (2609 bytes)
"""Exercise 4 -- where the central limit theorem actually fails.
The Exponential distribution has a finite variance, so its sample mean's
spread should shrink by close to sqrt(100) = 10x when n grows from 10 to
1,000. The Cauchy distribution has NO defined mean or variance -- its tails
are too heavy -- and the mean of n Cauchy draws is itself standard-Cauchy
distributed, for every n. Averaging a thousand of them should be no better
than averaging ten.
The interquartile range (IQR) is used as the spread measure here rather than
the standard deviation, and that choice is load-bearing: a Cauchy sample's
standard deviation is not an estimate of anything, because the population
quantity it would estimate does not exist. The IQR depends only on the order
of the data, so it stays meaningful even when the mean and variance do not.
"""
import dataset as D
from sampling import cauchy_mean_iqr, exponential_mean_iqr
import numpy as np
rng = np.random.default_rng(4)
exp_iqr_small = exponential_mean_iqr(D.CAUCHY_DEMO_N_SMALL, D.CAUCHY_DEMO_TRIALS, rng, D.EXPONENTIAL_SCALE)
exp_iqr_large = exponential_mean_iqr(D.CAUCHY_DEMO_N_LARGE, D.CAUCHY_DEMO_TRIALS, rng, D.EXPONENTIAL_SCALE)
exp_ratio = exp_iqr_small / exp_iqr_large
cauchy_iqr_small = cauchy_mean_iqr(D.CAUCHY_DEMO_N_SMALL, D.CAUCHY_DEMO_TRIALS, rng)
cauchy_iqr_large = cauchy_mean_iqr(D.CAUCHY_DEMO_N_LARGE, D.CAUCHY_DEMO_TRIALS, rng)
cauchy_ratio = cauchy_iqr_small / cauchy_iqr_large
print(f"Exponential(scale={D.EXPONENTIAL_SCALE}): IQR of the mean at n={D.CAUCHY_DEMO_N_SMALL} = "
f"{exp_iqr_small:.4f}, at n={D.CAUCHY_DEMO_N_LARGE} = {exp_iqr_large:.4f}")
print(f" ratio = {exp_ratio:.2f} (100x more data, expected shrink ~ sqrt(100) = 10x)")
print()
print(f"standard Cauchy: IQR of the mean at n={D.CAUCHY_DEMO_N_SMALL} = "
f"{cauchy_iqr_small:.4f}, at n={D.CAUCHY_DEMO_N_LARGE} = {cauchy_iqr_large:.4f}")
print(f" ratio = {cauchy_ratio:.2f} (100x more data, expected shrink: NONE)")
assert exp_ratio > D.EXPONENTIAL_SHRINK_FLOOR, (
f"the Exponential mean's spread shrank by only {exp_ratio:.2f}x, "
f"expected close to 10x"
)
assert D.CAUCHY_NO_SHRINK_LOW < cauchy_ratio < D.CAUCHY_NO_SHRINK_HIGH, (
f"the Cauchy mean's spread shrank by {cauchy_ratio:.2f}x -- it should have stayed "
f"roughly flat, not shrunk toward the Exponential's ~10x"
)
assert cauchy_ratio < exp_ratio / 3.0, (
"the Cauchy ratio was not clearly smaller than the Exponential ratio -- "
"the whole point of this exercise is the contrast between the two"
)
print("04_the_cauchy_counterexample.py: every assertion held.")
examples/05_bias_does_not_shrink.py (2703 bytes)
"""Exercise 5 -- sampling bias is not sampling error, and n does not fix it.
An UNBIASED sampler draws from the whole population; a BIASED sampler draws
only from the half of the population strictly above the population's own
median, no matter how many draws it takes. Both errors -- mean absolute
distance from the true population mean -- are tracked as the sample size
grows 100x, from 30 to 3,000.
The unbiased sampler's error should shrink by close to 10x, exactly the
sqrt(n) law from exercise 2. The biased sampler's error should stay roughly
FLAT: more data buys it a more precise estimate of the wrong number, and the
mathematics does not know the difference between "confident" and "correct".
"""
import dataset as D
from sampling import biased_pool, mean_absolute_error, sampling_distribution
import numpy as np
rng = np.random.default_rng(5)
true_mean = float(D.SKEWED_POP.mean())
pool = biased_pool(D.SKEWED_POP)
print(f"true population mean = {true_mean:.4f}")
print(f"biased pool (values above the population median) mean = {pool.mean():.4f} "
f"-- this is what the biased sampler converges to, not the true mean")
print()
unbiased_small = mean_absolute_error(
sampling_distribution(D.SKEWED_POP, D.BIAS_DEMO_N_SMALL, D.BIAS_DEMO_TRIALS, rng), true_mean
)
unbiased_large = mean_absolute_error(
sampling_distribution(D.SKEWED_POP, D.BIAS_DEMO_N_LARGE, D.BIAS_DEMO_TRIALS, rng), true_mean
)
biased_small = mean_absolute_error(
sampling_distribution(pool, D.BIAS_DEMO_N_SMALL, D.BIAS_DEMO_TRIALS, rng), true_mean
)
biased_large = mean_absolute_error(
sampling_distribution(pool, D.BIAS_DEMO_N_LARGE, D.BIAS_DEMO_TRIALS, rng), true_mean
)
unbiased_ratio = unbiased_small / unbiased_large
biased_ratio = biased_small / biased_large
print(f"UNBIASED sampler: mean abs error at n={D.BIAS_DEMO_N_SMALL} = {unbiased_small:.4f}, "
f"at n={D.BIAS_DEMO_N_LARGE} = {unbiased_large:.4f} (ratio = {unbiased_ratio:.2f})")
print(f"BIASED sampler: mean abs error at n={D.BIAS_DEMO_N_SMALL} = {biased_small:.4f}, "
f"at n={D.BIAS_DEMO_N_LARGE} = {biased_large:.4f} (ratio = {biased_ratio:.2f})")
assert unbiased_ratio > D.UNBIASED_SHRINK_FLOOR, (
f"the unbiased sampler's error only shrank by {unbiased_ratio:.2f}x, expected close to 10x"
)
assert D.BIASED_FLAT_LOW < biased_ratio < D.BIASED_FLAT_HIGH, (
f"the biased sampler's error changed by {biased_ratio:.2f}x -- it should have stayed roughly flat"
)
assert biased_large > 3.0 * unbiased_large, (
"at the LARGE sample size the biased sampler's error should still dwarf the unbiased "
"sampler's -- more data did not rescue it"
)
print("05_bias_does_not_shrink.py: every assertion held.")
examples/06_bootstrap_from_scratch.py (2900 bytes)
"""Exercise 6 -- the bootstrap, from scratch.
Resample a single dataset with replacement, recompute a statistic on every
resample, and read the statistic's standard error straight off the spread of
the results -- no formula for the statistic's own sampling distribution
required.
First check it against the MEAN, where a formula (sigma_hat / sqrt(n)) does
exist, so the bootstrap can be judged against a known answer. Then apply the
exact same code to the MEDIAN, where no simple closed form exists, and check
its answer for sanity against the spread of medians computed from genuinely
fresh, independent samples of the same population -- the bootstrap's whole
reason for existing.
"""
import dataset as D
from sampling import bootstrap_standard_error
import numpy as np
rng = np.random.default_rng(6)
sample = rng.normal(loc=D.BOOTSTRAP_SAMPLE_MEAN, scale=D.BOOTSTRAP_SAMPLE_STD, size=D.BOOTSTRAP_SAMPLE_SIZE)
sigma_hat = sample.std(ddof=1)
theoretical_se = sigma_hat / np.sqrt(D.BOOTSTRAP_SAMPLE_SIZE)
boot_se_mean = bootstrap_standard_error(
sample, lambda a: a.mean(axis=1), D.BOOTSTRAP_N_BOOT, rng
)
boot_se_median = bootstrap_standard_error(
sample, lambda a: np.median(a, axis=1), D.BOOTSTRAP_N_BOOT, rng
)
# "Genuinely fresh samples", drawn straight from the same Normal population
# rather than resampled from the one dataset above -- this is the sanity
# check the median bootstrap is measured against, since no formula exists.
fresh = rng.normal(
loc=D.BOOTSTRAP_SAMPLE_MEAN,
scale=D.BOOTSTRAP_SAMPLE_STD,
size=(D.FRESH_MEDIAN_REPLICATIONS, D.BOOTSTRAP_SAMPLE_SIZE),
)
fresh_median_se = np.median(fresh, axis=1).std(ddof=1)
relative_error_mean = abs(boot_se_mean - theoretical_se) / theoretical_se
median_ratio = boot_se_median / fresh_median_se
print(f"sample size = {D.BOOTSTRAP_SAMPLE_SIZE}, sigma_hat = {sigma_hat:.4f}")
print(f"theoretical SE of the mean (sigma_hat / sqrt(n)) = {theoretical_se:.4f}")
print(f"bootstrap SE of the mean ({D.BOOTSTRAP_N_BOOT} resamples) = {boot_se_mean:.4f}")
print(f" relative error = {relative_error_mean:.3%}")
print()
print(f"bootstrap SE of the MEDIAN ({D.BOOTSTRAP_N_BOOT} resamples) = {boot_se_median:.4f}")
print(f"SE of the median from {D.FRESH_MEDIAN_REPLICATIONS} genuinely fresh samples = {fresh_median_se:.4f}")
print(f" ratio (bootstrap / fresh) = {median_ratio:.2f}")
assert relative_error_mean < D.BOOTSTRAP_MEAN_RELATIVE_TOLERANCE, (
f"the bootstrap SE of the mean disagreed with sigma_hat / sqrt(n) by "
f"{relative_error_mean:.1%}, expected under {D.BOOTSTRAP_MEAN_RELATIVE_TOLERANCE:.0%}"
)
assert D.BOOTSTRAP_MEDIAN_SANITY_LOW < median_ratio < D.BOOTSTRAP_MEDIAN_SANITY_HIGH, (
f"the bootstrap SE of the median ({boot_se_median:.4f}) is not within a sane "
f"range of the fresh-sample benchmark ({fresh_median_se:.4f})"
)
print("06_bootstrap_from_scratch.py: every assertion held.")
examples/07_dependence_inflates_se.py (2147 bytes)
"""Exercise 7 -- dependence inflates the true standard error, quietly.
Generate an autocorrelated AR(1) series -- each observation is 0.7 times the
previous one plus fresh noise, so consecutive observations are far from
independent. Measure the TRUE standard error of its sample mean the only
honest way: generate many independent replications of the whole series and
look at the spread of their means. Compare that against the NAIVE standard
error, sample_std / sqrt(n), which assumes independence the data does not
have.
The naive formula should understate the true standard error -- meaningfully,
not by a rounding error -- which is worse than an honest formula being
merely imprecise: it makes the analyst confident in exact proportion to how
wrong they are.
"""
import dataset as D
from sampling import ar1_series, naive_standard_error, true_standard_error_by_replication
import numpy as np
rng = np.random.default_rng(7)
true_se = true_standard_error_by_replication(D.AR1_N, D.AR1_PHI, D.AR1_SIGMA, D.AR1_REPLICATIONS, rng)
# The naive SE is reported as an average over many single series, since any
# one series' sample standard deviation is itself noisy.
naive_ses = [
naive_standard_error(ar1_series(D.AR1_N, D.AR1_PHI, D.AR1_SIGMA, rng))
for _ in range(500)
]
naive_se_avg = float(np.mean(naive_ses))
ratio = true_se / naive_se_avg
print(f"AR(1) series: n = {D.AR1_N}, phi = {D.AR1_PHI}, sigma = {D.AR1_SIGMA}")
print(f"TRUE standard error (from {D.AR1_REPLICATIONS} independent replications) = {true_se:.4f}")
print(f"NAIVE standard error (sample_std / sqrt(n), averaged over 500 series) = {naive_se_avg:.4f}")
print(f"ratio (true / naive) = {ratio:.2f}")
print()
print("An analyst who trusted the naive formula here would report a confidence "
"interval that is too narrow -- not slightly, but by a factor that a "
"single glance at the ratio above makes obvious.")
assert ratio > D.AR1_INFLATION_FLOOR, (
f"the naive SE was not meaningfully smaller than the true SE (ratio = {ratio:.2f}, "
f"expected above {D.AR1_INFLATION_FLOOR})"
)
print("07_dependence_inflates_se.py: every assertion held.")
examples/08_the_evaluation_margin.py (1622 bytes)
"""Exercise 8 -- the evaluation-margin calculation.
Every accuracy number reported for a model on a held-out set is itself a
sample statistic -- a proportion, computed from a fixed number of examples --
and it carries a standard error exactly like any other. This exercise makes
the AI thread's claim a test: an accuracy of 91.4% measured on 500 examples
has a standard error of about 1.25 percentage points, so a 0.3-point
difference between two models on that same set is comfortably inside one
standard error of noise, not a demonstrated improvement.
"""
import dataset as D
from sampling import binomial_standard_error
se = binomial_standard_error(D.EVAL_ACCURACY, D.EVAL_N)
se_pct = se * 100.0
margin_in_se_units = D.EVAL_MARGIN_PCT / se_pct
print(f"accuracy = {D.EVAL_ACCURACY:.1%} on {D.EVAL_N} held-out examples")
print(f"standard error = sqrt(p_hat * (1 - p_hat) / n) = {se:.5f} = {se_pct:.3f} percentage points")
print()
print(f"a {D.EVAL_MARGIN_PCT} percentage-point difference between two models on this set "
f"is {margin_in_se_units:.2f} standard errors -- ")
print("well inside one standard error, i.e. indistinguishable from noise on a set this size.")
assert abs(se_pct - D.EVAL_EXPECTED_SE_PCT) < D.EVAL_SE_TOLERANCE_PCT, (
f"the binomial standard error came out to {se_pct:.3f} percentage points, "
f"expected close to {D.EVAL_EXPECTED_SE_PCT}"
)
assert margin_in_se_units < 1.0, (
f"a {D.EVAL_MARGIN_PCT}-point margin should sit well inside one standard error "
f"({margin_in_se_units:.2f} SE), not outside it"
)
print("08_the_evaluation_margin.py: every assertion held.")
examples/09_reproducibility.py (2390 bytes)
"""Exercise 9 -- reproducibility.
Every simulation in this lab depends on a seeded `numpy.random.Generator`.
The same seed must give bit-identical results -- otherwise none of the
"measured X" claims elsewhere in this lab mean anything, because a rerun
could silently produce a different number. A different seed should give a
DIFFERENT sampling distribution that still agrees with the first one within
a tolerance derived from the standard error, since both are estimating the
same underlying quantity.
"""
import numpy as np
import dataset as D
from sampling import sampling_distribution, theoretical_standard_error, population_mean_std
rng_a1 = np.random.default_rng(D.REPRO_SEED_A)
rng_a2 = np.random.default_rng(D.REPRO_SEED_A)
rng_b = np.random.default_rng(D.REPRO_SEED_B)
means_a1 = sampling_distribution(D.SKEWED_POP, D.REPRO_N, D.REPRO_TRIALS, rng_a1)
means_a2 = sampling_distribution(D.SKEWED_POP, D.REPRO_N, D.REPRO_TRIALS, rng_a2)
means_b = sampling_distribution(D.SKEWED_POP, D.REPRO_N, D.REPRO_TRIALS, rng_b)
identical = np.array_equal(means_a1, means_a2)
different = not np.array_equal(means_a1, means_b)
pop_mean, pop_sigma = population_mean_std(D.SKEWED_POP)
theoretical_se = theoretical_standard_error(pop_sigma, D.REPRO_N)
# The standard error of an estimate built from REPRO_TRIALS sample means.
se_of_mean_estimate = theoretical_se / np.sqrt(D.REPRO_TRIALS)
gap_a_b = abs(means_a1.mean() - means_b.mean())
print(f"seed {D.REPRO_SEED_A}, run 1: first three means = {means_a1[:3]}")
print(f"seed {D.REPRO_SEED_A}, run 2: first three means = {means_a2[:3]}")
print(f"seed {D.REPRO_SEED_B}, run 1: first three means = {means_b[:3]}")
print()
print(f"same seed produces bit-identical arrays: {identical}")
print(f"different seed produces a different array: {different}")
print(f"gap between seed {D.REPRO_SEED_A}'s and seed {D.REPRO_SEED_B}'s estimate of the mean "
f"= {gap_a_b:.5f} ({gap_a_b / se_of_mean_estimate:.2f} standard errors)")
assert identical, "the same seed did not reproduce identical results"
assert different, "two different seeds produced identical results, which should not happen"
assert gap_a_b < 5.0 * se_of_mean_estimate, (
"two different seeds' estimates of the population mean disagreed by more than "
"5 standard errors -- they should agree within simulation noise"
)
print("09_reproducibility.py: every assertion held.")
examples/conftest.py (1041 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain modules called `sampling` and
`dataset`, 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 copy was seen first and reuse it for the other --
so the 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 ("sampling", "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 (7218 bytes)
"""Every population, parameter and trial count used anywhere in this lab.
All data here is invented and deterministic: every population is generated
once, at import time, from `numpy.random.default_rng(DATASET_SEED)`. Nothing
is read from a file and nothing touches the network. If you want a different
population, change a parameter here and every script and test downstream
picks it up automatically.
"""
import numpy as np
DATASET_SEED = 117
_rng = np.random.default_rng(DATASET_SEED)
# ---------------------------------------------------------------------------
# Populations. POP_SIZE stands in for "the population" -- large enough that
# sampling from it with replacement behaves like sampling from the true
# infinite population to every decimal place this lab checks.
# ---------------------------------------------------------------------------
POP_SIZE = 200_000
# A skewed population: waiting-time shaped, heavily right-tailed. This is the
# population exercises 1, 2, 3 and 5 build the sampling distribution from.
SKEWED_SCALE = 3.0
SKEWED_POP = _rng.exponential(scale=SKEWED_SCALE, size=POP_SIZE)
# A biased coin: values are 0 or 1, P(1) = 0.2, so the population itself is
# skewed in the opposite direction from SKEWED_POP -- used in the lesson to
# show the CLT working on a population that is not just skewed but discrete
# with only two possible values.
COIN_P = 0.2
COIN_POP = (_rng.random(POP_SIZE) < COIN_P).astype(float)
# A lumpy, two-spike population: two clusters far apart with a little noise
# around each, and nothing in between -- about as far from bell-shaped as a
# population can look while still having finite variance. The two spikes
# carry UNEQUAL weight (80/20) so the population itself is genuinely skewed,
# not merely bimodal -- an equal-weight version is symmetric and has zero
# skewness already, which would make it useless for exercise 3's monotone-
# decrease check.
TWO_SPIKE_CENTERS = (-5.0, 5.0)
TWO_SPIKE_WEIGHTS = (0.8, 0.2)
TWO_SPIKE_NOISE = 0.4
_spike_choice = _rng.choice(TWO_SPIKE_CENTERS, size=POP_SIZE, p=TWO_SPIKE_WEIGHTS)
TWO_SPIKE_POP = _spike_choice + _rng.normal(0.0, TWO_SPIKE_NOISE, size=POP_SIZE)
# ---------------------------------------------------------------------------
# Exercise 1 -- the sampling distribution itself
# ---------------------------------------------------------------------------
EX1_N = 40
EX1_TRIALS = 20_000
# ---------------------------------------------------------------------------
# Exercise 2 -- the sqrt(n) law
# ---------------------------------------------------------------------------
SQRT_N_LAW_NS = (10, 40, 160, 640)
SQRT_N_LAW_TRIALS = 20_000
# Each successive n is exactly 4x the one before it, so the standard error
# should shrink by a factor of exactly sqrt(4) = 2 each step. Simulation noise
# keeps the measured ratio from landing on 2.000 exactly; this tolerance was
# checked across seeds 1, 2, 3, 42, 117 and 999 before being fixed here, and
# the worst observed ratio across those runs was 1.98.
SQRT_N_LAW_RATIO_TOLERANCE = 0.25
# ---------------------------------------------------------------------------
# Exercise 3 -- the CLT's skewness signature
# ---------------------------------------------------------------------------
SKEW_DEMO_NS = (2, 5, 20, 80, 320)
SKEW_DEMO_TRIALS = 50_000
# ---------------------------------------------------------------------------
# Exercise 4 -- the Cauchy counterexample
# ---------------------------------------------------------------------------
CAUCHY_DEMO_TRIALS = 20_000
CAUCHY_DEMO_N_SMALL = 10
CAUCHY_DEMO_N_LARGE = 1_000
EXPONENTIAL_SCALE = 1.0
# From n=10 to n=1000 the sample size grows 100x, so a finite-variance mean's
# spread should shrink by close to sqrt(100) = 10x. Checked across six seeds,
# the exponential ratio never fell below 9.7; 8.0 leaves comfortable margin
# while still ruling out "did not shrink".
EXPONENTIAL_SHRINK_FLOOR = 8.0
# The Cauchy mean's spread should NOT shrink at all -- checked across the same
# six seeds, the ratio stayed within 0.98-1.03. A factor-of-3 band in either
# direction is generous and still miles from the exponential's ~10x.
CAUCHY_NO_SHRINK_LOW = 1.0 / 3.0
CAUCHY_NO_SHRINK_HIGH = 3.0
# ---------------------------------------------------------------------------
# Exercise 5 -- bias does not shrink
# ---------------------------------------------------------------------------
BIAS_DEMO_TRIALS = 5_000
BIAS_DEMO_N_SMALL = 30
BIAS_DEMO_N_LARGE = 3_000
# Same 100x growth in n as the Cauchy exercise. The unbiased sampler's mean
# absolute error should shrink by close to 10x; checked across six seeds the
# ratio stayed in 9.8-10.3, so a floor of 7 is comfortable.
UNBIASED_SHRINK_FLOOR = 7.0
# The biased sampler's error should stay roughly flat. Checked across six
# seeds the ratio (error at n=30 / error at n=3000) stayed within 0.99-1.00;
# a band of 0.4-2.5 is generous and still clearly distinguishes "flat" from
# the unbiased sampler's ~10x drop.
BIASED_FLAT_LOW = 0.4
BIASED_FLAT_HIGH = 2.5
# ---------------------------------------------------------------------------
# Exercise 6 -- the bootstrap, from scratch
# ---------------------------------------------------------------------------
BOOTSTRAP_SAMPLE_SIZE = 200
BOOTSTRAP_SAMPLE_MEAN = 50.0
BOOTSTRAP_SAMPLE_STD = 10.0
BOOTSTRAP_N_BOOT = 5_000
# The bootstrap standard error of the MEAN has a closed form to check against
# (sigma_hat / sqrt(n)), so this tolerance can be tight: checked across six
# seeds, the relative error never exceeded 0.7%.
BOOTSTRAP_MEAN_RELATIVE_TOLERANCE = 0.15
# The MEDIAN has no closed form. "Sane" here means: within a generous factor
# of the spread of medians computed from genuinely fresh independent samples
# of the population. Checked across six seeds, the ratio of the two spreads
# stayed within 0.57-1.11; a factor of 3 in either direction leaves room for
# the small-sample noise inherent in estimating a spread of a spread.
BOOTSTRAP_MEDIAN_SANITY_LOW = 1.0 / 3.0
BOOTSTRAP_MEDIAN_SANITY_HIGH = 3.0
FRESH_MEDIAN_REPLICATIONS = 2_000
# ---------------------------------------------------------------------------
# Exercise 7 -- dependence inflates the true standard error
# ---------------------------------------------------------------------------
AR1_N = 200
AR1_PHI = 0.7
AR1_SIGMA = 1.0
AR1_REPLICATIONS = 3_000
# Checked across six seeds, true_se / naive_se stayed within 2.34-2.46 for
# this (phi, n) combination, so a floor of 1.5 leaves a wide, safe margin
# while still requiring a real, meaningful understatement.
AR1_INFLATION_FLOOR = 1.5
# ---------------------------------------------------------------------------
# Exercise 8 -- the evaluation-margin calculation
# ---------------------------------------------------------------------------
EVAL_ACCURACY = 0.914
EVAL_N = 500
EVAL_EXPECTED_SE_PCT = 1.25 # percentage points, to 2 decimal places
EVAL_SE_TOLERANCE_PCT = 0.05
EVAL_MARGIN_PCT = 0.3
# ---------------------------------------------------------------------------
# Exercise 9 -- reproducibility
# ---------------------------------------------------------------------------
REPRO_N = 40
REPRO_TRIALS = 2_000
REPRO_SEED_A = 7
REPRO_SEED_B = 8
examples/sampling.py (7235 bytes)
"""Sampling distributions, the standard error, and the bootstrap -- from
scratch.
Every function here works from first principles: `numpy.random.Generator`
supplies uniform and named draws, and everything else -- the sampling
distribution itself, its standard error, its skewness, the bootstrap, the
naive-versus-true standard error under dependence -- is built on top of that,
not imported from a library that already does it.
"""
from __future__ import annotations
import math
from collections.abc import Callable
import numpy as np
def sampling_distribution(
population: np.ndarray, n: int, trials: int, rng: np.random.Generator
) -> np.ndarray:
"""Draw `trials` independent samples of size `n`, with replacement, from
`population`, and return the array of `trials` sample means.
This is the central object of the whole lesson: not a single statistic,
but the distribution *of* a statistic, built by literally repeating the
experiment.
"""
idx = rng.integers(0, population.shape[0], size=(trials, n))
return population[idx].mean(axis=1)
def population_mean_std(population: np.ndarray) -> tuple[float, float]:
"""The population mean and the population standard deviation (divided by
n, not n-1 -- there is no estimation happening here, the population is
fully known)."""
return float(population.mean()), float(population.std(ddof=0))
def theoretical_standard_error(sigma: float, n: int) -> float:
"""The standard error of the sample mean: sigma / sqrt(n)."""
return sigma / math.sqrt(n)
def skewness(values: np.ndarray) -> float:
"""The sample skewness g1 = m3 / m2^1.5, where mk is the k-th central
moment. Zero for a symmetric distribution (including the Normal),
positive for a right-tailed one."""
values = np.asarray(values, dtype=float)
centered = values - values.mean()
m2 = np.mean(centered**2)
m3 = np.mean(centered**3)
return float(m3 / m2**1.5)
def iqr(values: np.ndarray) -> float:
"""The interquartile range: a spread measure that stays meaningful even
when the standard deviation does not, because it depends only on the
order of the data, not on its moments. A Cauchy-distributed sample has an
undefined population variance, so its sample standard deviation is not
an estimate of anything -- the IQR is not affected by that at all."""
values = np.asarray(values, dtype=float)
q75, q25 = np.percentile(values, [75, 25])
return float(q75 - q25)
def exponential_mean_iqr(
n: int, trials: int, rng: np.random.Generator, scale: float = 1.0
) -> float:
"""The IQR of the sampling distribution of the mean, for `trials`
samples of size `n` drawn directly from Exponential(scale)."""
draws = rng.exponential(scale=scale, size=(trials, n))
return iqr(draws.mean(axis=1))
def cauchy_mean_iqr(n: int, trials: int, rng: np.random.Generator) -> float:
"""The IQR of the sampling distribution of the mean, for `trials`
samples of size `n` drawn from the standard Cauchy distribution.
The Cauchy distribution has no defined mean or variance -- its tails are
too heavy for either integral to converge. Averaging n Cauchy draws
produces a value that is ITSELF standard-Cauchy distributed, for every n,
which this function's caller checks by comparing this IQR at two very
different values of n and finding them the same.
"""
draws = rng.standard_cauchy(size=(trials, n))
return iqr(draws.mean(axis=1))
def mean_absolute_error(estimates: np.ndarray, truth: float) -> float:
"""The average absolute distance between a set of estimates and the
truth they are estimating -- a single number combining bias and
variance, which is exactly why it is the right thing to track across
exercise 5."""
return float(np.mean(np.abs(np.asarray(estimates, dtype=float) - truth)))
def biased_pool(population: np.ndarray) -> np.ndarray:
"""The subset of the population strictly above its own median -- a
sampling frame that can never produce a draw from the lower half, no
matter how many draws it makes."""
threshold = np.median(population)
return population[population > threshold]
def bootstrap_replicates(
data: np.ndarray,
statistic: Callable[[np.ndarray], np.ndarray],
n_boot: int,
rng: np.random.Generator,
) -> np.ndarray:
"""Resample `data` with replacement `n_boot` times (each resample the
same size as `data`), apply `statistic` to every resample, and return the
`n_boot` results.
`statistic` must accept a 2-D array of shape (n_boot, len(data)) and
return a 1-D array of shape (n_boot,) -- `lambda a: a.mean(axis=1)` or
`lambda a: numpy.median(a, axis=1)` are the two used in this lab.
"""
data = np.asarray(data, dtype=float)
n = data.shape[0]
idx = rng.integers(0, n, size=(n_boot, n))
resamples = data[idx]
return np.asarray(statistic(resamples), dtype=float)
def bootstrap_standard_error(
data: np.ndarray,
statistic: Callable[[np.ndarray], np.ndarray],
n_boot: int,
rng: np.random.Generator,
) -> float:
"""The bootstrap estimate of a statistic's standard error: the standard
deviation of the statistic computed across the bootstrap replicates.
No formula for the statistic's sampling distribution is used or needed."""
replicates = bootstrap_replicates(data, statistic, n_boot, rng)
return float(replicates.std(ddof=1))
def ar1_series(n: int, phi: float, sigma: float, rng: np.random.Generator) -> np.ndarray:
"""A length-n AR(1) series: x[0] ~ Normal(0, sigma), and each later value
is `phi * previous + innovation`, with the innovation's variance chosen
so the series' own marginal variance stays sigma^2 throughout (the
stationary AR(1) variance is innovation_variance / (1 - phi^2))."""
x = np.empty(n, dtype=float)
x[0] = rng.normal(0.0, sigma)
innovation_sigma = sigma * math.sqrt(1.0 - phi**2)
for t in range(1, n):
x[t] = phi * x[t - 1] + rng.normal(0.0, innovation_sigma)
return x
def naive_standard_error(series: np.ndarray) -> float:
"""The textbook formula, sample_std / sqrt(n), applied as if the
observations were independent -- which is exactly the assumption an
autocorrelated series violates."""
series = np.asarray(series, dtype=float)
n = series.shape[0]
return float(series.std(ddof=1) / math.sqrt(n))
def true_standard_error_by_replication(
n: int, phi: float, sigma: float, replications: int, rng: np.random.Generator
) -> float:
"""The actual standard error of the sample mean of an AR(1) series,
measured the only way that does not assume independence: generate many
independent length-n series, compute each one's sample mean, and take
the standard deviation of THOSE means."""
means = np.array(
[ar1_series(n, phi, sigma, rng).mean() for _ in range(replications)]
)
return float(means.std(ddof=1))
def binomial_standard_error(phat: float, n: int) -> float:
"""The standard error of a sample proportion (equivalently, an accuracy
measured on n examples): sqrt(phat * (1 - phat) / n)."""
return math.sqrt(phat * (1.0 - phat) / n)
examples/test_reference.py (6316 bytes)
"""The reference pytest suite: every function in `sampling.py`, checked
against real values from real seeded runs.
Run from the lab directory:
.venv/bin/pytest examples -q
"""
import math
import numpy as np
import pytest
import dataset as D
import sampling as S
# --------------------------------------------------------------------------
# sampling_distribution / theoretical_standard_error
# --------------------------------------------------------------------------
def test_sampling_distribution_returns_one_mean_per_trial():
rng = np.random.default_rng(101)
means = S.sampling_distribution(D.SKEWED_POP, n=20, trials=500, rng=rng)
assert means.shape == (500,)
def test_sampling_distribution_mean_is_close_to_population_mean():
rng = np.random.default_rng(102)
pop_mean, pop_sigma = S.population_mean_std(D.SKEWED_POP)
n, trials = 50, 20_000
means = S.sampling_distribution(D.SKEWED_POP, n, trials, rng)
se_of_mean = S.theoretical_standard_error(pop_sigma, n) / math.sqrt(trials)
assert abs(means.mean() - pop_mean) < 4.0 * se_of_mean
def test_theoretical_standard_error_scales_as_inverse_sqrt_n():
assert S.theoretical_standard_error(10.0, 100) == pytest.approx(1.0)
assert S.theoretical_standard_error(10.0, 400) == pytest.approx(0.5)
# --------------------------------------------------------------------------
# skewness / iqr
# --------------------------------------------------------------------------
def test_skewness_of_a_symmetric_sample_is_near_zero():
rng = np.random.default_rng(103)
symmetric = rng.normal(0.0, 1.0, size=200_000)
assert abs(S.skewness(symmetric)) < 0.02
def test_skewness_of_an_exponential_population_is_positive_and_near_two():
# The Exponential distribution's population skewness is exactly 2,
# regardless of scale. This checks the from-scratch estimator against
# that known constant on a large sample.
assert S.skewness(D.SKEWED_POP) == pytest.approx(2.0, abs=0.05)
def test_iqr_of_a_known_uniform_sample():
values = np.arange(0, 101) # 0..100, IQR should be exactly 50
assert S.iqr(values) == pytest.approx(50.0)
def test_iqr_ignores_a_single_extreme_outlier():
values = np.concatenate([np.arange(0, 99), [1_000_000.0]])
assert S.iqr(values) < 100.0 # unaffected by the one wild value
# --------------------------------------------------------------------------
# exponential_mean_iqr / cauchy_mean_iqr
# --------------------------------------------------------------------------
def test_exponential_mean_iqr_shrinks_with_more_data():
rng = np.random.default_rng(104)
small = S.exponential_mean_iqr(10, 5_000, rng, scale=1.0)
large = S.exponential_mean_iqr(1_000, 5_000, rng, scale=1.0)
assert small / large > 5.0
def test_cauchy_mean_iqr_does_not_shrink_with_more_data():
rng = np.random.default_rng(105)
small = S.cauchy_mean_iqr(10, 5_000, rng)
large = S.cauchy_mean_iqr(1_000, 5_000, rng)
assert 0.3 < small / large < 3.0
# --------------------------------------------------------------------------
# mean_absolute_error / biased_pool
# --------------------------------------------------------------------------
def test_mean_absolute_error_is_zero_for_exact_estimates():
assert S.mean_absolute_error(np.array([5.0, 5.0, 5.0]), 5.0) == 0.0
def test_mean_absolute_error_is_positive_for_biased_estimates():
assert S.mean_absolute_error(np.array([6.0, 7.0, 8.0]), 5.0) == pytest.approx(2.0)
def test_biased_pool_contains_only_values_above_the_median():
values = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
pool = S.biased_pool(values)
assert (pool > np.median(values)).all()
assert len(pool) < len(values)
# --------------------------------------------------------------------------
# bootstrap_replicates / bootstrap_standard_error
# --------------------------------------------------------------------------
def test_bootstrap_replicates_shape():
rng = np.random.default_rng(106)
data = np.arange(50, dtype=float)
reps = S.bootstrap_replicates(data, lambda a: a.mean(axis=1), 300, rng)
assert reps.shape == (300,)
def test_bootstrap_se_of_the_mean_agrees_with_the_formula():
rng = np.random.default_rng(107)
data = rng.normal(0.0, 5.0, size=300)
sigma_hat = data.std(ddof=1)
theoretical = sigma_hat / math.sqrt(len(data))
boot_se = S.bootstrap_standard_error(data, lambda a: a.mean(axis=1), 4_000, rng)
assert abs(boot_se - theoretical) / theoretical < 0.15
# --------------------------------------------------------------------------
# ar1_series / naive_standard_error / true_standard_error_by_replication
# --------------------------------------------------------------------------
def test_ar1_series_has_the_right_length():
rng = np.random.default_rng(108)
series = S.ar1_series(n=50, phi=0.5, sigma=1.0, rng=rng)
assert series.shape == (50,)
def test_ar1_series_is_independent_when_phi_is_zero():
rng = np.random.default_rng(109)
naive = np.mean(
[S.naive_standard_error(S.ar1_series(300, 0.0, 1.0, rng)) for _ in range(200)]
)
true_se = S.true_standard_error_by_replication(300, 0.0, 1.0, 600, rng)
# With phi = 0, there is no dependence, so naive and true should agree
# closely -- this is the control case for exercise 7's main result.
assert abs(naive - true_se) / true_se < 0.25
def test_dependence_makes_naive_se_understate_the_true_se():
rng = np.random.default_rng(110)
naive = np.mean(
[S.naive_standard_error(S.ar1_series(D.AR1_N, D.AR1_PHI, D.AR1_SIGMA, rng)) for _ in range(200)]
)
true_se = S.true_standard_error_by_replication(D.AR1_N, D.AR1_PHI, D.AR1_SIGMA, 1_500, rng)
assert true_se > naive
# --------------------------------------------------------------------------
# binomial_standard_error
# --------------------------------------------------------------------------
def test_binomial_standard_error_matches_the_brief():
se_pct = S.binomial_standard_error(0.914, 500) * 100.0
assert se_pct == pytest.approx(1.25, abs=0.05)
def test_binomial_standard_error_is_largest_at_p_one_half():
se_half = S.binomial_standard_error(0.5, 100)
se_extreme = S.binomial_standard_error(0.99, 100)
assert se_half > se_extreme
metadata.yml (4550 bytes)
lesson_id: D117
day: 117
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/math-statistics-and-data/day-117-sampling-and-the-central-limit-theorem
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/python3 -c "import numpy; print(numpy.__version__)"
run_commands:
- 'cd examples && ../.venv/bin/python3 01_sampling_distribution.py && cd ..'
- 'cd examples && ../.venv/bin/python3 02_the_sqrt_n_law.py && cd ..'
- 'cd examples && ../.venv/bin/python3 03_clt_from_a_skewed_population.py && cd ..'
- 'cd examples && ../.venv/bin/python3 04_the_cauchy_counterexample.py && cd ..'
- 'cd examples && ../.venv/bin/python3 05_bias_does_not_shrink.py && cd ..'
- 'cd examples && ../.venv/bin/python3 06_bootstrap_from_scratch.py && cd ..'
- 'cd examples && ../.venv/bin/python3 07_dependence_inflates_se.py && cd ..'
- 'cd examples && ../.venv/bin/python3 08_the_evaluation_margin.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: 35
last_executed: '2026-08-17'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 32 checks, 0 failure(s), exit 0; pytest examples -> 19 passed; pytest starter -> 1 passed, 12 skipped on an untouched checkout, and 13 passed against a fully solved copy of starter/ (verified by temporarily copying the reference sampling.py into starter/, confirming all 13 tests passed, then restoring the blank skeleton -- the skip count after restoring was confirmed back to 12, and collecting both suites together also reports 12 skipped, proving the two conftest.py import guards work). 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 5 of the harness re-runs script 08 (the evaluation-margin calculation) with its expected standard error temporarily replaced with a deliberately wrong value (99.0 instead of 1.25 percentage points), confirms the run exits non-zero with the named AssertionError showing both the wrong expectation and the correctly-computed value, and does not touch the real dataset.py file on disk -- so the suite is demonstrated to be capable of failing rather than merely claimed to be. Three honesty notes from this run. FIRST: scipy, pandas and matplotlib are not installed in this environment. scipy.stats (bootstrap, sem) and pandas'' DataFrame.sample are described from their public documentation in the lesson''s Tools section and explicitly marked as not run here; no output attributed to either anywhere in this lab or its lesson was actually produced by them. SECOND: every sampled figure in this lab (every standard error, skewness value, IQR, bootstrap replicate spread and AR(1) replication result) is a freshly measured number rather than a fixed literal, checked against a tolerance derived from a standard error, an inverse-square-root ratio, or a generous sanity band rather than a value chosen to make the test pass; the specific figures will differ slightly on another machine or NumPy version, as documented in expected-output/FIELDS.md. THIRD: every tolerance in dataset.py (the sqrt(n)-law ratio band, the Cauchy/Exponential shrink floors, the bias-flat band, the bootstrap-median sanity band, and the AR(1) inflation floor) was checked by rerunning the exercise logic across six different seeds (1, 2, 3, 42, 117, 999) during development before being fixed in that file, and the specific ranges observed across those seeds are recorded in the comments beside each tolerance -- this is reported as observed behaviour across six runs, not as a formal guarantee no seed can ever violate it. The binomial standard error (1.254 percentage points for 91.4% accuracy on 500 examples) is exact arithmetic from a closed-form formula and is identical on any correct implementation, anywhere.'
requirements/README.md (3190 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 | `numpy.random.default_rng` for every seeded draw, and vectorised array operations for building sampling distributions of tens of thousands of trials at once. |
| `pytest` | 9.1.1 | MIT | The reference suite (19 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 6 of
`tests/run_tests.sh` greps every source file in `examples/` and `starter/`
to prove that nothing else does.
## What is deliberately *not* installed
**`scipy.stats`** does the two heaviest pieces of this lab's work for you:
`scipy.stats.bootstrap` runs exercise 6 in a few lines, with several
confidence-interval methods to choose from, and `scipy.stats.sem` computes
exercise 1's standard error of the mean directly from a sample. Neither is
installed here, and **no output from scipy is reproduced anywhere** in
this lab or its lesson. The lesson's Tools section describes both from
their public documentation.
That is not a limitation to apologise for. The bootstrap you write in
`starter/sampling.py` -- resample with replacement, recompute the
statistic, read the standard error off the spread -- is the exact idea
`scipy.stats.bootstrap` implements. The difference between the two is
engineering: bias-corrected-and-accelerated confidence intervals, batched
vectorisation, and a stable public API. Having written the fifteen-line
version, you will read `scipy.stats.bootstrap`'s documentation differently.
**`pandas`** is also not installed. `DataFrame.sample` does a version of
this lab's population sampling on tabular data, and is described from its
documentation in the lesson's Tools section, not run here.
**`matplotlib`** is not installed either. Every figure this lesson
describes -- the sampling distribution's shape, the Cauchy-versus-
Exponential contrast -- is described in words and numbers rather than
plotted; the two hand-authored SVG diagrams in the lesson carry the visual
argument instead.
## If you cannot install anything at all
You still need NumPy for this lab, unlike some earlier days in this
section: every exercise draws random samples, and the standard library's
`random` module does not offer the same vectorised batch sampling this lab
relies on to draw tens of thousands of trials in a single call without
writing a Python-level loop for each one. If NumPy genuinely cannot be
installed, the *ideas* -- a statistic has a distribution, the standard
error shrinks as `1/sqrt(n)`, bias does not shrink, the bootstrap resamples
with replacement -- can still be worked through by hand on a small dataset
with `random.Random`, but this lab's exercises and tests are not written
against that path.
requirements/requirements.txt (27 bytes)
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (3906 bytes)
# The nine exercises
Work through these in order, in `sampling.py`. 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 sampling distribution itself
`sampling_distribution(population, n, trials, rng)` and
`population_mean_std(population)`. Draw `trials` independent samples of size
`n` from `population`, with replacement, and return the array of sample
means. Assert its own mean is close to the population mean, and its own
standard deviation close to `sigma / sqrt(n)`, both within three standard
errors — a statistic has a distribution too, and this is what it looks like.
## 2. The standard error and the sqrt(n) law
`theoretical_standard_error(sigma, n)`. For `n` in `{10, 40, 160, 640}` --
each exactly 4x the one before -- assert the measured standard error roughly
**halves** each time, not quarters. Assert the *ratios* between successive
standard errors, not four hard-coded values.
## 3. The CLT from a skewed population
`skewness(values)`. Measure the skewness of the sampling distribution of the
mean for an increasingly large `n`, drawn from a heavily right-skewed
population. Assert it falls monotonically toward zero as `n` grows -- the
population's lopsidedness washes out of the statistic.
## 4. The Cauchy counterexample
`iqr(values)`, `exponential_mean_iqr(n, trials, rng, scale)`,
`cauchy_mean_iqr(n, trials, rng)`. For an Exponential population, assert the
spread of the sample mean shrinks by roughly the expected factor from n=10
to n=1000. For a **Cauchy** population, assert it does **not** shrink. Use
the IQR rather than the standard deviation -- a Cauchy sample's standard
deviation is not an estimate of anything, because the population variance it
would estimate does not exist.
## 5. Bias does not shrink
`mean_absolute_error(estimates, truth)`, `biased_pool(population)`. Build a
sampler that can only draw from the half of the population above its own
median. Assert its error stays roughly constant as `n` grows by a factor of
100, while an unbiased sampler's error shrinks by roughly ten -- more data
buys a biased sampler a more *precise* wrong answer, never a correct one.
## 6. The bootstrap, from scratch
`bootstrap_replicates(data, statistic, n_boot, rng)`,
`bootstrap_standard_error(data, statistic, n_boot, rng)`. Resample a dataset
with replacement, recompute a statistic on every resample, and read its
standard error off the spread of the results. Assert it agrees with
`sigma_hat / sqrt(n)` for the mean, then apply the same code to the
**median**, where no simple formula exists, and check the result for
sanity against the spread of medians from genuinely fresh samples.
## 7. Dependence inflates the true standard error
`ar1_series(n, phi, sigma, rng)`, `naive_standard_error(series)`,
`true_standard_error_by_replication(n, phi, sigma, replications, rng)`.
Generate an autocorrelated series, measure its TRUE standard error by
replication, and assert the naive `sample_std / sqrt(n)` formula understates
it meaningfully.
## 8. The evaluation-margin calculation
`binomial_standard_error(phat, n)`. For an accuracy of 91.4% on 500
examples, assert the standard error is about 1.25 percentage points, and
that a 0.3-point difference between two models is well inside one standard
error -- noise, not a demonstrated improvement.
## 9. Reproducibility
Nothing new to write here -- exercise 9 in `examples/09_reproducibility.py`
runs against whatever `sampling_distribution` you wrote for exercise 1. The
same seed must give identical results; a different seed must give different
results that still agree within tolerance. If exercise 1 is correct, this
one follows for free -- and if it does not hold, that is a sign exercise 1's
random-index construction is not using `rng` correctly.
starter/conftest.py (1043 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain modules called `sampling` and
`dataset`, 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 copy was seen first and reuse it for the other --
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 ("sampling", "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 (4925 bytes)
"""Every population, parameter and trial count used anywhere in this lab.
This file is complete and does not need editing -- your work for each
exercise lives in `sampling.py`. All data here is invented and deterministic:
every population is generated once, at import time, from
`numpy.random.default_rng(DATASET_SEED)`. Nothing is read from a file and
nothing touches the network.
"""
import numpy as np
DATASET_SEED = 117
_rng = np.random.default_rng(DATASET_SEED)
# ---------------------------------------------------------------------------
# Populations. POP_SIZE stands in for "the population" -- large enough that
# sampling from it with replacement behaves like sampling from the true
# infinite population to every decimal place this lab checks.
# ---------------------------------------------------------------------------
POP_SIZE = 200_000
# A skewed population: waiting-time shaped, heavily right-tailed. This is the
# population exercises 1, 2, 3 and 5 build the sampling distribution from.
SKEWED_SCALE = 3.0
SKEWED_POP = _rng.exponential(scale=SKEWED_SCALE, size=POP_SIZE)
# A biased coin: values are 0 or 1, P(1) = 0.2, so the population itself is
# skewed in the opposite direction from SKEWED_POP -- used in the lesson to
# show the CLT working on a population that is not just skewed but discrete
# with only two possible values.
COIN_P = 0.2
COIN_POP = (_rng.random(POP_SIZE) < COIN_P).astype(float)
# A lumpy, two-spike population: two clusters far apart with a little noise
# around each, and nothing in between -- about as far from bell-shaped as a
# population can look while still having finite variance. The two spikes
# carry UNEQUAL weight (80/20) so the population itself is genuinely skewed,
# not merely bimodal.
TWO_SPIKE_CENTERS = (-5.0, 5.0)
TWO_SPIKE_WEIGHTS = (0.8, 0.2)
TWO_SPIKE_NOISE = 0.4
_spike_choice = _rng.choice(TWO_SPIKE_CENTERS, size=POP_SIZE, p=TWO_SPIKE_WEIGHTS)
TWO_SPIKE_POP = _spike_choice + _rng.normal(0.0, TWO_SPIKE_NOISE, size=POP_SIZE)
# ---------------------------------------------------------------------------
# Exercise 1 -- the sampling distribution itself
# ---------------------------------------------------------------------------
EX1_N = 40
EX1_TRIALS = 20_000
# ---------------------------------------------------------------------------
# Exercise 2 -- the sqrt(n) law
# ---------------------------------------------------------------------------
SQRT_N_LAW_NS = (10, 40, 160, 640)
SQRT_N_LAW_TRIALS = 20_000
SQRT_N_LAW_RATIO_TOLERANCE = 0.25
# ---------------------------------------------------------------------------
# Exercise 3 -- the CLT's skewness signature
# ---------------------------------------------------------------------------
SKEW_DEMO_NS = (2, 5, 20, 80, 320)
SKEW_DEMO_TRIALS = 50_000
# ---------------------------------------------------------------------------
# Exercise 4 -- the Cauchy counterexample
# ---------------------------------------------------------------------------
CAUCHY_DEMO_TRIALS = 20_000
CAUCHY_DEMO_N_SMALL = 10
CAUCHY_DEMO_N_LARGE = 1_000
EXPONENTIAL_SCALE = 1.0
EXPONENTIAL_SHRINK_FLOOR = 8.0
CAUCHY_NO_SHRINK_LOW = 1.0 / 3.0
CAUCHY_NO_SHRINK_HIGH = 3.0
# ---------------------------------------------------------------------------
# Exercise 5 -- bias does not shrink
# ---------------------------------------------------------------------------
BIAS_DEMO_TRIALS = 5_000
BIAS_DEMO_N_SMALL = 30
BIAS_DEMO_N_LARGE = 3_000
UNBIASED_SHRINK_FLOOR = 7.0
BIASED_FLAT_LOW = 0.4
BIASED_FLAT_HIGH = 2.5
# ---------------------------------------------------------------------------
# Exercise 6 -- the bootstrap, from scratch
# ---------------------------------------------------------------------------
BOOTSTRAP_SAMPLE_SIZE = 200
BOOTSTRAP_SAMPLE_MEAN = 50.0
BOOTSTRAP_SAMPLE_STD = 10.0
BOOTSTRAP_N_BOOT = 5_000
BOOTSTRAP_MEAN_RELATIVE_TOLERANCE = 0.15
BOOTSTRAP_MEDIAN_SANITY_LOW = 1.0 / 3.0
BOOTSTRAP_MEDIAN_SANITY_HIGH = 3.0
FRESH_MEDIAN_REPLICATIONS = 2_000
# ---------------------------------------------------------------------------
# Exercise 7 -- dependence inflates the true standard error
# ---------------------------------------------------------------------------
AR1_N = 200
AR1_PHI = 0.7
AR1_SIGMA = 1.0
AR1_REPLICATIONS = 3_000
AR1_INFLATION_FLOOR = 1.5
# ---------------------------------------------------------------------------
# Exercise 8 -- the evaluation-margin calculation
# ---------------------------------------------------------------------------
EVAL_ACCURACY = 0.914
EVAL_N = 500
EVAL_EXPECTED_SE_PCT = 1.25 # percentage points, to 2 decimal places
EVAL_SE_TOLERANCE_PCT = 0.05
EVAL_MARGIN_PCT = 0.3
# ---------------------------------------------------------------------------
# Exercise 9 -- reproducibility
# ---------------------------------------------------------------------------
REPRO_N = 40
REPRO_TRIALS = 2_000
REPRO_SEED_A = 7
REPRO_SEED_B = 8
starter/sampling.py (4484 bytes)
"""Sampling distributions, the standard error, and the bootstrap -- from
scratch.
Work through `starter/00_brief.md` in order, filling in the functions below.
Check your progress with:
.venv/bin/pytest starter -q
Unattempted work reports as skipped, never failed. Every function currently
returns None -- replace the body, not just the return statement.
"""
from __future__ import annotations
import math
from collections.abc import Callable
import numpy as np
def sampling_distribution(
population: np.ndarray, n: int, trials: int, rng: np.random.Generator
) -> np.ndarray:
"""Exercise 1. Draw `trials` independent samples of size `n`, with
replacement, from `population`, and return the array of `trials` sample
means.
Hint: `rng.integers(0, population.shape[0], size=(trials, n))` gives you
every sample's indices in one call; index `population` with that array
and take `.mean(axis=1)`.
"""
return None
def population_mean_std(population: np.ndarray) -> tuple[float, float]:
"""The population mean and the population standard deviation (ddof=0,
since the whole population is known -- there is no estimation here)."""
return None
def theoretical_standard_error(sigma: float, n: int) -> float:
"""Exercise 1 / 2. The standard error of the sample mean: sigma / sqrt(n)."""
return None
def skewness(values: np.ndarray) -> float:
"""Exercise 3. The sample skewness g1 = m3 / m2^1.5, where mk is the k-th
central moment: mk = mean((x - mean(x))**k)."""
return None
def iqr(values: np.ndarray) -> float:
"""Exercise 4. The interquartile range: the 75th percentile minus the
25th. Use `numpy.percentile`."""
return None
def exponential_mean_iqr(
n: int, trials: int, rng: np.random.Generator, scale: float = 1.0
) -> float:
"""Exercise 4. The IQR of the sampling distribution of the mean, for
`trials` samples of size `n` drawn directly from Exponential(scale) via
`rng.exponential`."""
return None
def cauchy_mean_iqr(n: int, trials: int, rng: np.random.Generator) -> float:
"""Exercise 4. The same idea, drawing from `rng.standard_cauchy` instead."""
return None
def mean_absolute_error(estimates: np.ndarray, truth: float) -> float:
"""Exercise 5. The average absolute distance between a set of estimates
and the truth they are estimating."""
return None
def biased_pool(population: np.ndarray) -> np.ndarray:
"""Exercise 5. The subset of `population` strictly above its own median.
Use `numpy.median` and boolean indexing."""
return None
def bootstrap_replicates(
data: np.ndarray,
statistic: Callable[[np.ndarray], np.ndarray],
n_boot: int,
rng: np.random.Generator,
) -> np.ndarray:
"""Exercise 6. Resample `data` with replacement `n_boot` times (each
resample the same size as `data`), apply `statistic` to every resample,
and return the `n_boot` results.
Hint: build an index array of shape (n_boot, len(data)) with
`rng.integers`, index `data` with it to get all the resamples at once,
and pass that 2-D array straight to `statistic`.
"""
return None
def bootstrap_standard_error(
data: np.ndarray,
statistic: Callable[[np.ndarray], np.ndarray],
n_boot: int,
rng: np.random.Generator,
) -> float:
"""Exercise 6. The standard deviation (ddof=1) of `bootstrap_replicates`."""
return None
def ar1_series(n: int, phi: float, sigma: float, rng: np.random.Generator) -> np.ndarray:
"""Exercise 7. A length-n AR(1) series: x[0] ~ Normal(0, sigma), and each
later value is `phi * previous + innovation`, where the innovation's own
standard deviation is `sigma * sqrt(1 - phi**2)` -- chosen so the
series' marginal variance stays sigma**2 throughout."""
return None
def naive_standard_error(series: np.ndarray) -> float:
"""Exercise 7. sample_std(ddof=1) / sqrt(n) -- the textbook formula,
applied as if the observations were independent."""
return None
def true_standard_error_by_replication(
n: int, phi: float, sigma: float, replications: int, rng: np.random.Generator
) -> float:
"""Exercise 7. Generate `replications` independent length-n AR(1) series,
compute each one's sample mean, and return the standard deviation
(ddof=1) of those means."""
return None
def binomial_standard_error(phat: float, n: int) -> float:
"""Exercise 8. sqrt(phat * (1 - phat) / n)."""
return None
starter/test_starter.py (9835 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.
"""
import math
import numpy as np
import pytest
import dataset as D
import sampling as S
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 test_the_suite_itself_runs():
"""One test that always passes, so a green run is distinguishable from a
collection error that quietly ran nothing at all."""
assert D.POP_SIZE > 0
# --------------------------------------------------------------------------
# Exercise 1 -- the sampling distribution itself
# --------------------------------------------------------------------------
def test_1_sampling_distribution_has_one_mean_per_trial():
rng = np.random.default_rng(201)
means = attempt(
lambda: S.sampling_distribution(D.SKEWED_POP, n=20, trials=300, rng=rng),
"sampling_distribution",
)
assert means.shape == (300,)
def test_1_sampling_distribution_mean_is_close_to_population_mean():
rng = np.random.default_rng(202)
pop_mean, pop_sigma = attempt(
lambda: S.population_mean_std(D.SKEWED_POP), "population_mean_std"
)
n, trials = 50, 20_000
means = attempt(
lambda: S.sampling_distribution(D.SKEWED_POP, n, trials, rng), "sampling_distribution"
)
se_theory = attempt(
lambda: S.theoretical_standard_error(pop_sigma, n), "theoretical_standard_error"
)
se_of_mean = se_theory / math.sqrt(trials)
assert abs(means.mean() - pop_mean) < 4.0 * se_of_mean
# --------------------------------------------------------------------------
# Exercise 2 -- the sqrt(n) law
# --------------------------------------------------------------------------
def test_2_standard_error_halves_when_n_quadruples():
rng = np.random.default_rng(203)
ses = {}
for n in D.SQRT_N_LAW_NS:
means = attempt(
lambda n=n: S.sampling_distribution(D.SKEWED_POP, n, D.SQRT_N_LAW_TRIALS, rng),
"sampling_distribution",
)
ses[n] = means.std(ddof=1)
for smaller, larger in zip(D.SQRT_N_LAW_NS, D.SQRT_N_LAW_NS[1:]):
ratio = ses[smaller] / ses[larger]
assert abs(ratio - 2.0) < D.SQRT_N_LAW_RATIO_TOLERANCE, (
f"SE(n={smaller})/SE(n={larger}) = {ratio:.3f}, expected near 2.0"
)
# --------------------------------------------------------------------------
# Exercise 3 -- CLT from a skewed population
# --------------------------------------------------------------------------
def test_3_skewness_of_the_sampling_distribution_decreases_monotonically():
rng = np.random.default_rng(204)
skews = []
for n in D.SKEW_DEMO_NS:
means = attempt(
lambda n=n: S.sampling_distribution(D.SKEWED_POP, n, D.SKEW_DEMO_TRIALS, rng),
"sampling_distribution",
)
skews.append(attempt(lambda means=means: S.skewness(means), "skewness"))
for a, b in zip(skews, skews[1:]):
assert b < a, f"skewness did not decrease: {skews}"
# --------------------------------------------------------------------------
# Exercise 4 -- the Cauchy counterexample
# --------------------------------------------------------------------------
def test_4_exponential_mean_shrinks_but_cauchy_mean_does_not():
rng = np.random.default_rng(205)
exp_small = attempt(
lambda: S.exponential_mean_iqr(D.CAUCHY_DEMO_N_SMALL, D.CAUCHY_DEMO_TRIALS, rng, D.EXPONENTIAL_SCALE),
"exponential_mean_iqr",
)
exp_large = attempt(
lambda: S.exponential_mean_iqr(D.CAUCHY_DEMO_N_LARGE, D.CAUCHY_DEMO_TRIALS, rng, D.EXPONENTIAL_SCALE),
"exponential_mean_iqr",
)
cauchy_small = attempt(
lambda: S.cauchy_mean_iqr(D.CAUCHY_DEMO_N_SMALL, D.CAUCHY_DEMO_TRIALS, rng), "cauchy_mean_iqr"
)
cauchy_large = attempt(
lambda: S.cauchy_mean_iqr(D.CAUCHY_DEMO_N_LARGE, D.CAUCHY_DEMO_TRIALS, rng), "cauchy_mean_iqr"
)
assert exp_small / exp_large > D.EXPONENTIAL_SHRINK_FLOOR
assert D.CAUCHY_NO_SHRINK_LOW < cauchy_small / cauchy_large < D.CAUCHY_NO_SHRINK_HIGH
# --------------------------------------------------------------------------
# Exercise 5 -- bias does not shrink
# --------------------------------------------------------------------------
def test_5_biased_sampler_error_stays_flat_while_unbiased_shrinks():
rng = np.random.default_rng(206)
true_mean = float(D.SKEWED_POP.mean())
pool = attempt(lambda: S.biased_pool(D.SKEWED_POP), "biased_pool")
def err(population, n):
means = S.sampling_distribution(population, n, D.BIAS_DEMO_TRIALS, rng)
return attempt(lambda: S.mean_absolute_error(means, true_mean), "mean_absolute_error")
unbiased_small = err(D.SKEWED_POP, D.BIAS_DEMO_N_SMALL)
unbiased_large = err(D.SKEWED_POP, D.BIAS_DEMO_N_LARGE)
biased_small = err(pool, D.BIAS_DEMO_N_SMALL)
biased_large = err(pool, D.BIAS_DEMO_N_LARGE)
assert unbiased_small / unbiased_large > D.UNBIASED_SHRINK_FLOOR
assert D.BIASED_FLAT_LOW < biased_small / biased_large < D.BIASED_FLAT_HIGH
# --------------------------------------------------------------------------
# Exercise 6 -- the bootstrap, from scratch
# --------------------------------------------------------------------------
def test_6_bootstrap_se_of_mean_agrees_with_formula():
rng = np.random.default_rng(207)
data = rng.normal(D.BOOTSTRAP_SAMPLE_MEAN, D.BOOTSTRAP_SAMPLE_STD, D.BOOTSTRAP_SAMPLE_SIZE)
theoretical = data.std(ddof=1) / math.sqrt(len(data))
boot_se = attempt(
lambda: S.bootstrap_standard_error(data, lambda a: a.mean(axis=1), D.BOOTSTRAP_N_BOOT, rng),
"bootstrap_standard_error",
)
assert abs(boot_se - theoretical) / theoretical < D.BOOTSTRAP_MEAN_RELATIVE_TOLERANCE
def test_6_bootstrap_se_of_median_is_sane():
rng = np.random.default_rng(208)
data = rng.normal(D.BOOTSTRAP_SAMPLE_MEAN, D.BOOTSTRAP_SAMPLE_STD, D.BOOTSTRAP_SAMPLE_SIZE)
boot_se_median = attempt(
lambda: S.bootstrap_standard_error(data, lambda a: np.median(a, axis=1), D.BOOTSTRAP_N_BOOT, rng),
"bootstrap_standard_error",
)
fresh = rng.normal(
D.BOOTSTRAP_SAMPLE_MEAN, D.BOOTSTRAP_SAMPLE_STD, size=(D.FRESH_MEDIAN_REPLICATIONS, D.BOOTSTRAP_SAMPLE_SIZE)
)
fresh_se = np.median(fresh, axis=1).std(ddof=1)
ratio = boot_se_median / fresh_se
assert D.BOOTSTRAP_MEDIAN_SANITY_LOW < ratio < D.BOOTSTRAP_MEDIAN_SANITY_HIGH
# --------------------------------------------------------------------------
# Exercise 7 -- dependence inflates the true standard error
# --------------------------------------------------------------------------
def test_7_naive_se_understates_the_true_se_under_dependence():
rng = np.random.default_rng(209)
naive_ses = [
attempt(
lambda: S.naive_standard_error(S.ar1_series(D.AR1_N, D.AR1_PHI, D.AR1_SIGMA, rng)),
"naive_standard_error / ar1_series",
)
for _ in range(300)
]
naive_avg = float(np.mean(naive_ses))
true_se = attempt(
lambda: S.true_standard_error_by_replication(D.AR1_N, D.AR1_PHI, D.AR1_SIGMA, D.AR1_REPLICATIONS, rng),
"true_standard_error_by_replication",
)
assert true_se / naive_avg > D.AR1_INFLATION_FLOOR
# --------------------------------------------------------------------------
# Exercise 8 -- the evaluation-margin calculation
# --------------------------------------------------------------------------
def test_8_binomial_se_matches_the_brief_and_the_margin_is_noise():
se = attempt(lambda: S.binomial_standard_error(D.EVAL_ACCURACY, D.EVAL_N), "binomial_standard_error")
se_pct = se * 100.0
assert abs(se_pct - D.EVAL_EXPECTED_SE_PCT) < D.EVAL_SE_TOLERANCE_PCT
assert D.EVAL_MARGIN_PCT / se_pct < 1.0
# --------------------------------------------------------------------------
# Exercise 9 -- reproducibility (depends only on exercise 1)
# --------------------------------------------------------------------------
def test_9_same_seed_reproduces_identical_results():
a1 = attempt(
lambda: S.sampling_distribution(D.SKEWED_POP, D.REPRO_N, D.REPRO_TRIALS, np.random.default_rng(D.REPRO_SEED_A)),
"sampling_distribution",
)
a2 = attempt(
lambda: S.sampling_distribution(D.SKEWED_POP, D.REPRO_N, D.REPRO_TRIALS, np.random.default_rng(D.REPRO_SEED_A)),
"sampling_distribution",
)
assert np.array_equal(a1, a2)
def test_9_different_seed_gives_a_different_but_compatible_result():
a = attempt(
lambda: S.sampling_distribution(D.SKEWED_POP, D.REPRO_N, D.REPRO_TRIALS, np.random.default_rng(D.REPRO_SEED_A)),
"sampling_distribution",
)
b = attempt(
lambda: S.sampling_distribution(D.SKEWED_POP, D.REPRO_N, D.REPRO_TRIALS, np.random.default_rng(D.REPRO_SEED_B)),
"sampling_distribution",
)
assert not np.array_equal(a, b)
pop_mean, pop_sigma = attempt(lambda: S.population_mean_std(D.SKEWED_POP), "population_mean_std")
se_theory = attempt(lambda: S.theoretical_standard_error(pop_sigma, D.REPRO_N), "theoretical_standard_error")
se_of_mean = se_theory / math.sqrt(D.REPRO_TRIALS)
assert abs(a.mean() - b.mean()) < 5.0 * se_of_mean
tests/run_tests.sh (12055 bytes)
#!/usr/bin/env bash
# Tests for the Day 117 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:
#
# * a statistic has a distribution -- the sampling distribution of the mean
# has its own mean close to the population mean and its own spread close
# to sigma / sqrt(n);
# * the sqrt(n) law -- quadrupling n roughly halves the standard error,
# asserted as ratios across four sample sizes, not four fixed values;
# * the central limit theorem -- the skewness of the sampling distribution
# of the mean, built from a heavily skewed population, falls
# monotonically toward zero as n grows;
# * the Cauchy counterexample -- an Exponential population's sample mean
# tightens by roughly 10x from n=10 to n=1000, while a Cauchy
# population's sample mean does not tighten at all;
# * sampling bias is not sampling error -- a biased sampling frame's error
# stays flat as n grows 100x, while an unbiased sampler's error falls by
# roughly ten;
# * the bootstrap, from scratch, agrees with sigma_hat / sqrt(n) for the
# mean and gives a sane answer for the median, where no formula exists;
# * dependence quietly inflates the true standard error beyond what the
# naive sigma_hat / sqrt(n) formula reports;
# * the evaluation-margin calculation -- a 0.3-point accuracy difference on
# 500 examples sits well inside one binomial standard error;
# * the same seed reproduces identical results, and a different seed gives
# a different but statistically compatible 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 117 — Sampling and the Central Limit Theorem"
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_sampling_distribution 02_the_sqrt_n_law 03_clt_from_a_skewed_population \
04_the_cauchy_counterexample 05_bias_does_not_shrink 06_bootstrap_from_scratch \
07_dependence_inflates_se 08_the_evaluation_margin 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 15 ]; then
check "the reference suite ran at least 15 tests (ran ${ref_passed})" "yes"
else
check "the reference suite ran at least 15 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 `sampling` and
# `dataset`, 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 harness can actually fail"
# --------------------------------------------------------------------------
# A green test suite proves nothing until you have watched it go red. This
# section re-runs script 08 (the evaluation margin) with its expected
# standard error deliberately swapped for a wrong one, and asserts that the
# re-run reports the failure and exits non-zero. If this section passes,
# section 2 is not decorative.
if [ -z "${D117_SELF_TEST:-}" ]; then
self_out="$(cd "${lab_dir}/examples" && D117_SELF_TEST=1 "${python_bin}" -c "
import dataset as D
D.EVAL_EXPECTED_SE_PCT = 99.0 # a deliberately wrong expectation
exec(open('08_the_evaluation_margin.py').read())
" 2>&1)"
self_status=$?
if [ "${self_status}" -ne 0 ]; then
check "a deliberately wrong expectation makes script 08 exit non-zero (${self_status})" "yes"
else
check "a deliberately wrong expectation makes script 08 exit non-zero" "no"
fi
case "${self_out}" in
*"AssertionError"*"the binomial standard error came out to"*)
check "the failing assertion is named in the output with both values" "yes" ;;
*) check "the failing assertion is named in the output with both values" "no" ;;
esac
else
echo " (self-test run: section 5 does not recurse)"
fi
# --------------------------------------------------------------------------
echo
echo "6. 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. Searching them would report a failure the
# reader cannot fix and did not cause. Everything the lab itself writes lives
# outside `.venv`, which is exactly what these two checks look at.
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 'sampling'
You ran a reference script from the lab directory instead of from inside
examples/. The scripts import sampling and dataset from beside
themselves.
cd examples
../.venv/bin/python3 01_sampling_distribution.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 in
this lab 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 returns None. Every function in
starter/sampling.py has return None as its last line -- write your body
above it and replace that line with your own return, rather than adding
code before an unchanged return None.
My exercise 2 or 5 ratio assertion fails, but only sometimes
These exercises compare a ratio of two measured standard errors or
errors, and both quantities carry their own simulation noise. If a ratio
lands just outside the tolerance in dataset.py, first check you are
passing rng -- the single shared generator -- into every call rather than
creating a fresh numpy.random.default_rng() inside your function each
time, which would silently make every call start from the same default
state and correlate results in a way that inflates or deflates the
measured ratio.
My Cauchy IQR "shrinks" almost as much as the Exponential one
You most likely used the standard deviation instead of the IQR somewhere
in cauchy_mean_iqr, or built the Cauchy draws with rng.standard_normal
instead of rng.standard_cauchy. The whole point of exercise 4 is that the
Cauchy mean's spread refuses to shrink; if your measured ratio is
anywhere near the Exponential's ~10x, re-check which NumPy method you
called and which spread function you applied to the result.
My biased sampler's error shrinks almost as fast as the unbiased one
biased_pool is not actually restricting the population. Check that you
compared each value against numpy.median(population) with a strict >,
and that sampling_distribution was called with the pool, not the
original population, for the biased measurement. A silent no-op filter --
for example comparing against the wrong array, or using >= on a
population with repeated values in a way that includes almost everything
-- produces a "biased" sampler that behaves almost like the unbiased one.
My bootstrap standard error of the mean does not match sigma_hat / sqrt(n)
Check that your statistic function operates along axis=1 on a 2-D
array of shape (n_boot, len(data)), not along axis=0 or over a flat
array -- bootstrap_replicates passes the whole batch of resamples at
once, not one resample at a time, and a wrong axis silently computes the
statistic over the wrong dimension without raising an error.
My AR(1) series does not show the naive/true standard error gap
Check the innovation standard deviation: it must be
sigma * sqrt(1 - phi**2), not sigma itself. Using sigma directly
makes the series' marginal variance grow without bound as phi approaches
1, which changes the comparison in a way that has nothing to do with
dependence.
RuntimeWarning: invalid value encountered from the Cauchy exercise
This is expected on rare draws and does not indicate a bug: the standard
Cauchy distribution has no defined mean or variance, and if you compute a
sample variance (rather than the IQR the reference solution uses) on a
large batch of Cauchy draws, an extreme draw can occasionally produce a
value large enough to trigger a NumPy overflow warning during the internal
sum of squares. The lab's own cauchy_mean_iqr avoids this entirely by
never computing a variance or standard deviation on Cauchy data.
__pycache__ or .pytest_cache appears and section 6 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, and a check that searched them would
report a failure you cannot fix and did not cause. .venv itself is the
documented setup and is never treated as a stray file.
The lab's own commands leave neither directory behind -- the scripts run
with PYTHONDONTWRITEBYTECODE=1 and the harness's pytest invocations pass
-p no:cacheprovider. The harness clears both at the start of its run
for the same reason Day 110's did: the README documents
.venv/bin/pytest starter -q, which legitimately writes both, and an
earlier version of this style of harness would have then reported them as
litter left by this run rather than by that documented command.
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 sampling and dataset. 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.
If you delete or edit either conftest.py, section 4 of the harness will
notice: it compares the skip count from pytest starter against the skip
count from pytest with no arguments and requires them to be identical.
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. Nothing in
the lab 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
population, seed, sample size and tolerance is invented and is written out
in examples/dataset.py.
Section 6 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.
Three things worth carrying away from this particular day
A spread measure computed on the wrong kind of data can be a plausible
number attached to nothing real. A standard deviation computed on a
sample from the Cauchy distribution looks like an ordinary float -- it
prints, it has units, it is not nan or inf -- and it estimates nothing,
because the population variance it would be estimating does not exist. The
lab's fix is exercise 4's choice of the interquartile range instead, which
depends only on the order of the data. If you ever compute a "confidence
interval" or "error bar" on a metric with a genuinely heavy tail --
latency percentiles under contention, financial returns, retry counts under
a cascading failure -- ask whether the underlying quantity has a finite
variance before trusting a standard-deviation-based error bar on it.
A naive standard error fails silently, and it fails in the dangerous
direction. Exercise 7 measures a case where sample_std / sqrt(n)
understates the true standard error of a dependent series by more than a
factor of two. It does not raise, does not warn, and produces a number of
entirely plausible size -- the failure is invisible until you compare it
against a measurement that does not share its independence assumption.
Time-series metrics, session-level telemetry, and anything sampled from a
system with momentum (a queue, a cache, a slowly drifting user population)
are exactly the settings where this understatement shows up in production
dashboards as false confidence.
Sampling bias produces a more precise wrong answer as you add more
data, and there is no purely statistical test that can catch it from the
sample alone. Exercise 5's biased sampler -- one that can only see the
upper half of the population -- becomes more confident in its (wrong)
estimate as n grows, exactly as fast as an unbiased sampler becomes more
confident in the (right) one. The standard error, the confidence interval,
and every quantity this lab computes describe the sampling error, not
the sampling frame. Whether the frame reaches the population you actually
care about is a question about how the data was collected, and no amount
of additional data collected the same way answers it.
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 public documentation, including its bootstrap and
sem functions, and marked as not run here. The bootstrap you build in
starter/sampling.py implements the same idea scipy.stats.bootstrap
does -- resample, recompute, read off the spread -- and differs from it in
engineering (confidence-interval methods, vectorisation, bias correction
options), not in the core idea.