Machine LearningRegression › Day 151

Hands-on lab — Day 151: Regularization: Ridge and Lasso

Commands

Setup

cd labs/sections/machine-learning/day-151-regularization-ridge-and-lasso
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/regularization_lib.py
examples/report_measurements.py
examples/test_regularization_claims.py
examples/test_regularization_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/regularization_lib.py
starter/test_regularization_claims.py
starter/test_regularization_lib.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 151 lab — What the Penalty Does

Lesson

Purpose

Day 145 already measured that a ridge penalty rescued an overfit degree-24 polynomial by a factor of 39,588. That day treated "regularization" as one thing. It is not.

This lab measures the CONTRAST between the two most common penalties — L2 (ridge) and L1 (lasso) — on the same dataset, with the same alphas, so the difference is not a claim you take on faith:

alpha lasso zeros lasso R2 ridge zeros ridge R2
0.001 0/10 0.3588 0/10 0.3586
0.01 1/10 0.3541 0/10 0.3567
0.1 3/10 0.3550 0/10 0.3690
1.0 8/10 0.2782 0/10 0.3570

Ridge zeros nothing, at any alpha tried. Lasso zeros progressively more. sklearn.datasets.load_diabetes, train_test_split(test_size=0.25, random_state=0). That contrast, measured on real data, is the spine of this lab.

Eight groups of exercises measure why, and what it costs:

  1. The headline table above.
  2. The coefficient path — the exact alpha at which each lasso coefficient hits zero, and confirmation that no ridge coefficient ever does, across a 60-point sweep.
  3. Whether lasso recovers the RIGHT features against a known sparse ground truth, and how that recovery degrades with noise.
  4. Scale-dependence: the same alpha, on the same data, in three different units, selects 10, 7 and 3 features respectively.
  5. ElasticNet, and the alpha-scale mismatch between it and plain Ridge.
  6. Correlated predictors — ridge splits the weight, lasso picks one.
  7. Ridge's closed form against lasso's iterative solve.
  8. The corner: a two-feature case small enough to see the geometry.

Learning objectives

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

  1. State, from a measurement rather than a rule of thumb, that ridge never zeros a coefficient while lasso zeros progressively more as alpha grows.
  2. Explain the L1-versus-L2 difference in terms of constraint-region geometry — a diamond has corners on the axes, a circle does not — and point to the exact alpha where a lasso coefficient reaches zero.
  3. Distinguish lasso as simultaneous shrinkage and feature selection from ridge as shrinkage alone.
  4. Measure whether lasso recovers a KNOWN set of informative features, and report honestly when it does not.
  5. Demonstrate that regularization requires scaled features, with three different feature-selection outcomes from the same alpha in three different units.
  6. Use ElasticNet as the combination of both penalties and know when it is preferable to either alone.
  7. State that Ridge and ElasticNet do NOT share an alpha scale, and correct for the difference.
  8. Predict what ridge and lasso each do to a pair of near-duplicate (highly correlated) predictors, connecting back to Day 150.
  9. State that ridge has a closed-form solution while lasso requires an iterative solve, and say why the L1 penalty forces that.

Prerequisites

  • Day 145's measurement that a penalty trades variance for bias — this lab does not re-measure that trade, it measures the shape of the penalty itself.
  • Day 148 (the one-predictor linear model), Day 149 (loss functions and the normal equations) and Day 150 (many predictors, the design matrix and multicollinearity) — this lab uses all three without re-teaching them.
  • Comfort with NumPy arrays and reading a pytest failure, and python3 3.11 or newer on your PATH.

Supported operating systems

  • macOS (Apple Silicon or Intel) — the capture machine was macOS 26.5.2 on arm64.
  • Linux (any distribution with Python 3.11+ and bash).
  • Windows via WSL2. The harness is a bash script and uses mktemp -d, find and process substitution; native PowerShell is not supported.

Hardware requirements

Any machine that can run Python. No GPU is needed or used — this machine is Apple Silicon with no CUDA GPU, and everything here is small-array NumPy and scikit-learn on the CPU. The heaviest step is a 60-point alpha sweep refitting ten small linear models; the whole harness completes in well under a minute on the capture machine. Around 400 MB of disk for the virtual environment, almost all of it scikit-learn and scipy.

Required software

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

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

Free and open-source options

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

  • NumPy and scikit-learn are BSD 3-Clause licensed.
  • pytest is MIT licensed.
  • load_diabetes ships bundled inside the scikit-learn package and is read from local disk; no dataset is downloaded, and no dataset licence beyond scikit-learn's own applies.

Ridge, Lasso, LassoCV and ElasticNet are all part of scikit-learn, along with the make_regression synthetic-data generator used for the known-sparse-ground-truth exercise.

Installation

From the repository root:

cd labs/sections/machine-learning/day-151-regularization-ridge-and-lasso
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-151-regularization-ridge-and-lasso/
├── 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
│   ├── regularization_lib.py          complete machinery — not the exercise
│   ├── test_regularization_lib.py     four machinery checks, already solved
│   └── test_regularization_claims.py  fourteen exercises, each a skip to replace
├── examples/
│   ├── regularization_lib.py          identical to the starter copy
│   ├── test_regularization_lib.py     the same four machinery checks
│   ├── test_regularization_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/regularization_lib.py and examples/regularization_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.

What the commands do

Command What it does
python3 -m venv .venv Creates a lab-local environment so nothing installs into your system Python
.venv/bin/pip install -r requirements/requirements.txt Installs the three pinned packages, plus scipy, joblib and threadpoolctl as scikit-learn's own dependencies
.venv/bin/pytest starter -q Runs your work: four machinery checks pass, fourteen exercises skip until you write them
.venv/bin/pytest examples -q Runs the reference solutions — eighteen assertions about what a penalty does
.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, directions re-confirmed at unquoted alphas and seeds, and cleanliness

Expected output

bash tests/run_tests.sh ends with:

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

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

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

Read expected-output/FIELDS.md before concluding that a mismatch on your machine is a bug. It separates what is exact everywhere — ridge never zeroing, the directions of every comparison — from what holds only under the pinned versions.

Validation steps

  1. bash tests/run_tests.sh; echo "exit=$?"14 checks, 0 failure(s) and exit=0.
  2. .venv/bin/pytest examples -q18 passed.
  3. .venv/bin/pytest starter -q4 passed, 14 skipped before you start; 18 passed when you have finished every exercise.
  4. .venv/bin/python3 examples/report_measurements.py | diff - expected-output/measured-values.txt → no output.
  5. Break one assertion in examples/test_regularization_claims.py on purpose, re-run the harness, and confirm it reports a failure 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 regularization_lib, with no pytest involved — so a broken test file cannot hide a broken library, and vice versa. 5. pytest examples -q reports 18 passed. 6. pytest starter -q reports 4 passed, 14 skipped. 7. The combined pytest examples starter invocation aborts, as documented. 8. report_measurements.py output is byte-identical to the captured table. 9-10. A scratch copy of examples/ passes, then fails with a non-zero exit and the failing test named after one assertion is deliberately rewritten. 11. Ridge's never-zeros behaviour, lasso's monotone zeroing, and the near-duplicate splitting are re-confirmed at alphas and dataset seeds the lesson never quotes, so no directional claim rests on a single value. 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, a near-duplicate split that is not an exact zero at some seeds, ElasticNet's alpha not matching Ridge's, and Lasso convergence warnings.

Security notes

See security.md. In short: no network after the install, no credentials, no sudo, no write outside this directory except a mktemp -d scratch directory the harness removes in the same run, and everything reversible with rm -rf .venv.

Extension exercises

  1. Sweep alpha finer, and plot the path. Exercise 2 samples 60 alphas. Use sklearn.linear_model.lasso_path to get the exact piecewise-linear path scikit-learn computes internally, and compare the alphas it reports crossing zero to the ones this lab measured by grid search.
  2. A three-way tie. Build three near-identical columns instead of two and measure whether lasso still picks exactly one, or splits its selection across two of the three at some alphas.
  3. LassoCV versus a hand-rolled grid search. Exercise 1b uses LassoCV. Reimplement its cross-validation loop by hand with KFold and Lasso, and confirm you recover the same alpha.
  4. RidgeCV. Do the same for ridge: does RidgeCV's chosen alpha ever zero a coefficient? It should not, by the same geometry that governs the rest of this lab — confirm it.
  5. A harder sparse-recovery case. Exercise 3 uses independent informative features. Rebuild it with make_regression(effective_rank=...) or correlated informative features, and measure whether recovery degrades even without adding noise.
  6. The geometry in three features. Exercise 8 uses two features so the constraint region is a 2-D diamond or circle. Extend it to three features (an octahedron versus a sphere) and measure how many coefficients lasso zeros as you push alpha up, compared to the two-feature case.
  7. Standardize inside a Pipeline. Exercise 4 standardizes by hand. Rebuild it with sklearn.pipeline.Pipeline([("scale", StandardScaler()), ("lasso", Lasso(...))]) and confirm the selected feature set matches.
  • Lab brief: starter/00_brief.md
  • Previous lab: ../day-150-multiple-and-polynomial-regression/
  • Next lab: ../day-152-regression-metrics/
  • 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 structural or arithmetic facts, not measurements that happened
to come out a certain way. Harness check 8 confirms the directional ones
at alphas and dataset seeds the lesson does not quote.

- **Ridge never produces an exact zero, at any alpha.** This follows from
  the L2 penalty's constraint region being smooth (a sphere in
  coefficient space) — its boundary never touches a coordinate axis except
  at a tangent point that would require infinite penalty. Confirmed across
  a 60-point sweep from alpha=0.001 to alpha=100, and re-confirmed at a
  different 25-point sweep from alpha=0.0001 to alpha=1000 in check 8.
- **Lasso's zero count never decreases as alpha grows.** The L1 penalty's
  feasible region shrinks monotonically as alpha grows, so the set of
  coefficients it can afford to keep nonzero can only shrink too.
- **Ridge and ElasticNet do not share an alpha scale**, because Ridge's
  objective sums the squared error and ElasticNet's averages it over
  `n_samples`. The correction factor is exactly `n_train`. This is
  arithmetic, not a measurement.
- **A model with `n_iter_ = None` (Ridge, by default) was solved directly;
  a model with a real `n_iter_` (Lasso, ElasticNet) was solved
  iteratively.** Structural, from how scikit-learn's estimators report
  themselves.
- **At a small enough alpha, both ridge and lasso agree with the
  unregularised (OLS) solution.** As alpha approaches zero, both penalty
  terms vanish and the optimisation problem becomes plain least squares.
  Confirmed to within 0.01 on the two-feature demo at alpha=0.001.

## Exact under these pins, and only these

Everything else is a specific coefficient, R2, alpha, or iteration count
from `Ridge`, `Lasso`, `LassoCV`, `ElasticNet`, or `LinearRegression`
fitted on `load_diabetes` or `make_regression`. Both scikit-learn's
internal coordinate-descent implementation and NumPy's linear-algebra
routines can change these values, even when the documented behaviour does
not change, between releases.

| Value | Exercise | What it is |
| --- | --- | --- |
| the four rows of the headline table | 1 | lasso and ridge zero counts and test R2 at alpha 0.001-1.0 |
| `alpha=0.07874`, `zeros=4`, `kept=['sex','bmi','bp','s1','s3','s5']` | 1b | LassoCV's own choice |
| the ten `zero_at` alphas, and that `s3` is first and `bmi` is last | 2, 2b | where each coefficient hits zero in the sweep |
| `precision=1.0`, `recall=1.0`, `n_selected=5` at alpha=1.0, noise=1.0 | 3 | sparse recovery, low noise |
| `recall=0.2` at noise=30, `recall=0.0` at noise=10, both alpha=80 | 3b | sparse recovery, too much penalty |
| mean precision `1.0` and `0.6792` over 10 seeds | 3b | sparse recovery across seeds |
| `n_kept = 10, 7, 3` for raw, standardized, sklearn's own scaling | 4 | scale-dependence |
| the seven-row ElasticNet sweep | 5 | zeros and R2 by l1_ratio |
| `[6.3098, 1.229, 22.6076]` and `[6.3097, 1.229, 22.6076]` | 5b | Ridge/ElasticNet after the n_train correction |
| `[3.048, 2.9742, 0.9944]` and `[5.0848, 0.0, 0.0]` at alpha=1.0 | 6 | ridge splits, lasso picks one |
| `[2.9724, 2.9646, 0.9664]` and `[0.0, 0.0, 0.0]` at alpha=10 | 6b | lasso zeros both, ridge still splits |
| `{0.001: 368, 0.01: 62, 0.1: 135, 1.0: 6}` | 7 | lasso iteration counts |
| `[1.9564, 1.9381]` (OLS) and the five-row corner table | 8, 8b | the two-feature demonstration |

## Sampled, and therefore soft even here

- **The near-duplicate split in exercise 6 is not guaranteed to be an
  EXACT zero at every dataset seed.** The two columns are built to
  correlate at roughly 0.9999, not to be identically collinear, so the
  precise floating-point split lasso's coordinate descent lands on can
  leave a tiny nonzero residual on the coefficient it is effectively
  dropping. Harness check 8 found `[5.0215, 0.0007]` at seed 3, rather
  than an exact `[x, 0.0]`. The lab's own claim at the quoted seed (0) IS
  an exact zero, and the harness's cross-seed check asserts the weaker
  but more honest "20x-or-greater asymmetric split" rather than an exact
  zero, because that is what holds at every seed tried.
