UMAP es un método de reducción de dimensionalidad no lineal, diseñado para preservar tanto las estructuras locales (vecindarios) como globales (relaciones grandes) del espacio de alta dimensión. Es especialmente útil para:
Se basa en teoría topológica (espacios de simplicidad y homotopía), pero su implementación final puede explicarse mediante construcción y alineación de grafos ponderados.
Notas:
Espacio de simplicidad: Un espacio de simplicidad (o complejo simplicial) es una estructura que usamos para estudiar formas usando piezas geométricas simples como puntos, líneas, triángulos o tetraedros. Estas piezas se llaman símplices, y al unirlas de forma ordenada podemos construir modelos de objetos más complejos.
Ejemplo: Imagina que quieres representar una esfera. Puedes cubrirla con triángulos conectados, como si fuera una red. Ese conjunto de triángulos es un espacio de simplicidad que aproxima la forma de la esfera.
Homotopía: La homotopía es una forma de decir que dos funciones o espacios se pueden transformar uno en otro de manera continua, como si fueran hechos de goma. Si podemos deformar un espacio en otro sin romperlo ni pegarlo, decimos que son homotópicamente equivalentes.
Ejemplo: Una taza con asa y un donut son homotópicamente equivalentes porque ambos tienen un solo agujero: se puede transformar uno en otro con una deformación continua.

Fuente: Modelo 3-D del Smithsonian institute. Proyección 2-D usando el código de acá
Vamos a visualizar cómo están organizados los números del 1 al 1.000.000… pero no según su tamaño, sino según qué números primos los dividen. En este ejemplo, vamos a representar los primeros 1.000.000 de números enteros como vectores binarios de alta dimensión, donde cada dimensión indica si el número tiene o no un cierto factor primo.
Porque los números esconden patrones muy ricos: algunos son primos, otros son múltiplos de varios primos, otros son potencias… ¡y todo eso está codificado en su ADN matemático!
Pero ese ADN no es visible fácilmente. Si los ponemos en una lista del 1 al millón, no vemos mucho más que una recta.
Imagina que cada número es como una tarjeta con casillas, y cada casilla dice si ese número es divisible por 2, por 3, por 5, por 7… así hasta muchos primos.
Ejemplo:
Entonces cada número tiene un perfil único de divisibilidad.
Cada número se representa con un vector como:
$x_i = [b_1, b_2, b_3, \dots, b_p]$
Donde:
¿Es posible “dibujar” todos esos números de manera que aquellos que se parezcan en cómo están compuestos (por sus divisores primos), queden cerca en el dibujo?
Usamos UMAP, que:
Este ejemplo es muy ilustrativo: no le dijimos a la máquina qué era un número primo, ni un múltiplo, y sin embargo, UMAP fue capaz de "ver" esas propiedades solo por cómo se repetían patrones entre ellos.

