Skip to main content
MLOps intermediate Lesson 7 of 8

MLOps Data Versioning with DVC

Version datasets and models alongside code using DVC — reproducible pipelines, remote storage, and experiment tracking.

Real-World Scenario

An ML team has a data science repo where nobody knows which model corresponds to which dataset version. Running the training script with different data gives different results and nobody can reproduce last month’s best model. DVC solves this: every experiment is tagged with the exact data version, preprocessing code, and hyperparameters that produced it.

DVC Setup and First Dataset

# Install
pip install dvc dvc-s3  # or dvc-gs, dvc-azure for other clouds

# Initialize in an existing git repo
git init ml-project && cd ml-project
dvc init
git add .dvc .dvcignore
git commit -m "Initialize DVC"

# Configure remote storage (S3 example)
dvc remote add -d myremote s3://my-ml-bucket/dvc-storage
dvc remote modify myremote region us-east-1
git add .dvc/config
git commit -m "Configure DVC remote"
# prepare_data.py — download and track a dataset
import pandas as pd
import numpy as np
from pathlib import Path

Path("data/raw").mkdir(parents=True, exist_ok=True)

# Simulate downloading a dataset
rng = np.random.default_rng(42)
df = pd.DataFrame({
    "feature_1": rng.normal(0, 1, 10000),
    "feature_2": rng.uniform(0, 100, 10000),
    "feature_3": rng.integers(0, 5, 10000),
    "label":     rng.integers(0, 2, 10000),
})
df.to_csv("data/raw/dataset.csv", index=False)
print(f"Dataset: {df.shape}  |  {df['label'].value_counts().to_dict()}")
# Track the dataset with DVC (not Git)
dvc add data/raw/dataset.csv
git add data/raw/dataset.csv.dvc data/raw/.gitignore
git commit -m "Add raw dataset v1 (10k samples)"

# Push data to remote storage
dvc push

# Now anyone can reproduce this exact dataset:
# git clone <repo>
# dvc pull  ← downloads from remote storage

DVC Pipelines (dvc.yaml)

# dvc.yaml — define the ML pipeline as stages
stages:

  preprocess:
    cmd: python src/preprocess.py
    deps:
      - src/preprocess.py
      - data/raw/dataset.csv
    outs:
      - data/processed/train.csv
      - data/processed/test.csv
    params:
      - params.yaml:
          - preprocess.test_size
          - preprocess.random_state

  train:
    cmd: python src/train.py
    deps:
      - src/train.py
      - data/processed/train.csv
    outs:
      - models/model.pkl
    params:
      - params.yaml:
          - train.n_estimators
          - train.max_depth
          - train.learning_rate
    metrics:
      - metrics/scores.json:
          cache: false   # keep in Git so we can diff metrics

  evaluate:
    cmd: python src/evaluate.py
    deps:
      - src/evaluate.py
      - models/model.pkl
      - data/processed/test.csv
    metrics:
      - metrics/eval_report.json:
          cache: false
# params.yaml — all hyperparameters in one place
preprocess:
  test_size: 0.2
  random_state: 42

train:
  n_estimators: 200
  max_depth: 5
  learning_rate: 0.1
# src/preprocess.py — uses params.yaml via dvc
import pandas as pd
import yaml
from sklearn.model_selection import train_test_split
from pathlib import Path

params = yaml.safe_load(open("params.yaml"))["preprocess"]
df     = pd.read_csv("data/raw/dataset.csv")

train, test = train_test_split(
    df, test_size=params["test_size"], random_state=params["random_state"], stratify=df["label"]
)

Path("data/processed").mkdir(exist_ok=True)
train.to_csv("data/processed/train.csv", index=False)
test.to_csv("data/processed/test.csv",  index=False)
print(f"Train: {len(train)}  Test: {len(test)}")
# src/train.py
import pandas as pd
import yaml
import joblib
import json
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score
from pathlib import Path

params  = yaml.safe_load(open("params.yaml"))["train"]
train   = pd.read_csv("data/processed/train.csv")

X_train = train.drop("label", axis=1).values
y_train = train["label"].values

