Detecting Regional Distribution Drift
A federated geospatial model degrades regionally long before it degrades globally: a new construction season changes road surfaces in one province, a device-firmware rollout changes GNSS characteristics in one operator’s footprint, and the national average never moves. Detecting that from private aggregates is a different problem from detecting it centrally, because the drift statistic must itself be computed on-device, bounded, and noised — and because its error bar sets the smallest drift you can ever see. This page builds the detector, under federated evaluation and monitoring in Federated Learning Workflows for Geospatial Data.
Parameter Configuration and Calibration
reference_window— what “normal” means. Drift is a comparison, so the reference has to be pinned: a fixed historical window (e.g. the four weeks after the last model release) rather than a rolling baseline. A rolling baseline drifts with the data and will silently normalise a genuine regression over a few weeks.statistic— what is compared. Compare distributions, not means. A bucketed histogram of the local feature or error distribution, compared with a total-variation or Jensen–Shannon distance, detects a shape change that a mean would miss entirely. Both statistics are bounded in , which conveniently makes their sensitivity trivial to state.alert_threshold— set from the error bar, then from the business. The floor is the noise: a threshold below the error bar fires constantly. The ceiling is what matters operationally. If the two are incompatible, the fix is a larger cohort or a longer window — never a quieter alert.consecutive_rounds— how many rounds a signal must persist. Requiring two or three consecutive breaches cuts the false-alarm rate roughly quadratically while delaying detection by the same number of rounds. For a daily round cadence, two is a good default.
| Statistic | Range | Sensitivity per client | Detects |
|---|---|---|---|
| Total-variation distance | any shape change | ||
| Jensen–Shannon distance | shape, smoother tails | ||
| Mean shift | unbounded | needs clipping | location changes only |
| Bucket-share change | per bucket | which part of the shape moved |
Reference Implementation
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from typing import Mapping, Sequence
@dataclass(frozen=True)
class DriftSignal:
region: str
distance: float
error_bar: float
contributors: int
breached: bool
worst_bucket: int
def total_variation(p: Sequence[float], q: Sequence[float]) -> float:
"""TV distance between two normalised histograms; bounded in [0, 1]."""
if len(p) != len(q):
raise ValueError("histograms must share their bucket edges")
sp, sq = sum(p), sum(q)
if sp <= 0 or sq <= 0:
return 0.0
return 0.5 * sum(abs(a / sp - b / sq) for a, b in zip(p, q))
def jensen_shannon(p: Sequence[float], q: Sequence[float]) -> float:
"""JS distance — the square root of the JS divergence, also bounded in [0, 1]."""
sp, sq = sum(p), sum(q)
if sp <= 0 or sq <= 0:
return 0.0
pa = [a / sp for a in p]
qa = [b / sq for b in q]
def kl(x: Sequence[float], y: Sequence[float]) -> float:
return sum(xi * math.log(xi / yi) for xi, yi in zip(x, y) if xi > 0 and yi > 0)
m = [(a + b) / 2 for a, b in zip(pa, qa)]
return math.sqrt(max(0.0, 0.5 * kl(pa, m) + 0.5 * kl(qa, m)) / math.log(2))
def detect_drift(
region: str,
current: Sequence[float],
reference: Sequence[float],
*,
contributors: int,
epsilon: float,
threshold: float,
seed: int = 0,
) -> DriftSignal:
"""Compare a region's noised histogram against a pinned reference.
Both histograms are already DP aggregates, so no further noise is added here —
adding it again would double-charge the budget for a statistic computed from
released values. The error bar propagates the aggregate's own uncertainty into
the distance, which is what makes the threshold comparison honest.
"""
distance = total_variation(current, reference)
# A histogram bucket's noise is Laplace(cap/eps); normalised, the distance
# inherits roughly that over the contributor count, per bucket.
per_bucket = math.sqrt(2.0) / (epsilon * max(1, contributors))
error_bar = 1.96 * per_bucket * math.sqrt(len(current)) / 2.0
diffs = [
abs(a / max(1e-9, sum(current)) - b / max(1e-9, sum(reference)))
for a, b in zip(current, reference)
]
return DriftSignal(
region=region,
distance=distance,
error_bar=error_bar,
contributors=contributors,
breached=distance > max(threshold, error_bar),
worst_bucket=int(max(range(len(diffs)), key=lambda i: diffs[i])),
)
def persistent_breaches(
history: Mapping[str, Sequence[DriftSignal]], *, required: int = 2
) -> dict[str, int]:
"""Regions whose most recent `required` rounds all breached.
Requiring persistence trades detection latency for a large reduction in false
alarms — with independent rounds the false-alarm rate falls roughly as the
per-round rate raised to `required`.
"""
out: dict[str, int] = {}
for region, signals in history.items():
recent = list(signals)[-required:]
if len(recent) == required and all(s.breached for s in recent):
out[region] = recent[-1].worst_bucket
return out
Validation Checkpoint
def _validate() -> None:
rng = random.Random(31)
reference = [1200.0, 3400.0, 2900.0, 1100.0, 400.0, 90.0]
# 1. An unchanged distribution must not breach.
same = [v * (1 + rng.gauss(0, 0.01)) for v in reference]
quiet = detect_drift("stable", same, reference, contributors=800,
epsilon=1.0, threshold=0.05)
assert not quiet.breached, quiet
# 2. A real shape change must breach, and must name the moved bucket.
shifted = [400.0, 1200.0, 2900.0, 3100.0, 1300.0, 200.0]
loud = detect_drift("shifted", shifted, reference, contributors=800,
epsilon=1.0, threshold=0.05)
assert loud.breached and loud.distance > 0.15, loud
assert loud.worst_bucket in (0, 1, 3), loud.worst_bucket
# 3. A tiny cohort must widen the bar enough to suppress a marginal signal.
marginal = [1150.0, 3300.0, 3000.0, 1150.0, 410.0, 95.0]
small = detect_drift("small", marginal, reference, contributors=12,
epsilon=1.0, threshold=0.01)
assert small.error_bar > small.distance, small
# 4. Both distance measures must agree on direction, if not on magnitude.
assert jensen_shannon(shifted, reference) > jensen_shannon(same, reference)
# 5. Persistence must require consecutive breaches, not merely frequent ones.
hist = {
"a": [loud, quiet, loud], # alternating: not persistent
"b": [quiet, loud, loud], # last two breached: persistent
}
flagged = persistent_breaches(hist, required=2)
assert "b" in flagged and "a" not in flagged
# 6. Distances are bounded, so a degenerate region cannot produce a huge signal.
assert 0.0 <= total_variation([0.0, 0.0, 1.0], reference) <= 1.0
print("regional drift detection: all assertions passed")
_validate()
Assertion 3 is the property that keeps a drift dashboard credible. A sparse region’s distance estimate is dominated by its own noise, and a detector that does not widen the threshold with the error bar will flag exactly the regions it knows least about — which is how teams learn to ignore the alert.
Incident Response and Edge Cases
- Every region breaches at once. Something global changed: a model release, a feature-extraction change, or the reference window rolling over. Check the deployment log before investigating geography; a synchronised signal across independent regions is almost never a data phenomenon.
- One region breaches and its cohort just shrank. Composition change, not distribution change. The clients that dropped out are not a random subset — they are the ones with worse connectivity — so the surviving distribution genuinely differs. Track cohort composition alongside the distance.
- The worst bucket is always the last one. Tail buckets are the sparsest and therefore the noisiest in relative terms. Consider merging the extreme buckets, or report bucket-level attribution only when that bucket’s own share exceeds its own error bar.
- Drift is detected but the model’s loss is unchanged. Possible and important: the input distribution moved while the model still handles it. That is an early warning rather than a regression, and it is exactly the case where a rolling baseline would have hidden the signal.
- The reference window is stale after a model release. Every model release invalidates the reference, because the error distribution is a property of the model. Re-pin the reference on release, and mark the transition on the dashboard so historical comparisons are not read across it.
Frequently Asked Questions
Why compare distributions instead of means?
Because the failures that matter are usually shape changes. A model whose errors develop a heavy tail in one region has the same mean error and a much worse product; a bucketed distance sees that immediately, and a mean never will.
Should the drift statistic be computed on-device or on the server?
On the server, from already-published aggregates. The device's job is to emit a bounded histogram; computing a distance on-device would add a second release with its own budget and would make the reference window part of the client build.
How small a drift can be detected?
Roughly the size of the error bar, which scales inversely with the cohort size and epsilon. Solve for the cohort you need rather than tuning the threshold downward — a threshold below the bar produces alerts that carry no information.
Does a fixed reference window ever need to move?
Yes, at every model release, and after a deliberate change to the feature pipeline or the bucket edges. Those are the events that redefine what "normal" means. Anything else — including a long-running drift — should move the alert, not the reference.
Related
- Federated Evaluation and Monitoring — the parent design and its budget model.
- Private Federated Metrics for Spatial Models — the histograms this detector consumes.
- Debugging Federated Rounds Without Seeing Data — what to do once a region is flagged.
- Handling Non-IID Geospatial Data in Federated Learning — the underlying regional heterogeneity.
Up one level: Federated Evaluation and Monitoring · Section: Federated Learning Workflows for Geospatial Data.