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.
- Módulo
- 5.7 · Deep learning con PyTorch
- Duración
- 75 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
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.
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 %.
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")37 activos | 2006-01-04 a 2025-12-31 | 240 fines de mesdef 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())F (227, 37, 3) | S (227, 37, 37) | R (227, 37)
decisiones: 2007-01-31 a 2025-11-28Cada 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 que saldrán del modelo, el problema de media-varianza de solo largos:
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 ,
Antes de usarla, conviene mirar qué cartera da en este universo:
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())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.85Con 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 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:
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 (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 avanza a paso de tortuga en las direcciones de los activos poco volátiles. La solución es precondicionar con la diagonal : 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 , que tiene la misma solución por ordenación que la euclídea: , con el que hace que los pesos sumen 1.
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()})")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):
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))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 hay activos en el borde del soporte (la mínima varianza tiene cinco activos con peso) y una diferencia finita de 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: , una combinación lineal de las tres features con tres parámetros (el /100 pone en una escala de rendimientos mensuales y deja de orden 1). Con 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:
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).
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))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:
| Cartera | Qué usa | Parámetros aprendidos |
|---|---|---|
| 1/N | nada | 0 |
| Volatilidad inversa | volatilidades de la covarianza | 0 |
| Mínima varianza | la capa con | 0 |
| PTO | la capa con de una regresión de los rendimientos (menos su media del mes) sobre las features | 3, por mínimos cuadrados |
| E2E media-varianza | la capa con | 3, por la pérdida de Sharpe |
| E2E softmax | , sin covarianza (Zhang et al., 2020) | 3, por la pérdida de Sharpe |
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) 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.15En 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 . Su 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:
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 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.18En 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 : 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 () y el Sharpe
verdadero medio de los pesos elegidos, 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.
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())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.39La 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.
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 , 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 años de rendimientos mensuales, el error típico de un Sharpe anual es de unos . Con 15 años:
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) 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.39Un 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ón | En esta lección | Qué haría falta |
|---|---|---|
| Muchas decisiones independientes | 37 activos, de 46 a 191 meses por ventana | universos de cientos de activos, datos diarios o intradía |
| Una relación estable entre features y rendimientos | las crisis del guion premian a los bonos en unas ventanas y no en otras | features con señal estable (lección 5.4.3) |
| Costes y restricciones dentro de la capa | la rotación se mide después, no se penaliza | penalización de rotación y límites por activo en el problema de la capa |
| Regularización frente a la cartera base | ninguna | penalizar la distancia a la mínima varianza, parar antes, ensembles |
| Validación honesta del procedimiento | walk-forward con margen | tambié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 , fuera de muestra y después de costes?
Ejercicios
- 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
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) 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.37El 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.
- 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
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) 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.39PTO 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.
- La capa de esta lección fija γ = 5 y deja libre la escala de . ¿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 libre, γ no añade nada: la cartera depende de , así que multiplicar
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
¿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.
¿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 ≥ 0la 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 ≥ 0la 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 libertadLas 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.
- APorque sin
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.
¿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).
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
MONETdel universo y la capa impone1′w = 1yw ≥ 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 entornoml). - 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
Libros
- Elmachtoub, A. N. y Grigas, P. (2017): Smart "Predict, then Optimize" (arXiv:1710.08005)
- Agrawal, A. et al. (2019): Differentiable Convex Optimization Layers (NeurIPS; arXiv:1910.12430)
- Zhang, Z., Zohren, S. y Roberts, S. (2020): Deep Learning for Portfolio Optimization (arXiv:2005.13665)
- Butler, A. y Kwon, R. H. (2021): Integrating prediction in mean-variance portfolio optimization (arXiv:2102.09287)