Capítulo 25 de 29 8 secciones 10 min

Compartir

Que alguien más le pueda preguntar

Guardar el pipeline, cargarlo en otro proceso, validar lo que llega antes de predecir y montar la API que lo sirve.

Servir un modelo es dejar que otro proceso le pida predicciones. Se guarda el pipeline entero con joblib, se carga una sola vez al arrancar y se expone con una API. Lo que de verdad hay que trabajar no es la API, son las tres cosas que entran mal sin dar error: una categoría nueva, un número que llega como texto y una columna que falta 🚀

Hasta acá el modelo vive en tu cuaderno. Lo entrenas, lo mides, lo explicas y ahí se queda 🐣

Este capítulo es el tramo que casi nadie enseña: entre "tengo un modelo que funciona" y "el sistema de la empresa le pide predicciones" hay un trabajo, y no es el que uno se imagina. La API son veinte líneas. Lo difícil es lo que llega por ella.

Se guarda el pipeline, no el modelo

Esto es lo primero y es donde se equivoca casi todo el mundo. Lo que hay que guardar no es el clasificador: es el pipeline entero del capítulo 11, con su imputación, su escalado y sus dummies dentro.

import os
import tempfile
import pandas as pd
import joblib
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression

NUM = ['unidades', 'monto', 'satisfaccion']
CAT = ['ciudad', 'segmento', 'canal']

df = pd.read_csv('ventas-miss-yera.csv')
for c in NUM:
    df[c] = pd.to_numeric(df[c], errors='coerce')
d = df[NUM + CAT + ['compro']].dropna(subset=['compro'])
X, y = d[NUM + CAT], d['compro'].astype(int)

pre = ColumnTransformer([
    ('num', Pipeline([('imp', SimpleImputer(strategy='median')),
                      ('esc', StandardScaler())]), NUM),
    ('cat', OneHotEncoder(handle_unknown='ignore'), CAT)])
pipe = Pipeline([('pre', pre), ('modelo', LogisticRegression(max_iter=1000))])
pipe.fit(X, y)

RUTA = os.path.join(tempfile.mkdtemp(), 'modelo.joblib')
joblib.dump(pipe, RUTA)
print('entrenado con', len(X), 'filas')
entrenado con 3037 filas

Si guardaras solo la regresión, quien la cargue del otro lado tendría que reproducir a mano el escalado y las dummies, exactamente iguales. Y no van a ser exactamente iguales. Ese es el bug clásico de este tema y tiene nombre: training-serving skew, o sea que entrenas con una preparación y predices con otra.

Cargarlo y pedirle algo

modelo = joblib.load(RUTA)

venta = {'unidades': 10, 'monto': 480.37, 'satisfaccion': 4.0,
         'ciudad': 'Lima', 'segmento': 'Minimarket', 'canal': 'Web'}

p = float(modelo.predict_proba(pd.DataFrame([venta]))[0, 1])
print('probabilidad de que compre:', round(p, 4))
print('decision con umbral 0.5   :', int(p >= 0.5))
probabilidad de que compre: 0.6779
decision con umbral 0.5   : 1

Ya está: eso es servir un modelo. Todo lo demás es envolverlo.

Fíjate en el pd.DataFrame([venta]): el pipeline espera una tabla con nombres de columna, no una lista de números. Y en el umbral 0.5, que no es sagrado: cuál te conviene lo decidiste en el capítulo 15.

Los tres fallos, y solo uno hace ruido

Acá está el capítulo de verdad. Le voy a mandar tres cosas mal y quiero que mires cuál protesta.

Uno: falta una columna.

sin_canal = {k: v for k, v in venta.items() if k != 'canal'}
modelo.predict_proba(pd.DataFrame([sin_canal]))
ValueError: columns are missing: {'canal'}

Perfecto. Revienta, dice qué falta, y quien mire los registros lo va a encontrar en dos minutos. Este es el fallo bueno.

Dos: una ciudad que el modelo nunca vio.

otra = dict(venta, ciudad='Tarapoto')
print('probabilidad:', round(float(modelo.predict_proba(pd.DataFrame([otra]))[0, 1]), 4))
probabilidad: 0.6369

Ni un aviso. Devolvió 0.6369, que es un número perfectamente creíble.

Lo que pasó por dentro: handle_unknown='ignore' dejó todas las columnas de ciudad en cero. O sea que el modelo predijo como si esa venta no tuviera ciudad. No es una respuesta mala del todo, y tampoco es la que tú creías estar dando.

Tres: el número llega como texto.

texto = dict(venta, monto='480.37')
print('probabilidad:', round(float(modelo.predict_proba(pd.DataFrame([texto]))[0, 1]), 4))
probabilidad: 0.6779

Salió bien. Y aquí es donde quiero que desconfíes: salió bien de casualidad, porque pandas convirtió esa cadena a número él solo. Si en vez de '480.37' llega '480,37' con coma, que es como lo manda medio Perú, la conversión falla en silencio y ese monto entra como nulo, y el imputador del pipeline lo rellena con la mediana.

