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
lstsqlstsqrather 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 moves with ”. With several correlated features, each coefficient answers a much narrower question: how moves with this feature once the others have already had their say. Miss that and you will misread almost every model you fit.
flowchart LR X1["x1 size"] --> M["Weighted sum
theta transpose x"] X2["x2 bedrooms"] --> M X3["x3 age"] --> M XN["x_n ..."] --> M M --> Y["Prediction ŷ"]
The math
The model in matrix form
Stack instances as rows of a design matrix , whose first column is all ones:
Deriving the Normal Equation
Expand the squared norm as an inner product and drop the constant — it cannot move the minimiser:
Differentiate with respect to using two standard identities — for symmetric , and :
That is the Normal Equation: one formula, no iteration, no learning rate.
The residual is orthogonal to every feature
Rearranged, the Normal Equation says : the residual vector is perpendicular to every column of . 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 (off) and (on), which keeps the arithmetic exact.
| promotion | weekend | sales | ||
|---|---|---|---|---|
| 1 | 1 | −1 | −1 | 1 |
| 2 | 1 | −1 | +1 | 3 |
| 3 | 1 | +1 | −1 | 5 |
| 4 | 1 | +1 | +1 | 8 |
Step 1 — build . Every column holds four entries of , and any two different columns are orthogonal, so all off-diagonal entries vanish:
Step 2 — build .
Step 3 — multiply.
So .
Step 4 — check the fit.
| residual | |||||
|---|---|---|---|---|---|
| 1 | −1 | −1 | 0.75 | 1 | +0.25 |
| 2 | −1 | +1 | 3.25 | 3 | −0.25 |
| 3 | +1 | −1 | 5.25 | 5 | −0.25 |
| 4 | +1 | +1 | 7.75 | 8 | +0.25 |
| 0.00 |
Residuals sum to zero, and they are orthogonal to both feature columns — , and the same for . Exactly what the Normal Equation promised.
Reading the coefficients. Because the features are coded , switching the promotion on moves sales by units, and a weekend adds . 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.
From scratch
Three ways to solve the same system, in increasing order of how much you should trust them:
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 columnimport 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 columnWith scikit-learn
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.12from 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.12Ten features lift test 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.
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”:
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.48from 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“Holding everything else constant”
The textbook reading of is: the change in for a one-unit increase in , 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
Detect it with the variance inflation factor. For feature , regress it on all the others and take:
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.
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.48import 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.48The 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.
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
| Approach | Handles | Stable under collinearity | Selects features | Interpretable |
|---|---|---|---|---|
| OLS | No | No | No | Very |
| Ridge | Yes | Yes | No | Good |
| Lasso | Yes | Partly | Yes | Good |
| Elastic Net | Yes | Yes | Yes | Good |
| Principal component regression | Yes | Yes | No | Poor |
| Gradient boosting | Yes | Yes | Implicitly | Poor |
What does the Normal Equation compute?
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.
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.
Two features in your model have a correlation of 0.99. What breaks?
Collinearity inflates coefficient variance. The fitted surface and its predictions are unaffected; only the attribution between the correlated features is unreliable.
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.
Why standardise before comparing coefficient magnitudes?
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.
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.
Why does scikit-learn use lstsq instead of inverting X transpose X?
Explicit inversion is slower, less accurate, and fails on singular matrices. lstsq returns the minimum-norm solution instead of raising an error.
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 , and setting gives .
- The residual is orthogonal to every column of — hence residuals summing to zero.
- On an orthogonal design the whole fit is one division: .
- Ten diabetes features reach test 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 coffeeWas this page helpful?
Let us know how we did
