Skip to content

Multiple Linear Regression

  • the matrix form of the model, and how to derive the Normal Equation by setting a gradient to zero
  • how to solve a three-parameter fit by hand when the design is orthogonal
  • the exact meaning of “holding all other features constant”, and when that phrase is a lie
  • why coefficients are only comparable after standardising
  • what multicollinearity breaks — and, just as importantly, what it does not
  • why scikit-learn uses lstsq rather than inverting a matrix

One feature gives you a line. Two features give you a plane. Ten features give you a hyperplane nobody can picture — but the arithmetic never changes: multiply each feature by its weight, add them all up, add the bias.

What does change is interpretation. With one feature, the slope is simply “how yy moves with xx”. With several correlated features, each coefficient answers a much narrower question: how yy moves with this feature once the others have already had their say. Miss that and you will misread almost every model you fit.

diagram Diagram mermaid

Stack mm instances as rows of a design matrix XRm×(n+1)\mathbf{X} \in \mathbb{R}^{m\times(n+1)}, whose first column is all ones:

y^=Xθ,J(θ)=1mXθy22\hat{\mathbf{y}} = \mathbf{X}\boldsymbol{\theta}, \qquad J(\boldsymbol{\theta}) = \frac{1}{m}\lVert \mathbf{X}\boldsymbol{\theta} - \mathbf{y}\rVert_2^2

Expand the squared norm as an inner product and drop the constant 1/m1/m — it cannot move the minimiser:

J(θ)(Xθy)(Xθy)=θXXθ2θXy+yyJ(\boldsymbol{\theta}) \propto (\mathbf{X}\boldsymbol{\theta} - \mathbf{y})^\top(\mathbf{X}\boldsymbol{\theta} - \mathbf{y}) = \boldsymbol{\theta}^\top\mathbf{X}^\top\mathbf{X}\boldsymbol{\theta} - 2\boldsymbol{\theta}^\top\mathbf{X}^\top\mathbf{y} + \mathbf{y}^\top\mathbf{y}

Differentiate with respect to θ\boldsymbol{\theta} using two standard identities — θ(θAθ)=2Aθ\nabla_{\boldsymbol{\theta}}(\boldsymbol{\theta}^\top\mathbf{A}\boldsymbol{\theta}) = 2\mathbf{A}\boldsymbol{\theta} for symmetric A\mathbf{A}, and θ(θb)=b\nabla_{\boldsymbol{\theta}}(\boldsymbol{\theta}^\top\mathbf{b}) = \mathbf{b}:

θJ=2XXθ2Xy=0\nabla_{\boldsymbol{\theta}} J = 2\mathbf{X}^\top\mathbf{X}\boldsymbol{\theta} - 2\mathbf{X}^\top\mathbf{y} = \mathbf{0}   θ=(XX)1Xy  \boxed{\;\boldsymbol{\theta} = (\mathbf{X}^\top\mathbf{X})^{-1}\mathbf{X}^\top\mathbf{y}\;}

That is the Normal Equation: one formula, no iteration, no learning rate.

The residual is orthogonal to every feature

Section titled “The residual is orthogonal to every feature”

Rearranged, the Normal Equation says X(yXθ)=0\mathbf{X}^\top(\mathbf{y} - \mathbf{X}\boldsymbol{\theta}) = \mathbf{0}: the residual vector is perpendicular to every column of X\mathbf{X}. Two consequences you can check on any fit:

  • because the first column is all ones, the residuals sum to zero
  • because every other column is a feature, the residuals are uncorrelated with each feature

If either fails, you did not fit least squares.

An orthogonal design — a small experiment where two on/off factors were varied deliberately. Both are coded 1-1 (off) and +1+1 (on), which keeps the arithmetic exact.

iix0x_0promotion x1x_1weekend x2x_2sales yy
11−1−11
21−1+13
31+1−15
41+1+18

Step 1 — build XX\mathbf{X}^\top\mathbf{X}. Every column holds four entries of ±1\pm 1, and any two different columns are orthogonal, so all off-diagonal entries vanish:

XX=[400040004],(XX)1=14I\mathbf{X}^\top\mathbf{X} = \begin{bmatrix} 4 & 0 & 0 \\ 0 & 4 & 0 \\ 0 & 0 & 4 \end{bmatrix}, \qquad (\mathbf{X}^\top\mathbf{X})^{-1} = \tfrac{1}{4}\mathbf{I}

