Lección 5.6.1 · 60 min

Keras 3: un modelo, varios motores de cálculo

Keras 3 separa la definición del modelo del motor que hace las cuentas: el mismo código corre sobre PyTorch, JAX o TensorFlow. Esta lección configura el backend, escribe un modelo con las tres APIs de Keras y entrena un HAR de volatilidad con PyTorch y con JAX para ver qué coincide entre motores, qué no y por qué.

Fig. — MSE de prueba (2018-2025) del HAR del índice entrenado con Keras 3 como una capa densa, época a época, con el backend PyTorch y con el JAX, frente a la solución por mínimos cuadrados
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

Una API, varios motores

Keras nació como una capa sobre otros motores de cálculo (Theano, después TensorFlow) y durante años fue, en la práctica, la API de alto nivel de TensorFlow. Keras 3, publicado en noviembre de 2023, volvió al diseño original: la definición del modelo (capas, pérdidas, optimizadores, fit) es independiente del motor que hace las cuentas, el backend. La documentación oficial enumera tres para entrenar: JAX, TensorFlow y PyTorch. El código del paquete trae además un backend de NumPy y otro de OpenVINO, pensados para inferencia.

Modelo Keraskeras.opsPyTorchJAXTensorFlowCPU o GPU
Fuente Mermaid
flowchart TB
  M[Modelo Keras] --> O[keras.ops]
  O --> T[PyTorch]
  O --> J[JAX]
  O --> F[TensorFlow]
  T --> C[CPU o GPU]
  J --> C
  F --> C

Para un equipo de research esto tiene tres consecuencias prácticas:

  • El modelo no ata al motor. Un modelo escrito en Keras puede entrenarse en JAX (rápido para modelos pequeños, como se verá) y servirse dentro de una aplicación de PyTorch, o al revés, con los mismos pesos.
  • Los tensores son los del backend. Con PyTorch, cada tensor es un torch.Tensor y el modelo es un torch.nn.Module; con JAX, un jax.Array. Se puede bajar al motor cuando hace falta.
  • El azar no es portable. Cada backend tiene su generador de números aleatorios: la misma semilla no da los mismos pesos iniciales en dos backends. Hay que saberlo para no confundir ruido con un error.

En el entorno del curso están PyTorch 2.14.1 (solo CPU) y JAX 0.12.0.1, con Keras 3.15.1. TensorFlow no: la última versión estable, la 2.21.0, no publica ruedas para Python 3.14, y la primera que las tiene, la 2.22.0rc0, es una versión candidata. Su código aparece al final, sin ejecutar.

Configurar el backend

Keras decide el backend una sola vez, al importarse, y no permite cambiarlo después. Lo busca en este orden: la variable de entorno KERAS_BACKEND y, si no está, el campo backend de keras.json (en ~/.keras/ o en la carpeta de KERAS_HOME); sin ninguno de los dos, usa TensorFlow. El ejecutor de las lecciones del nivel 5 define KERAS_BACKEND=torch y apunta KERAS_HOME a una carpeta temporal:

IN [01]python
import json
import os
import numpy as np
import pandas as pd
import torch
import keras
from keras import layers, ops

torch.use_deterministic_algorithms(True)
print("keras", keras.__version__)
print("KERAS_BACKEND:", os.environ.get("KERAS_BACKEND"))
print("backend activo:", keras.config.backend())
with open(os.path.join(os.environ["KERAS_HOME"], "keras.json")) as fh:
    print("keras.json:", json.load(fh))
OUT [01]Qué backend usa este proceso
keras 3.15.1
KERAS_BACKEND: torch
backend activo: torch
keras.json: {'floatx': 'float32', 'epsilon': 1e-07, 'backend': 'tensorflow', 'image_data_format': 'channels_last'}

Fíjate en la última línea: el keras.json que Keras escribió al importarse por primera vez en esa carpeta dice tensorflow (el valor por defecto), pero el backend activo es torch, porque la variable de entorno manda. El ejercicio 1 lo comprueba con un keras.json propio. La regla práctica: el backend se fija en el entorno del proceso (en la shell, en el contenedor, en el servicio), o en las primeras líneas del programa con os.environ["KERAS_BACKEND"] = "jax" antes de cualquier import keras, incluidos los indirectos (una librería que importe Keras por dentro también cuenta).

keras.ops: cálculo portable

Las capas de Keras están escritas con keras.ops, una API con la forma de NumPy (ops.mean, ops.std, ops.matmul, ops.where…) más las operaciones de redes (ops.relu, ops.softmax, convoluciones). Cada función llama a la del backend y devuelve un tensor del backend. Una pérdida o una métrica escrita con keras.ops vale en los tres; una escrita con torch o con numpy dentro del grafo, no. Como ejemplo, el Sharpe anualizado de los seis activos sintéticos de vol5_diario.parquet (lección 5.1.3 para el Sharpe como métrica):

