M
Back to package projects
packageMar 2026 – Jun 2026

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

Implementing statistical tests from papers rather than calling themI had used KS tests and CUSUM as library functions. Writing them meant reading Hoeffding's bound closely enough to place the harmonic mean of sub-window sizes correctly in ε_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.
False-positive-rate testing as a first-class test categoryI was used to testing that code detects the thing. Here the more useful test is that it *doesn't* — 1,000 samples of stable Gaussian noise through each detector, asserting the alarm rate stays under its theoretical bound (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.
Zero-mandatory-dependency packaging with optional extrasFive extras (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.0 survived 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 sensitivity parameter 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

Built with

Python
Pandas
scikit-learn
Matplotlib
PyYAML
Hypothesis
pytest
Questions about this build? Get in touch