CI/CD Pipelines for ML: Automating the MLOps Lifecycle
In the first article of this series, we explored why 85% of ML projects never reach production and how MLOps solves this problem. We transformed a monolithic notebook into a modular, configurable pipeline. Now it is time to take the next step: automating the entire pipeline with CI/CD, so that every change to code, data, or configuration automatically triggers training, validation, and model deployment.
In this article, we will build a complete CI/CD pipeline for machine learning using GitHub Actions as the orchestrator and Docker as the execution runtime. We will go beyond theory and build a fully functional project with a sentiment classifier, complete with multi-stage Dockerfiles, YAML workflows, data validation, model registry integration, and automated deployment.
What You Will Learn
- Why CI/CD for ML differs fundamentally from traditional software CI/CD
- How to design the architecture of an end-to-end ML pipeline
- How to write optimized multi-stage Dockerfiles for training and serving
- How to write complete GitHub Actions workflows for ML
- How to integrate data validation, model registry, and automated deployment
- How to manage data versioning with DVC inside the pipeline
- How to implement ML-specific testing (unit, integration, smoke)
- How to monitor the model after deployment
- How to reduce costs with caching and self-hosted runners
- How to choose the right CI/CD tool for your team
Why CI/CD for ML Is Different
If you come from traditional software development, you might assume that standard CI/CD practices translate directly to ML projects. In reality, machine learning introduces unique complexities that require a purpose-built approach. The fundamental difference is that traditional CI/CD manages a single artifact (source code), while ML CI/CD must manage three simultaneously: code, data, and model.
The Three ML Artifacts
In traditional software, if the code does not change, the output does not change. In ML, even with identical code, a change in the data produces a different model. This means the CI/CD pipeline must track and validate three independent dimensions.
| Dimension | Traditional CI/CD | ML CI/CD |
|---|---|---|
| Code | Git push triggers build + test | Git push triggers training + evaluation |
| Data | Not applicable | New data triggers retraining |
| Model | Not applicable | New model requires validation + promotion |
| Configuration | Feature flags, env vars | Hyperparameters, feature sets, metric thresholds |
| Environment | OS + libraries | OS + libraries + GPU drivers + CUDA version |
| Validation | Tests pass or fail | Metrics above/below threshold + comparison with production model |
| Deployment | Deploy or rollback | Gradual rollout + A/B test + drift monitoring |
Continuous Training: The Key Concept
CI/CD for ML introduces a concept absent from traditional software engineering: Continuous Training (CT). Beyond Continuous Integration and Continuous Deployment, CT ensures the model is automatically retrained whenever:
- New data arrives: the dataset is updated with new observations
- Code changes: preprocessing logic or the algorithm is modified
- Metrics degrade: monitoring detects data drift or performance drop
- A timer fires: scheduled retraining (e.g., weekly) is triggered
Common Mistake: CI/CD Without CT
Many teams implement CI/CD for ML code but forget Continuous Training. The result is a model that gets deployed once and then never updated, silently degrading over time as production data diverges from training data. A pipeline without CT is like a car without maintenance: it works until it breaks down.
ML Pipeline Architecture
Before writing code, let us design the complete architecture. Each phase has specific inputs and outputs, and failure in one phase blocks all downstream phases. This "fail fast" approach ensures that only validated models reach production.
+------------------+ +------------------+ +------------------+
| DATA INGESTION |---->| PREPROCESSING |---->| TRAINING |
| | | | | |
| - Pull DVC data | | - Data cleaning | | - Train model |
| - Validation | | - Feature eng. | | - Log metrics |
| - Schema check | | - Train/val/test | | - Log params |
| | | split | | - Save artifacts |
+------------------+ +------------------+ +------------------+
| |
| (trigger: new data |
| or schedule) v
| +------------------+
| | EVALUATION |
| | |
| | - Metrics |
| | - Compare with |
| | production |
| | - Gate: thresholds|
| +------------------+
| |
| (if metrics > threshold)
| v
+------------------+ +------------------+ +------------------+
| MONITORING |<----| SMOKE TEST |<----| DEPLOYMENT |
| | | | | |
| - Health check | | - Test endpoint | | - Push registry |
| - Drift detect. | | - Sample predict | | - Stage/Prod |
| - Alert | | - Latency check | | - Rollback ready |
| - Trigger retrain| | | | |
+------------------+ +------------------+ +------------------+
Each block corresponds to a step in the GitHub Actions workflow. Let us now look at how to implement each phase, starting with containerization using Docker.
Docker for Machine Learning
Docker solves one of the most frustrating problems in ML: "it works on my machine". By containerizing the training and serving environments, we guarantee that code produces identical results wherever it runs: on the data scientist's laptop, in the CI/CD runner, and in production. For ML, Docker requires special attention: images tend to be very large (scientific libraries + GPU drivers) and builds can be slow.
Base Images for ML
Choosing the right base image is critical for both size and compatibility. Here are the main options and when to use each.
| Base Image | Size | Use | When to Choose |
|---|---|---|---|
| python:3.11-slim | ~120 MB | CPU Training/Serving | scikit-learn, XGBoost, lightweight serving |
| python:3.11-bookworm | ~900 MB | Training with build tools | Dependencies that require C/C++ compilation |
| nvidia/cuda:12.1-runtime | ~3.5 GB | GPU Inference | Deep learning model serving |
| nvidia/cuda:12.1-devel | ~5.2 GB | GPU Training | PyTorch/TensorFlow training with CUDA |
| pytorch/pytorch:2.1.0-cuda12.1 | ~6 GB | PyTorch Training/Serving | PyTorch projects that want to avoid manual CUDA setup |
Multi-Stage Dockerfile for Training and Serving
The multi-stage pattern is fundamental for ML. Using two separate stages, we can have a full build environment (with compilers and build tools) and a lean final image that contains only the runtime needed. This reduces the final image size by up to 60%.
# ============================================
# Stage 1: Builder - install dependencies
# ============================================
FROM python:3.11-slim AS builder
WORKDIR /build
# Install build tools required for native dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Copy and install dependencies into a virtual environment
COPY requirements.txt .
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# ============================================
# Stage 2: Training - runs the training job
# ============================================
FROM python:3.11-slim AS trainer
WORKDIR /app
# Copy virtual environment from builder stage
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy source code
COPY src/ ./src/
COPY config/ ./config/
COPY train.py .
COPY evaluate.py .
ENTRYPOINT ["python", "train.py"]
# ============================================
# Stage 3: Serving - production API
# ============================================
FROM python:3.11-slim AS serving
WORKDIR /app
# Non-root user for security
RUN useradd --create-home appuser
# Copy virtual environment from builder stage
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy only the code needed for serving
COPY src/serving/ ./src/serving/
COPY src/preprocessing/ ./src/preprocessing/
# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
USER appuser
EXPOSE 8000
ENTRYPOINT ["uvicorn", "src.serving.app:app", "--host", "0.0.0.0", "--port", "8000"]
Why Multi-Stage for ML?
- Security: the serving image contains no compilers or build tools
- Size: the serving stage is much lighter (~300 MB vs ~1.2 GB)
- Cache: dependencies change less often than code, leveraging layer cache
- Flexibility: you can build only the training stage or only the serving stage
Layer Cache Optimization
The order of COPY instructions in the Dockerfile is critical for caching. Python
dependencies change rarely; source code changes often. By copying
requirements.txt first and then the code, we avoid reinstalling
dependencies on every code change.
# Data and models (managed by DVC, not Docker)
data/
models/
*.pkl
*.h5
*.pt
# Development environment
.venv/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
# Git and CI
.git/
.github/
.dvc/cache/
# IDE and editor
.vscode/
.idea/
*.swp
# Documentation
docs/
*.md
LICENSE
Docker with GPU Support
For deep learning model training, you need GPU support inside the container. Docker supports NVIDIA GPUs through the NVIDIA Container Toolkit. The configuration requires NVIDIA drivers on the host and the toolkit installed.
# Base image with CUDA runtime
FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04 AS gpu-trainer
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 \
python3.11-venv \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN python3.11 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# PyTorch with CUDA dependencies
COPY requirements-gpu.txt .
RUN pip install --no-cache-dir -r requirements-gpu.txt
COPY src/ ./src/
COPY train.py .
# CUDA environment variables
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
ENTRYPOINT ["python", "train.py"]
# Build GPU image
docker build -f Dockerfile.gpu -t ml-trainer:gpu .
# Run with GPU access
docker run --gpus all \
-v $(pwd)/data:/app/data \
-v $(pwd)/models:/app/models \
ml-trainer:gpu \
--config config/training.yaml
GitHub Actions for Machine Learning
GitHub Actions is a CI/CD service integrated into GitHub that executes automated workflows in response to events (push, pull request, schedule, manual dispatch). For ML, it offers significant advantages: native integration with the Git repository, a marketplace with pre-built actions, secret management for credentials, and up to 2,000 minutes/month free for public repositories.
ML Workflow Structure
A GitHub Actions workflow for ML has a specific structure: multiple jobs corresponding to pipeline phases, with explicit dependencies between jobs and execution conditions based on model metrics.
name: ML Pipeline - Train, Evaluate, Deploy
on:
# Trigger on push to main (code or config changes)
push:
branches: [main]
paths:
- 'src/**'
- 'config/**'
- 'requirements.txt'
- 'train.py'
- 'evaluate.py'
# Scheduled trigger for periodic retraining
schedule:
- cron: '0 6 * * 1' # Every Monday at 06:00 UTC
# Manual trigger with parameters
workflow_dispatch:
inputs:
force_deploy:
description: 'Force deployment even if metrics do not improve'
required: false
default: 'false'
type: choice
options:
- 'true'
- 'false'
training_config:
description: 'Training configuration file'
required: false
default: 'config/training.yaml'
env:
PYTHON_VERSION: '3.11'
DOCKER_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/ml-model
MLFLOW_TRACKING_URI: ${{ secrets.MLFLOW_TRACKING_URI }}
jobs:
# ============================================
# Job 1: Data Validation
# ============================================
data-validation:
name: Validate Data Quality
runs-on: ubuntu-latest
outputs:
data_valid: ${{ steps.validate.outputs.valid }}
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Setup DVC
uses: iterative/setup-dvc@v2
- name: Pull data from DVC
run: dvc pull
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Validate data quality
id: validate
run: |
python -m src.data.validate_data \
--data-path data/raw/reviews.csv \
--schema-path config/data_schema.yaml
echo "valid=true" >> $GITHUB_OUTPUT
# ============================================
# Job 2: Model Training
# ============================================
training:
name: Train Model
needs: data-validation
if: needs.data-validation.outputs.data_valid == 'true'
runs-on: ubuntu-latest
outputs:
model_version: ${{ steps.train.outputs.model_version }}
run_id: ${{ steps.train.outputs.run_id }}
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Setup DVC and pull data
run: |
pip install dvc[s3]
dvc pull
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Train model
id: train
run: |
python train.py \
--config ${{ github.event.inputs.training_config || 'config/training.yaml' }} \
--output-dir models/
echo "model_version=$(cat models/version.txt)" >> $GITHUB_OUTPUT
echo "run_id=$(cat models/run_id.txt)" >> $GITHUB_OUTPUT
env:
MLFLOW_TRACKING_URI: ${{ env.MLFLOW_TRACKING_URI }}
MLFLOW_TRACKING_USERNAME: ${{ secrets.MLFLOW_USERNAME }}
MLFLOW_TRACKING_PASSWORD: ${{ secrets.MLFLOW_PASSWORD }}
- name: Upload model artifact
uses: actions/upload-artifact@v4
with:
name: trained-model
path: models/
retention-days: 30
# ============================================
# Job 3: Model Evaluation (Gate)
# ============================================
evaluation:
name: Evaluate Model
needs: training
runs-on: ubuntu-latest
outputs:
metrics_pass: ${{ steps.evaluate.outputs.metrics_pass }}
accuracy: ${{ steps.evaluate.outputs.accuracy }}
auc_roc: ${{ steps.evaluate.outputs.auc_roc }}
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Download trained model
uses: actions/download-artifact@v4
with:
name: trained-model
path: models/
- name: Run evaluation and metric gate
id: evaluate
run: |
python evaluate.py \
--model-path models/ \
--data-path data/processed/test.csv \
--output-path evaluation/metrics.json
# Parse metrics and set gate
python -c "
import json, sys
with open('evaluation/metrics.json') as f:
m = json.load(f)
passed = m['auc_roc'] >= 0.82 and m['accuracy'] >= 0.80
print(f'AUC-ROC: {m[\"auc_roc\"]:.4f} | Accuracy: {m[\"accuracy\"]:.4f}')
print(f'Gate: {\"PASS\" if passed else \"FAIL\"}')
with open('$GITHUB_OUTPUT', 'a') as out:
out.write(f'metrics_pass={str(passed).lower()}\n')
out.write(f'accuracy={m[\"accuracy\"]}\n')
out.write(f'auc_roc={m[\"auc_roc\"]}\n')
sys.exit(0 if passed else 1)
"
# ============================================
# Job 4: Build and Push Docker Image
# ============================================
build-push:
name: Build and Push Image
needs: evaluation
if: needs.evaluation.outputs.metrics_pass == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Download model artifact
uses: actions/download-artifact@v4
with:
name: trained-model
path: models/
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
target: serving
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ============================================
# Job 5: Deploy to Staging
# ============================================
deploy-staging:
name: Deploy to Staging
needs: build-push
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to staging environment
run: |
echo "Deploying ${{ needs.build-push.outputs.image_tag }} to staging"
# kubectl set image or docker service update
kubectl set image deployment/ml-model-staging \
ml-api=${{ needs.build-push.outputs.image_tag }} \
--namespace staging
kubectl rollout status deployment/ml-model-staging --namespace staging
- name: Run smoke tests on staging
run: |
python tests/smoke/test_api.py \
--base-url ${{ secrets.STAGING_URL }}
Example Project: Sentiment Classifier
Let's put everything together with a concrete project. We will build a sentiment classifier for product reviews, with a complete CI/CD pipeline. The project uses scikit-learn for simplicity, but the architecture applies identically to PyTorch or TensorFlow models.
Project Structure
sentiment-classifier/
src/
data/
__init__.py
preprocessing.py # Text cleaning and transformation
validate_data.py # Schema and quality validation
models/
__init__.py
trainer.py # Classifier training
serving/
__init__.py
app.py # FastAPI application
schemas.py # Pydantic schemas
monitoring/
__init__.py
health.py # Health checks
tests/
test_preprocessing.py # Unit test preprocessing
test_trainer.py # Unit test training
test_api.py # Integration test API
config/
training.yaml # Training configuration
thresholds.yaml # Metric thresholds
data_schema.yaml # Data validation schema
train.py # Training entrypoint
evaluate.py # Evaluation entrypoint
Dockerfile # Multi-stage build
requirements.txt # Python dependencies
.github/
workflows/
ml-pipeline.yml # CI/CD pipeline
.dvc/ # DVC configuration
dvc.yaml # DVC pipeline
dvc.lock # DVC lock file
Training Script
"""Main training script for the sentiment classifier."""
import argparse
import yaml
import mlflow
import mlflow.sklearn
from pathlib import Path
from datetime import datetime
from src.data.preprocessing import load_and_preprocess_data, split_dataset
from src.models.trainer import create_pipeline, train_model
def parse_args():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Train sentiment classifier"
)
parser.add_argument(
"--config",
type=str,
default="config/training.yaml",
help="Path to the configuration file"
)
parser.add_argument(
"--output-dir",
type=str,
default="models/",
help="Directory where the model is saved"
)
parser.add_argument(
"--experiment-name",
type=str,
default="sentiment-classifier",
help="Name of the MLflow experiment"
)
return parser.parse_args()
def main():
"""Runs the full training pipeline."""
args = parse_args()
# 1. Load configuration
with open(args.config) as f:
config = yaml.safe_load(f)
# 2. Setup MLflow
mlflow.set_experiment(args.experiment_name)
with mlflow.start_run(run_name=f"train-{datetime.now().strftime('%Y%m%d-%H%M%S')}") as run:
# 3. Log parameters
mlflow.log_params(config["model"])
mlflow.log_param("data_path", config["data"]["train_path"])
mlflow.log_param("test_size", config["data"]["test_size"])
# 4. Load and preprocess data
print("[1/5] Loading and preprocessing data...")
X, y = load_and_preprocess_data(config["data"]["train_path"])
# 5. Split dataset
print("[2/5] Train/validation split...")
X_train, X_val, y_train, y_val = split_dataset(
X, y,
test_size=config["data"]["test_size"],
random_state=config["data"]["random_state"]
)
mlflow.log_param("train_size", len(X_train))
mlflow.log_param("val_size", len(X_val))
# 6. Create preprocessing + model pipeline
print("[3/5] Creating ML pipeline...")
pipeline = create_pipeline(config["model"])
# 7. Training
print("[4/5] Training in progress...")
trained_pipeline = train_model(pipeline, X_train, y_train)
# 8. Evaluation on validation set
print("[5/5] Evaluation...")
from src.models.trainer import evaluate_model
metrics = evaluate_model(trained_pipeline, X_val, y_val)
# 9. Log metrics to MLflow
mlflow.log_metrics(metrics)
# 10. Save model
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
import joblib
model_path = output_dir / "model.pkl"
joblib.dump(trained_pipeline, model_path)
# Log model to MLflow with signature
from mlflow.models.signature import infer_signature
signature = infer_signature(X_train[:5], trained_pipeline.predict(X_train[:5]))
mlflow.sklearn.log_model(
trained_pipeline,
"model",
signature=signature,
registered_model_name="sentiment-classifier"
)
# Save version and run_id for CI/CD
(output_dir / "version.txt").write_text(run.info.run_id[:8])
(output_dir / "run_id.txt").write_text(run.info.run_id)
print(f"\nTraining completed!")
print(f" Run ID: {run.info.run_id}")
for name, value in metrics.items():
print(f" {name}: {value:.4f}")
if __name__ == "__main__":
main()
Preprocessing Module
"""Module for preprocessing text data."""
import re
import pandas as pd
from typing import Tuple
from sklearn.model_selection import train_test_split
def clean_text(text: str) -> str:
"""Cleans a single text by removing HTML, special characters and extra spaces."""
if not isinstance(text, str):
return ""
# Remove HTML tags
text = re.sub(r'<[^>]+>', '', text)
# Remove URLs
text = re.sub(r'http\S+|www\.\S+', '', text)
# Remove special characters (keep letters, numbers, spaces)
text = re.sub(r'[^a-zA-Z0-9\s]', ' ', text)
# Normalize spaces
text = re.sub(r'\s+', ' ', text).strip()
return text.lower()
def load_and_preprocess_data(data_path: str) -> Tuple[pd.Series, pd.Series]:
"""Loads and preprocesses the review dataset."""
df = pd.read_csv(data_path)
# Validate required columns
required_cols = ["review_text", "sentiment"]
missing = [c for c in required_cols if c not in df.columns]
if missing:
raise ValueError(f"Missing columns in dataset: {missing}")
# Remove rows with missing values
df = df.dropna(subset=required_cols)
# Clean texts
df["clean_text"] = df["review_text"].apply(clean_text)
# Remove empty texts after cleaning
df = df[df["clean_text"].str.len() > 0]
return df["clean_text"], df["sentiment"]
def split_dataset(
X: pd.Series,
y: pd.Series,
test_size: float = 0.2,
random_state: int = 42
) -> Tuple[pd.Series, pd.Series, pd.Series, pd.Series]:
"""Stratified split of the dataset."""
return train_test_split(
X, y,
test_size=test_size,
random_state=random_state,
stratify=y
)
Training Module
"""Module for creating, training and evaluating the model."""
from sklearn.pipeline import Pipeline
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (
accuracy_score, f1_score, precision_score,
recall_score, roc_auc_score
)
from typing import Dict, Any
import pandas as pd
MODELS = {
"logistic_regression": LogisticRegression,
"random_forest": RandomForestClassifier,
}
def create_pipeline(model_config: Dict[str, Any]) -> Pipeline:
"""Creates a sklearn pipeline with TF-IDF + classifier."""
algorithm = model_config.get("algorithm", "logistic_regression")
model_class = MODELS.get(algorithm)
if model_class is None:
raise ValueError(
f"Unsupported algorithm: {algorithm}. "
f"Supported: {list(MODELS.keys())}"
)
# Model-specific parameters
model_params = {
k: v for k, v in model_config.items()
if k not in ("algorithm", "tfidf")
}
# TF-IDF parameters
tfidf_params = model_config.get("tfidf", {})
return Pipeline([
("tfidf", TfidfVectorizer(
max_features=tfidf_params.get("max_features", 10000),
ngram_range=tuple(tfidf_params.get("ngram_range", [1, 2])),
min_df=tfidf_params.get("min_df", 2),
max_df=tfidf_params.get("max_df", 0.95),
)),
("classifier", model_class(**model_params)),
])
def train_model(
pipeline: Pipeline,
X_train: pd.Series,
y_train: pd.Series
) -> Pipeline:
"""Trains the pipeline on the training set."""
pipeline.fit(X_train, y_train)
return pipeline
def evaluate_model(
pipeline: Pipeline,
X_test: pd.Series,
y_test: pd.Series
) -> Dict[str, float]:
"""Evaluates the model and returns all metrics."""
y_pred = pipeline.predict(X_test)
metrics = {
"accuracy": accuracy_score(y_test, y_pred),
"f1_score": f1_score(y_test, y_pred, average="weighted"),
"precision": precision_score(y_test, y_pred, average="weighted"),
"recall": recall_score(y_test, y_pred, average="weighted"),
}
# AUC-ROC only for binary classification
if len(set(y_test)) == 2:
y_proba = pipeline.predict_proba(X_test)[:, 1]
metrics["auc_roc"] = roc_auc_score(y_test, y_proba)
return metrics
Evaluation Script
"""Model evaluation script with threshold comparison."""
import argparse
import json
import yaml
import joblib
import pandas as pd
from pathlib import Path
from src.data.preprocessing import clean_text
from src.models.trainer import evaluate_model
def parse_args():
parser = argparse.ArgumentParser(description="Evaluate trained model")
parser.add_argument("--model-path", required=True, help="Path to the .pkl model")
parser.add_argument("--test-data", required=True, help="Path to the test data")
parser.add_argument("--thresholds", required=True, help="Path to the YAML thresholds file")
parser.add_argument("--output", required=True, help="Path for the JSON report")
return parser.parse_args()
def main():
args = parse_args()
# 1. Load model and data
pipeline = joblib.load(args.model_path)
df = pd.read_csv(args.test_data)
df["clean_text"] = df["review_text"].apply(clean_text)
X_test = df["clean_text"]
y_test = df["sentiment"]
# 2. Evaluate
metrics = evaluate_model(pipeline, X_test, y_test)
# 3. Compare with thresholds
with open(args.thresholds) as f:
thresholds = yaml.safe_load(f)["thresholds"]
all_pass = True
results = {}
for metric_name, threshold_value in thresholds.items():
actual = metrics.get(metric_name, 0.0)
passed = actual >= threshold_value
if not passed:
all_pass = False
results[metric_name] = {
"value": actual,
"threshold": threshold_value,
"pass": passed
}
# 4. Generate report
report = {
**metrics,
"thresholds": thresholds,
"details": results,
"pass": all_pass
}
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(report, f, indent=2)
# 5. Print results
print("\n=== Evaluation Report ===")
for name, detail in results.items():
status = "PASS" if detail["pass"] else "FAIL"
print(f" {name}: {detail['value']:.4f} (threshold: {detail['threshold']}) [{status}]")
print(f"\nOverall: {'PASS' if all_pass else 'FAIL'}")
# Non-zero exit code if the metrics do not pass
if not all_pass:
print("\nWARNING: Metrics do not reach the minimum thresholds!")
# We do not use exit(1) because the workflow reads the output
if __name__ == "__main__":
main()
Training and Threshold Configuration
# Training pipeline configuration
data:
train_path: "data/raw/reviews.csv"
test_size: 0.2
random_state: 42
model:
algorithm: "logistic_regression"
max_iter: 1000
C: 1.0
random_state: 42
tfidf:
max_features: 15000
ngram_range: [1, 2]
min_df: 3
max_df: 0.9
mlflow:
experiment_name: "sentiment-classifier"
registered_model_name: "sentiment-classifier"
# Minimum thresholds to approve the deployment
thresholds:
accuracy: 0.85
f1_score: 0.83
precision: 0.80
recall: 0.80
auc_roc: 0.90
# Comparison with the production model
comparison:
# The new model must improve by at least 0.5%
min_improvement: 0.005
# Metrics on which the improvement is required
compare_metrics:
- f1_score
- auc_roc
Requirements File
# Core ML
scikit-learn==1.4.0
pandas==2.2.0
numpy==1.26.3
# NLP preprocessing
nltk==3.8.1
# Experiment tracking
mlflow==2.10.0
# Model serving
fastapi==0.109.0
uvicorn==0.27.0
pydantic==2.5.3
# Data validation
pandera==0.18.0
great-expectations==0.18.8
# Data versioning
dvc[s3]==3.42.0
# Configuration
pyyaml==6.0.1
python-dotenv==1.0.1
# Serialization
joblib==1.3.2
# Testing
pytest==7.4.4
httpx==0.26.0
Data Validation: Preventing Bad Data from Reaching Training
One of the most valuable components of an ML pipeline is data validation. Training on corrupt, incomplete, or drift-affected data produces a model that will fail in production without any obvious error. We use Great Expectations to define declarative data quality rules that serve as a gate before training begins.
# validate_data.py
# Data quality checks using Great Expectations
# Executed as first step in the CI/CD pipeline
import json
import sys
import logging
from pathlib import Path
import pandas as pd
import great_expectations as gx
from great_expectations.core.batch import BatchRequest
logger = logging.getLogger(__name__)
def validate_training_data(
data_path: str,
output_path: str = "data/raw/validation_summary.json"
) -> bool:
"""
Validates training data against a set of predefined expectations.
Returns:
True if all expectations pass, False otherwise.
"""
df = pd.read_csv(data_path)
context = gx.get_context()
# Create a pandas datasource
datasource = context.sources.add_pandas("training_data")
data_asset = datasource.add_dataframe_asset("reviews")
batch_request = data_asset.build_batch_request(dataframe=df)
# Define expectations
suite = context.add_expectation_suite("training_data_suite")
validator = context.get_validator(
batch_request=batch_request,
expectation_suite=suite
)
# ---- Core expectations ----
# 1. Minimum number of rows
validator.expect_table_row_count_to_be_between(min_value=10_000)
# 2. Required columns must exist
for col in ["text", "label", "timestamp"]:
validator.expect_column_to_exist(col)
# 3. No nulls in critical columns
validator.expect_column_values_to_not_be_null("text")
validator.expect_column_values_to_not_be_null("label")
# 4. Label values within expected range (binary classification)
validator.expect_column_values_to_be_in_set("label", [0, 1])
# 5. Class balance check: neither class exceeds 90%
validator.expect_column_mean_to_be_between("label", min_value=0.1, max_value=0.9)
# 6. Text column: reasonable length
validator.expect_column_value_lengths_to_be_between(
"text", min_value=5, max_value=5000
)
# 7. No duplicate rows
validator.expect_compound_columns_to_be_unique(["text"])
# ---- Run validation ----
results = validator.validate()
summary = {
"all_checks_passed": results.success,
"total_checks": len(results.results),
"passed_checks": sum(1 for r in results.results if r.success),
"failed_checks": sum(1 for r in results.results if not r.success),
"row_count": int(df.shape[0]),
"column_count": int(df.shape[1]),
"failures": [
{
"expectation": str(r.expectation_config.expectation_type),
"column": r.expectation_config.kwargs.get("column", "N/A")
}
for r in results.results if not r.success
]
}
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(summary, f, indent=2)
if not results.success:
logger.error(f"Data validation FAILED: {summary['failed_checks']} checks failed")
for failure in summary["failures"]:
logger.error(f" - {failure['expectation']} on column '{failure['column']}'")
else:
logger.info(f"Data validation PASSED: {summary['passed_checks']}/{summary['total_checks']} checks")
return results.success
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--data-path", required=True)
parser.add_argument("--output-path", default="data/raw/validation_summary.json")
args = parser.parse_args()
passed = validate_training_data(args.data_path, args.output_path)
sys.exit(0 if passed else 1)
Data Versioning with DVC in the Pipeline
Data is too large for Git, but it must be versioned and synchronized
in the CI/CD pipeline. DVC (Data Version Control) solves this problem:
it stores the data on remote storage (S3, GCS, Azure Blob) and tracks only the
metadata (hash, size) in Git. GitHub Actions can use dvc pull to
download exactly the version of the data associated with the current commit.
stages:
prepare:
cmd: python -m src.data.preprocessing --config config/training.yaml
deps:
- src/data/preprocessing.py
- config/training.yaml
- data/raw/reviews.csv
outs:
- data/processed/train.csv
- data/processed/test.csv
train:
cmd: python train.py --config config/training.yaml
deps:
- train.py
- src/models/trainer.py
- data/processed/train.csv
- config/training.yaml
outs:
- models/model.pkl
metrics:
- metrics/train_metrics.json:
cache: false
evaluate:
cmd: >-
python evaluate.py
--model-path models/model.pkl
--test-data data/processed/test.csv
--thresholds config/thresholds.yaml
--output metrics/eval_metrics.json
deps:
- evaluate.py
- models/model.pkl
- data/processed/test.csv
- config/thresholds.yaml
metrics:
- metrics/eval_metrics.json:
cache: false
# Initialize DVC
dvc init
# Configure remote storage (S3 in this example)
dvc remote add -d myremote s3://my-ml-data-bucket/sentiment-classifier
dvc remote modify myremote region eu-west-1
# Track the dataset
dvc add data/raw/reviews.csv
# Commit the DVC files to Git
git add data/raw/reviews.csv.dvc data/raw/.gitignore dvc.yaml dvc.lock
git commit -m "feat: add DVC tracking for training data"
# Push the data to S3
dvc push
DVC + GitHub Actions: How It Works
- Git tracks the
.dvcfiles (metadata: SHA256 hash, size) - The actual data lives on S3 (or GCS, Azure Blob, Google Drive)
- The GitHub Actions workflow runs
dvc pullto download the data - S3 credentials are passed through GitHub Secrets
- Every Git commit corresponds to an exact version of the data
Model Registry with MLflow
The Model Registry is the component that manages the lifecycle of models after training. Every trained model is registered with a name, a version and a stage (Staging, Production, Archived). The CI/CD pipeline interacts with the registry to promote the models that pass the validation thresholds.
"""Script for promoting the model in the MLflow registry."""
import mlflow
from mlflow.tracking import MlflowClient
def promote_model(
model_name: str,
run_id: str,
target_stage: str = "Production"
) -> None:
"""Promotes a model to Production in the registry."""
client = MlflowClient()
# Find the model version associated with the run
model_versions = client.search_model_versions(
f"name='{model_name}'"
)
target_version = None
for mv in model_versions:
if mv.run_id == run_id:
target_version = mv.version
break
if target_version is None:
raise ValueError(
f"No model found for run_id={run_id}"
)
# Archive the model currently in Production
for mv in model_versions:
if mv.current_stage == "Production":
client.transition_model_version_stage(
name=model_name,
version=mv.version,
stage="Archived",
archive_existing_versions=False
)
print(f"Archived model v{mv.version} (previous Production)")
# Promote the new model
client.transition_model_version_stage(
name=model_name,
version=target_version,
stage=target_stage
)
print(f"Promoted model v{target_version} to {target_stage}")
def load_production_model(model_name: str):
"""Loads the model currently in Production."""
model_uri = f"models:/{model_name}/Production"
return mlflow.sklearn.load_model(model_uri)
Model Signature and Input Example
The model signature documents the schema of the model inputs and outputs. This serves both as documentation and as automatic validation: if someone tries to pass inputs with a different schema, MLflow raises a clear error.
import mlflow
from mlflow.models.signature import ModelSignature
from mlflow.types.schema import Schema, ColSpec
# Define the explicit signature
input_schema = Schema([
ColSpec("string", "text")
])
output_schema = Schema([
ColSpec("long", "prediction")
])
signature = ModelSignature(
inputs=input_schema,
outputs=output_schema
)
# Example input for documentation
input_example = {
"text": "This product is excellent, highly recommended!"
}
# Register the model with signature and example
mlflow.sklearn.log_model(
sk_model=trained_pipeline,
artifact_path="model",
signature=signature,
input_example=input_example,
registered_model_name="sentiment-classifier"
)
ML-Specific Testing
ML systems require test types that do not exist in traditional software. In addition to unit tests for utility functions, we need tests that verify the behavior of the model itself: does the preprocessing produce the expected shapes? Does the model output valid probabilities? Does the API respond correctly to edge cases?
Unit Tests for Preprocessing
"""Unit tests for the preprocessing module."""
import pytest
import pandas as pd
from src.data.preprocessing import clean_text, load_and_preprocess_data
class TestCleanText:
"""Tests for the clean_text function."""
def test_removes_html_tags(self):
assert clean_text("<p>Hello</p>") == "hello"
def test_removes_urls(self):
assert clean_text("Visit http://example.com for info") == "visit for info"
def test_removes_special_characters(self):
assert clean_text("Hello!!! World???") == "hello world"
def test_normalizes_whitespace(self):
assert clean_text("Hello World") == "hello world"
def test_lowercases(self):
assert clean_text("HELLO WORLD") == "hello world"
def test_empty_string(self):
assert clean_text("") == ""
def test_none_input(self):
assert clean_text(None) == ""
def test_numeric_preserved(self):
assert clean_text("Rating 5 out of 10") == "rating 5 out of 10"
class TestLoadAndPreprocess:
"""Tests for loading and preprocessing the data."""
def test_missing_columns_raises(self, tmp_path):
"""Checks that missing columns raise an error."""
df = pd.DataFrame({"wrong_col": ["text"]})
csv_path = tmp_path / "test.csv"
df.to_csv(csv_path, index=False)
with pytest.raises(ValueError, match="Missing columns"):
load_and_preprocess_data(str(csv_path))
def test_drops_na_rows(self, tmp_path):
"""Checks that rows with NA values are removed."""
df = pd.DataFrame({
"review_text": ["Good product", None, "Bad product"],
"sentiment": [1, 0, 0]
})
csv_path = tmp_path / "test.csv"
df.to_csv(csv_path, index=False)
X, y = load_and_preprocess_data(str(csv_path))
assert len(X) == 2
def test_output_types(self, tmp_path):
"""Checks the output types."""
df = pd.DataFrame({
"review_text": ["Great product", "Terrible service"],
"sentiment": [1, 0]
})
csv_path = tmp_path / "test.csv"
df.to_csv(csv_path, index=False)
X, y = load_and_preprocess_data(str(csv_path))
assert isinstance(X, pd.Series)
assert isinstance(y, pd.Series)
Integration Tests for the Pipeline
"""Integration tests for training and evaluating the model."""
import pytest
import pandas as pd
from src.models.trainer import create_pipeline, train_model, evaluate_model
@pytest.fixture
def sample_data():
"""Creates a synthetic test dataset."""
texts = [
"amazing product love it", "terrible waste of money",
"great quality recommended", "horrible experience never again",
"excellent value for price", "poor quality disappointed",
"best purchase ever made", "worst product i bought",
"fantastic results happy", "awful terrible regret buying",
] * 10 # Repeated to have enough data
sentiments = [1, 0, 1, 0, 1, 0, 1, 0, 1, 0] * 10
return pd.Series(texts), pd.Series(sentiments)
class TestPipeline:
"""Tests for the training pipeline."""
def test_create_pipeline_logistic(self):
"""Checks pipeline creation with LogisticRegression."""
config = {"algorithm": "logistic_regression", "max_iter": 100}
pipeline = create_pipeline(config)
assert len(pipeline.steps) == 2
def test_create_pipeline_invalid_algorithm(self):
"""Checks the error raised for an unsupported algorithm."""
with pytest.raises(ValueError, match="Unsupported"):
create_pipeline({"algorithm": "invalid_algo"})
def test_train_and_evaluate(self, sample_data):
"""End-to-end test: training + evaluation."""
X, y = sample_data
config = {
"algorithm": "logistic_regression",
"max_iter": 200,
"random_state": 42
}
pipeline = create_pipeline(config)
trained = train_model(pipeline, X[:80], y[:80])
metrics = evaluate_model(trained, X[80:], y[80:])
assert "accuracy" in metrics
assert "f1_score" in metrics
assert 0.0 <= metrics["accuracy"] <= 1.0
assert 0.0 <= metrics["f1_score"] <= 1.0
def test_model_deterministic(self, sample_data):
"""Checks that training is deterministic with a fixed seed."""
X, y = sample_data
config = {
"algorithm": "logistic_regression",
"max_iter": 200,
"random_state": 42
}
p1 = train_model(create_pipeline(config), X[:80], y[:80])
p2 = train_model(create_pipeline(config), X[:80], y[:80])
m1 = evaluate_model(p1, X[80:], y[80:])
m2 = evaluate_model(p2, X[80:], y[80:])
assert m1["accuracy"] == m2["accuracy"]
Smoke Tests Against the Deployed API
# test_api.py
# Smoke tests run immediately after deployment
# Verify the API is working correctly before traffic routing
import json
import sys
import time
import argparse
import requests
import logging
logger = logging.getLogger(__name__)
def run_smoke_tests(base_url: str, timeout: int = 30) -> bool:
"""
Runs smoke tests against the deployed API.
Args:
base_url: Base URL of the deployed API (e.g., https://api.example.com)
timeout: Max wait time in seconds for health check
Returns:
True if all tests pass, False otherwise
"""
results = {}
# ---- Test 1: Health check ----
print("Test 1: Health check...")
deadline = time.time() + timeout
while time.time() < deadline:
try:
resp = requests.get(f"{base_url}/health", timeout=5)
if resp.status_code == 200:
health = resp.json()
assert health.get("status") == "healthy", f"Unexpected health: {health}"
results["health_check"] = True
print(f" PASS - model_version: {health.get('model_version', 'unknown')}")
break
except Exception:
time.sleep(2)
else:
results["health_check"] = False
print(" FAIL - health check timed out")
# ---- Test 2: Single prediction ----
print("Test 2: Single prediction...")
try:
payload = {
"text": "This product is absolutely fantastic, I love it!",
"max_length": 128
}
start = time.time()
resp = requests.post(f"{base_url}/predict", json=payload, timeout=10)
latency_ms = (time.time() - start) * 1000
assert resp.status_code == 200, f"HTTP {resp.status_code}: {resp.text}"
result = resp.json()
assert "prediction" in result, "Missing 'prediction' field"
assert "confidence" in result, "Missing 'confidence' field"
assert 0.0 <= result["confidence"] <= 1.0, "Confidence out of range [0,1]"
assert latency_ms < 500, f"Latency too high: {latency_ms:.0f}ms (max 500ms)"
results["single_prediction"] = True
print(f" PASS - prediction: {result['prediction']}, latency: {latency_ms:.0f}ms")
except Exception as e:
results["single_prediction"] = False
print(f" FAIL - {e}")
# ---- Test 3: Batch prediction ----
print("Test 3: Batch prediction...")
try:
batch_payload = {
"texts": [
"Excellent quality and fast shipping!",
"Terrible experience, never buying again.",
"Average product, nothing special."
]
}
resp = requests.post(f"{base_url}/predict/batch", json=batch_payload, timeout=15)
assert resp.status_code == 200
batch_result = resp.json()
assert len(batch_result["predictions"]) == 3
for pred in batch_result["predictions"]:
assert 0.0 <= pred["confidence"] <= 1.0
results["batch_prediction"] = True
print(f" PASS - {len(batch_result['predictions'])} predictions returned")
except Exception as e:
results["batch_prediction"] = False
print(f" FAIL - {e}")
# ---- Test 4: Edge cases ----
print("Test 4: Edge cases...")
try:
# Empty text should return validation error, not 500
resp = requests.post(f"{base_url}/predict", json={"text": ""}, timeout=5)
assert resp.status_code == 422, f"Expected 422 for empty text, got {resp.status_code}"
# Very long text - should not crash
long_text = "word " * 2000
resp = requests.post(f"{base_url}/predict", json={"text": long_text}, timeout=10)
assert resp.status_code in [200, 422], f"Unexpected status: {resp.status_code}"
results["edge_cases"] = True
print(" PASS")
except Exception as e:
results["edge_cases"] = False
print(f" FAIL - {e}")
# ---- Summary ----
all_passed = all(results.values())
passed = sum(results.values())
total = len(results)
print(f"\nSmoke test results: {passed}/{total} passed")
if not all_passed:
failed = [k for k, v in results.items() if not v]
print(f"Failed tests: {', '.join(failed)}")
return all_passed
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", required=True)
parser.add_argument("--timeout", type=int, default=30)
args = parser.parse_args()
passed = run_smoke_tests(args.base_url, args.timeout)
sys.exit(0 if passed else 1)
In-Process Smoke Tests for the Serving API
"""Smoke tests for the FastAPI serving API."""
import pytest
from httpx import AsyncClient, ASGITransport
from src.serving.app import app
@pytest.fixture
def client():
"""HTTP client for testing the API."""
transport = ASGITransport(app=app)
return AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_health_endpoint(client):
"""Checks that the /health endpoint returns 200."""
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
@pytest.mark.asyncio
async def test_predict_positive(client):
"""Checks the prediction for a positive text."""
response = await client.post(
"/predict",
json={"text": "This product is amazing, I love it!"}
)
assert response.status_code == 200
data = response.json()
assert "prediction" in data
assert "confidence" in data
assert data["confidence"] > 0.0
@pytest.mark.asyncio
async def test_predict_empty_text(client):
"""Checks the error returned for an empty text."""
response = await client.post(
"/predict",
json={"text": ""}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_predict_batch(client):
"""Checks batch prediction."""
response = await client.post(
"/predict/batch",
json={"texts": [
"Great product",
"Terrible experience"
]}
)
assert response.status_code == 200
data = response.json()
assert len(data["predictions"]) == 2
# Add this job before training in the workflow
tests:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest-asyncio pytest-cov
- name: Run unit tests
run: pytest tests/ -v --cov=src --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.xml
Serving API with FastAPI
The trained model must be reachable through a REST API. FastAPI is the ideal choice for ML serving in Python: it is fast, it provides automatic input validation through Pydantic, and it generates OpenAPI documentation automatically.
"""Serving API for the sentiment classifier."""
import os
import time
import joblib
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from src.serving.schemas import (
PredictionRequest, PredictionResponse,
BatchPredictionRequest, BatchPredictionResponse,
HealthResponse
)
from src.data.preprocessing import clean_text
# Global variables for the model
model_pipeline = None
model_version = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Loads the model when the application starts."""
global model_pipeline, model_version
model_path = os.getenv("MODEL_PATH", "models/model.pkl")
if not Path(model_path).exists():
raise RuntimeError(f"Model not found: {model_path}")
model_pipeline = joblib.load(model_path)
model_version = os.getenv("MODEL_VERSION", "unknown")
print(f"Model loaded: v{model_version}")
yield
model_pipeline = None
app = FastAPI(
title="Sentiment Classifier API",
version="1.0.0",
lifespan=lifespan
)
@app.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check of the API."""
return HealthResponse(
status="healthy" if model_pipeline is not None else "unhealthy",
model_version=model_version or "not loaded"
)
@app.post("/predict", response_model=PredictionResponse)
async def predict(request: PredictionRequest):
"""Single prediction."""
if model_pipeline is None:
raise HTTPException(status_code=503, detail="Model not loaded")
start = time.time()
cleaned = clean_text(request.text)
if not cleaned:
raise HTTPException(status_code=422, detail="Text is empty after cleaning")
prediction = model_pipeline.predict([cleaned])[0]
probabilities = model_pipeline.predict_proba([cleaned])[0]
confidence = float(max(probabilities))
latency_ms = (time.time() - start) * 1000
return PredictionResponse(
prediction=int(prediction),
confidence=confidence,
label="positive" if prediction == 1 else "negative",
latency_ms=round(latency_ms, 2)
)
@app.post("/predict/batch", response_model=BatchPredictionResponse)
async def predict_batch(request: BatchPredictionRequest):
"""Batch prediction on multiple texts."""
if model_pipeline is None:
raise HTTPException(status_code=503, detail="Model not loaded")
start = time.time()
cleaned_texts = [clean_text(t) for t in request.texts]
predictions = model_pipeline.predict(cleaned_texts)
probabilities = model_pipeline.predict_proba(cleaned_texts)
latency_ms = (time.time() - start) * 1000
results = []
for i, text in enumerate(request.texts):
results.append(PredictionResponse(
prediction=int(predictions[i]),
confidence=float(max(probabilities[i])),
label="positive" if predictions[i] == 1 else "negative",
latency_ms=0
))
return BatchPredictionResponse(
predictions=results,
total_latency_ms=round(latency_ms, 2)
)
"""Pydantic schemas for the serving API."""
from pydantic import BaseModel, Field
from typing import List
class PredictionRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=5000,
description="Review text")
class PredictionResponse(BaseModel):
prediction: int = Field(..., description="0=negative, 1=positive")
confidence: float = Field(..., ge=0.0, le=1.0,
description="Prediction confidence")
label: str = Field(..., description="Readable label")
latency_ms: float = Field(..., description="Latency in milliseconds")
class BatchPredictionRequest(BaseModel):
texts: List[str] = Field(..., min_length=1, max_length=100,
description="List of texts")
class BatchPredictionResponse(BaseModel):
predictions: List[PredictionResponse]
total_latency_ms: float
class HealthResponse(BaseModel):
status: str
model_version: str
Post-Deployment Monitoring
Deployment is not the end of the pipeline, but the start of its most critical phase: production monitoring. An ML model can degrade silently when real-world data drifts away from the training data. The pipeline must include continuous health checks, prediction logging and automatic retraining triggers.
"""Post-deployment monitoring for the ML model."""
import time
import logging
from datetime import datetime, timedelta
from collections import deque
from typing import Dict, Optional
from dataclasses import dataclass, field
@dataclass
class PredictionLog:
"""Log of a single prediction."""
timestamp: datetime
input_text: str
prediction: int
confidence: float
latency_ms: float
class ModelMonitor:
"""Monitors the performance of the model in production."""
def __init__(
self,
window_size: int = 1000,
min_confidence_threshold: float = 0.6,
max_latency_ms: float = 500.0,
drift_check_interval: int = 100
):
self.window_size = window_size
self.min_confidence = min_confidence_threshold
self.max_latency = max_latency_ms
self.drift_check_interval = drift_check_interval
self.predictions: deque = deque(maxlen=window_size)
self.alert_callbacks = []
self.prediction_count = 0
self.logger = logging.getLogger("model_monitor")
def log_prediction(self, log: PredictionLog) -> None:
"""Records a prediction and checks the metrics."""
self.predictions.append(log)
self.prediction_count += 1
# Latency check
if log.latency_ms > self.max_latency:
self._alert(
"HIGH_LATENCY",
f"Latency {log.latency_ms:.0f}ms exceeds threshold {self.max_latency}ms"
)
# Low confidence check
if log.confidence < self.min_confidence:
self._alert(
"LOW_CONFIDENCE",
f"Confidence {log.confidence:.2f} below threshold {self.min_confidence}"
)
# Periodic drift check
if self.prediction_count % self.drift_check_interval == 0:
self._check_distribution_drift()
def get_metrics(self) -> Dict:
"""Returns the current metrics of the window."""
if not self.predictions:
return {"status": "no_data"}
recent = list(self.predictions)
confidences = [p.confidence for p in recent]
latencies = [p.latency_ms for p in recent]
predictions = [p.prediction for p in recent]
positive_rate = sum(1 for p in predictions if p == 1) / len(predictions)
return {
"total_predictions": self.prediction_count,
"window_size": len(recent),
"avg_confidence": sum(confidences) / len(confidences),
"min_confidence": min(confidences),
"avg_latency_ms": sum(latencies) / len(latencies),
"p95_latency_ms": sorted(latencies)[int(len(latencies) * 0.95)],
"positive_rate": positive_rate,
"low_confidence_pct": sum(
1 for c in confidences if c < self.min_confidence
) / len(confidences),
}
def _check_distribution_drift(self) -> None:
"""Checks whether the distribution of predictions has changed."""
if len(self.predictions) < self.window_size:
return
recent = list(self.predictions)
half = len(recent) // 2
first_half = [p.prediction for p in recent[:half]]
second_half = [p.prediction for p in recent[half:]]
rate_first = sum(first_half) / len(first_half)
rate_second = sum(second_half) / len(second_half)
# If the distribution changes by more than 15%, signal drift
if abs(rate_first - rate_second) > 0.15:
self._alert(
"DISTRIBUTION_DRIFT",
f"Positive rate changed: {rate_first:.2f} -> {rate_second:.2f}"
)
def _alert(self, alert_type: str, message: str) -> None:
"""Sends an alert."""
self.logger.warning(f"[{alert_type}] {message}")
for callback in self.alert_callbacks:
callback(alert_type, message)
Key Metrics to Monitor
- Latency (p50, p95, p99): API response time
- Throughput: number of predictions per second
- Prediction distribution: changes in the positive/negative ratio
- Average confidence: a drop indicates the model is "uncertain"
- Error rate: percentage of HTTP 5xx errors
- Data drift: divergence between production data and training data
Cost Optimization: Caching and Self-Hosted Runners
ML pipelines on CI/CD can become expensive quickly: model training takes time and consumes runner minutes. There are several strategies to keep costs under control without sacrificing automation.
Aggressive Layer Caching
# In your GitHub Actions workflow: maximize caching
- name: Setup Python with full pip cache
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: 'pip'
cache-dependency-path: |
requirements.txt
requirements-dev.txt
# DVC cache: avoid re-downloading unchanged data
- name: Cache DVC data
uses: actions/cache@v4
with:
path: .dvc/cache
key: dvc-${{ hashFiles('dvc.lock') }}
restore-keys: |
dvc-
# Docker BuildKit cache (GHA cache backend)
- name: Build with BuildKit cache
uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max
# 'max' mode caches every layer, not just final
Conditional Job Execution
Not every pipeline run needs to retrain the model. Use path filters and conditions to skip expensive steps when they are unnecessary.
# Only trigger full training when relevant files change
on:
push:
paths:
- 'src/training/**'
- 'src/data/**'
- 'config/model_config.yaml'
- 'requirements.txt'
# Ignore: docs/, README.md, tests/ (unless you want test-triggered training)
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
training_needed: ${{ steps.filter.outputs.training }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
training:
- 'src/training/**'
- 'config/model_config.yaml'
- 'data/**'
training:
needs: check-changes
if: needs.check-changes.outputs.training_needed == 'true'
runs-on: ubuntu-latest
# ... training steps
Self-Hosted Runners with GPU
For GPU training, GitHub-hosted runners are not enough (they have no GPU). The solution is a self-hosted runner on a machine with a GPU. This also eliminates the per-minute cost of GitHub Actions.
# 1. On the GPU machine, download the runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
tar xzf actions-runner.tar.gz
# 2. Configure the runner
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \
--token YOUR_TOKEN \
--labels gpu,cuda12,ml-training
# 3. Install as a service
sudo ./svc.sh install
sudo ./svc.sh start
training:
name: Train Model (GPU)
runs-on: [self-hosted, gpu, cuda12]
# The job runs on the machine with the GPU
steps:
- uses: actions/checkout@v4
- name: Train with GPU
run: |
nvidia-smi # Check that the GPU is available
python train.py --config config/training-gpu.yaml --device cuda
Cost Estimate by Scenario
| Scenario | Minutes/Month | GitHub Actions Cost | Total Cost |
|---|---|---|---|
| Prototype (manual training, scikit-learn) | ~200 | Free (Free plan) | ~0 EUR/month |
| SMB (weekly training, medium-sized model) | ~800 | ~12 EUR/month | ~50 EUR/month (with S3) |
| Scale-up (daily training, deep learning) | ~3,000 | ~48 EUR/month | ~200 EUR/month (with cloud GPU) |
| Enterprise (multi-model, continuous training) | ~10,000+ | Self-hosted runner | ~500+ EUR/month |
Best Practices and Tool Comparison
| Tool | Strengths | Weaknesses | Best For |
|---|---|---|---|
| GitHub Actions | Integrated with Git, huge marketplace, free tier | Limited GPU runners, no native ML features | Projects already on GitHub, teams <20 people |
| GitLab CI | Self-hosted option, strong security, good GPU support | More complex configuration, steeper learning curve | Enterprise with on-premise requirements |
| Kubeflow Pipelines | Native K8s, pipeline visualization, artifact tracking | Complex setup, Kubernetes required | Large ML teams, complex DAG pipelines |
| Prefect / Airflow | Advanced scheduling, retries, observability | Adds operational overhead, not CI/CD-native | Data engineering teams, complex orchestration |
| DVC Pipelines + GH Actions | Reproducibility, data versioning, lightweight | Learning curve for DVC, less visualization | Research teams, projects prioritizing reproducibility |
Recommended Approach for Budgets Under EUR 5K/Year
- CI/CD: GitHub Actions (free tier covers most use cases)
- Orchestration: DVC Pipelines (free, reproducible, Git-native)
- Registry: GitHub Container Registry (free for public repos)
- Serving: Self-hosted on Hetzner VPS (~EUR 6/month)
- Tracking: MLflow self-hosted on same VPS
- Total infrastructure: ~EUR 360/year, well under budget
Full Setup Under EUR 5K per Year
For an SMB that wants to implement a complete ML CI/CD pipeline, it is possible to stay under EUR 5,000 per year by using open-source tools and cloud services with free or low-cost tiers. Here is the recommended stack.
| Component | Tool | Cost/Year | Notes |
|---|---|---|---|
| Repository + CI/CD | GitHub (Team) | ~400 EUR | 3,000 min/month Actions included |
| Data Storage | AWS S3 | ~120 EUR | ~500 GB dataset, transfer included |
| Experiment Tracking | MLflow (self-hosted) | ~0 EUR | Open-source, deployed on a cloud VM |
| Model Registry | MLflow Model Registry | ~0 EUR | Included in MLflow |
| Container Registry | GitHub Container Registry | ~0 EUR | Included with GitHub |
| Model Hosting | Cloud VM (e2-medium) | ~500 EUR | For FastAPI serving + MLflow server |
| Data Versioning | DVC | ~0 EUR | Open-source, storage already counted above |
| Monitoring | Prometheus + Grafana | ~0 EUR | Open-source, on the same VM |
| Data Validation | Pandera / Great Expectations | ~0 EUR | Open-source |
| GPU Training (occasional) | Cloud GPU spot instance | ~600 EUR | ~50 hours/month T4 spot |
Estimated total: ~EUR 1,620/year - well under the EUR 5,000 budget, with room for scaling. The largest cost is GPU training: if you use classic models (scikit-learn, XGBoost) the GPU cost drops to zero.
Watch Out for Hidden Costs
The cost of the tools is only one part. The most significant cost is the team's time: setting up the initial pipeline takes about 2-4 weeks for an experienced engineer. Ongoing maintenance takes about 2-4 hours/week. Also account for the data transfer costs between cloud services (egress), which can grow quickly with large datasets.
Conclusions and Next Steps
We have built a complete CI/CD pipeline for ML: from multi-stage Dockerfiles that optimize image size and security, to GitHub Actions workflows with data validation, training, metric gates, Docker build, and automated deployment. Every component is connected and automated.
The key principle to take away: a CI/CD pipeline for ML must manage code, data, and model as first-class citizens. Treating only code as a versioned artifact while leaving data and models untracked is one of the primary causes of unreproducible experiments and silent production degradation.
The pipeline we built corresponds to Level 2 of Google's MLOps maturity model: complete automation of training and deployment, with integrated validation and monitoring.
The key takeaways are:
- CI/CD for ML manages three artifacts (code, data, model) and introduces Continuous Training
- Multi-stage Docker separates build, training and serving for optimized images
- GitHub Actions orchestrates the entire pipeline with dependent and conditional jobs
- Data validation is the first gate: corrupt data produces unusable models
- The Model Registry manages model versions and promotion
- ML testing covers three levels: unit, integration, smoke tests
- Monitoring after deployment is critical to detect drift and degradation
- Costs are manageable: with open-source tools you stay under EUR 2,000/year
Continue the Series
- Previous article: MLOps 101: From Experiment to Production - Foundational concepts and maturity levels
- Next article: Dataset and Model Versioning: DVC vs LakeFS - Deep dive into data versioning with DVC and LakeFS
- Related: Serving ML Models: FastAPI, Uvicorn and Containerization - Building the production API referenced in this pipeline
Commenti
Caricamento commenti...
Sign in to comment