Machine LearningMachine Learning Fundamentals › Day 143

Hands-on lab — Day 143: The Machine Learning Workflow

Commands

Setup

cd labs/sections/machine-learning/day-143-the-machine-learning-workflow
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import numpy, sklearn; print(numpy.__version__, sklearn.__version__)"

Run

.venv/bin/pytest examples -q
.venv/bin/pytest starter -q
.venv/bin/python3 examples/report_measurements.py

Test

bash tests/run_tests.sh

File tree

examples/report_measurements.py
examples/test_workflow_claims.py
examples/test_workflow_lib.py
examples/workflow_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/test_workflow_claims.py
starter/test_workflow_lib.py
starter/workflow_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 143 lab — The Workflow, Wired Up

Lesson

Purpose

The machine learning workflow is normally drawn as a row of boxes with arrows between them. Everybody nods at the diagram, and then everybody goes and writes a notebook where the boxes are cells and the arrows are whatever order they happened to run them in.

This lab builds the same workflow with the arrows made load-bearing. Every stage declares what it requires and what it produces, the runner refuses to run a stage whose inputs are absent, and every run leaves a step log and a manifest of content hashes behind it.

The reason for all that machinery is one measurement. On a dataset of 100 rows and 5000 features where the labels are coin flips and no feature carries any information at all:

Pipeline Order Score
honest load, split, select, fit, baseline 0.50
leaky, contracts off load, select, split, fit, baseline 0.73
leaky, contracts on same as above StageContractError

Twenty-three accuracy points on data with nothing in it, produced by transposing two stages. Same data, same model, same folds, same seed. Nothing raises. Nothing warns.

The third row is the point of the lab: a stage contract is what turns a silent twenty-three point lie into a loud error naming the stage that broke.

You will also measure the decision that comes before any model exists — which metric you optimise. On an eight-percent-positive problem, a majority-class baseline scores 0.92 accuracy with zero recall, and the one model that actually finds most of the positives scores worse than the constant.

Learning objectives

By the end of this lab you will be able to:

  1. Express a workflow as stages with declared input and output contracts, rather than as cells in an execution order.
  2. Demonstrate that transposing two stages changes the reported score, and quantify by how much.
  3. Explain why an honest contract declaration is the mechanism that makes a mis-ordering detectable at all.
  4. Show that a leaky ordering's inflation grows with the number of features selected.
  5. Choose an evaluation metric before choosing a model, and demonstrate a case where the metric inverts the decision.
  6. Compare any reported score against a majority-class baseline, including the case where a useful model loses to a constant.
  7. Read a confusion matrix and state what an accuracy figure concealed.
  8. Prove a pipeline is deterministic with a manifest of content hashes, and prove the manifest is not a constant.
  9. Measure the relative size of the modelling stage in your own pipeline rather than repeating a folklore percentage.
  10. Distinguish a failure that names the broken stage from one that names a missing dictionary key.

Prerequisites

  • Day 141 for what a score means, and Day 142 for naming the setting before choosing an algorithm.
  • Day 126, whose reproducible-pipeline discipline — idempotence, determinism, contracts at both ends, a step log, a manifest of hashes — is what this lab applies to a modelling workflow.
  • Day 137 for the concept of leakage. This lab is about the ordering that causes it rather than the concept itself.
  • Days 117-118 for the standard error, which is why two honest scores here land below chance and the lab asserts an inequality rather than a value.
  • Comfort with NumPy arrays and reading a pytest failure, and python3 3.11 or newer on your PATH.

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, find and process substitution; native PowerShell is not supported.

Hardware requirements

Any machine that can run Python. The heaviest single step computes 5000 feature-label correlations five times over, which takes a few seconds here. No GPU. Around 400 MB of disk for the virtual environment, almost all of it scikit-learn and its scipy dependency.

Required software

  • Python 3.11 or newer (3.14.0 during capture).
  • bash 3.2 or newer (3.2.57 during capture — the macOS system bash).
  • The three pinned packages in requirements/requirements.txt: numpy==2.5.2, scikit-learn==1.9.0, pytest==9.1.1.

find, grep, awk, sed, diff and mktemp are used by the harness and ship with every supported system.

Free and open-source options

Everything here is free and open source, and there is no paid tier anywhere in this lab.

  • NumPy and scikit-learn are BSD 3-Clause licensed.
  • pytest is MIT licensed.
  • No dataset is downloaded or bundled: every dataset is generated on the spot from a seeded generator, so no dataset licence applies to your use of this lab.

The stage runner is written from scratch in about forty lines rather than pulled from a workflow framework. scikit-learn's own Pipeline and ColumnTransformer solve the specific leakage this lab measures, and are the right tool in practice; the lesson covers them. Kedro, Metaflow and Prefect (all free and open source) solve the same problem at project scale. None of those is installed here and no output from any of them is reproduced.

Installation

From the repository root:

cd labs/sections/machine-learning/day-143-the-machine-learning-workflow
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.

File structure

day-143-the-machine-learning-workflow/
├── 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
│   ├── workflow_lib.py            complete machinery — not the exercise
│   ├── test_workflow_lib.py       four machinery checks, already solved
│   └── test_workflow_claims.py    thirteen exercises, each a skip to replace
├── examples/
│   ├── workflow_lib.py            identical to the starter copy
│   ├── test_workflow_lib.py       the same four machinery checks
│   ├── test_workflow_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/workflow_lib.py and examples/workflow_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, thirteen exercises skip until you write them
.venv/bin/pytest examples -q Runs the reference solutions — seventeen assertions about the workflow and its ordering
.venv/bin/python3 examples/report_measurements.py Recomputes every published number and prints them as one table
bash tests/run_tests.sh Fourteen checks: version pins, every claim reproduced without pytest, both suites, the collision, a byte-comparison of the report, a deliberate self-break, the contract at five seeds, and cleanliness

Expected output

bash tests/run_tests.sh ends with:

---------------------------------------------------------------
14 checks, 0 failure(s)

and exits 0. pytest examples -q reports 17 passed. pytest starter -q reports 4 passed, 13 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 the results that are exact everywhere — the step logs, the StageContractError, the direction of the inflation, the two-runs-agree property — from the ones that hold only under the pinned versions, which includes all four manifest hashes.

Validation steps

  1. bash tests/run_tests.sh; echo "exit=$?"14 checks, 0 failure(s) and exit=0.
  2. .venv/bin/pytest examples -q17 passed.
  3. .venv/bin/pytest starter -q4 passed, 13 skipped before you start; 17 passed when you have finished every exercise.
  4. .venv/bin/python3 examples/report_measurements.py | diff - expected-output/measured-values.txt → no output.
  5. Break one assertion in examples/test_workflow_claims.py on purpose, re-run the harness, and confirm it reports failures and exits non-zero. Restore it. A test suite you have never seen fail is not evidence.

Tests

tests/run_tests.sh is a bash assert harness. It prints one ok: or FAIL: line per check, ends with N checks, M failure(s), and exits non-zero when M is not zero.

The fourteen checks are:

1-3. The installed numpy, scikit-learn and pytest match the pins exactly. 4. Every published claim reproduced directly against workflow_lib, with no pytest involved — so a broken test file cannot hide a broken library, and vice versa. 5. pytest examples -q reports 17 passed. 6. pytest starter -q reports 4 passed, 13 skipped. 7. The combined pytest examples starter invocation aborts, as documented. 8. report_measurements.py output is byte-identical to the captured table. 9-10. A scratch copy of examples/ passes, then fails with a non-zero exit and the failing test named after one assertion is deliberately rewritten. 11. The stage contract rejects the out-of-order pipeline at five different seeds, not just the one the lesson quotes. 12-14. No URL appears in any source file; no __pycache__ and no .pytest_cache are left behind.

Caches are cleared at the start of the run as well as the end, so check 13 measures what that run left rather than what a previous manual pytest invocation left.

Cleanup

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf .venv          # optional: removes the lab virtual environment
git checkout -- starter/   # optional: reset your work

The harness already removes its own scratch directory. Nothing else is created outside this directory.

Troubleshooting

See troubleshooting.md, which covers the missing virtual environment, StageContractError when you did not expect one, the bare KeyError you get with contracts disabled, the import file mismatch collision, honest scores landing below chance, manifest hashes moving with the NumPy pin, stage line counts changing when you edit the library, and LogisticRegression convergence warnings.

Security notes

See security.md. In short: no network after the install, no credentials, no sudo, and the only write outside this directory is a mktemp -d scratch directory that the harness removes in the same run. It also explains why the manifest is a supply-chain control and not only a reproducibility one.

Extension exercises

  1. Add the missing stages. This pipeline has no cleaning, no monitoring and no deployment stage, which is why exercise 7 reports its 30 percent as an upper bound. Add a clean stage with an honest contract, re-measure stage_source_lines, and report how the fraction moves.
  2. Make the leaky pipeline pass its contracts, dishonestly. Change select to declare requires=("X", "y", "k") and watch the whole thing run green at 0.73. Then write two sentences on what that tells you about where the real control lives.
  3. Replace the runner with sklearn.pipeline.Pipeline. Wrap the selection and the model in a Pipeline and pass it to cross_val_score. Confirm you get the honest number, and explain in a comment which part of Pipeline makes the leak impossible.
  4. Find the threshold. Exercise 4 compares logistic regression at its default threshold against balanced class weights. Instead, sweep the decision threshold from 0.05 to 0.95 with predict_proba and plot precision against recall. Report the threshold at which F1 is highest and compare it to both models in the table.
  5. Break determinism on purpose. Remove random_state from the StratifiedKFold in folds() and confirm the manifest stops matching between runs. Then say what you would have concluded if you had found that in a real project without a manifest to tell you.
  6. Extend the manifest. Hash the selected feature indices as well, and check whether the honest pipeline picks the same features at two different seeds. Report what you find and what it implies about the stability of correlation-based selection.
  7. Cost the ordering error. At k = 50 the wrong order invents 0.47 of accuracy. Find the k at which the inflation is largest by sweeping k from 5 to 100, and explain the shape of the curve.
  • Lab brief: starter/00_brief.md
  • Previous lab: ../day-142-supervised-unsupervised-and-reinforcement-learning/
  • Next lab: ../day-144-train-validation-and-test-splits/
  • 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 structural facts about the pipeline, not measurements that
happened to come out a certain way.

- **The step logs.** `load, split, select, fit_and_score, baseline` for
  the honest pipeline and `load, select, split, fit_and_score, baseline`
  for the leaky one. These are the orders the two stage lists are written
  in, and nothing random touches them.
- **`StageContractError` naming `'select'` and `folds`.** The leaky
  pipeline puts `select` before `split`, and `select` declares `folds`
  among its requirements. The runner checks requirements against the keys
  present, which is pure dictionary arithmetic. This fires on every seed,
  on every machine, forever — check 8 of the harness runs it at five
  different seeds to make that concrete.
- **The stage line counts** — `load` 5, `split` 2, `select` 10,
  `fit_and_score` 9, `baseline` 4, total 30. These come from
  `inspect.getsource` over source files that ship in this directory. They
  will change if you edit the library, which is intended: the measurement
  is of *this* pipeline.
- **The confusion matrix summing to 2000**, and the accuracy recomputed
  from it agreeing with `accuracy_score`. Arithmetic.
- **The majority baseline having recall exactly 0.0.** A model that never
  predicts the positive class catches none of them, by definition.
- **Two runs at the same seed producing the same manifest.** This is what
  determinism means. If it ever fails, something genuinely non-deterministic
  has entered the pipeline and that is a real bug, not a version drift.
- **Every wrong-order score exceeding its honest counterpart** in the
  `inflation_by_k` table, and every honest score sitting at or below
  chance. The specific values move with the pins; the direction does not,
  because the wrong order fits the feature selection to the answers.

## Exact under these pins, and only these

Everything else depends on NumPy's `default_rng` bit stream and on
scikit-learn's estimator internals. **NumPy's own documentation states
that `Generator` carries no stream-compatibility guarantee across
versions**, so seeding makes these reproducible under the pins in
`requirements/requirements.txt` and not beyond them.

| Value | Exercise | What it is |
| --- | --- | --- |
| `0.5000` | 2 | honest cross-validated accuracy on 100 rows of pure noise |
| `0.5400` | 2 | majority-class baseline — 100 coin flips do not land exactly even |
| `[0.5, 0.55, 0.5, 0.4, 0.55]` | 2 | the five per-fold scores behind that 0.5 |
| `0.7300` | 3 | the leaky pipeline's score, contracts disabled |
| `0.2300` | 3 | accuracy invented purely by transposing two stages |
| the `inflation_by_k` table | 3b | wrong and right scores at k = 5, 10, 20, 50 |
| `0.9200` / `0.0000` | 4 | the majority baseline's accuracy and recall |
| `0.9435` / `0.4813` | 4 | logistic regression at its default threshold |
| `0.8685` / `0.8438` | 4 | the same model with balanced class weights |
| `0.9360`, `0.9275` | 4 | 5-NN and the depth-3 tree |
| `[[1810, 30], [83, 77]]` | 5 | the confusion matrix |
| `51b0a421bd652dd2` and the other three hashes | 6 | the manifest |
| `160` positives in `2000` rows | 4 | the test set's composition |

The manifest hashes deserve a note of their own. They are SHA-256 over the
raw bytes of the arrays, so they depend on the exact float values, which
depend on the generator stream. A different NumPy will give you four
different hashes and the two-runs-agree property will still hold. **That
property is the one worth asserting; the literal hashes are a convenience
for spotting silent drift on this machine.**

## Sampled, and therefore soft even here

- **The honest score of `0.5000` is not a guarantee that the method is
  unbiased**, only that it landed on chance for this seed. The per-fold
  scores range from 0.40 to 0.55, which is what 20 test rows per fold buys
  you: a standard error of roughly 0.11. The point of the exercise is the
  *gap* between 0.50 and 0.73, not the 0.50.
- **`0.39` and `0.38`, the honest scores at k=5 and k=50, are below
  chance.** That is not a bug and it is not evidence of anti-learning. With
  100 rows and 5 folds, an estimate of a 0.5 quantity wanders, and it
  wanders below as readily as above. The lab asserts `right <= 0.5` rather
  than `right == 0.5` for exactly this reason.

## Timings

No timing is asserted anywhere in this lab. The heaviest step is computing
5000 correlations five times over, which takes a few seconds here and will
take longer elsewhere without changing a single assertion, because every
assertion is about a shape or a value.

examples-run.txt

.................                                                        [100%]
17 passed in 5.09s

measured-values.txt

Day 143 -- the machine learning workflow, measured
=================================================

1. The pipeline as stages, with a step log
------------------------------------------
    load           -> X, y
    split          -> folds
    select         -> selected
    fit_and_score  -> fold_scores, score
    baseline       -> baseline

2. The honest pipeline on data that is pure noise
-------------------------------------------------
  cross-validated accuracy               : 0.5000
  majority-class baseline                : 0.5400
  per-fold scores                        : [0.5, 0.55, 0.5, 0.4, 0.55]
  the labels are coin flips, so chance is the only honest answer

3. The same five stages, two of them transposed
-----------------------------------------------
  split then select (correct)            : 0.5000
  select then split (wrong, unchecked)   : 0.7300
  accuracy invented by the reordering    : 0.2300
    correct order : load -> split -> select -> fit_and_score -> baseline
    wrong order   : load -> select -> split -> fit_and_score -> baseline
  with contracts enforced                : StageContractError
    stage 'select' requires ['folds'] which no earlier stage produced

3b. How the inflation grows with the number of features chosen
--------------------------------------------------------------
    k    wrong   right   invented
     5   0.6500  0.3900   +0.2600
    10   0.7200  0.5000   +0.2200
    20   0.7300  0.5000   +0.2300
    50   0.8500  0.3800   +0.4700

4. The metric decides which model you ship
------------------------------------------
  model                              acc    prec  recall      f1
  majority baseline               0.9200  0.0000  0.0000  0.0000
  logistic (default threshold)    0.9435  0.7196  0.4813  0.5768
  logistic (balanced)             0.8685  0.3619  0.8438  0.5066
  5-NN                            0.9360  0.6739  0.3875  0.4921
  depth-3 tree                    0.9275  0.6027  0.2750  0.3777
  best by accuracy  : logistic (default threshold)
  best by precision : logistic (default threshold)
  best by recall    : logistic (balanced)
  best by f1        : logistic (default threshold)
  test set carries 160 positives in 2000 rows

5. Error analysis: what the accuracy hides
------------------------------------------
  rows = true class, columns = predicted class
    true 0: [1810, 30]
    true 1: [83, 77]
  94.35 percent accurate, and it misses 83 positives while catching 77

6. Reproducibility: the same inputs give the same artifact
----------------------------------------------------------
    X            51b0a421bd652dd2
    fold_scores  8f0ac332958b9bc4
    score        d2cbad71ff333de6
    y            9984503b5352c5a1
  two runs at seed 143 agree             : True
  a run at seed 144 differs              : True

7. The modelling stage is the small one
---------------------------------------
    load             5 lines
    split            2 lines
    select          10 lines
    fit_and_score    9 lines
    baseline         4 lines
    total           30 lines
  the fitting stage is 30.00% of this pipeline
  and this pipeline has no cleaning, monitoring or deployment stage,
  so that figure is an upper bound rather than an estimate

starter-run.txt

sssssssssssss....                                                        [100%]
4 passed, 13 skipped in 0.58s

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-8 reproduced directly against workflow_lib, no pytest involved

3. examples/ passes in full
  ok: pytest examples -q -> 17 passed

4. starter/ is an untouched skeleton
  ok: pytest starter -q -> 4 passed, 13 skipped (the machinery checks pass; the thirteen exercises are stubs)

5. pytest examples starter (one invocation) aborts on the module-name collision
  ok: combined invocation reports import file mismatch, as documented -- never run starter and examples together

6. The report reproduces the captured table exactly
  ok: report_measurements.py output is byte-identical to expected-output/measured-values.txt

7. Proof the harness can fail
  ok: scratch copy of examples/ passes before it is broken
  ok: breaking exercise 3's assertion produces a non-zero exit and names the failing test

8. The contract catches an out-of-order pipeline every time, not just once
  ok: the stage contract rejects the out-of-order pipeline at every seed tried

9. Offline, and nothing left behind
  ok: no URLs inside examples/ or starter/ source -- this lab reaches no network
  ok: no __pycache__ left behind (cleaned during this run)
  ok: no .pytest_cache left behind (cleaned during this run)

---------------------------------------------------------------
14 checks, 0 failure(s)

Source files

examples/report_measurements.py (4788 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 workflow_lib as w  # noqa: E402


def rule(title: str) -> None:
    print()
    print(title)
    print("-" * len(title))


def main() -> None:
    print("Day 143 -- the machine learning workflow, measured")
    print("=" * 49)

    rule("1. The pipeline as stages, with a step log")
    honest = w.run_pipeline(w.honest_stages(), w.starting_artifact())
    for name, produced in honest.log:
        print(f"    {name:<14} -> {', '.join(produced)}")

    rule("2. The honest pipeline on data that is pure noise")
    print(f"  cross-validated accuracy               : {honest.data['score']:.4f}")
    print(f"  majority-class baseline                : {honest.data['baseline']:.4f}")
    print(f"  per-fold scores                        : {honest.data['fold_scores'].tolist()}")
    print("  the labels are coin flips, so chance is the only honest answer")

    rule("3. The same five stages, two of them transposed")
    leaky = w.run_pipeline(w.leaky_stages(), w.starting_artifact(), enforce_contracts=False)
    print(f"  split then select (correct)            : {honest.data['score']:.4f}")
    print(f"  select then split (wrong, unchecked)   : {leaky.data['score']:.4f}")
    print(f"  accuracy invented by the reordering    : {leaky.data['score'] - honest.data['score']:.4f}")
    print(f"    correct order : {' -> '.join(n for n, _ in honest.log)}")
    print(f"    wrong order   : {' -> '.join(n for n, _ in leaky.log)}")
    try:
        w.run_pipeline(w.leaky_stages(), w.starting_artifact(), enforce_contracts=True)
        print("  with contracts enforced                : NO ERROR RAISED")
    except w.StageContractError as exc:
        print(f"  with contracts enforced                : {type(exc).__name__}")
        print(f"    {exc}")

    rule("3b. How the inflation grows with the number of features chosen")
    X, y = w.noise_dataset()
    print("    k    wrong   right   invented")
    for k, wrong, right, gap in w.inflation_by_k(X, y, [5, 10, 20, 50]):
        print(f"    {k:2d}   {wrong:.4f}  {right:.4f}   {gap:+.4f}")

    rule("4. The metric decides which model you ship")
    train = w.imbalanced_dataset(1000, 11)
    test = w.imbalanced_dataset(2000, 12)
    scores = w.score_all(w.candidate_models(), train[0], train[1], test[0], test[1])
    print(f"  {'model':<30}{'acc':>8}{'prec':>8}{'recall':>8}{'f1':>8}")
    for name, s in scores.items():
        print(
            f"  {name:<30}{s['accuracy']:>8.4f}{s['precision']:>8.4f}"
            f"{s['recall']:>8.4f}{s['f1']:>8.4f}"
        )
    for metric in ("accuracy", "precision", "recall", "f1"):
        print(f"  best by {metric:<10}: {w.winner(scores, metric)}")
    print(f"  test set carries {int(test[1].sum())} positives in {len(test[1])} rows")

    rule("5. Error analysis: what the accuracy hides")
    model = w.candidate_models()["logistic (default threshold)"]
    model.fit(train[0], train[1])
    table = w.error_table(model, test[0], test[1])
    print("  rows = true class, columns = predicted class")
    print(f"    true 0: {table[0]}")
    print(f"    true 1: {table[1]}")
    print(f"  94.35 percent accurate, and it misses {table[1][0]} positives while catching {table[1][1]}")

    rule("6. Reproducibility: the same inputs give the same artifact")
    keys = ("X", "y", "fold_scores", "score")
    first = w.manifest(w.run_pipeline(w.honest_stages(), w.starting_artifact()), keys)
    second = w.manifest(w.run_pipeline(w.honest_stages(), w.starting_artifact()), keys)
    other = w.manifest(w.run_pipeline(w.honest_stages(), w.starting_artifact(seed=144)), keys)
    for key in sorted(first):
        print(f"    {key:<12} {first[key]}")
    print(f"  two runs at seed 143 agree             : {first == second}")
    print(f"  a run at seed 144 differs              : {first != other}")

    rule("7. The modelling stage is the small one")
    lines = w.stage_source_lines(w.honest_stages())
    total = sum(lines.values())
    for name in ("load", "split", "select", "fit_and_score", "baseline"):
        print(f"    {name:<14} {lines[name]:>3} lines")
    print(f"    {'total':<14} {total:>3} lines")
    print(f"  the fitting stage is {lines['fit_and_score'] / total:.2%} of this pipeline")
    print("  and this pipeline has no cleaning, monitoring or deployment stage,")
    print("  so that figure is an upper bound rather than an estimate")


if __name__ == "__main__":
    main()
examples/test_workflow_claims.py (8832 bytes)
"""The reference solutions: what the workflow is, and what its order costs.

Every number here was captured from a real run of this file on the
authoring machine. If a number changes, the claim in the lesson is wrong
and one of the two must be fixed.
"""

import numpy as np
import pytest

import workflow_lib as w


@pytest.fixture(scope="module")
def noise():
    return w.noise_dataset()


@pytest.fixture(scope="module")
def imbalanced():
    return w.imbalanced_dataset(1000, 11), w.imbalanced_dataset(2000, 12)


# --- 1. The workflow is stages with contracts, not boxes with arrows ------


def test_01_the_honest_pipeline_runs_in_the_declared_order():
    result = w.run_pipeline(w.honest_stages(), w.starting_artifact())
    assert [name for name, _keys in result.log] == [
        "load",
        "split",
        "select",
        "fit_and_score",
        "baseline",
    ]
    # Each stage recorded exactly the keys it declared it would produce.
    produced = dict(result.log)
    assert produced["load"] == ("X", "y")
    assert produced["split"] == ("folds",)
    assert produced["select"] == ("selected",)
    assert produced["fit_and_score"] == ("fold_scores", "score")
    assert produced["baseline"] == ("baseline",)


def test_01b_a_stage_never_mutates_the_artifact_it_was_given():
    start = w.starting_artifact()
    before = set(start.data)
    w.run_pipeline(w.honest_stages(), start)
    # The starting artifact is untouched: every stage returned a new one.
    assert set(start.data) == before
    assert start.log == []


# --- 2. The honest pipeline reports chance on data that is chance --------


def test_02_the_honest_pipeline_reports_chance_on_pure_noise():
    result = w.run_pipeline(w.honest_stages(), w.starting_artifact())
    assert result.data["score"] == 0.5
    assert result.data["baseline"] == 0.54
    assert result.data["fold_scores"].shape == (5,)
    # Labels are coin flips, so chance is the only honest answer.
    assert abs(result.data["score"] - 0.5) < 0.01


# --- 3. The same stages in the wrong order, and what a contract is for ---


def test_03_reordering_two_stages_invents_twenty_three_accuracy_points():
    honest = w.run_pipeline(w.honest_stages(), w.starting_artifact())
    leaky = w.run_pipeline(w.leaky_stages(), w.starting_artifact(), enforce_contracts=False)
    assert honest.data["score"] == 0.5
    assert leaky.data["score"] == 0.73
    assert round(leaky.data["score"] - honest.data["score"], 4) == 0.23
    # The step logs differ by one transposition and nothing else.
    assert [n for n, _ in honest.log] == ["load", "split", "select", "fit_and_score", "baseline"]
    assert [n for n, _ in leaky.log] == ["load", "select", "split", "fit_and_score", "baseline"]


def test_03b_the_contract_turns_a_silent_lie_into_a_named_failure():
    with pytest.raises(w.StageContractError) as excinfo:
        w.run_pipeline(w.leaky_stages(), w.starting_artifact(), enforce_contracts=True)
    message = str(excinfo.value)
    assert "'select'" in message
    assert "folds" in message
    # The honest pipeline passes the same contracts without complaint.
    ok = w.run_pipeline(w.honest_stages(), w.starting_artifact(), enforce_contracts=True)
    assert ok.data["score"] == 0.5


def test_03c_the_inflation_grows_with_the_number_of_features_chosen(noise):
    X, y = noise
    rows = w.inflation_by_k(X, y, [5, 10, 20, 50])
    assert rows == [
        (5, 0.65, 0.39, 0.26),
        (10, 0.72, 0.5, 0.22),
        (20, 0.73, 0.5, 0.23),
        (50, 0.85, 0.38, 0.47),
    ]
    # Every wrong-order score beats its honest counterpart, at every k.
    assert all(wrong > right for _k, wrong, right, _gap in rows)
    # And the honest scores stay at or below chance, as they must.
    assert all(right <= 0.5 for _k, _wrong, right, _gap in rows)


# --- 4. The metric is chosen before the model, and it decides ------------


def test_04_the_metric_you_choose_decides_which_model_you_ship(imbalanced):
    (X_train, y_train), (X_test, y_test) = imbalanced
    scores = w.score_all(w.candidate_models(), X_train, y_train, X_test, y_test)
    assert scores["majority baseline"] == {
        "accuracy": 0.92,
        "precision": 0.0,
        "recall": 0.0,
        "f1": 0.0,
    }
    assert scores["logistic (default threshold)"]["accuracy"] == 0.9435
    assert scores["logistic (default threshold)"]["recall"] == 0.4813
    assert scores["logistic (balanced)"]["accuracy"] == 0.8685
    assert scores["logistic (balanced)"]["recall"] == 0.8438
    # The decision inverts on the metric, with nothing else changing.
    assert w.winner(scores, "accuracy") == "logistic (default threshold)"
    assert w.winner(scores, "recall") == "logistic (balanced)"
    assert w.winner(scores, "accuracy") != w.winner(scores, "recall")


def test_04b_a_model_that_never_predicts_the_positive_class_scores_ninety_two(
    imbalanced,
):
    (X_train, y_train), (X_test, y_test) = imbalanced
    scores = w.score_all(w.candidate_models(), X_train, y_train, X_test, y_test)
    baseline = scores["majority baseline"]
    assert baseline["accuracy"] == 0.92 and baseline["recall"] == 0.0
    beats = {
        name: s["accuracy"]
        for name, s in scores.items()
        if s["accuracy"] > baseline["accuracy"]
    }
    # Three of the four real models beat a constant -- by at most 2.35 points.
    assert set(beats) == {"logistic (default threshold)", "5-NN", "depth-3 tree"}
    assert round(max(beats.values()) - baseline["accuracy"], 4) == 0.0235
    # And the one that actually finds positives is the one that loses here.
    assert scores["logistic (balanced)"]["accuracy"] < baseline["accuracy"]
    assert scores["logistic (balanced)"]["recall"] == 0.8438
    assert int(y_test.sum()) == 160 and len(y_test) == 2000


# --- 5. Error analysis is a stage, not an afterthought ------------------


def test_05_the_confusion_matrix_says_what_the_accuracy_hides(imbalanced):
    (X_train, y_train), (X_test, y_test) = imbalanced
    model = w.candidate_models()["logistic (default threshold)"]
    model.fit(X_train, y_train)
    table = w.error_table(model, X_test, y_test)
    assert table == [[1810, 30], [83, 77]]
    true_negative, false_positive = table[0]
    false_negative, true_positive = table[1]
    assert true_negative + false_positive + false_negative + true_positive == 2000
    # 94.35% accurate, and it misses more positives than it catches.
    assert false_negative > true_positive
    assert round((true_negative + true_positive) / 2000, 4) == 0.9435


# --- 6. Reproducibility: the same inputs must give the same artifact ----


def test_06_two_runs_of_the_pipeline_are_byte_identical():
    keys = ("X", "y", "fold_scores", "score")
    first = w.manifest(w.run_pipeline(w.honest_stages(), w.starting_artifact()), keys)
    second = w.manifest(w.run_pipeline(w.honest_stages(), w.starting_artifact()), keys)
    assert first == second
    assert first == {
        "X": "51b0a421bd652dd2",
        "fold_scores": "8f0ac332958b9bc4",
        "score": "d2cbad71ff333de6",
        "y": "9984503b5352c5a1",
    }


def test_06b_a_different_seed_produces_a_different_manifest():
    keys = ("X", "y", "fold_scores", "score")
    first = w.manifest(w.run_pipeline(w.honest_stages(), w.starting_artifact()), keys)
    other = w.manifest(
        w.run_pipeline(w.honest_stages(), w.starting_artifact(seed=144)), keys
    )
    assert first != other
    # A manifest that never changes is not evidence of determinism.
    assert first["X"] != other["X"]


# --- 7. The modelling stage is the small one ---------------------------


def test_07_the_modelling_stage_is_the_smallest_part_of_the_pipeline():
    lines = w.stage_source_lines(w.honest_stages())
    assert lines == {"load": 5, "split": 2, "select": 10, "fit_and_score": 9, "baseline": 4}
    total = sum(lines.values())
    assert total == 30
    assert round(lines["fit_and_score"] / total, 4) == 0.3
    # And this pipeline has no cleaning, no monitoring and no deployment
    # stage at all -- so 30% is an upper bound, not an estimate.
    assert set(lines) == {"load", "split", "select", "fit_and_score", "baseline"}
    assert "clean" not in lines and "monitor" not in lines and "deploy" not in lines


# --- 8. A missing input is caught before it becomes a wrong number ------


def test_08_a_stage_cannot_run_without_the_inputs_it_declared():
    stages = w.honest_stages()
    empty = w.Artifact(data={})
    with pytest.raises(w.StageContractError) as excinfo:
        w.run_pipeline(stages, empty)
    assert "'load'" in str(excinfo.value)
    # With contracts off it fails too -- but as a KeyError deep inside the
    # stage, naming a dictionary key rather than the stage that broke.
    with pytest.raises(KeyError):
        w.run_pipeline(stages, empty, enforce_contracts=False)
examples/test_workflow_lib.py (2357 bytes)
"""Machinery checks: the runner itself behaves, before any claim is made.

These four tests are solved in both `starter/` and `examples/`. They exist
so that a broken runner reports itself as a broken runner rather than as a
surprising scientific result.
"""

import numpy as np
import pytest

import workflow_lib as w


def test_the_artifact_is_immutable_and_carries_its_history():
    start = w.Artifact(data={"a": 1})
    nxt = start.with_(b=2)
    assert start.data == {"a": 1}
    assert nxt.data == {"a": 1, "b": 2}
    assert nxt is not start
    # Overwriting a key is allowed; silently mutating the original is not.
    third = nxt.with_(a=99)
    assert third.data["a"] == 99 and nxt.data["a"] == 1


def test_the_fingerprint_is_stable_content_addressing():
    a = np.arange(10)
    b = np.arange(10)
    assert w.fingerprint(a) == w.fingerprint(b)
    assert w.fingerprint(a) != w.fingerprint(np.arange(11))
    # dtype is part of the identity: the same values in a different type
    # are a different artifact, and treating them as equal hides real bugs.
    assert w.fingerprint(np.arange(10)) != w.fingerprint(np.arange(10, dtype=float))
    assert len(w.fingerprint(a)) == 16


def test_the_contract_checks_both_directions():
    called = []

    def under_producing(_artifact):
        called.append("under")
        return {}

    stages = [w.Stage("under", under_producing, requires=(), produces=("x",))]
    with pytest.raises(w.StageContractError) as excinfo:
        w.run_pipeline(stages, w.Artifact(data={}))
    assert "'under'" in str(excinfo.value)
    assert called == ["under"]

    def over_producing(_artifact):
        return {"x": 1, "surprise": 2}

    stages = [w.Stage("over", over_producing, requires=(), produces=("x",))]
    with pytest.raises(w.StageContractError):
        w.run_pipeline(stages, w.Artifact(data={}))


def test_the_folds_are_stratified_and_cover_every_row_exactly_once():
    X, y = w.imbalanced_dataset(500, seed=3)
    splits = w.folds(X, y, n_splits=5, seed=143)
    assert len(splits) == 5
    seen = np.concatenate([test for _train, test in splits])
    assert sorted(seen.tolist()) == list(range(500))
    # Stratified: every fold carries roughly the population positive rate.
    rate = float(y.mean())
    for _train, test in splits:
        assert abs(float(y[test].mean()) - rate) < 0.02
examples/workflow_lib.py (14515 bytes)
"""The machine learning workflow as runnable stages, with contracts.

The workflow in the textbook is a row of boxes with arrows. This module is
the same workflow with the arrows made load-bearing: every stage declares
what it requires and what it produces, the runner refuses to run a stage
whose inputs are absent, and every run leaves a step log and a manifest of
content hashes behind it.

That is not ceremony. The point of this lab is that **the same three
stages in a different order produce a different number**, and that the
wrong order is silent. A stage contract is what turns a silent
twenty-three point lie into a loud error naming the stage that broke.
"""

from __future__ import annotations

import hashlib
import inspect
from dataclasses import dataclass, field
from typing import Callable

import numpy as np

from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    accuracy_score,
    confusion_matrix,
    f1_score,
    precision_score,
    recall_score,
)
from sklearn.model_selection import StratifiedKFold
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier


class StageContractError(RuntimeError):
    """Raised when a stage is asked to run without the inputs it declared."""


