Lección 5.5.6 · 70 min

Learning to rank para la selección de acciones

Una cartera larga-corta solo necesita el orden de las acciones, y los modelos de learning to rank optimizan justo un orden. Esta lección entrena LambdaRank de LightGBM y los rankers de XGBoost sobre los cortes semanales del panel sintético, los compara con una regresión y con la ridge y descubre por qué la métrica de ranking más usada, el NDCG, premia apostar por la volatilidad.

Fig. — Siete modelos de selección y una feature sola sobre el panel del nivel 5 (2019-2025): NDCG@40 medio con ganancia 2^rel-1 frente al Sharpe anual de la cartera larga-corta por deciles, neto de 10 pb por lado y sin la parte explicada por su beta. El orden al azar (NDCG 0,196; Sharpe -3,41) queda fuera de la escala
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

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

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

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

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

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

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

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

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

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

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

Ordenar, no predecir

Una cartera larga-corta por deciles (lección 5.1.3) no usa la predicción del rendimiento de cada acción: usa su puesto dentro de la semana. Las lecciones 5.5.1 a 5.5.3 entrenan modelos puntuales (pointwise): cada fila por separado, con una pérdida que castiga el error de cada predicción, y después se ordena. El learning to rank (LTR), la familia de modelos de los buscadores, entrena directamente un orden. Hay tres formas de plantearlo:

Una semanaPuntualPor paresPor listasError de cada filaPares invertidosNDCG de la lista
Fuente Mermaid
flowchart TB
  A[Una semana] --> B[Puntual]
  A --> C[Por pares]
  A --> D[Por listas]
  B --> E[Error de cada fila]
  C --> F[Pares invertidos]
  D --> G[NDCG de la lista]
  • Puntual: regresión o clasificación fila a fila (ridge, LightGBM con error cuadrático).
  • Por pares (pairwise): una pérdida logística sobre cada par de acciones de la misma semana con distinto grado, que castiga los pares en el orden equivocado (RankNet; rank:pairwise en XGBoost).
  • Por listas (listwise): pesa cada par por cuánto cambiaría una métrica de toda la lista, normalmente el NDCG, si se intercambiaran. Es LambdaRank; con árboles de boosting, LambdaMART (Burges, 2010), que es lo que hacen objective="lambdarank" en LightGBM y rank:ndcg en XGBoost.

La promesa es atractiva: si lo que se opera es el orden, optimizar el orden debería ganar a optimizar el error cuadrático. La lección comprueba esa promesa en el panel sintético del nivel (README de public/curso/datos/nivel-5/), donde se conoce el alfa verdadero de cada acción y semana, y encuentra una trampa que vale para datos reales.

El panel como consultas

En el lenguaje de los buscadores, cada consulta tiene una lista de documentos con un grado de relevancia entero. Aquí la consulta es la semana, los documentos son las 400 acciones y el grado, el decil de su rendimiento de la semana siguiente (0 el peor, 9 el mejor). Las features son las 21 del panel sin la fuga, en rango normal de cada semana (lección 5.2.4); entrenamiento 2012-2018 sin la última semana (su etiqueta acaba en 2019) y prueba 2019-2025.

Filas por semanagroup: 400 filasGrado: decil 0 a 9LGBMRanker.fitqid por filaXGBRanker.fit
Fuente Mermaid
flowchart TB
  A[Filas por semana] --> B[group: 400 filas]
  A --> C[Grado: decil 0 a 9]
  B --> D[LGBMRanker.fit]
  C --> D
  A --> E[qid por fila]
  C --> F[XGBRanker.fit]
  E --> F
IN [01]python
import numpy as np
import pandas as pd
from scipy.stats import norm

f = pd.read_parquet("panel5_features.parquet")
e = pd.read_parquet("panel5_etiquetas.parquet",
                    columns=["fecha", "ticker", "ret_sig_semana"])
v = pd.read_parquet("panel5_verdad.parquet",
                    columns=["fecha", "ticker", "alfa_verdadero"])
beta = pd.read_csv("panel5_activos_verdad.csv",
                   index_col="ticker")["beta"]
d = (f.merge(e, on=["fecha", "ticker"])
     .merge(v, on=["fecha", "ticker"])
     .dropna(subset=["ret_sig_semana"])
     .sort_values(["fecha", "ticker"]).reset_index(drop=True))
FE = [c for c in f.columns[3:] if c != "revision_analistas"]

def g(s):
    """Rango normal de cada semana (lección 5.2.4); sin dato, 0."""
    por = s.groupby(d["fecha"])
    u = (por.rank() - 0.5) / por.transform("count")
    return np.nan_to_num(norm.ppf(u.to_numpy()))

