Del model al servei
- MLOps
- Dades per a producció
- Serialització
- Predicció online i batch
- Servir el model amb FastAPI
- Empaquetar amb Docker
- Versionat de models
- Resum
- Referències
Fins ara hem vist com entrenar un model, com triar-lo i com mesurar-ne la qualitat (vegeu Avaluació de models), i com construir sistemes amb LLMs (LLMs i IA generativa). Però un model que funciona al portàtil encara no serveix a ningú. Desplegar-lo (deployment) vol dir fer-lo disponible per a altres aplicacions, de manera fiable, reproduïble i actualitzable. Aquest document explica com empaquetar un model, servir-lo amb una API dins d’un contenidor i gestionar-ne les versions.
MLOps
MLOps (Machine Learning Operations) és la pràctica de portar models a producció i mantenir-los: aplica les idees de DevOps (automatització, tests, CI/CD) a sistemes on, a més del codi, hi ha dades i models entrenats.
Aquests documents segueixen el cicle de vida d’un model en producció. Primer el recorren amb un model de ML, que el servei carrega des d’un fitxer. Després tornen a recórrer les mateixes etapes per a un sistema amb un LLM, on el model és un servei extern:
| Pàgina | Pregunta que respon |
|---|---|
| Del model al servei (aquesta) | Com empaqueto i serveixo el model? |
| Validació i CI/CD | Quan pot arribar a producció, i com ho automatitzo? |
| Monitoratge i reentrenament | Continua funcionant bé? Què faig quan es degrada? |
| Sistemes LLM en producció | Què canvia quan el model és un LLM? |
| Patrons d’implementació | Receptes concretes per a problemes habituals |
El pipeline
En MLOps no es desplega només un model, sinó un pipeline: una seqüència d’etapes automatitzades on la sortida d’una és l’entrada de la següent.
┌─► Dades d'entrada
│ │
│ ▼
│ Preparació de dades validar, netejar, dividir, guardar en Parquet
│ │
│ ▼
│ Entrenament model + preprocessament en un sol fitxer
│ │
│ ▼
│ Validació compleix els criteris? ── No ──► STOP
│ │ Sí
│ ▼
│ Desplegament imatge Docker amb l'API
│ │
│ ▼
│ Monitoratge drift, errors, latència
│ │
│ ▼
│ Cal reentrenar?
│ │
└──── Sí ───┘
Sense pipeline, cada pas és manual i propens a errors: “he entrenat un model nou però no l’he validat”, “quin preprocessament vaig fer servir?”. Amb pipeline, el mateix procés s’executa sempre igual i cada execució queda registrada. Si les dades canvien, no cal refer res a mà: es torna a executar el pipeline. Per això es diu que el pipeline és el producte, i el model n’és una sortida.
Quatre termes que apareixen a tot arreu:
- Etapa (stage): un pas del pipeline, com l’entrenament o la validació.
- Artefacte (artifact): el que produeix una etapa, com un fitxer
.parquet, un.pklo unmetadata.json. - Porta de qualitat (quality gate): un punt de decisió automàtic que atura el pipeline si no es compleix un criteri.
- Disparador (trigger): el que posa el pipeline en marxa, sigui un
git push, una hora programada o una alerta de drift.
MLOps i DevOps
Un pipeline de MLOps és un pipeline de DevOps ampliat amb dades i models:
| DevOps | MLOps | |
|---|---|---|
| Artefacte | Codi compilat | Model entrenat, que depèn del codi i de les dades |
| Porta de qualitat | Els tests passen | Els tests passen i les mètriques del model superen un llindar |
| Com es degrada | Un bug provoca errors visibles | Les dades canvien i les prediccions empitjoren sense cap error |
| Reentrenament | No existeix | Una etapa habitual |
| Versionat | Codi | Codi, dades i model |
Dades per a producció
Les dades ja preparades es guarden en Parquet, no en CSV:
- Conserva els tipus: en CSV tot és text, i pandas ha d’endevinar si
42és un número o una cadena. Parquet guarda enters, decimals i dates amb el seu tipus. - Ocupa menys i es llegeix més de pressa: està comprimit i organitzat per columnes, així que es poden llegir només les columnes necessàries.
df = pd.read_csv("input/vehicles.csv")
df.to_parquet("data/training_set.parquet", index=False)
El production set
Les dades es divideixen en tres parts: 60% per entrenar, 20% per validar i 20% per a l’avaluació final (el perquè d’aquesta divisió s’explica a Separar les dades). Des del punt de vista del desplegament, aquest últim 20% és millor pensar-lo com a production set: dades que el model no ha vist mai i que simulen el que trobarà en producció.
data/
├── training_set.parquet # 60%: entrenar el model
├── validation_set.parquet # 20%: ajustar-lo i comprovar els criteris de desplegament
└── production_set.parquet # 20%: simula dades de producció
El production set té dues funcions:
- Estimar el rendiment real: abans de desplegar, diu com funcionarà el model amb dades noves.
- Servir de referència per detectar drift: després de desplegar, es comparen les dades que arriben amb aquestes per saber si han canviat (ho veiem a Monitoratge i reentrenament).
Per això no s’ha de fer servir per prendre decisions durant el desenvolupament: si ajustes el model mirant el production set, deixa de simular dades noves.
Serialització
Un model entrenat viu a la memòria del procés de Python: si el procés acaba, desapareix. Serialitzar (serialize) és convertir-lo en un fitxer que es pot guardar, copiar a un servidor i tornar a carregar sense reentrenar.
Model i preprocessament junts
El model no rep les dades tal com arriben: abans cal imputar valors absents, codificar categories, escalar. Si aquest preprocessament es fa en un fitxer a part (un scaler.pkl al costat del model.pkl), l’API l’ha de reproduir exactament igual, i qualsevol diferència entre entrenament i servei (train/serve skew) dona prediccions errònies sense cap error visible.
La solució és posar-ho tot en un sol objecte: un Pipeline de scikit-learn.
import joblib
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestRegressor
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
NUMERIC = ["year", "odometer"]
CATEGORICAL = ["manufacturer", "fuel", "transmission"]
preprocess = ColumnTransformer([
("num", SimpleImputer(strategy="median"), NUMERIC),
("cat", OneHotEncoder(handle_unknown="ignore"), CATEGORICAL),
])
model = Pipeline([
("preprocess", preprocess),
("regressor", RandomForestRegressor(random_state=42)),
])
model.fit(X_train, y_train)
joblib.dump(model, "models/model_v1.pkl")
L’API carrega un sol fitxer i li passa les dades crues. A més:
SimpleImputeraprèn la mediana durant l’entrenament i la fa servir per omplir valors absents en producció. La mateixa regla a tot arreu.OneHotEncoder(handle_unknown="ignore")fa que una categoria no vista en entrenament (un fabricant nou) no faci fallar la predicció.
joblib i la seguretat de pickle
joblib és l’eina habitual per serialitzar models de scikit-learn. Internament fa servir pickle, el format de serialització de Python, que té un risc important: carregar un fitxer pickle pot executar codi arbitrari. Carrega només fitxers que has generat tu o que vénen d’una font de confiança.
Dues precaucions més:
- Fixa la versió de scikit-learn al
requirements.txt. Un model guardat amb una versió pot no carregar, o carregar amb avisos, amb una altra. - Comprova la càrrega: un test que carregui el fitxer i faci una predicció detecta el problema abans de desplegar.
Altres formats existeixen per a altres necessitats. SafeTensors guarda només els pesos d’una xarxa neuronal, sense codi executable, i és el format habitual dels models de Hugging Face. ONNX és un format portable que permet executar un model des d’altres llenguatges. Per a un model de scikit-learn, joblib és suficient.
Predicció online i batch
Hi ha dues maneres de servir prediccions, i la tria depèn de qui espera la resposta.
La predicció online respon cada petició a l’instant: el model és carregat en memòria i l’usuari espera uns mil·lisegons. Exemples: detectar frau en una transacció, valorar un cotxe quan l’usuari omple un formulari.
sequenceDiagram
participant U as Usuari
participant A as API
participant M as Model
U->>A: Petició amb dades
A->>M: Features
M->>A: Predicció
A->>U: Resposta (ms)
La predicció batch (per lots) calcula moltes prediccions de cop, normalment de manera programada, i les guarda en una base de dades. L’aplicació les consulta després. Exemples: puntuacions de risc calculades cada nit, recomanacions precalculades per enviar per correu.
sequenceDiagram
participant S as Script batch
participant M as Model
participant DB as Base de dades
participant App as Aplicació
Note over S,DB: Cada nit
S->>M: Totes les dades
M->>S: Prediccions
S->>DB: Guardar
Note over App,DB: Més tard
App->>DB: Consultar predicció
DB->>App: Predicció precalculada
| Online | Batch | |
|---|---|---|
| Latència | Mil·lisegons | Minuts o hores |
| Volum per execució | Una o poques prediccions | Milers o milions |
| Infraestructura | Servei sempre actiu | S’executa i s’atura |
| Resultat | Es retorna a l’instant | Es guarda per consultar-lo |
Molts sistemes combinen les dues: una botiga en línia precalcula cada nit les recomanacions generals (batch) i calcula a l’instant les que depenen del que l’usuari mira ara (online). La resta d’aquest document se centra en la predicció online.
Servir el model amb FastAPI
Perquè altres aplicacions puguin demanar prediccions, el model s’exposa amb una API REST: unes URL (endpoints) que reben peticions HTTP i retornen JSON. FastAPI és el framework habitual en Python: valida l’entrada a partir de tipus i genera la documentació interactiva a /docs automàticament.
# app/api.py
import json
from contextlib import asynccontextmanager
from pathlib import Path
import joblib
import pandas as pd
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from app.config import settings
state = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# Es carrega un cop en arrencar. Si el fitxer no hi és, el servei no arrenca.
state["model"] = joblib.load(settings.MODEL_PATH)
state["version"] = json.loads(Path(settings.METADATA_PATH).read_text())["version"]
yield
state.clear()
app = FastAPI(title="Car price API", lifespan=lifespan)
class CarFeatures(BaseModel):
year: int = Field(ge=1950, le=2030)
odometer: float = Field(ge=0)
manufacturer: str
fuel: str
transmission: str
@app.post("/predict")
def predict(car: CarFeatures):
if "model" not in state:
raise HTTPException(status_code=503, detail="Model not loaded")
row = pd.DataFrame([car.model_dump()])
price = float(state["model"].predict(row)[0])
return {"price": price, "model_version": state["version"]}
@app.get("/health")
def health():
return {"status": "healthy", "model_loaded": "model" in state}
Les peces:
lifespancarrega el model una sola vegada, en arrencar, i no a cada petició. Aquí, si el fitxer no hi és, el servei no arrenca: el problema es veu en desplegar i no quan arriba el primer usuari. L’alternativa és arrencar igualment i respondre503fins que el model estigui disponible, útil quan el model es baixa d’un servei extern i pot trigar.CarFeaturesés l’esquema d’entrada, definit amb Pydantic. Una petició ambodometer: -5es rebutja automàticament amb un422abans d’arribar al model.503és la xarxa de seguretat si, tot i així, el model no és carregat./healthdiu si el servei pot atendre peticions. El fan servir Docker i el pipeline de CI/CD (vegeu Health checks).model_versiona cada resposta permet saber quina versió ha fet cada predicció.
Per executar-la en local: uvicorn app.api:app --reload.
Empaquetar amb Docker
L’API depèn d’una versió concreta de Python, de scikit-learn i del fitxer del model. Un contenidor ho empaqueta tot junt perquè s’executi igual a qualsevol màquina. Amb Docker, una imatge és la plantilla (el que es construeix) i un contenidor és una imatge en execució.
El Dockerfile
El Dockerfile descriu com construir la imatge:
FROM python:3.13-slim
WORKDIR /app
# curl per al health check
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Primer les dependències: aquesta capa es reaprofita mentre només canviï el codi
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Codi i artefactes: el model viatja dins de la imatge
COPY app/ ./app/
COPY models/ ./models/
COPY metadata/ ./metadata/
COPY config/ ./config/
COPY tests/ ./tests/
RUN mkdir -p /app/data
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl --fail http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.api:app", "--host", "0.0.0.0", "--port", "8000"]
L’ordre de les capes importa. Docker reaprofita cada instrucció si no ha canviat. Copiant requirements.txt abans que el codi, canviar una línia de codi no torna a instal·lar totes les dependències.
En aquest exemple els tests també es copien a la imatge, perquè el CI els pugui executar dins de la mateixa imatge que es desplegarà. Molts projectes, en canvi, els executen abans de construir-la i en deixen fora els tests per fer-la més petita.
On viu el model
Hi ha tres maneres habituals de fer arribar el model al contenidor:
| Opció | Com | Avantatge | Inconvenient |
|---|---|---|---|
| Dins de la imatge | COPY models/ al Dockerfile | Cada imatge porta exactament un model; el que s’ha provat és el que es desplega | Canviar de model vol dir construir una imatge nova |
| Volum muntat | El fitxer és al servidor i es munta al contenidor | Es pot canviar el model sense reconstruir | La imatge sola no diu quin model executa; cal controlar el fitxer del servidor |
| Descarregat en arrencar | El servei el baixa d’un registre de models o d’un emmagatzematge d’objectes | Imatges petites; el mateix codi serveix models diferents | Depèn d’un servei extern per arrencar |
Per a un projecte petit amb un sol model, posar-lo dins de la imatge és el més senzill i el més fàcil de reproduir. Quan els models canvien sovint o pesen molt, les altres opcions guanyen pes.
Dependències fixades
Per reproduir la imatge, les dependències han de tenir versions fixades:
fastapi==0.139.0
scikit-learn==1.9.0 # ha de coincidir amb la versió que va entrenar el model
pandas==3.0.3
Hi ha dues maneres de fer-ho:
- Fixar-ho tot a mà en un
requirements.txtamb versions exactes (==). És senzill, però les dependències de les dependències queden sense fixar. - Un fitxer de bloqueig (lock file): escrius només les dependències directes i una eina com
pip-tools,uvo Poetry genera la llista completa amb totes les versions fixades.
En tots dos casos, la versió de scikit-learn ha de coincidir amb la de l’entrenament.
Docker Compose
Docker Compose defineix com s’executa el contenidor en un fitxer YAML, en lloc d’una ordre docker run llarga:
# compose.yml
services:
api:
build: .
ports:
- "${PORT:-8080}:8000" # port de l'amfitrió -> port del contenidor
env_file:
- .env
volumes:
- data:/app/data # dades de l'aplicació (registre de prediccions)
restart: unless-stopped # reinicia després d'una fallada o d'un reinici del servidor
volumes:
data:
Ordres habituals: docker compose build, docker compose up -d, docker compose logs -f api, docker compose down.
Un contenidor es pot esborrar i recrear en qualsevol moment, així que cal decidir què passa amb el que escriu:
- Els logs del servei van preferentment a la sortida estàndard (stdout): Docker els recull i es consulten amb
docker compose logs. Un fitxer de log dins del contenidor es perdria en recrear-lo (vegeu Logs). - Les dades que han de durar, com el registre de prediccions, necessiten un lloc fora del contenidor: un volum de Docker (com el
data:de l’exemple) o una base de dades. Un volum és suficient per a un sol servidor; una base de dades és el pas natural quan hi ha diverses instàncies o cal fer consultes.
Configuració per entorn
El mateix codi s’executa en diversos entorns: l’ordinador de desenvolupament, un entorn de proves i producció. El que canvia entre ells (rutes, ports, la URL d’un servei extern, claus d’API) no va al codi sinó a la configuració, normalment variables d’entorn (vegeu Config).
Dues regles valen sempre:
- Els secrets no van mai al repositori. Una clau d’API es passa a l’entorn des d’un fitxer local ignorat per git, des del gestor de secrets de la plataforma de CI/CD o des d’un servei com Vault.
- El codi no pregunta en quin entorn és. No hi ha
if env == "production": cada entorn simplement rep valors diferents.
Una manera habitual d’organitzar-ho és un fitxer .env per entorn, amb un fitxer d’exemple (.env.example) al repositori que documenta quines variables cal definir. Com es llegeix aquesta configuració des del codi es detalla a Configuration Management.
Versionat de models
Sense versions, no es pot respondre “quin model hi ha en producció?” ni tornar a l’anterior si el nou falla. Versionar un model vol dir tres coses: posar-li un nom únic, guardar-ne la informació que el descriu i decidir quina versió se serveix.
Nom i metadades
Cada entrenament produeix una versió nova, sense sobreescriure l’anterior. Els esquemes de nom habituals són:
- Un número creixent (
v1,v2,v3): el més senzill, suficient per a un projecte petit. - Versionat semàntic (
v1.2.0): el primer número canvia amb l’algorisme o les features, els altres amb reentrenaments o correccions. Comunica la mena de canvi. - Data o identificador del commit (
2026-10-01,a3f9c21): lliga el model amb el moment o el codi que l’ha produït.
Al costat de cada versió es guarden unes metadades: quan es va entrenar, amb quines features, amb quines mètriques i si compleix els criteris de desplegament.
{
"version": "v2",
"trained_at": "2026-10-01T09:31:21",
"features": ["year", "odometer", "manufacturer", "fuel", "transmission"],
"metrics": {"mae": 753.8, "r2": 0.987},
"approved": true
}
Com es decideix si una versió està aprovada es tracta a Criteris de desplegament.
Quina versió se serveix
Entrenar un model nou no hauria de canviar automàticament el que veuen els usuaris. Separar entrenar de promocionar (posar en producció) permet revisar el model abans. Maneres habituals de decidir quina versió se serveix:
| Mecanisme | Com | Adequat per a |
|---|---|---|
| Configuració | Una variable d’entorn, com MODEL_PATH=models/model_v2.pkl | Un sol servei; el canvi passa pel mateix pipeline que el codi |
| Àlies en un registre | Un registre de models marca una versió com production o champion | Diversos models i equips |
| La imatge | Cada imatge Docker porta el seu model; es desplega una etiqueta d’imatge concreta | Quan el model va dins de la imatge |
Tornar enrere (rollback) és el mateix mecanisme en sentit contrari: apuntar a la versió anterior, canviar l’àlies o desplegar la imatge anterior. Només funciona si les versions anteriors no s’han esborrat ni sobreescrit.
On es guarden els fitxers
Un fitxer .pkl pot ocupar desenes de megabytes, i git no està pensat per a fitxers binaris grans que canvien sovint. Les opcions habituals:
- DVC (Data Version Control): guarda els fitxers en un emmagatzematge extern i deixa a git només un fitxer petit que hi apunta. Amb
git checkoutd’un commit idvc pulles recupera el model exacte d’aquell commit. Serveix també per a datasets. - Git LFS: una extensió de git que fa el mateix per a fitxers grans, integrada a plataformes com GitHub i GitLab.
- Un registre de models com MLflow: un servei que guarda cada versió amb les seves mètriques i hi permet assignar àlies. Té sentit quan hi ha molts models o equips.
- Un emmagatzematge d’objectes (S3 o equivalent) amb una convenció de noms.
Les metadades, en canvi, són petites i es poden guardar a git: així es poden comparar entre versions i el pipeline les pot llegir sense baixar el model.
Resum
- MLOps aplica DevOps a sistemes amb dades i models; el pipeline és el producte.
- El production set simula dades noves i serveix de referència per detectar drift.
- Un
Pipelinede scikit-learn guarda model i preprocessament en un sol.pkl, sense diferències entre entrenament i servei. - FastAPI serveix el model; es carrega en arrencar i, si falta, el servei no arrenca.
- La imatge Docker empaqueta l’API; el model hi pot anar a dins, en un volum o descarregat en arrencar. Els logs van a stdout i les dades que han de durar, fora del contenidor.
- Cada entrenament crea una versió nova amb metadades; un mecanisme separat (configuració, àlies o imatge) tria quina se serveix, i tornar enrere és tornar a apuntar a l’anterior.
A Validació i CI/CD veiem com decidir automàticament si una versió pot arribar a producció.