Gradient descent with many features

The same downhill walk, now in many directions at once. Write the cost and its gradient for n features, vectorise both, and train on the house table.

Lesson · 14 min

In this lesson you will

  • Write the cost J(w, b) for multiple linear regression
  • State the update rule for each weight w_j and for b
  • Compute the whole gradient at once with X.T @ error / m
  • Run gradient descent in NumPy and notice when some weights barely move

You already know how to train a one-feature model: measure the cost, find the slope, step downhill, repeat. With nn features there are n+1n + 1 parameters (w1w_1 to wnw_n and bb), so the cost landscape has n+1n + 1 directions instead of two. That sounds harder to picture, and it is, but the algorithm hardly changes. Each parameter simply gets its own slope.

The cost is the same idea

The cost still measures the average squared error over the training set. The only change is that the prediction now uses the dot product:

J(w⃗,b)=12m∑i=1m(fw⃗,b(x⃗(i))−y(i))2fw⃗,b(x⃗(i))=w⃗⋅x⃗(i)+bJ(\vec{w}, b) = \frac{1}{2m} \sum_{i=1}^{m} \left( f_{\vec{w},b}(\vec{x}^{(i)}) - y^{(i)} \right)^2 \qquad f_{\vec{w},b}(\vec{x}^{(i)}) = \vec{w} \cdot \vec{x}^{(i)} + b

With vectorisation that’s three short lines: predict everything, take the errors, average their squares.

import numpy as np

X = np.array([
    [1850, 3, 2, 12],
    [1200, 2, 1, 40],
    [2400, 4, 2, 8],
    [950, 2, 1, 55],
    [1600, 3, 1, 25],
    [3100, 5, 2, 3],
], dtype=float)
y = np.array([412, 236, 538, 178, 330, 690], dtype=float)
m, n = X.shape

def compute_cost(X, y, w, b):
    err = X @ w + b - y              # one error per house
    return (err ** 2).sum() / (2 * len(y))

print("cost with all zeros:", compute_cost(X, y, np.zeros(n), 0.0))
print("cost with a guess:  ", compute_cost(X, y, np.array([0.2, 10, 5, -1]), 30.0))

One slope per parameter

In Math for ML, section 3, you met partial derivatives: the slope of a function along one input while the others stay put, and the gradient, the vector of all of them. For our cost, the partial derivative with respect to weight wjw_j is:

∂J∂wj=1m∑i=1m(fw⃗,b(x⃗(i))−y(i))xj(i)∂J∂b=1m∑i=1m(fw⃗,b(x⃗(i))−y(i))\frac{\partial J}{\partial w_j} = \frac{1}{m} \sum_{i=1}^{m} \left( f_{\vec{w},b}(\vec{x}^{(i)}) - y^{(i)} \right) x_j^{(i)} \qquad \frac{\partial J}{\partial b} = \frac{1}{m} \sum_{i=1}^{m} \left( f_{\vec{w},b}(\vec{x}^{(i)}) - y^{(i)} \right)

Compare it with Section 1: it’s the same formula, with x(i)x^{(i)} replaced by feature jj of example ii. Each weight’s slope is “the errors, weighted by how much of that feature each house had”. If big houses are under-priced, the error is negative where size is large, the slope for the size weight is negative, and gradient descent pushes that weight up.

Then every parameter steps downhill at the same time, a simultaneous update exactly as before:

wj:=wj−α∂J∂wjfor j=1,…,nb:=b−α∂J∂bw_j := w_j - \alpha \frac{\partial J}{\partial w_j} \quad \text{for } j = 1, \dots, n \qquad b := b - \alpha \frac{\partial J}{\partial b}

The whole gradient in one line

Writing a loop over jj would work, but look at what the sum does: for each feature, multiply the error vector by that feature’s column and add up. That’s a dot product of the error vector with each column of XX, and doing it for all columns at once is a matrix product with the transpose XTX^T:

∇w⃗J=1mXT(Xw⃗+b−y⃗)\nabla_{\vec{w}} J = \frac{1}{m} X^T \left( X\vec{w} + b - \vec{y} \right)

