Skip to content
DriftWatch v0.1.0

Technical noteSeptember 2026MIT

A drift detector that measures its own noise

Two windows of the same data, some weeks apart. DriftWatch compares them and returns typed findings — not a dashboard, not a number to eyeball. This note describes what it computes, how a number becomes a severity, and the things it will not tell you.

It is a Python library. It runs inside a batch job or a CI step, imports nothing but numpy and pandas, and exits non-zero when a policy you configured fails.


00Install

Python 3.10 or newer. Two runtime dependencies, both of which you already have.

  1. Install the package

    No extras, no optional backends. SciPy and scikit-learn are test dependencies and are never imported at runtime.

    pip install driftwatch
  2. Compare two windows

    A reference frame and a current frame. The report is a list of typed Finding objects that renders to JSON, Markdown or a terminal.

    import driftwatch as dw
    
    report = dw.compare(reference_df, current_df)
    print(report.to_terminal())
    
    if report.failed:          # any CRITICAL finding
        raise SystemExit(1)
  3. Or run it as a CI step

    Same shape from a shell, with the exit code doing the work.

    driftwatch check reference.parquet current.parquet --config drift.toml
    # exit 0 = clean · 1 = critical drift · 2 = the check itself broke

01What it does

You give it a reference window — the training set, a signed-off month, or simply yesterday — and a current window. It compares them column by column and returns a report of Finding objects.

Each finding carries the feature, the metric, both sample sizes, the value, a p-value where one exists, the adjusted p-value, the threshold that produced the verdict, a severity, and any caveats the computation earned along the way. The same objects render as JSON, Markdown or terminal output, and round-trip losslessly.

DriftWatch  CRITICAL
  reference (20,000 rows)  ->  current (20,000 rows)
  4 critical | 3 warning | 13 ok | 20 checks

        FEATURE  METRIC                VALUE      P(ADJ)    THRESH        N REF->CUR
  CRIT  age      wasserstein          0.4194           -    0.2500    20,000->20,000
  CRIT  region   chi2                 0.1959    <1e-308*    0.1500    20,000->20,000
  CRIT  region   frequency_shift      0.1502           -    0.1500    20,000->20,000
  CRIT  income   missingness          0.0877    <1e-308*    0.0500    20,000->20,000
  WARN  age      ks                   0.1700     2e-251*    0.1000    20,000->20,000
  ... 2 more

  10 tests corrected with benjamini_hochberg (alpha=0.05): 3 significant raw -> 3 after correction

  caveats
    - income/missingness: column had no nulls in the reference window and 8.77%
      now; the rate ratio is undefined, judge this on the absolute rate

The exit code is the part that matters in automation:

exit 0
Ran, and nothing reached the configured severity.
exit 1
Ran, and something did. A result, not an error — the report on stdout is complete.
exit 2
Could not run: bad arguments, unreadable input, invalid config.

The split between 1 and 2 is the point. Conflating them turns a broken monitoring job into a silent pass, which is the failure this library exists to make impossible.

02The engineering model

A comparison is six stages. Nothing is stateful; nothing is learned. The same two frames always produce the same report.

01
StructureDiff the schemas. A removed column, a changed dtype, a column that is now entirely null — these are incidents, not statistics, and they are reported first.
02
ProfileDecide what each shared column is. Numeric, categorical, or ignored — inferred from dtype, overridable per column, and the reason recorded in the report. A zip code stored as an integer is not a quantity.
03
MeasureRun the configured metrics, one column at a time. Nulls, NaTs and infinities are excluded from the distributional comparison and counted by the completeness metric instead, so a broken pipeline cannot masquerade as a distribution shift.
04
CorrectCollect every p-value in the run — features and model alike — into one family and apply the multiple-testing correction once.
05
JudgeApply the governing policy. A finding escalates only if it clears all three gates: magnitude · not-noise · sample size.
06
ReportEmit typed findings, worst first, each carrying the evidence that produced it. Render to JSON, Markdown or a terminal. Exit accordingly.

Stage 05 is where the library takes its position. A metric never decides its own severity, and a p-value never becomes an alert on its own.

03What gets measured

Metrics are chosen so that they disagree. When PSI is quiet and Wasserstein is loud, that difference is information about where the mass went.

The metric families
FamilyMetricsEvidenceAnswers
Numericpsi · ks · wassersteinmagnitude · both · magnitudeHow far did the distribution move, and is it more than noise?
Categoricalfrequency_shift · psi_categorical · chi2 · jensen_shannonmagnitude · magnitude · both · magnitudeWhat share of the mix moved, and which levels?
Operationalmissingness · schema.*both · structuralDid the pipeline break?
Modelcalibration.ece_delta · calibration.mce_deltamagnitudeDo the predicted probabilities still match observed rates?
Performanceperformance.* · performance_drop.*magnitude · bothIs the model measurably worse? Needs labels.
Label pipelinelabels.coverage · labels.coverage_drop · labels.lag_p50_ratiomagnitude · both · magnitudeIs the labelling healthy enough to conclude anything?

Two deliberate choices

