Regularisation: Ridge, Lasso, Elastic Net
The scene: the problem left over from Chapter 15
At the end of Chapter 15 we saw something and didn't fix it. The coefficients jumped around depending on the train split:
| random_state | xonalar | maydon_m2 |
|---|---|---|
| 0 | ... | ... |
| 42 | ... | ... |
The reason: the correlation between xonalar and maydon_m2 is 0.94. The model doesn't know which of the two to "trust", so it divides the weight differently on every split.
And Chapter 15 had one more problem: at polynomial degree 3 the number of features rose to 83 and the result got worse.
Both problems have one fix: regularisation.
Column names
The data is synthetic Tashkent rental listings, and its column names are Uzbek. xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), narx_usd monthly rent in USD, ijaraga_berildi_30k rented within 30 days.
The idea: we fine the model
In Chapter 13 the cost function was:
Now we add a penalty on top:
The model now has to do two things at once: reduce the error and keep the weights small. As a result it gives large weights only to features that are really useful.
What is "the size of the weights"? This is where the norms from Chapter 12 come in.
import pandas as pd import numpy as np w = np.array([3.0, -4.0, 0.5]) print(f"weights: {w}") print(f" L2 penalty (sum of squares) : {np.sum(w**2):.4f}") print(f" L1 penalty (absolute values) : {np.sum(np.abs(w)):.4f}")
weights: [ 3. -4. 0.5] L2 penalty (sum of squares) : 25.2500 L1 penalty (absolute values) : 7.5000
show(pd.DataFrame([ {"Model": "Ridge", "Penalty": "L2: alpha * sum(w^2)", "Effect on the weights": "SHRINKS all of them, never to zero", "When": "When every feature is a little useful"}, {"Model": "Lasso", "Penalty": "L1: alpha * sum(|w|)", "Effect on the weights": "Pushes some EXACTLY TO ZERO", "When": "When you need feature selection"}, {"Model": "Elastic Net", "Penalty": "A mix of L1 and L2", "Effect on the weights": "In between the two", "When": "Many features that are related to each other"}, ]))
| Model | Penalty | Effect on the weights | When |
|---|---|---|---|
| Ridge | L2: alpha * sum(w^2) | SHRINKS all of them, never to zero | When every feature is a little useful |
| Lasso | L1: alpha * sum(|w|) | Pushes some EXACTLY TO ZERO | When you need feature selection |
| Elastic Net | A mix of L1 and L2 | In between the two | Many features that are related to each other |
First: scaling is MANDATORY here
from sklearn.model_selection import train_test_split, cross_val_score from sklearn.linear_model import LinearRegression, Ridge, Lasso, ElasticNet from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler df = pd.read_csv(DATA / "uy_ijara.csv") num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel"] X, y = df[num], df["narx_usd"] X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42) print("Ridge, WITH and WITHOUT scaling, at different alphas:") print(f"{'alpha':>8} {'no scaling':>14} {'scaling':>13} {'gap':>9}") print("-" * 48) for a in [1, 10, 100, 1000, 10000]: y1 = cross_val_score(Ridge(alpha=a), X, y, cv=5, scoring="r2").mean() y2 = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", Ridge(alpha=a))]), X, y, cv=5, scoring="r2").mean() print(f"{a:>8} {y1:>14.4f} {y2:>13.4f} {y2-y1:>+9.4f}")
Ridge, WITH and WITHOUT scaling, at different alphas:
alpha no scaling scaling gap
------------------------------------------------
1 0.8759 0.8759 +0.0000
10 0.8758 0.8758 +0.0000
100 0.8713 0.8718 +0.0004
1000 0.8443 0.7553 -0.0890
10000 0.8097 0.2405 -0.5693
The result came out different from what I expected, so I'm leaving it unchanged.
When alpha is small (1, 10, 100), scaling barely changes the result. When alpha is very large, scaling makes the result worse.
I was wrong here, and the measurement showed it
I had written this section as "scaling is mandatory in regularisation, otherwise the result is bad". I measured it, and it didn't hold up.
The reason: without scaling, maydon_m2 has large values, so its coefficient is naturally small, and the sum(w^2) penalty barely touches it. Unscaled, the model "hides" from the penalty.
Scaled, the penalty falls equally on every feature, so a large alpha chokes all of them at once.
So what is scaling for? Not for the result, but for fairness:
- to compare coefficients with each other (Chapter 15)
- so that Lasso selects features correctly (we'll see below)
- so that the value of
alphadoesn't depend on one column's units
The rule doesn't change: you use Ridge/Lasso with StandardScaler. But the reason is not "otherwise R2 drops".
Ridge reins in the coefficients
print(f"{'alpha':>10} {'CV R2':>9} {'±':>7} {'coefficients (scaled)'}") print("-" * 92) for a in [0, 0.1, 1, 10, 100, 1000]: m = LinearRegression() if a == 0 else Ridge(alpha=a) pipe = Pipeline([("sc", StandardScaler()), ("m", m)]) b = cross_val_score(pipe, X, y, cv=5, scoring="r2") pipe.fit(X_tr, y_tr) k = pipe.named_steps["m"].coef_ name = "0 (plain)" if a == 0 else str(a) print(f"{name:>10} {b.mean():>9.4f} {b.std():>7.4f} {np.round(k, 1)}")
alpha CV R2 ± coefficients (scaled)
--------------------------------------------------------------------------------------------
0 (plain) 0.8759 0.0091 [ 67.3 142.8 5.7 8.2 -44. 44.1]
0.1 0.8759 0.0091 [ 67.3 142.7 5.7 8.2 -44. 44.1]
1 0.8759 0.0091 [ 67.9 142. 5.7 8.2 -44. 44.1]
10 0.8758 0.0092 [ 72.8 135.9 5.5 8.3 -43.5 43.6]
100 0.8718 0.0104 [ 86.2 111.5 4.7 8.5 -39. 39.6]
1000 0.7553 0.0153 [ 63. 66.6 3.6 6.4 -19.4 20.2]
Read the table column by column. As alpha rises the coefficients shrink, but none of them becomes zero.
When alpha is very large, the model pulls almost everything to zero and R2 drops: underfitting begins.
Ridge fixes the instability from Chapter 15
This is the main proof of the chapter. In Chapter 15 the coefficients jumped from split to split. Let's measure.
result = {"Plain (LinearRegression)": [], "Ridge (alpha=10)": [], "Ridge (alpha=100)": []} for rs in [0, 1, 7, 13, 42, 99, 123, 2026]: Xa, _, ya, _ = train_test_split(X, y, test_size=0.3, random_state=rs) for name, m in [("Plain (LinearRegression)", LinearRegression()), ("Ridge (alpha=10)", Ridge(alpha=10)), ("Ridge (alpha=100)", Ridge(alpha=100))]: q = Pipeline([("sc", StandardScaler()), ("m", m)]).fit(Xa, ya) result[name].append(q.named_steps["m"].coef_[:2]) # xonalar and maydon_m2 rows = [] for name, lst in result.items(): arr = np.array(lst) rows.append({ "Model": name, "xonalar: lowest..highest": f"{arr[:,0].min():.1f} .. {arr[:,0].max():.1f}", "xonalar spread": round(float(arr[:, 0].std()), 2), "maydon_m2 spread": round(float(arr[:, 1].std()), 2), }) show(pd.DataFrame(rows))
| Model | xonalar: lowest..highest | xonalar spread | maydon_m2 spread |
|---|---|---|---|
| Plain (LinearRegression) | 54.5 .. 69.6 | 4.73 | 4.49 |
| Ridge (alpha=10) | 62.5 .. 75.8 | 4.1 | 3.77 |
| Ridge (alpha=100) | 82.6 .. 89.1 | 2.13 | 1.66 |
Look at the last two columns: as alpha rises, the spread of the coefficients shrinks.
In other words, Ridge barely changes prediction accuracy, but it gives a stable answer to "how much do rooms affect the price". That matters a lot in projects that require explanations (banking, medicine).
Lasso selects features
from sklearn.preprocessing import PolynomialFeatures # back to the Chapter 15 problem: the polynomial created 83 features pf = PolynomialFeatures(3, include_bias=False) print(f"Features: {len(num)} -> {pf.fit_transform(X[:1]).shape[1]}") print() print(f"{'alpha':>8} {'CV R2':>9} {'coef equal to 0':>17} {'features kept':>18}") print("-" * 58) for a in [0.001, 0.01, 0.1, 1, 10]: pipe = Pipeline([("p", pf), ("sc", StandardScaler()), ("m", Lasso(alpha=a, max_iter=50000))]) b = cross_val_score(pipe, X, y, cv=5, scoring="r2") pipe.fit(X_tr, y_tr) k = pipe.named_steps["m"].coef_ zero = int((k == 0).sum()) print(f"{a:>8} {b.mean():>9.4f} {zero:>13} / {len(k)} {len(k)-zero:>18}")
Features: 6 -> 83
alpha CV R2 coef equal to 0 features kept
----------------------------------------------------------
0.001 0.8707 3 / 83 80
0.01 0.8716 12 / 83 71
0.1 0.8742 43 / 83 40
1 0.8753 66 / 83 17
10 0.8681 74 / 83 9
Here is Lasso's superpower: as alpha rises, it pushes coefficients exactly to zero.
A coefficient of zero means that feature is not used at all in the model. Lasso does feature selection automatically.
pipe_l = Pipeline([("p", PolynomialFeatures(3, include_bias=False)), ("sc", StandardScaler()), ("m", Lasso(alpha=1, max_iter=50000))]).fit(X_tr, y_tr) k = pipe_l.named_steps["m"].coef_ names = pipe_l.named_steps["p"].get_feature_names_out(num) alive = pd.DataFrame({"feature": names, "coefficient": k.round(3)}) alive = alive[alive.coefficient != 0].sort_values("coefficient", key=abs, ascending=False) print(f"Features Lasso (alpha=1) kept: {len(alive)} of {len(k)}:") show(alive.head(12))
Features Lasso (alpha=1) kept: 17 of 83:
| feature | coefficient |
|---|---|
| maydon_m2 | 143.484 |
| xonalar | 60.1 |
| metro_km | -51.271 |
| mebel | 41.385 |
| qavat | 16.221 |
| metro_km^3 | 11.008 |
| qavat^2 mebel | -8.395 |
| xonalar qavatlar_soni | 6.4 |
| qavat^3 | -4.769 |
| qavat qavatlar_soni | 3.871 |
| maydon_m2 qavat^2 | -3.447 |
| xonalar mebel | 3.177 |
Why L1 reaches zero and L2 doesn't
The reason is in the shape of the penalty.
- The
L2penalty isw^2: as a weight approaches zero, so does the derivative. So it pulls the weight towards zero but never reaches it. - The
L1penalty is|w|: its derivative doesn't shrink near zero (it stays ±1). So it pushes the weight all the way to zero and holds it there.
The geometric explanation: L2's boundary is a circle, L1's is a diamond. The diamond's corners sit on the axes, so the solution is likely to land on a corner (meaning some coordinates are zero).
Elastic Net: in between the two
print(f"{'model':<34} {'CV R2':>9} {'±':>7}") print("-" * 52) for name, m in [ ("LinearRegression (no penalty)", LinearRegression()), ("Ridge (alpha=10)", Ridge(alpha=10)), ("Lasso (alpha=1)", Lasso(alpha=1, max_iter=50000)), ("ElasticNet (l1_ratio=0.5)", ElasticNet(alpha=1, l1_ratio=0.5, max_iter=50000)), ("ElasticNet (l1_ratio=0.9)", ElasticNet(alpha=1, l1_ratio=0.9, max_iter=50000)), ]: b = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", m)]), X, y, cv=5, scoring="r2") print(f"{name:<34} {b.mean():>9.4f} {b.std():>7.4f}")
model CV R2 ± ---------------------------------------------------- LinearRegression (no penalty) 0.8759 0.0091 Ridge (alpha=10) 0.8758 0.0092 Lasso (alpha=1) 0.8758 0.0089 ElasticNet (l1_ratio=0.5) 0.8300 0.0137 ElasticNet (l1_ratio=0.9) 0.8713 0.0102
On our data the differences are small, because there are few features (6) and all of them are useful. Regularisation has a strong effect when there are many useless features.
Let's show that: we add 60 noise columns.
r = np.random.default_rng(42) noise = pd.DataFrame(r.normal(size=(len(X), 60)), columns=[f"noise_{i}" for i in range(60)], index=X.index) X_noisy = pd.concat([X, noise], axis=1) print(f"Features: {X.shape[1]} -> {X_noisy.shape[1]} (60 of them USELESS)") print() print(f"{'model':<34} {'CV R2':>9} {'±':>7}") print("-" * 52) for name, m in [ ("LinearRegression (no penalty)", LinearRegression()), ("Ridge (alpha=10)", Ridge(alpha=10)), ("Lasso (alpha=1)", Lasso(alpha=1, max_iter=50000)), ]: b = cross_val_score(Pipeline([("sc", StandardScaler()), ("m", m)]), X_noisy, y, cv=5, scoring="r2") print(f"{name:<34} {b.mean():>9.4f} {b.std():>7.4f}")
Features: 6 -> 66 (60 of them USELESS) model CV R2 ± ---------------------------------------------------- LinearRegression (no penalty) 0.8685 0.0095 Ridge (alpha=10) 0.8687 0.0096 Lasso (alpha=1) 0.8724 0.0091
print("How Lasso cleans out the noise, depending on alpha:") print(f"{'alpha':>8} {'CV R2':>9} {'real kept':>18} {'noise kept':>18}") print("-" * 58) for a in [0.1, 1, 5, 20, 50]: q = Pipeline([("sc", StandardScaler()), ("m", Lasso(alpha=a, max_iter=50000))]) b = cross_val_score(q, X_noisy, y, cv=5, scoring="r2").mean() q.fit(X_noisy, y) k_ = q.named_steps["m"].coef_ real = sum(1 for n, v in zip(X_noisy.columns, k_) if v != 0 and not n.startswith("noise")) nz = sum(1 for n, v in zip(X_noisy.columns, k_) if v != 0 and n.startswith("noise")) print(f"{a:>8} {b:>9.4f} {real:>14} / 6 {nz:>14} / 60")
How Lasso cleans out the noise, depending on alpha:
alpha CV R2 real kept noise kept
----------------------------------------------------------
0.1 0.8690 6 / 6 56 / 60
1 0.8724 6 / 6 39 / 60
5 0.8739 6 / 6 1 / 60
20 0.8507 4 / 6 0 / 60
50 0.7491 2 / 6 0 / 60
This table explains Lasso properly, and what I first wrote was wrong.
I wrote "Lasso throws away the noise by itself" and used alpha=1 as the example. But at alpha=1 Lasso kept 39 of the 60 noise columns. In other words, it didn't do its job.
The real picture:
alpha=0.1: the penalty is too weak, 56/60 noise columns stayalpha=5: the sweet spot: 6/6 real features kept, 59/60 noise columns dropped, the highestR2alpha=20: the penalty is so strong that two of the real features get dropped tooalpha=50: only 2 of the 6 remain, andR2collapses
"Lasso selects features" is a half-truth
Textbooks use that sentence a lot and it misleads students. Lasso doesn't select on its own: it selects according to alpha, and you find alpha.
The table above shows it: one model, five alphas, completely different results.
That's why LassoCV or GridSearchCV (Chapter 24) is mandatory. Setting alpha=1 and hoping "Lasso will sort it out" doesn't work.
How you choose alpha
Not by hand: with GridSearchCV (Chapter 24). But sklearn also has a ready-made option:
from sklearn.linear_model import RidgeCV, LassoCV alphas = np.logspace(-3, 3, 40) rcv = Pipeline([("sc", StandardScaler()), ("m", RidgeCV(alphas=alphas, cv=5))]).fit(X_tr, y_tr) lcv = Pipeline([("sc", StandardScaler()), ("m", LassoCV(alphas=alphas, cv=5, max_iter=50000, random_state=42))]).fit(X_tr, y_tr) print(f"alpha chosen by RidgeCV : {rcv.named_steps['m'].alpha_:.4f}") print(f"alpha chosen by LassoCV : {lcv.named_steps['m'].alpha_:.4f}") print() from sklearn.metrics import r2_score print(f"RidgeCV test R2: {r2_score(y_te, rcv.predict(X_te)):.4f}") print(f"LassoCV test R2: {r2_score(y_te, lcv.predict(X_te)):.4f}")
alpha chosen by RidgeCV : 7.0170 alpha chosen by LassoCV : 0.0702 RidgeCV test R2: 0.8660 LassoCV test R2: 0.8665
Don't choose alpha on the test set
RidgeCV has cross-validation inside: it splits within train. That's correct.
If you write for alpha in [...] by hand, measure each one on test and pick the best, you've used the test set as validation (Chapter 6). Your result will be optimistic.
Regularisation in logistic regression
An important fact: in LogisticRegression, regularisation is on by default.
from sklearn.linear_model import LogisticRegression yc = df["ijaraga_berildi_30k"] print("LogisticRegression's C parameter (C = 1/alpha):") print(f"{'C':>8} {'penalty':>14} {'CV F1':>9} {'coef L2 norm':>17}") print("-" * 52) for C in [0.01, 0.1, 1.0, 100.0]: q = Pipeline([("sc", StandardScaler()), ("m", LogisticRegression(C=C, max_iter=3000))]) b = cross_val_score(q, X, yc, cv=5, scoring="f1") q.fit(X, yc) norm = np.linalg.norm(q.named_steps["m"].coef_) strength = "very strong" if C <= 0.01 else ("strong" if C < 1 else ("default" if C == 1 else "almost none")) print(f"{C:>8} {strength:>14} {b.mean():>9.4f} {norm:>17.4f}")
LogisticRegression's C parameter (C = 1/alpha):
C penalty CV F1 coef L2 norm
----------------------------------------------------
0.01 very strong 0.7258 0.7005
0.1 strong 0.7252 0.9195
1.0 default 0.7210 0.9819
100.0 almost none 0.7210 0.9930
C is the INVERSE of alpha
This is the most commonly confused thing in sklearn:
Ridge(alpha=...): large alpha = strong penaltyLogisticRegression(C=...)andSVC(C=...): large C = WEAK penalty
C = 1/alpha. So C=0.01 is very strong regularisation and C=100 is almost none.
It comes up in interviews, and in practice it's easy to write it the wrong way round.
Which one to choose
show(pd.DataFrame([ {"Situation": "Few features, all useful", "Choice": "LinearRegression or Ridge (small alpha)"}, {"Situation": "Many features, some useless", "Choice": "Lasso: it selects"}, {"Situation": "Many features AND related to each other", "Choice": "ElasticNet"}, {"Situation": "The coefficients must be stable", "Choice": "Ridge"}, {"Situation": "You need to know which features matter", "Choice": "Lasso (or SHAP, Chapter 26)"}, {"Situation": "More features than rows (m > n)", "Choice": "Ridge or Lasso: REQUIRED"}, ]))
| Situation | Choice |
|---|---|
| Few features, all useful | LinearRegression or Ridge (small alpha) |
| Many features, some useless | Lasso: it selects |
| Many features AND related to each other | ElasticNet |
| The coefficients must be stable | Ridge |
| You need to know which features matter | Lasso (or SHAP, Chapter 26) |
| More features than rows (m > n) | Ridge or Lasso: REQUIRED |
The last row matters: when m > n (for example 20 000 genes and 200 patients), plain linear regression finds infinitely many solutions, all of them perfect on train. Regularisation forces it to pick one.
What we learned today
show(pd.DataFrame([ {"Concept": "Regularisation", "In one sentence": "Cost = MSE + alpha * a penalty on the weights"}, {"Concept": "Ridge (L2)", "In one sentence": "Shrinks all of them, never to zero"}, {"Concept": "Lasso (L1)", "In one sentence": "Pushes some EXACTLY to zero: selects features"}, {"Concept": "Why L1 reaches zero", "In one sentence": "The derivative of |w| doesn't shrink near zero (diamond vs circle)"}, {"Concept": "ElasticNet", "In one sentence": "A mix of L1 and L2, for related features"}, {"Concept": "Scaling", "In one sentence": "MANDATORY in regularisation, so the penalty is fair"}, {"Concept": "C and alpha", "In one sentence": "C = 1/alpha, inverted; large C = weak penalty"}, {"Concept": "Fixing the instability", "In one sentence": "Ridge shrinks the spread of the coefficients"}, ]))
| Concept | In one sentence |
|---|---|
| Regularisation | Cost = MSE + alpha * a penalty on the weights |
| Ridge (L2) | Shrinks all of them, never to zero |
| Lasso (L1) | Pushes some EXACTLY to zero: selects features |
| Why L1 reaches zero | The derivative of |w| doesn't shrink near zero (diamond vs circle) |
| ElasticNet | A mix of L1 and L2, for related features |
| Scaling | MANDATORY in regularisation, so the penalty is fair |
| C and alpha | C = 1/alpha, inverted; large C = weak penalty |
| Fixing the instability | Ridge shrinks the spread of the coefficients |
That's the end of linear models. The next chapter brings a completely different idea: no gradient descent and no coefficients. Just questions and answers: the tree.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.