Machine LearningRegression › Day 152

Hands-on lab — Day 152: Regression Metrics

Commands

Setup

cd labs/sections/machine-learning/day-152-regression-metrics
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/regression_metrics_lib.py
examples/report_measurements.py
examples/test_metrics_claims.py
examples/test_metrics_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/regression_metrics_lib.py
starter/test_metrics_claims.py
starter/test_metrics_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 152 lab — What You Report Is Not What You Optimise

Lesson

  • Lesson title: Regression Metrics
  • Day number: 152 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-152-regression-metrics
  • Lab files: everything you need is in this directory — follow “How to run” below.
  • Browse the course locally: from the repository root, this lab also appears in the course website at /labs/day-152-regression-metrics when the site is running.

Purpose

Day 149 established the line: a loss is what you optimise, a metric is what you report, and they do not have to be the same function. This lab measures the reporting side for regression -- RMSE, MAE, MAPE, R2 and adjusted R2 -- and the specific way each one can mislead you.

The centrepiece is R2, because it is the most quoted and least understood number in regression. Add columns of pure noise to a linear model and its training R2 climbs anyway, from 0.5554 to 0.7403 with a hundred useless columns, because more predictors can only help a training-set fit. Adjusted R2 corrects that climb at a modest number of extra columns and then breaks down itself once the predictor count approaches the sample size. And R2 has no lower bound at all: a deliberately bad predictor scores -4.7009, not the 0 most people expect as a floor.

The other headline measurement is a genuine metric ranking inversion:

Model RMSE MAE
A: many small, consistent errors 1.947 1.586
B: right almost everywhere, badly wrong a few times 4.4353 0.8417

RMSE prefers Model A. MAE prefers Model B. Reporting only one metric silently picks a winner the other metric disagrees with.

Learning objectives

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

  1. Explain why a rising training R2 is not evidence a model has improved, and demonstrate it by adding pure-noise predictors.
  2. Compute adjusted R2 and identify the point at which its own correction breaks down.
  3. State the exact baseline R2 is measured against, and demonstrate that R2 has no lower bound.
  4. Show that RMSE and MAE respond differently to a single outlier, and explain the mechanism (squaring versus not squaring the error).
  5. Break MAPE at a zero true value and at a near-zero true value, and state what scikit-learn does instead of raising an exception.
  6. Demonstrate MAPE's structural asymmetry between over- and under-prediction.
  7. Construct two models where RMSE and MAE disagree about which is better, and explain why both rankings are correct about different things.
  8. State the unit RMSE and MAE are reported in, for a real dataset.
  9. Confirm that sklearn.metrics.r2_score agrees with LinearRegression.score.
  10. Demonstrate that r2_score's argument order changes its answer, and explain the mechanism.

Prerequisites

  • Day 149 for the loss/metric distinction and least squares; Day 143 for the machine-learning workflow this lab's split follows; Day 150 for multiple regression, which this lab's noise-column exercise extends.
  • 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. No GPU is needed or used -- this lab is fitting LinearRegression on at most 442 rows and 110 columns, which completes in well under a second on the capture machine (macOS 26.5.2, Apple Silicon, CPU only). Around 400 MB of disk for the virtual environment, almost all of it scikit-learn and scipy.

Required software

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

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

Free and open-source options

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

  • NumPy and scikit-learn are BSD 3-Clause licensed.
  • pytest is MIT licensed.
  • No dataset is downloaded: load_diabetes ships bundled inside scikit-learn's own installed files, and every synthetic example is generated on the spot from a seeded generator.

Installation

From the repository root:

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

That last line should print 2.5.2 1.9.0. The install step is the only part of this lab that needs the network, and it installs into a lab-local environment -- never into your system Python. rm -rf .venv reverses it completely.

File structure

day-152-regression-metrics/
├── 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
│   ├── regression_metrics_lib.py    complete machinery -- not the exercise
│   ├── test_metrics_lib.py          four machinery checks, already solved
│   └── test_metrics_claims.py       twelve exercises, each a skip to replace
├── examples/
│   ├── regression_metrics_lib.py    identical to the starter copy
│   ├── test_metrics_lib.py          the same four machinery checks
│   ├── test_metrics_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/regression_metrics_lib.py and examples/regression_metrics_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, twelve exercises skip until you write them
.venv/bin/pytest examples -q Runs the reference solutions -- sixteen assertions about what each regression metric reports
.venv/bin/python3 examples/report_measurements.py Recomputes every published number and prints them as one table
bash tests/run_tests.sh Fourteen checks: version pins, every claim reproduced without pytest, both suites, the collision, a byte-comparison of the report, a deliberate self-break, three directions re-confirmed at unquoted seeds, and cleanliness

Expected output

bash tests/run_tests.sh ends with:

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

and exits 0. pytest examples -q reports 16 passed. pytest starter -q reports 4 passed, 12 skipped until you start work.

The complete captured runs are in expected-output/. The measurement table is compared byte for byte by check 6, so if a number in the lesson ever drifts from the code, the harness fails rather than the lesson quietly becoming wrong.

Read expected-output/FIELDS.md before concluding that a mismatch on your machine is a bug. It separates what is exact everywhere -- the direction of every claim -- from what holds only under the pinned versions, which is most of the decimals.

Validation steps

  1. bash tests/run_tests.sh; echo "exit=$?"14 checks, 0 failure(s) and exit=0.
  2. .venv/bin/pytest examples -q16 passed.
  3. .venv/bin/pytest starter -q4 passed, 12 skipped before you start; 16 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_metrics_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 regression_metrics_lib, with no pytest involved -- so a broken test file cannot hide a broken library, and vice versa. 5. pytest examples -q reports 16 passed. 6. pytest starter -q reports 4 passed, 12 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 noise-column climb and the RMSE/MAE ranking inversion are re-confirmed at seeds the lesson never quotes, so no directional claim rests on a single lucky seed. 12-14. No URL appears in any source file; no __pycache__ and no .pytest_cache are left behind.

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

Cleanup

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

The harness already removes its own scratch directory. Nothing else is created outside this directory, so those four commands return your machine to exactly the state it was in.

Troubleshooting

See troubleshooting.md, which covers the missing virtual environment, the import file mismatch collision, an astronomically large MAPE number, adjusted R2 climbing back above the baseline at a large predictor count, identical metrics on raw and scaled features, and the r2_score argument-order bug.

Security notes

See security.md. In short: no network after the install, no credentials, no sudo, no write outside this directory except a mktemp -d scratch directory the harness removes in the same run, and everything reversible with rm -rf .venv. It also reads r2_score's argument-order bug as an instance of a broader class of function-contract mistakes worth defending against with keyword arguments and a second, independent check.

Extension exercises

  1. Find the break-even predictor count. Exercise 1b shows adjusted R2 correcting at 20 noise columns and failing at 100. Sweep intermediate values and find where the correction stops working, on this dataset.
  2. Repeat the noise-column climb on make_regression. Construct a dataset where you control the true number of informative features, and confirm the same climb happens even when you know for certain the added columns are noise.
  3. A third model for the ranking-inversion exercise. Construct a model that is worse than both A and B on both RMSE and MAE, and confirm there is no metric under which it wins. Then construct a fourth model that ties Model A on RMSE while beating it on MAE.
  4. Symmetric MAPE. Look up symmetric mean absolute percentage error and implement it against the near-zero-target case in exercise 4b. Report whether it still explodes, and by how much less.
  5. A confidence interval for RMSE. Use bootstrap resampling (Days 117-118) to put an interval around the RMSE in exercise 3, before and after the outlier shift, and report how much the interval widens.
  6. Cross-validated R2. Exercise 1's noise-column climb uses the training set. Repeat it with 5-fold cross-validated R2 instead, and report whether the climb still happens.
  • Lab brief: starter/00_brief.md
  • Previous lab: ../day-151-regularization-ridge-and-lasso/
  • Next lab: ../day-153-linear-regression-from-scratch/
  • Week 22 project: ../projects/week-22/

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, joblib and
threadpoolctl pulled in as scikit-learn's own dependencies.

## Exact on any machine, for any reason

These are arithmetic or structural facts, not measurements that happened to
come out a certain way. Check 8 of the harness confirms the directional
ones at seeds the lesson does not quote.

- **Training R2 is non-decreasing as predictors are added.** This is a
  property of ordinary least squares, not an observation about this
  dataset: adding a column can only reduce or leave unchanged the training
  sum of squared residuals, because the old solution is still available to
  the fit. It holds for any noise columns, at any seed.
- **`sqrt(p(1-p)/n)`-style reasoning aside, a constant-mean predictor scores
  R2 essentially exactly zero on fresh data drawn from the same
  distribution.** R2 is defined relative to exactly that predictor, so this
  is what the metric compares against, not an incidental property of the
  diabetes dataset.
- **R2 has no lower bound.** A predictor worse than always guessing the
  mean scores below zero, and there is no floor -- the all-zeros predictor
  used here scores -4.7009, and a still-worse predictor would score lower
  still.
- **RMSE moves more than MAE when a single target is shifted far away**, for
  any dataset and any shift, because RMSE squares the error contributed by
  that one row while MAE does not.
- **MAPE explodes at a zero true value** because scikit-learn floors the
  denominator at machine epsilon rather than raising -- a structural
  choice in the library, not a property of any particular input.
- **MAPE is bounded at 1.0 for the worst possible systematic
  under-prediction (always guessing zero) and unbounded for over-prediction.**
  This follows from the definition of the metric, not from a measurement.
- **Ordinary least squares is invariant to a per-column affine rescaling of
  its inputs.** `load_diabetes(scaled=True)` and `load_diabetes(scaled=
  False)` therefore produce identical predictions, and identical RMSE, MAE
  and R2, once a model is fit on each.
