Skip to content

Dimensionality Reduction

Dimensionality Reduction

Three methods, three different objectives. PCA preserves global variance and is reversible. t-SNE preserves local neighbourhoods and distorts everything else. UMAP attempts both and fails differently. Knowing which distortion each one introduces is the whole content of this page.1

Four reasons to reduce, and they need different methods

Confusing them is the source of nearly every misuse.

Goal What the method must provide Typical choice
Visualize 2-D or 3-D output; local fidelity is enough t-SNE, UMAP
Compress / accelerate reconstruction, and a transform applicable to new data PCA
Remove noise or collinearity discard low-variance directions PCA, truncated SVD
Preprocess for another model an honest fit/transform, no leakage PCA, almost always

A method built for the first row has no transform for new rows, and one built for the second row makes a poor picture. Picking by popularity rather than by row is how people end up feeding two t-SNE coordinates into a classifier.


1. Why the statistics push you to reduce

Beyond the four goals above there is a structural reason: in high dimensions, geometry stops behaving the way two- and three-dimensional intuition expects. Volume runs to the surface, the inscribed ball all but vanishes, random directions become almost orthogonal, and β€” the one that actually breaks things β€” pairwise distances concentrate, so "nearest neighbour" gradually stops meaning anything.

That last effect is why t-SNE and UMAP both recommend running PCA first when the input has hundreds or thousands of columns. It is not only speed: it is the quality of the neighbourhoods those methods are built on.

But the usual one-line version is wrong in a way that matters

"High dimension breaks distance-based methods" is not quite it. The correct statement is:

Dimensions that carry no information about the task dilute the information of the ones that do β€” each adds noise to the distance while the signal stays constant.

Measured below: the same 128 extra columns take a 5-NN from 0.906 down to 0.595 when they are noise, and up to 1.000 when they carry signal. The curse of dimensionality takes the claim apart into the four separate phenomena it compresses, with a panel for each.

2. PCA: two derivations, one answer

Centre the data so \(\mu = 0\), and look for a unit direction \(\mathbf{w}\) that summarizes it well. There are two ways to say "well", and the elegance of PCA is that they coincide.

Keep as much spread as possible after projecting:

\[ \max_{\lVert \mathbf{w} \rVert = 1} \; \frac{1}{n}\sum_{i=1}^{n} (\mathbf{x}_i^\top \mathbf{w})^2 \;=\; \max_{\lVert \mathbf{w} \rVert = 1} \; \mathbf{w}^\top \Sigma \mathbf{w} \]

The constraint gives a Lagrangian; differentiating and setting to zero gives \(\Sigma \mathbf{w} = \lambda \mathbf{w}\). The direction sought is an eigenvector of the covariance matrix, and the variance along it is the corresponding eigenvalue \(\lambda\).

Lose as little as possible when projecting and coming back:

\[ \min_{\lVert \mathbf{w} \rVert = 1} \; \frac{1}{n}\sum_{i=1}^{n} \lVert \mathbf{x}_i - (\mathbf{x}_i^\top \mathbf{w})\mathbf{w} \rVert^2 \]

Because the residual is perpendicular to the projection, Pythagoras splits each point's norm between the two:

\[ \lVert \mathbf{x}_i \rVert^2 = (\mathbf{x}_i^\top \mathbf{w})^2 + \lVert \text{residual}_i \rVert^2 \]

Summing over \(n\), the left side does not depend on \(\mathbf{w}\) at all. So minimizing the residual and maximizing the projected variance are the same problem, with the sign flipped.

Try to find the best angle before pressing snap to PC1. The two bars always sum to the same number, at every angle: that is the identity above, applied to the whole cloud.

The second component is the eigenvector of the second largest eigenvalue, necessarily orthogonal to the first because \(\Sigma\) is symmetric. Stacking the first \(k\) into \(W\) gives the projection \(Z = XW\) and the reconstruction \(\hat X = ZW^\top\).

In practice: SVD, not the covariance matrix

Libraries do not form \(\Sigma = \frac{1}{n}X^\top X\). They compute \(X = U S V^\top\); the columns of \(V\) are the components and \(\lambda_j = s_j^2 / n\). Forming \(X^\top X\) squares the condition number and degrades precision β€” the same reason one does not solve least squares through the normal equations.

Choosing \(k\)

