Clustering (Agrupamiento)

Modelos de Machine Learning No-Supervisados


📌 Dr. Cristian Candia

🎓 Universidad del Desarrollo (UDD), Chile

🎓 Northwestern University, United States

🚀 Founder


DBSCAN: A Density-Based Algorithm for Discovering Clusters in Large Spatial Databases with Noise

Paper: https://cdn.aaai.org/KDD/1996/KDD96-037.pdf

Ejemplo

Clustering Basado en Densidad: Introducción a DBSCAN

Hasta ahora hemos visto métodos de clustering que separan los datos en grupos completos, pero en muchos problemas reales eso no siempre es natural. A veces no sabemos cuántos grupos hay, los clústeres tienen formas irregulares y algunos puntos están demasiado aislados como para pertenecer a un grupo.

DBSCAN aborda este problema con una idea simple:

si en una zona del espacio hay muchos puntos cercanos entre sí, probablemente ahí hay un clúster; si un punto está muy aislado, probablemente es ruido.

A diferencia de métodos como k-means, DBSCAN no requiere fijar de antemano el número de clústeres y puede detectar grupos de forma arbitraria, no sólo clústeres redondos o convexos.

En términos generales, DBSCAN construye clústeres identificando regiones densas del espacio y separándolas de las zonas dispersas.


¿Por qué es útil?

DBSCAN es especialmente útil porque:

  • no requiere especificar el número de clústeres;
  • puede detectar clústeres con formas irregulares;
  • identifica ruido u outliers de manera explícita;
  • funciona bien cuando los datos forman zonas densas separadas por regiones vacías.

Idea general

La lógica de DBSCAN es simple: para cada punto, mira cuántos vecinos tiene cerca. Si un punto está en una zona suficientemente densa, puede formar parte de un clúster; si está demasiado aislado, se considera ruido.

Para formalizar esta idea, necesitamos responder dos preguntas:

  • ¿qué significa que dos puntos estén cerca?
  • ¿cuántos vecinos son suficientes para considerar que una zona es densa?

Estas preguntas dan origen a los dos parámetros fundamentales del método:

  • $\varepsilon$: el radio que define el vecindario de un punto;
  • MinPts: el número mínimo de puntos necesarios para considerar que hay densidad suficiente.

A partir de esto, DBSCAN distinguirá entre puntos núcleo, puntos borde y puntos ruido.


Ejemplo: Conjuntos de Datos Sintéticos

In [1]:
import matplotlib.pyplot as plt
import numpy as np
from sklearn.datasets import make_blobs, make_moons, make_circles
from sklearn.neighbors import NearestNeighbors
import seaborn as sns
sns.set(style="whitegrid")

# Generar tres conjuntos de datos que imiten las figuras del paper
X1, _ = make_blobs(n_samples=300, centers=4, cluster_std=[1.0, 2.0, 0.5, 0.2], random_state=42)
X2, _ = make_moons(n_samples=300, noise=0.05, random_state=0)
X3, _ = make_circles(n_samples=300, factor=0.5, noise=0.05)

# Agregar ruido a X3 para simular la tercera figura con clusters + noise
rng = np.random.RandomState(42)
X3_noise = np.vstack([X3, np.random.RandomState(42).uniform(low=-1, high=1, size=(30, 2))])

# Función para graficar conjuntos de datos
def plot_datasets(X_list, titles):
    fig, axs = plt.subplots(1, 3, figsize=(18, 5))
    for i, (X, title) in enumerate(zip(X_list, titles)):
        axs[i].scatter(X[:, 0], X[:, 1], s=15)
        axs[i].set_title(title)
        axs[i].set_xticks([])
        axs[i].set_yticks([])
    plt.tight_layout()
    return fig

fig = plot_datasets(
    [X1, X2, X3_noise],
    ["Base de datos 1: clusters esféricos", 
     "Base de datos 2: clusters no convexos",
     "Base de datos 3: clusters + ruido"]
)

fig.show()
/var/folders/q8/5g879lls62l1z8smyjkh4h700000gn/T/ipykernel_73762/1230094852.py:35: UserWarning: FigureCanvasAgg is non-interactive, and thus cannot be shown
  fig.show()

Esta figura muestra tres bases de datos:

  • Clústeres esféricos de diferente tamaño.

  • Clústeres con formas no convexas (en forma de luna).

  • Clústeres con diferentes formas + ruido (outliers aleatorios).

Nota: Un conjunto es convexo si para cualquier par de puntos dentro del conjunto, la línea recta que los une también está completamente dentro del conjunto. Ejemplo clásico: un círculo, un cuadrado, una elipse. Los algoritmos como k-means generan clústeres convexos, porque asignan puntos al centro más cercano y forman regiones tipo "celdas" de Voronoi. Un conjunto no convexo es aquel donde puede haber dos puntos dentro del clúster cuya línea recta pasa fuera del clúster. Ejemplo: forma de "U", luna creciente (como en make_moons), dos anillos, forma de serpiente. DBSCAN permite descubrir estos clústeres porque no impone ninguna forma predefinida. La flexibilidad en la forma es una de las grandes ventajas de DBSCAN. Puede encontrar agrupaciones que otros métodos no verían porque no son “redonditas”.


Definiciones clave

Los gráficos anteriores sugieren la intuición central de DBSCAN: un clúster no se define por su forma, sino por la presencia de regiones donde los puntos están suficientemente concentrados. Para formalizar esta idea, necesitamos definir qué significa que dos puntos estén cerca y qué significa que una zona sea densa.

Vecindario $\varepsilon$

Dado un punto $p$ en el conjunto de datos $D$ y un parámetro $\varepsilon > 0$, definimos el vecindario $\varepsilon$ de $p$ como el conjunto de todos los puntos que están a una distancia menor o igual que $\varepsilon$ de $p$:

$N_{\varepsilon}(p) = \{ q \in D \mid \text{dist}(p,q) \leq \varepsilon \}$

En otras palabras, si dibujamos un círculo (o una esfera en dimensiones más altas) de radio $\varepsilon$ alrededor de $p$, su vecindario contiene todos los puntos que caen dentro de esa región.


Densidad local en DBSCAN

DBSCAN no estima densidad en el sentido clásico de la estadística. En cambio, evalúa si una zona es densa usando una idea simple:

contar cuántos puntos hay en el vecindario $\varepsilon$ de un punto.

Por eso, la densidad local alrededor de $p$ se entiende a partir del tamaño de su vecindario:

$|N_{\varepsilon}(p)|$

  • Si este número es grande, $p$ está en una zona densa.
  • Si este número es pequeño, $p$ está en una zona dispersa.

El parámetro que define cuántos vecinos son suficientes es MinPts.


Punto núcleo

Un punto $p$ es un punto núcleo si en su vecindario hay al menos MinPts puntos:

$|N_{\varepsilon}(p)| \geq \text{MinPts}$

Intuitivamente, un punto núcleo está en el interior de una región densa y puede servir para expandir un clúster.


Punto borde

Un punto $p$ es un punto borde si:

  • no tiene suficientes vecinos para ser núcleo, pero
  • está dentro del vecindario $\varepsilon$ de algún punto núcleo.

En otras palabras, pertenece a la periferia de una región densa: puede formar parte de un clúster, pero no puede expandirlo por sí mismo.


Punto ruido

Un punto es ruido si:

  • no es núcleo, y
  • no está dentro del vecindario $\varepsilon$ de ningún punto núcleo.

Estos puntos quedan aislados respecto de las regiones densas y DBSCAN no los asigna a ningún clúster.


Resumen conceptual

Tipo de punto Condición
Núcleo Tiene al menos MinPts puntos en su vecindario $\varepsilon$
Borde No es núcleo, pero está dentro del vecindario de un punto núcleo
Ruido No es núcleo ni está dentro del vecindario de ningún núcleo

Alcanzabilidad directa por densidad

Un punto $p$ es directamente alcanzable por densidad desde un punto $q$ si se cumplen dos condiciones:

  1. $p \in N_{\varepsilon}(q)$
  2. $q$ es un punto núcleo, es decir, $|N_{\varepsilon}(q)| \geq \text{MinPts}$

Formalmente:

$p \in N_{\varepsilon}(q) \quad \text{y} \quad |N_{\varepsilon}(q)| \geq \text{MinPts}$

La idea es que un punto núcleo puede alcanzar directamente a los puntos que están en su vecindario. Esto es lo que permite que un clúster se expanda a partir de regiones densas.

Importante: esta relación no es simétrica. Puede ocurrir que $p$ sea directamente alcanzable desde $q$, pero no al revés, porque $p$ podría no ser un punto núcleo.


Intuición general

DBSCAN construye clústeres partiendo desde puntos núcleo y agregando los puntos que están conectados a ellos a través de vecindarios densos. Los puntos borde pueden pertenecer a un clúster, pero no ayudan a seguir expandiéndolo. Los puntos ruido quedan fuera.

In [2]:
# Re-import required libraries after kernel reset
import matplotlib.pyplot as plt
import numpy as np

# Figura 2: core point y border point
fig, ax = plt.subplots(figsize=(6, 6))

# Puntos del ejemplo
core_point = np.array([0, 0])
border_point = np.array([1.2, 0.2])
other_points = np.array([
    [-0.5, 0.2], [0.3, 0.5], [-0.3, -0.5], [0.4, -0.4], [1.5, 0.5]
])

# Dibujar vecindario de radio eps = 1.0 alrededor del core
circle_core = plt.Circle(core_point, 1.2, color='blue', fill=False, linestyle='--', label='Vecindario ε (núcleo)')
ax.add_patch(circle_core)

# Dibujar puntos
ax.scatter(core_point[0], core_point[1], color='blue', s=120, label='Punto núcleo (q)', edgecolor='black', zorder=3)
ax.scatter(border_point[0], border_point[1], color='green', s=120, label='Punto borde (p)', edgecolor='black', zorder=3)
ax.scatter(other_points[:, 0], other_points[:, 1], color='gray', s=80, label='Vecinos', edgecolor='black', zorder=2)

# Flechas para mostrar dirección de alcanzabilidad
ax.annotate("", xy=border_point, xytext=core_point,
            arrowprops=dict(arrowstyle="->", lw=2, color='black'))

# Texto explicativo
ax.text(-1.8, -1.5, "El punto borde p está en el vecindario de q,\npero no al revés: la relación no es simétrica.", fontsize=10)

ax.set_xlim(-2, 3)
ax.set_ylim(-2, 2)
ax.set_aspect('equal')
ax.set_xticks([])
ax.set_yticks([])
ax.set_title("Figura 2: Punto núcleo y punto borde\n(alcanzabilidad directa no simétrica)")
ax.legend(loc='upper right')
plt.tight_layout()


plt.show()

Esta figura ilustra la idea de alcanzabilidad directa por densidad:

  • El punto azul q es un punto núcleo, ya que su vecindario (círculo punteado) contiene suficientes puntos.

  • El punto verde p está dentro del vecindario de q, así que p es directamente alcanzable por densidad desde q.

  • Pero p no tiene suficientes vecinos, así que no es un núcleo y q no es directamente alcanzable desde p.

  • Por eso, esta relación no es simétrica.

Esta distinción es clave para entender cómo DBSCAN forma clústeres conectando puntos a través de núcleos, y cómo incluye puntos que están en los bordes del grupo.


¿Qué es alcanzabilidad por densidad?

Este concepto describe si un punto pertenece a un grupo (clúster) porque está conectado con otros puntos densamente agrupados.

En palabras simples:

Un punto $A$ es alcanzable por densidad desde un punto $B$ si puedes llegar desde $B$ hasta $A$ saltando de punto en punto, y cada salto es corto (menor que ε) y cada punto intermedio está rodeado de muchos otros puntos (es decir, son puntos núcleo). Lo clave es que puedes "caminar" de núcleo en núcleo hasta llegar a tu destino.

Ejemplos mentales:

  • Imagina una cadena de personas tomadas de la mano, donde cada una está rodeada de otras personas (núcleos). Si puedes ir de una persona a otra por esa cadena, entonces estás conectado y puedes ser parte del mismo grupo.

  • Quieres llegar desde una persona A hasta otra persona B en una fiesta, pero están lejos. Si hay una cadena de personas populares (núcleos) paradas una al lado de la otra, y cada una te lleva a la siguiente, entonces puedes alcanzar a B por densidad aunque no estén directamente cerca.

Alcanzabilidad por Densidad (Density-Reachable) formalmente:

Un punto $p$ es alcanzable por densidad desde $q$ si existe una cadena de puntos $p_1, p_2, \ldots, p_n$ tal que:

  • $p_1 = q$, $p_n = p$
  • $\forall i,\; p_{i+1}$ es directamente alcanzable por densidad desde $p_i$

Esta relación es transitiva pero no simétrica.


In [313]:
import matplotlib.pyplot as plt
import numpy as np

# Parámetros
epsilon = 1.0  # radio de vecindad
min_pts = 5    # mínimo de puntos para ser núcleo

# Coordenadas principales: q (rojo), núcleos azules, frontera (verde), ruido (grises)
main_points = np.array([
    [0, 0],    # Punto origen q (núcleo)
    [0.9, 0],  # Núcleo 1
    [1.8, 0],  # Núcleo 2
    [2.7, 0],  # Núcleo 3
    [3.6, 0],  # Punto alcanzado p (no núcleo)
    [1.2, 1.2],  # Ruido
    [2.4, 1.3],  # Ruido
])

colors = ['red', 'blue', 'blue', 'blue', 'green', 'gray', 'gray']
labels = ['q (núcleo)', 'núcleo', 'núcleo', 'núcleo', 'p (alcanzado)', 'ruido', 'ruido']

# Coordenadas de vecinos alrededor de los núcleos (para justificar que son núcleo)
extra_neighbors = []
for center in main_points[:4]:  # sólo para q y núcleos azules
    angle = np.linspace(0, 2*np.pi, 6, endpoint=False)
    radius = 0.25
    extras = center + np.c_[radius * np.cos(angle), radius * np.sin(angle)]
    extra_neighbors.append(extras)
extra_neighbors = np.vstack(extra_neighbors)

# Crear figura
fig, ax = plt.subplots(figsize=(10, 4))

# Dibujar vecindades epsilon para núcleos y punto frontera
for i in range(5):
    circle = plt.Circle(main_points[i], epsilon, color='blue', linestyle='dashed', fill=False)
    ax.add_patch(circle)

# Dibujar puntos extra (vecinos) alrededor de los núcleos
ax.scatter(extra_neighbors[:, 0], extra_neighbors[:, 1], c='black', s=20, zorder=2)

