Pavan Rangani

HomeBlogMLOps Pipeline: From Jupyter Notebook to Production Model Serving

MLOps Pipeline: From Jupyter Notebook to Production Model Serving

By Pavan Rangani · February 12, 2026 · AI & ML

MLOps Pipeline: From Jupyter Notebook to Production Model Serving

MLOps Pipeline: From Jupyter Notebooks to Production ML Systems

Data scientists build brilliant models in Jupyter notebooks, but those notebooks can’t serve 10,000 predictions per second, retrain automatically when data drifts, or roll back when a model performs poorly. MLOps bridges this gap — it’s the engineering discipline that turns experimental notebooks into reliable production systems. Therefore, this guide covers the practical tools and patterns for building ML pipelines that train, validate, deploy, and monitor models at scale.

The Notebook-to-Production Gap

Jupyter notebooks are excellent for exploration: you can visualize data, experiment with features, and iterate on model architectures interactively. However, notebooks hide critical problems. They execute cells out of order, creating hidden state dependencies. They hardcode file paths, dataset versions, and hyperparameters. Moreover, they mix data exploration, preprocessing, training, and evaluation in a single file that can’t be unit tested or code reviewed effectively. As a result, a notebook that “works on my machine” frequently fails the moment it leaves the data scientist’s laptop.

The first step toward production is refactoring notebook code into Python modules. Specifically, extract data loading into a dataset module, feature engineering into a feature pipeline, model definition into a model module, and training into a training script. Additionally, parameterize everything — dataset paths, hyperparameters, output directories — so the same code runs locally and in your CI/CD pipeline without edits.

# Refactored from notebook: structured ML pipeline
# src/train.py — parameterized training script
import argparse
import mlflow
from src.data import load_dataset, split_data
from src.features import build_feature_pipeline
from src.model import create_model, evaluate_model

def train(config):
    mlflow.set_experiment(config.experiment_name)

    with mlflow.start_run():
        # Log parameters for reproducibility
        mlflow.log_params({
            "learning_rate": config.lr,
            "batch_size": config.batch_size,
            "epochs": config.epochs,
            "model_type": config.model_type,
            "dataset_version": config.dataset_version,
        })

        # Reproducible data loading
        df = load_dataset(config.dataset_path, version=config.dataset_version)
        train_df, val_df, test_df = split_data(df, seed=42)

        # Feature engineering pipeline (fitted on train, applied to all)
        feature_pipeline = build_feature_pipeline(config)
        X_train = feature_pipeline.fit_transform(train_df)
        X_val = feature_pipeline.transform(val_df)
        X_test = feature_pipeline.transform(test_df)

        # Train model
        model = create_model(config)
        model.fit(X_train, train_df['target'],
                  validation_data=(X_val, val_df['target']))

        # Evaluate and log metrics
        metrics = evaluate_model(model, X_test, test_df['target'])
        mlflow.log_metrics(metrics)

        # Log model artifact
        mlflow.sklearn.log_model(model, "model",
                                  registered_model_name=config.model_name)

        # Log feature pipeline for serving
        mlflow.sklearn.log_model(feature_pipeline, "feature_pipeline")

        print(f"Metrics: {metrics}")

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--lr", type=float, default=0.001)
    parser.add_argument("--batch-size", type=int, default=32)
    parser.add_argument("--epochs", type=int, default=50)
    parser.add_argument("--model-type", default="gradient_boosting")
    parser.add_argument("--dataset-version", default="v3")
    parser.add_argument("--dataset-path", default="s3://ml-data/training/")
    parser.add_argument("--experiment-name", default="churn-prediction")
    parser.add_argument("--model-name", default="churn-model")
    train(parser.parse_args())

Notice that the feature pipeline is fitted only on the training split and then merely transformed on validation and test data. This single discipline prevents one of the most common silent bugs in ML: data leakage, where statistics from the test set bleed into preprocessing and inflate offline metrics that then collapse in production.

MLOps pipeline from Jupyter notebook to production
Refactoring notebooks into modules with MLflow tracking creates reproducible, auditable training pipelines

Model Versioning and Experiment Tracking

Every training run should be tracked: hyperparameters, dataset version, code commit, metrics, and the model artifact itself. MLflow, Weights & Biases, and Neptune provide experiment tracking that makes it trivial to compare runs and reproduce results. Furthermore, model registries manage the lifecycle from “experimental” to “staging” to “production” with explicit approval workflows, so a model only ships after a human or an automated gate promotes it.

