Detecting Data Drift Between Deliveries
This page profiles every incoming delivery and compares it with the last accepted one — the schema and attribute names, the classification mix, point density, CRS and vertical datum, coordinate extents and the distribution of values — so that a delivery which is valid but different is caught before it propagates into the twin, in EPSG:25832+7837.
Why you hit this
Validation asks whether a delivery is well formed. Drift detection asks whether it is the same kind of data as last time, and the answers diverge more often than anyone expects. A contractor upgrades their classification software and class 5 starts including what used to be class 4. A register adds a column and renames another. An aerial survey is flown at a different altitude, so density halves. Each delivery passes every schema check and each one silently changes what the twin shows — and because the pipeline succeeded, nobody looks until a user notices that all the trees disappeared. The monitoring context is in pipeline observability and monitoring.
Prerequisites
- Python 3.10+ with
laspy[lazrs]>=2.5,numpy>=1.24,geopandas>=0.14,pyproj>=3.6. - A place to keep accepted profiles — a JSON file per dataset in the repository is enough, and being in version control is an advantage.
- A convention for what a “delivery” is: one tile, one batch, one nightly extract. The profile is per delivery unit.
Step-by-Step
1. Profile a point cloud delivery
import json
from pathlib import Path
import laspy
import numpy as np
def profile_point_cloud(path, sample=2_000_000):
with laspy.open(path) as reader:
h = reader.header
crs = h.parse_crs()
las = reader.read()
n = h.point_count
idx = (np.random.default_rng(11).choice(n, min(sample, n), replace=False)
if n > sample else np.arange(n))
xyz = np.column_stack([las.x, las.y, las.z])[idx]
cls = np.asarray(las.classification)[idx]
classes, counts = np.unique(cls, return_counts=True)
area = float((h.maxs[0] - h.mins[0]) * (h.maxs[1] - h.mins[1]))
return {
"kind": "point_cloud",
"file": Path(path).name,
"points": int(n),
"crs": crs.to_string() if crs else None,
"epsg": crs.to_epsg() if crs else None,
"has_vertical_crs": bool(crs and len(crs.sub_crs_list) > 1) if crs else False,
"point_format": int(h.point_format.id),
"extra_dims": sorted(d.name for d in h.extra_dimensions),
"density_per_m2": round(n / area, 2) if area else None,
"class_mix": {int(c): round(float(k) / len(cls), 4) for c, k in zip(classes, counts)},
"z_percentiles": [round(float(v), 2) for v in np.percentile(xyz[:, 2], [1, 50, 99])],
"intensity_p99": int(np.percentile(np.asarray(las.intensity)[idx], 99)),
"returns_max": int(np.asarray(las.number_of_returns)[idx].max()),
}
profile = profile_point_cloud("deliveries/2026-09/tile_691_5335.laz")
print(json.dumps(profile, indent=2)[:600])
The profile is deliberately about shape rather than content: proportions instead of counts, percentiles instead of values, sorted names instead of order. That makes it comparable between deliveries of different sizes and different areas, which is the whole requirement — a tile with twice the points is not drift, and a tile whose vegetation share fell from 22% to 3% is.
2. Profile a vector delivery
import geopandas as gpd
def profile_vector(path, layer=None):
gdf = gpd.read_file(path, layer=layer)
geom_types = gdf.geometry.geom_type.value_counts(normalize=True).round(4).to_dict()
numeric = {}
for col in gdf.select_dtypes("number").columns:
s = gdf[col].dropna()
if len(s):
numeric[col] = [round(float(v), 3) for v in np.percentile(s, [1, 50, 99])]
return {
"kind": "vector",
"file": Path(path).name,
"layer": layer,
"features": int(len(gdf)),
"epsg": gdf.crs.to_epsg() if gdf.crs else None,
"columns": sorted(gdf.columns.drop("geometry").tolist()),
"dtypes": {c: str(t) for c, t in sorted(gdf.dtypes.astype(str).items()) if c != "geometry"},
"geom_types": geom_types,
"null_share": {c: round(float(gdf[c].isna().mean()), 4) for c in gdf.columns if c != "geometry"},
"numeric_percentiles": numeric,
"bounds": [round(float(v), 1) for v in gdf.total_bounds],
"invalid_share": round(float((~gdf.geometry.is_valid).mean()), 4),
}
vprofile = profile_vector("deliveries/2026-09/buildings.gpkg", layer="buildings")
For vector data the schema is the first thing that drifts and the easiest to check: a sorted column list and a dtype map catch a renamed field, a new column and a type change from integer to string. The null share per column catches the subtler version, where a column still exists and has stopped being populated.
3. Compare against the last accepted profile
def compare(profile, accepted, tolerances=None):
tol = {"density_per_m2": 0.25, "class_share": 0.05, "features": 0.25,
"percentile": 0.20, "null_share": 0.05, **(tolerances or {})}
findings = []
for key in ("epsg", "point_format", "kind"):
if key in accepted and profile.get(key) != accepted.get(key):
findings.append(("critical", key, accepted.get(key), profile.get(key)))
if accepted.get("has_vertical_crs") and not profile.get("has_vertical_crs"):
findings.append(("critical", "has_vertical_crs", True, False))
for key in ("columns", "extra_dims"):
old, new = set(accepted.get(key) or []), set(profile.get(key) or [])
if old - new:
findings.append(("critical", f"{key}:removed", sorted(old - new), None))
if new - old:
findings.append(("warning", f"{key}:added", None, sorted(new - old)))
if accepted.get("density_per_m2") and profile.get("density_per_m2"):
rel = abs(profile["density_per_m2"] - accepted["density_per_m2"]) / accepted["density_per_m2"]
if rel > tol["density_per_m2"]:
findings.append(("warning", "density_per_m2",
accepted["density_per_m2"], profile["density_per_m2"]))
for cls, share in (accepted.get("class_mix") or {}).items():
new_share = (profile.get("class_mix") or {}).get(str(cls), (profile.get("class_mix") or {}).get(int(cls), 0.0))
if abs(new_share - share) > tol["class_share"]:
findings.append(("warning", f"class_{cls}_share", share, round(new_share, 4)))
for col, pct in (accepted.get("numeric_percentiles") or {}).items():
new_pct = (profile.get("numeric_percentiles") or {}).get(col)
if new_pct and pct[1] and abs(new_pct[1] - pct[1]) / max(abs(pct[1]), 1e-9) > tol["percentile"]:
findings.append(("warning", f"{col}_median", pct[1], new_pct[1]))
return findings
accepted = json.loads(Path("profiles/city_point_cloud.json").read_text())
findings = compare(profile, accepted)
for sev, key, old, new in findings:
print(f"{sev.upper():<9}{key:<28}{old} → {new}")
The severities encode what can be automated and what cannot. A changed EPSG, a lost vertical CRS or a removed column is a critical finding, because downstream code will either fail or silently misplace data. A density change, a shifted class mix or a new column is a warning: it might be a legitimate change in the survey, and a human has to say so.
4. Gate the pipeline, and record the decision
import sys
def gate_on_drift(findings, allow_file="profiles/accepted_drift.json"):
allowed = json.loads(Path(allow_file).read_text()) if Path(allow_file).exists() else {}
blocking, noted = [], []
for sev, key, old, new in findings:
note = allowed.get(key)
if note and str(note.get("new")) == str(new):
noted.append((key, note["reason"]))
elif sev == "critical":
blocking.append((key, old, new))
else:
noted.append((key, "warning, not blocking"))
for key, reason in noted:
print(f"noted: {key} — {reason}")
if blocking:
for key, old, new in blocking:
print(f"BLOCKING: {key} changed {old} → {new}")
sys.exit(2)
return noted
gate_on_drift(findings)
The allow file is the mechanism that keeps this from becoming noise. When a delivery legitimately changes — the contractor’s new classifier really is better — the change is recorded with its reason and the new value, and the gate stops complaining about that specific change while still catching the next one. It is a short JSON file, reviewed in a pull request, which is exactly where a decision about data semantics belongs.
5. Promote a profile once a delivery is accepted
def accept_profile(profile, path, note):
record = {**profile, "accepted_at": datetime.now(timezone.utc).isoformat(), "note": note}
Path(path).write_text(json.dumps(record, indent=2, sort_keys=True))
print(f"profile accepted: {path}")
return record
accept_profile(profile, "profiles/city_point_cloud.json",
note="2026-09 delivery; classifier upgraded, class 4/5 split reviewed")
Promoting the profile is the step that closes the loop, and it must be deliberate rather than automatic — a pipeline that overwrites the accepted profile on every run has no baseline and detects nothing. Keeping the file in the repository means each promotion is a reviewable commit with a note explaining what changed.
Expected Output & Verification
CRITICAL has_vertical_crs True → False
WARNING class_5_share 0.2184 → 0.0312
WARNING class_4_share 0.0421 → 0.2203
WARNING density_per_m2 18.4 → 31.2
noted: class_4_share — 2026-09: classifier now splits medium/high vegetation differently
noted: class_5_share — 2026-09: classifier now splits medium/high vegetation differently
BLOCKING: has_vertical_crs changed True → False
That is the drift report doing its job on a real pattern: the vegetation classes swapped proportions, which was expected and recorded, while the delivery quietly lost its vertical CRS — which would have put every height in the twin 47 m out and passed every other check.
Verify the detector with a synthetic delivery, because a drift check nobody has seen fire is not known to work:
import copy
base = json.loads(Path("profiles/city_point_cloud.json").read_text())
mutated = copy.deepcopy(base)
mutated["epsg"] = 32632 # wrong UTM zone
mutated["class_mix"]["2"] = base["class_mix"]["2"] + 0.3
findings = compare(mutated, base)
keys = {k for _, k, _, _ in findings}
assert "epsg" in keys, "CRS change not detected"
assert any(k.startswith("class_2_share") for k in keys), "class mix change not detected"
print("drift detector catches the synthetic mutations")
Performance Notes
- Profiling reads headers and a sample, not the whole file: a 2-million-point sample of a 200-million-point tile takes seconds and is statistically ample for shares and percentiles.
- Seed the sampler so re-profiling the same delivery gives the same numbers; unseeded sampling produces spurious drift of a few tenths of a percent.
- Profile per delivery unit, compare per unit. A city-wide average hides a single tile that came from a different flight.
- Keep profiles small — a few kilobytes — so a year of them lives in the repository without thought.
- Run the profile before the expensive stages. A blocked delivery should cost seconds, not the three hours of tiling that would have consumed it.
Common Errors
Every delivery reports drift in the percentiles. The tolerance is tighter than natural variation between areas. Calibrate the tolerances on a handful of known-good deliveries rather than picking round numbers.
Class shares compare as strings against integers. JSON object keys are strings, so a profile round-tripped through a file has "2" where the fresh one has 2. The comparison above checks both; forgetting this silently reports every class as changed.
A renamed column is reported as one removal and one addition. That is correct and is exactly what it is; the allow file should record the rename as a pair with the reason, and downstream code has to be updated before the delivery is accepted.
The baseline drifts along with the data. Something promotes the profile automatically — a script, or a well-meaning cleanup job. Promotion belongs in a reviewed commit, and a test that the profile file is unchanged in CI is a reasonable guard.
Frequently Asked Questions
Is this not what schema validation does?
Schema validation checks that a delivery conforms to a contract. Drift detection checks that it resembles the last one. A delivery can satisfy the schema and have half the vegetation class it had last month; only the comparison catches that.
How is this different from change detection?
Change detection measures how the world changed between two epochs, which is the product. Drift detection measures how the data about the world changed, which is usually an artefact of the production process. Confusing the two produces reports of buildings that grew by two metres when a vertical datum moved.
Should drift block or warn?
Block on anything that would make downstream code wrong — CRS, units, schema, missing vertical datum. Warn on distributions, which need judgement. The allow file is what keeps the warnings from becoming background noise.
What about drift in imagery?
The same idea with different fields: resolution, band count and order, bit depth, nodata value, and the percentiles of each band. A CIR product delivered where RGB was expected is the classic case, and it is caught by band statistics rather than by any schema.
Related Guides
- Pipeline Observability and Monitoring — the signal set this belongs to
- Schema Validation Gates for Spatial Data — the conformance half of the problem
- Checking Point Cloud Classification Completeness — a deeper look at the class mix