X = np.column_stack([g(d[k]) for k in FE]).astype(np.float32)
y = g(d["ret_sig_semana"])                     # etiqueta continua
r = d["ret_sig_semana"].to_numpy()
alfa = d["alfa_verdadero"].to_numpy()
b = d["ticker"].map(beta).to_numpy()
decil = (d.groupby("fecha")["ret_sig_semana"].rank(pct=True) * 10)
decil = np.minimum(decil, 9.999).astype(int).to_numpy()  # 0 a 9

fechas = np.unique(d["fecha"])
sem = np.searchsorted(fechas, d["fecha"].to_numpy())
NB = int((fechas < np.datetime64("2019-01-01")).sum())
TR = np.flatnonzero(sem < NB - 1)     # 2012-2018, sin la última
TE = np.flatnonzero(sem >= NB)        # 2019-2025
grupos = np.bincount(sem[TR])         # filas de cada consulta
print(f"entrenamiento {len(TR)} filas en {len(grupos)} semanas; "
      f"prueba {len(TE)} filas")
print("acciones por semana:", np.unique(grupos),
      " grados:", np.bincount(decil[TR]).tolist())
OUT [01]El panel: semanas como consultas
entrenamiento 145200 filas en 363 semanas; prueba 146000 filas
acciones por semana: [400]  grados: [14157, 14520, 14524, 14518, 14522, 14516, 14520, 14520, 14521, 14882]

Las filas tienen que estar ordenadas por consulta: group dice cuántas filas seguidas forman cada semana (363 semanas de 400) y en XGBoost qid repite la semana en cada fila. Los grados son casi uniformes, unas 14.500 filas por decil (los extremos varían algo por los empates del redondeo).

NDCG: la métrica de los buscadores

Para una lista ordenada por el modelo, la ganancia acumulada descontada de las kk primeras posiciones y su versión normalizada son

DCG@k=∑i=1k2reli−1log⁡2(i+1),NDCG@k=DCG@kIDCG@kDCG@k = \sum_{i=1}^{k} \frac{2^{rel_i} - 1}{\log_2(i + 1)}, \qquad NDCG@k = \frac{DCG@k}{IDCG@k}

donde relirel_i es el grado del documento en la posición ii e IDCG@kIDCG@k el DCG del orden perfecto (Järvelin y Kekäläinen, 2002). Tres decisiones están escondidas en la fórmula: solo cuenta la cabeza de la lista (las kk primeras, aquí 40, el decil que se compraría), la posición se descuenta con un logaritmo y la ganancia es exponencial en el grado: el decil 9 vale 511 y el 8, 255. No todas las librerías usan la misma: LightGBM usa 2rel−12^{rel}-1 por defecto (label_gain), XGBoost también (ndcg_exp_gain=True, que limita los grados a 31), y ndcg_score de scikit-learn usa el grado tal cual.

IN [02]python
from sklearn.metrics import ndcg_score

def M(a, filas):
    """Vector del panel a matriz semanas x 400 acciones."""
    return a[filas].reshape(-1, 400)

def ndcg(p, filas, k=40, rel=None):
    """NDCG@k de cada semana con ganancia 2^rel - 1."""
    gan = 2.0 ** M(decil if rel is None else rel, filas) - 1
    orden = np.argsort(-M(p, filas), axis=1)[:, :k]
    desc = 1 / np.log2(np.arange(2, k + 2))
    dcg = (np.take_along_axis(gan, orden, 1) * desc).sum(1)
    idcg = (-np.sort(-gan, axis=1)[:, :k] * desc).sum(1)
    return dcg / idcg

azar = np.random.default_rng(0).random(len(d))
s0 = TE[:400]                                   # primera semana
propio = ndcg(azar, s0)[0]
sk = ndcg_score([2.0 ** decil[s0] - 1], [azar[s0]], k=40)
sk_lineal = ndcg_score([decil[s0]], [azar[s0]], k=40)
print(f"semana 1: propio {propio:.4f}, scikit-learn con 2^rel-1 "
      f"{sk:.4f}, scikit-learn con rel {sk_lineal:.4f}")
print(f"NDCG@40 medio de un orden al azar (2019-2025): "
      f"{ndcg(azar, TE).mean():.4f}")
OUT [02]NDCG desde cero y contrastado con scikit-learn
semana 1: propio 0.2596, scikit-learn con 2^rel-1 0.2596, scikit-learn con rel 0.5286
NDCG@40 medio de un orden al azar (2019-2025): 0.1957

La función propia coincide con scikit-learn cuando se le pasan las ganancias exponenciales (0,2596 en la primera semana) y no cuando se le pasa el grado (0,5286): la misma lista tiene dos NDCG según la convención. El valor de un orden al azar, 0,1957 de media, es la referencia: un NDCG solo dice algo comparado con el azar y con el orden verdadero.

Cinco modelos

