Linear regression

Linear regression is the model every other model gets measured against, and it packs a surprising amount of numerical linear algebra into one line of math. This page derives least squares from a Gaussian noise assumption, derives the normal equations and then explains why you should never solve them by inverting XTX, works a small conditioning example that makes the danger concrete, and treats gradient descent and ridge regression as the two standard escapes when the problem gets large or ill-posed. Closed-form and gradient-descent implementations follow in both PyTorch and JAX, checked against scikit-learn and statsmodels.

What it is and when you reach for it

Linear regression predicts a continuous target as a weighted sum of features: ŷ = w1x1 + … + wdxd + b. That restriction to a hyperplane is the whole point. It makes the model trainable in closed form, interpretable coefficient by coefficient, cheap enough to fit thousands of times in a hyperparameter sweep, and honest about what it cannot represent. Among its neighbors, it sits one step below logistic regression (same linear score, squashed through a sigmoid for classification), one step above predicting the mean (which is linear regression with no features), and at the base of everything with "linear layer" in its name: the final layer of nearly every deep network is a linear regression or classification head over learned features. You reach for it first when you need a baseline, when you need coefficients you can explain to a stakeholder or a reviewer, when you have far more rows than you have compute budget, and whenever the honest answer to "do we need a deep model here?" has not yet been established. A tuned gradient-boosted tree or a neural net that cannot beat a well-featurized linear model is telling you something about your data, and a linear baseline is the only way to hear it.

The math

Least squares from Gaussian maximum likelihood

Why squared error and not, say, absolute error? The clean answer comes from a noise model. Assume each observation is the linear signal plus independent Gaussian noise: yi = xiTw + εi with εi ~ N(0, σ2). The likelihood of the dataset is the product of Gaussian densities, and its logarithm is

log p(y | X, w) = −(n/2) log(2πσ2) − (1 / 2σ2) Σi (yi − xiTw)2.

The first term does not involve w, and the second is a negative constant times the sum of squared residuals. Maximizing the likelihood over w is therefore exactly minimizing L(w) = ‖y − Xw‖2: least squares is not an arbitrary choice of loss, it is the maximum-likelihood estimator under Gaussian noise. The assumption is also the fine print. If the noise is heavy-tailed, a few outliers dominate the squared loss and drag the fit; under Laplace noise the same derivation yields absolute error and the median takes the place of the mean. When people say least squares is "sensitive to outliers", this is the precise sense: it is optimal for a noise distribution that makes outliers extremely improbable.

The normal equations

Stack the samples into X (n × d, with a column of ones appended so the intercept is just another weight) and expand the loss: L(w) = yTy − 2wTXTy + wTXTXw. The gradient is ∇L = −2XTy + 2XTXw, and setting it to zero gives the normal equations:

XTX w = XTy.

Because XTX is positive semidefinite, the loss is convex and any stationary point is a global minimum. The equations also have a geometry worth keeping in your head: they say XT(y − Xw) = 0, that is, the residual is orthogonal to every column of X. The prediction Xw* is the orthogonal projection of y onto the column space of X, the closest point to y that the features can express.

        y
        |\
        | \   residual  r = y − Xw*
        |  \  (orthogonal to the plane: Xᵀr = 0)
        |   \
  ------+----*------------------------
       /    Xw*  = projection of y
      /     column space of X (all reachable predictions)
          

Why you do not invert XTX: a worked conditioning example

The textbook next step is w = (XTX)−1 XTy, and production code should essentially never do it. The problem is conditioning. The condition number κ(A) = σmaxmin measures how much a matrix amplifies relative error, and forming XTX squares it: κ(XTX) = κ(X)2. Any algorithm that touches XTX, whether it inverts it or solves it by Cholesky, pays the squared condition number; an algorithm that factorizes X directly pays only κ(X).

Here is the smallest example that shows the squaring destroying real information. Take two nearly collinear columns:

X = [[1, 1], [1, 1.0001]],  so   XTX = [[2, 2.0001], [2.0001, 2.00020001]].