k explained cumulative recon_mse trustworthiness knn_acc
1 0.7296 0.7296 0.2704 0.888 0.913
2 0.2285 0.9581 0.0419 0.980 0.900
3 0.0367 0.9948 0.0052 0.999 0.960
4 0.0052 1.0000 0.0000 1.000 0.953
"""PCA on Iris: what each component costs, and what it is worth.

Four standardized measurements, four components. `explained` is the share of
total variance each one carries, `recon_mse` is what is lost by keeping only
the first k and projecting back, `trustworthiness` is how many of each point's
twelve nearest neighbours in four dimensions are still its neighbours in k, and
`knn_acc` is a 5-NN classifier trained on the k components.

The last column is the one to look at twice. Two components carry 95.8% of the
variance and reconstruct almost perfectly β€” and they classify *worse* than
three. PCA is unsupervised: it maximizes variance, and nothing guarantees that
the directions with the most variance are the directions that separate the
classes.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.datasets import load_iris
from sklearn.decomposition import PCA
from sklearn.manifold import trustworthiness
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_iris(return_X_y=True)
X_std = StandardScaler().fit_transform(X)        # PCA maximizes variance, and variance has units
cv = StratifiedKFold(5, shuffle=True, random_state=0)

ratios = PCA().fit(X_std).explained_variance_ratio_

print("| `k` | `explained` | `cumulative` | `recon_mse` | `trustworthiness` | `knn_acc` |")
print("|---:|---:|---:|---:|---:|---:|")
for k in (1, 2, 3, 4):
    pca = PCA(k).fit(X_std)
    scores = pca.transform(X_std)
    recon = np.mean((X_std - pca.inverse_transform(scores)) ** 2)
    # the accuracy is cross-validated with PCA *inside* the pipeline, or it would leak
    acc = cross_val_score(
        make_pipeline(StandardScaler(), PCA(k), KNeighborsClassifier(5)), X, y, cv=cv
    ).mean()
    print(f"| {k} | {ratios[k - 1]:.4f} | {ratios[:k].sum():.4f} | {recon:.4f} "
          f"| {trustworthiness(X_std, scores, n_neighbors=12):.3f} | **{acc:.3f}** |")

Iris, standardized, four components. The first two carry 95.8% of the variance and reconstruct with an error of 0.042 against 0.270 for one. By the usual criteria β€” "keep 95% of the variance", "look for the elbow in the scree plot" β€” the answer is \(k = 2\) and you stop.

Now read the last column. \(k = 2\) classifies at 0.900 and \(k = 3\) at 0.960. The third component carries 3.7% of the variance and it is worth six points of accuracy.

The five traps

  1. Not standardizing. PCA maximizes variance, and variance has units. Income in reais dominates age in years by construction. Standardize whenever the scales are not comparable β€” which is the same as running PCA on the correlation matrix.
  2. Fitting before the split. fit on the full dataset is leakage: the projection basis has seen the test set. Fit on train, transform the rest β€” which is why PCA lives inside a Pipeline.
  3. Assuming high-variance components are the useful ones. There is no such guarantee: PCA is unsupervised and ignores \(y\) entirely. The table above is a mild case; there are classic examples where the predictive signal is in the last component. When the goal is to discriminate, consider LDA or PLS.
  4. Reading components as real factors. The sign and the scale are arbitrary, and rotation within a subspace of near-equal eigenvalues is indeterminate.
  5. Applying PCA to one-hot columns without thinking. It runs, but the "variance" of an indicator is \(p(1-p)\), so rare categories are discarded automatically.
from sklearn.decomposition import PCA
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

pipe = make_pipeline(StandardScaler(), PCA(n_components=0.95), MLPClassifier())
# n_components as a float = "keep enough components for this share of the variance"
# and, inside the pipeline, it is re-fitted on every training fold

Doing it by hand, once

The eigendecomposition is worth writing out once, so that PCA(n_components=2) stops being a black box. Both scripts produce the same projection, up to the arbitrary sign of each component.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from io import StringIO
from sklearn.datasets import load_iris
from sklearn.preprocessing import StandardScaler

# Loading Iris dataset
iris = load_iris()

# Transform in dataframe
df = pd.DataFrame(
    data=iris.data,
    columns=['sepal_l', 'sepal_w', 'petal_l', 'petal_w']
)
df['class'] = iris.target_names[iris.target]

X = df.iloc[:,0:4].values
y = df.iloc[:,4].values

# Standardizing
X_std = StandardScaler().fit_transform(X)

# Covariance
cov_mat = np.cov(X_std.T)

# Calculate autovalues and autovectors
eig_vals, eig_vecs = np.linalg.eig(cov_mat)

print('Eigenvectors \n%s' %eig_vecs)
print('\nEigenvalues \n%s' %eig_vals)

# Make a list of (eigenvalue, eigenvector) tuples
eig_pairs = [(np.abs(eig_vals[i]), eig_vecs[:,i]) for i in range(len(eig_vals))]

# Sort the (eigenvalue, eigenvector) tuples from high to low
eig_pairs.sort(key=lambda x: x[0], reverse=True)

# Visually confirm that the list is correctly sorted by decreasing eigenvalues
print('Eigenvalues in descending order:')
for i in eig_pairs: print(i[0])

# Sum the cummulative of each eigen value
tot = sum(eig_vals)
var_exp = [(i / tot)*100 for i in sorted(eig_vals, reverse=True)]
cum_var_exp = np.cumsum(var_exp)

n_eigen = [1, 2, 3, 4]

# Plot the cumulative for each eign value
plt.figure(figsize=(6, 4))
plt.bar(n_eigen, var_exp, alpha=0.5, align='center',
    label='individual explained variance')
plt.step(n_eigen, cum_var_exp, where='mid',
    label='cumulative explained variance')
plt.ylabel('Explained variance ratio')
plt.xlabel('Principal components')
plt.legend(loc='best')
plt.tight_layout()

# Take the only the two firsts eigen values
matrix_w = np.hstack((eig_pairs[0][1].reshape(4,1),
                      eig_pairs[1][1].reshape(4,1)))

print('*' * 10)
print('Reduced to 2-D')
print('Matrix W:\n', matrix_w)

# Calculate the new Y for all samples
Y = X_std.dot(matrix_w)

# Plot the data for the 2 firsts principal components
plt.figure(figsize=(6, 4))
for lab, col in zip(('setosa', 'versicolor', 'virginica'), ('blue', 'red', 'green')):
    plt.scatter(Y[y==lab, 0],
                Y[y==lab, 1],
                label=lab,
                c=col)
plt.xlabel('Principal Component 1')
plt.ylabel('Principal Component 2')
plt.legend(loc='lower center')
plt.tight_layout()

# Para imprimir na pΓ‘gina HTML
buffer = StringIO()
plt.savefig(buffer, format="svg", transparent=True)
print(buffer.getvalue())
plt.close()
import matplotlib.pyplot as plt
import pandas as pd
from io import StringIO
from sklearn.datasets import load_iris
from sklearn.decomposition import PCA as pca
from sklearn.preprocessing import StandardScaler

# Loading Iris dataset
iris = load_iris()

# Transform in dataframe
df = pd.DataFrame(
    data=iris.data,
    columns=['sepal_l', 'sepal_w', 'petal_l', 'petal_w']
)
df['class'] = iris.target_names[iris.target]

X = df.iloc[:,0:4].values
y = df.iloc[:,4].values

# Standardizing
X_std = StandardScaler().fit_transform(X)

sklearn_pca = pca(n_components=2)
Y = sklearn_pca.fit_transform(X_std)

# Plot the data for the 2 firsts principal components
plt.figure(figsize=(6, 4))
for lab, col in zip(('setosa', 'versicolor', 'virginica'), ('blue', 'red', 'green')):
    plt.scatter(Y[y==lab, 0],
                Y[y==lab, 1],
                label=lab,
                c=col)
plt.xlabel('Principal Component 1')
plt.ylabel('Principal Component 2')
plt.legend(loc='lower center')
plt.tight_layout()

# Para imprimir na pΓ‘gina HTML
buffer = StringIO()
plt.savefig(buffer, format="svg", transparent=True)
print(buffer.getvalue())
plt.close()

Two details that trip people up

The eigenvalues do not come out of np.linalg.eig in descending order β€” sorting them is a step, not a formality. And np.cov divides by \(n-1\) while scikit-learn's explained_variance_ uses the same convention; if you divide by \(n\) by hand, your eigenvalues differ from scikit-learn's by a factor of \(n/(n-1)\) while the ratios are identical. The components and the projection are unaffected.


3. Where PCA fails

PCA finds a linear subspace. When the data lies on a curved manifold β€” a spiral, a swiss roll, the set of all images of a rotating object β€” no plane summarizes it, and the first components will happily describe a direction that cuts straight through the curve.

1970-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/

A spring: one curve, coiled through three dimensions, coloured by position along it. The plane that captures the most variance is the one the coils lie in, so PCA stacks every turn onto every other.

The number under each projection counts false neighbours: of the ten nearest points in the map, how many are more than a tenth of the curve away along it. PCA scores 72% β€” three out of four of each point's apparent neighbours are somewhere else entirely. The starred pair is the extreme case: four turns apart on the spring, 0.007 apart after projection.

Two things are worth noticing before moving on.

First, this failure is invisible to trustworthiness, the metric section 7 recommends. It scores PCA at 0.956 here, because it compares the map against distances in the ambient 3-D space β€” and in 3-D, points on adjacent coils genuinely are close. What the projection destroys is the intrinsic structure, the distance along the curve, and you only see that by measuring against it. Which metric you pick decides what counts as a failure.

Second, look at what t-SNE did. It gets the neighbourhoods almost perfectly β€” 1% false β€” and it tears the curve into arcs and scatters them. That is not a defect to fix; it is the trade. The neighbourhoods are right and the arrangement of the pieces means nothing, which is exactly why the seven forbidden statements exist.

That is the opening for the non-linear methods, and they change the question. PCA asks "which directions concentrate variance?" β€” a global question with a linear, reversible answer. t-SNE and UMAP ask "who is near whom?" β€” a local question, answered by deliberately sacrificing the global geometry.


4. t-SNE: matching neighbourhood distributions2

In one sentence: turn distances into neighbourhood probabilities in both spaces, and move the points of the 2-D map until the two distributions agree.

Step 1 β€” neighbourhoods in the original space. For each point \(i\), a gaussian kernel over its neighbours:

\[ p_{j|i} = \frac{\exp(-\lVert \mathbf{x}_i - \mathbf{x}_j \rVert^2 / 2\sigma_i^2)}{\sum_{k \neq i} \exp(-\lVert \mathbf{x}_i - \mathbf{x}_k \rVert^2 / 2\sigma_i^2)} \]

Each point gets its own \(\sigma_i\), found by binary search until the perplexity \(2^{H(P_i)}\) hits the value you asked for. Perplexity β‰ˆ the effective number of neighbours. Dense regions get a small \(\sigma\), sparse regions a large one β€” an automatic adaptation of scale. The result is symmetrized, \(p_{ij} = (p_{j|i} + p_{i|j})/2n\), so that every point contributes to the loss, including isolated ones.

Step 2 β€” neighbourhoods in the map, with a heavy tail.

\[ q_{ij} = \frac{(1 + \lVert \mathbf{y}_i - \mathbf{y}_j \rVert^2)^{-1}}{\sum_{k \neq l}(1 + \lVert \mathbf{y}_k - \mathbf{y}_l \rVert^2)^{-1}} \]

A Student-\(t\) with one degree of freedom (a Cauchy), and this is the essential difference from the original SNE. Why the heavy tail β€” the crowding problem: the volume of a ball grows as \(r^d\), so a point in high dimensions has room for many neighbours at moderate distance, and in 2-D that room does not exist. With a gaussian on both sides, every moderate neighbour would be forced toward the centre and the map would collapse. The heavy tail lets moderately distant pairs sit very far apart in the map at low cost, which frees space to separate the groups.

The side effect is the thing you must remember: distances between groups in the picture stop being interpretable.

Step 3 β€” minimize the divergence.

\[ \mathrm{KL}(P \parallel Q) = \sum_{i \neq j} p_{ij} \log \frac{p_{ij}}{q_{ij}} \]

The KL is asymmetric, and the asymmetry is the whole behaviour of the method. High \(p\) with low \(q\) β€” real neighbours drawn apart β€” is punished heavily. Low \(p\) with high \(q\) β€” distant points drawn together β€” is nearly free. Hence the rule:

What is together in a t-SNE map may not be together in reality.

Watch it happen. The panel runs the real algorithm β€” the perplexity search, the early exaggeration, the gradient descent β€” a few iterations per frame.

The dataset to spend the most time on is the last one. It is twelve columns of uniform noise, with nothing to find, and t-SNE will hand you tidy separated groups anyway.


5. UMAP: a fuzzy graph, drawn

UMAP reaches a similar result by a different route. Instead of distributions over all pairs, it builds an explicit \(k\)-neighbour graph with fuzzy weights and then draws it.

Step 1 β€” a local graph with guaranteed connectivity.

\[ w_{i \to j} = \exp\!\left(-\frac{\max(0, d(\mathbf{x}_i, \mathbf{x}_j) - \rho_i)}{\sigma_i}\right) \]

\(\rho_i\) is the distance to the nearest neighbour and \(\sigma_i\) is calibrated so that \(\sum_j w_{i \to j} = \log_2 k\). Subtracting \(\rho_i\) guarantees every point has at least one edge of weight 1, so nothing is left isolated. The two directed views are combined by a fuzzy union β€” the analogue of t-SNE's symmetrization.

Step 2 β€” draw the graph. The loss is a binary cross-entropy rather than a KL:

\[ \sum_{(i,j)} \Big[ w_{ij} \log \frac{w_{ij}}{q_{ij}} + (1 - w_{ij}) \log \frac{1 - w_{ij}}{1 - q_{ij}} \Big] \]

The second term is what t-SNE does not have: an explicit penalty for placing far things near each other. That is the origin of UMAP's slightly better global preservation. Optimization uses SGD with negative sampling β€” one edge is drawn and its endpoints attract, a few random points are drawn as negatives and repel β€” which avoids summing over all pairs and is why UMAP is faster.

# not a course dependency: pip install umap-learn
import umap
Z = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=0).fit_transform(X_std)

n_neighbors trades local detail against global structure; min_dist controls how tightly points may pack in the map, and it is purely aesthetic β€” it changes the picture without changing what was learned.

The panel below builds the graph and then draws it, with the same four datasets. Turn on graph to see the thing being optimized: the layout is that graph, untangled.

Run the coiled curve in both panels and the difference in the losses shows up directly β€” UMAP's second term, the explicit penalty for placing far things near each other, keeps the curve connected noticeably longer.


6. Choosing, and what you may say afterwards

PCA t-SNE UMAP
Preserves global variance local neighbourhoods local, and a little global
Linear yes no no
Reversible yes no no
transform for new rows yes no approximate
Deterministic yes (up to sign) no no
Main parameter \(k\) perplexity n_neighbors, min_dist
Typical use compression, preprocessing visual diagnosis visual exploration at scale
flowchart TD
    A["what is the reduction for?"] --> B{"will a model consume<br/>the output?"}
    B -->|yes| P["<b>PCA</b><br/><small>honest fit/transform, reversible</small>"]
    B -->|"no β€” it is a picture"| C{"how many rows?"}
    C -->|"up to ~10k"| T["<b>t-SNE</b><br/><small>best local fidelity</small>"]
    C -->|"more, or new rows arrive"| U["<b>UMAP</b><br/><small>faster, approximate transform</small>"]
    T --> V["and always: PCA to ~50 first"]
    U --> V

    classDef ok fill:#e6f4ea,stroke:#3fb950,color:#14532d
    classDef q  fill:#eef2f7,stroke:#8b949e,color:#1f2937
    class P,T,U,V ok
    class A,B,C q

The last box is not optional. With hundreds or thousands of input columns, run PCA down to ~50 before t-SNE or UMAP β€” not only for speed, but because the neighbourhoods those methods consume are computed with the very distances that A3 above is about.

Seven things you may not say about a t-SNE or UMAP plot

  1. "This cluster is bigger, so it has more variability." β€” Size in the map is an artefact of local density.
  2. "These two clusters are close, so they are similar." β€” Distances between clusters are not preserved.
  3. "There are five clusters in the data." β€” There are five blobs in the picture. Clustering is validated in the original space.
  4. "The horizontal axis represents X." β€” The axes have no meaning; rotation and reflection are arbitrary.
  5. "I ran it once and saw this." β€” Results vary with the seed. Report several runs.
  6. "I used the two t-SNE coordinates as features for my classifier." β€” There is no honest transform for new rows, and you compressed 500 dimensions into 2 chosen to please the eye.
  7. "I ran t-SNE on everything and then split train/test." β€” Leakage: the map was built using the test set.

7. How to judge a reduction

A picture is not a result. If a reduction is going to support a claim, it needs a number. The three that are used:

  • Reconstruction error β€” only meaningful for reversible methods (PCA, an autoencoder). It measures what was lost.
  • Trustworthiness β€” of each point's \(k\) nearest neighbours in the map, how many were genuinely among its nearest neighbours in the original space. Applies to any method, including t-SNE and UMAP. It is in sklearn.manifold.trustworthiness.
  • Downstream performance β€” the accuracy of a kNN trained in the reduced space against the original. The most honest test when the reduction is preprocessing.
digits_1797x64 -> 2d trustworthiness
PCA 0.817
t-SNE 0.985
uniform_noise_600x20 silhouette_of_5_means
original_20d 0.042
t-SNE_map_2d 0.333
"""What t-SNE is good at, and the thing it will do to you if you let it.

