Lección 5.7.5 · 75 min

Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe

La receta clásica predice rendimientos y después optimiza la cartera con esas predicciones. El enfoque end-to-end entrena el modelo de predicción por lo que importa al final, el Sharpe de la cartera, haciendo pasar el gradiente a través del optimizador. Esta lección construye esa capa en PyTorch, la entrena con una pérdida de Sharpe y la evalúa en walk-forward contra las carteras sencillas del nivel 4, con la verdad del universo para separar habilidad de suerte.

Fig. — Sharpe realizado (2011-2025, ±2 errores típicos) y Sharpe verdadero de los pesos de seis carteras en el universo del nivel 4: referencias simples, predecir y optimizar (PTO) y dos carteras end-to-end
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

Predecir y luego optimizar, o aprender la cartera

Las lecciones 4.1.1 a 4.1.4 construyeron carteras en dos pasos: estimar rendimientos esperados y covarianzas, y pasar esas estimaciones a un optimizador. Si los rendimientos los predice un modelo de machine learning, la receta es la misma: predecir y luego optimizar (PTO). El modelo se entrena por el error de sus predicciones, y el optimizador no participa en el entrenamiento.

El problema es que el error cuadrático de la predicción no es lo que se quiere minimizar. A una cartera le da igual acertar el rendimiento de un activo que no va a comprar; le importa mucho el orden entre los activos que se disputan el peso y lo que pase en las colas. El enfoque end-to-end (E2E), o decision-focused learning, mete el optimizador dentro del modelo y entrena por la métrica final: el Sharpe de la cartera resultante. Para eso el gradiente tiene que atravesar el optimizador, que debe ser una capa diferenciable.

gradienteFeaturesModelo muOptimizadorCovarianzaPesosRendimientoPérdida Sharpe
Fuente Mermaid
flowchart TB
  F[Features] --> M[Modelo mu]
  M --> O[Optimizador]
  S[Covarianza] --> O
  O --> W[Pesos]
  W --> P[Rendimiento]
  P --> L[Pérdida Sharpe]
  L -. gradiente .-> M

En PTO, la flecha de vuelta sale de un error de predicción y no pasa por el optimizador. Elmachtoub y Grigas (2017) formalizaron la idea general (Smart predict, then optimize); Zhang, Zohren y Roberts (2020) entrenaron redes que producen pesos con una capa softmax y una pérdida de Sharpe; Butler y Kwon (2021) integraron la predicción en una capa de media-varianza. La promesa es razonable. La pregunta de esta lección es la de siempre: si sobrevive a un walk-forward honesto frente a referencias simples.

El universo, las features y la covarianza

El universo es el de 38 índices de rentabilidad total del nivel 4 (universo4_*), sin MONET, el monetario: su exceso sobre el tipo libre es casi nulo con una volatilidad del 0,2 %, y una pérdida de Sharpe sin más restricciones puede acabar concentrada en un activo así (pregunta 5 de la autoevaluación). Quedan 37.

Cada fin de mes, para cada activo, tres features tipificadas entre activos (momentum de 12 meses sin el último, rendimiento del último mes y volatilidad de tres meses), la covarianza del último año y el rendimiento del mes siguiente en exceso del tipo libre. La covarianza es de Ledoit-Wolf sobre los rendimientos tipificados, es decir, encogiendo la correlación y no la covarianza: la lección 4.1.2 mostró que el objetivo de identidad escalada estropea la mínima varianza cuando las volatilidades van del 1 % al 30 %.

IN [01]python
import numpy as np
import pandas as pd
import torch
from sklearn.covariance import LedoitWolf

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)
torch.set_default_dtype(torch.float64)

precios = pd.read_parquet("universo4_precios.parquet")
precios = precios.set_index("fecha").drop(columns="MONET")
rf = pd.read_parquet("universo4_factores_diarios.parquet")
rf = rf.set_index("fecha")["rf"]
cols = precios.columns.tolist()
r = precios.pct_change().iloc[1:]
ex = r.sub(rf.loc[r.index], axis=0)          # exceso diario simple
logp = np.log(precios).iloc[1:]               # alineado con r
fin_mes = r.index.to_series().groupby(r.index.to_period("M")).max()
print(f"{len(cols)} activos | {r.index[0].date()} a "
      f"{r.index[-1].date()} | {len(fin_mes)} fines de mes")
OUT [01]Rendimientos en exceso y fines de mes
37 activos | 2006-01-04 a 2025-12-31 | 240 fines de mes
IN [02]python
def features(i):
    """Features de cada activo al cierre de la sesión i, tipificadas
    entre activos: momentum 12-1, rendimiento de 1 mes, vol. 3 meses."""
    f = pd.DataFrame({
        "mom_12_1": logp.iloc[i - 21] - logp.iloc[i - 252],
        "ret_1m": logp.iloc[i] - logp.iloc[i - 21],
        "vol_3m": ex.iloc[i - 62:i + 1].std() * np.sqrt(252)})
    return ((f - f.mean()) / f.std()).to_numpy()

def covarianza(i):
    """Ledoit-Wolf de la correlación del último año, mensual."""
    x = ex.iloc[i - 251:i + 1].to_numpy()
    sd = x.std(axis=0)
    corr = LedoitWolf().fit(x / sd).covariance_
    return corr * np.outer(sd, sd) * 21

F, S, R, meses = [], [], [], []
fines = fin_mes.to_numpy()
for d0, d1 in zip(fines[:-1], fines[1:]):
    i0, i1 = r.index.get_loc(d0), r.index.get_loc(d1)
    if i0 < 252:
        continue
    F.append(features(i0))
    S.append(covarianza(i0))
    tramo = r.index[i0 + 1:i1 + 1]
    R.append((1 + r.loc[tramo]).prod().to_numpy()
             - (1 + rf.loc[tramo]).prod())
    meses.append(pd.Timestamp(d0))
