Contenido

  1. Por qué el notebook no escala (y para qué sí sirve)
  2. El diseño del repositorio cineclick-churn
  3. Refactorización paso a paso: datos, features, entrenamiento y evaluación
  4. Configuración externa: config.yaml en lugar de valores hardcodeados
  5. pyproject.toml: el proyecto como paquete instalable
  6. Un primer test con pytest (adelanto)

Por qué el notebook no escala (y para qué sí sirve)

Empecemos con justicia: el notebook no es el enemigo. Es la mejor herramienta que existe para la fase de exploración —iterar rápido, visualizar, probar hipótesis, documentar hallazgos junto al código— y esa fase no desaparece con MLOps. Laura seguirá usando notebooks, y tú también. El problema es usar el notebook como unidad de producción, porque como tal tiene defectos estructurales que ningún parche arregla:

  • Estado oculto y ejecución fuera de orden: las celdas pueden ejecutarse en cualquier orden, y una variable puede venir de una celda que ya no existe. El resultado de un notebook depende de su historia de ejecución, no solo de su código. En producción eso es inaceptable: el mismo código debe producir el mismo resultado.
  • No es importable: no puedes hacer from notebook import limpiar_datos desde un servicio FastAPI ni desde un test. Todo lo que vive en un notebook está condenado a copiarse y pegarse — y las copias divergen (el problema nº 9 del diagnóstico: para predecir en producción habría que reimplementar el preprocesado).
  • No se testea bien: pytest descubre y ejecuta funciones en módulos .py. Un notebook no expone funciones testeables; expone celdas.
  • Los diffs son ilegibles: un .ipynb es un JSON con salidas, contadores de ejecución e imágenes en base64 incrustadas. Un git diff de dos versiones es ruido; revisar un cambio en un pull request, imposible.
  • Todo en un solo bloque: carga, limpieza, features, entrenamiento y evaluación mezclados significa que no puedes re-ejecutar solo una parte (algo que será oro en la lección 02-04) ni razonar sobre cada pieza por separado.

La división de trabajo sana es esta:

Herramienta Para qué SÍ Para qué NO
Notebook Explorar datos, prototipar features, visualizar, análisis puntuales, comunicar hallazgos Código de producción, lógica reutilizable, cualquier cosa que deba ejecutarse sin humanos
Paquete Python (módulos .py) Lógica de datos/features/entrenamiento/evaluación, todo lo testeable y automatizable Exploración interactiva rápida (se puede, pero es más lento iterar)

La regla práctica: el notebook descubre; el paquete industrializa. Cuando un fragmento del notebook demuestra su valor, se "gradúa" a un módulo del paquete, y el notebook pasa a importarlo en lugar de contener su copia.

El diseño del repositorio cineclick-churn

Creamos un repositorio Git llamado cineclick-churn con esta estructura (léela con calma; cada directorio tiene una razón de ser):

cineclick-churn/
├── src/
│   └── cineclick_churn/        # El paquete Python (código de producción)
│       ├── __init__.py
│       ├── data.py             # Carga y limpieza de datos
│       ├── features.py         # Construcción de features (one-hot, etc.)
│       ├── train.py            # Entrenamiento del modelo
│       └── evaluate.py         # Evaluación con métricas honestas
├── tests/                      # Tests automatizados (pytest)
│   └── test_features.py
├── data/
│   ├── raw/                    # Datos originales, INTOCABLES (clientes_churn.csv)
│   └── processed/              # Datos derivados, regenerables desde raw/
├── models/                     # Modelos serializados (artefactos de salida)
├── notebooks/                  # Exploración (aquí se muda el notebook de Laura)
│   └── exploracion_churn.ipynb
├── configs/
│   └── config.yaml             # Rutas y parámetros externalizados
├── pyproject.toml              # Definición del paquete y metadatos
└── README.md

