El mòdul 4 va acabar amb una confessió incòmoda: tot el sistema de CineClick funciona, però el que ho lliga tot són mans. Els tests s'executen "si algú se'n recorda", i això vol dir que un git push un divendres a la tarda pot trencar features.py sense que ningú se n'adoni fins que el batch de dilluns produeixi prediccions absurdes. La integració contínua (CI) resol exactament això: cada canvi dispara automàticament una bateria de comprovacions, i el canvi no s'integra si alguna cosa falla. Però en un projecte de ML la CI clàssica es queda curta, perquè el comportament del sistema no depèn només del codi: depèn del codi, de les dades i del model. En aquesta lliçó construirem la CI real de cineclick-churn: ampliarem els tests de codi existents, afegirem validació de dades amb pandera, escriurem tests de model (de fum, de comportament i de llindar de mètriques) i ho lligarem tot amb un workflow de GitHub Actions.

Contingut

  1. Què canvia a la CI quan hi ha ML: tres coses a testejar
  2. La piràmide de tests per a ML
  3. Tests de codi: features i API
  4. Tests de dades: esquema i expectatives amb pandera
  5. Tests de model: fum, comportament i llindar de mètriques
  6. El workflow: .github/workflows/ci.yml explicat per blocs
  7. Què corre a cada esdeveniment

Què canvia a la CI quan hi ha ML: tres coses a testejar

En una aplicació web tradicional, CI significa: a cada push, executa el linter i els tests; si passen, el codi es pot integrar a main. La premissa implícita és que el codi és l'única font de comportament: si el codi no canvia, el sistema es comporta igual.