IN [02]python
v = pd.read_parquet("vol5_diario.parquet")
r = v.pivot(index="fecha", columns="activo", values="rendimiento")
t = ops.convert_to_tensor(r.to_numpy("float32"))
print(type(t).__module__, type(t).__name__, tuple(t.shape))

def sharpe(x, eje=0):
    """Sharpe anualizado con keras.ops: vale en cualquier backend."""
    m, s = ops.mean(x, axis=eje), ops.std(x, axis=eje)
    return m / s * ops.sqrt(252.0)

pd.DataFrame({"keras.ops": ops.convert_to_numpy(sharpe(t)),
              "pandas": r.mean() / r.std(ddof=0) * np.sqrt(252)}
             ).round(3)
OUT [02]keras.ops: una API de NumPy que corre en el backend
torch Tensor (4529, 6)
        keras.ops  pandas
activo
accion      0.956   0.956
bono       -0.281  -0.281
cripto      0.424   0.424
divisa      0.243   0.243
indice      0.784   0.784
oro         0.349   0.349

El tensor es un torch.Tensor de 4.529 sesiones por 6 activos, y el resultado coincide con pandas en las tres cifras mostradas (la desviación típica de ops.std es la poblacional, de ahí ddof=0). La lección 5.6.4 usa esta misma función como base de una pérdida.

El banco de pruebas: un HAR con respuesta conocida

Para comparar motores hace falta un problema cuya solución se conozca por otra vía. El modelo HAR de Corsi predice el logaritmo de la varianza realizada de mañana con sus medias de 1, 5 y 22 días:

log⁡RVt=c+βdlog⁡RVt−1+βslog⁡RV‾t−5:t−1+βmlog⁡RV‾t−22:t−1+εt\log RV_{t} = c + \beta_d \log RV_{t-1} + \beta_s \overline{\log RV}_{t-5:t-1} + \beta_m \overline{\log RV}_{t-22:t-1} + \varepsilon_t

En los datos del nivel 5 el proceso verdadero es un HAR en logaritmos (README de los datos), así que la regresión lineal es casi el mejor modelo posible. Una red de una sola capa densa sin activación, Dense(1), es esa regresión: y^=w⊤x+b\hat y = w^\top x + b. Entrenada con el error cuadrático, su óptimo es el de mínimos cuadrados. Así que hay respuesta conocida: la OLS. Se usa el índice, con entrenamiento de 2008 a 2017 y prueba de 2018 a 2025 (el mismo corte que la tabla de previsiones del README):

IN [03]python
d = v[v["activo"] == "indice"].set_index("fecha")
lrv = np.log(d["rv_5min"])
har = pd.DataFrame({"d1": lrv, "s5": lrv.rolling(5).mean(),
                    "m22": lrv.rolling(22).mean()}).shift(1)
har["y"] = lrv
har = har.dropna()
COLS = ["d1", "s5", "m22"]
ent = har.index < "2018-01-01"
Xe, ye = har.loc[ent, COLS].to_numpy(), har.loc[ent, "y"].to_numpy()
Xp, yp = har.loc[~ent, COLS].to_numpy(), har.loc[~ent, "y"].to_numpy()

b_ols = np.linalg.lstsq(np.c_[np.ones(len(Xe)), Xe], ye, rcond=None)[0]
p_ols = np.c_[np.ones(len(Xp)), Xp] @ b_ols
print(f"filas: entreno {len(Xe)}, prueba {len(Xp)}")
print("OLS: constante", round(b_ols[0], 3),
      "pendientes", np.round(b_ols[1:], 3))
print(f"MSE de prueba de la OLS: {np.mean((p_ols - yp) ** 2):.4f}")
OUT [03]El HAR del índice y su solución por mínimos cuadrados
filas: entreno 2496, prueba 2011
OLS: constante -0.743 pendientes [0.373 0.278 0.273]
MSE de prueba de la OLS: 0.1738

Las pendientes suman 0,92, la persistencia del proceso verdadero. El MSE de prueba de la OLS, 0,1738, es la vara de medir del resto de la lección.

Tres formas de escribir un modelo

Keras ofrece tres APIs para definir un modelo, y las tres producen el mismo tipo de objeto, con fit, predict, save y summary:

APICómo se escribePara qué sirve
Secuenciallista de capas en keras.Sequentialpilas simples: una entrada, una salida, sin ramas
Funcionalse llama a cada capa sobre tensores simbólicos y keras.Model(entradas, salidas)grafos: varias entradas o salidas, ramas, capas compartidas
Subclaseclase hija de keras.Model con las capas en __init__ y el cálculo en calllógica propia en el paso hacia delante (bucles, condiciones, ruido)

