Regression Is Not PCA
Math 123: Mathematical Aspects of Data Analysis — Fall 2026
This page is meant to be read alongside Lecture 7. Both least squares and PCA “fit features to a point cloud”, so it is worth being precise about how they differ. We fit both to the same \(20\) points in the plane and compare.
Two fits
Least squares treats \(y\) as the response, with design matrix \(\bfX=[\mathbf{1}\ \bfx]\in\Rbb^{n\times2}\), and chooses \(\theta_0,\theta_1\) to minimize the sum of squared vertical distances \(\sum_i(y_i-\theta_0-\theta_1x_i)^2\).
PCA treats the points \((x_i,y_i)\) as rows of a data matrix, centers it, and takes the line through the mean in the direction of the top right singular vector \(\bfv_1\). By best-fitting subspaces come from the SVD, this line minimizes the sum of squared perpendicular distances from the points to a line through the mean, and the minimum is \(\sigma_2^2\).
# Least squares: regress y on [1, x].
X = np.column_stack([np.ones(n), x])
(ls_int, ls_slope), *_ = np.linalg.lstsq(X, y, rcond=None)
# PCA: the top right singular vector of the centered data.
mean = np.array([x.mean(), y.mean()])
D = np.column_stack([x, y]) - mean
_, s, Vt = np.linalg.svd(D, full_matrices=False)
v1 = Vt[0]
pca_slope = v1[1] / v1[0]
pca_int = mean[1] - pca_slope * mean[0]
print(f"least squares: y = {ls_int:.3f} + {ls_slope:.3f} x")
print(f"PCA: y = {pca_int:.3f} + {pca_slope:.3f} x")least squares: y = 1.461 + 0.587 x
PCA: y = 0.589 + 0.750 x
Figure 1 draws each line together with the distances it minimizes.
Plot both fits and their residuals
# The same muted palette as the other examples, which reads correctly against
# both the light and the dark version of this site.
INK = "#7c7c78"
GRID = "rgba(128, 128, 128, 0.28)"
BLUE, ORANGE, GREEN = "#2a78d6", "#eb6834", "#1baf7a"
xs = np.array([-0.5, 10.5])
def line(intercept, slope, name, color, dash="solid", showlegend=True):
"""The line y = intercept + slope * x across the plot."""
return go.Scatter(x=xs, y=intercept + slope * xs, mode="lines", name=name,
showlegend=showlegend, hoverinfo="skip",
line=dict(color=color, width=3 if dash == "solid" else 2, dash=dash))
def segments(x0, y0, x1, y1, name):
"""Segments from each (x0, y0) to (x1, y1), as one trace broken by None."""
sx = np.column_stack([x0, x1, np.full(n, np.nan)]).ravel()
sy = np.column_stack([y0, y1, np.full(n, np.nan)]).ravel()
return go.Scatter(x=sx, y=sy, mode="lines", name=name, hoverinfo="skip",
line=dict(color=ORANGE, width=1.5))
points = go.Scatter(x=x, y=y, mode="markers", name="data", showlegend=False,
marker=dict(color=INK, size=8, line=dict(color="white", width=1)))
# Foot of each perpendicular: project the centered point onto v_1.
feet = mean + np.outer(D @ v1, v1)
fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.08,
subplot_titles=["least squares: vertical residuals",
"PCA: perpendicular residuals"])
fig.add_trace(segments(x, y, x, ls_int + ls_slope * x, "vertical residuals"), row=1, col=1)
fig.add_trace(line(pca_int, pca_slope, "the other method's line", INK, "dash"), row=1, col=1)
fig.add_trace(line(ls_int, ls_slope, "least squares line", BLUE), row=1, col=1)
fig.add_trace(points, row=1, col=1)
fig.add_trace(segments(x, y, feet[:, 0], feet[:, 1], "perpendicular residuals"), row=1, col=2)
fig.add_trace(line(ls_int, ls_slope, "least squares line", INK, "dash", showlegend=False),
row=1, col=2)
fig.add_trace(line(pca_int, pca_slope, "PCA line", GREEN), row=1, col=2)
fig.add_trace(points, row=1, col=2)
fig.update_layout(
template="simple_white",
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
font=dict(color=INK, size=13),
margin=dict(l=60, r=30, t=40, b=50),
height=480,
legend=dict(orientation="h", y=-0.15, x=0),
)
# Equal aspect ratio, so that perpendicular segments look perpendicular. Both panels
# get the same fixed ranges and shrink their domain (not their range) to keep the aspect.
ys = [min(y.min(), feet[:, 1].min()) - 0.5, max(y.max(), feet[:, 1].max()) + 0.5]
for col in (1, 2):
fig.update_xaxes(range=xs, constrain="domain", title_text="x", gridcolor=GRID,
linecolor=GRID, zerolinecolor=GRID, row=1, col=col)
fig.update_yaxes(range=ys, constrain="domain", scaleanchor="x" if col == 1 else "x2",
scaleratio=1, gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID,
row=1, col=col)
fig.update_yaxes(title_text="y", row=1, col=1)
fig.show()These are optimizing for fundamentally different things! They each win at their own game. Scoring both lines under both criteria:
def vertical_error(intercept, slope):
return np.sum((y - intercept - slope * x) ** 2)
def perpendicular_error(intercept, slope):
# The distance from (x, y) to the line y = a + b x is |y - a - b x| / sqrt(1 + b^2).
return vertical_error(intercept, slope) / (1 + slope ** 2)
print(f"{'':15s}{'vertical':>12s}{'perpendicular':>16s}")
for name, (a, b) in {"least squares": (ls_int, ls_slope), "PCA": (pca_int, pca_slope)}.items():
print(f"{name:15s}{vertical_error(a, b):12.3f}{perpendicular_error(a, b):16.3f}")
print(f"\nsigma_2^2 = {s[1] ** 2:.3f}") vertical perpendicular
least squares 54.697 40.676
PCA 59.351 37.968
sigma_2^2 = 37.968
The PCA line’s perpendicular error is exactly \(\sigma_2^2\), as heralded by Eckart Young.
To summarize:
- Regression is supervised: one coordinate, \(y\), is singled out as the thing to predict. It assumes the features are measured exactly and all the noise is in \(y\), so it measures error vertically, in the \(y\) direction only.
- PCA is unsupervised: It measures error perpendicular to the subspace, treating all coordinates symmetrically.
In a nutshell, regression asks “how well can \(\bfX\) explain \(\bfy\)?” while PCA asks “what is the most efficient way to represent \(\bfX\)?”
Now, technically, people do study total least squares, which is essentially the PCA version. We wont get into that this course but, yes, it is interesting!