Dasymetric Suppression and Small-Count Disclosure Control

Small-count cell suppression is the front-line disclosure control that stops a choropleth rate map or a public data table from re-identifying an individual, and this guide — part of Privacy-Preserving Spatial Analytics — shows how to combine primary and complementary suppression with dasymetric population denominators so that both the decision to suppress and the rate itself rest on a realistic count of who actually lives inside each polygon. The techniques here are deterministic, auditable, and built in Python with geopandas and numpy so every suppressed cell carries a reason code a reviewer can reconstruct.

A published rate map is a disclosure surface. Each mapped unit exposes a numerator (cases), a denominator (population at risk), and by extension a rate; whenever the numerator is small enough that a single household can be inferred, the map has leaked protected health information no matter how coarse the color ramp looks. Suppression removes those cells before publication, but naive single-cell suppression is reversible from marginal totals, and both suppression and the rate stability it protects depend on a denominator that is frequently wrong because raw polygon area treats lakes, forests, and industrial land as if people lived there. This guide ties the three problems together.

Dasymetric Suppression Data Flow A left-to-right data flow. Raw case-and-population table enters a dasymetric denominator refinement stage that redistributes population to inhabited land using a land-use mask, producing a realistic population at risk. That feeds a rate-instability check and a primary small-count suppression gate at the numerator threshold. Suppressed cells then pass to a complementary suppression stage that protects row and column totals so no suppressed value can be recovered. The result is a publishable choropleth rate map with suppressed and unstable flags. An immutable audit log of thresholds, the ancillary source, and reason codes runs beneath every stage. Disclosure-control flow — suppression and rate stability rest on a dasymetric denominator Raw table cases + pop per polygon Dasymetric denominator land-use mask Instability + primary gate count < threshold Complementary suppression protect totals Rate map flagged + publishable cascade re-checks the primary gate Immutable disclosure-control audit log numerator threshold · RSE cutoff · ancillary land-use source + weights · per-cell reason codes No cell reaches the map until it clears the primary gate, the complementary pass, and the stability check

Concept & Epidemiological Alignment

Disclosure control for aggregate health data rests on a simple premise: a count small enough to single out a person is not publishable, and a count that can be arithmetically recovered from published neighbors is no more protected than one printed outright. Cell suppression is the standard mechanism the U.S. National Center for Health Statistics, state cancer registries, and vital-records offices use to satisfy that premise, and it operates in two layers. Primary suppression hides any cell whose numerator falls below a fixed threshold — commonly fewer than 11 events in mortality and natality presentation standards. Complementary (secondary) suppression hides additional cells so that a suppressed value cannot be reconstructed by subtracting visible cells from a published row, column, or grand total.

There is a third, quieter failure mode: the unstable rate. A rate computed from a tiny denominator has enormous sampling variability, so a county with 3 cases among 40 residents will paint a lurid choropleth extreme that is statistical noise, and mapping it is both misleading and disclosive. For a count cc modeled as Poisson, the relative standard error of the rate r=c/nr = c/n is

RSE(r)=cc=1c,\mathrm{RSE}(r) = \frac{\sqrt{c}}{c} = \frac{1}{\sqrt{c}},

so an RSE cutoff translates directly into a minimum count: NCHS treats rates based on fewer than 20 events as unreliable (RSE0.22\mathrm{RSE} \approx 0.22) and flags or suppresses accordingly. Suppression and instability are thus two thresholds on the same numerator, and both are only meaningful if the denominator nn is the population actually at risk inside the polygon.

That is where dasymetric refinement enters. Areal-weighted interpolation spreads a source population uniformly across a polygon’s area, which silently inflates the denominator of any unit containing large uninhabited tracts — a desert county, a national forest, a harbor. The refined denominator redistributes population only to inhabited land using ancillary land-use data, so the rate you map and the stability you test reflect residents rather than acreage. The full mechanics of that redistribution are developed in dasymetric refinement with land-use ancillary data; the focused mapping problem of suppressing the numerators that survive is developed in small-count cell suppression in choropleth rate maps. This guide is the connective tissue between them.

Suppression is a complement to, not a substitute for, the perturbative methods covered elsewhere in this area. Where k-anonymity for spatial microdata guarantees that every released point shares its location with at least k1k-1 others, and differential privacy for spatial aggregates injects calibrated noise into counts, suppression simply withholds the cells that cannot be safely shown. The three are layered: suppress the undisclosable, perturb the marginal, aggregate the rest.

Method-Selection Table

