Aprendizaje no supervisado y sistemas de recomendación

"El aprendizaje no supervisado es descubrir estructura en datos que nadie etiquetó. Es el tipo de aprendizaje más cercano a cómo los humanos entendemos el mundo."

Qué vas a aprender en este capítulo

En el aprendizaje supervisado teníamos etiquetas (yy). En el no supervisado, no: solo tenemos los datos X\mathbf{X} y queremos encontrar estructura — grupos naturales, patrones, relaciones entre ítems.

Aplicación central: construir el sistema de recomendación de platillos de La Esquina — sin etiquetas, solo con el historial de qué pidieron los clientes.


4.1 K-means clustering

💡 Intuición

Querés segmentar a los clientes de La Esquina para hacer marketing diferenciado. No sabés cuántas categorías hay ni cómo se llaman — solo tenés su historial de consumo.

K-means divide los datos en K grupos (clusters) buscando que los puntos dentro de cada grupo se parezcan entre sí y se diferencien de los otros grupos.

Es como organizar las mesas de un restaurante: agrupás a la gente que llega junta (que probablemente se parece en preferencias) sin saber de antemano cuántos grupos formarán.

📐 Fundamento

Algoritmo K-means:

1. Elegir K centroides aleatorios
2. Repetir hasta convergencia:
   a. Asignar cada punto al centroide más cercano
   b. Recalcular el centroide de cada cluster (promedio de sus puntos)

Implementación:

import numpy as np
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler

# Datos de clientes: [gasto_promedio, frecuencia_mensual]
clientes = np.array([
    [5, 2], [8, 3], [4, 1], [50, 8], [60, 10], [55, 9],
    [25, 4], [30, 5], [28, 6], [100, 15], [90, 12], [110, 14]
])

# Escalar (K-means es sensible a la escala)
scaler = StandardScaler()
clientes_scaled = scaler.fit_transform(clientes)

# Determinar K óptimo con el método del codo
inercias = []
for k in range(1, 9):
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    km.fit(clientes_scaled)
    inercias.append(km.inertia_)  # suma de distancias^2 a centroide

# Graficar: buscar el "codo" en la curva
plt.plot(range(1, 9), inercias, 'bx-')
plt.xlabel('K')
plt.ylabel('Inercia (WCSS)')
plt.title('Método del codo')

# Entrenar con K=3 (asumiendo que el codo está en 3)
km = KMeans(n_clusters=3, random_state=42, n_init=10)
etiquetas = km.fit_predict(clientes_scaled)
centroides = scaler.inverse_transform(km.cluster_centers_)

print("Centroides (escala original):")
for i, c in enumerate(centroides):
    print(f"  Cluster {i}: gasto=${c[0]:.0f}, frecuencia={c[1]:.0f}/mes")

# Posible interpretación:
# Cluster 0: "Clientes ocasionales" (gasto bajo, visitan poco)
# Cluster 1: "Clientes frecuentes" (gasto medio, visitan seguido)
# Cluster 2: "Clientes VIP" (gasto alto, muy frecuentes)

Métricas de calidad del clustering:

from sklearn.metrics import silhouette_score, davies_bouldin_score

# Silhouette score: [-1, 1], más alto es mejor
# Mide qué tan bien separados están los clusters
sil = silhouette_score(clientes_scaled, etiquetas)
print(f"Silhouette score: {sil:.3f}")  # > 0.5 es bueno

# Davies-Bouldin: [0, ∞), más bajo es mejor
db = davies_bouldin_score(clientes_scaled, etiquetas)
print(f"Davies-Bouldin: {db:.3f}")

Limitaciones de K-means:

  • Requiere especificar K de antemano.
  • Asume clusters esféricos — falla con formas irregulares.
  • Sensible a outliers.
  • Sensible a la inicialización (K-means++ mejora esto).
  • No funciona bien con alta dimensionalidad.

Alternativas:

  • DBSCAN: detecta clusters de forma arbitraria, identifica outliers.
  • Hierarchical clustering: construye un árbol de clusters (no requiere K a priori).
  • Gaussian Mixture Models: versión "blanda" de K-means con probabilidades.

4.2 Reducción de dimensionalidad — PCA

💡 Intuición

Si tenés 50 features por cliente (qué platillos pidió), visualizarlos o aplicar K-means en 50 dimensiones es difícil. PCA comprime los datos a 2-3 dimensiones manteniendo la mayor variación posible.

Es como hacer una foto de un objeto 3D: la foto es 2D pero captura la esencia del objeto si la tomás desde el ángulo correcto.

📐 Fundamento

PCA — Principal Component Analysis:

from sklearn.decomposition import PCA
import matplotlib.pyplot as plt

# Supongamos que cada cliente tiene un vector de 50 features
# (cuántas veces pidió cada platillo)
X = np.random.rand(200, 50)  # 200 clientes, 50 platillos

# Reducir a 2 dimensiones para visualización
pca = PCA(n_components=2)
X_2d = pca.fit_transform(X)

print(f"Varianza explicada por PC1: {pca.explained_variance_ratio_[0]:.1%}")
print(f"Varianza explicada por PC2: {pca.explained_variance_ratio_[1]:.1%}")
print(f"Total: {sum(pca.explained_variance_ratio_):.1%}")

# Visualizar
plt.scatter(X_2d[:, 0], X_2d[:, 1], alpha=0.6)
plt.xlabel('Componente Principal 1')
plt.ylabel('Componente Principal 2')
plt.title('Clientes en espacio reducido')

# Elegir n_components que expliquen el 95% de la varianza
pca_95 = PCA(n_components=0.95)  # sklearn elige n automáticamente
X_reducido = pca_95.fit_transform(X)
print(f"Dimensiones para 95% varianza: {X_reducido.shape[1]}")

PCA antes de K-means:

# Pipeline: PCA + K-means (práctica recomendada)
from sklearn.pipeline import Pipeline

pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('pca', PCA(n_components=10)),  # reducir a 10 dims
    ('kmeans', KMeans(n_clusters=4, random_state=42))
])
pipeline.fit(X)
etiquetas = pipeline.predict(X)

4.3 Sistemas de recomendación

💡 Intuición

Netflix recomienda películas que no pediste pero que te van a gustar. Lo hace analizando qué peliculas vieron personas similares a vos.

Este es collaborative filtering: "si Ana y Carlos tienen gustos similares (les gustaron las mismas 5 películas), lo que le gusta a Carlos probablemente le gustará a Ana".

📐 Fundamento

Tipos de sistemas de recomendación:

Tipo Basado en Ventaja Desventaja
Collaborative filtering Comportamiento de usuarios similares Descubre preferencias inesperadas Cold start (usuarios nuevos)
Content-based Atributos de los ítems Funciona con usuarios nuevos Recommends lo obvio
Híbrido Ambos Mejor de ambos Más complejo

Collaborative filtering basado en memoria:

import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

# Matriz usuario-platillo: filas=clientes, columnas=platillos
# Valores: cuántas veces lo pidió (0 = nunca)
ratings = np.array([
    # P1  P2  P3  P4  P5  P6
    [5,   3,  0,  0,  1,  2],  # Ana
    [4,   0,  4,  1,  0,  0],  # Beto
    [0,   3,  0,  5,  3,  0],  # Carlos
    [0,   0,  5,  4,  0,  3],  # Diana
    [1,   0,  0,  0,  5,  4],  # Elena
])
usuarios = ['Ana', 'Beto', 'Carlos', 'Diana', 'Elena']
platillos = ['Pupusa queso', 'Pupusa loroco', 'Tamales', 'Yuca', 'Refresco', 'Postre']

# Similitud coseno entre usuarios
sim_usuarios = cosine_similarity(ratings)
print("Similitudes:")
for i, u in enumerate(usuarios):
    similares = [(usuarios[j], sim_usuarios[i,j]) for j in range(len(usuarios)) if i != j]
    similares.sort(key=lambda x: -x[1])
    print(f"  {u}: más similar a {similares[0][0]} ({similares[0][1]:.2f})")

def recomendar(usuario_idx, n_recomendaciones=3):
    usuario = ratings[usuario_idx]
    similitudes = sim_usuarios[usuario_idx]
    
    # Calcular score predicho para cada platillo no pedido
    scores = np.zeros(ratings.shape[1])
    for j in range(len(usuarios)):
        if j != usuario_idx:
            scores += similitudes[j] * ratings[j]
    
    # Excluir platillos ya pedidos
    scores[usuario > 0] = -1
    
    # Top N recomendaciones
    top_n = np.argsort(scores)[::-1][:n_recomendaciones]
    return [(platillos[i], scores[i]) for i in top_n if scores[i] > 0]

