Layer-wise Relevance Propagation for physioex.explain (CNN, recurrent, attention models) - #14
Merged
Merged
Conversation
Introduce Layer-wise Relevance Propagation (Bach 2015) as a new `physioex.explain.lrp` family, built on Zennit (CNN/RNN rules, canonizers). LRP needs the layered nn.Module graph, so `LRP` wraps the model directly and selects the target neuron via a one-hot seed on the (B, L, n_classes) output (no Funct/SeqFunct scalar wrapper). - `lrp/attributor.py`: `LRP(model, in_index, out_index, composite, canonizers)`; `forward(x) -> relevance` shaped like the (B, L, C, T) input. - `lrp/composites.py`: `physioex_composite` (ε dense / γ conv / w² first-layer, the correct input rule for unbounded z-scored EEG, not the pixel z-box rule) and `epsilon_composite` (pure-ε, relevance-conserving reference). - `lrp/canonizers.py`: `default_canonizers` (SequentialMergeBatchNorm). - Lazy `LRP` re-export in `explain/__init__.py` (PEP 562) so importing `physioex.explain` does not pull in the optional zennit/lxt deps. - New optional `[explain]` extra (zennit, lxt); CI installs it so the LRP tests run instead of skipping. - Tests: API/shape, finiteness, conservation (Σ R ≈ f(x)), target selection, BatchNorm canonizer, w²/zbox composites. - Docs: LRP section in the explain page. Transformer/attention (LXT/AttnLRP), the Arras LSTM/GRU signal-take rule, foundation encoders and DFT/STFT-LRP follow in Phases 2-4. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Conservation (Σ R ≈ f_c(x)) is the defining LRP property. The backward pass must be seeded with the target *logit value* f_c(x), not a bare 1.0 one-hot (which normalises total relevance to 1). Verified on Sofia: the bias-free-MLP conservation test now passes (9/9 LRP tests green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nn.LSTM/nn.GRU are fused (gate products invisible to hooks), so LRPLSTM / LRPGRU re-run the recurrence at cell level using LXT functionals: - linear_epsilon for the gate pre-activations (ε-LRP), - add2 for the c=f⊙c₋₁+i⊙g and h=(1-z)⊙n+z⊙h sums (proportional split), - a custom signal-take product (all relevance to the source, none to the gate — Arras 2017/2019; LXT's mul2 is 50/50 uniform, hence the custom Fn), - straight-through tanh/sigmoid so relevance passes the source nonlinearity as identity. from_torch() shares the trained weights (no retraining); the forward output is numerically identical to the fused module. Supports num_layers, bidirectional, batch_first — covers tinysleepnet, seqsleepnet, lseqsleepnet and protosleepnet's recurrent stacks. Verified on Sofia: 11/11 (LSTM+GRU forward-equivalence uni/bi × 1-2 layers, plus bias-free conservation Σ R ≈ f). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hase 2b) Wrapping nn.TransformerEncoderLayer with a single LRP rule does NOT conserve relevance (verified on Sofia: ratio ~0.06), and its forward introspects self.self_attn.batch_first so the inner MHA cannot be wrapped either. So reimplement the attention/FFN forward with LXT functionals: - linear_epsilon for the QKV/out projections and the FFN, - a custom CP-LRP attention (softmax(QKᵀ/√d)·V with the attention matrix treated as constant → relevance flows through the value path; conserves exactly, unlike propagating through softmax which leaks), - straight-through identity for LayerNorm (AttnLRP identity rule), - add2 for the residual sums. LRPMultiheadAttention / LRPTransformerEncoderLayer / LRPTransformerEncoder load trained weights via from_torch(); forward is numerically identical to the fused modules (Δ ~1e-7). swap_transformer_layers() replaces both stacks in place (the nn.TransformerEncoder container also introspects batch_first, so the whole stack is swapped, not just its layers). Verified on Sofia: 28/28 LRP tests (forward-equivalence for MHA/TEL post- & pre-norm; bias-free conservation Σ R ≈ f = 1.000; swap round-trip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (Phase 2c) Wire the LRP engines to whole PhysioEx models: - pooling.py: CP-LRP for the softmax attention-pooling blocks (out = Σ_t softmax(score)_t·x_t, weights treated as constant → relevance through the value path). LRPAttentionPooling (SleepTransformer) and LRPAttentionLayer (SeqSleepNet, reused by lseq/proto), matched by class name. - model.py: prepare_model_for_lrp() swaps LSTM/GRU→LRPLSTM/GRU, TransformerEncoder(Layer)→LRP versions, MultiheadAttention→LRPMHA, custom pooling→CP-LRP, and wraps remaining Linear/Conv/BatchNorm with LXT EpsilonRule and LayerNorm with IdentityRule. ModelLRP deep-copies the model, prepares it (disabling in-place ops that would corrupt saved tensors) and runs a target-seeded backward, returning relevance shaped like the input. - LRPLSTM/LRPGRU now return (output, states) like nn.LSTM/GRU so the models' `out, _ = self.rnn(x)` calls keep working after the swap. Verified on Sofia: 33/33 synthetic LRP tests, PLUS end-to-end on the real models across all three families — sleeptransformer, seqsleepnet, tinysleepnet: prepared-forward matches the original (Δ~1e-8) and relevance is finite with the input's shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…idation) Two idiosyncrasies blocked the last two models: - protosleepnet's forward does `isinstance(sequence_encoder, nn.GRU)` and unpacks `out, _ = ...`; coresleep returns a dict and calls its cross-attention `nn.MultiheadAttention` as `out, _ = mha(query=, key=, value=)`. Fixes: - LRPLSTM / LRPGRU now SUBCLASS nn.LSTM / nn.GRU (from_torch loads the trained weights via load_state_dict, forward overridden with the cell-level LRP). So isinstance checks pass and the (output, states) interface matches natively. - LRPMultiheadAttentionModule: an nn.MultiheadAttention-compatible adapter (returns (out, None), accepts **kwargs) used when swapping standalone attention; prepare_model_for_lrp uses it for nn.MultiheadAttention. - ModelLRP already supports dict outputs via output_key (e.g. "combined"). Verified on Sofia: 34/34 synthetic tests, PLUS end-to-end on ALL SIX real models — sleeptransformer, seqsleepnet, tinysleepnet, coresleep (dict output), protosleepnet (from_sleep_transformer and from_seq_sleep_net): prepared-forward Δ~1e-8, relevance finite with the input's shape. Note: models with a plain `+` residual in their own Python forward (coresleep's cross residual) get finite relevance but not strict conservation there (a plain add over-counts vs the proportional add2 rule) — see the review brief. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n, robustness
Outcome of an in-depth 4-reviewer verification (recurrent math, attention math,
practical wiring, methodological completeness) with every reported defect
confirmed numerically on Sofia before fixing.
Correctness (relevance vs gradient):
- LearnableFilterbank (first layer of seqsleepnet/lseq/proto-seq) was a raw
matmul on plain autograd → input relevance was gradient (Σ R off by ~9×).
Now covered by an ε-rule adapter; new audit_lrp_coverage() / strict=True
report any parametric leaf left without a rule.
- Plain `+` residuals in a model's own forward over-counted relevance (ratio
exactly 2.0 per residual). ModelLRP now redirects grad-carrying adds to the
proportional add_eps rule at runtime (TorchFunctionMode, patch_residuals=True).
- ChannelMixer (protosleepnet) was documented as CP-LRP but was not: new
LRPChannelMixer adapter (constant softmax weights, proportional residual).
Stabilisers / numerics (new shared _functional.py):
- signed, dtype-preserving stabiliser z + ε·sign(z) (Arras) replaces LXT's
sign-blind z + ε (±1e8 blow-up on near-cancelling sums) and the float32
promotion of torch.where(out>=0, eps, -eps) that broke fp16 backward;
- ε is plumbed to every rule (add2 was silently running with LXT's 1e-8);
- st_identity is a bit-exact autograd Function (old x+(act-x).detach() lost
tanh at |x|~1e8); GELU/ReLU in the FFN and all activations/GroupNorm use
the identity rule; BatchNorm is folded into the preceding layer (Zennit
canonizer) on the ModelLRP path too.
Robustness / API:
- adapters matched by fully-qualified class name + structural guard (the
short-name match swapped sleepfm.AttentionPooling and crashed) with a
public register_lrp_adapter(); shared modules replaced once (memo);
prepare is idempotent; ModelLRP works under torch.no_grad(), freezes the
copy, handles rank-2 outputs, uses autograd.grad; from_torch copies
leaves (no longer freezes the caller's model); batch_first=False,
bias_k/add_zero_attn, masks, hx, proj_size now raise instead of silently
misbehaving; LRPMultiheadAttentionModule returns averaged weights on
need_weights; LRP (Zennit) forces eval().
- diagnostics.ConservationReport / check_conservation (Σ R / f per sample)
and return_report=True on both entry points.
- lxt pin corrected to >=2.0 (lxt.explicit does not exist in 0.x); tests
gate on importorskip("lxt.explicit").
Tests: 34 → 85 (functional primitives, multi-layer/bidirectional RNN
conservation at rtol 1e-3, CP-attention zero-Q/K + cross-attention, GELU,
fp16, residual over-count fixed, filterbank/ChannelMixer/BN/conv via ModelLRP,
registry, no_grad, shared modules, idempotence, audit/strict, and the six
real architectures with full coverage). Docs: entry-point/rule matrix,
how ModelLRP rewrites a model, how to read conservation, unsupported cases.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lxt dependency CI failed on the previous commit with `operator torchvision::nms does not exist`: lxt 2.x drags in transformers/open_clip → a torchvision build incompatible with the CPU torch wheel installed by the workflow. The subsystem only used two thin LXT module wrappers (EpsilonRule via vjp, IdentityRule); they are now implemented in `_rules.py` (~50 lines, same semantics, signed stabiliser). All other primitives were already local (`_functional.py`). - `[explain]` extra is now zennit-only (composites + BatchNorm canonization). - tests no longer gate on lxt; the full suite passes on Sofia with lxt hidden from the import system (85/85), proving independence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
zennit requires torchvision; without pinning it to the same CPU index pip pulls a CUDA build from PyPI that is incompatible with the CPU torch wheel (`operator torchvision::nms does not exist` at import). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ce/dtype, coverage tests Findings of the adversarial verification pass on the fixed subsystem: - CRITICAL: add_eps saved *view* operands; CoReSleep (bimodal) writes `x[:, 0] = x[:, 0] + cls_cross` after the add, bumping the base's version counter → "modified by an inplace operation" in the LRP backward. The rule now saves copies of view operands. - MAJOR: LRPLSTM/LRPGRU.from_torch built the replacement on CPU/fp32 → device/dtype mismatch on GPU or half models; now inherits device/dtype. - MAJOR: coverage gaps behind the "6 architectures" claim: added real-model cases for bimodal CoReSleep (cross-attention + in-place residual write), L-SeqSleepNet (fold/unfold BiLSTMs, residual + LayerNorm) and ProtoSleepNet with the channel mixer; test skips only on ImportError so real failures surface. - MINOR: LRPMultiheadAttentionModule.forward mirrors nn.MultiheadAttention's positional signature (a positional key_padding_mask no longer binds to need_weights; per-head weights raise); prepare_model_for_lrp disables in-place activations itself and warns on training-mode models; BatchNorm merging warns when zennit is missing or the merge fails (affine=False) instead of silently falling back; explicit `q.grad is None` assertion; docstring/doc nits. Code formatted with black (88). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ng-mode warning) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… uniform) Exposes the alternative 50/50 product rule (Arras 2019 'LRP-all', LXT mul2) next to the default Arras signal-take, so users can compare how the gate rule changes recurrent attributions. Both conserve; tests added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rule conserves Under gate_rule="uniform" (Arras 2019 "LRP-all") half of a product's relevance flows into the gate branch; with the sigmoid on plain autograd that half was scaled by σ' and lost (CI: Σ R ≈ f/2). Gates now use the identity rule (st_identity) — value σ(z), relevance to the gate pre-activation — so both gate rules conserve; under signal-take the gates receive zero anyway. GRU's (1−z) is routed to the same pre-activation. Adds a GRU uniform conservation test. 91/91 on Sofia. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…SeqSleepNet N3 example - explain.md: how to choose ε from the ConservationReport (measured on the pretrained SeqSleepNet: 1e-6 inflates 2–3×, 1e-2 conserves, 1e-1 absorbs), and a literature-grounded note on why LRP is a root-point (Deep Taylor) decomposition rather than a path method, its relation to Integrated Gradients, DeepLIFT/DeepSHAP (Ancona et al. 2018 equivalences) and the status of "integrated LRP" (Zennit attributor + composite; exact only for LRP-0). - examples/explain/lrp_seqsleepnet_n3.py: reproducible script explaining the N3 logit of the pretrained seqsleepnet-phan on MASS training sequences with Saliency / Input×Gradient / IG and LRP under different rule assignments (gate rule, filterbank input rule, attention handling, ε sweep), saving attributions and conservation ratios. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Layer-wise Relevance Propagation for
physioex.explainAdds a conservation-based attribution family (Bach et al. 2015) to PhysioEx, faithful across the whole model zoo — including the recurrent and transformer architectures that plain conv/dense LRP cannot handle.
What's in
Two entry points, one convention (target neuron seeded with its logit so
Σ R ≈ f_c(x);return_report=Truegives a per-sampleConservationReport):LRP(Zennit composites)ModelLRPLRPLSTM/LRPGRUsubclassnn.LSTM/GRU, forward bit-identical),gate_rule="signal_take"|"uniform"ModelLRPprepare_model_for_lrprewrites a deep copy of the model (fused RNNs,TransformerEncoder/MultiheadAttention, PhysioEx's softmax poolings and the learnable filterbank via an adapter registry, ε on leaves, BatchNorm folding); plain+residuals in a model's ownforwardare redirected to the proportional rule at runtime (patch_residuals);audit_lrp_coverage/strict=Trueflag any parametric leaf left on plain autograd (gradient ≠ relevance). All primitives use a signed, dtype-safe stabiliserz + ε·sign z. No LXT dependency — theexplainextra iszennitonly.Verification
Developed under a multi-agent review: four independent reviewers (recurrent math, attention math, wiring/API, methodological completeness) → every reported defect reproduced numerically on Sofia before fixing → adversarial verification of the fixes → three further blockers fixed. Highlights of what that caught: the seqsleepnet filterbank propagating gradient instead of relevance, plain residuals over-counting exactly 2×, LXT's sign-blind stabiliser (±1e8 on near-cancelling sums), fp16 promotion, CoReSleep's in-place residual write invalidating saved views.
tests/explain/lrp/): forward-equivalence to the fused modules, exact conservation on bias-free blocks/models, rule/adapter wiring, robustness (no_grad, shared modules, idempotence, device/dtype), and all six real architectures end-to-end (incl. bimodal CoReSleep, L-SeqSleepNet, ProtoSleepNet with channel mixer) with full rule coverage. Green on Sofia and in CI (3.11/3.12).-Wdocs build green;physioex.explain.lrpadded to the API reference.seqsleepnet-phan(MASS, N3):examples/explain/lrp_seqsleepnet_n3.pycompares Saliency / Input×Gradient / IG with LRP under different rule assignments and shows why ε must be scaled to the activations (1e-6 inflates 2–3×, 1e-2 conserves).Docs
docs/pages/explain/explain.md: entry-point/rule matrix, howModelLRPrewrites a model, how to read conservation and choose ε, unsupported cases (masks,batch_first=False, RNN initial states,proj_size, VQ path), and a literature note on LRP vs path-based attribution (Deep Taylor, IG, DeepLIFT/DeepSHAP, "integrated LRP").Known limitations / follow-ups (not blocking)
attn_rule="attnlrp".bias_modeand a relative-ε default for recurrent models are natural next steps.🤖 Generated with Claude Code