Step 2 — build Xy\mathbf{X}^\top\mathbf{y}.

Xy=[1+3+5+813+5+81+35+8]=[1795]\mathbf{X}^\top\mathbf{y} = \begin{bmatrix} 1+3+5+8 \\ -1-3+5+8 \\ -1+3-5+8 \end{bmatrix} = \begin{bmatrix} 17 \\ 9 \\ 5 \end{bmatrix}

Step 3 — multiply.

θ=14[1795]=[4.252.251.25]\boldsymbol{\theta} = \tfrac{1}{4}\begin{bmatrix} 17 \\ 9 \\ 5 \end{bmatrix} = \begin{bmatrix} 4.25 \\ 2.25 \\ 1.25 \end{bmatrix}

So y^=4.25+2.25x1+1.25x2\hat{y} = 4.25 + 2.25\,x_1 + 1.25\,x_2.

Step 4 — check the fit.

iix1x_1x2x_2y^\hat{y}yyresidual
1−1−10.751+0.25
2−1+13.253−0.25
3+1−15.255−0.25
4+1+17.758+0.25
0.00
SSE=4(0.25)2=0.25,SST=26.75,R2=10.2526.750.991\text{SSE} = 4(0.25)^2 = 0.25, \qquad \text{SST} = 26.75, \qquad R^2 = 1 - \frac{0.25}{26.75} \approx 0.991

Residuals sum to zero, and they are orthogonal to both feature columns — x1e=0.25+0.250.25+0.25=0\sum x_1 e = -0.25 + 0.25 - 0.25 + 0.25 = 0, and the same for x2x_2. Exactly what the Normal Equation promised.

Reading the coefficients. Because the features are coded ±1\pm 1, switching the promotion on moves sales by 2×2.25=4.52 \times 2.25 = 4.5 units, and a weekend adds 2×1.25=2.52 \times 1.25 = 2.5. The promotion is the stronger lever, and here that comparison is legitimate — both features share a scale and are uncorrelated.

The Normal Equation solves for the plane in one shot. Below, a flat baseline plane tilts into the least-squares fit over two features; the red stems are residuals shrinking as it locks in.

sketch Fitting a plane with two features p5.js
The Normal Equation tilts a flat baseline plane into the least-squares fit over two features; residual stems shrink as it locks in.

Three ways to solve the same system, in increasing order of how much you should trust them:

normal_equation.py
import numpy as np
 
X = np.array([[1, -1, -1],
              [1, -1,  1],
              [1,  1, -1],
              [1,  1,  1]], dtype=float)
y = np.array([1.0, 3.0, 5.0, 8.0])
 
# 1. Textbook form — never use this in production
theta_inv = np.linalg.inv(X.T @ X) @ X.T @ y
 
# 2. Solve the linear system instead of inverting: faster, better conditioned
theta_solve = np.linalg.solve(X.T @ X, X.T @ y)
 
# 3. What scikit-learn actually does: least squares via SVD.
#    Still returns an answer when X.T @ X is singular.
theta_lstsq, *_ = np.linalg.lstsq(X, y, rcond=None)
 
print(theta_inv.round(4))     # [4.25 2.25 1.25]
print(theta_solve.round(4))   # [4.25 2.25 1.25]
print(theta_lstsq.round(4))   # [4.25 2.25 1.25]
 
resid = y - X @ theta_lstsq
print(resid.round(4))                 # [ 0.25 -0.25 -0.25  0.25]
print(round(resid.sum(), 10))         # 0.0
print((X.T @ resid).round(10))        # [0. 0. 0.]  <- orthogonal to every column
diabetes_multi.py
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
 
data = load_diabetes()
X_train, X_test, y_train, y_test = train_test_split(
    data.data, data.target, test_size=0.3, random_state=42
)
 
model = LinearRegression().fit(X_train, y_train)
 
print(f"train R^2 = {model.score(X_train, y_train):.4f}")   # train R^2 = 0.5244
print(f"test  R^2 = {model.score(X_test, y_test):.4f}")     # test  R^2 = 0.4773
 
rmse = mean_squared_error(y_test, model.predict(X_test)) ** 0.5
print(f"test RMSE = {rmse:.2f}")                            # test RMSE = 53.12

