UMAP (Uniform Manifold Approximation and Projection)

Ejemplo

https://arxiv.org/abs/1802.03426

1. ¿Qué es UMAP?

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:

  • Visualización de datos complejos.
  • Preprocesamiento antes de clustering.
  • Aceleración de modelos supervisados (reduciendo input space).

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.


Ejemplo

Fuente: Modelo 3-D del Smithsonian institute. Proyección 2-D usando el código de acá


Visualizando el primer millón de enteros

¿Qué es lo que queremos hacer?

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.


¿Por qué esto es interesante?

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.


¿Cómo representamos un número?

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:

  • El número 30 sería: ✔️ divisible por 2, ✔️ por 3, ✔️ por 5, ❌ por 7, ❌ por 11, etc.
  • El número 29 sería: ❌ por 2, ❌ por 3, ❌ por 5, ❌ por 7… ¡pero sí por 29!

Entonces cada número tiene un perfil único de divisibilidad.

¿Cómo se codifican los números?

Cada número se representa con un vector como:

$x_i = [b_1, b_2, b_3, \dots, b_p]$

Donde:

  • $ b_j = 1 $ si el número $ i $ es divisible por el $ j $-ésimo primo.
  • $ p $ es la cantidad total de primos considerados (por ejemplo, los primeros 168 primos menores a 1000).
  • Es decir, cada dimensión representa un factor primo → espacio de alta dimensión.

El problema

¿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?


¿Qué herramienta usamos?

Usamos UMAP, que:

  • Mira cómo están relacionados los números (en este caso, cuántos factores primos tienen en común).
  • Intenta dibujar eso en una hoja en 2D.
  • Hace que números similares queden cerca, y números distintos queden lejos.

¿Qué vamos a ver en el video?

  • Cúmulos de números que tienen estructuras similares.
  • Por ejemplo: los primos quedan solos; los pares se agrupan; los múltiplos de 6, de 10, de 15 también aparecen como grupos.
  • Sin decirle nada sobre matemáticas, UMAP descubre la estructura interna de los números… solo mirando su forma binaria.

UMAP

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.

Ejemplo

¿Qué muestra esta imagen?

La imagen es el resultado de aplicar UMAP a 1 millón de números enteros, donde:

  • Cada punto representa un número entero.
  • La posición en el espacio fue calculada por UMAP usando los vectores binarios de factorización prima.
  • El color de cada punto está determinado por su factor primo más pequeño.

¿Qué significa eso de "coloreado por prime factor"?

Cada número tiene una lista de divisores primos. Aquí se usa el menor de ellos para asignar un color.

Ejemplos:

  • El número 15 = 3 × 5 → color de 3.
  • El número 50 = 2 × 5 × 5 → color de 2.
  • El número 37 → primo → su color es único.
  • El número 143 = 11 × 13 → color de 11.

¿Por qué es interesante?

  • Los puntos con el mismo factor primo más pequeño tienden a organizarse en formas geométricas repetitivas (como flores, espirales o ramas).
  • Se pueden ver:
    • Anillos con primos puros (solo 1 factor).
    • Ramas que conectan combinaciones de primos.
    • Núcleos donde muchos factores se cruzan.

¿Qué revela esta visualización?

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.


Ejercicio

Objetivo:

Visualizar los primeros 1000 números naturales de acuerdo con su estructura de factorización prima, proyectados en 2D usando UMAP.

¿Por qué es interesante?

  • Cada número entero mayor que 1 puede representarse como un conjunto de factores primos.
  • Eso es como una "huella digital aritmética" de cada número.
  • Queremos ver si números similares estructuralmente (como múltiplos de 6, cuadrados perfectos, primos, etc.) se agrupan.
