0%

Building Production-Ready ML Pipelines: A Complete Guide

Technical article

Apr 15, 2026 5 min read Vaibhav

Building Production-Ready ML Pipelines: A Complete Guide

Moving from a Jupyter notebook to production is one of the biggest challenges in machine learning. Here’s how I build ML pipelines that are robust, scalable, and maintainable.

The Problem with Notebook-First Development

Jupyter notebooks are great for exploration, but they have limitations:

  • Hard to version control
  • Difficult to test
  • Not reproducible
  • Challenging to deploy

Architecture Overview

Here’s the production ML pipeline architecture I use:

Data Ingestion → Feature Engineering → Model Training → 
Validation → Deployment → Monitoring

1. Data Ingestion Pipeline

from dataclasses import dataclass
from pathlib import Path
import pandas as pd
from typing import Optional

@dataclass
class DataConfig:
    source_path: Path
    output_path: Path
    sample_size: Optional[int] = None

class DataIngestion:
    def __init__(self, config: DataConfig):
        self.config = config
    
    def fetch_data(self) -> pd.DataFrame:
        """Fetch data from source with error handling"""
        try:
            df = pd.read_csv(self.config.source_path)
            
            if self.config.sample_size:
                df = df.sample(n=self.config.sample_size, random_state=42)
            
            self._validate_data(df)
            return df
        except Exception as e:
            raise DataIngestionError(f"Failed to fetch data: {e}")
    
    def _validate_data(self, df: pd.DataFrame):
        """Validate data quality"""
        assert not df.empty, "DataFrame is empty"
        assert df.isnull().sum().sum() < len(df) * 0.5, "Too many null values"

2. Feature Engineering with Scikit-learn Pipelines

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer

def create_preprocessing_pipeline(numeric_features, categorical_features):
    """Create reusable preprocessing pipeline"""
    
    numeric_transformer = Pipeline(steps=[
        ('imputer', SimpleImputer(strategy='median')),
        ('scaler', StandardScaler())
    ])
    
    categorical_transformer = Pipeline(steps=[
        ('imputer', SimpleImputer(strategy='constant', fill_value='missing')),
        ('onehot', OneHotEncoder(handle_unknown='ignore'))
    ])
    
    preprocessor = ColumnTransformer(
        transformers=[
            ('num', numeric_transformer, numeric_features),
            ('cat', categorical_transformer, categorical_features)
        ])
    
    return preprocessor

3. Model Training with MLflow Tracking

import mlflow
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score

def train_model(X_train, y_train, params: dict):
    """Train model with experiment tracking"""
    
    with mlflow.start_run():
        # Log parameters
        mlflow.log_params(params)
        
        # Create and train model
        model = RandomForestClassifier(**params)
        model.fit(X_train, y_train)
        
        # Cross-validation
        cv_scores = cross_val_score(model, X_train, y_train, cv=5)
        
        # Log metrics
        mlflow.log_metric("cv_score_mean", cv_scores.mean())
        mlflow.log_metric("cv_score_std", cv_scores.std())
        
        # Log model
        mlflow.sklearn.log_model(model, "model")
        
        return model

4. Model Validation

from sklearn.metrics import classification_report, confusion_matrix
import json

class ModelValidator:
    def __init__(self, model, X_test, y_test):
        self.model = model
        self.X_test = X_test
        self.y_test = y_test
    
    def validate(self, threshold: float = 0.8) -> bool:
        """Validate model meets minimum requirements"""
        
        y_pred = self.model.predict(self.X_test)
        
        # Get metrics
        report = classification_report(self.y_test, y_pred, output_dict=True)
        accuracy = report['accuracy']
        
        # Log validation results
        self._log_results(report)
        
        # Check if meets threshold
        if accuracy < threshold:
            raise ValueError(f"Model accuracy {accuracy} below threshold {threshold}")
        
        return True
    
    def _log_results(self, report: dict):
        """Log validation results"""
        with open('validation_report.json', 'w') as f:
            json.dump(report, f, indent=2)

5. Configuration Management

Use Hydra or Pydantic for configuration:

from pydantic import BaseModel

class TrainingConfig(BaseModel):
    n_estimators: int = 100
    max_depth: int = 10
    random_state: int = 42
    test_size: float = 0.2

