Intermediate

Statistical Detection

PSI, KL divergence, KS test, and Jensen-Shannon divergence: the four statistical methods that catch drift before users do, with worked Python examples and decision guidance.

✍️ AI School Editorial Team · Lilly Tech Systems 📅 Published Jul 30, 2026 · Reviewed Jul 30, 2026

The Core Problem These Methods Solve

You have a baseline: the distribution of inputs (or outputs) at deployment time. You have a current window: the distribution of the same features over the last day, week, or batch. The question is: has the distribution changed significantly, or is the difference just normal sampling variation?

Statistical drift detection methods answer that question with a number. Each method produces a score; you compare that score to a threshold and make a binary decision: drift detected or not detected. The methods differ in what they measure, what they assume, and how they handle edge cases.

Which to use when: PSI for tabular input features in business/financial contexts (the industry standard). KS test for continuous distributions without assumptions. KL divergence for probability distributions where you need information-theoretic grounding. Jensen-Shannon for output distributions and embeddings (symmetric, bounded, no division-by-zero). In practice, run at least two: one for inputs, one for outputs.

Method 1: Population Stability Index (PSI)

PSI is the workhorse of drift detection in credit risk, insurance, and enterprise ML. It compares two distributions by bucketing values and measuring how much each bucket's proportion changed.

How it works

Divide the feature's value range into N buckets (typically 10). For each bucket, calculate the expected proportion (from the baseline) and the actual proportion (from the current window). PSI is the sum over all buckets of:

📈
(Actual% - Expected%) × ln(Actual% / Expected%)

Worked example

A customer age feature at training had this distribution (simplified to 5 buckets):

BucketTraining %Current %Diffln(Curr/Train)Contribution
18-2515%28%+0.130.6240.0811
26-3530%35%+0.050.1540.0077
36-4528%20%-0.08-0.3360.0269
46-6020%12%-0.08-0.5110.0409
60+7%5%-0.02-0.3360.0067

PSI = 0.0811 + 0.0077 + 0.0269 + 0.0409 + 0.0067 = 0.163

Interpreting PSI

PSI ValueInterpretationAction
< 0.1No significant changeMonitor; no action needed
0.1 - 0.2Moderate shift - worth investigatingReview; consider recalibration
> 0.2Significant shift - drift confirmedEscalate; root cause analysis

Our example PSI of 0.163 falls in the "investigate" range: a 13-point swing in the youngest cohort is significant and warrants investigation.

Python: PSI implementation
import numpy as np

def calculate_psi(baseline, current, buckets=10, eps=1e-4):
    """Population Stability Index between two arrays."""
    breakpoints = np.percentile(baseline, np.linspace(0, 100, buckets + 1))
    breakpoints[0] = -np.inf
    breakpoints[-1] = np.inf

    base_counts = np.histogram(baseline, bins=breakpoints)[0]
    curr_counts = np.histogram(current, bins=breakpoints)[0]

    base_pct = base_counts / len(baseline) + eps
    curr_pct = curr_counts / len(current) + eps

    psi = np.sum((curr_pct - base_pct) * np.log(curr_pct / base_pct))
    return psi

# Usage
baseline_ages = ...   # array from training data
current_ages  = ...   # array from last 7 days of production traffic
psi = calculate_psi(baseline_ages, current_ages)
print(f"PSI: {psi:.4f} - {'Drift detected' if psi > 0.2 else 'Monitor' if psi > 0.1 else 'Stable'}")

Method 2: Kolmogorov-Smirnov (KS) Test

The KS test is a non-parametric test that measures the maximum absolute difference between the cumulative distribution functions (CDFs) of two samples. Unlike PSI, it makes no assumption about binning and uses the data's actual distribution.

The KS statistic D is the largest vertical gap between the two CDFs. When D is large (and the p-value is small), the distributions are significantly different.

Python: KS test with scipy
from scipy import stats

# Two-sample KS test
ks_stat, p_value = stats.ks_2samp(baseline_feature, current_feature)

print(f"KS Statistic: {ks_stat:.4f}")
print(f"p-value: {p_value:.4f}")

if p_value < 0.05:
    print("Drift detected: distributions are significantly different")
else:
    print("No significant drift detected at alpha=0.05")
💡
KS test pitfall: With very large sample sizes (millions of inferences), even tiny, practically irrelevant differences will produce significant p-values. Always report the KS statistic (D) alongside the p-value and set a minimum D threshold (e.g. 0.05) before triggering alerts.

Method 3: KL Divergence

Kullback-Leibler (KL) divergence measures the information loss when one distribution is used to approximate another. Unlike PSI and KS, it is asymmetric: KL(P||Q) is not equal to KL(Q||P). This makes it useful when you have a clear "true" distribution (the baseline) and want to measure how far the current distribution departs from it.

KL divergence has a critical limitation: if any bucket in the current distribution has zero probability while the baseline has non-zero probability, the calculation is undefined. Always add a small epsilon (smoothing constant) or use Jensen-Shannon instead for production monitoring.

Method 4: Jensen-Shannon (JS) Divergence

JS divergence is the symmetric, bounded version of KL divergence. It is bounded between 0 (identical distributions) and 1 (completely disjoint distributions, using base-2 logarithm). Because it is symmetric and handles zero probabilities gracefully, it is the preferred method for monitoring output distributions and embedding space similarity.

Python: Jensen-Shannon divergence
from scipy.spatial.distance import jensenshannon
import numpy as np

def distribution_to_histogram(data, bins=50):
    """Convert raw data to a normalized probability distribution."""
    counts, _ = np.histogram(data, bins=bins, density=True)
    return counts / counts.sum()

baseline_dist = distribution_to_histogram(baseline_outputs)
current_dist  = distribution_to_histogram(current_outputs)

js_dist = jensenshannon(baseline_dist, current_dist)  # range: 0 to 1
print(f"JS Distance: {js_dist:.4f}")
# Threshold guidance: > 0.1 is worth investigating; > 0.2 is significant drift

Authoritative References

The implementations above use widely available open-source libraries. The following official documentation pages are stable references for the underlying functions:

Choosing the Right Method for Your Data

Use CaseRecommended MethodWhy
Numerical input features (business context)PSIIndustry standard; interpretable thresholds; handles categorical binning
Continuous features, no bin assumptionKS testNon-parametric; exact; no binning required
Probability distributions (model scores)JS DivergenceSymmetric; bounded; no zero-probability issues
Embedding space similarityJS Divergence or cosine similarity histogramsWorks well for high-dimensional spaces when projected to 1D distributions
LLM output distributions (token counts, formats)PSI + JS DivergencePSI for categorical output properties; JS for continuous metrics
Production tip: Run detection daily on a rolling 7-day window vs. your training baseline. Do not use the previous 7 days as your reference - if drift is gradual, week-over-week comparison misses it. Always compare against a fixed deployment-time baseline. Reset the baseline only deliberately, after a confirmed retrain.

Ready to Go Deeper?

Live instructor-led courses from our partners. Affiliate disclosure.