Lección 5.3.4 · 70 min

Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial

Dos etiquetas de la triple barrera que viven a la vez comparten minutos de precio: no son dos observaciones independientes. Esta lección mide cuánto se solapan las 6.533 etiquetas de FNX, calcula su unicidad media y los pesos por atribución del rendimiento y por antigüedad, y compara el bootstrap estándar con el secuencial en un bagging de árboles. La conclusión es práctica: el solapamiento infla el error fuera de la bolsa, y lo que más ayuda aquí es reducir cada muestra a la unicidad media.

Fig. — Etiquetas de la triple barrera de una sesión de FNX y cuántas están vivas cada minuto. Cuanto más se solapa una etiqueta con otras, menos información propia aporta: su unicidad media baja
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

Muchas filas, menos observaciones

La estadística clásica supone observaciones independientes: mil filas son mil datos. Con etiquetas de la triple barrera eso no se cumple. Una etiqueta que empieza a las 11:52 y termina a las 12:16 usa los precios de esos 24 minutos; si otra empieza a las 12:02 y termina a las 12:10, las dos comparten 8 minutos de precio y sus resultados están correlacionados. Un modelo que entrena con las dos no ha visto dos ejemplos: ha visto uno y medio.

La figura de la cabecera muestra el problema en una sesión de FNX (datos sintéticos del nivel 5). Cada barra es una etiqueta, desde el evento hasta el toque de su primera barrera; abajo, cuántas están vivas cada minuto. El capítulo 4 de López de Prado (2018) propone tres respuestas, que esta lección aplica una a una:

  • Medir el solapamiento: la concurrencia de cada minuto y la unicidad media de cada etiqueta.
  • Pesar cada etiqueta según su información propia: por unicidad, por el rendimiento que le corresponde y por su antigüedad.
  • Muestrear teniéndolo en cuenta: el bootstrap secuencial para los métodos de bagging.

Concurrencia y unicidad

Los eventos son los del filtro CUSUM del nivel (lección 5.3.5) y las etiquetas, las de la triple barrera con ±σ30\pm\sigma\sqrt{30} y vertical a 30 minutos (5.3.2). Lo único nuevo es que ahora importan el inicio y el fin de cada una. Las 17 etiquetas de eventos en el último minuto de la sesión no tienen vida (su vertical es su propia entrada) y quedan fuera.

IN [01]python
import numpy as np
import pandas as pd

barras = pd.read_parquet("hf5_barras_1min.parquet")
n_ses = len(barras) // 390
C = barras["cierre"].to_numpy().reshape(n_ses, 390)
V = barras["volumen"].to_numpy().reshape(n_ses, 390)
VC = barras["volumen_comprador"].to_numpy().reshape(n_ses, 390)
lr = np.diff(np.log(C), axis=1)
sig = (pd.Series(lr.ravel()).ewm(span=100).std()
       .to_numpy().reshape(n_ses, 389))


def eventos_cusum(lr, sig, k=4.0):
    """Filtro CUSUM simétrico por sesión (lección 5.3.5)."""
    filas = []
    for d in range(lr.shape[0]):
        s_pos = s_neg = 0.0
        for m in range(lr.shape[1]):
            h = k * sig[d, m]
            if not np.isfinite(h):
                continue
            s_pos = max(0.0, s_pos + lr[d, m])
            s_neg = min(0.0, s_neg + lr[d, m])
            if s_pos > h or s_neg < -h:
                filas.append((d, m + 1, 1 if s_pos > h else -1,
                              sig[d, m]))
                s_pos = s_neg = 0.0
    return pd.DataFrame(
        filas, columns=["sesion", "minuto", "lado", "sigma"])


def triple_barrera(C, ev, k=1.0, h=30):
    """Barreras ±k·σ·√h y vertical a h minutos (lección 5.3.2)."""
    fin, ret, toque = [], [], []
    for d, m, s in ev[["sesion", "minuto", "sigma"]].itertuples(
            index=False):
        ult = min(m + h, C.shape[1] - 1)
        tramo = np.log(C[d, m + 1:ult + 1] / C[d, m])
        b = k * s * np.sqrt(h)
        golpes = np.flatnonzero(np.abs(tramo) >= b)
        j = golpes[0] if len(golpes) else len(tramo) - 1
        fin.append(m + 1 + j)
        ret.append(float(tramo[j]) if len(tramo) else 0.0)
        toque.append(int(np.sign(tramo[j])) if len(golpes) else 0)
    return ev.assign(fin=fin, ret=ret, toque=toque)


ev = triple_barrera(C, eventos_cusum(lr, sig))
ev = ev[ev.fin > ev.minuto].reset_index(drop=True)  # 17 sin vida
ev["y"] = (ev.lado * ev.ret > 0).astype(int)
print(len(ev), "etiquetas; vida media:",
      round((ev.fin - ev.minuto).mean(), 1), "minutos")
