Despliega un Modelo de Machine Learning con Sklearn, FastAPI y Docker
Crea una API REST para desplegar modelos de scikit-learn con FastAPI y Docker: del entrenamiento a un contenedor listo para producción en minutos.
Entrenar un modelo de machine learning es solo la mitad del trabajo. El otro 50% — el que muchos científicos de datos postergan — es hacer que ese modelo esté disponible para que otros sistemas lo consuman. Un notebook de Jupyter no es una API. Un archivo .pkl en tu escritorio no está en producción. Este artículo te muestra cómo cerrar esa brecha: entrenarás un clasificador de scikit-learn, lo expondrás como una API REST validada con FastAPI y empaquetarás todo en un contenedor Docker que puedes desplegar donde quieras.
Por Qué Necesitas Desplegar Modelos de ML
Cuando tu modelo vive en un notebook, el único que puede usarlo eres tú, manualmente. Para integrarlo con una aplicación web, una app móvil, un pipeline de datos o cualquier otro servicio, necesitas exponerlo a través de una interfaz estándar. Una API REST es el contrato más universal: cualquier sistema que pueda hacer una petición HTTP puede consumir tu modelo, sin importar el lenguaje en que esté escrito.
FastAPI ofrece ventajas concretas sobre alternativas como Flask para este caso de uso:
- Validación automática de datos con Pydantic
- Documentación interactiva generada automáticamente (Swagger UI en
/docs) - Soporte nativo para async, útil cuando el modelo tarda en responder
- Tipado estático que evita bugs en producción
Entrenando y Guardando el Modelo con joblib
Empezamos entrenando un clasificador de iris y guardándolo en disco. joblib es más eficiente que pickle para objetos que contienen arrays NumPy grandes (como los árboles de decisión de sklearn). Si el paso de entrenamiento es nuevo para ti, Tu Primer Modelo de Machine Learning con Scikit-learn lo explica en detalle.
# train_model.py
import joblib
import numpy as np
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
# Cargar datos y entrenar
iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
iris.data, iris.target, test_size=0.2, random_state=42
)
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
# Verificar rendimiento
accuracy = accuracy_score(y_test, model.predict(X_test))
print(f"Accuracy en test: {accuracy:.4f}")
# Guardar modelo y metadata de clases
joblib.dump(model, "model/classifier.joblib")
joblib.dump(iris.target_names.tolist(), "model/class_names.joblib")
print("Modelo guardado en model/classifier.joblib")
Ejecuta este script primero:
mkdir model
python train_model.py
# Accuracy en test: 1.0000
# Modelo guardado en model/classifier.joblib
Construyendo la API con FastAPI
Con el modelo guardado, construimos la API. La clave es definir los esquemas de entrada y salida con Pydantic: FastAPI los usa para validar automáticamente cada petición y generar la documentación.
# app/main.py
from contextlib import asynccontextmanager
from typing import List
import joblib
import numpy as np
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
# Esquema de entrada: las cuatro medidas de una flor iris
class IrisFeatures(BaseModel):
sepal_length: float = Field(..., gt=0, description="Longitud del sépalo en cm")
sepal_width: float = Field(..., gt=0, description="Ancho del sépalo en cm")
petal_length: float = Field(..., gt=0, description="Longitud del pétalo en cm")
petal_width: float = Field(..., gt=0, description="Ancho del pétalo en cm")
model_config = {
"json_schema_extra": {
"example": {
"sepal_length": 5.1,
"sepal_width": 3.5,
"petal_length": 1.4,
"petal_width": 0.2
}
}
}
# Esquema de salida
class PredictionResponse(BaseModel):
prediction: int
class_name: str
probabilities: List[float]
# Cargar modelo al iniciar la aplicación
ml_models = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
ml_models["classifier"] = joblib.load("model/classifier.joblib")
ml_models["class_names"] = joblib.load("model/class_names.joblib")
yield
ml_models.clear()
app = FastAPI(
title="Iris Classifier API",
description="Clasifica flores iris usando un Random Forest",
version="1.0.0",
lifespan=lifespan
)
@app.get("/health")
def health_check():
return {"status": "healthy", "model_loaded": "classifier" in ml_models}
@app.post("/predict", response_model=PredictionResponse)
def predict(features: IrisFeatures):
if "classifier" not in ml_models:
raise HTTPException(status_code=503, detail="Modelo no disponible")
# Preparar features como array 2D
X = np.array([[
features.sepal_length,
features.sepal_width,
features.petal_length,
features.petal_width
]])
model = ml_models["classifier"]
prediction = int(model.predict(X)[0])
probabilities = model.predict_proba(X)[0].tolist()
class_name = ml_models["class_names"][prediction]
return PredictionResponse(
prediction=prediction,
class_name=class_name,
probabilities=probabilities
)
@app.post("/predict/batch", response_model=List[PredictionResponse])
def predict_batch(features_list: List[IrisFeatures]):
if "classifier" not in ml_models:
raise HTTPException(status_code=503, detail="Modelo no disponible")
X = np.array([[f.sepal_length, f.sepal_width, f.petal_length, f.petal_width]
for f in features_list])
model = ml_models["classifier"]
predictions = model.predict(X).tolist()
probabilities = model.predict_proba(X).tolist()
class_names = ml_models["class_names"]
return [
PredictionResponse(
prediction=pred,
class_name=class_names[pred],
probabilities=probs
)
for pred, probs in zip(predictions, probabilities)
]
Probando la API Localmente con uvicorn
Instala las dependencias y levanta el servidor:
pip install fastapi uvicorn joblib scikit-learn numpy
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
Abre http://localhost:8000/docs para la documentación interactiva. También puedes probar con curl:
curl -X POST "http://localhost:8000/predict" \
-H "Content-Type: application/json" \
-d '{"sepal_length": 5.1, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2}'
Respuesta esperada:
{
"prediction": 0,
"class_name": "setosa",
"probabilities": [0.97, 0.02, 0.01]
}
Escribiendo el Dockerfile
Para containerizar la API necesitamos un Dockerfile. Usamos python:3.12-slim para mantener la imagen pequeña:
# Dockerfile
FROM python:3.12-slim
# Crear usuario no-root para seguridad
RUN useradd --create-home appuser
WORKDIR /app
# Instalar dependencias primero (aprovecha caché de Docker)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copiar código y modelo
COPY app/ ./app/
COPY model/ ./model/
# Cambiar a usuario no-root
USER appuser
# Exponer puerto
EXPOSE 8000
# Comando de inicio
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
El archivo requirements.txt:
fastapi==0.115.6
uvicorn[standard]==0.34.0
scikit-learn==1.6.1
joblib==1.5.0
numpy==2.2.3
pydantic==2.11.3
Construyendo y Ejecutando el Contenedor Docker
# Construir la imagen
docker build -t iris-classifier:v1 .
# Ejecutar el contenedor
docker run -d \
--name iris-api \
-p 8000:8000 \
iris-classifier:v1
# Verificar que está corriendo
docker ps
docker logs iris-api
Probando la API Dockerizada
Con el contenedor corriendo, las mismas pruebas funcionan:
# Health check
curl http://localhost:8000/health
# {"status":"healthy","model_loaded":true}
# Predicción individual
curl -X POST "http://localhost:8000/predict" \
-H "Content-Type: application/json" \
-d '{"sepal_length": 6.7, "sepal_width": 3.0, "petal_length": 5.2, "petal_width": 2.3}'
# {"prediction":2,"class_name":"virginica","probabilities":[0.0,0.05,0.95]}
O usando Python requests:
import requests
url = "http://localhost:8000/predict/batch"
payload = [
{"sepal_length": 5.1, "sepal_width": 3.5, "petal_length": 1.4, "petal_width": 0.2},
{"sepal_length": 6.7, "sepal_width": 3.0, "petal_length": 5.2, "petal_width": 2.3},
]
response = requests.post(url, json=payload)
for result in response.json():
print(f"{result['class_name']}: {max(result['probabilities']):.2%} confianza")
# setosa: 97.00% confianza
# virginica: 95.00% confianza
Consideraciones para Producción
El código anterior es funcional, pero un entorno de producción real requiere algunos ajustes adicionales.
CORS: si tu API es consumida desde un frontend en otro dominio, necesitas configurar CORS:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://tu-dominio.com"],
allow_methods=["POST", "GET"],
allow_headers=["*"],
)
Autenticación: para APIs no públicas, añade un header de API key:
import os
from fastapi import Security
from fastapi.security.api_key import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key")
@app.post("/predict")
def predict(features: IrisFeatures, api_key: str = Security(api_key_header)):
if api_key != os.environ["API_KEY"]:
raise HTTPException(status_code=403, detail="Clave inválida")
# ... resto del handler
Escalado: corre uvicorn con múltiples workers o detrás de gunicorn (añade gunicorn a requirements.txt):
CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"]
Conclusión
Pasar de un modelo entrenado a una API REST containerizada toma menos de una hora siguiendo este flujo: entrenar con sklearn, guardar con joblib, desplegar con FastAPI, empaquetar con Docker. La estructura es lo suficientemente simple para iterar rápido y lo suficientemente robusta para desplegarse en cualquier orquestador de contenedores (Kubernetes, Azure Container Apps, AWS ECS). El siguiente paso es añadir un pipeline de CI/CD que reconstruya la imagen cada vez que reentrenes el modelo — un re-entrenamiento que puedes automatizar con Prefect — y vigilar el drift del modelo desplegado: Monitoreo de Modelos ML en Producción con Evidently.
Sobre el autor
Rodrigo Arenas es arquitecto de software e ingeniero de machine learning en Medellín, Colombia. Construye productos, plataformas y software open source para IA, Data Engineering y Machine Learning — es el creador de Ciaren, sklearn-genetic-opt y PyWorkforce.
Lectura recomendada
Rodrigo Arenas — ML Engineer & Open Source Maintainer
¿Estás trabajando en algo similar? Puedo ayudarte a llevarlo a producción.