The ML Toolchain — DVC, Prefect, MLflow, and Friends

Stop Hoarding Tools, Start Picking the Right One

Every month there’s a new ML tool that promises to fix your entire pipeline. Here’s the thing — you don’t need a platform. You need three separate things that do three separate jobs.

The three categories:

  • Data versioning — DVC, LakeFS, Quilt
  • Orchestration — Prefect, Dagster, Airflow
  • Experiment tracking & serving — MLflow, Weights & Biases, Neptune

Data Versioning — DVC

Your training data is a CSV. You change one row. Now you have training_v2.csv, training_final_v3.csv, and training_actually_final_v4.csv. Your model metrics went up, but you can’t remember which data version produced them.

DVC treats datasets like Git treats code. It hashes the contents and tracks the metadata in Git. The actual data lives in S3 or GCS.

# Tell DVC to track data/raw/transactions.csv — creates a .dvc pointer file
dvc add data/raw/transactions.csv
# Stage the .dvc pointer file (not the CSV itself) into Git's index
git add data/raw/transactions.csv.dvc
# Commit the pointer so Git permanently records which data version was used
git commit -m "add training data v1"

# Upload the actual CSV from the local cache to the remote storage (S3, GCS, etc.)
dvc push

# Download the CSV from remote storage back to the local cache and workspace
dvc pull

The .dvc file is a tiny pointer that goes in Git. The actual CSV lives in S3.

# data/raw/transactions.csv.dvc
# MD5 hash of the file contents — changes whenever the data changes
md5: a1b2c3d4e5f6...
# File size in bytes, used to verify integrity after download
size: 234000000

When DVC shines:

  • You need to reproduce an experiment from 3 months ago with the exact same data.
  • You work with datasets that don’t belong in Git.
  • Your team keeps asking “where did the training data come from?”

When DVC doesn’t help:

  • You need to version a database (use LakeFS).
  • Your data is already in a warehouse (dbt + git tags work fine).

DVC + MLflow

Tie your data version to your experiment:

# Import MLflow for experiment tracking and logging
import mlflow
# Import DVC's Python API to query tracked data programmatically
import dvc.api

# Start an MLflow run — everything logged inside this block belongs to one experiment
with mlflow.start_run():
    # Get the remote URL (S3/GCS path) for the DVC-tracked data file
    data_url = dvc.api.get_url("data/raw/transactions.csv")
    # Get the Git revision (commit hash or tag) of the DVC-tracked data file
    data_rev = dvc.api.get_rev("data/raw/transactions.csv")

    # Log the data revision as a parameter so the run records exactly which data was used
    mlflow.log_param("data_version", data_rev)
    # Log the data URL as a parameter for debugging and access traceability
    mlflow.log_param("data_url", data_url)

    # Train the model (function call placeholder for actual training logic)
    model = train_model()
    # Serialize and log the trained scikit-learn model as an MLflow artifact
    mlflow.sklearn.log_model(model, "model")
    # Record the accuracy metric so runs can be compared in the MLflow UI
    mlflow.log_metric("accuracy", 0.97)

Six months later, look at the MLflow run, see the DVC rev, run dvc checkout, and you’ve got the exact dataset.

Orchestration — Prefect

You’ve got a pipeline: extract data from API → clean it → train model → evaluate → deploy. Running those steps by hand is fine for about two iterations. Then you need a scheduler, retries, logging, and alerting.

Prefect is what you use when Airflow feels too heavy and cron feels too light:

# Import Prefect decorators that turn functions into pipeline nodes
from prefect import flow, task
# Import pandas for tabular data manipulation
import pandas as pd
# Import the ML model we will train inside the pipeline
from sklearn.ensemble import RandomForestClassifier

# Decorate this function as a Prefect task: retry up to 3 times, wait 60s between retries
@task(retries=3, retry_delay_seconds=60)
def extract_data():
    # Fetch raw transaction data from an external REST API
    resp = requests.get("https://api.example.com/transactions")
    # Raise an exception if the HTTP response indicates an error (4xx or 5xx)
    resp.raise_for_status()
    # Parse the JSON response into a DataFrame and return it
    return pd.DataFrame(resp.json())

# Decorate this function as a Prefect task with default retry behaviour
@task
def clean_data(raw: pd.DataFrame):
    # Remove any rows that contain null/NaN values from the DataFrame
    return raw.dropna()

# Decorate this function as a Prefect task
@task
def train_model(cleaned: pd.DataFrame):
    # Instantiate a random forest classifier with default hyperparameters
    model = RandomForestClassifier()
    # Fit the model on features (all columns except "label") and the target ("label")
    model.fit(cleaned.drop("label", axis=1), cleaned["label"])
    # Return the trained model object for downstream tasks
    return model

# Decorate this function as a Prefect task
@task
def evaluate(model, cleaned: pd.DataFrame):
    # Compute the model's accuracy on the training data
    score = model.score(cleaned.drop("label", axis=1), cleaned["label"])
    # Print the accuracy score to the console (captured by Prefect's logging)
    print(f"Accuracy: {score}")
    # If accuracy is below 80%, abort the pipeline by raising a ValueError
    if score < 0.8:
        raise ValueError("Model too bad to deploy")

