The cineclick-churn repository now has structure, fixed seeds, and external configuration, but we closed the previous lesson pointing at the crack: nothing declares which versions of pandas, scikit-learn, or Python that code should run with. It's problem #8 from the diagnosis ("implicit dependencies"), and in ML it's more treacherous than in classic software, because a different library version doesn't always break with a visible error: sometimes it simply produces a different model or different metrics, silently. In this lesson we'll set up the project's reproducible environment: a virtual environment, dependencies declared in pyproject.toml, versions pinned with a lock file, and the extra details that reproducibility in ML demands beyond the package list.
Contents
- "Works on my machine", ML edition
- Virtual environments: one per project
- Declaring dependencies in
pyproject.toml - Pinning versions: ranges, pins, and lock files
- Reproducibility beyond packages
- Good practices for the CineClick repo
"Works on my machine", ML edition
Imagine that a new colleague, Marc, clones cineclick-churn, runs pip install pandas scikit-learn pyyaml in his global Python, and launches the training. Possible endings to the story, all of them real:
- Noisy breakage: his version of scikit-learn removed or renamed a parameter we use, and the script dies with a
TypeError. Annoying, but at least it's visible. - Silent breakage of results: implementation details change between scikit-learn versions (internal algorithms, tie handling, hyperparameter defaults). The same code with the same seed can produce a model with slightly different metrics. Marc gets recall 0.41 instead of 0.43 and loses an afternoon hunting a bug that doesn't exist: the difference was the library version. The seed pins the randomness within a version, not across versions.
- The treacherous pickle: Marc tries to load the
churn_model.pkltrained with another version of scikit-learn. A pickle is not an interchange format: it's a dump of Python objects that assumes the classes at load time are the same as at save time. With mismatched versions it can fail to load, or — worse — load with a warning (InconsistentVersionWarning) and predict badly. Iron rule: a pickled model is served with exactly the same versions it was trained with. - Cross-project contamination: Marc later installs another project that demands an incompatible version of pandas in the same global Python, and
cineclick-churnstops working without anyone having touched it.
The conclusion is the same as in classic DevOps, only aggravated: the environment is part of the artifact. Reproducing the model requires reproducing code + data + environment.
Virtual environments: one per project
The first line of defense is isolation: each project with its own set of packages, sharing nothing with the global Python or with other projects. In Python the built-in tool is venv:
# From the root of cineclick-churn python -m venv .venv # Activate it — Linux/macOS: source .venv/bin/activate # Activate it — Windows (PowerShell): .venv\Scripts\Activate.ps1 # Check that we're inside (it should point to .venv): which python # Get-Command python in PowerShell
What just happened: python -m venv .venv creates a .venv/ directory with a lightweight copy of the interpreter and its own, empty site-packages. On activation, the shell puts that environment first on the PATH: python and pip become the project's. Every subsequent pip install installs inside .venv/, touching nothing else on the machine.
Conventions we adopt at CineClick:
- The environment is named
.venvand lives at the project root (it's the convention that tools and editors detect automatically). .venv/goes into.gitignore: the environment is never versioned (it's huge, OS-specific, and regenerable). What gets versioned is the recipe to recreate it — that's what the next two sections are about.- One project = one environment. Never install dependencies of two projects into the same environment "because they're similar anyway".
An alternative you'll see in many data teams: conda, which manages non-Python binaries (CUDA, compilers) as well as Python packages. It's a legitimate option, especially in deep learning; in this course we stick with
venv+ pip as the minimal standard, and because our stack doesn't need more.
Declaring dependencies in pyproject.toml
With the environment isolated, the recipe is still missing. In the previous lesson we left a minimal pyproject.toml; now we add the dependencies section, distinguishing two categories:
- Runtime dependencies (
dependencies): the ones the code needs in order to work — pandas, scikit-learn, PyYAML. Anyone installing the package needs them, including the production image. - Development dependencies (optional extras): the ones needed only by someone working on the project — pytest, lint or lock tooling. Production shouldn't carry them.
# pyproject.toml
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "cineclick-churn"
version = "0.1.0"
description = "CineClick churn prediction model"
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"]And the development install becomes:
The [dev] suffix activates the extra: it installs the package in editable mode plus the development tools. In production (or in module 4's Docker image), a plain pip install . will do, with no pytest or development utilities on board.
Look at the ranges: scikit-learn>=1.5,<1.6 is deliberately stricter than the one for pandas, because of what we saw about the pickle — we don't want a minor scikit-learn version bump slipping in between training and serving. Which brings us to the key question: how fixed should the versions be?
Pinning versions: ranges, pins, and lock files
There are three possible strategies, each with its place:
| Strategy | Example | Advantages | Drawbacks | When to use it |
|---|---|---|---|---|
| Unconstrained | pandas |
Always the latest | Irreproducible; any release can break you | Never in a serious project |
| Compatible range | pandas>=2.2,<3 |
Flexible; allows security patches; avoids conflicts between packages | Two installs on different dates can differ | In pyproject.toml (the intent) |
| Exact pin | pandas==2.2.3 |
Total reproducibility | Rigid; freezes bugs too; conflicts if another package wants a different version | In the lock file (the exact snapshot) |
Modern practice combines the last two: ranges in pyproject.toml, exact pins in an automatically generated lock file. The pyproject.toml says "I work with any scikit-learn 1.5.x"; the lock says "the blessed environment, the one that produces exactly our metrics, is scikit-learn 1.5.2 with these 30 exact transitive dependencies". What matters about the lock is that it includes the transitive dependencies: you declare pandas, but pandas drags in numpy, python-dateutil, tzdata... and a different version of numpy can also change numeric results.
To generate the lock we'll use pip-tools (the modern, much faster alternative is uv, whose flow is almost identical — uv pip compile / uv pip sync; pick whichever you prefer, the concept is the same):
# Generates requirements.lock from pyproject.toml (resolves the WHOLE tree) pip-compile pyproject.toml --extra dev -o requirements.lock # Installs EXACTLY what the lock says (and uninstalls anything extra) pip-sync requirements.lock pip install -e . --no-deps # the package itself, without re-resolving dependencies
The resulting requirements.lock is a plain-text file, versioned in Git, that looks like this (fragment):
# Generated by pip-compile from pyproject.toml — do NOT edit by hand
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)
...The team's workflow ends up like this:
flowchart LR
A["pyproject.toml<br/>(ranges: the intent)"] -- "pip-compile<br/>(when deps change)" --> B["requirements.lock<br/>(pins: the exact snapshot)"]
B -- "pip-sync<br/>(every colleague, every machine)" --> C["identical .venv<br/>everywhere"]- Adding or updating a dependency: edit
pyproject.toml, re-runpip-compile, and commit both files together. The lock's diff shows exactly which versions changed — reviewable in a pull request. - Joining the project or deploying: never a manual
pip install pandas; alwayspip-sync requirements.lock. Marc gets an environment identical to Laura's, byte for byte in versions. - Upgrading deliberately (e.g. a new scikit-learn version):
pip-compile --upgrade-package scikit-learn, retrain, compare metrics, and only then commit. Dependency updates in ML are an experiment, not a formality.
Reproducibility beyond packages
With environment + lock we've covered the libraries, but three loose ends remain that matter in ML:
The Python version
Python 3.11 and 3.12 are not interchangeable: performance, warnings, and occasionally the behavior of libraries compiled against each version all change. We already declared requires-python = ">=3.11" in pyproject.toml (the acceptable range); on top of that, we pin the concrete development version in a .python-version file at the root:
It's a convention understood by Python version managers (pyenv, uv, several IDEs): on entering the directory, they select that interpreter automatically. A one-line file that eliminates an entire category of "works on my machine".
PYTHONHASHSEED
Python randomizes string hashing in each process by default (a security measure). Consequence: any code whose result depends on the iteration order of a set or on string hashes can vary between runs even with every ML seed pinned. Our current pipeline doesn't depend on it (pandas and scikit-learn use their own generators, which we already control with random_state), but it's a classic, hard-to-hunt source of irreproducibility. The vaccine is to set the environment variable before running:
At CineClick we document it in the README and, later on, it will come pinned by default in the Docker image (04-03) and in the automated pipelines.
The seeds (already done) and the hardware (out of scope)
The seeds for train_test_split and the RandomForestClassifier were pinned in 02-01 — they're the third leg, and the best-known one, of reproducibility. There's one last level you should know about even though it doesn't affect us: with GPUs and deep learning, certain operations are non-deterministic by design (parallelism with floating-point sums in variable order), and exact reproducibility requires framework-specific options, sometimes at a performance cost. For our RandomForest on CPU, it doesn't apply; if one day CineClick trains neural networks, remember the boundary exists.
The full hierarchy, from the inside out:
| Layer | What it pins | Tool at CineClick |
|---|---|---|
| Algorithm randomness | Splits and sampling | random_state=42 (02-01) |
| Python process | String hashes | PYTHONHASHSEED=42 |
| Packages | Exact versions of the whole tree | requirements.lock (pip-tools) |
| Interpreter | Python version | .python-version + requires-python |
| Operating system and system libraries | Everything else | Docker — the next level of isolation, in lesson 04-03 |
Each layer assumes the previous one. And notice the last row: even though virtual environment + lock cover 95% of cases, they still run on top of your operating system. Packaging the OS as well is exactly what Docker does, and we leave it for module 4.
Good practices for the CineClick repo
Recapping, the state of the repository after this lesson:
cineclick-churn/ ├── .python-version # 3.11.9 — the project's Python version ├── .venv/ # virtual environment (in .gitignore) ├── pyproject.toml # package + dependencies with ranges (runtime and [dev]) ├── requirements.lock # exact pins generated with pip-compile (DOES go to Git) ├── ... # (rest as in 02-01)
And the onboarding ritual for any colleague, documented in the 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 # if this passes, the environment is healthy
Team rules now established: nobody installs packages by hand outside the pyproject.toml → pip-compile → commit flow; the lock is only regenerated deliberately and its diff is reviewed; and every machine that trains or serves the model — human or automated — starts from the same lock.
Common Mistakes and Tips
- Mistake: versioning
.venv/in Git. It's heavy, OS-specific, and regenerable. You version the recipe (pyproject.toml+requirements.lock), never the environment. If you see.venv/in agit status, run to the.gitignore. - Mistake:
pip freeze > requirements.txtas a "lock". It half works, but it dumps everything installed in the environment (including what you tried one afternoon and don't use), doesn't distinguish direct from transitive dependencies, and can't be regenerated from a source of intent.pip-compilestarts frompyproject.tomland annotates where each package comes from: it's the difference between a tidy snapshot and the drawer full of cables. - Mistake: training with one set of versions and serving with another. The treacherous pickle case. The training environment and the inference environment must come from the same lock; when we get to Docker (04-03) and the service (04-02), this rule will turn into image building, but the discipline starts today.
- Mistake: upgrading dependencies "because there was a new version" without re-evaluating. In ML a scikit-learn upgrade can move your metrics. Treat it like any other change: branch,
pip-compile --upgrade-package, retrain, compare metrics, review, merge. - Tip: if the team already knows uv, use it: it's a near drop-in replacement (
uv venv,uv pip compile,uv pip sync) and orders of magnitude faster. What matters in this lesson is the intent/lock pattern, not the specific tool.
Exercises
Exercise 1
Marc clones the repo and creates his .venv, but instead of using the lock he runs pip install pandas scikit-learn pyyaml pytest and then pip install -e . --no-deps. The tests pass, the training runs without errors... but he gets recall = 0.41 instead of the expected 0.43. (1) What is the most likely cause? (2) Why didn't any error or test catch it? (3) Which two commands should he have run instead?
Exercise 2
Classify these CineClick project dependencies as runtime (dependencies) or development (optional-dependencies.dev), justifying each one: (a) scikit-learn — trains and runs the model; (b) pytest — runs the tests; (c) pyyaml — loads configs/config.yaml; (d) pip-tools — regenerates the lock; (e) fastapi — will serve the model in module 4: when it arrives, where will it go?
Exercise 3
The current requirements.lock pins scikit-learn==1.5.2. Version 1.6.0 comes out with a performance improvement you care about. Describe, step by step and with the concrete commands, the correct procedure for adopting it at CineClick, including what you would do with the already-trained churn_model.pkl and why.
Solutions
Solution 1: (1) pip install without versions installed the latest of each package, most likely a version of scikit-learn (or of numpy, transitively) different from the lock's; the algorithm's internal details change between versions and, even with the same seed, the resulting model differs slightly. (2) There's no error because the API we use didn't change, and the features test doesn't evaluate model metrics — it checks the encoding, which is deterministic in any reasonable version (the tests that watch metrics will arrive in 05-01). (3) pip-sync requirements.lock followed by pip install -e . --no-deps: that reproduces the exact blessed environment.
Solution 2: (a) runtime — without scikit-learn you can neither train nor deserialize/use the model; (b) dev — production doesn't run tests; (c) runtime — the training code reads the configuration in whatever environment it runs; (d) dev — it's a workflow tool, the code never imports it; (e) runtime — the prediction service will import it to work in production (in fact, large teams sometimes split train/serve extras so the service doesn't carry all of scikit-learn and vice versa; for CineClick, a single runtime group is enough for now).
Solution 3: (1) create a branch (git checkout -b update-sklearn-16); (2) widen the range in pyproject.toml (scikit-learn>=1.5,<1.7, or directly >=1.6,<1.7); (3) regenerate the lock for that package only: pip-compile pyproject.toml --extra dev -o requirements.lock --upgrade-package scikit-learn; (4) pip-sync requirements.lock and pip install -e . --no-deps; (5) retrain the model from scratch and compare the metrics against the baseline (accuracy 0.86 / precision 0.61 / recall 0.43 / F1 0.50); (6) discard the old churn_model.pkl: a pickle from 1.5.2 must not be served from a 1.6.0 environment — the "good" model becomes the retrained one; (7) commit pyproject.toml + requirements.lock together, pull request with the compared metrics, merge. If the metrics got worse with no explanation, you drop the branch and nothing happened: that cheap reversibility is exactly what the lock and Git buy you.
Conclusion
Problem #8's days are numbered: CineClick's environment is now a versioned recipe — a .venv per project, dependencies with ranges in pyproject.toml separating runtime from development, exact pins for the whole tree in a requirements.lock generated with pip-compile, and .python-version plus PYTHONHASHSEED tying up the loose ends — and any colleague can recreate, byte for byte in versions, the environment that produces our metrics. Reproducible code (02-01) + reproducible environment (this lesson) leave us at the doorstep of the third ingredient, and the bulkiest one: the data. The churn_customers.csv is still a loose file that every monthly extract overwrites, with no history and no way back — problem #3 from the diagnosis. Git can't handle it (and in the next lesson we'll see exactly why), so it's time to meet the tool born for this: DVC, version control for data.
MLOps Course
Module 1: MLOps Fundamentals
- What MLOps is and why models die in the notebook
- The lifecycle of an ML model in production
- MLOps maturity levels and team roles
- The course project: from notebook to production
Module 2: From Notebook to Reproducible Code
- Structuring an ML project: from notebook to package
- Reproducible environments and dependency management
- Data versioning with DVC
- Reproducible training pipelines
Module 3: Experiments and Model Registry
- Experiment tracking with MLflow
- Model registry: versioning and promoting models
- Feature stores: when and what for
Module 4: Serving Models in Production
- Deployment patterns: batch, online and streaming
- A prediction service with FastAPI
- Packaging with Docker
- Scaling and deployment: Kubernetes and serverless
- Inference optimization: latency and cost
Module 5: Automation: CI/CD and Orchestration
- CI for ML: testing code, data and models
- CD: automating model deployment
- Orchestrating ML pipelines
- Release strategies: shadow, canary and A/B
