fix: honor training_data_fraction when training shadow models - #441
Conversation
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
henrikfo
left a comment
There was a problem hiding this comment.
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! :)
… 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
|
@henrikfo All four points addressed. Every one still reproduced against current Branch first merged
Two things worth your attention before approving. I fixed a pre-existing bug inside this PR. The BASE assert already fails on LiRA drops points, BASE raises. Deliberate asymmetry. Not done, flagging rather than fixing: Verification: 440 passed, ruff clean, on |
Closes #345.
Problem
construct_balanced_assignments()always assigned exactly half of the shadow population to every shadow model, so anytraining_data_fractionother than 0.5 was silently ignored. Worse: the cache signature records the fraction-deriveddata_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()takespoints_per_model(the samedata_sizethe cache signature records) and uses greedy least-loaded selection with random tie-breaking.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 onint(0.75 * population)points and a re-run with the same fraction reuses the cache instead of retraining. Both fail on the old code.leakpro/tests/mia_attacks/suite passes (49/49).🤖 Generated with Claude Code
https://claude.ai/code/session_01U4j7ifPMD8R2Mgd7om97F2