Skip to main content

driftkit

CI Python License: MIT

Correct, dependency-light stability metrics for tabular models — PSI, CSI, WOE/IV and calibration drift.

Depends on numpy and pandas. Nothing else.

pip install driftkit

Why another drift library

Most PSI implementations you'll find in a notebook are subtly wrong, and the popular monitoring frameworks that get it right bring a large dependency tree with them.

The single most common bug is re-binning the comparison sample:

# WRONG — each sample gets its own quantiles
expected_pct = pd.qcut(reference, 10).value_counts(normalize=True)
actual_pct = pd.qcut(current, 10).value_counts(normalize=True)

Quantile bins are equal-frequency by construction, so both histograms come out uniform and PSI collapses toward zero no matter how far the population has actually moved. driftkit makes this mistake hard: bins are learned once on the reference sample, frozen in a BinSpec, and applied unchanged thereafter.

There is a test for exactly this. On a 100-point mean shift, the correct calculation reads 3.28; naive re-binning reads 0.00 — not "a bit low", but exactly zero, because re-fitting quantiles on each sample forces both histograms to be identical by construction.

Also handled, because these are the things that bite in production:

  • Out-of-range values. Outer bin edges are -inf/+inf, so a value below anything in the reference set lands in bin 0 instead of silently becoming NaN.
  • Missing data. NaN gets its own bin rather than being dropped. A feed that starts returning nulls is drift, and dropping nulls hides it precisely when you need to see it.
  • Empty bins. ln(actual/expected) is undefined when either side is empty. Instead of an arbitrary epsilon, driftkit uses additive smoothing (Jeffreys prior, alpha=0.5) — the well-defined version of the same idea.
  • Tied features. Duplicate quantile edges collapse instead of producing empty bins that inflate PSI with noise.

Usage

Population drift on one feature

import numpy as np
from driftkit import psi

rng = np.random.default_rng(0)
reference = rng.normal(700, 50, 10_000)  # training population
current = rng.normal(660, 60, 10_000)  # this month's scoring window

result = psi(reference, current)

result.value  # 0.5244
result.interpretation  # 'significant shift'

Every headline number can be traced back to the bins that caused it:

result.top_bins.head(3)
bin expected_count actual_count expected_pct actual_pct contribution
[-inf, 637.049) 1000 3524 0.100 0.352 0.3177
[764.127, inf) 1000 405 0.100 0.041 0.0537
[742.792, 764.127) 1000 415 0.100 0.042 0.0514

The bottom decile of the reference population now holds 35% of the current one — that single bin is 61% of the total PSI.

Monitoring a whole frame

from driftkit import monitor

report = monitor(reference_df, current_df)
print(report.to_markdown(limit=5))
report.unstable  # features with PSI >= 0.25
report.results["fico"]  # full per-bin detail for one feature

Comparing many periods against one baseline

Fit the bins once so the series is comparable over time:

from driftkit import fit_bins, psi

spec = fit_bins(reference["fico"], n_bins=10)
trend = [psi(reference["fico"], period["fico"], bins=spec).value for period in months]

Which input moved the score

PSI on the model output tells you the population moved. CSI on an input tells you which feature moved it — and with scorecard points, how many points that's worth:

from driftkit import csi

result = csi(reference["fico"], current["fico"], points=fico_points, bins=spec)
result.table["score_impact"].sum()  # expected point movement from this feature

WOE encoding and Information Value

from driftkit import WOEEncoder

encoder = WOEEncoder(n_bins=10).fit(X_train, y_train)
X_woe = encoder.transform(X_train)

encoder.information_values_  # {'fico': 0.809, 'dti': 0.078, 'noise': 0.001}
encoder.summary()  # per-bin WOE table, sorted by IV

Follows the scikit-learn transformer protocol, so it drops into a Pipeline — but doesn't import scikit-learn, because the arithmetic doesn't need it.

Sign convention is stated explicitly: WOE = ln(P(bin | y=0) / P(bin | y=1)). Positive WOE means the bin is over-represented among non-events. The opposite convention flips every coefficient's sign, so it's worth being unambiguous.

