Agrupar sin etiquetas: clustering

Tema 14 · Fase 3, Más modelos, tiempo y estructura · Toda la ruta

En esta ficha · 5 secciones
  1. Antes de empezar
  2. Ejercicios
  3. Mini-proyecto: pingüinos sin nombre
  4. Errores comunes
  5. ¿Lo lograste?

Antes de empezar

Varios ejercicios usan las variables y funciones de la teoría: X (los 300 puntos de las manchas), y_verdad, kmeans, inercia, dibujar_grupos, X_lunas, y_lunas y Z (las uniones del dendrograma), además de lo importado allá (KMeans, DBSCAN, AgglomerativeClustering, adjusted_rand_score, silhouette_score, make_moons). Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código y después trabaja debajo. Intenta cada ejercicio antes de abrir la respuesta.

Ejercicios

1. ¿Con etiquetas o sin ellas?

Para cada situación, di si es aprendizaje supervisado o no supervisado, y por qué.

  1. Predecir el precio de una casa a partir de su tamaño, con una tabla de casas ya vendidas.
  2. Separar en grupos a los usuarios de una biblioteca según los libros que piden, sin saber de antemano qué grupos hay.
  3. Decidir si un correo es basura, con miles de correos que alguien ya marcó como basura o no.
  4. Reducir una foto a 16 colores.
  5. Encontrar compras con tarjeta muy distintas de todas las demás, sin una lista de fraudes conocidos.
Respuesta
  1. Supervisado: cada casa vendida trae la respuesta (el precio). Es una regresión, como en el tema 6.
  2. No supervisado: no hay grupos dados; hay que descubrirlos. Es clustering.
  3. Supervisado: los correos marcados son las etiquetas. Es una clasificación.
  4. No supervisado: nadie dice cuáles son los 16 colores; K-means los encuentra agrupando los píxeles.
  5. No supervisado: se buscan puntos raros sin etiquetas. DBSCAN sirve para esto: lo que marca como ruido (−1) son candidatos a revisar.

2. K-means a mano en una recta

Seis puntos en una recta: 1, 2, 3, 8, 9 y 13. Haz K-means con k = 2 empezando con los centroides en 1 y en 2. En cada vuelta, escribe qué puntos van a cada grupo y a dónde se mueven los centroides, hasta que no cambien. Calcula la inercia final. Después compruébalo con la función kmeans de la teoría (los puntos tienen que ir como una columna, con forma (6, 1)).

Respuesta
  • Vuelta 1. Con centroides 1 y 2, el 1 va al primero y todos los demás (2, 3, 8, 9, 13) al segundo, porque están más cerca del 2. El primer centroide queda en 1 y el segundo se mueve al promedio (2 + 3 + 8 + 9 + 13) / 5 = 7.
  • Vuelta 2. Con centroides 1 y 7: el 2 está a 1 del primero y a 5 del segundo, y el 3 está a 2 y a 4, así que los dos se pasan al primer grupo. Grupos: 1, 2, 3 y 8, 9, 13. Centroides: 2 y 10.
  • Vuelta 3. Con centroides 2 y 10 nadie cambia de grupo, y los centroides se quedan quietos. Terminó.

Inercia = (1 − 2)² + (2 − 2)² + (3 − 2)² + (8 − 10)² + (9 − 10)² + (13 − 10)² = 1 + 0 + 1 + 4 + 1 + 9 = 16.

recta = np.array([1, 2, 3, 8, 9, 13]).reshape(-1, 1)
g_recta, c_recta, h = kmeans(recta, np.array([[1.0], [2.0]]))
print("Grupos:", g_recta)
print("Centroides de cada vuelta:", [ci.ravel().tolist() for ci in h])
print("Inercia:", inercia(recta, g_recta, c_recta))
Grupos: [0 0 0 1 1 1]
Centroides de cada vuelta: [[1.0, 2.0], [1.0, 7.0], [2.0, 10.0]]
Inercia: 16.0

