AI and Machine Learning

MLOps Pipeline: From Notebook to Production Reality

Master MLOps by building a real-world pipeline from a Jupyter notebook to production. Learn about data versioning, experiment tracking, CI/CD, deployment, and monitoring.

Khader Vali June 26, 2026 15 min read

As senior engineers and data scientists, we’ve all been there: a brilliant machine learning model, meticulously crafted in a Jupyter notebook, delivering impressive metrics on development data. The excitement is palpable. But then comes the inevitable question: “How do we get this into production?” This chasm between exploratory analysis and robust, scalable, and maintainable production systems is where MLOps steps in. It’s the bridge that transforms promising research into tangible business value.

At Khadervali.com, we believe in practical, actionable insights. This article isn’t just about theoretical MLOps; it’s a deep dive into building a real-world MLOps pipeline, guiding you through each crucial stage from the initial notebook experiment to a fully monitored production deployment. We’ll cover the tools, the architectural considerations, and the best practices that enable seamless collaboration and continuous improvement.

Our journey will focus on a common scenario: building and deploying a customer churn prediction model. This use case provides a rich ground for exploring data versioning, experiment tracking, continuous integration/continuous deployment (CI/CD) for ML, robust model serving, and crucial monitoring mechanisms. By the end, you’ll have a holistic understanding of how to operationalize machine learning effectively.

The Genesis: From Idea to Notebook Experiment

Every ML project begins with an idea, often followed by data exploration and model prototyping in an interactive environment like a Jupyter Notebook. This phase is characterized by rapid iteration, trying different algorithms, features, and hyperparameters. While invaluable for discovery, notebooks inherently pose challenges when moving towards production.

The Notebook Advantage and Its Pitfalls

Jupyter Notebooks are fantastic for:

  • Interactive Exploration: Quickly visualize data, test hypotheses, and prototype models.
  • Reproducibility (within limits): Sharing a notebook allows others to run your code, assuming environments are identical.
  • Documentation: Combining code, output, and markdown explanations in one document.

However, they often fall short in a production context:

  • Lack of Modularity: Code tends to be monolithic, making testing and reuse difficult.
  • Dependency Hell: Managing environments across multiple notebooks and developers can be chaotic.
  • Version Control Challenges: Notebooks (.ipynb files) are JSON-based, leading to difficult-to-resolve merge conflicts in Git.
  • Reproducibility Issues: The “stateful” nature of notebooks can lead to hidden dependencies on execution order.
  • Scalability: Not designed for large-scale training or serving.

Best Practices for Notebook Development

To mitigate these issues, even in the early stages, adopt these habits:

  1. Modularize Your Code: Extract data loading, preprocessing, model training, and evaluation logic into separate Python functions or classes. Import them into your notebook.
  2. Environment Management: Use conda or venv from day one. Create an environment.yml or requirements.txt file and keep it updated.
  3. Version Control: Commit frequently. Use tools like nbdime for better diffing of notebooks, or even better, convert notebooks to plain Python scripts for committing and use notebooks purely for visualization/reporting.
  4. Basic Experiment Tracking: Even simple logging of parameters and metrics to a CSV or a local database is better than nothing.

Let’s imagine our initial churn prediction model. We start with a notebook, fetch some data, preprocess it, train a simple Logistic Regression model, and evaluate it.


# churn_notebook_initial.ipynb
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
import logging

# Basic logging setup
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

def load_data(path="data/churn_data.csv"):
    logging.info(f"Loading data from {path}")
    return pd.read_csv(path)

def preprocess_data(df):
    logging.info("Preprocessing data...")
    # Example preprocessing: drop customerID, one-hot encode categorical, scale numerical
    df = df.drop(['customerID'], axis=1)
    categorical_cols = df.select_dtypes(include='object').columns
    numerical_cols = df.select_dtypes(include=['int64', 'float64']).columns.drop('Churn')

    df = pd.get_dummies(df, columns=categorical_cols, drop_first=True)

    # Convert 'Yes'/'No' in 'Churn' to 1/0
    df['Churn'] = df['Churn'].apply(lambda x: 1 if x == 'Yes' else 0)

    scaler = StandardScaler()
    df[numerical_cols] = scaler.fit_transform(df[numerical_cols])
    return df, scaler # Return scaler for future use

