Skip to content

Commit 014cb1e

Browse files
Guido Gagliardiclaude
andcommitted
refactor(explain/lrp): vendor the ε/identity rule wrappers, drop the 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>
1 parent b1b53a3 commit 014cb1e

11 files changed

Lines changed: 85 additions & 24 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
- name: Install package (editable) with dev + legacy + explain extras
3333
# Editable so coverage's source=["physioex"] measures the executed tree
3434
# (a non-editable install runs the site-packages copy -> 0% reported).
35-
# 'explain' pulls in zennit/lxt so the LRP tests run instead of skipping.
35+
# 'explain' pulls in zennit so the LRP tests run instead of skipping.
3636
run: pip install -e .[dev,legacy,explain]
3737

3838
- name: Run test suite with coverage gate

docs/pages/explain/explain.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,10 @@ ProtoSleepNet's VQ path (`quantize=True`) runs under `no_grad` and stops relevan
112112
`default_canonizers` (BatchNorm merge).
113113
- **Blocks**: `lrp/recurrent.py`, `lrp/transformer.py`, `lrp/pooling.py`;
114114
shared primitives in `lrp/_functional.py`; diagnostics in `lrp/diagnostics.py`.
115-
- **Backends**: [Zennit](https://github.com/chr5tphr/zennit) (composites,
116-
canonizers) and [LXT](https://github.com/rachtibat/LRP-eXplains-Transformers)
117-
≥ 2.0 (`EpsilonRule`/`IdentityRule` module wrappers).
115+
- **Backends**: [Zennit](https://github.com/chr5tphr/zennit) for the composites
116+
and BatchNorm canonization; the recurrent, attention and pooling rules are
117+
implemented in PhysioEx (`_functional.py`, `_rules.py`) following Arras et al.,
118+
Ali et al. (CP-LRP) and the LXT conventions — no LXT dependency.
118119

119120
See the [API Reference](../../api/index.md) for verified signatures across all
120121
families.

physioex/explain/lrp/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
pip install "physioex[explain]"
1919
"""
2020

21+
from physioex.explain.lrp._rules import EpsilonRule, IdentityRule
2122
from physioex.explain.lrp._functional import (
2223
add_eps,
2324
linear_eps,
@@ -77,7 +78,9 @@
7778
"LRPAttentionLayer",
7879
"LRPChannelMixer",
7980
"cp_weighted_pool",
80-
# primitives
81+
# rules & primitives
82+
"EpsilonRule",
83+
"IdentityRule",
8184
"linear_eps",
8285
"add_eps",
8386
"mul_signal_take",

physioex/explain/lrp/_rules.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Module-level LRP rule wrappers (relevance-as-gradient convention).
2+
3+
Small, dependency-free counterparts of LXT's ``EpsilonRule`` / ``IdentityRule``
4+
module wrappers: they replace a module in the tree, reproduce its forward
5+
exactly, and substitute the backward with the LRP rule.
6+
7+
* :class:`EpsilonRule` — ε-LRP through a module that is **linear in its input**
8+
(``Linear``, ``Conv*``, eval-mode ``BatchNorm``, PhysioEx's
9+
``LearnableFilterbank``): ``R_x = x ⊙ Jᵀ(R_y / (y ± ε))`` computed with a
10+
vector-Jacobian product, so any such module is handled without knowing its
11+
weights layout. Bias relevance is absorbed (standard LRP-ε).
12+
* :class:`IdentityRule` — relevance passes through unchanged (LayerNorm,
13+
GroupNorm, element-wise activations; shape-preserving modules only).
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import torch
19+
import torch.nn as nn
20+
21+
from physioex.explain.lrp._functional import stabilize, st_identity
22+
23+
24+
class _RuleWrapper(nn.Module):
25+
"""Base class: holds the wrapped ``module`` (frozen)."""
26+
27+
def __init__(self, module: nn.Module):
28+
super().__init__()
29+
self.module = module
30+
for p in module.parameters():
31+
p.requires_grad_(False)
32+
33+
def extra_repr(self) -> str: # pragma: no cover - repr only
34+
return ""
35+
36+
37+
class _EpsilonVJP(torch.autograd.Function):
38+
@staticmethod
39+
def forward(ctx, x, module, epsilon):
40+
y = module(x)
41+
ctx.module, ctx.epsilon = module, epsilon
42+
ctx.save_for_backward(x, y)
43+
return y
44+
45+
@staticmethod
46+
def backward(ctx, relevance):
47+
x, y = ctx.saved_tensors
48+
s = relevance / stabilize(y, ctx.epsilon)
49+
with torch.enable_grad():
50+
x_ = x.detach().requires_grad_(True)
51+
(grad,) = torch.autograd.grad(ctx.module(x_), x_, s)
52+
return x * grad, None, None
53+
54+
55+
class EpsilonRule(_RuleWrapper):
56+
"""ε-LRP wrapper for a single-input module linear in its input."""
57+
58+
def __init__(self, module: nn.Module, epsilon: float = 1e-6):
59+
super().__init__(module)
60+
self.epsilon = float(epsilon)
61+
62+
def forward(self, x):
63+
return _EpsilonVJP.apply(x, self.module, self.epsilon)
64+
65+
66+
class IdentityRule(_RuleWrapper):
67+
"""Identity-rule wrapper: forward is the module's, backward passes relevance."""
68+
69+
def forward(self, x, *args, **kwargs):
70+
return st_identity(x, self.module(x, *args, **kwargs))

physioex/explain/lrp/model.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* standalone ``nn.MultiheadAttention`` → :class:`LRPMultiheadAttentionModule`
99
* registered custom blocks (PhysioEx's softmax poolings, the learnable
1010
filterbank; see :func:`register_lrp_adapter`) → their CP-LRP / ε adapters
11-
* leaf ``Linear`` / ``Conv`` → LXT ``EpsilonRule`` (ε-LRP)
11+
* leaf ``Linear`` / ``Conv`` → ``EpsilonRule`` (ε-LRP, vjp)
1212
* ``BatchNorm`` → merged into the preceding linear layer
1313
(Zennit canonizer), then identity
1414
* ``LayerNorm`` / ``GroupNorm`` / element-wise activations → identity rule
@@ -23,7 +23,7 @@
2323
``TorchFunctionMode`` (``patch_residuals=True``); without it a residual gives
2424
both branches the full relevance and total relevance is over-counted.
2525
26-
Requires the ``explain`` extra (``zennit``, ``lxt>=2.0``).
26+
Requires the ``explain`` extra (``zennit``, for composites and BatchNorm canonization).
2727
"""
2828

2929
from __future__ import annotations
@@ -37,6 +37,7 @@
3737
import torch.nn as nn
3838

3939
from physioex.explain.lrp._functional import add_eps, target_seed
40+
from physioex.explain.lrp._rules import EpsilonRule, IdentityRule, _RuleWrapper
4041
from physioex.explain.lrp.diagnostics import ConservationReport
4142
from physioex.explain.lrp.pooling import (
4243
LRPAttentionLayer,
@@ -62,10 +63,8 @@
6263

6364

6465
def _epsilon_rule_factory(module: nn.Module, epsilon: float) -> nn.Module:
65-
"""ε-LRP via LXT's vjp super-function — exact for modules linear in their
66+
"""ε-LRP via a vector-Jacobian product — exact for modules linear in their
6667
input (e.g. ``LearnableFilterbank``: ``x @ (sigmoid(W)·S)``)."""
67-
from lxt.explicit.rules import EpsilonRule
68-
6968
return EpsilonRule(module, epsilon)
7069

7170

@@ -130,11 +129,7 @@ def _adapter_for(module: nn.Module) -> Optional[AdapterFactory]:
130129

131130

132131
def _is_lrp_module(m: nn.Module) -> bool:
133-
try:
134-
from lxt.explicit.rules import WrapModule
135-
except ImportError: # pragma: no cover
136-
WrapModule = ()
137-
return isinstance(m, _LRP_TYPES) or isinstance(m, WrapModule)
132+
return isinstance(m, _LRP_TYPES) or isinstance(m, _RuleWrapper)
138133

139134

140135
def _bn_is_identity(bn: nn.Module) -> bool:
@@ -162,8 +157,6 @@ def _merge_batchnorm(model: nn.Module):
162157

163158
def _replace(child: nn.Module, epsilon: float):
164159
"""Return the LRP replacement for ``child`` or ``None`` to recurse into it."""
165-
from lxt.explicit.rules import EpsilonRule, IdentityRule
166-
167160
if _is_lrp_module(child):
168161
return child # already prepared (idempotence)
169162
if isinstance(child, nn.LSTM):

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@ datasets = ["mne>=1.6.0"] # HOMEPAP EDF fallback reader (data.datas
4545
dev = ["ruff", "pytest", "pytest-cov", "black~=22.0"]
4646
legacy = ["pytorch_lightning>=2.5.0", "lightning>=2.5.0", "torchmetrics>=1.8.0"]
4747
explain = [
48-
"zennit>=0.5.1", # LRP rules/composites/canonizers for CNN/RNN architectures
49-
"lxt>=2.0", # lxt.explicit rules (EpsilonRule/IdentityRule); 0.x lacks the explicit API
48+
"zennit>=0.5.1", # LRP composites/canonizers (Zennit path) + BatchNorm merging; attention/RNN rules are built in
5049
]
5150
docs = [
5251
"sphinx==8.1.3",

tests/explain/lrp/test_functional.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import pytest
44
import torch
55

6-
pytest.importorskip("lxt.explicit", reason="requires the 'explain' extra (lxt>=2.0)")
76

87
from physioex.explain.lrp._functional import ( # noqa: E402
98
add_eps,

tests/explain/lrp/test_model.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import torch
1515
import torch.nn as nn
1616

17-
pytest.importorskip("lxt.explicit", reason="requires the 'explain' extra (lxt>=2.0)")
1817
pytest.importorskip("zennit")
1918

2019
from physioex.explain.lrp import ( # noqa: E402

tests/explain/lrp/test_real_models.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import pytest
1313
import torch
1414

15-
pytest.importorskip("lxt.explicit", reason="requires the 'explain' extra (lxt>=2.0)")
1615
pytest.importorskip("zennit")
1716

1817
from physioex.explain.lrp import ModelLRP, prepare_model_for_lrp # noqa: E402

tests/explain/lrp/test_recurrent.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import torch
1010
import torch.nn as nn
1111

12-
pytest.importorskip("lxt.explicit", reason="requires the 'explain' extra (lxt>=2.0)")
1312

1413
from physioex.explain.lrp.recurrent import LRPGRU, LRPLSTM # noqa: E402
1514

0 commit comments

Comments
 (0)