In [1]:
# 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()
2026-03-30 12:19:06.764093: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/sklearn/utils/deprecation.py:151: FutureWarning: 'force_all_finite' was renamed to 'ensure_all_finite' in 1.6 and will be removed in 1.8.
  warnings.warn(
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/umap/umap_.py:1887: UserWarning: gradient function is not yet implemented for hamming distance metric; inverse_transform will be unavailable
  warn(
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/umap/umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
  warn(
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.

¿Qué muestra el resultado?

Interpretación del gráfico:

  • Clusters de puntos son grupos de números con estructuras similares de factores primos.
  • Por ejemplo:
    • Los múltiplos de 2, 3 o 5 forman regiones distintas.
    • Números con pocos factores primos (como primos o primos al cuadrado) tienden a agruparse.
  • La coloración muestra la progresión de los números: valores cercanos no siempre están juntos → el agrupamiento no depende del valor numérico, sino de su estructura multiplicativa.

Limitaciones de UMAP (y este ejercicio)


1. Es menos interpretable que PCA

"Sabemos que algo se agrupó... pero no siempre sabemos por qué."

  • UMAP no entrega reglas claras ni pesos explícitos.
  • No sabemos que qué relación explícita hay entre la proyección y los factores primos.
  • No se puede invertir la proyección → no hay función $ y \mapsto x $.
  • No sabemos con certeza qué dimensiones influyen más en cada grupo.

> UMAP se percibe como una caja negra porque su embedding no entrega ejes con interpretación directa en términos de las variables originales, depende de hiperparámetros relevantes, y surge de una optimización no lineal orientada a preservar vecindades locales más que relaciones geométricas globales simples.

2. Dependencia del azar (random_state)

  • Cambiar random_state puede alterar la forma final del embedding.
  • Aunque la estructura global suele ser robusta, la forma exacta no es única.

3. Sensibilidad a los hiperparámetros

  • n_neighbors, min_dist y la métrica afectan mucho el resultado.
  • No existe una receta universal para elegirlos → hay que probar y validar visualmente.

4. No capta relaciones numéricas directas

“UMAP no sabe sumar ni multiplicar.”

  • Si dos números comparten estructura multiplicativa, los puede juntar.
  • Pero no tiene noción del orden $ 3 < 4 < 5 $, ni del valor como magnitud.
  • Por eso algunos números "cercanos" en valor pueden aparecer separados.

5. No preserva (tanto) distancias globales

  • Aunque UMAP mejora t-SNE en estructura global, sigue siendo una técnica local.
  • La distancia entre clusters no siempre tiene significado geométrico real.

6. No detecta causalidad ni reglas exactas

  • UMAP descubre patrones o similitudes estadísticas, no leyes exactas.
  • En este caso, no puede decir: “todos estos tienen el mismo número de factores”.

Conclusión

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.


2. Intuición de UMAP paso a paso

PASO 1: Representar la geometría local como un grafo

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.

  1. Se calcula la distancia a sus $ k $ vecinos más cercanos (definido por n_neighbors).
  2. 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)$

    • $ \rho_i $: “offset” → distancia mínima a un vecino ($\rho_i$ corrige localmente las distancias para que los vecinos extremadamente cercanos a $x_i$ tengan alta conectividad, respetando la estructura local y evitando penalizar distancias dentro de la escala mínima relevante de ese punto. En otras palabras, UMAP reconoce que en datos complejos tienden a haber diferentes densidades).
    • $ \sigma_i $: controla cuán amplio es el vecindario de $ x_i $; se ajusta automáticamente para que la entropía del vecindario sea constante (similar al perplexity en t-SNE).

In [1]:
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()

¿Qué muestra esta gráfica?

  • El punto rojo es $ x_i $: el centro desde donde medimos vecindad.
  • Cada punto $ x_j $ a su alrededor es evaluado uno por uno.
  • Aparece su valor de conectividad $ p_{ij} $, que depende de la distancia.
  • El color del punto varía: más brillante = más fuerte conexión.

¿Qué enseña esta visualización?

  • Los valores $ p_{ij} $ no son 0 o 1, sino grados intermedios (por eso es “fuzzy”).
  • Incluso puntos lejanos tienen una conexión, pero más débil.
  • Esta es la base del grafo que UMAP usa para reducir la dimensión del espacio.

PASO 2: Unir todos los vecindarios en un grafo global

  • Se construye un grafo fuzzy ponderado en todo el dataset: los pesos de las aristas representan similitud entre puntos.
  • 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”.

In [4]:
# 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()

¿Qué muestra esta figura?

  • El punto rojo es $ x_i $, y el azul es $ x_j $.
  • Las flechas indican que estamos calculando dos conectividades:
    • $ p_{ij} $: qué tan fuerte $ x_i $ considera a $ x_j $ como vecino (verde).
    • $ p_{ji} $: qué tan fuerte $ x_j $ considera a $ x_i $ como vecino (morado).

¿Qué hace UMAP?

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.

Pero.... ¿Qué pasó en el ejemplo?

En la simulación:

$p_{ij} \approx p_{ji} \approx 0.08$

Esto ocurrió porque:

  • La distancia entre $ x_i $ y $ x_j $ es la misma en ambos sentidos (trivial).
  • Los parámetros $ \rho $ y $ \sigma $ usados para calcular $ p_{ij} $ y $ p_{ji} $ eran iguales:
    • $ \rho_i = \rho_j = 0.2 $
    • $ \sigma_i = \sigma_j = 0.5 $

Entonces:

$p_{ij} = \exp\left(-\frac{d(x_i, x_j) - \rho_i}{\sigma_i}\right) = p_{ji}$


Pero en UMAP real...

... los valores de $ \rho_i $ y $ \sigma_i $ son distintos para cada punto porque:

  • $ \rho_i $: es la distancia al vecino más cercano de $ x_i $ (puede ser diferente del de $ x_j $).
  • $ \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.


In [7]:
# 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()

¿Qué vemos?

  • Se usaron valores distintos para los parámetros:
    • $ \rho_i = 0.1, \sigma_i = 0.3 $
    • $ \rho_j = 0.4, \sigma_j = 0.6 $
  • Como resultado:
    • $ p_{ij} \ne p_{ji} $
    • Ambos son válidos pero representan la vecindad desde diferentes puntos de vista.
  • La conectividad simétrica $ p_{sym} $ los combina y da un valor más justo, pero no simplemente el promedio.

Intuición:

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.


Esta diferencia ocurre porque $ \rho_i $ y $ \sigma_i $ se ajustan de manera independiente para cada punto en UMAP.

Veamos por qué y cómo:


¿Qué es $ \rho_i $?

  • $ \rho_i $ es la distancia al vecino más cercano de $ x_i $ que no sea el mismo.
  • Se usa como un offset mínimo: para que ningún punto tenga una similitud máxima por defecto.
  • Intuición: garantiza que el primer vecino relevante tenga un valor base positivo de conexión.

¿Qué es $ \sigma_i $ en UMAP?

$ \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 $:

  • valores grandes de $ \sigma_i $ producen un decaimiento más suave,
  • valores pequeños de $ \sigma_i $ producen un decaimiento más rápido.

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.


¿Qué significa esto?

  • Cada punto $ x_i $ tiene su propia idea de “vecino”: depende de su contexto local.
  • En regiones densas → $ \sigma_i $ es pequeño → vecindario apretado.
  • En regiones dispersas → $ \sigma_i $ es grande → vecindario amplio.

Resultado: $ p_{ij} \ne p_{ji} $

  • Aunque la distancia $ d(x_i, x_j) $ es simétrica…
  • La evaluación de esa distancia se hace bajo dos sistemas de referencia distintos:
    • Uno con $ \rho_i, \sigma_i $
    • Otro con $ \rho_j, \sigma_j $

Por eso UMAP necesita simmetrizar:

$p_{ij}^{\text{sym}} = p_{ij} + p_{ji} - p_{ij} \cdot p_{ji}$


Entonces

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.


PASO 3: Buscar una proyección que mantenga el grafo

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:

  • $ y_i, y_j $: son las coordenadas proyectadas.
  • $ a $ y $ b $: son parámetros que controlan la forma de la función de decaimiento.
    • Se eligen automáticamente (basado en min_dist).
  • Esta función se parece a una t-Student, pero más generalizada.

Intuición:
  • Para puntos cercanos, $ \| y_i - y_j \| $ es pequeño → $ q_{ij} \approx 1 $
  • Para puntos lejanos, $ q_{ij} \to 0 $, pero más lentamente que con una Gaussiana.

Esto permite que:

  • Puntos cercanos sigan teniendo conexiones fuertes.
  • Puntos lejanos sigan estando representados sin colapsar el espacio.

In [2]:
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()
Visualización delas funciones de conectividad utilizadas por PCA, t-SNE y UMAP en función de la distancia entre puntos:

¿Qué vemos en el gráfico?

  • Gaussiana: decae muy rápidamente → puntos lejanos casi no tienen influencia.
  • t-Student (t-SNE): decae más lento → puntos lejanos siguen teniendo un poco de peso → evita "crowding".
  • UMAP: su kernel personalizado decae de forma más gradual aún y puede adaptarse a distintos tipos de estructura.

¿Qué enseña esta figura?

  • En espacios de alta dimensión, la Gaussiana funciona bien para medir vecindad (por eso se usa en PCA y t-SNE original).
  • Pero para mapear en 2D o 3D, necesitamos que la distancia entre puntos refleje mejor las conexiones lejanas.
  • Por eso t-SNE y UMAP usan funciones de colas más largas.

> En el mundo real, no todos los puntos están apretados como en una foto grupal. Necesitamos funciones que permitan que algunos puntos estén lejos, pero aún cuenten. Eso es lo que hace la función de UMAP.

PASO 4: Minimizar la diferencia entre los grafos (Alta vs Baja dimensión)

Objetivo: Preservar la estructura local

Hasta ahora tenemos:

  • Un grafo difuso $ P $ en alta dimensión construido con funciones de conectividad fuzzy entre puntos $ x_i $.
  • 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}$

¿Cómo se define la conectividad en baja dimensión?

Se utiliza una función tipo t-Student generalizada:

$q_{ij} = \frac{1}{1 + a \cdot \|y_i - y_j\|^{2b}}$

Donde:

  • $ y_i, y_j $: coordenadas proyectadas de los datos.
  • $ a $, $ b $: parámetros aprendidos que controlan la forma de la caída de la función (depende de min_dist).

Nota: Esta función tiene colas más largas que una Gaussiana → útil para evitar el crowding problem.


Función de pérdida: Entropía cruzada binaria

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:

  1. 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.

  2. 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.

Como leemos la ecuación:

  • 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} \approx 0$, entonces $1-q_{ij}\approx 1$ y $\log(1)=0$, así que la penalización es pequeña Pero:
    • 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:

  • si $P_{ij}$ es alto, UMAP empuja a que $q_{ij}$ también sea alto;
  • si $P_{ij}$ es bajo, UMAP penaliza que $q_{ij}$ sea alto.

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.

