9  Despliegue de modelos como servicios

mlops
produccion
envios
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

24 de octubre de 2026

Serie MLOps y Producción · de la ingeniería de datos al monitoreo.

El problema que vas a resolver

Laura recibe un paquete de 520 kilómetros, sin peso registrado y con servicio económico. El modelo del capítulo 6 puede calcular su probabilidad de retraso. El registro del capítulo 8 identifica qué versión se eligió. Falta conectar esa combinación con la pantalla donde Laura registra el envío.

La pantalla necesita enviar los datos y recibir una respuesta en ese momento. Quien la utiliza conoce paquetes, no objetos de scikit-learn. Vas a construir una aplicación que reciba las medidas originales, compruebe la entrada y responda con una probabilidad, una alerta y la versión utilizada.

Ficha En este capítulo
Pregunta ¿Cómo pide el sistema de Laura una predicción para cada paquete?
Datos Los mismos 600 envíos sintéticos y la versión conocida como corrida_02
Herramientas Python, scikit-learn, joblib, FastAPI, Pydantic, httpx y matplotlib
Lo que produces Una aplicación comprobada localmente con peticiones y respuestas
Lectura y nivel Unos 60 minutos; introducción a servicios de predicción
Condiciones CPU, FastAPI y httpx ya instalados; sin abrir un puerto de red

Objetivos de aprendizaje

Al terminar este capítulo podrás:

  1. Explicar quién pide y quién responde en un servicio de predicción.
  2. Escribir el paquete de Laura como un mensaje JSON, con el peso faltante como null.
  3. Validar cada envío con Pydantic antes de llamar al modelo.
  4. Distinguir una entrada incorrecta (código 422) de un fallo interno (código 500).
  5. Cargar el pipeline una sola vez al arrancar y devolver el umbral de Laura en cada respuesta.
  6. Medir cuánto tarda en responder la aplicación.

Empezarás en una ventanilla que recibe un diccionario y devuelve otro. Después escribirás el mensaje que viajaría por la red, prepararás la versión elegida del modelo y definirás el servicio. Harás peticiones a la aplicación para observar respuestas correctas y errores provocados. Terminarás comparando el paquete de Laura con un lote pequeño atendido por el mismo modelo.

TipLo que necesitas recordar
Concepto En una línea Dónde se explica
Pipeline Recibe columnas originales y aplica la preparación junto con el modelo. Capítulo 5
Esquema Describe columnas, tipos y rangos que se admiten. Capítulo 5
joblib y huella Recuperan el pipeline e identifican su archivo. Capítulo 5
Probabilidad y umbral El modelo calcula el número; la política decide cuándo alertar. Capítulo 6
Manifiesto Relaciona archivos y resultados que otra parte del sistema necesita. Capítulo 7
Corrida y referencia actual Identifican el modelo elegido y conservan su historia. Capítulo 8

1) Empezar por una ventanilla

En un restaurante entregas un pedido en la ventanilla. La persona que lo recibe comprueba que puede atenderlo. Si falta el plato que quieres, te lo pregunta antes de enviarlo a la cocina. Si todo está claro, recibes una respuesta sin tener que conocer el interior de la cocina.

La ventanilla de los envíos puede empezar igual de pequeña. Recibe un diccionario con los datos del paquete y devuelve otro que indica si lo admite. Usará una regla de juguete: revisar los recorridos de 300 km o más. Es una decisión inventada para practicar la entrada y la salida; el modelo aprendido aparecerá después.

Un pedido con un paquete de 520 kilómetros y servicio económico llega a revisión de campos y unidades. Si se admite, vuelve una respuesta con el resultado identificado.

La ventanilla revisa el pedido antes de enviarlo al trabajo que lo atiende.

La primera celda registra las bibliotecas utilizadas. La carpeta de resultados tiene un nombre fijo y se vacía al empezar. Las peticiones que harás aquí se dirigen a una aplicación local de prueba y todos los envíos del ejemplo son sintéticos.

from pathlib import Path
from contextlib import ExitStack
import hashlib
import json
import platform
import shutil
import time
import warnings
import joblib
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import sklearn
import fastapi
import httpx
import pydantic
from IPython.display import display
from starlette.exceptions import StarletteDeprecationWarning
# Conservamos el cliente instalado; solo silenciamos su aviso de migración de httpx.
warnings.filterwarnings("ignore", category=StarletteDeprecationWarning,
                        message="Using `httpx` with `starlette.testclient` is deprecated.*")
from fastapi.testclient import TestClient
from pydantic import ValidationError
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from servicio_envios import crear_app, Envio, predecir_envios

SEMILLA = 42
CARPETA = Path("artefactos/servicio-envios")
shutil.rmtree(CARPETA, ignore_errors=True)
CARPETA.mkdir(parents=True)
versiones = {"python": platform.python_version(), "pandas": pd.__version__,
             "scikit-learn": sklearn.__version__, "joblib": joblib.__version__,
             "numpy": np.__version__, "fastapi": fastapi.__version__,
             "httpx": httpx.__version__, "pydantic": pydantic.__version__}
