D
Practica aplicada M7 intermedio 30 min

Capitulo 07

ML aplicado: pipelines, segmentacion y sistemas de recomendacion

Donde el ML se conecta con problemas de negocio reales

Pipelines reproducibles con scikit-learn (la pieza que faltaba en M6), clustering K-Means aplicado a segmentacion RFM, sistemas de recomendacion user-based e item-based, y validacion temporal para series temporales.

7.1 Pipelines: la pieza que faltaba

En M6 entrenaste modelos “sueltos”. En produccion esto no escala. Si tienes 5 modelos y cada uno tiene 3 pasos de preprocessing, son 5*3 = 15 oportunidades de meter la pata al cambiar algo. El Pipeline de scikit-learn resuelve esto encadenando transformaciones + estimador en un solo objeto que se entrena, predice y persiste.

python Pipeline end-to-end con validacion honesta
import pandas as pd
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold
from sklearn.metrics import classification_report, roc_auc_score
import joblib

np.random.seed(42)
N = 2000

# Generar dataset realista: predicción de churn en una empresa de telecomunicaciones
df = pd.DataFrame({
    'tenure':          np.random.randint(1, 73, N),                # meses como cliente
    'monthly_charges': np.random.normal(65, 25, N).clip(20, 200),  # cargo mensual
    'total_charges':   np.nan,                                    # calculado
    'contract':        np.random.choice(['Mes a mes', '1 anio', '2 anios'], N, p=[0.55, 0.25, 0.2]),
    'internet':        np.random.choice(['Fibra', 'DSL', 'No'], N),
    'tech_support':    np.random.choice(['Si', 'No', np.nan], N, p=[0.4, 0.55, 0.05]),
    'churn':           np.nan,
})

# Logica de negocio: mas churn en mes-a-mes, alta factura, poca antigüedad
df['total_charges'] = df['tenure'] * df['monthly_charges'] * np.random.uniform(0.95, 1.05, N)
prob_churn = (
    (df['contract'] == 'Mes a mes') * 0.35 +
    (df['monthly_charges'] > 80) * 0.20 +
    (df['tenure'] < 12) * 0.15 +
    np.random.uniform(0, 0.1, N)
).clip(0, 1)
df['churn'] = (np.random.uniform(0, 1, N) < prob_churn).astype(int)

# Split
X = df.drop('churn', axis=1)
y = df['churn']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)

# Definir columnas por tipo
num_cols = ['tenure', 'monthly_charges', 'total_charges']
cat_cols = ['contract', 'internet', 'tech_support']

# Sub-pipeline para numericas: imputar + escalar
num_pipe = Pipeline([
    ('imputer', SimpleImputer(strategy='median')),
    ('scaler',  StandardScaler())
])

# Sub-pipeline para categoricas: imputar + one-hot
cat_pipe = Pipeline([
    ('imputer', SimpleImputer(strategy='most_frequent')),
    ('ohe',     OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])

# ColumnTransformer: une ambos, aplicando a las columnas correctas
preprocessor = ColumnTransformer([
    ('num', num_pipe, num_cols),
    ('cat', cat_pipe, cat_cols)
])

# Pipeline completo: preprocesamiento + modelo
pipeline = Pipeline([
    ('prep',  preprocessor),
    ('model', RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1))
])

# Cross-validation honesta (todo el preprocessing DENTRO del CV)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipeline, X_train, y_train, cv=cv, scoring='roc_auc')
print(f"AUC en CV: {scores.mean():.3f} ± {scores.std():.3f}")

# Entrenar y evaluar en test
pipeline.fit(X_train, y_train)
y_proba = pipeline.predict_proba(X_test)[:, 1]
y_pred  = pipeline.predict(X_test)
print(f"\nAUC en test: {roc_auc_score(y_test, y_proba):.3f}")
print(classification_report(y_test, y_pred, target_names=['Activo', 'Churn']))

# Persistir TODO el pipeline
joblib.dump(pipeline, 'churn_pipeline.joblib')
loaded = joblib.load('churn_pipeline.joblib')
assert loaded.score(X_test, y_test) == pipeline.score(X_test, y_test), "Error de reproducibilidad"
print("\nPipeline persistido y recargado OK")
3 garantias del Pipeline bien hecho
  1. Sin leakage: cada fold de CV entrena su propia copia del imputer/scaler, no la global.
  2. Reproducible: un archivo .joblib contiene TODO lo necesario para predecir. No hay archivos sueltos de “el scaler que use en su momento”.
  3. Extensible: quieres agregar una feature? Creás un FunctionTransformer y lo metes en la cadena. No tocás el modelo.

