Skip to content

The normal equation vs gradient descent

Chapter 14 12 code blocks 3079 words

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

The scene: two roads, one answer

In Chapter 13 we found the bottom of the bowl by walking down step by step. It took 300 000 steps.

Now I'm telling you: for linear regression you don't need steps. You can find the answer with one formula, in one go.

This is the normal equation. It has no alpha, no steps, no iterations.

Which raises a question: if there's an exact formula, why do we need gradient descent at all?

This chapter answers that question. And the answer comes from measurement.

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.

Where the normal equation comes from

At the bottom of the bowl the slope is zero. So we set the gradient to zero:

Three operations: X.T @ X, a matrix inverse, X.T @ y. All familiar from Chapter 12.

python13 lines
import pandas as pd
import numpy as np
import time

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "metro_km", "mebel"]
X_raw = df[num].to_numpy(dtype=float)
y = df["narx_usd"].to_numpy(dtype=float)

# add a column of ones for the bias
X = np.c_[np.ones(len(X_raw)), X_raw]
print(f"X shape (with the bias column): {X.shape}")
print(X[:3].round(2))
Output
X shape (with the bias column): (1200, 5)
[[  1.     2.    40.6    1.43   1.  ]
 [  1.     2.    55.5    4.18   1.  ]
 [  1.     5.   114.     3.84   1.  ]]
python13 lines
def normal_equation(X, y):
    return np.linalg.inv(X.T @ X) @ X.T @ y

t0 = time.perf_counter()
theta = normal_equation(X, y)
ne_time = time.perf_counter() - t0

print("Normal equation result:")
print(f"  bias (b)  : {theta[0]:>8.3f}")
for name, v in zip(num, theta[1:]):
    print(f"  {name:10s}: {v:>8.3f}")
print()
print(f"Compute time: {ne_time*1000:.3f} ms  (in ONE go)")
Output
Normal equation result:
  bias (b)  :  229.507
  xonalar   :   57.963
  maydon_m2 :    7.317
  metro_km  :  -27.800
  mebel     :   89.005

Compute time: 0.054 ms  (in ONE go)

Checking against sklearn

python12 lines
from sklearn.linear_model import LinearRegression

lr = LinearRegression().fit(X_raw, y)
compare = pd.DataFrame({
    "parameter": ["bias"] + num,
    "normal equation": np.round(theta, 4),
    "sklearn": np.round(np.r_[lr.intercept_, lr.coef_], 4),
})
compare["diff"] = (compare["normal equation"] - compare["sklearn"]).abs().round(10)
show(compare)
print()
print("The same:", bool(np.allclose(theta, np.r_[lr.intercept_, lr.coef_])))
Output
The same: True
parameternormal equationsklearndiff
bias229.507229.5070
xonalar57.96357.9630
maydon_m27.3177.3170
metro_km-27.8-27.80
mebel89.00589.0050

