Curso de ML EN

Capítulo 26 de 37 · intermedio

Datos de alta dimensión, la maldición de la dimensionalidad

De qué trata este capítulo

Toda la intuición que tienes sobre distancias se entrenó en dos y tres dimensiones, y toda ella te miente en doscientas. Este es el capítulo donde eso deja de ser una frase bonita y se vuelve algo que puedes medir. La maldición de la dimensionalidad no es un solo problema, es una familia de problemas, y todos salen de la misma raíz: el espacio de alta dimensión está casi vacío, y en un espacio vacío todo está lejos de todo más o menos por igual. Cuando eso pasa, "vecino más cercano" deja de significar algo, y cada método que se apoya en distancias — kNN, k-means, cualquier cosa con un kernel — deja de funcionar en silencio mientras te sigue regresando respuestas con toda seguridad.

Así que este es un capítulo de metodología, no de algoritmo. No hay una sola cosa que construir. En vez de eso construimos los instrumentos: una forma de medir cómo se concentran las distancias conforme sube la dimensión, vista cuadro por cuadro mientras la brecha entre el punto más cercano y el más lejano se colapsa hasta desaparecer. Después construimos a mano dos de las defensas clásicas — umbral de varianza y selección univariada de features — antes de entregárselas a scikit-learn, y traemos PCA del capítulo veintiocho como la tercera. Y vemos a un clasificador subir hasta un pico y luego caerse de él conforme le damos más y más features que son casi puro ruido, que es la maldición apareciendo como un número de accuracy en lugar de un hecho geométrico.

Los datos son sintéticos a propósito. Todo el chiste es convertir la dimensión en una perilla y girarla de 2 hasta 1000, y eso solo lo puedes hacer cuando tú eres quien fabrica los puntos.

Un poco de historia

El nombre es de Richard Bellman. En su libro de 1957, Dynamic Programming, estaba tratando de optimizar sobre mallas de estados, y notó que el trabajo explotaba en cuanto agregabas variables: una malla lo bastante fina para servir en una dimensión necesita elevar su resolución a la potencia de la dimensión para seguir sirviendo, y esa potencia no perdona. La llamó "la maldición de la dimensionalidad", medio en frustración, y el nombre se quedó porque cualquiera que trabaje con muchas variables acaba topándose con la misma pared. Bellman hablaba de optimización, pero la frase terminó describiendo algo mucho más general sobre cómo se comporta el volumen cuando tienes un montón de ejes.

La versión que le pega directo al machine learning consiguió su número once años después. En 1968 Gordon Hughes publicó "On the mean accuracy of statistical pattern recognizers" en las IEEE Transactions on Information Theory, y demostró algo que todavía sorprende a la gente: con una cantidad fija de datos de entrenamiento, el accuracy de un clasificador no sigue subiendo conforme agregas features. Sube un rato, llega a un pico, y luego baja — más mediciones empeoran el modelo. A ese pico hoy se le llama el fenómeno de Hughes, o nada más peaking, y es la razón por la que "junta más features" no es la comida gratis que suena. Entre la geometría de Bellman y la curva de accuracy de Hughes tienes la forma completa del problema: el espacio se vacía conforme agregas dimensiones, y un dataset finito no puede seguirle el paso.

La intuición

Aquí está el hecho que rompe todo, dicho lo más simple que puedo. Agarra un montón de puntos regados en el espacio, escoge uno, y mide qué tan lejos está de su vecino más cercano y de su vecino más lejano. En dos dimensiones esos dos números son radicalmente distintos — el punto más cercano está justo al lado y el más lejano está del otro lado del cuarto. Ahora haz lo mismo en mil dimensiones, y el más cercano y el más lejano están casi a la misma distancia. Cada punto está más o menos equidistante de todos los demás. La multitud no tiene cerca ni lejos; es nada más una neblina uniforme.

Lo puedes ver pasar. Abajo está la distancia promedio al más cercano y la distancia promedio al más lejano entre 200 puntos aleatorios, ambas divididas entre la distancia media para que estén en la misma escala, graficadas conforme sube la dimensión. En d = 2 el más cercano se para en 0.07 de la media y el más lejano en 1.92 — un abismo entre ellos. Para d = 1000 el más cercano subió a 0.95 y el más lejano bajó a 1.05, y las dos líneas prácticamente se tocaron. El abismo se cerró.

