Ciencia de datos

DBSCAN y la selección de ε: teoría, intuición y aplicación práctica

Cuando hablamos de clustering, lo primero que viene a la mente suele ser k-means. Pero k-means tiene dos limitaciones importantes: hay que decidir cuántos clusters queremos a priori, y trata cualquier punto como parte de algún grupo, aunque sea claramente un valor atípico.

DBSCAN (Density-Based Spatial Clustering of Applications with Noise) resuelve precisamente esos dos problemas:

  • No necesita que le digamos cuántos clusters hay.
  • Detecta el ruido de forma natural.
  • Encuentra clusters de formas arbitrarias (no solo “bolitas” como es el caso de k-means).

Pero todo tiene un precio: DBSCAN depende crucialmente de un hiperparámetro llamado ε (épsilon), y elegirlo bien es la diferencia entre un análisis útil y uno completamente inservible.

En esta entrada vamos a ver:

  • Cómo funciona DBSCAN por dentro.
  • Qué significa realmente ε.
  • Cómo usar la herramienta interactiva del Laboratorio de Analytics Lane para elegirlo sin esfuerzo.
  • Cómo replicar exactamente ese proceso en Python.

¿Cómo funciona DBSCAN?

La idea central de DBSCAN es muy intuitiva:

Un cluster es una región densa de puntos rodeada de regiones poco densas.

Piensa en una fiesta vista desde el techo: los grupos de gente conversando son los clusters, las personas sueltas paseando son ruido. DBSCAN identifica exactamente estos patrones en los datos, pero formalmente.

Los dos hiperparámetros

Para definir qué significa “denso”, DBSCAN necesita dos hiperparámetros numéricos:

  • ε (epsilon): el radio de vecindad. Define qué entendemos por “estar cerca”.
  • minPts: el número mínimo de vecinos dentro de ese radio para considerar una zona como densa.

Tres tipos de puntos

En base a estos dos hiperparámetro, cada punto del conjunto de datos cae en una de la siguientes tres categorías:

  • Puntos núcleo (core): tienen al menos minPts vecinos dentro del radio ε. Son el corazón de un cluster.
  • Puntos frontera (border): están dentro del radio de un punto núcleo, pero no tienen suficientes vecinos propios. Forman parte del cluster, pero “en el borde”.
  • Ruido (noise): ni son densos ni están cerca de un punto denso. No pertenecen a ningún clusters.

El algoritmo arranca en un punto núcleo, expande el cluster siguiendo cadenas de vecinos densos, y cuando se queda sin vecinos densos, salta a otro punto núcleo y empieza un cluster nuevo.

Todo este proceso se puede ver en directo en el simulador. Una vez cargados los datos, basta con cambiar el modo de ejecución a Paso a paso o Automático para observar cómo el algoritmo va clasificando cada punto como núcleo, frontera o ruido. Pulsa Iniciar para verlo correr de golpe, o avanza manualmente si quieres seguir en detalle cómo se actualizan los puntos en la gráfica a medida que se forman los clusters.

El gran problema: elegir ε

Como en k-means con el núcleo de clusteres, aquí está el desafío a la hora de entrenar DBSCAN. Si nos equivocamos con ε, el resultado es desastroso:

  • Si ε es demasiado pequeño → casi todo se clasifica como ruido.
  • Si ε es demasiado grande → todos los clusters se fusionan en uno solo.

Y lo peor: el valor “correcto” depende totalmente de la escala y la densidad de tus datos. No existe un valor universal.

Entonces, ¿cómo lo elegimos sin lanzar una moneda?

La idea clave: el gráfico de distancias k

La técnica estándar (y la que utiliza casi todo el mundo) se llama: k-distance plot (gráfico de distancias al k-ésimo vecino).

El procedimiento tiene tres pasos:

  1. Para cada punto del dataset, calcular la distancia a su k-ésimo vecino más cercano, donde k = minPts.
  2. Ordenar esas distancias de mayor a menor (orden descendente).
  3. Representarlas en un gráfico.

El resultado es siempre una curva decreciente: empieza alta (los puntos en zonas dispersas, con vecinos lejanos), cae de forma abrupta en algún momento, y luego se aplana en valores bajos (los puntos en zonas densas).

Nota: hay implementaciones que ordenan al revés (de menor a mayor), produciendo una curva creciente en espejo. La idea y el procedimiento son los mismos, solo cambia la orientación visual.

¿Dónde está ε? El “codo”

La magia está en buscar el famoso codo (elbow) de la curva:

  • Antes del codo (parte alta) → puntos en zonas dispersas o ruido (su k-ésimo vecino está lejos).
  • Después del codo (parte plana baja) → puntos que viven en zonas densas (su k-ésimo vecino está cerca).

