Skip to content

The cost function and gradient descent

Chapter 13 16 code blocks 3811 words

The code in this chapter uses these filesrun it on your own machine

The scene: where do the weights come from

In Chapter 12 I wrote the weights by hand:

python1 lines
w = np.array([55.0, 7.5, -28.0, 90.0])

Where did I get them? I made them up. And the prediction came out close, but not exact.

The question: how does the model find these numbers itself?

The answer has two steps:

  1. Write a function that measures the error (the cost function)
  2. Search for the weights that make that function as small as possible (gradient descent)

This chapter is the mechanism inside fit().

Column names

The data is synthetic Tashkent rental listings, and its column names are Uzbek. xonalar rooms, maydon_m2 area in m², metro_km distance to the metro, mebel furnished (0/1), narx_usd monthly rent in USD.

Step 1: measure the error

python19 lines
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["maydon_m2"]                      # ONE feature first, easier to see
X = df[num].to_numpy(dtype=float)
y = df["narx_usd"].to_numpy(dtype=float)

def predict(X, w, b):
    return X @ w + b

def mse(X, y, w, b):
    err = predict(X, w, b) - y
    return float(np.mean(err ** 2))

# a few different guesses
for w_t, b_t in [(np.array([5.0]), 200.0), (np.array([7.5]), 180.0), (np.array([12.0]), 50.0)]:
    print(f"w={w_t[0]:>5.1f}  b={b_t:>5.0f}  ->  MSE = {mse(X, y, w_t, b_t):>12,.0f}".replace(",", " "))
Output
w=  5.0  b=  200  ->  MSE =      105 898
w=  7.5  b=  180  ->  MSE =       41 789
w= 12.0  b=   50  ->  MSE =       14 025

MSE (Mean Squared Error) is the average squared error:

Why squared? Three reasons:

python5 lines
show(pd.DataFrame([
    {"Reason": "Removing the sign", "Note": "A +50 and a -50 error must not cancel each other out"},
    {"Reason": "Punishing big errors more", "Note": "An error of 10 -> 100; an error of 100 -> 10 000"},
    {"Reason": "An easy derivative", "Note": "The derivative of a square is linear, so gradients are convenient"},
]))
ReasonNote
Removing the signA +50 and a -50 error must not cancel each other out
Punishing big errors moreAn error of 10 -> 100; an error of 100 -> 10 000
An easy derivativeThe derivative of a square is linear, so gradients are convenient

MSE is very sensitive to outliers

The square amplifies large errors. If the data has a single price = 999999, it drags the whole MSE, and the whole model, with it.

In that case you use MAE (absolute error) or HuberRegressor. We compare them in Chapter 15.

The cost function is a bowl

Let's draw the MSE for one weight. We fix b and change only w.

python15 lines
w_range = np.linspace(0, 20, 200)
cost = [mse(X, y, np.array([wi]), 180.0) for wi in w_range]
best_i = int(np.argmin(cost))

figure, ax = plt.subplots(figsize=(9.5, 5))
ax.plot(w_range, cost, "b-", lw=2)
ax.plot(w_range[best_i], cost[best_i], "ro", ms=11,
        label=f"lowest: w={w_range[best_i]:.2f}")
ax.set_xlabel("w (the weight of maydon_m2)")
ax.set_ylabel("MSE (cost)")
ax.set_title("The cost function is shaped like a bowl", fontweight="bold")
ax.legend(); ax.grid(alpha=.3)
fig("kosa.png", caption="MSE is shaped like a bowl. The lowest point is the best weight. Gradient descent walks down to it.")

print(f"Lowest point: w = {w_range[best_i]:.3f},  MSE = {cost[best_i]:,.0f}".replace(",", " "))
Output
Lowest point: w = 10.352   MSE = 11 057

MSE is shaped like a bowl. The lowest point is the best weight. Gradient descent walks down to it.

MSE is shaped like a bowl. The lowest point is the best weight. Gradient descent walks down to it.

Now the task is clear: find the bottom of the bowl.

With one weight you can just try 200 values. But with 4 weights that's

billion combinations. With 100 weights it's impossible.

So we need another way.

Step 2: the derivative tells you which way is down

Imagine you're on a mountain in thick fog and you want to get down. You can't see the whole valley, but you can feel the slope under your feet.

Whichever way the slope goes down, you step that way. Repeat. You reach the bottom.

The derivative is that slope, in maths.

python12 lines
def gradient(X, y, w, b):
    """The derivatives of MSE with respect to w and b."""
    n = len(y)
    err = predict(X, w, b) - y            # shape (n,)
    dw = (2 / n) * (X.T @ err)            # shape (m,)
    db = (2 / n) * np.sum(err)
    return dw, db

