Index Ai Academy
Phase 3 · Machine learning · Unsupervised

Dimensionality reduction

Visualize and compress high-dimensional data.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

An embedding can have hundreds or thousands of numbers per item. You can't look at that, and storing millions of them gets expensive. Dimensionality reduction helps with both. It squeezes data down to 2 numbers per item so you can plot it. Or it squeezes it to fewer numbers so it's cheaper to store and search. You met the ideas behind PCA in Phase 2. This lesson is about using PCA and its cousin t-SNE in practice, and about not being fooled by the pictures they draw.

What you'll be able to do

  • Use PCA in scikit-learn to visualize data and to compress features before a model
  • Choose how many components to keep from the explained variance and from model scores
  • Use t-SNE for pictures of high-dimensional data, and read those pictures correctly
  • Connect dimensionality reduction to embeddings: plotting them and shortening them

What it covers

  1. Two jobs, two families
  2. How much does PCA keep?
  3. A running total of the shares
  4. PCA in scikit-learn
  5. t-SNE: a map of neighborhoods
  6. Reading a t-SNE map
  7. PCA before a model
  8. Shorter embeddings

Two jobs, two families

Think about a globe and a flat world map. The map has one fewer dimension than the globe. To fit, it has to stretch something. Some maps keep shapes right and get sizes wrong. Others do the reverse. Every flat map is a trade-off, and you pick the one that fits your job.

Squeezing data works the same way. A dimension here is one column of numbers. Reducing dimensions means describing each row with fewer numbers. There are two common jobs, and each has its own family of tools:

  • Compress: keep most of the information in fewer numbers. The tool is PCA. It's linear, which means it only mixes columns with fixed weights. It's fast, and it gives the same answer every time. It can also transform new data later, and it fits inside a model pipeline.
  • Visualize: draw the data in 2D so that items that were neighbors stay neighbors. The tools are t-SNE and UMAP. They're nonlinear, so they can bend and fold the data. Their pictures are much clearer. But a picture is the only thing they're for.

How much does PCA keep?

Recall from the SVD and PCA lesson that each principal component has a share of the spread. That share is called its explained variance ratio. All the shares add up to 1, or 100%. To know how much information kk components keep, where kk is the number of components, you add up their shares:

kept(k)=r1+r2+⋯+rk\text{kept}(k) = r_1 + r_2 + \dots + r_k

Here r1r_1 is the share of the first component. r2r_2 is the share of the second, and so on. Components come sorted, biggest share first.

In plain words: to know how much the top few components keep, add up their shares. A running total like this is called a cumulative sum.

Say four components have shares of 60%, 25%, 10% and 5%. The top one keeps 60%. The top two keep 60 + 25 = 85%. The top three keep 85 + 10 = 95%. So if you want to keep 90%, three components is the smallest number that gets there.

Quick check
What does this print?
shares = [0.62, 0.21, 0.09, 0.08]
print(round(sum(shares[:2]), 2))
  1. 0.21
  2. 0.62
  3. 1.0
  4. 0.83
Show the answer

0.83

shares[:2] takes 0.62 and 0.21, which add up to 0.83. Add up the first few shares to see how much variation that many components keep.

Try it now

Turn the line and see how much of the spread survives. PCA simply finds the best direction, then the next best, and so on.

Worked example

A running total of the shares

Four components with shares 0.6, 0.25, 0.1 and 0.05. How much do the top 1, 2, 3 and 4 keep?

shares = [0.6, 0.25, 0.1, 0.05]   # each component's share of the variance
kept = 0
for k, share in enumerate(shares, start=1):
    kept += share
    print(f"top {k}: {kept:.0%} kept")
It runs right here in your browser, with nothing to install.

It prints 60%, 85%, 95% and 100%. The loop keeps a running total, which is the formula above written one step at a time. NumPy does the same thing in one call with np.cumsum.

PCA in scikit-learn

The digits dataset holds small images of handwritten digits, 0 to 9. Each image is 8 × 8 pixels, so it's described by 64 numbers. The code below does two things. First, it prints how much of the variance different numbers of components keep. Then it uses PCA to squash each image down to the 2 directions of greatest spread, and plots them. Each dot is one image, colored by its digit.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA

digits = load_digits()
X, y = digits.data[:500], digits.target[:500]

pca = PCA().fit(X)
cumulative = np.cumsum(pca.explained_variance_ratio_)
for k in [2, 10, 20, 30]:
    print(f"{k:>2} components keep {cumulative[k - 1]:.0%} of the variance")
print("components needed for 90%:", PCA(n_components=0.9).fit(X).n_components_)

Z = PCA(n_components=2).fit_transform(X)
plt.scatter(Z[:, 0], Z[:, 1], c=y, cmap="tab10", s=8)
for d in range(10):
    plt.text(*np.median(Z[y == d], axis=0), str(d), fontsize=14, weight="bold")
