Skip to content

Commit 965abb5

Browse files
Guido Gagliardiclaude
andcommitted
feat(explain/lrp): end-to-end model wiring + attention-pooling CP-LRP (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>
1 parent 70082d3 commit 965abb5

6 files changed

Lines changed: 391 additions & 14 deletions

File tree

physioex/explain/lrp/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,34 @@
1717
from physioex.explain.lrp.attributor import LRP
1818
from physioex.explain.lrp.canonizers import default_canonizers
1919
from physioex.explain.lrp.composites import epsilon_composite, physioex_composite
20+
from physioex.explain.lrp.model import ModelLRP, prepare_model_for_lrp
21+
from physioex.explain.lrp.pooling import LRPAttentionLayer, LRPAttentionPooling
22+
from physioex.explain.lrp.recurrent import LRPGRU, LRPLSTM
23+
from physioex.explain.lrp.transformer import (
24+
LRPMultiheadAttention,
25+
LRPTransformerEncoder,
26+
LRPTransformerEncoderLayer,
27+
swap_transformer_layers,
28+
)
2029

2130
__all__ = [
31+
# CNN / Zennit composites (Phase 1)
2232
"LRP",
2333
"physioex_composite",
2434
"epsilon_composite",
2535
"default_canonizers",
36+
# recurrent (Phase 2a)
37+
"LRPLSTM",
38+
"LRPGRU",
39+
# transformer / attention (Phase 2b)
40+
"LRPMultiheadAttention",
41+
"LRPTransformerEncoderLayer",
42+
"LRPTransformerEncoder",
43+
"swap_transformer_layers",
44+
# attention pooling (Phase 2c)
45+
"LRPAttentionPooling",
46+
"LRPAttentionLayer",
47+
# whole-model wiring (Phase 2c)
48+
"ModelLRP",
49+
"prepare_model_for_lrp",
2650
]

physioex/explain/lrp/model.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""End-to-end LRP for whole PhysioEx models (Phase 2c wiring).
2+
3+
:func:`prepare_model_for_lrp` swaps every fused / attention block in a model for
4+
its relevance-carrying counterpart:
5+
6+
* ``nn.LSTM`` / ``nn.GRU`` → :class:`LRPLSTM` / :class:`LRPGRU`
7+
* ``nn.TransformerEncoder(Layer)`` → the ``transformer`` LRP versions
8+
* ``nn.MultiheadAttention`` → :class:`LRPMultiheadAttention`
9+
* custom softmax pooling (matched by class name ``AttentionPooling`` /
10+
``AttentionLayer``) → the ``pooling`` CP-LRP versions
11+
* remaining leaf ``Linear`` / ``Conv`` / ``BatchNorm`` → LXT ``EpsilonRule``
12+
* standalone ``LayerNorm`` → LXT ``IdentityRule``
13+
14+
:class:`ModelLRP` deep-copies the trained model, prepares it, then runs a forward
15+
plus a target-seeded backward, returning relevance shaped like the input.
16+
17+
Requires the ``explain`` extra (``lxt``).
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import copy
23+
24+
import torch
25+
import torch.nn as nn
26+
27+
from physioex.explain.lrp.pooling import LRPAttentionLayer, LRPAttentionPooling
28+
from physioex.explain.lrp.recurrent import LRPGRU, LRPLSTM
29+
from physioex.explain.lrp.transformer import (
30+
LRPMultiheadAttention,
31+
LRPTransformerEncoder,
32+
LRPTransformerEncoderLayer,
33+
)
34+
35+
# custom pooling classes are matched by name to avoid importing the model modules
36+
_POOLING_BY_NAME = {
37+
"AttentionPooling": LRPAttentionPooling,
38+
"AttentionLayer": LRPAttentionLayer,
39+
}
40+
41+
42+
def prepare_model_for_lrp(model: nn.Module, epsilon: float = 1e-6) -> nn.Module:
43+
"""In-place: replace fused/attention blocks with their LRP counterparts and
44+
wrap remaining linear leaves with LXT rules. Returns ``model``.
45+
46+
Call on a copy (see :class:`ModelLRP`) — it mutates the module tree.
47+
"""
48+
from lxt.explicit.rules import EpsilonRule, IdentityRule
49+
50+
for name, child in list(model.named_children()):
51+
cname = type(child).__name__
52+
if isinstance(child, nn.LSTM):
53+
setattr(model, name, LRPLSTM.from_torch(child))
54+
elif isinstance(child, nn.GRU):
55+
setattr(model, name, LRPGRU.from_torch(child))
56+
elif isinstance(child, nn.TransformerEncoder):
57+
setattr(model, name, LRPTransformerEncoder.from_torch(child))
58+
elif isinstance(child, nn.TransformerEncoderLayer):
59+
setattr(model, name, LRPTransformerEncoderLayer.from_torch(child))
60+
elif isinstance(child, nn.MultiheadAttention):
61+
setattr(model, name, LRPMultiheadAttention.from_torch(child))
62+
elif cname in _POOLING_BY_NAME:
63+
setattr(model, name, _POOLING_BY_NAME[cname](child, epsilon))
64+
elif isinstance(child, (nn.Linear, nn.Conv1d, nn.Conv2d, nn.Conv3d)):
65+
setattr(model, name, EpsilonRule(child, epsilon))
66+
elif isinstance(child, nn.LayerNorm):
67+
setattr(model, name, IdentityRule(child))
68+
elif isinstance(child, (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d)):
69+
# in eval mode BatchNorm is an affine-linear op → ε-LRP
70+
setattr(model, name, EpsilonRule(child, epsilon))
71+
else:
72+
# not a leaf/known block: recurse into it
73+
prepare_model_for_lrp(child, epsilon)
74+
return model
75+
76+
77+
class ModelLRP(nn.Module):
78+
"""LRP attribution for a whole PhysioEx model (recurrent / transformer /
79+
attention architectures).
80+
81+
Args:
82+
model: trained model, ``(B, L, C, T[, F]) -> (B, L, n_classes)`` (or a
83+
dict output — set ``output_key``).
84+
in_index / out_index: sequence epoch and class to explain.
85+
output_key: for models returning a dict (e.g. CoReSleep → ``"combined"``).
86+
epsilon: ε for the ε-LRP rules.
87+
88+
``forward(x)`` returns relevance shaped like ``x``. The trained model is
89+
deep-copied and left untouched.
90+
"""
91+
92+
def __init__(
93+
self,
94+
model: nn.Module,
95+
in_index: int = 0,
96+
out_index: int = 0,
97+
output_key: str | None = None,
98+
epsilon: float = 1e-6,
99+
):
100+
super().__init__()
101+
self.in_index = in_index
102+
self.out_index = out_index
103+
self.output_key = output_key
104+
prepared = copy.deepcopy(model).eval()
105+
self.model = prepare_model_for_lrp(prepared, epsilon)
106+
# LRP saves tensors for the custom backward, so in-place ops (e.g.
107+
# ReLU(inplace=True)) would corrupt them — disable them on the copy.
108+
for m in self.model.modules():
109+
if getattr(m, "inplace", False):
110+
m.inplace = False
111+
112+
def forward(self, x: torch.Tensor) -> torch.Tensor:
113+
x = x.detach().requires_grad_(True)
114+
out = self.model(x)
115+
if self.output_key is not None:
116+
out = out[self.output_key]
117+
seed = torch.zeros_like(out)
118+
seed[:, self.in_index, self.out_index] = out[
119+
:, self.in_index, self.out_index
120+
].detach()
121+
out.backward(seed)
122+
return x.grad

physioex/explain/lrp/pooling.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""CP-LRP for softmax attention-pooling blocks.
2+
3+
PhysioEx's custom attention poolings all collapse a sequence with softmax
4+
weights: ``out = Σ_t softmax(score(x))_t · x_t`` — ``AttentionPooling``
5+
(sleeptransformer), ``AttentionLayer`` (seqsleepnet, reused by lseqsleepnet and
6+
protosleepnet), and ``ChannelMixer.attn_pool`` (protosleepnet). Their softmax
7+
leaks relevance just like attention, so the conservative-propagation (CP-LRP)
8+
rule applies: treat the pooling weights as **constant** and route relevance
9+
through the value path, which conserves exactly.
10+
11+
Requires the ``explain`` extra.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import torch
17+
import torch.nn as nn
18+
19+
20+
class _CPWeightedPool(torch.autograd.Function):
21+
"""``out = Σ_t w_t · x_t`` over the sequence dim, weights constant.
22+
23+
Backward (ε-LRP through the linear pooling with fixed ``w``):
24+
``R(x[t,d]) = x[t,d] · w[t] · R_out[d] / (out[d]+ε)``.
25+
"""
26+
27+
@staticmethod
28+
def forward(ctx, x, weights, epsilon):
29+
out = (x * weights).sum(dim=1) # (B, D)
30+
ctx.save_for_backward(x, weights, out)
31+
ctx.epsilon = epsilon
32+
return out
33+
34+
@staticmethod
35+
def backward(ctx, relevance):
36+
x, weights, out = ctx.saved_tensors
37+
eps = ctx.epsilon
38+
denom = out + torch.where(out >= 0, eps, -eps)
39+
s = (relevance / denom).unsqueeze(1) # (B, 1, D)
40+
r_x = x * weights * s
41+
return r_x, None, None
42+
43+
44+
def cp_weighted_pool(x, weights, epsilon=1e-6):
45+
"""Weighted sum over ``dim=1`` with the CP-LRP backward. ``weights`` is
46+
``(B, T, 1)`` and must be detached (constant) by the caller."""
47+
return _CPWeightedPool.apply(x, weights, epsilon)
48+
49+
50+
class LRPAttentionPooling(nn.Module):
51+
"""CP-LRP for ``sleeptransformer.AttentionPooling`` (and any pooling with a
52+
``self.attention`` MLP + softmax over ``dim=1``)."""
53+
54+
def __init__(self, orig: nn.Module, epsilon: float = 1e-6):
55+
super().__init__()
56+
self.orig = orig
57+
self.epsilon = epsilon
58+
59+
def forward(self, x):
60+
with torch.no_grad():
61+
w = torch.softmax(self.orig.attention(x), dim=1) # (B, T, 1)
62+
return cp_weighted_pool(x, w, self.epsilon)
63+
64+
65+
class LRPAttentionLayer(nn.Module):
66+
"""CP-LRP for ``seqsleepnet.AttentionLayer`` (additive/Bahdanau pooling with
67+
a manual softmax)."""
68+
69+
def __init__(self, orig: nn.Module, epsilon: float = 1e-6):
70+
super().__init__()
71+
self.orig = orig
72+
self.epsilon = epsilon
73+
74+
def forward(self, x, r_alphas: bool = False):
75+
with torch.no_grad():
76+
B, S, H = x.size()
77+
v = torch.tanh(
78+
torch.matmul(x.reshape(B * S, H), self.orig.W_omega)
79+
+ self.orig.b_omega.reshape(1, -1)
80+
)
81+
vu = torch.matmul(v, self.orig.u_omega.reshape(-1, 1))
82+
exps = torch.exp(vu).reshape(-1, S)
83+
alphas = (exps / exps.sum(1, keepdim=True)).reshape(B, S, 1)
84+
out = cp_weighted_pool(x, alphas, self.epsilon)
85+
if r_alphas:
86+
return out, alphas.reshape(B, S)
87+
return out

physioex/explain/lrp/recurrent.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,17 @@ def _layer_dir(self, x, w_ih, w_hh, b_ih, b_hh, reverse: bool):
146146
# h' = o⊙tanh(c') ; source is tanh(c') → identity bwd to c'
147147
h = mul_signal_take(o, _st_act(c, torch.tanh(c)))
148148
outs[t] = h
149-
return torch.stack(outs, dim=0)
149+
return torch.stack(outs, dim=0), h, c # (T,B,H), final (h, c)
150150

151-
def forward(self, x: torch.Tensor) -> torch.Tensor:
151+
def forward(self, x: torch.Tensor):
152+
"""Returns ``(output, (h_n, c_n))`` like ``nn.LSTM``."""
152153
if self.batch_first:
153154
x = x.transpose(0, 1) # (B, T, in) -> (T, B, in)
154-
num_dir = 2 if self.bidirectional else 1
155155
layer_in = x
156+
h_states, c_states = [], []
156157
for layer in range(self.num_layers):
157158
suff = f"_l{layer}"
158-
fwd = self._layer_dir(
159+
fwd, hf, cf = self._layer_dir(
159160
layer_in,
160161
self._p("weight_ih" + suff),
161162
self._p("weight_hh" + suff),
@@ -164,7 +165,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
164165
reverse=False,
165166
)
166167
if self.bidirectional:
167-
bwd = self._layer_dir(
168+
bwd, hb, cb = self._layer_dir(
168169
layer_in,
169170
self._p("weight_ih" + suff + "_reverse"),
170171
self._p("weight_hh" + suff + "_reverse"),
@@ -173,12 +174,16 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
173174
reverse=True,
174175
)
175176
layer_in = torch.cat([fwd, bwd], dim=-1) # (T, B, 2H)
177+
h_states += [hf, hb]
178+
c_states += [cf, cb]
176179
else:
177180
layer_in = fwd
181+
h_states.append(hf)
182+
c_states.append(cf)
178183
out = layer_in
179184
if self.batch_first:
180185
out = out.transpose(0, 1) # (T, B, dir*H) -> (B, T, dir*H)
181-
return out
186+
return out, (torch.stack(h_states, 0), torch.stack(c_states, 0))
182187

183188

184189
# ---------------------------------------------------------------------------
@@ -256,15 +261,17 @@ def _layer_dir(self, x, w_ih, w_hh, b_ih, b_hh, reverse: bool):
256261
one_minus_z = 1.0 - z
257262
h = add2(mul_signal_take(one_minus_z, n), mul_signal_take(z, h))
258263
outs[t] = h
259-
return torch.stack(outs, dim=0)
264+
return torch.stack(outs, dim=0), h # (T,B,H), final h
260265

261-
def forward(self, x: torch.Tensor) -> torch.Tensor:
266+
def forward(self, x: torch.Tensor):
267+
"""Returns ``(output, h_n)`` like ``nn.GRU``."""
262268
if self.batch_first:
263269
x = x.transpose(0, 1)
264270
layer_in = x
271+
h_states = []
265272
for layer in range(self.num_layers):
266273
suff = f"_l{layer}"
267-
fwd = self._layer_dir(
274+
fwd, hf = self._layer_dir(
268275
layer_in,
269276
self._p("weight_ih" + suff),
270277
self._p("weight_hh" + suff),
@@ -273,7 +280,7 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
273280
reverse=False,
274281
)
275282
if self.bidirectional:
276-
bwd = self._layer_dir(
283+
bwd, hb = self._layer_dir(
277284
layer_in,
278285
self._p("weight_ih" + suff + "_reverse"),
279286
self._p("weight_hh" + suff + "_reverse"),
@@ -282,9 +289,11 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
282289
reverse=True,
283290
)
284291
layer_in = torch.cat([fwd, bwd], dim=-1)
292+
h_states += [hf, hb]
285293
else:
286294
layer_in = fwd
295+
h_states.append(hf)
287296
out = layer_in
288297
if self.batch_first:
289298
out = out.transpose(0, 1)
290-
return out
299+
return out, torch.stack(h_states, 0)

0 commit comments

Comments
 (0)