diff --git a/dpsynth/discrete_mechanisms/__init__.py b/dpsynth/discrete_mechanisms/__init__.py index 41cf921..70e6d06 100644 --- a/dpsynth/discrete_mechanisms/__init__.py +++ b/dpsynth/discrete_mechanisms/__init__.py @@ -16,9 +16,10 @@ # pylint: disable=g-importing-member -from dpsynth.api import DPMechanism as DiscreteMechanism from dpsynth.discrete_mechanisms.aim import AIMMechanism from dpsynth.discrete_mechanisms.aim_gdp import AIMGDPMechanism +from dpsynth.discrete_mechanisms.base import DiscreteMechanism +from dpsynth.discrete_mechanisms.base import GaussianMarginalMeasurement from dpsynth.discrete_mechanisms.common import DiscreteMechanismResult from dpsynth.discrete_mechanisms.common import MechanismDiagnostics from dpsynth.discrete_mechanisms.direct import DirectMechanism diff --git a/dpsynth/discrete_mechanisms/aim.py b/dpsynth/discrete_mechanisms/aim.py index 845af33..389049b 100644 --- a/dpsynth/discrete_mechanisms/aim.py +++ b/dpsynth/discrete_mechanisms/aim.py @@ -19,8 +19,8 @@ from absl import logging import dp_accounting -from dpsynth import api from dpsynth.discrete_mechanisms import accounting +from dpsynth.discrete_mechanisms import base from dpsynth.discrete_mechanisms import common import jax.numpy as jnp import mbi @@ -86,7 +86,7 @@ def _worst_approximated( @dataclasses.dataclass -class AIMMechanism(api.DPMechanism): +class AIMMechanism(base.DiscreteMechanism): """Configuration for the AIM mechanism. Details are described in the paper: @@ -106,32 +106,21 @@ class AIMMechanism(api.DPMechanism): 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. - pgm_iters: The number of iterations for the mirror descent algorithm. max_model_size: The maximum size of the graphical model in megabytes. Controls the utility/runtime trade-off. max_marginal_size: The maximum size of a marginal query to consider. - marginal_oracle: The marginal oracle to use for the mirror descent - algorithm. anneal_factor: The factor by which to anneal the privacy. - one_way_budget_fraction: The fraction of the total budget to use for one-way - marginal queries. - compress_columns: Controls domain compression. True compresses all columns, - False disables compression, or a list of column names to compress. select_budget_fraction: The fraction of the total budget to use for selecting two-way marginal queries. """ workload: Mapping[mbi.Clique, float] | Iterable[mbi.Clique] | None = None max_rounds: int | None = None - pgm_iters: int = 1000 max_model_size: int = 80 max_marginal_size: float = 1e6 - marginal_oracle: mbi.MarginalOracle | None = None anneal_factor: float = 4.0 - one_way_budget_fraction: float = 0.1 - compress_columns: bool | Sequence[str] = False select_budget_fraction: float = 0.1 - zcdp_rho: float | None = None + _loop_rho: float | None = dataclasses.field(default=None, repr=False) def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: """Returns the workload cliques filtered by max_marginal_size.""" @@ -139,69 +128,44 @@ def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: domain, self.workload, self.max_marginal_size ) - def configure(self, *, zcdp_rho: float, delta: float = 0.0) -> 'AIMMechanism': - """Returns a new instance calibrated to the given zCDP budget.""" - return dataclasses.replace(self, zcdp_rho=zcdp_rho) - - @property - def dp_event(self) -> dp_accounting.DpEvent: - """Returns the DP event for the AIM mechanism.""" - if self.zcdp_rho is None: - raise ValueError('Must call calibrate() before using the mechanism.') - return dp_accounting.ZCDpEvent(self.zcdp_rho) + def _one_way_cliques(self, data): + """Returns only the workload-specified one-way cliques.""" + return common.one_way_cliques(self.workload, data.domain) - def __call__( + def configure( self, - rng: np.random.Generator, - data: mbi.Dataset | mbi.CliqueVector, *, + zcdp_rho: float, + delta: float = 0.0, initial_measurements: list[mbi.LinearMeasurement] | None = None, - constraints: tuple[mbi.Constraint, ...] = (), - ) -> common.DiscreteMechanismResult: - """Runs the AIM mechanism on the given data. - - Args: - rng: A numpy random number generator. - data: The input data. Must be an mbi.Dataset for domain compression; - mbi.CliqueVector is supported but compression will be skipped. - initial_measurements: Optional initial measurements to start from. - constraints: Structural constraints for the estimation. - - Returns: - A DiscreteMechanismResult containing the estimated data distribution. - """ - if self.zcdp_rho is None: - raise ValueError('Must call calibrate() before using the mechanism.') + **kwargs, + ) -> 'AIMMechanism': + """Returns a new instance calibrated to the given zCDP budget.""" + result = super().configure( + zcdp_rho=zcdp_rho, + delta=delta, + initial_measurements=initial_measurements, + ) + return dataclasses.replace(result, _loop_rho=result.remaining_rho) + @property + def dp_event(self) -> dp_accounting.DpEvent: + """Returns the DP event for the AIM mechanism.""" + self._check_calibration() + events = [] + if self.one_way_mechanism is not None: + events.append(self.one_way_mechanism.dp_event) + events.append(dp_accounting.ZCDpEvent(self._loop_rho)) + return dp_accounting.ComposedDpEvent(events) + + def _run(self, rng, data, measurements, constraints, phase_times, mappings): + """Adaptively selects, measures, and estimates in an annealed loop.""" logging.info('[AIM]: Starting Mechanism.') - phase_times = {} - zcdp_rho = self.zcdp_rho terminate = False - rho_remaining = zcdp_rho + rho_remaining = self._loop_rho max_rounds = self.max_rounds or 16 * len(data.domain) - rho_per_round = zcdp_rho / max_rounds - - if initial_measurements is None: - rho_remaining -= self.one_way_budget_fraction * zcdp_rho - marginal_queries = common.one_way_cliques(self.workload, data.domain) - measurements = common.measure_marginals_with_noise( - rng, - data, - marginal_queries=marginal_queries, # pyrefly: ignore[bad-argument-type] - gdp_sigma=accounting.zcdp_gaussian_sigma( - zcdp_rho * self.one_way_budget_fraction - ), - ) - else: - measurements = list(initial_measurements) - - mappings = common.compression_mappings( - measurements, self.compress_columns, constraints - ) - if mappings: - data = data.compress(mappings) - measurements = [m.compress(mappings, data.domain) for m in measurements] + rho_per_round = self._loop_rho / max_rounds ######################################################################### # Compile workload into candidate measurements, and precompute answers. # @@ -305,16 +269,7 @@ def __call__( sigma = accounting.zcdp_gaussian_sigma((1 - fraction) * rho_per_round) logging.info('[AIM] Reducing sigma: %.1f', sigma) - diagnostics = common.clique_stats(model) - diagnostics.phase_times = phase_times - diagnostics.num_rounds = t synthetic_data = model.synthetic_data() if mappings: synthetic_data = synthetic_data.decompress(mappings) - return common.DiscreteMechanismResult( - model=model, - synthetic_data=synthetic_data, - measurements=measurements, - diagnostics=diagnostics, - mappings=mappings, - ) + return model, synthetic_data, measurements diff --git a/dpsynth/discrete_mechanisms/aim_gdp.py b/dpsynth/discrete_mechanisms/aim_gdp.py index 13216eb..4eedceb 100644 --- a/dpsynth/discrete_mechanisms/aim_gdp.py +++ b/dpsynth/discrete_mechanisms/aim_gdp.py @@ -20,8 +20,8 @@ from absl import logging import dp_accounting -from dpsynth import api from dpsynth.discrete_mechanisms import accounting +from dpsynth.discrete_mechanisms import base from dpsynth.discrete_mechanisms import common import jax.numpy as jnp import mbi @@ -124,7 +124,7 @@ def _worst_approximated( @dataclasses.dataclass -class AIMGDPMechanism(api.DPMechanism): +class AIMGDPMechanism(base.DiscreteMechanism): """Configuration for the AIM mechanism with Gaussian DP. Details are described in the paper: @@ -146,7 +146,6 @@ class AIMGDPMechanism(api.DPMechanism): each marginal query. A default value of 1.0 will be assigned if the workload is provided as a list. max_rounds: The maximum number of rounds to run the mechanism. - pgm_iters: The number of iterations for the mirror descent algorithm. max_model_size: The maximum size of the graphical model in megabytes. Controls the utility/runtime trade-off. max_marginal_size: The maximum size of a marginal query to consider. @@ -154,31 +153,19 @@ class AIMGDPMechanism(api.DPMechanism): round. This can improve privacy budget utilization as well as speed up the "Select" step, which in some settings is the main bottelneck of the mechanism. - marginal_oracle: The marginal oracle to use for the mirror descent - algorithm. anneal_factor: The factor by which to anneal the privacy budget. - one_way_budget_fraction: The fraction of the privacy budget to use for - one-way marginals. - compress_columns: Controls domain compression. True compresses all columns, - False disables compression, or a list of column names to compress. select_budget_fraction: The fraction of the privacy budget to use for the "Select" step. - gdp_sigma: The GDP sigma of the end-to-end mechanism. Privacy budget is - split across rounds internally. """ workload: Mapping[mbi.Clique, float] | Iterable[mbi.Clique] | None = None max_rounds: int | None = None - pgm_iters: int = 1000 max_model_size: int = 80 max_marginal_size: float = 1e6 max_candidates_per_round: int = 16 - marginal_oracle: mbi.MarginalOracle | None = None anneal_factor: float = 4.0 - one_way_budget_fraction: float = 0.1 - compress_columns: bool | Sequence[str] = False select_budget_fraction: float = 0.1 - gdp_sigma: float | None = None + _loop_rho: float | None = dataclasses.field(default=None, repr=False) def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: """Returns the workload cliques filtered by max_marginal_size.""" @@ -186,77 +173,49 @@ def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: domain, self.workload, self.max_marginal_size ) + def _one_way_cliques(self, data): + """Returns only the workload-specified one-way cliques.""" + return common.one_way_cliques(self.workload, data.domain) + def configure( - self, *, zcdp_rho: float, delta: float = 0.0 + self, + *, + zcdp_rho: float, + delta: float = 0.0, + initial_measurements: list[mbi.LinearMeasurement] | None = None, + **kwargs, ) -> 'AIMGDPMechanism': """Returns a new instance calibrated to the given zCDP budget.""" - return dataclasses.replace( - self, gdp_sigma=accounting.zcdp_gaussian_sigma(zcdp_rho) + result = super().configure( + zcdp_rho=zcdp_rho, + delta=delta, + initial_measurements=initial_measurements, ) + return dataclasses.replace(result, _loop_rho=result.remaining_rho) @property def dp_event(self) -> dp_accounting.DpEvent: """Returns the DP event for the AIM-GDP mechanism.""" - if self.gdp_sigma is None: - raise ValueError('Must call calibrate() before using the mechanism.') - return dp_accounting.GaussianDpEvent(noise_multiplier=self.gdp_sigma) - - def __call__( - self, - rng: np.random.Generator, - data: mbi.Dataset | mbi.CliqueVector, - *, - initial_measurements: list[mbi.LinearMeasurement] | None = None, - constraints: tuple[mbi.Constraint, ...] = (), - ) -> common.DiscreteMechanismResult: - """Runs the AIM-GDP mechanism on the given data. - - Args: - rng: A numpy random number generator. - data: The input data. Must be an mbi.Dataset for domain compression; - mbi.CliqueVector is supported but compression will be skipped. - initial_measurements: Optional initial measurements to start from. - constraints: Structural constraints for the estimation. - - Returns: - A DiscreteMechanismResult containing the estimated data distribution. - """ - if self.gdp_sigma is None: - raise ValueError('Must call calibrate() before using the mechanism.') - + self._check_calibration() + events = [] + if self.one_way_mechanism is not None: + events.append(self.one_way_mechanism.dp_event) + # The loop's privacy cost in zCDP terms. + events.append(dp_accounting.ZCDpEvent(self._loop_rho)) + return dp_accounting.ComposedDpEvent(events) + + def _run(self, rng, data, measurements, constraints, phase_times, mappings): + """Adaptively selects, measures, and estimates in an annealed loop (GDP).""" logging.info('[AIM] Starting Mechanism.') - phase_times = {} - # Convert end-to-end GDP sigma to budget for internal allocation. - gdp_budget = 1.0 / self.gdp_sigma**2 + # Convert loop's zCDP budget to GDP budget for internal allocation. + gdp_budget = accounting.zcdp_to_gdp(self._loop_rho) terminate = False budget_remaining = gdp_budget max_rounds = self.max_rounds or 16 * len(data.domain) budget_per_round = budget_remaining / max_rounds - if initial_measurements is None: - one_way_budget = self.one_way_budget_fraction * gdp_budget - one_way_gdp_sigma = accounting.gdp_gaussian_sigma(one_way_budget) - budget_remaining -= one_way_budget - marginal_queries = common.one_way_cliques(self.workload, data.domain) - # measure_marginals_with_noise splits one_way_gdp_sigma across queries. - measurements = common.measure_marginals_with_noise( - rng, - data, - marginal_queries=marginal_queries, - gdp_sigma=one_way_gdp_sigma, - ) - else: - measurements = list(initial_measurements) - - mappings = common.compression_mappings( - measurements, self.compress_columns, constraints - ) - if mappings: - data = data.compress(mappings) - measurements = [m.compress(mappings, data.domain) for m in measurements] - ######################################################################### # Compile workload into candidate measurements, and precompute answers. # ######################################################################### @@ -376,16 +335,7 @@ def __call__( '[AIM] Increasing budget per round: %.5f', budget_per_round ) - diagnostics = common.clique_stats(model) - diagnostics.phase_times = phase_times - diagnostics.num_rounds = t synthetic_data = model.synthetic_data() if mappings: synthetic_data = synthetic_data.decompress(mappings) - return common.DiscreteMechanismResult( - model=model, - synthetic_data=synthetic_data, - measurements=measurements, - diagnostics=diagnostics, - mappings=mappings, - ) + return model, synthetic_data, measurements diff --git a/dpsynth/discrete_mechanisms/base.py b/dpsynth/discrete_mechanisms/base.py new file mode 100644 index 0000000..75f3c4d --- /dev/null +++ b/dpsynth/discrete_mechanisms/base.py @@ -0,0 +1,246 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Base classes for the select-measure-estimate paradigm. + +This module defines the ``DiscreteMechanism`` base class, which implements the +select-measure-estimate paradigm from `McKenna et al. (2021) +`_. Each step of the paradigm is a separate +method that subclasses can override independently, enabling code reuse across +mechanisms that differ primarily in the *select* step. +""" + +from __future__ import annotations + +from collections.abc import Sequence +import dataclasses + +from absl import logging +import dp_accounting +from dpsynth import api +from dpsynth.discrete_mechanisms import accounting +from dpsynth.discrete_mechanisms import common +import mbi +import mbi.callbacks +import mbi.estimation +import numpy as np + + +@dataclasses.dataclass +class GaussianMarginalMeasurement(api.DPMechanism): + """Measures marginal queries using the Gaussian mechanism.""" + + zcdp_rho: float | None = None + + def configure( + self, *, zcdp_rho: float, delta: float = 0.0, **kwargs + ) -> GaussianMarginalMeasurement: + """Returns a copy configured with the given zCDP budget.""" + return dataclasses.replace(self, zcdp_rho=zcdp_rho) + + @property + def dp_event(self) -> dp_accounting.DpEvent: + sigma = accounting.zcdp_gaussian_sigma(self.zcdp_rho) + return dp_accounting.GaussianDpEvent(noise_multiplier=sigma) + + def __call__( + self, + rng: np.random.Generator, + data: mbi.Projectable, + cliques: list[mbi.Clique], + weights: np.ndarray | None = None, + ) -> list[mbi.LinearMeasurement]: + """Measures the given cliques with calibrated Gaussian noise.""" + sigma = accounting.zcdp_gaussian_sigma(self.zcdp_rho) + return common.measure_marginals_with_noise( + rng, data, cliques, sigma, weights + ) + + +@dataclasses.dataclass +class DiscreteMechanism(api.DPMechanism): + """Base class for mechanisms following the select-measure-estimate paradigm. + + Subclasses implement ``_select`` to define which marginals to measure. + The base ``__call__`` orchestrates the full pipeline:: + + check_calibration → measure_one_way → compress → run → result + + where ``_run`` performs select → measure → estimate → generate. One-shot + mechanisms need only override ``_select``; adaptive mechanisms (e.g. AIM) or + those needing a custom estimator (e.g. SWIFT) override ``_run`` directly. + + Attributes: + marginal_oracle: Oracle for marginal inference in Private-PGM. + pgm_iters: Number of mirror descent iterations for estimation. + compress_columns: Domain compression config. True = all, list = specific. + one_way_budget_fraction: Fraction of zCDP budget for one-way marginals. + zcdp_rho: Total zCDP budget (set by configure). + one_way_mechanism: Sub-mechanism for one-way measurements. + measurement_mechanism: Sub-mechanism for selected marginal measurements. + """ + + marginal_oracle: mbi.MarginalOracle | None = None + pgm_iters: int = 5000 + compress_columns: bool | Sequence[str] = False + one_way_budget_fraction: float = 1 / 3 + zcdp_rho: float | None = None + one_way_mechanism: GaussianMarginalMeasurement | None = dataclasses.field( + default=None, repr=False + ) + measurement_mechanism: GaussianMarginalMeasurement | None = dataclasses.field( + default=None, repr=False + ) + + def configure( + self, + *, + zcdp_rho: float, + delta: float = 0.0, + initial_measurements: list[mbi.LinearMeasurement] | None = None, + **kwargs, + ) -> DiscreteMechanism: + """Configures the mechanism with a zCDP budget.""" + if initial_measurements is not None or self.one_way_budget_fraction <= 0: + one_way = None + else: + one_way = GaussianMarginalMeasurement().configure( + zcdp_rho=zcdp_rho * self.one_way_budget_fraction + ) + return dataclasses.replace( + self, zcdp_rho=zcdp_rho, one_way_mechanism=one_way + ) + + @property + def remaining_rho(self): + """zCDP budget remaining after one-way measurements.""" + if self.one_way_mechanism is not None: + return self.zcdp_rho - self.one_way_mechanism.zcdp_rho + return self.zcdp_rho + + def _check_calibration(self): + """Raises ValueError if the mechanism has not been configured.""" + if self.zcdp_rho is None: + raise ValueError('Must call calibrate() before using the mechanism.') + + def _one_way_cliques(self, data): + """Returns the one-way cliques to measure.""" + cliques = [(a,) for a in data.domain] + if hasattr(data, 'cliques'): + supported = common.downward_closure(data.cliques) # pytype: disable=attribute-error + cliques = [cl for cl in cliques if cl in supported] + return cliques + + def _measure_one_way( + self, rng, data, phase_times, *, initial_measurements=None + ): + """Measures one-way marginals or returns pre-measured ones.""" + if initial_measurements is not None: + return list(initial_measurements) + if self.one_way_mechanism is None: + return [] + with common.timed(phase_times, 'measurement'): + return self.one_way_mechanism(rng, data, self._one_way_cliques(data)) + + def _compress(self, data, measurements, constraints): + """Compresses the domain by merging rare values.""" + mappings = common.compression_mappings( + measurements, self.compress_columns, constraints + ) + if mappings and hasattr(data, 'compress'): + data = data.compress(mappings) # pytype: disable=attribute-error + measurements = [m.compress(mappings, data.domain) for m in measurements] + return data, measurements, mappings + + def _select(self, rng, data, measurements, phase_times): + """Selects which marginals to measure. Mechanism-specific.""" + raise NotImplementedError + + def __call__( + self, + rng: np.random.Generator, + data: mbi.Dataset | mbi.CliqueVector, + *, + initial_measurements: list[mbi.LinearMeasurement] | None = None, + constraints: tuple[mbi.Constraint, ...] = (), + ) -> common.DiscreteMechanismResult: + """Runs the select-measure-estimate pipeline.""" + self._check_calibration() + phase_times = {} + measurements = self._measure_one_way( + rng, data, phase_times, initial_measurements=initial_measurements + ) + data, measurements, mappings = self._compress( + data, measurements, constraints + ) + model, synthetic_data, measurements = self._run( + rng, data, measurements, constraints, phase_times, mappings + ) + diagnostics = common.clique_stats(model) # pytype: disable=wrong-arg-types + diagnostics.phase_times = phase_times + return common.DiscreteMechanismResult( + model=model, # pytype: disable=wrong-arg-types + synthetic_data=synthetic_data, + measurements=measurements, + diagnostics=diagnostics, + mappings=mappings, + ) + + def _run(self, rng, data, measurements, constraints, phase_times, mappings): + """Selects, measures, estimates, and generates in a single pass.""" + # Adaptive mechanisms (e.g. AIM) override this to interleave selection and + # measurement in a loop; SWIFT overrides it to use a junction-tree oracle. + selected = self._select(rng, data, measurements, phase_times) + all_cliques = [m.clique for m in measurements] + list(selected) + logging.info( + '[%s]:\n%s', + type(self).__name__, + mbi.summarize(data.domain, all_cliques), + ) + + # Kick off async AOT compilation of the estimator while we measure. + estimator = mbi.estimation.MirrorDescent(self.marginal_oracle) + futures = None + try: + futures = estimator.precompile( + data.domain, measurements, extra_cliques=list(selected) + ) + except Exception as e: # pylint: disable=broad-exception-caught + logging.warning('Precompile failed (non-fatal): %s', e) + + if selected: + with common.timed(phase_times, 'measurement'): + measurements = measurements + self.measurement_mechanism( + rng, data, selected + ) + + with common.timed(phase_times, 'estimation'): + if futures is not None: + try: + futures.result() + except Exception as e: # pylint: disable=broad-exception-caught + logging.warning('Precompile wait failed (non-fatal): %s', e) + model = estimator.estimate( + data.domain, + measurements, + iters=self.pgm_iters, + callback_fn=mbi.callbacks.default(measurements, data.domain), + constraints=constraints, + ) + assert isinstance(model, mbi.MarkovRandomField) + + synthetic_data = model.synthetic_data() + if mappings: + synthetic_data = synthetic_data.decompress(mappings) + return model, synthetic_data, measurements diff --git a/dpsynth/discrete_mechanisms/common.py b/dpsynth/discrete_mechanisms/common.py index b16109c..dbcf112 100644 --- a/dpsynth/discrete_mechanisms/common.py +++ b/dpsynth/discrete_mechanisms/common.py @@ -40,7 +40,6 @@ class MechanismDiagnostics: Attributes: phase_times: Wall-clock time in seconds for each named phase. - num_rounds: Number of select-measure rounds (iterative mechanisms only). num_cliques: Number of cliques in the fitted model. max_clique_size: Size of the largest clique. total_clique_size: Sum of all clique sizes. @@ -49,7 +48,6 @@ class MechanismDiagnostics: """ phase_times: dict[str, float] = dataclasses.field(default_factory=dict) - num_rounds: int = 0 num_cliques: int = 0 max_clique_size: int = 0 total_clique_size: int = 0 diff --git a/dpsynth/discrete_mechanisms/direct.py b/dpsynth/discrete_mechanisms/direct.py index 73ab5ef..36b4ac0 100644 --- a/dpsynth/discrete_mechanisms/direct.py +++ b/dpsynth/discrete_mechanisms/direct.py @@ -14,123 +14,67 @@ """Implementation of the direct mechanism.""" -from collections.abc import Sequence import dataclasses -from absl import logging import dp_accounting -from dpsynth import api -from dpsynth.discrete_mechanisms import accounting -from dpsynth.discrete_mechanisms import common +from dpsynth.discrete_mechanisms import base import mbi import numpy as np @dataclasses.dataclass -class DirectMechanism(api.DPMechanism): +class DirectMechanism(base.DiscreteMechanism): """Configuration for the direct mechanism. + The direct mechanism measures a prespecified set of marginal queries, + allocating the entire privacy budget to those measurements. It does not + measure its own one-way marginals, but can incorporate externally supplied + ``initial_measurements`` (e.g. compressed one-ways from an orchestration + layer) at no additional budget cost. + Attributes: prespecified_marginal_queries: A list of k-way marginals that a user has - specified, ONLY these will be used outside of the initial measurements. - compress_columns: Controls domain compression. True compresses all columns, - False disables compression, or a list of column names to compress. - pgm_iters: The number of iterations for the mirror descent algorithm. - marginal_oracle: The marginal oracle to use for the mirror descent - algorithm. - gdp_sigma: The GDP sigma of the end-to-end mechanism. Privacy budget is - split across the prespecified marginal queries internally. + specified. Only these will be measured with privacy budget. + one_way_budget_fraction: Fraction of the zCDP budget allocated to one-way + marginals. Overridden to 0.0 because this mechanism does not measure its + own one-way marginals. """ - prespecified_marginal_queries: list[tuple[str, ...]] - compress_columns: bool | Sequence[str] = False - pgm_iters: int = 5000 - marginal_oracle: mbi.MarginalOracle | None = None - gdp_sigma: float | None = None + prespecified_marginal_queries: list[tuple[str, ...]] = dataclasses.field( + default_factory=list + ) + one_way_budget_fraction: float = 0.0 def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: """Returns the prespecified marginal queries.""" - del domain # Unused; cliques are user-specified. return list(self.prespecified_marginal_queries) def configure( - self, *, zcdp_rho: float, delta: float = 0.0 + self, + *, + zcdp_rho: float, + delta: float = 0.0, + initial_measurements: list[mbi.LinearMeasurement] | None = None, + **kwargs, ) -> 'DirectMechanism': - """Returns a copy calibrated to the given zCDP budget.""" + """Allocates the full budget to measuring prespecified queries.""" + result = super().configure( + zcdp_rho=zcdp_rho, + delta=delta, + initial_measurements=initial_measurements, + ) return dataclasses.replace( - self, gdp_sigma=accounting.zcdp_gaussian_sigma(zcdp_rho) + result, + measurement_mechanism=base.GaussianMarginalMeasurement().configure( + zcdp_rho=zcdp_rho, + ), ) @property def dp_event(self) -> dp_accounting.DpEvent: """Returns the DP event for the direct mechanism.""" - if self.gdp_sigma is None: - raise ValueError('Must call calibrate() before using the mechanism.') - return dp_accounting.GaussianDpEvent(noise_multiplier=self.gdp_sigma) - - def __call__( - self, - rng: np.random.Generator, - data: mbi.Dataset | mbi.CliqueVector, - *, - initial_measurements: list[mbi.LinearMeasurement] | None = None, - constraints: tuple[mbi.Constraint, ...] = (), - ) -> common.DiscreteMechanismResult: - """Generate synthetic data using user specified two way marginals.""" - if self.gdp_sigma is None: - raise ValueError('Must call calibrate() before using the mechanism.') + assert self.measurement_mechanism is not None + return self.measurement_mechanism.dp_event - phase_times = {} - - # Domain compression: merge rare values to shrink the state space. - one_way = ( - [m for m in initial_measurements if len(m.clique) == 1] - if initial_measurements - else [] - ) - mappings = common.compression_mappings( - one_way, self.compress_columns, constraints - ) - if mappings: - data = data.compress(mappings) - if mappings and initial_measurements: - initial_measurements = [ - m.compress(mappings, data.domain) for m in initial_measurements - ] - - # measure_marginals_with_noise splits gdp_sigma across the queries - # internally via weight normalization. - with common.timed(phase_times, 'measurement'): - new_measurements = common.measure_marginals_with_noise( - rng, data, self.prespecified_marginal_queries, self.gdp_sigma - ) - if initial_measurements: - all_measurements = initial_measurements + new_measurements - else: - all_measurements = new_measurements - - logging.info( - '[Direct]:\n%s', - mbi.summarize(data.domain, [m.clique for m in all_measurements]), - ) - # fit a distribution to the noisy measurements - with common.timed(phase_times, 'estimation'): - estimator = mbi.estimation.MirrorDescent(self.marginal_oracle) - model = estimator.estimate( - data.domain, - all_measurements, - iters=self.pgm_iters, - constraints=constraints, - ) - diagnostics = common.clique_stats(model) - diagnostics.phase_times = phase_times - synthetic_data = model.synthetic_data() - if mappings: - synthetic_data = synthetic_data.decompress(mappings) - return common.DiscreteMechanismResult( - model=model, - synthetic_data=synthetic_data, - measurements=all_measurements, - diagnostics=diagnostics, - mappings=mappings, - ) + def _select(self, rng, data, measurements, phase_times): + return list(self.prespecified_marginal_queries) diff --git a/dpsynth/discrete_mechanisms/independent.py b/dpsynth/discrete_mechanisms/independent.py index 11403f4..ee0f6a8 100644 --- a/dpsynth/discrete_mechanisms/independent.py +++ b/dpsynth/discrete_mechanisms/independent.py @@ -14,115 +14,28 @@ """This mechanisms measures all 1-way marginals via the Gaussian mechanism.""" -from collections.abc import Sequence import dataclasses -from absl import logging import dp_accounting -from dpsynth import api -from dpsynth.discrete_mechanisms import accounting -from dpsynth.discrete_mechanisms import common +from dpsynth.discrete_mechanisms import base import mbi -import numpy as np @dataclasses.dataclass -class IndependentMechanism(api.DPMechanism): - """Configuration for the independent mechanism. +class IndependentMechanism(base.DiscreteMechanism): + """Measures only one-way marginals, allocating the entire budget to them.""" - Attributes: - pgm_iters: The number of iterations for the mirror descent algorithm. - compress_columns: Controls domain compression. True compresses all columns, - False disables compression, or a list of column names to compress. - marginal_oracle: The marginal oracle to use for the mirror descent - algorithm. - gdp_sigma: The GDP sigma of the end-to-end mechanism. Privacy budget is - split across the one-way marginals internally. - """ - - pgm_iters: int = 5000 - compress_columns: bool | Sequence[str] = False - marginal_oracle: mbi.MarginalOracle | None = None - gdp_sigma: float | None = None + one_way_budget_fraction: float = 1.0 def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: """Returns the one-way marginals this mechanism will measure.""" return [(a,) for a in domain.attributes] - def configure( - self, *, zcdp_rho: float, delta: float = 0.0 - ) -> 'IndependentMechanism': - """Returns a copy calibrated to the given zCDP budget.""" - return dataclasses.replace( - self, gdp_sigma=accounting.zcdp_gaussian_sigma(zcdp_rho) - ) - @property def dp_event(self) -> dp_accounting.DpEvent: """Returns the DP event for the independent mechanism.""" - if self.gdp_sigma is None: - raise ValueError('Must call calibrate() before using the mechanism.') - return dp_accounting.GaussianDpEvent(noise_multiplier=self.gdp_sigma) - - def __call__( - self, - rng: np.random.Generator, - data: mbi.Dataset | mbi.CliqueVector, - *, - initial_measurements: list[mbi.LinearMeasurement] | None = None, - constraints: tuple[mbi.Constraint, ...] = (), - ) -> common.DiscreteMechanismResult: - """Generate synthetic data via the independent mechanism.""" - if self.gdp_sigma is None: - raise ValueError('Must call calibrate() before using the mechanism.') - - # Split end-to-end gdp_sigma across the d one-way marginals: - # per-query sigma = gdp_sigma * sqrt(d). - attributes = len(data.domain) - per_query_sigma = self.gdp_sigma * attributes**0.5 - phase_times = {} - measurements = initial_measurements or [] - existing_cliques = {m.clique for m in measurements} - with common.timed(phase_times, 'measurement'): - for attr in data.domain: - clique = (attr,) - if clique in existing_cliques: - continue - marginal = data.project(clique).datavector() - noisy_marginal = ( - marginal + rng.normal(size=marginal.shape) * per_query_sigma - ) - measurements.append(mbi.LinearMeasurement(noisy_marginal, clique)) - - one_way_only = [m for m in measurements if len(m.clique) == 1] - mappings = common.compression_mappings( - one_way_only, self.compress_columns, constraints - ) - if mappings: - data = data.compress(mappings) - measurements = [m.compress(mappings, data.domain) for m in measurements] + assert self.one_way_mechanism is not None + return self.one_way_mechanism.dp_event - logging.info( - '[Independent]:\n%s', - mbi.summarize(data.domain, [m.clique for m in measurements]), - ) - with common.timed(phase_times, 'estimation'): - estimator = mbi.estimation.MirrorDescent(self.marginal_oracle) - model = estimator.estimate( - data.domain, - measurements, - iters=self.pgm_iters, - constraints=constraints, - ) - diagnostics = common.clique_stats(model) - diagnostics.phase_times = phase_times - synthetic_data = model.synthetic_data() - if mappings: - synthetic_data = synthetic_data.decompress(mappings) - return common.DiscreteMechanismResult( - model=model, - synthetic_data=synthetic_data, - measurements=measurements, - diagnostics=diagnostics, - mappings=mappings, - ) + def _select(self, rng, data, measurements, phase_times): + return [] diff --git a/dpsynth/discrete_mechanisms/mst.py b/dpsynth/discrete_mechanisms/mst.py index 08c7861..443ebc5 100644 --- a/dpsynth/discrete_mechanisms/mst.py +++ b/dpsynth/discrete_mechanisms/mst.py @@ -23,8 +23,7 @@ from absl import logging import dp_accounting -from dpsynth import api -from dpsynth.discrete_mechanisms import accounting +from dpsynth.discrete_mechanisms import base from dpsynth.discrete_mechanisms import common import mbi import networkx as nx @@ -150,7 +149,7 @@ def _select_two_way_marginal_queries( @dataclasses.dataclass -class MSTMechanism(api.DPMechanism): +class MSTMechanism(base.DiscreteMechanism): """Configuration for the maximum spanning tree mechanism. Details are described in the paper: @@ -158,26 +157,15 @@ class MSTMechanism(api.DPMechanism): private synthetic data](https://arxiv.org/abs/2108.04978) Attributes: - pgm_iters: The number of iterations for the mirror descent algorithm. + select_budget_fraction: The fraction of the remaining budget (after one-way + measurements) to use for selecting two-way marginal queries. maximum_marginal_size: The maximum size of a marginal query. - marginal_oracle: The marginal oracle to use for the mirror descent - algorithm. - one_way_budget_fraction: The fraction of the total budget to use for one-way - marginal queries. - compress_columns: Controls domain compression. True compresses all columns, - False disables compression, or a sequence of column names to compress - selectively. - select_budget_fraction: The fraction of the total budget to use for - selecting two-way marginal queries. + _select_rho: zCDP budget for the exponential mechanism (set by configure). """ - pgm_iters: int = 5000 - maximum_marginal_size: int = 10_000_000 - marginal_oracle: mbi.MarginalOracle | None = None - one_way_budget_fraction: float = 1 / 3 - compress_columns: bool | Sequence[str] = False select_budget_fraction: float = 1 / 3 - zcdp_rho: float | None = None + maximum_marginal_size: int = 10_000_000 + _select_rho: float | None = dataclasses.field(default=None, repr=False) def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: """Returns all pairwise marginals within the size limit.""" @@ -187,9 +175,29 @@ def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: self.maximum_marginal_size, ) - def configure(self, *, zcdp_rho: float, delta: float = 0.0) -> MSTMechanism: - """Returns a copy calibrated to the given zCDP budget.""" - return dataclasses.replace(self, zcdp_rho=zcdp_rho) + def configure( + self, + *, + zcdp_rho: float, + delta: float = 0.0, + initial_measurements: list[mbi.LinearMeasurement] | None = None, + **kwargs, + ) -> MSTMechanism: + result = super().configure( + zcdp_rho=zcdp_rho, + delta=delta, + initial_measurements=initial_measurements, + ) + remaining = result.remaining_rho + select_rho = remaining * self.select_budget_fraction + measure_rho = remaining - select_rho + return dataclasses.replace( + result, + _select_rho=select_rho, + measurement_mechanism=base.GaussianMarginalMeasurement().configure( + zcdp_rho=measure_rho, + ), + ) @property def dp_event(self) -> dp_accounting.DpEvent: @@ -199,107 +207,12 @@ def dp_event(self) -> dp_accounting.DpEvent: # exponential mechanisms and (d-1) Gaussian mechanisms. return dp_accounting.ZCDpEvent(self.zcdp_rho) - def __call__( - self, - rng: np.random.Generator, - data: mbi.Dataset | mbi.CliqueVector, - *, - initial_measurements: list[mbi.LinearMeasurement] | None = None, - constraints: tuple[mbi.Constraint, ...] = (), - ) -> common.DiscreteMechanismResult: - """Runs the MST mechanism on the given data. - - Args: - rng: A numpy random number generator. - data: The sensitive dataset. Must be an mbi.Dataset for domain - compression; mbi.CliqueVector is supported but compression will be - skipped. - initial_measurements: Optional pre-existing one-way measurements. - constraints: Structural constraints for the estimation. - - Returns: - A DiscreteMechanismResult containing the estimated data distribution. - - Raises: - ValueError: If calibrate() has not been called. - """ - if self.zcdp_rho is None: - raise ValueError('Must call calibrate() before using the mechanism.') - logging.info('[MST]: Starting MST mechanism.') - phase_times = {} - budget_remaining = self.zcdp_rho - - with common.timed(phase_times, 'measurement'): - if initial_measurements is None: - budget_remaining -= self.one_way_budget_fraction * self.zcdp_rho - one_way_rho = self.zcdp_rho * self.one_way_budget_fraction - one_way_sigma = accounting.zcdp_gaussian_sigma(one_way_rho) - one_way_measurements = common.measure_marginals_with_noise( - rng, - data, - marginal_queries=[(a,) for a in data.domain], - gdp_sigma=one_way_sigma, - ) - else: - one_way_measurements = initial_measurements - - mappings = common.compression_mappings( - one_way_measurements, self.compress_columns, constraints - ) - if mappings: - data = data.compress(mappings) - one_way_measurements = [ - m.compress(mappings, data.domain) for m in one_way_measurements - ] - - exponential_rho = self.select_budget_fraction * self.zcdp_rho - budget_remaining -= exponential_rho - # Select and measure 2-way marginals using rho/3 budget for each step. + def _select(self, rng, data, measurements, phase_times): with common.timed(phase_times, 'selection'): - two_way_marginal_queries = _select_two_way_marginal_queries( + return _select_two_way_marginal_queries( rng, data, - exponential_rho, - one_way_measurements, + self._select_rho, + measurements, maximum_marginal_size=self.maximum_marginal_size, ) - logging.info('[MST]: Selected two-way marginal queries.') - with common.timed(phase_times, 'measurement'): - gaussian_rho = budget_remaining - sigma = accounting.zcdp_gaussian_sigma(gaussian_rho) - two_way_measurements = common.measure_marginals_with_noise( - rng, data, two_way_marginal_queries, sigma - ) - logging.info('[MST]: Measured two-way marginals.') - all_measurements = one_way_measurements + two_way_measurements - # Fit a distribution to the noisy measurements using Private-PGM. - model_size = mbi.junction_tree.hypothetical_model_size( - data.domain, [m.clique for m in all_measurements] - ) - logging.info('[MST]: Model size: %d MB', model_size) - logging.info( - '[MST]:\n%s', - mbi.summarize(data.domain, [m.clique for m in all_measurements]), - ) - with common.timed(phase_times, 'estimation'): - estimator = mbi.estimation.MirrorDescent(self.marginal_oracle) - model = estimator.estimate( - data.domain, - all_measurements, - iters=self.pgm_iters, - callback_fn=mbi.callbacks.default(all_measurements, data.domain), - constraints=constraints, - ) - logging.info('[MST]: Fit distribution to the noisy measurements.') - diagnostics = common.clique_stats(model) - diagnostics.phase_times = phase_times - synthetic_data = model.synthetic_data() - if mappings: - synthetic_data = synthetic_data.decompress(mappings) - return common.DiscreteMechanismResult( - model=model, - synthetic_data=synthetic_data, - measurements=all_measurements, - diagnostics=diagnostics, - mappings=mappings, - ) diff --git a/dpsynth/discrete_mechanisms/swift.py b/dpsynth/discrete_mechanisms/swift.py index 62bcc8a..35dfff5 100644 --- a/dpsynth/discrete_mechanisms/swift.py +++ b/dpsynth/discrete_mechanisms/swift.py @@ -35,8 +35,8 @@ from absl import logging import dp_accounting -from dpsynth import api from dpsynth.discrete_mechanisms import accounting +from dpsynth.discrete_mechanisms import base from dpsynth.discrete_mechanisms import clique_tree from dpsynth.discrete_mechanisms import common from dpsynth.discrete_mechanisms import swift_utils @@ -46,7 +46,7 @@ @dataclasses.dataclass -class SWIFTMechanism(api.DPMechanism): +class SWIFTMechanism(base.DiscreteMechanism): """Configuration for the SWIFT mechanism. Attributes: @@ -56,27 +56,21 @@ class SWIFTMechanism(api.DPMechanism): the junction tree. max_marginal_size: The maximum size (domain product) of any marginal considered in the workload. - marginal_oracle: An optional oracle for marginal computations. - pgm_iters: Number of iterations for the PGM estimation. select_budget_frac: Fraction of the total budget used for selecting which marginals to measure. - one_way_budget_frac: Fraction of the total budget used for measuring one-way - marginals initially. - compress_columns: Controls domain compression. True compresses all columns, - False disables compression, or a list of column names to compress. - gdp_sigma: The GDP sigma of the end-to-end mechanism. Privacy budget is - split across measurement steps internally. + one_way_budget_frac: Alias for one_way_budget_fraction. Kept for backward + compatibility. """ workload: Mapping[mbi.Clique, float] | Iterable[mbi.Clique] | None = None max_clique_size: float = 1e9 max_marginal_size: float = 1e7 - marginal_oracle: mbi.MarginalOracle | None = None pgm_iters: int = 25_000 select_budget_frac: float = 0.1 - one_way_budget_frac: float = 0.1 - compress_columns: bool | Sequence[str] = False - gdp_sigma: float | None = None + one_way_budget_fraction: float = 0.1 + + # Internal state set by configure. + _select_rho: float | None = dataclasses.field(default=None, repr=False) def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: """Returns the workload cliques filtered by max_marginal_size.""" @@ -84,72 +78,48 @@ def supporting_cliques(self, domain: mbi.Domain) -> list[mbi.Clique]: domain, self.workload, self.max_marginal_size ) - def configure(self, *, zcdp_rho: float, delta: float = 0.0) -> SWIFTMechanism: - """Returns a copy calibrated to the given zCDP budget.""" + def configure( + self, + *, + zcdp_rho: float, + delta: float = 0.0, + initial_measurements: list[mbi.LinearMeasurement] | None = None, + **kwargs: typing.Any, + ) -> SWIFTMechanism: + """Configures the mechanism with a zCDP budget.""" + result = super().configure( + zcdp_rho=zcdp_rho, + delta=delta, + initial_measurements=initial_measurements, + ) + remaining = result.remaining_rho + select_rho = remaining * self.select_budget_frac + measure_rho = remaining - select_rho return dataclasses.replace( - self, gdp_sigma=accounting.zcdp_gaussian_sigma(zcdp_rho) + result, + _select_rho=select_rho, + measurement_mechanism=base.GaussianMarginalMeasurement().configure( + zcdp_rho=measure_rho + ), ) @property def dp_event(self) -> dp_accounting.DpEvent: """Returns the DP event for the SWIFT mechanism.""" - if self.gdp_sigma is None: - raise ValueError('Must call calibrate() before using the mechanism.') - return dp_accounting.GaussianDpEvent(noise_multiplier=self.gdp_sigma) - - def __call__( - self, - rng: np.random.Generator, - data: mbi.Dataset | mbi.CliqueVector, - *, - initial_measurements: Sequence[mbi.LinearMeasurement] | None = None, - constraints: tuple[mbi.Constraint, ...] = (), - ) -> common.DiscreteMechanismResult: - """Runs the SWIFT mechanism on the given data. - - Args: - rng: A numpy random number generator. - data: The sensitive dataset. Must be an mbi.Dataset for domain - compression; mbi.CliqueVector is supported but compression will be - skipped. - initial_measurements: Optional pre-existing one-way measurements. - constraints: Structural constraints for the estimation. - - Returns: - A DiscreteMechanismResult containing the estimated data distribution. - - Raises: - ValueError: If calibrate() has not been called. - """ - if self.gdp_sigma is None: - raise ValueError('Must call calibrate() before using the mechanism.') - - logging.info('[SWIFT] Starting Mechanism.') - phase_times = {} - - # Convert end-to-end GDP sigma to budget for internal allocation. - gdp_budget = 1.0 / self.gdp_sigma**2 - budget_remaining = gdp_budget - if initial_measurements is None: - budget_oneway = self.one_way_budget_frac * gdp_budget - sigma_oneway = accounting.gdp_gaussian_sigma(budget_oneway) - budget_remaining -= budget_oneway - marginal_queries = common.one_way_cliques(self.workload, data.domain) - measurements = common.measure_marginals_with_noise( - rng, - data, - marginal_queries, - gdp_sigma=sigma_oneway, - ) - else: - measurements = list(initial_measurements) - - mappings = common.compression_mappings( - measurements, self.compress_columns, constraints + self._check_calibration() + # SWIFT's budget is split between one-way, selection, and measurement. + # All three are Gaussian mechanism applications. + return dp_accounting.ZCDpEvent(self.zcdp_rho) + + def _run(self, rng, data, measurements, constraints, phase_times, mappings): + """Runs SWIFT's select-measure-estimate pipeline in a single pass.""" + assert self._select_rho is not None + assert self.measurement_mechanism is not None + + # Budgets in GDP units, derived from the zCDP allocation set by configure. + gdp_budget = accounting.zcdp_to_gdp( + self._select_rho + self.measurement_mechanism.zcdp_rho ) - if mappings: - data = data.compress(mappings) - measurements = [m.compress(mappings, data.domain) for m in measurements] ######################################################################### # Compile workload into candidate measurements, and precompute answers. # @@ -163,7 +133,7 @@ def __call__( logging.info('[SWIFT] %d candidates.', len(candidates)) with common.timed(phase_times, 'from_projectable'): - answers = mbi.CliqueVector.from_projectable(data, candidates) # pyrefly: ignore[bad-argument-type] + answers = mbi.CliqueVector.from_projectable(data, candidates) domain = data.domain with common.timed(phase_times, 'initial_mirror_descent'): @@ -177,13 +147,12 @@ def __call__( # Select subset of candidates to measure. # ########################################### with common.timed(phase_times, 'selection'): - assert 0 < self.select_budget_frac < 1 - l1_error_budget = self.select_budget_frac * gdp_budget - budget_remaining -= l1_error_budget + l1_error_budget = accounting.zcdp_to_gdp(self._select_rho) + budget_remaining = gdp_budget - l1_error_budget with common.timed(phase_times, 'compute_initial_errors'): errors = _compute_initial_errors( - rng, answers, model, list(candidates), l1_error_budget # pyrefly: ignore[bad-argument-type] + rng, answers, model, list(candidates), l1_error_budget ) with common.timed(phase_times, 'select_queries'): @@ -219,7 +188,7 @@ def __call__( with common.timed(phase_times, 'measurement'): logging.info('[SWIFT] Starting measurements.') new_measurements, _ = _measure_selected_marginals( - rng, answers, selected, budget_remaining # pyrefly: ignore[bad-argument-type] + rng, answers, selected, budget_remaining ) measurements.extend(new_measurements) logging.info('[SWIFT] Finished measurements.') @@ -260,15 +229,7 @@ def __call__( syn = syn.decompress(mappings) logging.info('[SWIFT] Generated %d synthetic records.', rows) - diagnostics = common.clique_stats(final_model) - diagnostics.phase_times = phase_times - return common.DiscreteMechanismResult( - model=final_model, - synthetic_data=syn, - measurements=measurements, - diagnostics=diagnostics, - mappings=mappings, - ) + return final_model, syn, measurements def _is_supported(clique: mbi.Clique, tree: nx.Graph) -> bool: diff --git a/tests/discrete_mechanisms/swift_test.py b/tests/discrete_mechanisms/swift_test.py index 68b737f..6465259 100644 --- a/tests/discrete_mechanisms/swift_test.py +++ b/tests/discrete_mechanisms/swift_test.py @@ -145,10 +145,10 @@ def test_dp_event_requires_calibration(self): with self.assertRaises(ValueError): _ = config.dp_event - def test_dp_event_returns_gaussian(self): + def test_dp_event_returns_zcdp(self): config = swift.SWIFTMechanism().configure(zcdp_rho=1.0) event = config.dp_event - self.assertIsInstance(event, dp_accounting.GaussianDpEvent) + self.assertIsInstance(event, dp_accounting.ZCDpEvent) if __name__ == '__main__':