Calibration drift

Discrimination and calibration fail independently. A model whose AUC hasn't moved can still have drifted badly if its probabilities no longer mean what they used to:

from driftkit import calibration_report

report = calibration_report(y_true, y_prob)

report.ece  # 0.0039  — expected calibration error
report.bias  # -0.0032 — mean prediction minus observed rate
report.reliability  # 0.000022 — calibration term (lower is better)
report.resolution  # 0.0824   — discrimination term (higher is better)
report.table  # per-bin reliability diagram data

reliability - resolution + uncertainty ≈ brier (Murphy's decomposition). The uncertainty term is base-rate variance — a property of the sample, not the model, and the reason Brier scores move when the base rate moves even if nothing about the model changed.

Worked example

examples/credit_monitoring.ipynb runs a full monitoring pack on a synthetic credit scorecard through a simulated downturn. It ends on the case that motivates the library:

Question Metric Reference Current
Still rank-orders? AUC 0.7769 0.7789
Probabilities still right? ECE 0.0031 0.0653

AUC does not move. Calibration degrades 21×. Discrimination and calibration fail independently, and a dashboard carrying AUC alone would have called this model healthy while it under-predicted the portfolio's default rate by 6.5 percentage points.

The correct action there is recalibration, not retraining — the learned relationship is intact, only the intercept is stale.

from driftkit import make_credit_data, monitor

reference = make_credit_data(40_000, seed=0)
current = make_credit_data(40_000, drift=True, seed=1)

monitor(reference, current).summary
feature psi interpretation
debt_to_income 1.0821 significant shift
revol_util 0.9065 significant shift
fico_score 0.5144 significant shift
text_sentiment 0.4454 significant shift
delinq_2yrs 0.1329 moderate shift
age 0.0003 stable

age is generated identically in both scenarios — it's the control. A metric that flags it is producing noise.

On the interpretation bands

interpret_psi uses the conventional retail-credit bands: <0.10 stable, 0.10–0.25 moderate, >0.25 significant.

These are industry convention, not a hypothesis test. They carry no sample-size correction, so on a few hundred rows a "significant" reading is often noise, and on several million rows a "stable" reading can still hide a shift that matters. Treat them as a triage prompt, not a verdict.

API

Function Purpose
psi(expected, actual) Population Stability Index
csi(expected, actual, points=...) Characteristic Stability Index, with score impact
psi_from_counts(expected, actual) PSI from pre-binned count vectors
monitor(reference_df, current_df) PSI across every shared column
fit_bins(x, n_bins) Learn a frozen BinSpec
WOEEncoder Weight of Evidence transformer
information_value(x, y) IV of one feature
calibration_report(y_true, y_prob) Brier, ECE, Murphy decomposition
make_credit_data(n, drift=...) Synthetic credit portfolio, for examples and tests

Development

pip install -e ".[dev]"
pytest --cov=driftkit
ruff check . && mypy

License

MIT

Metadata

Release files for driftkit 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for driftkit 0.1.0
File Size Uploaded
driftkit-0.1.0.tar.gz 30.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for driftkit 0.1.0
File Interpreter ABI Platform
driftkit-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 51.5 kB

Release files / driftkit-0.1.0.tar.gz

Download URL driftkit-0.1.0.tar.gz
Size 30.1 kB
Tags Source
SHA-256 checksum
How to use checksums
ffa5571031ece96c4e9b4744f3cb33b49ba2a20a923a905d604223cc7a5f0633
BLAKE2b-256 checksum
How to use checksums
4f9009e3ee7e93c76ca9712f6fa4b4a994c5ffe8102edaec5e236e0e9258fbd8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 13, 2026.

Transparency log

Release files / driftkit-0.1.0-py3-none-any.whl

Download URL driftkit-0.1.0-py3-none-any.whl
Size 21.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5d6410b26e1adbecb52af53a1b9090d1a80c3bbb9b9de7357151fa3ab8886105
BLAKE2b-256 checksum
How to use checksums
314efd963e395b58749188871db8e67f444ee94ba99822b208c8e8c7f6bd373b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 13, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page