El valor de ε se elige justo en ese punto de transición, donde la curva deja de caer abruptamente y empieza a aplanarse.

¿Por qué funciona? Porque la gráfica separa de manera natural dos regímenes muy distintos: dentro de un cluster, los vecinos están todos a distancias parecidas y pequeñas. Fuera, las distancias se disparan. El codo marca la frontera.

Usando la herramienta interactiva

Si no quieres programarlo a mano, la aplicación del Laboratorio de Analytics Lane implementa este proceso de forma visual:Simulador de DBSCAN

El flujo es muy sencillo:

  1. Selecciona un conjunto de datos en la aplicación.
  2. Pulsa sobre “¿Cómo elegir epsilon?” para acceder al componente que hace el cálculo.
  3. La herramienta muestra el k-distance plot y sugiere automáticamente el valor del codo.
  4. A partir de ahí, puedes usar ese valor sugerido o seleccionar otro manualmente si quieres ajustar la sensibilidad.

Esto convierte una heurística abstracta en una decisión visual: ves el codo, y eliges.

Intuición visual: por qué es más que una heurística

Este gráfico no es un truco arbitrario, tiene una interpretación clara:

  • La parte alta → estás midiendo distancias hacia el ruido.
  • La caída abrupta → es la transición: el codo.
  • La parte plana baja → estás midiendo distancias dentro de los clusters.

En otras palabras, lo que el codo te dice es:

Estás estimando la densidad mínima que define un cluster en tus datos.

Es una forma de dejar que los datos hablen, en lugar de imponerles un valor a ojo.

Cómo replicarlo en Python

Ahora viene lo interesante: reproducir exactamente lo que hace la herramienta. El código TypeScript de la aplicación implementa esencialmente este algoritmo:

  1. Para cada punto, calcular las distancias a todos los demás puntos (excluyendo el propio).
  2. Ordenarlas de menor a mayor y quedarse con la k-ésima.
  3. Construir el array de todas esas k-distancias y ordenarlo de mayor a menor.
  4. Detectar el codo y devolver la distancia en esa posición como ε.

En Python son apenas unas líneas, y vamos a seguir exactamente esa misma secuencia.

Paso 1: calcular vecinos más cercanos

Queremos, para cada punto, la distancia a su k-ésimo vecino más cercano excluyendo el propio punto (justo lo que hace la aplicación con su filter(candidate.id !== point.id)).

En sklearn hay un detalle importante: cuando llamas a kneighbors(X) sobre los mismos datos con los que entrenaste, el primer vecino de cada punto es él mismo, con distancia 0. Por eso pedimos k + 1 vecinos: el primero (índice 0) es el propio punto, y los siguientes k son los vecinos reales.

from sklearn.neighbors import NearestNeighbors
from sklearn.datasets import make_moons
import numpy as np
import matplotlib.pyplot as plt

# Reemplaza X por tus datos
X, _ = make_moons(n_samples=200, noise=0.2, random_state=42)
k = 5  

neigh = NearestNeighbors(n_neighbors=k + 1)
neigh.fit(X)

distances, _ = neigh.kneighbors(X)

Paso 2: quedarnos con la distancia al k-ésimo vecino

kneighbors devuelve las distancias ordenadas a los k + 1 vecinos. Como el primero es el propio punto (distancia 0), nos quedamos con el último de la fila, que es el k-ésimo vecino real:

k_distances = distances[:, k]

Paso 3: ordenar las distancias de mayor a menor

k_distances = np.sort(k_distances)[::-1]   # orden descendente

Paso 4: graficar

plt.plot(k_distances)
plt.xlabel("Puntos ordenados")
plt.ylabel(f"Distancia al {k}-ésimo vecino")
plt.title("K-distance plot")
plt.grid(True)
plt.show()
K-distance plot: distancia al k-ésimo vecino más cercano de cada punto del dataset, ordenada de mayor a menor. El codo de la curva indica el valor óptimo de ε para DBSCAN.

Paso 5: elegir ε visualmente

Mira la gráfica, localiza el codo, y lee el valor en el eje Y. Ese es tu ε.

Esto funciona, pero tiene un problema: depende del ojo humano. Si quieres que el proceso sea reproducible y automático (como hace la herramienta interactiva), necesitas un método matemático para localizar el codo.

Detectando el codo automáticamente

¿Cómo decide un programa dónde está el codo? Existen varios algoritmos, pero el más extendido por su simplicidad y robustez es el método de la máxima distancia a la cuerda.

La intuición geométrica

La idea es elegante:

  1. Imagina la curva del k-distance plot.
  2. Traza una línea recta que una el primer punto con el último punto de la curva. Esa recta es la “cuerda”.
  3. Para cada punto de la curva, mide la distancia perpendicular a esa recta.
  4. El codo es el punto que está más lejos de la cuerda.