def train_model(X_train, y_train, params={'solver': 'liblinear', 'random_state': 42}):
    logging.info(f"Training model with params: {params}")
    model = LogisticRegression(**params)
    model.fit(X_train, y_train)
    return model

def evaluate_model(model, X_test, y_test):
    logging.info("Evaluating model...")
    y_pred = model.predict(X_test)
    metrics = {
        'accuracy': accuracy_score(y_test, y_pred),
        'precision': precision_score(y_test, y_pred),
        'recall': recall_score(y_test, y_pred),
        'f1': f1_score(y_test, y_pred)
    }
    logging.info(f"Model metrics: {metrics}")
    return metrics

# --- Main execution in notebook ---
if __name__ == "__main__":
    df = load_data()
    processed_df, scaler = preprocess_data(df.copy()) # Use .copy() to avoid SettingWithCopyWarning

    X = processed_df.drop('Churn', axis=1)
    y = processed_df['Churn']

    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

    model = train_model(X_train, y_train)
    metrics = evaluate_model(model, X_test, y_test)

    # In a real scenario, you'd save the model and scaler
    # import joblib
    # joblib.dump(model, 'models/churn_model.pkl')
    # joblib.dump(scaler, 'models/scaler.pkl')

This initial notebook is a starting point. It’s functional, but far from production-ready. The next step is to transition this exploratory code into a more structured, maintainable, and testable format.

Phase 2: Transitioning to Production-Ready Code

The core principle here is to move from interactive, ad-hoc scripting to well-defined, modular, and testable Python code. This transformation is critical for building reliable MLOps pipelines.

Refactoring and Modularity

Extract functions and classes into separate Python files. This improves readability, reusability, and makes testing much easier.


# src/data_processor.py
import pandas as pd
from sklearn.preprocessing import StandardScaler
import logging

logger = logging.getLogger(__name__)

class DataProcessor:
    def __init__(self):
        self.scaler = StandardScaler()

    def load_data(self, path="data/churn_data.csv"):
        logger.info(f"Loading data from {path}")
        return pd.read_csv(path)

    def preprocess(self, df, fit_scaler=True):
        logger.info("Preprocessing data...")
        df_copy = df.copy() # Work on a copy
        df_copy = df_copy.drop(['customerID'], axis=1)
        
        categorical_cols = df_copy.select_dtypes(include='object').columns
        numerical_cols = df_copy.select_dtypes(include=['int64', 'float64']).columns.drop('Churn')

        df_copy = pd.get_dummies(df_copy, columns=categorical_cols, drop_first=True)
        df_copy['Churn'] = df_copy['Churn'].apply(lambda x: 1 if x == 'Yes' else 0)

        if fit_scaler:
            df_copy[numerical_cols] = self.scaler.fit_transform(df_copy[numerical_cols])
        else:
            df_copy[numerical_cols] = self.scaler.transform(df_copy[numerical_cols])
            
        return df_copy

    def save_scaler(self, path="models/scaler.pkl"):
        import joblib
        joblib.dump(self.scaler, path)
        logger.info(f"Scaler saved to {path}")

    def load_scaler(self, path="models/scaler.pkl"):
        import joblib
        self.scaler = joblib.load(path)
        logger.info(f"Scaler loaded from {path}")

# src/model_trainer.py
import logging
import joblib
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score

logger = logging.getLogger(__name__)

