---
title: "5.3.4 Unicidad, pesos y bootstrap secuencial"
description: "Etiquetas que se solapan en el tiempo: concurrencia, unicidad media, pesos por rendimiento y por antigüedad, y bootstrap secuencial en un bagging."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/unicidad-pesos-y-bootstrap-secuencial/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/unicidad-pesos-y-bootstrap-secuencial.png"
---

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

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.3 · Etiquetado y muestreo
- Duración: 70 min
- Requisitos: [5.3.2 · Método de la triple barrera: etiquetas que imitan una operación](https://pythonparatrading.com/curso-python-trading/nivel-5/metodo-de-la-triple-barrera/), [5.3.3 · Meta-labeling: un modelo para el lado y otro para el tamaño](https://pythonparatrading.com/curso-python-trading/nivel-5/meta-labeling/), [2.1.1 · Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos](https://pythonparatrading.com/curso-python-trading/nivel-2/contrastes-bootstrap-y-errores-robustos/)

## Al terminar esta lección

- Calcular la concurrencia de etiquetas minuto a minuto y la unicidad media de cada una con sumas acumuladas, sin matrices enormes.
- Construir pesos por atribución del rendimiento y por decaimiento temporal y explicar qué corrige cada uno.
- Implementar el bootstrap secuencial por bloques de sesión y medir cuánto sube la unicidad de la muestra frente al bootstrap estándar.
- Comparar en un bagging de árboles el bootstrap estándar, la muestra reducida a la unicidad media y el secuencial, con el AUC fuera de la bolsa y en sesiones nuevas.
- Explicar por qué el error fuera de la bolsa no es una validación honesta con etiquetas solapadas.

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

```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")
```
```output
6533 etiquetas; vida media: 22.6 minutos
```

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

```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)
```
```output
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 $i$ en el minuto $t$ es $1_{t,i}/c_t$: la fracción del minuto que le corresponde. Su
**unicidad media** es la media en su vida:

$$
\overline{u}_i = \frac{\sum_t 1_{t,i}/c_t}{\sum_t 1_{t,i}}.
$$

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

```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")
```
```output
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:

```python
s133 = ev[(ev.sesion == 133) & (ev.minuto.between(130, 200))]
s133[["minuto", "fin", "lado", "toque", "unicidad"]].round(2)
```
```output
      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.

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

La unicidad depende del diseño de las etiquetas, no del mercado. Barreras más anchas o una vertical más lejana
alargan la vida de cada etiqueta y bajan la unicidad (ejercicio 1); un filtro CUSUM más exigente separa los eventos y
la sube. Muestrear por eventos (5.3.5) en lugar de etiquetar cada minuto es la primera defensa contra el solapamiento.

</aside>

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

$$
\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 $r_t$ el log-rendimiento del minuto. La normalización hace que los pesos sumen $N$, como si no hubiera pesos.

```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)
```
```output
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 $\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 $c_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), \qquad x_i = \sum_{j \le i} \overline{u}_j, \qquad d(x_N) = 1.
$$

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

```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)
```
```output
      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 = 0$, la etiqueta del primer cuartil pesa 0,25 y la de la mitad, 0,50. Con $c = -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(\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 $c^{(k)}_t$ las extracciones previas vivas en el minuto $t$.

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

```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)
```
```output
                     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.* Dos paneles de barras. Unicidad media: todas las etiquetas 0,617, una muestra del bootstrap estándar 0,472 y una del secuencial 0,528 (destacada). Etiquetas distintas en la muestra: 63,1 % con el estándar y 70,8 % con el secuencial.

Una muestra bootstrap estándar de 6.533 extracciones tiene un 63,1 % de etiquetas distintas (el $1 - 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.

```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))
```
```output
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 $\overline{u} \cdot N$ etiquetas.

```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)
```
```output
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 filas con dos puntos unidos por una línea. Bootstrap estándar: AUC OOB 0,572 y en prueba 0,529. Muestra reducida a la unicidad media: OOB 0,570 y prueba 0,544 (destacado). Bootstrap secuencial: OOB 0,566 y prueba 0,529. Una línea discontinua marca 0,50, el azar.

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 $\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 $N$ 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.

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

