Drift is the divergence between the data a model was trained on and the data it
now scores. This guide explains the three statistical tests this framework uses
โ PSI, the Kolmogorov-Smirnov test, and the chi-square test โ how to
read their output, and how to tune the thresholds that turn a raw statistic into
a PASS / WARN / FAIL verdict.
changes. The relationship between inputs and target may still hold, but the
model is now extrapolating into regions it saw little of during training.
now imply a different outcome (e.g. spending โฌ500/month meant "low risk" in
2019 and "high risk" after an economic shock).
This framework detects data drift directly, by comparing input
distributions. That is its superpower: it needs no ground-truth labels, so
it warns you *today*, weeks before the labels that would reveal concept drift
arrive. Persistent, large data drift is also the most common practical *cause*
of concept drift, so a drifting feature is a strong leading indicator even when
you cannot measure P(Y|X) directly.
Every drift check compares two datasets:
recent window you validated and were happy with.
traffic.
from model_validation import DataDriftDetector, DriftThresholds
detector = DataDriftDetector(
thresholds=DriftThresholds(psi_warn=0.10, psi_fail=0.25, ks_p_value=0.05),
categorical_features=["region", "plan"], # the rest are auto-detected
)
report = detector.detect(reference_columns, current_columns)Input can be a dict[str, sequence] (column name โ values) or a pandas
DataFrame. Numeric columns are auto-detected by dtype; name any categorical
columns explicitly via categorical_features to be safe.
PSI is the workhorse drift metric for tabular data. It buckets a feature using
the reference quantiles, then measures how much probability mass moved
between the reference and current samples:
PSI = ฮฃ_i (c_i โ r_i) ยท ln(c_i / r_i)
where r_i and c_i are the proportion of the reference and current samples
that fall in bucket *i*. Because the buckets are defined by reference quantiles,
an unshifted feature scores โ 0. The widely used interpretation bands:
| PSI | Interpretation | Default action |
|---|---|---|
| < 0.10 | No significant population change | none |
| 0.10 โ 0.25 | Moderate shift, worth investigating | WARN |
| > 0.25 | Major shift, model likely stale | FAIL |
PSI is an effect-size measure: it tells you *how much* the distribution
moved, not whether the move is statistically "significant". That is exactly what
you want for a stability gate โ a change large enough to matter shows up
regardless of sample size.
from model_validation import population_stability_index
psi = population_stability_index(reference["age"], current["age"], bins=10)Notes on the implementation:
low-cardinality features never create zero-width buckets.
reference range (a classic drift symptom) are captured rather than dropped.
log of โ zero.
PSI tells you the size of a move; the two-sample KS test tells you whether a
move is real. It computes D, the maximum absolute gap between the two empirical
cumulative distribution functions, and converts it to a p-value via the
Kolmogorov distribution:
from model_validation import ks_2samp
d_statistic, p_value = ks_2samp(reference["income"], current["income"])from the same distribution. p < ks_p_value (default 0.05) rejects "same
distribution".
The p-value uses the standard asymptotic Kolmogorov series with the
small-sample correction term, the same approximation SciPy uses โ but
implemented here in pure NumPy, so the framework has no SciPy dependency.
With large production samples, the KS test becomes hypersensitive: a difference
far too small to affect the model becomes "statistically significant" simply
because you have a million rows. This is the classic *large-n* trap. The
detector therefore flags a numeric feature only when:
PSI โฅ psi_fail (a large move on its own), OR
(KS significant AND PSI โฅ psi_warn) (a real move that is also big enough)
Significance answers "is it real?"; PSI answers "is it big enough to care?". You
need both to avoid a wall of false alarms.
For categorical features, the detector builds a 2 ร k contingency table
(reference vs current counts across the union of categories) and runs a
chi-square test of homogeneity:
from model_validation import chi_square_test
statistic, p_value, dof = chi_square_test(reference["plan"], current["plan"])The p-value is computed from the chi-square survival function via the
regularised upper incomplete gamma function โ again, pure NumPy. As with numeric
features, the detector pairs significance with a categorical PSI effect-size
gate, so a new rare category or a tiny proportion wobble does not trip a false
alarm on a large sample.
detector.detect(...) returns a DriftReport. The most useful members:
report.n_features # how many features were compared
report.n_drifted # how many drifted
report.drift_share # fraction drifted (0.0โ1.0)
report.has_major_drift # any feature in the "major" severity band?
report.drifted_features # list[FeatureDriftResult]
report.get("income") # the result for one feature
report.to_dict() # JSON-serialisable summaryEach FeatureDriftResult carries the feature name, kind (numeric /
categorical), drifted flag, severity (none / moderate / major),
the psi, the test statistic, and the p_value. Example console output from
examples/drift_check.py:
feature kind severity psi p_value
monthly_spend numeric major 0.839 2.7e-295 <== DRIFT
plan categorical moderate 0.235 2.6e-123 <== DRIFT
age numeric none 0.003 0.888
Here monthly_spend shifted enough to be a hard failure, plan's category mix
moved into the warning band, and age is stable.
DriftThresholds controls both per-feature sensitivity and how the whole check
is graded:
| Field | Default | Controls |
|---|---|---|
psi_warn | 0.10 | PSI at/above which a feature is a warning |
psi_fail | 0.25 | PSI at/above which a feature is a hard failure |
ks_p_value | 0.05 | Numeric significance cut-off |
chi2_p_value | 0.05 | Categorical significance cut-off |
max_drifted_share_warn | 0.10 | Drifted-feature fraction that warns the whole check |
max_drifted_share_fail | 0.30 | Drifted-feature fraction that fails the whole check |
The DataDriftCheck grade combines per-feature severity with the overall share:
it fails if any feature shows *major* drift or the drifted share exceeds
max_drifted_share_fail; it warns if the drifted share exceeds
max_drifted_share_warn (or any feature drifted at all); otherwise it passes.
WARN. Watch a few weeks of real traffic to learn your baseline noise floor
before you let drift block a deployment.
snapshot will eventually flag seasonal-but-benign change. A rolling
"last validated window" reference adapts to legitimate evolution.
noisy; aggregate to at least a few hundred rows per feature before trusting a
verdict.
stricter gate) for the handful of features that drive most of the model's
predictions, and looser ones for long-tail features.
from model_validation import DataDriftDetector
import sys
detector = DataDriftDetector(categorical_features=["region", "plan"])
report = detector.detect(reference, last_24h)
print(report.to_dict())
sys.exit(1 if report.has_major_drift else 0) # alert / page on major driftSchedule this against a rolling production window and alert on a non-zero exit.
For the full version with formatted output, see examples/drift_check.py.
model got worse, only that the world it operates in has changed. Pair it with
PerformanceCheck once labels arrive.
each feature's marginal looks fine but their *joint* distribution shifts.
Treat a clean per-feature report as necessary, not sufficient.
the model is broken โ sometimes the world genuinely changed and the model
still generalises. The gate exists to make sure a human looks.
Get the full Model Validation Framework and unlock everything.
Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.
Access all interactive tools with complete data, all workload profiles, and the full scenario library.
Downloadable source code, configuration files, and working examples from every chapter.
Free updates for life. Every new chapter, tool, and improvement included.