# --------------------------------------------------------------------------
# The runner
# --------------------------------------------------------------------------


@dataclass
class Artifact:
    """Everything the pipeline knows so far, plus how it came to know it."""

    data: dict = field(default_factory=dict)
    log: list = field(default_factory=list)

    def with_(self, **produced) -> "Artifact":
        """Return a new artifact carrying the additional keys.

        Deliberately not in-place. A stage that mutates its input makes the
        step log a work of fiction, because the log then describes states
        that no longer exist.
        """
        merged = dict(self.data)
        merged.update(produced)
        return Artifact(data=merged, log=list(self.log))


@dataclass
class Stage:
    """One step of the workflow, with its input and output contract."""

    name: str
    run: Callable[[Artifact], dict]
    requires: tuple = ()
    produces: tuple = ()


def run_pipeline(stages, artifact: Artifact, enforce_contracts: bool = True) -> Artifact:
    """Run every stage in order, recording what each one did.

    With ``enforce_contracts=False`` the runner behaves like most real
    pipelines: it runs whatever it is given, in whatever order, and reports
    a number. Exercise 3 measures what that costs.
    """
    for stage in stages:
        if enforce_contracts:
            missing = [k for k in stage.requires if k not in artifact.data]
            if missing:
                raise StageContractError(
                    f"stage {stage.name!r} requires {missing} which no earlier stage produced"
                )
        produced = stage.run(artifact)
        if enforce_contracts:
            unexpected = sorted(set(produced) - set(stage.produces))
            absent = sorted(set(stage.produces) - set(produced))
            if absent or unexpected:
                raise StageContractError(
                    f"stage {stage.name!r} declared {list(stage.produces)} "
                    f"but produced {sorted(produced)}"
                )
        artifact = artifact.with_(**produced)
        artifact.log.append((stage.name, tuple(sorted(produced))))
    return artifact


def step_log(artifact: Artifact):
    """The ordered record of which stage produced which keys."""
    return list(artifact.log)


def fingerprint(value) -> str:
    """A stable content hash for an array, a number or a string."""
    if isinstance(value, np.ndarray):
        payload = np.ascontiguousarray(value).tobytes() + str(value.dtype).encode()
    else:
        payload = repr(value).encode()
    return hashlib.sha256(payload).hexdigest()[:16]


def manifest(artifact: Artifact, keys) -> dict:
    """Content hashes for the named keys, so two runs can be compared.

    A pipeline that cannot prove it produced the same thing twice cannot be
    debugged, because you can never tell a fix from a coincidence.
    """
    return {k: fingerprint(artifact.data[k]) for k in sorted(keys)}


def stage_source_lines(stages) -> dict:
    """How many lines of code each stage's function actually is.

    Self-referential on purpose: exercise 7 uses this to measure the shape
    of this very pipeline, rather than repeating the folklore figure about
    how much of the work is not modelling.
    """
    out = {}
    for stage in stages:
        source = inspect.getsource(stage.run)
        lines = [ln for ln in source.splitlines() if ln.strip() and not ln.strip().startswith("#")]
        out[stage.name] = len(lines)
    return out


# --------------------------------------------------------------------------
# Datasets
# --------------------------------------------------------------------------


def noise_dataset(n_samples: int = 100, n_features: int = 5000, seed: int = 143):
    """Labels are coin flips. No feature carries any information whatsoever.

    Any workflow that reports better than chance on this data has a bug,
    and the bug is what exercise 3 is about.
    """
    rng = np.random.default_rng(seed)
    X = rng.normal(size=(n_samples, n_features))
    y = rng.integers(0, 2, size=n_samples)
    return X, y


def imbalanced_dataset(n: int, seed: int, rate: float = 0.08):
    """A rare positive class whose features overlap the negatives.

    Eight percent positive, shifted by 1.1 standard deviations. Separable
    enough to be worth modelling, overlapping enough that the metric you
    choose decides which model wins.
    """
    rng = np.random.default_rng(seed)
    n_pos = int(round(n * rate))
    y = np.zeros(n, dtype=int)
    y[:n_pos] = 1
    X = rng.normal(size=(n, 4))
    X[y == 1] += 1.1
    order = rng.permutation(n)
    return X[order], y[order]


# --------------------------------------------------------------------------
# The individual steps, usable on their own
# --------------------------------------------------------------------------


def correlation_ranking(X, y):
    """Absolute correlation between each column and the label."""
    y = np.asarray(y, dtype=float)
    out = np.empty(X.shape[1])
    for j in range(X.shape[1]):
        out[j] = abs(float(np.corrcoef(X[:, j], y)[0, 1]))
    return out


def top_k_features(X, y, k: int):
    """The k columns most correlated with the label, as indices."""
    return np.argsort(correlation_ranking(X, y))[-k:]


def folds(X, y, n_splits: int = 5, seed: int = 143):
    """Deterministic stratified folds, so both orderings see the same splits."""
    return list(StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed).split(X, y))


def select_then_split_score(X, y, k: int = 20, n_splits: int = 5, seed: int = 143) -> float:
    """The WRONG order: choose features using every row, then cross-validate.

    Every fold's test rows helped choose the features, so the features were
    fitted to the answers. Nothing raises. A number comes out.
    """
    chosen = top_k_features(X, y, k)
    scores = []
    for train, test in folds(X, y, n_splits, seed):
        model = KNeighborsClassifier(1).fit(X[train][:, chosen], y[train])
        scores.append(float(np.mean(model.predict(X[test][:, chosen]) == y[test])))
    return float(np.mean(scores))


def split_then_select_score(X, y, k: int = 20, n_splits: int = 5, seed: int = 143) -> float:
    """The RIGHT order: split first, then choose features inside each fold."""
    scores = []
    for train, test in folds(X, y, n_splits, seed):
        chosen = top_k_features(X[train], y[train], k)
        model = KNeighborsClassifier(1).fit(X[train][:, chosen], y[train])
        scores.append(float(np.mean(model.predict(X[test][:, chosen]) == y[test])))
    return float(np.mean(scores))


def inflation_by_k(X, y, ks, n_splits: int = 5, seed: int = 143):
    """How much the wrong order inflates the score, at each feature count."""
    rows = []
    for k in ks:
        wrong = select_then_split_score(X, y, k, n_splits, seed)
        right = split_then_select_score(X, y, k, n_splits, seed)
        rows.append((k, round(wrong, 4), round(right, 4), round(wrong - right, 4)))
    return rows


# --------------------------------------------------------------------------
# Metrics, baselines and error analysis
# --------------------------------------------------------------------------


def candidate_models() -> dict:
    """The five candidates exercise 4 compares. Settings are fixed."""
    return {
        "majority baseline": DummyClassifier(strategy="most_frequent"),
        "logistic (default threshold)": LogisticRegression(max_iter=1000),
        "logistic (balanced)": LogisticRegression(max_iter=1000, class_weight="balanced"),
        "5-NN": KNeighborsClassifier(5),
        "depth-3 tree": DecisionTreeClassifier(max_depth=3, random_state=0),
    }


def score_all(models: dict, X_train, y_train, X_test, y_test) -> dict:
    """Accuracy, precision, recall and F1 for every candidate."""
    out = {}
    for name, model in models.items():
        model.fit(X_train, y_train)
        pred = model.predict(X_test)
        out[name] = {
            "accuracy": round(float(accuracy_score(y_test, pred)), 4),
            "precision": round(float(precision_score(y_test, pred, zero_division=0)), 4),
            "recall": round(float(recall_score(y_test, pred, zero_division=0)), 4),
            "f1": round(float(f1_score(y_test, pred, zero_division=0)), 4),
        }
    return out


def winner(scores: dict, metric: str) -> str:
    """Which candidate a given metric would have you ship."""
    return max(scores, key=lambda name: scores[name][metric])


def error_table(model, X_test, y_test):
    """The confusion matrix, as plain integers -- rows true, columns predicted."""
    return confusion_matrix(y_test, model.predict(X_test)).tolist()


# --------------------------------------------------------------------------
# The stages themselves
# --------------------------------------------------------------------------


def _stage_load(artifact: Artifact) -> dict:
    X, y = noise_dataset(
        artifact.data["n_samples"], artifact.data["n_features"], artifact.data["seed"]
    )
    return {"X": X, "y": y}


def _stage_split(artifact: Artifact) -> dict:
    return {"folds": folds(artifact.data["X"], artifact.data["y"], seed=artifact.data["seed"])}


def _stage_select(artifact: Artifact) -> dict:
    """Choose features. Requires `folds`, so it cannot run before the split."""
    per_fold = []
    for train, _test in artifact.data["folds"]:
        per_fold.append(
            top_k_features(
                artifact.data["X"][train], artifact.data["y"][train], artifact.data["k"]
            )
        )
    return {"selected": per_fold}


def _stage_leaky_select(artifact: Artifact) -> dict:
    """Choose features from every row at once, then reuse them in every fold.

    This is what a leaky pipeline actually does. It is not a strawman: the
    global selection is computed once, which is cheaper, and then handed to
    every fold as though it had been computed there.
    """
    chosen = top_k_features(artifact.data["X"], artifact.data["y"], artifact.data["k"])
    n_folds = len(artifact.data.get("folds", [])) or artifact.data["n_splits"]
    return {"selected": [chosen for _ in range(n_folds)]}


def _stage_fit_and_score(artifact: Artifact) -> dict:
    scores = []
    for (train, test), chosen in zip(artifact.data["folds"], artifact.data["selected"]):
        model = KNeighborsClassifier(1).fit(
            artifact.data["X"][train][:, chosen], artifact.data["y"][train]
        )
        pred = model.predict(artifact.data["X"][test][:, chosen])
        scores.append(float(np.mean(pred == artifact.data["y"][test])))
    return {"fold_scores": np.array(scores), "score": float(np.mean(scores))}


def _stage_baseline(artifact: Artifact) -> dict:
    y = artifact.data["y"]
    counts = np.bincount(y)
    return {"baseline": float(counts.max() / len(y))}


def honest_stages():
    """Load, split, select inside the split, fit, baseline. In that order."""
    return [
        Stage("load", _stage_load, requires=("n_samples", "n_features", "seed"), produces=("X", "y")),
        Stage("split", _stage_split, requires=("X", "y", "seed"), produces=("folds",)),
        Stage("select", _stage_select, requires=("X", "y", "folds", "k"), produces=("selected",)),
        Stage(
            "fit_and_score",
            _stage_fit_and_score,
            requires=("X", "y", "folds", "selected"),
            produces=("fold_scores", "score"),
        ),
        Stage("baseline", _stage_baseline, requires=("y",), produces=("baseline",)),
    ]


def leaky_stages():
    """The same five stages, with selection moved in front of the split.

    Note the `select` stage still declares `folds` among its requirements,
    because that requirement is *true*: choosing features is a per-fold
    operation. Declaring it honestly is the entire mechanism by which the
    runner can notice the ordering is wrong. A team that writes
    `requires=("X", "y")` here has not been caught out by a subtle bug --
    they have written down a claim that is false.
    """
    return [
        Stage("load", _stage_load, requires=("n_samples", "n_features", "seed"), produces=("X", "y")),
        Stage(
            "select",
            _stage_leaky_select,
            requires=("X", "y", "folds", "k"),
            produces=("selected",),
        ),
        Stage("split", _stage_split, requires=("X", "y", "seed"), produces=("folds",)),
        Stage(
            "fit_and_score",
            _stage_fit_and_score,
            requires=("X", "y", "folds", "selected"),
            produces=("fold_scores", "score"),
        ),
        Stage("baseline", _stage_baseline, requires=("y",), produces=("baseline",)),
    ]


def starting_artifact(
    n_samples: int = 100,
    n_features: int = 5000,
    k: int = 20,
    seed: int = 143,
    n_splits: int = 5,
):
    """The inputs the pipeline is given before any stage has run."""
    return Artifact(
        data={
            "n_samples": n_samples,
            "n_features": n_features,
            "k": k,
            "seed": seed,
            "n_splits": n_splits,
        }
    )