Tu API contestaría una probabilidad calculada sobre un monto que nunca existió 🙃

Entonces se valida antes, y a mano

ESPERADO = {'unidades': 'numero', 'monto': 'numero', 'satisfaccion': 'numero',
            'ciudad': 'texto', 'segmento': 'texto', 'canal': 'texto'}

def revisa(venta, conocidas):
    faltan = [c for c in ESPERADO if c not in venta]
    if faltan:
        return 'faltan columnas: ' + str(faltan)
    for c, tipo in ESPERADO.items():
        if tipo == 'numero' and not isinstance(venta[c], (int, float)):
            return c + ' llego como ' + type(venta[c]).__name__ + ', se esperaba numero'
    if venta['ciudad'] not in conocidas:
        return 'ciudad desconocida: ' + venta['ciudad']
    return None

conocidas = set(df['ciudad'].dropna().unique())
casos = [('completa y sana', venta),
         ('sin canal', sin_canal),
         ('monto como texto', dict(venta, monto='480.37')),
         ('ciudad nueva', dict(venta, ciudad='Tarapoto'))]
for etiqueta, caso in casos:
    print(etiqueta.ljust(18), '->', revisa(caso, conocidas) or 'OK')
completa y sana    -> OK
sin canal          -> faltan columnas: ['canal']
monto como texto   -> monto llego como str, se esperaba numero
ciudad nueva       -> ciudad desconocida: Tarapoto

Los tres se cazan antes de llegar al modelo. Eso es un contrato de entrada, y es el hermano del contrato de datos del capítulo 2: aquel era con quien te da los datos para entrenar, este es con quien te los manda para predecir.

Y ahora mira lo que pasa con las ciudades conocidas

print(sorted(conocidas))
['Arequipa', 'Chiclayo', 'Cusco', 'LIMA ', 'Lima', 'Líma', 'Piura', 'Trujillo', 'lima']

Cuatro maneras de escribir Lima 😅

Así que mi validación, tal como está, deja pasar 'LIMA ' con espacio y rechaza 'LIMA' sin espacio, sin ningún motivo. La lista de categorías válidas hereda la suciedad del entrenamiento, que es justo lo que trabajamos en el capítulo 5.

La forma correcta es normalizar en los dos sitios con la misma función: minúsculas, sin espacios y sin tildes, antes de entrenar y antes de predecir. Y que sea la misma función, importada del mismo archivo, no dos copias que se parecen.

La API, que es la parte fácil

Con eso resuelto, exponerlo es un archivo. Esto va en api.py:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import pandas as pd, joblib

app = FastAPI()
modelo = joblib.load('modelo.joblib')      # una vez, al arrancar

class Venta(BaseModel):
    unidades: int
    monto: float
    satisfaccion: float
    ciudad: str
    segmento: str
    canal: str

@app.get('/salud')
def salud():
    return {'estado': 'vivo'}

@app.post('/predecir')
def predecir(venta: Venta):
    fila = pd.DataFrame([venta.model_dump()])
    p = float(modelo.predict_proba(fila)[0, 1])
    return {'probabilidad': round(p, 4), 'compra': int(p >= 0.5)}

Y se levanta así:

uvicorn api:app --reload

Cuatro cosas que quiero señalarte de ese archivo, porque son las que importan:

  • El joblib.load está fuera de la función. Se carga una vez al arrancar, no en cada petición. Cargarlo dentro es el error de rendimiento número uno de este tema.
  • La clase Venta es media validación gratis. Pydantic ya rechaza lo que no sea del tipo que dice. Lo que no sabe es qué ciudades conoce tu modelo: eso lo pones tú.
  • /salud no sirve para nada y hace falta siempre: es lo que consulta quien vigila para saber si el servicio está vivo.
  • Devuelve la probabilidad, no solo el sí o el no. Quien consuma la API puede querer otro umbral, y con el número crudo puede; con el cero o uno, ya no.

Esa API está lista para recibir peticiones, y sigue estando en tu máquina. Que corra en la de otra persona es el capítulo 26.

Lo que te llevas

  • Se guarda el pipeline entero, no el modelo suelto.
  • Entrenar con una preparación y predecir con otra tiene nombre y te va a pasar.
  • Falta una columna: revienta, y eso es bueno.
  • Categoría nueva y número como texto: no revientan, y eso es lo peligroso.
  • Se valida a mano antes de predecir, con un contrato de entrada.
  • La lista de categorías válidas hereda la suciedad del entrenamiento.
  • Carga el modelo al arrancar, no en cada petición.
  • Devuelve la probabilidad, no la decisión.

Comprueba que lo tienes

Tu API recibe una venta con la ciudad "Tarapoto", que el modelo nunca vio porque no estaba en el entrenamiento. ¿Qué devuelve?

  • Una probabilidad normal, sin avisar de nada
  • Un error, porque la categoría no existe
  • La probabilidad media de todas las ciudades
  • Un nulo, que después revienta más adelante

Ejercicios

1. Guarda solo el modelo y mira el desastre

Para que veas por qué se guarda el pipeline entero.