Ese cierre es la maldición. Una vez que el vecino más cercano y el más lejano están a la misma distancia, la palabra "más cercano" no tiene contenido. kNN pregunta "¿qué está más cerca?" y la respuesta honesta es "todo, por igual". k-means pregunta "¿de qué centro está cerca este punto?" y recibe el mismo encogimiento de hombros. Los métodos no truenan — te regresan un vecino, te asignan un cluster — pero lo que regresan es ruido disfrazado de respuesta, y nada en la salida te avisa. Este es el modo de falla que más quiero que te dé miedo, porque es silencioso.

Las matemáticas

Escoge un punto de consulta qq y un conjunto de puntos x1,,xnx_1, \dots, x_n. La cantidad que importa es el contraste relativo — cuánto excede el vecino más lejano al más cercano, medido en unidades del más cercano:

contrast(q)=maxid(q,xi)minid(q,xi)minid(q,xi)\text{contrast}(q) = \frac{\max_i \, d(q, x_i) - \min_i \, d(q, x_i)}{\min_i \, d(q, x_i)}

En dimensiones bajas esto es grande: el más cercano está cerca, el más lejano está lejos, la razón es grande. El resultado que le da nombre a todo el capítulo, demostrado por Beyer, Goldstein, Ramakrishnan y Shaft en 1999 para una clase amplia de distribuciones, es que bajo condiciones suaves este contraste se colapsa:

limd  contrast(q)    0\lim_{d \to \infty} \; \text{contrast}(q) \; \to \; 0

El más cercano y el más lejano convergen, así que su diferencia se desvanece frente a cualquiera de los dos. Ese único límite es el corazón geométrico de todo lo demás que hay aquí.

¿Por qué se vacía el espacio? Porque el volumen crece como una potencia de la dimensión. El volumen de una bola de radio rr en dd dimensiones es

Vd(r)=πd/2Γ ⁣(d2+1)rdV_d(r) = \frac{\pi^{d/2}}{\Gamma\!\left(\frac{d}{2} + 1\right)} \, r^{d}

y la única parte que importa para la intuición es el rdr^{d}. Duplica el radio en 2D y obtienes 4 veces el volumen; duplícalo en 20 dimensiones y obtienes un millón de veces el volumen. Voltéalo y lo que dice es: para mantener los puntos igual de densos conforme agregas dimensiones — para mantener el mismo espaciado promedio ε\varepsilon entre vecinos — la cantidad de puntos que necesitas crece exponencialmente,

N(1ε)dN \propto \left(\frac{1}{\varepsilon}\right)^{d}

Este es el problema del espacio vacío, y es la razón por la que más features exigen calladamente exponencialmente más datos. Diez puntos bien muestreados por eje son cien en 2D, mil en 3D, y 102010^{20} en veinte dimensiones — más muestras de las que vas a juntar en toda tu vida. Tus datos no llenan el espacio donde viven. Se aferran a una capa delgadita dentro de una caja casi vacía, y las distancias medidas a través de ese vacío salen todas más o menos iguales.

Dónde muerde la maldición, y dónde no

No todos los métodos sufren igual, y saber cuáles sangran primero es la mitad de manejar datos de alta dimensión en la práctica. Los métodos que se mueren son los construidos directamente sobre distancias: kNN, k-means, métodos de kernel con un kernel RBF, cualquier cosa que calcule una similitud por pares y confíe en ella. Cuando el contraste se colapsa, su cantidad central deja de informar, y se degradan de forma suave y silenciosa — sin error, sin advertencia, nada más respuestas que se van volviendo cada vez más aleatorias conforme sube la dimensión. Estos son los métodos a los que deberías recurrir al final cuando tienes muchas features, y los que deberías estandarizar y reducir con más fuerza antes de siquiera usarlos.

Los métodos que aguantan son los que no le dan el mismo peso a cada dimensión. Un árbol de decisión parte por una feature a la vez y simplemente nunca parte por las inútiles, así que cien columnas de ruido casi no le cuestan más que tiempo de entrenamiento. Un modelo lineal regularizado — ridge, lasso, una regresión logística con penalización L2 — encoge hacia cero los coeficientes de las features que no se ganan su lugar, que es selección de features ocurriendo de forma continua dentro del ajuste. Vas a ver esto directo en unas cuantas secciones: el mismo montón de ruido que destripa a un clasificador kNN apenas le raspa a una regresión logística regularizada. Así que el primer movimiento práctico en alta dimensión muchas veces es nada más escoger un modelo hecho para ignorar basura, y el segundo es quitar la basura tú mismo. Este capítulo trata del segundo movimiento.

Los datos

