Lesson 5.6.4 · 75 min
Callbacks, a Sharpe loss and the model in production
Training with the squared error and choosing with the Sharpe leaves a gap between what the model optimizes and what matters. This lesson closes it from both sides: callbacks that watch the IC during training and a loss that is the portfolio's Sharpe, with its traps measured. Then it saves the model, exports it, loads it in another backend and turns it into portfolio weights.
- Module
- 5.6 · Deep learning with Keras 3
- Duration
- 75 min
- Prerequisites
- 5.6.2 · An MLP on a stock panel: regularize and compare, 5.6.1 · Keras 3: one model, several compute engines, 5.1.3 · Economic metrics for trading models: beyond accuracy
- 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
The gap between the loss and the objective
In lesson 5.6.2 the network was trained with the squared error and chosen with the IC, and the portfolio, which is what makes or loses money, was computed separately. There are two ways to bring training closer to what matters:
- Watch the right metric during training: callbacks that compute the validation IC or Sharpe and decide when to stop, which weights to keep and when to lower the learning rate.
- Optimize it directly: a loss that is the Sharpe of the portfolio formed by the predictions. It is the idea of work such as that of Lim, Zohren and Roberts (2019) and Zhang, Zohren and Roberts (2020), which train networks with the Sharpe as the objective function.
For both, the panel is organized differently: each sample is a whole week, a matrix of 400 stocks by 21 features,
and the label, the 400 returns of the following week. A Dense layer acts on the last axis, so the same network is
applied to each stock and the loss sees the full cross-section of each week. The features are normal ranks by week
(lesson 5.2.4) and the splits, those of 5.6.2 (PyTorch backend, the default one):
import json
import os
import zipfile
import numpy as np
import pandas as pd
from scipy.stats import norm
import torch
import keras
from keras import layers, ops
torch.use_deterministic_algorithms(True)
f = pd.read_parquet("panel5_features.parquet")
e = pd.read_parquet("panel5_etiquetas.parquet")
d = (f.merge(e[["fecha", "ticker", "ret_sig_semana"]],
on=["fecha", "ticker"]).sort_values(["fecha", "ticker"]))
FE = [c for c in f.columns[3:] if c != "revision_analistas"]
def rango_normal(df, cols):
"""Normal rank of each column on each date; missing, 0."""
por = df.groupby("fecha")[cols]
u = (por.rank() - 0.5) / por.transform("count")
return pd.DataFrame(norm.ppf(u), index=df.index,
columns=cols).fillna(0.0)
G = rango_normal(d, FE)
fechas = d["fecha"].unique()
T, N, K = len(fechas), d["ticker"].nunique(), len(FE)
X = G.to_numpy("float32").reshape(T, N, K)
R = d["ret_sig_semana"].to_numpy("float32").reshape(T, N)
Y = (rango_normal(d, ["ret_sig_semana"]).to_numpy("float32")
.reshape(T, N))
ok = ~np.isnan(R).any(axis=1) # weeks with a label
X, R, Y, fechas = X[ok], R[ok], Y[ok], fechas[ok]
fe = np.datetime64
ent = fechas < fe("2016-12-26")
val = (fechas >= fe("2017-01-06")) & (fechas < fe("2018-12-24"))
te = fechas >= fe("2019-01-01")
print("X:", X.shape, "| weeks:", int(ent.sum()), int(val.sum()),
int(te.sum()))X: (729, 400, 21) | weeks: 259 103 365That leaves 729 complete weeks: 259 for training, 103 for validation and 365 for test.
The informe function is the minimal backtest of a cross-sectional signal: each week's portfolio is long the stocks
with a score above the mean and short those below, in proportion to the distance, with (half a
dollar long and half short). It gives the mean IC, the gross annual Sharpe, the net one with 10 basis points per unit
traded and the weekly turnover, . As a reference, the signal of a single feature,
the one-week reversal (ret_1s with the sign flipped):
def pesos(s):
"""Long-short: score minus its weekly mean, with
sum of |weights| = 1 (0.5 long and 0.5 short)."""
s = s - s.mean(axis=1, keepdims=True)
return s / np.abs(s).sum(axis=1, keepdims=True)
def informe(score, m, coste=0.001):
"""Mean IC, gross and net annual Sharpe (cost per unit traded)
and weekly turnover of the portfolio of `score`, the score
of the weeks `m`."""
rs = np.argsort(np.argsort(score, 1), 1)
rr = np.argsort(np.argsort(R[m], 1), 1)
ic = np.mean([np.corrcoef(a, b)[0, 1] for a, b in zip(rs, rr)])
w = pesos(score)
rp = (w * R[m]).sum(axis=1)
rot = np.abs(np.diff(w, axis=0)).sum(axis=1)
neto = rp[1:] - coste * rot
anual = lambda x: x.mean() / x.std() * np.sqrt(52)
return {"IC": ic, "gross Sharpe": anual(rp),
"net Sharpe": anual(neto), "turnover": rot.mean()}
reversion = -X[..., FE.index("ret_1s")] # single-feature signal
pd.DataFrame({t: informe(reversion[m], m)
for t, m in [("train", ent), ("validation", val),
("test", te)]}).T.round(3) IC gross Sharpe net Sharpe turnover
train 0.020 1.518 -0.565 1.374
validation 0.003 0.005 -2.127 1.363
test 0.016 1.217 -0.882 1.370A single feature gives a gross Sharpe of 1.52 in training and 1.22 in the test, with a validation period (2017-2018) in which it earns nothing (0.005). But it turns over 1.37 times its size every week, and with 10 bp per unit traded the net Sharpe is negative in all three segments. It is the pattern of almost all the weekly signals in this panel (data README: the oracle has a net Sharpe of 0.38 with costs): turnover decides.
Callbacks: hooks in the training loop
A callback is an object with methods that Keras calls at fixed points of the fit loop. They all receive the logs
dictionary with the metrics of the moment, and they can read it and write to it:
Mermaid source
flowchart TB A[on_train_begin] --> B[on_epoch_begin] B --> C[on_train_batch_end] C --> B2[Validation] B2 --> D[on_epoch_end] D --> B D --> E[on_train_end]
The built-in ones most used in finance:
| Callback | What it does | Detail that matters |
|---|---|---|
EarlyStopping | stops when the watched metric does not improve in patience epochs | mode="max" for metrics that go up; restore_best_weights=True |
ModelCheckpoint | saves the model (or the weights) to disk | save_best_only=True with the watched metric |
ReduceLROnPlateau | multiplies the rate by factor if the metric stalls | a patience shorter than the stopping one is advisable |
CSVLogger | writes one row per epoch with all the logs | the epoch column starts at 0 |
TerminateOnNaN | stops as soon as the loss is nan or infinite | essential with custom losses |
The custom piece, ICVal, computes the validation IC at the end of each epoch and writes it to logs["val_ic"].
Callbacks are called in the order of the list with the same dictionary, so ICVal goes first: the next three
watch val_ic in max mode. The network applies to each stock a hidden layer of 8 neurons with tanh and an output,
and it is trained with the squared error on the normal rank of the return, as in 5.6.2:
class ICVal(keras.callbacks.Callback):
"""At the end of each epoch: validation IC in logs["val_ic"],
so that the following callbacks can watch it."""
def on_epoch_end(self, epoca, logs=None):
s = self.model.predict(X[val], verbose=0)
logs["val_ic"] = float(informe(s, val)["IC"])
def red(semilla=0, ocultas=8, salida="zeros"):
keras.utils.set_random_seed(semilla)
x_in = keras.Input((None, K), name="features")
h = layers.Dense(ocultas, activation="tanh")(x_in)
s = layers.Dense(1, kernel_initializer=salida)(h)
return keras.Model(x_in, layers.Reshape((-1,), name="score")(s))
os.makedirs("ckpt", exist_ok=True)
callbacks = [
ICVal(), # first: writes val_ic
keras.callbacks.ModelCheckpoint(
"ckpt/mejor.keras", monitor="val_ic", mode="max",
save_best_only=True),
keras.callbacks.EarlyStopping(
monitor="val_ic", mode="max", patience=8,
restore_best_weights=True),
keras.callbacks.ReduceLROnPlateau(
monitor="val_ic", mode="max", factor=0.5, patience=4),
keras.callbacks.CSVLogger("historia.csv"),
keras.callbacks.TerminateOnNaN(),
]
mse_red = red()
mse_red.compile(keras.optimizers.Adam(3e-3), "mse")
h = mse_red.fit(X[ent], Y[ent], epochs=60, batch_size=16, verbose=0,
validation_data=(X[val], Y[val]), callbacks=callbacks)
hist = pd.read_csv("historia.csv").set_index("epoch")
print("epochs:", len(hist), "| best val_ic in epoch",
int(hist["val_ic"].idxmax()) + 1)
hist[["loss", "val_loss", "val_ic", "learning_rate"]].iloc[
[0, 4, 9, 14, -1]].round(5)epochs: 25 | best val_ic in epoch 17
loss val_loss val_ic learning_rate
epoch
0 0.99675 0.99691 0.00642 0.00300
4 0.99613 0.99691 0.01127 0.00300
9 0.99587 0.99696 0.01495 0.00300
14 0.99568 0.99722 0.01173 0.00150
24 0.99550 0.99729 0.01326 0.00075Training lasted 25 epochs: the best validation IC came in epoch 17 (0.0151; in the CSV it is row 16, because epoch
starts at 0) and, after 8 epochs without improvement, early stopping cut training and restored the weights of that
epoch. ReduceLROnPlateau lowered the rate from 0.003 to 0.0015 and then to 0.00075. And ckpt/mejor.keras holds the
model of epoch 17. The loss, meanwhile, says little: the training loss falls without stopping (from 0.99675 to
0.99550) and the validation loss oscillates in the fourth and fifth decimal places, between 0.99686 (epoch 11) and
0.99740, with a tendency to rise. The figure also shows how noisy the IC is from one epoch to the next: with 103 weeks
of validation, jumps of 0.004 are normal, and the “best epoch” is partly luck (the same warning as in 5.6.2).
A loss that is the portfolio's Sharpe
If what you want is the portfolio's Sharpe, why not minimize its negative? With the network applied to each stock and
the loss seeing the whole week, it can be written with keras.ops (so that it works in any backend):
where the mean and the standard deviation are taken over the weeks of the batch. The first trap appears as soon as you train with the network from the previous section, with the output initialized to zero:
def sharpe_negativo(y_true, y_pred):
"""-Annual Sharpe of the long-short portfolio of each week of
the batch. y_true: returns (weeks, stocks)."""
s = y_pred
s = s - ops.mean(s, axis=-1, keepdims=True)
w = s / ops.sum(ops.abs(s), axis=-1, keepdims=True)
rp = ops.sum(w * y_true, axis=-1)
return -ops.mean(rp) / ops.std(rp) * ops.sqrt(52.0)
cero = red(salida="zeros")
cero.compile(keras.optimizers.Adam(1e-3), sharpe_negativo)
h = cero.fit(X[ent], R[ent], epochs=3, batch_size=26, verbose=0,
callbacks=[keras.callbacks.TerminateOnNaN()])
print("losses:", h.history["loss"])Batch 0: Invalid loss, terminating training
losses: [nan]Trap 1: division by zero. With the output at zero, all the scores of a week are equal; subtracting the mean
leaves zeros, the sum of is 0 and the weights are . The loss is nan in the first batch and
TerminateOnNaN stops training. The zero initialization, which was the right one with the MSE (lesson 5.6.2), breaks
the loss here. The fix: an epsilon in both denominators and random initial weights in the output.
With that, the comparison with the network trained with MSE in the previous section (batches of 26 weeks, 80 epochs, Adam 0.001). The table gives the IC and the gross Sharpe in training, and the full report in the test:
def entrena_sharpe(perdida, semilla=0, epocas=80, barajar=True):
m = red(semilla, salida="glorot_uniform")
m.compile(keras.optimizers.Adam(1e-3), perdida)
m.fit(X[ent], R[ent], epochs=epocas, batch_size=26, verbose=0,
shuffle=barajar)
return m
def tabla(modelos):
"""Training: IC and gross Sharpe. Test: the full report."""
filas = {}
for nombre, m in modelos.items():
s = m.predict(X, verbose=0)
a, b = informe(s[ent], ent), informe(s[te], te)
filas[nombre] = {"train IC": a["IC"],
"train SR": a["gross Sharpe"],
"IC": b["IC"], "gross SR": b["gross Sharpe"],
"net SR": b["net Sharpe"],
"turn.": b["turnover"]}
return pd.DataFrame(filas).T.round(3)
def sharpe_eps(y_true, y_pred, eps=1e-6):
s = y_pred
s = s - ops.mean(s, axis=-1, keepdims=True)
w = s / (ops.sum(ops.abs(s), axis=-1, keepdims=True) + eps)
rp = ops.sum(w * y_true, axis=-1)
return -ops.mean(rp) / (ops.std(rp) + eps) * ops.sqrt(52.0)
modelos = {"MSE (ranks)": mse_red,
"Sharpe": entrena_sharpe(sharpe_eps)}
tabla(modelos) train IC train SR IC gross SR net SR turn.
MSE (ranks) 0.036 2.665 0.016 1.410 -0.176 0.878
Sharpe 0.030 4.371 0.014 1.569 -0.326 0.741Trap 2: overfitting to the metric. The network trained with the Sharpe has a gross Sharpe of 4.37 in training, against 2.67 with MSE; in the test, 1.57 against 1.41, and net, −0.33 against −0.18. Maximizing in training the same metric that is reported later puts the selection bias inside the training: the optimizer looks for the combinations that gave a good Sharpe in those 259 weeks, whether signal or chance. The figure at the top puts it on one scale: the drop from training to test is the largest of the four variants in the lesson.
Trap 3: the free scale. The Sharpe does not change if all the scores are multiplied by a constant (the weights are normalized). The loss does not push the network's weights toward any size, and whatever the random initialization put on the features without signal stays there if nothing removes it. One way to see it without depending on the scale is the sensitivity of the score to each feature, relative to the largest:
def sensibilidad(m):
"""Change in the score when the feature rises by 1 (rest at 0),
in proportion to the largest: free scale."""
uno = np.eye(K, dtype="float32")[None] # 1 week, K "stocks"
ef = (ops.convert_to_numpy(m(uno))[0]
- ops.convert_to_numpy(m(np.zeros_like(uno)))[0])
return ef / np.abs(ef).max()
sens = pd.DataFrame({n: sensibilidad(m) for n, m in modelos.items()},
index=FE)
ruido = [c for c in FE if c.startswith("ruido")]
print("mean relative weight of the five noise features:")
print(sens.loc[ruido].abs().mean().round(3).to_string())mean relative weight of the five noise features:
MSE (ranks) 0.231
Sharpe 0.460The five noise features weigh on average 46% of the most important feature in the Sharpe network, twice as much as in the MSE one (23%). With the squared error, which does have a scale, the gradient shrinks what does not help predict; with the Sharpe, it does not. The remedy is the usual one: weight decay or an explicit L2 penalty, and fewer parameters.
Trap 4: costs. The gross Sharpe ignores turnover, and in this panel turnover is what decides. Costs can be put
into the loss: with = 10 bp per unit traded, the net return of week is
. But turnover needs the previous week, and a batch only holds the weeks
of the batch: the term only measures the real turnover if the weeks of the batch are consecutive, that is, with
shuffle=False. The loss is written as a keras.losses.Loss class, with get_config and registered, so it can be
saved with the model (next section). It is trained with contiguous batches and, for comparison, shuffled ones:
@keras.saving.register_keras_serializable(package="curso")
class SharpeNeto(keras.losses.Loss):
"""-Net Sharpe: discounts cost × turnover between consecutive
weeks of the batch (requires contiguous batches: shuffle=False)."""
def __init__(self, coste=0.001, eps=1e-6, **kwargs):
super().__init__(**kwargs)
self.coste, self.eps = coste, eps
def call(self, y_true, y_pred):
s = y_pred - ops.mean(y_pred, axis=-1, keepdims=True)
w = s / (ops.sum(ops.abs(s), axis=-1, keepdims=True)
+ self.eps)
rp = ops.sum(w * y_true, axis=-1)
rot = ops.sum(ops.abs(w[1:] - w[:-1]), axis=-1)
rn = rp[1:] - self.coste * rot
return -ops.mean(rn) / (ops.std(rn) + self.eps) * ops.sqrt(52.0)
def get_config(self):
return {**super().get_config(), "coste": self.coste,
"eps": self.eps}
modelos["net Sharpe"] = entrena_sharpe(SharpeNeto(0.001),
barajar=False)
modelos["net Sharpe, shuffled"] = entrena_sharpe(SharpeNeto(0.001))
tabla(modelos) train IC train SR IC gross SR net SR turn.
MSE (ranks) 0.036 2.665 0.016 1.410 -0.176 0.878
Sharpe 0.030 4.371 0.014 1.569 -0.326 0.741
net Sharpe 0.026 3.369 0.011 1.298 0.261 0.431
net Sharpe, shuffled 0.023 2.921 0.016 1.694 0.643 0.436With the costs in the loss, the test turnover falls from 0.74-0.88 to 0.43-0.44, half, and the test net Sharpe turns positive: 0.26 with contiguous batches and 0.64 with shuffled ones. The gross Sharpe barely changes (1.30 and 1.69): what is gained is not paying costs. Two caveats:
- Trap 5: the order of the batches changes the meaning of the loss. With shuffled batches, the cost term does not
measure turnover: it measures the difference in weights between weeks chosen at random. It is a penalty on the
variability of the signal over time, which also pushes toward slow signals. Here it gave a better result, and
exercise 1 shows that it is not seed luck (0.58 on average against 0.21 over three seeds), but the reason is not the
one the name of the loss suggests. The only measurement that counts is the one afterwards: the real portfolio with
informe. - A single seven-year test. An annual Sharpe measured over 365 weeks has a standard error of the order of . A 0.6 is not yet firm evidence, and this lesson has tried several losses before looking at the test: the honest comparison goes through validation and the Deflated Sharpe (lesson 5.4.4).
Saving the model
The native format of Keras 3 is .keras: a ZIP with the configuration (config.json: architecture, loss,
optimizer), the metadata (Keras version) and the weights in HDF5. It also saves the optimizer state, to resume a
training run where it was left:
modelo = modelos["net Sharpe"] # compiled with SharpeNeto
modelo.save("senal.keras")
print(zipfile.ZipFile("senal.keras").namelist())
cfg = json.loads(zipfile.ZipFile("senal.keras").read("config.json"))
perd = cfg["compile_config"]["loss"]
print("saved loss:", perd["class_name"],
{k: perd["config"][k] for k in ("coste", "eps")})
recargado = keras.saving.load_model("senal.keras")
x_prueba = X[te][:4]
iguales = np.array_equal(
ops.convert_to_numpy(modelo(x_prueba)),
ops.convert_to_numpy(recargado(x_prueba)))
print("identical predictions after loading:", iguales)['metadata.json', 'config.json', 'model.weights.h5']
saved loss: SharpeNeto {'coste': 0.001, 'eps': 1e-06}
identical predictions after loading: TrueThe configuration stores the loss class and its parameters (coste, eps) thanks to get_config, and the reloaded
model gives bit-for-bit identical predictions. If the class is not registered, Keras does not know how to rebuild it:
class SharpeSinRegistro(keras.losses.Loss):
def call(self, y_true, y_pred):
return sharpe_eps(y_true, y_pred)
m = red()
m.compile(keras.optimizers.Adam(1e-3), SharpeSinRegistro())
m.save("sin_registro.keras")
try:
keras.saving.load_model("sin_registro.keras")
except TypeError as err:
print("TypeError:", str(err).split(". ")[0])
m2 = keras.saving.load_model("sin_registro.keras", compile=False)
print("with compile=False it loads; loss:", m2.loss)TypeError: Could not locate class 'SharpeSinRegistro'
with compile=False it loads; loss: NoneWithout @keras.saving.register_keras_serializable, loading fails because Keras cannot find the class. With
compile=False the model loads without loss or optimizer, which is all you need to predict. The rule of thumb:
the code that defines the custom classes is imported before loading, or the model is loaded without compiling for
inference.
Exporting for serving
For inference outside Keras, model.export generates artifacts that do not need Keras to run. The formats in Keras
3.15 are tf_saved_model, onnx, openvino, litert and torch. With the PyTorch backend, format="torch" uses
torch.export and writes a .pt2 program that is loaded with torch.export.load. The input signature fixes the
shape: one week of 400 stocks.
firma = [keras.InputSpec(shape=(1, N, K), dtype="float32")]
modelo.export("senal.pt2", format="torch", verbose=False,
input_signature=firma)
programa = torch.export.load("senal.pt2")
semana = torch.tensor(X[te][:1]) # 1 week, 400 stocks
p_torch = programa.module()(semana).detach().numpy()
p_keras = ops.convert_to_numpy(modelo(X[te][:1]))
print("shape:", p_torch.shape, "| max. difference < 1e-6:",
bool(np.abs(p_torch - p_keras).max() < 1e-6))
import importlib.util
print("onnx installed:", importlib.util.find_spec("onnx") is not None)shape: (1, 400) | max. difference < 1e-6: True
onnx installed: FalseThe exported program gives the same 400 scores as the Keras model (maximum difference below ). ONNX, the
most widespread interchange format for serving models in other languages and runtimes, cannot be tried here: onnx is
not installed in the course environment (it has wheels for Python 3.14 on PyPI, but it is not part of the level 5
stack). The code would be this:
# uv add onnx onnxscript onnxruntime (outside this environment)
modelo.export("senal.onnx", format="onnx")
import onnxruntime as ort
sesion = ort.InferenceSession("senal.onnx")
nombre = sesion.get_inputs()[0].name
scores = sesion.run(None, {nombre: X[te][:1]})[0]And the portability between backends of lesson 5.6.1 also holds for the full .keras: a process with
KERAS_BACKEND=jax loads the file saved with PyTorch (without compiling, because it only predicts) and gives the same
scores:
import subprocess
import sys
np.save("semana.npy", X[te][:1])
codigo = """
import json, numpy as np, keras
m = keras.saving.load_model("senal.keras", compile=False)
p = keras.ops.convert_to_numpy(m(np.load("semana.npy")))
np.save("p_jax.npy", p)
print(keras.config.backend())
"""
env = {**os.environ, "KERAS_BACKEND": "jax"}
salida = subprocess.run([sys.executable, "-c", codigo], env=env,
capture_output=True, text=True, check=True)
p_jax = np.load("p_jax.npy")
print(salida.stdout.strip(), "| equal to PyTorch (1e-6):",
bool(np.abs(p_jax - p_keras).max() < 1e-6))jax | equal to PyTorch (1e-6): TrueInference: the model inside a process
In production the model does not receive a ready-made tensor, but the rows of a week as they arrive from the data process. Between the two sits the transformation (normal rank by week) and a contract: which features, in what order, with what transformation and with which version it was trained. It is saved next to the model and checked on every call:
Mermaid source
flowchart TB F[Rows of the week] --> C[Contract] C --> T[Normal rank] T --> M[.keras model] M --> W[Portfolio weights] W --> P[Parity with lab]
The function in the cell validates that all the features are there, sorts the stocks, applies the same transformation
as in training and returns the weights. To test it, it receives the columns out of order (FE[::-1]) and is
compared with the weights computed in the lab for that same week:
CONTRATO = {"features": FE, "transformacion": "weekly normal rank",
"keras": keras.__version__}
with open("senal.json", "w") as fh:
json.dump(CONTRATO, fh)
def pesos_semana(filas, modelo, contrato):
"""filas: DataFrame for one date with 'ticker' and the features."""
falta = set(contrato["features"]) - set(filas.columns)
if falta:
raise ValueError(f"missing features: {sorted(falta)}")
filas = filas.sort_values("ticker").assign(fecha=0)
g = rango_normal(filas, contrato["features"])
x = g[contrato["features"]].to_numpy("float32")[None]
s = modelo.predict(x, verbose=0)[0]
return pd.Series(pesos(s[None])[0], index=filas["ticker"])
ultima = d[d["fecha"] == fechas[te][-1]]
w_prod = pesos_semana(ultima[["ticker"] + FE[::-1]], recargado,
json.load(open("senal.json")))
w_lab = pesos(ops.convert_to_numpy(recargado(X[te][-1:])))[0]
print("sum of |weights|:", round(float(w_prod.abs().sum()), 6),
"| net:", round(float(w_prod.sum()), 6) + 0.0)
print("same as in the lab:", np.allclose(w_prod, w_lab,
atol=1e-7))
w_prod.sort_values().iloc[[0, 1, -2, -1]].round(5)sum of |weights|: 1.0 | net: 0.0
same as in the lab: True
ticker
CENO -0.00666
CUCU -0.00643
ROVO 0.00707
VUVE 0.00744
dtype: float32The weights add up to 1 in absolute value and 0 net, and they match those of the lab even though the columns arrived in another order: the contract reorders them. The largest positions are 0.7% of the gross notional, because the portfolio spreads the risk across 400 stocks.
What a parity test like this catches, and what would otherwise only show up in the losses: columns in a different order, a feature computed with another window, a different transformation (standardizing with the mean of the whole history instead of the weekly rank), another Keras version with a different default behavior. Lessons 4.5.1 (architecture of a live system) and 4.5.3 (paper and shadow trading) cover operations; here the rule is the same: the model is deployed with its contract and with a reference week whose weights are known.
Exercises
- The results of the net Sharpe losses come from a single seed. Train the contiguous-batch version and the shuffled one with seeds 0, 1 and 2 and compare the mean test net Sharpe and its dispersion.
Solution
filas = {}
for nombre, barajar in [("contiguous", False), ("shuffled", True)]:
sr = []
for s in range(3):
m = entrena_sharpe(SharpeNeto(0.001), semilla=s,
barajar=barajar)
sr.append(informe(m.predict(X[te], verbose=0), te)
["net Sharpe"])
filas[nombre] = {"seed 0": sr[0], "seed 1": sr[1],
"seed 2": sr[2], "mean": np.mean(sr)}
pd.DataFrame(filas).T.round(3) seed 0 seed 1 seed 2 mean
contiguous 0.261 0.238 0.122 0.207
shuffled 0.643 0.645 0.442 0.577With contiguous batches, the test net Sharpe ranges from 0.12 to 0.26 depending on the seed (mean 0.21); with shuffled batches, from 0.44 to 0.65 (mean 0.58). The advantage of the shuffled version repeats across the three seeds, so it is not initialization luck. A reasonable explanation: with fixed batches of 26 consecutive weeks, the optimizer always sees the same ten blocks and the penalty only acts within each one; by shuffling, it compares distant weeks with each other and punishes more any dependence of the signal on the regime of a particular year. Before using it, you would have to confirm it on validation and discount all the variants tried.
- The program exported to
.pt2was traced for 400 stocks. What happens if tomorrow the universe has 401? Check it and propose how to solve it.
Solution
mas_una = torch.tensor(np.concatenate([X[te][:1], X[te][:1, :1]], 1))
try:
programa.module()(mas_una)
except AssertionError as err:
print("AssertionError:", str(err).splitlines()[0])
print("the .keras model does accept 401:",
tuple(recargado(mas_una.numpy()).shape))AssertionError: Guard failed: args_0.size()[1] == 400
the .keras model does accept 401: (1, 401)The .pt2 program checks the shape of the input with a guard and rejects 401 stocks; the .keras accepts them,
because its input was declared with None stocks. There are three ways out: export with dynamic dimensions (the
dynamic_shapes argument of torch.export), pad up to a fixed maximum size with dummy stocks of zero weight, or
export again when the universe changes. In all three cases, a change of universe is a change of the model's contract
and is versioned as such.
- In this lesson's portfolio the weights add up to 1 in absolute value and 0 net. If the mean subtraction were removed from the Sharpe loss (no neutrality), what would the model learn with data in which the market rises on average?
Solution
Without subtracting the weekly mean of the scores, the portfolio can end up net long. In a sample where the equal-weighted portfolio returns 9.3% a year with a Sharpe of 0.52 (data README), the easiest way to raise the loss's Sharpe is to load up on beta: the model would learn a positive bias in the score, weighted toward the higher-beta stocks in the bullish stretches of the training period, and would present the market premium of those years as a “signal”. Out of sample, that exposure loses in the downturns (2020, 2022, 2025 in these data). Neutrality is imposed in the construction of the portfolio itself inside the loss (as here) or the net exposure and the beta are penalized, and the result is always measured against the market portfolio.
Each answer is folded under its question
Self-assessment
A custom callback computes the validation IC and writes it to
logs["val_ic"]. Why does it have to go first in the list of callbacks?- ABecause Keras only runs the first one
- BBecause that way it runs faster
- CThe order does not matter
- DBecause callbacks are called in the order of the list with the same
logsdictionary: ifEarlyStoppingorModelCheckpointgo before it, they look forval_icwhen it does not exist yet
Show answer
Answer D. Because callbacks are called in the order of the list with the same
logsdictionary: ifEarlyStoppingorModelCheckpointgo before it, they look forval_icwhen it does not exist yetAt the end of each epoch Keras goes through the list in order. A callback that watches
val_icbefore another one writes it does not find it (it warns and does nothing) or, worse, uses the one from the previous epoch.With the network's output initialized to zero, the Sharpe loss gives
nanin the first batch. Why?- ABecause all the scores are equal: subtracting the mean leaves zeros, the sum of |weights| is 0 and the weights are 0/0; TerminateOnNaN stops training at that batch
- BBecause of a Keras bug
- CBecause the learning rate is too high
- DBecause the returns have gaps
Show answer
Answer A. Because all the scores are equal: subtracting the mean leaves zeros, the sum of |weights| is 0 and the weights are 0/0; TerminateOnNaN stops training at that batch
A normalized portfolio loss is not defined if the signal is constant. It is fixed with an epsilon in the denominators and with a random initialization of the output, exactly the opposite of what suited the MSE (lesson 5.6.2).
The network trained with the Sharpe loss has a gross Sharpe of 4.37 in training and 1.57 in the test. The one trained with MSE, 2.67 and 1.41. What do you conclude?
- AThat the Sharpe loss is much better
- BThat the Sharpe loss overfits more: it directly maximizes what is measured in training, and out of sample the advantage almost disappears (and net of costs it is worse: −0.33 against −0.18)
- CThat the MSE is badly implemented
- DThat you need to train more epochs with the Sharpe
Show answer
Answer B. That the Sharpe loss overfits more: it directly maximizes what is measured in training, and out of sample the advantage almost disappears (and net of costs it is worse: −0.33 against −0.18)
Optimizing the Sharpe in training is optimizing the metric that is reported later: the selection bias is inside the training. The drop from 4.37 to 1.57 is the measure of that overfitting.
The net Sharpe loss discounts the turnover between consecutive weeks of the batch. With shuffled batches, what does that term measure?
- AThe portfolio's real turnover
- BNothing: Keras ignores the order
- CThe difference in weights between weeks chosen at random: it is not the turnover but a penalty on the variability of the signal over time; here it worked better (mean net Sharpe of 0.58 against 0.21 over three seeds), but for a reason different from what its name says
- DThe cost of the last week
Show answer
Answer C. The difference in weights between weeks chosen at random: it is not the turnover but a penalty on the variability of the signal over time; here it worked better (mean net Sharpe of 0.58 against 0.21 over three seeds), but for a reason different from what its name says
With
shuffle=Truethe weeks of a batch are not consecutive. The penalty still pushes toward slow signals, which is what really reduces turnover. What is measured afterwards, with the real portfolio, is what counts.The model is exported to
.pt2with a signature of 1 week × 400 stocks. A stock is added to the universe. What happens?- AIt works the same
- BIt predicts 400 and drops the last one
- CThe exported program rejects the input (the shape was fixed at export time); the
.kerasdoes accept 401 stocks. You have to export again or declare dynamic dimensions, and treat it as a model version change - DIt returns zeros
Show answer
Answer C. The exported program rejects the input (the shape was fixed at export time); the
.kerasdoes accept 401 stocks. You have to export again or declare dynamic dimensions, and treat it as a model version changetorch.exporttraces the program with concrete shapes and adds guards. A change of universe is a change of the input contract, just like a change of features.
Key points
- A callback can write metrics to
logsso that others watch them: the validation IC as the criterion forEarlyStopping,ModelCheckpointandReduceLROnPlateau. The order of the list matters. - A Sharpe loss is written in a few lines with
keras.ops, but it needs a well-defined portfolio (neutral and normalized), an epsilon against 0/0 and random initial weights. - Optimizing the Sharpe in training overfits: 4.37 in training and 1.57 in the test, against 2.67 and 1.41 with MSE. Its free scale leaves the weights of the noise features unshrunk.
- Putting the costs in the loss halves the turnover (0.43 against 0.88) and takes the test net Sharpe into positive territory; with three seeds, 0.21 on average with contiguous batches and 0.58 with shuffled ones, still within the error of seven years.
.kerassaves architecture, weights, loss and optimizer, and loads in any backend; a custom class needsregister_keras_serializableandget_config..pt2(torch.export) fixes the input shape.- In production, the model travels with its contract (features, order, transformation) and a parity test against the lab.