Ch 3 — ML Monitoring & Observability¶
Chapter Meta
Level: Advanced | Reading time: 75 min | Volume: 9 — MLOps
Learning Objectives¶
By the end of this chapter you will be able to:
- Explain why ML monitoring requires fundamentally different practices from traditional software monitoring, specifically regarding silent degradation and delayed ground truth.
- Implement PSI, Kolmogorov-Smirnov, and chi-squared tests to detect data drift in production feature distributions.
- Monitor LLM-specific quality metrics (hallucination rate, faithfulness, toxicity, cost per query) using Langfuse tracing and LLM-as-judge scoring.
- Configure Prometheus alert rules and Alertmanager routing for infrastructure and drift metrics.
- Design a composite retraining trigger policy combining scheduled, drift-triggered, and performance-triggered strategies.
Why ML Monitoring Differs from Software Monitoring¶
Traditional software fails loudly: an exception is thrown, an error code is returned, and a monitoring dashboard turns red. ML systems fail silently:
- No clear error signal: A model that classifies 60% of fraud correctly (down from 85%) still returns valid HTTP 200 responses. Nothing breaks — the model is simply wrong more often.
- Silent degradation: Distribution shifts accumulate gradually. By the time business impact is measurable, weeks of damage have occurred.
- Delayed labels: The ground truth for a loan default may arrive 60 days after the prediction. Accuracy cannot be computed in real time; only proxy metrics are available immediately.
Timeline for a loan default model:
Day 0 → model predicts "no default" (confidence 0.82)
Day 5 → first missed payment (observable signal)
Day 60 → loan formally defaulted (ground-truth label)
Day 90 → model retrained — 3 months of degraded predictions affected decisions
Effective ML monitoring bridges the gap between deployment and delayed ground truth using proxy metrics, statistical drift detection, and LLM-as-judge quality scoring.
Infrastructure Metrics¶
Before monitoring model quality, instrument the serving layer. Infrastructure failures are detectable immediately and affect all users simultaneously.
| Metric | Description | Target threshold |
|---|---|---|
| Latency p50 | Median end-to-end response time | < 300 ms for LLM TTFT |
| Latency p95 | 95th percentile response time | < 2 s SLO (typical) |
| Latency p99 | 99th percentile response time | < 5 s (long-tail budget) |
| Throughput (req/s) | Requests per second served | Capacity-plan dependent |
| GPU utilisation | % GPU compute in use | 60–80% (headroom for spikes) |
| GPU memory utilisation | % VRAM occupied | < 90% (headroom for KV cache) |
| Error rate | % of requests with 4xx/5xx status | < 0.1% |
| Cost per query | USD per API request | Budget-dependent |
Expose these from FastAPI using prometheus-fastapi-instrumentator:
from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator
from prometheus_client import Counter, Gauge, Histogram
app = FastAPI()
# Custom LLM-specific counters
token_counter = Counter(
"llm_tokens_total",
"Total tokens processed",
labelnames=["model", "direction"], # direction: input | output
)
cost_histogram = Histogram(
"llm_request_cost_usd",
"Cost per LLM request in USD",
buckets=[0.0001, 0.001, 0.01, 0.05, 0.1, 0.5, 1.0],
)
gpu_utilisation_gauge = Gauge("gpu_utilisation_percent", "GPU utilisation %")
# Instrument all HTTP endpoints automatically
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
Data Drift Detection¶
Data drift occurs when the distribution of model inputs in production diverges from the training distribution. It does not require labels to detect.
PSI — Population Stability Index¶
PSI is the most widely used metric for monitoring numeric feature distributions in production ML, especially in financial services.
Where \(E_i\) is the proportion in bin \(i\) in the reference (training) distribution and \(A_i\) is the proportion in the current (production) distribution.
Interpretation thresholds:
| PSI | Interpretation | Recommended action |
|---|---|---|
| < 0.10 | No significant shift | Monitor normally |
| 0.10 – 0.20 | Moderate shift | Investigate feature pipeline; increase monitoring frequency |
| > 0.20 | Significant shift | Investigate immediately; consider model refresh |
"""
drift_detection.py — Statistical drift tests for ML monitoring.
Requirements:
pip install numpy scipy pandas
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from scipy import stats
def compute_psi(
expected: np.ndarray,
actual: np.ndarray,
n_bins: int = 10,
epsilon: float = 1e-6,
) -> float:
"""
Compute Population Stability Index between two 1-D numeric distributions.
Args:
expected: Reference distribution (e.g., training feature values).
actual: Current distribution (e.g., production feature values).
n_bins: Number of quantile-based bins.
epsilon: Smoothing term to avoid log(0).
Returns:
PSI value (float). Threshold: < 0.1 stable, 0.1-0.2 moderate, > 0.2 significant.
"""
bin_edges = np.percentile(expected, np.linspace(0, 100, n_bins + 1))
bin_edges[0] -= epsilon
bin_edges[-1] += epsilon
expected_counts, _ = np.histogram(expected, bins=bin_edges)
actual_counts, _ = np.histogram(actual, bins=bin_edges)
e_pct = expected_counts / len(expected) + epsilon
a_pct = actual_counts / len(actual) + epsilon
psi = float(np.sum((a_pct - e_pct) * np.log(a_pct / e_pct)))
return psi
KS Test for Continuous Features¶
The two-sample Kolmogorov-Smirnov test measures the maximum absolute difference between two empirical CDFs. It is non-parametric and distribution-free.
def ks_drift_test(
reference: np.ndarray,
current: np.ndarray,
alpha: float = 0.05,
) -> dict:
"""
Two-sample KS test for distribution shift on a continuous feature.
Returns:
dict with statistic, p_value, and drift_detected flag.
"""
result = stats.ks_2samp(reference, current)
return {
"statistic": float(result.statistic),
"p_value": float(result.pvalue),
"drift_detected": bool(result.pvalue < alpha),
"interpretation": (
"Distributions differ significantly" if result.pvalue < alpha
else "No significant difference detected"
),
}
Chi-Squared Test for Categorical Features¶
For categorical features, the chi-squared test detects whether the observed category frequencies differ significantly from the expected (reference) frequencies.
from scipy.stats import chi2_contingency
def chi2_drift_test(
reference_series: pd.Series,
current_series: pd.Series,
alpha: float = 0.05,
) -> dict:
"""
Chi-squared test for distribution shift on a categorical feature.
Args:
reference_series: Categorical values from the reference period.
current_series: Categorical values from the current period.
alpha: Significance level (default 5%).
Returns:
dict with chi2 statistic, p_value, degrees of freedom, drift_detected.
"""
categories = set(reference_series.unique()) | set(current_series.unique())
ref_counts = reference_series.value_counts().reindex(categories, fill_value=0)
cur_counts = current_series.value_counts().reindex(categories, fill_value=0)
contingency = pd.DataFrame({"reference": ref_counts, "current": cur_counts})
chi2, p_value, dof, _ = chi2_contingency(contingency.T)
return {
"chi2_statistic": float(chi2),
"p_value": float(p_value),
"degrees_of_freedom": int(dof),
"drift_detected": bool(p_value < alpha),
}
Drift Monitoring with Evidently AI¶
Evidently AI provides pre-built drift tests and HTML reports for the full feature set:
import pandas as pd
from evidently.metric_preset import DataDriftPreset, DataQualityPreset
from evidently.report import Report
reference_data = pd.read_parquet("data/reference_features.parquet")
production_data = pd.read_parquet("data/production_week_24.parquet")
report = Report(metrics=[DataDriftPreset(), DataQualityPreset()])
report.run(reference_data=reference_data, current_data=production_data)
report.save_html("drift_report_week_24.html")
result = report.as_dict()
drifted = [
m["result"]["column_name"]
for m in result["metrics"]
if m.get("result", {}).get("drift_detected", False)
]
print(f"Drifted features ({len(drifted)}): {drifted}")
if len(drifted) >= 3:
trigger_retraining_pipeline()
Concept Drift¶
Concept drift occurs when the statistical relationship between inputs and the correct output changes — the model's mapping becomes stale even if input distributions remain stable.
Outcome Drift (Target Distribution Changes)¶
If the base rate of positive labels changes (e.g., fraud becomes more common due to a new attack vector), a model calibrated for the old rate will be systematically miscalibrated.
Monitor the distribution of model output scores (not just accuracy) as a proxy:
def detect_prediction_score_drift(
reference_scores: np.ndarray,
current_scores: np.ndarray,
alpha: float = 0.05,
) -> dict:
"""
Detect drift in the distribution of model prediction probabilities.
A shift here indicates concept drift even without ground-truth labels.
"""
return ks_drift_test(reference_scores, current_scores, alpha=alpha)
Delayed Label Collection Problem¶
| Strategy | Latency | Cost | Accuracy |
|---|---|---|---|
| Proxy labels (e.g., click = implicit positive) | Immediate | Low | Noisy |
| Human spot-checking (1–5% sample) | Same day | High | High |
| LLM-as-judge | Minutes | Medium | Medium-High |
| A/B testing with outcome metrics | Weeks | Low | High |
| Weak supervision (Snorkel) | Hours | Low | Medium |
Surrogate Metrics¶
When ground-truth labels are unavailable, use leading indicators that correlate with future model quality:
- Prediction confidence distribution: a sharp drop in mean confidence often precedes accuracy degradation.
- Feature anomaly rate: % of requests where any feature is outside the training range.
- Output length distribution (for LLMs): sudden shifts in token count can indicate prompt or context drift.
LLM-Specific Monitoring¶
LLMs exhibit failure modes — hallucination, factual drift, instruction non-compliance — that accuracy metrics do not capture.
| Metric | Description | Measurement |
|---|---|---|
| Hallucination rate | % outputs containing unsupported factual claims | LLM-as-judge + human audit |
| Faithfulness | Fraction of answer grounded in retrieved context | LLM-as-judge (0–1 scale) |
| Relevance score | How well the answer addresses the question | LLM-as-judge |
| Toxicity | % outputs containing harmful/offensive content | Classifier (e.g., detoxify) |
| Cost per query | USD spent per request | usage.input_tokens × price + usage.output_tokens × price |
| Latency per token | ms / output token | latency_ms / tokens_generated |
Langfuse for LLM Observability¶
Langfuse is an open-source observability platform for LLM applications providing distributed tracing, quality scoring, cost tracking, and dashboards.
"""
llm_observability.py — LLM tracing and quality monitoring with Langfuse.
Requirements:
pip install langfuse anthropic
"""
from __future__ import annotations
import json
import os
import anthropic
from langfuse import Langfuse
from langfuse.decorators import langfuse_context, observe
langfuse = Langfuse(
public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
secret_key=os.environ["LANGFUSE_SECRET_KEY"],
host=os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com"),
)
client = anthropic.Anthropic()
FAITHFULNESS_PROMPT = """\
You evaluate whether an AI answer is faithfully grounded in the provided context.
Context:
{context}
Question: {question}
Answer: {answer}
Rate faithfulness 0.0–1.0 (1.0 = fully grounded, 0.0 = fabricated).
Respond with JSON only: {{"faithfulness": <float>, "reason": "<brief>"}}
"""
@observe(name="rag_query")
def rag_with_observability(question: str, context: str) -> str:
"""
Execute a RAG query with full Langfuse tracing and quality scoring.
Traces: input/output, token usage, cost, model
Scores: faithfulness (LLM-as-judge), latency_per_token
"""
langfuse_context.update_current_trace(
name="RAG Pipeline",
input={"question": question, "context_length": len(context)},
tags=["rag", "production"],
)
# ── Generation ────────────────────────────────────────────────────────────
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=512,
messages=[
{
"role": "user",
"content": (
f"Context:\n{context}\n\n"
f"Question: {question}\n\n"
"Answer using only the provided context. "
"If the context does not contain the answer, say so."
),
}
],
)
answer = response.content[0].text
# ── Faithfulness scoring (LLM-as-judge) ──────────────────────────────────
judge_response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=128,
messages=[
{
"role": "user",
"content": FAITHFULNESS_PROMPT.format(
context=context, question=question, answer=answer
),
}
],
)
faithfulness_score = 0.5
try:
score_data = json.loads(judge_response.content[0].text)
faithfulness_score = float(score_data.get("faithfulness", 0.5))
except (json.JSONDecodeError, KeyError, ValueError):
pass
# ── Cost tracking ─────────────────────────────────────────────────────────
input_cost = response.usage.input_tokens * 0.80 / 1_000_000 # $/token (Haiku)
output_cost = response.usage.output_tokens * 4.00 / 1_000_000
total_cost_usd = input_cost + output_cost
# ── Log scores to Langfuse ────────────────────────────────────────────────
langfuse_context.score_current_trace(
name="faithfulness",
value=faithfulness_score,
comment=f"cost_usd={total_cost_usd:.6f}",
)
langfuse_context.update_current_trace(
output={"answer": answer},
metadata={
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
"cost_usd": total_cost_usd,
},
)
return answer
Alerting¶
Prometheus + Grafana Setup¶
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "alerts/ml_alerts.yml"
scrape_configs:
- job_name: llm-serving
static_configs:
- targets: ["llm-server:8080"]
metrics_path: /metrics
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
Alert Rules in YAML¶
# alerts/ml_alerts.yml
groups:
- name: ml-serving
rules:
- alert: HighErrorRate
expr: |
rate(http_requests_total{status=~"5.."}[5m]) /
rate(http_requests_total[5m]) > 0.01
for: 2m
labels:
severity: critical
team: ml-platform
annotations:
summary: "Error rate > 1% for 2 minutes on {{ $labels.job }}"
runbook: "https://wiki.example.com/runbooks/ml-high-error-rate"
- alert: HighP95Latency
expr: |
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 2.0
for: 5m
labels:
severity: warning
annotations:
summary: "p95 latency > 2 s for 5 minutes"
- alert: DataDriftDetected
expr: ml_psi_score{} > 0.2
for: 1m
labels:
severity: warning
annotations:
summary: "PSI > 0.2 on feature {{ $labels.feature }} — possible data drift"
- alert: LowFaithfulnessScore
expr: avg_over_time(llm_faithfulness_score[30m]) < 0.7
for: 10m
labels:
severity: warning
annotations:
summary: "Mean faithfulness < 0.7 — possible hallucination regression"
- alert: HighGPUMemoryPressure
expr: gpu_memory_used_bytes / gpu_memory_total_bytes > 0.92
for: 5m
labels:
severity: critical
annotations:
summary: "GPU memory > 92% — OOM risk"
PagerDuty Integration Pattern¶
# alertmanager.yml
route:
group_by: ["alertname", "severity"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: "slack-default"
routes:
- match:
severity: critical
receiver: "pagerduty-ml-oncall"
continue: true
receivers:
- name: "slack-default"
slack_configs:
- api_url: "$SLACK_WEBHOOK_URL"
channel: "#ml-alerts"
title: "{{ .GroupLabels.alertname }}"
text: "{{ range .Alerts }}{{ .Annotations.summary }}{{ end }}"
- name: "pagerduty-ml-oncall"
pagerduty_configs:
- routing_key: "$PAGERDUTY_ROUTING_KEY"
description: "{{ .GroupLabels.alertname }}: {{ .CommonAnnotations.summary }}"
severity: "{{ .GroupLabels.severity }}"
details:
runbook: "{{ .CommonAnnotations.runbook }}"
Retraining Triggers¶
No single trigger strategy is sufficient. Combine all three:
Scheduled Retraining¶
Retrain on a fixed cadence regardless of observed drift. Simple; provides a guaranteed freshness floor.
# Example: weekly retraining every Sunday at 03:00 UTC
# In Kubernetes, use a CronJob:
# schedule: "0 3 * * 0"
Drift-Triggered (PSI Threshold)¶
def should_retrain_on_drift(
reference_data: pd.DataFrame,
current_data: pd.DataFrame,
psi_threshold: float = 0.2,
n_features_to_trigger: int = 3,
) -> tuple[bool, list[str]]:
"""
Trigger retraining when PSI exceeds threshold for at least
`n_features_to_trigger` numeric features.
Returns:
(trigger_retraining, list_of_drifted_features)
"""
drifted: list[str] = []
for col in reference_data.select_dtypes(include="number").columns:
psi = compute_psi(reference_data[col].values, current_data[col].values)
if psi > psi_threshold:
drifted.append(col)
return len(drifted) >= n_features_to_trigger, drifted
Performance-Triggered (Metric Threshold)¶
def should_retrain_on_performance(
recent_metric: float,
baseline_metric: float,
relative_drop_threshold: float = 0.05,
metric_name: str = "accuracy",
) -> bool:
"""
Trigger retraining when a quality metric drops by more than
`relative_drop_threshold` relative to baseline.
Args:
recent_metric: Current period metric value.
baseline_metric: Reference (post-last-training) metric value.
relative_drop_threshold: Fraction drop to trigger retraining (default 5%).
Returns:
True if retraining should be triggered.
"""
relative_drop = (baseline_metric - recent_metric) / (baseline_metric + 1e-9)
if relative_drop > relative_drop_threshold:
print(
f"RETRAIN: {metric_name} dropped {relative_drop:.1%} "
f"({baseline_metric:.4f} → {recent_metric:.4f})"
)
return True
return False
Combined trigger policy
Run drift-triggered checks daily. Run performance-triggered checks as soon as labels arrive (even partial). Keep scheduled retraining as a weekly backstop. Any one trigger is sufficient to initiate the retraining pipeline.
Exercises¶
-
PSI monitoring pipeline: Write a script that (a) loads a reference feature distribution from a Parquet file, (b) loads 12 weekly production snapshots, © computes PSI for each numeric feature per week, (d) plots a PSI time series heatmap (weeks × features), and (e) prints an alert for any week where PSI > 0.2 for three or more features.
-
LLM faithfulness + relevance monitor: Extend
rag_with_observabilityto also score relevance (does the answer address the question?) using a second LLM-as-judge call. Log both scores to Langfuse and plot the distribution of faithfulness and relevance scores over 100 test queries. -
Prometheus alert: Write a Prometheus alert rule that fires when the 30-minute rolling mean of
llm_faithfulness_scoredrops below 0.70. Write the Alertmanager route to send a Slack message to#ml-qualityand a PagerDuty alert for severitycritical. Test the alert by pushing a low score viaprometheus_client. -
Retraining policy simulation: Simulate 26 weekly snapshots with linearly increasing PSI (0.05 → 0.35). Implement the three trigger strategies from this chapter. Compare: number of retraining events, weeks of degraded model operation (PSI > 0.2), and compute cost (assume 10 GPU-hours per retraining) across all three strategies.
-
Evidently automated report: Use Evidently AI to build a weekly monitoring job that (a) loads the current week's production features, (b) runs drift detection against the reference, © saves the HTML report to S3, and (d) sends a summary email via
smtpliblisting the drifted features and their PSI values.
Summary¶
- ML monitoring differs fundamentally from software monitoring: silent degradation, no clear error signal, and delayed labels mean that standard alerting on exceptions misses most ML quality regressions.
- Infrastructure metrics (p50/p95/p99 latency, throughput, GPU utilisation, error rate) should be instrumented first — they fail loudly and are immediately actionable.
- Data drift detection uses PSI (\(< 0.1\) stable, \(0.1\)–\(0.2\) moderate, \(> 0.2\) significant) for numeric features, KS test for distributional comparison, and chi-squared for categorical features — all implementable without ground-truth labels.
- Concept drift manifests as changes in outcome distributions or prediction confidence; it is detected through surrogate metrics, LLM-as-judge scoring, or delayed ground-truth comparison.
- LLM-specific monitoring — faithfulness, hallucination rate, toxicity, cost per query, latency per token — requires an LLM-as-judge pipeline; Langfuse provides the tracing infrastructure to make this operationally sustainable.
- Retraining triggers should combine scheduled (freshness guarantee), drift-triggered (proactive), and performance-triggered (outcome-based) strategies — each catches a different class of model degradation.