Version your datasets alongside your models. A model trained on dataset v3 might perform differently on dataset v4 due to schema changes or data quality issues. Tools like DVC (Data Version Control) track large datasets in Git without storing them in the repository — the data lives in S3 or GCS while Git tracks only the version pointer. Consequently, you can reproduce any training run by checking out the corresponding code and data versions together.

# DVC: version control for ML datasets
dvc init
dvc remote add -d s3storage s3://ml-data-versions/

# Track a dataset
dvc add data/training/churn_dataset_v3.parquet
git add data/training/churn_dataset_v3.parquet.dvc .gitignore
git commit -m "Track churn dataset v3"
dvc push

# Reproduce a previous experiment
git checkout v1.2.0  # Checkout code version
dvc checkout          # Restore matching dataset version
python src/train.py   # Exact reproduction

Feature Stores: Consistent Features Across Training and Serving

The most insidious bug in this discipline is training-serving skew — when features are computed differently during training versus real-time inference. A feature store solves this by providing a single source of truth for feature computation. Specifically, features are defined once, computed in batch for training and in real-time for serving, and the store guarantees consistency between the two paths.

Feast is the most widely adopted open-source feature store. It integrates with offline stores (BigQuery, Redshift, Snowflake) for training and online stores (Redis, DynamoDB) for low-latency serving. For example, a fraud detection model needs features like “average transaction amount in the last 24 hours” — the feature store computes this identically whether you’re training on historical data or scoring a live transaction.

# Feast feature store definition
from feast import Entity, Feature, FeatureView, FileSource, ValueType
from datetime import timedelta

# Entity: the business object features describe
customer = Entity(
    name="customer_id",
    value_type=ValueType.STRING,
    description="Unique customer identifier",
)

# Feature view: a group of related features
customer_features = FeatureView(
    name="customer_features",
    entities=[customer],
    ttl=timedelta(days=1),
    features=[
        Feature(name="avg_transaction_30d", dtype=ValueType.FLOAT),
        Feature(name="transaction_count_7d", dtype=ValueType.INT64),
        Feature(name="days_since_last_purchase", dtype=ValueType.INT64),
        Feature(name="lifetime_value", dtype=ValueType.FLOAT),
    ],
    online=True,
    source=FileSource(
        path="s3://ml-features/customer_features.parquet",
        timestamp_field="event_timestamp",
    ),
)

# Training: get historical features
training_df = store.get_historical_features(
    entity_df=training_entities,
    features=["customer_features:avg_transaction_30d",
              "customer_features:transaction_count_7d"],
).to_df()

# Serving: get real-time features
online_features = store.get_online_features(
    features=["customer_features:avg_transaction_30d"],
    entity_rows=[{"customer_id": "C12345"}],
).to_dict()

A subtle but essential capability here is point-in-time correctness. When Feast joins historical features for training, it retrieves the feature value as it existed at each event’s timestamp rather than the latest value. Otherwise, you would train on future information that won’t exist at inference time — another form of leakage that makes offline metrics look excellent and production performance disappointing.

Feature store architecture for MLOps pipeline
Feature stores eliminate training-serving skew by providing a single source of truth for feature computation

Serving Models: Batch, Real-Time, and Streaming

Not every model needs a low-latency endpoint, and choosing the wrong serving pattern wastes both money and engineering effort. Batch serving scores a large set of records on a schedule and writes predictions to a table — ideal for daily churn scores or nightly recommendation refreshes. Real-time serving, by contrast, exposes a synchronous endpoint that returns a prediction within milliseconds, which fraud and personalization use cases demand. Streaming serving sits between the two, consuming events from Kafka or Kinesis and emitting predictions continuously.

# Real-time serving: load logged model + feature pipeline from MLflow
import mlflow.pyfunc
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
model = mlflow.pyfunc.load_model("models:/churn-model/Production")
feature_pipeline = mlflow.sklearn.load_model(
    "models:/churn-model-features/Production")

class PredictRequest(BaseModel):
    customer_id: str

@app.post("/predict")
def predict(req: PredictRequest):
    # Pull fresh online features so train/serve features match
    features = store.get_online_features(
        features=["customer_features:avg_transaction_30d",
                  "customer_features:transaction_count_7d"],
        entity_rows=[{"customer_id": req.customer_id}],
    ).to_df()

    X = feature_pipeline.transform(features)
    score = float(model.predict(X)[0])
    return {"customer_id": req.customer_id, "churn_probability": score}

