---
title: "5.3.3 Meta-labeling: modelo primario y secundario"
description: "Un modelo primario decide el lado y un secundario aprende cuándo acierta: filtrado, precisión frente a recall y tamaño de la apuesta con su probabilidad."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/meta-labeling/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/meta-labeling.png"
---

# 5.3.3 · Meta-labeling: un modelo para el lado y otro para el tamaño

> El modelo primario de FNX acierta el 50,7 % de las veces: el 60,4 % en tendencia y el 44,6 % en reversión, pero el régimen no se observa. El meta-labeling entrena un segundo modelo, con información del momento del evento, que estima la probabilidad de que el primario acierte. Esta lección lo construye, mide cuánto del régimen recupera, compara umbrales con métricas económicas y convierte la probabilidad en un tamaño de apuesta.

- 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.1.3 · Métricas económicas para modelos de trading: más allá del acierto](https://pythonparatrading.com/curso-python-trading/nivel-5/metricas-economicas-para-modelos-de-trading/), [4.2.2 · Criterio de Kelly y volatility targeting: cuánto arriesgar](https://pythonparatrading.com/curso-python-trading/nivel-4/kelly-y-volatility-targeting/)

## Al terminar esta lección

- Separar la decisión del lado (modelo primario) de la decisión de operar y cuánto (modelo secundario) y construir la meta-etiqueta con la triple barrera.
- Construir features observables en el momento del evento sin información posterior y validar con una partición por sesiones que no corta ninguna etiqueta.
- Comparar el meta-modelo con una baseline de una línea y con un oráculo que conoce el régimen, con errores típicos.
- Elegir el umbral con el compromiso entre precisión y recall y con métricas económicas, no con el F1.
- Convertir la probabilidad del meta-modelo en un tamaño de apuesta con 2Φ(z) − 1 y medir su efecto a igual exposición.

## Dos decisiones, dos modelos

Un sistema de trading toma dos decisiones en cada señal: **de qué lado** ponerse y **cuánto** apostar (cero incluido).
La mayoría de los modelos de ML las mezclan: un clasificador predice «sube» o «baja» y opera con tamaño fijo. El
**meta-labeling** (López de Prado, 2018, sección 3.6) las separa:

- El **modelo primario** decide el lado. Puede ser una regla técnica, un modelo econométrico, un analista
  fundamental o un modelo de ML. Se diseña para tener mucho **recall**: proponer casi todas las oportunidades,
  aunque muchas fallen.
- El **modelo secundario**, o meta-modelo, recibe cada propuesta del primario y estima la probabilidad de que acierte.
  No decide el lado: decide si se opera y con qué tamaño. Su trabajo es subir la **precisión**.

```mermaid
flowchart TB
  D[Datos hasta t] --> P[Primario: lado]
  P --> TB[Triple barrera]
  TB --> Y[Meta-etiqueta]
  D --> F[Features en t]
  F --> S[Secundario: p]
  Y --> S
  S --> Z[Operar y tamaño]
```

La separación tiene tres ventajas. El primario puede ser sencillo e interpretable, y el ML solo actúa como filtro, lo
que reduce lo que puede sobreajustar. El tamaño sale de una probabilidad, no de una regla aparte. Y el método funciona
con cualquier primario, también con uno discrecional: el secundario aprende en qué condiciones acierta ese analista.

El caso de esta lección tiene la respuesta conocida. En FNX (datos **sintéticos** del nivel 5), el primario, un
momentum disparado por un filtro CUSUM (lección 5.3.5), acierta el 60,4 % de las veces en el régimen de tendencia y
el 44,6 % en el de reversión. En conjunto, el 50,7 %: no gana. Un meta-modelo bueno debería acercarse a lo que haría
quien conociera el régimen.

## El modelo primario y la meta-etiqueta

Los datos, la volatilidad y las dos funciones vienen de las lecciones 5.3.2 y 5.3.5, en versión compacta:

```python
import numpy as np
import pandas as pd

barras = pd.read_parquet("hf5_barras_1min.parquet")
verdad = pd.read_parquet("hf5_verdad.parquet")
n_ses = len(barras) // 390


def matriz(col):
    """Columna de las barras como matriz sesión x minuto."""
    return barras[col].to_numpy().reshape(n_ses, 390)


C, V = matriz("cierre"), matriz("volumen")
VC = matriz("volumen_comprador")
tend = (verdad["regimen"] == "tendencia").to_numpy()
tend = tend.reshape(n_ses, 390)        # la verdad: solo para medir
lr = np.diff(np.log(C), axis=1)
sig = (pd.Series(lr.ravel()).ewm(span=100).std()
       .to_numpy().reshape(n_ses, 389))
print(C.shape, f"σ mediana {1e4 * np.nanmedian(sig):.2f} pb")
```
```output
(247, 390) σ mediana 4.22 pb
```

```python
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 = [], []
    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])
        golpes = np.flatnonzero(np.abs(tramo) >= k * s * np.sqrt(h))
        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)
    return ev.assign(fin=fin, ret=ret)


ev = triple_barrera(C, eventos_cusum(lr, sig))
ev["y"] = (ev.lado * ev.ret > 0).astype(int)   # meta-etiqueta
ev["tendencia"] = tend[ev.sesion, ev.minuto]
print(len(ev), "eventos; acierto del primario:", round(ev.y.mean(), 3))
ev.groupby("tendencia")["y"].agg(["size", "mean"]).round(3)
```
```output
6550 eventos; acierto del primario: 0.507
           size   mean
tendencia
False      4010  0.446
True       2540  0.604
```

La **meta-etiqueta** `y` es 1 si el rendimiento hasta el primer toque de la triple barrera ($\pm\sigma\sqrt{30}$,
vertical a 30 minutos) tiene el signo del lado del primario. Son las cifras del README: 6.550 eventos y un acierto del
50,7 %, del 60,4 % en los 2.540 eventos en tendencia y del 44,6 % en los 4.010 en reversión. La columna `tendencia`
viene de `hf5_verdad.parquet` y no se usará como feature: sirve para comprobar qué aprende el meta-modelo.

## Features que se conocen en el evento

El meta-modelo solo puede usar lo que se sabe al cierre del minuto del evento. El README describe cuatro features de
referencia, todas sobre la ventana de 30 minutos que termina en ese cierre:

- `imb_lado`: el **desequilibrio** del flujo (compras agresoras menos ventas, sobre el volumen) con el signo del lado.
  Positivo cuando el flujo empuja a favor de la operación. En FNX, en tendencia, la fracción compradora tiene un sesgo
  persistente hacia la dirección del episodio; en los dos regímenes, además, sigue al precio de cada minuto.
- `imb_abs`: su valor absoluto, la intensidad del flujo sin dirección.
- `vol_rel`: el logaritmo del volumen de la ventana frente al de la misma franja horaria en las 20 sesiones
  anteriores. Compararlo con la misma franja quita la U intradía del volumen.
- `rv_rel`: el logaritmo de la volatilidad realizada de la ventana frente a la que predecía la EWMA.

```python
d, m = ev.sesion.to_numpy(), ev.minuto.to_numpy()
cv = np.cumsum(V, axis=1)               # volumen acumulado
ci = np.cumsum(2 * VC - V, axis=1)      # compras menos ventas


def ultimos_30(acum, d, m):
    """Suma de los 30 minutos que acaban en el minuto m (incluido)."""
    antes = np.where(m >= 30, acum[d, np.maximum(m - 30, 0)], 0)
    return acum[d, m] - antes


vol30 = ultimos_30(cv, d, m)
imb30 = ultimos_30(ci, d, m) / vol30
# volumen de la misma franja en las 20 sesiones anteriores
hist = np.array([
    ultimos_30(cv, np.arange(max(0, s - 20), s),
               np.full(min(s, 20), mm)).mean() if s > 0 else np.nan
    for s, mm in zip(d, m)])
lc = np.log(C)
rv30 = np.array([np.sqrt((np.diff(lc[s, max(mm - 30, 0):mm + 1])**2)
                         .sum()) for s, mm in zip(d, m)])
X = pd.DataFrame({
    "imb_lado": ev.lado * imb30,          # a favor del lado: > 0
    "imb_abs": np.abs(imb30),
    "vol_rel": np.log(vol30 / hist),
    "rv_rel": np.log(rv30 / (ev.sigma * np.sqrt(np.minimum(m, 30)))),
})
X.describe().loc[["count", "mean", "std"]].round(3)
```
```output
       imb_lado   imb_abs   vol_rel    rv_rel
count  6550.000  6550.000  6528.000  6550.000
mean      0.033     0.055    -0.012     0.015
std       0.062     0.045     0.265     0.239
```

`np.cumsum` por sesión permite sumar cualquier ventana con una resta. El volumen histórico se calcula con las 20
sesiones **anteriores**, así que los eventos de la primera sesión no tienen `vol_rel` (son los 22 que faltan). El
desequilibrio a favor del lado es positivo de media (0,033): es lógico, porque el CUSUM dispara después de un
movimiento y el flujo sigue al precio. El meta-modelo tiene que distinguir cuándo ese flujo es persistente.

## Validación: dos mitades por sesiones

La lección 5.4.2 explica por qué una validación cruzada con etiquetas solapadas necesita purga. Aquí basta una
partición más simple: entrenar en las primeras 123 sesiones y evaluar en las 124 siguientes. Ninguna etiqueta cruza
el cierre de una sesión, así que ninguna etiqueta de entrenamiento comparte minutos con una de prueba.

```python
ok = np.isfinite(X).all(axis=1).to_numpy()
mitad = n_ses // 2
ent = ok & (d < mitad)
pru = ok & (d >= mitad)
y, t = ev.y.to_numpy(), ev.tendencia.to_numpy()
print("entrenamiento:", ent.sum(), " prueba:", pru.sum())
print("acierto del primario en la prueba:", round(y[pru].mean(), 3))
print("eventos en tendencia en la prueba:", round(t[pru].mean(), 3))
print("etiquetas que cruzan sesiones:", (ev.fin > 389).sum())
```
```output
entrenamiento: 3241  prueba: 3287
acierto del primario en la prueba: 0.501
eventos en tendencia en la prueba: 0.402
etiquetas que cruzan sesiones: 0
```

En la segunda mitad el primario acierta el 50,1 % y el 40,2 % de los eventos cae en tendencia: es la población sobre
la que se mide todo lo que sigue.

## La baseline de una línea y el techo

Antes de entrenar nada conviene saber cuánto da la regla más simple que se le ocurriría a alguien que ha leído el
README, y cuánto daría el que conoce el régimen:

```python
def resumen(sel, nombre):
    """Fracción operada, precisión y su error típico, media en pb."""
    yy = y[pru][sel]
    r = (ev.lado * ev.ret).to_numpy()[pru][sel]
    return {"filtro": nombre, "N": int(sel.sum()),
            "fraccion": sel.mean(), "precision": yy.mean(),
            "error": np.sqrt(yy.mean() * (1 - yy.mean()) / len(yy)),
            "media_pb": 1e4 * r.mean()}


filas = [resumen(np.ones(pru.sum(), bool), "todas"),
         resumen(X.imb_lado.to_numpy()[pru] > 0, "imb_lado > 0"),
         resumen(t[pru], "oráculo: tendencia")]
pd.DataFrame(filas).set_index("filtro").round(3)
```
```output
                       N  fraccion  precision  error  media_pb
filtro
todas               3287     1.000      0.501  0.009     0.467
imb_lado > 0        2301     0.700      0.535  0.010     1.885
oráculo: tendencia  1323     0.402      0.597  0.013     4.925
```

La regla `imb_lado > 0` opera el 70 % de los eventos con un 53,5 % de precisión (error típico de 1,0 puntos) y
1,9 pb de media por operación. El oráculo, que solo opera en tendencia, llega al 59,7 % y 4,9 pb: es el techo. Un
meta-modelo que lo supere en la segunda mitad estaría midiendo ruido o usando información que no tiene.

## El modelo secundario

Una regresión logística con las features tipificadas (lección 5.5.1), entrenada en la primera mitad:

```python
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

meta = make_pipeline(StandardScaler(), LogisticRegression())
meta.fit(X[ent], y[ent])
p = meta.predict_proba(X[pru])[:, 1]
coef = meta[-1].coef_[0].round(3).tolist()
print("coeficientes:", dict(zip(X.columns, coef)))
print("AUC frente al acierto:", round(roc_auc_score(y[pru], p), 3))
print("AUC frente al régimen:", round(roc_auc_score(t[pru], p), 3))
filas.insert(2, resumen(p > 0.5, "logística p > 0,5"))
pd.DataFrame(filas).set_index("filtro").round(3)
```
```output
coeficientes: {'imb_lado': 0.244, 'imb_abs': 0.028, 'vol_rel': 0.074, 'rv_rel': -0.011}
AUC frente al acierto: 0.575
AUC frente al régimen: 0.707
                       N  fraccion  precision  error  media_pb
filtro
todas               3287     1.000      0.501  0.009     0.467
imb_lado > 0        2301     0.700      0.535  0.010     1.885
logística p > 0,5   1729     0.526      0.545  0.012     2.502
oráculo: tendencia  1323     0.402      0.597  0.013     4.925
```

Los números coinciden con los de referencia del README. El coeficiente del imbalance a favor del lado (0,244) domina
a los otros tres: el volumen relativo aporta algo (0,074) y la volatilidad realizada y la intensidad sin signo, casi
nada. El AUC frente al acierto es 0,575, modesto, pero frente al **régimen verdadero** es 0,707: el modelo no ve el
régimen y ha aprendido a reconocerlo bastante mejor de lo que predice cada operación, porque dentro de cada régimen el
resultado sigue siendo ruidoso.

Operando solo cuando $p > 0{,}5$, la precisión pasa al 54,5 % (error de 1,2 puntos) con la mitad de los eventos, y la
media por operación de 0,5 a 2,5 pb. Queda entre el primario y el oráculo, como debe ser, y apenas por encima de la
regla de una línea: la diferencia entre 53,5 % y 54,5 % no se distingue del ruido.

La prueba de que ha aprendido el régimen se ve por deciles de su probabilidad:

```python
dec = pd.DataFrame({"p": p, "tendencia": t[pru], "acierto": y[pru],
                    "decil": pd.qcut(p, 10, labels=False) + 1})
dec.groupby("decil").mean().round(3).T
```
```output
decil         1      2      3      4      5      6      7      8      9      10
p          0.407  0.443  0.462  0.479  0.496  0.513  0.533  0.556  0.589  0.650
tendencia  0.286  0.195  0.235  0.246  0.316  0.348  0.407  0.503  0.705  0.784
acierto    0.401  0.432  0.503  0.429  0.498  0.448  0.520  0.527  0.647  0.605
```

*Fig. — El meta-modelo no ve el régimen, pero lo aprende: en el decil más alto de su probabilidad el 78 % de los eventos es de tendencia, frente al 40 % del total.* Barras por decil de la probabilidad del meta-modelo en la segunda mitad: fracción de eventos en tendencia de 29 %, 20 %, 23 %, 25 %, 32 %, 35 %, 41 %, 50 %, 70 % y 78 % (decil 10, destacado). Una línea discontinua marca el 40 % de todos los eventos.

En los deciles bajos, entre el 20 % y el 29 % de los eventos son de tendencia; en el más alto, el 78 %. La precisión
sube con ellos: del 40 % al 60-65 %. Y la probabilidad está razonablemente **calibrada**: en el decil 10 el modelo
dice 0,65 y acierta el 60,5 %; en el 1 dice 0,41 y acierta el 40,1 %. Si se va a usar como tamaño, esa calibración
importa tanto como el AUC; con modelos de árboles conviene comprobarla y corregirla si hace falta.

## Precisión, recall y el umbral

El umbral 0,5 no tiene nada de especial. Subirlo opera menos y con más precisión; bajarlo, al revés. La figura de la
cabecera recorre todos los umbrales; la tabla, algunos:

```python
yp = y[pru]
r = (ev.lado * ev.ret).to_numpy()[pru]
filas_u = []
for umbral in (0.45, 0.48, 0.50, 0.52, 0.55, 0.58):
    s = p > umbral
    prec, rec = yp[s].mean(), yp[s].sum() / yp.sum()
    filas_u.append({"umbral": umbral, "operadas": s.mean(),
                    "precision": prec, "recall": rec,
                    "F1": 2 * prec * rec / (prec + rec),
                    "media_pb": 1e4 * r[s].mean(),
                    "error_pb": 1e4 * r[s].std() / np.sqrt(s.sum())})
pd.DataFrame(filas_u).set_index("umbral").round(3)
```
```output
        operadas  precision  recall     F1  media_pb  error_pb
umbral
0.45       0.814      0.521   0.847  0.645     1.311     0.415
0.48       0.647      0.532   0.687  0.600     2.006     0.468
0.50       0.526      0.545   0.572  0.558     2.502     0.525
0.52       0.411      0.570   0.468  0.514     3.917     0.594
0.55       0.275      0.602   0.330  0.427     5.128     0.734
0.58       0.172      0.631   0.217  0.323     6.423     0.969
```

- Con umbral 0,45 se opera el 81 % con un 52,1 %; con 0,55, el 27,5 % con un 60,2 %, ya al nivel del oráculo pero con
  menos de un tercio de los eventos y un error típico mayor (0,73 pb en la media).
- El **F1** baja al subir el umbral: 0,645 con 0,45 y 0,427 con 0,55. Operar todo (recall 1, precisión 0,501) daría
  el F1 más alto de todos, 0,668. El F1 pesa igual una operación ganadora perdida que una perdedora hecha; en trading,
  con costes, no pesan igual. El umbral se elige con la métrica económica (lección 5.1.3): media neta por operación,
  número de operaciones y riesgo.
- La media por operación sube de 1,3 a 6,4 pb, pero el número de operaciones cae a la quinta parte. El total neto, que
  es lo que se cobra, tiene un máximo intermedio.

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

Elegir el umbral mirando la segunda mitad y presentar después el resultado de esa misma mitad es sobreajustar. El
umbral se fija con los datos de entrenamiento (o con una validación dentro de ellos, lección 5.4.3) y la segunda
mitad se mira una vez. Aquí la tabla se enseña completa para entender el compromiso, no para elegir.

</aside>

## ¿Un modelo más flexible?

Si la logística aprende el régimen con cuatro features, un modelo no lineal podría aprender más:

```python
import lightgbm as lgb

gbm = lgb.LGBMClassifier(
    n_estimators=200, learning_rate=0.02, num_leaves=8,
    min_child_samples=50, subsample=0.8, subsample_freq=1,
    random_state=0, n_jobs=4, deterministic=True,
    force_row_wise=True, verbose=-1)
gbm.fit(X[ent], y[ent])
p_gbm = gbm.predict_proba(X[pru])[:, 1]
print("AUC frente al acierto:", round(roc_auc_score(y[pru], p_gbm), 3))
print("AUC frente al régimen:", round(roc_auc_score(t[pru], p_gbm), 3))
pd.DataFrame([resumen(p_gbm > 0.5, "LightGBM p > 0,5")]).set_index(
    "filtro").round(3)
```
```output
AUC frente al acierto: 0.567
AUC frente al régimen: 0.653
                     N  fraccion  precision  error  media_pb
filtro
LightGBM p > 0,5  1676      0.51      0.548  0.012     2.509
```

LightGBM (lección 5.5.3), con 200 árboles pequeños, queda **peor** que la logística en AUC (0,567 frente a 0,575)
y en reconocer el régimen (0,653 frente a 0,707), y casi igual en precisión con $p > 0{,}5$ (54,8 %). La relación que
importa aquí es casi lineal en una feature; la flexibilidad solo añade varianza. Es el resultado habitual con poca
señal: la baseline simple y el modelo lineal son el listón que un modelo complejo tiene que superar en datos que no
ha visto, no en los de entrenamiento.

## De la probabilidad al tamaño

La probabilidad del meta-modelo puede decidir el tamaño, no solo el sí o el no. La regla de López de Prado (2018,
capítulo 10) trata $p$ como el resultado de un contraste de la hipótesis «la operación es una moneda al aire»:

$$
z = \frac{p - \tfrac{1}{2}}{\sqrt{p(1-p)}}, \qquad m = 2\,\Phi(z) - 1 \in [0, 1],
$$

con $\Phi$ la función de distribución normal y $m = 0$ cuando $p \le 0{,}5$. El tamaño se redondea a múltiplos de un
paso para no cambiar la posición por diferencias mínimas de probabilidad:

```python
from scipy.stats import norm


def tamano(p, paso=0.05):
    """2·Φ(z) - 1 con z = (p - 1/2) / √(p(1 - p)); 0 si p ≤ 1/2;
    redondeado a múltiplos de `paso`."""
    z = (p - 0.5) / np.sqrt(p * (1 - p))
    m = np.clip(2 * norm.cdf(z) - 1, 0, None)
    return np.round(m / paso) * paso


for q in (0.50, 0.55, 0.60, 0.65, 0.70):
    print(f"p = {q:.2f}  tamaño = {tamano(np.array([q]))[0]:.2f}")
tam = tamano(p)
print("tamaño medio:", round(tam.mean(), 3), " con tamaño > 0:",
      (tam > 0).sum())
```
```output
p = 0.50  tamaño = 0.00
p = 0.55  tamaño = 0.10
p = 0.60  tamaño = 0.15
p = 0.65  tamaño = 0.25
p = 0.70  tamaño = 0.35
tamaño medio: 0.056  con tamaño > 0: 1441
```

La regla es prudente: con $p = 0{,}6$ apuesta 0,15 del tamaño máximo y con 0,7 solo 0,35. Con las probabilidades de
FNX, casi todas entre 0,40 y 0,65, el tamaño medio es 0,056 y 1.441 eventos reciben un tamaño mayor que cero. Para
compararla con el filtro hay que llevarla a la **misma exposición total**: multiplicar los tamaños por 9,4. La
lección 4.2.2 explica por qué el tamaño absoluto es una decisión de riesgo aparte.

## El resultado económico en la segunda mitad

La P&G de cada operación es el tamaño por el rendimiento del lado hasta la salida, menos un coste. El coste supuesto
es de 0,6 pb por operación completa y unidad de tamaño: un tick de FNX son unos 0,47 pb al precio medio del año
(horquilla de un tick, pagada a medias al entrar y al salir) más una comisión. Es un supuesto, no un dato del mercado;
la lección 2.4.7 explica cómo modelarlo.

```python
COSTE = 0.6e-4        # por operación completa y unidad de tamaño
ses_pru = d[pru]


def pyg(tam_op):
    """P&G neta por operación y Sharpe anualizado de la P&G diaria."""
    neto = tam_op * r - COSTE * tam_op
    diario = pd.Series(neto).groupby(ses_pru).sum()
    sr = diario.mean() / diario.std() * np.sqrt(252)
    return {"exposicion": tam_op.sum(),
            "bruto_pb_por_unidad": 1e4 * (tam_op * r).sum()
            / tam_op.sum(),
            "neto_total_pb": 1e4 * neto.sum(), "sharpe": sr,
            "error_sharpe": np.sqrt((1 + sr**2 / 504) / len(diario)
                                    * 252)}


escala = (p > 0.5).sum() / tam.sum()   # misma exposición total
res = pd.DataFrame({
    "todas": pyg(np.ones(len(p))),
    "logística p > 0,5": pyg((p > 0.5).astype(float)),
    "tamaño 2Φ(z) - 1": pyg(tam * escala),
    "oráculo": pyg(t[pru].astype(float)),
}).T
print(f"escala del tamaño: x{escala:.1f}")
res.round(2)
```
```output
escala del tamaño: x9.4
                   exposicion  bruto_pb_por_unidad  neto_total_pb  sharpe  error_sharpe
todas                  3287.0                 0.47        -436.04   -0.45          1.43
logística p > 0,5      1729.0                 2.50        3288.91    3.75          1.45
tamaño 2Φ(z) - 1       1729.0                 4.51        6768.89    5.12          1.46
oráculo                1323.0                 4.93        5722.38    8.01          1.51
```

*Fig. — P&G neta acumulada en la segunda mitad (0,6 pb de coste por operación y unidad): el primario solo pierde; filtrado por el meta-modelo gana, y dimensionado con su probabilidad, a la misma exposición total, gana más. Efecto plantado en datos sintéticos.* Cuatro líneas de P&G acumulada en puntos básicos a lo largo de 124 sesiones. Todas las operaciones del primario terminan en −436 pb. Con el filtro de la logística (destacado), 3.289 pb. Con el tamaño 2Φ(z) − 1 escalado a la misma exposición, 6.769 pb. El oráculo que solo opera en tendencia, con menos exposición, 5.722 pb.

- Operar todo pierde: 0,47 pb brutos por operación no pagan 0,6 de coste.
- Filtrar con $p > 0{,}5$ gana 2,5 pb brutos por operación y 3.289 pb netos en 124 sesiones, con un Sharpe anualizado
  de 3,75.
- Dimensionar con $2\Phi(z) - 1$, a la misma exposición total, gana 4,5 pb por unidad de tamaño, muy cerca del oráculo
  (4,9), y 6.769 pb netos: concentra el tamaño en los eventos más claros.
- El oráculo opera menos (1.323 unidades) con el mejor resultado por unidad y un Sharpe de 8,0.

Dos advertencias. El error típico de cada Sharpe es de unos 1,45 con 124 sesiones: la diferencia entre el filtro y el
tamaño (3,75 frente a 5,12) es de un error típico y no permite elegir entre ellos con esta muestra. Y el efecto de
régimen está **plantado** con un tamaño grande para que el método se vea: en un mercado real la señal es más débil,
cambia con el tiempo y la explotan otros. Lo que la lección demuestra es que el meta-labeling recupera una estructura
que existe; no que exista en ningún futuro real.

## Errores típicos

- **Features con información posterior.** Cualquier ventana que incluya el minuto siguiente al evento, o un volumen
  «del día» completo, filtra el resultado. En el meta-modelo es especialmente fácil, porque las features se calculan
  después de conocer los eventos.
- **Entrenar el secundario con las mismas etiquetas con las que se ajustó el primario.** Si el umbral del CUSUM o las
  barreras se eligieron mirando el acierto, el secundario aprende sobre una muestra seleccionada.
- **Usar la probabilidad como tamaño sin comprobar la calibración.** Una probabilidad mal calibrada da tamaños
  sistemáticamente grandes o pequeños.
- **Medir con accuracy o F1.** El objetivo es económico; el meta-modelo puede empeorar el F1 y mejorar el resultado.
- **Olvidar el solapamiento.** Las meta-etiquetas de eventos cercanos comparten minutos; para validación cruzada
  dentro del entrenamiento hace falta purga y embargo (5.4.2), y para el bagging, pesos por unicidad (5.3.4).

## Ejercicios

1. Invierte las mitades: entrena el meta-modelo en la segunda y evalúalo en la primera. ¿Se mantienen el AUC y la
   precisión con $p > 0{,}5$?

**Solución.**

```python
meta2 = make_pipeline(StandardScaler(), LogisticRegression())
meta2.fit(X[pru], y[pru])
p2 = meta2.predict_proba(X[ent])[:, 1]
print("AUC frente al acierto:", round(roc_auc_score(y[ent], p2), 3))
print("AUC frente al régimen:", round(roc_auc_score(t[ent], p2), 3))
s2 = p2 > 0.5
print(f"operadas {s2.mean():.3f}  precisión {y[ent][s2].mean():.3f}"
      f"  (todas: {y[ent].mean():.3f})")
```
```output
AUC frente al acierto: 0.574
AUC frente al régimen: 0.674
operadas 0.535  precisión 0.564  (todas: 0.513)
```

Con las mitades al revés el resultado es casi el mismo: AUC de 0,574 frente al acierto y 0,674 frente al régimen, y
una precisión del 56,4 % operando el 53,5 % de los eventos, frente al 51,3 % de todos los de la primera mitad. Que la
mejora se mantenga en las dos direcciones es una comprobación barata de estabilidad; no sustituye a una validación
con reentrenamiento (lección 5.4.3), pero descarta que el resultado dependa de una mitad afortunada.

2. Añade como feature si el evento anterior de la misma sesión tuvo el mismo lado (`+1`, `−1` o `0` si es el
   primero). En tendencia los eventos seguidos suelen repetir lado. ¿Mejora el AUC fuera de muestra?

**Solución.**

```python
misma = (ev.sesion == ev.sesion.shift()).to_numpy()
prev = np.where(misma, ev.lado * ev.lado.shift().fillna(0), 0)
print("mismo lado que el anterior, en tendencia / reversión:",
      round((prev[t] == 1).mean(), 3), round((prev[~t] == 1).mean(), 3))
X5 = X.assign(prev_igual=prev)
meta5 = make_pipeline(StandardScaler(), LogisticRegression())
meta5.fit(X5[ent], y[ent])
p5 = meta5.predict_proba(X5[pru])[:, 1]
print("AUC con la feature:", round(roc_auc_score(y[pru], p5), 3),
      " sin ella:", round(roc_auc_score(y[pru], p), 3))
```
```output
mismo lado que el anterior, en tendencia / reversión: 0.6 0.434
AUC con la feature: 0.574  sin ella: 0.575
```

En tendencia, el 60 % de los eventos repite el lado del anterior; en reversión, el 43,4 %. La feature tiene
información, pero el AUC no mejora (0,574 frente a 0,575): lo que dice ya lo dice el imbalance a favor del lado, que
también mide la persistencia del flujo. Añadir features que repiten la misma información no ayuda y suma varianza;
la lección 5.2.5 enseña a medirlo con la importancia por grupos de features correlacionadas.

3. Una gestora tiene un analista que recomienda comprar o vender índices sectoriales una vez por semana. ¿Cómo
   aplicarías el meta-labeling a sus recomendaciones y qué cuidarías especialmente?

**Solución.**

El analista es el modelo primario: sus recomendaciones dan el lado. Cada recomendación se etiqueta con una triple
barrera coherente con cómo se gestionaría la posición (por ejemplo, ±1,5 desviaciones semanales y vertical a cuatro
semanas) y la meta-etiqueta es si ganó. El secundario usa solo información disponible en la fecha de cada
recomendación: régimen de volatilidad, momentum del sector, dispersión, tiempo desde la última recomendación, incluso
el historial reciente de aciertos del analista. Hay que cuidar tres cosas. Pocas observaciones: con 52 recomendaciones
al año, un modelo lineal con pocas features y una validación con purga (las etiquetas de cuatro semanas se solapan).
Que el analista cambie: su proceso no es estacionario, y el meta-modelo debe reentrenarse y vigilarse (5.10.2). Y que
la fecha de cada recomendación sea la real, no la de su registro posterior.

## Autoevaluación

1. ¿Qué predice exactamente el modelo secundario en meta-labeling?

   - A. Si el precio va a subir o a bajar
   - B. El régimen de mercado
   - C. La anchura óptima de las barreras
   - D. La probabilidad de que la operación propuesta por el modelo primario, con su lado, sea ganadora según la triple barrera

   *Respuesta D: La probabilidad de que la operación propuesta por el modelo primario, con su lado, sea ganadora según la triple barrera.* El lado lo pone el primario; la meta-etiqueta es 1 si su operación acierta y 0 si no. El secundario decide si operar y cuánto. Que su probabilidad acabe pareciéndose al régimen (AUC de 0,707 frente a él) es una consecuencia, no su objetivo.

2. En la segunda mitad, la regla `imb_lado > 0` opera el 70 % de los eventos con una precisión del 53,5 % y la logística (p > 0,5) el 52,6 % con un 54,5 %, con errores típicos de 1,0 y 1,2 puntos. ¿Qué se concluye?

   - A. Que la logística es mucho mejor
   - B. Que casi toda la mejora la da una feature y una regla de una línea; la logística añade poco y su ventaja sobre la regla está dentro del ruido
   - C. Que la regla es mejor porque opera más
   - D. Que el meta-labeling no funciona

   *Respuesta B: Que casi toda la mejora la da una feature y una regla de una línea; la logística añade poco y su ventaja sobre la regla está dentro del ruido.* La baseline simple es la vara: el coeficiente tipificado del imbalance a favor del lado (0,244) domina a los otros tres. Un LightGBM con las mismas features da un AUC de 0,567, peor que la logística (0,575).

3. Con el umbral en 0,5, el F1 del meta-modelo es 0,558; el del primario solo (operar todo: recall 1, precisión 0,501) es 0,668. ¿Por qué no es un argumento contra el meta-modelo?

   - A. Porque el F1 pesa igual precisión y recall, y en trading perder operaciones dudosas cuesta poco: la P&G neta pasa de −436 a +3.289 pb en la segunda mitad
   - B. Porque el F1 está mal calculado
   - C. Porque el F1 solo vale para tres clases
   - D. Porque el recall no importa nunca

   *Respuesta A: Porque el F1 pesa igual precisión y recall, y en trading perder operaciones dudosas cuesta poco: la P&G neta pasa de −436 a +3.289 pb en la segunda mitad.* Operar todo maximiza el recall con precisión de moneda al aire; con costes eso pierde. La métrica que decide es la económica (lección 5.1.3): resultado neto por operación y Sharpe con su error.

4. Con p = 0,60, ¿qué tamaño da la regla 2Φ(z) − 1 con z = (p − 0,5)/√(p(1 − p))?

   - A. 0,60
   - B. 1, porque p > 0,5
   - C. Unos 0,16 (0,15 tras redondear a múltiplos de 0,05): la regla es prudente con probabilidades cercanas a 0,5
   - D. 0,20

   *Respuesta C: Unos 0,16 (0,15 tras redondear a múltiplos de 0,05): la regla es prudente con probabilidades cercanas a 0,5.* z = 0,1/0,49 = 0,204 y 2Φ(0,204) − 1 = 0,162. En la segunda mitad el tamaño medio es 0,056: para compararlo con el filtro hay que escalarlo a la misma exposición total (×9,4).

5. El meta-modelo da un Sharpe de 3,75 en la segunda mitad (error típico de 1,45). ¿Cómo hay que leerlo?

   - A. Como la rentabilidad esperada de la estrategia en un futuro real
   - B. Como un resultado sobreajustado que hay que descartar
   - C. Como el máximo posible: el oráculo da menos
   - D. Como la confirmación de que el método recupera un efecto plantado en datos sintéticos, con un error típico enorme por tener 124 sesiones, sin deslizamiento ni competencia

   *Respuesta D: Como la confirmación de que el método recupera un efecto plantado en datos sintéticos, con un error típico enorme por tener 124 sesiones, sin deslizamiento ni competencia.* El efecto de régimen está plantado con un tamaño conocido; el oráculo que lo conoce da un Sharpe de 8,0. En un mercado real los efectos son menores, cambian y otros los compiten. Y seis meses de datos dejan un intervalo de ±2,9 en el Sharpe.

## Ideas clave

- El meta-labeling separa dos decisiones: el primario elige el lado con mucho recall; el secundario estima la probabilidad de que acierte y decide si operar y cuánto.
- La meta-etiqueta sale de la triple barrera con el lado del primario: 1 si la operación gana. Las features tienen que conocerse al cierre del minuto del evento.
- En FNX el meta-modelo pasa del 50,1 % de precisión al 54,5 % operando la mitad de los eventos (el oráculo del régimen, 59,7 %), con un AUC de 0,575 frente al acierto y de 0,707 frente al régimen que no ve.
- La baseline de una línea (`imb_lado > 0`) da el 53,5 %: casi toda la mejora es de una feature. Un LightGBM no la mejora.
- El umbral se elige con métricas económicas: el F1 baja, pero la P&G neta pasa de −436 a +3.289 pb en la segunda mitad.
- La probabilidad se convierte en tamaño con 2Φ(z) − 1: a igual exposición mejora el resultado por unidad (4,5 pb frente a 2,5), con un Sharpe cuyo error típico (1,45) impide separar las variantes.

## Recursos

### Research

- [Alpha Factors y Alpha Trading en Python](https://pythonparatrading.com/alpha-factors-y-alpha-trading-en-python/) · Research-0007 · 5 min

### Documentación

- [scikit-learn: LogisticRegression](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html) · scikit-learn.org
- [scikit-learn: precision_recall_curve](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.precision_recall_curve.html) · scikit-learn.org
- [scikit-learn: calibración de probabilidades](https://scikit-learn.org/stable/modules/calibration.html) · scikit-learn.org

### Libros

- [Marcos López de Prado: Advances in Financial Machine Learning (Wiley, 2018), capítulo 3 (3.6, «Meta-Labeling») y capítulo 10, «Bet Sizing»](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
- [Datos del nivel 5: régimen verdadero de FNX minuto a minuto (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_verdad.parquet) · Descarga · hf5_verdad.parquet

Lección anterior: [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/)

Lección siguiente: [5.3.4 · Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial](https://pythonparatrading.com/curso-python-trading/nivel-5/unicidad-pesos-y-bootstrap-secuencial/)