class MLConfig(BaseModel):
    data: DataConfig
    training: TrainingConfig
    model_path: str = "models/production"

# Load from YAML
config = MLConfig.parse_file('config.yaml')

6. Deployment with FastAPI

from fastapi import FastAPI
from pydantic import BaseModel
import joblib
import numpy as np

app = FastAPI()

# Load model at startup
model = joblib.load('model.pkl')
preprocessor = joblib.load('preprocessor.pkl')

class PredictionRequest(BaseModel):
    features: dict

class PredictionResponse(BaseModel):
    prediction: int
    probability: float

@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest):
    # Preprocess
    features_df = pd.DataFrame([request.features])
    features_processed = preprocessor.transform(features_df)
    
    # Predict
    prediction = model.predict(features_processed)[0]
    probability = model.predict_proba(features_processed)[0].max()
    
    return PredictionResponse(
        prediction=int(prediction),
        probability=float(probability)
    )

7. Monitoring

from prometheus_client import Counter, Histogram
import time

# Metrics
prediction_counter = Counter('predictions_total', 'Total predictions made')
prediction_duration = Histogram('prediction_duration_seconds', 'Prediction duration')

@app.post("/predict")
async def predict_with_monitoring(request: PredictionRequest):
    start_time = time.time()
    
    try:
        result = await predict(request)
        prediction_counter.inc()
        return result
    finally:
        prediction_duration.observe(time.time() - start_time)

8. Testing Strategy

import pytest
from sklearn.datasets import make_classification

@pytest.fixture
def sample_data():
    X, y = make_classification(n_samples=100, random_state=42)
    return X, y

def test_preprocessing_pipeline(sample_data):
    X, _ = sample_data
    pipeline = create_preprocessing_pipeline(
        numeric_features=list(range(X.shape[1])),
        categorical_features=[]
    )
    X_transformed = pipeline.fit_transform(X)
    assert X_transformed.shape[0] == X.shape[0]

def test_model_prediction(sample_data, trained_model):
    X, _ = sample_data
    predictions = trained_model.predict(X)
    assert len(predictions) == len(X)
    assert all(p in [0, 1] for p in predictions)

Complete Pipeline Example

class MLPipeline:
    def __init__(self, config: MLConfig):
        self.config = config
        
    def run(self):
        # 1. Data Ingestion
        data_ingestion = DataIngestion(self.config.data)
        df = data_ingestion.fetch_data()
        
        # 2. Train-test split
        X = df.drop('target', axis=1)
        y = df['target']
        X_train, X_test, y_train, y_test = train_test_split(
            X, y, test_size=self.config.training.test_size
        )
        
        # 3. Preprocessing
        preprocessor = create_preprocessing_pipeline(
            numeric_features=X.select_dtypes(include=[np.number]).columns,
            categorical_features=X.select_dtypes(include=['object']).columns
        )
        X_train_processed = preprocessor.fit_transform(X_train)
        X_test_processed = preprocessor.transform(X_test)
        
        # 4. Training
        model = train_model(
            X_train_processed, 
            y_train, 
            self.config.training.dict()
        )
        
        # 5. Validation
        validator = ModelValidator(model, X_test_processed, y_test)
        validator.validate()
        
        # 6. Save artifacts
        joblib.dump(model, f"{self.config.model_path}/model.pkl")
        joblib.dump(preprocessor, f"{self.config.model_path}/preprocessor.pkl")
        
        return model

if __name__ == "__main__":
    config = MLConfig.parse_file('config.yaml')
    pipeline = MLPipeline(config)
    pipeline.run()

Best Practices Summary

  1. Separate concerns: Data, preprocessing, training, deployment
  2. Use configuration files: Don’t hardcode parameters
  3. Implement logging: Track everything
  4. Write tests: Unit, integration, and ML-specific tests
  5. Version everything: Data, code, models
  6. Monitor in production: Track performance degradation
  7. Automate CI/CD: Use GitHub Actions or similar

Conclusion

Building production ML systems requires discipline and proper engineering practices. This pipeline architecture has served me well across multiple projects, from small MVPs to large-scale production systems.

Key takeaway: Treat ML code like software engineering, not just experimentation.


Tags: #MachineLearning #MLOps #Production #Python #BestPractices

Topics

Machine Learning MLOps Python Production

Share this article

Related Articles