class ModelTrainer:
    def __init__(self, params=None):
        self.params = params if params else {'solver': 'liblinear', 'random_state': 42}
        self.model = LogisticRegression(**self.params)

    def train(self, X_train, y_train):
        logger.info(f"Training model with params: {self.params}")
        self.model.fit(X_train, y_train)
        return self.model

    def evaluate(self, X_test, y_test):
        logger.info("Evaluating model...")
        y_pred = self.model.predict(X_test)
        metrics = {
            'accuracy': accuracy_score(y_test, y_pred),
            'precision': precision_score(y_test, y_pred),
            'recall': recall_score(y_test, y_pred),
            'f1': f1_score(y_test, y_pred)
        }
        logger.info(f"Model metrics: {metrics}")
        return metrics

    def save_model(self, path="models/churn_model.pkl"):
        joblib.dump(self.model, path)
        logger.info(f"Model saved to {path}")

    def load_model(self, path="models/churn_model.pkl"):
        self.model = joblib.load(path)
        logger.info(f"Model loaded from {path}")

Testing

Unit tests and integration tests are non-negotiable for production code. They ensure correctness and prevent regressions. Use frameworks like pytest.


# tests/test_data_processor.py
import pandas as pd
from src.data_processor import DataProcessor

def test_load_data(tmp_path):
    # Create a dummy CSV file for testing
    dummy_data = {
        'customerID': ['1', '2'],
        'Gender': ['Male', 'Female'],
        'Churn': ['Yes', 'No']
    }
    dummy_df = pd.DataFrame(dummy_data)
    dummy_csv_path = tmp_path / "test_data.csv"
    dummy_df.to_csv(dummy_csv_path, index=False)

    processor = DataProcessor()
    loaded_df = processor.load_data(dummy_csv_path)
    assert not loaded_df.empty
    assert 'customerID' in loaded_df.columns

def test_preprocess_data():
    processor = DataProcessor()
    dummy_df = pd.DataFrame({
        'customerID': ['1', '2'],
        'Gender': ['Male', 'Female'],
        'MonthlyCharges': [10.0, 20.0],
        'Churn': ['Yes', 'No']
    })
    processed_df = processor.preprocess(dummy_df)
    assert 'Gender_Male' in processed_df.columns # One-hot encoded
    assert 'MonthlyCharges' in processed_df.columns # Scaled
    assert processed_df['Churn'].dtype == 'int64' # Converted to 0/1
    assert 'customerID' not in processed_df.columns # Dropped

Configuration Management

Hardcoding parameters is bad practice. Use configuration files (YAML, JSON) or libraries like Hydra or ConfigArgParse for managing hyperparameters, file paths, and other settings.


# config/model_config.yaml
data_path: "data/churn_data.csv"
test_size: 0.2
random_state: 42
model_params:
  solver: "liblinear"
  random_state: 42
model_save_path: "models/churn_model.pkl"
scaler_save_path: "models/scaler.pkl"

Now, our main training script can load these configurations.


# train.py
import yaml
import logging
from sklearn.model_selection import train_test_split
from src.data_processor import DataProcessor
from src.model_trainer import ModelTrainer

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

def main():
    with open('config/model_config.yaml', 'r') as f:
        config = yaml.safe_load(f)

    data_processor = DataProcessor()
    df = data_processor.load_data(config['data_path'])
    processed_df = data_processor.preprocess(df, fit_scaler=True)

    X = processed_df.drop('Churn', axis=1)
    y = processed_df['Churn']

    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=config['test_size'], random_state=config['random_state'], stratify=y
    )

    model_trainer = ModelTrainer(config['model_params'])
    model_trainer.train(X_train, y_train)
    metrics = model_trainer.evaluate(X_test, y_test)

    data_processor.save_scaler(config['scaler_save_path'])
    model_trainer.save_model(config['model_save_path'])

    logger.info(f"Training complete. Model saved to {config['model_save_path']}")
    logger.info(f"Scaler saved to {config['scaler_save_path']}")
    logger.info(f"Final metrics: {metrics}")

if __name__ == "__main__":
    main()

This structured approach is the bedrock for building an MLOps pipeline. The code is now ready to be integrated into automated workflows.

Phase 3: The MLOps Pipeline Architecture (A Conceptual Diagram in Words)

An MLOps pipeline is a series of automated steps that take a machine learning project from development to production and beyond. It encompasses data management, model training, deployment, and monitoring. Let’s visualize a typical architecture for our churn prediction model:

