Skip to content

fix: honor training_data_fraction when training shadow models - #441

Merged
fazelehh merged 6 commits into
mainfrom
fix/shadow-model-training-fraction
Sep 21, 2026
Merged

fazelehh merged 6 commits into
mainfrom
fix/shadow-model-training-fraction

Conversation

@fazelehh

Copy link
Copy Markdown
Collaborator

Closes #345.

Problem

construct_balanced_assignments() always assigned exactly half of the shadow population to every shadow model, so any training_data_fraction other than 0.5 was silently ignored. Worse: the cache signature records the fraction-derived data_size, while the models were actually trained on the half-population size — so with fraction ≠ 0.5 cached shadow models never matched their signature and every run retrained from scratch (the behavior reported in #345, and one of the two causes behind #432).

Fix

  • construct_balanced_assignments() takes points_per_model (the same data_size the cache signature records) and uses greedy least-loaded selection with random tie-breaking.
  • Every model gets exactly the requested number of points, and each point's inclusion count stays within 1 across models — the balanced IN/OUT property (Shadow model datasplit #334) now holds for any fraction, not just 0.5.
  • Default stays at half the population, so existing caches for fraction 0.5 remain valid.

Tests

  • test_construct_balanced_assignments_respects_points_per_model: exact per-model sizes and ≤1 inclusion-count spread for fractions 0.3 / 0.5 / 0.72 / 1.0, plus rejection of degenerate sizes.
  • test_shadow_model_training_fraction_is_used_and_cached: shadow models train on int(0.75 * population) points and a re-run with the same fraction reuses the cache instead of retraining. Both fail on the old code.
  • Full leakpro/tests/mia_attacks/ suite passes (49/49).

🤖 Generated with Claude Code

https://claude.ai/code/session_01U4j7ifPMD8R2Mgd7om97F2

construct_balanced_assignments() always put exactly half of the shadow
population in every model, so any training_data_fraction other than 0.5
was silently ignored. Worse, the cache signature records the
fraction-derived data_size while the trained models recorded the actual
half-population size, so with fraction != 0.5 cached shadow models
never matched and every run retrained from scratch.

The assignment now takes points_per_model (the same data_size the cache
signature records) and uses greedy least-loaded selection with random
tie-breaking: every model still gets exactly the requested number of
points, and each point's inclusion count stays within 1 across models,
preserving the balanced IN/OUT property for any fraction. The default
remains half the population, so existing caches for fraction 0.5 stay
valid.

Closes #345.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4j7ifPMD8R2Mgd7om97F2
@fazelehh
fazelehh requested a review from henrikfo August 13, 2026 14:19
Comment thread leakpro/attacks/utils/shadow_model_handler.py
Comment thread leakpro/attacks/utils/shadow_model_handler.py

@henrikfo henrikfo 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.

Some changes/ additions is needed to make sure all attacks for as intended I think.

Also added some thoughts on making these leakpro changes a little bit more robust, not needed for approval though! :)

fazelehh and others added 5 commits September 21, 2026 08:44
… redrawing it

