diff --git a/CHANGELOG.md b/CHANGELOG.md index 766fec325..085b14be9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Development +- adds the `LinearGaussianSCM` and `ConfoundedChainSCM` synthetic games in `shapiq_games.synthetic`, which evaluate the marginal, the conditional, and the causal (Heskes et al., 2020) value function in closed form on one and the same linear-Gaussian data-generating process. The model is linear and the features are Gaussian, so every conditional expectation involved is itself linear: the games are exact references for benchmarking approximators and for comparing the three semantics against each other, with no sampling and no random seed. [#600](https://github.com/mmschlk/shapiq/pull/600) - fixes the `"CHII"` estimates of `SHAPIQ` and `SVARMIQ`, which were wrong even with a budget of all `2**n` coalitions: the Monte Carlo approximators weighted the discrete derivatives with `s / t`, the coefficient of the Moebius representation of the chaining interaction index, instead of its cardinal-index weight `s * (s + t - 1)! * (n - s - t)! / n!`. [#643](https://github.com/mmschlk/shapiq/pull/643) - adds GraphSHAP-IQ in `shapiq.graph` for exact Shapley values and interactions of the nodes of graph neural networks (Muschalik et al., 2025). `GraphExplainer` explains `torch_geometric` models with a graph-level output, and `shapiq.Explainer` picks it for models with message passing layers. As for trees, a graph is converted into an internal representation, the `GraphModel` (edges, node features, and the radius of the receptive fields), and the new `GraphBaselineImputer`, a `BaselineImputer` whose players are the nodes, gives absent nodes the baseline node features. `GraphSHAPIQ` computes the values of any game that is local on a graph without `torch`; with `max_interaction_size`, it approximates them with fewer evaluations while keeping them efficient. `torch_geometric` graphs and models need the new extra `shapiq[graph]`. [#550](https://github.com/mmschlk/shapiq/pull/550) - adds graph games to `shapiq_games` and `shapiq_benchmark`: `shapiq_games.GraphGame`, the `GraphComputer` of their exact values with GraphSHAP-IQ (chosen by `default_computer` for games that are local on the graph), and `GraphSetup`, which trains a GCN, GIN, or GAT on a TUDataset graph classification dataset (`load_graph_dataset`, e.g. `"MUTAG"` or `"PROTEINS"`). Their exact values are feasible for graphs far beyond the 25 players of brute force. [#550](https://github.com/mmschlk/shapiq/pull/550) diff --git a/docs/source/api_reference_games.rst b/docs/source/api_reference_games.rst index 11453448a..9c644a2d2 100644 --- a/docs/source/api_reference_games.rst +++ b/docs/source/api_reference_games.rst @@ -82,6 +82,8 @@ Games whose interaction values are known in closed form. UnanimityGame SOUM RandomTableGame + LinearGaussianSCM + ConfoundedChainSCM .. _games-model-specific: diff --git a/docs/source/conf.py b/docs/source/conf.py index 506e871be..0e87b8333 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -182,6 +182,7 @@ "technicalities": ( "plot_imputers.py", "plot_conditional_imputer.py", + "plot_causal_shapley_values.py", "plot_budgets_and_seeds.py", "plot_parallel_computation.py", ), diff --git a/docs/source/references.bib b/docs/source/references.bib index 17f2e1b9b..4e738c94d 100644 --- a/docs/source/references.bib +++ b/docs/source/references.bib @@ -808,6 +808,14 @@ @inproceedings{Herbinger.2022 pages = {10209--10233}, url = {https://proceedings.mlr.press/v151/herbinger22a.html} } +@inproceedings{Heskes.2020, + title = {{Causal {Shapley} Values: Exploiting Causal Knowledge to Explain Individual Predictions of Complex Models}}, + author = {Tom Heskes and Evi Sijben and Ioan Gabriel Bucur and Tom Claassen}, + year = {2020}, + booktitle = {Advances in Neural Information Processing Systems (NeurIPS)}, + volume = {33}, + pages = {4778--4789} +} @inproceedings{Bordt.2023, title = {{From {Shapley} values to generalized additive models and back}}, author = {Sebastian Bordt and Ulrike {von Luxburg}}, diff --git a/examples/technicalities/plot_causal_shapley_values.py b/examples/technicalities/plot_causal_shapley_values.py new file mode 100644 index 000000000..14b75e010 --- /dev/null +++ b/examples/technicalities/plot_causal_shapley_values.py @@ -0,0 +1,124 @@ +""" +Causal Shapley Values: The Same Feature, +3.00 and -2.33 +========================================================= + +This example contrasts the three value functions a Shapley explanation can be built +on, using the :class:`~shapiq_games.ConfoundedChainSCM` game: the +**marginal** one, the **conditional** one, and the **causal** one of +Heskes et al. (2020). + +All three decompose the very same prediction of the very same model at the very same +point. They disagree on the sign of one feature, because they answer three different +questions: + +- **marginal**: what does knowing ``X3 = 1`` add, if I know nothing else? +- **conditional**: what does knowing ``X3 = 1`` add, given everything else I know? +- **causal**: what happens if I *set* ``X3 = 1``, following the causal structure? + +The data-generating process puts an unobserved common cause behind the first two +features and makes the third a descendant of both:: + + X1 = U + noise + X2 = U + noise # X1 and X2 share a hidden cause + X3 = 2*X1 + X2 + noise # X3 is downstream of both + f = X1 + 2*X2 + 3*X3 + +Because the model is linear and the features are Gaussian, every conditional +expectation involved is linear and all three value functions are available in closed +form. Three features means eight coalitions, enumerated exactly, so the Shapley values +below carry no approximation error and no random seed. +""" + +from __future__ import annotations + +import matplotlib.pyplot as plt +import numpy as np + +import shapiq +from shapiq_games import ConfoundedChainSCM + +FEATURE_NAMES = ["X1", "X2", "X3"] +MODES = ["marginal", "conditional", "causal"] + +# %% +# The Causal Chain Graph +# ---------------------- +# The causal structure enters through two arguments, exactly as in the R package +# ``shapr``: a ``causal_ordering`` of feature groups and one ``confounding`` flag per +# group. Here the first two features form one confounded group and the third follows. + +game = ConfoundedChainSCM(mode="causal") +print(f"causal ordering: {game.causal_ordering}") +print(f"confounding: {game.confounding}") +print(f"f(x*) = {game.coefficients @ game.x_explain:.2f}") + +# %% +# Coalition Values +# ---------------- +# Two sanity checks are visible directly in the table. Intervening on ``X3``, which has +# no descendants, cannot differ from perturbing it, so the marginal and the causal +# column agree on ``{X3}``. Fixing every ancestor leaves nothing for an intervention to +# cut, so the conditional and the causal column agree on ``{X1,X2}``. + +games = {mode: ConfoundedChainSCM(mode=mode) for mode in MODES} +coalitions = np.array( + [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 0], [1, 0, 1], [0, 1, 1], [1, 1, 1]] +).astype(bool) + +labels = [ + "{}" if not coalition.any() else "{" + ",".join(np.array(FEATURE_NAMES)[coalition]) + "}" + for coalition in coalitions +] +values = {mode: games[mode](coalitions) for mode in MODES} + +print(f"\n{'S':<12}{'marginal':>12}{'conditional':>13}{'causal':>10}") +for i, label in enumerate(labels): + row = "".join( + f"{values[mode][i]:>12.4f}" if mode != "conditional" else f"{values[mode][i]:>13.4f}" + for mode in MODES + ) + print(f"{label:<12}{row}") + +# %% +# Exact Shapley Values +# -------------------- +# With three players all eight coalitions are enumerated, so these are exact. + +attributions = {} +for mode in MODES: + exact_computer = shapiq.ExactComputer(n_players=3, game=games[mode]) + sv = exact_computer(index="SV", order=1) + attributions[mode] = np.array([sv[(player,)] for player in range(3)]) + +print(f"\n{'':<14}{'X1':>8}{'X2':>8}{'X3':>8}{'sum':>8}") +for mode in MODES: + phi = attributions[mode] + print(f"{mode:<14}" + "".join(f"{value:>8.2f}" for value in phi) + f"{phi.sum():>8.2f}") + +# %% +# Why the Third Feature Flips +# --------------------------- +# The marginal value function compares ``X3 = 1`` against its unconditional mean of +# zero, and the model weights it by 3, so it looks like the strongest positive +# contributor. The other two compare it against what its parents predict for it, +# ``2*1 + 1*1 = 3``. Observed against expected, this point's ``X3`` came in two units +# low and drags the prediction down. +# +# Conditional and causal then part ways on ``X2``. Intervening on ``X1`` says nothing +# about ``X2``, because their dependence runs through the hidden common cause and the +# intervention cuts that path. Conditioning on ``X1`` does say something. Observational +# attribution therefore lets credit leak between the two; causal attribution does not. + +fig, ax = plt.subplots(figsize=(6, 4)) +positions = np.arange(3) +width = 0.26 +for offset, mode in zip((-width, 0.0, width), MODES, strict=True): + ax.bar(positions + offset, attributions[mode], width, label=mode) +ax.axhline(0.0, color="black", linewidth=0.8) +ax.set_xticks(positions) +ax.set_xticklabels(FEATURE_NAMES) +ax.set_ylabel("Shapley value") +ax.set_title("One prediction, three value functions") +ax.legend() +fig.tight_layout() +plt.show() diff --git a/src/shapiq_games/__init__.py b/src/shapiq_games/__init__.py index 80c88e7fb..56742a6da 100644 --- a/src/shapiq_games/__init__.py +++ b/src/shapiq_games/__init__.py @@ -23,7 +23,8 @@ Game families: - synthetic games with analytic values: :class:`DummyGame`, :class:`UnanimityGame`, - :class:`SOUM`, :class:`RandomTableGame` + :class:`SOUM`, :class:`RandomTableGame`, :class:`LinearGaussianSCM`, + :class:`ConfoundedChainSCM` - model-specific games with exact explainers: :class:`PathDependentTreeGame`, :class:`InterventionalTreeGame`, :class:`KNNGame`, :class:`WeightedKNNGame`, :class:`ThresholdNNGame`, :class:`ProductKernelGame`, :class:`GraphGame` (whose players are @@ -46,7 +47,14 @@ from .language import SentimentAnalysis from .local_xai import TabularLocalExplanation from .nn import KNNGame, ThresholdNNGame, WeightedKNNGame -from .synthetic import SOUM, DummyGame, RandomTableGame, UnanimityGame +from .synthetic import ( + SOUM, + ConfoundedChainSCM, + DummyGame, + LinearGaussianSCM, + RandomTableGame, + UnanimityGame, +) from .tree import InterventionalTreeGame, PathDependentTreeGame from .uncertainty import TabularUncertaintyExplanation from .unsupervised import UnsupervisedData @@ -55,7 +63,9 @@ __all__ = [ # synthetic + "ConfoundedChainSCM", "DummyGame", + "LinearGaussianSCM", "RandomTableGame", "SOUM", "UnanimityGame", diff --git a/src/shapiq_games/synthetic/__init__.py b/src/shapiq_games/synthetic/__init__.py index 0684194ce..402e4757e 100644 --- a/src/shapiq_games/synthetic/__init__.py +++ b/src/shapiq_games/synthetic/__init__.py @@ -1,7 +1,15 @@ """Synthetic games with analytic or fully known values.""" from .dummy import DummyGame +from .linear_gaussian_scm import ConfoundedChainSCM, LinearGaussianSCM from .random_table import RandomTableGame from .soum import SOUM, UnanimityGame -__all__ = ["SOUM", "DummyGame", "RandomTableGame", "UnanimityGame"] +__all__ = [ + "SOUM", + "ConfoundedChainSCM", + "DummyGame", + "LinearGaussianSCM", + "RandomTableGame", + "UnanimityGame", +] diff --git a/src/shapiq_games/synthetic/linear_gaussian_scm.py b/src/shapiq_games/synthetic/linear_gaussian_scm.py new file mode 100644 index 000000000..cff35dbf0 --- /dev/null +++ b/src/shapiq_games/synthetic/linear_gaussian_scm.py @@ -0,0 +1,310 @@ +"""Synthetic linear-Gaussian SCM games with closed-form value functions. + +This module provides a ground-truth benchmark for the three value functions that +Shapley-value explanations of a model are built on: the *marginal* (also called +interventional in the feature-perturbation sense), the *conditional* +(observational), and the *causal* one of Heskes et al. (2020) +:cite:t:`Heskes.2020`. + +For a linear model on Gaussian features every conditional expectation involved is +itself linear, so all three value functions compose analytically. No sampling and +no random seed is involved, which makes the resulting games exact references for +benchmarking approximators and for comparing the three semantics against each +other on one and the same data-generating process. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +import numpy as np + +from shapiq.game import Game + +if TYPE_CHECKING: + from collections.abc import Sequence + +ValueFunctionMode = Literal["marginal", "conditional", "causal"] + + +class LinearGaussianSCM(Game): + r"""A linear model on Gaussian features with marginal, conditional and causal value functions. + + The features follow :math:`X \sim \mathcal{N}(\mu, \Sigma)` and the model is linear, + :math:`f(x) = \beta^\top x`. For an explanation point :math:`x^*` the three value + functions of a coalition :math:`S` are + + .. math:: + :nowrap: + + \begin{eqnarray} + v_{\text{marginal}}(S) & = & \mathbb{E}[f(x^*_S, X_{\bar S})] \\ + v_{\text{conditional}}(S) & = & \mathbb{E}[f(X) \mid X_S = x^*_S] \\ + v_{\text{causal}}(S) & = & \mathbb{E}[f(X) \mid do(X_S = x^*_S)] + \end{eqnarray} + + The causal value function follows the causal chain graph of + :cite:t:`Heskes.2020`, specified by a ``causal_ordering`` (feature groups in + causal order) together with a ``confounding`` flag per group. Within a group, + the features that were *not* intervened on are drawn conditional on every + preceding group, plus the intervened features of the same group -- the latter + only when that group is not confounded, because an intervention cuts a + dependence that runs through an unobserved common cause. This is the rule the + R package ``shapr`` implements in ``get_S_causal_steps``. + + Note that for the linear-Gaussian case the three value functions are fully + determined by :math:`\beta`, :math:`x^*`, :math:`\mu`, :math:`\Sigma` and the + causal chain graph. The structural coefficients themselves are not needed. + + Attributes: + coefficients: The model coefficients :math:`\beta`. + x_explain: The explanation point :math:`x^*`. + mean: The feature mean :math:`\mu`. + cov: The feature covariance :math:`\Sigma`. + causal_ordering: The feature groups in causal order. + confounding: One flag per group in ``causal_ordering``. + mode: Which of the three value functions the game evaluates. + + Examples: + >>> import numpy as np + >>> game = LinearGaussianSCM( + ... coefficients=np.array([1.0, 2.0]), + ... x_explain=np.array([1.0, 1.0]), + ... cov=np.array([[1.0, 0.5], [0.5, 1.0]]), + ... mode="conditional", + ... normalize=False, + ... ) + >>> coalitions = np.array([[0, 0], [1, 0], [0, 1], [1, 1]]).astype(bool) + >>> game(coalitions) + array([0. , 2. , 2.5, 3. ]) + + """ + + def __init__( + self, + coefficients: np.ndarray, + x_explain: np.ndarray, + cov: np.ndarray, + *, + mean: np.ndarray | None = None, + causal_ordering: Sequence[Sequence[int]] | None = None, + confounding: Sequence[bool] | None = None, + mode: ValueFunctionMode = "causal", + normalize: bool = True, + ) -> None: + """Initialize the LinearGaussianSCM game. + + Args: + coefficients: The model coefficients of shape ``(n_features,)``. + x_explain: The point to explain of shape ``(n_features,)``. + cov: The feature covariance matrix of shape ``(n_features, n_features)``. + mean: The feature mean of shape ``(n_features,)``. Defaults to ``None``, which + is the zero vector. + causal_ordering: The feature groups in causal order, as a sequence of sequences + of feature indices that partitions all features. Defaults to ``None``, which + places every feature in one single group. Only used by ``mode='causal'``. + confounding: One boolean per group in ``causal_ordering``, marking whether the + dependence inside that group is due to an unobserved common cause. Defaults + to ``None``, which is ``False`` for every group. Only used by + ``mode='causal'``. + mode: Which value function to evaluate. One of ``'marginal'``, ``'conditional'`` + or ``'causal'``. Defaults to ``'causal'``. + normalize: Whether to center the game at the empty coalition. Defaults to ``True``. + + Raises: + ValueError: If ``mode`` is not one of the three supported value functions, if the + shapes of the inputs disagree, or if ``causal_ordering`` does not partition + the features. + """ + if mode not in ("marginal", "conditional", "causal"): + msg = f"mode must be 'marginal', 'conditional', or 'causal'; got {mode!r}." + raise ValueError(msg) + + self.coefficients = np.asarray(coefficients, dtype=float).reshape(-1) + self.x_explain = np.asarray(x_explain, dtype=float).reshape(-1) + self.cov = np.asarray(cov, dtype=float) + n_players = len(self.coefficients) + + if self.x_explain.shape != (n_players,): + msg = ( + f"x_explain has shape {self.x_explain.shape}, expected ({n_players},) to " + f"match coefficients." + ) + raise ValueError(msg) + if self.cov.shape != (n_players, n_players): + msg = ( + f"cov has shape {self.cov.shape}, expected ({n_players}, {n_players}) to " + f"match coefficients." + ) + raise ValueError(msg) + + self.mean = ( + np.zeros(n_players) if mean is None else np.asarray(mean, dtype=float).reshape(-1) + ) + if self.mean.shape != (n_players,): + msg = ( + f"mean has shape {self.mean.shape}, expected ({n_players},) to match coefficients." + ) + raise ValueError(msg) + + if causal_ordering is None: + causal_ordering = [list(range(n_players))] + self.causal_ordering = [list(group) for group in causal_ordering] + flat = [feature for group in self.causal_ordering for feature in group] + if sorted(flat) != list(range(n_players)): + msg = ( + f"causal_ordering must partition the {n_players} features exactly once each; " + f"got {self.causal_ordering}." + ) + raise ValueError(msg) + + if confounding is None: + confounding = [False] * len(self.causal_ordering) + self.confounding = [bool(flag) for flag in confounding] + if len(self.confounding) != len(self.causal_ordering): + msg = ( + f"confounding has {len(self.confounding)} entries but causal_ordering has " + f"{len(self.causal_ordering)} groups." + ) + raise ValueError(msg) + + self.mode: ValueFunctionMode = mode + self._empty_value = float(self.coefficients @ self.mean) + + super().__init__( + n_players=n_players, + normalize=normalize, + normalization_value=self._empty_value, + ) + + def _conditional_mean( + self, target: list[int], given: list[int], x_given: np.ndarray + ) -> np.ndarray: + """Return E[X_target | X_given = x_given] for the Gaussian features.""" + if not target: + return np.zeros(0) + if not given: + return self.mean[target] + cov_tg = self.cov[np.ix_(target, given)] + cov_gg = self.cov[np.ix_(given, given)] + residual = x_given - self.mean[given] + return self.mean[target] + cov_tg @ np.linalg.solve(cov_gg, residual) + + def _feature_means(self, coalition: np.ndarray) -> np.ndarray: + """Return the expected feature vector the model is evaluated on for one coalition.""" + in_coalition = [int(j) for j in np.flatnonzero(coalition)] + out_coalition = [j for j in range(self.n_players) if j not in set(in_coalition)] + + means = self.mean.copy() + means[in_coalition] = self.x_explain[in_coalition] + + if self.mode == "marginal" or not out_coalition: + return means + + if self.mode == "conditional": + means[out_coalition] = self._conditional_mean( + out_coalition, in_coalition, self.x_explain[in_coalition] + ) + return means + + # causal: walk the chain graph one group at a time + intervened = set(in_coalition) + preceding: list[int] = [] + for group, confounded in zip(self.causal_ordering, self.confounding, strict=True): + group_intervened = [j for j in group if j in intervened] + group_generated = [j for j in group if j not in intervened] + if group_generated: + given = list(preceding) if confounded else [*preceding, *group_intervened] + means[group_generated] = self._conditional_mean( + group_generated, given, means[given] + ) + preceding.extend(group) + return means + + def value_function(self, coalitions: np.ndarray) -> np.ndarray: + """Evaluate the selected value function on a set of coalitions. + + Args: + coalitions: Binary matrix of shape ``(n_coalitions, n_players)``. + + Returns: + The coalition values of shape ``(n_coalitions,)``. + """ + worth = np.empty(len(coalitions), dtype=float) + for i, coalition in enumerate(coalitions): + worth[i] = float(self.coefficients @ self._feature_means(coalition)) + return worth + + +class ConfoundedChainSCM(LinearGaussianSCM): + r"""A three-feature confounded SCM on which the three value functions disagree in sign. + + The data-generating process places an unobserved common cause :math:`U` behind the + first two features and makes the third a descendant of both: + + .. math:: + :nowrap: + + \begin{eqnarray} + X_1 & = & U + \varepsilon_1 \\ + X_2 & = & U + \varepsilon_2 \\ + X_3 & = & 2 X_1 + X_2 + \varepsilon_3 \\ + f(x) & = & x_1 + 2 x_2 + 3 x_3 + \end{eqnarray} + + with :math:`U, \varepsilon_1, \varepsilon_2, \varepsilon_3 \sim \mathcal{N}(0, 1)` + independent, which gives the causal chain graph ``[[0, 1], [2]]`` with + ``confounding=[True, False]``. The explanation point is :math:`x^* = (1, 1, 1)`. + + At that point the three value functions attribute the prediction of ``6.0`` as + + ====================== ========= ========= ========= + value function ``X_1`` ``X_2`` ``X_3`` + ====================== ========= ========= ========= + ``'marginal'`` ``1.00`` ``2.00`` ``3.00`` + ``'conditional'`` ``3.95`` ``4.38`` ``-2.33`` + ``'causal'`` ``4.00`` ``3.50`` ``-1.50`` + ====================== ========= ========= ========= + + The sign of the third feature flips: against its unconditional mean of zero it is the + largest positive contributor, while against what its parents predict for it + (:math:`2 \cdot 1 + 1 \cdot 1 = 3`) the observed value of ``1`` falls short and drags + the prediction down. The conditional and the causal value function then part ways on + the second feature, because intervening on the first cuts the confounded path between + them while conditioning on it does not. + + Examples: + >>> import numpy as np + >>> game = ConfoundedChainSCM(mode="causal") + >>> coalitions = np.array([[0, 0, 0], [1, 1, 0], [1, 1, 1]]).astype(bool) + >>> np.round(game(coalitions), 4) + array([ 0., 12., 6.]) + + """ + + COEFFICIENTS = np.array([1.0, 2.0, 3.0]) + """The model coefficients of the benchmark.""" + + X_EXPLAIN = np.array([1.0, 1.0, 1.0]) + """The explanation point of the benchmark.""" + + COV = np.array([[2.0, 1.0, 5.0], [1.0, 2.0, 4.0], [5.0, 4.0, 15.0]]) + """The feature covariance implied by the structural equations.""" + + def __init__(self, *, mode: ValueFunctionMode = "causal", normalize: bool = True) -> None: + """Initialize the ConfoundedChainSCM benchmark game. + + Args: + mode: Which value function to evaluate. One of ``'marginal'``, ``'conditional'`` + or ``'causal'``. Defaults to ``'causal'``. + normalize: Whether to center the game at the empty coalition. Defaults to ``True``. + """ + super().__init__( + coefficients=self.COEFFICIENTS, + x_explain=self.X_EXPLAIN, + cov=self.COV, + causal_ordering=[[0, 1], [2]], + confounding=[True, False], + mode=mode, + normalize=normalize, + ) diff --git a/tests/shapiq_games/test_contract.py b/tests/shapiq_games/test_contract.py index e741b37ef..633b49a2f 100644 --- a/tests/shapiq_games/test_contract.py +++ b/tests/shapiq_games/test_contract.py @@ -84,6 +84,16 @@ def _image_game() -> Game: "unanimity": (lambda: sg.UnanimityGame(np.array([1, 0, 1, 0])), True), "soum": (lambda: sg.SOUM(6, 10, random_state=3, normalize=True), True), "random_table": (lambda: sg.RandomTableGame(5, random_state=1, normalize=True), True), + "linear_gaussian_scm": ( + lambda: sg.LinearGaussianSCM( + np.array([1.0, 2.0, 3.0]), + np.array([1.0, -0.5, 1.0]), + np.array([[1.0, 0.6, 0.3], [0.6, 1.0, 0.4], [0.3, 0.4, 1.0]]), + mode="conditional", + ), + True, + ), + "confounded_chain_scm": (lambda: sg.ConfoundedChainSCM(), True), "local_xai_marginal": ( lambda: sg.TabularLocalExplanation(_tree_regressor(), _X_TRAIN[:50], x=_X_TEST[0]), True, diff --git a/tests/shapiq_games/test_linear_gaussian_scm.py b/tests/shapiq_games/test_linear_gaussian_scm.py new file mode 100644 index 000000000..c7487254b --- /dev/null +++ b/tests/shapiq_games/test_linear_gaussian_scm.py @@ -0,0 +1,118 @@ +"""Tests for the linear-Gaussian SCM games.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from shapiq.game_theory.exact import ExactComputer +from shapiq_games.synthetic import ConfoundedChainSCM, LinearGaussianSCM + +# Closed-form Shapley values of ConfoundedChainSCM, see the class docstring. +EXPECTED_SHAPLEY_VALUES = { + "marginal": np.array([1.0, 2.0, 3.0]), + "conditional": np.array([3.9484, 4.3841, -2.3325]), + "causal": np.array([4.0, 3.5, -1.5]), +} + + +def _shapley_values(game: LinearGaussianSCM) -> np.ndarray: + exact = ExactComputer(n_players=game.n_players, game=game) + values = exact(index="SV", order=1) + return np.array([values[(player,)] for player in range(game.n_players)]) + + +@pytest.mark.parametrize("mode", ["marginal", "conditional", "causal"]) +def test_confounded_chain_matches_closed_form(mode: str) -> None: + """The three value functions reproduce their analytically derived attributions.""" + game = ConfoundedChainSCM(mode=mode) + np.testing.assert_allclose(_shapley_values(game), EXPECTED_SHAPLEY_VALUES[mode], atol=1e-4) + + +@pytest.mark.parametrize("mode", ["marginal", "conditional", "causal"]) +def test_confounded_chain_is_efficient(mode: str) -> None: + """All three value functions satisfy efficiency at the explanation point.""" + game = ConfoundedChainSCM(mode=mode) + assert _shapley_values(game).sum() == pytest.approx(6.0) + + +def test_marginal_and_causal_agree_on_the_sink() -> None: + """Intervening on a feature with no descendants cannot differ from perturbing it.""" + sink = np.array([[0, 0, 1]]).astype(bool) + marginal = ConfoundedChainSCM(mode="marginal")(sink) + causal = ConfoundedChainSCM(mode="causal")(sink) + np.testing.assert_allclose(marginal, causal) + np.testing.assert_allclose(marginal, [3.0]) + + +def test_conditional_and_causal_agree_on_the_full_source_group() -> None: + """Fixing every ancestor leaves nothing for an intervention to cut.""" + sources = np.array([[1, 1, 0]]).astype(bool) + conditional = ConfoundedChainSCM(mode="conditional")(sources) + causal = ConfoundedChainSCM(mode="causal")(sources) + np.testing.assert_allclose(conditional, causal) + np.testing.assert_allclose(conditional, [12.0]) + + +def test_confounding_flag_changes_the_causal_value() -> None: + """Declaring the first group unconfounded lets an intervention inform its sibling.""" + coalition = np.array([[1, 0, 0]]).astype(bool) + confounded = ConfoundedChainSCM(mode="causal")(coalition) + unconfounded = LinearGaussianSCM( + coefficients=ConfoundedChainSCM.COEFFICIENTS, + x_explain=ConfoundedChainSCM.X_EXPLAIN, + cov=ConfoundedChainSCM.COV, + causal_ordering=[[0, 1], [2]], + confounding=[False, False], + mode="causal", + )(coalition) + assert confounded != pytest.approx(unconfounded) + + +def test_causal_without_ordering_equals_conditional() -> None: + """One single group means every feature is conditioned on, as in the observational case.""" + coalitions = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 1], [1, 1, 1]]).astype(bool) + conditional = ConfoundedChainSCM(mode="conditional")(coalitions) + causal = LinearGaussianSCM( + coefficients=ConfoundedChainSCM.COEFFICIENTS, + x_explain=ConfoundedChainSCM.X_EXPLAIN, + cov=ConfoundedChainSCM.COV, + mode="causal", + )(coalitions) + np.testing.assert_allclose(conditional, causal) + + +def test_nonzero_mean_shifts_the_empty_coalition() -> None: + """The normalization value follows the feature mean.""" + game = LinearGaussianSCM( + coefficients=np.array([1.0, 2.0]), + x_explain=np.array([1.0, 1.0]), + cov=np.eye(2), + mean=np.array([1.0, 1.0]), + mode="marginal", + normalize=False, + ) + empty = np.array([[0, 0]]).astype(bool) + np.testing.assert_allclose(game(empty), [3.0]) + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"mode": "interventional"}, "mode must be"), + ({"x_explain": np.array([1.0])}, "x_explain has shape"), + ({"cov": np.eye(3)}, "cov has shape"), + ({"mean": np.array([0.0])}, "mean has shape"), + ({"causal_ordering": [[0]]}, "must partition"), + ({"causal_ordering": [[0], [1]], "confounding": [False]}, "confounding has"), + ], +) +def test_invalid_arguments_raise(kwargs: dict, match: str) -> None: + """Malformed inputs fail loudly rather than silently producing a different game.""" + valid = { + "coefficients": np.array([1.0, 2.0]), + "x_explain": np.array([1.0, 1.0]), + "cov": np.eye(2), + } + with pytest.raises(ValueError, match=match): + LinearGaussianSCM(**{**valid, **kwargs})