Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ argument to :class:`~dpsynth.TabularConfig`.
NumericalAttribute
OpenSetCategoricalAttribute
FreeFormTextAttribute
Schema
Workload

----

Expand Down
2 changes: 2 additions & 0 deletions dpsynth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from dpsynth.domain import NumericalAttribute
from dpsynth.domain import OpenSetCategoricalAttribute
from dpsynth.domain import Schema
from dpsynth.domain import Workload
from dpsynth.reporting import PrivacyReport
from dpsynth.serialize import from_yaml
from dpsynth.serialize import to_yaml
Expand Down Expand Up @@ -70,6 +71,7 @@
'TabularConfig',
'TabularMechanism',
'TabularSynthesizer',
'Workload',
'api',
'calibrate',
'checkpoint',
Expand Down
19 changes: 9 additions & 10 deletions dpsynth/_calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def calibrate(
*,
epsilon: float,
delta: float,
workload: Any = None,
delta_split: float = 0.5,
poisson_sampling_prob: float = 1.0,
group_size: int = 1,
Expand All @@ -89,6 +90,8 @@ def calibrate(
domain: Optional domain specification, forwarded to ``config.configure()``.
epsilon: Target epsilon for (epsilon, delta)-DP.
delta: Target delta for (epsilon, delta)-DP.
workload: Optional workload specification, forwarded to
``config.configure()``.
delta_split: Fraction of ``delta`` passed to ``config.configure()`` for
sub-mechanisms that consume approximate DP budget directly (e.g. open-set
partition selection). Defaults to 0.5.
Expand Down Expand Up @@ -123,12 +126,12 @@ def calibrate(
if group_size < 1:
raise ValueError(f'group_size < 1: {group_size}.')

configure_kwargs: dict[str, Any] = {'delta': delta * delta_split}
if workload is not None:
configure_kwargs['workload'] = workload

def make_event_fn(rho: float) -> dp_accounting.DpEvent:
base = config.configure(
domain,
budget=rho,
delta=delta * delta_split,
).dp_event
base = config.configure(domain, budget=rho, **configure_kwargs).dp_event
base = with_group_size(base, group_size)
sampled = dp_accounting.PoissonSampledDpEvent(poisson_sampling_prob, base)
return base if poisson_sampling_prob == 1.0 else sampled
Expand Down Expand Up @@ -169,8 +172,4 @@ def make_event_fn(rho: float) -> dp_accounting.DpEvent:
)

optimal_rho = max(rhos.values())
return config.configure(
domain,
budget=optimal_rho,
delta=delta * delta_split,
)
return config.configure(domain, budget=optimal_rho, **configure_kwargs)
20 changes: 10 additions & 10 deletions dpsynth/adapters/beam.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,8 @@ def _run_two_pass(
column_measurements, synth.schema
).mbi_domain

assert hasattr(synth.config.discrete_mechanism, 'supporting_cliques')
workload = synth.config.discrete_mechanism.supporting_cliques(mbi_domain)
assert hasattr(synth.base_mechanism, 'supporting_cliques')
workload = synth.base_mechanism.supporting_cliques(mbi_domain)

# Pass 2: compute the marginal workload.
with beam.Pipeline(**pipeline_kwargs) as p:
Expand Down Expand Up @@ -558,20 +558,20 @@ class BeamTabularConfig(api.MechanismConfig):
temp_location: str | None = None
pipeline_options: beam.options.pipeline_options.PipelineOptions | None = None

def __post_init__(self):
if not hasattr(self.synthesizer.discrete_mechanism, 'supporting_cliques'):
raise ValueError(
'self.synthesizer.discrete_mechanism must have a supporting_cliques'
' method.'
)

def configure(self, schema=None, *, budget, delta=0) -> BeamTabularMechanism:
def configure(
self, schema=None, *, budget, delta=0, workload=None
) -> BeamTabularMechanism:
"""Returns a copy whose synthesizer is configured with the given budget."""
synthesizer = self.synthesizer.configure(
schema,
budget=budget,
delta=delta,
workload=workload,
)
if not hasattr(synthesizer.base_mechanism, 'supporting_cliques'):
raise ValueError(
'synthesizer.base_mechanism must have a supporting_cliques method.'
)
return BeamTabularMechanism(
synthesizer=synthesizer,
temp_location=self.temp_location,
Expand Down
9 changes: 8 additions & 1 deletion dpsynth/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ def get_subclass(cls, name: str) -> type[MechanismConfig] | None:
return cls._registry.get(name)

@abc.abstractmethod
def configure(self, domain=None, *, budget, delta=0) -> CalibratedMechanism:
def configure(
self, domain=None, *, budget, delta=0, workload=None
) -> CalibratedMechanism:
"""Returns a calibrated mechanism for the given dummy budget.

Converts the budget into the mechanism's natural privacy parameter
Expand All @@ -166,6 +168,9 @@ def configure(self, domain=None, *, budget, delta=0) -> CalibratedMechanism:
delta: Approximate DP delta consumed by the mechanism itself (e.g., for
thresholding). Defaults to 0 (pure zCDP). Mechanisms that need delta
should raise if it is 0.
workload: Optional workload specification (e.g., a ``dpsynth.Workload``,
sequence of attribute tuples, or mapping from attribute tuples to
weights). Mechanisms that do not use a workload ignore this argument.

Returns:
A calibrated, runnable mechanism.
Expand All @@ -178,6 +183,7 @@ def calibrate(
*,
epsilon: float,
delta: float,
workload: Any = None,
delta_split: float = 0.5,
poisson_sampling_prob: float = 1.0,
group_size: int = 1,
Expand All @@ -197,6 +203,7 @@ def calibrate(
domain,
epsilon=epsilon,
delta=delta,
workload=workload,
delta_split=delta_split,
poisson_sampling_prob=poisson_sampling_prob,
group_size=group_size,
Expand Down
25 changes: 18 additions & 7 deletions dpsynth/data_generation_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,8 @@ def from_summary(
]
logging.info('[DPSynth]: Compressed discrete domain:\n%s', data.domain)

cfg = self.config.discrete_mechanism
if hasattr(cfg, 'supporting_cliques'):
cliques = cfg.supporting_cliques(data.domain)
if hasattr(self.base_mechanism, 'supporting_cliques'):
cliques = self.base_mechanism.supporting_cliques(data.domain)
data = _checkpoint.get_or_compute(
'precomputed_marginals',
dm_common.precompute_marginals,
Expand Down Expand Up @@ -443,6 +442,7 @@ def configure(
*,
budget: float,
delta: float = 0.0,
workload: domain.WorkloadInput | None = None,
) -> TabularMechanism:
"""Returns a calibrated mechanism configured with the given privacy budget.

Expand All @@ -460,12 +460,17 @@ def configure(
delta: Approximate DP delta allocated to partition selection for open-set
columns (split evenly across open-set columns). Must be positive when
open-set categorical attributes are present.
workload: Optional workload specification (e.g. a ``dpsynth.Workload``,
sequence of attribute tuples, or mapping from attribute tuples to
weights) validated against ``schema`` and forwarded to the discrete
mechanism.

Returns:
A calibrated TabularMechanism ready to be run on tabular data.

Raises:
ValueError: If open-set attributes exist but delta is 0.
ValueError: If open-set attributes exist but delta is 0, or if the
workload contains attributes not present in ``schema``.
"""
if schema is not None and isinstance(schema, domain.Schema):
pass
Expand All @@ -483,6 +488,7 @@ def configure(
' construction time.'
)

resolved_workload = domain.Workload.from_any(workload, schema)
per_col_deltas = self._compute_per_col_deltas(schema, delta)

inits = create_initializers(
Expand All @@ -503,9 +509,14 @@ def configure(
for col, init in inits.items()
}

calibrated_discrete = self.discrete_mechanism.configure(
budget=discrete_rho,
)
if resolved_workload is not None:
calibrated_discrete = self.discrete_mechanism.configure(
schema, budget=discrete_rho, workload=resolved_workload
)
else:
calibrated_discrete = self.discrete_mechanism.configure(
schema, budget=discrete_rho
)

return TabularMechanism(
config=self,
Expand Down
24 changes: 12 additions & 12 deletions dpsynth/discrete_mechanisms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,17 @@ does not model relationships between columns.

**Public API:** `IndependentConfig`

## `direct.py` — Caller-Defined Workload
## `direct.py` - Caller-Defined Workload

Implements `DirectConfig`, which measures the cliques supplied through
`prespecified_marginal_queries`. It performs no data-dependent selection and
does not create its own one-way measurements, so the full budget is available
for the specified workload. Initial measurements supplied by another layer are
included when fitting the final model.
Implements `DirectConfig`, which measures the cliques supplied through the
`workload` parameter of `configure()` / `dpsynth.calibrate()`. It performs no
data-dependent selection and does not create its own one-way measurements, so
the full budget is available for the specified workload. Initial measurements
supplied by another layer are included when fitting the final model.

**Public API:** `DirectConfig(prespecified_marginal_queries=...)`
**Public API:** `DirectConfig`

## `mst.py` — Private Pairwise Spanning-Tree Selection
## `mst.py` - Private Pairwise Spanning-Tree Selection

Implements `MSTConfig`, the default general-purpose mechanism for preserving
pairwise relationships. It begins with one-way marginals, privately selects
Expand All @@ -75,27 +75,27 @@ selection and measurement; `_select()` calls the spanning-tree selection logic.
`dp_maximum_spanning_tree()` and `_select_two_way_marginal_queries()` implement
the private pairwise-selection step.

## `aim.py` — Adaptive Iterative Selection
## `aim.py` - Adaptive Iterative Selection

Implements `AIMConfig`, an adaptive workload-based mechanism. Instead of
selecting cliques once, it repeatedly finds a marginal that the current model
approximates poorly, measures it, and updates the model.

**Public API:** `AIMConfig(workload=...)`
**Public API:** `AIMConfig`

**Internal behavior:** `_one_way_cliques()` limits initial measurements to the
workload; `_allocate_budget()` reserves rho for the adaptive loop; `_run()`
replaces the standard base execution path. Helper functions filter valid
candidates and privately choose the worst-approximated marginal.

## `swift.py` — Workload and Clique-Tree Mechanism
## `swift.py` - Workload and Clique-Tree Mechanism

Implements `SWIFT`, a workload-informed mechanism that selects
marginals while controlling clique-tree complexity. It uses a custom
junction-tree-aware estimation and sampling path rather than the standard
one-pass implementation in `base.py`.

**Public API:** `SWIFTConfig(workload=...)`
**Public API:** `SWIFTConfig`

**Internal behavior:** `_allocate_budget()` splits rho between selection and
measurement; `_run()` compiles the workload, selects supported cliques, builds a
Expand Down
32 changes: 20 additions & 12 deletions dpsynth/discrete_mechanisms/aim.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@

"""Implementation of the Adaptive+Iterative Mechanism (AIM)."""

from collections.abc import Iterable, Mapping
from collections.abc import Mapping
from collections.abc import Sequence
import dataclasses
from absl import logging
import dp_accounting
from dpsynth import api
from dpsynth import domain as domain_lib
from dpsynth.discrete_mechanisms import accounting
from dpsynth.discrete_mechanisms import common
from dpsynth.local_mode import primitives
Expand Down Expand Up @@ -104,8 +105,6 @@ class AIMConfig(api.MechanismConfig):
max_model_size >= 80.

Attributes:
workload: A collection of marginal queries (and weights) the synthetic data
should be tailored to.
max_rounds: The maximum number of rounds to run the mechanism.
max_model_size: The maximum size of the graphical model in megabytes.
Controls the utility/runtime trade-off.
Expand All @@ -115,7 +114,6 @@ class AIMConfig(api.MechanismConfig):
selecting two-way marginal queries.
"""

workload: Mapping[mbi.Clique, float] | Iterable[mbi.Clique] | None = None
max_rounds: int | None = None
max_model_size: int = 80
max_marginal_size: float = 1e6
Expand All @@ -124,16 +122,19 @@ class AIMConfig(api.MechanismConfig):
pgm_iters: int = 1000
marginal_oracle: mbi.MarginalOracle | None = None

def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]:
"""Returns the workload cliques filtered by max_marginal_size."""
return common.supporting_cliques(
domain, self.workload, self.max_marginal_size
)

def configure(self, _=None, *, budget, delta=0):
def configure(
self,
domain=None,
*,
budget: float,
delta: float = 0.0,
workload: domain_lib.WorkloadInput | None = None,
) -> 'AIM':
del delta
return AIM(
config=self,
zcdp_rho=budget,
workload=domain_lib.Workload.from_any(workload, domain),
)


Expand All @@ -143,6 +144,13 @@ class AIM(api.CalibratedMechanism):

config: AIMConfig
zcdp_rho: float
workload: domain_lib.Workload | None = None

def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]:
"""Returns the workload cliques filtered by max_marginal_size."""
return common.supporting_cliques(
domain, self.workload, self.config.max_marginal_size
)

@property
def dp_event(self) -> dp_accounting.DpEvent:
Expand Down Expand Up @@ -171,7 +179,7 @@ def __call__(
# Compile workload into candidate measurements. #
#########################################################################
candidates = common.compiled_workload(
data.domain, self.config.workload, self.config.max_marginal_size
data.domain, self.workload, self.config.max_marginal_size
)

estimator = mbi.estimation.MirrorDescent(self.config.marginal_oracle)
Expand Down
Loading
Loading