7.2 K-Means: segmentacion RFM

RFM (Recency, Frequency, Monetary)

M7

Framework de segmentacion de clientes usado en CRM y marketing desde los 80. Tres features: R = dias desde la ultima compra, F = cantidad de compras, M = monto total gastado. La hipotesis: clientes con valores similares en estas 3 dimensiones se comportan parecido.

#negocio #segmentacion #core
python Segmentacion RFM end-to-end
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from sklearn.decomposition import PCA
import seaborn as sns

# Generar transacciones de 12 meses
np.random.seed(42)
N_CLIENTES = 500
N_TRANSACC = 8000

clientes = pd.DataFrame({
    'cliente_id': range(1, N_CLIENTES + 1),
    'segmento_real': np.random.choice(['VIP', 'Habitual', 'Nuevo', 'En_riesgo'], N_CLIENTES, p=[0.1, 0.5, 0.25, 0.15])
})

# Simular transacciones según segmento
fechas = pd.date_range('2023-01-01', '2023-12-31', freq='H')
trans = pd.DataFrame({
    'fecha':       np.random.choice(fechas, N_TRANSACC),
    'cliente_id':  np.random.choice(clientes['cliente_id'], N_TRANSACC),
    'monto':       np.random.lognormal(4, 1.2, N_TRANSACC).round(2)
})
trans = trans.merge(clientes, on='cliente_id')

# Calcular RFM
fecha_corte = trans['fecha'].max()
rfm = trans.groupby('cliente_id').agg(
    recency  = ('fecha', lambda x: (fecha_corte - x.max()).days),  # dias desde ultima compra
    frequency= ('fecha', 'count'),                                   # cantidad de compras
    monetary  = ('monto', 'sum')                                      # gasto total
).reset_index()
rfm = rfm.merge(clientes[['cliente_id', 'segmento_real']], on='cliente_id')

# 1) ESCALAR (obligatorio antes de K-Means)
scaler = StandardScaler()
rfm_scaled = scaler.fit_transform(rfm[['recency', 'frequency', 'monetary']])

# 2) Elegir K: metodo del codo + silhouette
inercias, silhouettes, K_range = [], [], range(2, 9)
for k in K_range:
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = km.fit_predict(rfm_scaled)
    inercias.append(km.inertia_)
    silhouettes.append(silhouette_score(rfm_scaled, labels))

fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].plot(K_range, inercias, 'o-', color='#06B6D4', linewidth=2)
axes[0].set_title('Metodo del Codo (Inercia)')
axes[0].set_xlabel('K')
axes[1].plot(K_range, silhouettes, 'o-', color='#8B5CF6', linewidth=2)
axes[1].set_title('Silhouette (mayor = mejor)')
axes[1].set_xlabel('K')
for ax in axes:
    sns.despine(ax=ax)
    ax.grid(alpha=0.3, linestyle='--')
plt.tight_layout()
plt.show()

# 3) Elegir K_optimo (acá: 4, el valor "real")
K_OPTIMO = 4
km = KMeans(n_clusters=K_OPTIMO, random_state=42, n_init=10)
rfm['cluster'] = km.fit_predict(rfm_scaled)

# 4) Visualizar con PCA 2D
pca = PCA(n_components=2, random_state=42)
coords = pca.fit_transform(rfm_scaled)
rfm['pca1'], rfm['pca2'] = coords[:, 0], coords[:, 1]

fig, ax = plt.subplots(figsize=(10, 7))
palette = sns.color_palette('viridis', K_OPTIMO)
for i, color in enumerate(palette):
    mask = rfm['cluster'] == i
    ax.scatter(rfm.loc[mask, 'pca1'], rfm.loc[mask, 'pca2'], c=[color], label=f'Cluster {i}', alpha=0.6, s=30)
ax.set_title(f'Segmentacion RFM (K={K_OPTIMO}, PCA {pca.explained_variance_ratio_.sum():.0%} varianza)')
ax.legend()
sns.despine(ax=ax)
plt.tight_layout()
plt.show()

# 5) Perfil de cada cluster (para presentar a negocio)
perfil = rfm.groupby('cluster').agg(
    n=('cliente_id', 'count'),
    recency_promedio=('recency', 'mean'),
    frequency_promedio=('frequency', 'mean'),
    monetary_promedio=('monetary', 'mean'),
).round(1)
print(perfil)
Validacion con negocio

Un cluster con K-Means puede tener sentido estadistico (silhouette alto) pero no sentido de negocio. Antes de actuar sobre los clusters, presentalos al stakeholder: “el cluster 0 compra mucho y hace poco, quiere decir que son clientes premium? o son clientes que se van?”. Si no puedes nombrar cada cluster, volve a K-Means con un K diferente o prueba otro algoritmo (DBSCAN, jerarquico).

7.3 Sistemas de recomendacion

Filtrado colaborativo user-based

M7

Recomienda items que usuarios SIMILARES al usuario activo compraron/calificaron. Pasos: (1) medir similitud entre usuarios (coseno, Pearson), (2) encontrar K usuarios mas parecidos, (3) recomendar items que esos usuarios consumieron y el activo no. Pros: descubri patrones inesperados. Contras: cold start (usuarios nuevos sin historial).

#ml #recomendacion #colaborativo

Filtrado colaborativo item-based

M7

Recomienda items SIMILARES al item que el usuario acaba de consumir. Similitud entre items (no entre usuarios) suele ser mas estable: las peliculas no cambian de gusto, los usuarios si. Usado por Amazon, Netflix. Pros: mas estable, mejor con datasets grandes. Contras: misma limitacion de cold start.

#ml #recomendacion #colaborativo
python Recomendador item-based con scikit-surprise
# pip install scikit-surprise
from surprise import Dataset, Reader, KNNWithMeans
from surprise.model_selection import train_test_split
from surprise import accuracy

# Ejemplo con dataset MovieLens-100k
data = Dataset.load_builtin('ml-100k', prompt=False)
trainset, testset = train_test_split(data, test_size=0.2, random_state=42)

# item-based con similitud coseno ajustada por la media
sim_options = {
    'name': 'pearson_baseline',  # usa pearson con shrinkage
    'user_based': False           # item-based
}
modelo = KNNWithMeans(sim_options=sim_options, k=40)
modelo.fit(trainset)

preds = modelo.test(testset)
print(f"RMSE: {accuracy.rmse(preds):.3f}")
print(f"MAE:  {accuracy.mae(preds):.3f}")

# Top-5 recomendaciones para un usuario
def top_n_recomendaciones(modelo, user_id, n=5):
    """Devuelve los N items con mayor prediccion para el usuario."""
    items_no_calificados = [iid for iid in modelo.trainset.all_items()
                            if not modelo.trainset.knows_user(user_id) or
                            iid not in [j for (j, _) in modelo.trainset.ur[user_id]]]
    preds = [(iid, modelo.predict(user_id, iid).est) for iid in items_no_calificados]
    return sorted(preds, key=lambda x: x[1], reverse=True)[:n]

# Uso: top_n_recomendaciones(modelo, user_id=196, n=5)
Cold start: el problema fundamental de las recomendaciones
  • Usuario nuevo: no tiene historial, no podemos recomendar. Soluciones: pedir preferencias iniciales, recomendar lo popular, usar datos demograficos.
  • Item nuevo: nadie lo ha consumido, no podemos recomendarlo. Soluciones: usar metadata (categoria, descripcion) con modelos basados en contenido, hybrid recommender.

La solucion canonica: sistema hibrido (colaborativo + basado en contenido + knowledge-based). Netflix, Amazon, Spotify lo hacen.

7.4 TimeSeriesSplit: validacion para series temporales

python TimeSeriesSplit: respetar el orden temporal
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import TimeSeriesSplit
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error

# Serie temporal sintetica con tendencia
np.random.seed(42)
n = 365
t = np.arange(n)
y = 100 + 0.5 * t + 10 * np.sin(2 * np.pi * t / 30) + np.random.normal(0, 5, n)

# TimeSeriesSplit: 5 splits, cada uno entrena en el pasado y valida en el futuro
tscv = TimeSeriesSplit(n_splits=5)
fig, ax = plt.subplots(figsize=(12, 5))

for i, (train_idx, val_idx) in enumerate(tscv.split(y)):
    ax.axvspan(train_idx[0], train_idx[-1], alpha=0.15, color='blue', label='Train' if i == 0 else '')
    ax.axvspan(val_idx[0], val_idx[-1], alpha=0.25, color='orange', label='Validacion' if i == 0 else '')