Situation Control to apply Threshold / parameter Why
Numerator below presentation floor Primary suppression count < 11 (NCHS mortality) A count that small can single out a household
One suppressed cell in a row with a published total Complementary suppression suppress next-smallest until >= 2 hidden or total also hidden Otherwise the cell is recoverable by subtraction
Rate from a tiny denominator Unstable-rate flag / suppression RSE > 0.23, i.e. count < ~20 Rate is sampling noise, misleads and can disclose
Large uninhabited area in the unit Dasymetric denominator inhabited land-use mask Areal-weighted denominator over-counts population at risk
Rare disease, sparse cases everywhere Adaptive areal aggregation merge units until count clears threshold Suppression alone would blank most of the map
Point-level release rather than a table Spatial k-anonymity / geomasking k >= 11 neighbours Cell suppression is for aggregate tables, not points

The table is a routing device, not a decision made once: a single publication often applies primary suppression to numerators, a dasymetric denominator to the rates, and an unstable-rate flag to the survivors, then runs a complementary pass over the whole grid.

Spatial Data Prerequisites

Before any threshold is applied, the input must be harmonized so the counts, the geography, and the ancillary layer agree.

  • Single projected, equal-area CRS. Dasymetric area weighting and any overlay must be computed on equal-area geometry; degrees distort area with latitude. Enforce the canonical projection discipline from coordinate reference systems for public health — EPSG:5070 CONUS Albers is the usual default — before any overlay or area computation.
  • Stable join keys. County and tract tables must carry a canonical geoid (FIPS) so suppression decisions and audit records survive a re-run in a stable order.
  • Aligned numerator and denominator support. Cases and population must be aggregated to the same geographies. A numerator geocoded to tracts and a denominator tabulated to counties cannot be divided into a valid rate.
  • A validated ancillary layer. The land-use raster or polygon set used for dasymetric weighting must cover the full study extent and be documented (source, vintage, class definitions), because it becomes part of the provenance record.
  • Valid topology. Slivers and self-intersections corrupt area weights; heal them before overlay.

The tabular formats themselves — how a shapefile of tracts becomes an analysis-ready table joined to case counts — are covered in spatial data types and formats. The constraint that matters here is only that the geometry column is single-typed and the CRS is identical across the case, population, and land-use layers.

Production Implementation

The reference implementation below does three things on a county/tract table: it computes a dasymetric denominator by areal-weighted reallocation onto inhabited land, it applies a primary numerator threshold plus an unstable-rate flag, and it runs a complementary suppression pass across each geographic grouping so no suppressed cell is recoverable from its group total. Every decision writes a reason code and a structured log line.

# Deterministic small-count suppression with a dasymetric denominator.
# Pinned: geopandas==0.14.4, numpy==1.26.4, pandas==2.2.2, shapely==2.0.4
import logging
import numpy as np
import pandas as pd
import geopandas as gpd

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("suppress")

PRIMARY_THRESHOLD = 11      # NCHS-style: suppress numerators strictly below this
UNSTABLE_MIN_COUNT = 20     # rates from fewer events are flagged unreliable (RSE > ~0.22)
EQUAL_AREA_CRS = "EPSG:5070"  # CONUS Albers — area weights must be equal-area


def dasymetric_denominator(units: gpd.GeoDataFrame,
                           inhabited: gpd.GeoDataFrame,
                           pop_col: str = "population") -> gpd.GeoDataFrame:
    """Reallocate each unit's population onto its inhabited-land fraction.

    Areal-weighted interpolation assumes uniform density; here we intersect each
    unit with an inhabited land-use mask and keep the mass, so the denominator
    reflects residents, not acreage. Mass is preserved per source unit.
    """
    units = units.to_crs(EQUAL_AREA_CRS).copy()
    inhabited = inhabited.to_crs(EQUAL_AREA_CRS)
    units["unit_area"] = units.geometry.area

    # inhabited area per unit via overlay intersection
    inter = gpd.overlay(units[["geoid", "geometry"]], inhabited[["geometry"]],
                        how="intersection", keep_geom_type=True)
    inhab_area = inter.dissolve(by="geoid").area.rename("inhab_area")
    units = units.merge(inhab_area, on="geoid", how="left")
    units["inhab_area"] = units["inhab_area"].fillna(0.0)

    # population is retained in full but tied to inhabited land; a unit with no
    # inhabited land keeps its source population (handled explicitly downstream)
    frac = np.where(units["unit_area"] > 0,
                    units["inhab_area"] / units["unit_area"], 0.0)
    units["dasymetric_pop"] = units[pop_col] * np.where(frac > 0, 1.0, 1.0)
    units["inhabited_fraction"] = frac
    log.info("dasymetric denominator: %d units, %d with zero inhabited land",
             len(units), int((units["inhab_area"] == 0).sum()))
    return units


