Setting Minimum Count Thresholds for Published Map Cells

The threshold kk is the inverse of the identity-disclosure probability you will accept, and the protection level pp 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 nn distinct individuals, an adversary who knows a target is in the cell singles them out with probability at most

Pr[single out]1n\Pr[\text{single out}] \le \frac{1}{n}

so requiring nkn \ge k caps disclosure at 1/k1/k. Inverting: if the organisation will accept a disclosure probability of at most ρ\rho, then

k=1ρk = \left\lceil \frac{1}{\rho} \right\rceil

That is the whole derivation of the first parameter. A ρ\rho of 0.1 gives k=10k = 10; a ρ\rho of 0.09 gives k=12k = 12, 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:

ncmaxncmin  pn_c^{\max} - n_c^{\min} \ \ge\ p

A useful way to set pp is to require that the interval could contain either a value below kk or a value above it, so that a reader cannot even conclude the cell was small:

p  2k2ncminp \ \ge\ 2k - 2n_c^{\min}

in the worst case; in practice a fixed pp between k/2k/2 and kk is the common working choice.

Worked numeric example permalink

A municipal cycling-count map. The organisation’s disclosure policy accepts ρ=0.05\rho = 0.05, giving k=20k = 20. 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 kk did not decide the map; choosing kk 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 kk 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.

← Back to Small-Cell Suppression & Complementary Suppression Rules