Los cinco se entrenan con los mismos hiperparámetros moderados (200 árboles, tasa 0,03, hojas grandes) y semilla fija; no hay búsqueda, para no añadir el sesgo de selección de la lección 5.5.4:

  • ridge sobre los rangos (α = 1, lección 5.5.1) y LightGBM regresión: puntuales, con la etiqueta en rango normal.
  • LambdaRank de LightGBM con la ganancia por defecto (2rel−12^{rel}-1) y con ganancia lineal (label_gain=[0, 1, …, 9]).
  • XGBoost rank:ndcg (LambdaMART) y rank:pairwise (RankNet). En XGBoost 2.0 y posteriores, los dos construyen los pares por defecto con lambdarank_pair_method="topk", centrado en la cabeza de la lista; el ejercicio 3 prueba los pares al azar.
IN [03]python
import lightgbm as lgb
import xgboost as xgb
from sklearn.linear_model import Ridge

LGB = dict(n_estimators=200, learning_rate=0.03, num_leaves=15,
           min_child_samples=500, subsample=0.7, subsample_freq=1,
           colsample_bytree=0.7, random_state=0, n_jobs=4,
           deterministic=True, force_row_wise=True, verbose=-1)
XGB = dict(n_estimators=200, learning_rate=0.03, max_depth=4,
           min_child_weight=50, subsample=0.7, colsample_bytree=0.7,
           random_state=0, n_jobs=4, tree_method="hist")
pred = {"ridge": Ridge(alpha=1.0).fit(X[TR], y[TR]).predict(X),
        "lightgbm regresión": lgb.LGBMRegressor(**LGB)
        .fit(X[TR], y[TR]).predict(X)}
for nombre, extra in [("lambdarank 2^rel-1", {}),
                      ("lambdarank lineal",
                       {"label_gain": list(range(10))})]:
    m = lgb.LGBMRanker(objective="lambdarank", **extra, **LGB)
    pred[nombre] = m.fit(X[TR], decil[TR], group=grupos).predict(X)
for obj in ("rank:ndcg", "rank:pairwise"):
    m = xgb.XGBRanker(objective=obj, **XGB)
    pred["xgboost " + obj] = m.fit(X[TR], decil[TR],
                                   qid=sem[TR]).predict(X)
pred["oráculo"] = alfa
pred["azar"] = azar
print(list(pred))
OUT [03]Cinco modelos: regresión, LambdaRank y XGBoost
['ridge', 'lightgbm regresión', 'lambdarank 2^rel-1', 'lambdarank lineal', 'xgboost rank:ndcg', 'xgboost rank:pairwise', 'oráculo', 'azar']

NDCG frente a IC

IN [04]python
def ic(p, filas):
    P = M(p, filas).argsort(1).argsort(1).astype(float)
    Rr = M(r, filas).argsort(1).argsort(1).astype(float)
    P -= P.mean(1, keepdims=True)
    Rr -= Rr.mean(1, keepdims=True)
    return (P * Rr).sum(1) / np.sqrt((P**2).sum(1) * (Rr**2).sum(1))

vol = X[:, FE.index("vol_63d")]
def corr_vol(p, filas):
    """Correlación de rangos media con vol_63d en cada semana."""
    return np.mean([np.corrcoef(a, c)[0, 1] for a, c in zip(
        M(p, filas).argsort(1).argsort(1), M(vol, filas))])

pred["solo vol_63d"] = vol
tabla = pd.DataFrame({n: {"NDCG@40": ndcg(p, TE).mean(),
                          "IC": ic(p, TE).mean(),
                          "corr. con vol_63d": corr_vol(p, TE)}
                      for n, p in pred.items()}).T
tabla.round(4)
OUT [04]NDCG frente a IC: dos rankings distintos
                       NDCG@40      IC  corr. con vol_63d
ridge                   0.2386  0.0180             0.0174
lightgbm regresión      0.2433  0.0125             0.0115
lambdarank 2^rel-1      0.2870  0.0086             0.8798
lambdarank lineal       0.2473  0.0101             0.2684
xgboost rank:ndcg       0.2883  0.0093             0.8748
xgboost rank:pairwise   0.2521  0.0096             0.3497
oráculo                 0.2178  0.0256            -0.3155
azar                    0.1957  0.0009             0.0001
solo vol_63d            0.2818  0.0073             0.9780

Los dos rankings no se parecen. Por NDCG@40, ganan XGBoost rank:ndcg (0,2883) y LambdaRank (0,2870), y el oráculo, que conoce el alfa verdadero, queda casi el último (0,2178), apenas por encima del azar. Por IC, el oráculo es el primero (0,0256), la ridge el segundo (0,0180) y los dos rankers con ganancia exponencial, los últimos modelos (0,0086 y 0,0093). La tercera columna explica la contradicción: las puntuaciones de LambdaRank y de rank:ndcg tienen una correlación de rangos de 0,88 con vol_63d. Han aprendido a ordenar por volatilidad. Y ordenar solo por vol_63d, sin modelo, da un NDCG@40 de 0,2818: más que cualquier modelo puntual y más que el oráculo.

