Skip to content

PCA: reducing dimensions

Chapter 27 13 code blocks 3965 words

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

The scene: there is no target

Until now there was always a y. We were always predicting something.

Now a different situation: you only have X. No answers; nobody labelled anything.

What can you do then? Two things:

  1. Reduce the dimensions: bring 100 columns down to 10 without losing information (this chapter)
  2. Grouping: find rows that are alike (Chapter 28)

Step 3 of the protocol

The table in Chapter 1 had this row: "NO target -> Clustering or PCA".

This is that branch. Steps 1-8 of the protocol don't change: you open the file, check the columns, clean. You only turn onto a different road after step 9.

Column names

The rental data has Uzbek column names. xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built. The target ijaraga_berildi_30k means rented within 30 days.

The problem: the curse of dimensionality from Chapter 21

python11 lines
import pandas as pd
import numpy as np

r = np.random.default_rng(42)
print("What happens as columns multiply:")
print(f"{'dimension':>9} {'nearest distance':>18} {'farthest':>10} {'ratio':>8}")
print("-" * 49)
for m in [2, 10, 100, 1000]:
    P = r.normal(size=(400, m))
    d = np.linalg.norm(P[1:] - P[0], axis=1)
    print(f"{m:>9} {d.min():>18.3f} {d.max():>10.3f} {d.max()/d.min():>8.2f}")
Output
What happens as columns multiply:
dimension   nearest distance   farthest    ratio
-------------------------------------------------
        2              0.103      4.301    41.62
       10              1.982      6.687     3.37
      100             11.370     16.832     1.48
     1000             41.602     47.110     1.13

The ratio approaches 1: every point ends up equally far away. KNN, SVM, clustering: everything that relies on distance breaks.

The fix: throw away the excess dimensions. But which ones?

The PCA idea: which direction carries the most information

PCA asks the question differently. It doesn't drop columns: it builds new ones.

The idea: find the direction in which the data is most spread out. That direction carries the most information.

python28 lines
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler

# we show it in two dimensions, so we can see it
X2 = r.multivariate_normal([0, 0], [[3, 2.4], [2.4, 2.2]], size=300)
p2 = PCA(n_components=2).fit(X2)

figure, axes = plt.subplots(1, 2, figsize=(13, 5))
axes[0].scatter(X2[:, 0], X2[:, 1], alpha=.45, s=22, color="#1a73e8")
for ev, vec in zip(p2.explained_variance_, p2.components_):
    axes[0].arrow(0, 0, vec[0]*np.sqrt(ev)*2, vec[1]*np.sqrt(ev)*2,
                  width=.06, color="#c5221f", length_includes_head=True)
axes[0].set_title("The original data and its main directions", fontweight="bold")
axes[0].set_aspect("equal"); axes[0].grid(alpha=.3)

Xp = p2.transform(X2)
axes[1].scatter(Xp[:, 0], Xp[:, 1], alpha=.45, s=22, color="#137333")
axes[1].axhline(0, color="k", lw=.8); axes[1].axvline(0, color="k", lw=.8)
axes[1].set_xlabel("PC1"); axes[1].set_ylabel("PC2")
axes[1].set_title("After PCA: the axes are rotated", fontweight="bold")
axes[1].set_aspect("equal"); axes[1].grid(alpha=.3)
fig("pca_goya.png", caption="The red arrows are the principal components. The longer one is the direction of greatest spread. On the right, those directions become the axes.")

print("How much variance each component explains:")
for i, v in enumerate(p2.explained_variance_ratio_, 1):
    print(f"  PC{i}: {v:.4f}  ({v:.1%})")
print(f"  Total: {p2.explained_variance_ratio_.sum():.4f}")
Output
How much variance each component explains:
  PC1: 0.9629  (96.3%)
  PC2: 0.0371  (3.7%)
  Total: 1.0000

The red arrows are the principal components. The longer one is the direction of greatest spread. On the right, those directions become the axes.

The red arrows are the principal components. The longer one is the direction of greatest spread. On the right, those directions become the axes.

The first component takes most of the variance. If we keep only it, we go from two columns to one and keep most of the information.