Interpretación práctica

  • Se favorece que los pares conectados en alta D sigan conectados en baja D.
  • Se penaliza que pares no conectados en alta D aparezcan juntos en 2D.

Esto hace que:

  • Las relaciones locales se mantengan.
  • Las relaciones globales se preserven mejor que en t-SNE (aunque no perfectamente).

Optimización

  • UMAP minimiza $ \mathcal{L} $ usando Stochastic Gradient Descent (SGD).
  • Solo se usan los pares $ (i,j) $ con $ P_{ij} > 0 $ (sparse optimization).
  • Se incluye repulsión para puntos con baja probabilidad $ P_{ij} $, pero no explícita como en t-SNE.

Intuición

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.

In [10]:
# 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()

Interpretación del gráfico

Cada curva representa la pérdida para un par de puntos $ (i, j) $ según su conectividad en el grafo original $ P_{ij} $:

Línea verde: $ P_{ij} = 0.9 $ (puntos cercanos en alta dimensión)

  • Baja pérdida cuando los puntos están cerca en 2D.
  • Aumenta si los separas: UMAP quiere mantenerlos juntos.

Línea naranja: $ P_{ij} = 0.5 $ (conectividad media)

  • Tiene un mínimo suave.
  • Penaliza tanto si los juntas demasiado como si los alejas.