print(versiones)
{'python': '3.13.14', 'pandas': '3.0.5', 'scikit-learn': '1.9.0', 'joblib': '1.5.3', 'numpy': '2.5.1', 'fastapi': '0.162.0', 'httpx': '0.28.1', 'pydantic': '2.14.1'}

Las versiones fastapi, httpx y pydantic identifican las herramientas que recibirán y comprobarán los mensajes. La semilla 42 permitirá reconstruir el mismo historial del modelo. Ahora puedes probar la ventanilla sin introducir todavía mensajes de red.

def ventanilla(paquete):
    """Recibe un diccionario de juguete y devuelve recepción o error de campos."""
    if "distancia_km" not in paquete:
        return {"aceptado": False, "motivo": "Falta distancia_km"}
    return {"aceptado": True, "revisar_recorrido": paquete["distancia_km"] >= 300}


print(ventanilla({"distancia_km": 520, "peso_kg": None, "servicio": "económico"}))
print(ventanilla({"peso_kg": 4, "servicio": "express"}))
{'aceptado': True, 'revisar_recorrido': True}
{'aceptado': False, 'motivo': 'Falta distancia_km'}

El paquete de 520 km devuelve aceptado igual a True y revisar_recorrido igual a True. El segundo diccionario carece de distancia_km y devuelve aceptado igual a False con el motivo Falta distancia_km. Las 2 respuestas indican qué ocurrió antes de introducir comunicación HTTP.

En pocas palabras: primero se revisa el pedido y solo después se hace el trabajo. Si falta distancia, devuelve el nombre del campo que necesita. Con un paquete admitido aplica la regla de revisión. Mantendrás esa separación cuando cambies la regla de juguete por el pipeline ajustado.

2) Dar un nombre a quien pide y a quien responde

NotaCliente, servidor y servicio

El cliente es el programa que pide una operación, como la pantalla de recepción de Laura. El servidor es el proceso que recibe la petición y prepara la respuesta. Un servicio de predicción ofrece una forma acordada de solicitar resultados del modelo a otros programas.

El cliente conoce distancia, peso y servicio. El servidor conserva el pipeline, su umbral y la versión elegida. Esa separación permite cambiar el archivo utilizado en el servidor sin pedirle a Laura que prepare nuevas columnas en su pantalla.

El cliente de Laura envía distancia, peso y servicio en una petición JSON al servidor. El servidor valida y utiliza el pipeline con su umbral. Una flecha de vuelta representa la respuesta al cliente.

El cliente envía un caso y el servidor devuelve una decisión.
NotaPetición, respuesta y JSON

Una petición es el mensaje con el que un cliente solicita una operación. La respuesta es el mensaje que vuelve con el resultado o el problema encontrado. JSON es un formato de texto para intercambiar objetos con nombres y valores, listas, números, booleanos y valores nulos.

El diccionario de Python puede convertirse en JSON y recuperarse después. Hay un detalle importante para el paquete de Laura: el peso ausente se escribe null en JSON y se recupera como None en Python. Enviarás un valor nulo declarado, en lugar de una cadena vacía o el texto «desconocido» dentro de una medida numérica.

paquete_laura = {"distancia_km": 520.0, "peso_kg": None, "servicio": "económico"}
mensaje = json.dumps(paquete_laura, ensure_ascii=False, allow_nan=False)
print(mensaje)
print("Peso recuperado:", json.loads(mensaje)["peso_kg"])
{"distancia_km": 520.0, "peso_kg": null, "servicio": "económico"}
Peso recuperado: None

El mensaje contiene distancia_km de 520.0, peso_kg igual a null y servicio económico. Al recuperarlo, el peso aparece como None. Conservaste las 3 entradas del paquete y expresaste de manera explícita el dato ausente.

El cliente enviará las medidas originales, con distancia en kilómetros y peso en kilogramos. Escalar y rellenar siguen siendo responsabilidades del pipeline. Si el cliente enviase una distancia ya escalada bajo el nombre distancia_km, el servidor recibiría otra medida con un nombre incorrecto.

Qué aporta HTTP

NotaHTTP, ruta y API

HTTP es el protocolo de petición y respuesta que usarás para comunicar programas. Una ruta identifica una operación del servicio, como /v1/predecir. Una API es la interfaz que describe cómo puede un programa pedir esas operaciones. El método HTTP indica la acción: aquí POST envía un caso y GET consulta el estado.

El método y la ruta van acompañados de un cuerpo: el JSON del paquete. La respuesta tiene un código de estado y otro cuerpo. Puedes leerlos como el rótulo y el contenido de un sobre: primero identificas qué ocurrió y después revisas los detalles necesarios para continuar.

Las rutas serán GET /salud, POST /v1/predecir y POST /v1/predecir-lote. El segmento v1 identifica esta versión de la interfaz. La versión del modelo será otro dato de la respuesta: cambiar los coeficientes y cambiar los nombres de campos son cambios diferentes.

NotaCódigo de estado

Un código de estado resume el resultado de una petición. En este servicio, 200 significa que la operación terminó correctamente; 422, que la entrada incumple el esquema; 500, que ocurrió un fallo inesperado dentro del servidor.

