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.

Fig. — Gross annual Sharpe of the weekly long-short portfolio in training (2012-2016) and test (2019-2025) for the same network trained with four losses, on the level 5 panel
Level 5 lessons
  1. 5.1 · ML fundamentals applied to finance

    1. 5.1.1 Why financial machine learning is different
    2. 5.1.2 Supervised, unsupervised and reinforcement learning; scikit-learn pipelines
    3. 5.1.3 Economic metrics for trading models: beyond accuracy
  2. 5.2 · Feature engineering

    1. 5.2.1 Price, volume and microstructure features
    2. 5.2.2 Point-in-time fundamentals, macro and alternative data
    3. 5.2.3 Fractional differentiation for features
    4. 5.2.4 Leakage-free normalization and cross-sectional rank
    5. 5.2.5 Feature importance: MDI, MDA, SFI and SHAP
  3. 5.3 · Labelling and sampling

    1. 5.3.1 Fixed-horizon labeling and its problems
    2. 5.3.2 Triple-barrier method: labels that mimic a trade
    3. 5.3.3 Meta-labeling: one model for the side and another for the size
    4. 5.3.4 Label uniqueness, overlap weights and the sequential bootstrap
    5. 5.3.5 Event-based sampling: the symmetric CUSUM filter
  4. 5.4 · Validation for financial series

    1. 5.4.1 Why random k-fold fails on financial data
    2. 5.4.2 Purged k-fold with embargo and CPCV for ML models
    3. 5.4.3 Walk-forward with retraining: windows and frequency
    4. 5.4.4 Backtesting ML signals with costs and Deflated Sharpe
  5. 5.5 · Classical models

    1. 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
    2. 5.5.2 Decision trees, Random Forest and bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
    4. 5.5.4 Optuna and the risk of overfitting the validation
    5. 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
    6. 5.5.6 Learning to rank for stock selection
  6. 5.6 · Deep learning with Keras 3

    1. 5.6.1 Keras 3: one model, several compute engines
    2. 5.6.2 An MLP on a stock panel: regularize and compare
    3. 5.6.3 1D CNN, LSTM and GRU: windows, scaling and leaks
    4. 5.6.4 Callbacks, a Sharpe loss and the model in production
  7. 5.7 · Deep learning with PyTorch

    1. 5.7.1 PyTorch: tensors, autograd and the training loop
    2. 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
    3. 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
    4. 5.7.4 Graph neural networks for asset relationships
    5. 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
  8. 5.8 · Time series foundation models

    1. 5.8.1 Time series foundation models: pretrain once, forecast without training
    2. 5.8.2 Foundation model families: architecture, data and license
    3. 5.8.3 In practice: context, quantiles, covariates and fine-tuning
    4. 5.8.4 Foundation models in finance: what the evidence says
    5. 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
  9. 5.9 · Reinforcement learning

    1. 5.9.1 MDP, Q-learning, PPO and Gymnasium environments
    2. 5.9.2 RL for execution, market making and dynamic hedging
    3. 5.9.3 Why reinforcement learning overfits in directional trading
  10. 5.10 · Interpretability, robustness and model risk

    1. 5.10.1 SHAP, partial dependence and the stability of feature importances
    2. 5.10.2 Data drift and concept drift: detecting them and what to do
    3. 5.10.3 Ensembles, signal combination and synthetic data

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):

IN [01]python
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()))
OUT [01]The panel as a tensor of weeks by stocks
X: (729, 400, 21) | weeks: 259 103 365

That 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 ∑i∣wi∣=1\sum_i |w_i| = 1 (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, ∑i∣wi,t−wi,t−1∣\sum_i |w_{i,t} - w_{i,t-1}|. As a reference, the signal of a single feature, the one-week reversal (ret_1s with the sign flipped):

IN [02]python
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)
OUT [02]Measuring a signal: IC, Sharpe and turnover of a portfolio
               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.370

A 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:

on_train_beginon_epoch_beginon_train_batch_endValidationon_epoch_endon_train_end
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:

CallbackWhat it doesDetail that matters
EarlyStoppingstops when the watched metric does not improve in patience epochsmode="max" for metrics that go up; restore_best_weights=True
ModelCheckpointsaves the model (or the weights) to disksave_best_only=True with the watched metric
ReduceLROnPlateaumultiplies the rate by factor if the metric stallsa patience shorter than the stopping one is advisable
CSVLoggerwrites one row per epoch with all the logsthe epoch column starts at 0
TerminateOnNaNstops as soon as the loss is nan or infiniteessential 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:

