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.