Skip to content

Layer-wise Relevance Propagation for physioex.explain (CNN, recurrent, attention models) - #14

Merged
guidogagl merged 17 commits into
physioex-devfrom
feat/lrp-explain
Sep 13, 2026
Merged

guidogagl merged 17 commits into
physioex-devfrom
feat/lrp-explain

Conversation

@guidogagl

Copy link
Copy Markdown
Owner

Layer-wise Relevance Propagation for physioex.explain

Adds 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=True gives a per-sample ConservationReport):

Models Entry point Rules
Feed-forward / CNN (Tsinalis, Chambon2018) LRP (Zennit composites) ε dense · γ conv · first layer (unbounded EEG, not the pixel z-box) · BatchNorm canonized · αβ/flat/z-box configurable
Recurrent (TinySleepNet, SeqSleepNet, L-SeqSleepNet, ProtoSleepNet-seq) ModelLRP Arras signal-take LSTM/GRU at cell level (LRPLSTM/LRPGRU subclass nn.LSTM/GRU, forward bit-identical), gate_rule="signal_take"|"uniform"
Attention / transformer (SleepTransformer, CoReSleep, ProtoSleepNet-tf) ModelLRP CP-LRP attention (Ali et al. 2022; value path, exact conservation), identity rule on LayerNorm/activations, proportional rule on residuals, ε on projections/FFN

prepare_model_for_lrp rewrites 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 own forward are redirected to the proportional rule at runtime (patch_residuals); audit_lrp_coverage / strict=True flag any parametric leaf left on plain autograd (gradient ≠ relevance). All primitives use a signed, dtype-safe stabiliser z + ε·sign z. No LXT dependency — the explain extra is zennit only.

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.

  • 91 tests (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).
  • Strict -W docs build green; physioex.explain.lrp added to the API reference.
  • Applied check on the pretrained seqsleepnet-phan (MASS, N3): examples/explain/lrp_seqsleepnet_n3.py compares 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, how ModelLRP rewrites 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)

  • CP-LRP gives zero relevance to the query/key path by design; full AttnLRP (non-conserving) could be added as attn_rule="attnlrp".
  • Bias relevance is absorbed (LRP-ε convention); an Arras δ=1 bias_mode and a relative-ε default for recurrent models are natural next steps.
  • Phase 3 (foundation encoders: SleepFM easy, NeuroLM medium, BIOT/REVE blockers) and Phase 4 (DFT/STFT-LRP on the existing spectral layers) not started.
  • DeepLIFT-style explicit-reference rule and head-level relevance would complete the toolkit.

🤖 Generated with Claude Code

Guido Gagliardi and others added 17 commits September 10, 2026 04:33
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>
@guidogagl
guidogagl merged commit abf90c9 into physioex-dev Sep 13, 2026
6 checks passed
@guidogagl
guidogagl deleted the feat/lrp-explain branch September 13, 2026 10:30
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.

1 participant