Skip to content

Multiple Linear Regression

What you’ll learn

  • 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 lstsqlstsq rather than inverting a matrix

Intuition

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

The math

The model in matrix form

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

Deriving the Normal Equation

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

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.

Worked example by hand

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.

See it move

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.

From scratch

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
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

With scikit-learn

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
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.

figurePredicted against actual on held-out datamatplotlib
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.

Reading the plot

  • 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.

Interpreting coefficients

Raw coefficients are not comparable

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
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
figureStandardised coefficients, sortedmatplotlib
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.

“Holding everything else constant”

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 ageage 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”.

Multicollinearity

figure120 refits on fresh samples from the same processmatplotlib
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
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 s1s1 and s5s5 carry large coefficients of opposite sign: the model is taking a difference between two nearly identical columns.

algorithmMultiple Linear Regression (OLS)Supervised · Regression

APIsklearn.linear_model.LinearRegression

Assumes

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

Cost

train
O(m·n² + n³)
predict
O(n)
memory
O(n)

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

Hyperparameters that matter

  • fit_interceptdefault TrueLeave on unless the data is already centred or theory forces a zero intercept.
  • positivedefault FalseConstrain every coefficient to be non-negative — useful for mixing weights and physical quantities.
  • n_jobsdefault NoneParallelism for the multi-target case only; it will not speed up a single target.

Reach for it when

  • 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

Look elsewhere when

  • 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

Pitfalls

Compare

ApproachHandles n>mn > mStable under collinearitySelects featuresInterpretable
OLSNoNoNoVery
RidgeYesYesNoGood
LassoYesPartlyYesGood
Elastic NetYesYesYesGood
Principal component regressionYesYesNoPoor
Gradient boostingYesYesImplicitlyPoor
quizCheck yourself
  1. What does the Normal Equation compute?

    Show answer

    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?

    Show answer

    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?

    Show answer

    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?

    Show answer

    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.

🧪 Try It Yourself

Exercise 1 – Add the bias column

Exercise 2 – Solve the Normal Equation

Exercise 3 – Residuals are orthogonal to the features

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

Exercise 5 – Measure a variance inflation factor

Recap

  • 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

Next

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

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did