Ten features lift test R2R^2 from the 0.34 that BMI alone managed to 0.48. The gap between train (0.52) and test (0.48) is small, which is what a well-behaved linear model looks like.

figure Predicted against actual on held-out data matplotlib
Scatter of predicted against actual disease progression on held-out patients, with a dashed diagonal marking perfect prediction. Points cluster around the diagonal with wide spread. Scatter of predicted against actual disease progression on held-out patients, with a dashed diagonal marking perfect prediction. Points cluster around the diagonal with wide spread.
The single most useful diagnostic once you pass two features. Points should hug the diagonal with no curvature and no fanning.
  • No systematic curve. Points sit around the diagonal rather than bowing above or below it, so a linear model is a reasonable shape here.
  • Compression at the extremes. Low actual values are over-predicted and high ones under-predicted — the classic signature of a model that has captured the trend but not the tails.
  • Vertical spread is the error. At any actual value the scatter is roughly ±50, which matches the RMSE of 53.

A coefficient is expressed in target units per one unit of that feature. A coefficient of 949 on BMI and 3 on age says nothing about relative importance until both features share a scale. Standardise first, and each coefficient becomes “target change per one standard deviation”:

standardised_coefficients.py
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import StandardScaler
 
data = load_diabetes()
X = StandardScaler().fit_transform(data.data)
model = LinearRegression().fit(X, data.target)
 
for name, coef in sorted(zip(data.feature_names, model.coef_),
                         key=lambda pair: -abs(pair[1])):
    print(f"{name:>5} {coef:8.2f}")
 
#    s1   -37.68
#    s5    35.73
#   bmi    24.73
#    s2    22.68
#    bp    15.43
#   sex   -11.41
#    s4     8.42
#    s3     4.81
#    s6     3.22
#   age    -0.48
figure Standardised coefficients, sorted matplotlib
Horizontal bar chart of standardised coefficients for the ten diabetes features, sorted from most negative to most positive. Horizontal bar chart of standardised coefficients for the ten diabetes features, sorted from most negative to most positive.
Now the bars are comparable. s1 and s5 dominate; age contributes essentially nothing once the other nine features are present.

The textbook reading of θj\theta_j is: the change in y^\hat{y} for a one-unit increase in xjx_j, with all other features held fixed. That clause is doing enormous work. If two features always move together in the real world, no observation ever shows one changing while the other stays put — so the coefficient describes a scenario your data never contained.

That is why age scores −0.48 here despite age plainly mattering to disease progression: its information is already carried by blood pressure and the serum measurements. The coefficient does not say “age does not matter”, it says “age adds nothing beyond the other nine columns”.

figure 120 refits on fresh samples from the same process matplotlib
Two scatter plots of fitted coefficient pairs from 120 refits. With independent features the points cluster tightly around the true values; with correlated features they spread along a diagonal ridge. Two scatter plots of fitted coefficient pairs from 120 refits. With independent features the points cluster tightly around the true values; with correlated features they spread along a diagonal ridge.
Correlated features do not bias the coefficients — the cloud is still centred on the truth — but they scatter them enormously. Any single fit could report almost anything.

Detect it with the variance inflation factor. For feature jj, regress it on all the others and take:

VIFj=11Rj2\text{VIF}_j = \frac{1}{1 - R^2_j}

A VIF of 1 means no collinearity, above 5 is worth a look, and above 10 means that coefficient is close to meaningless on its own.

vif.py
import numpy as np
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
 
data = load_diabetes()
X = data.data
 
for j, name in enumerate(data.feature_names):
    others = np.delete(X, j, axis=1)
    r2 = LinearRegression().fit(others, X[:, j]).score(others, X[:, j])
    vif = 1 / (1 - r2)
    flag = "  <-- high" if vif > 10 else ""
    print(f"{name:>5}  VIF = {vif:7.2f}{flag}")
 
#   age  VIF =    1.22
#   sex  VIF =    1.28
#   bmi  VIF =    1.51
#    bp  VIF =    1.46
#    s1  VIF =   59.20  <-- high
#    s2  VIF =   39.19  <-- high
#    s3  VIF =   15.40  <-- high
#    s4  VIF =    8.89
#    s5  VIF =   10.08  <-- high
#    s6  VIF =    1.48

The serum measurements are heavily entangled, which is exactly why s1 and s5 carry large coefficients of opposite sign: the model is taking a difference between two nearly identical columns.

