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.
- Módulo
- 5.5 · Modelos clásicos
- Duración
- 70 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
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:
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:pairwiseen 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 yrank:ndcgen 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.
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
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())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 primeras posiciones y su versión normalizada son
donde es el grado del documento en la posición e 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
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
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.
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}")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.1957La 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 () y con ganancia lineal
(
label_gain=[0, 1, …, 9]). - XGBoost
rank:ndcg(LambdaMART) yrank:pairwise(RankNet). En XGBoost 2.0 y posteriores, los dos construyen los pares por defecto conlambdarank_pair_method="topk", centrado en la cabeza de la lista; el ejercicio 3 prueba los pares al azar.
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))['ridge', 'lightgbm regresión', 'lambdarank 2^rel-1', 'lambdarank lineal', 'xgboost rank:ndcg', 'xgboost rank:pairwise', 'oráculo', 'azar']NDCG frente a IC
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) 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.9780Los 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
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) 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.432Una 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.
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) 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.034La 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_63drota 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.
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}")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.55Todos 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.
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}")mejor: ridge, Sharpe anual 0.19
PSR(0) 0.692; DSR con N = 6: 0.359El 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 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.
groupyqidsuponen 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
- Entrena un segundo LambdaRank con los grados invertidos (
9 − decil) para elegir las acciones que cortar, y compara la correlación de sus puntuaciones convol_63dy su IC con los del LambdaRank para el lado largo.
Solución
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}")largo: corr. con vol_63d +0.88, IC +0.0086
corto: corr. con vol_63d -0.89, IC -0.0058Las 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.
- Calcula el NDCG@40 con ganancia lineal y el NDCG de toda la lista (k = 400) con
ndcg_scorepara la ridge, los dos LambdaRank, el oráculo,vol_63dsola y el azar. ¿Qué versión separa mejor el oráculo del azar?
Solución
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) 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.8698Con 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.
- Entrena
rank:pairwisede XGBoost con pares al azar en toda la lista (lambdarank_pair_method="mean", un par por acción) en lugar deltopkpor defecto. ¿Desaparece el sesgo de la volatilidad? ¿Mejora la cartera?
Solución
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}")NDCG@40 0.2191, IC 0.0036, corr. con vol_63d +0.027
Sharpe bruto 0.48, neto -1.88, rotación 2.91Con 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
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ásUna 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_63dya da 0,282.¿Qué estructura de datos necesita
LGBMRanker.fity 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
groupcon 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
groupcon el número de filas de cada una (aquí 400 por semana): el objetivo compara solo filas de la misma consultaLambdaRank y los objetivos de XGBoost construyen pares (o listas) dentro de cada consulta. En XGBoost se pasa
qidpor fila, también ordenado. Mezclar semanas en una consulta compararía acciones de mercados distintos.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.
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.
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 (
groupen LightGBM,qiden 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:ndcgaprenden esa volatilidad (correlación 0,88 convol_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
Documentación
Libros
- Burges, C. J. C. (2010): From RankNet to LambdaRank to LambdaMART: An Overview. Microsoft Research, MSR-TR-2010-82
- 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
- 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