Por qué la volatilidad gana en NDCG

IN [05]python
sexto = (M(vol, TE).argsort(1).argsort(1) * 6) // 400   # 0 a 5
D = M(decil, TE)
filas = {}
for q in range(6):
    m = sexto == q
    filas[q + 1] = {"en el decil alto": (D[m] == 9).mean(),
                    "en el decil bajo": (D[m] == 0).mean(),
                    "ganancia media": (2.0 ** D[m] - 1).mean()}
pd.DataFrame(filas).T.rename_axis("sexto de vol_63d").round(3)
OUT [05]Por qué la volatilidad gana en NDCG
                  en el decil alto  en el decil bajo  ganancia media
sexto de vol_63d
1                            0.052             0.057          76.680
2                            0.073             0.066          89.698
3                            0.089             0.081          97.549
4                            0.104             0.095         104.221
5                            0.127             0.121         115.219
6                            0.171             0.165         132.432
Fig. — Probabilidad de que una acción acabe la semana siguiente en el decil alto o en el bajo de rendimiento según el sexto de su volatilidad de 63 sesiones (2019-2025): las volátiles están más en los dos extremos

Una acción del sexto menos volátil de su semana acaba en el decil alto de rendimiento el 5,2 % de las veces; una del más volátil, el 17,1 %. Pero también acaba en el decil bajo el 16,5 % de las veces. La volatilidad no predice la dirección; predice la magnitud, y una acción que se mueve mucho llega más a menudo a los dos extremos. NDCG@40 con ganancia exponencial solo mira la cabeza de la lista y no castiga que una acción de la cabeza acabe en el decil 0 (ganancia 0) más de lo que castiga que acabe en el 4 (ganancia 15). Lo que cuenta es la frecuencia con la que la cabeza contiene deciles 8 y 9, y esa frecuencia la sube la volatilidad: la ganancia media pasa de 77 en el sexto más tranquilo a 132 en el más volátil. Un modelo que optimiza esa métrica aprende exactamente eso.

En un buscador la asimetría tiene sentido: un documento irrelevante en la cabeza no cuesta nada. En una cartera, una acción comprada que acaba en el decil 0 cuesta tanto como gana una del decil 9. El objetivo del ranker y el de la cartera no son el mismo.

Lo que da cada modelo en una cartera

La convención es la del nivel (lección 5.1.3): cada semana, larga en las 40 primeras y corta en las 40 últimas, pesos de ±1/40 (nominal bruto de 2), rendimientos simples y 10 pb por lado sobre la rotación. Dos columnas más: la beta de la cartera (suma de pesos por la beta verdadera de cada acción, de panel5_activos_verdad.csv; con datos reales sería una beta estimada con el pasado) y el Sharpe neto sin beta, restando a cada semana la beta de la cartera por el rendimiento medio de las 400 acciones.

IN [06]python
def cartera(p, filas, coste_pb=10):
    """Larga el decil alto y corta el bajo (40 y 40, pesos ±1/40):
    rendimientos semanales, rotación y beta de la cartera."""
    P, Rr, B = M(p, filas), M(r, filas), M(b, filas)
    o = P.argsort(1)
    w = np.zeros_like(Rr)
    np.put_along_axis(w, o[:, -40:], 1 / 40, 1)
    np.put_along_axis(w, o[:, :40], -1 / 40, 1)
    bruto = (w * Rr).sum(1)
    rot = np.abs(np.diff(w, axis=0, prepend=0)).sum(1)
    beta_p = (w * B).sum(1)
    mercado = Rr.mean(1)
    return {"bruto": bruto, "neto": bruto - coste_pb / 1e4 * rot,
            "rot": rot, "beta": beta_p,
            "neto_sin_beta": bruto - coste_pb / 1e4 * rot
            - beta_p * mercado}

sr = lambda x: x.mean() / x.std() * np.sqrt(52)
C = {n: cartera(p, TE) for n, p in pred.items()}
eco = pd.DataFrame({n: {
    "anual bruto": c["bruto"].mean() * 52,
    "sharpe bruto": sr(c["bruto"]),
    "rotación": c["rot"].mean(), "beta": c["beta"].mean(),
    "sharpe neto": sr(c["neto"]),
    "neto sin beta": sr(c["neto_sin_beta"])} for n, c in C.items()}).T
eco.round(3)
OUT [06]Carteras por deciles: bruto, neto, beta
                       anual bruto  sharpe bruto  rotación   beta  sharpe neto  neto sin beta