for wi in [3.0, 7.5, 15.0]:
    dw, db = gradient(X, y, np.array([wi]), 180.0)
    direction = "w should GO UP" if dw[0] < 0 else "w should GO DOWN"
    print(f"w={wi:>5.1f}  ->  dw={dw[0]:>12,.1f}   {direction}".replace(",", " "))
Output
w=  3.0  ->  dw=   -54 337.2   w should GO UP
w=  7.5  ->  dw=   -21 257.9   w should GO UP
w= 15.0  ->  dw=    33 874.3   w should GO DOWN

The gradient's sign gives the direction; its size gives the steepness.

The formulas (for a linear model with MSE):

Notice: X.T @ err is the operation from Chapter 12. Nothing new.

Gradient descent: the rule in one line

In words: subtract the gradient from the weight, but first multiply it by a small number. That small number is alpha, the learning rate.

python13 lines
def gradient_descent(X, y, alpha=0.0001, steps=200):
    w = np.zeros(X.shape[1])
    b = 0.0
    history = []
    for i in range(steps):
        dw, db = gradient(X, y, w, b)
        w = w - alpha * dw
        b = b - alpha * db
        history.append({"step": i, "w": float(w[0]), "b": b, "mse": mse(X, y, w, b)})
    return w, b, pd.DataFrame(history)

w_fin, b_fin, history = gradient_descent(X, y, alpha=0.0002, steps=400)
show(history.iloc[[0, 1, 2, 5, 20, 100, 399]].round(3))
stepwbmse
019.3950.31156 913.295
110.2690.17746 637.126
214.5630.25322 220.079
513.0450.25515 350.696
2013.1860.38715 269.7
10013.1761.07815 239.823
39913.1363.64115 130.008
python7 lines
print(f"Start : w=0.000     MSE={history.mse.iloc[0]:>12,.0f}".replace(",", " "))
print(f"End   : w={w_fin[0]:.3f}   MSE={history.mse.iloc[-1]:>12,.0f}".replace(",", " "))
print()
from sklearn.linear_model import LinearRegression
lr = LinearRegression().fit(X, y)
print(f"sklearn's answer: w={lr.coef_[0]:.3f}  b={lr.intercept_:.2f}")
print(f"Our answer      : w={w_fin[0]:.3f}  b={b_fin:.2f}")
Output
Start : w=0.000     MSE=     156 913
End   : w=13.136   MSE=      15 130

sklearn's answer: w=10.132  b=196.68
Our answer      : w=13.136  b=3.64

Look at the numbers. They don't match:

  • w: we got 13.136, sklearn 10.132
  • b: we got 3.64, sklearn 196.68

b is terribly far behind: it started at 0, reached only 3.64, and didn't get anywhere near 196.68.

Why? 400 steps aren't enough. But how many is "enough"? Let's measure.

python9 lines
print("How many steps unscaled data needs:")
print(f"{'steps':>10} {'w':>9} {'b':>10} {'MSE':>12}")
print("-" * 44)
for n in [400, 5000, 50000, 300000]:
    w_t, b_t, h_t = gradient_descent(X, y, alpha=0.0002, steps=n)
    mse_text = f"{h_t.mse.iloc[-1]:,.0f}".replace(",", " ")
    print(f"{n:>10} {w_t[0]:>9.3f} {b_t:>10.2f} {mse_text:>12}")
print()
print(f"sklearn:   w={lr.coef_[0]:.3f}  b={lr.intercept_:.2f}")
Output
How many steps unscaled data needs:
     steps         w          b          MSE
--------------------------------------------
       400    13.136       3.64       15 130
      5000    12.584      39.08       13 760
     50000    10.469     175.02       11 072
    300000    10.132     196.68       11 021

sklearn:   w=10.132  b=196.68

It took 300 000 steps. For one feature and 1200 rows.

That price is unacceptable. And the cause is the data's scale: maydon_m2 runs from 22 to 220, so alpha is forced to be tiny, and b's gradient gets multiplied by that same tiny alpha.

The fix comes in the next section.

python14 lines
figure, axes = plt.subplots(1, 2, figsize=(14, 4.8))
axes[0].plot(history.step, history.mse, "b-", lw=2)
axes[0].set_xlabel("Step"); axes[0].set_ylabel("MSE")
axes[0].set_title("The cost falls step by step", fontweight="bold")
axes[0].grid(alpha=.3)

axes[1].plot(w_range, cost, "k-", lw=1.5, alpha=.5, label="cost function")
some_steps = history.iloc[::12]
axes[1].plot(some_steps.w, [mse(X, y, np.array([wi]), 180.0) for wi in some_steps.w],
             "ro-", ms=5, lw=1, label="gradient descent path")