# Decorate this function as a Prefect flow — the top-level orchestration unit
@flow
def ml_pipeline():
    # Call the extract task; Prefect tracks its execution and return value
    raw = extract_data()
    # Pass the raw DataFrame into the clean task; Prefect builds the dependency graph
    cleaned = clean_data(raw)
    # Pass cleaned data into the training task; Prefect runs this only after clean succeeds
    model = train_model(cleaned)
    # Pass the model and cleaned data into the evaluation task
    evaluate(model, cleaned)

# Standard Python entrypoint guard — run the flow only when executing this script directly
if __name__ == "__main__":
    # Execute the flow; Prefect resolves dependencies and runs tasks in order
    ml_pipeline()

Deploy it:

# Generate a deployment YAML file from the ml_pipeline function in ml_pipeline.py
prefect deployment build ml_pipeline.py:ml_pipeline -n "training-v1"
# Register (apply) the deployment so Prefect's server knows about it
prefect deployment apply ml_pipeline-deployment.yaml

Now you’ve got scheduling, retries, logging, and a UI.

When to use Prefect:

  • You want scheduling + retries without managing a database and a web server.
  • Your pipelines are Python-heavy.
  • You want to test locally and deploy without changing code.

When Airflow wins:

  • You need 200+ DAGs with complex dependencies.
  • Your team already knows Airflow.
  • You need sensor patterns.

When to just use cron:

  • Your pipeline runs once a day, takes 30 seconds, and never fails.

Prefect + MLflow

# Import Prefect decorators to define tasks and flows
from prefect import flow, task
# Import MLflow for experiment tracking and model logging
import mlflow

# Decorate this function as a Prefect task
@task
def train_and_log():
    # Open an MLflow run context — all logs belong to this single run
    with mlflow.start_run():
        # Create a random forest model with 100 trees
        model = RandomForestClassifier(n_estimators=100)
        # Train the model on the provided feature and target arrays
        model.fit(X_train, y_train)
        # Serialise and log the trained model as an MLflow artifact named "model"
        mlflow.sklearn.log_model(model, "model")
        # Log the accuracy metric for comparison in the MLflow UI
        mlflow.log_metric("accuracy", 0.97)

# Decorate this function as a Prefect flow — the top-level pipeline definition
@flow
def training_pipeline():
    # Task 1: free up old artefacts or temporary storage (placeholder function)
    cleanup_storage()
    # Task 2: train the model and log everything to MLflow
    train_and_log()
    # Task 3: compare the new model to the current champion and promote if better
    promote_if_best()

Prefect orchestrates. MLflow tracks.

Experiment Tracking — MLflow

You’ve trained 50 models this week. You know the last one was the best, but you closed the terminal and now you can’t remember the hyperparameters.

# Import MLflow for experiment tracking
import mlflow
# Import the classifier we will train across different hyperparameter combinations
from sklearn.ensemble import RandomForestClassifier

# Outer loop: vary the number of trees in the forest
for n_estimators in [50, 100, 200]:
    # Inner loop: vary the maximum depth of each tree
    for max_depth in [5, 10, 15]:
        # Each combination gets its own MLflow run — 9 total runs
        with mlflow.start_run():
            # Create a random forest with the current hyperparameter pair
            model = RandomForestClassifier(
                n_estimators=n_estimators, max_depth=max_depth
            )
            # Train the model on the training set
            model.fit(X_train, y_train)
            # Compute accuracy on the held-out test set
            acc = accuracy_score(y_test, model.predict(X_test))

            # Log the number of estimators so we can filter/sort by it later
            mlflow.log_param("n_estimators", n_estimators)
            # Log the max depth so we can compare across runs
            mlflow.log_param("max_depth", max_depth)
            # Log the accuracy metric — the primary value to compare across runs
            mlflow.log_metric("accuracy", acc)
            # Serialise and store the trained model for later deployment or inspection
            mlflow.sklearn.log_model(model, "model")

Open mlflow ui and you get a table comparing all 9 runs. Sort by accuracy. Pick the winner.

The MLflow Registry:

# Register the model logged under run <run_id> into the "fraud-detector" registered model
mlflow models register -m runs:/<run_id>/model -n fraud-detector
# Transition version 1 of the "fraud-detector" model into the Production stage
mlflow models transition -m models:/fraud-detector/1 --stage Production

When MLflow works:

  • You’re experimenting and want to compare results.
  • You need a model registry.
  • You want to package models with their dependencies.

When you should pick something else:

  • You need collaborative experiment tracking (Weights & Biases has better team features).
  • You’re doing deep learning with complex visualizations (Neptune or W&B).

Comparable Alternatives

You need Use this Instead of MLflow if
Better team collaboration Weights & Biases You don’t want to run a tracking server
Deep learning visualization Neptune.ai You need GPU utilization charts
Managed tracking W&B / Neptune You don’t want to maintain infrastructure
Database-scale versioning LakeFS DVC can’t handle your 10TB data lake
Lightweight orchestration Prefect Airflow setup takes too long
Heavy-duty orchestration Airflow You need the ecosystem

The Minimal Viable Stack

Starting from scratch today:

  1. DVC for data versioning
  2. MLflow for experiment tracking and model registry
  3. Prefect for orchestration

Plus optionally BentoML for production serving.

The Golden Rule

Don’t adopt a tool before you feel the pain it solves. Don’t set up DVC because a blog post said to. Set it up when you lose a dataset. Don’t deploy Prefect because it looks cool. Deploy it when your cron job fails silently for a week.

Let the pain guide you. The tools will be waiting.

Comments

Leave a comment

What is 2 + 3?
Please solve the arithmetic question above to verify you're human.