- **The sparse-recovery numbers in exercise 3 depend on `make_regression`'s
  own seeded noise draw**, which is why exercise 3b reports a mean over
  ten seeds rather than trusting the one seed quoted in exercise 3.

## Timings

No timing is asserted anywhere in this lab. The heaviest step is the
60-point coefficient-path sweep in exercise 2, which refits twenty small
linear models sixty times over. It takes well under a second here and
will take longer elsewhere without changing a single assertion, because
every assertion is about a shape, a count, or a value — never a duration.

examples-run.txt

..................                                                       [100%]
18 passed in 0.68s

measured-values.txt

Day 151 -- regularization: ridge and lasso, measured
======================================================

1. Ridge never zeros; lasso zeros progressively more
----------------------------------------------------
   alpha   lasso-zeros   lasso-R2   ridge-zeros   ridge-R2
   0.001       0/10      0.3588        0/10      0.3586
   0.010       1/10      0.3541        0/10      0.3567
   0.100       3/10      0.3550        0/10      0.3690
   1.000       8/10      0.2782        0/10      0.3570

1b. LassoCV picks its own alpha
-------------------------------
  alpha=0.07874  zeros=4/10  test R2=0.3562
  kept: ['sex', 'bmi', 'bp', 's1', 's3', 's5']

2. The coefficient path: where each lasso coefficient hits zero
---------------------------------------------------------------
  age  zeros at alpha = 0.0126
  sex  zeros at alpha = 0.3486
  bmi  zeros at alpha = 2.4538
  bp   zeros at alpha = 1.1242
  s1   zeros at alpha = 0.1597
  s2   zeros at alpha = 0.0126
  s3   zeros at alpha = 0.0032
  s4   zeros at alpha = 0.0495
  s5   zeros at alpha = 2.0188
  s6   zeros at alpha = 0.2360
  ridge ever zero across the same 60-point sweep: False

3. Does lasso recover the RIGHT features? A known sparse truth
--------------------------------------------------------------
  alpha=1.0  noise=1.0   precision=1.0000  recall=1.0000  n_selected=5
  alpha=80.0 noise=30.0  precision=1.0000  recall=0.2000  n_selected=1
  alpha=80.0 noise=10.0  precision=0.0000  recall=0.0000  n_selected=0
  mean over 10 seeds, alpha=1.0 noise=1.0  : precision=1.0000  recall=0.9800
  mean over 10 seeds, alpha=1.0 noise=10.0 : precision=0.6792  recall=0.9800

4. Regularization requires scaled features
------------------------------------------
  raw                n_kept=10  kept=['age', 'sex', 'bmi', 'bp', 's1', 's2', 's3', 's4', 's5', 's6']
  standardized       n_kept= 7  kept=['sex', 'bmi', 'bp', 's1', 's3', 's5', 's6']
  sklearn_unit_norm  n_kept= 3  kept=['bmi', 'bp', 's5']

5. ElasticNet interpolates between ridge and lasso
--------------------------------------------------
  l1_ratio   zeros   R2
     0.0      0/10   0.0555
     0.1      0/10   0.0605
     0.3      0/10   0.0741
     0.5      0/10   0.0963
     0.7      1/10   0.1389
     0.9      0/10   0.2511
     1.0      3/10   0.3550

5b. Ridge and ElasticNet do not share an alpha scale
----------------------------------------------------
  Ridge(alpha=0.1 * n_train) coefs[:3]        : [6.3098, 1.229, 22.6076]
  ElasticNet(alpha=0.1, l1_ratio=0) coefs[:3] : [6.3097, 1.229, 22.6076]
  max abs difference after the n_train correction: 0.0000

6. Correlated predictors: ridge splits, lasso picks one
-------------------------------------------------------
  correlation between the two near-duplicate columns: 0.999918
  alpha= 0.001  ridge=[4.4375, 1.5944, 0.9971]  lasso=[6.0307, 0.0005, 0.9956]
  alpha= 0.100  ridge=[3.3161, 2.7149, 0.9972]  lasso=[5.937, 0.0004, 0.8961]
  alpha= 1.000  ridge=[3.048, 2.9742, 0.9944]  lasso=[5.0848, 0.0, 0.0]
  alpha=10.000  ridge=[2.9724, 2.9646, 0.9664]  lasso=[0.0, 0.0, 0.0]

7. Ridge has a closed form; lasso needs iterations
--------------------------------------------------
  ridge solver: auto  ridge has n_iter_: False
  lasso has n_iter_: True
  lasso alpha=0.001  n_iter_=368
  lasso alpha=0.01   n_iter_=62
  lasso alpha=0.1    n_iter_=135
  lasso alpha=1.0    n_iter_=6

8. The corner: two correlated features, small enough to see it
--------------------------------------------------------------
  OLS coefficients (no penalty): [1.9564, 1.9381]
  alpha= 0.001  ridge=[1.9564, 1.9381]  lasso=[1.9583, 1.9352]
  alpha= 0.500  ridge=[1.9558, 1.9337]  lasso=[1.9588, 1.4176]
  alpha= 1.000  ridge=[1.9551, 1.9295]  lasso=[1.9593, 0.899]
  alpha= 3.000  ridge=[1.9507, 1.9141]  lasso=[0.8919, 0.0]
  alpha= 8.000  ridge=[1.9345, 1.882]  lasso=[0.0, 0.0]

starter-run.txt

ssssssssssssss....                                                       [100%]
4 passed, 14 skipped in 0.69s

test-run.txt

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

2. Every published claim, reproduced directly (no pytest involved)
  ok: exercises 1-8 reproduced directly against regularization_lib, no pytest involved

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

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

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

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

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

8. The direction of every result holds beyond the quoted alpha or seed
  ok: ridge never-zeros, lasso monotone zeroing, and duplicate splitting hold beyond the quoted alphas and seeds

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)
exit=0

Source files

examples/regularization_lib.py (14462 bytes)
"""Ridge and lasso, measured: what the penalty does, and why the two differ
in kind rather than degree.

Day 145 already measured that a ridge penalty rescued an overfit polynomial
by a factor of 39,588, and that a penalty trades variance for bias. This
module does not re-measure that trade; it measures the CONTRAST between the
two penalty shapes: an L2 penalty that shrinks every coefficient toward zero
but never quite reaches it, and an L1 penalty that drives some coefficients
to exactly zero and leaves the rest alone.

Everything here is deterministic given a seed, and every dataset is either
scikit-learn's bundled ``load_diabetes`` or generated on the spot with
``numpy.random.default_rng`` or ``sklearn.datasets.make_regression``.
"""

from __future__ import annotations

import numpy as np

from sklearn.datasets import load_diabetes, make_regression
from sklearn.linear_model import ElasticNet, Lasso, LassoCV, Ridge
from sklearn.metrics import r2_score
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

FEATURE_NAMES = ["age", "sex", "bmi", "bp", "s1", "s2", "s3", "s4", "s5", "s6"]


# --------------------------------------------------------------------------
# 1. Ridge zeros nothing; lasso zeros progressively more
# --------------------------------------------------------------------------


def load_train_test(scaled: bool = True, seed: int = 0):
    """The diabetes dataset, split the way every exercise in this lab uses.

    ``scaled=True`` is scikit-learn's own bundled version: mean-centred and
    scaled so every column has unit L2 norm. ``scaled=False`` returns the raw
    measurement units (age in years, bmi as a ratio, bp in mm Hg, and so on).
    """
    X, y = load_diabetes(return_X_y=True, scaled=scaled)
    return train_test_split(X, y, test_size=0.25, random_state=seed)


def zero_counts_and_r2(alphas):
    """For each alpha, how many lasso coefficients are exactly zero, how
    many ridge coefficients are exactly zero, and each model's test R2.

    Returns rows of ``(alpha, lasso_zeros, lasso_r2, ridge_zeros, ridge_r2)``.
    ``Lasso`` needs ``max_iter=50000`` on this dataset -- the default 1000
    does not converge and emits a ``ConvergenceWarning``.
    """
    X_train, X_test, y_train, y_test = load_train_test()
    rows = []
    for alpha in alphas:
        lasso = Lasso(alpha=alpha, max_iter=50000).fit(X_train, y_train)
        ridge = Ridge(alpha=alpha).fit(X_train, y_train)
        lasso_zeros = int(np.sum(lasso.coef_ == 0))
        ridge_zeros = int(np.sum(ridge.coef_ == 0))
        lasso_r2 = round(r2_score(y_test, lasso.predict(X_test)), 4)
        ridge_r2 = round(r2_score(y_test, ridge.predict(X_test)), 4)
        rows.append((alpha, lasso_zeros, lasso_r2, ridge_zeros, ridge_r2))
    return rows


def lasso_cv_selection():
    """The alpha LassoCV picks by 5-fold cross-validation on the training
    split, how many coefficients it zeros, and which features it keeps.
    """
    X_train, X_test, y_train, y_test = load_train_test()
    model = LassoCV(cv=5, random_state=0, max_iter=50000).fit(X_train, y_train)
    zeros = int(np.sum(model.coef_ == 0))
    kept = [name for name, coef in zip(FEATURE_NAMES, model.coef_) if coef != 0]
    return {
        "alpha": float(model.alpha_),
        "zeros": zeros,
        "kept": kept,
        "r2": round(r2_score(y_test, model.predict(X_test)), 4),
    }


# --------------------------------------------------------------------------
# 2. The coefficient path: where each lasso coefficient hits exactly zero
# --------------------------------------------------------------------------


def coefficient_path(alphas):
    """Every coefficient, for both models, at every alpha in the sweep.

    Returns ``(lasso_path, ridge_path)``, each an array of shape
    ``(len(alphas), 10)`` in ``FEATURE_NAMES`` order, fitted on the full
    diabetes dataset (scaled) so the path is not split-dependent.
    """
    X, y = load_diabetes(return_X_y=True)
    lasso_path = np.zeros((len(alphas), X.shape[1]))
    ridge_path = np.zeros((len(alphas), X.shape[1]))
    for i, alpha in enumerate(alphas):
        lasso_path[i] = Lasso(alpha=alpha, max_iter=50000).fit(X, y).coef_
        ridge_path[i] = Ridge(alpha=alpha).fit(X, y).coef_
    return lasso_path, ridge_path


def alpha_where_each_lasso_coefficient_first_hits_zero(alphas):
    """The first alpha in the (ascending) sweep at which each lasso
    coefficient becomes exactly zero, and whether ridge ever hits zero at
    any alpha in the same sweep.

    Returns ``(zero_at, ridge_ever_zero)`` where ``zero_at`` maps each name
    in ``FEATURE_NAMES`` to the alpha value, or ``None`` if it never zeroed
    inside this sweep.
    """
    lasso_path, ridge_path = coefficient_path(alphas)
    zero_at = {name: None for name in FEATURE_NAMES}
    for i, alpha in enumerate(alphas):
        for j, name in enumerate(FEATURE_NAMES):
            if lasso_path[i, j] == 0.0 and zero_at[name] is None:
                zero_at[name] = float(alpha)
    ridge_ever_zero = bool(np.any(ridge_path == 0.0))
    return zero_at, ridge_ever_zero


# --------------------------------------------------------------------------
# 3. Does lasso pick the RIGHT features? A known sparse ground truth
# --------------------------------------------------------------------------


def sparse_recovery(alpha: float, noise: float, seed: int = 0):
    """Precision and recall of lasso's selected set against a KNOWN
    informative set, on synthetic data built with ``make_regression``.

    20 features, 5 informative, 200 rows. Returns
    ``(precision, recall, n_selected)``.
    """
    X, y, coef = make_regression(
        n_samples=200, n_features=20, n_informative=5, noise=noise, coef=True, random_state=seed
    )
    true_set = set(np.nonzero(coef)[0].tolist())
    model = Lasso(alpha=alpha, max_iter=50000).fit(X, y)
    picked = set(np.nonzero(model.coef_)[0].tolist())
    if not picked:
        return 0.0, 0.0, 0
    true_positives = len(true_set & picked)
    precision = round(true_positives / len(picked), 4)
    recall = round(true_positives / len(true_set), 4)
    return precision, recall, len(picked)


def sparse_recovery_grid(alphas, noises, seed: int = 0):
    """``sparse_recovery`` over every combination of alpha and noise.

    Returns rows of ``(alpha, noise, precision, recall, n_selected)``.
    """
    rows = []
    for noise in noises:
        for alpha in alphas:
            precision, recall, n_selected = sparse_recovery(alpha, noise, seed=seed)
            rows.append((alpha, noise, precision, recall, n_selected))
    return rows


def sparse_recovery_across_seeds(alpha: float, noise: float, seeds=range(10)):
    """``sparse_recovery`` averaged over several dataset seeds, so the
    finding does not rest on one lucky draw. Returns
    ``(mean_precision, mean_recall)``, each rounded to 4 places.
    """
    precisions = []
    recalls = []
    for seed in seeds:
        precision, recall, _n_selected = sparse_recovery(alpha, noise, seed=seed)
        precisions.append(precision)
        recalls.append(recall)
    return round(float(np.mean(precisions)), 4), round(float(np.mean(recalls)), 4)


# --------------------------------------------------------------------------
# 4. Scale-dependence: the penalty lives in whatever units the coefficients
#    happen to be in
# --------------------------------------------------------------------------


