Módulo 4 · Aprendizaje no supervisado

Lección 2 de 4

t-SNE y UMAP

En esta páginaEl problema

El problema

Tomas 500 imágenes de load_digits. Cada imagen tiene 8 × 8 píxeles, así que cada fila es un punto en 64 dimensiones. Una proyección con PCA a dos dimensiones muestra los dígitos encimados. Un mapa de t-SNE con perplexity=30 y random_state=0 muestra unas diez islas bien separadas. Un compañero colorea las islas con la etiqueta real y escribe estas conclusiones:

lo que se ve en el mapa t-SNEconclusión propuesta
la isla del 9 es la más cercana a la del 0el 9 es el dígito más parecido al 0
la isla del 9 es la más extendidalos 9 son los dígitos con más variación en sus píxeles
hay unas diez islas separadasK-means sobre las dos coordenadas encuentra los grupos de los datos

Proyección con PCA

La lección anterior usó PCA para comprimir. Aquí PCA sirve como punto de comparación: proyecta los 500 dígitos sobre las dos direcciones de mayor varianza. Los 64 píxeles miden intensidad en la misma escala de 0 a 16, así que no hace falta escalar.

Para medir qué tan bien un mapa conserva vecindarios, vecino_mismo_digito busca el vecino más cercano de cada punto y cuenta la fracción de veces que tiene la misma etiqueta. Las etiquetas no intervienen en la proyección. Solo se usan para evaluar el mapa.

Pregunta. Estima la fracción de vecinos con el mismo dígito en 64 dimensiones y en la proyección PCA. Estima también qué fracción de la varianza conservan las dos componentes.

Python 3 · ejemplo reproducible
import numpy as np
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.neighbors import NearestNeighbors

X_todo, y_todo = load_digits(return_X_y=True)
rng = np.random.default_rng(0)
filas = rng.choice(len(X_todo), size=500, replace=False)
X = X_todo[filas]
y = y_todo[filas]


def vecino_mismo_digito(Z):
    vecinos = NearestNeighbors(n_neighbors=2).fit(Z)
    indices = vecinos.kneighbors(Z, return_distance=False)[:, 1]
    return (y[indices] == y).mean()


pca = PCA(n_components=2).fit(X)
Z_pca = pca.transform(X)

print(X.shape, Z_pca.shape)
print("varianza explicada:", pca.explained_variance_ratio_.round(3))
print("total:", round(pca.explained_variance_ratio_.sum(), 3))
print("vecino con el mismo dígito, 64 dimensiones:", round(vecino_mismo_digito(X), 3))
print("vecino con el mismo dígito, PCA:", round(vecino_mismo_digito(Z_pca), 3))
Salida esperada

La salida esperada está oculta.

Proyección con t-SNE

t-SNE (t-distributed Stochastic Neighbor Embedding) no busca direcciones de varianza. Para cada punto calcula qué otros puntos son sus vecinos en 64 dimensiones y convierte esas distancias en probabilidades. perplexity fija, de forma aproximada, cuántos vecinos cuentan para cada punto. Después coloca los 500 puntos en el plano desde posiciones aleatorias (init="random") y los mueve para que las probabilidades de vecindad en el plano se parezcan a las originales. El resultado depende de random_state, que fija esas posiciones iniciales.

La función tsne limita la optimización a max_iter=500 iteraciones (el valor por defecto es 1000) y usa un solo proceso con n_jobs=1.

Pregunta. ¿La fracción de vecinos con el mismo dígito en el mapa t-SNE quedará más cerca de la de PCA o de la de 64 dimensiones? ¿Tienen pca y un objeto TSNE un método transform para proyectar imágenes nuevas?

Python 3 · ejemplo reproducible
from sklearn.manifold import TSNE


def tsne(perplexity, semilla):
    modelo = TSNE(
        n_components=2,
        perplexity=perplexity,
        init="random",
        max_iter=500,
        random_state=semilla,
        n_jobs=1,
    )
    return modelo.fit_transform(X)


