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.
- Módulo
- 5.6 · Deep learning con Keras 3
- Duración
- 75 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
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):
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()))X: (729, 400, 21) | semanas: 259 103 365Quedan 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
(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, . Como referencia, la señal de
una sola feature, la reversión de una semana (ret_1s con el signo cambiado):
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) 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.370Una 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:
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:
| 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:
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)é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.00075El 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):
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:
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"])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 es 0 y los pesos son . 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:
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) 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.741Trampa 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:
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())peso relativo medio de las cinco features de ruido:
MSE (rangos) 0.231
Sharpe 0.460Las 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 = 10 pb por unidad negociada, el rendimiento neto de la semana es
. 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:
@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) 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.436Con 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 . 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ó:
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)['metadata.json', 'config.json', 'model.weights.h5']
pérdida guardada: SharpeNeto {'coste': 0.001, 'eps': 1e-06}
predicciones idénticas tras cargar: TrueLa 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:
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)TypeError: Could not locate class 'SharpeSinRegistro'
con compile=False carga; pérdida: NoneSin @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.
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)forma: (1, 400) | máx. diferencia < 1e-6: True
onnx instalado: FalseEl programa exportado da las mismas 400 puntuaciones que el modelo de Keras (diferencia máxima por debajo de
). 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:
# 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:
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))jax | igual a PyTorch (1e-6): TrueInferencia: 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:
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:
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)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: float32Los 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
- 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
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) semilla 0 semilla 1 semilla 2 media
contiguos 0.261 0.238 0.122 0.207
barajados 0.643 0.645 0.442 0.577Con 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.
- El programa exportado a
.pt2se trazó para 400 acciones. ¿Qué pasa si mañana el universo tiene 401? Compruébalo y propón cómo resolverlo.
Solución
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))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.
- 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
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: siEarlyStoppingoModelCheckpointvan antes, buscanval_iccuando aún no existe
Ver respuesta
Respuesta D. Porque los callbacks se llaman en el orden de la lista con el mismo diccionario
logs: siEarlyStoppingoModelCheckpointvan antes, buscanval_iccuando aún no existeAl final de cada época Keras recorre la lista en orden. Un callback que vigila
val_icantes de que otro lo escriba no lo encuentra (avisa y no actúa) o, peor, usa el de la época anterior.Con la salida de la red inicializada a cero, la pérdida de Sharpe da
nanen 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).
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.
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=Truelas 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.Se exporta el modelo a
.pt2con 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
.kerassí 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
.kerassí acepta 401 acciones. Hay que volver a exportar o declarar dimensiones dinámicas, y tratarlo como un cambio de versión del modelotorch.exporttraza 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
logspara que otros las vigilen: el IC de validación como criterio deEarlyStopping,ModelCheckpointyReduceLROnPlateau. 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.
.kerasguarda arquitectura, pesos, pérdida y optimizador, y se carga en cualquier backend; una clase propia necesitaregister_keras_serializableyget_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.