ridge                        0.128         1.364     2.286 -0.027        0.096          0.188
lightgbm regresión           0.101         1.265     2.622  0.000       -0.434         -0.266
lambdarank 2^rel-1           0.086         0.618     1.297  0.470        0.136         -0.542
lambdarank lineal            0.127         1.476     2.575  0.165       -0.077         -0.276
xgboost rank:ndcg            0.099         0.744     1.272  0.473        0.247         -0.386
xgboost rank:pairwise        0.113         1.309     2.552  0.133       -0.234         -0.391
oráculo                      0.167         1.676     2.648 -0.218        0.295          0.745
azar                        -0.006        -0.110     3.599 -0.005       -3.411         -3.410
solo vol_63d                 0.104         0.654     0.438  0.617        0.510          0.034
Fig. — Sharpe anual de la cartera larga-corta por deciles (40 y 40 acciones, rebalanceo semanal, 2019-2025) de cada modelo: bruto, neto de 10 pb por lado sobre la rotación y neto sin la parte explicada por su beta

La figura ordena las tres columnas de Sharpe por modelo. Tres lecturas:

  • Bruto, LambdaRank con ganancia lineal (1,48) y la ridge (1,36) están arriba, cerca del oráculo (1,68). Los rankers exponenciales dan la mitad (0,62 y 0,74).
  • Neto, los rankers exponenciales parecen salir mejor parados (0,14 y 0,25 frente a 0,10 de la ridge): rotan la mitad (1,30 y 1,27 semanales frente a 2,29), porque la volatilidad cambia despacio. Ordenar solo por vol_63d rota un 44 % y da el mejor neto de todos (0,51).
  • Sin beta, el espejismo se deshace. Las carteras de los rankers exponenciales tienen una beta de 0,47 (0,62 la de la volatilidad sola): son largas en acciones volátiles y cortas en tranquilas, una apuesta por el mercado que en 2019-2025 sube. Quitando la beta, LambdaRank da −0,54 y rank:ndcg, −0,39; la volatilidad sola, 0,03. La ridge, con beta −0,03, conserva 0,19, y el oráculo 0,75 (su beta es negativa, −0,22, por la prima de baja volatilidad plantada).

La figura de la cabecera resume la lección: en este panel, cuanto más NDCG@40, peor resultado económico.

¿Supera alguno a la ridge?

La comparación justa es emparejada: la misma semana, la diferencia de rendimiento neto sin beta entre cada modelo y la ridge, con su error típico.

IN [07]python
base = C["ridge"]["neto_sin_beta"]
for n in ["lightgbm regresión", "lambdarank 2^rel-1",
          "lambdarank lineal", "xgboost rank:ndcg",
          "xgboost rank:pairwise"]:
    dif = C[n]["neto_sin_beta"] - base
    t = dif.mean() / dif.std() * np.sqrt(len(dif))
    print(f"{n:22s} {dif.mean() * 52:+.2%} anual  t = {t:+.2f}")
OUT [07]¿Alguno supera a la ridge? Diferencias emparejadas
lightgbm regresión     -3.69% anual  t = -1.29
lambdarank 2^rel-1     -6.57% anual  t = -1.45
lambdarank lineal      -3.77% anual  t = -1.20
xgboost rank:ndcg      -5.07% anual  t = -1.16
xgboost rank:pairwise  -4.67% anual  t = -1.55

Todos pierden contra la ridge, de −3,7 % a −6,6 % anual, con t entre −1,2 y −1,6: ninguna diferencia es significativa por sí sola, pero van todas en la misma dirección, y la ridge es la más barata y la más fácil de explicar. Que la mejor sea la baseline no la convierte en una estrategia: con seis modelos probados en el mismo periodo, la mejor también está seleccionada.

IN [08]python
def psr(s, umbral, t, asim, curt):
    den = np.sqrt(1 - asim * s + (curt - 1) / 4 * s**2)
    return norm.cdf((s - umbral) * np.sqrt(t - 1) / den)

def max_esperado(n):
    gm = np.euler_gamma
    return ((1 - gm) * norm.ppf(1 - 1 / n)
            + gm * norm.ppf(1 - 1 / (n * np.e)))

modelos = ["ridge", "lightgbm regresión", "lambdarank 2^rel-1",
           "lambdarank lineal", "xgboost rank:ndcg",
           "xgboost rank:pairwise"]
S = pd.DataFrame({n: C[n]["neto_sin_beta"] for n in modelos})
srs = S.mean() / S.std()
k = srs.idxmax()
T, asim, curt = len(S), S[k].skew(), S[k].kurt() + 3
sr0 = np.sqrt(srs.var()) * max_esperado(len(modelos))
print(f"mejor: {k}, Sharpe anual {srs[k] * np.sqrt(52):.2f}")
print(f"PSR(0) {psr(srs[k], 0, T, asim, curt):.3f}; "
      f"DSR con N = {len(modelos)}: "
      f"{psr(srs[k], sr0, T, asim, curt):.3f}")