OUT [01]Eventos con su inicio y su fin
6533 etiquetas; vida media: 22.6 minutos

La vida de la etiqueta ii son los minutos (ti,0,ti,1](t_{i,0}, t_{i,1}]: desde el siguiente al evento hasta el del toque. Con 1t,i=11_{t,i} = 1 si la etiqueta ii está viva en el minuto tt, la concurrencia del minuto es ct=∑i1t,ic_t = \sum_i 1_{t,i}. Calcularla no exige construir la matriz 1t,i1_{t,i} (96.330 minutos por 6.533 etiquetas): basta sumar +1+1 en el minuto en que nace cada etiqueta y −1-1 en el siguiente al que muere, y acumular.

IN [02]python
G = n_ses * 390                          # minutos de todo el año
ini = ev.sesion.to_numpy() * 390 + ev.minuto.to_numpy() + 1
fin = ev.sesion.to_numpy() * 390 + ev.fin.to_numpy() + 1  # exclusivo
dif = np.zeros(G + 1)
np.add.at(dif, ini, 1)
np.add.at(dif, fin, -1)
conc = np.cumsum(dif)[:G]                # etiquetas vivas por minuto
print(f"minutos con alguna etiqueta viva: {(conc > 0).mean():.1%}")
print(f"concurrencia media en ellos: {conc[conc > 0].mean():.2f}")
pd.Series(conc).value_counts(normalize=True).sort_index().round(3)
OUT [02]Concurrencia minuto a minuto
minutos con alguna etiqueta viva: 94.9%
concurrencia media en ellos: 1.61
0.0    0.051
1.0    0.443
2.0    0.436
3.0    0.067
4.0    0.003
5.0    0.000
Name: proportion, dtype: float64

np.add.at suma también cuando un índice se repite (dos etiquetas que nacen en el mismo minuto), cosa que dif[ini] += 1 no hace. El 94,9 % de los minutos del año tiene al menos una etiqueta viva, y en ellos hay 1,61 de media: el 43,6 % de los minutos tiene dos y el 6,7 %, tres.

La unicidad de la etiqueta ii en el minuto tt es 1t,i/ct1_{t,i}/c_t: la fracción del minuto que le corresponde. Su unicidad media es la media en su vida:

u‾i=∑t1t,i/ct∑t1t,i.\overline{u}_i = \frac{\sum_t 1_{t,i}/c_t}{\sum_t 1_{t,i}}.

Otra vez, con una suma acumulada de 1/ct1/c_t se obtiene la de todas las etiquetas con dos restas:

IN [03]python
inv = np.where(conc > 0, 1 / np.maximum(conc, 1), 0.0)
acum = np.r_[0.0, np.cumsum(inv)]
ev["unicidad"] = (acum[fin] - acum[ini]) / (fin - ini)
u = ev["unicidad"].to_numpy()
print(f"unicidad media {u.mean():.3f}, mediana {np.median(u):.3f}")
print(f"etiquetas «efectivas»: {u.sum():.0f} de {len(u)}")
tramos = pd.cut(ev.unicidad, [0, 0.25, 0.5, 0.75, 0.999, 1.0])
ev.groupby(tramos, observed=True).size().rename("etiquetas")
OUT [03]Unicidad media de cada etiqueta
unicidad media 0.617, mediana 0.583
etiquetas «efectivas»: 4029 de 6533
unicidad
(0.0, 0.25]         6
(0.25, 0.5]      2288
(0.5, 0.75]      2767
(0.75, 0.999]    1314
(0.999, 1.0]      158
Name: etiquetas, dtype: int64

La unicidad media es 0,617 y la mediana 0,583, las cifras del README. Sumadas, las 6.533 etiquetas aportan la información de unas 4.029 independientes. Solo 158 están solas toda su vida; la mayoría (5.055) tiene una unicidad entre 0,25 y 0,75.

La sesión de la figura de la cabecera, en tabla:

IN [04]python
s133 = ev[(ev.sesion == 133) & (ev.minuto.between(130, 200))]
s133[["minuto", "fin", "lado", "toque", "unicidad"]].round(2)
OUT [04]Una sesión de cerca
      minuto  fin  lado  toque  unicidad
3524     130  142     1      1      0.75
3525     142  166     1      1      0.69
3526     152  160    -1      1      0.46
3527     158  168     1      1      0.47
3528     166  196     1      0      0.92
3529     193  223     1      0      0.65

La etiqueta del evento de las 11:52 (minuto 142 de la sesión, la de la figura de la lección 5.3.2) tiene una unicidad de 0,69: comparte parte de su vida con las de los minutos 152 y 158. La del minuto 166 vive 30 minutos casi sola (0,92). En la figura aparece destacada la menos única de la ventana, la del minuto 223 (0,42), que nunca está sola: la mitad de su vida comparte con una etiqueta y la otra mitad con dos.