Código Caso de Laura Siguiente paso
200 El paquete se admite y existe una predicción. Usar la respuesta identificada.
422 La distancia es negativa o falta un campo requerido. Corregir la entrada indicada.
500 La entrada era válida, pero el componente de predicción falló. Investigar el servicio y gestionar el fallo.

Una petición de envío se ramifica en 200 con probabilidad y alerta, 422 con entrada inválida que necesita corrección, y 500 con un fallo interno que requiere revisar el servicio.

La misma entrada al servicio puede llevar a tres resultados distinguibles.

3) Preparar la versión que va a responder

El capítulo 8 eligió la referencia corrida_02. Para mantener este notebook independiente reconstruirás su pipeline con los mismos datos, separación y ajustes. El relleno utiliza mediana; la regresión tiene C igual a 10 y pesos equilibrados. El registro guarda el umbral 0.45 junto con el identificador de versión y la huella del archivo.

Esta reconstrucción forma parte de preparar la demostración. Una aplicación en operación recibiría el paquete de archivos ya seleccionado. Al atender un envío solamente tendría que cargarlo al arrancar y aplicarlo. Así mantienes separadas la producción del modelo y la atención de peticiones.

rng = np.random.default_rng(SEMILLA)
n = 600
distancia = rng.gamma(2.0, 150, n).round(1)
peso = rng.lognormal(1.2, 0.6, n).round(2)
servicio = rng.choice(["express", "estándar", "económico"], size=n, p=[0.25, 0.5, 0.25])
efecto_servicio = pd.Series(servicio).map({"express": -1.0, "estándar": 0.0, "económico": 0.9}).to_numpy()
logit = -1.2 + 0.0035 * (distancia - 300) + 0.12 * (peso - 4) + efecto_servicio
llega_tarde = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
peso_registrado = peso.copy()
peso_registrado[rng.random(n) < 0.10] = np.nan
datos = pd.DataFrame({"distancia_km": distancia, "peso_kg": peso_registrado,
                      "servicio": servicio, "llega_tarde": llega_tarde})

X = datos[["distancia_km", "peso_kg", "servicio"]]
y = datos.llega_tarde
X_ent, X_pru, y_ent, y_pru = train_test_split(
    X, y, test_size=0.25, random_state=SEMILLA, stratify=y
)
preparar = ColumnTransformer([
    ("numericas", Pipeline([("rellenar", SimpleImputer(strategy="median")),
                            ("escalar", StandardScaler())]), ["distancia_km", "peso_kg"]),
    ("categoricas", OneHotEncoder(handle_unknown="ignore"), ["servicio"]),
])
pipeline = Pipeline([("preparar", preparar),
    ("modelo", LogisticRegression(C=10, class_weight="balanced", max_iter=1000))])
_ = pipeline.fit(X_ent, y_ent)
ruta_pipeline = CARPETA / "pipeline_envios.joblib"
_ = joblib.dump(pipeline, ruta_pipeline)
registro = {"version_modelo": "corrida_02", "umbral": 0.45,
            "archivo_pipeline": ruta_pipeline.name,
            "huella_pipeline": hashlib.sha256(ruta_pipeline.read_bytes()).hexdigest(),
            "versiones": versiones, "semilla": SEMILLA}
ruta_registro = CARPETA / "registro_modelo.json"
_ = ruta_registro.write_text(json.dumps(registro, indent=2, ensure_ascii=False), encoding="utf-8")
print("Envíos:", len(datos), "Pesos faltantes:", int(datos.peso_kg.isna().sum()))
print("Porcentaje tardío:", round(100 * y.mean(), 1))
print("Ajuste:", len(X_ent), "Prueba reservada sin consultar:", len(X_pru))
print("Versión:", registro["version_modelo"], "Umbral:", registro["umbral"])
Envíos: 600 Pesos faltantes: 67
Porcentaje tardío: 24.3
Ajuste: 450 Prueba reservada sin consultar: 150
Versión: corrida_02 Umbral: 0.45

La fuente contiene 600 envíos, 67 pesos faltantes y 24.3 % de tardíos. El pipeline se ajustó con 450 y los otros 150 permanecieron sin consultar. El registro identifica corrida_02 y conserva el corte 0.45, que utilizará la aplicación al responder.

La prueba queda reservada sin calcular nuevas métricas. Aquí quieres comprobar que sirves la versión y la política conocidas, no volver a decidir qué configuración gana. La evaluación que justifica esa elección se encuentra en los capítulos 6 y 8.

Cargar una vez al iniciar

NotaArranque y carga única

El arranque prepara lo que el proceso necesita antes de atender peticiones. La carga única recupera el pipeline en ese momento y conserva el objeto en memoria. Cada petición reutiliza ese objeto; no vuelve a entrenar ni abre el archivo del modelo por cada paquete.

El registro identifica corrida_02 y umbral 0.45. El arranque verifica la huella y carga el pipeline una vez. Las peticiones posteriores reutilizan el objeto en memoria y predicen sin entrenar.

El registro y el archivo se leen al iniciar y después se reutilizan en memoria.