3. Una mala inicialización

Con los 300 puntos de la teoría, elige a mano cuatro centroides iniciales que dejen a K-means en un mal mínimo local. Pásalos a tu función kmeans y compara la inercia final con 462,8. Después dale los mismos centroides a scikit-learn con KMeans(n_clusters=4, init=tus_centroides, n_init=1). ¿Llega al mismo lugar? ¿Qué cambia si en lugar de eso usas KMeans(n_clusters=4, n_init=10, random_state=0)?

Pista: mira la figura de los datos. ¿Qué pasa si dos centroides empiezan en la misma mancha?

Respuesta

Una forma: dos centroides en la mancha de la derecha, uno en la de abajo y uno entre las dos manchas de arriba.

malos = np.array([[9.0, 0.0], [10.0, 1.0], [0.0, -9.0], [-5.0, 5.0]])
g, c, h = kmeans(X, malos)
print(f"Nuestra función:  inercia {inercia(X, g, c):.1f}   "
      f"tamaños {np.bincount(g)}")

mismo_inicio = KMeans(n_clusters=4, init=malos, n_init=1).fit(X)
print(f"scikit-learn, mismo inicio:  inercia {mismo_inicio.inertia_:.1f}")

varios = KMeans(n_clusters=4, n_init=10, random_state=0).fit(X)
print(f"scikit-learn, k-means++ y n_init=10:  "
      f"inercia {varios.inertia_:.1f}")
Nuestra función:  inercia 2358.0   tamaños [ 33  42  75 150]
scikit-learn, mismo inicio:  inercia 2358.0
scikit-learn, k-means++ y n_init=10:  inercia 462.8

Con esos centroides, K-means termina con una inercia de 2358,0, unas cinco veces más que 462,8. Los tamaños lo explican: el grupo de 150 puntos son las dos manchas de arriba unidas (el centroide que pusimos entre ellas se las quedó a las dos), y la mancha de la derecha quedó partida en dos grupos de 33 y 42 puntos, porque tenía dos centroides. scikit-learn, con el mismo inicio y una sola corrida, llega exactamente al mismo mal mínimo: el algoritmo es el mismo. Con k-means++ y 10 inicios, en cambio, llega a 462,8, la buena agrupación.

4. La silueta de un punto, a mano

Con el resultado del ejercicio 2 (grupos 1, 2, 3 y 8, 9, 13), calcula a mano la silueta del punto 3: su a, su b y su s. ¿Por qué es menor que la del punto 1? Compruébalo con silhouette_samples, que da la silueta de cada punto.

Respuesta

Para el punto 3:

  • a = distancia promedio a los otros puntos de su grupo = (|3 − 1| + |3 − 2|) / 2 = (2 + 1) / 2 = 1,5.
  • b = distancia promedio al otro grupo = (|3 − 8| + |3 − 9| + |3 − 13|) / 3 = (5 + 6 + 10) / 3 = 7.
  • s = (b − a) / máx(a, b) = (7 − 1,5) / 7 ≈ 0,786.

El punto 1 está en la punta, todavía más lejos del otro grupo (b = (7 + 8 + 12) / 3 = 9) y con la misma a = 1,5, así que su silueta es más alta. El 3 es el punto del primer grupo más cercano al segundo: el que está más «en la frontera».

from sklearn.metrics import silhouette_samples

sil = silhouette_samples(recta, g_recta)
for x, s in zip(recta.ravel(), sil):
    print(f"punto {x:2d}:  silueta {s:.3f}")
punto  1:  silueta 0.833
punto  2:  silueta 0.875
punto  3:  silueta 0.786
punto  8:  silueta 0.500
punto  9:  silueta 0.643
punto 13:  silueta 0.591

5. La escala manda

