Módulo 4 · Aprendizaje no supervisado
Selección de método
En esta páginaEl problema
El problema
Recibes la tabla de load_wine: 178 vinos con 13 mediciones químicas, como alcohol, color, magnesio y prolina. load_wine también trae el cultivar de cada vino en vino.target. Esa columna se guarda aparte: ningún método la usa.
Llegan tres solicitudes sobre la misma tabla. Ninguna nombra un método:
| solicitud | texto |
|---|---|
| A | «Quiero una imagen para ver si los vinos forman nubes separadas.» |
| B | «Necesito menos columnas para entrenar otro modelo, sin perder más del 10 % de la varianza.» |
| C | «Dime qué tipos de vino hay y a cuál pertenece cada uno.» |
Un compañero ya respondió C. Corrió KMeans(n_clusters=3) sobre la tabla sin escalar, obtuvo grupos de 69, 47 y 62 vinos y escribió: «hay tres tipos de vino».
Tipo de salida
Las tres solicitudes piden salidas distintas. Visualizar produce dos o tres coordenadas por vino para mirarlas. Comprimir produce una tabla con menos columnas que otro método usará como entrada. Agrupar produce una etiqueta por vino.
PCA y t-SNE, de K-means y PCA y t-SNE y UMAP, producen coordenadas nuevas para cada vino.
load_wine(as_frame=True) devuelve las mediciones como DataFrame en vino.data y el cultivar como Series en vino.target. StandardScaler deja cada columna con media 0 y desviación estándar 1.
from sklearn.datasets import load_wine
from sklearn.preprocessing import StandardScaler
vino = load_wine(as_frame=True)
vinos = vino.data
cultivar = vino.target
X_esc = StandardScaler().fit_transform(vinos)
print(vinos.shape, X_esc.shape)
La salida esperada está oculta.
vinos tiene una fila por vino y una columna por medición. X_esc es un arreglo de NumPy con las mismas filas y columnas, ya escaladas.
Con un entero, n_components fija cuántas componentes guarda la PCA. Con un número entre 0 y 1, guarda las necesarias para conservar esa fracción de la varianza. explained_variance_ratio_ da la fracción de varianza de cada componente, y hasattr dice si un objeto tiene un método con ese nombre. fit_predict de k-means devuelve una etiqueta de grupo por vino; n_init=10 prueba diez inicializaciones y se queda con la mejor.
Pregunta. Para B, estima cuántas de las 13 columnas quedan. Decide también si TSNE tiene un método transform.
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
vista = PCA(n_components=2).fit(X_esc)
compresion = PCA(n_components=0.90).fit(X_esc)
grupos = KMeans(n_clusters=3, n_init=10, random_state=0).fit_predict(X_esc)
print("A:", vista.transform(X_esc).shape, round(vista.explained_variance_ratio_.sum(), 3))
print("B:", compresion.transform(X_esc).shape, round(compresion.explained_variance_ratio_.sum(), 3))
print("C:", grupos.shape)
print("TSNE tiene transform:", hasattr(TSNE(), "transform"))
La salida esperada está oculta.
Forma y ruido
Para C hay tres candidatos de M4: k-means, clustering jerárquico aglomerativo y DBSCAN. Aquí se comparan con dos conjuntos sintéticos que sí traen la respuesta. La celda plegada los genera con make_moons y make_blobs, les añade puntos de ruido uniforme y escala el resultado.
Mostrar códigoOcultar código
import numpy as np
from sklearn.datasets import make_blobs, make_moons
def con_ruido(X, y, semilla):
rng = np.random.default_rng(semilla)
ruido = rng.uniform(X.min(axis=0), X.max(axis=0), size=(20, 2))
X_total = StandardScaler().fit_transform(np.vstack([X, ruido]))
return X_total, np.concatenate([y, np.full(20, -1)])
X_lunas, y_lunas = con_ruido(*make_moons(300, noise=0.06, random_state=0), semilla=0)
X_nubes, y_nubes = con_ruido(
*make_blobs(300, centers=3, cluster_std=0.6, random_state=0), semilla=0
)
lunas tiene dos grupos curvos y nubes tres grupos redondos; ambos reciben 20 puntos de ruido uniforme, marcados con -1. La tabla da la forma de cada conjunto y cuántos puntos tiene cada etiqueta, empezando por -1.
| conjunto | forma | puntos por etiqueta: -1, 0, 1, … |
|---|---|---|
lunas | (320, 2) | 20, 150, 150 |
nubes | (320, 2) | 20, 100, 100, 100 |
El índice de Rand ajustado (adjusted_rand_score, ARI) compara dos particiones del mismo conjunto de puntos. Solo mira qué puntos quedan juntos, no el número de cada grupo. Vale 1 si las particiones coinciden y queda cerca de 0 si coinciden como lo harían por azar; puede ser negativo. Aquí se calcula solo sobre los 300 puntos reales. La comparación de métodos también muestra el tamaño del grupo más pequeño y cuántos puntos reales y de ruido reciben la etiqueta -1.
evaluar ajusta un modelo con fit_predict y devuelve esas cuatro cantidades. La máscara reales separa los 300 puntos reales de los 20 de ruido. np.bincount cuenta cuántas veces aparece cada entero no negativo, así que antes se quitan las etiquetas -1.
from sklearn.metrics import adjusted_rand_score
def evaluar(modelo, X, y):
etiquetas = modelo.fit_predict(X)
reales = y >= 0
ari = adjusted_rand_score(y[reales], etiquetas[reales])
menor = np.bincount(etiquetas[etiquetas >= 0]).min() # excluye la etiqueta -1
menos_uno_reales = (etiquetas[reales] == -1).sum()
menos_uno_ruido = (etiquetas[~reales] == -1).sum()
return ari, menor, menos_uno_reales, menos_uno_ruido
En AgglomerativeClustering, linkage decide qué par de grupos se une en cada paso: con "ward", el que menos aumenta la varianza dentro de los grupos; con "average", el de menor distancia media entre sus puntos. En DBSCAN, eps es el radio de vecindad y min_samples el número mínimo de puntos dentro de ese radio para que una región cuente como densa.
Pregunta. Para cada conjunto, ordena los cuatro métodos por ARI. Decide qué métodos no pueden dejar ningún punto como ruido.
from sklearn.cluster import DBSCAN, AgglomerativeClustering
print(f"{'datos':<6} {'método':<8} {'ARI':>5} {'grupo menor':>11} {'-1 reales':>9} {'-1 ruido':>8}")
for nombre, X, y, k in [("lunas", X_lunas, y_lunas, 2), ("nubes", X_nubes, y_nubes, 3)]:
metodos = {
"k-means": KMeans(n_clusters=k, n_init=10, random_state=0),
"ward": AgglomerativeClustering(n_clusters=k, linkage="ward"),
"average": AgglomerativeClustering(n_clusters=k, linkage="average"),
"DBSCAN": DBSCAN(eps=0.3, min_samples=5),
}
for metodo, modelo in metodos.items():
ari, menor, menos_uno_reales, menos_uno_ruido = evaluar(modelo, X, y)
print(
f"{nombre:<6} {metodo:<8} {ari:>5.3f} {menor:>11} "
f"{menos_uno_reales:>9} {menos_uno_ruido:>8}"
)
La salida esperada está oculta.
Con estos resultados, la elección del método depende de la forma de los grupos y del ruido:
| situación | candidato |
|---|---|
| grupos redondos, de tamaño parecido | k-means o ward |
| grupos curvos, no convexos | DBSCAN |
| puntos aislados que no deben forzarse a un grupo | DBSCAN |
varios valores de k desde un mismo ajuste | jerárquico |
enlace average con puntos aislados | ward, o revisar el grupo menor |
El jerárquico aparece por otra razón: su árbol, visto en Clustering por densidad y jerárquico, se corta en cualquier k sin volver a ajustar.
Escala
La respuesta del compañero usó la tabla sin escalar. k-means mide distancias euclidianas, así que una columna con mayor dispersión (desviación estándar) domina la suma. DBSCAN tiene el mismo problema: eps es una distancia en las unidades de los datos. En K-means y PCA, k-means sobre la tabla sin escalar cortó la prolina en tres franjas. El ARI mide cuánto se parece esa partición a la escalada.
vinos.std() da la desviación estándar de cada columna. groupby(grupos_crudos) reúne los vinos de cada grupo sin escalar para promediar sus columnas.
Pregunta. ¿Qué ARI esperas entre la partición sin escalar y la partición escalada: cerca de 1, cerca de 0 o intermedio? ¿Se parecerán los tamaños de los grupos?
grupos_crudos = KMeans(n_clusters=3, n_init=10, random_state=0).fit_predict(vinos)
print(vinos.std().round(2).sort_values(ascending=False).head(4))
print("tamaños sin escalar:", np.bincount(grupos_crudos))
print("tamaños escalados:", np.bincount(grupos))
print("ARI sin escalar vs escalado:", round(adjusted_rand_score(grupos_crudos, grupos), 3))
print(vinos.groupby(grupos_crudos)[["proline", "alcohol", "flavanoids"]].mean().round(1))
La salida esperada está oculta.
Estabilidad del agrupamiento
Un agrupamiento que cambia al repetir el cálculo no describe una estructura de los datos. Hay dos repeticiones útiles:
- semillas: ARI promedio entre cada par de 10 corridas de k-means con
n_init=1y semillas distintas - submuestras: ARI promedio contra la partición de referencia (k-means con
n_init=10sobre todos los datos; aquí, los 178 vinos) en 20 ajustes de k-means, cada uno con el 80 % de los vinos sin reemplazo, que etiquetan los 178 conpredict
estabilidad_semillas hace la primera prueba. Con n_init=1, cada corrida parte de una sola inicialización, así que la semilla puede cambiar el resultado. combinations(corridas, 2) da cada par de corridas una vez.
from itertools import combinations
def estabilidad_semillas(X, k, semillas=10):
corridas = [
KMeans(n_clusters=k, n_init=1, random_state=s).fit_predict(X)
for s in range(semillas)
]
pares = combinations(corridas, 2)
return np.mean([adjusted_rand_score(a, b) for a, b in pares])
estabilidad_submuestras hace la segunda. rng.choice(..., replace=False) toma índices sin repetir. modelo.predict(X) asigna cada vino al centroide más cercano, también los que no entraron al ajuste. La función devuelve además la silueta de la partición de referencia, calculada con silhouette_score de K-means y PCA, como segunda evidencia.
from sklearn.metrics import silhouette_score
def estabilidad_submuestras(X, k, repeticiones=20, fraccion=0.8):
referencia = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(X)
rng = np.random.default_rng(0)
tamano = int(fraccion * len(X))
valores = []
for _ in range(repeticiones):
indices = rng.choice(len(X), size=tamano, replace=False)
modelo = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X[indices])
valores.append(adjusted_rand_score(referencia, modelo.predict(X)))
return np.mean(valores), silhouette_score(X, referencia)
Pregunta. Para k de 2 a 6 sobre X_esc, ¿qué k será el más estable? ¿La estabilidad baja siempre al aumentar k?
print(" k semillas submuestras silueta")
for k in range(2, 7):
semillas = estabilidad_semillas(X_esc, k)
submuestras, silueta = estabilidad_submuestras(X_esc, k)
print(f"{k:>2} {semillas:>8.3f} {submuestras:>11.3f} {silueta:>7.3f}")
La salida esperada está oculta.
Grupos y categorías
Las mismas pruebas se aplican ahora a gota: 300 puntos de una sola distribución normal, alargada en el eje horizontal, con desviación estándar 3 en x y 0.7 en y.
Pregunta. Con k=2, ¿la estabilidad por semillas de gota será mayor o menor que la de los vinos con k=3? ¿Y su silueta?
Ver resultado
La tabla muestra, para gota con k=2, las dos pruebas de estabilidad y la silueta, con la fila de los vinos escalados con k=3 como referencia. «semillas» es el ARI promedio entre pares de corridas con semillas distintas, «submuestras» el ARI promedio contra la partición de referencia y «silueta» es la silueta de la partición de referencia. Por ejemplo, la fila de gota dice que dos corridas de k-means con semillas distintas coinciden, en promedio, con ARI 0.981.
| datos | k | semillas | submuestras | silueta |
|---|---|---|---|---|
gota | 2 | 0.981 | 0.901 | 0.497 |
| vinos escalados | 3 | 0.957 | 0.980 | 0.285 |
gota sale de una sola distribución y aun así obtiene 0.981 por semillas, más que el 0.957 de los vinos con k=3. Su silueta, 0.497, también supera el 0.285 de los vinos. Por submuestras, gota queda en 0.901, debajo del 0.980 de los vinos, pero sigue alta: ninguna de las tres cifras separa una nube única de una tabla con categorías. k-means corta la nube alargada en dos mitades, y la forma favorece el mismo corte en cada corrida. Estabilidad y silueta miden si una partición se repite y si sus grupos se separan. No miden si detrás de cada grupo hay una categoría distinta: una partición estable y con buena silueta todavía no es una lista de categorías reales.Ahora entra la columna apartada. El cultivar es evidencia externa: no salió de las 13 mediciones que usó el clustering. pd.crosstab cuenta cuántos vinos de cada grupo hay en cada cultivar.
Pregunta. K-means y PCA mostró las tablas cruzadas de ambas particiones contra el cultivar. Estima el ARI de cada una: ¿la escalada llegará a 1? ¿La del compañero quedará más cerca de 0 o de la escalada?
import pandas as pd
print("ARI sin escalar vs cultivar:", round(adjusted_rand_score(cultivar, grupos_crudos), 3))
print("ARI escalado vs cultivar:", round(adjusted_rand_score(cultivar, grupos), 3))
print(pd.crosstab(pd.Series(grupos, name="grupo"), cultivar.rename("cultivar")))
La salida esperada está oculta.
Aun con esta coincidencia, la palabra «cultivar» vino de vino.target, no del clustering. Sin esa columna, el grupo 0 solo es «vinos cercanos al mismo centroide». Para las tres solicitudes: A se responde con PCA de dos componentes o con t-SNE; B, con StandardScaler y PCA(n_components=0.90) en un Pipeline; C, con k-means sobre datos escalados y k=3, descrito como una partición estable de las mediciones. Llamar «tipo de vino» a cada grupo requiere una fuente externa como el cultivar.
Práctica
Traza guiada
Antes de ejecutar, predice el ARI de cada par. Decide cuál vale exactamente 1, cuál es menor que 1 pero positivo y cuál queda cerca de 0 o por debajo.
from sklearn.metrics import adjusted_rand_score
a = [0, 0, 1, 1, 2, 2]
b = [2, 2, 0, 0, 1, 1]
c = [0, 0, 1, 1, 1, 1]
d = [0, 1, 2, 0, 1, 2]
Comprueba con adjusted_rand_score(a, b), adjusted_rand_score(a, c) y adjusted_rand_score(a, d). Explica con los pares de puntos por qué a y b coinciden aunque ningún número de grupo sea igual.
Submuestras de la mitad
Cambia fraccion=0.8 por fraccion=0.5 en estabilidad_submuestras y repite la tabla de k de 2 a 6. Antes de procesar, predice si la estabilidad sube o baja y si k=3 sigue como el más estable. Explica el resultado con la cantidad de vinos que ve cada ajuste.
Caso de iris
Carga load_iris(return_X_y=True) y aparta la especie sin mirarla. Escribe una nota que incluya:
- si la tarea es visualizar, comprimir o agrupar, y la forma de la salida
- las desviaciones estándar de las columnas y la decisión de escalar o no
- el método elegido entre k-means, jerárquico y DBSCAN, con la razón según forma y ruido
- la tabla de estabilidad y silueta para
kde 2 a 5 - el ARI contra la especie y un
pd.crosstab, calculados al final
Si el k más estable no es 3, explica qué especies quedan juntas.
Reto opcional
AgglomerativeClustering no tiene predict. Mide la estabilidad de linkage="ward" con n_clusters=3 sobre X_esc con otra regla: ajusta en dos submuestras distintas del 80 % y calcula el ARI solo sobre los vinos que aparecen en ambas. Compara el promedio con el de k-means.
Errores que revisar
- Si comparas etiquetas de dos corridas con
==, el resultado depende de la numeración de los grupos. Compara particiones conadjusted_rand_score. - Si una columna tiene una desviación estándar cientos de veces mayor que las demás, k-means y DBSCAN agrupan casi solo por ella. Revisa
std()y escala antes de agrupar. - Si DBSCAN devuelve un solo grupo o casi todo ruido,
epsno corresponde a las unidades de los datos. Escala y prueba variosepsantes de descartar el método. - Si comprimes con t-SNE para entrenar otro modelo, no podrás transformar datos nuevos. Usa PCA cuando la salida sea entrada de otro método.
- Si nombras un grupo («vinos caros», «perfil avanzado») a partir de sus promedios, solo describiste una región del espacio. Un nombre de categoría necesita evidencia externa a las columnas agrupadas.
Cierre
Un análisis de 500 estudiantes con 6 puntajes estandarizados produce 4 grupos con k-means. La estabilidad por semillas es 0.97 y la silueta es 0.48. Explica si esos números bastan para afirmar «hay cuatro perfiles de estudiante» y qué evidencia pedirías.
Lista de comprobación
- ¿Qué salida produce cada tarea: visualizar, comprimir y agrupar?
- ¿Por qué k-means separa mal dos lunas y DBSCAN no?
- ¿Qué mide el ARI y por qué no depende de la numeración de los grupos?
- ¿Por qué una partición estable de
gotano demuestra dos categorías?
Documentación oficial
- Clustering en scikit-learn: comparación de k-means, DBSCAN y jerárquico, y métricas de evaluación de particiones.
- Comparación de algoritmos de clustering: los mismos métodos sobre conjuntos con formas y densidades distintas.
adjusted_rand_score: definición, rango de valores y ejemplos del ARI.- Descomposición de señales: PCA, varianza explicada y
transformpara datos nuevos.