Differentially Private Spatial Range Counts
A range count — “how many devices were inside this rectangle?” — looks like the simplest query a spatial store can answer, and it is the one where naive differential privacy performs worst. Answer each range independently and error grows with the query’s size; answer them from a flat noisy grid and every large query accumulates every cell’s noise. The standard fix is a hierarchical structure that answers a range from a handful of pre-noised nodes instead of thousands of leaves, with a constraint step that makes the answers mutually consistent. This guide builds it, under differentially private spatial aggregation in Differential Privacy for Geospatial Data.
Parameter Configuration and Calibration
fanoutanddepth— the shape of the hierarchy. A quadtree over a square domain has fanout 4; a binary hierarchy per axis has fanout 2. Error for a range query scales with , and the node count for an arbitrary range is — which is minimised around a fanout of 4 to 16 for realistic grid sizes. Depth follows from the finest resolution you intend to answer at.epsilon_per_level— the budget split down the tree. Every level is a separate release over the same data, so the budget divides sequentially across levels. A uniform split is the default; a geometric split favouring deeper levels is better when most queries are small, and the reverse when most queries are large. Read the query log before choosing.consistency— whether to enforce parent = sum of children. Without it, the same region answered two ways gives two different numbers, which analysts correctly read as a bug. With it, a least-squares projection redistributes noise and reduces error as a bonus. It is post-processing, so it is free.materialise_once— answer from a fixed noisy structure. Build the noisy hierarchy once per release period and answer every query from it. Re-noising per query multiplies the budget by the query count for no benefit, and it makes answers inconsistent.
| Strategy | Error for a range of leaves | Budget per period | Consistent? |
|---|---|---|---|
| Independent per-query noise | per query | grows with query count | no |
| Flat noisy grid | one release | yes | |
| Hierarchy, uniform split | one release | after projection |
Reference Implementation
from __future__ import annotations
import math
import random
from dataclasses import dataclass, field
from typing import Sequence
@dataclass
class HierarchicalCounts:
"""A one-dimensional hierarchy over `leaves` cells; compose two for a grid.
Real deployments run this over a space-filling-curve ordering (H3 parent/child,
or a Z-order index), which reduces a rectangle query to a small number of
contiguous ranges — the same structure this class answers.
"""
leaves: int
fanout: int
epsilon: float
levels: list[list[float]] = field(default_factory=list)
def build(self, counts: Sequence[float], *, seed: int = 0) -> "HierarchicalCounts":
"""Noise every level under an equal split of the total budget."""
if len(counts) != self.leaves:
raise ValueError("counts must cover every leaf")
rng = random.Random(seed)
depth = max(1, math.ceil(math.log(self.leaves, self.fanout)))
per_level_eps = self.epsilon / depth
scale = 1.0 / per_level_eps
level = list(counts)
self.levels = []
while True:
noisy = []
for value in level:
u = rng.random() - 0.5
noisy.append(value - scale * math.copysign(1.0, u) * math.log1p(-2 * abs(u)))
self.levels.append(noisy)
if len(level) <= 1:
break
level = [sum(level[i:i + self.fanout]) for i in range(0, len(level), self.fanout)]
self.levels.reverse() # root first
return self
def range_count(self, lo: int, hi: int) -> float:
"""Answer [lo, hi) by covering it with the fewest nodes available.
Greedy cover: take the largest aligned node that fits, then recurse. This is
what keeps error at sqrt(log m) rather than sqrt(m) — the whole point of the
structure.
"""
if not 0 <= lo <= hi <= self.leaves:
raise ValueError("range outside the domain")
total = 0.0
i = lo
while i < hi:
best_level, best_span = len(self.levels) - 1, 1
for depth, nodes in enumerate(self.levels):
span = self.fanout ** (len(self.levels) - 1 - depth)
if span and i % span == 0 and i + span <= hi and span > best_span:
best_level, best_span = depth, span
node_index = i // best_span
if node_index < len(self.levels[best_level]):
total += self.levels[best_level][node_index]
i += best_span
return total
def enforce_consistency(self) -> None:
"""Project so each parent equals the sum of its children (free, and helpful).
A single top-down pass distributes each parent's residual equally among its
children. This is post-processing: it consumes no budget, makes answers
mutually consistent, and reduces mean squared error.
"""
for depth in range(len(self.levels) - 1):
parents, children = self.levels[depth], self.levels[depth + 1]
for p_idx, parent in enumerate(parents):
start = p_idx * self.fanout
block = children[start:start + self.fanout]
if not block:
continue
residual = (parent - sum(block)) / len(block)
for offset in range(len(block)):
children[start + offset] += residual
Validation Checkpoint
def _validate() -> None:
rng = random.Random(5)
leaves = 256
truth = [max(0.0, rng.lognormvariate(4.0, 1.0)) for _ in range(leaves)]
tree = HierarchicalCounts(leaves=leaves, fanout=4, epsilon=1.0).build(truth, seed=1)
# 1. A wide range must be far more accurate than the sqrt(m) flat-grid bound.
wide_true = sum(truth)
wide_err = abs(tree.range_count(0, leaves) - wide_true)
flat_bound = math.sqrt(leaves) * math.sqrt(2.0) / 1.0
assert wide_err < flat_bound, (wide_err, flat_bound)
# 2. Consistency projection must not change the root's meaning, and must make
# parents equal the sum of their children.
tree.enforce_consistency()
for depth in range(len(tree.levels) - 1):
parents, children = tree.levels[depth], tree.levels[depth + 1]
for p_idx, parent in enumerate(parents):
block = children[p_idx * 4:(p_idx + 1) * 4]
if block:
assert abs(parent - sum(block)) < 1e-6
# 3. Two decompositions of the same range must now agree exactly.
whole = tree.range_count(0, 64)
halves = tree.range_count(0, 32) + tree.range_count(32, 64)
assert abs(whole - halves) < 1e-6
# 4. A narrow range is answered from few nodes, so its error stays bounded.
narrow_err = abs(tree.range_count(10, 14) - sum(truth[10:14]))
assert narrow_err < 40.0, narrow_err
# 5. More budget must reduce error for the same query.
rich = HierarchicalCounts(leaves=leaves, fanout=4, epsilon=8.0).build(truth, seed=1)
assert abs(rich.range_count(0, 64) - sum(truth[:64])) < abs(
HierarchicalCounts(leaves=leaves, fanout=4, epsilon=0.5)
.build(truth, seed=1).range_count(0, 64) - sum(truth[:64])
)
# 6. An out-of-domain range must raise rather than answer.
try:
tree.range_count(-1, 10)
except ValueError:
pass
else:
raise AssertionError("out-of-domain ranges must raise")
print("hierarchical range counts: all assertions passed")
_validate()
Assertion 3 is what makes the release usable by humans. Analysts decompose regions constantly — by borough, then by ward — and a store that returns different totals depending on how the region was carved is one they will stop trusting long before they file a bug.
Incident Response and Edge Cases
- Large queries are accurate and small ones are unusable. Working as designed, and often the wrong design for the workload. Re-split the budget geometrically toward the deeper levels, and re-measure against the real query log.
- The same query returns different answers on consecutive days. Each release period draws fresh noise. If consumers compare across days, publish a difference-aware view or hold the structure for longer; the daily jitter is twice the noise of one release.
- Consistency projection makes some counts negative. Expected on sparse regions. Clamp at the point of display, not inside the structure, or the parent–child identity breaks and the inconsistency returns.
- Rectangle queries touch many ranges under the space-filling curve. The curve’s locality is imperfect at some boundaries. Pad the query to the enclosing aligned block and subtract the padding’s own range counts, rather than issuing dozens of tiny ranges.
- Budget is being debited per query. Someone is re-noising. Materialise once per period; the structure is a single release, and answering from it is post-processing.
Frequently Asked Questions
Why is a hierarchy better than a flat noisy grid?
Because a range that covers a whole subtree can be answered from one pre-noised node instead of summing thousands of leaves. Error grows with the number of nodes touched, so the hierarchy turns a √m dependence into roughly √log m — at the cost of splitting the budget across levels.
Does enforcing consistency cost privacy budget?
No. It is a deterministic function of already-released values, which makes it post-processing. It is one of the rare steps that improves accuracy and consistency simultaneously and costs nothing, which is why it should be the default rather than an option.
What fanout should I use?
Between 4 and 16 for most spatial grids. A small fanout means many levels and therefore a thinner budget per level; a large fanout means few levels but more nodes to sum per query. Measure against your own query-size distribution — the optimum is workload-dependent, not universal.
How does this relate to a private quadtree?
A quadtree is this structure with a data-dependent branching rule: it stops splitting where counts are small. That saves budget on empty space but makes the structure itself data-dependent, which has to be accounted for. The uniform hierarchy here is simpler to reason about and is the right starting point.
Related
- Differentially Private Spatial Aggregation — the parent method and its grid choices.
- Private Quadtree Spatial Histograms — the data-dependent variant of this structure.
- Measuring Spatial Query Error Under Differential Privacy — how to report the error these answers carry.
- Per-User vs Per-Cell Budget Partitioning — why every level is a separate sequential release.
Up one level: Differentially Private Spatial Aggregation · Section: Differential Privacy for Geospatial Data.