construct_balanced_assignments started its greedy pass from zero counts every time, and
create_shadow_models then handed the new models the first rows of that fresh design. The
memberships of cached models were never consulted, so a run that resumed after a crash, or
one that simply raised num_shadow_models, produced a second independent balanced design.
The union of the two is only balanced within 2, which is not what the balanced IN/OUT
property (#334) promises.

The greedy pass now accepts prior_inclusion_counts, and create_shadow_models seeds it from
the cached models' stored train_indices and generates rows only for the models it is about
to train. Cached and new models together keep the within-1 property.

Also reject degenerate training fractions before any model is trained: 0 leaves a shadow
model with no data, and 1 puts every point in every shadow model, so no point has an OUT
reference model and every reference-based attack degenerates into a NaN score.

Adds audit_points_with_reference_models, the shared predicate for "this point has the
shadow models the attack needs", used by the LiRA attacks in the following commit. yoqo.py
and dts.py carry two verbatim copies of the same logic and can adopt it separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXREv9MsTCe4HcEDvAqMPd
…scoring them as NaN

LiRA fits the OUT Gaussian on the shadow models that did not train on a point, and the IN
Gaussian (online only) on those that did. A point missing either side gives a mean over an
empty slice, so the score is NaN and the attack dies on a bare ValueError("Score is NaN")
that says nothing about the cause.

This was unreachable while every shadow model took exactly half the population, because
each point was then IN for one model of every pair and OUT for the other. Once the training
fraction is honored it is reachable: the per-point membership counts sit within 1 of
num_shadow_models * training_data_fraction, so a fraction above 1 - 1/num_shadow_models
leaves points in every model, and one below 1/num_shadow_models leaves points in none.
With 2 shadow models at fraction 0.7, 40% of audit points have no OUT model.

Both LiRA variants now drop those points and warn with the counts and the fraction that
produced them, mirroring the filtering yoqo.py and dts.py already do. MS-LiRA had this
filtering on its online path only; its offline path needed it too and did not have it.

Dropping is a last resort, not a silent fix, so the warning names the configuration: under
balanced sampling a non-empty drop set means the fraction is wrong for this number of
shadow models, and the count will be large rather than incidental.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXREv9MsTCe4HcEDvAqMPd
…ow models

Offline BASE asserted that every audit point has exactly num_shadow_models // 2 OUT models.
That encoded the old fixed half-and-half split rather than anything the attack needs: the
threshold is logsumexp(out_logits) - log(n_out_models), the log mean confidence over the
models that did not train on the point, which is well defined for any positive count.

The assert was wrong twice over. It rejects every training_data_fraction other than 0.5,
which is what this branch makes configurable. It also already fails on main for any odd
num_shadow_models at fraction 0.5, where the pair-partition assignment leaves counts
straddling the half: with 3 shadow models the OUT counts are 1 and 2, and the assert
demands 1. Verified against origin/main.

Replaced by a check that no scored point is left without an OUT model, raising an error
that names the fraction and the model count. The check also guards score_samples, which
RaMIA drives, where dropping points would break the range clusters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXREv9MsTCe4HcEDvAqMPd
Every shadow-model attack accepted ge=0.0, le=1.0. A fraction of 1 trains every shadow
model on every point, so no point has an OUT reference model and LiRA, RMIA, BASE and the
rest have nothing to calibrate against; a fraction of 0 leaves nothing to train on. Neither
is a configuration any of these attacks can use, so both are now rejected at config
validation, alongside the same guard in the shadow model handler.

qmia.py keeps ge/le: its training_data_fraction subsamples the attack data for the quantile
regressor and does not drive shadow-model membership, so 1.0 is meaningful there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXREv9MsTCe4HcEDvAqMPd
@fazelehh

fazelehh commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

@henrikfo All four points addressed. Every one still reproduced against current main, so none had aged out. Details are in the three threads; summary here.

Branch first merged origin/main (it was cut before the get_device/seed work landed), then four commits:

Commit Your point What changed
89138f4 cached models and resume construct_balanced_assignments takes prior_inclusion_counts; create_shadow_models seeds it from cached models' train_indices and generates rows only for new models
a3f5059 LiRA NaN with fraction > 0.5 Shared audit_points_with_reference_models predicate; LiRA and MS-LiRA drop unscorable points and warn with the counts and the fraction
c82101b BASE forces 0.5 Both num_shadow_models // 2 asserts replaced with a positive-OUT-count check
d62b56e is 1.0 allowed gt=0.0, lt=1.0 across the eight shadow-model attack configs, plus the same guard inside the handler

Two things worth your attention before approving.

I fixed a pre-existing bug inside this PR. The BASE assert already fails on main for any odd num_shadow_models, independent of this branch. I kept the fix here because it is the same assert that blocks the feature, but say the word and I will split it into its own PR against main.

LiRA drops points, BASE raises. Deliberate asymmetry. score_samples is driven by RaMIA over range clusters, and silently dropping points there would change the clusters rather than just the sample size. Pushed back slightly on the yoqo/dts precedent in one respect: the drop is logged as a warning naming the configuration, because under balanced sampling a non-empty drop set means the fraction is wrong for the model count, so it is structural rather than incidental. A silently smaller audit set understates the result.

Not done, flagging rather than fixing: yoqo.py and dts.py still carry two verbatim copies of the filtering block. Correcting myself here, since I first wrote that switching them over was mechanical and had not checked. It is not, in general. Neither attack computes a per-point OUT statistic: yoqo uses the mask as per-model loss weights and sets them to all-ones offline, and dts uses it only for the online filter and an offline log line. So neither can hit the empty-slice NaN the predicate exists to prevent, and their online-only filtering is correct for them. A behaviour-preserving dedup is still available, about six lines, but only with the call kept inside the if self.online branch. Adopting the predicate the way the sentence above implied would make them drop points they can legitimately score. Worth a separate PR at most.

Verification: 440 passed, ruff clean, on d62b56e in leakpro_py311. Eleven new tests, including two that fail on the old code. Two failures excluded as environment issues, both confirmed identical on origin/main: four collection errors from missing transformers/diffusers extras, and test_aux_file_path, which hardcodes the repo directory name and fails in any worktree.

@henrikfo
henrikfo self-requested a review September 21, 2026 11:45
@fazelehh
fazelehh merged commit ecc4de2 into main Sep 21, 2026
1 check passed
@fazelehh
fazelehh deleted the fix/shadow-model-training-fraction branch September 21, 2026 12:11
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.

Not reusing shadow models

2 participants