Decisiones de diseño que conviene entender:

  • src/ layout: el paquete vive dentro de src/ en lugar de en la raíz. Es la convención moderna recomendada porque obliga a instalar el paquete para usarlo (con pip install -e ., lo veremos abajo): así los tests y el código de producción importan exactamente el mismo paquete que se desplegará, y no un directorio que casualmente estaba en el path.
  • Un módulo por etapa del ciclo de vida: data.pyfeatures.pytrain.pyevaluate.py refleja las etapas que vimos en la lección 01-02. No es casual: en la lección 02-04 cada módulo se convertirá en un paso de pipeline con dependencias explícitas.
  • data/raw/ es sagrado: los datos originales nunca se modifican ni se sobrescriben; todo lo derivado va a data/processed/ y debe poder regenerarse. (Cómo versionar lo que hay en data/ es el tema de la lección 02-03 — de momento añadiremos data/ y models/ al .gitignore, porque Git no es el sitio para ellos.)
  • notebooks/ sigue existiendo: la exploración no muere, se ordena. El notebook de Laura se renombra a algo digno (exploracion_churn.ipynb) y se conserva como registro del descubrimiento inicial.
  • configs/ separa el qué del cuánto: el código define la lógica; los ficheros de configuración, los valores. Lo desarrollamos en el apartado 4.

Refactorización paso a paso: datos, features, entrenamiento y evaluación

Ahora troceamos el notebook. La estrategia: identificar cada responsabilidad, extraerla a una función con entradas y salidas explícitas, y colocarla en su módulo. Ninguna función usará rutas absolutas ni valores mágicos: todo llega por parámetro.

Paso 1 — data.py: carga y limpieza

Del notebook extraemos la carga (pd.read_csv con la ruta del escritorio de Laura) y la limpieza (dropna, filtro de horas negativas, eliminación del id):

# src/cineclick_churn/data.py
"""Carga y limpieza del dataset de churn de CineClick."""
import pandas as pd


def cargar_datos(ruta: str) -> pd.DataFrame:
    """Lee el CSV de clientes. La ruta llega por parámetro: nada de rutas fijas."""
    return pd.read_csv(ruta)


def limpiar_datos(df: pd.DataFrame) -> pd.DataFrame:
    """Aplica las reglas de limpieza acordadas con Laura.

    - Elimina filas con valores nulos.
    - Elimina filas con horas de visualización negativas (error conocido de logs).
    - Elimina id_cliente: es un identificador sin valor predictivo.
    """
    df = df.dropna()
    df = df[df["horas_semana"] >= 0]
    df = df.drop(columns=["id_cliente"])
    return df.reset_index(drop=True)

Fíjate en tres cambios respecto al notebook: la ruta es un parámetro (el problema nº 1 empieza a morir aquí), cada función tiene una responsabilidad y un docstring que documenta las decisiones (antes eran comentarios sueltos), y reset_index deja el DataFrame limpio de índices agujereados tras el filtrado — un clásico generador de bugs silenciosos.

Paso 2 — features.py: construcción de features

El one-hot encoding con get_dummies se gradúa a su propio módulo, con una mejora crucial para el futuro: fijamos explícitamente qué columnas son categóricas y cuál es la etiqueta, en lugar de dejar que el código lo "adivine":

# src/cineclick_churn/features.py
"""Transformación de features para el modelo de churn."""
import pandas as pd

COLUMNAS_CATEGORICAS = ["plan", "metodo_pago"]
COLUMNA_ETIQUETA = "abandono"


def construir_features(df: pd.DataFrame) -> pd.DataFrame:
    """Convierte las categóricas en columnas 0/1 (one-hot encoding).

    'plan' produce plan_basico / plan_estandar / plan_premium;
    'metodo_pago' produce metodo_pago_tarjeta / _domiciliacion / _paypal.
    """
    return pd.get_dummies(df, columns=COLUMNAS_CATEGORICAS, dtype=int)


def separar_features_etiqueta(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]:
    """Separa X (features) de y (etiqueta 'abandono')."""
    X = df.drop(columns=[COLUMNA_ETIQUETA])
    y = df[COLUMNA_ETIQUETA]
    return X, y

¿Por qué las constantes en mayúsculas al principio? Porque el servicio de predicción del módulo 4 importará este mismo módulo para transformar los clientes nuevos exactamente igual que en entrenamiento. Una sola implementación del preprocesado, compartida: el problema nº 9 (preprocesado no reutilizable) queda estructuralmente encarrilado.

Paso 3 — train.py: entrenamiento con el azar bajo control

Aquí atacamos el problema nº 2. En el notebook, ni train_test_split ni RandomForestClassifier llevaban semilla, así que cada ejecución era irrepetible. Ahora la semilla es un parámetro obligatorio:

# src/cineclick_churn/train.py
"""Entrenamiento del modelo de churn."""
import pickle
from pathlib import Path

import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split


def dividir_datos(X, y, test_size: float, random_state: int):
    """Divide en train/test de forma reproducible y estratificada.

    - random_state fija el azar: la misma semilla produce la misma partición.
    - stratify=y garantiza que train y test conserven el ~15% de abandono;
      sin ello, con clases desbalanceadas el test podría quedar sesgado.
    """
    return train_test_split(
        X, y, test_size=test_size, random_state=random_state, stratify=y
    )


def entrenar_modelo(X_train, y_train, n_estimators: int, random_state: int):
    """Entrena el RandomForest con semilla fija: mismos datos -> mismo bosque."""
    modelo = RandomForestClassifier(n_estimators=n_estimators, random_state=random_state)
    modelo.fit(X_train, y_train)
    return modelo


def guardar_modelo(modelo, ruta: str) -> None:
    """Serializa el modelo, creando el directorio destino si no existe."""
    Path(ruta).parent.mkdir(parents=True, exist_ok=True)
    with open(ruta, "wb") as f:
        pickle.dump(modelo, f)

Dos semillas, un mismo valor: random_state controla tanto la partición como la aleatoriedad interna del bosque (qué filas y columnas muestrea cada árbol). Con esto, ejecutar dos veces el entrenamiento sobre los mismos datos produce exactamente el mismo modelo y las mismas métricas. Añadimos también stratify=y, una mejora que el notebook no tenía: con solo un 15% de positivos, una partición no estratificada puede dejar el test con un porcentaje de churn distinto al real y distorsionar la evaluación.

Paso 4 — evaluate.py: métricas que no mienten

El momento de la verdad. El notebook celebraba accuracy = 0.87, pero recuerda el problema nº 5: con un 85% de clientes que no abandonan, un modelo trivial que siempre prediga "se queda" ya puntúa 0.85. Introducimos las métricas que miran a la clase que importa (los que se van):

  • Precision (de la clase positiva): de los clientes que el modelo señala como "van a abandonar", ¿qué fracción abandona de verdad? Precision baja = el equipo de retención gasta descuentos en clientes que no se iban.
  • Recall (de la clase positiva): de los clientes que realmente abandonan, ¿qué fracción detecta el modelo? Recall bajo = las bajas se producen sin que nadie las viera venir. Es la métrica que le duele a negocio.
  • F1: media armónica de las dos anteriores; útil como resumen en un solo número cuando hay que comparar modelos.
# src/cineclick_churn/evaluate.py
"""Evaluación honesta del modelo de churn."""
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score


def evaluar_modelo(modelo, X_test, y_test) -> dict:
    """Calcula las métricas sobre el conjunto de test.

    precision/recall/f1 se calculan sobre la clase positiva (abandono=1),
    que es la minoritaria y la que interesa a negocio.
    """
    predicciones = modelo.predict(X_test)
    return {
        "accuracy": round(accuracy_score(y_test, predicciones), 4),
        "precision": round(precision_score(y_test, predicciones), 4),
        "recall": round(recall_score(y_test, predicciones), 4),
        "f1": round(f1_score(y_test, predicciones), 4),
    }

Al ejecutar el flujo completo con random_state=42 y n_estimators=100 sobre el dataset, obtenemos (y ahora sí, obtendremos siempre lo mismo):

{"accuracy": 0.86, "precision": 0.61, "recall": 0.43, "f1": 0.5}

Leamos esto sin anestesia: la accuracy sigue rondando lo que Laura veía (el 0.87 original bailaba con el azar), pero el modelo solo detecta el 43% de los clientes que abandonan, y de los que señala, acierta el 61%. No es un desastre —detectar 4 de cada 10 abandonos con esa precisión ya permite campañas rentables—, pero es una foto radicalmente distinta del "0.87, ¡mejor que la v2!". Esta es la línea base honesta sobre la que mediremos toda mejora futura. Mejorar estas cifras (probar hiperparámetros, umbrales, otros algoritmos) requerirá comparar muchos experimentos con comodidad: ese es el trabajo del módulo 3.

Configuración externa: config.yaml en lugar de valores hardcodeados

Ya no hay rutas absolutas en el código, pero los valores (rutas relativas, test_size, n_estimators, la semilla) tienen que vivir en algún sitio. Si los escribimos en el script que orquesta todo, volvemos a mezclar lógica y parámetros. La solución estándar: un fichero de configuración legible, versionado en Git junto al código.

# configs/config.yaml
datos:
  ruta_raw: data/raw/clientes_churn.csv
  ruta_processed: data/processed/clientes_limpio.csv

modelo:
  ruta: models/modelo_churn.pkl
  n_estimators: 100
  random_state: 42

evaluacion:
  test_size: 0.2

Observa que todas las rutas son relativas a la raíz del repositorio: el proyecto funciona igual en el portátil de Laura, en el tuyo o en un servidor. Cargarlo desde Python es trivial con PyYAML:

# Fragmento de un script de entrenamiento (p. ej. al final de train.py)
import yaml

from cineclick_churn import data, evaluate, features, train


def main():
    with open("configs/config.yaml", encoding="utf-8") as f:
        config = yaml.safe_load(f)  # safe_load: nunca yaml.load a secas

    df = data.limpiar_datos(data.cargar_datos(config["datos"]["ruta_raw"]))
    df = features.construir_features(df)
    X, y = features.separar_features_etiqueta(df)

    X_train, X_test, y_train, y_test = train.dividir_datos(
        X, y, config["evaluacion"]["test_size"], config["modelo"]["random_state"]
    )
    modelo = train.entrenar_modelo(
        X_train, y_train, config["modelo"]["n_estimators"], config["modelo"]["random_state"]
    )
    train.guardar_modelo(modelo, config["modelo"]["ruta"])
    print(evaluate.evaluar_modelo(modelo, X_test, y_test))


if __name__ == "__main__":
    main()

El bloque if __name__ == "__main__": permite ejecutar el módulo como script (python -m cineclick_churn.train) además de importarlo: nos vendrá de perlas en la lección 02-04, cuando cada etapa se convierta en un comando de pipeline. Cambiar un hiperparámetro ya no es editar código: es editar un YAML, y ese cambio queda registrado en el historial de Git con su commit y su porqué. (En 02-04 refinaremos esta idea separando los hiperparámetros a un params.yaml que DVC entiende de forma nativa.)

pyproject.toml: el proyecto como paquete instalable

Falta el pegamento: que from cineclick_churn import features funcione desde cualquier sitio (tests, notebooks, el futuro servicio) sin trucos de sys.path. Para eso el proyecto se declara como paquete en pyproject.toml, el fichero estándar de configuración de proyectos Python:

# pyproject.toml
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "cineclick-churn"
version = "0.1.0"
description = "Modelo de prediccion de churn de CineClick"
requires-python = ">=3.11"

[tool.setuptools.packages.find]
where = ["src"]

Y se instala en modo editable:

pip install -e .

El -e (editable) significa que pip no copia el código a su directorio de paquetes, sino que crea un enlace hacia src/: cada cambio que hagas en los módulos es visible al instante, sin reinstalar. Es el modo de trabajo estándar durante el desarrollo. A partir de ahora, el notebook de exploración de Laura puede empezar con from cineclick_churn.features import construir_features — el notebook consume el paquete, nunca al revés. (Este pyproject.toml está deliberadamente incompleto: la declaración de dependencias —pandas, scikit-learn, PyYAML y compañía— es el tema central de la próxima lección.)

Un primer test con pytest (adelanto)

El problema nº 6 del diagnóstico era "cero tests". No vamos a resolverlo entero hoy —los tests serios de ML (de datos, de comportamiento del modelo, en CI) son el tema de la lección 05-01—, pero ahora que tenemos funciones importables, escribir el primero cuesta dos minutos y cambia la cultura del proyecto:

# tests/test_features.py
import pandas as pd

from cineclick_churn.features import construir_features


def test_one_hot_genera_columnas_de_plan():
    df = pd.DataFrame({
        "antiguedad_meses": [12, 3],
        "horas_semana": [5.0, 1.5],
        "tickets_soporte": [0, 2],
        "plan": ["basico", "premium"],
        "metodo_pago": ["tarjeta", "domiciliacion"],
        "descuento_activo": [0, 1],
        "abandono": [0, 1],
    })

    resultado = construir_features(df)

    assert "plan_basico" in resultado.columns
    assert "plan_premium" in resultado.columns
    assert "plan" not in resultado.columns          # la original desaparece
    assert resultado["plan_basico"].tolist() == [1, 0]

Se ejecuta con pytest desde la raíz del repo. El test construye un mini-DataFrame ficticio de dos filas, aplica la transformación real (la misma que usará producción) y comprueba que el one-hot hace lo que promete. Si mañana alguien "mejora" features.py y rompe el encoding, el test fallará antes de que el error llegue a un modelo. Pequeño, sí; pero es la semilla de la red de seguridad que tejeremos en el módulo 5.

Errores Comunes y Consejos

  • Error: refactorizar y "mejorar" a la vez. La tentación de aprovechar para cambiar el algoritmo o añadir features es enorme. Resístela: primero reproduce el comportamiento del notebook en la nueva estructura (misma limpieza, mismo modelo), verifica que funciona, y luego mejora. Si cambias estructura y lógica a la vez y algo se rompe, no sabrás cuál de las dos cosas fue. (La única "mejora" que hemos colado —stratify y las métricas nuevas— afecta a la medición, no al modelo.)
  • Error: olvidar random_state en uno de los dos sitios. Fijarlo en el split pero no en el RandomForestClassifier (o al revés) da una falsa sensación de reproducibilidad: las métricas seguirán bailando. Regla: busca todo parámetro llamado random_state o seed en las funciones que uses y decide su valor conscientemente.
  • Error: imports relativos frágiles o sys.path.append. Si te encuentras escribiendo sys.path.append("../src") en un test o un notebook, la solución no es esa: es pip install -e .. El src layout + instalación editable existe precisamente para eliminar esa clase de hacks.
  • Error: dejar el config.yaml fuera de Git. La configuración es código a todos los efectos: sin ella, el proyecto no se puede ejecutar. Lo que NO va a Git son datos y modelos (lo gestionaremos en 02-03), no la configuración.
  • Consejo: refactoriza con Laura, no contra Laura. Ella sabe por qué se filtran las horas negativas o se eliminó una columna. Cada decisión de limpieza rescatada del notebook debe acabar documentada en un docstring; el conocimiento tácito es deuda técnica.

Ejercicios

Ejercicio 1

Sin escribir código: clasifica cada uno de estos fragmentos del notebook original según el módulo del paquete al que pertenece (data.py, features.py, train.py, evaluate.py) o si pertenece a la configuración (config.yaml): (a) df = df[df["horas_semana"] >= 0]; (b) test_size=0.2; (c) pd.get_dummies(df, columns=["plan", "metodo_pago"]); (d) accuracy_score(y_test, predicciones); (e) "C:/Users/laura/Desktop/datos/clientes_churn.csv"; (f) RandomForestClassifier(n_estimators=100).

Ejercicio 2

Escribe un segundo test para tests/test_features.py que verifique que separar_features_etiqueta funciona correctamente: que X no contiene la columna abandono, que y contiene exactamente los valores de esa columna, y que X conserva el mismo número de filas que el DataFrame de entrada. Reutiliza el mini-DataFrame del test existente (idealmente, extráelo a una fixture de pytest para no duplicarlo).