plt.title("PCA: 64 pixels → 2 components")

Two components keep under a third of the variance. So the picture is crowded. Some digits sit apart, such as 0 and 6. Several others pile on top of each other.

Look at the line with n_components=0.9. When you pass a fraction like this, PCA picks as many components as it takes to keep 90% of the variance. It's the running total from the example above, done for you.

Quick check
PCA's top two components keep 30% of the variance. You plot them. Two customers land on the same spot. What can you conclude?
  1. They may still be quite different: 70% of the variation isn't in the picture
  2. They are almost identical customers
  3. The other components must be noise
  4. PCA has made a mistake
Show the answer

They may still be quite different: 70% of the variation isn't in the picture

A 2D PCA plot shows only the two biggest directions. Customers can differ a lot in the directions you left out.

t-SNE: a map of neighborhoods

Think of a subway map. It shows which stops are next to each other and which lines connect. It does not show true distances. Two stops that look far apart on the map might be a short walk apart. Nobody minds, because the map's job is to show what's next to what.

t-SNE draws your data like a subway map. It ignores the big directions that PCA cares about. Instead it tries to keep points that are close in 64 dimensions close in 2D. (The name is short for "t-distributed stochastic neighbor embedding". You don't need to remember that.)

Measuring how many neighbors survive

To compare the two methods fairly, you need a score. Take one image. List its 10 nearest images in the original 64 numbers. Then list its 10 nearest on the 2D picture. Count how many appear on both lists:

overlap=neighbors on both listsneighbors checked\text{overlap} = \frac{\text{neighbors on both lists}}{\text{neighbors checked}}

In plain words: out of the neighbors you checked, what share are still neighbors in the picture? 1 means the picture kept every neighbor. 0 means it kept none.

Here's a small case with 4 neighbors instead of 10. In the full data, an image's nearest neighbors are A, B, C and D. On the map they're A, C, B and F. Three names appear on both lists (A, B and C), so the overlap is 3 ÷ 4 = 0.75, or 75%. The order inside each list doesn't matter.

original = ["A", "B", "C", "D"]   # nearest neighbors in the full data
picture = ["A", "C", "B", "F"]    # nearest neighbors on the 2D map
shared = len(set(original) & set(picture))
print(shared, "of", len(original), "kept:", shared / len(original))

This prints 3 of 4 kept: 0.75. The & between two sets keeps only the items in both.

The code below does this for every image, then averages the scores. It uses 300 images and checks 10 neighbors each. It also draws the PCA picture and the t-SNE picture side by side.

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
from sklearn.neighbors import NearestNeighbors

digits = load_digits()
X, y = digits.data[:300], digits.target[:300]          # 300 images keeps t-SNE quick in the browser
views = {"PCA": PCA(n_components=2).fit_transform(X),
         "t-SNE": TSNE(n_components=2, perplexity=30, init="pca", max_iter=500, random_state=0).fit_transform(X)}

def neighbors(A, k=10):
    return NearestNeighbors(n_neighbors=k + 1).fit(A).kneighbors(A, return_distance=False)[:, 1:]

fig, axes = plt.subplots(1, 2, figsize=(10, 4.2))
for ax, (name, Z) in zip(axes, views.items()):
    kept = np.mean([len(set(a) & set(b)) / 10 for a, b in zip(neighbors(X), neighbors(Z))])
    print(f"{name:<6} keeps {kept:.0%} of each image's 10 nearest neighbors")
    ax.scatter(Z[:, 0], Z[:, 1], c=y, cmap="tab10", s=6)
    for d in range(10):
        ax.text(*np.median(Z[y == d], axis=0), str(d), fontsize=13, weight="bold")
    ax.set_title(name)

The neighbors function asks for 11 neighbors and drops the first one, because each image's nearest neighbor is itself.

The t-SNE map splits the digits into clean islands. It also keeps far more of the true neighbors than PCA does.

Go deeper How t-SNE builds its map

t-SNE works in two steps. First, for every point, it turns distances in the full data into "how likely is this other point to be my neighbor". Close points get a high score and far points get almost zero. Second, it scatters the points in 2D and works out the same scores there. Then it nudges the points, step by step, until the two sets of scores match as well as they can. That nudging is a form of gradient descent, and max_iter=500 caps it at 500 steps.

In 2D, t-SNE uses a scoring rule with long tails, from a curve called the t-distribution. That lets far-apart points sit even further apart on the map. It's one reason the islands look so separated, and one reason the gaps between them can't be trusted.

init="pca" starts the points from the PCA layout, and random_state=0 fixes the random seed, so you get the same map each run.

Reading a t-SNE map

Like a subway map, a t-SNE map is useful only if you read it the right way. Three rules:

  • Island sizes and the gaps between islands mean little. t-SNE keeps who is near whom. It doesn't keep distances. Two islands far apart on the map aren't necessarily more different than two close ones. An island's size doesn't reliably tell you how many items it holds, or how varied they are.
  • Settings change the picture. One setting is called perplexity. Roughly, it's how many neighbors each point pays attention to. (It shares a name with the perplexity of language models, but here it's a dial you set.) Perplexity and the random seed can both reshape the map. Look at more than one map before you conclude anything.
  • It can't place new points, and its coordinates aren't features. t-SNE positions only the points it was given. Never feed t-SNE output into a model.