# Dibujar puntos principales
for (x, y), color, label in zip(main_points, colors, labels):
    ax.scatter(x, y, color=color, s=250, edgecolor='k', zorder=3)
    # Dibujar strip detrás del texto (cada uno individual)
    ax.text(x, y + 0.35, label,
            ha='center', va='center',
            fontsize=9, zorder=4,
            bbox=dict(boxstyle='round,pad=0.2', facecolor='#eeeeee', edgecolor='gray'))

# Dibujar flechas de alcanzabilidad (negras)
for i in range(4):
    start, end = main_points[i], main_points[i + 1]
    ax.annotate('', xy=end, xytext=start,
                arrowprops=dict(arrowstyle='->', color='black', lw=2), zorder=1)

# Configuración del gráfico
ax.set_xlim(-1, 5)
ax.set_ylim(-1, 2.5)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title("Figura: Alcanzabilidad por densidad con puntos núcleo (q y azules) y punto frontera alcanzado (verde)", fontsize=12)

plt.tight_layout()
plt.savefig("alcanzabilidad_densidad_strip_individual.png", dpi=300)
plt.show()

En esta figura:

  • El punto rojo q es el punto de inicio.

  • Los puntos azules son núcleos conectados entre sí, y sus círculos representan su vecindario $\varepsilon$.

  • El punto verde p es alcanzable desde q mediante una cadena de pasos directos entre núcleos.

  • Los puntos grises son ruido (no forman parte del clúster).

Esta ilustración muestra cómo DBSCAN permite agrupar puntos a través de una secuencia de núcleos, incluso si p está lejos de q, siempre que haya una ruta válida entre ellos. Además, demuestra que la alcanzabilidad es transitiva pero no simétrica: p puede ser alcanzado desde q, pero no necesariamente al revés.

Nota: El punto q es un núcleo, por lo tanto puede "extender" densidad hacia otros puntos como p, que están conectados por una cadena de núcleos. El punto p, en cambio, no es un núcleo (no tiene suficientes vecinos dentro de su radio $\varepsilon$), así que no puede propagar densidad hacia atrás. Aunque p puede ser alcanzado desde q, el camino inverso no está permitido porque p no cumple con la condición de ser núcleo, y en DBSCAN solo los núcleos pueden "empujar" la expansión del clúster.

La alcanzabilidad directa por densidad es una relación local: un núcleo alcanza a los puntos de su vecindario. La alcanzabilidad por densidad extiende esa idea: un punto pertenece al mismo clúster si puede conectarse con un núcleo a través de una cadena de vecindarios densos.


Conectividad por densidad (Density-Connected)

Dos puntos $p$ y $q$ están conectados por densidad si existe un punto $o$ tal que tanto $p$ como $q$ son alcanzables por densidad desde $o$.

Formalmente, existen conectividad por densidad si:

  • $p$ es alcanzable por densidad desde $o$, y
  • $q$ es alcanzable por densidad desde $o$.

La idea es que, aunque $p$ y $q$ no estén directamente conectados entre sí, ambos pertenecen a la misma región densa porque pueden enlazarse a través de un mismo punto de referencia, típicamente un punto núcleo.

A diferencia de la alcanzabilidad por densidad, esta relación sí es simétrica: si $p$ está conectado por densidad con $q$, entonces $q$ también está conectado por densidad con $p$.

Esta noción es la que permite definir correctamente un clúster en DBSCAN, agrupando puntos que pertenecen a una misma región densa, incluidos los puntos borde.

En resumen:

Dos puntos $p$ y $q$ están conectados por densidad si existe un punto $o$ desde el cual ambos son alcanzables por densidad. Esta relación es simétrica y permite definir qué puntos pertenecen al mismo clúster.

In [314]:
import matplotlib.pyplot as plt
import numpy as np

# Parámetros
epsilon = 1.0  # radio de vecindad
min_pts = 5    # mínimo de puntos para ser núcleo

# Coordenadas principales: puntos conectados por densidad desde un tercero
main_points = np.array([
    [1.8, 0],   # Punto intermedio (núcleo) - "origen común" o
    [0.9, 0],   # p1 - alcanzable por densidad desde o
    [2.7, 0],   # p2 - alcanzable por densidad desde o
    [4, 0],   # ruido
    [2, 1.2], # ruido
])

colors = ['blue', 'green', 'green', 'gray', 'gray']
labels = ['o (núcleo)', 'p', 'q', 'ruido', 'ruido']

# Vecinos para justificar que o es núcleo
extra_neighbors = []
angle = np.linspace(0, 2*np.pi, 6, endpoint=False)
radius = 0.25
extras = main_points[0] + np.c_[radius * np.cos(angle), radius * np.sin(angle)]
extra_neighbors.append(extras)
extra_neighbors = np.vstack(extra_neighbors)

# Crear figura
fig, ax = plt.subplots(figsize=(10, 4))

# Dibujar vecindades epsilon de los tres primeros puntos
for i in range(3):
    circle = plt.Circle(main_points[i], epsilon, color='blue', linestyle='dashed', fill=False)
    ax.add_patch(circle)

# Vecinos del núcleo
ax.scatter(extra_neighbors[:, 0], extra_neighbors[:, 1], c='black', s=20, zorder=2)

# Dibujar puntos principales
for (x, y), color, label in zip(main_points, colors, labels):
    ax.scatter(x, y, color=color, s=250, edgecolor='k', zorder=3)
    ax.text(x, y + 0.35, label,
            ha='center', va='center',
            fontsize=9, zorder=4,
            bbox=dict(boxstyle='round,pad=0.2', facecolor='#eeeeee', edgecolor='gray'))

# Flechas desde núcleo o a p y q
ax.annotate('', xy=main_points[1], xytext=main_points[0],
            arrowprops=dict(arrowstyle='->', color='black', lw=2), zorder=1)
ax.annotate('', xy=main_points[2], xytext=main_points[0],
            arrowprops=dict(arrowstyle='->', color='black', lw=2), zorder=1)

# Configuración del gráfico
ax.set_xlim(-0.5, 4.5)
ax.set_ylim(-1, 2.5)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title("Figura: Conectividad por densidad\np y q están conectados por densidad a través de o", fontsize=12)

plt.tight_layout()
plt.show()

Definición Formal de Clúster y Ruido

Clúster

Un conjunto $C \subseteq D$ es un clúster si cumple:

  1. (Maximalidad) Si $p \in C$ y $q$ es alcanzable por densidad desde $p$, entonces $q \in C$.
  2. (Conectividad) Todos los puntos de $C$ están conectados por densidad entre sí.

Nota: Tomamos un grupo de puntos C que pertenecen al conjunto total de datos D. Si ese grupo cumple ciertas condiciones (como estar conectados por densidad), entonces lo llamamos un clúster.

Ruido

El ruido se define como el conjunto de puntos que no pertenecen a ningún clúster:

$$ \text{Ruido} = D \setminus \bigcup_{i=1}^{k} C_i $$

Ruido es igual al conjunto D menos la unión de todos los clústeres $ C_i$ desde $ i = 1 $ hasta $ k $, entonces Ruido son los puntos que están en D pero no en ninguno de los clústeres $C_i$.

Nota: El ruido está formado por todos los puntos que no pertenecen a ningún clúster. Es decir, son los puntos que quedaron fuera de todas las agrupaciones que encontró el algoritmo.


Algoritmo DBSCAN

DBSCAN recibe como entrada:

  • Un conjunto de puntos $D$
  • Un valor de radio $\varepsilon$
  • Un umbral mínimo de puntos $\text{MinPts}$

Idea: Para cada punto no clasificado:

  • Si es un punto núcleo, se crea un nuevo clúster y se agregan todos los puntos alcanzables por densidad.
  • Si no es núcleo y no es alcanzable, se marca como ruido.

Complejidad: $O(n \log n)$ si se utiliza un índice espacial como R*-tree.


Ejemplo

Selección de Parámetros

Una técnica común para determinar $\varepsilon$ es el gráfico de k-distancia, donde se grafica la distancia al k-ésimo vecino más cercano de cada punto. El valor de $\varepsilon$ se elige visualmente en el “codo” de la curva.

Se suele fijar $\text{MinPts} = 4$ para datos en 2D.


In [ ]:
from sklearn.neighbors import NearestNeighbors

# Generamos un conjunto de puntos con forma compleja + ruido
from sklearn.datasets import make_moons

X, _ = make_moons(n_samples=200, noise=0.05, random_state=0)

# Parámetro k
k = 4

# Calcular las distancias al k-ésimo vecino más cercano
nbrs = NearestNeighbors(n_neighbors=k) # Inicializamos el modelo de vecinos más cercanos con k vecinos. Este modelo se utilizará para calcular las distancias entre cada punto y sus k vecinos más cercanos, lo que es fundamental para estimar el parámetro ε en DBSCAN.
nbrs.fit(X)# Ajustamos el modelo de vecinos más cercanos a los datos X para que pueda calcular las distancias entre cada punto y sus k vecinos más cercanos. Esto es un paso necesario antes de llamar a kneighbors para obtener las distancias.
distances, indices = nbrs.kneighbors(X) #   Calculamos las distancias entre cada punto y sus k vecinos más cercanos utilizando el método kneighbors del modelo de vecinos más cercanos. Esto nos devuelve una matriz de distancias y una matriz de índices que indican qué puntos son los vecinos más cercanos para cada punto en el dataset.

# Extraer la distancia al k-ésimo vecino (última columna)
k_distances = distances[:, -1] # Obtenemos la distancia al k-ésimo vecino más cercano para cada punto, que es la última columna de la matriz de distancias. Estas distancias se utilizarán para crear el gráfico k-dist, que nos ayudará a estimar un valor adecuado para ε en DBSCAN.
k_distances_sorted = np.sort(k_distances)[::-1]  # orden descendente para facilitar la identificación del "codo" en el gráfico k-dist, que es un punto donde la pendiente cambia significativamente, indicando una posible elección para ε. Al ordenar las distancias en orden descendente, podemos visualizar claramente este cambio en la pendiente y hacer una estimación informada de ε para DBSCAN.

# Figura 4: gráfico k-dist
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(range(1, len(k_distances_sorted)+1), k_distances_sorted, marker='o')
ax.set_xlabel("Índice del punto (ordenado)")
ax.set_ylabel(f"Distancia al {k}-ésimo vecino más cercano")
ax.set_title(f"Figura 4: Gráfico {k}-dist para estimar ε")
ax.grid(True)

# Agregar línea vertical en posible "codo"
elbow_idx = 30
ax.axvline(x=elbow_idx, color='red', linestyle='--', label='Estimación de ε')
ax.legend()

fig.tight_layout()
plt.show()

Este gráfico muestra la distancia al 4° vecino más cercano para cada punto, ordenados de mayor a menor.

¿Cómo se usa este gráfico?

  • Se busca el punto donde ocurre un cambio abrupto en la pendiente (un “codo”).

  • Ese punto indica el valor de $\varepsilon$ que separa zonas densas (a la derecha) de zonas dispersas o ruido (a la izquierda).

En este ejemplo, el codo está aproximadamente en el índice 30, lo que sugiere un buen valor para $\varepsilon$.

En el eje vertical, el gráfico muestra la distancia entre cada punto y su k-ésimo vecino más cercano (por ejemplo, el 4° vecino más cercano).

Los puntos están ordenados en el eje horizontal de mayor a menor distancia.

¿Cómo interpretar la forma del gráfico?
  • Parte izquierda (valores altos de distancia):

    • Corresponde a puntos aislados o en regiones poco densas.

    • Para llegar al 4° vecino más cercano, tienen que “viajar” más lejos.

    • Estos puntos probablemente sean ruido.

  • Parte derecha (valores bajos de distancia):

    • Son puntos en regiones densas, con muchos vecinos cercanos.

    • Sus 4 vecinos más cercanos están muy cerca.

    • Estos puntos probablemente pertenezcan a clústeres reales.

¿Dónde se separan las zonas?

La pendiente del gráfico cambia bruscamente en un punto: eso se llama el “codo”.

  • A la izquierda del codo: puntos con distancias muy grandes → potencial ruido.

  • A la derecha del codo: puntos en zonas densas → posibles núcleos de clústeres.

Conclusión

DBSCAN es un algoritmo poderoso para descubrir estructuras no triviales en datos espaciales y de alta dimensión, permitiendo manejar ruido de forma explícita y sin requerir el número de clústeres de antemano. Su formulación basada en densidad lo vuelve particularmente útil para aplicaciones del mundo real como imágenes satelitales, análisis geográfico, y más.

En DBSCAN, un clúster es un conjunto de puntos conectados por densidad. Eso significa que puede haber muchos núcleos dentro del mismo clúster, todos conectados entre sí mediante sus vecindarios. Mientras un punto tenga suficientes vecinos, puede ser considerado núcleo, y contribuir a expandir el clúster.


Ejemplos en Python

In [32]:
from sklearn.cluster import DBSCAN

# X es tu conjunto de datos: un array de tamaño (n_samples, n_features)
db = DBSCAN(eps=0.5, min_samples=5) # Inicializamos el modelo DBSCAN con un valor de ε (eps) de 0.5 y un mínimo de muestras (min_samples) de 5. Estos parámetros controlan cómo se forman los clusters: ε define el radio de vecindad para considerar puntos como parte del mismo cluster, y min_samples define cuántos puntos deben estar dentro de ese radio para que un punto sea considerado un núcleo.
labels = db.fit_predict(X) # Aplicamos el algoritmo DBSCAN a los datos X utilizando el método fit_predict, que ajusta el modelo a los datos y devuelve las etiquetas de cluster asignadas a cada punto. Las etiquetas indican a qué cluster pertenece cada punto, o si es considerado ruido (etiqueta -1).

Eso es todo lo que necesitas para ejecutar DBSCAN con scikit-learn.

  • eps: radio máximo para considerar vecinos.

  • min_samples: cantidad mínima de puntos en ese radio para formar un núcleo.

  • labels: el resultado, donde cada número indica a qué clúster pertenece un punto, y -1 indica ruido.

Ejemplo comparativo con datos complejos (forma de media luna)

Este es un ejemplo donde DBSCAN funciona muy bien y los otros algoritmos en general fallan

In [33]:
import matplotlib.pyplot as plt
import numpy as np
from sklearn.datasets import make_moons
from sklearn.cluster import KMeans, AgglomerativeClustering, DBSCAN
from sklearn.neighbors import NearestNeighbors
from scipy.cluster.hierarchy import dendrogram, linkage

# Generar datos no convexos
X, _ = make_moons(n_samples=300, noise=0.05, random_state=42)

