Density-Aware Sensitivity Scoring with H3
A single global sensitivity score is wrong everywhere at once: it over-protects a stadium concourse where ten thousand people share a cell, and under-protects a farm track where one vehicle is the entire population. The fix is to make the score a function of local density, and H3 makes that practical because its hierarchy lets you measure density at one resolution and enforce a floor at another. This guide implements a density-aware scorer that emits both a risk tier and the resolution that tier is allowed to publish at. It sits under spatial sensitivity scoring models in Core Fundamentals & Architecture for Spatial Privacy, and the tiers it produces drive the routing described in the privacy model comparison.
Parameter Configuration and Calibration
measure_resolution— where density is measured. Measure coarse, enforce fine. Density estimated at the same resolution you intend to publish is circular and unstable: a cell with three observations gives a density estimate with an error bar wider than the estimate. Measure at resolution 7 or 8, where counts are large enough to be stable, and use the H3 parent–child relationship to attribute that density down to the publishing cell.k_target— the anonymity floor the resolution must satisfy. The scorer’s job is to find the finest resolution at which the expected occupancy still clears this floor. With average H3 cell areas known per resolution, expected occupancy is for local density , so the required resolution follows directly rather than being chosen by hand.facility_weightanduniqueness_weight— the non-density terms. Density alone is not sensitivity. A sparse cell containing a clinic is more sensitive than a dense one containing a shopping street, and a cell whose visitors follow highly distinctive trajectories is more sensitive than its density suggests. Keep these as explicit additive terms so an audit can see which term drove a tier.smoothing_k— the ring radius over which density is averaged. Raw per-cell counts are spiky; averaging over a k-ring of neighbours produces a density surface that does not flip tiers between adjacent cells. A radius of 1 or 2 is enough, and it must be applied to the measurement resolution, not the publishing one.
| Local density (per km²) | Finest resolution clearing k ≥ 5 | Typical tier |
|---|---|---|
| 12,000 (stadium, event) | 10 | 1 — public grid |
| 3,800 (urban core) | 9 | 1–2 |
| 1,400 (suburban) | 8 | 2 — DP required |
| 140 (exurban) | 7 | 2–3 |
| 14 (rural) | 6 | 3 — secure computation or suppression |
Reference Implementation
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping, Sequence
import math
# Average H3 cell area in square kilometres, derived from the published edge lengths.
H3_AREA_KM2: Mapping[int, float] = {
5: 189.6, 6: 27.1, 7: 3.87, 8: 0.553, 9: 0.079, 10: 0.0113, 11: 0.00161,
}
@dataclass(frozen=True)
class SensitivityScore:
"""The scorer's output: a tier, the resolution it may publish at, and why."""
cell: str
density_per_km2: float
publish_resolution: int
expected_k: float
density_term: float
facility_term: float
uniqueness_term: float
tier: int
@property
def composite(self) -> float:
return self.density_term + self.facility_term + self.uniqueness_term
def finest_resolution_for_k(density_per_km2: float, k_target: float) -> int:
"""Finest H3 resolution whose expected occupancy still clears the floor.
Expected occupancy in a cell is density * area, so this walks from fine to
coarse and returns the first resolution that satisfies the floor. Returning the
coarsest resolution when nothing qualifies is deliberate: the caller must then
decide between suppression and secure computation, and silently publishing at
the coarsest grid would hide that decision.
"""
for res in sorted(H3_AREA_KM2, reverse=True):
if density_per_km2 * H3_AREA_KM2[res] >= k_target:
return res
return min(H3_AREA_KM2)
def score_cell(
cell: str,
*,
density_per_km2: float,
sensitive_facility_within_m: float | None,
trajectory_uniqueness: float,
k_target: float = 5.0,
facility_weight: float = 0.45,
uniqueness_weight: float = 0.30,
facility_buffer_m: float = 500.0,
) -> SensitivityScore:
"""Score one cell and decide the resolution it is allowed to be published at.
`trajectory_uniqueness` is in [0, 1] — the fraction of visitors whose local
path is unique within the measurement window. It is the term that makes empty
countryside score highly despite its low density.
"""
if not 0.0 <= trajectory_uniqueness <= 1.0:
raise ValueError("trajectory_uniqueness must be in [0, 1]")
res = finest_resolution_for_k(density_per_km2, k_target)
expected_k = density_per_km2 * H3_AREA_KM2[res]
# Sparse cells are MORE sensitive: the density term is inverted and log-scaled
# so that the difference between 10 and 100 per km2 matters more than the
# difference between 10,000 and 100,000.
density_term = max(0.0, 1.0 - math.log1p(density_per_km2) / math.log1p(20_000.0))
if sensitive_facility_within_m is None:
facility_term = 0.0
else:
proximity = max(0.0, 1.0 - sensitive_facility_within_m / facility_buffer_m)
facility_term = facility_weight * proximity
uniqueness_term = uniqueness_weight * trajectory_uniqueness
composite = density_term + facility_term + uniqueness_term
tier = 1 if composite < 0.35 else 2 if composite < 0.60 else 3 if composite < 0.85 else 4
return SensitivityScore(
cell=cell,
density_per_km2=density_per_km2,
publish_resolution=res,
expected_k=expected_k,
density_term=density_term,
facility_term=facility_term,
uniqueness_term=uniqueness_term,
tier=tier,
)
def smooth_density(
counts: Mapping[str, float],
rings: Mapping[str, Sequence[str]],
area_km2: float,
) -> dict[str, float]:
"""Average raw counts over each cell's k-ring before converting to a density.
Unsmoothed counts flip tiers between adjacent cells, which produces a mosaic of
routing decisions that is impossible to explain to an analyst — and which leaks
the underlying counts through the tier boundaries.
"""
out: dict[str, float] = {}
for cell, ring in rings.items():
members = [cell, *ring]
total = sum(counts.get(m, 0.0) for m in members)
out[cell] = total / (len(members) * area_km2)
return out
Validation Checkpoint
def _validate() -> None:
# 1. The resolution chosen must actually clear the anonymity floor.
for density in (14.0, 140.0, 1_400.0, 12_000.0):
res = finest_resolution_for_k(density, 5.0)
assert density * H3_AREA_KM2[res] >= 5.0
# 2. Denser areas may publish at a finer resolution — never the reverse.
assert finest_resolution_for_k(12_000.0, 5.0) >= finest_resolution_for_k(14.0, 5.0)
# 3. Sparse rural land scores as sensitive even with no facility nearby.
rural = score_cell("r1", density_per_km2=14.0,
sensitive_facility_within_m=None, trajectory_uniqueness=0.9)
assert rural.tier >= 3, rural.composite
# 4. A dense street with no facility and low uniqueness is tier 1.
street = score_cell("u1", density_per_km2=8_000.0,
sensitive_facility_within_m=None, trajectory_uniqueness=0.05)
assert street.tier == 1
# 5. Facility proximity must be able to lift a dense cell out of tier 1.
clinic = score_cell("u2", density_per_km2=8_000.0,
sensitive_facility_within_m=80.0, trajectory_uniqueness=0.05)
assert clinic.tier > street.tier
# 6. Smoothing must not change the total mass, only its distribution.
counts = {"a": 100.0, "b": 0.0, "c": 20.0}
rings = {"a": ["b"], "b": ["a", "c"], "c": ["b"]}
dens = smooth_density(counts, rings, area_km2=0.553)
assert dens["b"] > 0.0, "an empty cell beside a busy one must not read as empty"
print("density-aware scoring: all assertions passed")
_validate()
Assertions 3 and 4 encode the counter-intuitive result that makes this scorer worth building: empty countryside is more sensitive than a crowded street. Any scoring model that ranks by density alone gets this exactly backwards, and it is the error that leads to rural trajectories being published on a fine grid because “there is nobody there”.
Incident Response and Edge Cases
- Tiers flip between neighbouring cells. Density was measured at the publishing resolution, or smoothing is off. Measure coarse and smooth over a k-ring; a tier boundary that follows a cell edge rather than a real geographic boundary is an artefact.
- A stadium becomes tier 3 on event days and tier 1 otherwise. Correct behaviour, badly communicated. Score per release window rather than once, and make the tier a property of the release, not of the geography — but hold the finest allowed resolution to the worst case seen in the window, or an adversary differences the two releases to detect the event.
- The scorer wants resolution 5 for open water or desert. Density-driven resolution selection degenerates where there is no population at all. Add an explicit “no releasable population” branch that suppresses rather than publishing an enormous cell containing one vehicle.
- Facility proximity is computed against a public POI list. Then the tier itself is inferable from public data, which is fine, but the buffer radius should not be published at a precision that lets an adversary invert exactly which facility triggered a tier bump.
- Scores drift after a data-source change. Density is estimated from observed devices, not population, so a change in market share moves every score. Recalibrate against a stable denominator — census population or road length — rather than against last month’s device counts.
Frequently Asked Questions
Why measure density at a coarser resolution than you publish?
Because a density estimate from a handful of observations is dominated by its own sampling noise, and using it to pick the publishing resolution makes the choice unstable and self-referential. Measuring at a resolution where counts are in the hundreds and attributing downward through the H3 hierarchy gives a stable surface.
Should sparse areas simply be published at a coarser grid?
That is the first move and it is often enough. But coarsening does not address trajectory uniqueness: a single vehicle on a rural road is identifiable from the sequence of coarse cells it passes through, regardless of any individual cell's occupancy. Sparse geography usually needs a temporal control as well as a spatial one.
Does the tier boundary itself leak information?
Slightly, yes — knowing a cell moved from tier 1 to tier 2 reveals that its density crossed a threshold. Publish tiers at a coarse granularity, change them on a schedule rather than continuously, and treat a tier map as a release in its own right if it is exposed outside the pipeline.
How does this relate to k-anonymity thresholds?
The scorer chooses the resolution at which the expected occupancy clears the floor; the k-anonymity gate then enforces the floor on the actual, observed counts. Expectation is a planning tool and the gate is the control — a cell can clear the expected floor and still be suppressed on the day.
Related
- Spatial Sensitivity Scoring Models — the parent method and its routing tiers.
- How to Calculate Spatial k-Anonymity Thresholds — the gate that enforces what this scorer plans for.
- Threat Mapping for GIS Data — where the facility and uniqueness terms come from.
- Compliance Framework Mapping — the regulatory floors the resolution choice has to respect.
Up one level: Spatial Sensitivity Scoring Models · Section: Core Fundamentals & Architecture for Spatial Privacy.