OUT [08]El ganador, descontado por los intentos
mejor: ridge, Sharpe anual 0.19
PSR(0) 0.692; DSR con N = 6: 0.359

El Sharpe de 0,19 de la ridge tiene un PSR(0) de 0,692 y un Deflated Sharpe de 0,359 con N = 6: no hay evidencia de que ninguna de estas carteras tenga un rendimiento positivo después de costes, beta y selección (lecciones 2.5.4 y 5.4.4). Es lo que el README del panel anticipa: con 10 pb por lado, ni el oráculo pasa de un Sharpe neto modesto, y la rotación semanal se lleva casi todo (la lección 5.1.3 suaviza la señal para rotar menos).

Cuándo tiene sentido el learning to rank

Nada de lo anterior dice que el LTR no sirva en finanzas; dice que el objetivo de un buscador no es el de una cartera. Poh, Lim, Zohren y Roberts (2021) incorporan rankers por pares y por listas a una estrategia de momentum transversal y, en sus datos, informan de Sharpes unas tres veces mayores que los de ordenar con heurísticas o con las salidas de regresiones y clasificadores. Es un resultado con sus datos y su validación; al replicarlo, lo primero es comprobar la trampa de esta lección: cuánto de la mejora es magnitud y beta. Para que un ranker sirva a una cartera larga-corta conviene:

  • Ganancias que pesen igual los dos extremos. Ganancia lineal, grados simétricos o dos rankers, uno para cada lado; el ejercicio 1 muestra que entrenar un segundo LambdaRank para el lado corto no basta si la ganancia sigue siendo exponencial.
  • Etiquetas sin la magnitud que no se quiere. Rendimientos residuales de beta y de sector, o tipificados por una volatilidad conocida en la fecha, para que la dispersión de cada acción no decida el grado.
  • Métricas de la cartera. Seleccionar el modelo por IC, Sharpe neto y beta, nunca solo por NDCG, y comparar con la baseline puntual en las mismas semanas.
  • Un horizonte y un tamaño de cabeza que sean los de la cartera. lambdarank_truncation_level (30 por defecto en LightGBM) y el kk del NDCG deberían ser el número de acciones que se compran.

Errores típicos

  • Leer el NDCG sin la referencia del azar y del oráculo. 0,287 parece mucho más que 0,218, y es la diferencia entre apostar por la volatilidad y conocer el alfa.
  • Confundir convenciones de NDCG. La misma lista da 0,26 o 0,53 según la ganancia; compara siempre con la misma función.
  • Filas sin ordenar por consulta. group y qid suponen filas contiguas de cada semana; si no lo están, el ranker compara acciones de semanas distintas sin avisar.
  • Grados que mezclan magnitud y dirección. Los deciles de rendimiento bruto premian la volatilidad en una métrica asimétrica.
  • Atribuir a la selección lo que es beta. Una larga-corta de volátiles contra tranquilas gana cuando el mercado sube.
  • No comparar con la baseline puntual. La ridge gana a todos los rankers en este panel.

Ejercicios

  1. Entrena un segundo LambdaRank con los grados invertidos (9 − decil) para elegir las acciones que cortar, y compara la correlación de sus puntuaciones con vol_63d y su IC con los del LambdaRank para el lado largo.
Solución
IN [09]python
m = lgb.LGBMRanker(objective="lambdarank", **LGB)
corto = -m.fit(X[TR], 9 - decil[TR], group=grupos).predict(X)
largo = pred["lambdarank 2^rel-1"]
for n, p in [("largo", largo), ("corto", corto)]:
    print(f"{n}: corr. con vol_63d {corr_vol(p, TE):+.2f}, "
          f"IC {ic(p, TE).mean():+.4f}")
OUT [09]LambdaRank para el lado corto
largo: corr. con vol_63d +0.88, IC +0.0086
corto: corr. con vol_63d -0.89, IC -0.0058

Las dos correlaciones son casi iguales y de signo opuesto (+0,88 y −0,89), porque corto es la puntuación del ranker de perdedores cambiada de signo: los dos rankers ponen arriba las acciones más volátiles. El de ganadores las quiere comprar y el de perdedores, vender: las mismas acciones. El IC del ranker de cortos es negativo (−0,0058): ordenar por «probabilidad de estar en el decil bajo» acaba poniendo arriba acciones volátiles que, en un mercado que sube y con más beta, de media suben más. Con ganancia exponencial, separar los lados no resuelve el problema: cada ranker sigue aprendiendo magnitud.

  1. Calcula el NDCG@40 con ganancia lineal y el NDCG de toda la lista (k = 400) con ndcg_score para la ridge, los dos LambdaRank, el oráculo, vol_63d sola y el azar. ¿Qué versión separa mejor el oráculo del azar?
