S085 · Linear Regression from Scratch (numpy)
Build linear regression from first principles — normal equation, gradient descent, and why every ML paper still calls back to y = wx + b.
🎯 Derive, code, and debug linear regression by hand — normal equation, batch GD, and mini-batch SGD — so you understand the gradient step every deeper model relies on.
Why this session exists
Every ML algorithm you'll ever meet — neural networks, transformers, gradient boosting — is a generalisation of linear regression in some direction. If you cannot derive the closed-form solution and code the gradient step for the simplest possible model, you'll always be one layer of abstraction away from understanding what's happening. This session strips the abstractions off and rebuilds the algorithm in ~80 lines of numpy so the machinery becomes obvious.
- Derive the closed-form normal equation θ = (XᵀX)⁻¹Xᵀy on a napkin.
- Code batch gradient descent for linear regression in ~40 lines of numpy.
- Explain why gradient descent works and when the closed form is preferable.
- Diagnose the top 3 gradient descent failures (diverges, oscillates, plateaus) by learning-rate tuning.
- Compare your from-scratch model against sklearn and get identical coefficients.
Prerequisites
- S040 · Linear algebra crash — matrices, transpose, inverse.
- S045 · Calculus for ML — comfortable with partial derivatives and the chain rule.
- S084 · ML mental model — X/y/split/loss/metric scaffold.
(a) Intuition · 5 min
Imagine a stiff wooden plank suspended on a table by springs — one spring per data point, each attached to a house. Every spring pulls the plank toward its point with force proportional to the vertical distance. The plank settles where the total pulling force is zero.
The settled position of the plank is the least-squares regression line. Springs that pull twice as hard when stretched twice as far = squared error.
Linear regression assumes y = Xθ + ε where θ is a vector of weights and ε is noise. You find θ by minimising the squared errors: L(θ) = Σᵢ (yᵢ - xᵢᵀθ)². Because this is a convex quadratic in θ, there's exactly one minimum, and you can either solve for it directly (normal equation) or walk downhill from any starting point (gradient descent).
The reason everyone teaches this first is that every deeper model is a variation: swap the loss (logistic regression), add a penalty (ridge, lasso), stack many of these (neural network layer).
The three ideas you'll own by the end
- Model: ŷ = Xθ — a weighted sum of features plus a bias, packed into θ.
- Loss: MSE = (1/n)·||y - Xθ||² — a convex bowl with exactly one minimum.
- Fit: either solve XᵀXθ = Xᵀy directly (small d, cheap) or walk downhill via θ ← θ - η·∇L (big d, or when XᵀX isn't invertible).
From Gauss to today
- 1795Least squares · Gauss (17 years old)Used to predict the orbit of the asteroid Ceres. Method predates any computer by 150+ years.
- 1885Regression toward the mean · GaltonNamed ‘regression’ from studying heights of parents vs children. The name stuck.
- 1951Robbins–Monro · SGDThe stochastic gradient descent algorithm that trains every neural network on Earth.
- 1974BFGS quasi-NewtonSecond-order optimisation. sklearn LogReg still uses L-BFGS by default.
- 2010Adam optimiserAdaptive learning rates make gradient descent robust to bad hyperparameter choices. Standard for deep learning.
(b) Visual walkthrough · 15 min
The loss surface — a convex bowl
Because MSE is a quadratic in θ, the surface has one minimum and no local traps. Gradient descent is guaranteed to reach it if the learning rate is small enough.
Two ways to fit — same answer, different cost
Solve algebraically · O(d³)
- θ* = (XᵀX)⁻¹ Xᵀy — one line of numpy.
- Exact, no hyperparameters to tune.
- O(nd² + d³) — great for small d (say d < 10,000).
- Fails if XᵀX is singular (perfect collinearity).
- sklearn LinearRegression uses this internally via lstsq.
Walk downhill · O(nd · epochs)
- θ ← θ - η·∇L — repeat until converged.
- Needs learning rate η, iteration count, sometimes momentum.
- O(nd) per step — dominant for large d and huge n.
- Works even when the closed form doesn't (regularised, non-linear).
- Scales to billions of parameters (SGD variants train GPT).
The gradient — one derivative, done once
Deriving ∇L for MSE
Learning-rate zoo — three failure modes
Loss decreases painfully slowly. You never converge in reasonable time. Fix: 10× larger.
Loss oscillates or diverges to infinity. Weights explode to NaN. Fix: 10× smaller.
Loss decreases smoothly and levels off. Grid search over [1e-4, 1e-1] on a log scale.
Different feature scales break GD. Always StandardScaler before GD unless features are already comparable.
"Linear regression fits a straight line, so it can only model linear relationships. If my data curves, linear regression is the wrong tool and I need something non-linear."
Linear regression is linear in the parameters, not in the inputs. You can fit curves, interactions, periodicities, and step functions with it — y = β₀ + β₁x + β₂x² + β₃log(x) + β₄(x·z) is still ordinary least squares, still has a closed-form solution, still convex. The "linear" constraint is that the prediction is a weighted sum of features; what those features are is entirely up to you.
The myth is sticky because of the picture everyone is taught first: one input, one output, a straight line through a scatter plot. That image encodes "linear in x" and it is correct for that example. The generalisation to "linear in θ" never gets drawn, because it needs more than two dimensions to show. So the constraint you internalised is a property of the diagram, not of the method — and it quietly costs people the single cheapest, most interpretable model they had available.
Fit an obviously curved relationship with plain least squares and watch R² go to ~1:
import numpy as np
from sklearn.linear_model import LinearRegression
x = np.linspace(-3, 3, 200)
y = 2*x**2 - 3*x + 1 + np.random.normal(0, 0.5, 200)
X_bad = x.reshape(-1, 1) # just x
X_good = np.c_[x, x**2] # x and x-squared
print(LinearRegression().fit(X_bad, y).score(X_bad, y)) # ~0.3
print(LinearRegression().fit(X_good, y).score(X_good, y)) # ~0.99
# same algorithm. different feature space.Why squared error? Absolute error is the more natural measure of "how far off am I", and it is far less sensitive to outliers. Squaring looks like an arbitrary convention chosen to make the calculus pleasant. There is more to it.
- 1We want the parameters that make the observed data most probable — maximum likelihood. That requires an assumption about how the noise around the true line is distributed.forced by · without a noise model, "best fit" is undefined; you are only choosing between different definitions of best
- 2Assume the errors are independent and Gaussian with constant variance. That is not arbitrary: any error which is the sum of many small independent perturbations tends to Gaussian by the central limit theorem, which describes an enormous class of measurement processes.forced by · real-world residuals usually aggregate many unmodelled small causes
- 3The Gaussian density carries
exp(−(y − ŷ)² / 2σ²). The likelihood of the whole dataset is the product of those terms, and taking the log converts the product into a sum and cancels the exponential.forced by · logs turn products into sums and are monotonic, so they preserve the argmax - 4What remains after dropping constants is
−Σ(y − ŷ)². Maximising it is exactly minimising the sum of squared errors. So least squares is maximum likelihood under Gaussian noise — the squaring was derived, not chosen.forced by · the square came from the exponent of the Gaussian, nowhere else - 5The convenience is a bonus that follows: squared error is convex and differentiable everywhere, so setting the gradient to zero gives the normal equations
θ = (XᵀX)⁻¹Xᵀy— a unique global optimum in closed form. Absolute error is not differentiable at zero and has no closed form.forced by · a smooth convex objective admits an exact analytic solution; a kinked one does not
Therefore squared error is not a convention — it is the log-likelihood of a Gaussian noise model, and choosing a different loss is choosing a different belief about your noise.
And note what this predicts: if your noise is not Gaussian, least squares is no longer the right estimator. Heavy-tailed noise with occasional extreme values corresponds to a Laplace distribution, whose log-likelihood gives you absolute error — which is exactly why robust regression uses MAE or Huber loss, and why a single wild outlier can drag an OLS line badly while barely moving a MAE fit. The loss you pick is a statement about the outliers you expect.
Stop picturing a line through points. Picture your target y as a single vector in n-dimensional space (one dimension per observation). Your features span a much lower-dimensional subspace — the column space of X, every prediction you are capable of making.
Almost certainly y does not lie in that subspace, so exact fit is impossible. Least squares finds the point in the subspace closest to y — the orthogonal projection. The residual is the leftover component sticking out perpendicular to everything you can express. That is why residuals are uncorrelated with every feature by construction: if any correlation remained, you could reduce the error further, and you would not be at the projection.
- Adding a feature adds a dimension to the subspace, so training error can never increase. That is why R² always rises with more features, and why it is useless for model selection.
- Perfectly collinear features add no new dimension — the subspace does not grow,
XᵀXbecomes singular, and the coefficients become unidentifiable. Individually meaningless, jointly fine. - Coefficients mean "change in y per unit change in this feature, holding the others fixed". If features move together in reality, that clause is fictional and the coefficient is not interpretable alone.
- Residuals must look like structureless noise. Any visible pattern — curvature, a funnel shape, autocorrelation — is signal you failed to put into the subspace.
Fire this the moment you see: coefficients that flip sign when a feature is added · huge standard errors on individually "insignificant" but jointly strong features · R² used to compare models with different feature counts · a residual plot with visible curvature · a "linear model won't work, the data is curved" conclusion drawn without trying a basis expansion.
How do you actually solve for the coefficients: the closed-form normal equations, a QR/SVD decomposition, or iterative gradient descent?
XᵀX costs O(p²n) and inverting it O(p³), so it becomes impractical as the feature count grows into the thousands. Worse, XᵀX squares the condition number, so near-collinear features produce numerically garbage coefficients — the mathematically correct formula is a numerically poor algorithm.XᵀX, so it is dramatically more numerically stable under collinearity; SVD additionally gives you the pseudo-inverse, so it returns a sensible minimum-norm answer even when the matrix is rank-deficientUse the library, which is already doing QR or SVD for you — the reason sklearn's LinearRegression does not implement the textbook inverse is precisely the conditioning problem above. Derive and code the normal equations once, by hand, to own the geometry; then never ship them.
Learn gradient descent here even though it is unnecessary for this model, because linear regression is the only setting where you can check the iterative answer against an exact one. Every model after this point has no closed form, and you will want to have debugged your intuition about learning rates and feature scaling somewhere the ground truth is available.
(c) Hands-on · 25 min
We're going to implement linear regression three ways — normal equation, batch GD, mini-batch SGD — then compare against sklearn.
# linreg_scratch.py — linear regression, three ways, ~120 lines.
import numpy as np
from sklearn.datasets import make_regression
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score
np.random.seed(42)
# --- Data: 1000 rows, 5 features, some noise ---
X, y = make_regression(n_samples=1000, n_features=5, noise=10.0, random_state=42)
def add_bias(X):
"""Prepend a column of 1s so bias is folded into θ[0]."""
return np.hstack([np.ones((X.shape[0], 1)), X])
Xb = add_bias(X) # shape (1000, 6)
print(f"X shape: {X.shape}, Xb (with bias): {Xb.shape}")
# =====================================================
# 1. NORMAL EQUATION — θ = (XᵀX)⁻¹ Xᵀy
# =====================================================
def fit_normal(X, y):
"""Closed-form OLS. One line if you don't count the docstring."""
return np.linalg.inv(X.T @ X) @ X.T @ y
theta_normal = fit_normal(Xb, y)
yhat_normal = Xb @ theta_normal
print(f"[normal eq] RMSE = {mean_squared_error(y, yhat_normal, squared=False):.4f}")
# =====================================================
# 2. BATCH GRADIENT DESCENT
# =====================================================
def fit_gd(X, y, lr=0.05, epochs=1000, verbose=False):
"""Batch gradient descent for MSE."""
n, d = X.shape
theta = np.zeros(d) # initialise at origin
history = []
for epoch in range(epochs):
yhat = X @ theta # (n,)
residual = yhat - y # (n,)
grad = (1 / n) * (X.T @ residual) # (d,)
theta = theta - lr * grad # update
loss = 0.5 * np.mean(residual ** 2) # MSE
history.append(loss)
if verbose and epoch % 100 == 0:
print(f" epoch {epoch:4d}: loss={loss:.4f}, ||grad||={np.linalg.norm(grad):.4f}")
return theta, history
theta_gd, history = fit_gd(Xb, y, lr=0.05, epochs=1000, verbose=True)
yhat_gd = Xb @ theta_gd
print(f"[batch GD] RMSE = {mean_squared_error(y, yhat_gd, squared=False):.4f}")
# =====================================================
# 3. MINI-BATCH STOCHASTIC GD
# =====================================================
def fit_sgd(X, y, lr=0.05, epochs=50, batch_size=32):
"""SGD — noisier gradient, faster convergence for huge datasets."""
n, d = X.shape
theta = np.zeros(d)
for epoch in range(epochs):
idx = np.random.permutation(n)
for start in range(0, n, batch_size):
batch = idx[start:start + batch_size]
Xb_, yb_ = X[batch], y[batch]
grad = (1 / len(batch)) * (Xb_.T @ (Xb_ @ theta - yb_))
theta = theta - lr * grad
return theta
theta_sgd = fit_sgd(Xb, y)
yhat_sgd = Xb @ theta_sgd
print(f"[mini-batch] RMSE = {mean_squared_error(y, yhat_sgd, squared=False):.4f}")
# =====================================================
# 4. sklearn — sanity check
# =====================================================
sk = LinearRegression().fit(X, y) # note: raw X, no bias col
theta_sk = np.concatenate([[sk.intercept_], sk.coef_])
yhat_sk = sk.predict(X)
print(f"[sklearn] RMSE = {mean_squared_error(y, yhat_sk, squared=False):.4f}")
# =====================================================
# 5. Compare coefficients — should be near-identical
# =====================================================
print("\ncoefficient comparison (bias + 5 features):")
print(f" normal: {np.round(theta_normal, 3)}")
print(f" GD: {np.round(theta_gd, 3)}")
print(f" SGD: {np.round(theta_sgd, 3)}")
print(f" sklearn: {np.round(theta_sk, 3)}")
# =====================================================
# 6. Learning-rate sanity check
# =====================================================
print("\n=== Learning rate demo ===")
for lr in [1e-4, 1e-2, 5e-2, 3e-1, 1.0]:
_, hist = fit_gd(Xb, y, lr=lr, epochs=200)
final = hist[-1]
tag = "✅" if final < 100 else ("❌ diverged" if np.isnan(final) or final > 1e6 else "❌ slow")
print(f" lr={lr:>6}: final loss = {final:>10.2f} {tag}")What each block does
Anatomy of the script
Generate features on wildly different scales — one in [0, 1], another in [0, 1e6] — and re-run fit_gd with lr=0.05. You'll see it either fail to converge or oscillate. Now add a StandardScaler before fitting and it works again.
The reason: the loss surface becomes a stretched ellipse instead of a round bowl. Gradient descent bounces across the narrow axis instead of walking to the minimum.
(d) Production reality · 15 min
Product team runs an A/B test, uses linear regression to model conversion vs a treatment flag plus 20 covariates, and reports "the coefficient on treatment is 0.03 — the feature helps." Six months later the feature launches and moves the metric by -0.01.
Root cause: violated one of the OLS assumptions (usually independence or homoscedasticity), or the model was overfitting the 20 covariates. The confidence interval printed by statsmodels was correct under the assumptions, and those assumptions were false.
The winning entry to the Netflix Prize was an ensemble of hundreds of models. But early in the competition, teams that started with a simple linear regression baseline (rating ≈ user mean + movie mean + linear correction) climbed the leaderboard fastest. Complex methods without a linear baseline consistently underperformed.
Researcher trains a transformer with billions of parameters, reports SOTA on some benchmark. Reviewer asks "did you compare against a linear regression on the features?" Turns out the linear baseline gets 85 % of the SOTA number.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- What's the model, what's the loss, what's the fit rule? (one sentence each)
- When does the closed form fail, and what do you switch to?
- What's the effect of a too-large learning rate on the loss curve?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.