Tienes la API del capítulo 25 corriendo en tu computadora. Ahora hay que llevarla a un servidor, y ahí empieza la frase más cara del oficio: "en mi máquina funciona" 🙃
Primero, por qué el archivo no viaja solo
Casi todo el mundo cree que un .joblib se basta a sí mismo. No.
Vamos a verlo, y de paso hago las cosas como hay que hacerlas: la función de
limpieza en su propio archivo, no suelta en el cuaderno.
import os import sys import tempfile import joblib from sklearn.preprocessing import FunctionTransformer from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression carpeta = tempfile.mkdtemp() with open(os.path.join(carpeta, 'limpieza.py'), 'w') as f: f.write('def limpia_ciudad(X):\n return X\n') sys.path.insert(0, carpeta) import limpieza pipe = Pipeline([('limpia', FunctionTransformer(limpieza.limpia_ciudad)), ('modelo', LogisticRegression())]).fit([[0.0], [1.0]], [0, 1]) ruta = os.path.join(carpeta, 'modelo.joblib') joblib.dump(pipe, ruta) crudo = open(ruta, 'rb').read() print('el archivo guarda el NOMBRE de tu funcion:', b'limpia_ciudad' in crudo) print('y el modulo donde vivia :', b'limpieza' in crudo) print('pero NO guarda su codigo :', b'return X' not in crudo)
el archivo guarda el NOMBRE de tu funcion: True y el modulo donde vivia : True pero NO guarda su codigo : True
Lee las tres líneas juntas, porque ahí está todo el capítulo. El archivo sabe cómo se llama tu función y de dónde sacarla, y no lleva dentro ni una línea de lo que hace.
Es una nota que dice "acá va la función limpia_ciudad, que está en el módulo limpieza". Y una nota solo sirve si lo que apunta existe.
Ahora hago desaparecer ese módulo, que es exactamente lo que pasa cuando el archivo llega a un servidor donde tu proyecto no está:
del sys.modules['limpieza'] sys.path.remove(carpeta) joblib.load(ruta)
ModuleNotFoundError: No module named 'limpieza'
El modelo está intacto dentro del archivo. Lo que falta es el código para rearmarlo.
Y esto no es un caso raro de laboratorio: en cuanto metes un
FunctionTransformer con una limpieza tuya, que es lo que recomendé
en el capítulo 10, ya estás aquí. Y si esa
función la tenías suelta en el cuaderno en vez de en un archivo, estás peor,
porque entonces ni siquiera hay un módulo que copiar.
Y hay una segunda cosa que tampoco viaja
Las versiones. Un pipeline guardado con una versión de scikit-learn y cargado con otra puede fallar al cargar, o peor, cargar y predecir distinto, porque el valor por defecto de algún parámetro cambió entre medio.
Así que lo que hace falta llevar junto son tres cosas:
- El archivo del modelo.
- Tu código, el de las funciones de limpieza y el de la API.
- Las versiones exactas de todo lo que importas.
Un contenedor es una caja que lleva las tres, y encima el Python con el que las corres 📦
Fija las versiones, y fíjalas de verdad
Esto es lo mínimo, y se hace antes de pensar en Docker. En
requirements.txt:
scikit-learn==1.7.2 pandas==2.3.1 numpy==2.1.3 joblib==1.4.2 fastapi==0.115.6 uvicorn==0.34.0
Con el ==, no con >= ni sin nada. Los números de
arriba son un ejemplo: los tuyos salen de correr pip freeze en el
entorno donde el modelo funcionó, y son los que tienes que apuntar el día que
entrenaste, no el día que despliegas.
Esta es la parte que la gente se salta porque parece burocracia, y es la que te salva. Sin versiones fijas, tu servicio funciona hoy y deja de funcionar dentro de cuatro meses sin que nadie haya tocado una línea, porque una librería sacó versión nueva.
El Dockerfile
Un archivo llamado Dockerfile, sin extensión, al lado de tu
código:
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY modelo.joblib . COPY limpieza.py api.py . EXPOSE 8000 CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]
Léelo de arriba abajo, que es como se ejecuta:
- FROM: de qué partes.
slimes la versión sin cosas de más, y pesa como una quinta parte de la normal. - WORKDIR: la carpeta donde va a vivir todo dentro de la caja.
- COPY requirements y RUN pip antes de copiar tu código. Esto parece un detalle de orden y no lo es: Docker guarda cada paso, y si tu código cambia pero las dependencias no, se salta la instalación entera. Es la diferencia entre esperar dos minutos o dos segundos cada vez que cambias una línea.
- COPY del modelo y del código. Los tres que faltaban.
- CMD: qué se ejecuta al arrancar. El
--host 0.0.0.0no es opcional: sin eso, la API solo escucha dentro de la caja y desde fuera parece muerta. Es el error número uno de la primera vez que alguien mete una API en un contenedor 🧷
Y se usa así:
docker build -t modelo-ventas . docker run -p 8000:8000 modelo-ventas
El -p 8000:8000 conecta el puerto de tu máquina con el de dentro.
Si te lo saltas, el contenedor corre y no lo alcanza nadie.
El archivo que evita que subas medio disco
Un .dockerignore, hermano del .gitignore del
capítulo de ignorar archivos de mi libro de Git:
.git __pycache__ *.ipynb datos/ .venv
Sin esto, el COPY se lleva tus cuadernos, tus datos crudos y la
carpeta .git entera. He visto imágenes de tres gigas que en realidad
eran cuarenta megas y el resto era historia de Git.
Dónde ponerlo a correr
Con la imagen construida, ya puedes usarla en cualquier sitio que acepte contenedores. Sin recomendarte ninguno en particular, porque los precios cambian cada semana, las tres formas que hay son estas:
- Un servicio que corre contenedores por ti. Le das el repositorio, él construye y lo levanta. Es lo más rápido para empezar y lo más caro por unidad si crece.
- Una máquina virtual tuya. Instalas Docker y corres el contenedor. Más barato, y el mantenimiento es tuyo.
- El servicio de contenedores de una nube grande. Escala solo y la factura es difícil de predecir hasta que aprendes a leerla.
Mi consejo para un primer modelo: el más simple de los tres. Si el proyecto crece hasta que el precio duela, moverte va a ser fácil justamente porque está en un contenedor 🌸
Lo que un contenedor NO arregla
Te lo digo porque se vende como si arreglara todo:
- No arregla la deriva. Tu modelo empaquetado se puede quedar obsoleto igual, y eso es el capítulo 27.
- No arregla los datos sucios. Si entra una ciudad que el modelo no vio, dentro de la caja pasa exactamente lo mismo que fuera.
- No hace tu modelo más rápido. Es la misma cuenta con un sombrero.
- No es una máquina virtual. Comparte el núcleo del sistema, así que arranca en segundos y pesa mucho menos, y tampoco te aísla igual de bien.
Lo que te llevas
- Un joblib guarda los nombres de tus funciones, no su código.
- Si el código no está del otro lado, revienta al cargar.
- Hay que llevar tres cosas juntas: modelo, código y versiones.
- Fija versiones con
==, salidas depip freezeel día que entrenaste. - Copia las dependencias antes que el código, para aprovechar la caché.
--host 0.0.0.0y-p, o nadie lo alcanza.- Un
.dockerignoreo subes tu historia de Git entera. - El contenedor no arregla la deriva ni los datos sucios.
Comprueba que lo tienes
Guardas el pipeline con joblib y lo mandas a un servidor. Allá revienta con "Can't get attribute". ¿Qué falta?
- El código de tus funciones: el archivo guarda su nombre, no su cuerpo
- El dataset con el que se entrenó
- Volver a entrenarlo en el servidor
- Una versión más nueva de scikit-learn
Ejercicios
1. Devuelve el módulo a su sitio y míralo revivir
El arreglo es literalmente lo que hace un COPY del Dockerfile.
sys.path.insert(0, carpeta) recuperado = joblib.load(ruta) print('ahora si carga y predice:', recuperado.predict([[1.0]])) print('el modelo nunca estuvo roto:', recuperado.named_steps['modelo'].n_features_in_, 'columna')
ahora si carga y predice: [1] el modelo nunca estuvo roto: 1 columna
No hubo que reentrenar nada ni tocar el archivo: bastó con que el módulo
estuviera donde el proceso pudiera encontrarlo. Esa línea de
sys.path.insert es, en pequeño, lo mismo que hace
COPY limpieza.py dentro del contenedor.
2. Un modelo sin funciones tuyas sí viaja
Comprueba que el problema es tu código, no joblib.
from sklearn.preprocessing import StandardScaler limpio = Pipeline([('esc', StandardScaler()), ('modelo', LogisticRegression())]).fit([[0.0], [1.0]], [0, 1]) ruta2 = os.path.join(carpeta, 'sin_funcion.joblib') joblib.dump(limpio, ruta2) otro = joblib.load(ruta2) print('cargo sin problemas y predice:', otro.predict([[1.0]])) print('menciona limpieza :', b'limpieza' in open(ruta2, 'rb').read())
cargo sin problemas y predice: [1] menciona limpieza : False
Sin código tuyo dentro, el archivo solo pide clases de scikit-learn, que van a existir en cualquier máquina que tenga scikit-learn instalado. Por eso un pipeline hecho solo con piezas de la librería es mucho más fácil de mover, y por eso conviene usar las piezas de fábrica siempre que se pueda.
3. Escribe tu requirements con las versiones de verdad
Las tuyas, no las que copies de un tutorial.
import sklearn import pandas import numpy paquetes = {'scikit-learn': sklearn, 'pandas': pandas, 'numpy': numpy, 'joblib': joblib} lineas = [n + '==' + m.__version__ for n, m in paquetes.items()] print('\n'.join('requirements.txt -> ' + l.split('==')[0] for l in lineas)) print('cuantas lineas:', len(lineas))
requirements.txt -> scikit-learn requirements.txt -> pandas requirements.txt -> numpy requirements.txt -> joblib cuantas lineas: 4
Imprimo solo los nombres a propósito, porque las versiones de tu máquina no
son las mías y este libro no te puede mentir con un número. Quita el
.split y vas a ver las tuyas con su == puesto: esas son
las que van al archivo. Y el día que actualices una, lo actualizas ahí y
reconstruyes la imagen, que es justamente la gracia de tener esto escrito.
4. El modelo no lleva los datos dentro
Una duda razonable cuando vas a mandarlo a un servidor ajeno.
print('el csv pesa ', round(os.path.getsize('ventas-miss-yera.csv') / 1024, 1), 'KB') print('el modelo pesa ', round(os.path.getsize(ruta2) / 1024, 1), 'KB') print('menciona el nombre del csv:', b'ventas-miss-yera' in open(ruta2, 'rb').read())
el csv pesa 272.3 KB el modelo pesa 1.3 KB menciona el nombre del csv: False
El modelo guarda lo que aprendió, no de dónde lo aprendió. Eso es bueno para el tamaño y también para la privacidad: puedes mandar el modelo a un sitio donde no podrías mandar los datos.
Con la trampa de que un modelo sí puede filtrar información de sus datos si alguien lo interroga con paciencia, y eso es harina de otro costal.
5. La prueba que va antes de desplegar
Que lo que cargas prediga exactamente lo que predecía.
import numpy as np muestra = [[0.0], [1.0], [0.5]] antes = limpio.predict_proba(muestra)[:, 1] despues = joblib.load(ruta2).predict_proba(muestra)[:, 1] print('maxima diferencia:', float(np.abs(antes - despues).max())) print('identicos :', bool(np.array_equal(antes, despues)))
maxima diferencia: 0.0 identicos : True
Cero exacto, no parecido. Guarda unas cuantas filas con su respuesta el día que entrenaste y corre esta comprobación cada vez que construyas la imagen. Es la forma más barata que hay de cazar un cambio de versión que altera resultados sin dar ningún error.
6. Qué NO debería entrar en la imagen
El .dockerignore, comprobado en vez de
supuesto.
revisar = tempfile.mkdtemp() for nombre in ('api.py', 'limpieza.py', 'modelo.joblib', '.env', 'ventas.csv', 'exploracion.ipynb'): open(os.path.join(revisar, nombre), 'w').write('x') NUNCA = ('.env', '.ipynb', '.csv', '.git') for archivo in sorted(os.listdir(revisar)): peligro = any(archivo.endswith(x) or archivo == x for x in NUNCA) print((' NO subir ' if peligro else ' va '), archivo)
NO subir .env va api.py NO subir exploracion.ipynb va limpieza.py va modelo.joblib NO subir ventas.csv
Tres de seis archivos no tienen nada que hacer dentro de la imagen, y el
.env es el que quita el sueño: ahí viven las claves. Una imagen es
un archivo que se copia, se sube a un registro y se queda ahí; lo que metas
dentro, metido está.
Y ojo, que borrarlo en una línea posterior del Dockerfile no lo quita: cada paso queda guardado y la clave sigue en la capa de antes. Es el primo hermano de lo que cuento en el capítulo de subir una clave sin querer de mi libro de Git.
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.