At the center of our MLOps universe lies a **Version Control System (VCS)** like Git, hosting our code, configurations, and pipeline definitions.

Surrounding this core, we have several interconnected components:

1. Data Layer:

  • Data Source: Operational databases (PostgreSQL, MySQL), data warehouses (Snowflake, BigQuery), streaming platforms (Kafka). For our churn model, this could be a CRM database or a data lake.
  • Data Storage: Cloud object storage (AWS S3, Azure Blob Storage, Google Cloud Storage) for raw and processed datasets.
  • Data Versioning & Management: Tools like DVC (Data Version Control) to track datasets, pipelines, and their dependencies, linked directly to our Git repository.

2. Experimentation & Development Environment:

  • IDEs/Notebooks: Visual Studio Code, Jupyter, PyCharm for initial exploration and script development.
  • Feature Store (Optional but Recommended): A centralized repository for curated features, ensuring consistency between training and serving (e.g., Feast). For our churn model, this would store features like “MonthlyCharges” or “Tenure”.
  • Experiment Tracking: MLflow, Weights & Biases, Comet ML to log code versions, parameters, metrics, and artifacts (models, scalers) for each experiment.

3. CI/CD Pipeline (Orchestration Layer):

  • CI/CD Platform: GitHub Actions, GitLab CI, Jenkins, Azure DevOps, AWS CodePipeline, Kubeflow Pipelines. This is the orchestrator of our entire pipeline.
  • Trigger: A push to a specific Git branch (e.g., main, release) or a scheduled event.
  • Stages:
    1. Code Linting & Testing: Runs linters (Black, Flake8) and unit/integration tests for our src/ code.
    2. Data Validation: Checks for data schema, range, and completeness (e.g., Great Expectations).
    3. Data Preprocessing: Executes our DataProcessor to transform raw data.
    4. Model Training: Runs our ModelTrainer, logging results to the experiment tracking system.
    5. Model Evaluation & Validation: Compares the newly trained model against a baseline or predefined thresholds.
    6. Model Registration: If the model passes validation, it’s registered in a Model Registry (e.g., MLflow Model Registry) with a version.
    7. Model Deployment: Deploys the registered model to a serving environment.

4. Model Serving Layer:

  • Containerization: Docker to package the model and its inference code into a portable image.
  • API Gateway/Load Balancer: Manages incoming requests to the model endpoint.
  • Inference Service: A web framework (FastAPI, Flask) or a dedicated inference server (NVIDIA Triton Inference Server) hosting the model.
  • Orchestration: Kubernetes, AWS ECS/EKS, Azure AKS, Google GKE for scaling and managing containers.
  • Batch Inference (Alternative): For non-realtime predictions, scheduled jobs that process data in batches and store predictions.

5. Monitoring & Feedback Layer:

  • Metrics Collection: Prometheus, Grafana for infrastructure and model performance metrics (latency, error rates, resource usage).
  • Model Monitoring: Dedicated tools or custom solutions to detect data drift, concept drift, and model decay.
  • Alerting: PagerDuty, Slack for notifying engineers of anomalies.
  • Logging: Centralized logging (ELK stack, Splunk, CloudWatch Logs) for inference requests and responses.
  • Feedback Loop: Mechanisms to capture actual outcomes (e.g., did the customer actually churn?) and feed them back for model retraining and improvement.

This architecture is a continuous loop. Changes in code or data trigger a new run through the CI/CD pipeline, leading to a potentially new model deployment, which is then continuously monitored to inform future iterations.

MLOps Pipeline: From Notebook to Production Reality
Generated Image

Phase 4: Building the Pipeline – Step-by-Step Implementation

Let’s flesh out the implementation of this architecture using practical tools.

A. Data Management & Versioning with DVC

Data is the lifeblood of ML. Just as we version code, we must version data and models. DVC (Data Version Control) helps us achieve this by working alongside Git.


# Initialize DVC in your project
dvc init

