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.

Vertical steps from threshold check, through primary suppression, complementary passes and margin decision, to the published table.
The loop terminates when every row and column has zero or at least two holes.

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.

Bars comparing cells suppressed and records hidden for thresholds of 3, 5 and 10 on the same table.
Between thresholds 3 and 10 the cells blanked roughly double; the records hidden quadruple.

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.

Two panels contrasting a bare blank in a suppressed table with a sentinel value plus a flag column.
A blank is ambiguous between zero and suppressed, and users guess.

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.

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.