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é.
- Predecir el precio de una casa a partir de su tamaño, con una tabla de casas ya vendidas.
- Separar en grupos a los usuarios de una biblioteca según los libros que piden, sin saber de antemano qué grupos hay.
- Decidir si un correo es basura, con miles de correos que alguien ya marcó como basura o no.
- Reducir una foto a 16 colores.
- Encontrar compras con tarjeta muy distintas de todas las demás, sin una lista de fraudes conocidos.
Respuesta
- Supervisado: cada casa vendida trae la respuesta (el precio). Es una regresión, como en el tema 6.
- No supervisado: no hay grupos dados; hay que descubrirlos. Es clustering.
- Supervisado: los correos marcados son las etiquetas. Es una clasificación.
- No supervisado: nadie dice cuáles son los 16 colores; K-means los encuentra agrupando los píxeles.
- 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
epsque 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.
- ¿Cuántos grupos quedan si cortas a la altura 80? ¿Y a 130? ¿Y a 5?
- ¿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
- 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.
- 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.
- 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. - 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?
- Agrupa. Corre K-means con el k que elegiste, y también con k = 3.
- Describe cada grupo. Agrega una columna
grupoa la tabla y calcula congroupbyel 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». - Prueba otro método. Agrupa también con
AgglomerativeClustering(enlaceward) y compara con K-means usandoadjusted_rand_score. - 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? - 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_initen 10 o más y fijarandom_statepara 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
epsde otro problema.epses 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
epsymin_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).