def scale_dependence(alpha: float = 1.0):
    """Lasso's selected feature set on the SAME data in three different
    units, at the SAME alpha: raw measurement units, standardised to unit
    variance, and scikit-learn's own bundled convention (unit L2 norm).

    Returns a dict with one entry per convention: ``kept`` (the feature
    names with a nonzero coefficient) and ``n_kept``.
    """
    X_raw, y = load_diabetes(return_X_y=True, scaled=False)
    X_unit_norm, _ = load_diabetes(return_X_y=True)  # sklearn's own scaled=True
    X_unit_variance = StandardScaler().fit_transform(X_raw)

    results = {}
    for label, X in (
        ("raw", X_raw),
        ("standardized", X_unit_variance),
        ("sklearn_unit_norm", X_unit_norm),
    ):
        model = Lasso(alpha=alpha, max_iter=50000).fit(X, y)
        kept = [name for name, coef in zip(FEATURE_NAMES, model.coef_) if coef != 0]
        results[label] = {"kept": kept, "n_kept": len(kept)}
    return results


# --------------------------------------------------------------------------
# 5. ElasticNet: the combination, and the alpha convention it does NOT share
#    with Ridge
# --------------------------------------------------------------------------


def elasticnet_sweep(alpha: float, l1_ratios):
    """ElasticNet's zero count and test R2 as l1_ratio moves from 0 (pure
    L2) to 1 (pure L1), at a fixed alpha, on the diabetes split.

    Returns rows of ``(l1_ratio, zeros, r2)``.
    """
    X_train, X_test, y_train, y_test = load_train_test()
    rows = []
    for l1_ratio in l1_ratios:
        model = ElasticNet(alpha=alpha, l1_ratio=l1_ratio, max_iter=50000).fit(X_train, y_train)
        zeros = int(np.sum(model.coef_ == 0))
        r2 = round(r2_score(y_test, model.predict(X_test)), 4)
        rows.append((l1_ratio, zeros, r2))
    return rows


def ridge_elasticnet_equivalence(alpha: float = 0.1):
    """Ridge and ElasticNet(l1_ratio=1) both define an L2-only penalty, but
    at DIFFERENT alpha scales: Ridge's objective sums the squared error,
    ElasticNet's averages it over n_samples. This measures the correction
    factor and confirms the two models agree once it is applied.

    Returns ``(ridge_coef_head, elasticnet_coef_head, max_abs_difference)``
    for the first three coefficients, after refitting Ridge at
    ``alpha * n_train``.
    """
    X_train, X_test, y_train, y_test = load_train_test()
    n_train = X_train.shape[0]
    ridge = Ridge(alpha=alpha * n_train).fit(X_train, y_train)
    elastic = ElasticNet(alpha=alpha, l1_ratio=0.0, max_iter=50000).fit(X_train, y_train)
    max_diff = float(np.max(np.abs(ridge.coef_ - elastic.coef_)))
    return ridge.coef_[:3].round(4).tolist(), elastic.coef_[:3].round(4).tolist(), round(max_diff, 4)


# --------------------------------------------------------------------------
# 6. Correlated predictors: ridge splits the weight, lasso picks one
# --------------------------------------------------------------------------


def near_duplicate_dataset(n: int = 300, seed: int = 0):
    """Two columns built from the same underlying signal plus tiny
    independent noise, so they are correlated at essentially 1.0, and a
    third column that is genuinely independent.
    """
    rng = np.random.default_rng(seed)
    base = rng.normal(size=n)
    x1 = base + rng.normal(scale=0.01, size=n)
    x2 = base + rng.normal(scale=0.01, size=n)
    x3 = rng.normal(size=n)
    X = np.column_stack([x1, x2, x3])
    y = 3.0 * x1 + 3.0 * x2 + 1.0 * x3 + rng.normal(scale=0.5, size=n)
    correlation = float(np.corrcoef(x1, x2)[0, 1])
    return X, y, correlation


def ridge_vs_lasso_on_duplicates(alphas):
    """Ridge and lasso coefficients on the near-duplicate dataset, at each
    alpha. Returns rows of ``(alpha, ridge_coefs, lasso_coefs)``, each a
    3-element list rounded to 4 places, in ``(x1, x2, x3)`` order.
    """
    X, y, _correlation = near_duplicate_dataset()
    rows = []
    for alpha in alphas:
        ridge = Ridge(alpha=alpha).fit(X, y)
        lasso = Lasso(alpha=alpha, max_iter=50000).fit(X, y)
        rows.append((alpha, ridge.coef_.round(4).tolist(), lasso.coef_.round(4).tolist()))
    return rows


# --------------------------------------------------------------------------
# 7. Ridge has a closed form; lasso does not
# --------------------------------------------------------------------------


def lasso_iteration_counts(alphas):
    """How many coordinate-descent iterations ``Lasso`` needed to converge,
    at each alpha, on the diabetes training split. Ridge has no equivalent
    attribute: it is solved directly by a single linear-algebra call, never
    iterated.
    """
    X_train, _X_test, y_train, _y_test = load_train_test()
    counts = {}
    for alpha in alphas:
        model = Lasso(alpha=alpha, max_iter=50000).fit(X_train, y_train)
        counts[alpha] = int(model.n_iter_)
    return counts


def ridge_has_no_iteration_count():
    """Confirm a fitted Ridge model carries no ``n_iter_`` under its default
    (closed-form) solver, unlike a fitted Lasso model.
    """
    X_train, _X_test, y_train, _y_test = load_train_test()
    ridge = Ridge(alpha=1.0).fit(X_train, y_train)
    lasso = Lasso(alpha=1.0, max_iter=50000).fit(X_train, y_train)
    return {
        "ridge_solver": ridge.solver,
        "ridge_has_n_iter": hasattr(ridge, "n_iter_") and ridge.n_iter_ is not None,
        "lasso_has_n_iter": hasattr(lasso, "n_iter_") and lasso.n_iter_ is not None,
    }


# --------------------------------------------------------------------------
# 8. The corner, in the smallest case that shows it: two correlated features
# --------------------------------------------------------------------------


def two_feature_dataset(n: int = 200, seed: int = 3):
    """Two strongly correlated features and a real, equal-weighted signal,
    small enough to draw. Returns ``(X, y, correlation)``.
    """
    rng = np.random.default_rng(seed)
    x1 = rng.normal(size=n)
    x2 = 0.9 * x1 + rng.normal(scale=0.3, size=n)
    X = np.column_stack([x1, x2])
    y = 2.0 * x1 + 2.0 * x2 + rng.normal(scale=1.0, size=n)
    correlation = float(np.corrcoef(x1, x2)[0, 1])
    return X, y, correlation


def two_feature_corner_demo(alphas):
    """Ridge and lasso coefficients on the two-feature dataset, at each
    alpha, alongside the unregularised (OLS) coefficients for reference.

    Returns ``(ols_coef, rows)`` where each row is
    ``(alpha, ridge_coefs, lasso_coefs)``, coefficients rounded to 4 places.
    """
    from sklearn.linear_model import LinearRegression

    X, y, _correlation = two_feature_dataset()
    ols = LinearRegression().fit(X, y).coef_.round(4).tolist()
    rows = []
    for alpha in alphas:
        ridge = Ridge(alpha=alpha).fit(X, y)
        lasso = Lasso(alpha=alpha, max_iter=50000).fit(X, y)
        rows.append((alpha, ridge.coef_.round(4).tolist(), lasso.coef_.round(4).tolist()))
    return ols, rows
