Módulo 9: Casos Prácticos Resueltos
Machine Learning para Quants — Laboratorio en Python
📋 Introducción
Estos ejercicios aplican machine learning a finanzas con la disciplina del Módulo 8. Construirás features, entrenarás modelos, demostrarás por qué la validación estándar engaña y comprobarás cuándo lo simple gana. Ejecuta cada bloque y experimenta.
Requisitos Previos
pip install numpy pandas matplotlib yfinance scikit-learn
🧪 Caso Práctico 1: Feature Engineering Financiero
Objetivo
Construir features con sentido económico a partir de datos de precios.
Código
import yfinance as yf
import numpy as np
import pandas as pd
datos = yf.download("SPY", start="2015-01-01", end="2024-01-01")
df = pd.DataFrame()
df["precio"] = datos["Close"]
df["ret"] = df["precio"].pct_change()
# === FEATURES (todas con sentido económico) ===
# Retornos pasados a distintos horizontes
df["ret_1d"] = df["ret"]
df["ret_5d"] = df["precio"].pct_change(5)
df["ret_20d"] = df["precio"].pct_change(20)
# Volatilidad reciente (Módulo 3)
df["vol_20d"] = df["ret"].rolling(20).std()
# Distancia a medias móviles (momentum/tendencia, Módulo 7)
df["dist_sma20"] = df["precio"] / df["precio"].rolling(20).mean() - 1
df["dist_sma50"] = df["precio"] / df["precio"].rolling(50).mean() - 1
# RSI simplificado (sobrecompra/sobreventa)
delta = df["ret"]
ganancia = delta.where(delta > 0, 0).rolling(14).mean()
perdida = -delta.where(delta < 0, 0).rolling(14).mean()
df["rsi"] = 100 - 100/(1 + ganancia/perdida)
# === ETIQUETA (lo que queremos predecir) ===
# ¿El retorno de MAÑANA será positivo? (clasificación)
df["target"] = (df["ret"].shift(-1) > 0).astype(int)
df = df.dropna()
print("=== FEATURES CONSTRUIDAS ===")
print(df[["ret_5d", "ret_20d", "vol_20d", "dist_sma20", "rsi", "target"]].head())
print(f"\nTotal de observaciones: {len(df)}")
print(f"Proporción de días positivos: {df['target'].mean():.1%}")
Interpretación
- Cada feature tiene sentido económico: retornos pasados (momentum), volatilidad (régimen), distancia a medias (tendencia), RSI (sobrecompra)
- La etiqueta (
target) usa.shift(-1): predecimos el retorno de MAÑANA con datos de HOY (sin look-ahead al construirla correctamente) - Nota que los días positivos rondan el 53-54%: la señal predecible es minúscula (mercados casi eficientes)
Lección: en finanzas, buenas features con sentido económico valen más que algoritmos complejos. Aquí está la materia prima del modelo.
🧪 Caso Práctico 2: Modelo Simple — Regresión Logística Regularizada
Objetivo
Entrenar un modelo simple e interpretable, respetando el orden temporal.
Código
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
# Features y etiqueta
features = ["ret_5d", "ret_20d", "vol_20d", "dist_sma20", "dist_sma50", "rsi"]
X = df[features].values
y = df["target"].values
# División TEMPORAL (NO aleatoria) - crítico en finanzas
n = len(df)
corte = int(n * 0.7)
X_train, X_test = X[:corte], X[corte:]
y_train, y_test = y[:corte], y[corte:]
# Escalar features (importante para modelos lineales)
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test) # usar el scaler del train (no del test)
# Modelo simple con regularización (C bajo = más regularización)
modelo = LogisticRegression(C=0.1, max_iter=1000)
modelo.fit(X_train_s, y_train)
# Evaluar
precision_train = modelo.score(X_train_s, y_train)
precision_test = modelo.score(X_test_s, y_test)
print("=== REGRESIÓN LOGÍSTICA REGULARIZADA ===")
print(f"Precisión in-sample (train): {precision_train:.1%}")
print(f"Precisión out-of-sample (test): {precision_test:.1%}")
print(f"Baseline (predecir siempre positivo): {max(y_test.mean(), 1-y_test.mean()):.1%}")
# Interpretabilidad: ¿qué features usa?
print("\n=== IMPORTANCIA DE FEATURES (coeficientes) ===")
for f, coef in sorted(zip(features, modelo.coef_[0]), key=lambda x: -abs(x[1])):
print(f"{f:<12}: {coef:+.3f}")
Interpretación
Qué observas:
- La precisión out-of-sample probablemente esté cerca del baseline (~53%): la señal en retornos diarios es minúscula
- Los coeficientes son interpretables: ves qué features influyen y en qué dirección
- La división es temporal (
X[:corte]), no aleatoria → evita la fuga de información
Lección: un modelo simple e interpretable es el punto de partida correcto. Si apenas supera el baseline, es honesto reconocerlo en lugar de buscar modelos complejos que “mejoren” por overfitting.
🧪 Caso Práctico 3: La Trampa del Cross-Validation Estándar
Objetivo
Demostrar cómo el cross-validation aleatorio infla engañosamente los resultados frente al temporal.
Código
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score, KFold, TimeSeriesSplit
from sklearn.preprocessing import StandardScaler
X_s = StandardScaler().fit_transform(X)
modelo = LogisticRegression(C=0.1, max_iter=1000)
# === CV ALEATORIO (INCORRECTO en finanzas) ===
kf_aleatorio = KFold(n_splits=5, shuffle=True, random_state=42)
scores_aleatorio = cross_val_score(modelo, X_s, y, cv=kf_aleatorio)
# === CV TEMPORAL (CORRECTO) ===
tscv = TimeSeriesSplit(n_splits=5)
scores_temporal = cross_val_score(modelo, X_s, y, cv=tscv)
print("=== COMPARACIÓN DE VALIDACIÓN ===")
print(f"CV ALEATORIO (incorrecto): {scores_aleatorio.mean():.1%} (±{scores_aleatorio.std():.1%})")
print(f"CV TEMPORAL (correcto): {scores_temporal.mean():.1%} (±{scores_temporal.std():.1%})")
print(f"\nEl CV aleatorio suele dar un resultado más optimista porque permite")
print(f"fuga de información: datos correlacionados en el tiempo acaban en")
print(f"train y test simultáneamente. El temporal respeta el orden y es honesto.")
Interpretación
Qué demuestra:
- El CV aleatorio suele dar una precisión mayor (más optimista) que el temporal
- La diferencia es fuga de información: al mezclar fechas, datos correlacionados acaban en train y test, y el modelo “hace trampa”
- El
TimeSeriesSplitrespeta el orden (entrena con pasado, valida con futuro)
Lección crítica: en finanzas, NUNCA uses cross-validation aleatorio. La validación debe respetar el tiempo. Esta es una de las diferencias clave entre el ML financiero serio y el ingenuo.
🧪 Caso Práctico 4: Simple vs. Complejo — ¿Quién Gana?
Objetivo
Comparar una regresión regularizada contra un modelo complejo (random forest) out-of-sample.
Código
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
# División temporal
corte = int(len(df) * 0.7)
X_train, X_test = X[:corte], X[corte:]
y_train, y_test = y[:corte], y[corte:]
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s = scaler.transform(X_test)
# === MODELO SIMPLE: Regresión logística regularizada ===
simple = LogisticRegression(C=0.1, max_iter=1000)
simple.fit(X_train_s, y_train)
prec_simple_train = simple.score(X_train_s, y_train)
prec_simple_test = simple.score(X_test_s, y_test)
# === MODELO COMPLEJO: Random Forest profundo ===
complejo = RandomForestClassifier(n_estimators=200, max_depth=15, random_state=42)
complejo.fit(X_train, y_train)
prec_complejo_train = complejo.score(X_train, y_train)
prec_complejo_test = complejo.score(X_test, y_test)
print("=== SIMPLE vs COMPLEJO ===")
print(f"{'Modelo':<25}{'Train':>10}{'Test':>10}{'Gap':>10}")
print(f"{'Logística (simple)':<25}{prec_simple_train:>9.1%}{prec_simple_test:>10.1%}{prec_simple_train-prec_simple_test:>10.1%}")
print(f"{'Random Forest (complejo)':<25}{prec_complejo_train:>9.1%}{prec_complejo_test:>10.1%}{prec_complejo_train-prec_complejo_test:>10.1%}")
print(f"\nObserva el GAP (train - test): el modelo complejo suele tener")
print(f"un gap mayor (memoriza el train), señal de overfitting. El simple")
print(f"generaliza mejor pese a parecer 'peor' en el entrenamiento.")
Interpretación
Qué observas:
- El Random Forest suele tener una precisión en train muy alta (casi memoriza) pero un gap grande con el test
- La regresión simple tiene menos gap: generaliza mejor
- En test, a menudo el modelo simple iguala o supera al complejo
Lección: esto reproduce el Caso 2 de la guía (Lasso vence a deep learning). En finanzas, la complejidad facilita el overfitting. El gap train-test es tu detector. Empieza simple.
🧪 Caso Práctico 5: Clustering para Detectar Regímenes
Objetivo
Usar K-Means (no supervisado) para identificar regímenes de mercado.
Código
import yfinance as yf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
datos = yf.download("SPY", start="2015-01-01", end="2024-01-01")
precio = datos["Close"]
ret = precio.pct_change().dropna()
# Features para caracterizar el régimen
regimen_df = pd.DataFrame()
regimen_df["retorno_20d"] = ret.rolling(20).mean()
regimen_df["volatilidad_20d"] = ret.rolling(20).std()
regimen_df = regimen_df.dropna()
# K-Means con 3 regímenes (calma alcista, lateral, crisis)
X_reg = StandardScaler().fit_transform(regimen_df.values)
kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
regimen_df["regimen"] = kmeans.fit_predict(X_reg)
# Caracterizar cada régimen
print("=== CARACTERÍSTICAS DE CADA RÉGIMEN ===")
for r in sorted(regimen_df["regimen"].unique()):
sub = regimen_df[regimen_df["regimen"] == r]
print(f"Régimen {r}: retorno medio {sub['retorno_20d'].mean()*100:.3f}%/día, "
f"volatilidad media {sub['volatilidad_20d'].mean()*100:.2f}%")
# Visualizar los regímenes sobre el precio
precio_alineado = precio.loc[regimen_df.index]
plt.figure(figsize=(13, 6))
colores = ["green", "orange", "red"]
for r in sorted(regimen_df["regimen"].unique()):
mask = regimen_df["regimen"] == r
plt.scatter(precio_alineado.index[mask], precio_alineado[mask],
c=colores[r], s=5, label=f"Régimen {r}")
plt.title("Regímenes de mercado detectados por K-Means")
plt.ylabel("Precio SPY")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Interpretación
Qué observas:
- K-Means agrupa los días en regímenes según retorno y volatilidad, sin etiquetas previas (no supervisado)
- Típicamente identifica: régimen de calma alcista (verde), lateral (naranja) y crisis/alta volatilidad (rojo)
- Sobre el gráfico, los periodos de crisis (como COVID) aparecen marcados en rojo
Lección: el clustering es una herramienta exploratoria valiosa. Detectar regímenes conecta con los filtros de régimen del Módulo 7: podrías usar estos clusters para decidir qué estrategia aplicar en cada entorno.
🧪 Caso Práctico 6: Importancia de Features (Interpretabilidad)
Objetivo
Entender en qué features se apoya un modelo, como defensa contra el overfitting.
Código
import numpy as np
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier
# Entrenar un random forest sobre el train temporal
corte = int(len(df) * 0.7)
X_train, y_train = X[:corte], y[:corte]
rf = RandomForestClassifier(n_estimators=200, max_depth=5, random_state=42)
rf.fit(X_train, y_train)
# Importancia de cada feature
importancias = rf.feature_importances_
indices = np.argsort(importancias)[::-1]
print("=== IMPORTANCIA DE FEATURES ===")
for i in indices:
print(f"{features[i]:<12}: {importancias[i]:.3f}")
# Visualizar
plt.figure(figsize=(10, 5))
plt.bar(range(len(features)), importancias[indices], color="teal", alpha=0.7)
plt.xticks(range(len(features)), [features[i] for i in indices], rotation=45)
plt.title("Importancia de features (Random Forest)")
plt.ylabel("Importancia")
plt.tight_layout()
plt.show()
print("\nVerificación de cordura: ¿las features más importantes tienen")
print("sentido económico? Si el modelo se apoya en features sin lógica,")
print("es señal de overfitting o de relaciones espurias (Módulo 8).")
Interpretación
Qué hace:
- Muestra en qué features se apoya el modelo para decidir
- Permite una “verificación de cordura”: ¿las features importantes tienen sentido económico?
- Si el modelo se apoyara en features sin lógica, sería una bandera roja
Lección: la interpretabilidad es una defensa contra el overfitting. Un modelo que se apoya en relaciones con sentido económico es más fiable que una caja negra. Herramientas como shap (no incluida aquí por simplicidad) llevan esto más lejos.
🎓 Proyecto del Módulo
Enunciado
Construye un pipeline de ML financiero responsable:
- Construye 5-8 features con sentido económico
- Define una etiqueta (signo del retorno de mañana)
- Divide los datos TEMPORALMENTE (no aleatoriamente)
- Entrena un modelo simple (logística regularizada) y reporta precisión train/test
- Entrena un modelo complejo (random forest) y compáralos
- Compara con el baseline (predecir siempre la clase mayoritaria)
- Escribe un párrafo: ¿algún modelo supera al baseline de forma convincente? ¿Confiarías en él?
Solución de Referencia (Plantilla)
import yfinance as yf
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler
datos = yf.download("SPY", start="2015-01-01", end="2024-01-01")
df = pd.DataFrame({"precio": datos["Close"]})
df["ret"] = df["precio"].pct_change()
# 1. Features
df["ret_5d"] = df["precio"].pct_change(5)
df["ret_20d"] = df["precio"].pct_change(20)
df["vol_20d"] = df["ret"].rolling(20).std()
df["dist_sma20"] = df["precio"]/df["precio"].rolling(20).mean() - 1
df["dist_sma50"] = df["precio"]/df["precio"].rolling(50).mean() - 1
# 2. Etiqueta
df["target"] = (df["ret"].shift(-1) > 0).astype(int)
df = df.dropna()
feats = ["ret_5d", "ret_20d", "vol_20d", "dist_sma20", "dist_sma50"]
X, y = df[feats].values, df["target"].values
# 3. División temporal
corte = int(len(df)*0.7)
Xtr, Xte, ytr, yte = X[:corte], X[corte:], y[:corte], y[corte:]
sc = StandardScaler(); Xtr_s = sc.fit_transform(Xtr); Xte_s = sc.transform(Xte)
# 4-5. Modelos
simple = LogisticRegression(C=0.1, max_iter=1000).fit(Xtr_s, ytr)
complejo = RandomForestClassifier(n_estimators=100, max_depth=8, random_state=1).fit(Xtr, ytr)
# 6. Comparar con baseline
baseline = max(yte.mean(), 1-yte.mean())
print(f"Baseline (clase mayoritaria): {baseline:.1%}")
print(f"Logística (test): {simple.score(Xte_s, yte):.1%}")
print(f"Random Forest (test): {complejo.score(Xte, yte):.1%}")
# 7. Tu análisis aquí
Criterios de Evaluación
- Features con sentido económico: bien construidas (20%)
- División temporal correcta: no aleatoria (25%)
- Comparación simple vs. complejo: bien hecha (20%)
- Comparación con baseline: incluida (15%)
- Análisis crítico honesto: reconoce si supera o no al baseline (20%)
📝 Resumen del Módulo Práctico
Has aprendido a:
✓ Construir features financieras con sentido económico ✓ Entrenar un modelo simple e interpretable con división temporal ✓ Demostrar la trampa del cross-validation aleatorio (fuga de información) ✓ Comparar modelos simples vs. complejos (el gap train-test revela overfitting) ✓ Usar clustering para detectar regímenes de mercado ✓ Interpretar la importancia de features como defensa anti-overfitting
“Has aplicado machine learning a finanzas de la forma correcta: empezando simple, validando con honestidad temporal y comparando siempre con un baseline. Habrás notado que la ‘señal’ que encuentras es pequeña y frágil — eso no es un fracaso, es la realidad honesta de los mercados. Quien finge encontrar señales fuertes y fáciles, miente o se engaña. Tu escepticismo es tu mayor activo.”
En el Módulo 10, el proyecto final: unirás absolutamente todo lo aprendido en una estrategia cuantitativa completa, desde la hipótesis hasta la validación rigurosa.
Fin de los Casos Prácticos del Módulo 9