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.

Baseline snapshot compared against each publication of a slowly changing layer A pinned baseline snapshot of a reference layer captures per-column null ratio, category histogram, and a content checksum. Each successive publication is profiled the same way and compared. Small expected edits stay below thresholds and refresh the baseline. A null-ratio jump or a population stability index above 0.25 raises drift. A checksum change with no logged edit raises a silent-edit alarm. Pinned baseline vs each publication Baseline snapshot null ratio · histogram content checksum New publication profiled identically Compare PSI · null-delta · hash Expected edit refresh baseline Distribution drift PSI ≥ 0.25 / null jump Silent edit hash changed, no log

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 bib_i and current proportion cic_i across the kk observed categories:

PSI=i=1k(cibi)ln ⁣(cibi)\text{PSI} = \sum_{i=1}^{k} (c_i - b_i)\,\ln\!\left(\frac{c_i}{b_i}\right)

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 bi0b_i \to 0 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 ρ0\rho_0, 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.10.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.