En ML aquesta premissa és falsa. El comportament del servei de churn de CineClick és el producte de tres ingredients, i qualsevol dels tres es pot degradar de manera independent:

  • Codi: features.py, train.py, l'API de FastAPI. Canvia quan la Laura o en Marc fan commits. Es testeja com sempre: tests unitaris i d'integració.
  • Dades: clients_churn.csv i les seves futures actualitzacions. Canvien quan l'equip de dades exporta una versió nova, encara que ningú no toqui ni una línia de codi. Un canvi d'esquema silenciós (una columna reanomenada, un pla nou, nuls on no n'hi havia) pot enverinar l'entrenament sense produir cap error de Python.
  • Model: l'artefacte registrat a MLflow. Un model reentrenat amb els mateixos hiperparàmetres pot ser pitjor que l'anterior si les dades han canviat, i pytest no té ni idea de què és un recall.

La conseqüència pràctica: la nostra CI necessita tres famílies de tests, no una. I cada família falla per motius diferents i protegeix contra riscos diferents:

Família Què valida Contra què protegeix Exemple a CineClick
Tests de codi Que les funcions fan el que diuen Bugs de programació clàssics ratio_tickets amb antiguitat 0 no divideix per zero
Tests de dades Que les dades compleixen el contracte esperat Canvis silenciosos a la font de dades Un pla nou ("familiar") que el one-hot no coneix
Tests de model Que el model es comporta raonablement i rendeix prou Models degradats o amb lògica invertida Un challenger amb recall 0.40 no es pot promocionar

La piràmide de tests per a ML

La piràmide clàssica de testing (molts tests unitaris barats a baix, pocs tests end-to-end cars a dalt) continua aplicant-se, però en ML s'hi afegeixen capes. Ordenades de més barates i freqüents a més cares i esporàdiques:

Nivell Tipus de test Cost Quan corre Necessita
1 Linter + unitaris de codi Segons Cada push Només el codi
2 Tests de l'API (validació, contractes) Segons Cada push Codi + un model fals o cap
3 Tests de dades (esquema, expectatives) Segons-minuts Cada PR i abans de cada entrenament Una mostra versionada de dades
4 Test de fum d'entrenament ~1 minut Cada PR que toca src/ Dades sintètiques
5 Tests de comportament del model Minuts PRs que toquen src/ o promocions Un model real (challenger)
6 Llindar de mètriques contra el test set Minuts Abans de promocionar un model Model real + test set versionat

La regla d'or: el que és barat corre sempre; el que és car corre quan aporta. No té sentit avaluar el recall del challenger a cada push a una branca de treball, però sí que és obligatori abans que aquest challenger aspiri a @champion.

Tests de codi: features i API

Ampliar tests/test_features.py amb casos límit

Al mòdul 3 vam decidir que features.py és l'única font de veritat de les features: la fan servir l'entrenament, l'API online i el scoring batch. Això la converteix en el fitxer més crític del repo — un bug aquí es propaga als tres consumidors alhora. Mereix els tests més exhaustius.

Recordem el detall fixat al seu dia: ratio_tickets es calcula com a tiquets de suport per mes d'antiguitat, amb clip(lower=1) al denominador per evitar la divisió per zero amb clients acabats d'arribar. Això és exactament el tipus de decisió que un company "simplifica" sense voler en un refactor. La blindem amb tests:

# tests/test_features.py (ampliació)
import pandas as pd
import pytest

from cineclick_churn.features import construir_features


def _client_base(**canvis) -> pd.DataFrame:
    """Retorna un client sintètic vàlid; els kwargs sobreescriuen camps."""
    base = {
        "id_client": "C-0001",
        "antiguitat_mesos": 12,
        "hores_setmana": 6.5,
        "tiquets_suport": 2,
        "pla": "premium",
        "metode_pagament": "targeta",
        "descompte_actiu": 0,
    }
    base.update(canvis)
    return pd.DataFrame([base])


def test_ratio_tickets_amb_antiguitat_zero_no_divideix_per_zero():
    """Un client al seu primer mes té antiguitat_mesos = 0.
    El clip(lower=1) ha de tractar el denominador com a 1, no petar."""
    df = construir_features(_client_base(antiguitat_mesos=0, tiquets_suport=3))
    assert df["ratio_tickets"].iloc[0] == pytest.approx(3.0)


def test_ratio_tickets_cas_normal():
    df = construir_features(_client_base(antiguitat_mesos=10, tiquets_suport=5))
    assert df["ratio_tickets"].iloc[0] == pytest.approx(0.5)


def test_one_hot_categoria_desconeguda_no_trenca_ni_desalinea():
    """Si demà màrqueting llança el pla 'familiar', el one-hot no es pot
    inventar una columna nova (desalinearia el model entrenat):
    ha de produir les MATEIXES columnes, amb els plans coneguts a 0."""
    df = construir_features(_client_base(pla="familiar"))
    columnes_pla = [c for c in df.columns if c.startswith("pla_")]
    assert sorted(columnes_pla) == ["pla_basic", "pla_estandard", "pla_premium"]
    assert df[columnes_pla].sum(axis=1).iloc[0] == 0

Punts que convé entendre bé:

  • _client_base com a fàbrica de fixtures: en lloc de repetir un diccionari de 7 camps a cada test, se'n defineix un de vàlid i cada test canvia només el que és rellevant. Els tests es llegeixen com frases: "un client amb antiguitat 0 i 3 tiquets té ràtio 3".
  • El test del one-hot codifica una decisió de disseny, no només un càlcul: construir_features ha de fer servir una llista fixa de categories conegudes (per exemple pd.Categorical amb categories explícites), perquè pd.get_dummies sense més genera columnes segons el que veu, i un pla nou en producció produiria un DataFrame amb columnes diferents de les de l'entrenament — el model fallaria o, pitjor, prediria sobre columnes desordenades.
  • Els docstrings expliquen el perquè. Quan aquest test falli d'aquí a un any, qui el llegeixi sabrà què protegeix.

Tests de l'API: tests/test_api.py

L'API ja té el seu test de validació del mòdul 4: comprovar que una petició mal formada retorna 422 sense invocar el model. Aquest test és or a la CI perquè no necessita MLflow ni artefactes — corre en segons a qualsevol runner:

# tests/test_api.py (recordatori de la peça clau)
from fastapi.testclient import TestClient

from cineclick_churn.api.main import app

client = TestClient(app)


def test_peticio_invalida_retorna_422():
    """hores_setmana negatives violen l'esquema Pydantic: FastAPI talla
    a la validació i el model ni es carrega ni s'invoca."""
    resposta = client.post("/predir", json={
        "id_client": "C-0001",
        "antiguitat_mesos": 12,
        "hores_setmana": -3,          # invàlid
        "tiquets_suport": 2,
        "pla": "premium",
        "metode_pagament": "targeta",
        "descompte_actiu": 0,
    })
    assert resposta.status_code == 422

A la CI aquest test valida el contracte d'entrada del servei: els esquemes de schemas.py són la frontera entre el món exterior i el model, i qualsevol relaxació accidental (algú treu una restricció de Pydantic) es detecta aquí.

Tests de dades: esquema i expectatives amb pandera

Per què no n'hi ha prou amb asserts a mà

Es pot validar un DataFrame amb asserts solts (assert df["pla"].isin(...)), i per a casos trivials és suficient. Però un esquema declaratiu amb pandera aporta tres coses que els asserts no donen: tots els errors de cop (no només el primer), un informe llegible de què ha fallat i a quines files, i un objecte esquema que es pot importar des de qualsevol punt (CI, entrenament, scoring batch) — de nou, una única font de veritat, aquesta vegada del contracte de dades.

L'esquema de clients_churn.csv

El definim al paquet, no als tests, precisament per poder-lo reutilitzar:

# src/cineclick_churn/validacio.py
import pandera as pa
from pandera import Check, Column

PLANS_VALIDS = ["basic", "estandard", "premium"]
METODES_PAGAMENT_VALIDS = ["targeta", "domiciliacio", "paypal"]

esquema_clients = pa.DataFrameSchema(
    columns={
        "id_client": Column(str, Check.str_matches(r"^C-\d{4,}$"), unique=True),
        "antiguitat_mesos": Column(int, Check.in_range(0, 240)),
        "hores_setmana": Column(float, Check.in_range(0, 168), nullable=True),
        "tiquets_suport": Column(int, Check.ge(0)),
        "pla": Column(str, Check.isin(PLANS_VALIDS)),
        "metode_pagament": Column(str, Check.isin(METODES_PAGAMENT_VALIDS)),
        "descompte_actiu": Column(int, Check.isin([0, 1])),
        "abandonament": Column(int, Check.isin([0, 1])),
    },
    checks=[
        # Expectatives sobre el dataset complet, no columna a columna:
        Check(lambda df: df["hores_setmana"].isna().mean() < 0.05,
              error="mes del 5% de nuls a hores_setmana"),
        Check(lambda df: df["abandonament"].mean().round(2) <= 0.30,
              error="taxa de churn sospitosament alta (>30%): revisar l'extraccio"),
    ],
    strict=True,   # columnes de mes tambe son un error
    coerce=True,   # intenta convertir tipus abans de fallar
)

Desglossament de les decisions:

  • Tipus i rangs: antiguitat_mesos entre 0 i 240 (20 anys; CineClick no existia abans), hores_setmana entre 0 i 168 (no hi ha més hores en una setmana). Els rangs impossibles són la trampa més habitual d'una exportació trencada.
  • Categories tancades: pla i metode_pagament només admeten els valors coneguts. Si apareix un pla nou legítim, aquest check ha de fallar: és el senyal per actualitzar features.py, reentrenar i actualitzar l'esquema, en aquest ordre i a consciència — no en silenci.
  • Proporció de nuls: hores_setmana admet nuls (hi ha clients sense telemetria), però si superen el 5% alguna cosa va malament a la instrumentació. És una expectativa estadística, no una regla per fila.
  • Taxa de churn ≤ 30%: el dataset històric volta el 15%. Si de sobte arriba un 45%, el més probable no és una estampida de clients sinó un filtre trencat a l'extracció. Validar la forma de la variable objectiu evita entrenar amb escombraries.
  • strict=True converteix "algú ha afegit una columna" en un error visible en lloc d'una sorpresa.

Validar és una línia: esquema_clients.validate(df, lazy=True) (amb lazy=True pandera acumula tots els errors en un sol informe en lloc d'aturar-se al primer).

Quan corren els tests de dades

Aquí hi ha una subtilesa important: les dades completes no són al repo (són al remote magatzem de DVC), i la CI no hauria de descarregar gigabytes a cada PR. La solució té dues potes:

  1. A la CI: es valida una mostra versionada (data/mostres/clients_churn_mostra.csv, unes 2.000 files, trackejada amb DVC igual que el dataset complet). Detecta canvis d'esquema i de contracte amb cost mínim. El workflow fa dvc pull només d'aquesta mostra.
  2. Abans de cada entrenament: la primera acció de l'stage preparar_dades del dvc.yaml (i, com veurem a 05-03, la segona task del flow de scoring) és validar el dataset complet amb el mateix esquema_clients. Mateix esquema, dues ubicacions: la CI vigila el contracte; el pipeline vigila les dades reals del dia.

Tests de model: fum, comportament i llindar de mètriques

Test de fum d'entrenament

El test més rendible de tota aquesta lliçó: comprovar que el pipeline d'entrenament funciona de principi a fi amb dades sintètiques diminutes. No valida qualitat — valida que ningú no ha trencat la cadena dades → features → fit → predict:

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

from cineclick_churn.features import construir_features
from cineclick_churn.train import entrenar_model

RNG = np.random.default_rng(42)


def _dades_sintetiques(n: int = 200) -> pd.DataFrame:
    """200 files amb la mateixa pinta que clients_churn.csv. Fictícies:
    generades a l'atzar amb distribucions plausibles."""
    return pd.DataFrame({
        "id_client": [f"C-{i:04d}" for i in range(n)],
        "antiguitat_mesos": RNG.integers(0, 60, n),
        "hores_setmana": RNG.uniform(0, 30, n).round(1),
        "tiquets_suport": RNG.poisson(1.5, n),
        "pla": RNG.choice(["basic", "estandard", "premium"], n),
        "metode_pagament": RNG.choice(["targeta", "domiciliacio", "paypal"], n),
        "descompte_actiu": RNG.integers(0, 2, n),
        "abandonament": RNG.choice([0, 1], n, p=[0.85, 0.15]),
    })


def test_fum_entrenament_produeix_model_que_prediu():
    """Entrena amb 200 files sintètiques en segons i comprova que el
    resultat és un model capaç d'emetre probabilitats vàlides."""
    df = _dades_sintetiques()
    X = construir_features(df.drop(columns=["abandonament"]))
    model = entrenar_model(X, df["abandonament"], n_estimators=10, max_depth=3)
    probas = model.predict_proba(X)[:, 1]
    assert probas.shape == (200,)
    assert ((probas >= 0) & (probas <= 1)).all()

Fixa't en els hiperparàmetres nans (n_estimators=10): no volem el model de producció, volem velocitat. Aquest test triga segons i hauria caçat la meitat dels "funciona a la meva màquina" del mòdul 1.

Tests de comportament: invariància i direccionalitat

Aquesta idea ve del paper CheckList (Ribeiro et al., 2020), pensat originalment per a models de NLP, i la seva intuïció es trasllada perfectament al tabular: a més de preguntar "quina mètrica treu el model?", cal preguntar "es comporta el model segons el que sabem del problema?". Un model pot tenir bon recall global i tot i així haver après una relació absurda que petarà amb dades futures. Dos tipus de check:

  • Invariància: hi ha entrades que no haurien d'afectar la predicció. id_client és un identificador arbitrari; si canviar-lo canvia la probabilitat de churn, el model (o més probablement features.py) està filtrant l'identificador com a feature — un bug greu i silenciós.
  • Direccionalitat: hi ha canvis el sentit dels quals coneixem encara que no en sapiguem la magnitud. Un client idèntic amb més tiquets_suport està, com a mínim, igual d'insatisfet: la seva probabilitat de churn no hauria de baixar. Si baixa, o les features estan mal construïdes o el model va aprendre soroll en aquella zona.
def test_invariancia_id_client(model_challenger):
    """Canviar només l'id_client no pot moure la predicció."""
    a = _client_base(id_client="C-0001")
    b = _client_base(id_client="C-9999")
    p_a = model_challenger.predict_proba(construir_features(a))[0, 1]
    p_b = model_challenger.predict_proba(construir_features(b))[0, 1]
    assert p_a == pytest.approx(p_b)


def test_direccionalitat_tiquets_suport(model_challenger):
    """Més tiquets de suport en un client tipus => probabilitat de churn
    igual o més gran. Tolerem el soroll mínim del bosc (-0.02)."""
    pocs = _client_base(tiquets_suport=1)
    molts = _client_base(tiquets_suport=8)
    p_pocs = model_challenger.predict_proba(construir_features(pocs))[0, 1]
    p_molts = model_challenger.predict_proba(construir_features(molts))[0, 1]
    assert p_molts >= p_pocs - 0.02

El fixture model_challenger carrega models:/churn-cineclick@challenger des del registry (necessita MLFLOW_TRACKING_URI, per això aquests tests porten la marca @pytest.mark.model i no corren a qualsevol push). Fixa't en la tolerància -0.02 a la direccionalitat: un RandomForest no és monòton per construcció i exigir monotonicitat estricta en un punt donaria falsos positius; el que cacem és una inversió clara de la relació.

Llindar mínim de mètriques: el criteri de negoci fet test

Al mòdul 3 el criteri de promoció va quedar escrit en prosa: "precision ≥ 0.50 si recall > 0.60". La prosa no bloqueja merges; els tests sí. El convertim en el gate final, avaluant el challenger contra el test set versionat (data/processed/test.csv, l'split amb test_size=0.2 i random_state=42 de params.yaml, trackejat per DVC — si el test set canviés a cada execució, les mètriques no serien comparables entre candidats):

from sklearn.metrics import precision_score, recall_score

RECALL_MINIM = 0.60
PRECISIO_MINIMA = 0.50


@pytest.mark.model
def test_llindar_metriques_challenger(model_challenger, test_set_versionat):
    X, y = test_set_versionat
    pred = model_challenger.predict(construir_features(X))
    recall = recall_score(y, pred)
    precision = precision_score(y, pred)
    assert recall >= RECALL_MINIM, f"recall {recall:.3f} < {RECALL_MINIM}"
    assert precision >= PRECISIO_MINIMA, f"precision {precision:.3f} < {PRECISIO_MINIMA}"

Com a referència: el champion actual (v2) dona recall 0.68 i precision 0.55, així que passa amb marge. Un challenger que no passi aquest test no arriba ni a la conversa de promoció — la revisió humana del mòdul 3 continua existint (la reprenem a 05-02), però ara revisa només candidats que ja han superat el llistó automàtic.

El workflow: .github/workflows/ci.yml explicat per blocs

Tot l'anterior necessita un motor que ho executi sol. Aquest és el workflow real del repo:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches-ignore: [main]     # branques de treball
  pull_request:
    branches: [main]            # la porta d'entrada a main

jobs:
  lint-i-tests-rapids:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: pip                        # caceja ~/.cache/pip entre execucions

      - name: Instal·lar dependències bloquejades
        run: |
          pip install -r requirements.lock
          pip install -e .

      - name: Linter
        run: ruff check src tests

      - name: Tests ràpids (codi i API)
        run: pytest tests -m "not model" -q

  tests-dades:
    needs: lint-i-tests-rapids
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.11", cache: pip }
      - run: pip install -r requirements.lock && pip install -e .

      - name: Descarregar la mostra versionada
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.DVC_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.DVC_SECRET_ACCESS_KEY }}
        run: dvc pull data/mostres/clients_churn_mostra.csv

      - name: Validar esquema i expectatives
        run: pytest tests/test_dades.py -q

  tests-model:
    needs: tests-dades
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: dorny/paths-filter@v3
        id: canvis
        with:
          filters: |
            src:
              - 'src/**'

      - uses: actions/setup-python@v5
        if: steps.canvis.outputs.src == 'true' || contains(github.event.pull_request.labels.*.name, 'tests-model')
        with: { python-version: "3.11", cache: pip }

      - name: Tests de model (fum, comportament, llindars)
        if: steps.canvis.outputs.src == 'true' || contains(github.event.pull_request.labels.*.name, 'tests-model')
        env:
          MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
          AWS_ACCESS_KEY_ID: ${{ secrets.DVC_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.DVC_SECRET_ACCESS_KEY }}
        run: |
          pip install -r requirements.lock && pip install -e .
          dvc pull data/processed/test.csv
          pytest tests/test_model.py -m model -q

Bloc a bloc:

  • on: — dos disparadors amb intencions diferents. push a branques de treball dona feedback ràpid mentre desenvolupes; pull_request cap a main és la porta amb tots els controls.
  • actions/checkout@v4 — clona el repo al runner. Sense això no hi ha res a testejar.
  • setup-python amb cache: pip — instal·la Python 3.11 (la versió fixada al mòdul 2) i caceja els paquets descarregats; la segona execució instal·la en segons.
  • pip install -r requirements.lock — instal·lem des del lock de pip-tools, no des de dependències soltes: el runner reprodueix exactament l'entorn de producció (scikit-learn 1.5.2, pandas 2.2.3, FastAPI 0.115.5). Una CI amb versions flotants és una CI que menteix.
  • ruff + pytest -m "not model" — linter i tots els tests que no requereixen ni registry ni dades. El marcador model (declarat a pyproject.toml) separa el que és barat del que és car.
  • dvc pull amb credencials a secrets — les credencials del remote magatzem viuen als secrets del repositori de GitHub (Settings → Secrets and variables → Actions), mai al YAML ni al codi. El workflow les injecta com a variables d'entorn només al pas que les necessita.
  • dorny/paths-filter + etiqueta — els tests de model només corren si la PR toca src/** o si algú posa l'etiqueta tests-model (útil per forçar-los en una PR de dades o de configuració). És el mecanisme que implementa "el que és car corre quan aporta".
  • needs: — encadena els jobs: si el linter falla, no gastem temps (ni minuts de runner) a descarregar dades.

Què corre a cada esdeveniment

Comprovació push a branca de treball PR cap a main
ruff (linter)
Tests de codi (features, API)
Tests de dades (pandera sobre la mostra) No
Test de fum d'entrenament No Sí, si toca src/ o porta etiqueta
Tests de comportament del model No Sí, si toca src/ o porta etiqueta
Llindar de mètriques del challenger No Sí, si toca src/ o porta etiqueta

I una fila que falta expressament: construir i publicar la imatge Docker. Això ja no és integració sinó lliurament, i és exactament el tema de la propera lliçó.

Errors Comuns i Consells

  • Instal·lar a la CI amb pip install pandas scikit-learn a pèl. El runner resoldrà les versions del dia i els teus tests validaran un entorn que no és el de producció. Sempre des de requirements.lock.
  • Tests de comportament massa estrictes. Exigir monotonicitat exacta a un RandomForest produeix tests intermitents (flaky), i un test flaky acaba ignorat — cosa que és pitjor que no tenir-lo. Fes servir toleràncies petites i clients "tipus" representatius.
  • Validar les dades només a la CI. La mostra de la CI vigila el contracte, però les dades fresques de producció es validen al pipeline (stage preparar_dades i, des de 05-03, al flow de scoring). Si només valides la mostra, dilluns entrenaràs amb el que sigui que hagi arribat.
  • Posar les credencials de DVC al YAML "només per provar". Queden a l'historial de Git per sempre. Secrets de GitHub des del primer minut, i rota la clau si mai s'ha escapat.
  • Llindar de mètriques contra un test set que canvia. Si cada execució fa el seu propi split, dos candidats mai no es comparen sobre el mateix terreny. El test set és un artefacte versionat amb DVC, i punt.
  • Convertir la CI en un mur de 40 minuts. Si cada push triga mitja hora, la gent agrupa canvis enormes o busca com saltar-se-la. Protegeix el cicle ràpid (nivells 1-2 de la piràmide en menys de 5 minuts) i empeny el que és car cap a les PRs.
  • Consell: quan un bug arribi a producció malgrat la CI, la retrospectiva correcta no és "qui ha estat?" sinó "quin test faltava?". Afegeix aquell test al mateix fix. Així creix una suite que reflecteix les fallades reals del teu sistema.

Exercicis

Exercici 1

En Marc proposa afegir la feature dies_des_ultim_tiquet. Escriu dos tests unitaris per a ella a l'estil de test_features.py: (a) un client que mai no ha obert cap tiquet (el camp font data_ultim_tiquet és nul) ha de produir un valor sentinella de 9999, no un nul ni un error; (b) el valor mai no pot ser negatiu encara que arribi una data futura per error de dades.

Exercici 2

Amplia esquema_clients amb dues comprovacions noves: (a) una columna pais amb categories permeses ["ES", "PT", "AD"] i sense nuls; (b) una expectativa a nivell de dataset: cap client amb descompte_actiu = 1 no pot tenir pla = "basic" (el descompte només s'aplica als plans de pagament superiors). Escriu només el fragment d'esquema.

Exercici 3

El job tests-model triga 8 minuts i l'equip es queixa que les PRs de documentació (canvis només a docs/) també l'esperen. L'esperen de debò, amb el workflow tal com està escrit? Raona la resposta mirant el YAML i explica quin paper hi juga dorny/paths-filter.

Solucions

Exercici 1

def test_dies_des_ultim_tiquet_sense_tiquets_usa_sentinella():
    df = construir_features(_client_base(data_ultim_tiquet=None))
    assert df["dies_des_ultim_tiquet"].iloc[0] == 9999
    assert not df["dies_des_ultim_tiquet"].isna().any()


def test_dies_des_ultim_tiquet_mai_negatiu():
    """Una data futura (error de dades) no pot produir dies negatius:
    s'espera clip(lower=0) al càlcul."""
    df = construir_features(_client_base(data_ultim_tiquet="2099-01-01"))
    assert df["dies_des_ultim_tiquet"].iloc[0] >= 0

La clau de l'apartat (a) és el doble assert: no n'hi ha prou amb no fallar, cal comprovar el sentinella concret i l'absència de nuls (un nul aquí trencaria scikit-learn més tard, lluny de l'origen del problema). A (b), el test fixa la decisió de disseny (clip(lower=0)) igual que vam fer amb ratio_tickets.

Exercici 2

"pais": Column(str, Check.isin(["ES", "PT", "AD"]), nullable=False),

i a la llista checks de l'esquema:

Check(
    lambda df: ~((df["descompte_actiu"] == 1) & (df["pla"] == "basic")).any(),
    error="hi ha clients amb descompte actiu al pla basic",
),

La segona és una regla de negoci entre columnes: no es pot expressar com a check d'una sola columna, per això va als checks a nivell de DataFrame. Aquest tipus de regla és la que detecta corrupcions lògiques que els tipus i els rangs no veuen.

Exercici 3

No l'esperen (gairebé): el job arrenca, però dorny/paths-filter avalua els fitxers tocats per la PR i, com que docs/** no encaixa amb el filtre src/**, la sortida steps.canvis.outputs.src és 'false'. Tots els passos pesats porten if: sobre aquesta sortida (i sobre l'etiqueta tests-model), així que se salten: el job acaba en segons havent fet només el checkout i el filtre. El cost real d'una PR de docs és el job ràpid de lint i tests, que sí que corre sempre — i això és correcte: fins i tot una PR de docs pot trencar un import si toca un fitxer de més.

Conclusió

La CI de cineclick-churn ja no depèn de la memòria de ningú: cada push passa linter i tests de codi en minuts; cada PR cap a main valida a més el contracte de dades amb pandera sobre una mostra versionada; i quan el canvi toca src/, el candidat demostra que entrena (fum), que raona (invariància respecte a id_client, direccionalitat de tiquets_suport) i que rendeix (recall ≥ 0.60 i precision ≥ 0.50 contra el test set versionat — el criteri de negoci del mòdul 3 convertit en codi que bloqueja merges). La piràmide completa, amb el que és barat corrent sempre i el que és car corrent quan aporta. Però fixa't on acaba: en un tic verd. El codi validat continua sense arribar a producció tot sol — la imatge encara es construeix en un portàtil i el kubectl apply encara es tecleja. La lliçó següent tanca aquest forat amb el lliurament continu, i amb una particularitat que és ML pur: a CineClick no hi ha un pipeline de desplegament, n'hi ha dos — un per al servei i un altre per al model — i confondre'ls surt car.

© Copyright 2026. Tots els drets reservats