Todo el módulo hasta aquí ha sido aprendizaje supervisado: cada entrega histórica traía su etiqueta (retraso, minutos_entrega) y el modelo aprendía a predecirla. Pero hay preguntas de Rutalia sin etiqueta posible: ¿qué zonas de la ciudad se comportan igual? ¿Qué tipos de entrega existen, en realidad? Nadie ha marcado la respuesta en los datos porque nadie la conoce: hay que descubrir la estructura, no imitarla. Eso es el aprendizaje no supervisado, y su técnica central es el clustering: agrupar ejemplos de modo que los de un grupo se parezcan entre sí más que a los de otros grupos. En esta lección implementaremos k-means desde cero (el algoritmo de Lloyd), veremos el clustering jerárquico y DBSCAN, cerraremos un círculo pendiente desde el módulo 3 (cortar el MST de Kruskal es clustering) y aplicaremos todo a un problema real de Rutalia: agrupar zonas por patrón de demanda para rediseñar las rutas — lo que nos devolverá, elegantemente, al TSP del módulo 2.
Contenido
- Aprendizaje sin etiquetas: qué cambia
- k-means: el algoritmo de Lloyd
- Inicialización y k-means++
- Elegir k: método del codo y silueta
- Limitaciones de k-means
- Clustering jerárquico: el dendrograma
- Single-linkage = MST: cerrando el círculo del módulo 3
- DBSCAN: clustering por densidad
- Aplicación Rutalia: zonas por patrón de demanda (y vuelta al TSP)
- Tabla comparativa
Aprendizaje sin etiquetas: qué cambia
Sin y, cambian tres cosas fundamentales:
- No hay respuesta correcta. En clasificación, la matriz de confusión zanjaba la discusión. Aquí no existe "el clustering verdadero": distintas agrupaciones pueden ser válidas para distintos propósitos.
- La evaluación es interna. Medimos propiedades geométricas (compacidad, separación) y, sobre todo, utilidad para el negocio.
- La distancia lo es todo. "Parecerse" significa estar cerca en el espacio de features — así que el escalado de 05-01 pasa de importante a crítico: una feature mal escalada define los clusters ella sola.
k-means: el algoritmo de Lloyd
k-means busca k centroides (puntos medios) que minimicen la inercia: la suma de distancias al cuadrado de cada punto a su centroide más cercano. Minimizarla exactamente es NP-duro (viejo conocido de 02-02), así que se usa una heurística iterativa excelente, el algoritmo de Lloyd:
flowchart TD
A["1. Elegir k centroides iniciales"] --> B["2. ASIGNAR: cada punto,\nal centroide más cercano"]
B --> C["3. ACTUALIZAR: cada centroide,\na la media de sus puntos"]
C --> D{"¿Cambió alguna asignación?"}
D -->|sí| B
D -->|no| E["Converge: clusters finales"]
Cada paso tiene una lógica de mejora garantizada: asignar cada punto a su centroide más cercano no puede aumentar la inercia, y mover cada centroide a la media de sus puntos tampoco (la media es el punto que minimiza la suma de cuadrados de distancias — el mismo hecho que apareció en mínimos cuadrados en 05-03). La inercia baja o se mantiene en cada iteración y las asignaciones posibles son finitas: el algoritmo siempre termina. Lo que no garantiza es terminar en el óptimo global — igual que los voraces de 02-02, converge al mínimo local que le pille cerca.
def kmeans_lloyd(X, k, semilla=42, max_iter=100):
"""k-means desde cero. Devuelve asignaciones, centroides e inercia."""
rng = np.random.default_rng(semilla)
centroides = X[rng.choice(len(X), k, replace=False)] # k puntos al azar
for _ in range(max_iter):
# ASIGNAR: matriz de distancias (n × k) por broadcasting
dists = np.linalg.norm(X[:, None, :] - centroides[None, :, :], axis=2)
asig = dists.argmin(axis=1) # centroide más cercano
# ACTUALIZAR: cada centroide a la media de sus puntos
nuevos = np.array([X[asig == c].mean(axis=0) if (asig == c).any()
else centroides[c] for c in range(k)])
if np.allclose(nuevos, centroides): # sin cambios: convergido
break
centroides = nuevos
inercia = float(((X - centroides[asig]) ** 2).sum())
return asig, centroides, inerciaComplejidad por iteración: O(n·k·d) — lineal en todo, por eso k-means escala a los millones de registros de Rutalia. El número de iteraciones hasta converger suele ser pequeño en la práctica.
Inicialización y k-means++
El talón de Aquiles de Lloyd: el resultado depende de los centroides iniciales. Dos arranques desafortunados (p. ej., dos centroides cayendo en el mismo grupo natural) convergen a mínimos locales distintos, algunos malos. Dos remedios complementarios:
- Reinicios múltiples: ejecutar el algoritmo varias veces con semillas distintas y quedarse con la menor inercia (sklearn:
n_init=10por defecto). - k-means++: elegir los centroides iniciales ya separados entre sí. El primero al azar; cada siguiente se sortea con probabilidad proporcional a D(x)², la distancia al cuadrado del punto a su centroide ya elegido más cercano — los puntos lejanos de todo lo elegido tienen más papeletas.
def kmeans_pp_init(X, k, rng):
"""Inicialización k-means++: centroides separados con alta probabilidad."""
centroides = [X[rng.integers(len(X))]] # el primero, uniforme
for _ in range(k - 1):
d2 = np.min([((X - c) ** 2).sum(axis=1) for c in centroides], axis=0)
probs = d2 / d2.sum() # ∝ distancia al cuadrado
centroides.append(X[rng.choice(len(X), p=probs)])
return np.array(centroides)k-means++ no solo funciona bien empíricamente: garantiza en esperanza una inercia a factor O(log k) del óptimo — una cota de aproximación probabilística, pariente de las garantías que discutimos en 02-02. Es el defecto de sklearn (init="k-means++").
Elegir k: método del codo y silueta
En supervisado, los hiperparámetros se elegían con validación cruzada contra la etiqueta. Sin etiqueta, necesitamos criterios internos:
- Método del codo: graficar la inercia frente a k. La inercia siempre baja al crecer k (con k=n sería 0: cada punto su propio cluster — el sobreajuste del clustering), pero baja a saltos grandes mientras k está por debajo del número natural de grupos y a saltos pequeños después. El "codo" de la curva sugiere el k natural. Es visual y algo subjetivo.
- Coeficiente de silueta: para cada punto, compara
a(distancia media a los de su propio cluster) conb(distancia media a los del cluster ajeno más cercano):s = (b − a) / max(a, b)∈ [−1, 1]. Cerca de 1: bien agrupado; cerca de 0: en la frontera; negativo: probablemente en el cluster equivocado. La silueta media del dataset se maximiza sobre varios k candidatos — objetivo y automatizable (sklearn.metrics.silhouette_score), a cambio de costar O(n²).
for k in range(2, 9):
_, _, inercia = kmeans_lloyd(X_e, k)
print(f"k={k}: inercia={inercia:10.1f}") # busca el codo en la secuenciaLimitaciones de k-means
k-means lleva escrita su geometría: al asignar por distancia al centroide, asume clusters convexos, aproximadamente esféricos y de tamaño similar. Falla de forma predecible cuando la realidad no coopera:
| Situación | Qué hace k-means | Qué haría falta |
|---|---|---|
| Clusters alargados o anidados (dos anillos, dos medias lunas) | Los corta por la mitad con fronteras rectas | DBSCAN o jerárquico single-linkage |
| Densidades muy distintas | El cluster denso "roba" puntos al disperso | DBSCAN |
| Outliers | Arrastran los centroides (la media no es robusta) | DBSCAN (los marca como ruido) |
| k desconocido | Hay que dárselo | Jerárquico (decides después) o DBSCAN (lo infiere) |
Estas limitaciones motivan exactamente los dos algoritmos siguientes.
Clustering jerárquico: el dendrograma
El clustering aglomerativo no fija k por adelantado: construye todas las granularidades a la vez.
- Empieza con n clusters (cada punto, el suyo).
- Une los dos clusters más cercanos.
- Repite hasta que quede uno solo.
El historial de fusiones forma un árbol, el dendrograma: la altura de cada fusión es la distancia a la que ocurrió. Cortando el dendrograma a una altura se obtiene una partición; cortes más bajos dan más clusters. Se elige la granularidad después de ver la estructura — el lujo que k-means no da.
Queda por definir "distancia entre clusters" (el linkage), y la elección cambia el carácter del algoritmo:
| Linkage | Distancia entre clusters A y B | Tendencia |
|---|---|---|
| Single | mínimo entre pares (a∈A, b∈B) | Encadena: sigue estructuras alargadas (y crea "puentes" indeseados) |
| Complete | máximo entre pares | Clusters compactos y redondos |
| Average | media entre pares | Compromiso |
| Ward | menor aumento de inercia al fusionar | El más parecido a k-means; defecto habitual |
En scipy: scipy.cluster.hierarchy.linkage(X, method="ward") y dendrogram(...) para dibujarlo; fcluster corta a la altura elegida. Coste O(n² log n) y memoria O(n²): perfecto para agrupar las 9 zonas de Rutalia o unos miles de clientes, inviable para millones de filas.
Single-linkage = MST: cerrando el círculo del módulo 3
En el ejercicio final de 03-04 adelantamos, casi como curiosidad, que cortar las k−1 aristas más caras del árbol de expansión mínima de Kruskal parte el grafo en k grupos "naturales". Toca cerrar ese círculo: eso era clustering jerárquico single-linkage, exactamente.
La correspondencia es precisa. Kruskal (03-04) procesa las aristas de menor a mayor peso uniendo componentes con union-find (01-04). Single-linkage fusiona en cada paso los dos clusters con el par de puntos más cercano entre ellos. Son el mismo algoritmo: la secuencia de uniones de Kruskal sobre el grafo completo de distancias es la secuencia de fusiones de single-linkage, y las alturas del dendrograma son los pesos de las aristas del MST en el orden en que Kruskal las acepta. Por eso cortar las k−1 aristas más caras del MST equivale a cortar el dendrograma para obtener k clusters.
def clusters_por_mst(X, k):
"""Single-linkage vía Kruskal: corta las k-1 aristas más caras del MST."""
n = len(X)
aristas = sorted((np.linalg.norm(X[i] - X[j]), i, j)
for i in range(n) for j in range(i + 1, n))
padre = list(range(n)) # union-find de 01-04
def find(a):
while padre[a] != a:
padre[a] = padre[padre[a]] # compresión de camino
a = padre[a]
return a
aceptadas = []
for w, i, j in aristas: # Kruskal, tal cual 03-04
ri, rj = find(i), find(j)
if ri != rj:
padre[ri] = rj
aceptadas.append((w, i, j))
# MST completo: n-1 aristas. Quitar las k-1 más caras = k componentes.
padre = list(range(n))
for w, i, j in aceptadas[:-(k - 1)]: # nos saltamos las k-1 últimas (más caras)
padre[find(i)] = find(j)
raices = {find(i) for i in range(n)}
return np.array([sorted(raices).index(find(i)) for i in range(n)])Un algoritmo de grafos del módulo 3, con la estructura de datos del módulo 1, resulta ser un algoritmo de aprendizaje no supervisado del módulo 5. Las fronteras entre "algoritmia clásica" y "machine learning" son más finas de lo que parecen: ML es, en gran parte, algoritmia aplicada a datos.
DBSCAN: clustering por densidad
DBSCAN abandona centroides y jerarquías: un cluster es una región densa de puntos, tenga la forma que tenga. Dos parámetros: eps (radio de vecindad) y min_samples (cuántos vecinos hacen "denso" a un punto).
- Punto núcleo: tiene ≥
min_samplesvecinos a distancia ≤eps. - Un cluster es un conjunto máximo de núcleos conectados por cadenas de vecindad (alcanzabilidad por densidad), más los puntos frontera pegados a ellos.
- Lo que no alcanza ningún núcleo es ruido: queda sin cluster, etiquetado −1.
Algorítmicamente es una vieja amiga: una exploración tipo BFS (03-02) sobre el "grafo de densidad" — desde cada núcleo no visitado, expandir la vecindad; si un vecino es núcleo, seguir expandiendo por él. Coste O(n²) ingenuo, O(n log n) con índices espaciales.
Cuándo gana a k-means:
- Formas arbitrarias: sigue la densidad, no impone esferas — resuelve los anillos y medias lunas donde k-means fracasa.
- No pide k: el número de clusters emerge de los datos.
- Ruido explícito: los outliers no contaminan ningún cluster; en Rutalia, las entregas anómalas (dirección errónea, incidencia grave) quedan señaladas gratis — clustering y detección de anomalías en uno.
Sus debilidades: elegir eps requiere tacto (regla práctica: gráfico de la distancia al k-ésimo vecino y buscar el codo), y sufre cuando los clusters tienen densidades muy dispares. En sklearn: DBSCAN(eps=0.5, min_samples=5).fit_predict(X_e).
Aplicación Rutalia: zonas por patrón de demanda (y vuelta al TSP)
Juntemos las piezas en un problema real. Rutalia quiere rediseñar sus rutas: ¿qué zonas se comportan igual y podrían compartir estrategia de reparto? Construimos, con el dataset canónico, el perfil de demanda de cada zona: un vector de features agregadas por zona.
datos = generar_dataset() # canónico, semilla 42 (05-01)
perfiles, nombres = [], []
for z in ZONAS:
m = datos["zona"] == z
perfiles.append([
datos["minutos_entrega"][m].mean(), # duración media
datos["retraso"][m].mean(), # tasa de retraso
(datos["hora_salida"][m] >= 17).mean(),# fracción de reparto vespertino
datos["peso_kg"][m].mean(), # peso medio
])
nombres.append(z)
P = np.array(perfiles)
P_e = (P - P.mean(axis=0)) / P.std(axis=0) # estandarizar: crítico
asig, cents, _ = kmeans_lloyd(P_e, k=3)
for c in range(3):
print(f"cluster {c}: {[nombres[i] for i in range(9) if asig[i] == c]}")Con la verdad oculta del generador, los grupos que emergen son interpretables: las zonas congestionadas (CEN, MER, HOS) se agrupan por sus duraciones y tasas de retraso altas; las fluidas (IND, PAR, UNI) forman otro perfil; las intermedias (ALM, RIO, EST), el tercero. Nadie etiquetó las zonas: la estructura estaba en los datos y el algoritmo la hizo visible. (Con 9 puntos, el jerárquico con dendrograma sería igual de apropiado y más ilustrativo; k-means gana cuando agrupamos miles de clientes o millones de entregas.)
Y aquí se cierra el círculo grande del curso: con los clusters en la mano, operaciones asigna repartidores por grupo de zonas homogéneas... y planificar la ruta de cada repartidor dentro de su grupo es, de nuevo, el TSP del módulo 2 — aquella instancia canónica de óptimo 35,22 km — sobre el grafo del módulo 3, con el A* del módulo 4 para los trayectos y los modelos de este módulo prediciendo los tiempos que alimentan los pesos. El aprendizaje automático no sustituye a la algoritmia clásica: la alimenta con mejores datos y mejores agrupaciones, y esa decisión final de rediseño la toman las personas de operaciones con los análisis en la mano.
Tabla comparativa
| Criterio | k-means | Jerárquico (aglomerativo) | DBSCAN |
|---|---|---|---|
| ¿Pide k? | Sí, por adelantado | No (se corta el dendrograma después) | No (emerge de la densidad) |
| Forma de clusters | Esféricos, convexos | Según linkage (single: alargados) | Arbitraria |
| Manejo de ruido | No (los outliers arrastran centroides) | No | Sí, etiqueta −1 |
| Coste | O(n·k·d) por iteración | O(n² log n), memoria O(n²) | O(n²), O(n log n) con índice |
| Escala a millones | Sí | No | Con índices espaciales, aceptable |
| Determinista | No (inicialización) | Sí | Sí (dado eps/min_samples) |
| Parámetros delicados | k, inicialización | linkage, altura de corte | eps, min_samples |
| Úsalo cuando… | Grupos compactos, datos masivos | Quieres la jerarquía / pocos datos | Formas raras, outliers, k desconocido |
Errores Comunes y Consejos
- Agrupar sin estandarizar. En el perfil de zonas,
minutos_entrega(~20-60) aplastaría aretraso(~0-0.4): los clusters saldrían de una sola feature. Sin etiqueta que te delate el error, puede pasar inadvertido — estandariza siempre. - Una sola ejecución de k-means. Lloyd converge al mínimo local de turno. Usa k-means++ y varios reinicios (
n_init), y compara inercias. - Tomar el k del codo como verdad revelada. El codo sugiere; la silueta cuantifica; el negocio decide. Si k=3 y k=4 son parecidos en silueta pero operaciones trabaja con 4 equipos de reparto, la respuesta es 4.
- Interpretar los clusters como categorías reales. Un cluster es una regularidad geométrica, no una verdad del mundo. Valida siempre con conocimiento del dominio antes de actuar sobre él.
- Usar DBSCAN con densidades muy dispares. Un único
epsno puede servir a la vez para un casco urbano denso y un polígono disperso; considera jerárquico o variantes (HDBSCAN). - Consejo: dibuja siempre que puedas (proyecta a 2D si hace falta). En clustering, un vistazo al scatter con colores vale más que cualquier métrica interna.
Ejercicios
-
Lloyd bajo la lupa. Modifica
kmeans_lloydpara que guarde la inercia en cada iteración y ejecútalo sobre los perfiles de zona (k=3) con 5 semillas distintas. Comprueba que (a) la inercia nunca sube dentro de una ejecución, y (b) distintas semillas pueden terminar en inercias finales distintas. ¿Cuál de las dos propiedades demostramos y cuál es solo empírica? -
El codo y la silueta. Sobre una muestra estandarizada de 500 entregas del dataset canónico (features: distancia_km, hora_salida, peso_kg), calcula la inercia de k-means para k=2..8 y la silueta media (
sklearn.metrics.silhouette_score) para cada k. ¿Coinciden el codo y el máximo de silueta? ¿Qué harías si no coinciden? -
Kruskal como clusterizador. Aplica
clusters_por_mstcon k=3 a los perfiles de zona estandarizados y compara las particiones con las de k-means y conAgglomerativeClustering(n_clusters=3, linkage="single")de sklearn. Verifica que MST y single-linkage coinciden exactamente y explica por qué k-means puede diferir.
Soluciones
Ejercicio 1:
# Dentro del bucle de kmeans_lloyd, tras asignar:
# historial.append(float(((X - centroides[asig]) ** 2).sum()))
for s in range(5):
asig, _, inercia = kmeans_lloyd(P_e, 3, semilla=s)
print(f"semilla {s}: inercia final = {inercia:.3f}")(a) es un teorema: cada paso de asignación y de actualización no puede aumentar la inercia (lo argumentamos al presentar Lloyd), así que el historial es monótono no creciente en toda ejecución. (b) es empírica: la convergencia es a un mínimo local, y cuál toque depende del arranque — verás semillas que terminan en inercias distintas. De ahí k-means++ y los reinicios.
Ejercicio 2:
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
Xm = np.column_stack([datos["distancia_km"], datos["hora_salida"],
datos["peso_kg"]])[:500]
Xm = (Xm - Xm.mean(axis=0)) / Xm.std(axis=0)
for k in range(2, 9):
km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xm)
print(f"k={k}: inercia={km.inertia_:8.1f} "
f"silueta={silhouette_score(Xm, km.labels_):.3f}")Con datos de entregas individuales (bastante continuos, sin grupos muy marcados), es normal que el codo sea suave y la silueta modesta y con máximo en k pequeño: señal honesta de que la estructura de grupos es débil. Si codo y silueta no coinciden, ni uno ni otra mandan: se examinan los candidatos (visualización, perfiles medios de cada cluster) y decide la interpretabilidad y el uso de negocio. "No hay clusters nítidos" también es un resultado válido del análisis.
Ejercicio 3:
from sklearn.cluster import AgglomerativeClustering
mst = clusters_por_mst(P_e, 3)
single = AgglomerativeClustering(n_clusters=3, linkage="single").fit_predict(P_e)
km = kmeans_lloyd(P_e, 3)[0]
print(mst, single, km, sep="\n")MST y single-linkage producen la misma partición (las etiquetas pueden estar permutadas — compara la agrupación, no los números): son el mismo algoritmo, como argumentamos, con la secuencia de fusiones dictada por las aristas del MST. k-means puede diferir porque optimiza otra cosa (inercia respecto a centroides, favoreciendo grupos redondos y equilibrados), mientras single-linkage sigue cadenas de cercanía aunque formen grupos alargados o de tamaños dispares.
Conclusión
Con el clustering completamos el mapa del módulo: aprendizaje no supervisado, donde no se imita una etiqueta sino que se descubre estructura. Viste k-means por dentro (Lloyd y su inercia monótona, k-means++ con su garantía probabilística, el codo y la silueta para elegir k, y sus supuestos esféricos), el jerárquico con su dendrograma multiescala, y DBSCAN encontrando clusters de forma libre y señalando el ruido — con la revelación de que single-linkage es exactamente el Kruskal del módulo 3 cortado en sus aristas más caras: la algoritmia clásica y el aprendizaje automático son el mismo continente. Y con ella se cierra el módulo 5 entero: partimos de un cambio de paradigma —dejar que las reglas se aprendan de los datos— y lo recorremos completo: el protocolo honesto de evaluación (05-01), clasificar (05-02), predecir cantidades con el descenso de gradiente como motor (05-03), componer no linealidad con redes y backpropagation (05-04) y descubrir estructura sin etiquetas (05-05), siempre sobre las mismas 2000 entregas del dataset canónico de Rutalia. Ahora tienes el arsenal completo del curso: análisis y estructuras (módulo 1), optimización (módulo 2), grafos (módulo 3), búsqueda y ordenación (módulo 4) y aprendizaje automático (módulo 5). En el módulo 6 dejamos de afilar herramientas y salimos al mundo: casos de estudio reales donde estos algoritmos se combinan —optimización en la industria, grafos en redes sociales, búsqueda y ordenación a gran escala, y el ML de este módulo puesto a trabajar en producción—, empezando en 06-01 por la optimización en la industria.
Algoritmos Avanzados
Módulo 1: Introducción a los Algoritmos Avanzados
- Conceptos Básicos y Notación
- Análisis de Complejidad
- Recursión y Programación Dinámica
- Estructuras de Datos Avanzadas
Módulo 2: Algoritmos de Optimización
- Programación Lineal
- Algoritmos de Optimización Combinatoria
- Backtracking y Branch and Bound
- Algoritmos Genéticos
- Optimización de Colonia de Hormigas
Módulo 3: Algoritmos en Grafos
- Representación de Grafos
- Búsqueda en Grafos: BFS y DFS
- Algoritmos de Caminos Mínimos
- Árboles de Expansión Mínima
- Algoritmos de Flujo Máximo
- Algoritmos de Emparejamiento en Grafos
Módulo 4: Algoritmos de Búsqueda y Ordenación
Módulo 5: Algoritmos de Aprendizaje Automático
- Introducción al Aprendizaje Automático
- Algoritmos de Clasificación
- Algoritmos de Regresión
- Redes Neuronales y Deep Learning
- Algoritmos de Clustering
Módulo 6: Casos de Estudio y Aplicaciones
- Optimización en la Industria
- Aplicaciones de Grafos en Redes Sociales
- Búsqueda y Ordenación en Grandes Volúmenes de Datos
- Aplicaciones de Aprendizaje Automático en la Vida Real
