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
- Què canvia a la CI quan hi ha ML: tres coses a testejar
- La piràmide de tests per a ML
- Tests de codi: features i API
- Tests de dades: esquema i expectatives amb pandera
- Tests de model: fum, comportament i llindar de mètriques
- El workflow:
.github/workflows/ci.ymlexplicat per blocs - 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.csvi 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
pytestno 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] == 0Punts que convé entendre bé:
_client_basecom 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_featuresha de fer servir una llista fixa de categories conegudes (per exemplepd.Categoricalamb categories explícites), perquèpd.get_dummiessense 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 == 422A 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_mesosentre 0 i 240 (20 anys; CineClick no existia abans),hores_setmanaentre 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:
plaimetode_pagamentnomés admeten els valors coneguts. Si apareix un pla nou legítim, aquest check ha de fallar: és el senyal per actualitzarfeatures.py, reentrenar i actualitzar l'esquema, en aquest ordre i a consciència — no en silenci. - Proporció de nuls:
hores_setmanaadmet 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=Trueconverteix "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:
- 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 fadvc pullnomés d'aquesta mostra. - Abans de cada entrenament: la primera acció de l'stage
preparar_dadesdeldvc.yaml(i, com veurem a 05-03, la segona task del flow de scoring) és validar el dataset complet amb el mateixesquema_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 probablementfeatures.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_suportestà, 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.02El 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 -qBloc a bloc:
on:— dos disparadors amb intencions diferents.pusha branques de treball dona feedback ràpid mentre desenvolupes;pull_requestcap amainés la porta amb tots els controls.actions/checkout@v4— clona el repo al runner. Sense això no hi ha res a testejar.setup-pythonambcache: 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 marcadormodel(declarat apyproject.toml) separa el que és barat del que és car.dvc pullamb credencials asecrets— les credencials del remotemagatzemviuen 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 tocasrc/**o si algú posa l'etiquetatests-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) | Sí | Sí |
| Tests de codi (features, API) | Sí | Sí |
| Tests de dades (pandera sobre la mostra) | No | Sí |
| 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-learna pèl. El runner resoldrà les versions del dia i els teus tests validaran un entorn que no és el de producció. Sempre des derequirements.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_dadesi, 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] >= 0La 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
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.
Curs de MLOps
Mòdul 1: Fonaments de MLOps
- Què és MLOps i per què els models moren al notebook
- El cicle de vida d'un model de ML en producció
- Nivells de maduresa MLOps i rols de l'equip
- El projecte del curs: del notebook a producció
Mòdul 2: Del notebook al codi reproduïble
- Estructura d'un projecte de ML: del notebook al paquet
- Entorns reproduïbles i gestió de dependències
- Versionat de dades amb DVC
- Pipelines d'entrenament reproduïbles
Mòdul 3: Experiments i registre de models
- Tracking d'experiments amb MLflow
- Model registry: versionar i promocionar models
- Feature stores: quan i per a què
Mòdul 4: Servir models en producció
- Patrons de desplegament: batch, online i streaming
- Un servei de predicció amb FastAPI
- Empaquetatge amb Docker
- Escalat i desplegament: Kubernetes i serverless
- Optimització de la inferència: latència i cost
Mòdul 5: Automatització: CI/CD i orquestració
- CI per a ML: tests de codi, dades i models
- CD: automatitzar el desplegament del model
- Orquestració de pipelines de ML
- Estratègies de release: shadow, canary i A/B
