Conversation
…ashes hash_model called .numpy() on every state-dict tensor, which raises TypeError for bfloat16 — the usual dtype of fine-tuned LLM checkpoints. Since AbstractMIA._hash_attack hashes the target for every attack, no LLM target could be audited at all. Flatten and reinterpret each tensor as uint8 instead. For every dtype numpy already supported this produces the exact same bytes as before, so no cached attack id, shadow model or logit cache is invalidated; the new test pins this against the legacy byte path for a Linear model, a BatchNorm model (whose 0-dim num_batches_tracked buffer is why the reshape(-1) is needed) and a non-contiguous parameter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018czjKibgtK26Fme7A4w5EV
…ction AttackFactoryMIA.create_attack built a ShadowModelHandler and a DistillationModelHandler for every attack before even looking up the attack class. ModelHandler.__init__ hashes the full target state dict and caches a dense forward pass over the audit set, so attacks that train nothing (HSJ, the population attack, and the upcoming LLM reference-model attacks) paid for machinery they never used — and for a language model that dense (N, T, vocab) cache is not merely wasteful but infeasible. Add requires_shadow_models / requires_distillation_models class flags on AbstractMIA, defaulting to True, and have the factory consult them on the class before instantiation. Every existing attack inherits the defaults, so its handler construction is unchanged; a regression test pins that. The factory now also rejects an unknown attack name before constructing anything. Also extract the PytorchModel wrapping of the target into an overridable _wrap_target_model hook so a subclass can supply a different Model implementation for non-classifier targets. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018czjKibgtK26Fme7A4w5EV
Every membership-inference attack against fine-tuned LLMs (EZ-MIA, WBC, LOSS, Reference-Loss, Min-K%, Min-K%++) reduces the same per-token quantities: the log-probability of each realised token, the model's top-1 prediction at that position, and a validity mask. Extract exactly those and nothing else. The dense (batch, seq, vocab) logits are reduced on-device and never returned — at realistic sizes that tensor is hundreds of gigabytes, which is why the existing PytorchModel / cache_logits path cannot be reused for language models. CausalLMModel implements the existing Model ABC directly rather than subclassing PytorchModel, whose positional single-tensor call, bare (batch, classes) assumption, output-retaining forward hooks and per-batch device moves are all wrong for a HuggingFace causal LM. Model outputs are duck-typed so leakpro core never imports transformers. TokenEvidence arrays are already shifted (logprob[i, j] scores token_ids[i, j], original position j+1); the tests pin that convention against a direct log_softmax computation on a pure-torch toy LM, and check padded and unpadded encodings of the same sequence agree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018czjKibgtK26Fme7A4w5EV
Add leakpro.attacks.mia_attacks.llm, a subpackage holding one module per LLM attack, and its AbstractLLMMIA base. The base owns everything the attacks in this family share so each attack file is just a reduction: - an attack-side DataLoader built from handler.get_dataset with CausalLMCollate. The handler's stored dataloader parameters are the ones the model was fine-tuned with and may yield dict batches, and MIAHandler.get_dataloader offers no way to swap the collate. - frozen reference models declared *inside* the attack config (pretrained base checkpoint, the target itself, or a re-initialised copy). Living in the attack config means a different reference gives a different attack hash and is recorded in the result metadata; a top-level config section would do neither. - rank_top(), which maps "must be classified as a member" edge cases (EZ-MIA's N == 0 and zero-error sequences) and any non-finite score to finite values strictly above every ordinary score. MIAResult's descending-sort assert fails on nan and on two or more inf values. These attacks train nothing, so the base opts out of the shadow and distillation handlers and wraps the target as a CausalLMModel. Tests run against a fake handler and a pure-torch toy LM; no transformers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018czjKibgtK26Fme7A4w5EV
Two training-free, reference-based membership-inference attacks, each a NumPy reduction over the per-token evidence AbstractLLMMIA provides. EZ-MIA (Ilić et al., arXiv:2601.12104) restricts attention to positions the target mispredicts and scores the ratio of upward to downward log-probability movement relative to the pretrained reference. The paper's edge cases — N == 0 and sequences with no error positions — are its strongest member signal; they are routed through rank_top so they rank above every ordinary score without emitting inf. The default aggregation is the paper's P/N; the ablated alternatives are config options. WBC (Chen et al., arXiv:2601.02751) slides windows of geometrically spaced sizes and votes on the sign of the windowed loss difference. The paper's l^R - l^T over losses equals lp^T - lp^R over log-probs, so both attacks share one delta; a test pins that identity. The paper never defines windows longer than the sequence; here they are skipped for that sequence, with a whole-sequence window as the fallback. Tests use hand-worked examples and a fake handler with a pure-torch toy LM, so nothing here needs transformers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018czjKibgtK26Fme7A4w5EV
Wire both LLM attacks into AttackFactoryMIA under the keys "ez_mia" and "wbc". test_attack_factory_attack_list_is_covered requires every factory key to have end-to-end coverage, so the harness gains a text branch: a tiny pure-torch causal LM target, a fixed-length token population, a TinyTextInputHandler, and a _build_attack_config branch that injects a random_init reference — a required nested field the schema walker cannot synthesise, and deliberately not `self`, which would give delta == 0 and a vacuous pass. The factory gate test now exempts AbstractLLMMIA subclasses, which opt out of the auxiliary handlers by design. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018czjKibgtK26Fme7A4w5EV
The LLM attacks themselves need only torch; transformers, datasets, accelerate and peft are for the example that fine-tunes and loads HuggingFace models. Follows the existing mia / minv / synthetic extras. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018czjKibgtK26Fme7A4w5EV
…sweep examples/mia/llm_mia follows the other MIA examples' split: a role="data" handler supplying the tokenised UserDataset, a role="model" handler with full and LoRA fine-tuning, a prepare_target.py that writes target_model.pkl + model_metadata.pkl through LeakPro.make_mia_metadata, and run_audit.py wiring LeakPro(LLMDataHandler, audit.yaml, model_handler=LLMModelHandler). The attacks audit a saved model; they never fine-tune their own target. Defaults reproduce the EZ-MIA paper's WikiText-103 / GPT-2 / 128-token setting. sweep_report.py composes MIAResult.load, create_roc_plot and create_results over several runs so the paper's headline result — the LoRA-vs-full gap in TPR at low FPR — comes out as one combined ROC and table with no new reporting code; verified end-to-end on the tiny text harness. The HF wrapper stores its constructor arguments under their own names (get_model_init_params needs that to round-trip), saves CPU tensors, and merges LoRA adapters before saving so the plain wrapper reloads them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018czjKibgtK26Fme7A4w5EV
Muhaddisabarat
left a comment
There was a problem hiding this comment.
Automated review of the LLM MIA integration (EZ-MIA + WBC). Overall the seam design (requires_shadow_models/requires_distillation_models, AbstractLLMMIA base) is solid and the infra changes (hash_model, factory flags) look correctly regression-tested. Found 7 issues below, two of which are correctness bugs that will silently corrupt results rather than error out — flagging those as the priority before merge.
| AbstractMIA.population = handler.population | ||
| AbstractMIA.population_size = handler.population_size | ||
| AbstractMIA.target_model = PytorchModel(handler.target_model, handler.get_criterion()) | ||
| AbstractMIA.target_model = self._wrap_target_model(handler) |
There was a problem hiding this comment.
Correctness — shared class attribute overwritten across attacks.
AbstractMIA.target_model = self._wrap_target_model(handler) assigns to a class attribute, not an instance attribute. If an audit.yaml lists an LLM attack (ez_mia/wbc) alongside a regular classifier attack (e.g. dts/yoqo/oslo) in the same attack_list, whichever attack is constructed last overwrites target_model for all attack instances built in that run.
AbstractLLMMIA._wrap_target_model returns a CausalLMModel whose get_logits/get_grad/get_intermediate_outputs unconditionally raise NotImplementedError — but the classifier attacks call exactly those methods on self.target_model. So mixing an LLM attack with a classifier attack in one run crashes one of them (or silently scores against the wrong model, depending on order). Worth either making this per-instance, or having the factory/scheduler refuse to mix LLM and non-LLM attacks in one run.
There was a problem hiding this comment.
Fixed in cb8239f. Root cause was older than this PR: the class body defines both target_model = None and a target_model property (the property wins), but __init__ then did AbstractMIA.target_model = wrapper, which replaced the property object on the class with a plain shared value. The wrapper is now stored only on the instance (self._target_model) and the property returns it; nothing outside the class read AbstractMIA.target_model directly. New test test_mixed_llm_and_classifier_attacks_keep_their_own_target_wrapper builds an RMIA attack and an LLM attack on one handler in both orders and checks each keeps its own wrapper type. Existing RMIA/LiRA suites still pass.
There was a problem hiding this comment.
Follow-up: the same property-clobbering pattern still exists for population, population_size, audit_dataset and handler on AbstractMIA. Harmless today (one handler per run) but the same trap; tracked separately in #462 rather than widening this PR.
|
|
||
| """ | ||
| scores = np.asarray(scores, dtype=np.float64) | ||
| forced = np.asarray(force_top, dtype=bool) | ~np.isfinite(scores) |
There was a problem hiding this comment.
Correctness — rank_top inverts ranking for reachable -inf scores, not just the intended edge case.
forced = np.asarray(force_top, dtype=bool) | ~np.isfinite(scores) forces any non-finite score to the top, not only the paper's N==0/n_err==0 "must be a member" rows.
ez_mia.py's log_ratio aggregation computes raw = np.log(P) - np.log(N), which is -inf whenever P==0 and N>0 — a normal, reachable case, not one of the documented edge cases. A -inf log_ratio should mean the weakest member signal (low P), but this forces it to the top, i.e. the opposite ranking of the mathematically-equivalent ratio aggregation for the same row. That breaks the documented log_ratio/ratio ROC-equivalence invariant and silently corrupts output — worth restricting the force-top condition to the specific documented edge case rather than blanket non-finite.
There was a problem hiding this comment.
Fixed in 4db114b. rank_top now forces only the documented force_top rows plus +inf; -inf is placed just below the lowest ordinary score (it is the weakest signal, e.g. log(P/N) with P == 0), and a nan outside forced rows raises instead of being silently ranked. The ratio/log_ratio equivalence test now includes P == 0, N > 0 rows and asserts they rank lowest under both aggregations.
| def _reinitialise(module: nn.Module) -> None: | ||
| """Re-run ``reset_parameters`` on every submodule that defines it (random-init reference).""" | ||
| for child in module.modules(): | ||
| reset = getattr(child, "reset_parameters", None) |
There was a problem hiding this comment.
Correctness — random_init reference doesn't randomize GPT-2's attention/MLP weights.
_reinitialise only calls reset_parameters() on submodules that define it. HuggingFace's Conv1D (used for GPT-2's c_attn/c_proj/c_fc — the bulk of the model) has no reset_parameters, so it's silently skipped. Against the shipped GPT-2 example, a source: random_init reference only randomizes embeddings/LayerNorm; the attention/MLP matrices stay pretrained, making the "random" reference nearly identical to the pretrained one with no error or warning — this would invalidate that ablation. The unit test uses a toy nn.Linear-based model, which does have reset_parameters, so it doesn't catch the Conv1D gap. Might be worth falling back to nn.init on parameters directly for modules without reset_parameters, or at least warning when a submodule is skipped.
There was a problem hiding this comment.
Fixed in 4db114b. _reinitialise still calls reset_parameters where it exists; for modules that own parameters but define no reset (HF Conv1D) it now applies N(0, 0.02) to weights and zeros to biases — GPT-2's own init — and logs which module types took the fallback. Test test_reinitialise_randomises_modules_without_reset_parameters uses a Conv1D-like module with no reset_parameters and checks its weights change.
| """Run the target and every configured reference over ``indices``. | ||
|
|
||
| Models are run one after another; each reference is offloaded to CPU after its pass so at most | ||
| one large model is resident on the device at a time. |
There was a problem hiding this comment.
Docstring vs. behavior — target model is never offloaded, so peak memory is target+reference, not just one model.
This docstring says "each reference is offloaded to CPU after its pass so at most one large model is resident on the device at a time," but the target model is scored first (target = self.target_model.evidence_from_loader(...) below) and is never offloaded. During each reference's forward pass, target + that reference are both on-device simultaneously — double the documented peak memory, in exactly the large-model scenario this offload mechanism is meant to protect. Either offload the target between passes too, or fix the docstring so users don't under-provision GPU memory based on it.
There was a problem hiding this comment.
Fixed in 4db114b. The target is now offloaded after its pass as well (_score_model offloads whatever it scored), so at most one model is resident during the reference passes; the docstring matches the behaviour.
| TokenEvidence over all rows, padded to the longest batch. | ||
|
|
||
| """ | ||
| if isinstance(loader, DataLoader) and getattr(loader.sampler, "shuffle", False): |
There was a problem hiding this comment.
Correctness — shuffle guard is dead code, never fires.
getattr(loader.sampler, "shuffle", False) always evaluates False — neither RandomSampler nor SequentialSampler exposes a .shuffle attribute in stock PyTorch, so this check can never catch a shuffled loader despite the docstring's explicit safety claim ("the loader must not shuffle: row order is the caller's link back to population indices"). If any future caller passes shuffle=True, evidence rows would silently be mismatched to the wrong population indices with no exception raised.
leakpro/utils/conversion.py:50 already uses the correct pattern (isinstance(sampler, RandomSampler)) elsewhere in this codebase — suggest reusing that here.
There was a problem hiding this comment.
Fixed in 4db114b — now isinstance(loader.sampler, RandomSampler), the same check utils/conversion.py uses. Test test_evidence_from_loader_rejects_shuffled_loader confirms a shuffle=True loader raises.
| return DataLoader(dataset, batch_size=self.configs.batch_size, shuffle=False, | ||
| collate_fn=CausalLMCollate(pad_token_id=self.configs.pad_token_id)) | ||
|
|
||
| def evidence(self: Self, indices: np.ndarray, request: Optional[EvidenceRequest] = None) -> TokenEvidenceSet: |
There was a problem hiding this comment.
Efficiency — target/reference forward passes are duplicated across attacks in the same run.
evidence() has no caching, so EZ-MIA and WBC (both AbstractLLMMIA subclasses) each independently redo full forward passes over the target and every reference model for the whole audit set. The shipped examples/mia/llm_mia/audit.yaml runs both attacks against the same target and same reference (gpt2), so the scheduler computes identical forward passes twice back-to-back — for a large model this doubles GPU time for redundant work. LLM attacks opt out of the factory's existing shared-handler cache (requires_shadow_models=False) that other attack types already benefit from — might be worth a similar cache keyed on (model, dataset indices) for the LLM path, even if it's a follow-up rather than blocking this PR.
There was a problem hiding this comment.
Addressed in 4db114b with an in-memory memo rather than a disk cache: evidence() memoises TokenEvidence per handler, keyed on (model, need_moments, sha256 of the indices). Attacks in one attack_list share the handler, so EZ-MIA + WBC in the shipped example now run the target and reference forward passes once total. Living on the handler, the memo dies with the run and cannot collide across runs. Test test_evidence_is_memoised_per_handler_across_attacks counts forward passes. A persistent on-disk cache keyed on model fingerprint is left as a follow-up, as you suggested.
| for path in sorted((run_dir / "data_objects").glob("*.json")): | ||
| res = MIAResult.load(str(path)) | ||
| # reduce_to_unique_labels() splits the id on "-" and uses the first part as the series name. | ||
| name = res.result_name.replace("-", "") + f"[{label}]" |
There was a problem hiding this comment.
Correctness — hyphenated labels break the legend/table names.
name = res.result_name.replace("-", "") + f"[{label}]" strips hyphens from result_name but not from label, before building res.id = f"{name}-{config_hash}". Downstream, reduce_to_unique_labels() recovers the display name via s.split('-')[0] (and again at line 58 here: res.id.split('-')[0]), so any hyphen in label truncates it — e.g. label='full-v2' produces id EZMIA[full-v2]-abc123, which splits back to EZMIA[full instead of the full label. label defaults to a run-directory suffix, which commonly contains hyphens, so this will likely trigger in normal use. Suggest stripping hyphens from label the same way result_name is stripped, or using a delimiter other than - to separate the hash.
There was a problem hiding this comment.
Fixed in ad081e9: hyphens in the run label are replaced the same way they already were in the attack name. Verified with a full-v2 run: series render as EZMIA[full_v2] / WBC[full_v2] in the legend and table.
…ribute Review finding on #461: AbstractMIA.target_model was assigned on the class, so an audit that listed an LLM attack next to a classifier attack gave every attack whichever wrapper was built last — a CausalLMModel whose get_logits raises, or a PytorchModel that mis-handles a causal LM. The root cause is older than the LLM work: the class body defines both `target_model = None` and a `target_model` property, and the property wins — but `__init__` then did `AbstractMIA.target_model = wrapper`, replacing the property object on the class with a plain value. From the first construction on, every read hit that shared value. Store the wrapper on the instance only and have the property return it. No code outside this class read AbstractMIA.target_model directly. The new test builds a classifier attack and an LLM attack on one handler in both orders and checks each keeps its own wrapper type. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LfAkackfbGFZt1haPrXigN
…and loader safety Five findings from the #461 review, each with a regression test: - rank_top forced *every* non-finite score to the top. log(P/N) with P == 0 is -inf and is the weakest signal, so log_ratio inverted the ranking of exactly those rows relative to ratio. Now only the documented force_top rows and +inf go to the top; -inf sits just below the lowest ordinary score; a nan outside forced rows raises. The ratio/log_ratio equivalence test now includes P == 0 rows. - _reinitialise only called reset_parameters, which HuggingFace's Conv1D (GPT-2's attention and MLP projections) does not define, so a random_init reference kept most of its pretrained weights. Parameters of modules without a reset now get N(0, 0.02) / zeros, GPT-2's own initialisation, and the fallback is logged. - evidence() offloaded references but never the target, contradicting its docstring. The target is now offloaded after its pass too. - evidence() recomputed identical forward passes for every attack in a run. Results are memoised per handler, keyed on (model, moments, indices hash); the shipped example's EZ-MIA + WBC pair now shares both passes. Living on the handler, the memo dies with the run. - The shuffle guard in evidence_from_loader tested a `.shuffle` attribute no PyTorch sampler has. Use isinstance(RandomSampler), the same check utils/conversion.py already uses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LfAkackfbGFZt1haPrXigN
reduce_to_unique_labels() splits result ids on "-" and takes the first part as the series name, so a run label such as "full-v2" was truncated to "EZMIA[full". Replace hyphens in the label the same way the attack name already is. Verified with a "full-v2" run: series render as EZMIA[full_v2] / WBC[full_v2]. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LfAkackfbGFZt1haPrXigN
|
Ran EZ-MIA + WBC end-to-end on real Habana Gaudi hardware ( Pipeline itself is clean on HPU — One real bug found, unrelated to HPU: |
|
Follow-up review pass (correctness/reuse/efficiency), on top of the earlier round already fixed in cb8239f/4db114bb/ad081e9a. Verified each of these directly against the current code before posting, not just proposed on suspicion. 1. [High] New evidence cache silently depends on a known, already-deferred bug —
2. [Medium] LLM attacks still pay the full state-dict hash the PR says they avoid —
3. [Medium] Reference-model cache is unscoped and shared process-wide —
4. [Medium] The 5. [Low] Evidence memo key ignores The memo key is 6. [Low]
7. [Low] WBC recomputes the same cumsum 10x per run —
8. [Low] When 9. [Low] Fragile heuristic guesses whether a batch already has an attention mask — Guesses via 10. [Low] Duplicated The Also flagging (not a bug, a coverage suggestion): the shipped example covers one model (GPT-2) and one dataset (WikiText) for both EZ-MIA and WBC, vs. the original papers' evaluation across Pythia/GPT-2/GPT-J-6B/Llama-3.2-3B/Mamba-1.4B and Khan Academy/Stanford/stories/web-samples-v2/auto-math-text/wikiHow. The attack code itself ( Happy to open a follow-up issue for any of these rather than blocking on them here, whichever you prefer. |
Muhaddisabarat
left a comment
There was a problem hiding this comment.
Thanks — the 7 items from the first review round are all fixed and verified (cb8239f, 4db114b, ad081e9); ran EZ-MIA and WBC end-to-end on real HPU hardware with no crashes (see comments).
Requesting changes primarily for finding #1 in the follow-up review (issuecomment-5680654389): the new evidence-memoisation cache (_memo()) reads/writes through self.handler, a shared class attribute. I've now reproduced this concretely as a live bug (see #462, issuecomment-5681056522) — building a second attack on an unrelated handler silently overwrites the first, already-built attack's population/handler/audit_dataset. This PR is the first code whose correctness (not just convenience) depends on self.handler staying stable per-instance, so I'd like this fixed here — or #462 landed first — rather than deferred further.
The remaining 9 items in that same comment (3 Medium, 6 Low: hash-cost skip, reference-cache scoping, init-scale accuracy, memo-key completeness, etc.) are not blocking — happy to take those as fast follow-ups.
…ance Closes #462. Review round 2 on #461 showed this is no longer latent: the new LLM evidence memo is attached via self.handler, so a second attack built on another handler in the same process silently pointed the first attack's memo — and its population and audit split — at the wrong run. Same root cause and same fix as cb8239f for target_model: __init__ assigned to AbstractMIA.<name>, which replaces the property object on the class with a plain shared value. All four are now stored on the instance; the properties return the instance value; a `handler` property is added; the dead `= None` class attributes are removed. ramia.py, the only external reader of AbstractMIA.handler, now uses self.handler. The existing RMIA, LiRA and OSLO suites exercise every changed property. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTVd31kzVYZzjoDRDkFedG
…attack Review finding #2 on #461: LLM attacks still ran hash_model over the full target state dict on construction — tens of GB for a bf16 7B model, the cost opting out of the shadow handlers was meant to avoid. fingerprint_model hashes each tensor's name, shape, dtype and its first and last 1024 values plus the model's pretrained_name_or_path, so it is O(tensors) not O(parameters). Fine-tuning perturbs every weight, so the sample still separates checkpoints; it is not a full-weight hash and is documented as unfit for shadow-model cache validity. hash_attack takes a model_hasher argument defaulting to the unchanged hash_model. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTVd31kzVYZzjoDRDkFedG
…init, self-reference Findings #2, #3, #4, #5, #6, #8 and #10 from the second #461 review: - _hash_attack is overridden to use fingerprint_model, so building an LLM attack no longer reads the whole state dict. - The pretrained-reference cache is no longer a module-level lru_cache shared by every audit in the process; loaded modules live in the same per-handler run memo as the evidence, so two audits cannot .to()/ offload() each other's reference. - random_init references use gpt2_style_init_: N(0, 0.02) for every >=2-D weight, ones for LayerNorm, zeros for biases. The previous reset_parameters path left nn.Embedding at N(0, 1) — fifty times the GPT-2 scale — so the paper's random-reference ablation was not representative. The replica is built through handler.get_target_ replica(), the same path shadow models use (GroupNorm fix-up included). - batch_size is documented as deliberately absent from the memo key: per-sequence evidence is batch-invariant (pinned by the token_evidence tests). - A `source: self` reference reuses the target's evidence instead of running an identical second forward pass. - The transformers dtype/torch_dtype shim now lives in one public, uncached helper (load_pretrained_causal_lm) that the example wrapper imports; uncached because the wrapper fine-tunes what it loads. Tests: two attacks on two handlers keep their own population, split and memo (the #462 reproduction); self-reference costs no pass; init scale checked on Embedding, Linear, a Conv1D-like module and LayerNorm; the attack id separates configs and targets without hash_model. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTVd31kzVYZzjoDRDkFedG
…dow size Review finding #7 on #461. window_stat recomputed the O(N·T) cumsum for each of the |W| window sizes over the same delta; wbc_scores now builds it once and passes it in. Results are unchanged (existing hand-worked tests), cost drops by |W| = 10 at the defaults. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTVd31kzVYZzjoDRDkFedG
… shared HF loader From the reviewer's end-to-end run on Gaudi and findings #9 / #10: - `hf_dataset: wikitext` no longer resolves on current `datasets`; use the full hub id `Salesforce/wikitext` (README updated). - llm_model_handler._unpack guessed whether the second batch element was a mask by inspecting its values. Every loader in the example is built with CausalLMCollate, so batches are always (input_ids, attention_mask); unpack them as such. - hf_wrapper reuses load_pretrained_causal_lm instead of carrying its own copy of the transformers dtype/torch_dtype shim. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTVd31kzVYZzjoDRDkFedG
|
Round 2 addressed — all ten findings plus the dataset id, pushed in Blocking — #1, memo on shared Dataset id (from your Gaudi run): Non-blocking, all taken here rather than as follow-ups:
Coverage suggestion (more model/dataset configs, Mamba check) → #463. Full suite: 315 passed; |
…ified WBC window fix Adds two paper-config knobs to the shared LLM attack base: `max_samples` (the papers' `test_samples`, a stratified/seeded subsample of the audit rows, independent of population size) and `n_bootstrap_samples` (bootstrap-resampled mean/95% CI for AUC and TPR-at-fixed-FPR, attached as `result.bootstrap`). WBC gets `window_lengths`: an explicit window-size list, used verbatim instead of the geometric formula. Checked directly against the real reference implementation (github.com/Stry233/WBC, `attacks/wbc.py`): it has no formula at all, just a plain list from config — and its `_compute_window_score` clamps an oversized window down to the sequence's own length rather than skipping it, a real behavioral difference from what this file did before. Reimplemented `window_stat`/`wbc_scores` to match that clamping exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…val/checkpointing, local JSON data Extends `LLMModelHandler.train()` with `gradient_accumulation_steps` (so a large model can use a small per-step batch_size while keeping the paper's effective batch size), `warmup_steps` (linear warmup then linear decay to 0, matching HF Trainer's default schedule), and optional per-epoch held-out eval + checkpointing with pruning (`eval_strategy`/`save_strategy`/ `save_total_limit`) -- all opt-in, default behavior unchanged. `prepare_target.py` gains `data.source: local`: fine-tune directly on pre-split member/non-member JSON files (the WBC paper's own config shape), via a new shared `json_dataset.py` (also used by the new `import_external_target.py`, for auditing an already-trained checkpoint without fine-tuning it here). Tokenises one JSON entry per example (`tokenise_one_per_text`), matching the reference codebase (github.com/Stry233/WBC, `trainer/misc/data.py`) rather than the HF-dataset path's concatenate-and-chunk convention, which would have silently merged unrelated entries into one training row. Also fixes two latent bugs on the local-JSON path surfaced while wiring this up: `n_members` and `data_cfg["hf_dataset"]` were referenced unconditionally but undefined there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…periment folders Two self-contained experiments instead of one shared config pair, so runs never collide: `ez-mia/` is the GPT-2/WikiText-103 demo (EZ-MIA only -- train_config_ez-mia.yaml, audit.yaml, and an interactive llm_mia_main.ipynb mirroring examples/mia/cifar/cifar_main.ipynb's structure); `wbc/` is the WBC paper's own Pythia-2.8B / Khan Academy reference config (train_config_wbc_pythia.yaml, audit_wbc_pythia_paper.yaml -- the paper's window list, batch_size 1, n_bootstrap_samples 100, local JSON data, and float32 reference dtype matching the reference codebase's un-forced default). Shared code (hf_wrapper.py, llm_model_handler.py, prepare_target.py, etc.) stays one level up; each moved audit.yaml's `target.module_path` becomes `../hf_wrapper.py` since leakpro resolves it relative to the cwd the script runs from, not the config file's own location. README rewritten throughout with the new layout and invocation pattern (`cd <folder> && python ../script.py`). Verified end-to-end after the move, not just moved and assumed correct: reran prepare_target.py + run_audit.py from inside ez-mia/, confirming the cached population, module_path, and output paths all resolve into the new folder. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
|
Round 2 fixes all verified (thanks for closing out #462 there too) — while re-reviewing, went further on a few things based on comparing this against the actual reference implementations rather than just the paper text, and implemented them directly rather than just flagging. Pushed in
All verified end-to-end on real HPU hardware where applicable (fine-tune + audit re-run from the new folder layout), not just unit-tested in isolation. Full test suite still green, ruff clean. Not submitting an updated formal review yet — holding approval aside for now; this is additional input alongside the round-2 fixes, happy to discuss any of it before that. |
Muhaddisabarat
left a comment
There was a problem hiding this comment.
All items from the original review are fixed and verified: the 7 first-round findings (cb8239f/4db114bb/ad081e9a), the #462 handler-sharing bug (65b21d4), and the second round of correctness/efficiency items (580caf4/deac9927/5aee2adc/ec45cc0e). On top of that, verified this PR's WBC implementation and example pipeline directly against the actual reference codebases (github.com/Stry233/WBC and the local HPU-adapted copy) and pushed additional fixes/features (7670ec8/4e8955bd/afbafebc): max_samples/n_bootstrap_samples, a corrected WBC window-clamping algorithm and window_lengths override, gradient accumulation/LR warmup/per-epoch eval-checkpointing for the training example, a local-JSON data path matching the paper's own tokenisation convention, and a cleaner ez-mia/wbc example split.
Everything ran end-to-end on real HPU hardware (fine-tune + audit, both EZ-MIA and WBC), full test suite green, ruff clean.
Approving.
…ist them 7670ec8 added n_bootstrap_samples for the LLM attacks but computed AUC / TPR-at-FPR with sklearn's roc_curve + np.interp, while the point estimate in the same result comes from MIAResult's own sweep (largest achievable TPR at FPR <= target). Interpolation sits above the achievable step, so at 0.1% FPR the bootstrap mean and the headline number disagreed by construction — two definitions of one quantity in one result. The block was also a plain attribute: never saved by MIAResult.save(), never printed, so the WBC paper config's n_bootstrap_samples: 100 produced nothing a user could see. MIAResult.bootstrap_metrics scores every resample by constructing a MIAResult, so the bootstrap uses exactly the point estimate's definitions and keys, and the sklearn duplicate in the attack module is gone. MIAResultSchema gains an optional `bootstrap` field, set via MIAResult.set_bootstrap and restored by MIAResult.load (older JSONs lack it and load as None). run_audit.py prints the interval next to the point estimate. Tests: keys match the point estimate's, one-class resamples are skipped, and the block survives save -> load. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011h3Tn1PVj4qUYPKSCPV8sW
… tidy example comments The list [2,3,4,6,9,13,18,25,32,40] is what github.com/Stry233/WBC's configs/example.yaml uses; the paper text gives eq. 12. Say so in the wbc module, the config description, the README and the WBC audit config instead of calling it the paper's published grid. Rename the geometric_windows test to stop claiming the formula yields "the paper's grid". Make the ez-mia dtype comment consistent with the wbc config's deliberate float32 reference. Drop a write to LLMModelHandler.pad_token_id, an attribute removed in ec45cc0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011h3Tn1PVj4qUYPKSCPV8sW
|
Reviewed Two things I changed on top ( Fixed — bootstrap used a different TPR@FPR definition than the point estimate. Fixed — the bootstrap block was never saved or shown. Nits fixed: the list is the reference config's, not "the paper's published grid" — the paper text gives eq. 12 (wording in module/README/config/test); Questions on
Not changed, noting for later: |
Summary
Adds the first membership-inference attacks against fine-tuned causal language models, integrated into the existing
AbstractMIA/AttackFactoryMIA/MIAResultarchitecture rather than as a parallel stack. Two attacks land now; the seam is designed so the next ones (LOSS, Reference-Loss, Min-K%, Min-K%++) are each one small file underleakpro/attacks/mia_attacks/llm/.Both are training-free: two forward passes per sequence, no shadow models.
What changed
Infrastructure (shared code paths — default-preserving, regression-tested)
utils/save_load.py—hash_modelreinterprets tensors asuint8so bfloat16 checkpoints hash. Byte-identical for every dtype numpy already supported, so no cached hash / shadow model / attack id changes. Pinned against Linear and BatchNorm fixtures (the 0-dimnum_batches_trackedbuffer is whyreshape(-1)is needed).abstract_mia.py/attack_factory_mia.py—requires_shadow_models/requires_distillation_modelsclass flags. The factory previously built both handlers for every attack before looking at the attack class;ModelHandler.__init__hashes the full target and caches a dense forward pass, which for an LM is a(N, T, vocab)array. The 13 existing attacks inheritTrueand are unchanged (a test constructs each and checks). Also an overridable_wrap_target_modelhook.New
signals/token_evidence.py—CausalLMModel(implements theModelABC directly;PytorchModel's positional call,(batch, classes)assumption, retaining forward hooks and per-batch device moves are all wrong for an LM),TokenEvidence(per-token log-prob / argmax / mask, already shifted, reduced on-device so dense logits never leave the GPU),CausalLMCollate. Core never importstransformers.attacks/mia_attacks/llm/abstract_llm_mia.py— shared base: attack-side dataloader, reference models declared inside the attack config (so they land in the attack hash and result metadata),rank_topfor the paper's "must be a member" edge cases (MIAResultcannot takenanor ≥2inf).attacks/mia_attacks/llm/ez_mia.py,wbc.py— the reductions. WBC'sℓ^R − ℓ^Tover losses equals EZ-MIA'slp^T − lp^R; a test pins the identity.E2E_TESTED_MIA_ATTACKScovers both.examples/mia/llm_mia/— data/model handlers (full + LoRA), HF wrapper,prepare_target.py,run_audit.py,sweep_report.py(LoRA-vs-full comparison via existingMIAResultAPIs). Defaults reproduce the EZ-MIA WikiText / GPT-2 / 128-token setting.pyproject.toml—llmextra.Verification
pytest leakpro/tests(excluding synthetic + minv, which need extras not in this env): 303 passed, up from 262.ruff check .from repo root: clean.N == 0and zero-error rows rank as members under every aggregation; WBC windows never cross into padding; short-sequence fallback finite.run_audit → data_objects/*.json → sweep_report.pyverified end-to-end on the tiny text harness (two labelled runs, combined ROC + LaTeX table).Not verified: the real GPT-2 / WikiText reproduction. The dev env has torch 2.4.1; transformers 5.2 needs ≥ 2.5. Expected from the paper: AUC 0.984, TPR@1%FPR 66.3%, TPR@0.1%FPR 14.0%. A large shortfall should be treated as a bug in this port.
Design notes for review
data_modality: "text"still maps to no modality extension (unchanged); neither attack needs one._hash_attackstill hashes the full target per attack construction (~14 GB for a bf16 7B model). Accepted for now; the hook is overridable.🤖 Generated with Claude Code
https://claude.ai/code/session_018czjKibgtK26Fme7A4w5EV