IN [03]python
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)
OUT [03]A callback that computes the IC and leaves it in the logs
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.00075
Fig. — Validation IC epoch by epoch of a network trained with squared loss and five callbacks: the custom IC, best-model checkpoint, early stopping (patience 8) and halving of the learning rate

Training 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):

wi,t=si,t−s‾t∑j∣sj,t−s‾t∣,rp,t=∑iwi,t ri,t+1,L=−52 mean(rp)sd(rp)w_{i,t} = \frac{s_{i,t} - \overline{s}_t}{\sum_j |s_{j,t} - \overline{s}_t|}, \qquad r_{p,t} = \sum_i w_{i,t}\, r_{i,t+1}, \qquad \mathcal{L} = -\sqrt{52}\, \frac{\text{mean}(r_p)}{\text{sd}(r_p)}

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:

IN [04]python
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"])
OUT [04]A Sharpe loss with keras.ops
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 ∣s−s‾∣|s - \overline{s}| is 0 and the weights are 0/00/0. 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:

IN [05]python
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)
OUT [05]Training with the Sharpe versus training with the MSE
             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

Trap 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:

IN [06]python
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())
OUT [06]The weights the Sharpe does not shrink
mean relative weight of the five noise features:
MSE (ranks)    0.231
Sharpe         0.460

The 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 cc = 10 bp per unit traded, the net return of week tt is rp,t−c∑i∣wi,t−wi,t−1∣r_{p,t} - c \sum_i |w_{i,t} - w_{i,t-1}|. 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:

IN [07]python
@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)
OUT [07]Costs inside the loss: contiguous batches
                      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.436
Fig. — Weekly turnover and test net Sharpe (2019-2025, 10 bp per unit traded) of the network trained with four losses: the two that deduct costs trade half as much

With 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 1/7≈0.381/\sqrt{7} \approx 0.38. 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:

IN [08]python
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)
OUT [08]Saving and loading a model with its loss
['metadata.json', 'config.json', 'model.weights.h5']
saved loss: SharpeNeto {'coste': 0.001, 'eps': 1e-06}
identical predictions after loading: True

The 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:

IN [09]python
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)
OUT [09]What happens if the class is not registered
TypeError: Could not locate class 'SharpeSinRegistro'
with compile=False it loads; loss: None

Without @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.

IN [10]python
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)
OUT [10]Exporting to PyTorch (torch.export) and checking parity
shape: (1, 400) | max. difference < 1e-6: True
onnx installed: False

The exported program gives the same 400 scores as the Keras model (maximum difference below 10−610^{-6}). 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:

IN [11]python
# 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:

IN [12]python
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))
OUT [12]The .keras model in another backend
jax | equal to PyTorch (1e-6): True

Inference: 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:

Rows of the weekContractNormal rank.keras modelPortfolio weightsParity with lab
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:

IN [13]python
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)
OUT [13]Inference: from the rows of a week to the portfolio weights
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: float32

The 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

  1. 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
IN [14]python
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)
OUT [14]Three seeds per variant
            seed 0  seed 1  seed 2   mean
contiguous   0.261   0.238   0.122  0.207
shuffled     0.643   0.645   0.442  0.577

With 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.

  1. The program exported to .pt2 was traced for 400 stocks. What happens if tomorrow the universe has 401? Check it and propose how to solve it.
Solution
IN [15]python
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))
OUT [15]A universe of 401 stocks
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.

  1. 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

  1. 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 logs dictionary: if EarlyStopping or ModelCheckpoint go before it, they look for val_ic when it does not exist yet
    Show answer

    Answer D. Because callbacks are called in the order of the list with the same logs dictionary: if EarlyStopping or ModelCheckpoint go before it, they look for val_ic when it does not exist yet

    At the end of each epoch Keras goes through the list in order. A callback that watches val_ic before another one writes it does not find it (it warns and does nothing) or, worse, uses the one from the previous epoch.

  2. With the network's output initialized to zero, the Sharpe loss gives nan in 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).

  3. 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.

  4. 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=True the 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.

  5. The model is exported to .pt2 with 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 .keras does 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 .keras does accept 401 stocks. You have to export again or declare dynamic dimensions, and treat it as a model version change

    torch.export traces 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 logs so that others watch them: the validation IC as the criterion for EarlyStopping, ModelCheckpoint and ReduceLROnPlateau. 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.
  • .keras saves architecture, weights, loss and optimizer, and loads in any backend; a custom class needs register_keras_serializable and get_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.

Resources

Data