examples/report_measurements.py (4588 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 regularization_lib as r  # noqa: E402

ALPHA_GRID = [0.001, 0.01, 0.1, 1.0]
PATH_ALPHAS = np.logspace(-3, 2, 60)


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


def main() -> None:
    print("Day 151 -- regularization: ridge and lasso, measured")
    print("=" * 54)

    rule("1. Ridge never zeros; lasso zeros progressively more")
    print("   alpha   lasso-zeros   lasso-R2   ridge-zeros   ridge-R2")
    for alpha, lz, lr2, rz, rr2 in r.zero_counts_and_r2(ALPHA_GRID):
        print(f"  {alpha:6.3f}       {lz}/10      {lr2:.4f}        {rz}/10      {rr2:.4f}")

    rule("1b. LassoCV picks its own alpha")
    cv = r.lasso_cv_selection()
    print(f"  alpha={cv['alpha']:.5f}  zeros={cv['zeros']}/10  test R2={cv['r2']:.4f}")
    print(f"  kept: {cv['kept']}")

    rule("2. The coefficient path: where each lasso coefficient hits zero")
    zero_at, ridge_ever_zero = r.alpha_where_each_lasso_coefficient_first_hits_zero(PATH_ALPHAS)
    for name in r.FEATURE_NAMES:
        print(f"  {name:4s} zeros at alpha = {zero_at[name]:.4f}")
    print(f"  ridge ever zero across the same 60-point sweep: {ridge_ever_zero}")

    rule("3. Does lasso recover the RIGHT features? A known sparse truth")
    p1, r1, n1 = r.sparse_recovery(alpha=1.0, noise=1.0, seed=0)
    print(f"  alpha=1.0  noise=1.0   precision={p1:.4f}  recall={r1:.4f}  n_selected={n1}")
    p2, r2v, n2 = r.sparse_recovery(alpha=80.0, noise=30.0, seed=0)
    print(f"  alpha=80.0 noise=30.0  precision={p2:.4f}  recall={r2v:.4f}  n_selected={n2}")
    p3, r3, n3 = r.sparse_recovery(alpha=80.0, noise=10.0, seed=0)
    print(f"  alpha=80.0 noise=10.0  precision={p3:.4f}  recall={r3:.4f}  n_selected={n3}")
    mp_low, mr_low = r.sparse_recovery_across_seeds(alpha=1.0, noise=1.0)
    mp_high, mr_high = r.sparse_recovery_across_seeds(alpha=1.0, noise=10.0)
    print(f"  mean over 10 seeds, alpha=1.0 noise=1.0  : precision={mp_low:.4f}  recall={mr_low:.4f}")
    print(f"  mean over 10 seeds, alpha=1.0 noise=10.0 : precision={mp_high:.4f}  recall={mr_high:.4f}")

    rule("4. Regularization requires scaled features")
    scale = r.scale_dependence(alpha=1.0)
    for label in ("raw", "standardized", "sklearn_unit_norm"):
        print(f"  {label:18s} n_kept={scale[label]['n_kept']:2d}  kept={scale[label]['kept']}")

    rule("5. ElasticNet interpolates between ridge and lasso")
    print("  l1_ratio   zeros   R2")
    for l1_ratio, zeros, r2v in r.elasticnet_sweep(alpha=0.1, l1_ratios=[0.0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0]):
        print(f"  {l1_ratio:6.1f}      {zeros}/10   {r2v:.4f}")

    rule("5b. Ridge and ElasticNet do not share an alpha scale")
    ridge_head, elastic_head, max_diff = r.ridge_elasticnet_equivalence(alpha=0.1)
    print(f"  Ridge(alpha=0.1 * n_train) coefs[:3]        : {ridge_head}")
    print(f"  ElasticNet(alpha=0.1, l1_ratio=0) coefs[:3] : {elastic_head}")
    print(f"  max abs difference after the n_train correction: {max_diff:.4f}")

    rule("6. Correlated predictors: ridge splits, lasso picks one")
    _X, _y, correlation = r.near_duplicate_dataset()
    print(f"  correlation between the two near-duplicate columns: {correlation:.6f}")
    for alpha, ridge_coefs, lasso_coefs in r.ridge_vs_lasso_on_duplicates([0.001, 0.1, 1.0, 10.0]):
        print(f"  alpha={alpha:6.3f}  ridge={ridge_coefs}  lasso={lasso_coefs}")

    rule("7. Ridge has a closed form; lasso needs iterations")
    info = r.ridge_has_no_iteration_count()
    print(f"  ridge solver: {info['ridge_solver']}  ridge has n_iter_: {info['ridge_has_n_iter']}")
    print(f"  lasso has n_iter_: {info['lasso_has_n_iter']}")
    counts = r.lasso_iteration_counts(ALPHA_GRID)
    for alpha, n_iter in counts.items():
        print(f"  lasso alpha={alpha:<6} n_iter_={n_iter}")

    rule("8. The corner: two correlated features, small enough to see it")
    ols, rows = r.two_feature_corner_demo([0.001, 0.5, 1.0, 3.0, 8.0])
    print(f"  OLS coefficients (no penalty): {ols}")
    for alpha, ridge_coefs, lasso_coefs in rows:
        print(f"  alpha={alpha:6.3f}  ridge={ridge_coefs}  lasso={lasso_coefs}")


if __name__ == "__main__":
    main()
examples/test_regularization_claims.py (8327 bytes)
"""Fourteen exercises in what a penalty actually does, measured.

Read `00_brief.md` first. Each function below is solved here and is a
`pytest.skip` in `starter/` naming exactly what to build and what to
assert. `regularization_lib.py` is complete -- it is the machinery, not the
exercise.

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 regularization_lib as r

ALPHA_GRID = [0.001, 0.01, 0.1, 1.0]
PATH_ALPHAS = np.logspace(-3, 2, 60)


def test_01_ridge_never_zeros_lasso_progressively_zeros():
    rows = r.zero_counts_and_r2(ALPHA_GRID)
    assert rows == [
        (0.001, 0, 0.3588, 0, 0.3586),
        (0.01, 1, 0.3541, 0, 0.3567),
        (0.1, 3, 0.355, 0, 0.369),
        (1.0, 8, 0.2782, 0, 0.357),
    ]
    ridge_zeros = [row[3] for row in rows]
    lasso_zeros = [row[1] for row in rows]
    assert ridge_zeros == [0, 0, 0, 0]
    # lasso's zero count never goes down as the penalty grows
    assert all(a <= b for a, b in zip(lasso_zeros, lasso_zeros[1:]))
    assert lasso_zeros[-1] > lasso_zeros[0]


def test_01b_lasso_cv_picks_alpha_and_six_features():
    result = r.lasso_cv_selection()
    assert round(result["alpha"], 5) == 0.07874
    assert result["zeros"] == 4
    assert result["kept"] == ["sex", "bmi", "bp", "s1", "s3", "s5"]
    assert result["r2"] == 0.3562


def test_02_the_coefficient_path_lasso_hits_exact_zeros_ridge_never_does():
    zero_at, ridge_ever_zero = r.alpha_where_each_lasso_coefficient_first_hits_zero(PATH_ALPHAS)
    # every lasso coefficient hits exactly zero somewhere in this sweep
    assert all(value is not None for value in zero_at.values())
    assert ridge_ever_zero is False


def test_02b_the_weakest_coefficients_zero_out_first():
    zero_at, _ridge_ever_zero = r.alpha_where_each_lasso_coefficient_first_hits_zero(PATH_ALPHAS)
    # s3 is the first coefficient lasso can afford to drop; bmi is the last
    weakest_first = min(zero_at, key=zero_at.get)
    strongest_last = max(zero_at, key=zero_at.get)
    assert weakest_first == "s3"
    assert strongest_last == "bmi"
    assert round(zero_at["s3"], 4) == 0.0032
    assert round(zero_at["bmi"], 4) == 2.4538


def test_03_lasso_recovers_the_right_features_at_low_noise():
    precision, recall, n_selected = r.sparse_recovery(alpha=1.0, noise=1.0, seed=0)
    assert precision == 1.0
    assert recall == 1.0
    assert n_selected == 5
    precision2, recall2, n_selected2 = r.sparse_recovery(alpha=0.1, noise=0.1, seed=0)
    assert precision2 == 1.0
    assert recall2 == 1.0
    assert n_selected2 == 5


def test_03b_recovery_degrades_with_noise_and_too_much_penalty():
    # honest failure: at high noise AND a heavy penalty, lasso can miss
    # most of the true informative set, or all of it
    precision, recall, n_selected = r.sparse_recovery(alpha=80.0, noise=30.0, seed=0)
    assert recall == 0.2
    assert n_selected == 1
    precision0, recall0, n_selected0 = r.sparse_recovery(alpha=80.0, noise=10.0, seed=0)
    assert recall0 == 0.0
    assert n_selected0 == 0
    # and this is not one unlucky seed: averaged over ten dataset seeds,
    # recovery is excellent at moderate noise and degrades at high noise
    mean_precision_low, mean_recall_low = r.sparse_recovery_across_seeds(alpha=1.0, noise=1.0)
    assert mean_precision_low == 1.0
    assert mean_recall_low == 0.98
    mean_precision_high, mean_recall_high = r.sparse_recovery_across_seeds(alpha=1.0, noise=10.0)
    assert mean_precision_high < mean_precision_low
    assert mean_precision_high == 0.6792


def test_04_regularization_requires_scaled_features():
    result = r.scale_dependence(alpha=1.0)
    assert result["raw"]["kept"] == [
        "age", "sex", "bmi", "bp", "s1", "s2", "s3", "s4", "s5", "s6",
    ]
    assert result["raw"]["n_kept"] == 10
    assert result["standardized"]["kept"] == ["sex", "bmi", "bp", "s1", "s3", "s5", "s6"]
    assert result["standardized"]["n_kept"] == 7
    assert result["sklearn_unit_norm"]["kept"] == ["bmi", "bp", "s5"]
    assert result["sklearn_unit_norm"]["n_kept"] == 3
    # three different answers, same data, same alpha -- only the units differ
    counts = {result[key]["n_kept"] for key in result}
    assert len(counts) == 3


def test_05_elasticnet_interpolates_between_ridge_and_lasso():
    rows = r.elasticnet_sweep(alpha=0.1, l1_ratios=[0.0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0])
    assert rows == [
        (0.0, 0, 0.0555),
        (0.1, 0, 0.0605),
        (0.3, 0, 0.0741),
        (0.5, 0, 0.0963),
        (0.7, 1, 0.1389),
        (0.9, 0, 0.2511),
        (1.0, 3, 0.355),
    ]
    # pure L1 (l1_ratio=1.0) is the only setting that matches plain Lasso's
    # own zero count at the same alpha
    lasso_only = r.zero_counts_and_r2([0.1])[0]
    assert rows[-1][1] == lasso_only[1]


def test_05b_ridge_and_elasticnet_do_not_share_an_alpha_scale():
    ridge_head, elastic_head, max_diff = r.ridge_elasticnet_equivalence(alpha=0.1)
    # naively comparing Ridge(alpha=0.1) to ElasticNet(alpha=0.1, l1_ratio=0)
    # would NOT agree -- Ridge sums the squared error, ElasticNet averages
    # it, so the alphas differ by a factor of n_train. Once that correction
    # is applied (Ridge fitted at alpha * n_train), the two match closely.
    assert max_diff < 0.001
    assert ridge_head == [6.3098, 1.229, 22.6076]
    assert elastic_head == [6.3097, 1.229, 22.6076]


def test_06_ridge_splits_the_weight_between_near_duplicates():
    _X, _y, correlation = r.near_duplicate_dataset()
    assert correlation > 0.999
    rows = r.ridge_vs_lasso_on_duplicates([1.0])
    _alpha, ridge_coefs, lasso_coefs = rows[0]
    ridge_x1, ridge_x2, _ridge_x3 = ridge_coefs
    lasso_x1, lasso_x2, _lasso_x3 = lasso_coefs
    # ridge splits the true combined weight of 6.0 roughly evenly
    assert abs(ridge_x1 - ridge_x2) < 0.15
    assert abs((ridge_x1 + ridge_x2) - 6.0) < 0.2
    # lasso picks one and drives the other to exactly zero
    assert lasso_x2 == 0.0
    assert lasso_x1 > 5.0


def test_06b_enough_penalty_zeros_both_duplicates_in_lasso_but_ridge_still_splits():
    rows = r.ridge_vs_lasso_on_duplicates([10.0])
    _alpha, ridge_coefs, lasso_coefs = rows[0]
    ridge_x1, ridge_x2, _ridge_x3 = ridge_coefs
    assert lasso_coefs[0] == 0.0
    assert lasso_coefs[1] == 0.0
    # ridge, at the same alpha, still has both near-duplicate coefficients
    # alive and still close to each other
    assert ridge_x1 > 2.5
    assert ridge_x2 > 2.5
    assert abs(ridge_x1 - ridge_x2) < 0.15


def test_07_ridge_has_a_closed_form_lasso_needs_iterations():
    info = r.ridge_has_no_iteration_count()
    assert info["ridge_has_n_iter"] is False
    assert info["lasso_has_n_iter"] is True
    counts = r.lasso_iteration_counts(ALPHA_GRID)
    assert counts == {0.001: 368, 0.01: 62, 0.1: 135, 1.0: 6}
    # every count is real work, and none of them hit the ceiling
    assert all(0 < n < 50000 for n in counts.values())


def test_08_the_corner_two_correlated_features_and_a_lasso_zero():
    ols, rows = r.two_feature_corner_demo([0.001, 0.5, 1.0, 3.0, 8.0])
    assert ols == [1.9564, 1.9381]
    by_alpha = {alpha: (ridge, lasso) for alpha, ridge, lasso in rows}
    # at alpha=3.0, lasso's solution has landed exactly on the axis --
    # the second coefficient is exactly zero -- while ridge's has not
    ridge_at_3, lasso_at_3 = by_alpha[3.0]
    assert lasso_at_3 == [0.8919, 0.0]
    assert ridge_at_3[0] != 0.0 and ridge_at_3[1] != 0.0
    assert abs(ridge_at_3[1]) > 1.5


def test_08b_at_a_tiny_alpha_both_models_agree_with_ols():
    ols, rows = r.two_feature_corner_demo([0.001, 8.0])
    by_alpha = {alpha: (ridge, lasso) for alpha, ridge, lasso in rows}
    ridge_tiny, lasso_tiny = by_alpha[0.001]
    for ols_coef, ridge_coef in zip(ols, ridge_tiny):
        assert abs(ols_coef - ridge_coef) < 0.01
    for ols_coef, lasso_coef in zip(ols, lasso_tiny):
        assert abs(ols_coef - lasso_coef) < 0.01
    # and at a large enough alpha, lasso has zeroed everything while
    # ridge, which never zeros, has merely shrunk
    ridge_big, lasso_big = by_alpha[8.0]
    assert lasso_big == [0.0, 0.0]
    assert ridge_big[0] != 0.0 and ridge_big[1] != 0.0
examples/test_regularization_lib.py (1973 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 regularization_lib as r


def test_the_diabetes_split_is_reproducible_and_shaped_right():
    a = r.load_train_test()
    b = r.load_train_test()
    X_train_a, X_test_a, y_train_a, y_test_a = a
    X_train_b, _X_test_b, _y_train_b, _y_test_b = b
    assert X_train_a.shape == (331, 10)
    assert X_test_a.shape == (111, 10)
    assert y_train_a.shape == (331,)
    assert y_test_a.shape == (111,)
    # same seed, same split -- every downstream measurement depends on this
    assert np.array_equal(X_train_a, X_train_b)


def test_the_near_duplicate_columns_really_are_near_duplicate():
    X, y, correlation = r.near_duplicate_dataset()
    assert X.shape == (300, 3)
    assert y.shape == (300,)
    assert correlation > 0.999
    # the third column is not part of the near-duplicate pair
    assert abs(float(np.corrcoef(X[:, 0], X[:, 2])[0, 1])) < 0.3


def test_the_synthetic_sparse_dataset_really_has_five_informative_features():
    from sklearn.datasets import make_regression

    X, y, coef = make_regression(
        n_samples=200, n_features=20, n_informative=5, noise=1.0, coef=True, random_state=0
    )
    assert X.shape == (200, 20)
    assert int(np.sum(coef != 0)) == 5


def test_ridge_solves_directly_and_lasso_iterates():
    info = r.ridge_has_no_iteration_count()
    assert info["ridge_has_n_iter"] is False
    assert info["lasso_has_n_iter"] is True
    counts = r.lasso_iteration_counts([0.001, 1.0])
    # every count is a positive number of iterations, and none of them
    # hit the ceiling -- so no ConvergenceWarning was silently swallowed
    for alpha, n_iter in counts.items():
        assert 0 < n_iter < 50000, f"alpha={alpha} iterated {n_iter} times"
metadata.yml (8380 bytes)
lesson_id: D151
day: 151
kind: guided-build
languages:
  - python
  - bash
setup_commands:
  - cd labs/sections/machine-learning/day-151-regularization-ridge-and-lasso
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - >-
    .venv/bin/python3 -c "import numpy, sklearn; print(numpy.__version__,
    sklearn.__version__)"
run_commands:
  - .venv/bin/pytest examples -q
  - .venv/bin/pytest starter -q
  - .venv/bin/python3 examples/report_measurements.py
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - >-
    find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {}
    +
  - rm -rf .pytest_cache
  - 'rm -rf .venv  # optional: removes the lab virtual environment'
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 60
last_executed: '2026-08-27'
executed_on: >-
  macOS 26.5.2 (Apple Silicon, arm64, CPU only -- no GPU is needed or used), Python
  3.14.0, numpy 2.5.2, scikit-learn 1.9.0, pytest 9.1.1, bash 3.2.57 -- bash
  tests/run_tests.sh -> 14 checks, 0 failure(s), exit 0. pytest examples -q -> 18
  passed. pytest starter -q -> 4 passed, 14 skipped (the four machinery checks in
  test_regularization_lib.py are solved in both directories; the fourteen exercise
  stubs in starter/test_regularization_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 -- load_diabetes ships bundled inside the scikit-learn package
  itself and is read from local disk, never fetched at runtime; every synthetic
  dataset is generated on the spot from a seeded numpy.random.default_rng or
  sklearn.datasets.make_regression; harness check 9 confirms no URL appears anywhere
  in starter/ or examples/ source. Section 7 of the harness copies examples/ into a
  mktemp-d scratch directory, confirms 18 passed, rewrites `assert lasso_x2 == 0.0` in
  test_06_ridge_splits_the_weight_between_near_duplicates to `assert lasso_x2 ==
  999.0`, confirms a non-zero exit naming the failing test, and removes the scratch
  directory. Separately, by hand, `assert result["raw"]["n_kept"] == 10` was changed
  to 3 in examples/test_regularization_claims.py and the whole harness re-run: it
  reported 14 checks, 2 failure(s) and exited 1 (both the pytest run and the
  pytest-free direct reproduction in section 2 caught it); the file was restored and
  the harness returned to 14 checks, 0 failure(s), exit 0. MEASURED PAIRS, all
  captured verbatim in expected-output/measured-values.txt. (1) HEADLINE, on
  load_diabetes(return_X_y=True) with train_test_split(test_size=0.25,
  random_state=0): at alpha 0.001, 0.01, 0.1 and 1.0, lasso zeros 0, 1, 3 and 8 of 10
  coefficients (test R2 0.3588, 0.3541, 0.3550, 0.2782) while ridge zeros 0 of 10 at
  every one of those alphas (test R2 0.3586, 0.3567, 0.3690, 0.3570). LassoCV(cv=5)
  picks alpha=0.07874, zeros 4/10, and keeps ['sex','bmi','bp','s1','s3','s5'] with
  test R2 0.3562. (2) THE COEFFICIENT PATH, swept over 60 log-spaced alphas from 0.001
  to 100 on the full dataset: every one of the ten lasso coefficients hits EXACTLY
  zero somewhere in the sweep -- s3 first, at alpha=0.0032, bmi last, at alpha=2.4538
  -- while not one ridge coefficient hits zero at any alpha in the same sweep. (3)
  SPARSE RECOVERY against a KNOWN ground truth (make_regression, 5 informative of 20
  features): at alpha=1.0, noise=1.0, lasso recovers precision=1.0, recall=1.0,
  selecting exactly the 5 true features. At alpha=80.0 (too much penalty) with
  noise=30.0, recall falls to 0.2 (1 of 5 recovered); at noise=10.0, recall falls to
  0.0 (the penalty zeroed everything). Averaged over 10 dataset seeds at alpha=1.0:
  mean precision 1.0 at noise=1.0, falling to mean precision 0.6792 at noise=10.0,
  both with mean recall 0.98. (4) SCALE-DEPENDENCE, THE MOST PRACTICALLY IMPORTANT
  RESULT: at the identical alpha=1.0, on the identical diabetes data, lasso in raw
  measurement units keeps all 10 of 10 features; standardised to unit variance
  (StandardScaler) it keeps 7 (['sex','bmi','bp','s1','s3','s5','s6']); in
  scikit-learn's own bundled "scaled=True" convention (unit L2 norm, not unit
  variance) it keeps 3 (['bmi','bp','s5']). Three different answers, same data, same
  alpha, same random_state -- because a lasso penalty is applied in whatever units the
  coefficients happen to be in, and even "scaled" is not one convention. (5)
  ELASTICNET at alpha=0.1: zero count and test R2 move from (0 zeros, R2=0.0555) at
  l1_ratio=0.0 up to (3 zeros, R2=0.3550) at l1_ratio=1.0, and the l1_ratio=1.0 row's
  zero count and R2 match plain Lasso(alpha=0.1) exactly. (6) RIDGE AND ELASTICNET DO
  NOT SHARE AN ALPHA SCALE: Ridge's objective sums the squared error over all rows;
  ElasticNet's averages it over n_samples. Naively comparing Ridge(alpha=0.1) to
  ElasticNet(alpha=0.1, l1_ratio=0.0) would NOT agree; fitting Ridge at
  alpha*n_train=33.1 instead matches ElasticNet(alpha=0.1, l1_ratio=0.0) to within
  0.0001 on the first three coefficients (measured max abs difference 3.8e-6). (7)
  CORRELATED PREDICTORS (two columns built from one shared signal, correlation
  0.999918): at alpha=1.0, ridge splits the true combined weight of 6.0 almost evenly
  (3.048 and 2.9742), while lasso keeps one at 5.0848 and sets the other to EXACTLY
  0.0. At alpha=10.0, lasso has zeroed BOTH
  duplicate coefficients while ridge STILL splits its two nonzero coefficients almost
  evenly (2.9724 and 2.9646) -- ridge never produces the corner, at any alpha tried.
  (8) RIDGE HAS A CLOSED FORM; LASSO DOES NOT: a fitted Ridge model carries no
  n_iter_ attribute at all (solved by one linear-algebra call, solver="auto");
  a fitted Lasso model does, and the iteration count varies with alpha: 368 at
  alpha=0.001, 62 at alpha=0.01, 135 at alpha=0.1, 6 at alpha=1.0 -- none of them
  hitting the max_iter=50000 ceiling, so no ConvergenceWarning was silently
  swallowed. (9) THE CORNER, in the smallest case that shows it (two features
  correlated at 0.9999, OLS solution [1.9564, 1.9381]): as alpha rises through 0.001,
  0.5, 1.0, 3.0 and 8.0, ridge's two coefficients shrink together and stay both
  nonzero the whole way (1.9345 and 1.8820 even at alpha=8.0); lasso's second
  coefficient lands on EXACTLY 0.0 at alpha=3.0, while its first coefficient is still
  0.8919 -- the corner of the L1 diamond sitting on an axis. FOUR HONESTY CALLS.
  FIRST: exercise 3b shows lasso can fail outright -- at high noise and a heavy
  penalty, recall drops to 0.0, meaning the penalty zeroed every informative feature.
  Regularization is not free precision; too much of it destroys the signal it was
  supposed to isolate. SECOND: the near-duplicate columns in exercise 6 are
  correlated at 0.9999, not identically collinear, so lasso's split is not guaranteed
  to be an EXACT hard zero at every dataset seed -- harness check 8 re-runs the
  construction at three more seeds and found seed 3 gives lasso coefficients
  [5.0215, 0.0007] rather than an exact [x, 0.0]; the assertion there was loosened to
  "a 20x-or-greater asymmetric split" rather than an exact zero, because that is what
  the measurement actually supports beyond the one quoted seed. THIRD: the
  scale-dependence result (exercise 4) does not merely differ in HOW MANY features
  survive -- StandardScaler's unit-variance convention and scikit-learn's own
  unit-L2-norm convention are BOTH legitimately called "scaled" and give different
  answers (7 vs 3 features kept) at the identical nominal alpha, which means alpha
  values are not portable even between two reasonable definitions of "scaled." FOURTH:
  the Ridge/ElasticNet alpha-scale mismatch (exercise 5b) is a real API gotcha, not a
  contrived one -- an engineer who naively swaps ElasticNet(l1_ratio=0) in for Ridge
  without correcting alpha by n_train will silently regularize far more aggressively
  than intended, which is exactly what the uncorrected comparison in this lab's own
  exploratory work initially produced (R2 0.0555 against Ridge's 0.3690 at a
  nominally-matched alpha=0.1).
requirements/README.md (2126 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 number in this lab comes from `sklearn.linear_model.Ridge`,
`Lasso`, `LassoCV` and `ElasticNet` fitted on `sklearn.datasets.load_diabetes`
and `sklearn.datasets.make_regression`. Coordinate-descent convergence
paths, default solver choices, and exact floating-point results can shift
between scikit-learn releases even when the documented behaviour does not.

What does not depend on the pins: the directions of every result — ridge
never zeroing a coefficient at any alpha tried, lasso zeroing progressively
more, the constraint-region geometry that makes a lasso coefficient land
exactly on zero, the alpha-scale mismatch between `Ridge` and `ElasticNet`,
and the fact that a penalty applied to unscaled features selects a
different set than the same penalty applied to scaled ones.
`expected-output/FIELDS.md` separates the two categories in full.

## Installing

From the lab directory:

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

The install step needs the network. Everything after it is offline: the
only dataset downloaded from anywhere is `load_diabetes`, which ships
bundled inside the scikit-learn package itself and is read from local
disk, never fetched over the network at runtime. The synthetic datasets
are generated on the spot with a seeded generator.

## Free and open-source status

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

Day 145 already measured that a ridge penalty rescued an overfit
degree-24 polynomial by a factor of 39,588, and that a penalty raises
training error while lowering the variance of what the model learns.

That day treated "regularization" as one thing. It is not. This lab
measures the contrast between the two most common penalties — L2 (ridge)
and L1 (lasso) — on the same dataset, with the same alphas, so the
difference is not a claim you take on faith.

## The claim you are here to measure

> Ridge shrinks. Lasso shrinks and selects. The difference is not a
> matter of degree — it is a difference in shape, and the shape has a
> geometric reason.

Exercise 1 puts the two side by side on `sklearn.datasets.load_diabetes`,
sweeping alpha from 0.001 to 1.0:

| alpha | lasso zeros | lasso R2 | ridge zeros | ridge R2 |
| --- | --- | --- | --- | --- |
| 0.001 | 0/10 | 0.3588 | 0/10 | 0.3586 |
| 0.01 | 1/10 | 0.3541 | 0/10 | 0.3567 |
| 0.1 | 3/10 | 0.3550 | 0/10 | 0.3690 |
| 1.0 | 8/10 | 0.2782 | 0/10 | 0.3570 |

Read the ridge column first. **It never moves.** Zero coefficients at
every alpha tried, from barely-there to aggressive. Ridge shrinks
everything toward zero and never quite arrives.

Now read the lasso column. It climbs steadily to 8 of 10 coefficients
zeroed. Lasso is doing feature selection, not just shrinkage — and it is
doing it as a side effect of the penalty shape, not because anyone told
it which features matter.

## Why: the geometry, made numeric

Exercise 8 builds the smallest case that shows the mechanism: two
strongly correlated features (correlation 0.9999) and an ordinary
least-squares solution of `[1.9564, 1.9381]`. As alpha grows:

| alpha | ridge | lasso |
| --- | --- | --- |
| 0.001 | `[1.9564, 1.9381]` | `[1.9583, 1.9352]` |
| 3.0 | `[1.9507, 1.9141]` | `[0.8919, 0.0]` |
| 8.0 | `[1.9345, 1.882]` | `[0.0, 0.0]` |

At alpha=3.0, lasso's second coefficient is **exactly** 0.0 — not small,
not rounded, exactly zero — while ridge's is still 1.91. That is the
corner of the L1 constraint region landing on an axis. A diamond has
corners on its axes; a circle (ridge's constraint region) does not
touch an axis except at a single tangent point that requires infinite
penalty to reach. Sections "How it works" and the architecture diagram
in the lesson walk through why the shape of the constraint region
forces this.

## What that buys you, and what it costs you

**Exercise 3** confirms lasso does not merely shrink toward *some*
sparse answer — on `make_regression` with a known set of 5 informative
features out of 20, it recovers precisely that set (precision 1.0,
recall 1.0) at a sensible alpha. **Exercise 3b** is the honest half:
push the alpha too high on noisy data and lasso can zero out the truth
entirely (recall drops to 0.0). More penalty is not free precision.

**Exercise 4** is the one with the most practical consequence. The same
alpha, on the same data, in three different units — raw measurement
units, standardized to unit variance, and scikit-learn's own bundled
convention (unit L2 norm) — selects **10, 7, and 3 features
respectively**. The penalty lives in whatever units the coefficients
happen to be in. Skip the scaling step and you have not actually
regularized anything meaningful; you have penalized whichever feature
happened to have small natural units.

**Exercise 6** connects straight back to Day 150's multicollinearity: on
two near-duplicate columns, ridge splits the combined weight roughly
evenly between them (each gets about half); lasso picks one and zeros
the other, arbitrarily as far as the data is concerned.

**Exercise 7** is the practical cost of the shape: ridge is one
linear-algebra call (no `n_iter_` attribute at all); lasso is solved
iteratively — coordinate descent — because its penalty is not
differentiable at zero and no closed form exists.

## How to work

1. Build the environment (see the lab `README.md`).
2. Run `.venv/bin/pytest starter -q`. You will see four passes (the
   machinery checks in `test_regularization_lib.py`) and fourteen skips.
3. Replace one `pytest.skip(...)` at a time with real code. The skip
   text names the exact helper and the exact value to assert.
4. Print every measured pair. 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 `regularization_lib.py`, `test_regularization_lib.py` and
`test_regularization_claims.py`; pytest aborts on the module-name
collision. Run them separately, always.

## The scope of this lab

This lab does not re-teach that regularization trades variance for
bias — Day 145 measured that. It does not build linear regression from
scratch — Day 148 and Day 149 own that. It does not cover which metric
to report — Day 152 owns that. It measures exactly one thing in depth:
what the L1 and L2 penalty shapes do differently, and why.
starter/regularization_lib.py (14462 bytes)
"""Ridge and lasso, measured: what the penalty does, and why the two differ
in kind rather than degree.

Day 145 already measured that a ridge penalty rescued an overfit polynomial
by a factor of 39,588, and that a penalty trades variance for bias. This
module does not re-measure that trade; it measures the CONTRAST between the
two penalty shapes: an L2 penalty that shrinks every coefficient toward zero
but never quite reaches it, and an L1 penalty that drives some coefficients
to exactly zero and leaves the rest alone.

Everything here is deterministic given a seed, and every dataset is either
scikit-learn's bundled ``load_diabetes`` or generated on the spot with
``numpy.random.default_rng`` or ``sklearn.datasets.make_regression``.
"""

from __future__ import annotations

import numpy as np

from sklearn.datasets import load_diabetes, make_regression
from sklearn.linear_model import ElasticNet, Lasso, LassoCV, Ridge
from sklearn.metrics import r2_score
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

FEATURE_NAMES = ["age", "sex", "bmi", "bp", "s1", "s2", "s3", "s4", "s5", "s6"]


# --------------------------------------------------------------------------
# 1. Ridge zeros nothing; lasso zeros progressively more
# --------------------------------------------------------------------------


def load_train_test(scaled: bool = True, seed: int = 0):
    """The diabetes dataset, split the way every exercise in this lab uses.

    ``scaled=True`` is scikit-learn's own bundled version: mean-centred and
    scaled so every column has unit L2 norm. ``scaled=False`` returns the raw
    measurement units (age in years, bmi as a ratio, bp in mm Hg, and so on).
    """
    X, y = load_diabetes(return_X_y=True, scaled=scaled)
    return train_test_split(X, y, test_size=0.25, random_state=seed)


def zero_counts_and_r2(alphas):
    """For each alpha, how many lasso coefficients are exactly zero, how
    many ridge coefficients are exactly zero, and each model's test R2.

    Returns rows of ``(alpha, lasso_zeros, lasso_r2, ridge_zeros, ridge_r2)``.
    ``Lasso`` needs ``max_iter=50000`` on this dataset -- the default 1000
    does not converge and emits a ``ConvergenceWarning``.
    """
    X_train, X_test, y_train, y_test = load_train_test()
    rows = []
    for alpha in alphas:
        lasso = Lasso(alpha=alpha, max_iter=50000).fit(X_train, y_train)
        ridge = Ridge(alpha=alpha).fit(X_train, y_train)
        lasso_zeros = int(np.sum(lasso.coef_ == 0))
        ridge_zeros = int(np.sum(ridge.coef_ == 0))
        lasso_r2 = round(r2_score(y_test, lasso.predict(X_test)), 4)
        ridge_r2 = round(r2_score(y_test, ridge.predict(X_test)), 4)
        rows.append((alpha, lasso_zeros, lasso_r2, ridge_zeros, ridge_r2))
    return rows


def lasso_cv_selection():
    """The alpha LassoCV picks by 5-fold cross-validation on the training
    split, how many coefficients it zeros, and which features it keeps.
    """
    X_train, X_test, y_train, y_test = load_train_test()
    model = LassoCV(cv=5, random_state=0, max_iter=50000).fit(X_train, y_train)
    zeros = int(np.sum(model.coef_ == 0))
    kept = [name for name, coef in zip(FEATURE_NAMES, model.coef_) if coef != 0]
    return {
        "alpha": float(model.alpha_),
        "zeros": zeros,
        "kept": kept,
        "r2": round(r2_score(y_test, model.predict(X_test)), 4),
    }


# --------------------------------------------------------------------------
# 2. The coefficient path: where each lasso coefficient hits exactly zero
# --------------------------------------------------------------------------


def coefficient_path(alphas):
    """Every coefficient, for both models, at every alpha in the sweep.

    Returns ``(lasso_path, ridge_path)``, each an array of shape
    ``(len(alphas), 10)`` in ``FEATURE_NAMES`` order, fitted on the full
    diabetes dataset (scaled) so the path is not split-dependent.
    """
    X, y = load_diabetes(return_X_y=True)
    lasso_path = np.zeros((len(alphas), X.shape[1]))
    ridge_path = np.zeros((len(alphas), X.shape[1]))
    for i, alpha in enumerate(alphas):
        lasso_path[i] = Lasso(alpha=alpha, max_iter=50000).fit(X, y).coef_
        ridge_path[i] = Ridge(alpha=alpha).fit(X, y).coef_
    return lasso_path, ridge_path


def alpha_where_each_lasso_coefficient_first_hits_zero(alphas):
    """The first alpha in the (ascending) sweep at which each lasso
    coefficient becomes exactly zero, and whether ridge ever hits zero at
    any alpha in the same sweep.

    Returns ``(zero_at, ridge_ever_zero)`` where ``zero_at`` maps each name
    in ``FEATURE_NAMES`` to the alpha value, or ``None`` if it never zeroed
    inside this sweep.
    """
    lasso_path, ridge_path = coefficient_path(alphas)
    zero_at = {name: None for name in FEATURE_NAMES}
    for i, alpha in enumerate(alphas):
        for j, name in enumerate(FEATURE_NAMES):
            if lasso_path[i, j] == 0.0 and zero_at[name] is None:
                zero_at[name] = float(alpha)
    ridge_ever_zero = bool(np.any(ridge_path == 0.0))
    return zero_at, ridge_ever_zero


# --------------------------------------------------------------------------
# 3. Does lasso pick the RIGHT features? A known sparse ground truth
# --------------------------------------------------------------------------


def sparse_recovery(alpha: float, noise: float, seed: int = 0):
    """Precision and recall of lasso's selected set against a KNOWN
    informative set, on synthetic data built with ``make_regression``.

    20 features, 5 informative, 200 rows. Returns
    ``(precision, recall, n_selected)``.
    """
    X, y, coef = make_regression(
        n_samples=200, n_features=20, n_informative=5, noise=noise, coef=True, random_state=seed
    )
    true_set = set(np.nonzero(coef)[0].tolist())
    model = Lasso(alpha=alpha, max_iter=50000).fit(X, y)
    picked = set(np.nonzero(model.coef_)[0].tolist())
    if not picked:
        return 0.0, 0.0, 0
    true_positives = len(true_set & picked)
    precision = round(true_positives / len(picked), 4)
    recall = round(true_positives / len(true_set), 4)
    return precision, recall, len(picked)


def sparse_recovery_grid(alphas, noises, seed: int = 0):
    """``sparse_recovery`` over every combination of alpha and noise.

    Returns rows of ``(alpha, noise, precision, recall, n_selected)``.
    """
    rows = []
    for noise in noises:
        for alpha in alphas:
            precision, recall, n_selected = sparse_recovery(alpha, noise, seed=seed)
            rows.append((alpha, noise, precision, recall, n_selected))
    return rows


def sparse_recovery_across_seeds(alpha: float, noise: float, seeds=range(10)):
    """``sparse_recovery`` averaged over several dataset seeds, so the
    finding does not rest on one lucky draw. Returns
    ``(mean_precision, mean_recall)``, each rounded to 4 places.
    """
    precisions = []
    recalls = []
    for seed in seeds:
        precision, recall, _n_selected = sparse_recovery(alpha, noise, seed=seed)
        precisions.append(precision)
        recalls.append(recall)
    return round(float(np.mean(precisions)), 4), round(float(np.mean(recalls)), 4)


# --------------------------------------------------------------------------
# 4. Scale-dependence: the penalty lives in whatever units the coefficients
#    happen to be in
# --------------------------------------------------------------------------


def scale_dependence(alpha: float = 1.0):
    """Lasso's selected feature set on the SAME data in three different
    units, at the SAME alpha: raw measurement units, standardised to unit
    variance, and scikit-learn's own bundled convention (unit L2 norm).

    Returns a dict with one entry per convention: ``kept`` (the feature
    names with a nonzero coefficient) and ``n_kept``.
    """
    X_raw, y = load_diabetes(return_X_y=True, scaled=False)
    X_unit_norm, _ = load_diabetes(return_X_y=True)  # sklearn's own scaled=True
    X_unit_variance = StandardScaler().fit_transform(X_raw)

    results = {}
    for label, X in (
        ("raw", X_raw),
        ("standardized", X_unit_variance),
        ("sklearn_unit_norm", X_unit_norm),
    ):
        model = Lasso(alpha=alpha, max_iter=50000).fit(X, y)
        kept = [name for name, coef in zip(FEATURE_NAMES, model.coef_) if coef != 0]
        results[label] = {"kept": kept, "n_kept": len(kept)}
    return results


# --------------------------------------------------------------------------
# 5. ElasticNet: the combination, and the alpha convention it does NOT share
#    with Ridge
# --------------------------------------------------------------------------


def elasticnet_sweep(alpha: float, l1_ratios):
    """ElasticNet's zero count and test R2 as l1_ratio moves from 0 (pure
    L2) to 1 (pure L1), at a fixed alpha, on the diabetes split.

    Returns rows of ``(l1_ratio, zeros, r2)``.
    """
    X_train, X_test, y_train, y_test = load_train_test()
    rows = []
    for l1_ratio in l1_ratios:
        model = ElasticNet(alpha=alpha, l1_ratio=l1_ratio, max_iter=50000).fit(X_train, y_train)
        zeros = int(np.sum(model.coef_ == 0))
        r2 = round(r2_score(y_test, model.predict(X_test)), 4)
        rows.append((l1_ratio, zeros, r2))
    return rows


def ridge_elasticnet_equivalence(alpha: float = 0.1):
    """Ridge and ElasticNet(l1_ratio=1) both define an L2-only penalty, but
    at DIFFERENT alpha scales: Ridge's objective sums the squared error,
    ElasticNet's averages it over n_samples. This measures the correction
    factor and confirms the two models agree once it is applied.

    Returns ``(ridge_coef_head, elasticnet_coef_head, max_abs_difference)``
    for the first three coefficients, after refitting Ridge at
    ``alpha * n_train``.
    """
    X_train, X_test, y_train, y_test = load_train_test()
    n_train = X_train.shape[0]
    ridge = Ridge(alpha=alpha * n_train).fit(X_train, y_train)
    elastic = ElasticNet(alpha=alpha, l1_ratio=0.0, max_iter=50000).fit(X_train, y_train)
    max_diff = float(np.max(np.abs(ridge.coef_ - elastic.coef_)))
    return ridge.coef_[:3].round(4).tolist(), elastic.coef_[:3].round(4).tolist(), round(max_diff, 4)


# --------------------------------------------------------------------------
# 6. Correlated predictors: ridge splits the weight, lasso picks one
# --------------------------------------------------------------------------


def near_duplicate_dataset(n: int = 300, seed: int = 0):
    """Two columns built from the same underlying signal plus tiny
    independent noise, so they are correlated at essentially 1.0, and a
    third column that is genuinely independent.
    """
    rng = np.random.default_rng(seed)
    base = rng.normal(size=n)
    x1 = base + rng.normal(scale=0.01, size=n)
    x2 = base + rng.normal(scale=0.01, size=n)
    x3 = rng.normal(size=n)
    X = np.column_stack([x1, x2, x3])
    y = 3.0 * x1 + 3.0 * x2 + 1.0 * x3 + rng.normal(scale=0.5, size=n)
    correlation = float(np.corrcoef(x1, x2)[0, 1])
    return X, y, correlation


def ridge_vs_lasso_on_duplicates(alphas):
    """Ridge and lasso coefficients on the near-duplicate dataset, at each
    alpha. Returns rows of ``(alpha, ridge_coefs, lasso_coefs)``, each a
    3-element list rounded to 4 places, in ``(x1, x2, x3)`` order.
    """
    X, y, _correlation = near_duplicate_dataset()
    rows = []
    for alpha in alphas:
        ridge = Ridge(alpha=alpha).fit(X, y)
        lasso = Lasso(alpha=alpha, max_iter=50000).fit(X, y)
        rows.append((alpha, ridge.coef_.round(4).tolist(), lasso.coef_.round(4).tolist()))
    return rows


# --------------------------------------------------------------------------
# 7. Ridge has a closed form; lasso does not
# --------------------------------------------------------------------------


def lasso_iteration_counts(alphas):
    """How many coordinate-descent iterations ``Lasso`` needed to converge,
    at each alpha, on the diabetes training split. Ridge has no equivalent
    attribute: it is solved directly by a single linear-algebra call, never
    iterated.
    """
    X_train, _X_test, y_train, _y_test = load_train_test()
    counts = {}
    for alpha in alphas:
        model = Lasso(alpha=alpha, max_iter=50000).fit(X_train, y_train)
        counts[alpha] = int(model.n_iter_)
    return counts


def ridge_has_no_iteration_count():
    """Confirm a fitted Ridge model carries no ``n_iter_`` under its default
    (closed-form) solver, unlike a fitted Lasso model.
    """
    X_train, _X_test, y_train, _y_test = load_train_test()
    ridge = Ridge(alpha=1.0).fit(X_train, y_train)
    lasso = Lasso(alpha=1.0, max_iter=50000).fit(X_train, y_train)
    return {
        "ridge_solver": ridge.solver,
        "ridge_has_n_iter": hasattr(ridge, "n_iter_") and ridge.n_iter_ is not None,
        "lasso_has_n_iter": hasattr(lasso, "n_iter_") and lasso.n_iter_ is not None,
    }


# --------------------------------------------------------------------------
# 8. The corner, in the smallest case that shows it: two correlated features
# --------------------------------------------------------------------------


def two_feature_dataset(n: int = 200, seed: int = 3):
    """Two strongly correlated features and a real, equal-weighted signal,
    small enough to draw. Returns ``(X, y, correlation)``.
    """
    rng = np.random.default_rng(seed)
    x1 = rng.normal(size=n)
    x2 = 0.9 * x1 + rng.normal(scale=0.3, size=n)
    X = np.column_stack([x1, x2])
    y = 2.0 * x1 + 2.0 * x2 + rng.normal(scale=1.0, size=n)
    correlation = float(np.corrcoef(x1, x2)[0, 1])
    return X, y, correlation


def two_feature_corner_demo(alphas):
    """Ridge and lasso coefficients on the two-feature dataset, at each
    alpha, alongside the unregularised (OLS) coefficients for reference.

    Returns ``(ols_coef, rows)`` where each row is
    ``(alpha, ridge_coefs, lasso_coefs)``, coefficients rounded to 4 places.
    """
    from sklearn.linear_model import LinearRegression

    X, y, _correlation = two_feature_dataset()
    ols = LinearRegression().fit(X, y).coef_.round(4).tolist()
    rows = []
    for alpha in alphas:
        ridge = Ridge(alpha=alpha).fit(X, y)
        lasso = Lasso(alpha=alpha, max_iter=50000).fit(X, y)
        rows.append((alpha, ridge.coef_.round(4).tolist(), lasso.coef_.round(4).tolist()))
    return ols, rows
starter/test_regularization_claims.py (7509 bytes)
"""Fourteen exercises in what a penalty actually does.

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.
`regularization_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 regularization_lib as r  # noqa: F401  (you will need it)

ALPHA_GRID = [0.001, 0.01, 0.1, 1.0]
PATH_ALPHAS = np.logspace(-3, 2, 60)


def test_01_ridge_never_zeros_lasso_progressively_zeros():
    pytest.skip(
        "Call r.zero_counts_and_r2(ALPHA_GRID) and assert it equals the four "
        "rows in expected-output/measured-values.txt, from "
        "(0.001, 0, 0.3588, 0, 0.3586) to (1.0, 8, 0.2782, 0, 0.357). Then "
        "assert the ridge-zeros column is [0, 0, 0, 0] and the lasso-zeros "
        "column never decreases as alpha grows. Ridge shrinks; lasso "
        "shrinks and selects."
    )


def test_01b_lasso_cv_picks_alpha_and_six_features():
    pytest.skip(
        "Call r.lasso_cv_selection() and assert alpha rounds to 0.07874, "
        "zeros == 4, kept == ['sex', 'bmi', 'bp', 's1', 's3', 's5'], and "
        "r2 == 0.3562. Cross-validation, not eyeballing a curve, is how "
        "alpha actually gets chosen in practice."
    )


def test_02_the_coefficient_path_lasso_hits_exact_zeros_ridge_never_does():
    pytest.skip(
        "Call r.alpha_where_each_lasso_coefficient_first_hits_zero(PATH_ALPHAS), "
        "which returns (zero_at, ridge_ever_zero). Assert every one of the "
        "ten lasso coefficients has a non-None zero_at value, and assert "
        "ridge_ever_zero is False across the same 60-point sweep. A circle "
        "has no corners; a diamond has one on every axis."
    )


def test_02b_the_weakest_coefficients_zero_out_first():
    pytest.skip(
        "From the same zero_at dict, assert the feature with the smallest "
        "zero_at value is 's3' (it rounds to alpha=0.0032) and the feature "
        "with the largest is 'bmi' (alpha=2.4538). The coefficients lasso "
        "can least afford to shrink are the ones it keeps longest."
    )


def test_03_lasso_recovers_the_right_features_at_low_noise():
    pytest.skip(
        "Call r.sparse_recovery(alpha=1.0, noise=1.0, seed=0) against a "
        "known sparse ground truth (5 informative of 20 features) and "
        "assert precision == 1.0, recall == 1.0, n_selected == 5. Repeat at "
        "alpha=0.1, noise=0.1 and assert the same three values. At low "
        "noise, lasso finds exactly the right features -- not merely some "
        "features that predict well."
    )


def test_03b_recovery_degrades_with_noise_and_too_much_penalty():
    pytest.skip(
        "Call r.sparse_recovery(alpha=80.0, noise=30.0, seed=0) and assert "
        "recall == 0.2, n_selected == 1. Call it again at noise=10.0 and "
        "assert recall == 0.0, n_selected == 0 -- too much penalty on noisy "
        "data can zero out the truth entirely. Then call "
        "r.sparse_recovery_across_seeds at (alpha=1.0, noise=1.0) and at "
        "(alpha=1.0, noise=10.0), and assert the second call's mean "
        "precision (0.6792) is lower than the first's (1.0). This is not "
        "one unlucky seed."
    )


def test_04_regularization_requires_scaled_features():
    pytest.skip(
        "Call r.scale_dependence(alpha=1.0). Assert the 'raw' result keeps "
        "all 10 features, 'standardized' keeps 7 "
        "(['sex','bmi','bp','s1','s3','s5','s6']), and 'sklearn_unit_norm' "
        "keeps 3 (['bmi','bp','s5']) -- three different answers, same data, "
        "same alpha. The penalty is applied in whatever units the "
        "coefficients happen to be in, and 'scaled' is not even one "
        "convention: unit-variance and unit-norm disagree too."
    )


def test_05_elasticnet_interpolates_between_ridge_and_lasso():
    pytest.skip(
        "Call r.elasticnet_sweep(alpha=0.1, l1_ratios=[0.0, 0.1, 0.3, 0.5, "
        "0.7, 0.9, 1.0]) and assert it equals the captured rows, ending "
        "(1.0, 3, 0.355). Then assert that row's zero count matches plain "
        "Lasso's own zero count at the same alpha "
        "(r.zero_counts_and_r2([0.1])[0]). l1_ratio=1.0 is not an "
        "approximation to lasso; it is lasso."
    )


def test_05b_ridge_and_elasticnet_do_not_share_an_alpha_scale():
    pytest.skip(
        "Call r.ridge_elasticnet_equivalence(alpha=0.1), which fits "
        "Ridge at alpha * n_train against ElasticNet(alpha=0.1, "
        "l1_ratio=0.0) directly. Assert max_diff < 0.001. Ridge's objective "
        "sums the squared error; ElasticNet's averages it over n_samples -- "
        "so the same alpha means two different penalty strengths until you "
        "correct for it."
    )


def test_06_ridge_splits_the_weight_between_near_duplicates():
    pytest.skip(
        "Get correlation from r.near_duplicate_dataset() and assert it "
        "exceeds 0.999. Call r.ridge_vs_lasso_on_duplicates([1.0]) and, "
        "from the one row, assert the two ridge coefficients differ by "
        "less than 0.15 and sum to within 0.2 of 6.0 -- ridge splits the "
        "true combined weight roughly evenly. Assert lasso's second "
        "coefficient is exactly 0.0 and its first exceeds 5.0 -- lasso "
        "picks one and drops the other."
    )


def test_06b_enough_penalty_zeros_both_duplicates_in_lasso_but_ridge_still_splits():
    pytest.skip(
        "Call r.ridge_vs_lasso_on_duplicates([10.0]). Assert both lasso "
        "coefficients are exactly 0.0. Assert both ridge coefficients "
        "still exceed 2.5 and still differ from each other by less than "
        "0.15. Ridge never produces the corner; more penalty just shrinks "
        "both halves together."
    )


def test_07_ridge_has_a_closed_form_lasso_needs_iterations():
    pytest.skip(
        "Call r.ridge_has_no_iteration_count() and assert "
        "ridge_has_n_iter is False and lasso_has_n_iter is True. Call "
        "r.lasso_iteration_counts(ALPHA_GRID) and assert it equals "
        "{0.001: 368, 0.01: 62, 0.1: 135, 1.0: 6}. Ridge is one "
        "linear-algebra call; lasso is solved iteratively because its "
        "penalty is not differentiable at zero."
    )


def test_08_the_corner_two_correlated_features_and_a_lasso_zero():
    pytest.skip(
        "Call r.two_feature_corner_demo([0.001, 0.5, 1.0, 3.0, 8.0]). At "
        "alpha=3.0, assert lasso's coefficients equal [0.8919, 0.0] exactly "
        "-- it has landed on the axis -- while ridge's second coefficient "
        "still exceeds 1.5 in magnitude. This is the geometry: a diamond's "
        "corner sits on an axis; a circle's does not."
    )


def test_08b_at_a_tiny_alpha_both_models_agree_with_ols():
    pytest.skip(
        "From the same two_feature_corner_demo call (use alphas [0.001, "
        "8.0]), assert both ridge's and lasso's coefficients at alpha=0.001 "
        "are within 0.01 of the OLS coefficients [1.9564, 1.9381]. Then "
        "assert that at alpha=8.0, lasso's coefficients are both exactly "
        "0.0 while ridge's are both still nonzero. As alpha shrinks toward "
        "zero, both penalties vanish and agree with plain least squares."
    )
starter/test_regularization_lib.py (1973 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 regularization_lib as r


def test_the_diabetes_split_is_reproducible_and_shaped_right():
    a = r.load_train_test()
    b = r.load_train_test()
    X_train_a, X_test_a, y_train_a, y_test_a = a
    X_train_b, _X_test_b, _y_train_b, _y_test_b = b
    assert X_train_a.shape == (331, 10)
    assert X_test_a.shape == (111, 10)
    assert y_train_a.shape == (331,)
    assert y_test_a.shape == (111,)
    # same seed, same split -- every downstream measurement depends on this
    assert np.array_equal(X_train_a, X_train_b)


def test_the_near_duplicate_columns_really_are_near_duplicate():
    X, y, correlation = r.near_duplicate_dataset()
    assert X.shape == (300, 3)
    assert y.shape == (300,)
    assert correlation > 0.999
    # the third column is not part of the near-duplicate pair
    assert abs(float(np.corrcoef(X[:, 0], X[:, 2])[0, 1])) < 0.3


def test_the_synthetic_sparse_dataset_really_has_five_informative_features():
    from sklearn.datasets import make_regression

    X, y, coef = make_regression(
        n_samples=200, n_features=20, n_informative=5, noise=1.0, coef=True, random_state=0
    )
    assert X.shape == (200, 20)
    assert int(np.sum(coef != 0)) == 5


def test_ridge_solves_directly_and_lasso_iterates():
    info = r.ridge_has_no_iteration_count()
    assert info["ridge_has_n_iter"] is False
    assert info["lasso_has_n_iter"] is True
    counts = r.lasso_iteration_counts([0.001, 1.0])
    # every count is a positive number of iterations, and none of them
    # hit the ceiling -- so no ConvergenceWarning was silently swallowed
    for alpha, n_iter in counts.items():
        assert 0 < n_iter < 50000, f"alpha={alpha} iterated {n_iter} times"
tests/run_tests.sh (13333 bytes)
#!/usr/bin/env bash
# Day 151 lab harness: "What the Penalty Does"
#
# 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 manual pytest invocation 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 regularization_lib as r

ALPHA_GRID = [0.001, 0.01, 0.1, 1.0]
PATH_ALPHAS = np.logspace(-3, 2, 60)

errors = []


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


# 1. Ridge never zeros; lasso zeros progressively more
rows = r.zero_counts_and_r2(ALPHA_GRID)
expect(
    "zero counts and r2",
    rows,
    [
        (0.001, 0, 0.3588, 0, 0.3586),
        (0.01, 1, 0.3541, 0, 0.3567),
        (0.1, 3, 0.355, 0, 0.369),
        (1.0, 8, 0.2782, 0, 0.357),
    ],
)
ridge_zeros = [row[3] for row in rows]
lasso_zeros = [row[1] for row in rows]
expect("ridge zeros at every alpha", ridge_zeros, [0, 0, 0, 0])
if not all(a <= b for a, b in zip(lasso_zeros, lasso_zeros[1:])):
    errors.append("lasso zero count decreased somewhere as alpha grew")

# 1b. LassoCV
cv = r.lasso_cv_selection()
expect("LassoCV alpha", round(cv["alpha"], 5), 0.07874)
expect("LassoCV zeros", cv["zeros"], 4)
expect("LassoCV kept", cv["kept"], ["sex", "bmi", "bp", "s1", "s3", "s5"])
expect("LassoCV r2", cv["r2"], 0.3562)

# 2. Coefficient path
zero_at, ridge_ever_zero = r.alpha_where_each_lasso_coefficient_first_hits_zero(PATH_ALPHAS)
if any(v is None for v in zero_at.values()):
    errors.append("not every lasso coefficient zeroed within the sweep")
expect("ridge ever zero", ridge_ever_zero, False)
weakest_first = min(zero_at, key=zero_at.get)
strongest_last = max(zero_at, key=zero_at.get)
expect("weakest coefficient to zero", weakest_first, "s3")
expect("strongest coefficient to zero", strongest_last, "bmi")
expect("s3 zeros at", round(zero_at["s3"], 4), 0.0032)
expect("bmi zeros at", round(zero_at["bmi"], 4), 2.4538)

# 3. Sparse recovery
p1, r1, n1 = r.sparse_recovery(alpha=1.0, noise=1.0, seed=0)
expect("recovery precision, low noise", p1, 1.0)
expect("recovery recall, low noise", r1, 1.0)
expect("recovery n_selected, low noise", n1, 5)

# 3b. Recovery degrades
_p, r_high, n_high = r.sparse_recovery(alpha=80.0, noise=30.0, seed=0)
expect("recall, heavy penalty + noise", r_high, 0.2)
expect("n_selected, heavy penalty + noise", n_high, 1)
_p2, r_none, n_none = r.sparse_recovery(alpha=80.0, noise=10.0, seed=0)
expect("recall, heavy penalty zeroes truth", r_none, 0.0)
expect("n_selected, heavy penalty zeroes truth", n_none, 0)
mp_low, mr_low = r.sparse_recovery_across_seeds(alpha=1.0, noise=1.0)
mp_high, mr_high = r.sparse_recovery_across_seeds(alpha=1.0, noise=10.0)
expect("mean precision, low noise, 10 seeds", mp_low, 1.0)
expect("mean precision, high noise, 10 seeds", mp_high, 0.6792)
if not (mp_high < mp_low):
    errors.append("mean precision did not fall with more noise")

# 4. Scale dependence
scale = r.scale_dependence(alpha=1.0)
expect("raw n_kept", scale["raw"]["n_kept"], 10)
expect("standardized n_kept", scale["standardized"]["n_kept"], 7)
expect("sklearn_unit_norm n_kept", scale["sklearn_unit_norm"]["n_kept"], 3)
expect(
    "standardized kept set",
    scale["standardized"]["kept"],
    ["sex", "bmi", "bp", "s1", "s3", "s5", "s6"],
)
expect("sklearn_unit_norm kept set", scale["sklearn_unit_norm"]["kept"], ["bmi", "bp", "s5"])

# 5. ElasticNet
en_rows = r.elasticnet_sweep(alpha=0.1, l1_ratios=[0.0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0])
expect(
    "elasticnet sweep",
    en_rows,
    [
        (0.0, 0, 0.0555),
        (0.1, 0, 0.0605),
        (0.3, 0, 0.0741),
        (0.5, 0, 0.0963),
        (0.7, 1, 0.1389),
        (0.9, 0, 0.2511),
        (1.0, 3, 0.355),
    ],
)

# 5b. Ridge/ElasticNet alpha scale
ridge_head, elastic_head, max_diff = r.ridge_elasticnet_equivalence(alpha=0.1)
if not (max_diff < 0.001):
    errors.append(f"ridge/elasticnet correction did not converge: max_diff={max_diff}")
expect("ridge head", ridge_head, [6.3098, 1.229, 22.6076])
expect("elastic head", elastic_head, [6.3097, 1.229, 22.6076])

# 6. Near duplicates
_X, _y, correlation = r.near_duplicate_dataset()
if not (correlation > 0.999):
    errors.append(f"near-duplicate correlation too low: {correlation}")
dup_rows_1 = r.ridge_vs_lasso_on_duplicates([1.0])
_alpha, ridge_c, lasso_c = dup_rows_1[0]
if abs(ridge_c[0] - ridge_c[1]) >= 0.15:
    errors.append("ridge did not split the duplicate weight evenly at alpha=1.0")
if lasso_c[1] != 0.0:
    errors.append("lasso did not zero the second duplicate at alpha=1.0")

# 6b. High alpha
dup_rows_10 = r.ridge_vs_lasso_on_duplicates([10.0])
_alpha10, ridge_c10, lasso_c10 = dup_rows_10[0]
expect("lasso zeros both duplicates at alpha=10", lasso_c10, [0.0, 0.0, 0.0])
if not (ridge_c10[0] > 2.5 and ridge_c10[1] > 2.5):
    errors.append("ridge zeroed a duplicate at alpha=10, which it must never do")

# 7. Closed form vs iterative
info = r.ridge_has_no_iteration_count()
expect("ridge has n_iter_", info["ridge_has_n_iter"], False)
expect("lasso has n_iter_", info["lasso_has_n_iter"], True)
counts = r.lasso_iteration_counts(ALPHA_GRID)
expect("lasso iteration counts", counts, {0.001: 368, 0.01: 62, 0.1: 135, 1.0: 6})

# 8. The corner
ols, corner_rows = r.two_feature_corner_demo([0.001, 0.5, 1.0, 3.0, 8.0])
expect("OLS reference coefficients", ols, [1.9564, 1.9381])
by_alpha = {a: (rc, lc) for a, rc, lc in corner_rows}
ridge3, lasso3 = by_alpha[3.0]
expect("lasso lands on the axis at alpha=3.0", lasso3, [0.8919, 0.0])
if ridge3[0] == 0.0 or ridge3[1] == 0.0:
    errors.append("ridge produced an exact zero, which it must never do")

if errors:
    for e in errors:
        print("ERROR:", e)
    sys.exit(1)
print("all direct checks passed")
PYEOF
)
if echo "$DIRECT_CHECK" | grep -q "all direct checks passed"; then
  ok "exercises 1-8 reproduced directly against regularization_lib, no pytest involved"
else
  fail "direct library checks failed"
  echo "$DIRECT_CHECK" | sed 's/^/    /'
fi

echo ""
echo "3. examples/ passes in full"
EXAMPLES_OUT=$("$PYTEST" examples -q 2>&1)
if echo "$EXAMPLES_OUT" | tail -1 | grep -qE "^18 passed"; then
  ok "pytest examples -q -> 18 passed"
else
  fail "pytest examples -q did not report 18 passed"
  echo "$EXAMPLES_OUT" | tail -20 | sed 's/^/    /'
fi

echo ""
echo "4. starter/ is an untouched skeleton"
STARTER_OUT=$("$PYTEST" starter -q 2>&1)
if echo "$STARTER_OUT" | tail -1 | grep -qE "4 passed, 14 skipped"; then
  ok "pytest starter -q -> 4 passed, 14 skipped (the machinery checks pass; the fourteen exercises are stubs)"
else
  fail "pytest starter -q did not report 4 passed, 14 skipped"
  echo "$STARTER_OUT" | tail -20 | sed 's/^/    /'
fi

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

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

echo ""
echo "7. Proof the harness can fail"
SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/d151-scratch.XXXXXX")
cp examples/*.py "$SCRATCH"/
SCRATCH_OUT=$("$PYTEST" "$SCRATCH" -q 2>&1)
if echo "$SCRATCH_OUT" | tail -1 | grep -qE "^18 passed"; then
  ok "scratch copy of examples/ passes before it is broken"
else
  fail "scratch copy did not pass before being broken: $(echo "$SCRATCH_OUT" | tail -3)"
fi
"$PYTHON" - "$SCRATCH/test_regularization_claims.py" <<'PYEOF'
import sys
path = sys.argv[1]
text = open(path).read()
needle = "assert lasso_x2 == 0.0"
replacement = "assert lasso_x2 == 999.0"
assert needle in text, "could not find the assertion to break"
open(path, "w").write(text.replace(needle, replacement, 1))
PYEOF
BROKEN_OUT=$("$PYTEST" "$SCRATCH" -q 2>&1)
BROKEN_STATUS=$?
if [ "$BROKEN_STATUS" -ne 0 ] && echo "$BROKEN_OUT" | grep -q "test_06_ridge_splits_the_weight_between_near_duplicates"; then
  ok "breaking exercise 6's assertion produces a non-zero exit and names the failing test"
else
  fail "broken copy did not fail as expected (exit=$BROKEN_STATUS)"
fi
rm -rf "$SCRATCH"

echo ""
echo "8. The direction of every result holds beyond the quoted alpha or seed"
DIRECTION=$("$PYTHON" - <<'PYEOF'
import sys
sys.path.insert(0, "examples")
import numpy as np
import regularization_lib as r

problems = []

# Ridge never zeros a coefficient, at alphas the lesson does not quote.
_lasso_path, ridge_path = r.coefficient_path(np.logspace(-4, 3, 25))
if np.any(ridge_path == 0.0):
    problems.append("ridge produced an exact zero at an unquoted alpha")

# Lasso's zero count is monotone non-decreasing over a different, denser grid.
alphas = np.logspace(-3, 0.5, 12)
lasso_zeros = []
X_train, X_test, y_train, y_test = r.load_train_test()
from sklearn.linear_model import Lasso
for a in alphas:
    lasso_zeros.append(int(np.sum(Lasso(alpha=a, max_iter=50000).fit(X_train, y_train).coef_ == 0)))
if not all(a <= b for a, b in zip(lasso_zeros, lasso_zeros[1:])):
    problems.append(f"lasso zero count was not monotone over a denser alpha grid: {lasso_zeros}")

# Near-duplicate splitting holds at other dataset seeds too.
for seed in (1, 2, 3):
    X, y, corr = r.near_duplicate_dataset(seed=seed)
    if corr <= 0.999:
        problems.append(f"seed {seed}: near-duplicate correlation too low ({corr})")
    from sklearn.linear_model import Ridge
    ridge = Ridge(alpha=1.0).fit(X, y)
    lasso = Lasso(alpha=1.0, max_iter=50000).fit(X, y)
    if abs(ridge.coef_[0] - ridge.coef_[1]) >= 0.2:
        problems.append(f"seed {seed}: ridge did not split the duplicate weight")
    # lasso need not land on an EXACT zero at every seed -- these columns
    # are correlated at 0.9999, not identically collinear -- but it must
    # produce a heavily asymmetric split rather than ridge's near-even one
    small, large = sorted(abs(c) for c in lasso.coef_[:2])
    if large < 20 * max(small, 1e-6):
        problems.append(f"seed {seed}: lasso did not produce an asymmetric split ({lasso.coef_[:2]})")

if problems:
    for p in problems:
        print("ERROR:", p)
else:
    print("every direction held")
PYEOF
)
if [ "$DIRECTION" = "every direction held" ]; then
  ok "ridge never-zeros, lasso monotone zeroing, and duplicate splitting hold beyond the quoted alphas and seeds"
else
  fail "a direction failed beyond the quoted alpha or 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 (regularization_lib, test_regularization_lib, test_regularization_claims), so pytest cannot decide which one 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 near-duplicate columns don't give lasso an exact zero

At the default seed (near_duplicate_dataset(), seed=0) they do: [5.0848, 0.0, 0.0] at alpha=1.0. But the two columns are correlated at 0.999918, not identically collinear, so at other seeds lasso can leave a tiny nonzero residual on the coefficient it is effectively dropping — harness check 8 found [5.0215, 0.0007] at seed 3. That is still the same story: an overwhelmingly asymmetric split, ridge's stays nearly even. If your run gives two roughly EQUAL nonzero lasso coefficients on this dataset, something is genuinely wrong.

ElasticNet(l1_ratio=0) doesn't match Ridge at the "same" alpha

It should not, and exercise 5b measures exactly why. Ridge's objective sums the squared error across all rows; ElasticNet's averages it over n_samples. So Ridge(alpha=a) corresponds to ElasticNet(alpha=a / n_train, l1_ratio=0), not ElasticNet(alpha=a, l1_ratio=0). This lab fits Ridge(alpha=a * n_train) and checks it against ElasticNet(alpha=a, l1_ratio=0) for exactly this reason. If you skip the correction you will see ElasticNet look far more aggressively regularised than Ridge at a nominally matching alpha — for example alpha=0.1 gives ElasticNet a test R2 of 0.0555 against Ridge's 0.3690, which is not a bug, it is the uncorrected comparison.

Lasso warns about convergence, or the coefficients look different from the lesson

max_iter=50000 is set everywhere in this lab specifically to avoid a ConvergenceWarning — the default max_iter=1000 does not converge on this dataset at small alphas. If you construct your own Lasso() with the default, expect a warning and possibly slightly different coefficients. Match the library's settings.

My scale-dependence numbers differ from the lesson's

Read expected-output/FIELDS.md. The directions — raw keeps the most features, standardized keeps fewer, scikit-learn's own unit-norm convention keeps fewer still, at the identical alpha — must hold on any version. The exact kept sets and counts are pinned to the exact package versions in requirements/requirements.txt.

The harness takes a while

It should not: the heaviest step is the 60-point alpha sweep in exercise 2, which refits ten small linear models sixty times. On the capture machine the whole harness runs in well under a minute. No timing is asserted anywhere, so a slower machine changes nothing about whether it passes.

sqrt, n_iter_, or another attribute is missing on my fitted model

Check which model you fitted. Ridge has no n_iter_ under its default solver (solver="auto", which resolves to a direct linear-algebra solve); Lasso and ElasticNet always have n_iter_, because they are solved by coordinate descent. Exercise 7 asserts this contrast directly.

Security notes

Security notes

What this lab touches

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

  • Filesystem. The lab reads only files inside its own directory, plus the diabetes dataset that scikit-learn bundles inside its own installed package (sklearn/datasets/data/diabetes_*.csv.gz) — that file is read from local disk, never fetched over the network. The one write outside this directory is check 7 of the harness, which creates a scratch directory with mktemp -d under $TMPDIR, copies examples/*.py into it, deliberately breaks one assertion to prove the harness can fail, and removes the directory again in the same run. 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 in examples/ or starter/ source. Every synthetic dataset is generated on the spot from a seeded numpy.random.default_rng or sklearn.datasets.make_regression; the one real dataset, load_diabetes, ships bundled inside the scikit-learn wheel.
  • Credentials. There are none. requires_api_key is false, no account is needed, and nothing in this lab reads an environment variable that could hold a secret.
  • Privileges. Nothing here needs sudo. If a step appears to ask for administrator rights, stop and re-read it — it is not this lab.
  • Reversibility. Everything this lab creates is inside its own directory and is removed by the cleanup commands in metadata.yml. rm -rf .venv returns the machine to exactly its prior state.

The one install step, and how to check it

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

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

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

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

The security idea in this lab

Regularization itself is worth reading as a control on a model's trustworthiness in production, not only as a statistics topic.

A model with 10 nonzero coefficients over unscaled inputs has an attack surface: whichever raw feature happens to arrive in large natural units (a count in the thousands rather than a fraction between 0 and 1) can dominate the fit regardless of whether it actually carries signal, purely because the penalty — or the absence of one — treats it as "big" or "small" in the wrong units. Exercise 4 in this lab measures that failure directly: the identical alpha, in three different units, selects 10, 7 and 3 features. A pipeline that fits a penalised model on unscaled inputs without noticing has not actually regularised anything meaningful.

Sparsity is also an auditability property. A model with 3 nonzero coefficients is far easier to review, explain, and monitor for drift than one with 10 small nonzero coefficients that are individually unremarkable and collectively opaque. Exercise 3 measures that lasso can recover the RIGHT sparse set, which is what makes that auditability claim trustworthy rather than accidental — and exercise 3b measures that it can also fail to, which is why "the model is sparse" is not by itself evidence that the model found the truth.

What the code does that is worth understanding

  • Every dataset generator either bundles data read-only from the scikit-learn package or 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.sh itself and never reads the status of a pipeline. cmd | tail reports tail's status, which is almost always zero — an always-passing test suite is a security control that has quietly stopped working.

Reporting a problem

If you find something in this lab that writes outside its own directory, reaches a network it did not start, or asks for a credential, that is a bug. Nothing here is supposed to do any of those things.