Para comprobar que son el mismo cálculo, la celda construye una red de 3 entradas, 8 neuronas ocultas con ReLU y una salida de las tres formas, copia los pesos de la primera a las otras dos y compara las salidas sobre cuatro filas de prueba tipificadas:

IN [04]python
keras.utils.set_random_seed(0)
secuencial = keras.Sequential([
    keras.Input((3,)),
    layers.Dense(8, activation="relu"),
    layers.Dense(1)])

entrada = keras.Input((3,), name="har")
h = layers.Dense(8, activation="relu")(entrada)
funcional = keras.Model(entrada, layers.Dense(1)(h))

class MLP(keras.Model):
    def __init__(self, ocultas=8, **kwargs):
        super().__init__(**kwargs)
        self.oculta = layers.Dense(ocultas, activation="relu")
        self.salida = layers.Dense(1)

    def call(self, x, training=False):
        return self.salida(self.oculta(x))

subclase = MLP()
subclase(np.zeros((1, 3), "float32"))      # crea los pesos
funcional.set_weights(secuencial.get_weights())
subclase.set_weights(secuencial.get_weights())

x = ((Xp[:4] - Xe.mean(0)) / Xe.std(0)).astype("float32")
for m in (secuencial, funcional, subclase):
    print(f"{type(m).__name__:<11}{m.count_params():>3} parámetros",
          np.round(ops.convert_to_numpy(m(x)).ravel(), 4))
OUT [04]Un modelo, tres APIs
Sequential  41 parámetros [0.4735 0.8178 1.1518 1.2612]
Functional  41 parámetros [0.4735 0.8178 1.1518 1.2612]
MLP         41 parámetros [0.4735 0.8178 1.1518 1.2612]

Las tres tienen 41 parámetros (3×8 + 8 de la capa oculta, 8 + 1 de la salida) y dan las mismas cuatro predicciones. La subclase necesita una llamada previa (subclase(np.zeros((1, 3)))) para crear sus pesos, porque Keras no conoce la forma de la entrada hasta que la ve; las otras dos la declaran con keras.Input.

La API funcional se gana el sitio cuando el modelo deja de ser una pila. Un caso típico en finanzas: un modelo de panel que comparte las pendientes del HAR entre los seis activos y da a cada uno su propio nivel. Las medias entran por un lado y el identificador del activo por otro, a través de un Embedding de dimensión 1, que no es más que una constante por activo aprendida (el equivalente de los efectos fijos de una regresión de panel):

IN [05]python
rv_in = keras.Input((3,), name="har")
activo_in = keras.Input((1,), dtype="int32", name="activo")
e = layers.Flatten()(layers.Embedding(6, 1, name="nivel")(activo_in))
h = layers.Concatenate()([rv_in, e])
salida = layers.Dense(1, name="prevision")(h)
panel = keras.Model([rv_in, activo_in], salida)
for capa in panel.layers:
    print(f"{capa.name:<12}{type(capa).__name__:<13}"
          f"{str(capa.output.shape):<14}{capa.count_params()}")
OUT [05]La API funcional: dos entradas y un grafo que se puede leer
activo      InputLayer   (None, 1)     0
nivel       Embedding    (None, 1, 1)  6
har         InputLayer   (None, 3)     0
flatten     Flatten      (None, 1)     0
concatenate Concatenate  (None, 4)     0
prevision   Dense        (None, 1)     5

El grafo tiene dos entradas, una capa nivel con 6 parámetros (uno por activo) y una salida con 5 (cuatro pendientes y la constante). El listado se lee como el grafo: qué capa produce qué forma y cuántos parámetros tiene. La lección 5.6.2 usa esta API para un MLP con una rama lineal.

La subclase es la API más flexible y la más fácil de romper. Todo lo que hace el modelo está en call, y Keras no puede inspeccionarlo antes de ejecutarlo. El detalle que más errores produce es el argumento training: capas como Dropout o GaussianNoise se comportan distinto al entrenar y al predecir, y la subclase tiene que pasarlo a cada una. Un ejemplo con ruido gaussiano en las entradas, una forma de regularización que se usa con datos financieros ruidosos:

IN [06]python
class MLPRuido(MLP):
    """Subclase: ruido gaussiano en las entradas solo al entrenar."""
    def __init__(self, sigma=0.5, **kwargs):
        super().__init__(**kwargs)
        self.ruido = layers.GaussianNoise(sigma, seed=0)

    def call(self, x, training=False):
        return super().call(self.ruido(x, training=training))

mr = MLPRuido()
mr(x)
mr.set_weights(secuencial.get_weights())
print("inferencia :", np.round(ops.convert_to_numpy(
    mr(x, training=False)).ravel(), 4))
print("entrenando :", np.round(ops.convert_to_numpy(
    mr(x, training=True)).ravel(), 4))