# Configure a remote storage for DVC (e.g., S3)
# Replace with your S3 bucket or other cloud storage
dvc remote add -d s3_remote s3://your-mlops-bucket/dvc_cache

# Add your data file to DVC
dvc add data/churn_data.csv

# This creates data/churn_data.csv.dvc (a small file tracked by Git)
# and moves churn_data.csv into the DVC cache.
# Now, commit both to Git:
git add data/churn_data.csv.dvc .dvcignore .dvc/config
git commit -m "Add churn data to DVC"

# Push DVC cache to remote storage
dvc push

# To pull data on another machine:
# dvc pull

DVC can also define ML pipelines. Let’s create a dvc.yaml that defines the sequence of steps for data preprocessing, training, and evaluation.


# dvc.yaml
stages:
  preprocess:
    cmd: python src/pipeline_steps.py preprocess config/model_config.yaml
    deps:
      - data/churn_data.csv
      - src/data_processor.py
      - config/model_config.yaml
    outs:
      - data/processed_churn_data.pkl
    params:
      - test_size
      - random_state
  train:
    cmd: python src/pipeline_steps.py train config/model_config.yaml
    deps:
      - data/processed_churn_data.pkl
      - src/model_trainer.py
      - config/model_config.yaml
    outs:
      - models/churn_model.pkl
      - models/scaler.pkl
    params:
      - model_params.solver
      - model_params.random_state
    metrics:
      - metrics.json:
          cache: false # We'll track metrics with MLflow, but DVC can also store them

And the corresponding src/pipeline_steps.py to execute these stages:


# src/pipeline_steps.py
import pandas as pd
import yaml
import logging
import sys
import json
from sklearn.model_selection import train_test_split
from src.data_processor import DataProcessor
from src.model_trainer import ModelTrainer

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

def load_config(config_path):
    with open(config_path, 'r') as f:
        return yaml.safe_load(f)

def preprocess_step(config_path):
    config = load_config(config_path)
    data_processor = DataProcessor()
    df = data_processor.load_data(config['data_path'])
    processed_df = data_processor.preprocess(df, fit_scaler=True)
    
    # Save processed data and scaler for next steps
    processed_df.to_pickle("data/processed_churn_data.pkl")
    data_processor.save_scaler(config['scaler_save_path'])
    logger.info("Preprocessing step complete.")

def train_step(config_path):
    config = load_config(config_path)
    processed_df = pd.read_pickle("data/processed_churn_data.pkl")
    
    X = processed_df.drop('Churn', axis=1)
    y = processed_df['Churn']

    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=config['test_size'], random_state=config['random_state'], stratify=y
    )

    model_trainer = ModelTrainer(config['model_params'])
    model_trainer.train(X_train, y_train)
    metrics = model_trainer.evaluate(X_test, y_test)
    
    model_trainer.save_model(config['model_save_path'])

    # Save metrics to a file for DVC or other tools
    with open("metrics.json", "w") as f:
        json.dump(metrics, f)

    logger.info("Training step complete.")

if __name__ == "__main__":
    if len(sys.argv) < 3:
        logger.error("Usage: python src/pipeline_steps.py  ")
        sys.exit(1)
    
    step_name = sys.argv[1]
    config_file = sys.argv[2]

    if step_name == "preprocess":
        preprocess_step(config_file)
    elif step_name == "train":
        train_step(config_file)
    else:
        logger.error(f"Unknown step: {step_name}")
        sys.exit(1)

Now, running dvc repro will execute the pipeline, ensuring reproducibility and tracking all dependencies.

B. Experiment Tracking & Model Management with MLflow

MLflow provides tools for tracking experiments, packaging ML code, and managing models. We’ll integrate it

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

RLHF Explained: Aligning AI with Human Preferences

Jul 24, 2026 · 20 min read

Vector Databases Under the Hood: ChromaDB, Pinecone, Qdrant

May 26, 2026 · 17 min read

Knowledge Graphs: Enhancing LLM Reasoning with Structured Data

Jun 18, 2026 · 16 min read