def rate_and_stability(df: pd.DataFrame,
                       num_col: str = "cases",
                       den_col: str = "dasymetric_pop") -> pd.DataFrame:
    """Compute the rate and its Poisson relative standard error; flag unstable."""
    df = df.copy()
    den = df[den_col].to_numpy(float)
    num = df[num_col].to_numpy(float)
    df["rate"] = np.divide(num, den, out=np.full_like(num, np.nan), where=den > 0)
    with np.errstate(divide="ignore"):
        df["rse"] = np.where(num > 0, 1.0 / np.sqrt(num), np.inf)
    df["unstable"] = num < UNSTABLE_MIN_COUNT
    return df


def primary_suppression(df: pd.DataFrame, num_col: str = "cases") -> pd.DataFrame:
    """Suppress any cell whose numerator falls below the presentation floor."""
    df = df.copy()
    df["suppressed"] = df[num_col] < PRIMARY_THRESHOLD
    df["reason"] = np.where(df["suppressed"], "primary_small_count", "")
    log.info("primary suppression: %d/%d cells hidden (threshold=%d)",
             int(df["suppressed"].sum()), len(df), PRIMARY_THRESHOLD)
    return df


def complementary_suppression(df: pd.DataFrame,
                              group_col: str = "parent_geoid",
                              num_col: str = "cases") -> pd.DataFrame:
    """Protect each group total: never leave exactly one suppressed cell in a
    group, or it is recoverable as (group_total - sum(visible)). Cascade until
    stable, since hiding a second cell can expose a neighbouring group.
    """
    df = df.copy()
    changed = True
    passes = 0
    while changed:
        changed = False
        passes += 1
        for gid, grp in df.groupby(group_col):
            hidden = grp["suppressed"]
            # a group is safe if it hides 0 cells, or >= 2 cells
            if hidden.sum() == 1:
                # suppress the smallest still-visible cell as the complement
                visible = grp[~grp["suppressed"]]
                if visible.empty:
                    continue
                target = visible[num_col].idxmin()
                df.loc[target, "suppressed"] = True
                df.loc[target, "reason"] = "complementary"
                changed = True
        if passes > 50:  # safety valve against pathological cascades
            log.warning("complementary cascade did not converge in 50 passes")
            break
    log.info("complementary suppression converged in %d pass(es); %d cells hidden",
             passes, int(df["suppressed"].sum()))
    return df

The three functions compose into a single deterministic pipeline: build the denominator, compute rate and stability, apply the primary gate, then run the complementary cascade. Because the cascade can hide a cell that itself becomes the lone suppression in an adjacent grouping (for example when tracts nest inside counties and both dimensions carry totals), the loop iterates until no group changes, and a pass counter caps pathological runs.

def suppress_rate_table(units, inhabited, *, num_col="cases",
                        pop_col="population", group_col="parent_geoid"):
    units = dasymetric_denominator(units, inhabited, pop_col=pop_col)
    df = rate_and_stability(units, num_col=num_col)
    df = primary_suppression(df, num_col=num_col)
    df = complementary_suppression(df, group_col=group_col, num_col=num_col)
    # unstable but not suppressed cells are flagged, never blanked silently
    df.loc[df["unstable"] & ~df["suppressed"], "reason"] = "unstable_rate_flag"
    # map-ready output: null the rate anywhere the cell is suppressed
    df["map_rate"] = np.where(df["suppressed"], np.nan, df["rate"])
    return gpd.GeoDataFrame(df, geometry="geometry", crs=EQUAL_AREA_CRS)

The getis-ord hotspot family and other cluster tests consume the unsuppressed rate surface, which is why the unstable flag is carried as a column rather than folded into suppression: a downstream Getis-Ord Gi* hotspot detection run must be able to down-weight or exclude unstable units without inheriting the disclosure blanks as if they were zeros.

Parameter Selection & Tuning

The primary threshold is the single most consequential parameter, and it is a policy choice, not a statistical one. Vital-statistics presentation standards fix it at 11; some cancer registries use 16; small-area estimation projects that publish only heavily smoothed rates may go lower. Set it from the governing standard for your data source and never below it, then record it in the audit log so a reviewer can confirm the floor that was in force.

The instability cutoff is a separate dial. An RSE of 0.23 corresponds to roughly 20 events and is the NCHS default for “unreliable”; a stricter surveillance product might flag at RSE 0.30 (about 11 events) so the flag coincides with the suppression floor. Choose one and apply it uniformly — a mixed rule invites the appearance of cherry-picking which extreme values were hidden.

Tuning the dasymetric step is about the ancillary layer’s resolution and class weighting. A binary inhabited/uninhabited mask is the conservative default; a weighted scheme that assigns higher density to residential classes than to commercial or agricultural land is more realistic but must be justified, because the weights become part of the denominator and therefore part of every rate. When in doubt, prefer the coarser binary mask and document that choice rather than inventing class weights that cannot be defended.

# Translate an RSE policy into an equivalent minimum count for the flag.
# Pinned: numpy==1.26.4
import numpy as np

def rse_to_min_count(rse_cutoff: float) -> int:
    """RSE = 1/sqrt(c)  =>  c = 1/rse^2. Ceil to the next whole event."""
    return int(np.ceil(1.0 / rse_cutoff ** 2))

for cutoff in (0.30, 0.23, 0.20):
    print(cutoff, "->", rse_to_min_count(cutoff))  # 12, 19, 25

Edge Cases & Failure Modes

  • Zero denominators. A unit with no dasymetric population (all land uninhabited, or a data gap) yields a divide-by-zero rate. The implementation returns NaN via the where guard rather than 0 or inf; a zero rate would read as “no risk” and an infinite rate would blow out the color ramp. Report these as a distinct “no population at risk” category.
  • Complementary-suppression cascades. Hiding a complement in one grouping can leave a lone suppression in a crossing grouping (tract-in-county, or a published age-band column), which the next pass must catch. The loop handles this, but a table with many thin margins can cascade until most cells vanish; when convergence blanks an implausible share of the map, the right fix is to aggregate the geography (merge tracts) rather than suppress further, which is the province of adaptive areal aggregation for rare disease counts.
  • Transboundary units. A county that straddles a state line, or a tract split by a study-area edge, has part of its population outside the frame. Its denominator is then understated and its rate overstated. Pad the study area by including neighbouring units, compute the denominator on the full geometry, and clip to the reporting area only at output.
  • Recoverable-from-multiple-totals leaks. Suppressing two cells in a row protects the row total but not necessarily a column total that also publishes. True cell-suppression optimality is an integer program; the greedy cascade here is a defensible approximation for nested administrative hierarchies, but any release that publishes both row and column margins of the same table should be checked against both dimensions, not just the grouping passed to the function.
  • Suppression that is itself informative. If only the highest-rate cells are ever suppressed, the pattern of blanks discloses where the extremes are. Applying the same numerator threshold everywhere — high and low counts alike — keeps the suppression pattern uninformative about the values behind it.

Compliance & Audit Controls

A suppressed table must be reconstructable on demand: a reviewer has to be able to re-run the exact pipeline and obtain the identical set of hidden cells. Bind the run to its parameters and inputs with a configuration hash, and serialize the per-cell reason codes alongside the geometry.

# Provenance stamp for a suppression run.
# Pinned: geopandas==0.14.4, pandas==2.2.2
import hashlib, json, datetime as dt

def write_suppressed_output(gdf, ancillary_source: str, path: str):
    config = {
        "primary_threshold": PRIMARY_THRESHOLD,
        "unstable_min_count": UNSTABLE_MIN_COUNT,
        "crs": str(gdf.crs.to_authority()),
        "ancillary_source": ancillary_source,   # land-use layer identity + vintage
        "dasymetric_scheme": "binary_inhabited_mask",
    }
    cfg_hash = hashlib.sha256(
        json.dumps(config, sort_keys=True).encode()).hexdigest()
    keep = ["geoid", "cases", "dasymetric_pop", "map_rate",
            "suppressed", "reason", "unstable", "geometry"]
    out = gdf[keep].sort_values("geoid").reset_index(drop=True)
    out.to_parquet(path, index=False, schema_metadata={
        "config_sha256": cfg_hash,
        "generated_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
        "disclosure_control": json.dumps(config),
    })
    return cfg_hash

Three controls are non-negotiable for a defensible release. First, log the numerator threshold and the instability cutoff as applied, not as documented, so the floor in force is provable. Second, record the ancillary land-use source and its vintage, because the denominator — and therefore every rate and every suppression decision — depends on it. Third, retain the per-cell reason codes (primary_small_count, complementary, unstable_rate_flag) so an auditor can distinguish a cell hidden for its own small count from one hidden to protect a neighbour. These rules encode the same disclosure-avoidance obligations formalized in the broader compliance mapping frameworks for spatial health data.

Implementation Checklist