OUT [06]El modo de entrenamiento cambia la salida
inferencia : [0.4735 0.8178 1.1518 1.2612]
entrenando : [-0.104   0.215   1.2085  1.0265]

Con training=False (lo que hacen predict y evaluate) la salida es la de la red sin ruido, idéntica a las de la celda anterior; con training=True (lo que hace fit), cada llamada añade ruido de desviación 0,5 a las entradas y la primera predicción pasa de 0,4735 a −0,104. Keras 3 lleva un contexto de llamada que propaga training a las capas internas aunque call no lo pase (comprobado con esta versión), pero pasarlo de forma explícita deja claro qué hace el modelo en cada modo y no depende de ese mecanismo cuando la capa se llama desde código propio.

El mismo entrenamiento en PyTorch y en JAX

Para entrenar con dos backends hacen falta dos procesos. La celda guarda los datos tipificados con la media y la desviación del entrenamiento (la tipificación con datos de prueba sería una fuga, lección 5.2.4) y escribe un guion corto que entrena Dense(1) con Adam (tasa 0,01, lotes de 128, 40 épocas), guarda los pesos y las predicciones, y escribe un JSON con las pendientes y la curva de validación. Si recibe un fichero de pesos como argumento, no entrena: los carga y predice.

IN [07]python
mu, sd = Xe.mean(0), Xe.std(0)
my, sy = ye.mean(), ye.std()
np.savez("har.npz",
         Xe=((Xe - mu) / sd).astype("float32"),
         ye=((ye - my) / sy).astype("float32"),
         Xp=((Xp - mu) / sd).astype("float32"),
         yp=((yp - my) / sy).astype("float32"))

GUION = '''
import json, sys
import numpy as np
import keras

keras.utils.set_random_seed(0)
z = np.load("har.npz")
m = keras.Sequential([keras.Input((3,)), keras.layers.Dense(1)])
b = keras.config.backend()
if len(sys.argv) > 1:             # pesos guardados con otro backend
    m.load_weights(sys.argv[1])
    curva = []
else:
    m.compile(keras.optimizers.Adam(0.01), "mse")
    h = m.fit(z["Xe"], z["ye"], epochs=40, batch_size=128,
              verbose=0, validation_data=(z["Xp"], z["yp"]))
    m.save_weights(f"har_{b}.weights.h5")
    curva = h.history["val_loss"]
p = m.predict(z["Xp"], verbose=0).ravel()
np.save(f"pred_{b}{len(sys.argv)}.npy", p)
w, c = m.layers[-1].get_weights()
print(json.dumps({"w": w.ravel().tolist(), "c": float(c[0]),
                  "curva": curva}))
'''
with open("har_keras.py", "w") as fh:
    fh.write(GUION)

La siguiente celda lanza el guion dos veces con subprocess y el mismo intérprete, cambiando solo KERAS_BACKEND, y devuelve las pendientes a la escala original (multiplicando por σy/σx\sigma_y / \sigma_x):

IN [08]python
import subprocess
import sys

def ejecuta(backend, *args):
    entorno = {**os.environ, "KERAS_BACKEND": backend}
    out = subprocess.run([sys.executable, "har_keras.py", *args],
                         env=entorno, capture_output=True, text=True,
                         check=True).stdout
    return json.loads(out.strip().splitlines()[-1])

res = {b: ejecuta(b) for b in ("torch", "jax")}
filas = {"OLS": list(b_ols[1:]) + [np.mean((p_ols - yp) ** 2)]}
for b, r_ in res.items():
    pend = np.array(r_["w"]) * sy / sd          # a la escala original
    pred = np.load(f"pred_{b}1.npy") * sy + my
    filas[f"keras-{b}"] = list(pend) + [np.mean((pred - yp) ** 2)]
pd.DataFrame(filas, index=COLS + ["MSE prueba"]).T.round(4)
OUT [08]El mismo guion con PyTorch y con JAX
                 d1      s5     m22  MSE prueba
OLS          0.3734  0.2777  0.2728      0.1738
keras-torch  0.3738  0.2780  0.2767      0.1738
keras-jax    0.3708  0.2879  0.2633      0.1737
Fig. — Pendientes del HAR del índice (medias de 1, 5 y 22 días del log RV) por mínimos cuadrados y entrenadas con Keras 3 en PyTorch y en JAX

Las dos versiones de Keras llegan al MSE de prueba de la OLS a la cuarta cifra (0,1738 y 0,1737), pero las pendientes no son idénticas: la de PyTorch queda a menos de 0,004 de la OLS en las tres; la de JAX se separa hasta 0,010 en la semanal y la mensual. No es un error. Las tres medias del HAR están muy correlacionadas (cada una contiene a la anterior), y la pérdida forma un valle casi plano en el que muchas combinaciones de pendientes dan el mismo error; el descenso por gradiente con minilotes se para en algún punto del valle que depende del azar de la inicialización y del barajado. Para predecir da igual. Para leer coeficientes, la OLS (o la lección 5.5.1) es la herramienta.