F, S, R = (torch.tensor(np.array(a)) for a in (F, S, R))
meses = pd.DatetimeIndex(meses)
print("F", tuple(F.shape), "| S", tuple(S.shape), "| R", tuple(R.shape))
print("decisiones:", meses[0].date(), "a", meses[-1].date())
OUT [02]Features, covarianza de Ledoit-Wolf y rendimiento siguiente
F (227, 37, 3) | S (227, 37, 37) | R (227, 37)
decisiones: 2007-01-31 a 2025-11-28

Cada decisión se toma al cierre del último día del mes con datos hasta ese día, y su resultado es el rendimiento del mes siguiente. La primera decisión necesita un año de historia (enero de 2007); la última, de noviembre de 2025, cubre diciembre.

Una capa de optimización diferenciable

La capa resuelve, para unos rendimientos esperados μ^\hat\mu que saldrán del modelo, el problema de media-varianza de solo largos:

w⋆(μ^)=arg⁡min⁡w γ2 w′Σw−μ^′ws.a.1′w=1,  w≥0w^\star(\hat\mu) = \arg\min_{w} \ \tfrac{\gamma}{2}\, w'\Sigma w - \hat\mu' w \quad \text{s.a.} \quad \mathbf{1}'w = 1,\ \ w \ge 0

Si solo hubiera la restricción de presupuesto, las condiciones de Karush-Kuhn-Tucker darían una fórmula cerrada, diferenciable sin más: la cartera de mínima varianza más una inclinación proporcional a μ^\hat\mu,

