Capítulo 26 de 37 · intermedio
High-dimensional data, the curse of dimensions
What this chapter covers
Every intuition you have about distance was trained in two and three dimensions, and every one of them lies in two hundred. This is the chapter where that stops being a slogan and becomes something you can measure. The curse of dimensionality is not one problem, it's a family of them, and they all come from the same root: high-dimensional space is mostly empty, and in an empty space everything is far from everything else by about the same amount. When that happens, "nearest neighbor" stops meaning anything, and every method that leans on distance — kNN, k-means, anything with a kernel — quietly stops working while still returning confident answers.
So this is a methodology chapter, not an algorithm. There's no single thing to build. Instead we build the instruments: a way to measure how distances concentrate as the dimension climbs, watched frame by frame as the gap between the nearest and farthest points collapses toward nothing. Then we build two of the classic defenses by hand — variance-threshold and univariate feature selection — before handing them to scikit-learn, and we bring in PCA from chapter twenty-eight as the third. And we watch a classifier climb to a peak and then fall off it as we feed it more and more mostly-noise features, which is the curse showing up as an accuracy number instead of a geometry fact.
The data is synthetic on purpose. The whole point is to turn the dimension into a knob and crank it from 2 up to 1000, and you can only do that when you're the one making the points.
A bit of history
The name is Richard Bellman's. In his 1957 book Dynamic Programming he was trying to optimize over grids of states, and he noticed that the work exploded the moment you added variables: a grid fine enough to be useful in one dimension needs its resolution raised to the power of the dimension to stay useful, and that power is merciless. He called it "the curse of dimensionality," half in frustration, and the name stuck because everyone who works with many variables eventually meets the same wall. Bellman was talking about optimization, but the phrase turned out to describe something much more general about how volume behaves when you have a lot of axes.
The version that bites machine learning directly got its number eleven years later. In 1968 Gordon Hughes published "On the mean accuracy of statistical pattern recognizers" in the IEEE Transactions on Information Theory, and he proved something that still surprises people: with a fixed amount of training data, a classifier's accuracy does not keep rising as you add features. It rises for a while, reaches a peak, and then declines — more measurements make the model worse. That peak is now called the Hughes phenomenon, or just peaking, and it's the reason "collect more features" is not the free lunch it sounds like. Between Bellman's geometry and Hughes's accuracy curve you have the whole shape of the problem: space empties out as you add dimensions, and a finite dataset can't keep up.
The intuition
Here's the fact that breaks everything, stated as plainly as I can. Take a bunch of points scattered in space, pick one, and measure how far it is from its nearest neighbor and its farthest neighbor. In two dimensions those two numbers are wildly different — the nearest point is right next door and the farthest is across the room. Now do the same in a thousand dimensions, and the nearest and the farthest are almost the same distance away. Every point is roughly equidistant from every other point. The crowd has no near and no far; it's just a uniform haze.
You can watch it happen. Below is the average nearest distance and the average farthest distance among 200 random points, both divided by the mean distance so they're on the same scale, plotted as the dimension climbs. At d = 2 the nearest sits at 0.07 of the mean and the farthest at 1.92 — a chasm between them. By d = 1000 the nearest has risen to 0.95 and the farthest has fallen to 1.05, and the two lines have all but touched. The chasm closed.
That closing is the curse. Once the nearest and farthest neighbors are the same distance away, the word "nearest" has no content. kNN asks "what's closest?" and the honest answer is "everything, equally." k-means asks "which center is this point near?" and gets the same shrug. The methods don't crash — they return a neighbor, they assign a cluster — but the thing they return is noise dressed as an answer, and nothing in the output tells you that. This is the failure mode I most want you to fear, because it's silent.
The math
Pick a query point and a set of points . The quantity that matters is the relative contrast — how much the farthest neighbor exceeds the nearest, measured in units of the nearest:
In low dimensions this is large: the nearest is close, the farthest is far, the ratio is big. The result that names the whole chapter, proved by Beyer, Goldstein, Ramakrishnan and Shaft in 1999 for a broad class of distributions, is that under mild conditions this contrast collapses:
The nearest and the farthest converge, so their difference vanishes relative to either one. That single limit is the geometric core of everything else here.
Why does space empty out? Because volume grows like a power of the dimension. The volume of a ball of radius in dimensions is
and the only part that matters for intuition is the . Double the radius in 2D and you get 4 times the volume; double it in 20 dimensions and you get a million times the volume. Turn that around and it says: to keep points as densely packed as you add dimensions — to keep the same average spacing between neighbors — the number of points you need grows exponentially,
This is the empty-space problem, and it's why more features quietly demand exponentially more data. Ten well-sampled points per axis is a hundred in 2D, a thousand in 3D, and in twenty dimensions — more samples than you will ever collect. Your data does not fill the space it lives in. It clings to a thin scatter inside a mostly-empty box, and distances measured across that emptiness all come out about the same.
Where the curse bites, and where it doesn't
Not every method suffers equally, and knowing which ones bleed first is half of handling high-dimensional data in practice. The methods that die are the ones built directly on distance: kNN, k-means, kernel methods with an RBF kernel, anything that computes a pairwise similarity and trusts it. When contrast collapses, their central quantity becomes uninformative, and they degrade smoothly and silently — no error, no warning, just answers that get steadily more random as the dimension climbs. These are the methods you should reach for last when you have many features, and the ones you should standardize and reduce hardest before you use at all.
The methods that hold up are the ones that don't weigh every dimension equally. A decision tree splits on one feature at a time and simply never splits on the useless ones, so a hundred noise columns cost it almost nothing but training time. A regularized linear model — ridge, lasso, an L2-penalized logistic regression — shrinks the coefficients on features that don't earn their keep toward zero, which is feature selection happening continuously inside the fit. You'll see this directly in a few sections: the same pile of noise that guts a kNN classifier barely scratches a regularized logistic regression. So the first practical move in high dimensions is often just picking a model that's built to ignore junk, and the second is removing the junk yourself. This chapter is about the second move.
The data
Two synthetic setups, because synthetic is the only kind of data where you own the dimension. The first is nothing but random points in the unit cube — n points drawn uniformly in , with no structure at all, generated fresh for each dimension from 2 up to 1000. There's nothing to classify here; the points exist only so we can measure their pairwise distances and watch the contrast collapse. Stripping out all structure is the point: whatever happens to these distances is pure geometry, not an artifact of the data having clusters or correlations.
The second is a classification set built to expose the peaking phenomenon.
Using scikit-learn's make_classification, seeded, we make 800 samples with
exactly 5 informative features — the only columns that carry any signal about
the label — and then we bolt on a reservoir of pure-noise columns, standard
Gaussian, related to nothing. Growing the feature count from 1 up to 500 never
adds signal past the fifth column; it only pours in more junk. That's the honest
version of what happens in the wild when you throw every measurement you have at
a model and hope the algorithm sorts out which ones matter. It doesn't sort them
out for free, and this dataset is built to show exactly how it fails to.
Build it, one function at a time
The from-scratch pieces here aren't an algorithm, they're the measuring tools: one to see the curse, two to fight it. Start with the point sampler — the source of all the geometry. No structure, just uniform noise in the cube, because we want the dimension to be the only thing that varies.
def sample_uniform(n, d, rng):
"""n points drawn uniformly at random from the d-dimensional unit cube.
The cleanest way to isolate the curse: no structure, no clusters, just
random points in [0, 1]^d. Whatever happens to their distances is the
geometry of the space talking, not the data.
"""
return rng.random((n, d))
Everything downstream is distances, so next is the distance from one point to all the others. It's the same broadcast Euclidean distance kNN and k-means run on — which is the whole point, since it's this exact quantity that goes bad in high dimensions:
def dist_to_all(X, q):
"""Euclidean distance from a query point q to every row of X.
X is (n, d); q is (d,). The subtraction broadcasts q across all rows, so
one call returns all n distances at once. This is the same distance kNN
and k-means lean on — which is exactly why they suffer when it stops
discriminating.
"""
return np.sqrt(((X - q) ** 2).sum(axis=1))
Now the instrument that reads the curse. For each point, take its distance to every other point, form (farthest − nearest) / nearest, and average that over all the points. One number that says how much near and far still differ:
def relative_contrast(X):
"""How much the nearest and farthest neighbors differ, on average.
For each point, look at its distance to every other point, and form
(farthest - nearest) / nearest. Average that ratio over all the points.
In low dimensions the nearest neighbor is much closer than the farthest,
so the ratio is large. As the dimension grows every point drifts toward
the same distance from every other, the gap between nearest and farthest
shrinks relative to the nearest, and the ratio collapses toward zero.
When it does, "nearest neighbor" has stopped meaning anything.
"""
ratios = []
for i in range(len(X)):
d = dist_to_all(X, X[i])
d = d[d > 0] # drop the zero distance to itself
ratios.append((d.max() - d.min()) / d.min())
return float(np.mean(ratios))
That's the whole diagnostic. Feed it points in 2 dimensions and it comes back big; feed it points in 1000 and it comes back near zero. The rest of the code is the defense side.
The cheapest defense first: drop features that barely vary. A column that's nearly constant can't separate anything, so it can go before you even look at the labels. This returns a boolean keep-mask over the columns:
def variance_threshold(X, threshold):
"""Keep the columns whose variance is above a cutoff; drop the rest.
The cheapest feature filter there is. A feature that barely varies carries
almost no information to separate anything, so it can go before you even
look at the labels. Returns a boolean mask over the columns. Note what it
does NOT do: a pure-noise feature has plenty of variance, so this filter
sails right past it. It kills dead columns, not useless ones.
"""
variances = X.var(axis=0) # population variance, matches sklearn
return variances > threshold
Read the comment in that function, because it's the trap: variance threshold kills dead columns, not useless ones. A pure-noise feature has plenty of variance, so this filter sails right past it. It's the wrong tool for noise and the right tool for constants, and confusing the two is a common way to think you've cleaned your data when you haven't.
The defense that actually sees the labels is univariate selection. For each
feature, ask whether it separates the classes: split the feature's values by
class and compare how far apart the class means are against how much scatter
there is within each class. That ratio is the ANOVA F-score, and it's exactly
what scikit-learn's f_classif computes, which is what lets us prove our
version is the real thing later.
def anova_f(X, y):
"""Per-feature ANOVA F-score: does this feature separate the classes?
For one feature, split its values by class. If the class means are far
apart relative to the scatter within each class, the feature separates the
classes and the F-score is large; if the classes overlap, F is near zero.
F = (between-class variance) / (within-class variance), computed for every
column at once. This is exactly what scikit-learn's f_classif returns, so
ranking features by it reproduces SelectKBest's choice.
"""
classes = np.unique(y)
n, d = X.shape
k = len(classes)
grand_mean = X.mean(axis=0)
ss_between = np.zeros(d)
ss_within = np.zeros(d)
for c in classes:
Xc = X[y == c]
mean_c = Xc.mean(axis=0)
ss_between += len(Xc) * (mean_c - grand_mean) ** 2
ss_within += ((Xc - mean_c) ** 2).sum(axis=0)
ms_between = ss_between / (k - 1) # between-class, k-1 degrees of freedom
ms_within = ss_within / (n - k) # within-class, n-k degrees of freedom
return ms_between / ms_within
With a score per feature, selection is just taking the top k — sorted back into column order so it lines up with what the library returns:
def select_k_best(scores, k):
"""Indices of the k highest-scoring features, in ascending index order.
Sort the scores, take the top k, then re-sort those indices so the column
order is preserved — which is what sklearn's SelectKBest.get_support does,
and what lets us assert the two agree feature for feature.
"""
top = np.argsort(scores)[::-1][:k]
return np.sort(top)
And the usual accuracy helper, for scoring the classifiers we'll run downstream:
def accuracy(y_true, y_pred):
"""Fraction of predictions that match the truth."""
return float((np.asarray(y_true) == np.asarray(y_pred)).mean())
Watch it work
This is the picture worth slowing down for — the curse happening in real time. Each frame samples 200 fresh points in the unit cube at one dimension, then plots the distribution of their pairwise distances, every distance divided by the mean so the shape is comparable frame to frame. The cyan line marks the average nearest neighbor, the orange line the average farthest, and the caption reads out the dimension and the contrast ratio (max − min) / min, which is unitless.
Press play and watch the histogram collapse. At d = 2 it's a wide, lopsided spread — the nearest marker sits far to the left near 0.07, the farthest way out past 1.9, and the contrast is 41.90. As the dimension climbs the whole distribution pulls inward toward 1, the two markers march at each other, and by d = 1000 the distances are a tight spike: nearest at 0.95 of the mean, farthest at 1.05, contrast down to 0.10. That spike is what "all points are equidistant" looks like. Reset and run it as often as you like — it's seeded, so the same dimensions fall the same way every time.
Now the same story as a single curve: the contrast ratio against the dimension, on a log-log scale so you can see it fall across three orders of magnitude. It's a clean, monotone slide from 41.90 at d = 2 to 0.10 at d = 1000. There's no threshold, no cliff — the curse doesn't switch on at some magic dimension, it just keeps tightening. By the time you're in the hundreds of features, contrast is well under one, which means the farthest neighbor is less than twice as far as the nearest. That's the regime where distance-based methods are running on fumes.
The full implementation
The whole toolkit, top to bottom — the sampler, the distance, the contrast diagnostic, and the two from-scratch feature selectors. No scikit-learn anywhere in this file; this is what the animation above actually ran:
"""The curse of dimensionality, measured from scratch.
There is no single algorithm here — this is a methodology chapter — so the
"from scratch" part is the machinery for seeing the curse and fighting it:
a way to measure how distances concentrate as the dimension grows, and two
classic feature-selection defenses (variance threshold, univariate selection)
built by hand before we hand them to scikit-learn.
Pure NumPy, no ML library anywhere in this file. Every function below appears
in the chapter one step at a time (the `# region:` markers are what the book's
include directives pull in).
"""
import numpy as np
# region: sample_uniform
def sample_uniform(n, d, rng):
"""n points drawn uniformly at random from the d-dimensional unit cube.
The cleanest way to isolate the curse: no structure, no clusters, just
random points in [0, 1]^d. Whatever happens to their distances is the
geometry of the space talking, not the data.
"""
return rng.random((n, d))
# endregion
# region: dist_to_all
def dist_to_all(X, q):
"""Euclidean distance from a query point q to every row of X.
X is (n, d); q is (d,). The subtraction broadcasts q across all rows, so
one call returns all n distances at once. This is the same distance kNN
and k-means lean on — which is exactly why they suffer when it stops
discriminating.
"""
return np.sqrt(((X - q) ** 2).sum(axis=1))
# endregion
# region: relative_contrast
def relative_contrast(X):
"""How much the nearest and farthest neighbors differ, on average.
For each point, look at its distance to every other point, and form
(farthest - nearest) / nearest. Average that ratio over all the points.
In low dimensions the nearest neighbor is much closer than the farthest,
so the ratio is large. As the dimension grows every point drifts toward
the same distance from every other, the gap between nearest and farthest
shrinks relative to the nearest, and the ratio collapses toward zero.
When it does, "nearest neighbor" has stopped meaning anything.
"""
ratios = []
for i in range(len(X)):
d = dist_to_all(X, X[i])
d = d[d > 0] # drop the zero distance to itself
ratios.append((d.max() - d.min()) / d.min())
return float(np.mean(ratios))
# endregion
# region: variance_threshold
def variance_threshold(X, threshold):
"""Keep the columns whose variance is above a cutoff; drop the rest.
The cheapest feature filter there is. A feature that barely varies carries
almost no information to separate anything, so it can go before you even
look at the labels. Returns a boolean mask over the columns. Note what it
does NOT do: a pure-noise feature has plenty of variance, so this filter
sails right past it. It kills dead columns, not useless ones.
"""
variances = X.var(axis=0) # population variance, matches sklearn
return variances > threshold
# endregion
# region: anova_f
def anova_f(X, y):
"""Per-feature ANOVA F-score: does this feature separate the classes?
For one feature, split its values by class. If the class means are far
apart relative to the scatter within each class, the feature separates the
classes and the F-score is large; if the classes overlap, F is near zero.
F = (between-class variance) / (within-class variance), computed for every
column at once. This is exactly what scikit-learn's f_classif returns, so
ranking features by it reproduces SelectKBest's choice.
"""
classes = np.unique(y)
n, d = X.shape
k = len(classes)
grand_mean = X.mean(axis=0)
ss_between = np.zeros(d)
ss_within = np.zeros(d)
for c in classes:
Xc = X[y == c]
mean_c = Xc.mean(axis=0)
ss_between += len(Xc) * (mean_c - grand_mean) ** 2
ss_within += ((Xc - mean_c) ** 2).sum(axis=0)
ms_between = ss_between / (k - 1) # between-class, k-1 degrees of freedom
ms_within = ss_within / (n - k) # within-class, n-k degrees of freedom
return ms_between / ms_within
# endregion
# region: select_k_best
def select_k_best(scores, k):
"""Indices of the k highest-scoring features, in ascending index order.
Sort the scores, take the top k, then re-sort those indices so the column
order is preserved — which is what sklearn's SelectKBest.get_support does,
and what lets us assert the two agree feature for feature.
"""
top = np.argsort(scores)[::-1][:k]
return np.sort(top)
# endregion
# region: accuracy
def accuracy(y_true, y_pred):
"""Fraction of predictions that match the truth."""
return float((np.asarray(y_true) == np.asarray(y_pred)).mean())
# endregion
The library version
Nobody writes their own feature selectors in production, and once you've written
them once you shouldn't either. The two we built have exact scikit-learn
counterparts. VarianceThreshold is the variance filter, returning the same
keep-mask:
def sklearn_variance_threshold(X, threshold):
"""VarianceThreshold: the same variance filter, returning the keep-mask."""
sel = VarianceThreshold(threshold=threshold)
sel.fit(X)
return sel.get_support() # boolean mask over columns
SelectKBest with the ANOVA F-test is the univariate selector — the same score,
the same top-k, and we hand back the chosen indices and the raw scores so we can
check both against our version:
def sklearn_select_k_best(X, y, k):
"""SelectKBest with the ANOVA F-test — the library's univariate selection.
Returns (indices, scores) so the chapter can check both the ranking and the
chosen columns against our from-scratch anova_f + select_k_best.
"""
sel = SelectKBest(score_func=f_classif, k=k)
sel.fit(X, y)
idx = np.sort(np.where(sel.get_support())[0])
return idx, sel.scores_
The third defense is the one selection can't give you: instead of choosing which original features to keep, build new axes that pack the variance and discard the rest. That's PCA from chapter twenty-eight, fit on the training data and applied to the test data so nothing leaks:
def sklearn_pca(X_train, X_test, k):
"""Project onto the top k principal components (fit on train only).
The dimensionality-reduction defense: instead of choosing which of the
original features to keep, build k new axes that pack the variance and
throw the rest away. Fit on the training data, apply the same rotation to
the test data — fitting on the test set would leak.
"""
pca = PCA(n_components=k, random_state=0)
return pca.fit_transform(X_train), pca.transform(X_test)
And two classifiers, so the next section can show the curse as an accuracy number: a distance-based kNN, which the concentration effect hits first, and an L2-regularized logistic regression, which shrugs most of the noise off.
def knn_accuracy(X_train, y_train, X_test, y_test, k=5):
"""Test accuracy of a plain k-NN classifier — the distance-based victim."""
model = KNeighborsClassifier(n_neighbors=k)
model.fit(X_train, y_train)
return float(model.score(X_test, y_test))
def logistic_accuracy(X_train, y_train, X_test, y_test):
"""Test accuracy of an L2-regularized logistic regression — the survivor.
The ridge penalty (sklearn's default) shrinks coefficients on features that
don't earn their keep, so pouring in noise columns costs it far less than it
costs k-NN.
"""
model = LogisticRegression(C=1.0, max_iter=2000, random_state=0)
model.fit(X_train, y_train)
return float(model.score(X_test, y_test))
Peaking, and how to fight back
Now the curse as an accuracy curve. We take that seeded classification set — 5 informative features and a growing pile of noise — split it 560 train / 240 test, and score both classifiers as the feature count climbs from 1 to 500. The metric is test accuracy: the fraction of held-out points classified correctly, unitless, from 0 to 1.
There's the Hughes phenomenon, exactly as promised. The kNN line climbs as the first few informative features come in, peaks at 0.9958 with all 5 informative features present, and then falls — steadily, all the way down to 0.6708 at 500 features. Every feature past the fifth is pure noise, and every one of them adds a little more meaningless distance to every comparison, so the neighbor search gets a little more random with each column. More measurements, worse model. That's the curse cashed out as a number, and it's why "just add more features" is advice that quietly destroys distance-based classifiers.
Look at the other line, though. The L2 logistic regression climbs to the same neighborhood and then barely comes down at all — it's still at 0.9208 out at 500 features where kNN has cratered. The ridge penalty is shrinking the coefficients on all those noise columns toward zero, so they cost it almost nothing. Same data, same noise, wildly different outcome — the difference is entirely in whether the model weighs every dimension equally. That gap between the two lines is the single most useful thing on this page: it's the reason "which model" is often a better answer to high-dimensional data than any amount of preprocessing.
But suppose you're stuck with a distance-based method, or you just want the noise gone. Take the worst point on that curve — 200 features, kNN down in the valley at 0.7250 — and try the three defenses. Each one is fit on the training data only and scored on the held-out set:
Three defenses, three very different results, and the differences are the lesson. Variance threshold does nothing — it lands on 0.7250, exactly the baseline — because there are no dead columns to remove. The noise features all have real variance; they're useless, not constant, and variance threshold can't tell the difference. That's not a failure of the tool, it's the tool being honest about what it's for. Reach for it to strip constants and near-constants, not to fight noise.
Univariate selection is the star. SelectKBest with k = 5 keeps the five
highest-F columns and kNN jumps back to 0.9833 — almost all the way to the
peak — because dropping 195 noise columns restores the contrast that made
distances mean something. Worth one honest caveat: the five columns it kept were
four of the informative features plus one noise column, and it missed the fifth
informative feature entirely. That feature happens to be individually
near-useless — it only carries signal in combination with the others — and a
univariate test, which judges every feature on its own, is blind to that kind of
teamwork. It's a known limitation of the method, and here it costs nothing
because the feature it missed wasn't pulling much weight alone. On data where
the signal lives in combinations, it can cost you more.
PCA lands in between at 0.8542. It doesn't get to see the labels, so it can't target the informative features directly; it just packs the variance into five new axes. With signal and noise at the same scale, some of that variance is noise, so the recovery is partial — better than doing nothing, not as clean as selection that knows what it's aiming at. That's the usual trade with PCA: it's unsupervised, so it's safe from leaking the labels and it works when you have no labels at all, but it can't distinguish variance that matters from variance that doesn't.
One more thing about that face-off, and it's the checkpoint that keeps this
chapter honest. Our from-scratch variance_threshold returns the same mask as
scikit-learn's, and our anova_f plus select_k_best picks the same five
columns as SelectKBest, scored to within floating-point tolerance. The
generator asserts all of it on every run, so if our version ever drifted from
the library's, the data wouldn't build. Matching sklearn isn't the point in
itself — it's the proof that the selectors we built are the real ones and not
lookalikes.
Takeaways
The one sentence to carry out of here: more features is not more signal, and past a point it's actively less. That's Hughes's peak, and it's not a corner case — it's what happens by default when you feed a finite dataset a growing pile of measurements. The instinct to collect every column you can and let the model sort it out is exactly backwards; the model does not sort it out for free, and distance-based models don't sort it out at all.
Know who dies first. Anything built on distance — kNN, k-means, RBF kernels — degrades silently as the dimension climbs, because the contrast that distance depends on collapses toward zero and every point becomes equidistant from every other. These methods won't warn you; they'll return neighbors and clusters that are noise, with the same confidence they'd show on clean two-dimensional data. If you're in high dimensions and reaching for a distance-based method, standardize first, reduce hard, and check whether a tree or a regularized linear model does better — often it does, precisely because it's built to ignore the columns that don't matter.
And when you do fight the dimension directly, match the tool to the problem. Variance threshold clears dead columns and does nothing about noise, so don't mistake it for a noise filter. Univariate selection is cheap and effective and recovers most of what the noise cost you, as long as you remember it judges features one at a time and can miss the ones that only matter in combination. PCA reduces without labels and is the right move when you have none, at the cost of not knowing which variance is the signal. None of them beats the two blunt answers that sit underneath the whole chapter: get more data, or use fewer dimensions. The curse is exponential in the dimension, and the only things that scale with an exponential are another exponential — more data — or spending less of it — fewer features. Everything in this chapter is a way of buying one of those two with the other.