Ridge Regression From Scratch: Taming Collinear Features

BaseballAdvancedPython~5 min read

What you'll build

A ridge estimator built in pure numpy on three collinear MLB predictors where plain OLS is singular and fails outright, plus the coefficient shrinkage path as the penalty grows.

A ridge estimator built in pure numpy on three collinear MLB predictors where plain OLS is singular and fails outright, plus the coefficient shrinkage path as the penalty grows.
Data: Bundled sample (real 2023 MLB standings) + ridge regression, retrieved June 2026

Ordinary least squares carries a failure mode most tutorials never show you. When two predictors carry the same information, the matrix OLS has to invert goes singular — the coefficients are no longer uniquely defined, and the solver either refuses or returns garbage. This isn't a rare edge case; near-collinear features are everywhere in sports data. Ridge regression fixes it with one small change: add a penalty on the size of the coefficients, and the system becomes solvable and stable again. We'll build the closed-form ridge estimator in pure numpy, watch OLS fail where ridge succeeds, and trace how the coefficients shrink as the penalty grows.

This builds on Correlation and Regression (ridge is regression with a leash) and on Z-Scores (we standardize so the penalty is fair to every feature). The data is the bundled sample_standings.csv (real 2023 MLB standings), so it runs offline.

For the underlying statistics, read Correlation and Simple Linear Regression — a free DataField.dev chapter.

  1. Build a deliberately collinear problem

    We'll predict wins from three columns: runs scored, runs allowed, and run differential. The trap is that RunDiff is exactly RS - RA — it adds no new information, it's a linear copy of the other two. Standardize the features so the penalty treats them on one scale, and center the target so we can solve for slopes without an intercept.

    python
    import numpy as np, pandas as pd
    
    df = pd.read_csv("sample_standings.csv")
    feats = ["RS", "RA", "RunDiff"]          # RunDiff = RS - RA, exactly
    X = df[feats].to_numpy(float)
    Xs = (X - X.mean(0)) / X.std(0)          # standardize each column
    y = df["W"].to_numpy(float); yc = y - y.mean()
    XtX, Xty = Xs.T @ Xs, Xs.T @ yc
    OLS meets a singular matrix
    Features: ['RS', 'RA', 'RunDiff']
    Check: RunDiff == RS - RA ? True
    Rank of X'X: 2 (needs to be 3 to invert)
    OLS (lambda = 0) FAILED: singular matrix - the collinear column broke it.

    The rank of X'X is 2 where it needs to be 3, so plain OLS — ridge with a penalty of zero — throws a LinAlgError. The math literally cannot pick coefficients, because infinitely many combinations fit equally well.

  2. Add the ridge penalty

    Ridge minimizes the squared error plus λ times the squared length of the coefficient vector. That one extra term turns the closed-form solution into (X'X + λI)-1 X'y. Adding λ down the diagonal always makes the matrix invertible — the singular problem becomes solvable for any λ > 0.

    python
    def ridge_fit(lam):
        n = XtX.shape[0]
        return np.linalg.solve(XtX + lam * np.eye(n), Xty)
    
    for lam in (0.1, 1.0, 10.0, 100.0):
        print(lam, ridge_fit(lam))
    Ridge solves, and shrinks
    lambda=   0.1  RS=+3.788  RA=-4.673  RunDiff=+4.987  |w|=7.81
    lambda=   1.0  RS=+3.759  RA=-4.599  RunDiff=+4.927  |w|=7.72
    lambda=  10.0  RS=+3.448  RA=-4.005  RunDiff=+4.393  |w|=6.87
    lambda= 100.0  RS=+1.726  RA=-1.852  RunDiff=+2.110  |w|=3.30
    Ridge always solves - and the coefficients shrink as lambda grows.

    At λ=0.1 the coefficient vector has length 7.81; by λ=100 it's down to 3.30. The penalty is doing exactly what it promises — pulling the weights toward zero, harder as λ rises.

  3. Trace the shrinkage path

    The signature plot of ridge is the coefficient path: each weight against λ on a log scale. Sweep λ across several orders of magnitude and stack the fits.

    python
    import matplotlib.pyplot as plt
    lambdas = np.logspace(-2, 3, 60)
    paths = np.array([ridge_fit(l) for l in lambdas])   # (60, 3)
    
    fig, ax = plt.subplots()
    for j, name in enumerate(feats):
        ax.plot(lambdas, paths[:, j], label=name)
    ax.set_xscale("log"); ax.legend()
    fig.savefig("ridge_path.png", dpi=144, bbox_inches="tight")
    Three coefficient paths for RS, RA and RunDiff plotted against ridge penalty lambda on a log x-axis, all curving toward zero as lambda grows
    Data: Bundled sample (real 2023 MLB standings) + ridge regression, retrieved June 2026

    Read it right to left. At small λ the coefficients sit at their large, unstable values; as λ grows every curve bends toward the zero line. That is the whole idea of regularization in one picture: trade a little bias (coefficients pulled off their least-squares values) for a lot of stability (a solvable, less jumpy model). The same penalty is what keeps models with dozens of overlapping features from overfitting.

Troubleshooting

My OLS didn't error — it returned huge numbers

If your features are nearly collinear rather than exactly collinear, X'X is technically invertible but ill-conditioned, so solve succeeds and hands back wild, sign-flipping coefficients that swing violently on a tiny data change. That instability is the softer version of the same disease, and ridge is the same cure.

How do I choose λ?

Don't eyeball it — pick the λ that minimizes error on data the model didn't train on. That's exactly what k-fold cross-validation is for: score a grid of λ values by cross-validated error and take the best. Ridge without cross-validation is half a method.

Why standardize first?

The penalty is on the raw coefficient sizes, so a feature measured in small units gets an unfairly large coefficient and absorbs more of the penalty. Standardizing puts every feature on a unit-variance scale so λ penalizes them evenhandedly. Note we also don't penalize the intercept — centering the target lets us drop it cleanly.

Challenge yourself

Add a fourth, genuinely informative feature (say, one-run-game record if you have it) and watch its path behave differently from the collinear trio. Then implement the sister method, lasso (an L1 penalty), with coordinate descent and compare the paths — lasso drives coefficients to exactly zero, doing feature selection, while ridge only shrinks them. Finally, wire in cross-validation to pick λ and report the test error at the chosen value versus at λ=0.

Get the code

Here's the complete, working script for this tutorial. It runs exactly as shown.

Download the finished script (77_ridge_regression_from_scratch.py)

This script imports a small shared helper (and reads any bundled sample data) that live next to it in /downloads/ — grab these into the same folder so it runs as-is: sdt_common.py.

More Baseball tutorials

Your first real Statcast pull, cached, with an exit-velocity histogram.
Baseball Beginner

Pull Your First MLB Data with pybaseball

Install pybaseball, turn on caching, and pull a week of real Statcast data — ending with an exit-velocity histogram that proves the data is really there.

~8 min
A pitch-location heatmap for one pitcher with the strike zone drawn on top.
Baseball Intermediate

Make a Pitch-Location Heatmap in Python

Use a single pitcher's Statcast data to build a 2-D location heatmap, draw the strike zone from the catcher's view, and read what the hot spots tell you.

~8 min