Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Del model al servei

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àginaPregunta que respon
Del model al servei (aquesta)Com empaqueto i serveixo el model?
Validació i CI/CDQuan pot arribar a producció, i com ho automatitzo?
Monitoratge i reentrenamentContinua 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 .pkl o un metadata.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:

DevOpsMLOps
ArtefacteCodi compilatModel entrenat, que depèn del codi i de les dades
Porta de qualitatEls tests passenEls tests passen i les mètriques del model superen un llindar
Com es degradaUn bug provoca errors visiblesLes dades canvien i les prediccions empitjoren sense cap error
ReentrenamentNo existeixUna etapa habitual
VersionatCodiCodi, 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:

  1. Estimar el rendiment real: abans de desplegar, diu com funcionarà el model amb dades noves.
  2. 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:

  • SimpleImputer aprè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
OnlineBatch
LatènciaMil·lisegonsMinuts o hores
Volum per execucióUna o poques prediccionsMilers o milions
InfraestructuraServei sempre actiuS’executa i s’atura
ResultatEs retorna a l’instantEs 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:

  • lifespan carrega 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 respondre 503 fins 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ó amb odometer: -5 es rebutja automàticament amb un 422 abans d’arribar al model.
  • 503 és la xarxa de seguretat si, tot i així, el model no és carregat.
  • /health diu si el servei pot atendre peticions. El fan servir Docker i el pipeline de CI/CD (vegeu Health checks).
  • model_version a 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óComAvantatgeInconvenient
Dins de la imatgeCOPY models/ al DockerfileCada imatge porta exactament un model; el que s’ha provat és el que es desplegaCanviar de model vol dir construir una imatge nova
Volum muntatEl fitxer és al servidor i es munta al contenidorEs pot canviar el model sense reconstruirLa imatge sola no diu quin model executa; cal controlar el fitxer del servidor
Descarregat en arrencarEl servei el baixa d’un registre de models o d’un emmagatzematge d’objectesImatges petites; el mateix codi serveix models diferentsDepè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.txt amb 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, uv o 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:

MecanismeComAdequat per a
ConfiguracióUna variable d’entorn, com MODEL_PATH=models/model_v2.pklUn sol servei; el canvi passa pel mateix pipeline que el codi
Àlies en un registreUn registre de models marca una versió com production o championDiversos models i equips
La imatgeCada imatge Docker porta el seu model; es desplega una etiqueta d’imatge concretaQuan 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 checkout d’un commit i dvc pull es 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 Pipeline de 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ó.

Referències

Last change: , commit: 2280835