A la lliçó anterior vam deixar encesos els senyals: proxies operatius que avisen quan "alguna cosa es mou" a les entrades o a les sortides del model. Aquesta lliçó posa rigor a aquest moviment. Veurem per què un model es degrada sense que ningú desplegui res, els quatre tipus de deriva amb exemples concrets de CineClick, com quantificar-la amb PSI calculat a mà (per entendre de debò què mesura), i com complir per fi el compromís que vam adquirir al mòdul 1: Evidently com a eina de detecció, executada cada setmana per Prefect, amb alerta a #alertes-ml. Tanquem amb el playbook de què fer quan l'alarma sona — perquè detectar deriva és fàcil; interpretar-la bé és el que separa un sistema útil d'una fàbrica de falses alarmes.
Contingut
- El model es degrada sense que ningú toqui el codi
- Els quatre tipus de deriva, amb exemples de CineClick
- Detecció univariant: PSI pas a pas
- Detecció multivariant en una pinzellada
- Evidently: deriva com a informe reproduïble
- Programar la vigilància: Evidently dins de Prefect
- El playbook: què fer quan salta l'alarma
El model es degrada sense que ningú toqui el codi
Recorda la idea que vam plantar al mòdul 1: el programari tradicional falla quan algú canvia el codi; un model de ML pot fallar quan el món canvia, amb el codi intacte. El champion v2 de CineClick és una fotografia dels patrons d'abandonament que existien a les dades amb què es va entrenar. Aquesta fotografia no caduca per temps, caduca per distància: com més s'allunyi la realitat actual de la realitat fotografiada, pitjor generalitza.
Formalment, el model va aprendre una aproximació de P(y|X) sobre una mostra de P(X). Hi ha dues maneres que això deixi de valer:
- Canvia
P(X): els clients que arriben ja no s'assemblen als de l'entrenament. El model opina sobre terreny que no coneix. - Canvia
P(y|X): els mateixos clients ara es comporten diferent. El patró après és directament fals.
Cap de les dues coses no trenca el servei, ni fa fallar un test, ni dispara un 5xx. Per això aquest problema és invisible per a tot el que hem construït fins al mòdul 5, i per això la taula de prediccions de 06-01 és la matèria primera d'aquesta lliçó.
Els quatre tipus de deriva, amb exemples de CineClick
Data drift (deriva de dades): canvia P(X)
Màrqueting llança una promo de tres mesos a meitat de preu i entra una cohort nova: clients joves, gairebé tots amb pla basic, amb antiguitat_mesos baixíssima i poques hores_setmana encara. La distribució de les features d'entrada ja no és la de l'entrenament. El model no està necessàriament equivocat sobre ells — però està extrapolant, i la seva fiabilitat sobre aquesta cohort és una incògnita.
Prediction drift (deriva de prediccions): canvia la distribució de sortides
Abans que ningú miri les features, l'histograma de probabilitats emeses canvia de forma: la taxa de positius salta del 14% al 28%. La deriva de prediccions sol ser el primer símptoma visible — és l'ombra que la deriva de dades projecta sobre la sortida del model — i és baratíssima de vigilar (ja ho fem des de 06-01 amb la banda 10–25%). Això sí: és un símptoma, no un diagnòstic; cal mirar les entrades per saber-ne la causa.
Concept drift (deriva de concepte): canvia P(y|X)
CineClick apuja el preu de tots els plans un 15%. Clients fidels, amb molta antiguitat i moltes hores setmanals — el perfil que el model va aprendre com a "segur" — comencen a donar-se de baixa. Les features d'aquests clients no han canviat gens: hores_setmana continua alta, tiquets_suport continua baix. El que ha canviat és la relació entre aquestes features i l'abandonament. El patró après ha deixat de ser veritat, i cap inspecció de P(X) no ho detectarà.
Label shift: canvia P(y)
La proporció global de churn es mou (del ~15% de l'entrenament al 22%, per exemple, per una crisi econòmica), encara que el mecanisme que relaciona features i abandonament continuï sent aproximadament el mateix. A la pràctica sol venir barrejat amb els anteriors; el seu efecte més traïdor és descalibrar el llindar: un 0.5 calibrat per a un món amb un 15% de churn ja no talla on ha de tallar.
| Tipus | Què canvia | Exemple CineClick | Símptoma típic | Detecció | Necessita etiquetes? |
|---|---|---|---|---|---|
| Data drift | P(X) | Cohort nova de la promo | Features fora de rang històric | PSI/KS per feature, Evidently | No |
| Prediction drift | P(ŷ) | Taxa de positius 14% → 28% | L'histograma de scores canvia de forma | PSI sobre el score, banda de positius | No |
| Concept drift | P(y|X) | Pujada de preu: els fidels se'n van | El recall real cau amb entrades "normals" | Mètriques reals del job de 06-01 | Sí |
| Label shift | P(y) | Churn global 15% → 22% | Descalibratge del llindar | Comparar prevalença real per període | Sí |
L'última columna és la que organitza tota la feina: data drift i prediction drift es detecten avui, amb la taula de prediccions. La deriva de concepte només es confirma quan arriben les etiquetes — és a dir, amb el job de ground truth de 06-01. Els dos mecanismes són complementaris, no alternatius: la deriva de dades és el sistema d'alerta primerenca; les mètriques reals són el jutge.
Detecció univariant: PSI pas a pas
El Population Stability Index (PSI) és la mètrica clàssica per quantificar quant s'ha mogut la distribució d'una variable entre una població de referència (les dades d'entrenament del champion) i una d'actual (per exemple, l'última setmana de la taula de prediccions). El calcularem a mà sobre hores_setmana, perquè entendre el càlcul val més que memoritzar el llindar.
La recepta:
- Divideix el rang de la variable en bins (cubetes), definits sobre la referència (típicament 10 quantils).
- Calcula quina proporció de la referència cau a cada bin (
esperat) i quina proporció de les dades actuals cau en aquests mateixos bins (observat). - Per a cada bin:
(observat - esperat) * ln(observat / esperat). Suma tots els bins.
import numpy as np
def psi(referencia: np.ndarray, actual: np.ndarray, n_bins: int = 10) -> float:
"""PSI d'una variable numèrica entre referència i actual."""
# 1. Vores de bins per quantils de la REFERÈNCIA (no de les dades actuals:
# la vara de mesurar es fixa una vegada i no es mou amb el que mesurem).
vores = np.quantile(referencia, np.linspace(0, 1, n_bins + 1))
vores[0], vores[-1] = -np.inf, np.inf # que cap valor actual no quedi fora
# 2. Proporció de cada població a cada bin.
esperat = np.histogram(referencia, bins=vores)[0] / len(referencia)
observat = np.histogram(actual, bins=vores)[0] / len(actual)
# 3. Un bin buit faria ln(0) = -inf; se substitueix per un valor petit.
esperat = np.where(esperat == 0, 1e-4, esperat)
observat = np.where(observat == 0, 1e-4, observat)
return float(np.sum((observat - esperat) * np.log(observat / esperat)))I l'apliquem amb dades fictícies que imiten l'escenari de la promo:
rng = np.random.default_rng(42)
# Referència: hores_setmana de l'entrenament del champion (mitjana ~9 h).
hores_referencia = rng.gamma(shape=4.0, scale=2.2, size=20_000)
# Actual: la cohort de la promo mira menys hores (encara); mitjana ~6 h.
hores_actual = rng.gamma(shape=3.0, scale=2.0, size=4_000)
print(f"PSI hores_setmana: {psi(hores_referencia, hores_actual):.3f}")
# PSI hores_setmana: 0.196Com llegir el número, amb els llindars convencionals del sector:
| PSI | Interpretació | Acció a CineClick |
|---|---|---|
| < 0.10 | Estable | Res |
| 0.10 – 0.25 | Canvi moderat | Vigilar; anotar-ho al dashboard |
| > 0.25 | Canvi significatiu | Alerta a #alertes-ml → playbook |
Intuïció de la fórmula: cada bin aporta segons quant ha canviat la seva proporció (observat - esperat) ponderat per en quina direcció relativa (ln(observat/esperat)). Tots dos factors tenen sempre el mateix signe, així que cada bin suma en positiu: PSI = 0 només si res no s'ha mogut. El nostre 0.196 diu "canvi moderat, encara no crític" — coherent amb una cohort nova que és minoria de la població total.
Alternativa habitual: el test de Kolmogorov-Smirnov (KS), que compara les distribucions acumulades i dona un p-valor (scipy.stats.ks_2samp). Funciona bé, amb una advertència: amb els volums d'una plataforma de streaming, KS declara "significatives" diferències minúscules i irrellevants (amb n enorme, tot és significatiu). Per això a la pràctica industrial es prefereix PSI, que mesura magnitud de canvi, no significació estadística. Per a les features categòriques (pla, metode_pagament), el PSI es calcula igual fent servir cada categoria com a bin.
Detecció multivariant en una pinzellada
El PSI mira cada feature per separat, i això té un punt cec: les distribucions marginals poden mantenir-se mentre canvien les correlacions (per exemple, abans els clients de pla premium eren els de més hores; ara ja no — cada marginal intacta, l'estructura conjunta trencada).
La tècnica conceptualment més elegant per a això: entrena un classificador la tasca del qual sigui distingir si una fila ve de la referència o de les dades actuals (etiqueta 0 = referència, 1 = actual). Si el millor classificador possible no supera un AUC de ~0.5, les dues poblacions són indistingibles — no hi ha drift. Si arriba a 0.75, alguna cosa ha canviat a l'estructura conjunta, i les seves feature importances et diuen on mirar. És el "drift del score d'un classificador domini-actual vs. referència".
Ens quedem al concepte: per a la mida i el risc del cas CineClick, el PSI univariant per feature més el prediction drift cobreixen la gran majoria dels casos reals, i el classificador de domini queda com a eina d'investigació quan els univariants no expliquen el que veus.
Evidently: deriva com a informe reproduïble
Calcular PSI a mà educa, però mantenir a mà el càlcul per a 7 features, amb visualitzacions, tests per tipus de variable i un JSON estructurat per automatitzar, no escala. Aquí complim el compromís del mòdul 1: Evidently és l'eina de deriva de CineClick.
Dos ingredients:
- Referència: les dades d'entrenament del champion v2. No una còpia solta: la versió exacta, recuperada amb DVC (
dvc pulldel commit amb què es va entrenar v2). Si la referència no està versionada, el detector de deriva mesura contra una vara de mesurar desconeguda. - Actual: l'última setmana de la taula de prediccions de 06-01, que guarda les features tal com van entrar al model.
# flows/deteccio_deriva.py (nucli)
import pandas as pd
from evidently import Report, DataDefinition, Dataset
from evidently.presets import DataDriftPreset
FEATURES_NUM = ["antiguitat_mesos", "hores_setmana", "tiquets_suport", "ratio_tickets"]
FEATURES_CAT = ["pla", "metode_pagament", "descompte_actiu"]
definicio = DataDefinition(
numerical_columns=FEATURES_NUM,
categorical_columns=FEATURES_CAT,
)
def generar_informe_deriva(ruta_referencia: str, ruta_actual: str):
referencia = Dataset.from_pandas(pd.read_parquet(ruta_referencia), data_definition=definicio)
actual = Dataset.from_pandas(pd.read_parquet(ruta_actual), data_definition=definicio)
informe = Report([DataDriftPreset()])
resultat = informe.run(reference_data=referencia, current_data=actual)
resultat.save_html("informe_deriva.html") # per a humans
return resultat.dict() # per a màquinesQuè fa cada peça:
DataDefinitiondiu a Evidently quina columna és què. Importa perquè el test estadístic adequat depèn del tipus (numèrica vs. categòrica); Evidently tria automàticament segons tipus i mida de mostra (Wasserstein, KS, Jensen-Shannon, PSI…).DataDriftPresetés un paquet de tests: deriva per columna + resum global.- El
.dict()és la sortida clau per automatitzar. D'allà n'extraiem dos números: quantes columnes han derivat idrift_share, la fracció de columnes amb deriva detectada.
Lectura de l'HTML: una fila per feature amb el seu test, el seu score de deriva i un veredicte detected/not detected, més les distribucions superposades referència vs. actual. Al nostre escenari de la promo hi veuries antiguitat_mesos i hores_setmana en vermell, pla desplaçada cap a basic, i tiquets_suport tranquil·la — el patró exacte de "cohort nova", que reconeixeràs en segons amb les gràfiques al davant.
Programar la vigilància: Evidently dins de Prefect
Un informe que algú s'ha de recordar de generar és el problema que aquest mòdul va venir a eliminar. El convertim en un flow de Prefect setmanal, igual que el scoring de 05-03, sobre el mateix work pool cineclick-pool:
# flows/deteccio_deriva.py (orquestració)
from prefect import flow, task
from prefect.blocks.notifications import SlackWebhook
LLINDAR_DRIFT_SHARE = 0.3 # si més del 30% de les features deriven, alerta
@task(retries=2, retry_delay_seconds=300)
def carregar_referencia() -> str:
# dvc pull de les dades d'entrenament del champion (versió fixada)
...
@task(retries=2, retry_delay_seconds=300)
def carregar_actual_darrera_setmana() -> str:
# Llegeix les particions de la taula de prediccions dels últims 7 dies
...
@task
def avaluar_i_alertar(resultat: dict) -> None:
drift = resultat["metrics"][0]["result"] # resum del preset
share = drift["share_of_drifted_columns"]
if share > LLINDAR_DRIFT_SHARE:
derivades = [c for c, r in drift["drift_by_columns"].items() if r["drift_detected"]]
SlackWebhook.load("alertes-ml").notify(
f":warning: Deriva de dades a churn-cineclick: "
f"{share:.0%} de features derivades ({', '.join(derivades)}). "
f"Informe: informe_deriva.html — aplicar el playbook de deriva."
)
@flow(name="deteccio-deriva-churn")
def deteccio_deriva():
ref, act = carregar_referencia(), carregar_actual_darrera_setmana()
resultat = generar_informe_deriva(ref, act)
avaluar_i_alertar(resultat)Es desplega amb el mateix patró que scoring-setmanal-churn: cron "0 7 * * 1" (dilluns a les 07:00 Europe/Madrid, una hora després del scoring, perquè l'última setmana estigui completa i fresca). Reintents, rastre d'execucions i alertes: tota la bastida de 05-03, reutilitzada.
Detall important: l'alerta porta el què (features derivades i magnitud) i l'on continuar (informe + playbook). Una alerta que només diu "hi ha drift" obliga a començar la investigació des de zero a les 07:00 d'un dilluns.
El playbook: què fer quan salta l'alarma
La reacció reflexa — "hi ha drift, reentrenem" — és l'equivocada amb prou freqüència com per merèixer un procediment. Quan arriba l'alerta, l'ordre de preguntes és aquest:
flowchart TD
A["Alerta de deriva a #alertes-ml"] --> B{"És un error de dades<br/>aigües amunt?"}
B -->|"Sí: nuls, unitats canviades,<br/>categoria nova per typo"| C["Arreglar la dada amb l'equip<br/>productor. NO reentrenar:<br/>aprendries el bug"]
B -->|No| D{"És un canvi legítim<br/>de la població?"}
D -->|"Sí: cohort nova,<br/>promo, estacionalitat"| E{"Afecta el rendiment?<br/>(proxies de 06-01 i, quan<br/>arribin, mètriques reals)"}
D -->|No està clar| F["Investigar: classificador de<br/>domini, talls per segment"]
E -->|No, de moment| G["Documentar i vigilar<br/>de prop aquesta cohort"]
E -->|"Sí, o gairebé segur"| H["Candidat a reentrenament<br/>→ decisió sistemàtica a 06-03"]Els tres desenllaços, en detall:
- Error de dades aigües amunt. El cas més comú a la vida real: un canvi d'esquema,
hores_setmanaque passa a venir en minuts, una migració que omple nuls amb 0. La validació amb pandera (esquema_clients, 05-01) atrapa els canvis de tipus i rang, però un canvi de distribució amb valors vàlids se li escapa — per a això hi ha el detector de deriva. La solució és arreglar la dada, mai reentrenar sobre ella. - Cohort nova legítima sense impacte (encara). La promo porta clients reals; el model extrapola sobre ells. Si els proxies continuen en banda i no hi ha senyal de degradació, es documenta i es vigila. Reentrenar immediatament seria prematur: la cohort encara no té etiquetes de les quals aprendre (acaben d'arribar, cap no ha tingut temps de donar-se de baixa!).
- Deriva amb impacte al rendiment. Aquí sí que toca plantejar-se reentrenar. Però quan exactament, amb quina política de tret i amb quin pipeline — això és la decisió sistemàtica que construeix la propera lliçó.
I el recordatori final de l'apartat: tot l'anterior detecta canvis a P(X) i P(ŷ). La deriva de concepte — el patró que deixa de ser veritat amb entrades d'aspecte normal, com la pujada de preu — és invisible per a Evidently sense etiquetes. Només la confirma el job de ground truth de 06-01, quan el recall real cau setmana rere setmana amb PSI tranquil. Per això el dashboard únic té les dues files: proxies i mètriques reals es necessiten mútuament.
Errors Comuns i Consells
- Fer servir com a referència "les dades del mes passat" en lloc de les d'entrenament. Contra una referència lliscant, una degradació lenta i sostinguda no dispara mai l'alarma: cada setmana s'assembla a l'anterior. La referència canònica són les dades d'entrenament del champion (versionades amb DVC); una referència lliscant es pot afegir com a segona vista per detectar canvis bruscos.
- Confondre prediction drift amb concept drift. Que canviïn les sortides no vol dir que el patró après sigui fals: gairebé sempre és data drift reflectit. El concept drift només es confirma amb etiquetes.
- Reentrenar com a acte reflex davant de qualsevol alerta. Si la causa és un bug de dades, el reentrenament l'aprèn i el consolida. Playbook primer, sempre.
- Llindars de llibre sense context. El 0.1/0.25 del PSI és una convenció raonable per començar, no una llei. Calibra amb el teu històric: calcula el PSI setmana contra setmana en èpoques sanes i mira quant "soroll de fons" té el teu negoci (estacionalitat, campanyes) abans de fixar el llindar d'alerta.
- Executar el detector sobre les dades crues en lloc de les features del model. El que degrada el model és la deriva al seu espai d'entrada (inclosa
ratio_tickets, que és derivada). Per això la taula de prediccions guarda les features processades. - Ignorar la mida de mostra. Amb poques files actuals (un festiu, una caiguda de trànsit), els tests es tornen sorollosos. Exigeix un mínim de files abans de donar el resultat per vàlid, o acumula més dies.
Exercicis
-
Classifica la deriva. Per a cada escenari a CineClick, identifica el tipus (data drift, prediction drift, concept drift, label shift) i si Evidently el detectaria sense etiquetes: (a) una sèrie exclusiva d'un èxit enorme fa que clients amb poques hores deixin de donar-se de baixa durant dos mesos; (b) l'equip de l'app canvia el registre i ara
metode_pagamentarriba com a"CARD"en lloc de"targeta"per als nous; (c) després d'una campanya a televisió, la mediana d'antiguitat_mesosde les peticions baixa de 26 a 9. -
PSI a mà. Amb la funció
psi()de la lliçó, genera una referènciarng.normal(10, 2, 10_000)i tres escenaris actuals: (a)rng.normal(10, 2, 2_000), (b)rng.normal(11, 2, 2_000), (c)rng.normal(14, 3, 2_000). Calcula el PSI de cadascun, classifica'l segons la taula de llindars i explica el perquè de cada resultat. -
Dissenya la referència. Quan en el futur es promocioni un nou champion v3 (entrenat amb dades més recents), què ha de passar amb la referència del detector de deriva i com ho implementaries perquè passi sense intervenció manual? Pista: pensa en què "sap" el registry sobre cada versió.
Solucions
-
(a) Concept drift: canvia P(y|X) — el perfil "poques hores" ja no implica risc com abans. Evidently sobre les entrades no hi veuria res (els clients tenen el mateix aspecte); es manifestaria com a caiguda de precision real al job de 06-01 (vam predir baixes que no passen). (b) Data drift per error aigües amunt: apareix una categoria nova a P(X). Evidently sí que ho detecta (deriva a la categòrica
metode_pagament) i probablement abans ho atraparia l'esquema_clientsde pandera si la categoria no està permesa. Desenllaç del playbook: arreglar aigües amunt, no reentrenar. (c) Data drift legítim (cohort nova): detectable sense etiquetes via PSI aantiguitat_mesos; gairebé segur que vindrà acompanyat de prediction drift, perquè l'antiguitat baixa empeny les probabilitats de churn a l'alça. -
Valors orientatius (varien amb la llavor): (a) PSI ≈ 0.01–0.03 → estable; és la mateixa distribució, i el petit valor residual és soroll de mostreig — bona il·lustració que el PSI no dona mai exactament 0. (b) PSI ≈ 0.25–0.30 → a la frontera del canvi significatiu; mitjana desplaçada mitja desviació típica: les proporcions dels bins centrals es redistribueixen de manera apreciable. (c) PSI > 1 → canvi dràstic; mitjana desplaçada dues desviacions i variància més gran: bona part de la massa actual cau als bins extrems de la referència, i aquests quocients
observat/esperatenormes disparen la suma. -
La referència ha de passar a ser el dataset d'entrenament de v3, en el moment exacte de la promoció — ni abans (mesuraríem el champion actual amb la vara del futur) ni mai (mesuraríem v3 contra dades de v2, generant drift fantasma permanent). Implementació: en registrar cada versió a MLflow es guarda com a tag el
dades_dvc_md5del seu dataset d'entrenament (el llinatge que el flow de reentrenament formalitzarà a 06-03). La taskcarregar_referenciadel flow no porta ruta fixa: consulta el registry permodels:/churn-cineclick@champion, llegeix aquell tag i fadvc pulld'aquella versió exacta. Així, promocionar el champion actualitza la referència automàticament, sense tocar el flow.
Conclusió
Ja saps posar nom i número al que a 06-01 era només "alguna cosa es mou". Els quatre tipus de deriva queden delimitats — data drift i prediction drift, detectables avui sense etiquetes; concept drift i label shift, que només el ground truth confirma — i CineClick té un detector en producció: PSI entès des de la fórmula, Evidently comparant cada dilluns la referència versionada del champion contra l'última setmana de la taula de prediccions, i una alerta a #alertes-ml amb playbook inclòs quan drift_share creua el llindar. La lliçó deixa una porta oberta a propòsit: el playbook acaba en "candidat a reentrenament", però no diu quan es dispara, amb quines dades es construeix el nou dataset sense cometre el leakage temporal que vam anunciar a 03-03, ni com el resultat arriba a producció sense saltar-se les salvaguardes del mòdul 5. Aquest és exactament el contingut de la lliçó següent: el reentrenament automatitzat, la peça que tanca de debò el cicle de vida que vam dibuixar a 01-02.
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
