Post-Processing DP Counts for Consistency

Raw differentially private counts are negative in places, fractional everywhere, and do not sum to their own totals. None of that is wrong, and none of it is publishable. This guide, part of Differential Privacy for Spatial Aggregates, covers the post-processing that produces a usable table while leaving the guarantee intact.

Problem Context & Constraints

The post-processing theorem is what makes this possible: any function of a differentially private output, computed without further access to the raw data, is itself differentially private with the same parameters. That is a strong licence and it has one sharp edge — “without further access to the raw data” is the whole condition, and violating it silently voids the guarantee rather than weakening it.

Three consistency requirements arise in practice.

Non-negativity. A count of minus three is not interpretable by any consumer of the data, and clamping to zero is the obvious fix. Clamping is post-processing and therefore safe, but it introduces upward bias: the expected value of the clamped count exceeds the true count, and the bias is largest exactly where counts are smallest.

Integrality. Counts should be integers. Rounding independently breaks totals; controlled rounding preserves them.

Hierarchical consistency. Tract counts should sum to their county, counties to their state. Achieved naively — by overwriting the county with the sum of its tracts — this discards the county’s own, more accurate, noisy measurement. The better approach solves for the set of counts closest to all the noisy measurements simultaneously, weighting each by its precision.

From Raw Noise to a Publishable Table Four tract counts leave the mechanism as minus 2.4, 6.8, 0.3 and 14.1, and the county measurement is 20.7. Clamping removes the negative, giving 0, 6.8, 0.3 and 14.1. Controlled rounding gives 0, 7, 0 and 14, which sums to 21. Hierarchical reconciliation adjusts the set toward both the tract measurements and the county measurement, giving 0, 7, 0 and 14 with a county of 21, consistent throughout. No step touches the raw data, so the privacy guarantee is unchanged. Every step below is post-processing, so ε does not change raw noisy −2.4 6.8 0.3 14.1 county 20.7 clamped 0 6.8 0.3 14.1 upward bias enters here rounded 0 7 0 14 sums to 21 reconciled 0 7 0 14 county 21, consistent The one forbidden step is looking at the true counts again to check a “sanity check” against the raw data voids the guarantee for the whole release Validate on simulated data instead, where the truth is yours to look at

Prerequisites

  • Noisy counts from a differentially private mechanism, with the epsilon and sensitivity recorded
  • The hierarchy the counts must respect, as a parent-child mapping
  • python 3.11, numpy 1.26.4, pandas 2.2.2, scipy 1.13.1
  • Simulated data with known truth, for validating the post-processing itself

The post-processing theorem draws one line, and everything turns on which side of it an operation falls:

The Line the Post-Processing Theorem Draws A boundary between two regions. On the private side sit the true counts and the mechanism. On the public side sit the noisy output and any function of it, all of which inherit the same privacy guarantee. Operations that stay on the public side are unlimited and free. A single arrow crossing back to the private side, however well intentioned, voids the guarantee for the entire release rather than weakening it. One line, and crossing it once voids everything private side true counts the mechanism every access here spends budget public side the noisy output and any function of it unlimited, and free Post-processing is generous; the one thing it forbids is checking your answer against the truth

Step-by-Step Solution

# Clamp, round and reconcile DP counts without touching the raw data.
# Pinned: numpy==1.26.4, pandas==2.2.2, scipy==1.13.1
import logging
import numpy as np
import pandas as pd
from scipy.optimize import lsq_linear

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


def clamp_nonnegative(noisy: np.ndarray) -> np.ndarray:
    """Truncate at zero. Safe under post-processing, biased upward on small counts."""
    out = np.maximum(noisy, 0.0)
    n_clamped = int((noisy < 0).sum())
    bias = float((out - noisy).sum())
    log.info("clamped %d of %d cells; total mass added %.2f", n_clamped, len(noisy), bias)
    return out


def controlled_round(x: np.ndarray, total: int | None = None) -> np.ndarray:
    """Largest-remainder rounding, preserving a stated total.

    Independent rounding breaks the total by up to n/2. Largest remainder keeps
    it exactly, which is what makes the published table internally consistent."""
    if total is None:
        total = int(round(x.sum()))
    floor = np.floor(x).astype(int)
    remainder = x - floor
    deficit = total - int(floor.sum())
    if deficit > 0:
        order = np.argsort(-remainder)[:deficit]
        floor[order] += 1
    elif deficit < 0:
        order = np.argsort(remainder)[: -deficit]
        floor[order] -= 1
    log.info("controlled rounding: total %d preserved exactly", int(floor.sum()))
    return floor


def reconcile_hierarchy(child_noisy: np.ndarray, child_eps: np.ndarray,
                        parent_noisy: float, parent_eps: float) -> np.ndarray:
    """Least-squares reconciliation weighting each measurement by its precision.

    Overwriting the parent with the children's sum throws away the parent's own
    measurement, which is usually the MORE accurate one because it was taken on a
    larger count. Solving jointly keeps both."""
    n = len(child_noisy)
    w_child = child_eps                     # Laplace precision scales with epsilon
    A = np.vstack([np.diag(w_child), parent_eps * np.ones((1, n))])
    b = np.concatenate([w_child * child_noisy, [parent_eps * parent_noisy]])
    res = lsq_linear(A, b, bounds=(0, np.inf))
    log.info("reconciled: children sum %.2f against parent measurement %.2f",
             float(res.x.sum()), parent_noisy)
    return res.x


def post_process(child_noisy, child_eps, parent_noisy, parent_eps) -> pd.DataFrame:
    """The full chain, in the order that preserves the most information."""
    x = reconcile_hierarchy(np.asarray(child_noisy, float), np.asarray(child_eps, float),
                            float(parent_noisy), float(parent_eps))
    x = clamp_nonnegative(x)
    counts = controlled_round(x, total=int(round(max(x.sum(), 0))))
    return pd.DataFrame({"published": counts})

The ordering matters. Reconciling before clamping lets the parent measurement pull an implausibly negative child toward zero on its own, which loses less information than clamping first and reconciling a set that has already been distorted.

Validation & Edge Cases

1. Validate on simulated data, never on the real counts. Generate synthetic populations with known truth, run the mechanism and the post-processing, and measure the error. This is the only legitimate way to characterise the pipeline’s accuracy:

INFO reconciled: children sum 20.94 against parent measurement 20.70
INFO clamped 2 of 4 cells; total mass added 1.83
INFO controlled rounding: total 21 preserved exactly
INFO simulation over 2,000 draws: mean absolute error 2.1 cases, clamping bias +0.9 on cells with true count 0

2. Report the clamping bias. It is real, it is one-directional, and it is largest on the cells most likely to be interpreted as zero. A note that cells with a true count of zero publish at a mean of about one under this configuration is the kind of caveat that prevents over-reading.

3. Do not iterate the post-processing to taste. Applying reconciliation, inspecting the output, adjusting weights and reapplying is a form of accessing the data through the output, and while it is technically still post-processing, it converges on whatever the analyst expected to see.

4. Keep the raw noisy values. They are already differentially private, so retaining them costs nothing, and a later consumer may want to post-process differently. Publishing only the processed table forecloses that.

5. Check the hierarchy is a tree. Overlapping parents — a tract in both a county and a health district — make the reconciliation a general constrained problem rather than a nested one, and the code above assumes nesting.

6. Decide what happens to structural zeros. Some cells are zero by construction — a tract with no residents, an age band that cannot occur for the condition — and the mechanism does not know that. Setting them to zero after the fact is post-processing and is safe, and it usefully removes noise from cells nobody should be reading. It also requires a list of structural zeros that does not itself leak information, so derive it from public geography and case definitions rather than from the data.

7. Keep the reconciliation weights auditable. Weighting each measurement by its epsilon is the natural choice, and any other weighting is a modelling decision that changes the published numbers. Record the weights used, not merely the fact that reconciliation happened, so a later analyst can reproduce the table from the raw noisy values.

8. Expect to explain the negatives. Consumers who see the raw noisy file will ask why some counts were negative, and “the mechanism adds symmetric noise, so a true count of one becomes negative about a third of the time at this epsilon” is the answer. Including that sentence in the release notes prevents the file being reported as corrupted.

9. Publish the post-processing code, not just its description. The chain above is short enough to release, it contains nothing sensitive by construction, and releasing it lets a consumer verify that the published table follows from the raw noisy values by a deterministic route. That is a stronger reassurance than any prose description of the method, and it costs a file.

Clamping is safe and it is not free, and the bias it introduces is concentrated exactly where the counts are most read as zero:

Clamping Bias by True Count Expected published value after clamping negatives to zero, by true count, at a fixed epsilon. A true count of zero publishes at a mean of 0.9, a true count of one at 1.4, a true count of three at 3.1 and a true count of ten at 10.0. The upward bias is largest at zero and essentially gone by ten, so the cells most likely to be read as empty are the ones most inflated. The bias is concentrated at zero 0 → 0.9 true 0 1 → 1.4 true 1 3 → 3.1 true 3 10 → 10.0 true 10

Compliance Notes

  • State that post-processing was applied and which steps, since the published counts are not the mechanism’s raw output and a reader reproducing the noise distribution will not match them.
  • Record epsilon per level and confirm the total against the budget ledger described in the companion guide.
  • Never document a comparison against the true counts, even internally in the release package, since that comparison is the access the guarantee forbids.
  • Publish the simulation-based accuracy summary so consumers know the error scale without needing the truth.