print("\nRecomendaciones para Ana:")
for plato, score in recomendar(0):
    print(f"  {plato}: {score:.2f}")

Matrix Factorization (SVD) — el algoritmo de Netflix:

from sklearn.decomposition import TruncatedSVD

# Factorizar la matriz ratings en factores latentes
n_factors = 3
svd = TruncatedSVD(n_components=n_factors)
user_factors = svd.fit_transform(ratings)
item_factors = svd.components_.T

# Reconstruir ratings predichos
ratings_pred = user_factors @ item_factors.T

print("Ratings predichos para Ana:")
for i, (plato, pred) in enumerate(zip(platillos, ratings_pred[0])):
    actual = ratings[0, i]
    print(f"  {plato}: real={actual}, pred={pred:.1f}")

Métricas de evaluación:

from sklearn.metrics import mean_squared_error
import numpy as np

# Dividir en train/test (ocultar algunos ratings reales)
# Evaluar con RMSE (Root Mean Squared Error)
rmse = np.sqrt(mean_squared_error(ratings_conocidos, ratings_predichos))
print(f"RMSE: {rmse:.3f}")  # menor es mejor

# Precision@K: de las K recomendaciones, ¿cuántas fueron relevantes?
# Recall@K: de todos los ítems relevantes, ¿cuántos recomendé?

🛠️ En la práctica

La Esquina: sistema completo de recomendación

# Pipeline: segmentación + recomendación por cluster
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
import pandas as pd

# 1. Preparar datos: historial de pedidos
def construir_perfil_cliente(df_pedidos):
    """Convierte historial de pedidos en vector por cliente."""
    return pd.pivot_table(
        df_pedidos,
        values='cantidad',
        index='cliente_id',
        columns='platillo_id',
        aggfunc='sum',
        fill_value=0
    )

# 2. Segmentar clientes
def segmentar_clientes(perfiles, n_clusters=4):
    scaler = StandardScaler()
    perfiles_scaled = scaler.fit_transform(perfiles)
    km = KMeans(n_clusters=n_clusters, random_state=42)
    return km.fit_predict(perfiles_scaled)

# 3. Recomendar dentro del cluster
def recomendar_por_cluster(cliente_id, cluster_id, perfiles, clusters):
    """Recomienda platillos populares en el cluster que el cliente no ha pedido."""
    clientes_cluster = perfiles[clusters == cluster_id]
    popularidad = clientes_cluster.mean().sort_values(ascending=False)
    ya_pedidos = perfiles.loc[cliente_id]
    recomendaciones = popularidad[ya_pedidos == 0].head(3)
    return recomendaciones.index.tolist()

# Resultado: "Ana, como a vos te gustan las pupusas y a otros clientes similares
# también les gusta la sopa de pata — ¿querés agregarla a tu próximo pedido?"

4.4 Ejercicios

✏️ Ejercicio 4.1 — K-means paso a paso

Dado el siguiente conjunto de puntos en 2D y K=2, ejecutá manualmente 2 iteraciones de K-means:

Puntos: A(1,1), B(2,1), C(8,8), D(9,8), E(1,2), F(9,9)

Centroides iniciales: C1=(1,1), C2=(9,9)

Para cada iteración: asignar puntos a centroide más cercano, calcular nuevos centroides.

✏️ Ejercicio 4.2 — Diseño de sistema de recomendación

Describí cómo diseñarías un sistema de recomendación para una librería en línea:

a. ¿Qué datos usarías como input? b. ¿Content-based, collaborative filtering, o híbrido? Justificá. c. ¿Cómo manejarías el "cold start problem" (usuario nuevo sin historial)? d. ¿Cómo evaluarías si el sistema funciona bien?


4.5 Para profundizar


Definiciones nuevas: aprendizaje no supervisado, clustering, K-means, centroide, inercia, método del codo, silhouette score, PCA, componente principal, varianza explicada, sistema de recomendación, collaborative filtering, content-based filtering, cold start, matrix factorization, SVD, RMSE, precision@K.