La curva de validación de cada backend, época a época:

IN [09]python
curvas = pd.DataFrame({b: np.array(r_["curva"]) * sy**2
                       for b, r_ in res.items()},
                      index=range(1, 41)).rename_axis("época")
curvas.iloc[[0, 1, 2, 4, 9, 19, 39]].round(4)
OUT [09]Las curvas de validación de los dos backends
        torch     jax
época
1      0.2820  0.3861
2      0.2354  0.2905
3      0.2291  0.2682
5      0.2128  0.2330
10     0.1859  0.1899
20     0.1735  0.1741
40     0.1738  0.1737

La figura de la cabecera dibuja las dos curvas completas. Empiezan en sitios distintos (0,282 en PyTorch y 0,386 en JAX tras la primera época) porque parten de pesos iniciales distintos con la misma semilla, y se juntan sobre la línea de la OLS hacia la época 20. A partir de ahí, las dos oscilan en la cuarta cifra decimal.

Pesos portables entre backends

Si los entrenamientos difieren por el azar, la prueba de que los dos backends calculan lo mismo es otra: los mismos pesos tienen que dar las mismas predicciones. save_weights escribe un fichero .weights.h5 (HDF5) con los pesos de cada capa, independiente del backend. La celda carga en un proceso de JAX los pesos guardados por PyTorch:

IN [10]python
cruzado = ejecuta("jax", "har_torch.weights.h5")
p_torch = np.load("pred_torch1.npy")
p_cruz = np.load("pred_jax2.npy")
print("mismos pesos:", np.allclose(cruzado["w"], res["torch"]["w"]))
print("predicciones iguales (tolerancia 1e-6):",
      np.allclose(p_torch, p_cruz, rtol=0, atol=1e-6))
OUT [10]Pesos de PyTorch cargados en JAX
mismos pesos: True
predicciones iguales (tolerancia 1e-6): True

Las predicciones coinciden dentro de una tolerancia de 10−610^{-6} en las 2.011 sesiones de prueba. Las diferencias que quedan, del orden de 10−710^{-7}, son de redondeo en float32: cada backend suma en su propio orden. Un modelo entrenado en JAX en una estación de trabajo puede servirse con PyTorch dentro de otra aplicación, o al revés; la lección 5.6.4 lo hace con el formato .keras completo.

Determinismo en cada backend

Un resultado que cambia al volver a ejecutar no se puede revisar ni depurar. En CPU, con hilos fijos, basta con fijar todas las fuentes de azar; en GPU hacen falta además algoritmos deterministas, que a veces son más lentos.

BackendSemillasAlgoritmos deterministasNotas
Todoskeras.utils.set_random_seed(s): Python, NumPy y el generador del backend—fijar antes de crear el modelo
PyTorchlo anterior (llama a torch.manual_seed)torch.use_deterministic_algorithms(True)una operación sin versión determinista lanza RuntimeError
JAXlo anterior (Keras lleva su propio SeedGenerator)en CPU, las ejecuciones son idénticas sin másen código JAX puro, claves explícitas (jax.random.key)
TensorFlowlo anteriortf.config.experimental.enable_op_determinism()también hace deterministas las operaciones de tf.data

Además, el número de hilos de BLAS y OpenMP tiene que ser fijo (el ejecutor del curso pone 4): repartir una suma entre otro número de hilos cambia el orden y, con él, los últimos decimales. Y las salidas se redondean a lo que la conclusión necesita, porque otra CPU puede dar decimales lejanos distintos con el mismo código. La comprobación en este proceso (PyTorch):

IN [11]python
z = np.load("har.npz")

def entrena(semilla, epocas=5):
    if semilla is not None:
        keras.utils.set_random_seed(semilla)
    m = keras.Sequential([keras.Input((3,)), layers.Dense(1)])
    m.compile(keras.optimizers.Adam(0.01), "mse")
    m.fit(z["Xe"], z["ye"], epochs=epocas, batch_size=128, verbose=0)
    return m.layers[-1].get_weights()[0].ravel()

a, b = entrena(0), entrena(0)
c = entrena(None)
print("semilla 0 dos veces, idénticos:", np.array_equal(a, b))
print("sin volver a fijar la semilla :", np.array_equal(a, c))
OUT [11]Determinismo: semilla fija, dos entrenamientos
semilla 0 dos veces, idénticos: True
sin volver a fijar la semilla : False

Con la semilla fijada antes de cada entrenamiento, dos ajustes dan pesos idénticos bit a bit. Sin volver a fijarla, el generador sigue donde se quedó y el segundo modelo empieza con otros pesos y otro orden de lotes. Por eso cada experimento de las lecciones fija la semilla justo antes de construir su modelo.

