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.
- Módulo
- 5.3 · Etiquetado y muestreo
- Duración
- 70 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
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 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.
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")6533 etiquetas; vida media: 22.6 minutosLa vida de la etiqueta son los minutos : desde el siguiente al evento hasta el del toque. Con si la etiqueta está viva en el minuto , la concurrencia del minuto es . Calcularla no exige construir la matriz (96.330 minutos por 6.533 etiquetas): basta sumar en el minuto en que nace cada etiqueta y en el siguiente al que muere, y acumular.
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)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: float64np.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 en el minuto es : la fracción del minuto que le corresponde. Su unicidad media es la media en su vida:
Otra vez, con una suma acumulada de se obtiene la de todas las etiquetas con dos restas:
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")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: int64La 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:
s133 = ev[(ev.sesion == 133) & (ev.minuto.between(130, 200))]
s133[["minuto", "fin", "lado", "toque", "unicidad"]].round(2) 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.65La 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:
con el log-rendimiento del minuto. La normalización hace que los pesos sumen , como si no hubiera pesos.
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)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.403Las 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 . 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 : 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:
Un parámetro decide la forma: con no hay decaimiento; con la etiqueta más antigua pesa ; con la fracción más antigua pesa cero.
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) 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.499Con , la etiqueta del primer cuartil pesa 0,25 y la de la mitad, 0,50. Con , 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:
con las extracciones previas vivas en el minuto .
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.
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) estándar secuencial
unicidad media 0.472 0.528
etiquetas distintas 0.631 0.708Una muestra bootstrap estándar de 6.533 extracciones tiene un 63,1 % de etiquetas distintas (el 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.
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))27170 etiquetas; concurrencia media: 7.6 ; unicidad media: 0.136Con 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 etiquetas.
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)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.037Tres resultados, en orden de importancia:
- 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.
- Reducir la muestra a 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.
- El bootstrap secuencial, aquí, no cambia nada (0,529 en prueba). Sube la unicidad de cada muestra, pero sigue extrayendo 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
| 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 | 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
- 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
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}")unicidad media 0.347; efectivas 2270 de 6533; vida media 42.6Con 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 .
- Repite la comparación entre el bootstrap estándar y la muestra reducida con
RandomForestClassifierde scikit-learn y 200 árboles. ¿Se mantiene el resultado?
Solución
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}") estándar: OOB 0.576 prueba 0.536
max_samples=0.137: OOB 0.582 prueba 0.553Con 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.
- 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
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.
¿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.
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 %.
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).
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).