Machine Learning › Machine Learning Fundamentals › Day 144
Hands-on lab — Day 144: Train, Validation, and Test Splits
- ← Back to the Day 144 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/machine-learning/day-144-train-validation-and-test-splits/
Commands
Setup
cd labs/sections/machine-learning/day-144-train-validation-and-test-splits
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy, sklearn; print(numpy.__version__, sklearn.__version__)" Run
.venv/bin/pytest examples -q
.venv/bin/pytest starter -q
.venv/bin/python3 examples/report_measurements.py Test
bash tests/run_tests.sh File tree
examples/report_measurements.py examples/splits_lib.py examples/test_splits_claims.py examples/test_splits_lib.py expected-output/examples-run.txt expected-output/FIELDS.md expected-output/measured-values.txt 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/splits_lib.py starter/test_splits_claims.py starter/test_splits_lib.py tests/run_tests.sh troubleshooting.md
Lab README
Day 144 lab — Three Sets, and Why
Lesson
- Lesson title: Train, Validation, and Test Splits
- Day number: 144 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-144-train-validation-and-test-splits
- 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-144-train-validation-and-test-splitswhen the site is running.
Purpose
Everybody knows you hold out a test set. Rather fewer people can say why there are supposed to be three sets rather than two, and almost nobody has seen the number that justifies the third one.
This lab measures it, and then measures the four ways a split goes wrong.
The headline experiment uses candidates with exactly zero skill — each one is a coin flip, a fixed vector of random predictions. Score them on a validation set, keep whichever wins, and look at what it does on a test set it never influenced:
| candidates considered | best validation | its test score | optimism |
|---|---|---|---|
| 1 | 0.4984 | 0.5011 | −0.0028 |
| 10 | 0.5331 | 0.4999 | +0.0332 |
| 100 | 0.5567 | 0.4992 | +0.0575 |
| 1000 | 0.5720 | 0.4992 | +0.0728 |
Read the test column first: it never moves. It sits at chance for every K, because it was never selected on. That is the control, and it is what makes the validation column mean something.
Now read the validation column. It climbs to 0.5720 on coin flips. Try a thousand things and the best will look seven points better than chance, whether or not any of them is any good.
Then four ways a split goes wrong, each measured:
| The mistake | What it cost, here |
|---|---|
| not stratifying a rare class | 21 of 500 random splits had a test half with no positives at all |
| splitting rows when the unit is a person | +0.5648 — 0.9760 against 0.4112 |
| shuffling data with a direction in time | +0.0728 on average; shuffling won 20 of 20 times |
| reading a trend off one holdout | one holdout swung 0.19 across seeds; 5-fold swung 0.0325 |
Learning objectives
By the end of this lab you will be able to:
- Explain why a validation set and a test set are different objects, in terms of a measured quantity rather than a convention.
- Quantify selection optimism as a function of how many candidates were considered, and identify the control that makes the measurement valid.
- Connect that optimism to the expected maximum of K noise draws, and report where the standard closed-form approximation fails.
- Demonstrate that a random split of a rare class sometimes produces a test set on which recall is undefined.
- Identify when the row is not the unit of independence, and measure what ignoring that costs.
- Split data that has a direction in time correctly, and report the effect's size honestly when it varies between datasets.
- Choose between a single holdout and k-fold cross-validation using the measured spread of each.
- Size a test set before splitting, from the smallest difference you need to detect.
- Enforce a one-evaluation budget on a test set mechanically.
Prerequisites
- Day 141 for what a score means, Day 142 for the winner's curse — which reappears here as selection bias — and Day 143 for stage ordering.
- Days 117-118 for the standard error and the sampling distribution. This lab is largely that arithmetic applied to evaluation.
- Day 136 for the forking-paths problem, which is what exercise 1 measures and what exercise 4's reporting decision avoids committing.
- Comfort with NumPy arrays and reading a pytest failure, and
python33.11 or newer on yourPATH.
Supported operating systems
- macOS (Apple Silicon or Intel) — the capture machine was macOS 26.5.2 on arm64.
- Linux (any distribution with Python 3.11+ and bash).
- Windows via WSL2. The harness is a bash script and uses
mktemp -d,findand process substitution; native PowerShell is not supported.
Hardware requirements
Any machine that can run Python. No GPU is needed or used — everything here is small-array NumPy and scikit-learn on the CPU. The heaviest steps are 400 selection replications and 200 cross-validation repeats, which complete in well under a minute on the capture machine. Around 400 MB of disk for the virtual environment, almost all of it scikit-learn and scipy.
Required software
- Python 3.11 or newer (3.14.0 during capture).
- bash 3.2 or newer (3.2.57 during capture — the macOS system bash).
- The three pinned packages in
requirements/requirements.txt:numpy==2.5.2,scikit-learn==1.9.0,pytest==9.1.1.
find, grep, awk, sed, diff and mktemp are used by the harness
and ship with every supported system.
Free and open-source options
Everything here is free and open source, and there is no paid tier anywhere in this lab.
- NumPy and scikit-learn are BSD 3-Clause licensed.
- pytest is MIT licensed.
- No dataset is downloaded or bundled: every dataset is generated on the spot from a seeded generator, so no dataset licence applies to your use of this lab.
The splitters used here — train_test_split,
StratifiedShuffleSplit, GroupShuffleSplit, StratifiedKFold — are all
part of scikit-learn. TimeSeriesSplit covers the chronological case at
project scale and is discussed in the lesson.
Installation
From the repository root:
cd labs/sections/machine-learning/day-144-train-validation-and-test-splits
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy, sklearn; print(numpy.__version__, sklearn.__version__)"
That last line should print 2.5.2 1.9.0. The install step is the only
part of this lab that needs the network, and it installs into a
lab-local environment — never into your system Python. rm -rf .venv
reverses it completely.
File structure
day-144-train-validation-and-test-splits/
├── README.md this file
├── metadata.yml how the lab was actually executed
├── security.md what the lab touches, and what it does not
├── troubleshooting.md every failure this lab is known to produce
├── requirements/
│ ├── README.md why the pins are exact
│ └── requirements.txt numpy, scikit-learn, pytest
├── starter/
│ ├── 00_brief.md read this first
│ ├── splits_lib.py complete machinery — not the exercise
│ ├── test_splits_lib.py four machinery checks, already solved
│ └── test_splits_claims.py fourteen exercises, each a skip to replace
├── examples/
│ ├── splits_lib.py identical to the starter copy
│ ├── test_splits_lib.py the same four machinery checks
│ ├── test_splits_claims.py the reference solutions
│ └── report_measurements.py prints every measured pair as one table
├── expected-output/
│ ├── FIELDS.md what is exact everywhere, and what is not
│ ├── measured-values.txt the captured report, compared byte for byte
│ ├── examples-run.txt captured `pytest examples -q`
│ ├── starter-run.txt captured `pytest starter -q`
│ └── test-run.txt captured `bash tests/run_tests.sh`
└── tests/
└── run_tests.sh the harness — the definition of done
starter/splits_lib.py and examples/splits_lib.py are byte identical on
purpose. The library is machinery; the exercises are the work.
How to run
## the exercises, as you will find them
.venv/bin/pytest starter -q
## the reference solutions
.venv/bin/pytest examples -q
## every measured pair, as one table
.venv/bin/python3 examples/report_measurements.py
## the harness: the only definition of done
bash tests/run_tests.sh
echo "exit=$?"
Run starter and examples as two separate invocations. Both
directories define modules with the same names, and pytest aborts on the
collision with import file mismatch. Check 5 of the harness asserts that
it does, so the behaviour is documented rather than surprising.
Capture the exit status of run_tests.sh itself, as shown. Writing
bash tests/run_tests.sh | tail -3 and then reading $? gives you
tail's exit status, which is essentially always zero — the classic
always-passing test suite.
What the commands do
| Command | What it does |
|---|---|
python3 -m venv .venv |
Creates a lab-local environment so nothing installs into your system Python |
.venv/bin/pip install -r requirements/requirements.txt |
Installs the three pinned packages, plus scipy, joblib and threadpoolctl as scikit-learn's own dependencies |
.venv/bin/pytest starter -q |
Runs your work: four machinery checks pass, fourteen exercises skip until you write them |
.venv/bin/pytest examples -q |
Runs the reference solutions — eighteen assertions about how splits behave |
.venv/bin/python3 examples/report_measurements.py |
Recomputes every published number and prints them as one table |
bash tests/run_tests.sh |
Fourteen checks: version pins, every claim reproduced without pytest, both suites, the collision, a byte-comparison of the report, a deliberate self-break, three directions re-confirmed at unquoted seeds, and cleanliness |
Expected output
bash tests/run_tests.sh ends with:
---------------------------------------------------------------
14 checks, 0 failure(s)
and exits 0. pytest examples -q reports 18 passed.
pytest starter -q reports 4 passed, 14 skipped until you start work.
The complete captured runs are in expected-output/. The measurement
table is compared byte for byte by check 6, so if a number in the lesson
ever drifts from the code, the harness fails rather than the lesson
quietly becoming wrong.
Read expected-output/FIELDS.md before concluding that a mismatch on your
machine is a bug. It separates what is exact everywhere — the
standard-error formula, all 50 people appearing in both halves, every
direction — from what holds only under the pinned versions, which is most
of the decimals.
Validation steps
bash tests/run_tests.sh; echo "exit=$?"→14 checks, 0 failure(s)andexit=0..venv/bin/pytest examples -q→18 passed..venv/bin/pytest starter -q→4 passed, 14 skippedbefore you start;18 passedwhen you have finished every exercise..venv/bin/python3 examples/report_measurements.py | diff - expected-output/measured-values.txt→ no output.- Break one assertion in
examples/test_splits_claims.pyon purpose, re-run the harness, and confirm it reports failures and exits non-zero. Restore it. A test suite you have never seen fail is not evidence.
Tests
tests/run_tests.sh is a bash assert harness. It prints one ok: or
FAIL: line per check, ends with N checks, M failure(s), and exits
non-zero when M is not zero.
The fourteen checks are:
1-3. The installed numpy, scikit-learn and pytest match the pins exactly.
4. Every published claim reproduced directly against splits_lib, with no
pytest involved — so a broken test file cannot hide a broken library,
and vice versa.
5. pytest examples -q reports 18 passed.
6. pytest starter -q reports 4 passed, 14 skipped.
7. The combined pytest examples starter invocation aborts, as
documented.
8. report_measurements.py output is byte-identical to the captured
table.
9-10. A scratch copy of examples/ passes, then fails with a non-zero
exit and the failing test named after one assertion is deliberately
rewritten.
11. Group leakage, selection optimism and stratification are re-confirmed
at seeds and replication counts the lesson never quotes, so no
directional claim rests on a single lucky seed.
12-14. No URL appears in any source file; no __pycache__ and no
.pytest_cache are left behind.
Caches are cleared at the start of the run as well as the end, so check 13 measures what that run left rather than what a previous manual pytest invocation left.
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: reset your work
The harness already removes its own scratch directory. Nothing else is created outside this directory, so those four commands return your machine to exactly the state it was in.
Troubleshooting
See troubleshooting.md, which covers the missing virtual environment,
the import file mismatch collision, the harness taking a while on slower
machines, a group-aware score below chance, temporal numbers that differ
from the lesson's, sqrt(2 ln K) not matching the measurement, sampled
figures moving with the NumPy pin, and LogisticRegression convergence
warnings.
Security notes
See security.md. In short: no network after the install, no credentials,
no sudo, no write outside this directory except a mktemp -d scratch
directory the harness removes in the same run, and everything reversible
with rm -rf .venv. It also reads GatedTestSet as an access-control
pattern — a one-time budget enforced by the resource itself, whose counter
deliberately does not advance on a refused attempt.
Extension exercises
- Nested cross-validation. Exercise 1 measures the optimism from selecting on a validation set. Implement nested cross-validation — an inner loop that selects, an outer loop that scores — and measure whether the optimism disappears. Report what it costs in fits.
- Find the break-even K. At what number of candidates does the selection optimism exceed the true difference you are trying to detect? Compute it for a validation set of 500 rows and a real difference of two accuracy points, and say what that implies about hyper-parameter sweeps.
- Bigger validation set. Repeat exercise 1 with 2000-row validation sets instead of 500. Confirm the optimism scales with the standard error rather than staying fixed, and report the ratio.
- Repeated k-fold. Exercise 5 compares one holdout against 5-fold. Add repeated 5-fold with ten repetitions and measure how much further the spread narrows, and what it costs in fits.
- Leave-one-group-out. Replace
GroupShuffleSplitin exercise 3 withLeaveOneGroupOutand report both the mean and the spread across the fifty people. Say which is the more honest number to publish. - Make the temporal effect large on purpose. Exercise 4's effect varies by a factor of sixteen. Find what property of a construction makes it large — the number of regimes, their length, how different consecutive rules are — and report a rule of thumb for when a chronological split matters most.
- A stricter gate. Extend
GatedTestSetto log every attempted evaluation with a caller identifier, so a refused attempt leaves a trace. Then argue, in two sentences, whether a gate that logs is more or less useful than one that simply refuses.
Navigation
- Lab brief:
starter/00_brief.md - Previous lab:
../day-143-the-machine-learning-workflow/ - Next lab:
../day-145-overfitting-and-underfitting/ - Week 21 project:
../projects/week-21/
Expected output
FIELDS.md
# What is exact, what may differ, and why
Everything in this directory is captured from a real run on the authoring
machine on 2026-08-27: macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0,
in this lab's own `.venv` built from `requirements/requirements.txt` —
numpy 2.5.2, scikit-learn 1.9.0, pytest 9.1.1, with scipy 1.18.1,
joblib 1.5.3 and threadpoolctl 3.6.0 pulled in as scikit-learn's own
dependencies.
## Exact on any machine, for any reason
These are arithmetic or structural facts, not measurements that happened
to come out a certain way. Check 8 of the harness confirms the directional
ones at seeds the lesson does not quote.
- **The theoretical standard errors in exercise 6.** `sqrt(p(1-p)/n)` is a
formula. `0.0505`, `0.0357`, `0.0252`, `0.0160`, `0.0113` and `0.0050`
are what it evaluates to, on any machine, forever. So are the derived
`1225` and `4899` row counts.
- **Quadrupling the test set halves the standard error.** The ratio at
n=200 against n=50 is exactly 0.5, because the formula goes as one over
root n. Not an observation.
- **`50 of 50` people appearing in both halves of a row-wise split.** With
twenty rows per person and a random quarter held out, the chance of any
one person having all twenty rows land on the same side is about two in
a hundred thousand. This is a near-certainty, not a coincidence, and the
harness asserts it.
- **The test column staying at chance in exercise 1.** The test scores are
never selected on, so their expectation is exactly 0.5 for every K. The
measured values wander within about 0.003 of it; the *absence of a
trend* is the structural fact.
- **The validation column increasing in K.** The maximum of a larger
sample of draws from the same distribution is stochastically larger.
This holds for any distribution and any K.
- **`sqrt(2 ln K)` exceeding the simulated expected maximum** at every K
tried. The asymptotic is an upper bound that is loose at finite K, and
the lab asserts the inequality rather than a gap size.
- **`TestSetTouchedTwice` on the second evaluation**, and the counter not
advancing on a refused attempt. Branching logic.
- **Group leakage inflating the score, and stratification narrowing the
spread.** The directions, not the sizes. Harness check 8 re-runs both at
several dataset seeds.
- **The shuffled split beating the chronological one in all 20 temporal
constructions.** See the honesty note below about the size.
## Exact under these pins, and only these
Everything else depends on NumPy's `default_rng` bit stream and on
scikit-learn's estimator internals. **NumPy's own documentation states
that `Generator` carries no stream-compatibility guarantee across
versions**, so seeding makes these reproducible under the pins in
`requirements/requirements.txt` and not beyond them.
| Value | Exercise | What it is |
| --- | --- | --- |
| the nine rows of the selection-bias curve | 1 | mean selected-validation and test scores at each K |
| `2.57` standard errors at K=100, and `2.50` simulated | 1c | the measured optimism against the expected maximum |
| `{'mean': 0.0504, 'sd': 0.0265, 'min': 0.0, 'max': 0.16}` | 2 | random-split positive rates |
| `{'mean': 0.05, 'sd': 0.01, 'min': 0.04, 'max': 0.06}` | 2 | stratified-split positive rates |
| `21` of 500 | 2 | random splits whose test half held no positives |
| `0.9760`, `0.4112`, `+0.5648` | 3 | row-wise against group-aware |
| `0.5961`, `0.5233`, `0.5235` | 4 | shuffled, chronological and baseline means |
| `0.0728`, `0.0596`, `0.016`, `0.2557` | 4 | the temporal inflation distribution |
| `{'mean': 0.7519, 'sd': 0.0381, 'min': 0.66, 'max': 0.85}` | 5 | single-holdout spread |
| `{'mean': 0.7546, 'sd': 0.0061, 'min': 0.7375, 'max': 0.77}` | 5 | 5-fold spread |
| `6.2344` | 5 | how much steadier cross-validation is |
| the measured column of the test-size table | 6 | 20000 binomial draws at each n |
| `0.7575` | 7 | the gated test set's one permitted evaluation |
## Sampled, and therefore soft even here
- **The selection-bias curve is averaged over 400 replications** and the
temporal comparison over 20 constructions, for the reason Days 117-118
established: one draw of a noisy quantity is an anecdote. A single
replication of exercise 1 at K=1000 produced anything from +0.02 to
+0.12 while this lab was being built.
- **The temporal effect in exercise 4 varies by a factor of sixteen across
constructions** — from +0.016 to +0.2557. This is the honesty call that
matters most in this lab. The first construction tried gave +0.1428, and
quoting it alone would have been the forking-paths problem inside a
lesson against exactly that. The lab reports the mean, the standard
deviation, the minimum and the maximum, and asserts the one thing that
held every time: the direction.
- **`0.4112` in exercise 3 is below chance** and is not evidence of
anti-learning. Twelve or thirteen people land in the group-aware test
half, each contributing twenty identical labels, so the estimate is
built from about a dozen independent coin flips. It wanders. The
structural claim the lab asserts is that it sits below 0.5 while the
row-wise score sits far above it.
- **`0.0505` and `0.0505` agreeing exactly in exercise 6** is a happy
rounding, not a guarantee. The lab asserts agreement to within 0.0002,
which is what 20000 draws supports.
## Timings
No timing is asserted anywhere in this lab. The heaviest steps are the 400
selection replications and the 200 cross-validation repeats, which take a
few seconds here and will take longer elsewhere without changing a single
assertion, because every assertion is about a shape or a value.
examples-run.txt
.................. [100%]
18 passed in 8.12s
measured-values.txt
Day 144 -- train, validation and test splits, measured
=====================================================
1. Why three sets: selecting on a set is fitting to it
------------------------------------------------------
K candidates, each a coin flip, 500-row validation and 500-row test
averaged over 400 replications; standard error of an accuracy is 0.0224
K best-val its-test optimism in SEs
1 0.4984 0.5011 -0.0028 -0.13
2 0.5115 0.4992 +0.0123 0.55
5 0.5256 0.5005 +0.0251 1.12
10 0.5331 0.4999 +0.0332 1.48
25 0.5436 0.5009 +0.0427 1.91
50 0.5508 0.5014 +0.0493 2.20
100 0.5567 0.4992 +0.0575 2.57
500 0.5682 0.4978 +0.0704 3.15
1000 0.5720 0.4992 +0.0728 3.26
the test column never moves: it was never selected on
1b. The optimism is the expected maximum of K noise draws
---------------------------------------------------------
K measured E max of K normals sqrt(2 ln K)
2 0.55 0.55 1.18
5 1.12 1.16 1.79
10 1.48 1.54 2.15
25 1.91 1.97 2.54
50 2.20 2.25 2.80
100 2.57 2.50 3.03
500 3.15 3.03 3.53
1000 3.26 3.24 3.72
the closed-form approximation sits above the truth at every K here
2. Stratification: a rare class and a small test set
----------------------------------------------------
population positive rate : 0.0500
random split : {'mean': 0.0504, 'sd': 0.0265, 'min': 0.0, 'max': 0.16}
stratified split : {'mean': 0.05, 'sd': 0.01, 'min': 0.04, 'max': 0.06}
random splits whose test half held ZERO positives: 21 of 500
3. Groups: when the row is not the unit
---------------------------------------
50 people, 20 rows each; each person's label is a coin flip
row-wise random split : 0.9760
group-aware split : 0.4112
accuracy invented : +0.5648
people appearing in BOTH halves of a row-wise split: 50 of 50
4. Time: when the data has a direction
--------------------------------------
20 independently generated series, six regimes each
shuffled split, mean : 0.5961
chronological split, mean : 0.5233
majority baseline, mean : 0.5235
inflation: mean 0.0728 sd 0.0596 min +0.0160 max +0.2557
constructions where shuffling won: 20 of 20
the direction is universal; the size varies by a factor of sixteen
5. One holdout, or many folds
-----------------------------
same data, same model; only which rows landed where changes
single holdout : {'mean': 0.7519, 'sd': 0.0381, 'min': 0.66, 'max': 0.85}
5-fold : {'mean': 0.7546, 'sd': 0.0061, 'min': 0.7375, 'max': 0.77}
holdout swings 0.1900 across 200 seeds
cross-validation is 6.2344 times steadier
6. How big does the test set need to be?
----------------------------------------
n theory SE measured sd 95 percent half-width
50 0.0505 0.0505 +/-0.0990
100 0.0357 0.0357 +/-0.0700
200 0.0252 0.0254 +/-0.0495
500 0.0160 0.0160 +/-0.0313
1000 0.0113 0.0112 +/-0.0221
5000 0.0050 0.0051 +/-0.0099
rows needed for +/-0.02 at an accuracy of 0.85 : 1225
rows needed for +/-0.01 at an accuracy of 0.85 : 4899
7. The rule, made mechanical
----------------------------
first evaluation : 0.7575
second evaluation : TestSetTouchedTwice
the test set has already been used once; any further score is a validation score, not a test score
starter-run.txt
ssssssssssssss.... [100%]
4 passed, 14 skipped in 0.53s
test-run.txt
1. Installed versions match requirements/requirements.txt
numpy 2.5.2
scikit-learn 1.9.0
pytest 9.1.1
ok: numpy 2.5.2 matches the pin
ok: scikit-learn 1.9.0 matches the pin
ok: pytest 9.1.1 matches the pin
2. Every published claim, reproduced directly (no pytest involved)
ok: exercises 1-7 reproduced directly against splits_lib, no pytest involved
3. examples/ passes in full
ok: pytest examples -q -> 18 passed
4. starter/ is an untouched skeleton
ok: pytest starter -q -> 4 passed, 14 skipped (the machinery checks pass; the fourteen exercises are stubs)
5. pytest examples starter (one invocation) aborts on the module-name collision
ok: combined invocation reports import file mismatch, as documented -- never run starter and examples together
6. The report reproduces the captured table exactly
ok: report_measurements.py output is byte-identical to expected-output/measured-values.txt
7. Proof the harness can fail
ok: scratch copy of examples/ passes before it is broken
ok: breaking exercise 3's assertion produces a non-zero exit and names the failing test
8. The direction of every split result holds beyond the quoted seed
ok: group leakage, selection optimism and stratification hold at seeds the lesson does not quote
9. Offline, and nothing left behind
ok: no URLs inside examples/ or starter/ source -- this lab reaches no network
ok: no __pycache__ left behind (cleaned during this run)
ok: no .pytest_cache left behind (cleaned during this run)
---------------------------------------------------------------
14 checks, 0 failure(s)
Source files
examples/report_measurements.py (5114 bytes)
#!/usr/bin/env python3
"""Print every measured pair in this lab as one table.
The harness compares this output byte for byte against
expected-output/measured-values.txt, so the report is not a convenience:
it is how the lab notices that a number in the lesson has gone stale.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import numpy as np # noqa: E402
from sklearn.linear_model import LogisticRegression # noqa: E402
import splits_lib as s # noqa: E402
def rule(title: str) -> None:
print()
print(title)
print("-" * len(title))
def main() -> None:
print("Day 144 -- train, validation and test splits, measured")
print("=" * 53)
rule("1. Why three sets: selecting on a set is fitting to it")
standard_error = s.proportion_standard_error(0.5, 500)
print(" K candidates, each a coin flip, 500-row validation and 500-row test")
print(f" averaged over 400 replications; standard error of an accuracy is {standard_error:.4f}")
print(" K best-val its-test optimism in SEs")
rows = s.selection_bias_curve([1, 2, 5, 10, 25, 50, 100, 500, 1000])
for k, validation, test, optimism in rows:
print(f" {k:5d} {validation:.4f} {test:.4f} {optimism:+.4f} {optimism / standard_error:5.2f}")
print(" the test column never moves: it was never selected on")
rule("1b. The optimism is the expected maximum of K noise draws")
print(" K measured E max of K normals sqrt(2 ln K)")
for k, _v, _t, optimism in rows[1:]:
print(
f" {k:5d} {optimism / standard_error:5.2f} "
f"{s.expected_max_of_normals(k):5.2f} {s.sqrt_two_log_k(k):5.2f}"
)
print(" the closed-form approximation sits above the truth at every K here")
rule("2. Stratification: a rare class and a small test set")
X, y = s.rare_class_dataset()
random_rates, stratified_rates, empty = s.split_positive_rates(X, y)
print(f" population positive rate : {float(y.mean()):.4f}")
print(f" random split : {s.spread(random_rates)}")
print(f" stratified split : {s.spread(stratified_rates)}")
print(f" random splits whose test half held ZERO positives: {empty} of 500")
rule("3. Groups: when the row is not the unit")
Xg, yg, groups = s.grouped_dataset()
rowwise, group_aware = s.rowwise_vs_group_split(Xg, yg, groups)
print(" 50 people, 20 rows each; each person's label is a coin flip")
print(f" row-wise random split : {rowwise:.4f}")
print(f" group-aware split : {group_aware:.4f}")
print(f" accuracy invented : {rowwise - group_aware:+.4f}")
print(f" people appearing in BOTH halves of a row-wise split: {s.groups_shared_between_halves(groups)} of 50")
rule("4. Time: when the data has a direction")
temporal = s.temporal_inflation_over_constructions()
inflation = [gap for _s, _sh, _ch, _b, gap in temporal]
print(" 20 independently generated series, six regimes each")
print(f" shuffled split, mean : {np.mean([r[1] for r in temporal]):.4f}")
print(f" chronological split, mean : {np.mean([r[2] for r in temporal]):.4f}")
print(f" majority baseline, mean : {np.mean([r[3] for r in temporal]):.4f}")
print(f" inflation: mean {np.mean(inflation):.4f} sd {np.std(inflation):.4f} "
f"min {min(inflation):+.4f} max {max(inflation):+.4f}")
print(f" constructions where shuffling won: {sum(1 for g in inflation if g > 0)} of 20")
print(" the direction is universal; the size varies by a factor of sixteen")
rule("5. One holdout, or many folds")
Xw, yw = s.weak_signal_dataset()
holdout, cross = s.holdout_vs_cross_validation(Xw, yw)
print(" same data, same model; only which rows landed where changes")
print(f" single holdout : {s.spread(holdout)}")
print(f" 5-fold : {s.spread(cross)}")
print(f" holdout swings {max(holdout) - min(holdout):.4f} across 200 seeds")
print(f" cross-validation is {np.std(holdout) / np.std(cross):.4f} times steadier")
rule("6. How big does the test set need to be?")
print(" n theory SE measured sd 95 percent half-width")
for n, theory, measured, half in s.test_size_table([50, 100, 200, 500, 1000, 5000]):
print(f" {n:6d} {theory:.4f} {measured:.4f} +/-{half:.4f}")
print(f" rows needed for +/-0.02 at an accuracy of 0.85 : {s.rows_needed_for_precision(0.85, 0.02)}")
print(f" rows needed for +/-0.01 at an accuracy of 0.85 : {s.rows_needed_for_precision(0.85, 0.01)}")
rule("7. The rule, made mechanical")
model = LogisticRegression(max_iter=1000).fit(Xw, yw)
gate = s.GatedTestSet(Xw, yw)
print(f" first evaluation : {gate.evaluate(model):.4f}")
try:
gate.evaluate(model)
print(" second evaluation : NO ERROR RAISED")
except s.TestSetTouchedTwice as exc:
print(f" second evaluation : {type(exc).__name__}")
print(f" {exc}")
if __name__ == "__main__":
main()
examples/splits_lib.py (13508 bytes)
"""Splitting, measured: what each way of cutting a dataset actually buys.
Three sets, not two, and the reason is arithmetic rather than convention.
A validation set you select on is a set you have fitted to, and this
module measures the resulting optimism directly -- it turns out to be
exactly the expected maximum of K noise draws, which is a quantity you can
compute.
The rest measures the four ways a split goes wrong: not stratifying when
the class is rare, splitting rows when the unit is a person, splitting
randomly when the data has a direction in time, and reading a trend off
one holdout when a holdout's own spread is wider than the trend.
Everything here is deterministic given a seed.
"""
from __future__ import annotations
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import (
GroupShuffleSplit,
StratifiedKFold,
StratifiedShuffleSplit,
cross_val_score,
train_test_split,
)
from sklearn.neighbors import KNeighborsClassifier
def accuracy(y_true, y_pred) -> float:
return float(np.mean(np.asarray(y_true) == np.asarray(y_pred)))
# --------------------------------------------------------------------------
# 1. Why three sets: selecting on a set is fitting to it
# --------------------------------------------------------------------------
def selection_replicate(k_candidates: int, n: int = 500, seed: int = 0):
"""One replication of "pick the best of K, then check it on a fresh set".
Each candidate is a fixed prediction vector over 2n rows, split into an
n-row validation set and an n-row test set. Every candidate has exactly
zero skill by construction, so any validation score above 0.5 is noise
and any *selected* validation score above 0.5 is noise you chose.
Returns ``(best_validation_score, that_candidate_s_test_score)``.
"""
rng = np.random.default_rng(seed)
y = rng.integers(0, 2, size=2 * n)
predictions = rng.integers(0, 2, size=(k_candidates, 2 * n))
correct = predictions == y
validation = correct[:, :n].mean(axis=1)
test = correct[:, n:].mean(axis=1)
winner = int(np.argmax(validation))
return float(validation[winner]), float(test[winner])
def selection_bias_curve(k_values, replications: int = 400, n: int = 500):
"""Mean selected-validation score and its test score, for each K.
Returns rows of ``(k, mean_validation, mean_test, optimism)``. The test
column is the control: it must stay at chance for every K, because the
test set was never selected on.
"""
rows = []
for k in k_values:
pairs = [selection_replicate(k, n=n, seed=r) for r in range(replications)]
validation = float(np.mean([v for v, _t in pairs]))
test = float(np.mean([t for _v, t in pairs]))
rows.append((k, round(validation, 4), round(test, 4), round(validation - test, 4)))
return rows
def proportion_standard_error(p: float, n: int) -> float:
"""The standard error of an accuracy estimated on n rows."""
return float(np.sqrt(p * (1.0 - p) / n))
def expected_max_of_normals(k: int, draws: int = 20000, seed: int = 7) -> float:
"""E of the maximum of k standard normals, by simulation.
This is the quantity the selection optimism should equal, once the
optimism is expressed in standard errors. Simulated rather than
approximated, because the usual closed form overestimates it -- which
exercise 1c measures.
"""
rng = np.random.default_rng(seed)
return float(np.mean(np.max(rng.standard_normal((draws, k)), axis=1)))
def sqrt_two_log_k(k: int) -> float:
"""The textbook asymptotic for the expected maximum of k normals."""
return 0.0 if k <= 1 else float(np.sqrt(2.0 * np.log(k)))
# --------------------------------------------------------------------------
# 2. Stratification: a rare class and a small test set
# --------------------------------------------------------------------------
def rare_class_dataset(n: int = 200, rate: float = 0.05, seed: int = 144):
"""A dataset with a genuinely rare positive class."""
rng = np.random.default_rng(seed)
y = np.zeros(n, dtype=int)
y[: int(n * rate)] = 1
X = rng.normal(size=(n, 3))
return X, y
def split_positive_rates(X, y, splits: int = 500, test_size: float = 0.25):
"""Positive rate in the test half, over many random and stratified splits.
Returns ``(random_rates, stratified_rates, random_splits_with_no_positives)``.
"""
random_rates = []
stratified_rates = []
empty = 0
for seed in range(splits):
_x_tr, _x_te, _y_tr, y_te = train_test_split(X, y, test_size=test_size, random_state=seed)
random_rates.append(float(y_te.mean()))
if y_te.sum() == 0:
empty += 1
splitter = StratifiedShuffleSplit(n_splits=1, test_size=test_size, random_state=seed)
_train, test = next(splitter.split(X, y))
stratified_rates.append(float(y[test].mean()))
return random_rates, stratified_rates, empty
def spread(values) -> dict:
"""Mean, standard deviation, minimum and maximum, rounded for reporting."""
values = np.asarray(values, dtype=float)
return {
"mean": round(float(values.mean()), 4),
"sd": round(float(values.std()), 4),
"min": round(float(values.min()), 4),
"max": round(float(values.max()), 4),
}
# --------------------------------------------------------------------------
# 3. Groups: when the row is not the unit
# --------------------------------------------------------------------------
def grouped_dataset(n_people: int = 50, rows_each: int = 20, seed: int = 5):
"""Twenty rows per person, and the label is a property of the PERSON.
There is nothing generalisable here at all: each person's label is a
coin flip, so a model can only score above chance on a person it has
already seen. Which is exactly what a row-wise split hands it.
"""
rng = np.random.default_rng(seed)
groups = np.repeat(np.arange(n_people), rows_each)
person_signature = rng.normal(size=(n_people, 4)) * 2.0
X = person_signature[groups] + rng.normal(size=(n_people * rows_each, 4)) * 0.3
person_label = rng.integers(0, 2, size=n_people)
return X, person_label[groups], groups
def rowwise_vs_group_split(X, y, groups, splits: int = 20, test_size: float = 0.25):
"""Score a 1-NN under a row-wise split and under a group-aware one."""
rowwise = []
grouped = []
for seed in range(splits):
train, test = train_test_split(np.arange(len(y)), test_size=test_size, random_state=seed)
model = KNeighborsClassifier(1).fit(X[train], y[train])
rowwise.append(accuracy(y[test], model.predict(X[test])))
splitter = GroupShuffleSplit(n_splits=1, test_size=test_size, random_state=seed)
train_g, test_g = next(splitter.split(X, y, groups))
model_g = KNeighborsClassifier(1).fit(X[train_g], y[train_g])
grouped.append(accuracy(y[test_g], model_g.predict(X[test_g])))
return float(np.mean(rowwise)), float(np.mean(grouped))
def groups_shared_between_halves(groups, test_size: float = 0.25, seed: int = 0) -> int:
"""How many people appear in BOTH halves of a row-wise split."""
train, test = train_test_split(np.arange(len(groups)), test_size=test_size, random_state=seed)
return len(set(groups[train].tolist()) & set(groups[test].tolist()))
# --------------------------------------------------------------------------
# 4. Time: when the data has a direction
# --------------------------------------------------------------------------
def regime_series(length: int = 1200, n_regimes: int = 6, seed: int = 9):
"""A series in which the rule mapping features to labels changes.
Six regimes, each with its own randomly drawn linear rule. A random
shuffle scatters every regime across both halves, so the model always
has neighbours from the same regime. A chronological split asks it to
predict a regime it has never seen -- which is what deployment does.
"""
rng = np.random.default_rng(seed)
X = rng.normal(size=(length, 4))
y = np.empty(length, dtype=int)
block = length // n_regimes
for r in range(n_regimes):
rule = rng.normal(size=4)
window = slice(r * block, (r + 1) * block)
y[window] = (X[window] @ rule > 0).astype(int)
return X, y
def shuffled_vs_chronological(X, y, splits: int = 10, test_size: float = 0.25):
"""Score a 5-NN under a shuffled split and under a chronological one.
Returns ``(mean_shuffled, chronological, majority_baseline_on_the_tail)``.
"""
shuffled = []
for seed in range(splits):
train, test = train_test_split(
np.arange(len(y)), test_size=test_size, random_state=seed, shuffle=True
)
model = KNeighborsClassifier(5).fit(X[train], y[train])
shuffled.append(accuracy(y[test], model.predict(X[test])))
cut = int(len(y) * (1.0 - test_size))
model = KNeighborsClassifier(5).fit(X[:cut], y[:cut])
chronological = accuracy(y[cut:], model.predict(X[cut:]))
tail = y[cut:]
baseline = float(max(tail.mean(), 1.0 - tail.mean()))
return float(np.mean(shuffled)), chronological, baseline
def temporal_inflation_over_constructions(constructions: int = 20, splits: int = 10):
"""The same comparison over many independently generated series.
One construction is an anecdote. Reporting the seed that gave the
largest gap would be the forking-paths problem in a lesson about not
doing that -- so this returns the whole distribution.
"""
rows = []
for seed in range(constructions):
X, y = regime_series(seed=seed)
shuffled, chronological, baseline = shuffled_vs_chronological(X, y, splits=splits)
rows.append((seed, shuffled, chronological, baseline, shuffled - chronological))
return rows
# --------------------------------------------------------------------------
# 5. One holdout, or many folds
# --------------------------------------------------------------------------
def weak_signal_dataset(n: int = 400, seed: int = 21):
"""A real but modest relationship, so the estimate has something to vary around."""
rng = np.random.default_rng(seed)
X = rng.normal(size=(n, 4))
y = (X[:, 0] + X[:, 1] * 0.6 + rng.normal(size=n) * 1.2 > 0).astype(int)
return X, y
def holdout_vs_cross_validation(X, y, repeats: int = 200, test_size: float = 0.25, folds: int = 5):
"""The spread of a single-holdout estimate against a k-fold estimate.
Same data, same model. The only thing that changes between repeats is
which rows landed where.
"""
holdout = []
cross = []
for seed in range(repeats):
train, test = train_test_split(
np.arange(len(y)), test_size=test_size, random_state=seed, stratify=y
)
model = LogisticRegression(max_iter=1000).fit(X[train], y[train])
holdout.append(accuracy(y[test], model.predict(X[test])))
cross.append(
float(
np.mean(
cross_val_score(
LogisticRegression(max_iter=1000),
X,
y,
cv=StratifiedKFold(folds, shuffle=True, random_state=seed),
)
)
)
)
return holdout, cross
# --------------------------------------------------------------------------
# 6. How big does the test set need to be?
# --------------------------------------------------------------------------
def test_size_table(sizes, p: float = 0.85, draws: int = 20000, seed: int = 33):
"""Predicted and measured standard error of an accuracy, by test-set size.
Rows are ``(n, theoretical_se, measured_sd, half_width_of_95_interval)``.
The theory is Day 117's; this is it arriving where the decisions are.
"""
rows = []
for n in sizes:
theory = proportion_standard_error(p, n)
sample = np.random.default_rng(seed).binomial(n, p, size=draws) / n
rows.append((n, round(theory, 4), round(float(sample.std()), 4), round(1.96 * theory, 4)))
return rows
def rows_needed_for_precision(p: float, half_width: float) -> int:
"""Smallest test set whose 95 percent interval is no wider than requested."""
n = int(np.ceil(p * (1.0 - p) * (1.96 / half_width) ** 2))
return n
# --------------------------------------------------------------------------
# 7. The rule, made checkable
# --------------------------------------------------------------------------
class TestSetTouchedTwice(RuntimeError):
"""Raised when the test set is evaluated against more than once."""
class GatedTestSet:
"""A test set that permits exactly one evaluation, then refuses.
Not a substitute for discipline. It is the discipline made mechanical,
in the same spirit as Day 143's stage contract: a rule that lives in
code is a rule somebody can check.
"""
def __init__(self, X, y):
self._X = X
self._y = y
self.evaluations = 0
def evaluate(self, model) -> float:
if self.evaluations >= 1:
raise TestSetTouchedTwice(
"the test set has already been used once; any further score is a "
"validation score, not a test score"
)
self.evaluations += 1
return accuracy(self._y, model.predict(self._X))
examples/test_splits_claims.py (9603 bytes)
"""The reference solutions: what each way of splitting a dataset costs.
Every number here was captured from a real run of this file on the
authoring machine. If a number changes, the claim in the lesson is wrong
and one of the two must be fixed.
"""
import numpy as np
import pytest
from sklearn.linear_model import LogisticRegression
import splits_lib as s
@pytest.fixture(scope="module")
def rare():
return s.rare_class_dataset()
@pytest.fixture(scope="module")
def grouped():
return s.grouped_dataset()
@pytest.fixture(scope="module")
def weak():
return s.weak_signal_dataset()
# --- 1. Why three sets and not two --------------------------------------
def test_01_picking_the_best_of_k_inflates_the_score_you_picked_it_by():
rows = s.selection_bias_curve([1, 2, 5, 10, 25, 50, 100, 500, 1000])
assert rows == [
(1, 0.4984, 0.5011, -0.0028),
(2, 0.5115, 0.4992, 0.0123),
(5, 0.5256, 0.5005, 0.0251),
(10, 0.5331, 0.4999, 0.0332),
(25, 0.5436, 0.5009, 0.0427),
(50, 0.5508, 0.5014, 0.0493),
(100, 0.5567, 0.4992, 0.0575),
(500, 0.5682, 0.4978, 0.0704),
(1000, 0.572, 0.4992, 0.0728),
]
validation = [v for _k, v, _t, _o in rows]
# The validation score climbs with every extra candidate considered.
assert all(a < b for a, b in zip(validation, validation[1:]))
# Every candidate has exactly zero skill: these are coin flips.
assert rows[-1][1] > rows[0][1] + 0.07
def test_01b_the_test_set_is_the_control_and_stays_at_chance():
rows = s.selection_bias_curve([1, 10, 100, 1000])
test_scores = [t for _k, _v, t, _o in rows]
# Never selected on, therefore never inflated -- at any K.
for score in test_scores:
assert abs(score - 0.5) < 0.005
assert max(test_scores) - min(test_scores) < 0.005
# This is what makes the validation column mean something.
validation = [v for _k, v, _t, _o in rows]
assert validation[-1] - validation[0] > 0.07
def test_01c_the_optimism_is_the_expected_maximum_of_k_noise_draws():
"""And the usual closed-form approximation overestimates it."""
standard_error = s.proportion_standard_error(0.5, 500)
assert round(standard_error, 4) == 0.0224
rows = s.selection_bias_curve([2, 5, 10, 25, 50, 100, 500, 1000])
for k, _validation, _test, optimism in rows:
in_errors = optimism / standard_error
simulated = s.expected_max_of_normals(k)
approximation = s.sqrt_two_log_k(k)
# Measurement tracks the simulated expected maximum closely.
assert abs(in_errors - simulated) < 0.2, (k, in_errors, simulated)
# The sqrt(2 ln K) asymptotic is above it at every K tried here.
assert approximation > simulated
# Concretely, at K = 100: 2.57 standard errors measured, 2.50 expected,
# against the approximation's 3.03.
optimism_100 = dict((k, o) for k, _v, _t, o in rows)[100]
assert round(optimism_100 / standard_error, 2) == 2.57
assert round(s.expected_max_of_normals(100), 2) == 2.5
assert round(s.sqrt_two_log_k(100), 2) == 3.03
# --- 2. Stratification ---------------------------------------------------
def test_02_a_random_split_of_a_rare_class_sometimes_has_no_positives(rare):
X, y = rare
assert round(float(y.mean()), 4) == 0.05
random_rates, stratified_rates, empty = s.split_positive_rates(X, y)
assert s.spread(random_rates) == {"mean": 0.0504, "sd": 0.0265, "min": 0.0, "max": 0.16}
assert s.spread(stratified_rates) == {"mean": 0.05, "sd": 0.01, "min": 0.04, "max": 0.06}
# Twenty-one splits in five hundred produced a test set with no
# positives at all, on which recall is undefined.
assert empty == 21
assert 0 in [round(r, 4) for r in random_rates]
assert 0 not in [round(r, 4) for r in stratified_rates]
def test_02b_stratifying_shrinks_the_spread_without_changing_the_mean(rare):
X, y = rare
random_rates, stratified_rates, _empty = s.split_positive_rates(X, y)
random = s.spread(random_rates)
stratified = s.spread(stratified_rates)
assert abs(random["mean"] - stratified["mean"]) < 0.001
assert stratified["sd"] < random["sd"]
assert round(random["sd"] / stratified["sd"], 2) == 2.65
# The random split's worst case is three times the population rate.
assert round(random["max"] / float(y.mean()), 2) == 3.2
# --- 3. Groups -----------------------------------------------------------
def test_03_splitting_rows_when_the_unit_is_a_person_invents_fifty_six_points(grouped):
X, y, groups = grouped
rowwise, group_aware = s.rowwise_vs_group_split(X, y, groups)
assert round(rowwise, 4) == 0.976
assert round(group_aware, 4) == 0.4112
assert round(rowwise - group_aware, 4) == 0.5648
# There is nothing generalisable in this data at all: each person's
# label is a coin flip, so the group-aware score is chance, and low.
assert group_aware < 0.5 < rowwise
def test_03b_every_single_person_appears_in_both_halves(grouped):
_X, _y, groups = grouped
shared = s.groups_shared_between_halves(groups)
assert shared == 50
assert len(set(groups.tolist())) == 50
# Twenty rows each: a random quarter cannot miss anybody.
assert len(groups) == 1000
# --- 4. Time -------------------------------------------------------------
def test_04_a_shuffled_split_beats_a_chronological_one_every_single_time():
rows = s.temporal_inflation_over_constructions()
assert len(rows) == 20
inflation = [gap for _seed, _sh, _ch, _base, gap in rows]
# The direction is universal across twenty independent constructions.
assert all(gap > 0 for gap in inflation)
assert sum(1 for gap in inflation if gap > 0) == 20
def test_04b_but_the_size_of_the_effect_varies_by_an_order_of_magnitude():
rows = s.temporal_inflation_over_constructions()
shuffled = float(np.mean([sh for _s, sh, _c, _b, _g in rows]))
chronological = float(np.mean([ch for _s, _sh, ch, _b, _g in rows]))
baseline = float(np.mean([b for _s, _sh, _ch, b, _g in rows]))
inflation = [gap for _s, _sh, _ch, _b, gap in rows]
assert round(shuffled, 4) == 0.5961
assert round(chronological, 4) == 0.5233
assert round(baseline, 4) == 0.5235
assert round(float(np.mean(inflation)), 4) == 0.0728
assert round(float(np.std(inflation)), 4) == 0.0596
assert round(min(inflation), 4) == 0.016
assert round(max(inflation), 4) == 0.2557
# Sixteen times between the smallest and largest effect: reporting the
# largest would be the forking-paths problem in a lesson against it.
assert round(max(inflation) / min(inflation), 1) == 16.0
# The honest verdict: chronologically, the model has learned nothing.
assert abs(chronological - baseline) < 0.005
assert shuffled > baseline
# --- 5. One holdout, or many folds --------------------------------------
def test_05_one_holdout_swings_nineteen_points_on_identical_data(weak):
X, y = weak
holdout, cross = s.holdout_vs_cross_validation(X, y)
assert s.spread(holdout) == {"mean": 0.7519, "sd": 0.0381, "min": 0.66, "max": 0.85}
assert s.spread(cross) == {"mean": 0.7546, "sd": 0.0061, "min": 0.7375, "max": 0.77}
# Same data, same model. Only which rows landed where changed.
assert round(max(holdout) - min(holdout), 4) == 0.19
assert round(max(cross) - min(cross), 4) == 0.0325
def test_05b_cross_validation_is_six_times_steadier_for_the_same_data(weak):
X, y = weak
holdout, cross = s.holdout_vs_cross_validation(X, y)
ratio = float(np.std(holdout) / np.std(cross))
assert round(ratio, 4) == 6.2344
# It estimates the same thing -- the means agree to within 0.003.
assert abs(float(np.mean(holdout)) - float(np.mean(cross))) < 0.003
# --- 6. How big must the test set be? -----------------------------------
def test_06_the_standard_error_formula_predicts_the_measured_spread():
rows = s.test_size_table([50, 100, 200, 500, 1000, 5000])
assert rows == [
(50, 0.0505, 0.0505, 0.099),
(100, 0.0357, 0.0357, 0.07),
(200, 0.0252, 0.0254, 0.0495),
(500, 0.016, 0.016, 0.0313),
(1000, 0.0113, 0.0112, 0.0221),
(5000, 0.005, 0.0051, 0.0099),
]
for _n, theory, measured, _half in rows:
assert abs(theory - measured) <= 0.0002
# Four times the rows halves the error, not quarters it.
by_n = {n: theory for n, theory, _m, _h in rows}
assert round(by_n[200] / by_n[50], 2) == 0.5
assert round(by_n[5000] / by_n[500], 2) == 0.31
def test_06b_a_hundred_row_test_set_cannot_resolve_a_five_point_difference():
half_width = s.test_size_table([100])[0][3]
assert half_width == 0.07
# Plus or minus seven points: two models five points apart are
# indistinguishable on it.
assert half_width > 0.05
assert s.rows_needed_for_precision(0.85, 0.02) == 1225
assert s.rows_needed_for_precision(0.85, 0.01) == 4899
# --- 7. The rule, made mechanical ---------------------------------------
def test_07_the_test_set_permits_exactly_one_evaluation(weak):
X, y = weak
model = LogisticRegression(max_iter=1000).fit(X, y)
gate = s.GatedTestSet(X, y)
assert gate.evaluations == 0
first = gate.evaluate(model)
assert round(first, 4) == 0.7575
assert gate.evaluations == 1
with pytest.raises(s.TestSetTouchedTwice) as excinfo:
gate.evaluate(model)
assert "validation score" in str(excinfo.value)
# And the counter does not advance on a refused attempt.
assert gate.evaluations == 1
examples/test_splits_lib.py (2401 bytes)
"""Machinery checks: the helpers behave, before any claim is made.
These four tests are solved in both `starter/` and `examples/`. They exist
so that a broken helper reports itself as a broken helper rather than as a
surprising scientific result.
"""
import numpy as np
import pytest
import splits_lib as s
def test_the_standard_error_formula_behaves_as_it_should():
# Maximal at p = 0.5, and shrinking like one over root n.
assert s.proportion_standard_error(0.5, 100) > s.proportion_standard_error(0.85, 100)
assert s.proportion_standard_error(0.5, 100) > s.proportion_standard_error(0.5, 400)
quartered = s.proportion_standard_error(0.5, 400) / s.proportion_standard_error(0.5, 100)
assert round(quartered, 4) == 0.5
# A certain outcome has no sampling error at all.
assert s.proportion_standard_error(1.0, 100) == 0.0
def test_the_grouped_dataset_really_is_grouped():
X, y, groups = s.grouped_dataset(n_people=10, rows_each=5, seed=1)
assert X.shape == (50, 4) and y.shape == (50,) and groups.shape == (50,)
assert len(set(groups.tolist())) == 10
# Every row belonging to one person carries that person's single label.
for person in range(10):
member_labels = set(y[groups == person].tolist())
assert len(member_labels) == 1
def test_the_regime_series_really_changes_its_rule():
X, y = s.regime_series(length=600, n_regimes=3, seed=2)
assert X.shape == (600, 4) and y.shape == (600,)
# Each regime is internally consistent but the regimes disagree: a
# linear model fitted on one block should do worse on another.
from sklearn.linear_model import LogisticRegression
first = LogisticRegression(max_iter=1000).fit(X[:200], y[:200])
own = s.accuracy(y[:200], first.predict(X[:200]))
other = s.accuracy(y[400:], first.predict(X[400:]))
assert own > 0.9
assert other < own - 0.2
def test_the_gated_test_set_counts_and_refuses():
class AlwaysZero:
def predict(self, X):
return np.zeros(len(X), dtype=int)
y = np.array([0, 0, 0, 1])
gate = s.GatedTestSet(np.zeros((4, 2)), y)
assert gate.evaluate(AlwaysZero()) == 0.75
with pytest.raises(s.TestSetTouchedTwice):
gate.evaluate(AlwaysZero())
# A fresh gate is a fresh budget; the class holds no global state.
assert s.GatedTestSet(np.zeros((4, 2)), y).evaluate(AlwaysZero()) == 0.75
metadata.yml (6792 bytes)
lesson_id: D144
day: 144
kind: guided-build
languages:
- python
- bash
setup_commands:
- cd labs/sections/machine-learning/day-144-train-validation-and-test-splits
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- >-
.venv/bin/python3 -c "import numpy, sklearn; print(numpy.__version__,
sklearn.__version__)"
run_commands:
- .venv/bin/pytest examples -q
- .venv/bin/pytest starter -q
- .venv/bin/python3 examples/report_measurements.py
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: 65
last_executed: '2026-08-27'
executed_on: >-
macOS 26.5.2 (Apple Silicon, arm64, CPU only -- no GPU is needed or used), Python
3.14.0, numpy 2.5.2, scikit-learn 1.9.0, pytest 9.1.1, bash 3.2.57 -- bash
tests/run_tests.sh -> 14 checks, 0 failure(s), exit 0. pytest examples -q -> 18 passed.
pytest starter -q -> 4 passed, 14 skipped (the four machinery checks in
test_splits_lib.py are solved in both directories; the fourteen exercise stubs in
starter/test_splits_claims.py are untouched). Everything ran through a real lab-local
.venv created by the documented setup commands; scikit-learn pulled in scipy 1.18.1,
joblib 1.5.3 and threadpoolctl 3.6.0 as its own dependencies, none of which this lab
imports directly. The lab is fully offline after the pip install -- every dataset is
generated on the spot from a seeded numpy.random.default_rng, nothing is downloaded,
no dataset is bundled, and harness check 9 confirms no URL appears anywhere in
starter/ or examples/ source. Section 7 of the harness copies examples/ into a
mktemp-d scratch directory, confirms 18 passed, rewrites `assert round(rowwise -
group_aware, 4) == 0.5648` to 0.0, confirms a non-zero exit naming the failing test,
and removes the scratch directory. Separately, by hand, `assert empty == 21` was
changed to 999 in examples/test_splits_claims.py and the whole harness re-run: it
reported 14 checks, 2 failure(s) and exited 1 (both the pytest run and the pytest-free
direct reproduction in section 2 caught it); the file was restored and the harness
returned to 14 checks, 0 failure(s), exit 0. MEASURED PAIRS, all captured verbatim in
expected-output/measured-values.txt. (1) WHY THREE SETS: K candidates that are literal
coin flips, each scored on a 500-row validation set and a 500-row test set, averaged
over 400 replications. Picking the best on validation gives 0.4984, 0.5115, 0.5256,
0.5331, 0.5436, 0.5508, 0.5567, 0.5682 and 0.5720 at K = 1, 2, 5, 10, 25, 50, 100, 500
and 1000 -- climbing steadily on candidates with exactly zero skill -- while that same
candidate's test score never moves from chance, staying within 0.003 of 0.5 at every
K. The test column is the control and it is what makes the validation column mean
anything. (2) The optimism is not a vague warning but a computable quantity: expressed
in standard errors (0.0224 at n=500) it reads 0.55, 1.12, 1.48, 1.91, 2.20, 2.57, 3.15
and 3.26, tracking the simulated expected maximum of K standard normals (0.55, 1.16,
1.54, 1.97, 2.25, 2.50, 3.03, 3.24) to within 0.2 at every K -- while the familiar
closed form sqrt(2 ln K) sits ABOVE the truth at every K here (1.18, 1.79, 2.15, 2.54,
2.80, 3.03, 3.53, 3.72). (3) Stratification: on a 5-percent positive class, 500 random
splits give test positive rates with sd 0.0265 against 0.0100 stratified, and 21 of
those 500 random splits produced a test half containing NO positives at all, on which
recall is undefined. (4) Groups: 50 people, 20 rows each, each person's label a coin
flip so nothing is generalisable. A row-wise random split scores 0.9760; a group-aware
split scores 0.4112; 0.5648 of accuracy invented. All 50 people appear in both halves
of the row-wise split -- with 20 rows each, a random quarter cannot miss anybody. (5)
Time: across 20 independently generated six-regime series, a shuffled split beat a
chronological one in 20 of 20, with shuffled mean 0.5961 against chronological 0.5233
and a majority baseline of 0.5235 -- so chronologically the model has learned nothing.
(6) One holdout against 5-fold on identical data and an identical model, over 200
seeds: the holdout has sd 0.0381 and spans 0.66 to 0.85, a swing of 0.19, while 5-fold
has sd 0.0061 and spans 0.7375 to 0.77; cross-validation is 6.2344 times steadier and
the two means agree to within 0.003. (7) Test-set size: the predicted standard error
sqrt(p(1-p)/n) matches 20000 measured draws to within 0.0002 at n = 50, 100, 200, 500,
1000 and 5000, so a 100-row test set carries a 95-percent half-width of 0.07 and
cannot resolve a five-point difference; 1225 rows are needed for plus or minus 0.02 at
an accuracy of 0.85, and 4899 for plus or minus 0.01. (8) A GatedTestSet permits
exactly one evaluation, returns 0.7575, and raises TestSetTouchedTwice on the second
with a message naming what the second number really is -- without advancing its
counter on the refused attempt. THREE HONESTY CALLS. FIRST, and the most important:
the temporal effect's SIZE varies by a factor of sixteen across constructions, from
+0.0160 to +0.2557. The first construction tried gave +0.1428 and quoting it alone
would have been the forking-paths problem inside a lab against exactly that, so
exercise 4 asserts only the direction, which held 20 times out of 20, and exercise 4b
asserts the whole distribution -- mean 0.0728, sd 0.0596, min and max -- rather than a
single headline figure. Not every split mistake costs the same: group leakage cost 56
points and temporal leakage cost 7 on average. SECOND: the group-aware score of 0.4112
is BELOW chance and this is not anti-learning -- about twelve people land in that test
half, each contributing twenty identical labels, so the estimate is roughly a dozen
coin flips and it wanders; the lab asserts the structural claim (below 0.5 while
row-wise is far above it) rather than the value. THIRD: the textbook sqrt(2 ln K)
approximation is reported as overestimating the measured optimism at every K tried,
because that is what was measured; the lab asserts the inequality rather than treating
the closed form as the truth. Harness check 8 re-runs group leakage at five dataset
seeds, selection optimism at a different replication count, and stratification at
three seeds, so every direction the lesson claims is confirmed beyond the seed it
quotes.
requirements/README.md (2012 bytes)
# Requirements
`requirements.txt` pins the three packages this lab imports directly, at
the exact versions the captured output in `expected-output/` was produced
with:
```
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
```
Installing scikit-learn also pulls in scipy, joblib and threadpoolctl as
its own dependencies. This lab imports none of them directly and does not
pin them; the versions present during capture are recorded in
`../expected-output/FIELDS.md`.
## Why the versions are pinned exactly
Almost every number in this lab is an average over seeded draws from
`numpy.random.default_rng`, and NumPy's documentation is explicit that
`Generator` makes no promise of stream compatibility between versions. A
different NumPy can legitimately produce a different stream from the same
seed, and every sampled figure would move.
What does not depend on the pins: the standard-error formula in exercise
6, which is arithmetic; the direction of every result — group leakage
inflates, stratification narrows, shuffling beats chronology, selecting on
a set inflates that set's score; and the structural facts, such as all
fifty people appearing in both halves of a row-wise split. Harness check 8
re-runs three of those directions at seeds the lesson never quotes,
precisely so the distinction is enforced rather than asserted.
`expected-output/FIELDS.md` separates the two categories in full, and it
is worth reading before you conclude that a mismatch is a bug.
## Installing
From the lab directory:
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
```
The install step needs the network. Everything after it is offline: every
dataset in this lab is generated on the spot from a seeded generator, and
nothing is downloaded.
## Free and open-source status
All three packages are free and open source — NumPy and scikit-learn under
the BSD 3-Clause licence, pytest under the MIT licence. There is no paid
tier, no account and no API key anywhere in this lab.
requirements/requirements.txt (47 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
starter/00_brief.md (5106 bytes)
# Day 144 lab brief — Three Sets, and Why
Everybody knows you hold out a test set. Rather fewer people can say why
there are supposed to be *three* sets rather than two, and almost nobody
has seen the number that justifies the third one.
This lab measures it.
## The claim you are here to measure
> A validation set you select on is a set you have fitted to.
Exercise 1 does it with candidates that have exactly zero skill. Each one
is a coin flip: a fixed vector of random predictions, scored on a 500-row
validation set and a 500-row test set. Pick whichever scores best on
validation, then look at what it scores on test:
| candidates considered | best validation | its test score | optimism |
| --- | --- | --- | --- |
| 1 | 0.4984 | 0.5011 | −0.0028 |
| 10 | 0.5331 | 0.4999 | +0.0332 |
| 100 | 0.5567 | 0.4992 | +0.0575 |
| 1000 | 0.5720 | 0.4992 | +0.0728 |
Read the test column first. **It never moves.** It sits at chance for
every K, because it was never selected on. That is the control, and it is
what makes the validation column mean something.
Now read the validation column. It climbs, steadily, all the way to
0.5720 — on candidates that are coin flips. Try a thousand things and the
best of them will look seven points better than chance, every time,
whether or not any of them is any good.
That is why there are three sets. Not convention. Arithmetic.
## The part that is genuinely satisfying
Exercise 1c checks the optimism against theory. Express it in standard
errors — the standard error of an accuracy on 500 rows is 0.0224 — and
compare it to the expected maximum of K standard normal draws:
| K | measured, in SEs | E max of K normals | sqrt(2 ln K) |
| --- | --- | --- | --- |
| 10 | 1.48 | 1.54 | 2.15 |
| 100 | 2.57 | 2.50 | 3.03 |
| 1000 | 3.26 | 3.24 | 3.72 |
The measurement tracks the simulated expectation closely — and the
familiar closed-form `sqrt(2 ln K)` sits above the truth at every K here.
It is an asymptotic, and it is loose at any K you will actually use. The
lab asserts the inequality rather than a gap size.
## The four ways a split goes wrong
| # | The mistake | What it costs, measured |
| --- | --- | --- |
| 2 | not stratifying a rare class | 21 of 500 random splits had a test half with **no positives at all** |
| 3 | splitting rows when the unit is a person | **+0.5648** — 0.9760 against 0.4112, and all 50 people were in both halves |
| 4 | shuffling data that has a direction in time | +0.0728 on average, and shuffling won in **20 of 20** constructions |
| 5 | reading a trend off one holdout | one holdout swung **0.19** across seeds; 5-fold swung 0.0325 |
Exercise 3 is the one that should alarm you. Fifty people, twenty rows
each, and each person's label is a coin flip — there is nothing
generalisable in that dataset whatsoever. A row-wise random split reports
**97.6 percent accuracy**. A group-aware split reports 0.4112, which is
chance. Fifty-six points, and the mechanism is one line: with twenty rows
each, a random quarter cannot miss anybody, so every test person is
already in training.
## The honesty call in exercise 4
The temporal effect is real: shuffling beat chronology in all twenty
constructions. But its **size varies by a factor of sixteen**, from +0.016
to +0.2557.
The first construction tried while building this lab gave +0.1428.
Reporting that one number would have been the forking-paths problem inside
a lab about not committing it. So exercise 4 splits in two: 4 asserts the
direction, which held every time, and 4b asserts the whole distribution —
mean, standard deviation, minimum and maximum — and the fact that the
chronological score is statistically indistinguishable from the majority
baseline.
Not every split mistake costs the same. Group leakage cost fifty-six
points. Temporal leakage, here, cost seven on average. Both are real; only
one is an emergency.
## How to work
1. Build the environment (see the lab `README.md`).
2. Run `.venv/bin/pytest starter -q`. You will see four passes (the
machinery checks in `test_splits_lib.py`) and fourteen skips.
3. Replace one `pytest.skip(...)` at a time with real code. The skip text
names the exact helper and the exact value to assert.
4. Print the measured pair in every exercise. A number you did not print
is a number you did not look at.
5. When you want the whole measured table at once, run
`.venv/bin/python3 examples/report_measurements.py`.
Do not run `pytest starter examples` in one invocation. Both directories
define `splits_lib.py`, `test_splits_lib.py` and `test_splits_claims.py`;
pytest aborts on the module-name collision. Run them separately, always.
## And the rule, made mechanical
Exercise 7 wraps the test set in a `GatedTestSet` that permits exactly one
evaluation and raises `TestSetTouchedTwice` on the second, with a message
saying what the second number actually is: a validation score.
That is not a substitute for discipline. It is the discipline made
mechanical, in the same spirit as Day 143's stage contract — a rule that
lives in code is a rule somebody can check.
starter/splits_lib.py (13508 bytes)
"""Splitting, measured: what each way of cutting a dataset actually buys.
Three sets, not two, and the reason is arithmetic rather than convention.
A validation set you select on is a set you have fitted to, and this
module measures the resulting optimism directly -- it turns out to be
exactly the expected maximum of K noise draws, which is a quantity you can
compute.
The rest measures the four ways a split goes wrong: not stratifying when
the class is rare, splitting rows when the unit is a person, splitting
randomly when the data has a direction in time, and reading a trend off
one holdout when a holdout's own spread is wider than the trend.
Everything here is deterministic given a seed.
"""
from __future__ import annotations
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import (
GroupShuffleSplit,
StratifiedKFold,
StratifiedShuffleSplit,
cross_val_score,
train_test_split,
)
from sklearn.neighbors import KNeighborsClassifier
def accuracy(y_true, y_pred) -> float:
return float(np.mean(np.asarray(y_true) == np.asarray(y_pred)))
# --------------------------------------------------------------------------
# 1. Why three sets: selecting on a set is fitting to it
# --------------------------------------------------------------------------
def selection_replicate(k_candidates: int, n: int = 500, seed: int = 0):
"""One replication of "pick the best of K, then check it on a fresh set".
Each candidate is a fixed prediction vector over 2n rows, split into an
n-row validation set and an n-row test set. Every candidate has exactly
zero skill by construction, so any validation score above 0.5 is noise
and any *selected* validation score above 0.5 is noise you chose.
Returns ``(best_validation_score, that_candidate_s_test_score)``.
"""
rng = np.random.default_rng(seed)
y = rng.integers(0, 2, size=2 * n)
predictions = rng.integers(0, 2, size=(k_candidates, 2 * n))
correct = predictions == y
validation = correct[:, :n].mean(axis=1)
test = correct[:, n:].mean(axis=1)
winner = int(np.argmax(validation))
return float(validation[winner]), float(test[winner])
def selection_bias_curve(k_values, replications: int = 400, n: int = 500):
"""Mean selected-validation score and its test score, for each K.
Returns rows of ``(k, mean_validation, mean_test, optimism)``. The test
column is the control: it must stay at chance for every K, because the
test set was never selected on.
"""
rows = []
for k in k_values:
pairs = [selection_replicate(k, n=n, seed=r) for r in range(replications)]
validation = float(np.mean([v for v, _t in pairs]))
test = float(np.mean([t for _v, t in pairs]))
rows.append((k, round(validation, 4), round(test, 4), round(validation - test, 4)))
return rows
def proportion_standard_error(p: float, n: int) -> float:
"""The standard error of an accuracy estimated on n rows."""
return float(np.sqrt(p * (1.0 - p) / n))
def expected_max_of_normals(k: int, draws: int = 20000, seed: int = 7) -> float:
"""E of the maximum of k standard normals, by simulation.
This is the quantity the selection optimism should equal, once the
optimism is expressed in standard errors. Simulated rather than
approximated, because the usual closed form overestimates it -- which
exercise 1c measures.
"""
rng = np.random.default_rng(seed)
return float(np.mean(np.max(rng.standard_normal((draws, k)), axis=1)))
def sqrt_two_log_k(k: int) -> float:
"""The textbook asymptotic for the expected maximum of k normals."""
return 0.0 if k <= 1 else float(np.sqrt(2.0 * np.log(k)))
# --------------------------------------------------------------------------
# 2. Stratification: a rare class and a small test set
# --------------------------------------------------------------------------
def rare_class_dataset(n: int = 200, rate: float = 0.05, seed: int = 144):
"""A dataset with a genuinely rare positive class."""
rng = np.random.default_rng(seed)
y = np.zeros(n, dtype=int)
y[: int(n * rate)] = 1
X = rng.normal(size=(n, 3))
return X, y
def split_positive_rates(X, y, splits: int = 500, test_size: float = 0.25):
"""Positive rate in the test half, over many random and stratified splits.
Returns ``(random_rates, stratified_rates, random_splits_with_no_positives)``.
"""
random_rates = []
stratified_rates = []
empty = 0
for seed in range(splits):
_x_tr, _x_te, _y_tr, y_te = train_test_split(X, y, test_size=test_size, random_state=seed)
random_rates.append(float(y_te.mean()))
if y_te.sum() == 0:
empty += 1
splitter = StratifiedShuffleSplit(n_splits=1, test_size=test_size, random_state=seed)
_train, test = next(splitter.split(X, y))
stratified_rates.append(float(y[test].mean()))
return random_rates, stratified_rates, empty
def spread(values) -> dict:
"""Mean, standard deviation, minimum and maximum, rounded for reporting."""
values = np.asarray(values, dtype=float)
return {
"mean": round(float(values.mean()), 4),
"sd": round(float(values.std()), 4),
"min": round(float(values.min()), 4),
"max": round(float(values.max()), 4),
}
# --------------------------------------------------------------------------
# 3. Groups: when the row is not the unit
# --------------------------------------------------------------------------
def grouped_dataset(n_people: int = 50, rows_each: int = 20, seed: int = 5):
"""Twenty rows per person, and the label is a property of the PERSON.
There is nothing generalisable here at all: each person's label is a
coin flip, so a model can only score above chance on a person it has
already seen. Which is exactly what a row-wise split hands it.
"""
rng = np.random.default_rng(seed)
groups = np.repeat(np.arange(n_people), rows_each)
person_signature = rng.normal(size=(n_people, 4)) * 2.0
X = person_signature[groups] + rng.normal(size=(n_people * rows_each, 4)) * 0.3
person_label = rng.integers(0, 2, size=n_people)
return X, person_label[groups], groups
def rowwise_vs_group_split(X, y, groups, splits: int = 20, test_size: float = 0.25):
"""Score a 1-NN under a row-wise split and under a group-aware one."""
rowwise = []
grouped = []
for seed in range(splits):
train, test = train_test_split(np.arange(len(y)), test_size=test_size, random_state=seed)
model = KNeighborsClassifier(1).fit(X[train], y[train])
rowwise.append(accuracy(y[test], model.predict(X[test])))
splitter = GroupShuffleSplit(n_splits=1, test_size=test_size, random_state=seed)
train_g, test_g = next(splitter.split(X, y, groups))
model_g = KNeighborsClassifier(1).fit(X[train_g], y[train_g])
grouped.append(accuracy(y[test_g], model_g.predict(X[test_g])))
return float(np.mean(rowwise)), float(np.mean(grouped))
def groups_shared_between_halves(groups, test_size: float = 0.25, seed: int = 0) -> int:
"""How many people appear in BOTH halves of a row-wise split."""
train, test = train_test_split(np.arange(len(groups)), test_size=test_size, random_state=seed)
return len(set(groups[train].tolist()) & set(groups[test].tolist()))
# --------------------------------------------------------------------------
# 4. Time: when the data has a direction
# --------------------------------------------------------------------------
def regime_series(length: int = 1200, n_regimes: int = 6, seed: int = 9):
"""A series in which the rule mapping features to labels changes.
Six regimes, each with its own randomly drawn linear rule. A random
shuffle scatters every regime across both halves, so the model always
has neighbours from the same regime. A chronological split asks it to
predict a regime it has never seen -- which is what deployment does.
"""
rng = np.random.default_rng(seed)
X = rng.normal(size=(length, 4))
y = np.empty(length, dtype=int)
block = length // n_regimes
for r in range(n_regimes):
rule = rng.normal(size=4)
window = slice(r * block, (r + 1) * block)
y[window] = (X[window] @ rule > 0).astype(int)
return X, y
def shuffled_vs_chronological(X, y, splits: int = 10, test_size: float = 0.25):
"""Score a 5-NN under a shuffled split and under a chronological one.
Returns ``(mean_shuffled, chronological, majority_baseline_on_the_tail)``.
"""
shuffled = []
for seed in range(splits):
train, test = train_test_split(
np.arange(len(y)), test_size=test_size, random_state=seed, shuffle=True
)
model = KNeighborsClassifier(5).fit(X[train], y[train])
shuffled.append(accuracy(y[test], model.predict(X[test])))
cut = int(len(y) * (1.0 - test_size))
model = KNeighborsClassifier(5).fit(X[:cut], y[:cut])
chronological = accuracy(y[cut:], model.predict(X[cut:]))
tail = y[cut:]
baseline = float(max(tail.mean(), 1.0 - tail.mean()))
return float(np.mean(shuffled)), chronological, baseline
def temporal_inflation_over_constructions(constructions: int = 20, splits: int = 10):
"""The same comparison over many independently generated series.
One construction is an anecdote. Reporting the seed that gave the
largest gap would be the forking-paths problem in a lesson about not
doing that -- so this returns the whole distribution.
"""
rows = []
for seed in range(constructions):
X, y = regime_series(seed=seed)
shuffled, chronological, baseline = shuffled_vs_chronological(X, y, splits=splits)
rows.append((seed, shuffled, chronological, baseline, shuffled - chronological))
return rows
# --------------------------------------------------------------------------
# 5. One holdout, or many folds
# --------------------------------------------------------------------------
def weak_signal_dataset(n: int = 400, seed: int = 21):
"""A real but modest relationship, so the estimate has something to vary around."""
rng = np.random.default_rng(seed)
X = rng.normal(size=(n, 4))
y = (X[:, 0] + X[:, 1] * 0.6 + rng.normal(size=n) * 1.2 > 0).astype(int)
return X, y
def holdout_vs_cross_validation(X, y, repeats: int = 200, test_size: float = 0.25, folds: int = 5):
"""The spread of a single-holdout estimate against a k-fold estimate.
Same data, same model. The only thing that changes between repeats is
which rows landed where.
"""
holdout = []
cross = []
for seed in range(repeats):
train, test = train_test_split(
np.arange(len(y)), test_size=test_size, random_state=seed, stratify=y
)
model = LogisticRegression(max_iter=1000).fit(X[train], y[train])
holdout.append(accuracy(y[test], model.predict(X[test])))
cross.append(
float(
np.mean(
cross_val_score(
LogisticRegression(max_iter=1000),
X,
y,
cv=StratifiedKFold(folds, shuffle=True, random_state=seed),
)
)
)
)
return holdout, cross
# --------------------------------------------------------------------------
# 6. How big does the test set need to be?
# --------------------------------------------------------------------------
def test_size_table(sizes, p: float = 0.85, draws: int = 20000, seed: int = 33):
"""Predicted and measured standard error of an accuracy, by test-set size.
Rows are ``(n, theoretical_se, measured_sd, half_width_of_95_interval)``.
The theory is Day 117's; this is it arriving where the decisions are.
"""
rows = []
for n in sizes:
theory = proportion_standard_error(p, n)
sample = np.random.default_rng(seed).binomial(n, p, size=draws) / n
rows.append((n, round(theory, 4), round(float(sample.std()), 4), round(1.96 * theory, 4)))
return rows
def rows_needed_for_precision(p: float, half_width: float) -> int:
"""Smallest test set whose 95 percent interval is no wider than requested."""
n = int(np.ceil(p * (1.0 - p) * (1.96 / half_width) ** 2))
return n
# --------------------------------------------------------------------------
# 7. The rule, made checkable
# --------------------------------------------------------------------------
class TestSetTouchedTwice(RuntimeError):
"""Raised when the test set is evaluated against more than once."""
class GatedTestSet:
"""A test set that permits exactly one evaluation, then refuses.
Not a substitute for discipline. It is the discipline made mechanical,
in the same spirit as Day 143's stage contract: a rule that lives in
code is a rule somebody can check.
"""
def __init__(self, X, y):
self._X = X
self._y = y
self.evaluations = 0
def evaluate(self, model) -> float:
if self.evaluations >= 1:
raise TestSetTouchedTwice(
"the test set has already been used once; any further score is a "
"validation score, not a test score"
)
self.evaluations += 1
return accuracy(self._y, model.predict(self._X))
starter/test_splits_claims.py (7820 bytes)
"""Fourteen exercises in what each way of splitting a dataset costs.
Read `00_brief.md` first. Each function below is a `pytest.skip` naming
exactly what to build and what to assert; replace the skip with real code.
`splits_lib.py` is complete -- it is the machinery, not the exercise.
Run this suite on its own:
.venv/bin/pytest starter -q
Never run `pytest starter examples` in one invocation: both directories
define modules with the same names and pytest aborts on the collision.
"""
import numpy as np # noqa: F401 (you will need it)
import pytest
from sklearn.linear_model import LogisticRegression # noqa: F401
import splits_lib as s # noqa: F401 (you will need it)
@pytest.fixture(scope="module")
def rare():
return s.rare_class_dataset()
@pytest.fixture(scope="module")
def grouped():
return s.grouped_dataset()
@pytest.fixture(scope="module")
def weak():
return s.weak_signal_dataset()
def test_01_picking_the_best_of_k_inflates_the_score_you_picked_it_by():
pytest.skip(
"Assert s.selection_bias_curve([1, 2, 5, 10, 25, 50, 100, 500, 1000]) "
"equals the nine rows in expected-output/measured-values.txt, from "
"(1, 0.4984, 0.5011, -0.0028) to (1000, 0.572, 0.4992, 0.0728). Then "
"assert the validation column is strictly increasing in K. Every "
"candidate is a coin flip with exactly zero skill, so all of that "
"climb is noise you selected."
)
def test_01b_the_test_set_is_the_control_and_stays_at_chance():
pytest.skip(
"For K in [1, 10, 100, 1000], assert every test score is within "
"0.005 of 0.5 and that the whole test column spans less than 0.005, "
"while the validation column climbs by more than 0.07. The test set "
"was never selected on, so it was never inflated -- and that is what "
"makes the validation column mean anything."
)
def test_01c_the_optimism_is_the_expected_maximum_of_k_noise_draws():
pytest.skip(
"Assert s.proportion_standard_error(0.5, 500) rounds to 0.0224. For "
"each K in [2, 5, 10, 25, 50, 100, 500, 1000], divide the optimism "
"by that standard error and assert it is within 0.2 of "
"s.expected_max_of_normals(k), and that s.sqrt_two_log_k(k) is "
"LARGER than the simulated expectation at every K. Then assert the "
"concrete case: at K=100 the measured optimism is 2.57 standard "
"errors, the simulated expectation is 2.50, and the textbook "
"approximation says 3.03."
)
def test_02_a_random_split_of_a_rare_class_sometimes_has_no_positives(rare):
pytest.skip(
"The population positive rate is 0.05. Assert s.split_positive_rates "
"gives random spread {'mean': 0.0504, 'sd': 0.0265, 'min': 0.0, "
"'max': 0.16} and stratified spread {'mean': 0.05, 'sd': 0.01, "
"'min': 0.04, 'max': 0.06}, and that exactly 21 of the 500 random "
"splits produced a test half with no positives at all. Recall is "
"undefined on those."
)
def test_02b_stratifying_shrinks_the_spread_without_changing_the_mean(rare):
pytest.skip(
"Assert the two means agree to within 0.001 while the stratified "
"standard deviation is smaller, with a ratio of exactly 2.65. Then "
"assert the random split's worst case is 3.2 times the population "
"positive rate. Stratifying does not change what you are estimating; "
"it changes how much the estimate wobbles."
)
def test_03_splitting_rows_when_the_unit_is_a_person_invents_fifty_six_points(grouped):
pytest.skip(
"Assert s.rowwise_vs_group_split gives 0.976 for the row-wise split "
"and 0.4112 for the group-aware one, a gap of 0.5648. Then assert "
"the group-aware score is below 0.5 and the row-wise one above it. "
"Each person's label is a coin flip, so there is nothing "
"generalisable here at all -- and a row-wise split reports 97.6 "
"percent."
)
def test_03b_every_single_person_appears_in_both_halves(grouped):
pytest.skip(
"Assert s.groups_shared_between_halves returns 50, that there are 50 "
"distinct people, and that the dataset has 1000 rows. With twenty "
"rows each, a random quarter cannot miss anybody -- which is the "
"mechanism behind exercise 3, stated as a count rather than as an "
"intuition."
)
def test_04_a_shuffled_split_beats_a_chronological_one_every_single_time():
pytest.skip(
"Call s.temporal_inflation_over_constructions(). Assert there are 20 "
"rows and that the inflation is positive in every single one. The "
"direction of this effect is universal; the next exercise is about "
"its size."
)
def test_04b_but_the_size_of_the_effect_varies_by_an_order_of_magnitude():
pytest.skip(
"Assert the means across 20 constructions are shuffled 0.5961, "
"chronological 0.5233 and baseline 0.5235, with inflation mean "
"0.0728, sd 0.0596, min 0.016 and max 0.2557 -- a factor of 16.0 "
"between smallest and largest. Assert the chronological score is "
"within 0.005 of the baseline while the shuffled one is above it. "
"Quoting the 0.2557 seed alone would be the forking-paths problem, "
"in a lab against it."
)
def test_05_one_holdout_swings_nineteen_points_on_identical_data(weak):
pytest.skip(
"Assert s.holdout_vs_cross_validation gives holdout spread {'mean': "
"0.7519, 'sd': 0.0381, 'min': 0.66, 'max': 0.85} and 5-fold spread "
"{'mean': 0.7546, 'sd': 0.0061, 'min': 0.7375, 'max': 0.77}. Assert "
"the holdout range is exactly 0.19 and the cross-validated range "
"0.0325. Same data, same model: only which rows landed where changed."
)
def test_05b_cross_validation_is_six_times_steadier_for_the_same_data(weak):
pytest.skip(
"Assert the ratio of the two standard deviations is 6.2344, and that "
"the two means agree to within 0.003. Cross-validation estimates the "
"same quantity; it just estimates it with far less noise, because "
"every row serves as test data exactly once."
)
def test_06_the_standard_error_formula_predicts_the_measured_spread():
pytest.skip(
"Assert s.test_size_table([50, 100, 200, 500, 1000, 5000]) matches "
"the captured rows, and that theory and measurement differ by at "
"most 0.0002 at every size. Then assert the scaling: quadrupling the "
"rows halves the error (200 against 50 gives a ratio of 0.5), and "
"5000 against 500 gives 0.31. This is Day 117's formula arriving "
"where the decisions are."
)
def test_06b_a_hundred_row_test_set_cannot_resolve_a_five_point_difference():
pytest.skip(
"Assert the 95 percent half-width at n=100 is 0.07, which is wider "
"than 0.05 -- so two models five points apart are indistinguishable "
"on it. Then assert s.rows_needed_for_precision(0.85, 0.02) is 1225 "
"and (0.85, 0.01) is 4899. Decide how big your test set must be "
"BEFORE you split, from the difference you need to detect."
)
def test_07_the_test_set_permits_exactly_one_evaluation(weak):
pytest.skip(
"Fit LogisticRegression(max_iter=1000) on the weak-signal data, wrap "
"the data in s.GatedTestSet, and assert the first evaluation is "
"0.7575 and the counter becomes 1. Then assert a second evaluation "
"raises s.TestSetTouchedTwice with a message mentioning 'validation "
"score', and that the counter did NOT advance on the refused "
"attempt. The gate is not a substitute for discipline; it is the "
"discipline made mechanical."
)
starter/test_splits_lib.py (2401 bytes)
"""Machinery checks: the helpers behave, before any claim is made.
These four tests are solved in both `starter/` and `examples/`. They exist
so that a broken helper reports itself as a broken helper rather than as a
surprising scientific result.
"""
import numpy as np
import pytest
import splits_lib as s
def test_the_standard_error_formula_behaves_as_it_should():
# Maximal at p = 0.5, and shrinking like one over root n.
assert s.proportion_standard_error(0.5, 100) > s.proportion_standard_error(0.85, 100)
assert s.proportion_standard_error(0.5, 100) > s.proportion_standard_error(0.5, 400)
quartered = s.proportion_standard_error(0.5, 400) / s.proportion_standard_error(0.5, 100)
assert round(quartered, 4) == 0.5
# A certain outcome has no sampling error at all.
assert s.proportion_standard_error(1.0, 100) == 0.0
def test_the_grouped_dataset_really_is_grouped():
X, y, groups = s.grouped_dataset(n_people=10, rows_each=5, seed=1)
assert X.shape == (50, 4) and y.shape == (50,) and groups.shape == (50,)
assert len(set(groups.tolist())) == 10
# Every row belonging to one person carries that person's single label.
for person in range(10):
member_labels = set(y[groups == person].tolist())
assert len(member_labels) == 1
def test_the_regime_series_really_changes_its_rule():
X, y = s.regime_series(length=600, n_regimes=3, seed=2)
assert X.shape == (600, 4) and y.shape == (600,)
# Each regime is internally consistent but the regimes disagree: a
# linear model fitted on one block should do worse on another.
from sklearn.linear_model import LogisticRegression
first = LogisticRegression(max_iter=1000).fit(X[:200], y[:200])
own = s.accuracy(y[:200], first.predict(X[:200]))
other = s.accuracy(y[400:], first.predict(X[400:]))
assert own > 0.9
assert other < own - 0.2
def test_the_gated_test_set_counts_and_refuses():
class AlwaysZero:
def predict(self, X):
return np.zeros(len(X), dtype=int)
y = np.array([0, 0, 0, 1])
gate = s.GatedTestSet(np.zeros((4, 2)), y)
assert gate.evaluate(AlwaysZero()) == 0.75
with pytest.raises(s.TestSetTouchedTwice):
gate.evaluate(AlwaysZero())
# A fresh gate is a fresh budget; the class holds no global state.
assert s.GatedTestSet(np.zeros((4, 2)), y).evaluate(AlwaysZero()) == 0.75
tests/run_tests.sh (12518 bytes)
#!/usr/bin/env bash
# Day 144 lab harness: "Three Sets, and Why"
#
# Prints "N checks, M failure(s)" and exits 0 only when M is zero.
set -u
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$LAB_DIR"
PYTHON="${PYTHON:-.venv/bin/python3}"
PYTEST="${PYTEST:-.venv/bin/pytest}"
# Clear caches at the START so the final cleanliness check measures what
# THIS run left behind, not what a previous `pytest starter -q` left.
find . -path ./.venv -prune -o -type d -name '__pycache__' -exec rm -rf -- {} + 2>/dev/null
rm -rf .pytest_cache
CHECKS=0
FAILURES=0
ok() {
CHECKS=$((CHECKS + 1))
echo " ok: $1"
}
fail() {
CHECKS=$((CHECKS + 1))
FAILURES=$((FAILURES + 1))
echo " FAIL: $1"
}
if [ ! -x "$PYTHON" ]; then
echo "No lab .venv found at $PYTHON."
echo "Run: python3 -m venv .venv && .venv/bin/pip install -r requirements/requirements.txt"
exit 2
fi
echo "1. Installed versions match requirements/requirements.txt"
VERSION_CHECK=$("$PYTHON" - <<'PYEOF'
import numpy, sklearn, pytest
print("numpy", numpy.__version__)
print("scikit-learn", sklearn.__version__)
print("pytest", pytest.__version__)
PYEOF
)
echo "$VERSION_CHECK" | sed 's/^/ /'
while read -r pkg pin; do
pin_version="${pin#*==}"
installed=$(echo "$VERSION_CHECK" | awk -v p="$pkg" '$1==p {print $2}')
if [ "$installed" = "$pin_version" ]; then
ok "$pkg $installed matches the pin"
else
fail "$pkg installed=$installed pinned=$pin_version"
fi
done < <(sed 's/==/ ==/' requirements/requirements.txt)
echo ""
echo "2. Every published claim, reproduced directly (no pytest involved)"
DIRECT_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
from sklearn.linear_model import LogisticRegression
import splits_lib as s
errors = []
def expect(label, got, want):
if got != want:
errors.append(f"{label}: expected {want}, got {got}")
# 1. Selection bias
rows = s.selection_bias_curve([1, 2, 5, 10, 25, 50, 100, 500, 1000])
expect(
"selection bias curve",
rows,
[
(1, 0.4984, 0.5011, -0.0028),
(2, 0.5115, 0.4992, 0.0123),
(5, 0.5256, 0.5005, 0.0251),
(10, 0.5331, 0.4999, 0.0332),
(25, 0.5436, 0.5009, 0.0427),
(50, 0.5508, 0.5014, 0.0493),
(100, 0.5567, 0.4992, 0.0575),
(500, 0.5682, 0.4978, 0.0704),
(1000, 0.572, 0.4992, 0.0728),
],
)
validation = [v for _k, v, _t, _o in rows]
if not all(a < b for a, b in zip(validation, validation[1:])):
errors.append("the validation column was not strictly increasing in K")
test_column = [t for _k, _v, t, _o in rows]
if max(test_column) - min(test_column) >= 0.005:
errors.append("the test column moved, which it must not")
for score in test_column:
if abs(score - 0.5) >= 0.005:
errors.append(f"a test score drifted from chance: {score}")
# 1b. The optimism equals the expected maximum
standard_error = s.proportion_standard_error(0.5, 500)
expect("standard error at n=500", round(standard_error, 4), 0.0224)
for k, _v, _t, optimism in rows[1:]:
in_errors = optimism / standard_error
simulated = s.expected_max_of_normals(k)
if abs(in_errors - simulated) >= 0.2:
errors.append(f"K={k}: optimism {in_errors:.2f} SEs vs expected max {simulated:.2f}")
if s.sqrt_two_log_k(k) <= simulated:
errors.append(f"K={k}: sqrt(2 ln K) did not overestimate the expected maximum")
expect("expected max at K=100", round(s.expected_max_of_normals(100), 2), 2.5)
expect("sqrt(2 ln 100)", round(s.sqrt_two_log_k(100), 2), 3.03)
# 2. Stratification
X, y = s.rare_class_dataset()
expect("population positive rate", round(float(y.mean()), 4), 0.05)
random_rates, stratified_rates, empty = s.split_positive_rates(X, y)
expect(
"random split spread",
s.spread(random_rates),
{"mean": 0.0504, "sd": 0.0265, "min": 0.0, "max": 0.16},
)
expect(
"stratified split spread",
s.spread(stratified_rates),
{"mean": 0.05, "sd": 0.01, "min": 0.04, "max": 0.06},
)
expect("random splits with no positives", empty, 21)
# 3. Groups
Xg, yg, groups = s.grouped_dataset()
rowwise, group_aware = s.rowwise_vs_group_split(Xg, yg, groups)
expect("row-wise split", round(rowwise, 4), 0.976)
expect("group-aware split", round(group_aware, 4), 0.4112)
expect("accuracy invented by ignoring groups", round(rowwise - group_aware, 4), 0.5648)
expect("people in both halves", s.groups_shared_between_halves(groups), 50)
if not (group_aware < 0.5 < rowwise):
errors.append("the group-aware score was not below chance while the row-wise one was above it")
# 4. Time
temporal = s.temporal_inflation_over_constructions()
inflation = [gap for _s, _sh, _ch, _b, gap in temporal]
expect("constructions", len(temporal), 20)
expect("constructions where shuffling won", sum(1 for g in inflation if g > 0), 20)
expect("shuffled mean", round(float(np.mean([r[1] for r in temporal])), 4), 0.5961)
expect("chronological mean", round(float(np.mean([r[2] for r in temporal])), 4), 0.5233)
expect("baseline mean", round(float(np.mean([r[3] for r in temporal])), 4), 0.5235)
expect("inflation mean", round(float(np.mean(inflation)), 4), 0.0728)
expect("inflation sd", round(float(np.std(inflation)), 4), 0.0596)
expect("smallest inflation", round(min(inflation), 4), 0.016)
expect("largest inflation", round(max(inflation), 4), 0.2557)
expect("ratio of largest to smallest", round(max(inflation) / min(inflation), 1), 16.0)
# 5. Holdout versus cross-validation
Xw, yw = s.weak_signal_dataset()
holdout, cross = s.holdout_vs_cross_validation(Xw, yw)
expect(
"holdout spread",
s.spread(holdout),
{"mean": 0.7519, "sd": 0.0381, "min": 0.66, "max": 0.85},
)
expect(
"cross-validated spread",
s.spread(cross),
{"mean": 0.7546, "sd": 0.0061, "min": 0.7375, "max": 0.77},
)
expect("holdout range", round(max(holdout) - min(holdout), 4), 0.19)
expect("steadiness ratio", round(float(np.std(holdout) / np.std(cross)), 4), 6.2344)
# 6. Test-set size
expect(
"test size table",
s.test_size_table([50, 100, 200, 500, 1000, 5000]),
[
(50, 0.0505, 0.0505, 0.099),
(100, 0.0357, 0.0357, 0.07),
(200, 0.0252, 0.0254, 0.0495),
(500, 0.016, 0.016, 0.0313),
(1000, 0.0113, 0.0112, 0.0221),
(5000, 0.005, 0.0051, 0.0099),
],
)
expect("rows for +/-0.02", s.rows_needed_for_precision(0.85, 0.02), 1225)
expect("rows for +/-0.01", s.rows_needed_for_precision(0.85, 0.01), 4899)
# 7. The gate
model = LogisticRegression(max_iter=1000).fit(Xw, yw)
gate = s.GatedTestSet(Xw, yw)
expect("first evaluation", round(gate.evaluate(model), 4), 0.7575)
expect("evaluation counter", gate.evaluations, 1)
try:
gate.evaluate(model)
except s.TestSetTouchedTwice as exc:
if "validation score" not in str(exc):
errors.append(f"the gate's message did not explain itself: {exc}")
if gate.evaluations != 1:
errors.append("the counter advanced on a refused evaluation")
else:
errors.append("the gate permitted a second evaluation, which it must not")
if errors:
for e in errors:
print("ERROR:", e)
sys.exit(1)
print("all direct checks passed")
PYEOF
)
if echo "$DIRECT_CHECK" | grep -q "all direct checks passed"; then
ok "exercises 1-7 reproduced directly against splits_lib, no pytest involved"
else
fail "direct library checks failed"
echo "$DIRECT_CHECK" | sed 's/^/ /'
fi
echo ""
echo "3. examples/ passes in full"
EXAMPLES_OUT=$("$PYTEST" examples -q 2>&1)
if echo "$EXAMPLES_OUT" | tail -1 | grep -qE "^18 passed"; then
ok "pytest examples -q -> 18 passed"
else
fail "pytest examples -q did not report 18 passed"
echo "$EXAMPLES_OUT" | tail -20 | sed 's/^/ /'
fi
echo ""
echo "4. starter/ is an untouched skeleton"
STARTER_OUT=$("$PYTEST" starter -q 2>&1)
if echo "$STARTER_OUT" | tail -1 | grep -qE "4 passed, 14 skipped"; then
ok "pytest starter -q -> 4 passed, 14 skipped (the machinery checks pass; the fourteen exercises are stubs)"
else
fail "pytest starter -q did not report 4 passed, 14 skipped"
echo "$STARTER_OUT" | tail -20 | sed 's/^/ /'
fi
echo ""
echo "5. pytest examples starter (one invocation) aborts on the module-name collision"
COMBINED_OUT=$("$PYTEST" examples starter 2>&1)
if echo "$COMBINED_OUT" | grep -q "import file mismatch"; then
ok "combined invocation reports import file mismatch, as documented -- never run starter and examples together"
else
fail "combined invocation did not fail with import file mismatch as expected"
fi
echo ""
echo "6. The report reproduces the captured table exactly"
REPORT_OUT=$("$PYTHON" examples/report_measurements.py 2>&1)
if [ "$REPORT_OUT" = "$(cat expected-output/measured-values.txt)" ]; then
ok "report_measurements.py output is byte-identical to expected-output/measured-values.txt"
else
fail "report_measurements.py drifted from expected-output/measured-values.txt"
echo "$REPORT_OUT" | diff - expected-output/measured-values.txt | head -20 | sed 's/^/ /'
fi
echo ""
echo "7. Proof the harness can fail"
SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/d144-scratch.XXXXXX")
cp examples/*.py "$SCRATCH"/
SCRATCH_OUT=$("$PYTEST" "$SCRATCH" -q 2>&1)
if echo "$SCRATCH_OUT" | tail -1 | grep -qE "^18 passed"; then
ok "scratch copy of examples/ passes before it is broken"
else
fail "scratch copy did not pass before being broken: $(echo "$SCRATCH_OUT" | tail -3)"
fi
"$PYTHON" - "$SCRATCH/test_splits_claims.py" <<'PYEOF'
import sys
path = sys.argv[1]
text = open(path).read()
needle = "assert round(rowwise - group_aware, 4) == 0.5648"
replacement = "assert round(rowwise - group_aware, 4) == 0.0"
assert needle in text, "could not find the assertion to break"
open(path, "w").write(text.replace(needle, replacement, 1))
PYEOF
BROKEN_OUT=$("$PYTEST" "$SCRATCH" -q 2>&1)
BROKEN_STATUS=$?
if [ "$BROKEN_STATUS" -ne 0 ] && echo "$BROKEN_OUT" | grep -q "test_03_splitting_rows_when_the_unit_is_a_person_invents_fifty_six_points"; then
ok "breaking exercise 3's assertion produces a non-zero exit and names the failing test"
else
fail "broken copy did not fail as expected (exit=$BROKEN_STATUS)"
fi
rm -rf "$SCRATCH"
echo ""
echo "8. The direction of every split result holds beyond the quoted seed"
DIRECTION=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import splits_lib as s
problems = []
# Group leakage is not a property of one dataset seed.
for seed in range(5):
X, y, groups = s.grouped_dataset(n_people=40, rows_each=15, seed=seed)
rowwise, grouped = s.rowwise_vs_group_split(X, y, groups, splits=5)
if rowwise <= grouped:
problems.append(f"seed {seed}: row-wise {rowwise:.4f} did not beat group-aware {grouped:.4f}")
# Selection optimism is not a property of one replication count.
short = s.selection_bias_curve([1, 100], replications=120)
if short[1][1] <= short[0][1]:
problems.append("selection optimism vanished at a different replication count")
# Stratification always narrows the spread.
for seed in (144, 145, 146):
X, y = s.rare_class_dataset(seed=seed)
r, st, _empty = s.split_positive_rates(X, y, splits=200)
if s.spread(st)["sd"] >= s.spread(r)["sd"]:
problems.append(f"seed {seed}: stratifying did not narrow the spread")
if problems:
for p in problems:
print("ERROR:", p)
else:
print("every direction held")
PYEOF
)
if [ "$DIRECTION" = "every direction held" ]; then
ok "group leakage, selection optimism and stratification hold at seeds the lesson does not quote"
else
fail "a direction failed beyond the quoted seed"
echo "$DIRECTION" | sed 's/^/ /'
fi
echo ""
echo "9. Offline, and nothing left behind"
if ! grep -rInE "https?://" examples/*.py starter/*.py > /dev/null 2>&1; then
ok "no URLs inside examples/ or starter/ source -- this lab reaches no network"
else
fail "found a URL inside examples/ or starter/"
fi
if [ -z "$(find . -path ./.venv -prune -o -type d -name '__pycache__' -print 2>/dev/null)" ]; then
ok "no __pycache__ left behind"
else
find . -path ./.venv -prune -o -type d -name '__pycache__' -exec rm -rf -- {} + 2>/dev/null
ok "no __pycache__ left behind (cleaned during this run)"
fi
if [ ! -d .pytest_cache ]; then
ok "no .pytest_cache left behind"
else
rm -rf .pytest_cache
ok "no .pytest_cache left behind (cleaned during this run)"
fi
echo ""
echo "---------------------------------------------------------------"
echo "$CHECKS checks, $FAILURES failure(s)"
if [ "$FAILURES" -ne 0 ]; then
exit 1
fi
exit 0
Troubleshooting
Troubleshooting
No lab .venv found at .venv/bin/python3
The harness will not run against whatever Python is on your PATH,
because every number here is pinned to exact package versions. Build the
environment first:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
If you deliberately want a different interpreter, the harness honours
PYTHON and PYTEST:
PYTHON=/path/to/python3 PYTEST=/path/to/pytest bash tests/run_tests.sh
Expect version-check failures if those do not match the pins. That is the harness working, not the harness breaking.
import file mismatch when running pytest
You ran pytest examples starter in one invocation. Both directories
contain modules with the same names, so pytest cannot decide which
splits_lib a test meant. Run them separately:
.venv/bin/pytest examples -q
.venv/bin/pytest starter -q
Check 5 of the harness deliberately asserts that the combined invocation fails, so this is documented behaviour rather than a surprise.
The harness takes a while
It does. The selection-bias curve averages 400 replications at nine values of K, and the holdout comparison fits 200 logistic regressions plus 200 five-fold cross-validations. On the capture machine the whole harness runs in well under a minute; on a slower one it will take several.
No timing is asserted anywhere, so a slow machine changes nothing about
whether it passes. If you want a faster loop while developing, call the
library functions directly with a smaller replications or repeats
argument — both are parameters — and put them back before running the
harness.
My group-aware score is below 0.5 and that looks broken
It is not, and exercise 3 asserts it. Each person's label in
grouped_dataset is a coin flip, so there is genuinely nothing to learn
about a person you have not met. About twelve people land in the
group-aware test half, each contributing twenty identical labels, so the
estimate is effectively a dozen coin flips and it wanders.
The structural claim the lab makes is the one worth defending: the group-aware score sits below 0.5 while the row-wise score sits far above it. If those have swapped, something is genuinely wrong.
My temporal numbers differ from the lesson's
Almost certainly fine, and the lesson says so at length. The effect varies by a factor of sixteen across constructions — +0.016 to +0.2557 — which is why exercise 4 asserts only the direction and exercise 4b asserts the whole distribution rather than a single figure.
What must hold on any version is that shuffling beats chronology in every construction. If it has stopped doing that, investigate properly rather than adjusting the assertion.
sqrt(2 ln K) does not match the measured optimism
Correct, and asserted. It is an asymptotic approximation to the expected maximum of K normals, and it is loose at every K you will actually use. At K=100 it says 3.03 standard errors where the simulated expectation is 2.50 and the measurement is 2.57.
The lab asserts that the approximation exceeds the simulated expectation, not that it equals the measurement. Treating the closed form as the truth here would be quoting a formula over a measurement, which is the one thing this course does not do.
The selection-bias numbers move on my machine
Read expected-output/FIELDS.md. Every figure in that curve is an average
over seeded draws from numpy.random.default_rng, and NumPy's
documentation is explicit that Generator gives no stream-compatibility
guarantee between versions.
What must hold anywhere: the validation column increases in K, the test column does not move, and the test column sits at chance. Harness check 8 confirms the selection optimism survives a different replication count, so it is not an artefact of the 400.
LogisticRegression warns about convergence
max_iter=1000 is set everywhere in this lab specifically to avoid this.
If you construct your own with the default of 100 you may see a
ConvergenceWarning and slightly different scores. Match the library's
settings, or use its helpers directly.
Security notes
Security notes
What this lab touches
Nothing outside its own directory, and nothing outside your machine.
- Filesystem. The lab reads only files inside its own directory. The
one write outside it is check 7 of the harness, which creates a scratch
directory with
mktemp -dunder$TMPDIR, copiesexamples/*.pyinto it, deliberately breaks one assertion to prove the harness can fail, and removes the directory again in the same run. Nothing is written to your home directory, nothing above the lab root is modified, and no system path is touched. - Network. After the one
pip install, this lab is completely offline. Check 9 asserts that no URL appears anywhere inexamples/orstarter/source. Every dataset here is generated on the spot from a seedednumpy.random.default_rng; nothing is downloaded and no dataset is bundled. - Credentials. There are none.
requires_api_keyisfalse, no account is needed, and nothing in this lab reads an environment variable that could hold a secret. - Privileges. Nothing here needs
sudo. If a step appears to ask for administrator rights, stop and re-read it — it is not this lab. - Reversibility. Everything this lab creates is inside its own
directory and is removed by the cleanup commands in
metadata.yml.rm -rf .venvreturns the machine to exactly its prior state.
The one install step, and how to check it
pip install -r requirements/requirements.txt downloads three packages
from the Python Package Index into a lab-local virtual environment,
never into your system Python. Pinning exact versions is a security
control as well as a reproducibility one: an unpinned install resolves to
whatever is newest at the moment you run it, which is a moving target you
have not reviewed.
If you want to verify what you are installing before you install it, pip can check hashes for you:
.venv/bin/pip install --require-hashes -r requirements/requirements.txt
That requires a hash-annotated requirements file, which this lab does not
ship because the correct hashes differ per platform wheel. Generating one
for your own platform with pip-compile --generate-hashes is a reasonable
habit for any environment you care about.
The security idea in this lab
The GatedTestSet in exercise 7 is worth reading as an access-control
pattern rather than only as a teaching device.
It holds data, permits exactly one read, counts the reads, and refuses the second with a message explaining what the refused answer would actually have been. That is a budget enforced by the resource itself rather than by the good intentions of whoever holds it — the same shape as a one-time token, a single-use signed URL, or a rate limiter.
The design detail worth copying is that the counter does not advance on a refused attempt. A gate whose refusals consume budget can be drained by an attacker who never succeeds at anything, and the lab asserts the correct behaviour explicitly.
The wider point connects to the whole lesson. A test set is a non-renewable resource: its value comes entirely from never having influenced anything. Every look spends some of it, and the spending is invisible in the result — which is exactly the property that makes it a control worth enforcing mechanically.
What the code does that is worth understanding
- Every dataset generator takes a seed and returns fresh arrays. Nothing is cached to disk, nothing is memoised across runs, and no global state carries between tests.
GatedTestSetholds no class-level state, so two gates are two independent budgets. The machinery test asserts this, because a gate that leaked state between instances would be a subtle and serious bug.- Nothing in this lab evaluates a string, imports dynamically, reads a path from data, or inspects the environment.
- The harness captures the exit status of
run_tests.shitself and never reads the status of a pipeline.cmd | tailreportstail's status, which is almost always zero — an always-passing test suite is a security control that has quietly stopped working.
Reporting a problem
If you find something in this lab that writes outside its own directory, reaches a network it did not start, or asks for a credential, that is a bug. Nothing here is supposed to do any of those things.