Loading both the model and its feature pipeline from the same registry version is what guarantees that the transformation applied at serving time matches the one used during training. A frequent production incident is deploying a new model while forgetting to deploy the matching preprocessing artifact — the endpoint runs without error but returns quietly wrong predictions.

Model Monitoring and Drift Detection

A model that was 95% accurate at deployment can degrade to 70% within weeks if the input data distribution changes. Data drift detection compares the statistical properties of incoming data against the training distribution. When features drift beyond a threshold, trigger an alert or automatic retraining. Additionally, monitor the prediction distribution itself — if your fraud model suddenly flags 40% of transactions instead of 2%, something changed upstream even before you have ground-truth labels.

Concept drift is harder to detect because the relationship between features and the target changes without the features themselves changing. For example, during a pandemic, customer purchase patterns changed dramatically while demographic features stayed the same. The only reliable way to detect concept drift is monitoring actual outcomes against predictions. As a result, set up feedback loops that compare predictions with ground truth labels as they become available.

# Model monitoring: detect data drift with Evidently
from evidently.report import Report
from evidently.metric_preset import DataDriftPreset, TargetDriftPreset

def check_drift(reference_data, current_data, threshold=0.1):
    report = Report(metrics=[
        DataDriftPreset(),
        TargetDriftPreset(),
    ])

    report.run(reference_data=reference_data,
               current_data=current_data)

    results = report.as_dict()
    drift_score = results['metrics'][0]['result']['dataset_drift_score']

    if drift_score > threshold:
        trigger_retraining_pipeline()
        alert_ml_team(f"Data drift detected: {drift_score:.3f}")

    return drift_score

One practical caution: drift alerts are noisy by nature. A statistically significant distribution shift is not always a meaningful one, so teams typically pair a drift signal with a business-metric guardrail before triggering an expensive retrain. Otherwise the pipeline retrains on every seasonal fluctuation and the alerts lose their meaning.

CI/CD for Machine Learning

ML CI/CD extends traditional software pipelines with model-specific stages: data validation, training, evaluation against baseline metrics, A/B testing, and gradual rollout. A model doesn’t get deployed just because the code passes tests — it must beat the current production model on held-out evaluation data. Furthermore, canary deployments route a small percentage of traffic to the new model while monitoring prediction quality, so a regression is caught on 5% of users rather than 100%.

Tools like Kubeflow Pipelines, MLflow Pipelines, and Vertex AI Pipelines orchestrate these stages. Each pipeline run is a complete, reproducible experiment with tracked inputs, outputs, and metrics. However, start simple — a shell script that trains, evaluates, and deploys is better than no automation, even if it’s not a fancy DAG. Teams routinely over-invest in orchestration before they have a single model in production, which is exactly backwards.

ML model monitoring and drift detection dashboard
Monitoring prediction distributions and comparing against ground truth catches model degradation early

When NOT to Build a Full Pipeline: Trade-offs

Sophisticated ML tooling is not free, and applying it prematurely is a common failure mode. If you have a single model that retrains monthly and serves a handful of internal users, a feature store, a streaming pipeline, and automated canary rollouts are overhead you will pay for without benefit. In that situation, a tracked training script plus a simple scheduled batch job delivers nearly all the value at a fraction of the operational cost.

Weigh the trade-offs honestly. First, every component you add — feature store, registry, drift monitor, orchestrator — is another system to operate, secure, and debug at 3 a.m. Second, the tooling has a learning curve, and a small team can spend months wiring infrastructure instead of improving the model that actually drives the metric. Third, premature standardization can lock you into abstractions that fight your problem rather than fit it. A reasonable progression is to add each capability only when a real pain point demands it: add experiment tracking when you can’t reproduce a result, add a feature store when training-serving skew bites you, and add drift detection when a model silently degrades in production. Match the sophistication of the pipeline to the stakes and scale of the model — not to the latest conference talk.

Related Reading:

Resources:

In conclusion, building an MLOps pipeline transforms experimental notebooks into production-grade ML systems. Start by refactoring notebooks into parameterized scripts with experiment tracking, add dataset versioning for reproducibility, implement feature stores to prevent training-serving skew, and deploy drift detection to catch model degradation. The goal isn’t to build the most sophisticated pipeline — it’s to build one that reliably delivers value, matches the stakes of the model, and catches failures before they reach users.

← Back to all articles