ax.plot(y, color='black', linewidth=1, alpha=0.7)
ax.set_title('TimeSeriesSplit: cada fold es estrictamente en el futuro')
ax.set_xlabel('Tiempo')
ax.set_ylabel('Valor')
ax.legend()
plt.tight_layout()
plt.show()

# Comparar con KFold (incorrecto para series temporales)
from sklearn.model_selection import KFold

def comparar_splits(X, y):
    resultados = {}
    for nombre, splitter in [('K-Fold', KFold(n_splits=5, shuffle=True, random_state=42)),
                              ('TimeSeries', TimeSeriesSplit(n_splits=5))]:
        maes = []
        for train_idx, val_idx in splitter.split(X):
            modelo = LinearRegression().fit(X[train_idx], y[train_idx])
            preds = modelo.predict(X[val_idx])
            maes.append(mean_absolute_error(y[val_idx], preds))
        resultados[nombre] = (np.mean(maes), np.std(maes))
    return resultados

X = t.reshape(-1, 1)
print(comparar_splits(X, y))
# K-Fold va a dar un MAE artificialmente bajo porque "ve" el futuro
# TimeSeriesSplit da una estimacion honesta

7.5 Errores comunes

Estandarizar fuera del Pipeline (data leakage en CV)
✗ X_scaled = StandardScaler().fit_transform(X); cross_val_score(model, X_scaled, y)
✓ cross_val_score(pipeline, X, y) # el scaler aprende solo en train de cada fold
Persistir solo el modelo, no el Pipeline
✗ joblib.dump(modelo) # en produccion faltan scaler, imputer, encoder
✓ joblib.dump(pipeline_completo) # preprocessing + modelo juntos
K-Means sin escalar (variable de monto domina)
✗ KMeans().fit(rfm) # 'monetary' (rango 0-500k) aplasta a 'frequency' (rango 1-20)
✓ KMeans().fit(StandardScaler().fit_transform(rfm)) # SIEMPRE escalar primero
K-Fold en series temporales (mezcla futuro y pasado)
✗ KFold(shuffle=True).split(serie) # el fold de validacion incluye el futuro
✓ TimeSeriesSplit(n_splits=5).split(serie) # train siempre es pasado, val es futuro
Recomendar sin manejar cold start
✗ El usuario es nuevo, el sistema le devuelve 'sin recomendaciones'.
✓ Recomendaciones por defecto: populares por categoria, basadas en la primera interaccion explicita (onboarding), o demograficas.
Libro: "Hands-On Machine Learning" (3ra ed) - Aurélien Géron. Cap 8-9 son el canon de pipelines y clustering.
Libro: "Recommender Systems: The Textbook" - Charu C. Aggarwal. Completo, academico pero accesible.
Video: StatQuest: "K-means clustering" (YouTube, la mejor explicacion visual)
Video: Google Developers: "Machine Learning with scikit-learn" (YouTube, 5 videos practicos)
Articulo: scikit-learn docs: "Pipelines and composite estimators" (la doc oficial es muy buena)
Herramienta: scikit-surprise: libreria especializada en recomendadores (SVD, KNN, Co-clustering)
Herramienta: implicit: recomendadores con feedback implicito (clicks, vistas) optimizados para datasets grandes
Mini-proyecto 7: churn end-to-end con persistencia avanzado
  1. Usá el dataset generado en el codigo de 7.1 (o el de tu eleccion).
  2. Implementa el Pipeline completo: ColumnTransformer + modelo (RF o GBM). Evalua con Stratified K-Fold (k=5), reportando AUC, F1 y accuracy con std.
  3. Persisti el pipeline con joblib.dump. En un script aparte, cargalo y demostrá que predice con el mismo score. Verifica que NO hay archivos sueltos.
  4. Hacé un sistema de recomendacion item-based: en el dataset RFM de la seccion 7.2, identifica el cluster “VIP” y recomiendales los 3 productos mas populares. Compara con recomendacion “lo mas popular global”.
  5. Para una serie temporal sintetica, compará K-Fold vs TimeSeriesSplit. Reporta la diferencia de MAE y explica por que importa.
  6. Bonus: deploya el pipeline con FastAPI en un endpoint POST. Probá con un caso real.