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 ε=1\varepsilon = 1 and the same heatmap at ε=4,000\varepsilon = 4{,}000. 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 ε\varepsilon 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 ε\varepsilon once, total
Two resolutions of the same data sequential 2ε2\varepsilon
Hourly releases over the same grid sequential (RDP-composed) grows with cadence
One user in cc cells, unclipped sequential within the release cεc\,\varepsilon — usually unintended
Quadtree levels, parent and child sequential one debit per level
The cost of mis-applying composition across a disjoint grid Two lines of the epsilon charged for a single release over a disjoint grid, against the cell count. Correct parallel composition charges a flat one regardless of grid size. Dividing the budget across cells — a common misreading — charges in proportion to the cell count, reaching 10,000 for a fine grid, which makes an affordable release look impossible. The cost of mis-applying composition across a disjoint grid total ε charged for one release over n disjoint cells 10 100 1000 10000 0 5000 10000 cells in the partition ε charged for the release parallel composition (correct) ε divided per cell (over-charge)
A user's data touches exactly one cell, so every cell may spend the full ε. Dividing the budget across a 10,000-cell grid multiplies the noise 10,000-fold for no privacy gain.

Reference Implementation

python
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

python
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()
The contribution cap is a sensitivity, not a convenience A grouped bar chart over five contribution caps. The Laplace noise scale grows linearly with the cap, because the cap is the sensitivity. The share of a device's real movement retained grows quickly at first and then saturates, so a cap around five captures most of the signal at a fifth of the noise a cap of thirty would cost. The contribution cap is a sensitivity, not a convenience one device's cells per release, ε = 1 0 50 100 scale / % 1 22 cap = 1 2 39 cap = 2 5 71 cap = 5 10 92 cap = 10 30 100 cap = 30 Laplace scale per cell % of movement retained
Allowing a device into 30 cells does not cost 30 releases — it costs 30× the noise on every cell of one release. The knee around 5 is where most deployments settle.

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_cost check 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.
Every overlapping layer is another sequential release A grouped bar chart over four publication plans. Publishing one resolution charges one epsilon. Adding a second overlapping resolution doubles the charge, and a five-level quadtree charges five times, because the layers cover the same users and therefore compose sequentially. Under a fixed cap, each added layer proportionally reduces the budget available to every layer. Every overlapping layer is another sequential release publishing the same data at multiple resolutions 0 2 4 ε 1.00 1.00 res 7 only 2.00 0.50 res 7 + 9 3.00 0.33 res 7 + 8 + 9 5.00 0.20 quadtree, 5 levels total ε charged ε per layer at a 1.0 cap
If both a coarse and a fine layer are needed, derive the coarse one by summing the released fine layer — that is post-processing, and it costs nothing.

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.

Up one level: Privacy Budget Management · Section: Core Fundamentals & Architecture for Spatial Privacy.