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

Validació i CI/CD

Al document Del model al servei hem empaquetat un model en una imatge Docker que serveix prediccions. Ara cal decidir quan aquesta imatge pot arribar a producció. La resposta té tres parts: uns criteris objectius que diuen si el model és prou bo, uns tests que diuen si el codi funciona, i un pipeline de CI/CD que ho comprova tot automàticament a cada git push.

Criteris de desplegament

Sense criteris escrits, la decisió de desplegar és una opinió: “un error mitjà de 900 € és bo?”, “el model nou és millor que l’anterior?”. Els criteris es fixen abans d’entrenar i es guarden en un fitxer versionat, perquè ningú els ajusti a posteriori per fer passar un model.

Tres tipus de criteris

  • Tècnics: el rendiment del model sobre el validation set, amb les mètriques de Avaluació de models. En regressió, l’error mitjà (MAE, RMSE) i la variància explicada (R²). En classificació, el recall, la precisió o l’F1.
  • De negoci: el cost real dels errors. En un diagnòstic mèdic, un fals negatiu (malaltia no detectada) pot costar vint vegades més que un fals positiu (una prova innecessària), i el criteri ho ha de reflectir.
  • Operacionals: el que l’usuari nota del servei. La latència (temps de resposta) es mesura en percentils: el p95 és el temps per sota del qual acaben el 95% de les peticions. Si el p50 és de 40 ms però el p99 és de 2 s, una de cada cent peticions va molt lenta encara que la mitjana sembli bona.

La baseline

Un llindar només té sentit comparat amb alguna cosa. La idea de rendiment de referència ens serveix aquí per fixar el mínim. La baseline més senzilla és el resultat d’una estratègia trivial: si el model no la supera clarament, no aporta res.

from sklearn.dummy import DummyRegressor
from sklearn.metrics import mean_absolute_error

baseline = DummyRegressor(strategy="median").fit(X_train, y_train)
baseline_mae = mean_absolute_error(y_val, baseline.predict(X_val))
# baseline_mae = 6200: predir sempre la mediana del preu s'equivoca 6200 de mitjana

Si el model nou fa un MAE de 750, el millora molt. Si en fa 5.800, gairebé no aprèn res de les features. Un cop hi ha un model en producció, aquest passa a ser la baseline: el candidat no hi pot ser pitjor.

Criteris en un fitxer

Els llindars es guarden en un fitxer versionat amb el codi, per exemple en YAML:

# config/criteria.yaml
min_r2: 0.70       # explica almenys el 70% de la variància del preu
max_mae: 2500.0    # error absolut mitjà per sota de 2500

Una funció compara les mètriques del model amb cada llindar i retorna què falla:

def check_criteria(metrics: dict, criteria: dict) -> list[str]:
    """Retorna les comprovacions que fallen; una llista buida vol dir que el model passa."""
    failed = []
    if metrics["r2"] < criteria["min_r2"]:
        failed.append(f"r2 {metrics['r2']:.3f} < {criteria['min_r2']}")
    if metrics["mae"] > criteria["max_mae"]:
        failed.append(f"mae {metrics['mae']:.0f} > {criteria['max_mae']}")
    return failed

El resultat d’aquesta comprovació es pot fer servir de dues maneres:

  • Com a codi de sortida d’un script (0 si passa, 1 si no): el pipeline de CI/CD l’executa i s’atura si falla.
  • Com un camp de les metadades del model ("approved": true): l’entrenament el calcula una vegada i el pipeline només l’ha de llegir.

En tots dos casos, la idea clau és la mateixa: un model no és desplegable per defecte, i només ho és si passa una comprovació automàtica contra criteris escrits abans. Un error habitual és un script d’entrenament que marca sempre el model com a aprovat: llavors la porta no protegeix res.

Testing i validació

En programari convencional, add(2, 3) ha de retornar 5 sempre. Un model, en canvi, pot donar prediccions diferents després de cada entrenament, i un model millor pot encertar uns casos i fallar-ne uns altres que l’anterior encertava. Per això cal separar dues activitats:

  • El testing comprova el codi que envolta el model: es carrega, valida l’entrada, retorna el format correcte. El resultat és determinista i s’executa a cada canvi amb pytest.
  • La validació mesura si el model és prou bo: mètriques sobre el validation set, comparades amb els criteris. El resultat varia entre entrenaments i s’executa en entrenar.
TestingValidació
Què verificaEl codi funcionaEl model és prou bo
ResultatDeterministaPot variar entre entrenaments
EinapytestMètriques i criteris YAML
QuanA cada commitEn entrenar
Si fallaHi ha un bugCal iterar el model

La regla pràctica: si el resultat pot canviar legítimament entre execucions, és validació, no un test. Un test com assert model.score(X, y) > 0.95 falla de tant en tant sense cap bug (un flaky test) i acaba ignorat.

Què cal testejar

Els tests verifiquen comportaments que han de ser certs per a qualsevol model ben construït, no valors concrets:

# tests/test_model.py
import numpy as np
import pandas as pd

ROW = {"year": 2015, "odometer": 80000, "manufacturer": "toyota",
       "fuel": "gas", "transmission": "automatic"}

def test_model_predicts_positive_price(model):
    price = model.predict(pd.DataFrame([ROW]))[0]
    assert price > 0

def test_unseen_category_does_not_break_inference(model):
    # Un fabricant que no apareix a l'entrenament no ha de provocar cap error
    row = {**ROW, "manufacturer": "unknown-brand"}
    assert model.predict(pd.DataFrame([row]))[0] > 0

def test_missing_numeric_is_imputed(model):
    row = {**ROW, "odometer": np.nan}
    assert model.predict(pd.DataFrame([row]))[0] > 0

Els dos últims tests protegeixen decisions de disseny del pipeline (el OneHotEncoder(handle_unknown="ignore") i el SimpleImputer): si algú les treu, el test falla.

A l’API, els tests comproven els codis de resposta:

# tests/test_api.py
def test_predict_valid_input(client):
    response = client.post("/predict", json=ROW)
    assert response.status_code == 200
    assert response.json()["price"] > 0

def test_predict_rejects_negative_odometer(client):
    response = client.post("/predict", json={**ROW, "odometer": -5})
    assert response.status_code == 422  # rebutjat per l'esquema de Pydantic

def test_health(client):
    assert client.get("/health").json()["model_loaded"] is True

El que no s’ha de testejar:

  • Mètriques del model (accuracy, MAE): és validació.
  • Prediccions exactes (predict(x) == 14200): un model millor les canvia.
  • L’entrenament complet: triga minuts i els tests han de trigar segons.

Fixtures

Una fixture de pytest prepara un recurs que els tests reben pel nom del paràmetre. Es defineixen a tests/conftest.py, que pytest carrega automàticament.

# tests/conftest.py
import joblib
import pytest
from fastapi.testclient import TestClient

from app.api import app

@pytest.fixture(scope="session")
def model():
    return joblib.load("models/model_v1.pkl")

@pytest.fixture
def client(monkeypatch, tmp_path):
    # El registre de prediccions va a un fitxer temporal, no al real
    monkeypatch.setattr("app.config.settings.PREDICTIONS_LOG_PATH",
                        str(tmp_path / "predictions.jsonl"))
    with TestClient(app) as c:  # el "with" executa el lifespan, que carrega el model
        yield c

Tres detalls importants:

  • scope="session" carrega el model una sola vegada per a tots els tests.
  • with TestClient(app) executa el lifespan de l’API. Sense el with, el model no es carrega i tots els tests de l’API fallen.
  • tmp_path dona un directori temporal per test i monkeypatch canvia una configuració només durant el test. Junts eviten que els tests escriguin al log real.

Substituir una dependència externa

Si l’API crida un servei extern (un LLM, una altra API), els tests no l’han de cridar de debò: serien lents, costarien diners i fallarien quan el servei no respon. FastAPI permet substituir una dependència injectada per un fake amb app.dependency_overrides:

from app.api import app, get_llm

class FakeLLMClient:
    def extract(self, text: str) -> dict:
        return {"manufacturer": "toyota", "year": 2015}

@pytest.fixture(autouse=True)
def fake_llm():
    app.dependency_overrides[get_llm] = lambda: FakeLLMClient()
    yield
    app.dependency_overrides.clear()

Amb autouse=True, cap test fa crides reals. El patró de la dependència injectada (get_llm) es descriu a Dependency Injection.

Quan una etapa és un LLM

Fins aquí, el model és un fitxer: es prova amb tests i es valida amb mètriques sobre el validation set. Molts sistemes, però, combinen un model de ML amb un LLM. Per exemple, n’hi ha que encadenen dues etapes: un LLM extreu dades estructurades d’un text lliure, i un model de ML hi fa la predicció (és el patró LLM com a extractor de features). Per exemple, d’un anunci “Toyota Corolla del 2015, 80.000 km, automàtic” l’LLM extreu les features i el model en prediu el preu.

L’etapa LLM és no determinista i depèn d’un servei extern, així que es tracta en dos nivells:

  1. Tests amb l’LLM substituït: el FakeLLMClient de l’apartat anterior fa que els tests siguin deterministes i no necessitin xarxa ni clau. Comproven la frontera: què passa si l’LLM retorna JSON mal format, un any impossible o res de res.
  2. Una eval amb l’LLM real: s’executa l’extractor sobre un golden set, un conjunt de casos amb la sortida esperada escrita a mà (vegeu El conjunt de referència). El resultat és un informe amb les mètriques. L’eval es pot executar dins del pipeline, o bé fora, perquè necessita una clau d’API i costa diners, i guardar-ne l’informe perquè el pipeline només l’hagi de llegir.

L’informe alimenta una segona porta de qualitat, amb criteris propis d’un LLM:

  • Qualitat: quina fracció dels casos del golden set s’extreu correctament.
  • Latència: quant triga cada crida.
  • Cost: quant costa cada crida, segons els tokens consumits.

Cal decidir què vol dir “correcte”. Si l’extracció té onze camps, una mitjana per camp pot donar un 95% encara que la meitat dels anuncis tinguin algun camp malament; comptar els casos perfectes és més exigent. La mètrica s’ha de triar segons el que importa al model que ve després.

Com integrar les evals al CI quan el sistema és només un LLM (comparació amb una baseline, suites ràpides i completes) es tracta a Avaluació, dins de Sistemes LLM en producció.

CI/CD per a ML

Els conceptes generals de integració contínua, lliurament continu i git hooks es tracten a DevOps. Aquí veiem què hi afegeix un projecte de ML i com s’organitza el pipeline.

Què hi afegeix ML

FaseProgramari convencionalML hi afegeix
TestsUnitaris i d’integracióTests del model (càrrega, categories noves, valors absents)
Porta de qualitatTests en verdCriteris del model i, si n’hi ha, l’eval de l’LLM
ArtefacteImatge DockerEl model i les seves metadades, dins o fora de la imatge
Comprovació finalHealth checkUna predicció real d’extrem a extrem

Les fases d’un pipeline

Sigui quina sigui l’eina, un pipeline per a un servei de ML acostuma a tenir aquestes fases:

flowchart LR
    A[git push] --> B[Build<br/>imatge]
    B --> C[Tests]
    C --> D[Portes de<br/>qualitat]
    D --> E[Desplegar]
    E --> F[Smoke test]
  1. Build: construir la imatge Docker.
  2. Tests: executar pytest, millor dins de la imatge construïda, que és la que es desplegarà.
  3. Portes de qualitat: comprovar els criteris del model i, si cal, l’eval de l’LLM.
  4. Desplegar: arrencar la imatge nova a l’entorn de destinació.
  5. Smoke test (prova de fum): una comprovació ràpida que el servei funciona de debò, com cridar /health i fer una predicció real. Si falla, es torna a la versió anterior.

Cada fase és una ordre que acaba amb un codi de sortida: 0 vol dir èxit i qualsevol altre valor atura el pipeline. Aquesta és tota la interfície entre el projecte i l’eina de CI/CD.

Quan s’executa cada fase és una decisió de l’equip. Un esquema habitual és validar (fases 1 a 3) a cada push o pull request, i desplegar (fases 4 i 5) només des de la branca principal. Entre validar i desplegar hi pot haver una aprovació manual (continuous delivery) o cap (continuous deployment), vegeu Lliurament continu.

On s’executa

Les fases són les mateixes en qualsevol plataforma; canvia on es defineixen:

  • Plataformes de CI/CD (GitHub Actions, GitLab CI, Jenkins): un fitxer YAML al repositori descriu les fases i la plataforma les executa en màquines pròpies.
  • Git hooks en un servidor propi: un script que el servidor git executa en rebre un push (vegeu Git hooks). Més senzill d’entendre, però tota la infraestructura és teva.

Per exemple, amb GitHub Actions:

# .github/workflows/ci.yml
on: [push]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker compose build
      - run: docker compose run --rm api pytest tests/
      - run: docker compose run --rm api python -m app.check_model

L’últim pas és la porta de qualitat: un script que llegeix les metadades o recalcula les mètriques, les compara amb els criteris i retorna 1 si el model no passa.

Les mateixes ordres a tot arreu

Si el pipeline executa ordres que ningú pot executar a l’ordinador, els errors només es descobreixen al servidor. Per evitar-ho, les fases es defineixen com a ordres curtes que funcionen igual als dos llocs, sovint targets d’un Makefile (make build, make test, make check-model) o scripts d’un directori scripts/. El fitxer de CI/CD només les crida. Així, abans de fer push, pots executar exactament el mateix que executarà el servidor (vegeu Makefile).

Un hook pre-commit també pot passar els tests en local abans de cada commit, però qualsevol se’l pot saltar (git commit --no-verify): la validació que compta és la del servidor.

Construir un cop

Un principi útil: la imatge es construeix una sola vegada, a la fase de build, i s’etiqueta de manera única (per exemple, amb l’identificador del commit). El desplegament fa servir exactament aquesta imatge, sense tornar-la a construir.

Si es reconstruís en desplegar, podria sortir una imatge diferent de la que ha passat els tests: una dependència publicada entre mig, una capa de cache diferent. Construint un cop, el que s’ha provat és el que es desplega.

Resum

  • Els criteris de desplegament es fixen abans d’entrenar, en un fitxer versionat, i es comparen amb una baseline. Un model no és desplegable per defecte.
  • Els tests comproven el codi (determinista); la validació mesura el model (variable). Les dependències externes se substitueixen per fakes als tests.
  • Una etapa LLM afegeix una eval sobre un golden set, amb criteris de qualitat, latència i cost.
  • Un pipeline de CI/CD encadena build, tests, portes de qualitat, desplegament i smoke test. L’eina pot variar; les fases i els codis de sortida són els mateixos.
  • Les mateixes ordres a l’ordinador i al servidor, i una imatge construïda un cop.

El model ja és a producció. A Monitoratge i reentrenament veiem com saber si continua funcionant bé quan les dades canvien.

Referències

Last change: , commit: 2280835