Comparativa de Modelos de Clustering
Comparación de K-Means, clustering jerárquico, DBSCAN y Gaussian Mixture sobre datos reales de Covertype.
Clustering completo en datos reales: K-Means, jerárquico, DBSCAN y Gaussian Mixture
En este notebook construiremos una práctica integral, didáctica y rigurosa de aprendizaje no supervisado para clustering, en coherencia con la teoría del submódulo de Machine Learning (K-Means y métodos alternativos).
Trabajaremos con un dataset real y complejo: Forest CoverType (fetch_covtype), que contiene variables cartográficas y topográficas con estructura no trivial. Aunque el clustering es no supervisado, el dataset sí incluye etiquetas reales de tipo de cobertura forestal; las usaremos solo para evaluación posterior, nunca para entrenar.
Objetivos del notebook
- Entender cómo aplicar clustering de forma práctica sobre datos tabulares complejos.
- Comparar varios enfoques mencionados en teoría:
- K-Means
- Clustering jerárquico (Agglomerative)
- DBSCAN
- Gaussian Mixture Model (GMM)
- Analizar resultados con métricas internas y, cuando proceda, externas.
- Visualizar estructuras, fronteras y errores de segmentación para aprender a interpretar resultados.
Fundamentos matemáticos y computacionales (nivel fundamentos)
1) Qué es clustering
Dado un conjunto de datos sin etiquetas $X={x_i}_{i=1}^n$, queremos encontrar grupos de observaciones similares.
2) K-Means
K-Means busca $K$ centroides minimizando la inercia (WCSS):
$$ J = \sum_{k=1}^{K}\sum_{x_i \in C_k}\lVert x_i - \mu_k Vert^2 $$
Es rápido y potente, pero asume clusters aproximadamente esféricos y exige elegir $K$.
3) Clustering jerárquico (agglomerative)
Construye clusters fusionando puntos/grupos de forma iterativa según una regla de enlace (ward, complete, average...). No requiere un entrenamiento "por épocas", pero sí decisiones de corte del dendrograma (número final de clusters).
4) DBSCAN
Define clusters por densidad usando dos hiperparámetros:
eps: radio de vecindad.min_samples: densidad mínima local.
Ventajas: detecta formas arbitrarias e identifica ruido/outliers. Limitación: sensible a escala y a la elección de hiperparámetros.
5) Gaussian Mixture Model (GMM)
Modela los datos como mezcla de gaussianas:
$$ p(x)=\sum_{k=1}^{K} \pi_k, \mathcal{N}(x\mid\mu_k,\Sigma_k) $$
Se entrena con EM (Expectation-Maximization), permite clusters elípticos y asignaciones probabilísticas.
Qué compararemos exactamente
- Selección de hiperparámetros (ej. K en K-Means/GMM, eps en DBSCAN).
- Métricas internas: Silhouette, Calinski-Harabasz, Davies-Bouldin.
- Métricas externas (solo diagnóstico): ARI, NMI, V-measure frente a etiquetas reales.
- Visualizaciones 2D con PCA para interpretar la geometría de clusters.
Importante: en escenarios reales de clustering no siempre hay etiquetas verdaderas; por eso priorizamos métricas internas e interpretabilidad.
# Configuración general y librerías
import warnings
warnings.filterwarnings('ignore')
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import fetch_covtype
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans, AgglomerativeClustering, DBSCAN
from sklearn.mixture import GaussianMixture
from sklearn.metrics import (
silhouette_score,
calinski_harabasz_score,
davies_bouldin_score,
adjusted_rand_score,
normalized_mutual_info_score,
v_measure_score
)
sns.set_theme(style='whitegrid', context='notebook')
plt.rcParams['figure.figsize'] = (9, 5)
SEED = 42
np.random.seed(SEED)
1) Carga de datos y EDA inicial
Comenzamos explorando el dataset para entender su tamaño, tipos de variables y balance de clases reales (solo para diagnóstico, no para entrenamiento).
# Cargamos CoverType
cov = fetch_covtype(as_frame=True)
df = cov.frame.copy()
# Convertimos etiquetas a [0..6] para comodidad en visualización/métricas externas
df['Cover_Type'] = df['Cover_Type'] - 1
print('Shape total:', df.shape)
print('Número de clases reales:', df['Cover_Type'].nunique())
df.head()
Shape total: (581012, 55) Número de clases reales: 7
| Elevation | Aspect | Slope | Horizontal_Distance_To_Hydrology | Vertical_Distance_To_Hydrology | Horizontal_Distance_To_Roadways | Hillshade_9am | Hillshade_Noon | Hillshade_3pm | Horizontal_Distance_To_Fire_Points | ... | Soil_Type_31 | Soil_Type_32 | Soil_Type_33 | Soil_Type_34 | Soil_Type_35 | Soil_Type_36 | Soil_Type_37 | Soil_Type_38 | Soil_Type_39 | Cover_Type | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2596.0 | 51.0 | 3.0 | 258.0 | 0.0 | 510.0 | 221.0 | 232.0 | 148.0 | 6279.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 4 |
| 1 | 2590.0 | 56.0 | 2.0 | 212.0 | -6.0 | 390.0 | 220.0 | 235.0 | 151.0 | 6225.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 4 |
| 2 | 2804.0 | 139.0 | 9.0 | 268.0 | 65.0 | 3180.0 | 234.0 | 238.0 | 135.0 | 6121.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1 |
| 3 | 2785.0 | 155.0 | 18.0 | 242.0 | 118.0 | 3090.0 | 238.0 | 238.0 | 122.0 | 6211.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 1 |
| 4 | 2595.0 | 45.0 | 2.0 | 153.0 | -1.0 | 391.0 | 220.0 | 234.0 | 150.0 | 6172.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 4 |
5 rows × 55 columns
# Revisión estructural y nulos
print(df.info())
print('\nTotal de nulos en dataset:', int(df.isnull().sum().sum()))
<class 'pandas.core.frame.DataFrame'> RangeIndex: 581012 entries, 0 to 581011 Data columns (total 55 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Elevation 581012 non-null float64 1 Aspect 581012 non-null float64 2 Slope 581012 non-null float64 3 Horizontal_Distance_To_Hydrology 581012 non-null float64 4 Vertical_Distance_To_Hydrology 581012 non-null float64 5 Horizontal_Distance_To_Roadways 581012 non-null float64 6 Hillshade_9am 581012 non-null float64 7 Hillshade_Noon 581012 non-null float64 8 Hillshade_3pm 581012 non-null float64 9 Horizontal_Distance_To_Fire_Points 581012 non-null float64 10 Wilderness_Area_0 581012 non-null float64 11 Wilderness_Area_1 581012 non-null float64 12 Wilderness_Area_2 581012 non-null float64 13 Wilderness_Area_3 581012 non-null float64 14 Soil_Type_0 581012 non-null float64 15 Soil_Type_1 581012 non-null float64 16 Soil_Type_2 581012 non-null float64 17 Soil_Type_3 581012 non-null float64 18 Soil_Type_4 581012 non-null float64 19 Soil_Type_5 581012 non-null float64 20 Soil_Type_6 581012 non-null float64 21 Soil_Type_7 581012 non-null float64 22 Soil_Type_8 581012 non-null float64 23 Soil_Type_9 581012 non-null float64 24 Soil_Type_10 581012 non-null float64 25 Soil_Type_11 581012 non-null float64 26 Soil_Type_12 581012 non-null float64 27 Soil_Type_13 581012 non-null float64 28 Soil_Type_14 581012 non-null float64 29 Soil_Type_15 581012 non-null float64 30 Soil_Type_16 581012 non-null float64 31 Soil_Type_17 581012 non-null float64 32 Soil_Type_18 581012 non-null float64 33 Soil_Type_19 581012 non-null float64 34 Soil_Type_20 581012 non-null float64 35 Soil_Type_21 581012 non-null float64 36 Soil_Type_22 581012 non-null float64 37 Soil_Type_23 581012 non-null float64 38 Soil_Type_24 581012 non-null float64 39 Soil_Type_25 581012 non-null float64 40 Soil_Type_26 581012 non-null float64 41 Soil_Type_27 581012 non-null float64 42 Soil_Type_28 581012 non-null float64 43 Soil_Type_29 581012 non-null float64 44 Soil_Type_30 581012 non-null float64 45 Soil_Type_31 581012 non-null float64 46 Soil_Type_32 581012 non-null float64 47 Soil_Type_33 581012 non-null float64 48 Soil_Type_34 581012 non-null float64 49 Soil_Type_35 581012 non-null float64 50 Soil_Type_36 581012 non-null float64 51 Soil_Type_37 581012 non-null float64 52 Soil_Type_38 581012 non-null float64 53 Soil_Type_39 581012 non-null float64 54 Cover_Type 581012 non-null int32 dtypes: float64(54), int32(1) memory usage: 241.6 MB None Total de nulos en dataset: 0
# Distribución de clases reales
class_counts = df['Cover_Type'].value_counts().sort_index()
class_props = (class_counts / class_counts.sum()).round(4)
display(pd.DataFrame({'count': class_counts, 'proportion': class_props}))
plt.figure(figsize=(8, 4))
sns.barplot(x=class_counts.index, y=class_counts.values, palette='viridis')
plt.title('Distribución de clases reales (solo referencia)')
plt.xlabel('Clase real')
plt.ylabel('Número de muestras')
plt.tight_layout()
plt.show()
| count | proportion | |
|---|---|---|
| Cover_Type | ||
| 0 | 211840 | 0.3646 |
| 1 | 283301 | 0.4876 |
| 2 | 35754 | 0.0615 |
| 3 | 2747 | 0.0047 |
| 4 | 9493 | 0.0163 |
| 5 | 17367 | 0.0299 |
| 6 | 20510 | 0.0353 |
# EDA visual sobre una muestra para no saturar gráficos
eda_sample = df.sample(6000, random_state=SEED)
num_cols = [
'Elevation', 'Aspect', 'Slope',
'Horizontal_Distance_To_Hydrology',
'Vertical_Distance_To_Hydrology',
'Horizontal_Distance_To_Roadways',
'Hillshade_9am', 'Hillshade_Noon', 'Hillshade_3pm',
'Horizontal_Distance_To_Fire_Points'
]
_ = eda_sample[num_cols + ['Cover_Type']].hist(bins=30, figsize=(14, 10), edgecolor='black')
plt.suptitle('Distribuciones (muestra EDA)', y=1.02)
plt.tight_layout()
plt.show()
2) Preparación para clustering
El dataset completo es grande; para una práctica docente reproducible usamos una muestra estratificada de 30k observaciones.
Además:
- estandarizamos variables (
StandardScaler), - aplicamos PCA para visualización (2D) y para apoyar algunos experimentos con DBSCAN (20D).
# Muestreo estratificado para trabajo práctico
N_SAMPLE = 30_000
sample_df = (
df.groupby('Cover_Type', group_keys=False)
.apply(lambda x: x.sample(max(1, int(np.floor(len(x) / len(df) * N_SAMPLE))), random_state=SEED))
)
# Ajuste fino para tener tamaño exacto
if len(sample_df) > N_SAMPLE:
sample_df = sample_df.sample(N_SAMPLE, random_state=SEED)
elif len(sample_df) < N_SAMPLE:
faltan = N_SAMPLE - len(sample_df)
extra = df.drop(sample_df.index).sample(faltan, random_state=SEED)
sample_df = pd.concat([sample_df, extra], axis=0)
sample_df = sample_df.sample(frac=1, random_state=SEED).reset_index(drop=True)
X = sample_df.drop(columns=['Cover_Type']).values
y_true = sample_df['Cover_Type'].values.astype(int)
print('Shape muestra de trabajo:', X.shape)
print('Clases reales en muestra:', np.unique(y_true))
Shape muestra de trabajo: (30000, 54) Clases reales en muestra: [0 1 2 3 4 5 6]
# Estandarización (clave para distancias y DBSCAN)
scaler = StandardScaler()
X_sc = scaler.fit_transform(X)
# PCA para visualización 2D
pca2 = PCA(n_components=2, random_state=SEED)
X_pca2 = pca2.fit_transform(X_sc)
# PCA para reducir ruido/dimensión en algunos experimentos densidad
pca20 = PCA(n_components=20, random_state=SEED)
X_pca20 = pca20.fit_transform(X_sc)
print('Varianza explicada PCA 2D:', pca2.explained_variance_ratio_.sum().round(4))
print('Varianza explicada PCA 20D:', pca20.explained_variance_ratio_.sum().round(4))
Varianza explicada PCA 2D: 0.1243 Varianza explicada PCA 20D: 0.5469
# Visualización base en 2D por clase real (solo referencia)
plt.figure(figsize=(8, 6))
scatter = plt.scatter(X_pca2[:, 0], X_pca2[:, 1], c=y_true, s=8, alpha=0.45, cmap='tab10')
plt.title('PCA 2D coloreado por clase real (diagnóstico)')
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.colorbar(scatter, label='Clase real')
plt.tight_layout()
plt.show()
3) Funciones auxiliares de evaluación
Definimos métricas internas y externas para comparar modelos con una tabla homogénea.
def safe_internal_metrics(X_eval, labels):
"""Métricas internas robustas a casos degenerados (1 cluster o todo ruido)."""
unique = np.unique(labels)
# Ignoramos ruido -1 de DBSCAN para métricas internas cuando exista
mask = labels != -1
if len(unique) <= 1 or mask.sum() < 5 or len(np.unique(labels[mask])) <= 1:
return {'Silhouette': np.nan, 'CalinskiHarabasz': np.nan, 'DaviesBouldin': np.nan}
X_m = X_eval[mask]
y_m = labels[mask]
return {
'Silhouette': silhouette_score(X_m, y_m),
'CalinskiHarabasz': calinski_harabasz_score(X_m, y_m),
'DaviesBouldin': davies_bouldin_score(X_m, y_m)
}
def external_metrics(y_true, labels):
"""Métricas externas usando clases reales solo para diagnóstico."""
return {
'ARI': adjusted_rand_score(y_true, labels),
'NMI': normalized_mutual_info_score(y_true, labels),
'VMeasure': v_measure_score(y_true, labels)
}
def summarize_model(name, X_eval, y_true, labels):
row = {'Modelo': name, 'N_clusters': len(np.unique(labels[labels != -1]))}
row.update(safe_internal_metrics(X_eval, labels))
row.update(external_metrics(y_true, labels))
row['Noise_ratio'] = float(np.mean(labels == -1))
return row
def plot_cluster_map(X2d, labels, title):
plt.figure(figsize=(8, 6))
plt.scatter(X2d[:, 0], X2d[:, 1], c=labels, s=8, alpha=0.5, cmap='tab20')
plt.title(title)
plt.xlabel('PC1')
plt.ylabel('PC2')
plt.tight_layout()
plt.show()
4) Modelo 1 — K-Means
Seguimos la teoría: usamos método del codo (inercia) y silhouette para decidir un valor razonable de $K$.
k_range = range(2, 13)
km_inertia, km_silhouette = [], []
for k in k_range:
km_tmp = KMeans(n_clusters=k, n_init=20, random_state=SEED)
labels_tmp = km_tmp.fit_predict(X_sc)
km_inertia.append(km_tmp.inertia_)
km_silhouette.append(silhouette_score(X_sc, labels_tmp))
fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))
axes[0].plot(list(k_range), km_inertia, marker='o')
axes[0].set_title('K-Means: método del codo (inercia)')
axes[0].set_xlabel('K')
axes[0].set_ylabel('Inercia')
axes[1].plot(list(k_range), km_silhouette, marker='o', color='darkgreen')
axes[1].set_title('K-Means: silhouette vs K')
axes[1].set_xlabel('K')
axes[1].set_ylabel('Silhouette')
plt.tight_layout()
plt.show()
k_best = int(list(k_range)[np.argmax(km_silhouette)])
print('K sugerido por silhouette:', k_best)
K sugerido por silhouette: 12
kmeans = KMeans(n_clusters=k_best, n_init=30, random_state=SEED)
kmeans_labels = kmeans.fit_predict(X_sc)
plot_cluster_map(X_pca2, kmeans_labels, f'K-Means (K={k_best}) en PCA2D')
kmeans_row = summarize_model(f'KMeans (K={k_best})', X_sc, y_true, kmeans_labels)
kmeans_row
{'Modelo': 'KMeans (K=12)',
'N_clusters': 12,
'Silhouette': 0.20894340337835718,
'CalinskiHarabasz': 1090.6910751124635,
'DaviesBouldin': 1.8758360280758588,
'ARI': 0.054205026823897905,
'NMI': 0.17190307236636737,
'VMeasure': 0.1719030723663674,
'Noise_ratio': 0.0}
5) Modelo 2 — Clustering jerárquico (Agglomerative)
Compararemos varios números de clusters y linkages. Para mantener coste razonable, usamos una submuestra para el barrido y luego ajustamos la mejor configuración.
# Submuestra para búsqueda de hiperparámetros jerárquicos
idx_sub = np.random.RandomState(SEED).choice(len(X_sc), size=6000, replace=False)
X_h_sub = X_sc[idx_sub]
y_h_sub = y_true[idx_sub]
linkages = ['ward', 'complete', 'average']
k_range_h = range(2, 13)
rows_h = []
for link in linkages:
for k in k_range_h:
# Ward requiere distancia euclídea y funciona bien en datos estandarizados
model = AgglomerativeClustering(n_clusters=k, linkage=link)
labels_h = model.fit_predict(X_h_sub)
row = {'linkage': link, 'k': k}
row.update(safe_internal_metrics(X_h_sub, labels_h))
row.update(external_metrics(y_h_sub, labels_h))
rows_h.append(row)
ag_search = pd.DataFrame(rows_h)
ag_search.head()
| linkage | k | Silhouette | CalinskiHarabasz | DaviesBouldin | ARI | NMI | VMeasure | |
|---|---|---|---|---|---|---|---|---|
| 0 | ward | 2 | 0.102688 | 327.288086 | 3.674373 | 0.036949 | 0.079077 | 0.079077 |
| 1 | ward | 3 | 0.111769 | 287.453872 | 3.178844 | 0.082184 | 0.134495 | 0.134495 |
| 2 | ward | 4 | 0.084265 | 266.542405 | 3.127694 | 0.101133 | 0.162612 | 0.162612 |
| 3 | ward | 5 | 0.092889 | 259.040531 | 2.575666 | 0.099964 | 0.162269 | 0.162269 |
| 4 | ward | 6 | 0.100011 | 256.079591 | 2.202763 | 0.100084 | 0.161696 | 0.161696 |
# Curvas de silhouette por linkage (análogo de curva de aprendizaje para elección de k)
plt.figure(figsize=(9, 5))
for link in linkages:
sub = ag_search[ag_search['linkage'] == link].sort_values('k')
plt.plot(sub['k'], sub['Silhouette'], marker='o', label=link)
plt.title('Agglomerative: silhouette vs número de clusters')
plt.xlabel('n_clusters')
plt.ylabel('Silhouette')
plt.legend()
plt.tight_layout()
plt.show()
best_ag = ag_search.sort_values('Silhouette', ascending=False).iloc[0]
best_link, best_k = best_ag['linkage'], int(best_ag['k'])
print('Mejor configuración jerárquica (submuestra):', best_link, '| k =', best_k)
Mejor configuración jerárquica (submuestra): complete | k = 2
# Ajuste final en todo el conjunto de trabajo
agg = AgglomerativeClustering(n_clusters=best_k, linkage=best_link)
agg_labels = agg.fit_predict(X_sc)
plot_cluster_map(X_pca2, agg_labels, f'Agglomerative ({best_link}, k={best_k}) en PCA2D')
agg_row = summarize_model(f'Agglomerative ({best_link}, k={best_k})', X_sc, y_true, agg_labels)
agg_row
{'Modelo': 'Agglomerative (complete, k=2)',
'N_clusters': 2,
'Silhouette': 0.8530085557516217,
'CalinskiHarabasz': 579.1987931037814,
'DaviesBouldin': 0.1242846654703788,
'ARI': 0.00022402380933890685,
'NMI': 0.0004831680606908407,
'VMeasure': 0.0004831680606908407,
'Noise_ratio': 0.0}
6) Modelo 3 — DBSCAN
DBSCAN es muy sensible a escala/dimensión. Para facilitar densidades más estables, haremos barrido de eps sobre representación PCA-20D.
eps_values = np.linspace(1.2, 3.2, 9)
min_samples = 12
db_rows = []
for eps in eps_values:
db = DBSCAN(eps=float(eps), min_samples=min_samples, n_jobs=-1)
labels_db = db.fit_predict(X_pca20)
n_clusters = len(np.unique(labels_db[labels_db != -1]))
noise_ratio = np.mean(labels_db == -1)
internal = safe_internal_metrics(X_pca20, labels_db)
db_rows.append({
'eps': float(eps),
'n_clusters': n_clusters,
'noise_ratio': noise_ratio,
'Silhouette': internal['Silhouette']
})
db_scan = pd.DataFrame(db_rows)
db_scan
| eps | n_clusters | noise_ratio | Silhouette | |
|---|---|---|---|---|
| 0 | 1.20 | 75 | 0.046300 | 0.424600 |
| 1 | 1.45 | 71 | 0.021867 | 0.401155 |
| 2 | 1.70 | 70 | 0.011633 | 0.409067 |
| 3 | 1.95 | 61 | 0.006600 | 0.438157 |
| 4 | 2.20 | 58 | 0.003567 | 0.440025 |
| 5 | 2.45 | 59 | 0.002000 | 0.435833 |
| 6 | 2.70 | 59 | 0.001533 | 0.435808 |
| 7 | 2.95 | 51 | 0.001333 | 0.398590 |
| 8 | 3.20 | 48 | 0.001200 | 0.399954 |
fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))
axes[0].plot(db_scan['eps'], db_scan['n_clusters'], marker='o')
axes[0].set_title('DBSCAN: clusters detectados vs eps')
axes[0].set_xlabel('eps')
axes[0].set_ylabel('Nº clusters (sin ruido)')
axes[1].plot(db_scan['eps'], db_scan['noise_ratio'], marker='o', color='firebrick')
axes[1].set_title('DBSCAN: ratio de ruido vs eps')
axes[1].set_xlabel('eps')
axes[1].set_ylabel('Proporción de ruido')
plt.tight_layout()
plt.show()
# Elegimos eps con mejor silhouette válida
valid = db_scan.dropna(subset=['Silhouette'])
if len(valid) == 0:
eps_best = 2.0
else:
eps_best = float(valid.sort_values('Silhouette', ascending=False).iloc[0]['eps'])
print('eps elegido para DBSCAN:', eps_best)
eps elegido para DBSCAN: 2.2
dbscan = DBSCAN(eps=eps_best, min_samples=min_samples, n_jobs=-1)
db_labels = dbscan.fit_predict(X_pca20)
plot_cluster_map(X_pca2, db_labels, f'DBSCAN (eps={eps_best:.2f}, min_samples={min_samples}) en PCA2D')
db_row = summarize_model(f'DBSCAN (eps={eps_best:.2f})', X_pca20, y_true, db_labels)
db_row
{'Modelo': 'DBSCAN (eps=2.20)',
'N_clusters': 58,
'Silhouette': 0.4400252296653968,
'CalinskiHarabasz': 2519.9771129230694,
'DaviesBouldin': 0.8442049612590427,
'ARI': 0.05538245376354429,
'NMI': 0.20152156552480283,
'VMeasure': 0.20152156552480283,
'Noise_ratio': 0.0035666666666666668}
7) Modelo 4 — Gaussian Mixture Model (GMM)
Para elegir número de componentes usamos BIC/AIC. Luego analizamos evolución del lower bound de EM con distintas iteraciones máximas.
comp_range = range(2, 13)
bic_vals, aic_vals = [], []
for c in comp_range:
gmm_tmp = GaussianMixture(
n_components=c,
covariance_type='full',
random_state=SEED,
n_init=3,
max_iter=200
)
gmm_tmp.fit(X_pca20)
bic_vals.append(gmm_tmp.bic(X_pca20))
aic_vals.append(gmm_tmp.aic(X_pca20))
fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))
axes[0].plot(list(comp_range), bic_vals, marker='o')
axes[0].set_title('GMM: BIC vs nº componentes')
axes[0].set_xlabel('n_components')
axes[0].set_ylabel('BIC (menor es mejor)')
axes[1].plot(list(comp_range), aic_vals, marker='o', color='purple')
axes[1].set_title('GMM: AIC vs nº componentes')
axes[1].set_xlabel('n_components')
axes[1].set_ylabel('AIC (menor es mejor)')
plt.tight_layout()
plt.show()
gmm_best_c = int(list(comp_range)[np.argmin(bic_vals)])
print('n_components elegido por BIC:', gmm_best_c)
n_components elegido por BIC: 12
# Curva de convergencia aproximada de EM (lower bound) aumentando max_iter con warm_start
iters = list(range(5, 105, 5))
gmm_prog = GaussianMixture(
n_components=gmm_best_c,
covariance_type='full',
random_state=SEED,
n_init=1,
warm_start=True,
max_iter=1
)
lower_bounds = []
for it in iters:
gmm_prog.set_params(max_iter=it)
gmm_prog.fit(X_pca20)
lower_bounds.append(gmm_prog.lower_bound_)
plt.figure(figsize=(8, 4.5))
plt.plot(iters, lower_bounds, marker='o')
plt.title('GMM: evolución del lower bound (EM)')
plt.xlabel('max_iter')
plt.ylabel('Lower bound (mayor es mejor)')
plt.tight_layout()
plt.show()
gmm = GaussianMixture(
n_components=gmm_best_c,
covariance_type='full',
random_state=SEED,
n_init=5,
max_iter=200
)
gmm.fit(X_pca20)
gmm_labels = gmm.predict(X_pca20)
plot_cluster_map(X_pca2, gmm_labels, f'GMM (n_components={gmm_best_c}) en PCA2D')
gmm_row = summarize_model(f'GMM (C={gmm_best_c})', X_pca20, y_true, gmm_labels)
gmm_row
{'Modelo': 'GMM (C=12)',
'N_clusters': 12,
'Silhouette': 0.2790680979361827,
'CalinskiHarabasz': 2440.4569523056016,
'DaviesBouldin': 1.6247852041342667,
'ARI': 0.05896619082273678,
'NMI': 0.1659608656424893,
'VMeasure': 0.16596086564248932,
'Noise_ratio': 0.0}
8) Comparativa global de modelos de clustering
results = pd.DataFrame([kmeans_row, agg_row, db_row, gmm_row])
# Ordenamos por silhouette (si hay NaN, quedan al final)
results_sorted = results.sort_values('Silhouette', ascending=False, na_position='last').reset_index(drop=True)
results_sorted
| Modelo | N_clusters | Silhouette | CalinskiHarabasz | DaviesBouldin | ARI | NMI | VMeasure | Noise_ratio | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | Agglomerative (complete, k=2) | 2 | 0.853009 | 579.198793 | 0.124285 | 0.000224 | 0.000483 | 0.000483 | 0.000000 |
| 1 | DBSCAN (eps=2.20) | 58 | 0.440025 | 2519.977113 | 0.844205 | 0.055382 | 0.201522 | 0.201522 | 0.003567 |
| 2 | GMM (C=12) | 12 | 0.279068 | 2440.456952 | 1.624785 | 0.058966 | 0.165961 | 0.165961 | 0.000000 |
| 3 | KMeans (K=12) | 12 | 0.208943 | 1090.691075 | 1.875836 | 0.054205 | 0.171903 | 0.171903 | 0.000000 |
fig, axes = plt.subplots(2, 2, figsize=(15, 10))
sns.barplot(data=results_sorted, x='Silhouette', y='Modelo', ax=axes[0, 0], palette='Blues')
axes[0, 0].set_title('Silhouette (mayor es mejor)')
sns.barplot(data=results_sorted, x='DaviesBouldin', y='Modelo', ax=axes[0, 1], palette='Reds_r')
axes[0, 1].set_title('Davies-Bouldin (menor es mejor)')
sns.barplot(data=results_sorted, x='ARI', y='Modelo', ax=axes[1, 0], palette='Greens')
axes[1, 0].set_title('ARI vs clases reales (diagnóstico)')
sns.barplot(data=results_sorted, x='Noise_ratio', y='Modelo', ax=axes[1, 1], palette='Purples')
axes[1, 1].set_title('Ratio de ruido detectado')
plt.tight_layout()
plt.show()
9) Análisis cualitativo adicional: tamaño de clusters
No basta con una métrica global: también conviene revisar si aparecen clusters muy pequeños, desequilibrados o demasiado dominantes.
label_dict = {
results_sorted.loc[0, 'Modelo']: None
}
# Mapeo explícito para evitar ambigüedad en el notebook
model_labels_map = {
kmeans_row['Modelo']: kmeans_labels,
agg_row['Modelo']: agg_labels,
db_row['Modelo']: db_labels,
gmm_row['Modelo']: gmm_labels
}
best_name = results_sorted.loc[0, 'Modelo']
best_labels = model_labels_map[best_name]
counts = pd.Series(best_labels).value_counts().sort_index()
print('Mejor modelo por silhouette:', best_name)
display(counts.to_frame('size'))
plt.figure(figsize=(8, 4))
sns.barplot(x=counts.index.astype(str), y=counts.values, palette='mako')
plt.title(f'Tamaño de clusters del mejor modelo: {best_name}')
plt.xlabel('ID cluster')
plt.ylabel('Número de muestras')
plt.tight_layout()
plt.show()
Mejor modelo por silhouette: Agglomerative (complete, k=2)
| size | |
|---|---|
| 0 | 29992 |
| 1 | 8 |
10) Tests rápidos de coherencia (sanity checks)
Estas comprobaciones detectan salidas degeneradas y ayudan a validar el pipeline.
assert len(results_sorted) == 4, 'Deben compararse 4 modelos'
assert results_sorted['N_clusters'].notnull().all(), 'N_clusters no debe tener nulos'
assert (results_sorted['N_clusters'] >= 1).all(), 'Cada modelo debe tener al menos 1 cluster'
assert np.isfinite(results_sorted['ARI']).all(), 'ARI debe ser finito'
assert np.isfinite(results_sorted['NMI']).all(), 'NMI debe ser finito'
assert np.isfinite(results_sorted['VMeasure']).all(), 'VMeasure debe ser finito'
print('Silhouette máximo:', float(results_sorted['Silhouette'].dropna().max()))
print('Mejor modelo (silhouette):', results_sorted.iloc[0]['Modelo'])
print('✅ Sanity checks completados correctamente')
Silhouette máximo: 0.8530085557516217 Mejor modelo (silhouette): Agglomerative (complete, k=2) ✅ Sanity checks completados correctamente
Conclusiones y sugerencias de ampliación
Conclusiones principales
- K-Means sigue siendo una referencia fuerte cuando los clusters tienen geometría relativamente compacta.
- Agglomerative aporta una lectura estructural útil y puede comportarse mejor o peor según el linkage.
- DBSCAN destaca cuando hay ruido y formas irregulares, pero su rendimiento depende fuertemente de
eps/min_samplesy de la escala. - GMM es útil cuando esperamos clusters elípticos y queremos asignaciones probabilísticas.
- En clustering real, ninguna métrica única decide todo: conviene combinar criterios internos + análisis visual + conocimiento del dominio.
Qué podrías probar después
- Ajuste más fino de hiperparámetros con búsqueda sistemática.
- Probar diferentes representaciones previas (PCA con más/menos componentes).
- Evaluar estabilidad de clusters bajo distintas semillas o submuestras.
- Añadir HDBSCAN/OPTICS para densidad con distinta granularidad.
- Hacer análisis semántico de clusters (perfilado de features por cluster).
Idea clave: en clustering, “el mejor modelo” no es solo el de mejor métrica, sino el que produce grupos útiles, interpretables y estables para el problema real.