- **`r2_score` is not symmetric in its two arguments.** The denominator is
  the variance of whichever array is passed first, so swapping the
  arguments changes the answer for any pair of non-identical arrays.

## Exact under these pins, and only these

Everything with a specific decimal depends on `load_diabetes`'s fixed
array (itself bundled and version-independent within scikit-learn's data
files) combined with `numpy.random.default_rng` for the handful of places
this lab adds synthetic noise or predictions. **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 necessarily beyond them.

| Value | Exercise | What it is |
| --- | --- | --- |
| the five rows of the noise-column curve | 1, 1b | train R2 and adjusted R2 at 0, 1, 5, 20 and 100 noise columns |
| `0.3594`, `-0.0001`, `-4.7009` | 2, 2b | full-model, constant-mean and bad-predictor test R2 |
| `(2.4801, 1.9833, 28.2569, 5.9448)` | 3 | RMSE and MAE before and after the outlier shift |
| `5.6295e+15` | 4 | MAPE at a zero true value (the exact digits depend on floating-point rounding of the epsilon floor) |
| `(3.3667, 5.0)` | 4b | MAPE and MAE on the near-zero-target rows |
| `(1.0, 10.0)` | 5 | the MAPE asymmetry bound |
| `(1.947, 1.586, 4.4353, 0.8417)` | 6 | the ranking-inversion RMSE and MAE for Models A and B |
| `(56.3929, 45.1206, 0.3594)` | 7 | RMSE, MAE and R2 on raw and on scaled features |
| `0.359409` / `-0.209635` | 8, 8b | r2_score in the correct and the swapped argument order |

## Sampled, and therefore soft even here

- **The exact magnitude of the MAPE-at-zero explosion.** The direction
  (it is enormous) is structural. The precise digit sequence
  `5629499534213120.0` follows from the exact floating-point value of
  `np.finfo(np.float64).eps` and the exact numerator on this input, and is
  reported as-is because it was genuinely observed, but no claim in the
  lesson depends on it being exactly that figure rather than another
  enormous one.
- **The ranking-inversion numbers in exercise 6.** The direction of the
  inversion -- RMSE prefers Model A, MAE prefers Model B -- is checked at
  three further seeds by harness check 8 and holds at all of them. The
  exact decimals are specific to seed 2.
- **The noise-column decimals in exercise 1.** The monotonic climb in
  training R2 is structural (see above); the specific values 0.5554
  through 0.7403 depend on the exact noise columns drawn, which depend on
  the NumPy version.

## Timings

No timing is asserted anywhere in this lab. The heaviest step fits eleven
`LinearRegression` models on at most 331 rows and 110 columns, which
completes in well under a second on the capture machine and will take
longer elsewhere without changing a single assertion, because every
assertion is about a shape or a value.

examples-run.txt

................                                                         [100%]
16 passed in 0.58s

measured-values.txt

Day 152 -- regression metrics, measured
=========================================

1. Train R2 climbs on pure-noise columns
----------------------------------------
  n_noise   n_rows   n_predictors   train_r2   adjusted_r2
        0      331             10   0.5554     0.5415
        1      331             11   0.5555     0.5402
        5      331             15   0.5648     0.5441
       20      331             30   0.5754     0.5329
      100      331            110   0.7403     0.6104
  every added column is independent noise, unrelated to the target

2. R2 is not bounded below by zero
----------------------------------
  full 10-feature model, test R2       : 0.3594
  constant-mean predictor, test R2     : -0.0001
  deliberately bad (all-zeros), test R2: -4.7009

3. RMSE versus MAE under one outlier
------------------------------------
  before: RMSE 2.4801  MAE 1.9833
  after : RMSE 28.2569  MAE 5.9448  (one target moved +200)
  RMSE moved by a factor of 11.39
  MAE moved by a factor of 3.00

4. MAPE breaking
----------------
  MAPE with one true value exactly zero      : 5.6295e+15
  MAPE with a true value of 0.5 in the mix   : 3.3667  (MAE on the same rows: 5.0000)
  MAPE of the worst possible under-prediction: 1.0000
  MAPE of an eleven-times over-prediction    : 10.0000
  under-prediction is capped at 1.0; over-prediction is not

5. A metric ranking inversion
-----------------------------
  Model A (many small errors)      : RMSE 1.9470  MAE 1.5860
  Model B (few large errors)       : RMSE 4.4353  MAE 0.8417
  RMSE prefers: A
  MAE prefers : B

6. RMSE and MAE carry the target's units
----------------------------------------
  scaled  features: RMSE 56.3929  MAE 45.1206  R2 0.3594
  raw     features: RMSE 56.3929  MAE 45.1206  R2 0.3594
  identical either way -- ordinary least squares is invariant to
  a per-column affine rescaling of its inputs

7. r2_score: agreement, and the argument-order bug
--------------------------------------------------
  r2_score(y_test, pred)         : 0.359409
  model.score(X_test, y_test)    : 0.359409
  r2_score(y_test, pred)  correct : 0.359409
  r2_score(pred, y_test) swapped : -0.209635

starter-run.txt

ssssssssssss....                                                         [100%]
4 passed, 12 skipped in 0.53s

test-run.txt

1. Installed versions match requirements/requirements.txt
    numpy 2.5.2
    scikit-learn 1.9.0
    pytest 9.1.1
  ok: numpy 2.5.2 matches the pin
  ok: scikit-learn 1.9.0 matches the pin
  ok: pytest 9.1.1 matches the pin

2. Every published claim, reproduced directly (no pytest involved)
  ok: every claim reproduced directly against regression_metrics_lib, no pytest involved

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

