Capítulo 26 de 29 9 secciones 10 min

Compartir

Que corra en la máquina de otro

Por qué un joblib no viaja solo, qué le falta, y cómo se empaqueta el modelo con su código y sus versiones para que arranque igual en cualquier sitio.

Un archivo joblib guarda los nombres de las clases y funciones que hay dentro, no su código. Si la otra máquina no las tiene, revienta al cargarlo. Un contenedor resuelve eso llevando juntos el modelo, tu código y las versiones exactas de las librerías, para que arranque igual en cualquier sitio 📦

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:

  1. El archivo del modelo.
  2. Tu código, el de las funciones de limpieza y el de la API.
  3. 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. slim es 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.0 no 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 de pip freeze el día que entrenaste.
  • Copia las dependencias antes que el código, para aprovechar la caché.
  • --host 0.0.0.0 y -p, o nadie lo alcanza.
  • Un .dockerignore o 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.

¿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?