Search Tech Journey

Find topics, journeys and posts

6-month learning plan85 / 130
back to blog
mlintermediate 55m read

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.

🤖Machine LearningM11 · Classical ML· Session 085 of 130 90 min

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

You will be able to
  • 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

Fitting a line is a spring-loaded plank
🌍 Real world

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.

💻 Code world

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

Linear regression in three sentences
  • 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

  1. 1795
    Least squares · Gauss (17 years old)
    Used to predict the orbit of the asteroid Ceres. Method predates any computer by 150+ years.
  2. 1885
    Regression toward the mean · Galton
    Named ‘regression’ from studying heights of parents vs children. The name stuck.
  3. 1951
    Robbins–Monro · SGD
    The stochastic gradient descent algorithm that trains every neural network on Earth.
  4. 1974
    BFGS quasi-Newton
    Second-order optimisation. sklearn LogReg still uses L-BFGS by default.
  5. 2010
    Adam optimiser
    Adaptive 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

Normal equation

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

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

Loss
L(θ) = (1/2n) · ||y - Xθ||² = (1/2n) · (y - Xθ)ᵀ(y - Xθ). The 1/2 makes the gradient cleaner.
step 1
Expand
= (1/2n) · (yᵀy - 2yᵀXθ + θᵀXᵀXθ). Standard quadratic form.
step 2
Differentiate
∂L/∂θ = (1/n) · (XᵀXθ - Xᵀy) = (1/n) · Xᵀ(Xθ - y) = (1/n) · Xᵀ(ŷ - y).
step 3
Interpret
The gradient is Xᵀ times the residual vector, scaled by 1/n. That's the update signal for every parameter at once.
step 4
Update rule
θ ← θ - η · (1/n)·Xᵀ(ŷ - y). Take a small step against the gradient. Repeat.
step 5

Learning-rate zoo — three failure modes

1
η too small

Loss decreases painfully slowly. You never converge in reasonable time. Fix: 10× larger.

2
η too big

Loss oscillates or diverges to infinity. Weights explode to NaN. Fix: 10× smaller.

3
η just right

Loss decreases smoothly and levels off. Grid search over [1e-4, 1e-1] on a log scale.

4💡
Bonus · normalise features first

Different feature scales break GD. Always StandardScaler before GD unless features are already comparable.


Common misconception
✗ What most people think

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

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.
From first principles
Start with the question

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.

  1. 1
    We 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
  2. 2
    Assume 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
  3. 3
    The 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
  4. 4
    What 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
  5. 5
    The 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

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.

Mental modelProjection onto the column space

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ᵀX becomes 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.
🔔 Fires when you see

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.

The tradeoff

How do you actually solve for the coefficients: the closed-form normal equations, a QR/SVD decomposition, or iterative gradient descent?

Normal equations, θ = (XᵀX)⁻¹Xᵀy
+ you gain exact solution in one shot, no hyperparameters, no learning rate, no convergence criterion, no iteration count to tune — the answer is the answer
− you pay forming 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.
pick when few features (roughly hundreds), well-conditioned data, and you want a reference answer to check other implementations against
QR or SVD decomposition
+ you gain solves the same least-squares problem without ever forming 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-deficient
− you pay still O(np²)-ish and still requires the full design matrix in memory, so it does not solve the scale problem — only the stability problem. Slower than the normal equations on small, well-behaved data.
pick when moderate feature counts with suspected collinearity — this is why production libraries default here rather than to the textbook formula
Gradient descent / SGD
+ you gain cost per step is independent of the total dataset size when minibatched, so it scales to data that does not fit in memory and streams naturally; the identical machinery then generalises to logistic regression, neural networks, and any differentiable loss
− you pay approximate rather than exact, introduces learning rate and stopping criteria as things you can get wrong, and requires feature scaling — unscaled features produce an elongated loss surface where the gradient points mostly sideways and convergence crawls
pick when n is very large or streaming, p is very large, or you are building toward models with no closed form anyway
What a senior engineer actually does

Use 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

add_bias helper
Prepends a column of 1s so the bias term becomes θ[0]. Cleaner math than tracking b separately.
setup
fit_normal
Literally the equation on paper. np.linalg.inv is O(d³) — fine here (d=6), catastrophic for d=100000.
closed form
fit_gd
The core of every deep-learning training loop, boiled down. Compute prediction → residual → gradient → step. Repeat.
iterative
fit_sgd
Sample a mini-batch, compute gradient on it, step. Noisier per-step but converges faster on large data. This is what PyTorch's SGD optimiser does.
scale
sklearn compare
Sanity check — your code should match sklearn to 3 decimal places. If not, you have a bug.
validate
LR sweep
Grid over 5 learning rates. Watch it diverge at lr=1.0 and crawl at lr=1e-4. Teaches you the hyperparameter feel in 5 seconds.
tuning
Try itBreak gradient descent by removing feature scaling

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.

💡 Hint · Then wrap X in a StandardScaler.fit_transform(X) and watch it converge again. This is why every neural network paper says ‘we normalise the inputs.’

(d) Production reality · 15 min

War story A/B testing at every consumer app · alwaysuniversal
🔥 What broke

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 fix
Modern experimentation teams use CUPED-style variance reduction with linear regression on baseline features. But they always validate the point estimate against a hold-out set, and prefer robust standard errors over classical ones.
🎓 Lesson to steal
Linear regression is the workhorse of causal inference in industry, but only if you take the assumptions seriously. Print the residual plot. Check for heteroscedasticity. Always compare against a simpler baseline.
War story Netflix Prize · 2006–2009· 2009$1M prize · 3 years of work
🔥 What broke

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.

🧯 The fix
The two winning teams (BellKor and Ensemble) both spent the first month of the 3-year competition building linear baselines. Everything else was residuals on top of those baselines.
🎓 Lesson to steal
Even in ‘won by deep learning’ eras of ML history, linear models are still the load-bearing 80 % of the score. Start linear. Add complexity only after you can beat the linear baseline on a proper val set.
Post-mortem
War story Every deep learning paper · every yearuniversal
🔥 What broke

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.

🧯 The fix
NeurIPS + ICML now require baseline reporting. It doesn't stop the paper from being accepted, but it stops the marketing claim from being "we invented this from nothing."
🎓 Lesson to steal
Every complex model has a linear baseline it must beat. If it beats the baseline by 1 %, ship the baseline. If it beats by 20 %, ship the complex model. But never ship without knowing the gap.

Where this shows up in the rest of the plan

Linear regression is the foundation the rest of ML builds on
S086 · Logistic regression
Same model + sigmoid + cross-entropy loss. Same GD update.
S087 · Regularization
Add λ||θ||² (ridge) or λ||θ||₁ (lasso) to the loss. Same GD update, one extra term.
S088 · Bias–variance
Analyse when linear underfits (high bias) vs overfits (high variance).
S095 · Neural network fundamentals
A single linear layer IS linear regression. Stack them with nonlinearity for a deep net.
S096 · Feature engineering
Polynomial features + one-hot + interactions turn linear regression into non-linear function approximators.
S097 · Bayesian regression
Same likelihood, add a prior on θ. Recovers ridge as MAP estimate under Gaussian prior.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. What's the model, what's the loss, what's the fit rule? (one sentence each)
  2. When does the closed form fail, and what do you switch to?
  3. 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.