Monitoring a deployed system for drift: Prediction-Powered Risk Monitoring¶
EmpiricalPPIMeanMonitor watches a quality metric across successive batches of production data and raises an alarm the moment there is statistically valid evidence that it has drifted past a threshold you fix in advance. This is necessary because a deployed system's quality is rarely static: traffic shifts and upstream models change, so a metric that looked fine at launch can drift silently, and a single-shot estimate made at first deployment time cannot catch that.
What you will learn:
- Why re-checking a fresh confidence interval multiple times inflates the false-alarm rate
- How
EmpiricalPPIMeanMonitorgives an anytime-valid alternative that stays safe under repeated checks - How to read its running-mean curve, its confidence bound, and its alarms
The problem: the metric can drift after launch, and repeated checking breaks validity¶
Suppose you have a customer-facing AI assistant that handles an open-ended troubleshooting flow, and hallucinations are a real risk there. Every week, a fresh batch of conversations is judged by your LLM judge, and a handful are sent for human review. Your team has fixed a business threshold on the hallucination rate, and wants to know as soon as the rate has drifted past it.
The tempting approach is to compute a fresh confidence interval every week and raise an alarm the moment one crosses the threshold. This is peeking: each weekly test carries its own false-alarm probability, and checking after every batch compounds those chances, so a false alarm becomes likely over a long enough monitoring horizon even if nothing has actually drifted. The Monitors user guide explains why this happens and how EmpiricalPPIMeanMonitor, an anytime-valid confidence sequence that stays safe no matter how often you look, avoids it. This tutorial focuses on what that means for you in practice.
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
from glide.estimators import PPIMeanEstimator
from glide.monitors import EmpiricalPPIMeanMonitor
from glide.samplers import UniformSampler
from glide.simulators import generate_stratified_binary_dataset, simulate_annotation
# ── Colour palette ──────────────────────────────────────────
C_ESTIMATE = "#95A5A6" # per-batch point estimate — grey
C_NAIVE = "#E74C3C" # naive baseline alarm — red-orange
C_MONITOR = "#27AE60" # monitor running mean — green
C_ALARM = "#8E44AD" # monitor alarm — purple
C_THRESHOLD = "#2C3E50" # threshold — dark slate
# ── Global plot style ───────────────────────────────────────
plt.rcParams.update(
{
"figure.facecolor": "white",
"axes.facecolor": "#FAFAFA",
"axes.grid": True,
"grid.color": "#E5E5E5",
"grid.linewidth": 0.8,
"font.size": 18,
"axes.labelsize": 18,
"axes.titlesize": 18,
"legend.fontsize": 14,
"xtick.labelsize": 16,
"ytick.labelsize": 16,
"figure.titlesize": 19,
}
)
Simulating a batched production stream¶
We use generate_stratified_binary_dataset to simulate successive batches of conversations, each with its own true hallucination rate, proxy rate, and correlation, stacked into a single stream with an integer identifying the chronological batch each row belongs to.
simulate_batches below builds such a stream from a list of per-week true hallucination rates. Every week gets BATCH_SIZE proxy-judged conversations, of which LABELS_PER_BATCH are sent for human review: we use the UniformSampler to simulate uniform allocation of the review budget at each week. The human labels are revealed only for selected conversations, leaving the rest as np.nan. We fix an alarm threshold above the stationary rate and assess the monitoring methods' ability to detect when it is exceeded while avoiding false alarms. All batches in the stream are monitored.
N_BATCHES = 50
BATCH_SIZE = 100
LABELS_PER_BATCH = 20
STATIONARY_MEAN = 0.28
PROXY_BIAS = 0.1
CORRELATION = 0.7
CONFIDENCE_LEVEL = 0.7
THRESHOLD = 0.3
def simulate_batches(true_means, random_seed):
proxy_means = true_means + PROXY_BIAS
correlations = np.full(len(true_means), CORRELATION)
y_true_oracle, y_proxy, batches = generate_stratified_binary_dataset(
n_total=np.full(len(true_means), BATCH_SIZE),
true_mean=true_means,
proxy_mean=proxy_means,
correlation=correlations,
random_seed=random_seed,
)
xi = np.hstack(
[UniformSampler().sample(BATCH_SIZE, LABELS_PER_BATCH, random_seed=random_seed + i) for i in range(N_BATCHES)]
)
y_true = simulate_annotation(y_true_oracle, xi)
return y_true, y_proxy, batches
The naive baseline: one confidence interval per batch¶
The tempting-but-invalid approach simply calls PPIMeanEstimator once per week, comparing that week's confidence interval to the threshold and alarming whenever the interval lies above it.
def naive_per_batch_alarms(y_true, y_proxy, batches, threshold, n_batches):
means = np.zeros(n_batches)
lower_bounds = np.zeros(n_batches)
alarms = np.zeros(n_batches, dtype=bool)
for batch_id in range(n_batches):
batch_mask = batches == batch_id
batch_result = PPIMeanEstimator().estimate(y_true[batch_mask], y_proxy[batch_mask])
means[batch_id] = batch_result.mean
lower_bounds[batch_id] = batch_result.confidence_interval.lower_bound
alarms[batch_id] = batch_result.confidence_interval.lower_bound > threshold
return means, lower_bounds, alarms
def plot_monitor_vs_baseline(batch_means, monitor_result, naive_alarms, title):
weeks = np.arange(1, len(batch_means) + 1)
fig, ax = plt.subplots(figsize=(10, 5.5))
ax.scatter(weeks, batch_means, color=C_ESTIMATE, s=28, alpha=0.8, zorder=3, label="Per-batch estimate")
ax.plot(weeks, monitor_result.running_means, color=C_MONITOR, linewidth=2.5, zorder=4, label="Monitor running mean")
ax.plot(
weeks,
monitor_result.confidence_bounds,
color=C_MONITOR,
linewidth=2.5,
linestyle="--",
zorder=4,
label="Monitor lower bound",
)
ax.axhline(THRESHOLD, color=C_THRESHOLD, linestyle="--", linewidth=1.8, zorder=2, label="Threshold")
if naive_alarms.any():
ax.scatter(
weeks[naive_alarms],
batch_means[naive_alarms],
marker="x",
s=110,
linewidths=2.5,
color=C_NAIVE,
zorder=5,
label="Naive baseline alarm",
)
if monitor_result.drift_detected:
alarm_week = monitor_result.first_alarm_index + 1
alarm_label = f"Monitor alarm (week {alarm_week})"
ax.axvline(alarm_week, color=C_ALARM, linestyle=":", linewidth=2.2, zorder=2, label=alarm_label)
ax.set_xlabel("Week")
ax.set_ylabel("Hallucination Rate")
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0%}"))
ax.set_title(title)
ax.set_ylim(-0.05, 1.0)
ax.spines[["top", "right"]].set_visible(False)
ax.legend(loc="upper left", framealpha=0.9)
plt.tight_layout()
plt.show()
Scenario 1: no drift, no false alarm¶
We generate a fully stationary stream: the true hallucination rate stays below the threshold. Neither method should alarm, except by chance.
true_means_no_drift = np.full(N_BATCHES, STATIONARY_MEAN)
y_true_no_drift, y_proxy_no_drift, batches_no_drift = simulate_batches(true_means_no_drift, random_seed=7)
n_labeled = np.sum(~np.isnan(y_true_no_drift))
print(f"Total conversations : {len(y_true_no_drift):,}")
print(f" Human-reviewed : {n_labeled:,}")
print(f" Number of weeks : {len(np.unique(batches_no_drift))}")
Total conversations : 5,000 Human-reviewed : 1,000 Number of weeks : 50
Run the two monitoring methods.
means_no_drift, lower_no_drift, naive_alarms_no_drift = naive_per_batch_alarms(
y_true_no_drift, y_proxy_no_drift, batches_no_drift, THRESHOLD, N_BATCHES
)
monitor_result_no_drift = EmpiricalPPIMeanMonitor().detect(
y_true_no_drift,
y_proxy_no_drift,
batches_no_drift,
higher_is_better=False,
threshold=THRESHOLD,
confidence_level=CONFIDENCE_LEVEL,
metric_name="Hallucination Rate",
)
naive_alarm_weeks = np.arange(1, N_BATCHES + 1)[naive_alarms_no_drift]
print(f"Naive baseline alarms at week(s): {naive_alarm_weeks}")
print(f"Monitor drift detected: {monitor_result_no_drift.drift_detected}")
print(
f"Monitor running mean / bound after {N_BATCHES} weeks: {monitor_result_no_drift.running_means[-1]:.1%} / "
f"{monitor_result_no_drift.confidence_bounds[-1]:.1%}"
)
Naive baseline alarms at week(s): [11 16 21 50] Monitor drift detected: False Monitor running mean / bound after 50 weeks: 29.9% / 24.8%
plot_monitor_vs_baseline(
means_no_drift,
monitor_result_no_drift,
naive_alarms_no_drift,
title="Scenario 1: stationary stream (no drift)",
)
At some point, ordinary sampling noise pushes one of the confidence intervals above the threshold, and the naive baseline raises false alarms, purely from peeking: with a fresh, independent-looking test every week, the chance of at least one false alarm climbs the longer you keep checking, even though the hallucination rate never moved. EmpiricalPPIMeanMonitor, in contrast, never alarms across the full stream: its running mean stays below the threshold, with an anytime-valid lower bound safely underneath it as well.
Scenario 2: slow drift, early alarm¶
Now we generate data with a progressive drift: partway through the stream, the hallucination rate ramps up to an unacceptably high level.
DRIFT_START = 15
DRIFT_AMPLITUDE = 0.35
RAMP_LENGTH = 30
true_means_slow_drift = np.hstack(
[
np.full(DRIFT_START, STATIONARY_MEAN),
np.linspace(STATIONARY_MEAN, STATIONARY_MEAN + DRIFT_AMPLITUDE, RAMP_LENGTH),
np.full(N_BATCHES - RAMP_LENGTH - DRIFT_START, STATIONARY_MEAN + DRIFT_AMPLITUDE),
]
)
y_true_slow_drift, y_proxy_slow_drift, batches_slow_drift = simulate_batches(true_means_slow_drift, random_seed=0)
means_slow_drift, lower_slow_drift, naive_alarms_slow_drift = naive_per_batch_alarms(
y_true_slow_drift, y_proxy_slow_drift, batches_slow_drift, THRESHOLD, N_BATCHES
)
monitor_result_slow_drift = EmpiricalPPIMeanMonitor().detect(
y_true_slow_drift,
y_proxy_slow_drift,
batches_slow_drift,
higher_is_better=False,
threshold=THRESHOLD,
confidence_level=CONFIDENCE_LEVEL,
metric_name="Hallucination Rate",
)
naive_alarm_weeks = np.flatnonzero(naive_alarms_slow_drift) + 1
print(f"Naive baseline alarms: {len(naive_alarm_weeks)} weeks, first at week {naive_alarm_weeks[0]}")
alarm_index = monitor_result_slow_drift.first_alarm_index
if alarm_index is not None:
print(f"Monitor drift detected: True, first alarm at week {alarm_index + 1}")
Naive baseline alarms: 18 weeks, first at week 10 Monitor drift detected: True, first alarm at week 38
plot_monitor_vs_baseline(
means_slow_drift,
monitor_result_slow_drift,
naive_alarms_slow_drift,
title="Scenario 2: drift occurrence during deployment",
)
Observe that the peeking baseline alarms earlier. However, this occurs even before the drift happens and, therefore, qualifies as a false alarm. In real time, a team watching the naive baseline has no way to tell a validated alarm from a lucky (or unlucky) one, because its error rate was never controlled for repeated testing in the first place: Scenario 1 already showed it firing on a completely stationary stream. On the other hand, although the anytime-valid alarm comes with some delay after the drift, it does so based on consistently observed drift, making it more trustworthy.
EmpiricalPPIMeanMonitor's single alarm carries no such ambiguity: it is backed by the same anytime-valid guarantee demonstrated in Scenario 1, so under no drift the chance it would ever fire at all across the whole monitoring horizon corresponds to the used confidence level. At the moment it fires, its anytime-valid bound has just crossed the threshold. By the end of the stream, the running mean and its bound are both clearly above the threshold.
Reading the result object¶
In both scenarios, the EmpiricalPPIMeanMonitor returned a PredictionPoweredMeanMonitoringResult. Its public surface covers everything used above, plus a formatted summary.
print(f"{'Drift detected':<32}: {monitor_result_slow_drift.drift_detected}")
print(f"{'First alarm index':<32}: {monitor_result_slow_drift.first_alarm_index}")
print(f"{'Alarm threshold':<32}: {monitor_result_slow_drift.alarm_threshold}")
print(f"{'Confidence level':<32}: {monitor_result_slow_drift.confidence_level}")
print(f"{'Running means (last 3)':<32}: {np.round(monitor_result_slow_drift.running_means[-3:], 3)}")
print(f"{'Confidence bounds (last 3)':<32}: {np.round(monitor_result_slow_drift.confidence_bounds[-3:], 3)}")
print(f"{'True labels per batch (last 3)':<32}: {monitor_result_slow_drift.batch_n_true[-3:]}")
print(f"{'Proxy labels per batch (last 3)':<32}: {monitor_result_slow_drift.batch_n_proxy[-3:]}")
Drift detected : True First alarm index : 37 Alarm threshold : 0.3 Confidence level : 0.7 Running means (last 3) : [0.427 0.431 0.437] Confidence bounds (last 3) : [0.359 0.363 0.369] True labels per batch (last 3) : [20 20 20] Proxy labels per batch (last 3) : [100 100 100]
Summary : Anytime-valid Risk Monitoring detects drifts and reduces false alarms¶
| Per-batch baseline | EmpiricalPPIMeanMonitor |
|
|---|---|---|
| Valid under repeated looks? | ❌ No | ✅ Yes |
| Detects a sustained drift? | 🟠 Yes, but prone to early false alarms | ✅ Yes |
Key takeaways:
- Repeated significance testing is not a monitor. Scenario 1 showed the per-batch baseline raise a false alarm on a perfectly stationary stream, purely from checking every week.
- An anytime-valid confidence sequence makes checking every week safe.
EmpiricalPPIMeanMonitor's guarantee holds simultaneously across all looks, so its single alarm in Scenario 2 can be trusted the moment it fires, without waiting to see whether later weeks confirm it. - The naive baseline's alarms carry no error-rate guarantee, correct or not. Scenario 2's naive alarms happened to line up with a real regression, but Scenario 1 already showed the same procedure crying wolf under no drift at all: there is no way to tell the two situations apart from the naive alarms alone.
Want to go further? The PPRM scientific validation notebook provides rigorous empirical evidence on false-alarm control and detection power, and the Monitors user guide derives the anytime-valid confidence sequence used here from first principles.