Ch.14: Ridge Regression: The Math and the Code
Inspired by: YouTube
In the previous post, we established why Ridge Regression works: appending a penalty term to the OLS loss function makes large, unstable coefficients expensive, which pulls the fitted line away from a perfect but brittle fit on the training set and toward one that generalizes better.
This post picks up where that one left off. We derive the closed-form formula that actually produces the shrunk coefficient, first for the single-feature case, then for the general n-dimensional case using matrix calculus, and implement both from scratch to confirm they land on exactly the coefficients Scikit-Learn's Ridge class does.
1. Deriving the Closed-Form Solution for One Feature
Recall simple linear regression's loss with the L2 penalty added:
To minimize , take partial derivatives with respect to and and set both to zero.
Differentiating with respect to :
The term vanishes since it doesn't involve . Solving this gives the exact same formula for as unregularized OLS:
The intercept is untouched by Ridge. Only the slope changes.
Differentiating with respect to :
Substituting and simplifying (this reuses the exact same expansion as the derivation in the Ch.2 simple linear regression post, just with an extra term carried through), the terms collect onto one side:
Compare this to plain OLS, where . The numerator is identical. The only change is added to the denominator: raise , and the denominator grows, so shrinks toward zero. At the formula collapses back to OLS exactly. The intercept formula, by contrast, doesn't reference at all, so it barely moves.
2. Watching the Formula Shrink Real Coefficients
Fitting sklearn.linear_model.Ridge at alpha=0, alpha=10, and alpha=100 on a synthetic single-feature dataset should make that denominator effect visible directly in the coefficients:
from sklearn.datasets import make_regression
from sklearn.linear_model import LinearRegression, Ridge
X, y = make_regression(n_samples=100, n_features=1, n_informative=1,
n_targets=1, noise=20, random_state=13)
lr = LinearRegression()
lr.fit(X, y)
print(lr.coef_, lr.intercept_)
# [27.83] -2.29
rr = Ridge(alpha=10)
rr.fit(X, y)
print(rr.coef_, rr.intercept_)
# [24.95] -2.13
rr1 = Ridge(alpha=100)
rr1.fit(X, y)
print(rr1.coef_, rr1.intercept_)
# [12.93] -1.42The slope drops from 27.83 (unregularized) to 24.95 (alpha=10) to 12.93 (alpha=100) exactly as the formula predicts, while the intercept moves by comparison very little, -2.29 to -2.13 to -1.42. Plotting all three fitted lines on the same axes shows the flattening directly:
The red alpha=0 line has the steepest slope and passes closest through the bulk of the points. The green alpha=10 line is visibly shallower, and the orange alpha=100 line is shallower still, on its way toward flattening into a horizontal line predicting the mean as .
3. Coding the Formula from Scratch
class MeraRidge:
def __init__(self, alpha=0.1):
self.alpha = alpha
self.m = None
self.b = None
def fit(self, X_train, y_train):
num = 0
den = 0
for i in range(X_train.shape[0]):
num = num + (y_train[i] - y_train.mean()) * (X_train[i] - X_train.mean())
den = den + (X_train[i] - X_train.mean()) * (X_train[i] - X_train.mean())
self.m = num / (den + self.alpha)
self.b = y_train.mean() - (self.m * X_train.mean())Running MeraRidge(alpha=10).fit(X, y) prints m=24.95, b=-2.13, and MeraRidge(alpha=100).fit(X, y) prints m=12.93, b=-1.42, matching Ridge to four decimal places. The formula holds.
4. Extending to n Dimensions: The Matrix Form
The single-feature derivation doesn't scale to datasets with multiple features. For that, the loss needs to be written in matrix form, the same way Ch.6 rewrote plain multiple linear regression before deriving its closed-form solution.
Stack the training rows into a design matrix (with a leading column of ones for the intercept) and the weights, including the intercept , into a single vector :
Plain linear regression's loss, , becomes in matrix form. Ridge adds the penalty (the dot product of with itself is exactly ):
Expanding the first term using :
Since and are both scalars and transposes of each other, they're equal, so they combine:
Now differentiate with respect to . Two standard matrix-calculus identities are needed here:
- when is symmetric (here , which is always symmetric).
Applying these term by term:
Dividing by 2 and isolating :
This is Ridge's closed-form solution. It's the same normal equation from plain linear regression, , with one change: added before inverting. here is the identity matrix, matching the shape of .
5. Implementing the Matrix Solution
The formula translates directly into code with numpy, but there's one subtlety: the intercept should never be shrunk toward zero, since it's just centering the predictions, not controlling the model's sensitivity to any feature. If is applied naively, the identity matrix's [0][0] entry regularizes along with every other weight. The fix is to zero out that one entry before adding it in:
In the weight vector , index
0is the intercept , not a slope, since was built with a leading column of ones.I[0][0] = 0turns off regularization for exactly that position, so when is added to , ends up multiplied only against the slope terms , leaving the intercept free to shift wherever the data needs it to.
import numpy as np
class MeraRidge:
def __init__(self, alpha=0.1):
self.alpha = alpha
self.coef_ = None
self.intercept_ = None
def fit(self, X_train, y_train):
X_train = np.insert(X_train, 0, 1, axis=1)
I = np.identity(X_train.shape[1])
I[0][0] = 0
result = np.linalg.inv(np.dot(X_train.T, X_train) + self.alpha * I).dot(X_train.T).dot(y_train)
self.intercept_ = result[0]
self.coef_ = result[1:]
def predict(self, X_test):
return np.dot(X_test, self.coef_) + self.intercept_Testing this against sklearn.linear_model.Ridge on the diabetes dataset:
from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split
from sklearn.linear_model import Ridge
from sklearn.metrics import r2_score
X, y = load_diabetes(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=4)
reg = Ridge(alpha=0.1, solver='cholesky')
reg.fit(X_train, y_train)
y_pred = reg.predict(X_test)
print(r2_score(y_test, y_pred)) # 0.4693
print(reg.coef_[:3], reg.intercept_) # [ 44.02 -241.69 452.99] 150.89
mera = MeraRidge(alpha=0.1)
mera.fit(X_train, y_train)
y_pred2 = mera.predict(X_test)
print(r2_score(y_test, y_pred2)) # 0.4693
print(mera.coef_[:3], mera.intercept_) # [ 44.02 -241.69 452.99] 150.89Both the score and every coefficient match to the fourth decimal place. Ridge(solver='cholesky') computes the exact same closed-form matrix equation internally, just with a numerically more stable factorization than a direct matrix inverse.
6. Why This Isn't the Whole Story
The closed-form solution is exact, and there's something satisfying about deriving by hand and watching it reproduce Scikit-Learn's numbers to four decimal places. But it depends on computing , a matrix inverse of a matrix, which costs roughly .
That's a non-issue for the diabetes dataset's 10 features. It becomes a real bottleneck once climbs into the thousands, the kind of feature counts common with one-hot encoded categorical data or bag-of-words text representations. At that scale, computing a matrix inverse directly stops being practical.
The next post covers the alternative: solving the same regularized loss with gradient descent instead of a direct matrix inversion, the same iterative approach from Ch.7 adapted to include the term in the gradient update.