Solución
IN [10]python
def ndcg_sk(p, k=None):
    return np.mean([ndcg_score([dd], [pp], k=k) for dd, pp
                    in zip(M(decil, TE), M(p, TE))])

lineal = {}
for n in ["ridge", "lambdarank 2^rel-1", "lambdarank lineal",
          "oráculo", "solo vol_63d", "azar"]:
    lineal[n] = {"NDCG@40 2^rel-1": ndcg(pred[n], TE).mean(),
                 "NDCG@40 lineal": ndcg_sk(pred[n], 40),
                 "NDCG@400 lineal": ndcg_sk(pred[n])}
pd.DataFrame(lineal).T.round(4)
OUT [10]NDCG con relevancia lineal y truncado en las 400
                    NDCG@40 2^rel-1  NDCG@40 lineal  NDCG@400 lineal
ridge                        0.2386          0.5139           0.8733
lambdarank 2^rel-1           0.2870          0.5023           0.8712
lambdarank lineal            0.2473          0.5147           0.8733
oráculo                      0.2178          0.5210           0.8748
solo vol_63d                 0.2818          0.5026           0.8709
azar                         0.1957          0.5007           0.8698

Con ganancia lineal, el sesgo de la volatilidad casi desaparece (vol_63d sola baja a 0,5026, al nivel del azar, 0,5007) y el oráculo queda primero (0,5210)... a dos centésimas del azar. Con toda la lista, todo queda entre 0,870 y 0,875. La versión lineal ordena mejor los modelos, pero ninguna variante del NDCG tiene la resolución del IC en un problema con esta relación señal/ruido: los valores de referencia son altos y las diferencias, pequeñas.

  1. Entrena rank:pairwise de XGBoost con pares al azar en toda la lista (lambdarank_pair_method="mean", un par por acción) en lugar del topk por defecto. ¿Desaparece el sesgo de la volatilidad? ¿Mejora la cartera?
Solución
IN [11]python
m = xgb.XGBRanker(objective="rank:pairwise",
                  lambdarank_pair_method="mean",
                  lambdarank_num_pair_per_sample=1, **XGB)
p = m.fit(X[TR], decil[TR], qid=sem[TR]).predict(X)
c = cartera(p, TE)
print(f"NDCG@40 {ndcg(p, TE).mean():.4f}, IC {ic(p, TE).mean():.4f}, "
      f"corr. con vol_63d {corr_vol(p, TE):+.3f}")
print(f"Sharpe bruto {sr(c['bruto']):.2f}, neto {sr(c['neto']):.2f}, "
      f"rotación {c['rot'].mean():.2f}")
OUT [11]Pares al azar en toda la lista
NDCG@40 0.2191, IC 0.0036, corr. con vol_63d +0.027
Sharpe bruto 0.48, neto -1.88, rotación 2.91

Con pares al azar, el sesgo desaparece: la correlación con vol_63d baja a 0,027 y el NDCG@40, a 0,2191, el nivel del oráculo. Pero el modelo apenas aprende la dirección (IC de 0,0036, algo más de un tercio del de topk, 0,0096, y una quinta parte del de la ridge) y su cartera, con un Sharpe bruto de 0,48 y una rotación del 291 % semanal, pierde con los costes (−1,88). Un par por acción y semana es poca información para una señal de este tamaño, y cada par solo dice cuál de las dos acciones quedó por encima, sin cuánto. Quitar el sesgo no basta para ganar a la regresión puntual.

Respuesta plegada en cada pregunta

