Skip to main content

Regularization

Examples

Ridge regression (L2)

Top left, the data and the fitted line. Top right, the level lines of the regularized loss, with the fit at its minimum. Bottom left, the two coefficients along the whole path. Bottom right, the level lines of the unregularized loss, the same path, and the constraint region \(\|\theta\|_2^2 \le s\) that this \(\lambda\) corresponds to. At \(\lambda = 0\) nothing is constrained, so the whole plane is allowed.

At \(\lambda_2 = 0\) the fit is \(\beta_0 = 0.074\), \(\beta_1 = 0.083\). Nothing happens until about \(\lambda_2 = 10^{-3}\); then both coefficients are pulled towards the origin, \(\beta_1\) faster. At \(\lambda_2 = 562\) they are 0.0002 and 0.0001 — small, but not zero. The two right hand plots are the same statement twice: the penalty moves the centre of the level lines, and the point where the path leaves the circle is where the unregularized loss is smallest among the parameters that penalty allows.

See scikit-learn implementation if you want to use L2 regularization.

Regularization can decrease the variance, but it may increase the bias:

the simulation behind the figure
import numpy as np
import matplotlib.pyplot as plt

lams = np.logspace(-6, 4, 41)
loglam = np.log10(lams)
sim = np.random.default_rng(12)

test_x = sim.uniform(0, 1, 10**4)        # one test set, used by every fit
test_y = 0.1 * test_x + 0.2 * sim.standard_normal(10**4)
mx, mxx = test_x.mean(), (test_x**2).mean()
my, myy = test_y.mean(), (test_y**2).mean()
mxy = (test_x * test_y).mean()

def ridge_path(x, y):                    # all 41 penalties at once
    a, b, c, d = x.mean(), y.mean(), (x**2).mean(), (x * y).mean()
    b1 = (d - a * b / (1 + lams)) / (c - a**2 / (1 + lams) + lams)
    return (b - b1 * a) / (1 + lams), b1

B0, B1 = np.empty((41, 500)), np.empty((41, 500))
for k in range(500):
    x = sim.uniform(0, 1, 10)
    B0[:, k], B1[:, k] = ridge_path(x, 0.1 * x + 0.2 * sim.standard_normal(10))

# the test error from the test set's moments: exact, and it turns 20 500
# passes over 10 000 points into one array expression
mse = (B0**2 + 2 * B0 * B1 * mx - 2 * B0 * my
       + B1**2 * mxx - 2 * B1 * mxy + myy)

fig, ax = plt.subplot_mosaic([["b0", "mse"], ["b1", "mse"]], figsize=(8.8, 3.6))
cloud = dict(s=1.5, alpha=0.25, color="#1f5f7a", linewidths=0, rasterized=True)
xs = np.repeat(loglam, 500)

for key, vals, truth in (("b0", B0, 0.0), ("b1", B1, 0.1)):
    ax[key].scatter(xs, vals.ravel(), **cloud)
    ax[key].plot(loglam, vals.mean(axis=1), lw=2, color="#a8452f", label="average")
    ax[key].axhline(truth, lw=2, color="#4c7a5a", label="true value")
    ax[key].set(ylabel=rf"$\beta_{key[-1]}$", ylim=(-0.6, 0.6))
ax["b0"].tick_params(labelbottom=False)
ax["b0"].legend(fontsize=7, loc="upper right")
ax["b1"].set_xlabel(r"$\log_{10}\lambda_2$")

ax["mse"].scatter(xs, mse.ravel(), **cloud)
ax["mse"].plot(loglam, mse.mean(axis=1), lw=2, color="#a8452f", label="average")
ax["mse"].axhline(0.2**2, lw=2, color="#4c7a5a", label="irreducible error")
ax["mse"].set(xlabel=r"$\log_{10}\lambda_2$", ylabel="test MSE", ylim=(0.03, 0.1))
ax["mse"].legend(fontsize=7)
plt.show()
Figure 17.1: Each dot is one of 500 training sets of ten points, fitted at every penalty. Left, the two coefficients with their average and the value the generator used. Right, the test error of each fit with its average and the irreducible error.

Unpenalized, the estimate of \(\beta_1\) is unbiased but useless: its average over the 500 training sets is 0.109, right on the generator’s 0.1, but its standard deviation is 0.25 — two and a half times the quantity being estimated. The penalty collapses that scatter and drags the average off the truth with it. At the best penalty, \(\log_{10}\lambda_2 = 0.25\), the spread is down to 0.014 and the average is 0.012, and the test error is at its lowest: 0.042 against 0.050 unpenalized, for an irreducible error of 0.040. When the regularization gets stronger, the slope is too strongly biased towards 0, and this bias dominates the error.

Lasso (L1)

The same four plots for the L1 penalty. The constraint region is a diamond with corners on the axes.

The coefficients do not approach zero here, they arrive: \(\beta_1\) is exactly 0 from \(\lambda_1 = 0.018\) onwards and \(\beta_0\) from 0.178. The corners of the diamond sit on the axes, and a corner is where one coefficient is zero, which is why L1 selects predictors and L2 only shrinks them.