# Función para encontrar el número óptimo de clusters para K-Means usando el método del codo
def find_optimal_k(X):
    distortions = []
    K = range(1, 10)
    for k in K:
        kmeans = KMeans(n_clusters=k, random_state=0).fit(X) # Ajustamos el modelo KMeans a los datos X para cada valor de k en el rango especificado. Esto nos permite calcular la distorsión (inercia) para cada número de clusters, lo que es esencial para aplicar el método del codo y determinar el número óptimo de clusters.
        distortions.append(kmeans.inertia_)# Almacenamos la distorsión (inercia) para cada valor de k en la lista distortions, que se utilizará para graficar el método del codo. La inercia es una medida de la calidad del clustering, y al observar cómo cambia con diferentes valores de k, podemos identificar un punto donde la mejora se vuelve marginal, lo que sugiere un número óptimo de clusters.
    plt.figure(figsize=(8, 4))
    plt.plot(K, distortions, 'bx-')
    plt.xlabel('Número de Clusters')
    plt.ylabel('Distorsión')
    plt.title('Método del Codo para encontrar el número óptimo de Clusters')
    plt.show()
    return 2  # Basado en la gráfica del codo

# Función para encontrar el valor óptimo de epsilon para DBSCAN usando el gráfico k-dist
def find_optimal_eps(X, k=4):
    nbrs = NearestNeighbors(n_neighbors=k).fit(X) # Inicializamos el modelo de vecinos más cercanos con k vecinos. Este modelo se utilizará para calcular las distancias entre cada punto y sus k vecinos más cercanos, lo que es fundamental para estimar el parámetro ε en DBSCAN.
    distances, indices = nbrs.kneighbors(X) #   Calculamos las distancias entre cada punto y sus k vecinos más cercanos utilizando el método kneighbors del modelo de vecinos más cercanos. Esto nos devuelve una matriz de distancias y una matriz de índices que indican qué puntos son los vecinos más cercanos para cada punto en el dataset.
    k_distances = distances[:, -1] # Obtenemos la distancia al k-ésimo vecino más cercano para cada punto, que es la última columna de la matriz de distancias. Estas distancias se utilizarán para crear el gráfico k-dist, que nos ayudará a estimar un valor adecuado para ε en DBSCAN.
    k_distances_sorted = np.sort(k_distances)[::-1] # orden descendente para facilitar la identificación del "codo" en el gráfico k-dist, que es un punto donde la pendiente cambia significativamente, indicando una posible elección para ε. Al ordenar las distancias en orden descendente, podemos visualizar claramente este cambio en la pendiente y hacer una estimación informada de ε para DBSCAN.
    plt.figure(figsize=(8, 4))
    plt.plot(range(1, len(k_distances_sorted) + 1), k_distances_sorted, marker='o')
    plt.xlabel("Índice del punto (ordenado)")
    plt.ylabel(f"Distancia al {k}-ésimo vecino más cercano")
    plt.title(f"Gráfico {k}-dist para estimar ε")
    plt.grid(True)
    plt.show()
    return 0.1  # Basado en la gráfica k-dist

# Función para encontrar el número óptimo de clusters para Agglomerative Clustering usando el dendrograma
def plot_dendrogram(X):
    Z = linkage(X, 'ward') # Calculamos la matriz de enlace (linkage) utilizando el método de Ward, que es una técnica de clustering jerárquico. Esta matriz se utiliza para construir el dendrograma, que nos ayuda a visualizar cómo se agrupan los datos en diferentes niveles de similitud y a determinar el número óptimo de clusters.
    plt.figure(figsize=(10, 5))
    dendrogram(Z)
    plt.title('Dendrograma para encontrar el número óptimo de Clusters')
    plt.xlabel('Puntos de datos')
    plt.ylabel('Distancia')
    plt.show()
    return 5  # Basado en el dendrograma

# Encontrar el número óptimo de clusters para K-Means
optimal_k = find_optimal_k(X) # Llamamos a la función find_optimal_k para determinar el número óptimo de clusters para K-Means utilizando el método del codo. Esta función graficará la distorsión para diferentes valores de k y nos permitirá identificar visualmente el punto donde la mejora se vuelve marginal, lo que sugiere un número óptimo de clusters.

# Encontrar el valor óptimo de epsilon para DBSCAN
optimal_eps = find_optimal_eps(X)

# Encontrar el número óptimo de clusters para Agglomerative Clustering
optimal_clusters = plot_dendrogram(X)
In [34]:
# Aplicar K-Means
kmeans = KMeans(n_clusters=optimal_k, random_state=0).fit_predict(X)

# Aplicar DBSCAN
dbscan = DBSCAN(eps=optimal_eps, min_samples=5).fit_predict(X)

# Aplicar Agglomerative Clustering
agglo = AgglomerativeClustering(n_clusters=optimal_clusters).fit_predict(X)

# Visualizar resultados
titles = ['K-Means', 'Clustering Jerárquico', 'DBSCAN']
clusters = [kmeans, agglo, dbscan]

fig, axs = plt.subplots(1, 3, figsize=(15, 4))
for i, (title, labels) in enumerate(zip(titles, clusters)):
    axs[i].scatter(X[:, 0], X[:, 1], c=labels, cmap='Paired', edgecolor='k')
    axs[i].set_title(title)
    axs[i].set_xticks([])
    axs[i].set_yticks([])
plt.tight_layout()
plt.show()

DBSCAN no asume formas geométricas simples, y puede ignorar el ruido, lo que lo hace ideal para clústeres con forma arbitraria y datos reales con outliers. Recuerda del paper:

As a rule of thumb, MinPts can be set to 4 (including the point itself), and larger values should be used for higher-dimensional data.

In [ ]:
import matplotlib.pyplot as plt
import numpy as np
from sklearn.datasets import make_moons
from sklearn.cluster import KMeans, AgglomerativeClustering, DBSCAN
from sklearn.neighbors import NearestNeighbors
from scipy.cluster.hierarchy import dendrogram, linkage

# Generar datos no convexos
X, _ = make_moons(n_samples=300, noise=0.05, random_state=42)

# Método del codo para KMeans
def find_optimal_k(X):
    distortions = []
    K = range(1, 10)
    for k in K:
        kmeans = KMeans(n_clusters=k, random_state=0).fit(X)
        distortions.append(kmeans.inertia_)
    plt.figure(figsize=(8, 4))
    plt.plot(K, distortions, 'bx-')
    plt.xlabel('Número de Clusters')
    plt.ylabel('Distorsión')
    plt.title('Método del Codo para K-Means')
    plt.show()
    return 2  # visualmente óptimo para make_moons

# Método para estimar eps usando curvatura
def find_optimal_eps(X, k=5):
    # 1. Calcular distancia al k-ésimo vecino más cercano
    nbrs = NearestNeighbors(n_neighbors=k).fit(X) # Inicializamos el modelo de vecinos más cercanos con k vecinos. Este modelo se utilizará para calcular las distancias entre cada punto y sus k vecinos más cercanos, lo que es fundamental para estimar el parámetro ε en DBSCAN.
    distances, _ = nbrs.kneighbors(X) #  Calculamos las distancias entre cada punto y sus k vecinos más cercanos utilizando el método kneighbors del modelo de vecinos más cercanos. Esto nos devuelve una matriz de distancias y una matriz de índices que indican qué puntos son los vecinos más cercanos para cada punto en el dataset.
    k_distances = distances[:, -1] # Obtenemos la distancia al k-ésimo vecino más cercano para cada punto, que es la última columna de la matriz de distancias. Estas distancias se utilizarán para crear el gráfico k-dist, que nos ayudará a estimar un valor adecuado para ε en DBSCAN.
    k_distances_sorted = np.sort(k_distances)[::-1] # orden descendente para facilitar la identificación del "codo" en el gráfico k-dist, que es un punto donde la pendiente cambia significativamente, indicando una posible elección para ε. Al ordenar las distancias en orden descendente, podemos visualizar claramente este cambio en la pendiente y hacer una estimación informada de ε para DBSCAN.

    # 2. Calcular primera y segunda derivada
    grad_1 = np.gradient(k_distances_sorted) # Calculamos la primera derivada (gradiente) de las distancias ordenadas para analizar cómo cambian las distancias a medida que avanzamos por los puntos. Esto nos ayudará a identificar el punto donde la pendiente cambia significativamente, lo que es crucial para estimar un valor adecuado de ε en DBSCAN.
    grad_2 = np.gradient(grad_1) # Calculamos la segunda derivada de las distancias ordenadas para analizar la curvatura de la curva k-dist. La curvatura nos ayudará a identificar el punto de máxima curvatura, que es un indicador clave para estimar un valor adecuado de ε en DBSCAN, ya que representa el punto donde la pendiente cambia más abruptamente.
    curvature = np.abs(grad_2) / (1 + grad_1**2)**1.5 # Calculamos la curvatura utilizando la fórmula de curvatura para curvas en 2D, que nos permite identificar el punto de máxima curvatura en la curva k-dist. Este punto es crucial para estimar un valor adecuado de ε en DBSCAN, ya que representa el punto donde la pendiente cambia más abruptamente, indicando una posible separación entre clusters y ruido.

    # 3. Buscar punto de máxima curvatura en el 20% inicial
    idx_max = np.argmax(curvature[:int(0.2 * len(curvature))]) # Buscamos el índice del punto de máxima curvatura en el primer 20% de la curva k-dist, ya que es más probable que el "codo" o punto de inflexión se encuentre en esta región. Este índice nos ayudará a estimar un valor adecuado de ε para DBSCAN, ya que representa el punto donde la pendiente cambia más abruptamente, indicando una posible separación entre clusters y ruido.
    eps = k_distances_sorted[idx_max] # Estimamos ε como la distancia al k-ésimo vecino correspondiente al punto de máxima curvatura, ya que este punto representa el cambio más significativo en la pendiente de la curva k-dist, lo que sugiere una posible separación entre clusters y ruido. Este valor de ε se utilizará para configurar el parámetro eps en DBSCAN, lo que nos permitirá identificar clusters de manera más efectiva y separar el ruido de los datos.
    print(eps)
    # 4. Graficar
    plt.figure(figsize=(8, 4))
    plt.plot(range(1, len(k_distances_sorted)+1), k_distances_sorted, marker='o', label='k-distancia')
    plt.axvline(idx_max + 1, color='red', linestyle='--', label=f'ε ≈ {eps:.2f}')
    plt.xlabel("Puntos (orden descendente)")
    plt.ylabel(f"{k}-dist")
    plt.title(f"Estimación de ε usando curvatura")
    plt.legend()
    plt.grid(True)
    plt.show()

    return eps

# Dendrograma para Agglomerative
def plot_dendrogram(X):
    Z = linkage(X, 'ward')
    plt.figure(figsize=(10, 5))
    dendrogram(Z)
    plt.title('Dendrograma (Agglomerative Clustering)')
    plt.xlabel('Puntos')
    plt.ylabel('Distancia')
    plt.show()
    return 2  # para make_moons puede ser 4–6 según la altura

# Obtener parámetros óptimos
optimal_k = find_optimal_k(X)
optimal_eps = find_optimal_eps(X)
optimal_clusters = plot_dendrogram(X)

# Aplicar clustering
kmeans = KMeans(n_clusters=optimal_k, random_state=0).fit_predict(X)
dbscan = DBSCAN(eps=optimal_eps, min_samples=5).fit_predict(X)
agglo = AgglomerativeClustering(n_clusters=optimal_clusters).fit_predict(X)

# Visualizar
titles = ['K-Means', 'Clustering Jerárquico', 'DBSCAN']
clusters = [kmeans, agglo, dbscan]

fig, axs = plt.subplots(1, 3, figsize=(15, 4))
for i, (title, labels) in enumerate(zip(titles, clusters)):
    axs[i].scatter(X[:, 0], X[:, 1], c=labels, cmap='tab10', edgecolor='k')
    axs[i].set_title(title)
    axs[i].set_xticks([])
    axs[i].set_yticks([])
plt.tight_layout()
plt.show()
0.15994716734133904
In [23]:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs, make_moons, make_circles
from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors
from matplotlib.colors import ListedColormap

# 1. Crear datasets: blobs, lunas, círculos + ruido
# X1, _ = make_blobs(n_samples=300, centers=4, cluster_std=[1.0, 2.0, 0.5, 0.2], random_state=42)
X1, _ = make_blobs(n_samples=300, centers=4, cluster_std=[1.0, 1.0, 0.5, 1], random_state=10) 
X2, _ = make_moons(n_samples=300, noise=0.05, random_state=0) # noise=0.05 para agregar un poco de ruido a las lunas, lo que hace que el gráfico k-dist sea más realista y muestre un "codo" más claro, facilitando la identificación de un valor adecuado de ε para DBSCAN. Esto también ayuda a ilustrar cómo el método del codo puede ser efectivo incluso en conjuntos de datos con formas no convexas y algo de ruido.
X3, _ = make_circles(n_samples=300, factor=0.4, noise=0.02, random_state=42) # factor=0.2 para círculos más cercanos, lo que hace que el "codo" en el gráfico k-dist sea más pronunciado y fácil de identificar, lo que a su vez facilita la estimación de ε para DBSCAN. Esto es especialmente útil para ilustrar cómo la curvatura puede ayudar a identificar un valor adecuado de ε en conjuntos de datos con formas complejas.
# X3 = np.vstack([X3, np.random.RandomState(42).uniform(low=-1, high=1, size=(30, 2))])
X3 = np.vstack([X3, np.random.RandomState(42).uniform(low=-2, high=2, size=(10, 2))])# Agregar más ruido para hacer el gráfico k-dist más realista y mostrar un "codo" más claro, lo que facilita la identificación de un valor adecuado de ε para DBSCAN. Esto también ayuda a ilustrar cómo el método del codo puede ser efectivo incluso en conjuntos de datos con formas no convexas y algo de ruido.

datasets = [X1, X2, X3]
titles = ['Dataset 1: blobs', 'Dataset 2: lunas', 'Dataset 3: círculos + ruido']

# 2. Parámetro para DBSCAN
min_samples = 5