metadata.yml (6181 bytes)
lesson_id: D143
day: 143
kind: guided-build
languages:
  - python
  - bash
setup_commands:
  - cd labs/sections/machine-learning/day-143-the-machine-learning-workflow
  - 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: 60
last_executed: '2026-08-27'
executed_on: >-
  macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, numpy 2.5.2, scikit-learn 1.9.0,
  pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 14 checks, 0 failure(s), exit
  0. pytest examples -q -> 17 passed. pytest starter -q -> 4 passed, 13 skipped (the
  four machinery checks in test_workflow_lib.py are solved in both directories; the
  thirteen exercise stubs in starter/test_workflow_claims.py are untouched). Everything
  ran through a real lab-local .venv created by the documented setup commands;
  scikit-learn pulled in scipy 1.18.1, joblib 1.5.3 and threadpoolctl 3.6.0 as its own
  dependencies, none of which this lab imports directly. The lab is fully offline after
  the pip install -- every dataset is generated on the spot from a seeded
  numpy.random.default_rng, nothing is downloaded, no dataset is bundled, and harness
  check 9 confirms no URL appears anywhere in starter/ or examples/ source. Section 7 of
  the harness copies examples/ into a mktemp-d scratch directory, confirms 17 passed,
  rewrites `assert leaky.data["score"] == 0.73` to 0.50, confirms a non-zero exit naming
  the failing test, and removes the scratch directory. Separately, by hand, `assert
  result.data["baseline"] == 0.54` was changed to 0.99 in
  examples/test_workflow_claims.py and the whole harness re-run: it reported 14 checks,
  2 failure(s) and exited 1 (both the pytest run and the pytest-free direct reproduction
  in section 2 caught it); the file was restored and the harness returned to 14 checks,
  0 failure(s), exit 0. MEASURED PAIRS, all captured verbatim in
  expected-output/measured-values.txt. (1) The pipeline runs as five declared stages
  leaving a step log -- load produces X and y, split produces folds, select produces
  selected, fit_and_score produces fold_scores and score, baseline produces baseline --
  and no stage mutates the artifact it was given, verified by checking the starting
  artifact is untouched after a full run. (2) On 100 rows of 5000 pure-noise features
  whose labels are coin flips, the honest pipeline scores exactly 0.5000 against a
  majority baseline of 0.5400, with per-fold scores [0.5, 0.55, 0.5, 0.4, 0.55]. (3) THE
  CENTRAL RESULT: transposing exactly two stages -- moving select in front of split --
  raises that score to 0.7300 with contracts disabled, inventing 0.2300 of accuracy on
  data that contains nothing to learn, with no warning of any kind; the step logs differ
  by one transposition and nothing else. With contracts enforced the same pipeline
  raises StageContractError naming stage 'select' and the missing key 'folds', and
  harness check 8 confirms it does so at five different seeds rather than once. (4) The
  inflation grows with the number of features chosen: at k = 5, 10, 20 and 50 the wrong
  order scores 0.65, 0.72, 0.73 and 0.85 against honest scores of 0.39, 0.50, 0.50 and
  0.38, inventing 0.26, 0.22, 0.23 and 0.47. (5) On an 8-percent-positive imbalanced
  problem the metric decides which model ships: a majority-class baseline scores 0.9200
  accuracy with 0.0000 recall, logistic regression at its default threshold scores
  0.9435 accuracy and 0.4813 recall, the same model with balanced class weights scores
  0.8685 accuracy and 0.8438 recall, 5-NN scores 0.9360 and a depth-3 tree 0.9275 --
  accuracy and precision pick the default-threshold logistic while recall picks the
  balanced one, so the decision inverts with nothing changing but the metric. Three of
  the four real models beat the constant, by at most 0.0235, and the only model that
  finds most of the positives scores worse than the constant. (6) Error analysis on that
  94.35-percent-accurate model gives the confusion matrix [[1810, 30], [83, 77]]: it
  misses 83 positives while catching 77, so it misses more of the thing you care about
  than it finds. (7) Two runs of the honest pipeline at seed 143 produce byte-identical
  manifests of SHA-256 content hashes while a run at seed 144 does not, which is the
  control that makes the first claim mean something. (8) The fitting stage is 9 of the
  pipeline's 30 lines, 30.00 percent -- and this pipeline has no cleaning, monitoring or
  deployment stage at all, so that figure is reported as an upper bound rather than an
  estimate. THREE HONESTY CALLS. FIRST: two of the honest scores in the inflation table
  are BELOW chance, at 0.39 and 0.38. That is not anti-learning; 20 test rows per fold
  gives a standard error near 0.11. The lab asserts right <= 0.5 rather than right ==
  0.5, because the structural claim is the defensible one, and FIELDS.md says so
  explicitly. SECOND: the stage line counts are measured from this lab's own source with
  inspect.getsource and will legitimately change if the library is edited; the lesson
  reports 30 percent as an upper bound on a pipeline with no cleaning, monitoring or
  deployment stage rather than repeating the familiar folklore percentage, which could
  not be verified here. THIRD: the four manifest hashes are content hashes over exact
  float bytes and therefore move with the NumPy pin; the property asserted as
  version-independent is that two runs at one seed agree and two seeds do not, with the
  literal hashes kept only as a drift detector on this machine.
