Machine Learning › Regression › Day 151
Hands-on lab — Day 151: Regularization: Ridge and Lasso
- ← Back to the Day 151 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/machine-learning/day-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
- Lesson title: Regularization: Ridge and Lasso
- Day number: 151 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-151-regularization-ridge-and-lasso
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-151-regularization-ridge-and-lassowhen the site is running.
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:
- The headline table above.
- 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.
- Whether lasso recovers the RIGHT features against a known sparse ground truth, and how that recovery degrades with noise.
- Scale-dependence: the same alpha, on the same data, in three different units, selects 10, 7 and 3 features respectively.
- ElasticNet, and the alpha-scale mismatch between it and plain Ridge.
- Correlated predictors — ridge splits the weight, lasso picks one.
- Ridge's closed form against lasso's iterative solve.
- 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:
- State, from a measurement rather than a rule of thumb, that ridge never zeros a coefficient while lasso zeros progressively more as alpha grows.
- 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.
- Distinguish lasso as simultaneous shrinkage and feature selection from ridge as shrinkage alone.
- Measure whether lasso recovers a KNOWN set of informative features, and report honestly when it does not.
- Demonstrate that regularization requires scaled features, with three different feature-selection outcomes from the same alpha in three different units.
- Use ElasticNet as the combination of both penalties and know when it is preferable to either alone.
- State that Ridge and ElasticNet do NOT share an alpha scale, and correct for the difference.
- Predict what ridge and lasso each do to a pair of near-duplicate (highly correlated) predictors, connecting back to Day 150.
- 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
python33.11 or newer on yourPATH.
Supported operating systems
- macOS (Apple Silicon or Intel) — the capture machine was macOS 26.5.2 on arm64.
- Linux (any distribution with Python 3.11+ and bash).
- Windows via WSL2. The harness is a bash script and uses
mktemp -d,findand process substitution; native PowerShell is not supported.
Hardware requirements
Any machine that can run Python. No GPU is needed or used — 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_diabetesships 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
bash tests/run_tests.sh; echo "exit=$?"→14 checks, 0 failure(s)andexit=0..venv/bin/pytest examples -q→18 passed..venv/bin/pytest starter -q→4 passed, 14 skippedbefore you start;18 passedwhen you have finished every exercise..venv/bin/python3 examples/report_measurements.py | diff - expected-output/measured-values.txt→ no output.- Break one assertion in
examples/test_regularization_claims.pyon 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
- Sweep alpha finer, and plot the path. Exercise 2 samples 60 alphas.
Use
sklearn.linear_model.lasso_pathto 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. - 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.
- LassoCV versus a hand-rolled grid search. Exercise 1b uses
LassoCV. Reimplement its cross-validation loop by hand withKFoldandLasso, and confirm you recover the same alpha. - 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. - 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. - 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.
- 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.
Navigation
- 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 withmktemp -dunder$TMPDIR, copiesexamples/*.pyinto it, deliberately breaks one assertion to prove the harness can fail, and removes the directory again in the same run. Nothing is written to your home directory, nothing above the lab root is modified, and no system path is touched. - Network. After the one
pip install, this lab is completely offline. Check 9 asserts that no URL appears anywhere inexamples/orstarter/source. Every synthetic dataset is generated on the spot from a seedednumpy.random.default_rngorsklearn.datasets.make_regression; the one real dataset,load_diabetes, ships bundled inside the scikit-learn wheel. - Credentials. There are none.
requires_api_keyisfalse, no account is needed, and nothing in this lab reads an environment variable that could hold a secret. - Privileges. Nothing here needs
sudo. If a step appears to ask for administrator rights, stop and re-read it — it is not this lab. - Reversibility. Everything this lab creates is inside its own
directory and is removed by the cleanup commands in
metadata.yml.rm -rf .venvreturns the machine to exactly its prior state.
The one install step, and how to check it
pip install -r requirements/requirements.txt downloads three packages
from the Python Package Index into a lab-local virtual environment,
never into your system Python. Pinning exact versions is a security
control as well as a reproducibility one: an unpinned install resolves to
whatever is newest at the moment you run it, which is a moving target you
have not reviewed.
If you want to verify what you are installing before you install it, pip can check hashes for you:
.venv/bin/pip install --require-hashes -r requirements/requirements.txt
That requires a hash-annotated requirements file, which this lab does not
ship because the correct hashes differ per platform wheel. Generating one
for your own platform with pip-compile --generate-hashes is a reasonable
habit for any environment you care about.
The security idea in this lab
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.shitself and never reads the status of a pipeline.cmd | tailreportstail's status, which is almost always zero — an always-passing test suite is a security control that has quietly stopped working.
Reporting a problem
If you find something in this lab that writes outside its own directory, reaches a network it did not start, or asks for a credential, that is a bug. Nothing here is supposed to do any of those things.