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 ρA(r)\rho \cdot A(r) for local density ρ\rho, so the required resolution follows directly rather than being chosen by hand.
  • facility_weight and uniqueness_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
The resolution the scorer picks, and the k it delivers A grouped bar chart over five local densities. The chosen publishing resolution rises with density, from resolution six in the sparsest rural case to resolution ten in a stadium-scale crowd. The expected occupancy at each chosen resolution clears the floor of five in every case, which is the property the selection rule guarantees. The resolution the scorer picks, and the k it delivers finest H3 resolution whose expected occupancy clears k ≥ 5 0 50 100 150 resolution / persons 8 8 14/km² 9 11 140/km² 10 16 1,400/km² 10 43 3,800/km² 10 136 12,000/km² chosen resolution expected k at that resolution
Resolution stops being a global constant and becomes a function of local density — which is the only way one rule can protect a stadium and a farm track at the same time.

Reference Implementation

python
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

python
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()
Why sparse countryside outscores a crowded street A stacked bar chart of the composite sensitivity score for four zones. The rural track scores highest at 1.13, driven by the inverted density term and by trajectory uniqueness. The clinic block scores 0.78, dominated by facility proximity. The office park and the stadium score lowest. The ordering is the opposite of a density ranking, which is the point of the model. Why sparse countryside outscores a crowded street composite score decomposed by term 0 0.5 1 composite score 1.13 rural track 0.78 clinic block 0.55 office park 0.14 stadium density term facility proximity trajectory uniqueness
Any scorer that ranks by population gets this backwards. Empty countryside is the highest-risk geography in a mobility dataset, and it is where fine grids get published by accident.

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.
Smoothing the density surface stops the tier mosaic A bar chart comparing the number of routing-tier changes between adjacent cells before and after neighbour smoothing. The raw counts produce many more tier flips than the smoothed surface, because a single sparse cell beside a busy one crosses a threshold. Smoothing roughly halves the flips, producing tier boundaries that follow geography rather than sampling noise. Smoothing the density surface stops the tier mosaic 40 adjacent cells from a sampled density field 0 10 20 30 adjacent-cell tier changes 31 raw counts 16 k-ring smoothed
An unsmoothed surface produces a mosaic of routing decisions that is impossible to explain — and whose boundaries leak the underlying 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.

Up one level: Spatial Sensitivity Scoring Models · Section: Core Fundamentals & Architecture for Spatial Privacy.