"""Conceptual Spectral Decomposition (CSD).
Explains foundation model embeddings by:
1. Using a pluggable specificity strategy to identify which embedding
dimensions are class-specific (vs generic/shared across classes)
2. Running SpectralGradients independently on each selected dimension
to produce per-concept time-frequency attribution maps
3. Optionally aggregating concept maps into a single class-level map
The primary output is a **per-concept decomposition**: one (n_bands, T)
time-frequency map for each class-specific embedding dimension, showing
which spectral patterns in the input signal drive that concept.
The secondary output is a **class-level aggregation**: the weighted sum
of all concept maps, answering "why class c?" in time-frequency.
Usage::
from physioex.explain.foundational import ConceptualSpectralDecomposition
from physioex.explain.foundational import MarginSpecificity
from physioex.models.embed import load_probe
# Load encoder + trained probe
model, probe_weights = load_probe("cbramod", "hmc", probe_path="...")
csd = ConceptualSpectralDecomposition(
model=model, probe_weights=probe_weights,
fs=200.0, freq_step=4.0,
specificity=MarginSpecificity(tau=0.5),
)
result = csd.explain(x, target_class=3)
# Per-concept maps (primary result)
for d, concept in result.concepts.items():
print(f"dim {d}: W={concept.weight:.3f}, top freq={concept.top_band_hz:.1f} Hz")
# concept.attribution: (B, n_bands, T)
# Aggregated class map (secondary)
# result.class_attribution: (B, n_bands, T) = Σ_d W[c,d] · map_d
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import torch
import torch.nn as nn
from torch import Tensor
from physioex.explain.foundational.multichannel_sg import MultiChannelSpectralGradients
from physioex.explain.foundational.sleep_bands import FrequencyBand, SLEEP_BANDS
from physioex.explain.foundational.specificity import (
SpecificityStrategy,
MarginSpecificity,
)
logger = logging.getLogger("physioex.explain.csd")
@dataclass
class ConceptAttribution:
"""Attribution map for a single embedding dimension (concept)."""
dim: int # embedding dimension index
attribution: Tensor # (B, n_bands, C, T) raw SG map OR (B, n_bands) if skip_ig=True
weight: float # W[c, d] — probe weight for target class
mask_value: float # specificity mask value for this dim
contribution: Tensor # (B,) = W[c,d] * emb_normed_d per sample
skip_ig: bool = False # Whether fast mode (no temporal resolution) was used
@property
def weighted_attribution(self) -> Tensor:
"""(B, n_bands, C, T) attribution weighted by probe weight."""
return self.attribution * self.weight
@property
def channel_aggregated(self) -> Tensor:
"""(B, n_bands, T) attribution summed over channels."""
if self.skip_ig:
raise AttributeError("channel_aggregated not available in skip_ig mode (no temporal resolution)")
return self.attribution.sum(dim=2)
@property
def band_energy(self) -> Tensor:
"""(n_bands,) total attribution energy per band (mean over B, sum over C and T)."""
if self.skip_ig:
# In skip_ig mode, attribution is (B, n_bands) - mean over B directly
return self.attribution.abs().mean(dim=0)
return self.attribution.abs().mean(dim=0).sum(dim=(-2, -1))
@property
def channel_energy(self) -> Tensor:
"""(C,) total attribution energy per channel (mean over B, sum over bands and T)."""
if self.skip_ig:
raise AttributeError("channel_energy not available in skip_ig mode (no channel resolution)")
return self.attribution.abs().mean(dim=0).sum(dim=(0, -1))
@property
def top_band_idx(self) -> int:
return self.band_energy.argmax().item()
[docs]
@dataclass
class CSDResult:
"""Result of a CSD explanation."""
# Per-concept maps (PRIMARY result)
concepts: Dict[int, ConceptAttribution] # {dim: ConceptAttribution}
# Class-level aggregation (SECONDARY — derived from concepts)
class_attribution: Tensor # (B, n_bands, T) = Σ_d W[c,d] · map_d
# Specificity mask
mask: Tensor # (D,) or (B, D)
selected_dims: List[int] # dimensions selected by the mask
# Metadata
band_frequencies: Tensor # (n_bands,) in Hz
target_class: int
specificity_name: str
probe_weights: Tensor # (D,) = W[c, :]
contributions: Tensor # (B, D) = W[c,:] * emb_normed
[docs]
def top_band_hz(self, dim: int) -> float:
"""Return the center frequency of the top band for a concept."""
idx = self.concepts[dim].top_band_idx
return self.band_frequencies[idx].item()
[docs]
class ConceptualSpectralDecomposition(nn.Module):
"""CSD: per-concept time-frequency explanation of foundation model embeddings.
For each class-specific embedding dimension (selected by the specificity
strategy), runs SpectralGradients to produce an independent (n_bands, T)
attribution map. The class-level map is the weighted sum of all concept maps.
Args:
model: FoundationEncoder (pure encoder without head).
probe_weights: Dictionary containing 'ln' (LayerNorm) and 'W' (Linear weights)
from the trained linear probe.
fs: Sampling rate of the preprocessed signal (Hz).
freq_step: SpectralGradients band width (Hz). Default 4.0.
bands: Custom frequency bands. If None, uniform bands of width freq_step.
steps: Integration steps per local IG. Default 10. Ignored if skip_ig=True.
path: SG ablation direction ('marginal', 'cumulative_add', 'cumulative_remove',
'both_cumulative', 'shapley'). Default 'marginal'.
specificity: Strategy for selecting class-specific dimensions.
Default: MarginSpecificity(tau=0.5).
mask_threshold: Dimensions with mask > threshold get their own map.
Default 0.5.
skip_ig: If True, skip IG computation and return fast band importance only.
Returns (B, n_bands) instead of (B, n_bands, C, T). Default False.
n_perms: Number of permutations for Shapley path. Default 20.
device: Torch device. Default: auto.
"""
def __init__(
self,
model: nn.Module,
probe_weights: dict,
fs: float,
freq_step: float = 4.0,
bands: Optional[List[FrequencyBand]] = None,
steps: int = 10,
path: str = "marginal",
specificity: Optional[SpecificityStrategy] = None,
mask_threshold: float = 0.5,
skip_ig: bool = False,
n_perms: int = 20,
device: Optional[str] = None,
):
super().__init__()
self.model = model
self.fs = fs
self.freq_step = freq_step
self.bands = bands
self.steps = steps
self.path = path
self.specificity = specificity or MarginSpecificity(tau=0.5)
self.mask_threshold = mask_threshold
self.skip_ig = skip_ig
self.n_perms = n_perms
self.device = torch.device(
device or ("cuda:0" if torch.cuda.is_available() else "cpu")
)
# Extract probe weights from dict
self._ln = probe_weights["ln"] # LayerNorm
self._W = probe_weights["W"] # Linear weight: (n_classes, D)
self._n_classes = self._W.shape[0]
self._D = self._W.shape[1]
def _prepare_signal(self, x: Tensor) -> Tensor:
"""Preprocess signal, keeping multi-channel structure.
Returns:
x_prep: (B, C', T') preprocessed signal (always 3D)
"""
if x.ndim == 4:
B, L, C, T = x.shape
x = x.reshape(B * L, C, T)
x_prep = self.model._preprocess(x)
# Ensure 3D: (B, C, T)
if x_prep.ndim == 2:
x_prep = x_prep.unsqueeze(1)
return x_prep
def _estimate_n_bands(self, T: int) -> int:
freq_res = self.fs / T
bin_step = max(1, round(self.freq_step / freq_res))
n_freqs = T // 2 + 1
return math.ceil(n_freqs / bin_step)
def _make_dim_scorer(self, dim: int):
"""Create a scoring function for a single embedding dimension.
The function maps multi-channel signal (B, C, T) -> (B,) scalar.
Used as the `f` argument to MultiChannelSpectralGradients.
"""
def f_dim(x_signal):
# x_signal: (B, C, T) from MultiChannelSG
emb = self.model._encode(x_signal) # (B, D)
return emb[:, dim] # (B,) scalar
return f_dim
[docs]
def explain(
self,
x: Tensor,
target_class: int,
embeddings: Optional[Tensor] = None,
labels: Optional[Tensor] = None,
max_concepts: Optional[int] = None,
) -> CSDResult:
"""Compute per-concept CSD explanation.
Args:
x: (B, C, T) or (B, L, C, T) raw signal.
target_class: Class index to explain.
embeddings: (N, D) dataset embeddings for static strategies.
labels: (N,) labels for static strategies.
max_concepts: Limit number of concepts to explain (None = all
above threshold). Selects top by ``|mask * W[c,d]|``.
Returns:
CSDResult with per-concept maps and aggregated class map.
"""
x = x.to(self.device)
self.model.to(self.device)
self.model.eval()
# 1. Preprocess (keeps multi-channel structure)
x_prep = self._prepare_signal(x) # (B, C, T)
B, C_prep, T_prep = x_prep.shape
# 2. Get embeddings and compute specificity mask
with torch.no_grad():
emb = self.model._encode(x_prep)
emb_normed = self._ln(emb)
W_c = self._W[target_class].to(self.device)
contributions = W_c.unsqueeze(0) * emb_normed # (B, D)
# Apply LayerNorm to dataset embeddings too, so that static
# strategies (CohenDSpecificity) operate on the same quantities
# that the probe sees. Without this, Cohen's d would be computed
# on raw embeddings while the probe uses LayerNorm'd embeddings,
# potentially giving a different specificity ranking.
embeddings_normed = None
if embeddings is not None:
with torch.no_grad():
embeddings_normed = self._ln(embeddings.to(self.device))
mask = self.specificity.compute_mask(
W=self._W.to(self.device),
embeddings=embeddings_normed,
labels=labels,
x=emb_normed,
target_class=target_class,
).to(self.device)
# 3. Select dimensions above threshold
if mask.ndim == 2:
mask_summary = mask.mean(dim=0) # (D,) average over batch
else:
mask_summary = mask
above_threshold = (mask_summary > self.mask_threshold).nonzero(as_tuple=True)[0]
# Rank by |mask * W[c,d]| and optionally limit
importance = (mask_summary * W_c.abs())[above_threshold]
order = importance.argsort(descending=True)
selected = above_threshold[order]
if max_concepts is not None:
selected = selected[:max_concepts]
selected_dims = selected.tolist()
n_selected = len(selected_dims)
logger.info(
f"[CSD] class={target_class}, {n_selected} concepts selected "
f"(threshold={self.mask_threshold}, strategy={self.specificity.name})"
)
# 4. Run MultiChannelSG for each selected dimension independently
concepts: Dict[int, ConceptAttribution] = {}
band_freqs = None
for i, d in enumerate(selected_dims):
f_d = self._make_dim_scorer(d)
mcsg = MultiChannelSpectralGradients(
f=f_d,
fs=self.fs,
freq_step=self.freq_step,
bands=self.bands,
steps=self.steps,
path=self.path,
n_perms=self.n_perms,
)
attr_d = mcsg(x_prep, skip_ig=self.skip_ig) # (B, n_bands) if skip_ig, else (B, n_bands, C, T)
if band_freqs is None:
band_freqs = mcsg.band_frequencies()
mask_val = mask_summary[d].item()
w_val = W_c[d].item()
contrib_d = contributions[:, d] # (B,)
concepts[d] = ConceptAttribution(
dim=d,
attribution=attr_d.detach(),
weight=w_val,
mask_value=mask_val,
contribution=contrib_d.detach(),
skip_ig=self.skip_ig,
)
logger.info(
f" concept {i+1}/{n_selected}: dim={d}, W={w_val:+.3f}, "
f"mask={mask_val:.2f}"
)
# 5. Aggregate: class map = Σ_d W[c,d] · map_d
if concepts:
if self.skip_ig:
# In skip_ig mode: attribution is (B, n_bands), just weight and sum
class_attr = torch.zeros(B, next(iter(concepts.values())).attribution.shape[1], device=self.device)
for d, concept in concepts.items():
class_attr = class_attr + concept.weight * concept.attribution
else:
# Full mode: attribution is (B, n_bands, C, T)
class_attr = torch.zeros_like(next(iter(concepts.values())).attribution)
for d, concept in concepts.items():
class_attr = class_attr + concept.weighted_attribution
else:
# No concepts selected — return zeros
n_bands_val = self._estimate_n_bands(T_prep)
if self.skip_ig:
class_attr = torch.zeros(B, n_bands_val, device=self.device)
else:
class_attr = torch.zeros(B, n_bands_val, C_prep, T_prep, device=self.device)
band_freqs = torch.zeros(n_bands_val)
return CSDResult(
concepts=concepts,
class_attribution=class_attr,
mask=mask,
selected_dims=selected_dims,
band_frequencies=band_freqs,
target_class=target_class,
specificity_name=self.specificity.name,
probe_weights=W_c,
contributions=contributions,
)