Pattern Drift
Streaming-native concept drift detection library with four canonical algorithms, drift type classification, retraining window recommendation, and multi-channel alerting — zero mandatory dependencies.
Solo. I own every line of the library — the five-stage pipeline in pattern_drift/, all four
detector implementations (ADWIN, Page-Hinkley, KSWIN, DDM) written from the source papers against
the Python standard library only, the 14-file / 2,229-line test suite against 1,317 lines of
library code, the packaging config, and the reference docs (docs/system-report.md,
docs/configuration-report.md). There was no second reviewer, which shows up later in this
document.
// the problem
What was broken
Deployed models decay quietly. The data feeding them shifts — a new customer segment, a pricing change, a sensor that starts reading half a degree high — and nothing in the pipeline reports it, because every stage still returns a number. Teams cope in two bad ways: retrain on a blind calendar schedule and pay for the runs whether or not anything changed, or wait for a business metric to sag and work backward, weeks late. Worse, once someone finally accepts the model has gone stale, nobody can say *which slice of history is still clean enough to retrain on* — so the usual answer is "the last 90 days", chosen because it is round.
// constraints
What made it hard
The library had to install into pipelines that already existed, without asking anyone to adopt a
monitoring platform or add a dependency their security review would have to clear — which ruled
out scipy for the statistics and pandas for the data handling. It had to work on a stream, one
record at a time, with bounded memory, because the target is an inference handler or a Kafka
consumer loop, not a batch job with the whole dataset in hand. The four algorithms come from
papers spanning 1954 to 2020, several of them paywalled: of the 22 references in the system
report, 12 had a downloadable open-access PDF and 3 were dropped entirely because no version was
locatable (docs/papers/DOWNLOAD_LOG.md). Page-Hinkley and DDM were implemented from secondary
descriptions rather than the originals.
// architecture
How it fits together
Five stages, one per file, because the hard part of drift detection is not the statistics — it is
that four different algorithms disagree about what a "drift signal" even is, and the pipeline
around them has to not care. BaseDetector is deliberately two methods: update(value) ->
(bool, float) and reset(). Everything above it — classification, window selection, alerting —
consumes only that tuple, so adding a detector means writing a class and adding one line to
_METHODS in monitor.py. The cost of that narrowness is real and visible in the code: DDM
consumes prediction-correctness signals rather than feature values, and the interface has nowhere
to say so, so sensitivity is accepted and silently ignored for DDM
(docs/configuration-report.md §3.4) and Page-Hinkley's lambda_ stays pinned at 50.0 with no way
to reach it through DriftMonitor.
The bigger decision was one detector *per feature* instead of one multivariate test over the whole
row. Per-feature means the alert names the column that moved, which is what makes it actionable,
and it means a stable feature can never be dragged into an alarm by a noisy neighbour — there is a
whole test file enforcing exactly that (tests/test_multi_feature_isolation.py). The tradeoff is
that correlational drift is invisible: if two features shift together such that each marginal
stays put, nothing fires. That is documented as a limitation, not solved. Stage 4 is the piece
that does not exist in the comparable libraries — when drift fires, RetrainingWindowEngine walks
the score history backward to the last continuous run below the sensitivity threshold, trims 10%
off each end to avoid edge contamination, and hands back concrete indices instead of leaving the
"retrain on what?" question open.
Client
Streaming producer calls DriftMonitor.update(record) — dict, pandas Series, or DataFrame micro-batch
FeatureExtractor
Coerces to {feature: float}, drops non-numeric columns, auto-discovers feature names on first call
Detector pool
One BaseDetector per feature (ADWIN | PageHinkley | KSWIN | DDM), each returns (drift_detected, score)
DriftClassifier
50-observation deque per feature; labels the event sudden / recurring / incremental / gradual by signal shape
RetrainingWindowEngine
Walks _score_history backward to the last stable run, trims 10% per end, returns start/end/n_samples/confidence
AlertDispatcher
Fires registered callbacks (Slack, webhook, log, custom), each wrapped so user code cannot kill the stream
DriftResult
Returned to caller; _score_history kept in memory bounded by max_window, exportable to JSON or CSV
// challenges
Where I got stuck
The KS test without scipy
KSWIN needs a two-sample Kolmogorov-Smirnov p-value, and scipy is a ~30 MB dependency I had refused to take. Computing the D statistic is a merge-walk over two sorted arrays; the p-value is the hard half, because the Kolmogorov distribution is an infinite alternating series that oscillates badly near zero. What worked was the Kolmogorov–Smirnov asymptotic approximation with the (√n + 0.12 + 0.11/√n) finite-sample correction, the series truncated at 100 terms, and hard cutoffs at z < 0.2 → 1.0 and z > 5.0 → 0.0 where the series stops being numerically worth evaluating (kswin.py:124-138).
Detector scores were on four incompatible scales
ADWIN produces a ratio against a Hoeffding bound, Page-Hinkley a cumulative sum against λ, KSWIN a p-value, DDM a deviation from a running minimum. Everything downstream — the classifier's < 0.2 / > 0.6 shape thresholds, the window engine's comparison against sensitivity, the timeline plot — assumes one shared scale. I normalised all four into roughly [0, 1], but ADWIN and Page-Hinkley get there through divisors (min(score / 10.0, 1.0), U / lambda_) that are calibration constants with no derivation behind them. It works because the tests pin behaviour rather than values; it is the part of the codebase I trust least.
The retraining window's confidence score is structurally meaningless
find_window() computes confidence as the fraction of samples in the stable region that are below the sensitivity threshold — but the region was selected by walking backward *until* a sample exceeded that threshold, so every sample in it passes by construction. Confidence is always exactly 1.0. I verified this against a synthetic history with an unstable spike and got RetrainingWindowResult(start=65, end=184, n_samples=120, confidence=1.0). The tests missed it because they assert confidence >= 0.8 and 0.0 <= confidence <= 1.0 — both true of a constant (tests/test_retraining_window.py:64,85). The field ships as a stub and the number should not be trusted until the calculation is redone over a window that can actually contain instability.
Refusing to depend on sklearn broke sklearn integration
DriftDetector implements fit/transform/get_params/set_params by hand instead of inheriting BaseEstimator, so that pip install pattern-drift never pulls scikit-learn. That worked under the duck-typed estimator protocol and stopped working when sklearn moved to __sklearn_tags__: on sklearn 1.8.0, all three TestPipelineIntegration tests fail with 'DriftDetector' object has no attribute '__sklearn_tags__', while the 15 standalone wrapper tests still pass. The isolated wrapper is fine; the thing the wrapper exists for is broken. The fix is a lazy BaseEstimator import behind the optional extra, which trades the clean import graph for a live integration — not yet done.
Packaging failed on details the code could not catch
The initial release shipped build-backend = "setuptools.backends.legacy:build", which is not a real backend, alongside license = { text = "MIT" } and a License :: OSI Approved classifier that modern setuptools now rejects as a pair. Nothing in 169 tests touches pyproject.toml, so this surfaced only at build time and took its own release (da6b6e9, 0.1.0 → 0.1.1) to correct. __init__.py still reports __version__ = "0.1.0" — the bump never propagated past the manifest.
// new ground
What I hadn't used before
ε_cut = √((1/2m)·ln(4n/δ)), and discovering that the direction of a "sensitivity" knob is not a shared convention — ADWIN's δ and KSWIN's α are significance levels where larger means *earlier* detection, Page-Hinkley's δ is a minimum detectable magnitude where smaller means earlier. Exposing them all as one sensitivity parameter means the parameter reverses meaning depending on method, which is why tests/test_sensitivity_calibration.py exists as its own file: to pin the monotonicity direction per algorithm before anyone relies on it.tests/test_false_positive_rate.py). A drift detector that fires on stable data is worse than no detector, because it trains the on-call engineer to ignore it. This is the category that caught the most broken intermediate implementations.viz, alerts, yaml, pandas, sklearn), each guarded by a try: import at the call site that raises an ImportError naming the exact install command. What I understand now that I did not: this pattern makes the dependency graph clean and the *test* graph a liability, because the code paths behind each extra only run when someone happens to have the package installed — the sklearn breakage had every ingredient to sit undetected in a CI environment that pinned an older sklearn.// takeaways
What I took away
- A test that asserts a bound rather than a value will pass forever against a constant, which is how a hardcoded
confidence = 1.0survived twelve tests written specifically to check it. - The interesting output of a drift detector is not the alarm but the answer to "retrain on what?" — and that answer is the part no comparable library ships.
- Refusing a dependency is a real, defensible decision that transfers the maintenance burden onto you: sklearn's estimator protocol changed, and because I had opted out of inheriting from it, the break was mine to absorb.
- Four algorithms behind one
sensitivityparameter is a friendly API that quietly lies, because the parameter's direction inverts between ADWIN and Page-Hinkley — the abstraction needs either per-algorithm names or per-algorithm documentation, and I chose documentation. - Packaging metadata is untested code with a release-shaped blast radius; 169 passing tests said nothing about whether the wheel would build.
// the journey
How it actually went
I started this because of the retraining-schedule problem, not the detection problem. Detection
had four decent published answers; what nobody shipped was the thing that comes after the alarm.
That framing is why window_engine.py exists at all, and it is the reason the pipeline has five
stages rather than stopping at three.
I got the dependency question backwards at first. My instinct was to reach for scipy for the KS
test and pandas for input handling, and I spent a while there before accepting that a monitoring
library asking for a 30 MB scientific stack is a library that gets declined in review. Rewriting
the KS p-value against math alone was the moment the design clicked — once I was forced to hold
the whole statistical core in the standard library, the detector interface collapsed to two
methods and everything above it got simpler. The optional-extras pattern fell out of that
constraint rather than being planned.
The thing I got most wrong was trusting my own test suite. I wrote 169 tests across 14 categories and told myself the surface was covered. Then I actually ran everything on a current environment for this write-up and found three sklearn integration tests failing on an API change, plus a confidence score that is arithmetically incapable of returning anything but 1.0 despite a dedicated test file. Both had been sitting there. Neither is exotic — one is a dependency moving, one is a loop invariant I did not think through — and both are exactly what a second reader or a CI job would have caught in an afternoon.
What I would do differently: put CI in before the first release, not after — this repo has no
.github/ and no workflow file, and a matrix across sklearn versions would have caught the
__sklearn_tags__ break the week it landed. And I would stop writing the report before the
implementation is final. docs/system-report.md claims 175 tests with 151 passing; the actual
suite collects 169 and reports 166 passed, 3 failed, 1 skipped. The document drifted from the code
it describes, which is a slightly humiliating thing to have happen in a project about drift.
// outcome
What it changed
Shipped as pattern-drift 0.1.1 on 18 Jun 2026 — wheel and sdist built into dist/, Python 3.9+,
zero mandatory runtime dependencies, MIT. Four detection algorithms behind one interface, per
feature isolation, four-way drift-type classification, a retraining-window recommendation, and a
callback dispatcher with Slack/webhook/logging built in. Optional extras cover matplotlib, requests,
pyyaml, pandas, and scikit-learn.
Verified state as of this review, on Python 3.13.14 / sklearn 1.8.0 / pandas 2.3.3: 169 tests
collected, 166 passed, 3 failed, 1 skipped. The three failures are all
tests/test_sklearn_pipeline.py::TestPipelineIntegration, blocked on __sklearn_tags__; the skip
is the Hypothesis property-based module, which self-skips when hypothesis is not installed.
Two known defects are open: the always-1.0 retraining-window confidence, and __init__.py
reporting __version__ = "0.1.0" against a 0.1.1 manifest.
The library is documented against 12 downloaded source papers with 3 citations dropped for lack of
open access (docs/papers/DOWNLOAD_LOG.md).
1.1 was published to PyPI or only built locally — dist/ has the artifacts,
the repo has no publish step.
The cost model in docs/system-report.md §10.2 is a set of assumed inputs, not
measurements, and should not be quoted as a result.
// overview
In more detail
pattern-drift is a pure-Python, zero-dependency library for continuous, per-feature drift detection in production ML pipelines. It integrates ADWIN, Page-Hinkley, KSWIN, and DDM detectors in a five-stage pipeline: feature extraction → per-feature detection → temporal drift classification (sudden, gradual, incremental, recurring) → retraining window recommendation → alert dispatching. Includes a scikit-learn Pipeline wrapper, YAML config support, JSON/CSV report export, and 175 passing tests.
// features
What it does
- Four detection algorithms: ADWIN, Page-Hinkley, KSWIN, DDM
- Per-feature independent detector instances
- Drift type classification: sudden, gradual, incremental, recurring
- Retraining window recommendation with confidence scoring
- scikit-learn Pipeline transformer integration
- Zero mandatory runtime dependencies
- Built-in Slack, HTTP webhook, and logging callbacks
- 175 tests across 14 test files — 151 passing
// stack