Una librería tiene datos sintéticos de 200 clientes: la edad y el gasto mensual en bolivianos. La mitad son estudiantes de colegio (unos 17 años) y la otra mitad, adultos (unos 45). El gasto no depende del grupo. Agrupa con K-means (k = 2) sin escalar y escalando con StandardScaler dentro de un Pipeline (tema 10). ¿Cuál encuentra a estudiantes y adultos? ¿Por qué?

from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

rng = np.random.default_rng(0)
edad = np.concatenate([rng.normal(17, 2, 100), rng.normal(45, 6, 100)])
gasto = rng.normal(400, 120, 200)          # bolivianos al mes
clientes = np.column_stack([edad, gasto])
tipo = np.repeat([0, 1], 100)              # 0: estudiante, 1: adulto
print(clientes[:3].round(1))
[[ 17.3 320.4]
 [ 16.7 326.4]
 [ 18.3 207.4]]
Respuesta
sin_escalar = KMeans(n_clusters=2, n_init=10, random_state=0)
con_escala = make_pipeline(StandardScaler(),
                           KMeans(n_clusters=2, n_init=10, random_state=0))

for nombre, modelo in [("Sin escalar", sin_escalar),
                       ("Escalando  ", con_escala)]:
    g = modelo.fit_predict(clientes)
    medias = [clientes[g == j].mean(axis=0).round(0) for j in (0, 1)]
    print(f"{nombre}: acuerdo {adjusted_rand_score(tipo, g):.2f}  "
          f"medias (edad, gasto) {medias[0]} {medias[1]}")
Sin escalar: acuerdo 0.01  medias (edad, gasto) [ 32. 500.] [ 30. 304.]
Escalando  : acuerdo 1.00  medias (edad, gasto) [ 45. 395.] [ 17. 383.]

Sin escalar, el acuerdo con los tipos reales es 0,01: como al azar. Los dos grupos tienen casi la misma edad promedio (32 y 30 años) y se diferencian en el gasto (500 y 304 Bs): K-means partió a los clientes en «gastan más» y «gastan menos», que no es ninguna estructura real de estos datos. La razón es la escala: entre dos clientes, el gasto puede diferir en cientos de bolivianos y la edad, en unas decenas de años, así que en la distancia euclidiana el gasto pesa mucho más. Escalando, cada columna pesa lo mismo y el acuerdo es 1,00: un grupo de 17 años de promedio y otro de 45, con un gasto parecido.

6. Mover eps

Con las lunas de la teoría (X_lunas), corre DBSCAN con min_samples=5 y eps igual a 0,05; 0,1; 0,2 y 0,5. Para cada uno, cuenta los grupos y los puntos de ruido. Antes de correrlo, predice qué va a pasar en los extremos.

Respuesta
for eps in [0.05, 0.1, 0.2, 0.5]:
    g = DBSCAN(eps=eps, min_samples=5).fit_predict(X_lunas)
    print(f"eps = {eps:4}:  grupos {len(set(g) - {-1}):2d}   "
          f"ruido {(g == -1).sum():3d}   "
          f"acuerdo {adjusted_rand_score(y_lunas, g):.2f}")
eps = 0.05:  grupos  4   ruido 278   acuerdo 0.00
eps =  0.1:  grupos 14   ruido  38   acuerdo 0.20
eps =  0.2:  grupos  2   ruido   1   acuerdo 0.99
eps =  0.5:  grupos  1   ruido   0   acuerdo 0.00
  • Con eps = 0,05 el círculo es tan chico que casi nadie tiene 5 vecinos: 278 de los 300 puntos son ruido.
  • Con 0,1 aparecen 14 grupitos sueltos y 38 puntos de ruido: las lunas se rompen en pedazos.
  • Con 0,2 salen las dos lunas y un solo punto de ruido.
  • Con 0,5 el círculo es tan grande que alcanza a la otra luna: todo queda en un solo grupo.

eps controla qué tan cerca tienen que estar los puntos para encadenarse. Hay una franja de valores que funciona; fuera de ella, o todo es ruido o todo se une.

7. Elegir el algoritmo

