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

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.
DBSCAN es especialmente útil porque:
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:
Estas preguntas dan origen a los dos parámetros fundamentales del método:
A partir de esto, DBSCAN distinguirá entre puntos núcleo, puntos borde y puntos ruido.
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()
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”.
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.
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.
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)|$
El parámetro que define cuántos vecinos son suficientes es MinPts.
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.
Un punto $p$ es un punto borde si:
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.
Un punto es ruido si:
Estos puntos quedan aislados respecto de las regiones densas y DBSCAN no los asigna a ningún clúster.
| 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 |
Un punto $p$ es directamente alcanzable por densidad desde un punto $q$ si se cumplen dos condiciones:
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.
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.
# 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.
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.
Un punto $p$ es alcanzable por densidad desde $q$ si existe una cadena de puntos $p_1, p_2, \ldots, p_n$ tal que:
Esta relación es transitiva pero no simétrica.
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.
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:
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.
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()
Un conjunto $C \subseteq D$ es un clúster si cumple:
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.
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.
DBSCAN recibe como entrada:
Idea: Para cada punto no clasificado:
Complejidad: $O(n \log n)$ si se utiliza un índice espacial como R*-tree.

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.
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.
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.
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.
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.
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.
Este es un ejemplo donde DBSCAN funciona muy bien y los otros algoritmos en general fallan
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)
# 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.
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()
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()
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:
Dataset 1: blobs (esféricos, distintos tamaños)
Dataset 2: lunas (forma no convexa)
Dataset 3: círculos + ruido
Conclusión general:
DBSCAN destaca por:
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 (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.
eps y minPts;eps;| Característica | DBSCAN | HDBSCAN |
|---|---|---|
¿Requiere eps? |
Sí | No, no como parámetro principal |
| ¿Detecta ruido? | Sí | Sí |
| ¿Permite clústeres no convexos? | Sí | Sí |
| ¿Maneja bien densidades muy distintas? | Limitado | Mejor |
| ¿Produce jerarquía? | No | Sí |
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.
HDBSCAN suele ser preferible cuando:
eps. 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.
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
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.
#pip install hdbscan # Descomentar y correr una vez para instalar HDBSCAN
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()
Aunque HDBSCAN es muy potente, no siempre es la mejor opción. Aquí algunos casos donde puede no ser adecuado:
.predict)¶.predict() estándar como K-Means o DBSCAN en scikit-learn.min_cluster_size, HDBSCAN los tratará como ruido.eps¶eps de forma implícita.eps específico por alguna razón del dominio, DBSCAN puede ser más controlable.Usa HDBSCAN cuando quieras robustez y adaptabilidad.
Evítalo cuando necesites velocidad, predicción directa o control fino sobre los parámetros.
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.
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)
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 amin_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)
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')
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)
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)
| 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 |
import pandas as pd
df = pd.read_csv("./Datasets/Mall_Customers.csv")
df.head()
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()
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()
# 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()
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()
¿Cuántos clústeres detecta DBSCAN vs HDBSCAN?
¿Cuál maneja mejor el ruido?
¿Cuál genera clústeres más naturales?
# !pip install geopandas
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)
plt.scatter(df['lon'], df['lat'], alpha=0.6, edgecolor='k')
plt.xlabel("Longitud")
plt.ylabel("Latitud")
plt.title("Restaurantes en Santiago")
plt.show()
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.
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()
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()
¿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?
# !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.
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
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
m_hdb
print("Ruido detectado:", sum(labels_hdb == -1))
print("N° de clústeres:", len(set(labels_hdb)) - (1 if -1 in labels_hdb else 0))
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.
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.
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.
Esta es la diferencia conceptual más importante:
eps) y una sola exigencia de densidad.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.
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.
Gana:
Pierde:
eps;Gana:
Pierde:
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.
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:
La elección correcta depende del problema y del nivel de resolución que queremos capturar.
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
# 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()
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.
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:
En este nuevo mapa, HDBSCAN produce una partición todavía más rica y detallada del espacio urbano. Se observan:
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”.
min_cluster_size¶Controla el tamaño mínimo permitido para un clúster.
min_samples¶Controla qué tan estricta es la definición de densidad.
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.Aquí elegimos el “mejor” modelo con una regla simple:
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.
Este mapa sugiere que la distribución de restaurantes en Santiago no está organizada sólo en grandes polos compactos. También aparecen:
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.
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:
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.
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?
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.
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.
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.
Para emprendedores o cadenas, entender los clústeres permite tomar decisiones informadas sobre dónde instalarse:
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.
Desde el punto de vista del gobierno local, identificar estos clústeres permite:
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.
¿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.).
¿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.
¿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.
¿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.
¿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.
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.
¿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.
# !pip install watermark
%load_ext watermark
%watermark -iv
# 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)
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.
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.
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.
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.
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”.
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.
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.