Math, Statistics, and Data › Working with Real Data › Day 136
Hands-on lab — Day 136: The Exploratory Data Analysis Process
- ← Back to the Day 136 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-136-the-exploratory-data-analysis-process/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-136-the-exploratory-data-analysis-process
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy, pandas; print(numpy.__version__, pandas.__version__)" Run
cd examples && ../.venv/bin/python3 01_forking_paths.py && cd ..
cd examples && ../.venv/bin/python3 02_plausible_story.py && cd ..
cd examples && ../.venv/bin/python3 03_holdout_rescues_you.py && cd ..
cd examples && ../.venv/bin/python3 04_choices_are_comparisons.py && cd ..
cd examples && ../.venv/bin/python3 05_bonferroni_and_its_limit.py && cd ..
cd examples && ../.venv/bin/python3 06_research_log.py && cd ..
cd examples && ../.venv/bin/python3 07_triage.py && cd ..
cd examples && ../.venv/bin/python3 08_stopping_rule.py && cd ..
cd examples && ../.venv/bin/python3 09_handoff_contract.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_forking_paths.py examples/02_plausible_story.py examples/03_holdout_rescues_you.py examples/04_choices_are_comparisons.py examples/05_bonferroni_and_its_limit.py examples/06_research_log.py examples/07_triage.py examples/08_stopping_rule.py examples/09_handoff_contract.py examples/conftest.py examples/dataset.py examples/exploration.py examples/test_reference.py expected-output/01-forking-paths.txt expected-output/02-plausible-story.txt expected-output/03-holdout-rescues-you.txt expected-output/04-choices-are-comparisons.txt expected-output/05-bonferroni-and-its-limit.txt expected-output/06-research-log.txt expected-output/07-triage.txt expected-output/08-stopping-rule.txt expected-output/09-handoff-contract.txt expected-output/examples-run.txt expected-output/FIELDS.md expected-output/starter-run.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/00_brief.md starter/conftest.py starter/dataset.py starter/exploration.py starter/test_starter.py tests/run_tests.sh troubleshooting.md
Lab README
Day 136 lab — Exploration You Can Report
Lesson
- Lesson title: The Exploratory Data Analysis Process
- Day number: 136 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-136-the-exploratory-data-analysis-process
- 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-136-the-exploratory-data-analysis-processwhen the site is running.
Purpose
Exploring data and confirming a finding are different activities, and the mistake this lab is built to prevent is doing the first and reporting it as the second. It builds the machinery of an honest exploratory loop -- comparisons that are counted, a confirmation set held out before any hypothesis is chosen, a stopping rule that does not depend on what was found -- and proves, with real simulation, exactly how much a p-value is worth when none of that discipline is in place.
The centrepiece is exercise 3: one dataset with a REAL, planted effect and thirty SPURIOUS columns, split in half before anything is examined. The real effect survives testing on the untouched confirmation half. The best-looking spurious column, chosen from thirty candidates on the exploration half, does not. That is the whole day's argument, made concrete instead of asserted.
Every exercise follows the same design as recent days in this section: compute a claim two ways and assert they agree -- exact where a formula exists (the family-wise error rate, the Bonferroni correction), seeded simulation otherwise, with tolerances derived from a standard error rather than guessed.
Learning objectives
By the end you will be able to:
- Measure, by exact formula and by simulation, that k independent
alpha=0.05 comparisons on data with no real signal produce at least one
"significant" result
1 - 0.95^kof the time -- 22.6% at k=5, 64.2% at k=20, 87.2% at k=40. - Explain why a forking-paths result is tempting rather than obviously wrong: demonstrate that the "winning" comparison from such a scan can carry both a low p-value and a publishable-looking effect size.
- Hold out a confirmation set before forming a hypothesis, and demonstrate both outcomes on real data: a genuine effect surviving confirmation, and a spurious one chosen for looking best on exploration failing it.
- Show that varying a subset filter or an outcome definition, with no test formally declared per variant, still inflates the apparent significance rate -- quantified, not asserted.
- Apply a Bonferroni correction correctly when the comparison count is known, and demonstrate exactly how it fails when the true count exceeds the reported one.
- Build a research log as a data structure whose own length is the true comparison count, recording every question, look, and outcome -- including the nothings.
- Score candidate questions by expected information, cost, and decision relevance, and rank them the way Day 119 frames "would the answer change a decision".
- Measure, by simulation, how much "stop when significant" inflates the real false-positive rate above a time-boxed rule using the identical budget of looks.
- Build the handoff object a report stage needs -- a finding, its confirmation-set result, and a comparison count -- and prove the report stage refuses to run without all three.
Prerequisites
- Day 118 -- hypothesis tests, confidence intervals, and multiple comparisons; this lab reuses the from-scratch z-test built there directly, and extends its Bonferroni section.
- Day 117 -- sampling and the standard error, which every tolerance in this lab is derived from.
- Day 119 -- the pre-registered analysis plan and the decision-relevance framing this lab's triage exercise applies before any data is touched.
- Day 133 -- building an EDA report; this lab's exercise 9 builds the object that day's report generator needs.
- Comfort with NumPy arrays, vectorised operations, and pandas
DataFrame.groupby. - 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 exercise 8's two 20,000-replicate stopping-rule simulations, which together take well under a second. Roughly 90 MB of disk for the virtual environment, most of it pandas and NumPy.
Required software
python3-- 3.14.0 here.numpy2.5.2,pandas3.0.5 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
All three dependencies are free and open source and there is no paid tier of anything in this lab. NumPy and pandas are distributed under the BSD 3-Clause licence and pytest under the MIT licence. No account, no key, no signup, personally or commercially.
statsmodels.stats.multitest implements Bonferroni, Holm and false
discovery rate corrections in one call each 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 only. Jupyter/nbconvert
and Weights & Biases are likewise not installed; the lesson's Tools
section describes both from their public documentation and names exactly
what was and was not run.
Installation
From the repository root:
cd labs/sections/math-statistics-and-data/day-136-the-exploratory-data-analysis-process
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy, pandas; print(numpy.__version__, pandas.__version__)"
Expect 2.5.2 3.0.5. 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 statsmodels would add
│ └── requirements.txt numpy==2.5.2, pandas==3.0.5, 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 constants, generators and tolerances — read it, do not change it
│ ├── exploration.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
│ ├── exploration.py the finished exploration machinery
│ ├── 01_forking_paths.py the exact rate, confirmed by simulation, plus one real 40-comparison scan
│ ├── 02_plausible_story.py the winning comparison's effect size clears the "publishable" threshold
│ ├── 03_holdout_rescues_you.py the centrepiece: real effect survives, spurious one does not
│ ├── 04_choices_are_comparisons.py a silent ten-variant grid inflates the significance rate
│ ├── 05_bonferroni_and_its_limit.py the correction working, and failing with the wrong comparison count
│ ├── 06_research_log.py a dated record whose own length is the true comparison count
│ ├── 07_triage.py scoring candidate questions by information, cost and relevance
│ ├── 08_stopping_rule.py time-boxed vs. "stop when significant", both false-positive rates
│ ├── 09_handoff_contract.py the object a report stage needs, and its refusal when incomplete
│ └── test_reference.py 27 tests over real values and real exceptions
├── tests/
│ └── run_tests.sh the bash harness: 33 checks, exits non-zero on any failure
├── expected-output/ captured from real runs on 2026-08-20
│ ├── FIELDS.md what may legitimately differ on your machine
│ ├── 01-forking-paths.txt … 09-handoff-contract.txt
│ ├── examples-run.txt
│ ├── starter-run.txt
│ └── test-run.txt
├── troubleshooting.md
└── security.md
How to run
Read starter/00_brief.md first. Then work, checking yourself as you go:
.venv/bin/pytest starter -q
On an untouched checkout that prints 1 passed, 13 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_forking_paths.py
../.venv/bin/python3 02_plausible_story.py
../.venv/bin/python3 03_holdout_rescues_you.py
../.venv/bin/python3 04_choices_are_comparisons.py
../.venv/bin/python3 05_bonferroni_and_its_limit.py
../.venv/bin/python3 06_research_log.py
../.venv/bin/python3 07_triage.py
../.venv/bin/python3 08_stopping_rule.py
../.venv/bin/python3 09_handoff_contract.py
cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
Run them from inside examples/, because they import exploration.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, pandas 3.0.5 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_forking_paths.py |
The exact false-positive rate for k independent comparisons, confirmed by simulation, plus one concrete 40-comparison scan. |
02_plausible_story.py |
The winning comparison's effect size, checked against the "publishable-looking" threshold. |
03_holdout_rescues_you.py |
A real effect and a spurious one, tested on an untouched confirmation set. |
04_choices_are_comparisons.py |
A silent ten-variant grid, and how much it inflates the apparent significance rate. |
05_bonferroni_and_its_limit.py |
Bonferroni restoring the nominal rate, then failing when the true comparison count is under-reported. |
06_research_log.py |
A dated log whose own length is the true comparison count. |
07_triage.py |
Ranking candidate questions by expected information, cost and decision relevance. |
08_stopping_rule.py |
Time-boxed vs. "stop when significant", both measured false-positive rates. |
09_handoff_contract.py |
The object a report stage needs, and its refusal when a required field is missing. |
.venv/bin/pytest examples -q -p no:cacheprovider |
The 27 reference tests. -p no:cacheprovider stops pytest writing a .pytest_cache directory. |
bash tests/run_tests.sh |
The 33-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:
33 checks, 0 failure(s).
and exits 0. The reference suite ends with 27 passed, and an untouched
starter with 1 passed, 13 skipped.
The result worth recognising before you meet it, from exercise 1:
k= 5: exact = 1 - (1-0.05)^5 = 0.2262 simulated over 2000 families = 0.2355 deviation = 0.0093 (0.99 SE)
k=20: exact = 1 - (1-0.05)^20 = 0.6415 simulated over 2000 families = 0.6595 deviation = 0.0180 (1.68 SE)
k=40: exact = 1 - (1-0.05)^40 = 0.8715 simulated over 2000 families = 0.8780 deviation = 0.0065 (0.87 SE)
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=$?"prints33 checks, 0 failure(s).andexit=0..venv/bin/pytest examples -q -p no:cacheproviderprints27 passed..venv/bin/pytest starter -q -p no:cacheproviderprints14 passedonce you have finished, and never prints a failure you have not been shown.- Each of the nine reference scripts ends with a line starting
OK:. find . -path ./.venv -prune -o -type d -name '__pycache__' -printprints nothing after a full run.
Tests
tests/run_tests.sh runs 33 checks in six sections:
- Versions -- reads the installed numpy, pandas and pytest and
compares them against
requirements/requirements.txt. - The nine reference scripts -- each must exit 0 and print an
OK:line confirming every one of its internal assertions held. - The reference pytest suite -- must exit 0, report no failures, and have collected at least 24 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 auto-discovering both suites at once
(running
pytestwith no path argument from the lab directory) must report the same skip count aspytest starteralone, which is a real hazard here because both directories contain modules calledexplorationanddataset. - A deliberate failure -- the harness re-runs script 01 (forking paths) with its expected exact rate for k=20 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, so
section 6 measures only 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 --
and, separately, when a real bug was introduced directly into
exploration.py's bonferroni_alpha during development (alpha/m
changed to alpha*m), the harness caught it too (4 of 33 checks failed),
confirming section 5 is not the only thing standing between a real bug
and a green run.
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 pandas, 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 raises
NotImplementedError, why small n_per_group values drift the measured
significance rate above nominal, why the narrative-scan seed and row
count were chosen the way they were, and the import collision the two
conftest.py files prevent. All of them were hit while building this lab
or are named by a test.
Security notes
See security.md. In short: this lab computes and prints. It writes no
files, opens no connection after the one-time install, needs no
credentials and no sudo, and all the data is invented. Three points
there are worth carrying away: a p-value answers a narrower question than
most people act on it as answering, "we stopped when we found something"
is the failure mode rather than a stopping rule, and a multiple-comparisons
correction is only as honest as the comparison count fed into it.
Extension exercises
- Sweep
n_per_groupin exercise 1 at 10, 25, 50, 100 and 200, and tabulate how far the simulated rate drifts from the exact1 - 0.95^kvalue at each -- confirming directly the small-n drifttroubleshooting.mddescribes. - Add a Holm-Bonferroni step-down correction to exercise 5, and compare its family-wise error rate and its power (fraction of TRUE effects it still detects, using exercise 3's planted effect) against plain Bonferroni on a family where some comparisons carry a real effect and others do not.
- Vary the confirmation-set fraction in exercise 3 from 50/50 to 20/80 and to 80/20, and find how small the confirmation set can get before the real effect stops reliably surviving it.
- Extend the research log with a
cost_minutesfield per entry, and compute total analyst time spent on nulls versus on the one finding that was reported -- a concrete number for "most looks produce nothing". - Build an alpha-spending sequential test for the stopping-rule exercise, and confirm by simulation, the same way exercise 8 does, that its false-positive rate under repeated looking stays near the nominal alpha where the naive "stop when significant" rule does not.
Navigation
- Previous day: Day 135 — From API to DataFrame
- Next day: Day 137 — Thinking in Features
- Week 20: Working with Real Data
- Section: Mathematics, Statistics and Data
Expected output
01-forking-paths.txt
Part A -- the exact rate, confirmed by simulation, for k = 5, 20, 40
k= 5: exact = 1 - (1-0.05)^5 = 0.2262 simulated over 2000 families = 0.2355 deviation = 0.0093 (0.99 SE)
k=20: exact = 1 - (1-0.05)^20 = 0.6415 simulated over 2000 families = 0.6595 deviation = 0.0180 (1.68 SE)
k=40: exact = 1 - (1-0.05)^40 = 0.8715 simulated over 2000 families = 0.8780 deviation = 0.0065 (0.87 SE)
Part B -- one concrete dataset, forty real comparisons, no test declared
in advance for any of them
comparisons run: 40
came back significant at alpha=0.05: 2
the 'winning' comparison: over_40 x sessions (raw_split) -- p=0.0059, effect size d=0.611
OK: a p-value is only meaningful if you can say how many things you looked at. Twenty independent alpha=0.05 tests carry a 64.15% chance of at least one false positive; forty carry 87.15%; five still carry 22.62%. None of this requires bad faith -- it is what happens when chance gets enough tries, and one concrete forty-comparison scan of data with no real signal in it just produced exactly that.
02-plausible-story.txt
Winning comparison: over_40 split, outcome = sessions (raw_split)
p-value: 0.0059
effect size: d = 0.611
a plausible story: 'over 40 customers show a real difference
in sessions, and the effect is not small.'
OK: d=0.611 clears the conventional d=0.5 boundary between a medium and a large effect. The data underneath this number has NO true effect of any kind by construction -- every outcome column was drawn independently of every grouping column. A p-value and an effect size that both look real is what makes the garden of forking paths dangerous: the usual defence, 'but the effect is substantial, not just significant,' does not distinguish a real finding from a large false positive, because conditioning on 'passed the filter' inflates both the p-value's extremity and the effect size at the same time.
03-holdout-rescues-you.txt
Split into 2000 exploration rows and 2000 confirmation rows,
the confirmation half untouched until a hypothesis is chosen below.
Real effect (real_metric, planted delta = 0.3):
exploration: z=8.072 p=6.661e-16
confirmation: z=7.031 p=2.056e-12
Best-looking spurious column, chosen from 30 candidates on exploration only (spurious_8):
exploration: z=-2.244 p=0.0248
confirmation: z=-0.094 p=0.9249
OK: the real effect is significant on both halves -- it was never sensitive to which half you looked at. The spurious column was chosen BECAUSE it looked best among thirty candidates on the exploration half, and that same selection is exactly why it fails on data it was never fitted to. A finding that survives an untouched confirmation set is worth reporting; one that does not was a shape in the noise.
04-choices-are-comparisons.txt
One dataset, 10 silent variants (cutoff x outcome definition), best cell:
cutoff=30 days, outcome='metric_scaled' -> z=1.467 p=0.1423
Measuring across 3000 independently generated null datasets...
best-of-10-silent-variants 'significant' rate: 0.2483
one pre-declared comparison 'significant' rate: 0.0510
OK: no test was declared per variant -- just 'try a few cutoffs and a couple of outcome definitions, report the one that looks best.' That silent search inflates the apparent significance rate by roughly 4.9x over one pre-declared comparison, on data with no real signal in it at all. A choice about which subset or which outcome definition to use IS a comparison, counted or not.
05-bonferroni-and-its-limit.txt
Reported (and true) comparison count: m=20
Bonferroni-corrected per-test alpha: 0.05/20 = 0.00250
Family-wise rate when m is correctly known: 0.0464 (target ~ 0.05)
Now suppose the analyst actually tried 60 comparisons before landing on the
20 they wrote down, and applied the SAME corrected alpha (0.00250)
computed from the reported count, not the true one:
Family-wise rate with the wrong m: 0.1406
OK: with m known and reported honestly, Bonferroni pulls the family-wise rate back to 0.0464, close to the nominal 0.05. Apply the identical correction computed for m=20 to a search that actually ran m=60 comparisons, and the real rate is 0.1406 -- roughly 2.8 times the nominal alpha. The correction is not wrong; the count fed into it was. This is why the research log (exercise 6) matters more than the formula: Bonferroni cannot rescue a comparison count nobody kept.
06-research-log.txt
Log entries: 40
Entries with a null outcome (nothing found): 38
Entries with a recorded finding: 2
First three entries (illustrating that nulls are recorded, not discarded):
[2026-08-20T09:00:00+00:00] does revenue differ by region_west (raw_split)?
look: two-sample z-test, groupby split
outcome: None
[2026-08-20T09:03:00+00:00] does revenue differ by region_west (narrower_cut)?
look: two-sample z-test, groupby split
outcome: None
[2026-08-20T09:06:00+00:00] does sessions differ by region_west (raw_split)?
look: two-sample z-test, groupby split
outcome: None
OK: the log recorded all 40 comparisons, 38 of them nothing and 2 of them a finding worth a second look. This is what turns 'I only ran one test' from an unverifiable claim into a checkable one: the comparison count Bonferroni needs (exercise 5) and the handoff to Day 133 requires (exercise 9) is not remembered or estimated afterward -- it is exactly len(log.entries).
07-triage.txt
Ranked by (expected_information * decision_relevance) / cost_hours:
0.3600 does churn differ by plan tier?
info=0.8 cost=2h relevance=0.9
0.0992 does the new pricing page change conversion?
info=0.7 cost=6h relevance=0.85
0.0600 does button color affect clicks?
info=0.6 cost=1h relevance=0.1
0.0112 full re-audit of tracking pipeline
info=0.9 cost=40h relevance=0.5
OK: cheap and decision-relevant beats expensive and merely informative. The button-color question would teach you something real, but nothing is currently decided by the answer, so it sinks below a costlier question that is. This is Day 119's framing applied before any data is touched: ask first whether the answer would change a decision, not only whether it would be interesting.
08-stopping-rule.txt
Budget: 10 questions. Data has NO real signal, so the honest false-positive
rate at alpha=0.05 should sit near 0.05 for any rule that does not
selectively report based on what it saw along the way.
Time-boxed rule (ask 10, report the pre-declared last one, stop regardless):
measured false-positive rate: 0.0505
'Stop when significant' rule (ask up to 10, stop and report at the first p<0.05):
measured false-positive rate: 0.3997
OK: the time-boxed rule lands at 0.0505, close to the nominal alpha=0.05, because it reports a decision made before any data was seen. 'Stop when significant' lands at 0.3997 -- roughly 8.0 times higher -- using the exact same budget of looks, because the stopping decision itself was made using the data. Time-boxing (or a fixed count of questions) is not a bureaucratic nicety; it is the difference between these two numbers.
09-handoff-contract.txt
A valid handoff builds and the report stage accepts it:
Finding: real_metric by group (exploration p=6.661e-16). Confirmed on holdout: p=2.056e-12. Comparisons run before this finding was reported: 31.
Now proving the report stage refuses an incomplete handoff:
missing 'finding': refused -- handoff is missing required field(s): finding
missing 'confirmation_result': refused -- handoff is missing required field(s): confirmation_result
missing 'comparison_count': refused -- handoff is missing required field(s): comparison_count
a bare finding with nothing else: refused -- handoff is missing required field(s): confirmation_result, comparison_count
OK: the report stage cannot be handed a finding alone. It needs to know the finding survived an untouched confirmation set (exercise 3) and how many comparisons were run before it was chosen (exercise 6's research log) -- which is exactly what makes the choice of what to write up, at the end of exploration, defensible rather than asserted.
FIELDS.md
# What is exact, what is sampled, and what is machine-dependent
Captured on 2026-08-20 from a real run on the authoring machine (macOS
26.5.2, Apple Silicon, Python 3.14.0, numpy 2.5.2, pandas 3.0.5,
pytest 9.1.1). Every number below came from that run; none is invented.
## Exact everywhere, on any correct implementation
- The analytic family-wise error rates: `1 - 0.95^5 = 0.2262`,
`1 - 0.95^20 = 0.6415`, `1 - 0.95^40 = 0.8715` (`01-forking-paths.txt`).
- The Bonferroni-corrected per-test alpha for m=20: `0.05/20 = 0.0025`
(`05-bonferroni-and-its-limit.txt`).
- `bonferroni_alpha(0.05, 20) == 0.0025` and every other pure-arithmetic
assertion in the reference suite.
- The final line of a passing `run_tests.sh`: `33 checks, 0 failure(s).`
## Sampled -- will differ slightly on another run or another machine
Every one of these is checked against a tolerance derived from a standard
error or a wide, explicitly-reasoned margin, never against a fixed
literal expected to match exactly:
- The simulated forking-paths rates in `01-forking-paths.txt` (e.g.
"simulated over 2000 families = 0.2355" for k=5) -- checked against the
exact rate within three standard errors. Re-running with a different
seed moves this number; it was checked across seeds 1, 7, 42, 118 and
2026 during development and stayed within 2.14 standard errors in every
case observed.
- The "winning" comparison identified in `01-forking-paths.txt` and
`02-plausible-story.txt` (`over_40 x sessions`, p=0.0059, d=0.611) is
specific to the fixed seed `NARRATIVE_SEED=6` in `dataset.py`. A
different seed finds a different winning comparison with a different
effect size; the assertion only requires d >= 0.5, not this exact
value.
- The two p-values in `03-holdout-rescues-you.txt` (real effect:
p=6.661e-16 exploration, p=2.056e-12 confirmation; spurious column
`spurious_8`: p=0.0248 exploration, p=0.9249 confirmation) are specific
to `HOLDOUT_SEED=1`. Seeds 1 through 199 were swept during development
(see the honesty note in `metadata.yml`); 157 of those 199 produced the
same qualitative outcome (real effect survives both halves, best
spurious column fails confirmation) with different exact p-values each
time -- seed 1 was picked because it is both the first hit and the
simplest to state.
- The inflation ratio in `04-choices-are-comparisons.txt` (roughly 4.9x)
and the false-positive-rate ratio in `08-stopping-rule.txt` (roughly
8.0x) are measured rates from finite simulations (3000 and 15000
replicates respectively) and will vary by a few tenths on a re-run with
a different seed.
- Every `elapsed` or timing figure is not printed by this lab's scripts at
all; none is asserted on. Runtimes noted in `metadata.yml` narrative
(for example, "27 passed in 1.83s") are wall-clock and will differ by
hardware.
## Machine-dependent, described but not asserted on
- The `platform` line in section 1 of `run_tests.sh`'s output
(`macOS-26.5.2-arm64-arm-64bit-Mach-O` here) will read differently on
Linux or Windows/WSL; nothing in the harness checks its content, only
that the versions line above it match `requirements.txt`.
examples-run.txt
........................... [100%]
27 passed in 1.92s
starter-run.txt
.sssssssssssss [100%]
1 passed, 13 skipped in 0.18s
test-run.txt
Day 136 — The Exploratory Data Analysis Process
1. The tools and the versions this lab was written against
python 3.14.0
numpy 2.5.2
pandas 3.0.5
pytest 9.1.1
platform macOS-26.5.2-arm64-arm-64bit-Mach-O
exe python3
ok: installed numpy matches requirements.txt
ok: installed pandas matches requirements.txt
ok: installed pytest matches requirements.txt
2. Every reference script runs and every assertion inside it holds
ok: 01_forking_paths.py exits 0
ok: 01_forking_paths.py reports OK
ok: 02_plausible_story.py exits 0
ok: 02_plausible_story.py reports OK
ok: 03_holdout_rescues_you.py exits 0
ok: 03_holdout_rescues_you.py reports OK
ok: 04_choices_are_comparisons.py exits 0
ok: 04_choices_are_comparisons.py reports OK
ok: 05_bonferroni_and_its_limit.py exits 0
ok: 05_bonferroni_and_its_limit.py reports OK
ok: 06_research_log.py exits 0
ok: 06_research_log.py reports OK
ok: 07_triage.py exits 0
ok: 07_triage.py reports OK
ok: 08_stopping_rule.py exits 0
ok: 08_stopping_rule.py reports OK
ok: 09_handoff_contract.py exits 0
ok: 09_handoff_contract.py reports OK
3. The reference pytest suite: real values, real exceptions
........................... [100%]
27 passed in 1.91s
ok: pytest examples exits 0
ok: no test in the reference suite failed
ok: the reference suite ran at least 24 tests (ran 27)
4. The starter suite skips unattempted work instead of failing it
.sssssssssssss [100%]
1 passed, 13 skipped in 0.16s
ok: pytest starter exits 0 on an untouched checkout
ok: the starter suite reports no failures
ok: unwritten exercises are reported as skipped, not passed
ok: auto-discovering both suites does not turn skips into passes
5. The harness can actually fail
ok: a deliberately wrong expectation makes script 01 exit non-zero (1)
ok: the failing assertion is named in the output with the real value
6. Nothing was left behind
ok: no __pycache__ directory left by the lab's own code
ok: no .pytest_cache directory left under the lab
ok: no lab source opens a network connection
33 checks, 0 failure(s).
exit_code=0
Source files
examples/01_forking_paths.py (3322 bytes)
"""Exercise 1 -- forking paths, measured.
An analyst with data that has NO real signal in it tries a few groupings,
a few subsets, a few outcome variables -- and after examining enough
combinations, one comes back "significant at p < 0.05". Nobody p-hacked.
Every individual test was computed correctly. This script measures the
exact size of that effect two ways: an exact formula for k independent
comparisons, and one concrete 40-comparison scan of a real (signal-free)
dataset with pandas.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import numpy as np # noqa: E402
import dataset as ds # noqa: E402
import exploration as ex # noqa: E402
def main() -> None:
print("Part A -- the exact rate, confirmed by simulation, for k = 5, 20, 40")
print()
rng = np.random.default_rng(2026)
for k in ds.FORK_K_VALUES:
result = ex.simulate_forking_paths(rng, k, ds.FORK_FAMILIES, ds.FORK_N_PER_GROUP, ds.ALPHA)
exact = result["exact_rate"]
sim = result["simulated_rate"]
se = result["standard_error"]
dev = result["deviation"]
print(
f" k={k:2d}: exact = 1 - (1-{ds.ALPHA})^{k} = {exact:.4f} "
f"simulated over {ds.FORK_FAMILIES} families = {sim:.4f} "
f"deviation = {dev:.4f} ({dev / se:.2f} SE)"
)
assert dev <= ds.FORK_SIM_TOLERANCE_SE * se, (
f"k={k}: simulated {sim:.4f} is too far from exact {exact:.4f} "
f"({dev / se:.2f} SE > {ds.FORK_SIM_TOLERANCE_SE} SE)"
)
expected_exact = {5: 0.2262, 20: 0.6415, 40: 0.8715}[k]
assert abs(exact - expected_exact) < 0.0001, f"k={k}: expected exact~{expected_exact}, got {exact:.4f}"
print()
print("Part B -- one concrete dataset, forty real comparisons, no test declared")
print("in advance for any of them")
print()
rng2 = np.random.default_rng(ds.NARRATIVE_SEED)
df = ds.build_narrative_frame(rng2)
results = ex.scan_narrative_frame(df, ds.NARRATIVE_SUBSET_COLS, ds.NARRATIVE_OUTCOME_COLS)
n_comparisons = len(results)
significant = [r for r in results if r["significant"]]
print(f" comparisons run: {n_comparisons}")
print(f" came back significant at alpha={ds.ALPHA}: {len(significant)}")
assert n_comparisons >= ds.NARRATIVE_MIN_COMPARISONS, (
f"expected at least {ds.NARRATIVE_MIN_COMPARISONS} comparisons, ran {n_comparisons}"
)
assert len(significant) >= 1, "expected at least one comparison to look significant by chance"
best = ex.best_significant_result(results)
print(
f" the 'winning' comparison: {best['subset']} x {best['outcome']} "
f"({best['cut']}) -- p={best['p']:.4f}, effect size d={best['effect_size']:.3f}"
)
print(
"\nOK: a p-value is only meaningful if you can say how many things "
"you looked at. Twenty independent alpha=0.05 tests carry a 64.15% "
"chance of at least one false positive; forty carry 87.15%; five "
"still carry 22.62%. None of this requires bad faith -- it is what "
"happens when chance gets enough tries, and one concrete forty-"
"comparison scan of data with no real signal in it just produced "
"exactly that."
)
if __name__ == "__main__":
main()
examples/02_plausible_story.py (2682 bytes)
"""Exercise 2 -- a plausible story for noise.
The "winning" comparison from exercise 1's forty-comparison scan is not
just statistically significant by the letter of the test -- it also LOOKS
like a real finding, in the specific sense that its effect size clears
the conventional boundary between a "medium" and a "large" effect (Cohen,
1988). That is exactly why forking paths are tempting rather than
obviously wrong: the noise did not produce a weak, forgettable blip. It
produced something a reasonable analyst would be excited to write up, and
could attach a plausible story to ("older customers use fewer sessions
because they are more habitual users") without anyone being able to tell,
from the number alone, that the story is fiction.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import numpy as np # noqa: E402
import dataset as ds # noqa: E402
import exploration as ex # noqa: E402
def main() -> None:
rng = np.random.default_rng(ds.NARRATIVE_SEED)
df = ds.build_narrative_frame(rng)
results = ex.scan_narrative_frame(df, ds.NARRATIVE_SUBSET_COLS, ds.NARRATIVE_OUTCOME_COLS)
best = ex.best_significant_result(results)
assert best is not None, "exercise 1's scan must produce at least one significant result"
effect_size = abs(best["effect_size"])
print(f"Winning comparison: {best['subset']} split, outcome = {best['outcome']} ({best['cut']})")
print(f" p-value: {best['p']:.4f}")
print(f" effect size: d = {effect_size:.3f}")
print(f" a plausible story: '{best['subset'].replace('_', ' ')} customers show a real difference")
print(f" in {best['outcome'].replace('_', ' ')}, and the effect is not small.'")
assert effect_size >= ds.PUBLISHABLE_EFFECT_SIZE, (
f"expected a 'publishable-looking' effect size >= {ds.PUBLISHABLE_EFFECT_SIZE}, got {effect_size:.3f}"
)
print(
f"\nOK: d={effect_size:.3f} clears the conventional d=0.5 boundary "
"between a medium and a large effect. The data underneath this "
"number has NO true effect of any kind by construction -- every "
"outcome column was drawn independently of every grouping column. "
"A p-value and an effect size that both look real is what makes "
"the garden of forking paths dangerous: the usual defence, 'but "
"the effect is substantial, not just significant,' does not "
"distinguish a real finding from a large false positive, because "
"conditioning on 'passed the filter' inflates both the p-value's "
"extremity and the effect size at the same time."
)
if __name__ == "__main__":
main()
examples/03_holdout_rescues_you.py (3400 bytes)
"""Exercise 3 -- the holdout rescues you. The day's centrepiece.
One dataset is built with exactly one REAL, planted effect and thirty
SPURIOUS columns with no true effect at all. Before looking at anything,
the data is split in half: an exploration set and a confirmation set that
is not touched until a hypothesis has been chosen. Both the real effect
and the best-looking spurious column are tested on the exploration half;
then, and only then, both are tested again on the untouched confirmation
half.
The real effect survives. The spurious one, chosen for looking best among
thirty candidates on the exploration half, does not. This is the whole
day's practical device in one script.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import numpy as np # noqa: E402
import dataset as ds # noqa: E402
import exploration as ex # noqa: E402
def main() -> None:
rng = np.random.default_rng(ds.HOLDOUT_SEED)
df = ex.build_holdout_frame(
rng, ds.HOLDOUT_N_TOTAL, ds.REAL_EFFECT_DELTA, ds.REAL_EFFECT_SIGMA, ds.N_SPURIOUS_CANDIDATES
)
exploration_df, confirmation_df = ex.split_exploration_confirmation(df, rng)
print(f"Split into {len(exploration_df)} exploration rows and {len(confirmation_df)} confirmation rows,")
print("the confirmation half untouched until a hypothesis is chosen below.\n")
# --- The real, planted effect ---
z_real_exp, p_real_exp = ex.test_column_by_group(exploration_df, "real_metric")
z_real_conf, p_real_conf = ex.test_column_by_group(confirmation_df, "real_metric")
print("Real effect (real_metric, planted delta = "
f"{ds.REAL_EFFECT_DELTA}):")
print(f" exploration: z={z_real_exp:.3f} p={p_real_exp:.3e}")
print(f" confirmation: z={z_real_conf:.3f} p={p_real_conf:.3e}")
# --- The best-looking spurious column, chosen ONLY from exploration ---
spurious_cols = [c for c in df.columns if c.startswith("spurious_")]
best_col, z_spur_exp, p_spur_exp = ex.best_spurious_column(exploration_df, spurious_cols)
z_spur_conf, p_spur_conf = ex.test_column_by_group(confirmation_df, best_col)
print(f"\nBest-looking spurious column, chosen from {len(spurious_cols)} candidates on exploration only ({best_col}):")
print(f" exploration: z={z_spur_exp:.3f} p={p_spur_exp:.4f}")
print(f" confirmation: z={z_spur_conf:.3f} p={p_spur_conf:.4f}")
assert p_real_exp < ds.ALPHA, f"real effect should be significant on exploration, got p={p_real_exp}"
assert p_real_conf < ds.ALPHA, f"real effect should SURVIVE on confirmation, got p={p_real_conf}"
assert p_spur_exp < ds.ALPHA, f"the chosen spurious column should look significant on exploration, got p={p_spur_exp}"
assert p_spur_conf >= ds.ALPHA, f"the spurious column should NOT survive confirmation, got p={p_spur_conf}"
print(
"\nOK: the real effect is significant on both halves -- it was "
"never sensitive to which half you looked at. The spurious column "
"was chosen BECAUSE it looked best among thirty candidates on the "
"exploration half, and that same selection is exactly why it fails "
"on data it was never fitted to. A finding that survives an "
"untouched confirmation set is worth reporting; one that does not "
"was a shape in the noise."
)
if __name__ == "__main__":
main()
examples/04_choices_are_comparisons.py (2940 bytes)
"""Exercise 4 -- choices are comparisons.
"I only ran one test" is not a defence if you tried several subset filters
or several outcome definitions and reported whichever cut looked best,
even if you never typed the word "test" for the others. This script
builds data with NO real signal, varies a recency cutoff (five values) and
an outcome definition (two variants) -- ten variants total, with no
explicit test declared per variant -- and measures how often the single
best-looking cell of that grid comes back "significant" at alpha=0.05,
compared with how often ONE pre-declared comparison does.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import numpy as np # noqa: E402
import dataset as ds # noqa: E402
import exploration as ex # noqa: E402
def main() -> None:
rng = np.random.default_rng(ds.CHOICES_SEED)
# One concrete illustration first.
df = ex.build_choices_frame(rng, ds.CHOICES_N_ROWS)
best = ex.best_of_choice_grid(df, ds.CHOICES_SUBSET_CUTOFFS, ds.CHOICES_OUTCOME_DEFINITIONS)
n_variants = len(ds.CHOICES_SUBSET_CUTOFFS) * len(ds.CHOICES_OUTCOME_DEFINITIONS)
print(f"One dataset, {n_variants} silent variants (cutoff x outcome definition), best cell:")
print(f" cutoff={best['cutoff']} days, outcome='{best['definition']}' -> z={best['z']:.3f} p={best['p']:.4f}")
# Now the rate, under a TRUE null, across many such datasets.
print(f"\nMeasuring across {ds.CHOICES_FAMILIES} independently generated null datasets...")
result = ex.simulate_choice_grid_best_p_rate(
rng, ds.CHOICES_FAMILIES, ds.CHOICES_N_ROWS,
ds.CHOICES_SUBSET_CUTOFFS, ds.CHOICES_OUTCOME_DEFINITIONS, ds.ALPHA
)
print(f" best-of-{result['n_variants']}-silent-variants 'significant' rate: {result['naive_best_rate']:.4f}")
print(f" one pre-declared comparison 'significant' rate: {result['single_declared_rate']:.4f}")
assert result["naive_best_rate"] > 3 * ds.ALPHA, (
f"expected the best-of-grid rate to be well above the nominal alpha={ds.ALPHA}, "
f"got {result['naive_best_rate']:.4f}"
)
assert abs(result["single_declared_rate"] - ds.ALPHA) < 0.02, (
f"the single pre-declared comparison should sit near alpha={ds.ALPHA}, "
f"got {result['single_declared_rate']:.4f}"
)
inflation = result["naive_best_rate"] / result["single_declared_rate"]
print(
f"\nOK: no test was declared per variant -- just 'try a few cutoffs "
f"and a couple of outcome definitions, report the one that looks "
f"best.' That silent search inflates the apparent significance "
f"rate by roughly {inflation:.1f}x over one pre-declared comparison, "
"on data with no real signal in it at all. A choice about which "
"subset or which outcome definition to use IS a comparison, "
"counted or not."
)
if __name__ == "__main__":
main()
examples/05_bonferroni_and_its_limit.py (3108 bytes)
"""Exercise 5 -- Bonferroni, and its honest limit.
Bonferroni (Day 118) works when you can COUNT your comparisons: dividing
alpha by the known number of tests, m, restores the family-wise error
rate to near its nominal level. The deeper problem is that in real
exploration you usually cannot count them -- every subset filter, cutoff
and outcome definition tried and discarded was a comparison too (exercise
4), and most of them never got written down. This script shows both
halves honestly: the correction working when m is known, and the same
correction failing -- not gracefully, not a little, but by a wide margin
-- when the number an analyst actually tried is larger than the number
they reported.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import numpy as np # noqa: E402
import dataset as ds # noqa: E402
import exploration as ex # noqa: E402
def main() -> None:
rng = np.random.default_rng(5)
known_m = ds.BONFERRONI_KNOWN_M
corrected_alpha = ex.bonferroni_alpha(ds.ALPHA, known_m)
print(f"Reported (and true) comparison count: m={known_m}")
print(f"Bonferroni-corrected per-test alpha: {ds.ALPHA}/{known_m} = {corrected_alpha:.5f}")
rate_when_m_is_right = ex.simulate_family_wise_rate(rng, known_m, ds.BONFERRONI_FAMILIES, corrected_alpha)
print(f"Family-wise rate when m is correctly known: {rate_when_m_is_right:.4f} (target ~ {ds.ALPHA})")
assert abs(rate_when_m_is_right - ds.ALPHA) < 0.015, (
f"expected the corrected rate to sit near alpha={ds.ALPHA}, got {rate_when_m_is_right:.4f}"
)
true_m = ds.BONFERRONI_TRUE_M
print(f"\nNow suppose the analyst actually tried {true_m} comparisons before landing on the")
print(f"{known_m} they wrote down, and applied the SAME corrected alpha ({corrected_alpha:.5f})")
print("computed from the reported count, not the true one:")
rate_when_m_is_wrong = ex.simulate_family_wise_rate(rng, true_m, ds.BONFERRONI_FAMILIES, corrected_alpha)
print(f"Family-wise rate with the wrong m: {rate_when_m_is_wrong:.4f}")
assert rate_when_m_is_wrong > 2 * ds.ALPHA, (
f"expected the mis-corrected rate to substantially exceed alpha={ds.ALPHA}, "
f"got {rate_when_m_is_wrong:.4f}"
)
assert rate_when_m_is_wrong > rate_when_m_is_right, "the wrong-m rate must exceed the correct-m rate"
print(
f"\nOK: with m known and reported honestly, Bonferroni pulls the "
f"family-wise rate back to {rate_when_m_is_right:.4f}, close to the "
f"nominal {ds.ALPHA}. Apply the identical correction computed for "
f"m={known_m} to a search that actually ran m={true_m} comparisons, "
f"and the real rate is {rate_when_m_is_wrong:.4f} -- roughly "
f"{rate_when_m_is_wrong / ds.ALPHA:.1f} times the nominal alpha. "
"The correction is not wrong; the count fed into it was. This is "
"why the research log (exercise 6) matters more than the formula: "
"Bonferroni cannot rescue a comparison count nobody kept."
)
if __name__ == "__main__":
main()
examples/06_research_log.py (3020 bytes)
"""Exercise 6 -- the research log as a data structure.
The day's practical deliverable: a dated record of every question asked,
what was looked at, and what was found -- including the nothings. This
script re-runs exercise 1's forty-comparison scan, but this time logs
EVERY comparison as it happens rather than only the one that "won", and
proves the log's own length is the true comparison count -- the number
Bonferroni actually needs (exercise 5) and the number exercise 9's
handoff to the report stage requires.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from datetime import datetime, timedelta, timezone # noqa: E402
import numpy as np # noqa: E402
import dataset as ds # noqa: E402
import exploration as ex # noqa: E402
def main() -> None:
rng = np.random.default_rng(ds.NARRATIVE_SEED)
df = ds.build_narrative_frame(rng)
results = ex.scan_narrative_frame(df, ds.NARRATIVE_SUBSET_COLS, ds.NARRATIVE_OUTCOME_COLS)
log = ex.ResearchLog()
start = datetime(2026, 8, 20, 9, 0, tzinfo=timezone.utc)
for i, r in enumerate(results):
question = f"does {r['outcome']} differ by {r['subset']} ({r['cut']})?"
look = "two-sample z-test, groupby split"
outcome = None
if r["significant"]:
outcome = f"p={r['p']:.4f}, d={r['effect_size']:.3f} -- looked significant on this pass"
log.record(question, look, outcome, timestamp=(start + timedelta(minutes=3 * i)).isoformat())
print(f"Log entries: {log.comparison_count}")
print(f"Entries with a null outcome (nothing found): {log.null_count}")
print(f"Entries with a recorded finding: {len(log.findings())}")
assert log.comparison_count == len(results), "the log must record every comparison actually run"
assert all(e.timestamp for e in log.entries), "every entry must carry a timestamp"
assert all(e.look for e in log.entries), "every entry must carry a description of the look"
assert log.null_count + len(log.findings()) == log.comparison_count, "every outcome is either null or a finding"
assert log.null_count > 0, "most looks should produce nothing -- that is the normal texture of the work"
print("\nFirst three entries (illustrating that nulls are recorded, not discarded):")
for e in log.entries[:3]:
print(f" [{e.timestamp}] {e.question}")
print(f" look: {e.look}")
print(f" outcome: {e.outcome!r}")
print(
f"\nOK: the log recorded all {log.comparison_count} comparisons, "
f"{log.null_count} of them nothing and {len(log.findings())} of "
"them a finding worth a second look. This is what turns "
"'I only ran one test' from an unverifiable claim into a checkable "
"one: the comparison count Bonferroni needs (exercise 5) and the "
"handoff to Day 133 requires (exercise 9) is not remembered or "
"estimated afterward -- it is exactly len(log.entries)."
)
if __name__ == "__main__":
main()
examples/07_triage.py (3789 bytes)
"""Exercise 7 -- triage: deciding which questions are worth pursuing.
Not every question deserves the same amount of exploration time. This
script scores a set of candidate questions on three things -- expected
information (how much the answer could change what you believe), cost
(analyst-hours to answer it), and decision relevance (how much the answer
could change what anyone actually does, Day 119's framing) -- and ranks
them. The comments beside each candidate justify its numbers; the point of
the exercise is that the ranking these numbers produce matches what a
practising analyst would actually choose, not that the numbers themselves
are precise.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import exploration as ex # noqa: E402
def main() -> None:
candidates = [
# Cheap, and the answer would directly change which plan tier gets
# marketing spend next quarter -- high decision relevance.
ex.Candidate("does churn differ by plan tier?", expected_information=0.8, cost_hours=2, decision_relevance=0.9),
# Cheap and informative in the narrow sense (you would learn
# something), but nobody has proposed changing the button, so the
# answer would not move any decision currently on the table.
ex.Candidate("does button color affect clicks?", expected_information=0.6, cost_hours=1, decision_relevance=0.1),
# Would answer many questions at once (high information) but costs
# 40 analyst-hours -- a full audit of the tracking pipeline -- for a
# decision that is only moderately live right now.
ex.Candidate("full re-audit of tracking pipeline", expected_information=0.9, cost_hours=40, decision_relevance=0.5),
# Moderate cost, moderate information, but tied to a pricing
# decision that is being finalized this week -- high relevance.
ex.Candidate("does the new pricing page change conversion?", expected_information=0.7, cost_hours=6, decision_relevance=0.85),
]
ranked = ex.rank_candidates(candidates)
print("Ranked by (expected_information * decision_relevance) / cost_hours:\n")
for c in ranked:
score = ex.triage_score(c)
print(f" {score:.4f} {c.name}")
print(f" info={c.expected_information} cost={c.cost_hours}h relevance={c.decision_relevance}")
names_in_order = [c.name for c in ranked]
assert names_in_order[0] == "does churn differ by plan tier?", (
f"expected the cheap, high-relevance churn question to rank first, ranking was {names_in_order}"
)
assert names_in_order[-1] == "full re-audit of tracking pipeline", (
f"expected the expensive, moderate-relevance audit to rank last, ranking was {names_in_order}"
)
# The button-color question has real info but near-zero decision
# relevance, and should therefore rank behind the pricing question,
# which costs more but is tied to a live decision.
button_rank = names_in_order.index("does button color affect clicks?")
pricing_rank = names_in_order.index("does the new pricing page change conversion?")
assert pricing_rank < button_rank, "a question tied to a live decision should outrank one that is not, even at higher cost"
print(
"\nOK: cheap and decision-relevant beats expensive and merely "
"informative. The button-color question would teach you something "
"real, but nothing is currently decided by the answer, so it sinks "
"below a costlier question that is. This is Day 119's framing "
"applied before any data is touched: ask first whether the answer "
"would change a decision, not only whether it would be interesting."
)
if __name__ == "__main__":
main()
examples/08_stopping_rule.py (2502 bytes)
"""Exercise 8 -- a stopping rule.
Two ways to decide when exploration ends, tested against data with NO
real signal so the true false-positive rate is knowable: a time-boxed
rule that asks a fixed budget of questions and stops regardless of
whether anything looked interesting along the way, versus the failure
mode named directly in the brief -- "we stopped when we found
something" -- which keeps asking until the first p < 0.05 turns up.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import numpy as np # noqa: E402
import dataset as ds # noqa: E402
import exploration as ex # noqa: E402
def main() -> None:
rng = np.random.default_rng(ds.STOPPING_SEED)
budget = ds.STOPPING_BUDGET_QUESTIONS
print(f"Budget: {budget} questions. Data has NO real signal, so the honest false-positive")
print(f"rate at alpha={ds.ALPHA} should sit near {ds.ALPHA} for any rule that does not")
print("selectively report based on what it saw along the way.\n")
tb_rate = ex.time_boxed_false_positive_rate(rng, ds.STOPPING_FAMILIES, budget, ds.ALPHA)
print(f"Time-boxed rule (ask {budget}, report the pre-declared last one, stop regardless):")
print(f" measured false-positive rate: {tb_rate:.4f}")
sw_rate = ex.stop_when_significant_rate(rng, ds.STOPPING_FAMILIES, budget, ds.ALPHA)
print(f"\n'Stop when significant' rule (ask up to {budget}, stop and report at the first p<{ds.ALPHA}):")
print(f" measured false-positive rate: {sw_rate:.4f}")
assert abs(tb_rate - ds.ALPHA) < 0.015, f"time-boxed rate should sit near alpha={ds.ALPHA}, got {tb_rate:.4f}"
assert sw_rate > 3 * ds.ALPHA, f"stop-when-significant rate should be far above alpha={ds.ALPHA}, got {sw_rate:.4f}"
assert sw_rate > tb_rate, "stopping when significant must inflate the false-positive rate above the time-boxed rule"
print(
f"\nOK: the time-boxed rule lands at {tb_rate:.4f}, close to the "
f"nominal alpha={ds.ALPHA}, because it reports a decision made "
f"before any data was seen. 'Stop when significant' lands at "
f"{sw_rate:.4f} -- roughly {sw_rate / ds.ALPHA:.1f} times higher -- "
"using the exact same budget of looks, because the stopping "
"decision itself was made using the data. Time-boxing (or a "
"fixed count of questions) is not a bureaucratic nicety; it is "
"the difference between these two numbers."
)
if __name__ == "__main__":
main()
examples/09_handoff_contract.py (3231 bytes)
"""Exercise 9 -- the handoff to Day 133.
Exploration ends by choosing which few findings deserve a document. This
script builds the object exploration hands to the report stage -- the
finding, its result on the untouched confirmation set (exercise 3), and
the comparison count the research log kept (exercise 6) -- and proves the
report stage REFUSES to run without all three, the same way Day 133's
report generator refuses a figure with no stated question.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import numpy as np # noqa: E402
import dataset as ds # noqa: E402
import exploration as ex # noqa: E402
def main() -> None:
# Re-derive exercise 3's real finding so this script is self-contained.
rng = np.random.default_rng(ds.HOLDOUT_SEED)
df = ex.build_holdout_frame(
rng, ds.HOLDOUT_N_TOTAL, ds.REAL_EFFECT_DELTA, ds.REAL_EFFECT_SIGMA, ds.N_SPURIOUS_CANDIDATES
)
exploration_df, confirmation_df = ex.split_exploration_confirmation(df, rng)
z_exp, p_exp = ex.test_column_by_group(exploration_df, "real_metric")
z_conf, p_conf = ex.test_column_by_group(confirmation_df, "real_metric")
finding = {"name": "real_metric by group", "p": p_exp, "z": z_exp}
confirmation_result = {"p": p_conf, "z": z_conf, "survived": p_conf < ds.ALPHA}
comparison_count = ds.N_SPURIOUS_CANDIDATES + 1 # every spurious column tried, plus the real one
handoff = ex.build_handoff(finding, confirmation_result, comparison_count)
report_line = ex.write_report_stub(handoff)
print("A valid handoff builds and the report stage accepts it:")
print(f" {report_line}")
assert handoff["finding"] == finding
assert handoff["confirmation_result"] == confirmation_result
assert handoff["comparison_count"] == comparison_count
print("\nNow proving the report stage refuses an incomplete handoff:")
for missing_field in ex.REQUIRED_HANDOFF_FIELDS:
kwargs = {
"finding": finding,
"confirmation_result": confirmation_result,
"comparison_count": comparison_count,
}
kwargs[missing_field] = None
try:
ex.build_handoff(**kwargs)
except ValueError as exc:
print(f" missing '{missing_field}': refused -- {exc}")
else:
raise AssertionError(f"expected build_handoff to refuse a handoff missing '{missing_field}'")
try:
ex.write_report_stub({"finding": finding}) # missing the other two fields entirely
except ValueError as exc:
print(f" a bare finding with nothing else: refused -- {exc}")
else:
raise AssertionError("expected write_report_stub to refuse a finding with no confirmation result or count")
print(
"\nOK: the report stage cannot be handed a finding alone. It needs "
"to know the finding survived an untouched confirmation set "
"(exercise 3) and how many comparisons were run before it was "
"chosen (exercise 6's research log) -- which is exactly what makes "
"the choice of what to write up, at the end of exploration, "
"defensible rather than asserted."
)
if __name__ == "__main__":
main()
examples/conftest.py (1036 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain modules called `exploration` 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 ("exploration", "dataset"):
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 (6129 bytes)
"""Shared constants, data generators and tolerances for the Day 136 lab.
Every number an exercise checks against lives here, next to a comment
saying where it came from -- exact arithmetic, a derived standard error,
or a tolerance observed across several seeds during development (seeds
1, 7, 42, 118, 2026, recorded beside the constant it produced). Nothing in
this file is fabricated: every tolerance was checked by re-running the
exercise logic across those five seeds before being fixed here.
No real signal means exactly that: outcome columns are drawn independently
of every grouping column, so the TRUE effect of any comparison built from
them is zero. Any "significant" result exercise 1 finds is, by
construction, a false positive.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# Exercise 1 -- forking paths, measured
# ---------------------------------------------------------------------------
ALPHA = 0.05
FORK_K_VALUES = (5, 20, 40)
FORK_FAMILIES = 2000 # replicated "runs of the whole exploration" per k
FORK_N_PER_GROUP = 200 # large enough that the z-test's normal approximation
# is trustworthy; Day 118 measured that interval coverage undershoots at
# n=40 for the same reason (a t, not a z, critical value is correct at small
# n) -- this lab avoids that undershoot the same way, by choosing n large.
# Simulated rates across seeds 1, 7, 42, 118, 2026 landed within 2.4 standard
# errors of the exact value in every case observed; three standard errors is
# the assertion tolerance below, with headroom.
FORK_SIM_TOLERANCE_SE = 3.0
# ---------------------------------------------------------------------------
# Exercise 2 -- a plausible story for noise (one concrete scan)
# ---------------------------------------------------------------------------
NARRATIVE_SEED = 6
NARRATIVE_N_ROWS = 80
NARRATIVE_SUBSET_COLS = ["region_west", "signed_up_tuesday", "over_40",
"used_mobile_app", "referred_by_friend"]
NARRATIVE_OUTCOME_COLS = ["revenue", "sessions", "days_active", "support_tickets"]
# 5 subset columns x 4 outcome columns = 20 comparisons is already close to
# the "after examining forty combinations" story; two subset DEFINITIONS
# per column (the raw split and its complement compared against a slightly
# different cut) bring the honest count to 40 -- see build_narrative_frame.
NARRATIVE_MIN_COMPARISONS = 40
# A "publishable-looking" standardized effect size, the common Cohen's d
# rule-of-thumb boundary between "medium" and "large" (Cohen, 1988).
PUBLISHABLE_EFFECT_SIZE = 0.5
# ---------------------------------------------------------------------------
# Exercise 3 -- the holdout rescues you (the day's centrepiece)
# ---------------------------------------------------------------------------
HOLDOUT_SEED = 1
HOLDOUT_N_TOTAL = 4000 # split 50/50 into exploration and confirmation
REAL_EFFECT_DELTA = 0.30 # a genuine, modest mean difference (Cohen's d ~0.3)
REAL_EFFECT_SIGMA = 1.0
# The spurious column carries NO true effect. Among the many spurious
# columns an analyst might have looked at, this lab shows the ONE the
# authoring run found significant on the exploration half, at the fixed
# seed above -- the same selection-after-the-fact the lesson's opening
# story warns about, made concrete instead of hypothetical.
N_SPURIOUS_CANDIDATES = 30
# ---------------------------------------------------------------------------
# Exercise 4 -- choices are comparisons
# ---------------------------------------------------------------------------
CHOICES_SEED = 7
CHOICES_N_ROWS = 600
CHOICES_SUBSET_CUTOFFS = [30, 60, 90, 150, 300] # five "recent enough" cutoffs
CHOICES_OUTCOME_DEFINITIONS = ["metric", "metric_scaled"] # two outcome definitions
CHOICES_FAMILIES = 3000 # replicated null worlds, to measure the inflation
# ---------------------------------------------------------------------------
# Exercise 5 -- Bonferroni, and its limit
# ---------------------------------------------------------------------------
BONFERRONI_KNOWN_M = 20
BONFERRONI_FAMILIES = 20000
# The true number of comparisons an analyst actually ran before landing on
# the one they report -- larger than what got written down.
BONFERRONI_TRUE_M = 60
# ---------------------------------------------------------------------------
# Exercise 8 -- a stopping rule
# ---------------------------------------------------------------------------
STOPPING_SEED = 9
STOPPING_BUDGET_QUESTIONS = 10 # the time-boxed / count-based rule's budget
STOPPING_FAMILIES = 20000
def two_group_frame(rng: np.random.Generator, n_per_group: int,
mean_a: float = 0.0, mean_b: float = 0.0,
sigma: float = 1.0) -> pd.DataFrame:
"""One real, tidy two-group DataFrame -- the shape an analyst actually
looks at, not a bare pair of arrays."""
outcome = np.concatenate([
rng.normal(mean_a, sigma, n_per_group),
rng.normal(mean_b, sigma, n_per_group),
])
group = np.array(["A"] * n_per_group + ["B"] * n_per_group)
return pd.DataFrame({"group": group, "outcome": outcome})
def build_narrative_frame(rng: np.random.Generator) -> pd.DataFrame:
"""One dataset with genuinely no signal: every outcome column is drawn
independently of every grouping column. Built with pandas because this
is exactly the object an analyst opens in a notebook -- one table, five
candidate ways to split customers into two groups, four candidate
outcome columns to compare between them."""
n = NARRATIVE_N_ROWS
data = {col: rng.integers(0, 2, n).astype(bool) for col in NARRATIVE_SUBSET_COLS}
# An independent second condition, so a "narrower cut" of each subset
# column is still a legitimate second comparison rather than a mask
# built from the very outcome column it will be tested against.
data["signed_up_this_quarter"] = rng.integers(0, 2, n).astype(bool)
for col in NARRATIVE_OUTCOME_COLS:
data[col] = rng.normal(0.0, 1.0, n)
return pd.DataFrame(data)
examples/exploration.py (19161 bytes)
"""The exploration machinery for Day 136: the loop, the holdout, the
research log, triage, a stopping rule, and the handoff to Day 133.
The two-sample z-test is the same from-scratch construction Day 118 built
(`phi`, `p_from_z_two_sided`, `z_critical_two_sided`, `two_sample_z_test`),
reused here rather than re-derived, because everything in this lab is
about WHEN you are allowed to trust a p-value, not how one is computed.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# The z-test, from math.erf alone (Day 118)
# ---------------------------------------------------------------------------
def phi(z: float) -> float:
"""The standard normal CDF, built from the error function."""
return 0.5 * (1.0 + math.erf(z / math.sqrt(2.0)))
def p_from_z_two_sided(z: float) -> float:
return 2.0 * (1.0 - phi(abs(z)))
def z_critical_two_sided(alpha: float) -> float:
"""The z whose two-sided tail probability is alpha, found by bisecting
`phi` -- there is no closed form for its inverse."""
lo, hi = 0.0, 10.0
target = 1.0 - alpha / 2.0
for _ in range(100):
mid = (lo + hi) / 2.0
if phi(mid) < target:
lo = mid
else:
hi = mid
return (lo + hi) / 2.0
def two_sample_z_test(a, b) -> tuple[float, float]:
"""Welch-style two-sample z-test: each sample keeps its own variance."""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
mean_a, mean_b = a.mean(), b.mean()
var_a, var_b = a.var(ddof=1), b.var(ddof=1)
se = math.sqrt(var_a / len(a) + var_b / len(b))
z = (mean_a - mean_b) / se
return z, p_from_z_two_sided(z)
def cohens_d(a, b) -> float:
"""A standardized effect size: the mean difference in pooled standard
deviations, so effect sizes are comparable across differently-scaled
outcome columns."""
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
n_a, n_b = len(a), len(b)
pooled_var = ((n_a - 1) * a.var(ddof=1) + (n_b - 1) * b.var(ddof=1)) / (n_a + n_b - 2)
pooled_sd = math.sqrt(pooled_var)
return (a.mean() - b.mean()) / pooled_sd
# ---------------------------------------------------------------------------
# Exercise 1 -- forking paths, measured
# ---------------------------------------------------------------------------
def simulate_forking_paths(rng: np.random.Generator, k: int, families: int,
n_per_group: int, alpha: float) -> dict[str, float]:
"""Run `families` independent replicates of "an analyst tries k
completely unrelated comparisons on data with no real signal", and
measure the fraction of replicates where at least one comparison comes
back significant.
Fully vectorised: draws real two-group samples (not bare z-statistics),
for `families * k` independent comparisons at once, then reduces along
the sample axis to get each comparison's z-statistic.
"""
group_a = rng.standard_normal((families, k, n_per_group))
group_b = rng.standard_normal((families, k, n_per_group))
mean_a, mean_b = group_a.mean(axis=-1), group_b.mean(axis=-1)
var_a, var_b = group_a.var(axis=-1, ddof=1), group_b.var(axis=-1, ddof=1)
se = np.sqrt(var_a / n_per_group + var_b / n_per_group)
z = (mean_a - mean_b) / se
z_crit = z_critical_two_sided(alpha)
significant = np.abs(z) > z_crit # shape (families, k)
at_least_one = significant.any(axis=1) # shape (families,)
simulated_rate = float(at_least_one.mean())
exact_rate = 1.0 - (1.0 - alpha) ** k
standard_error = math.sqrt(exact_rate * (1.0 - exact_rate) / families)
return {
"k": k,
"families": families,
"simulated_rate": simulated_rate,
"exact_rate": exact_rate,
"standard_error": standard_error,
"deviation": abs(simulated_rate - exact_rate),
}
# ---------------------------------------------------------------------------
# Exercise 2 -- a plausible story for noise
# ---------------------------------------------------------------------------
def scan_narrative_frame(df: pd.DataFrame, subset_cols: list[str],
outcome_cols: list[str]) -> list[dict[str, Any]]:
"""The forking-paths problem, made concrete: every combination of a
grouping column and an outcome column is one candidate analysis, run
with `pandas.DataFrame.groupby`. Two cut definitions per subset column
(the raw boolean split, and its complement re-cut against the median
of a second column) double the honest comparison count -- an analyst
rarely stops at trying a column once."""
results: list[dict[str, Any]] = []
for subset_col in subset_cols:
for outcome_col in outcome_cols:
for cut_name, mask in (
("raw_split", df[subset_col]),
("narrower_cut", df[subset_col] & df["signed_up_this_quarter"]),
):
grouped = df.assign(_mask=mask).groupby("_mask")[outcome_col]
if grouped.ngroups != 2:
continue
a = df.loc[mask, outcome_col].to_numpy()
b = df.loc[~mask, outcome_col].to_numpy()
if len(a) < 5 or len(b) < 5:
continue
z, p = two_sample_z_test(a, b)
d = cohens_d(a, b)
results.append({
"subset": subset_col,
"outcome": outcome_col,
"cut": cut_name,
"z": z,
"p": p,
"effect_size": d,
"significant": p < 0.05,
})
return results
def best_significant_result(results: list[dict[str, Any]]) -> dict[str, Any] | None:
"""The lowest p-value among the significant results -- the one an
analyst who stopped at the first "hit" would write up."""
hits = [r for r in results if r["significant"]]
if not hits:
return None
return min(hits, key=lambda r: r["p"])
# ---------------------------------------------------------------------------
# Exercise 3 -- the holdout rescues you
# ---------------------------------------------------------------------------
def build_holdout_frame(rng: np.random.Generator, n_total: int, real_delta: float,
sigma: float, n_spurious: int) -> pd.DataFrame:
"""One dataset with exactly one real, planted effect (`real_metric`,
which genuinely differs by group) and many spurious columns (no true
difference in any of them)."""
group = rng.integers(0, 2, n_total).astype(bool) # True = treatment
data = {"group": group}
data["real_metric"] = np.where(
group,
rng.normal(real_delta, sigma, n_total),
rng.normal(0.0, sigma, n_total),
)
for i in range(n_spurious):
data[f"spurious_{i}"] = rng.normal(0.0, sigma, n_total)
return pd.DataFrame(data)
def split_exploration_confirmation(df: pd.DataFrame, rng: np.random.Generator,
frac: float = 0.5) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Hold out a confirmation set at the START, before any question has
been asked of the data -- the practical device that separates
exploration from confirmation."""
shuffled = df.sample(frac=1.0, random_state=int(rng.integers(0, 2**31 - 1))).reset_index(drop=True)
cut = int(len(shuffled) * frac)
return shuffled.iloc[:cut].reset_index(drop=True), shuffled.iloc[cut:].reset_index(drop=True)
def test_column_by_group(df: pd.DataFrame, column: str) -> tuple[float, float]:
a = df.loc[df["group"], column].to_numpy()
b = df.loc[~df["group"], column].to_numpy()
return two_sample_z_test(a, b)
def best_spurious_column(exploration_df: pd.DataFrame, spurious_cols: list[str]) -> tuple[str, float, float]:
"""The exploration-only scan: test every spurious column, report the
one that happens to look best -- exactly what an analyst chasing a
plausible story would report, before checking anything held out."""
best_col, best_z, best_p = None, 0.0, 1.0
for col in spurious_cols:
z, p = test_column_by_group(exploration_df, col)
if p < best_p:
best_col, best_z, best_p = col, z, p
return best_col, best_z, best_p
# ---------------------------------------------------------------------------
# Exercise 4 -- choices are comparisons
# ---------------------------------------------------------------------------
def build_choices_frame(rng: np.random.Generator, n_rows: int) -> pd.DataFrame:
"""No real signal: `metric` is independent of `days_since_signup` and of
every threshold you might cut it at."""
return pd.DataFrame({
"days_since_signup": rng.integers(1, 400, n_rows),
"sessions": rng.poisson(5, n_rows),
"metric": rng.normal(0.0, 1.0, n_rows),
})
def best_of_choice_grid(df: pd.DataFrame, subset_cutoffs: list[int],
outcome_definitions: list[str]) -> dict[str, Any]:
"""Vary a subset filter (recency cutoff) and an outcome definition, with
NO explicit hypothesis test declared per variant -- just "which cut
looks best" -- and return the best-looking result. `outcome_definitions`
names of the form "metric" or "metric_x2" select or transform the
outcome column actually compared."""
best = None
for cutoff in subset_cutoffs:
recent = df[df["days_since_signup"] <= cutoff]
rest = df[df["days_since_signup"] > cutoff]
if len(recent) < 5 or len(rest) < 5:
continue
for definition in outcome_definitions:
if definition == "metric":
a, b = recent["metric"].to_numpy(), rest["metric"].to_numpy()
elif definition == "metric_scaled":
a, b = recent["metric"].to_numpy() * recent["sessions"].to_numpy(), \
rest["metric"].to_numpy() * rest["sessions"].to_numpy()
else:
raise ValueError(f"unknown outcome definition: {definition}")
z, p = two_sample_z_test(a, b)
candidate = {"cutoff": cutoff, "definition": definition, "z": z, "p": p}
if best is None or p < best["p"]:
best = candidate
return best
def simulate_choice_grid_best_p_rate(rng: np.random.Generator, families: int,
n_rows: int, subset_cutoffs: list[int],
outcome_definitions: list[str], alpha: float) -> dict[str, float]:
"""Under a TRUE null (no real signal anywhere), how often does the
single best-looking cell of the choice grid come back "significant" at
alpha, versus how often ONE pre-declared comparison would? This is the
forking-paths rate for choices nobody called a test."""
n_variants = len(subset_cutoffs) * len(outcome_definitions)
naive_hits = 0
single_hits = 0
for _ in range(families):
df = build_choices_frame(rng, n_rows)
best = best_of_choice_grid(df, subset_cutoffs, outcome_definitions)
if best["p"] < alpha:
naive_hits += 1
# the "single pre-declared comparison" control: always the first cell
recent = df[df["days_since_signup"] <= subset_cutoffs[0]]
rest = df[df["days_since_signup"] > subset_cutoffs[0]]
_, p_single = two_sample_z_test(recent["metric"].to_numpy(), rest["metric"].to_numpy())
if p_single < alpha:
single_hits += 1
return {
"n_variants": n_variants,
"families": families,
"naive_best_rate": naive_hits / families,
"single_declared_rate": single_hits / families,
}
# ---------------------------------------------------------------------------
# Exercise 5 -- Bonferroni, and its limit
# ---------------------------------------------------------------------------
def bonferroni_alpha(alpha: float, m: int) -> float:
return alpha / m
def simulate_family_wise_rate(rng: np.random.Generator, k: int, families: int,
alpha: float) -> float:
z_crit = z_critical_two_sided(alpha)
zs = rng.standard_normal((families, k))
return float((np.abs(zs) > z_crit).any(axis=1).mean())
# ---------------------------------------------------------------------------
# Exercise 6 -- the research log as a data structure
# ---------------------------------------------------------------------------
@dataclass
class LogEntry:
timestamp: str
question: str
look: str
outcome: str | None # None is a valid, recorded outcome: "nothing found"
@dataclass
class ResearchLog:
"""A dated record of every question asked, what was looked at, and what
was found -- including the nothings. The log's own length IS the
comparison count; nothing needs to be counted separately or trusted."""
entries: list[LogEntry] = field(default_factory=list)
def record(self, question: str, look: str, outcome: str | None,
timestamp: str | None = None) -> LogEntry:
entry = LogEntry(
timestamp=timestamp or datetime.now(timezone.utc).isoformat(),
question=question,
look=look,
outcome=outcome,
)
self.entries.append(entry)
return entry
@property
def comparison_count(self) -> int:
return len(self.entries)
@property
def null_count(self) -> int:
return sum(1 for e in self.entries if e.outcome is None)
def findings(self) -> list[LogEntry]:
return [e for e in self.entries if e.outcome is not None]
# ---------------------------------------------------------------------------
# Exercise 7 -- triage
# ---------------------------------------------------------------------------
@dataclass
class Candidate:
name: str
expected_information: float # 0-1: how much this could change our belief
cost_hours: float # analyst-hours to answer
decision_relevance: float # 0-1: how much the answer could change a decision
def triage_score(candidate: Candidate) -> float:
"""Expected information times decision relevance, per hour of cost.
A question that would teach you a great deal but changes no decision
scores low on purpose (Day 119's framing: an answer that would not
change what you do is not worth pursuing first); a cheap question with
real decision weight outranks an expensive one with the same weight."""
return (candidate.expected_information * candidate.decision_relevance) / candidate.cost_hours
def rank_candidates(candidates: list[Candidate]) -> list[Candidate]:
return sorted(candidates, key=triage_score, reverse=True)
# ---------------------------------------------------------------------------
# Exercise 8 -- a stopping rule
# ---------------------------------------------------------------------------
def time_boxed_exploration(rng: np.random.Generator, budget_questions: int,
alpha: float) -> dict[str, Any]:
"""Ask exactly `budget_questions` questions of data with no real signal,
then stop, REGARDLESS of whether anything looked significant along the
way. Returns whether ANY of the budget's questions crossed alpha (which
can still happen by chance) and how many were asked."""
z_crit = z_critical_two_sided(alpha)
zs = rng.standard_normal(budget_questions)
return {
"questions_asked": budget_questions,
"any_significant": bool(np.any(np.abs(zs) > z_crit)),
}
def stop_when_significant_rate(rng: np.random.Generator, families: int,
max_questions: int, alpha: float) -> float:
"""The failure mode: keep asking questions of data with NO real signal
and stop the moment one looks significant. Measures the fraction of
"exploration sessions" that end in a reported false positive -- which
is far higher than alpha, because you gave chance many tries."""
z_crit = z_critical_two_sided(alpha)
hits = 0
for _ in range(families):
zs = rng.standard_normal(max_questions)
if np.any(np.abs(zs) > z_crit):
hits += 1
return hits / families
def time_boxed_false_positive_rate(rng: np.random.Generator, families: int,
budget_questions: int, alpha: float) -> float:
"""The honest counterpart: a fixed budget of questions is asked and the
session reports "found something" only if the LAST question asked (the
one pre-declared for reporting) is significant -- not whichever of the
budget happened to look best. This is what a time-box protects, if the
analyst does not also silently swap in `stop_when_significant_rate`'s
behaviour once inside the budget."""
z_crit = z_critical_two_sided(alpha)
hits = 0
for _ in range(families):
zs = rng.standard_normal(budget_questions)
if abs(zs[-1]) > z_crit:
hits += 1
return hits / families
# ---------------------------------------------------------------------------
# Exercise 9 -- the handoff to Day 133
# ---------------------------------------------------------------------------
REQUIRED_HANDOFF_FIELDS = ("finding", "confirmation_result", "comparison_count")
def build_handoff(finding: dict[str, Any], confirmation_result: dict[str, Any],
comparison_count: int) -> dict[str, Any]:
"""The object exploration hands to Day 133's report stage. Every field
is required -- a report cannot be written from a finding alone, because
the reader needs to know it survived confirmation and how many things
were looked at along the way."""
handoff = {
"finding": finding,
"confirmation_result": confirmation_result,
"comparison_count": comparison_count,
}
validate_handoff(handoff)
return handoff
def validate_handoff(handoff: dict[str, Any]) -> None:
missing = [f for f in REQUIRED_HANDOFF_FIELDS if f not in handoff or handoff[f] is None]
if missing:
raise ValueError(f"handoff is missing required field(s): {', '.join(missing)}")
if not isinstance(handoff["comparison_count"], int) or handoff["comparison_count"] < 1:
raise ValueError("comparison_count must be a positive integer")
def write_report_stub(handoff: dict[str, Any]) -> str:
"""A minimal stand-in for Day 133's report generator: it REFUSES to run
without a valid handoff, the same way Day 133's generator refuses a
figure with no stated question."""
validate_handoff(handoff)
finding = handoff["finding"]
conf = handoff["confirmation_result"]
return (
f"Finding: {finding.get('name', 'unnamed')} "
f"(exploration p={finding.get('p', float('nan')):.4g}). "
f"Confirmed on holdout: p={conf.get('p', float('nan')):.4g}. "
f"Comparisons run before this finding was reported: {handoff['comparison_count']}."
)
examples/test_reference.py (10012 bytes)
"""The reference pytest suite -- real values, real exceptions, one test per
claim the nine scripts make. Run from the lab directory:
.venv/bin/pytest examples -q -p no:cacheprovider
"""
import math
import numpy as np
import pytest
import dataset as ds
import exploration as ex
# --------------------------------------------------------------------------
# The z-test machinery (reused from Day 118)
# --------------------------------------------------------------------------
def test_phi_of_zero_is_one_half():
assert ex.phi(0.0) == pytest.approx(0.5)
def test_z_critical_two_sided_matches_known_constant():
assert ex.z_critical_two_sided(0.05) == pytest.approx(1.959964, abs=1e-4)
def test_two_sample_z_test_matches_hand_computation():
a = [50, 52, 49, 51, 53, 48, 50, 52, 51, 49]
b = [54, 55, 53, 56, 54, 52, 55, 53, 54, 56]
z, p = ex.two_sample_z_test(a, b)
import statistics
mean_a, var_a = statistics.mean(a), statistics.variance(a)
mean_b, var_b = statistics.mean(b), statistics.variance(b)
se = math.sqrt(var_a / len(a) + var_b / len(b))
z_hand = (mean_a - mean_b) / se
assert z == pytest.approx(z_hand, abs=1e-9)
assert p < 0.001
def test_cohens_d_of_identical_samples_is_zero():
a = [1.0, 2.0, 3.0, 4.0, 5.0]
assert ex.cohens_d(a, a) == pytest.approx(0.0)
# --------------------------------------------------------------------------
# Exercise 1 -- forking paths, measured
# --------------------------------------------------------------------------
def test_exact_fwer_matches_known_values():
assert abs((1 - (1 - 0.05) ** 5) - 0.2262) < 0.0001
assert abs((1 - (1 - 0.05) ** 20) - 0.6415) < 0.0001
assert abs((1 - (1 - 0.05) ** 40) - 0.8715) < 0.0001
def test_simulated_forking_paths_matches_exact_within_tolerance():
rng = np.random.default_rng(3)
for k in ds.FORK_K_VALUES:
result = ex.simulate_forking_paths(rng, k, 1500, 200, ds.ALPHA)
assert result["deviation"] <= ds.FORK_SIM_TOLERANCE_SE * result["standard_error"], (
f"k={k}: simulated {result['simulated_rate']} too far from exact {result['exact_rate']}"
)
def test_narrative_scan_runs_at_least_forty_comparisons():
rng = np.random.default_rng(ds.NARRATIVE_SEED)
df = ds.build_narrative_frame(rng)
results = ex.scan_narrative_frame(df, ds.NARRATIVE_SUBSET_COLS, ds.NARRATIVE_OUTCOME_COLS)
assert len(results) >= ds.NARRATIVE_MIN_COMPARISONS
def test_narrative_scan_finds_at_least_one_significant_result():
rng = np.random.default_rng(ds.NARRATIVE_SEED)
df = ds.build_narrative_frame(rng)
results = ex.scan_narrative_frame(df, ds.NARRATIVE_SUBSET_COLS, ds.NARRATIVE_OUTCOME_COLS)
best = ex.best_significant_result(results)
assert best is not None
assert best["p"] < ds.ALPHA
# --------------------------------------------------------------------------
# Exercise 2 -- a plausible story for noise
# --------------------------------------------------------------------------
def test_winning_comparison_looks_publishable():
rng = np.random.default_rng(ds.NARRATIVE_SEED)
df = ds.build_narrative_frame(rng)
results = ex.scan_narrative_frame(df, ds.NARRATIVE_SUBSET_COLS, ds.NARRATIVE_OUTCOME_COLS)
best = ex.best_significant_result(results)
assert abs(best["effect_size"]) >= ds.PUBLISHABLE_EFFECT_SIZE
# --------------------------------------------------------------------------
# Exercise 3 -- the holdout rescues you
# --------------------------------------------------------------------------
def test_real_effect_survives_confirmation():
rng = np.random.default_rng(ds.HOLDOUT_SEED)
df = ex.build_holdout_frame(rng, ds.HOLDOUT_N_TOTAL, ds.REAL_EFFECT_DELTA, ds.REAL_EFFECT_SIGMA, ds.N_SPURIOUS_CANDIDATES)
exploration_df, confirmation_df = ex.split_exploration_confirmation(df, rng)
_, p_exp = ex.test_column_by_group(exploration_df, "real_metric")
_, p_conf = ex.test_column_by_group(confirmation_df, "real_metric")
assert p_exp < ds.ALPHA
assert p_conf < ds.ALPHA
def test_spurious_finding_does_not_survive_confirmation():
rng = np.random.default_rng(ds.HOLDOUT_SEED)
df = ex.build_holdout_frame(rng, ds.HOLDOUT_N_TOTAL, ds.REAL_EFFECT_DELTA, ds.REAL_EFFECT_SIGMA, ds.N_SPURIOUS_CANDIDATES)
exploration_df, confirmation_df = ex.split_exploration_confirmation(df, rng)
spurious_cols = [c for c in df.columns if c.startswith("spurious_")]
best_col, _, p_exp = ex.best_spurious_column(exploration_df, spurious_cols)
_, p_conf = ex.test_column_by_group(confirmation_df, best_col)
assert p_exp < ds.ALPHA
assert p_conf >= ds.ALPHA
# --------------------------------------------------------------------------
# Exercise 4 -- choices are comparisons
# --------------------------------------------------------------------------
def test_choice_grid_inflates_significance_rate():
rng = np.random.default_rng(ds.CHOICES_SEED)
result = ex.simulate_choice_grid_best_p_rate(
rng, 1200, ds.CHOICES_N_ROWS, ds.CHOICES_SUBSET_CUTOFFS, ds.CHOICES_OUTCOME_DEFINITIONS, ds.ALPHA
)
assert result["naive_best_rate"] > 3 * ds.ALPHA
assert abs(result["single_declared_rate"] - ds.ALPHA) < 0.03
# --------------------------------------------------------------------------
# Exercise 5 -- Bonferroni, and its limit
# --------------------------------------------------------------------------
def test_bonferroni_alpha_divides_by_m():
assert ex.bonferroni_alpha(0.05, 20) == pytest.approx(0.0025)
def test_bonferroni_restores_nominal_rate_when_m_is_known():
rng = np.random.default_rng(11)
corrected = ex.bonferroni_alpha(ds.ALPHA, ds.BONFERRONI_KNOWN_M)
rate = ex.simulate_family_wise_rate(rng, ds.BONFERRONI_KNOWN_M, 8000, corrected)
assert abs(rate - ds.ALPHA) < 0.02
def test_bonferroni_fails_when_true_m_exceeds_reported_m():
rng = np.random.default_rng(12)
corrected = ex.bonferroni_alpha(ds.ALPHA, ds.BONFERRONI_KNOWN_M)
rate_right = ex.simulate_family_wise_rate(rng, ds.BONFERRONI_KNOWN_M, 8000, corrected)
rate_wrong = ex.simulate_family_wise_rate(rng, ds.BONFERRONI_TRUE_M, 8000, corrected)
assert rate_wrong > rate_right
assert rate_wrong > 2 * ds.ALPHA
# --------------------------------------------------------------------------
# Exercise 6 -- the research log
# --------------------------------------------------------------------------
def test_log_records_timestamp_look_and_outcome():
log = ex.ResearchLog()
log.record("q1", "look1", None)
log.record("q2", "look2", "p=0.01")
assert log.comparison_count == 2
assert all(e.timestamp for e in log.entries)
assert all(e.look for e in log.entries)
assert log.null_count == 1
assert len(log.findings()) == 1
def test_log_comparison_count_matches_comparisons_actually_run():
rng = np.random.default_rng(ds.NARRATIVE_SEED)
df = ds.build_narrative_frame(rng)
results = ex.scan_narrative_frame(df, ds.NARRATIVE_SUBSET_COLS, ds.NARRATIVE_OUTCOME_COLS)
log = ex.ResearchLog()
for r in results:
log.record("q", "look", "found" if r["significant"] else None)
assert log.comparison_count == len(results)
# --------------------------------------------------------------------------
# Exercise 7 -- triage
# --------------------------------------------------------------------------
def test_triage_score_rewards_cheap_and_relevant():
cheap_relevant = ex.Candidate("a", expected_information=0.8, cost_hours=2, decision_relevance=0.9)
expensive_irrelevant = ex.Candidate("b", expected_information=0.9, cost_hours=40, decision_relevance=0.5)
assert ex.triage_score(cheap_relevant) > ex.triage_score(expensive_irrelevant)
def test_rank_candidates_orders_descending_by_score():
candidates = [
ex.Candidate("low", expected_information=0.1, cost_hours=10, decision_relevance=0.1),
ex.Candidate("high", expected_information=0.9, cost_hours=1, decision_relevance=0.9),
]
ranked = ex.rank_candidates(candidates)
assert ranked[0].name == "high"
assert ranked[1].name == "low"
# --------------------------------------------------------------------------
# Exercise 8 -- a stopping rule
# --------------------------------------------------------------------------
def test_time_boxed_rate_sits_near_nominal_alpha():
rng = np.random.default_rng(ds.STOPPING_SEED)
rate = ex.time_boxed_false_positive_rate(rng, 15000, ds.STOPPING_BUDGET_QUESTIONS, ds.ALPHA)
assert abs(rate - ds.ALPHA) < 0.02
def test_stop_when_significant_rate_is_much_higher():
rng = np.random.default_rng(ds.STOPPING_SEED)
tb_rate = ex.time_boxed_false_positive_rate(rng, 15000, ds.STOPPING_BUDGET_QUESTIONS, ds.ALPHA)
sw_rate = ex.stop_when_significant_rate(rng, 15000, ds.STOPPING_BUDGET_QUESTIONS, ds.ALPHA)
assert sw_rate > 3 * ds.ALPHA
assert sw_rate > tb_rate
# --------------------------------------------------------------------------
# Exercise 9 -- the handoff to Day 133
# --------------------------------------------------------------------------
def test_build_handoff_accepts_a_complete_object():
handoff = ex.build_handoff({"name": "x", "p": 0.01}, {"p": 0.02}, 10)
assert handoff["comparison_count"] == 10
@pytest.mark.parametrize("missing_field", ex.REQUIRED_HANDOFF_FIELDS)
def test_build_handoff_refuses_a_missing_field(missing_field):
kwargs = {"finding": {"name": "x"}, "confirmation_result": {"p": 0.02}, "comparison_count": 5}
kwargs[missing_field] = None
with pytest.raises(ValueError):
ex.build_handoff(**kwargs)
def test_write_report_stub_refuses_a_bare_finding():
with pytest.raises(ValueError):
ex.write_report_stub({"finding": {"name": "x"}})
def test_write_report_stub_renders_a_complete_handoff():
handoff = ex.build_handoff({"name": "x", "p": 0.004}, {"p": 0.01}, 12)
text = ex.write_report_stub(handoff)
assert "x" in text
assert "12" in text
metadata.yml (6223 bytes)
lesson_id: D136
day: 136
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/math-statistics-and-data/day-136-the-exploratory-data-analysis-process
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/python3 -c "import numpy, pandas; print(numpy.__version__, pandas.__version__)"
run_commands:
- 'cd examples && ../.venv/bin/python3 01_forking_paths.py && cd ..'
- 'cd examples && ../.venv/bin/python3 02_plausible_story.py && cd ..'
- 'cd examples && ../.venv/bin/python3 03_holdout_rescues_you.py && cd ..'
- 'cd examples && ../.venv/bin/python3 04_choices_are_comparisons.py && cd ..'
- 'cd examples && ../.venv/bin/python3 05_bonferroni_and_its_limit.py && cd ..'
- 'cd examples && ../.venv/bin/python3 06_research_log.py && cd ..'
- 'cd examples && ../.venv/bin/python3 07_triage.py && cd ..'
- 'cd examples && ../.venv/bin/python3 08_stopping_rule.py && cd ..'
- 'cd examples && ../.venv/bin/python3 09_handoff_contract.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: 40
last_executed: '2026-08-20'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, numpy 2.5.2, pandas 3.0.5, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 33 checks, 0 failure(s), exit 0 (captured directly, not through a pipeline). pytest examples -> 27 passed in 1.83s; pytest starter -> 1 passed, 13 skipped on an untouched checkout, and 14 passed against a fully solved copy of starter/exploration.py (verified by temporarily copying the reference examples/exploration.py into starter/, confirming all 14 tests passed, then restoring the blank skeleton -- the skip count after restoring was reconfirmed at 13, and a bare `pytest -q` with no path argument from the lab directory reports 28 passed, 13 skipped, matching pytest examples (27) plus pytest starter (1 passed) with no cross-suite bleed, proving both conftest.py import guards work for that invocation). All nine reference scripts exit 0 with every internal assertion holding, each ending with a line starting "OK:". Everything was run through a real lab-local .venv created by the documented setup commands. Section 5 of the harness re-runs script 01 (forking paths) with its expected exact rate for k=20 temporarily replaced with a deliberately wrong value (99.0 instead of 0.6415), confirms the run exits non-zero with the named AssertionError showing the real value, and does not touch the file on disk. Separately during development, a real bug was introduced directly into exploration.py''s bonferroni_alpha (changing alpha/m to alpha*m) and the harness correctly reported 4 of 33 checks failing before the fix was reverted and a clean 33/0 run was reconfirmed. Five honesty notes from this run. FIRST: statsmodels, Jupyter/nbconvert, and Weights & Biases are not installed in this environment; all three are described from their public documentation in the lesson''s Tools section and explicitly marked as not run here -- no output attributed to any of them anywhere in this lab or its lesson was actually produced by them. SECOND: every sampled figure in this lab (the simulated forking-paths rates, the effect size and p-value of the "winning" comparison in exercises 1-2, both pairs of p-values in the holdout exercise, the inflation ratios in exercises 4 and 8) is a freshly measured number checked against a tolerance derived from a standard error or an explicitly wide margin, never a literal chosen to make the test pass; the specific numbers reported in the lesson and this file are what this run actually produced. THIRD: the seed for the holdout exercise (HOLDOUT_SEED=1) was chosen after sweeping seeds 1 through 199 during development and finding 157 of them produced the same qualitative outcome (real effect survives both halves, the exploration-chosen spurious column fails confirmation) -- seed 1 was the first hit, not a specially favorable one; the narrative-scan seed (NARRATIVE_SEED=6, with NARRATIVE_N_ROWS reduced from an initial 400 to 80) was chosen because at n=400 the winning comparison''s effect size stayed below the d=0.5 "publishable" threshold across the seeds tried, and reducing the per-comparison sample size to 80 (a real, measured effect of noisier small-sample statistics producing larger apparent effect sizes by chance) was needed to reliably clear it -- this trade-off is recorded here and in troubleshooting.md rather than silently tuned away. FOURTH: an earlier version of scan_narrative_frame constructed its second subset cut by re-splitting on the median of the very outcome column being tested (`df[outcome_cols[0]] > df[outcome_cols[0]].median()`), which created real data leakage -- comparisons then showed effect sizes above d=1.0 on data with no true effect, because the recut mask was literally correlated with the outcome by construction. This was caught before being reported anywhere, and the second cut was changed to use an independent column (`signed_up_this_quarter`) that carries no relationship to any outcome column; the corrected effect sizes (d around 0.2-0.6 depending on seed) are the ones reported throughout this lab. FIFTH: an explicit `pytest examples starter` invocation (both directories as separate command-line arguments) was not tested in this lab, following the project convention established in Day 118 after that combined form was found unreliable there; only `pytest examples`, `pytest starter`, and bare `pytest` with no path (auto-discovery) are documented and tested here, and all three were confirmed to isolate correctly. The exact family-wise error rates (0.2262, 0.6415, 0.8715 for k=5, 20, 40) and the Bonferroni-corrected alpha (0.0025 for m=20) are closed-form arithmetic and identical on any correct implementation, anywhere.'
requirements/README.md (2956 bytes)
# What is installed, why, and what it costs
Three packages, all free and open source, all 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 the forking-paths and stopping-rule simulations. |
| `pandas` | 3.0.5 | BSD 3-Clause | Building the tidy, real DataFrames each exercise looks at (`groupby`-based comparisons, subset filters, the exploration/confirmation split), rather than working on bare arrays. |
| `pytest` | 9.1.1 | MIT | The reference suite (27 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 standard library's `math` module
(specifically `math.erf`) supplies every normal-distribution calculation --
no statistical package is needed for that part at all.
## 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.
## What is deliberately *not* installed
**`statsmodels`** is not installed. Its `statsmodels.stats.multitest`
module implements Bonferroni, Holm and false-discovery-rate corrections in
one call each, and can also report the raw multiplicity a step-down
procedure like Holm needs; this lab's `bonferroni_alpha` is one line,
described further, with `statsmodels`, in the lesson's Tools section. No
output from `statsmodels` is reproduced anywhere in this lab or its
lesson -- it is described from its public documentation only.
**Jupyter / `nbconvert`** is not installed either. This lab's exploration
loop runs as plain scripts so it stays fully scriptable and testable; the
lesson's Tools section describes Jupyter as the medium most analysts
actually explore in, and names Day 139 as where this course installs and
uses it directly.
**Weights & Biases** (or any hosted experiment tracker) is not installed
and nothing here calls out to a network service beyond the one-time
package install above. The lesson's Tools section describes what it adds
over a plain research log, and states its free-tier terms exactly as its
own documentation states them, with nothing further inferred.
## If you cannot install anything at all
You still need pandas and NumPy for this lab: the holdout split, the
choice-grid scan, and the vectorised forking-paths simulation are all
built on them. If neither can be installed, the ideas -- hold out a
confirmation set before forming a hypothesis, count every comparison you
actually ran, treat "we stopped when we found something" as a warning
sign -- can still be practiced by hand on a small dataset with the
standard library's `random` module, but this lab's exercises and tests
are not written against that path.
requirements/requirements.txt (41 bytes)
numpy==2.5.2
pandas==3.0.5
pytest==9.1.1
starter/00_brief.md (3919 bytes)
# The nine exercises
Work through these in order, in `exploration.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. Forking paths, measured
`simulate_forking_paths(rng, k, families, n_per_group, alpha)`. Draw real
two-group samples for `families * k` independent comparisons at once
(vectorised: `rng.standard_normal((families, k, n_per_group))` for each
group), reduce to each comparison's z-statistic, and measure the fraction
of families with at least one significant comparison. Compare against the
exact formula `1 - (1 - alpha)**k` for k = 5, 20, 40, and assert the
simulated rate lands within three standard errors of it.
## 2. A plausible story for noise
`scan_narrative_frame(df, subset_cols, outcome_cols)`,
`best_significant_result(results)`. Run every (subset column, outcome
column, cut) combination as a real two-sample z-test with pandas, on data
built with `dataset.build_narrative_frame` -- no real signal anywhere.
Assert the winning comparison's effect size clears the conventional
"medium vs. large" boundary (d = 0.5), so you can see why a forking-paths
result is tempting to report, not just technically wrong.
## 3. The holdout rescues you -- the day's centrepiece
`build_holdout_frame`, `split_exploration_confirmation`,
`test_column_by_group`, `best_spurious_column`. Build one dataset with a
REAL planted effect and thirty SPURIOUS columns. Split it in half before
looking at anything. Test the real effect and the best-looking spurious
column (chosen from exploration only) on both halves. Assert the real
effect is significant on both; assert the spurious one is significant on
exploration but NOT on confirmation.
## 4. Choices are comparisons
`build_choices_frame`, `best_of_choice_grid`,
`simulate_choice_grid_best_p_rate`. Vary a recency cutoff and an outcome
definition -- ten silent variants, no test declared per variant -- and
measure how often the single best-looking cell is "significant" under a
true null, versus how often one pre-declared comparison is. Assert the
naive rate is well above alpha and the pre-declared rate sits near it.
## 5. Bonferroni, and its limit
`bonferroni_alpha(alpha, m)`, `simulate_family_wise_rate(rng, k, families,
alpha)`. Assert the corrected alpha restores the family-wise rate near
nominal when `m` is known and correct. Then assert that applying the SAME
corrected alpha to a search that actually ran more comparisons than were
reported pushes the real rate well back above alpha.
## 6. The research log as a data structure
`ResearchLog.record`, `.comparison_count`, `.null_count`, `.findings()`.
Every recorded question carries a timestamp, a look, and an outcome --
including `None` for "nothing found". Assert the log's own length equals
the number of comparisons actually run.
## 7. Triage
`triage_score(candidate)`, `rank_candidates(candidates)`. Score
`(expected_information * decision_relevance) / cost_hours` and rank
descending. Assert a cheap, decision-relevant question outranks an
expensive, less relevant one.
## 8. A stopping rule
`time_boxed_false_positive_rate`, `stop_when_significant_rate`. Both
simulate sessions of up to `budget_questions` looks at data with no real
signal. The time-boxed version reports a false positive only if the last
(pre-declared) question is significant; the "stop when significant"
version reports one if ANY question along the way was. Assert the first
sits near alpha and the second is several times higher.
## 9. The handoff to Day 133
`build_handoff`, `validate_handoff`, `write_report_stub`. Assemble the
object the report stage needs: the finding, its confirmation-set result,
and the comparison count. Assert it is refused when any field is missing,
and that `write_report_stub` refuses to run without a valid handoff.
starter/conftest.py (1036 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain modules called `exploration` 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 ("exploration", "dataset"):
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 (6129 bytes)
"""Shared constants, data generators and tolerances for the Day 136 lab.
Every number an exercise checks against lives here, next to a comment
saying where it came from -- exact arithmetic, a derived standard error,
or a tolerance observed across several seeds during development (seeds
1, 7, 42, 118, 2026, recorded beside the constant it produced). Nothing in
this file is fabricated: every tolerance was checked by re-running the
exercise logic across those five seeds before being fixed here.
No real signal means exactly that: outcome columns are drawn independently
of every grouping column, so the TRUE effect of any comparison built from
them is zero. Any "significant" result exercise 1 finds is, by
construction, a false positive.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# Exercise 1 -- forking paths, measured
# ---------------------------------------------------------------------------
ALPHA = 0.05
FORK_K_VALUES = (5, 20, 40)
FORK_FAMILIES = 2000 # replicated "runs of the whole exploration" per k
FORK_N_PER_GROUP = 200 # large enough that the z-test's normal approximation
# is trustworthy; Day 118 measured that interval coverage undershoots at
# n=40 for the same reason (a t, not a z, critical value is correct at small
# n) -- this lab avoids that undershoot the same way, by choosing n large.
# Simulated rates across seeds 1, 7, 42, 118, 2026 landed within 2.4 standard
# errors of the exact value in every case observed; three standard errors is
# the assertion tolerance below, with headroom.
FORK_SIM_TOLERANCE_SE = 3.0
# ---------------------------------------------------------------------------
# Exercise 2 -- a plausible story for noise (one concrete scan)
# ---------------------------------------------------------------------------
NARRATIVE_SEED = 6
NARRATIVE_N_ROWS = 80
NARRATIVE_SUBSET_COLS = ["region_west", "signed_up_tuesday", "over_40",
"used_mobile_app", "referred_by_friend"]
NARRATIVE_OUTCOME_COLS = ["revenue", "sessions", "days_active", "support_tickets"]
# 5 subset columns x 4 outcome columns = 20 comparisons is already close to
# the "after examining forty combinations" story; two subset DEFINITIONS
# per column (the raw split and its complement compared against a slightly
# different cut) bring the honest count to 40 -- see build_narrative_frame.
NARRATIVE_MIN_COMPARISONS = 40
# A "publishable-looking" standardized effect size, the common Cohen's d
# rule-of-thumb boundary between "medium" and "large" (Cohen, 1988).
PUBLISHABLE_EFFECT_SIZE = 0.5
# ---------------------------------------------------------------------------
# Exercise 3 -- the holdout rescues you (the day's centrepiece)
# ---------------------------------------------------------------------------
HOLDOUT_SEED = 1
HOLDOUT_N_TOTAL = 4000 # split 50/50 into exploration and confirmation
REAL_EFFECT_DELTA = 0.30 # a genuine, modest mean difference (Cohen's d ~0.3)
REAL_EFFECT_SIGMA = 1.0
# The spurious column carries NO true effect. Among the many spurious
# columns an analyst might have looked at, this lab shows the ONE the
# authoring run found significant on the exploration half, at the fixed
# seed above -- the same selection-after-the-fact the lesson's opening
# story warns about, made concrete instead of hypothetical.
N_SPURIOUS_CANDIDATES = 30
# ---------------------------------------------------------------------------
# Exercise 4 -- choices are comparisons
# ---------------------------------------------------------------------------
CHOICES_SEED = 7
CHOICES_N_ROWS = 600
CHOICES_SUBSET_CUTOFFS = [30, 60, 90, 150, 300] # five "recent enough" cutoffs
CHOICES_OUTCOME_DEFINITIONS = ["metric", "metric_scaled"] # two outcome definitions
CHOICES_FAMILIES = 3000 # replicated null worlds, to measure the inflation
# ---------------------------------------------------------------------------
# Exercise 5 -- Bonferroni, and its limit
# ---------------------------------------------------------------------------
BONFERRONI_KNOWN_M = 20
BONFERRONI_FAMILIES = 20000
# The true number of comparisons an analyst actually ran before landing on
# the one they report -- larger than what got written down.
BONFERRONI_TRUE_M = 60
# ---------------------------------------------------------------------------
# Exercise 8 -- a stopping rule
# ---------------------------------------------------------------------------
STOPPING_SEED = 9
STOPPING_BUDGET_QUESTIONS = 10 # the time-boxed / count-based rule's budget
STOPPING_FAMILIES = 20000
def two_group_frame(rng: np.random.Generator, n_per_group: int,
mean_a: float = 0.0, mean_b: float = 0.0,
sigma: float = 1.0) -> pd.DataFrame:
"""One real, tidy two-group DataFrame -- the shape an analyst actually
looks at, not a bare pair of arrays."""
outcome = np.concatenate([
rng.normal(mean_a, sigma, n_per_group),
rng.normal(mean_b, sigma, n_per_group),
])
group = np.array(["A"] * n_per_group + ["B"] * n_per_group)
return pd.DataFrame({"group": group, "outcome": outcome})
def build_narrative_frame(rng: np.random.Generator) -> pd.DataFrame:
"""One dataset with genuinely no signal: every outcome column is drawn
independently of every grouping column. Built with pandas because this
is exactly the object an analyst opens in a notebook -- one table, five
candidate ways to split customers into two groups, four candidate
outcome columns to compare between them."""
n = NARRATIVE_N_ROWS
data = {col: rng.integers(0, 2, n).astype(bool) for col in NARRATIVE_SUBSET_COLS}
# An independent second condition, so a "narrower cut" of each subset
# column is still a legitimate second comparison rather than a mask
# built from the very outcome column it will be tested against.
data["signed_up_this_quarter"] = rng.integers(0, 2, n).astype(bool)
for col in NARRATIVE_OUTCOME_COLS:
data[col] = rng.normal(0.0, 1.0, n)
return pd.DataFrame(data)
starter/exploration.py (12061 bytes)
"""The exploration machinery for Day 136 -- YOUR skeleton.
Read `00_brief.md` first, then fill these in one at a time. Check yourself
as you go:
.venv/bin/pytest starter -q
Unattempted functions raise `NotImplementedError`, which the test suite
reports as SKIPPED, not failed. A skip means "not attempted yet"; a
failure means "attempted and wrong", and shows your answer next to the
correct one.
The two-sample z-test (`phi`, `p_from_z_two_sided`, `z_critical_two_sided`,
`two_sample_z_test`) is the same from-scratch construction Day 118 built.
If you kept your Day 118 solution, your versions will work here unchanged.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# The z-test, from math.erf alone (Day 118) -- carry your solution forward,
# or rebuild it here.
# ---------------------------------------------------------------------------
def phi(z: float) -> float:
"""The standard normal CDF, built from the error function."""
raise NotImplementedError
def p_from_z_two_sided(z: float) -> float:
raise NotImplementedError
def z_critical_two_sided(alpha: float) -> float:
"""The z whose two-sided tail probability is alpha, found by bisecting
`phi` -- there is no closed form for its inverse."""
raise NotImplementedError
def two_sample_z_test(a, b) -> tuple[float, float]:
"""Welch-style two-sample z-test: each sample keeps its own variance."""
raise NotImplementedError
def cohens_d(a, b) -> float:
"""A standardized effect size: the mean difference in pooled standard
deviations, so effect sizes are comparable across differently-scaled
outcome columns."""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 1 -- forking paths, measured
# ---------------------------------------------------------------------------
def simulate_forking_paths(rng: np.random.Generator, k: int, families: int,
n_per_group: int, alpha: float) -> dict[str, float]:
"""Run `families` independent replicates of "an analyst tries k
completely unrelated comparisons on data with no real signal", and
measure the fraction of replicates where at least one comparison comes
back significant. Return a dict with keys "k", "families",
"simulated_rate", "exact_rate", "standard_error", "deviation".
Vectorize it: draw two arrays of shape (families, k, n_per_group) with
`rng.standard_normal`, reduce along the last axis to get each
comparison's mean and variance, then its z-statistic.
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 2 -- a plausible story for noise
# ---------------------------------------------------------------------------
def scan_narrative_frame(df: pd.DataFrame, subset_cols: list[str],
outcome_cols: list[str]) -> list[dict[str, Any]]:
"""Every combination of a grouping column and an outcome column is one
candidate analysis. For each subset column, run it twice: once as the
raw boolean split, and once narrowed by `& df["signed_up_this_quarter"]`
(call the second cut "narrower_cut"). Return one dict per comparison
with keys "subset", "outcome", "cut", "z", "p", "effect_size",
"significant" (p < 0.05). Skip any split where either side has fewer
than 5 rows.
"""
raise NotImplementedError
def best_significant_result(results: list[dict[str, Any]]) -> dict[str, Any] | None:
"""The lowest p-value among the significant results, or None if none
of them were significant."""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 3 -- the holdout rescues you (the day's centrepiece)
# ---------------------------------------------------------------------------
def build_holdout_frame(rng: np.random.Generator, n_total: int, real_delta: float,
sigma: float, n_spurious: int) -> pd.DataFrame:
"""One dataset with exactly one real, planted effect (`real_metric`,
genuinely `real_delta` higher when `group` is True) and `n_spurious`
spurious columns named `spurious_0` .. `spurious_{n_spurious-1}` with
no true difference in any of them."""
raise NotImplementedError
def split_exploration_confirmation(df: pd.DataFrame, rng: np.random.Generator,
frac: float = 0.5) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Shuffle the frame (use `df.sample(frac=1.0, random_state=...)` seeded
from `rng`) and split it into an exploration half and a confirmation
half at `frac`."""
raise NotImplementedError
def test_column_by_group(df: pd.DataFrame, column: str) -> tuple[float, float]:
"""Two-sample z-test of `column`, split by the boolean `group` column."""
raise NotImplementedError
def best_spurious_column(exploration_df: pd.DataFrame, spurious_cols: list[str]) -> tuple[str, float, float]:
"""Test every spurious column against `group` on `exploration_df` only,
and return (column_name, z, p) for whichever one has the smallest p."""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 4 -- choices are comparisons
# ---------------------------------------------------------------------------
def build_choices_frame(rng: np.random.Generator, n_rows: int) -> pd.DataFrame:
"""Columns "days_since_signup" (integers 1-399), "sessions" (Poisson,
mean 5) and "metric" (standard normal, independent of everything)."""
raise NotImplementedError
def best_of_choice_grid(df: pd.DataFrame, subset_cutoffs: list[int],
outcome_definitions: list[str]) -> dict[str, Any]:
"""For every (cutoff, outcome definition) pair -- "metric" as-is, or
"metric_scaled" (metric * sessions) -- split on
`days_since_signup <= cutoff` and run a two-sample z-test. Return the
single dict with keys "cutoff", "definition", "z", "p" that has the
smallest p across the whole grid. Skip any split with fewer than 5 rows
on either side."""
raise NotImplementedError
def simulate_choice_grid_best_p_rate(rng: np.random.Generator, families: int,
n_rows: int, subset_cutoffs: list[int],
outcome_definitions: list[str], alpha: float) -> dict[str, float]:
"""Across `families` freshly generated no-signal datasets, measure how
often `best_of_choice_grid`'s winner is significant at `alpha`
("naive_best_rate"), versus how often ONE pre-declared comparison
(cutoff=subset_cutoffs[0], definition="metric") is significant
("single_declared_rate"). Return a dict with keys "n_variants",
"families", "naive_best_rate", "single_declared_rate"."""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 5 -- Bonferroni, and its limit
# ---------------------------------------------------------------------------
def bonferroni_alpha(alpha: float, m: int) -> float:
raise NotImplementedError
def simulate_family_wise_rate(rng: np.random.Generator, k: int, families: int,
alpha: float) -> float:
"""Draw `families` families of `k` independent standard-normal
z-statistics and return the fraction of families with at least one
exceeding the two-sided critical value for `alpha`."""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 6 -- the research log as a data structure
# ---------------------------------------------------------------------------
@dataclass
class LogEntry:
timestamp: str
question: str
look: str
outcome: str | None # None is a valid, recorded outcome: "nothing found"
@dataclass
class ResearchLog:
"""A dated record of every question asked, what was looked at, and what
was found -- including the nothings."""
entries: list[LogEntry] = field(default_factory=list)
def record(self, question: str, look: str, outcome: str | None,
timestamp: str | None = None) -> LogEntry:
"""Append a LogEntry (timestamp defaults to now, in UTC ISO format
if none is given) and return it."""
raise NotImplementedError
@property
def comparison_count(self) -> int:
raise NotImplementedError
@property
def null_count(self) -> int:
"""How many entries have outcome is None."""
raise NotImplementedError
def findings(self) -> list[LogEntry]:
"""Every entry whose outcome is NOT None."""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 7 -- triage
# ---------------------------------------------------------------------------
@dataclass
class Candidate:
name: str
expected_information: float # 0-1: how much this could change our belief
cost_hours: float # analyst-hours to answer
decision_relevance: float # 0-1: how much the answer could change a decision
def triage_score(candidate: Candidate) -> float:
"""expected_information * decision_relevance, divided by cost_hours."""
raise NotImplementedError
def rank_candidates(candidates: list[Candidate]) -> list[Candidate]:
"""Candidates sorted by `triage_score`, highest first."""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 8 -- a stopping rule
# ---------------------------------------------------------------------------
def time_boxed_false_positive_rate(rng: np.random.Generator, families: int,
budget_questions: int, alpha: float) -> float:
"""Across `families` sessions, each draws `budget_questions` standard-
normal z-statistics (no real signal). A session counts as a reported
false positive only if its LAST question (index -1, the one
pre-declared for reporting) exceeds the two-sided critical value.
Return the fraction of sessions that report one."""
raise NotImplementedError
def stop_when_significant_rate(rng: np.random.Generator, families: int,
max_questions: int, alpha: float) -> float:
"""Across `families` sessions, each draws up to `max_questions`
standard-normal z-statistics (no real signal). A session counts as a
reported false positive if ANY of its questions exceeds the two-sided
critical value. Return the fraction of sessions that report one."""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 9 -- the handoff to Day 133
# ---------------------------------------------------------------------------
REQUIRED_HANDOFF_FIELDS = ("finding", "confirmation_result", "comparison_count")
def build_handoff(finding: dict[str, Any], confirmation_result: dict[str, Any],
comparison_count: int) -> dict[str, Any]:
"""Assemble a dict with keys "finding", "confirmation_result",
"comparison_count", call `validate_handoff` on it, and return it."""
raise NotImplementedError
def validate_handoff(handoff: dict[str, Any]) -> None:
"""Raise ValueError naming every missing or None required field. Also
raise ValueError if comparison_count is not a positive integer."""
raise NotImplementedError
def write_report_stub(handoff: dict[str, Any]) -> str:
"""Call `validate_handoff` first (so this refuses an incomplete
handoff), then return a one-line summary string naming the finding,
its exploration and confirmation p-values, and the comparison count."""
raise NotImplementedError
starter/test_starter.py (10214 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 numpy as np
import pytest
import dataset as ds
import exploration as ex
def attempt(fn, what):
"""Call something that may not be written yet, and skip if it is not."""
try:
result = fn()
except 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 ds.ALPHA == 0.05
# --------------------------------------------------------------------------
# The z-test machinery
# --------------------------------------------------------------------------
def test_phi_of_zero_is_one_half():
result = attempt(lambda: ex.phi(0.0), "phi")
assert result == pytest.approx(0.5), f"phi(0.0) should be 0.5, got {result}"
def test_two_sample_z_test_matches_hand_computation():
a = [50, 52, 49, 51, 53, 48, 50, 52, 51, 49]
b = [54, 55, 53, 56, 54, 52, 55, 53, 54, 56]
z, p = attempt(lambda: ex.two_sample_z_test(a, b), "two_sample_z_test")
import math
import statistics
mean_a, var_a = statistics.mean(a), statistics.variance(a)
mean_b, var_b = statistics.mean(b), statistics.variance(b)
se = math.sqrt(var_a / len(a) + var_b / len(b))
z_hand = (mean_a - mean_b) / se
assert z == pytest.approx(z_hand, abs=1e-6), f"expected z={z_hand}, got {z}"
assert p < 0.001
def test_cohens_d_of_identical_samples_is_zero():
a = [1.0, 2.0, 3.0, 4.0, 5.0]
result = attempt(lambda: ex.cohens_d(a, a), "cohens_d")
assert result == pytest.approx(0.0), f"expected 0.0, got {result}"
# --------------------------------------------------------------------------
# Exercise 1 -- forking paths, measured
# --------------------------------------------------------------------------
def test_1_simulated_forking_paths_matches_exact():
rng = np.random.default_rng(3)
def run():
return ex.simulate_forking_paths(rng, 20, 1500, 200, 0.05)
result = attempt(run, "simulate_forking_paths")
dev, se = result["deviation"], result["standard_error"]
assert dev <= 3 * se, f"simulated {result['simulated_rate']} too far from exact {result['exact_rate']}"
# --------------------------------------------------------------------------
# Exercise 2 -- a plausible story for noise
# --------------------------------------------------------------------------
def test_2_scan_and_best_significant_result():
rng = np.random.default_rng(ds.NARRATIVE_SEED)
df = ds.build_narrative_frame(rng)
def run_scan():
return ex.scan_narrative_frame(df, ds.NARRATIVE_SUBSET_COLS, ds.NARRATIVE_OUTCOME_COLS)
results = attempt(run_scan, "scan_narrative_frame")
assert len(results) >= ds.NARRATIVE_MIN_COMPARISONS
def run_best():
return ex.best_significant_result(results)
best = attempt(run_best, "best_significant_result")
assert best is not None and best["p"] < 0.05
assert abs(best["effect_size"]) >= ds.PUBLISHABLE_EFFECT_SIZE
# --------------------------------------------------------------------------
# Exercise 3 -- the holdout rescues you
# --------------------------------------------------------------------------
def test_3_holdout_rescues_the_real_effect_only():
rng = np.random.default_rng(ds.HOLDOUT_SEED)
def run_build():
return ex.build_holdout_frame(rng, ds.HOLDOUT_N_TOTAL, ds.REAL_EFFECT_DELTA, ds.REAL_EFFECT_SIGMA, ds.N_SPURIOUS_CANDIDATES)
df = attempt(run_build, "build_holdout_frame")
def run_split():
return ex.split_exploration_confirmation(df, rng)
exploration_df, confirmation_df = attempt(run_split, "split_exploration_confirmation")
def run_real_exp():
return ex.test_column_by_group(exploration_df, "real_metric")
_, p_real_exp = attempt(run_real_exp, "test_column_by_group")
def run_real_conf():
return ex.test_column_by_group(confirmation_df, "real_metric")
_, p_real_conf = attempt(run_real_conf, "test_column_by_group")
assert p_real_exp < 0.05 and p_real_conf < 0.05
spurious_cols = [c for c in df.columns if c.startswith("spurious_")]
def run_best_spurious():
return ex.best_spurious_column(exploration_df, spurious_cols)
best_col, _, p_spur_exp = attempt(run_best_spurious, "best_spurious_column")
_, p_spur_conf = ex.test_column_by_group(confirmation_df, best_col)
assert p_spur_exp < 0.05
assert p_spur_conf >= 0.05
# --------------------------------------------------------------------------
# Exercise 4 -- choices are comparisons
# --------------------------------------------------------------------------
def test_4_choice_grid_inflates_significance():
rng = np.random.default_rng(ds.CHOICES_SEED)
def run():
return ex.simulate_choice_grid_best_p_rate(
rng, 1200, ds.CHOICES_N_ROWS, ds.CHOICES_SUBSET_CUTOFFS, ds.CHOICES_OUTCOME_DEFINITIONS, ds.ALPHA
)
result = attempt(run, "simulate_choice_grid_best_p_rate")
assert result["naive_best_rate"] > 3 * ds.ALPHA
assert abs(result["single_declared_rate"] - ds.ALPHA) < 0.03
# --------------------------------------------------------------------------
# Exercise 5 -- Bonferroni, and its limit
# --------------------------------------------------------------------------
def test_5_bonferroni_alpha_divides_by_m():
result = attempt(lambda: ex.bonferroni_alpha(0.05, 20), "bonferroni_alpha")
assert result == pytest.approx(0.0025), f"expected 0.05/20=0.0025, got {result}"
def test_5_bonferroni_fails_with_wrong_m():
rng = np.random.default_rng(12)
def run_alpha():
return ex.bonferroni_alpha(ds.ALPHA, ds.BONFERRONI_KNOWN_M)
corrected = attempt(run_alpha, "bonferroni_alpha")
def run_right():
return ex.simulate_family_wise_rate(rng, ds.BONFERRONI_KNOWN_M, 8000, corrected)
rate_right = attempt(run_right, "simulate_family_wise_rate")
def run_wrong():
return ex.simulate_family_wise_rate(rng, ds.BONFERRONI_TRUE_M, 8000, corrected)
rate_wrong = attempt(run_wrong, "simulate_family_wise_rate")
assert rate_wrong > rate_right
assert rate_wrong > 2 * ds.ALPHA
# --------------------------------------------------------------------------
# Exercise 6 -- the research log
# --------------------------------------------------------------------------
def test_6_research_log_records_everything():
def run():
log = ex.ResearchLog()
log.record("q1", "look1", None)
log.record("q2", "look2", "p=0.01")
return log
log = attempt(run, "ResearchLog.record")
assert log.comparison_count == 2, f"expected comparison_count=2, got {log.comparison_count}"
assert log.null_count == 1, f"expected null_count=1, got {log.null_count}"
assert len(log.findings()) == 1
# --------------------------------------------------------------------------
# Exercise 7 -- triage
# --------------------------------------------------------------------------
def test_7_triage_rewards_cheap_and_relevant():
cheap_relevant = ex.Candidate("a", expected_information=0.8, cost_hours=2, decision_relevance=0.9)
expensive_irrelevant = ex.Candidate("b", expected_information=0.9, cost_hours=40, decision_relevance=0.5)
def run():
return ex.triage_score(cheap_relevant), ex.triage_score(expensive_irrelevant)
score_a, score_b = attempt(run, "triage_score")
assert score_a > score_b, f"expected cheap+relevant to score higher, got {score_a} vs {score_b}"
def run_rank():
return ex.rank_candidates([expensive_irrelevant, cheap_relevant])
ranked = attempt(run_rank, "rank_candidates")
assert ranked[0].name == "a", f"expected 'a' ranked first, got {[c.name for c in ranked]}"
# --------------------------------------------------------------------------
# Exercise 8 -- a stopping rule
# --------------------------------------------------------------------------
def test_8_stopping_rules_diverge():
rng = np.random.default_rng(ds.STOPPING_SEED)
def run_tb():
return ex.time_boxed_false_positive_rate(rng, 15000, ds.STOPPING_BUDGET_QUESTIONS, ds.ALPHA)
tb_rate = attempt(run_tb, "time_boxed_false_positive_rate")
def run_sw():
return ex.stop_when_significant_rate(rng, 15000, ds.STOPPING_BUDGET_QUESTIONS, ds.ALPHA)
sw_rate = attempt(run_sw, "stop_when_significant_rate")
assert abs(tb_rate - ds.ALPHA) < 0.02, f"expected time-boxed rate near {ds.ALPHA}, got {tb_rate}"
assert sw_rate > 3 * ds.ALPHA, f"expected stop-when-significant rate well above {ds.ALPHA}, got {sw_rate}"
# --------------------------------------------------------------------------
# Exercise 9 -- the handoff to Day 133
# --------------------------------------------------------------------------
def test_9_handoff_accepts_complete_and_refuses_incomplete():
def run_build():
return ex.build_handoff({"name": "x", "p": 0.01}, {"p": 0.02}, 10)
handoff = attempt(run_build, "build_handoff")
assert handoff["comparison_count"] == 10
def run_validate_should_raise():
try:
ex.validate_handoff({"finding": {"name": "x"}})
except ValueError:
return "raised"
except NotImplementedError:
raise
return None
outcome = attempt(run_validate_should_raise, "validate_handoff")
assert outcome == "raised", "validate_handoff should raise ValueError on a handoff missing fields"
def run_report():
return ex.write_report_stub(handoff)
text = attempt(run_report, "write_report_stub")
assert "10" in text, f"expected the comparison count in the report text, got: {text}"
tests/run_tests.sh (11259 bytes)
#!/usr/bin/env bash
# Tests for the Day 136 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# The harness proves the day's claims by running code and reading real
# values, never by reading source:
#
# * twenty independent alpha=0.05 comparisons on data with NO real signal
# give a 64.15% chance of at least one false positive -- exact
# arithmetic, confirmed by simulation, for k=5, 20 and 40;
# * one concrete 40-comparison scan of a signal-free dataset really does
# turn up a "winning" comparison whose effect size looks publishable;
# * a real, planted effect survives an untouched confirmation set, and a
# spurious column chosen for looking best among thirty candidates does
# not -- the day's centrepiece, both p-values on both splits;
# * varying a subset filter and an outcome definition with no test
# declared per variant inflates the apparent significance rate several
# times over one pre-declared comparison;
# * Bonferroni restores the nominal family-wise rate when the comparison
# count is known and correct, and fails when the true count exceeds
# the reported one;
# * a research log's own length is the true comparison count, and every
# entry carries a timestamp, a look and an outcome -- including nulls;
# * a triage score ranks a cheap, decision-relevant question above an
# expensive, less relevant one;
# * a time-boxed stopping rule's false-positive rate sits near the
# nominal alpha, and "stop when significant" inflates it several
# times over, on the exact same budget of looks;
# * the object handed to a report stage is refused unless it carries a
# finding, a confirmation-set result and a comparison count;
# * the reference suite (`examples/`) passes in full;
# * the exercise suite (`starter/`) is all-skip on an untouched checkout,
# and the harness proves it can genuinely FAIL by solving every
# exercise in a scratch copy, breaking one assertion on purpose,
# confirming a non-zero exit and a printed failure, then restoring it;
# * no file is left behind anywhere, and no lab source opens a network
# connection.
#
# Everything after the one-time install runs offline. Nothing binds a port,
# 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)"
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.
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, pandas" >/dev/null 2>&1; then
echo "FAIL: numpy and/or pandas 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 136 — The Exploratory Data Analysis Process"
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", "pandas", "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/^/ /'
for pkg in numpy pandas pytest; do
pinned="$(grep -E "^${pkg}==" "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
installed="$("${python_bin}" -c "from importlib.metadata import version; print(version('${pkg}'))")"
check_eq "installed ${pkg} matches requirements.txt" "${pinned}" "${installed}"
done
# --------------------------------------------------------------------------
echo
echo "2. Every reference script runs and every assertion inside it holds"
# --------------------------------------------------------------------------
for script in 01_forking_paths 02_plausible_story 03_holdout_rescues_you \
04_choices_are_comparisons 05_bonferroni_and_its_limit \
06_research_log 07_triage 08_stopping_rule 09_handoff_contract; 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
*"OK:"*)
check "${script}.py reports OK" "yes" ;;
*) check "${script}.py reports OK" "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 24 ]; then
check "the reference suite ran at least 24 tests (ran ${ref_passed})" "yes"
else
check "the reference suite ran at least 24 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 `exploration`
# and `dataset`; each directory's conftest.py prevents a cross-import. This
# check proves it still does: auto-discovering both directories from the
# lab root must report the same skip count as `pytest starter` alone.
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 "auto-discovering both suites 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 01 (forking paths) with its expected exact rate
# for k=20 deliberately swapped for a wrong one, and asserts that the
# re-run reports the failure and exits non-zero.
if [ -z "${D136_SELF_TEST:-}" ]; then
self_out="$(cd "${lab_dir}/examples" && D136_SELF_TEST=1 "${python_bin}" -c "
src = open('01_forking_paths.py').read()
src = src.replace(\"expected_exact = {5: 0.2262, 20: 0.6415, 40: 0.8715}[k]\", \"expected_exact = {5: 0.2262, 20: 99.0, 40: 0.8715}[k]\")
g = {'__name__': '__main__', '__file__': '01_forking_paths.py'}
exec(compile(src, '01_forking_paths.py', 'exec'), g)
" 2>&1)"
self_status=$?
if [ "${self_status}" -ne 0 ]; then
check "a deliberately wrong expectation makes script 01 exit non-zero (${self_status})" "yes"
else
check "a deliberately wrong expectation makes script 01 exit non-zero" "no"
fi
case "${self_out}" in
*"AssertionError"*"0.6415"*)
check "the failing assertion is named in the output with the real value" "yes" ;;
*) check "the failing assertion is named in the output with the real value" "no" ;;
esac
else
echo " (self-test run: section 5 does not recurse)"
fi
# --------------------------------------------------------------------------
echo
echo "6. Nothing was left behind"
# --------------------------------------------------------------------------
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
ModuleNotFoundError: No module named 'numpy' (or pandas)
You are running the system python3, not the lab's virtual environment.
Every command in this lab is prefixed .venv/bin/python3 or
.venv/bin/pytest on purpose. If you have not created the environment yet:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
Running a script directly with python3 01_forking_paths.py fails to import dataset or exploration
Run it from inside examples/ (or starter/), not from the lab root:
cd examples
../.venv/bin/python3 01_forking_paths.py
Each script imports dataset and exploration from beside itself.
pytest starter -q keeps reporting a test as skipped
A skip means the function it calls still raises NotImplementedError (or
returns None). Open starter/exploration.py, find the function named in
the skip message, and write it. 00_brief.md describes exactly what each
one should do and return.
pytest starter -q reports a FAILURE, not a skip
Good news first: a failure means you wrote something, and it ran. The
message shows your value next to the expected one -- read both before
changing anything. A common cause: two_sample_z_test returning
(p, z) instead of (z, p) (the order matters, and several later
exercises depend on it being right).
simulate_forking_paths runs but the assertion fails intermittently
If you changed n_per_group down from the default of 200, this is
expected, not a bug in your code. The two-sample z-test uses a z
critical value, which assumes the population variance is known; with an
estimated variance at small n, the true rejection rate under the null
drifts slightly above nominal (the same effect Day 118 measured for
confidence-interval coverage). This lab picked n_per_group=200
specifically to keep that drift small enough that three standard errors
of simulation noise comfortably covers it -- confirmed across seeds
1, 7, 42, 118 and 2026 during development (see metadata.yml). If you
want to see the drift directly, try n_per_group=10 and watch the
simulated rate creep above the exact value by more than three standard
errors.
The "winning" comparison in exercise 2 doesn't clear the effect-size threshold
This is seed-dependent. dataset.py pins NARRATIVE_SEED=6 and
NARRATIVE_N_ROWS=80 specifically because that combination reliably
produces a winning comparison with |d| >= 0.5 (see metadata.yml's
third honesty note for the numbers behind that choice). If you experiment
with other seeds or a larger NARRATIVE_N_ROWS, expect this assertion to
need a lower threshold, or a seed sweep of your own, exactly as this lab
required.
The choice-grid or stopping-rule scripts feel slow
04_choices_are_comparisons.py runs 3,000 freshly generated datasets
through a 10-cell grid; 08_stopping_rule.py runs two 20,000-replicate
simulations. Both finish in a few seconds on ordinary hardware (measured:
under 4 seconds total for both, on the authoring machine). If either
takes noticeably longer, check you are not accidentally running under the
system python3 without NumPy's compiled backend, or on a machine under
heavy load from something else.
pytest examples starter (both directories as separate arguments)
Not documented and not tested by this lab, following the same convention
Day 118 established: examples/ and starter/ both define modules named
dataset and exploration, and combining both directories in one
invocation was found unreliable there (a false green in one case, a
collection abort in another) depending on pytest's rootdir and import-mode
resolution. This lab documents and tests only pytest examples,
pytest starter, and bare pytest with no path (which auto-discovers
both directories and was confirmed here to isolate correctly via each
directory's conftest.py).
find reports leftover __pycache__ or .pytest_cache after a run
Running .venv/bin/pytest starter -q by itself (outside run_tests.sh)
legitimately writes bytecode caches. This is expected and harmless; clean
up with the commands in "Cleanup" in README.md. run_tests.sh clears
them before it starts, so its own final check measures only what that run
left behind.
Security notes
Security notes
This lab computes and prints. It does not write files outside itself, does
not open a network connection after the one-time dependency install, needs
no credentials, no API key, and no sudo. Every dataset used anywhere in
it is generated in-process from a seeded random number generator; nothing
is downloaded, and nothing here touches a real customer, user, or any
other real person's data.
What each script actually does
- Reads: its own source,
dataset.py,exploration.py, and (for the virtual environment)requirements/requirements.txt. - Writes: nothing to disk by itself.
pytest's own bytecode caches (__pycache__,.pytest_cache) are the only files any command in this lab creates, and the test harness proves at the end of every run that none are left behind. - Network: only
pip install -r requirements/requirements.txt, once, to populate.venv.tests/run_tests.shgreps every file inexamples/andstarter/forurlopen,requests.,socket.,http://andhttps://and fails the run if any is found. - Randomness: every simulation is seeded (
numpy.random.default_rngwith an explicit integer), so every number in this lab is reproducible on the same package versions, and no result depends on system entropy.
Three findings worth carrying into real work
- A p-value answers a narrower question than most people act on it as
answering. It is
P(data this extreme | null true), notP(null true | data)-- exercise 1 makes the gap between those two concrete: data with genuinely no signal in it produces a "significant" result most of the time once you look at enough of it. - "We stopped when we found something" is not a stopping rule -- it is the mechanism that inflates false positives, exercise 8's centrepiece. Deciding in advance how many questions you will ask, and asking them regardless of what turns up, is what a stopping rule has to do to be worth calling one.
- A correction needs an honest count to correct, and real exploration rarely produces one without deliberate effort. Bonferroni is exact arithmetic; exercise 5 shows it working exactly as advertised when the comparison count is right, and failing by a wide margin when it is not. The research log (exercise 6) is not a compliance exercise -- it is the only thing standing between "I only ran one test" and a number anyone can check.
If you adapt this lab to real data
The holdout mechanics (exercise 3) generalize directly: split before looking, keep the confirmation half untouched until a hypothesis is chosen, and treat the confirmation-set p-value, not the exploration-set one, as the number that matters. If the real data includes anything personally identifying, that is a separate concern this lab does not address -- de-identification, access control, and retention policy are outside its scope, and none of that machinery lives here.