axes[1].set_xlabel("w"); axes[1].set_ylabel("MSE")
axes[1].set_title("Walking down the bowl", fontweight="bold")
axes[1].legend(); axes[1].grid(alpha=.3)
fig("gd_yoli.png", caption="Left: the cost goes down at every step. Right: what those steps look like on the bowl.")

Left: the cost goes down at every step. Right: what those steps look like on the bowl.

Left: the cost goes down at every step. Right: what those steps look like on the bowl.

The learning rate: the most important setting

If alpha is too small, it's slow. If it's too big, it overshoots the bowl and explodes.

python14 lines
print(f"{'alpha':>10} {'w after 400 steps':>22} {'MSE':>16} {'status':>14}")
print("-" * 68)
for a in [0.000001, 0.00002, 0.0002, 0.001, 0.01]:
    w_t, b_t, h = gradient_descent(X, y, alpha=a, steps=400)
    m = h.mse.iloc[-1]
    if not np.isfinite(m) or m > 1e12:
        status, m_text = "EXPLODED", "inf"
    elif abs(w_t[0] - lr.coef_[0]) < 0.5:
        status, m_text = "converged", f"{m:,.0f}".replace(",", " ")
    else:
        status, m_text = "too slow", f"{m:,.0f}".replace(",", " ")
    print(f"{a:>10} {w_t[0]:>22.3f} {m_text:>16} {status:>14}")
print()
print(f"sklearn's answer: w = {lr.coef_[0]:.3f}")
Output
     alpha      w after 400 steps              MSE         status
--------------------------------------------------------------------
     1e-06                 12.500           17 022       too slow
     2e-05                 13.184           15 263       too slow
    0.0002                 13.136           15 130       too slow
     0.001                    nan              inf       EXPLODED
      0.01                    nan              inf       EXPLODED

sklearn's answer: w = 10.132

The table shows three states: too slow, right, exploded. The window between them is not wide.

Why gradient descent REQUIRES scaling

The alpha values above are tiny: 0.0002. Why?

Because maydon_m2 runs from 22 to 220. The gradient is proportional to that size, so the step has to be very small.

If the columns are on different scales, one alpha can't fit them all: it's too big for one and too small for another. The bowl is "stretched" and gradient descent zigzags.

After StandardScaler, the bowl becomes round and one alpha works for every direction. This is the real reason behind the "scaling is required" rule from Chapter 5.

python8 lines
from sklearn.preprocessing import StandardScaler
Xs = StandardScaler().fit_transform(X)
print("On scaled data a MUCH bigger alpha works:")
print(f"{'alpha':>8} {'w':>10} {'MSE':>14}")
print("-" * 34)
for a in [0.001, 0.01, 0.1, 0.5]:
    w_t, b_t, h = gradient_descent(Xs, y, alpha=a, steps=400)
    print(f"{a:>8} {w_t[0]:>10.2f} {h.mse.iloc[-1]:>13,.0f}".replace(",", " "))
Output
On scaled data a MUCH bigger alpha works:
   alpha          w            MSE
----------------------------------
   0.001     112.41       140 823
    0.01     203.93        11 021
     0.1     203.99        11 021
     0.5     203.99        11 021

After scaling, even alpha=0.1 works. Before scaling, it would have exploded.

With several features

python13 lines
num4 = ["xonalar", "maydon_m2", "metro_km", "mebel"]
X4 = StandardScaler().fit_transform(df[num4].to_numpy(dtype=float))
w4, b4, h4 = gradient_descent(X4, y, alpha=0.1, steps=1500)

lr4 = LinearRegression().fit(X4, y)
compare = pd.DataFrame({
    "feature": num4,
    "we found": w4.round(2),
    "sklearn": lr4.coef_.round(2),
})
show(compare)
print(f"bias: ours {b4:.2f}  |  sklearn {lr4.intercept_:.2f}")
print(f"MSE : ours {h4.mse.iloc[-1]:,.0f}  |  sklearn {mse(X4, y, lr4.coef_, lr4.intercept_):,.0f}".replace(",", " "))
Output
bias: ours 776.10  |  sklearn 776.10
MSE : ours 6 567  |  sklearn 6 567
featurewe foundsklearn
xonalar62.0262.02
maydon_m2147.32147.32
metro_km-44.55-44.55
mebel43.3643.36

Nothing in our code changed; X just got more columns. Because gradient() is written with matrix operations, it works with any number of columns.

Three kinds of gradient descent

So far we used all the data at every step. There are other options.