4. starter/ is an untouched skeleton
  ok: pytest starter -q -> 4 passed, 12 skipped (the machinery checks pass; the twelve 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 6's assertion produces a non-zero exit and names the failing test

8. Key results hold at seeds the lesson does not quote
  ok: the noise-column climb and the RMSE/MAE ranking inversion hold at seeds the lesson does not quote

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

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

Source files

examples/regression_metrics_lib.py (9988 bytes)
"""Regression metrics, measured: what each one reports, and what it hides.

Day 149 drew the line once: a loss is what you optimise, a metric is what
you report, and they need not be the same function. This module measures
the reporting side for regression -- RMSE, MAE, MAPE, R-squared and
adjusted R-squared -- and the traps in each, on the diabetes dataset
(``sklearn.datasets.load_diabetes``) and on constructed data where a
property needs to be exact rather than merely typical.

Everything here is deterministic given a seed.
"""

from __future__ import annotations

import numpy as np

from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
from sklearn.metrics import (
    mean_absolute_error,
    mean_absolute_percentage_error,
    mean_squared_error,
    r2_score,
)
from sklearn.model_selection import train_test_split


# --------------------------------------------------------------------------
# 0. The dataset this module shares
# --------------------------------------------------------------------------


def diabetes_split(scaled: bool = True, seed: int = 0):
    """The 75/25 split every measurement below is built on."""
    X, y = load_diabetes(return_X_y=True, scaled=scaled)
    return train_test_split(X, y, test_size=0.25, random_state=seed)


def rmse(y_true, y_pred) -> float:
    return float(np.sqrt(mean_squared_error(y_true, y_pred)))


def mae(y_true, y_pred) -> float:
    return float(mean_absolute_error(y_true, y_pred))


# --------------------------------------------------------------------------
# 1. Train R-squared is not a quality measure: the noise-column climb
# --------------------------------------------------------------------------


def adjusted_r2(r2: float, n: int, p: int) -> float:
    """R-squared, penalised for the number of predictors used to get it."""
    return 1.0 - (1.0 - r2) * (n - 1) / (n - p - 1)


def noise_column_r2_curve(noise_counts=(0, 1, 5, 20, 100), seed: int = 0):
    """Train R2 and adjusted R2 as pure-noise columns are added.

    Every added column is independent standard-normal noise with no
    relationship whatsoever to the target. Returns rows of
    ``(n_noise, n_rows, n_predictors, train_r2, adjusted_r2)``.
    """
    X_train, _X_test, y_train, _y_test = diabetes_split(seed=0)
    rng = np.random.default_rng(seed)
    rows = []
    for n_noise in noise_counts:
        if n_noise == 0:
            X_aug = X_train
        else:
            noise = rng.normal(size=(X_train.shape[0], n_noise))
            X_aug = np.hstack([X_train, noise])
        model = LinearRegression().fit(X_aug, y_train)
        r2 = r2_score(y_train, model.predict(X_aug))
        n_rows, n_predictors = X_aug.shape
        rows.append((n_noise, n_rows, n_predictors, round(r2, 4), round(adjusted_r2(r2, n_rows, n_predictors), 4)))
    return rows


def full_model_test_r2(seed: int = 0) -> float:
    """The test R2 of the ordinary ten-feature model -- the honest number."""
    X_train, X_test, y_train, y_test = diabetes_split(seed=seed)
    model = LinearRegression().fit(X_train, y_train)
    return round(float(r2_score(y_test, model.predict(X_test))), 4)


# --------------------------------------------------------------------------
# 2. R-squared is not bounded below by zero
# --------------------------------------------------------------------------


def constant_mean_test_r2(seed: int = 0) -> float:
    """R2 of predicting the train mean for every test row."""
    _X_train, _X_test, y_train, y_test = diabetes_split(seed=seed)
    prediction = np.full_like(y_test, y_train.mean(), dtype=float)
    return round(float(r2_score(y_test, prediction)), 4)


def bad_predictor_test_r2(seed: int = 0) -> float:
    """R2 of a deliberately bad predictor: zero, always."""
    _X_train, _X_test, _y_train, y_test = diabetes_split(seed=seed)
    prediction = np.zeros_like(y_test, dtype=float)
    return round(float(r2_score(y_test, prediction)), 4)


# --------------------------------------------------------------------------
# 3. RMSE versus MAE under an outlier
# --------------------------------------------------------------------------


def rmse_mae_outlier_shift(seed: int = 1, n: int = 50, shift: float = 200.0):
    """Same predictions, one target moved far away. Which metric moves more?

    Returns ``(rmse_before, mae_before, rmse_after, mae_after)``.
    """
    rng = np.random.default_rng(seed)
    y_true = rng.normal(100.0, 10.0, size=n)
    y_pred = y_true + rng.normal(0.0, 3.0, size=n)
    before = (rmse(y_true, y_pred), mae(y_true, y_pred))

    y_true_shifted = y_true.copy()
    y_true_shifted[0] += shift
    after = (rmse(y_true_shifted, y_pred), mae(y_true_shifted, y_pred))
    return round(before[0], 4), round(before[1], 4), round(after[0], 4), round(after[1], 4)


# --------------------------------------------------------------------------
# 4. MAPE breaking: zero targets, near-zero targets, and structural asymmetry
# --------------------------------------------------------------------------


def mape_at_zero_target() -> float:
    """MAPE where one true value is exactly zero.

    Not undefined in the mathematical sense of raising -- scikit-learn
    floors the denominator at machine epsilon, so this returns a huge,
    silently wrong number rather than an error or a warning.
    """
    y_true = np.array([10.0, 20.0, 0.0, 30.0])
    y_pred = np.array([12.0, 18.0, 5.0, 28.0])
    return float(mean_absolute_percentage_error(y_true, y_pred))


def mape_near_zero_target():
    """MAPE explodes on a true value close to (but not) zero.

    Returns ``(mape, mae)`` on the same three rows, so the contrast between
    a metric that explodes and one that does not is visible in one call.
    """
    y_true = np.array([100.0, 100.0, 0.5])
    y_pred = np.array([105.0, 95.0, 5.5])
    return (
        round(float(mean_absolute_percentage_error(y_true, y_pred)), 4),
        round(float(mean_absolute_error(y_true, y_pred)), 4),
    )


def mape_asymmetry_bound(true_value: float = 100.0):
    """MAPE's structural asymmetry: bounded under, unbounded over.

    A model that under-predicts every row can be wrong by at most 100
    percent (predict zero, and the error cannot exceed the true value).
    A model that over-predicts has no such ceiling. Returns
    ``(max_under_prediction_mape, ten_times_over_prediction_mape)``.
    """
    y_true = np.full(5, true_value)
    max_under = np.zeros(5)  # the worst possible under-prediction: always zero
    ten_x_over = y_true * 11.0  # predicting eleven times the true value
    return (
        round(float(mean_absolute_percentage_error(y_true, max_under)), 4),
        round(float(mean_absolute_percentage_error(y_true, ten_x_over)), 4),
    )


# --------------------------------------------------------------------------
# 5. A metric ranking inversion: RMSE and MAE prefer different models
# --------------------------------------------------------------------------


def ranking_inversion_models(seed: int = 2, n: int = 100):
    """Two models scored on the same targets: one wins on RMSE, one on MAE.

    Model A makes many small, consistent errors. Model B is right almost
    everywhere but wrong by a lot on a few rows. Returns
    ``(rmse_a, mae_a, rmse_b, mae_b)``.
    """
    rng = np.random.default_rng(seed)
    y_true = rng.normal(50.0, 5.0, size=n)

    errors_a = rng.normal(0.0, 2.0, size=n)
    pred_a = y_true + errors_a

    errors_b = np.zeros(n)
    big_idx = rng.choice(n, size=5, replace=False)
    errors_b[big_idx] = rng.normal(0.0, 15.0, size=5)
    pred_b = y_true + errors_b

    return (
        round(rmse(y_true, pred_a), 4),
        round(mae(y_true, pred_a), 4),
        round(rmse(y_true, pred_b), 4),
        round(mae(y_true, pred_b), 4),
    )


# --------------------------------------------------------------------------
# 6. Units: RMSE and MAE carry the units of the target
# --------------------------------------------------------------------------


def raw_and_scaled_metrics(seed: int = 0):
    """RMSE, MAE and R2 fit on raw-unit features versus standardised ones.

    ``load_diabetes(scaled=False)`` returns the ten features in their
    original units -- age in years, bmi as bmi, blood pressure as measured
    -- while the target is the same disease-progression score either way.
    Ordinary least squares is invariant to a per-column affine rescaling of
    its inputs, so this returns identical numbers under both, which is
    itself the thing worth confirming rather than assuming.
    """
    results = {}
    for label, scaled in (("scaled", True), ("raw", False)):
        X_train, X_test, y_train, y_test = diabetes_split(scaled=scaled, seed=seed)
        model = LinearRegression().fit(X_train, y_train)
        pred = model.predict(X_test)
        results[label] = (round(rmse(y_test, pred), 4), round(mae(y_test, pred), 4), round(float(r2_score(y_test, pred)), 4))
    return results


# --------------------------------------------------------------------------
# 7. r2_score: agreement with .score, and the argument-order bug
# --------------------------------------------------------------------------


def r2_score_vs_model_score(seed: int = 0):
    """r2_score(y_true, y_pred) against LinearRegression.score(X, y)."""
    X_train, X_test, y_train, y_test = diabetes_split(seed=seed)
    model = LinearRegression().fit(X_train, y_train)
    pred = model.predict(X_test)
    return round(float(r2_score(y_test, pred)), 6), round(float(model.score(X_test, y_test)), 6)


def r2_score_argument_order(seed: int = 0):
    """r2_score is NOT symmetric in its two arguments: swap them and it changes.

    Returns ``(correct_order, swapped_order)``.
    """
    X_train, X_test, y_train, y_test = diabetes_split(seed=seed)
    model = LinearRegression().fit(X_train, y_train)
    pred = model.predict(X_test)
    return round(float(r2_score(y_test, pred)), 6), round(float(r2_score(pred, y_test)), 6)
examples/report_measurements.py (3549 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 regression_metrics_lib as m  # noqa: E402


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


def main() -> None:
    print("Day 152 -- regression metrics, measured")
    print("=" * 41)

    rule("1. Train R2 climbs on pure-noise columns")
    print("  n_noise   n_rows   n_predictors   train_r2   adjusted_r2")
    for n_noise, n_rows, n_predictors, r2, adj in m.noise_column_r2_curve():
        print(f"  {n_noise:7d}   {n_rows:6d}   {n_predictors:12d}   {r2:.4f}     {adj:.4f}")
    print("  every added column is independent noise, unrelated to the target")

    rule("2. R2 is not bounded below by zero")
    print(f"  full 10-feature model, test R2       : {m.full_model_test_r2():.4f}")
    print(f"  constant-mean predictor, test R2     : {m.constant_mean_test_r2():.4f}")
    print(f"  deliberately bad (all-zeros), test R2: {m.bad_predictor_test_r2():.4f}")

    rule("3. RMSE versus MAE under one outlier")
    rmse_before, mae_before, rmse_after, mae_after = m.rmse_mae_outlier_shift()
    print(f"  before: RMSE {rmse_before:.4f}  MAE {mae_before:.4f}")
    print(f"  after : RMSE {rmse_after:.4f}  MAE {mae_after:.4f}  (one target moved +200)")
    print(f"  RMSE moved by a factor of {rmse_after / rmse_before:.2f}")
    print(f"  MAE moved by a factor of {mae_after / mae_before:.2f}")

    rule("4. MAPE breaking")
    print(f"  MAPE with one true value exactly zero      : {m.mape_at_zero_target():.4e}")
    mape_nz, mae_nz = m.mape_near_zero_target()
    print(f"  MAPE with a true value of 0.5 in the mix   : {mape_nz:.4f}  (MAE on the same rows: {mae_nz:.4f})")
    max_under, ten_x_over = m.mape_asymmetry_bound()
    print(f"  MAPE of the worst possible under-prediction: {max_under:.4f}")
    print(f"  MAPE of an eleven-times over-prediction    : {ten_x_over:.4f}")
    print("  under-prediction is capped at 1.0; over-prediction is not")

    rule("5. A metric ranking inversion")
    rmse_a, mae_a, rmse_b, mae_b = m.ranking_inversion_models()
    print(f"  Model A (many small errors)      : RMSE {rmse_a:.4f}  MAE {mae_a:.4f}")
    print(f"  Model B (few large errors)       : RMSE {rmse_b:.4f}  MAE {mae_b:.4f}")
    print(f"  RMSE prefers: {'A' if rmse_a < rmse_b else 'B'}")
    print(f"  MAE prefers : {'A' if mae_a < mae_b else 'B'}")

    rule("6. RMSE and MAE carry the target's units")
    results = m.raw_and_scaled_metrics()
    for label, (r, a, r2) in results.items():
        print(f"  {label:7s} features: RMSE {r:.4f}  MAE {a:.4f}  R2 {r2:.4f}")
    print("  identical either way -- ordinary least squares is invariant to")
    print("  a per-column affine rescaling of its inputs")

    rule("7. r2_score: agreement, and the argument-order bug")
    from_metric, from_model = m.r2_score_vs_model_score()
    print(f"  r2_score(y_test, pred)         : {from_metric:.6f}")
    print(f"  model.score(X_test, y_test)    : {from_model:.6f}")
    correct_order, swapped_order = m.r2_score_argument_order()
    print(f"  r2_score(y_test, pred)  correct : {correct_order:.6f}")
    print(f"  r2_score(pred, y_test) swapped : {swapped_order:.6f}")


if __name__ == "__main__":
    main()
examples/test_metrics_claims.py (7838 bytes)
"""The reference solutions: what each regression metric reports, and hides.

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 regression_metrics_lib as m


# --- 1. Train R2 is not a quality measure ---------------------------------


def test_01_train_r2_climbs_on_pure_noise_columns():
    rows = m.noise_column_r2_curve()
    assert rows == [
        (0, 331, 10, 0.5554, 0.5415),
        (1, 331, 11, 0.5555, 0.5402),
        (5, 331, 15, 0.5648, 0.5441),
        (20, 331, 30, 0.5754, 0.5329),
        (100, 331, 110, 0.7403, 0.6104),
    ]
    train_r2 = [row[3] for row in rows]
    # Every added column is pure noise, independent of the target, and
    # yet the training R2 climbs anyway -- by more as more columns are
    # added, because more predictors can only help a training-set fit.
    assert all(a < b for a, b in zip(train_r2, train_r2[1:]))
    assert train_r2[-1] - train_r2[0] > 0.18


def test_01b_adjusted_r2_corrects_the_climb_then_breaks_down_itself():
    rows = m.noise_column_r2_curve()
    by_noise = {row[0]: row for row in rows}
    baseline_adj = by_noise[0][4]
    # At a modest number of noise columns, adjusted R2 does its job: it
    # falls below the no-noise baseline, correctly reporting that these
    # columns did not earn their place.
    assert by_noise[20][4] < baseline_adj
    # But at p=110 predictors on n=331 rows -- a third of the sample size
    # spent on predictors -- the penalty term itself becomes unstable, and
    # adjusted R2 climbs back ABOVE the baseline even though every one of
    # those 100 extra columns is still pure noise. The correction is not
    # a cure; it has its own failure mode.
    assert by_noise[100][4] > baseline_adj
    assert by_noise[100][4] > by_noise[20][4]


# --- 2. R2 is not bounded below by zero ------------------------------------


def test_02_the_full_model_beats_a_constant_mean_predictor_on_test():
    full = m.full_model_test_r2()
    constant = m.constant_mean_test_r2()
    assert full == 0.3594
    # A constant-mean predictor scores R2 essentially exactly zero on a
    # fresh test set, by construction: R2 is defined relative to that
    # exact predictor, so this is the thing R2 compares against, not an
    # incidental fact about this dataset.
    assert abs(constant) < 0.001
    assert full > constant


def test_02b_r2_has_no_lower_bound():
    bad = m.bad_predictor_test_r2()
    # A deliberately bad predictor -- zero, always -- is not merely worse
    # than the constant-mean baseline; it is worse by nearly five full
    # units of R2, which is impossible if R2 lived in [0, 1] the way most
    # readers assume.
    assert bad == -4.7009
    assert bad < -4.0


# --- 3. RMSE versus MAE under one outlier ----------------------------------


def test_03_rmse_moves_more_than_mae_when_one_target_is_an_outlier():
    rmse_before, mae_before, rmse_after, mae_after = m.rmse_mae_outlier_shift()
    assert (rmse_before, mae_before, rmse_after, mae_after) == (2.4801, 1.9833, 28.2569, 5.9448)
    rmse_ratio = rmse_after / rmse_before
    mae_ratio = mae_after / mae_before
    # Squaring the error in RMSE means one very wrong prediction dominates
    # the sum; MAE, which never squares anything, moves by far less.
    assert rmse_ratio > 11.0
    assert mae_ratio < 3.5
    assert rmse_ratio > 3 * mae_ratio


# --- 4. MAPE breaking -------------------------------------------------------


def test_04_mape_explodes_silently_at_a_zero_true_value():
    value = m.mape_at_zero_target()
    # scikit-learn does not raise or warn on a zero true value: it floors
    # the denominator at machine epsilon and returns a number. The result
    # is not a small mistake -- it is off by roughly fourteen orders of
    # magnitude from anything a percentage error should look like.
    assert value > 1.0e10


def test_04b_mape_explodes_near_zero_while_mae_stays_sane():
    mape_value, mae_value = m.mape_near_zero_target()
    assert (mape_value, mae_value) == (3.3667, 5.0)
    # The same three rows: MAE reports a modest, believable 5.0 units of
    # error. MAPE reports 336.67 percent -- a number nobody would present
    # to a stakeholder -- because one of the three true values is 0.5 and
    # a five-unit miss on 0.5 is a factor of ten.
    assert mape_value > 3.0
    assert mae_value < 10.0


def test_05_mape_is_bounded_under_but_not_over():
    max_under, ten_x_over = m.mape_asymmetry_bound()
    assert (max_under, ten_x_over) == (1.0, 10.0)
    # The worst possible systematic under-prediction -- always guessing
    # zero -- cannot exceed 100 percent MAPE, because the error can never
    # exceed the true value once the prediction floor of zero is hit.
    # Over-prediction has no such ceiling: predicting eleven times the
    # truth reports 1000 percent, and there is no larger multiple that
    # would not report a correspondingly larger number. The two directions
    # of being wrong are not scored on the same scale.
    assert max_under == 1.0
    assert ten_x_over > max_under


# --- 6. A metric ranking inversion ------------------------------------------


def test_06_rmse_and_mae_prefer_different_models():
    rmse_a, mae_a, rmse_b, mae_b = m.ranking_inversion_models()
    assert (rmse_a, mae_a, rmse_b, mae_b) == (1.947, 1.586, 4.4353, 0.8417)
    # Model A makes many small, consistent errors. Model B is right almost
    # everywhere and badly wrong on a handful of rows. RMSE, which squares
    # every error, is dominated by Model B's few large misses and prefers
    # A. MAE, which weighs every error equally, is dominated by the
    # ninety-five rows Model B gets almost exactly right and prefers B.
    assert rmse_a < rmse_b
    assert mae_b < mae_a


# --- 7. RMSE and MAE carry the target's units -------------------------------


def test_07_rmse_and_mae_are_identical_on_raw_and_standardised_features():
    results = m.raw_and_scaled_metrics()
    assert results["scaled"] == (56.3929, 45.1206, 0.3594)
    assert results["raw"] == (56.3929, 45.1206, 0.3594)
    # Ordinary least squares is invariant to a per-column affine rescaling
    # of its inputs, so the predictions -- and every metric computed from
    # them -- are identical whether the features are standardised or left
    # in their original units (age in years, bmi, raw blood pressure).
    assert results["scaled"] == results["raw"]
    # RMSE and MAE are stated in the target's own units. The diabetes
    # target has no physical unit -- it is a composite disease-progression
    # score running 25 to 346 -- so a mean absolute error of 45.12 means
    # "45.12 points on that 25-346 scale", not "45.12 of anything you
    # could hand a doctor". A metric you cannot state a unit for is a
    # metric you cannot explain.


# --- 8. r2_score: agreement, and the argument-order bug ---------------------


def test_08_r2_score_agrees_with_linearregression_score():
    from_metric, from_model = m.r2_score_vs_model_score()
    assert from_metric == from_model == 0.359409


def test_08b_r2_score_argument_order_changes_the_answer():
    correct_order, swapped_order = m.r2_score_argument_order()
    assert correct_order == 0.359409
    # Swapping the two arguments is not a harmless typo: r2_score is not
    # symmetric in y_true and y_pred, because the denominator is the
    # variance of whichever array is passed FIRST. Swapped, the same
    # predictions score negative -- a model that looked genuinely useful
    # now looks worse than a constant-mean baseline, from one call written
    # the wrong way round.
    assert swapped_order == -0.209635
    assert swapped_order != correct_order
    assert swapped_order < 0 < correct_order
examples/test_metrics_lib.py (1946 bytes)
"""Machinery checks: the helpers behave, before any claim is made.

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

import numpy as np

import regression_metrics_lib as m


def test_diabetes_split_shapes_and_ranges():
    X_train, X_test, y_train, y_test = m.diabetes_split()
    assert X_train.shape == (331, 10)
    assert X_test.shape == (111, 10)
    assert y_train.shape == (331,)
    assert y_test.shape == (111,)
    # The target is documented as running from 25 to 346.
    assert 25.0 <= float(np.concatenate([y_train, y_test]).min())
    assert float(np.concatenate([y_train, y_test]).max()) <= 346.0


def test_rmse_and_mae_agree_on_a_perfect_prediction():
    y_true = np.array([1.0, 2.0, 3.0, 4.0])
    assert m.rmse(y_true, y_true) == 0.0
    assert m.mae(y_true, y_true) == 0.0


def test_rmse_is_never_smaller_than_mae():
    # A standard inequality: RMSE >= MAE always, with equality only when
    # every absolute error is identical.
    rng = np.random.default_rng(0)
    y_true = rng.normal(size=40)
    y_pred = y_true + rng.normal(scale=2.0, size=40)
    assert m.rmse(y_true, y_pred) >= m.mae(y_true, y_pred)
    # Equality when every error has the same magnitude.
    y_true_eq = np.zeros(6)
    y_pred_eq = np.array([1.0, -1.0, 1.0, -1.0, 1.0, -1.0])
    assert round(m.rmse(y_true_eq, y_pred_eq), 10) == round(m.mae(y_true_eq, y_pred_eq), 10)


def test_adjusted_r2_equals_r2_when_no_predictors_are_added():
    # With p fixed, adjusted R2 is a deterministic function of r2, n and p --
    # sanity-check the formula against a hand-computable case.
    # n=10, p=2, r2=0.8: 1 - (1-0.8) * 9/7
    assert round(m.adjusted_r2(0.8, 10, 2), 6) == round(1.0 - 0.2 * 9.0 / 7.0, 6)
    # Adjusted R2 is always <= R2 whenever p > 0 and n > p + 1.
    assert m.adjusted_r2(0.5, 100, 5) <= 0.5
metadata.yml (7131 bytes)
lesson_id: D152
day: 152
kind: guided-build
languages:
  - python
  - bash
setup_commands:
  - cd labs/sections/machine-learning/day-152-regression-metrics
  - 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: 55
last_executed: '2026-08-27'
executed_on: >-
  macOS 26.5.2 (Apple Silicon, arm64, CPU only -- no GPU is needed or used), Python
  3.14.0, numpy 2.5.2, scikit-learn 1.9.0, pytest 9.1.1, bash 3.2.57 -- bash
  tests/run_tests.sh -> 14 checks, 0 failure(s), exit 0. pytest examples -q -> 16 passed.
  pytest starter -q -> 4 passed, 12 skipped (the four machinery checks in
  test_metrics_lib.py are solved in both directories; the twelve exercise stubs in
  starter/test_metrics_claims.py are untouched). Everything ran through a real lab-local
  .venv created by the documented setup commands; scikit-learn pulled in scipy, joblib
  and threadpoolctl as its own dependencies, none of which this lab imports directly. The
  lab is fully offline after the pip install -- load_diabetes ships bundled inside
  scikit-learn's own installed files, nothing is downloaded, 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 16 passed, rewrites
  `assert (rmse_a, mae_a, rmse_b, mae_b) == (1.947, 1.586, 4.4353, 0.8417)` to
  `(0.0, 0.0, 0.0, 0.0)`, confirms a non-zero exit naming the failing test, and removes
  the scratch directory. Separately, by hand, `assert m.bad_predictor_test_r2() ==
  -4.7009` was changed to `== 0.0` in examples/test_metrics_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) Fitting LinearRegression
  on the diabetes train split (331 rows, test_size=0.25, random_state=0) and adding
  independent standard-normal noise columns gives train R2 of 0.5554, 0.5555, 0.5648,
  0.5754 and 0.7403 at 0, 1, 5, 20 and 100 noise columns -- strictly increasing on
  columns with zero relationship to the target, because more predictors can only help a
  training-set fit. (2) Adjusted R2 on the same five fits is 0.5415, 0.5402, 0.5441,
  0.5329 and 0.6104: it correctly falls below the 0-noise baseline at 20 noise columns,
  then climbs back ABOVE the baseline at 100 noise columns once the predictor count (110)
  becomes a large fraction of the sample size (331) -- the correction has its own failure
  mode. (3) The full ten-feature model scores test R2 of 0.3594; a constant-mean
  predictor scores -0.0001 (essentially exactly zero, by construction); a deliberately
  bad all-zeros predictor scores -4.7009, nearly five full units below zero, which is
  impossible if R2 lived in [0, 1]. (4) Fifty points with small consistent errors give
  RMSE 2.4801 and MAE 1.9833; moving one true value 200 units away (predictions
  unchanged) gives RMSE 28.2569 (a factor of 11.39) and MAE 5.9448 (a factor of 3.00) --
  RMSE moved nearly four times as much, because it squares the one large error and MAE
  does not. (5) mean_absolute_percentage_error on four rows including one true value of
  exactly zero returns 5629499534213120.0 -- not an exception, not a warning, because
  scikit-learn floors the zero denominator at machine epsilon rather than raising. On
  three rows including one true value of 0.5, MAPE is 3.3667 (336.67 percent) while MAE
  on the same rows is a sane 5.0. The worst possible systematic under-prediction (always
  guessing zero) gives MAPE exactly 1.0 (100 percent, the ceiling); predicting eleven
  times the truth gives MAPE 10.0 (1000 percent, with no ceiling of its own). (6) Two
  models on the same 100 targets: Model A (many small consistent errors) scores RMSE
  1.947, MAE 1.586; Model B (right on 95 rows, badly wrong on 5) scores RMSE 4.4353, MAE
  0.8417 -- RMSE prefers A, MAE prefers B, a genuine ranking inversion between the two
  most common regression metrics. (7) RMSE 56.3929, MAE 45.1206 and R2 0.3594 are
  IDENTICAL whether LinearRegression is fit on load_diabetes(scaled=True) or
  load_diabetes(scaled=False) -- ordinary least squares is invariant to a per-column
  affine rescaling of its inputs. (8) r2_score(y_test, pred) and
  model.score(X_test, y_test) agree at 0.359409; r2_score(pred, y_test), the arguments
  swapped, returns -0.209635 -- a real and common bug, not a contrived one, since the
  same predictions look like a usable model in the correct order and worse than guessing
  the mean in the swapped one. THREE HONESTY CALLS. FIRST: adjusted R2 is usually taught
  as unambiguously "the corrected version" of R2, but the measurement shows its own
  failure mode once the predictor count approaches the sample size -- this was not
  anticipated going in and is reported because it was measured, not because it was
  expected. SECOND: MAPE's asymmetry claim needed real construction rather than folklore.
  A symmetric-in-absolute-error construction (moving the true value by the same delta in
  each direction) produces IDENTICAL MAPE in both directions, because the metric's
  denominator is always the true value regardless of which way the error points -- so the
  "MAPE penalizes over- and under-prediction differently" folklore is only correct in the
  specific, verified sense that the worst-case ACHIEVABLE under-prediction is bounded at
  100 percent (predictions cannot go below zero) while over-prediction is unbounded; it is
  not correct as a claim about equal-magnitude errors on a fixed true value, which are
  symmetric by the formula's own construction. This distinction is stated explicitly in
  the lesson rather than repeating the folklore's looser version. THIRD: the diabetes
  target has no natural physical unit -- it is a composite disease-progression score
  running 25 to 346, not a measurement in mg/dL or any other real unit -- so the lesson
  states RMSE and MAE in "points on that scale" rather than fabricating a unit the
  dataset's own documentation does not provide. Harness check 8 re-runs the noise-column
  climb at three further seeds, the ranking inversion at three further seeds, and the
  RMSE-vs-MAE-under-an-outlier direction at three further shift sizes, so no directional
  claim rests on the one seed the lesson quotes.
requirements/README.md (2102 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.

## Why the versions are pinned exactly

Almost every number in this lab comes from `sklearn.datasets.load_diabetes`
(a fixed, bundled dataset -- no download, no randomness in the data itself)
combined with `numpy.random.default_rng` for the handful of exercises that
add synthetic noise or construct toy predictions. NumPy's documentation is
explicit that `Generator` makes no promise of stream compatibility between
versions, so a different NumPy can legitimately produce a different noise
stream from the same seed, and any figure built from it could move by a
little.

What does not depend on the pins: every direction this lab claims --
training R2 climbing as noise columns are added, R2 having no lower bound,
RMSE moving more than MAE under an outlier, MAPE exploding at a zero or
near-zero true value, the RMSE/MAE ranking inversion, and the r2_score
argument-order bug. Harness check 8 re-runs three of those at seeds the
lesson never quotes, precisely so the distinction between "always true" and
"true under these pins" is enforced rather than merely asserted.
`expected-output/FIELDS.md` separates the two categories in full.

## Installing

From the lab directory:

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

The install step needs the network. Everything after it is offline: the
diabetes dataset ships inside scikit-learn itself, and every synthetic
example in this lab is generated on the spot from a seeded generator.

## 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 (3436 bytes)
# Day 152 lab brief — What You Report Is Not What You Optimise

Day 149 drew the line: a loss is what you optimise, a metric is what you
report, and they do not have to be the same function. This lab measures
the reporting side -- RMSE, MAE, MAPE, R2 and adjusted R2 -- and the traps
in each.

## The claim you are here to measure

> R2 is the most quoted and least understood number in regression.

Exercise 1 measures why. Fit a linear model on the diabetes dataset, then
add columns of **pure noise** -- independent random numbers with zero
relationship to the target -- and watch training R2 anyway:

| noise columns added | train R2 |
| --- | --- |
| 0 | 0.5554 |
| 1 | 0.5555 |
| 5 | 0.5648 |
| 20 | 0.5754 |
| 100 | 0.7403 |

Every one of those columns is garbage. The model climbs to 0.7403 anyway,
because more predictors can only help a training-set fit -- never hurt it.
Exercise 1b brings in adjusted R2, which is built to penalise exactly this,
and shows it working at a modest number of extra columns and **breaking
down at a large one**: at 100 noise columns (110 predictors on 331 rows),
adjusted R2 climbs back above the no-noise baseline even though nothing
useful was added.

## The part most people get wrong

Most people believe R2 lives in `[0, 1]` and means "percent of variance
explained". Exercise 2 shows the first half is false: a deliberately bad
predictor scores **-4.7009**, nearly five full units below zero. There is
no floor. R2 compares your model to one specific baseline -- always predict
the training mean -- and if you do worse than that baseline, the number
goes negative with no limit.

## Why RMSE and MAE can disagree about which model is better

Exercise 6 is the single most valuable measurement in this lab. Two models,
scored on the same targets:

| Model | RMSE | MAE |
| --- | --- | --- |
| A: many small errors | 1.947 | 1.586 |
| B: a few large errors | 4.4353 | 0.8417 |

RMSE says A is better. MAE says B is better. **Both are correct, about
different things.** RMSE squares every error before averaging, so a
handful of large misses dominates it. MAE weighs every error equally, so
being right almost everywhere dominates it. Reporting only one of the two
would silently pick a winner the other metric disagrees with.

## MAPE, breaking

Exercises 4, 4b and 5 build MAPE up and then break it three ways: it
explodes (without raising or warning) when a true value is exactly zero;
it explodes almost as badly when a true value is merely close to zero; and
it is structurally asymmetric -- the worst possible under-prediction caps
out at 100 percent, while over-prediction has no ceiling at all.

## 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_metrics_lib.py`) and twelve 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 `regression_metrics_lib.py`, `test_metrics_lib.py` and
`test_metrics_claims.py`; pytest aborts on the module-name collision. Run
them separately, always.
starter/regression_metrics_lib.py (9988 bytes)
"""Regression metrics, measured: what each one reports, and what it hides.

Day 149 drew the line once: a loss is what you optimise, a metric is what
you report, and they need not be the same function. This module measures
the reporting side for regression -- RMSE, MAE, MAPE, R-squared and
adjusted R-squared -- and the traps in each, on the diabetes dataset
(``sklearn.datasets.load_diabetes``) and on constructed data where a
property needs to be exact rather than merely typical.

Everything here is deterministic given a seed.
"""

from __future__ import annotations

import numpy as np

from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
from sklearn.metrics import (
    mean_absolute_error,
    mean_absolute_percentage_error,
    mean_squared_error,
    r2_score,
)
from sklearn.model_selection import train_test_split


# --------------------------------------------------------------------------
# 0. The dataset this module shares
# --------------------------------------------------------------------------


def diabetes_split(scaled: bool = True, seed: int = 0):
    """The 75/25 split every measurement below is built on."""
    X, y = load_diabetes(return_X_y=True, scaled=scaled)
    return train_test_split(X, y, test_size=0.25, random_state=seed)


def rmse(y_true, y_pred) -> float:
    return float(np.sqrt(mean_squared_error(y_true, y_pred)))


def mae(y_true, y_pred) -> float:
    return float(mean_absolute_error(y_true, y_pred))


# --------------------------------------------------------------------------
# 1. Train R-squared is not a quality measure: the noise-column climb
# --------------------------------------------------------------------------


def adjusted_r2(r2: float, n: int, p: int) -> float:
    """R-squared, penalised for the number of predictors used to get it."""
    return 1.0 - (1.0 - r2) * (n - 1) / (n - p - 1)


def noise_column_r2_curve(noise_counts=(0, 1, 5, 20, 100), seed: int = 0):
    """Train R2 and adjusted R2 as pure-noise columns are added.

    Every added column is independent standard-normal noise with no
    relationship whatsoever to the target. Returns rows of
    ``(n_noise, n_rows, n_predictors, train_r2, adjusted_r2)``.
    """
    X_train, _X_test, y_train, _y_test = diabetes_split(seed=0)
    rng = np.random.default_rng(seed)
    rows = []
    for n_noise in noise_counts:
        if n_noise == 0:
            X_aug = X_train
        else:
            noise = rng.normal(size=(X_train.shape[0], n_noise))
            X_aug = np.hstack([X_train, noise])
        model = LinearRegression().fit(X_aug, y_train)
        r2 = r2_score(y_train, model.predict(X_aug))
        n_rows, n_predictors = X_aug.shape
        rows.append((n_noise, n_rows, n_predictors, round(r2, 4), round(adjusted_r2(r2, n_rows, n_predictors), 4)))
    return rows


def full_model_test_r2(seed: int = 0) -> float:
    """The test R2 of the ordinary ten-feature model -- the honest number."""
    X_train, X_test, y_train, y_test = diabetes_split(seed=seed)
    model = LinearRegression().fit(X_train, y_train)
    return round(float(r2_score(y_test, model.predict(X_test))), 4)


# --------------------------------------------------------------------------
# 2. R-squared is not bounded below by zero
# --------------------------------------------------------------------------


def constant_mean_test_r2(seed: int = 0) -> float:
    """R2 of predicting the train mean for every test row."""
    _X_train, _X_test, y_train, y_test = diabetes_split(seed=seed)
    prediction = np.full_like(y_test, y_train.mean(), dtype=float)
    return round(float(r2_score(y_test, prediction)), 4)


def bad_predictor_test_r2(seed: int = 0) -> float:
    """R2 of a deliberately bad predictor: zero, always."""
    _X_train, _X_test, _y_train, y_test = diabetes_split(seed=seed)
    prediction = np.zeros_like(y_test, dtype=float)
    return round(float(r2_score(y_test, prediction)), 4)


# --------------------------------------------------------------------------
# 3. RMSE versus MAE under an outlier
# --------------------------------------------------------------------------


def rmse_mae_outlier_shift(seed: int = 1, n: int = 50, shift: float = 200.0):
    """Same predictions, one target moved far away. Which metric moves more?

    Returns ``(rmse_before, mae_before, rmse_after, mae_after)``.
    """
    rng = np.random.default_rng(seed)
    y_true = rng.normal(100.0, 10.0, size=n)
    y_pred = y_true + rng.normal(0.0, 3.0, size=n)
    before = (rmse(y_true, y_pred), mae(y_true, y_pred))

    y_true_shifted = y_true.copy()
    y_true_shifted[0] += shift
    after = (rmse(y_true_shifted, y_pred), mae(y_true_shifted, y_pred))
    return round(before[0], 4), round(before[1], 4), round(after[0], 4), round(after[1], 4)


# --------------------------------------------------------------------------
# 4. MAPE breaking: zero targets, near-zero targets, and structural asymmetry
# --------------------------------------------------------------------------


def mape_at_zero_target() -> float:
    """MAPE where one true value is exactly zero.

    Not undefined in the mathematical sense of raising -- scikit-learn
    floors the denominator at machine epsilon, so this returns a huge,
    silently wrong number rather than an error or a warning.
    """
    y_true = np.array([10.0, 20.0, 0.0, 30.0])
    y_pred = np.array([12.0, 18.0, 5.0, 28.0])
    return float(mean_absolute_percentage_error(y_true, y_pred))


def mape_near_zero_target():
    """MAPE explodes on a true value close to (but not) zero.

    Returns ``(mape, mae)`` on the same three rows, so the contrast between
    a metric that explodes and one that does not is visible in one call.
    """
    y_true = np.array([100.0, 100.0, 0.5])
    y_pred = np.array([105.0, 95.0, 5.5])
    return (
        round(float(mean_absolute_percentage_error(y_true, y_pred)), 4),
        round(float(mean_absolute_error(y_true, y_pred)), 4),
    )


def mape_asymmetry_bound(true_value: float = 100.0):
    """MAPE's structural asymmetry: bounded under, unbounded over.

    A model that under-predicts every row can be wrong by at most 100
    percent (predict zero, and the error cannot exceed the true value).
    A model that over-predicts has no such ceiling. Returns
    ``(max_under_prediction_mape, ten_times_over_prediction_mape)``.
    """
    y_true = np.full(5, true_value)
    max_under = np.zeros(5)  # the worst possible under-prediction: always zero
    ten_x_over = y_true * 11.0  # predicting eleven times the true value
    return (
        round(float(mean_absolute_percentage_error(y_true, max_under)), 4),
        round(float(mean_absolute_percentage_error(y_true, ten_x_over)), 4),
    )


# --------------------------------------------------------------------------
# 5. A metric ranking inversion: RMSE and MAE prefer different models
# --------------------------------------------------------------------------


def ranking_inversion_models(seed: int = 2, n: int = 100):
    """Two models scored on the same targets: one wins on RMSE, one on MAE.

    Model A makes many small, consistent errors. Model B is right almost
    everywhere but wrong by a lot on a few rows. Returns
    ``(rmse_a, mae_a, rmse_b, mae_b)``.
    """
    rng = np.random.default_rng(seed)
    y_true = rng.normal(50.0, 5.0, size=n)

    errors_a = rng.normal(0.0, 2.0, size=n)
    pred_a = y_true + errors_a

    errors_b = np.zeros(n)
    big_idx = rng.choice(n, size=5, replace=False)
    errors_b[big_idx] = rng.normal(0.0, 15.0, size=5)
    pred_b = y_true + errors_b

    return (
        round(rmse(y_true, pred_a), 4),
        round(mae(y_true, pred_a), 4),
        round(rmse(y_true, pred_b), 4),
        round(mae(y_true, pred_b), 4),
    )


# --------------------------------------------------------------------------
# 6. Units: RMSE and MAE carry the units of the target
# --------------------------------------------------------------------------


def raw_and_scaled_metrics(seed: int = 0):
    """RMSE, MAE and R2 fit on raw-unit features versus standardised ones.

    ``load_diabetes(scaled=False)`` returns the ten features in their
    original units -- age in years, bmi as bmi, blood pressure as measured
    -- while the target is the same disease-progression score either way.
    Ordinary least squares is invariant to a per-column affine rescaling of
    its inputs, so this returns identical numbers under both, which is
    itself the thing worth confirming rather than assuming.
    """
    results = {}
    for label, scaled in (("scaled", True), ("raw", False)):
        X_train, X_test, y_train, y_test = diabetes_split(scaled=scaled, seed=seed)
        model = LinearRegression().fit(X_train, y_train)
        pred = model.predict(X_test)
        results[label] = (round(rmse(y_test, pred), 4), round(mae(y_test, pred), 4), round(float(r2_score(y_test, pred)), 4))
    return results


# --------------------------------------------------------------------------
# 7. r2_score: agreement with .score, and the argument-order bug
# --------------------------------------------------------------------------


def r2_score_vs_model_score(seed: int = 0):
    """r2_score(y_true, y_pred) against LinearRegression.score(X, y)."""
    X_train, X_test, y_train, y_test = diabetes_split(seed=seed)
    model = LinearRegression().fit(X_train, y_train)
    pred = model.predict(X_test)
    return round(float(r2_score(y_test, pred)), 6), round(float(model.score(X_test, y_test)), 6)


def r2_score_argument_order(seed: int = 0):
    """r2_score is NOT symmetric in its two arguments: swap them and it changes.

    Returns ``(correct_order, swapped_order)``.
    """
    X_train, X_test, y_train, y_test = diabetes_split(seed=seed)
    model = LinearRegression().fit(X_train, y_train)
    pred = model.predict(X_test)
    return round(float(r2_score(y_test, pred)), 6), round(float(r2_score(pred, y_test)), 6)
starter/test_metrics_claims.py (7285 bytes)
"""Twelve exercises in what a regression metric reports, and what it hides.

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.
`regression_metrics_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 regression_metrics_lib as m  # noqa: F401  (you will need it)


# --- 1. Train R2 is not a quality measure ---------------------------------


def test_01_train_r2_climbs_on_pure_noise_columns():
    pytest.skip(
        "Call m.noise_column_r2_curve() and assert it equals the five rows in "
        "expected-output/measured-values.txt, from (0, 331, 10, 0.5554, 0.5415) "
        "to (100, 331, 110, 0.7403, 0.6104). Then assert the train_r2 column "
        "(index 3 of each row) is strictly increasing, and that it climbs by "
        "more than 0.18 from no noise to 100 noise columns. Every added column "
        "is independent random noise with zero relationship to the target."
    )


def test_01b_adjusted_r2_corrects_the_climb_then_breaks_down_itself():
    pytest.skip(
        "Build a dict keyed by noise count from m.noise_column_r2_curve(). "
        "Assert the adjusted R2 at 20 noise columns is LOWER than the 0-noise "
        "baseline (0.5329 < 0.5415) -- the correction working as intended. "
        "Then assert the adjusted R2 at 100 noise columns is HIGHER than both "
        "the baseline and the 20-noise value (0.6104 > 0.5415 and "
        "0.6104 > 0.5329) -- the correction breaking down once the number of "
        "predictors (110) becomes a large fraction of the sample size (331)."
    )


# --- 2. R2 is not bounded below by zero ------------------------------------


def test_02_the_full_model_beats_a_constant_mean_predictor_on_test():
    pytest.skip(
        "Assert m.full_model_test_r2() equals 0.3594 and that "
        "abs(m.constant_mean_test_r2()) is less than 0.001. A predictor that "
        "always guesses the training mean scores R2 essentially exactly zero "
        "on fresh test data BY CONSTRUCTION -- R2 is defined relative to "
        "exactly that predictor, which is why zero is the number it compares "
        "against, not an accident of this dataset."
    )


def test_02b_r2_has_no_lower_bound():
    pytest.skip(
        "Assert m.bad_predictor_test_r2() equals -4.7009 and is less than "
        "-4.0. A deliberately bad predictor -- always zero -- scores nearly "
        "five FULL UNITS below zero, which is impossible if R2 lived in "
        "[0, 1] the way most people assume it does."
    )


# --- 3. RMSE versus MAE under one outlier ----------------------------------


def test_03_rmse_moves_more_than_mae_when_one_target_is_an_outlier():
    pytest.skip(
        "Call m.rmse_mae_outlier_shift() and assert the four values equal "
        "(2.4801, 1.9833, 28.2569, 5.9448). Compute rmse_ratio = "
        "rmse_after / rmse_before and mae_ratio = mae_after / mae_before. "
        "Assert rmse_ratio > 11.0, mae_ratio < 3.5, and rmse_ratio > "
        "3 * mae_ratio. Same predictions both times; only one target row "
        "moved far away."
    )


# --- 4. MAPE breaking -------------------------------------------------------


def test_04_mape_explodes_silently_at_a_zero_true_value():
    pytest.skip(
        "Call m.mape_at_zero_target() and assert the result is greater than "
        "1.0e10. Note what does NOT happen: no exception, no warning -- "
        "scikit-learn floors the zero denominator at machine epsilon and "
        "returns a number that is off by roughly fourteen orders of "
        "magnitude from anything a percentage error should look like."
    )


def test_04b_mape_explodes_near_zero_while_mae_stays_sane():
    pytest.skip(
        "Call m.mape_near_zero_target() and assert it equals (3.3667, 5.0). "
        "The first value is MAPE, the second is MAE, on the SAME three "
        "predictions. MAE reports a believable 5.0 units of error; MAPE "
        "reports 336.67 percent, because one true value is 0.5 and a "
        "five-unit miss on 0.5 is a factor of ten."
    )


def test_05_mape_is_bounded_under_but_not_over():
    pytest.skip(
        "Call m.mape_asymmetry_bound() and assert it equals (1.0, 10.0). "
        "The first value is the MAPE of the worst possible systematic "
        "under-prediction -- always guessing zero -- which cannot exceed "
        "100 percent. The second is the MAPE of predicting eleven times the "
        "truth, which is 1000 percent, with no ceiling of its own. Assert "
        "ten_x_over > max_under. Being wrong in one direction is capped; "
        "being wrong in the other is not."
    )


# --- 6. A metric ranking inversion ------------------------------------------


def test_06_rmse_and_mae_prefer_different_models():
    pytest.skip(
        "Call m.ranking_inversion_models() and assert the four values equal "
        "(1.947, 1.586, 4.4353, 0.8417) for (rmse_a, mae_a, rmse_b, mae_b). "
        "Then assert rmse_a < rmse_b (RMSE prefers Model A) and mae_b < "
        "mae_a (MAE prefers Model B). Model A makes many small consistent "
        "errors; Model B is nearly perfect except for a handful of large "
        "misses. Reporting only one metric would silently pick a winner the "
        "other metric disagrees with."
    )


# --- 7. RMSE and MAE carry the target's units -------------------------------


def test_07_rmse_and_mae_are_identical_on_raw_and_standardised_features():
    pytest.skip(
        "Call m.raw_and_scaled_metrics() and assert results['scaled'] == "
        "(56.3929, 45.1206, 0.3594) and results['raw'] == the same tuple. "
        "Ordinary least squares is invariant to a per-column affine "
        "rescaling of its inputs, so every metric computed from its "
        "predictions is identical whether the features are standardised or "
        "left in raw units (age in years, bmi, raw blood pressure). Then "
        "state in a comment what unit the RMSE and MAE numbers are actually "
        "in for this dataset."
    )


# --- 8. r2_score: agreement, and the argument-order bug ---------------------


def test_08_r2_score_agrees_with_linearregression_score():
    pytest.skip(
        "Call m.r2_score_vs_model_score() and assert both returned values "
        "equal 0.359409. sklearn.metrics.r2_score(y_true, y_pred) and "
        "LinearRegression.score(X, y) compute the same quantity."
    )


def test_08b_r2_score_argument_order_changes_the_answer():
    pytest.skip(
        "Call m.r2_score_argument_order() and assert it equals "
        "(0.359409, -0.209635). r2_score is NOT symmetric in its two "
        "arguments -- the denominator is the variance of whichever array is "
        "passed FIRST. Assert the swapped value is negative while the "
        "correct-order value is positive: the same predictions, scored with "
        "the arguments the wrong way round, look worse than a constant-mean "
        "baseline."
    )
starter/test_metrics_lib.py (1946 bytes)
"""Machinery checks: the helpers behave, before any claim is made.

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

import numpy as np

import regression_metrics_lib as m


def test_diabetes_split_shapes_and_ranges():
    X_train, X_test, y_train, y_test = m.diabetes_split()
    assert X_train.shape == (331, 10)
    assert X_test.shape == (111, 10)
    assert y_train.shape == (331,)
    assert y_test.shape == (111,)
    # The target is documented as running from 25 to 346.
    assert 25.0 <= float(np.concatenate([y_train, y_test]).min())
    assert float(np.concatenate([y_train, y_test]).max()) <= 346.0


def test_rmse_and_mae_agree_on_a_perfect_prediction():
    y_true = np.array([1.0, 2.0, 3.0, 4.0])
    assert m.rmse(y_true, y_true) == 0.0
    assert m.mae(y_true, y_true) == 0.0


def test_rmse_is_never_smaller_than_mae():
    # A standard inequality: RMSE >= MAE always, with equality only when
    # every absolute error is identical.
    rng = np.random.default_rng(0)
    y_true = rng.normal(size=40)
    y_pred = y_true + rng.normal(scale=2.0, size=40)
    assert m.rmse(y_true, y_pred) >= m.mae(y_true, y_pred)
    # Equality when every error has the same magnitude.
    y_true_eq = np.zeros(6)
    y_pred_eq = np.array([1.0, -1.0, 1.0, -1.0, 1.0, -1.0])
    assert round(m.rmse(y_true_eq, y_pred_eq), 10) == round(m.mae(y_true_eq, y_pred_eq), 10)


def test_adjusted_r2_equals_r2_when_no_predictors_are_added():
    # With p fixed, adjusted R2 is a deterministic function of r2, n and p --
    # sanity-check the formula against a hand-computable case.
    # n=10, p=2, r2=0.8: 1 - (1-0.8) * 9/7
    assert round(m.adjusted_r2(0.8, 10, 2), 6) == round(1.0 - 0.2 * 9.0 / 7.0, 6)
    # Adjusted R2 is always <= R2 whenever p > 0 and n > p + 1.
    assert m.adjusted_r2(0.5, 100, 5) <= 0.5
tests/run_tests.sh (9540 bytes)
#!/usr/bin/env bash
# Day 152 lab harness: "What You Report Is Not What You Optimise"
#
# 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 regression_metrics_lib as m

errors = []


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


# 1. Noise-column climb
rows = m.noise_column_r2_curve()
expect(
    "noise-column curve",
    rows,
    [
        (0, 331, 10, 0.5554, 0.5415),
        (1, 331, 11, 0.5555, 0.5402),
        (5, 331, 15, 0.5648, 0.5441),
        (20, 331, 30, 0.5754, 0.5329),
        (100, 331, 110, 0.7403, 0.6104),
    ],
)
train_r2 = [row[3] for row in rows]
if not all(a < b for a, b in zip(train_r2, train_r2[1:])):
    errors.append("train R2 was not strictly increasing in the number of noise columns")

# 1b. Adjusted R2 corrects, then breaks down
by_noise = {row[0]: row for row in rows}
if not (by_noise[20][4] < by_noise[0][4]):
    errors.append("adjusted R2 at 20 noise columns did not fall below the baseline")
if not (by_noise[100][4] > by_noise[0][4]):
    errors.append("adjusted R2 at 100 noise columns did not rise back above the baseline")

# 2 and 2b. R2's bounds
expect("full-model test R2", m.full_model_test_r2(), 0.3594)
constant = m.constant_mean_test_r2()
if abs(constant) >= 0.001:
    errors.append(f"constant-mean test R2 was not near zero: {constant}")
expect("bad-predictor test R2", m.bad_predictor_test_r2(), -4.7009)

# 3. RMSE vs MAE under an outlier
expect(
    "outlier shift",
    m.rmse_mae_outlier_shift(),
    (2.4801, 1.9833, 28.2569, 5.9448),
)

# 4. MAPE breaking
if m.mape_at_zero_target() <= 1.0e10:
    errors.append("MAPE at a zero true value did not explode as expected")
expect("MAPE near zero", m.mape_near_zero_target(), (3.3667, 5.0))
expect("MAPE asymmetry bound", m.mape_asymmetry_bound(), (1.0, 10.0))

# 5. Ranking inversion
rmse_a, mae_a, rmse_b, mae_b = m.ranking_inversion_models()
expect("ranking inversion", (rmse_a, mae_a, rmse_b, mae_b), (1.947, 1.586, 4.4353, 0.8417))
if not (rmse_a < rmse_b and mae_b < mae_a):
    errors.append("the RMSE/MAE ranking did not invert between the two models")

# 6. Units
results = m.raw_and_scaled_metrics()
expect("scaled-feature metrics", results["scaled"], (56.3929, 45.1206, 0.3594))
expect("raw-feature metrics", results["raw"], (56.3929, 45.1206, 0.3594))

# 7. r2_score agreement and argument order
expect("r2_score vs model.score", m.r2_score_vs_model_score(), (0.359409, 0.359409))
expect("r2_score argument order", m.r2_score_argument_order(), (0.359409, -0.209635))

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 "every claim reproduced directly against regression_metrics_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 "^16 passed"; then
  ok "pytest examples -q -> 16 passed"
else
  fail "pytest examples -q did not report 16 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, 12 skipped"; then
  ok "pytest starter -q -> 4 passed, 12 skipped (the machinery checks pass; the twelve exercises are stubs)"
else
  fail "pytest starter -q did not report 4 passed, 12 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}/d152-scratch.XXXXXX")
cp examples/*.py "$SCRATCH"/
SCRATCH_OUT=$("$PYTEST" "$SCRATCH" -q 2>&1)
if echo "$SCRATCH_OUT" | tail -1 | grep -qE "^16 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_metrics_claims.py" <<'PYEOF'
import sys
path = sys.argv[1]
text = open(path).read()
needle = "assert (rmse_a, mae_a, rmse_b, mae_b) == (1.947, 1.586, 4.4353, 0.8417)"
replacement = "assert (rmse_a, mae_a, rmse_b, mae_b) == (0.0, 0.0, 0.0, 0.0)"
assert needle in text, "could not find the assertion to break"
open(path, "w").write(text.replace(needle, replacement, 1))
PYEOF
BROKEN_OUT=$("$PYTEST" "$SCRATCH" -q 2>&1)
BROKEN_STATUS=$?
if [ "$BROKEN_STATUS" -ne 0 ] && echo "$BROKEN_OUT" | grep -q "test_06_rmse_and_mae_prefer_different_models"; then
  ok "breaking exercise 6'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. Key results hold at seeds the lesson does not quote"
SEED_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import regression_metrics_lib as m

problems = []

# The noise-column climb is not a property of one dataset seed.
for seed in (1, 2, 3):
    rows = m.noise_column_r2_curve(seed=seed)
    train_r2 = [row[3] for row in rows]
    if not (train_r2[-1] > train_r2[0]):
        problems.append(f"seed {seed}: train R2 did not climb with noise columns")

# The RMSE/MAE ranking inversion is not a property of one seed either.
for seed in (5, 6, 7):
    rmse_a, mae_a, rmse_b, mae_b = m.ranking_inversion_models(seed=seed)
    if not (rmse_a < rmse_b and mae_b < mae_a):
        problems.append(f"seed {seed}: the RMSE/MAE ranking did not invert")

# RMSE is never smaller than MAE, at other outlier shifts.
for shift in (50.0, 100.0, 500.0):
    _rb, mb, ra, ma = m.rmse_mae_outlier_shift(shift=shift)
    if not (ra >= ma):
        problems.append(f"shift {shift}: RMSE was smaller than MAE")

if problems:
    for p in problems:
        print("ERROR:", p)
else:
    print("every direction held")
PYEOF
)
if [ "$SEED_CHECK" = "every direction held" ]; then
  ok "the noise-column climb and the RMSE/MAE ranking inversion hold at seeds the lesson does not quote"
else
  fail "a direction failed beyond the quoted seed"
  echo "$SEED_CHECK" | sed 's/^/    /'
fi

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

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

Troubleshooting

Troubleshooting

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

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

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

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

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

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

import file mismatch when running pytest

You ran pytest examples starter in one invocation. Both directories contain modules with the same names, so pytest cannot decide which regression_metrics_lib a test meant. Run them separately:

.venv/bin/pytest examples -q
.venv/bin/pytest starter -q

Check 5 of the harness deliberately asserts that the combined invocation fails, so this is documented behaviour rather than a surprise.

My MAPE number is astronomically large and I think something is broken

Nothing is broken -- exercise 4 asserts exactly this. sklearn.metrics. mean_absolute_percentage_error does not raise or warn when a true value is zero; it floors the denominator at machine epsilon and returns whatever that division produces. On the exact rows this lab uses, that is roughly 5.6e15, not a small mistake. If your number is a different enormous figure, that is expected too -- the floor value is np.finfo(np.float64). eps, and the exact result depends on the numerator, but "enormous and meaningless" is the point being demonstrated, not a specific digit.

My adjusted R2 at 100 noise columns is HIGHER than at 0 noise columns and that looks backwards

It is not backwards, and exercise 1b asserts exactly this. Adjusted R2 does correctly penalise the climb at a modest number of extra columns -- at 20 noise columns it drops below the no-noise baseline, as it should. But once the number of predictors (110, once you count the ten real features) gets close to a third of the number of training rows (331), the correction term (n-1)/(n-p-1) itself becomes large and unstable, and it can push adjusted R2 back above the baseline even though every added column is still pure noise. The lesson calls this out explicitly: the correction is not a cure, it has its own failure mode.

sklearn.linear_model.LinearRegression() gives different numbers on raw versus scaled features

It should not, and exercise 7 asserts that it does not. Ordinary least squares is invariant to a per-column affine rescaling of its inputs, so load_diabetes(scaled=True) and load_diabetes(scaled=False) produce identical predictions, and therefore identical RMSE, MAE and R2, once a model is fit on each. If your numbers differ, check that you fit two separate models -- one per feature set -- rather than reusing a model fit on one set of features to predict from the other.

The r2_score argument order thing seems too small to matter

Try it on your own data before deciding that. sklearn.metrics.r2_score is not symmetric in its two arguments: the denominator is the variance of whichever array is passed first. On this lab's exact predictions, the correct call reports 0.359409 (a usable model) and the swapped call reports -0.209635 (worse than guessing the mean, for the exact same predictions). This is a real and common bug, not a contrived one -- it is easy to write r2_score(y_pred, y_test) out of habit from functions where argument order does not matter.

LogisticRegression warns about convergence

This lab does not use LogisticRegression -- every model here is LinearRegression, which has a closed-form solution and never warns about convergence. If you see that warning, check that you have not accidentally imported from a different day's lab directory.

The harness takes a while

It should not, on any machine that can run Python at all. The heaviest step fits eleven LinearRegression models on at most 331 rows and 110 columns, which completes in well under a second on the capture machine. No timing is asserted anywhere, so a slow machine changes nothing about whether the harness passes.

Security notes

Security notes

What this lab touches

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

  • Filesystem. The lab reads only files inside its own directory, plus the diabetes dataset that ships bundled inside scikit-learn's own installed package files -- nothing is read from your home directory or from anywhere above the lab root. The one write outside the lab directory 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.
  • Network. After the one pip install, this lab is completely offline. Check 9 asserts that no URL appears anywhere in examples/ or starter/ source. load_diabetes returns an array bundled inside the scikit-learn wheel; nothing is downloaded and no external dataset file is fetched.
  • 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.
  • Reversibility. Everything this lab creates is inside its own directory and is removed by the cleanup commands in metadata.yml. rm -rf .venv returns the machine to exactly its prior state.

The one install step, and how to check it

pip install -r requirements/requirements.txt downloads three packages from the Python Package Index into a lab-local virtual environment, never into your system Python. Pinning exact versions is a security control as well as a reproducibility one: an unpinned install resolves to whatever is newest at the moment you run it, which is a moving target you have not reviewed.

If you want to verify what you are installing before you install it, pip can check hashes for you:

.venv/bin/pip install --require-hashes -r requirements/requirements.txt

That requires a hash-annotated requirements file, which this lab does not ship because the correct hashes differ per platform wheel. Generating one for your own platform with pip-compile --generate-hashes is a reasonable habit for any environment you care about.

The security idea in this lab

r2_score's argument-order bug in exercise 8b is worth reading as a general lesson about function contracts rather than only as a metrics quirk.

r2_score(y_true, y_pred) and r2_score(y_pred, y_true) are two different, silently-accepted calls that return two different numbers, and nothing in the type system or the function signature stops you from writing the wrong one. This is the same shape as a security bug caused by swapped arguments to a comparison function, a signature-verification call, or an access-control check that takes (subject, resource) and is called as (resource, subject): the call succeeds, returns a plausible-looking value, and the mistake surfaces only when someone checks the number against an independent source of truth. The defence in both cases is the same -- prefer keyword arguments for anything where the order is not obvious from context, and cross-check a computed value against a second method (r2_score(y_test, pred) against model.score(X_test, y_test), in this lab) rather than trusting a single call site.

What the code does that is worth understanding

  • Every dataset and every synthetic example takes a seed (or uses the bundled, unchanging diabetes data) and returns fresh arrays. Nothing is cached to disk, nothing is memoised across runs, and no global state carries between calls.
  • Nothing in this lab evaluates a string, imports dynamically, reads a path from data, or inspects the environment.
  • The harness captures the exit status of run_tests.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.