Drift monitoring¶
The holdout gate answers was this model good on history. Live calibration answers is it still calibrated on money that was actually at risk. This answers a third question, and the only one that needs no outcomes: is the world the model is being fed still the one it was trained on.
That is why it is worth having. A market resolves days or weeks after the forecast; a feature that has moved is visible today. It is also not the same as the model being wrong — inputs can move without hurting a robust model, and a model can degrade with its inputs unchanged — so read it beside calibration, not instead of it.
The quick version¶
import numpy as np
import pandas as pd
from pmlab import drift_report
rng = np.random.default_rng(0)
n = 2_000
# The panel the champion was trained on.
reference = pd.DataFrame({
"feature_price": rng.normal(0.50, 0.10, n),
"feature_volume": rng.lognormal(3.0, 0.5, n),
"feature_sector": rng.choice(["politics", "sports", "crypto"], n, p=[0.5, 0.3, 0.2]),
})
# What it is being fed this week: price has drifted up, a new sector has
# appeared, and volume has started arriving blank 30% of the time.
current = pd.DataFrame({
"feature_price": rng.normal(0.58, 0.10, n),
"feature_volume": rng.lognormal(3.0, 0.5, n),
"feature_sector": rng.choice(["politics", "sports", "crypto", "weather"], n,
p=[0.4, 0.25, 0.15, 0.2]),
})
current.loc[rng.random(n) < 0.30, "feature_volume"] = np.nan
report = drift_report(reference, current)
print(f"overall: {report.status} "
f"({report.n_alert} alert, {report.n_warn} warn, {report.n_ok} ok)")
for f in report.features:
psi = "n/a" if f.psi is None else f"{f.psi:.3f}"
print(f"{f.name:16} {f.status:7} psi={psi:>6} drivers={f.drivers}")
# Look inside the two that are not a plain distribution shift.
sector = next(f for f in report.features if f.name == "feature_sector")
volume = next(f for f in report.features if f.name == "feature_volume")
print("sector: unseen", sector.unseen_categories, f"= {sector.unseen_rate:.1%} of live rows")
print(f"volume: {volume.reference_missing_rate:.1%} missing -> {volume.current_missing_rate:.1%}"
f" (change {volume.missing_rate_change:+.3f}), psi on what remains {volume.psi:.3f}")
overall: alert (3 alert, 0 warn, 0 ok)
feature_price alert psi= 0.687 drivers=('psi', 'kl')
feature_sector alert psi= 1.595 drivers=('psi', 'kl')
feature_volume alert psi= 0.020 drivers=('missing_rate',)
sector: unseen ('weather',) = 20.3% of live rows
volume: 0.0% missing -> 28.1% (change +0.281), psi on what remains 0.020
Three different things went wrong, and the report says which kind:
feature_pricemoved: the whole distribution shifted.psiandklfire.feature_sectorhas a category the training panel never had.feature_volumelooks fine — its PSI on the values that remain is tiny — but about 30% of it is blank. Only the missing-rate signal fires, which is why it is a separate signal and not folded into the distribution.
Reading a report¶
drift_report returns a DriftReport; each feature is a FeatureDrift. Both
are frozen dataclasses with to_dict() / from_dict().
Every feature gets one status, and so does the report (the worst of its features and, if you supplied one, the prediction):
| status | meaning |
|---|---|
ok |
measured, and below the warn band on every signal |
warn |
at or above the warn threshold on at least one signal |
alert |
at or above the alert threshold on at least one signal |
insufficient |
could not be measured — see Too little data |
Precedence is alert > warn > insufficient > ok. insufficient outranks ok
on purpose: we could not check must never read as we checked and it is fine.
It does not outrank a measured problem, so one broken feature is never hidden by
another that is merely unmeasurable.
drivers names the signals that set the status — any of "psi", "kl",
"missing_rate" — and is empty when the status is ok.
| field | what it is |
|---|---|
psi |
Population Stability Index of the non-missing values. None if insufficient. |
kl |
KL(current ‖ reference), in nats. None if insufficient. |
reference_missing_rate, current_missing_rate, missing_rate_change |
share of missing values on each side, and current − reference (signed) |
unseen_rate |
share of live values the reference never contained: outside its min/max, or in an unseen category (see Bins) |
n_unseen_categories, unseen_categories |
for categorical features: how many, and the most frequent (listing capped at 20) |
n_reference, n_current |
rows on each side |
n_reference_valid, n_current_valid |
non-missing values on each side — what the statistics were computed from |
kind |
"numeric" or "categorical" |
On the report itself: features (ordered by name), prediction,
n_current_excluded_overlap (live-window markets dropped because the training
panel already contains them — see Reading the live window from the snapshot
store; always 0 from the pure
drift_report), absent_from_current (assessed features the live table does not have at all —
scored as 100% missing), new_in_current (columns the reference lacks —
informational), and config, so a stored report records the thresholds it was
judged against.
Which number to read¶
psi is the headline. It is 0 for identical distributions, grows with the
shift, and has conventional bands: below 0.10 nothing meaningful, 0.10–0.25
moderate, above 0.25 significant.
kl says which direction the shift went. It is KL(current ‖ reference)
— Σ cᵢ ln(cᵢ / rᵢ) over bins, where c is the live distribution and r the
training one. Read it as: how surprised would a model trained on the reference
be by this live data? It punishes live mass where training had little, and
barely notices training mass that has vanished. PSI is the two directions added
together, psi = KL(cur ‖ ref) + KL(ref ‖ cur), so PSI is direction-free and kl
is the half that matters for a model that has to cope with what it is fed.
from pmlab import drift_report
ref = ["a"] * 500 + ["b"] * 500
cur = ["a"] * 900 + ["b"] * 100
fwd = drift_report({"x": ref}, {"x": cur}).features[0]
rev = drift_report({"x": cur}, {"x": ref}).features[0] # arguments swapped
print(f"drift_report(ref, cur): psi {fwd.psi:.4f} kl {fwd.kl:.4f}")
print(f"drift_report(cur, ref): psi {rev.psi:.4f} kl {rev.kl:.4f}")
print(f"the two KLs add up to PSI: {fwd.kl + rev.kl:.4f}")
# Live traffic has a category the training panel never saw.
new = ["a"] * 400 + ["b"] * 400 + ["new"] * 200
fwd = drift_report({"x": ref}, {"x": new}).features[0]
rev = drift_report({"x": new}, {"x": ref}).features[0]
print(f"unseen category: kl(cur || ref) {fwd.kl:.4f} kl(ref || cur) {rev.kl:.4f}")
drift_report(ref, cur): psi 0.8789 kl 0.3681
drift_report(cur, ref): psi 0.8789 kl 0.5108
the two KLs add up to PSI: 0.8789
unseen category: kl(cur || ref) 1.3418 kl(ref || cur) 0.2223
missing_rate_change is current − reference, classified by its absolute
value: a feature that stopped being missing is a change in the pipeline too.
unseen_rate is how much of the live data the reference never contained.
For a categorical feature that is the share in unseen categories; for a numeric
feature, the share outside the reference's min/max. Either way that mass already
sits in bins of its own and is already inside psi and kl, so unseen_rate has
no thresholds of its own — see Out-of-range values have their own
bins. It is on the report so you can
see how much of the live data the model has never met, and so a coarse
epsilon cannot hide it.
Bins come from the reference only¶
Numeric features are cut at the quantiles of the reference sample (ten bins by default), and those same edges are reused for the live sample. This is not a detail. There are two ways to get it wrong:
- Bin each sample on its own quantiles, and both come out roughly uniform. A distribution shifted wholesale then scores about zero — drift disappears exactly when it is largest.
- Take the shared edges from the current sample, and the statistic depends on the very sample being judged.
import numpy as np
from pmlab import drift_report
rng = np.random.default_rng(0)
ref = rng.normal(0, 1, 2000)
cur = rng.normal(3, 1, 2000) # same shape, shifted by 3 standard deviations
fd = drift_report({"x": ref}, {"x": cur}).features[0]
print(f"reference bins: psi {fd.psi:.2f} ({fd.status})")
def own_quantile_shares(x): # the trap: each sample on its own quantiles
edges = np.quantile(x, np.linspace(0, 1, 11)[1:-1])
return np.bincount(np.searchsorted(edges, x), minlength=10) / len(x)
r, c = own_quantile_shares(ref), own_quantile_shares(cur)
print(f"own-quantile bins: psi {np.sum((c - r) * np.log(c / r)):.2f}")
reference bins: psi 9.21 (alert)
own-quantile bins: psi 0.00
Out-of-range values have their own bins¶
Everything strictly below the reference minimum and everything strictly
above its maximum gets a bin of its own, symmetrically; the quantile bins cut up
the range in between. That matters most when the reference is constant or heavily
tied, because tied values collapse the quantile edges. With open-ended end bins, a
constant reference would leave only "<= c" and "> c", and a live sample half
below c would share a bin with the reference's own mass and score exactly
zero. With outer bins it cannot:
from pmlab import drift_report
ref = [1.0] * 500 # a feature that never varied in training
for label, cur in (("unchanged", [1.0] * 500),
("half below", [1.0] * 250 + [0.0] * 250),
("half above", [1.0] * 250 + [2.0] * 250)):
fd = drift_report({"x": ref}, {"x": cur}).features[0]
print(f"{label:10} psi {fd.psi:5.2f} {fd.status:6} unseen_rate {fd.unseen_rate:.0%}")
unchanged psi 0.00 ok unseen_rate 0%
half below psi 4.60 alert unseen_rate 50%
half above psi 4.60 alert unseen_rate 50%
A sample compared with itself still scores exactly 0.0, for constant, tied,
discrete and continuous references alike: the outer bins are empty on both sides
and drop out.
unseen_rate is therefore redundant for the status, and has no thresholds.
The outer bins are empty in the reference, so their share of the live data is
floored at epsilon there and moves PSI and KL on its own. At the default
epsilon a share of 1% beyond the reference range is ok, 1.5% is a warn, and
3% an alert:
import numpy as np
from pmlab import DriftConfig, drift_report
ref = np.random.default_rng(1).normal(0, 1, 5000)
print("share beyond the reference max default epsilon epsilon 1e-2")
for share in (0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.10):
cur = np.random.default_rng(1).normal(0, 1, 5000)
cur[: int(share * len(cur))] = ref.max() + 1
default = drift_report({"x": ref}, {"x": cur}).features[0]
coarse = drift_report({"x": ref}, {"x": cur}, config=DriftConfig(epsilon=1e-2)).features[0]
print(f"{share:>30.1%} {default.status:>15} {coarse.status:>12}")
share beyond the reference max default epsilon epsilon 1e-2
0.5% ok ok
1.0% ok ok
1.5% warn ok
2.0% warn ok
3.0% alert ok
5.0% alert ok
10.0% alert alert
The caveat is the second column: epsilon sets how loud an empty bin is, and a
coarse one makes out-of-range mass cheap — at 1e-2, 5% of the live data beyond
anything in training no longer changes the status. If you raise epsilon, keep
watching unseen_rate.
Empty bins: large, never infinite¶
p · ln(p / 0) is infinite, and a value range the reference never saw is
precisely the drift that matters most. So each bin's proportion is floored at
epsilon (default 1e-4) and the vector is rescaled to sum to one. A range
the training panel never contained scores large but finite — never inf or NaN
— and the report stays valid JSON.
import numpy as np
from pmlab import DriftConfig, drift_report
rng = np.random.default_rng(0)
ref = rng.normal(0, 1, 2000)
cur = rng.normal(0, 1, 2000) + 100 # nowhere near anything in training
for eps in (1e-2, 1e-4, 1e-6):
fd = drift_report({"x": ref}, {"x": cur}, config=DriftConfig(epsilon=eps)).features[0]
print(f"epsilon {eps:g}: psi {fd.psi:5.2f} kl {fd.kl:.2f} unseen_rate {fd.unseen_rate:.0%}")
epsilon 0.01: psi 6.21 kl 3.89 unseen_rate 100%
epsilon 0.0001: psi 16.10 kl 9.19 unseen_rate 100%
epsilon 1e-06: psi 25.33 kl 13.82 unseen_rate 100%
epsilon is a knob on how loud "never seen" is. With no overlap at all, the live
mass sits in an outer bin the reference left empty, and the reference's mass sits
in bins the live sample left empty, so PSI grows with ln(1/epsilon) twice over:
each factor of 100 in epsilon adds roughly 9 to 10 (and kl, which sees only the
live side, about 5). Bins that are empty on both sides are omitted rather than
floored, so ties in the reference (a feature that is 70% zeros) do not add noise.
Missing values¶
NaN, None and (for numeric features) ±inf are excluded from the
distribution but counted. A feature going from 0% to 40% missing is drift even
though every remaining value looks fine; dropping the blanks silently would report
"no drift" on a broken feed. The missing-rate signal is classified separately, by
the absolute change in the missing share (defaults: warn at 0.05, alert at 0.20).
A column that goes entirely blank is an alert with psi = None — there is no
distribution left to compare, and that is not the same as "insufficient". A column
absent from the live table is treated the same way and also listed in
absent_from_current.
Categorical features¶
Categorical features are compared by category frequency. A category the reference
never had is drift, not noise to discard: all unseen categories are pooled
into one bin whose reference proportion is zero (so floored at epsilon), and
unseen_categories lists them by live frequency. A category that vanished from
live data is drift too — it shows up as reference mass with nothing opposite it.
Numeric vs categorical is decided from the reference column, never from the
live one — a feed that dies and starts emitting all-None is still a numeric
feature that went missing, not a categorical one:
- integers and floats are numeric (including object columns of numbers with
None); - strings, booleans, categoricals and everything else are categorical.
Override it where the dtype lies — integer-coded categories are the usual case:
from pmlab import DriftConfig, drift_report
codes = [0, 1, 2, 3] * 250
for cfg in (DriftConfig(), DriftConfig(categorical=("sector_code",))):
fd = drift_report({"sector_code": codes}, {"sector_code": codes}, config=cfg).features[0]
print(f"{fd.name}: {fd.kind}")
sector_code: numeric
sector_code: categorical
A feature named in numeric or categorical that the reference does not have
raises ValueError rather than being ignored: a typo there would otherwise leave
the real feature silently on auto-detect.
Two things about categories and kinds that are easy to trip over:
1,1.0andTrueare one category. Categories are counted in a PythonCounter, which treats values that hash alike as equal. That is deliberate: an integer column becomes float the moment aNaNshows up, and a boolean column round-trips through integers, and neither should read as a brand-new category. The string"1"is a different category from the number1.- One stray string flips a numeric column to categorical. Kind is inferred
from the whole reference column, so a numeric column holding a single
"n/a"is read as categorical, every distinct float becomes its own category, and the live data reads as entirely unseen. Auto-detection is left alone on purpose; the override is explicit, and it fails loudly rather than guessing. Clean the value (toNaN, which then counts as missing) and the feature is numeric again:
import numpy as np
import pandas as pd
from pmlab import DriftConfig, drift_report
# 1, 1.0 and True hash alike in Python, so they are one category.
ref = pd.Series([1, 2] * 300, dtype=object)
cur = pd.Series([True, 2.0] * 300, dtype=object)
fd = drift_report({"f": ref}, {"f": cur}, config=DriftConfig(categorical=("f",))).features[0]
print(f"[1, 2] vs [True, 2.0]: unseen categories {fd.n_unseen_categories}, psi {fd.psi:.1f}")
# A numeric column with one stray string is read as categorical...
rng = np.random.default_rng(0)
ref = list(rng.normal(0, 1, 599)) + ["n/a"]
cur = list(rng.normal(0, 1, 600))
fd = drift_report({"x": ref}, {"x": cur}).features[0]
print(f"auto-detect: {fd.kind}, {fd.status}, unseen_rate {fd.unseen_rate:.0%}")
# ...declaring it numeric turns the silent mis-read into a loud error naming the feature...
try:
drift_report({"x": ref}, {"x": cur}, config=DriftConfig(numeric=("x",)))
except ValueError as exc:
print("numeric=('x',): ", str(exc).split(":")[0])
# ...and coercing the stray value to NaN makes it what it should be: a missing value.
clean = pd.to_numeric(pd.Series(ref), errors="coerce")
fd = drift_report({"x": clean}, {"x": cur}).features[0]
print(f"after coercing: {fd.kind}, {fd.status}, reference missing {fd.reference_missing_rate:.2%}")
[1, 2] vs [True, 2.0]: unseen categories 0, psi 0.0
auto-detect: categorical, alert, unseen_rate 100%
numeric=('x',): feature 'x' is numeric but its reference values are not
after coercing: numeric, ok, reference missing 0.17%
Too little data¶
Below min_samples usable values on either side the statistics are None,
not a small number, and the status is insufficient. The default is 300, and
it is not arbitrary: PSI of an unchanged feature is not zero, it has a noise
floor of roughly (n_bins − 1) / n for n live values. Simulating 500 windows of
a feature that did not change at all:
import numpy as np
from pmlab import DriftConfig, drift_report
print("live values median PSI p95 PSI windows at or above the 0.10 warn line")
for n_cur in (100, 200, 300, 500, 1000):
scores = []
for seed in range(500):
rng = np.random.default_rng(seed)
ref, cur = rng.normal(0, 1, 2000), rng.normal(0, 1, n_cur)
fd = drift_report({"x": ref}, {"x": cur}, config=DriftConfig(min_samples=50)).features[0]
scores.append(fd.psi)
s = np.array(scores)
print(f"{n_cur:>11} {np.median(s):10.3f} {np.percentile(s, 95):7.3f} "
f"{int((s >= 0.10).sum()):>3} of 500")
live values median PSI p95 PSI windows at or above the 0.10 warn line
100 0.094 0.189 223 of 500
200 0.049 0.103 26 of 500
300 0.035 0.074 3 of 500
500 0.024 0.048 0 of 500
1000 0.015 0.029 0 of 500
At 100 live values, nearly half the windows (223 of 500) would warn about a
feature that never moved. At the default of 300, three in five hundred. If you change n_bins,
change min_samples with it — the floor scales with the number of bins.
Two details. Only usable values count: 500 rows of which 20 are non-blank is
insufficient for the distribution, but the 96% missing rate is still measured
and still alerts. And the rows-vs-values distinction applies to the missing-rate
signal too, which needs min_samples rows on each side before it classifies.
Prediction drift¶
Inputs can each look unchanged while the model's output moves — small shifts
compound through a model — and the output is what you trade on. Pass the model's
predicted probabilities on the training panel and on the live window and the same
machinery runs on them, reported separately as report.prediction:
import numpy as np
import pandas as pd
from pmlab import drift_report
rng = np.random.default_rng(0)
cols = ["feature_a", "feature_b", "feature_c", "feature_d"]
reference = pd.DataFrame({c: rng.normal(0.50, 0.10, 2000) for c in cols})
current = pd.DataFrame({c: rng.normal(0.52, 0.10, 2000) for c in cols}) # each moved a fifth of an sd
def model(df): # stand-in for champion.predict_proba(...)[:, 1]
return 1 / (1 + np.exp(-10 * (df[cols] - 0.5).sum(axis=1)))
report = drift_report(
reference, current,
reference_predictions=model(reference),
current_predictions=model(current),
)
for f in report.features:
print(f"input {f.name}: psi {f.psi:.3f} {f.status}")
p = report.prediction
print(f"prediction {p.name}: psi {p.psi:.3f} {p.status}")
print(f"report: {report.status}")
input feature_a: psi 0.083 ok
input feature_b: psi 0.054 ok
input feature_c: psi 0.038 ok
input feature_d: psi 0.056 ok
prediction prediction: psi 0.168 warn
report: warn
Here every input is below the warn line, and the report's status of warn comes
from the prediction alone. (A single-feature model that is monotone in its input
would show identical PSI for input and output — quantile bins map one-to-one
through a monotone function — so the prediction signal earns its keep once a model
combines features.)
Both arrays are required together, and every value must be a probability in
[0, 1] — NaN is allowed and counts as missing, as for features, but anything
else (including ±inf) raises ValueError naming the side and how many values
are out of range. A model that returns logits, percentages or a stale column would
otherwise be binned like any other number and reported as spectacular drift. The
two arrays are different samples and need not be the same length.
from pmlab import drift_report
try:
drift_report({}, {}, reference_predictions=[0.2] * 500,
current_predictions=[0.2] * 495 + [7.0] * 5) # logits, not probabilities
except ValueError as exc:
print(exc)
current predictions must be probabilities in [0, 1]; 5 of 500 are not (min 7, max 7)
Score the whole live window with the model.
Do not use model_prob from the paper-trade log as the live predictions: the log
holds only the markets the strategy chose to trade, a selected subset, and
comparing it with predictions on every training row would flag the selection as
drift.
Reading the live window from the snapshot store¶
drift_report is a pure function over two tables. drift_report_from_store
builds the live table for you from a FeatureSnapshotStore, and it does so
point-in-time: the window is read through the store's own as_of, so for each
market it holds the latest snapshot captured strictly before cutoff.
Nothing captured at or after the cutoff can leak in.
window_start is required, not optional, and so is keeping the model's own
markets out. Without a lower bound, and without excluding the markets in the
training panel, "current" would include the very markets the model was trained on,
and drift would be understated. So the window is restricted to snapshots captured
at or after window_start, and to markets whose market_id is not in the
reference panel (exclude_reference_markets=True, the default). The number of
live-window markets dropped for that reason is reported as
report.n_current_excluded_overlap; pass exclude_reference_markets=False to keep
them. The reference needs a market_id column (the panel builder's output has
one); without it the check cannot be made, so it raises rather than skipping
silently — add the column or opt out.
import tempfile
import numpy as np
import pandas as pd
from pmlab import drift_report_from_store
from pmlab.data import FeatureSnapshotStore, build_panel_from_snapshots
rng = np.random.default_rng(0)
store = FeatureSnapshotStore(tempfile.mkdtemp())
# Training era: 400 markets captured in January, resolved after the 10th.
store.append("politics", [
{"market_id": f"t{i}", "captured_at": "2025-01-05T00:00:00Z",
"feature_price": float(rng.normal(0.50, 0.10))} for i in range(400)])
resolutions = pd.DataFrame({
"market_id": [f"t{i}" for i in range(400)],
"decision_date": "2025-01-10",
"winning_label": rng.choice(["Yes", "No"], 400),
})
panel = build_panel_from_snapshots(store, "politics", resolutions)
# Live era: 400 markets captured in early March, then re-captured in April
# after a price jump.
store.append("politics", [
{"market_id": f"l{i}", "captured_at": "2025-03-05T00:00:00Z",
"feature_price": float(rng.normal(0.50, 0.10))} for i in range(400)])
store.append("politics", [
{"market_id": f"l{i}", "captured_at": "2025-04-05T00:00:00Z",
"feature_price": float(rng.normal(0.90, 0.02))} for i in range(400)])
for cutoff in ("2025-03-10", "2025-04-10"):
report = drift_report_from_store(
store, "politics", panel, window_start="2025-03-01", cutoff=cutoff)
f = report.features[0]
print(f"cutoff {cutoff}: {report.n_current} live markets {f.name} {f.status} psi {f.psi:.3f}")
# A nightly crawl also revisits 100 training-era markets inside the March window,
# at prices that have since moved. They are not new evidence: they are excluded
# by default, and counted.
store.append("politics", [
{"market_id": f"t{i}", "captured_at": "2025-03-06T00:00:00Z", "feature_price": 0.95}
for i in range(100)])
for exclude in (True, False):
report = drift_report_from_store(
store, "politics", panel, window_start="2025-03-01", cutoff="2025-03-10",
exclude_reference_markets=exclude)
f = report.features[0]
print(f"exclude_reference_markets={exclude!s:5}: {report.n_current} live, "
f"{report.n_current_excluded_overlap} excluded {f.status} psi {f.psi:.3f}")
cutoff 2025-03-10: 400 live markets feature_price ok psi 0.039
cutoff 2025-04-10: 400 live markets feature_price alert psi 16.100
exclude_reference_markets=True : 400 live, 100 excluded ok psi 0.039
exclude_reference_markets=False: 500 live, 0 excluded alert psi 1.600
At the March cutoff the April re-captures do not exist as far as the report is
concerned; move the cutoff past them and the same call sees the jump. The last two
lines are the overlap: 100 training-era markets re-captured inside the window at
prices that have since moved would, if kept, turn a clean window into an alert on
evidence the model has already seen. Features default to the panel's feature_*
columns; pass features= to override, and note that an empty list (or a panel
with no feature_* columns and no features=) raises ValueError.
An empty window gives a report whose features are all insufficient rather than
an error — no live data is not evidence the world is fine. That covers a window
with nothing captured in it, one emptied by the overlap exclusion, and
window_start == cutoff. What does raise ValueError, because it is almost
always a mistake rather than a quiet period: a family the store has no
snapshots for at all (a typo would otherwise read as "nothing live yet"), and a
window_start after cutoff.
To get prediction drift here, pass predict_fn, called with the full reference
panel and the live window (after the exclusion) and returning exactly one
probability per row: a 1-D array of the same length as the frame. A (n, 2)
predict_proba output, a scalar, or an off-by-one raises ValueError saying
which side it failed on; select the column yourself (predict_proba(X)[:, 1]).
It is not called on an empty window. The champion manifest does not record the
feature list its model was trained on, so pass features= explicitly if the panel
carries feature_* columns the model does not use — a loaded forecaster's
feature_names() is the natural source.
Compare like with like. The store gives each market its latest snapshot before the cutoff, while the training panel holds the snapshot taken at decision time. A market in the live window may therefore be much closer to resolution than the panel's markets were when the model saw them, and many features move with a market's age on their own: prices converge toward 0 or 1, spreads tighten, volume accumulates, and anything built from time-to-resolution shifts by construction. Part of the drift you measure can be markets being at a different point in their life rather than the world changing. Choose the window and the capture schedule so the live snapshots sit at a comparable stage — the same days-to-resolution as the panel's decision date — and read the drift of any age-dependent feature with that in mind.
Thresholds are configuration, not constants¶
The classifier is in DriftThresholds, carried on DriftConfig, and written into
every report:
| signal | warn | alert | where the default comes from |
|---|---|---|---|
| PSI | 0.10 | 0.25 | the widely used credit-scoring convention |
| KL(cur ‖ ref) | 0.05 | 0.125 | derived, not a standard: half the PSI bands, because for small shifts PSI ≈ 2 · KL |
| missing-rate change (absolute) | 0.05 | 0.20 | judgment; there is no convention |
A value at or above a threshold is in that band, with 1e-9 of slack for float
round-off: a missing rate going from 10% to 30% changes by
0.19999999999999998, and that is an alert at 0.20, not a warn. The same slack
applies to every comparison (PSI, KL and missing rate); a value that is really
below the line is not rounded up. These are starting points:
features differ in how noisy they are, so one set of bands will suit some better
than others — tune on your own history. Validation rejects negative, non-finite or
inverted (warn > alert) thresholds. To take a signal out of the decision, set
both its thresholds high — it is still reported.
import numpy as np
from pmlab import DriftConfig, DriftThresholds, drift_report
rng = np.random.default_rng(0)
ref, cur = rng.normal(0.50, 0.10, 2000), rng.normal(0.58, 0.10, 2000)
lenient = DriftConfig(thresholds=DriftThresholds(
psi_warn=1.0, psi_alert=2.0, kl_warn=1.0, kl_alert=2.0))
for label, cfg in (("default", DriftConfig()), ("lenient", lenient)):
f = drift_report({"price": ref}, {"price": cur}, config=cfg).features[0]
print(f"{label:8} psi {f.psi:.3f} -> {f.status:6} drivers={f.drivers}")
default psi 0.694 -> alert drivers=('psi', 'kl')
lenient psi 0.694 -> ok drivers=()
The float trap, for the record:
import numpy as np
from pmlab import drift_report
x = np.random.default_rng(0).normal(0, 1, 1000)
ref, cur = x.copy(), x.copy()
ref[:100] = np.nan # 10% missing
cur[:300] = np.nan # 30% missing
fd = drift_report({"x": ref}, {"x": cur}).features[0]
print(f"change {fd.missing_rate_change!r} -> {fd.status} {fd.drivers}")
change 0.19999999999999998 -> alert ('missing_rate',)
Logging to an experiment tracker¶
to_dict() is strict JSON — no inf, NaN or numpy scalars — and
DriftReport.from_dict() restores it exactly. The top level carries flat numbers
(n_alert, n_warn, n_ok, n_insufficient, n_current_excluded_overlap,
max_psi, max_kl, prediction_psi, prediction_kl) because trackers such as MLflow log only
top-level numerics; the per-feature detail rides along as nested lists.
import tempfile
import numpy as np
from pmlab import DriftReport, LocalJSONTracker, drift_report
rng = np.random.default_rng(0)
report = drift_report(
{"price": rng.normal(0.50, 0.10, 2000), "volume": rng.lognormal(3, 0.5, 2000)},
{"price": rng.normal(0.58, 0.10, 2000), "volume": rng.lognormal(3, 0.5, 2000)},
)
tracker = LocalJSONTracker(tempfile.mkdtemp())
with tracker.run("drift", "nightly") as run:
run.log_metrics(report)
(record,) = tracker.list_runs("drift")
flat = {k: round(v, 3) if isinstance(v, float) else v
for k, v in record.metrics.items()
if k != "prediction" and not isinstance(v, (list, dict))}
print(flat)
print("restored exactly:", DriftReport.from_dict(report.to_dict()) == report)
{'status': 'alert', 'n_reference': 2000, 'n_current': 2000, 'n_current_excluded_overlap': 0, 'n_ok': 1, 'n_warn': 0, 'n_alert': 1, 'n_insufficient': 0, 'max_psi': 0.715, 'max_kl': 0.356, 'prediction_psi': None, 'prediction_kl': None}
restored exactly: True
The report is deterministic — same inputs, identical report, no randomness — and features are ordered by name whatever order the columns arrived in, so two runs diff cleanly.
Limits¶
- Classification, not action. This computes numbers and sorts them into bands; deciding what to do about an alert — pause trading, retrain, page someone — is the watchdog's job (#14). The bands are a policy choice, which is why they are explicit configuration.
- Drift is not degradation. An alert says the inputs moved, not that the model got worse. Pair it with live calibration, which says whether the forecasts are still right once outcomes arrive.
- One feature at a time. Each feature is compared on its own. Two features can each look unchanged while the relationship between them has broken; PSI will not see that.
unseen_ratehas no thresholds. Out-of-range mass has bins of its own and already drives PSI and KL (see Out-of-range values have their own bins), but a coarseepsilonmakes it cheap. If you raiseepsilon, keep readingunseen_rate.- Do not include identifiers or timestamps. A
market_idor a date is all-unseen by construction and will read as total drift. Passfeatures=. The same goes for very high-cardinality categoricals, where most categories have too few rows to compare. - The live window should not overlap training.
drift_report_from_storedrops the markets the panel contains and reports how many; the puredrift_reportsees only the two tables you hand it, so excluding training markets there is your job.window_startis yours to choose. - Age is not drift, but it looks like it. The live snapshot of a market is its latest before the cutoff; the training snapshot is the one at decision time. See Compare like with like.
- A statistic on a sample. PSI has a noise floor (see
Too little data). A single
warnjust above the line on a small window is weak evidence; awarnthat persists across windows is not. - Binary forecasts. Prediction drift is one probability column; a multiclass market is monitored per outcome label.