Skip to content

feat(mia): LLM membership-inference attacks — EZ-MIA and WBC - #461

Open
fazelehh wants to merge 22 commits into
mainfrom
feature/llm-mia-ez-wbc
Open

fazelehh wants to merge 22 commits into
mainfrom
feature/llm-mia-ez-wbc

Conversation

@fazelehh

Copy link
Copy Markdown
Collaborator

Summary

Adds the first membership-inference attacks against fine-tuned causal language models, integrated into the existing AbstractMIA / AttackFactoryMIA / MIAResult architecture 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 under leakpro/attacks/mia_attacks/llm/.

  • EZ-MIA — Ilić, Stanojević, Cvejoski, arXiv:2601.12104. Ratio of upward to downward log-prob shift (target vs frozen pretrained reference) at the tokens the target mispredicts.
  • WBC — Chen et al., arXiv:2601.02751. Sign vote over sliding windows of geometrically spaced sizes on the same per-token difference.

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.pyhash_model reinterprets tensors as uint8 so 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-dim num_batches_tracked buffer is why reshape(-1) is needed).
  • abstract_mia.py / attack_factory_mia.pyrequires_shadow_models / requires_distillation_models class 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 inherit True and are unchanged (a test constructs each and checks). Also an overridable _wrap_target_model hook.

New

  • signals/token_evidence.pyCausalLMModel (implements the Model ABC 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 imports transformers.
  • 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_top for the paper's "must be a member" edge cases (MIAResult cannot take nan or ≥2 inf).
  • attacks/mia_attacks/llm/ez_mia.py, wbc.py — the reductions. WBC's ℓ^R − ℓ^T over losses equals EZ-MIA's lp^T − lp^R; a test pins the identity.
  • E2E harness text branch with a tiny pure-torch causal LM; E2E_TESTED_MIA_ATTACKS covers 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 existing MIAResult APIs). Defaults reproduce the EZ-MIA WikiText / GPT-2 / 128-token setting.
  • pyproject.tomlllm extra.

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.
  • Numerics: hand-worked EZ-MIA values under all six aggregations; N == 0 and 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.py verified 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.
  • WBC's paper never defines windows longer than the sequence (it uses ≥512 tokens). Here such a window is skipped for that sequence; if none fits, one whole-sequence window is used. Documented in the module.
  • _hash_attack still 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

fazelehh and others added 8 commits September 11, 2026 09:13
…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 Muhaddisabarat 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.

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)

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.

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.

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

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.

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)

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.

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.

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 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)

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.

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.

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

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.

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.

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

Comment thread leakpro/signals/token_evidence.py Outdated
TokenEvidence over all rows, padded to the longest batch.