¿Por qué funciona? Porque el codo es, por definición, el punto donde la curva más se aleja de su tendencia lineal global. Si la curva fuera una recta perfecta, todos los puntos estarían a distancia cero. Cuanto más pronunciado sea el codo, mayor será la distancia máxima.

Visualmente, con la curva ordenada de mayor a menor:

   primer →  ●
   punto      ●
                ●
                  ●  ↕ máxima distancia a la cuerda
       codo →     ●  ↕
                      ●
                        ●
                            ● ● ● ● ● ● ● ● ●  ← último punto

La cuerda es la línea recta que une el primer punto (arriba a la izquierda) con el último (abajo a la derecha). El codo es el punto de la curva más alejado de esa línea.

Un detalle importante: normalizar los ejes

Hay una trampa sutil. El eje X va de 0 a N (donde N puede ser miles de puntos), mientras que el eje Y son distancias pequeñas (a menudo entre 0 y 1). Si calculamos la distancia geométrica directamente, el eje X dominará por completo y la detección fallará.

Solución: normalizar ambos ejes al rango [0, 1] antes de calcular distancias. Así los dos ejes contribuyen por igual a la geometría.

Implementación en Python

Una nota antes del código: por qué la app muestra ε como entero y aquí no

En la aplicación interactiva, ε se presenta como un número entero por una razón puramente didáctica: los datos con los que trabaja están en coordenadas grandes (distancias del orden de cientos), así que mostrar ε = 50 en lugar de ε = 49.83 es más limpio en la interfaz y la parte decimal es irrelevante en esa escala.

ε es un parámetro real que recibe DBSCAN, y si tus datos están escalados (con StandardScaler, o sintéticos como make_moons), las distancias serán del orden de 0.1 a 1.0. Por lo que se debe tratar como un real.

import numpy as np

def find_elbow(k_distances):
    """
    Detecta el codo en la curva del k-distance plot usando el método
    de la máxima distancia a la cuerda.

    Funciona indistintamente con la curva ordenada de mayor a menor
    (descendente, como en la aplicación) o de menor a mayor (ascendente),
    porque la geometría es simétrica.

    Devuelve
    --------
    elbow_idx : int
        Índice del codo en el array.
    elbow_eps : float
        Valor de epsilon en ese punto, sin redondear.
    """
    n = len(k_distances)

    # 1. Coordenadas de la curva
    x = np.arange(n, dtype=float)
    y = np.asarray(k_distances, dtype=float)

    # 2. Normalizar ambos ejes a [0, 1] para que sean comparables
    x_norm = (x - x.min()) / (x.max() - x.min())
    y_norm = (y - y.min()) / (y.max() - y.min())

    # 3. Definir la cuerda (primer punto -> último punto)
    p1 = np.array([x_norm[0],  y_norm[0]])
    p2 = np.array([x_norm[-1], y_norm[-1]])
    line_vec = p2 - p1
    line_len = np.linalg.norm(line_vec)

    # 4. Distancia perpendicular de cada punto a la cuerda
    #    (fórmula del producto cruz en 2D, en valor absoluto)
    points = np.column_stack([x_norm, y_norm])
    cross = np.abs(
        line_vec[0] * (p1[1] - points[:, 1]) -
        (p1[0] - points[:, 0]) * line_vec[1]
    )
    distances_to_line = cross / line_len

    # 5. El codo es el punto con máxima distancia a la cuerda
    elbow_idx = int(np.argmax(distances_to_line))
    elbow_eps = float(k_distances[elbow_idx])

    return elbow_idx, elbow_eps

Y se usa así, encadenado con los pasos anteriores:

elbow_idx, eps_auto = find_elbow(k_distances)
print(f"Codo detectado en el índice {elbow_idx}, ε ≈ {eps_auto:.4f}")
Codo detectado en el índice 31, ε ≈ 0.2746

Visualizar el resultado

Para ganar confianza en el valor detectado, conviene representarlo sobre la gráfica:

plt.plot(k_distances, label="Distancia al k-ésimo vecino")
plt.axvline(elbow_idx, color="red",   linestyle="--", label=f"Codo (idx={elbow_idx})")
plt.axhline(eps_auto,  color="green", linestyle="--", label=f"ε ≈ {eps_auto:.4f}")
plt.xlabel("Puntos ordenados (descendente)")
plt.ylabel(f"Distancia al {k}-ésimo vecino")
plt.title("Detección automática del codo")
plt.legend()
plt.grid(True)
plt.show()
Detección automática del codo aplicada al k-distance plot. La línea roja marca el índice del codo y la línea verde el valor de ε sugerido.

Este es exactamente el proceso que hace la herramienta interactiva por debajo: calcula el k-distance plot, normaliza los ejes, busca el punto de máxima distancia a la cuerda, y te lo muestra como sugerencia.