La carga única es por proceso. Si una empresa ejecuta varias copias del servidor, cada copia necesita su objeto en memoria. Lo que evita el ejemplo es una lectura y una carga nueva por petición. El dato que viaja en cada mensaje sigue siendo el paquete de Laura.

El arranque también verifica la huella. Si el archivo no corresponde al registro, la aplicación falla antes de atender casos. Estar encendido no basta: el servicio solo está listo si cargó el modelo correcto.

4) Revisar la entrada con un esquema visible

NotaPydantic y validación de entrada

Pydantic comprueba datos frente a una descripción de campos y restricciones. La validación de entrada decide si una petición cumple esa descripción antes de llamar al modelo. Aquí exigirá tipos numéricos, unidades y rangos declarados, un servicio admitido y ausencia de campos adicionales.

El peso será requerido, pero puede valer null. Así distingues «el sistema informó que no hay peso» de «el sistema dejó de enviar ese campo». La distancia debe estar entre 0 y 3000 km; el peso observado, entre 0 y 70 kg. Son rangos operativos ilustrativos que retoman el capítulo 5.

El esquema admite express, estándar, económico y mismo día. Los tres primeros aparecieron en entrenamiento; mismo día es el caso nuevo que ya conoces. Lo admitimos de forma explícita y el codificador deja sus indicadores conocidos en cero. Otros nombres se rechazan hasta que se acuerde cómo recibirlos.

Los números deben llegar como números. El texto "3,5" requiere una conversión explícita en la captura, donde sabes qué separador se está utilizando. Una distancia de 520000 bajo el nombre distancia_km supera el rango declarado y puede revelar una confusión entre metros y kilómetros.

validado = Envio.model_validate(paquete_laura)
print("Entrada válida:", validado.model_dump())
try:
    _ = Envio.model_validate({**paquete_laura, "distancia_km": -20.0})
except ValidationError as error:
    print("ValidationError:", str(error).splitlines()[0])
Entrada válida: {'distancia_km': 520.0, 'peso_kg': None, 'servicio': 'económico'}
ValidationError: 1 validation error for Envio

La entrada válida conserva los 3 campos y el peso None. El caso con distancia de −20 devuelve ValidationError: 1 validation error for Envio. Esa comprobación sucede antes de construir la tabla que recibe el pipeline.

El tipo del campo y su rango expresan decisiones diferentes. Una distancia negativa puede tener un tipo numérico correcto y aun así incumplir la regla de la empresa. Los mensajes de Pydantic conservarán el campo y la clase de problema para que el cliente pueda explicar qué necesita corregirse.

5) Leer la aplicación antes de hacer peticiones

NotaFastAPI y TestClient

FastAPI construye la aplicación HTTP a partir de funciones y esquemas. TestClient permite hacer peticiones a esa aplicación dentro del proceso de prueba: ejecuta sus rutas, validaciones y respuestas sin abrir un puerto de red. httpx es parte de las herramientas que utiliza este cliente de pruebas.

El archivo servicio_envios.py, situado junto al notebook, reúne el código que vas a ejecutar. Envio declara el paquete; Lote limita la cantidad de paquetes; Respuesta describe lo que recibirá Laura. Las tres rutas utilizan la misma función de predicción y el mismo estado cargado al iniciar.

Lee primero predecir_envios. Recibe entradas ya validadas, construye una tabla con las columnas originales y llama a predict_proba. Después compara con el umbral del registro. Cada respuesta conserva tanto la probabilidad como la decisión, para que la política sea visible.

En crear_app, el bloque de arranque ocurre antes de atender casos y libera el modelo al terminar. La función salud consulta si hay un objeto cargado. Las funciones asociadas a rutas atienden los paquetes. El manejador final convierte un error inesperado en una respuesta pública breve, sin enviar detalles internos al cliente.

"""Servicio local de envíos: esquema, carga única y rutas de predicción.

crear_app recibe la ruta relativa del registro JSON y devuelve una aplicación.
El arranque verifica la huella del pipeline y lo carga una vez por proceso.
"""

from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal
import hashlib
import json
import joblib
import pandas as pd
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field


class Envio(BaseModel):
    """Campos de un paquete: unidades explícitas y peso requerido pero anulable."""
    model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
    distancia_km: float = Field(ge=0, le=3000, strict=True)
    peso_kg: float | None = Field(ge=0, le=70, strict=True)
    servicio: Literal["express", "estándar", "económico", "mismo día"]


class Lote(BaseModel):
    """Recibe de uno a cien paquetes que se validan antes de predecir."""
    model_config = ConfigDict(extra="forbid")
    envios: list[Envio] = Field(min_length=1, max_length=100)


class Respuesta(BaseModel):
    """Respuesta pública con probabilidad, política y versión utilizada."""
    probabilidad_tarde: float = Field(ge=0, le=1)
    alerta: bool
    umbral: float = Field(ge=0, le=1)
    version_modelo: str
    version_api: str


