Two consecutive density releases are compared cell by cell and every cell has changed. The underlying events barely moved, the bandwidth is the same, the grid is anchored — and the numbers are all different, because the normalisation rescaled the whole surface.
Part of Density Mapping & Heat Generation: that page covers making a single surface reproducible. This one is about making two of them comparable, which is a stronger requirement and a different mistake.
Nearly every density pipeline ends with a normalisation step, and nearly all of them are adaptive: scale to the maximum, scale to the ninety-ninth percentile, standardise to zero mean and unit variance. All three are excellent for producing a single readable map, and all three make consecutive releases incomparable.
The mechanism is simple. An adaptive scale is computed from the release, so the same absolute density maps to a different scaled value whenever the release’s own distribution moves. Add one unusually dense cell and the p99 shifts, and every other cell’s value changes to accommodate it — including cells whose underlying events did not change at all.
That produces three symptoms that look like separate problems:
Everything changed. A cell-by-cell diff between releases is dominated by the rescaling, so no per-cell comparison is meaningful.
A change in one place moves values everywhere. New development in one district shifts the p99, which changes every cell in the country.
Trends are unmeasurable. A genuine increase in density across the whole extent produces no change at all in a max-scaled surface, because the maximum increased too.
The same underlying change under four normalisation schemes: three of them report a change in cells where nothing happened, and one of them reports no change where something did.
Before choosing a scheme, decide what a consumer will do with the numbers, because the schemes are not interchangeable.
python
INTENT ={"cartographic":"one map, read by eye, no cross-release comparison","comparative":"two releases compared cell by cell","absolute":"values used as densities in a downstream calculation",}
Only the first is served by adaptive scaling, and it is the intent most pipelines were originally built for — which is why the normalisation is usually inherited rather than chosen. The second and third both require the scale to be independent of the release, and they differ in whether the units have to mean anything.
defemit(surface, contract:dict)->dict:"""The absolute surface is the artifact; scaled variants are derived views."""return{"density": surface,# events per km², absolute"units":"events_per_km2","bandwidth_m": contract["bandwidth_m"],"cell_m": contract["cell_m"],"crs": contract["crs"],}
This alone resolves most of the problem, and it is often resisted because the absolute surface is harder to render. That is a rendering concern, and the answer is to derive the display scaling at render time rather than to bake it into the artifact — which is the same argument as shipping unsnapped coordinates with a separately versioned match.
from dataclasses import dataclass
@dataclass(frozen=True)classScaleAnchor:
reference_release:str# the artifact hash the anchor was computed from
p99:float# events per km² at the reference's 99th percentile
computed_at:strdefscale(surface, anchor: ScaleAnchor):"""Scale by a constant taken from a named reference, not from this release."""return surface / anchor.p99
The anchor is a stored constant with a provenance record, not a computation. Two releases scaled against the same anchor are directly comparable, and a cell whose value is 1.4 means the same density in both.
An anchor eventually drifts out of usefulness — the population genuinely changed, and a scale set three years ago compresses everything into the bottom of the range. Re-anchoring is legitimate; doing it silently is not.
python
defshould_reanchor(surface, anchor: ScaleAnchor, drift:float=0.35)->bool:"""Flag when the current release's p99 has moved far from the anchor's."""
current = percentile(surface,99)returnabs(current - anchor.p99)/ anchor.p99 > drift
When it fires, the correct action is a new anchor version and a release note saying so — not an adjustment. A consumer comparing across the re-anchor point needs to know that the scale changed, and the only thing that tells them is the anchor version travelling with the surface.
An anchor's life: fixed across many releases, drifting slowly as the population moves, and superseded explicitly rather than adjusted.
deftest_unchanged_cells_are_unchanged(previous, current, unchanged_mask, tol=1e-9):"""Cells whose underlying events did not change must have identical values."""
delta =abs(current[unchanged_mask]- previous[unchanged_mask]).max()assert delta < tol,f"a cell with no event change moved by {delta:.6f}"deftest_scale_anchor_is_recorded(manifest):assert manifest["density"]["scale_anchor"]["reference_release"],("a scaled surface with no anchor reference is not comparable to anything")deftest_absolute_surface_is_published(release):assert"density"in release and release["units"]=="events_per_km2"deftest_reanchor_is_flagged(surface, anchor, manifest):if should_reanchor(surface, anchor):assert manifest["density"]["scale_anchor"]["version"]!= anchor.version,("the p99 has drifted past the re-anchor threshold and the anchor did not change")
A slowly growing population against a fixed anchor: the drift check reports when a re-anchor decision is due rather than adjusting the scale itself.
The first assertion is the one that would have caught the original symptom, and it is only expressible because the generator knows which cells changed. That is the same argument as the ground-truth stop layer and the on-network flag: the producer holds information the consumer cannot recover, and a check built on it catches things no consumer-side check can.
The reference release is withdrawn. The anchor outlives it — it is a stored constant, not a live dependency — but the provenance record now points at something unavailable. Keep the anchor’s numeric value and its reference hash regardless; a withdrawn reference does not invalidate a scale computed from it, and pretending otherwise forces an unnecessary re-anchor.
Different regions need different scales. A single global anchor compresses a rural surface to nothing. Anchor per region, version each independently, and make the region key part of the anchor identity — but resist per-release regional anchors, which reintroduces the original problem at a finer granularity.
Log scaling. Widely used for density and entirely compatible with anchoring, provided the log is taken before the anchored division rather than after. Taking it after produces a scale whose zero point moves with the anchor, which is the same defect in a subtler form.
A consumer who wants the p99 anyway. Publish it as a statistic of the release alongside the surface. The problem was never computing the percentile — it was using it as the scale.
Adaptive normalisation is not a careless choice. It is the right choice for the thing most
density pipelines were originally built to do, and it becomes wrong later without anybody
changing a line of code.
The first release of a density surface is almost always cartographic. Somebody wants a map, the
map needs to use its colour ramp fully, and scaling to the release’s own percentile is exactly
how you get that. It is correct, it is conventional, and it has no downside in a world with one
release.
The second release arrives and the requirement silently changes. Now somebody wants to know what
moved. The pipeline is unchanged, the surfaces look right individually, and the comparison is
meaningless — but nothing failed, no error was raised, and the numbers are all plausible. That is
the whole difficulty: an incomparable pair of surfaces looks exactly like a comparable pair, and
the only way to tell them apart is to know how the scale was computed.
Three habits make this survivable:
Treat “will anyone compare two of these?” as a design question, not a future concern. The
answer for anything published on a schedule is yes, and the cost of anchoring from the start is
a stored constant.
Make the scale visible in the artifact. A surface that carries its units and its anchor
version can be checked; one that carries neither cannot be, by anybody, ever.
Separate the map from the data. The rendering can scale however it likes, per release, per
region, per viewer. The problem only appears when the display choice is baked into the stored
values.
None of that is specific to density. It is the same argument as versioning a schema or shipping
unsnapped coordinates: a transformation applied before storage destroys information the consumer
needed, and the fix is always to move the transformation later rather than to make it smarter.