Despliegue de modelos y MLOps

deep-learning
mlops
despliegue
Serialización, inferencia, APIs, monitoreo, drift y fundamentos de MLOps.
Author

Wilder Ramírez Delgado

Published

September 11, 2026

Despliegue de modelos y MLOps

Serie Deep Learning · del perceptrón a los transformers.

Open in Colab

En este artículo cerramos el ciclo: serializamos una CNN, servimos inferencia con un contrato tipo API, detectamos data drift y model drift, y situamos el reentrenamiento dentro de MLOps, no como un fit suelto.

1. Introducción

Entrenar no es desplegar. Un accuracy de notebook no llega al usuario: hay que congelar pesos, exponer una entrada/salida estable, registrar lo que se predijo y decidir cuándo el modelo dejó de valer.

MLOps es la unión de Machine Learning y operaciones: versionar datos, código y modelos; automatizar entrenamiento y publicación; observar el servicio. No es «subir un .h5 a un VPS».

Pregunta: ¿qué artefactos y qué alarmas hacen falta para que una CNN de la sesión 6 sobreviva fuera del laboratorio?

Qué ya se conoce

CNN, Train/Validation/Test, guardado .keras (sesión 6b), generalización, evaluación.

Recorrido

SERIALIZAR → INFERIR → API → CICLO DE VIDA → MONITOREO → DRIFT → REENTRENAMIENTO → MLOPS.

2. Serialización de modelos

Serializar es escribir el grafo y los pesos en un artefacto reproducible. En Keras 3 el formato nativo es una carpeta/archivo .keras. Alternativas: SavedModel (TensorFlow Serving), ONNX (interop), TFLite (móvil).

Reglas que evitan sustos:

  • Guarde después de elegir el checkpoint (mejor val_loss), no solo el último fit.
  • Versionar el artefacto (mnist_cnn_2026-09-11.keras), no sobrescribir modelo.keras.
  • Guardar junto el preproceso (escala [0,1], tamaño 28×28, orden de canales). Un modelo correcto con un preproceso distinto es un modelo distinto.

Idea clave

El artefacto no es solo pesos. Es pesos + contrato de entrada. Si el contrato se rompe, el servicio «funciona» y predice basura.

import json
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import tensorflow as tf
from scipy.stats import ks_2samp

tf.keras.utils.set_random_seed(42)

(x_tr, y_tr), (x_te, y_te) = tf.keras.datasets.fashion_mnist.load_data()
x_tr = x_tr.astype("float32") / 255.0
x_te = x_te.astype("float32") / 255.0
x_tr, x_te = x_tr[..., None], x_te[..., None]

modelo = tf.keras.Sequential(
    [
        tf.keras.layers.Input(shape=(28, 28, 1)),
        tf.keras.layers.Conv2D(16, 3, activation="relu"),
        tf.keras.layers.MaxPool2D(),
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(32, activation="relu"),
        tf.keras.layers.Dense(10, activation="softmax"),
    ]
)
modelo.compile(optimizer="adam", loss="sparse_categorical_crossentropy", metrics=["accuracy"])
modelo.fit(x_tr[:8000], y_tr[:8000], epochs=3, batch_size=128, verbose=1)

carpeta = Path("artefactos_mlops")
carpeta.mkdir(exist_ok=True)
ruta = carpeta / "fashion_cnn_v1.keras"
modelo.save(ruta)
print("Guardado:", ruta.resolve(), "bytes", ruta.stat().st_size)
WARNING:tensorflow:TensorFlow GPU support is not available on native Windows for TensorFlow >= 2.11. Even if CUDA/cuDNN are installed, GPU will not be used. Please use WSL2 or the TensorFlow-DirectML plugin.

Epoch 1/3


 1/63 ━━━━━━━━━━━━━━━━━━━━ 40s 647ms/step - accuracy: 0.1328 - loss: 2.3596

10/63 ━━━━━━━━━━━━━━━━━━━━ 0s 6ms/step - accuracy: 0.2672 - loss: 2.1007   

