Choosing Grid Cell Size for Population Density Maps

Pick the finest cell size — the highest H3 resolution or smallest edge length — at which every populated cell still holds at least k people, then suppress the handful of cells that fall short. That single rule reconciles disclosure safety with density detail.

Core Calculation permalink

A grid aggregation map is safe to publish when every released cell meets a minimum count. Model the expected number of individuals in a cell as density times area:

E[c]=ρ×A\mathbb{E}[c] = \rho \times A

where ρ\rho is the local population density (people per km²) and AA is the cell area (km²). To guarantee the minimum count k, require:

ρmin×AkAkρmin\rho_{\min} \times A \geq k \quad\Longrightarrow\quad A \geq \frac{k}{\rho_{\min}}

Here ρmin\rho_{\min} is the lowest density you still intend to map — not the global minimum, which would drive the cell size absurdly large. Any cell whose observed count cc satisfies c<kc < k is suppressed (dropped or merged), and the suppression fraction

s=ci<kciicis = \frac{\sum_{c_i < k} c_i}{\sum_i c_i}

measures the share of the population lost to protect the tail. The design goal is the smallest A that keeps s acceptable.

H3 resolution → area permalink

H3 resolutions are discrete, so you choose the coarsest resolution whose average hexagon area is at least the required A. Approximate average areas:

H3 resolution Avg cell area (km²) Avg edge length (km) Typical use
5 252.9 8.54 Regional / rural
6 36.13 3.23 County / district
7 5.161 1.22 Town / suburb
8 0.7373 0.461 Neighbourhood
9 0.1053 0.174 City block
10 0.0150 0.0658 Building cluster

Each step finer divides the area by roughly 7. That factor is the lever: dropping one resolution multiplies the expected count per cell by about 7, so it either rescues a sub-k cell or is the reason it fell below k.

Worked numeric example permalink

  • Sparsest area you must still map: ρmin=40\rho_{\min} = 40 people/km² (peri-urban fringe)
  • Minimum count threshold: k=100k = 100
  • Required area: A100/40=2.5A \geq 100 / 40 = 2.5 km²

The coarsest resolutions above 2.5 km² are 6 (36.1 km²) and 7 (5.16 km²). Resolution 7 at 5.16 km² clears the bar with headroom, giving an expected fringe count of 40×5.16120640 \times 5.161 \approx 206 — comfortably above 100 — while retaining ~1.2 km detail. Resolution 8 (0.737 km²) yields only 40×0.7372940 \times 0.737 \approx 29 in the fringe, below k, so it would suppress those cells. Choose resolution 7 globally, or use resolution 8 in the dense core and coarsen only the fringe cells to their resolution-7 parent.

H3 resolution selection under a minimum count threshold A flow starting from the sparsest density and threshold k, computing the required cell area, mapping it to the coarsest H3 resolution, testing populated cells against k, and either accepting the resolution or coarsening and suppressing residual cells. rho_min and k from policy A = k / rho_min coarsest H3 res cell count ≥ k ? yes publish this res no coarsen cell to parent or suppress if still < k
Selecting the finest H3 resolution that still satisfies the minimum count threshold, coarsening or suppressing the residual cells.

Python Implementation permalink

The function sweeps candidate resolutions, bins points, and returns the finest resolution whose populated cells meet k, then produces a suppressed, safe-to-publish frame. Coordinates are WGS84 (EPSG:4326); H3 operates directly on lat/lon, so no metric reprojection is needed for the binning itself.

import numpy as np
import pandas as pd
import geopandas as gpd
import h3
from typing import Sequence


def choose_grid_resolution(
    points: gpd.GeoDataFrame,
    k: int,
    candidate_resolutions: Sequence[int] = (5, 6, 7, 8, 9),
) -> dict:
    """
    Select the finest H3 resolution at which every populated cell holds >= k
    points, then suppress any residual sub-k cells.

    Privacy rationale: publishing a cell count is safe only when at least k
    individuals share the cell, so no single person is isolable from the
    density surface. We prefer the finest (most detailed) resolution that
    still guarantees this everywhere.

    Args:
        points: Point GeoDataFrame in WGS84 (EPSG:4326). One row per individual.
        k: Minimum count threshold (spatial k-anonymity parameter).
        candidate_resolutions: H3 resolutions to sweep, coarse -> fine.

    Returns:
        dict with the chosen resolution, the safe cell counts, and the
        fraction of population suppressed.
    """
    if points.crs is None or points.crs.to_epsg() != 4326:
        raise ValueError("points must be in WGS84 (EPSG:4326) for H3 binning.")
    if k < 1:
        raise ValueError(f"k must be >= 1, got {k!r}")

    lat = points.geometry.y.to_numpy()
    lon = points.geometry.x.to_numpy()
    total = len(points)

    chosen_res = None
    chosen_counts = None

    # Sweep fine -> coarse; the first resolution whose *populated* cells all
    # meet k (after suppressing the smallest tail) is the most detailed safe one.
    for res in sorted(candidate_resolutions, reverse=True):
        cells = np.array([h3.latlng_to_cell(y, x, res) for y, x in zip(lat, lon)])
        counts = pd.Series(cells).value_counts()

        # Fraction of population that would be suppressed at this resolution.
        suppressed_pop = counts[counts < k].sum()
        supp_fraction = suppressed_pop / total

        # Accept the finest resolution whose suppression stays within budget.
        if supp_fraction <= 0.05:  # <=5% of population lost is our utility budget
            chosen_res = res
            chosen_counts = counts
            break

    if chosen_res is None:
        # No fine resolution qualifies; fall back to the coarsest candidate.
        chosen_res = min(candidate_resolutions)
        cells = np.array([h3.latlng_to_cell(y, x, chosen_res) for y, x in zip(lat, lon)])
        chosen_counts = pd.Series(cells).value_counts()

    # Suppress residual sub-k cells so the published surface is k-safe.
    safe = chosen_counts[chosen_counts >= k]
    suppressed_fraction = 1.0 - safe.sum() / total

    return {
        "resolution": chosen_res,
        "avg_cell_area_km2": h3.average_hexagon_area(chosen_res, unit="km^2"),
        "safe_cell_counts": safe,          # H3 index -> count, all >= k
        "n_cells_published": int(len(safe)),
        "n_cells_suppressed": int((chosen_counts < k).sum()),
        "suppressed_fraction": round(float(suppressed_fraction), 4),
    }

The key decisions:

  • Fine-to-coarse sweep: iterating from the finest resolution and stopping at the first that respects the suppression budget maximises retained detail rather than defaulting to a safe-but-blurry global cell size.
  • Suppression budget, not zero suppression: demanding that no cell ever falls below k forces the coarsest resolution; allowing up to 5% of the population to be suppressed keeps the map detailed while the sparse tail is dropped.
  • H3 on WGS84 directly: latlng_to_cell consumes spherical coordinates and average_hexagon_area reports equal-area cell sizes, so there is no projection-induced area distortion in the binning.

Verification permalink

def verify_k_safety(result: dict, k: int) -> dict:
    """Confirm every published cell meets k and report detail retained."""
    counts = result["safe_cell_counts"]
    assert (counts >= k).all(), "A published cell violates the k threshold"
    assert result["suppressed_fraction"] <= 0.05, "Suppression exceeds budget"
    return {
        "min_published_count": int(counts.min()),
        "resolution": result["resolution"],
        "cell_area_km2": round(result["avg_cell_area_km2"], 4),
        "population_retained": round(1 - result["suppressed_fraction"], 4),
    }

# Example expectation for k=100 over a peri-urban dataset:
# {'min_published_count': 100, 'resolution': 7, 'cell_area_km2': 5.161,
#  'population_retained': 0.98}

The assertion counts.min() >= k is the hard guarantee; population_retained is the utility counterpart. Plotting suppression fraction against resolution across the sweep gives the full privacy-utility curve, and the elbow of that curve is usually the right cell size.

Edge Cases & Adjustments permalink

  • Dense/sparse mixtures: a single global resolution over-suppresses rural cells or under-protects nothing but wastes urban detail. Run the sweep independently per density stratum, or coarsen sub-k cells to their H3 parent so the dense core stays fine while only the fringe rolls up — an adaptive scheme that is a spatial cousin of k-anonymity grouping for location traces.
  • Very sparse study areas: if even the coarsest candidate cannot reach k in remote regions, publish those regions only as a single aggregated polygon or omit them, rather than releasing sub-k cells with rounding, which leaks the tail.
  • Custom square grids and CRS: when you must align to an existing statistical lattice instead of H3, build the grid in an equal-area projection (a suitable UTM zone or national equal-area CRS) so that each cell covers the same ground area; a grid generated in EPSG:4326 has cells whose metric area shrinks toward the poles, silently breaking the density calculation. The PostGIS workflow in implementing hexagonal grid aggregation in PostGIS shows the equal-area setup in SQL.
  • Temporal slicing: publishing the same map per hour or per day shrinks each cell’s count and can push many cells below k. Either widen the time window or raise the cell size for finer temporal releases, and account for the repeated exposure of the same individuals across slices.

FAQ permalink

Should I use a single resolution or an adaptive grid?

A single resolution is simpler to audit and to defend to a regulator, but it wastes detail in dense areas and over-suppresses sparse ones. If your region mixes dense cores with sparse land, an adaptive scheme that coarsens only the cells below k preserves far more detail while still guaranteeing the threshold everywhere. H3 supports this natively: any cell can be replaced by its coarser parent, so the dense core stays at resolution 9 while the fringe rolls up to 7.

How does k relate to k-anonymity here?

When every published cell contains at least k individuals and the cell identifier is the only quasi-identifier released, each person is indistinguishable from at least k − 1 others in the cell. The minimum count threshold is a direct spatial application of k-anonymity, so choose k from the same disclosure policy you would use for spatial grouping — commonly 5, 11, or 100 depending on sector and sensitivity.

Why aggregate on H3 rather than a square lattice?

H3 hexagons sit at a uniform distance from all six neighbours, avoiding the diagonal-versus-edge ambiguity of square grids, and the hierarchy lets any cell roll up cleanly to a coarser parent for adaptive suppression. Square lattices are appropriate when you must align to an existing statistical grid such as INSPIRE, but they complicate adjacency and adaptive coarsening.

What CRS should I use to compute cell areas?

H3 works in spherical coordinates and reports average cell areas directly, so you rarely need a projected CRS for the binning. If you build your own square grid, generate it in an equal-area projection so that each cell genuinely covers the same ground area; a grid built in EPSG:4326 has cells whose metric area shrinks toward the poles and will distort your density estimates.


Related

Back to Grid Aggregation & Spatial Binning Strategies