t-SNE produces those familiar 2-D maps in which high-dimensional data separates into clean islands. This page explains the algorithm, the role of perplexity, a working example, and the interpretation traps that make t-SNE plots easy to misread.
t-distributed Stochastic Neighbor Embedding (van der Maaten and Hinton, 2008) is a nonlinear method for visualizing high-dimensional data in 2 or 3 dimensions. It converts distances into probabilities that two points are neighbors, then searches for a low-dimensional layout whose neighbor probabilities match. It is excellent at revealing clusters in embeddings, single-cell gene expression, or image features, and should be used for looking, not as a general feature transform.
i, define a Gaussian over others: pj|i ∝ exp(-||xi - xj||2 / 2σi2). σi is found by binary search so the distribution's perplexity (2H, an effective neighbor count) equals the user setting.pij = (pj|i + pi|j) / 2n.qij ∝ (1 + ||yi - yj||2)-1.KL(P||Q) by gradient descent, with an early-exaggeration phase that multiplies P to let clusters form first.Exact t-SNE is O(n2). Barnes–Hut (scikit-learn default) approximates in O(n log n); FFT-accelerated interpolation (openTSNE, FIt-SNE) scales to millions of points.
Perplexity (typically 5–50, default 30) balances local versus broader structure: too low fragments real clusters into islands; too high merges them. Other settings that matter: init="pca" (more stable and preserves more global layout than random), learning_rate="auto" (scales with n), and enough iterations to converge. Always try several perplexities and seeds before drawing conclusions.
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
X, y = load_digits(return_X_y=True)
X50 = PCA(n_components=50, random_state=0).fit_transform(X) # denoise + speed up
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))
for ax, perp in zip(axes, [5, 30, 100]):
Z = TSNE(n_components=2, perplexity=perp, init="pca",
learning_rate="auto", random_state=0).fit_transform(X50)
ax.scatter(Z[:, 0], Z[:, 1], c=y, s=4, cmap="tab10")
ax.set_title(f"perplexity={perp}")
plt.tight_layout(); plt.show()
transform for new points in scikit-learn; re-running changes the layout. openTSNE supports adding points; otherwise use UMAP.Perplexity is defined as 2H(Pi), where H is the Shannon entropy (in bits) of point i's neighbor distribution. If the conditional probabilities were spread uniformly over exactly 30 neighbors and zero elsewhere, the entropy would be log2 30 ≈ 4.9 bits and the perplexity exactly 30. That is why perplexity is described as a soft "effective number of neighbors."
The binary search for σi is what makes t-SNE adaptive. In a dense region, a small σ already captures 30 effective neighbors; in a sparse region σ must grow. The consequence is that every point gets roughly the same neighborhood size regardless of local density — which is exactly why dense and sparse clusters end up looking similar in size on the map.
Practical bounds follow from this definition. Perplexity must be well below n (scikit-learn requires it to be smaller than the number of samples), and the default of 30 is meaningless for a 60-point dataset where it would cover half the data. For very large datasets (hundreds of thousands of points), perplexity alone cannot reach global structure; increasing early exaggeration, using PCA initialization, or using larger perplexities with FFT-accelerated implementations are the usual remedies.
The heavy-tailed Student-t kernel in the low-dimensional map is the other key idea. With a Gaussian in both spaces, moderately distant points would need to be squeezed together to match their small but non-zero pij (the "crowding problem"). The t-distribution's heavier tail lets those points sit farther apart, which opens up visible gaps between clusters.
| PCA | t-SNE | UMAP | |
|---|---|---|---|
| Type | Linear projection | Nonlinear, neighbor probabilities | Nonlinear, fuzzy neighbor graph |
| Preserves | Global variance | Local neighborhoods | Local neighborhoods, somewhat more global layout |
| Deterministic | Yes | No (seed-dependent) | No (seed-dependent) |
| New points | transform | Not in scikit-learn; openTSNE can | transform |
| Use as features | Yes | No | Sometimes, with care |
| Main knob | Number of components | Perplexity | n_neighbors, min_dist |
Later analysis (for example Kobak and Linderman, 2021) showed that much of UMAP's apparent advantage in global structure comes from its default initialization rather than its loss; t-SNE with PCA initialization preserves global layout comparably in many cases. Choose based on tooling, speed and whether you need to embed new points, not on folklore.
A defensible t-SNE figure follows a fixed recipe. Standardize the features (or L2-normalise embeddings), reduce to about 50 dimensions with PCA to remove noise and speed up neighbor search, then run t-SNE at two or three perplexities and several seeds. Any structure you report should survive all of them.
Do the analytical work in the original or PCA space: cluster there with K-Means, DBSCAN or graph-based methods, and use the t-SNE map only to colour and display the result. Where you have known labels (cell types, document categories, model predictions), colouring by them is the fastest way to see whether an embedding separates what it should.
Typical uses: inspecting the embedding space of a neural network or a RAG embedding model, exploring single-cell RNA-seq data (where t-SNE became a standard view), spotting mislabelled examples that sit inside the wrong island, and sanity-checking that a feature pipeline captures the distinctions you expect.