Per-User vs Per-Cell Budget Partitioning
The question “who does the budget belong to?” has two plausible answers in a spatial pipeline, and choosing the wrong one either wastes an order of magnitude of accuracy or silently voids the guarantee. Budget partitioned per user is a statement about a person and composes sequentially across everything they touch. Budget partitioned per cell is a statement about geography and composes in parallel across disjoint cells — which is free. Getting these two composition rules straight is the difference between a heatmap at and the same heatmap at . This guide sits under privacy budget management in Core Fundamentals & Architecture for Spatial Privacy.
Parameter Configuration and Calibration
privacy_unit— the subject the guarantee is about. Per-user partitioning requires this to be a person, device or household; per-cell partitioning requires nothing, because a cell is not a subject. Almost every real deployment is per-user with parallel composition across cells, and the confusion arises from calling that “per-cell budget”.max_cells_per_user_per_release— the clipping bound that makes parallel composition valid. Parallel composition applies only when a user’s data influences exactly one cell. A device that moves through 30 cells in an hour influences 30 counts, and the noise scale must be multiplied by 30 unless contributions are clipped. This is the parameter that is most often assumed rather than enforced, and assuming it wrong understates the real by that factor.epsilon_per_release— spent once for the whole disjoint grid. Under a correct clipping bound, every cell in a partition may use the full per-release budget, because the neighbouring dataset differs in exactly one cell. Dividing the budget by the number of cells is the classic and extremely expensive over-charge.overlapping_layers— the count of non-disjoint outputs in one release. Publishing the same data at resolution 7 and resolution 9 is two releases over overlapping partitions, and those compose sequentially. Every extra layer is a full budget debit.
| Structure | Composition rule | Budget cost |
|---|---|---|
| Disjoint cells, one release | parallel | once, total |
| Two resolutions of the same data | sequential | |
| Hourly releases over the same grid | sequential (RDP-composed) | grows with cadence |
| One user in cells, unclipped | sequential within the release | — usually unintended |
| Quadtree levels, parent and child | sequential | one debit per level |
Reference Implementation
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, Mapping, Sequence
import numpy as np
class CompositionError(RuntimeError):
"""Raised when a release's structure contradicts its claimed composition rule."""
@dataclass(frozen=True)
class ReleasePlan:
"""One release: which partitions it touches and how a user maps into them."""
name: str
epsilon: float
cell_ids: Sequence[int] # the partition being released
user_cells: Mapping[str, Sequence[int]] # user -> cells they contribute to
max_cells_per_user: int # the enforced clipping bound
def validate_parallel(self) -> None:
"""Confirm the release really is a disjoint partition with clipped users.
Parallel composition is only sound when neighbouring datasets differ in ONE
cell. Two things break it: duplicated cells in the partition, and a user
whose contributions were not clipped to the stated bound.
"""
if len(set(self.cell_ids)) != len(self.cell_ids):
raise CompositionError(f"{self.name}: partition contains duplicate cells")
for user, cells in self.user_cells.items():
if len(set(cells)) > self.max_cells_per_user:
raise CompositionError(
f"{self.name}: user {user} spans {len(set(cells))} cells, "
f"bound is {self.max_cells_per_user} — clip before binning"
)
def effective_sensitivity(self) -> float:
"""Sensitivity of the whole release given the clipping bound."""
return float(self.max_cells_per_user)
def noise_scale(self) -> float:
"""Laplace scale that actually delivers the claimed epsilon."""
return self.effective_sensitivity() / self.epsilon
def sequential_cost(plans: Iterable[ReleasePlan]) -> float:
"""Total epsilon for releases over overlapping or repeated partitions."""
return float(sum(p.epsilon for p in plans))
def parallel_cost(plans: Sequence[ReleasePlan]) -> float:
"""Total epsilon for releases over mutually disjoint partitions: the max, not the sum."""
seen: set[int] = set()
for p in plans:
overlap = seen & set(p.cell_ids)
if overlap:
raise CompositionError(
f"{p.name}: partitions overlap on {len(overlap)} cell(s) — "
"these compose sequentially, not in parallel"
)
seen |= set(p.cell_ids)
return float(max(p.epsilon for p in plans))
Validation Checkpoint
def _validate() -> None:
# A clean disjoint partition with users clipped to one cell each.
good = ReleasePlan(
name="hourly-heatmap",
epsilon=1.0,
cell_ids=list(range(100)),
user_cells={"u1": [3], "u2": [17], "u3": [42]},
max_cells_per_user=1,
)
good.validate_parallel()
# 1. With a clipping bound of 1, the noise scale is the textbook Delta/epsilon.
assert abs(good.noise_scale() - 1.0) < 1e-9
# 2. Two disjoint partitions cost the MAX, not the sum.
north = ReleasePlan("north", 1.0, list(range(0, 50)), {"u1": [3]}, 1)
south = ReleasePlan("south", 1.0, list(range(50, 100)), {"u9": [77]}, 1)
assert parallel_cost([north, south]) == 1.0
assert sequential_cost([north, south]) == 2.0
# 3. Overlapping partitions must be refused, not silently treated as parallel.
overlapping = ReleasePlan("res9", 1.0, list(range(40, 90)), {"u1": [42]}, 1)
try:
parallel_cost([north, overlapping])
except CompositionError as exc:
assert "overlap" in str(exc)
else:
raise AssertionError("overlapping partitions must raise")
# 4. An unclipped user must be caught before the release, not after.
unclipped = ReleasePlan(
"od-matrix", 1.0, list(range(100)),
{"commuter": [1, 2, 3, 4, 5, 6]}, max_cells_per_user=1,
)
try:
unclipped.validate_parallel()
except CompositionError as exc:
assert "clip before binning" in str(exc)
else:
raise AssertionError("unclipped contributions must raise")
# 5. Raising the clipping bound raises the noise scale proportionally: the
# honest cost of letting a device appear in several cells.
trajectory = ReleasePlan("od-matrix", 1.0, list(range(100)),
{"commuter": [1, 2, 3]}, max_cells_per_user=3)
trajectory.validate_parallel()
assert abs(trajectory.noise_scale() - 3.0) < 1e-9
print("budget partitioning: all assertions passed")
_validate()
Assertion 5 is the whole guide in one line. Allowing a device to contribute to three cells does not cost three releases — it costs three times the noise on every cell of one release. Which of those is cheaper depends on the workload, and it is a decision to make deliberately rather than to inherit from whichever binning code was written first.
Incident Response and Edge Cases
- The same grid is published at two resolutions. Sequential composition, two debits. If both are genuinely needed, derive the coarse layer from the released fine layer by summing — that is post-processing and costs nothing, at the price of the coarse layer inheriting the fine layer’s noise.
- A user appears in many cells because the release covers a long window. Either shorten the window, or raise the clipping bound and accept the proportional noise. A third option that looks attractive and is not: sampling one cell per user at random, which biases the counts toward users with fewer cells unless the sampling is weighted and the weighting is accounted for.
- The partition is disjoint in code but not in data. Cells derived from overlapping buffers,
administrative boundaries that share edges, or H3 rings around query points routinely overlap. The
parallel_costcheck exists because “disjoint” is an assertion about the actual id sets, not about the intent of whoever wrote the query. - A budget dashboard shows per-cell spend. Almost always a sign the ledger key is wrong. The ledger key must include the privacy unit; a cell is not one, and per-cell spend is not a quantity that means anything on its own.
- Two teams release over the same population with separate ledgers. Their budgets compose, and neither ledger knows it. Merge the accounting at the privacy-unit level, or partition the population between the teams so their releases really are parallel.
Frequently Asked Questions
Do disjoint cells really each get the full epsilon?
Yes, provided one user's data influences exactly one cell. That is what parallel composition means: neighbouring datasets differ in one cell, so the worst-case leakage is one cell's noise, not the sum across the grid. The precondition — clipped per-user contributions — is what makes it sound, and it has to be enforced rather than assumed.
What if a device genuinely visits ten cells in the release window?
Then sensitivity is ten unless you clip. The two honest options are to clip contributions to a bound and accept the resulting sampling bias, or to keep all contributions and multiply the noise by the bound. Claiming sensitivity one while binning every ping is the failure mode this page exists to prevent.
Is per-cell budget ever the right model?
Only when a cell is genuinely the privacy unit — for example when the protected entity is a business location rather than a person. For human mobility the unit is a person or device, and "per-cell budget" is shorthand for per-user budget composed in parallel across cells. Keeping the language precise prevents the accounting error.
How does this interact with RDP accounting?
Parallel composition happens first, within a release: it determines the single sensitivity and epsilon that release costs. RDP then composes those per-release costs across time. The two operate at different levels and neither substitutes for the other.
Related
- Privacy Budget Management — the ledger and its keys.
- Composition Accounting with RDP for Spatial Queries — composing these debits across time.
- Differentially Private Spatial Aggregation — the release whose partition structure is at issue.
- W-Event Privacy for Streaming Trajectories — the streaming analogue of this partitioning question.
Up one level: Privacy Budget Management · Section: Core Fundamentals & Architecture for Spatial Privacy.