Two measurements, each answering one half of the question.

First: does a two-dimensional map keep the neighbourhoods of the original
space? `trustworthiness` counts, for every point, how many of its twelve
nearest neighbours in the map were genuinely among its nearest neighbours in
64 dimensions. This is what t-SNE optimizes and PCA does not, and the gap is
the reason t-SNE exists.

Second: what does t-SNE do to data with no structure at all? The input is
uniform noise in twenty dimensions β€” no clusters, by construction. A 5-means
silhouette near zero is the correct answer, and the original space gives it.
The t-SNE map does not: the optimization has to put the points somewhere, and
where it puts them looks like groups.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.cluster import KMeans
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE, trustworthiness
from sklearn.metrics import silhouette_score
from sklearn.preprocessing import StandardScaler

NEIGHBOURS, PERPLEXITY = 12, 30

digits = StandardScaler().fit_transform(load_digits(return_X_y=True)[0])
embed = lambda X: TSNE(n_components=2, init="pca", perplexity=PERPLEXITY,
                       random_state=0).fit_transform(X)

print(f"| `digits_1797x64 -> 2d` | `trustworthiness` |")
print("|---|---:|")
for name, Z in (("PCA", PCA(2, random_state=0).fit_transform(digits)),
                ("t-SNE", embed(digits))):
    print(f"| `{name}` | **{trustworthiness(digits, Z, n_neighbors=NEIGHBOURS):.3f}** |")