def predecir_envios(envios, estado):
    """Recibe casos ya validados y estado; devuelve una respuesta por paquete."""
    tabla = pd.DataFrame([envio.model_dump() for envio in envios])
    tabla["peso_kg"] = pd.to_numeric(tabla["peso_kg"])
    probabilidades = estado.modelo.predict_proba(tabla)[:, 1]
    respuestas = []
    for probabilidad in probabilidades:
        respuestas.append({"probabilidad_tarde": float(probabilidad),
            "alerta": bool(probabilidad >= estado.registro["umbral"]),
            "umbral": estado.registro["umbral"],
            "version_modelo": estado.registro["version_modelo"], "version_api": "v1"})
    return respuestas


def crear_app(ruta_registro):
    """Recibe la ruta del registro; devuelve FastAPI sin abrir un puerto."""
    ruta_registro = Path(ruta_registro)

    @asynccontextmanager
    async def arranque(aplicacion):
        """Carga y verifica al iniciar; libera el modelo al terminar."""
        registro = json.loads(ruta_registro.read_text(encoding="utf-8"))
        ruta_modelo = ruta_registro.parent / registro["archivo_pipeline"]
        if hashlib.sha256(ruta_modelo.read_bytes()).hexdigest() != registro["huella_pipeline"]:
            raise ValueError("La huella del pipeline no coincide con el registro")
        aplicacion.state.modelo = joblib.load(ruta_modelo)
        aplicacion.state.registro = registro
        aplicacion.state.cargas = 1
        yield
        aplicacion.state.modelo = None

    app = FastAPI(title="Alertas de envíos", version="1.0.0", lifespan=arranque)

    @app.get("/salud")
    def salud():
        """Devuelve disponibilidad y versión de la instancia cargada."""
        return {"listo": app.state.modelo is not None,
                "version_modelo": app.state.registro["version_modelo"]}

    @app.post("/v1/predecir", response_model=Respuesta)
    def predecir(envio: Envio):
        """Recibe un paquete validado y devuelve su respuesta identificada."""
        return predecir_envios([envio], app.state)[0]

    @app.post("/v1/predecir-lote", response_model=list[Respuesta])
    def predecir_lote(lote: Lote):
        """Recibe un lote validado y devuelve respuestas en el mismo orden."""
        return predecir_envios(lote.envios, app.state)

    @app.exception_handler(Exception)
    async def fallo_interno(peticion, error):
        """Recibe un error inesperado y devuelve un mensaje público breve."""
        return JSONResponse(status_code=500, content={"detail": "Fallo interno de predicción"})

    return app

Mantendrás abierto un único cliente de pruebas durante las comprobaciones. ExitStack permite conservar su contexto entre las celdas y cerrarlo al finalizar el recorrido. Esa apertura ejecuta el arranque; las llamadas posteriores encuentran el pipeline en memoria.

app = crear_app(ruta_registro)
vida = ExitStack()
cliente = vida.enter_context(TestClient(app, raise_server_exceptions=False))
salud = cliente.get("/salud")
respuesta = cliente.post("/v1/predecir", json=paquete_laura)
print("Salud:", salud.status_code, salud.json())
print("Predicción:", respuesta.status_code, respuesta.json())
print("Cargas del modelo:", app.state.cargas)
Salud: 200 {'listo': True, 'version_modelo': 'corrida_02'}
Predicción: 200 {'probabilidad_tarde': 0.7626165643744094, 'alerta': True, 'umbral': 0.45, 'version_modelo': 'corrida_02', 'version_api': 'v1'}
Cargas del modelo: 1

La consulta de salud devuelve 200 y listo igual a True para corrida_02. La predicción devuelve 200, probabilidad 0.7626165643744094, alerta True y umbral 0.45. El contador muestra 1 carga del modelo. Al redondear a 6 decimales recuperas el 0.762617 de referencia del libro.

La respuesta conserva dos identidades: v1 para esta interfaz y corrida_02 para el archivo del modelo reconstruido. Si alguna vez cambia la probabilidad para el mismo paquete, esas identidades ayudarán a localizar qué se estaba usando. El corte 0.45 también queda a la vista.

6) Provocar entradas incorrectas y un fallo interno

Ahora envía cinco casos con problemas concretos. Falta el peso, la distancia llegó como metros, el peso es texto, aparece un servicio fuera del catálogo o sobra una columna. Cada caso debería terminar en 422 antes de llamar al pipeline. La tabla mostrará qué campo identifica la validación.

Después enviarás mismo día para comprobar la política que declaraste. Finalmente quitarás temporalmente el objeto del modelo de la memoria de prueba. Primero observarás la excepción de Python y después la respuesta HTTP correspondiente. La restauración ocurre en un bloque finally, para conservar el modelo sano incluso si una comprobación falla.

casos_invalidos = {
    "falta peso": {"distancia_km": 520.0, "servicio": "económico"},
    "distancia en metros": {**paquete_laura, "distancia_km": 520000.0},
    "peso como texto": {**paquete_laura, "peso_kg": "3,5"},
    "servicio desconocido": {**paquete_laura, "servicio": "urgente especial"},
    "campo adicional": {**paquete_laura, "peso": 4.0},
}
filas = []
for nombre, caso in casos_invalidos.items():
    r = cliente.post("/v1/predecir", json=caso)
    detalle = r.json()["detail"][0]
    filas.append({"caso": nombre, "estado": r.status_code,
                  "campo": detalle["loc"][-1], "tipo": detalle["type"]})