solo_modelo = pipe.named_steps['modelo']
print('el modelo suelto espera', solo_modelo.n_features_in_, 'columnas')
print('tu venta tiene         ', len(venta), 'campos')
el modelo suelto espera 20 columnas
tu venta tiene          6 campos

20 contra 6. Esas 20 son las 3 numéricas escaladas más las 17 dummies que sacó el OneHotEncoder: 9 ciudades, 4 segmentos y 4 canales. Para usar el modelo suelto tendrías que fabricar esas 20 columnas en el mismo orden, y el día que el entrenamiento vea una ciudad más pasan a ser 21 y tu código de servicio ni se entera.

2. La coma decimal, que es la de verdad

El caso peruano que rompe en silencio.

for valor in ('480.37', '480,37'):
    fila = pd.DataFrame([dict(venta, monto=valor)])
    fila['monto'] = pd.to_numeric(fila['monto'], errors='coerce')
    p = float(modelo.predict_proba(fila)[0, 1])
    print(repr(valor), '-> monto queda', fila['monto'].iloc[0],
          '| probabilidad', round(p, 4))
'480.37' -> monto queda 480.37 | probabilidad 0.6779
'480,37' -> monto queda nan | probabilidad 0.6792

Mira la diferencia: 0.6779 contra 0.6792. Trece diezmilésimas.

La segunda está calculada sobre un monto que se convirtió en nulo y que el imputador rellenó con la mediana. Ese cliente recibió una predicción que no tiene nada que ver con lo que facturó, y la respuesta se parece tanto a la correcta que no hay forma de verlo mirando la salida.

Si el fallo hubiera devuelto 0.02 lo cazabas el primer día. Cambiando la tercera cifra, puede estar meses corriendo. Por eso la validación va antes y no después 🧾

3. Normaliza en los dos lados con la misma función

Arregla las cuatro Limas.

import unicodedata

def normaliza(texto):
    t = unicodedata.normalize('NFD', str(texto).strip().lower())
    return ''.join(c for c in t if unicodedata.category(c) != 'Mn')

crudas = sorted(df['ciudad'].dropna().unique())
limpias = sorted(set(normaliza(c) for c in crudas))
print('antes :', len(crudas), crudas)
print('despues:', len(limpias), limpias)
antes : 9 ['Arequipa', 'Chiclayo', 'Cusco', 'LIMA ', 'Lima', 'Líma', 'Piura', 'Trujillo', 'lima']
despues: 6 ['arequipa', 'chiclayo', 'cusco', 'lima', 'piura', 'trujillo']

De 9 a 6. Y lo importante no es el número: es que esa función tiene que correr antes de entrenar y antes de predecir, importada del mismo archivo. Dos copias parecidas en dos repositorios distintos es exactamente cómo aparece el training-serving skew del que hablábamos arriba.

4. El umbral se decide fuera del modelo

Por eso la API devuelve la probabilidad y no el sí o el no.

p = float(modelo.predict_proba(pd.DataFrame([venta]))[0, 1])
for u in (0.3, 0.5, 0.7):
    print('umbral', u, '-> decision', int(p >= u))
print('la probabilidad es la misma:', round(p, 4))
umbral 0.3 -> decision 1
umbral 0.5 -> decision 1
umbral 0.7 -> decision 0
la probabilidad es la misma: 0.6779

La misma venta es un sí o un no según quién la mire, y el modelo no cambió nada. Si tu API devolviera solo el cero o el uno, cambiar de umbral obligaría a redesplegar; devolviendo el número, lo cambia quien consume con una línea suya. Cuál conviene es el capítulo 15.

5. Un lote en vez de una fila

La misma llamada sirve para muchas ventas.

import numpy as np

lote = pd.DataFrame([venta,
                     dict(venta, unidades=1, monto=40.0, satisfaccion=1.0),
                     dict(venta, unidades=50, monto=3000.0, satisfaccion=5.0)])
pr = modelo.predict_proba(lote)[:, 1]
print('probabilidades:', np.round(pr, 4))
print('una llamada para', len(lote), 'ventas')
probabilidades: [0.6779 0.4615 0.7957]
una llamada para 3 ventas

Si tu sistema va a puntuar diez mil ventas de madrugada, hacerlo en un lote y no en diez mil peticiones cambia el tiempo por completo. Conviene que la API acepte las dos formas: una venta y una lista.

6. Manda las columnas al revés

Comprueba si el pipeline se guía por el nombre o por la posición.

al_reves = {k: venta[k] for k in reversed(list(venta))}
print('orden que mando:', list(al_reves))
p2 = float(modelo.predict_proba(pd.DataFrame([al_reves]))[0, 1])
print('misma probabilidad:', round(p2, 4))
orden que mando: ['canal', 'segmento', 'ciudad', 'satisfaccion', 'monto', 'unidades']
misma probabilidad: 0.6779

Idéntica. El ColumnTransformer busca por nombre, así que el orden no importa. Y esto vale justo porque guardaste el pipeline entero: si hubieras guardado el modelo suelto y armado las columnas a mano, mandarlas al revés te daría un número distinto sin avisar.

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?