Ejercicio 3

El equipo de retención te pregunta: "con las métricas nuevas (precision 0.61, recall 0.43), si en un mes 1.000 clientes van a abandonar realmente, ¿a cuántos detectará el modelo, y cuántos falsos positivos generará aproximadamente?". Razona la respuesta con esas dos métricas y explica cuál de los dos tipos de error es más caro para CineClick, sabiendo que la campaña de retención consiste en un descuento del 20% durante 3 meses.

Soluciones

Solución 1: (a) data.py — es una regla de limpieza; (b) config.yaml — es un parámetro, no lógica (lo lee quien hace el split en train.py); (c) features.py — transformación de features; (d) evaluate.py — cálculo de métricas; (e) config.yaml — es una ruta, y además debe pasar a ser relativa (data/raw/clientes_churn.csv); (f) la llamada pertenece a train.py, pero el valor n_estimators=100 pertenece a config.yaml. La pauta general: la lógica va al módulo de su etapa; los valores concretos, a configuración.

Solución 2:

import pandas as pd
import pytest

from cineclick_churn.features import separar_features_etiqueta


@pytest.fixture
def df_ejemplo():
    return pd.DataFrame({
        "antiguedad_meses": [12, 3],
        "horas_semana": [5.0, 1.5],
        "tickets_soporte": [0, 2],
        "plan": ["basico", "premium"],
        "metodo_pago": ["tarjeta", "domiciliacion"],
        "descuento_activo": [0, 1],
        "abandono": [0, 1],
    })


def test_separar_features_etiqueta(df_ejemplo):
    X, y = separar_features_etiqueta(df_ejemplo)

    assert "abandono" not in X.columns       # la etiqueta no se filtra a las features
    assert y.tolist() == [0, 1]              # y contiene exactamente la etiqueta
    assert len(X) == len(df_ejemplo)         # no se pierden filas

La fixture (@pytest.fixture) es la forma idiomática de pytest de compartir datos de prueba entre tests: se declara una vez y cualquier test la recibe como parámetro.

Solución 3: con recall 0.43, de los 1.000 clientes que abandonarán, el modelo detecta ~430. Con precision 0.61, esos 430 aciertos son el 61% de todas las alertas que emite el modelo, así que emite en total ~430 / 0.61 ≈ 705 alertas, de las cuales ~275 son falsos positivos (clientes marcados como riesgo que no se iban). El coste de cada error: un falso positivo cuesta un descuento innecesario (20% × 3 meses de una cuota, unos pocos euros); un falso negativo cuesta un cliente entero (todo su valor futuro, que el módulo 1 cifró en 5-7 veces el coste de adquisición evitado). Para CineClick, el falso negativo es mucho más caro, lo que sugiere que en el futuro convendrá empujar el recall aunque la precision baje algo — exactamente el tipo de experimento que registraremos ordenadamente en el módulo 3.

Conclusión

El notebook de Laura ya no es un monolito: es un repositorio cineclick-churn con un paquete instalable (src/cineclick_churn/ con data.py, features.py, train.py y evaluate.py), configuración externa en configs/config.yaml con rutas relativas, semillas fijas (random_state=42 en split y modelo, con estratificación), una evaluación honesta que sustituye la accuracy engañosa por precision 0.61 / recall 0.43 / F1 0.50 como línea base, y un primer test que vigila el preprocesado. Los problemas nº 1 (rutas personales), nº 2 (azar sin control) y nº 9 (preprocesado no reutilizable) del diagnóstico están resueltos o encarrilados. Pero hay una grieta que este trabajo no tapa: nuestro código es reproducible sobre nuestro entorno, y nada garantiza que tu compañero tenga las mismas versiones de pandas o scikit-learn que tú — el problema nº 8 sigue vivito y coleando. En la próxima lección lo atacamos de frente: entornos virtuales, declaración de dependencias y ficheros de lock para que "en mi máquina funciona" deje de ser una amenaza.

© Copyright 2026. Todos los derechos reservados