Exactly the same. Because there is no gradient descent inside LinearRegression: it uses a closed-form solution too (more precisely, scipy's least-squares solver).

That's an important fact: many students think LinearRegression().fit() "runs gradient descent". It doesn't.

Now we compare with gradient descent

python24 lines
from sklearn.preprocessing import StandardScaler

def mse(X, y, theta):
    return float(np.mean((X @ theta - y) ** 2))

def gd(X, y, alpha, steps):
    theta = np.zeros(X.shape[1])
    for _ in range(steps):
        grad = (2 / len(y)) * X.T @ (X @ theta - y)
        theta = theta - alpha * grad
    return theta

# GD works well on scaled data (Chapter 13)
Xs_raw = StandardScaler().fit_transform(X_raw)
Xs = np.c_[np.ones(len(Xs_raw)), Xs_raw]

theta_ne_s = normal_equation(Xs, y)
print(f"{'method':<28} {'MSE':>12} {'time (ms)':>11}")
print("-" * 53)
t0 = time.perf_counter(); tn = normal_equation(Xs, y); tv = (time.perf_counter()-t0)*1000
print(f"{'Normal equation':<28} {mse(Xs, y, tn):>12,.1f} {tv:>11.3f}".replace(",", " "))
for n in [100, 1000, 10000]:
    t0 = time.perf_counter(); tg = gd(Xs, y, 0.1, n); tv = (time.perf_counter()-t0)*1000
    print(f"{'GD (' + str(n) + ' steps)':<28} {mse(Xs, y, tg):>12,.1f} {tv:>11.3f}".replace(",", " "))
Output
method                                MSE   time (ms)
-----------------------------------------------------
Normal equation                   6 566.6       0.016
GD (100 steps)                    6 586.5       0.439
GD (1000 steps)                   6 566.6       4.190
GD (10000 steps)                  6 566.6      41.940

On scaled data, gradient descent catches the normal equation after 1000 steps. But the normal equation found it in one go, and ran faster.

So why do we need gradient descent?

The answer: the number of columns

The normal equation contains the matrix (X.T @ X). Its size is columns x columns. And it has to be inverted.

The cost of inverting grows cubically with the number of columns:

.

python11 lines
print("How long the normal equation takes as the number of columns grows:")
print(f"{'columns':>10} {'X.T@X shape':>16} {'normal equation':>18} {'GD (200 steps)':>17}")
print("-" * 66)
r = np.random.default_rng(42)
for m in [10, 100, 500, 1500]:
    n = 3000
    Xb = r.normal(size=(n, m))
    yb = Xb @ r.normal(size=m) + r.normal(scale=.5, size=n)
    t0 = time.perf_counter(); normal_equation(Xb, yb); nt = (time.perf_counter()-t0)*1000
    t0 = time.perf_counter(); gd(Xb, yb, 0.01, 200); gt = (time.perf_counter()-t0)*1000
    print(f"{m:>10} {f'{m}x{m}':>16} {nt:>15.1f} ms {gt:>14.1f} ms")
Output
How long the normal equation takes as the number of columns grows:
   columns      X.T@X shape    normal equation    GD (200 steps)
------------------------------------------------------------------
        10            10x10             0.1 ms            2.8 ms
       100          100x100             1.9 ms           26.7 ms
       500          500x500            11.5 ms          151.8 ms
      1500        1500x1500            91.6 ms         1246.8 ms

Read the table: as the number of columns grows, the normal equation's time shoots up, while gradient descent grows much more gently.

These timings are from MY computer

The exact milliseconds will differ for you: the processor, the BLAS library and the load all matter.

But the ratio doesn't change: as m grows, the normal equation grows cubically and gradient descent linearly. So at some point GD becomes cheaper.

Run this code on your own computer and find your own crossover point.

The worst case: a matrix that can't be inverted

The normal equation has one more problem, and it happens in practice.

python17 lines
# two columns are IDENTICAL (perfectly correlated); this happens in real data too
X_broken = np.c_[np.ones(100), r.normal(size=100)]
X_broken = np.c_[X_broken, X_broken[:, 1]]       # column 3 is a copy of column 2
y_broken = r.normal(size=100)

print(f"X shape: {X_broken.shape}")
print(f"determinant of X.T @ X: {np.linalg.det(X_broken.T @ X_broken):.3e}   <- very close to zero")
print()
try:
    np.linalg.inv(X_broken.T @ X_broken)
    print("inv() worked (but the result is unreliable)")
except np.linalg.LinAlgError as e:
    print(f"inv() raised an ERROR: {e}")
print()
print("THE FIX: pinv(), the pseudo-inverse (sklearn uses it too)")
theta_p = np.linalg.pinv(X_broken) @ y_broken
print(f"  pinv() worked, result: {theta_p.round(4)}")
Output
X shape: (100, 3)
determinant of X.T @ X: 0.000e+00   <- very close to zero

inv() raised an ERROR: Singular matrix

THE FIX: pinv(), the pseudo-inverse (sklearn uses it too)
  pinv() worked, result: [-0.0836  0.0039  0.0039]

That's why in practice you use pinv() or np.linalg.lstsq(), not inv().

python8 lines
print("Do the three methods give the same answer on our data:")
t1 = np.linalg.inv(X.T @ X) @ X.T @ y
t2 = np.linalg.pinv(X) @ y
t3 = np.linalg.lstsq(X, y, rcond=None)[0]
print(f"  inv  : {t1.round(4)}")
print(f"  pinv : {t2.round(4)}")
print(f"  lstsq: {t3.round(4)}")
print(f"  All agree: {bool(np.allclose(t1, t2) and np.allclose(t2, t3))}")
Output
Do the three methods give the same answer on our data:
  inv  : [229.5068  57.9632   7.3175 -27.8001  89.0049]
  pinv : [229.5068  57.9632   7.3175 -27.8001  89.0049]
  lstsq: [229.5068  57.9632   7.3175 -27.8001  89.0049]
  All agree: True

Don't use inv(): use pinv() or lstsq()

Books write the formula as (X^T X)^{-1}, so students write np.linalg.inv.

In practice that's a mistake: when columns are related to each other (multicollinearity, the 0.94 from Chapter 4!), the matrix can't be inverted or the result is unstable.

pinv() works even then. lstsq() is more stable still, and faster.

Side by side

python10 lines
show(pd.DataFrame([
    {"Property": "Iterations", "Normal equation": "NONE, one go", "Gradient descent": "Thousands of steps"},
    {"Property": "Learning rate", "Normal equation": "NOT NEEDED", "Gradient descent": "Must be chosen, delicate"},
    {"Property": "Scaling", "Normal equation": "Not needed", "Gradient descent": "REQUIRED (Chapter 13)"},
    {"Property": "Many columns (m > 10 000)", "Normal equation": "Very slow, O(m^3)", "Gradient descent": "Works"},
    {"Property": "Many rows (n > 1 million)", "Normal equation": "Memory problems", "Gradient descent": "Works with mini-batches"},
    {"Property": "Data arrives as a stream", "Normal equation": "Doesn't work", "Gradient descent": "Works with SGD"},
    {"Property": "Multicollinearity", "Normal equation": "inv() falls over (needs pinv)", "Gradient descent": "Works"},
    {"Property": "Fits other models", "Normal equation": "ONLY linear + MSE", "Gradient descent": "Logistic, NN, boosting: all of them"},
]))
PropertyNormal equationGradient descent
IterationsNONE, one goThousands of steps
Learning rateNOT NEEDEDMust be chosen, delicate
ScalingNot neededREQUIRED (Chapter 13)
Many columns (m > 10 000)Very slow, O(m^3)Works
Many rows (n > 1 million)Memory problemsWorks with mini-batches
Data arrives as a streamDoesn't workWorks with SGD
Multicollinearityinv() falls over (needs pinv)Works
Fits other modelsONLY linear + MSELogistic, NN, boosting: all of them

The last row matters most.

The normal equation exists only for linear regression with MSE. Logistic regression has no closed-form formula: its cost function (log loss) can't be solved analytically. Neither can a neural network.

In other words: the normal equation is a neat shortcut for one special case. Gradient descent is the universal mechanism.

python8 lines
show(pd.DataFrame([
    {"Model": "Linear Regression", "Closed-form formula?": "YES", "What sklearn uses": "lstsq (closed form)"},
    {"Model": "Ridge", "Closed-form formula?": "YES", "What sklearn uses": "Closed form or iterative (solver=)"},
    {"Model": "Lasso", "Closed-form formula?": "NO", "What sklearn uses": "Coordinate descent"},
    {"Model": "Logistic Regression", "Closed-form formula?": "NO", "What sklearn uses": "lbfgs / newton-cg (iterative)"},
    {"Model": "Neural network", "Closed-form formula?": "NO", "What sklearn uses": "Mini-batch GD (Adam)"},
    {"Model": "XGBoost", "Closed-form formula?": "NO", "What sklearn uses": "Gradient boosting"},
]))
ModelClosed-form formula?What sklearn uses
Linear RegressionYESlstsq (closed form)
RidgeYESClosed form or iterative (solver=)
LassoNOCoordinate descent
Logistic RegressionNOlbfgs / newton-cg (iterative)
Neural networkNOMini-batch GD (Adam)
XGBoostNOGradient boosting

When to choose which

In practice the answer is simple: you don't choose. You write LinearRegression() and sklearn picks the right method itself.

But you need to know why, especially when the model is slow or falls over.

python7 lines
show(pd.DataFrame([
    {"Situation": "Small data, few columns", "Choice": "Normal equation (LinearRegression)"},
    {"Situation": "More than 10 000 columns", "Choice": "SGDRegressor or Ridge(solver='sag')"},
    {"Situation": "Millions of rows", "Choice": "SGDRegressor (mini-batch)"},
    {"Situation": "Data arrives as a stream", "Choice": "SGDRegressor + partial_fit"},
    {"Situation": "A non-linear model", "Choice": "No other way: gradient descent"},
]))
SituationChoice
Small data, few columnsNormal equation (LinearRegression)
More than 10 000 columnsSGDRegressor or Ridge(solver='sag')
Millions of rowsSGDRegressor (mini-batch)
Data arrives as a streamSGDRegressor + partial_fit
A non-linear modelNo other way: gradient descent
python15 lines
from sklearn.linear_model import SGDRegressor
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_score

print("Two sklearn models, the same data:")
lr_scores = cross_val_score(LinearRegression(), Xs_raw, y, cv=5, scoring="r2")
sgd_scores = cross_val_score(SGDRegressor(max_iter=5000, tol=1e-4, random_state=42),
                             Xs_raw, y, cv=5, scoring="r2")
print(f"  LinearRegression (closed form)  : R2 = {lr_scores.mean():.4f} ±{lr_scores.std():.4f}")
print(f"  SGDRegressor     (gradient desc): R2 = {sgd_scores.mean():.4f} ±{sgd_scores.std():.4f}")
print()
print(f"Gap: {abs(lr_scores.mean() - sgd_scores.mean()):.4f}  |  spread: ±{max(lr_scores.std(), sgd_scores.std()):.4f}")
print("Verdict:", "the gap is within the spread, so both reach the same answer"
      if abs(lr_scores.mean()-sgd_scores.mean()) < max(lr_scores.std(), sgd_scores.std())
      else "the gap is noticeable")
Output
Two sklearn models, the same data:
  LinearRegression (closed form)  : R2 = 0.8741 ±0.0082
  SGDRegressor     (gradient desc): R2 = 0.8741 ±0.0083

Gap: 0.0000  |  spread: ±0.0083
Verdict: the gap is within the spread, so both reach the same answer

We reached the verdict with the rule from Chapter 10: compare the gap with the spread of the folds.

What we learned today

python8 lines
show(pd.DataFrame([
    {"Concept": "Normal equation", "In one sentence": "w = (X^T X)^-1 X^T y: the exact answer in one go"},
    {"Concept": "Inside LinearRegression", "In one sentence": "NOT gradient descent: a closed-form solution (lstsq)"},
    {"Concept": "The danger of inv()", "In one sentence": "Fails under multicollinearity; use pinv() or lstsq()"},
    {"Concept": "Cost of the normal equation", "In one sentence": "O(m^3): expensive with many columns"},
    {"Concept": "Cost of GD", "In one sentence": "Linear in the number of columns: cheaper for large m"},
    {"Concept": "Why GD is universal", "In one sentence": "A closed form exists only for linear + MSE"},
]))
ConceptIn one sentence
Normal equationw = (X^T X)^-1 X^T y: the exact answer in one go
Inside LinearRegressionNOT gradient descent: a closed-form solution (lstsq)
The danger of inv()Fails under multicollinearity; use pinv() or lstsq()
Cost of the normal equationO(m^3): expensive with many columns
Cost of GDLinear in the number of columns: cheaper for large m
Why GD is universalA closed form exists only for linear + MSE

That completes Part 3. You now know what's inside fit(): a cost function, a gradient and a solving method.

In the next part, real algorithms are built on top of this mechanism, starting with linear regression.

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