The singular values of X are σ1 ≈ 2.00005 and σ2 ≈ 5.0 × 10−5, so κ(X) ≈ 4 × 104 and κ(XTX) ≈ 1.6 × 109. Now look at where the information actually lives. det(XTX) = 2 × 2.00020001 − 2.00012 = 10−8: everything that distinguishes the two columns sits in the eighth decimal digit of the entry 2.00020001. Float32 carries about seven decimal digits, with a spacing of roughly 2.4 × 10−7 between representable numbers near 2.0, so merely storing XTX in float32 rounds each entry by up to about 1.2 × 10−7, an error more than ten times larger than the determinant itself. The computed determinant of the stored matrix is rounding noise; it can even come out negative, at which point the "covariance" matrix is numerically indefinite and a Cholesky factorization simply fails. The rule-of-thumb bound says the relative error of a solve scales like κ times machine epsilon: through the normal equations that is κ2(X) · ε ≈ 1.6 × 109 × 1.2 × 10−7 ≈ 190, meaning no correct digits at all, while a QR factorization of X pays κ(X) · ε ≈ 5 × 10−3, about two to three good digits. Same data, same precision, and the only difference is whether the algorithm ever formed XTX. Squaring the condition number halves the number of digits you get to keep, and float32 does not have that many digits to spare.

The safe path is a factorization of X itself. QR writes X = QR with Q orthonormal and R upper triangular; then ‖y − Xw‖ = ‖QTy − Rw‖ and the solution comes from back-substituting the triangular system Rw = QTy. Orthogonal transformations do not change lengths, so nothing gets amplified, and Householder QR is backward stable. This is exactly what lstsq routines do: LAPACK's gelsy driver is QR with column pivoting, gelsd is an SVD-based solve that also handles rank deficiency gracefully, and both are reachable from torch.linalg.lstsq, while JAX's jnp.linalg.lstsq goes through the SVD. In practice the rule is short: to solve a least-squares problem, call lstsq; if you must solve the normal equations (for example because ridge already regularized them), use a Cholesky solve, and reserve explicit matrix inversion for the rare case where you need the inverse itself, such as coefficient covariance matrices in statistics.

Gradient descent: the scalable alternative

A QR factorization costs O(nd2), which is wonderful until n or d stops fitting. When the data streams from disk, when d is in the millions (text n-grams, hashed features), or when the "linear regression" is the last layer of a network being trained anyway, you trade the exact solve for iterations of gradient descent. With L(w) = (1/2n)‖Xw − y‖2 the gradient is ∇L = XT(Xw − y)/n, one matrix-vector product per step, and the update is w ← w − η ∇L. Convexity guarantees convergence for any step size below 2/L where L = σmax2/n is the largest eigenvalue of the Hessian XTX/n, and the convergence speed is governed by the same villain as before: the error contracts by roughly a factor (κ − 1)/(κ + 1) per step, where κ is the condition number of the Hessian. Badly scaled features make κ huge and gradient descent crawl, which is why the implementation below standardizes columns first; after standardization every feature has unit variance, the Hessian's eigenvalues are all O(1), and a single global learning rate works. Minibatch SGD is the same story with noisier gradients, and it is the version that scales to datasets that never fit in memory.

Ridge regression

When columns are nearly collinear, as in the worked example, the least-squares solution exists but is wildly sensitive: tiny noise in y swings the coefficients by enormous amounts along the ill-conditioned direction. Ridge regression adds a penalty, minimizing ‖y − Xw‖2 + λ‖w‖2, with solution (XTX + λI) w = XTy. The effect on conditioning is direct: every eigenvalue of XTX gains λ, so the condition number drops from σmax2min2 to (σmax2 + λ)/(σmin2 + λ), and directions the data barely determines get shrunk toward zero instead of exploding. Probabilistically, ridge is the MAP estimate under a Gaussian prior w ~ N(0, τ2I), with λ = σ22; the penalty is literally a prior belief that coefficients are small. Two practical rules follow from the math. The penalty is not scale-invariant, so standardize features before ridging or λ punishes coefficients for the units their features happen to be measured in. And never penalize the intercept: shrinking b toward zero would bias every prediction toward zero for no reason, which is why implementations either center the data first or exclude the intercept column from the penalty, as the code below does. Numerically, you still avoid the explicit inverse: ridge is exactly ordinary least squares on an augmented system, X stacked on top of √λ·I and y padded with zeros, so one lstsq call solves it stably.

