Small-Count Cell Suppression in Choropleth Rate Maps
Mapping raw disease rates leaks every cell whose numerator sits below the suppression threshold, and hiding those cells one at a time is reversible from the row and column totals a map legend or companion table publishes. This guide, part of dasymetric suppression and small-count disclosure control, shows how to combine a numerator threshold with a complementary pass across each geographic grouping and then prove — not assume — that no suppressed value can be recovered before a choropleth ever renders.
Problem Context & Constraints
A choropleth rate map is often accompanied by an attribute table, a downloadable GeoJSON, or a hover tooltip that exposes the underlying numerator and denominator. The naive disclosure control — blank any cell with a numerator below the threshold and paint the rest — fails for a reason that has nothing to do with the color ramp: the arithmetic around a suppressed cell gives it away.
Consider a county with five tracts and a published county total of 47 cases. Four tracts show 9, 14, 12, and 8 cases; the fifth is suppressed because it holds 4. A reader subtracts: . The suppression accomplished nothing. This is primary suppression without complementary suppression, and it is the single most common disclosure failure in published health maps. The threshold cell is protected in isolation but not against the totals that surround it.
Three constraints make this distinct from simply masking values:
- Totals are almost always published somewhere. A state map shows county rollups; a county map shows a state banner; an export carries a grand total. Any margin that sums the suppressed dimension is a recovery vector.
- A lone suppression is always recoverable. If exactly one cell in a summed group is hidden and the group total is visible, that cell equals the total minus the visible cells. Protection therefore requires at least two suppressions per group, or suppression of the total itself.
- The suppression pattern must not itself disclose. If only high-rate cells are ever blanked, the blanks map the extremes. The threshold has to apply identically to high and low numerators.
There is a subtler recovery vector that trips up first attempts: bounding rather than exact recovery. Even when two cells are hidden behind a total, an attacker knows each is non-negative and each is below the threshold, so a group total of, say, 12 hidden across two cells implies each lies in a narrow window. When the threshold is small and the total is small, the “protected” cells are still tightly bounded. Complementary suppression makes exact recovery impossible; keeping the choice of complement independent of the value (always the smallest visible cell, never the cell nearest the threshold) keeps those bounds from collapsing into a near-exact estimate. The threshold and the complement rule are therefore a pair — neither is sufficient alone.
This page treats the numerator threshold as fixed by policy and focuses on making it non-reversible. Setting the threshold itself, and the separate question of when a rate is too unstable to map regardless of disclosure, belong to the parent guide; here the numerator floor is an input, not a decision.
The recoverability failure, and the complementary fix that closes it, is exactly what the validation gate below must catch:
Prerequisites
Pin the stack so the suppressed cell set is byte-reproducible across reviewers:
python3.11geopandas0.14.4 (pullsshapely2.0.x,pyproj3.6.x)numpy1.26.4,pandas2.2.2
Input state assumed by the code below:
- A
GeoDataFrameof mapping units (tracts) with a stablegeoid, aparent_geoidnaming the grouping whose total is published (the county), acasesnumerator, and apopdenominator already reflecting population at risk. - CRS: a single projected CRS for any area or centroid work; suppression itself is tabular, but the map render must be consistent. Align projections exactly as covered in coordinate reference systems for public health so the choropleth and its export agree.
- A denominator that is population at risk, not raw polygon area — build it with dasymetric refinement with land-use ancillary data if large uninhabited areas are present.
- The published margin identified explicitly: this code protects the
parent_geoidgrouping. If your map also publishes a crossing margin (an age-band column, a state rollup), run the pass over that dimension too.
Step-by-Step Solution
The function applies the numerator threshold, then a complementary cascade that guarantees every grouping hides zero or at least two cells, and finally emits a map-ready GeoDataFrame with a suppressed flag, a reason code, and a nulled map_rate column ready to bind to a color ramp.
# Small-count + complementary suppression for a choropleth rate map.
# Pinned: python==3.11, geopandas==0.14.4, numpy==1.26.4, pandas==2.2.2
import logging
import numpy as np
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cell_suppress")
THRESHOLD = 11 # NCHS presentation floor: suppress numerators strictly below this
def suppress_for_map(gdf: gpd.GeoDataFrame, *, group_col="parent_geoid",
num_col="cases", den_col="pop") -> gpd.GeoDataFrame:
"""Primary + complementary suppression, emitting a map-ready GeoDataFrame."""
gdf = gdf.sort_values("geoid").reset_index(drop=True).copy()
# ---- primary: hide any numerator below the presentation floor ----
gdf["suppressed"] = gdf[num_col] < THRESHOLD
gdf["reason"] = np.where(gdf["suppressed"], "primary_small_count", "")
n_primary = int(gdf["suppressed"].sum())
# ---- complementary: never leave exactly one hidden cell in a group ----
passes = 0
while True:
passes += 1
changed = False
for gid, grp in gdf.groupby(group_col):
if int(grp["suppressed"].sum()) == 1:
visible = grp[~grp["suppressed"]]
if visible.empty:
continue # only one cell in the group; total must be hidden instead
# complement = smallest visible cell -> least information withheld
target = visible[num_col].idxmin()
gdf.loc[target, "suppressed"] = True
gdf.loc[target, "reason"] = "complementary"
changed = True
if not changed:
break
if passes > 50:
raise RuntimeError("complementary cascade failed to converge")
# ---- map-ready columns ----
rate = np.divide(gdf[num_col].to_numpy(float), gdf[den_col].to_numpy(float),
out=np.full(len(gdf), np.nan),
where=gdf[den_col].to_numpy(float) > 0)
gdf["map_rate"] = np.where(gdf["suppressed"], np.nan, rate)
log.info("suppression complete: primary=%d total_hidden=%d passes=%d",
n_primary, int(gdf["suppressed"].sum()), passes)
return gdf
The complement is chosen as the smallest visible cell in the group. That is deliberate: withholding the smallest value discloses the least aggregate information while still creating the second unknown that makes recovery impossible. Choosing the largest, or a random cell, protects equally well but hides more signal than necessary.
Two properties make this routine safe to run unattended in a publication pipeline. First it is deterministic: the input is sorted by geoid, the complement is always the group’s smallest visible cell, and ties break on the sorted order, so a re-run on the same inputs produces the identical suppressed set — the precondition for any auditable release. Second it is convergent: each pass can only add suppressions, the number of cells is finite, and once every group hides zero or at least two cells no further pass changes anything. The passes > 50 guard is a safety valve for pathological many-margin tables, not an expected path; in nested administrative hierarchies the cascade almost always settles in one or two passes, as the log line confirms. If it does not converge, that is itself a signal that the geography is too sparse to publish at this resolution and should be aggregated rather than suppressed further.
Note what the function deliberately does not do: it never imputes, smooths, or rounds a suppressed value to a “safe” number. A blanked cell is NaN in map_rate, and the color ramp must render NaN as an explicit “suppressed” category — a hatch pattern or neutral gray — never as the low end of the sequential scale. A suppressed cell painted the same color as a genuine zero rate is a disclosure and a lie at once.
Validation & Edge Cases
A suppression pipeline that is not validated is a suppression pipeline that leaks. The gate below re-derives every published group total and asserts that no group is left with exactly one hidden cell — the recoverable condition. Run it as an assertion, so a non-conforming release halts instead of rendering.
def assert_no_recoverable_cells(gdf, *, group_col="parent_geoid"):
"""Fail loudly if any group hides exactly one cell against a visible total."""
offenders = []
for gid, grp in gdf.groupby(group_col):
n_hidden = int(grp["suppressed"].sum())
if n_hidden == 1 and len(grp) > 1:
offenders.append((gid, n_hidden))
if offenders:
log.error("recoverable groups: %s", offenders[:10])
raise AssertionError(f"{len(offenders)} groups have a recoverable cell")
log.info("validation passed: %d groups, none recoverable",
gdf[group_col].nunique())
Three failure modes recur in production:
1. The single-tract county. A group with exactly one mapping unit cannot be protected by a complement — there is no second cell to hide. The loop skips it (visible.empty), and the correct action is to suppress the group total upstream or merge the unit into a neighbour. The validation guard’s len(grp) > 1 condition avoids falsely flagging these, but the release process must confirm the total is not published.
INFO suppression complete: primary=38 total_hidden=51 passes=2
WARNING single-unit group 06075 cannot be complement-protected; total must be hidden
2. Crossing-margin leakage. Protecting the county grouping does not protect a published age-band column that crosses it. A cell hidden in the county dimension may be the lone suppression in its column. If the map or its table publishes both margins, run suppress_for_map and the validation over each dimension and union the suppression flags.
3. Cascade blanking the map. In a sparse rare-disease surface, the cascade can suppress an implausible share of tracts. That is a signal to change the geography, not to keep suppressing — merge units until counts clear the threshold, as in adaptive areal aggregation for rare disease counts. Log the suppressed fraction and gate on it:
frac = gdf["suppressed"].mean()
if frac > 0.4:
log.warning("suppressed fraction %.2f exceeds 0.40; consider aggregating geography", frac)
Compliance Notes
For a defensible published map, three artifacts must survive the run. Log the numerator threshold as applied alongside the count of primary and complementary suppressions, so a reviewer can confirm the floor in force and see how many cells were withheld to protect it. Retain the per-cell reason codes — primary_small_count versus complementary — because the distinction between a cell hidden for its own count and one hidden to shield a neighbour is exactly what an auditor checks. And persist the validation result: an assertion that passed is the evidence that no cell is recoverable, and it belongs in the run’s metadata table next to the input hash and library versions so the same reviewer re-executing the pipeline reproduces the identical suppressed set. The threshold and reason codes are the map’s disclosure-control record; treat them as part of the published artifact, not as scratch state.
Related Topics
- Dasymetric Suppression and Small-Count Disclosure Control — the parent guide combining suppression with realistic denominators.
- Dasymetric Refinement with Land-Use Ancillary Data — building the population-at-risk denominator the rates in this map need.
- K-Anonymity for Spatial Microdata — the disclosure control to reach for when the release is points, not an aggregate table.