Pesos por atribución del rendimiento

La unicidad dice cuánta información propia tiene una etiqueta, pero no cuánta información tiene esa información. Una etiqueta vertical que termina en +0,2 pb tiene un signo casi aleatorio; una que toca la barrera con +25 pb describe un movimiento claro. Los pesos por atribución del rendimiento reparten el rendimiento de cada minuto entre las etiquetas vivas y pesan cada una por el valor absoluto de lo que le toca:

w~i=∣∑t1t,i rtct∣,wi=w~i N∑jw~j,\tilde w_i = \left|\sum_t \frac{1_{t,i}\, r_t}{c_t}\right|, \qquad w_i = \tilde w_i\,\frac{N}{\sum_j \tilde w_j},

con rtr_t el log-rendimiento del minuto. La normalización hace que los pesos sumen NN, como si no hubiera pesos.

IN [05]python
r = np.c_[np.zeros(n_ses), lr].ravel()   # r del minuto, 0 al abrir
ra = np.r_[0.0, np.cumsum(r * inv)]
w_ret = np.abs(ra[fin] - ra[ini])
w_ret = w_ret * len(w_ret) / w_ret.sum()   # suman N
ev["w_ret"] = w_ret
print("correlación con la unicidad:",
      round(np.corrcoef(w_ret, u)[0, 1], 3))
ev.groupby("toque")[["unicidad", "w_ret"]].mean().round(3)
OUT [05]Pesos por atribución del rendimiento
correlación con la unicidad: 0.336
       unicidad  w_ret
toque
-1        0.607  1.406
 0        0.623  0.648
 1        0.611  1.403

Las etiquetas que tocan una barrera horizontal pesan 1,40 de media y las verticales 0,65: el rendimiento de las primeras es, por construcción, de al menos σ30\sigma\sqrt{30}. La unicidad media de los dos grupos es casi la misma. La correlación de 0,336 entre peso y unicidad viene de la división por ctc_t: a igual movimiento, la etiqueta solapada se queda una fracción menor.

Estos pesos se pasan al modelo como sample_weight (casi todos los estimadores de scikit-learn, LightGBM y XGBoost lo aceptan). Son una corrección de lo que se aprende, no de la validación.

Decaimiento temporal

Los mercados cambian (lección 5.10.2) y una observación de hace un año puede enseñar una relación que ya no existe. El decaimiento temporal de López de Prado baja el peso de lo antiguo de forma lineal, pero sobre la unicidad acumulada y no sobre el número de filas, para que un periodo con muchas etiquetas solapadas no cuente como mucho tiempo:

d(x)=max⁡(0,  a+b x),xi=∑j≤iu‾j,d(xN)=1.d(x) = \max(0,\; a + b\,x), \qquad x_i = \sum_{j \le i} \overline{u}_j, \qquad d(x_N) = 1.

Un parámetro cc decide la forma: con c=1c = 1 no hay decaimiento; con c∈[0,1)c \in [0, 1) la etiqueta más antigua pesa cc; con c<0c < 0 la fracción ∣c∣|c| más antigua pesa cero.

IN [06]python
def decaimiento(u, c=1.0):
    """Peso por antigüedad, lineal en la unicidad acumulada; la
    etiqueta más reciente pesa 1. c = 1: sin decaimiento; c en
    [0, 1): la más antigua pesa c; c < 0: la fracción |c| más
    antigua pesa 0."""
    x = np.cumsum(u)
    b = (1 - c) / x[-1] if c >= 0 else 1 / ((c + 1) * x[-1])
    return np.clip(1 - b * (x[-1] - x), 0, None)


