Ch 1 — ML Infrastructure¶
Chapter Meta
Level: Advanced | Reading time: 90 min | Volume: 9 — MLOps
Learning Objectives¶
By the end of this chapter you will be able to:
- Set up an MLflow tracking server and log metrics, parameters, and artefacts from a training run with full reproducibility.
- Configure Weights & Biases for experiment tracking and run a Bayesian hyperparameter sweep across multiple parameters.
- Design a feature store architecture that serves consistent, point-in-time-correct features for both offline training and online inference.
- Version datasets and define reproducible ML pipelines as DAGs using DVC.
- Build multi-stage GPU-enabled Docker images for ML workloads and deploy them to Kubernetes with resource limits and autoscaling.
The ML Lifecycle¶
Every production ML system moves through a repeating lifecycle. Infrastructure choices at each stage determine cost, reproducibility, and team velocity. The loop is intentional: monitoring signals feed back into data collection and feature engineering.
flowchart LR
DC["Data\nCollection"] --> FE["Feature\nEngineering"]
FE --> TR["Model\nTraining"]
TR --> EV["Evaluation"]
EV --> DP["Deployment"]
DP --> MO["Monitoring"]
MO -->|"drift / degradation"| DC Infrastructure that does not support this loop — where monitoring is bolted on afterward, or features are computed differently during training and serving — creates the training-serving skew that silently degrades production models over time.
Experiment Tracking¶
Experiment tracking records the inputs (hyperparameters, data versions, code commits) and outputs (metrics, artefacts, plots) of every training run. Without it, a team cannot answer: "Which configuration produced our best model six weeks ago?"
MLflow¶
MLflow is an open-source platform covering the complete ML lifecycle: Tracking, Projects, Models, and Registry.
Server setup:
pip install mlflow psycopg2-binary boto3
# Development: SQLite backend + local artefact store
mlflow server \
--backend-store-uri sqlite:///mlflow.db \
--default-artifact-root ./mlruns \
--host 0.0.0.0 \
--port 5000
# Production: PostgreSQL + S3
mlflow server \
--backend-store-uri postgresql://user:pass@db-host:5432/mlflow \
--default-artifact-root s3://my-ml-bucket/mlflow-artifacts \
--host 0.0.0.0 \
--port 5000
Full training run with parameter, metric, and artefact logging:
"""
mlflow_training.py — MLflow experiment tracking example.
Requirements:
pip install mlflow scikit-learn pandas matplotlib
"""
from __future__ import annotations
import os
import matplotlib.pyplot as plt
import mlflow
import mlflow.sklearn
import pandas as pd
from mlflow.models import infer_signature
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score, f1_score, roc_auc_score
from sklearn.model_selection import train_test_split
def train_and_track(
n_estimators: int = 200,
max_depth: int = 5,
learning_rate: float = 0.1,
subsample: float = 0.8,
experiment_name: str = "breast-cancer-gbt",
) -> str:
"""
Train a Gradient Boosting classifier with full MLflow tracking.
Returns:
The MLflow run ID for downstream reference.
"""
mlflow.set_tracking_uri(
os.environ.get("MLFLOW_TRACKING_URI", "http://localhost:5000")
)
mlflow.set_experiment(experiment_name)
data = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(
data.data,
data.target,
test_size=0.2,
random_state=42,
stratify=data.target,
)
with mlflow.start_run() as run:
# ── Log hyperparameters ──────────────────────────────────────────────
mlflow.log_params(
{
"n_estimators": n_estimators,
"max_depth": max_depth,
"learning_rate": learning_rate,
"subsample": subsample,
"dataset": "sklearn:breast_cancer",
"n_features": data.data.shape[1],
"n_train_samples": len(X_train),
}
)
# ── Train ────────────────────────────────────────────────────────────
model = GradientBoostingClassifier(
n_estimators=n_estimators,
max_depth=max_depth,
learning_rate=learning_rate,
subsample=subsample,
random_state=42,
)
model.fit(X_train, y_train)
# ── Evaluate ─────────────────────────────────────────────────────────
y_pred = model.predict(X_test)
y_proba = model.predict_proba(X_test)[:, 1]
metrics = {
"accuracy": accuracy_score(y_test, y_pred),
"f1": f1_score(y_test, y_pred),
"roc_auc": roc_auc_score(y_test, y_proba),
}
mlflow.log_metrics(metrics)
# ── Log artefacts ────────────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(8, 5))
importances = pd.Series(
model.feature_importances_, index=data.feature_names
)
importances.sort_values().tail(10).plot.barh(ax=ax)
ax.set_title("Top 10 Feature Importances")
fig.tight_layout()
mlflow.log_figure(fig, "feature_importances.png")
plt.close(fig)
# ── Register model ───────────────────────────────────────────────────
signature = infer_signature(X_train, model.predict(X_train))
mlflow.sklearn.log_model(
sk_model=model,
artifact_path="model",
signature=signature,
input_example=X_test[:3],
registered_model_name="breast-cancer-gbt",
)
print(f"Run ID: {run.info.run_id} | Metrics: {metrics}")
return run.info.run_id
def load_production_model(model_name: str = "breast-cancer-gbt") -> object:
"""Load the model currently in the Production stage of the registry."""
return mlflow.sklearn.load_model(f"models:/{model_name}/Production")
if __name__ == "__main__":
for lr in [0.05, 0.1, 0.2]:
for depth in [3, 5, 7]:
train_and_track(n_estimators=200, max_depth=depth, learning_rate=lr)
Weights & Biases¶
W&B provides a cloud-native experiment tracking platform with richer visualisations, native sweep support, and strong collaboration features.
Initialising a run and logging training metrics:
"""
wandb_training.py — W&B experiment tracking with hyperparameter sweeps.
Requirements:
pip install wandb torch transformers datasets
"""
from __future__ import annotations
import wandb
def train_one_run(config: dict | None = None) -> None:
"""Train a single model run, optionally driven by a W&B sweep."""
with wandb.init(config=config) as run:
cfg = wandb.config
# Build model with sweep-provided hyperparameters
model = build_transformer_model(
model_name=cfg.model_name,
dropout=cfg.dropout,
)
optimizer = build_optimizer(model, lr=cfg.learning_rate, wd=cfg.weight_decay)
train_loader, val_loader = get_dataloaders(batch_size=cfg.batch_size)
for epoch in range(cfg.epochs):
model.train()
for step, batch in enumerate(train_loader):
loss, acc = train_step(model, optimizer, batch)
wandb.log(
{
"train/loss": loss,
"train/accuracy": acc,
"epoch": epoch,
"global_step": epoch * len(train_loader) + step,
}
)
val_metrics = evaluate(model, val_loader)
wandb.log(
{
"val/loss": val_metrics["loss"],
"val/accuracy": val_metrics["accuracy"],
"val/f1": val_metrics["f1"],
"epoch": epoch,
}
)
# Save best checkpoint as a W&B artefact
artifact = wandb.Artifact(
name=f"model-{run.id}",
type="model",
metadata={"val_accuracy": val_metrics["accuracy"]},
)
artifact.add_file("checkpoint_best.pt")
run.log_artifact(artifact)
Hyperparameter sweep with Bayesian optimisation:
import wandb
sweep_config: dict = {
"method": "bayes",
"metric": {"name": "val/accuracy", "goal": "maximize"},
"parameters": {
"model_name": {"value": "bert-base-uncased"},
"learning_rate": {
"distribution": "log_uniform_values",
"min": 1e-5,
"max": 1e-3,
},
"batch_size": {"values": [16, 32, 64]},
"dropout": {"distribution": "uniform", "min": 0.0, "max": 0.5},
"weight_decay": {"distribution": "uniform", "min": 0.0, "max": 0.1},
"epochs": {"value": 5},
},
"early_terminate": {"type": "hyperband", "min_iter": 2},
}
sweep_id = wandb.sweep(sweep_config, project="transformer-classification")
# Launch 30 agents to explore the parameter space
wandb.agent(sweep_id, function=train_one_run, count=30)
Experiment Tracking Comparison¶
| Feature | MLflow | Weights & Biases | Neptune |
|---|---|---|---|
| Self-hosted | Yes (free, Apache 2) | Yes (enterprise only) | Yes (enterprise only) |
| Cloud SaaS | Databricks-managed | Yes (generous free tier) | Yes (free tier) |
| UI quality | Good | Excellent | Very good |
| Hyperparameter sweeps | Via Optuna plugin | Native Bayesian/random/grid | Native |
| LLM/prompt tracking | MLflow AI Gateway | W&B Prompts (beta) | Neptune |
| Dataset versioning | Artefacts only | W&B Artifacts | Neptune Files |
| Model registry | Built-in, stage-based | Built-in | Built-in |
| Integrations | 20+ | 50+ | 25+ |
| Best for | On-premise / compliance | Research teams, rich sweeps | Enterprise audit trails |
Feature Stores¶
Online vs Offline Stores¶
A feature store decouples feature computation from model training and serving, making features consistently available to both pipelines.
| Dimension | Offline Store | Online Store |
|---|---|---|
| Purpose | Training data generation | Real-time serving at inference |
| Storage backend | Data warehouse (BigQuery, Redshift, S3+Parquet) | Redis, DynamoDB, Bigtable |
| Latency | Minutes to hours (batch) | < 10 ms |
| Update frequency | Hourly or daily batch jobs | Streaming or near-real-time |
| Data volume | Full historical records (petabytes) | Latest value per entity |
The Point-in-Time Correctness Problem¶
The most common source of training-serving skew is temporal leakage in training data construction. When building a training dataset, joining features naively on entity ID pulls the current feature value, not the value that existed at the time of the label.
Example: credit fraud model
t=0 → transaction occurs (inference time: features must reflect t=0 state)
t=7 → account flagged suspicious (account_flagged = True)
t=30 → fraud confirmed → label generated for training
Naive join: training row gets account_flagged = True ← LEAKAGE
Point-in-time join: training row gets account_flagged = False ← CORRECT
Feast Setup¶
"""
feast_features.py — Point-in-time correct feature store with Feast.
Requirements:
pip install feast[redis]>=0.38
"""
from datetime import timedelta
import pandas as pd
from feast import Entity, FeatureService, FeatureStore, FeatureView, Field
from feast.infra.offline_stores.file_source import FileSource
from feast.types import Float32, Int64, String
# ── Entity ───────────────────────────────────────────────────────────────────
user = Entity(name="user_id", description="Unique user identifier")
# ── Data source ──────────────────────────────────────────────────────────────
user_stats_source = FileSource(
path="data/user_stats.parquet", # can also be BigQuery, Redshift
timestamp_field="event_timestamp",
created_timestamp_column="created",
)
# ── Feature view ─────────────────────────────────────────────────────────────
user_stats_fv = FeatureView(
name="user_stats",
entities=[user],
ttl=timedelta(days=30),
schema=[
Field(name="purchase_count_7d", dtype=Int64),
Field(name="avg_order_value_30d", dtype=Float32),
Field(name="days_since_last_login", dtype=Int64),
Field(name="preferred_category", dtype=String),
],
online=True,
source=user_stats_source,
)
# ── Feature service (named set for a model) ──────────────────────────────────
recommendation_svc = FeatureService(
name="recommendation_v1",
features=[
user_stats_fv[["purchase_count_7d", "avg_order_value_30d"]],
],
)
from datetime import datetime
from feast import FeatureStore
store = FeatureStore(repo_path="feature_repo/")
# Materialise to online store (incremental, safe to run repeatedly)
store.materialize_incremental(end_date=datetime.utcnow())
# ── Online serving (< 10 ms with Redis) ──────────────────────────────────────
online_features = store.get_online_features(
features=[
"user_stats:purchase_count_7d",
"user_stats:avg_order_value_30d",
"user_stats:days_since_last_login",
],
entity_rows=[
{"user_id": "user_42"},
{"user_id": "user_99"},
],
).to_dict()
# ── Point-in-time correct historical features for training ───────────────────
entity_df = pd.DataFrame(
{
"user_id": ["user_42", "user_99", "user_07"],
"event_timestamp": pd.to_datetime(
["2024-01-15", "2024-01-16", "2024-01-14"]
),
"label": [1, 0, 1],
}
)
training_df = store.get_historical_features(
entity_df=entity_df,
features=[
"user_stats:purchase_count_7d",
"user_stats:avg_order_value_30d",
"user_stats:days_since_last_login",
],
).to_df()
Data Versioning with DVC¶
DVC (Data Version Control) brings Git-like versioning to large data files and defines ML pipelines as dependency-tracked DAGs. Large files live in remote storage (S3, GCS, Azure Blob); lightweight .dvc pointer files are committed to Git.
# ── Initialise ────────────────────────────────────────────────────────────────
pip install dvc dvc-s3
git init && dvc init
# Configure remote storage
dvc remote add -d myremote s3://my-ml-bucket/dvc-store
dvc remote modify myremote region us-east-1
git commit -m "Initialise DVC with S3 remote"
# ── Track a dataset ──────────────────────────────────────────────────────────
dvc add data/raw/train.csv
git add data/raw/train.csv.dvc data/raw/.gitignore
git commit -m "Track raw training data v1"
dvc push # upload to S3
# ── On a new machine ─────────────────────────────────────────────────────────
git clone https://github.com/org/my-ml-project
dvc pull # download the data pinned by the commit
Defining a pipeline as a DAG in dvc.yaml:
# dvc.yaml
stages:
preprocess:
cmd: python src/preprocess.py --input data/raw/train.csv --output data/processed/
deps:
- src/preprocess.py
- data/raw/train.csv
outs:
- data/processed/train_features.parquet
- data/processed/val_features.parquet
train:
cmd: python src/train.py --data data/processed/ --model models/model.pkl
deps:
- src/train.py
- data/processed/train_features.parquet
- data/processed/val_features.parquet
params:
- params.yaml:
- model.n_estimators
- model.max_depth
- training.learning_rate
outs:
- models/model.pkl
metrics:
- metrics/eval.json:
cache: false
evaluate:
cmd: python src/evaluate.py --model models/model.pkl --data data/processed/val_features.parquet
deps:
- src/evaluate.py
- models/model.pkl
- data/processed/val_features.parquet
metrics:
- metrics/eval.json:
cache: false
dvc repro # only re-runs stages whose inputs changed
dvc metrics show # display current metrics
dvc metrics diff HEAD~1 # compare metrics against previous commit
Container Fundamentals for ML¶
Multi-Stage Dockerfile for a Python ML App¶
Multi-stage builds separate the build environment (with compilers and dev dependencies) from the lean runtime environment. This keeps production images small and avoids shipping build tools.
# ── Stage 1: builder ─────────────────────────────────────────────────────────
FROM python:3.11-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# Install into /install prefix so they can be copied in isolation
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM python:3.11-slim AS runtime
# Copy only the installed packages — no build tools
COPY --from=builder /install /usr/local
WORKDIR /app
COPY src/ ./src/
COPY models/ ./models/
# Security: run as non-root
RUN useradd --create-home --shell /bin/bash appuser
USER appuser
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"
EXPOSE 8080
ENTRYPOINT ["python", "-m", "src.serve"]
CMD ["--host", "0.0.0.0", "--port", "8080", "--workers", "4"]
GPU-Enabled Base Image (CUDA)¶
For LLM inference and deep learning training, base on NVIDIA's official CUDA images. The cudnn-runtime variant includes cuDNN without training-time compiler tools.
# GPU runtime image: CUDA 12.1 + cuDNN 8 on Ubuntu 22.04
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV TRANSFORMERS_CACHE=/app/.cache/huggingface
RUN apt-get update && apt-get install -y --no-install-recommends \
python3.11 \
python3.11-dev \
python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& ln -s /usr/bin/python3.11 /usr/local/bin/python
WORKDIR /app
COPY requirements-gpu.txt .
RUN pip install --no-cache-dir -r requirements-gpu.txt
COPY src/ ./src/
COPY models/ ./models/
# Expose only GPU 0 by default; override at runtime
ENV CUDA_VISIBLE_DEVICES=0
EXPOSE 8080
ENTRYPOINT ["python", "src/serve_gpu.py"]
# Build
DOCKER_BUILDKIT=1 docker build --target runtime -t my-ml-app:latest .
# Run with GPU pass-through (requires nvidia-container-toolkit)
docker run --gpus all -p 8080:8080 my-ml-app:latest
# Verify GPU access inside the container
docker run --gpus all my-ml-app:latest \
python -c "import torch; print(f'CUDA available: {torch.cuda.is_available()}')"
Kubernetes for ML¶
Pod Spec with GPU Resource Limits¶
# llm-inference-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: llm-inference
labels:
app: llm-inference
spec:
containers:
- name: inference-server
image: my-registry/llm-serve:1.2.0
ports:
- containerPort: 8080
resources:
requests:
cpu: "4"
memory: "16Gi"
nvidia.com/gpu: "1"
limits:
cpu: "8"
memory: "32Gi"
nvidia.com/gpu: "1"
env:
- name: MODEL_ID
value: "meta-llama/Llama-3-8B-Instruct"
- name: MAX_BATCH_SIZE
value: "32"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 60
periodSeconds: 15
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
nodeSelector:
accelerator: nvidia-a100
tolerations:
- key: nvidia.com/gpu
operator: Exists
effect: NoSchedule
Deployment and Service¶
# llm-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-inference
namespace: ml-serving
spec:
replicas: 2
selector:
matchLabels:
app: llm-inference
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # zero-downtime rolling update
template:
metadata:
labels:
app: llm-inference
spec:
containers:
- name: inference-server
image: my-registry/llm-serve:1.2.0
resources:
requests:
cpu: "4"
memory: "16Gi"
nvidia.com/gpu: "1"
limits:
cpu: "8"
memory: "32Gi"
nvidia.com/gpu: "1"
---
apiVersion: v1
kind: Service
metadata:
name: llm-inference-svc
namespace: ml-serving
spec:
selector:
app: llm-inference
ports:
- name: http
port: 80
targetPort: 8080
type: ClusterIP
Horizontal Pod Autoscaler¶
# llm-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: llm-inference-hpa
namespace: ml-serving
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: llm-inference
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "100"
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 2
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 120
Cloud ML Platform Comparison¶
| Capability | AWS SageMaker | Google Vertex AI | Azure ML |
|---|---|---|---|
| Managed training jobs | SageMaker Training | Custom Training Jobs | Compute Clusters |
| Spot / preemptible | Managed Spot Training | Preemptible VMs | Low-Priority Compute |
| Distributed training | SageMaker Distributed (data + model parallel) | Vertex AI Distributed | DeepSpeed / Horovod |
| Model registry | SageMaker Model Registry | Vertex AI Model Registry | Azure ML Model Registry |
| Real-time endpoints | SageMaker Real-time Inference | Vertex AI Online Prediction | Azure ML Online Endpoints |
| Batch transform | SageMaker Batch Transform | Vertex AI Batch Prediction | Azure ML Batch Endpoints |
| Feature store | SageMaker Feature Store | Vertex AI Feature Store | Azure ML (limited) |
| Experiment tracking | SageMaker Experiments | Vertex AI Experiments | Native MLflow integration |
| AutoML | SageMaker Autopilot | Vertex AI AutoML | Azure AutoML |
| Pipeline orchestration | SageMaker Pipelines | Vertex AI Pipelines (Kubeflow) | Azure ML Pipelines |
| LLM fine-tuning | SageMaker JumpStart | Vertex AI Model Garden | Azure AI Studio |
| Pricing model | Per-second compute + storage | Per-second compute + storage | Per-minute compute + storage |
When to use a managed platform
Choose a managed cloud platform when your team lacks Kubernetes expertise, when deep integration with cloud data services is required, or when compliance controls (VPC, IAM, encryption at rest) are mandated. Choose self-hosted (Kubernetes + MLflow + Feast) when multi-cloud flexibility, avoiding vendor lock-in, or raw cost optimisation is the priority.
Exercises¶
-
MLflow sweep: Run a nine-run hyperparameter grid over
n_estimators∈ {100, 200, 400} andmax_depth∈ {3, 5, 7} using thetrain_and_trackfunction from this chapter. Usemlflow.search_runs()to programmatically find the run with the highestroc_aucand transition it to theProductionstage in the model registry. -
W&B Bayesian sweep: Define a W&B sweep with at least five hyperparameters (include at least one log-uniform continuous parameter). Run 20 agents and use the W&B parallel coordinates plot to identify the top-3 most influential parameters. Write a 200-word interpretation of your findings.
-
Feature store simulation: Without installing Feast, implement a
PointInTimeFeatureStoreclass in Python. It should store features as apd.DataFramekeyed by(entity_id, timestamp)and implementget_historical_features(entity_df: pd.DataFrame) -> pd.DataFramewith correct point-in-time semantics usingpd.merge_asof. -
DVC pipeline: Convert an existing three-step ML workflow (preprocess → train → evaluate) into a DVC pipeline. Add a
params.yamlfor two hyperparameters. Verify that (a)dvc reprois a no-op when nothing changes, (b) changing a parameter re-runs only downstream stages, and ©dvc metrics diffshows the metric change. -
Kubernetes HPA design: Write a Kubernetes Deployment, Service, and HPA spec for a BERT-based classification model. The HPA should target p95 latency < 200 ms (assume a custom metrics adapter that exposes
http_p95_latency_ms). Set appropriateminReplicas,maxReplicas,scaleUp, andscaleDownbehaviour, and justify each value.
Summary¶
This chapter established the infrastructure layer that underpins reliable ML systems:
- Experiment tracking with MLflow (open-source, self-hosted) and W&B (cloud-native, richer sweep tooling) prevents the "which run was that?" problem and supports systematic hyperparameter exploration.
- Feature stores with dual offline/online architectures solve training-serving skew and guarantee point-in-time correctness in training data — the single most important correctness guarantee in production ML.
- DVC treats data files and pipelines as versioned artefacts, enabling any experiment to be reproduced from a Git commit hash alone.
- Multi-stage Docker images with CUDA-enabled GPU base images package ML workloads into portable, production-safe containers.
- Kubernetes provides the orchestration layer for scaling, rolling updates, GPU scheduling, and resource isolation — essential for multi-tenant ML serving.
- Cloud ML platforms (SageMaker, Vertex AI, Azure ML) offer managed versions of this infrastructure at the cost of reduced flexibility and potential vendor lock-in.
Next: Ch 2 — Model Deployment