El módulo 4 terminó con una confesión incómoda: todo el sistema de CineClick funciona, pero el pegamento son manos. Los tests se ejecutan "si alguien se acuerda", y eso significa que un git push un viernes por la tarde puede romper features.py sin que nadie lo note hasta que el batch del lunes produzca predicciones absurdas. La integración continua (CI) resuelve exactamente eso: cada cambio dispara automáticamente una batería de comprobaciones, y el cambio no se integra si algo falla. Pero en un proyecto de ML la CI clásica se queda corta, porque el comportamiento del sistema no depende solo del código: depende del código, de los datos y del modelo. En esta lección construiremos la CI real de cineclick-churn: ampliaremos los tests de código existentes, añadiremos validación de datos con pandera, escribiremos tests de modelo (de humo, de comportamiento y de umbral de métricas) y lo ataremos todo con un workflow de GitHub Actions.
Contenido
- Qué cambia en CI cuando hay ML: tres cosas que testear
- La pirámide de tests para ML
- Tests de código: features y API
- Tests de datos: esquema y expectativas con pandera
- Tests de modelo: humo, comportamiento y umbral de métricas
- El workflow:
.github/workflows/ci.ymlexplicado por bloques - Qué corre en cada evento
Qué cambia en CI cuando hay ML: tres cosas que testear
En una aplicación web tradicional, CI significa: en cada push, ejecuta linter y tests; si pasan, el código puede integrarse en main. La premisa implícita es que el código es la única fuente de comportamiento: si el código no cambia, el sistema se comporta igual.
En ML esa premisa es falsa. El comportamiento del servicio de churn de CineClick es el producto de tres ingredientes, y cualquiera de los tres puede degradarse de forma independiente:
- Código:
features.py,train.py, el API de FastAPI. Cambia cuando Laura o Marc hacen commits. Se testea como siempre: tests unitarios y de integración. - Datos:
clientes_churn.csvy sus futuras actualizaciones. Cambian cuando el equipo de datos exporta una versión nueva, aunque nadie toque una línea de código. Un cambio de esquema silencioso (una columna renombrada, un plan nuevo, nulos donde no los había) puede envenenar el entrenamiento sin producir ningún error de Python. - Modelo: el artefacto registrado en MLflow. Un modelo reentrenado con los mismos hiperparámetros puede ser peor que el anterior si los datos han cambiado, y
pytestno tiene ni idea de qué es un recall.
La consecuencia práctica: nuestra CI necesita tres familias de tests, no una. Y cada familia falla por motivos distintos y protege contra riesgos distintos:
| Familia | Qué valida | Contra qué protege | Ejemplo en CineClick |
|---|---|---|---|
| Tests de código | Que las funciones hacen lo que dicen | Bugs de programación clásicos | ratio_tickets con antigüedad 0 no divide por cero |
| Tests de datos | Que los datos cumplen el contrato esperado | Cambios silenciosos en la fuente de datos | Un plan nuevo ("familiar") que el one-hot no conoce |
| Tests de modelo | Que el modelo se comporta razonablemente y rinde lo suficiente | Modelos degradados o con lógica invertida | Un challenger con recall 0.40 no puede promocionarse |
La pirámide de tests para ML
La pirámide clásica de testing (muchos tests unitarios baratos abajo, pocos tests end-to-end caros arriba) sigue aplicando, pero en ML se le añaden capas. Ordenadas de más baratas y frecuentes a más caras y esporádicas:
| Nivel | Tipo de test | Coste | Cuándo corre | Necesita |
|---|---|---|---|---|
| 1 | Linter + unitarios de código | Segundos | Cada push | Solo el código |
| 2 | Tests del API (validación, contratos) | Segundos | Cada push | Código + un modelo falso o ninguno |
| 3 | Tests de datos (esquema, expectativas) | Segundos-minutos | Cada PR y antes de cada entrenamiento | Una muestra versionada de datos |
| 4 | Test de humo de entrenamiento | ~1 minuto | Cada PR que toca src/ |
Datos sintéticos |
| 5 | Tests de comportamiento del modelo | Minutos | PRs que tocan src/ o promociones |
Un modelo real (challenger) |
| 6 | Umbral de métricas contra test set | Minutos | Antes de promocionar un modelo | Modelo real + test set versionado |
La regla de oro: lo barato corre siempre; lo caro corre cuando aporta. No tiene sentido evaluar el recall del challenger en cada push a una rama de trabajo, pero sí es obligatorio antes de que ese challenger aspire a @champion.
Tests de código: features y API
Ampliar tests/test_features.py con casos borde
En el módulo 3 decidimos que features.py es la única fuente de verdad de las features: la usan el entrenamiento, el API online y el scoring batch. Eso la convierte en el fichero más crítico del repo — un bug aquí se propaga a los tres consumidores a la vez. Merece los tests más exhaustivos.
Recordemos el detalle fijado en su día: ratio_tickets se calcula como tickets de soporte por mes de antigüedad, con clip(lower=1) en el denominador para evitar la división por cero con clientes recién llegados. Eso es exactamente el tipo de decisión que un compañero "simplifica" sin querer en un refactor. Lo blindamos con tests:
# tests/test_features.py (ampliación)
import pandas as pd
import pytest
from cineclick_churn.features import construir_features
def _cliente_base(**cambios) -> pd.DataFrame:
"""Devuelve un cliente sintético válido; los kwargs sobreescriben campos."""
base = {
"id_cliente": "C-0001",
"antiguedad_meses": 12,
"horas_semana": 6.5,
"tickets_soporte": 2,
"plan": "premium",
"metodo_pago": "tarjeta",
"descuento_activo": 0,
}
base.update(cambios)
return pd.DataFrame([base])
def test_ratio_tickets_con_antiguedad_cero_no_divide_por_cero():
"""Un cliente en su primer mes tiene antiguedad_meses = 0.
El clip(lower=1) debe tratar el denominador como 1, no explotar."""
df = construir_features(_cliente_base(antiguedad_meses=0, tickets_soporte=3))
assert df["ratio_tickets"].iloc[0] == pytest.approx(3.0)
def test_ratio_tickets_caso_normal():
df = construir_features(_cliente_base(antiguedad_meses=10, tickets_soporte=5))
assert df["ratio_tickets"].iloc[0] == pytest.approx(0.5)
def test_one_hot_categoria_desconocida_no_rompe_ni_desalinea():
"""Si mañana marketing lanza el plan 'familiar', el one-hot no puede
inventarse una columna nueva (desalinearia el modelo entrenado):
debe producir las MISMAS columnas, con los planes conocidos a 0."""
df = construir_features(_cliente_base(plan="familiar"))
columnas_plan = [c for c in df.columns if c.startswith("plan_")]
assert sorted(columnas_plan) == ["plan_basico", "plan_estandar", "plan_premium"]
assert df[columnas_plan].sum(axis=1).iloc[0] == 0Puntos que conviene entender bien:
_cliente_basecomo fábrica de fixtures: en vez de repetir un diccionario de 7 campos en cada test, se define uno válido y cada test cambia solo lo relevante. Los tests se leen como frases: "un cliente con antigüedad 0 y 3 tickets tiene ratio 3".- El test del one-hot codifica una decisión de diseño, no solo un cálculo:
construir_featuresdebe usar una lista fija de categorías conocidas (por ejemplopd.Categoricalcon categorías explícitas), porquepd.get_dummiesa secas genera columnas según lo que ve, y un plan nuevo en producción produciría un DataFrame con columnas distintas a las del entrenamiento — el modelo fallaría o, peor, predeciría sobre columnas desordenadas. - Los docstrings explican el porqué. Cuando este test falle dentro de un año, quien lo lea sabrá qué protege.
Tests del API: tests/test_api.py
El API ya tiene su test de validación del módulo 4: comprobar que una petición malformada devuelve 422 sin invocar al modelo. Este test es oro en CI porque no necesita MLflow ni artefactos — corre en segundos en cualquier runner:
# tests/test_api.py (recordatorio de la pieza clave)
from fastapi.testclient import TestClient
from cineclick_churn.api.main import app
cliente = TestClient(app)
def test_peticion_invalida_devuelve_422():
"""horas_semana negativas violan el esquema Pydantic: FastAPI corta
en la validación y el modelo ni se carga ni se invoca."""
respuesta = cliente.post("/predecir", json={
"id_cliente": "C-0001",
"antiguedad_meses": 12,
"horas_semana": -3, # inválido
"tickets_soporte": 2,
"plan": "premium",
"metodo_pago": "tarjeta",
"descuento_activo": 0,
})
assert respuesta.status_code == 422En CI este test valida el contrato de entrada del servicio: los esquemas de schemas.py son la frontera entre el mundo exterior y el modelo, y cualquier relajación accidental (alguien quita una restricción de Pydantic) se detecta aquí.
Tests de datos: esquema y expectativas con pandera
Por qué no bastan los asserts a mano
Se puede validar un DataFrame con asserts sueltos (assert df["plan"].isin(...)), y para casos triviales es suficiente. Pero un esquema declarativo con pandera aporta tres cosas que los asserts no dan: todos los errores de golpe (no solo el primero), un informe legible de qué falló y en qué filas, y un objeto esquema que se puede importar desde cualquier punto (CI, entrenamiento, scoring batch) — de nuevo, una única fuente de verdad, esta vez del contrato de datos.
El esquema de clientes_churn.csv
Lo definimos en el paquete, no en los tests, precisamente para poder reutilizarlo:
# src/cineclick_churn/validacion.py
import pandera as pa
from pandera import Check, Column
PLANES_VALIDOS = ["basico", "estandar", "premium"]
METODOS_PAGO_VALIDOS = ["tarjeta", "domiciliacion", "paypal"]
esquema_clientes = pa.DataFrameSchema(
columns={
"id_cliente": Column(str, Check.str_matches(r"^C-\d{4,}$"), unique=True),
"antiguedad_meses": Column(int, Check.in_range(0, 240)),
"horas_semana": Column(float, Check.in_range(0, 168), nullable=True),
"tickets_soporte": Column(int, Check.ge(0)),
"plan": Column(str, Check.isin(PLANES_VALIDOS)),
"metodo_pago": Column(str, Check.isin(METODOS_PAGO_VALIDOS)),
"descuento_activo": Column(int, Check.isin([0, 1])),
"abandono": Column(int, Check.isin([0, 1])),
},
checks=[
# Expectativas sobre el dataset completo, no columna a columna:
Check(lambda df: df["horas_semana"].isna().mean() < 0.05,
error="mas del 5% de nulos en horas_semana"),
Check(lambda df: df["abandono"].mean().round(2) <= 0.30,
error="tasa de churn sospechosamente alta (>30%): revisar la extraccion"),
],
strict=True, # columnas de mas tambien son un error
coerce=True, # intenta convertir tipos antes de fallar
)Desglose de las decisiones:
- Tipos y rangos:
antiguedad_mesesentre 0 y 240 (20 años; CineClick no existía antes),horas_semanaentre 0 y 168 (no hay más horas en una semana). Los rangos imposibles son la trampa más habitual de una exportación rota. - Categorías cerradas:
planymetodo_pagosolo admiten los valores conocidos. Si aparece un plan nuevo legítimo, este check debe fallar: es la señal para actualizarfeatures.py, reentrenar y actualizar el esquema, en ese orden y a conciencia — no en silencio. - Proporción de nulos:
horas_semanaadmite nulos (hay clientes sin telemetría), pero si superan el 5% algo va mal en la instrumentación. Es una expectativa estadística, no una regla por fila. - Tasa de churn ≤ 30%: el dataset histórico ronda el 15%. Si de repente llega un 45%, lo más probable no es una estampida de clientes sino un filtro roto en la extracción. Validar la forma de la variable objetivo evita entrenar con basura.
strict=Trueconvierte "alguien añadió una columna" en un error visible en vez de en una sorpresa.
Validar es una línea: esquema_clientes.validate(df, lazy=True) (con lazy=True pandera acumula todos los errores en un solo informe en lugar de parar en el primero).
Cuándo corren los tests de datos
Aquí hay una sutileza importante: los datos completos no están en el repo (están en el remote almacen de DVC), y CI no debería descargar gigabytes en cada PR. La solución tiene dos patas:
- En CI: se valida una muestra versionada (
data/muestras/clientes_churn_muestra.csv, unas 2.000 filas, trackeada con DVC igual que el dataset completo). Detecta cambios de esquema y de contrato con coste mínimo. El workflow hacedvc pullsolo de esa muestra. - Antes de cada entrenamiento: la primera acción del stage
preparar_datosdeldvc.yaml(y, como veremos en 05-03, la segunda task del flow de scoring) es validar el dataset completo con el mismoesquema_clientes. Mismo esquema, dos ubicaciones: CI vigila el contrato; el pipeline vigila los datos reales del día.
Tests de modelo: humo, comportamiento y umbral de métricas
Test de humo de entrenamiento
El test más rentable de toda esta lección: comprobar que el pipeline de entrenamiento funciona de principio a fin con datos sintéticos diminutos. No valida calidad — valida que nadie ha roto la cadena datos → features → fit → predict:
# tests/test_modelo.py
import numpy as np
import pandas as pd
import pytest
from cineclick_churn.features import construir_features
from cineclick_churn.train import entrenar_modelo
RNG = np.random.default_rng(42)
def _datos_sinteticos(n: int = 200) -> pd.DataFrame:
"""200 filas con la misma pinta que clientes_churn.csv. Ficticias:
generadas al azar con distribuciones plausibles."""
return pd.DataFrame({
"id_cliente": [f"C-{i:04d}" for i in range(n)],
"antiguedad_meses": RNG.integers(0, 60, n),
"horas_semana": RNG.uniform(0, 30, n).round(1),
"tickets_soporte": RNG.poisson(1.5, n),
"plan": RNG.choice(["basico", "estandar", "premium"], n),
"metodo_pago": RNG.choice(["tarjeta", "domiciliacion", "paypal"], n),
"descuento_activo": RNG.integers(0, 2, n),
"abandono": RNG.choice([0, 1], n, p=[0.85, 0.15]),
})
def test_humo_entrenamiento_produce_modelo_que_predice():
"""Entrena con 200 filas sinteticas en segundos y comprueba que el
resultado es un modelo capaz de emitir probabilidades validas."""
df = _datos_sinteticos()
X = construir_features(df.drop(columns=["abandono"]))
modelo = entrenar_modelo(X, df["abandono"], n_estimators=10, max_depth=3)
probas = modelo.predict_proba(X)[:, 1]
assert probas.shape == (200,)
assert ((probas >= 0) & (probas <= 1)).all()Fíjate en los hiperparámetros enanos (n_estimators=10): no queremos el modelo de producción, queremos velocidad. Este test tarda segundos y habría cazado la mitad de los "funciona en mi máquina" del módulo 1.
Tests de comportamiento: invariancia y direccionalidad
Esta idea viene del paper CheckList (Ribeiro et al., 2020), pensado originalmente para modelos de NLP, y su intuición traduce perfectamente a tabular: además de preguntar "¿qué métrica saca el modelo?", hay que preguntar "¿se comporta el modelo según lo que sabemos del problema?". Un modelo puede tener buen recall global y aun así haber aprendido una relación absurda que explotará con datos futuros. Dos tipos de check:
- Invariancia: hay entradas que no deberían afectar a la predicción.
id_clientees un identificador arbitrario; si cambiarlo cambia la probabilidad de churn, el modelo (o más probablementefeatures.py) está filtrando el identificador como feature — un bug grave y silencioso. - Direccionalidad: hay cambios cuyo sentido conocemos aunque no sepamos la magnitud. Un cliente idéntico con más
tickets_soporteestá, como mínimo, igual de insatisfecho: su probabilidad de churn no debería bajar. Si baja, o las features están mal construidas o el modelo aprendió ruido en esa zona.
def test_invariancia_id_cliente(modelo_challenger):
"""Cambiar solo el id_cliente no puede mover la prediccion."""
a = _cliente_base(id_cliente="C-0001")
b = _cliente_base(id_cliente="C-9999")
p_a = modelo_challenger.predict_proba(construir_features(a))[0, 1]
p_b = modelo_challenger.predict_proba(construir_features(b))[0, 1]
assert p_a == pytest.approx(p_b)
def test_direccionalidad_tickets_soporte(modelo_challenger):
"""Mas tickets de soporte en un cliente tipo => probabilidad de churn
igual o mayor. Toleramos ruido minimo del bosque (-0.02)."""
pocos = _cliente_base(tickets_soporte=1)
muchos = _cliente_base(tickets_soporte=8)
p_pocos = modelo_challenger.predict_proba(construir_features(pocos))[0, 1]
p_muchos = modelo_challenger.predict_proba(construir_features(muchos))[0, 1]
assert p_muchos >= p_pocos - 0.02El fixture modelo_challenger carga models:/churn-cineclick@challenger desde el registry (necesita MLFLOW_TRACKING_URI, por eso estos tests llevan marca @pytest.mark.modelo y no corren en cualquier push). Nota la tolerancia -0.02 en la direccionalidad: un RandomForest no es monótono por construcción y exigir monotonicidad estricta en un punto daría falsos positivos; lo que cazamos es una inversión clara de la relación.
Umbral mínimo de métricas: el criterio de negocio hecho test
En el módulo 3 el criterio de promoción quedó escrito en prosa: "precision ≥ 0.50 si recall > 0.60". La prosa no bloquea merges; los tests sí. Lo convertimos en el gate final, evaluando al challenger contra el test set versionado (data/procesado/test.csv, el split con test_size=0.2 y random_state=42 de params.yaml, trackeado por DVC — si el test set cambiara en cada ejecución, las métricas no serían comparables entre candidatos):
from sklearn.metrics import precision_score, recall_score
RECALL_MINIMO = 0.60
PRECISION_MINIMA = 0.50
@pytest.mark.modelo
def test_umbral_metricas_challenger(modelo_challenger, test_set_versionado):
X, y = test_set_versionado
pred = modelo_challenger.predict(construir_features(X))
recall = recall_score(y, pred)
precision = precision_score(y, pred)
assert recall >= RECALL_MINIMO, f"recall {recall:.3f} < {RECALL_MINIMO}"
assert precision >= PRECISION_MINIMA, f"precision {precision:.3f} < {PRECISION_MINIMA}"Como referencia: el champion actual (v2) da recall 0.68 y precision 0.55, así que pasa con margen. Un challenger que no pase este test no llega ni a la conversación de promoción — la revisión humana del módulo 3 sigue existiendo (la retomamos en 05-02), pero ahora revisa solo candidatos que ya superaron el listón automático.
El workflow: .github/workflows/ci.yml explicado por bloques
Todo lo anterior necesita un motor que lo ejecute solo. Este es el workflow real del repo:
# .github/workflows/ci.yml
name: CI
on:
push:
branches-ignore: [main] # ramas de trabajo
pull_request:
branches: [main] # la puerta de entrada a main
jobs:
lint-y-tests-rapidos:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip # cachea ~/.cache/pip entre ejecuciones
- name: Instalar dependencias bloqueadas
run: |
pip install -r requirements.lock
pip install -e .
- name: Linter
run: ruff check src tests
- name: Tests rapidos (codigo y API)
run: pytest tests -m "not modelo" -q
tests-datos:
needs: lint-y-tests-rapidos
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: Descargar muestra versionada
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DVC_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DVC_SECRET_ACCESS_KEY }}
run: dvc pull data/muestras/clientes_churn_muestra.csv
- name: Validar esquema y expectativas
run: pytest tests/test_datos.py -q
tests-modelo:
needs: tests-datos
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: cambios
with:
filters: |
src:
- 'src/**'
- uses: actions/setup-python@v5
if: steps.cambios.outputs.src == 'true' || contains(github.event.pull_request.labels.*.name, 'tests-modelo')
with: { python-version: "3.11", cache: pip }
- name: Tests de modelo (humo, comportamiento, umbrales)
if: steps.cambios.outputs.src == 'true' || contains(github.event.pull_request.labels.*.name, 'tests-modelo')
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/procesado/test.csv
pytest tests/test_modelo.py -m modelo -qBloque a bloque:
on:— dos disparadores con intención distinta.pusha ramas de trabajo da feedback rápido mientras desarrollas;pull_requesthaciamaines la puerta con todos los controles.actions/checkout@v4— clona el repo en el runner. Sin esto no hay nada que testear.setup-pythonconcache: pip— instala Python 3.11 (la versión fijada en el módulo 2) y cachea los paquetes descargados; la segunda ejecución instala en segundos.pip install -r requirements.lock— instalamos desde el lock de pip-tools, no desde dependencias sueltas: el runner reproduce exactamente el entorno de producción (scikit-learn 1.5.2, pandas 2.2.3, FastAPI 0.115.5). CI con versiones flotantes es CI que miente.ruff+pytest -m "not modelo"— linter y todos los tests que no requieren registry ni datos. El marcadormodelo(declarado enpyproject.toml) separa lo barato de lo caro.dvc pullcon credenciales ensecrets— las credenciales del remotealmacenviven en los secrets del repositorio de GitHub (Settings → Secrets and variables → Actions), nunca en el YAML ni en el código. El workflow las inyecta como variables de entorno solo en el paso que las necesita.dorny/paths-filter+ etiqueta — los tests de modelo solo corren si la PR tocasrc/**o si alguien pone la etiquetatests-modelo(útil para forzarlos en una PR de datos o de configuración). Es el mecanismo que implementa "lo caro corre cuando aporta".needs:— encadena los jobs: si el linter falla, no gastamos tiempo (ni minutos de runner) en descargar datos.
Qué corre en cada evento
| Comprobación | push a rama de trabajo |
PR hacia main |
|---|---|---|
| ruff (linter) | Sí | Sí |
| Tests de código (features, API) | Sí | Sí |
| Tests de datos (pandera sobre la muestra) | No | Sí |
| Test de humo de entrenamiento | No | Sí, si toca src/ o lleva etiqueta |
| Tests de comportamiento del modelo | No | Sí, si toca src/ o lleva etiqueta |
| Umbral de métricas del challenger | No | Sí, si toca src/ o lleva etiqueta |
Y una fila que falta a propósito: construir y publicar la imagen Docker. Eso ya no es integración sino entrega, y es exactamente el tema de la próxima lección.
Errores Comunes y Consejos
- Instalar en CI con
pip install pandas scikit-learna pelo. El runner resolverá las versiones del día y tus tests validarán un entorno que no es el de producción. Siempre desderequirements.lock. - Tests de comportamiento demasiado estrictos. Exigir monotonicidad exacta a un RandomForest produce tests intermitentes (flaky), y un test flaky acaba ignorado — que es peor que no tenerlo. Usa tolerancias pequeñas y clientes "tipo" representativos.
- Validar los datos solo en CI. La muestra de CI vigila el contrato, pero los datos frescos de producción se validan en el pipeline (stage
preparar_datosy, desde 05-03, en el flow de scoring). Si solo validas la muestra, el lunes entrenarás con lo que sea que haya llegado. - Meter las credenciales de DVC en el YAML "solo para probar". Quedan en el historial de Git para siempre. Secrets de GitHub desde el primer minuto, y rota la clave si alguna vez se escapó.
- Umbral de métricas contra un test set que cambia. Si cada ejecución hace su propio split, dos candidatos nunca se comparan sobre el mismo terreno. El test set es un artefacto versionado con DVC, punto.
- Convertir la CI en un muro de 40 minutos. Si cada push tarda media hora, la gente agrupa cambios enormes o busca cómo saltársela. Protege el ciclo rápido (nivel 1-2 de la pirámide en menos de 5 minutos) y empuja lo caro a las PRs.
- Consejo: cuando un bug llegue a producción pese a la CI, la retrospectiva correcta no es "¿quién fue?" sino "¿qué test faltaba?". Añade ese test en el mismo fix. Así crece una suite que refleja los fallos reales de tu sistema.
Ejercicios
Ejercicio 1
Marc propone añadir la feature dias_desde_ultimo_ticket. Escribe dos tests unitarios para ella en el estilo de test_features.py: (a) un cliente que nunca ha abierto ticket (el campo fuente fecha_ultimo_ticket es nulo) debe producir un valor centinela de 9999, no un nulo ni un error; (b) el valor nunca puede ser negativo aunque llegue una fecha futura por error de datos.
Ejercicio 2
Amplía esquema_clientes con dos comprobaciones nuevas: (a) una columna pais con categorías permitidas ["ES", "PT", "AD"] y sin nulos; (b) una expectativa a nivel de dataset: ningún cliente con descuento_activo = 1 puede tener plan = "basico" (el descuento solo aplica a planes de pago superiores). Escribe solo el fragmento de esquema.
Ejercicio 3
El job tests-modelo tarda 8 minutos y el equipo se queja de que las PRs de documentación (cambios solo en docs/) también lo esperan. ¿Lo esperan de verdad, con el workflow tal y como está escrito? Razona la respuesta mirando el YAML y explica qué papel juega dorny/paths-filter.
Soluciones
Ejercicio 1
def test_dias_desde_ultimo_ticket_sin_tickets_usa_centinela():
df = construir_features(_cliente_base(fecha_ultimo_ticket=None))
assert df["dias_desde_ultimo_ticket"].iloc[0] == 9999
assert not df["dias_desde_ultimo_ticket"].isna().any()
def test_dias_desde_ultimo_ticket_nunca_negativo():
"""Una fecha futura (error de datos) no puede producir dias negativos:
se espera clip(lower=0) en el calculo."""
df = construir_features(_cliente_base(fecha_ultimo_ticket="2099-01-01"))
assert df["dias_desde_ultimo_ticket"].iloc[0] >= 0La clave del apartado (a) es el doble assert: no basta con no fallar, hay que comprobar el centinela concreto y la ausencia de nulos (un nulo aquí rompería a scikit-learn más tarde, lejos del origen del problema). En (b), el test fija la decisión de diseño (clip(lower=0)) igual que hicimos con ratio_tickets.
Ejercicio 2
y en la lista checks del esquema:
Check(
lambda df: ~((df["descuento_activo"] == 1) & (df["plan"] == "basico")).any(),
error="hay clientes con descuento activo en plan basico",
),La segunda es una regla de negocio entre columnas: no se puede expresar como check de una sola columna, por eso va en los checks a nivel de DataFrame. Este tipo de regla es la que detecta corrupciones lógicas que los tipos y rangos no ven.
Ejercicio 3
No lo esperan (casi): el job arranca, pero dorny/paths-filter evalúa los ficheros tocados por la PR y, como docs/** no casa con el filtro src/**, la salida steps.cambios.outputs.src es 'false'. Todos los pasos pesados llevan if: sobre esa salida (y sobre la etiqueta tests-modelo), así que se saltan: el job termina en segundos habiendo hecho solo el checkout y el filtro. El coste real de una PR de docs es el job rápido de lint y tests, que sí corre siempre — y eso es correcto: hasta una PR de docs puede romper un import si toca un fichero de más.
Conclusión
La CI de cineclick-churn ya no depende de la memoria de nadie: cada push pasa linter y tests de código en minutos; cada PR hacia main valida además el contrato de datos con pandera sobre una muestra versionada; y cuando el cambio toca src/, el candidato demuestra que entrena (humo), que razona (invariancia respecto a id_cliente, direccionalidad de tickets_soporte) y que rinde (recall ≥ 0.60 y precision ≥ 0.50 contra el test set versionado — el criterio de negocio del módulo 3 convertido en código que bloquea merges). La pirámide completa, con lo barato corriendo siempre y lo caro corriendo cuando aporta. Pero fíjate en dónde termina: en un tick verde. El código validado sigue sin llegar a producción solo — la imagen se sigue construyendo en un portátil y el kubectl apply se sigue tecleando. La siguiente lección cierra ese hueco con la entrega continua, y con una particularidad que es puro ML: en CineClick no hay un pipeline de despliegue, hay dos — uno para el servicio y otro para el modelo — y confundirlos sale caro.
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
