Skip to content

Regularisation: Ridge, Lasso, Elastic Net

Chapter 17 14 code blocks 4122 words

The code in this chapter uses these filesrun it on your own machine

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.

python7 lines
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}")
Output
weights: [ 3.  -4.   0.5]
  L2 penalty (sum of squares)    : 25.2500
  L1 penalty (absolute values)   : 7.5000
python11 lines
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"},
]))
ModelPenaltyEffect on the weightsWhen
RidgeL2: alpha * sum(w^2)SHRINKS all of them, never to zeroWhen every feature is a little useful
LassoL1: alpha * sum(|w|)Pushes some EXACTLY TO ZEROWhen you need feature selection
Elastic NetA mix of L1 and L2In between the twoMany features that are related to each other

First: scaling is MANDATORY here

python18 lines
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}")
Output
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 alpha doesn'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

python10 lines
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)}")
Output
     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.

python19 lines
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))
Modelxonalar: lowest..highestxonalar spreadmaydon_m2 spread
Plain (LinearRegression)54.5 .. 69.64.734.49
Ridge (alpha=10)62.5 .. 75.84.13.77
Ridge (alpha=100)82.6 .. 89.12.131.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

python16 lines
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}")
Output
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.

python8 lines
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))
Output
Features Lasso (alpha=1) kept: 17 of 83:
featurecoefficient
maydon_m2143.484
xonalar60.1
metro_km-51.271
mebel41.385
qavat16.221
metro_km^311.008
qavat^2 mebel-8.395
xonalar qavatlar_soni6.4
qavat^3-4.769
qavat qavatlar_soni3.871
maydon_m2 qavat^2-3.447
xonalar mebel3.177

Why L1 reaches zero and L2 doesn't

The reason is in the shape of the penalty.

  • The L2 penalty is w^2: as a weight approaches zero, so does the derivative. So it pulls the weight towards zero but never reaches it.
  • The L1 penalty 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

python11 lines
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}")
Output
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.

python15 lines
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}")
Output
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
python11 lines
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")
Output
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 stay
  • alpha=5: the sweet spot: 6/6 real features kept, 59/60 noise columns dropped, the highest R2
  • alpha=20: the penalty is so strong that two of the real features get dropped too
  • alpha=50: only 2 of the 6 remain, and R2 collapses

"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:

python12 lines
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}")
Output
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.

python13 lines
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}")
Output
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 penalty
  • LogisticRegression(C=...) and SVC(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

python8 lines
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"},
]))
SituationChoice
Few features, all usefulLinearRegression or Ridge (small alpha)
Many features, some uselessLasso: it selects
Many features AND related to each otherElasticNet
The coefficients must be stableRidge
You need to know which features matterLasso (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

python10 lines
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"},
]))
ConceptIn one sentence
RegularisationCost = 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 zeroThe derivative of |w| doesn't shrink near zero (diamond vs circle)
ElasticNetA mix of L1 and L2, for related features
ScalingMANDATORY in regularisation, so the penalty is fair
C and alphaC = 1/alpha, inverted; large C = weak penalty
Fixing the instabilityRidge 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.

Write on Telegram Contents