Skip to content

DP-SGD privacy-utility optimization: RMIA-audited Bayesian search - #438

Open
fazelehh wants to merge 21 commits into
mainfrom
feature/pet-optimization
Open

fazelehh wants to merge 21 commits into
mainfrom
feature/pet-optimization

Conversation

@fazelehh

@fazelehh fazelehh commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

What this does

Finds the privacy-utility frontier of a DP-SGD model by optimizing its four hyperparameters — noise multiplier, clipping norm, learning rate, batch size — against LeakPro's own RMIA attack.

Per trial:

  1. Optuna (multi-objective TPE) proposes a configuration from the (utility, TPR) results observed so far.
  2. A target is trained under it and saved in the standard LeakPro target-folder layout.
  3. The target is audited with the real AttackRMIA through LeakPro(...).run_audit() — the RMIA shadow models are retrained by ShadowModelHandler under the candidate's exact configuration (full mimicry, once per config).
  4. Objectives: maximize utility, minimize MIAResult.fixed_fpr_table["TPR@1%FPR"]. The Pareto front is study.best_trials.

Design points

  • No attack or scoring reimplemented. The privacy number comes from the same AttackRMIA + MIAResult path every LeakPro audit uses (leakpro/optimization/audit.py is a thin bridge). The earlier hand-rolled mean-calibrated attack and duplicate TPR code are deleted.
  • Real optimization, resumable. Proposals depend on prior results (unit-tested); the study persists to SQLite, so rerunning the same command resumes exactly.
  • Utility gate. A model at/below its metric's chance level (configurable: accuracy / balanced_accuracy / auc) is pruned — attack skipped, never on the frontier — so a model that learned nothing can't masquerade as "perfectly private".
  • All user settings in one YAML (dpsgd_optimization.yaml, schema leakpro.schemas.PrivacyUtilityConfig): knob bounds, budget, seed, DP delta, proxy FPR, utility metric + gate, the RMIA attack block (passed verbatim), split sizes. Per-trial audit.yaml files are generated output.

Layout

  • Core: leakpro/optimization/search.py (Optuna loop), audit.py (RMIA bridge), knobs.py, frontier.py, validation.py (re-audit Pareto points with more shadow models; proxy-agreement check).
  • Example: examples/mia/cifar/dpsgd_optimization/ — training-only dp_handler.py, run_optimization.py (CLI + --smoke), validate_frontier.py. Also callable from the last cell of cifar_main.ipynb.
  • LOS LR/GRU-D campaigns are removed pending migration to this pattern (a follow-up PR will port them following the CIFAR reference). The absolute-path examples/mia/cifar/data symlink is removed.

Verification

  • 15 unit tests (search adaptivity, resume, gate pruning, RMIA-bridge TPR parity with fixed_fpr_table), ruff clean.
  • End-to-end --smoke run: DP targets + mimicking shadow models trained, real RMIA audits, Pareto front + frontier.png; resume re-runs 0 trials; validate_frontier reports TPR at 0.1%/1% with selection_bias.

🤖 Generated with Claude Code

https://claude.ai/code/session_011Tcj6Aq9YZdQnC9T4Qnb7w

@fazelehh
fazelehh marked this pull request as draft August 17, 2026 06:45
@fazelehh

Copy link
Copy Markdown
Collaborator Author

CIFAR-10 campaign result (promised in the description)

50 Sobol configs, joint search over {noise_multiplier, max_grad_norm, learning_rate, batch_size}, 15 epochs, 2 matched reference models per config. Runtime ~90 min on one 24 GB GPU, including one OOM crash and resume (fixed in 4ac35bb; JSONL resume meant only 5 configs re-ran).

The privacy axis is alive on this target, unlike LOS: TPR@1%FPR spans 0.5% to 10.7% (chance = 1%). Pareto points:

accuracy TPR@1% formal eps
0.48 0.5% 15
0.54 0.8% 8
0.56 1.0% 61
0.58 1.4% 66

Caveats so nobody over-reads this:

  • Raw corr(log eps, TPR) is slightly negative, which is confounded: near-zero-noise configs with a bad learning rate neither learn nor leak. Read the frontier, not the correlation.
  • Max accuracy 0.58 (small CNN, 15 epochs, 15k subset). A longer-epoch run sharpens the leaky corner; 10.7% TPR is this sweep's ceiling, not the target's.
  • 2000 audit members/nonmembers, so TPR@1% rests on ~10-200 events. Clopper-Pearson CIs are in the JSONL per config.

Full per-config log: examples/mia/cifar/pet_optimization/leakpro_output/pet_optimization/evaluations.jsonl (seed 0, reproducible).

@fazelehh

Copy link
Copy Markdown
Collaborator Author

Validation pass — result (and an honest caveat)

Ran validate_frontier.py on the 4 Pareto points with a stronger attack (8 references, 10k-member/10k-nonmember audit set), plus the proxy-agreement check over 6 configs spanning the observed range.

Frontier points revalidated:

cfg loop TPR@1% validated TPR@1% (95% CI) validated TPR@0.1% (95% CI) events @0.1%
10 0.5% 1.3% [1.1, 1.5] 0.06% [0.02, 0.13] 6/10000
26 0.8% 1.3% [1.1, 1.6] 0.24% [0.15, 0.36] 24/10000
13 1.0% 1.4% [1.2, 1.7] 0.17% [0.10, 0.27] 17/10000
18 1.4% 1.4% [1.2, 1.6] 0.27% [0.18, 0.39] 27/10000

Proxy agreement (rank by TPR@1% vs by TPR@0.1%): Spearman rho = 0.086 (p = 0.87).

Read this correctly before concluding the proxy is broken: at 15 epochs this CNN barely leaks — validated TPR@1% is 1.1–1.4% against a 1% chance line, and TPR@0.1% rests on 6–27 events. There is almost no signal, so the proxy check is correlating noise against noise; rho near 0 is what you get when the configs do not separate, not evidence the proxy is wrong. The check is simply uninformative on a target this quiet — exactly the low-FPR power problem the design flags.

Takeaway: the validation machinery is confirmed end to end (stronger attack, larger audit set, tail FPR with CIs, proxy correlation). A meaningful proxy rho needs a leakier target (more epochs / larger subset so TPR@1% reaches 5–10%); that re-run is parked and does not block this PR, which delivers the pipeline, not a leakage claim about CIFAR at 15 epochs.

@fazelehh
fazelehh marked this pull request as ready for review August 17, 2026 11:55
@fazelehh
fazelehh requested a review from TheColdIce August 17, 2026 11:56
fazelehh and others added 6 commits August 18, 2026 11:49
Adds leakpro/optimization: a Sobol-sweep campaign that evaluates PET
configurations on (utility, attack TPR) to trace the privacy-utility
frontier. Design choices per the frontier plan:

- Joint DP-SGD search space {noise multiplier, clip norm, lr, batch size},
  log-scaled, user can fix or narrow any knob
- Loop optimizes TPR@1% FPR (powered proxy), never tail statistics;
  Clopper-Pearson CIs and event counts recorded per evaluation
- Decoupled objectives: optional utility gate skips the attack on
  utility-uncompetitive configs
- JSONL persistence with resume, Pareto extraction, frontier plot
- Core is callable-based (train/utility/attack fns) so full-mimicry
  wiring lives in example adapters; qNEHVI can replace the Sobol
  sampler later without touching anything else

11 unit tests; ruff clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkoiVGT8PFUhQ1tzXvT64k
Wires the PET optimization core to the MIMIC LOS LR target:
joint DP-SGD knob search, matched-reference MIA (full mimicry:
references share the candidate's config), AUC utility, formal
epsilon recorded per config via campaign_extras for the
attack-calibrated vs epsilon-indexed comparison.

Smoke-verified end-to-end on GPU (~1.5s per model).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkoiVGT8PFUhQ1tzXvT64k
Small Opacus-compatible CNN (GroupNorm) on a 15k CIFAR subset - the
standard leaky MIA setup - as the counterpart to the near-null LOS
target. --include-nonprivate lets noise reach 0 to anchor the leaky
end of the frontier; epsilon recorded per config as in LOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkoiVGT8PFUhQ1tzXvT64k
Large sampled batch sizes (up to 1024) made per-example gradients
exceed GPU memory at config 45/50. BatchMemoryManager caps the
physical batch at 256 without changing the sampled batch size or the
accounting; reference models are now trained and scored one at a
time and freed immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkoiVGT8PFUhQ1tzXvT64k
The campaign optimizes TPR@1% as a powered proxy; this makes good on
the two things that leaves open.

leakpro/optimization/validation.py:
- validate_frontier: re-measures the Pareto points at the reported FPR
  levels (default 0.1% and 1%) with a caller-supplied stronger attack,
  reporting events and Clopper-Pearson CIs. Tail risk is re-measured on
  a larger audit set, never re-thresholded from the loop's scores.
- proxy_agreement: Spearman correlation between ranking configs by the
  proxy FPR and by the reported FPR, over configs spanning the whole
  observed range. Low rho means the loop optimized the wrong thing.
- resolution_warning: flags audit sets too small for a requested FPR
  (fewer than 10 expected events), attached to every reported number.

CIFAR example gains validate_frontier.py (more references, larger
audit set) and load_splits grows an audit_size argument.

4 new unit tests, including a disagreement case that must yield
negative rho; ruff clean. Smoke-verified against the real campaign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkoiVGT8PFUhQ1tzXvT64k
Adds the GRU-D counterpart to the LOS logistic-regression campaign, in
the same self-contained style: joint DP-SGD knob search, matched
references (full mimicry), AUC utility, TPR@1% loop metric. GRU-D is
the leakier LOS target, so its frontier has a real privacy axis.

Two GRU-D specifics: raw-logit output (BCEWithLogitsLoss) scored by a
signed-logit membership signal, and Opacus on its functorch
per-sample-gradient path (force_functorch) for the custom FilterLinear
and manual recurrence. Smoke-verified end to end under DP-SGD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fy6fvLfZtfrSLBp5u8rMo
… isolation

Findings from an adversarial review of this PR, most-severe first.

tpr_at_fpr is now tie-safe. The old rule placed the threshold at the
floor(alpha*n)-th nonmember score and counted members with >=; with tied
nonmember scores at that position — the normal case under DP-SGD, which
saturates outputs — the whole tied block was admitted and the realized FPR
could exceed the requested one by orders of magnitude, overstating every
attack TPR the frontier reports. The threshold now moves above any tie block
that would blow the budget, so realized FPR <= requested FPR always; with
all-distinct scores the result is bit-identical to the classic rule (test
asserts this). Regression tests reproduce the reviewer's tied-scores cases
and fail on the old implementation.

Campaign resume refuses incompatible sweeps: a campaign.json sidecar records
(seed, proxy_fpr, knob space) and construction raises if an existing output
dir holds a different identity — resume is index-based and indices only name
Sobol positions of one (seed, space) pair.

One failing configuration no longer kills the sweep or livelocks resume: the
error is recorded on the config's record and the sweep continues; errored
indices are retried on the next resume.

Campaign.run(anchors=...) evaluates explicit configurations under reserved
negative indices. --include-nonprivate now forces one exact noise=0 anchor
instead of sampling a linear [0,4] range where 0 has probability zero and
near-zero draws corrupted the log. Non-finite floats (the anchor's
epsilon=inf) are stored as null: bare Infinity is not JSON and browsers
reject it.

proxy_agreement reports rho=None and an INCONCLUSIVE warning instead of
propagating spearmanr's NaN, which silently disabled the agreement check.

plot_frontier only forces the Agg backend when pyplot is not yet imported,
instead of hijacking an interactive session's backend.

Examples: split arithmetic is guarded (CIFAR audit sets use the capped size;
LOS caps audit size and rejects too-small datasets); validate_frontier
cross-checks --epochs/--seed against the campaign's recorded run_meta.json
and refuses mismatches; the GRU-D script drops its --device flag, which GRUD
could not honor (device-pinned unregistered attributes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6zdgpRhLbscVhC99K3UhY
@fazelehh

Copy link
Copy Markdown
Collaborator Author

Addressed the findings from an adversarial code review of this PR (10 findings, 4 confirmed empirically). Pushed as one fix commit (d4f13de) on top of the rebase:

  • tpr_at_fpr is now tie-safe — the biggest one: with tied nonmember scores (normal under DP-SGD saturation) the old threshold rule admitted whole tie blocks and the realized FPR could exceed the requested one by orders of magnitude, overstating every reported attack TPR. The threshold now moves above any tie block that would blow the budget; with all-distinct scores the result is bit-identical to the classic rule. Regression tests reproduce the reviewer's cases and fail on the old code.
  • Campaign resume refuses incompatible sweeps (campaign.json identity sidecar: seed, proxy_fpr, knob space).
  • One failing config no longer kills the sweep or livelocks resume (error recorded on the config's record, retried on next resume).
  • --include-nonprivate now forces an exact noise=0 anchor (reserved index -1) instead of sampling a linear range where 0 has probability zero; non-finite floats (the anchor's epsilon) are stored as null since bare Infinity is not JSON.
  • proxy_agreement reports rho=None + INCONCLUSIVE instead of silently propagating spearmanr's NaN.
  • plot_frontier no longer hijacks an interactive matplotlib backend; example split arithmetic guarded; validate_frontier refuses mismatched --epochs/--seed; the GRU-D script drops the --device flag GRUD cannot honor.

Suite is 23 tests on this branch (all green), ruff clean.

@fazelehh fazelehh changed the title pet_opt_main PET optimization: privacy-utility frontier campaigns Aug 19, 2026

@TheColdIce TheColdIce left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at d4f13de0. I ran the test suite (23 pass), ruff check . (clean — note pyproject.toml excludes ./examples and ./leakpro/tests, so that covers leakpro/optimization only), and re-ran the CIFAR campaign at the documented recipe (15 epochs, 2 references, 12 Sobol configs plus the non-private anchor) with attack_fn wrapped to dump the raw AttackScores, so the new tie rule could be tested against real data rather than by inspection.

What holds up: the resume identity guard, crash isolation and negative-index anchors all behave as described; scipy's Sobol prefixes are stable, so index-based resume is sound; and the Pareto front from my run is monotone in ε with the non-private anchor at the leaky end, which is what it should look like.

The findings below are about measurement validity — what the privacy axis actually reports.

Blocking: the realized FPR is never recorded

tpr_at_fpr can no longer hit the requested FPR exactly. That is the intended consequence of the tie fix and the docstring says so — but only (tpr, k, n) come back, and _evaluate stores no realized FPR and no threshold, so the operating point behind each number is unrecoverable.

My run produced a concrete case. Config 8 (noise=3.86, lr=0.356) records:

"attack_tpr": 0.0, "attack_tpr_events": 0, "attack_tpr_n": 2000,
"attack_tpr_ci95": [0.0, 0.0018]

That reads as a confident measurement of near-perfect privacy at FPR 1%. The realized FPR was 0.0000, and the score distribution behind it has five distinct values across 2000 nonmembers:

+27.617827  x164     target clamped high, references clamped low
+13.808914  x33
  0.000000  x1503    target and references clamped the same way
-13.808914  x293
-27.617827  x7

Heavy noise collapsed the model to a near-constant predictor (test accuracy 0.1096, below the 10% chance floor for CIFAR-10), so its outputs quantize onto the clamp values in _confidence_logits. The top tie block is 164 nonmembers against a budget of 20, so no admissible threshold exists, within comes back empty, and the TPR is computed as members strictly above every nonmember — zero, because 172 members sit exactly on that value.

The substantive reading of that config is not wrong: interpolated ROC gives 0.0105, which is chance, and the attack genuinely has no signal there. The problem is that nothing in the output says so. A 0.0 from an unresolvable estimate and a 0.0 from a genuinely private model are the same three characters in evaluations.jsonl, and the Clopper–Pearson interval asserts a precision that does not exist.

Please record the realized FPR and threshold alongside every TPR, and warn (or refuse) when the realized FPR falls materially short of the requested one. Persisting raw scores, or at least a histogram, for frontier points would also make this auditable from a normal run — I could only establish the above by monkeypatching attack_fn, since the module persists aggregates only.

Should fix

1. The tie rule is discontinuous, and it puts artifacts on the front. See the inline note on objectives.py. Confirmed to fire in the real recipe, 1 of 13 configs. Config 8 lands on the Pareto front at exactly x=0 — unbeatable on the privacy axis, so any config that trips this is guaranteed non-dominated. Interpolated ROC has no such discontinuity and agrees with the current rule to the last bit on distinct scores.

2. Neither example sets a utility_gate. Campaign supports one and both examples pass None. Gating at above-chance accuracy would have skipped the attack on config 8 entirely, which is the right answer — a model that does not work cannot be meaningfully audited, and three trainings were spent to learn nothing.

3. proxy_agreement compares two things that differ in more than the FPR level. See the inline note on validation.py.

4. --seed does not make runs reproducible. There is no torch.manual_seed in any of the three example scripts. The seed covers the Sobol draw and the numpy split permutation; model init, DataLoader shuffling, Opacus Poisson sampling and the DP noise itself all run off the unseeded global torch RNG. The campaign docstring promises "fixed seed + fixed space = same sweep", the resume guard refuses a mismatched seed, and validate_frontier.py raises SystemExit because "a different seed reshuffles every split" — all of which imply a determinism that is not there. A resumed run trains different models than a fresh one at the same index, and validation retrains a different target than the one that is on the frontier.

5. attack_tpr_ci95 understates uncertainty. Clopper–Pearson covers binomial sampling error over audit points only. Target-model training randomness and the reference-model draw are not in it and are plausibly larger. pareto_front then selects the minimum over ~50 draws of that noisy quantity, which is a winner's curse. validate_frontier is the right instrument, but it never compares its re-measured TPR against the loop's attack_tpr — both are in the record, and that delta is the number that would quantify the bias.

6. This is a second MIA implementation living outside LeakPro's attack stack. leakpro/optimization imports nothing from leakpro except the logger. The score in run_campaign.py is hand-rolled offline LiRA without variance normalisation, _confidence_logits reimplements a signal leakpro/signals/ already provides, and the TPR metric duplicates what MIAResult tabulates. Given the repo already carries two MIAResult classes, a third scoring path with its own conventions deserves a deliberate decision rather than arriving as a side effect. See also the inline note on the module docstring.

7. No documentation, and neither example runs as shipped. Twelve files, no prose. No README in either pet_optimization/ directory, and examples/mia/LOS/ReadMe.md is untouched. Both examples open gitignored pickles that other notebooks generate, so a new user gets a bare FileNotFoundError with no indication of what to run first.


Happy to hand over the probe script and the 13 dumped score files if they would be useful.

Comment thread leakpro/optimization/objectives.py Outdated
Comment on lines +66 to +73
within = np.nonzero(admitted <= n_admit)[0]
if within.size == 0:
# Even the top tie-block alone exceeds the budget: only scores
# strictly above every nonmember can be counted.
tpr = float(np.mean(member > float(distinct[0])))
else:
threshold = float(distinct[within[-1]])
tpr = float(np.mean(member >= threshold))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restricting the threshold to an observed distinct nonmember value makes realized FPR a step function, and a tall step can jump the budget. With c nonmembers tied at value v and a above it, the block is unusable whenever

a <= n_admit < a + c

The threshold then retreats to the next distinct value above, and every member scoring in [v, next) is discarded. When the block is at the top, within is empty and line 70 counts only members strictly above every nonmember.

The estimator is therefore discontinuous in its input. 2000 members / 2000 nonmembers, 5% of members sharing a high score, varying how many nonmembers share it (n_admit = 20):

tied nonmembers this rule interpolated ROC
20 0.0500 0.0500
22 0.0000 0.0455
30 0.0000 0.0333
60 0.0000 0.0167

Two extra tied nonmembers move the result from 5% to 0%. Ties here are exact rather than approximate, because _confidence_logits clamps probabilities to [1e-6, 1-1e-6] and division by n_refs is exact in binary floating point, so a saturated point produces one specific float across models.

This fires in the real recipe (config 8 of my 15-epoch run, details in the review body), and the affected config lands on the Pareto front at exactly x=0.

The interpolated (equivalently randomized) threshold — sklearn.metrics.roc_curve plus np.interp — is continuous, hits the requested FPR in expectation, and is bit-identical to this rule when scores are distinct. Note that TestTieSafety asserts tpr == 0.0 for the all-tied cases and would need to assert ≈ chance instead.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This estimator is gone: objectives.py is deleted. The loop now reads TPR from LeakPro's own MIAResult.fixed_fpr_table, so it reports the same number as any LeakPro audit. Note that rule is also a step function (not interpolated ROC) — changing that would be a leakpro/metrics decision affecting all attacks. The saturated models that produced these tie blocks are now pruned by a utility gate before any attack runs.

Comment thread leakpro/optimization/campaign.py Outdated
Comment on lines +183 to +188
record.update(
attack_tpr=tpr,
attack_tpr_events=k,
attack_tpr_n=n,
attack_tpr_ci95=[lower, upper],
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the blocking item. The record keeps the TPR, the event counts and a Clopper–Pearson interval, but not the FPR that was actually realized, nor the threshold. Since the tie rule can only realize an FPR at or below the requested one, that is precisely the field needed to tell a resolved estimate from an unresolved one — and my run contains a case where the two are indistinguishable in the log (see the review body).

Suggest returning the realized FPR and threshold from tpr_at_fpr and storing them here, plus a warning when realized falls materially short of proxy_fpr.

The interval is also binomial-only: it excludes target-training randomness and the reference draw, which are plausibly the larger terms, so it is narrower than the true uncertainty on a quantity pareto_front then minimizes over ~50 draws.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Every trial now records realized_fpr (the FPR actually achievable at or below the proxy level) and a degenerate_audit flag when the attack produces no ROC, so an unresolved TPR=0 is distinguishable from a private one. The binomial-only CI is removed rather than kept as a false precision. Raw scores and the full ROC are persisted per trial by MIAResult.save(), so this is auditable from a normal run.

Comment thread leakpro/optimization/validation.py Outdated
Comment on lines +116 to +118
scores = revalidate_fn(record["config"])
target_tpr, k, n = tpr_at_fpr(scores, target_fpr)
proxy_tprs.append(record["attack_tpr"])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

target_tprs comes from revalidate_fn (8 references, 10k audit points, a freshly retrained target) while proxy_tprs is read back from the loop's record (2 references, 2k points, the campaign's target model). A low rho therefore cannot be attributed to the FPR level — attack strength, audit size and target-model instance all changed at the same time.

Since this is the check that licenses the proxy, it should compare like with like: take tpr_at_fpr(scores, proxy_fpr) from the same revalidated scores and correlate that against tpr_at_fpr(scores, target_fpr). The scores are already in hand, so it costs nothing.

Related: make_revalidate_fn reseeds the reference draw per config, while the loop uses common random numbers across configs. For a rank correlation that adds variance for no benefit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as suggested: proxy_agreement now takes both TPRs from the same re-audited result, so only the FPR level differs between the two rankings. Validation also no longer retrains anything — it re-audits the saved per-trial target with more shadow models, and records selection_bias (revalidated minus loop TPR) per frontier point.

Comment thread leakpro/optimization/campaign.py Outdated
Comment on lines +12 to +16
The campaign is decoupled from LeakPro internals on purpose: it consumes three
callables (train, utility, attack), and the example-side adapter maps sampled
knob values into an ``AbstractInputHandler`` recipe. Full mimicry is the
adapter's contract: reference models inside ``attack_fn`` must be trained with
the same configuration as the candidate target.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of the three examples use an AbstractInputHandler — all of them hand-roll training and scoring against raw tensors. Either wire attack_fn to LeakPro's own attack stack, or drop the claim and state plainly that this module is deliberately outside it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claim is now true instead of dropped: the attack runs as AttackRMIA through LeakPro(...).run_audit() with AbstractInputHandler handlers, and the shadow models are trained by ShadowModelHandler under each candidate's exact configuration. The hand-rolled attack and scoring are deleted.

Comment on lines +246 to +251
campaign = Campaign(
train_fn, utility_fn, attack_fn,
knob_space=knob_space(),
output_dir=args.out,
seed=args.seed,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things missing here:

utility_gate is left at None. In my 15-epoch run, config 8 trained to 0.1096 accuracy — below the 10% chance floor for CIFAR-10 — and still paid for two reference models and a full attack, producing the degenerate TPR = 0.0 that then sat on the Pareto front. A gate at above-chance accuracy would have skipped it.

There is also no torch.manual_seed(args.seed) anywhere in this script, so --seed fixes the Sobol draw and the split permutation but not model init, shuffling, Poisson sampling or the DP noise. The resume guard and validate_frontier.py's seed check both imply a reproducibility this does not deliver.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed. A utility gate is now on by default (the metric's chance level, configurable): a model at or below it is pruned — no attack, never on the Pareto front. And every RNG is seeded per trial from (seed, trial number), so a resumed run trains the same models as a fresh one; validation reuses the saved targets instead of retraining.

fazelehh and others added 2 commits August 19, 2026 14:14
…ates, seeding

Addresses @TheColdIce's review, which re-ran the CIFAR campaign with attack_fn
wrapped to dump raw scores rather than reading the code. His config 8 is the
case that drives most of this.

BLOCKING — the operating point is now recorded. tpr_at_fpr returns a TPRAtFPR
named tuple carrying realized_fpr, threshold and a warning alongside (tpr,
events, n), and Campaign persists all of them. Previously a TPR of 0.0 from an
unresolvable estimate and a 0.0 from a genuinely private model were the same
three characters in evaluations.jsonl, with a Clopper-Pearson interval
asserting a precision that did not exist. A warning fires when the nearest
achievable FPR falls below half the requested one, which is exactly the
degenerate case he found.

The tie rule is replaced by the interpolated ROC. Restricting the threshold to
an observed distinct nonmember value made the estimator a step function: a tie
block spanning the budget forced the threshold above it and discarded every
member inside, so two extra tied nonmembers moved the answer from 5% to 0%.
That artifact lands at TPR 0 — unbeatable on the privacy axis and therefore
guaranteed to sit on the Pareto front. Interpolation is continuous and returns
chance-level TPR for a no-signal attack. On his config 8 it gives 0.0105,
matching his independent calculation exactly.

Two corrections to the review's framing, both verified in tests:

- Interpolated ROC is NOT bit-identical to the classic floor(fpr*n) rule on
  distinct scores; it is never worse and is sometimes strictly better. Multiple
  ROC points can share one realized FPR, and members falling between the
  floor(fpr*n)-th nonmember and the next one down can be counted without
  admitting another nonmember. Since an attack lower-bounds risk, the tighter
  bound is the honest number. The test asserts >= rather than ==.
- sklearn's drop_intermediate default prunes points and can remove the exact
  operating point; roc_curve is called with drop_intermediate=False.

Also from the review:

- Both examples set a utility_gate at the chance floor (1/num_classes for
  CIFAR, 0.5 AUC for the LOS targets). Config 8 trained to 0.1096 accuracy on
  CIFAR-10 and still paid for two reference models to measure nothing.
- torch.manual_seed(args.seed) in all three examples. The seed previously
  covered only the Sobol draw and the numpy split permutation, while model
  init, shuffling, Poisson sampling and the DP noise ran off the unseeded
  global torch RNG — so the resume guard and validate_frontier's seed check
  promised a determinism that did not exist.
- proxy_agreement computes BOTH TPRs from the same revalidated scores. It
  previously compared the loop's record against a revalidation that differed in
  attack strength, audit size and target-model instance simultaneously, so a
  low rho could not be attributed to the FPR level. Its disagreement test was
  rebuilt accordingly: the old one could only fail through the confound.
- validate_frontier records selection_bias, the gap between the loop's TPR and
  the revalidated TPR at the same FPR — the direct measure of the optimism
  pareto_front's minimum-over-draws introduces. The CI is labelled
  clopper_pearson_binomial_only, since it excludes training and reference-draw
  randomness.
- The campaign docstring claimed an AbstractInputHandler recipe that no example
  uses. It now states plainly that this module sits outside LeakPro's attack
  stack, what that buys, and that wiring attack_fn to the attack stack remains
  open.
- READMEs for both pet_optimization directories, covering the ungitignored
  pickle prerequisites (previously a bare FileNotFoundError), the compute cost,
  resume behaviour, how to read a record, and validation. Linked from the LOS
  ReadMe.

24 tests pass, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBPG7MprEqhqG95fFURk8h
Two items from @TheColdIce's review were reported as addressed but were not.

Per-configuration seeding. I seeded torch once at script start, which does not
fix what the review described: resume skips finished configurations, so the RNG
state reached at a given index depends on how many configurations ran before it
in that process. A fresh run and a resumed run therefore trained different
models at the same index, and validate_frontier retrained a different target
than the one on the frontier — exactly the determinism the resume guard and the
seed check imply. Campaign now derives a seed from (campaign seed, config
index) and reseeds random/numpy/torch before each train_fn. Verified directly:
a fresh 4-config run and a resume-from-2 run now produce bit-identical model
draws at every index.

Raw score persistence — the second half of the blocking item, which I missed
entirely. Aggregates alone made the degenerate cases unauditable: establishing
that a TPR of 0 came from a saturated five-distinct-value score distribution
rather than from real privacy required monkeypatching attack_fn. Each
configuration's member and nonmember scores are now written to
scores/config_{index}.npz, and the record carries
attack_distinct_nonmember_scores as the cheap in-record diagnostic for
saturation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBPG7MprEqhqG95fFURk8h
fazelehh added a commit that referenced this pull request Aug 20, 2026
tpr_at_fpr now returns a TPRAtFPR named tuple rather than a 3-tuple (#438
review), so the runner's unpack was broken — verification would have crashed.
Confirmed against the real function before fixing.

While updating it, the verified result also carries realized_fpr and the
resolution warning, and the panel renders that warning: a low attack number can
mean "well protected" or "we could not measure it", and those must not look
identical to a non-technical reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBPG7MprEqhqG95fFURk8h
Follow-up to @TheColdIce's finding that leakpro/optimization reimplemented
things the library already had. An audit after his review found nine
duplications, not three; this commit removes the ones in this PR.

Signal. The three examples each carried a private _confidence_logits. They now
call leakpro.optimization.confidence_signal, which adds only the batched
forward pass and delegates the actual signal to
leakpro.signals.functional.rescaled_logits — Carlini's phi from LiRA, the same
function the attack stack scores with. The campaign and the attack stack can no
longer disagree about what a membership score is.

This is not cosmetic. My copies clamped probabilities to [1e-6, 1-1e-6];
rescaled_logits uses +1e-45. On a saturated model the clamp collapsed a wide
range of confidences onto one value (measured: 13.80 where rescaled_logits
gives 79.31), manufacturing the exact tied blocks that produced the degenerate
TPR=0 the review found. Using the library's signal attacks that at its source
rather than only compensating for it in the metric.

Seeding. Campaign._seed_for now calls leakpro.utils.seed.seed_everything
instead of reseeding random/numpy/torch by hand. Theirs also pins
cudnn.deterministic, which mine omitted, so the reproducibility claim is now
actually true on GPU.

Not done here, deliberately: leakpro/metrics/attack_result.py::find_tpr_at_fpr
duplicates this PR's tpr_at_fpr AND carries the same tie bug — on the review's
config 8 it returns 0.0 where the honest answer is 1.05%. It also takes
precomputed ROC arrays and returns a 0-100 percentage rather than a fraction.
Fixing it changes every MIA number LeakPro reports, so it belongs in its own PR
against main, not buried in this one.

24 tests pass, ruff clean, all four example scripts import cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBPG7MprEqhqG95fFURk8h
fazelehh added a commit that referenced this pull request Aug 20, 2026
tpr_at_fpr now returns a TPRAtFPR named tuple rather than a 3-tuple (#438
review), so the runner's unpack was broken — verification would have crashed.
Confirmed against the real function before fixing.

While updating it, the verified result also carries realized_fpr and the
resolution warning, and the panel renders that warning: a low attack number can
mean "well protected" or "we could not measure it", and those must not look
identical to a non-technical reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBPG7MprEqhqG95fFURk8h
fazelehh and others added 3 commits August 20, 2026 12:26
Replace the fixed Sobol sweep and hand-rolled scoring with the library's
own machinery:

- Attack: leakpro/optimization/audit.py runs AttackRMIA through
  LeakPro(...).run_audit() and reads TPR from MIAResult.fixed_fpr_table;
  the duplicated scoring path (objectives.py) is deleted.
- Search: leakpro/optimization/search.py is an Optuna multi-objective
  TPE study (maximize utility, minimize TPR@proxy FPR) with SQLite
  resume — proposals depend on observed results.
- Utility gate: ObjectiveResult.tpr=None prunes a config that failed the
  "did it learn" floor, so degenerate models never reach the frontier.
- User settings schema: PrivacyUtilityConfig (knobs, delta, proxy_fpr,
  utility metric + gate, RMIA block, splits) in leakpro/schemas.py.
- Tests rewritten: search adaptivity, resume, gate pruning, RMIA bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Tcj6Aq9YZdQnC9T4Qnb7w
examples/mia/cifar/dpsgd_optimization (was pet_optimization): per config,
train a DP-SGD target under the sampled knobs, save it in the standard
target-folder layout, and audit it with the real RMIA attack — shadow
models are retrained under the candidate's exact configuration. All user
settings live in dpsgd_optimization.yaml; validate_frontier re-audits the
Pareto points with more references, reusing the saved targets. Also
callable from the last cell of cifar_main.ipynb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Tcj6Aq9YZdQnC9T4Qnb7w
The LOS LR/GRU-D scripts were removed in the core rework (they scored the
frontier with a non-RMIA attack over fixed points); the stub README now
describes how to migrate them following examples/mia/cifar/dpsgd_optimization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Tcj6Aq9YZdQnC9T4Qnb7w
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@fazelehh fazelehh changed the title PET optimization: privacy-utility frontier campaigns DP-SGD privacy-utility optimization: RMIA-audited Bayesian search Aug 20, 2026
fazelehh and others added 2 commits August 20, 2026 12:44
Close the two review findings that survived the rework:

- Seed every RNG per trial from (seed, trial number), so a trial's
  training is reproducible regardless of how many trials ran earlier in
  the process (a resumed run previously trained different models than a
  fresh one at the same trial).
- Record the realized FPR at/below the proxy level on each trial: a TPR
  of 0 at realized FPR 0 is an unresolved operating point, not evidence
  of privacy, and the two were indistinguishable in the record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Tcj6Aq9YZdQnC9T4Qnb7w
The removed LOS scripts' story lives in the commit history and PR
discussion; a directory holding only a README earned its keep for a
week at most, since the LOS migration will follow the CIFAR reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Tcj6Aq9YZdQnC9T4Qnb7w
@fazelehh
fazelehh requested a review from TheColdIce August 20, 2026 13:29

@TheColdIce TheColdIce left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the four issues I consider blocking. Verified first: 15/15 tests pass, and ruff is clean over CI's scope (ruff check leakpro --exclude examples,leakpro/tests,leakpro/webapp), so that claim holds.

The design is right in the way that matters most — no attack or scoring is reimplemented, and test_tpr_matches_fixed_fpr_table pins the optimizer to the real MIAResult so the search and the report cannot drift apart. It also imports the live leakpro.reporting.mia_result rather than the stale leakpro.metrics.attack_result, so the key parsing is correct. The four findings below are about the measurement being fed into the search, not about that structure.

Findings 1 and 3 are confirmed by execution, not just by reading. Full review including 13 further medium/minor findings is in the session notes.

# A model that passed the gate can still saturate every RMIA score (no
# ROC). That is a measurable outcome — no membership signal resolvable —
# so report TPR 0 and flag it, rather than crashing the run.
degenerate = not result.fixed_fpr_table

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: an unresolvable operating point is handed to the optimizer as "perfectly private".

Two paths produce tpr = 0.0 for reasons that are not privacy:

  1. degenerate here (no fixed_fpr_table at all), and
  2. tpr_at_fixed_fpr itself, because MIAResult._get_result_fixed_fpr reads the table as max((tpr for fpr, tpr in ... if fpr <= fpr_target), default=0.0) — so when no threshold reaches the proxy FPR, the table honestly reports 0.

The comment on L240-243 states the hazard exactly right ("not evidence of privacy; record it so the two cases are distinguishable") and then returns the value as a legitimate objective anyway. Zero is the global minimum of the axis being minimized, so every such point is unconditionally Pareto-optimal — and worse, TPE is attracted to the region.

Confirmed with a synthetic objective through the real optimize(), where a sub-region returns utility=0.30, tpr=0.0:

trials in degenerate region: 9
front size: 25
degenerate on front: ['u=0.30,tpr=0.00' x 9]

Nine of 25 trials were spent in the degenerate region and all nine landed on the front. This is precisely the masquerade the utility gate exists to prevent, arriving through the privacy axis instead: the gate stops "it learned nothing, so it looks private", and this lets through "we could not measure it, so it looks private".

Suggested fix: return tpr=None (prune, exactly as the gate does) when degenerate is true or when realized_fpr is materially below cfg.proxy_fpr, keeping realized_fpr and degenerate_audit in extras for the record. The information is already computed on L244-247 — it just needs to change the decision rather than only the metadata.

Related: with splits.n_test = 2000 the FPR grid has resolution 1/2000, so proxy_fpr: 0.0001 (offered as a valid choice in the yaml comment and by PrivacyUtilityConfig) allows 0.2 false positives and reads 0 for every config, silently collapsing the whole search into pure utility maximization. Worth validating proxy_fpr against the tabulated levels and warning when n_test * proxy_fpr is below ~10.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fd9438e. New resolved_proxy_tpr() in leakpro/optimization/audit.py returns None when the audit is degenerate or the realized FPR falls below half the proxy, and the objective now prunes on it exactly like the utility gate — realized_fpr and degenerate_audit stay in the trial record so the pruning is auditable.

Also took your related point: the run now refuses to start when proxy_fpr isn't a tabulated level or n_test * proxy_fpr allows fewer than ~10 false positives. That meant --smoke had to move to the 10% level (300 nonmembers can't resolve 1%).

The smoke run hit this live: one of two trials came back degenerate and was pruned instead of landing on the front at x=0.

Comment thread leakpro/optimization/audit.py Outdated
RMIA reference (shadow) models through the *same* input handler and the *same*
stored training configuration as the target — optimizer and batch size from the
target metadata, DP-SGD noise and clipping from the target's ``dpsgd`` config —
so every reference model matches the candidate target it is used to attack.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: the shadow models do not mimic the target's training regime, and this is the PR's headline claim.

This docstring, the README ("every RMIA reference model mimics the target it attacks") and the PR body ("full mimicry") all promise that the references match the candidate. The optimizer/clipping/batch-size half of that is true. The training-set size is not.

AttackRMIA._prepare_shadow_models calls sample_indices_from_population(include_train_indices=True, include_test_indices=True), so shadow_population is the entire population — 20 000 points under the shipped yaml. ShadowModelHandler.create_shadow_models under balanced sampling then asserts np.sum(A, axis=1) == len(shadow_population) // 2, i.e. every reference model trains on 10 000 points while the target trained on 5 000 (splits.n_target), at the same batch size and the same 15 epochs.

For a DP-SGD-specific tool that is not a detail. The sampling rate q = B/N halves and the step count doubles, so the reference models sit at a different noise-per-example regime than the candidate they are calibrating. The RMIA null distribution is therefore built from models trained under a DP-SGD configuration the target never saw — which is exactly the thing the frontier is supposed to be measuring.

Two honest ways out:

  • ship pop_size = 2 * n_target in dpsgd_optimization.yaml (e.g. pop_size: 10000, n_target: 5000, n_test: 2000) so len(shadow_population) // 2 lands on the target's training-set size, and say in the README that this coupling is deliberate; or
  • keep the population as is and drop "exact configuration" / "full mimicry" from this docstring, search.py, the README and the PR body, replacing it with what actually holds: same architecture, same optimizer settings, same DP-SGD noise and clipping, larger training set.

The first is better — the claim is worth making true, since it is the reason to trust the privacy axis at all.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and I took the first option: pop_size is now 2 * n_target (10000/5000/2000 in the yaml, 600/300/300 in smoke), enforced in build_population before any data loads. I verified the mechanism you pointed at — expected_size = len(shadow_population) // 2 in ShadowModelHandler — and the coupling is documented as deliberate in the README and in this docstring, which now also states that the size dimension of mimicry is the caller's job, not automatic.

Confirmed in the smoke run: shadow metadata shows num_train: 300 = n_target.

Fixed in 18e1db7. This invalidates the earlier campaign numbers, so I'll re-run the 15-epoch recipe before asking for re-review.

"""Re-audit a frontier config's *already-trained* target with more RMIA references."""

def revalidate_fn(params: dict): # noqa: ANN202
trial_dir = _config_dir(run_dir, params)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: validation breaks as soon as any knob is pinned via fixed:.

_config_dir is hashed over two different dictionaries in the two call sites:

  • run_optimization.objective_fn passes config = knob_space.suggest(trial), which is searched knobs plus KnobSpace.fixed (knobs.py L105-107 does config.update(self.fixed)).
  • here it is trial.params, which Optuna populates with the searched parameters only — a fixed knob consumes no search dimension, so it is absent.

Different dicts, different hash, different directory. Confirmed by running both paths on the same trial with fixed={"batch_size": 128.0}:

full config dir : config_294d78cb8f
trial.params    : {'noise_multiplier': 1.4153814363626203}
params-only dir : config_27a9d30f1f
MATCH: False

So validate_frontier.py raises FileNotFoundError("No trained target for config ...") for every frontier point, and leakpro.optimization.validation.validate_frontier / proxy_agreement inherit the same defect since both call revalidate_fn(trial.params). It is latent only because the shipped yaml has fixed: {} — which also means the smoke run could not have caught it. Pinning one knob is an advertised feature (README: "pin any to a constant").

Suggested fix: record the resolved configuration on the trial inside optimize() (trial.set_user_attr("config", config), which is already the natural home given utility/tpr are stored there) and have validate_frontier / proxy_agreement pass trial.user_attrs["config"] to revalidate_fn, falling back to trial.params. That also fixes the core module's contract, which currently documents config -> MIAResult while handing over something that is not the config.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 217fc92, the way you suggested: optimize() records the resolved config with trial.set_user_attr("config", config), and validate_frontier / proxy_agreement now pass trial.user_attrs["config"] to revalidate_fn, falling back to trial.params for studies from older runs. So the core module's config -> MIAResult contract actually holds now.

Added a regression test that pins batch_size and checks both that the search records the full config and that validation hands it to the re-audit.

## Reading the results

The Pareto front is `study.best_trials`; each trial's `values` are
`(utility, TPR@1%)` and `trial.user_attrs` carries `epsilon` (the formal DP

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (documentation, but the most consequential): the reported epsilon is not the privacy cost of this procedure.

trial.user_attrs["epsilon"] comes from engine.get_epsilon(delta) for one training run. But the run tunes 40 configurations against the same private dataset and then a configuration is selected off the frontier. Hyperparameter selection on private data is itself a mechanism, so the epsilon of the winning run is not the budget of the procedure that produced it — see Liu & Talwar, Private Selection from Private Candidates (STOC 2019) and Papernot & Steinke, Hyperparameter Tuning with Renyi Differential Privacy (ICLR 2022). The tuned-and-selected configuration carries a strictly larger budget, and the frontier plot is what makes the selection.

This matters more than it usually would because of what LeakPro is. A user picks a point off frontier.png, reads eps=2.31 off the trial record, and writes that number into a DPIA. The tool will have supplied a formal-looking guarantee it does not have.

I am not asking for private selection to be implemented here — that is a research project, and the empirical TPR axis is the defensible output of this run. What I do think is required before merge:

  1. a caveat in this section stating plainly that the epsilon shown is per-configuration and does not account for the search over 40 configurations, so it must not be reported as the guarantee of a config chosen off this frontier;
  2. the same sentence in the notebook markdown cell, which is where most people will actually read the number; and
  3. ideally a tuning_accounted: false (or equivalent) field alongside epsilon in the trial record, so the caveat travels with the data rather than living only in prose.

Worth noting the honest half: the TPR axis has no such problem, and selection_bias in validate_frontier already measures the optimism the search introduces on it. That asymmetry — a measured axis with a bias diagnostic next to a formal axis with none — is what makes the missing caveat conspicuous.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6db7c53, all three parts: the caveat is in the README's results section (with the Liu & Talwar and Papernot & Steinke pointers), the same warning is in the notebook markdown cell next to where the eps is printed, and every trial now records tuning_accounted: false alongside epsilon so the caveat travels with the data.

Agreed on the asymmetry — the TPR axis with selection_bias next to a formal axis with nothing was the giveaway. Not implementing private selection here, as you said.

fazelehh and others added 5 commits August 25, 2026 13:16
trial.params holds only searched knobs; a knob pinned via KnobSpace.fixed
consumes no search dimension and is absent from it. _config_dir hashes the
full config at training time, so validate_frontier/proxy_agreement raised
FileNotFoundError for every frontier point as soon as any knob was pinned.

The search now records the resolved config (searched + fixed) as a trial
user attribute, and validation passes that to revalidate_fn, falling back
to trial.params for studies from older runs. Latent until now only because
the shipped yaml has fixed: {}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUNAX7xTsrwDAxhyGDpFqC
Two paths produced tpr=0.0 for reasons that are not privacy: a degenerate
audit (no fixed_fpr_table) and score ties so coarse that no threshold
reaches the proxy FPR. Zero is the global minimum of the minimized axis,
so every such trial was unconditionally Pareto-optimal and TPE was drawn
to the region — 'we could not measure it' masqueraded as 'perfectly
private', the same failure the utility gate exists to prevent.

resolved_proxy_tpr() (leakpro.optimization.audit) now returns None when
the audit is degenerate or the realized FPR falls below half the proxy;
the example prunes on it exactly like the gate, keeping realized_fpr and
degenerate_audit in the trial record. load_settings fails fast when
n_test * proxy_fpr allows < 10 false positives (which would prune every
trial), and --smoke moves to the 10% level its 300 nonmembers can resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUNAX7xTsrwDAxhyGDpFqC
…arget

RMIA's balanced shadow sampling trains every reference model on
pop_size // 2 points (ShadowModelHandler.create_shadow_models,
expected_size = len(shadow_population) // 2). With the shipped
pop_size=20000 / n_target=5000, references trained on 10000 points —
double the target's set — so their DP-SGD sampling rate q = B/N halved
and their step count doubled: the RMIA null distribution came from a
noise-per-example regime the target never saw, contradicting the
'full mimicry' claim the privacy axis rests on.

pop_size is now 2 * n_target in the yaml and in --smoke, enforced in
build_population before any data loads, and the coupling is documented
as deliberate in the README and the audit-bridge docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUNAX7xTsrwDAxhyGDpFqC
The accountant's epsilon covers one training run, but the run tunes
n_trials configurations against the same private data and a winner is
selected off the frontier — private selection is itself a mechanism
(Liu & Talwar, STOC 2019; Papernot & Steinke, ICLR 2022), so the
selected configuration's true budget is strictly larger than the ε on
its trial record. Left implicit, a user reads eps off frontier.png and
writes a guarantee the tool does not have into a DPIA.

The caveat now lives in the README's results section and the notebook
markdown cell, and every trial records tuning_accounted: false next to
epsilon so it travels with the data. The measured TPR axis is unaffected
(selection_bias in validate_frontier quantifies its search optimism).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUNAX7xTsrwDAxhyGDpFqC
The realized FPR of the saturated-tie case is 0.0 locally but 0.002 (one
grid step, 1/500) on CI's sklearn/numpy — roc_curve tie handling differs
across versions. The contract under test is only that the realized FPR
sits below half the proxy so the trial prunes; assert that instead of
the exact grid value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUNAX7xTsrwDAxhyGDpFqC
@fazelehh

Copy link
Copy Markdown
Collaborator Author

All four blocking findings are fixed (217fc92, fd9438e, 18e1db7, 6db7c53 — each thread has a reply with details), CI is green, and since the shadow-size fix changes the attack regime I re-ran the full 15-epoch campaign from scratch before asking for another look.

Re-run (40 trials, corrected shadow regime — references now train on 5000 points = n_target):

  • 36 completed, 4 pruned: 1 by the utility gate (acc 0.09) and 3 as unresolved audits — models that passed the gate but saturated every RMIA score. Under the old code those 3 would be on the front at TPR = 0.
  • Pareto front: 6 points, monotone in ε — acc 0.33 / TPR@1% 0.006 / ε 1.6 up to acc 0.49 / TPR@1% 0.018 / ε 35.

Validation (validate_frontier.py --n-shadow 16):

  • selection_bias per point is small and mixed-sign (−0.003 to +0.008); the re-validated TPR@1% stays in 0.012–0.022, so the loop's numbers hold up.
  • proxy_agreement: rho = 0.37 (p = 0.47) over 6 configs. Being upfront about this one: it is inconclusive rather than a pass. TPR@0.1% on 2000 nonmembers is a ~2-false-positive tail estimate and the six points' tail TPRs span only 0.001–0.004, so the ranking is mostly noise. The defensible reported level for this recipe is TPR@1%; making 0.1% meaningful needs a larger n_test, which I'd treat as a config choice for real runs rather than something to hardcode here.

Ready for another look when you have time.

@fazelehh
fazelehh requested a review from TheColdIce August 26, 2026 10:12

@TheColdIce TheColdIce left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 6274a665. All four blocking findings are fixed, and I verified each rather than taking the replies at face value: the unresolved-audit prune reproduces on a saturated tie block against the real MIAResult; the pop_size = 2 * n_target coupling holds end to end (expected_size = len(shadow_population) // 2 in ShadowModelHandler, shadow training routed through dp_train with the trial's own dpsgd_dic.pkl, and target_model_hash in the reuse signature so no shadow set is shared across trials); the resolved-config plumbing works with a pinned knob; and the ε caveat is in all three places including the pruned path. 19/19 tests pass, ruff clean over CI's scope, notebook is valid JSON.

Two things below. The first is a residual on the fix for blocking finding 1 (inline on audit.py). The second is not your bug at all, but it lands on this PR's privacy axis, so it belongs in the same conversation.


Not this PR: LeakPro's ROC mis-counts tied scores

While tracing what realized_fpr actually means I read MIAResult._compute_confusion_arrays (leakpro/reporting/mia_result.py:170) and it has an off-by-one-block error. It predates this PR by a year (c6e7ea4b, 2025-03-24) and affects every attack, but ties are exactly what DP-SGD saturation produces, so it degrades the axis you are optimizing.

_, first_indices = np.unique(sorted_scores, return_index=True)
first_indices = first_indices[::-1]
self.tp = tp_cumsum[first_indices]
self.fp = fp_cumsum[first_indices]

np.unique(..., return_index=True) gives the first occurrence of each value. In a descending array that is the top of a tie block, and tp_cumsum[i] counts through i inclusive — so each vertex counts everything above the block plus exactly one arbitrary element of it. That operating point is unreachable: you cannot admit one member of a tie and reject its twins.

Scores [9,9,5,5], labels [1,0,1,0]:

leakpro tp : [1 2]   fp: [0 1]      fpr: [0.00 0.50]
correct tp : [1 2]   fp: [1 2]      fpr: [0.50 1.00]
leakpro auc: 0.3750   sklearn auc: 0.5000

A phantom vertex appears near the origin, the curve never reaches (1,1), and a coin-flip attack scores AUC 0.375.

On saturation-shaped data (120 members + 60 nonmembers tied at a clamp value, 2000/2000):

FPR leakpro correct
0.01% 0.0005 0.0000
0.1% 0.0005 0.0000
1% 0.0005 0.0000
10% 0.2065 0.2065

And the value is not stable, because it depends on which tied point argsort puts first. Same data, six reshuffles:

TPR@1% over 6 shuffles: [0.0005, 0.0005, 0.0, 0.0, 0.0005, 0.0]

TPR@0%FPR — a headline column in the LaTeX table at mia_result.py:501 — is this artifact in pure form: with any tie block at the top score it reports a coin flip on sort order.

With all-distinct scores every block has size 1, first index equals last index, and the code is exactly right. So this is invisible on continuous signals and bites on clamped or quantized ones.

Fix is to snapshot the end of each block:

last_indices = np.r_[first_indices[1:] - 1, len(sorted_scores) - 1]
self.tp = tp_cumsum[last_indices]
self.fp = fp_cumsum[last_indices]

Checked on 200 randomized heavy-tie cases against roc_curve(..., drop_intermediate=False): the current code produces a different vertex set in 200/200, the patched version matches in 200/200.

This is a separate issue and should not block this PR — I am not asking you to fix mia_result.py here. Two consequences that do touch it, though: the realized_fpr your new guard checks is itself computed from these vertices and is slightly understated, and any TPR@0%FPR or TPR@0.01%FPR read off a saturated audit is currently sort-order noise. Happy to file the issue and the patch separately unless you would rather take it.

Comment thread leakpro/optimization/audit.py Outdated
fpr = np.asarray(result.fpr, dtype=float)
at_or_below = fpr[fpr <= proxy_fpr]
realized_fpr = float(at_or_below.max()) if at_or_below.size else 0.0
if degenerate or realized_fpr is None or realized_fpr < min_realized_fraction * proxy_fpr:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 0.5 tolerance reproduces the blocking bug in bounded form.

An accepted trial can have its TPR read at half the requested FPR and reported as if it were at the proxy. tpr_at_fixed_fpr returns max(tpr for fpr <= target), i.e. the TPR at realized_fpr, and this guard lets realized_fpr sit anywhere in [0.5 * proxy, proxy]. So the objective handed to TPE is measured at a different operating point in every trial, under a label that says they are the same one.

Probed against the real MIAResult, 2000/2000, proxy 1%. None of these are pruned:

case realized FPR next vertex reported TPR ROC at 1% understated
wide straddling segment 0.60% 6.60% 0.0200 0.0300 1.5x
narrow, member-heavy 0.60% 1.10% 0.0205 0.2601 12.7x
narrow, member-heavier 0.60% 1.05% 0.0105 0.4105 39.1x
narrow, nearly all mass 0.50% 1.10% 0.0030 0.5026 167x

The structural point: the 2x tolerance on FPR does not bound the error on TPR. The bound is set by how much member mass sits in the single ROC segment straddling the proxy, and a vertical jump there is unbounded. The last row is a config leaking half its members at 1% FPR and reporting 0.3%. These are synthetic constructions and I did not observe one on CIFAR — but a tie block straddling the proxy is the normal shape under DP-SGD saturation, which is what config 8 showed in its terminal form.

Why it matters more than ordinary measurement noise:

  1. The error only points down. TPR(realized) <= TPR(proxy) since the ROC is monotone. Nothing is ever overstated by this.
  2. The axis is minimized, so a downward error is indistinguishable from privacy, and TPE then samples more densely around it.
  3. The bias correlates with the knob being searched. The tied fraction grows with the noise multiplier, so high-noise trials are systematically the most understated ones — and the front is a min over ~40 draws of that quantity.

A bigger audit set does not help, which surprised me until I checked. Saturation ties a fraction of the audit set, so the gap scales with it. Same score distribution at three sizes:

regime n=2000 n=20000 n=200000
ties are a fixed fraction (saturation) 12.7x 14.2x 14.3x
all scores distinct exact exact exact
tie block of fixed count (21 points) 14.0x exact exact

Flat across two orders of magnitude. MIN_PROXY_EVENTS correctly handles the finite-audit-set half of the problem; this half it cannot reach.

Options, in the order I would rank them:

  1. Interpolate at exactly proxy_fpr for the objective, keep the table value in extras. The interpolated (randomized-threshold) ROC is the conventional reading of "TPR at 1% FPR", is continuous, and is bit-identical to the vertex rule on distinct scores. I take the point from the 20 Aug thread that changing fixed_fpr_table is a leakpro/metrics decision affecting all attacks — this keeps that rule untouched and only changes what the search consumes, with report parity preserved as a recorded field rather than as an identity.
  2. Raise min_realized_fraction toward ~0.9 and expose it in the yaml. Keeps parity exactly, bounds the FPR shortfall to 10%, but as the table above shows it does not bound the TPR error — and each extra prune costs a full target plus its shadow set.
  3. At minimum, surface realized_fpr on frontier.png and in the selection-facing output, so nobody reads a frontier point without seeing that its operating point moved.

I would ask for 1. It is the smallest change that makes the numbers being compared actually comparable, and it retires the finding instead of bounding it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took option 1, exactly as ranked: the objective is now interpolated_tpr_at_fpr() — linear interpolation on the ROC at exactly proxy_fpr, with the trivial (0,0)/(1,1) endpoints added — so every trial is measured at the same operating point. The half-proxy tolerance is gone; pruning remains only for degenerate audits (no ROC). The fixed-FPR table value stays on every trial as tpr_fixed_fpr_table for parity with LeakPro reports, and validation measures with the same rule so selection_bias compares like with like. Fixed in 2be4a14.

Re-ran the full campaign (per-trial seeding reproduces the same 40 models, so the only variable is the estimator). Your finding shows up on real CIFAR, not just synthetically: 4 of 36 completed trials diverged, worst was trial 25 — table read 0.0100 at realized FPR 0.55%, interpolated 0.0145 at 1%, a 45% understatement. The frontier itself is unchanged (all six front points happen to have vertices at the proxy).

One number you'll like: proxy_agreement went from rho 0.37 (p 0.47) with table reads to rho 0.83 (p 0.042) with interpolation — the earlier 'inconclusive' rho was largely the operating-point mismatch you diagnosed, not a real proxy failure.

Caveat you already know: on tied scores the interpolation runs over the vertex set your other finding shows is mis-placed, so until that lands the interpolated value inherits a (conservative, upward) distortion on tie blocks. On that note — took the mia_result fix too: #465, with your patch, credit, and regression tests against sklearn.

…oxy FPR

The fixed-FPR table reads each trial at its own realized FPR, anywhere
below the proxy under tied scores, and the resulting TPR understatement
is unbounded — it equals the member mass inside the ROC segment
straddling the proxy. The error only points down, the axis is minimized,
and the tied fraction grows with the noise multiplier being searched, so
TPE would sample most densely exactly where the measurement is worst.
The previous half-proxy tolerance on realized_fpr bounded the FPR
shortfall but not the TPR error, reproducing the original blocking
finding in bounded form.

The objective is now interpolated_tpr_at_fpr() — the conventional
randomized-threshold reading, evaluated at the same operating point for
every trial and identical to the vertex value whenever a vertex exists
at the proxy. Pruning remains only for degenerate audits (no ROC).
Validation measures with the same rule, so selection_bias compares like
with like; the fixed-FPR table value is kept per trial as
tpr_fixed_fpr_table for parity with LeakPro reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeK6CQMD4wbW7LqWcdLdo4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants