Kiru Lab  /  Classical Machine Learning  /  Learning From Data

Linear Regression From Scratch

The smallest complete model. Build it twice — closed form and iterative — and compare.

Code Walkthrough  ·  about 40 minutes

Linear regression predicts a number as a weighted sum of features. It is worth building from scratch because it is the only interesting model whose optimum you can also compute exactly, which gives you a ground truth to check an iterative method against.

y_hat = X w + b Loss = (1/n) * sum_i (y_hat_i - y_i)^2 Closed form: w = (X^T X)^{-1} X^T y
Both routes to the same answer
import numpy as np

def fit_closed_form(X, y):
    X1 = np.hstack([X, np.ones((len(X), 1))])          # absorb the bias term
    return np.linalg.lstsq(X1, y, rcond=None)[0]        # lstsq, not inv — see below

def fit_gradient_descent(X, y, lr=0.05, steps=2000):
    X1 = np.hstack([X, np.ones((len(X), 1))])
    w = np.zeros(X1.shape[1])
    history = []
    for _ in range(steps):
        residual = X1 @ w - y                            # (n,)
        loss = float((residual ** 2).mean())
        grad = 2.0 / len(y) * (X1.T @ residual)          # (d+1,)
        w -= lr * grad
        history.append(loss)
    return w, history
Never invert a matrix you can solve instead

The textbook formula contains an explicit inverse. Computing it directly is slower and numerically worse than a least-squares solver, and it fails outright when features are collinear. This is the conditioning lesson from track two showing up in the first model you build — and it is the same reason the robotics track uses a damped pseudoinverse rather than a true inverse.

Reading the loss curve

  • Smooth decay to a plateau: healthy.
  • Decreasing but still steep at the end: undertrained, or learning rate too small.
  • Oscillating: learning rate too high — you are stepping over the minimum.
  • NaN after a few steps: learning rate far too high, or unscaled features.
  • Flat from step one: gradient is not reaching the parameters. Check your shapes.

Learn to read this curve now. It is the same diagnostic you will use on a transformer, where you cannot inspect the parameters directly and the curve is most of what you get.


Hold on to

  • Closed-form solutions give you a ground truth for checking iterative ones
  • Prefer a least-squares solve over an explicit matrix inverse
  • The shape of a loss curve names the failure

Work through

Try each one before opening the solution. Getting it wrong first is most of where the learning happens.

  1. Fit both ways on the same data and confirm the weights agree to four decimals.
    Hint

    Use enough gradient steps and a learning rate small enough not to oscillate.

    Solution

    With scaled features, a learning rate around 0.05 and a couple of thousand steps brings gradient descent within 1e-4 of the closed-form solution. If they disagree, the usual causes are unscaled features (which make the problem ill-conditioned and slow one direction to a crawl) or too few steps.

    import numpy as np
    
    rng = np.random.default_rng(0)
    X = rng.normal(size=(200, 3))
    true_w = np.array([1.5, -2.0, 0.5])
    y = X @ true_w + 4.0 + rng.normal(scale=0.1, size=200)
    
    w_closed = fit_closed_form(X, y)
    w_gd, history = fit_gradient_descent(X, y, lr=0.05, steps=5000)
    assert np.abs(w_closed - w_gd).max() < 1e-4
    Check your work

    Paste this after your own code. If it runs without raising, you have it.

    import numpy as np
    assert np.abs(w_closed - w_gd).max() < 1e-4
    assert history[-1] < history[0]
    print("ok")
  2. Duplicate a feature column and observe what the closed form does. Explain using rank.
    Hint

    A duplicated column makes the design matrix rank-deficient.

    Solution

    With an exact duplicate the system is underdetermined: infinitely many weight vectors give identical predictions, because any amount can be shifted between the two identical columns. `np.linalg.lstsq` returns the minimum-norm solution, which splits the coefficient evenly between them rather than failing. An explicit `inv(X.T @ X)` would raise a singular-matrix error or return garbage. This is why you never read a coefficient in isolation when features are correlated — and it is the same rank deficiency that defines a robot singularity.

    import numpy as np
    
    X2 = np.hstack([X, X[:, :1]])            # column 0 duplicated
    print(np.linalg.matrix_rank(X2), X2.shape[1])   # 3 vs 4 — rank deficient
    
    w = np.linalg.lstsq(np.hstack([X2, np.ones((len(X2), 1))]), y, rcond=None)[0]
    print(w[0], w[3])                        # the coefficient split between the twins
  3. Sweep the learning rate across four orders of magnitude and collect the five curve shapes above.
    Hint

    Try learning rates spanning something like 1e-5 to 10.

    Solution

    Too small: the curve is still steeply descending when the steps run out. Just right: smooth decay to a plateau. Slightly too large: oscillation, a sawtooth that descends unevenly. Too large: divergence to enormous values. Far too large: NaN within a few steps, because the overshoot compounds until the floats overflow. The boundary sits near 2 divided by the largest eigenvalue of the curvature.

    for lr in (1e-5, 1e-3, 0.05, 0.5, 2.0):
        _, history = fit_gradient_descent(X, y, lr=lr, steps=200)
        print(f"lr={lr:<8} first={history[0]:.3e} last={history[-1]:.3e}")

Sign in to track your progress through the lab.