---
title: "5.10.3 Ensembles, combinación de señales y datos sintéticos"
description: "Combinar señales con pesos fuera de muestra, la diversificación de sus errores y un GAN pequeño frente al bootstrap: qué prometen los datos sintéticos."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/ensembles-combinacion-de-senales-y-datos-sinteticos/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/ensembles-combinacion-de-senales-y-datos-sinteticos.png"
---

# 5.10.3 · 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.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.10 · Interpretabilidad, robustez y riesgo de modelo
- Duración: 70 min
- Requisitos: [5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia](https://pythonparatrading.com/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento/), [5.5.3 · Gradient boosting: XGBoost, LightGBM y CatBoost](https://pythonparatrading.com/curso-python-trading/nivel-5/xgboost-lightgbm-y-catboost/), [5.7.4 · Redes de grafos para relaciones entre activos](https://pythonparatrading.com/curso-python-trading/nivel-5/redes-de-grafos-para-activos/), [2.5.7 · Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping](https://pythonparatrading.com/curso-python-trading/nivel-2/datos-sinteticos-para-pruebas-de-estres/)

## Al terminar esta lección

- Generar señales fuera de muestra en walk-forward (ridge, LightGBM, una combinación fija de factores y la señal del grafo) y medir su IC y la correlación de sus errores.
- Predecir el IC de una media de señales a partir de sus IC y de sus correlaciones, y comprobarlo con los datos.
- Combinar con pesos iguales, por IC pasado y por stacking anidado, frente a unos pesos elegidos en retrospectiva, y explicar el «puzle de la combinación de previsiones».
- Entrenar un GAN pequeño sobre rendimientos y evaluarlo como se evalúa cualquier generador: hechos estilizados, memorización y un discriminador externo, frente al bootstrap de la lección 2.5.7.

## 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 $k$ señales de IC $\rho_i$ y correlaciones
$c_{ij}$ ($c_{ii} = 1$):

$$
\text{IC}\Big(\tfrac{1}{k}\textstyle\sum_i s_i\Big)
= \frac{\overline{\rho}}{\sqrt{\overline{c}}}, \qquad
\overline{\rho} = \frac{1}{k}\sum_i \rho_i, \quad
\overline{c} = \frac{1}{k^2}\sum_{i,j} c_{ij}
$$

Si todas las señales son la misma, $\overline{c} = 1$ y la media no gana nada. Si son independientes y con el mismo IC,
$\overline{c} = 1/k$ y el IC se multiplica por $\sqrt{k}$. 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 $\overline{\rho}$.

```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.

<aside class="callout" data-kind="aviso">

Datos sintéticos del nivel 5, con efectos plantados y un techo conocido: el IC del oráculo (`alfa_verdadero`) en
2015-2025 es 0,0269. Las cifras describen este panel, no los mercados.

</aside>

## 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.

```python
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()}")
```
```output
730 semanas x 400 acciones x 21 features | prueba: 574 semanas desde 2015-01-02
```

```python
senal = {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))
```
```output
semanas de entrenamiento en 2015 y en 2025: 154 676
```

```python
j = {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()})
```
```output
{'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.

```python
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))
```
```output
              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.00
```

El 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

```python
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
```
```output
                                 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.1
```

La 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 $\sqrt{0{,}5}$ 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 $w$ 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** |

```python
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)))
```
```output
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}
```

*Fig. — Pesos del stacking anidado (regresión de la etiqueta sobre las cuatro señales con las semanas pasadas), al final de cada trimestre de 2016 a 2025.* Cuatro líneas trimestrales. El ridge oscila entre 0,5 y 1,2; LightGBM (destacado) tiene peso negativo casi todo el periodo, hasta −0,52, aunque su IC fuera de muestra es positivo; los factores pasan de 0,6 a −0,28 y vuelven a 0,04; el grafo se estabiliza en torno a 0,3. Una línea discontinua marca los pesos iguales, 0,25.

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.

<aside class="callout" data-kind="riesgo">

Los pesos «en retrospectiva» no son un resultado: se eligieron con las semanas que luego se evalúan, como el
umbral elegido sobre la prueba de la lección 5.4.4. En un informe, una combinación cuyos pesos se fijaron mirando todo
el periodo vale lo mismo que un backtest sobreajustado.

</aside>

## 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):

```python
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)
```
```output
                     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.49
```

Combinar 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.

```python
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}")
```
```output
ventanas: entrenamiento (3002, 20), prueba (1489, 20), GAN (3000, 20)
pérdidas finales: discriminador 1.20, generador 0.80
```

Las 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.

```python
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)
```
```output
                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.009
```

*Fig. — Dos hechos estilizados de ventanas de 20 sesiones del activo indice: reales, generadas por un GAN pequeño, normales y por bootstrap estacionario.* Dos paneles de barras. Curtosis en exceso: real 2008-2019 3,79; real 2020-2025 1,87; GAN 0,62 (destacado); normal 0,02; bootstrap 3,65. Autocorrelación de orden 1 de los rendimientos: real 2008-2019 −0,03; real 2020-2025 0,06; GAN −0,24 (destacado); normal 0,00; bootstrap −0,02. El GAN no tiene colas y se inventa una autocorrelación negativa.

El 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.

```python
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)
```
```output
                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.457
```

La 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

1. Con dos señales incorreladas, la combinación óptima pesa cada una en proporción a su IC y alcanza un IC de
   $\sqrt{\rho_1^2 + \rho_2^2}$. 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.**

```python
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}")
```
```output
techo teórico 0.0205 | pesos por IC (conocidos al final) 0.0205 | media 0.0194
en 2016-2025: media 0.0193 | stacking 0.0193
```

Los 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.

2. 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.**

```python
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))
```
```output
{'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.773
```

Con 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.

3. 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.

## Autoevaluación

1. 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?

   - A. Porque la media de señales nunca mejora a la mejor
   - B. Porque LightGBM está mal entrenado
   - 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
   - D. Porque habría que multiplicar la media por tres

   *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.

2. 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?

   - 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
   - B. El IC baja porque la señal del grafo es más débil
   - C. No cambia nada: solo importa la mejor señal
   - D. Sube el IC pero baja su t

   *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.

3. 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?

   - A. Un error en el stacking
   - 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
   - C. Una prueba de que el stacking siempre es peor
   - D. Que la media simple usa información futura

   *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.

4. 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?

   - A. Que el GAN es mejor porque genera datos nuevos
   - B. Que el AUC no es una buena medida
   - C. Que hay que usar el GAN para entrenar los modelos
   - 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

   *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.

5. 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?

   - A. Que la difusión es más lenta que un GAN
   - 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
   - C. Que los datos sintéticos son siempre ilegales
   - D. Que hace falta GPU

   *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](https://doi.org/10.1057/jors.1969.103) · doi.org
- [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](https://doi.org/10.1111/j.1468-0084.2008.00541.x) · doi.org
- [Wolpert, D. H. (1992): Stacked generalization. Neural Networks 5(2), 241-259](https://doi.org/10.1016/S0893-6080(05)80023-1) · doi.org
- [Wiese, M. et al. (2020): Quant GANs: deep generation of financial time series. Quantitative Finance 20(9), 1419-1440](https://doi.org/10.1080/14697688.2020.1730426) · doi.org
- [Takahashi, T. y Mizuno, T. (2024): Generation of synthetic financial time series by diffusion models (arXiv:2410.18897)](https://arxiv.org/abs/2410.18897) · arxiv.org
- [Assefa, S. A. et al. (2020): Generating synthetic data in finance: opportunities, challenges and pitfalls. ICAIF 2020](https://doi.org/10.1145/3383455.3422554) · doi.org

### Datos

- [Datos del nivel 5: features semanales del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_features.parquet) · Descarga · panel5_features.parquet

Lección anterior: [5.10.2 · Deriva de datos y de concepto: detectarla y qué hacer](https://pythonparatrading.com/curso-python-trading/nivel-5/deriva-de-datos-y-de-concepto/)
