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

  1. Qué cambia en CI cuando hay ML: tres cosas que testear
  2. La pirámide de tests para ML
  3. Tests de código: features y API
  4. Tests de datos: esquema y expectativas con pandera
  5. Tests de modelo: humo, comportamiento y umbral de métricas
  6. El workflow: .github/workflows/ci.yml explicado por bloques
  7. 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.csv y 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 pytest no 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] == 0

Puntos que conviene entender bien:

  • _cliente_base como 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_features debe usar una lista fija de categorías conocidas (por ejemplo pd.Categorical con categorías explícitas), porque pd.get_dummies a 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 == 422

En 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_meses entre 0 y 240 (20 años; CineClick no existía antes), horas_semana entre 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: plan y metodo_pago solo admiten los valores conocidos. Si aparece un plan nuevo legítimo, este check debe fallar: es la señal para actualizar features.py, reentrenar y actualizar el esquema, en ese orden y a conciencia — no en silencio.
  • Proporción de nulos: horas_semana admite 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=True convierte "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:

  1. 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 hace dvc pull solo de esa muestra.
  2. Antes de cada entrenamiento: la primera acción del stage preparar_datos del dvc.yaml (y, como veremos en 05-03, la segunda task del flow de scoring) es validar el dataset completo con el mismo esquema_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_cliente es un identificador arbitrario; si cambiarlo cambia la probabilidad de churn, el modelo (o más probablemente features.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_soporte está, 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.02

El 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 -q

Bloque a bloque:

  • on: — dos disparadores con intención distinta. push a ramas de trabajo da feedback rápido mientras desarrollas; pull_request hacia main es la puerta con todos los controles.
  • actions/checkout@v4 — clona el repo en el runner. Sin esto no hay nada que testear.
  • setup-python con cache: 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 marcador modelo (declarado en pyproject.toml) separa lo barato de lo caro.
  • dvc pull con credenciales en secrets — las credenciales del remote almacen viven 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 toca src/** o si alguien pone la etiqueta tests-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)
Tests de código (features, API)
Tests de datos (pandera sobre la muestra) No
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-learn a pelo. El runner resolverá las versiones del día y tus tests validarán un entorno que no es el de producción. Siempre desde requirements.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_datos y, 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] >= 0

La 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

"pais": Column(str, Check.isin(["ES", "PT", "AD"]), nullable=False),

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.

© Copyright 2026. Todos los derechos reservados