requirements/README.md (1776 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

Most numbers in this lab come from a seeded `numpy.random.default_rng`,
and NumPy's documentation is explicit that `Generator` makes no promise of
stream compatibility between versions. A different NumPy can legitimately
produce a different stream from the same seed, and every sampled figure
would move — including the four manifest hashes in exercise 6, which are
content hashes over those exact float bytes.

The structural results do not depend on the pins at all: the step logs,
the `StageContractError`, the direction of the inflation, and the fact
that two runs at one seed agree. `expected-output/FIELDS.md` separates the
two categories explicitly, and it is worth reading before you conclude
that a mismatch is a bug.

## Installing

From the lab directory:

```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
```

The install step needs the network. Everything after it is offline: every
dataset in this lab is generated on the spot from a seeded generator, and
nothing is downloaded.

## Free and open-source status

All three packages are free and open source — NumPy and scikit-learn under
the BSD 3-Clause licence, pytest under the MIT licence. There is no paid
tier, no account and no API key anywhere in this lab.
requirements/requirements.txt (47 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
starter/00_brief.md (4854 bytes)
# Day 143 lab brief — The Workflow, Wired Up

The machine learning workflow is normally drawn as a row of boxes with
arrows between them. Everybody nods. Then everybody goes and writes a
notebook where the boxes are cells, the arrows are the order you happened
to run them in, and the whole thing produces a number that nobody can
reproduce and nobody can defend.

This lab builds the same workflow with the arrows made load-bearing.
Every stage declares **what it requires** and **what it produces**, the
runner refuses to run a stage whose inputs are absent, and every run
leaves a step log and a manifest of content hashes behind it.

## The claim you are here to measure

> The same stages in a different order give a different answer, and the
> wrong order is silent.

Exercise 3 does it in five lines. A dataset of 100 rows and 5000
features, where the labels are coin flips and no feature carries any
information whatsoever:

| Pipeline | Order | Score |
| --- | --- | --- |
| honest | load, **split, select**, fit, baseline | **0.50** |
| leaky, contracts off | load, **select, split**, fit, baseline | **0.73** |
| leaky, contracts on | same as above | `StageContractError` |

Twenty-three accuracy points, on data that contains nothing to learn,
produced by transposing two stages. Same data, same model, same folds,
same seed. Nothing raises. Nothing warns. A number comes out and it looks
like a result.

And then the third row, which is the actual lesson:

```
StageContractError: stage 'select' requires ['folds'] which no earlier
stage produced
```

The contract is not ceremony. It is what turns a silent twenty-three
point lie into a loud error naming the stage that broke.

## The thing to understand about that contract

Look carefully at how `leaky_stages()` is written. Its `select` stage
still declares `folds` among its requirements — because that requirement
is **true**. Choosing features is a per-fold operation. Declaring it
honestly is the entire mechanism by which the runner can notice.

A team that writes `requires=("X", "y")` on that stage has not been caught
out by a subtle bug. They have written down a claim that is false, and
every checking tool in the world is downstream of that.

## Six more things this lab measures

| # | What it establishes |
| --- | --- |
| 1 | The pipeline as stages with a step log, and stages that never mutate their input |
| 2 | The honest pipeline reports chance on data that is chance |
| 3b | The inflation grows with the number of features chosen: +0.26, +0.22, +0.23, +0.47 |
| 4 | The metric decides which model you ship — accuracy and recall pick different winners |
| 5 | A 94.35 percent accurate model that misses more positives than it catches |
| 6 | Two runs of the pipeline are byte-identical; a different seed is not |
| 7 | The modelling stage is 30 percent of this pipeline, and that is an upper bound |

Exercise 4 is the one to sit with. On an imbalanced problem — 8 percent
positive — a majority-class baseline scores **0.92 accuracy with zero
recall**, and three of the four real models beat it by at most 2.35
points. The one model that actually finds most of the positives, at 0.8438
recall, scores **worse than the constant** on accuracy.

Choosing the metric is therefore not a reporting decision made at the end.
It is the decision that determines which model you ship, and you make it
before any model exists.

## 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_workflow_lib.py`) and thirteen skips.
3. Replace one `pytest.skip(...)` at a time with real code. The skip text
   names the exact helper and the exact value to assert.
4. Print the measured pair in every exercise. A number you did not print
   is a number you did not look at.
5. When you want the whole measured table at once, run
   `.venv/bin/python3 examples/report_measurements.py`.

Do not run `pytest starter examples` in one invocation. Both directories
define `workflow_lib.py`, `test_workflow_lib.py` and
`test_workflow_claims.py`; pytest aborts on the module-name collision.
Run them separately, always.

## A note on the honest score being 0.50

It is 0.50 because the labels are coin flips, and that is the correct
answer. But the five per-fold scores behind it are `[0.5, 0.55, 0.5, 0.4,
0.55]`, which is a wide spread — twenty test rows per fold buys you a
standard error of roughly 0.11.

Two of the honest scores in the `inflation_by_k` table are *below* chance,
at 0.39 and 0.38. That is not anti-learning and it is not a bug. An
estimate of a 0.5 quantity from a small sample wanders, and it wanders
below as readily as above. The lab therefore asserts `right <= 0.5`
rather than `right == 0.5`, because the structural claim is the one worth
asserting.
starter/test_workflow_claims.py (7016 bytes)
"""Thirteen exercises in what the machine learning workflow actually is.

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.
`workflow_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 workflow_lib as w  # noqa: F401  (you will need it)


@pytest.fixture(scope="module")
def noise():
    return w.noise_dataset()


@pytest.fixture(scope="module")
def imbalanced():
    return w.imbalanced_dataset(1000, 11), w.imbalanced_dataset(2000, 12)


def test_01_the_honest_pipeline_runs_in_the_declared_order():
    pytest.skip(
        "Run w.run_pipeline(w.honest_stages(), w.starting_artifact()). Assert "
        "the stage names in result.log are load, split, select, fit_and_score, "
        "baseline in that order, and that each stage recorded exactly the keys "
        "it declared: load -> ('X', 'y'), split -> ('folds',), select -> "
        "('selected',), fit_and_score -> ('fold_scores', 'score'), baseline -> "
        "('baseline',)."
    )


def test_01b_a_stage_never_mutates_the_artifact_it_was_given():
    pytest.skip(
        "Capture set(start.data) before running the pipeline, run it, and "
        "assert the starting artifact's keys and empty log are unchanged. A "
        "stage that mutates its input makes the step log a work of fiction, "
        "because the log then describes states that no longer exist."
    )


def test_02_the_honest_pipeline_reports_chance_on_pure_noise():
    pytest.skip(
        "Assert the honest pipeline's score is exactly 0.5, its baseline is "
        "0.54, and fold_scores has shape (5,). The labels in noise_dataset "
        "are coin flips, so chance is the only honest answer -- and note the "
        "baseline of 0.54 is above 0.5 because 100 coin flips do not land "
        "exactly fifty-fifty."
    )


def test_03_reordering_two_stages_invents_twenty_three_accuracy_points():
    pytest.skip(
        "Run the honest pipeline, then run w.leaky_stages() with "
        "enforce_contracts=False. Assert the scores are 0.5 and 0.73 and that "
        "the difference is 0.23. Then assert the two step logs differ by "
        "exactly one transposition: select and split have swapped places and "
        "nothing else changed. Same data, same model, same folds."
    )


def test_03b_the_contract_turns_a_silent_lie_into_a_named_failure():
    pytest.skip(
        "Run w.leaky_stages() with enforce_contracts=True inside "
        "pytest.raises(w.StageContractError). Assert the message names the "
        "stage 'select' and the missing key 'folds'. Then assert the honest "
        "pipeline passes the very same contracts and still scores 0.5."
    )


def test_03c_the_inflation_grows_with_the_number_of_features_chosen(noise):
    pytest.skip(
        "Assert w.inflation_by_k(X, y, [5, 10, 20, 50]) equals [(5, 0.65, "
        "0.39, 0.26), (10, 0.72, 0.5, 0.22), (20, 0.73, 0.5, 0.23), (50, "
        "0.85, 0.38, 0.47)]. Then assert the structural facts that hold "
        "regardless of the numbers: every wrong-order score beats its honest "
        "counterpart, and every honest score is at or below chance."
    )


def test_04_the_metric_you_choose_decides_which_model_you_ship(imbalanced):
    pytest.skip(
        "Score w.candidate_models() with w.score_all. Assert the majority "
        "baseline is accuracy 0.92 with recall 0.0, that logistic at the "
        "default threshold is 0.9435 accuracy and 0.4813 recall, and that "
        "logistic balanced is 0.8685 accuracy and 0.8438 recall. Then assert "
        "w.winner picks 'logistic (default threshold)' on accuracy and "
        "'logistic (balanced)' on recall -- the decision inverts, with "
        "nothing changing but the metric."
    )


def test_04b_a_model_that_never_predicts_the_positive_class_scores_ninety_two(imbalanced):
    pytest.skip(
        "Assert the majority baseline scores 0.92 accuracy and 0.0 recall. "
        "Collect the models that beat 0.92 accuracy and assert the set is "
        "exactly logistic (default threshold), 5-NN and depth-3 tree, and "
        "that the best of them beats the constant by only 0.0235. Then "
        "assert the one model that actually finds positives -- logistic "
        "balanced, recall 0.8438 -- scores WORSE than the constant. Assert "
        "the test set carries 160 positives in 2000 rows."
    )


def test_05_the_confusion_matrix_says_what_the_accuracy_hides(imbalanced):
    pytest.skip(
        "Fit logistic at the default threshold and assert w.error_table is "
        "[[1810, 30], [83, 77]]. Assert the four cells sum to 2000, that the "
        "false negatives (83) exceed the true positives (77), and that the "
        "accuracy recomputed from the table is 0.9435. A model can be 94 "
        "percent accurate and miss more of the thing you care about than it "
        "finds."
    )


def test_06_two_runs_of_the_pipeline_are_byte_identical():
    pytest.skip(
        "Build w.manifest over ('X', 'y', 'fold_scores', 'score') for two "
        "separate runs at seed 143 and assert they are equal. Assert the "
        "manifest is {'X': '51b0a421bd652dd2', 'fold_scores': "
        "'8f0ac332958b9bc4', 'score': 'd2cbad71ff333de6', 'y': "
        "'9984503b5352c5a1'}. A pipeline that cannot prove it produced the "
        "same thing twice cannot be debugged."
    )


def test_06b_a_different_seed_produces_a_different_manifest():
    pytest.skip(
        "Compare the seed 143 manifest against a seed 144 one and assert "
        "they differ, including on the 'X' key specifically. This is the "
        "control: a manifest that never changes is not evidence of "
        "determinism, it is evidence that you are hashing a constant."
    )


def test_07_the_modelling_stage_is_the_smallest_part_of_the_pipeline():
    pytest.skip(
        "Assert w.stage_source_lines(w.honest_stages()) is {'load': 5, "
        "'split': 2, 'select': 10, 'fit_and_score': 9, 'baseline': 4}, "
        "totalling 30, and that fit_and_score is 30 percent of it. Then "
        "assert what is NOT in that dict: no cleaning stage, no monitoring "
        "stage, no deployment stage. That is why 30 percent is an upper "
        "bound and not an estimate."
    )


def test_08_a_stage_cannot_run_without_the_inputs_it_declared():
    pytest.skip(
        "Run the honest pipeline against an empty w.Artifact(data={}) inside "
        "pytest.raises(w.StageContractError) and assert the message names "
        "'load'. Then run the same thing with enforce_contracts=False and "
        "assert it raises KeyError instead. Both fail; only one tells you "
        "which stage broke."
    )
starter/test_workflow_lib.py (2357 bytes)
"""Machinery checks: the runner itself behaves, before any claim is made.

These four tests are solved in both `starter/` and `examples/`. They exist
so that a broken runner reports itself as a broken runner rather than as a
surprising scientific result.
"""

import numpy as np
import pytest

import workflow_lib as w


def test_the_artifact_is_immutable_and_carries_its_history():
    start = w.Artifact(data={"a": 1})
    nxt = start.with_(b=2)
    assert start.data == {"a": 1}
    assert nxt.data == {"a": 1, "b": 2}
    assert nxt is not start
    # Overwriting a key is allowed; silently mutating the original is not.
    third = nxt.with_(a=99)
    assert third.data["a"] == 99 and nxt.data["a"] == 1


def test_the_fingerprint_is_stable_content_addressing():
    a = np.arange(10)
    b = np.arange(10)
    assert w.fingerprint(a) == w.fingerprint(b)
    assert w.fingerprint(a) != w.fingerprint(np.arange(11))
    # dtype is part of the identity: the same values in a different type
    # are a different artifact, and treating them as equal hides real bugs.
    assert w.fingerprint(np.arange(10)) != w.fingerprint(np.arange(10, dtype=float))
    assert len(w.fingerprint(a)) == 16


def test_the_contract_checks_both_directions():
    called = []

    def under_producing(_artifact):
        called.append("under")
        return {}

    stages = [w.Stage("under", under_producing, requires=(), produces=("x",))]
    with pytest.raises(w.StageContractError) as excinfo:
        w.run_pipeline(stages, w.Artifact(data={}))
    assert "'under'" in str(excinfo.value)
    assert called == ["under"]

    def over_producing(_artifact):
        return {"x": 1, "surprise": 2}

    stages = [w.Stage("over", over_producing, requires=(), produces=("x",))]
    with pytest.raises(w.StageContractError):
        w.run_pipeline(stages, w.Artifact(data={}))


def test_the_folds_are_stratified_and_cover_every_row_exactly_once():
    X, y = w.imbalanced_dataset(500, seed=3)
    splits = w.folds(X, y, n_splits=5, seed=143)
    assert len(splits) == 5
    seen = np.concatenate([test for _train, test in splits])
    assert sorted(seen.tolist()) == list(range(500))
    # Stratified: every fold carries roughly the population positive rate.
    rate = float(y.mean())
    for _train, test in splits:
        assert abs(float(y[test].mean()) - rate) < 0.02
starter/workflow_lib.py (14515 bytes)
"""The machine learning workflow as runnable stages, with contracts.

The workflow in the textbook is a row of boxes with arrows. This module is
the same workflow with the arrows made load-bearing: every stage declares
what it requires and what it produces, the runner refuses to run a stage
whose inputs are absent, and every run leaves a step log and a manifest of
content hashes behind it.

That is not ceremony. The point of this lab is that **the same three
stages in a different order produce a different number**, and that the
wrong order is silent. A stage contract is what turns a silent
twenty-three point lie into a loud error naming the stage that broke.
"""

from __future__ import annotations

import hashlib
import inspect
from dataclasses import dataclass, field
from typing import Callable

import numpy as np

from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    accuracy_score,
    confusion_matrix,
    f1_score,
    precision_score,
    recall_score,
)
from sklearn.model_selection import StratifiedKFold
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier


class StageContractError(RuntimeError):
    """Raised when a stage is asked to run without the inputs it declared."""


# --------------------------------------------------------------------------
# The runner
# --------------------------------------------------------------------------


@dataclass
class Artifact:
    """Everything the pipeline knows so far, plus how it came to know it."""

    data: dict = field(default_factory=dict)
    log: list = field(default_factory=list)

    def with_(self, **produced) -> "Artifact":
        """Return a new artifact carrying the additional keys.

        Deliberately not in-place. A stage that mutates its input makes the
        step log a work of fiction, because the log then describes states
        that no longer exist.
        """
        merged = dict(self.data)
        merged.update(produced)
        return Artifact(data=merged, log=list(self.log))


@dataclass
class Stage:
    """One step of the workflow, with its input and output contract."""

    name: str
    run: Callable[[Artifact], dict]
    requires: tuple = ()
    produces: tuple = ()


def run_pipeline(stages, artifact: Artifact, enforce_contracts: bool = True) -> Artifact:
    """Run every stage in order, recording what each one did.

    With ``enforce_contracts=False`` the runner behaves like most real
    pipelines: it runs whatever it is given, in whatever order, and reports
    a number. Exercise 3 measures what that costs.
    """
    for stage in stages:
        if enforce_contracts:
            missing = [k for k in stage.requires if k not in artifact.data]
            if missing:
                raise StageContractError(
                    f"stage {stage.name!r} requires {missing} which no earlier stage produced"
                )
        produced = stage.run(artifact)
        if enforce_contracts:
            unexpected = sorted(set(produced) - set(stage.produces))
            absent = sorted(set(stage.produces) - set(produced))
            if absent or unexpected:
                raise StageContractError(
                    f"stage {stage.name!r} declared {list(stage.produces)} "
                    f"but produced {sorted(produced)}"
                )
        artifact = artifact.with_(**produced)
        artifact.log.append((stage.name, tuple(sorted(produced))))
    return artifact


def step_log(artifact: Artifact):
    """The ordered record of which stage produced which keys."""
    return list(artifact.log)


def fingerprint(value) -> str:
    """A stable content hash for an array, a number or a string."""
    if isinstance(value, np.ndarray):
        payload = np.ascontiguousarray(value).tobytes() + str(value.dtype).encode()
    else:
        payload = repr(value).encode()
    return hashlib.sha256(payload).hexdigest()[:16]


def manifest(artifact: Artifact, keys) -> dict:
    """Content hashes for the named keys, so two runs can be compared.

    A pipeline that cannot prove it produced the same thing twice cannot be
    debugged, because you can never tell a fix from a coincidence.
    """
    return {k: fingerprint(artifact.data[k]) for k in sorted(keys)}


def stage_source_lines(stages) -> dict:
    """How many lines of code each stage's function actually is.

    Self-referential on purpose: exercise 7 uses this to measure the shape
    of this very pipeline, rather than repeating the folklore figure about
    how much of the work is not modelling.
    """
    out = {}
    for stage in stages:
        source = inspect.getsource(stage.run)
        lines = [ln for ln in source.splitlines() if ln.strip() and not ln.strip().startswith("#")]
        out[stage.name] = len(lines)
    return out


# --------------------------------------------------------------------------
# Datasets
# --------------------------------------------------------------------------


def noise_dataset(n_samples: int = 100, n_features: int = 5000, seed: int = 143):
    """Labels are coin flips. No feature carries any information whatsoever.

    Any workflow that reports better than chance on this data has a bug,
    and the bug is what exercise 3 is about.
    """
    rng = np.random.default_rng(seed)
    X = rng.normal(size=(n_samples, n_features))
    y = rng.integers(0, 2, size=n_samples)
    return X, y


def imbalanced_dataset(n: int, seed: int, rate: float = 0.08):
    """A rare positive class whose features overlap the negatives.

    Eight percent positive, shifted by 1.1 standard deviations. Separable
    enough to be worth modelling, overlapping enough that the metric you
    choose decides which model wins.
    """
    rng = np.random.default_rng(seed)
    n_pos = int(round(n * rate))
    y = np.zeros(n, dtype=int)
    y[:n_pos] = 1
    X = rng.normal(size=(n, 4))
    X[y == 1] += 1.1
    order = rng.permutation(n)
    return X[order], y[order]


# --------------------------------------------------------------------------
# The individual steps, usable on their own
# --------------------------------------------------------------------------


def correlation_ranking(X, y):
    """Absolute correlation between each column and the label."""
    y = np.asarray(y, dtype=float)
    out = np.empty(X.shape[1])
    for j in range(X.shape[1]):
        out[j] = abs(float(np.corrcoef(X[:, j], y)[0, 1]))
    return out


def top_k_features(X, y, k: int):
    """The k columns most correlated with the label, as indices."""
    return np.argsort(correlation_ranking(X, y))[-k:]


def folds(X, y, n_splits: int = 5, seed: int = 143):
    """Deterministic stratified folds, so both orderings see the same splits."""
    return list(StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed).split(X, y))


def select_then_split_score(X, y, k: int = 20, n_splits: int = 5, seed: int = 143) -> float:
    """The WRONG order: choose features using every row, then cross-validate.

    Every fold's test rows helped choose the features, so the features were
    fitted to the answers. Nothing raises. A number comes out.
    """
    chosen = top_k_features(X, y, k)
    scores = []
    for train, test in folds(X, y, n_splits, seed):
        model = KNeighborsClassifier(1).fit(X[train][:, chosen], y[train])
        scores.append(float(np.mean(model.predict(X[test][:, chosen]) == y[test])))
    return float(np.mean(scores))


def split_then_select_score(X, y, k: int = 20, n_splits: int = 5, seed: int = 143) -> float:
    """The RIGHT order: split first, then choose features inside each fold."""
    scores = []
    for train, test in folds(X, y, n_splits, seed):
        chosen = top_k_features(X[train], y[train], k)
        model = KNeighborsClassifier(1).fit(X[train][:, chosen], y[train])
        scores.append(float(np.mean(model.predict(X[test][:, chosen]) == y[test])))
    return float(np.mean(scores))


def inflation_by_k(X, y, ks, n_splits: int = 5, seed: int = 143):
    """How much the wrong order inflates the score, at each feature count."""
    rows = []
    for k in ks:
        wrong = select_then_split_score(X, y, k, n_splits, seed)
        right = split_then_select_score(X, y, k, n_splits, seed)
        rows.append((k, round(wrong, 4), round(right, 4), round(wrong - right, 4)))
    return rows


# --------------------------------------------------------------------------
# Metrics, baselines and error analysis
# --------------------------------------------------------------------------


def candidate_models() -> dict:
    """The five candidates exercise 4 compares. Settings are fixed."""
    return {
        "majority baseline": DummyClassifier(strategy="most_frequent"),
        "logistic (default threshold)": LogisticRegression(max_iter=1000),
        "logistic (balanced)": LogisticRegression(max_iter=1000, class_weight="balanced"),
        "5-NN": KNeighborsClassifier(5),
        "depth-3 tree": DecisionTreeClassifier(max_depth=3, random_state=0),
    }


def score_all(models: dict, X_train, y_train, X_test, y_test) -> dict:
    """Accuracy, precision, recall and F1 for every candidate."""
    out = {}
    for name, model in models.items():
        model.fit(X_train, y_train)
        pred = model.predict(X_test)
        out[name] = {
            "accuracy": round(float(accuracy_score(y_test, pred)), 4),
            "precision": round(float(precision_score(y_test, pred, zero_division=0)), 4),
            "recall": round(float(recall_score(y_test, pred, zero_division=0)), 4),
            "f1": round(float(f1_score(y_test, pred, zero_division=0)), 4),
        }
    return out


def winner(scores: dict, metric: str) -> str:
    """Which candidate a given metric would have you ship."""
    return max(scores, key=lambda name: scores[name][metric])


def error_table(model, X_test, y_test):
    """The confusion matrix, as plain integers -- rows true, columns predicted."""
    return confusion_matrix(y_test, model.predict(X_test)).tolist()


# --------------------------------------------------------------------------
# The stages themselves
# --------------------------------------------------------------------------


def _stage_load(artifact: Artifact) -> dict:
    X, y = noise_dataset(
        artifact.data["n_samples"], artifact.data["n_features"], artifact.data["seed"]
    )
    return {"X": X, "y": y}


def _stage_split(artifact: Artifact) -> dict:
    return {"folds": folds(artifact.data["X"], artifact.data["y"], seed=artifact.data["seed"])}


def _stage_select(artifact: Artifact) -> dict:
    """Choose features. Requires `folds`, so it cannot run before the split."""
    per_fold = []
    for train, _test in artifact.data["folds"]:
        per_fold.append(
            top_k_features(
                artifact.data["X"][train], artifact.data["y"][train], artifact.data["k"]
            )
        )
    return {"selected": per_fold}


def _stage_leaky_select(artifact: Artifact) -> dict:
    """Choose features from every row at once, then reuse them in every fold.

    This is what a leaky pipeline actually does. It is not a strawman: the
    global selection is computed once, which is cheaper, and then handed to
    every fold as though it had been computed there.
    """
    chosen = top_k_features(artifact.data["X"], artifact.data["y"], artifact.data["k"])
    n_folds = len(artifact.data.get("folds", [])) or artifact.data["n_splits"]
    return {"selected": [chosen for _ in range(n_folds)]}


def _stage_fit_and_score(artifact: Artifact) -> dict:
    scores = []
    for (train, test), chosen in zip(artifact.data["folds"], artifact.data["selected"]):
        model = KNeighborsClassifier(1).fit(
            artifact.data["X"][train][:, chosen], artifact.data["y"][train]
        )
        pred = model.predict(artifact.data["X"][test][:, chosen])
        scores.append(float(np.mean(pred == artifact.data["y"][test])))
    return {"fold_scores": np.array(scores), "score": float(np.mean(scores))}


def _stage_baseline(artifact: Artifact) -> dict:
    y = artifact.data["y"]
    counts = np.bincount(y)
    return {"baseline": float(counts.max() / len(y))}


def honest_stages():
    """Load, split, select inside the split, fit, baseline. In that order."""
    return [
        Stage("load", _stage_load, requires=("n_samples", "n_features", "seed"), produces=("X", "y")),
        Stage("split", _stage_split, requires=("X", "y", "seed"), produces=("folds",)),
        Stage("select", _stage_select, requires=("X", "y", "folds", "k"), produces=("selected",)),
        Stage(
            "fit_and_score",
            _stage_fit_and_score,
            requires=("X", "y", "folds", "selected"),
            produces=("fold_scores", "score"),
        ),
        Stage("baseline", _stage_baseline, requires=("y",), produces=("baseline",)),
    ]


def leaky_stages():
    """The same five stages, with selection moved in front of the split.

    Note the `select` stage still declares `folds` among its requirements,
    because that requirement is *true*: choosing features is a per-fold
    operation. Declaring it honestly is the entire mechanism by which the
    runner can notice the ordering is wrong. A team that writes
    `requires=("X", "y")` here has not been caught out by a subtle bug --
    they have written down a claim that is false.
    """
    return [
        Stage("load", _stage_load, requires=("n_samples", "n_features", "seed"), produces=("X", "y")),
        Stage(
            "select",
            _stage_leaky_select,
            requires=("X", "y", "folds", "k"),
            produces=("selected",),
        ),
        Stage("split", _stage_split, requires=("X", "y", "seed"), produces=("folds",)),
        Stage(
            "fit_and_score",
            _stage_fit_and_score,
            requires=("X", "y", "folds", "selected"),
            produces=("fold_scores", "score"),
        ),
        Stage("baseline", _stage_baseline, requires=("y",), produces=("baseline",)),
    ]


def starting_artifact(
    n_samples: int = 100,
    n_features: int = 5000,
    k: int = 20,
    seed: int = 143,
    n_splits: int = 5,
):
    """The inputs the pipeline is given before any stage has run."""
    return Artifact(
        data={
            "n_samples": n_samples,
            "n_features": n_features,
            "k": k,
            "seed": seed,
            "n_splits": n_splits,
        }
    )
tests/run_tests.sh (11683 bytes)
#!/usr/bin/env bash
# Day 143 lab harness: "The Workflow, Wired Up"
#
# 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 workflow_lib as w

errors = []


def expect(label, got, want):
    if got != want:
        errors.append(f"{label}: expected {want}, got {got}")


# 1. The pipeline as stages with a step log
honest = w.run_pipeline(w.honest_stages(), w.starting_artifact())
expect(
    "honest step log",
    [name for name, _ in honest.log],
    ["load", "split", "select", "fit_and_score", "baseline"],
)
expect("load produces", dict(honest.log)["load"], ("X", "y"))
expect("fit produces", dict(honest.log)["fit_and_score"], ("fold_scores", "score"))

# 1b. Stages do not mutate their input
start = w.starting_artifact()
before = set(start.data)
w.run_pipeline(w.honest_stages(), start)
expect("starting artifact untouched", set(start.data), before)
expect("starting artifact log empty", start.log, [])

# 2. Chance on noise
expect("honest score on pure noise", honest.data["score"], 0.5)
expect("majority baseline", honest.data["baseline"], 0.54)
expect("fold scores", honest.data["fold_scores"].tolist(), [0.5, 0.55, 0.5, 0.4, 0.55])

# 3. The transposition, and the contract
leaky = w.run_pipeline(w.leaky_stages(), w.starting_artifact(), enforce_contracts=False)
expect("leaky score, contracts off", leaky.data["score"], 0.73)
expect("accuracy invented by reordering", round(leaky.data["score"] - honest.data["score"], 4), 0.23)
expect(
    "leaky step log",
    [name for name, _ in leaky.log],
    ["load", "select", "split", "fit_and_score", "baseline"],
)
try:
    w.run_pipeline(w.leaky_stages(), w.starting_artifact(), enforce_contracts=True)
except w.StageContractError as exc:
    message = str(exc)
    if "'select'" not in message or "folds" not in message:
        errors.append(f"contract error did not name the stage and key: {message}")
else:
    errors.append("the leaky pipeline passed its contracts, which it must not")

# 3b. Inflation by k
X, y = w.noise_dataset()
expect(
    "inflation by k",
    w.inflation_by_k(X, y, [5, 10, 20, 50]),
    [(5, 0.65, 0.39, 0.26), (10, 0.72, 0.5, 0.22), (20, 0.73, 0.5, 0.23), (50, 0.85, 0.38, 0.47)],
)

# 4. The metric decides
train = w.imbalanced_dataset(1000, 11)
test = w.imbalanced_dataset(2000, 12)
scores = w.score_all(w.candidate_models(), train[0], train[1], test[0], test[1])
expect("baseline accuracy", scores["majority baseline"]["accuracy"], 0.92)
expect("baseline recall", scores["majority baseline"]["recall"], 0.0)
expect("logistic default accuracy", scores["logistic (default threshold)"]["accuracy"], 0.9435)
expect("logistic default recall", scores["logistic (default threshold)"]["recall"], 0.4813)
expect("logistic balanced accuracy", scores["logistic (balanced)"]["accuracy"], 0.8685)
expect("logistic balanced recall", scores["logistic (balanced)"]["recall"], 0.8438)
expect("5-NN accuracy", scores["5-NN"]["accuracy"], 0.936)
expect("depth-3 tree accuracy", scores["depth-3 tree"]["accuracy"], 0.9275)
expect("winner on accuracy", w.winner(scores, "accuracy"), "logistic (default threshold)")
expect("winner on recall", w.winner(scores, "recall"), "logistic (balanced)")
if w.winner(scores, "accuracy") == w.winner(scores, "recall"):
    errors.append("the metric did not invert the decision, contradicting exercise 4")
beats = {n for n, s in scores.items() if s["accuracy"] > 0.92}
expect("models beating the constant", beats, {"logistic (default threshold)", "5-NN", "depth-3 tree"})
expect("test positives", int(test[1].sum()), 160)

# 5. Error analysis
model = w.candidate_models()["logistic (default threshold)"]
model.fit(train[0], train[1])
table = w.error_table(model, test[0], test[1])
expect("confusion matrix", table, [[1810, 30], [83, 77]])
if table[1][0] <= table[1][1]:
    errors.append("the model did not miss more positives than it caught")

# 6. Reproducibility
keys = ("X", "y", "fold_scores", "score")
first = w.manifest(w.run_pipeline(w.honest_stages(), w.starting_artifact()), keys)
second = w.manifest(w.run_pipeline(w.honest_stages(), w.starting_artifact()), keys)
other = w.manifest(w.run_pipeline(w.honest_stages(), w.starting_artifact(seed=144)), keys)
expect(
    "manifest",
    first,
    {
        "X": "51b0a421bd652dd2",
        "fold_scores": "8f0ac332958b9bc4",
        "score": "d2cbad71ff333de6",
        "y": "9984503b5352c5a1",
    },
)
if first != second:
    errors.append("two runs at the same seed produced different manifests")
if first == other:
    errors.append("a different seed produced an identical manifest")

# 7. Stage sizes
lines = w.stage_source_lines(w.honest_stages())
expect(
    "stage line counts",
    lines,
    {"load": 5, "split": 2, "select": 10, "fit_and_score": 9, "baseline": 4},
)
expect("total lines", sum(lines.values()), 30)
expect("fitting share", round(lines["fit_and_score"] / sum(lines.values()), 4), 0.3)

# 8. A missing input
try:
    w.run_pipeline(w.honest_stages(), w.Artifact(data={}))
except w.StageContractError as exc:
    if "'load'" not in str(exc):
        errors.append(f"empty-artifact error did not name the load stage: {exc}")
else:
    errors.append("an empty artifact passed the contracts, which it must not")
try:
    w.run_pipeline(w.honest_stages(), w.Artifact(data={}), enforce_contracts=False)
except KeyError:
    pass
else:
    errors.append("with contracts off, an empty artifact did not raise KeyError")

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-8 reproduced directly against workflow_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 "^17 passed"; then
  ok "pytest examples -q -> 17 passed"
else
  fail "pytest examples -q did not report 17 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, 13 skipped"; then
  ok "pytest starter -q -> 4 passed, 13 skipped (the machinery checks pass; the thirteen exercises are stubs)"
else
  fail "pytest starter -q did not report 4 passed, 13 skipped"
  echo "$STARTER_OUT" | tail -20 | sed 's/^/    /'
fi

echo ""
echo "5. pytest examples starter (one invocation) aborts on the module-name collision"
COMBINED_OUT=$("$PYTEST" examples starter 2>&1)
if echo "$COMBINED_OUT" | grep -q "import file mismatch"; then
  ok "combined invocation reports import file mismatch, as documented -- never run starter and examples together"
else
  fail "combined invocation did not fail with import file mismatch as expected"
fi

echo ""
echo "6. The report reproduces the captured table exactly"
REPORT_OUT=$("$PYTHON" examples/report_measurements.py 2>&1)
if [ "$REPORT_OUT" = "$(cat expected-output/measured-values.txt)" ]; then
  ok "report_measurements.py output is byte-identical to expected-output/measured-values.txt"
else
  fail "report_measurements.py drifted from expected-output/measured-values.txt"
  echo "$REPORT_OUT" | diff - expected-output/measured-values.txt | head -20 | sed 's/^/    /'
fi

echo ""
echo "7. Proof the harness can fail"
SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/d143-scratch.XXXXXX")
cp examples/*.py "$SCRATCH"/
SCRATCH_OUT=$("$PYTEST" "$SCRATCH" -q 2>&1)
if echo "$SCRATCH_OUT" | tail -1 | grep -qE "^17 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_workflow_claims.py" <<'PYEOF'
import sys
path = sys.argv[1]
text = open(path).read()
needle = 'assert leaky.data["score"] == 0.73'
replacement = 'assert leaky.data["score"] == 0.50'
assert needle in text, "could not find the assertion to break"
open(path, "w").write(text.replace(needle, replacement, 1))
PYEOF
BROKEN_OUT=$("$PYTEST" "$SCRATCH" -q 2>&1)
BROKEN_STATUS=$?
if [ "$BROKEN_STATUS" -ne 0 ] && echo "$BROKEN_OUT" | grep -q "test_03_reordering_two_stages_invents_twenty_three_accuracy_points"; then
  ok "breaking exercise 3's assertion produces a non-zero exit and names the failing test"
else
  fail "broken copy did not fail as expected (exit=$BROKEN_STATUS)"
fi
rm -rf "$SCRATCH"

echo ""
echo "8. The contract catches an out-of-order pipeline every time, not just once"
CONTRACT_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import workflow_lib as w

caught = 0
for seed in range(5):
    try:
        w.run_pipeline(w.leaky_stages(), w.starting_artifact(seed=seed), enforce_contracts=True)
    except w.StageContractError:
        caught += 1
if caught != 5:
    print(f"ERROR: the contract caught {caught} of 5 out-of-order pipelines")
else:
    print("contract caught all five")
PYEOF
)
if [ "$CONTRACT_CHECK" = "contract caught all five" ]; then
  ok "the stage contract rejects the out-of-order pipeline at every seed tried"
else
  fail "contract check failed: $CONTRACT_CHECK"
fi

echo ""
echo "9. Offline, and nothing left behind"
if ! grep -rInE "https?://" examples/*.py starter/*.py > /dev/null 2>&1; then
  ok "no URLs inside examples/ or starter/ source -- this lab reaches no network"
else
  fail "found a URL inside examples/ or starter/"
fi
if [ -z "$(find . -path ./.venv -prune -o -type d -name '__pycache__' -print 2>/dev/null)" ]; then
  ok "no __pycache__ left behind"
else
  find . -path ./.venv -prune -o -type d -name '__pycache__' -exec rm -rf -- {} + 2>/dev/null
  ok "no __pycache__ left behind (cleaned during this run)"
fi
if [ ! -d .pytest_cache ]; then
  ok "no .pytest_cache left behind"
else
  rm -rf .pytest_cache
  ok "no .pytest_cache left behind (cleaned during this run)"
fi

echo ""
echo "---------------------------------------------------------------"
echo "$CHECKS checks, $FAILURES failure(s)"
if [ "$FAILURES" -ne 0 ]; then
  exit 1
fi
exit 0

Troubleshooting

Troubleshooting

No lab .venv found at .venv/bin/python3

The harness will not run against whatever Python is on your PATH, because every number here is pinned to exact package versions. Build the environment first:

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

If you deliberately want a different interpreter, the harness honours PYTHON and PYTEST:

PYTHON=/path/to/python3 PYTEST=/path/to/pytest bash tests/run_tests.sh

Expect version-check failures if those do not match the pins. That is the harness working, not the harness breaking.

StageContractError when I did not expect one

Read the message. It names the stage and the key:

stage 'select' requires ['folds'] which no earlier stage produced

That means a stage was placed before something it depends on. If you are running leaky_stages() with contracts enforced, this error is the expected result and exercise 3b asserts it. If you are seeing it on a pipeline of your own, the ordering genuinely is wrong.

The contract also checks the other direction: a stage that produces a key it did not declare, or fails to produce one it did, raises the same error. That catch is deliberate — a stage quietly adding keys is how a pipeline becomes impossible to reason about.

KeyError deep inside a stage

You ran with enforce_contracts=False and a required key was absent. The pipeline fails either way; the difference is that the contract tells you which stage broke and the KeyError tells you only which dictionary key was missing, from somewhere inside a function you now have to find.

Exercise 8 asserts both behaviours side by side, because the contrast is the argument for contracts.

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 workflow_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 that this is documented behaviour rather than a surprise.

My honest score is below 0.5, which looks like anti-learning

It is not. With 100 rows split into 5 folds, each fold's score is measured on 20 rows, which gives a standard error of about 0.11 around a true value of 0.5. Landing at 0.39 or 0.38 is well within that.

This is exactly why the lab asserts right <= 0.5 in exercise 3c rather than right == 0.5. The claim worth defending is that the honest pipeline does not beat chance on data with nothing in it — not that it lands on 0.5000 every time.

The manifest hashes do not match on my machine

Check expected-output/FIELDS.md first. The four hashes are SHA-256 over the raw bytes of the arrays, so they depend on the exact float values, which depend on NumPy's generator stream. NumPy's documentation is explicit that Generator gives no stream-compatibility guarantee across versions.

The property that must still hold on any version is that two runs at the same seed agree and a run at a different seed does not. If that has broken, something genuinely non-deterministic has entered the pipeline and is worth finding. If only the literal hashes have moved, the pins are doing their job.

The stage line counts do not match

stage_source_lines reads the actual source with inspect.getsource, so if you have edited workflow_lib.py the counts will legitimately have changed. That is the intended behaviour — exercise 7 measures this pipeline, not a remembered figure. Either restore the file or update the assertion to what your version actually is, and say so.

LogisticRegression warns about convergence

max_iter=1000 is set on both logistic models specifically to avoid this. If you construct your own with the default of 100 you may see a ConvergenceWarning, and the scores will differ slightly from the captured ones. Use w.candidate_models() rather than building your own, or match its settings.

Security notes

Security notes

What this lab touches

Nothing outside its own directory, and nothing outside your machine.

  • Filesystem. The lab reads only files inside its own directory. The one write outside it is check 7 of the harness, which creates a scratch directory with mktemp -d under $TMPDIR, copies examples/*.py into 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, and nothing above the lab root is modified.
  • Network. After the one pip install, this lab is completely offline. Check 9 asserts that no URL appears anywhere in examples/ or starter/ source. Every dataset here is generated on the spot from a seeded numpy.random.default_rng; nothing is downloaded and no dataset is bundled.
  • Credentials. There are none. requires_api_key is false, 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.

The one install step, and how to check it

pip install -r requirements/requirements.txt downloads three packages from the Python Package Index. 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-relevant idea in this lab

The manifest in exercise 6 is worth reading as a supply-chain control and not only as a reproducibility one.

fingerprint() is a SHA-256 over the raw bytes of an array, including its dtype. Two runs that produce the same hash produced the same bytes. That is the same primitive behind package lock files, container digests and signed release artifacts, and it answers the same question: is what I have now the thing I checked before?

A pipeline that cannot answer that question cannot be audited. If a regulator, a reviewer or a future colleague asks which data produced a deployed model, "the notebook I ran in March" is not an answer and a manifest of content hashes is.

Note also what the fingerprint deliberately includes: str(value.dtype). The same numbers stored as int64 and as float64 hash differently, on purpose. Treating them as the same artifact would hide a real class of bug, and silently equal hashes are worse than no hashes at all.

What the code does that is worth understanding

  • Artifact.with_() returns a new artifact rather than mutating in place. A stage that mutates its input makes the step log a work of fiction, because the log then describes states that no longer exist — and an audit trail that can be rewritten by the thing it audits is not an audit trail.
  • run_pipeline checks contracts in both directions: a stage that fails to produce what it declared, and a stage that produces something extra, both raise. The second is easy to dismiss and worth keeping — undeclared outputs are how a pipeline accumulates hidden coupling.
  • Nothing in this lab evaluates a string, imports dynamically, or reads a path from data. inspect.getsource in stage_source_lines reads the source of functions defined in this package and nothing else.
  • The harness captures the exit status of run_tests.sh itself and never reads the status of a pipeline. cmd | tail reports tail'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.