How to suppress small counts in a spatial table in Python
Problem statement
You have a table of counts by area and category, and some of the counts are small enough to identify someone. Blanking them is the obvious move and, done naively, it does not work: a hidden value in a row whose total is published is a subtraction, not a secret.
A correct suppression pass has three stages โ primary suppression of cells below the threshold, complementary suppression of enough extra cells that nothing can be derived from a margin, and a decision about what to publish in place of the holes. This guide implements all three on a real table: 19,619 central London crime records for the first quarter of 2025, aggregated to 130 cells of 250 m by 14 crime categories.
Quick answer
Suppress the small cells, then iterate until no row or column has exactly one hole:
import numpy as np
MIN = 5
values = table.values
nonempty = values > 0
sup = nonempty & (values < MIN) # primary suppression
while True:
changed = False
for i in range(values.shape[0]): # rows
if sup[i].sum() == 1:
cand = np.flatnonzero(nonempty[i] & ~sup[i])
if len(cand):
sup[i, cand[values[i, cand].argmin()]] = True
changed = True
for j in range(values.shape[1]): # columns
if sup[:, j].sum() == 1:
cand = np.flatnonzero(nonempty[:, j] & ~sup[:, j])
if len(cand):
sup[cand[values[cand, j].argmin()], j] = True
changed = True
if not changed:
break
On the London table this stabilised after two passes: 643 primary suppressions became 646, hiding 1,361 of 19,619 crimes (6.9%) across 52.6% of the non-empty cells.
Step-by-step solution
1. Build the table at the finest unit the release exposes
If users can filter by month and by category, the table is cell ร month ร category. Suppressing a coarser table protects nothing that the fine one gives away.
2. Count subjects, not rows
Deduplicate to subjects before the crosstab if one person can appear several times. A cell of five records from two people is a cell of two.
3. Apply primary suppression
Everything strictly below the threshold and strictly above zero. Whether to suppress zeros as well is a separate decision โ see step 6.
4. Iterate complementary suppression to a fixed point
A row or column with exactly one hole gives that hole away when its margin is published. Suppressing the smallest remaining value costs the least information. The loop terminates quickly in practice; on the London table, two passes and three extra cells.
5. Handle the rows that cannot be fixed
Some rows have only one non-empty cell, so there is no second value to hide. Three of the 130 London rows were in that position. The only honest options are to suppress the row total as well, or to merge the row into a neighbouring area.
6. Decide what a blank means, and say so
A blank is ambiguous between "zero" and "suppressed", and that ambiguity is useful โ but only if documented. The usual choices are a sentinel (-1, "<5"), a separate flag column, or publishing a band. A flag column is easiest to consume.
7. Weigh the threshold against what it costs
The same table under three thresholds, after the complementary passes:
| threshold | cells suppressed | share of non-empty | records hidden |
|---|---|---|---|
| 3 | 443 | 36.1% | 655 (3.3%) |
| 5 | 646 | 52.6% | 1,361 (6.9%) |
| 10 | 857 | 69.8% | 2,768 (14.1%) |
Cells blank out roughly twice as fast between 3 and 10; records disappear four times as fast, because the distribution is concentrated in the single digits.
8. Consider rounding instead
Controlled rounding to a base of 3 or 5 publishes a number everywhere. Users generally prefer a table of approximate numbers to a table half full of holes, and it removes the complementary-suppression problem entirely.
9. Check the published margins agree
After suppression, the sum of the published values plus the suppressed mass must equal the published total, or users will find the discrepancy and reconstruct it.
Code examples
Example 1 โ the table and the primary pass
import geopandas as gpd, pandas as pd
crimes = gpd.read_file("london_2025q1.gpkg").to_crs(27700)
cell = (crimes.geometry.x // 250).astype(int).astype(str) + "_" + \
(crimes.geometry.y // 250).astype(int).astype(str)
table = pd.crosstab(cell, crimes["category"])
MIN = 5
nonempty = table.values > 0
primary = nonempty & (table.values < MIN)
print(f"table {table.shape}, non-empty {nonempty.sum():,}, "
f"primary suppressions {primary.sum():,} "
f"({primary.sum()/nonempty.sum():.1%}) hiding {table.values[primary].sum():,} records")
table (130, 14), non-empty 1,228, primary suppressions 643 (52.4%) hiding 1,343 records
Example 2 โ complementary suppression as a function
import numpy as np
def suppress(table, min_count=5, suppress_zeros=False):
v = table.values
nonempty = v > 0 if not suppress_zeros else np.ones_like(v, bool)
sup = nonempty & (v < min_count)
for _ in range(100):
changed = False
for axis in (0, 1):
s = sup if axis == 1 else sup.T
ne = nonempty if axis == 1 else nonempty.T
vals = v if axis == 1 else v.T
for i in range(s.shape[0]):
if s[i].sum() == 1:
cand = np.flatnonzero(ne[i] & ~s[i])
if len(cand):
s[i, cand[vals[i, cand].argmin()]] = True
changed = True
if not changed:
break
out = table.mask(sup)
return out, sup
published, mask = suppress(table, 5)
print(f"suppressed {mask.sum():,} of {(table.values > 0).sum():,} non-empty "
f"({mask.sum()/(table.values > 0).sum():.1%}), "
f"hiding {table.values[mask].sum():,} records "
f"({table.values[mask].sum()/table.values.sum():.1%})")
suppressed 646 of 1,228 non-empty (52.6%), hiding 1,361 records (6.9%)
Three extra cells over the primary pass โ complementary suppression is cheap here because the table is wide and the small values are spread out.
Example 3 โ publishing the result unambiguously
long = (published.stack(dropna=False)
.rename("count").reset_index()
.rename(columns={"level_0": "cell", "category": "category"}))
long["flag"] = np.where(long["count"].isna(),
np.where(table.stack().values > 0, "suppressed", "zero"),
"published")
long["count"] = long["count"].fillna(-1).astype(int)
print(long["flag"].value_counts().to_string())
Ship the flag. Without it, every user writes their own guess about what a blank means, and half of them guess zero.
Explanation
Why the complementary pass is mandatory
Suppression removes a value from the page, not from the arithmetic. With four of five categories published and the row total published, the fifth is a subtraction. The loop enforces the minimal property that makes the subtraction underdetermined: at least two unknowns in every equation.
Why "at least two holes" is a floor, not a guarantee
Two unknowns in a row is underdetermined only if the same two are not each determined by their own columns. Statistical agencies solve this properly as a linear programme that gives every suppressed cell a feasible interval wider than a protection level. The iterative heuristic here is adequate for open data and inadequate for a release where the attacker will spend real effort; if the table has many margins and subtotals, use a purpose-built cell-suppression solver.
Why raising the threshold is not free
Going from 5 to 10 on this table changes a small number of hidden records into a large number of hidden cells, because most of the distribution sits in the single digits. Look at both costs โ cells lost and records hidden โ before choosing.
Why rounding often wins
Controlled rounding turns every cell into an approximate number instead of a hole. Nothing has to be suppressed, the margins can be made consistent, and the user gets a usable table. Its weakness is that a rounded value still bounds the truth, so it needs a base large enough that the bound is not disclosive.
Edge cases or notes
- Suppress subtotals too. A subtotal is a margin like any other.
- Time series need a joint decision. A cell suppressed once and published later is disclosed by the annual total.
- Percentages and rates leak. 100% of a cell of 3 publishes the 3.
- Zeros may need suppressing. In a small known population, a zero can be as revealing as a one.
- Rows with a single non-empty cell cannot be fixed by complementary suppression. Suppress the total or merge the row.
- Keep the mask, not just the masked table. You need it next release to stay consistent.
- Do not suppress after adding noise. Mixing differential privacy with a data-dependent suppression breaks the guarantee.
- Publish the rule. Users must be able to tell a blank from a zero without asking.
Internal links
- Aggregation and suppression rules explained โ the threshold rules and why the cross-product matters
- Suppressed cells can be recovered from the totals โ the failure mode this prevents
- How to aggregate points to units that meet a minimum count โ avoiding suppression by changing the geography
- How to add differentially private noise to spatial counts โ the alternative to holes
- Spatial k-anonymity explained โ where the threshold comes from
- Small area rates explained โ why small denominators are also a disclosure channel
- How to normalise census counts into rates in Python โ the denominators that leak
- Margins of error explained โ published uncertainty around suppressed cells
FAQ
What counts as a small count?
Anything below the threshold you publish โ commonly 3, 5 or 10. The threshold applies to subjects, not records.
Why is suppressing the small cells not enough?
Because a single hole in a row with a published total can be recovered by subtraction. Every row and column needs zero or at least two holes.
How many extra cells does complementary suppression cost?
Usually few. On the London table it added three cells to 643, taking the total hidden from 1,343 to 1,361 records.
What should I put in a suppressed cell?
A sentinel plus a flag column, so users can tell a suppressed cell from a true zero. A bare blank is ambiguous and will be read as zero.
Should I round instead of suppressing?
Often yes. Controlled rounding to a base of 3 or 5 publishes a number everywhere and avoids the complementary-suppression problem.
What if a row has only one non-empty cell?
There is nothing to pair it with. Suppress the row total as well, or merge the row into an adjacent area.