Línea roja: $ P_{ij} = 0.1 $ (puntos lejanos en alta dimensión)

  • Alta pérdida si los proyectas juntos.
  • Baja pérdida si los dejas alejados → UMAP los quiere separados.

Intuición

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.


3. Comparación con t-SNE y PCA

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

4. Parámetros importantes de UMAP

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)

5. Intuición geométrica

  • PCA: proyecta sobre una base rotada → solo ve varianza global.
  • t-SNE: mantiene vecindarios locales, pero ignora lo global y es lento.
  • UMAP: modela vecindarios como grafos y los mantiene en el nuevo espacio, capturando estructuras locales y globales.

Resumen

Mapa del Algoritmo UMAP

Paso 1: Construcción del grafo en alta dimensión

  • Cada punto $ x_i \in \mathbb{R}^p $ se conecta a sus $ k $ vecinos más cercanos.
  • 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)$

    • $ \rho_i $: offset (distancia mínima no nula)
    • $ \sigma_i $: ajustado para mantener entropía constante (como perplexity en t-SNE)

Paso 2: Grafo objetivo en espacio proyectado

  • Se proyectan los puntos en $ y_i \in \mathbb{R}^2 $ o $ \mathbb{R}^3 $
  • Se define una nueva similitud:

    $q_{ij} = \frac{1}{1 + a \|y_i - y_j\|^{2b}}$

    • con $ a, b $ parámetros aprendidos a partir de min_dist