display(pd.DataFrame(filas))
nuevo_servicio = cliente.post("/v1/predecir", json={**paquete_laura, "servicio": "mismo día"})
print("Mismo día:", nuevo_servicio.status_code, nuevo_servicio.json())

modelo_sano = app.state.modelo
try:
    app.state.modelo = None
    try:
        _ = predecir_envios([validado], app.state)
    except AttributeError as error:
        print("AttributeError:", str(error).splitlines()[0])
    fallo = cliente.post("/v1/predecir", json=paquete_laura)
    print("Fallo interno:", fallo.status_code, fallo.json())
finally:
    app.state.modelo = modelo_sano
restaurada = cliente.post("/v1/predecir", json=paquete_laura)
print("Restaurada:", restaurada.status_code)
caso estado campo tipo
0 falta peso 422 peso_kg missing
1 distancia en metros 422 distancia_km less_than_equal
2 peso como texto 422 peso_kg float_type
3 servicio desconocido 422 servicio literal_error
4 campo adicional 422 peso extra_forbidden
Mismo día: 200 {'probabilidad_tarde': 0.615655731347003, 'alerta': True, 'umbral': 0.45, 'version_modelo': 'corrida_02', 'version_api': 'v1'}
AttributeError: 'NoneType' object has no attribute 'predict_proba'
Fallo interno: 500 {'detail': 'Fallo interno de predicción'}
Restaurada: 200

Los 5 casos inválidos devolvieron 422. La tabla identifica peso_kg ausente, distancia_km fuera del límite, peso_kg con tipo incorrecto, servicio fuera del catálogo y peso como campo adicional. Mismo día sí devolvió 200, con probabilidad 0.615655731347003 y alerta True. Al retirar el modelo apareció AttributeError: 'NoneType' object has no attribute 'predict_proba'; la petición correspondiente recibió 500 y el mensaje público Fallo interno de predicción. Después de restaurarlo, la respuesta volvió a 200.

La entrada incorrecta necesita una corrección del caso recibido. El fallo interno necesita revisar el componente que predice. El cliente de pruebas se configuró para recibir la respuesta 500 en lugar de volver a lanzar la excepción dentro del notebook; por eso puedes examinar su código y cuerpo como lo haría otro programa.

La respuesta pública de 500 es breve. En una aplicación operativa registrarías por separado el detalle técnico y un identificador de petición, de modo que el equipo pudiera investigar sin mostrar rutas o información interna a Laura. La tabla final resume esa ampliación de operación.

7) Un paquete o varios conservan la misma preparación

NotaPredicción individual y por lote

Una predicción individual atiende un paquete. Una predicción por lote recibe varios y devuelve una respuesta por fila, en el mismo orden. Ambas utilizan el mismo pipeline y el mismo umbral; cambia cómo agrupas el trabajo, no lo que aprende la preparación.

Una recepción puede pedir la alerta al registrar cada paquete. Otra operación puede reunir los paquetes de un vehículo y pedir todas las alertas juntas. En la ruta de lote admitirás entre 1 y 100 envíos. Todos se validan antes de predecir: si una fila incumple el esquema, el lote se rechaza completo.

La comparación utiliza el paquete de Laura, un express de 100 km y 2 kg y un estándar de 300 km y 4 kg. Primero pide cada uno por separado y después los tres juntos. La igualdad de respuestas comprobará la misma preparación y política para las dos formas de solicitar el resultado.

paquetes = [paquete_laura,
    {"distancia_km": 100.0, "peso_kg": 2.0, "servicio": "express"},
    {"distancia_km": 300.0, "peso_kg": 4.0, "servicio": "estándar"}]
individuales = [cliente.post("/v1/predecir", json=p).json() for p in paquetes]
respuesta_lote = cliente.post("/v1/predecir-lote", json={"envios": paquetes})
print("Lote:", respuesta_lote.status_code, "Filas:", len(respuesta_lote.json()))
display(pd.DataFrame(respuesta_lote.json()))
print("Coinciden con peticiones individuales:", individuales == respuesta_lote.json())
print("Lote vacío:", cliente.post("/v1/predecir-lote", json={"envios": []}).status_code)
Lote: 200 Filas: 3
probabilidad_tarde alerta umbral version_modelo version_api
0 0.762617 True 0.45 corrida_02 v1
1 0.109867 False 0.45 corrida_02 v1
2 0.455890 True 0.45 corrida_02 v1
Coinciden con peticiones individuales: True
Lote vacío: 422

El lote devolvió 200 y 3 respuestas, con probabilidades redondeadas de 0.762617, 0.109867 y 0.455890. Sus alertas son True, False y True con el mismo umbral 0.45. La igualdad con las peticiones individuales devuelve True. Un lote vacío devuelve 422 porque requiere al menos 1 envío.

La forma de agrupar puede reducir trabajo repetido de comunicación y construcción de tablas. El tamaño permitido limita cuánto puede contener una petición. El ejemplo conserva pocas filas para que puedas seguir la correspondencia entre cada envío y su respuesta.