Inside PCA: X.T @ X

We saw X.T @ X in Chapter 12. PCA starts from exactly that.

python16 lines
# PCA by hand: the eigenvectors of the covariance matrix
Xm = X2 - X2.mean(axis=0)
cov = (Xm.T @ Xm) / (len(Xm) - 1)
eig_val, eig_vec = np.linalg.eigh(cov)
order = np.argsort(eig_val)[::-1]
eig_val, eig_vec = eig_val[order], eig_vec[:, order]

print("Computed by hand:")
print(f"  eigenvalues : {eig_val.round(4)}")
print(f"  shares      : {(eig_val/eig_val.sum()).round(4)}")
print()
print("sklearn PCA:")
print(f"  variances   : {p2.explained_variance_.round(4)}")
print(f"  shares      : {p2.explained_variance_ratio_.round(4)}")
print()
print(f"Do they match: {bool(np.allclose(eig_val, p2.explained_variance_))}")
Output
Computed by hand:
  eigenvalues : [4.9782 0.1916]
  shares      : [0.9629 0.0371]

sklearn PCA:
  variances   : [4.9782 0.1916]
  shares      : [0.9629 0.0371]

Do they match: True

PCA is the eigenvectors of the covariance matrix. No new maths: just the operations from Chapter 12.

Scaling is MANDATORY for PCA

python16 lines
df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"]
from sklearn.impute import SimpleImputer
Xd = SimpleImputer(strategy="median").fit_transform(df[num])

print("Column variances (raw):")
for name, v in zip(num, Xd.var(axis=0)):
    print(f"  {name:16s} {v:>12.1f}")
print()
p_raw = PCA().fit(Xd)
p_sc = PCA().fit(StandardScaler().fit_transform(Xd))
print(f"{'component':>10} {'no scaling':>14} {'scaling':>13}")
print("-" * 40)
for i in range(4):
    print(f"{'PC'+str(i+1):>10} {p_raw.explained_variance_ratio_[i]:>14.4f} "
          f"{p_sc.explained_variance_ratio_[i]:>13.4f}")
Output
Column variances (raw):
  xonalar                   1.1
  maydon_m2               405.3
  qavat                    13.0
  qavatlar_soni            18.6
  metro_km                  2.6
  mebel                     0.2
  qurilgan_yil            203.2

 component     no scaling       scaling
----------------------------------------
       PC1         0.6309        0.2781
       PC2         0.3155        0.2312
       PC3         0.0398        0.1479
       PC4         0.0092        0.1406

Without scaling, PC1 takes almost all the variance, because qurilgan_yil's variance is hundreds of times larger than the others'. PCA simply finds that column and doesn't see the rest.

PCA doesn't work without scaling

PCA maximises variance. When the columns are in different units (years, metres, kilometres), the column with the biggest numbers wins, not because it matters, but because its unit is large.

That's why PCA is always applied after StandardScaler. The reason is the same as for Ridge/Lasso (Chapter 17) and KNN (Chapter 21).

How many components to keep

python13 lines
Xs = StandardScaler().fit_transform(Xd)
p = PCA().fit(Xs)
cumulative = np.cumsum(p.explained_variance_ratio_)
table = pd.DataFrame({
    "component": [f"PC{i}" for i in range(1, len(num)+1)],
    "variance share": p.explained_variance_ratio_.round(4),
    "cumulative": cumulative.round(4),
})
show(table)
print()
for target in [0.80, 0.90, 0.95, 0.99]:
    n = int(np.searchsorted(cumulative, target) + 1)
    print(f"  for {target:.0%} of the variance you need {n} components (of {len(num)})")
Output
  for 80% of the variance you need 5 components (of 7)
  for 90% of the variance you need 5 components (of 7)
  for 95% of the variance you need 6 components (of 7)
  for 99% of the variance you need 6 components (of 7)
componentvariance sharecumulative
PC10.2780.278
PC20.2310.509
PC30.1480.657
PC40.1410.798
PC50.1380.936
PC60.0550.992
PC70.0091
python14 lines
figure, axes = plt.subplots(1, 2, figsize=(13, 4.5))
axes[0].bar(range(1, len(num)+1), p.explained_variance_ratio_, color="#1a73e8", alpha=.85)
axes[0].set_xlabel("Component"); axes[0].set_ylabel("Variance share")
axes[0].set_title("Scree plot: where is the 'elbow'", fontweight="bold")
axes[0].grid(alpha=.3, axis="y")

axes[1].plot(range(1, len(num)+1), cumulative, "o-", lw=2, color="#137333")
for ch in [0.90, 0.95]:
    axes[1].axhline(ch, ls="--", color="gray", lw=1)
    axes[1].text(len(num)*0.6, ch+0.008, f"{ch:.0%}", fontsize=9, color="gray")
axes[1].set_xlabel("Number of components"); axes[1].set_ylabel("Cumulative variance")
axes[1].set_title("How many are enough", fontweight="bold")
axes[1].grid(alpha=.3)
fig("scree.png", caption="Left: each component's share; you look for the 'elbow'. Right: the cumulative share; you pick a 90% or 95% cut-off.")

Left: each component's share; you look for the 'elbow'. Right: the cumulative share; you pick a 90% or 95% cut-off.

Left: each component's share; you look for the 'elbow'. Right: the cumulative share; you pick a 90% or 95% cut-off.

In sklearn you can pass the cut-off directly:

python3 lines
for ch in [0.90, 0.95]:
    pc = PCA(n_components=ch).fit(Xs)
    print(f"  PCA(n_components={ch}) -> kept {pc.n_components_} components")
Output
  PCA(n_components=0.9) -> kept 5 components
  PCA(n_components=0.95) -> kept 6 components

Does PCA improve a model's result? Let's measure

python24 lines
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier

y = df["ijaraga_berildi_30k"]
Xf = df[num]

print(f"{'variant':<40} {'CV F1':>9} {'±':>8}")
print("-" * 60)
for name, steps in [
    ("Logistic, NO PCA", [("i", SimpleImputer(strategy="median")), ("sc", StandardScaler()),
                          ("m", LogisticRegression(max_iter=3000))]),
    ("Logistic, PCA(0.95)", [("i", SimpleImputer(strategy="median")), ("sc", StandardScaler()),
                             ("p", PCA(n_components=0.95)), ("m", LogisticRegression(max_iter=3000))]),
    ("Logistic, PCA(2 components)", [("i", SimpleImputer(strategy="median")), ("sc", StandardScaler()),
                                     ("p", PCA(n_components=2)), ("m", LogisticRegression(max_iter=3000))]),
    ("KNN, NO PCA", [("i", SimpleImputer(strategy="median")), ("sc", StandardScaler()),
                     ("m", KNeighborsClassifier(n_neighbors=15))]),
    ("KNN, PCA(0.95)", [("i", SimpleImputer(strategy="median")), ("sc", StandardScaler()),
                        ("p", PCA(n_components=0.95)), ("m", KNeighborsClassifier(n_neighbors=15))]),
]:
    b = cross_val_score(Pipeline(steps), Xf, y, cv=5, scoring="f1")
    print(f"{name:<40} {b.mean():>9.4f} {b.std():>8.4f}")
Output
variant                                      CV F1        ±
------------------------------------------------------------
Logistic, NO PCA                            0.7219   0.0106
Logistic, PCA(0.95)                         0.7251   0.0105
Logistic, PCA(2 components)                 0.7302   0.0091
KNN, NO PCA                                 0.7010   0.0162
KNN, PCA(0.95)                              0.7098   0.0184

PCA isn't for raising accuracy, but it doesn't lower it either

I had written this section as "PCA doesn't help, and sometimes hurts". The measurement didn't confirm that.

Look at the table: every PCA variant came out equal to the baseline or slightly higher. The differences are within the spread, so they're not reliable, but no harm showed up either.

The most interesting result is the PCA(2 components) row: dropping 5 of the 7 columns, the model's result didn't fall.

What does that mean? Our 7 columns don't really carry 7 independent pieces of information. xonalar and maydon_m2 correlate at 0.94 (Chapter 4), and qavat and qavatlar_soni are related too. PCA squeezed out that repetition.