model = HistGradientBoostingClassifier(
    max_iter=params["n_estimators"],
    max_depth=params["max_depth"],
    learning_rate=params["learning_rate"],
    random_state=42,
)
model.fit(X_train, y_train)

Path("models").mkdir(exist_ok=True)
joblib.dump(model, "models/model.pkl")

# Log training metrics
train_auc = roc_auc_score(y_train, model.predict_proba(X_train)[:, 1])
Path("metrics").mkdir(exist_ok=True)
json.dump({"train_auc": round(float(train_auc), 4)}, open("metrics/scores.json", "w"))
print(f"Saved model. Train AUC: {train_auc:.4f}")

Running Experiments with DVC

# Run the full pipeline (only re-runs changed stages)
dvc repro

# Check what would run without executing
dvc status

# Run a specific stage
dvc repro train

# ── Experiment 1: baseline ─────────────────────────────────────────────
git add . && git commit -m "Baseline: 200 trees, lr=0.1"

# ── Experiment 2: more trees ──────────────────────────────────────────
# Edit params.yaml: n_estimators: 500
dvc repro train evaluate   # only retrain, not preprocess (data unchanged)
git add . && git commit -m "Experiment: 500 trees"

# Compare experiments
dvc metrics diff HEAD~1     # compare current vs previous commit
# Output:
#   Path                     Metric    HEAD~1    HEAD     Change
#   metrics/eval_report.json auc       0.8901    0.9023   0.0122

# Show all metrics across commits
dvc metrics show

Data Versioning: Multiple Dataset Versions

# create_dataset_v2.py — augmented dataset
import pandas as pd
import numpy as np
from pathlib import Path

rng = np.random.default_rng(99)
df_v1 = pd.read_csv("data/raw/dataset.csv")

# Add 5000 new samples and a new feature
df_new = pd.DataFrame({
    "feature_1": rng.normal(0.5, 1, 5000),   # slight distribution shift
    "feature_2": rng.uniform(0, 100, 5000),
    "feature_3": rng.integers(0, 5, 5000),
    "label":     rng.integers(0, 2, 5000),
})
df_v2 = pd.concat([df_v1, df_new], ignore_index=True)
df_v2.to_csv("data/raw/dataset.csv", index=False)
print(f"Dataset v2: {df_v2.shape}")
# Update the DVC-tracked file
dvc add data/raw/dataset.csv    # updates the .dvc pointer
git add data/raw/dataset.csv.dvc
git commit -m "Dataset v2: added 5k samples, slight distribution shift"
dvc push   # uploads new data to remote

# To switch back to dataset v1:
git checkout HEAD~1 -- data/raw/dataset.csv.dvc
dvc checkout   # restores the v1 data file from cache/remote
dvc repro      # re-runs pipeline with v1 data

DVC with GitHub Actions

# .github/workflows/dvc-pipeline.yml
name: DVC Pipeline

on:
  push:
    branches: [main]
    paths:
      - "src/**"
      - "params.yaml"
      - "dvc.yaml"
      - "data/**/*.dvc"

jobs:
  run-pipeline:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install dvc dvc-s3 -r requirements.txt

      - name: Configure DVC remote credentials
        env:
          AWS_ACCESS_KEY_ID:     ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: dvc remote modify myremote access_key_id $AWS_ACCESS_KEY_ID

      - name: Pull data from DVC remote
        run: dvc pull

      - name: Run pipeline
        run: dvc repro

      - name: Report metrics
        run: dvc metrics show

      - name: Push new artifacts to DVC remote
        run: dvc push

Frequently Asked Questions

Why can't I just use Git to version datasets?
Git stores file content as blobs — it works for code because source files are small. A 5GB training dataset would bloat the repository and make every clone download it. DVC solves this by storing only a small .dvc pointer file in Git, while pushing the actual data to remote storage (S3, GCS, Azure Blob, SSH). You get reproducibility without bloating the repo.
What is a DVC pipeline and how is it different from a Makefile?
A DVC pipeline (dvc.yaml) defines stages with inputs (deps) and outputs (outs). DVC tracks file hashes, so it only re-runs a stage if its inputs have changed — like Make, but content-aware rather than timestamp-aware. It also integrates with Git for experiment branching and can push/pull pipeline outputs from remote storage.