Cuatro conjuntos sintéticos de 300 puntos, todos estandarizados:

  • manchas: tres manchas redondas;
  • lunas: las dos lunas de la teoría;
  • anillos: un anillo chico dentro de uno grande;
  • densidades: una mancha muy apretada al lado de otra muy dispersa.

Antes de correr el código, dibuja (o imagina) cada conjunto y predice qué método funciona en cada uno: K-means, DBSCAN (eps=0.3) o el jerárquico con enlace simple. Después corre el código y compara con tu predicción.

from sklearn.datasets import make_circles

formas = {
    "manchas": make_blobs(n_samples=300, centers=3, random_state=1),
    "lunas": make_moons(n_samples=300, noise=0.08, random_state=0),
    "anillos": make_circles(n_samples=300, factor=0.4, noise=0.05,
                            random_state=0),
    "densidades": make_blobs(n_samples=300, centers=[[0, 0], [6, 0]],
                             cluster_std=[0.4, 2.0], random_state=0),
}

print("              K-means  DBSCAN  Simple")
for nombre, (datos, verdad) in formas.items():
    datos = StandardScaler().fit_transform(datos)
    k = len(np.unique(verdad))
    metodos = [KMeans(n_clusters=k, n_init=10, random_state=0),
               DBSCAN(eps=0.3),
               AgglomerativeClustering(n_clusters=k, linkage="single")]
    acuerdos = [adjusted_rand_score(verdad, m.fit_predict(datos))
                for m in metodos]
    print(f"{nombre:12s}" + "".join(f"{a:8.2f}" for a in acuerdos))
              K-means  DBSCAN  Simple
manchas         0.98    0.57    0.57
lunas           0.43    0.99    1.00
anillos        -0.00    0.86    1.00
densidades      0.73    0.70    0.00
Respuesta
  • manchas: K-means acierta (0,98), porque los grupos son redondos. DBSCAN y el enlace simple se quedan en 0,57: dos de las manchas se tocan, y los dos métodos las encadenan en un solo grupo.
  • lunas: K-means falla (0,43); DBSCAN (0,99) y el enlace simple (1,00) siguen la forma.
  • anillos: K-means no tiene ninguna posibilidad (0,00), porque el centro de los dos anillos es el mismo. El enlace simple separa los anillos perfecto (1,00) y DBSCAN casi (0,86).
  • densidades: ninguno es perfecto. K-means (0,73) le pasa 22 puntos de la mancha dispersa a la apretada: su frontera queda a mitad de camino entre los dos centroides, y la mancha dispersa se extiende más allá de esa mitad. DBSCAN (0,70) no tiene un eps que sirva para las dos densidades: con 0,3 agrupa bien la mancha apretada, pero deja 58 puntos de la dispersa como ruido. El enlace simple (0,00) une las dos manchas en un grupo y deja como «segundo grupo» a 4 puntos sueltos.

La lección: no hay un método que gane siempre. Mira la forma de los datos (o pruébalos todos y mira los grupos) antes de quedarte con uno.

8. Leer el dendrograma

Mira el dendrograma de la teoría. Las cuatro últimas uniones ocurren a las alturas 9,3; 62,3; 114,4 y 162,8.

  1. ¿Cuántos grupos quedan si cortas a la altura 80? ¿Y a 130? ¿Y a 5?
  2. ¿Qué altura de corte elegirías y por qué?

Compruébalo con fcluster(Z, t=altura, criterion="distance") de scipy.cluster.hierarchy, que corta el árbol a esa altura y devuelve el grupo de cada punto.

Respuesta
  1. Una línea a la altura h deja separados los grupos que se unen por encima de h. A 80, las uniones de 114,4 y 162,8 quedan arriba: se deshacen dos uniones y quedan 3 grupos. A 130, solo la de 162,8 queda arriba: 2 grupos. A 5 quedan 14 grupos, porque se cortan también las uniones dentro de cada mancha.
  2. Un corte donde hay un salto grande entre una unión y la siguiente: entre 9,3 y 62,3, como la línea coral. Ahí se separan grupos que están lejos entre sí, sin partir ninguna mancha.
from scipy.cluster.hierarchy import fcluster

for altura in [5, 35, 80, 130]:
    g = fcluster(Z, t=altura, criterion="distance")
    print(f"Corte a {altura:3d}:  {len(np.unique(g)):3d} grupos")
Corte a   5:   14 grupos
Corte a  35:    4 grupos
Corte a  80:    3 grupos
Corte a 130:    2 grupos

Mini-proyecto: pingüinos sin nombre

Vas a agrupar pingüinos solo por sus medidas, sin mirar la especie, y al final vas a comparar tus grupos con las especies reales.

Los datos. El conjunto penguins de seaborn tiene 344 pingüinos de tres especies (Adelia, barbijo y papúa, en inglés Adelie, Chinstrap y Gentoo) medidos en tres islas de la Antártida. La primera vez que lo pides se descarga de internet; después queda guardado. Usaremos cuatro columnas numéricas:

Columna Qué es
bill_length_mm, bill_depth_mm Largo y alto del pico, en milímetros
flipper_length_mm Largo de la aleta, en milímetros
body_mass_g Masa del cuerpo, en gramos
import pandas as pd
import seaborn as sns

pinguinos = sns.load_dataset("penguins")
medidas = ["bill_length_mm", "bill_depth_mm", "flipper_length_mm",
           "body_mass_g"]
pinguinos = pinguinos.dropna(subset=medidas)
print(pinguinos.shape)
print(pinguinos[medidas].describe().loc[["mean", "std"]].round(1))
(342, 7)
      bill_length_mm  bill_depth_mm  flipper_length_mm  body_mass_g
mean            43.9           17.2              200.9       4201.8
std              5.5            2.0               14.1        802.0

Pasos.

  1. Escala. La masa está en miles de gramos y los picos en decenas de milímetros: sin escalar, la masa manda. Estandariza las cuatro columnas con StandardScaler.
  2. Elige k. Calcula la inercia y la silueta para k de 2 a 6, y grafícalas como en la teoría. ¿Qué k sugiere cada una?
  3. Agrupa. Corre K-means con el k que elegiste, y también con k = 3.
  4. Describe cada grupo. Agrega una columna grupo a la tabla y calcula con groupby el promedio de cada medida (en las unidades originales, no las estandarizadas) y cuántos pingüinos tiene cada grupo. Ponle a cada grupo un nombre que lo describa, como «aletas largas y pesados».
  5. Prueba otro método. Agrupa también con AgglomerativeClustering (enlace ward) y compara con K-means usando adjusted_rand_score.
  6. Recién ahora, mira la especie. Haz una tabla cruzada de grupo contra especie con pd.crosstab. ¿Algún grupo coincide con una especie? ¿Qué especies se mezclan y por qué podría ser?
  7. Concluye. Escribe tres conclusiones de una o dos frases cada una.
Resultados de referencia, para comprobar los tuyos
X_ping = StandardScaler().fit_transform(pinguinos[medidas])

for k in range(2, 7):
    m = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X_ping)
    print(f"k = {k}:  inercia {m.inertia_:6.1f}   "
          f"silueta {silhouette_score(X_ping, m.labels_):.3f}")

km3 = KMeans(n_clusters=3, n_init=10, random_state=0).fit(X_ping)
pinguinos["grupo"] = km3.labels_
resumen = pinguinos.groupby("grupo")[medidas].mean().round(0)
resumen["cantidad"] = pinguinos["grupo"].value_counts().sort_index()
print(resumen.to_string())

ag3 = AgglomerativeClustering(n_clusters=3).fit_predict(X_ping)
print("Acuerdo K-means y jerárquico:",
      round(adjusted_rand_score(km3.labels_, ag3), 2))