Lo nativo de cada backend

La portabilidad no impide bajar al motor. Con el backend de PyTorch, un modelo de Keras es un torch.nn.Module: sus pesos son torch.nn.Parameter, autograd calcula sus gradientes y cualquier optimizador o bucle de PyTorch puede entrenarlo. La celda calcula el gradiente del error cuadrático de un lote de 256 filas con autograd y lo compara con la fórmula a mano, ∇w=2nX⊤(Xw+b−y)\nabla_w = \tfrac{2}{n} X^\top (Xw + b - y):

IN [12]python
m = keras.Sequential([keras.Input((3,)), layers.Dense(1)])
m.load_weights("har_torch.weights.h5")
print("es torch.nn.Module:", isinstance(m, torch.nn.Module))
print([tuple(p.shape) for p in m.parameters()])

xb = torch.tensor(z["Xe"][:256])
yb = torch.tensor(z["ye"][:256])
perdida = torch.mean((m(xb).squeeze(-1) - yb) ** 2)
perdida.backward()
capa = m.layers[-1]
g_torch = np.r_[capa.kernel.value.grad.numpy().ravel(),
                capa.bias.value.grad.numpy()]

w, c = capa.get_weights()
resid = z["Xe"][:256] @ w.ravel() + c[0] - z["ye"][:256]
g_mano = 2 * np.r_[z["Xe"][:256].T @ resid, resid.sum()] / 256
print("gradiente autograd:", np.round(g_torch, 5))
print("gradiente a mano  :", np.round(g_mano, 5))
OUT [12]Con PyTorch, el modelo de Keras es un nn.Module
es torch.nn.Module: True
[(1,), (3, 1)]
gradiente autograd: [0.06436 0.02329 0.05671 0.07229]
gradiente a mano  : [0.06436 0.02329 0.05671 0.07229]

parameters() devuelve la constante (forma (1,)) y la matriz de pesos (3, 1), y el gradiente de autograd coincide con el de la fórmula en las cinco cifras mostradas, en el orden pendiente diaria, semanal, mensual y constante. La lección 5.7.1 desarrolla el bucle de entrenamiento de PyTorch; un modelo de Keras se puede meter en él.

Con TensorFlow, el código es el mismo cambiando la variable; con la versión estable de TensorFlow que no se puede instalar aquí, quedaría así:

IN [13]python
import os
os.environ["KERAS_BACKEND"] = "tensorflow"   # antes de importar keras
import keras
import tensorflow as tf

keras.utils.set_random_seed(0)
tf.config.experimental.enable_op_determinism()
print(keras.config.backend())   # tensorflow

Con JAX, el equivalente de bajar al motor es la función sin estado. JAX trabaja con funciones puras: los pesos no viven dentro del modelo, se pasan como argumento. stateless_call evalúa el modelo con unos pesos dados y devuelve la salida y el estado nuevo, así que jax.grad y jax.jit se aplican directamente (la guía de Keras «Writing a training loop from scratch in JAX» lo desarrolla). Este bloque necesita un proceso con KERAS_BACKEND=jax, por eso no se ejecuta dentro de esta lección:

IN [14]python
import os
os.environ["KERAS_BACKEND"] = "jax"
import jax
import keras

m = keras.Sequential([keras.Input((3,)), keras.layers.Dense(1)])
entrenables = [v.value for v in m.trainable_variables]
no_entrenables = [v.value for v in m.non_trainable_variables]

def perdida(entrenables, x, y):
    pred, _ = m.stateless_call(entrenables, no_entrenables, x)
    return jax.numpy.mean((pred[:, 0] - y) ** 2)

grad = jax.jit(jax.grad(perdida))(entrenables, x, y)

Qué backend elegir

No hay una respuesta universal, pero sí criterios para un equipo de research cuantitativo:

Modelo pequeño en CPUJAXIntegrar con PyTorchPyTorchServir en TF ServingTensorFlowMismo código Keras
Fuente Mermaid
flowchart TB
  A[Modelo pequeño en CPU] --> J[JAX]
  B[Integrar con PyTorch] --> T[PyTorch]
  C[Servir en TF Serving] --> F[TensorFlow]
  J --> K[Mismo código Keras]
  T --> K
  F --> K
  • PyTorch si el resto del sistema ya es PyTorch (los modelos fundacionales del módulo 5.8 lo son) o si hace falta mezclar capas de Keras con módulos de PyTorch.
  • JAX para entrenar muchos modelos pequeños en CPU, como en una búsqueda de hiperparámetros o un walk-forward con reentrenamiento (lección 5.4.3): la compilación con XLA amortiza el coste fijo de cada paso.
  • TensorFlow si el despliegue usa su ecosistema (TF Serving, LiteRT). En Python 3.14 aún no hay versión estable.