8) Medir cuánto espera el cliente

NotaLatencia y percentil

La latencia es el tiempo transcurrido entre iniciar una petición y recibir su respuesta. Un percentil resume la distribución de esos tiempos: p50 es la mediana y p95 deja aproximadamente el 95 % de los tiempos por debajo de ese valor, según la convención de cálculo utilizada.

Medirás treinta peticiones después del arranque. El reloj rodea la llamada completa del cliente de pruebas, que incluye recorrido de la aplicación, validación y predicción. El tiempo de entrenamiento queda fuera de esta medida porque ocurrió al preparar el archivo, antes de atender casos.

El ejemplo utiliza TestClient dentro del computador. Una aplicación por red sumaría transporte y otros componentes de la infraestructura. Puedes usar esta medida local para comprobar el recorrido del programa y más adelante medir en el lugar donde realmente lo utiliza Laura.

latencias = []
for _ in range(30):
    inicio_peticion = time.perf_counter()
    r = cliente.post("/v1/predecir", json=paquete_laura)
    latencias.append((time.perf_counter() - inicio_peticion) * 1000)
latencia_resumen = {"peticiones": len(latencias), "p50_ms": float(np.median(latencias)),
                    "p95_ms": float(np.percentile(latencias, 95))}
display(pd.DataFrame([latencia_resumen]).round(3))
print("Cargas tras las peticiones:", app.state.cargas)
_ = (CARPETA / "latencias.json").write_text(
    json.dumps(latencia_resumen, indent=2), encoding="utf-8"
)
peticiones p50_ms p95_ms
0 30 5.156 6.324
Cargas tras las peticiones: 1

Las 30 peticiones tuvieron p50 de 5.156 ms y p95 de 6.324 ms en esta ejecución local. El contador de cargas continúa en 1. Estas cifras miden la llamada a la aplicación ya iniciada, con el mismo paquete y modelo en cada petición.

La gráfica conserva un punto por petición y la referencia p95. Los marcadores permiten localizar una petición más lenta sin depender del color. El valor medido puede cambiar al ejecutar en otro momento, aunque el modelo y las probabilidades se mantengan iguales.

figura, eje = plt.subplots(figsize=(7, 3.5))
eje.plot(range(1, 31), latencias, "-o", color="#1786B3", label="Petición individual")
eje.axhline(latencia_resumen["p95_ms"], color="#EE5400", linestyle="--", label="p95")
eje.set(xlabel="Petición medida", ylabel="Latencia local (ms)")
eje.legend()
figura.tight_layout()
plt.show()
vida.close()
print("Modelo liberado al cerrar:", app.state.modelo is None)
Treinta puntos con círculos muestran milisegundos por petición mediante TestClient; una línea discontinua representa el percentil 95. La medida no incluye una red externa.
Figura 9.1: Tiempo de respuesta de treinta peticiones locales
Modelo liberado al cerrar: True

Los 30 puntos conservan los tiempos individuales y la línea discontinua marca p95 de 6.324 ms. La salida final indica Modelo liberado al cerrar: True: el contexto terminó y el estado dejó de conservar el pipeline en memoria.

Cerrar el contexto libera el objeto cargado para esta aplicación de prueba. Los archivos de artefactos/servicio-envios permanecen para revisión. Si quieres retirar los resultados, puedes borrar únicamente esa carpeta cuando termines de inspeccionarla; el siguiente Restart + Run All la reconstruirá desde cero.

9) Qué necesita añadirse para una operación real

La aplicación que probaste ya tiene rutas ejecutables, esquemas y un modelo identificado. Una operación de empresa incorpora otras decisiones alrededor de ella. La tabla las presenta como preguntas concretas, para que puedas relacionarlas con las peticiones que acabas de observar.

Aspecto Qué significa aquí Pregunta para el sistema de Laura
Autenticación Comprobar quién llama al servicio. ¿Qué aplicaciones están autorizadas?
CORS Declarar qué orígenes de navegador pueden acceder. ¿Desde qué sitio web se hará la petición?
Tamaño máximo Limitar la entrada admitida. ¿Bastan los 100 envíos permitidos por lote?
Observabilidad Conservar señales para entender cómo funciona. ¿Guardas estado, versión y latencia sin datos innecesarios?
Prueba de humo Comprobación breve de que el servicio responde. ¿Salud y un paquete conocido funcionan antes de recibir trabajo?

Los nombres describen responsabilidades que puedes asignar al equipo. La autenticación distingue clientes autorizados; CORS corresponde a restricciones del navegador entre sitios. La prueba de humo utiliza una entrada conocida para descubrir problemas al iniciar una versión nueva. El capítulo 11 incorporará comprobaciones de este tipo antes de cambiar la referencia operativa.

El modelo y el programa también dependen de las bibliotecas instaladas. Guardaste las versiones en el registro, pero todavía tienes que preparar el entorno donde se ejecutará la aplicación. En el siguiente capítulo prepararás ese entorno para ejecutar el servicio en otra máquina.

NotaAlcance del experimento