# 3. Función para estimar eps con curvatura y orden descendente ajustado
def estimate_eps_curvature_descending(X, min_samples=5):
    # Calcular distancias al k-ésimo vecino más cercano
    neigh = NearestNeighbors(n_neighbors=min_samples) # Inicializamos el modelo de vecinos más cercanos con min_samples vecinos. Este modelo se utilizará para calcular las distancias entre cada punto y sus min_samples vecinos más cercanos, lo que es fundamental para estimar el parámetro ε en DBSCAN.
    neigh.fit(X) # Ajustamos el modelo de vecinos más cercanos a los datos X para que pueda calcular las distancias entre cada punto y sus min_samples vecinos más cercanos. Esto es un paso necesario antes de llamar a kneighbors para obtener las distancias.
    dists, _ = neigh.kneighbors(X) # Calculamos las distancias entre cada punto y sus min_samples vecinos más cercanos utilizando el método kneighbors del modelo de vecinos más cercanos. Esto nos devuelve una matriz de distancias y una matriz de índices que indican qué puntos son los vecinos más cercanos para cada punto en el dataset.
    k_dists = dists[:, -1] # Obtenemos la distancia al min_samples-ésimo vecino más cercano para cada punto, que es la última columna de la matriz de distancias. Estas distancias se utilizarán para crear el gráfico k-dist, que nos ayudará a estimar un valor adecuado para ε en DBSCAN.
    sorted_indices = np.argsort(-k_dists)  # orden descendente
    k_dists_sorted = k_dists[sorted_indices] # Ordenamos las distancias al min_samples-ésimo vecino en orden descendente para facilitar la identificación del "codo" en el gráfico k-dist, que es un punto donde la pendiente cambia significativamente, indicando una posible elección para ε. Al ordenar las distancias en orden descendente, podemos visualizar claramente este cambio en la pendiente y hacer una estimación informada de ε para DBSCAN.

    # Calcular curvatura con puntos suavizados
    first_deriv = np.gradient(k_dists_sorted) # Calculamos la primera derivada (gradiente) de las distancias ordenadas para analizar cómo cambian las distancias a medida que avanzamos por los puntos. Esto nos ayudará a identificar el punto donde la pendiente cambia significativamente, lo que es crucial para estimar un valor adecuado de ε en DBSCAN.
    second_deriv = np.gradient(first_deriv) # Calculamos la segunda derivada de las distancias ordenadas para analizar la curvatura de la curva k-dist. La curvatura nos ayudará a identificar el punto de máxima curvatura, que es un indicador clave para estimar un valor adecuado de ε en DBSCAN, ya que representa el punto donde la pendiente cambia más abruptamente.
    curvature = np.abs(second_deriv) / (1 + first_deriv**2)**1.5 # Calculamos la curvatura utilizando la fórmula de curvatura para curvas en 2D, que nos permite identificar el punto de máxima curvatura en la curva k-dist. Este punto es crucial para estimar un valor adecuado de ε en DBSCAN, ya que representa el punto donde la pendiente cambia más abruptamente, indicando una posible separación entre clusters y ruido.

    # Mejor estrategia: buscar el codo más marcado antes del 30% inicial
    max_range = int(len(k_dists_sorted) * 0.3) # Buscamos el índice del punto de máxima curvatura en el primer 30% de la curva k-dist, ya que es más probable que el "codo" o punto de inflexión se encuentre en esta región. Este índice nos ayudará a estimar un valor adecuado de ε para DBSCAN, ya que representa el punto donde la pendiente cambia más abruptamente, indicando una posible separación entre clusters y ruido.
    elbow_idx = np.argmax(curvature[:max_range]) # Obtenemos el índice del punto de máxima curvatura dentro del rango especificado, lo que nos ayudará a estimar un valor adecuado de ε para DBSCAN, ya que representa el punto donde la pendiente cambia más abruptamente, indicando una posible separación entre clusters y ruido.
    eps = k_dists_sorted[elbow_idx] # Estimamos ε como la distancia al min_samples-ésimo vecino correspondiente al punto de máxima curvatura, ya que este punto representa el cambio más significativo en la pendiente de la curva k-dist, lo que sugiere una posible separación entre clusters y ruido. Este valor de ε se utilizará para configurar el parámetro eps en DBSCAN, lo que nos permitirá identificar clusters de manera más efectiva y separar el ruido de los datos.
    return eps, k_dists_sorted, elbow_idx # Devolvemos el valor estimado de ε, las distancias ordenadas y el índice del codo para su uso en la visualización.

# 4. Visualización
fig, axs = plt.subplots(2, 3, figsize=(18, 8))
cmap = plt.cm.get_cmap("tab10", 10)
color_outlier = (0.3, 0.3, 0.3, 1.0)  # gris oscuro

for i, (X, title) in enumerate(zip(datasets, titles)): # Iteramos sobre cada dataset y su título correspondiente para realizar el análisis y visualización. En cada iteración, estimaremos el valor de ε utilizando la función de curvatura, aplicaremos DBSCAN con ese valor, y luego visualizaremos tanto el gráfico k-dist como los resultados del clustering en subplots separados para cada dataset.
    # Estimar ε con curvatura corregida
    eps, k_dists, elbow_idx = estimate_eps_curvature_descending(X, min_samples=min_samples) # Llamamos a la función estimate_eps_curvature_descending para estimar el valor de ε utilizando la curvatura de la curva k-dist. Esta función devuelve el valor estimado de ε, las distancias ordenadas y el índice del codo, que se utilizarán para configurar DBSCAN y para la visualización del gráfico k-dist.

    # --- SUBPLOT SUPERIOR: gráfico k-dist estilo paper ---
    axs[0, i].plot(range(1, len(k_dists) + 1), k_dists, color='black', marker='o', markersize=2, label='k-distancia')
    axs[0, i].axvline(x=elbow_idx + 1, color='red', linestyle='--', label=f'ε ≈ {eps:.2f}')
    axs[0, i].set_title(f"{title}")
    axs[0, i].set_xlabel("Puntos (orden descendente)")
    axs[0, i].set_ylabel(f"{min_samples}-dist")
    axs[0, i].legend()
    axs[0, i].grid(True)

    # --- DBSCAN ---
    labels = DBSCAN(eps=eps, min_samples=min_samples).fit_predict(X) # Aplicamos el algoritmo DBSCAN a los datos X utilizando el valor estimado de ε y el mínimo de muestras. Esto nos devuelve las etiquetas de cluster asignadas a cada punto, donde cada etiqueta indica a qué cluster pertenece cada punto o si es considerado ruido (etiqueta -1).
    unique_labels = sorted(set(labels)) # Obtenemos el conjunto de etiquetas únicas para identificar los diferentes clusters y el ruido. Esto nos permitirá contar cuántos clusters se han formado y cómo se distribuyen los puntos entre ellos, lo que es esencial para la visualización del clustering en el subplot inferior.
    n_clusters = len([l for l in unique_labels if l != -1]) # Contamos el número de clusters formados, excluyendo la etiqueta -1 que representa el ruido. Esto nos dará una idea de cuántos grupos distintos se han identificado en los datos utilizando DBSCAN, lo que es importante para interpretar los resultados del clustering y para la visualización en el subplot inferior.

    # --- SUBPLOT INFERIOR: visualización del clustering ---
    for label in unique_labels:
        mask = (labels == label)
        color = cmap(label % 10) if label != -1 else color_outlier
        size = 40 if label != -1 else 10
        axs[1, i].scatter(X[mask, 0], X[mask, 1], c=[color], s=size, edgecolor='k', marker='o')

    axs[1, i].set_title(f"{title}\n(DBSCAN: ε ≈ {eps:.2f}, {n_clusters} clústeres)")
    axs[1, i].set_xticks([])
    axs[1, i].set_yticks([])

plt.tight_layout()
plt.show()
/var/folders/q8/5g879lls62l1z8smyjkh4h700000gn/T/ipykernel_79733/3631474504.py:45: MatplotlibDeprecationWarning: The get_cmap function was deprecated in Matplotlib 3.7 and will be removed in 3.11. Use ``matplotlib.colormaps[name]`` or ``matplotlib.colormaps.get_cmap()`` or ``pyplot.get_cmap()`` instead.
  cmap = plt.cm.get_cmap("tab10", 10)

DBSCAN sobre los tres conjuntos de datos de la Figura 1 del paper. Esta figura muestra cómo DBSCAN resuelve con éxito distintos desafíos que presentan otras técnicas de clustering:

Interpretación:

  1. Dataset 1: blobs (esféricos, distintos tamaños)

    • DBSCAN detecta los 4 clústeres correctamente, sin necesidad de especificar su número.
    • Agrupa bien incluso con distintas densidades internas.
  2. Dataset 2: lunas (forma no convexa)

    • A diferencia de k-means o clustering jerárquico, DBSCAN respeta la forma curva.
    • Separa perfectamente las dos lunas sin asumir formas esféricas.
  3. Dataset 3: círculos + ruido

    • DBSCAN detecta los dos anillos y marca como ruido algunos puntos aleatorios agregados.
    • Otros algoritmos forzarían esos puntos a pertenecer a algún clúster.

Conclusión general:

DBSCAN destaca por:

  • Detectar clústeres de forma arbitraria.
  • Manejar bien diferentes densidades.
  • Excluir outliers como ruido.
  • No necesitar el número de clústeres como parámetro.

Pero ¿qué pasa si los clústeres tienen distintas densidades?

DBSCAN funciona muy bien cuando una sola escala de densidad permite separar los grupos. El problema aparece cuando distintos clústeres tienen densidades muy diferentes: un valor de eps que sirve para un grupo puede ser demasiado grande o demasiado pequeño para otro. Ese es uno de los límites clásicos de DBSCAN.

HDBSCAN: idea central

HDBSCAN (Hierarchical Density-Based Spatial Clustering of Applications with Noise) extiende DBSCAN para manejar mejor este problema. En vez de fijar un único valor de eps y obtener una sola partición, construye una jerarquía de clústeres a distintos niveles de densidad y luego selecciona los grupos más estables.

La intuición es simple:

si cambiamos el nivel de densidad exigido, algunos clústeres aparecen, otros se dividen y otros desaparecen; HDBSCAN organiza esa información jerárquicamente y conserva los clústeres más estables.


¿En qué se diferencia de DBSCAN?

DBSCAN

  • usa una noción local de densidad;
  • requiere fijar eps y minPts;
  • identifica ruido explícitamente;
  • funciona bien cuando los clústeres tienen una densidad relativamente homogénea. :contentReference[oaicite:2]{index=2}

HDBSCAN

  • evita fijar un único eps;
  • construye una jerarquía de clústeres según densidad;
  • selecciona automáticamente los clústeres más estables;
  • suele funcionar mejor cuando hay clústeres con diferentes densidades. :contentReference[oaicite:3]{index=3}

Comparación rápida

Característica DBSCAN HDBSCAN
¿Requiere eps? No, no como parámetro principal
¿Detecta ruido?
¿Permite clústeres no convexos?
¿Maneja bien densidades muy distintas? Limitado Mejor
¿Produce jerarquía? No

Parámetros principales

En la práctica, HDBSCAN suele trabajar con parámetros como:

  • min_cluster_size: tamaño mínimo de clúster;
  • min_samples: controla qué tan conservadora es la noción de densidad.

A diferencia de DBSCAN, el usuario no necesita escoger directamente un único radio global eps, que suele ser la parte más delicada.


¿Cuándo conviene usar HDBSCAN?

HDBSCAN suele ser preferible cuando:

  • los clústeres tienen formas irregulares;
  • distintas regiones del dataset tienen densidades diferentes;
  • hay bastante ruido;
  • no quieres fijar manualmente un único valor de eps.

Resumen

DBSCAN encuentra clústeres usando una sola escala de densidad. HDBSCAN extiende esa idea construyendo una jerarquía de densidades y extrayendo los clústeres más estables.

Papers

  • Campello, R. J. G. B., Moulavi, D., & Sander, J. (2013). Density-Based Clustering Based on Hierarchical Density Estimates. PAKDD.

  • Campello, R. J. G. B., Moulavi, D., Zimek, A., & Sander, J. (2015). Hierarchical Density Estimates for Data Clustering, Visualization, and Outlier Detection. ACM TKDD. https://dl.acm.org/doi/pdf/10.1145/3448016.3457296

Robustez de los clústeres en HDBSCAN

HDBSCAN evalúa la robustez de un clúster a partir de su estabilidad en distintas escalas de densidad.

La idea es simple: si un grupo de puntos sólo aparece para una elección muy particular de densidad y desaparece de inmediato, ese grupo no es muy robusto. En cambio, si un clúster persiste a lo largo de un rango amplio de niveles de densidad, entonces es más estable y más confiable.

Por eso, HDBSCAN no se queda con cualquier partición posible, sino con los clústeres que sobreviven de forma más consistente en la jerarquía.

In [ ]:
#pip install hdbscan # Descomentar y correr una vez para instalar HDBSCAN
Collecting hdbscan
  Downloading hdbscan-0.8.40-cp310-cp310-macosx_12_0_x86_64.whl.metadata (15 kB)
