Desplegament de models
- Introducció
- Per què el desplegament és difícil?
- Preparació de dades per producció
- Contenidors amb Docker
- Què és un contenidor?
- Docker: l’eina estàndard
- Estructura bàsica d’un Dockerfile
- El flux de treball amb Docker
- Docker Compose: orquestració simplificada
- Quan usar Docker Compose?
- Estructura bàsica d’un docker-compose.yml
- Health checks: verificar que tot funciona
- Comandes de Docker Compose
- Exemple complet: API amb configuració
- Variables d’entorn: separar configuració i codi
- Docker Compose amb múltiples serveis (exemple avançat)
- Bones pràctiques amb Docker Compose
- Predicció online vs. predicció batch
- Servir models amb FastAPI
- Desplegament a producció: el flux complet
- Resum
Introducció
Un cop hem entrenat un model de machine learning que funciona bé en el nostre entorn de desenvolupament, ens enfrontem a un repte completament diferent: fer que aquest model estigui disponible per a usuaris reals, de manera fiable i escalable. Aquest procés s’anomena desplegament (deployment) i és on molts projectes de ML fallen. Segons diverses estimacions de la indústria, menys del 50% dels models entrenats arriben mai a producció.
En aquest capítol aprendrem els fonaments del desplegament de models, introduirem les tecnologies que ens permetran empaquetar i servir els nostres models (Docker i FastAPI), i explorarem les decisions arquitectòniques que haurem de prendre.
Per què el desplegament és difícil?
Quan entrenem un model, treballem en un entorn controlat: tenim les nostres llibreries instal·lades, les versions que ens agraden, i executem el codi manualment. En producció, tot canvia:
- El model ha de funcionar en una màquina diferent, potser amb un sistema operatiu diferent
- Ha de respondre a peticions de múltiples usuaris simultàniament
- Ha de ser actualitzable sense interrompre el servei
- Ha de gestionar errors de manera elegant
- Ha de ser monitoritzable per saber si funciona correctament
La solució a molts d’aquests problemes passa per contenidors i APIs.
Preparació de dades per producció
Abans de desplegar un model, necessitem preparar adequadament les nostres dades. Aquest pas és crucial perquè determina com podrem entrenar, validar, i simular el comportament del model en producció.
Per què dividir les dades?
Quan treballem en un entorn real, les dades es divideixen en diferents conjunts amb funcions diferents:
- Training set: Per entrenar el model
- Validation set: Per ajustar hiperparàmetres i avaluar el model durant el desenvolupament
- Production set: Per simular les dades que el model veurà en producció (testing final, detecció de drift)
La clau és mantenir aquests conjunts completament separats per evitar que el model “vegi” dades que hauria de predir més endavant.
Splits recomanats: 60/20/20
Una divisió comuna i pràctica és:
Dataset complet (exemple: 10,000 registres)
│
├── Training (60% = 6,000) → Entrenar el model
│
├── Validation (20% = 2,000) → Validar durant desenvolupament
│
└── Production (20% = 2,000) → Simular producció, detectar drift
Per què aquests percentatges?
- 60% training: Suficient per entrenar models robustos
- 20% validation: Suficient per avaluar de manera fiable
- 20% production: Simula dades noves que arribaran en producció
Implementació de la divisió
Podem usar scikit-learn per crear aquests splits de manera fàcil:
from sklearn.model_selection import train_test_split
import pandas as pd
# Carregar dataset
df = pd.read_csv('data/raw/dataset.csv')
# Primera divisió: separar production set (20%)
train_val, production = train_test_split(
df,
test_size=0.2,
random_state=42,
stratify=df['target'] # Mantenir proporció de classes
)
# Segona divisió: separar training i validation (60% i 20% del total)
training, validation = train_test_split(
train_val,
test_size=0.25, # 0.25 * 0.8 = 0.2 del total
random_state=42,
stratify=train_val['target']
)
# Verificar les mides
print(f"Training: {len(training)} ({len(training)/len(df):.1%})")
print(f"Validation: {len(validation)} ({len(validation)/len(df):.1%})")
print(f"Production: {len(production)} ({len(production)/len(df):.1%})")
Guardar els splits
És important guardar aquests splits en fitxers separats per poder usar-los consistentment:
# Guardar com a Parquet (més eficient que CSV per a datasets grans)
training.to_parquet('data/training_set.parquet', index=False)
validation.to_parquet('data/validation_set.parquet', index=False)
production.to_parquet('data/production_set.parquet', index=False)
# Alternativa: CSV (més universal però menys eficient)
training.to_csv('data/training_set.csv', index=False)
validation.to_csv('data/validation_set.csv', index=False)
production.to_csv('data/production_set.csv', index=False)
Parquet vs CSV:
- Parquet: Format columnar, més ràpid de llegir, ocupa menys espai
- CSV: Format més universal, fàcil de visualitzar en editors de text
Per datasets > 10K registres, Parquet és preferible.
Preprocessament de dades
Després de dividir, sovint cal preprocessar les dades. Les transformacions típiques inclouen:
1. Normalització de features numèriques
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
# Entrenar el scaler NOMÉS amb training data
scaler.fit(training[['age', 'income']])
# Aplicar a tots els splits
training[['age', 'income']] = scaler.transform(training[['age', 'income']])
validation[['age', 'income']] = scaler.transform(validation[['age', 'income']])
production[['age', 'income']] = scaler.transform(production[['age', 'income']])
⚠️ Important: Entrenar el preprocessador (scaler, encoder, etc.) només amb training data. Després aplicar-lo als altres splits. Mai entrenar amb validation o production!
2. Encoding de features categòriques
from sklearn.preprocessing import LabelEncoder
encoder = LabelEncoder()
# Entrenar amb training data
encoder.fit(training['region'])
# Aplicar a tots
training['region'] = encoder.transform(training['region'])
validation['region'] = encoder.transform(validation['region'])
production['region'] = encoder.transform(production['region'])
3. Pipeline complet
Per mantenir consistència, podem crear un script de preprocessament:
# src/preprocess.py
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import joblib
def preprocess_and_split(input_path: str, output_dir: str):
"""
Carrega, preprocessa, i divideix el dataset.
"""
# 1. Carregar dades
df = pd.read_csv(input_path)
# 2. Netejar dades (eliminar nulls, outliers extrems, etc.)
df = df.dropna()
# 3. Dividir
train_val, production = train_test_split(df, test_size=0.2, random_state=42)
training, validation = train_test_split(train_val, test_size=0.25, random_state=42)
# 4. Preprocessar features numèriques
numeric_features = ['age', 'income']
scaler = StandardScaler()
scaler.fit(training[numeric_features])
training[numeric_features] = scaler.transform(training[numeric_features])
validation[numeric_features] = scaler.transform(validation[numeric_features])
production[numeric_features] = scaler.transform(production[numeric_features])
# 5. Guardar splits
training.to_parquet(f'{output_dir}/training_set.parquet')
validation.to_parquet(f'{output_dir}/validation_set.parquet')
production.to_parquet(f'{output_dir}/production_set.parquet')
# 6. Guardar preprocessadors (per usar en producció!)
joblib.dump(scaler, f'{output_dir}/scaler.pkl')
print(f"✓ Splits guardats a {output_dir}")
print(f" Training: {len(training)} registres")
print(f" Validation: {len(validation)} registres")
print(f" Production: {len(production)} registres")
if __name__ == '__main__':
preprocess_and_split('data/raw/dataset.csv', 'data/processed')
Ús del production set
El production set té dues funcions principals:
- Testing final: Avaluar el model entrenat en dades “fresques” que mai ha vist
- Simular drift: Modificar aquest set per simular canvis en la distribució (útil per capítols posteriors)
# Exemple: simular drift d'edat (població envelleix)
production_drift = production.copy()
production_drift['age'] = production_drift['age'] + 5 # +5 anys
# Ara podem detectar aquest drift amb tests estadístics!
Bones pràctiques
- Random seed fix: Usa sempre el mateix
random_stateper reproduibilitat - Stratify per classes: En classificació, usa
stratify=df['target']per mantenir proporcions - Guardar preprocessadors: Desa scalers, encoders, etc. per usar-los en producció
- Documentar splits: Inclou un fitxer
data/README.mdexplicant com es van crear
# Dataset Splits
- **Source**: California Housing dataset
- **Total records**: 20,640
- **Split date**: 2024-01-15
- **Split method**: 60/20/20 stratified by price_category
- **Random seed**: 42
## Files
- `training_set.parquet`: 12,384 records (60%)
- `validation_set.parquet`: 4,128 records (20%)
- `production_set.parquet`: 4,128 records (20%)
- `scaler.pkl`: StandardScaler fitted on training data
Amb les dades preparades adequadament, estem llestos per entrenar el model i desplegar-lo!
Contenidors amb Docker
Què és un contenidor?
Un contenidor (container) és una unitat de programari que empaqueta el codi i totes les seves dependències perquè l’aplicació s’executi de manera ràpida i fiable en diferents entorns. Podem pensar en un contenidor com una “capsa” que conté tot el necessari per executar la nostra aplicació: el codi, les llibreries, les configuracions, i fins i tot una versió mínima del sistema operatiu.
┌─────────────────────────────────────┐
│ Contenidor │
│ ┌───────────────────────────────┐ │
│ │ La nostra aplicació (model) │ │
│ ├───────────────────────────────┤ │
│ │ Python 3.11, scikit-learn, │ │
│ │ FastAPI, pandas... │ │
│ ├───────────────────────────────┤ │
│ │ Sistema operatiu mínim │ │
│ │ (Ubuntu, Alpine...) │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Docker: l’eina estàndard
Docker és l’eina més utilitzada per crear i gestionar contenidors. Els conceptes clau són:
- Imatge (image): Una plantilla de només lectura amb les instruccions per crear un contenidor. És com un “motlle”.
- Contenidor (container): Una instància executable d’una imatge. És el “pastís” fet a partir del motlle.
- Dockerfile: Un fitxer de text amb les instruccions per construir una imatge.
- Docker Compose: Una eina per definir i executar aplicacions amb múltiples contenidors.
Estructura bàsica d’un Dockerfile
Un Dockerfile per a un model de ML típicament té aquesta estructura:
# Imatge base amb Python
FROM python:3.11-slim
# Directori de treball dins del contenidor
WORKDIR /app
# Copiar i instal·lar dependències
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copiar el codi i el model
COPY model/ ./model/
COPY app.py .
# Port on escoltarà l'aplicació
EXPOSE 8000
# Comanda per executar l'aplicació
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
El flux de treball amb Docker
El procés típic és:
- Escrivim el Dockerfile que descriu com construir la imatge
- Construïm la imatge localment amb
docker build - Provem el contenidor localment amb
docker run - Pugem la imatge a un registre (pot ser un registre privat al nostre servidor)
- Al servidor de producció, descarreguem i executem la imatge
Docker Compose: orquestració simplificada
Fins ara hem vist com crear una imatge Docker, però quan despleguem aplicacions reals, sovint necessitem múltiples contenidors treballant junts (per exemple, API + base de dades) o simplement volem configurar més fàcilment un sol contenidor. Aquí és on entra Docker Compose.
Docker Compose és una eina per definir i executar aplicacions Docker amb múltiples contenidors usant un fitxer YAML.
Quan usar Docker Compose?
- Configuració complexa: Moltes variables d’entorn, volums, ports
- Múltiples serveis: API + base de dades + cache
- Desenvolupament local: Levantar tot l’entorn amb un sol comando
- Reproduibilitat: Configuració compartible i versionable
Estructura bàsica d’un docker-compose.yml
version: '3.8'
services:
api:
build: .
ports:
- "8000:8000"
environment:
- MODEL_PATH=/models/model.pkl
- LOG_LEVEL=info
volumes:
- ./models:/models
- ./logs:/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
Explicació dels camps:
- services: Defineix els contenidors (aquí només n’hi ha un:
api) - build: Directori amb el Dockerfile
- ports: Mapatge port host:contenidor (
8000:8000= port 8000 del host → port 8000 del contenidor) - environment: Variables d’entorn
- volumes: Muntar directoris del host al contenidor
- healthcheck: Comanda per verificar que el servei està sa
Health checks: verificar que tot funciona
El health check és crucial per saber si el contenidor està funcionant correctament:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s # Cada quan executar el check
timeout: 10s # Temps màxim per resposta
retries: 3 # Quants errors abans de marcar com unhealthy
start_period: 40s # Temps d'espera inicial (per permetre arrencada)
Aquest health check crida l’endpoint /health cada 30 segons. Si falla 3 vegades seguides, Docker marca el contenidor com “unhealthy”.
Endpoint de health a FastAPI:
@app.get("/health")
def health():
"""Health check endpoint."""
return {
"status": "healthy",
"model_loaded": model is not None,
"timestamp": datetime.now().isoformat()
}
Comandes de Docker Compose
# Construir i iniciar tots els serveis
docker-compose up --build
# Iniciar en mode detached (background)
docker-compose up -d
# Veure logs
docker-compose logs -f
# Aturar serveis
docker-compose down
# Veure estat dels serveis
docker-compose ps
Exemple complet: API amb configuració
# docker-compose.yml
version: '3.8'
services:
model-api:
build:
context: .
dockerfile: Dockerfile
container_name: ml_model_api
ports:
- "8000:8000"
environment:
- MODEL_PATH=/app/models/model.pkl
- MODEL_VERSION=1.0.0
- LOG_LEVEL=info
- MAX_WORKERS=4
volumes:
- ./models:/app/models:ro # :ro = read-only
- ./logs:/app/logs
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped # Reiniciar automàticament si falla
Avantatges d’aquesta configuració:
- Models com volum: Podem actualitzar el model sense reconstruir la imatge
- Logs persistents: Els logs es guarden al host
- Health check: Docker sap si el servei funciona
- Restart policy: El contenidor es reinicia automàticament si falla
Variables d’entorn: separar configuració i codi
En lloc de hardcoded values al Dockerfile, usem variables d’entorn:
# api.py
import os
MODEL_PATH = os.getenv('MODEL_PATH', 'models/model.pkl')
MODEL_VERSION = os.getenv('MODEL_VERSION', 'unknown')
LOG_LEVEL = os.getenv('LOG_LEVEL', 'info')
model = joblib.load(MODEL_PATH)
Això permet canviar configuració sense reconstruir:
environment:
- MODEL_PATH=/app/models/model_v2.pkl # Canviar model
- LOG_LEVEL=debug # Canviar log level
Docker Compose amb múltiples serveis (exemple avançat)
Si necessitem una base de dades per guardar prediccions:
version: '3.8'
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/predictions
depends_on:
- db
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
db:
image: postgres:15
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=predictions
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 10s
volumes:
postgres_data:
Ara amb docker-compose up, aixequem API + PostgreSQL automàticament!
Bones pràctiques amb Docker Compose
- Noms de serveis clars:
model-api,postgres, noservice1,app - Health checks sempre: Per detectar problemes ràpidament
- Volums per dades persistents: Models, logs, bases de dades
- Variables d’entorn: Mai hardcoded secrets o configuracions
- restart: unless-stopped: Per serveis que han d’estar sempre actius
- Fitxer .env: Per secrets (no comitear!)
# .env (no comitear a git!)
DATABASE_PASSWORD=super_secret_123
MODEL_VERSION=1.2.0
# docker-compose.yml
services:
api:
env_file: .env # Carrega variables des de .env
Amb Docker Compose, el desplegament és tan simple com docker-compose up!
Predicció online vs. predicció batch
Hi ha dues estratègies principals per servir prediccions, i la tria depèn del cas d’ús.
Predicció online (en temps real)
La predicció online (online prediction) és quan el model rep una petició i ha de respondre immediatament. És el que veurem amb FastAPI a la següent secció.
Característiques:
- Latència baixa (mil·lisegons)
- Una predicció per petició (o poques)
- El model està carregat en memòria constantment
- Requereix infraestructura sempre disponible
Casos d’ús:
- Recomanacions en temps real
- Detecció de frau en transaccions
- Assistents virtuals
- Cerca personalitzada
Predicció batch (per lots)
La predicció batch (batch prediction) és quan processem moltes prediccions alhora, normalment de manera programada.
Característiques:
- La latència no és crítica (minuts o hores acceptable)
- Milers o milions de prediccions alhora
- El model es carrega, processa, i s’atura
- Més eficient en recursos per a grans volums
- Les prediccions es guarden en una base de dades per ser consultades després
Casos d’ús:
- Enviament massiu de correus personalitzats
- Càlcul nocturn de puntuacions de risc
- Generació de recomanacions pre-calculades
- Informes periòdics
Flux típic d’una predicció batch:
- Dades acumulades: Es recullen i acumulen les dades que s’han de processar.
- Script batch: S’executa un script o procés per iniciar el lot de prediccions.
- Carregar model: El model de machine learning es carrega a la memòria.
- Processar tot: Es processen totes les dades d’una sola vegada, aplicant el model a cada cas.
- Guardar a base de dades: Els resultats es desen en una base de dades (o fitxers) per a consulta posterior.
La diferència clau amb predicció online és que els resultats no es retornen immediatament a l’usuari. En lloc d’això, es guarden i l’aplicació els consulta quan els necessita:
Comparativa
| Aspecte | Online | Batch |
|---|---|---|
| Latència | Mil·lisegons | Minuts/hores |
| Volum per execució | Baix | Alt |
| Recursos | Sempre actius | Sota demanda |
| Complexitat | Més alta | Més baixa |
| Freshness de prediccions | Temps real | Periòdic |
| On es guarden resultats | Retorn immediat | Base de dades |
Arquitectura híbrida
En molts sistemes reals es combinen ambdues estratègies:
- Batch per pre-calcular prediccions per als casos més comuns
- Online per als casos nous o que requereixen context en temps real
Per exemple, una botiga online podria pre-calcular recomanacions cada nit per a tots els usuaris (batch), però calcular en temps real les recomanacions basades en el que l’usuari està mirant ara mateix (online).
A continuació, veurem com implementar predicció online amb FastAPI.
Servir models amb FastAPI
Per què una API?
Per fer que el nostre model sigui accessible, necessitem exposar-lo a través d’una API (Application Programming Interface). Una API web permet que altres aplicacions facin peticions al nostre model i rebin prediccions com a resposta.
El protocol més comú per a APIs web és HTTP, i l’arquitectura més utilitzada és REST (Representational State Transfer). En una API REST, fem peticions a URLs específiques (anomenades endpoints) utilitzant mètodes HTTP com GET o POST.
FastAPI: modern i ràpid
FastAPI és un framework de Python per crear APIs. Els seus avantatges principals són:
- Ràpid: Un dels frameworks més ràpids de Python, comparable a Node.js o Go
- Fàcil: Sintaxi intuïtiva basada en tipus de Python
- Documentació automàtica: Genera documentació interactiva automàticament
- Validació automàtica: Valida les dades d’entrada automàticament
Exemple bàsic d’una API per servir un model
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import joblib
import numpy as np
import logging
logger = logging.getLogger(__name__)
# Carregar el model entrenat
model = joblib.load("model/model.pkl")
# Crear l'aplicació FastAPI
app = FastAPI(title="API de Predicció")
# Definir l'estructura de les dades d'entrada
class InputData(BaseModel):
feature_1: float
feature_2: float
feature_3: float
# Definir l'estructura de la resposta
class Prediction(BaseModel):
prediction: float
model_version: str = "1.0.0"
# Endpoint per fer prediccions
@app.post("/predict", response_model=Prediction)
def predict(data: InputData) -> Prediction:
try:
# Convertir a format que espera el model
features = np.array([[data.feature_1, data.feature_2, data.feature_3]])
# Fer la predicció
result = model.predict(features)[0]
return Prediction(prediction=float(result))
except (ValueError, TypeError) as e:
logger.error(f"Validation error: {str(e)}")
raise HTTPException(status_code=422, detail=f"Invalid input: {str(e)}")
except Exception as e:
logger.error(f"Prediction error: {str(e)}")
raise HTTPException(status_code=500, detail="Server error")
# Endpoint de salut (health check)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "healthy"}
Integrant FastAPI amb Docker
El Dockerfile complet per a aquesta aplicació seria:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY model/ ./model/
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
On requirements.txt contindria:
fastapi>=0.109.0,<1.0.0
uvicorn[standard]>=0.27.0,<1.0.0
joblib>=1.4.0,<2.0.0
scikit-learn>=1.4.0,<2.0.0
pydantic>=2.5.0,<3.0.0
numpy>=1.24.0,<2.0.0
Versionat de models
Un aspecte crític del desplegament de models és el versionat: mantenir un registre clar de quins models hem desplegat, quan, i amb quines característiques. Sense versionat, és impossible fer rollback, comparar models, o simplement saber què està en producció.
Per què versionar models?
Problemes sense versionat:
- “Quin model està en producció ara?” → No ho sabem
- “Aquest model funciona malament, tornem a l’anterior” → Quin era?
- “Aquest bug existia en la v1.2?” → No tenim v1.2, només
model.pkl
Avantatges del versionat:
- Traçabilitat: Saber exactament què està desplegat
- Rollback: Tornar a versions anteriors si cal
- Comparació: Avaluar si un nou model és millor
- Reproducibilitat: Reconstruir models antics si cal
- Auditoria: Complir amb requisits regulatoris
Semantic Versioning per models
Adaptem semantic versioning (MAJOR.MINOR.PATCH) per a models ML:
v1.2.3
│ │ │
│ │ └─ PATCH: Bug fixes en preprocessament, mateix model i features
│ └─── MINOR: Mateix algoritme, afegir features o reentrenament amb més dades
└───── MAJOR: Nou algoritme o arquitectura significativament diferent
Exemples:
v1.0.0 → v1.0.1: Fix en normalització, mateix modelv1.0.1 → v1.1.0: Afegida feature “region”, mateix RandomForestv1.1.0 → v2.0.0: Canvi de RandomForest a XGBoost
Guardar models amb versions
Estructurar els models amb informació de versió:
import joblib
import json
from datetime import datetime
def save_versioned_model(
model,
version: str,
metrics: dict[str, float],
features: list[str],
output_dir: str
) -> tuple[str, str]:
"""
Guarda un model amb la seva versió i metadata.
Args:
model: Model entrenat
version: Versió (e.g., "1.2.0")
metrics: Diccionari de mètriques {'f1': 0.82, 'recall': 0.85}
features: Llista de features usades ['age', 'income', ...]
output_dir: Directori on guardar
Returns:
Tuple amb (model_path, metadata_path)
"""
# Preparar metadata
metadata = {
'version': version,
'trained_at': datetime.now().isoformat(),
'metrics': metrics,
'features': features,
'model_type': type(model).__name__
}
# Guardar model
model_path = f'{output_dir}/model_v{version}.pkl'
joblib.dump(model, model_path)
print(f"✓ Model guardat: {model_path}")
# Guardar metadata
metadata_path = f'{output_dir}/metadata_v{version}.json'
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
print(f"✓ Metadata guardada: {metadata_path}")
return model_path, metadata_path
# Ús
model = train_model(X_train, y_train)
metrics = evaluate_model(model, X_val, y_val)
save_versioned_model(
model=model,
version='1.2.0',
metrics={'f1': 0.82, 'recall': 0.85, 'precision': 0.79},
features=['age', 'income', 'score', 'region'],
output_dir='models'
)
Això genera:
models/
├── model_v1.2.0.pkl
└── metadata_v1.2.0.json
Contingut de metadata_v1.2.0.json:
{
"version": "1.2.0",
"trained_at": "2024-01-15T14:30:00",
"metrics": {
"f1": 0.82,
"recall": 0.85,
"precision": 0.79
},
"features": ["age", "income", "score", "region"],
"model_type": "RandomForestClassifier"
}
Symlink per “model actual”
Per facilitar el desplegament, crear un symlink que apunti al model actual:
# En Linux/Mac
ln -sf model_v1.2.0.pkl current_model.pkl
# Ara podem carregar sempre current_model.pkl
model = joblib.load('models/current_model.pkl')
Quan despleguem una nova versió:
# Actualitzar symlink a nova versió
ln -sf model_v1.3.0.pkl current_model.pkl
# Rollback si cal
ln -sf model_v1.2.0.pkl current_model.pkl
Metadata extesa (recomanat per producció)
Per a entorns més complexos, guardar més informació:
metadata = {
'version': '1.2.0',
'trained_at': '2024-01-15T14:30:00',
# Dataset info
'dataset': {
'training_rows': 10000,
'validation_rows': 2000,
'data_hash': 'abc123def456', # Hash del dataset per reproducibilitat
},
# Mètriques
'metrics': {
'f1': 0.82,
'precision': 0.79,
'recall': 0.85,
'roc_auc': 0.89,
'latency_p95_ms': 45
},
# Features
'features': ['age', 'income', 'score', 'region'],
'num_features': 4,
# Model info
'model_type': 'RandomForestClassifier',
'hyperparameters': {
'n_estimators': 100,
'max_depth': 10,
'random_state': 42
},
# Entorn
'python_version': '3.11.0',
'sklearn_version': '1.4.0',
# Deployment
'deployed_at': None, # S'omple quan es desplega
'deployed_by': 'ci/cd',
'environment': 'production'
}
Model registry simple
Un model registry és un lloc centralitzat per guardar i gestionar models. Pot ser tan simple com un directori amb convencions:
models/
├── v1.0.0/
│ ├── model.pkl
│ ├── metadata.json
│ └── scaler.pkl
├── v1.1.0/
│ ├── model.pkl
│ ├── metadata.json
│ └── scaler.pkl
├── v1.2.0/
│ ├── model.pkl
│ ├── metadata.json
│ └── scaler.pkl
└── current -> v1.2.0/ # Symlink
Funcions útils per gestionar el registry:
import os
import json
from pathlib import Path
def list_model_versions(registry_dir: str) -> list[str]:
"""Llista totes les versions disponibles."""
return sorted([
d.name for d in Path(registry_dir).iterdir()
if d.is_dir() and d.name.startswith('v')
])
def load_model_by_version(registry_dir: str, version: str):
"""Carrega un model específic per versió."""
model_path = f'{registry_dir}/{version}/model.pkl'
if not os.path.exists(model_path):
raise ValueError(f"Model {version} no trobat")
model = joblib.load(model_path)
# Carregar metadata
metadata_path = f'{registry_dir}/{version}/metadata.json'
with open(metadata_path, 'r') as f:
metadata = json.load(f)
return model, metadata
# Ús
versions = list_model_versions('models')
print(f"Versions disponibles: {versions}") # ['v1.0.0', 'v1.1.0', 'v1.2.0']
model, metadata = load_model_by_version('models', 'v1.2.0')
print(f"Model: {metadata['model_type']}, F1: {metadata['metrics']['f1']}")
Incloure versió a l’API
Afegir la versió del model a les respostes de l’API:
from fastapi import FastAPI
import joblib
import json
app = FastAPI()
# Carregar model i metadata
model = joblib.load('models/current/model.pkl')
with open('models/current/metadata.json') as f:
metadata = json.load(f)
@app.get("/model/info")
def model_info():
"""Retorna informació del model actual."""
return {
"version": metadata['version'],
"trained_at": metadata['trained_at'],
"model_type": metadata['model_type'],
"metrics": metadata['metrics']
}
@app.post("/predict")
def predict(data: InputData):
prediction = model.predict([data.features])[0]
return {
"prediction": float(prediction),
"model_version": metadata['version'] # Incloure versió!
}
Ara cada predicció indica quina versió del model s’ha usat:
{
"prediction": 0.85,
"model_version": "1.2.0"
}
Resum del versionat
Essencial:
- ✅ Versió clara per cada model (
v1.2.0) - ✅ Metadata amb mètriques i features
- ✅ Convencions de noms (
model_v1.2.0.pkl,metadata_v1.2.0.json)
Recomanat:
- ✅ Symlink
current_model.pklper facilitar desplegament - ✅ Directori per versió amb model + metadata + preprocessadors
- ✅ Versió inclosa a les respostes de l’API
Avançat:
- ✅ Metadata extesa (dataset hash, hyperparameters, entorn)
- ✅ Model registry centralitzat (MLflow, DVC, o directori estructurat)
- ✅ CI/CD que gestiona versions automàticament
Amb versionat adequat, tenim control complet sobre els models desplegats!
Desplegament a producció: el flux complet
Per tancar, vegem el flux complet des del desenvolupament fins a producció:
Bones pràctiques
- Versionar tot: El codi, el model, i les imatges Docker han de tenir versions clares
- Health checks: Incloure endpoints per verificar que el servei funciona (
/health) - Logging: Registrar peticions, errors, i temps de resposta
- Variables d’entorn: Configuracions (ports, paths) com a variables d’entorn, no hardcoded
- Provar localment: Mai desplegar directament a producció sense provar primer en local
Resum
En aquest capítol hem après els fonaments del desplegament de models:
- Preparar dades amb splits adequats (60/20/20) i preprocessament consistent
- Empaquetar models amb Docker per garantir reproducibilitat entre entorns
- Servir prediccions amb FastAPI, incloent versionat de models
- Triar entre predicció online (temps real) i batch (lots programats)
- El flux complet de desplegament des de desenvolupament fins a producció
Ara que sabem com desplegar models, al proper capítol aprendrem com garantir la seva qualitat i validació abans i després del desplegament.