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
- Separate concerns: Data, preprocessing, training, deployment
- Use configuration files: Don’t hardcode parameters
- Implement logging: Track everything
- Write tests: Unit, integration, and ML-specific tests
- Version everything: Data, code, models
- Monitor in production: Track performance degradation
- 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