Requirement already satisfied: numpy<3,>=1.20 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from hdbscan) (1.26.2)
Requirement already satisfied: scipy>=1.0 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from hdbscan) (1.11.4)
Requirement already satisfied: scikit-learn>=0.20 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from hdbscan) (1.3.2)
Requirement already satisfied: joblib>=1.0 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from hdbscan) (1.3.2)
Requirement already satisfied: threadpoolctl>=2.0.0 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from scikit-learn>=0.20->hdbscan) (3.2.0)
Downloading hdbscan-0.8.40-cp310-cp310-macosx_12_0_x86_64.whl (813 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 813.7/813.7 kB 10.9 MB/s eta 0:00:00a 0:00:01
Installing collected packages: hdbscan
Successfully installed hdbscan-0.8.40
In [28]:
import hdbscan
import matplotlib.pyplot as plt

# Lista de datasets y nombres
datasets = [X1, X2, X3]
titles = ['Dataset 1: blobs', 'Dataset 2: lunas', 'Dataset 3: círculos + ruido']

# Crear figura
fig, axs = plt.subplots(1, 3, figsize=(18, 5))
cmap = plt.cm.get_cmap("tab10", 10)
color_outlier = (0.3, 0.3, 0.3, 1.0)

for i, (X, title) in enumerate(zip(datasets, titles)):
    # Ajustar HDBSCAN
    clusterer = hdbscan.HDBSCAN(min_cluster_size=10) # Inicializamos el modelo HDBSCAN con un tamaño mínimo de cluster de 10. Este parámetro controla el tamaño mínimo que debe tener un cluster para ser considerado válido, lo que ayuda a evitar la formación de clusters muy pequeños que podrían ser considerados ruido. Al ajustar este parámetro, podemos influir en la cantidad de clusters que HDBSCAN identificará en los datos.
    labels = clusterer.fit_predict(X) # Aplicamos el algoritmo HDBSCAN a los datos X utilizando el método fit_predict, que ajusta el modelo a los datos y devuelve las etiquetas de cluster asignadas a cada punto. Las etiquetas indican a qué cluster pertenece cada punto, o si es considerado ruido (etiqueta -1).
    unique_labels = sorted(set(labels)) # Obtenemos el conjunto de etiquetas únicas para identificar los diferentes clusters y el ruido. Esto nos permitirá contar cuántos clusters se han formado y cómo se distribuyen los puntos entre ellos, lo que es esencial para la visualización del clustering en el subplot inferior.
    n_clusters = len([l for l in unique_labels if l != -1]) # Contamos el número de clusters formados, excluyendo la etiqueta -1 que representa el ruido. Esto nos dará una idea de cuántos grupos distintos se han identificado en los datos utilizando HDBSCAN, lo que es importante para interpretar los resultados del clustering y para la visualización en el subplot inferior.

    # Visualización
    for label in unique_labels:
        mask = (labels == label)
        color = cmap(label % 10) if label != -1 else color_outlier
        size = 40 if label != -1 else 10
        axs[i].scatter(X[mask, 0], X[mask, 1], c=[color], s=size, edgecolor='k', marker='o')
    
    axs[i].set_title(f"{title}\n(HDBSCAN: {n_clusters} clústeres)")
    axs[i].set_xticks([])
    axs[i].set_yticks([])

plt.tight_layout()
plt.show()
/var/folders/q8/5g879lls62l1z8smyjkh4h700000gn/T/ipykernel_79733/2344256030.py:10: MatplotlibDeprecationWarning: The get_cmap function was deprecated in Matplotlib 3.7 and will be removed in 3.11. Use ``matplotlib.colormaps[name]`` or ``matplotlib.colormaps.get_cmap()`` or ``pyplot.get_cmap()`` instead.
  cmap = plt.cm.get_cmap("tab10", 10)
/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/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/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/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/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/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(

¿Cuándo NO usar HDBSCAN?

Aunque HDBSCAN es muy potente, no siempre es la mejor opción. Aquí algunos casos donde puede no ser adecuado:

1. Cuando necesitas predecir en nuevos datos (.predict)

  • HDBSCAN no tiene un método .predict() estándar como K-Means o DBSCAN en scikit-learn.
  • Si necesitas clasificar nuevos datos rápidamente (por ejemplo, en producción), HDBSCAN no es ideal sin técnicas extra como soft clustering o modelos auxiliares.

2. Cuando los datos tienen clústeres muy pequeños

  • Si tus clústeres tienen menos puntos que el min_cluster_size, HDBSCAN los tratará como ruido.
  • Esto puede ser un problema si todos los clústeres son pequeños (por ejemplo, entidades raras o atípicas).

3. Cuando necesitas controlar explícitamente eps

  • HDBSCAN busca eps de forma implícita.
  • Si tú necesitas usar un valor de eps específico por alguna razón del dominio, DBSCAN puede ser más controlable.

4. Cuando necesitas alta velocidad con datos muy grandes

  • HDBSCAN es más lento que DBSCAN porque construye y procesa una jerarquía completa de densidad.
  • En datasets con millones de puntos, puede ser computacionalmente costoso.

En resumen

Usa HDBSCAN cuando quieras robustez y adaptabilidad.
Evítalo cuando necesites velocidad, predicción directa o control fino sobre los parámetros.

Parámetros principales de HDBSCAN

HDBSCAN extiende DBSCAN al construir una jerarquía de densidad y extraer los clústeres más estables. Sus parámetros controlan cómo se construyen y seleccionan los clústeres.


1. min_cluster_size

¿Qué es?
El número mínimo de puntos que debe tener un clúster para ser considerado válido.

¿Para qué sirve?
Controla la granularidad de los clústeres.

  • Valores más grandes ⇒ menos clústeres, más grandes y compactos.
  • Valores más pequeños ⇒ más clústeres pequeños y menos puntos clasificados como ruido.

Ejemplo:

HDBSCAN(min_cluster_size=5)

2. min_samples (opcional)

¿Qué es?
Cantidad mínima de vecinos para que un punto sea considerado denso (como en DBSCAN).
Si no se especifica, se asume igual a min_cluster_size.

¿Para qué sirve?
Aumenta la robustez frente al ruido:

  • Valores mayores ⇒ más puntos se etiquetan como ruido (modelo más conservador).
  • Valores menores ⇒ más puntos se incluyen en clústeres (modelo más permisivo).

Ejemplo:

HDBSCAN(min_cluster_size=5, min_samples=3)

3. cluster_selection_method

¿Qué es?
Determina cómo se eligen los clústeres finales desde la jerarquía de densidad.

Opciones:

  • 'eom' (Excess of Mass) → más conservador, selecciona clústeres bien definidos.
  • 'leaf'más fino y detallado, selecciona todos los clústeres hoja, incluso los más pequeños.

¿Para qué sirve?
Permite controlar si quieres clústeres grandes y robustos o más pequeños y numerosos.

Ejemplo:

HDBSCAN(min_cluster_size=5, cluster_selection_method='leaf')

4. metric

¿Qué es?
Métrica de distancia a utilizar. Por defecto es 'euclidean'.

¿Para qué sirve?
Permite adaptar HDBSCAN a distintos tipos de datos:

  • 'euclidean', 'manhattan', etc.
  • También puedes usar una función de distancia personalizada.

Ejemplo para coordenadas geográficas:

HDBSCAN(metric='euclidean')

(Nota: Si usas haversine, asegúrate de pasar los datos en radianes)


5. prediction_data=True

¿Para qué sirve?
Guarda datos adicionales que permiten asignar nuevos puntos a clústeres existentes (approximate_predict) y ver la probabilidad de pertenencia (.probabilities_).

Ejemplo:

HDBSCAN(prediction_data=True)

¿Cómo elegir los parámetros?

Objetivo Recomendación
Clústeres grandes y robustos min_cluster_size=10, min_samples=10, 'eom'
Detectar clústeres pequeños min_cluster_size=3, min_samples=2, 'leaf'
Reducir ruido Disminuir min_samples, usar 'leaf'
Evitar clústeres pequeños Aumentar min_cluster_size

Ejercicios: Clustering con DBSCAN y HDBSCAN usando Datos Reales

Dataset: "Mall Customers" (segmentación de clientes)


Ejercicio 1: Cargar y explorar los datos

In [30]:
import pandas as pd

df = pd.read_csv("./Datasets/Mall_Customers.csv")
df.head()
Out[30]:
CustomerID Gender Age Annual Income (k$) Spending Score (1-100)
0 1 Male 19 15 39
1 2 Male 21 15 81
2 3 Female 20 16 6
3 4 Female 23 16 77
4 5 Female 31 17 40

Preguntas:

  • ¿Qué variables parecen útiles para clustering?

  • ¿Cómo están distribuidos los ingresos y el score de gasto?

Ejercicio 2: Visualización inicial de los datos

In [31]:
import matplotlib.pyplot as plt

plt.scatter(df['Annual Income (k$)'], df['Spending Score (1-100)'], edgecolor='k')
plt.xlabel("Ingreso Anual")
plt.ylabel("Score de Gasto")
plt.title("Distribución de Clientes")
plt.show()

Preguntas:

  • ¿Observas alguna estructura que DBSCAN podría detectar mejor que KMeans?

Ejercicio 3: Aplicar DBSCAN con estimación de $ \varepsilon $

  • Usa el gráfico de k-distancia para estimar $ \varepsilon $.

  • Aplica DBSCAN con min_samples = 5.

In [33]:
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors
import numpy as np

X = df[['Annual Income (k$)', 'Spending Score (1-100)']].values
X = StandardScaler().fit_transform(X)

# Estimar eps con k-dist
k = 5
nbrs = NearestNeighbors(n_neighbors=k).fit(X)
dists, _ = nbrs.kneighbors(X)
k_dists = np.sort(dists[:, -1])[::-1]

plt.plot(k_dists)
plt.title(f"k-distancia (k={k})")
plt.xlabel("Puntos ordenados")
plt.ylabel("Distancia")
plt.grid(True)
plt.show()

Ejercicio 4: Visualiza los clústeres encontrados por DBSCAN

In [34]:
# Aplicar DBSCAN (ajusta el valor estimado de eps manualmente)
dbscan = DBSCAN(eps=0.35, min_samples=5)
labels = dbscan.fit_predict(X)


# Visualización DBSCAN con 'X' para el ruido
plt.figure(figsize=(6, 5))
for label in set(labels):
    mask = (labels == label)
    if label == -1:
        # Ruido
        plt.scatter(X[mask, 0], X[mask, 1], c='gray', marker='x', label='Ruido', alpha=0.6)
    else:
        plt.scatter(X[mask, 0], X[mask, 1], label=f'Clúster {label}', edgecolor='k')
plt.legend()
plt.title("Clústeres con DBSCAN (ruido = X)")
plt.xlabel("Ingreso (escalado)")
plt.ylabel("Score de Gasto (escalado)")
plt.grid(True)
plt.show()

Ejercicio 5: Aplicar HDBSCAN

In [38]:
import hdbscan

clusterer = hdbscan.HDBSCAN(min_cluster_size=5)#,min_samples=3,cluster_selection_method='eom')
hdb_labels = clusterer.fit_predict(X)


plt.figure(figsize=(6, 5))
for label in set(hdb_labels):
    mask = (hdb_labels == label)
    if label == -1:
        # Ruido
        plt.scatter(X[mask, 0], X[mask, 1], c='gray', marker='x', label='Ruido', alpha=0.6)
    else:
        plt.scatter(X[mask, 0], X[mask, 1], label=f'Clúster {label}', edgecolor='k')
plt.legend()
plt.title("Clústeres con HDBSCAN (ruido = X)")
plt.xlabel("Ingreso (escalado)")
plt.ylabel("Score de Gasto (escalado)")
plt.grid(True)
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/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(
Comparación:
  • ¿Cuántos clústeres detecta DBSCAN vs HDBSCAN?

  • ¿Cuál maneja mejor el ruido?

  • ¿Cuál genera clústeres más naturales?

Ejercicio: Restaurantes en Santiago

In [ ]:
# !pip install geopandas
Collecting geopandas
  Downloading geopandas-1.0.1-py3-none-any.whl.metadata (2.2 kB)
Requirement already satisfied: numpy>=1.22 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from geopandas) (1.26.2)
Collecting pyogrio>=0.7.2 (from geopandas)
  Downloading pyogrio-0.10.0-cp310-cp310-macosx_12_0_x86_64.whl.metadata (5.5 kB)
Requirement already satisfied: packaging in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from geopandas) (23.2)
Requirement already satisfied: pandas>=1.4.0 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from geopandas) (2.1.3)
Collecting pyproj>=3.3.0 (from geopandas)
  Downloading pyproj-3.7.1-cp310-cp310-macosx_13_0_x86_64.whl.metadata (31 kB)
Collecting shapely>=2.0.0 (from geopandas)
  Downloading shapely-2.0.7-cp310-cp310-macosx_10_9_x86_64.whl.metadata (6.8 kB)