filas = {}
for c in (1.0, 0.5, 0.0, -0.5):
    dk = decaimiento(u, c)
    filas[c] = {"primera": dk[0], "cuartil_1": dk[len(dk) // 4],
                "mitad": dk[len(dk) // 2], "ultima": dk[-1],
                "a_cero": (dk == 0).mean()}
pd.DataFrame(filas).T.rename_axis("c").round(3)
OUT [06]Decaimiento temporal
      primera  cuartil_1  mitad  ultima  a_cero
c
 1.0      1.0      1.000  1.000     1.0   0.000
 0.5      0.5      0.626  0.750     1.0   0.000
 0.0      0.0      0.252  0.501     1.0   0.000
-0.5      0.0      0.000  0.001     1.0   0.499

Con c=0c = 0, la etiqueta del primer cuartil pesa 0,25 y la de la mitad, 0,50. Con c=−0,5c = -0{,}5, la mitad más antigua de la muestra no cuenta. En un año de FNX el decaimiento no tiene mucho sentido, porque el modelo de los datos no cambia; en una muestra de diez años o en datos con deriva (5.10.2) es un parámetro más que se valida, nunca uno que se elige mirando el resultado en prueba.

El bootstrap secuencial

El bagging (lección 5.5.2) entrena muchos modelos sobre muestras con reemplazamiento y promedia. Funciona porque los modelos son distintos entre sí. Con etiquetas solapadas, una muestra bootstrap estándar está llena de etiquetas casi repetidas: dos que comparten 20 minutos de precio son para un árbol casi la misma fila. El bootstrap secuencial extrae las etiquetas una a una y, en cada paso, da a cada candidata una probabilidad proporcional a la unicidad media que tendría junto a las ya extraídas:

P(extraer j)=u‾j(k)∑mu‾m(k),u‾j(k)=1∑t1t,j∑t1t,j1+ct(k),P(\text{extraer } j) = \frac{\overline{u}_j^{(k)}}{\sum_m \overline{u}_m^{(k)}}, \qquad \overline{u}_j^{(k)} = \frac{1}{\sum_t 1_{t,j}}\sum_t \frac{1_{t,j}}{1 + c^{(k)}_t},

con ct(k)c^{(k)}_t las extracciones previas vivas en el minuto tt.

nosíMuestra vacíaPeso por unicidadExtraer al azarSumar su vida a c¿N extraídas?Muestra
Fuente Mermaid
flowchart TB
  I[Muestra vacía] --> P[Peso por unicidad]
  P --> E[Extraer al azar]
  E --> A[Sumar su vida a c]
  A --> Q{¿N extraídas?}
  Q -- no --> P
  Q -- sí --> F[Muestra]

La implementación literal, con la matriz indicadora completa, es lenta: en cada paso recalcula la unicidad de todas las candidatas. Como las etiquetas de FNX no cruzan el cierre de la sesión, la matriz se parte en un bloque por sesión, y una extracción solo cambia la unicidad de las etiquetas de su bloque. El sorteo se hace en dos pasos: primero la sesión, con probabilidad proporcional a la suma de las unicidades de sus etiquetas, y después la etiqueta dentro de ella. Es exactamente la misma distribución.

IN [07]python
def bloques_por_sesion(ses, a, b):
    """Para cada sesión, sus etiquetas y su matriz indicadora
    (etiquetas x minutos de la sesión) con la vida (a, b]."""
    out = []
    for s in np.unique(ses):
        idx = np.flatnonzero(ses == s)
        ind = np.zeros((len(idx), 390))
        for k, i in enumerate(idx):
            ind[k, a[i] + 1:b[i] + 1] = 1.0
        out.append((idx, ind))
    return out


def bootstrap_secuencial(bloques, n, rng):
    """Extrae n etiquetas; cada una con probabilidad proporcional a
    la unicidad media que tendría junto a las ya extraídas."""
    L = [ind.sum(axis=1) for _, ind in bloques]
    S = [x.copy() for x in L]          # suma de 1/(c+1) en su vida
    c = [np.zeros(390) for _ in bloques]
    u = [np.ones(len(i)) for i, _ in bloques]
    tot = np.array([x.sum() for x in u])
    out = np.empty(n, dtype=int)
    for k in range(n):
        acu = np.cumsum(tot)
        s = int(np.searchsorted(acu, rng.random() * acu[-1], "right"))
        acu = np.cumsum(u[s])
        j = int(np.searchsorted(acu, rng.random() * acu[-1], "right"))
        idx, ind = bloques[s]
        out[k] = idx[j]
        vida = np.flatnonzero(ind[j])
        antes = 1 / (c[s][vida] + 1)
        c[s][vida] += 1
        S[s] += ind[:, vida] @ (1 / (c[s][vida] + 1) - antes)
        u[s] = S[s] / L[s]
        tot[s] = u[s].sum()
    return out


def unicidad_muestra(bloques, muestra):
    """Unicidad media de las etiquetas de una muestra (con
    repeticiones), medida solo entre ellas."""
    filas = np.zeros(sum(len(i) for i, _ in bloques), dtype=int)
    for s, (idx, _) in enumerate(bloques):
        filas[idx] = s
    total = []
    for s, (idx, ind) in enumerate(bloques):
        sel = np.searchsorted(idx, muestra[filas[muestra] == s])
        if len(sel) == 0:
            continue
        m = ind[sel]
        cc = m.sum(axis=0)
        total.append((m / np.where(cc > 0, cc, 1)).sum(axis=1)
                     / m.sum(axis=1))
    return np.concatenate(total).mean()


bl = bloques_por_sesion(ev.sesion.to_numpy(), ev.minuto.to_numpy(),
                        ev.fin.to_numpy())
rng = np.random.default_rng(0)
n = len(ev)
res = {"estándar": [], "secuencial": []}
for _ in range(5):
    est = rng.integers(0, n, n)
    sec = bootstrap_secuencial(bl, n, rng)
    for nombre, mu in (("estándar", est), ("secuencial", sec)):
        res[nombre].append((unicidad_muestra(bl, mu),
                            len(np.unique(mu)) / n))
pd.DataFrame({k: np.mean(v, axis=0) for k, v in res.items()},
             index=["unicidad media", "etiquetas distintas"]).round(3)
OUT [07]Bootstrap secuencial
                     estándar  secuencial
unicidad media          0.472       0.528
etiquetas distintas     0.631       0.708
Fig. — Muestras de 6.533 extracciones sobre las etiquetas de FNX (media de cinco): el bootstrap secuencial elige etiquetas que se solapan menos entre sí y repite menos

Una muestra bootstrap estándar de 6.533 extracciones tiene un 63,1 % de etiquetas distintas (el 1−1/e1 - 1/e de siempre) y una unicidad media, medida entre las etiquetas de la propia muestra, de 0,472: las repeticiones y los solapamientos se suman. La secuencial sube la unicidad a 0,528 y las etiquetas distintas al 70,8 %. Es la mejora que promete el método; la pregunta es si se nota en un modelo.

Cuando el solapamiento es grande: un bagging

Con 1,6 etiquetas vivas por minuto el efecto es pequeño. Para verlo con claridad hace falta más solapamiento, el que tendría quien etiquetara a intervalos fijos en vez de por eventos: una etiqueta cada 3 minutos, con el lado del rendimiento de los 30 minutos anteriores (un momentum continuo) y la misma triple barrera. La meta-etiqueta es si ese lado acierta; las features, las del imbalance (5.3.3) y dos rendimientos recientes tipificados.

IN [08]python
PASO = 3
lc = np.log(C)
filas = [(d, m, 1 if lc[d, m] > lc[d, m - 30] else -1, sig[d, m - 1])
         for d in range(n_ses) for m in range(30, 360, PASO)]
dn = triple_barrera(C, pd.DataFrame(
    filas, columns=["sesion", "minuto", "lado", "sigma"]))
dn["y"] = (dn.lado * dn.ret > 0).astype(int)
ds, dm = dn.sesion.to_numpy(), dn.minuto.to_numpy()
cv = np.cumsum(V, axis=1)
ci = np.cumsum(2 * VC - V, axis=1)
imb = (ci[ds, dm] - ci[ds, dm - 30]) / (cv[ds, dm] - cv[ds, dm - 30])
Xd = np.column_stack([
    dn.lado * imb, np.abs(imb),
    dn.lado * (lc[ds, dm] - lc[ds, dm - 30]) / dn.sigma,
    dn.lado * (lc[ds, dm] - lc[ds, dm - 10]) / dn.sigma, dm])
yd = dn.y.to_numpy()
a_n, b_n = ds * 390 + dm + 1, ds * 390 + dn.fin.to_numpy() + 1
dif = np.zeros(G + 1)
np.add.at(dif, a_n, 1)
np.add.at(dif, b_n, -1)
conc_n = np.cumsum(dif)[:G]
acu_n = np.r_[0.0, np.cumsum(1 / np.maximum(conc_n, 1))]
u_n = (acu_n[b_n] - acu_n[a_n]) / (b_n - a_n)
print(len(dn), "etiquetas; concurrencia media:",
      round(conc_n[conc_n > 0].mean(), 1), "; unicidad media:",
      round(u_n.mean(), 3))
OUT [08]Etiquetas cada 3 minutos: mucho más solapamiento
27170 etiquetas; concurrencia media: 7.6 ; unicidad media: 0.136

Con 27.170 etiquetas la concurrencia sube a 7,6 y la unicidad media baja a 0,136: cada etiqueta comparte casi todos sus minutos con otras seis o siete.

El experimento entrena 40 árboles de decisión en la primera mitad de las sesiones con tres formas de muestrear y mide dos AUC: el fuera de la bolsa (OOB), que evalúa cada etiqueta con los árboles que no la vieron, y el de la segunda mitad, sesiones que ningún árbol ha visto. La tercera forma, «reducido», es la alternativa barata que propone el propio López de Prado: muestras con reemplazamiento de solo u‾⋅N\overline{u} \cdot N etiquetas.

IN [09]python
from sklearn.metrics import roc_auc_score
from sklearn.tree import DecisionTreeClassifier

ent = np.flatnonzero(ds < n_ses // 2)
pru = np.flatnonzero(ds >= n_ses // 2)
bl_n = bloques_por_sesion(ds[ent], dm[ent], dn.fin.to_numpy()[ent])
u_ent = u_n[ent].mean()


def bagging(muestreo, n_arboles=40, semilla=0):
    """Árboles sobre muestras de entrenamiento; AUC fuera de la bolsa
    (OOB) y en la segunda mitad."""
    rng = np.random.default_rng(semilla)
    n = len(ent)
    p_pru = np.zeros(len(pru))
    s_oob, n_oob = np.zeros(n), np.zeros(n)
    for _ in range(n_arboles):
        if muestreo == "estándar":
            mu = rng.integers(0, n, n)
        elif muestreo == "reducido":
            mu = rng.integers(0, n, int(round(u_ent * n)))
        else:
            mu = bootstrap_secuencial(bl_n, n, rng)
        arbol = DecisionTreeClassifier(
            min_samples_leaf=5, max_features=2,
            random_state=int(rng.integers(2**31)))
        arbol.fit(Xd[ent][mu], yd[ent][mu])
        p_pru += arbol.predict_proba(Xd[pru])[:, 1]
        fuera = np.setdiff1d(np.arange(n), mu)
        s_oob[fuera] += arbol.predict_proba(Xd[ent][fuera])[:, 1]
        n_oob[fuera] += 1
    v = n_oob > 0
    return {"AUC_OOB": roc_auc_score(yd[ent][v], s_oob[v] / n_oob[v]),
            "AUC_prueba": roc_auc_score(yd[pru], p_pru / n_arboles)}


print(f"unicidad media en entrenamiento: {u_ent:.3f}")
tabla = pd.DataFrame({m: bagging(m) for m in
                      ("estándar", "reducido", "secuencial")}).T
tabla["diferencia"] = tabla.AUC_OOB - tabla.AUC_prueba
tabla.round(3)
OUT [09]Bagging: bootstrap estándar, reducido y secuencial
unicidad media en entrenamiento: 0.137
            AUC_OOB  AUC_prueba  diferencia
estándar      0.572       0.529       0.043
reducido      0.570       0.544       0.026
secuencial    0.566       0.529       0.037
Fig. — Con etiquetas solapadas el AUC fuera de la bolsa (OOB) promete más que la prueba en sesiones nuevas. Reducir cada muestra a la unicidad media acerca los dos y mejora la prueba; el bootstrap secuencial, aquí, apenas cambia nada

Tres resultados, en orden de importancia:

  1. El AUC fuera de la bolsa es optimista con cualquier muestreo. Con el bootstrap estándar promete 0,572 y en sesiones nuevas da 0,529. La razón es la del k-fold aleatorio (lección 5.4.1): cada etiqueta fuera de la bolsa tiene vecinas solapadas dentro, con casi el mismo camino de precio y casi la misma etiqueta, y el árbol las ha memorizado. El OOB de un random forest con etiquetas financieras no es una validación.
  2. Reducir la muestra a u‾⋅N\overline{u} \cdot N es lo que más ayuda. Cada árbol ve unas 1.850 etiquetas en lugar de 13.500: los árboles son más distintos entre sí, memorizan menos vecinos y el promedio generaliza mejor. El AUC en prueba sube a 0,544 y la diferencia con el OOB baja de 0,043 a 0,026.
  3. El bootstrap secuencial, aquí, no cambia nada (0,529 en prueba). Sube la unicidad de cada muestra, pero sigue extrayendo NN etiquetas, y con una concurrencia de 7,6 casi todas siguen solapadas con otras. El método brilla cuando la concurrencia es moderada y desigual; con mucho solapamiento uniforme, la palanca es el tamaño de la muestra.

Las diferencias entre AUC de 0,529 y 0,544 parecen pequeñas, pero con 13.640 etiquetas de prueba son estables: las semillas cambian la tercera decimal. Y siguen siendo AUC modestos, porque la señal de FNX es la de un mercado: la mejora es de las que importan cuando se opera muchas veces, no de las que se ven en una operación.

Qué usar y cuándo

HerramientaCorrigeCuándo merece la pena
Muestreo por eventos (5.3.5)Etiquetas casi idénticas desde el origenSiempre que se pueda: es la primera defensa
Unicidad media u‾\overline{u}Cuenta la información real de la muestraSiempre como diagnóstico; como max_samples en bagging
Pesos por atribuciónEtiquetas planas y solapadas pesan menosCon barreras anchas y muchas verticales
Decaimiento temporalLo antiguo pesa menosMuestras largas con deriva; parámetro a validar
Bootstrap secuencialMuestras con menos solapamientoConcurrencia moderada y desigual; caro de calcular
Purga y embargo (5.4.2)Fuga entre entrenamiento y validaciónSiempre que se valide: ninguna de las anteriores la sustituye

Ejercicios

  1. Calcula la unicidad media de las etiquetas con vertical a 60 minutos en lugar de 30. ¿Cuánto baja y cuántas etiquetas «efectivas» quedan?
Solución
IN [10]python
e60 = triple_barrera(C, eventos_cusum(lr, sig), h=60)
e60 = e60[e60.fin > e60.minuto]
a60 = e60.sesion.to_numpy() * 390 + e60.minuto.to_numpy() + 1
b60 = e60.sesion.to_numpy() * 390 + e60.fin.to_numpy() + 1
d60 = np.zeros(G + 1)
np.add.at(d60, a60, 1)
np.add.at(d60, b60, -1)
c60 = np.cumsum(d60)[:G]
acu60 = np.r_[0.0, np.cumsum(1 / np.maximum(c60, 1))]
u60 = (acu60[b60] - acu60[a60]) / (b60 - a60)
print(f"unicidad media {u60.mean():.3f}; efectivas {u60.sum():.0f}"
      f" de {len(u60)}; vida media {(e60.fin - e60.minuto).mean():.1f}")
OUT [10]Unicidad con vertical a 60 minutos
unicidad media 0.347; efectivas 2270 de 6533; vida media 42.6

Con la vertical a 60 minutos las etiquetas viven 42,6 minutos de media en lugar de 22,6 y la unicidad media cae de 0,617 a 0,347: las mismas 6.533 etiquetas valen como unas 2.270 independientes, poco más de la mitad que con 30 minutos. Un horizonte más largo puede dar más acierto (ejercicio 1 de la lección 5.3.2), pero lo paga en información efectiva: el error típico de cualquier medida sobre estas etiquetas crece en proporción a 1/Nefectivo1/\sqrt{N_{\text{efectivo}}}.

  1. Repite la comparación entre el bootstrap estándar y la muestra reducida con RandomForestClassifier de scikit-learn y 200 árboles. ¿Se mantiene el resultado?
Solución
IN [11]python
from sklearn.ensemble import RandomForestClassifier

for ms in (None, u_ent):
    rf = RandomForestClassifier(
        n_estimators=200, min_samples_leaf=5, max_features=2,
        max_samples=ms, oob_score=True, random_state=0, n_jobs=4)
    rf.fit(Xd[ent], yd[ent])
    oob = roc_auc_score(yd[ent], rf.oob_decision_function_[:, 1])
    prueba = roc_auc_score(yd[pru], rf.predict_proba(Xd[pru])[:, 1])
    nombre = "estándar" if ms is None else f"max_samples={ms:.3f}"
    print(f"{nombre:>20}: OOB {oob:.3f}  prueba {prueba:.3f}")
OUT [11]Random forest con 200 árboles y max_samples
            estándar: OOB 0.576  prueba 0.536
   max_samples=0.137: OOB 0.582  prueba 0.553

Con 200 árboles el resultado se mantiene: el bootstrap estándar promete 0,576 fuera de la bolsa y da 0,536 en la segunda mitad; con max_samples igual a la unicidad media, 0,582 y 0,553. La muestra reducida mejora la prueba en casi dos centésimas, pero su OOB sigue siendo optimista en 0,029: más árboles no arreglan la fuga del OOB, solo reducen su ruido.

  1. Un compañero entrena un modelo con las etiquetas cada 3 minutos, elige la profundidad de los árboles con el oob_score_ del random forest y obtiene un AUC de 0,60. ¿Qué le dirías?
Solución

Que el 0,60 no es una estimación de lo que el modelo hará en datos nuevos. Con una unicidad media de 0,136, cada etiqueta fuera de la bolsa tiene seis o siete vecinas solapadas dentro: el OOB mide en parte memoria, y en esta lección promete entre 0,02 y 0,04 de AUC más que la prueba en sesiones nuevas. Además, elegir la profundidad con ese OOB premia a los árboles que mejor memorizan (los más profundos), así que el sesgo crece con la búsqueda. Le pediría tres cosas: reducir la muestra de cada árbol a la unicidad media (o muestrear por eventos), elegir hiperparámetros con una validación purgada con embargo (lección 5.4.2) y dar el resultado final en un periodo que no se haya usado para nada, una sola vez.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Una etiqueta vive 20 minutos: en 10 está sola y en los otros 10 comparte con otra etiqueta. ¿Cuál es su unicidad media?

    • A0,75: la media de 1/c en sus minutos, (10·1 + 10·0,5)/20
    • B0,5
    • C1, porque es una sola etiqueta
    • D0,25
    Ver respuesta

    Respuesta A. 0,75: la media de 1/c en sus minutos, (10·1 + 10·0,5)/20

    La unicidad de un minuto es 1/c, con c las etiquetas vivas en él; la de la etiqueta es la media en su vida. En FNX la media es 0,617: las 6.533 etiquetas valen como unas 4.029 independientes.

  2. ¿Por qué los pesos por atribución del rendimiento dan a las etiquetas que tocan una barrera horizontal más del doble de peso que a las verticales (1,40 frente a 0,65)?

    • APor un error de cálculo
    • BPorque las verticales son menos únicas
    • CPorque el peso es el valor absoluto del rendimiento de su vida repartido entre las etiquetas concurrentes, y una etiqueta que toca una barrera ha recorrido al menos σ·√30
    • DPorque las barreras horizontales son más frecuentes
    Ver respuesta

    Respuesta C. Porque el peso es el valor absoluto del rendimiento de su vida repartido entre las etiquetas concurrentes, y una etiqueta que toca una barrera ha recorrido al menos σ·√30

    La idea es pesar más las observaciones con movimientos grandes y propios y menos las que acaban casi planas, cuyo signo es casi ruido. La unicidad de los dos grupos es casi la misma (0,61 frente a 0,62): la diferencia la pone el rendimiento.

  3. En el bootstrap secuencial, ¿cómo cambia la probabilidad de extraer una etiqueta después de cada extracción?

    • ANo cambia: todas tienen la misma
    • BSube para las etiquetas más rentables
    • CSe pone a cero para las ya extraídas
    • DBaja para las etiquetas que se solapan con las ya extraídas, en proporción a la unicidad media que tendrían junto a ellas
    Ver respuesta

    Respuesta D. Baja para las etiquetas que se solapan con las ya extraídas, en proporción a la unicidad media que tendrían junto a ellas

    Cada etiqueta se extrae con probabilidad proporcional a su unicidad media respecto a la muestra ya formada. Las solapadas pierden probabilidad sin llegar a cero. En FNX la unicidad de la muestra sube de 0,472 a 0,528 y las etiquetas distintas del 63,1 % al 70,8 %.

  4. Con etiquetas cada 3 minutos (unicidad media 0,136), el bagging con bootstrap estándar da un AUC fuera de la bolsa de 0,572 y de 0,529 en la segunda mitad. ¿Qué explica la diferencia?

    • AQue la segunda mitad es más difícil por azar
    • BQue cada etiqueta fuera de la bolsa tiene vecinas solapadas dentro de ella: el árbol ha visto casi el mismo camino de precio y la misma etiqueta
    • CQue 40 árboles son pocos
    • DQue el AUC no sirve para clasificadores binarios
    Ver respuesta

    Respuesta B. Que cada etiqueta fuera de la bolsa tiene vecinas solapadas dentro de ella: el árbol ha visto casi el mismo camino de precio y la misma etiqueta

    Es la misma fuga que la del k-fold aleatorio (lección 5.4.1). Reducir cada muestra a la unicidad media por N baja la diferencia de 0,043 a 0,026 y sube el AUC en prueba a 0,544, pero no la elimina: para validar hace falta purga y embargo (5.4.2).

  5. Con c = −0,5 en el decaimiento temporal, ¿qué pasa con las etiquetas más antiguas?

    • APesan la mitad que las recientes
    • BPesan 0,5 la primera y 1 la última
    • CLa mitad más antigua (medida en unicidad acumulada) pesa cero y el resto crece linealmente hasta 1
    • DSe duplican
    Ver respuesta

    Respuesta C. La mitad más antigua (medida en unicidad acumulada) pesa cero y el resto crece linealmente hasta 1

    Con c < 0, la fracción |c| más antigua de la unicidad acumulada recibe peso 0. Con c en [0, 1), la primera pesa c y el peso sube linealmente hasta la última, que pesa 1. Con c = 1 no hay decaimiento.

Ideas clave

  • Las etiquetas de la triple barrera se solapan: en FNX hay 1,61 etiquetas vivas de media en los minutos con alguna, y una unicidad media de 0,617. Las 6.533 etiquetas valen como unas 4.029 independientes.
  • La unicidad media de una etiqueta es la media de 1/c en los minutos de su vida; con sumas acumuladas se calcula para todas sin construir la matriz indicadora completa.
  • Los pesos por atribución del rendimiento reparten el rendimiento de cada minuto entre las etiquetas vivas: pesan más los movimientos grandes y propios (1,40 los toques horizontales, 0,65 las verticales).
  • El decaimiento temporal resta peso a lo antiguo sobre la unicidad acumulada, no sobre el número de filas: un parámetro c, de sin decaimiento a descartar la parte más antigua.
  • El bootstrap secuencial sube la unicidad de la muestra (de 0,472 a 0,528), pero en un bagging con etiquetas cada 3 minutos no mejora la prueba; reducir cada muestra a la unicidad media por N, sí (AUC de 0,529 a 0,544).
  • Con etiquetas solapadas el error fuera de la bolsa es optimista con cualquier muestreo: la validación honesta es la purgada (5.4.2).

Recursos

Datos