Implementation, twice

First the closed-form path. Both versions append a ones column so the intercept is an ordinary weight, and both hand the problem to the library's lstsq, which factorizes X rather than forming XTX. Ridge reuses the same routine through the augmented system, keeping the intercept column out of the penalty block. The JAX version turns on float64 explicitly, because JAX defaults to float32 and the conditioning section above is exactly the argument for why a least-squares solver wants the extra digits.

import torch

def add_intercept(X):
    """Append a ones column: the bias becomes just another weight."""
    ones = torch.ones(X.shape[0], 1, dtype=X.dtype, device=X.device)
    return torch.cat([X, ones], dim=1)

def fit_lstsq(X, y):
    """Least squares via torch.linalg.lstsq.

    lstsq factorizes X itself (QR with pivoting on CPU), never
    forming X^T X, so the solve pays cond(X) rather than cond(X)^2.
    Returns w of shape (d + 1,); the last entry is the intercept.
    """
    Xb = add_intercept(X)
    return torch.linalg.lstsq(Xb, y.unsqueeze(-1)).solution.squeeze(-1)

def fit_ridge(X, y, lam):
    """Ridge as plain least squares on an augmented system.

    min ||Xw - y||^2 + lam * ||w||^2  is exactly lstsq on
    [X; sqrt(lam) * I] with y padded by zeros. The intercept
    column stays out of the penalty block, so it is not shrunk.
    """
    n, d = X.shape
    Xb = add_intercept(X)
    pen = torch.zeros(d, d + 1, dtype=X.dtype, device=X.device)
    pen[:, :d] = lam ** 0.5 * torch.eye(d, dtype=X.dtype, device=X.device)
    A = torch.cat([Xb, pen])
    b = torch.cat([y, torch.zeros(d, dtype=X.dtype, device=X.device)])
    return torch.linalg.lstsq(A, b.unsqueeze(-1)).solution.squeeze(-1)

def predict(X, w):
    return add_intercept(X) @ w
import jax
jax.config.update("jax_enable_x64", True)  # least squares wants float64
import jax.numpy as jnp

def add_intercept(X):
    """Append a ones column: the bias becomes just another weight."""
    return jnp.concatenate([X, jnp.ones((X.shape[0], 1), X.dtype)], axis=1)

def fit_lstsq(X, y):
    """Least squares via jnp.linalg.lstsq (SVD-based).

    The SVD route factorizes X itself, never forming X^T X, so the
    solve pays cond(X) rather than cond(X)^2, and it handles
    rank-deficient X gracefully. Last entry of w is the intercept.
    """
    Xb = add_intercept(X)
    w, *_ = jnp.linalg.lstsq(Xb, y, rcond=None)
    return w

def fit_ridge(X, y, lam):
    """Ridge as plain least squares on an augmented system.

    min ||Xw - y||^2 + lam * ||w||^2  is exactly lstsq on
    [X; sqrt(lam) * I] with y padded by zeros. The intercept
    column stays out of the penalty block, so it is not shrunk.
    """
    n, d = X.shape
    Xb = add_intercept(X)
    pen = jnp.concatenate(
        [lam ** 0.5 * jnp.eye(d, dtype=X.dtype),
         jnp.zeros((d, 1), X.dtype)], axis=1)
    A = jnp.concatenate([Xb, pen])
    b = jnp.concatenate([y, jnp.zeros(d, X.dtype)])
    w, *_ = jnp.linalg.lstsq(A, b, rcond=None)
    return w

def predict(X, w):
    return add_intercept(X) @ w

Now the gradient-descent version, the shape of the computation that survives when the closed form does not scale. Both implementations standardize columns so one global learning rate works, pick that rate from the spectral norm of the standardized matrix (the safe step is anything below 2/L; the code uses 1/L), and fold the standardization back into raw-feature coefficients at the end so the returned weights are directly comparable to the closed form. The PyTorch tab leans on autograd, since in practice this loop is what an optimizer does inside a larger model; the JAX tab expresses the same loop as a lax.scan over jitted steps, which compiles the whole training loop into one XLA program.

import torch

def fit_gd(X, y, steps=500, lam=0.0):
    """Full-batch gradient descent on (1/2n)||Xw + b - y||^2.

    Standardizing columns first is what makes a single global
    learning rate work: afterwards the Hessian eigenvalues are
    all O(1), so cond is small and convergence is fast.
    """
    n, d = X.shape
    mu, sigma = X.mean(0), X.std(0).clamp_min(1e-12)
    Xs = (X - mu) / sigma

    # largest Hessian eigenvalue L = sigma_max(Xs)^2 / n; step 1/L
    L = torch.linalg.matrix_norm(Xs, ord=2) ** 2 / n
    lr = 1.0 / L

    w = torch.zeros(d, dtype=X.dtype, requires_grad=True)
    b = torch.zeros((), dtype=X.dtype, requires_grad=True)
    for _ in range(steps):
        resid = Xs @ w + b - y
        loss = (resid ** 2).mean() / 2 + lam * (w ** 2).sum() / 2
        loss.backward()
        with torch.no_grad():
            w -= lr * w.grad
            b -= lr * b.grad
        w.grad = None
        b.grad = None

    # fold standardization back into raw-feature coefficients
    with torch.no_grad():
        w_raw = w / sigma
        b_raw = b - (w_raw * mu).sum()
    return w_raw, b_raw
import jax
import jax.numpy as jnp

def fit_gd(X, y, steps=500, lam=0.0):
    """Full-batch gradient descent on (1/2n)||Xw + b - y||^2.

    Standardizing columns first is what makes a single global
    learning rate work: afterwards the Hessian eigenvalues are
    all O(1), so cond is small and convergence is fast.
    lax.scan compiles the whole loop into one XLA program.
    """
    n, d = X.shape
    mu = X.mean(0)
    sigma = jnp.maximum(X.std(0), 1e-12)
    Xs = (X - mu) / sigma

    # largest Hessian eigenvalue L = sigma_max(Xs)^2 / n; step 1/L
    L = jnp.linalg.norm(Xs, ord=2) ** 2 / n
    lr = 1.0 / L

    def loss(params):
        w, b = params
        resid = Xs @ w + b - y
        return (resid ** 2).mean() / 2 + lam * (w ** 2).sum() / 2

    def step(params, _):
        g_w, g_b = jax.grad(loss)(params)
        w, b = params
        return (w - lr * g_w, b - lr * g_b), None

    init = (jnp.zeros(d, X.dtype), jnp.zeros((), X.dtype))
    (w, b), _ = jax.lax.scan(step, init, None, length=steps)

    # fold standardization back into raw-feature coefficients
    w_raw = w / sigma
    return w_raw, b - w_raw @ mu

Note the regularization bookkeeping: the gradient-descent loss uses a mean over samples while the closed-form ridge uses a sum, so the same amount of shrinkage requires lamgd = lamlstsq/n. This factor-of-n mismatch is one of the most common reasons two ridge implementations "disagree".

Using it on a real shape of problem

A realistic tabular shape: fifty thousand rows, sixty-four features, moderate noise. The snippet fits both paths and checks that they agree with each other and with the ground truth. With noise standard deviation 0.5 and n = 50,000, the standard error of each coefficient is about 0.5/√n ≈ 0.002, so recovered weights should sit within a few thousandths of the truth; exact digits are machine- and seed-dependent.

import torch
torch.manual_seed(0)

n, d = 50_000, 64
X = torch.randn(n, d, dtype=torch.float64)
w_true = torch.randn(d, dtype=torch.float64)
y = X @ w_true + 3.0 + 0.5 * torch.randn(n, dtype=torch.float64)

w = fit_lstsq(X, y)
print((w[:-1] - w_true).abs().max())   # ~1e-2 or below: recovery
print(w[-1])                           # ~3.0: the intercept

w_gd, b_gd = fit_gd(X, y, steps=500)
print((w_gd - w[:-1]).abs().max())     # tiny: both paths agree
print(((predict(X, w) - y) ** 2).mean())  # ~0.25 = noise variance
import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp

key = jax.random.PRNGKey(0)
kx, kw, ke = jax.random.split(key, 3)

n, d = 50_000, 64
X = jax.random.normal(kx, (n, d), jnp.float64)
w_true = jax.random.normal(kw, (d,), jnp.float64)
y = X @ w_true + 3.0 + 0.5 * jax.random.normal(ke, (n,), jnp.float64)

w = fit_lstsq(X, y)
print(jnp.abs(w[:-1] - w_true).max())  # ~1e-2 or below: recovery
print(w[-1])                           # ~3.0: the intercept

w_gd, b_gd = fit_gd(X, y, steps=500)
print(jnp.abs(w_gd - w[:-1]).max())    # tiny: both paths agree
print(((predict(X, w) - y) ** 2).mean())  # ~0.25 = noise variance

What to expect: the training mean squared error should flatten out near the true noise variance (0.25 here), and pushing it lower than that is by definition fitting noise. On this well-conditioned random design, gradient descent matches the closed form in a few hundred steps; if you delete the standardization and rescale one column by 1000, watch the same loop take thousands of steps or diverge, which is the condition-number story made visible.

Applications

Linear regression earns its keep first as the universal baseline. Any tabular prediction task, churn, revenue, delivery time, energy use, starts with a linear fit because it is the cheapest strong model and because everything more complex must justify itself against it. In forecasting, linear models with engineered features remain the production workhorse: short-term electricity load forecasting regresses demand on temperature, lagged load, and calendar dummies; retail demand and capacity planning do the same with promotions and seasonality features. Quantitative finance is built on it: CAPM and the Fama-French factor models are literally ordinary least squares of returns on factor returns, and "beta" is a regression coefficient. Experimentation platforms use regression adjustment to shrink A/B test variance, and covariate-adjustment schemes like CUPED are regression in light disguise. In the sciences it is the default instrument-calibration and effect-estimation tool, and econometrics is to a first approximation the study of when a regression coefficient can be read causally.

The application closest to modern deep learning research is the linear probe. To measure what a frozen network has learned, you extract activations at some layer and fit a linear model from them to a property of interest; the probe's accuracy measures how linearly accessible that information is. Alain and Bengio's linear classifier probes made this a standard diagnostic, the CLIP paper reported linear-probe accuracy across dozens of datasets as its headline representation-quality metric, and interpretability work on language models probes for syntax, position, and world state with small linear maps (Hewitt and Manning's structural probe is a single linear transformation). The linear-probe workflow is exactly the ridge regression on this page, applied to a design matrix of frozen activations, and knowing the closed form means a probe sweep over layers and penalties costs seconds, not GPU-hours. The same trick appears as the standard evaluation for self-supervised vision models (SimCLR, DINO and their descendants report linear evaluation on ImageNet features) and in fine-tuning practice, where training only the final linear head is the cheapest adaptation baseline.

Against the real libraries

The reference implementations above are a few dozen lines; here is what the production libraries add and when you should switch. scikit-learn's LinearRegression is the same computation, routed through LAPACK's SVD-based gelsd driver via SciPy, with the ergonomics that actually matter in practice: sparse input support, multi-target fitting, sample weights, a positive=True option that switches to non-negative least squares, and seamless composition with Pipeline, StandardScaler, and cross-validation. Its Ridge adds a menu of solvers (Cholesky, SVD, LSQR, sparse conjugate gradient, SAG/SAGA for huge n) chosen automatically by problem shape, and RidgeCV exploits the SVD to evaluate leave-one-out cross-validation across a whole grid of penalties at essentially the cost of one fit, something a from-scratch loop cannot touch. Note the objective convention: sklearn's alpha multiplies ‖w‖2 against a sum-of-squares data term, so it matches the lam of the closed-form ridge above directly, and equals n times the lam of the gradient-descent version.

statsmodels answers a different question. Where sklearn optimizes prediction, statsmodels.api.OLS delivers inference: standard errors, t-statistics and p-values per coefficient, confidence intervals, R2 and adjusted R2, AIC/BIC, heteroskedasticity-robust covariance options (HC0 through HC3), and a summary table that flags a large design-matrix condition number, the same diagnosis this page worked by hand. If anyone will ask "is this coefficient significantly different from zero?" or "how uncertain is this effect?", fit with statsmodels; if the model only needs to predict, sklearn is leaner. The from-scratch version is genuinely enough when you need speed and control inside a research loop: fitting linear probes on GPU-resident activations, solving thousands of small regressions inside a bigger algorithm, or differentiating through the fit itself, which the JAX version supports for free.

Verification is one fixed-seed script: generate float64 data, then check np.allclose(fit_lstsq(X, y)[:-1], LinearRegression().fit(X, y).coef_, atol=1e-8) and the same for the intercept, and for ridge compare fit_ridge(X, y, alpha) against Ridge(alpha=alpha).fit(X, y). Agreement to eight decimals in float64 is the expected outcome, not a lucky one; anything worse means an objective mismatch (the factor of n, a penalized intercept, unstandardized penalty) rather than "numerical noise", and hunting the mismatch is the best conditioning exercise there is.

Traps and misconceptions

"Just use inv(X.T @ X)." The formula is correct mathematics and poor numerics: forming XTX squares the condition number and the explicit inverse adds instability and cost on top. The worked example above loses every digit in float32 this way. Solve with lstsq or a factorization; compute an inverse only when the inverse itself is the deliverable.

"Ridge is scale-free." The penalty λ‖w‖2 compares coefficients across features as if their units were comparable. Measure a feature in grams instead of kilograms and its coefficient shrinks a thousandfold less. Standardize first, and keep the intercept out of the penalty entirely, or the fit is biased toward predicting zero.

"High R2 means a good model." R2 only says the features explain in-sample variance. It rises monotonically as you add features, junk included, says nothing about out-of-sample performance or causality, and can be high while residuals show blatant structure. Judge on held-out error and residual plots, and treat coefficient stories as causal only with a design that supports it.

"The coefficients are unstable, so the model is broken." Under collinearity the individual coefficients can swing wildly between resamples while predictions barely move, because only the sum of the collinear terms is pinned down. That is a statement about identifiability, not brokenness. If you need stable, interpretable coefficients, ridge (or dropping redundant features) is the cure; if you only need predictions, the instability may not matter at all.

"Gradient descent didn't converge, so the problem is non-convex." The least-squares loss is convex; a diverging or crawling loop means the step size exceeds 2/L or the features are badly scaled, making the condition number enormous. Standardize the columns and set the step from the spectral norm as the code does, and the same loop converges quickly. Blaming convexity here trains the wrong instinct for debugging the deep-learning loops where the same symptoms have the same cure.

Key takeaway: linear regression is Gaussian maximum likelihood with a closed form, and the entire craft is in how you solve it: factorize X instead of forming XTX because squaring the condition number halves your correct digits, switch to gradient descent when the factorization stops scaling, and add ridge when the data itself does not pin the answer down. Those three moves, stable solve, iterative solve, regularized solve, are the same three moves that reappear in every larger model you will ever train.