Dos setups sintéticos, porque lo sintético es el único tipo de datos donde tú eres dueño de la dimensión. El primero no es más que puntos aleatorios en el cubo unitario — n puntos tomados uniformemente en [0,1]d[0,1]^d, sin ninguna estructura, generados de cero para cada dimensión desde 2 hasta 1000. Aquí no hay nada que clasificar; los puntos existen solo para que podamos medir sus distancias por pares y ver el contraste colapsarse. Quitar toda la estructura es justo la idea: lo que sea que le pase a estas distancias es geometría pura, no un artefacto de que los datos tengan clusters o correlaciones.

El segundo es un conjunto de clasificación armado para exponer el fenómeno de peaking. Con make_classification de scikit-learn, con semilla fija, hacemos 800 muestras con exactamente 5 features informativas — las únicas columnas que cargan alguna señal sobre la etiqueta — y luego le atornillamos un depósito de columnas de puro ruido, gaussianas estándar, relacionadas con nada. Crecer el número de features de 1 hasta 500 nunca agrega señal después de la quinta columna; solo echa más basura. Esa es la versión honesta de lo que pasa allá afuera cuando le avientas a un modelo todas las mediciones que tienes y esperas que el algoritmo distinga cuáles importan. No las distingue gratis, y este dataset está hecho para mostrar exactamente cómo falla al intentarlo.

Constrúyelo, una función a la vez

Las piezas hechas desde cero aquí no son un algoritmo, son las herramientas de medición: una para ver la maldición, dos para pelear contra ella. Empieza por el muestreador de puntos — la fuente de toda la geometría. Sin estructura, nada más ruido uniforme en el cubo, porque queremos que la dimensión sea lo único que varíe.

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))

Todo lo que sigue son distancias, así que lo siguiente es la distancia de un punto a todos los demás. Es la misma distancia euclidiana con broadcast sobre la que corren kNN y k-means — que es justo el punto, porque es esta cantidad exacta la que se echa a perder en alta dimensión:

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))

Ahora el instrumento que lee la maldición. Para cada punto, toma su distancia a todos los demás, forma (más lejano − más cercano) / más cercano, y promedia eso sobre todos los puntos. Un solo número que dice cuánto siguen difiriendo el cerca y el lejos:

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))

Ese es todo el diagnóstico. Dale puntos en 2 dimensiones y regresa grande; dale puntos en 1000 y regresa casi cero. El resto del código es el lado de la defensa.

Primero la defensa más barata: tirar las features que casi no varían. Una columna que es casi constante no puede separar nada, así que se puede ir antes de que siquiera veas las etiquetas. Esto regresa una máscara booleana de qué columnas conservar:

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

Lee el comentario en esa función, porque ahí está la trampa: el umbral de varianza mata columnas muertas, no columnas inútiles. Una feature de puro ruido tiene bastante varianza, así que este filtro le pasa de largo. Es la herramienta equivocada para el ruido y la correcta para las constantes, y confundir las dos es una forma común de creer que ya limpiaste tus datos cuando no.

La defensa que sí ve las etiquetas es la selección univariada. Para cada feature, pregunta si separa las clases: parte los valores de la feature por clase y compara qué tan separadas están las medias de cada clase contra cuánta dispersión hay dentro de cada una. Esa razón es el F-score de ANOVA, y es exactamente lo que calcula f_classif de scikit-learn, que es lo que nos deja comprobar más adelante que nuestra versión es la de verdad.

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

Con un score por feature, seleccionar es nada más tomar las top k — reordenadas de vuelta al orden de columnas para que embone con lo que regresa la librería:

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)

Y el clásico helper de accuracy, para calificar a los clasificadores que vamos a correr más adelante:

def accuracy(y_true, y_pred):
    """Fraction of predictions that match the truth."""
    return float((np.asarray(y_true) == np.asarray(y_pred)).mean())

Míralo funcionar

Esta es la imagen por la que vale la pena bajarle a la velocidad — la maldición pasando en tiempo real. Cada cuadro muestrea 200 puntos nuevos en el cubo unitario en una dimensión, y luego grafica la distribución de sus distancias por pares, cada distancia dividida entre la media para que la forma sea comparable cuadro a cuadro. La línea cian marca el promedio del vecino más cercano, la naranja el promedio del más lejano, y el pie de imagen te muestra la dimensión y la razón de contraste (max − min) / min, que es adimensional.

