Why a model that fits the past perfectly predicts the future badly, how to measure it honestly, and how shrinkage fixes it.
LassoCV, RidgeCV, and ElasticNetCVCompanion notebook: lesson-04-regularization.ipynb — worked examples and self-checking exercises.
Too simple, too flexible, and just right.
Underfit (degree 1): misses the U entirely. High bias: wrong on average, whatever sample you draw.
About right (degree 4): follows the curve, ignores the noise.
Overfit (degree 15): threads every point. High variance: a new sample gives a wildly different curve.
| Degree | Bias² | Variance | Test MSE |
|---|---|---|---|
| 1 | 4.03 | 0.74 | 8.58 |
| 4 | 0.10 | 1.03 | 4.79 |
| 10 | 0.94 | 110 | 13.1 |
| 15 | 5.91 | 409 | 259 |
×10⁻⁴; averaged over 200 training samples of 30 drivers. The noise floor is 4.0.
Training error always falls as complexity rises. It can’t tell you when to stop. Only data the model hasn’t seen can.
from sklearn.model_selection import train_test_split
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, train_size=500, random_state=42)
Every time you look at the test score and change something, the test set becomes a validation set. Its score stops being honest.
Split the training data into K folds; fit on K − 1, score on the one left out; rotate; average. Every row is validated exactly once.
from sklearn.model_selection import KFold, cross_val_score
kf = KFold(5, shuffle=True, random_state=0)
cross_val_score(ols_pipe, X_tr, y_tr, cv=kf, scoring="r2")
# [0.240 0.232 0.319 0.384 0.370] mean 0.309
Insurance rows aren’t independent: one policyholder renews for years. Use GroupKFold on the policyholder so the same person never sits on both sides of a split. For pricing, also hold out a whole later year.
60 features, many correlated, some pure noise.
bio = pd.read_csv("data/health/biometrics.csv")
# member demographics + 8 chronic conditions
# + 2024 wellness screening (BMI, blood pressure, labs…)
# + 20 lifestyle survey items
# + 2024 encounter counts by category + log 2024 cost
X = pd.get_dummies(features, columns=["sex", "plan", "region"],
drop_first=True, dtype=float)
y = np.log1p(cost_2025) # mean 7.75, sd 1.84
X.shape # (8000, 60)
To mimic a small employer group, we fit on 500 members and test on the other 7,500.
Seven pairs have |r| > 0.7: BMI–waist 0.87, HbA1c–glucose 0.87, LDL–total cholesterol 0.85, eGFR–creatinine −0.85…
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
ols = make_pipeline(StandardScaler(), LinearRegression())
ols.fit(X_tr, y_tr)
r2_score(y_tr, ols.predict(X_tr)) # 0.482
r2_score(y_te, ols.predict(X_te)) # 0.292
With few observations per parameter, OLS fits the noise. Its estimates are unbiased but have enormous variance, and correlated features make it worse: (X⊤X)−1 blows up when columns are nearly collinear.
Regularization accepts a little bias in exchange for a large cut in variance, by penalizing large coefficients.
Lasso, Ridge, and ElasticNet.
$$\textbf{Ridge:}\;\; \min_\beta\; \tfrac{1}{2n}\lVert y - \mathbf{X}\beta\rVert^2 + \alpha\lVert\beta\rVert_2^2$$
$$\textbf{Lasso:}\;\; \min_\beta\; \tfrac{1}{2n}\lVert y - \mathbf{X}\beta\rVert^2 + \alpha\lVert\beta\rVert_1$$
$$\textbf{ElasticNet:}\;\; \ldots + \alpha\big(\rho\lVert\beta\rVert_1 + \tfrac{1-\rho}{2}\lVert\beta\rVert_2^2\big)$$
α sets the strength; ρ (l1_ratio) the mix. The intercept is never penalized.
| Ridge (L2) | Lasso (L1) | ElasticNet | |
|---|---|---|---|
| Shrinks coefficients | Smoothly toward 0 | Toward 0, many to exactly 0 | Both |
| Feature selection | No | Yes | Yes |
| Correlated group | Shares the weight | Picks one, often arbitrarily | Keeps the group together |
| Best when | Many small effects, multicollinearity | A few real effects among many | Correlated blocks + sparsity |
Penalized fitting is the same as minimizing the RSS inside a budget region.
Tighter budget (larger α) → smaller region → more shrinkage.
The penalty treats every coefficient alike, but a coefficient’s size depends on its feature’s units. Steps per day (sd 3,529) needs a tiny coefficient; diabetes (sd 0.36) a large one. Without scaling, the penalty barely touches the first and crushes the second.
# ✅ scaler learns mean/sd from each training fold only
lasso = make_pipeline(StandardScaler(), LassoCV(cv=kf))
# ❌ unscaled: same α, different model
Lasso(alpha=0.107).fit(X_tr, y_tr)
# keeps 20 features incl. steps_per_day and two survey items
# test R² 0.322 (scaled: 0.355)
Leakage pitfall: StandardScaler().fit(X) on all rows before splitting lets test-set means into training. Put the scaler in a Pipeline so cross-validation refits it on every fold.
Coefficients are now “per standard deviation”. To report them in original units, divide by the scaler’s scale_.
Scale 0/1 dummies too? It’s a choice: after scaling, rare levels are penalized less. Be consistent, and say which you did.
from sklearn.linear_model import LassoCV, RidgeCV, ElasticNetCV
lasso = make_pipeline(StandardScaler(),
LassoCV(cv=kf, random_state=0)).fit(X_tr, y_tr)
lasso[-1].alpha_ # 0.107
(lasso[-1].coef_ != 0).sum() # 13 of 60
ridge = make_pipeline(StandardScaler(),
RidgeCV(alphas=np.logspace(-2, 4, 60), cv=kf))
enet = make_pipeline(StandardScaler(),
ElasticNetCV(l1_ratio=[0.1, 0.5, 0.9], cv=kf))
The 1-SE rule picks the largest α within one standard error of the minimum: a simpler model (7 features, test R² 0.329) that’s statistically as good.
| Model | α | Train R² | Test R² | Test RMSE | Features |
|---|---|---|---|---|---|
| OLS | — | 0.482 | 0.292 | 1.548 | 60 |
| Ridge | 298 | 0.458 | 0.341 | 1.494 | 60 |
| Lasso | 0.107 | 0.424 | 0.355 | 1.477 | 13 |
| ElasticNet (ρ = 0.5) | 0.199 | 0.425 | 0.357 | 1.476 | 16 |
Every penalized model has a lower training R² and a higher test R² than OLS. That’s the trade-off working.
Prior utilization enters first and beats every biometric.
Survey item q04 (β = 0.005) is noise that slipped through; the 1-SE model drops it.
| Feature (standardized) | Lasso | OLS |
|---|---|---|
| n_pharmacy | 0.360 | 0.435 |
| n_lab | 0.285 | 0.254 |
| log_cost_2024 | 0.275 | 0.226 |
| n_specialist | 0.160 | 0.197 |
| hypertension · diabetes | 0.086 · 0.045 | 0.157 · 0.139 |
| bmi · weight · waist | 0 · 0 · 0 | −0.020 · 0.200 · −0.183 |
| 20 survey items (sum of |β|) | 0.005 | 0.899 |
| Training members | OLS test R² | Lasso test R² |
|---|---|---|
| 200 | 0.115 | 0.331 |
| 500 | 0.292 | 0.355 |
| 1,000 | 0.334 | 0.355 |
| 2,000 | 0.353 | 0.353 |
| 4,000 | 0.352 | 0.354 |
| 7,000 | 0.347 | 0.350 |
With enough data per feature, OLS catches up. Regularization matters most when data is thin relative to the number of parameters:
Penalized GLMs are credibility in modern dress: thin cells shrink toward the overall mean, just as Bühlmann credibility shrinks toward the collective. Lesson 8 uses a penalized Poisson GLM.
random_state=42RidgeCV, LassoCV, ElasticNetCV with 5-fold CV| OLS test R² | 0.292 |
| RidgeCV α · test R² | 298 · 0.341 |
| LassoCV α · test R² | 0.107 · 0.355 |
| Lasso non-zero features | 13 |
| 1-SE α · features · test R² | 0.264 · 7 · 0.329 |
Pipeline, never before the splitLassoCV, RidgeCV, ElasticNetCVNo target, no labels: K-means finds patient archetypes hidden in 180,000 encounters.