Time-Series Compliance Drift Analysis
Within the Reporting & Compliance Dashboards layer, drift analysis is the stage that stops treating each fixture’s compliance_percentage as an isolated number and starts treating it as a time series — a sequence of scores keyed by fixture_id and capture_timestamp that carries a trend, a seasonal shape, and, occasionally, a genuine merchandising decline hiding underneath both. The hard problem is not plotting the line; it is deciding whether a downward move is real. A store that resets its endcaps every Monday, a category that softens every January, and a camera whose capture cadence quietly halved over a holiday will all bend the curve in ways that look exactly like decay to a naive threshold. This page defines the typed record the drift series is built from, the rolling-baseline and decomposition architecture that separates signal from season, the windows and z-score thresholds that tune it, the failure modes that generate false alarms in production, and the partitioning strategy that keeps per-fixture drift detection cheap across a chain of thousands of stores.
Concept & Data Contract Jump to heading
Everything downstream of this page treats compliance as a signal that moves over time, so the inbound boundary is not a single score but an ordered series. Each point is a compliance observation produced upstream by the Planogram Sync & SKU Mapping Strategies layer and delivered to reporting through the Compliance Score APIs & Payload Contracts surface. The series is keyed by the triple of fixture_id, capture_timestamp, and registry_revision: the fixture says which shelf, the timestamp says when, and the revision says against which version of the planogram the score was computed. That third key is not optional — a compliance_percentage scored against revision 183 and one scored against revision 184 are measurements of different targets, and comparing them as if they were the same series is the single most common way drift analysis lies to a category manager.
A typed drift observation, before any decomposition runs, looks like this:
{
"store_id": "STORE-0417",
"fixture_id": "BAY-014-SHELF-03",
"capture_timestamp": "2026-06-28T07:42:11Z",
"registry_revision": 184,
"compliance_percentage": 0.883,
"out_of_stock_flags": 2,
"misplaced_sku_list": ["00098765432107"],
"sample_captures": 3,
"reset_window": false
}The record carries more than the headline score because the drift stage needs context the score alone cannot supply. sample_captures is the number of frames that were aggregated into this point; a value of 1 on a day that normally sees 4 is a cadence artifact, not a compliance event, and the analytics must be able to down-weight it. The reset_window boolean marks whether the observation fell inside a scheduled planogram reset, when the shelf is legitimately torn apart and low compliance is expected and meaningless. out_of_stock_flags and misplaced_sku_list travel alongside the percentage so that when drift is confirmed the alert can say why the score fell, not merely that it did.
The outbound contract of this stage is a drift verdict per fixture: the current deviation from baseline expressed as a z-score, a boolean is_drifting, an optional changepoint timestamp, and the residual after the seasonal and trend components have been removed. That verdict is what the Real-Time Compliance Alerting & Webhooks surface subscribes to and what the Category Manager Briefing Automation rolls into a morning summary. The verdict must be reproducible: given the same series and the same configuration, the same points must flag, which means every parameter — window length, threshold, minimum sample count — lives in versioned configuration rather than in code, and the registry_revision boundaries are recorded on the verdict so a reset-driven baseline reset is never mistaken for a detection.
Implementation Architecture Jump to heading
The naive detector — alert when today’s compliance_percentage drops below a fixed floor like 0.85 — fails on two fronts at once. It fires every Monday morning on stores that reset over the weekend, and it stays silent through a slow three-week decline that never crosses the floor but represents a real and worsening merchandising problem. A correct detector removes the predictable structure first, then tests what remains. Two complementary techniques do this, and a production system runs both: a rolling baseline with a z-score test for fast, low-latency detection, and a seasonal-trend decomposition for series long enough to have a stable seasonal shape.
Rolling baseline and z-score Jump to heading
The rolling approach maintains a trailing window of recent observations, computes the baseline as their median and the spread as a robust scale, and expresses the newest point as a number of deviations from that baseline. Using the median and the median absolute deviation rather than the mean and standard deviation matters: a single reset-day zero would blow up a standard deviation and desensitize the detector for days afterward, whereas the robust statistics shrug it off.
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class DriftVerdict:
fixture_id: str
z_score: float
is_drifting: bool
baseline: float
residual: float
def rolling_drift(
fixture_id: str,
series: np.ndarray, # compliance_percentage, oldest -> newest
reset_mask: np.ndarray, # True where reset_window applied
window: int = 21,
z_threshold: float = 3.0,
min_samples: int = 10,
) -> DriftVerdict:
"""Flag drift when the newest point deviates from a robust rolling baseline.
Reset-window points are masked out of the baseline so a scheduled teardown
never poisons the reference the newest capture is judged against.
"""
if series.shape != reset_mask.shape:
raise ValueError("series and reset_mask must align")
if len(series) < 2:
raise ValueError("need at least two observations to test drift")
latest = float(series[-1])
history = series[:-1][~reset_mask[:-1]] # drop reset days from baseline
history = history[-window:]
if len(history) < min_samples:
# too few clean points to judge; report no drift rather than a false one
return DriftVerdict(fixture_id, 0.0, False, latest, 0.0)
baseline = float(np.median(history))
mad = float(np.median(np.abs(history - baseline)))
scale = 1.4826 * mad or 1e-6 # MAD -> std-equivalent, guard /0
z = (latest - baseline) / scale
residual = latest - baseline
# only downward deviations are drift; an upward spike is not a compliance failure
is_drifting = z <= -z_threshold
return DriftVerdict(fixture_id, round(z, 2), is_drifting, baseline, round(residual, 4))Seasonal decomposition and changepoints Jump to heading
For fixtures with enough history to show a repeating weekly or annual pattern, a rolling baseline alone will either absorb the season into its window (and miss drift that coincides with a seasonal trough) or fire on the trough itself. Seasonal-trend decomposition using LOESS (STL, from statsmodels) splits the series into trend, seasonal, and residual components, and the residual is where genuine drift lives once the predictable shape is subtracted. Testing the residual with the same z-score logic, or running a changepoint detector over the trend, catches slow declines that never trip a fixed floor.
import numpy as np
from statsmodels.tsa.seasonal import STL
def seasonal_residual_drift(
series: np.ndarray, # regularly-spaced compliance_percentage
period: int = 7, # weekly seasonality on daily captures
z_threshold: float = 3.0,
) -> tuple[bool, float]:
"""Decompose the series and test the newest residual for drift.
STL removes trend and weekly seasonality; the residual isolates the
unexplained movement that a real merchandising decline shows up in.
"""
if len(series) < 2 * period:
raise ValueError(f"STL needs at least two full periods ({2 * period} points)")
result = STL(series, period=period, robust=True).fit()
resid = result.resid
scale = 1.4826 * np.median(np.abs(resid - np.median(resid))) or 1e-6
z_latest = float((resid[-1] - np.median(resid)) / scale)
return (z_latest <= -z_threshold, round(z_latest, 2))The library choices are deliberate. NumPy carries the rolling path because it is dependency-light and fast enough to run inline in the alerting consumer, giving sub-millisecond verdicts per fixture. statsmodels owns decomposition because STL’s robust mode is specifically resistant to the outliers a reset day injects, and it exposes trend and seasonal components separately so a changepoint search can run on the smooth trend rather than the noisy raw series. Running both is not redundant: the rolling z-score is the low-latency tripwire that fires within one capture of a sharp break, while the decomposition is the slower, higher-confidence judge that separates a January category softening from an actual execution failure. The seasonal-specific extension of this decomposition — including how to fit an annual period on top of the weekly one — is worked end to end in Detecting Seasonal Compliance Drift with Python.
Production Configuration & Tuning Jump to heading
The parameters, not the algorithm, are what make drift detection agree with an experienced category manager, and they belong in versioned configuration so they can be reconciled against reality without a deploy. The knobs interact, so they are tuned together:
drift_analysis:
rolling:
window_days: 21 # trailing baseline length; ~3 retail weeks
z_threshold: 3.0 # deviations below baseline before flagging
min_samples: 10 # clean points required, else report no drift
seasonal:
period_days: 7 # weekly seasonality on daily captures
annual_period_days: 364 # optional second period for year-long series
residual_z_threshold: 3.0
reset_masking:
mask_window_hours: 48 # exclude captures within this of a reset event
require_registry_stable: true # do not span a registry_revision change
cadence:
min_sample_captures: 2 # points below this are down-weighted, not scored
max_gap_days: 3 # gaps beyond this break the series, not interpolateThe window_days of 21 is a compromise: long enough that a single noisy capture does not move the baseline, short enough that a baseline established six months ago does not anchor today’s judgment to a store that has since been remodeled. The z_threshold of 3.0 is the most consequential number on the page — at 3.0 roughly one in a thousand purely random points flags, which across a chain of many fixtures is still a manageable alert volume, whereas dropping to 2.0 roughly quadruples false positives and trains category managers to ignore the feed. The min_samples floor of 10 is a refusal to guess: a fixture with fewer than ten clean baseline points reports is_drifting = false rather than emitting a confident verdict from three data points, because a false alarm that sends a district manager to a compliant store costs more trust than a missed slow decline that the decomposition will catch a week later anyway.
The reset_masking block is what separates this stage from a naive detector. mask_window_hours of 48 drops every capture within two days of a scheduled reset so the teardown-and-rebuild period never enters a baseline or triggers a flag, and require_registry_stable forbids a rolling window from spanning a registry_revision change — when the planogram version bumps, the baseline resets and the series starts fresh, because the old scores measured a target that no longer exists. The cadence block defends against the quietest failure of all: min_sample_captures down-weights points built from too few frames, and max_gap_days declares that a gap longer than three days breaks the series rather than being silently bridged, so a store whose camera went dark over a holiday does not have its outage interpolated into a smooth, fictitious decline. The z-threshold and the compliance floor it sits above are reconciled against ground-truth audits using the same discipline described in Threshold Tuning for Compliance Accuracy — drift detection tunes the rate of change while threshold tuning tunes the level, and confusing the two leads to chasing recalibrations that a drift flag would have explained.
Failure Modes & Debugging Workflow Jump to heading
When the drift feed looks wrong, the root cause is almost always one of five recurring problems. Work them in this order:
- A reset masquerading as drift. Symptom: a group of fixtures in one store all flag
is_driftingon the same morning, withcompliance_percentagecollapsing toward zero and recovering a day later. Reproduce by scoring any store across its scheduled reset window with masking disabled. Fix: confirm thereset_windowboolean is populated on the inbound record and thatmask_window_hourscovers the actual teardown duration — a reset that runs longer than the mask leaks its tail into the baseline. Never widenz_thresholdto suppress these; that blinds the detector to real drift everywhere else. - Sparse capture cadence faking a trend. Symptom: a fixture drifts slowly and monotonically with no corresponding change in
out_of_stock_flagsormisplaced_sku_list, and itssample_captureshas been declining for weeks. Reproduce by thinning a healthy series to one capture every few days. Fix: enforcemin_sample_capturesso thin points are down-weighted, and honormax_gap_daysso a broken cadence severs the series instead of being interpolated — the phantom trend is an artifact of aggregating fewer and fewer frames, not of the shelf. - A registry_revision break resetting the baseline. Symptom: every fixture on a planogram flags drift simultaneously the day a new revision goes live, then settles. Reproduce by bumping
registry_revisionmid-window withrequire_registry_stableoff. Fix: enforcerequire_registry_stableso the rolling window never spans a revision change; the baseline must restart at the revision boundary. This is also why the verdict records the revision it was computed under — see how history is repaired across such a boundary in Backfilling Compliance History After a Schema Change. - A seasonal trough read as decline. Symptom: the same fixtures flag every year in the same week, or every week on the same weekday, always recovering on schedule. Reproduce by running the rolling detector alone over a full seasonal cycle. Fix: route long-enough series through STL so the seasonal component is subtracted before the residual is tested; a dip the seasonal component predicts should leave a residual near zero and must not flag.
- A timezone or DST bug shifting the season. Symptom: seasonal alignment slips by an hour twice a year, or morning captures land in the wrong day’s bucket, smearing the weekly seasonal shape and inflating residuals. Reproduce by bucketing
capture_timestampin local time across a DST boundary. Fix: normalize everycapture_timestampto UTC before bucketing and decomposition, and derive the local reset schedule separately — the series is indexed in UTC, the reset calendar is resolved per store. An upstream cause worth ruling out before blaming the season is model drift in the detector feeding the scores; when compliance decays because the vision model itself degraded, the fix belongs in Error Handling in Computer Vision Pipelines, not in the drift thresholds here.
Keep a labeled repository of confirmed flags bucketed by these five causes. A monthly review of which bucket dominates tells you whether to fix masking, defend cadence, harden revision boundaries, extend decomposition, or audit timestamp handling — and it is the only durable way to keep the alert feed trustworthy enough that a category manager still opens it after a quarter.
Scaling & Performance Benchmarks Jump to heading
Drift analysis is embarrassingly parallel across fixtures: each fixture_id is an independent series, so the workload shards cleanly by partitioning on store_id for typical stores and on fixture_id for flagship locations with hundreds of bays. The rolling z-score verdict is a handful of NumPy operations over a 21-point window and clears in well under 1 ms per fixture, cheap enough to run inline in the alerting consumer on every new capture. STL decomposition is heavier — tens of milliseconds for a series of a few hundred points — so it runs on a slower cadence, typically once nightly per fixture rather than on every capture, and its verdict is cached until the next scheduled recompute.
The scaling constraint is series length, not fixture count. A fixture captured four times daily accumulates over a thousand points a year, and running STL over the full history on every recompute is wasteful when only the recent tail can drift. Windowed aggregation solves it: pre-aggregate captures into daily points, keep the rolling detector on the daily series, and cap the decomposition input at two or three seasonal cycles by downsampling older history rather than carrying every raw capture. Store the aggregated daily series in a columnar store partitioned by store_id and registry_revision so a recompute reads only the relevant partition, and design against a soft SLA of the full nightly drift pass completing before the morning briefing window — for a chain of thousands of stores that is comfortably met on a modest worker pool because each series is small and independent, so the pass scales linearly with added workers. Watch consumer lag on the drift partition rather than CPU; a backlog almost always means a chain-wide reset flooded the pipeline with reset-window captures, which the masking layer will discard but must still ingest, and adding workers clears it linearly. As with the rest of the platform, sync only the compact drift verdict upstream — the z-score, the boolean, the changepoint timestamp — never the raw series, keeping the drift tier a rounding error on the analytics bill while still emitting a per-fixture, per-capture audit trail across every store.
Frequently Asked Questions Jump to heading
How do I tell a real compliance decline from a seasonal dip? Remove the predictable structure first, then test what is left. A rolling baseline judges the newest point against a robust median of recent clean history, but on series long enough to have a repeating shape you also run STL decomposition, which subtracts the trend and seasonal components and leaves a residual. A dip the season predicts leaves a residual near zero and must not flag; a decline the season does not predict leaves a large negative residual and does. Running both — the fast rolling tripwire and the slower decomposition judge — is what separates a January category softening from an actual execution failure.
Why use the median and MAD instead of the mean and standard deviation? Because a single reset day or a dropped capture is an extreme outlier, and the mean and standard deviation are not robust to it. One reset-day score near zero inflates the standard deviation enough to desensitize the detector for days, so real drift that follows goes unnoticed. The median and the median absolute deviation ignore that outlier entirely, keeping the baseline stable and the z-score meaningful. Reset-window points are also masked out of the baseline explicitly, so the robust statistics are a second line of defense, not the only one.
What z-score threshold should I start with, and what does it trade off?
Start at 3.0. At that level roughly one in a thousand purely random points flags, which stays manageable across a large chain, and only downward deviations are treated as drift because an upward move is not a compliance failure. Dropping to 2.0 roughly quadruples the false-positive rate and quickly trains category managers to ignore the feed, which is a far worse outcome than a slow decline caught a few days late by the decomposition path. Tune the threshold against ground-truth audits, and treat drift detection as tuning the rate of change while the compliance floor tunes the level.
How does registry_revision affect the drift baseline?
It bounds it. A compliance_percentage scored against revision 183 measures a different target than one scored against revision 184, so a rolling window must never span a revision change. When the revision bumps, the baseline resets and the series restarts, which is why every drift verdict records the revision it was computed under. Without that boundary, the day a new planogram goes live every fixture on it appears to drift simultaneously — a pure artifact of comparing scores across incompatible targets.
What stops a camera outage from looking like a decline?
The cadence guards. Each observation carries sample_captures, the number of frames aggregated into it, and points built from too few frames are down-weighted rather than scored, so a fixture whose capture rate quietly halved does not manufacture a trend. A gap longer than max_gap_days breaks the series instead of being interpolated, so a store whose camera went dark over a holiday is not handed a smooth fictitious decline across the outage. Cadence artifacts are the quietest failure mode in drift analysis precisely because the curve still looks plausible, so the defense is structural rather than a threshold.
Related Jump to heading
- Detecting Seasonal Compliance Drift with Python — the end-to-end STL walkthrough that fits weekly and annual seasonality on the residual test here
- Backfilling Compliance History After a Schema Change — how to repair the series across a registry_revision boundary so baselines stay valid
- Threshold Tuning for Compliance Accuracy — the upstream discipline that tunes the compliance level, distinct from tuning the rate of change here
- Error Handling in Computer Vision Pipelines — where model drift, an upstream cause of falling scores, is diagnosed and fixed
- Reporting & Compliance Dashboards — the parent layer that consumes the typed compliance payload and turns it into APIs, drift analytics, alerts, and briefings