Contenido
- Por qué el notebook no escala (y para qué sí sirve)
- El diseño del repositorio
cineclick-churn - Refactorización paso a paso: datos, features, entrenamiento y evaluación
- Configuración externa:
config.yamlen lugar de valores hardcodeados pyproject.toml: el proyecto como paquete instalable- 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_datosdesde 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
.ipynbes un JSON con salidas, contadores de ejecución e imágenes en base64 incrustadas. Ungit diffde 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 desrc/en lugar de en la raíz. Es la convención moderna recomendada porque obliga a instalar el paquete para usarlo (conpip 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.py→features.py→train.py→evaluate.pyrefleja 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 adata/processed/y debe poder regenerarse. (Cómo versionar lo que hay endata/es el tema de la lección 02-03 — de momento añadiremosdata/ymodels/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):
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:
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 —
stratifyy las métricas nuevas— afecta a la medición, no al modelo.) - Error: olvidar
random_stateen uno de los dos sitios. Fijarlo en el split pero no en elRandomForestClassifier(o al revés) da una falsa sensación de reproducibilidad: las métricas seguirán bailando. Regla: busca todo parámetro llamadorandom_stateoseeden las funciones que uses y decide su valor conscientemente. - Error: imports relativos frágiles o
sys.path.append. Si te encuentras escribiendosys.path.append("../src")en un test o un notebook, la solución no es esa: espip install -e .. El src layout + instalación editable existe precisamente para eliminar esa clase de hacks. - Error: dejar el
config.yamlfuera 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 filasLa 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.
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