print(pd.crosstab(pinguinos["species"], pinguinos["grupo"]))
print("Acuerdo con la especie:",
      round(adjusted_rand_score(pinguinos["species"], km3.labels_), 2))
k = 2:  inercia  565.7   silueta 0.532
k = 3:  inercia  379.4   silueta 0.447
k = 4:  inercia  300.4   silueta 0.400
k = 5:  inercia  232.6   silueta 0.378
k = 6:  inercia  204.3   silueta 0.372
       bill_length_mm  bill_depth_mm  flipper_length_mm  body_mass_g  cantidad
grupo                                                                         
0                48.0           15.0              217.0       5076.0       123
1                38.0           18.0              188.0       3585.0       132
2                48.0           19.0              197.0       3902.0        87
Acuerdo K-means y jerárquico: 0.79
grupo        0    1   2
species                
Adelie       0  127  24
Chinstrap    0    5  63
Gentoo     123    0   0
Acuerdo con la especie: 0.79
  • La silueta prefiere k = 2 (0,532), y el codo no es claro. Con 2 grupos, K-means separa a los papúa (Gentoo) de todos los demás; es la separación más fuerte de los datos. Con 3 grupos, la silueta baja a 0,447.
  • Con k = 3, el grupo 0 tiene exactamente los 123 papúa: aletas largas (217 mm) y pesados (unos 5100 g). El grupo 1 tiene pico corto (38 mm) y es casi todo Adelia (127 de 132). El grupo 2 tiene pico largo (48 mm) y alto (19 mm): 63 barbijo y 24 Adelia.
  • El acuerdo con las especies es 0,79: bastante, para un método que nunca vio una etiqueta. Adelia y barbijo se mezclan porque pesan y miden casi lo mismo; lo que más las distingue es el largo del pico. El jerárquico con enlace ward da grupos parecidos (acuerdo 0,79 con K-means), pero no idénticos.
  • Que los grupos coincidan con las especies es una buena noticia, pero no estaba garantizado: los grupos podrían haber separado machos de hembras, que también difieren en tamaño.

Errores comunes

  • No escalar. K-means, DBSCAN y el jerárquico usan distancias: la columna con números más grandes decide los grupos (ejercicio 5). Escala antes, mejor dentro de un Pipeline.
  • Elegir k por la menor inercia. La inercia siempre baja al agregar grupos. Usa el codo, la silueta y lo que sabes del problema.
  • Correr K-means una sola vez. Puede quedarse en un mínimo local (ejercicio 3). Con scikit-learn, deja n_init en 10 o más y fija random_state para que el resultado se repita.
  • Leer los números de grupo como si significaran algo. El grupo 0 de una corrida puede ser el grupo 2 de otra. Para comparar agrupaciones usa adjusted_rand_score, no ==.
  • Usar K-means para formas no redondas. Con lunas, anillos o cadenas, prueba DBSCAN, el jerárquico simple o el espectral.
  • Copiar un eps de otro problema. eps es una distancia en las unidades de tus datos (escalados o no). Pruébalo con varios valores y mira cuántos puntos quedan como ruido.
  • Olvidar que DBSCAN deja puntos sin grupo. La etiqueta −1 no es un grupo: es ruido. Cuéntalo aparte.
  • Creer que los grupos son «la verdad». Un algoritmo siempre devuelve grupos, aunque los datos no tengan ninguno. Mira los datos, describe cada grupo y pregúntate si la agrupación sirve para algo.

¿Lo lograste?

Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.

  • Implementar K-means desde cero y explicar cada paso (ejercicios 2 y 3).
  • Elegir k con el método del codo y el coeficiente de silueta (ejercicio 4 y pasos 2 y 3 del mini-proyecto).
  • Usar DBSCAN y explicar eps y min_samples (ejercicios 6 y 7).
  • Construir e interpretar un dendrograma (ejercicio 8 y paso 5 del mini-proyecto).
  • Reconocer cuándo un método que sigue a los vecinos separa formas que K-means no puede (ejercicio 7).