Autoevaluación

  1. LambdaRank con la ganancia por defecto (2^rel − 1) tiene el mejor NDCG@40 de los modelos (0,287), por encima del oráculo (0,218). ¿Qué ha aprendido?

    • AUna señal mejor que el alfa verdadero
    • BA poner arriba las acciones más volátiles (correlación de rangos 0,88 con vol_63d), que caen más a menudo en el decil alto, donde la ganancia exponencial pesa más
    • CA predecir la dirección con menos ruido
    • DUna fuga de la etiqueta
    Ver respuesta

    Respuesta B. A poner arriba las acciones más volátiles (correlación de rangos 0,88 con vol_63d), que caen más a menudo en el decil alto, donde la ganancia exponencial pesa más

    Una acción del sexto más volátil acaba en el decil alto el 17,1 % de las semanas, frente al 5,2 % del menos volátil, y también en el bajo. NDCG@40 solo mira la cabeza de la lista y con 2^rel − 1 el decil 9 vale 511 y el 8, 255: acertar la magnitud paga más que acertar el signo. Ordenar solo por vol_63d ya da 0,282.

  2. ¿Qué estructura de datos necesita LGBMRanker.fit y por qué el orden de las filas importa?

    • ANinguna especial: es como un regresor
    • BUna matriz de pares de acciones
    • CUna columna con la fecha como feature
    • DLas filas ordenadas por consulta (semana) y group con el número de filas de cada una (aquí 400 por semana): el objetivo compara solo filas de la misma consulta
    Ver respuesta

    Respuesta D. Las filas ordenadas por consulta (semana) y group con el número de filas de cada una (aquí 400 por semana): el objetivo compara solo filas de la misma consulta

    LambdaRank y los objetivos de XGBoost construyen pares (o listas) dentro de cada consulta. En XGBoost se pasa qid por fila, también ordenado. Mezclar semanas en una consulta compararía acciones de mercados distintos.

  3. La cartera de LambdaRank tiene un Sharpe neto de 0,14, mayor que el de la ridge (0,10), pero su Sharpe neto sin beta es −0,54 frente a 0,19. ¿Cuál es la lectura correcta?

    • ALambdaRank es mejor porque rota la mitad
    • BLa ridge está sobreajustada
    • CLa cartera de LambdaRank es larga en beta (0,47): en 2019-2025 el mercado sintético sube y esa beta paga; sin ella, el ranker pierde. La ridge, con beta ≈ 0, gana algo por selección
    • DEl Sharpe sin beta no tiene sentido en una cartera larga-corta
    Ver respuesta

    Respuesta C. La cartera de LambdaRank es larga en beta (0,47): en 2019-2025 el mercado sintético sube y esa beta paga; sin ella, el ranker pierde. La ridge, con beta ≈ 0, gana algo por selección

    Una larga-corta no es neutral a la beta si los dos lados tienen betas distintas. Larga en volátiles y corta en tranquilas es una apuesta por el mercado que un índice hace más barata.

  4. Ninguno de los cinco modelos de árboles supera a la ridge sin beta: las diferencias van de −3,7 % a −6,6 % anual con t entre −1,2 y −1,6. ¿Qué se concluye?

    • AQue no hay evidencia de que ninguno mejore a la baseline lineal; las diferencias negativas no son significativas una a una, pero todas van en la misma dirección y la ridge es además la más barata
    • BQue los rankers son peores con certeza
    • CQue la ridge tiene una fuga
    • DQue hay que ajustar más hiperparámetros hasta que alguno gane
    Ver respuesta

    Respuesta A. Que no hay evidencia de que ninguno mejore a la baseline lineal; las diferencias negativas no son significativas una a una, pero todas van en la misma dirección y la ridge es además la más barata

    Con siete años de semanas, una diferencia de Sharpe de 0,5 no se distingue del ruido. Lo que sí se sabe: el modelo más simple no pierde contra ninguno, y buscar hasta que uno gane es el sobreajuste de la validación de la lección 5.5.4.

  5. Con NDCG@400 y ganancia lineal, el oráculo saca 0,875 y un orden al azar 0,870. ¿Qué indica?

    • AQue el oráculo apenas tiene señal
    • BQue esa versión del NDCG es casi insensible a la señal de un problema con este ruido: el valor de referencia del azar es alto y las diferencias quedan en el tercer decimal
    • CQue hay que usar siempre NDCG@400
    • DQue scikit-learn calcula mal el NDCG
    Ver respuesta

    Respuesta B. Que esa versión del NDCG es casi insensible a la señal de un problema con este ruido: el valor de referencia del azar es alto y las diferencias quedan en el tercer decimal

    Con relevancias de 0 a 9 casi uniformes, cualquier orden acumula mucha ganancia. Una métrica de ranking solo se lee contra el azar y contra la verdad, y para una cartera se completa con el IC y el resultado económico.

Ideas clave

  • Learning to rank trata cada semana como una consulta: grados de relevancia (aquí deciles 0-9) y un objetivo que compara acciones de la misma semana (group en LightGBM, qid en XGBoost).
  • NDCG@k con ganancia 2^rel − 1 mira solo la cabeza de la lista y premia la magnitud: las acciones volátiles están más en los dos deciles extremos (17,1 % y 16,5 % frente al 10 % del azar), y ordenar solo por volatilidad da más NDCG@40 que el oráculo.
  • LambdaRank y rank:ndcg aprenden esa volatilidad (correlación 0,88 con vol_63d): carteras con beta 0,47, que rotan la mitad y cuyo Sharpe neto sin beta es negativo.
  • Con ganancia lineal o pares, el sesgo baja pero no desaparece, y ningún ranker supera a la ridge: Sharpe neto sin beta 0,19 frente a −0,27 a −0,54.
  • La métrica de selección es la de la cartera: IC, Sharpe neto de costes y sin beta, con diferencias emparejadas y descontando los modelos probados (DSR del mejor con N = 6: 0,36).

Recursos

Datos