Dale play y mira cómo se colapsa el histograma. En d = 2 es una dispersión amplia y desbalanceada — el marcador del más cercano está hasta la izquierda, cerca de 0.07, el del más lejano bien afuera pasando 1.9, y el contraste es 41.90. Conforme sube la dimensión toda la distribución se jala hacia adentro, hacia el 1, los dos marcadores caminan uno hacia el otro, y para d = 1000 las distancias son un pico apretado: el más cercano en 0.95 de la media, el más lejano en 1.05, con el contraste caído a 0.10. Ese pico es cómo se ve "todos los puntos son equidistantes". Reinicia y córrelo cuantas veces quieras — tiene semilla fija, así que las mismas dimensiones caen igual cada vez.

Ahora la misma historia como una sola curva: la razón de contraste contra la dimensión, en escala log-log para que veas cómo cae a lo largo de tres órdenes de magnitud. Es un deslizamiento limpio y monótono de 41.90 en d = 2 a 0.10 en d = 1000. No hay umbral, no hay precipicio — la maldición no se enciende en alguna dimensión mágica, nada más sigue apretando. Para cuando andas en cientos de features, el contraste está muy por debajo de uno, lo que significa que el vecino más lejano está a menos del doble de distancia que el más cercano. Ese es el régimen donde los métodos basados en distancia van con el tanque en las últimas.

La implementación completa

Todo el toolkit, de arriba a abajo — el muestreador, la distancia, el diagnóstico de contraste, y los dos selectores de features hechos desde cero. Nada de scikit-learn en este archivo; esto es lo que realmente corrió la animación de arriba:

"""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

La versión con librería

Nadie escribe sus propios selectores de features en producción, y una vez que ya los escribiste una vez, tú tampoco deberías. Los dos que construimos tienen contrapartes exactas en scikit-learn. VarianceThreshold es el filtro de varianza, y regresa la misma máscara de columnas a conservar:

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 con la prueba F de ANOVA es el selector univariado — el mismo score, el mismo top-k, y regresamos los índices elegidos junto con los scores crudos para poder comparar ambos contra nuestra versión:

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_

La tercera defensa es la que la selección no te puede dar: en lugar de escoger cuáles features originales conservar, construye ejes nuevos que empaquen la varianza y descarta el resto. Eso es PCA del capítulo veintiocho, ajustado sobre los datos de entrenamiento y aplicado a los de prueba para que nada se filtre:

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)

Y dos clasificadores, para que la siguiente sección pueda mostrar la maldición como un número de accuracy: un kNN basado en distancia, al que el efecto de concentración le pega primero, y una regresión logística regularizada con L2, que se sacude casi todo el ruido.

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, y cómo contraatacar

Ahora la maldición como curva de accuracy. Tomamos ese conjunto de clasificación con semilla — 5 features informativas y un montón creciente de ruido — lo partimos en 560 de entrenamiento y 240 de prueba, y calificamos ambos clasificadores conforme el número de features sube de 1 a 500. La métrica es accuracy de prueba: la fracción de puntos apartados clasificados correctamente, adimensional, de 0 a 1.

Ahí está el fenómeno de Hughes, tal como lo prometimos. La línea de kNN sube conforme entran las primeras features informativas, hace pico en 0.9958 con las 5 features informativas presentes, y luego cae — sin parar, hasta 0.6708 con 500 features. Cada feature después de la quinta es puro ruido, y cada una le agrega un poquito más de distancia sin sentido a cada comparación, así que la búsqueda de vecinos se vuelve un poco más aleatoria con cada columna. Más mediciones, peor modelo. Esa es la maldición convertida en número, y es la razón por la que "nada más agrégale más features" es un consejo que destruye calladamente a los clasificadores basados en distancia.

Pero mira la otra línea. La regresión logística con L2 sube al mismo vecindario y después casi no baja — sigue en 0.9208 allá en las 500 features donde kNN ya se hundió. La penalización ridge está encogiendo hacia cero los coeficientes de todas esas columnas de ruido, así que casi no le cuestan. Los mismos datos, el mismo ruido, resultados radicalmente distintos — la diferencia está por completo en si el modelo le da el mismo peso a cada dimensión. Esa brecha entre las dos líneas es lo más útil de toda esta página: es la razón por la que "cuál modelo" suele ser mejor respuesta a los datos de alta dimensión que cualquier cantidad de preprocesamiento.

Pero supongamos que estás atorado con un método basado en distancia, o que nada más quieres el ruido fuera. Toma el peor punto de esa curva — 200 features, kNN hundido en el valle en 0.7250 — y prueba las tres defensas. Cada una se ajusta solo con los datos de entrenamiento y se califica sobre el conjunto apartado:

Tres defensas, tres resultados muy distintos, y las diferencias son la lección. El umbral de varianza no hace nada — se queda en 0.7250, exactamente el baseline — porque no hay columnas muertas que quitar. Las features de ruido todas tienen varianza real; son inútiles, no constantes, y el umbral de varianza no puede notar la diferencia. Eso no es una falla de la herramienta, es la herramienta siendo honesta sobre para qué sirve. Úsala para quitar constantes y casi-constantes, no para pelear contra el ruido.

La selección univariada es la estrella. SelectKBest con k = 5 conserva las cinco columnas con F más alto y kNN salta de vuelta a 0.9833 — casi hasta el pico — porque tirar 195 columnas de ruido restaura el contraste que hacía que las distancias significaran algo. Vale la pena una advertencia honesta: las cinco columnas que conservó fueron cuatro de las features informativas más una columna de ruido, y se le escapó por completo la quinta feature informativa. Esa feature resulta ser casi inútil por sí sola — solo carga señal en combinación con las otras — y una prueba univariada, que juzga cada feature por separado, es ciega a ese tipo de trabajo en equipo. Es una limitación conocida del método, y aquí no cuesta nada porque la feature que se le escapó tampoco cargaba mucho sola. En datos donde la señal vive en las combinaciones, te puede costar más.

PCA aterriza en medio, en 0.8542. No alcanza a ver las etiquetas, así que no puede apuntarle directo a las features informativas; nada más empaqueta la varianza en cinco ejes nuevos. Con la señal y el ruido en la misma escala, parte de esa varianza es ruido, así que la recuperación es parcial — mejor que no hacer nada, no tan limpia como una selección que sabe a qué le está apuntando. Ese es el trueque de siempre con PCA: es no supervisado, así que está a salvo de filtrar las etiquetas y funciona cuando no tienes etiquetas para nada, pero no puede distinguir la varianza que importa de la que no.

Una cosa más sobre ese enfrentamiento, y es el checkpoint que mantiene honesto a este capítulo. Nuestro variance_threshold hecho desde cero regresa la misma máscara que el de scikit-learn, y nuestro anova_f más select_k_best escoge las mismas cinco columnas que SelectKBest, con scores que coinciden dentro de la tolerancia de punto flotante. El generador lo verifica todo con asserts en cada corrida, así que si nuestra versión llegara a desviarse de la de la librería, los datos no se construirían. Empatarle a sklearn no es el objetivo en sí — es la prueba de que los selectores que construimos son los de verdad y no imitaciones.

Puntos clave

La frase que te tienes que llevar de aquí: más features no es más señal, y pasado cierto punto es activamente menos. Ese es el pico de Hughes, y no es un caso de esquina — es lo que pasa por default cuando le das a un dataset finito un montón creciente de mediciones. El instinto de juntar todas las columnas que puedas y dejar que el modelo se encargue está exactamente al revés; el modelo no se encarga gratis, y los modelos basados en distancia no se encargan en lo absoluto.

Ten claro quién muere primero. Todo lo construido sobre distancias — kNN, k-means, kernels RBF — se degrada en silencio conforme sube la dimensión, porque el contraste del que depende la distancia se colapsa hacia cero y cada punto se vuelve equidistante de todos los demás. Estos métodos no te van a avisar; te van a regresar vecinos y clusters que son ruido, con la misma seguridad que mostrarían con datos limpios en dos dimensiones. Si estás en alta dimensión y vas a echar mano de un método basado en distancia, estandariza primero, reduce con fuerza, y checa si un árbol o un modelo lineal regularizado lo hace mejor — muchas veces sí, precisamente porque está hecho para ignorar las columnas que no importan.

Y cuando sí pelees contra la dimensión de frente, empata la herramienta con el problema. El umbral de varianza limpia columnas muertas y no hace nada contra el ruido, así que no lo confundas con un filtro de ruido. La selección univariada es barata y efectiva y recupera la mayor parte de lo que te costó el ruido, mientras recuerdes que juzga las features una por una y puede dejar fuera las que solo importan en combinación. PCA reduce sin etiquetas y es la jugada correcta cuando no tienes ninguna, a costa de no saber cuál varianza es la señal. Ninguna de ellas le gana a las dos respuestas brutas que sostienen todo el capítulo: consigue más datos, o usa menos dimensiones. La maldición es exponencial en la dimensión, y lo único que escala contra una exponencial es otra exponencial — más datos — o gastar menos de ella — menos features. Todo en este capítulo es una forma de comprar una de esas dos con la otra.