print()
noise = np.random.default_rng(0).random((600, 20))       # no structure whatsoever
print("| `uniform_noise_600x20` | `silhouette_of_5_means` |")
print("|---|---:|")
for name, Z in (("original_20d", noise), ("t-SNE_map_2d", embed(noise))):
    labels = KMeans(5, n_init=10, random_state=0).fit_predict(Z)
    print(f"| `{name}` | **{silhouette_score(Z, labels):.3f}** |")

The first table is t-SNE doing what it exists for. Squeezing 64 dimensions of handwritten digits into 2, PCA keeps 0.817 of the neighbourhood structure and t-SNE keeps 0.985. If the question is "which digits does the model confuse with which", that difference is the answer to it.

The second table is the price. The input is uniform noise in twenty dimensions β€” no clusters exist, by construction. A 5-means silhouette of 0.042 in the original space is the correct report: there is nothing there. On the t-SNE map the same measurement gives 0.333, which is the number you would read as "well-separated clusters". The optimization has to put the points somewhere, and where it puts them looks like groups.

Three sentences to take away

  1. PCA answers "which directions concentrate variance" β€” a global question, a linear answer, reversible.
  2. t-SNE and UMAP answer "who is near whom" β€” a local question, answered by deliberately sacrificing the global geometry.
  3. Every reduction method has to lose information. Professional competence consists of knowing exactly which, and of asserting nothing that depends on the part that was lost.


  1. The structure, the derivations and the framing of this page follow the class handout Ver em 2D o que existe em D dimensΓ΅es, which carries the full worked example by hand, twelve live simulators, the Iris walkthrough and the MNIST exercises. β†©

  2. van der Maaten, L., Hinton, G. Visualizing Data using t-SNE, JMLR 2008. McInnes, L., Healy, J., Melville, J. UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction, 2018. β†©