pch.algoTag Multiple Linear Regression (OLS) Supervised · Regression

pch.algoApi sklearn.linear_model.LinearRegression

pch.algoAssumes

  • The target is linear in the parameters
  • Observations are independent
  • Residual variance is constant across the feature space
  • No perfect collinearity among the features

pch.algoCost

pch.algoTrain
O(m·n² + n³)
pch.algoPredict
O(n)
pch.algoMemory
O(n)

m = samples, n = features; training cost is dominated by forming and factorising X transpose X

pch.algoHyperparams

  • fit_intercept default True Leave on unless the data is already centred or theory forces a zero intercept.
  • positive default False Constrain every coefficient to be non-negative — useful for mixing weights and physical quantities.
  • n_jobs default None Parallelism for the multi-target case only; it will not speed up a single target.

pch.algoReachFor

  • You want a strong, fast, interpretable baseline on tabular data
  • The number of features is comfortably smaller than the number of samples
  • You need coefficients you can put in a report

pch.algoLookElsewhere

  • Features outnumber samples — the solution is not unique, so use Ridge or Lasso
  • Features are heavily collinear and you need to interpret individual effects
  • The relationship is strongly non-linear and you cannot engineer the terms
  • Outliers dominate the target
ApproachHandles n>mn > mStable under collinearitySelects featuresInterpretable
OLSNoNoNoVery
RidgeYesYesNoGood
LassoYesPartlyYesGood
Elastic NetYesYesYesGood
Principal component regressionYesYesNoPoor
Gradient boostingYesYesImplicitlyPoor
pch.quizTag pch.quizDefaultTitle
  1. What does the Normal Equation compute?

    pch.quizShowAnswer

    B — The exact parameter vector that minimises squared error, in one step — Setting the gradient of the squared-error cost to zero gives theta = (X transpose X) inverse times X transpose y — a closed-form minimiser, no iteration involved.

  2. Two features in your model have a correlation of 0.99. What breaks?

    pch.quizShowAnswer

    B — The individual coefficients become unstable, though the predictions stay fine — Collinearity inflates coefficient variance. The fitted surface and its predictions are unaffected; only the attribution between the correlated features is unreliable.

  3. Why standardise before comparing coefficient magnitudes?

    pch.quizShowAnswer

    B — Because a raw coefficient is in target units per feature unit, and different features use different units — A coefficient of 949 on a feature ranging over 0.1 and a coefficient of 3 on a feature ranging over 10,000 are not comparable until both features share a scale.

  4. Why does scikit-learn use lstsq instead of inverting X transpose X?

    pch.quizShowAnswer

    B — The SVD-based pseudoinverse is more numerically stable and still returns a solution when the matrix is singular — Explicit inversion is slower, less accurate, and fails on singular matrices. lstsq returns the minimum-norm solution instead of raising an error.

Exercise 3 – Residuals are orthogonal to the features

Section titled “Exercise 3 – Residuals are orthogonal to the features”

Exercise 4 – Fit ten features and score on held-out data

Section titled “Exercise 4 – Fit ten features and score on held-out data”

Exercise 5 – Measure a variance inflation factor

Section titled “Exercise 5 – Measure a variance inflation factor”
  • In matrix form the model is y^=Xθ\hat{\mathbf{y}} = \mathbf{X}\boldsymbol{\theta}, and setting J=0\nabla J = 0 gives θ=(XX)1Xy\boldsymbol{\theta} = (\mathbf{X}^\top\mathbf{X})^{-1}\mathbf{X}^\top\mathbf{y}.
  • The residual is orthogonal to every column of X\mathbf{X} — hence residuals summing to zero.
  • On an orthogonal design the whole fit is one division: θ=14[17,9,5]\boldsymbol{\theta} = \frac{1}{4}[17, 9, 5]^\top.
  • Ten diabetes features reach test R2=0.48R^2 = 0.48 against 0.34 for BMI alone.
  • Standardise before comparing coefficients; check VIF before interpreting any of them.
  • Collinearity destabilises coefficients, not predictions.

Exercise 6 – When the Normal Equation has no inverse

Section titled “Exercise 6 – When the Normal Equation has no inverse”

Continue to Polynomial Regression — bend the straight line into a curve without leaving the linear model behind.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading