We have the three ingredients versioned — code, environment, and data — but the recipe is still cooked by hand: someone has to remember to run the cleanup, then the features, then the training, then the evaluation, in that order, and to repeat the right steps when something changes. In this lesson we turn that ritual into a declared pipeline: a dvc.yaml file where each stage states which command it runs, what it depends on, and what it produces, and a single command — dvc repro — that rebuilds everything, re-running only what changed. It's the piece that closes the module and the promise we've been chasing since the diagnosis: that anyone on the team can clone the repository and obtain the same model as Laura, without Laura.

Contents

  1. What a training pipeline is and why chaining by hand is fragile
  2. The CineClick pipeline in dvc.yaml
  3. params.yaml: hyperparameters as a dependency
  4. dvc repro: the dependency graph in action
  5. dvc dag and dvc metrics diff: seeing the pipeline and comparing runs
  6. Closing the module: what we've solved and what remains open

What a training pipeline is and why chaining by hand is fragile

A training pipeline is the sequence of steps that transforms raw data into an evaluated model: in our case, clean → build features → train → evaluate. The sequence has existed implicitly in our code since 02-01; the problem is how it runs. Today, the procedure is mental: Laura (or you) launches the steps by hand. And manual chaining fails in very concrete ways:

  • Ordering and omissions: nothing stops you from training on stale features because you forgot to re-run the previous step after changing the cleanup. The model comes out, throws no error, and is subtly wrong — the worst class of bug.
  • Unnecessary re-runs: the defensive alternative ("just in case, I'll rerun everything from scratch") wastes time, and when data preparation takes hours, that "just in case" will be unsustainable.
  • Non-transferable knowledge: the correct order, the exact commands, and their conditions live in someone's head or in a README that ages. It's problem #10 from the diagnosis in a new tie.
  • No record of what produced what: even with the data versioned, nothing ties this version of the model to that run with those parameters.

The solution is the same one make and build systems have applied for decades: declare the steps with their dependencies and outputs, and let the tool work out what needs to run. DVC ships this built in, integrated with the data versioning from the previous lesson: each stage's outputs automatically come under DVC's control.

The CineClick pipeline in dvc.yaml

First, a small adaptation of the code: each package module gains a runnable main() function (python -m cineclick_churn.data, etc.) that reads its configuration and writes its outputs to disk — the pattern we left prepared in 02-01. The steps communicate through files, not in-memory variables: data.py writes the clean CSV, features.py reads it and writes the feature matrix, train.py reads that and writes the model plus the held-out test set, and evaluate.py reads both and writes the metrics. That file-based communication is exactly what lets DVC know who depends on whom.

With that in place, the full pipeline is declared in dvc.yaml, at the repo root:

# dvc.yaml — CineClick's training pipeline
stages:
  prepare_data:
    cmd: python -m cineclick_churn.data
    deps:
      - data/raw/churn_customers.csv
      - src/cineclick_churn/data.py
    outs:
      - data/processed/customers_clean.csv

  build_features:
    cmd: python -m cineclick_churn.features
    deps:
      - data/processed/customers_clean.csv
      - src/cineclick_churn/features.py
    outs:
      - data/processed/features.csv

  train:
    cmd: python -m cineclick_churn.train
    deps:
      - data/processed/features.csv
      - src/cineclick_churn/train.py
    params:
      - train.test_size
      - train.random_state
      - train.n_estimators
    outs:
      - models/churn_model.pkl
      - data/processed/test.csv

  evaluate:
    cmd: python -m cineclick_churn.evaluate
    deps:
      - models/churn_model.pkl
      - data/processed/test.csv
      - src/cineclick_churn/evaluate.py
    metrics:
      - metrics.json:
          cache: false

Let's dissect the anatomy of a stage:

  • cmd: the command that runs it. Any shell command works; we use the package's modules, which is our tested code.
  • deps: its dependencies — data files and also the step's source code. This is key: if someone modifies data.py, DVC knows the cleanup (and everything hanging from it) must be repeated. Code is a dependency like any other.
  • params: fine-grained dependencies on values in params.yaml (next section). Only the listed values affect the stage.
  • outs: what it produces. DVC takes control of these outputs: it caches them by hash (as dvc add did, but now automatically) and adds them to .gitignore. The churn_model.pkl model becomes DVC-versioned without us doing anything else — its receipt now lives in dvc.lock, the file DVC generates with the exact hashes of each run, and which does get committed.
  • metrics: a special output — a small JSON of metrics we do want readable and comparable (hence cache: false: it's tiny and we want to see it in Git, with its diffs).

The evaluate stage writes metrics.json with the honest metrics from 02-01:

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

One design detail deserves a pause: train produces two outputs, the model and test.csv (the test partition). Why? Because evaluate must measure on exactly the rows the training never saw; if each stage repeated the split on its own, a slip in the parameters could end up evaluating on training data. Materializing the test set as a file makes the boundary explicit and auditable.

params.yaml: hyperparameters as a dependency

In 02-01 we put the parameters in configs/config.yaml. DVC introduces a useful distinction we adopt now: the paths (where everything lives) are already declared in dvc.yaml itself via deps/outs, while the hyperparameters (the values you change when experimenting) move to params.yaml, the file DVC reads by convention:

# params.yaml — pipeline hyperparameters
train:
  test_size: 0.2
  random_state: 42
  n_estimators: 100

The code in train.py loads it just as it loaded the config (yaml.safe_load), so the change is minor. The gain is conceptual and practical at once: by declaring params: [train.n_estimators, ...] in the stage, each hyperparameter becomes an individually tracked dependency. DVC doesn't watch "the params.yaml file changed" but "the value train.n_estimators changed" — and it only invalidates the stages that depend on that specific value. Editing a comment in the YAML re-runs nothing.

dvc repro: the dependency graph in action

Everything's declared. The star command:

dvc repro

On the first run, DVC walks the graph in order and executes the four stages, caching each output and recording in dvc.lock the hashes of every stage's dependencies and outputs. That dvc.lock gets committed alongside dvc.yaml and params.yaml: it's the exact record of "these inputs produced these outputs".

The magic shows up on the second run. Suppose you want to try a bigger forest — you edit params.yaml and change n_estimators: 100 to n_estimators: 300:

dvc repro
Stage 'prepare_data' didn't change, skipping
Stage 'build_features' didn't change, skipping
Running stage 'train':
> python -m cineclick_churn.train
Running stage 'evaluate':
> python -m cineclick_churn.evaluate
Updating lock file 'dvc.lock'

DVC compared hashes: the raw data didn't change, neither did data.py or features.py, so the cleanup and the features get skipped — their cached outputs are still valid. Only train (whose parameter changed) and evaluate (which depends on the new model) re-run. With large datasets, this is the difference between iterating in minutes or in hours. And it works in every direction: if instead of the parameter it's data/raw/churn_customers.csv that changes (a new extract via dvc add... or directly as a dependency), everything re-runs, because everything hangs from the data; if only evaluate.py changes, only the evaluation re-runs. Never too much, never too little: "what do I have to rerun?" has stopped being a human decision.

dvc dag and dvc metrics diff: seeing the pipeline and comparing runs

To see the graph DVC has built from the declared dependencies:

dvc dag
+--------------+
| prepare_data |
+--------------+
        *
+----------------+
| build_features |
+----------------+
        *
    +-------+
    | train |
    +-------+
        *
  +----------+
  | evaluate |
  +----------+

Which in its pretty version is this DAG (directed acyclic graph — directed because the data flows in one direction, acyclic because no step can depend on itself):

flowchart TD
    RAW[("data/raw/<br/>churn_customers.csv<br/>(DVC)")] --> P
    P["prepare_data<br/><small>data.py</small>"] --> |customers_clean.csv| F
    F["build_features<br/><small>features.py</small>"] --> |features.csv| T
    PARAMS[["params.yaml<br/>n_estimators, test_size,<br/>random_state"]] --> T
    T["train<br/><small>train.py</small>"] --> |churn_model.pkl| E
    T --> |test.csv| E
    E["evaluate<br/><small>evaluate.py</small>"] --> M[["metrics.json"]]

And for the 300-tree experiment, the business question is "did it improve?". Since metrics.json is declared as a metric, DVC knows how to compare it between the current state and the last commit:

dvc metrics diff
Path          Metric     HEAD    workspace    Change
metrics.json  accuracy   0.86    0.8613       0.0013
metrics.json  f1         0.5     0.512        0.012
metrics.json  precision  0.61    0.618        0.008
metrics.json  recall     0.43    0.44         0.01

Verdict: tripling the trees scrapes out a marginal improvement across all metrics — probably not worth triple the training and inference time, so we revert the parameter (or commit the change if we decide it is). What matters is the flow: change a value, dvc repro, dvc metrics diff, decide with data. Now, let's be honest about the limits: this compares the workspace against one commit, one at a time. When you want to explore twenty hyperparameter combinations, see them in a table, sort them by recall, and recover any of them, this mechanism falls short — that comfort is exactly what MLflow brings, and it's the first stop of module 3.

The circle closes with a new colleague's complete flow. Marc, on a clean machine:

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
dvc pull        # fetches the data (and cached outputs) from the remote
dvc repro       # "Data and pipelines are up to date."

That final message — everything up to date, nothing to re-run — is reproducibility turned into a sentence: the hashes of data, code, and parameters on Marc's machine match exactly those in the committed dvc.lock, therefore his pipeline would produce the same model as Laura's. And if he deletes models/ and reruns dvc repro, he verifies it empirically: same metrics, decimal for decimal. CineClick has never been here before.

Closing the module: what we've solved and what remains open

Let's review the diagnosis from lesson 01-04, which was our task list:

# Problem Status after module 2
1 Absolute, personal paths ✅ Solved (02-01): relative paths in configuration and dvc.yaml
2 No random seed ✅ Solved (02-01): random_state=42 in split and model, stratified split
3 Unversioned data ✅ Solved (02-03): DVC + remote; time travel with git checkout + dvc checkout
8 Implicit dependencies ✅ Solved (02-02): pyproject.toml + requirements.lock + .python-version
9 Non-reusable preprocessing ✅ On track (02-01): importable features.py; module 4's service will consume it
10 A 100% manual process 🟡 Partial (02-04): repeatable with dvc repro, but still launched by hand and locally — scheduling and orchestrating it arrives in 05-03
5 A single, misleading metric 🟡 Partial (02-01): precision/recall/F1 in metrics.json; comparing many experiments comfortably → module 3
4 Model with no versioning or metadata 🟡 Partial: the .pkl is in DVC's cache tied to its commit, but with no formal registry of versions/stages → model registry, module 3
7 No experiment tracking ❌ Open: dvc metrics diff compares two at a time; the comfortable, queryable history → MLflow, module 3
6 No tests ❌ Open (except the seed planted in 02-01): data and model tests in CI → module 5

Four problems closed, three well on track, and the remaining three with an owner and a date. Not bad for one module.

Common Mistakes and Tips

  • Mistake: leaving the code out of the deps. If train only declares features.csv as a dependency, a change in train.py won't re-run anything and you'll keep serving the old code's model. Rule: each stage depends on its input data and its code.
  • Mistake: steps that communicate through in-memory variables. If a single script does features+training "because it's more convenient", DVC can't skip the expensive part when only the cheap one changes. The pipeline's granularity is set by the intermediate files: if you want to be able to skip a step, materialize its output.
  • Mistake: hand-editing an output (outs). Tweaking data/processed/features.csv with Excel lasts until the next dvc repro, which will regenerate it from its dependencies. Every change must enter through the top of the graph: raw data, code, or parameters.
  • Mistake: not committing dvc.lock. Without it, the rest of the team doesn't know which hashes correspond to the last good run, and Marc's dvc repro can't verify he's up to date. dvc.yaml, dvc.lock, and params.yaml travel together in the commit.
  • Tip: keep dvc repro cheap. The "did it improve?" discipline with dvc metrics diff only gets practiced if iterating takes minutes. If a step turns slow, split it up (the expensive, stable part separated from the cheap, changing one) so the cache works in your favor.

Exercises

Exercise 1

For each of these changes, state which stages the next dvc repro will re-run (prepare_data / build_features / train / evaluate) and why: (a) you change train.test_size from 0.2 to 0.25 in params.yaml; (b) the data engineer delivers the August extract and you replace data/raw/churn_customers.csv; (c) you fix a docstring in evaluate.py; (d) you add a new cleanup rule in data.py; (e) you reorder the comments in params.yaml without touching any value.

Exercise 2

The team wants to try limiting tree depth to speed up inference. Describe the exact changes needed to incorporate the max_depth hyperparameter into the pipeline (which files you touch and what you add in each), and the complete flow to evaluate whether max_depth: 10 is worth it against the baseline.

Exercise 3

A skeptical executive asks: "and how do you prove to me that the model you have is reproducible, and not Laura's 0.87 all over again?". Design the demonstration: the sequence of commands you would run in front of him on a clean machine, what you would expect to see at each step, and what concrete message/result constitutes the proof.

Solutions

Solution 1: (a) train and evaluate — the parameter is a declared dependency of train, and evaluate depends on its outputs (model and test set); the first two stages don't depend on it and get skipped. (b) All four — the entire graph hangs from the raw data. (c) Only evaluateevaluate.py is a dependency of that stage alone (yes, DVC re-runs even if the change is a docstring: DVC compares file hashes, not semantics; it's the price of safety). (d) All four — data.py is a dependency of prepare_data, its output changes (or may change), and the invalidation propagates downstream. (e) None — the stages depend on specific values in params.yaml (train.n_estimators, etc.), not on the file as text, and no value changed.

Solution 2: changes: (1) in params.yaml, add max_depth: 10 under train:; (2) in src/cineclick_churn/train.py, read the value and pass it to the RandomForestClassifier(..., max_depth=params["max_depth"]); (3) in dvc.yaml, add train.max_depth to the params list of the train stage. Evaluation flow: dvc repro (only re-runs train+evaluate; note: the first time it retrains anyway because train.py changed), dvc metrics diff to compare against HEAD; if the metrics hold up (similar recall) with smaller trees, commit params.yaml+train.py+dvc.yaml+dvc.lock and dvc push; if not, git checkout -- . and dvc checkout to discard.

Solution 3: on a clean machine (or a container): (1) git clone + environment creation with pip-sync requirements.lock + pip install -e . --no-deps — expect an error-free install with exact versions; (2) dvc pull — downloads the exact data referenced by the commit; (3) dvc repro — expect "Data and pipelines are up to date", which is in itself the hash verification; (4) for the strong proof: delete models/churn_model.pkl and metrics.json and rerun dvc repro — the pipeline retrains from scratch; (5) show metrics.json and compare it against the committed one (or dvc metrics diff, expecting empty changes). The concrete proof: the regenerated metrics match the committed ones decimal for decimal (accuracy 0.86, precision 0.61, recall 0.43, F1 0.50), on a machine Laura has never touched. That is exactly what the original 0.87 could not offer.

Conclusion

Module 2 ends where it promised: the churn_final_v3_FINAL.ipynb notebook is now a project with an installable, tested package, an environment pinned with a lock, data versioned with DVC, and a pipeline declared in dvc.yaml — prepare_data → build_features → train → evaluate — that anyone can rebuild with git clone, dvc pull, and dvc repro, obtaining the same model and the same honest metrics (precision 0.61, recall 0.43, F1 0.50) decimal for decimal. Reproducibility, the foundation of everything else, is in place. But reviewing the diagnosis showed us what's missing, and the 300-tree experiment made it tangible: changing a parameter and comparing one run against another works; exploring dozens of combinations, seeing them in a table sorted by recall, knowing what was tried three weeks ago and with what result, and managing which model version is "blessed" for production — that calls for dedicated tools. In module 3 we give them to CineClick: MLflow for experiment tracking, its model registry for versioning and promoting models, and a critical look at feature stores. Laura will finally be able to answer, with data, the question left hanging in module 1: what exactly was "v2", and whether it really was worse.

© Copyright 2026. All rights reserved