See scikit-learn implementation if you want to use L1 regularization.

Polynomial ridge regression

The same penalty applies to any model, polynomial regression included.

A polynomial of the chosen degree fitted to the regression data with an L2 penalty \(\lambda\) on its coefficients. The readout is the mean squared distance between the fit and the data generator over \([0, 1]\).

At degree 20 with \(\lambda = 0\) the reducible error is 11. At \(\lambda = 10^{-4}\) it is 0.0020, better than a degree 4 polynomial reaches at the same \(\lambda\) (0.0075). Raise \(\lambda\) further and the fit straightens out and underfits instead.

The penalty goes on the end of the same pipeline that built the powers.

from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import Ridge

def f(x):
    return 0.3 * np.sin(10 * x) + 0.7 * x

gen = np.random.default_rng(3)                         # the panel's data set
xs = gen.uniform(0, 1, 50).reshape(-1, 1)
ys = f(xs).ravel() + 0.1 * gen.standard_normal(50)
grid = np.linspace(0, 1, 101).reshape(-1, 1)

for lam in [1e-10, 1e-4, 1e-1]:
    m = make_pipeline(PolynomialFeatures(20, include_bias=False),
                      Ridge(alpha=lam, solver="svd")).fit(xs, ys)
    print(f"lambda = {lam:<8g} reducible error = "
          f"{np.mean((m.predict(grid) - f(grid).ravel()) ** 2):.5f}")
1
A degree 20 design matrix is badly conditioned, and the default solver gives up on it at a penalty this small. solver="svd" does not.
lambda = 1e-10    reducible error = 0.00616
lambda = 0.0001   reducible error = 0.00204
lambda = 0.1      reducible error = 0.02041

The lasso path on the weather data

fitting the path and drawing it
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import lasso_path
from sklearn.preprocessing import StandardScaler

weather = pd.read_csv("https://go.epfl.ch/bio322-weather2015-2018.csv")
X = weather.iloc[:-5].drop(columns=["LUZ_wind_peak", "time"])
y = weather["LUZ_wind_peak"][5:].values

alphas, coefs, _ = lasso_path(StandardScaler().fit_transform(X), y,
                              alphas=np.logspace(1, -2, 60))

# one row per predictor, one column per lambda, largest lambda first, so the
# first non-zero entry of a row is where that predictor enters
nonzero = coefs != 0
entered = np.where(nonzero.any(axis=1), nonzero.argmax(axis=1), len(alphas))
first5 = np.argsort(entered, kind="stable")[:5]

fig, ax = plt.subplots(figsize=(8.8, 3.6))
loglam = np.log10(alphas)
ax.plot(loglam, coefs.T, lw=0.7, color="#b8bfc4", zorder=1)
for rank, (j, colour) in enumerate(zip(first5, ["#1f5f7a", "#a8452f", "#8a6a18",
                                                "#4c7a5a", "#6b5b8a"]), 1):
    ax.plot(loglam, coefs[j], lw=2, color=colour, zorder=2,
            label=f"{rank}. {X.columns[j]}")
ax.axhline(0, lw=0.7, color="#7a838b")
ax.set(xlabel=r"$\log_{10}\lambda$", ylabel="coefficient")
ax.legend(loc="upper left", fontsize=7.5, ncol=2, framealpha=0.9)
ax.set_ylim(-5, 5)
plt.show()

for rank, j in enumerate(first5, 1):
    print(f"{rank}. {X.columns[j]:22s} enters at lambda = {alphas[entered[j]]:.2f}")
print(f"{(entered == len(alphas)).sum()} of {len(X.columns)} never leave zero")
1
The penalty treats every coefficient the same, but the predictors are not: one measured in metres gets a coefficient a thousand times smaller than the same predictor in millimetres, so the penalty hits it a thousand times less. Standardize before regularizing.

The lasso path for the wind peak in Luzern five hours ahead, over all 55 predictors. The five that leave zero first are drawn in colour and named; the other 50 are grey.
1. BER_wind_peak          enters at lambda = 4.41
2. BAS_wind_peak          enters at lambda = 3.92
3. LUZ_wind_mean          enters at lambda = 3.92
4. GUT_wind_peak          enters at lambda = 2.76
5. GVE_wind_peak          enters at lambda = 2.45
6 of 55 never leave zero

Read it from the right. At \(\lambda = 10\) the penalty is stronger than any predictor is worth and every coefficient is zero. Moving left weakens it, and the predictors leave zero one at a time; the height of a line is the coefficient that predictor would have in the model fitted at that \(\lambda\).

The order they leave in is a ranking, but a ranking given what is already in, not of each predictor on its own. The wind peak in Bern comes first, then Basel and Luzern’s own mean wind together, then Güttingen and Geneva — upwind gusts and the wind already blowing. The first pressure reading waits until \(\lambda \approx 1.5\), and a handful of predictors never enter at all. That is what makes the path worth drawing: one run gives the whole family of models from empty to full, it says how many predictors are actually earning their place, and a predictor that only repeats one already in the model stays flat at zero instead of splitting a coefficient with it.