Los datos son sintéticos y la reconstrucción usa una configuración ya elegida; no es una nueva selección de modelo. Los rangos y el catálogo de servicios son decisiones ilustrativas que deben acordarse con operaciones. TestClient ejecuta la aplicación sin un servidor de red, por lo que las latencias son locales. La carga única se refiere a cada proceso, y una operación con varias copias carga una por copia. El fallo del modelo se provocó únicamente en memoria y se restauró. La aplicación queda preparada y probada para revisión, sin publicación ni conexión con sistemas externos.

Errores frecuentes

Lo que ves Qué pasó Cómo se arregla
422 por peso_kg ausente El campo requerido desapareció del mensaje. Envía el peso o null cuando no se registró.
422 por rango de distancia Llegó una distancia fuera de las unidades o límites acordados. Comprueba la captura y los kilómetros.
422 por servicio La etiqueta está fuera del catálogo admitido. Revisa el nombre o acuerda la nueva categoría.
500 con entrada válida Falló un componente interno. Investiga el servidor y conserva el registro técnico del error.
Cada petición abre el joblib La carga está dentro de la función de predicción. Carga el objeto durante el arranque.
Las alertas difieren del registro Se aplicó otro corte, como 0.50. Lee el umbral que acompaña al modelo.
Individual y lote producen decisiones distintas Cambió el orden o la preparación entre rutas. Comparte el pipeline y conserva el orden de filas.

Lista de verificación

  • El cliente envía distancia y peso en las unidades declaradas.
  • El peso faltante viaja como null y sigue siendo un campo requerido.
  • El esquema define rangos, tipos y servicios admitidos.
  • La aplicación carga el pipeline y su registro al iniciar.
  • La respuesta incluye umbral y versión del modelo.
  • Las respuestas 200, 422 y 500 se comprobaron con peticiones reales a la aplicación.
  • La ruta individual y la de lote aplican la misma política.
  • El cliente de pruebas se cierra al terminar.

Ejercicios

  1. Escribe el JSON del paquete de Laura y explica la diferencia entre peso_kg ausente y peso_kg igual a null.
  2. Propón un nuevo rango de peso y decide qué entrada de prueba permitiría comprobar ambos límites sin modificar el modelo.
  3. Diseña un lote con una fila incorrecta. Predice su código de respuesta y explica qué ocurre con las otras filas bajo esta política.
  4. Explica qué cambiaría si la aplicación utilizase predict a 0.50 en lugar de comparar las probabilidades con el umbral del registro.
  5. Distingue cambiar corrida_02 por otro modelo compatible de renombrar distancia_km en la interfaz. Indica qué debe saber el cliente en cada caso.
  1. Usa distancia_km de 520.0, peso_kg igual a null y servicio económico. Un campo ausente incumple el esquema; null declara que el valor no está registrado.
  2. Declara primero el rango y prueba un valor dentro, los extremos admitidos y valores apenas fuera. La comprobación pertenece al esquema, antes de predecir.
  3. La respuesta es 422 para el lote completo. La aplicación valida todas las filas antes de llamar al modelo y evita una respuesta parcialmente aceptada.
  4. Probabilidades entre 0.45 y 0.50 dejarían de activar alertas. La política de Laura cambiaría aunque los coeficientes fuesen idénticos.
  5. Una nueva corrida puede conservar los campos y devolver otra versión de modelo. Renombrar un campo modifica lo que el cliente debe enviar y requiere planear compatibilidad o una nueva interfaz.

Glosario

Término Significado
Cliente Programa que solicita una operación.
Servidor Proceso que atiende peticiones.
Servicio de predicción Interfaz para obtener resultados de un modelo.
Petición y respuesta Mensajes de solicitud y de resultado.
JSON Formato de intercambio con nombres y valores.
HTTP Protocolo de comunicación de peticiones y respuestas.
API y ruta Interfaz del programa e identificación de una operación.
Código de estado Resumen del resultado de una petición.
Arranque Preparación previa a atender casos.
Carga única Recuperar el pipeline una vez por proceso al iniciar.
Pydantic Herramienta de validación de campos y restricciones.
FastAPI Herramienta para construir la aplicación HTTP.
TestClient Cliente de prueba que ejecuta la aplicación localmente.
Predicción por lote Respuestas para varios paquetes en una operación.
Latencia Tiempo de una petición hasta su respuesta.
Percentil Valor que resume una posición en la distribución de tiempos.
Autenticación Comprobación de quién llama al servicio.
CORS Reglas para acceso entre orígenes desde un navegador.
Observabilidad Señales que permiten entender el funcionamiento.
Prueba de humo Comprobación breve del recorrido básico de la aplicación.

Qué aprendiste

Convertiste la ventanilla de un diccionario en una aplicación que valida paquetes y devuelve resultados identificados. El modelo se cargó durante el arranque y su política viajó en el registro. Las peticiones mostraron por separado una predicción correcta, una entrada que necesita corregirse y un fallo interno.

Laura ya tiene una forma de pedir la alerta desde su sistema. En Contenerización y portabilidad prepararás el entorno que necesita esa aplicación para ejecutarse en otra máquina con las versiones adecuadas.

Referencias