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.loadestá 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
Ventaes 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ú. /saludno 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.