Lesson 5.7.5 · 75 min
End-to-end portfolio optimization: differentiable layers and Sharpe loss
The classic recipe predicts returns and then optimizes the portfolio with those predictions. The end-to-end approach trains the prediction model on what matters in the end, the portfolio's Sharpe, by passing the gradient through the optimizer. This lesson builds that layer in PyTorch, trains it with a Sharpe loss and evaluates it in walk-forward against the simple portfolios of level 4, with the universe's truth to separate skill from luck.
- Module
- 5.7 · Deep learning with PyTorch
- Duration
- 75 min
- Prerequisites
- 5.7.1 · PyTorch: tensors, autograd and the training loop, 4.1.4 · Risk parity, minimum variance, HRP and HERC, 4.1.2 · Covariance estimation: shrinkage, factor models and denoising, 5.4.3 · Walk-forward with retraining: windows and frequency
- Author
- Jesús Cuesta
- Updated
Level 5 lessons
5.1 · ML fundamentals applied to finance
5.2 · Feature engineering
5.3 · Labelling and sampling
5.4 · Validation for financial series
5.5 · Classical models
- 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
- 5.5.2 Decision trees, Random Forest and bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
- 5.5.4 Optuna and the risk of overfitting the validation
- 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
- 5.5.6 Learning to rank for stock selection
5.6 · Deep learning with Keras 3
5.7 · Deep learning with PyTorch
- 5.7.1 PyTorch: tensors, autograd and the training loop
- 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
- 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
- 5.7.4 Graph neural networks for asset relationships
- 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
5.8 · Time series foundation models
- 5.8.1 Time series foundation models: pretrain once, forecast without training
- 5.8.2 Foundation model families: architecture, data and license
- 5.8.3 In practice: context, quantiles, covariates and fine-tuning
- 5.8.4 Foundation models in finance: what the evidence says
- 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
5.9 · Reinforcement learning
5.10 · Interpretability, robustness and model risk
Predict then optimize, or learn the portfolio
Lessons 4.1.1 to 4.1.4 built portfolios in two steps: estimate expected returns and covariances, and pass those estimates to an optimizer. If the returns are predicted by a machine learning model, the recipe is the same: predict then optimize (PTO). The model is trained on the error of its predictions, and the optimizer takes no part in the training.
The problem is that the squared error of the prediction is not what you want to minimize. A portfolio does not care about getting right the return of an asset it is not going to buy; it cares a lot about the ranking among the assets competing for weight and about what happens in the tails. The end-to-end (E2E) approach, or decision-focused learning, puts the optimizer inside the model and trains on the final metric: the Sharpe of the resulting portfolio. For that, the gradient has to go through the optimizer, which must be a differentiable layer.
Mermaid source
flowchart TB F[Features] --> M[Model mu] M --> O[Optimizer] S[Covariance] --> O O --> W[Weights] W --> P[Return] P --> L[Sharpe loss] L -. gradient .-> M
In PTO, the backward arrow comes out of a prediction error and does not go through the optimizer. Elmachtoub and Grigas (2017) formalized the general idea (Smart predict, then optimize); Zhang, Zohren and Roberts (2020) trained networks that produce weights with a softmax layer and a Sharpe loss; Butler and Kwon (2021) integrated the prediction into a mean-variance layer. The promise is reasonable. The question of this lesson is the usual one: whether it survives an honest walk-forward against simple baselines.
The universe, the features and the covariance
The universe is the level 4 one of 38 total return indices (universo4_*), without MONET, the money market asset:
its excess over the risk-free rate is almost zero with a volatility of 0.2%, and a Sharpe loss with no other
constraints can end up concentrated in an asset like that (question 5 of the self-assessment). That leaves 37.
At each month end, for each asset, three features standardized across assets (12-month momentum skipping the last month, last month's return and three-month volatility), the covariance of the last year and the next month's return in excess of the risk-free rate. The covariance is Ledoit-Wolf on the standardized returns, that is, shrinking the correlation and not the covariance: lesson 4.1.2 showed that the scaled identity target spoils minimum variance when volatilities range from 1% to 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) # simple daily excess
logp = np.log(precios).iloc[1:] # aligned with r
fin_mes = r.index.to_series().groupby(r.index.to_period("M")).max()
print(f"{len(cols)} assets | {r.index[0].date()} to "
f"{r.index[-1].date()} | {len(fin_mes)} month ends")37 assets | 2006-01-04 to 2025-12-31 | 240 month endsdef features(i):
"""Features of each asset at the close of session i, standardized
across assets: 12-1 momentum, 1-month return, 3-month vol."""
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 of the last year's correlation, monthly."""
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("decisions:", meses[0].date(), "to", meses[-1].date())F (227, 37, 3) | S (227, 37, 37) | R (227, 37)
decisions: 2007-01-31 to 2025-11-28Each decision is made at the close of the last day of the month with data up to that day, and its outcome is the next month's return. The first decision needs a year of history (January 2007); the last one, in November 2025, covers December.
A differentiable optimization layer
The layer solves, for some expected returns that will come out of the model, the long-only mean-variance problem:
If there were only the budget constraint, the Karush-Kuhn-Tucker conditions would give a closed formula, differentiable as is: the minimum variance portfolio plus a tilt proportional to ,
Before using it, it is worth looking at the portfolio it gives in this universe:
def mv_presupuesto(mu, Sigma, gamma=5.0):
"""min gamma/2 w'Sw - mu'w with 1'w = 1 (no 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 # January 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()}: sum {w_cf.sum():.3f} | gross "
f"{w_cf.abs().sum():.2f} | min. {w_cf.min():.2f} | "
f"max. {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: sum 1.000 | gross 17.42 | min. -1.67 | max. 1.85
RF_T20 -1.67
RF_T710 -1.36
RF_T37 1.77
RF_T13 1.85With a modest prediction (−0.2% per month for each standard deviation of volatility), the portfolio without the long-only constraint has a gross exposure of 17 times capital: longs of 1.85 and 1.77 in the 1-3 and 3-7 year bonds and shorts of 1.67 and 1.36 in the 20 and 7-10 year ones. It bets with a lot of leverage on the differences between nearly redundant assets. A Sharpe loss would exploit that freedom. The constraint is the cheapest regularization, and with it there is no closed formula anymore.
The standard tool to differentiate a constrained convex problem is cvxpylayers (Agrawal et al., 2019), which
differentiates the problem's optimality conditions. Version 1.2.0 (Apache-2.0) requires cvxpy, which is not in the
course's ml environment; this is how it would be written:
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() # parameters multiplying w
capa_cvx = CvxpyLayer(problema, parameters=[mu, L], variables=[w])
(pesos,) = capa_cvx(mu_tch, torch.linalg.cholesky(S_tch))The dependency-free alternative is to unroll an iterative algorithm: accelerated projected gradient (FISTA) on the objective, with an exact projection onto the set (sort, accumulate and cut). Each iteration is made of PyTorch operations, so autograd differentiates through the 30 iterations as it would through 30 layers of a network.
There is a numerical detail that decides whether the layer is usable. The variances of the universe range from the short bond (≈ 1.5% annual volatility) to emerging market equities (≈ 27%): the covariance is very badly conditioned, and projected gradient with step crawls in the directions of the low-volatility assets. The solution is to precondition with the diagonal : the step is divided by each asset's variance (working in volatility units), the step size is set by the largest eigenvalue of the correlation (much better conditioned) and the projection is done in the -weighted norm, which has the same sorting solution as the Euclidean one: , with the value that makes the weights sum to 1.
def proyectar(y, d):
"""argmin sum d_i (w_i - y_i)^2 with w >= 0, sum(w) = 1, by rows:
w_i = max(0, y_i - tau / d_i)."""
b = y * d # breakpoints
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 of each month
corr = S / torch.sqrt(var[:, :, None] * var[:, None, :])
lam_corr = torch.linalg.eigvalsh(corr)[:, -1:]
print(f"median condition number: covariance "
f"{torch.linalg.cond(S).median():.0f}, correlation "
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
preconditioned by the diagonal, with each month's S."""
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 (("minimum variance", torch.zeros(n)),
("with the prediction", 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}: max. |w - SLSQP| = "
f"{np.abs(w_capa - ref).max():.4f} | assets with weight: "
f"{(w_capa > 1e-3).sum()} (SLSQP: {(ref > 1e-3).sum()})")median condition number: covariance 23925, correlation 426
minimum variance: max. |w - SLSQP| = 0.0022 | assets with weight: 5 (SLSQP: 5)
with the prediction: max. |w - SLSQP| = 0.0000 | assets with weight: 1 (SLSQP: 1)With 30 preconditioned iterations, the layer ends up thousandths of weight away from the SLSQP solution in both cases (without preconditioning, with 30 iterations the distance was tens of weight points). 30 will be used: the layer is a fixed, differentiable approximation of the optimizer, and it is the same in training and in evaluation.
The second check is the gradient. Autograd differentiates the unrolled algorithm; finite differences, the result. They should be similar, except when an asset enters or leaves the portfolio (the support changes and the function has a corner):
def sharpe(w, Rm):
"""Monthly Sharpe of portfolio w (simple returns, sum of w * r)."""
rp = (w * Rm).sum(-1)
return rp.mean() / rp.std()
ent0 = np.arange(0, 46) # 2007-01 to 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("finite diff.:", np.round(fd, 2))autograd: [ 2.43 -0.6 11.48]
finite diff.: [ 2.72 0.23 10.61]The two large components (momentum and volatility) agree in sign and approximate magnitude; the one for last month's return is small and does not even have the same sign. They do not match exactly because at there are assets on the edge of the support (minimum variance has five assets with weight) and a finite difference of crosses corners that the local derivative does not see: the loss is piecewise smooth. This is normal in layers with inequality constraints, and it is one of the reasons why training these layers is noisy.
The model and the Sharpe loss
The model is deliberately small: , a linear combination of the three features with three parameters (the /100 puts on a monthly return scale and leaves of order 1). With the layer returns the minimum variance portfolio; training decides how much to tilt and in which direction. The loss is the negative monthly Sharpe over the training months:
The portfolio returns are the sum of weights times the simple returns of each asset (with log returns, the Sharpe of a portfolio comes out understated: lesson 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("annualized in-sample Sharpe by epoch:", hist)
print("theta (mom_12_1, ret_1m, vol_3m):", theta_ej.numpy().round(3))annualized in-sample Sharpe by epoch: {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]In 150 epochs the in-sample Sharpe rises from 0.96 (minimum variance, epoch 0) to 1.56. The model learned to weight against volatility and in favor of last month's return. In 2007-2010 that describes well what happened: bonds and low-volatility assets were the safe haven of the crisis. Whether it is a rule or a memory of 46 months, the walk-forward will tell.
Walk-forward: six portfolios
Every three years (January 2011, 2014, 2017, 2020 and 2023) the model is retrained with all the months whose return already ended before the decision date: the last training month is the second to last, because the return of the last one ends exactly on that day (the gap of lesson 5.4.3). Six portfolios, all long-only and rebalanced every month:
| Portfolio | What it uses | Learned parameters |
|---|---|---|
| 1/N | nothing | 0 |
| Inverse volatility | volatilities from the covariance | 0 |
| Minimum variance | the layer with | 0 |
| PTO | the layer with from a regression of the returns (minus their monthly mean) on the features | 3, by least squares |
| E2E mean-variance | the layer with | 3, by the Sharpe loss |
| E2E softmax | , without covariance (Zhang et al., 2020) | 3, by the Sharpe loss |
nombres = ["1/N", "inverse vol.", "min. variance", "PTO",
"E2E mean-variance", "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) # one-month gap
# PTO: least squares of the month's relative return
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 with the layer and 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["inverse vol."][pru] = vi / vi.sum(1, keepdim=True)
W["min. variance"][pru] = capa(cero, pru)
W["PTO"][pru] = capa(F[pru] @ th_pto, pru)
W["E2E mean-variance"][pru] = capa(F[pru] @ th_e2e / 100, pru)
W["E2E softmax"][pru] = torch.softmax(F[pru] @ th_sm, -1)
sh_ent = {
"min. variance": sharpe(capa(torch.zeros(len(ent), n),
ent), R[ent]),
"E2E mean-variance": -perdida_en(th_e2e, ent)}
ventanas.append({
"trained until": meses[ent[-1]].strftime("%Y-%m"),
"θ E2E": th_e2e.numpy().round(2).tolist(),
"in-sample Sharpe E2E": float(sh_ent["E2E mean-variance"])
* np.sqrt(12),
"in-sample Sharpe min. var.": float(sh_ent["min. variance"])
* np.sqrt(12)})
pd.DataFrame(ventanas, index=[2011, 2014, 2017, 2020, 2023]).round(2) trained until θ E2E in-sample Sharpe E2E in-sample Sharpe min. 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.15In every window the E2E has a higher in-sample Sharpe than minimum variance, which is the portfolio it starts from with . Its almost always tells the same story: weight against volatility and in favor of the last month. The question is what happens in the three years following each training:
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 mean-variance", "min. variance"):
v[f"out-of-sample Sharpe {m}"] = [
sharpe_anual(m, np.flatnonzero((meses.year >= a)
& (meses.year < a + 3)))
for a in v.index]
v.drop(columns=["trained until", "θ E2E"]).round(2).T 2011 2014 2017 2020 2023
in-sample Sharpe E2E 1.63 0.89 0.77 0.65 0.20
in-sample Sharpe min. var. 1.05 0.63 0.61 0.43 0.15
out-of-sample Sharpe E2E mean-variance 0.30 0.66 -0.48 -0.84 -0.38
out-of-sample Sharpe min. variance 0.35 0.64 -0.75 -0.67 0.18In sample, the E2E beats minimum variance in all five windows, by 0.05 to 0.58 Sharpe points. In the following three years it wins in one (2017-2019, −0.48 against −0.75), ties in another (2014-2016) and loses in three; in 2023-2025 it loses by more than half a point. The in-sample advantage does not predict the out-of-sample advantage, which is the practical definition of overfitting. In the last window, moreover, training barely moves away from : with 2022 in the sample (bonds and equities falling at the same time), no tilt improved the training Sharpe.
Out-of-sample results
For 2011-2025 (179 months): excess return, volatility, gross and net Sharpe (10 bp per unit of turnover, as in lesson
4.1.5), maximum drawdown, annual turnover, effective number of assets () and the average true Sharpe
of the weights chosen, with the published μ and Σ of the universe (universo4_verdad.csv and
universo4_cov_verdad.csv, block total). The last one separates what each method chose from the luck it had
afterwards, as in lesson 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] = {
"excess %": 1200 * rp.mean(),
"vol. %": 100 * np.sqrt(12) * rp.std(),
"Sharpe": np.sqrt(12) * rp.mean() / rp.std(),
"net Sharpe": np.sqrt(12) * neto.mean() / neto.std(),
"max. drawdown %": 100 * (curva / np.maximum.accumulate(curva)
- 1).min(),
"turnover %": 1200 * rot.mean(),
"effective n": np.mean(1 / (w ** 2).sum(1)),
"true Sharpe": np.mean([x @ mu_v / np.sqrt(x @ sig_v @ x)
for x in w])}
tabla = pd.DataFrame(filas).T
print(f"{len(prueba)} months, {meses[prueba[0]].date()} to "
f"{meses[prueba[-1]].date()}")
print(tabla.round(2).to_string())179 months, 2011-01-31 to 2025-11-28
excess % vol. % Sharpe net Sharpe max. drawdown % turnover % effective n true Sharpe
1/N 6.54 13.89 0.47 0.47 -22.83 0.00 37.00 0.39
inverse vol. 3.15 7.68 0.41 0.41 -16.69 26.12 16.53 0.47
min. variance -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 mean-variance -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.39Turnover is the first symptom: the three portfolios that learn something turn over between ten and fifty times more than the simple ones, and costs take their toll.
The figure at the top puts the realized and the true Sharpe of each portfolio on the same scale. Four readings:
- E2E mean-variance is the worst by true Sharpe (0.30) and also by realized Sharpe (−0.13; −0.23 net). It does not improve on the portfolio it starts from with , minimum variance (0.39 true), and it turns over ten times more (191% per year against 17%).
- Minimum variance is almost a single asset: 1.07 effective assets, mostly the 1-3 year bond, as in lesson 4.1.4. Its realized Sharpe (−0.02) understates what it chose (0.39), because the universe's bonds returned less than their expectation in the sample.
- PTO has the best realized Sharpe (0.55) and the second-worst true one (0.31). Its weights depend on predictions with very little signal, jump from one month to the next (833% turnover per year) and happened to be right in the period. It is the result a backtest rewards and the truth does not support.
- Inverse volatility has the best true Sharpe (0.47) with 16.5 effective assets and 26% turnover: the simplest portfolio with a covariance beats everything that learns something. 1/N, with the highest realized Sharpe after PTO (0.47), has a true Sharpe of 0.39.
How noisy the comparison is
With years of monthly returns, the standard error of an annual Sharpe is about . With 15 years:
anyos = len(prueba) / 12
et = np.sqrt((1 + tabla["Sharpe"] ** 2 / 2) / anyos)
pd.DataFrame({"Sharpe": tabla["Sharpe"], "std. error": et,
"true Sharpe": tabla["true Sharpe"]}).round(2) Sharpe std. error true Sharpe
1/N 0.47 0.27 0.39
inverse vol. 0.41 0.27 0.47
min. variance -0.02 0.26 0.39
PTO 0.55 0.28 0.31
E2E mean-variance -0.13 0.26 0.30
E2E softmax 0.12 0.26 0.39A standard error of 0.26 to 0.28 per portfolio, larger than almost all the distances between them. A backtest of a single history cannot rank these portfolios; the true Sharpe, which does not exist on real data, can. And six methods have been tried (and in a real case, many more variants of features, γ and epochs): any winner by realized Sharpe would also have to pass the Deflated Sharpe discount of lesson 5.4.4.
When end-to-end can work
The result does not condemn the idea; it says what it needs. The E2E has the same information as PTO and more freedom to use it, and freedom is paid for in estimation variance:
| Condition | In this lesson | What would be needed |
|---|---|---|
| Many independent decisions | 37 assets, from 46 to 191 months per window | universes of hundreds of assets, daily or intraday data |
| A stable relationship between features and returns | the scripted crises reward bonds in some windows and not in others | features with a stable signal (lesson 5.4.3) |
| Costs and constraints inside the layer | turnover is measured afterwards, not penalized | turnover penalty and per-asset limits in the layer's problem |
| Regularization toward the base portfolio | none | penalize the distance to minimum variance, early stopping, ensembles |
| Honest validation of the procedure | walk-forward with a gap | also for choosing epochs, γ and features (without looking at the test) |
The papers that find improvements with E2E (Butler and Kwon, 2021; Uysal et al., 2021 with risk budgets) usually reduce the model's freedom, not increase it: layers with more structure, predictions that only tilt a robust portfolio and losses that mix the prediction error with the decision error. Before any of them, the question of this lesson: does it improve on its own portfolio with , out of sample and after costs?
Exercises
- Train the E2E mean-variance on 2007-2010 for 450 epochs and measure every 75 epochs the Sharpe in sample and in 2011-2013 (without retraining). What happens to each one? Can you use the 2011-2013 curve to choose the number of epochs?
Solution
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] = {"in sample": -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) in sample 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.37The in-sample Sharpe keeps rising with the epochs (from 0.96 to 1.69) and the 2011-2013 one does not follow it: it moves between 0.30 and 0.37, around that of minimum variance at epoch 0 (0.35). The extra epochs only buy fit to 46 months. Choosing the number of epochs with the 2011-2013 curve (here, keeping the last one) would mean using the test to tune the model: the 2011-2013 result would no longer be out of sample. Early stopping needs a validation stretch inside the training period (the last months before the gap) and, even so, with 46 months that stretch is so short that its Sharpe is almost all noise.
- Recompute the net Sharpe of the table with costs of 5, 10, 25 and 50 bp per unit of turnover. From what cost does PTO stop paying off against minimum variance?
Solution
sens = {}
for c in (5, 10, 25, 50):
fila = {}
for m in ("min. variance", "PTO", "E2E mean-variance",
"inverse vol."):
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} bp"] = fila
pd.DataFrame(sens).round(2) 5 bp 10 bp 25 bp 50 bp
min. variance -0.02 -0.03 -0.04 -0.07
PTO 0.46 0.36 0.08 -0.39
E2E mean-variance -0.18 -0.23 -0.38 -0.61
inverse vol. 0.41 0.41 0.40 0.39PTO stays above minimum variance up to 25 bp (0.08 against −0.04) and falls far below it at 50 bp (−0.39 against −0.07): its turnover (more than eight times capital per year) turns every basis point of cost into lost Sharpe. Inverse volatility barely moves (from 0.41 to 0.39). The comparison without costs systematically favors unstable methods, and that is why the net column of the table is the one to look at.
- The layer of this lesson fixes γ = 5 and leaves the scale of free. Would anything change if γ were also trained? And if the budget constraint were removed and the Sharpe loss left to decide the size?
Solution
With the scale of free, γ adds nothing: the portfolio depends on , so multiplying by
two is equivalent to dividing γ by two. Training both would give an unidentified parameter (the optimizer would move
them together without changing the loss). Removing the budget is worse: the Sharpe does not change when all the
weights are multiplied by a constant, so the loss does not fix the size of the portfolio, and a portfolio that only
has to be “the best in Sharpe” can degenerate (weights that grow without limit, or everything in the least volatile
asset, like MONET). The layer's constraints (budget, long-only, limits) are what shape the problem; the loss only
chooses within them.
Each answer is folded under its question
Self-assessment
What changes in the end-to-end approach compared with predicting and then optimizing?
- AThat no covariance is needed
- BThat the optimizer is replaced by an unconstrained neural network
- CThat more features are used
- DThat the parameters of the prediction model are chosen by the portfolio's result (the Sharpe loss), propagating the gradient through the optimizer, and not by the prediction error
Show answer
Answer D. That the parameters of the prediction model are chosen by the portfolio's result (the Sharpe loss), propagating the gradient through the optimizer, and not by the prediction error
In PTO the model minimizes the squared error of the returns and the optimizer is applied afterwards. In E2E the same optimization layer sits inside the computation graph and the model learns what improves the decision. It requires the optimizer to be differentiable.
Why does the lesson not use the closed-form mean-variance solution with only the budget constraint, even though it is differentiable and exact?
- ABecause without
w ≥ 0the solution takes huge short positions and leverage with 37 highly correlated assets (here, a gross exposure of 17 times capital), and a Sharpe loss would exploit that freedom - BBecause PyTorch cannot invert matrices
- CBecause it has no gradient
- DBecause it only works with two assets
Show answer
Answer A. Because without
w ≥ 0the solution takes huge short positions and leverage with 37 highly correlated assets (here, a gross exposure of 17 times capital), and a Sharpe loss would exploit that freedomThe KKT conditions with only the budget give a closed formula, but with nearly redundant assets (the hedged and unhedged versions of an index) the optimal portfolio bets on their differences with a lot of leverage. The long-only constraint is the cheapest regularization, and it requires an iterative optimizer or cvxpylayers.
- ABecause without
Trained on 2007-2010, the E2E portfolio has an in-sample Sharpe of 1.63 against 1.05 for minimum variance; in 2011-2013, 0.30 against 0.35. What does it indicate?
- AThat 2011-2013 was a bad period for any portfolio
- BOverfitting of the decision loss: with 46 months, the layer learns to concentrate on what worked in the crisis of the training window
- CThat it needs to be trained for more epochs
- DThat the optimization layer has a bug
Show answer
Answer B. Overfitting of the decision loss: with 46 months, the layer learns to concentrate on what worked in the crisis of the training window
The in-sample Sharpe measures how well the rule fits 46 specific months. The E2E optimizes that figure directly, so it inflates it more than a model trained on prediction error. Out of sample it does not improve on the portfolio it starts from; over 2011-2025 as a whole it ends up below it.
What is the “true Sharpe” of the weights, $w'\mu / \sqrt{w'\Sigma w}$ with the published μ and Σ of the universe, for?
- ATo replace the realized Sharpe in reports
- BTo compute the costs
- CTo separate what each method chose from the luck it had afterwards: with 15 years, the standard error of a realized Sharpe is about 0.26, more than many of the differences between methods
- DTo train the layer
Show answer
Answer C. To separate what each method chose from the luck it had afterwards: with 15 years, the standard error of a realized Sharpe is about 0.26, more than many of the differences between methods
On real data it does not exist. In the synthetic universe it does, and it shows that a high realized Sharpe can come from weights with a low expected Sharpe (and vice versa).
The Sharpe loss is invariant to the scale of the portfolio. What would happen if the budget constraint were removed and a money market asset with almost no volatility were left in?
- ANothing: the Sharpe already controls risk
- BThe model could concentrate on the money market asset, which with a tiny volatility has an arbitrary in-sample Sharpe, or scale the weights without limit; the loss needs constraints that fix the scale and a universe without degenerate assets
- CThe portfolio would always be the minimum variance one
- DDiversification would improve
Show answer
Answer B. The model could concentrate on the money market asset, which with a tiny volatility has an arbitrary in-sample Sharpe, or scale the weights without limit; the loss needs constraints that fix the scale and a universe without degenerate assets
That is why the lesson removes
MONETfrom the universe and the layer imposes1′w = 1andw ≥ 0. The layer's constraints are part of the model, not an implementation detail.
Key points
- PTO trains on prediction error and optimizes afterwards; E2E trains on the portfolio metric with the optimizer inside the computation graph. It needs a differentiable optimization layer.
- Mean-variance with only the budget has a closed-form solution (KKT), but with 37 correlated assets it gives extreme leverage. With
w ≥ 0, the layer is solved by unrolled projected gradient (here) or with cvxpylayers (not installed in themlenvironment). - With a badly conditioned covariance, the projected gradient needs diagonal preconditioning: with 30 iterations it ends up thousandths of weight away from SLSQP. The autograd gradient resembles the finite-difference one except at the corners (support changes). Both checks come before training.
- The Sharpe loss overfits: in sample, the E2E beats minimum variance in all five windows (1.63 against 1.05 in the first); out of sample (2011-2025), a Sharpe of −0.13 and −0.23 net, with a true Sharpe of 0.30, the worst of the six portfolios.
- With 15 years, the standard error of a realized Sharpe is about 0.27: PTO has the best realized (0.55) and the second-worst true (0.31). The true Sharpe ranks them: inverse volatility 0.47, then 1/N, minimum variance and E2E softmax (0.39), PTO and E2E mean-variance.
- What end-to-end needs to work: many independent decisions, a stable relationship, costs and constraints inside the layer, and regularization. With 37 assets and 18 years of months, they are not there.
Resources
Books
- Elmachtoub, A. N. and 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. and Roberts, S. (2020): Deep Learning for Portfolio Optimization (arXiv:2005.13665)
- Butler, A. and Kwon, R. H. (2021): Integrating prediction in mean-variance portfolio optimization (arXiv:2102.09287)