En los tres casos, el modelo y los pesos se pueden mover. Lo que no se mueve es el azar del entrenamiento: un resultado que solo aparece con un backend, o con una semilla, es ruido hasta que se demuestre lo contrario (lección 5.6.2).

Ejercicios

  1. Comprueba el orden de prioridad de la configuración: en un proceso hijo sin KERAS_BACKEND y con un KERAS_HOME propio cuyo keras.json diga "backend": "jax", ¿qué backend arranca? ¿Y si además se define KERAS_BACKEND=torch?
Solución
IN [15]python
import tempfile

casa = tempfile.mkdtemp()
with open(os.path.join(casa, "keras.json"), "w") as fh:
    json.dump({"backend": "jax", "floatx": "float32",
               "epsilon": 1e-07, "image_data_format": "channels_last"},
              fh)
orden = "import keras; print(keras.config.backend())"
base = {k: v for k, v in os.environ.items() if k != "KERAS_BACKEND"}
for extra in ({}, {"KERAS_BACKEND": "torch"}):
    entorno = {**base, "KERAS_HOME": casa, **extra}
    out = subprocess.run([sys.executable, "-c", orden], env=entorno,
                         capture_output=True, text=True, check=True)
    print(extra or "solo keras.json", "->", out.stdout.strip())
OUT [15]keras.json frente a KERAS_BACKEND
solo keras.json -> jax
{'KERAS_BACKEND': 'torch'} -> torch
  1. Añade al HAR la parte negativa del rendimiento de ayer, ℓt−1=min⁡(rt−1,0)\ell_{t-1} = \min(r_{t-1}, 0), como segunda entrada de un modelo funcional (las tres medias por un lado y ℓ\ell por otro, unidas con Concatenate). Compara sus coeficientes con los de la OLS con los cuatro regresores.
Solución
IN [16]python
neg = np.minimum(d["rendimiento"], 0).shift(1).reindex(har.index)
L = neg.to_numpy()
Le, Lp = L[ent], L[~ent]
b4 = np.linalg.lstsq(np.c_[np.ones(len(Xe)), Xe, Le], ye,
                     rcond=None)[0]
ml, sl = Le.mean(), Le.std()

keras.utils.set_random_seed(0)
e1 = keras.Input((3,), name="har")
e2 = keras.Input((1,), name="negativo")
s = layers.Dense(1)(layers.Concatenate()([e1, e2]))
har_l = keras.Model([e1, e2], s)
har_l.compile(keras.optimizers.Adam(0.01), "mse")
har_l.fit([z["Xe"], ((Le - ml) / sl)[:, None].astype("float32")],
          z["ye"], epochs=40, batch_size=128, verbose=0)
w = har_l.layers[-1].get_weights()[0].ravel()
pd.DataFrame({"OLS": b4[1:],
              "keras": w * sy / np.r_[sd, sl]},
             index=COLS + ["negativo"]).round(3)
OUT [16]HAR con efecto apalancamiento en la API funcional
             OLS   keras
d1         0.267   0.258
s5         0.344   0.341
m22        0.278   0.270
negativo -18.385 -19.045
  1. Un compañero entrena el mismo modelo con keras.utils.set_random_seed(0) en PyTorch y en JAX y le preocupa que los pesos finales no coincidan. ¿Es un error? ¿Qué comprobarías para saber si los dos backends calculan lo mismo?
Solución

