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:
where is the local population density (people per km²) and is the cell area (km²). To guarantee the minimum count k, require:
Here 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 satisfies is suppressed (dropped or merged), and the suppression fraction
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: people/km² (peri-urban fringe)
- Minimum count threshold:
- Required area: 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 — comfortably above 100 — while retaining ~1.2 km detail. Resolution 8 (0.737 km²) yields only 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.
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
kforces 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_cellconsumes spherical coordinates andaverage_hexagon_areareports 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-
kcells 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
kin remote regions, publish those regions only as a single aggregated polygon or omit them, rather than releasing sub-kcells 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
- Grid Aggregation & Spatial Binning Strategies — parent guide covering binning schemes and suppression rules
- Implementing Hexagonal Grid Aggregation in PostGIS — the equal-area SQL workflow for large datasets
- k-Anonymity Grouping for Location Traces — choosing and enforcing the k threshold spatially
- Geospatial Masking & Perturbation Techniques — the full family of masking methods and when each applies