Machine Learning › Machine Learning Fundamentals › Day 147
Hands-on lab — Day 147: An End-to-End Classification Exercise
- ← Back to the Day 147 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-147-an-end-to-end-classification-exercise/
Commands
Setup
cd labs/sections/machine-learning/day-147-an-end-to-end-classification-exercise
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/classification_lib.py examples/report_measurements.py examples/test_classification_claims.py examples/test_classification_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/classification_lib.py starter/test_classification_claims.py starter/test_classification_lib.py tests/run_tests.sh troubleshooting.md
Lab README
Day 147 lab — One Classification Project, Run Properly
Lesson
- Lesson title: An End-to-End Classification Exercise
- Day number: 147 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-147-an-end-to-end-classification-exercise
- 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-147-an-end-to-end-classification-exercisewhen the site is running.
Purpose
Days 141 through 146 each isolated one discipline in a lab built to show it alone: what a score means, the three feedback shapes, the workflow's stage contract, the three sets and the selection optimism they exist to control, the bias/variance trade, and the scikit-learn estimator API.
This lab is every one of those disciplines, spent on one real dataset, in the order a working project actually uses them: frame, baseline, split, pipeline, cross-validate, select, one test evaluation, error analysis, an honest verdict with an interval.
The dataset is chosen by measuring, not by habit — iris and wine are
both tried first and both discarded, because both saturate near-perfect
accuracy on 30-36 test rows, too coarse for an honest interval:
| dataset | rows | features | classes | baseline | test rows |
|---|---|---|---|---|---|
| iris | 150 | 4 | 3 | 0.3333 | 30 |
| wine | 178 | 13 | 3 | 0.3889 | 36 |
| breast_cancer | 569 | 30 | 2 | 0.6316 | 114 |
Using breast_cancer: 36 candidate pipelines are cross-validated on train
rows only, the winner (LogisticRegression(C=1), cv accuracy 0.9780) is
evaluated exactly once on test (0.9825), and Day 144's selection-optimism
formula predicts an inflation of 0.0326 for that sweep — while the
measured drop over 20 seeds averages −0.0001, because the formula
assumes independent zero-skill candidates and these 36 are neither
independent nor skill-free. A separate leaky version, which selects by
scoring all 36 candidates directly against the test set, never scores
worse than the honest one across 20 seeds, by a mean gap of +0.0096.
Learning objectives
By the end of this lab you will be able to:
- Choose a dataset for a classification project by measuring headroom, not by picking whichever one is most familiar.
- Establish a majority-class baseline before fitting any model.
- Build a real train/test split and hold the test rows back until one evaluation, using the discipline Days 143-144 built.
- Sweep a genuine set of candidate pipelines with scikit-learn's
Pipeline, and count K rather than losing track of it. - Select a winner using cross-validation on training rows only, never on test rows.
- Enforce a one-evaluation budget on a test set mechanically.
- Compute the selection optimism Day 144's formula predicts for a real sweep, and explain honestly where that prediction fails.
- Read a confusion matrix for the specific mistakes it reveals, not only for the accuracy it summarises.
- State a verdict with a 95 percent interval, and judge whether an improvement is distinguishable from a baseline at the test-set size you actually have.
- Reproduce, and recognise, the mistake of selecting a model by scoring every candidate directly against the test set.
Prerequisites
- Day 141 for what a score means, Day 142 for the winner's curse, Day 143
for stage ordering, Day 144 for the three sets and selection optimism,
Day 145 for overfitting and underfitting, and Day 146 for the
scikit-learn estimator API —
fit,predict,Pipeline. This lab uses every one of them and teaches none of them again. - 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 the two 20-seed sweeps in exercises 7b and 10b, each cross-validating 36 candidates 20 times over; on the capture machine the full harness completes in well under a minute. 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.
- The dataset,
sklearn.datasets.load_breast_cancer, ships inside the scikit-learn package itself; nothing is downloaded and no dataset licence beyond scikit-learn's own applies to your use of this lab.
The estimators used here — LogisticRegression, KNeighborsClassifier,
DecisionTreeClassifier, DummyClassifier — and the selection machinery
— Pipeline, StratifiedKFold, cross_val_score, train_test_split —
are all part of scikit-learn.
Installation
From the repository root:
cd labs/sections/machine-learning/day-147-an-end-to-end-classification-exercise
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-147-an-end-to-end-classification-exercise/
├── 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
│ ├── classification_lib.py complete machinery — not the exercise
│ ├── test_classification_lib.py four machinery checks, already solved
│ └── test_classification_claims.py fourteen exercises, each a skip to replace
├── examples/
│ ├── classification_lib.py identical to the starter copy
│ ├── test_classification_lib.py the same four machinery checks
│ ├── test_classification_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/classification_lib.py and examples/classification_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 the whole project |
.venv/bin/python3 examples/report_measurements.py |
Recomputes every published number and prints them as one table |
bash tests/run_tests.sh |
Fifteen checks: version pins, every claim reproduced without pytest, both suites, the collision, a byte-comparison of the report, the one-evaluation guarantee, a deliberate self-break, an unquoted-seed re-check, and cleanliness |
Expected output
bash tests/run_tests.sh ends with:
---------------------------------------------------------------
15 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 dataset's
shape, the standard-error formula, the leaky gap never going negative —
from what holds only under the pinned versions, which is most of the
decimals.
Validation steps
bash tests/run_tests.sh; echo "exit=$?"→15 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_classification_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 fifteen checks are:
1-3. The installed numpy, scikit-learn and pytest match the pins exactly.
4. Every published claim reproduced directly against classification_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. GatedTestSet permits exactly one evaluation, then refuses five
further attempts in a row without ever advancing its counter.
10-11. A scratch copy of examples/ passes, then fails with a non-zero
exit and the failing test named after one assertion is deliberately
rewritten.
12. The leaky-gap direction and the selection mechanics are re-confirmed
at seeds this lab never quotes, so no directional claim rests on a
single lucky seed.
13-15. 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 the cleanliness checks measure 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, a
winning configuration that differs from the lesson's, the predicted
optimism not matching the measured drop, leaky-gap numbers that differ
from the lesson's, LogisticRegression convergence warnings, and sampled
figures moving with the NumPy pin.
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, and shows explicitly that a budget enforced
only at one call site — as opposed to on the resource itself — can be
bypassed, which exercise 10's leaky search deliberately does.
Extension exercises
- Nested cross-validation. Implement an inner loop that selects among the 36 candidates and an outer loop that scores the winner, so the outer score is never contaminated by the selection. Measure whether the gap between cv_mean and test accuracy shrinks further, and report the cost in fits.
- A fourth family. Add support-vector classifiers to
candidate_configs, re-run the sweep, and report whether the winner or its cross-validated accuracy changes at seed 0. - Cost-sensitive error analysis. Exercise 8 counts false negatives and false positives with equal weight. Assign a cost to each — say, ten times worse for a missed malignancy — and find whether a different candidate in the sweep would have been preferred under that cost.
- Scale the test set. Repeat the leaky-gap comparison (exercise 10b) using a 40/60 train/test split instead of 80/20, so the test set has roughly 340 rows instead of 114. Report whether the mean gap changes and connect it to Day 144's test-sizing table.
- Break the independence assumption on purpose. Duplicate 10 percent of the rows into both the train and test splits before running the sweep, and measure how much the reported test accuracy inflates. This is Day 144's group-leakage lesson, reconstructed on real data.
- A stricter gate. Extend
GatedTestSetto log every attempted evaluation with a caller identifier, so a refused attempt leaves a trace, and use the log to show thatleaky_selection_test_score's accesses never go through the gate at all.
Navigation
- Lab brief:
starter/00_brief.md - Previous lab:
../day-146-your-first-model-with-scikit-learn/ - Week 21 — Machine Learning Fundamentals — ends here. Day 148 begins Week 22 (Regression) with linear regression.
- 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 9 of the harness confirms the directional
ones at seeds this lab does not quote.
- **The three datasets' shapes, class counts and majority baselines** in
exercise 1. `load_iris`, `load_wine` and `load_breast_cancer` are bundled
data, not samples — their row and feature counts do not depend on any
seed. The majority-class baseline at a fixed split is also deterministic
once the split itself is deterministic.
- **The standard-error formula in the verdict.** `sqrt(p(1-p)/n)` is
arithmetic, not a measurement.
- **Cross-validation selecting on train rows only, and never on test
rows.** Structural, by construction of `select_best`.
- **`TestSetTouchedTwice` on a second evaluation, and the counter not
advancing on a refused attempt.** Branching logic, asserted mechanically
by check 7 of the harness with five repeated refused attempts.
- **The leaky score never being lower than the honest score, at any
seed.** The leaky search considers the honest winner among its 36
candidates and can only replace it with something that scored at least
as well on the test rows it was allowed to peek at.
## Exact under these pins, and only these
Everything else depends on NumPy's `default_rng` and `RandomState` bit
streams (both are used, indirectly, through scikit-learn's
`random_state=` parameters) 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 |
| --- | --- | --- |
| `455` train rows, `114` test rows | 3 | the stratified 80/20 split at seed 0 |
| `0.6316` | 2 | majority-class baseline, test accuracy |
| `('logreg', 1)`, `0.978` | 5 | the winning configuration and its 5-fold CV accuracy |
| `0.9825` | 6 | the one permitted test evaluation |
| `0.0326` | 7 | the predicted selection optimism from Day 144's formula |
| `-0.0001` mean, `0.0149` sd, `0.033` mean predicted, `0.5` fraction positive | 7b | the 20-seed distribution of predicted versus measured optimism |
| `[[40, 2], [0, 72]]`, `2` false negatives, `0` false positives | 8 | the confusion matrix |
| `0.0123` se, `0.0241` half-width, `(0.9584, 1.0066)` | 9 | the verdict interval |
| `0.9825` leaky score at seed 0 | 10 | selecting by peeking at the test set |
| `0.0096` mean gap, `0.0103` sd, `0.0` min, `0.0351` max | 10b | the 20-seed leaky-gap distribution |
## Sampled, and therefore soft even here
- **The predicted-vs-measured optimism comparison in exercise 7b is
averaged over 20 seeds**, for the reason Day 144 gave for averaging over
400 replications: one draw of a noisy quantity is an anecdote. At any
single seed the measured drop can be positive or negative — it was
negative at the headline seed (−0.0045) and the 20-seed mean is close to
zero (−0.0001), while the naive prediction is a consistent 0.03-ish
overestimate at every seed tried.
- **The leaky-gap distribution in exercise 10b is likewise averaged over 20
seeds.** At any one seed the gap can be exactly zero — a ceiling effect,
because 114 test rows only support accuracy in steps of about 0.0088,
and the honest and leaky searches sometimes land on the same
configuration outright. The structural claim that survives every seed
is the direction: never negative.
- **The winning configuration itself, `LogisticRegression(C=1)`, is a
property of seed 0.** At other seeds in the 20-seed sweep the winner is
sometimes `knn` at a different `k`, always drawn from the same 36
candidates; the harness does not assert the same winner across seeds
because Day 145 already established that near-tied configurations trade
places under resampling.
## Timings
No timing is asserted anywhere in this lab. On the capture machine, one
seed's frame-to-verdict pipeline (baseline, sweep, cross-validate, select,
one test evaluation) completed in roughly 0.32 to 0.38 seconds across three
repeated measurements; the 20-seed comparisons in exercises 7b and 10b,
which each cross-validate all 36 candidates 20 times over, take roughly 7
to 9 seconds apiece, and the full `report_measurements.py` run — which
performs both 20-seed sweeps — completes in well under 30 seconds. All of
this runs on the CPU; no GPU is present, needed, or used.
examples-run.txt
.................. [100%]
18 passed in 20.27s
measured-values.txt
Day 147 -- an end-to-end classification exercise, measured
===========================================================
1. Choosing the dataset, by measuring
-------------------------------------
name n_samples n_features n_classes baseline n_test
iris 150 4 3 0.3333 30
wine 178 13 3 0.3889 36
breast_cancer 569 30 2 0.6316 114
iris and wine saturate near-perfect accuracy on 30-36 test rows;
breast_cancer is chosen: a non-trivial baseline, 114 test rows, room for an interval
chosen: breast_cancer, 569 rows, 30 features, classes ['malignant', 'benign']
split: train=455 test=114 (stratified, seed 0)
2. The frame and the baseline
-----------------------------
majority-class baseline, test accuracy: 0.6316
3. The sweep: cross-validate every candidate on train rows only
---------------------------------------------------------------
K = 36 candidate pipelines: 15 KNN, 11 logistic regression, 10 decision trees
winner: logreg (1) 5-fold CV accuracy = 0.9780
4. ONE test evaluation
----------------------
test accuracy: 0.9825
cv - test (the drop): -0.0045
second evaluation : TestSetTouchedTwice
the test set has already been used once; any further score is a validation score, not a test score
5. The predicted optimism, from Day 144's formula
-------------------------------------------------
predicted optimism (SE of a CV fold x expected max of 36 normals): 0.0326
measured drop at this seed: -0.0045
one seed is an anecdote -- the distribution below is the honest reading
5b. Predicted vs measured, over 20 seeds
----------------------------------------
seed cv_mean test_acc drop predicted
0 0.9780 0.9825 -0.0045 0.0326
1 0.9758 0.9912 -0.0154 0.0341
2 0.9714 0.9825 -0.0111 0.0370
3 0.9780 0.9737 +0.0043 0.0326
4 0.9824 0.9737 +0.0087 0.0292
5 0.9758 0.9825 -0.0067 0.0341
6 0.9780 0.9912 -0.0132 0.0326
7 0.9868 0.9649 +0.0219 0.0253
8 0.9758 0.9825 -0.0067 0.0341
9 0.9692 1.0000 -0.0308 0.0384
10 0.9780 0.9649 +0.0131 0.0326
11 0.9846 0.9561 +0.0285 0.0273
12 0.9736 0.9912 -0.0176 0.0356
13 0.9758 0.9737 +0.0021 0.0341
14 0.9758 0.9912 -0.0154 0.0341
15 0.9758 0.9737 +0.0021 0.0341
16 0.9714 0.9561 +0.0153 0.0370
17 0.9758 0.9825 -0.0067 0.0341
18 0.9802 0.9649 +0.0153 0.0309
19 0.9802 0.9649 +0.0153 0.0309
mean measured drop: -0.0001 sd 0.0149
mean predicted optimism: 0.0330
fraction of seeds where the drop was positive: 0.5000
the formula assumed independent, zero-skill candidates; these 36 are correlated
and genuinely skilled, so the naive prediction overestimates the real optimism here
6. Error analysis
-----------------
confusion matrix (rows=true, cols=predicted), labels ['malignant', 'benign']
[40, 2]
[0, 72]
false negatives (malignant predicted benign): 2
false positives (benign predicted malignant): 0
7. The verdict, with an interval
--------------------------------
n_test = 114 se = 0.0123 95 percent half-width = +/-0.0241
95 percent interval: [0.9584, 1.0066]
improvement over baseline: +0.3509
distinguishable from baseline at this test-set size: True
8. The leaky version: selecting by peeking at the test set
----------------------------------------------------------
honest (select on CV, look once): 0.9825
leaky (best of 36 scored directly on test): 0.9825
gap: +0.0000
8b. The leaky gap, over 20 seeds
--------------------------------
seed honest leaky gap
0 0.9825 0.9825 +0.0000
1 0.9912 0.9912 +0.0000
2 0.9825 0.9825 +0.0000
3 0.9737 0.9912 +0.0175
4 0.9737 0.9825 +0.0088
5 0.9825 0.9912 +0.0087
6 0.9912 0.9912 +0.0000
7 0.9649 0.9825 +0.0176
8 0.9825 0.9825 +0.0000
9 1.0000 1.0000 +0.0000
10 0.9649 0.9912 +0.0263
11 0.9561 0.9649 +0.0088
12 0.9912 0.9912 +0.0000
13 0.9737 0.9825 +0.0088
14 0.9912 0.9912 +0.0000
15 0.9737 0.9912 +0.0175
16 0.9561 0.9912 +0.0351
17 0.9825 0.9912 +0.0087
18 0.9649 0.9737 +0.0088
19 0.9649 0.9912 +0.0263
mean gap: +0.0096 sd 0.0103 min +0.0000 max +0.0351
fraction of seeds where the leak was non-negative: 1.0000
9. What the whole thing costs
-----------------------------
wall-clock cost is machine-dependent and not reproduced here byte for byte;
see metadata.yml and expected-output/FIELDS.md for the captured timing
starter-run.txt
ssssssssssssss.... [100%]
4 passed, 14 skipped in 0.83s
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-10 reproduced directly against classification_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. The test set is evaluated EXACTLY ONCE in the reference run
ok: GatedTestSet enforces exactly one evaluation mechanically, not by convention
8. Proof the harness can fail
ok: scratch copy of examples/ passes before it is broken
ok: breaking exercise 8's assertion produces a non-zero exit and names the failing test
9. The leaky-gap direction holds beyond the quoted seed range
ok: the leaky-gap direction and the selection mechanics hold at seeds this lab does not quote
10. Offline, and nothing left behind
ok: no URLs inside examples/ or starter/ source -- this lab reaches no network beyond the bundled dataset
ok: no __pycache__ left behind (cleaned during this run)
ok: no .pytest_cache left behind (cleaned during this run)
---------------------------------------------------------------
15 checks, 0 failure(s)
exit=0
Source files
examples/classification_lib.py (16202 bytes)
"""One classification project, run properly, once.
Days 141-146 each isolated one discipline: what a score means, the three
feedback shapes, the workflow's stage contract, the three sets and the
selection optimism they exist to control, the bias/variance trade, and the
scikit-learn estimator API. This module spends every one of those
disciplines on a single real dataset and produces one defensible verdict.
Frame, baseline, split, pipeline, cross-validate, select, ONE test
evaluation, error analysis, an honest interval. Nothing here is taught for
the first time; everything here is used.
"""
from __future__ import annotations
import time
import numpy as np
from sklearn.datasets import load_breast_cancer, load_iris, load_wine
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
# --------------------------------------------------------------------------
# 1. Choosing the dataset -- by measuring, not by assumption
# --------------------------------------------------------------------------
def candidate_summaries():
"""Baseline and headroom for the three datasets bundled in scikit-learn.
Returns one row per candidate: ``(name, n_samples, n_features,
n_classes, majority_baseline, n_test_rows_at_20_percent)``. This is the
evidence the choice of dataset is made from, not a rule of thumb.
"""
rows = []
for name, loader in (("iris", load_iris), ("wine", load_wine), ("breast_cancer", load_breast_cancer)):
d = loader()
X, y = d.data, d.target
_x_tr, x_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=0, stratify=y)
baseline = DummyClassifier(strategy="most_frequent").fit(_x_tr, y_tr)
rows.append(
(
name,
X.shape[0],
X.shape[1],
len(set(y.tolist())),
round(float(baseline.score(x_te, y_te)), 4),
x_te.shape[0],
)
)
return rows
def load_chosen_dataset():
"""The dataset this exercise uses: the Wisconsin breast-cancer set.
Bundled in scikit-learn, fully offline, 569 rows of 30 real-valued
measurements from a digitised fine-needle aspirate, two classes.
Chosen over iris and wine because both of those saturate near-perfect
accuracy on a test set of 30-36 rows, leaving no room for an honest
interval or a real selection-optimism check; see ``candidate_summaries``.
"""
d = load_breast_cancer()
return d.data, d.target, [str(name) for name in d.target_names]
# --------------------------------------------------------------------------
# 2. The frame and the baseline -- before any model
# --------------------------------------------------------------------------
def majority_baseline(x_train, y_train, x_test, y_test) -> float:
"""The accuracy of predicting the majority class every time.
Every model in this exercise has to beat this number to be worth
building at all. Day 141's whole point: a score is not evidence until
you know what it beats.
"""
dummy = DummyClassifier(strategy="most_frequent").fit(x_train, y_train)
return float(dummy.score(x_test, y_test))
# --------------------------------------------------------------------------
# 3. The split -- train for fitting, held for selecting, test for one look
# --------------------------------------------------------------------------
def split_once(X, y, seed: int = 0, test_size: float = 0.2):
"""One stratified train/test split. The test half is touched once, later.
Day 144's rule: the training portion is for fitting, unlimited looks.
The test portion's whole value comes from never having influenced a
choice, so nothing below this call may see ``x_test`` or ``y_test``
until the single evaluation at the end.
"""
return train_test_split(X, y, test_size=test_size, random_state=seed, stratify=y)
# --------------------------------------------------------------------------
# 4. The candidate pipelines -- three families, a real sweep
# --------------------------------------------------------------------------
_KNN_NEIGHBORS = list(range(1, 16))
_LOGREG_C = [0.001, 0.003, 0.01, 0.03, 0.1, 0.3, 1, 3, 10, 30, 100]
_TREE_DEPTHS = list(range(1, 11))
def candidate_configs():
"""36 candidate pipelines: 15 KNN, 11 logistic regression, 10 trees.
Every candidate is an actual scikit-learn ``Pipeline`` (Day 146),
scaling folded in wherever the estimator needs it, so cross-validation
below refits the scaler on each fold's training rows only -- Day 143's
stage-ordering rule, now enforced by the estimator's own contract
instead of by discipline.
Returns a list of ``(family, hyperparameter, make_pipeline)`` where
``make_pipeline`` is a zero-argument callable returning a fresh,
unfitted ``Pipeline`` -- fresh each call, because a fitted estimator is
not something you cross-validate with.
"""
configs = []
for k in _KNN_NEIGHBORS:
configs.append(
("knn", k, lambda k=k: Pipeline([("scale", StandardScaler()), ("clf", KNeighborsClassifier(k))]))
)
for c in _LOGREG_C:
configs.append(
(
"logreg",
c,
lambda c=c: Pipeline(
[("scale", StandardScaler()), ("clf", LogisticRegression(C=c, max_iter=5000))]
),
)
)
for depth in _TREE_DEPTHS:
configs.append(
("tree", depth, lambda depth=depth: Pipeline([("clf", DecisionTreeClassifier(max_depth=depth, random_state=0))]))
)
return configs
def candidate_count() -> int:
"""K, the number of configurations this exercise actually tries.
The number nobody remembers, Day 144 said -- so this project counts it.
"""
return len(candidate_configs())
# --------------------------------------------------------------------------
# 5. Cross-validate, then select -- the honest way to spend the train rows
# --------------------------------------------------------------------------
def cross_validate_configs(x_train, y_train, seed: int = 0, folds: int = 5):
"""5-fold stratified CV accuracy for every candidate, on train rows only.
Returns rows of ``(family, hyperparameter, cv_mean, cv_std)``, sorted
best first. This plays the role Day 144 gave the validation set --
many looks, and every look is spent here, never on the test rows.
"""
splitter = StratifiedKFold(n_splits=folds, shuffle=True, random_state=seed)
rows = []
for family, param, make in candidate_configs():
scores = cross_val_score(make(), x_train, y_train, cv=splitter)
rows.append((family, param, round(float(scores.mean()), 4), round(float(scores.std()), 4)))
rows.sort(key=lambda r: -r[2])
return rows
def select_best(x_train, y_train, seed: int = 0, folds: int = 5):
"""Fit the winner of the sweep on the full training set.
Returns ``(family, hyperparameter, cv_mean, fitted_pipeline)``. This is
the one moment of choice in the whole exercise -- everything before it
explores, everything after it is committed.
"""
rows = cross_validate_configs(x_train, y_train, seed=seed, folds=folds)
winner_family, winner_param, winner_cv, _sd = rows[0]
for family, param, make_fn in candidate_configs():
if family == winner_family and param == winner_param:
fitted = make_fn().fit(x_train, y_train)
return winner_family, winner_param, winner_cv, fitted
raise RuntimeError("winning configuration vanished between sweep and refit")
# --------------------------------------------------------------------------
# 6. The predicted optimism, from Day 144's formula -- and what it misses
# --------------------------------------------------------------------------
def proportion_standard_error(p: float, n: int) -> float:
"""The standard error of an accuracy estimated on n rows. Day 117's formula."""
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. Day 144's quantity."""
rng = np.random.default_rng(seed)
return float(np.mean(np.max(rng.standard_normal((draws, k)), axis=1)))
def predicted_selection_optimism(best_cv: float, n_train: int, k: int, folds: int = 5) -> float:
"""The optimism Day 144's formula predicts for this sweep.
Standard error of an accuracy measured on one CV fold's worth of rows,
times the expected maximum of K standard normal draws. Both are known
before the sweep runs, which is the entire point of the formula: it is
a number you can compute in advance, not a warning you discover after.
"""
n_fold = n_train // folds
se = proportion_standard_error(best_cv, n_fold)
return se * expected_max_of_normals(k)
def selection_optimism_over_seeds(X, y, seeds=range(20), folds: int = 5):
"""The formula's prediction against what actually happened, at each seed.
Returns rows of ``(seed, best_cv, test_acc, measured_drop,
predicted_optimism)``. One seed is an anecdote -- Day 144's own
lesson about the forking-paths problem -- so this returns the whole
distribution rather than the seed used as the headline.
"""
k = candidate_count()
rows = []
for seed in seeds:
x_train, x_test, y_train, y_test = split_once(X, y, seed=seed)
_family, _param, cv_mean, fitted = select_best(x_train, y_train, seed=seed, folds=folds)
test_acc = float(fitted.score(x_test, y_test))
drop = round(cv_mean - test_acc, 4)
predicted = round(predicted_selection_optimism(cv_mean, len(y_train), k, folds=folds), 4)
rows.append((seed, cv_mean, round(test_acc, 4), drop, predicted))
return rows
# --------------------------------------------------------------------------
# 7. The test set -- one look, enforced mechanically
# --------------------------------------------------------------------------
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.
Day 144's discipline, made mechanical again: the counter does not
advance on a refused attempt, so a caller that never succeeds cannot
drain the budget by retrying.
"""
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 float(model.score(self._X, self._y))
# --------------------------------------------------------------------------
# 8. Error analysis -- what the confusion matrix says, once you look
# --------------------------------------------------------------------------
def confusion_and_errors(y_true, y_pred, target_names):
"""The confusion matrix, plus the two counts that matter clinically.
Returns ``(matrix, false_negatives, false_positives)`` where a false
negative is a malignant case predicted benign -- the costlier mistake
in this domain, and a number worth reporting even though accuracy
alone would hide it.
"""
matrix = confusion_matrix(y_true, y_pred)
malignant_index = target_names.index("malignant")
benign_index = target_names.index("benign")
false_negatives = int(matrix[malignant_index, benign_index])
false_positives = int(matrix[benign_index, malignant_index])
return matrix, false_negatives, false_positives
# --------------------------------------------------------------------------
# 9. The verdict -- an interval, not a point
# --------------------------------------------------------------------------
def verdict_interval(test_acc: float, n_test: int):
"""The 95 percent interval around the one test score this project spent.
Returns ``(se, half_width, lower, upper)``. Day 144's sizing table
arriving at the actual decision: is the model distinguishable from the
baseline, given how few rows the test set actually has.
"""
se = proportion_standard_error(test_acc, n_test)
half_width = round(1.96 * se, 4)
return round(se, 4), half_width, round(test_acc - half_width, 4), round(test_acc + half_width, 4)
def distinguishable_from_baseline(test_acc: float, baseline_acc: float, n_test: int) -> bool:
"""Whether the improvement over baseline exceeds the test set's own interval.
An honest verdict may be "cannot distinguish" -- Day 144's whole point
about test-set sizing. Here it does not come to that, and this function
is how you would find out if it had.
"""
_se, half_width, _lo, _hi = verdict_interval(test_acc, n_test)
return (test_acc - baseline_acc) > half_width
# --------------------------------------------------------------------------
# 10. The leaky version -- selecting by peeking at the test set
# --------------------------------------------------------------------------
def leaky_selection_test_score(x_train, y_train, x_test, y_test):
"""Select the winner by fitting every candidate and scoring it on TEST.
This is the mistake this whole exercise has spent nine days learning
not to make: using the held-out set as if it were a validation set.
Returns the winning test score -- not a validation score followed by
one look, but K looks disguised as one.
"""
best_score = -1.0
for _family, _param, make in candidate_configs():
pipe = make().fit(x_train, y_train)
score = float(pipe.score(x_test, y_test))
if score > best_score:
best_score = score
return round(best_score, 4)
def leaky_vs_honest_over_seeds(X, y, seeds=range(20), folds: int = 5):
"""The gap between peeking at the test set and looking at it once.
Returns rows of ``(seed, honest_test, leaky_test, gap)``. Honest is
Day 144's discipline: select on cross-validated train rows, evaluate
the winner on test exactly once. Leaky is the mistake: fit every
candidate and let the test set itself do the selecting.
"""
rows = []
for seed in seeds:
x_train, x_test, y_train, y_test = split_once(X, y, seed=seed)
_family, _param, _cv, fitted = select_best(x_train, y_train, seed=seed, folds=folds)
honest_test = round(float(fitted.score(x_test, y_test)), 4)
leaky_test = leaky_selection_test_score(x_train, y_train, x_test, y_test)
rows.append((seed, honest_test, leaky_test, round(leaky_test - honest_test, 4)))
return rows
# --------------------------------------------------------------------------
# 11. What the whole thing costs
# --------------------------------------------------------------------------
def timed_full_run(seed: int = 0):
"""Run frame-to-verdict once, and time it.
Returns ``(elapsed_seconds, test_acc)``. Not asserted anywhere as a
threshold -- machines differ -- but reported, because "how long does
this take" is part of an honest verdict about whether the protocol is
usable day to day.
"""
start = time.perf_counter()
X, y, _names = load_chosen_dataset()
x_train, x_test, y_train, y_test = split_once(X, y, seed=seed)
majority_baseline(x_train, y_train, x_test, y_test)
_family, _param, _cv, fitted = select_best(x_train, y_train, seed=seed)
gate = GatedTestSet(x_test, y_test)
test_acc = gate.evaluate(fitted)
elapsed = time.perf_counter() - start
return round(elapsed, 4), round(test_acc, 4)
examples/report_measurements.py (5952 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
import classification_lib as c # noqa: E402
def rule(title: str) -> None:
print()
print(title)
print("-" * len(title))
def main() -> None:
print("Day 147 -- an end-to-end classification exercise, measured")
print("=" * 59)
rule("1. Choosing the dataset, by measuring")
print(" name n_samples n_features n_classes baseline n_test")
for name, n_samples, n_features, n_classes, baseline, n_test in c.candidate_summaries():
print(f" {name:14s} {n_samples:9d} {n_features:10d} {n_classes:9d} {baseline:.4f} {n_test:4d}")
print(" iris and wine saturate near-perfect accuracy on 30-36 test rows;")
print(" breast_cancer is chosen: a non-trivial baseline, 114 test rows, room for an interval")
X, y, names = c.load_chosen_dataset()
x_train, x_test, y_train, y_test = c.split_once(X, y, seed=0)
print(f" chosen: breast_cancer, {X.shape[0]} rows, {X.shape[1]} features, classes {names}")
print(f" split: train={x_train.shape[0]} test={x_test.shape[0]} (stratified, seed 0)")
rule("2. The frame and the baseline")
baseline_acc = c.majority_baseline(x_train, y_train, x_test, y_test)
print(f" majority-class baseline, test accuracy: {baseline_acc:.4f}")
rule("3. The sweep: cross-validate every candidate on train rows only")
k = c.candidate_count()
print(f" K = {k} candidate pipelines: 15 KNN, 11 logistic regression, 10 decision trees")
family, param, cv_mean, fitted = c.select_best(x_train, y_train, seed=0)
print(f" winner: {family} ({param}) 5-fold CV accuracy = {cv_mean:.4f}")
rule("4. ONE test evaluation")
gate = c.GatedTestSet(x_test, y_test)
test_acc = gate.evaluate(fitted)
drop = round(cv_mean - test_acc, 4)
print(f" test accuracy: {test_acc:.4f}")
print(f" cv - test (the drop): {drop:+.4f}")
try:
gate.evaluate(fitted)
print(" second evaluation: NO ERROR RAISED")
except c.TestSetTouchedTwice as exc:
print(f" second evaluation : {type(exc).__name__}")
print(f" {exc}")
rule("5. The predicted optimism, from Day 144's formula")
predicted = round(c.predicted_selection_optimism(cv_mean, len(y_train), k), 4)
print(f" predicted optimism (SE of a CV fold x expected max of {k} normals): {predicted:.4f}")
print(f" measured drop at this seed: {drop:+.4f}")
print(" one seed is an anecdote -- the distribution below is the honest reading")
rule("5b. Predicted vs measured, over 20 seeds")
rows = c.selection_optimism_over_seeds(X, y, seeds=range(20))
drops = np.array([r[3] for r in rows])
predicted_all = np.array([r[4] for r in rows])
print(" seed cv_mean test_acc drop predicted")
for seed, cv_mean_s, test_acc_s, drop_s, predicted_s in rows:
print(f" {seed:5d} {cv_mean_s:.4f} {test_acc_s:.4f} {drop_s:+.4f} {predicted_s:.4f}")
print(f" mean measured drop: {float(drops.mean()):+.4f} sd {float(drops.std()):.4f}")
print(f" mean predicted optimism: {float(predicted_all.mean()):.4f}")
print(f" fraction of seeds where the drop was positive: {float((drops > 0).mean()):.4f}")
print(" the formula assumed independent, zero-skill candidates; these 36 are correlated")
print(" and genuinely skilled, so the naive prediction overestimates the real optimism here")
rule("6. Error analysis")
preds = fitted.predict(x_test)
matrix, false_negatives, false_positives = c.confusion_and_errors(y_test, preds, names)
print(f" confusion matrix (rows=true, cols=predicted), labels {names}")
for row in matrix:
print(" ", row.tolist())
print(f" false negatives (malignant predicted benign): {false_negatives}")
print(f" false positives (benign predicted malignant): {false_positives}")
rule("7. The verdict, with an interval")
se, half_width, lower, upper = c.verdict_interval(test_acc, len(y_test))
print(f" n_test = {len(y_test)} se = {se:.4f} 95 percent half-width = +/-{half_width:.4f}")
print(f" 95 percent interval: [{lower:.4f}, {upper:.4f}]")
improvement = round(test_acc - baseline_acc, 4)
print(f" improvement over baseline: {improvement:+.4f}")
print(f" distinguishable from baseline at this test-set size: {c.distinguishable_from_baseline(test_acc, baseline_acc, len(y_test))}")
rule("8. The leaky version: selecting by peeking at the test set")
leaky = c.leaky_selection_test_score(x_train, y_train, x_test, y_test)
print(f" honest (select on CV, look once): {test_acc:.4f}")
print(f" leaky (best of {k} scored directly on test): {leaky:.4f}")
print(f" gap: {round(leaky - test_acc, 4):+.4f}")
rule("8b. The leaky gap, over 20 seeds")
rows2 = c.leaky_vs_honest_over_seeds(X, y, seeds=range(20))
gaps = np.array([r[3] for r in rows2])
print(" seed honest leaky gap")
for seed, honest_s, leaky_s, gap_s in rows2:
print(f" {seed:5d} {honest_s:.4f} {leaky_s:.4f} {gap_s:+.4f}")
print(f" mean gap: {float(gaps.mean()):+.4f} sd {float(gaps.std()):.4f} min {float(gaps.min()):+.4f} max {float(gaps.max()):+.4f}")
print(f" fraction of seeds where the leak was non-negative: {float((gaps >= 0).mean()):.4f}")
rule("9. What the whole thing costs")
print(" wall-clock cost is machine-dependent and not reproduced here byte for byte;")
print(" see metadata.yml and expected-output/FIELDS.md for the captured timing")
if __name__ == "__main__":
main()
examples/test_classification_claims.py (7996 bytes)
"""The reference solutions: one classification project, run properly, once.
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
import classification_lib as c
@pytest.fixture(scope="module")
def dataset():
return c.load_chosen_dataset()
@pytest.fixture(scope="module")
def split(dataset):
X, y, _names = dataset
return c.split_once(X, y, seed=0)
# --- 1. Choosing the dataset, by measuring --------------------------------
def test_01_the_dataset_choice_is_measured_not_assumed():
rows = {row[0]: row for row in c.candidate_summaries()}
assert rows["iris"] == ("iris", 150, 4, 3, 0.3333, 30)
assert rows["wine"] == ("wine", 178, 13, 3, 0.3889, 36)
assert rows["breast_cancer"] == ("breast_cancer", 569, 30, 2, 0.6316, 114)
# iris and wine both have fewer than 40 test rows -- one wrong answer
# moves accuracy by more than 2.5 points, too coarse for an honest interval.
assert rows["iris"][5] < 40
assert rows["wine"][5] < 40
assert rows["breast_cancer"][5] > 100
def test_01b_the_chosen_dataset_gives_headroom_for_an_interval(dataset):
X, y, names = dataset
assert X.shape == (569, 30)
assert names == ["malignant", "benign"]
# The class split is real imbalance, not degenerate: neither class is rare.
assert 0.3 < float(np.mean(y == 1)) < 0.7
# --- 2. The frame and the baseline -----------------------------------------
def test_02_the_majority_baseline_before_any_model(split):
x_train, x_test, y_train, y_test = split
baseline = c.majority_baseline(x_train, y_train, x_test, y_test)
assert round(baseline, 4) == 0.6316
# Any model worth building has to clear this, or it is worth nothing.
# --- 3. The split ------------------------------------------------------------
def test_03_the_split_is_stratified_and_holds_the_test_rows_back(dataset):
X, y, _names = dataset
x_train, x_test, y_train, y_test = c.split_once(X, y, seed=0)
assert x_train.shape == (455, 30)
assert x_test.shape == (114, 30)
# Stratified: the positive rate in each half matches the population's.
population_rate = float(np.mean(y == 1))
assert abs(float(np.mean(y_train == 1)) - population_rate) < 0.01
assert abs(float(np.mean(y_test == 1)) - population_rate) < 0.01
# --- 4. The sweep --------------------------------------------------------
def test_04_the_sweep_counts_thirty_six_candidate_pipelines():
assert c.candidate_count() == 36
families = [family for family, _param, _make in c.candidate_configs()]
assert families.count("knn") == 15
assert families.count("logreg") == 11
assert families.count("tree") == 10
# --- 5. Cross-validate, then select --------------------------------------
def test_05_cross_validation_selects_the_winner_on_train_rows_only(split):
x_train, _x_test, y_train, _y_test = split
family, param, cv_mean, fitted = c.select_best(x_train, y_train, seed=0)
assert (family, param) == ("logreg", 1)
assert round(cv_mean, 4) == 0.978
assert hasattr(fitted, "predict")
# The winner was never fitted on -- let alone scored against -- test rows.
# --- 6. The gate -----------------------------------------------------------
def test_06_the_gate_permits_exactly_one_test_evaluation(split):
x_train, x_test, y_train, y_test = split
_family, _param, _cv, fitted = c.select_best(x_train, y_train, seed=0)
gate = c.GatedTestSet(x_test, y_test)
assert gate.evaluations == 0
first = gate.evaluate(fitted)
assert round(first, 4) == 0.9825
assert gate.evaluations == 1
with pytest.raises(c.TestSetTouchedTwice) as excinfo:
gate.evaluate(fitted)
assert "validation score" in str(excinfo.value)
assert gate.evaluations == 1
# --- 7. The predicted optimism --------------------------------------------
def test_07_the_predicted_optimism_from_day_144s_formula(split):
x_train, _x_test, y_train, _y_test = split
_family, _param, cv_mean, _fitted = c.select_best(x_train, y_train, seed=0)
predicted = c.predicted_selection_optimism(cv_mean, len(y_train), c.candidate_count())
assert round(predicted, 4) == 0.0326
# The measured drop at this seed (-0.0045) is far smaller than the
# prediction: real, correlated candidates do not behave like coin flips.
def test_07b_predicted_vs_measured_over_twenty_seeds(dataset):
X, y, _names = dataset
rows = c.selection_optimism_over_seeds(X, y, seeds=range(20))
assert len(rows) == 20
drops = np.array([r[3] for r in rows])
predicted = np.array([r[4] for r in rows])
assert round(float(drops.mean()), 4) == -0.0001
assert round(float(drops.std()), 4) == 0.0149
assert round(float(predicted.mean()), 4) == 0.033
assert round(float((drops > 0).mean()), 4) == 0.5
# The formula, built for independent zero-skill candidates, overestimates
# the real optimism here by more than 300-fold on average -- these 36
# candidates are correlated and genuinely skilled, not coin flips.
assert float(predicted.mean()) > float(drops.mean()) + 0.02
# --- 8. Error analysis -----------------------------------------------------
def test_08_error_analysis_the_confusion_matrix(split):
x_train, x_test, y_train, y_test = split
_family, _param, _cv, fitted = c.select_best(x_train, y_train, seed=0)
preds = fitted.predict(x_test)
matrix, false_negatives, false_positives = c.confusion_and_errors(y_test, preds, ["malignant", "benign"])
assert matrix.tolist() == [[40, 2], [0, 72]]
assert false_negatives == 2
assert false_positives == 0
# Every error this model makes is the costlier kind: a missed malignancy.
# --- 9. The verdict ----------------------------------------------------------
def test_09_the_verdict_has_an_interval(split):
x_train, x_test, y_train, y_test = split
_family, _param, _cv, fitted = c.select_best(x_train, y_train, seed=0)
test_acc = float(fitted.score(x_test, y_test))
se, half_width, lower, upper = c.verdict_interval(test_acc, len(y_test))
assert se == 0.0123
assert half_width == 0.0241
assert (lower, upper) == (0.9584, 1.0066)
def test_09b_the_improvement_is_distinguishable_from_baseline(split):
x_train, x_test, y_train, y_test = split
baseline = c.majority_baseline(x_train, y_train, x_test, y_test)
_family, _param, _cv, fitted = c.select_best(x_train, y_train, seed=0)
test_acc = float(fitted.score(x_test, y_test))
assert c.distinguishable_from_baseline(test_acc, baseline, len(y_test)) is True
# Thirty-five points of improvement against a four-point interval: this
# is not a "cannot distinguish" verdict, and the arithmetic says so.
assert round(test_acc - baseline, 4) == 0.3509
# --- 10. The leaky version -------------------------------------------------
def test_10_the_leaky_version_selects_by_peeking_at_the_test_set(split):
x_train, x_test, y_train, y_test = split
_family, _param, _cv, fitted = c.select_best(x_train, y_train, seed=0)
honest = round(float(fitted.score(x_test, y_test)), 4)
leaky = c.leaky_selection_test_score(x_train, y_train, x_test, y_test)
assert honest == 0.9825
assert leaky == 0.9825
assert leaky >= honest
def test_10b_the_leaky_gap_over_twenty_seeds(dataset):
X, y, _names = dataset
rows = c.leaky_vs_honest_over_seeds(X, y, seeds=range(20))
assert len(rows) == 20
gaps = np.array([r[3] for r in rows])
assert round(float(gaps.mean()), 4) == 0.0096
assert round(float(gaps.std()), 4) == 0.0103
assert round(float(gaps.min()), 4) == 0.0
assert round(float(gaps.max()), 4) == 0.0351
# The leak never once helped the honest number and never hurt the leaky
# one: every seed's gap is non-negative, exactly as the mechanism predicts.
assert (gaps >= 0).all()
examples/test_classification_lib.py (1797 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 classification_lib as c
def test_the_standard_error_formula_behaves_as_it_should():
assert c.proportion_standard_error(0.5, 100) > c.proportion_standard_error(0.85, 100)
assert c.proportion_standard_error(0.5, 100) > c.proportion_standard_error(0.5, 400)
assert c.proportion_standard_error(1.0, 100) == 0.0
def test_candidate_configs_really_are_thirty_six_distinct_pipelines():
configs = c.candidate_configs()
assert len(configs) == 36
seen = set()
for family, param, make in configs:
pipe = make()
assert hasattr(pipe, "fit") and hasattr(pipe, "predict")
seen.add((family, param))
# No two configs share a (family, hyperparameter) pair.
assert len(seen) == 36
def test_the_gated_test_set_counts_and_refuses():
class AlwaysBenign:
def score(self, X, y):
return float(np.mean(np.asarray(y) == 1))
y = np.array([0, 1, 1, 1])
gate = c.GatedTestSet(np.zeros((4, 2)), y)
assert gate.evaluate(AlwaysBenign()) == 0.75
with pytest.raises(c.TestSetTouchedTwice):
gate.evaluate(AlwaysBenign())
# A fresh gate is a fresh budget; the class holds no global state.
assert c.GatedTestSet(np.zeros((4, 2)), y).evaluate(AlwaysBenign()) == 0.75
def test_the_chosen_dataset_loads_offline_and_matches_its_shape():
X, y, names = c.load_chosen_dataset()
assert X.shape == (569, 30)
assert y.shape == (569,)
assert names == ["malignant", "benign"]
assert set(y.tolist()) == {0, 1}
metadata.yml (6901 bytes)
lesson_id: D147
day: 147
kind: guided-build
languages:
- python
- bash
setup_commands:
- cd labs/sections/machine-learning/day-147-an-end-to-end-classification-exercise
- 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: 70
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 -> 15 checks, 0 failure(s), exit 0. pytest examples -q -> 18 passed.
pytest starter -q -> 4 passed, 14 skipped (the four machinery checks in
test_classification_lib.py are solved in both directories; the fourteen exercise
stubs in starter/test_classification_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 -- the dataset is bundled inside scikit-learn itself (load_breast_cancer),
nothing is downloaded, and harness check 10 confirms no URL appears anywhere in
starter/ or examples/ source. Section 8 of the harness copies examples/ into a
mktemp -d scratch directory, confirms 18 passed, rewrites the confusion-matrix
assertion in exercise 8 to a value that cannot hold, confirms a non-zero exit
naming the failing test, and removes the scratch directory. Separately, by hand,
the winning-configuration assertion in exercise 5 was changed from ('logreg', 1) to
('tree', 1) in examples/test_classification_claims.py and the whole harness re-run:
it reported 15 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 15 checks, 0 failure(s), exit 0. Measured frame-to-verdict
wall-clock time for one seed, three repeated runs on the capture machine: 0.3626s,
0.3563s, 0.3833s. THE DATASET, chosen by measuring rather than assumed: iris (150
rows, 4 features, 3 classes, baseline 0.3333, 30 test rows at 20 percent) and wine
(178 rows, 13 features, 3 classes, baseline 0.3889, 36 test rows) were both tried
first and both discarded -- a 36-candidate sweep saturates near-ceiling
cross-validated accuracy on both, and 30-36 test rows move accuracy in steps of
roughly 3 points, too coarse for an honest interval or a meaningful selection-
optimism check. breast_cancer (569 rows, 30 features, 2 classes, baseline 0.6316,
114 test rows) was chosen instead. MEASURED PAIRS, all captured verbatim in
expected-output/measured-values.txt. (1) Stratified 80/20 split: 455 train rows,
114 test rows. (2) Majority-class baseline: 0.6316. (3) K = 36 candidate pipelines
(15 k-nearest-neighbours settings, 11 logistic-regression regularisation strengths,
10 decision-tree depths), 5-fold stratified cross-validation on train rows only;
the winner at seed 0 is LogisticRegression(C=1) at cv_mean 0.9780. (4) The ONE test
evaluation, enforced by a GatedTestSet that raises TestSetTouchedTwice on any
further attempt without advancing its counter: test accuracy 0.9825, a drop from
cross-validation of -0.0045 -- the test score came out slightly BETTER than the
cross-validated one, not worse. (5) Day 144's formula (validation standard error
times the expected maximum of K standard normal draws) predicts an optimism of
0.0326 for this sweep; over 20 independent seeds the measured drop averages -0.0001
(sd 0.0149) against a mean prediction of 0.0330 -- the formula, built for
independent zero-skill candidates, overestimates the real optimism here by roughly
thirty-fold on average, because these 36 candidates are correlated (adjacent k and
adjacent C behave nearly identically) and genuinely skilled rather than pure noise.
(6) Error analysis: confusion matrix [[40, 2], [0, 72]] (rows=true, cols=predicted,
labels malignant/benign) -- 2 false negatives (malignant predicted benign), 0 false
positives; every mistake this model makes is the clinically costlier kind. (7) The
verdict: n_test=114, se=0.0123, 95 percent half-width +/-0.0241, interval [0.9584,
1.0066]; improvement over baseline +0.3509, comfortably larger than the interval,
so the verdict is DISTINGUISHABLE, not the "cannot distinguish" case a smaller test
set can force. (8) The leaky version -- selecting by fitting all 36 candidates and
scoring each directly on the test set instead of selecting on cross-validated train
rows: at seed 0 the leaky and honest scores tie (0.9825, gap 0.0, a ceiling effect
from only 114 test rows); over 20 seeds the mean gap is +0.0096 (sd 0.0103, max
0.0351) and the gap is NEVER negative at any seed -- the leak can only make the
reported number look as good or better, never worse. THREE HONESTY CALLS. FIRST,
and the most important: the naive application of Day 144's selection-optimism
formula badly overestimates the real drop here, because that formula assumes
independent, zero-skill candidates and this sweep's 36 candidates are neither --
this is reported as the closing finding it is, not smoothed over to make the
formula look more universally applicable than it is. SECOND: the leaky-selection
gap is small and sometimes exactly zero at any single seed, because 114 test rows
cap how finely accuracy can move (roughly 0.0088 per row) and because near-tied
real candidates sometimes land on the identical winner regardless of which set
selects them; the lab reports the 20-seed distribution rather than picking a seed
where the gap looks dramatic, and states plainly that the direction (never negative)
is the defensible claim, not any single seed's size. THIRD: iris and wine were
measured and rejected as this project's dataset rather than assumed unsuitable --
the rejection is backed by the same candidate_summaries() measurement the lesson
quotes, not by reputation. Harness check 9 re-runs the leaky-gap direction and the
selection mechanics at seeds this lab does not quote (20-24 and seed 41), so the
headline directions are confirmed beyond the seeds reported in prose.
requirements/README.md (1920 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
`StratifiedKFold(shuffle=True, random_state=...)` and every seeded split in
this lab produce results that depend on NumPy's `Generator` bit stream,
which NumPy's own documentation states carries no cross-version
compatibility guarantee. Different pinned versions can legitimately shuffle
the same seed into a different order, and every downstream number — the
winning configuration, its cross-validated score, the test score, the
confusion matrix — would move with it.
What does not depend on the pins: the standard-error formula, which is
arithmetic; the direction of every result — the leaky selection score is
never lower than the honest one, cross-validating selects on train rows
only, a gated test set refuses a second look; and the structural facts,
such as the dataset used here having 569 rows and two classes.
`expected-output/FIELDS.md` separates the two categories in full.
## 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: the
dataset is bundled inside scikit-learn itself, and nothing else 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 (5911 bytes)
# Day 147 lab brief — One Classification Project, Run Properly
Days 141 through 146 each isolated one discipline in a lab built to show it
in isolation: what a score means, the three feedback shapes, the workflow's
stage contract, the three sets and the selection optimism they exist to
control, the bias/variance trade, and the scikit-learn estimator API.
This lab is not a new discipline. It is all six of them, spent on one real
dataset, in the order a working project actually uses them: frame,
baseline, split, pipeline, cross-validate, select, **one** test evaluation,
error analysis, an honest verdict with an interval.
## The dataset, chosen by measuring
Three datasets ship inside scikit-learn and need no download: iris, wine
and the Wisconsin breast-cancer set. This lab tries all three before
choosing:
| dataset | rows | features | classes | majority baseline | test rows at 20% |
| --- | --- | --- | --- | --- | --- |
| iris | 150 | 4 | 3 | 0.3333 | 30 |
| wine | 178 | 13 | 3 | 0.3889 | 36 |
| breast_cancer | 569 | 30 | 2 | 0.6316 | 114 |
Iris and wine both saturate near-perfect cross-validated accuracy with a
36-candidate sweep, on a test set of 30 to 36 rows — one wrong answer moves
accuracy by more than two and a half points, which is too coarse for an
honest interval and leaves no room to see selection optimism behave the
way the theory predicts. **This lab uses breast_cancer**: a baseline that
is not trivially beaten, 114 test rows, and 30 real-valued measurements
from a digitised fine-needle aspirate. Exercise 1 asserts the numbers that
justify the choice.
## What the honest run measures
Thirty-six candidate pipelines — 15 k-nearest-neighbours settings, 11
logistic-regression regularisation strengths, 10 decision-tree depths —
are cross-validated five ways on the training rows only. The winner is
`LogisticRegression(C=1)`, at a cross-validated accuracy of **0.9780**.
Fitted on the full training set and evaluated **exactly once** against the
test rows, it scores **0.9825** — a drop of −0.0045, meaning the test score
came out very slightly *better* than the cross-validated one, not worse.
## The number this lab was built to check
Day 144 gave you a formula: the optimism from picking the best of K
candidates is the validation set's standard error times the expected
maximum of K standard normal draws — computable before you run the sweep.
Applied here, with K = 36 and the winner's cross-validated accuracy, it
predicts an optimism of **0.0326**. The measured drop at this seed is
**−0.0045**.
That is not a rounding error. Across 20 independent seeds the mean
measured drop is **−0.0001** — statistically indistinguishable from zero —
against a mean prediction of **0.0330**. The formula overestimates the real
optimism here by more than thirty-fold on average.
**Why**, and it is worth sitting with before exercise 7b: Day 144's
formula assumes K *independent, zero-skill* candidates — literal coin
flips. This lab's 36 candidates are neither. Adjacent `k` values in
k-nearest-neighbours and adjacent regularisation strengths in logistic
regression produce nearly identical predictions, so the *effective*
number of independent choices is far smaller than 36. And every candidate
here has genuine, if varying, skill — the "maximum of noise" framing
does not apply to a maximum taken over real signal. The formula is not
wrong; it is answering a question this sweep does not ask.
## The leak this lab lets you cause on purpose
Exercise 10 rebuilds the mistake Day 144 spent a whole lesson on, in its
most common real form: selecting a model by fitting every candidate and
scoring it **on the test set directly**, instead of selecting on
cross-validated train rows and looking at test once.
At the reported seed the leak costs nothing — a ceiling effect, because
114 test rows can only move accuracy in steps of about 0.88 points. Over
20 seeds it costs a mean of **+0.0096**, up to **+0.0351**, and it is
**never negative** — the leak can only make the reported number look as
good or better than the honest one, never worse. That asymmetry is the
mechanism, not luck.
## Error analysis, before the verdict
The one test evaluation makes two mistakes, both the same kind: two
malignant cases predicted benign. Zero benign cases are predicted
malignant. Accuracy alone — 0.9825 — does not tell you that every error
this model makes is the clinically costlier one.
## The verdict, with an interval
`n_test = 114` gives a standard error of 0.0123 and a 95 percent
half-width of ±0.0241. The interval is `[0.9584, 1.0066]`. The improvement
over the 0.6316 baseline is +0.3509 — comfortably larger than the
interval, so the honest verdict here is **distinguishable, clearly**, not
the "cannot distinguish" verdict Day 144 warned a small test set can force.
## 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_classification_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. 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 `classification_lib.py`, `test_classification_lib.py` and
`test_classification_claims.py`; pytest aborts on the module-name
collision. Run them separately, always.
## And the rule, made mechanical, again
Exercise 6 wraps the test set in the same `GatedTestSet` pattern Day 144
built: exactly one evaluation, `TestSetTouchedTwice` on the second, and a
counter that does not advance on a refused attempt. Nine days in, this is
not a new idea — it is the same discipline, proven on a real dataset
instead of a synthetic one.
starter/classification_lib.py (16202 bytes)
"""One classification project, run properly, once.
Days 141-146 each isolated one discipline: what a score means, the three
feedback shapes, the workflow's stage contract, the three sets and the
selection optimism they exist to control, the bias/variance trade, and the
scikit-learn estimator API. This module spends every one of those
disciplines on a single real dataset and produces one defensible verdict.
Frame, baseline, split, pipeline, cross-validate, select, ONE test
evaluation, error analysis, an honest interval. Nothing here is taught for
the first time; everything here is used.
"""
from __future__ import annotations
import time
import numpy as np
from sklearn.datasets import load_breast_cancer, load_iris, load_wine
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
# --------------------------------------------------------------------------
# 1. Choosing the dataset -- by measuring, not by assumption
# --------------------------------------------------------------------------
def candidate_summaries():
"""Baseline and headroom for the three datasets bundled in scikit-learn.
Returns one row per candidate: ``(name, n_samples, n_features,
n_classes, majority_baseline, n_test_rows_at_20_percent)``. This is the
evidence the choice of dataset is made from, not a rule of thumb.
"""
rows = []
for name, loader in (("iris", load_iris), ("wine", load_wine), ("breast_cancer", load_breast_cancer)):
d = loader()
X, y = d.data, d.target
_x_tr, x_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=0, stratify=y)
baseline = DummyClassifier(strategy="most_frequent").fit(_x_tr, y_tr)
rows.append(
(
name,
X.shape[0],
X.shape[1],
len(set(y.tolist())),
round(float(baseline.score(x_te, y_te)), 4),
x_te.shape[0],
)
)
return rows
def load_chosen_dataset():
"""The dataset this exercise uses: the Wisconsin breast-cancer set.
Bundled in scikit-learn, fully offline, 569 rows of 30 real-valued
measurements from a digitised fine-needle aspirate, two classes.
Chosen over iris and wine because both of those saturate near-perfect
accuracy on a test set of 30-36 rows, leaving no room for an honest
interval or a real selection-optimism check; see ``candidate_summaries``.
"""
d = load_breast_cancer()
return d.data, d.target, [str(name) for name in d.target_names]
# --------------------------------------------------------------------------
# 2. The frame and the baseline -- before any model
# --------------------------------------------------------------------------
def majority_baseline(x_train, y_train, x_test, y_test) -> float:
"""The accuracy of predicting the majority class every time.
Every model in this exercise has to beat this number to be worth
building at all. Day 141's whole point: a score is not evidence until
you know what it beats.
"""
dummy = DummyClassifier(strategy="most_frequent").fit(x_train, y_train)
return float(dummy.score(x_test, y_test))
# --------------------------------------------------------------------------
# 3. The split -- train for fitting, held for selecting, test for one look
# --------------------------------------------------------------------------
def split_once(X, y, seed: int = 0, test_size: float = 0.2):
"""One stratified train/test split. The test half is touched once, later.
Day 144's rule: the training portion is for fitting, unlimited looks.
The test portion's whole value comes from never having influenced a
choice, so nothing below this call may see ``x_test`` or ``y_test``
until the single evaluation at the end.
"""
return train_test_split(X, y, test_size=test_size, random_state=seed, stratify=y)
# --------------------------------------------------------------------------
# 4. The candidate pipelines -- three families, a real sweep
# --------------------------------------------------------------------------
_KNN_NEIGHBORS = list(range(1, 16))
_LOGREG_C = [0.001, 0.003, 0.01, 0.03, 0.1, 0.3, 1, 3, 10, 30, 100]
_TREE_DEPTHS = list(range(1, 11))
def candidate_configs():
"""36 candidate pipelines: 15 KNN, 11 logistic regression, 10 trees.
Every candidate is an actual scikit-learn ``Pipeline`` (Day 146),
scaling folded in wherever the estimator needs it, so cross-validation
below refits the scaler on each fold's training rows only -- Day 143's
stage-ordering rule, now enforced by the estimator's own contract
instead of by discipline.
Returns a list of ``(family, hyperparameter, make_pipeline)`` where
``make_pipeline`` is a zero-argument callable returning a fresh,
unfitted ``Pipeline`` -- fresh each call, because a fitted estimator is
not something you cross-validate with.
"""
configs = []
for k in _KNN_NEIGHBORS:
configs.append(
("knn", k, lambda k=k: Pipeline([("scale", StandardScaler()), ("clf", KNeighborsClassifier(k))]))
)
for c in _LOGREG_C:
configs.append(
(
"logreg",
c,
lambda c=c: Pipeline(
[("scale", StandardScaler()), ("clf", LogisticRegression(C=c, max_iter=5000))]
),
)
)
for depth in _TREE_DEPTHS:
configs.append(
("tree", depth, lambda depth=depth: Pipeline([("clf", DecisionTreeClassifier(max_depth=depth, random_state=0))]))
)
return configs
def candidate_count() -> int:
"""K, the number of configurations this exercise actually tries.
The number nobody remembers, Day 144 said -- so this project counts it.
"""
return len(candidate_configs())
# --------------------------------------------------------------------------
# 5. Cross-validate, then select -- the honest way to spend the train rows
# --------------------------------------------------------------------------
def cross_validate_configs(x_train, y_train, seed: int = 0, folds: int = 5):
"""5-fold stratified CV accuracy for every candidate, on train rows only.
Returns rows of ``(family, hyperparameter, cv_mean, cv_std)``, sorted
best first. This plays the role Day 144 gave the validation set --
many looks, and every look is spent here, never on the test rows.
"""
splitter = StratifiedKFold(n_splits=folds, shuffle=True, random_state=seed)
rows = []
for family, param, make in candidate_configs():
scores = cross_val_score(make(), x_train, y_train, cv=splitter)
rows.append((family, param, round(float(scores.mean()), 4), round(float(scores.std()), 4)))
rows.sort(key=lambda r: -r[2])
return rows
def select_best(x_train, y_train, seed: int = 0, folds: int = 5):
"""Fit the winner of the sweep on the full training set.
Returns ``(family, hyperparameter, cv_mean, fitted_pipeline)``. This is
the one moment of choice in the whole exercise -- everything before it
explores, everything after it is committed.
"""
rows = cross_validate_configs(x_train, y_train, seed=seed, folds=folds)
winner_family, winner_param, winner_cv, _sd = rows[0]
for family, param, make_fn in candidate_configs():
if family == winner_family and param == winner_param:
fitted = make_fn().fit(x_train, y_train)
return winner_family, winner_param, winner_cv, fitted
raise RuntimeError("winning configuration vanished between sweep and refit")
# --------------------------------------------------------------------------
# 6. The predicted optimism, from Day 144's formula -- and what it misses
# --------------------------------------------------------------------------
def proportion_standard_error(p: float, n: int) -> float:
"""The standard error of an accuracy estimated on n rows. Day 117's formula."""
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. Day 144's quantity."""
rng = np.random.default_rng(seed)
return float(np.mean(np.max(rng.standard_normal((draws, k)), axis=1)))
def predicted_selection_optimism(best_cv: float, n_train: int, k: int, folds: int = 5) -> float:
"""The optimism Day 144's formula predicts for this sweep.
Standard error of an accuracy measured on one CV fold's worth of rows,
times the expected maximum of K standard normal draws. Both are known
before the sweep runs, which is the entire point of the formula: it is
a number you can compute in advance, not a warning you discover after.
"""
n_fold = n_train // folds
se = proportion_standard_error(best_cv, n_fold)
return se * expected_max_of_normals(k)
def selection_optimism_over_seeds(X, y, seeds=range(20), folds: int = 5):
"""The formula's prediction against what actually happened, at each seed.
Returns rows of ``(seed, best_cv, test_acc, measured_drop,
predicted_optimism)``. One seed is an anecdote -- Day 144's own
lesson about the forking-paths problem -- so this returns the whole
distribution rather than the seed used as the headline.
"""
k = candidate_count()
rows = []
for seed in seeds:
x_train, x_test, y_train, y_test = split_once(X, y, seed=seed)
_family, _param, cv_mean, fitted = select_best(x_train, y_train, seed=seed, folds=folds)
test_acc = float(fitted.score(x_test, y_test))
drop = round(cv_mean - test_acc, 4)
predicted = round(predicted_selection_optimism(cv_mean, len(y_train), k, folds=folds), 4)
rows.append((seed, cv_mean, round(test_acc, 4), drop, predicted))
return rows
# --------------------------------------------------------------------------
# 7. The test set -- one look, enforced mechanically
# --------------------------------------------------------------------------
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.
Day 144's discipline, made mechanical again: the counter does not
advance on a refused attempt, so a caller that never succeeds cannot
drain the budget by retrying.
"""
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 float(model.score(self._X, self._y))
# --------------------------------------------------------------------------
# 8. Error analysis -- what the confusion matrix says, once you look
# --------------------------------------------------------------------------
def confusion_and_errors(y_true, y_pred, target_names):
"""The confusion matrix, plus the two counts that matter clinically.
Returns ``(matrix, false_negatives, false_positives)`` where a false
negative is a malignant case predicted benign -- the costlier mistake
in this domain, and a number worth reporting even though accuracy
alone would hide it.
"""
matrix = confusion_matrix(y_true, y_pred)
malignant_index = target_names.index("malignant")
benign_index = target_names.index("benign")
false_negatives = int(matrix[malignant_index, benign_index])
false_positives = int(matrix[benign_index, malignant_index])
return matrix, false_negatives, false_positives
# --------------------------------------------------------------------------
# 9. The verdict -- an interval, not a point
# --------------------------------------------------------------------------
def verdict_interval(test_acc: float, n_test: int):
"""The 95 percent interval around the one test score this project spent.
Returns ``(se, half_width, lower, upper)``. Day 144's sizing table
arriving at the actual decision: is the model distinguishable from the
baseline, given how few rows the test set actually has.
"""
se = proportion_standard_error(test_acc, n_test)
half_width = round(1.96 * se, 4)
return round(se, 4), half_width, round(test_acc - half_width, 4), round(test_acc + half_width, 4)
def distinguishable_from_baseline(test_acc: float, baseline_acc: float, n_test: int) -> bool:
"""Whether the improvement over baseline exceeds the test set's own interval.
An honest verdict may be "cannot distinguish" -- Day 144's whole point
about test-set sizing. Here it does not come to that, and this function
is how you would find out if it had.
"""
_se, half_width, _lo, _hi = verdict_interval(test_acc, n_test)
return (test_acc - baseline_acc) > half_width
# --------------------------------------------------------------------------
# 10. The leaky version -- selecting by peeking at the test set
# --------------------------------------------------------------------------
def leaky_selection_test_score(x_train, y_train, x_test, y_test):
"""Select the winner by fitting every candidate and scoring it on TEST.
This is the mistake this whole exercise has spent nine days learning
not to make: using the held-out set as if it were a validation set.
Returns the winning test score -- not a validation score followed by
one look, but K looks disguised as one.
"""
best_score = -1.0
for _family, _param, make in candidate_configs():
pipe = make().fit(x_train, y_train)
score = float(pipe.score(x_test, y_test))
if score > best_score:
best_score = score
return round(best_score, 4)
def leaky_vs_honest_over_seeds(X, y, seeds=range(20), folds: int = 5):
"""The gap between peeking at the test set and looking at it once.
Returns rows of ``(seed, honest_test, leaky_test, gap)``. Honest is
Day 144's discipline: select on cross-validated train rows, evaluate
the winner on test exactly once. Leaky is the mistake: fit every
candidate and let the test set itself do the selecting.
"""
rows = []
for seed in seeds:
x_train, x_test, y_train, y_test = split_once(X, y, seed=seed)
_family, _param, _cv, fitted = select_best(x_train, y_train, seed=seed, folds=folds)
honest_test = round(float(fitted.score(x_test, y_test)), 4)
leaky_test = leaky_selection_test_score(x_train, y_train, x_test, y_test)
rows.append((seed, honest_test, leaky_test, round(leaky_test - honest_test, 4)))
return rows
# --------------------------------------------------------------------------
# 11. What the whole thing costs
# --------------------------------------------------------------------------
def timed_full_run(seed: int = 0):
"""Run frame-to-verdict once, and time it.
Returns ``(elapsed_seconds, test_acc)``. Not asserted anywhere as a
threshold -- machines differ -- but reported, because "how long does
this take" is part of an honest verdict about whether the protocol is
usable day to day.
"""
start = time.perf_counter()
X, y, _names = load_chosen_dataset()
x_train, x_test, y_train, y_test = split_once(X, y, seed=seed)
majority_baseline(x_train, y_train, x_test, y_test)
_family, _param, _cv, fitted = select_best(x_train, y_train, seed=seed)
gate = GatedTestSet(x_test, y_test)
test_acc = gate.evaluate(fitted)
elapsed = time.perf_counter() - start
return round(elapsed, 4), round(test_acc, 4)
starter/test_classification_claims.py (7634 bytes)
"""Fourteen exercises: one classification project, run properly, once.
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.
`classification_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
import classification_lib as c # noqa: F401 (you will need it)
@pytest.fixture(scope="module")
def dataset():
return c.load_chosen_dataset()
@pytest.fixture(scope="module")
def split(dataset):
X, y, _names = dataset
return c.split_once(X, y, seed=0)
def test_01_the_dataset_choice_is_measured_not_assumed():
pytest.skip(
"Call c.candidate_summaries() and index it by name. Assert the iris row "
"equals ('iris', 150, 4, 3, 0.3333, 30), wine equals ('wine', 178, 13, 3, "
"0.3889, 36), and breast_cancer equals ('breast_cancer', 569, 30, 2, "
"0.6316, 114). Then assert both iris and wine have fewer than 40 test "
"rows, at 20 percent, while breast_cancer has more than 100 -- the "
"headroom the rest of this exercise needs."
)
def test_01b_the_chosen_dataset_gives_headroom_for_an_interval(dataset):
pytest.skip(
"Unpack (X, y, names) from the dataset fixture. Assert X.shape == "
"(569, 30) and names == ['malignant', 'benign']. Then assert the "
"positive rate (label 1) is strictly between 0.3 and 0.7 -- neither "
"class is rare, so nothing here needs the stratification machinery "
"Day 144 built for a 5 percent minority."
)
def test_02_the_majority_baseline_before_any_model(split):
pytest.skip(
"Unpack (x_train, x_test, y_train, y_test) from the split fixture. "
"Call c.majority_baseline and assert it rounds to 0.6316. Every model "
"in the rest of this exercise has to beat this number to be worth "
"building at all -- Day 141's whole point restated as a gate."
)
def test_03_the_split_is_stratified_and_holds_the_test_rows_back(dataset):
pytest.skip(
"Unpack (X, y, _names) from the dataset fixture and call "
"c.split_once(X, y, seed=0). Assert x_train.shape == (455, 30) and "
"x_test.shape == (114, 30). Then assert the positive rate in y_train "
"and in y_test both sit within 0.01 of the population's positive "
"rate -- the stratification Day 144 said should be your default."
)
def test_04_the_sweep_counts_thirty_six_candidate_pipelines():
pytest.skip(
"Assert c.candidate_count() == 36. Then unpack the (family, param, "
"make) triples from c.candidate_configs() and assert there are 15 "
"'knn', 11 'logreg' and 10 'tree' entries. K is the number nobody "
"remembers, Day 144 said -- so this exercise counts it before doing "
"anything else with it."
)
def test_05_cross_validation_selects_the_winner_on_train_rows_only(split):
pytest.skip(
"Unpack (x_train, _x_test, y_train, _y_test) from the split fixture "
"and call c.select_best(x_train, y_train, seed=0). Assert the "
"returned (family, param) equals ('logreg', 1) and the cv_mean "
"rounds to 0.978. The winner was chosen on cross-validated train "
"rows -- the test rows have not been touched yet."
)
def test_06_the_gate_permits_exactly_one_test_evaluation(split):
pytest.skip(
"Fit the winner from c.select_best on the train rows, wrap "
"(x_test, y_test) in c.GatedTestSet, and assert the first evaluation "
"rounds to 0.9825 and the counter becomes 1. Then assert a second "
"evaluation raises c.TestSetTouchedTwice mentioning 'validation "
"score', and that the counter did NOT advance on the refused "
"attempt -- Day 144's discipline, made mechanical again."
)
def test_07_the_predicted_optimism_from_day_144s_formula(split):
pytest.skip(
"Select the winner, then call c.predicted_selection_optimism with "
"its cv_mean, len(y_train) and c.candidate_count(). Assert the "
"result rounds to 0.0326 -- the optimism Day 144's formula predicts "
"for a sweep of 36 candidates, computable before you ever look at "
"the test set."
)
def test_07b_predicted_vs_measured_over_twenty_seeds(dataset):
pytest.skip(
"Unpack (X, y, _names) and call c.selection_optimism_over_seeds(X, "
"y, seeds=range(20)). Assert 20 rows come back. Compute the mean and "
"sd of the measured drops and assert they round to -0.0001 and "
"0.0149; assert the mean of the predicted column rounds to 0.033; "
"assert exactly half the drops were positive. Then assert the "
"predicted mean exceeds the measured mean by more than 0.02 -- the "
"formula assumed independent, zero-skill candidates, and these 36 "
"are correlated and genuinely skilled, so it overestimates badly."
)
def test_08_error_analysis_the_confusion_matrix(split):
pytest.skip(
"Select and fit the winner, predict on x_test, and call "
"c.confusion_and_errors(y_test, preds, ['malignant', 'benign']). "
"Assert the matrix equals [[40, 2], [0, 72]], false_negatives == 2 "
"and false_positives == 0. Every mistake this model makes is a "
"missed malignancy -- the costlier error in this domain -- and "
"accuracy alone would have hidden that."
)
def test_09_the_verdict_has_an_interval(split):
pytest.skip(
"Select the winner, fit it, score it on the test rows exactly once, "
"and call c.verdict_interval(test_acc, len(y_test)). Assert se == "
"0.0123, half_width == 0.0241, and the interval equals (0.9584, "
"1.0066). A point estimate without this interval is not a verdict."
)
def test_09b_the_improvement_is_distinguishable_from_baseline(split):
pytest.skip(
"Compute the baseline and the test accuracy as before. Assert "
"c.distinguishable_from_baseline(test_acc, baseline, len(y_test)) "
"is True, and that the improvement rounds to 0.3509. Thirty-five "
"points of improvement against a four-point interval is not a "
"'cannot distinguish' verdict, and the arithmetic is how you would "
"know if it had been."
)
def test_10_the_leaky_version_selects_by_peeking_at_the_test_set(split):
pytest.skip(
"Select and fit the honest winner and score it once on test. Then "
"call c.leaky_selection_test_score(x_train, y_train, x_test, "
"y_test), which fits every one of the 36 candidates and lets the "
"test set itself pick the winner. Assert the honest score rounds to "
"0.9825, the leaky score rounds to 0.9825, and leaky >= honest -- "
"the leak can only ever look as good or better, never worse."
)
def test_10b_the_leaky_gap_over_twenty_seeds(dataset):
pytest.skip(
"Call c.leaky_vs_honest_over_seeds(X, y, seeds=range(20)). Assert 20 "
"rows, mean gap rounding to 0.0096, sd 0.0103, min 0.0 and max "
"0.0351. Then assert every single gap is non-negative -- across 20 "
"independent seeds, selecting by peeking at the test set never once "
"did worse than selecting honestly, and often did strictly better. "
"That is the mechanism, not luck."
)
starter/test_classification_lib.py (1797 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 classification_lib as c
def test_the_standard_error_formula_behaves_as_it_should():
assert c.proportion_standard_error(0.5, 100) > c.proportion_standard_error(0.85, 100)
assert c.proportion_standard_error(0.5, 100) > c.proportion_standard_error(0.5, 400)
assert c.proportion_standard_error(1.0, 100) == 0.0
def test_candidate_configs_really_are_thirty_six_distinct_pipelines():
configs = c.candidate_configs()
assert len(configs) == 36
seen = set()
for family, param, make in configs:
pipe = make()
assert hasattr(pipe, "fit") and hasattr(pipe, "predict")
seen.add((family, param))
# No two configs share a (family, hyperparameter) pair.
assert len(seen) == 36
def test_the_gated_test_set_counts_and_refuses():
class AlwaysBenign:
def score(self, X, y):
return float(np.mean(np.asarray(y) == 1))
y = np.array([0, 1, 1, 1])
gate = c.GatedTestSet(np.zeros((4, 2)), y)
assert gate.evaluate(AlwaysBenign()) == 0.75
with pytest.raises(c.TestSetTouchedTwice):
gate.evaluate(AlwaysBenign())
# A fresh gate is a fresh budget; the class holds no global state.
assert c.GatedTestSet(np.zeros((4, 2)), y).evaluate(AlwaysBenign()) == 0.75
def test_the_chosen_dataset_loads_offline_and_matches_its_shape():
X, y, names = c.load_chosen_dataset()
assert X.shape == (569, 30)
assert y.shape == (569,)
assert names == ["malignant", "benign"]
assert set(y.tolist()) == {0, 1}
tests/run_tests.sh (10899 bytes)
#!/usr/bin/env bash
# Day 147 lab harness: "One Classification Project, Run Properly"
#
# 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
import classification_lib as c
errors = []
def expect(label, got, want):
if got != want:
errors.append(f"{label}: expected {want}, got {got}")
# 1. The dataset choice
rows = {r[0]: r for r in c.candidate_summaries()}
expect("iris summary", rows["iris"], ("iris", 150, 4, 3, 0.3333, 30))
expect("wine summary", rows["wine"], ("wine", 178, 13, 3, 0.3889, 36))
expect("breast_cancer summary", rows["breast_cancer"], ("breast_cancer", 569, 30, 2, 0.6316, 114))
X, y, names = c.load_chosen_dataset()
expect("chosen shape", X.shape, (569, 30))
expect("chosen names", names, ["malignant", "benign"])
# 2. The split and the baseline
x_train, x_test, y_train, y_test = c.split_once(X, y, seed=0)
expect("train shape", x_train.shape, (455, 30))
expect("test shape", x_test.shape, (114, 30))
baseline = c.majority_baseline(x_train, y_train, x_test, y_test)
expect("baseline", round(baseline, 4), 0.6316)
# 3. The sweep and selection
expect("K", c.candidate_count(), 36)
family, param, cv_mean, fitted = c.select_best(x_train, y_train, seed=0)
expect("winner", (family, param), ("logreg", 1))
expect("cv_mean", round(cv_mean, 4), 0.978)
# 4. The gate: exactly one evaluation
gate = c.GatedTestSet(x_test, y_test)
test_acc = gate.evaluate(fitted)
expect("test_acc", round(test_acc, 4), 0.9825)
expect("evaluations after first look", gate.evaluations, 1)
try:
gate.evaluate(fitted)
errors.append("the gate permitted a second evaluation, which it must not")
except c.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")
# 5. The predicted optimism
predicted = c.predicted_selection_optimism(cv_mean, len(y_train), c.candidate_count())
expect("predicted optimism", round(predicted, 4), 0.0326)
# 6. Error analysis
preds = fitted.predict(x_test)
matrix, fn, fp = c.confusion_and_errors(y_test, preds, names)
expect("confusion matrix", matrix.tolist(), [[40, 2], [0, 72]])
expect("false negatives", fn, 2)
expect("false positives", fp, 0)
# 7. The verdict
se, half_width, lower, upper = c.verdict_interval(test_acc, len(y_test))
expect("se", se, 0.0123)
expect("half_width", half_width, 0.0241)
expect("interval", (lower, upper), (0.9584, 1.0066))
expect(
"distinguishable from baseline",
c.distinguishable_from_baseline(test_acc, baseline, len(y_test)),
True,
)
# 8. The leaky version
leaky = c.leaky_selection_test_score(x_train, y_train, x_test, y_test)
expect("leaky score", leaky, 0.9825)
if leaky < round(test_acc, 4):
errors.append(f"leaky score {leaky} was lower than the honest score {round(test_acc, 4)}")
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-10 reproduced directly against classification_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. The test set is evaluated EXACTLY ONCE in the reference run"
GATE_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import classification_lib as c
X, y, _names = c.load_chosen_dataset()
x_train, x_test, y_train, y_test = c.split_once(X, y, seed=0)
_family, _param, _cv, fitted = c.select_best(x_train, y_train, seed=0)
gate = c.GatedTestSet(x_test, y_test)
assert gate.evaluations == 0, "a fresh gate must start at zero evaluations"
gate.evaluate(fitted)
assert gate.evaluations == 1, "one evaluation must advance the counter to exactly one"
refused = 0
for _ in range(5):
try:
gate.evaluate(fitted)
except c.TestSetTouchedTwice:
refused += 1
assert refused == 5, "every repeated attempt after the first must be refused"
assert gate.evaluations == 1, "repeated refused attempts must not advance the counter"
print("test set touched exactly once, five further attempts refused, counter never moved")
PYEOF
)
if echo "$GATE_CHECK" | grep -q "touched exactly once"; then
ok "GatedTestSet enforces exactly one evaluation mechanically, not by convention"
else
fail "the one-evaluation guarantee did not hold"
echo "$GATE_CHECK" | sed 's/^/ /'
fi
echo ""
echo "8. Proof the harness can fail"
SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/d147-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_classification_claims.py" <<'PYEOF'
import sys
path = sys.argv[1]
text = open(path).read()
needle = "assert matrix.tolist() == [[40, 2], [0, 72]]"
replacement = "assert matrix.tolist() == [[0, 0], [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_08_error_analysis_the_confusion_matrix"; then
ok "breaking exercise 8'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 "9. The leaky-gap direction holds beyond the quoted seed range"
DIRECTION=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import classification_lib as c
X, y, _names = c.load_chosen_dataset()
problems = []
# The leak is never negative, at seeds this lab does not quote.
rows = c.leaky_vs_honest_over_seeds(X, y, seeds=range(20, 25))
gaps = [g for _s, _h, _l, g in rows]
if not all(g >= 0 for g in gaps):
problems.append(f"a leaky gap went negative at an unquoted seed: {gaps}")
# Selecting is confined to train rows: refitting the winner never needs test.
x_train, x_test, y_train, y_test = c.split_once(X, y, seed=41)
_family, _param, cv_mean, fitted = c.select_best(x_train, y_train, seed=41)
if not (0.5 < cv_mean <= 1.0):
problems.append(f"cv_mean at an unquoted seed was out of range: {cv_mean}")
if problems:
for p in problems:
print("ERROR:", p)
else:
print("every direction held")
PYEOF
)
if [ "$DIRECTION" = "every direction held" ]; then
ok "the leaky-gap direction and the selection mechanics hold at seeds this lab does not quote"
else
fail "a direction failed beyond the quoted seeds"
echo "$DIRECTION" | sed 's/^/ /'
fi
echo ""
echo "10. 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 beyond the bundled dataset"
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
classification_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. Exercises 7b and 10b each cross-validate all 36 candidate pipelines across 20 independent seeds — 720 five-fold cross-validations apiece. On the capture machine the whole harness runs in well under a minute; on a slower one it will take longer.
No timing is asserted anywhere, so a slow machine changes nothing about
whether it passes. If you want a faster loop while developing, call
selection_optimism_over_seeds or leaky_vs_honest_over_seeds directly
with a smaller seeds=range(...) argument, and put it back before running
the harness.
My winning configuration is not LogisticRegression(C=1)
Check expected-output/FIELDS.md before assuming this is a bug. The
winner at seed 0, under the pinned versions, is ('logreg', 1) at a
cross-validated accuracy of 0.9780 — but several other configurations
score within a point or two of it, and Day 145 already established that
near-tied configurations trade places under resampling. What must hold on
any version: the winner's cross-validated accuracy is comfortably above
the 0.6316 baseline, and it was selected without ever touching the test
rows.
sqrt(2 ln K) or the predicted optimism does not match
If you compute it by hand and get something other than 0.0326, check
which n you used. predicted_selection_optimism uses the size of one
cross-validation fold (len(y_train) // folds, which is 455 // 5 = 91
here) as the "validation set" size, because that is the number of rows
each fold's held-out score is actually computed from — not the full 455
training rows.
If your number is correct and simply does not match the measured drop,
that is the point of exercise 7b, not a bug: the formula assumes
independent, zero-skill candidates, and this sweep's 36 candidates are
neither. Read the honesty note in starter/00_brief.md before concluding
anything is broken.
My leaky-gap numbers differ from the lesson's
Almost certainly fine. At any single seed the gap can land at exactly zero — a ceiling effect, since 114 test rows only support accuracy moving in steps of about 0.0088, and the honest and leaky searches sometimes land on the identical winner. What must hold across the 20-seed sweep in exercise 10b: the gap is never negative at any seed. If it has gone negative, investigate properly rather than adjusting the assertion.
LogisticRegression warns about convergence
max_iter=5000 is set everywhere in this lab specifically to avoid this
on the smaller regularisation strengths in the sweep. If you construct
your own LogisticRegression with the default max_iter=100 you may see
a ConvergenceWarning. Match the library's settings, or use its helpers
directly.
The selection-optimism and leaky-gap numbers move on my machine
Read expected-output/FIELDS.md. Every seeded split and every
cross-validation fold in this lab depends on NumPy's default_rng and
scikit-learn's internal use of it, and NumPy's documentation is explicit
that Generator gives no stream-compatibility guarantee between versions.
What must hold anywhere: cross-validation selects using train rows only, the leaky gap is never negative across the 20-seed sweep, and the test set is evaluated exactly once in the reference run. Harness check 9 confirms the leaky-gap direction and the selection mechanics survive at seeds this lab does not quote, so none of the directional claims rest on a single lucky seed.
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, plus
the breast-cancer dataset bundled inside your installed scikit-learn
package. The one write outside the lab directory is check 8 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 10 asserts that no URL appears anywhere inexamples/orstarter/source. The dataset issklearn.datasets.load_breast_cancer, bundled inside the scikit-learn package itself; nothing is downloaded and no external dataset file is fetched. - 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
This lab is a full-project version of the discipline Day 144 taught as an
isolated example: GatedTestSet here holds the real, once-selected test
rows and refuses a second look, exactly as it did there. Reading it as an
access-control pattern is still worthwhile — 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, which harness check 7 confirms with five repeated refused attempts in a row. A gate whose refusals consume budget can be drained by an attacker who never succeeds at anything.
The wider point, sharpened by this lab's leaky-selection exercise: a test
set spent by peeking rather than by an outright second .evaluate()
call is just as spent. Exercise 10 fits all 36 candidates and scores each
one on the test rows to find the best — no code anywhere calls evaluate
twice, and the leak is real anyway. A budget enforced only at one call
site is not the same as a budget enforced on the resource; this lab's
leaky_selection_test_score deliberately bypasses GatedTestSet to show
that the gate protects only the path that uses it.
What the code does that is worth understanding
- The dataset loader takes no seed and returns the same 569 rows every time, because it is bundled data, not sampled data. Every split and every cross-validation fold is separately seeded, and nothing is cached to disk or memoised across calls.
GatedTestSetholds no class-level state, so two gates are two independent budgets — the same guarantee Day 144's version made.- 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.