Setting Minimum Count Thresholds for Published Map Cells
The threshold is the inverse of the identity-disclosure probability you will accept, and the protection level is the width of the interval a reader may derive for a withheld cell. Both are policy numbers, both must be written down before the map is built, and neither should be inherited from a different sector’s practice without checking what it assumed.
Core Calculation permalink
For a published cell holding distinct individuals, an adversary who knows a target is in the cell singles them out with probability at most
so requiring caps disclosure at . Inverting: if the organisation will accept a disclosure probability of at most , then
That is the whole derivation of the first parameter. A of 0.1 gives ; a of 0.09 gives , which is why the health sector’s widely cited 11 corresponds to a bound just under 10%.
The second parameter is different in kind. When a cell is withheld, a reader may still bound it from the published marginals, and complementary suppression exists to widen that bound. The protection level is the minimum acceptable interval width:
A useful way to set is to require that the interval could contain either a value below or a value above it, so that a reader cannot even conclude the cell was small:
in the worst case; in practice a fixed between and is the common working choice.
Worked numeric example permalink
A municipal cycling-count map. The organisation’s disclosure policy accepts , giving . Cell counts at 250 m resolution:
| Resolution | Cells | Cells below k = 20 | Suppressed fraction | Median published count |
|---|---|---|---|---|
| 100 m | 4 820 | 3 611 | 74.9% | 27 |
| 250 m | 812 | 268 | 33.0% | 94 |
| 500 m | 214 | 31 | 14.5% | 340 |
| 1 km | 58 | 2 | 3.4% | 1 210 |
At 100 m the map is three-quarters holes and the suppression pattern itself maps the quiet streets. At 500 m the map is publishable with a 14.5% suppression rate. Choosing did not decide the map; choosing and then sweeping resolution did, and the sweep is where the real decision lives.
Python Implementation permalink
from __future__ import annotations
import math
import geopandas as gpd
import pandas as pd
def threshold_sweep(
points: gpd.GeoDataFrame,
person_col: str,
disclosure_ceiling: float,
resolutions_m: list[float],
suppression_tolerance: float = 0.20,
) -> pd.DataFrame:
"""Suppressed fraction at each candidate resolution for a policy-derived k.
k is derived from the accepted disclosure probability, never chosen to make a
preferred resolution work. The returned table is the artefact a reviewer
signs: it shows every resolution that satisfies the same policy.
Args:
points: point layer in a projected metric CRS.
person_col: column identifying individuals — counts are of people, not rows.
disclosure_ceiling: the maximum acceptable Pr[single out], e.g. 0.05.
resolutions_m: square cell sizes to evaluate.
suppression_tolerance: fraction of cells that may be withheld.
"""
if not points.crs or not points.crs.is_projected:
raise ValueError("project to a metric CRS before binning")
if not 0.0 < disclosure_ceiling < 1.0:
raise ValueError("disclosure_ceiling must lie strictly between 0 and 1")
k = math.ceil(1.0 / disclosure_ceiling)
x0, y0 = points.total_bounds[0], points.total_bounds[1] # pinned, not per-run
rows = []
for res in resolutions_m:
col = ((points.geometry.x - x0) // res).astype(int)
row = ((points.geometry.y - y0) // res).astype(int)
cell = col.astype(str) + "_" + row.astype(str)
per_cell = points.assign(_cell=cell).groupby("_cell")[person_col].nunique()
withheld = int((per_cell < k).sum())
rows.append({
"resolution_m": res,
"k": k,
"cells": int(per_cell.size),
"cells_withheld": withheld,
"suppressed_fraction": round(withheld / max(per_cell.size, 1), 4),
"median_published_count": float(per_cell[per_cell >= k].median()),
"publishable": bool(withheld / max(per_cell.size, 1) <= suppression_tolerance),
})
return pd.DataFrame(rows)
Verification permalink
def assert_threshold_policy(released: pd.DataFrame, k: int,
person_col: str = "n_persons") -> None:
"""Blocking check: every published cell clears k, counted over individuals."""
assert (released[person_col] >= k).all(), (
f"{int((released[person_col] < k).sum())} published cells fall below k={k}"
)
assert released[person_col].notna().all(), "a published cell has a null count"
def test_rejects_a_thin_cell():
"""Negative control — without this, a disconnected gate looks identical."""
thin = pd.DataFrame({"n_persons": [30, 4, 22]})
try:
assert_threshold_policy(thin, k=20)
except AssertionError:
return
raise AssertionError("the k gate accepted a sub-threshold cell")
Both belong in the release job rather than a notebook, for the reasons set out in automating spatial privacy checks in CI. A threshold enforced by a human reading a table is a threshold that will eventually ship broken.
Edge Cases & Adjustments permalink
- Sparse regions dominating the sweep. A rural tail can push the suppressed fraction over tolerance at every resolution. Stratify: run the sweep per density band and publish at a different resolution in each, recording the per-cell resolution as an attribute so the map remains interpretable.
- True zeros. A cell with nobody in it is usually publishable and is often the most useful part of a map. A cell where nobody was observed but people live is a different claim; treat it as a small count unless a land-use layer confirms the zero is structural.
- Repeated releases. A threshold satisfied once is not satisfied across a monthly series, because the differences between releases are new equations. Compute the suppression jointly across the series.
- Devices instead of people. Where identity cannot be resolved, say so in the record. A k floor over devices is a weaker claim than one over individuals, and a reviewer is entitled to know which they are getting.
- Very high k with rich attributes. Once exceeds a few dozen, the binding constraint is usually attribute homogeneity rather than count — the point at which l-diversity and t-closeness become the relevant tests.
FAQ permalink
Where does the number 5 come from?
From the statistical-disclosure tradition in official statistics, where a rule of three or five governs cell suppression in published tables. It corresponds to a disclosure bound of 33% or 20%, which is defensible for aggregate socioeconomic tables and generally too weak for individual-level location data. The sector-by-sector reasoning is in sector-specific k-anonymity thresholds for location data.
Can I use a different k in different parts of the map?
Yes, provided the bands are defined by something the adversary already knows — a published density stratum, an administrative boundary — and the per-cell threshold is published. A threshold that varies according to a private property of the data is itself a disclosure.
Should the threshold rise when I publish more attributes?
Yes. Each additional published column multiplies the number of groups and shrinks each one, so a k that holds for counts alone will not hold for counts crossed with a category. Recompute the sweep on the full published key, not on geography alone.
What if the suppressed fraction is acceptable but the holes cluster?
Then the suppression pattern is itself informative and the map needs a coarser geometry rather than more suppression. Check the spatial autocorrelation of the withheld set, not just its size.
Related permalink
- Small-Cell Suppression & Complementary Suppression Rules — why the threshold alone does not protect the withheld cells
- Applying Complementary Suppression to Choropleth Maps — closing the arithmetic once the threshold is set
- Choosing Grid Cell Size for Population Density Maps — the resolution half of the same sweep
- Sector-Specific k-Anonymity Thresholds for Location Data — where sector conventions come from
- Automating Spatial Privacy Checks in CI — making the threshold block a publish
← Back to Small-Cell Suppression & Complementary Suppression Rules