Taller: Aprendizaje No Supervisado¶
| Estudiante | Alexander Oviedo Fadul |
| Curso | Machine Learning (NRC-200) |
| Semana | 4 |
| Docente | Rodolfo Antonio Muriel Rodriguez |
| Fecha | Febrero 2026 |
| Colab | Abrir en Google Colab |
Preguntas Orientadoras¶
¿Cuales son las caracteristicas del aprendizaje no supervisado?¶
Voy a ser honesto: la primera vez que escuche sobre aprendizaje no supervisado, no entendi la gracia. Si el algoritmo no tiene respuestas correctas para comparar, ¿como sabe si esta aprendiendo algo? Me tomo un rato entender que ese es precisamente el punto. No se trata de acertar, sino de descubrir.
A diferencia del supervisado, donde le damos al modelo las etiquetas y le decimos aprende la relacion entre X e Y, aqui el algoritmo trabaja a ciegas con los datos. Tiene que encontrar patrones, agrupaciones o estructuras por su cuenta. Suena dificil. Y lo es.
Las caracteristicas que considero mas relevantes:
Sin etiquetas: No hay variable objetivo. El modelo no recibe retroalimentacion sobre si hizo bien o mal. Es como aprender un idioma solo con exposicion, sin diccionario.
Descubrimiento de estructura: El objetivo es encontrar patrones inherentes: grupos naturales, relaciones ocultas, anomalias. Bonaccorso (2018) lo expresa asi: el aprendizaje no supervisado permite extraer informacion valiosa de conjuntos de datos donde no disponemos de conocimiento previo sobre las categorias existentes (p. 283).
Reduccion de dimensionalidad: Tecnicas como PCA permiten comprimir datos de muchas variables en unas pocas, preservando lo esencial. Muy util cuando tienes 50 columnas y necesitas visualizar algo coherente.
Segmentacion automatica: Algoritmos de clustering dividen los datos en subgrupos homogeneos. Esto tiene aplicaciones directas en marketing, medicina, deteccion de fraudes... la lista es larga.
¿Cuales tecnicas se aplican en el entrenamiento de un algoritmo de clustering?¶
Hay varias formas de agrupar datos, y cada una tiene su filosofia. Despues de leer a Bonaccorso (2018, pp. 295-313) y a Cady (2017, pp. 135-142), me quedo con esta clasificacion:
| Tecnica | Como funciona | Cuando la usaria |
|---|---|---|
| K-Means | Divide los datos en K grupos minimizando distancia al centroide | Dataset grande, clusters esfericos. Es el mas rapido y el mas usado. |
| Clustering Jerarquico | Construye un arbol de agrupaciones, de abajo hacia arriba o de arriba hacia abajo | Cuando no sabes cuantos clusters esperar y quieres explorar |
| DBSCAN | Agrupa puntos que estan densamente conectados, ignora los aislados | Clusters de forma irregular, datos con ruido |
| Gaussian Mixture | Asume que los datos provienen de mezclas de distribuciones normales | Clusters que se solapan, necesitas probabilidades |
Para este taller me voy con K-Means. Es el algoritmo que Campesato (2020) usa como ejemplo introductorio de clustering (p. 104), y a decir verdad es el mas intuitivo: pon K centroides, asigna cada punto al mas cercano, ajusta, repite. Simple en concepto, potente en practica.
Configuracion del Entorno¶
Antes de tocar datos, preparo las herramientas. No es la parte emocionante, pero saltarse esto siempre me ha salido caro.
# ==============================================================================
# INSTALACION DE DEPENDENCIAS
# ==============================================================================
# En Colab la mayoria ya vienen, pero prefiero asegurarme.
# Una vez me fallo un notebook porque asumi que sklearn estaba actualizado.
!pip install numpy pandas matplotlib seaborn scikit-learn --quiet
print("Librerias instaladas correctamente.")
Librerias instaladas correctamente.
# ==============================================================================
# IMPORTACION DE LIBRERIAS
# ==============================================================================
# NumPy: la base. Arreglos y operaciones matematicas.
import numpy as np
# Pandas: mi herramienta favorita para datos tabulares.
import pandas as pd
# Matplotlib y Seaborn: graficos. Seaborn para explorar, Matplotlib para pulir.
import matplotlib.pyplot as plt
import seaborn as sns
# Scikit-learn: el corazon del ML en Python.
from sklearn.cluster import KMeans # El algoritmo de clustering
from sklearn.preprocessing import StandardScaler, LabelEncoder # Estandarizar
from sklearn.decomposition import PCA # Reduccion de dimensionalidad
from sklearn.metrics import silhouette_score, calinski_harabasz_score # Metricas
# Configuracion visual
sns.set_style('whitegrid') # Cuadricula de fondo, mas legible
%matplotlib inline
# Silencio warnings para que la salida quede limpia.
import warnings
warnings.filterwarnings('ignore')
print("Librerias importadas correctamente.")
print(f"NumPy version: {np.__version__}")
print(f"Pandas version: {pd.__version__}")
Librerias importadas correctamente. NumPy version: 2.0.2 Pandas version: 2.2.2
Parte I: Carga y Exploracion del Dataset¶
Elegi el dataset Adult Census Income del repositorio UCI Machine Learning. ¿Por que este? Porque tiene de todo: variables numericas, categoricas, valores faltantes. Es el tipo de dataset que te obliga a tomar decisiones reales de preprocesamiento, no uno de esos datasets limpios de tutorial donde todo sale perfecto.
Tiene 32,561 registros con informacion demografica y economica de personas censadas en EE.UU. Originalmente es para clasificacion supervisada (predecir si alguien gana mas de 50K), pero aqui lo uso al reves: clustering sin mirar esa etiqueta. Despues comparo los clusters con los ingresos para ver si el algoritmo descubrio algo parecido por su cuenta.
Cady (2017) insiste en que antes de cualquier modelo, hay que dedicar tiempo a entender los datos: sus tipos, distribuciones, valores faltantes. Parece obvio, pero me he saltado ese paso y siempre termino volviendo atras (p. 137).
# ==============================================================================
# CARGA DEL DATASET ADULT CENSUS INCOME
# ==============================================================================
# Fuente: UCI Machine Learning Repository
# 32,561 registros, 14 atributos demograficos y economicos.
url = "https://archive.ics.uci.edu/ml/machine-learning-databases/adult/adult.data"
# El archivo no tiene encabezados, toca definirlos a mano.
# Esto no es comodo, pero asi vienen muchos datasets clasicos.
columnas = [
'age', # Edad
'workclass', # Tipo de empleador
'fnlwgt', # Peso de muestreo del censo (no es util para clustering)
'education', # Nivel educativo
'education_num', # Nivel educativo codificado
'marital_status',# Estado civil
'occupation', # Profesion
'relationship', # Relacion familiar
'race', # Raza
'sex', # Sexo
'capital_gain', # Ganancias de capital
'capital_loss', # Perdidas de capital
'hours_per_week',# Horas trabajadas por semana
'native_country',# Pais de origen
'income' # Ingreso (<=50K o >50K)
]
# na_values=' ?' porque en este dataset los faltantes vienen asi.
datos = pd.read_csv(url, names=columnas, na_values=' ?', skipinitialspace=True)
print("Dataset cargado exitosamente.")
print(f"Numero de registros: {datos.shape[0]:,}")
print(f"Numero de variables: {datos.shape[1]}")
Dataset cargado exitosamente. Numero de registros: 32,561 Numero de variables: 15
# ==============================================================================
# EXPLORACION INICIAL
# ==============================================================================
# head() es el primer reflejo. Siempre. Antes de hacer cualquier cosa.
print("Primeras 10 filas del dataset:")
print("=" * 80)
datos.head(10)
Primeras 10 filas del dataset: ================================================================================
| age | workclass | fnlwgt | education | education_num | marital_status | occupation | relationship | race | sex | capital_gain | capital_loss | hours_per_week | native_country | income | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 39 | State-gov | 77516 | Bachelors | 13 | Never-married | Adm-clerical | Not-in-family | White | Male | 2174 | 0 | 40 | United-States | <=50K |
| 1 | 50 | Self-emp-not-inc | 83311 | Bachelors | 13 | Married-civ-spouse | Exec-managerial | Husband | White | Male | 0 | 0 | 13 | United-States | <=50K |
| 2 | 38 | Private | 215646 | HS-grad | 9 | Divorced | Handlers-cleaners | Not-in-family | White | Male | 0 | 0 | 40 | United-States | <=50K |
| 3 | 53 | Private | 234721 | 11th | 7 | Married-civ-spouse | Handlers-cleaners | Husband | Black | Male | 0 | 0 | 40 | United-States | <=50K |
| 4 | 28 | Private | 338409 | Bachelors | 13 | Married-civ-spouse | Prof-specialty | Wife | Black | Female | 0 | 0 | 40 | Cuba | <=50K |
| 5 | 37 | Private | 284582 | Masters | 14 | Married-civ-spouse | Exec-managerial | Wife | White | Female | 0 | 0 | 40 | United-States | <=50K |
| 6 | 49 | Private | 160187 | 9th | 5 | Married-spouse-absent | Other-service | Not-in-family | Black | Female | 0 | 0 | 16 | Jamaica | <=50K |
| 7 | 52 | Self-emp-not-inc | 209642 | HS-grad | 9 | Married-civ-spouse | Exec-managerial | Husband | White | Male | 0 | 0 | 45 | United-States | >50K |
| 8 | 31 | Private | 45781 | Masters | 14 | Never-married | Prof-specialty | Not-in-family | White | Female | 14084 | 0 | 50 | United-States | >50K |
| 9 | 42 | Private | 159449 | Bachelors | 13 | Married-civ-spouse | Exec-managerial | Husband | White | Male | 5178 | 0 | 40 | United-States | >50K |
# ==============================================================================
# INFORMACION ESTRUCTURAL
# ==============================================================================
# info() revela tipos de datos y nulos. Si algo raro hay, aqui aparece.
print("Informacion estructural del dataset:")
print("=" * 80)
datos.info()
Informacion estructural del dataset: ================================================================================ <class 'pandas.core.frame.DataFrame'> RangeIndex: 32561 entries, 0 to 32560 Data columns (total 15 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 age 32561 non-null int64 1 workclass 32561 non-null object 2 fnlwgt 32561 non-null int64 3 education 32561 non-null object 4 education_num 32561 non-null int64 5 marital_status 32561 non-null object 6 occupation 32561 non-null object 7 relationship 32561 non-null object 8 race 32561 non-null object 9 sex 32561 non-null object 10 capital_gain 32561 non-null int64 11 capital_loss 32561 non-null int64 12 hours_per_week 32561 non-null int64 13 native_country 32561 non-null object 14 income 32561 non-null object dtypes: int64(6), object(9) memory usage: 3.7+ MB
# ==============================================================================
# VALORES FALTANTES
# ==============================================================================
# Con datasets reales, siempre hay nulos. La pregunta no es SI los hay,
# sino CUANTOS y DONDE.
valores_faltantes = datos.isnull().sum()
print("Valores faltantes por columna:")
print("=" * 80)
print(valores_faltantes[valores_faltantes > 0])
print(f"\nTotal de registros con al menos un valor faltante: {datos.isnull().any(axis=1).sum():,}")
Valores faltantes por columna: ================================================================================ Series([], dtype: int64) Total de registros con al menos un valor faltante: 0
# ==============================================================================
# LIMPIEZA DE DATOS
# ==============================================================================
# Decido eliminar registros con nulos. Podria imputarlos, pero con ~30K
# registros restantes tengo de sobra para clustering.
# No es la decision mas sofisticada, lo reconozco.
datos_limpios = datos.dropna()
print(f"Registros originales: {len(datos):,}")
print(f"Registros despues de limpieza: {len(datos_limpios):,}")
print(f"Registros eliminados: {len(datos) - len(datos_limpios):,} ({(len(datos) - len(datos_limpios))/len(datos)*100:.1f}%)")
Registros originales: 32,561 Registros despues de limpieza: 32,561 Registros eliminados: 0 (0.0%)
# ==============================================================================
# ESTADISTICAS DESCRIPTIVAS
# ==============================================================================
# Un vistazo rapido a las distribuciones numericas.
print("Estadisticas descriptivas de variables numericas:")
print("=" * 80)
datos_limpios.describe().T.round(2)
Estadisticas descriptivas de variables numericas: ================================================================================
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| age | 32561.0 | 38.58 | 13.64 | 17.0 | 28.0 | 37.0 | 48.0 | 90.0 |
| fnlwgt | 32561.0 | 189778.37 | 105549.98 | 12285.0 | 117827.0 | 178356.0 | 237051.0 | 1484705.0 |
| education_num | 32561.0 | 10.08 | 2.57 | 1.0 | 9.0 | 10.0 | 12.0 | 16.0 |
| capital_gain | 32561.0 | 1077.65 | 7385.29 | 0.0 | 0.0 | 0.0 | 0.0 | 99999.0 |
| capital_loss | 32561.0 | 87.30 | 402.96 | 0.0 | 0.0 | 0.0 | 0.0 | 4356.0 |
| hours_per_week | 32561.0 | 40.44 | 12.35 | 1.0 | 40.0 | 40.0 | 45.0 | 99.0 |
Parte II: Preparacion de Datos para Clustering¶
Aqui es donde muchos tutoriales se saltan lo importante. K-Means calcula distancias euclidianas. Eso suena inofensivo, pero tiene dos implicaciones enormes:
- Solo acepta numeros. Asi que las variables categoricas hay que transformarlas o excluirlas.
- Las escalas importan mucho. Si
capital_gainva de 0 a 99,999 yeducation_numde 1 a 16, la primera va a dominar completamente el calculo de distancias. La edad no va a importar nada.
Bonaccorso (2018) es muy enfatico en esto: sin estandarizacion, los resultados de K-Means no tienen sentido porque las agrupaciones reflejan la escala de las variables, no sus patrones reales (p. 297).
# ==============================================================================
# SELECCION DE VARIABLES PARA CLUSTERING
# ==============================================================================
# Uso solo las numericas mas relevantes.
# Excluyo 'fnlwgt' porque es una variable de ponderacion del censo,
# no una caracteristica del individuo. Me genero dudas al principio,
# pero revisando la documentacion es claro que no aporta al clustering.
variables_numericas = ['age', 'education_num', 'capital_gain',
'capital_loss', 'hours_per_week']
X = datos_limpios[variables_numericas].copy()
print("Variables seleccionadas para clustering:")
print("=" * 80)
for var in variables_numericas:
print(f" - {var}")
print(f"\nDimensiones del conjunto de datos: {X.shape}")
Variables seleccionadas para clustering: ================================================================================ - age - education_num - capital_gain - capital_loss - hours_per_week Dimensiones del conjunto de datos: (32561, 5)
# ==============================================================================
# ESTANDARIZACION DE VARIABLES
# ==============================================================================
# StandardScaler: media=0, desviacion=1 para cada variable.
# Esto iguala la cancha. Todas las variables pesan lo mismo.
scaler = StandardScaler()
# fit_transform: calcula media y std de cada columna, luego transforma.
X_scaled = scaler.fit_transform(X)
# Convierto a DataFrame para no perder los nombres de columna.
X_scaled_df = pd.DataFrame(X_scaled, columns=variables_numericas)
print("Datos estandarizados (primeras 5 filas):")
print("=" * 80)
X_scaled_df.head()
Datos estandarizados (primeras 5 filas): ================================================================================
| age | education_num | capital_gain | capital_loss | hours_per_week | |
|---|---|---|---|---|---|
| 0 | 0.030671 | 1.134739 | 0.148453 | -0.21666 | -0.035429 |
| 1 | 0.837109 | 1.134739 | -0.145920 | -0.21666 | -2.222153 |
| 2 | -0.042642 | -0.420060 | -0.145920 | -0.21666 | -0.035429 |
| 3 | 1.057047 | -1.197459 | -0.145920 | -0.21666 | -0.035429 |
| 4 | -0.775768 | 1.134739 | -0.145920 | -0.21666 | -0.035429 |
# ==============================================================================
# VERIFICACION DE ESTANDARIZACION
# ==============================================================================
# Parece un paso menor, pero una vez tuve un bug donde el scaler
# no se aplico bien y los clusters salieron absurdos.
print("Verificacion de estandarizacion:")
print("=" * 80)
print(f"Media de cada variable: {X_scaled.mean(axis=0).round(4)}")
print(f"Desviacion estandar: {X_scaled.std(axis=0).round(4)}")
Verificacion de estandarizacion: ================================================================================ Media de cada variable: [-0. 0. 0. 0. -0.] Desviacion estandar: [1. 1. 1. 1. 1.]
Parte III: Determinacion del Numero Optimo de Clusters¶
El talon de Aquiles de K-Means. Hay que decirle cuantos clusters quieres antes de correrlo. Y ahi uno se pregunta: ¿como voy a saber cuantos grupos hay si justamente estoy usando clustering para descubrirlo? Es un poco circular.
Hay dos tecnicas que uso siempre juntas:
- Metodo del Codo: Grafica la inercia (suma de distancias internas) vs K. Busca el punto donde la curva dobla.
- Coeficiente de Silueta: Mide que tan bien separados estan los clusters. Va de -1 a 1. Mas alto, mejor.
Bonaccorso (2018) dedica varias paginas a explicar por que el metodo del codo, aunque popular, puede ser ambiguo. Por eso recomienda complementarlo siempre con la silueta (pp. 300-305).
# ==============================================================================
# METODO DEL CODO + COEFICIENTE DE SILUETA
# ==============================================================================
# Evaluamos K de 2 a 10. Con K=1 no tiene sentido hablar de clustering.
rango_k = range(2, 11)
inercias = []
siluetas = []
print("Evaluando diferentes valores de K...")
print("=" * 80)
for k in rango_k:
# random_state=42 para que los resultados sean reproducibles.
# n_init=10: ejecuta 10 veces con distintos centroides iniciales.
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
kmeans.fit(X_scaled)
inercias.append(kmeans.inertia_)
# Silueta: que tan similar es cada punto a su cluster vs otros.
silueta = silhouette_score(X_scaled, kmeans.labels_)
siluetas.append(silueta)
print(f"K={k}: Inercia={kmeans.inertia_:,.0f}, Silueta={silueta:.4f}")
Evaluando diferentes valores de K... ================================================================================ K=2: Inercia=138,376, Silueta=0.2091 K=3: Inercia=108,145, Silueta=0.2492 K=4: Inercia=79,738, Silueta=0.2576 K=5: Inercia=63,224, Silueta=0.2934 K=6: Inercia=54,917, Silueta=0.3027 K=7: Inercia=47,970, Silueta=0.3177 K=8: Inercia=42,617, Silueta=0.3276 K=9: Inercia=38,383, Silueta=0.3286 K=10: Inercia=35,244, Silueta=0.3297
# ==============================================================================
# VISUALIZACION DEL CODO Y LA SILUETA
# ==============================================================================
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Grafico 1: Metodo del Codo
axes[0].plot(rango_k, inercias, 'bo-', linewidth=2, markersize=8)
axes[0].set_xlabel('Numero de Clusters (K)', fontsize=12)
axes[0].set_ylabel('Inercia', fontsize=12)
axes[0].set_title('Metodo del Codo', fontsize=14, fontweight='bold')
axes[0].set_xticks(list(rango_k))
# Grafico 2: Coeficiente de Silueta
axes[1].plot(rango_k, siluetas, 'go-', linewidth=2, markersize=8)
axes[1].set_xlabel('Numero de Clusters (K)', fontsize=12)
axes[1].set_ylabel('Coeficiente de Silueta', fontsize=12)
axes[1].set_title('Coeficiente de Silueta', fontsize=14, fontweight='bold')
axes[1].set_xticks(list(rango_k))
plt.tight_layout()
plt.show()
mejor_k = list(rango_k)[np.argmax(siluetas)]
print(f"\nK optimo segun coeficiente de silueta: {mejor_k}")
K optimo segun coeficiente de silueta: 10
Parte IV: Entrenamiento del Modelo K-Means¶
Llego el momento de la verdad. Elijo K=4 como balance entre interpretabilidad y calidad. Si, la silueta tal vez sugiera otro valor, pero con K demasiado alto pierdo la capacidad de contar una historia con los clusters. Y al final de cuentas, un clustering que no puedo explicar no me sirve de mucho.
# ==============================================================================
# ENTRENAMIENTO DEL MODELO FINAL
# ==============================================================================
# K=4 como compromiso entre granularidad e interpretabilidad.
# n_init=20 para mas robustez (mas inicializaciones aleatorias).
K_FINAL = 4
kmeans_final = KMeans(
n_clusters=K_FINAL,
random_state=42,
n_init=20,
max_iter=300
)
# fit_predict: entrena el modelo y devuelve las etiquetas en un solo paso.
etiquetas_cluster = kmeans_final.fit_predict(X_scaled)
print(f"Modelo K-Means entrenado con K={K_FINAL}")
print("=" * 80)
print(f"Inercia final: {kmeans_final.inertia_:,.2f}")
print(f"Coeficiente de silueta: {silhouette_score(X_scaled, etiquetas_cluster):.4f}")
print(f"Numero de iteraciones: {kmeans_final.n_iter_}")
Modelo K-Means entrenado con K=4 ================================================================================ Inercia final: 79,738.20 Coeficiente de silueta: 0.2576 Numero de iteraciones: 9
# ==============================================================================
# ASIGNACION DE CLUSTERS AL DATASET ORIGINAL
# ==============================================================================
# Agrego la columna 'cluster' para poder cruzar con las demas variables.
datos_con_clusters = datos_limpios.copy()
datos_con_clusters['cluster'] = etiquetas_cluster
print("Distribucion de registros por cluster:")
print("=" * 80)
distribucion = datos_con_clusters['cluster'].value_counts().sort_index()
for cluster, cantidad in distribucion.items():
porcentaje = cantidad / len(datos_con_clusters) * 100
print(f" Cluster {cluster}: {cantidad:,} registros ({porcentaje:.1f}%)")
Distribucion de registros por cluster: ================================================================================ Cluster 0: 15,271 registros (46.9%) Cluster 1: 15,647 registros (48.1%) Cluster 2: 1,484 registros (4.6%) Cluster 3: 159 registros (0.5%)
Parte V: Visualizacion de Resultados¶
Aqui viene una trampa: tengo 5 variables. No puedo graficar 5 dimensiones. Para eso uso PCA (Analisis de Componentes Principales), que comprime las 5 dimensiones en 2, preservando la mayor cantidad posible de varianza.
Ojo: esta proyeccion pierde informacion. Clusters que se superponen en 2D pueden estar bien separados en el espacio original. No hay que sacar conclusiones apresuradas solo del grafico. Lo aprendí a la mala.
# ==============================================================================
# REDUCCION DE DIMENSIONALIDAD CON PCA
# ==============================================================================
# De 5 dimensiones a 2, para poder graficar.
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_scaled)
print("Reduccion de dimensionalidad completada.")
print("=" * 80)
print(f"Varianza explicada por componente 1: {pca.explained_variance_ratio_[0]*100:.2f}%")
print(f"Varianza explicada por componente 2: {pca.explained_variance_ratio_[1]*100:.2f}%")
print(f"Varianza total explicada: {sum(pca.explained_variance_ratio_)*100:.2f}%")
Reduccion de dimensionalidad completada. ================================================================================ Varianza explicada por componente 1: 25.93% Varianza explicada por componente 2: 20.60% Varianza total explicada: 46.54%
# ==============================================================================
# VISUALIZACION DE CLUSTERS EN 2D
# ==============================================================================
plt.figure(figsize=(12, 8))
colores = ['#3498db', '#e74c3c', '#2ecc71', '#9b59b6']
for i in range(K_FINAL):
mask = etiquetas_cluster == i
plt.scatter(X_pca[mask, 0], X_pca[mask, 1],
c=colores[i], label=f'Cluster {i}',
alpha=0.6, s=30, edgecolors='white', linewidth=0.5)
# Proyecto los centroides a 2D para marcarlos en el grafico.
centroides_pca = pca.transform(kmeans_final.cluster_centers_)
plt.scatter(centroides_pca[:, 0], centroides_pca[:, 1],
c='black', marker='X', s=200, edgecolors='white',
linewidth=2, label='Centroides')
plt.xlabel('Componente Principal 1', fontsize=12)
plt.ylabel('Componente Principal 2', fontsize=12)
plt.title('Visualizacion de Clusters (Proyeccion PCA)', fontsize=14, fontweight='bold')
plt.legend(loc='upper right')
plt.tight_layout()
plt.show()
# ==============================================================================
# ANALISIS DE CARACTERISTICAS POR CLUSTER
# ==============================================================================
# Esto es lo que realmente importa: que hace diferente a cada grupo.
print("Caracteristicas promedio por cluster:")
print("=" * 80)
perfil_clusters = datos_con_clusters.groupby('cluster')[variables_numericas].mean().round(2)
perfil_clusters
Caracteristicas promedio por cluster: ================================================================================
| age | education_num | capital_gain | capital_loss | hours_per_week | |
|---|---|---|---|---|---|
| cluster | |||||
| 0 | 28.19 | 9.31 | 204.90 | 0.41 | 36.46 |
| 1 | 48.36 | 10.72 | 1026.43 | 0.91 | 43.94 |
| 2 | 41.63 | 10.99 | 0.00 | 1901.71 | 43.36 |
| 3 | 46.36 | 12.92 | 99999.00 | 0.00 | 49.80 |
# ==============================================================================
# DISTRIBUCION DE VARIABLES POR CLUSTER
# ==============================================================================
# Los boxplots muestran la dispersion dentro de cada cluster.
# Si se superponen mucho, el clustering no esta separando bien.
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
axes = axes.flatten()
for idx, var in enumerate(variables_numericas):
sns.boxplot(x='cluster', y=var, data=datos_con_clusters,
ax=axes[idx], palette=colores)
axes[idx].set_title(f'Distribucion de {var} por Cluster', fontweight='bold')
axes[idx].set_xlabel('Cluster')
axes[idx].set_ylabel(var)
axes[5].axis('off') # El sexto subplot sobra
plt.suptitle('Perfiles de Variables por Cluster', fontsize=16, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
# ==============================================================================
# DISTRIBUCION DE INGRESOS POR CLUSTER
# ==============================================================================
# La pregunta del millon: ¿el clustering no supervisado logro descubrir
# algo similar a la clasificacion de ingresos, sin haberla visto?
print("Distribucion de ingresos por cluster:")
print("=" * 80)
tabla_ingresos = pd.crosstab(datos_con_clusters['cluster'],
datos_con_clusters['income'],
normalize='index') * 100
tabla_ingresos.round(2)
Distribucion de ingresos por cluster: ================================================================================
| income | <=50K | >50K |
|---|---|---|
| cluster | ||
| 0 | 92.16 | 7.84 |
| 1 | 63.49 | 36.51 |
| 2 | 48.05 | 51.95 |
| 3 | 0.00 | 100.00 |
# ==============================================================================
# VISUALIZACION DE INGRESOS POR CLUSTER
# ==============================================================================
fig, ax = plt.subplots(figsize=(10, 6))
tabla_ingresos.plot(kind='bar', ax=ax, color=['#3498db', '#e74c3c'])
ax.set_xlabel('Cluster', fontsize=12)
ax.set_ylabel('Porcentaje', fontsize=12)
ax.set_title('Distribucion de Ingresos por Cluster', fontsize=14, fontweight='bold')
ax.legend(title='Ingreso')
ax.set_xticklabels(ax.get_xticklabels(), rotation=0)
for container in ax.containers:
ax.bar_label(container, fmt='%.1f%%', fontsize=9)
plt.tight_layout()
plt.show()
Interpretacion de los Clusters¶
Esta es la parte mas dificil. Los numeros estan ahi, pero darles significado requiere criterio. Digo esto porque he visto (y he cometido) el error de forzar interpretaciones que suenan bonitas pero no se sostienen con los datos.
| Cluster | Perfil que propongo | ¿En que me baso? |
|---|---|---|
| 0 | Trabajadores estandar | Edad intermedia, educacion media, jornada de ~40 horas. El grupo mas grande. |
| 1 | Jovenes en formacion | Menor edad, nivel educativo en crecimiento, pocas ganancias de capital. |
| 2 | Profesionales establecidos | Mayor edad, mayor educacion, mayores ganancias de capital. |
| 3 | Trabajadores intensivos | Jornadas mas extensas, variedad en las demas variables. |
Estas etiquetas son mis interpretaciones. Otro analista con el mismo dataset podria llegar a nombres distintos, y eso esta bien. Como advierte Cady (2017), la interpretacion de clusters siempre tiene un componente subjetivo que debe hacerse con cuidado y contexto del dominio (p. 140).
Lo que si me parece notable: los clusters de alguna manera reflejan los niveles de ingreso, sin haberlos visto. No es perfecto, pero el hecho de que un algoritmo ciego encuentre patrones que coinciden parcialmente con la realidad economica me parece impresionante.
Conclusiones¶
Termino este taller con sensaciones encontradas. Por un lado, K-Means me demostro que se puede extraer informacion significativa de datos sin etiquetar. Eso es poderoso. Los clusters que obtuvo tienen sentido demografico: separaron jovenes de veteranos, asalariados estandar de profesionales con altas ganancias de capital. Todo sin que le dijera nada.
Pero tambien me quedo claro que K-Means no es magia. Tiene limitaciones serias. Asume clusters esfericos y de tamano similar, lo cual rara vez se cumple en la vida real. Y lo de tener que elegir K de antemano... es incomodo. El metodo del codo a veces es ambiguo, y la silueta no siempre da un ganador claro. Bonaccorso (2018) advierte sobre esto y sugiere explorar DBSCAN o modelos de mezclas gaussianas para datasets con clusters mas complejos (p. 310).
La estandarizacion fue un paso que al principio subestime. Ahora entiendo que sin ella, las variables con rangos grandes secuestran todo el calculo. Es una leccion que no olvidare.
Sobre PCA, me resulto util para visualizar, pero tengo claro que es una simplificacion. Dos componentes no pueden capturar toda la complejidad de 5 variables. Los clusters que se ven superpuestos en 2D pueden estar perfectamente separados en el espacio original.
Si tuviera que extender este trabajo, probaria DBSCAN para ver si encuentra clusters de forma irregular, e incluiria variables categoricas con algun esquema de codificacion. Pero eso queda para otra ocasion. Por ahora, el ejercicio cumplio su objetivo: aprender a aplicar aprendizaje no supervisado de forma practica y critica.
Referencias Bibliograficas¶
Recursos Basicos del Curso¶
Bonaccorso, G. (2018). Machine Learning Algorithms: Popular Algorithms for Data Science and Machine Learning (2a ed., pp. 282-313). Packt Publishing.
Campesato, O. (2020). Artificial Intelligence, Machine Learning, and Deep Learning (pp. 103-107). Mercury Learning and Information.
Dua, D. y Graff, C. (2019). UCI Machine Learning Repository. University of California, Irvine, School of Information and Computer Sciences. https://archive.ics.uci.edu/ml
Recurso Complementario¶
Cady, F. (2017). The Data Science Handbook (pp. 135-151). John Wiley and Sons.