Z_tsne = tsne(30, 0)

print(Z_tsne.shape)
print("vecino con el mismo dígito, t-SNE:", round(vecino_mismo_digito(Z_tsne), 3))
print("PCA tiene transform:", hasattr(pca, "transform"))
print("TSNE tiene transform:", hasattr(TSNE(), "transform"))
Salida esperada

La salida esperada está oculta.

Perplexity y semilla

Un mapa t-SNE es el resultado de una optimización con dos decisiones: cuántos vecinos cuentan (perplexity) y desde dónde empieza (random_state). mas_cercano_al_0 calcula el centroide de cada dígito en el mapa y devuelve el dígito cuyo centroide queda más cerca del centroide del 0. np.ptp mide el ancho del mapa en el eje horizontal.

Pregunta. Entre perplexity 5, 30 y 100, y entre las semillas 0 y 1, ¿cambiará mucho la fracción de vecinos con el mismo dígito? ¿El dígito más cercano al 0 será siempre el 9? ¿El ancho del mapa será parecido?

Python 3 · ejemplo reproducible
def centroides(Z):
    return np.array([Z[y == d].mean(axis=0) for d in range(10)])


def mas_cercano_al_0(Z):
    C = centroides(Z)
    distancias = np.linalg.norm(C[1:] - C[0], axis=1)
    return int(np.argmin(distancias)) + 1


mapas = {}
for perplexity, semilla in [(5, 0), (30, 0), (30, 1), (100, 0)]:
    Z = tsne(perplexity, semilla)
    mapas[(perplexity, semilla)] = Z
    print(
        f"perplexity {perplexity:>3}, semilla {semilla}: "
        f"vecino {vecino_mismo_digito(Z):.3f}  "
        f"ancho {np.ptp(Z[:, 0]):5.1f}  "
        f"más cercano al 0: {mas_cercano_al_0(Z)}"
    )
Salida esperada

La salida esperada está oculta.

Distancias y tamaños

Las conclusiones del problema hablan de distancias entre grupos y de tamaños de grupos. Las dos cosas se pueden medir en 64 dimensiones. El acuerdo de distancias compara las 45 distancias entre centroides de dígitos en el mapa con las mismas distancias en 64 dimensiones. pdist, de scipy.spatial.distance, da las 45 distancias entre centroides, y spearmanr, de scipy.stats, las compara: spearmanr(pdist(centroides(X)), pdist(centroides(Z))).statistic. La correlación de Spearman vale 1 si el orden de las distancias es idéntico y cerca de 0 si no hay relación. La dispersión de un dígito es la distancia media de sus puntos a su centroide.

Pregunta. ¿Qué dígito tiene el centroide más cercano al del 0 en 64 dimensiones? ¿Qué mapa tendrá el menor acuerdo de distancias? ¿El 9 será también el dígito más extendido en 64 dimensiones?

Ver resultado

La frase da el dígito cuyo centroide queda más cerca del centroide del 0 en 64 dimensiones. La primera tabla da, para cada proyección, el acuerdo de distancias entre centroides: la fila de PCA con 0.784 dice que el orden de las 45 distancias en el plano de PCA se parece bastante al de 64 dimensiones. La segunda tabla ordena los dígitos por dispersión, del más compacto al más extendido, y da el cociente entre la dispersión del más extendido y la del más compacto. Por ejemplo, la fila de 64 dimensiones empieza con 0 y termina con 1: el 0 es el dígito más compacto y el 1 el más extendido, y 1.57 dice que la dispersión del 1 es 1.57 veces la del 0.

En 64 dimensiones, el centroide más cercano al del 0 es el del 9.

proyecciónacuerdo de distancias
PCA0.784
t-SNE perplexity 5, semilla 00.363
t-SNE perplexity 30, semilla 00.835
t-SNE perplexity 30, semilla 10.821
t-SNE perplexity 100, semilla 00.778
espaciodel más compacto al más extendidocociente más extendido / más compacto
64 dimensiones[0 6 3 9 2 8 7 5 4 1]1.57
t-SNE perplexity 30, semilla 0[0 6 7 4 3 2 8 5 1 9]3.76
Lectura. En 64 dimensiones el 9 sí es el dígito más cercano al 0. El mapa de semilla 0 acertó, pero el de semilla 1 dijo 6: el mapa nunca fue la evidencia. La verificación está en los centroides originales. El acuerdo de distancias va de 0.835 con perplexity=30 a 0.363 con perplexity=5; la disposición de las islas depende del parámetro. Con los tamaños, la segunda conclusión falla. En 64 dimensiones el 1 es el más extendido y el 9 es el cuarto más compacto. En el mapa el 9 es el más extendido y el cociente entre el más extendido y el más compacto pasa de 1.57 a 3.76. t-SNE ajusta la escala de cada vecindario por separado, así que el tamaño de una isla no mide la variación del grupo.

Visualización y clustering

La tercera conclusión propone agrupar sobre las dos coordenadas del mapa. silhouette_score(datos, grupos) mide la separación de los grupos con las distancias de datos: el mismo agrupamiento puede medirse en el mapa o en los 64 píxeles.

Pregunta. Agrupa con K-means y k=10 sobre el mapa y sobre los 64 píxeles. ¿Qué silueta tendrán los grupos del mapa si se mide en el mapa y si se mide en 64 dimensiones?

Python 3 · ejemplo reproducible
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score


def kmeans10(Z):
    return KMeans(n_clusters=10, n_init=10, random_state=0).fit_predict(Z)


grupos_x = kmeans10(X)
grupos_tsne = kmeans10(Z_tsne)

print("silueta de grupos_tsne en el mapa:", round(silhouette_score(Z_tsne, grupos_tsne), 3))
print("silueta de grupos_tsne en 64 dimensiones:", round(silhouette_score(X, grupos_tsne), 3))
print("silueta de grupos_x en 64 dimensiones:", round(silhouette_score(X, grupos_x), 3))
Salida esperada

La salida esperada está oculta.

adjusted_rand_score (ARI) compara dos particiones: vale 1 si coinciden salvo por los nombres de los grupos y cerca de 0 si coinciden como al azar. Aquí se usa para comparar grupos entre sí y con los dígitos reales.

Pregunta. ¿Los grupos de K-means obtenidos sobre los mapas con perplexity 5 y 100 coincidirán? ¿Qué agrupamiento, el del mapa o el de los píxeles, coincidirá más con los dígitos reales?

Ver resultado

Cada fila compara dos particiones de los 500 dígitos y da su ARI. La columna «partición» nombra un agrupamiento, «comparada con» el otro agrupamiento o las etiquetas reales, y «ARI» su coincidencia. Por ejemplo, la fila de grupos_x con 0.57 dice que los grupos de K-means sobre los 64 píxeles coinciden con los dígitos reales con ARI 0.57.

particióncomparada conARI
K-means en t-SNE perplexity 5K-means en t-SNE perplexity 1000.776
grupos_tsnedígitos reales0.734
grupos_xdígitos reales0.57
Lectura. Con otra perplexity, la partición cambia: el ARI entre los grupos de perplexity 5 y 100 es 0.776, no 1. Aquí las etiquetas reales permiten ver que grupos_tsne coincide más con los dígitos (0.734 frente a 0.57). En un problema no supervisado esas etiquetas no existen. Solo quedan la silueta del mapa, que exagera, y una partición que depende de perplexity y de la semilla. Agrupar sobre la proyección requiere una justificación: grupos estables entre varias perplexity y semillas, silueta medida en el espacio original y un plan para los datos nuevos, porque TSNE no tiene transform. Sin eso, el mapa sirve para mirar y formular hipótesis, y los grupos se buscan en los datos.

UMAP

UMAP (Uniform Manifold Approximation and Projection) es otro método de proyección basado en vecindarios. Se instala aparte, con el paquete umap-learn, y se usa como un estimador de scikit-learn: umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=0).fit_transform(X). n_neighbors cumple un papel parecido a perplexity: fija el tamaño del vecindario que se intenta conservar. min_dist controla qué tan juntos pueden quedar los puntos en el mapa. A diferencia de TSNE, UMAP tiene transform para colocar puntos nuevos en un mapa ya ajustado. Sus mapas también dependen de los parámetros y de la semilla, así que las mismas precauciones sobre distancias y tamaños aplican.

Práctica

Traza guiada

Antes de ejecutar, predice qué pasa al llamar mapa_pocos.fit_transform(pocos) con estas 20 imágenes: si termina o lanza un error, qué forma tiene el resultado cuando termina y si mapa_pocos tiene transform.

Python 3 · ejemplo reproducible
pocos = X[:20]
mapa_pocos = TSNE(n_components=2, perplexity=30, random_state=0, n_jobs=1)

Comprueba ejecutando la llamada. Si falla, lee el mensaje y repite con perplexity=5. Relaciona el mensaje con lo que mide perplexity.

Semillas repetidas

Calcula tsne(30, semilla) para las semillas 0 a 4. Antes de procesar, predice cuánto variará vecino_mismo_digito y cuántas semillas darán 9 como dígito más cercano al 0. Registra ambos valores por semilla y explica cuál de los dos describe los datos y cuál describe una corrida.

Mapa de vinos

Con load_wine(return_X_y=True), construye una proyección PCA y un mapa t-SNE con perplexity=30 y random_state=0, primero sin escalar y después con StandardScaler. Adapta tsne para que reciba los datos como argumento. Adapta también vecino_mismo_digito, centroides y mas_cercano_al_0 para que reciban las etiquetas como argumento y usen solo las clases que aparecen en ellas: los vinos tienen 3 clases, no 10. Escribe:

  1. la fracción de vecinos con la misma clase en 13 dimensiones escaladas y en cada uno de los cuatro mapas
  2. qué cambió al escalar y por qué, según los rangos de las columnas
  3. la clase más cercana a la clase 0 en el espacio escalado y en el mapa t-SNE escalado
  4. si agruparías sobre el mapa t-SNE y qué comprobarías antes

Reto opcional

Cambia init="random" por init="pca" y calcula dos mapas con semillas 0 y 1. Compara las coordenadas con np.allclose. Explica el resultado con lo que controla random_state en t-SNE.

Errores que revisar

  • Si concluyes que dos grupos se parecen porque sus islas quedan juntas, repite el mapa con otra semilla y compara los centroides en el espacio original. El vecino del 0 cambió del 9 al 6 solo con la semilla.
  • Si lees el tamaño de una isla como variación del grupo, mide la dispersión en el espacio original. El 9 es el más extendido en el mapa y el cuarto más compacto en 64 dimensiones.
  • Si TSNE lanza ValueError con perplexity, revisa el número de filas: perplexity debe ser menor que la cantidad de muestras.
  • Si buscas transform para colocar datos nuevos en un mapa t-SNE, no existe. Recalcula el mapa con todos los datos o usa un método con transform, como PCA.
  • Si una silueta calculada sobre el mapa sale alta, calcúlala con las mismas etiquetas en el espacio original. En los dígitos bajó de 0.592 a 0.174.

Cierre

Supón que un mapa t-SNE de los mismos dígitos, con perplexity=5, muestra 14 islas y alguien propone reportar que los datos tienen 14 grupos. Escribe qué dos mediciones harías antes de aceptar o rechazar la afirmación y en qué espacio harías cada una.

Lista de comprobación

  • ¿Qué conserva un mapa t-SNE y qué no conserva?
  • ¿Por qué el vecino del 0 puede cambiar entre dos mapas con la misma perplexity?
  • ¿Dónde se verifica una afirmación sobre distancias o tamaños de grupos vistos en un mapa?
  • ¿Qué diferencia práctica hay entre PCA, TSNE y UMAP cuando llega un dato nuevo?

Documentación oficial

Cargando progreso…