Paso 3: Definir pérdida entre grafos

  • Se minimiza la diferencia entre los grafos fuzzy $ P $ y $ Q $ mediante:

$\mathcal{L} = \sum_{(i,j)} \left[P_{ij} \log(q_{ij}) + (1 - P_{ij}) \log(1 - q_{ij})\right]$

  • Interpretación:
    • Si $ P_{ij} \approx 1 $: forzar $ q_{ij} $ alto → mantener puntos juntos
    • Si $ P_{ij} \approx 0 $: forzar $ q_{ij} $ bajo → separar puntos

Paso 4: Optimización

  • Se minimiza $ \mathcal{L} $ usando Stochastic Gradient Descent (SGD)
  • Solo se actualizan los pares con $ P_{ij} > 0 $
  • El resultado es un embedding en 2D o 3D que preserva la estructura local y parcialmente la global

Conclusión

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.

Ejemplos

Objetivo del ejercicio

Visualizar cómo UMAP puede descubrir y separar estructuras latentes en imágenes de dígitos manuscritos (dataset Digits de sklearn) al reducir de 64 dimensiones (8×8 pixeles) a 2D.

In [12]:
# 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()
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/sklearn/utils/deprecation.py:151: FutureWarning: 'force_all_finite' was renamed to 'ensure_all_finite' in 1.6 and will be removed in 1.8.
  warnings.warn(
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/umap/umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
  warn(

Interpretación del resultado

¿Qué observamos?

  • Cada punto representa una imagen 8×8 de un dígito manuscrito.
  • Grupos bien definidos emergen naturalmente: los dígitos se separan.
  • Algunos grupos están más dispersos (como el 8 o el 1), y otros muy compactos (como el 0 o el 6).

¿Por qué UMAP logra separarlos?

  • Cada imagen tiene una estructura implícita en los pixeles.
  • UMAP aprende qué imágenes tienen patrones similares (trazos, formas) y los proyecta cerca.
  • No se le dijo qué es un “4” o un “7” → simplemente descubre que comparten vecindarios en el espacio de pixeles.

Limitaciones

  • UMAP no nos dice qué píxeles son importantes → es una representación no interpretable.
  • Algunos puntos pueden estar mal ubicados (ruido, escritura ambigua).
  • El resultado depende de n_neighbors, min_dist, y la métrica.

Conclusión

UMAP puede "ver" dígitos manuscritos sin saber lo que son. Agrupa imágenes por su estructura interna, sin poder explicarlo con palabras.

Ejercicio

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.
In [13]:
# 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()
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/sklearn/manifold/_t_sne.py:1164: FutureWarning: 'n_iter' was renamed to 'max_iter' in version 1.5 and will be removed in 1.7.
  warnings.warn(
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/threadpoolctl.py:1226: RuntimeWarning: 
Found Intel OpenMP ('libiomp') and LLVM OpenMP ('libomp') loaded at
the same time. Both libraries are known to be incompatible and this
can cause random crashes or deadlocks on Linux when loaded in the
same Python program.
Using threadpoolctl may cause crashes or deadlocks. For more
information and possible workarounds, please see
    https://github.com/joblib/threadpoolctl/blob/master/multiple_openmp.md

  warnings.warn(msg, RuntimeWarning)
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/sklearn/utils/deprecation.py:151: FutureWarning: 'force_all_finite' was renamed to 'ensure_all_finite' in 1.6 and will be removed in 1.8.
  warnings.warn(
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/umap/umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
  warn(

Interpretación de resultados: PCA vs t-SNE vs UMAP

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.


PCA (Análisis de Componentes Principales)

  • Lo que vemos: los puntos están muy mezclados, sin una separación clara entre clases.
  • Por qué: PCA es una transformación lineal que maximiza la varianza global. Como los dígitos comparten muchas características (por ejemplo, el 3 y el 8), sus proyecciones se solapan.
  • Ventaja: es rápido, interpretable, y útil como preprocesamiento.
  • 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.


t-SNE (Stochastic Neighbor Embedding)

  • Lo que vemos: los dígitos forman grupos muy bien separados, cada uno claramente definido.
  • Por qué: t-SNE construye una distribución de similitudes entre pares de puntos y luego trata de preservar esas relaciones en el espacio reducido.
  • Ventaja: Excelente para explorar la estructura local del conjunto de datos.
  • 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.


UMAP (Uniform Manifold Approximation and Projection)

  • Lo que vemos: también hay grupos bien definidos, con estructuras más compactas y ordenadas que t-SNE.
  • Por qué: UMAP modela los datos como un grafo ponderado que captura la topología local y luego proyecta el grafo a baja dimensión preservando la conectividad.
  • Ventaja: Preserva tanto la estructura local como parte de la global, es más rápido y escalable que t-SNE.
  • 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.

In [14]:
# 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()
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/sklearn/utils/deprecation.py:151: FutureWarning: 'force_all_finite' was renamed to 'ensure_all_finite' in 1.6 and will be removed in 1.8.
  warnings.warn(
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/umap/umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
  warn(

Interpretación de los resultados

1. Separación de clases clara (estructura local)

UMAP ha logrado separar muy bien los grupos de cada dígito:

  • No hay superposición fuerte entre los dígitos.
  • Clusters como el 0, 6, 1 y 4 están bien definidos, lo que indica que sus representaciones son muy distintas entre sí.

2. Estructura global (relaciones entre dígitos)

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:

  • 0 está muy aislado del resto → tiene una forma muy distinta (cerrado y redondo).
  • 4 y 7 están cerca → ambos tienen trazos rectos y una estructura en forma de ángulo.
  • 1 y 2 están cerca del 8 → el 8 comparte parte de su forma con el 1 (línea recta central) y el 2 (curvas superiores e inferiores).
  • 3, 5 y 9 están agrupados → todos tienen dos curvas en su forma manuscrita, lo que puede llevar a confusión en algunos casos.
  • 6 se separa de otros dígitos, pero mantiene una distancia intermedia → su forma es distintiva pero con algo de solapamiento visual con 5 o 8.

3. Interpretación cognitiva

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

Conclusión

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:

  • Captura tanto la estructura local (distingue dígitos individuales),
  • como la estructura global (relaciones entre dígitos).

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.


Notas Extras

¿Qué es Gradient Descent?

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

Intuición

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.


Regla general de actualización:

$\theta^{(t+1)} = \theta^{(t)} - \eta \cdot \nabla_\theta \mathcal{L}(\theta^{(t)})$

Donde:

  • $ \theta $: parámetros (vectores, matrices, embeddings…)
  • $ \eta $: learning rate (tamaño del paso)
  • $ \nabla_\theta \mathcal{L} $: gradiente de la función de pérdida respecto a los parámetros

¿Qué hace el gradiente?

  • Nos da la dirección de mayor aumento de la función de pérdida.
  • Como queremos minimizar, vamos en la dirección opuesta al gradiente.

¿Qué es Stochastic Gradient Descent (SGD)?

En problemas grandes, calcular el gradiente total sobre todos los datos puede ser costoso.

Solución: en lugar de calcular el gradiente con todos los datos, usamos uno solo o un pequeño subconjunto (mini-batch):

$\theta^{(t+1)} = \theta^{(t)} - \eta \cdot \nabla_\theta \mathcal{L}_i(\theta^{(t)})$

  • Aquí $ \mathcal{L}_i $ es la pérdida evaluada solo en un punto de datos o par $ (i,j) $.
  • Esto introduce aleatoriedad (stochastic), pero permite actualizar los parámetros mucho más rápido y frecuentemente.

Comparación Visual

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

Ejemplo con UMAP

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í:

  • Cada iteración de SGD es rápida.
  • El embedding evoluciona en tiempo real.
  • Se puede paralelizar y escalar bien.

Metáfora

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.


Ejemplo: "Bajando una montaña con los ojos cerrados"

Imagina esto:

Estás parado en algún punto de una montaña. Pero:

  • Estás con los ojos vendados (¡no sabes dónde está el valle!).
  • Solo puedes sentir con tus pies hacia dónde la pendiente es más empinada.
  • Cada paso que das es en la dirección que baja más.
  • Pero para no caerte, haces pasos pequeños.

Objetivo:

Llegar al punto más bajo posible (mínimo de la función de pérdida).


¿Qué te ayuda a saber hacia dónde ir?

El gradiente es como una brújula:

  • Te dice hacia dónde sube más la montaña.
  • Por lo tanto, tú caminas en la dirección opuesta: hacia abajo.

¿Qué tan grande es cada paso?

Ese es el learning rate $ \eta $:

  • Si das pasos muy grandes, podrías pasarte o caer por otro lado.
  • Si das pasos muy chiquitos, podrías tardar muchísimo en llegar al valle.

En una red neuronal:

  • La montaña es la función de pérdida.
  • El camino que tomas es el descenso por gradiente.
  • Cada paso actualiza los parámetros $ \theta $ de la red.
  • La meta es encontrar el punto más bajo posible: donde la red comete el menor error.

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


Ejemplo: "Bajando una montaña con los ojos cerrados"

Imagina esto:

Estás parado en algún punto de una montaña. Pero:

  • Estás con los ojos vendados (¡no sabes dónde está el valle!).
  • Solo puedes sentir con tus pies hacia dónde la pendiente es más empinada.
  • Cada paso que das es en la dirección que baja más.
  • Pero para no caerte, haces pasos pequeños.

Objetivo:

Llegar al punto más bajo posible (mínimo de la función de pérdida).


¿Qué te ayuda a saber hacia dónde ir?

El gradiente es como una brújula:

  • Te dice hacia dónde sube más la montaña.
  • Por lo tanto, tú caminas en la dirección opuesta: hacia abajo.

¿Qué tan grande es cada paso?

Ese es el learning rate $ \eta $:

  • Si das pasos muy grandes, podrías pasarte o caer por otro lado.
  • Si das pasos muy chiquitos, podrías tardar muchísimo en llegar al valle.

En una red neuronal:

  • La montaña es la función de pérdida.
  • El camino que tomas es el descenso por gradiente.
  • Cada paso actualiza los parámetros $ \theta $ de la red.
  • La meta es encontrar el punto más bajo posible: donde la red comete el menor error.

#

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