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

  • fanout and depth — 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 nodes touched\sqrt{\text{nodes touched}}, and the node count for an arbitrary range is O(fanoutlogfanoutN)O(\text{fanout} \cdot \log_\text{fanout} N) — 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 mm leaves Budget per period Consistent?
Independent per-query noise Δ/ε\Delta/\varepsilon per query grows with query count no
Flat noisy grid mσ\sqrt{m}\,\sigma one release yes
Hierarchy, uniform split logmLσ\approx\sqrt{\log m}\cdot L\sigma one release after projection
Error against query size, flat versus hierarchical Two curves of expected absolute error against query size on a logarithmic axis. The flat noisy grid's error grows as the square root of the query size, reaching about 45 counts for a thousand-leaf range. The hierarchy grows far more slowly because a large range is answered from a few pre-noised nodes, and it overtakes the flat grid only for the very smallest queries. Error against query size, flat versus hierarchical same total ε = 1.0 in both cases 1 10 100 1000 0 20 40 leaves covered by the query expected absolute error flat noisy grid 6-level hierarchy, fanout 4
The hierarchy loses on single-cell queries and wins by an order of magnitude on district-sized ones — which is the shape of most real query logs.

Reference Implementation

python
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

python
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()
Two ways to divide the budget down the levels A grouped bar chart of the epsilon share given to each of six hierarchy levels under two splitting rules. The uniform split gives every level one sixth. The geometric split weighted 1.6 to the power of the depth gives the root under two percent and the deepest level over a third, which suits a workload dominated by small queries. Two ways to divide the budget down the levels the right choice depends on your query-size distribution 0 0.2 0.4 share of ε 0.17 0.04 L0 0.17 0.06 L1 0.17 0.10 L2 0.17 0.16 L3 0.17 0.25 L4 0.17 0.40 L5 uniform split geometric split
Read your query log before splitting. Small-query workloads want budget at the leaves; district-level dashboards want it near the root.

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.
Consistency projection makes decompositions agree A grouped bar chart of the expected error of four different decompositions of the same region, before and after the least-squares consistency projection. Before projection, answering the region as sixty-four separate leaves carries four times the error of answering it as one node. After projection every decomposition returns the same value with the same error. Consistency projection makes decompositions agree the same region, answered four ways 0 20 40 expected error (counts) 11 11 one 64-leaf node 16 11 two 32-leaf nodes 22 11 four 16-leaf nodes 44 11 64 leaves before projection after projection
The projection is post-processing — free in budget — and it removes the class of bug reports that begins “these two numbers should match”.

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.

Up one level: Differentially Private Spatial Aggregation · Section: Differential Privacy for Geospatial Data.