---
title: "5.5.6 Learning to rank para selección de acciones"
description: "LambdaRank de LightGBM y rankers de XGBoost frente a regresión y ridge en cortes semanales: NDCG, IC y carteras por deciles netas de costes y de beta."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/learning-to-rank-para-seleccion-de-acciones/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/learning-to-rank-para-seleccion-de-acciones.png"
---

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

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.5 · Modelos clásicos
- Duración: 70 min
- Requisitos: [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/), [5.4.4 · Backtest de señales de ML con costes y Deflated Sharpe](https://pythonparatrading.com/curso-python-trading/nivel-5/backtest-de-senales-de-ml-y-deflated-sharpe/), [5.5.1 · Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística](https://pythonparatrading.com/curso-python-trading/nivel-5/ridge-lasso-elastic-net-y-logistica/), [5.5.3 · Gradient boosting: XGBoost, LightGBM y CatBoost](https://pythonparatrading.com/curso-python-trading/nivel-5/xgboost-lightgbm-y-catboost/)

## Al terminar esta lección

- Plantear la selección de acciones como un problema de ranking: semanas como consultas, grados de relevancia por deciles y la estructura `group`/`qid` de LightGBM y XGBoost.
- Calcular el NDCG@k desde cero, contrastarlo con scikit-learn y saber qué cambia con la ganancia exponencial o lineal.
- Entrenar LambdaRank, `rank:ndcg` y `rank:pairwise` y compararlos con una regresión puntual y una ridge con IC, NDCG y carteras por deciles netas de costes.
- Detectar cuándo un ranker aprende volatilidad en lugar de dirección, medir la beta de su cartera y comparar los modelos con diferencias emparejadas y Deflated Sharpe.

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

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

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

```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())
```
```output
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 $k$ primeras posiciones y su versión
normalizada son

$$
DCG@k = \sum_{i=1}^{k} \frac{2^{rel_i} - 1}{\log_2(i + 1)}, \qquad NDCG@k = \frac{DCG@k}{IDCG@k}
$$

donde $rel_i$ es el grado del documento en la posición $i$ e $IDCG@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 $k$
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
$2^{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.

```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}")
```
```output
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 ($2^{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.

```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))
```
```output
['ridge', 'lightgbm regresión', 'lambdarank 2^rel-1', 'lambdarank lineal', 'xgboost rank:ndcg', 'xgboost rank:pairwise', 'oráculo', 'azar']
```

## NDCG frente a IC

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

```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)
```
```output
                  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.* Seis pares de barras. En el sexto menos volátil, un 5,2 % de las acciones acaba en el decil alto y un 5,7 % en el bajo; la probabilidad sube con la volatilidad hasta el 17,1 % (decil alto, destacado) y el 16,5 % (decil bajo) en el sexto más volátil. Una línea discontinua marca el 10 % que daría el azar.

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.

```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)
```
```output
                       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.* Ocho filas con tres marcas. Brutos de 0,62 a 1,68; netos de -0,43 a 0,51; netos sin beta (destacados) de -0,54 a 0,75. La ridge: 1,36, 0,10 y 0,19. LambdaRank con ganancia exponencial: 0,62, 0,14 y -0,54. XGBoost rank:ndcg: 0,74, 0,25 y -0,39. Solo vol_63d: 0,65, 0,51 y 0,03. El oráculo: 1,68, 0,30 y 0,75. Los rankers con NDCG exponencial pierden poco con los costes porque rotan la mitad, pero su resultado es 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**.

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

Una cartera larga-corta con igual nominal en los dos lados no es neutral al mercado si los dos lados tienen distinta
beta. Antes de atribuir un Sharpe a la selección, mide la beta de la cartera y el resultado sin ella; la atribución
de una cartera a sus factores está en la lección 4.1.6.

</aside>

## ¿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.

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

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

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

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

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

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

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

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

   - A. Una señal mejor que el alfa verdadero
   - 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
   - C. A predecir la dirección con menos ruido
   - D. Una fuga de la etiqueta

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

   - A. Ninguna especial: es como un regresor
   - B. Una matriz de pares de acciones
   - C. Una columna con la fecha como feature
   - 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

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

   - A. LambdaRank es mejor porque rota la mitad
   - B. La ridge está sobreajustada
   - 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
   - D. El Sharpe sin beta no tiene sentido en una cartera larga-corta

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

   - 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
   - B. Que los rankers son peores con certeza
   - C. Que la ridge tiene una fuga
   - D. Que hay que ajustar más hiperparámetros hasta que alguno gane

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

   - A. Que el oráculo apenas tiene señal
   - 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
   - C. Que hay que usar siempre NDCG@400
   - D. Que scikit-learn calcula mal el NDCG

   *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

### Research

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

### Documentación

- [LightGBM: parámetros de ranking (lambdarank, label_gain, lambdarank_truncation_level, rank_xendcg)](https://lightgbm.readthedocs.io/en/latest/Parameters.html) · lightgbm.readthedocs.io
- [XGBoost: learning to rank (rank:ndcg, rank:pairwise, qid, construcción de pares)](https://xgboost.readthedocs.io/en/stable/tutorials/learning_to_rank.html) · xgboost.readthedocs.io
- [scikit-learn: ndcg_score](https://scikit-learn.org/stable/modules/generated/sklearn.metrics.ndcg_score.html) · scikit-learn.org

### Libros

- [Burges, C. J. C. (2010): From RankNet to LambdaRank to LambdaMART: An Overview. Microsoft Research, MSR-TR-2010-82](https://www.microsoft.com/en-us/research/publication/from-ranknet-to-lambdarank-to-lambdamart-an-overview/) · microsoft.com
- [Järvelin, K. y Kekäläinen, J. (2002): Cumulated Gain-Based Evaluation of IR Techniques. ACM Transactions on Information Systems 20(4), 422-446](https://doi.org/10.1145/582415.582418) · doi.org
- [Poh, D., Lim, B., Zohren, S. y Roberts, S. (2021): Building Cross-Sectional Systematic Strategies by Learning to Rank. The Journal of Financial Data Science 3(2), 70-86](https://doi.org/10.3905/jfds.2021.1.060) · doi.org

### Datos

- [Datos del nivel 5: etiquetas del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_etiquetas.parquet) · Descarga · panel5_etiquetas.parquet

Lección anterior: [5.5.5 · Aprendizaje no supervisado: PCA, clustering y autoencoders](https://pythonparatrading.com/curso-python-trading/nivel-5/pca-clustering-y-autoencoders/)

Lección siguiente: [5.6.1 · Keras 3: un modelo, varios motores de cálculo](https://pythonparatrading.com/curso-python-trading/nivel-5/keras-3-multi-backend/)
