En la lección anterior dejamos encendidas las señales: proxies operativos que avisan cuando "algo se mueve" en las entradas o en las salidas del modelo. Esta lección pone rigor a ese movimiento. Veremos por qué un modelo se degrada sin que nadie despliegue nada, los cuatro tipos de deriva con ejemplos concretos de CineClick, cómo cuantificarla con PSI calculado a mano (para entender de verdad qué mide), y cómo cumplir por fin el compromiso que adquirimos en el módulo 1: Evidently como herramienta de detección, ejecutada cada semana por Prefect, con alerta a #alertas-ml. Cerramos con el playbook de qué hacer cuando la alarma suena — porque detectar deriva es fácil; interpretarla bien es lo que separa un sistema útil de una fábrica de falsas alarmas.
Contenido
- El modelo se degrada sin que nadie toque el código
- Los cuatro tipos de deriva, con ejemplos de CineClick
- Detección univariante: PSI paso a paso
- Detección multivariante en una pincelada
- Evidently: deriva como informe reproducible
- Programar la vigilancia: Evidently dentro de Prefect
- El playbook: qué hacer cuando salta la alarma
El modelo se degrada sin que nadie toque el código
Recuerda la idea que plantamos en el módulo 1: el software tradicional falla cuando alguien cambia el código; un modelo de ML puede fallar cuando el mundo cambia, con el código intacto. El champion v2 de CineClick es una fotografía de los patrones de abandono que existían en los datos con los que se entrenó. Esa fotografía no caduca por tiempo, caduca por distancia: cuanto más se aleje la realidad actual de la realidad fotografiada, peor generaliza.
Formalmente, el modelo aprendió una aproximación de P(y|X) sobre una muestra de P(X). Hay dos maneras de que eso deje de valer:
- Cambia
P(X): los clientes que llegan ya no se parecen a los del entrenamiento. El modelo opina sobre terreno que no conoce. - Cambia
P(y|X): los mismos clientes ahora se comportan distinto. El patrón aprendido es directamente falso.
Ninguna de las dos cosas rompe el servicio, ni falla un test, ni dispara un 5xx. Por eso este problema es invisible para todo lo construido hasta el módulo 5, y por eso la tabla de predicciones de 06-01 es la materia prima de esta lección.
Los cuatro tipos de deriva, con ejemplos de CineClick
Data drift (deriva de datos): cambia P(X)
Marketing lanza una promo de tres meses a mitad de precio y entra una cohorte nueva: clientes jóvenes, casi todos con plan basico, con antiguedad_meses bajísima y pocas horas_semana todavía. La distribución de las features de entrada ya no es la del entrenamiento. El modelo no está necesariamente equivocado sobre ellos — pero está extrapolando, y su fiabilidad sobre esa cohorte es una incógnita.
Prediction drift (deriva de predicciones): cambia la distribución de salidas
Antes de que nadie mire las features, el histograma de probabilidades emitidas cambia de forma: la tasa de positivos salta del 14% al 28%. La deriva de predicciones suele ser el primer síntoma visible — es la sombra que la deriva de datos proyecta sobre la salida del modelo — y es baratísima de vigilar (ya lo hacemos desde 06-01 con la banda 10–25%). Eso sí: es un síntoma, no un diagnóstico; hay que mirar las entradas para saber la causa.
Concept drift (deriva de concepto): cambia P(y|X)
CineClick sube el precio de todos los planes un 15%. Clientes fieles, con mucha antigüedad y muchas horas semanales — el perfil que el modelo aprendió como "seguro" — empiezan a darse de baja. Las features de esos clientes no han cambiado nada: horas_semana sigue alta, tickets_soporte sigue bajo. Lo que ha cambiado es la relación entre esas features y el abandono. El patrón aprendido dejó de ser verdad, y ninguna inspección de P(X) lo detectará.
Label shift: cambia P(y)
La proporción global de churn se mueve (del ~15% del entrenamiento al 22%, por ejemplo, por una crisis económica), aunque el mecanismo que relaciona features y abandono siga siendo aproximadamente el mismo. En la práctica suele venir mezclado con los anteriores; su efecto más traicionero es descalibrar el umbral: un 0.5 calibrado para un mundo con 15% de churn ya no corta donde debe.
| Tipo | Qué cambia | Ejemplo CineClick | Síntoma típico | Detección | ¿Necesita etiquetas? |
|---|---|---|---|---|---|
| Data drift | P(X) | Cohorte nueva de la promo | Features fuera de rango histórico | PSI/KS por feature, Evidently | No |
| Prediction drift | P(ŷ) | Tasa de positivos 14% → 28% | Histograma de scores cambia de forma | PSI sobre el score, banda de positivos | No |
| Concept drift | P(y|X) | Subida de precio: los fieles se van | Recall real cae con entradas "normales" | Métricas reales del job de 06-01 | Sí |
| Label shift | P(y) | Churn global 15% → 22% | Descalibración del umbral | Comparar prevalencia real por periodo | Sí |
La última columna es la que organiza todo el trabajo: data drift y prediction drift se detectan hoy, con la tabla de predicciones. La deriva de concepto solo se confirma cuando llegan las etiquetas — es decir, con el job de verdad terreno de 06-01. Los dos mecanismos son complementarios, no alternativos: la deriva de datos es el sistema de alerta temprana; las métricas reales son el juez.
Detección univariante: PSI paso a paso
El Population Stability Index (PSI) es la métrica clásica para cuantificar cuánto se ha movido la distribución de una variable entre una población de referencia (los datos de entrenamiento del champion) y una actual (por ejemplo, la última semana de la tabla de predicciones). Vamos a calcularlo a mano sobre horas_semana, porque entender el cálculo vale más que memorizar el umbral.
La receta:
- Divide el rango de la variable en bins (cubetas), definidos sobre la referencia (típicamente 10 cuantiles).
- Calcula qué proporción de la referencia cae en cada bin (
esperado) y qué proporción de los datos actuales cae en esos mismos bins (observado). - Para cada bin:
(observado - esperado) * ln(observado / esperado). Suma todos los bins.
import numpy as np
def psi(referencia: np.ndarray, actual: np.ndarray, n_bins: int = 10) -> float:
"""PSI de una variable numérica entre referencia y actual."""
# 1. Bordes de bins por cuantiles de la REFERENCIA (no de los datos actuales:
# la vara de medir se fija una vez y no se mueve con lo que medimos).
bordes = np.quantile(referencia, np.linspace(0, 1, n_bins + 1))
bordes[0], bordes[-1] = -np.inf, np.inf # que ningún valor actual quede fuera
# 2. Proporción de cada población en cada bin.
esperado = np.histogram(referencia, bins=bordes)[0] / len(referencia)
observado = np.histogram(actual, bins=bordes)[0] / len(actual)
# 3. Un bin vacío haría ln(0) = -inf; se sustituye por un valor pequeño.
esperado = np.where(esperado == 0, 1e-4, esperado)
observado = np.where(observado == 0, 1e-4, observado)
return float(np.sum((observado - esperado) * np.log(observado / esperado)))Y lo aplicamos con datos ficticios que imitan el escenario de la promo:
rng = np.random.default_rng(42)
# Referencia: horas_semana del entrenamiento del champion (media ~9 h).
horas_referencia = rng.gamma(shape=4.0, scale=2.2, size=20_000)
# Actual: la cohorte de la promo ve menos horas (todavía); media ~6 h.
horas_actual = rng.gamma(shape=3.0, scale=2.0, size=4_000)
print(f"PSI horas_semana: {psi(horas_referencia, horas_actual):.3f}")
# PSI horas_semana: 0.196Cómo leer el número, con los umbrales convencionales del sector:
| PSI | Interpretación | Acción en CineClick |
|---|---|---|
| < 0.10 | Estable | Nada |
| 0.10 – 0.25 | Cambio moderado | Vigilar; anotar en el dashboard |
| > 0.25 | Cambio significativo | Alerta a #alertas-ml → playbook |
Intuición de la fórmula: cada bin aporta según cuánto cambió su proporción (observado - esperado) ponderado por en qué dirección relativa (ln(observado/esperado)). Ambos factores tienen siempre el mismo signo, así que cada bin suma en positivo: PSI = 0 solo si nada se movió. Nuestro 0.196 dice "cambio moderado, aún no crítico" — coherente con una cohorte nueva que es minoría de la población total.
Alternativa habitual: el test de Kolmogorov-Smirnov (KS), que compara las distribuciones acumuladas y da un p-valor (scipy.stats.ks_2samp). Funciona bien, con una advertencia: con los volúmenes de una plataforma de streaming, KS declara "significativas" diferencias minúsculas e irrelevantes (con n enorme, todo es significativo). Por eso en la práctica industrial se prefiere PSI, que mide magnitud de cambio, no significancia estadística. Para las features categóricas (plan, metodo_pago), el PSI se calcula igual usando cada categoría como bin.
Detección multivariante en una pincelada
El PSI mira cada feature por separado, y eso tiene un punto ciego: las distribuciones marginales pueden mantenerse mientras cambian las correlaciones (por ejemplo, antes los clientes de plan premium eran los de más horas; ahora ya no — cada marginal intacta, la estructura conjunta rota).
La técnica conceptualmente más elegante para eso: entrena un clasificador cuya tarea sea distinguir si una fila viene de la referencia o de los datos actuales (etiqueta 0 = referencia, 1 = actual). Si el mejor clasificador posible no supera un AUC de ~0.5, las dos poblaciones son indistinguibles — no hay drift. Si alcanza 0.75, algo ha cambiado en la estructura conjunta, y sus feature importances te dicen dónde mirar. Es el "drift del score de un clasificador dominio-actual vs. referencia".
Nos quedamos en el concepto: para el tamaño y el riesgo del caso CineClick, el PSI univariante por feature más el prediction drift cubren la gran mayoría de los casos reales, y el clasificador de dominio queda como herramienta de investigación cuando los univariantes no explican lo que ves.
Evidently: deriva como informe reproducible
Calcular PSI a mano educa, pero mantener a mano el cálculo para 7 features, con visualizaciones, tests por tipo de variable y un JSON estructurado para automatizar, no escala. Aquí cumplimos el compromiso del módulo 1: Evidently es la herramienta de deriva de CineClick.
Dos ingredientes:
- Referencia: los datos de entrenamiento del champion v2. No una copia suelta: la versión exacta, recuperada con DVC (
dvc pulldel commit con el que se entrenó v2). Si la referencia no está versionada, el detector de deriva mide contra una vara de medir desconocida. - Actual: la última semana de la tabla de predicciones de 06-01, que guarda las features tal y como entraron al modelo.
# flows/deteccion_deriva.py (núcleo)
import pandas as pd
from evidently import Report, DataDefinition, Dataset
from evidently.presets import DataDriftPreset
FEATURES_NUM = ["antiguedad_meses", "horas_semana", "tickets_soporte", "ratio_tickets"]
FEATURES_CAT = ["plan", "metodo_pago", "descuento_activo"]
definicion = 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=definicion)
actual = Dataset.from_pandas(pd.read_parquet(ruta_actual), data_definition=definicion)
informe = Report([DataDriftPreset()])
resultado = informe.run(reference_data=referencia, current_data=actual)
resultado.save_html("informe_deriva.html") # para humanos
return resultado.dict() # para máquinasQué hace cada pieza:
DataDefinitionle dice a Evidently qué columna es qué. Importa porque el test estadístico adecuado depende del tipo (numérica vs. categórica); Evidently elige automáticamente según tipo y tamaño de muestra (Wasserstein, KS, Jensen-Shannon, PSI…).DataDriftPresetes un paquete de tests: deriva por columna + resumen global.- El
.dict()es la salida clave para automatizar. De ahí extraemos dos números: cuántas columnas derivaron ydrift_share, la fracción de columnas con deriva detectada.
Lectura del HTML: una fila por feature con su test, su score de deriva y un veredicto detected/not detected, más las distribuciones superpuestas referencia vs. actual. En nuestro escenario de la promo verías antiguedad_meses y horas_semana en rojo, plan desplazada hacia basico, y tickets_soporte tranquila — el patrón exacto de "cohorte nueva", que reconocerás en segundos con las gráficas delante.
Programar la vigilancia: Evidently dentro de Prefect
Un informe que alguien tiene que acordarse de generar es el problema que este módulo vino a eliminar. Lo convertimos en un flow de Prefect semanal, igual que el scoring de 05-03, sobre el mismo work pool cineclick-pool:
# flows/deteccion_deriva.py (orquestación)
from prefect import flow, task
from prefect.blocks.notifications import SlackWebhook
UMBRAL_DRIFT_SHARE = 0.3 # si más del 30% de las features derivan, alerta
@task(retries=2, retry_delay_seconds=300)
def cargar_referencia() -> str:
# dvc pull de los datos de entrenamiento del champion (versión fijada)
...
@task(retries=2, retry_delay_seconds=300)
def cargar_actual_ultima_semana() -> str:
# Lee las particiones de la tabla de predicciones de los últimos 7 días
...
@task
def evaluar_y_alertar(resultado: dict) -> None:
drift = resultado["metrics"][0]["result"] # resumen del preset
share = drift["share_of_drifted_columns"]
if share > UMBRAL_DRIFT_SHARE:
derivadas = [c for c, r in drift["drift_by_columns"].items() if r["drift_detected"]]
SlackWebhook.load("alertas-ml").notify(
f":warning: Deriva de datos en churn-cineclick: "
f"{share:.0%} de features derivadas ({', '.join(derivadas)}). "
f"Informe: informe_deriva.html — aplicar playbook de deriva."
)
@flow(name="deteccion-deriva-churn")
def deteccion_deriva():
ref, act = cargar_referencia(), cargar_actual_ultima_semana()
resultado = generar_informe_deriva(ref, act)
evaluar_y_alertar(resultado)Se despliega con el mismo patrón que scoring-semanal-churn: cron "0 7 * * 1" (lunes a las 07:00 Europe/Madrid, una hora después del scoring, para que la última semana esté completa y fresca). Reintentos, rastro de ejecuciones y alertas: todo el andamiaje de 05-03, reutilizado.
Detalle importante: la alerta lleva el qué (features derivadas y magnitud) y el dónde seguir (informe + playbook). Una alerta que solo dice "hay drift" obliga a empezar la investigación desde cero a las 07:00 de un lunes.
El playbook: qué hacer cuando salta la alarma
La reacción refleja — "hay drift, reentrenemos" — es la equivocada con la frecuencia suficiente como para merecer un procedimiento. Cuando llega la alerta, el orden de preguntas es este:
flowchart TD
A["Alerta de deriva en #alertas-ml"] --> B{"¿Es un error de datos<br/>upstream?"}
B -->|"Sí: nulos, unidades cambiadas,<br/>categoría nueva por typo"| C["Arreglar el dato con el equipo<br/>productor. NO reentrenar:<br/>aprenderías el bug"]
B -->|No| D{"¿Es un cambio legítimo<br/>de la población?"}
D -->|"Sí: cohorte nueva,<br/>promo, estacionalidad"| E{"¿Afecta al rendimiento?<br/>(proxies de 06-01 y, cuando<br/>lleguen, métricas reales)"}
D -->|No está claro| F["Investigar: clasificador de<br/>dominio, cortes por segmento"]
E -->|No, de momento| G["Documentar y vigilar<br/>de cerca esa cohorte"]
E -->|"Sí, o casi seguro"| H["Candidato a reentrenamiento<br/>→ decisión sistemática en 06-03"]Los tres desenlaces, en detalle:
- Error de datos upstream. El caso más común en la vida real: un cambio de esquema,
horas_semanaque pasa a venir en minutos, una migración que rellena nulos con 0. La validación con pandera (esquema_clientes, 05-01) atrapa los cambios de tipo y rango, pero un cambio de distribución con valores válidos se le escapa — para eso está el detector de deriva. La solución es arreglar el dato, jamás reentrenar sobre él. - Cohorte nueva legítima sin impacto (aún). La promo trae clientes reales; el modelo extrapola sobre ellos. Si los proxies siguen en banda y no hay señal de degradación, se documenta y se vigila. Reentrenar inmediatamente sería prematuro: la cohorte aún no tiene etiquetas de las que aprender (¡acaban de llegar, ninguno ha tenido tiempo de darse de baja!).
- Deriva con impacto en rendimiento. Aquí sí toca plantearse reentrenar. Pero cuándo exactamente, con qué política de disparo y con qué pipeline — eso es la decisión sistemática que construye la próxima lección.
Y el recordatorio final del apartado: todo lo anterior detecta cambios en P(X) y P(ŷ). La deriva de concepto — el patrón que deja de ser verdad con entradas de aspecto normal, como la subida de precio — es invisible para Evidently sin etiquetas. Solo la confirma el job de verdad terreno de 06-01, cuando el recall real cae semana tras semana con PSI tranquilo. Por eso el dashboard único tiene las dos filas: proxies y métricas reales se necesitan mutuamente.
Errores Comunes y Consejos
- Usar como referencia "los datos del mes pasado" en vez de los de entrenamiento. Contra una referencia deslizante, una degradación lenta y sostenida nunca dispara la alarma: cada semana se parece a la anterior. La referencia canónica son los datos de entrenamiento del champion (versionados con DVC); una referencia deslizante puede añadirse como segunda vista para detectar cambios bruscos.
- Confundir prediction drift con concept drift. Que cambien las salidas no significa que el patrón aprendido sea falso: casi siempre es data drift reflejado. El concept drift solo se confirma con etiquetas.
- Reentrenar como acto reflejo ante cualquier alerta. Si la causa es un bug de datos, el reentrenamiento lo aprende y lo consolida. Playbook primero, siempre.
- Umbrales de libro sin contexto. El 0.1/0.25 del PSI es una convención razonable para arrancar, no una ley. Calibra con tu histórico: calcula el PSI semana contra semana en épocas sanas y mira cuánto "ruido de fondo" tiene tu negocio (estacionalidad, campañas) antes de fijar el umbral de alerta.
- Ejecutar el detector sobre los datos crudos en vez de las features del modelo. Lo que degrada al modelo es la deriva en su espacio de entrada (incluida
ratio_tickets, que es derivada). Por eso la tabla de predicciones guarda las features procesadas. - Ignorar el tamaño de muestra. Con pocas filas actuales (un festivo, una caída de tráfico), los tests se vuelven ruidosos. Exige un mínimo de filas antes de dar el resultado por válido, o acumula más días.
Ejercicios
-
Clasifica la deriva. Para cada escenario en CineClick, identifica el tipo (data drift, prediction drift, concept drift, label shift) y si Evidently lo detectaría sin etiquetas: (a) una serie exclusiva de enorme éxito hace que clientes con pocas horas dejen de darse de baja durante dos meses; (b) el equipo de la app cambia el registro y ahora
metodo_pagollega como"CARD"en vez de"tarjeta"para los nuevos; (c) tras una campaña en televisión, la mediana deantiguedad_mesesde las peticiones baja de 26 a 9. -
PSI a mano. Con la función
psi()de la lección, genera una referenciarng.normal(10, 2, 10_000)y tres escenarios actuales: (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 cada uno, clasifícalo según la tabla de umbrales y explica el porqué de cada resultado. -
Diseña la referencia. Cuando en el futuro se promocione un nuevo champion v3 (entrenado con datos más recientes), ¿qué debe pasar con la referencia del detector de deriva y cómo lo implementarías para que ocurra sin intervención manual? Pista: piensa en qué "sabe" el registry sobre cada versión.
Soluciones
-
(a) Concept drift: cambia P(y|X) — el perfil "pocas horas" ya no implica riesgo como antes. Evidently sobre las entradas no vería nada (los clientes tienen el mismo aspecto); se manifestaría como caída de precision real en el job de 06-01 (predijimos bajas que no ocurren). (b) Data drift por error upstream: aparece una categoría nueva en P(X). Evidently sí lo detecta (deriva en la categórica
metodo_pago) y probablemente antes lo atraparía elesquema_clientesde pandera si la categoría no está permitida. Desenlace del playbook: arreglar upstream, no reentrenar. (c) Data drift legítimo (cohorte nueva): detectable sin etiquetas vía PSI enantiguedad_meses; casi seguro vendrá acompañado de prediction drift, porque la antigüedad baja empuja las probabilidades de churn al alza. -
Valores orientativos (varían con la semilla): (a) PSI ≈ 0.01–0.03 → estable; es la misma distribución, y el pequeño valor residual es ruido de muestreo — buena ilustración de que PSI nunca da exactamente 0. (b) PSI ≈ 0.25–0.30 → en la frontera del cambio significativo; media desplazada media desviación típica: las proporciones de los bins centrales se redistribuyen de forma apreciable. (c) PSI > 1 → cambio drástico; media desplazada dos desviaciones y varianza mayor: buena parte de la masa actual cae en los bins extremos de la referencia, y esos cocientes
observado/esperadoenormes disparan la suma. -
La referencia debe pasar a ser el dataset de entrenamiento de v3, en el momento exacto de la promoción — ni antes (mediríamos al champion actual con la vara del futuro) ni nunca (mediríamos a v3 contra datos de v2, generando drift fantasma permanente). Implementación: al registrar cada versión en MLflow se guarda como tag el
datos_dvc_md5de su dataset de entrenamiento (el linaje que el flow de reentrenamiento formalizará en 06-03). La taskcargar_referenciadel flow no lleva ruta fija: consulta el registry pormodels:/churn-cineclick@champion, lee ese tag y hacedvc pullde esa versión exacta. Así, promocionar el champion actualiza la referencia automáticamente, sin tocar el flow.
Conclusión
Ya sabes ponerle nombre y número a lo que en 06-01 era solo "algo se mueve". Los cuatro tipos de deriva quedan delimitados — data drift y prediction drift, detectables hoy sin etiquetas; concept drift y label shift, que solo la verdad terreno confirma — y CineClick tiene un detector en producción: PSI entendido desde la fórmula, Evidently comparando cada lunes la referencia versionada del champion contra la última semana de la tabla de predicciones, y una alerta en #alertas-ml con playbook incluido cuando drift_share cruza el umbral. La lección deja una puerta abierta a propósito: el playbook termina en "candidato a reentrenamiento", pero no dice cuándo se dispara, con qué datos se construye el nuevo dataset sin cometer el leakage temporal que anunciamos en 03-03, ni cómo el resultado llega a producción sin saltarse las salvaguardas del módulo 5. Ese es exactamente el contenido de la siguiente lección: el reentrenamiento automatizado, la pieza que cierra de verdad el ciclo de vida que dibujamos en 01-02.
Curso de MLOps
Módulo 1: Fundamentos de MLOps
- Qué es MLOps y por qué los modelos mueren en el notebook
- El ciclo de vida de un modelo de ML en producción
- Niveles de madurez MLOps y roles del equipo
- El proyecto del curso: del notebook a producción
Módulo 2: Del notebook al código reproducible
- Estructura de un proyecto de ML: del notebook al paquete
- Entornos reproducibles y gestión de dependencias
- Versionado de datos con DVC
- Pipelines de entrenamiento reproducibles
Módulo 3: Experimentos y registro de modelos
- Tracking de experimentos con MLflow
- Model registry: versionar y promocionar modelos
- Feature stores: cuándo y para qué
Módulo 4: Servir modelos en producción
- Patrones de despliegue: batch, online y streaming
- Un servicio de predicción con FastAPI
- Empaquetado con Docker
- Escalado y despliegue: Kubernetes y serverless
- Optimización de la inferencia: latencia y coste
Módulo 5: Automatización: CI/CD y orquestación
- CI para ML: tests de código, datos y modelos
- CD: automatizar el despliegue del modelo
- Orquestación de pipelines de ML
- Estrategias de release: shadow, canary y A/B
