diff --git a/README.md b/README.md index c31a97e..185c6fa 100644 --- a/README.md +++ b/README.md @@ -108,29 +108,22 @@ exp.plot() # bar chart with one marker per method For classifiers, pass `target="benign"` (label or index) to explain a specific class; the default is the predicted class. -### Explain the whole model (global) - +### Explain multiple predictions (batch) ```python -glob = explain(model, X_test, y=y_test) # coalition mean|phi| + permutation importance -glob.to_text() +from xai_framework import explain, summarize +explanations = explain(model, X_train, instances=X_test.head(20)) +summary = summarize(explanations) +print(summary) ``` +### Examples -Without `y`, only the coalition explainer runs. Use held-out data for permutation -importance: on the training rows of a fully grown ensemble every feature looks -unimportant. - -### Pick methods explicitly +### Explain the whole model (global) ```python -explain(model, X, instance=0, methods="surrogate") # single method -explain(model, X, instance=0, methods=["coalition", "surrogate"], - weights={"coalition": 2, "surrogate": 1}, aggregation="rank") # weighted Borda -explain(model, X, instance=0, - explainer_kwargs={"surrogate": {"n_samples": 2000}, - "coalition": {"n_background": 100}}) -``` -### Examples +glob = explain(model, X_test, y=y_test) # coalition mean|phi| + permutation importance +glob.to_text() +``` - [Consensus Explanation Walkthrough](examples/01_consensus_walkthrough.ipynb) — demonstrates local consensus explanations, agreement between explanation methods, disagreement cases, and global explanations. ## Documentation diff --git a/src/xai_framework/__init__.py b/src/xai_framework/__init__.py index caead01..a5a995b 100644 --- a/src/xai_framework/__init__.py +++ b/src/xai_framework/__init__.py @@ -21,6 +21,7 @@ from .explanation import ConsensusExplanation, Explanation from .model import ModelAdapter from .registry import available_explainers, get_explainer, register_explainer +from .summary import summarize __version__ = "0.1.0" @@ -41,4 +42,5 @@ "explain_global", "get_explainer", "register_explainer", + "summarize", ] diff --git a/src/xai_framework/auto.py b/src/xai_framework/auto.py index ac3e27f..a0fdb51 100644 --- a/src/xai_framework/auto.py +++ b/src/xai_framework/auto.py @@ -87,6 +87,7 @@ def explain( X: Any, instance: Any = None, *, + instances: Any = None, methods: str | Sequence[str] = "auto", target: Any = None, y: Any = None, @@ -97,7 +98,7 @@ def explain( weights: dict[str, float] | None = None, aggregation: str = "mean", explainer_kwargs: dict[str, dict[str, Any]] | None = None, -) -> Explanation: +) -> Explanation | list[Explanation]: """Explain a model with one call. Parameters @@ -108,14 +109,16 @@ def explain( X Background data (training rows work well). A DataFrame supplies feature names. instance - The row to explain: an array/Series, or an integer index into ``X``. - Omit it for a global explanation of the whole dataset. + One row to explain: an array/Series, or an integer index into ``X``. + Kept for backward compatibility. + instances + Multiple rows to explain. Accepts a DataFrame, a 2-D array, or a list + of integer row indices into ``X``. methods - ``"auto"`` picks ``coalition + surrogate`` locally and ``coalition + permutation`` globally - (permutation only when ``y`` is given). Or pass one name or a list of names. + ``"auto"`` picks ``coalition + surrogate`` locally and + ``coalition + permutation`` globally. target - Class to explain for classifiers (label or index). Defaults to the predicted - class for local explanations and all classes for global ones. + Class to explain for classifiers. Defaults to the predicted class. y Ground-truth labels for ``X``; enables permutation importance globally. weights, aggregation, explainer_kwargs @@ -123,11 +126,44 @@ class for local explanations and all classes for global ones. Returns ------- - Explanation - A :class:`~xai_framework.explanation.ConsensusExplanation` when several - methods ran, otherwise the single method's :class:`Explanation`. + Explanation or list[Explanation] + A single explanation for ``instance`` or a list of explanations for + ``instances``. """ + + if instance is not None and instances is not None: + raise ValueError("provide either 'instance' or 'instances', not both") + + # Multiple local instances + if instances is not None: + explainer = build_explainer( + model, + X, + methods, + scope="local", + has_y=y is not None, + feature_names=feature_names, + class_names=class_names, + task=task, + random_state=random_state, + weights=weights, + aggregation=aggregation, + explainer_kwargs=explainer_kwargs, + ) + + # A list of integers means row indices into X. + if isinstance(instances, (list, tuple)) and all( + isinstance(i, (int, np.integer)) and not isinstance(i, bool) for i in instances + ): + selected = to_numpy(X)[list(instances)] + return explainer.explain(selected, target=target) + + # Otherwise instances is expected to be a DataFrame or 2-D array. + return explainer.explain(instances, target=target) + + # Existing single/global behavior scope = "global" if instance is None else "local" + explainer = build_explainer( model, X, @@ -142,10 +178,13 @@ class for local explanations and all classes for global ones. aggregation=aggregation, explainer_kwargs=explainer_kwargs, ) + if scope == "global": return explainer.explain_global(X, y, target=target) + if isinstance(instance, (int, np.integer)) and not isinstance(instance, bool): instance = to_numpy(X)[int(instance)] + return explainer.explain_instance(instance, target=target) diff --git a/src/xai_framework/summary.py b/src/xai_framework/summary.py new file mode 100644 index 0000000..c53ab8c --- /dev/null +++ b/src/xai_framework/summary.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import pandas as pd + +from .explanation import ConsensusExplanation, Explanation + + +def summarize(explanations: Sequence[Explanation]) -> pd.DataFrame: + """Summarize a collection of explanations as a DataFrame. + + Each row represents one explanation and contains its prediction, + target, top-3 features with their attributions, and agreement + when the explanation is a consensus explanation. + """ + rows: list[dict[str, Any]] = [] + + for explanation in explanations: + top_features = explanation.top(3) + + row: dict[str, Any] = { + "prediction": explanation.prediction, + "target": explanation.target, + } + + for i in range(3): + if i < len(top_features): + feature, attribution = top_features[i] + row[f"feature_{i + 1}"] = feature + row[f"attribution_{i + 1}"] = attribution + else: + row[f"feature_{i + 1}"] = None + row[f"attribution_{i + 1}"] = None + + if isinstance(explanation, ConsensusExplanation): + row["agreement"] = explanation.agreement + else: + row["agreement"] = float("nan") + + rows.append(row) + + return pd.DataFrame(rows) diff --git a/tests/test_auto_and_registry.py b/tests/test_auto_and_registry.py index 92efd79..ef6ab42 100644 --- a/tests/test_auto_and_registry.py +++ b/tests/test_auto_and_registry.py @@ -1,4 +1,5 @@ import numpy as np +import pandas as pd import pytest from xai_framework import ( @@ -11,6 +12,7 @@ explain_global, get_explainer, register_explainer, + summarize, ) from xai_framework.auto import resolve_methods @@ -98,3 +100,105 @@ def test_feature_and_class_names_passthrough(binary_gb, binary): ) assert exp.feature_names == ["alpha", "beta", "gamma"] assert exp.target in {"no", "yes"} + + +def test_explain_multiple_instances_dataframe(iris_rf, iris): + X, _ = iris + + explanations = explain( + iris_rf, + X, + instances=X.iloc[:3], + methods="coalition", + random_state=0, + ) + + assert isinstance(explanations, list) + assert len(explanations) == 3 + assert all(isinstance(exp, Explanation) for exp in explanations) + + +def test_explain_multiple_instances_numpy(iris_rf, iris): + X, _ = iris + + explanations = explain( + iris_rf, + X, + instances=X.iloc[:3].to_numpy(), + methods="coalition", + random_state=0, + ) + + assert len(explanations) == 3 + np.testing.assert_array_equal( + explanations[0].feature_values, + X.iloc[0].to_numpy(), + ) + + +def test_explain_multiple_instances_indices(iris_rf, iris): + X, _ = iris + + explanations = explain( + iris_rf, + X, + instances=[0, 2, 4], + methods="coalition", + random_state=0, + ) + + assert len(explanations) == 3 + + np.testing.assert_array_equal( + explanations[0].feature_values, + X.iloc[0].to_numpy(), + ) + + np.testing.assert_array_equal( + explanations[1].feature_values, + X.iloc[2].to_numpy(), + ) + + np.testing.assert_array_equal( + explanations[2].feature_values, + X.iloc[4].to_numpy(), + ) + + +def test_explain_rejects_instance_and_instances(iris_rf, iris): + X, _ = iris + + with pytest.raises(ValueError, match="either 'instance' or 'instances'"): + explain( + iris_rf, + X, + instance=0, + instances=[1, 2], + methods="coalition", + ) + + +def test_summarize_batch_explanations(iris_rf, iris): + X, _ = iris + + explanations = explain( + iris_rf, + X, + instances=X.iloc[:3], + random_state=0, + ) + + summary = summarize(explanations) + + assert isinstance(summary, pd.DataFrame) + assert len(summary) == 3 + + assert "prediction" in summary.columns + assert "target" in summary.columns + assert "feature_1" in summary.columns + assert "attribution_1" in summary.columns + assert "feature_2" in summary.columns + assert "attribution_2" in summary.columns + assert "feature_3" in summary.columns + assert "attribution_3" in summary.columns + assert "agreement" in summary.columns