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.
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
python3.11,numpy1.26.4,pandas2.2.2,scipy1.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:
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:
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.
Related Topics
- Differential Privacy for Spatial Aggregates — the parent guide, covering epsilon and the mechanism itself.
- Allocating a Privacy Budget Across Nested Geographies — where the per-level epsilons this reconciliation weights by come from.
- Small-Count Cell Suppression in Rate Maps — the alternative control, and one that composes badly with this one.