UMAP is a popular alternative. It draws similar pictures, runs faster on large data, and keeps a bit more of the overall layout. It lives in a separate package, umap-learn, which isn't available in this browser.

Quick check
On a t-SNE plot of product embeddings, the 'shoes' island is far from the 'hats' island and close to 'bags'. What can you safely conclude?
  1. Shoes are the most distinctive category
  2. Shoes are more similar to bags than to hats
  3. Products within each island are similar to each other; the distances between islands aren't reliable
  4. The embedding model is broken
Show the answer

Products within each island are similar to each other; the distances between islands aren't reliable

t-SNE preserves local neighborhoods, not the distances between clusters.

PCA before a model

Compressing features can also help a model. Think of a short summary of a long report. If it keeps the main points, a reader can decide almost as well, and faster. For a model, fewer inputs can mean less noise and faster training.

Whether it helps is something you measure, not guess. You measure it with cross-validation, from the Evaluation lessons. Here's the churn data from earlier lessons, with its 8 customer features. The pipeline scales the features, runs PCA to keep k components, then fits a logistic regression. The score is AUC, where 0.5 is a coin flip and 1 is perfect.

import pandas as pd
import requests
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
df["email_open_rate"] = df["email_open_rate"].fillna(0)
features = ["tenure_months", "orders_last_year", "avg_order_value", "days_since_last_order",
            "discount_share", "email_open_rate", "support_tickets", "trail_club"]
for k in [1, 2, 4, 6, 8]:
    model = make_pipeline(StandardScaler(), PCA(n_components=k), LogisticRegression())
    auc = cross_val_score(model, df[features], df["churned"], cv=5, scoring="roc_auc").mean()
    print(f"k = {k}: cross-validated AUC {auc:.3f}")

Four components do as well as all eight. With 8 features, that saving doesn't matter. With thousands of embedding dimensions, it can.

Notice that PCA sits inside the pipeline. So on each fold it's fit again, using only that fold's training rows. If you fit PCA on all the rows first, the test rows would help choose the components. That's a quiet form of leakage: information from the test data sneaking into training, which makes the score look better than it really is.

Quick check
Why does the code put PCA inside the pipeline instead of running it once on all the data first?
  1. To make PCA keep more components
  2. So PCA is refit on each fold's training rows and never sees the test rows
  3. Because logistic regression can't read PCA output otherwise
  4. Because PCA only works inside a pipeline
Show the answer

So PCA is refit on each fold's training rows and never sees the test rows

Fitting PCA on all the rows would let the test rows shape the components. That leaks information and inflates the score.

Shorter embeddings

The same idea applies to embeddings, and here the savings are real. Say you store ten million documents, each with a 3,072-number embedding. That's about 31 billion numbers. At the usual 4 bytes per number, it's roughly 123 GB to store and search. Cut each embedding to 256 numbers and it drops to about 10 GB.

Your options, roughly from most to least convenient:

  • Ask for shorter vectors. Some embedding models are trained so the first numbers carry the most meaning. They're called Matryoshka embeddings, after Russian nesting dolls: a smaller embedding sits inside the bigger one. Their APIs let you request fewer dimensions directly.
  • Use PCA. Fit it on a sample of your embeddings, then project everything to fewer dimensions. It works on documents you add later, too.
  • Quantize. Many vector databases can store each number in fewer bits. It's like rounding prices to the nearest dollar: a little less exact, much smaller.

Whichever you choose, check retrieval quality on your own evaluation set before and after. Do it the same way you tested PCA on the churn model above.

Quick check
You shrink 1,024-number embeddings to 256 numbers each, and keep everything else the same. Roughly how much storage do the vectors now need?
  1. Half of before
  2. A quarter of before
  3. Four times as much
  4. The same, because the number of documents didn't change
Show the answer

A quarter of before

Storage grows with how many numbers you keep. 256 is a quarter of 1,024, so the vectors take about a quarter of the space.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

You need to reduce 1,536-dimensional embeddings to 256 dimensions for a search index, including for documents added next month. Which method fits?

  1. PCA
  2. t-SNE
  3. Either: they're interchangeable
  4. Neither: you can't reduce embeddings

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson