---
title: "5.6.4 Callbacks, pérdidas propias y exportación en Keras"
description: "Callbacks con el IC, una pérdida de Sharpe con costes y sus trampas, y un modelo guardado en .keras y .pt2, cargado en otro backend y servido."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/callbacks-perdidas-personalizadas-y-exportacion/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/callbacks-perdidas-personalizadas-y-exportacion.png"
---

# 5.6.4 · 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.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.6 · Deep learning con Keras 3
- Duración: 75 min
- Requisitos: [5.6.2 · Un MLP sobre un panel de acciones: regularizar y comparar](https://pythonparatrading.com/curso-python-trading/nivel-5/mlp-para-datos-tabulares-financieros/), [5.6.1 · Keras 3: un modelo, varios motores de cálculo](https://pythonparatrading.com/curso-python-trading/nivel-5/keras-3-multi-backend/), [5.1.3 · Métricas económicas para modelos de trading: más allá del acierto](https://pythonparatrading.com/curso-python-trading/nivel-5/metricas-economicas-para-modelos-de-trading/)

## Al terminar esta lección

- Escribir un callback que calcule una métrica propia (el IC) y la deje en `logs` para que `EarlyStopping`, `ModelCheckpoint` y `ReduceLROnPlateau` la vigilen.
- Programar con `keras.ops` una pérdida de Sharpe de una cartera larga-corta, con y sin costes, y reconocer sus trampas: división por cero, escala libre, sobreajuste y orden de los lotes.
- Guardar un modelo con una pérdida propia en formato `.keras`, cargarlo en otro backend y exportarlo a un programa de PyTorch (`.pt2`) con su firma.
- Servir el modelo: de las filas de una semana a los pesos de la cartera, con un contrato de features y una comprobación de paridad con el laboratorio.

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

```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()))
```
```output
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
$\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, $\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):

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

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

| Callback | Qué hace | Detalle que importa |
|---|---|---|
| `EarlyStopping` | para cuando la métrica vigilada no mejora en `patience` épocas | `mode="max"` para métricas que suben; `restore_best_weights=True` |
| `ModelCheckpoint` | guarda el modelo (o los pesos) en disco | `save_best_only=True` con la métrica vigilada |
| `ReduceLROnPlateau` | multiplica la tasa por `factor` si la métrica se estanca | conviene una paciencia menor que la de la parada |
| `CSVLogger` | escribe una fila por época con todos los `logs` | la columna `epoch` empieza en 0 |
| `TerminateOnNaN` | para en cuanto la pérdida es `nan` o infinita | imprescindible 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:

```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)
```
```output
é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.* Una línea de 25 épocas que sube de 0,0064 a un máximo de 0,0151 en la época 17 (destacado) y se queda por debajo después; dos líneas verticales discontinuas en las épocas 15 y 22 marcan las reducciones de la tasa; el entrenamiento para en la 25.

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

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

```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"])
```
```output
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 - \overline{s}|$ es 0 y los pesos son $0/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:

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

```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())
```
```output
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 $c$ = 10 pb por unidad negociada, el rendimiento neto de la semana $t$ es
$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:

```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)
```
```output
                       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.* Cuatro puntos. MSE: rotación 0,88 y Sharpe neto -0,18. Sharpe: 0,74 y -0,33. Sharpe neto con lotes contiguos: 0,43 y 0,26. Sharpe neto con lotes barajados (destacado): 0,44 y 0,64.

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/\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ó:

```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)
```
```output
['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:

```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)
```
```output
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.

```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)
```
```output
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^{-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:

```python skip
# 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:

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

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

```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)
```
```output
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.

<aside class="callout" data-kind="nota">

`model.predict` procesa por lotes y devuelve NumPy; llamar al modelo, `modelo(x)`, devuelve un tensor del backend y
sirve para lotes pequeños. Los dos usan el modo de inferencia (`training=False`): dropout apagado y capas de
normalización con sus estadísticas guardadas. En un proceso que sirve una predicción por semana, la diferencia de
velocidad no importa; la de determinismo, sí: fija los hilos y compara con la referencia.

</aside>

## 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.**

```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)
```
```output
           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.

2. 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.**

```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))
```
```output
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.

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

## 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?

   - A. Porque Keras ejecuta solo el primero
   - B. Porque así se ejecuta más rápido
   - C. No importa el orden
   - 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

   *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é?

   - 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
   - B. Por un error de Keras
   - C. Porque la tasa de aprendizaje es demasiado alta
   - D. Porque los rendimientos tienen huecos

   *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?

   - A. Que la pérdida de Sharpe es mucho mejor
   - 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)
   - C. Que el MSE está mal implementado
   - D. Que hay que entrenar más épocas con el Sharpe

   *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?

   - A. La rotación real de la cartera
   - B. Nada: Keras ignora el orden
   - 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
   - D. El coste de la última semana

   *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?

   - A. Funciona igual
   - B. Predice 400 y descarta la última
   - 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
   - D. Devuelve ceros

   *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

### Documentación

- [Keras: Writing your own callbacks](https://keras.io/guides/writing_your_own_callbacks/) · keras.io
- [Keras: Save, serialize, and export models](https://keras.io/guides/serialization_and_saving/) · keras.io
- [Keras: Model export for inference](https://keras.io/api/models/model_saving_apis/export/) · keras.io
- [PyTorch: torch.export](https://docs.pytorch.org/docs/stable/export.html) · docs.pytorch.org

### Libros

- [Zhang, Z., Zohren, S. y Roberts, S.: Deep Learning for Portfolio Optimization. arXiv:2005.13665](https://arxiv.org/abs/2005.13665) · arxiv.org
- [Lim, B., Zohren, S. y Roberts, S.: Enhancing Time Series Momentum Strategies Using Deep Neural Networks. arXiv:1904.04912](https://arxiv.org/abs/1904.04912) · arxiv.org

### Datos

- [Datos del nivel 5: features del panel (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_features.parquet) · Descarga · panel5_features.parquet

Lección anterior: [5.6.3 · CNN 1D, LSTM y GRU: ventanas, escalado y fugas](https://pythonparatrading.com/curso-python-trading/nivel-5/cnn-1d-lstm-y-gru-para-secuencias/)

Lección siguiente: [5.7.1 · PyTorch: tensores, autograd y el bucle de entrenamiento](https://pythonparatrading.com/curso-python-trading/nivel-5/pytorch-tensores-autograd-y-bucle-de-entrenamiento/)