Alternativa: la librería kneed

Si prefieres no implementarlo a mano, existe una librería ya hecha llamada kneed, que implementa el algoritmo Kneedle (Satopaa et al., 2011), una versión más sofisticada de la misma idea:

from kneed import KneeLocator

knee = KneeLocator(
    range(len(k_distances)),
    k_distances,
    curve="convex",
    direction="decreasing",   # nuestra curva está ordenada de mayor a menor
)

eps_auto = float(k_distances[knee.knee])
print(f"ε automático (kneed) ≈ {eps_auto:.4f}")

Ambos métodos suelen dar valores muy parecidos. La función casera tiene la ventaja de ser transparente: ves exactamente qué está haciendo y por qué.

Aplicar DBSCAN con ese ε

Una vez tienes el valor, ejecutar DBSCAN es trivial:

from sklearn.cluster import DBSCAN

eps = eps_auto       # valor decimal devuelto por find_elbow, sin redondear
min_samples = k      # el mismo k que usaste antes

model = DBSCAN(eps=eps, min_samples=min_samples)
labels = model.fit_predict(X)

labels te devuelve la asignación de cada punto a un cluster. El valor -1 indica ruido.

Buenas prácticas

1. Elegir minPts con criterio

Una regla práctica muy extendida:

minPts ≈ 2 × (número de dimensiones)

En 2D, minPts = 4 o 5 suele funcionar bien. En espacios de mayor dimensión, conviene aumentarlo.

2. Escalar los datos antes de nada

DBSCAN se basa en distancias, así que si una variable va de 0 a 1 y otra de 0 a 1.000.000, la segunda dominará todo. Estandariza siempre:

from sklearn.preprocessing import StandardScaler
X_scaled = StandardScaler().fit_transform(X)

3. No existe un único ε “perfecto”

El método del codo es una heurística, no una fórmula matemática exacta. A veces el codo es muy claro, otras veces hay varios candidatos razonables. No te obsesiones: prueba dos o tres valores cercanos y compara los resultados.

4. Valida los resultados

Antes de dar por bueno un clustering:

  • Visualiza los clusters (en 2D directamente, en alta dimensión usa PCA o UMAP).
  • Revisa cuántos puntos quedan etiquetados como ruido. Si es el 80%, tu ε es probablemente demasiado pequeño.
  • Usa métricas como silhouette, pero con cautela: están diseñadas para clusters convexos y no siempre reflejan bien la calidad de un clustering basado en densidad.

Conclusiones

La selección de ε en DBSCAN no tiene por qué ser un misterio:

  • Se apoya en la estructura de densidad de los propios datos.
  • El k-distance plot la hace visible.
  • El codo marca la frontera natural entre clusters y ruido.

Una herramienta interactiva como la del Laboratorio de Analytics Lane aporta algo muy valioso: convierte una heurística abstracta en una decisión visual e inmediata. Y lo mejor es que ese mismo proceso es totalmente reproducible en Python con apenas unas líneas de código.

Al final, DBSCAN es un algoritmo poderoso, pero como toda herramienta potente, exige entenderla. Saber elegir ε es saber escuchar lo que los datos te están diciendo sobre su propia densidad.

Imagen de WikiImages en Pixabay

¿Te ha parecido de utilidad el contenido?

Daniel Rodríguez

Share
Published by
Daniel Rodríguez

Recent Posts

El bestiario de los indicadores económicos absurdos: El zoo patrio

Cualquier país desarrollado tiene sus propios indicadores folclóricos. España, por motivos que tienen mucho que…

5 días ago

Por qué el banco te ofrece un 3% TAE y no es lo que parece

Entras a la web de tu banco. En la página principal, un banner llamativo: “Depósito…

1 semana ago

Analytics Lane lanza la versión 1.3 del laboratorio con nuevas herramientas de evaluación de modelos y utilidades prácticas

Seguimos ampliando el laboratorio de Analytics Lane con el lanzamiento de la versión 1.3, disponible…

2 semanas ago

Augurios deportivos y portadas malditas, o cuando The Economist predice mejor al revés – El bestiario de los indicadores económicos absurdos (parte 3)

Cerramos la serie internacional con la categoría más estrambótica de todas: indicadores que predicen el…

2 semanas ago

El Binning en Credit Scoring: El Arte de Discretizar Variables

Si el WOE y el IV son la base matemática del credit scoring, el binning…

2 semanas ago

Analytics Lane lanza la versión 1.2 del laboratorio con nuevas herramientas de ajuste de curvas y cálculo matricial

Seguimos iterando sobre el laboratorio de Analytics Lane y lanzamos la versión 1.2, disponible en:https://www.analyticslane.com/lab/es…

3 semanas ago

This website uses cookies.