Lección 5.6.4 · 75 min

Callbacks, una pérdida de Sharpe y el modelo en producción

Entrenar con el error cuadrático y elegir con el Sharpe deja un hueco entre lo que el modelo optimiza y lo que interesa. Esta lección lo cierra por los dos lados: callbacks que vigilan el IC durante el entrenamiento y una pérdida que es el Sharpe de la cartera, con sus trampas medidas. Después guarda el modelo, lo exporta, lo carga en otro backend y lo convierte en pesos de una cartera.

Fig. — Sharpe bruto anual de la cartera larga-corta semanal en entreno (2012-2016) y en prueba (2019-2025) de la misma red entrenada con cuatro pérdidas, en el panel del nivel 5
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

El hueco entre la pérdida y el objetivo

En la lección 5.6.2 la red se entrenaba con el error cuadrático y se elegía con el IC, y la cartera, que es lo que gana o pierde dinero, se calculaba aparte. Hay dos formas de acercar el entrenamiento a lo que importa:

  • Vigilar la métrica buena durante el entrenamiento: callbacks que calculan el IC o el Sharpe de validación y deciden cuándo parar, qué pesos guardar y cuándo bajar la tasa de aprendizaje.
  • Optimizarla directamente: una pérdida que sea el Sharpe de la cartera que forman las predicciones. Es la idea de trabajos como los de Lim, Zohren y Roberts (2019) y Zhang, Zohren y Roberts (2020), que entrenan redes con el Sharpe como función objetivo.

Para las dos, el panel se organiza de otra forma: cada muestra es una semana entera, una matriz de 400 acciones por 21 features, y la etiqueta, los 400 rendimientos de la semana siguiente. Una capa Dense actúa sobre el último eje, así que la misma red se aplica a cada acción y la pérdida ve la sección cruzada completa de cada semana. Las features son rangos normales por semana (lección 5.2.4) y las particiones, las de 5.6.2 (backend PyTorch, el de serie):

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):
    """Rango normal de cada columna en cada fecha; sin dato, 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)                # semanas con etiqueta
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, "| semanas:", int(ent.sum()), int(val.sum()),
      int(te.sum()))
OUT [01]El panel como tensor de semanas por acciones
X: (729, 400, 21) | semanas: 259 103 365

Quedan 729 semanas completas: 259 de entreno, 103 de validación y 365 de prueba.

La función informe es el backtest mínimo de una señal transversal: la cartera de cada semana es larga en las acciones con puntuación por encima de la media y corta en las de debajo, en proporción a la distancia, con ∑i∣wi∣=1\sum_i |w_i| = 1 (medio dólar largo y medio corto). Da el IC medio, el Sharpe anual bruto, el neto con 10 puntos básicos por unidad negociada y la rotación semanal, ∑i∣wi,t−wi,t−1∣\sum_i |w_{i,t} - w_{i,t-1}|. Como referencia, la señal de una sola feature, la reversión de una semana (ret_1s con el signo cambiado):

IN [02]python
def pesos(s):
    """Larga-corta: puntuación menos su media de la semana, con
    suma de |pesos| = 1 (0,5 largo y 0,5 corto)."""
    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):
    """IC medio, Sharpe anual bruto y neto (coste por unidad
    negociada) y rotación semanal de la cartera de `score`, la
    puntuación de las semanas `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, "Sharpe bruto": anual(rp),
            "Sharpe neto": anual(neto), "rotación": rot.mean()}

reversion = -X[..., FE.index("ret_1s")]       # señal de una feature
pd.DataFrame({t: informe(reversion[m], m)
              for t, m in [("entreno", ent), ("validación", val),
                           ("prueba", te)]}).T.round(3)
OUT [02]Medir una señal: IC, Sharpe y rotación de una cartera
               IC  Sharpe bruto  Sharpe neto  rotación
entreno     0.020         1.518       -0.565     1.374
validación  0.003         0.005       -2.127     1.363
prueba      0.016         1.217       -0.882     1.370

Una sola feature da un Sharpe bruto de 1,52 en entreno y 1,22 en prueba, con un año de validación (2017-2018) en el que no gana nada (0,005). Pero rota 1,37 veces su tamaño cada semana, y con 10 pb por unidad negociada el Sharpe neto es negativo en los tres tramos. Es el patrón de casi todas las señales semanales de este panel (README de los datos: el oráculo tiene un Sharpe neto de 0,38 con costes): la rotación decide.

Callbacks: ganchos en el bucle de entrenamiento

Un callback es un objeto con métodos que Keras llama en momentos fijos del bucle de fit. Todos reciben el diccionario logs con las métricas del momento, y pueden leerlo y escribir en él:

on_train_beginon_epoch_beginon_train_batch_endValidaciónon_epoch_endon_train_end
Fuente Mermaid
flowchart TB
  A[on_train_begin] --> B[on_epoch_begin]
  B --> C[on_train_batch_end]
  C --> B2[Validación]
  B2 --> D[on_epoch_end]
  D --> B
  D --> E[on_train_end]

Los de serie que más se usan en finanzas:

CallbackQué haceDetalle que importa
EarlyStoppingpara cuando la métrica vigilada no mejora en patience épocasmode="max" para métricas que suben; restore_best_weights=True
ModelCheckpointguarda el modelo (o los pesos) en discosave_best_only=True con la métrica vigilada
ReduceLROnPlateaumultiplica la tasa por factor si la métrica se estancaconviene una paciencia menor que la de la parada
CSVLoggerescribe una fila por época con todos los logsla columna epoch empieza en 0
TerminateOnNaNpara en cuanto la pérdida es nan o infinitaimprescindible con pérdidas propias

La pieza propia, ICVal, calcula el IC de validación al final de cada época y lo escribe en logs["val_ic"]. Los callbacks se llaman en el orden de la lista con el mismo diccionario, así que ICVal va el primero: los tres siguientes vigilan val_ic en modo máximo. La red aplica a cada acción una capa oculta de 8 neuronas con tanh y una salida, y se entrena con el error cuadrático sobre el rango normal del rendimiento, como en 5.6.2:

IN [03]python
class ICVal(keras.callbacks.Callback):
    """Al final de cada época: IC de validación en logs["val_ic"],
    para que los callbacks siguientes puedan vigilarlo."""
    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(),                                  # primero: escribe 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("épocas:", len(hist), "| mejor val_ic en la época",
      int(hist["val_ic"].idxmax()) + 1)
hist[["loss", "val_loss", "val_ic", "learning_rate"]].iloc[
    [0, 4, 9, 14, -1]].round(5)
OUT [03]Un callback que calcula el IC y lo deja en los logs
épocas: 25 | mejor val_ic en la época 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. — IC de validación época a época de una red entrenada con la pérdida cuadrática y cinco callbacks: el IC propio, guardado del mejor modelo, parada temprana (paciencia 8) y reducción de la tasa a la mitad

El entrenamiento duró 25 épocas: el mejor IC de validación llegó en la 17 (0,0151; en el CSV es la fila 16, porque epoch empieza en 0) y, tras 8 épocas sin mejorar, la parada temprana cortó y devolvió los pesos de esa época. ReduceLROnPlateau bajó la tasa de 0,003 a 0,0015 y después a 0,00075. Y en ckpt/mejor.keras quedó guardado el modelo de la época 17. La pérdida, mientras tanto, apenas informa: la de entreno baja sin parar (de 0,99675 a 0,99550) y la de validación oscila en la cuarta y quinta cifra decimal, entre 0,99686 (época 11) y 0,99740, con tendencia a subir. La figura muestra además lo ruidoso que es el IC de una época a otra: con 103 semanas de validación, saltos de 0,004 son normales, y la «mejor época» es en parte suerte (la misma advertencia de 5.6.2).

Una pérdida que es el Sharpe de la cartera

Si lo que se quiere es el Sharpe de la cartera, ¿por qué no minimizar su negativo? Con la red aplicada a cada acción y la pérdida viendo la semana entera, se puede escribir con keras.ops (para que valga en cualquier backend):

wi,t=si,t−s‾t∑j∣sj,t−s‾t∣,rp,t=∑iwi,t ri,t+1,L=−52 media(rp)desv(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{media}(r_p)}{\text{desv}(r_p)}

donde la media y la desviación se toman sobre las semanas del lote. La primera trampa aparece en cuanto se entrena con la red de la sección anterior, con la salida inicializada a cero:

IN [04]python
def sharpe_negativo(y_true, y_pred):
    """-Sharpe anual de la cartera larga-corta de cada semana del
    lote. y_true: rendimientos (semanas, acciones)."""
    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("pérdidas:", h.history["loss"])
OUT [04]Una pérdida de Sharpe con keras.ops
Batch 0: Invalid loss, terminating training
pérdidas: [nan]

Trampa 1: la división por cero. Con la salida a cero, todas las puntuaciones de una semana son iguales; al restar la media quedan ceros, la suma de ∣s−s‾∣|s - \overline{s}| es 0 y los pesos son 0/00/0. La pérdida es nan en el primer lote y TerminateOnNaN corta el entrenamiento. La inicialización a cero, que era la buena con el MSE (lección 5.6.2), aquí rompe la pérdida. El arreglo: un épsilon en los dos denominadores y pesos iniciales aleatorios en la salida.

Con eso, la comparación con la red entrenada con MSE de la sección anterior (lotes de 26 semanas, 80 épocas, Adam 0,001). La tabla da el IC y el Sharpe bruto en entreno, y el informe completo en la prueba:

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):
    """Entreno: IC y Sharpe bruto. Prueba: todo el informe."""
    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] = {"IC ent": a["IC"], "SR ent": a["Sharpe bruto"],
                         "IC": b["IC"], "SR bruto": b["Sharpe bruto"],
                         "SR neto": b["Sharpe neto"],
                         "rot.": b["rotación"]}
    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 (rangos)": mse_red,
           "Sharpe": entrena_sharpe(sharpe_eps)}
tabla(modelos)
OUT [05]Entrenar con el Sharpe frente a entrenar con el MSE
              IC ent  SR ent     IC  SR bruto  SR neto   rot.
MSE (rangos)   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

Trampa 2: el sobreajuste a la métrica. La red entrenada con el Sharpe tiene un Sharpe bruto de 4,37 en entreno, frente a 2,67 con MSE; en prueba, 1,57 frente a 1,41, y en neto, −0,33 frente a −0,18. Maximizar en entreno la misma métrica que luego se reporta mete el sesgo de selección dentro del entrenamiento: el optimizador busca las combinaciones que dieron buen Sharpe en esas 259 semanas, sean señal o casualidad. La figura de la cabecera lo pone en una escala: la caída de entreno a prueba es la mayor de las cuatro variantes de la lección.

Trampa 3: la escala libre. El Sharpe no cambia si todas las puntuaciones se multiplican por una constante (los pesos se normalizan). La pérdida no empuja los pesos de la red hacia ningún tamaño, y lo que la inicialización aleatoria puso en las features sin señal se queda ahí si no hay nada que lo quite. Una forma de verlo sin depender de la escala es la sensibilidad de la puntuación a cada feature, relativa a la mayor:

IN [06]python
def sensibilidad(m):
    """Cambio de la puntuación al subir 1 la feature (resto en 0),
    en proporción a la mayor: escala libre."""
    uno = np.eye(K, dtype="float32")[None]     # 1 semana, K "acciones"
    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("peso relativo medio de las cinco features de ruido:")
print(sens.loc[ruido].abs().mean().round(3).to_string())
OUT [06]Los pesos que el Sharpe no encoge
peso relativo medio de las cinco features de ruido:
MSE (rangos)    0.231
Sharpe          0.460

Las cinco features de ruido pesan de media el 46 % de la feature más importante en la red del Sharpe, el doble que en la del MSE (23 %). Con el error cuadrático, que sí tiene escala, el gradiente encoge lo que no ayuda a predecir; con el Sharpe, no. El remedio es el de siempre: weight decay o una penalización L2 explícita, y menos parámetros.

Trampa 4: los costes. El Sharpe bruto ignora la rotación, y en este panel la rotación es lo que decide. Los costes se pueden meter en la pérdida: con cc = 10 pb por unidad negociada, el rendimiento neto de la semana tt es rp,t−c∑i∣wi,t−wi,t−1∣r_{p,t} - c \sum_i |w_{i,t} - w_{i,t-1}|. Pero la rotación necesita la semana anterior, y en un lote solo están las semanas del lote: el término solo mide la rotación real si las semanas del lote son consecutivas, es decir, con shuffle=False. La pérdida se escribe como clase de keras.losses.Loss, con get_config y registrada, para poder guardarla con el modelo (sección siguiente). Se entrena con lotes contiguos y, para comparar, barajados:

IN [07]python
@keras.saving.register_keras_serializable(package="curso")
class SharpeNeto(keras.losses.Loss):
    """-Sharpe neto: descuenta coste × rotación entre semanas
    consecutivas del lote (exige lotes contiguos: 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["Sharpe neto"] = entrena_sharpe(SharpeNeto(0.001),
                                        barajar=False)
modelos["Sharpe neto, barajado"] = entrena_sharpe(SharpeNeto(0.001))
tabla(modelos)
OUT [07]Costes dentro de la pérdida: lotes contiguos
                       IC ent  SR ent     IC  SR bruto  SR neto   rot.
MSE (rangos)            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
Sharpe neto             0.026   3.369  0.011     1.298    0.261  0.431
Sharpe neto, barajado   0.023   2.921  0.016     1.694    0.643  0.436
Fig. — Rotación semanal y Sharpe neto en prueba (2019-2025, 10 pb por unidad negociada) de la red entrenada con cuatro pérdidas: las dos que descuentan costes rotan la mitad

Con los costes en la pérdida, la rotación de prueba baja de 0,74-0,88 a 0,43-0,44, la mitad, y el Sharpe neto de prueba pasa a positivo: 0,26 con lotes contiguos y 0,64 con barajados. El Sharpe bruto apenas cambia (1,30 y 1,69): lo que se gana es no pagar costes. Dos cautelas:

  • Trampa 5: el orden de los lotes cambia el significado de la pérdida. Con lotes barajados, el término de costes no mide la rotación: mide la diferencia de pesos entre semanas elegidas al azar. Es una penalización de la variabilidad de la señal en el tiempo, que también empuja hacia señales lentas. Aquí dio mejor resultado, y el ejercicio 1 muestra que no es casualidad de la semilla (0,58 de media frente a 0,21 en tres semillas), pero el motivo no es el que dice el nombre de la pérdida. La única medida que vale es la de después: la cartera real con informe.
  • Una sola prueba de siete años. Un Sharpe anual medido en 365 semanas tiene un error típico del orden de 1/7≈0,381/\sqrt{7} \approx 0{,}38. Un 0,6 no es todavía evidencia firme, y esta lección ha probado varias pérdidas antes de mirar la prueba: la comparación honesta pasa por la validación y el Deflated Sharpe (lección 5.4.4).

Guardar el modelo

El formato nativo de Keras 3 es .keras: un ZIP con la configuración (config.json: arquitectura, pérdida, optimizador), los metadatos (versión de Keras) y los pesos en HDF5. Guarda también el estado del optimizador, para continuar un entrenamiento donde se dejó:

IN [08]python
modelo = modelos["Sharpe neto"]           # compilado con 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("pérdida guardada:", 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("predicciones idénticas tras cargar:", iguales)
OUT [08]Guardar y cargar un modelo con su pérdida
['metadata.json', 'config.json', 'model.weights.h5']
pérdida guardada: SharpeNeto {'coste': 0.001, 'eps': 1e-06}
predicciones idénticas tras cargar: True

La configuración guarda la clase de la pérdida y sus parámetros (coste, eps) gracias a get_config, y el modelo recargado da predicciones idénticas bit a bit. Si la clase no está registrada, Keras no sabe reconstruirla:

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("con compile=False carga; pérdida:", m2.loss)
OUT [09]Lo que pasa si la clase no está registrada
TypeError: Could not locate class 'SharpeSinRegistro'
con compile=False carga; pérdida: None

Sin @keras.saving.register_keras_serializable, la carga falla porque Keras no encuentra la clase. Con compile=False el modelo se carga sin pérdida ni optimizador, que es lo único que hace falta para predecir. La regla práctica: el código que define las clases propias se importa antes de cargar, o se carga sin compilar en inferencia.

Exportar para servir

Para inferencia fuera de Keras, model.export genera artefactos que no necesitan Keras para ejecutarse. Los formatos de Keras 3.15 son tf_saved_model, onnx, openvino, litert y torch. Con el backend PyTorch, format="torch" usa torch.export y escribe un programa .pt2 que se carga con torch.export.load. La firma de entrada fija la forma: una semana de 400 acciones.

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 semana, 400 acciones
p_torch = programa.module()(semana).detach().numpy()
p_keras = ops.convert_to_numpy(modelo(X[te][:1]))
print("forma:", p_torch.shape, "| máx. diferencia < 1e-6:",
      bool(np.abs(p_torch - p_keras).max() < 1e-6))
import importlib.util
print("onnx instalado:", importlib.util.find_spec("onnx") is not None)
OUT [10]Exportar a PyTorch (torch.export) y comprobar la paridad
forma: (1, 400) | máx. diferencia < 1e-6: True
onnx instalado: False

El programa exportado da las mismas 400 puntuaciones que el modelo de Keras (diferencia máxima por debajo de 10−610^{-6}). ONNX, el formato de intercambio más extendido para servir modelos en otros lenguajes y runtimes, no se puede probar aquí: onnx no está instalado en el entorno del curso (tiene ruedas para Python 3.14 en PyPI, pero no forma parte de la pila del nivel 5). El código sería este:

IN [11]python
# uv add onnx onnxscript onnxruntime   (fuera de este entorno)
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]

Y la portabilidad entre backends de la lección 5.6.1 vale también para el .keras completo: un proceso con KERAS_BACKEND=jax carga el fichero guardado con PyTorch (sin compilar, porque solo predice) y da las mismas puntuaciones:

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(), "| igual a PyTorch (1e-6):",
      bool(np.abs(p_jax - p_keras).max() < 1e-6))
OUT [12]El modelo .keras en otro backend
jax | igual a PyTorch (1e-6): True

Inferencia: el modelo dentro de un proceso

En producción el modelo no recibe un tensor ya preparado, sino las filas de una semana tal como llegan del proceso de datos. Entre las dos cosas está la transformación (rango normal por semana) y un contrato: qué features, en qué orden, con qué transformación y con qué versión se entrenó. Se guarda junto al modelo y se comprueba en cada llamada:

Filas de la semanaContratoRango normalModelo .kerasPesos carteraParidad con lab
Fuente Mermaid
flowchart TB
  F[Filas de la semana] --> C[Contrato]
  C --> T[Rango normal]
  T --> M[Modelo .keras]
  M --> W[Pesos cartera]
  W --> P[Paridad con lab]

La función de la celda valida que estén todas las features, ordena las acciones, aplica la misma transformación que el entrenamiento y devuelve los pesos. Para probarla, recibe las columnas desordenadas (FE[::-1]) y se compara con los pesos calculados en el laboratorio para esa misma semana:

IN [13]python
CONTRATO = {"features": FE, "transformacion": "rango normal semanal",
            "keras": keras.__version__}
with open("senal.json", "w") as fh:
    json.dump(CONTRATO, fh)

def pesos_semana(filas, modelo, contrato):
    """filas: DataFrame de una fecha con 'ticker' y las features."""
    falta = set(contrato["features"]) - set(filas.columns)
    if falta:
        raise ValueError(f"faltan 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("suma de |pesos|:", round(float(w_prod.abs().sum()), 6),
      "| neto:", round(float(w_prod.sum()), 6) + 0.0)
print("igual que en el laboratorio:", np.allclose(w_prod, w_lab,
                                                   atol=1e-7))
w_prod.sort_values().iloc[[0, 1, -2, -1]].round(5)
OUT [13]Inferencia: de las filas de una semana a los pesos de la cartera
suma de |pesos|: 1.0 | neto: 0.0
igual que en el laboratorio: True
ticker
CENO   -0.00666
CUCU   -0.00643
ROVO    0.00707
VUVE    0.00744
dtype: float32

Los pesos suman 1 en valor absoluto y 0 en neto, y coinciden con los del laboratorio aunque las columnas llegaran en otro orden: el contrato las reordena. Las posiciones mayores son de un 0,7 % del nominal bruto, porque la cartera reparte el riesgo entre 400 acciones.

Lo que una prueba de paridad así detecta, y que de otra forma solo se vería en las pérdidas: columnas cambiadas de orden, una feature calculada con otra ventana, una transformación distinta (la tipificación con la media de toda la historia en lugar del rango semanal), otra versión de Keras con otro comportamiento por defecto. Las lecciones 4.5.1 (arquitectura de un sistema en vivo) y 4.5.3 (paper y shadow trading) tratan la operación; aquí la regla es la misma: el modelo se despliega con su contrato y con una semana de referencia cuyos pesos se conocen.

Ejercicios

  1. Los resultados de las pérdidas de Sharpe neto salen de una sola semilla. Entrena la versión con lotes contiguos y la barajada con las semillas 0, 1 y 2 y compara el Sharpe neto de prueba medio y su dispersión.
Solución
IN [14]python
filas = {}
for nombre, barajar in [("contiguos", False), ("barajados", 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)
                  ["Sharpe neto"])
    filas[nombre] = {"semilla 0": sr[0], "semilla 1": sr[1],
                     "semilla 2": sr[2], "media": np.mean(sr)}
pd.DataFrame(filas).T.round(3)
OUT [14]Tres semillas por variante
           semilla 0  semilla 1  semilla 2  media
contiguos      0.261      0.238      0.122  0.207
barajados      0.643      0.645      0.442  0.577

Con lotes contiguos, el Sharpe neto de prueba va de 0,12 a 0,26 según la semilla (media 0,21); con lotes barajados, de 0,44 a 0,65 (media 0,58). La ventaja de la versión barajada se repite en las tres semillas, así que no es suerte de la inicialización. Una explicación razonable: con lotes fijos de 26 semanas consecutivas, el optimizador ve siempre los mismos diez bloques y la penalización solo actúa dentro de cada uno; barajando, compara semanas lejanas entre sí y castiga más cualquier dependencia de la señal con el régimen de un año concreto. Antes de usarla habría que confirmarlo en validación y descontar todas las variantes probadas.

  1. El programa exportado a .pt2 se trazó para 400 acciones. ¿Qué pasa si mañana el universo tiene 401? Compruébalo y propón cómo resolverlo.
Solución
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("el modelo .keras sí acepta 401:",
      tuple(recargado(mas_una.numpy()).shape))
OUT [15]Un universo de 401 acciones
AssertionError: Guard failed: args_0.size()[1] == 400
el modelo .keras sí acepta 401: (1, 401)

El programa .pt2 comprueba la forma de la entrada con una guarda y rechaza 401 acciones; el .keras las acepta, porque su entrada se declaró con None acciones. Hay tres salidas: exportar con dimensiones dinámicas (el argumento dynamic_shapes de torch.export), rellenar hasta un tamaño máximo fijo con acciones ficticias de peso cero, o volver a exportar cuando cambia el universo. En los tres casos, un cambio de universo es un cambio del contrato del modelo y se versiona como tal.

  1. En la cartera de esta lección los pesos suman 1 en valor absoluto y 0 en neto. Si se quitara la resta de la media en la pérdida de Sharpe (sin neutralidad), ¿qué aprendería el modelo con datos en los que el mercado sube de media?
Solución

Sin restar la media semanal de las puntuaciones, la cartera puede quedar neta larga. En una muestra donde la cartera equiponderada rinde un 9,3 % anual con un Sharpe de 0,52 (README de los datos), la forma más fácil de subir el Sharpe de la pérdida es cargar beta: el modelo aprendería un sesgo positivo en la puntuación, ponderado hacia las acciones de más beta en los tramos alcistas del entrenamiento, y presentaría como «señal» la prima de mercado de esos años. Fuera de muestra, esa exposición pierde en las caídas (2020, 2022, 2025 en estos datos). La neutralidad se impone en la propia construcción de la cartera dentro de la pérdida (como aquí) o se penaliza la exposición neta y la beta, y el resultado se mide siempre contra la cartera de mercado.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Un callback propio calcula el IC de validación y lo escribe en logs["val_ic"]. ¿Por qué tiene que ir el primero de la lista de callbacks?

    • APorque Keras ejecuta solo el primero
    • BPorque así se ejecuta más rápido
    • CNo importa el orden
    • DPorque los callbacks se llaman en el orden de la lista con el mismo diccionario logs: si EarlyStopping o ModelCheckpoint van antes, buscan val_ic cuando aún no existe
    Ver respuesta

    Respuesta D. Porque los callbacks se llaman en el orden de la lista con el mismo diccionario logs: si EarlyStopping o ModelCheckpoint van antes, buscan val_ic cuando aún no existe

    Al final de cada época Keras recorre la lista en orden. Un callback que vigila val_ic antes de que otro lo escriba no lo encuentra (avisa y no actúa) o, peor, usa el de la época anterior.

  2. Con la salida de la red inicializada a cero, la pérdida de Sharpe da nan en el primer lote. ¿Por qué?

    • APorque todas las puntuaciones son iguales: al restar la media quedan ceros, la suma de |pesos| es 0 y los pesos son 0/0; TerminateOnNaN corta el entrenamiento en ese lote
    • BPor un error de Keras
    • CPorque la tasa de aprendizaje es demasiado alta
    • DPorque los rendimientos tienen huecos
    Ver respuesta

    Respuesta A. Porque todas las puntuaciones son iguales: al restar la media quedan ceros, la suma de |pesos| es 0 y los pesos son 0/0; TerminateOnNaN corta el entrenamiento en ese lote

    Una pérdida de cartera normalizada no está definida si la señal es constante. Se arregla con un épsilon en los denominadores y con una inicialización aleatoria de la salida, justo lo contrario de lo que convenía con el MSE (lección 5.6.2).

  3. La red entrenada con la pérdida de Sharpe tiene un Sharpe bruto de 4,37 en entreno y de 1,57 en prueba. La entrenada con MSE, 2,67 y 1,41. ¿Qué se concluye?

    • AQue la pérdida de Sharpe es mucho mejor
    • BQue la pérdida de Sharpe sobreajusta más: maximiza directamente lo que se mide en entreno, y fuera de muestra la ventaja casi desaparece (y en neto es peor: −0,33 frente a −0,18)
    • CQue el MSE está mal implementado
    • DQue hay que entrenar más épocas con el Sharpe
    Ver respuesta

    Respuesta B. Que la pérdida de Sharpe sobreajusta más: maximiza directamente lo que se mide en entreno, y fuera de muestra la ventaja casi desaparece (y en neto es peor: −0,33 frente a −0,18)

    Optimizar el Sharpe en entreno es optimizar la métrica que luego se reporta: el sesgo de selección está dentro del entrenamiento. La caída de 4,37 a 1,57 es la medida de ese sobreajuste.

  4. La pérdida de Sharpe neto descuenta la rotación entre semanas consecutivas del lote. Con lotes barajados, ¿qué mide ese término?

    • ALa rotación real de la cartera
    • BNada: Keras ignora el orden
    • CLa diferencia de pesos entre semanas elegidas al azar: no es la rotación, sino una penalización de la variabilidad de la señal en el tiempo; aquí funcionó mejor (Sharpe neto medio de 0,58 frente a 0,21 en tres semillas), pero por un motivo distinto del que dice su nombre
    • DEl coste de la última semana
    Ver respuesta

    Respuesta C. La diferencia de pesos entre semanas elegidas al azar: no es la rotación, sino una penalización de la variabilidad de la señal en el tiempo; aquí funcionó mejor (Sharpe neto medio de 0,58 frente a 0,21 en tres semillas), pero por un motivo distinto del que dice su nombre

    Con shuffle=True las semanas de un lote no son consecutivas. La penalización sigue empujando hacia señales lentas, que es lo que reduce la rotación de verdad. Lo que se mide después, con la cartera real, es lo que vale.

  5. Se exporta el modelo a .pt2 con una firma de 1 semana × 400 acciones. Al universo se le añade una acción. ¿Qué pasa?

    • AFunciona igual
    • BPredice 400 y descarta la última
    • CEl programa exportado rechaza la entrada (la forma quedó fijada en la exportación); el .keras sí acepta 401 acciones. Hay que volver a exportar o declarar dimensiones dinámicas, y tratarlo como un cambio de versión del modelo
    • DDevuelve ceros
    Ver respuesta

    Respuesta C. El programa exportado rechaza la entrada (la forma quedó fijada en la exportación); el .keras sí acepta 401 acciones. Hay que volver a exportar o declarar dimensiones dinámicas, y tratarlo como un cambio de versión del modelo

    torch.export traza el programa con formas concretas y añade guardas. Un cambio de universo es un cambio del contrato de entrada, igual que un cambio de features.

Ideas clave

  • Un callback puede escribir métricas en logs para que otros las vigilen: el IC de validación como criterio de EarlyStopping, ModelCheckpoint y ReduceLROnPlateau. El orden de la lista importa.
  • Una pérdida de Sharpe se escribe en pocas líneas con keras.ops, pero necesita una cartera bien definida (neutral y normalizada), un épsilon contra 0/0 y pesos iniciales aleatorios.
  • Optimizar el Sharpe en entreno sobreajusta: 4,37 en entreno y 1,57 en prueba, frente a 2,67 y 1,41 con MSE. Su escala libre deja sin encoger los pesos de las features de ruido.
  • Meter los costes en la pérdida reduce la rotación a la mitad (0,43 frente a 0,88) y lleva el Sharpe neto de prueba a terreno positivo; con tres semillas, 0,21 de media con lotes contiguos y 0,58 con barajados, aún dentro del error de siete años.
  • .keras guarda arquitectura, pesos, pérdida y optimizador, y se carga en cualquier backend; una clase propia necesita register_keras_serializable y get_config. .pt2 (torch.export) fija la forma de entrada.
  • En producción, el modelo viaja con su contrato (features, orden, transformación) y una prueba de paridad con el laboratorio.

Recursos

Datos