chi2 reports Cramér’s V, not the raw statistic. The raw χ² grows with sample size and is useless as a threshold; V is bounded in [0, 1] and is not. Likewise wasserstein divides by a reference scale by default, so its value reads as standard deviations of mass movement rather than as unusable raw feature units.

The performance metrics ship with no default thresholds at all. There is no universal accuracy floor, and inventing one would produce confident nonsense. You set them from your own baseline or they never fire.

04Three questions, kept apart

Most drift tooling collapses these into one number. Keeping them separate is the library’s main architectural commitment.

Significance
Is it more than sampling noise? Lives in p_value and adjusted_p_value. With enough rows the answer is always yes — it is a question about your sample size at least as much as about your data.
Magnitude
How big is it? Lives in the finding’s value. A PSI of 0.3 is a PSI of 0.3 whether measured on 300 rows or 30 million.
Policy
Do you care? Lives in severity. This is a business decision about this feature in this pipeline, and it is the only one of the three that can fail a build.

A policy escalates on magnitude, and only when the shift is also distinguishable from noise. "Distinguishable from noise" is established with whichever tool the metric actually supports: metrics carrying a p-value must clear alpha; magnitude-only metrics must clear a multiple of their noise floor. The two are never applied to the same metric, because double-gating would silently halve its sensitivity.

A third gate caps severity on small windows. Below min_samples observations on either side, a finding can warn but cannot go critical — you still see it, it just will not fail your build on thirty rows.

ok warning critical — icon and label carry the meaning, not colour alone.

05The noise floor

Every divergence in this library is non-negative. PSI, Wasserstein, total variation, Jensen–Shannon and Cramér’s V cannot go below zero, so sampling noise can only push them up. Two independent draws from the same distribution produce a positive value, and the smaller the windows the larger it is.

This is the single biggest source of spurious drift alerts in practice, and it is why "PSI > 0.1 means investigate" is not a decision rule. It is a decision rule conditional on a sample size nobody wrote down.

So DriftWatch computes the floor. Each metric reports what it would read under the null, given the two sample sizes, as details["expected_null"].

Closed-form no-drift expectations
MetricE[value] with no driftWorst out-of-sample error
psi(B−1)(1/n₁ + 1/n₂)7%
ks√(π/2)·ln2·√(1/n₁+1/n₂)3%
wasserstein√(2/π)·√(1/n₁+1/n₂)·∫√(F(1−F))dx8%
frequency_shift½·√(2/π)·Σ√(p(1−p)(1/n₁+1/n₂))4%
chi2 (Cramér’s V)E[√χ²_d] / √N4%
jensen_shannonE[√χ²_d]·√((1/n₁+1/n₂)/(8 ln2))5%

E[√X] is not √E[X]. Cramér’s V and the Jensen–Shannon distance are square roots of chi-square-like quantities, and Jensen’s inequality is strict. Using √(d/N) overstates the floor by 25% at two categories. Both use E[√χ²_d] = √2·Γ((d+1)/2)/Γ(d/2) instead. This was a real bug, found by verifying the formulas out-of-sample rather than against the cases they were derived on.

What that looks like

Noise-floor values for a four-category feature and a ten-bin numeric feature, equal window sizes:

The floor falls as 1/√n — the thresholds do not
n per sidePSIWasserstein (σ)Total variationKS D
2000.0900.1240.0660.087
5000.0360.0800.0420.055
2,0000.0090.0400.0210.027
10,0000.0020.0180.0090.012

At 500 rows a side, the default warning line for total variation (0.05) is 1.2× the noise floor. At 10,000 rows it is 5.4×. The same threshold means completely different things.

The guard

A magnitude-only metric must exceed noise_multiple × expected_null — 2.0 by default — before a policy will escalate it. Measured on this project’s synthetic scenarios, 200 fixed-seed replicates per row:

Reports raising at least one warning, on data with no drift at all
n per sideGate offGate onCritical, either way
50070%20%0 / 200
2,0005.0%5.0%0 / 200
20,0000%0%0 / 200

Detection is unaffected: each of four drift scenarios is caught in 40 of 40 fixed seeds at both n=600 and n=4,000, where detection means at least one warning-or-above finding on the feature the scenario actually moved.

The multiplier is an operational guard, not a statistical law. It has no false-positive guarantee and no critical value. 2.0 is a working default that kept the measured alert rate low without losing detections; it was not derived from a distributional result and it was not tuned against the power scenarios it is reported alongside.

06Multiple testing

A report over 200 features × 2 test-bearing metrics runs 400 hypothesis tests. At α = 0.05 with nothing whatsoever wrong, roughly 20 come back significant. Any monitoring job that alerts on raw p-values therefore alerts every single run, and the team learns to ignore it.

Benjamini–Hochberg runs across the whole report by default, and both counts land in the metadata so the correction is visible rather than implied:

16 tests corrected with benjamini_hochberg (alpha=0.05): 9 significant raw -> 8 after correction

Holm and Bonferroni are available when a single false alarm is expensive — a page at 3am, an automated rollback. Benjamini–Yekutieli is there for when positive dependence cannot be assumed; at 400 tests it is about 6.6× more conservative.

Correction addresses the multiplicity of significance tests. It does nothing about the magnitude-threshold problem in §05 — those metrics have no p-value to adjust. The two mechanisms are independent and both are needed.

07Labels that arrive late

A model scores a loan today and learns whether it defaulted in eighteen months. Evaluating it means joining prediction events to labels, and the join is where the interesting mistakes live.

Scoring on whatever labels have arrived conditions on label arrival — and arrival time is very often correlated with the outcome. Confirmed fraud lands faster than cleared transactions. Churn is only observable at the end of a subscription period. A ticket resolved in an hour is not a random sample of tickets.

In a synthetic log where positives are confirmed in 1–5 days and negatives take 25–35, the churn rate among arrived labels is 54%. Among mature events it is 47%. More rows only estimate the biased quantity more precisely.

The defence is to evaluate only mature events — those old enough that a label would have arrived by now if one were ever going to. The join is deliberately a left join, so unlabelled predictions stay visible as NaN and are never quietly coerced to a negative class. When label coverage moves between the two windows, every performance finding carries an explicit caveat saying part of the change may be selection rather than the model.

08What it will not claim

Every metric here measures P(X), the distribution of the inputs, or P(Ŷ), the distribution of the predictions. Model quality depends on P(Y|X), the relationship between inputs and outcome. Those are different objects.

P(X) can be completely unchanged while P(Y|X) changes.

That is concept drift, and no metric in this library can detect it. A fraud model can degrade to uselessness while every feature distribution is pixel-identical, because the fraudsters changed what the same features mean. A demand model can fail after a competitor’s price change that never appears in its own inputs.

The reverse holds too: P(X) can change a great deal while P(Y|X) is stable, in which case the model is fine and the alert is noise you should not act on.

Only the performance.* and calibration.* findings — which need labels — are evidence about the model. A green report with labels still pending means your inputs look familiar. It does not mean your model works.

The assumption you must check yourself

The noise floors are derived for independent observations. Clustered, repeated-entity, longitudinal or autocorrelated data carries less information than its row count implies, so its true floor is higher than DriftWatch reports and the guard built on it is correspondingly too permissive. Nothing in the library detects dependence. reference_n and current_n are raw row counts, not effective sample sizes. Deduplicate to one row per entity, or raise the multiple.

09Verification

Every statistical quantity the library computes is pinned against a reference implementation, and every noise floor against simulation. SciPy and scikit-learn are test dependencies; the library itself imports neither, and a CI job asserts they are not importable at runtime.

Test suite
509 tests, 94% statement coverage. Fully deterministic — every replicate uses an explicit seed; nothing samples from a global RNG or depends on wall-clock time. Verified free of test-order dependence across five random orderings.
Reference parity
KS D matches SciPy bit-for-bit; the chi-square, Kolmogorov and normal survival functions to ~1e-16; Wasserstein to 1e-12; Benjamini–Hochberg and –Yekutieli against false_discovery_control. AUC, log loss, Brier, R² and calibration curves against scikit-learn.
Executed on
Python 3.10.20 (numpy 2.2.6 / pandas 2.3.3), 3.11.15 and 3.14.6 (numpy 2.4–2.5 / pandas 3.0.5) — all from a pristine clone, installed as a built wheel.
Static analysis
ruff check, ruff format --check and mypy clean. Ships py.typed.

Cost

Measured on an Apple M-series laptop, single core, median of seven repeats. These numbers describe that machine and workload only — wall-clock figures move 10–20% between runs and much more across hardware. The shapes are what transfer.

Throughput, and where it goes
WorkloadTimeNote
psi75 ns/rowReference quantiles, then searchsorted.
ks290 ns/rowSorts both samples — as does Wasserstein, at 286.
compare · 100k × 25 cols1.2 s100 checks.
compare · 100k × 500 cols19.4 s2,000 checks. Linear in cells, ~2M cells/s.
compare · 1M × 25 cols11.0 sSame throughput at 10× the rows.

Peak additional allocation — working memory beyond the frames themselves — tracks rows and not columns: roughly 16–19× the size of one float64 column. That is 13–16 MB at 100k rows whether the frame has 25 columns or 500, and 208 MB at 1.6M rows. Columns are compared one at a time, so width costs time rather than memory. It is O(rows) with a small constant, not constant memory.

What an audit found

A final adversarial pass over the finished library turned up five defects worth naming, because each is the kind that hides:

NaT sentinel
A null datetime cast to the int64 sentinel — about the year 1677 — and was compared as a real observation, producing large spurious drift.
Invisible infinities
Dropped by every distributional metric and counted by none, so a column that became 30% +inf reported no drift and no missingness. A clean bill of health for a broken pipeline.
Jensen’s inequality
Two noise floors used √E[X] where E[√X] was required, overstating them by 25% at two categories.
Silent NaN threshold
A NaN threshold compares false against everything, so the severity level never fired — a policy that looked configured and was not.
Version-specific parsing
A fix that matched on a pandas error message worked on one version and broke on another. Only executing the older Python caught it.

Three of the project’s own published claims were corrected in the same pass, for being measured at one sample size and stated as general.