Detecting Attribute Drift in Slowly Changing Layers
Slowly changing layers are the blind spot of spatial data quality. A cadastral parcel table, an administrative-boundary reference set, or a zoning layer might see a few hundred edited rows a quarter against millions of stable ones, so nobody watches it — and that is exactly when a vendor quietly re-codes a domain, a geocoder starts leaving a formerly-complete field null, or an out-of-band correction rewrites a handful of attributes in place. This focused guide covers how to catch that slow, low-amplitude attribute drift using pinned baseline snapshots and population-stability scoring. It sits under Schema & Attribute Drift Detection and within the wider Spatial Data Freshness & Quality Metrics program, and it is written for the data engineers and compliance teams who certify reference layers.
Why slow drift evades ordinary checks
A fast-moving feed self-audits: if a real-time GPS stream starts nulling a field, the volume makes the regression obvious within a scrape interval. A slowly changing layer offers no such signal. Between quarterly publications the row count barely moves, freshness looks perfect because the file did arrive on schedule, and geometry validity is unchanged because nobody touched the shapes. The only thing that moved is a distribution — and a distribution shift of a few percent against a stable baseline is invisible to every threshold tuned for a live feed. The defect is orthogonal to the datum guarantees in Coordinate Reference System Validation: the parcels are exactly where they were, they simply mean something subtly different.
Three drift shapes recur in reference data. Null creep — a formerly-complete field (say owner_type) starts arriving null because an upstream join broke on the vendor’s side. Category re-coding — the domain vocabulary shifts, either wholesale (R1 becomes 1) or by introducing a new class no downstream legend maps. Silent in-place edits — an out-of-band correction rewrites attribute values on a small set of rows without an entry in any change log, so no row-count or timestamp check ever sees it. Each needs a different detector, but all three share one prerequisite: a pinned baseline to compare against.
Baseline snapshots as the anchor
The baseline is a per-column statistical profile plus a content checksum, captured at a known-good publication and version-controlled alongside the pipeline code so a legitimate change is an explicit, reviewed baseline bump — never a silent overwrite. Store the profile as a compact row per column: null ratio, distinct count, and a JSON histogram of category frequencies for coded columns.
-- Capture a baseline profile for one coded reference column
INSERT INTO drift_baseline.parcels_profile (column_name, captured_at, null_ratio, distinct_count, histogram)
SELECT
'land_use',
now(),
count(*) FILTER (WHERE land_use IS NULL)::numeric / NULLIF(count(*), 0),
count(DISTINCT land_use),
jsonb_object_agg(land_use, freq) FILTER (WHERE land_use IS NOT NULL)
FROM (
SELECT land_use, count(*) AS freq FROM curated.parcels GROUP BY land_use
) h, curated.parcels;
The companion is a content checksum over the non-geometry attributes, which is what catches silent edits that a histogram would average away. Hashing the sorted per-row attribute digest yields one comparable value per snapshot:
-- Content fingerprint over immutable attributes of a versioned reference layer
SELECT md5(string_agg(row_hash, '' ORDER BY row_hash)) AS content_hash, count(*) AS n
FROM (
SELECT md5(parcel_id || '|' || COALESCE(land_use, '') || '|'
|| COALESCE(zone_code, '') || '|' || COALESCE(owner_type, '')) AS row_hash
FROM curated.parcels
) r;
When the content hash changes but the change-log table records no edits for the interval, that is a silent-edit alarm regardless of what the distributions say — the single most valuable check for a layer everyone assumes is frozen.
Population stability index for category drift
For coded columns, the population stability index (PSI) is the right lens because it is sensitive to a category whose proportion shifts even when the overall distinct count is unchanged — the exact signature of a one-for-one re-coding. With baseline proportion and current proportion across the observed categories:
Read it on the conventional bands: below 0.1 is stable, 0.1 to 0.25 is moderate drift worth a look, and 0.25 or above signals a re-coding that will break downstream legends and rollups. A new category appearing in the current data but absent from the baseline drives and the term explodes, which is the desired behavior — an unmapped code is exactly what should page. Compute it directly from the stored histogram:
-- PSI of the current land_use distribution against the pinned baseline histogram
WITH base AS (
SELECT key AS code, value::numeric AS b_count
FROM drift_baseline.parcels_profile, jsonb_each_text(histogram)
WHERE column_name = 'land_use'
),
cur AS (
SELECT land_use AS code, count(*)::numeric AS c_count
FROM curated.parcels WHERE land_use IS NOT NULL GROUP BY land_use
),
props AS (
SELECT
COALESCE(b.code, c.code) AS code,
COALESCE(b.b_count, 0) / NULLIF(sum(COALESCE(b.b_count,0)) OVER (), 0) AS b_prop,
COALESCE(c.c_count, 0) / NULLIF(sum(COALESCE(c.c_count,0)) OVER (), 0) AS c_prop
FROM base b FULL OUTER JOIN cur c ON b.code = c.code
)
SELECT round(sum(
(c_prop - b_prop) * ln(NULLIF(c_prop, 0) / NULLIF(b_prop, 0))
), 4) AS psi
FROM props
WHERE b_prop > 0 AND c_prop > 0;
Guard the b_prop = 0 and c_prop = 0 cases explicitly: a disappeared or brand-new code should be surfaced as a discrete gis.spatial.domain_code_drift_total event rather than folded into the PSI sum, where a division by zero would either error or hide it.
Null-ratio drift and emitting the signal
Null creep is simpler — a direct delta of the current null ratio against the baseline , alerted per column because a mandatory identifier and an optional note have completely different tolerances. A profiling job runs the comparison at each publication and emits the results through the OpenTelemetry SDK using the metric vocabulary defined by the parent Schema & Attribute Drift Detection guide, so a slow-layer regression lands in the same series as the fast-path drift signals:
from opentelemetry import metrics
meter = metrics.get_meter("gis.spatial.slow_drift")
null_ratio = meter.create_gauge("gis.spatial.attribute_null_ratio", unit="ratio")
null_delta = meter.create_gauge("gis.spatial.attribute_null_delta", unit="ratio")
psi_gauge = meter.create_gauge("gis.spatial.attribute_psi", unit="1")
def emit_slow_drift(table: str, column: str, ratio_now: float,
ratio_base: float, psi: float) -> None:
dims = {"table": table, "column": column}
null_ratio.set(ratio_now, dims)
null_delta.set(ratio_now - ratio_base, dims) # positive = creep
psi_gauge.set(psi, dims)
Because publications are infrequent, alert on the event of a new publication crossing a threshold rather than on a rate — a rate over a quarter is meaningless. The PromQL keys on the delta and PSI gauges the job sets, with the null baseline computed over a long window so a single stable publication does not skew it:
groups:
- name: spatial-slow-layer-drift
rules:
- alert: SlowLayerNullCreep
expr: gis_spatial_attribute_null_delta > 0.05
for: 0m
labels: { severity: warning }
annotations:
summary: "Null ratio up {{ $value | humanizePercentage }} on {{ $labels.table }}.{{ $labels.column }}"
- alert: SlowLayerRecoding
expr: gis_spatial_attribute_psi >= 0.25
for: 0m
labels: { severity: warning }
annotations:
summary: "PSI {{ $value }} on {{ $labels.table }}.{{ $labels.column }} — category re-coding"
When drift correlates with a change in publication cadence — the layer arriving early or late alongside the shift — reconcile it against Tracking Spatial Data Freshness SLAs to tell a genuine content regression apart from a partially-loaded publication, and against Automated Row Count & Attribute Sync to confirm whether rows were lost or merely re-valued.
FAQ
How is this different from monitoring a fast-moving feed?
Volume. A live feed makes a regression statistically obvious within a single scrape window, so a rate-based alert works. A slowly changing layer produces one comparable event per publication — weekly or quarterly — so there is no rate to alert on. You compare each publication against a pinned baseline and alert on the discrete crossing, which is why the baseline snapshot, not a rolling window, is the anchor.
What PSI threshold should I use for reference layers?
Start from the conventional bands — stable below 0.1, moderate 0.1–0.25, significant at 0.25 and above — and tighten only for authoritative layers where any re-coding is consequential. A cadastral or regulatory layer that is supposed to change one-for-one should treat a moderate PSI as actionable, because even a small proportional shift can indicate the vendor swapped a coding scheme.
Why keep a content checksum if I already compute PSI and null ratios?
Because a small in-place edit to a handful of rows barely moves an aggregate. Editing 40 rows in a two-million-row layer changes no null ratio and no histogram measurably, but it changes the content hash. If the hash moves and your change log records nothing, that is a silent edit — the defect class distribution statistics are structurally unable to catch.
How do I stop legitimate updates from paging every publication?
Make baseline refresh an explicit, reviewed step. When a publication drifts for a known reason — an approved re-coding or a planned schema change — bump the version-controlled baseline as part of accepting that publication. The alert then fires only on unexplained drift, and the baseline history doubles as an audit trail of every accepted change.
Can I profile only a sample instead of the full layer?
For null ratio and PSI, a stratified sample across partitions is fine and keeps the profiling cheap. For the content checksum, you must hash the whole layer — a sample cannot prove that no untouched row was edited. Run the cheap statistical profile every publication and the full-layer checksum on the immutability-critical columns only.
Related
- Schema & Attribute Drift Detection — the parent guide; structural and statistical drift across all spatial tables.
- Automated Row Count & Attribute Sync — confirms whether drift reflects lost rows or re-valued ones.
- Coordinate Reference System Validation — the orthogonal datum gate; slow drift assumes geometry is unchanged.
- Tracking Spatial Data Freshness SLAs — separates a content regression from a partially-loaded publication.
- Spatial Data Freshness & Quality Metrics — the measurement program these reference-layer checks belong to.