Shapes check: XTX^T is (n,m)(n, m), the error vector is (m,)(m,), so the result is (n,)(n,), one slope per weight. In NumPy:

def compute_gradient(X, y, w, b):
    err = X @ w + b - y              # shape (m,)
    dj_dw = X.T @ err / len(y)       # shape (n,): one slope per weight
    dj_db = err.mean()               # a single number
    return dj_dw, dj_db

dj_dw, dj_db = compute_gradient(X, y, np.zeros(n), 0.0)
print("slopes for w:", dj_dw)
print("slope for b: ", dj_db)

All the slopes are negative at w⃗=0\vec{w} = 0: every prediction is zero, far below every price, so increasing any weight would help. Notice how different their sizes are, though. The size slope is in the hundreds of thousands, the floors slope is in the hundreds. Keep that in mind.

Train it

Put the pieces in a loop. We record the cost after each step so we can check that it falls.

def gradient_descent(X, y, w, b, alpha, iters):
    history = []
    for _ in range(iters):
        dj_dw, dj_db = compute_gradient(X, y, w, b)
        w = w - alpha * dj_dw        # all weights at once
        b = b - alpha * dj_db
        history.append(compute_cost(X, y, w, b))
    return w, b, history

w, b, history = gradient_descent(X, y, np.zeros(n), 0.0, alpha=1e-7, iters=1000)
print("cost: start", round(history[0], 1), "-> end", round(history[-1], 1))
print("weights:", np.round(w, 4), " b:", round(b, 4))
print("predictions:", np.round(X @ w + b), "\nactual:     ", y)

It learned something: the cost fell and the predictions are in the right ballpark. But look at the weights. The size weight moved to about 0.22, while the bedrooms and floors weights are still essentially zero after a thousand steps, and bb has barely moved. Gradient descent has learnt “price is about 0.22 times size” and almost nothing else.

Why such a tiny learning rate? Try it: change alpha to 1e-6 and run again. The cost explodes. Size is measured in thousands, so its slope is huge, and any bigger step overshoots wildly along the size direction. But that same tiny step is far too small for features measured in single digits. One learning rate can’t suit features on such different scales. The next lesson fixes this, and the fix is pleasingly simple.

Your turn

Exercise · Python

Vectorised cost and gradient

Write two functions with no loops:

  • compute_cost(X, y, w, b) returns J=12m∑i(w⃗⋅x⃗(i)+b−y(i))2J = \frac{1}{2m}\sum_i (\vec{w}\cdot\vec{x}^{(i)} + b - y^{(i)})^2,
  • compute_gradient(X, y, w, b) returns a pair (dj_dw, dj_db): the array of partial derivatives for the weights, 1mXT(Xw⃗+b−y⃗)\frac{1}{m}X^T(X\vec{w}+b-\vec{y}), and the single number 1m∑i(y^(i)−y(i))\frac{1}{m}\sum_i (\hat{y}^{(i)} - y^{(i)}).
import numpy as np

X = np.array([
    [1850, 3, 2, 12],
    [1200, 2, 1, 40],
    [2400, 4, 2, 8],
    [950, 2, 1, 55],
    [1600, 3, 1, 25],
    [3100, 5, 2, 3],
], dtype=float)
y = np.array([412, 236, 538, 178, 330, 690], dtype=float)

def compute_cost(X, y, w, b):
    m = len(y)
    # your code here
    return None

def compute_gradient(X, y, w, b):
    m = len(y)
    # your code here
    dj_dw = None
    dj_db = None
    return dj_dw, dj_db

print(compute_cost(X, y, np.zeros(4), 0.0))
print(compute_gradient(X, y, np.zeros(4), 0.0))

Check yourself

Self-check · 3 questions

Gradient descent with many features

1. Which expression is ∂J∂wj\frac{\partial J}{\partial w_j} for multiple linear regression?
2. In NumPy, which line computes all the weight slopes at once, given err = X @ w + b - y?
3. On raw house features (size in square feet, bedrooms 1 to 5), any learning rate large enough to move the bedrooms weight makes training blow up. Why?