Machine Learning › Regression › Day 149
Hands-on lab — Day 149: Loss Functions and Least Squares
- ← Back to the Day 149 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/machine-learning/day-149-loss-functions-and-least-squares/
Commands
Setup
cd labs/sections/machine-learning/day-149-loss-functions-and-least-squares
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/loss_lib.py examples/report_measurements.py examples/test_loss_claims.py examples/test_loss_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/loss_lib.py starter/test_loss_claims.py starter/test_loss_lib.py tests/run_tests.sh troubleshooting.md
Lab README
Day 149 lab — Loss Functions and Least Squares
Lesson
- Lesson title: Loss Functions and Least Squares
- Day number: 149 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-149-loss-functions-and-least-squares
- 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-149-loss-functions-and-least-squareswhen the site is running.
Purpose
Everybody who has fit a line knows to minimise the error. Rather fewer people have measured what "the error" actually means, or noticed that it is a choice rather than a single obvious thing.
This lab measures the choice, using one outlier moved 80 units off an otherwise ordinary straight line:
| estimator | loss it minimises | slope before | slope after | movement |
|---|---|---|---|---|
LinearRegression |
squared error | 3.0465 | 3.8010 | +0.7545 |
HuberRegressor |
Huber (blended) | 2.9870 | 3.0308 | +0.0437 |
QuantileRegressor(0.5) |
absolute error | 2.9961 | 3.0064 | +0.0104 |
One point, and least squares moves seventeen times further than Huber and seventy-two times further than the median fit. That gap is not a quirk of scikit-learn's solvers — it is squaring the residual: an 80-unit error contributes 6,400 to a squared-error total and only 80 to an absolute-error total.
Before that, the lab measures the two textbook facts underneath every loss function: the mean minimises squared error and the median minimises absolute error, confirmed by a numerical grid search rather than asserted; and squared error's landscape is a smooth parabola while absolute error's is piecewise-linear and kinked, which is exactly why squared error has a closed-form solution — the normal equations — and absolute error does not.
The last two exercises ask what squared error is silently betting on. Fit both estimators 500 times each on freshly generated data, once with Gaussian errors and once with heavy-tailed errors of similar spread:
| errors | OLS spread (sd) | Huber spread (sd) | which is tighter |
|---|---|---|---|
| Gaussian | 0.0560 | 0.0588 | OLS |
| heavy-tailed | 0.0589 | 0.0422 | Huber |
Gauss-Markov's promise that ordinary least squares is the best linear unbiased estimator is conditional on the errors. Change what the errors look like and the ranking measurably flips.
Learning objectives
By the end of this lab you will be able to:
- Demonstrate numerically that the mean minimises squared error and the median minimises absolute error, using a grid search rather than a citation.
- Distinguish a smooth loss landscape from a kinked one by its second differences, and explain why that distinction is exactly why one loss has a closed-form solution and the other does not.
- Solve the normal equations directly and confirm the result matches
LinearRegressionto many decimal places. - Measure how far a single outlier moves a squared-error fit compared with a Huber fit and a median (absolute-error) fit on identical data.
- Sweep Huber's
epsilonparameter and observe it interpolate smoothly between absolute-error-like and squared-error-like behaviour. - State the Gauss-Markov result precisely — best linear, unbiased estimator, under its assumptions — and explain why every one of those words is load-bearing.
- Measure that OLS is the more precise (lower-variance) estimator under Gaussian errors and that Huber is more precise under heavy-tailed errors, on identical true parameters.
- State the distinction between a loss (what you optimise) and a metric (what you report), and explain why they need not be the same function.
- Verify that
HuberRegressorandQuantileRegressorboth exist and converge in a specific scikit-learn release, rather than assuming it.
Prerequisites
- Day 148 for the linear model itself: geometry, coefficient interpretation, and residual plots. This lab assumes you can already fit a line and read a residual; it does not re-teach that.
- Days 141-147 for what a score means, splits, overfitting, and the scikit-learn estimator API in general.
- Comfort with NumPy arrays and reading a pytest failure, and
python33.11 or newer on yourPATH.
Supported operating systems
- macOS (Apple Silicon or Intel) — the capture machine was macOS 26.5.2 on arm64.
- Linux (any distribution with Python 3.11+ and bash).
- Windows via WSL2. The harness is a bash script and uses
mktemp -d,findand process substitution; native PowerShell is not supported.
Hardware requirements
Any machine that can run Python. No GPU is needed or used — everything here is small-array NumPy and scikit-learn on the CPU. The heaviest step is 2,000 model fits behind the last two exercises, which completes in a few seconds on the capture machine. Around 400 MB of disk for the virtual environment, almost all of it scikit-learn and scipy.
Required software
- Python 3.11 or newer (3.14.0 during capture).
- bash 3.2 or newer (3.2.57 during capture — the macOS system bash).
- The three pinned packages in
requirements/requirements.txt:numpy==2.5.2,scikit-learn==1.9.0,pytest==9.1.1.
find, grep, awk, sed, diff and mktemp are used by the harness
and ship with every supported system.
Free and open-source options
Everything here is free and open source, and there is no paid tier anywhere in this lab.
- NumPy and scikit-learn are BSD 3-Clause licensed.
- pytest is MIT licensed.
- No dataset is downloaded or bundled: every dataset is a synthetic straight line generated on the spot from a seeded generator, so no dataset licence applies to your use of this lab.
The estimators used here — LinearRegression, HuberRegressor,
QuantileRegressor — are all part of scikit-learn. Ridge and lasso, which
add a penalty on top of a loss, are Day 151's subject and are not used
here.
Installation
From the repository root:
cd labs/sections/machine-learning/day-149-loss-functions-and-least-squares
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-149-loss-functions-and-least-squares/
├── 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
│ ├── loss_lib.py complete machinery — not the exercise
│ ├── test_loss_lib.py four machinery checks, already solved
│ └── test_loss_claims.py ten exercises, each a skip to replace
├── examples/
│ ├── loss_lib.py identical to the starter copy
│ ├── test_loss_lib.py the same four machinery checks
│ ├── test_loss_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/loss_lib.py and examples/loss_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, ten exercises skip until you write them |
.venv/bin/pytest examples -q |
Runs the reference solutions — fourteen assertions about how losses behave |
.venv/bin/python3 examples/report_measurements.py |
Recomputes every published number and prints them as one table |
bash tests/run_tests.sh |
Fourteen checks: version pins, every claim reproduced without pytest, both suites, the collision, a byte-comparison of the report, a deliberate self-break, three directions re-confirmed beyond the quoted seeds, and cleanliness |
Expected output
bash tests/run_tests.sh ends with:
---------------------------------------------------------------
14 checks, 0 failure(s)
and exits 0. pytest examples -q reports 14 passed.
pytest starter -q reports 4 passed, 10 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 mean and
median as minimisers, the normal equations matching LinearRegression,
the direction of every comparison — from what holds only under the pinned
versions, which is most of the sampled decimals.
Validation steps
bash tests/run_tests.sh; echo "exit=$?"→14 checks, 0 failure(s)andexit=0..venv/bin/pytest examples -q→14 passed..venv/bin/pytest starter -q→4 passed, 10 skippedbefore you start;14 passedwhen you have finished every exercise..venv/bin/python3 examples/report_measurements.py | diff - expected-output/measured-values.txt→ no output.- Break one assertion in
examples/test_loss_claims.pyon purpose, re-run the harness, and confirm it reports failures and exits non-zero. Restore it. A test suite you have never seen fail is not evidence.
Tests
tests/run_tests.sh is a bash assert harness. It prints one ok: or
FAIL: line per check, ends with N checks, M failure(s), and exits
non-zero when M is not zero.
The fourteen checks are:
1-3. The installed numpy, scikit-learn and pytest match the pins exactly.
4. Every published claim reproduced directly against loss_lib, with no
pytest involved — so a broken test file cannot hide a broken library,
and vice versa.
5. pytest examples -q reports 14 passed.
6. pytest starter -q reports 4 passed, 10 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 after exercise 4's outlier-movement assertion is deliberately
rewritten, naming the failing test.
11. Outlier sensitivity, the normal equations, and the Gauss-Markov
efficiency ranking are re-confirmed at seeds and a replication count
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, sampled figures moving with the
package pins, QuantileRegressor's solver argument, HuberRegressor
convergence, the grid search's finite resolution, and how long the
efficiency comparison takes.
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 the
outlier-sensitivity result as a security idea: a loss function decides,
silently, how much weight one extreme input gets, and squared error gives
it the most.
Extension exercises
- Ridge, ahead of schedule. Fit
Ridge(alpha=1.0)on the outlier- contaminated dataset from exercise 4 and measure how far its slope moves compared with plainLinearRegression. Ridge adds a penalty on top of squared error rather than changing the loss itself — Day 151 owns this properly, but the comparison is worth seeing once here. - A third heavy tail. Repeat exercises 6 and 6b with a Laplace- distributed error instead of Student's t, and report whether the efficiency ranking still flips in Huber's favour.
- Where does the ranking cross? Sweep the degrees of freedom of the
Student's t error from 30 down to 1 and find, approximately, the value
at which
sd(OLS) == sd(Huber). Report what that implies about how heavy the tails need to be before Huber earns its keep. - A second outlier. Add a second point 80 units off the line at a different x position and re-measure the outlier-shift table. Does OLS's movement roughly double, or is it worse than that?
epsilonversus outlier size. Fixepsilon=1.35and sweep the outlier's offset from 5 to 200. At what offset does Huber's slope start moving noticeably, and how does that connect to the residual scale in this dataset?- The quantile beyond the median. Fit
QuantileRegressoratquantile=0.1andquantile=0.9on the outlier-contaminated data and compare the two fitted lines. What does each one describe about the conditional distribution of y that a single squared-error fit cannot? - Time the normal equations against gradient descent. Day 153 owns gradient descent properly, but as a preview: write a five-line gradient descent loop for squared error on the 300-row dataset from exercise 3, and report how many iterations it takes to match the normal equations' answer to four decimal places.
Navigation
- Lab brief:
starter/00_brief.md - Previous lab:
../day-148-linear-regression/ - Next lab:
../day-150-multiple-and-polynomial-regression/ - 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 1.18.1,
joblib 1.5.3 and threadpoolctl 3.6.0 pulled in as scikit-learn's own
dependencies.
## Exact on any machine, for any reason
These are arithmetic or structural facts, not measurements that happened
to come out a certain way.
- **The mean minimises squared error and the median minimises absolute
error.** These are calculus facts (the derivative of sum-of-squares is
linear in the candidate and zero at the mean; sum-of-absolute-values is
minimised where equal counts of points sit on each side). The grid
search in exercise 1 lands within its own resolution of the closed-form
answer on any machine, because it is searching the same arithmetic.
- **Squared error's landscape is a parabola and absolute error's is
piecewise linear**, for any dataset. Exercise 2's specific numbers are
sampled (see below), but the *structural* fact — constant second
differences for one, varying second differences for the other — holds
for any x, y and any slope range.
- **The normal equations match `LinearRegression` to many decimal
places.** Both solve the identical system `(X^T X) beta = X^T y`; one
does it by hand with `numpy.linalg.solve` and the other through
scikit-learn's LAPACK-backed solver. Harness check 8 confirms the
agreement at three sample sizes the lesson does not quote.
- **OLS moves further than Huber, which moves further than the median
fit, when a single point becomes an outlier.** This is squaring versus
not squaring the residual: an 80-unit residual contributes 6,400 to a
squared-error total and 80 to an absolute-error total, so the direction
of "least squares reacts hardest" holds for essentially any outlier size
and any underlying dataset. Harness check 8 re-confirms the ranking at
five dataset seeds the lesson never quotes.
- **The Huber epsilon sweep is non-decreasing and converges to the OLS
slope at large epsilon.** Structural: Huber's loss is squared error
inside its threshold and (scaled) absolute error outside it, so raising
the threshold can only move the fit toward the pure-squared-error
answer, never away from it.
- **Gaussian errors favour OLS; heavy-tailed errors favour Huber.** The
direction, not the exact ratio, is asserted at a different replication
count (150 instead of 500) in harness check 8, so it is not an artefact
of the specific replication count quoted in the lesson.
## Exact under these pins, and only these
Every sampled figure in this lab comes from `numpy.random.default_rng` or
from an iterative scikit-learn solver (`HuberRegressor`,
`QuantileRegressor`). **NumPy's own documentation states that `Generator`
carries no stream-compatibility guarantee across versions**, and a
solver's exact output can shift in its last few decimal places between
scikit-learn releases even at a fixed seed, because the stopping tolerance
is part of the library, not the seed. So these are reproducible under the
pins in `requirements/requirements.txt` and not guaranteed beyond them.
| Value | Exercise | What it is |
| --- | --- | --- |
| grid argmin `23.39975` and `5.00005` | 1 | numerical grid search near the mean and median |
| sd of second differences `0.000000` and `1.9366` | 2 | the loss-landscape curvature measurement |
| normal-equations `slope=2.9779`, `intercept=4.9663` | 3 | on the specific 300-row seeded dataset |
| the outlier-shift table: `3.0465 -> 3.8010`, `2.987 -> 3.0308`, `2.9961 -> 3.0064` | 4, 4b | before/after slopes on the specific 60-row seeded dataset |
| the seven-row Huber epsilon sweep | 5 | slopes at each epsilon on the same contaminated dataset |
| `(2.998, 0.056, 2.9977, 0.0588)` under Gaussian errors | 6 | 500-replication mean and sd of each estimator's slope |
| `(2.9967, 0.0589, 2.9984, 0.0422)` under heavy-tailed errors | 6b | the same, with Student's t (df=3) errors |
## Sampled, and therefore soft even here
- **The outlier-shift and epsilon-sweep numbers are from one dataset
(seed 1, 60 rows).** A different seed changes the exact decimals but not
the ranking, which is what harness check 8 verifies across five seeds.
- **The efficiency-under-noise ratios (0.9524 and 1.3957) are averages
over 500 replications.** A single replication is far noisier — early
exploration while building this lab saw individual Huber slopes ranging
from about 2.85 to 3.15 on one dataset alone. The mean and standard
deviation over many replications is what makes the comparison
meaningful, per Days 117-118.
- **`2.9977` and `2.9984`, Huber's mean slopes under Gaussian and
heavy-tailed errors, sit slightly further from the true value of `3.0`
than OLS's `2.998` does under Gaussian errors.** This is sampling noise
in the mean of 500 draws, not evidence that Huber is biased; harness
check 8 asserts both means stay within 0.01 of the truth at a shorter
replication count too.
## The one honesty call this lab required
`HuberRegressor` and `QuantileRegressor` both had to be verified to exist
and converge in scikit-learn 1.9.0 before anything was built on them —
per the day's instructions, neither was assumed. Both converged without
warning on every dataset used here; `QuantileRegressor` is fit with
`solver="highs"` and `alpha=0.0` to disable the L1 regulariser it applies
by default, so it measures plain, unregularised absolute-error (median)
regression rather than a penalised variant. Penalised losses — ridge,
lasso and their relatives — are Day 151's subject, not this lab's.
## Timings
No timing is asserted anywhere in this lab. The heaviest step is the
1,000 model fits behind exercises 6 and 6b (500 replications, two
estimators each, in two error settings), which takes a few seconds here
and will take longer elsewhere without changing a single assertion,
because every assertion is about a shape or a value.
examples-run.txt
.............. [100%]
14 passed in 2.15s
measured-values.txt
Day 149 -- loss functions and least squares, measured
=======================================================
1. What each loss minimises
---------------------------
values: [2.0, 3.0, 5.0, 7.0, 100.0]
mean = 23.4000 grid argmin of squared error = 23.39975
median = 5.0000 grid argmin of absolute error = 5.00005
2. The shape of the loss landscape
----------------------------------
argmin, squared-error slope : 3.0
argmin, absolute-error slope : 3.0
sd of squared-error's second differences : 0.000000 (constant -> parabola)
sd of absolute-error's second differences : 1.9366 (varies -> kinked)
3. The normal equations, squared error's closed form
----------------------------------------------------
normal equations : intercept=4.966335 slope=2.977853
LinearRegression : intercept=4.966335 slope=2.977853
difference : intercept=5.68e-14 slope=1.20e-14
4. Outlier sensitivity: one point, moved 80 units off the line
--------------------------------------------------------------
estimator before after movement
ols 3.0465 3.8010 +0.7545
huber 2.9870 3.0308 +0.0437
quantile 2.9961 3.0064 +0.0104
OLS moved 17.3x further than Huber, 72.5x further than the median fit
5. Huber's delta, sweeping from absolute-error-like to squared-error-like
-------------------------------------------------------------------------
epsilon slope
1.00 3.0064
1.35 3.0308
1.50 3.0505
2.00 3.0906
5.00 3.1511
20.00 3.8010
100.00 3.8010
OLS on the same outlier-contaminated data: slope=3.8010 -- matches the large-epsilon end of the sweep
6. What squared error assumes about the errors
----------------------------------------------
500 replications, n=150 rows each, true slope 3.0
Gaussian errors : OLS mean=2.9980 sd=0.0560 Huber mean=2.9977 sd=0.0588
ratio sd(OLS)/sd(Huber) = 0.9524 (below 1: OLS is the more efficient choice)
heavy-tailed errors : OLS mean=2.9967 sd=0.0589 Huber mean=2.9984 sd=0.0422
ratio sd(OLS)/sd(Huber) = 1.3957 (above 1: Huber is now the more efficient choice)
both estimators stay close to unbiased in both settings; only the spread changes
starter-run.txt
ssssssssss.... [100%]
4 passed, 10 skipped in 0.52s
test-run.txt
1. Installed versions match requirements/requirements.txt
numpy 2.5.2
scikit-learn 1.9.0
pytest 9.1.1
ok: numpy 2.5.2 matches the pin
ok: scikit-learn 1.9.0 matches the pin
ok: pytest 9.1.1 matches the pin
2. Every published claim, reproduced directly (no pytest involved)
ok: exercises 1-6 reproduced directly against loss_lib, no pytest involved
3. examples/ passes in full
ok: pytest examples -q -> 14 passed
4. starter/ is an untouched skeleton
ok: pytest starter -q -> 4 passed, 10 skipped (the machinery checks pass; the ten 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 4's assertion produces a non-zero exit and names the failing test
8. The direction of every result holds beyond the quoted seeds
ok: outlier sensitivity, the normal equations and the Gauss-Markov ranking 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/loss_lib.py (9264 bytes)
"""Loss functions, measured: what choosing squared error over absolute error
actually decides, and what each is implicitly betting on.
A loss function is a choice, not a law of nature. This module measures four
consequences of that choice: the minimiser of squared error is the mean and
the minimiser of absolute error is the median; the squared-error landscape is
smooth with one minimum while the absolute-error landscape is piecewise
linear and kinked; the normal equations solve squared error in closed form
because that smoothness gives a zero derivative one can solve for directly,
while absolute error has no such closed form; and swapping which loss you
minimise changes how far a single outlier can move your line, and which loss
wins depends on what the errors actually look like -- Gaussian, or
heavy-tailed.
Everything here is deterministic given a seed.
"""
from __future__ import annotations
import numpy as np
from sklearn.linear_model import HuberRegressor, LinearRegression, QuantileRegressor
# --------------------------------------------------------------------------
# 1. What each loss minimises
# --------------------------------------------------------------------------
def sse(values, candidate: float) -> float:
"""Sum of squared error between a scalar candidate and every value."""
values = np.asarray(values, dtype=float)
return float(np.sum((values - candidate) ** 2))
def sae(values, candidate: float) -> float:
"""Sum of absolute error between a scalar candidate and every value."""
values = np.asarray(values, dtype=float)
return float(np.sum(np.abs(values - candidate)))
def grid_minimize(values, loss_fn, lo: float, hi: float, steps: int = 200_001) -> float:
"""Find the candidate in a fine grid that minimises the given loss.
A brute-force numerical stand-in for calculus: since it searches a
finite grid it lands within ``(hi - lo) / (steps - 1)`` of the true
minimiser, not exactly on it.
"""
grid = np.linspace(lo, hi, steps)
losses = np.array([loss_fn(values, c) for c in grid])
return float(grid[int(np.argmin(losses))])
# --------------------------------------------------------------------------
# 2. The shape of the loss landscape: smooth, or kinked
# --------------------------------------------------------------------------
def make_line_data(n: int = 150, seed: int = 0, true_intercept: float = 5.0,
true_slope: float = 3.0, noise_sd: float = 2.0,
heavy_tailed: bool = False, heavy_df: int = 3,
heavy_scale: float = 1.2):
"""A simple one-predictor dataset with a known true line.
``heavy_tailed=True`` swaps the Gaussian error for a scaled Student's t
with ``heavy_df`` degrees of freedom, which has the same rough central
spread but far fatter tails -- the construction used to measure what
squared error implicitly assumes about the errors.
"""
rng = np.random.default_rng(seed)
x = rng.uniform(0.0, 10.0, n)
if heavy_tailed:
errors = rng.standard_t(df=heavy_df, size=n) * heavy_scale
else:
errors = rng.normal(0.0, noise_sd, n)
y = true_intercept + true_slope * x + errors
return x, y
def loss_landscape(x, y, intercept: float, slopes):
"""Total squared error and total absolute error at each candidate slope,
with the intercept held fixed. Returns ``(sq_losses, abs_losses)``.
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
slopes = np.asarray(slopes, dtype=float)
sq_losses = np.empty_like(slopes)
abs_losses = np.empty_like(slopes)
for i, m in enumerate(slopes):
residual = y - (intercept + m * x)
sq_losses[i] = np.sum(residual**2)
abs_losses[i] = np.sum(np.abs(residual))
return sq_losses, abs_losses
def second_differences(values) -> np.ndarray:
"""The discrete second derivative of a sequence: ``diff(diff(values))``.
Constant second differences mean the curve is a parabola -- smooth,
with a single well-defined slope of the slope. Jumping second
differences mean the curve bends only at particular points -- a
piecewise-linear, kinked shape.
"""
return np.diff(np.asarray(values, dtype=float), n=2)
# --------------------------------------------------------------------------
# 3. The normal equations: squared error's closed form
# --------------------------------------------------------------------------
def normal_equations(x, y):
"""Solve for (intercept, slope) directly from the normal equations.
Squared error is smooth everywhere, so setting its derivative to zero
gives a linear system to solve: ``(X^T X) beta = X^T y``. Absolute
error has no derivative at a residual of zero, so no equivalent closed
form exists for it -- fitting it requires an iterative solver instead
(which is what ``QuantileRegressor`` runs).
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
design = np.column_stack([np.ones_like(x), x])
beta = np.linalg.solve(design.T @ design, design.T @ y)
return float(beta[0]), float(beta[1])
def fit_ols(x, y):
"""Fit ordinary least squares with scikit-learn; return (intercept, slope)."""
model = LinearRegression().fit(np.asarray(x, dtype=float).reshape(-1, 1), y)
return float(model.intercept_), float(model.coef_[0])
# --------------------------------------------------------------------------
# 4. Outlier sensitivity: squared error, Huber, and absolute error compared
# --------------------------------------------------------------------------
def fit_huber(x, y, epsilon: float = 1.35, max_iter: int = 500):
"""Fit scikit-learn's HuberRegressor; return (intercept, slope)."""
model = HuberRegressor(epsilon=epsilon, max_iter=max_iter).fit(
np.asarray(x, dtype=float).reshape(-1, 1), y
)
return float(model.intercept_), float(model.coef_[0])
def fit_quantile(x, y, quantile: float = 0.5):
"""Fit scikit-learn's QuantileRegressor at the median (absolute error's
minimiser); return (intercept, slope). ``alpha=0`` disables the
regulariser this estimator applies by default, so it measures plain
absolute error.
"""
model = QuantileRegressor(quantile=quantile, alpha=0.0, solver="highs").fit(
np.asarray(x, dtype=float).reshape(-1, 1), y
)
return float(model.intercept_), float(model.coef_[0])
def outlier_shift(x, y, outlier_index: int | None = None, outlier_offset: float = 80.0):
"""Fit OLS, Huber and median regression before and after moving one
point far off the line. Returns a dict with each estimator's slope
before, slope after, and how far the slope moved.
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
if outlier_index is None:
outlier_index = int(np.argmax(x))
y_outlier = y.copy()
y_outlier[outlier_index] = y_outlier[outlier_index] + outlier_offset
result = {}
for name, fitter in (("ols", fit_ols), ("huber", fit_huber), ("quantile", fit_quantile)):
_b0, before = fitter(x, y)
_a0, after = fitter(x, y_outlier)
result[name] = {
"before": round(before, 4),
"after": round(after, 4),
"movement": round(after - before, 4),
}
return result
def huber_epsilon_sweep(x, y, epsilons):
"""The Huber slope at each epsilon, holding the (outlier-contaminated)
data fixed. Small epsilon leans on the absolute-error half of the
loss and large epsilon leans on the squared-error half, converging to
plain OLS as epsilon grows without bound.
"""
rows = []
for eps in epsilons:
_intercept, slope = fit_huber(x, y, epsilon=eps)
rows.append((float(eps), round(slope, 4)))
return rows
# --------------------------------------------------------------------------
# 5. What each loss assumes: Gaussian errors, or something heavier-tailed
# --------------------------------------------------------------------------
def efficiency_under_noise(heavy_tailed: bool, replications: int = 500,
n: int = 150, true_slope: float = 3.0):
"""Fit OLS and Huber on many independent datasets with the same true
line, and report the mean and standard deviation of each estimator's
slope. Returns ``(ols_mean, ols_sd, huber_mean, huber_sd)``.
Under Gaussian errors, Gauss-Markov says OLS is the best LINEAR
UNBIASED estimator: among estimators that are linear in y and unbiased,
OLS has the smallest variance. Under heavy-tailed errors that
guarantee no longer implies OLS is the lowest-variance choice, and
this function measures whether it still is.
"""
ols_slopes = np.empty(replications)
huber_slopes = np.empty(replications)
for seed in range(replications):
x, y = make_line_data(n=n, seed=seed, true_slope=true_slope, heavy_tailed=heavy_tailed)
_i0, ols_slopes[seed] = fit_ols(x, y)
_i1, huber_slopes[seed] = fit_huber(x, y)
return (
round(float(ols_slopes.mean()), 4),
round(float(ols_slopes.std()), 4),
round(float(huber_slopes.mean()), 4),
round(float(huber_slopes.std()), 4),
)
examples/report_measurements.py (4328 bytes)
#!/usr/bin/env python3
"""Print every measured pair in this lab as one table.
The harness compares this output byte for byte against
expected-output/measured-values.txt, so the report is not a convenience:
it is how the lab notices that a number in the lesson has gone stale.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import numpy as np # noqa: E402
import loss_lib as L # noqa: E402
VALUES = [2.0, 3.0, 5.0, 7.0, 100.0]
def rule(title: str) -> None:
print()
print(title)
print("-" * len(title))
def main() -> None:
print("Day 149 -- loss functions and least squares, measured")
print("=" * 55)
rule("1. What each loss minimises")
best_sq = L.grid_minimize(VALUES, L.sse, 0.0, 110.0)
best_abs = L.grid_minimize(VALUES, L.sae, 0.0, 110.0)
print(f" values: {VALUES}")
print(f" mean = {np.mean(VALUES):.4f} grid argmin of squared error = {best_sq:.5f}")
print(f" median = {np.median(VALUES):.4f} grid argmin of absolute error = {best_abs:.5f}")
rule("2. The shape of the loss landscape")
x, y = L.make_line_data(n=40, seed=3)
slopes = np.round(np.arange(2.0, 4.01, 0.1), 4)
sq_losses, abs_losses = L.loss_landscape(x, y, intercept=5.0, slopes=slopes)
print(f" argmin, squared-error slope : {slopes[int(np.argmin(sq_losses))]}")
print(f" argmin, absolute-error slope : {slopes[int(np.argmin(abs_losses))]}")
print(f" sd of squared-error's second differences : {np.std(L.second_differences(sq_losses)):.6f} (constant -> parabola)")
print(f" sd of absolute-error's second differences : {np.std(L.second_differences(abs_losses)):.4f} (varies -> kinked)")
rule("3. The normal equations, squared error's closed form")
x3, y3 = L.make_line_data(n=300, seed=2)
intercept_eq, slope_eq = L.normal_equations(x3, y3)
intercept_sk, slope_sk = L.fit_ols(x3, y3)
print(f" normal equations : intercept={intercept_eq:.6f} slope={slope_eq:.6f}")
print(f" LinearRegression : intercept={intercept_sk:.6f} slope={slope_sk:.6f}")
print(f" difference : intercept={abs(intercept_eq - intercept_sk):.2e} slope={abs(slope_eq - slope_sk):.2e}")
rule("4. Outlier sensitivity: one point, moved 80 units off the line")
x4, y4 = L.make_line_data(n=60, seed=1, noise_sd=1.5)
result = L.outlier_shift(x4, y4, outlier_offset=80.0)
print(" estimator before after movement")
for name in ("ols", "huber", "quantile"):
r = result[name]
print(f" {name:9s} {r['before']:.4f} {r['after']:.4f} {r['movement']:+.4f}")
ols_move = abs(result["ols"]["movement"])
print(f" OLS moved {ols_move / abs(result['huber']['movement']):.1f}x further than Huber, "
f"{ols_move / abs(result['quantile']['movement']):.1f}x further than the median fit")
rule("5. Huber's delta, sweeping from absolute-error-like to squared-error-like")
y_outlier = np.asarray(y4, dtype=float).copy()
y_outlier[int(np.argmax(x4))] += 80.0
sweep = L.huber_epsilon_sweep(x4, y_outlier, [1.0, 1.35, 1.5, 2.0, 5.0, 20.0, 100.0])
print(" epsilon slope")
for eps, slope in sweep:
print(f" {eps:7.2f} {slope:.4f}")
ols_intercept, ols_slope = L.fit_ols(x4, y_outlier)
print(f" OLS on the same outlier-contaminated data: slope={ols_slope:.4f} -- matches the large-epsilon end of the sweep")
rule("6. What squared error assumes about the errors")
gauss = L.efficiency_under_noise(heavy_tailed=False, replications=500)
heavy = L.efficiency_under_noise(heavy_tailed=True, replications=500)
print(" 500 replications, n=150 rows each, true slope 3.0")
print(f" Gaussian errors : OLS mean={gauss[0]:.4f} sd={gauss[1]:.4f} Huber mean={gauss[2]:.4f} sd={gauss[3]:.4f}")
print(f" ratio sd(OLS)/sd(Huber) = {gauss[1] / gauss[3]:.4f} (below 1: OLS is the more efficient choice)")
print(f" heavy-tailed errors : OLS mean={heavy[0]:.4f} sd={heavy[1]:.4f} Huber mean={heavy[2]:.4f} sd={heavy[3]:.4f}")
print(f" ratio sd(OLS)/sd(Huber) = {heavy[1] / heavy[3]:.4f} (above 1: Huber is now the more efficient choice)")
print(" both estimators stay close to unbiased in both settings; only the spread changes")
if __name__ == "__main__":
main()
examples/test_loss_claims.py (5493 bytes)
"""Ten exercises in what choosing a loss function actually decides.
Read `../starter/00_brief.md` first. `loss_lib.py` is complete -- it is the
machinery, not the exercise. This file holds the reference solutions.
Run this suite on its own:
.venv/bin/pytest examples -q
Never run `pytest examples starter` in one invocation: both directories
define modules with the same names and pytest aborts on the collision.
"""
import numpy as np
import loss_lib as L
VALUES = [2.0, 3.0, 5.0, 7.0, 100.0]
def test_01_mean_minimizes_squared_error():
best = L.grid_minimize(VALUES, L.sse, 0.0, 110.0)
assert round(np.mean(VALUES), 4) == 23.4
assert abs(best - 23.4) < 0.001
def test_01b_median_minimizes_absolute_error():
best = L.grid_minimize(VALUES, L.sae, 0.0, 110.0)
assert np.median(VALUES) == 5.0
assert abs(best - 5.0) < 0.001
# The mean is dragged toward the 100.0 outlier; the median ignores its
# size entirely and only counts how many values sit on each side of it.
assert np.mean(VALUES) > 4 * np.median(VALUES)
def test_02_squared_error_landscape_is_smooth_with_one_minimum():
x, y = L.make_line_data(n=40, seed=3)
slopes = np.round(np.arange(2.0, 4.01, 0.1), 4)
sq_losses, _abs_losses = L.loss_landscape(x, y, intercept=5.0, slopes=slopes)
assert slopes[int(np.argmin(sq_losses))] == 3.0
# A parabola has a CONSTANT second difference; this is what "smooth,
# one minimum" means numerically rather than just visually.
second = L.second_differences(sq_losses)
assert round(float(np.std(second)), 6) == 0.0
def test_02b_absolute_error_landscape_is_piecewise_linear_and_kinked():
x, y = L.make_line_data(n=40, seed=3)
slopes = np.round(np.arange(2.0, 4.01, 0.1), 4)
_sq_losses, abs_losses = L.loss_landscape(x, y, intercept=5.0, slopes=slopes)
assert slopes[int(np.argmin(abs_losses))] == 3.0
# Absolute error's second difference is NOT constant: the slope of the
# loss changes only at the slopes where a residual crosses zero, so the
# curve is a sequence of straight segments meeting at kinks.
second = L.second_differences(abs_losses)
assert round(float(np.std(second)), 4) == 1.9366
assert float(np.std(second)) > 0.0
def test_03_the_normal_equations_solve_squared_error_in_closed_form():
x, y = L.make_line_data(n=300, seed=2)
intercept_eq, slope_eq = L.normal_equations(x, y)
intercept_sk, slope_sk = L.fit_ols(x, y)
assert abs(intercept_eq - intercept_sk) < 1e-9
assert abs(slope_eq - slope_sk) < 1e-9
assert round(slope_eq, 4) == 2.9779
assert round(intercept_eq, 4) == 4.9663
def test_04_ols_moves_far_when_a_single_point_becomes_an_outlier():
x, y = L.make_line_data(n=60, seed=1, noise_sd=1.5)
result = L.outlier_shift(x, y, outlier_offset=80.0)
assert result["ols"] == {"before": 3.0465, "after": 3.801, "movement": 0.7545}
def test_04b_huber_and_median_regression_barely_move():
x, y = L.make_line_data(n=60, seed=1, noise_sd=1.5)
result = L.outlier_shift(x, y, outlier_offset=80.0)
assert result["huber"] == {"before": 2.987, "after": 3.0308, "movement": 0.0437}
assert result["quantile"] == {"before": 2.9961, "after": 3.0064, "movement": 0.0104}
# OLS moved roughly 17x further than Huber and 72x further than the
# median fit, from the identical single outlier.
ols_move = abs(result["ols"]["movement"])
assert round(ols_move / abs(result["huber"]["movement"]), 1) == 17.3
assert round(ols_move / abs(result["quantile"]["movement"]), 1) == 72.5
def test_05_hubers_delta_interpolates_between_absolute_and_squared_error():
x, y = L.make_line_data(n=60, seed=1, noise_sd=1.5)
y_outlier = np.asarray(y, dtype=float).copy()
y_outlier[int(np.argmax(x))] += 80.0
sweep = L.huber_epsilon_sweep(x, y_outlier, [1.0, 1.35, 1.5, 2.0, 5.0, 20.0, 100.0])
assert sweep == [
(1.0, 3.0064),
(1.35, 3.0308),
(1.5, 3.0505),
(2.0, 3.0906),
(5.0, 3.1511),
(20.0, 3.801),
(100.0, 3.801),
]
slopes = [s for _e, s in sweep]
assert all(a <= b for a, b in zip(slopes, slopes[1:]))
ols_intercept, ols_slope = L.fit_ols(x, y_outlier)
assert sweep[-1][1] == round(ols_slope, 4)
def test_06_squared_error_is_the_most_efficient_choice_under_gaussian_errors():
ols_mean, ols_sd, huber_mean, huber_sd = L.efficiency_under_noise(
heavy_tailed=False, replications=500
)
assert (ols_mean, ols_sd, huber_mean, huber_sd) == (2.998, 0.056, 2.9977, 0.0588)
assert abs(ols_mean - 3.0) < 0.01
assert abs(huber_mean - 3.0) < 0.01
# Under Gaussian errors OLS has the smaller spread -- Gauss-Markov's
# promise that it is the BEST (lowest-variance) LINEAR UNBIASED
# ESTIMATOR is not an abstraction here, it is this ratio being below 1.
assert round(ols_sd / huber_sd, 4) == 0.9524
assert ols_sd < huber_sd
def test_06b_but_not_under_heavy_tailed_errors():
ols_mean, ols_sd, huber_mean, huber_sd = L.efficiency_under_noise(
heavy_tailed=True, replications=500
)
assert (ols_mean, ols_sd, huber_mean, huber_sd) == (2.9967, 0.0589, 2.9984, 0.0422)
assert abs(ols_mean - 3.0) < 0.01
assert abs(huber_mean - 3.0) < 0.01
# Both estimators are still roughly unbiased. But with fat-tailed
# errors the ranking flips: Huber's spread is now the smaller one.
assert round(ols_sd / huber_sd, 4) == 1.3957
assert huber_sd < ols_sd
examples/test_loss_lib.py (1426 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 loss_lib as L
def test_sse_and_sae_are_zero_at_a_perfect_fit_and_positive_elsewhere():
values = [1.0, 2.0, 3.0]
assert L.sse(values, 2.0) == 2.0 # (1-2)^2 + (2-2)^2 + (3-2)^2
assert L.sae(values, 2.0) == 2.0 # |1-2| + |2-2| + |3-2|
assert L.sse(values, 100.0) > 0
assert L.sae(values, 100.0) > 0
def test_make_line_data_is_deterministic_given_a_seed():
x1, y1 = L.make_line_data(n=20, seed=42)
x2, y2 = L.make_line_data(n=20, seed=42)
assert np.array_equal(x1, x2)
assert np.array_equal(y1, y2)
x3, _y3 = L.make_line_data(n=20, seed=43)
assert not np.array_equal(x1, x3)
def test_grid_minimize_finds_the_minimum_of_a_simple_quadratic():
# sse(values, c) for a single value v is (v - c)^2, minimised at c = v.
best = L.grid_minimize([7.0], L.sse, 0.0, 14.0, steps=14001)
assert abs(best - 7.0) < 0.01
def test_normal_equations_recovers_a_known_line_exactly_when_there_is_no_noise():
x = np.linspace(0.0, 10.0, 30)
y = 5.0 + 3.0 * x # no noise at all
intercept, slope = L.normal_equations(x, y)
assert abs(intercept - 5.0) < 1e-9
assert abs(slope - 3.0) < 1e-9
metadata.yml (6271 bytes)
lesson_id: D149
day: 149
kind: guided-build
languages:
- python
- bash
setup_commands:
- cd labs/sections/machine-learning/day-149-loss-functions-and-least-squares
- 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 -> 14 passed.
pytest starter -q -> 4 passed, 10 skipped (the four machinery checks in
test_loss_lib.py are solved in both directories; the ten exercise stubs in
starter/test_loss_claims.py are untouched). Everything ran through a real lab-local
.venv created by the documented setup commands; scikit-learn pulled in scipy 1.18.1,
joblib 1.5.3 and threadpoolctl 3.6.0 as its own dependencies, none of which this lab
imports directly. The lab is fully offline after the pip install -- every dataset is
a synthetic straight line generated on the spot from a seeded numpy.random.default_rng,
nothing is downloaded, no dataset is bundled, and harness check 9 confirms no URL
appears anywhere in starter/ or examples/ source. Section 7 of the harness copies
examples/ into a mktemp -d scratch directory, confirms 14 passed, rewrites the
exercise-4 OLS-movement assertion so its expected movement is 0.0 instead of 0.7545,
confirms a non-zero exit naming the failing test, and removes the scratch directory.
MEASURED PAIRS, all captured verbatim in expected-output/measured-values.txt. (1)
WHAT EACH LOSS MINIMISES: on values [2.0, 3.0, 5.0, 7.0, 100.0], a fine grid search
over sum((v-c)^2) lands at 23.39975 against the true mean of 23.4000, and the same
search over sum(|v-c|) lands at 5.00005 against the true median of 5.0000 -- the
single value of 100.0 drags the mean far from the other four points while the median
ignores its size entirely. (2) LOSS-LANDSCAPE SHAPE: sweeping a candidate slope from
2.0 to 4.0 on a 40-row dataset, squared error's second differences have standard
deviation 0.000000 (a parabola: smooth, one minimum) while absolute error's have
standard deviation 1.9366 (piecewise-linear, kinked at every point where a residual
crosses zero); both landscapes are minimised at the identical slope of 3.0. (3) THE
NORMAL EQUATIONS: solved directly with numpy.linalg.solve on a 300-row dataset, they
give intercept=4.966335126423676 and slope=2.977853013652841, matching scikit-learn's
LinearRegression (4.966335126423733, 2.977853013652829) to within 6e-14 on the
intercept and 1e-14 on the slope -- squared error's smoothness is what makes this
closed form possible; absolute error has no equivalent. (4) OUTLIER SENSITIVITY, the
centrepiece: on a 60-row dataset, moving ONE point 80 units off the line moves
LinearRegression's slope from 3.0465 to 3.8010 (+0.7545), HuberRegressor's from 2.9870
to 3.0308 (+0.0437), and QuantileRegressor(quantile=0.5, alpha=0, solver='highs')'s
from 2.9961 to 3.0064 (+0.0104) -- OLS moved 17.3x further than Huber and 72.5x
further than the median fit. (5) HUBER'S DELTA SWEEP on the same outlier-contaminated
data: epsilon 1.0 gives slope 3.0064, 1.35 (the scikit-learn default) gives 3.0308,
1.5 gives 3.0505, 2.0 gives 3.0906, 5.0 gives 3.1511, and both 20.0 and 100.0 give
3.8010, exactly matching plain OLS on the same data -- the sweep is monotonic and
converges exactly to least squares as epsilon grows. (6) WHAT SQUARED ERROR ASSUMES:
over 500 replications of 150-row datasets with a true slope of 3.0, Gaussian errors
give OLS mean=2.998 sd=0.0560 against Huber mean=2.9977 sd=0.0588 (ratio 0.9524,
OLS the tighter estimator), while Student's-t (df=3) heavy-tailed errors of similar
central spread give OLS mean=2.9967 sd=0.0589 against Huber mean=2.9984 sd=0.0422
(ratio 1.3957, Huber now the tighter estimator) -- both estimators stay within 0.01
of the true slope in both settings, so only the precision ranking flips, not the bias.
Harness check 8 re-confirms the outlier-sensitivity ranking at five dataset seeds, the
normal equations at three further sample sizes, and the Gauss-Markov ranking at a
shorter replication count (150), so no directional claim in this lab rests on the
single seed or replication count quoted in the lesson. TWO HONESTY CALLS. FIRST:
HuberRegressor and QuantileRegressor were both verified to exist and converge in
scikit-learn 1.9.0, without a warning, on every dataset used in this lab, before
anything was built on them -- neither was assumed from memory or from an older
version's documentation. QuantileRegressor is fit with alpha=0.0 and solver='highs'
explicitly, because its default alpha applies an L1 penalty that this lab's plain
absolute-error claim does not want mixed in, and naming the solver avoids depending
on whichever default happens to be current. SECOND: Huber's mean slope under both
error settings (2.9977 and 2.9984) sits slightly further from the true value of 3.0
than OLS's Gaussian-error mean (2.998) does, which is sampling noise in an average of
500 draws rather than evidence that Huber is biased -- harness check 8 confirms both
estimators' means stay within 0.01 of the truth at a different replication count too,
and no claim in this lab treats one estimator's mean as more "correct" than the
other's; the whole point of exercises 6 and 6b is that both are close to unbiased and
only their spread differs, and which spread is smaller depends on the errors.
requirements/README.md (2500 bytes)
# Requirements
`requirements.txt` pins the three packages this lab imports directly, at
the exact versions the captured output in `expected-output/` was produced
with:
```
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
```
Installing scikit-learn also pulls in scipy, joblib and threadpoolctl as
its own dependencies. This lab imports none of them directly and does not
pin them; the versions present during capture — scipy 1.18.1, joblib
1.5.3, threadpoolctl 3.6.0 — are recorded in `../expected-output/FIELDS.md`.
## Why the versions are pinned exactly
Every sampled figure in this lab — the outlier-shift movements, the Huber
epsilon sweep on real data, and both efficiency-under-noise comparisons —
comes from `numpy.random.default_rng`, and NumPy's own documentation is
explicit that `Generator` makes no promise of stream compatibility between
versions. A different NumPy can legitimately produce a different stream
from the same seed, and every sampled figure would move.
`HuberRegressor` and `QuantileRegressor` are also estimator internals: the
exact coefficients from an iterative solver can shift in the last few
decimal places across scikit-learn releases even with the seed held fixed,
because the solver's stopping tolerance and default number of iterations
are part of the library, not the seed.
What does not depend on the pins: the mean minimises squared error and the
median minimises absolute error, which is arithmetic; the normal equations
matching `LinearRegression` to many decimal places, which is closed-form
algebra; the constant-versus-varying second differences that distinguish a
smooth loss from a kinked one; and the direction of every comparison —
least squares moves further than Huber and further still than the median
fit when a single point becomes an outlier, and Gaussian errors favour
least squares while heavy-tailed errors favour Huber.
`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: every
dataset in this lab is generated on the spot from a seeded generator, and
nothing is downloaded.
## Free and open-source status
All three packages are free and open source — NumPy and scikit-learn under
the BSD 3-Clause licence, pytest under the MIT licence. There is no paid
tier, no account and no API key anywhere in this lab.
requirements/requirements.txt (47 bytes)
numpy==2.5.2
scikit-learn==1.9.0
pytest==9.1.1
starter/00_brief.md (4743 bytes)
# Day 149 lab brief — Loss Functions and Least Squares
A loss function is not part of the mathematics of a straight line. It is a
choice you make, and this lab measures what that choice actually decides.
## The claim you are here to measure
> Squared error and absolute error agree on the easy cases and disagree
> sharply on the hard ones, and the disagreement is exactly outlier
> sensitivity.
Exercise 1 starts with five numbers: `2.0, 3.0, 5.0, 7.0, 100.0`. A grid
search over every candidate value confirms two textbook facts as
measurements rather than assertions:
| loss | minimiser | value here |
| --- | --- | --- |
| squared error, `sum((v - c)^2)` | the **mean** | 23.4000 |
| absolute error, `sum(|v - c|)` | the **median** | 5.0000 |
The mean is dragged nearly to 23.4 by the single value of 100.0. The
median does not move — it only counts how many points sit on each side of
it, never how far away they are. That single fact is the whole reason a
squared-error fit and an absolute-error fit disagree about outliers.
## The shape of the two landscapes
Exercise 2 sweeps a candidate slope from 2.0 to 4.0 against the same
sixty-row dataset and totals both losses at each value:
| loss | second differences | what that means |
| --- | --- | --- |
| squared error | constant (sd = 0.000000) | a parabola: smooth, one minimum |
| absolute error | varies (sd = 1.9366) | piecewise-linear: kinked at every point where a residual crosses zero |
Constant curvature is why squared error has a closed-form solution.
Exercise 3 solves that closed form directly — the **normal equations** —
and confirms it matches `LinearRegression` to thirteen decimal places.
Absolute error has no equivalent closed form, because its derivative does
not exist at a residual of exactly zero; fitting it needs an iterative
solver instead.
## The centrepiece: one outlier, three losses
Exercises 4 and 4b move a single point 80 units off the line and re-fit
three estimators on identical data:
| estimator | loss it minimises | slope before | slope after | movement |
| --- | --- | --- | --- | --- |
| `LinearRegression` | squared error | 3.0465 | 3.8010 | **+0.7545** |
| `HuberRegressor` | Huber (blended) | 2.9870 | 3.0308 | +0.0437 |
| `QuantileRegressor(0.5)` | absolute error | 2.9961 | 3.0064 | +0.0104 |
One point, and least squares moves seventeen times further than Huber and
seventy-two times further than the median fit. Squaring the residual
means a residual of 80 contributes 6,400 to the total loss instead of 80
— the optimizer will trade a great deal of fit elsewhere to shrink that
one huge term, and "elsewhere" is every other point on the line.
Exercise 5 sweeps Huber's `epsilon` parameter on that same contaminated
data, from 1.0 up to 100.0. The slope climbs smoothly from
absolute-error-like behaviour toward the least-squares answer, and at
large epsilon it lands exactly on the OLS slope — Huber's blend of the two
losses is genuinely continuous, not a discrete switch.
## What squared error is betting on
Exercises 6 and 6b are the payoff. Fit OLS and Huber on 500 independently
generated datasets, twice — once with Gaussian errors, once with
heavy-tailed (Student's t, 3 degrees of freedom) errors of similar central
spread:
| errors | OLS spread (sd) | Huber spread (sd) | ratio OLS/Huber |
| --- | --- | --- | --- |
| Gaussian | 0.0560 | 0.0588 | 0.9524 — OLS tighter |
| heavy-tailed | 0.0589 | 0.0422 | 1.3957 — Huber tighter |
Both estimators stay close to unbiased in both settings. What changes is
which one is more *precise*. Gauss-Markov's theorem says ordinary least
squares is the **best linear unbiased estimator** under its assumptions —
every one of those four words is load-bearing, and "best" specifically
means lowest variance *among linear, unbiased estimators*, not lowest
variance full stop, and not under any distribution of errors whatsoever.
Change the error distribution and the ranking measurably flips.
## 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_loss_lib.py`) and ten 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 `loss_lib.py`, `test_loss_lib.py` and `test_loss_claims.py`; pytest
aborts on the module-name collision. Run them separately, always.
starter/loss_lib.py (9264 bytes)
"""Loss functions, measured: what choosing squared error over absolute error
actually decides, and what each is implicitly betting on.
A loss function is a choice, not a law of nature. This module measures four
consequences of that choice: the minimiser of squared error is the mean and
the minimiser of absolute error is the median; the squared-error landscape is
smooth with one minimum while the absolute-error landscape is piecewise
linear and kinked; the normal equations solve squared error in closed form
because that smoothness gives a zero derivative one can solve for directly,
while absolute error has no such closed form; and swapping which loss you
minimise changes how far a single outlier can move your line, and which loss
wins depends on what the errors actually look like -- Gaussian, or
heavy-tailed.
Everything here is deterministic given a seed.
"""
from __future__ import annotations
import numpy as np
from sklearn.linear_model import HuberRegressor, LinearRegression, QuantileRegressor
# --------------------------------------------------------------------------
# 1. What each loss minimises
# --------------------------------------------------------------------------
def sse(values, candidate: float) -> float:
"""Sum of squared error between a scalar candidate and every value."""
values = np.asarray(values, dtype=float)
return float(np.sum((values - candidate) ** 2))
def sae(values, candidate: float) -> float:
"""Sum of absolute error between a scalar candidate and every value."""
values = np.asarray(values, dtype=float)
return float(np.sum(np.abs(values - candidate)))
def grid_minimize(values, loss_fn, lo: float, hi: float, steps: int = 200_001) -> float:
"""Find the candidate in a fine grid that minimises the given loss.
A brute-force numerical stand-in for calculus: since it searches a
finite grid it lands within ``(hi - lo) / (steps - 1)`` of the true
minimiser, not exactly on it.
"""
grid = np.linspace(lo, hi, steps)
losses = np.array([loss_fn(values, c) for c in grid])
return float(grid[int(np.argmin(losses))])
# --------------------------------------------------------------------------
# 2. The shape of the loss landscape: smooth, or kinked
# --------------------------------------------------------------------------
def make_line_data(n: int = 150, seed: int = 0, true_intercept: float = 5.0,
true_slope: float = 3.0, noise_sd: float = 2.0,
heavy_tailed: bool = False, heavy_df: int = 3,
heavy_scale: float = 1.2):
"""A simple one-predictor dataset with a known true line.
``heavy_tailed=True`` swaps the Gaussian error for a scaled Student's t
with ``heavy_df`` degrees of freedom, which has the same rough central
spread but far fatter tails -- the construction used to measure what
squared error implicitly assumes about the errors.
"""
rng = np.random.default_rng(seed)
x = rng.uniform(0.0, 10.0, n)
if heavy_tailed:
errors = rng.standard_t(df=heavy_df, size=n) * heavy_scale
else:
errors = rng.normal(0.0, noise_sd, n)
y = true_intercept + true_slope * x + errors
return x, y
def loss_landscape(x, y, intercept: float, slopes):
"""Total squared error and total absolute error at each candidate slope,
with the intercept held fixed. Returns ``(sq_losses, abs_losses)``.
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
slopes = np.asarray(slopes, dtype=float)
sq_losses = np.empty_like(slopes)
abs_losses = np.empty_like(slopes)
for i, m in enumerate(slopes):
residual = y - (intercept + m * x)
sq_losses[i] = np.sum(residual**2)
abs_losses[i] = np.sum(np.abs(residual))
return sq_losses, abs_losses
def second_differences(values) -> np.ndarray:
"""The discrete second derivative of a sequence: ``diff(diff(values))``.
Constant second differences mean the curve is a parabola -- smooth,
with a single well-defined slope of the slope. Jumping second
differences mean the curve bends only at particular points -- a
piecewise-linear, kinked shape.
"""
return np.diff(np.asarray(values, dtype=float), n=2)
# --------------------------------------------------------------------------
# 3. The normal equations: squared error's closed form
# --------------------------------------------------------------------------
def normal_equations(x, y):
"""Solve for (intercept, slope) directly from the normal equations.
Squared error is smooth everywhere, so setting its derivative to zero
gives a linear system to solve: ``(X^T X) beta = X^T y``. Absolute
error has no derivative at a residual of zero, so no equivalent closed
form exists for it -- fitting it requires an iterative solver instead
(which is what ``QuantileRegressor`` runs).
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
design = np.column_stack([np.ones_like(x), x])
beta = np.linalg.solve(design.T @ design, design.T @ y)
return float(beta[0]), float(beta[1])
def fit_ols(x, y):
"""Fit ordinary least squares with scikit-learn; return (intercept, slope)."""
model = LinearRegression().fit(np.asarray(x, dtype=float).reshape(-1, 1), y)
return float(model.intercept_), float(model.coef_[0])
# --------------------------------------------------------------------------
# 4. Outlier sensitivity: squared error, Huber, and absolute error compared
# --------------------------------------------------------------------------
def fit_huber(x, y, epsilon: float = 1.35, max_iter: int = 500):
"""Fit scikit-learn's HuberRegressor; return (intercept, slope)."""
model = HuberRegressor(epsilon=epsilon, max_iter=max_iter).fit(
np.asarray(x, dtype=float).reshape(-1, 1), y
)
return float(model.intercept_), float(model.coef_[0])
def fit_quantile(x, y, quantile: float = 0.5):
"""Fit scikit-learn's QuantileRegressor at the median (absolute error's
minimiser); return (intercept, slope). ``alpha=0`` disables the
regulariser this estimator applies by default, so it measures plain
absolute error.
"""
model = QuantileRegressor(quantile=quantile, alpha=0.0, solver="highs").fit(
np.asarray(x, dtype=float).reshape(-1, 1), y
)
return float(model.intercept_), float(model.coef_[0])
def outlier_shift(x, y, outlier_index: int | None = None, outlier_offset: float = 80.0):
"""Fit OLS, Huber and median regression before and after moving one
point far off the line. Returns a dict with each estimator's slope
before, slope after, and how far the slope moved.
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
if outlier_index is None:
outlier_index = int(np.argmax(x))
y_outlier = y.copy()
y_outlier[outlier_index] = y_outlier[outlier_index] + outlier_offset
result = {}
for name, fitter in (("ols", fit_ols), ("huber", fit_huber), ("quantile", fit_quantile)):
_b0, before = fitter(x, y)
_a0, after = fitter(x, y_outlier)
result[name] = {
"before": round(before, 4),
"after": round(after, 4),
"movement": round(after - before, 4),
}
return result
def huber_epsilon_sweep(x, y, epsilons):
"""The Huber slope at each epsilon, holding the (outlier-contaminated)
data fixed. Small epsilon leans on the absolute-error half of the
loss and large epsilon leans on the squared-error half, converging to
plain OLS as epsilon grows without bound.
"""
rows = []
for eps in epsilons:
_intercept, slope = fit_huber(x, y, epsilon=eps)
rows.append((float(eps), round(slope, 4)))
return rows
# --------------------------------------------------------------------------
# 5. What each loss assumes: Gaussian errors, or something heavier-tailed
# --------------------------------------------------------------------------
def efficiency_under_noise(heavy_tailed: bool, replications: int = 500,
n: int = 150, true_slope: float = 3.0):
"""Fit OLS and Huber on many independent datasets with the same true
line, and report the mean and standard deviation of each estimator's
slope. Returns ``(ols_mean, ols_sd, huber_mean, huber_sd)``.
Under Gaussian errors, Gauss-Markov says OLS is the best LINEAR
UNBIASED estimator: among estimators that are linear in y and unbiased,
OLS has the smallest variance. Under heavy-tailed errors that
guarantee no longer implies OLS is the lowest-variance choice, and
this function measures whether it still is.
"""
ols_slopes = np.empty(replications)
huber_slopes = np.empty(replications)
for seed in range(replications):
x, y = make_line_data(n=n, seed=seed, true_slope=true_slope, heavy_tailed=heavy_tailed)
_i0, ols_slopes[seed] = fit_ols(x, y)
_i1, huber_slopes[seed] = fit_huber(x, y)
return (
round(float(ols_slopes.mean()), 4),
round(float(ols_slopes.std()), 4),
round(float(huber_slopes.mean()), 4),
round(float(huber_slopes.std()), 4),
)
starter/test_loss_claims.py (6362 bytes)
"""Ten exercises in what choosing a loss function actually decides.
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.
`loss_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 loss_lib as L # noqa: F401 (you will need it)
VALUES = [2.0, 3.0, 5.0, 7.0, 100.0]
def test_01_mean_minimizes_squared_error():
pytest.skip(
"Call L.grid_minimize(VALUES, L.sse, 0.0, 110.0) and assert it is "
"within 0.001 of np.mean(VALUES), which rounds to 23.4. The mean is "
"not chosen for squared error by convention -- it is what a grid "
"search over the loss actually finds."
)
def test_01b_median_minimizes_absolute_error():
pytest.skip(
"Call L.grid_minimize(VALUES, L.sae, 0.0, 110.0) and assert it is "
"within 0.001 of np.median(VALUES), which is 5.0. Then assert "
"np.mean(VALUES) is more than four times np.median(VALUES): the "
"single value of 100.0 drags the mean far from the other four "
"points, while the median only counts how many values sit on each "
"side of it and ignores their size entirely."
)
def test_02_squared_error_landscape_is_smooth_with_one_minimum():
pytest.skip(
"Build x, y = L.make_line_data(n=40, seed=3). Sweep slopes = "
"np.round(np.arange(2.0, 4.01, 0.1), 4) with the intercept fixed "
"at 5.0 using L.loss_landscape, and assert the squared-error "
"minimiser is exactly slope 3.0. Then assert "
"round(np.std(L.second_differences(sq_losses)), 6) == 0.0 -- a "
"CONSTANT second difference is the numerical signature of a "
"parabola: smooth, with one minimum."
)
def test_02b_absolute_error_landscape_is_piecewise_linear_and_kinked():
pytest.skip(
"Using the same x, y and slopes as test_02, assert the "
"absolute-error minimiser is also exactly slope 3.0, but that "
"round(np.std(L.second_differences(abs_losses)), 4) == 1.9366 -- "
"NOT constant. Absolute error only bends where a residual crosses "
"zero, so its landscape is a sequence of straight segments meeting "
"at kinks rather than one smooth curve."
)
def test_03_the_normal_equations_solve_squared_error_in_closed_form():
pytest.skip(
"Build x, y = L.make_line_data(n=300, seed=2). Solve with "
"L.normal_equations(x, y) and separately with L.fit_ols(x, y), and "
"assert the two intercepts agree to within 1e-9 and the two slopes "
"agree to within 1e-9. Then assert the normal-equations slope "
"rounds to 2.9779 and the intercept to 4.9663. Squared error is "
"smooth everywhere, so setting its derivative to zero gives a "
"linear system with an exact solution -- absolute error has no "
"equivalent closed form, because it has no derivative at a "
"residual of exactly zero."
)
def test_04_ols_moves_far_when_a_single_point_becomes_an_outlier():
pytest.skip(
"Build x, y = L.make_line_data(n=60, seed=1, noise_sd=1.5) and call "
"L.outlier_shift(x, y, outlier_offset=80.0). Assert result['ols'] "
"equals {'before': 3.0465, 'after': 3.801, 'movement': 0.7545}. "
"Moving ONE point 80 units off the line moves the least-squares "
"slope by three quarters of a unit."
)
def test_04b_huber_and_median_regression_barely_move():
pytest.skip(
"Using the same result from test_04, assert result['huber'] equals "
"{'before': 2.987, 'after': 3.0308, 'movement': 0.0437} and "
"result['quantile'] equals {'before': 2.9961, 'after': 3.0064, "
"'movement': 0.0104}. Then assert OLS's movement, divided by "
"Huber's, rounds to 17.3, and divided by the median fit's, rounds "
"to 72.5. Same data, same outlier, same single point moved -- only "
"the loss changed."
)
def test_05_hubers_delta_interpolates_between_absolute_and_squared_error():
pytest.skip(
"Reuse x, y from test_04, copy y, add 80.0 to the row at "
"np.argmax(x), and call L.huber_epsilon_sweep on the contaminated "
"data with epsilons [1.0, 1.35, 1.5, 2.0, 5.0, 20.0, 100.0]. Assert "
"the result equals [(1.0, 3.0064), (1.35, 3.0308), (1.5, 3.0505), "
"(2.0, 3.0906), (5.0, 3.1511), (20.0, 3.801), (100.0, 3.801)]. "
"Assert the slopes are non-decreasing in epsilon, and that the "
"final slope equals L.fit_ols on the same contaminated data, "
"rounded to 4 places. Small epsilon leans on absolute error; large "
"epsilon converges to plain least squares."
)
def test_06_squared_error_is_the_most_efficient_choice_under_gaussian_errors():
pytest.skip(
"Call L.efficiency_under_noise(heavy_tailed=False, "
"replications=500) and assert it equals (2.998, 0.056, 2.9977, "
"0.0588) -- (ols_mean, ols_sd, huber_mean, huber_sd). Assert both "
"means are within 0.01 of the true slope of 3.0 (both estimators "
"are roughly unbiased), then assert round(ols_sd / huber_sd, 4) == "
"0.9524 and that ols_sd < huber_sd. Under Gaussian errors, OLS has "
"the smaller spread -- this ratio being below 1 is what Gauss-"
"Markov's promise of the BEST (lowest-variance) LINEAR UNBIASED "
"ESTIMATOR looks like when you measure it."
)
def test_06b_but_not_under_heavy_tailed_errors():
pytest.skip(
"Call L.efficiency_under_noise(heavy_tailed=True, "
"replications=500) and assert it equals (2.9967, 0.0589, 2.9984, "
"0.0422). Assert both means are still within 0.01 of 3.0, then "
"assert round(ols_sd / huber_sd, 4) == 1.3957 and that huber_sd < "
"ols_sd -- the ranking has FLIPPED. Gauss-Markov's guarantee is "
"conditional on the errors; change what the errors look like and "
"the best linear unbiased estimator is no longer the most precise "
"one available."
)
starter/test_loss_lib.py (1426 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 loss_lib as L
def test_sse_and_sae_are_zero_at_a_perfect_fit_and_positive_elsewhere():
values = [1.0, 2.0, 3.0]
assert L.sse(values, 2.0) == 2.0 # (1-2)^2 + (2-2)^2 + (3-2)^2
assert L.sae(values, 2.0) == 2.0 # |1-2| + |2-2| + |3-2|
assert L.sse(values, 100.0) > 0
assert L.sae(values, 100.0) > 0
def test_make_line_data_is_deterministic_given_a_seed():
x1, y1 = L.make_line_data(n=20, seed=42)
x2, y2 = L.make_line_data(n=20, seed=42)
assert np.array_equal(x1, x2)
assert np.array_equal(y1, y2)
x3, _y3 = L.make_line_data(n=20, seed=43)
assert not np.array_equal(x1, x3)
def test_grid_minimize_finds_the_minimum_of_a_simple_quadratic():
# sse(values, c) for a single value v is (v - c)^2, minimised at c = v.
best = L.grid_minimize([7.0], L.sse, 0.0, 14.0, steps=14001)
assert abs(best - 7.0) < 0.01
def test_normal_equations_recovers_a_known_line_exactly_when_there_is_no_noise():
x = np.linspace(0.0, 10.0, 30)
y = 5.0 + 3.0 * x # no noise at all
intercept, slope = L.normal_equations(x, y)
assert abs(intercept - 5.0) < 1e-9
assert abs(slope - 3.0) < 1e-9
tests/run_tests.sh (11595 bytes)
#!/usr/bin/env bash
# Day 149 lab harness: "Loss Functions and Least Squares"
#
# Prints "N checks, M failure(s)" and exits 0 only when M is zero.
set -u
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$LAB_DIR"
PYTHON="${PYTHON:-.venv/bin/python3}"
PYTEST="${PYTEST:-.venv/bin/pytest}"
# Clear caches at the START so the final cleanliness check measures what
# THIS run left behind, not what a previous `pytest starter -q` left.
find . -path ./.venv -prune -o -type d -name '__pycache__' -exec rm -rf -- {} + 2>/dev/null
rm -rf .pytest_cache
CHECKS=0
FAILURES=0
ok() {
CHECKS=$((CHECKS + 1))
echo " ok: $1"
}
fail() {
CHECKS=$((CHECKS + 1))
FAILURES=$((FAILURES + 1))
echo " FAIL: $1"
}
if [ ! -x "$PYTHON" ]; then
echo "No lab .venv found at $PYTHON."
echo "Run: python3 -m venv .venv && .venv/bin/pip install -r requirements/requirements.txt"
exit 2
fi
echo "1. Installed versions match requirements/requirements.txt"
VERSION_CHECK=$("$PYTHON" - <<'PYEOF'
import numpy, sklearn, pytest
print("numpy", numpy.__version__)
print("scikit-learn", sklearn.__version__)
print("pytest", pytest.__version__)
PYEOF
)
echo "$VERSION_CHECK" | sed 's/^/ /'
while read -r pkg pin; do
pin_version="${pin#*==}"
installed=$(echo "$VERSION_CHECK" | awk -v p="$pkg" '$1==p {print $2}')
if [ "$installed" = "$pin_version" ]; then
ok "$pkg $installed matches the pin"
else
fail "$pkg installed=$installed pinned=$pin_version"
fi
done < <(sed 's/==/ ==/' requirements/requirements.txt)
echo ""
echo "2. Every published claim, reproduced directly (no pytest involved)"
DIRECT_CHECK=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import loss_lib as L
errors = []
VALUES = [2.0, 3.0, 5.0, 7.0, 100.0]
def expect(label, got, want):
if got != want:
errors.append(f"{label}: expected {want}, got {got}")
# 1. Mean and median
best_sq = L.grid_minimize(VALUES, L.sse, 0.0, 110.0)
best_abs = L.grid_minimize(VALUES, L.sae, 0.0, 110.0)
expect("mean", round(float(np.mean(VALUES)), 4), 23.4)
if abs(best_sq - 23.4) >= 0.001:
errors.append(f"grid argmin of squared error {best_sq} not close to the mean")
expect("median", float(np.median(VALUES)), 5.0)
if abs(best_abs - 5.0) >= 0.001:
errors.append(f"grid argmin of absolute error {best_abs} not close to the median")
# 2. Landscape shape
x, y = L.make_line_data(n=40, seed=3)
slopes = np.round(np.arange(2.0, 4.01, 0.1), 4)
sq_losses, abs_losses = L.loss_landscape(x, y, intercept=5.0, slopes=slopes)
expect("squared-error argmin slope", float(slopes[int(np.argmin(sq_losses))]), 3.0)
expect("absolute-error argmin slope", float(slopes[int(np.argmin(abs_losses))]), 3.0)
expect("sd of squared-error second differences", round(float(np.std(L.second_differences(sq_losses))), 6), 0.0)
expect("sd of absolute-error second differences", round(float(np.std(L.second_differences(abs_losses))), 4), 1.9366)
# 3. Normal equations
x3, y3 = L.make_line_data(n=300, seed=2)
intercept_eq, slope_eq = L.normal_equations(x3, y3)
intercept_sk, slope_sk = L.fit_ols(x3, y3)
if abs(intercept_eq - intercept_sk) >= 1e-9:
errors.append(f"normal-equations intercept {intercept_eq} vs sklearn {intercept_sk}")
if abs(slope_eq - slope_sk) >= 1e-9:
errors.append(f"normal-equations slope {slope_eq} vs sklearn {slope_sk}")
expect("normal-equations slope", round(slope_eq, 4), 2.9779)
expect("normal-equations intercept", round(intercept_eq, 4), 4.9663)
# 4. Outlier shift
x4, y4 = L.make_line_data(n=60, seed=1, noise_sd=1.5)
result = L.outlier_shift(x4, y4, outlier_offset=80.0)
expect("ols outlier shift", result["ols"], {"before": 3.0465, "after": 3.801, "movement": 0.7545})
expect("huber outlier shift", result["huber"], {"before": 2.987, "after": 3.0308, "movement": 0.0437})
expect("quantile outlier shift", result["quantile"], {"before": 2.9961, "after": 3.0064, "movement": 0.0104})
ols_move = abs(result["ols"]["movement"])
expect("ols/huber movement ratio", round(ols_move / abs(result["huber"]["movement"]), 1), 17.3)
expect("ols/quantile movement ratio", round(ols_move / abs(result["quantile"]["movement"]), 1), 72.5)
# 5. Huber epsilon sweep
y_outlier = np.asarray(y4, dtype=float).copy()
y_outlier[int(np.argmax(x4))] += 80.0
sweep = L.huber_epsilon_sweep(x4, y_outlier, [1.0, 1.35, 1.5, 2.0, 5.0, 20.0, 100.0])
expect(
"huber epsilon sweep",
sweep,
[(1.0, 3.0064), (1.35, 3.0308), (1.5, 3.0505), (2.0, 3.0906), (5.0, 3.1511), (20.0, 3.801), (100.0, 3.801)],
)
sweep_slopes = [s for _e, s in sweep]
if not all(a <= b for a, b in zip(sweep_slopes, sweep_slopes[1:])):
errors.append("huber epsilon sweep was not non-decreasing")
_i, ols_slope_outlier = L.fit_ols(x4, y_outlier)
expect("sweep converges to OLS", sweep[-1][1], round(ols_slope_outlier, 4))
# 6. Efficiency under noise
gauss = L.efficiency_under_noise(heavy_tailed=False, replications=500)
heavy = L.efficiency_under_noise(heavy_tailed=True, replications=500)
expect("gaussian efficiency", gauss, (2.998, 0.056, 2.9977, 0.0588))
expect("heavy-tailed efficiency", heavy, (2.9967, 0.0589, 2.9984, 0.0422))
if not (gauss[1] < gauss[3]):
errors.append("OLS was not the tighter estimator under Gaussian errors")
if not (heavy[3] < heavy[1]):
errors.append("Huber was not the tighter estimator under heavy-tailed errors")
expect("ratio under gaussian errors", round(gauss[1] / gauss[3], 4), 0.9524)
expect("ratio under heavy-tailed errors", round(heavy[1] / heavy[3], 4), 1.3957)
if errors:
for e in errors:
print("ERROR:", e)
sys.exit(1)
print("all direct checks passed")
PYEOF
)
if echo "$DIRECT_CHECK" | grep -q "all direct checks passed"; then
ok "exercises 1-6 reproduced directly against loss_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 "^14 passed"; then
ok "pytest examples -q -> 14 passed"
else
fail "pytest examples -q did not report 14 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, 10 skipped"; then
ok "pytest starter -q -> 4 passed, 10 skipped (the machinery checks pass; the ten exercises are stubs)"
else
fail "pytest starter -q did not report 4 passed, 10 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}/d149-scratch.XXXXXX")
cp examples/*.py "$SCRATCH"/
SCRATCH_OUT=$("$PYTEST" "$SCRATCH" -q 2>&1)
if echo "$SCRATCH_OUT" | tail -1 | grep -qE "^14 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_loss_claims.py" <<'PYEOF'
import sys
path = sys.argv[1]
text = open(path).read()
needle = "assert result[\"ols\"] == {\"before\": 3.0465, \"after\": 3.801, \"movement\": 0.7545}"
replacement = "assert result[\"ols\"] == {\"before\": 3.0465, \"after\": 3.801, \"movement\": 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_04_ols_moves_far_when_a_single_point_becomes_an_outlier"; then
ok "breaking exercise 4's assertion produces a non-zero exit and names the failing test"
else
fail "broken copy did not fail as expected (exit=$BROKEN_STATUS)"
fi
rm -rf "$SCRATCH"
echo ""
echo "8. The direction of every result holds beyond the quoted seeds"
DIRECTION=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import loss_lib as L
problems = []
# Outlier sensitivity is not a property of one dataset seed.
for seed in (1, 2, 3, 4, 5):
x, y = L.make_line_data(n=60, seed=seed, noise_sd=1.5)
result = L.outlier_shift(x, y, outlier_offset=80.0)
ols_move = abs(result["ols"]["movement"])
huber_move = abs(result["huber"]["movement"])
quantile_move = abs(result["quantile"]["movement"])
if not (ols_move > huber_move and ols_move > quantile_move):
problems.append(f"seed {seed}: OLS did not move furthest (ols={ols_move}, huber={huber_move}, quantile={quantile_move})")
# The normal equations match LinearRegression at other sample sizes too.
for n, seed in ((50, 10), (500, 11), (1000, 12)):
x, y = L.make_line_data(n=n, seed=seed)
i_eq, s_eq = L.normal_equations(x, y)
i_sk, s_sk = L.fit_ols(x, y)
if abs(i_eq - i_sk) >= 1e-8 or abs(s_eq - s_sk) >= 1e-8:
problems.append(f"n={n} seed={seed}: normal equations diverged from sklearn")
# Gauss-Markov's ranking is not a property of one replication count.
gauss_short = L.efficiency_under_noise(heavy_tailed=False, replications=150)
heavy_short = L.efficiency_under_noise(heavy_tailed=True, replications=150)
if not (gauss_short[1] < gauss_short[3]):
problems.append("OLS was not tighter than Huber under Gaussian errors at 150 replications")
if not (heavy_short[3] < heavy_short[1]):
problems.append("Huber was not tighter than OLS under heavy-tailed errors at 150 replications")
if problems:
for p in problems:
print("ERROR:", p)
else:
print("every direction held")
PYEOF
)
if [ "$DIRECTION" = "every direction held" ]; then
ok "outlier sensitivity, the normal equations and the Gauss-Markov ranking hold at seeds the lesson does not quote"
else
fail "a direction failed beyond the quoted seed"
echo "$DIRECTION" | sed 's/^/ /'
fi
echo ""
echo "9. Offline, and nothing left behind"
if ! grep -rInE "https?://" examples/*.py starter/*.py > /dev/null 2>&1; then
ok "no URLs inside examples/ or starter/ source -- this lab reaches no network"
else
fail "found a URL inside examples/ or starter/"
fi
if [ -z "$(find . -path ./.venv -prune -o -type d -name '__pycache__' -print 2>/dev/null)" ]; then
ok "no __pycache__ left behind"
else
find . -path ./.venv -prune -o -type d -name '__pycache__' -exec rm -rf -- {} + 2>/dev/null
ok "no __pycache__ left behind (cleaned during this run)"
fi
if [ ! -d .pytest_cache ]; then
ok "no .pytest_cache left behind"
else
rm -rf .pytest_cache
ok "no .pytest_cache left behind (cleaned during this run)"
fi
echo ""
echo "---------------------------------------------------------------"
echo "$CHECKS checks, $FAILURES failure(s)"
if [ "$FAILURES" -ne 0 ]; then
exit 1
fi
exit 0
Troubleshooting
Troubleshooting
No lab .venv found at .venv/bin/python3
The harness will not run against whatever Python is on your PATH,
because every number here is pinned to exact package versions. Build the
environment first:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
If you deliberately want a different interpreter, the harness honours
PYTHON and PYTEST:
PYTHON=/path/to/python3 PYTEST=/path/to/pytest bash tests/run_tests.sh
Expect version-check failures if those do not match the pins. That is the harness working, not the harness breaking.
import file mismatch when running pytest
You ran pytest examples starter in one invocation. Both directories
contain modules with the same names, so pytest cannot decide which
loss_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 numbers in exercises 4-6 differ slightly from the lesson's
Read expected-output/FIELDS.md first. Every figure past exercise 3 comes
from numpy.random.default_rng or from an iterative scikit-learn solver
(HuberRegressor, QuantileRegressor), and neither NumPy's random
streams nor a solver's exact stopping point are guaranteed across package
versions. What must hold on any version: OLS moves further than Huber,
which moves further than the median fit, when the same point becomes an
outlier; the Huber epsilon sweep is non-decreasing and its large-epsilon
end matches OLS; and Gaussian errors favour OLS while heavy-tailed errors
favour Huber. Harness check 8 re-confirms these directions at seeds and
replication counts the lesson does not quote.
QuantileRegressor raised an error about the solver
This lab passes solver="highs" explicitly. If you construct your own
QuantileRegressor without specifying a solver, older scikit-learn
releases default to "interior-point", which was deprecated and removed;
"highs" is the current default in 1.9.0 but naming it explicitly avoids
any ambiguity about which linear-programming backend produced a given
number.
HuberRegressor gives a ConvergenceWarning
Not observed anywhere in this lab's captured runs — every fit converges
within the default 100 iterations on every dataset used here, and the
epsilon sweep raises max_iter to 500 as a margin. If you see one on data
of your own, it usually means the epsilon is very small relative to the
residual scale, which pushes the loss toward almost-everywhere-absolute
error and makes the optimisation harder; try scaling your features or
raising max_iter.
My grid-search argmin in exercise 1 isn't exactly the mean or median
It should be close, not exact — grid_minimize searches a finite grid,
so it lands within (hi - lo) / (steps - 1) of the true minimiser. At the
default 200,001 steps over a 0-to-110 range that resolution is about
0.00055, which is why the assertions check "within 0.001" rather than
equality. The exact minimisers — 23.4 for squared error and 5.0 for
absolute error on this lab's five values — come from numpy.mean and
numpy.median directly, which are exact.
The efficiency comparison in exercises 6 and 6b takes a while
It fits 2,000 models total (500 replications, two estimators, two error settings). On the capture machine the whole harness — including this step — runs in a few seconds; on a slower machine it will take longer. No timing is asserted anywhere, so a slow machine changes nothing about whether the checks pass.
Security notes
Security notes
What this lab touches
Nothing outside its own directory, and nothing outside your machine.
- Filesystem. The lab reads only files inside its own directory. The
one write outside it is check 7 of the harness, which creates a scratch
directory with
mktemp -dunder$TMPDIR, copiesexamples/*.pyinto it, deliberately breaks one assertion to prove the harness can fail, and removes the directory again in the same run. Nothing is written to your home directory, nothing above the lab root is modified, and no system path is touched. - Network. After the one
pip install, this lab is completely offline. Check 9 asserts that no URL appears anywhere inexamples/orstarter/source. Every dataset here is generated on the spot from a seedednumpy.random.default_rng; nothing is downloaded and no dataset is bundled. (This day does not usesklearn.datasets.load_diabetesor any bundled dataset — every example here is a synthetic straight line with known truth, which is what lets the lab inject an exact outlier and measure exactly what each loss does about it.) - Credentials. There are none.
requires_api_keyisfalse, no account is needed, and nothing in this lab reads an environment variable that could hold a secret. - Privileges. Nothing here needs
sudo. If a step appears to ask for administrator rights, stop and re-read it — it is not this lab. - Reversibility. Everything this lab creates is inside its own
directory and is removed by the cleanup commands in
metadata.yml.rm -rf .venvreturns the machine to exactly its prior state.
The one install step, and how to check it
pip install -r requirements/requirements.txt downloads three packages
from the Python Package Index into a lab-local virtual environment,
never into your system Python. Pinning exact versions is a security
control as well as a reproducibility one: an unpinned install resolves to
whatever is newest at the moment you run it, which is a moving target you
have not reviewed.
If you want to verify what you are installing before you install it, pip can check hashes for you:
.venv/bin/pip install --require-hashes -r requirements/requirements.txt
That requires a hash-annotated requirements file, which this lab does not
ship because the correct hashes differ per platform wheel. Generating one
for your own platform with pip-compile --generate-hashes is a reasonable
habit for any environment you care about.
The idea in this lab worth reading as a security idea
A loss function decides, silently, how much weight one extreme data point
gets relative to everything else. Squared error gives an 80-unit residual
6,400 times the weight of a 1-unit residual; absolute error gives it only
80 times the weight. That is not only a statistics fact — it is the same
shape as a system that lets one anomalous input dominate a decision simply
because nobody chose to bound its influence. A model trained with plain
squared-error loss on data an attacker can partially influence (a
recommendation signal, a price feed, a user-submitted rating) inherits
that same unbounded sensitivity: one adversarially large value can move
the fitted line far more than its single vote should buy it. Huber and
QuantileRegressor are, among other things, ways of putting an explicit
ceiling on how much any one point can buy — worth remembering the next
time "just use least squares" is the whole plan for a pipeline that
ingests data you do not fully control.
What the code does that is worth understanding
- Every dataset generator (
make_line_data) takes a seed and returns fresh arrays. Nothing is cached to disk, nothing is memoised across runs, and no global state carries between tests. - Nothing in this lab evaluates a string, imports dynamically, reads a path from data, or inspects the environment.
- The harness captures the exit status of
run_tests.shitself and never reads the status of a pipeline.cmd | tailreportstail's status, which is almost always zero — an always-passing test suite is a security control that has quietly stopped working.
Reporting a problem
If you find something in this lab that writes outside its own directory, reaches a network it did not start, or asks for a credential, that is a bug. Nothing here is supposed to do any of those things.