17/63 ━━━━━━━━━━━━━━━━━━━━ 0s 7ms/step - accuracy: 0.3704 - loss: 1.9421

25/63 ━━━━━━━━━━━━━━━━━━━━ 0s 6ms/step - accuracy: 0.4453 - loss: 1.7616

32/63 ━━━━━━━━━━━━━━━━━━━━ 0s 7ms/step - accuracy: 0.5007 - loss: 1.6041

40/63 ━━━━━━━━━━━━━━━━━━━━ 0s 7ms/step - accuracy: 0.5387 - loss: 1.4703

52/63 ━━━━━━━━━━━━━━━━━━━━ 0s 6ms/step - accuracy: 0.5823 - loss: 1.3120

62/63 ━━━━━━━━━━━━━━━━━━━━ 0s 6ms/step - accuracy: 0.6132 - loss: 1.2093

63/63 ━━━━━━━━━━━━━━━━━━━━ 1s 6ms/step - accuracy: 0.6143 - loss: 1.2047

Epoch 2/3


 1/63 ━━━━━━━━━━━━━━━━━━━━ 1s 26ms/step - accuracy: 0.7266 - loss: 0.7490

13/63 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 0.7782 - loss: 0.6237 

25/63 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 0.7841 - loss: 0.6045

38/63 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 0.7930 - loss: 0.5786

50/63 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 0.7941 - loss: 0.5736

63/63 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 0.7981 - loss: 0.5629

63/63 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 0.7981 - loss: 0.5629

Epoch 3/3


 1/63 ━━━━━━━━━━━━━━━━━━━━ 1s 25ms/step - accuracy: 0.8047 - loss: 0.5707

13/63 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 0.8263 - loss: 0.4988 

21/63 ━━━━━━━━━━━━━━━━━━━━ 0s 5ms/step - accuracy: 0.8307 - loss: 0.4903

33/63 ━━━━━━━━━━━━━━━━━━━━ 0s 5ms/step - accuracy: 0.8319 - loss: 0.4842

46/63 ━━━━━━━━━━━━━━━━━━━━ 0s 5ms/step - accuracy: 0.8331 - loss: 0.4825

60/63 ━━━━━━━━━━━━━━━━━━━━ 0s 4ms/step - accuracy: 0.8340 - loss: 0.4788

63/63 ━━━━━━━━━━━━━━━━━━━━ 0s 5ms/step - accuracy: 0.8339 - loss: 0.4776

Guardado: C:\Users\wilde\Dropbox\bit-blog\deep-learning\14-despliegue-mlops\artefactos_mlops\fashion_cnn_v1.keras bytes 1074833

3. Inferencia: cargar y predecir

La inferencia de producción no reentrena. Carga el artefacto, aplica el mismo preproceso y devuelve la distribución (no solo el argmax) para poder rechazar predicciones inciertas.

modelo_prod = tf.keras.models.load_model(ruta)
print(modelo_prod.evaluate(x_te[:2000], y_te[:2000], verbose=0))

def preprocesar(lote_uint8):
    """Contrato: uint8 (N,28,28) → float32 (N,28,28,1) en [0,1]."""
    x = np.asarray(lote_uint8, dtype="float32") / 255.0
    if x.ndim == 3:
        x = x[..., None]
    return x

def inferir(lote_uint8, umbral=0.45):
    x = preprocesar(lote_uint8)
    probs = modelo_prod.predict(x, verbose=0)
    clases = np.argmax(probs, axis=1)
    conf = probs[np.arange(len(clases)), clases]
    return [
        {
            "clase": int(c),
            "confianza": float(k),
            "rechazar": bool(k < umbral),
            "probs": [float(p) for p in row],
        }
        for c, k, row in zip(clases, conf, probs)
    ]

muestra = (x_te[:3] * 255).astype("uint8").squeeze()
print(json.dumps(inferir(muestra), indent=2)[:500], "...")
[0.4646139442920685, 0.8489999771118164]
[
  {
    "clase": 9,
    "confianza": 0.6872235536575317,
    "rechazar": false,
    "probs": [
      0.00017496460350230336,
      0.000886075256858021,
      0.0006050710799172521,
      7.52543201087974e-06,
      8.468355372315273e-05,
      0.04094283655285835,
      0.00019304617308080196,
      0.24652481079101562,
      0.023357415571808815,
      0.6872235536575317
    ]
  },
  {
    "clase": 2,
    "confianza": 0.9832543730735779,
    "rechazar": false,
    "probs": [
      0.00075312 ...

4. Exposición mediante APIs

En un servicio real, inferir se envuelve en HTTP (FastAPI, Flask, TensorFlow Serving, Vertex, SageMaker). El contrato JSON es más importante que el framework:

POST /prediccion
{ "instances": [ { "image_b64": "..." } ] }

200
{ "predictions": [ { "clase": 9, "confianza": 0.91, "rechazar": false } ] }

Esta sesión simula el servicio en proceso: misma función, sin abrir un puerto. Así el artículo corre en cualquier máquina del curso.

Checklist del endpoint:

  • validar forma y rango antes de llamar a TF,
  • timeout y tamaño máximo de lote,
  • no loguear imágenes crudas si hay PII,
  • versionar /v1/prediccion cuando cambie el contrato.
class ServicioModelo:
    def __init__(self, path, umbral=0.45):
        self.modelo = tf.keras.models.load_model(path)
        self.umbral = umbral
        self.n_consultas = 0
        self.confianzas = []

    def predecir(self, payload):
        inst = np.asarray(payload["instances"], dtype="uint8")
        if inst.ndim == 3:
            pass
        elif inst.ndim == 2:
            inst = inst[None, ...]
        else:
            raise ValueError("instances debe ser (28,28) o (N,28,28)")
        out = inferir(inst, umbral=self.umbral)
        self.n_consultas += len(out)
        self.confianzas.extend(r["confianza"] for r in out)
        return {"model": "fashion_cnn_v1", "predictions": out}

servicio = ServicioModelo(ruta)
resp = servicio.predecir({"instances": (x_te[0] * 255).astype("uint8").squeeze()})
print(resp["predictions"][0]["clase"], resp["predictions"][0]["confianza"])
print("consultas:", servicio.n_consultas)
9 0.6872240304946899
consultas: 1

5. Ciclo de vida del modelo

dato → entrenamiento → evaluación → registro → despliegue → monitoreo → reentrenamiento
         ↑                                                         |
         +------------------------- disparador --------------------+

Cada flecha tiene dueño y criterio. Si el reentrenamiento es «cuando alguien se acuerde», no hay ciclo: hay artesanía.

Versiones: v1 en producción, v2 candidata. Se promociona si gana en un test fijo y no empeora un slice crítico (por ejemplo la clase Shirt).

6. Monitoreo, data drift y model drift

  • Data drift: cambia (p(x)). Las fotos llegan más oscuras, o llega una prenda nueva.
  • Concept drift / model drift: cambia (p(y x)). La misma silueta ahora se etiqueta distinto (temporada, normativa).
  • Drift de predicción: cambia la distribución de () o de la confianza, síntoma de cualquiera de los dos.

Un test de Kolmogorov–Smirnov sobre un estadístico de la imagen (brillo medio) detecta un corrimiento univariado. No detecta un cambio sutil de estilo. Es una alarma, no un diagnóstico.

brillo_ref = x_tr[:4000].mean(axis=(1, 2, 3))
brillo_prod_ok = x_te[:2000].mean(axis=(1, 2, 3))
brillo_prod_drift = np.clip(x_te[:2000] * 0.55 + 0.05, 0, 1).mean(axis=(1, 2, 3))

ks_ok = ks_2samp(brillo_ref, brillo_prod_ok)
ks_dr = ks_2samp(brillo_ref, brillo_prod_drift)
print("KS producción ≈ train:  stat=%.3f p=%.4g" % (ks_ok.statistic, ks_ok.pvalue))
print("KS producción oscura:   stat=%.3f p=%.4g" % (ks_dr.statistic, ks_dr.pvalue))

# Model drift simulado: el servicio sigue con v1, las etiquetas «reales» se sesgan
pred = modelo_prod.predict(x_te[:2000], verbose=0).argmax(1)
acc_ahora = float((pred == y_te[:2000]).mean())
y_desplazado = y_te[:2000].copy()
y_desplazado[y_desplazado == 6] = 0  # Shirt se relabeléa como T-shirt
acc_concepto = float((pred == y_desplazado).mean())
print(f"Accuracy vs etiqueta original: {acc_ahora:.4f}")
print(f"Accuracy vs concepto desplazado (Shirt→T-shirt): {acc_concepto:.4f}")

fig, ax = plt.subplots(figsize=(6, 3))
ax.hist(brillo_ref, bins=30, alpha=0.5, label="Referencia train", density=True)
ax.hist(brillo_prod_drift, bins=30, alpha=0.5, label="Producción oscura", density=True)
ax.set_title("Data drift de un estadístico (brillo)")
ax.legend()
plt.tight_layout()
plt.show()
KS producción ≈ train:  stat=0.014 p=0.9483
KS producción oscura:   stat=0.317 p=0
Accuracy vs etiqueta original: 0.8490
Accuracy vs concepto desplazado (Shirt→T-shirt): 0.8080

Idea clave

Una alarma de drift no reentrena sola. Dispara una revisión: ¿cambió el sensor, la etiqueta, o el umbral de negocio?

Reto técnico

Fije un umbral de KS y un mínimo de 500 consultas. Escriba la regla if ...: crear_ticket() sin llamar a fit dentro del if.

7. Reentrenamiento periódico

Criterios honestos:

  • venció la ventana (cada lunes, cada 10k etiquetas nuevas),
  • cayó un KPI de negocio (devoluciones, clics), no solo el accuracy interno,
  • el slice crítico empeoró.

El reentrenamiento usa dato nuevo + dato de ancla (para no olvidar clases raras) y un test que no se reutiliza como validación del ajuste. El artefacto nuevo entra como candidata, no como reemplazo silencioso.

8. Fundamentos de MLOps (mapa)

Pieza Pregunta que responde
Control de versiones (git + DVC/Lake) ¿Qué dato y qué código produjeron v1?
Registro de modelos ¿Cuál está en prod y con qué métricas?
CI/CD ¿Un push reentrena y testea o solo lint?
Serving ¿Quién atiende POST /prediccion?
Observabilidad ¿Latencia, tasa de rechazo, drift?
Gobernanza ¿Quién puede promover v2?

Este artículo implementa tres ladrillos (artefacto, contrato de inferencia, alarma KS). El resto es organización. Sin esos tres, el resto es diapositiva.

9. Amenazas a la validez

  • El «servicio» vive en el kernel: no mide red, autenticación ni carga.
  • KS en brillo no es un monitor de visión real.
  • El relabel Shirt→T-shirt es un proxy grosero de concept drift.
  • Tres épocas y 8 000 imágenes: el .keras es didáctico.

10. Ejercicios (sin soluciones completas)

  1. Añada al JSON de respuesta un model_version leído de un metadata.json al lado del .keras.
  2. Simule 200 consultas, calcule la tasa de rechazar y grafíquela por lote de 20.
  3. Implemente un segundo estadístico (varianza espacial) y combine alarmas con AND/OR. ¿Qué cambia?
  4. Escriba el contrato de promoción v1 → v2 en cinco viñetas (métrica, slice, rollback).
  5. Sin escribir Flask, liste tres cabeceras HTTP que pediría en producción (auth, request-id, timeout).

💬 ¿Te sirvió?Deja en los comentarios una duda o un caso donde aplicarías esto —respondo todos. Sígueme para no perderte el próximo artículo de la serie ycomparte con alguien que esté aprendiendo deep learning.👉 El código completo está disponible para ejecutar directamente.