El playbook de la lliçó anterior acabava en una casella amb nom propi: "candidat a reentrenament → decisió sistemàtica". Aquesta lliçó construeix aquesta decisió i tot el que ve després. Veurem les tres polítiques de tret possibles i quina tria CineClick, materialitzarem per fi l'esbós de flows/reentrenament.py que vam deixar a 05-03 — resolent de passada el leakage temporal que vam anunciar a 03-03 —, i deixarem clar el principi que governa tot el disseny: el sistema reentrena i proposa, però mai no promociona sol. Al final, el cicle de vida que vam dibuixar a 01-02 quedarà tancat de debò, i CineClick haurà creuat al nivell 2 de maduresa.

Contingut

  1. Les tres polítiques de tret
  2. La política de CineClick
  3. El dataset nou: point-in-time correctness (el leakage de 03-03, resolt)
  4. El pipeline de reentrenament complet
  5. Avaluació: test congelat i test fresc
  6. Registrar com a challenger, mai com a champion
  7. Reentrenar no sempre arregla; i no és gratis
  8. El bucle tancat: CineClick assoleix el nivell 2

Les tres polítiques de tret

Quan s'ha de reentrenar un model? Hi ha tres famílies de resposta:

Política Com funciona Pros Contres
Programada periòdica Cada N setmanes/mesos, es reentrena passi el que passi Simple, predictible, pressupostable; cota superior a l'edat del model Reentrena sense necessitat (cost, risc, revisions inútils) o massa tard si el món canvia entre cicles
Per deriva / degradació Es dispara quan el detector de 06-02 o les mètriques reals de 06-01 creuen llindars Reacciona a la necessitat real; no gasta quan tot va bé Requereix el monitoratge ja construït; llindars mal calibrats = tempesta de reentrenaments o silenci etern
Per volum de dades noves Es dispara en acumular N etiquetes/files noves Garanteix que cada entrenament aporta prou informació fresca Ignora la urgència: pot acumular a poc a poc just quan el món canvia ràpid; N és un altre llindar per calibrar

No són excloents, i de fet la combinació programada + per degradació és el patró més comú a la indústria: la programada com a xarxa de seguretat ("el model mai no tindrà més de X d'antiguitat"), la de degradació com a reflex ràpid ("si alguna cosa va malament, no esperem el cicle").

La política de CineClick

CineClick adopta exactament aquesta combinació, amb números que ja coneixes — estan alineats amb els llindars de 06-01, 06-02 i amb el criteri de negoci que la CI converteix en bloqueig des de 05-01:

  • Tret programat: reentrenament mensual (primer dimarts de mes, després del scoring i la detecció de deriva del dilluns).
  • Tret extraordinari, si passa qualsevol d'aquestes coses:
    • PSI > 0.25 en alguna feature rellevant (el llindar de "canvi significatiu" de 06-02), un cop el playbook ha descartat error de dades aigües amunt;
    • recall real < 0.60 dues setmanes seguides al job de ground truth de 06-01 — el mateix 0.60 que és llindar bloquejant a la CI, perquè el criteri de negoci és un de sol, es mesuri on es mesuri. Es demanen dues setmanes per no reaccionar al soroll d'una setmana fluixa.

El tret extraordinari no és automàtic al 100%: l'alerta arriba a #alertes-ml, un humà executa el playbook de 06-02 (bug aigües amunt? cohort legítima?) i, si el desenllaç és "candidat a reentrenament", llança el flow amb un clic des de la UI de Prefect. La part cara i mecànica (construir dataset, entrenar, avaluar, registrar) sí que és 100% automàtica; el judici de "té sentit reentrenar?" continua sent humà. És la mateixa filosofia del mòdul 5: automatitzar l'execució, no la decisió.

El dataset nou: point-in-time correctness (el leakage de 03-03, resolt)

Aquí paguem un deute que arrosseguem des del mòdul 3. A 03-03 ho vam anunciar: el clients_churn.csv estàtic amb què hem treballat no té timestamps, i això amagava un problema que només aflora ara, en passar a dades contínues. Si construïm el dataset de reentrenament amb "l'estat actual" de cada client, cometem leakage temporal: les features d'un client que es va donar de baixa al març inclourien tiquets de suport de l'abril — informació posterior a l'esdeveniment que volem predir. El model aprendria a "predir el passat amb dades del futur", brillaria en validació i fracassaria en producció, on el futur no està disponible.

La regla es diu point-in-time correctness: per a cada exemple d'entrenament, les features es calculen exclusivament amb dades anteriors a un instant de referència, i l'etiqueta s'observa després d'aquest instant. Definim per a cada client:

  • data_tall: l'instant en què "fem la foto" (per a les baixes, un marge abans de la baixa; per als actius, una data de mostreig).
  • Features: agregats sobre esdeveniments amb timestamp < data_tall.
  • Etiqueta: hi va haver baixa a la finestra (data_tall, data_tall + 30 dies]? (la mateixa finestra de 30 dies del job d'avaluació de 06-01 — l'horitzó de la predicció és un de sol a tot el sistema).
# src/cineclick_churn/data.py — construcció point-in-time del dataset
import pandas as pd

HORITZO = pd.Timedelta(days=30)

def construir_dataset_pit(clients: pd.DataFrame, esdeveniments_tiquets: pd.DataFrame,
                          esdeveniments_visionat: pd.DataFrame, baixes: pd.DataFrame,
                          data_mostreig: pd.Timestamp) -> pd.DataFrame:
    """Dataset d'entrenament amb tall temporal per client.

    clients: mestre (id_client, data_alta, pla, metode_pagament, ...)
    esdeveniments_*: taules d'esdeveniments AMB timestamp (la novetat davant del CSV estàtic)
    baixes: (id_client, data_baixa)
    """
    df = clients.merge(baixes, on="id_client", how="left")

    # 1. data_tall per client: 30 dies abans de la baixa per als que se'n van anar
    #    (volem predir la baixa ABANS que passi, no el dia que passa),
    #    i la data de mostreig per als actius.
    df["data_tall"] = df["data_baixa"].sub(HORITZO).fillna(data_mostreig)

    # 2. Etiqueta: baixa dins de l'horitzó posterior al tall.
    df["abandonament"] = (
        df["data_baixa"].notna() & (df["data_baixa"] <= df["data_tall"] + HORITZO)
    ).astype(int)

    # 3. Features NOMÉS amb esdeveniments anteriors al tall. AQUESTA línia és la
    #    que resol el leakage: el filtre temporal abans d'agregar.
    tiquets_previs = (
        esdeveniments_tiquets.merge(df[["id_client", "data_tall"]], on="id_client")
        .query("timestamp < data_tall")                       # <-- el tall
        .groupby("id_client").size().rename("tiquets_suport")
    )
    hores_previes = (
        esdeveniments_visionat.merge(df[["id_client", "data_tall"]], on="id_client")
        .query("timestamp < data_tall")                       # <-- el tall
        .groupby("id_client")["hores"].mean().rename("hores_setmana")
    )

    df = df.join(tiquets_previs, on="id_client").join(hores_previes, on="id_client")
    df[["tiquets_suport", "hores_setmana"]] = df[["tiquets_suport", "hores_setmana"]].fillna(0)

    # 4. antiguitat_mesos també respecte al tall, no respecte a avui.
    df["antiguitat_mesos"] = (
        (df["data_tall"] - df["data_alta"]).dt.days / 30.44
    ).clip(lower=0).round().astype(int)

    return df  # ratio_tickets i la resta continuen vivint a features.py

L'essencial és als dos query("timestamp < data_tall") i a calcular antiguitat_mesos respecte al tall: sense timestamps als esdeveniments, aquesta operació és impossible — per això el CSV estàtic ens va protegir del problema durant cinc mòduls i per això esclata precisament aquí. En SQL seria el mateix patró: JOIN ... ON e.timestamp < c.data_tall abans del GROUP BY. Fixa't que ratio_tickets no necessita canvis: features.py continua sent la font única de features derivades (03-03), i com que es calcula a partir de columnes ja tallades en el temps, hereta la correcció.

Queda un segon contaminant, ja conegut de 05-04: la mateixa campanya de retenció. Als clients marcats en risc se'ls ofereix un descompte; si funciona, no es donen de baixa — i la seva etiqueta diu "no abandonament" gràcies a la nostra intervenció, no gràcies al fet que el model s'equivoqués. Entrenar amb aquests clients ensenya al model que el seu propi perfil de risc "no abandona". La solució ja la tenim desplegada des del mòdul 1 i formalitzada a 05-04: el grup de control permanent, que no rep mai el descompte. Les etiquetes del grup de control són les úniques no intervingudes, i són les que alimenten tant l'avaluació real (06-01) com, amb el pes adequat, el reentrenament. És el millor exemple del curs de per què les decisions de MLOps es prenen abans de necessitar-les: un grup de control no es pot crear retroactivament.

El pipeline de reentrenament complet

Ara sí, materialitzem l'esbós de flows/reentrenament.py que vam deixar apuntat a 05-03 (dvc pull → dvc repro → registrar com a challenger). La versió completa:

# flows/reentrenament.py
import subprocess
import mlflow
from prefect import flow, task
from prefect.blocks.notifications import SlackWebhook

LLINDAR_RECALL, LLINDAR_PRECISION = 0.60, 0.50  # el criteri de negoci, una vegada més

@task(retries=2, retry_delay_seconds=600)
def construir_dataset_nou(data_mostreig: str) -> str:
    """Executa construir_dataset_pit i deixa el resultat sota control de DVC."""
    # ... crida construir_dataset_pit(...) i escriu data/raw/clients_churn.csv ...
    subprocess.run(["dvc", "add", "data/raw/clients_churn.csv"], check=True)
    subprocess.run(["dvc", "push"], check=True)   # al remote `magatzem`
    md5 = llegir_md5_dvc("data/raw/clients_churn.csv.dvc")
    return md5                                     # llinatge: quines dades exactes

@task
def entrenar_amb_dvc() -> None:
    subprocess.run(["dvc", "pull"], check=True)
    # dvc repro re-executa NOMÉS les etapes afectades del pipeline del mòdul 2:
    # preparar_dades -> construir_features -> entrenar -> avaluar
    subprocess.run(["dvc", "repro"], check=True)

@task
def avaluar_candidat(run_id: str) -> dict:
    metriques = {
        "congelat": avaluar_sobre("data/processed/test.csv", run_id),   # test històric
        "fresc": avaluar_sobre("data/processed/test_fresc.csv", run_id),  # dades recents
    }
    return metriques

@task
def registrar_challenger(run_id: str, dades_md5: str, finestra: str) -> int:
    client = mlflow.MlflowClient()
    versio = mlflow.register_model(f"runs:/{run_id}/model", "churn-cineclick")
    client.set_registered_model_alias("churn-cineclick", "challenger", versio.version)
    # Tags de llinatge: amb això, qualsevol versió respon "d'on surts?"
    for k, v in {"dades_dvc_md5": dades_md5, "finestra_temporal": finestra,
                 "origen": "flow reentrenament-churn",
                 "flow_run": obtenir_flow_run_id()}.items():
        client.set_model_version_tag("churn-cineclick", versio.version, k, v)
    return versio.version

@flow(name="reentrenament-churn")
def reentrenament(data_mostreig: str, motiu: str = "programat-mensual"):
    dades_md5 = construir_dataset_nou(data_mostreig)
    entrenar_amb_dvc()
    run_id = darrer_run("churn-cineclick")        # el run que dvc repro va registrar
    metriques = avaluar_candidat(run_id)

    m = metriques["congelat"]
    if m["recall"] >= LLINDAR_RECALL and m["precision"] >= LLINDAR_PRECISION:
        v = registrar_challenger(run_id, dades_md5, finestra=f"fins a {data_mostreig}")
        SlackWebhook.load("alertes-ml").notify(
            f":package: Nou challenger churn-cineclick v{v} ({motiu}). "
            f"Congelat: recall {m['recall']:.2f} / precision {m['precision']:.2f}. "
            f"Fresc: recall {metriques['fresc']['recall']:.2f}. "
            f"Pendent de revisió humana: docs/promocio-model.md"
        )
    else:
        SlackWebhook.load("alertes-ml").notify(
            f":x: Candidat descartat ({motiu}): recall {m['recall']:.2f} / "
            f"precision {m['precision']:.2f} no supera el llindar. Revisar dades i run {run_id}."
        )

Res d'això no és nou com a peça — i aquesta és la gràcia. dvc repro és el pipeline del mòdul 2; el tracking i el registry són del mòdul 3; els llindars són els de la CI de 05-01; Prefect, els seus reintents i les seves alertes són de 05-03. El flow només encadena el que ja està construït. Quan un pipeline de reentrenament requereix peces noves, sol ser símptoma que els mòduls anteriors van deixar forats.

Avaluació: test congelat i test fresc

El flow avalua contra dos test sets, i la raó mereix el seu propi apartat:

  • Test congelat (data/processed/test.csv, versionat des del mòdul 2): el mateix examen que van aprovar v1 i v2. És l'única manera de comparar versions entre si en igualtat de condicions — si cada versió s'examina d'un test diferent, "v3 té millor recall que v2" no vol dir res. Contra aquest test s'apliquen els llindars bloquejants.
  • Test fresc (test_fresc.csv, extret de les etiquetes més recents del grup de control, disjunt de l'entrenament): mesura el que de debò importa cap endavant — funciona el candidat sobre el món actual, inclosa la cohort de la promo? Un candidat pot brillar al congelat (que envelleix amb el món) i punxar al fresc.
Test Respon a Risc si és l'únic
Congelat És millor que les versions anteriors, a igualtat d'examen? Aprovar models ajustats a un món que ja no existeix
Fresc Funciona sobre la població actual? Perdre la comparabilitat històrica entre versions

Amb el temps, el test congelat també es renova (quan s'acumula prou deriva, se'n congela un de nou i s'hi re-avaluen les versions vives), però això és una decisió documentada — un ADR, com veurem a 06-04 —, mai un canvi silenciós.

Registrar com a challenger, mai com a champion

La regla ja estava escrita a l'esbós de 05-03 i ara s'entén del tot: el flow registra el candidat com a @challenger i no toca mai @champion. Per què tan taxatiu, si el candidat ha superat els llindars?

Perquè els llindars offline són condició necessària, no suficient. Entre "ha aprovat l'examen" i "mereix el trànsit" hi ha tot el que el mòdul 5 va construir: la revisió humana documentada del PR de promoció (docs/promocio-model.md + scripts/promocionar_champion.py, 05-02), les dues setmanes d'ombra obligatòries per a tot challenger i el canary 5% → 25% → 100% amb mètriques de guàrdia (05-04). Un sistema que s'autopromociona se salta exactament les salvaguardes que existeixen perquè els tests offline no capturen el comportament online. El reentrenament automatitzat no crea un camí nou a producció: alimenta el camí que ja existeix.

Els tags de llinatge (dades_dvc_md5, finestra_temporal, flow_run) són l'altra meitat de la regla: quan d'aquí a sis mesos algú pregunti "amb quines dades exactes es va entrenar v3 i per què existia?", la resposta és al registry, no a la memòria de la Laura. A 06-04 això es convertirà en peça central de l'auditabilitat.

Reentrenar no sempre arregla; i no és gratis

Dues advertències que separen un sistema madur d'un que es dispara al peu:

Reentrenar no sempre arregla. Si la deriva detectada era un bug de dades aigües amunt (el metode_pagament com a "CARD", les hores en minuts), reentrenar no elimina el problema: l'aprèn. El model nou s'ajusta a la dada corrupta, els tests passen (el test fresc també està corrupte), i el bug queda consolidat amb un llaç. Per això el playbook de 06-02 va sempre abans que el tret: l'humà que revisa l'alerta descarta el bug abans de llançar el flow. És la raó de fons per la qual el tret extraordinari no és 100% automàtic.

Reentrenar no és gratis. Tres costos que creixen amb la freqüència:

  • Còmput: dataset complet + cerca d'hiperparàmetres + avaluació doble, cada vegada.
  • Risc: cada model nou és una oportunitat de regressió. Més versions a l'any = més ombres, més canaries, més ocasions de rollback.
  • Fatiga de revisió: si cada setmana hi ha un challenger esperant revisió humana, la revisió es torna tràmit — i una salvaguarda que s'aprova sense mirar ja no és una salvaguarda.

L'equilibri de CineClick — mensual + extraordinari amb filtre humà — surt d'aquests costos: freqüència suficient perquè el model no envelleixi (la deriva observada és de setmanes/mesos, no d'hores), i prou baixa perquè cada challenger rebi una revisió de debò. Un sistema de recomanació de portada necessitaria una altra resposta; la política de tret és una decisió de negoci amb forma tècnica.

El bucle tancat: CineClick assoleix el nivell 2

Amb aquesta peça, el cicle de vida que vam dibuixar a 01-02 — i que llavors era una promesa — queda tancat de debò:

flowchart LR
    MON["Monitoratge<br/>(06-01: servei + model,<br/>taula de prediccions)"] --> DER["Detecció de deriva<br/>(06-02: Evidently setmanal, PSI)"]
    DER -->|"alerta #alertes-ml"| PB{"Playbook<br/>(humà)"}
    PB -->|"bug aigües amunt"| FIX["Arreglar dades<br/>(no reentrenar)"]
    PB -->|"candidat"| TRIG["Tret<br/>(extraordinari o mensual)"]
    TRIG --> RE["reentrenament-churn<br/>(06-03: dataset point-in-time,<br/>dvc repro, doble avaluació)"]
    RE -->|"supera llindars"| CH["Registre com a @challenger<br/>+ tags de llinatge"]
    CH --> REV{"Revisió humana<br/>(PR de promoció, 05-02)"}
    REV --> SOM["Ombra 2 setmanes<br/>(05-04)"]
    SOM --> CAN["Canary 5→25→100%<br/>mètriques de guàrdia"]
    CAN --> CHAMP["@champion<br/>servint al 100%"]
    CHAMP --> MON
    FIX --> MON

Fixa't en els dos rombes: són els únics punts on el bucle s'atura a esperar una persona, i són exactament on hi ha judici a exercir — interpretar la deriva i aprovar la promoció. Tota la resta flueix sola.

I ara, la recapitulació promesa contra l'escala de maduresa de 01-03. El nivell 2 exigia: entrenament automatitzat i disparat per senyals (✔ aquest flow, amb les seves dues polítiques), monitoratge de model en producció (✔ 06-01), detecció de deriva que alimenta decisions (✔ 06-02), i desplegament continu del model amb salvaguardes (✔ mòdul 5). Al final del mòdul 5 vam dir que a CineClick li faltava "que el sistema miri sol": les tres últimes lliçons li han donat els ulls i els reflexos. CineClick és al nivell 2. El que queda del curs no afegeix automatització: afegeix govern (06-04) i perspectiva (06-05).

Errors Comuns i Consells

  • Autopromocionar "perquè les mètriques són millors". Les mètriques offline no veuen latència, ni comportament amb trànsit real, ni desacords per segment. L'ombra i el canary existeixen perquè l'examen i la feina són coses diferents. Challenger sempre; champion, només un humà.
  • Construir el dataset amb l'estat actual dels clients. És el leakage temporal en la seva forma més comuna i més silenciosa: mètriques de validació excel·lents, producció decebedora. Si el teu dataset de churn no té una columna data_tall (o equivalent), sospita.
  • Avaluar només contra el test congelat. Aprovaràs models excel·lents per al 2025. El test fresc és el que mesura el 2026.
  • Reentrenar com a resposta automàtica a qualsevol alerta de deriva. Si era un bug de dades, acabes de donar-l'hi de menjar al model. Playbook primer.
  • Registrar sense llinatge. Un challenger sense dades_dvc_md5 ni finestra temporal és un model orfe: quan alguna cosa vagi malament, no podràs reproduir-lo ni explicar d'on va sortir. Els tags costen quatre línies; escriu-les sempre.
  • Triar la freqüència de reentrenament per intuïció. Mira-la com a decisió de negoci: velocitat de la deriva observada, cost de còmput, capacitat real de revisió de l'equip. I revisa-la quan canviïn aquestes variables.

Exercicis

  1. Tria la política. Per a cada sistema, proposa una política de tret (o combinació) i justifica-la amb la taula de pros/contres: (a) un model de detecció de frau en pagaments, on els patrons adversaris canvien en dies; (b) un model de taxació d'immobles amb etiquetes que triguen mesos (preu real de venda); (c) el churn de CineClick si el negoci s'expandís a un país nou el proper trimestre.

  2. Caça el leakage. Marc proposa simplificar construir_dataset_pit així: "per als clients amb baixa, fem servir com a features els seus agregats a data de la baixa; total, un dia amunt o avall no importa". Explica quins dos problemes introdueix respecte a la versió de la lliçó i com es manifestaria cadascun (un en validació, l'altre en producció).

  3. El challenger sospitosament bo. El flow mensual registra v4 amb recall 0.81 i precision 0.63 sobre el test congelat — molt per sobre del 0.68/0.55 de v2. Abans d'alegrar-te, quines tres comprovacions faries fent servir el llinatge i els artefactes del sistema, i què esperaries trobar a cadascuna si hi ha gat amagat?

Solucions

  1. (a) Per deriva/degradació amb llindars agressius i finestres curtes, més una programada setmanal com a xarxa: la deriva adversària és ràpida i esperar el cicle mensual regala dies al frau; el cost de reentrenar és menor que el cost del frau no detectat. (b) Programada amb període llarg (trimestral) + per volum d'etiquetes noves: amb ground truth que triga mesos, un tret per degradació de mètriques reals reaccionaria tardíssim de totes maneres, i disparar per deriva d'entrada sense poder confirmar impacte genera reentrenaments a cegues; el volum d'etiquetes garanteix que cada cicle aprèn alguna cosa nova. (c) Mantenir mensual + extraordinari, però planificar un reentrenament proactiu en acumular prou història del país nou — i fins llavors, vigilar aquesta cohort com a segment separat al dashboard: és data drift legítim anunciat amb antelació, el cas ideal per avançar-se en lloc de reaccionar.

  2. Problema 1 — leakage cap a l'etiqueta: amb data_tall = data_baixa, les features capturen el comportament durant el procés de baixa (el pic de tiquets de qui truca per cancel·lar, la davallada d'hores de qui ja ha decidit marxar). El model aprèn a reconèixer "client donant-se de baixa", no "client que es donarà de baixa": recall espectacular en validació. Problema 2 — inutilitat en producció: el servei puntua clients abans que decideixin marxar; els senyals de comiat que el model va aprendre a buscar encara no existeixen en el moment de la predicció, així que el rendiment real s'enfonsa. A més, desapareix el marge d'actuació: predir la baixa el dia de la baixa no deixa temps a la campanya de retenció, que era el propòsit de negoci. El tall 30 dies abans (l'horitzó) resol les dues coses.

  3. Primera: amb el tag dades_dvc_md5, recuperar el dataset exacte i comprovar el solapament entre entrenament i test.csv (algun id_client a tots dos? es va regenerar el test per accident al dvc repro?); si hi ha files compartides, aquí hi ha el gat. Segona: revisar la construcció point-in-time d'aquesta finestra — un salt gran de recall és la firma clàssica del leakage temporal (exercici 2); comparar la distribució de data_tall i auditar una mostra de clients amb baixa. Tercera: al run de MLflow, comparar hiperparàmetres i features contra v2 (hi ha entrat una columna nova sospitosament predictiva, tipus "descompte de retenció aplicat", que és conseqüència de la predicció i no causa?). Si les tres surten netes, llavors sí: enhorabona, i a l'ombra dues setmanes com tothom — on un model amb leakage, per cert, es delataria amb desacords massius.

Conclusió

El bucle està tancat. CineClick reentrenarà cada mes — o abans, si el PSI supera 0.25 o el recall real passa dues setmanes per sota de 0.60 — amb un flow que construeix el dataset amb point-in-time correctness (el deute de 03-03, saldat: features només amb dades anteriors al tall, etiquetes netes gràcies al grup de control), re-executa el pipeline reproduïble de sempre, avalua contra el test congelat i contra un de fresc, i registra el resultat com a @challenger amb el seu llinatge complet. Mai com a champion: del challenger al trànsit, el camí continua sent el del mòdul 5 — revisió humana, dues setmanes d'ombra, canary amb mètriques de guàrdia. Amb això, el cicle de vida de 01-02 deixa de ser un dibuix i CineClick creua, per fi, al nivell 2 de maduresa de l'escala de 01-03. Però un sistema que decideix sobre persones i es millora a si mateix planteja preguntes que cap dashboard no respon: per què se li va oferir retenció a aquest client i no a aquell? Tracta igual de bé tots els segments? Què documentem, què retenim, què exigeix la regulació? Això — governança, documentació i compliment — és la lliçó següent.

© Copyright 2026. Tots els drets reservats