Requirement already satisfied: python-dateutil>=2.8.2 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from pandas>=1.4.0->geopandas) (2.8.2)
Requirement already satisfied: pytz>=2020.1 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from pandas>=1.4.0->geopandas) (2023.3.post1)
Requirement already satisfied: tzdata>=2022.1 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from pandas>=1.4.0->geopandas) (2023.3)
Requirement already satisfied: certifi in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from pyogrio>=0.7.2->geopandas) (2025.1.31)
Requirement already satisfied: six>=1.5 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from python-dateutil>=2.8.2->pandas>=1.4.0->geopandas) (1.16.0)
Downloading geopandas-1.0.1-py3-none-any.whl (323 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 323.6/323.6 kB 6.4 MB/s eta 0:00:00:00:01
Downloading pyogrio-0.10.0-cp310-cp310-macosx_12_0_x86_64.whl (16.5 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 16.5/16.5 MB 1.4 MB/s eta 0:00:0000:0100:01
Downloading pyproj-3.7.1-cp310-cp310-macosx_13_0_x86_64.whl (6.3 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.3/6.3 MB 4.8 MB/s eta 0:00:0000:0100:01m
WARNING: Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'ReadTimeoutError("HTTPSConnectionPool(host='files.pythonhosted.org', port=443): Read timed out. (read timeout=15)")': /packages/15/2e/02c694d6ddacd4f13b625722d313d2838f23c5b988cbc680132983f73ce3/shapely-2.0.7-cp310-cp310-macosx_10_9_x86_64.whl
Downloading shapely-2.0.7-cp310-cp310-macosx_10_9_x86_64.whl (1.5 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.5/1.5 MB 8.0 MB/s eta 0:00:0000:0100:01
Installing collected packages: shapely, pyproj, pyogrio, geopandas
Successfully installed geopandas-1.0.1 pyogrio-0.10.0 pyproj-3.7.1 shapely-2.0.7

Paso 1: Cargar y visualizar datos

In [41]:
import geopandas as gpd
import pandas as pd

# Leer el archivo GeoJSON
gdf = gpd.read_file("./Datasets/stgo_rest.geojson") # Cargar el archivo GeoJSON que contiene la información de los restaurantes en Santiago. Este archivo se leerá utilizando la función read_file de GeoPandas, lo que nos permitirá trabajar con los datos geoespaciales y extraer la información relevante para el análisis.

# Extraer latitud y longitud desde la geometría
gdf['lon'] = gdf.geometry.x # Extraemos la coordenada de longitud (x) de la geometría de cada restaurante y la almacenamos en una nueva columna llamada 'lon' en el GeoDataFrame. Esto nos permitirá tener las coordenadas geográficas de cada restaurante para su posterior análisis y visualización.
gdf['lat'] = gdf.geometry.y # Extraemos la coordenada de latitud (y) de la geometría de cada restaurante y la almacenamos en una nueva columna llamada 'lat' en el GeoDataFrame. Esto nos permitirá tener las coordenadas geográficas de cada restaurante para su posterior análisis y visualización.

# Filtrar solo columnas útiles
df = gdf[['name', 'cuisine', 'lat', 'lon']].dropna(subset=['lat', 'lon']) # Seleccionamos solo las columnas relevantes para el análisis: 'name' (nombre del restaurante), 'cuisine' (tipo de cocina), 'lat' (latitud) y 'lon' (longitud). Además, eliminamos cualquier fila que tenga valores faltantes en las columnas de latitud o longitud para asegurarnos de que todos los restaurantes en el dataset tengan coordenadas geográficas válidas para su análisis y visualización.

# Mostrar muestra
df.head(20)
Out[41]:
name cuisine lat lon
0 Matsuri japanese -33.403397 -70.582749
1 Le Fournil french -33.400988 -70.592712
2 Pizza Hut pizza -33.454214 -70.649937
3 Macharoas None -33.428060 -70.593501
4 None chinese -33.436799 -70.601202
5 El Toro None -33.433050 -70.642252
6 Fuente Alemana sandwich;german -33.437653 -70.636030
7 Gatsby coffee_shop -33.401881 -70.579564
8 Chunghwa chinese -33.410424 -70.573776
9 Delhi Darbar indian -33.421153 -70.606996
10 Giratorio None -33.421634 -70.608598
11 Sur Patagonico None -33.439131 -70.641244
12 La Chimenea None -33.442834 -70.656724
13 Liguria chilean -33.418277 -70.602825
14 Coquinaria None -33.414041 -70.598711
15 Miguel Torres spanish -33.413649 -70.602693
16 Liguria (Manuel Montt) None -33.428512 -70.619085
17 Normandie None -33.429012 -70.621103
18 Mizu japanese -33.413616 -70.585396
19 Mamut steak_house -33.424188 -70.611134
In [42]:
plt.scatter(df['lon'], df['lat'], alpha=0.6, edgecolor='k')
plt.xlabel("Longitud")
plt.ylabel("Latitud")
plt.title("Restaurantes en Santiago")
plt.show()

Paso 2: Transformar coordenadas (necesario para DBSCAN)

In [ ]:
from sklearn.preprocessing import StandardScaler

X = df[['lat', 'lon']].values # Extraemos las coordenadas de latitud y longitud del DataFrame y las almacenamos en una matriz X. Esta matriz se utilizará como entrada para el análisis de clustering, donde cada fila representa un restaurante con sus respectivas coordenadas geográficas.
X_scaled = StandardScaler().fit_transform(X) # Escalamos las coordenadas de latitud y longitud utilizando StandardScaler para normalizar los datos antes de aplicar el algoritmo de clustering. Esto es importante porque las unidades de latitud y longitud pueden tener diferentes escalas, y el escalado asegura que ambos atributos contribuyan de manera equitativa al análisis de clustering, evitando que uno domine sobre el otro debido a su escala.

X_scaled

# ojo con esta normalización puede ocultar patrones con significado en el mundo real, como la densidad de restaurantes en ciertas áreas. 
# En algunos casos, podría ser más apropiado utilizar las coordenadas sin escalar o aplicar una transformación diferente que preserve 
# las relaciones espaciales entre los puntos. Es importante considerar el contexto del análisis y la naturaleza de los datos al decidir 
# cómo preprocesar las coordenadas geográficas para el clustering. 
# 
# El preprocesamiento nunca es neutro y puede influir significativamente 
# en los resultados del clustering, por lo que es crucial evaluar cuidadosamente las opciones de preprocesamiento en función de los objetivos 
# del análisis y la interpretación de los resultados. 
Out[ ]:
array([[ 1.33312037,  1.39842845],
       [ 1.41999422,  1.15118438],
       [-0.49935152, -0.26893165],
       ...,
       [-0.06624796,  0.31332155],
       [-0.06622633,  0.32449134],
       [-0.06369486,  0.32321826]])

Paso 3: Estimar $ \varepsilon $ y aplicar DBSCAN

In [20]:
from sklearn.neighbors import NearestNeighbors
from sklearn.cluster import DBSCAN
import numpy as np

# Gráfico k-distancia
k = 5
nbrs = NearestNeighbors(n_neighbors=k).fit(X_scaled) # Inicializamos el modelo de vecinos más cercanos con k vecinos. Este modelo se utilizará para calcular las distancias entre cada punto y sus k vecinos más cercanos, lo que es fundamental para estimar el parámetro ε en DBSCAN.
dists, _ = nbrs.kneighbors(X_scaled) # Calculamos las distancias entre cada punto y sus k vecinos más cercanos utilizando el método kneighbors del modelo de vecinos más cercanos. Esto nos devuelve una matriz de distancias y una matriz de índices que indican qué puntos son los vecinos más cercanos para cada punto en el dataset.
k_dists = np.sort(dists[:, -1])[::-1] # Ordenamos las distancias al k-ésimo vecino en orden descendente para facilitar la identificación del "codo" en el gráfico k-dist, que es un punto donde la pendiente cambia significativamente, indicando una posible elección para ε. Al ordenar las distancias en orden descendente, podemos visualizar claramente este cambio en la pendiente y hacer una estimación informada de ε para DBSCAN.

plt.plot(k_dists)
plt.title(f"k-distancia para estimar ε")
plt.xlabel("Puntos ordenados")
plt.ylabel(f"{k}-dist")
plt.grid(True)
plt.show()

# Ajustar valor de eps manualmente según el gráfico
dbscan = DBSCAN(eps=0.3, min_samples=5)
labels = dbscan.fit_predict(X_scaled)

plt.scatter(df['lon'], df['lat'], c=labels, cmap='tab10', edgecolor='k')
plt.title("Clústeres de Restaurantes (DBSCAN)")
plt.xlabel("Longitud")
plt.ylabel("Latitud")
plt.show()

Paso 4: Aplicar HDBSCAN

In [ ]:
import hdbscan

# Inicializamos el modelo HDBSCAN con un tamaño mínimo de cluster de 10. Este parámetro controla el tamaño mínimo que debe tener un cluster 
# para ser considerado válido, lo que ayuda a evitar la formación de clusters muy pequeños que podrían ser considerados ruido. 
# Al ajustar este parámetro, podemos influir en la cantidad de clusters que HDBSCAN identificará en los datos.
hdb = hdbscan.HDBSCAN(min_cluster_size=10) 
# Por qué 10?: En el contexto de clustering de restaurantes en Santiago, un tamaño mínimo de cluster de 10 puede ser una elección razonable 
# para identificar grupos significativos de restaurantes sin incluir clusters demasiado pequeños que podrían no ser representativos o podrían 
# ser considerados ruido. Este valor permite capturar áreas con una concentración moderada de restaurantes, lo que puede ser útil para 
# identificar zonas comerciales o barrios con una oferta gastronómica diversa. Sin embargo, la elección del tamaño mínimo de cluster debe 
# basarse en el conocimiento del dominio y en la distribución de los datos, por lo que es recomendable experimentar con diferentes valores 
# para encontrar el que mejor se adapte a las características específicas del dataset y a los objetivos del análisis.


# Aplicamos el algoritmo HDBSCAN a los datos escalados utilizando el método fit_predict, que ajusta el modelo a los datos y devuelve las 
# etiquetas de cluster asignadas a cada punto. Las etiquetas indican a qué cluster pertenece cada punto, o si es considerado ruido (etiqueta -1).
labels_hdb = hdb.fit_predict(X_scaled) 

plt.scatter(df['lon'], df['lat'], c=labels_hdb, cmap='tab10', edgecolor='k')
plt.title("Clústeres con HDBSCAN")
plt.xlabel("Longitud")
plt.ylabel("Latitud")
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/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(

Preguntas para discutir

  • ¿Dónde se concentran los restaurantes?

  • ¿Cuáles zonas aparecen como clústeres densos?

  • ¿Qué puntos fueron marcados como ruido?

  • ¿Qué algoritmo fue más útil para este caso?

Mapa Interactivo

In [ ]:
# !pip install folium # Descomentar y correr una vez para instalar Folium

# que es folium: Folium es una biblioteca de Python que facilita la creación de mapas interactivos utilizando Leaflet.js. 
# Permite visualizar datos geoespaciales de manera sencilla y atractiva, integrándose fácilmente con pandas y otros paquetes de análisis de datos.
Collecting folium
  Downloading folium-0.19.5-py2.py3-none-any.whl.metadata (4.1 kB)
Collecting branca>=0.6.0 (from folium)
  Downloading branca-0.8.1-py3-none-any.whl.metadata (1.5 kB)
Requirement already satisfied: jinja2>=2.9 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from folium) (3.1.2)
Requirement already satisfied: numpy in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from folium) (1.26.2)
Requirement already satisfied: requests in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from folium) (2.31.0)
Collecting xyzservices (from folium)
  Downloading xyzservices-2025.1.0-py3-none-any.whl.metadata (4.3 kB)
Requirement already satisfied: MarkupSafe>=2.0 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from jinja2>=2.9->folium) (2.1.1)
Requirement already satisfied: charset-normalizer<4,>=2 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from requests->folium) (3.3.2)
Requirement already satisfied: idna<4,>=2.5 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from requests->folium) (3.4)
Requirement already satisfied: urllib3<3,>=1.21.1 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from requests->folium) (2.1.0)
Requirement already satisfied: certifi>=2017.4.17 in /Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages (from requests->folium) (2025.1.31)
Downloading folium-0.19.5-py2.py3-none-any.whl (110 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 110.9/110.9 kB 2.2 MB/s eta 0:00:00a 0:00:01
Downloading branca-0.8.1-py3-none-any.whl (26 kB)
Downloading xyzservices-2025.1.0-py3-none-any.whl (88 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 88.4/88.4 kB 3.0 MB/s eta 0:00:00
Installing collected packages: xyzservices, branca, folium
Successfully installed branca-0.8.1 folium-0.19.5 xyzservices-2025.1.0
In [ ]:
import geopandas as gpd
import folium

# 1. Cargar el archivo GeoJSON
gdf = gpd.read_file("./Datasets/stgo_rest.geojson")

# 2. Extraer latitud y longitud
gdf['lon'] = gdf.geometry.x
gdf['lat'] = gdf.geometry.y

# 3. Limpiar y seleccionar columnas útiles
df = gdf[['name', 'cuisine', 'lat', 'lon']].dropna(subset=['lat', 'lon'])

# 4. Centro del mapa (puedes ajustar si lo deseas)
centro_santiago = [-33.45, -70.66]

# 5. Crear el mapa con folium
m = folium.Map(location=centro_santiago, zoom_start=12, # nivel de zoom inicial
    width='80%',        # ancho en porcentaje (ocupa todo el ancho de la celda)
    height='500px'       # alto en píxeles)
)

# 6. Agregar marcadores
for _, row in df.iterrows():
    popup = f"<b>{row['name']}</b><br>Cocina: {row['cuisine']}"
    folium.CircleMarker(
        location=[row['lat'], row['lon']],
        radius=4,
        color='blue',
        fill=True,
        fill_color='blue',
        fill_opacity=0.7,
        popup=popup
    ).add_to(m)

# 7. Mostrar el mapa
m
Out[ ]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Clusters y Mapa

In [ ]:
import geopandas as gpd
import pandas as pd
import folium
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import DBSCAN
import hdbscan
import matplotlib.cm as cm
import matplotlib.colors as mcolors
from folium.features import DivIcon

# 1. Leer archivo GeoJSON
gdf = gpd.read_file("./Datasets/stgo_rest.geojson")
gdf['lon'] = gdf.geometry.x
gdf['lat'] = gdf.geometry.y
df = gdf[['name', 'cuisine', 'lat', 'lon']].dropna()

# 2. Preprocesar coordenadas para clustering
X = df[['lat', 'lon']].values
X_scaled = StandardScaler().fit_transform(X)

# 3. Aplicar DBSCAN
dbscan = DBSCAN(eps=0.3, min_samples=5) # Ajusta eps según el gráfico k-distancia para obtener resultados más significativos. Un valor de eps demasiado bajo puede resultar en muchos puntos etiquetados como ruido, mientras que un valor demasiado alto puede agrupar puntos que no deberían estar juntos. Es importante experimentar con diferentes valores de eps para encontrar el equilibrio adecuado entre la identificación de clusters y la clasificación de ruido en el contexto específico de los restaurantes en Santiago.
labels_db = dbscan.fit_predict(X_scaled) # Aplicamos el algoritmo DBSCAN a los datos escalados utilizando el método fit_predict, que ajusta el modelo a los datos y devuelve las etiquetas de cluster asignadas a cada punto. Las etiquetas indican a qué cluster pertenece cada punto, o si es considerado ruido (etiqueta -1).
df['cluster_dbscan'] = labels_db # Agregamos las etiquetas de cluster obtenidas de DBSCAN al DataFrame original en una nueva columna llamada 'cluster_dbscan'. Esto nos permitirá identificar a qué cluster pertenece cada restaurante o si es considerado ruido, lo que será útil para la visualización y el análisis posterior.

# 4. Aplicar HDBSCAN
hdb = hdbscan.HDBSCAN( # Ajustamos el modelo HDBSCAN con parámetros específicos para identificar clusters de restaurantes en Santiago. El parámetro min_cluster_size controla el tamaño mínimo de cluster que se considerará válido, lo que ayuda a evitar la formación de clusters muy pequeños que podrían ser considerados ruido. El parámetro min_samples define el número mínimo de muestras en un cluster para que sea considerado como tal, lo que también ayuda a diferenciar entre clusters y ruido. El parámetro cluster_selection_method='leaf' indica que se seleccionarán los clusters basados en las hojas del árbol de clusterización, lo que puede ayudar a identificar clusters más pequeños y densos en el contexto de los restaurantes en Santiago.
    min_cluster_size=5,
    min_samples=3,
    cluster_selection_method='leaf'
)
# hdb = hdbscan.HDBSCAN(min_cluster_size=10)
labels_hdb = hdb.fit_predict(X_scaled) # Aplicamos el algoritmo HDBSCAN a los datos escalados utilizando el método fit_predict, que ajusta el modelo a los datos y devuelve las etiquetas de cluster asignadas a cada punto. Las etiquetas indican a qué cluster pertenece cada punto, o si es considerado ruido (etiqueta -1).
df['cluster_hdbscan'] = labels_hdb # Agregamos las etiquetas de cluster obtenidas de HDBSCAN al DataFrame original en una nueva columna llamada 'cluster_hdbscan'. Esto nos permitirá identificar a qué cluster pertenece cada restaurante o si es considerado ruido, lo que será útil para la visualización y el análisis posterior.




# 5. Crear función de colores para clusters
def create_color_map(labels):
    # Obtener etiquetas únicas y asignar colores diferentes a cada cluster, reservando un color específico para el ruido (etiqueta -1). Utilizamos un mapa de colores predefinido (como 'tab20') para asignar colores a los clusters, y un color gris oscuro para el ruido, lo que nos permitirá visualizar claramente la distribución de los clusters y el ruido en el mapa de folium.
    unique_labels = sorted(set(labels))
    cmap = cm.get_cmap('tab20', len(unique_labels))
    color_dict = {}
    for i, label in enumerate(unique_labels):
        if label == -1:
            color_dict[label] = '#444444'  # gris oscuro para ruido
        else:
            rgb = cmap(i)[:3]
            hex_color = mcolors.to_hex(rgb)
            color_dict[label] = hex_color
    return color_dict

# 6. Crear mapa DBSCAN
color_map_db = create_color_map(labels_db)
m_db = folium.Map(location=[-33.45, -70.66], zoom_start=12, tiles='CartoDB positron',
    width='80%',        # ancho en porcentaje (ocupa todo el ancho de la celda)
    height='500px'       # alto en píxeles)
)


for _, row in df.iterrows():
    cluster = row['cluster_dbscan'] # Obtenemos la etiqueta de cluster asignada por DBSCAN para el restaurante actual. Esta etiqueta nos indica a qué cluster pertenece el restaurante o si es considerado ruido (etiqueta -1), lo que nos permitirá decidir cómo visualizarlo en el mapa de folium.
    
    if cluster == -1:
        # Ruido como X pequeña
        folium.Marker(
            location=[row['lat'], row['lon']],
            icon=DivIcon(
                icon_size=(10, 10),
                icon_anchor=(5, 5),
                html='<div style="font-size:10pt; color:gray;">✖</div>',
            ),
            popup=f"{row['name']}<br>Cocina: {row['cuisine']}<br>Ruido"
        ).add_to(m_db)
    else:
        folium.CircleMarker(
            location=[row['lat'], row['lon']],
            radius=5,
            color=color_map_db[cluster], # Asignamos el color correspondiente al cluster utilizando el diccionario de colores creado anteriormente. Esto nos permitirá visualizar claramente la distribución de los clusters en el mapa de folium, con cada cluster representado por un color diferente y el ruido representado por un color gris oscuro.
            fill=True,
            fill_opacity=0.8,
            popup=f"{row['name']}<br>Cocina: {row['cuisine']}<br>Cluster: {cluster}" # Configuramos el popup para mostrar el nombre del restaurante, el tipo de cocina y la etiqueta de cluster (o indicación de ruido) cuando se haga clic en el marcador. Esto proporcionará información adicional sobre cada restaurante al interactuar con el mapa de folium.
        ).add_to(m_db)

# 7. Crear mapa HDBSCAN
color_map_hdb = create_color_map(labels_hdb) # Creamos un mapa de colores para los clusters identificados por HDBSCAN utilizando la función create_color_map, que asigna colores diferentes a cada cluster y un color específico para el ruido (etiqueta -1). Esto nos permitirá visualizar claramente la distribución de los clusters y el ruido en el mapa de folium, facilitando la interpretación de los resultados del clustering de HDBSCAN en el contexto de los restaurantes en Santiago.
m_hdb = folium.Map(location=[-33.45, -70.66], zoom_start=12, tiles='CartoDB positron', # Utilizamos el estilo de mapa 'CartoDB positron' para una apariencia limpia y moderna que resalta los marcadores de los restaurantes. Este estilo es especialmente útil para visualizar datos geoespaciales con claridad, lo que es ideal para nuestro análisis de clustering de restaurantes en Santiago.
    width='80%',        # ancho en porcentaje (ocupa todo el ancho de la celda)
    height='500px'       # alto en píxeles)
)

# --- Mapa HDBSCAN ---
for _, row in df.iterrows():
    cluster = row['cluster_hdbscan'] # Obtenemos la etiqueta de cluster asignada por HDBSCAN para el restaurante actual. Esta etiqueta nos indica a qué cluster pertenece el restaurante o si es considerado ruido (etiqueta -1), lo que nos permitirá decidir cómo visualizarlo en el mapa de folium.
    
    if cluster == -1:
        folium.Marker(
            location=[row['lat'], row['lon']],
            icon=DivIcon(
                icon_size=(10, 10),
                icon_anchor=(5, 5),
                html='<div style="font-size:10pt; color:gray;">✖</div>',
            ),
            popup=f"{row['name']}<br>Cocina: {row['cuisine']}<br>Ruido"
        ).add_to(m_hdb)
    else:
        folium.CircleMarker(
            location=[row['lat'], row['lon']],
            radius=5,
            color=color_map_hdb[cluster],
            fill=True,
            fill_opacity=0.8,
            popup=f"{row['name']}<br>Cocina: {row['cuisine']}<br>Cluster: {cluster}"
        ).add_to(m_hdb)



# 8. Mostrar mapas en celdas distintas
m_db  # mapa con clusters DBSCAN
/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/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(
/var/folders/q8/5g879lls62l1z8smyjkh4h700000gn/T/ipykernel_79733/265269729.py:43: MatplotlibDeprecationWarning: The get_cmap function was deprecated in Matplotlib 3.7 and will be removed in 3.11. Use ``matplotlib.colormaps[name]`` or ``matplotlib.colormaps.get_cmap()`` or ``pyplot.get_cmap()`` instead.
  cmap = cm.get_cmap('tab20', len(unique_labels))
Out[ ]:
Make this Notebook Trusted to load map: File -> Trust Notebook

HDBSCAN

In [15]:
m_hdb
Out[15]:
Make this Notebook Trusted to load map: File -> Trust Notebook
In [21]:
print("Ruido detectado:", sum(labels_hdb == -1))
print("N° de clústeres:", len(set(labels_hdb)) - (1 if -1 in labels_hdb else 0))
Ruido detectado: 197
N° de clústeres: 40

Comparación entre DBSCAN y HDBSCAN en los restaurantes de Santiago

A primera vista, ambos métodos detectan estructura espacial y separan puntos que parecen ruido. Sin embargo, los resultados son bastante distintos en granularidad, sensibilidad a la densidad e interpretación.


1. DBSCAN produce pocos clústeres grandes

En el mapa de DBSCAN se observa que gran parte de los restaurantes del eje centro-oriente queda absorbida en un gran clúster continuo. Además, aparecen sólo algunos grupos adicionales bien separados y varios puntos marcados como ruido.

Interpretación:
DBSCAN está usando un único umbral global de densidad. Si ese umbral permite conectar muchos puntos cercanos entre sí, el algoritmo termina fusionando zonas extensas en un mismo clúster, incluso si internamente podrían existir subzonas diferentes.


2. HDBSCAN produce una partición mucho más fina

En el mapa de HDBSCAN el resultado es mucho más fragmentado: donde DBSCAN veía un gran clúster, HDBSCAN detecta muchos clústeres pequeños y medianos, especialmente en el sector más denso de la ciudad.

Interpretación:
HDBSCAN no trabaja con una sola escala fija de densidad. En cambio, explora distintas escalas y conserva grupos más estables. Por eso puede separar subestructuras que DBSCAN tiende a fusionar.


3. Diferencia central: una sola escala vs múltiples escalas de densidad

Esta es la diferencia conceptual más importante:

  • DBSCAN decide todo con una sola noción de vecindad (eps) y una sola exigencia de densidad.
  • HDBSCAN construye una jerarquía de agrupamientos y luego selecciona los clústeres más estables.

Consecuencia observable en los mapas:
DBSCAN privilegia una visión más gruesa y continua; HDBSCAN revela una estructura más local y heterogénea.


4. El tratamiento del ruido también cambia

En ambos mapas aparecen muchos puntos marcados como ruido, pero en HDBSCAN el ruido convive con una partición más rica del espacio: algunos puntos que en DBSCAN quedaron absorbidos por un clúster grande ahora quedan fuera o pasan a pequeños clústeres específicos.

Interpretación:
HDBSCAN suele ser más conservador con la pertenencia a clústeres cuando la estructura local no es suficientemente estable. Eso puede aumentar la separación entre zonas realmente densas y puntos periféricos o ambiguos.


5. Qué gana y qué pierde cada método en este ejemplo

DBSCAN

Gana:

  • resultado más simple de leer;
  • menos clústeres, más fácil de comunicar;
  • útil si interesa una zonificación gruesa.

Pierde:

  • puede fusionar áreas distintas en un solo clúster;
  • depende fuertemente de eps;
  • maneja peor las diferencias locales de densidad.

HDBSCAN

Gana:

  • detecta subzonas densas dentro de áreas grandes;
  • maneja mejor densidades diferentes;
  • produce una segmentación más fina y flexible.

Pierde:

  • resultado más fragmentado;
  • puede ser más difícil de interpretar rápidamente;
  • para uso aplicado, a veces genera más clústeres de los que conviene operacionalizar.

6. Lectura sustantiva del caso

Si el objetivo fuera describir grandes zonas gastronómicas de Santiago, DBSCAN puede ser útil porque resume el patrón en pocos clústeres grandes.

Si el objetivo fuera detectar microzonas comerciales, bolsillos locales de oferta o submercados más específicos, HDBSCAN parece más adecuado, porque distingue estructuras que DBSCAN mezcla.


7. Conclusión

En este ejemplo, DBSCAN tiende a unir, mientras que HDBSCAN tiende a descomponer.

No significa que uno sea “mejor” en términos absolutos. Significa que responden a preguntas distintas:

  • DBSCAN: ¿qué regiones densas aparecen si fijamos una sola escala de densidad?
  • HDBSCAN: ¿qué agrupaciones se mantienen estables cuando permitimos distintas escalas de densidad?

La elección correcta depende del problema y del nivel de resolución que queremos capturar.

Grilla de parámetros para buscar mejor clustering

In [ ]:
import geopandas as gpd
import pandas as pd
import numpy as np
import folium
from sklearn.preprocessing import StandardScaler
import hdbscan
from folium.plugins import MarkerCluster
from folium.features import DivIcon
import matplotlib.cm as cm
import matplotlib.colors as mcolors
from scipy.spatial.distance import pdist, squareform

# 1. Cargar y preparar los datos desde GeoJSON
gdf = gpd.read_file("./Datasets/stgo_rest.geojson")
df = gdf[['geometry', 'name', 'cuisine']]
df['lon'] = df.geometry.x
df['lat'] = df.geometry.y

# 2. Normalizar coordenadas para clustering
X = df[['lat', 'lon']].values
X_scaled = StandardScaler().fit_transform(X)

# 3. Grilla de búsqueda para HDBSCAN
param_grid = {
    "min_cluster_size": [3, 5, 10 , 15],
    "min_samples": [None, 3, 5,10],
    "cluster_selection_method": ["eom", "leaf"]
}

# 4. Buscar la mejor combinación con menos ruido
# Nota: Esto es una búsqueda exhaustiva y puede ser lento para grandes datasets. Para datasets más grandes, considera usar RandomizedSearchCV o una validación cruzada personalizada.
results = []
for mcs in param_grid["min_cluster_size"]:
    for ms in param_grid["min_samples"]:
        for csm in param_grid["cluster_selection_method"]:
            hdb = hdbscan.HDBSCAN( # Ajustamos el modelo HDBSCAN con parámetros específicos para identificar clusters de restaurantes en Santiago. El parámetro min_cluster_size controla el tamaño mínimo de cluster que se considerará válido, lo que ayuda a evitar la formación de clusters muy pequeños que podrían ser considerados ruido. El parámetro min_samples define el número mínimo de muestras en un cluster para que sea considerado como tal, lo que también ayuda a diferenciar entre clusters y ruido. El parámetro cluster_selection_method indica el método utilizado para seleccionar los clusters a partir del árbol de clusterización, lo que puede influir en la cantidad y forma de los clusters identificados.
                min_cluster_size=mcs,
                min_samples=ms,
                cluster_selection_method=csm
            )
            labels = hdb.fit_predict(X_scaled)
            n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
            n_noise = np.sum(labels == -1)
            results.append({
                "model": hdb,
                "labels": labels,
                "min_cluster_size": mcs,
                "min_samples": ms if ms is not None else "default",
                "method": csm,
                "n_clusters": n_clusters,
                "n_noise": n_noise
            })

# 5. Seleccionar el mejor resultado (menos ruido, más clústeres)
best_result = sorted(results, key=lambda x: (x['n_noise'], -x['n_clusters']))[0]
df['cluster_hdb_best'] = best_result['labels']
n_clusters = best_result['n_clusters']
n_noise = best_result['n_noise']


# 6. Crear paleta de colores para los clústeres
# cmap = cm.get_cmap('tab20', n_clusters)
# colors = [mcolors.to_hex(cmap(i)) for i in range(n_clusters)]
# color_map = {label: colors[i] for i, label in enumerate(sorted(set(df['cluster_hdb_best']) - {-1}))}
# color_map[-1] = "gray"  # ruido

# A1. Calcular centroides de clústeres (sin ruido)
centroids = df[df['cluster_hdb_best'] != -1].groupby('cluster_hdb_best')[['lat', 'lon']].mean()
cluster_labels = centroids.index.tolist()

#A2. Calcular matriz de distancias geográficas entre centroides
dist_matrix = squareform(pdist(centroids.values, metric='euclidean'))

# A3. Heurística de recorrido por el punto más lejano (Farthest Point Traversal)
# Empezamos por el centroide más al sur
start = np.argmin(centroids['lat'].values)
visited = [start]
remaining = set(range(len(cluster_labels))) - {start}

while remaining:
    last = visited[-1]
    next_point = max(remaining, key=lambda x: dist_matrix[last, x])
    visited.append(next_point)
    remaining.remove(next_point)

# A4. Crear paleta continua de colores
cmap = cm.get_cmap('nipy_spectral', len(visited))
colors = [mcolors.to_hex(cmap(i)) for i in range(len(visited))]

# A5. Asignar colores siguiendo el orden "espacialmente disperso"
color_map = {cluster_labels[i]: colors[j] for j, i in enumerate(visited)}
color_map[-1] = "gray"  # Ruido




# 7. Crear mapa interactivo centrado en Santiago
center = [df['lat'].mean(), df['lon'].mean()]
m_hdb = folium.Map(location=center, zoom_start=12, width='100%', height='600px')

# 8. Agregar marcadores
for _, row in df.iterrows():
    label = row['cluster_hdb_best']
    lat, lon = row['lat'], row['lon']
    
    if label == -1:
        # Ruido como X pequeña
        folium.Marker(
            location=[lat, lon],
            icon=DivIcon(
                icon_size=(5, 5),
                icon_anchor=(5, 5),
                html='<div style="font-size:10pt; color:gray;">✖</div>',
            ),
            popup=f"{row['name']}<br>Cocina: {row['cuisine']}<br>Ruido"
        ).add_to(m_hdb)
    else:
        folium.CircleMarker(
            location=[lat, lon],
            radius=5,
            color=color_map[label],
            fill=True,
            fill_opacity=0.8,
            popup=f"{row['name']}<br>Cocina: {row['cuisine']}<br>Cluster: {label}"
        ).add_to(m_hdb)

# 9. Mostrar resultado final
m_hdb
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/geopandas/geodataframe.py:1819: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  super().__setitem__(key, value)
/Users/crcandia/anaconda3/envs/candialab2/lib/python3.10/site-packages/geopandas/geodataframe.py:1819: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  super().__setitem__(key, value)
/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/geopandas/geodataframe.py:1819: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  super().__setitem__(key, value)
/var/folders/q8/5g879lls62l1z8smyjkh4h700000gn/T/ipykernel_79733/410542000.py:86: MatplotlibDeprecationWarning: The get_cmap function was deprecated in Matplotlib 3.7 and will be removed in 3.11. Use ``matplotlib.colormaps[name]`` or ``matplotlib.colormaps.get_cmap()`` or ``pyplot.get_cmap()`` instead.
  cmap = cm.get_cmap('nipy_spectral', len(visited))
Out[ ]:
Make this Notebook Trusted to load map: File -> Trust Notebook
In [19]:
# 10. Seleccionar el mejor resultado (menos ruido, más clústeres)
# Imprimir resumen del clustering
total_lugares = len(df)
print("Resumen de clustering HDBSCAN:")
print(f" - Total de lugares: {total_lugares}")
print(f" - min_cluster_size: {best_result['min_cluster_size']}")
print(f" - min_samples: {best_result['min_samples']}")
print(f" - cluster_selection_method: {best_result['method']}")
print(f" - Número de clústeres encontrados: {best_result['n_clusters']}")
print(f" - Número de puntos de ruido: {best_result['n_noise']}")


# Calcular el tamaño de cada clúster (sin ruido)
cluster_sizes = df[df['cluster_hdb_best'] != -1]['cluster_hdb_best'].value_counts()

# Distribución de probabilidad (frecuencia relativa)
size_distribution = cluster_sizes.value_counts().sort_index()
prob_distribution = size_distribution / size_distribution.sum()

# Graficar
plt.figure(figsize=(8, 5))
plt.bar(prob_distribution.index, prob_distribution.values, width=1, edgecolor='black')
plt.xlabel("Tamaño del clúster")
plt.ylabel("Probabilidad")
plt.title("Distribución de probabilidad del tamaño de los clústeres")
plt.grid(axis='y', linestyle='--', alpha=0.6)
plt.tight_layout()
plt.show()
Resumen de clustering HDBSCAN:
 - Total de lugares: 1522
 - min_cluster_size: 3
 - min_samples: default
 - cluster_selection_method: eom
 - Número de clústeres encontrados: 145
 - Número de puntos de ruido: 345

Ajustando HDBSCAN: búsqueda de parámetros y segmentación más fina

En el paso anterior vimos que HDBSCAN podía detectar mejor subestructuras que DBSCAN. Pero eso no significa que exista una única salida “correcta”. El resultado depende todavía de ciertas decisiones, especialmente del tamaño mínimo de clúster y del nivel de conservadurismo del algoritmo.

Por eso, ahora hacemos una búsqueda sistemática de parámetros para explorar distintas configuraciones de HDBSCAN y elegir una partición más conveniente para este problema.


¿Qué se hizo aquí?

Probamos varias combinaciones de tres parámetros:

  • min_cluster_size: tamaño mínimo que debe tener un grupo para ser considerado clúster;
  • min_samples: qué tan exigente será la noción de densidad;
  • cluster_selection_method: cómo se extraen los clústeres desde la jerarquía (eom o leaf).

Luego, comparamos los resultados y elegimos la configuración con este criterio:

  • menos puntos de ruido;
  • y, en caso de empate, más clústeres.

¿Qué cambia respecto del mapa anterior?

En este nuevo mapa, HDBSCAN produce una partición todavía más rica y detallada del espacio urbano. Se observan:

  • muchos clústeres pequeños y medianos;
  • subzonas bien separadas dentro del área central;
  • corredores lineales y bolsillos periféricos que antes podían quedar absorbidos;
  • una separación más fina entre estructura y ruido.

En otras palabras, pasamos de una lógica de “detectar zonas densas” a una lógica más cercana a “explorar distintos niveles de resolución y quedarnos con una partición útil”.


¿Qué efecto tienen los parámetros?

min_cluster_size

Controla el tamaño mínimo permitido para un clúster.

  • Si es muy pequeño, el algoritmo detecta muchos grupos pequeños.
  • Si es muy grande, fusiona zonas y puede dejar más puntos como ruido.

min_samples

Controla qué tan estricta es la definición de densidad.

  • Valores más altos hacen al algoritmo más conservador.
  • Eso suele aumentar el ruido y exigir regiones más densas para formar clústeres.

cluster_selection_method

Define cómo se extraen los clústeres desde la jerarquía.

  • eom tiende a seleccionar clústeres más estables y grandes.
  • leaf tiende a producir una partición más fina, con grupos más pequeños y locales.

¿Qué estamos optimizando realmente?

Aquí elegimos el “mejor” modelo con una regla simple:

  • minimizar el número de puntos clasificados como ruido;
  • y maximizar el número de clústeres detectados.

Pero esta decisión no es neutral.

Más clústeres no siempre significa mejor segmentación.
Menos ruido tampoco garantiza que la partición sea más útil.

Esta búsqueda refleja una decisión de modelado: estamos privilegiando una solución que capture la mayor cantidad posible de estructura local sin dejar demasiados puntos fuera.


Lectura del resultado

Este mapa sugiere que la distribución de restaurantes en Santiago no está organizada sólo en grandes polos compactos. También aparecen:

  • ejes lineales;
  • pequeñas centralidades barriales;
  • agrupaciones periféricas;
  • microzonas dentro del centro expandido.

Eso es interesante porque muestra que, dependiendo de los parámetros, HDBSCAN puede revelar una ciudad mucho más fragmentada y policéntrica de lo que sugería la primera partición.


Advertencia metodológica

Aunque esta estrategia es útil para explorar, no debemos asumir automáticamente que el resultado con menos ruido y más clústeres es el “verdadero”.

Siempre hay que preguntarse:

  • ¿estos clústeres tienen sentido geográfico?
  • ¿corresponden a zonas gastronómicas reconocibles?
  • ¿sirven para el problema aplicado que nos interesa?

Por ejemplo, una aplicación de delivery, planificación urbana o análisis comercial podría preferir menos clústeres, pero más interpretables y más accionables.


Idea clave

En clustering no sólo importa el algoritmo: también importa el criterio con que elegimos entre múltiples particiones posibles.

En este caso, HDBSCAN nos permite explorar distintas resoluciones del espacio. La pregunta importante ya no es sólo “qué clústeres detecta”, sino también:

¿qué tipo de segmentación queremos obtener y para qué uso?


La importancia de identificar clústeres de restaurantes en una ciudad

Detectar clústeres de restaurantes no es solo un ejercicio técnico de segmentación espacial; es una herramienta estratégica para comprender la lógica urbana de oferta y demanda, revelar patrones emergentes y orientar decisiones clave de planificación, inversión y políticas públicas.

1. Identificar polos gastronómicos consolidados o emergentes

Al encontrar clústeres densos de restaurantes, revelamos zonas con alta concentración de oferta gastronómica. Estas áreas suelen coincidir con barrios consolidados (como Bellavista, Lastarria o Italia), pero también pueden revelar nuevos focos emergentes donde la demanda está creciendo silenciosamente. Esto permite anticipar cambios urbanos y potenciales aumentos en plusvalía.

2. Entender la dinámica entre competencia y complementariedad

Los clústeres reflejan un equilibrio complejo entre competencia directa (restaurantes similares cerca) y complementariedad (diversidad de tipos de comida que atraen públicos variados). Esta co-localización es beneficiosa, ya que aumenta el "pull effect": más opciones generan más visitas.

3. Optimizar decisiones de localización comercial

Para emprendedores o cadenas, entender los clústeres permite tomar decisiones informadas sobre dónde instalarse:

  • ¿Conviene estar dentro de un clúster para aprovechar el flujo de personas?
  • ¿O cerca pero diferenciado para captar demanda sin enfrentar tanta competencia?

4. Relacionar infraestructura urbana y movilidad

Muchas veces los clústeres coinciden con accesibilidad a metro, avenidas, zonas caminables o turísticas. Por tanto, entender su localización también sirve para evaluar la eficiencia del acceso a la oferta gastronómica en función de la demanda potencial.

5. Informar políticas públicas y promoción económica local

Desde el punto de vista del gobierno local, identificar estos clústeres permite:

  • diseñar circuitos gastronómicos temáticos,
  • potenciar zonas con valor cultural,
  • o incluso descentralizar la oferta incentivando el crecimiento en barrios periféricos.

En resumen:

Encontrar clústeres de restaurantes en la ciudad es importante para entender cómo la oferta gastronómica se organiza espacialmente en función de la demanda, la movilidad y el comportamiento colectivo urbano. Es un paso clave para transformar datos en decisiones estratégicas para negocios, planificación y desarrollo sostenible de las ciudades.


Discusión:

  • Discusión en parejas.
  • Posteriormente se les preguntarán sus argumentos.

Preguntas para discusión

1. Interpretación de resultados

¿Cómo puedes saber si un clúster detectado por DBSCAN o HDBSCAN representa un grupo real y significativo, o es simplemente un artefacto del algoritmo o los parámetros?

Obj: Discute cómo validar clústeres usando contexto externo (mapa, datos semánticos, etc.).


2. Tensión entre flexibilidad y control

¿Qué pierdes y qué ganas al usar HDBSCAN en vez de DBSCAN, desde la perspectiva de control, interpretación y reproducibilidad?

Obj: Discute el rol de los parámetros explícitos (eps) vs. la selección automática y el impacto en reproducibilidad o trazabilidad.


3. Densidad ≠ valor

¿Puede un clúster “denso” de restaurantes en Santiago no representar una zona gastronómica relevante? ¿Qué otras variables deberías considerar para evaluar su importancia real?

Obj: Invita a reflexionar sobre la relación entre densidad geográfica y valor social, económico o cultural.


4. Pensar más allá del ruido

¿Tiene sentido considerar los “puntos de ruido” detectados por HDBSCAN como irrelevantes? ¿En qué casos podrían ser más importantes que los clústeres?

Obj: Esto lleva a discutir sobre valores atípicos, rarezas, nichos, oportunidades.


5. Más allá de la forma

¿Qué limitaciones tiene DBSCAN para detectar clústeres cuando los datos no están en el espacio euclidiano (lat/lon), sino en algo más abstracto como grafos, gustos o textos?

Obj: Explora la aplicabilidad del concepto de “densidad” fuera del espacio geométrico clásico.


6. Personalización del clustering

Imagina que trabajas para una aplicación de delivery en Santiago. ¿Cómo adaptarías DBSCAN o HDBSCAN para crear zonas operativas útiles y no solo geométricas?

Obj: Desafío aplicado: considerar variables como tiempo, tráfico, perfiles de cocina, demanda.


7. Experimentación guiada

¿Qué impacto tiene la escala (normalización) en la formación de clústeres con DBSCAN o HDBSCAN? ¿En qué casos puede ocultar patrones interesantes?

Obj: Reflexión sobre el preprocesamiento de datos y su efecto en la segmentación.

In [46]:
# !pip install watermark
%load_ext watermark
%watermark -iv
The watermark extension is already loaded. To reload it, use:
  %reload_ext watermark
folium    : 0.19.5
geopandas : 1.0.1
hdbscan   : 0.8.40
matplotlib: 3.10.8
numpy     : 1.26.4
pandas    : 2.3.3
scipy     : 1.13.1
sklearn   : 1.6.1

In [48]:
# Mas detalles
import sys
import importlib.metadata as md
import pandas as pd

packages = []

for name in sorted(set(m.split(".")[0] for m in sys.modules)):
    if name.startswith("_"):
        continue
    try:
        version = md.version(name)
        packages.append((name, version))
    except:
        pass

df_packages = pd.DataFrame(packages, columns=["package", "version"])
df_packages.head(40)
Out[48]:
package version
0 IPython 8.17.2
1 appnope 0.1.3
2 asttokens 2.4.1
3 branca 0.8.1
4 brotli 1.0.9
5 certifi 2026.2.25
6 chardet 5.2.0
7 charset_normalizer 3.4.2
8 cloudpickle 3.1.1
9 colorama 0.4.6
10 comm 0.1.4
11 cycler 0.12.1
12 debugpy 1.6.7
13 decorator 5.1.1
14 defusedxml 0.7.1
15 exceptiongroup 1.1.3
16 executing 2.0.1
17 folium 0.19.5
18 geopandas 1.0.1
19 hdbscan 0.8.40
20 idna 3.10
21 ipykernel 6.26.0
22 ipywidgets 8.1.5
23 jedi 0.19.1
24 jinja2 3.1.6
25 joblib 1.4.2
26 jupyter_client 8.6.0
27 jupyter_core 5.5.0
28 kiwisolver 1.4.5
29 markupsafe 3.0.2
30 matplotlib 3.10.8
31 matplotlib_inline 0.1.6
32 numpy 1.26.4
33 packaging 25.0
34 pandas 2.3.3
35 parso 0.8.3
36 pexpect 4.8.0
37 pickleshare 0.7.5
38 platformdirs 4.0.0
39 prompt_toolkit 3.0.41

Posibles respuestas

1. Interpretación de resultados

Un clúster no debe considerarse “real” sólo porque el algoritmo lo detectó. Hay que validarlo con evidencia externa: mapa, conocimiento del dominio, variables semánticas, patrones de demanda o comportamiento. También ayuda revisar su estabilidad frente a cambios de parámetros. Si el clúster desaparece con cambios pequeños o no tiene sentido sustantivo, podría ser un artefacto.


2. Tensión entre flexibilidad y control

Con HDBSCAN ganas flexibilidad, menos dependencia de un único eps y mejor manejo de densidades distintas. Pero pierdes algo de control directo e interpretabilidad simple, porque la partición final surge de una jerarquía y de criterios de estabilidad. DBSCAN es más trazable cuando quieres justificar exactamente qué radio y qué densidad usaste; HDBSCAN suele ser más robusto, pero menos transparente para una explicación inicial.


3. Densidad ≠ valor

Sí. Una zona densa de restaurantes no necesariamente es gastronómicamente relevante. Puede ser sólo una concentración espacial sin identidad ni valor especial. Para evaluar importancia real habría que mirar variables como calidad, diversidad, demanda, ticket promedio, reseñas, prestigio, flujo de personas, horario, turismo o valor cultural del sector.


4. Pensar más allá del ruido

No siempre. En clustering, “ruido” significa que un punto no pertenece claramente a una región densa, no que sea irrelevante. Esos puntos pueden representar nichos, anomalías, oportunidades de mercado, casos raros o señales tempranas de cambio. A veces lo más interesante del dataset no está en el clúster dominante, sino en los outliers.


5. Más allá de la forma

DBSCAN funciona bien cuando existe una noción clara de distancia y vecindad. En espacios abstractos como grafos, textos o gustos, la idea de densidad depende completamente de cómo definimos similitud. Si esa métrica no captura bien la estructura del problema, el clustering puede ser engañoso. El desafío no es sólo correr el algoritmo, sino definir correctamente qué significa “estar cerca”.


6. Personalización del clustering

No usaría sólo latitud y longitud. Incorporaría tiempos reales de viaje, tráfico, demanda, tipo de cocina, horarios, capacidad operativa y perfiles de pedido. La idea sería construir zonas que sean útiles para la operación, no sólo compactas en el mapa. En problemas reales, la mejor segmentación casi nunca es puramente geométrica.


7. Experimentación guiada

La escala afecta directamente qué puntos se consideran cercanos y, por tanto, cambia los clústeres detectados. Si una variable domina por magnitud, puede ocultar otras estructuras relevantes. Normalizar ayuda cuando las variables están en escalas distintas, pero también puede borrar diferencias sustantivas si esas escalas tenían significado real. Por eso el preprocesamiento no es neutro: define qué tipo de estructura el algoritmo podrá ver.