Source code for causalig.attr._core.attribution

"""
Base class for attribution methods, following Captum's architecture.
"""

from abc import ABC, abstractmethod
from graphlib import CycleError, TopologicalSorter

import torch


[docs] class Attribution(ABC): """ Abstract base class for all attribution methods in this package. Subclasses implement `attribute()` and inherit input validation and shape-normalisation utilities. The convention throughout is that a single sample may be passed as a 1-D tensor `(d,)`; the base class promotes it to `(1, d)` before computation and squeezes it back on return so callers never need to manage the batch dimension themselves. Args: forward_func: Model or callable to attribute. Must accept a `(batch, d)` float tensor and return a `(batch, C)` or `(batch,)` tensor. """ def __init__(self, forward_func): self.forward_func = forward_func
[docs] @abstractmethod def attribute( self, inputs: torch.Tensor, **kwargs ) -> torch.Tensor | tuple[torch.Tensor, ...]: """ Compute attributions for the given inputs. Args: inputs: Input tensor of shape `(d,)` or `(batch, d)`. **kwargs: Additional arguments specific to each subclass. Returns: Attribution tensor of the same shape as `inputs`, or a tuple `(direct, indirect)` when `separate_effects=True`. """ raise NotImplementedError
[docs] def _format_input(self, inputs: torch.Tensor) -> tuple[torch.Tensor, bool]: """ Promote a 1-D tensor to `(1, d)` and record whether it was 1-D. Args: inputs: Tensor of shape `(d,)` or `(batch, d)`. Returns: `(normalized, was_single)` where `normalized` is always `(batch, d)` and `was_single` is `True` when the original tensor was 1-D. """ if inputs.dim() == 1: return inputs.unsqueeze(0), True return inputs, False
[docs] def _format_output( self, attributions: torch.Tensor | tuple[torch.Tensor, ...], was_single: bool ) -> torch.Tensor | tuple[torch.Tensor, ...]: """ Squeeze the batch dimension when the original input was 1-D. Args: attributions: Tensor `(batch, d)` or tuple of such tensors. was_single: Value returned by `_format_input`. Returns: Tensor `(d,)` / `(batch, d)` or matching tuple. """ if was_single: if isinstance(attributions, tuple): return tuple(r.squeeze(0) for r in attributions) return attributions.squeeze(0) return attributions
[docs] def _validate_inputs( self, inputs: torch.Tensor, ordering: list[int], baselines: torch.Tensor | None = None, ) -> None: """ Sanity-check inputs before attribution begins. Checks that `inputs` is a non-empty tensor, that `ordering` length matches the feature dimension, and that `baselines` (if provided) has a compatible feature dimension and lives on the same device. Args: inputs: Input tensor. ordering: Topological ordering of feature indices. baselines: Optional baseline tensor. Raises: ValueError: On any shape or device mismatch. """ if not isinstance(inputs, torch.Tensor): raise ValueError("inputs must be a torch.Tensor") if inputs.numel() == 0: raise ValueError("inputs cannot be empty") if len(ordering) != inputs.shape[-1]: raise ValueError("Ordering length must match the feature dimension") if baselines is not None: if not isinstance(baselines, torch.Tensor): raise ValueError("baselines must be a torch.Tensor") inputs_normalized, _ = self._format_input(inputs) baselines_normalized, _ = self._format_input(baselines) if inputs_normalized.shape[-1] != baselines_normalized.shape[-1]: raise ValueError( f"inputs shape {inputs_normalized.shape} and baselines shape " f"{baselines_normalized.shape} must have the same feature dimension" ) if inputs.device != baselines.device: raise ValueError( f"inputs device {inputs.device} and baselines device " f"{baselines.device} must match" )
@staticmethod def _resolve_ordering( ordering: list[int] | None, adjacency_matrix: torch.Tensor | None ) -> list[int]: """ Return `ordering`, or derive a topological ordering from the graph. Args: ordering: Topological ordering of feature indices, or ``None``. adjacency_matrix: `(d, d)` tensor, `[i, j]` true if feature `i` causes feature `j`. Required when `ordering` is ``None``. Raises: ValueError: If both are ``None``, or the graph has a cycle. """ if ordering is not None: return ordering if adjacency_matrix is None: raise ValueError( "Pass `adjacency_matrix` (preferred) or `ordering`: without a " "graph, the ordering defines which features each feature may " "depend on." ) adjacency = torch.as_tensor(adjacency_matrix).bool().cpu() parents = { j: torch.nonzero(adjacency[:, j]).flatten().tolist() for j in range(adjacency.shape[1]) } try: return list(TopologicalSorter(parents).static_order()) except CycleError as e: raise ValueError( "adjacency_matrix contains a cycle; CIG/CEG need a DAG" ) from e @staticmethod def _require(value, name: str): """Raise a ``TypeError`` naming `name` if `value` is ``None``.""" if value is None: raise TypeError(f"attribute() missing required argument: '{name}'") return value
[docs] def _convergence_delta( self, input_normalized: torch.Tensor, baselines_normalized: torch.Tensor, target: int | None, total_attributions: torch.Tensor, ) -> torch.Tensor: """ Completeness (convergence) delta. Measures how well the summed attributions account for the model output difference between `input` and the average baseline: delta = sum_i(attributions_i) - (f(input) - mean_b f(baseline_b)) A delta close to zero indicates the completeness axiom holds; for finite `n_steps` / single-step sampling this is only approximately satisfied. Args: input_normalized: Input tensor `(batch, d)`. baselines_normalized: Baseline tensor `(n_baselines, d)`. target: Output index attributed, or ``None`` if the full model output was used. total_attributions: Direct + indirect attributions, summed shape `(batch, d, C)` (before any `C == 1` squeeze). Returns: Delta tensor of shape `(batch, C)`, matching `total_attributions` with the feature dimension reduced. """ with torch.no_grad(): f_input = self.forward_func(input_normalized) f_baselines = self.forward_func(baselines_normalized) if target is not None: f_input = f_input[:, target] f_baselines = f_baselines[:, target] expected_diff = f_input - f_baselines.mean(dim=0) attribution_sum = total_attributions.sum(dim=1) return attribution_sum - expected_diff.reshape(attribution_sum.shape)