w⋆=Σ−111′Σ−11+1γ(Σ−1μ^−Σ−11 1′Σ−1μ^1′Σ−11)w^\star = \frac{\Sigma^{-1}\mathbf{1}}{\mathbf{1}'\Sigma^{-1}\mathbf{1}} + \frac{1}{\gamma}\left(\Sigma^{-1}\hat\mu - \Sigma^{-1}\mathbf{1}\, \frac{\mathbf{1}'\Sigma^{-1}\hat\mu}{\mathbf{1}'\Sigma^{-1}\mathbf{1}}\right)

Antes de usarla, conviene mirar qué cartera da en este universo:

IN [03]python
def mv_presupuesto(mu, Sigma, gamma=5.0):
    """min gamma/2 w'Sw - mu'w con 1'w = 1 (sin w >= 0): KKT."""
    uno = torch.ones(mu.shape[-1])
    s1 = torch.linalg.solve(Sigma, uno)
    smu = torch.linalg.solve(Sigma, mu)
    return s1 / s1.sum() + (smu - s1 * smu.sum() / s1.sum()) / gamma

k = 48                                       # enero de 2011
mu_ej = F[k] @ torch.tensor([0.0, 0.0, -0.2]) / 100
w_cf = mv_presupuesto(mu_ej, S[k])
print(f"{meses[k].date()}: suma {w_cf.sum():.3f} | bruta "
      f"{w_cf.abs().sum():.2f} | mín. {w_cf.min():.2f} | "
      f"máx. {w_cf.max():.2f}")
top = pd.Series(w_cf.numpy(), cols).sort_values()
print(top.iloc[[0, 1, -2, -1]].round(2).to_string())
OUT [03]La solución cerrada sin solo largos
2011-01-31: suma 1.000 | bruta 17.42 | mín. -1.67 | máx. 1.85
RF_T20    -1.67
RF_T710   -1.36
RF_T37     1.77
RF_T13     1.85

Con una predicción modesta (−0,2 % al mes por cada desviación de volatilidad), la cartera sin la restricción de solo largos tiene una exposición bruta de 17 veces el capital: largos de 1,85 y 1,77 en los bonos a 1-3 y 3-7 años y cortos de 1,67 y 1,36 en los de 20 y 7-10 años. Apuesta con mucho apalancamiento por las diferencias entre activos casi redundantes. Una pérdida de Sharpe explotaría esa libertad. La restricción w≥0w \ge 0 es la regularización más barata, y con ella ya no hay fórmula cerrada.

La herramienta estándar para diferenciar un problema convexo con restricciones es cvxpylayers (Agrawal et al., 2019), que deriva las condiciones de optimalidad del problema. La versión 1.2.0 (Apache-2.0) necesita cvxpy, que no está en el entorno ml del curso; así se escribiría:

IN [04]python
import cvxpy as cp
from cvxpylayers.torch import CvxpyLayer

n = 37
w = cp.Variable(n)
mu = cp.Parameter(n)
L = cp.Parameter((n, n))            # Sigma = L L' (Cholesky)
gamma = 5.0
objetivo = cp.Minimize(gamma / 2 * cp.sum_squares(L.T @ w) - mu @ w)
problema = cp.Problem(objetivo, [cp.sum(w) == 1, w >= 0])
assert problema.is_dpp()             # parámetros multiplicando a w
capa_cvx = CvxpyLayer(problema, parameters=[mu, L], variables=[w])
(pesos,) = capa_cvx(mu_tch, torch.linalg.cholesky(S_tch))

La alternativa sin dependencias es desenrollar un algoritmo iterativo: gradiente proyectado acelerado (FISTA) sobre el objetivo, con una proyección exacta sobre el conjunto {w≥0,1′w=1}\{w \ge 0, \mathbf{1}'w = 1\} (ordenar, acumular y cortar). Cada iteración son operaciones de PyTorch, así que autograd deriva a través de las 30 iteraciones como a través de 30 capas de una red.

Hay un detalle numérico que decide si la capa sirve. Las varianzas del universo van del bono corto (≈ 1,5 % de volatilidad anual) a la renta variable emergente (≈ 27 %): la covarianza está muy mal condicionada, y el gradiente proyectado con paso 1/(γλmax⁡(Σ))1/(\gamma\lambda_{\max}(\Sigma)) avanza a paso de tortuga en las direcciones de los activos poco volátiles. La solución es precondicionar con la diagonal D=diag⁡(Σ)D = \operatorname{diag}(\Sigma): el paso se divide entre la varianza de cada activo (se trabaja en unidades de volatilidad), el tamaño del paso lo fija el mayor autovalor de la correlación (mucho mejor condicionada) y la proyección se hace en la norma ponderada por DD, que tiene la misma solución por ordenación que la euclídea: wi=max⁡(0, yi−τ/di)w_i = \max(0,\ y_i - \tau/d_i), con τ\tau el que hace que los pesos sumen 1.

IN [05]python
def proyectar(y, d):
    """argmin sum d_i (w_i - y_i)^2 con w >= 0, sum(w) = 1, por filas:
    w_i = max(0, y_i - tau / d_i)."""
    b = y * d                                # puntos de corte
    orden = torch.argsort(b, dim=-1, descending=True)
    ys, inv_d = y.gather(-1, orden), (1 / d).gather(-1, orden)
    tau_k = (ys.cumsum(-1) - 1) / inv_d.cumsum(-1)
    rho = (b.gather(-1, orden) > tau_k).sum(-1, keepdim=True)
    tau = tau_k.gather(-1, rho - 1)
    return torch.clamp(y - tau / d, min=0)

var = torch.diagonal(S, dim1=1, dim2=2)          # D de cada mes
corr = S / torch.sqrt(var[:, :, None] * var[:, None, :])
lam_corr = torch.linalg.eigvalsh(corr)[:, -1:]
print(f"número de condición mediano: covarianza "
      f"{torch.linalg.cond(S).median():.0f}, correlación "
      f"{torch.linalg.cond(corr).median():.0f}")

def capa(mu, meses_, gamma=5.0, iters=30):
    """argmin gamma/2 w'Sw - mu'w, w >= 0, 1'w = 1: FISTA
    precondicionado por la diagonal, con la S de cada mes."""
    Sig, d = S[meses_], var[meses_]
    paso = 1 / (gamma * lam_corr[meses_])
    w = torch.full(mu.shape, 1 / mu.shape[-1])
    z, t = w, 1.0
    for _ in range(iters):
        grad = gamma * torch.einsum("bij,bj->bi", Sig, z) - mu
        w_n = proyectar(z - paso * grad / d, d)
        t_n = (1 + np.sqrt(1 + 4 * t * t)) / 2
        z = w_n + (t - 1) / t_n * (w_n - w)
        w, t = w_n, t_n
    return w

from scipy.optimize import minimize
n = len(cols)
for nombre, mu_ in (("mínima varianza", torch.zeros(n)),
                    ("con la predicción", mu_ej)):
    ref = minimize(
        lambda x: 2.5 * x @ S[k].numpy() @ x - mu_.numpy() @ x,
        np.full(n, 1 / n), method="SLSQP", bounds=[(0, 1)] * n,
        constraints=[{"type": "eq", "fun": lambda x: x.sum() - 1}],
        options={"ftol": 1e-15, "maxiter": 1000}).x
    w_capa = capa(mu_[None], [k])[0].numpy()
    print(f"{nombre}: máx. |w - SLSQP| = "
          f"{np.abs(w_capa - ref).max():.4f} | activos con peso: "
          f"{(w_capa > 1e-3).sum()} (SLSQP: {(ref > 1e-3).sum()})")
OUT [05]Capa de media-varianza de solo largos, desenrollada
número de condición mediano: covarianza 23925, correlación 426
mínima varianza: máx. |w - SLSQP| = 0.0022 | activos con peso: 5 (SLSQP: 5)
con la predicción: máx. |w - SLSQP| = 0.0000 | activos con peso: 1 (SLSQP: 1)

Con 30 iteraciones precondicionadas, la capa queda a milésimas de peso de la solución de SLSQP en los dos casos (sin precondicionar, con 30 iteraciones la distancia era de decenas de puntos de peso). Se usarán 30: la capa es una aproximación fija y diferenciable del optimizador, y es la misma al entrenar y al evaluar.

El segundo control es el gradiente. Autograd deriva el algoritmo desenrollado; las diferencias finitas, el resultado. Deben parecerse, salvo cuando un activo entra o sale de la cartera (el soporte cambia y la función tiene una esquina):

IN [06]python
def sharpe(w, Rm):
    """Sharpe mensual de la cartera w (simples, suma de w * r)."""
    rp = (w * Rm).sum(-1)
    return rp.mean() / rp.std()

ent0 = np.arange(0, 46)                  # 2007-01 a 2010-10
def perdida(theta):
    w = capa(F[ent0] @ theta / 100, ent0)
    return -sharpe(w, R[ent0])

theta0 = torch.zeros(3, requires_grad=True)
perdida(theta0).backward()
h = 1e-4
fd = [((perdida(h * e) - perdida(-h * e)) / (2 * h)).item()
      for e in torch.eye(3)]
print("autograd:", theta0.grad.numpy().round(2))
print("dif. finitas:", np.round(fd, 2))
OUT [06]Gradiente de la capa: autograd frente a diferencias finitas
autograd: [ 2.43 -0.6  11.48]
dif. finitas: [ 2.72  0.23 10.61]

Las dos componentes grandes (momentum y volatilidad) coinciden en signo y en magnitud aproximada; la del rendimiento del último mes es pequeña y ni siquiera tiene el mismo signo. No coinciden exactamente porque en θ=0\theta = 0 hay activos en el borde del soporte (la mínima varianza tiene cinco activos con peso) y una diferencia finita de 10−410^{-4} cruza esquinas que la derivada local no ve: la pérdida es suave a trozos. Es normal en capas con restricciones de desigualdad, y es una de las razones por las que el entrenamiento de estas capas es ruidoso.

El modelo y la pérdida de Sharpe

El modelo es deliberadamente pequeño: μ^i=θ′fi/100\hat\mu_i = \theta' f_i / 100, una combinación lineal de las tres features con tres parámetros (el /100 pone μ^\hat\mu en una escala de rendimientos mensuales y deja θ\theta de orden 1). Con θ=0\theta = 0 la capa devuelve la cartera de mínima varianza; el entrenamiento decide cuánto inclinarse y hacia qué. La pérdida es el Sharpe mensual con signo menos sobre los meses de entrenamiento:

L(θ)=−rp‾sd⁡(rp),rp,t+1=w⋆ ⁣(μ^t(θ))′Rt+1\mathcal{L}(\theta) = -\frac{\overline{r_p}}{\operatorname{sd}(r_p)}, \qquad r_{p,t+1} = w^\star\!\left(\hat\mu_t(\theta)\right)' R_{t+1}

Los rendimientos de la cartera son la suma de pesos por rendimientos simples de cada activo (con logarítmicos, el Sharpe de una cartera sale infravalorado: lección 4.1.1).

IN [07]python
def entrenar_e2e(ent, epocas=150, lr=0.01, cada=None):
    torch.manual_seed(0)
    theta = torch.zeros(3, requires_grad=True)
    opt = torch.optim.Adam([theta], lr=lr)
    hist = {}
    for e in range(epocas + 1):
        opt.zero_grad()
        p_ = perdida_en(theta, ent)
        if cada and e % cada == 0:
            hist[e] = round(float(-p_.item() * np.sqrt(12)), 2)
        if e < epocas:
            p_.backward()
            opt.step()
    return theta.detach(), hist

def perdida_en(theta, ent):
    w = capa(F[ent] @ theta / 100, ent)
    return -sharpe(w, R[ent])

theta_ej, hist = entrenar_e2e(ent0, cada=30)
print("Sharpe anualizado en muestra por época:", hist)
print("theta (mom_12_1, ret_1m, vol_3m):", theta_ej.numpy().round(3))
OUT [07]Entrenar con la pérdida de Sharpe (2007-2010)
Sharpe anualizado en muestra por época: {0: 0.96, 30: 1.49, 60: 1.5, 90: 1.53, 120: 1.53, 150: 1.56}
theta (mom_12_1, ret_1m, vol_3m): [-0.025  0.115 -0.184]

En 150 épocas el Sharpe en muestra sube de 0,96 (la mínima varianza, época 0) a 1,56. El modelo aprendió a pesar en contra de la volatilidad y a favor del rendimiento del último mes. En 2007-2010 eso describe bien lo que pasó: los bonos y los activos poco volátiles fueron el refugio de la crisis. Si es una regla o un recuerdo de 46 meses lo dirá el walk-forward.

Walk-forward: seis carteras

Cada tres años (enero de 2011, 2014, 2017, 2020 y 2023) se reentrena con todos los meses cuyo rendimiento ya terminó antes de la fecha de decisión: el último mes de entrenamiento es el penúltimo, porque el rendimiento del último termina justo ese día (el margen de la lección 5.4.3). Seis carteras, todas de solo largos y rebalanceadas cada mes:

CarteraQué usaParámetros aprendidos
1/Nnada0
Volatilidad inversavolatilidades de la covarianza0
Mínima varianzala capa con μ^=0\hat\mu = 00
PTOla capa con μ^\hat\mu de una regresión de los rendimientos (menos su media del mes) sobre las features3, por mínimos cuadrados
E2E media-varianzala capa con μ^=θ′f/100\hat\mu = \theta' f / 1003, por la pérdida de Sharpe
E2E softmaxw=softmax⁡(θ′f)w = \operatorname{softmax}(\theta' f), sin covarianza (Zhang et al., 2020)3, por la pérdida de Sharpe
IN [08]python
nombres = ["1/N", "vol. inversa", "mín. varianza", "PTO",
           "E2E media-varianza", "E2E softmax"]
W = {m: torch.zeros(len(meses), n) for m in nombres}
ventanas = []
for anyo in (2011, 2014, 2017, 2020, 2023):
    pru = np.flatnonzero((meses.year >= anyo) & (meses.year < anyo + 3))
    ent = np.arange(0, pru[0] - 1)       # margen de un mes
    # PTO: mínimos cuadrados del rendimiento relativo del mes
    X_ = F[ent].reshape(-1, 3).numpy()
    y_ = (R[ent] - R[ent].mean(1, keepdim=True)).reshape(-1).numpy()
    th_pto = torch.tensor(np.linalg.lstsq(X_, y_, rcond=None)[0])
    # E2E con la capa y E2E softmax
    th_e2e, _ = entrenar_e2e(ent)
    torch.manual_seed(0)
    th_sm = torch.zeros(3, requires_grad=True)
    opt = torch.optim.Adam([th_sm], lr=0.05)
    for _ in range(150):
        opt.zero_grad()
        (-sharpe(torch.softmax(F[ent] @ th_sm, -1), R[ent])).backward()
        opt.step()
    with torch.no_grad():
        cero = torch.zeros(len(pru), n)
        vi = 1 / torch.diagonal(S[pru], dim1=1, dim2=2).sqrt()
        W["1/N"][pru] = 1 / n
        W["vol. inversa"][pru] = vi / vi.sum(1, keepdim=True)
        W["mín. varianza"][pru] = capa(cero, pru)
        W["PTO"][pru] = capa(F[pru] @ th_pto, pru)
        W["E2E media-varianza"][pru] = capa(F[pru] @ th_e2e / 100, pru)
        W["E2E softmax"][pru] = torch.softmax(F[pru] @ th_sm, -1)
        sh_ent = {
            "mín. varianza": sharpe(capa(torch.zeros(len(ent), n),
                                         ent), R[ent]),
            "E2E media-varianza": -perdida_en(th_e2e, ent)}
    ventanas.append({
        "entrena hasta": meses[ent[-1]].strftime("%Y-%m"),
        "θ E2E": th_e2e.numpy().round(2).tolist(),
        "Sharpe en muestra E2E": float(sh_ent["E2E media-varianza"])
        * np.sqrt(12),
        "Sharpe en muestra mín. var.": float(sh_ent["mín. varianza"])
        * np.sqrt(12)})
pd.DataFrame(ventanas, index=[2011, 2014, 2017, 2020, 2023]).round(2)
OUT [08]Walk-forward con reentrenamiento cada tres años
     entrena hasta                 θ E2E  Sharpe en muestra E2E  Sharpe en muestra mín. var.
2011       2010-11  [-0.02, 0.12, -0.18]                   1.63                         1.05
2014       2013-11    [0.0, 0.12, -0.18]                   0.89                         0.63
2017       2016-11   [-0.02, 0.09, -0.1]                   0.77                         0.61
2020       2019-11  [-0.05, 0.11, -0.12]                   0.65                         0.43
2023       2022-11  [0.01, -0.02, -0.02]                   0.20                         0.15

En todas las ventanas la E2E tiene en muestra más Sharpe que la mínima varianza, que es la cartera de la que parte con θ=0\theta = 0. Su θ\theta cuenta casi siempre la misma historia: peso en contra de la volatilidad y a favor del último mes. La pregunta es qué pasa en los tres años siguientes a cada entrenamiento:

IN [09]python
def sharpe_anual(m, meses_):
    rp = (W[m][meses_] * R[meses_]).sum(-1)
    return float(rp.mean() / rp.std() * np.sqrt(12))

v = pd.DataFrame(ventanas, index=[2011, 2014, 2017, 2020, 2023])
for m in ("E2E media-varianza", "mín. varianza"):
    v[f"Sharpe fuera {m}"] = [
        sharpe_anual(m, np.flatnonzero((meses.year >= a)
                                       & (meses.year < a + 3)))
        for a in v.index]
v.drop(columns=["entrena hasta", "θ E2E"]).round(2).T
OUT [09]Sharpe en muestra frente a los tres años siguientes
                                 2011  2014  2017  2020  2023
Sharpe en muestra E2E            1.63  0.89  0.77  0.65  0.20
Sharpe en muestra mín. var.      1.05  0.63  0.61  0.43  0.15
Sharpe fuera E2E media-varianza  0.30  0.66 -0.48 -0.84 -0.38
Sharpe fuera mín. varianza       0.35  0.64 -0.75 -0.67  0.18
Fig. — Sharpe de la cartera E2E de media-varianza en su ventana de entrenamiento y en los tres años siguientes, frente a la mínima varianza en esos mismos años

En muestra, la E2E gana a la mínima varianza en las cinco ventanas, por 0,05 a 0,58 puntos de Sharpe. En los tres años siguientes gana en una (2017-2019, −0,48 frente a −0,75), empata en otra (2014-2016) y pierde en tres; en 2023-2025 pierde por más de medio punto. La ventaja en muestra no predice la ventaja fuera, que es la definición práctica de sobreajuste. En la última ventana, además, el entrenamiento casi no se aleja de θ=0\theta = 0: con 2022 en la muestra (bonos y acciones cayendo a la vez), ninguna inclinación mejoró el Sharpe de entrenamiento.

Resultados fuera de muestra

Para 2011-2025 (179 meses): rendimiento en exceso, volatilidad, Sharpe bruto y neto (10 pb por unidad de rotación, como en la lección 4.1.5), caída máxima, rotación anual, número efectivo de activos (1/∑wi21/\sum w_i^2) y el Sharpe verdadero medio de los pesos elegidos, w′μ/w′Σww'\mu/\sqrt{w'\Sigma w} con la μ y la Σ publicadas del universo (universo4_verdad.csv y universo4_cov_verdad.csv, bloque total). El último separa lo que eligió cada método de la suerte que tuvo después, como en la lección 4.1.4.

IN [10]python
verdad = pd.read_csv("universo4_verdad.csv", index_col="activo")
mu_v = verdad.loc[cols, "mu_verdadero"].to_numpy()
cv = pd.read_csv("universo4_cov_verdad.csv")
cv = cv[cv["regimen"] == "total"].set_index("activo")
sig_v = cv.loc[cols, cols].to_numpy()

prueba = np.flatnonzero(meses.year >= 2011)
Rp = R[prueba].numpy()
filas = {}
for m in nombres:
    w = W[m][prueba].numpy()
    rp = (w * Rp).sum(1)
    rot = np.r_[0.0, np.abs(np.diff(w, axis=0)).sum(1)]
    neto = rp - 0.001 * rot
    curva = np.cumprod(1 + rp)
    filas[m] = {
        "exceso %": 1200 * rp.mean(),
        "vol. %": 100 * np.sqrt(12) * rp.std(),
        "Sharpe": np.sqrt(12) * rp.mean() / rp.std(),
        "Sharpe neto": np.sqrt(12) * neto.mean() / neto.std(),
        "caída máx. %": 100 * (curva / np.maximum.accumulate(curva)
                               - 1).min(),
        "rotación %": 1200 * rot.mean(),
        "n efectivo": np.mean(1 / (w ** 2).sum(1)),
        "Sharpe verdadero": np.mean([x @ mu_v / np.sqrt(x @ sig_v @ x)
                                     for x in w])}
tabla = pd.DataFrame(filas).T
print(f"{len(prueba)} meses, {meses[prueba[0]].date()} a "
      f"{meses[prueba[-1]].date()}")
print(tabla.round(2).to_string())
OUT [10]Métricas fuera de muestra y Sharpe verdadero
179 meses, 2011-01-31 a 2025-11-28
                    exceso %  vol. %  Sharpe  Sharpe neto  caída máx. %  rotación %  n efectivo  Sharpe verdadero
1/N                     6.54   13.89    0.47         0.47        -22.83        0.00       37.00              0.39
vol. inversa            3.15    7.68    0.41         0.41        -16.69       26.12       16.53              0.47
mín. varianza          -0.03    1.60   -0.02        -0.03         -7.44       16.60        1.07              0.39
PTO                     2.42    4.42    0.55         0.36        -12.47      833.17        1.65              0.31
E2E media-varianza     -0.24    1.80   -0.13        -0.23        -10.34      191.23        1.10              0.30
E2E softmax             0.38    3.11    0.12        -0.05        -12.80      531.52        3.73              0.39

La rotación es el primer síntoma: las tres carteras que aprenden algo rotan entre diez y cincuenta veces más que las simples, y los costes se lo cobran.

Fig. — Rotación anual frente a Sharpe neto de costes (10 pb por unidad de rotación) de las seis carteras, 2011-2025

La figura de la cabecera pone en una misma escala el Sharpe realizado y el verdadero de cada cartera. Cuatro lecturas:

  • La E2E de media-varianza es la peor por el Sharpe verdadero (0,30) y también por el realizado (−0,13; −0,23 neto). No mejora a la cartera de la que parte con θ=0\theta = 0, la mínima varianza (0,39 verdadero), y rota diez veces más (191 % al año frente a 17 %).
  • La mínima varianza es casi un solo activo: 1,07 activos efectivos, sobre todo el bono a 1-3 años, como en la lección 4.1.4. Su Sharpe realizado (−0,02) infravalora lo que eligió (0,39), porque los bonos del universo rindieron en la muestra menos que su esperanza.
  • PTO tiene el mejor Sharpe realizado (0,55) y el segundo peor verdadero (0,31). Sus pesos dependen de unas predicciones con muy poca señal, saltan de un mes a otro (833 % de rotación al año) y acertaron en el periodo. Es el resultado que un backtest premia y que la verdad no respalda.
  • La volatilidad inversa tiene el mejor Sharpe verdadero (0,47) con 16,5 activos efectivos y un 26 % de rotación: la cartera más simple con covarianza gana a todo lo que aprende algo. 1/N, con el realizado más alto después de PTO (0,47), tiene un verdadero de 0,39.

Cuánto ruido tiene la comparación

Con TT años de rendimientos mensuales, el error típico de un Sharpe anual es de unos (1+S2/2)/T\sqrt{(1 + S^2/2)/T}. Con 15 años:

IN [11]python
anyos = len(prueba) / 12
et = np.sqrt((1 + tabla["Sharpe"] ** 2 / 2) / anyos)
pd.DataFrame({"Sharpe": tabla["Sharpe"], "error típico": et,
              "Sharpe verdadero": tabla["Sharpe verdadero"]}).round(2)
OUT [11]Error típico del Sharpe realizado
                    Sharpe  error típico  Sharpe verdadero
1/N                   0.47          0.27              0.39
vol. inversa          0.41          0.27              0.47
mín. varianza        -0.02          0.26              0.39
PTO                   0.55          0.28              0.31
E2E media-varianza   -0.13          0.26              0.30
E2E softmax           0.12          0.26              0.39

Un error típico de 0,26 a 0,28 por cartera, mayor que casi todas las distancias entre ellas. El backtest de una sola historia no puede ordenar estas carteras; el Sharpe verdadero, que en datos reales no existe, sí. Y se han probado seis métodos (y en un caso real, muchas más variantes de features, γ y épocas): cualquier ganador del Sharpe realizado tendría que pasar además el descuento del Deflated Sharpe de la lección 5.4.4.

Cuándo puede funcionar el end-to-end

El resultado no condena la idea; dice qué necesita. La E2E tiene la misma información que PTO y más libertad para usarla, y la libertad se paga en varianza de estimación:

CondiciónEn esta lecciónQué haría falta
Muchas decisiones independientes37 activos, de 46 a 191 meses por ventanauniversos de cientos de activos, datos diarios o intradía
Una relación estable entre features y rendimientoslas crisis del guion premian a los bonos en unas ventanas y no en otrasfeatures con señal estable (lección 5.4.3)
Costes y restricciones dentro de la capala rotación se mide después, no se penalizapenalización de rotación y límites por activo en el problema de la capa
Regularización frente a la cartera baseningunapenalizar la distancia a la mínima varianza, parar antes, ensembles
Validación honesta del procedimientowalk-forward con margentambién para elegir épocas, γ y features (sin mirar la prueba)

Los trabajos que encuentran mejoras con E2E (Butler y Kwon, 2021; Uysal et al., 2021 con presupuestos de riesgo) suelen reducir la libertad del modelo, no aumentarla: capas con más estructura, predicciones que solo inclinan una cartera robusta y pérdidas que mezclan el error de predicción con el de la decisión. Antes de cualquiera de ellos, la pregunta de esta lección: ¿mejora a su propia cartera con θ=0\theta = 0, fuera de muestra y después de costes?

Ejercicios

  1. Entrena la E2E de media-varianza con 2007-2010 durante 450 épocas y mide cada 75 épocas el Sharpe en muestra y en 2011-2013 (sin reentrenar). ¿Qué pasa con cada uno? ¿Puedes usar la curva de 2011-2013 para elegir el número de épocas?
Solución
IN [12]python
pru0 = np.flatnonzero((meses.year >= 2011) & (meses.year < 2014))
torch.manual_seed(0)
theta = torch.zeros(3, requires_grad=True)
opt = torch.optim.Adam([theta], lr=0.01)
curva = {}
for e in range(451):
    opt.zero_grad()
    p_ = perdida_en(theta, ent0)
    if e % 75 == 0:
        with torch.no_grad():
            fuera = -perdida_en(theta, pru0)
        curva[e] = {"en muestra": -p_.item() * np.sqrt(12),
                    "2011-2013": fuera.item() * np.sqrt(12)}
    if e < 450:
        p_.backward()
        opt.step()
pd.DataFrame(curva).T.round(2)
OUT [12]Sharpe dentro y fuera de muestra por época
     en muestra  2011-2013
0          0.96       0.35
75         1.51       0.30
150        1.56       0.31
225        1.61       0.31
300        1.65       0.35
375        1.68       0.36
450        1.69       0.37

El Sharpe en muestra sube sin parar con las épocas (de 0,96 a 1,69) y el de 2011-2013 no lo acompaña: se mueve entre 0,30 y 0,37, alrededor del de la mínima varianza de la época 0 (0,35). Las épocas extra solo compran ajuste a 46 meses. Elegir el número de épocas con la curva de 2011-2013 (aquí, quedarse con la última) sería usar la prueba para ajustar el modelo: el resultado de 2011-2013 dejaría de ser fuera de muestra. Para parar antes de tiempo hace falta un tramo de validación dentro del entrenamiento (los últimos meses antes del margen) y, aun así, con 46 meses ese tramo es tan corto que su Sharpe es casi todo ruido.

  1. Repite el Sharpe neto de la tabla con costes de 5, 10, 25 y 50 pb por unidad de rotación. ¿A partir de qué coste deja PTO de compensar frente a la mínima varianza?
Solución
IN [13]python
sens = {}
for c in (5, 10, 25, 50):
    fila = {}
    for m in ("mín. varianza", "PTO", "E2E media-varianza",
              "vol. inversa"):
        w = W[m][prueba].numpy()
        rot = np.r_[0.0, np.abs(np.diff(w, axis=0)).sum(1)]
        rn = (w * Rp).sum(1) - c / 1e4 * rot
        fila[m] = np.sqrt(12) * rn.mean() / rn.std()
    sens[f"{c} pb"] = fila
pd.DataFrame(sens).round(2)
OUT [13]Sensibilidad a los costes
                    5 pb  10 pb  25 pb  50 pb
mín. varianza      -0.02  -0.03  -0.04  -0.07
PTO                 0.46   0.36   0.08  -0.39
E2E media-varianza -0.18  -0.23  -0.38  -0.61
vol. inversa        0.41   0.41   0.40   0.39

PTO sigue por encima de la mínima varianza hasta 25 pb (0,08 frente a −0,04) y cae muy por debajo con 50 pb (−0,39 frente a −0,07): su rotación (más de ocho veces el capital al año) convierte cada punto básico de coste en Sharpe perdido. La volatilidad inversa casi no se mueve (de 0,41 a 0,39). La comparación sin costes favorece sistemáticamente a los métodos inestables, y por eso la columna neta de la tabla es la que se mira.

  1. La capa de esta lección fija γ = 5 y deja libre la escala de θ\theta. ¿Cambiaría algo entrenar también γ? ¿Y quitar la restricción de presupuesto y dejar que la pérdida de Sharpe decida el tamaño?
Solución

Con la escala de θ\theta libre, γ no añade nada: la cartera depende de μ^/γ\hat\mu/\gamma, así que multiplicar θ\theta por dos equivale a dividir γ por dos. Entrenar los dos daría un parámetro no identificado (el optimizador los movería juntos sin cambiar la pérdida). Quitar el presupuesto es peor: el Sharpe no cambia al multiplicar todos los pesos por una constante, así que la pérdida no fija el tamaño de la cartera, y una cartera que solo tiene que ser «la mejor en Sharpe» puede degenerar (pesos que crecen sin límite, o todo en el activo menos volátil, como MONET). Las restricciones de la capa (presupuesto, solo largos, límites) son las que dan forma al problema; la pérdida solo elige dentro de ellas.

Respuesta plegada en cada pregunta

Autoevaluación

  1. ¿Qué cambia en el enfoque end-to-end respecto a predecir y luego optimizar?

    • AQue no hace falta covarianza
    • BQue el optimizador se sustituye por una red neuronal sin restricciones
    • CQue se usan más features
    • DQue los parámetros del modelo de predicción se eligen por el resultado de la cartera (la pérdida de Sharpe), propagando el gradiente a través del optimizador, y no por el error de predicción
    Ver respuesta

    Respuesta D. Que los parámetros del modelo de predicción se eligen por el resultado de la cartera (la pérdida de Sharpe), propagando el gradiente a través del optimizador, y no por el error de predicción

    En PTO el modelo minimiza el error cuadrático de los rendimientos y el optimizador se aplica después. En E2E la misma capa de optimización está dentro del grafo de cálculo y el modelo aprende lo que mejora la decisión. Necesita que el optimizador sea diferenciable.

  2. ¿Por qué la lección no usa la solución cerrada de media-varianza con solo la restricción de presupuesto, aunque sea diferenciable y exacta?

    • APorque sin w ≥ 0 la solución toma posiciones cortas y apalancamiento enormes con 37 activos muy correlacionados (aquí, una exposición bruta de 17 veces el capital), y una pérdida de Sharpe explotaría esa libertad
    • BPorque PyTorch no sabe invertir matrices
    • CPorque no tiene gradiente
    • DPorque solo sirve con dos activos
    Ver respuesta

    Respuesta A. Porque sin w ≥ 0 la solución toma posiciones cortas y apalancamiento enormes con 37 activos muy correlacionados (aquí, una exposición bruta de 17 veces el capital), y una pérdida de Sharpe explotaría esa libertad

    Las condiciones KKT con solo el presupuesto dan una fórmula cerrada, pero con activos casi redundantes (las versiones cubiertas y sin cubrir de un índice) la cartera óptima apuesta por sus diferencias con mucho apalancamiento. La restricción de solo largos es la regularización más barata, y exige un optimizador iterativo o cvxpylayers.

  3. Entrenada con 2007-2010, la cartera E2E tiene un Sharpe en muestra de 1,63 frente a 1,05 de la mínima varianza; en 2011-2013, 0,30 frente a 0,35. ¿Qué indica?

    • AQue 2011-2013 fue un mal periodo para cualquier cartera
    • BSobreajuste de la pérdida de decisión: con 46 meses, la capa aprende a concentrarse en lo que funcionó en la crisis de la ventana de entrenamiento
    • CQue hay que entrenar más épocas
    • DQue la capa de optimización tiene un error
    Ver respuesta

    Respuesta B. Sobreajuste de la pérdida de decisión: con 46 meses, la capa aprende a concentrarse en lo que funcionó en la crisis de la ventana de entrenamiento

    El Sharpe en muestra mide cuánto se ajusta la regla a 46 meses concretos. La E2E optimiza directamente esa cifra, así que la infla más que un modelo entrenado por error de predicción. Fuera de muestra no mejora a la cartera de la que parte; en el conjunto de 2011-2025 queda por debajo de ella.

  4. ¿Para qué sirve el «Sharpe verdadero» de los pesos, $w'\mu / \sqrt{w'\Sigma w}$ con la μ y la Σ publicadas del universo?

    • APara sustituir al Sharpe realizado en los informes
    • BPara calcular los costes
    • CPara separar lo que eligió cada método de la suerte que tuvo después: con 15 años, el error típico de un Sharpe realizado es de unas 0,26, más que muchas de las diferencias entre métodos
    • DPara entrenar la capa
    Ver respuesta

    Respuesta C. Para separar lo que eligió cada método de la suerte que tuvo después: con 15 años, el error típico de un Sharpe realizado es de unas 0,26, más que muchas de las diferencias entre métodos

    En datos reales no existe. En el universo sintético sí, y permite ver que un Sharpe realizado alto puede venir de pesos con un Sharpe esperado bajo (y al revés).

  5. La pérdida de Sharpe es invariante a la escala de la cartera. ¿Qué pasaría si se quitara la restricción de presupuesto y se dejara un activo monetario casi sin volatilidad?

    • ANada: el Sharpe ya controla el riesgo
    • BEl modelo podría concentrarse en el activo monetario, que con una volatilidad minúscula tiene un Sharpe en muestra arbitrario, o escalar los pesos sin límite; la pérdida necesita restricciones que fijen la escala y un universo sin activos degenerados
    • CLa cartera sería siempre la de mínima varianza
    • DMejoraría la diversificación
    Ver respuesta

    Respuesta B. El modelo podría concentrarse en el activo monetario, que con una volatilidad minúscula tiene un Sharpe en muestra arbitrario, o escalar los pesos sin límite; la pérdida necesita restricciones que fijen la escala y un universo sin activos degenerados

    Por eso la lección retira MONET del universo y la capa impone 1′w = 1 y w ≥ 0. Las restricciones de la capa son parte del modelo, no un detalle de implementación.

Ideas clave

  • PTO entrena por error de predicción y optimiza después; E2E entrena por la métrica de la cartera con el optimizador dentro del grafo de cálculo. Necesita una capa de optimización diferenciable.
  • La media-varianza con solo el presupuesto tiene solución cerrada (KKT), pero con 37 activos correlacionados da apalancamiento extremo. Con w ≥ 0, la capa se resuelve por gradiente proyectado desenrollado (aquí) o con cvxpylayers (no instalado en el entorno ml).
  • Con la covarianza mal condicionada, el gradiente proyectado necesita precondicionarse por la diagonal: con 30 iteraciones queda a milésimas de peso de SLSQP. El gradiente por autograd se parece al de diferencias finitas salvo en las esquinas (cambios de soporte). Las dos comprobaciones van antes de entrenar.
  • La pérdida de Sharpe sobreajusta: en muestra, la E2E supera a la mínima varianza en las cinco ventanas (1,63 frente a 1,05 en la primera); fuera de muestra (2011-2025), Sharpe de −0,13 y −0,23 neto, con un Sharpe verdadero de 0,30, el peor de las seis carteras.
  • Con 15 años, el error típico de un Sharpe realizado es de unas 0,27: PTO tiene el mejor realizado (0,55) y el segundo peor verdadero (0,31). El Sharpe verdadero ordena: volatilidad inversa 0,47, después 1/N, mínima varianza y E2E softmax (0,39), PTO y E2E media-varianza.
  • Lo que pide el end-to-end para funcionar: muchas decisiones independientes, una relación estable, costes y restricciones dentro de la capa y regularización. Con 37 activos y 18 años de meses, no los hay.

Recursos