The normal equation vs gradient descent
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.
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))
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. ]]
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)")
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
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_])))
The same: True
| parameter | normal equation | sklearn | diff |
|---|---|---|---|
| bias | 229.507 | 229.507 | 0 |
| xonalar | 57.963 | 57.963 | 0 |
| maydon_m2 | 7.317 | 7.317 | 0 |
| metro_km | -27.8 | -27.8 | 0 |
| mebel | 89.005 | 89.005 | 0 |
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
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(",", " "))
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:
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")
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.
# 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)}")
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().
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))}")
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
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"}, ]))
| Property | Normal equation | Gradient descent |
|---|---|---|
| Iterations | NONE, one go | Thousands of steps |
| Learning rate | NOT NEEDED | Must be chosen, delicate |
| Scaling | Not needed | REQUIRED (Chapter 13) |
| Many columns (m > 10 000) | Very slow, O(m^3) | Works |
| Many rows (n > 1 million) | Memory problems | Works with mini-batches |
| Data arrives as a stream | Doesn't work | Works with SGD |
| Multicollinearity | inv() falls over (needs pinv) | Works |
| Fits other models | ONLY linear + MSE | Logistic, 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.
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"}, ]))
| Model | Closed-form formula? | What sklearn uses |
|---|---|---|
| Linear Regression | YES | lstsq (closed form) |
| Ridge | YES | Closed form or iterative (solver=) |
| Lasso | NO | Coordinate descent |
| Logistic Regression | NO | lbfgs / newton-cg (iterative) |
| Neural network | NO | Mini-batch GD (Adam) |
| XGBoost | NO | Gradient 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.
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"}, ]))
| Situation | Choice |
|---|---|
| Small data, few columns | Normal equation (LinearRegression) |
| More than 10 000 columns | SGDRegressor or Ridge(solver='sag') |
| Millions of rows | SGDRegressor (mini-batch) |
| Data arrives as a stream | SGDRegressor + partial_fit |
| A non-linear model | No other way: gradient descent |
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")
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
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"}, ]))
| Concept | In one sentence |
|---|---|
| Normal equation | w = (X^T X)^-1 X^T y: the exact answer in one go |
| Inside LinearRegression | NOT gradient descent: a closed-form solution (lstsq) |
| The danger of inv() | Fails under multicollinearity; use pinv() or lstsq() |
| Cost of the normal equation | O(m^3): expensive with many columns |
| Cost of GD | Linear in the number of columns: cheaper for large m |
| Why GD is universal | A 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.