El repositorio cineclick-churn ya tiene estructura, semillas fijas y configuración externa, pero cerramos la lección anterior señalando la grieta: nada declara con qué versiones de pandas, scikit-learn o Python debe ejecutarse ese código. Es el problema nº 8 del diagnóstico ("dependencias implícitas"), y en ML es más traicionero que en el software clásico, porque una versión distinta de una librería no siempre rompe con un error visible: a veces simplemente produce un modelo distinto o métricas distintas, en silencio. En esta lección montaremos el entorno reproducible del proyecto: entorno virtual, dependencias declaradas en pyproject.toml, versiones fijadas con un fichero de lock, y los detalles extra que la reproducibilidad en ML exige más allá de la lista de paquetes.
Contenido
- "En mi máquina funciona", edición ML
- Entornos virtuales: uno por proyecto
- Declarar dependencias en
pyproject.toml - Fijar versiones: rangos, pins y ficheros de lock
- Reproducibilidad más allá de los paquetes
- Buenas prácticas para el repo de CineClick
"En mi máquina funciona", edición ML
Imagina que un nuevo compañero, Marc, clona cineclick-churn, hace pip install pandas scikit-learn pyyaml en su Python global y ejecuta el entrenamiento. Posibles finales de la historia, todos reales:
- Rotura ruidosa: su versión de scikit-learn eliminó o renombró un parámetro que usamos, y el script muere con un
TypeError. Molesto, pero al menos es visible. - Rotura silenciosa de resultados: entre versiones de scikit-learn cambian detalles de implementación (algoritmos internos, manejo de empates, valores por defecto de hiperparámetros). El mismo código con la misma semilla puede producir un modelo con métricas ligeramente distintas. Marc obtiene recall 0.41 en vez de 0.43 y pierde una tarde buscando un bug que no existe: la diferencia era la versión de la librería. La semilla fija el azar dentro de una versión, no entre versiones.
- El pickle traidor: Marc intenta cargar el
modelo_churn.pklentrenado con otra versión de scikit-learn. Un pickle no es un formato de intercambio: es un volcado de objetos Python que asume que las clases al cargarlo son las mismas que al guardarlo. Con versiones distintas puede fallar al cargar, o —peor— cargar con un aviso (InconsistentVersionWarning) y predecir mal. Regla de hierro: un modelo picklado se sirve con exactamente las mismas versiones con las que se entrenó. - Contaminación entre proyectos: Marc instala después otro proyecto que exige una versión incompatible de pandas en el mismo Python global, y
cineclick-churndeja de funcionar sin que nadie lo haya tocado.
La conclusión es la misma del DevOps clásico, agravada: el entorno es parte del artefacto. Reproducir el modelo exige reproducir código + datos + entorno.
Entornos virtuales: uno por proyecto
La primera defensa es aislar: cada proyecto con su propio conjunto de paquetes, sin compartir nada con el Python global ni con otros proyectos. En Python la herramienta de serie es venv:
# Desde la raíz de cineclick-churn python -m venv .venv # Activarlo — Linux/macOS: source .venv/bin/activate # Activarlo — Windows (PowerShell): .venv\Scripts\Activate.ps1 # Comprobar que estamos dentro (debe apuntar a .venv): which python # Get-Command python en PowerShell
Qué acaba de pasar: python -m venv .venv crea un directorio .venv/ con una copia ligera del intérprete y un site-packages vacío y propio. Al activarlo, la shell antepone ese entorno al PATH: python y pip pasan a ser los del proyecto. Todo pip install posterior instala dentro de .venv/, sin tocar nada más de la máquina.
Convenciones que adoptamos en CineClick:
- El entorno se llama
.venvy vive en la raíz del proyecto (es la convención que las herramientas y editores detectan automáticamente). .venv/va al.gitignore: el entorno no se versiona (es enorme, específico de cada SO, y regenerable). Lo que se versiona es la receta para recrearlo — de eso van los dos apartados siguientes.- Un proyecto = un entorno. Nunca instales dependencias de dos proyectos en el mismo entorno "porque total, son parecidos".
Alternativa que verás en muchos equipos de datos: conda, que además de paquetes Python gestiona binarios no-Python (CUDA, compiladores). Es una opción legítima, especialmente en deep learning; en este curso nos quedamos con
venv+ pip por ser el estándar mínimo y porque nuestro stack no lo necesita.
Declarar dependencias en pyproject.toml
Con el entorno aislado, falta la receta. En la lección anterior dejamos un pyproject.toml mínimo; ahora le añadimos la sección de dependencias, distinguiendo dos categorías:
- Dependencias de runtime (
dependencies): las que el código necesita para funcionar — pandas, scikit-learn, PyYAML. Cualquiera que instale el paquete las necesita, incluida la imagen de producción. - Dependencias de desarrollo (extras opcionales): las que solo necesita quien trabaja en el proyecto — pytest, herramientas de lint o de lock. Producción no debe cargar con ellas.
# 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"
dependencies = [
"pandas>=2.2,<3",
"scikit-learn>=1.5,<1.6",
"pyyaml>=6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pip-tools>=7.4",
]
[tool.setuptools.packages.find]
where = ["src"]Y la instalación para desarrollo pasa a ser:
El sufijo [dev] activa el extra: instala el paquete en modo editable más las herramientas de desarrollo. En producción (o en la imagen Docker del módulo 4) bastará pip install . a secas, sin pytest ni utilidades de desarrollo a bordo.
Fíjate en los rangos: scikit-learn>=1.5,<1.6 es deliberadamente más estricto que el de pandas, por lo que vimos del pickle — no queremos que un salto de versión menor de scikit-learn se cuele entre el entrenamiento y el servicio. Lo cual nos lleva a la pregunta clave: ¿cómo de fijas deben ser las versiones?
Fijar versiones: rangos, pins y ficheros de lock
Hay tres estrategias posibles, cada una con su lugar:
| Estrategia | Ejemplo | Ventajas | Inconvenientes | Cuándo usarla |
|---|---|---|---|---|
| Sin restricción | pandas |
Siempre lo último | Irreproducible; cualquier release puede romperte | Nunca en un proyecto serio |
| Rango compatible | pandas>=2.2,<3 |
Flexible; permite parches de seguridad; evita conflictos entre paquetes | Dos instalaciones en fechas distintas pueden diferir | En pyproject.toml (la intención) |
| Pin exacto | pandas==2.2.3 |
Reproducibilidad total | Rígido; congela también bugs; conflictos si otro paquete pide otra versión | En el fichero de lock (la foto exacta) |
La práctica moderna combina las dos últimas: rangos en pyproject.toml, pins exactos en un fichero de lock generado automáticamente. El pyproject.toml dice "funciono con cualquier scikit-learn 1.5.x"; el lock dice "el entorno bendecido, el que produce exactamente nuestras métricas, es scikit-learn 1.5.2 con estas 30 dependencias transitivas exactas". Lo importante del lock es que incluye las dependencias transitivas: tú declaras pandas, pero pandas arrastra numpy, python-dateutil, tzdata... y una versión distinta de numpy también puede cambiar resultados numéricos.
Para generar el lock usaremos pip-tools (la alternativa moderna y mucho más rápida es uv, cuyo flujo es casi idéntico — uv pip compile / uv pip sync; elige la que prefieras, el concepto es el mismo):
# Genera requirements.lock a partir de pyproject.toml (resuelve TODO el árbol) pip-compile pyproject.toml --extra dev -o requirements.lock # Instala EXACTAMENTE lo que dice el lock (y desinstala lo que sobre) pip-sync requirements.lock pip install -e . --no-deps # el propio paquete, sin re-resolver dependencias
El requirements.lock resultante es un fichero de texto plano, versionado en Git, con este aspecto (fragmento):
# Generado por pip-compile a partir de pyproject.toml — NO editar a mano
numpy==2.1.3
# via pandas, scikit-learn
pandas==2.2.3
# via cineclick-churn (pyproject.toml)
scikit-learn==1.5.2
# via cineclick-churn (pyproject.toml)
...El flujo de trabajo del equipo queda así:
flowchart LR
A["pyproject.toml<br/>(rangos: la intención)"] -- "pip-compile<br/>(cuando cambian deps)" --> B["requirements.lock<br/>(pins: la foto exacta)"]
B -- "pip-sync<br/>(cada compañero, cada máquina)" --> C[".venv idéntico<br/>en todas partes"]- Añadir o actualizar una dependencia: se edita
pyproject.toml, se re-ejecutapip-compile, y se commitean los dos ficheros juntos. El diff del lock muestra exactamente qué versiones cambiaron — revisable en un pull request. - Incorporarse al proyecto o desplegar: nunca
pip install pandasa mano; siemprepip-sync requirements.lock. Marc obtiene un entorno idéntico al de Laura, byte a byte de versiones. - Actualizar deliberadamente (p. ej. una versión nueva de scikit-learn):
pip-compile --upgrade-package scikit-learn, re-entrenar, comparar métricas, y solo entonces commitear. La actualización de dependencias en ML es un experimento, no un trámite.
Reproducibilidad más allá de los paquetes
Con entorno + lock cubrimos las librerías, pero quedan tres flecos que en ML importan:
La versión de Python
Python 3.11 y 3.12 no son intercambiables: cambian rendimiento, avisos y, ocasionalmente, comportamiento de librerías compiladas contra cada versión. Ya declaramos requires-python = ">=3.11" en pyproject.toml (el rango admisible); además, fijamos la versión concreta de desarrollo en un fichero .python-version en la raíz:
Es una convención que entienden los gestores de versiones de Python (pyenv, uv, varios IDE): al entrar en el directorio, seleccionan ese intérprete automáticamente. Un fichero de una línea que elimina toda una categoría de "en mi máquina funciona".
PYTHONHASHSEED
Python aleatoriza por defecto el hash de las cadenas en cada proceso (una medida de seguridad). Consecuencia: cualquier código cuyo resultado dependa del orden de iteración de un set o del hash de strings puede variar entre ejecuciones aunque todas las semillas de ML estén fijadas. Nuestro pipeline actual no depende de ello (pandas y scikit-learn usan sus propios generadores, que ya controlamos con random_state), pero es una fuente clásica de irreproducibilidad difícil de cazar. La vacuna es fijar la variable de entorno antes de ejecutar:
En CineClick la documentamos en el README y, más adelante, quedará fijada de serie en la imagen Docker (04-03) y en los pipelines automatizados.
Las semillas (ya hechas) y el hardware (fuera de alcance)
Las semillas de train_test_split y del RandomForestClassifier quedaron fijadas en 02-01 — son la tercera pata, y la más conocida, de la reproducibilidad. Queda un último nivel que debes conocer aunque no nos afecte: con GPU y deep learning, ciertas operaciones son no-deterministas por diseño (paralelismo con sumas en coma flotante en orden variable), y la reproducibilidad exacta exige opciones específicas del framework, a veces pagando rendimiento. Para nuestro RandomForest en CPU, no aplica; si algún día CineClick entrena redes, recuerda que la frontera existe.
La jerarquía completa, de dentro afuera:
| Capa | Qué fija | Herramienta en CineClick |
|---|---|---|
| Azar del algoritmo | Particiones y muestreos | random_state=42 (02-01) |
| Proceso Python | Hashes de strings | PYTHONHASHSEED=42 |
| Paquetes | Versiones exactas de todo el árbol | requirements.lock (pip-tools) |
| Intérprete | Versión de Python | .python-version + requires-python |
| Sistema operativo y librerías del sistema | Todo lo demás | Docker — el siguiente nivel de aislamiento, en la lección 04-03 |
Cada capa asume la anterior. Y observa la última fila: aunque entorno virtual + lock cubren el 95% de los casos, siguen ejecutándose sobre tu sistema operativo. Empaquetar también el SO es exactamente lo que hace Docker, y lo dejamos para el módulo 4.
Buenas prácticas para el repo de CineClick
Recapitulando, el estado del repositorio tras esta lección:
cineclick-churn/ ├── .python-version # 3.11.9 — versión de Python del proyecto ├── .venv/ # entorno virtual (en .gitignore) ├── pyproject.toml # paquete + dependencias con rangos (runtime y [dev]) ├── requirements.lock # pins exactos generados con pip-compile (SÍ va a Git) ├── ... # (resto igual que en 02-01)
Y el ritual de incorporación de cualquier compañero, documentado en el README:
git clone <repo> && cd cineclick-churn python -m venv .venv && source .venv/bin/activate pip install pip-tools pip-sync requirements.lock pip install -e . --no-deps pytest # si esto pasa, el entorno está sano
Reglas de equipo que quedan establecidas: nadie instala paquetes a mano fuera del flujo pyproject.toml → pip-compile → commit; el lock se regenera solo de forma deliberada y su diff se revisa; y toda máquina que entrene o sirva el modelo —humana o automatizada— parte del mismo lock.
Errores Comunes y Consejos
- Error: versionar
.venv/en Git. Es pesado, específico de cada sistema operativo y regenerable. Se versiona la receta (pyproject.toml+requirements.lock), jamás el entorno. Si ves.venv/en ungit status, corre al.gitignore. - Error:
pip freeze > requirements.txtcomo "lock". Funciona a medias, pero vuelca todo lo instalado en el entorno (incluido lo que probaste una tarde y no usas), no distingue directas de transitivas, y no se regenera desde una fuente de intención.pip-compileparte depyproject.tomly anota de dónde viene cada paquete: es la diferencia entre una foto ordenada y el cajón de los cables. - Error: entrenar con unas versiones y servir con otras. El caso del pickle traidor. El entorno de entrenamiento y el de inferencia deben salir del mismo lock; cuando lleguemos a Docker (04-03) y al servicio (04-02), esta regla se convertirá en construcción de imagen, pero la disciplina empieza hoy.
- Error: actualizar dependencias "porque había una versión nueva" sin re-evaluar. En ML una actualización de scikit-learn puede mover tus métricas. Trátala como un cambio más: rama,
pip-compile --upgrade-package, re-entrenar, comparar métricas, revisar, mergear. - Consejo: si el equipo ya domina uv, úsalo: es un reemplazo casi directo (
uv venv,uv pip compile,uv pip sync) y órdenes de magnitud más rápido. Lo importante de esta lección es el patrón intención/lock, no la herramienta concreta.
Ejercicios
Ejercicio 1
Marc clona el repo, crea su .venv, pero en lugar de usar el lock ejecuta pip install pandas scikit-learn pyyaml pytest y luego pip install -e . --no-deps. Los tests pasan, el entrenamiento corre sin errores... pero obtiene recall = 0.41 en lugar del 0.43 esperado. (1) ¿Cuál es la causa más probable? (2) ¿Por qué no lo detectó ningún error ni test? (3) ¿Qué dos comandos debería haber ejecutado en su lugar?
Ejercicio 2
Clasifica estas dependencias del proyecto CineClick como runtime (dependencies) o desarrollo (optional-dependencies.dev), justificando cada una: (a) scikit-learn — entrena y ejecuta el modelo; (b) pytest — ejecuta los tests; (c) pyyaml — carga configs/config.yaml; (d) pip-tools — regenera el lock; (e) fastapi — servirá el modelo en el módulo 4: cuando llegue, ¿dónde irá?
Ejercicio 3
El requirements.lock actual fija scikit-learn==1.5.2. Sale la 1.6.0 con una mejora de rendimiento que os interesa. Describe, paso a paso y con los comandos concretos, el procedimiento correcto para adoptarla en CineClick, incluyendo qué harías con el modelo modelo_churn.pkl ya entrenado y por qué.
Soluciones
Solución 1: (1) pip install sin versiones instaló lo último de cada paquete, seguramente una versión de scikit-learn (o de numpy, transitiva) distinta de la del lock; los detalles internos del algoritmo cambian entre versiones y, aunque la semilla es la misma, el modelo resultante difiere ligeramente. (2) No hay error porque la API que usamos no cambió, y el test de features no evalúa métricas del modelo — comprueba el encoding, que es determinista en cualquier versión razonable (los tests que vigilan métricas llegarán en 05-01). (3) pip-sync requirements.lock seguido de pip install -e . --no-deps: eso reproduce el entorno bendecido exacto.
Solución 2: (a) runtime — sin scikit-learn ni se entrena ni se puede deserializar/usar el modelo; (b) dev — producción no ejecuta tests; (c) runtime — el código de entrenamiento lee la configuración en cualquier entorno donde corra; (d) dev — es una herramienta del flujo de trabajo, el código nunca la importa; (e) runtime — el servicio de predicción la importará para funcionar en producción (de hecho, equipos grandes a veces separan extras train/serve para no llevar scikit-learn completo al servicio y viceversa; para CineClick, de momento, un solo grupo runtime es suficiente).
Solución 3: (1) crear una rama (git checkout -b actualiza-sklearn-16); (2) ampliar el rango en pyproject.toml (scikit-learn>=1.5,<1.7 o directamente >=1.6,<1.7); (3) regenerar el lock solo para ese paquete: pip-compile pyproject.toml --extra dev -o requirements.lock --upgrade-package scikit-learn; (4) pip-sync requirements.lock y pip install -e . --no-deps; (5) re-entrenar el modelo desde cero y comparar las métricas con la línea base (accuracy 0.86 / precision 0.61 / recall 0.43 / F1 0.50); (6) descartar el modelo_churn.pkl antiguo: un pickle de la 1.5.2 no debe servirse desde un entorno 1.6.0 — el modelo "bueno" pasa a ser el re-entrenado; (7) commit de pyproject.toml + requirements.lock juntos, pull request con las métricas comparadas, merge. Si las métricas empeoraran sin explicación, se descarta la rama y no ha pasado nada: esa reversibilidad barata es justo lo que compran el lock y Git.
Conclusión
El problema nº 8 tiene los días contados: el entorno de CineClick ya es una receta versionada —.venv por proyecto, dependencias con rangos en pyproject.toml separando runtime de desarrollo, pins exactos de todo el árbol en requirements.lock generado con pip-compile, .python-version y PYTHONHASHSEED cerrando los flecos— y cualquier compañero puede recrear byte a byte de versiones el entorno que produce nuestras métricas. Código reproducible (02-01) + entorno reproducible (esta lección) nos dejan a las puertas del tercer ingrediente, y el más voluminoso: los datos. El clientes_churn.csv sigue siendo un fichero suelto que cada extracto mensual sobrescribe, sin historia ni forma de volver atrás — el problema nº 3 del diagnóstico. Git no puede con él (y en la próxima lección veremos exactamente por qué), así que toca presentar a la herramienta nacida para esto: DVC, el control de versiones para datos.
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