En scikit-learn, `RandomForestClassifier(max_samples=u_media)` y `BaggingClassifier(max_samples=u_media)` aplican la muestra
reducida sin código propio. El bootstrap secuencial no está en ninguna librería estándar de scikit-learn: hay que
escribir el bagging, como en la celda. En ningún caso `oob_score_` sirve para elegir hiperparámetros con etiquetas
solapadas.

</aside>

## Qué usar y cuándo

| Herramienta | Corrige | Cuándo merece la pena |
|---|---|---|
| Muestreo por eventos (5.3.5) | Etiquetas casi idénticas desde el origen | Siempre que se pueda: es la primera defensa |
| Unicidad media $\overline{u}$ | Cuenta la información real de la muestra | Siempre como diagnóstico; como `max_samples` en bagging |
| Pesos por atribución | Etiquetas planas y solapadas pesan menos | Con barreras anchas y muchas verticales |
| Decaimiento temporal | Lo antiguo pesa menos | Muestras largas con deriva; parámetro a validar |
| Bootstrap secuencial | Muestras con menos solapamiento | Concurrencia moderada y desigual; caro de calcular |
| Purga y embargo (5.4.2) | Fuga entre entrenamiento y validación | Siempre 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.**

```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}")
```
```output
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/\sqrt{N_{\text{efectivo}}}$.

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

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

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

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

   - A. 0,75: la media de 1/c en sus minutos, (10·1 + 10·0,5)/20
   - B. 0,5
   - C. 1, porque es una sola etiqueta
   - D. 0,25

   *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)?

   - A. Por un error de cálculo
   - B. Porque las verticales son menos únicas
   - 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
   - D. Porque las barreras horizontales son más frecuentes

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

   - A. No cambia: todas tienen la misma
   - B. Sube para las etiquetas más rentables
   - C. Se pone a cero para las ya extraídas
   - 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

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

   - A. Que la segunda mitad es más difícil por azar
   - 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
   - C. Que 40 árboles son pocos
   - D. Que el AUC no sirve para clasificadores binarios

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

   - A. Pesan la mitad que las recientes
   - B. Pesan 0,5 la primera y 1 la última
   - C. La mitad más antigua (medida en unicidad acumulada) pesa cero y el resto crece linealmente hasta 1
   - D. Se duplican

   *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

### Research

- [Procesos Estocásticos - Paseos Aleatorios](https://pythonparatrading.com/procesos-estocasticos/) · Research-0005 · 12 min

### Documentación

- [scikit-learn: métodos de ensemble (bagging y random forests)](https://scikit-learn.org/stable/modules/ensemble.html#bagging) · scikit-learn.org
- [scikit-learn: RandomForestClassifier (max_samples, oob_score)](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html) · scikit-learn.org
- [NumPy: ufunc.at (np.add.at)](https://numpy.org/doc/stable/reference/generated/numpy.ufunc.at.html) · numpy.org

### Libros

- [Marcos López de Prado: Advances in Financial Machine Learning (Wiley, 2018), capítulo 4, «Sample Weights»](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086) · wiley.com

### Datos

- [Datos del nivel 5: barras de 1 minuto de FNX (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_barras_1min.parquet) · Descarga · hf5_barras_1min.parquet

Lección anterior: [5.3.3 · Meta-labeling: un modelo para el lado y otro para el tamaño](https://pythonparatrading.com/curso-python-trading/nivel-5/meta-labeling/)

Lección siguiente: [5.3.5 · Muestreo por eventos: el filtro CUSUM simétrico](https://pythonparatrading.com/curso-python-trading/nivel-5/muestreo-por-eventos-filtro-cusum/)