No es un error. La semilla de Keras fija el generador de cada backend, pero cada backend tiene su propio generador y su propia forma de consumirlo: la inicialización de los pesos y el barajado de los lotes salen distintos, y el optimizador recorre otro camino hacia el mismo mínimo. Lo que debe coincidir es el cálculo con los mismos pesos: cargar los pesos de un backend en el otro (save_weights / load_weights) y comparar las predicciones con una tolerancia de coma flotante, como en la lección (iguales a 10−610^{-6}), y comparar los gradientes de un lote fijo. Si eso cuadra, las diferencias de los pesos entrenados son ruido del optimizador y deben ser del tamaño de las que hay entre dos semillas del mismo backend.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Un guion hace import keras y en la línea siguiente os.environ["KERAS_BACKEND"] = "jax". ¿Con qué backend entrena?

    • ACon el que estuviera configurado al importar (la variable de entorno previa, keras.json o TensorFlow por defecto): el backend se fija una vez, en el primer import keras
    • BCon JAX: la variable se lee al compilar el modelo
    • CCon los dos a la vez
    • DLanza un error al cambiar la variable
    Ver respuesta

    Respuesta A. Con el que estuviera configurado al importar (la variable de entorno previa, keras.json o TensorFlow por defecto): el backend se fija una vez, en el primer import keras

    Keras lee KERAS_BACKEND y keras.json al importarse y no permite cambiar de backend después. Por eso la variable se pone en el entorno del proceso o en las primeras líneas, antes de cualquier import keras (también los indirectos).

  2. El mismo guion, con set_random_seed(0), da en PyTorch una pendiente diaria de 0,3738 y en JAX de 0,3708. ¿Qué indica?

    • AQue uno de los dos backends calcula mal la capa densa
    • BQue cada backend tiene su propio generador aleatorio: la inicialización y el barajado de los lotes difieren y el optimizador sigue otro camino; con los mismos pesos, las predicciones coinciden hasta la millonésima
    • CQue la semilla no funciona en JAX
    • DQue hay que entrenar en float64
    Ver respuesta

    Respuesta B. Que cada backend tiene su propio generador aleatorio: la inicialización y el barajado de los lotes difieren y el optimizador sigue otro camino; con los mismos pesos, las predicciones coinciden hasta la millonésima

    La lección carga en JAX los pesos guardados con PyTorch y obtiene las mismas predicciones dentro de la tolerancia de coma flotante. Las diferencias del entrenamiento vienen del azar de cada backend, no de la aritmética.

  3. ¿Cuándo compensa la API funcional frente a Sequential?

    • ANunca: son equivalentes
    • BSolo con el backend JAX
    • CCuando el modelo tiene más de diez capas
    • DCuando el modelo tiene varias entradas o salidas, ramas o capas compartidas: la funcional describe un grafo, la secuencial solo una pila
    Ver respuesta

    Respuesta D. Cuando el modelo tiene varias entradas o salidas, ramas o capas compartidas: la funcional describe un grafo, la secuencial solo una pila

    Sequential encadena capas una tras otra. Un modelo con las medias del HAR por una entrada y el identificador del activo por otra (con un Embedding) necesita un grafo, y la API funcional lo construye y lo deja inspeccionable.

  4. En una subclase de keras.Model con GaussianNoise o Dropout, ¿para qué sirve el argumento training de call?

    • APara elegir el backend
    • BPara activar el cálculo de gradientes en JAX
    • CPara que esas capas actúen solo al entrenar: en inferencia la salida tiene que ser determinista, y predict y fit pasan el valor correcto
    • DNo sirve para nada en Keras 3
    Ver respuesta

    Respuesta C. Para que esas capas actúen solo al entrenar: en inferencia la salida tiene que ser determinista, y predict y fit pasan el valor correcto

    Con training=True la capa de ruido cambia la salida (en la lección, de 0,4735 a -0,104 en la primera fila); con training=False, no. Una subclase que no propague training a sus capas mete ruido en las predicciones.

  5. La capa densa entrenada con Adam llega al mismo MSE de prueba que la OLS (0,1738) con pendientes que difieren en la tercera cifra decimal. ¿Por qué?

    • APorque Adam es mejor estimador que la OLS
    • BPorque la prueba es demasiado corta
    • CPorque Keras no tiene término constante
    • DPorque las tres medias del HAR están muy correlacionadas: hay muchas combinaciones de pendientes con casi el mismo error, y el ruido de los minilotes deja el resultado en algún punto de ese valle plano
    Ver respuesta

    Respuesta D. Porque las tres medias del HAR están muy correlacionadas: hay muchas combinaciones de pendientes con casi el mismo error, y el ruido de los minilotes deja el resultado en algún punto de ese valle plano

    La OLS da el mínimo exacto; el descenso por gradiente con minilotes se queda cerca, dentro de una región donde la pérdida apenas cambia. Para predecir da igual; para interpretar coeficientes, no.

Ideas clave

  • El backend de Keras 3 se elige antes del primer import keras: KERAS_BACKEND manda sobre keras.json, y sin ninguno de los dos Keras intenta usar TensorFlow.
  • keras.ops es una API de tipo NumPy que devuelve tensores del backend (aquí torch.Tensor); una función escrita con ella vale en PyTorch, JAX y TensorFlow.
  • Secuencial para pilas simples, funcional para grafos con varias entradas o salidas, subclase para lógica propia en call (respetando training). Con los mismos pesos, las tres dan la misma salida.
  • Una capa densa entrenada con Adam recupera la OLS del HAR (MSE de prueba 0,1738 en los dos backends); cada backend tiene su propio azar, pero los pesos guardados en uno predicen igual en el otro.
  • Determinismo: keras.utils.set_random_seed en todos los backends, más torch.use_deterministic_algorithms(True) en PyTorch y enable_op_determinism() en TensorFlow; hilos fijos y salidas redondeadas.
  • Con PyTorch, un modelo de Keras es un torch.nn.Module: autograd, optimizadores y bucles de PyTorch funcionan con él (lección 5.7.1).

Recursos