Aprendizaje No Supervisado¶
En los notebooks anteriores siempre tuvimos una variable objetivo $y$ que queríamos predecir. En el aprendizaje no supervisado no existe esa etiqueta: solo tenemos features $X$ y queremos descubrir estructura oculta en los datos. Este notebook cubre las dos técnicas más fundamentales: K-Means para encontrar grupos, y PCA para reducir dimensionalidad y visualizar datos complejos.
Librerías¶
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import silhouette_score, silhouette_samples
from sklearn.datasets import make_blobs
plt.rcParams['figure.figsize'] = (9, 4)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
np.random.seed(42)
Parte I: Clustering con K-Means¶
1. ¿Qué es el Clustering?¶
El clustering (o agrupamiento) consiste en dividir los datos en grupos donde las observaciones dentro de un mismo grupo son más similares entre sí que respecto a las de otros grupos.
Casos de uso típicos:
- Segmentación de clientes: agrupar usuarios con comportamientos similares
- Detección de anomalías: observaciones que no pertenecen a ningún cluster conocido
- Compresión de datos: representar muchos puntos con pocos centroides
- Exploración: descubrir estructura en datos antes de modelar
2. Algoritmo K-Means¶
K-Means divide los datos en $k$ grupos minimizando la inercia: la suma de distancias al cuadrado de cada punto a su centroide más cercano.
$$\text{Inercia} = \sum_{i=1}^{n} \min_{j} \| x_i - \mu_j \|^2$$
Algoritmo (iterativo):
- Inicializar $k$ centroides aleatoriamente
- Asignar cada punto al centroide más cercano
- Recalcular los centroides como promedio de los puntos asignados
- Repetir 2-3 hasta convergencia
Limitaciones importantes:
- Requiere especificar $k$ de antemano
- Asume clusters convexos y de tamaño similar
- Sensible a la escala de las features → siempre escalar
- El resultado puede variar según la inicialización
# Dataset: segmentación de clientes por comportamiento de compra
np.random.seed(42)
n = 400
# Tres segmentos naturales
seg1 = np.random.multivariate_normal([2, 8], [[0.5, 0], [0, 0.5]], n//3) # bajo gasto, alta freq.
seg2 = np.random.multivariate_normal([7, 5], [[0.8, 0], [0, 0.8]], n//3) # medio gasto, media freq.
seg3 = np.random.multivariate_normal([12, 2], [[0.6, 0], [0, 0.6]], n//3) # alto gasto, baja freq.
datos = np.vstack([seg1, seg2, seg3])
df = pd.DataFrame(datos, columns=['gasto_mensual', 'frecuencia_compras'])
df['gasto_mensual'] = (df['gasto_mensual'] * 50_000).clip(50_000, 800_000)
df['frecuencia_compras'] = df['frecuencia_compras'].round(0).clip(1, 15)
fig, ax = plt.subplots()
ax.scatter(df['gasto_mensual']/1e3, df['frecuencia_compras'],
alpha=0.5, color='steelblue', edgecolor='white', s=40)
ax.set_title('Clientes: Gasto mensual vs Frecuencia de compras (sin etiquetas)')
ax.set_xlabel('Gasto mensual (miles CLP)')
ax.set_ylabel('Compras por mes')
plt.tight_layout()
plt.show()
# Aplicar K-Means con k=3
# Importante: siempre escalar antes
kmeans = Pipeline([
('scaler', StandardScaler()),
('kmeans', KMeans(n_clusters=3, random_state=42, n_init=10))
])
kmeans.fit(df)
df['cluster'] = kmeans.named_steps['kmeans'].labels_
# Centroides en escala original
scaler = kmeans.named_steps['scaler']
centroides_scaled = kmeans.named_steps['kmeans'].cluster_centers_
centroides = scaler.inverse_transform(centroides_scaled)
print("=== Centroides por cluster ===")
df_cent = pd.DataFrame(centroides, columns=df.columns[:2])
df_cent['n_clientes'] = df['cluster'].value_counts().sort_index().values
print(df_cent.round(0).to_string(index=True))
=== Centroides por cluster === gasto_mensual frecuencia_compras n_clientes 0 352204.0 5.0 132 1 597076.0 2.0 134 2 97749.0 8.0 133
# Visualización de los clusters
colores = ['#2ecc71', '#3498db', '#e74c3c']
nombres = ['Ocasional (bajo gasto)', 'Regular (medio)', 'Premium (alto gasto)']
fig, ax = plt.subplots()
for k in range(3):
mask = df['cluster'] == k
ax.scatter(df.loc[mask, 'gasto_mensual']/1e3,
df.loc[mask, 'frecuencia_compras'],
alpha=0.5, color=colores[k], edgecolor='white',
s=40, label=nombres[k])
ax.scatter(centroides[k, 0]/1e3, centroides[k, 1],
color=colores[k], edgecolor='black', s=200,
marker='*', zorder=5)
ax.set_title('Segmentación de clientes — K-Means (k=3)')
ax.set_xlabel('Gasto mensual (miles CLP)')
ax.set_ylabel('Compras por mes')
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
3. ¿Cómo elegir k? Método del Codo y Silhouette¶
Elegir el número correcto de clusters es el principal desafío de K-Means. Dos métodos complementarios:
3.1 Método del Codo (Elbow)¶
Grafica la inercia (distancia intra-cluster) para distintos valores de $k$. Buscamos el punto donde la reducción de inercia deja de ser sustancial — el "codo" de la curva.
3.2 Coeficiente de Silhouette¶
Mide qué tan bien separados están los clusters. Para cada punto $i$:
$$s(i) = \frac{b(i) - a(i)}{\max(a(i), b(i))}$$
donde $a(i)$ es la distancia promedio a los puntos del mismo cluster y $b(i)$ es la distancia promedio al cluster más cercano.
| Silhouette | Interpretación |
|---|---|
| Cercano a 1 | El punto está bien asignado a su cluster |
| Cercano a 0 | El punto está en la frontera entre clusters |
| Negativo | El punto probablemente está mal asignado |
X_scaled = StandardScaler().fit_transform(df[['gasto_mensual', 'frecuencia_compras']])
inercias = []
silhouettes = []
k_range = range(2, 10)
for k in k_range:
km = KMeans(n_clusters=k, random_state=42, n_init=10)
labels = km.fit_predict(X_scaled)
inercias.append(km.inertia_)
silhouettes.append(silhouette_score(X_scaled, labels))
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
# Método del codo
ax = axes[0]
ax.plot(k_range, inercias, 'o-', color='steelblue', lw=2, ms=7)
ax.axvline(3, color='red', lw=1.5, linestyle='--', label='k óptimo = 3')
ax.set_title('Método del Codo')
ax.set_xlabel('Número de clusters (k)')
ax.set_ylabel('Inercia')
ax.legend()
# Silhouette
ax = axes[1]
ax.plot(k_range, silhouettes, 'o-', color='seagreen', lw=2, ms=7)
ax.axvline(k_range[np.argmax(silhouettes)], color='red', lw=1.5,
linestyle='--', label=f'k óptimo = {k_range[np.argmax(silhouettes)]}')
ax.set_title('Coeficiente de Silhouette')
ax.set_xlabel('Número de clusters (k)')
ax.set_ylabel('Silhouette promedio')
ax.legend()
plt.tight_layout()
plt.show()
# Gráfico de Silhouette detallado para k=3
km3 = KMeans(n_clusters=3, random_state=42, n_init=10)
labels3 = km3.fit_predict(X_scaled)
sil_vals = silhouette_samples(X_scaled, labels3)
fig, ax = plt.subplots(figsize=(8, 5))
y_lower = 10
for k in range(3):
vals_k = np.sort(sil_vals[labels3 == k])
size_k = len(vals_k)
y_upper = y_lower + size_k
ax.fill_betweenx(np.arange(y_lower, y_upper), 0, vals_k,
alpha=0.7, color=colores[k], label=f'Cluster {k}')
ax.text(-0.05, y_lower + size_k/2, str(k), fontsize=11)
y_lower = y_upper + 10
sil_avg = silhouette_score(X_scaled, labels3)
ax.axvline(sil_avg, color='red', lw=2, linestyle='--',
label=f'Silhouette promedio = {sil_avg:.3f}')
ax.set_title('Diagrama de Silhouette — k=3')
ax.set_xlabel('Coeficiente de Silhouette')
ax.set_ylabel('Cluster')
ax.set_yticks([])
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
4. Perfilado de Clusters¶
Una vez identificados los clusters, el siguiente paso es interpretar qué representa cada uno. Esto es lo que convierte un resultado técnico en un insight de negocio.
# Estadísticas descriptivas por cluster
perfil = df.groupby('cluster')[['gasto_mensual', 'frecuencia_compras']].agg(['mean', 'std', 'count'])
perfil.columns = ['Gasto medio', 'Gasto std', 'n',
'Frecuencia media', 'Frecuencia std', 'n2']
perfil = perfil.drop(columns='n2')
perfil['% clientes'] = (perfil['n'] / len(df) * 100).round(1)
print("=== Perfil de cada cluster ===")
print(perfil.round(0).to_string())
=== Perfil de cada cluster ===
Gasto medio Gasto std n Frecuencia media Frecuencia std % clientes
cluster
0 352204.0 44707.0 132 5.0 1.0 33.0
1 597076.0 40129.0 134 2.0 1.0 34.0
2 97749.0 31263.0 133 8.0 1.0 33.0
# Boxplots por cluster
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
for ax, col, label in [
(axes[0], 'gasto_mensual', 'Gasto mensual (CLP)'),
(axes[1], 'frecuencia_compras','Compras por mes')
]:
data_bp = [df.loc[df['cluster']==k, col].values for k in range(3)]
bp = ax.boxplot(data_bp, patch_artist=True, notch=False)
for patch, color in zip(bp['boxes'], colores):
patch.set_facecolor(color)
patch.set_alpha(0.7)
ax.set_xticklabels([f'Cluster {k}' for k in range(3)])
ax.set_title(f'Distribución de {label} por cluster')
ax.set_ylabel(label)
plt.tight_layout()
plt.show()
Parte II: Reducción de Dimensionalidad con PCA¶
5. ¿Qué es PCA?¶
El Análisis de Componentes Principales (PCA) transforma los datos originales en un nuevo sistema de coordenadas donde:
- La primera componente principal ($PC_1$) captura la máxima varianza posible
- La segunda ($PC_2$) captura la máxima varianza restante, siendo ortogonal a $PC_1$
- Y así sucesivamente...
Esto permite:
- Visualizar datos de alta dimensión en 2D o 3D
- Reducir ruido eliminando componentes de poca varianza
- Eliminar multicolinealidad antes de entrenar modelos
- Comprimir datos conservando la mayor información posible
⚠️ PCA también requiere escalar las features. Si no escalamos, las variables con mayor varianza dominan las componentes.
# Dataset más rico para PCA: perfiles de clientes con más features
np.random.seed(42)
n = 500
edad = np.random.normal(38, 10, n).clip(18, 70)
ingreso = np.random.normal(1_200_000, 400_000, n).clip(300_000, 4_000_000)
gasto = ingreso * np.random.uniform(0.1, 0.4, n) + np.random.normal(0, 50_000, n)
frecuencia = np.random.poisson(5, n).astype(float)
n_productos = np.random.randint(1, 8, n).astype(float)
antiguedad = np.random.uniform(0, 10, n)
score_sat = np.random.normal(7, 1.5, n).clip(1, 10)
n_reclamos = np.random.poisson(1, n).astype(float)
df_pca = pd.DataFrame({
'edad': edad,
'ingreso': ingreso,
'gasto': gasto,
'frecuencia': frecuencia,
'n_productos': n_productos,
'antiguedad': antiguedad,
'satisfaccion': score_sat,
'reclamos': n_reclamos,
})
print(f"Dataset: {df_pca.shape[0]} clientes, {df_pca.shape[1]} features")
print(df_pca.describe().round(1))
Dataset: 500 clientes, 8 features
edad ingreso gasto frecuencia n_productos antiguedad \
count 500.0 500.0 500.0 500.0 500.0 500.0
mean 38.1 1213725.2 308230.9 5.0 4.0 4.7
std 9.6 388683.8 160215.4 2.3 2.0 3.0
min 18.0 300000.0 -60750.7 0.0 1.0 0.0
25% 31.0 961883.3 183347.2 3.0 2.0 2.0
50% 38.1 1211412.6 290458.7 5.0 4.0 4.6
75% 44.4 1460496.9 411809.7 6.0 6.0 7.3
max 70.0 2252952.8 801147.4 12.0 7.0 10.0
satisfaccion reclamos
count 500.0 500.0
mean 7.0 1.1
std 1.5 1.0
min 1.4 0.0
25% 6.0 0.0
50% 7.1 1.0
75% 8.1 2.0
max 10.0 6.0
6. Varianza Explicada¶
Una pregunta clave en PCA es: ¿cuántas componentes necesitamos? La respuesta está en cuánta varianza acumula cada componente.
# Ajustamos PCA con todas las componentes
scaler = StandardScaler()
X_scaled = scaler.fit_transform(df_pca)
pca_full = PCA(random_state=42)
pca_full.fit(X_scaled)
var_exp = pca_full.explained_variance_ratio_
var_acum = np.cumsum(var_exp)
n_features = df_pca.shape[1]
fig, axes = plt.subplots(1, 2, figsize=(13, 4))
# Varianza por componente
ax = axes[0]
ax.bar(range(1, n_features+1), var_exp * 100,
color='steelblue', edgecolor='white', alpha=0.8)
ax.set_title('Varianza explicada por componente')
ax.set_xlabel('Componente principal')
ax.set_ylabel('Varianza explicada (%)')
ax.set_xticks(range(1, n_features+1))
# Varianza acumulada
ax = axes[1]
ax.plot(range(1, n_features+1), var_acum * 100,
'o-', color='seagreen', lw=2.5, ms=7)
ax.axhline(80, color='red', lw=1.5, linestyle='--', label='80%')
ax.axhline(95, color='orange', lw=1.5, linestyle='--', label='95%')
n_80 = np.argmax(var_acum >= 0.80) + 1
n_95 = np.argmax(var_acum >= 0.95) + 1
ax.axvline(n_80, color='red', lw=1, linestyle=':')
ax.axvline(n_95, color='orange', lw=1, linestyle=':')
ax.set_title('Varianza explicada acumulada')
ax.set_xlabel('Número de componentes')
ax.set_ylabel('Varianza acumulada (%)')
ax.set_xticks(range(1, n_features+1))
ax.legend()
plt.tight_layout()
plt.show()
print(f"Componentes para 80% de varianza: {n_80}")
print(f"Componentes para 95% de varianza: {n_95}")
Componentes para 80% de varianza: 6 Componentes para 95% de varianza: 7
7. Loadings: ¿Qué mide cada componente?¶
Los loadings son los coeficientes que relacionan las variables originales con cada componente principal. Nos permiten interpretar qué está capturando cada componente.
# Loadings de las primeras 3 componentes
pca_3 = PCA(n_components=3, random_state=42)
X_pca3 = pca_3.fit_transform(X_scaled)
loadings = pd.DataFrame(
pca_3.components_.T,
index=df_pca.columns,
columns=[f'PC{i+1}' for i in range(3)]
).round(3)
print("=== Loadings (contribución de cada variable a cada PC) ===")
print(loadings)
=== Loadings (contribución de cada variable a cada PC) ===
PC1 PC2 PC3
edad -0.138 0.144 -0.038
ingreso 0.697 -0.015 -0.010
gasto 0.694 0.006 -0.052
frecuencia -0.052 0.321 0.488
n_productos 0.086 -0.059 0.683
antiguedad -0.010 -0.329 -0.349
satisfaccion -0.002 -0.592 0.410
reclamos 0.067 0.643 0.027
# Heatmap de loadings
fig, ax = plt.subplots(figsize=(8, 5))
im = ax.imshow(loadings.T, cmap='RdBu_r', vmin=-1, vmax=1, aspect='auto')
plt.colorbar(im, ax=ax, label='Loading')
ax.set_xticks(range(len(df_pca.columns)))
ax.set_xticklabels(df_pca.columns, rotation=35, ha='right')
ax.set_yticks(range(3))
ax.set_yticklabels([f'PC{i+1}' for i in range(3)])
for i in range(3):
for j, col in enumerate(df_pca.columns):
ax.text(j, i, f'{loadings.loc[col, f"PC{i+1}"]:.2f}',
ha='center', va='center', fontsize=8,
color='white' if abs(loadings.loc[col, f'PC{i+1}']) > 0.4 else 'black')
ax.set_title('Heatmap de Loadings — Primeras 3 componentes')
plt.tight_layout()
plt.show()
# Barras de loadings por componente para interpretación
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
for i, ax in enumerate(axes):
pc = f'PC{i+1}'
vals = loadings[pc].sort_values()
colores_bar = ['salmon' if v > 0 else 'steelblue' for v in vals]
vals.plot(kind='barh', color=colores_bar, edgecolor='white', ax=ax)
ax.axvline(0, color='black', lw=0.8)
var_i = pca_3.explained_variance_ratio_[i] * 100
ax.set_title(f'{pc} ({var_i:.1f}% varianza)')
ax.set_xlabel('Loading')
plt.suptitle('Interpretación de componentes principales', y=1.02)
plt.tight_layout()
plt.show()
8. Visualización en 2D con PCA¶
Una de las aplicaciones más útiles de PCA es proyectar datos de alta dimensión en 2D para visualizarlos. Esto permite detectar grupos, outliers y patrones que serían imposibles de ver en el espacio original.
# Proyección en 2D
pca_2 = PCA(n_components=2, random_state=42)
X_pca2 = pca_2.fit_transform(X_scaled)
var1 = pca_2.explained_variance_ratio_[0] * 100
var2 = pca_2.explained_variance_ratio_[1] * 100
fig, ax = plt.subplots(figsize=(8, 6))
scatter = ax.scatter(X_pca2[:, 0], X_pca2[:, 1],
c=df_pca['ingreso'], cmap='viridis',
alpha=0.6, edgecolor='white', s=40)
plt.colorbar(scatter, ax=ax, label='Ingreso (CLP)')
ax.set_title(f'Proyección PCA en 2D (varianza explicada: {var1+var2:.1f}%)')
ax.set_xlabel(f'PC1 ({var1:.1f}%)')
ax.set_ylabel(f'PC2 ({var2:.1f}%)')
plt.tight_layout()
plt.show()
9. PCA + K-Means: Lo mejor de ambos mundos¶
Una combinación muy frecuente en la práctica: usar PCA para reducir dimensionalidad y eliminar ruido, y luego aplicar K-Means en el espacio reducido. Esto mejora la calidad del clustering en datos de alta dimensión.
# Pipeline: Scaler → PCA → KMeans
pipeline_completo = Pipeline([
('scaler', StandardScaler()),
('pca', PCA(n_components=3, random_state=42)),
('kmeans', KMeans(n_clusters=3, random_state=42, n_init=10))
])
pipeline_completo.fit(df_pca)
labels_pca_km = pipeline_completo.named_steps['kmeans'].labels_
# Visualización en 2D (usando las primeras 2 PCs)
colores_km = ['#e74c3c', '#3498db', '#2ecc71']
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# K-Means directo (sin PCA)
km_directo = Pipeline([
('scaler', StandardScaler()),
('kmeans', KMeans(n_clusters=3, random_state=42, n_init=10))
])
km_directo.fit(df_pca)
labels_directo = km_directo.named_steps['kmeans'].labels_
for ax, labels, titulo in [
(axes[0], labels_directo, 'K-Means directo (8 features)'),
(axes[1], labels_pca_km, 'K-Means sobre PCA (3 componentes)'),
]:
for k in range(3):
mask = labels == k
ax.scatter(X_pca2[mask, 0], X_pca2[mask, 1],
color=colores_km[k], alpha=0.5,
edgecolor='white', s=35, label=f'Cluster {k}')
sil = silhouette_score(X_scaled, labels)
ax.set_title(f'{titulo}\nSilhouette = {sil:.3f}')
ax.set_xlabel(f'PC1 ({var1:.1f}%)')
ax.set_ylabel(f'PC2 ({var2:.1f}%)')
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()
# Perfilado final del clustering combinado
df_pca['cluster'] = labels_pca_km
perfil_final = df_pca.groupby('cluster').mean().round(1)
perfil_final['n_clientes'] = df_pca['cluster'].value_counts().sort_index()
print("=== Perfil de clusters (PCA + K-Means) ===")
print(perfil_final.to_string())
=== Perfil de clusters (PCA + K-Means) ===
edad ingreso gasto frecuencia n_productos antiguedad satisfaccion reclamos n_clientes
cluster
0 39.7 937877.1 194366.4 5.0 3.0 5.1 6.3 1.3 166
1 36.9 1572275.0 471123.7 4.3 3.8 5.1 6.7 1.1 160
2 37.6 1147189.5 267073.9 5.7 5.3 4.1 8.0 0.8 174
Resumen¶
K-Means¶
| Concepto | Idea central |
|---|---|
| Clustering | Agrupar observaciones similares sin etiquetas previas |
| K-Means | Minimiza inercia (distancia intra-cluster); requiere especificar $k$ |
| Escalado | Obligatorio antes de K-Means; las distancias dependen de la escala |
| Método del codo | Buscar el punto donde la inercia deja de bajar drásticamente |
| Silhouette | Mide qué tan bien definidos están los clusters; mayor es mejor |
| Perfilado | Describir cada cluster con estadísticas → convertir en insight de negocio |
PCA¶
| Concepto | Idea central |
|---|---|
| PCA | Proyecta los datos en nuevos ejes que maximizan la varianza |
| Varianza explicada | Cuánta información captura cada componente |
| Varianza acumulada | Cuántas componentes necesitamos para retener X% de la información |
| Loadings | Coeficientes que conectan variables originales con componentes; permiten interpretación |
| Visualización 2D | Proyectar en 2 componentes para explorar patrones en datos de alta dimensión |
| PCA + K-Means | Combinar ambas técnicas mejora clustering en espacios de alta dimensión |
Referencias¶
- James, G., Witten, D., Hastie, T., Tibshirani, R. (2021). An Introduction to Statistical Learning. Springer. Cap. 12.
- Scikit-learn — KMeans: https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html
- Scikit-learn — PCA: https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html