🏭 Caso de Uso

Comparativa de Modelos de Clustering

Comparación de K-Means, clustering jerárquico, DBSCAN y Gaussian Mixture sobre datos reales de Covertype.

🐍 Python 📓 Jupyter Notebook

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

  1. Entender cómo aplicar clustering de forma práctica sobre datos tabulares complejos.
  2. Comparar varios enfoques mencionados en teoría:
    • K-Means
    • Clustering jerárquico (Agglomerative)
    • DBSCAN
    • Gaussian Mixture Model (GMM)
  3. Analizar resultados con métricas internas y, cuando proceda, externas.
  4. 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.

[1]
# 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).

[2]
# 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

[3]
# 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
[4]
# 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
Output
[5]
# 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()
Output

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).
[6]
# 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]
[7]
# 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
[8]
# 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()
Output

3) Funciones auxiliares de evaluación

Definimos métricas internas y externas para comparar modelos con una tabla homogénea.

[9]
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$.

[10]
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)
Output
K sugerido por silhouette: 12
[11]
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
Output
{'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.

[12]
# 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
[13]
# 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)
Output
Mejor configuración jerárquica (submuestra): complete | k = 2
[14]
# 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
Output
{'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.

[15]
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
[16]
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)
Output
eps elegido para DBSCAN: 2.2
[17]
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
Output
{'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.

[18]
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)
Output
n_components elegido por BIC: 12
[19]
# 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()
Output
[20]
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
Output
{'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

[21]
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
[22]
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()
Output

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.

[23]
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
Output

10) Tests rápidos de coherencia (sanity checks)

Estas comprobaciones detectan salidas degeneradas y ayudan a validar el pipeline.

[24]
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

  1. K-Means sigue siendo una referencia fuerte cuando los clusters tienen geometría relativamente compacta.
  2. Agglomerative aporta una lectura estructural útil y puede comportarse mejor o peor según el linkage.
  3. DBSCAN destaca cuando hay ruido y formas irregulares, pero su rendimiento depende fuertemente de eps/min_samples y de la escala.
  4. GMM es útil cuando esperamos clusters elípticos y queremos asignaciones probabilísticas.
  5. 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.