Lección 5.10.3 · 70 min
Ensembles, combinación de señales y datos sintéticos
Combinar varias señales es la mejora más barata y más fiable que existe en research cuantitativo, con una condición: que sus errores no sean los mismos. Esta lección combina cuatro señales del panel del nivel 5 con pesos estimados solo con el pasado, mide cuánto se diversifican sus errores y por qué los pesos iguales suelen ganar a los estimados. Después, los datos sintéticos de moda: GANs y difusión, qué reproducen, cómo se evalúan y por qué no fabrican señal.
- Módulo
- 5.10 · Interpretabilidad, robustez y riesgo de modelo
- Duración
- 70 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
Por qué combinar
En 1969, Bates y Granger mostraron que la media de dos previsiones de los pasajeros de líneas aéreas era mejor que cualquiera de ellas por separado. La idea es la misma que la de una cartera: dos activos con el mismo rendimiento esperado y correlación imperfecta dan, juntos, menos varianza. Con señales, lo que se diversifica son los errores.
Para señales tipificadas (media 0 y desviación 1 entre acciones cada semana), el IC de su media se calcula con dos cosas: los IC de cada una y la correlación media entre ellas. Con señales de IC y correlaciones ():
Si todas las señales son la misma, y la media no gana nada. Si son independientes y con el mismo IC, y el IC se multiplica por . Entre medias está todo lo que ocurre en la práctica. La fórmula también dice lo que cuesta añadir una señal mala: baja .
Fuente Mermaid
flowchart TB D[Datos y features] --> A[Señal ridge] D --> B[Señal LightGBM] D --> C[Factores fijos] G[Grafo de clientes] --> E[Señal grafo] A --> M[Combinar] B --> M C --> M E --> M M --> P[Cartera]
Las tres primeras señales salen de las mismas features del panel; la cuarta, de otra fuente: el grafo cliente-proveedor de la lección 5.7.4. Esa diferencia decidirá casi todo.
Cuatro señales fuera de muestra
Las señales se generan con el walk-forward de la lección 5.4.3: cada enero de 2015 a 2025 se reentrena con todas las semanas cuya etiqueta terminó antes de la fecha de decisión, y se predice el año. Las features van en rango normal transversal; la etiqueta es el rendimiento de la semana siguiente menos su media.
- Ridge (α = 10) con las 21 features, el modelo de referencia del README.
- LightGBM con las mismas features (lección 5.5.3), pequeño y muy regularizado.
- Factores fijos: la suma de seis features con el signo que da la literatura (momentum, valor, SUE y sentimiento con signo +; rendimiento de la última semana y volatilidad con signo −). No estima nada.
- Grafo: la media ponderada del rendimiento de los clientes la semana anterior, menos la media de su sector (lección 5.7.4). Es 0 en las 101 acciones sin clientes.
import numpy as np
import pandas as pd
from scipy.stats import norm, rankdata
from sklearn.linear_model import Ridge
import lightgbm as lgb
feat = pd.read_parquet("panel5_features.parquet")
etiq = pd.read_parquet("panel5_etiquetas.parquet")
feat = feat.sort_values(["fecha", "ticker"])
etiq = etiq.sort_values(["fecha", "ticker"])
FEATS = [c for c in feat.columns if c not in
("fecha", "ticker", "sector", "revision_analistas")]
fechas = pd.DatetimeIndex(feat["fecha"].unique())
nW, nA, nF = len(fechas), feat["ticker"].nunique(), len(FEATS)
F = feat[FEATS].to_numpy(dtype=float).reshape(nW, nA, nF)
with np.errstate(invalid="ignore", divide="ignore"):
rango = rankdata(F, axis=1, nan_policy="omit")
n_ = np.isfinite(F).sum(axis=1, keepdims=True)
G = np.nan_to_num(norm.ppf((rango - 0.5) / n_))
R = etiq["ret_sig_semana"].to_numpy().reshape(nW, nA)
fin = etiq["fin_sig_semana"].to_numpy().reshape(nW, nA)[:, 0]
con_feat = np.isfinite(F[:, :, 0]).all(axis=1)
con_etiq = np.isfinite(R).all(axis=1)
valida = con_feat & con_etiq
Yc = np.zeros_like(R)
Yc[valida] = R[valida] - R[valida].mean(axis=1, keepdims=True)
anyo = fechas.year.to_numpy()
pru = np.flatnonzero(con_feat & (anyo >= 2015))
evaluables = pru[con_etiq[pru]]
def ultima_conocida(w):
"""Última semana cuya etiqueta terminó antes de la fecha w."""
return int(np.searchsorted(fin, fechas[w], side="left")) - 1
print(f"{nW} semanas x {nA} acciones x {nF} features | prueba: "
f"{len(evaluables)} semanas desde {fechas[pru[0]].date()}")730 semanas x 400 acciones x 21 features | prueba: 574 semanas desde 2015-01-02senal = {k: np.full((nW, nA), np.nan) for k in ("ridge", "LightGBM")}
for a in range(2015, 2026):
semanas = pru[anyo[pru] == a]
ent = np.flatnonzero(valida[:ultima_conocida(semanas[0]) + 1])
X_ent, y_ent = G[ent].reshape(-1, nF), Yc[ent].reshape(-1)
ridge = Ridge(alpha=10.0).fit(X_ent, y_ent)
gbm = lgb.LGBMRegressor(
n_estimators=200, learning_rate=0.03, num_leaves=15,
min_child_samples=500, subsample=0.5, subsample_freq=1,
colsample_bytree=0.8, reg_lambda=10.0, random_state=0,
n_jobs=4, deterministic=True, force_row_wise=True,
verbose=-1).fit(X_ent, y_ent)
for w in semanas:
senal["ridge"][w] = ridge.predict(G[w])
senal["LightGBM"][w] = gbm.predict(G[w])
print("semanas de entrenamiento en 2015 y en 2025:",
len(np.flatnonzero(valida[:ultima_conocida(pru[0]) + 1])),
len(ent))semanas de entrenamiento en 2015 y en 2025: 154 676j = {f: i for i, f in enumerate(FEATS)}
signos = {"mom_12_1": 1, "ret_1s": -1, "valor_bp": 1, "sue": 1,
"sentimiento": 1, "vol_63d": -1}
senal["factores"] = sum(s * G[:, :, j[f]] for f, s in signos.items())
act = pd.read_csv("panel5_activos.csv").sort_values("ticker")
pos = {t: i for i, t in enumerate(act["ticker"])}
g = pd.read_csv("grafo5_relaciones.csv")
cli = g[g["tipo"] == "cliente"]
Wc = np.zeros((nA, nA))
Wc[cli["origen"].map(pos), cli["destino"].map(pos)] = cli["peso"]
sector = pd.factorize(act["sector"])[0]
Rs = R.copy()
for s_ in np.unique(sector):
m = sector == s_
with np.errstate(invalid="ignore"):
Rs[:, m] = R[:, m] - R[:, m].mean(axis=1, keepdims=True)
X_prev = np.zeros_like(R)
X_prev[1:] = np.nan_to_num(Rs[:-1]) # la semana que acaba en w
senal["grafo"] = X_prev @ Wc.T
print({k: int(np.isfinite(v[pru]).all()) for k, v in senal.items()}){'ridge': 1, 'LightGBM': 1, 'factores': 1, 'grafo': 1}Las cuatro señales se pasan a rango normal transversal cada semana, para que la combinación no dependa de su escala (la del ridge es de puntos básicos; la de los factores, de unidades). Después, su IC semanal, su t y dos medidas de cuánto se parecen: la correlación media entre sus puntuaciones y la correlación entre sus series de IC.
def rango_normal(p):
out = np.full_like(p, np.nan)
for w in pru:
out[w] = norm.ppf((rankdata(p[w]) - 0.5) / nA)
return out
def ic_semanal(p, semanas=evaluables):
return np.array([np.corrcoef(rankdata(p[w]), rankdata(R[w]))[0, 1]
for w in semanas])
def resumen(ic):
et = ic.std(ddof=1) / np.sqrt(len(ic))
return {"IC": round(float(ic.mean()), 4),
"error típico": round(float(et), 4),
"t": round(float(ic.mean() / et), 1)}
Z = {k: rango_normal(v) for k, v in senal.items()}
nombres = list(Z)
ics = {k: ic_semanal(Z[k]) for k in nombres}
print(pd.DataFrame({k: resumen(v) for k, v in ics.items()}).T)
C = np.mean([np.corrcoef(np.stack([Z[k][w] for k in nombres]))
for w in evaluables], axis=0)
print("\ncorrelación media entre puntuaciones")
print(pd.DataFrame(C, nombres, nombres).round(2))
print("\ncorrelación entre las series semanales de IC")
print(pd.DataFrame(ics).corr().round(2)) IC error típico t
ridge 0.0185 0.0038 4.9
LightGBM 0.0145 0.0032 4.6
factores 0.0149 0.0039 3.8
grafo 0.0090 0.0021 4.3
correlación media entre puntuaciones
ridge LightGBM factores grafo
ridge 1.00 0.63 0.68 -0.0
LightGBM 0.63 1.00 0.44 0.0
factores 0.68 0.44 1.00 0.0
grafo -0.00 0.00 0.00 1.0
correlación entre las series semanales de IC
ridge LightGBM factores grafo
ridge 1.00 0.84 0.46 -0.00
LightGBM 0.84 1.00 0.36 -0.02
factores 0.46 0.36 1.00 0.11
grafo -0.00 -0.02 0.11 1.00El ridge da el IC del README (0,0185, t = 4,9). LightGBM y los factores fijos quedan por debajo, y el grafo, con un IC de la mitad, tiene una t parecida porque su IC semanal es más estable. Lo importante está en las correlaciones: las tres señales que salen de las features se parecen mucho (puntuaciones entre 0,44 y 0,68; series de IC del ridge y LightGBM, 0,84), y el grafo no se parece a ninguna (0,00).
La media y su IC predicho
combos = {"ridge + LightGBM + factores": ["ridge", "LightGBM",
"factores"],
"ridge + grafo": ["ridge", "grafo"],
"las cuatro": nombres}
filas = {}
for nombre, ks in combos.items():
idx = [nombres.index(k) for k in ks]
predicho = (np.mean([ics[k].mean() for k in ks])
/ np.sqrt(C[np.ix_(idx, idx)].mean()))
ic_m = ic_semanal(sum(Z[k] for k in ks) / len(ks))
dif = ic_m - ics["ridge"]
filas[nombre] = {**resumen(ic_m),
"IC predicho": round(predicho, 4),
"dif. con ridge": round(dif.mean(), 4),
"t dif.": round(dif.mean() / dif.std(ddof=1)
* np.sqrt(len(dif)), 1)}
pd.DataFrame(filas).T IC error típico t IC predicho dif. con ridge t dif.
ridge + LightGBM + factores 0.0186 0.0036 5.2 0.0188 0.0001 0.1
ridge + grafo 0.0194 0.0030 6.4 0.0194 0.0010 0.5
las cuatro 0.0205 0.0034 6.0 0.0207 0.0020 1.1La fórmula acierta con un error de unas décimas de milésima en las tres medias. Y explica los resultados:
- Tres señales de las mismas features: correlación media alta, así que el denominador apenas baja de 1 y la media de sus IC (por debajo del ridge) se queda casi igual. La media no mejora al ridge.
- Ridge + grafo: correlación cero. La media de sus IC es baja (0,0137), pero se divide por y sube por encima del ridge. Lo más visible es la t: de 4,9 a 6,4.
- Las cuatro: 0,0205 con t = 6,0, el mejor IC de la tabla.
La t sube mucho más que el IC porque la diversificación actúa sobre todo en el error semana a semana: las semanas malas del ridge no son las del grafo, y la serie semanal del IC de la media es más estable. La diferencia pareada de la media del IC frente al ridge, en cambio, tiene t ≈ 1: el IC medio mejora un poco y la estabilidad mejora mucho. Para una cartera es lo segundo lo que más vale (lección 5.1.3: el Sharpe de una estrategia es, a grandes rasgos, el IC por la raíz de la amplitud, dividido por la variabilidad del IC).
Con qué pesos
La media simple da el mismo peso a una señal con IC de 0,018 que a una de 0,009. Parece mejorable: pesar por el IC pasado o aprender los pesos con una regresión (stacking, Wolpert, 1992; Breiman, 1996). La condición de honestidad es la de siempre: los pesos de la semana solo pueden usar semanas cuya etiqueta ya terminó, y con predicciones que fueron fuera de muestra en su momento. Se necesita un año de predicciones para empezar, así que la comparación es de 2016 a 2025.
Cuatro esquemas:
| Esquema | Pesos | Se estima |
|---|---|---|
| Media | 1/4 cada una | nada |
| Pesos por IC | proporcionales al IC medio pasado de cada señal (0 si es negativo) | 4 medias |
| Stacking anidado | regresión de la etiqueta sobre las 4 señales, con las semanas pasadas | 4 coeficientes |
| Retrospectiva | la misma regresión con todas las semanas de 2016-2025 | 4 coeficientes, con el futuro |
ic_w = {k: np.full(nW, np.nan) for k in nombres}
for k in nombres:
ic_w[k][evaluables] = ics[k]
por_ic = np.full((nW, nA), np.nan)
stack = np.full((nW, nA), np.nan)
pesos_stack = {}
for w in pru:
pasado = evaluables[evaluables <= ultima_conocida(w)]
if len(pasado) < 52:
continue
m = np.clip([np.mean(ic_w[k][pasado]) for k in nombres], 0, None)
por_ic[w] = sum(m[i] / m.sum() * Z[k][w]
for i, k in enumerate(nombres))
Xs = np.stack([Z[k][pasado].ravel() for k in nombres], axis=1)
b = np.linalg.lstsq(Xs, Yc[pasado].ravel(), rcond=None)[0]
stack[w] = sum(b[i] * Z[k][w] for i, k in enumerate(nombres))
pesos_stack[fechas[w]] = b / b.sum()
ev2 = np.array([w for w in evaluables if np.isfinite(stack[w]).all()])
Xr = np.stack([Z[k][ev2].ravel() for k in nombres], axis=1)
b_retro = np.linalg.lstsq(Xr, Yc[ev2].ravel(), rcond=None)[0]
media4 = sum(Z[k] for k in nombres) / 4
esquemas = {"ridge": Z["ridge"], "media": media4,
"pesos por IC": por_ic, "stacking anidado": stack,
"retrospectiva": sum(b_retro[i] * Z[k]
for i, k in enumerate(nombres))}
ic2 = {k: ic_semanal(p, ev2) for k, p in esquemas.items()}
tabla = pd.DataFrame({k: resumen(v) for k, v in ic2.items()}).T
for k, v in ic2.items():
dif = v - ic2["media"]
et = dif.std(ddof=1) / np.sqrt(len(dif))
tabla.loc[k, "dif. con la media"] = round(dif.mean(), 4)
tabla.loc[k, "t dif."] = (round(dif.mean() / et, 1)
if et > 1e-6 else np.nan)
print(f"{len(ev2)} semanas desde {fechas[ev2[0]].date()}")
print(tabla.to_string())
print("pesos en retrospectiva:",
dict(zip(nombres, (b_retro / b_retro.sum()).round(2).tolist())))
ultimos = pesos_stack[fechas[ev2[-1]]].round(2).tolist()
print("stacking, última semana:", dict(zip(nombres, ultimos)))521 semanas desde 2016-01-08
IC error típico t dif. con la media t dif.
ridge 0.0175 0.0040 4.4 -0.0021 -1.1
media 0.0196 0.0036 5.5 0.0000 NaN
pesos por IC 0.0184 0.0037 4.9 -0.0012 -2.4
stacking anidado 0.0178 0.0037 4.8 -0.0018 -1.3
retrospectiva 0.0201 0.0036 5.6 0.0005 0.4
pesos en retrospectiva: {'ridge': 0.62, 'LightGBM': -0.02, 'factores': 0.04, 'grafo': 0.36}
stacking, última semana: {'ridge': 0.66, 'LightGBM': 0.02, 'factores': 0.02, 'grafo': 0.31}La media simple gana a los dos esquemas que estiman pesos con el pasado, y empata con los pesos elegidos mirando el futuro, que son la mejor combinación lineal posible en esta muestra. Es el puzle de la combinación de previsiones: en los estudios con datos macroeconómicos, la media simple gana una y otra vez a combinaciones «óptimas». Smith y Wallis (2009) dan la explicación: los pesos óptimos de la muestra llevan error de estimación, y cuando las señales tienen IC pequeños y parecidos, ese error cuesta más que lo que se gana eligiendo bien. La figura muestra los pesos del stacking a lo largo del tiempo: se mueven con el ruido de cada año, y en algunos tramos dan peso negativo a señales que fuera de muestra son buenas.
De la señal a la cartera
El IC es la métrica de la señal; la cartera paga además la rotación. Una cartera larga-corta por deciles, equiponderada y rebalanceada cada semana, con 10 pb por lado sobre la rotación (como en el README):
def larga_corta(p, semanas=ev2, coste=0.001):
rend, rot, previo = [], [], np.zeros(nA)
for w in semanas:
rk = rankdata(p[w])
q = np.where(rk > 0.9 * nA, 1.0, 0.0)
q = q / q.sum() - np.where(rk <= 0.1 * nA, 1.0, 0.0) \
/ (rk <= 0.1 * nA).sum()
rend.append(q @ R[w])
rot.append(np.abs(q - previo).sum())
previo = q
rend, rot = np.array(rend), np.array(rot)
neto = rend - coste * rot
return {"rend. anual %": 5200 * rend.mean(),
"Sharpe bruto": np.sqrt(52) * rend.mean() / rend.std(),
"rotación semanal %": 100 * rot[1:].mean(),
"Sharpe neto": np.sqrt(52) * neto.mean() / neto.std()}
pd.DataFrame({k: larga_corta(Z[k] if k in Z else media4)
for k in ("ridge", "grafo", "media de las cuatro")}
).T.round(2) rend. anual % Sharpe bruto rotación semanal % Sharpe neto
ridge 10.77 1.13 262.72 -0.30
grafo 6.06 1.06 322.34 -1.87
media de las cuatro 10.04 1.18 272.89 -0.49Combinar no arregla los costes. La media de las cuatro señales tiene un Sharpe bruto parecido al del ridge (1,18 frente a 1,13) y rota algo más (273 % frente a 263 % a la semana), así que neto de costes sigue sin dar dinero (−0,49 frente a −0,30). La señal del grafo sola, con un Sharpe bruto de 1,06, es la que peor sale neta (−1,87): cambia de signo cada semana con el rendimiento de los clientes y rota un 322 %. En los tres casos, la rotación de una señal semanal transversal se come una ventaja de este tamaño (lección 5.1.3). La combinación mejora el ingrediente; la receta de la cartera (frecuencia de rebalanceo, bandas de no operar, coste por operación) es otra lección.
Datos sintéticos: GANs y difusión
La otra mitad del epígrafe es generar datos. La lección 2.5.7 usó generadores de modelo explícito (GBM, GARCH, bootstrap) para pruebas de estrés, y todos los datos de este curso salen de modelos con parámetros publicados. Los generadores profundos prometen otra cosa: aprender la distribución de las series directamente de los datos, sin escribir el modelo.
- GANs (Goodfellow et al., 2014): un generador transforma ruido en series y un discriminador intenta distinguirlas de las reales; se entrenan uno contra otro. En finanzas, Quant GANs (Wiese et al., 2020) usa redes convolucionales temporales en los dos y afirma reproducir agrupamiento de volatilidad, efecto apalancamiento y autocorrelaciones; TimeGAN (Yoon et al., 2019) añade una pérdida supervisada sobre la dinámica temporal.
- Difusión (Ho et al., 2020): se aprende a quitar ruido a datos a los que se ha añadido ruido paso a paso, y se genera desde ruido puro. Takahashi y Mizuno (2024) aplican modelos de difusión a series de precios, volúmenes y horquillas convertidas en imágenes con una transformada de ondículas y afirman reproducir colas gruesas, agrupamiento de volatilidad y estacionalidad; CoFinDiff (Tanaka et al., 2025) condiciona la generación a una tendencia y a una volatilidad realizada.
Lo que estos trabajos afirman se comprueba con tres familias de pruebas, que son también las que señala la revisión de Assefa et al. (2020) sobre datos sintéticos en finanzas:
| Prueba | Pregunta | Cómo |
|---|---|---|
| Hechos estilizados | ¿Reproduce colas, agrupamiento de volatilidad, autocorrelación casi nula de los rendimientos? | curtosis, cuantiles extremos, autocorrelación de r y de |r| |
| Memorización | ¿Copia la muestra de entrenamiento? | distancia de cada serie generada a la más cercana del entrenamiento, frente a la de datos reales nuevos |
| Discriminador externo | ¿Se distingue de los datos reales? | un clasificador fuera del entrenamiento; AUC 0,5 = indistinguible |
Un GAN pequeño frente al bootstrap
Para verlo con las manos, un GAN mínimo en PyTorch que genera ventanas de 20 sesiones de rendimientos del activo
indice de vol5_diario.parquet (el laboratorio de volatilidad del nivel: GARCH-HAR con colas t y efecto
apalancamiento). Se entrena con 2008-2019 (3 002 ventanas solapadas, tipificadas) y se compara con los datos de
2020-2025, con ruido normal y con el bootstrap estacionario de la lección 2.5.7 (bloques de 10 sesiones de media).
Un generador de verdad entrenaría mucho más y con redes temporales; este tarda unos segundos en CPU.
import torch
from scipy.stats import kurtosis
torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
vol = pd.read_parquet("vol5_diario.parquet")
r = vol[vol["activo"] == "indice"].set_index("fecha")["rendimiento"]
L, sd = 20, r[:"2019-12-31"].std()
ventanas = lambda x: np.lib.stride_tricks.sliding_window_view(x, L)
V_ent = ventanas(r[:"2019-12-31"].to_numpy() / sd)
V_pru = ventanas(r["2020-01-01":].to_numpy() / sd)
def red(entrada, salida):
return torch.nn.Sequential(
torch.nn.Linear(entrada, 64), torch.nn.LeakyReLU(0.2),
torch.nn.Linear(64, 64), torch.nn.LeakyReLU(0.2),
torch.nn.Linear(64, salida))
gen, disc = red(16, L), red(L, 1)
o_g = torch.optim.Adam(gen.parameters(), lr=2e-4, betas=(0.5, 0.999))
o_d = torch.optim.Adam(disc.parameters(), lr=2e-4, betas=(0.5, 0.999))
bce = torch.nn.BCEWithLogitsLoss()
reales = torch.tensor(V_ent, dtype=torch.float32)
azar = torch.Generator().manual_seed(0)
uno, cero = torch.ones(128, 1), torch.zeros(128, 1)
for paso in range(3000):
lote = reales[torch.randint(0, len(reales), (128,), generator=azar)]
falsas = gen(torch.randn(128, 16, generator=azar))
o_d.zero_grad()
p_d = bce(disc(lote), uno) + bce(disc(falsas.detach()), cero)
p_d.backward()
o_d.step()
o_g.zero_grad()
p_g = bce(disc(falsas), uno)
p_g.backward()
o_g.step()
with torch.no_grad():
V_gan = gen(torch.randn(3000, 16, generator=azar)).numpy()
V_gan = V_gan.astype(float)
print(f"ventanas: entrenamiento {V_ent.shape}, prueba {V_pru.shape}, "
f"GAN {V_gan.shape}")
print(f"pérdidas finales: discriminador {p_d.item():.2f}, "
f"generador {p_g.item():.2f}")ventanas: entrenamiento (3002, 20), prueba (1489, 20), GAN (3000, 20)
pérdidas finales: discriminador 1.20, generador 0.80Las pérdidas finales no dicen si el GAN es bueno: en un GAN, las dos redes pueden estar en equilibrio y el generador producir series que no se parecen en nada a las reales en lo que importa. Hay que medirlo.
rng = np.random.default_rng(0)
def bootstrap_estacionario(x, n, media=10):
out = np.empty((n, L))
for i in range(n):
j, serie = rng.integers(len(x)), []
while len(serie) < L:
serie.append(x[j])
j = (rng.integers(len(x)) if rng.random() < 1 / media
else (j + 1) % len(x))
out[i] = serie
return out
V_boot = bootstrap_estacionario(r[:"2019-12-31"].to_numpy() / sd, 3000)
V_norm = rng.standard_normal((3000, L))
def hechos(V):
def ac(Y, k):
return np.corrcoef(Y[:, :-k].ravel(), Y[:, k:].ravel())[0, 1]
return {"curtosis": kurtosis(V.ravel()),
"cuantil 0,5 %": np.quantile(V.ravel(), 0.005),
"AC(1) de r": ac(V, 1),
"AC(1) de |r|": ac(np.abs(V), 1),
"AC(5) de |r|": ac(np.abs(V), 5)}
conjuntos = {"real 2008-2019": V_ent, "real 2020-2025": V_pru,
"GAN": V_gan, "normal": V_norm, "bootstrap": V_boot}
pd.DataFrame({k: hechos(v) for k, v in conjuntos.items()}).T.round(3) curtosis cuantil 0,5 % AC(1) de r AC(1) de |r| AC(5) de |r|
real 2008-2019 3.793 -3.357 -0.026 0.088 0.028
real 2020-2025 1.865 -3.449 0.055 0.108 0.068
GAN 0.616 -2.709 -0.240 0.092 0.125
normal 0.023 -2.575 -0.002 0.005 -0.010
bootstrap 3.654 -3.357 -0.024 0.080 0.009El GAN aprendió la escala de los rendimientos y la autocorrelación de orden 1 de |r| (0,092 frente a 0,088), pero falla en lo demás: su curtosis es una sexta parte de la real (0,62 frente a 3,79: colas casi normales, con un cuantil del 0,5 % de −2,71 frente a −3,36), exagera la memoria de |r| a cinco sesiones (0,125 frente a 0,028) y se inventa una autocorrelación de orden 1 de los rendimientos de −0,24 que la serie real no tiene (−0,03). El bootstrap, que solo reordena bloques de la historia, reproduce las colas y la autocorrelación de los rendimientos por construcción y conserva parte del agrupamiento.
from sklearn.neighbors import NearestNeighbors
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
vecino = NearestNeighbors(n_neighbors=1).fit(V_ent)
def resumen_ventana(V):
a = np.abs(V)
ac = [np.corrcoef(x[:-1], x[1:])[0, 1] for x in a]
return np.c_[V.std(1), kurtosis(V, axis=1), ac, a.max(1), V.sum(1)]
X_real = resumen_ventana(V_pru[::5]) # 298 ventanas reales
filas = {}
for k in ("real 2020-2025", "GAN", "normal", "bootstrap"):
V = conjuntos[k]
fila = {"distancia al vecino (mediana)":
np.median(vecino.kneighbors(V[:1000])[0])}
if k != "real 2020-2025":
X_s = resumen_ventana(V[:len(X_real)])
X_c = np.r_[X_real, X_s]
y_c = np.r_[np.zeros(len(X_real)), np.ones(len(X_s))]
fila["AUC del discriminador"] = cross_val_score(
LogisticRegression(max_iter=1000), X_c, y_c, cv=5,
scoring="roc_auc").mean()
filas[k] = fila
pd.DataFrame(filas).T.round(3) distancia al vecino (mediana) AUC del discriminador
real 2020-2025 2.963 NaN
GAN 2.969 0.754
normal 3.288 0.627
bootstrap 2.671 0.457La distancia de las ventanas del GAN a la ventana de entrenamiento más cercana es la de los datos reales nuevos: no copia la muestra (memorizar es el riesgo de los generadores grandes con pocos datos, y también un problema de privacidad cuando los datos son de clientes). El discriminador externo, una regresión logística con cinco resúmenes de cada ventana, distingue las del GAN de las reales con un AUC de 0,75, mejor que las normales (0,63); las del bootstrap (0,46) no las distingue. Un generador profundo que no supera a un bootstrap en estas pruebas no aporta nada a una prueba de estrés.
Lo que los datos sintéticos no pueden hacer
| Uso | ¿Tiene sentido? | Por qué |
|---|---|---|
| Pruebas de estrés y escenarios | sí, con generadores evaluados | más caminos que la historia, con colas y agrupamiento controlados (lección 2.5.7) |
| Comprobar métodos con la respuesta conocida | sí | es lo que hace este curso: modelos explícitos con efectos plantados |
| Compartir datos sin exponer clientes | a veces | si se demuestra que no hay memorización |
| Aumentar datos para entrenar modelos de predicción | con mucho cuidado | el generador sale de la misma historia: no añade información sobre el futuro; cualquier mejora es regularización y hay que compararla con regularizar directamente |
| Fuente de señal | no | un generador reproduce la distribución que vio, incluido su ruido; la ventaja que «descubra» un modelo en datos sintéticos es la del generador |
Los generadores profundos son una herramienta de modelización más, con el mismo problema de fondo que cualquier modelo de mercado: un mercado es una sola historia que no se repite, y lo que no está en ella (la próxima crisis, con su forma nueva) tampoco lo está en un generador entrenado con ella.
Ejercicios
- Con dos señales incorreladas, la combinación óptima pesa cada una en proporción a su IC y alcanza un IC de . Calcula ese techo para el ridge y el grafo y compáralo con la media simple de los dos y con el stacking de solo esas dos señales.
Solución
r1, r2 = ics["ridge"].mean(), ics["grafo"].mean()
techo = np.sqrt(r1 ** 2 + r2 ** 2)
optima = ic_semanal(r1 * Z["ridge"] + r2 * Z["grafo"])
media2 = ic_semanal((Z["ridge"] + Z["grafo"]) / 2)
st2 = np.full((nW, nA), np.nan)
for w in ev2:
pasado = evaluables[evaluables <= ultima_conocida(w)]
Xs = np.stack([Z[k][pasado].ravel() for k in ("ridge", "grafo")], 1)
b = np.linalg.lstsq(Xs, Yc[pasado].ravel(), rcond=None)[0]
st2[w] = b[0] * Z["ridge"][w] + b[1] * Z["grafo"][w]
print(f"techo teórico {techo:.4f} | pesos por IC (conocidos al final) "
f"{optima.mean():.4f} | media {media2.mean():.4f}")
m2 = ic_semanal((Z["ridge"] + Z["grafo"]) / 2, ev2).mean()
print(f"en 2016-2025: media {m2:.4f} | stacking "
f"{ic_semanal(st2, ev2).mean():.4f}")techo teórico 0.0205 | pesos por IC (conocidos al final) 0.0205 | media 0.0194
en 2016-2025: media 0.0193 | stacking 0.0193Los pesos proporcionales al IC, calculados con todo el periodo (retrospectivos), alcanzan el techo (0,0205); la media simple se queda en 0,0194, porque da demasiado peso al grafo. Pero cuando los pesos se estiman de verdad con el pasado (stacking anidado, 2016-2025), empatan con la media: 0,0193 los dos. La ventaja teórica de pesar bien se la come el error de estimar los pesos, incluso con una señal claramente más débil que la otra. La regla práctica: pesos iguales salvo que haya razones fuertes (y mucha historia) para otra cosa.
- Entrena el GAN el doble de pasos (6 000) con la misma semilla. ¿Mejoran la curtosis, la autocorrelación de orden 1 y el AUC del discriminador?
Solución
torch.manual_seed(0)
gen, disc = red(16, L), red(L, 1)
o_g = torch.optim.Adam(gen.parameters(), lr=2e-4, betas=(0.5, 0.999))
o_d = torch.optim.Adam(disc.parameters(), lr=2e-4, betas=(0.5, 0.999))
azar = torch.Generator().manual_seed(0)
for paso in range(6000):
lote = reales[torch.randint(0, len(reales), (128,), generator=azar)]
falsas = gen(torch.randn(128, 16, generator=azar))
o_d.zero_grad()
(bce(disc(lote), uno) + bce(disc(falsas.detach()), cero)).backward()
o_d.step()
o_g.zero_grad()
bce(disc(falsas), uno).backward()
o_g.step()
with torch.no_grad():
V_gan2 = gen(torch.randn(3000, 16, generator=azar)).numpy()
V_gan2 = V_gan2.astype(float)
X_s = resumen_ventana(V_gan2[:len(X_real)])
auc = cross_val_score(LogisticRegression(max_iter=1000),
np.r_[X_real, X_s],
np.r_[np.zeros(len(X_real)), np.ones(len(X_s))],
cv=5, scoring="roc_auc").mean()
print({k: round(float(v), 3) for k, v in hechos(V_gan2).items()},
"| AUC", round(float(auc), 3)){'curtosis': 1.076, 'cuantil 0,5 %': -2.428, 'AC(1) de r': -0.15, 'AC(1) de |r|': 0.088, 'AC(5) de |r|': 0.044} | AUC 0.773Con 6 000 pasos la curtosis sube algo (1,08) y la autocorrelación espuria baja (−0,15), pero el discriminador lo distingue mejor que antes (AUC 0,77 frente a 0,75). Entrenar más mueve las cifras, pero no de forma monótona ni hacia lo que se busca: un GAN no minimiza una pérdida que mida los hechos estilizados, y su entrenamiento oscila. Por eso los trabajos serios eligen la arquitectura (redes temporales que puedan representar la memoria de la volatilidad, colas en el ruido de entrada) y evalúan con pruebas fijadas de antemano, no con la pérdida del discriminador.
- Un compañero propone entrenar el modelo de señales del panel con diez veces más semanas «generadas con difusión a partir del panel real». ¿Qué validación le pedirías antes de aceptarlo?
Solución
Primero, que el generador se ajuste solo con datos de entrenamiento de cada paso del walk-forward (si ve el periodo de prueba, es una fuga por la puerta de atrás). Segundo, una comparación pareada fuera de muestra entre el modelo entrenado con datos reales y el entrenado con reales más sintéticos, con el mismo walk-forward y su diferencia de IC con error típico. Tercero, una comparación con regularizar directamente (más penalización, menos árboles, ensembles por semillas): si los datos sintéticos ayudan, lo más probable es que lo hagan como regularización, y entonces lo barato gana. Y cuarto, las pruebas de memorización del generador: diez veces más semanas que sean casi copias de las reales solo multiplican el sobreajuste.
Respuesta plegada en cada pregunta
Autoevaluación
Ridge, LightGBM y una combinación fija de factores, todos con las mismas features, tienen IC de 0,0185, 0,0145 y 0,0149, con correlaciones entre sus puntuaciones de 0,44 a 0,68. Su media tiene un IC de 0,0186. ¿Por qué no mejora al ridge?
- APorque la media de señales nunca mejora a la mejor
- BPorque LightGBM está mal entrenado
- CPorque los tres modelos sacan la misma información de las mismas features: sus errores están muy correlacionados y la media diluye al mejor sin diversificar casi nada
- DPorque habría que multiplicar la media por tres
Ver respuesta
Respuesta C. Porque los tres modelos sacan la misma información de las mismas features: sus errores están muy correlacionados y la media diluye al mejor sin diversificar casi nada
El IC de una media de señales tipificadas es la media de los IC dividida por la raíz de la correlación media entre ellas. Con correlaciones altas el denominador apenas baja de 1 y compensa justo lo que la media de los IC (0,0160) pierde frente al mejor. La fórmula predice 0,0188 y se mide 0,0186.
La señal del grafo cliente-proveedor tiene un IC de solo 0,009, pero una correlación nula con el ridge. ¿Qué pasa al promediarla con él?
- AEl IC de la media sube y, sobre todo, se estabiliza: los errores semana a semana de las dos señales no coinciden y se compensan en parte
- BEl IC baja porque la señal del grafo es más débil
- CNo cambia nada: solo importa la mejor señal
- DSube el IC pero baja su t
Ver respuesta
Respuesta A. El IC de la media sube y, sobre todo, se estabiliza: los errores semana a semana de las dos señales no coinciden y se compensan en parte
Con correlación cero, el IC de la media es (0,0185 + 0,0090)/2/√0,5 ≈ 0,0194, más que el del ridge solo. Y la serie semanal del IC de la media es menos volátil, así que su t sube bastante más que su media: es la diversificación de errores.
El stacking anidado (una regresión de la etiqueta sobre las señales, estimada solo con semanas pasadas) da un IC de 0,0178 y la media simple de las cuatro señales, 0,0196. ¿Qué es esto?
- AUn error en el stacking
- BEl puzle de la combinación de previsiones: con poca señal, el error de estimar los pesos cuesta más de lo que se gana eligiéndolos, y los pesos iguales suelen ganar
- CUna prueba de que el stacking siempre es peor
- DQue la media simple usa información futura
Ver respuesta
Respuesta B. El puzle de la combinación de previsiones: con poca señal, el error de estimar los pesos cuesta más de lo que se gana eligiéndolos, y los pesos iguales suelen ganar
Smith y Wallis (2009) lo explican así: los pesos óptimos de la muestra se estiman con mucho ruido cuando las señales tienen IC parecidos y pequeños. Los pesos iguales no se estiman y no tienen ese error.
Un GAN entrenado sobre ventanas de 20 días de rendimientos tiene una curtosis de 0,6 (la serie real, 3,8), una autocorrelación de orden 1 de −0,24 (la real, −0,03) y un discriminador externo lo distingue de los datos reales con un AUC de 0,75. El bootstrap estacionario da 0,46. ¿Qué se concluye?
- AQue el GAN es mejor porque genera datos nuevos
- BQue el AUC no es una buena medida
- CQue hay que usar el GAN para entrenar los modelos
- DQue este GAN no reproduce las colas y se inventa una dependencia que no existe; para pruebas de estrés, el bootstrap de la lección 2.5.7 es mejor y mucho más barato
Ver respuesta
Respuesta D. Que este GAN no reproduce las colas y se inventa una dependencia que no existe; para pruebas de estrés, el bootstrap de la lección 2.5.7 es mejor y mucho más barato
Un generador se evalúa por lo que reproduce y por lo que se inventa. Este GAN pequeño falla en las dos cosas; los de la literatura (Quant GANs, difusión) afirman hacerlo mejor, pero se evalúan con las mismas pruebas.
Un proveedor vende datos sintéticos generados con difusión a partir de la historia de un mercado y afirma que entrenar con ellos mejora los modelos de predicción. ¿Cuál es la objeción principal?
- AQue la difusión es más lenta que un GAN
- BQue un generador entrenado con una sola historia no contiene más información sobre el futuro que esa historia: puede ayudar a regularizar o a probar en estrés, pero no crea señal, y puede copiar el ruido de la muestra
- CQue los datos sintéticos son siempre ilegales
- DQue hace falta GPU
Ver respuesta
Respuesta B. Que un generador entrenado con una sola historia no contiene más información sobre el futuro que esa historia: puede ayudar a regularizar o a probar en estrés, pero no crea señal, y puede copiar el ruido de la muestra
Aumentar datos con un generador ajustado a la misma muestra reutiliza la misma información. Si el modelo mejora en una validación honesta, la mejora viene de la regularización, y hay que compararla con regularizar directamente.
Ideas clave
- El IC de una media de k señales tipificadas es la media de sus IC dividida por la raíz de su correlación media. Funciona en los datos: predice 0,0207 para la media de las cuatro señales y se miden 0,0205.
- Señales sacadas de las mismas features (ridge, LightGBM, factores fijos) se correlacionan entre 0,44 y 0,68 y su media no mejora al ridge. Una señal de otra fuente (el grafo, correlación 0) sí mejora la media y, sobre todo, su estabilidad.
- Los pesos iguales ganan a los estimados (IC pasado, stacking anidado) y empatan con los pesos elegidos en retrospectiva: el puzle de la combinación de previsiones. Los pesos en retrospectiva no son un resultado, son una fuga.
- Combinar estabiliza el IC más que subirlo: la t del IC de la media de las cuatro señales es 5,5 frente a 4,4 del ridge solo, con una diferencia pareada de la media del IC que no es concluyente.
- GANs y difusión prometen series con colas, agrupamiento de volatilidad y apalancamiento. Se evalúan con hechos estilizados, memorización y un discriminador externo. Un GAN pequeño falla donde un bootstrap acierta.
- Un generador ajustado a una historia no crea información sobre el futuro: sirve para pruebas de estrés, privacidad y regularización, no como fuente de señal.
Recursos
Libros
- Bates, J. M. y Granger, C. W. J. (1969): The Combination of Forecasts. Journal of the Operational Research Society 20(4), 451-468
- Smith, J. y Wallis, K. F. (2009): A Simple Explanation of the Forecast Combination Puzzle. Oxford Bulletin of Economics and Statistics 71(3), 331-355
- Wolpert, D. H. (1992): Stacked generalization. Neural Networks 5(2), 241-259
- Wiese, M. et al. (2020): Quant GANs: deep generation of financial time series. Quantitative Finance 20(9), 1419-1440
- Takahashi, T. y Mizuno, T. (2024): Generation of synthetic financial time series by diffusion models (arXiv:2410.18897)
- Assefa, S. A. et al. (2020): Generating synthetic data in finance: opportunities, challenges and pitfalls. ICAIF 2020