So PCA's real role is removing redundancy, not raising accuracy. It's useful for:

  • Very many columns (hundreds, thousands) that are related to each other
  • Visualisation: bringing 50 dimensions down to 2 to look at them
  • Speed: making training faster
  • Reducing noise: the last components are often noise

And one more lesson: I wrote the guess "it hurts", then measured, and it was refuted. Measure every sentence of the form "sometimes this happens", or it stays just a guess.

Where PCA really works: many columns

python26 lines
r2 = np.random.default_rng(7)
# 150 columns, but they're combinations of 8 hidden factors
hidden = r2.normal(size=(len(Xf), 8))
mixing = r2.normal(size=(8, 150))
X_many = pd.DataFrame(hidden @ mixing + r2.normal(scale=.4, size=(len(Xf), 150)),
                      columns=[f"u{i}" for i in range(150)])
# the target is built from the hidden factors
y_many = ((hidden @ r2.normal(size=8) + r2.normal(scale=.5, size=len(Xf))) > 0).astype(int)

import time
print(f"{'variant':<34} {'CV F1':>9} {'±':>8} {'time':>8}")
print("-" * 62)
for name, steps in [
    ("150 columns, NO PCA", [("sc", StandardScaler()), ("m", LogisticRegression(max_iter=3000))]),
    ("PCA(0.95)", [("sc", StandardScaler()), ("p", PCA(n_components=0.95)),
                   ("m", LogisticRegression(max_iter=3000))]),
    ("PCA(8 components)", [("sc", StandardScaler()), ("p", PCA(n_components=8)),
                           ("m", LogisticRegression(max_iter=3000))]),
]:
    t0 = time.perf_counter()
    b = cross_val_score(Pipeline(steps), X_many, y_many, cv=5, scoring="f1")
    dt = time.perf_counter() - t0
    print(f"{name:<34} {b.mean():>9.4f} {b.std():>8.4f} {dt:>7.2f}s")
print()
pk = PCA(n_components=0.95).fit(StandardScaler().fit_transform(X_many))
print(f"PCA(0.95) kept {pk.n_components_} components out of 150 columns")
Output
variant                                CV F1        ±     time
--------------------------------------------------------------
150 columns, NO PCA                   0.9393   0.0111    0.13s
PCA(0.95)                             0.9443   0.0068    0.11s
PCA(8 components)                     0.9443   0.0068    1.72s

PCA(0.95) kept 8 components out of 150 columns

Here PCA works: the 150 columns were really built from 8 hidden factors, and PCA found them.

Visualisation: PCA's most reliable benefit

python21 lines
from sklearn.preprocessing import LabelEncoder
pg = pd.read_csv(DATA / "penguins.csv")
pnum = ["bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g"]
Xpg = SimpleImputer(strategy="median").fit_transform(pg[pnum])
Xpg = StandardScaler().fit_transform(Xpg)
species = pg.species.values

p_pg = PCA(n_components=2).fit(Xpg)
Z = p_pg.transform(Xpg)

figure, ax = plt.subplots(figsize=(8.5, 5.5))
for t, colour in zip(["Adelie", "Gentoo", "Chinstrap"], ["#1a73e8", "#137333", "#c5221f"]):
    mask = species == t
    ax.scatter(Z[mask, 0], Z[mask, 1], label=t, alpha=.7, s=30, color=colour)
ax.set_xlabel(f"PC1 ({p_pg.explained_variance_ratio_[0]:.1%})")
ax.set_ylabel(f"PC2 ({p_pg.explained_variance_ratio_[1]:.1%})")
ax.set_title("4-dimensional data in 2 dimensions", fontweight="bold")
ax.legend(); ax.grid(alpha=.3)
fig("pca_penguins.png", caption="Four dimensions brought down to two, and the classes still separate. PCA did NOT see the target: it only looked at the variance.")

print(f"PC1 and PC2 together explain {p_pg.explained_variance_ratio_.sum():.1%} of the variance")
Output
PC1 and PC2 together explain 88.2% of the variance

Four dimensions brought down to two, and the classes still separate. PCA did NOT see the target: it only looked at the variance.

Four dimensions brought down to two, and the classes still separate. PCA did NOT see the target: it only looked at the variance.

Note: PCA didn't see species. It only looked at the four measurements, and the classes separated anyway. That shows there's real structure in this data.

What the components are made of

python5 lines
loadings = pd.DataFrame(p_pg.components_.T, columns=["PC1", "PC2"], index=pnum).round(3)
show(loadings.reset_index().rename(columns={"index": "feature"}))
print()
print("The feature that contributes most to PC1:",
      pnum[int(np.argmax(np.abs(p_pg.components_[0])))])
Output
The feature that contributes most to PC1: flipper_length_mm
featurePC1PC2
bill_length_mm0.4550.597
bill_depth_mm-0.40.798
flipper_length_mm0.5760.002
body_mass_g0.5480.084

PCA components are hard to interpret

PC1 = 0.45*bill_length - 0.40*bill_depth + 0.58*flipper + 0.55*mass: what does that mean?

Nothing. It's just a mathematical combination. You can't explain it to a customer.

So in projects that need explanations (banking, medicine), be careful with PCA: the model's accuracy is kept, but the SHAP analysis from Chapter 26 loses its meaning: you can't explain the effect of PC3.

The variants

python10 lines
show(pd.DataFrame([
    {"Method": "PCA", "Linear?": "YES", "When": "The general case, fast",
     "Weakness": "Doesn't see non-linear structure"},
    {"Method": "TruncatedSVD", "Linear?": "YES", "When": "Sparse matrices (text, TF-IDF)",
     "Weakness": "Doesn't centre the data"},
    {"Method": "t-SNE", "Linear?": "NO", "When": "ONLY for visualisation",
     "Weakness": "No transform(): you can't add a new point"},
    {"Method": "UMAP", "Linear?": "NO", "When": "Visualisation, faster",
     "Weakness": "A separate library"},
]))
MethodLinear?WhenWeakness
PCAYESThe general case, fastDoesn't see non-linear structure
TruncatedSVDYESSparse matrices (text, TF-IDF)Doesn't centre the data
t-SNENOONLY for visualisationNo transform(): you can't add a new point
UMAPNOVisualisation, fasterA separate library

Don't put t-SNE in a model pipeline

t-SNE draws beautiful pictures, and students want to use it instead of PCA.

But t-SNE has no transform(): it only knows fit_transform(). So when a new row arrives, you can't place it in that space.

That means you cannot use t-SNE inside a Pipeline in front of a model. It's only for checking by eye.

What we learned today

python10 lines
show(pd.DataFrame([
    {"Concept": "The task", "In one sentence": "No target: find structure in the data itself"},
    {"Concept": "The PCA idea", "In one sentence": "Turn the directions of greatest variance into axes"},
    {"Concept": "Inside it", "In one sentence": "The eigenvectors of the covariance matrix (Chapter 12)"},
    {"Concept": "Scaling", "In one sentence": "MANDATORY: otherwise the column with big units wins"},
    {"Concept": "How many components", "In one sentence": "Where the cumulative variance reaches 90-95%"},
    {"Concept": "Effect on accuracy", "In one sentence": "Usually none: it removes redundancy, not accuracy"},
    {"Concept": "When it helps", "In one sentence": "Very many columns, visualisation, speed"},
    {"Concept": "The cost", "In one sentence": "Components can't be explained: SHAP loses its meaning"},
]))
ConceptIn one sentence
The taskNo target: find structure in the data itself
The PCA ideaTurn the directions of greatest variance into axes
Inside itThe eigenvectors of the covariance matrix (Chapter 12)
ScalingMANDATORY: otherwise the column with big units wins
How many componentsWhere the cumulative variance reaches 90-95%
Effect on accuracyUsually none: it removes redundancy, not accuracy
When it helpsVery many columns, visualisation, speed
The costComponents can't be explained: SHAP loses its meaning

Next chapter: we reduced the dimensions. Now we split the rows into groups.

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