"""
if isinstance(loader, DataLoader) and getattr(loader.sampler, "shuffle", False):

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.

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.

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 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:

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.

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.

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.

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.

Comment thread examples/mia/llm_mia/sweep_report.py Outdated
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}]"

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.

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.

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

fazelehh and others added 3 commits September 14, 2026 12:51
…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
@Muhaddisabarat

Copy link
Copy Markdown
Collaborator

Ran EZ-MIA + WBC end-to-end on real Habana Gaudi hardware (leakpro_hpu conda env: torch 2.10.0+cpu, transformers 5.9.0, datasets 5.0.1, habana_frameworks.torch 1.24.0.1007, HPU auto-detected by get_device()), since the PR description noted the real GPT-2/WikiText run hadn't been verified yet.

Pipeline itself is clean on HPUprepare_target.pyrun_audit.py completed without any device errors, and the evidence-memoisation fix from the earlier review round visibly kicked in (log shows WBC reusing the target/reference forward passes EZ-MIA already computed instead of redoing them). Smoke-tested with a 100-member/100-non-member/1-epoch scaled-down config, not the full paper setting, so the AUCs aren't comparable to the paper numbers — just confirming nothing crashes on real hardware.

One real bug found, unrelated to HPU: train_config.yaml's hf_dataset: wikitext fails to load with HfUriError: Repository id must be 'namespace/name', got 'wikitext'. Newer datasets/huggingface_hub versions (what's installed here) dropped the legacy short-name auto-resolution for this dataset; it now needs the full repo id Salesforce/wikitext. Confirmed Salesforce/wikitext loads correctly (1.8M train rows) with the exact same config otherwise. Suggest updating hf_dataset in train_config.yaml (and the README's install/usage notes if they reference the short name) so the example doesn't break on a newer datasets install — leaving this as a suggestion rather than pushing a fix myself.

@Muhaddisabarat

Copy link
Copy Markdown
Collaborator

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 bugleakpro/attacks/mia_attacks/llm/abstract_llm_mia.py:264, leakpro/attacks/mia_attacks/abstract_mia.py:38,84

_memo() attaches the evidence cache via self.handler, but handler is a shared class attribute (AbstractMIA.handler = handler in __init__, no instance override) — the same pattern already fixed for target_model in cb8239f. This exact issue for handler/population/audit_dataset was flagged as "harmless today, tracked in #462" — but this PR's new memo cache now makes correctness depend on it: if two audits are constructed in the same process before either runs (e.g. a multi-handler pytest session, or an in-process sweep), an earlier audit's attacks silently read/write the wrong handler's memo. Given this PR introduces the first real dependency on self.handler being correct, worth either storing the handler per-instance now (same fix shape as cb8239f) rather than waiting for #462, or scoping the memo some other way.

2. [Medium] LLM attacks still pay the full state-dict hash the PR says they avoidleakpro/attacks/mia_attacks/abstract_mia.py:107, inherited by AbstractLLMMIA

AbstractLLMMIA never overrides _hash_attack(), so every LLM attack still runs a full SHA-256 over the target's entire state dict — exactly the cost requires_shadow_models=False/requires_distillation_models=False were introduced to avoid, per this PR's own docstring ("prohibitive for large models"). _hash_attack's docstring already invites an override ("subclasses auditing very large models may substitute a cheaper fingerprint") — suggest AbstractLLMMIA does that.

3. [Medium] Reference-model cache is unscoped and shared process-wideleakpro/attacks/mia_attacks/llm/abstract_llm_mia.py:165

_load_pretrained is @functools.lru_cache(maxsize=4) at module level, returning the identical mutable module to any handler/run in the process requesting the same (name_or_path, dtype) — unlike the evidence memo, which is deliberately scoped to die with the run. Two audits sharing a process could have .to(device)/.offload() calls from one interfere with the other. Suggest scoping this the same way _memo() is, or documenting the one-audit-per-process assumption explicitly.

4. [Medium] random_init reference doesn't actually reproduce GPT-2's init scaleleakpro/attacks/mia_attacks/llm/abstract_llm_mia.py:140-162

The N(0, 0.02) fallback only applies to modules without reset_parameters (e.g. Conv1D). But nn.Embedding/nn.LayerNorm do define reset_parameters, so they use PyTorch's own default init instead (nn.Embedding.reset_parameters is normal_(0, 1.0) — 50x the intended scale) — inconsistent with real GPT-2 init (~0.02 std everywhere). Suggest applying the 0.02-std init explicitly to embeddings/layernorm too, so the random_init reference (the paper's sanity-check ablation) is representative.

5. [Low] Evidence memo key ignores batch_sizeleakpro/attacks/mia_attacks/llm/abstract_llm_mia.py:280,320

The memo key is (model_key, need_moments, indices_key) — no batch_size. If two attacks in one attack_list configure different batch sizes for the same target/reference, the second silently reuses the first's evidence computed at the first's batch size. Numerically harmless but worth adding to the key or documenting as shared.

6. [Low] random_init reference bypasses the handler's own replica-construction pathleakpro/attacks/mia_attacks/llm/abstract_llm_mia.py:200 vs leakpro/input_handler/mia_handler.py:310 (get_target_replica)

load_reference's random_init branch builds the reference directly via handler.target_model_blueprint(**init_params), duplicating handler.get_target_replica() — including its DP-SGD/GroupNorm fix-up and clearer error message. Suggest reusing get_target_replica() instead.

7. [Low] WBC recomputes the same cumsum 10x per runleakpro/attacks/mia_attacks/llm/wbc.py:87 (window_stat), called from wbc_scores:109

window_stat recomputes np.cumsum(delta, axis=1) from scratch on every call, and wbc_scores calls it once per window size (10 by default) over the same delta. Suggest hoisting the cumsum out of the loop — pure efficiency win, ~10x fewer O(N·T) passes.

8. [Low] source: self reference redoes a forward pass that's already been computedleakpro/attacks/mia_attacks/llm/abstract_llm_mia.py:196-197,320

When references: [{source: self}] (the paper's delta≡0 sanity check), load_reference returns the exact same module already scored as the target — but the memo key is cfg.key() for the reference vs. the literal 'target' string for the target, so it's a cache miss and the identical model runs a second full forward pass for guaranteed-zero information.

9. [Low] Fragile heuristic guesses whether a batch already has an attention maskexamples/mia/llm_mia/llm_model_handler.py:25 (_unpack)

Guesses via dtype == bool or "values are 0/1 and differ from the first tensor" instead of reusing the deterministic mask CausalLMCollate (leakpro/signals/token_evidence.py) already computes for these same dataloaders.

10. [Low] Duplicated transformers compat shimleakpro/attacks/mia_attacks/llm/abstract_llm_mia.py:170-174 and examples/mia/llm_mia/hf_wrapper.py:25-29

The dtype=/torch_dtype= fallback for transformers < 5 is duplicated verbatim in both files. Suggest hf_wrapper.py reuse _load_pretrained (or a shared helper) so a future argument-spelling change (the comment notes it's changed once already) only needs fixing in one place.


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 (AbstractLLMMIA/CausalLMModel) is model-agnostic — this would be additional example configs, not core changes — except Mamba, a state-space model whose compatibility with the per-token evidence extraction here is untested.

Happy to open a follow-up issue for any of these rather than blocking on them here, whichever you prefer.

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

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.

fazelehh and others added 6 commits September 16, 2026 06:24
…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
@fazelehh

Copy link
Copy Markdown
Collaborator Author

Round 2 addressed — all ten findings plus the dataset id, pushed in 65b21d48..ec45cc0e (and main merged in at 1439555d, which brought the offline-LiRA fix; no conflicts).

Blocking — #1, memo on shared handler: fixed at the root in 65b21d48, i.e. #462 landed in this PR as you asked. population, population_size, audit_dataset and handler are now stored on the instance; the properties return the instance value; the = None class attributes are gone; ramia.py uses self.handler. Test test_attacks_on_different_handlers_do_not_share_state is your #462 reproduction: two attacks on two handlers keep their own split, labels and memo (the memo lands on the right handler, the other has none). The RMIA / LiRA / OSLO suites cover the changed properties.

Dataset id (from your Gaudi run): Salesforce/wikitext in train_config.yaml + README (ec45cc0e). Thank you for running it on HPU — good to know the pipeline and the memo behave on real hardware.

Non-blocking, all taken here rather than as follow-ups:

# Fix Commit
2 AbstractLLMMIA._hash_attack uses a new fingerprint_model (name/shape/dtype + first/last 1024 values per tensor + pretrained_name_or_path); hash_attack gained a model_hasher argument defaulting to the unchanged hash_model. Documented as unfit for shadow-cache validity. Test asserts the id still separates configs and targets. 580caf40, deac9927
3 _load_pretrained's module-level lru_cache is gone; loaded reference modules live in the same per-handler run memo as the evidence, so nothing is shared across audits in a process. deac9927
4 gpt2_style_init_: N(0, 0.02) for every ≥2-D weight, ones for LayerNorm, zeros for biases — applied uniformly instead of via reset_parameters. Confirmed torch's nn.Embedding reset is N(0, 1). Test checks Embedding, Linear, a Conv1D-like module and LayerNorm. deac9927
5 batch_size documented as deliberately absent from the memo key (per-sequence evidence is batch-invariant; pinned by test_padding_is_invisible_per_sequence). deac9927
6 random_init builds the replica through handler.get_target_replica(). deac9927
7 Prefix sums computed once in wbc_scores and passed into window_stat. 5aee2adc
8 source: self reuses the target's evidence — no second pass (test counts forward calls: 1). deac9927
9 _unpack is a plain destructure; every loader in the example uses CausalLMCollate. ec45cc0e
10 One public, uncached load_pretrained_causal_lm holds the dtype/torch_dtype shim; the wrapper imports it. Uncached on purpose — the wrapper fine-tunes what it loads, so a shared cached module would be mutated. deac9927, ec45cc0e

Coverage suggestion (more model/dataset configs, Mamba check) → #463.

Full suite: 315 passed; ruff check . clean. One note for whoever runs the suite next: test_functional.py::TestTs2vecEncoderSharing::test_a_bound_encoder_makes_repeated_calls_identical took 50 minutes on this machine in one run and seconds in others — not touched by this PR, looks like TS2Vec training under CPU contention.

Muhaddisabarat and others added 3 commits September 16, 2026 13:57
…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>
@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

@Muhaddisabarat

Copy link
Copy Markdown
Collaborator

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 7670ec8b, 4e8955bd, afbafebc:

7670ec8b — max_samples/n_bootstrap_samples, and a verified WBC window fix

  • max_samples (the papers' test_samples): stratified/seeded subsample of the audit rows, independent of population size.
  • n_bootstrap_samples: bootstrap-resampled mean/95% CI for AUC and TPR-at-fixed-FPR, attached as result.bootstrap.
  • WBC window_lengths: explicit window-size list, used verbatim. Checked directly against github.com/Stry233/WBC's attacks/wbc.py — there's no geometric formula anywhere in the real codebase, just a plain list from config. Also found and fixed a real behavioral gap: the reference's _compute_window_score clamps an oversized window down to the sequence's own length (effective_window_size = min(window_size, min_length)) rather than skipping it — window_stat/wbc_scores now match that exactly instead of the skip-then-fallback logic this PR shipped with.

4e8955bd — gradient accumulation, LR warmup, per-epoch eval/checkpointing, local JSON data

  • LLMModelHandler.train(): gradient_accumulation_steps, warmup_steps (linear warmup then decay, matching HF Trainer's default schedule), optional per-epoch held-out eval + checkpoint pruning — all opt-in, needed to actually reproduce a real paper training recipe (Pythia-2.8B/Khan Academy), not just the audit side.
  • prepare_target.py data.source: local: fine-tune directly on pre-split member/non-member JSON files. Tokenises one JSON entry per example, matching github.com/Stry233/WBC's trainer/misc/data.py — not the HF-dataset path's concatenate-and-chunk convention, which would have silently merged unrelated entries.
  • Fixed two latent bugs surfaced while wiring this path up: n_members and data_cfg["hf_dataset"] were referenced unconditionally but undefined on it.

afbafebc — split examples/mia/llm_mia/ into ez-mia/ and wbc/

  • Two self-contained experiment folders so configs/data/output never collide: ez-mia/ (GPT-2/WikiText-103, EZ-MIA only, plus an interactive notebook mirroring cifar_main.ipynb) and wbc/ (the WBC paper's own Pythia-2.8B/Khan Academy reference config — its window list, batch_size: 1, n_bootstrap_samples: 100, local JSON data, float32 reference dtype matching the reference codebase's own un-forced default).

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
Muhaddisabarat previously approved these changes Sep 16, 2026

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

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.

fazelehh and others added 2 commits September 16, 2026 20:46
…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
@fazelehh

fazelehh commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Reviewed 7670ec8b / 4e8955bd / afbafebc against the code and the reference repo — thank you for going to the actual implementation. Confirmed independently: Stry233/WBC takes the window sizes as a plain list from configs/example.yaml ([2,3,4,6,9,13,18,25,32,40], no eq. 12 anywhere) and clamps effective_window_size = min(window_size, length) rather than skipping, so your window_stat change is the right semantics. The ez-mia/ + wbc/ split is also what we want: one config per attack, each in its own paper's setting. Tests, the LLM E2E cases and root ruff are green on your head here.

Two things I changed on top (8107ad63 bootstrap, 049157c3 wording/nits), one set of questions for you.

Fixed — bootstrap used a different TPR@FPR definition than the point estimate. bootstrap_auc_and_tpr computed TPR at fixed FPR with sklearn.roc_curve + np.interp; MIAResult._get_result_fixed_fpr takes the largest achievable TPR at FPR ≤ target from its own confusion sweep. Interpolation sits above the achievable step, so at 0.1% FPR the bootstrap mean and the headline number in the same result disagreed by construction. Now MIAResult.bootstrap_metrics scores each resample by building a MIAResult, so keys and definitions are identical, and the sklearn copy is gone from the attack module.

Fixed — the bootstrap block was never saved or shown. MIAResult.save() dumps the pydantic schema, so the plain attribute was dropped; run_audit.py/the notebook never printed it. MIAResultSchema now has an optional bootstrap field (set_bootstrap writes it, load restores it; older JSONs load as None), and run_audit.py prints the interval next to the point estimate. Round-trip test added.

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); import_external_target.py wrote to LLMModelHandler.pad_token_id, removed in ec45cc0e; the ez-mia dtype comment now matches wbc/'s deliberate float32 reference.

Questions on wbc/train_config_wbc_pythia.yaml (not changed by me):

  1. epochs: 5 sits next to a comment saying the paper uses num_train_epochs: 1. Which is it?
  2. data.train_path/test_path are absolute paths under your home directory. Could you swap them for placeholders (or a relative ./data/...) so the config is runnable by someone else?
  3. The configs/example.yaml I fetched from the reference repo shows n_bootstrap_samples: 10 and test_samples: null, while the WBC audit config says 100 "matches the paper's config". Which reference config did you copy from? Happy to be wrong here — the fetch may have hit a different file.

Not changed, noting for later: wbc_scores is back to a per-row Python loop with a cumsum per (row, window) — 2.1 s at N=20k / T=127 here, ~4× at T=512. Fine for now; I'd vectorise the clamped version in a follow-up.

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