La imagen es el resultado de aplicar UMAP a 1 millón de números enteros, donde:
Cada número tiene una lista de divisores primos. Aquí se usa el menor de ellos para asignar un color.
Aunque no se usó ninguna regla explícita sobre teoría de números, UMAP descubrió y organizó una estructura matemática oculta, solo a partir de la forma en que los números están compuestos.
Es una forma de hacer visible lo invisible: una topología de la aritmética.
Colorear por el factor primo más pequeño es como revelar la 'firma genética' de cada número. Lo fascinante es que UMAP, sin saber nada de matemática, fue capaz de agruparlos como si entendiera su lógica interna.
Visualizar los primeros 1000 números naturales de acuerdo con su estructura de factorización prima, proyectados en 2D usando UMAP.
# Requisitos: instalar paquetes si es la primera vez
# !pip install umap-learn sympy matplotlib
import numpy as np
import matplotlib.pyplot as plt
import umap
from sympy import primerange, factorint
# -------------------------------
# PARTE 1: Construcción del dataset
# -------------------------------
# Números a representar: del 2 al 1000
N = 1000
# Lista de todos los primos menores a N
primos = list(primerange(2, N)) # ejemplo: [2, 3, 5, 7, 11, ...] # hay 168 primos menores a 1000
# Mapeo: primo → índice de columna
primo_idx = {p: i for i, p in enumerate(primos)}
# Crear una matriz binaria (filas = números, columnas = primos)
# Si el número tiene un primo como factor → se marca con 1
X = np.zeros((N - 1, len(primos)), dtype=int)
for i, n in enumerate(range(2, N + 1)):
factores = factorint(n) # Ej: factorint(12) → {2: 2, 3: 1}
for f in factores:
if f in primo_idx:
X[i, primo_idx[f]] = 1 # marcamos el primo como presente
# Etiquetas = los propios números (del 2 al 1000)
labels = np.arange(2, N + 1)
# -------------------------------
# PARTE 2: Aplicación de UMAP
# -------------------------------
# Configurar UMAP para datos binarios usando métrica de Hamming
reducer = umap.UMAP(
n_neighbors=20, # cuántos vecinos tener en cuenta # ajustar para ver más o menos agrupamiento
min_dist=0.1, # mínima distancia en el espacio proyectado # ajustar para ver más o menos agrupamiento
metric='hamming', # ideal para vectores binarios # mide la proporción de bits diferentes entre dos vectores
random_state=42 # para reproducibilidad
)
# Obtener la proyección en 2D
X_umap = reducer.fit_transform(X)
# -------------------------------
# PARTE 3: Visualización
# -------------------------------
# Crear scatter plot con colores según el valor del número original
plt.figure(figsize=(10, 8))
scatter = plt.scatter(
X_umap[:, 0], # coordenada X proyectada
X_umap[:, 1], # coordenada Y proyectada
c=labels, # color = número original
cmap='viridis', # paleta de colores
s=20, alpha=0.7 # tamaño y transparencia
)
# Añadir barra de color y etiquetas
plt.colorbar(scatter, label='Número original')
plt.title('UMAP de los primeros 1000 números por sus factores primos')
plt.xlabel('Dim 1')
plt.ylabel('Dim 2')
plt.grid(True)
plt.tight_layout()
plt.show()
"Sabemos que algo se agrupó... pero no siempre sabemos por qué."
random_state puede alterar la forma final del embedding.n_neighbors, min_dist y la métrica afectan mucho el resultado.“UMAP no sabe sumar ni multiplicar.”
UMAP nos deja ver patrones que antes estaban escondidos. Pero hay que recordar que no es un oráculo. Nos sugiere relaciones, no las demuestra.
UMAP no dice simplemente:
"¿Este punto $ x_j $ es vecino de $ x_i $? Sí o no". Eso sería una conectividad dura o binaria.
En cambio, UMAP dice:
"¿Qué tan vecinos son $ x_i $ y $ x_j $?"
Para cada punto $ x_i $ en el espacio original, UMAP construye un grafo local basado en su vecindario.
n_neighbors).Se define una función de conectividad fuzzy entre $ x_i $ y cada $ x_j $ que asinga un grado de pertenencia entre 0 y 1 usando una función suave como:
$p_{ij} = \exp\left( -\frac{d(x_i, x_j) - \rho_i}{\sigma_i} \right)$
perplexity en t-SNE).import matplotlib.pyplot as plt
import numpy as np
# Simular 6 puntos en el plano, donde el punto central es x_i
np.random.seed(42)
x_i = np.array([0, 0])
vecinos = np.random.uniform(-1.5, 1.5, size=(6, 2))
# Calcular distancias y p_ij usando la fórmula fuzzy
rho_i = 0.2 # distancia mínima a un vecino
sigma_i = 0.5 # parámetro de suavizado
distancias = np.linalg.norm(vecinos - x_i, axis=1)
p_ij = np.exp(-(distancias - rho_i) / sigma_i)
# Normalizar colores (para representar fuerza de conexión)
norm = plt.Normalize(p_ij.min(), p_ij.max())
colors = plt.cm.viridis(norm(p_ij))
# Graficar
plt.figure(figsize=(6, 6))
plt.scatter(*vecinos.T, s=300, c=colors, edgecolor='black')
plt.scatter(*x_i, color='red', s=150, label='$x_i$')
for j, (vx, vy) in enumerate(vecinos):
plt.plot([x_i[0], vx], [x_i[1], vy], color='gray', alpha=0.4)
plt.text(vx + 0.1, vy, f'$p_{{ij}}$={p_ij[j]:.2f}', fontsize=9)
plt.title("Visualización de conectividad fuzzi entre $x_i$ y sus vecinos $x_j$")
plt.axis('equal')
plt.grid(True)
plt.legend()
plt.show()
Se simmetriza el grafo con:
$P_{ij} = p_{ij} + p_{ji} - p_{ij} \cdot p_{ji}$
Intuición: esto representa la probabilidad combinada de que $ x_i $ considere a $ x_j $ como vecino y viceversa. Es como decir: “nos consideramos mutuamente vecinos”.
# Simulamos la simmetrización de conectividad fuzzy para el mismo conjunto de puntos
# Para simplificar, seleccionamos una nueva "fuente" x_j para evaluar p_ji
x_j = vecinos[2] # elegimos arbitrariamente un vecino como centro
# Calcular distancias desde x_j hacia todos los otros puntos (incluyendo x_i)
dist_ji = np.linalg.norm(np.vstack([x_i] + [v for i, v in enumerate(vecinos) if i != 2]) - x_j, axis=1)
rho_j = 0.2
sigma_j = 0.5
p_ji = np.exp(-(dist_ji - rho_j) / sigma_j)
# Calcular p_ij desde x_i hacia x_j (ya lo teníamos)
p_ij_val = np.exp(-(np.linalg.norm(x_i - x_j) - rho_i) / sigma_i)
# Calcular conectividad simétrica
p_sym = p_ij_val + p_ji[0] - p_ij_val * p_ji[0]
# Crear un gráfico que muestre esta relación
fig, ax = plt.subplots(figsize=(6, 6))
ax.scatter(*x_i, color='red', s=150, label='$x_i$')
ax.scatter(*x_j, color='blue', s=150, label='$x_j$')
# Dibujar flechas direccionales
ax.annotate("", xy=x_j, xytext=x_i, arrowprops=dict(arrowstyle="->", color="gray"))
ax.annotate("", xy=x_i, xytext=x_j, arrowprops=dict(arrowstyle="->", color="gray"))
# Mostrar valores
midpoint = (x_i + x_j) / 2
ax.text(midpoint[0]+0.22, midpoint[1]+0.2, f"$p_{{ij}}$ = {p_ij_val:.3f}", color='green')
ax.text(midpoint[0]-0.2, midpoint[1]-0.3, f"$p_{{ji}}$ = {p_ji[0]:.3f}", color='purple')
ax.text(midpoint[0]-0.4, midpoint[1]+0.4, f"$p_{{sym}}$ = {p_sym:.3f}", color='black', fontweight='bold')
ax.set_title("Simmetrización de conectividad en UMAP")
ax.axis('equal')
ax.grid(True)
ax.legend()
plt.show()
UMAP combina estas dos medidas en una conectividad simétrica:
$p_{ij}^{\text{sym}} = p_{ij} + p_{ji} - p_{ij} \cdot p_{ji}$
Este valor aparece en negrita negra en la figura.
En la simulación:
$p_{ij} \approx p_{ji} \approx 0.08$
Esto ocurrió porque:
Entonces:
$p_{ij} = \exp\left(-\frac{d(x_i, x_j) - \rho_i}{\sigma_i}\right) = p_{ji}$
... los valores de $ \rho_i $ y $ \sigma_i $ son distintos para cada punto porque:
$ \sigma_i $: se ajusta automáticamente para que el vecindario tenga entropía fija (como perplexity).
Entonces en general:
$p_{ij} \ne p_{ji}$
Y por eso UMAP simmetriza para combinar ambas perspectivas.
# Asignamos diferentes valores de rho y sigma para x_i y x_j
rho_i = 0.1
sigma_i = 0.3
rho_j = 0.4
sigma_j = 0.6
# Recalcular distancias
dist_ij = np.linalg.norm(x_i - x_j)
dist_ji = np.linalg.norm(x_j - x_i) # es igual, pero incluimos por claridad
# Recalcular conectividades con diferentes parámetros
p_ij_diff = np.exp(-(dist_ij - rho_i) / sigma_i)
p_ji_diff = np.exp(-(dist_ji - rho_j) / sigma_j)
# Conectividad simétrica
p_sym_diff = p_ij_diff + p_ji_diff - p_ij_diff * p_ji_diff
# Crear visualización actualizada
fig, ax = plt.subplots(figsize=(6, 6))
ax.scatter(*x_i, color='red', s=150, label='$x_i$')
ax.scatter(*x_j, color='blue', s=150, label='$x_j$')
# Flechas
ax.annotate("", xy=x_j, xytext=x_i, arrowprops=dict(arrowstyle="->", color="gray"))
ax.annotate("", xy=x_i, xytext=x_j, arrowprops=dict(arrowstyle="->", color="gray"))
# Textos de conectividad
midpoint = (x_i + x_j) / 2
ax.text(midpoint[0]+0.22, midpoint[1]+0.2, f"$p_{{ij}}$ = {p_ij_diff:.3f}", color='green')
ax.text(midpoint[0]-0.2, midpoint[1]-0.3, f"$p_{{ji}}$ = {p_ji_diff:.3f}", color='purple')
ax.text(midpoint[0]-0.4, midpoint[1]+0.4, f"$p_{{sym}}$ = {p_sym_diff:.3f}", color='black', fontweight='bold')
ax.set_title("Simmetrización con $\\rho_i \\ne \\rho_j$ y $\\sigma_i \\ne \\sigma_j$")
ax.axis('equal')
ax.grid(True)
ax.legend()
plt.show()
UMAP reconoce que cada punto tiene su propia forma de medir cercanía. El proceso de simmetrización es como juntar ambas opiniones para construir una conexión mutua más completa.
Veamos por qué y cómo:
$ \sigma_i $ es un parámetro de escala local asociado al punto $ x_i $, que determina la tasa a la cual decrece la conectividad fuzzy con la distancia.
En la expresión
$ p_{ij} = \exp\left(-\frac{d(x_i,x_j)-\rho_i}{\sigma_i}\right) $
$ \sigma_i $ controla el ancho efectivo del vecindario de $ x_i $:
UMAP ajusta $ \sigma_i $ de manera adaptativa para cada punto, de modo que la estructura local sea comparable entre regiones con distinta densidad muestral.
$ \sigma_i $ es el parámetro de escala local que regula el ancho efectivo del vecindario de $ x_i $, permitiendo que UMAP adapte la noción de cercanía a la densidad local de los datos.
$p_{ij}^{\text{sym}} = p_{ij} + p_{ji} - p_{ij} \cdot p_{ji}$
En UMAP, cada punto define su propio sistema de percepción local del espacio. Por eso, cuando dos puntos se miran entre sí, ven cosas distintas. El proceso de simmetrización es como hacerlos conversar y acordar en qué medida están realmente conectados.
Queremos encontrar un conjunto de puntos $ y_1, ..., y_n $ en 2D o 3D que conserven las relaciones de vecindad del grafo original. Es decir: mantener las mismas relaciones de vecindad, pero ahora con puntos en el plano.
Se define un nuevo grafo $ Q $ en baja dimensión, con pesos:
$q_{ij} = \left(1 + a \cdot \|\mathbf{y}_i - \mathbf{y}_j\|^{2b}\right)^{-1}$
Donde:
min_dist).Esto permite que:
import numpy as np
import matplotlib.pyplot as plt
# Rango de distancias
d = np.linspace(0, 50, 1500)
# Definimos las funciones de conectividad
def gaussian(d, sigma=1.0):
return np.exp(-d**2 / (2 * sigma**2))
def t_student(d, df=1):
return 1 / (1 + d**2 / df)
def umap_kernel(d, a=1.929, b=0.7915):
return 1 / (1 + a * (d**2) ** b)
# Calcular valores
gauss = gaussian(d, sigma=1.0)
t_sne = t_student(d)
umap_conn = umap_kernel(d)
# Graficar
plt.figure(figsize=(10,6))
plt.plot(d, gauss, label="Gaussiana (PCA, t-SNE alta D)", linestyle='--')
plt.plot(d, t_sne, label="t-Student (t-SNE baja D)", linestyle='-.')
plt.plot(d, umap_conn, label="Kernel UMAP (espacio reducido)", linewidth=2)
plt.title("Comparación de funciones de conectividad")
plt.xlabel("Distancia entre puntos")
plt.ylabel("Similitud (conectividad)")
plt.legend()
plt.yscale('log')
# plt.xscale('log')
plt.ylim(.0001, 1.1)
plt.grid(True)
plt.tight_layout()
plt.show()
Hasta ahora tenemos:
Un grafo proyectado $ Q $ en baja dimensión, definido sobre los puntos $ y_i $.
Lo que queremos es que $ Q $ imite lo mejor posible a $ P $, es decir:
$P_{ij} \approx Q_{ij}$
Se utiliza una función tipo t-Student generalizada:
$q_{ij} = \frac{1}{1 + a \cdot \|y_i - y_j\|^{2b}}$
Donde:
min_dist).Nota: Esta función tiene colas más largas que una Gaussiana → útil para evitar el crowding problem.
Una vez construidas las conectividades fuzzy en alta dimensión $P_{ij}$ y en baja dimensión $q_{ij}$, UMAP busca que ambas coincidan lo mejor posible. Para ello utiliza una función de pérdida basada en entropía cruzada binaria:
$ \mathcal{L} = - \sum_{(i,j)} \left[P_{ij}\log(q_{ij}) + (1-P_{ij})\log(1-q_{ij})\right] $
Esta función favorece que los pares con alta conectividad en el espacio original mantengan una alta conectividad en la proyección, y penaliza que pares débilmente conectados aparezcan artificialmente cerca en baja dimensión. La pérdida tiene dos partes:
Término de atracción
$P_{ij}\log(q_{ij})$
Si $P_{ij}$ es alto, ese par era importante en alta dimensión. Entonces queremos que $q_{ij}$ también sea alto, es decir, que esos puntos queden cerca en el embedding. Si no ocurre, aparece penalización.
Término de repulsión
$(1-P_{ij})\log(1-q_{ij})$
Si $P_{ij}$ es bajo, ese par no estaba realmente conectado en alta dimensión. Entonces queremos que $q_{ij}$ no sea alto, o sea, que no queden demasiado cerca en el embedding. Si dos puntos no conectados aparecen juntos en 2D, la pérdida sube.
Caso 1: $P_{ij}$ alto
Eso significa: "En el espacio original, estos dos puntos sí deberían estar conectados." Entonces en la pérdida domina el término
$P_{ij}\log(q_{ij})$
porque $P_{ij}$ multiplica. Entonces, ¿Qué quiere ese término? Quiere que $q_{ij}$ sea alto. ¿Por qué?
si $q_{ij} \approx 1$, entonces $\log(q_{ij}) \approx 0$
y como hay un signo menos afuera, la penalización es pequeña Pero:
si $q_{ij}$ es pequeño, entonces $\log(q_{ij})$ es muy negativo y con el signo menos, eso se transforma en una penalización grande
Entonces: Si dos puntos eran vecinos reales, UMAP castiga que queden lejos.
Caso 2: $P_{ij}$ bajo
Eso significa: "En el espacio original, estos puntos no estaban realmente conectados." Entonces domina el término
$(1-P_{ij})\log(1-q_{ij})$
porque ahora $1-P_{ij}$ es grande. Entonces, ¿Qué quiere este término? Quiere que $q_{ij}$ sea bajo. ¿Por qué?
si $q_{ij}$ es alto, entonces $1-q_{ij}$ se acerca a 0 y $\log(1-q_{ij})$ se vuelve muy negativo con el signo menos, eso da una penalización grande
Entonces: Si dos puntos no eran vecinos, UMAP castiga que aparezcan artificialmente juntos.
En términos prácticos:
De este modo, el método preserva principalmente la estructura local del grafo de vecinos y, en muchos casos, también produce una organización global más coherente que t-SNE, aunque sin garantizar una preservación global exacta.
Esto hace que:
UMAP ve las conexiones entre puntos como probabilidades de que estén cerca. Luego intenta reubicar los puntos en un plano de forma que esas conexiones sean lo más parecidas posibles. Como un rompecabezas donde queremos que las piezas con vínculos fuertes sigan juntas.
# Reimportar todo tras reinicio del entorno
import numpy as np
import matplotlib.pyplot as plt
# Simular pares de puntos con distintas distancias
dists = np.linspace(0, 5, 300)
# Parámetros de la función de UMAP
a, b = 1.929, 0.7915
q_ij = 1 / (1 + a * (dists**2)**b)
# Casos de probabilidad alta, media, baja
P_high = 0.9
P_mid = 0.5
P_low = 0.1
# Función de pérdida binaria cruzada entre P y q
def cross_entropy(P, q):
return -(P * np.log(q + 1e-8) + (1 - P) * np.log(1 - q + 1e-8))
loss_high = cross_entropy(P_high, q_ij)
loss_mid = cross_entropy(P_mid, q_ij)
loss_low = cross_entropy(P_low, q_ij)
# Graficar
plt.figure(figsize=(10, 6))
plt.plot(dists, loss_high, label="$P_{ij}=0.9$ (puntos cercanos)", color='green')
plt.plot(dists, loss_mid, label="$P_{ij}=0.5$ (medio)", color='orange')
plt.plot(dists, loss_low, label="$P_{ij}=0.1$ (puntos lejanos)", color='red')
plt.title("Función de pérdida UMAP según la distancia entre puntos")
plt.xlabel("Distancia entre $y_i$ y $y_j$")
plt.ylabel("Pérdida (Cross-Entropy)")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
Cada curva representa la pérdida para un par de puntos $ (i, j) $ según su conectividad en el grafo original $ P_{ij} $:
La función de pérdida en UMAP actúa como un resorte: junta los puntos que eran vecinos en alta dimensión, y aleja aquellos que no lo eran. Es como reconstruir una red, pero en un plano.
| Característica | PCA | t-SNE | UMAP |
|---|---|---|---|
| Linealidad | Lineal | No lineal | No lineal |
| Preserva estructura | Global | Local | Local y global |
| Métrica flexible | ❌ Euclídea | ✅ | ✅ (puede usar cualquier métrica) |
| Escalabilidad | Alta | Baja en n > 10K | Alta |
| Proyecta nuevos datos | ✅ | ❌ | ✅ |
| Sensible a parámetros | Poco | Mucho | Medio |
| Parámetro | Significado |
|---|---|
n_neighbors |
Tamaño del vecindario local (define cuánto se enfoca en la estructura local) |
min_dist |
Distancia mínima entre puntos en la proyección (compactación) |
metric |
Métrica de distancia en el espacio original ('euclidean', 'cosine', etc.) |
n_components |
Dimensión de salida (típicamente 2 o 3 para visualización) |
Se define una conectividad fuzzy $ p_{ij} \in [0,1] $ con:
$p_{ij} = \exp\left( -\frac{d(x_i,x_j) - \rho_i}{\sigma_i} \right)$
Se define una nueva similitud:
$q_{ij} = \frac{1}{1 + a \|y_i - y_j\|^{2b}}$
min_dist$\mathcal{L} = \sum_{(i,j)} \left[P_{ij} \log(q_{ij}) + (1 - P_{ij}) \log(1 - q_{ij})\right]$
UMAP aprende un mapa donde los puntos que eran "amigos" en alta dimensión siguen siendo "amigos" en 2D. Pero también logra que los que no se conocían no se junten sin razón.
# Instalar UMAP si no lo tienes
# !pip install umap-learn
import umap # Algoritmo UMAP
import matplotlib.pyplot as plt # Visualización
from sklearn.datasets import load_digits # Dataset de dígitos
from sklearn.preprocessing import StandardScaler # Escalado
import pandas as pd
import seaborn as sns
# -------------------------------
# Cargar datos
# -------------------------------
digits = load_digits()
X = digits.data # Matriz (1797 muestras, 64 features: imágenes 8x8)
y = digits.target # Etiquetas: número representado (0-9)
# Escalar los datos (muy importante para evitar sesgo por magnitud de pixeles)
X_scaled = StandardScaler().fit_transform(X)
# -------------------------------
# Aplicar UMAP
# -------------------------------
umap_model = umap.UMAP(
n_neighbors=15, # tamaño de vecindario local (balance local/global)
min_dist=0.2, # cuán pegados o separados pueden estar los puntos
metric='euclidean', # métrica sobre pixeles (distancia L2)
random_state=42 # reproducibilidad
)
X_umap = umap_model.fit_transform(X_scaled) # proyección a 2D
# -------------------------------
# Preparar DataFrame para graficar
# -------------------------------
df_umap = pd.DataFrame(X_umap, columns=["UMAP1", "UMAP2"])
df_umap["label"] = y
# -------------------------------
# Visualizar el embedding
# -------------------------------
plt.figure(figsize=(10, 7))
sns.scatterplot(
data=df_umap,
x="UMAP1", y="UMAP2",
hue="label", # cada dígito con distinto color
palette="tab10", # paleta de colores categóricos
s=40, alpha=0.8 # tamaño y transparencia de puntos
)
plt.title("UMAP aplicado a Digits (64D → 2D)")
plt.xlabel("UMAP1")
plt.ylabel("UMAP2")
plt.legend(title="Etiqueta")
plt.grid(True)
plt.tight_layout()
plt.show()
n_neighbors, min_dist, y la métrica.UMAP puede "ver" dígitos manuscritos sin saber lo que son. Agrupa imágenes por su estructura interna, sin poder explicarlo con palabras.
El objetivo es comparar tres métodos de reducción de dimensionalidad:
| Método | ¿Qué hace? | ¿Ventaja? | ¿Desventaja? |
|---|---|---|---|
| PCA | Proyección lineal que maximiza la varianza. | Muy rápido e interpretable. | Captura solo relaciones lineales. |
| t-SNE | Preserva las distancias locales usando distribuciones de probabilidad. | Excelente para visualización local. | No preserva estructura global. |
| UMAP | Preserva estructura topológica con teoría de grafos. | Buena combinación de local y global. | Menos interpretable. |
# Instala si no los tienes
# !pip install umap-learn scikit-learn matplotlib seaborn
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd
from sklearn.datasets import load_digits
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
import umap
# -----------------------
# 1. Cargar datos
# -----------------------
digits = load_digits()
X = digits.data
y = digits.target
# -----------------------
# 2. Escalar
# -----------------------
X_scaled = StandardScaler().fit_transform(X)
# -----------------------
# 3. Reducción de dimensionalidad
# -----------------------
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_scaled)
tsne = TSNE(n_components=2, perplexity=30, random_state=42, n_iter=500)
X_tsne = tsne.fit_transform(X_scaled)
reducer = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=42)
X_umap = reducer.fit_transform(X_scaled)
# -----------------------
# 4. Crear DataFrames
# -----------------------
df_pca = pd.DataFrame(X_pca, columns=["Dim1", "Dim2"])
df_pca["label"] = y
df_pca["method"] = "PCA"
df_tsne = pd.DataFrame(X_tsne, columns=["Dim1", "Dim2"])
df_tsne["label"] = y
df_tsne["method"] = "t-SNE"
df_umap = pd.DataFrame(X_umap, columns=["Dim1", "Dim2"])
df_umap["label"] = y
df_umap["method"] = "UMAP"
df_all = pd.concat([df_pca, df_tsne, df_umap], axis=0)
# -----------------------
# 5. Visualización con leyenda
# -----------------------
plt.figure(figsize=(18, 5))
for i, method in enumerate(["PCA", "t-SNE", "UMAP"]):
plt.subplot(1, 3, i + 1)
scatter = sns.scatterplot(
data=df_all[df_all["method"] == method],
x="Dim1", y="Dim2",
hue="label", palette="tab10", s=30, alpha=0.7
)
plt.title(f"{method}")
plt.xlabel("Dim 1")
plt.ylabel("Dim 2")
plt.grid(True)
if i == 2: # Solo en el último gráfico
plt.legend(title="Dígito", bbox_to_anchor=(1.05, 1), loc='upper left')
else:
plt.legend([],[], frameon=False) # Oculta leyenda en los dos primeros
plt.suptitle("Comparación de PCA vs t-SNE vs UMAP - Digits Dataset", fontsize=16)
plt.tight_layout()
plt.show()
Esta figura muestra cómo cada técnica transforma el mismo conjunto de datos —imágenes de dígitos escritos a mano (0 al 9), originalmente en un espacio de 64 dimensiones (8x8 píxeles)— a un espacio de solo 2 dimensiones.
Desventaja: no captura relaciones no lineales ni estructuras complejas.
Analogía: como mirar una escena tridimensional con una linterna desde un solo ángulo. Se pierde información.
Desventaja: La forma general y la distancia entre clusters no tiene significado global (ej. la distancia entre “0” y “1” no implica cercanía semántica).
Analogía: como si cada dígito fuera una isla. Vemos claramente las comunidades, pero no sabemos qué tan lejos están unas de otras realmente.
Desventaja: La interpretación matemática es más compleja.
Analogía: como aplanar un mapa de conexiones (como el metro): sabes qué puntos están conectados directamente, pero también puedes ver el layout global.
# Requisitos:
# pip install umap-learn matplotlib seaborn scikit-learn pandas
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import load_digits
from sklearn.preprocessing import StandardScaler
import umap
# -----------------------
# 1. Cargar el dataset Digits
# -----------------------
digits = load_digits()
X = digits.data # Imágenes de dígitos (64D)
y = digits.target # Etiquetas (números 0-9)
# -----------------------
# 2. Estandarizar los datos
# -----------------------
X_scaled = StandardScaler().fit_transform(X)
# -----------------------
# 3. Aplicar UMAP
# -----------------------
reducer = umap.UMAP(n_neighbors=15, min_dist=0.1, random_state=42)
X_umap = reducer.fit_transform(X_scaled) # Ahora en 2D
# -----------------------
# 4. Crear un DataFrame para facilitar visualización
# -----------------------
df_umap = pd.DataFrame(X_umap, columns=["UMAP1", "UMAP2"])
df_umap["label"] = y
# -----------------------
# 5. Calcular los centroides de cada dígito
# -----------------------
centroids = df_umap.groupby("label")[["UMAP1", "UMAP2"]].mean()
# -----------------------
# 6. Visualizar: puntos + líneas entre centroides + etiquetas grandes
# -----------------------
plt.figure(figsize=(8, 6))
# Puntos coloreados por dígito
sns.scatterplot(data=df_umap, x="UMAP1", y="UMAP2", hue="label", palette="tab10", s=30, alpha=0.7)
# Dibujar líneas entre cada par de centroides (estructura global)
for i in centroids.index:
for j in centroids.index:
if i < j:
x_vals = [centroids.loc[i, "UMAP1"], centroids.loc[j, "UMAP1"]]
y_vals = [centroids.loc[i, "UMAP2"], centroids.loc[j, "UMAP2"]]
plt.plot(x_vals, y_vals, color='gray', linestyle='--', alpha=0.3)
# Etiquetas grandes en el centro de cada grupo
for idx, row in centroids.iterrows():
plt.text(row["UMAP1"], row["UMAP2"], str(idx), fontsize=14, fontweight='bold',
ha='center', va='center', color='black',
bbox=dict(facecolor='white', alpha=0.6, edgecolor='none'))
# -----------------------
# 7. Título y estética
# -----------------------
plt.title("Estructura global en UMAP: líneas entre centroides de dígitos", fontsize=14)
plt.xlabel("UMAP1")
plt.ylabel("UMAP2")
plt.grid(True)
plt.legend(title="Dígito")
plt.tight_layout()
plt.show()
UMAP ha logrado separar muy bien los grupos de cada dígito:
Las líneas grises conectan los centroides (puntos medios) de cada clase y nos ayudan a ver qué clases están más cercanas en la proyección 2D. Esto sugiere similitudes estructurales entre dígitos:
Esta proyección refleja cómo un humano podría confundir visualmente los dígitos si los viera rápidamente o mal escritos. UMAP ha capturado esa "geometría semántica" en el espacio proyectado:
| Dígitos relacionados visualmente | Posible explicación |
|---|---|
| 3 – 5 – 9 | Formas curvas similares |
| 1 – 7 | Trazos verticales/diagonales |
| 4 – 7 | Trazos rectos y ángulos |
| 6 – 8 – 0 | Presencia de bucles/círculos |
PCA explica bien la varianza general, pero no descubre patrones no lineales.
t-SNE es ideal para ver vecindarios locales y agrupar clases visualmente.
UMAP es una técnica moderna que balancea lo mejor de ambos mundos: buena visualización local y estructura global.
Esta última visualización es una demostración poderosa del poder de UMAP:
UMAP no sabe nada de matemáticas ni del concepto de “número”, pero descubre la estructura implícita que existe en los datos puramente a partir de su geometría interna.
Es un algoritmo de optimización que busca minimizar una función de pérdida $ \mathcal{L}(\theta) $, donde $ \theta $ son los parámetros del modelo (por ejemplo, las posiciones $ y_i $ en UMAP).
Imagina que estás bajando una colina a ciegas:
tomas un paso en la dirección donde la pendiente es más negativa, para bajar la pérdida.
$\theta^{(t+1)} = \theta^{(t)} - \eta \cdot \nabla_\theta \mathcal{L}(\theta^{(t)})$
Donde:
En problemas grandes, calcular el gradiente total sobre todos los datos puede ser costoso.
$\theta^{(t+1)} = \theta^{(t)} - \eta \cdot \nabla_\theta \mathcal{L}_i(\theta^{(t)})$
| Método | Cálculo del gradiente | Ruido | Velocidad | Convergencia |
|---|---|---|---|---|
| Gradient Descent | sobre todo el dataset | baja | lento | más estable |
| Stochastic Gradient Descent | punto a punto | alta | muy rápido | más ruidoso |
| Mini-batch GD | sobre grupos pequeños | media | balanceado | común en práctica |
UMAP minimiza esta pérdida:
$\mathcal{L} = \sum_{(i,j)} \left[P_{ij} \log(q_{ij}) + (1 - P_{ij}) \log(1 - q_{ij})\right]$
Pero no lo hace de una vez, sino elige pares $ (i,j) $ al azar y ajusta solo los puntos involucrados. Así:
Imaginen que quieren encontrar el punto más bajo de un valle, pero solo pueden mirar la pendiente de una roca cercana. Con cada paso, eligen una roca al azar, sienten su pendiente y bajan un poco. Al repetir esto muchas veces, terminan cerca del mínimo.
Estás parado en algún punto de una montaña. Pero:
Llegar al punto más bajo posible (mínimo de la función de pérdida).
El gradiente es como una brújula:
Ese es el learning rate $ \eta $:
Imaginen que estamos en una montaña, con los ojos cerrados. Lo único que sentimos es hacia dónde baja más el suelo. Cada paso que damos es hacia ese lado, pero sin pasarnos. Así, poco a poco, vamos bajando hasta llegar al fondo del valle. Eso mismo hace una red: ajusta sus parámetros para bajar el error, paso a paso, guiada por la pendiente (el gradiente).
Estás parado en algún punto de una montaña. Pero:
Llegar al punto más bajo posible (mínimo de la función de pérdida).
El gradiente es como una brújula:
Ese es el learning rate $ \eta $:
Imaginen que estamos en una montaña, con los ojos cerrados. Lo único que sentimos es hacia dónde baja más el suelo. Cada paso que damos es hacia ese lado, pero sin pasarnos. Así, poco a poco, vamos bajando hasta llegar al fondo del valle. Eso mismo hace una red: ajusta sus parámetros para bajar el error, paso a paso, guiada por la pendiente (el gradiente).