diff --git a/docs/accounting_philosophy.md b/docs/accounting_philosophy.md index c8cc9597..b8cabc76 100644 --- a/docs/accounting_philosophy.md +++ b/docs/accounting_philosophy.md @@ -225,20 +225,23 @@ context or non-DP disclosures: ```python PrivacyReport( dp_event=GaussianDpEvent(noise_multiplier=2.0), - epsilon_deltas=( + epsilon_deltas=[ (2.708, 1e-08), (2.490, 1e-07), (2.254, 1e-06), (1.993, 1e-05), (1.698, 1e-04), - ), + ], gdp_estimate=0.5, - trade_off_curve=( - (0.005, 0.001), - (0.034, 0.01), - (0.126, 0.05), - (0.217, 0.1), - (0.691, 0.5), + trade_off_curve=TradeOffCurve( + points=[ + (0.005, 0.001), + (0.034, 0.01), + (0.126, 0.05), + (0.217, 0.1), + (0.691, 0.5), + ], + auroc=None, ), notes=None, neighboring_relation='ADD_OR_REMOVE_ONE', diff --git a/docs/api_reference.rst b/docs/api_reference.rst index e53e2ca5..ff2836ba 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -109,6 +109,8 @@ privacy profile across evaluation points. :template: autosummary/class.rst PrivacyReport + AuditReport + TradeOffCurve ---- diff --git a/dpsynth/__init__.py b/dpsynth/__init__.py index 88777805..63eb0d80 100644 --- a/dpsynth/__init__.py +++ b/dpsynth/__init__.py @@ -38,7 +38,9 @@ from dpsynth.domain import NumericalAttribute from dpsynth.domain import OpenSetCategoricalAttribute from dpsynth.domain import Schema +from dpsynth.reporting import AuditReport from dpsynth.reporting import PrivacyReport +from dpsynth.reporting import TradeOffCurve from dpsynth.serialize import from_yaml from dpsynth.serialize import to_yaml import mbi @@ -58,6 +60,7 @@ MultiTableMechanism = relational.MultiTableMechanism __all__ = [ + 'AuditReport', 'CategoricalAttribute', 'ForeignKeyRelation', 'MultiDataGenerationResult', @@ -70,6 +73,7 @@ 'TabularConfig', 'TabularMechanism', 'TabularSynthesizer', + 'TradeOffCurve', 'api', 'calibrate', 'checkpoint', diff --git a/dpsynth/reporting.py b/dpsynth/reporting.py index b44eb182..ed98d266 100644 --- a/dpsynth/reporting.py +++ b/dpsynth/reporting.py @@ -19,17 +19,34 @@ from collections.abc import Callable, Sequence import dataclasses import functools -from typing import TypeAlias +from typing import TYPE_CHECKING, TypeAlias import dp_accounting import numpy as np +# Guarded by TYPE_CHECKING so jax_privacy remains an optional dependency. +if TYPE_CHECKING: + import jax_privacy # pylint: disable=g-import-not-at-top + Pair: TypeAlias = tuple[float, float] DEFAULT_TARGET_DELTAS = (1e-10, 1e-9, 1e-8, 1e-7, 1e-6, 1e-5, 1e-4, 1e-3) DEFAULT_TARGET_FPRS = (1e-5, 1e-4, 1e-3, 1e-2, 0.05, 0.1, 0.2, 0.5) +@dataclasses.dataclass(frozen=True) +class TradeOffCurve: + """Hypothesis testing trade-off curve (TPR vs. FPR) and optional AUROC. + + Attributes: + points: Sequence of ``(tpr, fpr)`` pairs evaluated at target FPRs. + auroc: Optional area under the receiver operating characteristic curve. + """ + + points: Sequence[Pair] + auroc: float | None = None + + @dataclasses.dataclass(frozen=True) class PrivacyReport: """Privacy guarantees computed from a differential privacy event. @@ -44,7 +61,7 @@ class PrivacyReport: dp_event: dp_accounting.DpEvent epsilon_deltas: Sequence[Pair] gdp_estimate: float | None = None - trade_off_curve: Sequence[Pair] | None = None + trade_off_curve: TradeOffCurve | None = None notes: str | None = None # All mechanisms in dpsynth assume the ADD_OR_REMOVE_ONE neighboring relation. neighboring_relation: str = 'ADD_OR_REMOVE_ONE' @@ -129,7 +146,7 @@ def from_dp_event( try: tprs = pld_accountant.get_true_positive_rates(np.asarray(target_fprs)) tpr_list = [float(t) for t in tprs] - trade_off_curve = list(zip(tpr_list, target_fprs)) + trade_off_curve = TradeOffCurve(points=list(zip(tpr_list, target_fprs))) except (dp_accounting.UnsupportedEventError, NotImplementedError): trade_off_curve = None @@ -142,6 +159,103 @@ def from_dp_event( ) +@dataclasses.dataclass(frozen=True) +class AuditReport: + """Empirical privacy audit summary computed from a CanaryScoreAuditor. + + Complements ``PrivacyReport`` with an empirical measure of privacy loss, + recording point estimates alongside two-sided bootstrap confidence bounds. + + Attributes: + num_canaries: Tuple of ``(held_in, held_out)`` canary counts. + confidence: Confidence level in ``(0, 1)`` for the bootstrap bounds. + trade_off_curve: Point-estimate trade-off curve and AUROC. + lower_bound: Lower bootstrap confidence bounds for the curve and AUROC. + upper_bound: Upper bootstrap confidence bounds for the curve and AUROC. + notes: Optional string capturing custom notes about the audit. + """ + + num_canaries: tuple[int, int] + confidence: float + trade_off_curve: TradeOffCurve + lower_bound: TradeOffCurve + upper_bound: TradeOffCurve + notes: str | None = None + + @classmethod + def from_auditor( + cls, + auditor: jax_privacy.auditing.CanaryScoreAuditor, + *, + confidence: float = 0.95, + num_bootstrap_samples: int = 1000, + target_false_positive_rates: Sequence[float] = DEFAULT_TARGET_FPRS, + seed: int | None = None, + notes: str | None = None, + ) -> 'AuditReport': + """Computes an AuditReport from a CanaryScoreAuditor. + + Evaluates empirical membership inference AUROC and true positive rates at + target false positive rates, alongside two-sided bias-corrected bootstrap + confidence intervals. + + Example: + >>> import jax_privacy + >>> auditor = jax_privacy.auditing.CanaryScoreAuditor( + ... in_canary_scores=np.arange(100) + 9.5, + ... out_canary_scores=np.arange(100), + ... ) + >>> report = AuditReport.from_auditor(auditor, seed=0) + >>> round(report.trade_off_curve.auroc, 3) + 0.595 + + Args: + auditor: The CanaryScoreAuditor to summarize. + confidence: Desired confidence level in ``(0, 1)`` for bootstrap bounds. + num_bootstrap_samples: Number of bootstrap resamples per metric. + target_false_positive_rates: Target FPRs at which to evaluate TPR. + seed: Optional integer seed for reproducible bootstrap intervals. + notes: Optional string capturing custom notes about the audit. + + Returns: + An AuditReport containing empirical privacy audit metrics and intervals. + """ + import jax_privacy # pylint: disable=g-import-not-at-top + + if num_bootstrap_samples < 1: + raise ValueError('num_bootstrap_samples must be positive.') + + bootstrap = jax_privacy.auditing.BootstrapParams.confidence_interval( + num_samples=num_bootstrap_samples, + confidence=confidence, + seed=seed, + ) + + target_fprs = [float(x) for x in target_false_positive_rates] + raw_tprs = auditor.tpr_at_given_fpr(target_fprs) + tprs = np.asarray(raw_tprs, dtype=float).tolist() + points = list(zip(tprs, target_fprs)) + lo_points, hi_points = [], [] + for fpr in target_fprs: + raw_ci = auditor.tpr_at_given_fpr(fpr, bootstrap_params=bootstrap) + lo, hi = np.asarray(raw_ci, dtype=float).tolist() + lo_points.append((lo, fpr)) + hi_points.append((hi, fpr)) + + auroc = float(auditor.attack_auroc()) + raw_auroc_ci = auditor.attack_auroc(bootstrap_params=bootstrap) + auroc_lo, auroc_hi = np.asarray(raw_auroc_ci, dtype=float).tolist() + + return cls( + num_canaries=auditor.num_canaries, + confidence=confidence, + trade_off_curve=TradeOffCurve(points=points, auroc=auroc), + lower_bound=TradeOffCurve(points=lo_points, auroc=auroc_lo), + upper_bound=TradeOffCurve(points=hi_points, auroc=auroc_hi), + notes=notes, + ) + + def _get_epsilons( event: dp_accounting.DpEvent, make_accountant: Callable[[], dp_accounting.PrivacyAccountant], diff --git a/pylock.toml b/pylock.toml index 02a6576a..847e9ea4 100644 --- a/pylock.toml +++ b/pylock.toml @@ -739,7 +739,7 @@ wheels = [{ url = "https://files.pythonhosted.org/packages/d4/5a/26a1305f8978bc4 [[packages]] name = "jax-privacy" version = "2.3.0.dev0" -vcs = { type = "git", url = "https://github.com/google-parfait/jax_privacy.git", commit-id = "615b9bf812024c06d3d58f75a7c325fb05b039f8" } +vcs = { type = "git", url = "https://github.com/google-parfait/jax_privacy.git", commit-id = "45af216fd55104964c1c8e33ff73b640c27ebc4c" } [[packages]] name = "jaxlib" diff --git a/tests/reporting_test.py b/tests/reporting_test.py index 87e83118..1c196e31 100644 --- a/tests/reporting_test.py +++ b/tests/reporting_test.py @@ -24,6 +24,8 @@ import dpsynth from dpsynth import reporting from dpsynth import serialize +import jax_privacy +import numpy as np class ReportingTest(parameterized.TestCase): @@ -49,8 +51,10 @@ def test_from_dp_event_gaussian_defaults(self): self.assertAlmostEqual(self.report.gdp_estimate, 0.5, delta=1e-2) self.assertIsNotNone(self.report.trade_off_curve) assert self.report.trade_off_curve is not None + self.assertIsInstance(self.report.trade_off_curve, reporting.TradeOffCurve) + self.assertIsNone(self.report.trade_off_curve.auroc) self.assertLen( - self.report.trade_off_curve, + self.report.trade_off_curve.points, len(reporting.DEFAULT_TARGET_FPRS), ) self.assertIsNone(self.report.notes) @@ -73,7 +77,7 @@ def test_from_dp_event_trade_off_curve_properties(self): trade_off = self.report.trade_off_curve assert trade_off is not None prev_tpr, prev_fpr = 0.0, 0.0 - for tpr, fpr in trade_off: + for tpr, fpr in trade_off.points: self.assertGreaterEqual(fpr, 0.0) self.assertLessEqual(fpr, 1.0) self.assertGreaterEqual(tpr, 0.0) @@ -91,8 +95,8 @@ def test_from_dp_event_trade_off_curve_custom_fprs(self): target_false_positive_rates=[0.05], ) assert report_single.trade_off_curve is not None - self.assertLen(report_single.trade_off_curve, 1) - self.assertAlmostEqual(report_single.trade_off_curve[0][1], 0.05) + self.assertLen(report_single.trade_off_curve.points, 1) + self.assertAlmostEqual(report_single.trade_off_curve.points[0][1], 0.05) report_none = reporting.PrivacyReport.from_dp_event( event, @@ -100,6 +104,9 @@ def test_from_dp_event_trade_off_curve_custom_fprs(self): target_false_positive_rates=None, ) self.assertIsNone(report_none.trade_off_curve) + self.assertEqual( + serialize.from_yaml(serialize.to_yaml(report_none)), report_none + ) def test_from_dp_event_notes(self): event = dp_accounting.GaussianDpEvent(noise_multiplier=2.0) @@ -150,6 +157,144 @@ def test_serialize_yaml_roundtrip(self): def test_public_reexport(self): self.assertIs(dpsynth.PrivacyReport, reporting.PrivacyReport) + self.assertIs(dpsynth.AuditReport, reporting.AuditReport) + self.assertIs(dpsynth.TradeOffCurve, reporting.TradeOffCurve) + + def test_from_auditor_defaults_and_yaml_roundtrip(self): + in_scores = np.arange(80, dtype=float) + 20.0 + out_scores = np.arange(120, dtype=float) + auditor = jax_privacy.auditing.CanaryScoreAuditor(in_scores, out_scores) + report = reporting.AuditReport.from_auditor( + auditor, + num_bootstrap_samples=200, + seed=42, + notes='Synthetic canary audit.', + ) + self.assertEqual(report.num_canaries, (80, 120)) + self.assertEqual(report.confidence, 0.95) + self.assertEqual(report.notes, 'Synthetic canary audit.') + assert report.trade_off_curve.auroc is not None + assert report.lower_bound.auroc is not None + assert report.upper_bound.auroc is not None + self.assertAlmostEqual( + report.trade_off_curve.auroc, float(auditor.attack_auroc()) + ) + self.assertLessEqual(report.lower_bound.auroc, report.upper_bound.auroc) + expected_fprs = list(reporting.DEFAULT_TARGET_FPRS) + self.assertEqual( + [fpr for _, fpr in report.trade_off_curve.points], expected_fprs + ) + self.assertEqual( + [fpr for _, fpr in report.lower_bound.points], expected_fprs + ) + self.assertEqual( + [fpr for _, fpr in report.upper_bound.points], expected_fprs + ) + for (tpr, fpr), (lo, _), (hi, _) in zip( + report.trade_off_curve.points, + report.lower_bound.points, + report.upper_bound.points, + ): + self.assertAlmostEqual(tpr, float(auditor.tpr_at_given_fpr(fpr))) + self.assertLessEqual(lo, hi) + self.assertGreaterEqual(lo, 0.0) + self.assertLessEqual(hi, 1.0) + + report_same_seed = reporting.AuditReport.from_auditor( + auditor, + num_bootstrap_samples=200, + seed=42, + notes='Synthetic canary audit.', + ) + self.assertEqual(report, report_same_seed) + + yaml_str = serialize.to_yaml(report) + self.assertNotIn('!!python/tuple', yaml_str) + self.assertIn('confidence: 0.95', yaml_str) + self.assertIn('num_canaries: [80, 120]', yaml_str) + loaded = serialize.from_yaml(yaml_str) + self.assertEqual(loaded, report) + self.assertIsInstance(loaded.num_canaries, tuple) + self.assertIsInstance(loaded.trade_off_curve, reporting.TradeOffCurve) + self.assertIsInstance(loaded.trade_off_curve.points[0], tuple) + self.assertIsInstance(loaded.lower_bound, reporting.TradeOffCurve) + self.assertIsInstance(loaded.lower_bound.points[0], tuple) + + @parameterized.parameters(0.0, 0.5, 2.0) + def test_from_auditor_custom_args_and_exact_bootstrap_match(self, shift): + rng = np.random.default_rng(123) + auditor = jax_privacy.auditing.CanaryScoreAuditor( + rng.normal(loc=shift, size=150), + rng.normal(loc=0.0, size=200), + ) + report = reporting.AuditReport.from_auditor( + auditor, + confidence=0.90, + num_bootstrap_samples=100, + target_false_positive_rates=[0.2, 0.05], + seed=7, + ) + expected_bootstrap = ( + jax_privacy.auditing.BootstrapParams.confidence_interval( + num_samples=100, + confidence=0.90, + seed=7, + ) + ) + expected_auroc_ci = np.asarray( + auditor.attack_auroc(bootstrap_params=expected_bootstrap) + ) + self.assertEqual(report.num_canaries, (150, 200)) + self.assertEqual(report.confidence, 0.90) + self.assertEqual( + [fpr for _, fpr in report.trade_off_curve.points], [0.2, 0.05] + ) + self.assertIsNone(report.notes) + assert report.trade_off_curve.auroc is not None + assert report.lower_bound.auroc is not None + assert report.upper_bound.auroc is not None + self.assertAlmostEqual( + report.trade_off_curve.auroc, float(auditor.attack_auroc()) + ) + self.assertAlmostEqual( + report.lower_bound.auroc, float(expected_auroc_ci[0]) + ) + self.assertAlmostEqual( + report.upper_bound.auroc, float(expected_auroc_ci[1]) + ) + self.assertLessEqual(report.lower_bound.auroc, report.trade_off_curve.auroc) + self.assertLessEqual(report.trade_off_curve.auroc, report.upper_bound.auroc) + for (est, fpr), (lo, lo_fpr), (hi, hi_fpr) in zip( + report.trade_off_curve.points, + report.lower_bound.points, + report.upper_bound.points, + ): + expected_ci = np.asarray( + auditor.tpr_at_given_fpr(fpr, bootstrap_params=expected_bootstrap) + ) + self.assertEqual(lo_fpr, fpr) + self.assertEqual(hi_fpr, fpr) + self.assertAlmostEqual(est, float(auditor.tpr_at_given_fpr(fpr))) + self.assertAlmostEqual(lo, float(expected_ci[0])) + self.assertAlmostEqual(hi, float(expected_ci[1])) + self.assertLessEqual(lo, est) + self.assertLessEqual(est, hi) + + @parameterized.parameters( + dict(confidence=0.0), + dict(confidence=1.0), + dict(confidence=-0.1), + dict(num_bootstrap_samples=0), + dict(num_bootstrap_samples=-5), + dict(target_false_positive_rates=[-0.1]), + dict(target_false_positive_rates=[1.5]), + ) + def test_from_auditor_invalid_args_raises(self, **kwargs): + auditor = jax_privacy.auditing.CanaryScoreAuditor( + np.arange(20, dtype=float), np.arange(20, dtype=float) + ) + with self.assertRaises(ValueError): + reporting.AuditReport.from_auditor(auditor, **kwargs) def load_tests(loader, tests, ignore):