python8 lines
show(pd.DataFrame([
    {"Kind": "Batch GD", "Per step": "ALL rows", "Speed": "Slow",
     "Path": "Smooth", "When": "Small data"},
    {"Kind": "Stochastic GD (SGD)", "Per step": "ONE row", "Speed": "Fast",
     "Path": "Very noisy", "When": "Huge data, online training"},
    {"Kind": "Mini-batch GD", "Per step": "32-256 rows", "Speed": "Fast",
     "Path": "Moderately noisy", "When": "The MOST USED in practice"},
]))
KindPer stepSpeedPathWhen
Batch GDALL rowsSlowSmoothSmall data
Stochastic GD (SGD)ONE rowFastVery noisyHuge data, online training
Mini-batch GD32-256 rowsFastModerately noisyThe MOST USED in practice
python18 lines
def mini_batch_gd(X, y, alpha=0.1, epochs=30, batch=64, seed=42):
    r = np.random.default_rng(seed)
    w, b = np.zeros(X.shape[1]), 0.0
    history = []
    n = len(y)
    for e in range(epochs):
        order = r.permutation(n)
        for start in range(0, n, batch):
            idx = order[start:start + batch]
            dw, db = gradient(X[idx], y[idx], w, b)
            w, b = w - alpha * dw, b - alpha * db
        history.append(mse(X, y, w, b))
    return w, b, history

w_mb, b_mb, hist_mb = mini_batch_gd(X4, y)
print(f"Batch GD      (1500 steps): MSE = {h4.mse.iloc[-1]:>10,.0f}".replace(",", " "))
print(f"Mini-batch GD (30 epochs) : MSE = {hist_mb[-1]:>10,.0f}".replace(",", " "))
print(f"sklearn                   : MSE = {mse(X4, y, lr4.coef_, lr4.intercept_):>10,.0f}".replace(",", " "))
Output
Batch GD      (1500 steps): MSE =      6 567
Mini-batch GD (30 epochs) : MSE =      6 584
sklearn                   : MSE =      6 567

Mini-batch reached a similar result with less computation. Neural networks (Chapters 30–31) use exactly this method.

Why this chapter matters

python7 lines
show(pd.DataFrame([
    {"Where": "Linear regression (Chapter 15)", "Role of gradient descent": "Finds the weights"},
    {"Where": "Logistic regression (Chapter 16)", "Role of gradient descent": "The same, with another cost function"},
    {"Where": "Ridge / Lasso (Chapter 17)", "Role of gradient descent": "A penalty is added to the cost"},
    {"Where": "Neural network (Chapter 31)", "Role of gradient descent": "Backpropagation is the gradient along a chain"},
    {"Where": "XGBoost (Chapter 20)", "Role of gradient descent": "It's in the name: Gradient Boosting"},
]))
WhereRole of gradient descent
Linear regression (Chapter 15)Finds the weights
Logistic regression (Chapter 16)The same, with another cost function
Ridge / Lasso (Chapter 17)A penalty is added to the cost
Neural network (Chapter 31)Backpropagation is the gradient along a chain
XGBoost (Chapter 20)It's in the name: Gradient Boosting

You've just learned one mechanism, and it comes back in half the book. Trees (Chapter 18) are the exception; they work in a completely different way.

What we learned today

python10 lines
show(pd.DataFrame([
    {"Concept": "Cost function", "In one sentence": "Measures the error as one number (MSE)"},
    {"Concept": "Why squared", "In one sentence": "Removes the sign, punishes big errors, easy derivative"},
    {"Concept": "The cost is a bowl", "In one sentence": "The task: find the bottom of the bowl"},
    {"Concept": "Gradient", "In one sentence": "The slope: its sign is the direction, its size the steepness"},
    {"Concept": "The GD rule", "In one sentence": "w := w - alpha * gradient"},
    {"Concept": "Learning rate", "In one sentence": "Small is slow, big explodes; the window is narrow"},
    {"Concept": "Why scaling is needed", "In one sentence": "It makes the bowl round, so one alpha works everywhere"},
    {"Concept": "Mini-batch", "In one sentence": "The variant used most in practice"},
]))
ConceptIn one sentence
Cost functionMeasures the error as one number (MSE)
Why squaredRemoves the sign, punishes big errors, easy derivative
The cost is a bowlThe task: find the bottom of the bowl
GradientThe slope: its sign is the direction, its size the steepness
The GD rulew := w - alpha * gradient
Learning rateSmall is slow, big explodes; the window is narrow
Why scaling is neededIt makes the bowl round, so one alpha works everywhere
Mini-batchThe variant used most in practice

Next chapter: it turns out linear regression doesn't need gradient descent. You can find the answer with one formula, and we'll see when to choose which.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents