Skip to content

API reference

Auto-generated from source-code docstrings via mkdocstrings. Metric functions return a tidy pandas.DataFrame indexed by the concept's /-joined path; plot functions return a plotly.graph_objects.Figure.

ConceptGraph

The tree-shaped concept graph. Constructors from dict / YAML / NetworkX; deterministic DFS traversal.

ConceptGraph

ConceptGraph(graph: DiGraph, root: str)

Tree of business concepts with feature leaves.

Internally backed by a networkx.DiGraph where edges point from parent to child. Every node carries the attributes kind and metadata.

Invariants enforced at construction:

  • Exactly one root (a concept node with no parent).
  • Every leaf is a feature node; every internal node is a concept.
  • Every feature node is a leaf.
  • Node names are unique across the graph.
Source code in src/concept_graph_xai/graph.py
44
45
46
47
48
49
def __init__(self, graph: nx.DiGraph, root: str) -> None:
    self._graph = graph
    self._root = root
    self._validate()
    self._order: list[str] = list(nx.dfs_preorder_nodes(self._graph, source=self._root))
    self._path_cache: dict[str, tuple[str, ...]] = {}

graph property

graph: DiGraph

Return a snapshot copy of the underlying NetworkX DiGraph.

The copy preserves all node attributes (kind, metadata) and edges, but mutations to the returned object never reach back into the ConceptGraph — preventing accidental cache / order corruption.

nodes_in_order

nodes_in_order() -> list[str]

Deterministic depth-first preorder list of node names.

Source code in src/concept_graph_xai/graph.py
75
76
77
78
def nodes_in_order(self) -> list[str]:
    """Deterministic depth-first preorder list of node names."""

    return list(self._order)

from_dict classmethod

from_dict(data: Mapping[str, Any] | Mapping[str, list[str] | Mapping[str, Any]], *, root: str | None = None) -> ConceptGraph

Build a ConceptGraph from a nested-dict representation.

data is a mapping with exactly one top-level key (the root concept) whose value describes the tree:

  • a list of strings: a concept whose children are all features;
  • a mapping name -> subtree: a concept whose children are concepts (recursive) or, when the leaf value is a list, features.
Source code in src/concept_graph_xai/graph.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
@classmethod
def from_dict(
    cls,
    data: Mapping[str, Any] | Mapping[str, list[str] | Mapping[str, Any]],
    *,
    root: str | None = None,
) -> ConceptGraph:
    """Build a ConceptGraph from a nested-dict representation.

    ``data`` is a mapping with exactly one top-level key (the root concept)
    whose value describes the tree:

    * a list of strings: a concept whose children are all features;
    * a mapping ``name -> subtree``: a concept whose children are concepts
      (recursive) or, when the leaf value is a list, features.
    """

    if root is not None:
        payload = data
    else:
        if len(data) != 1:
            raise ValueError(
                "from_dict requires either a single top-level key or an explicit root="
            )
        root = next(iter(data))
        payload = {root: data[root]}

    graph = nx.DiGraph()
    graph.add_node(root, kind="concept", metadata={})
    cls._build_subtree(graph, root, payload[root])
    return cls(graph, root)

from_networkx classmethod

from_networkx(graph: DiGraph, root: str) -> ConceptGraph

Wrap an existing NetworkX DiGraph (must already have kind attrs).

Source code in src/concept_graph_xai/graph.py
172
173
174
175
176
@classmethod
def from_networkx(cls, graph: nx.DiGraph, root: str) -> ConceptGraph:
    """Wrap an existing NetworkX DiGraph (must already have ``kind`` attrs)."""

    return cls(graph.copy(), root)

NodeView dataclass

NodeView(name: str, kind: NodeKind, parent: str | None, path: tuple[str, ...], metadata: Mapping[str, Any])

Read-only view of a single node in the graph.

IO

YAML load/dump for the nested-dict graph format — the plumbing behind ConceptGraph.from_yaml / ConceptGraph.to_yaml.

load_yaml

load_yaml(path: str | Path) -> dict[str, Any]

Load a nested-dict ConceptGraph definition from a YAML file.

Source code in src/concept_graph_xai/io/yaml.py
11
12
13
14
15
16
17
18
def load_yaml(path: str | Path) -> dict[str, Any]:
    """Load a nested-dict ConceptGraph definition from a YAML file."""

    with open(path, encoding="utf-8") as f:
        loaded = yaml.safe_load(f)
    if not isinstance(loaded, dict):
        raise ValueError(f"YAML at {path!s} must define a mapping at the top level")
    return loaded

dump_yaml

dump_yaml(data: dict[str, Any], path: str | Path) -> None

Dump a nested-dict ConceptGraph definition to YAML.

Source code in src/concept_graph_xai/io/yaml.py
21
22
23
24
25
def dump_yaml(data: dict[str, Any], path: str | Path) -> None:
    """Dump a nested-dict ConceptGraph definition to YAML."""

    with open(path, "w", encoding="utf-8") as f:
        yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)

Adapters

Convert SHAP, sklearn permutation results, and model.feature_importances_ into the canonical (values, feature_names) tuple.

from_shap_explanation

from_shap_explanation(explanation: Any, *, feature_names: Sequence[str] | None = None, class_index: int | None = None) -> tuple[ndarray, list[str]]

Convert a SHAP Explanation (or compatible object) to (values, names).

PARAMETER DESCRIPTION
explanation

Either a shap.Explanation instance or any object with .values and .feature_names attributes. A raw numpy.ndarray is also accepted when feature_names is provided.

TYPE: Any

feature_names

Required only when explanation does not carry feature_names of its own.

TYPE: Sequence[str] | None DEFAULT: None

class_index

For multi-class explanations (3D values of shape (N, F, C)), select one class. Defaults to the last class.

TYPE: int | None DEFAULT: None

Source code in src/concept_graph_xai/adapters/shap.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def from_shap_explanation(
    explanation: Any,
    *,
    feature_names: Sequence[str] | None = None,
    class_index: int | None = None,
) -> tuple[np.ndarray, list[str]]:
    """Convert a SHAP ``Explanation`` (or compatible object) to ``(values, names)``.

    Parameters
    ----------
    explanation:
        Either a ``shap.Explanation`` instance or any object with ``.values`` and
        ``.feature_names`` attributes. A raw ``numpy.ndarray`` is also accepted
        when ``feature_names`` is provided.
    feature_names:
        Required only when ``explanation`` does not carry ``feature_names`` of
        its own.
    class_index:
        For multi-class explanations (3D ``values`` of shape ``(N, F, C)``),
        select one class. Defaults to the last class.
    """

    values = getattr(explanation, "values", explanation)
    arr = np.asarray(values, dtype=float)

    names: list[str]
    explanation_names = getattr(explanation, "feature_names", None)
    if feature_names is not None:
        names = list(feature_names)
    elif explanation_names is not None:
        names = list(explanation_names)
    else:
        raise ValueError("feature_names must be provided when the explanation has none")

    if arr.ndim == 3:
        idx = class_index if class_index is not None else arr.shape[2] - 1
        arr = arr[:, :, idx]

    if arr.ndim not in (1, 2):
        raise ValueError(f"unexpected SHAP values rank: {arr.ndim}")
    if arr.shape[-1] != len(names):
        raise ValueError(
            f"shape mismatch: values has {arr.shape[-1]} features, names has {len(names)}"
        )
    if not np.all(np.isfinite(arr)):
        raise ValueError("SHAP values contain NaN or Inf; check the explainer output")

    return arr, names

from_permutation_importance

from_permutation_importance(result: Any, feature_names: Sequence[str], *, use: str = 'importances_mean') -> tuple[ndarray, list[str]]

Convert a sklearn Bunch (from permutation_importance) to arrays.

PARAMETER DESCRIPTION
result

The Bunch returned by sklearn.inspection.permutation_importance, with attributes importances_mean and importances_std.

TYPE: Any

feature_names

Names matching the order of features used during the permutation run.

TYPE: Sequence[str]

use

Which attribute on the Bunch to expose. Defaults to importances_mean.

TYPE: str DEFAULT: 'importances_mean'

Source code in src/concept_graph_xai/adapters/sklearn_perm.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def from_permutation_importance(
    result: Any,
    feature_names: Sequence[str],
    *,
    use: str = "importances_mean",
) -> tuple[np.ndarray, list[str]]:
    """Convert a sklearn ``Bunch`` (from ``permutation_importance``) to arrays.

    Parameters
    ----------
    result:
        The Bunch returned by ``sklearn.inspection.permutation_importance``,
        with attributes ``importances_mean`` and ``importances_std``.
    feature_names:
        Names matching the order of features used during the permutation run.
    use:
        Which attribute on the Bunch to expose. Defaults to ``importances_mean``.
    """

    if not hasattr(result, use):
        raise AttributeError(f"permutation_importance result has no attribute {use!r}")
    values = np.asarray(getattr(result, use), dtype=float)
    if values.ndim != 1:
        raise ValueError(f"expected 1D array, got shape {values.shape}")
    names = list(feature_names)
    if values.shape[0] != len(names):
        raise ValueError(
            f"shape mismatch: values has {values.shape[0]} entries, names has {len(names)}"
        )
    if not np.all(np.isfinite(values)):
        raise ValueError(f"{use!r} contains NaN or Inf; check the permutation_importance run")
    return values, names

from_feature_importances_

from_feature_importances_(model: Any, feature_names: Sequence[str]) -> tuple[ndarray, list[str]]

Pull model.feature_importances_ into the canonical (values, names).

Source code in src/concept_graph_xai/adapters/tree_native.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def from_feature_importances_(
    model: Any,
    feature_names: Sequence[str],
) -> tuple[np.ndarray, list[str]]:
    """Pull ``model.feature_importances_`` into the canonical ``(values, names)``."""

    if not hasattr(model, "feature_importances_"):
        raise AttributeError(f"{type(model).__name__} has no feature_importances_")
    values = np.asarray(model.feature_importances_, dtype=float)
    if values.ndim != 1:
        raise ValueError(f"expected 1D array, got shape {values.shape}")
    names = list(feature_names)
    if values.shape[0] != len(names):
        raise ValueError(
            f"shape mismatch: values has {values.shape[0]} entries, names has {len(names)}"
        )
    if not np.all(np.isfinite(values)):
        raise ValueError("feature_importances_ contains NaN or Inf")
    return values, names

Counts

How many features live under each concept?

feature_counts

feature_counts(graph: ConceptGraph) -> DataFrame

Return a tidy DataFrame with one row per node and a count column.

For a feature node, count = 1. For a concept node, count is the number of feature descendants of that concept.

Source code in src/concept_graph_xai/metrics/counts.py
11
12
13
14
15
16
17
18
19
20
21
def feature_counts(graph: ConceptGraph) -> pd.DataFrame:
    """Return a tidy DataFrame with one row per node and a ``count`` column.

    For a feature node, ``count = 1``. For a concept node, ``count`` is the
    number of feature descendants of that concept.
    """

    df = empty_concept_frame(graph)
    counts = [len(graph.descendant_features(n)) for n in graph.nodes_in_order()]
    df["count"] = counts
    return df

Importance

How much importance does each concept aggregate?

importance_sum

importance_sum(graph: ConceptGraph, feature_names: Sequence[str], importances: ndarray, *, signed: bool = False, agg: str = 'mean', on_unknown: str = 'warn') -> DataFrame

Sum per-feature importance under each concept.

PARAMETER DESCRIPTION
graph

ConceptGraph whose features are a subset of feature_names.

TYPE: ConceptGraph

feature_names

Names matching the columns of importances.

TYPE: Sequence[str]

importances

(F,) per-feature aggregate or (N, F) per-sample SHAP-style array.

TYPE: ndarray

signed

Pass-through to :func:aggregate_per_feature.

TYPE: bool DEFAULT: False

agg

Pass-through to :func:aggregate_per_feature.

TYPE: str DEFAULT: 'mean'

on_unknown

Behaviour when input features are missing from the graph: "warn", "ignore", or "raise".

TYPE: str DEFAULT: 'warn'

Source code in src/concept_graph_xai/metrics/importance.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def importance_sum(
    graph: ConceptGraph,
    feature_names: Sequence[str],
    importances: np.ndarray,
    *,
    signed: bool = False,
    agg: str = "mean",
    on_unknown: str = "warn",
) -> pd.DataFrame:
    """Sum per-feature importance under each concept.

    Parameters
    ----------
    graph:
        ConceptGraph whose features are a subset of ``feature_names``.
    feature_names:
        Names matching the columns of ``importances``.
    importances:
        ``(F,)`` per-feature aggregate or ``(N, F)`` per-sample SHAP-style array.
    signed:
        Pass-through to :func:`aggregate_per_feature`.
    agg:
        Pass-through to :func:`aggregate_per_feature`.
    on_unknown:
        Behaviour when input features are missing from the graph: ``"warn"``,
        ``"ignore"``, or ``"raise"``.
    """

    per_feature = aggregate_per_feature(importances, signed=signed, agg=agg)
    if per_feature.shape[0] != len(feature_names):
        raise ValueError(
            f"importance length {per_feature.shape[0]} != len(feature_names) {len(feature_names)}"
        )
    matched, indices, _missing = align_features(graph, feature_names, on_unknown=on_unknown)
    name_to_value = {
        name: float(per_feature[idx]) for name, idx in zip(matched, indices, strict=True)
    }

    df = empty_concept_frame(graph)
    sums: list[float] = []
    for node in graph.nodes_in_order():
        feats = graph.descendant_features(node)
        sums.append(float(sum(name_to_value.get(f, 0.0) for f in feats)))
    df["importance_sum"] = sums
    df["feature_count"] = [len(graph.descendant_features(n)) for n in graph.nodes_in_order()]
    return df

Bootstrap

Percentile confidence intervals on per-concept importance — is the ranking statistically separable?

bootstrap_importance

bootstrap_importance(graph: ConceptGraph, feature_names: Sequence[str], shap_values: ndarray, *, n_bootstrap: int = 200, ci: float = 0.95, random_state: int | None = None, agg: BootstrapAgg = 'mean_signed', on_unknown: str = 'warn') -> DataFrame

Bootstrap-resampled per-concept SHAP with percentile CI.

For each concept, the per-sample value is the sum of SHAP across the concept's descendant features. The statistic is the mean of those per-sample values across the held-out set. The bootstrap resamples row indices with replacement n_bootstrap times and reports the mean plus percentile confidence-interval bounds.

PARAMETER DESCRIPTION
graph

The ConceptGraph.

TYPE: ConceptGraph

feature_names

Names matching the columns of shap_values.

TYPE: Sequence[str]

shap_values

Per-sample SHAP values of shape (N, F).

TYPE: ndarray

n_bootstrap

Number of resamples (default 200).

TYPE: int DEFAULT: 200

ci

Confidence level in (0, 1). Default 0.95 → 2.5% / 97.5%.

TYPE: float DEFAULT: 0.95

random_state

Seed for the resampler.

TYPE: int | None DEFAULT: None

agg

"mean_signed" (default) uses the signed per-sample sum (cancellation can shrink the magnitude). "mean_abs" uses sum(|SHAP|) and is always non-negative. Naming matches the v0.5/v0.6 family (segment_importance, concept_disparity, attribution_drift, concept_interaction_matrix).

TYPE: BootstrapAgg DEFAULT: 'mean_signed'

on_unknown

Behaviour when feature_names contains entries not in the graph.

TYPE: str DEFAULT: 'warn'

RETURNS DESCRIPTION
DataFrame

Indexed by concept-path with columns name, kind, depth, parent, the value column (mean_signed_shap or mean_abs_shap), ci_lo, ci_hi, feature_count.

Source code in src/concept_graph_xai/metrics/bootstrap.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def bootstrap_importance(
    graph: ConceptGraph,
    feature_names: Sequence[str],
    shap_values: np.ndarray,
    *,
    n_bootstrap: int = 200,
    ci: float = 0.95,
    random_state: int | None = None,
    agg: BootstrapAgg = "mean_signed",
    on_unknown: str = "warn",
) -> pd.DataFrame:
    """Bootstrap-resampled per-concept SHAP with percentile CI.

    For each concept, the per-sample value is the sum of SHAP across the
    concept's descendant features. The statistic is the mean of those
    per-sample values across the held-out set. The bootstrap resamples row
    indices with replacement ``n_bootstrap`` times and reports the mean
    plus percentile confidence-interval bounds.

    Parameters
    ----------
    graph:
        The ConceptGraph.
    feature_names:
        Names matching the columns of ``shap_values``.
    shap_values:
        Per-sample SHAP values of shape ``(N, F)``.
    n_bootstrap:
        Number of resamples (default 200).
    ci:
        Confidence level in ``(0, 1)``. Default ``0.95`` → 2.5% / 97.5%.
    random_state:
        Seed for the resampler.
    agg:
        ``"mean_signed"`` (default) uses the signed per-sample sum
        (cancellation can shrink the magnitude). ``"mean_abs"`` uses
        ``sum(|SHAP|)`` and is always non-negative. Naming matches the
        v0.5/v0.6 family (``segment_importance``, ``concept_disparity``,
        ``attribution_drift``, ``concept_interaction_matrix``).
    on_unknown:
        Behaviour when ``feature_names`` contains entries not in the graph.

    Returns
    -------
    pandas.DataFrame
        Indexed by concept-path with columns ``name``, ``kind``, ``depth``,
        ``parent``, the value column (``mean_signed_shap`` or
        ``mean_abs_shap``), ``ci_lo``, ``ci_hi``, ``feature_count``.
    """

    if agg not in ("mean_signed", "mean_abs"):
        raise ValueError(f"unknown agg {agg!r}; expected 'mean_signed' or 'mean_abs'")

    arr = np.asarray(shap_values, dtype=float)
    if arr.ndim != 2:
        raise ValueError(f"shap_values must be 2D (N, F); got {arr.shape}")
    if arr.shape[1] != len(feature_names):
        raise ValueError(
            f"shap_values has {arr.shape[1]} cols but feature_names has {len(feature_names)}"
        )
    if not 0.0 < ci < 1.0:
        raise ValueError(f"ci must be in (0, 1); got {ci}")
    if n_bootstrap < 1:
        raise ValueError(f"n_bootstrap must be >= 1; got {n_bootstrap}")

    name_to_idx = aligned_index_map(graph, feature_names, on_unknown=on_unknown)

    nodes = graph.nodes_in_order()
    n_samples, _ = arr.shape
    # For mean_abs we want |SHAP| summed (cancellation does NOT shrink), so
    # we feed the absolute array into the per-sample helper. For mean_signed
    # we feed the signed array.
    source = np.abs(arr) if agg == "mean_abs" else arr
    per_sample_per_node, feature_counts = per_sample_per_concept(graph, source, name_to_idx)

    rng = np.random.default_rng(random_state)
    boot_means = np.empty((n_bootstrap, len(nodes)), dtype=float)
    for b in range(n_bootstrap):
        sampled = rng.integers(0, n_samples, size=n_samples)
        boot_means[b] = per_sample_per_node[sampled].mean(axis=0)

    alpha = (1.0 - ci) / 2.0
    ci_lo = np.percentile(boot_means, 100.0 * alpha, axis=0)
    ci_hi = np.percentile(boot_means, 100.0 * (1.0 - alpha), axis=0)
    mean_estimate = boot_means.mean(axis=0)

    df = empty_concept_frame(graph)
    value_col = "mean_signed_shap" if agg == "mean_signed" else "mean_abs_shap"
    df[value_col] = mean_estimate
    df["ci_lo"] = ci_lo
    df["ci_hi"] = ci_hi
    df["feature_count"] = feature_counts
    df.attrs["ci"] = ci
    df.attrs["n_bootstrap"] = n_bootstrap
    df.attrs["agg"] = agg
    return df

Utilization

Which concepts does the model actually use?

utilization

utilization(graph: ConceptGraph, feature_names: Sequence[str], importances: ndarray, *, threshold: float = 0.0, signed: bool = False, agg: str = 'mean', on_unknown: str = 'warn') -> DataFrame

Mark each node is_used when its aggregated importance exceeds threshold.

A feature node is used iff abs(importance) > threshold (when signed=False). A concept node is used iff any of its feature descendants is used.

Returned DataFrame columns: name, kind, depth, parent, importance_sum, feature_count, used_feature_count, is_used.

Source code in src/concept_graph_xai/metrics/utilization.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def utilization(
    graph: ConceptGraph,
    feature_names: Sequence[str],
    importances: np.ndarray,
    *,
    threshold: float = 0.0,
    signed: bool = False,
    agg: str = "mean",
    on_unknown: str = "warn",
) -> pd.DataFrame:
    """Mark each node ``is_used`` when its aggregated importance exceeds ``threshold``.

    A *feature* node is used iff ``abs(importance) > threshold`` (when
    ``signed=False``). A *concept* node is used iff any of its feature
    descendants is used.

    Returned DataFrame columns: ``name``, ``kind``, ``depth``, ``parent``,
    ``importance_sum``, ``feature_count``, ``used_feature_count``, ``is_used``.
    """

    per_feature = aggregate_per_feature(importances, signed=signed, agg=agg)
    if per_feature.shape[0] != len(feature_names):
        raise ValueError(
            f"importance length {per_feature.shape[0]} != len(feature_names) {len(feature_names)}"
        )
    matched, indices, _missing = align_features(graph, feature_names, on_unknown=on_unknown)
    name_to_value = {
        name: float(per_feature[idx]) for name, idx in zip(matched, indices, strict=True)
    }

    used_features: set[str] = set()
    for name, value in name_to_value.items():
        magnitude = abs(value) if not signed else value
        if magnitude > threshold:
            used_features.add(name)

    df = empty_concept_frame(graph)
    feature_count: list[int] = []
    used_feature_count: list[int] = []
    importance_sum_col: list[float] = []
    is_used_col: list[bool] = []
    for node in graph.nodes_in_order():
        feats = graph.descendant_features(node)
        used = [f for f in feats if f in used_features]
        feature_count.append(len(feats))
        used_feature_count.append(len(used))
        importance_sum_col.append(float(sum(name_to_value.get(f, 0.0) for f in feats)))
        is_used_col.append(len(used) > 0)
    df["importance_sum"] = importance_sum_col
    df["feature_count"] = feature_count
    df["used_feature_count"] = used_feature_count
    df["is_used"] = is_used_col
    return df

Interaction

Concept × concept aggregation of the feature-level SHAP-interaction tensor.

concept_interaction_matrix

concept_interaction_matrix(graph: ConceptGraph, feature_names: Sequence[str], shap_interaction_values: ndarray, *, only_concepts: bool = True, include_root: bool = False, agg: InteractionAgg = 'mean_abs', on_unknown: str = 'warn') -> DataFrame

Aggregate a per-sample feature × feature interaction tensor into concept × concept.

PARAMETER DESCRIPTION
graph

ConceptGraph.

TYPE: ConceptGraph

feature_names

Names matching the second/third dimensions of shap_interaction_values.

TYPE: Sequence[str]

shap_interaction_values

Tensor of shape (N, F, F) from shap.TreeExplainer(model).shap_interaction_values(X).

TYPE: ndarray

only_concepts

If True (default), drop feature leaves from the matrix axes.

TYPE: bool DEFAULT: True

include_root

If False (default), drop the root concept (it would otherwise sum every feature on both axes — uninformative).

TYPE: bool DEFAULT: False

agg

"mean_abs" (default) reports mean_n |sum_{i,j} interaction| — interaction strength. "mean_signed" reports mean_n sum_{i,j} interaction — net direction (can cancel).

TYPE: InteractionAgg DEFAULT: 'mean_abs'

on_unknown

Behaviour when feature_names contains entries not present in the graph: "warn" (default), "ignore", "raise".

TYPE: str DEFAULT: 'warn'

RETURNS DESCRIPTION
DataFrame

Square DataFrame, rows and columns are concept names. df.attrs carries agg and feature_count (per concept, dict).

Source code in src/concept_graph_xai/metrics/interaction.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def concept_interaction_matrix(
    graph: ConceptGraph,
    feature_names: Sequence[str],
    shap_interaction_values: np.ndarray,
    *,
    only_concepts: bool = True,
    include_root: bool = False,
    agg: InteractionAgg = "mean_abs",
    on_unknown: str = "warn",
) -> pd.DataFrame:
    """Aggregate a per-sample feature × feature interaction tensor into concept × concept.

    Parameters
    ----------
    graph:
        ConceptGraph.
    feature_names:
        Names matching the second/third dimensions of ``shap_interaction_values``.
    shap_interaction_values:
        Tensor of shape ``(N, F, F)`` from
        ``shap.TreeExplainer(model).shap_interaction_values(X)``.
    only_concepts:
        If ``True`` (default), drop feature leaves from the matrix axes.
    include_root:
        If ``False`` (default), drop the root concept (it would otherwise
        sum every feature on both axes — uninformative).
    agg:
        ``"mean_abs"`` (default) reports ``mean_n |sum_{i,j} interaction|``
        — interaction *strength*. ``"mean_signed"`` reports
        ``mean_n sum_{i,j} interaction`` — net direction (can cancel).
    on_unknown:
        Behaviour when ``feature_names`` contains entries not present in the
        graph: ``"warn"`` (default), ``"ignore"``, ``"raise"``.

    Returns
    -------
    pandas.DataFrame
        Square DataFrame, rows and columns are concept names. ``df.attrs``
        carries ``agg`` and ``feature_count`` (per concept, dict).
    """

    arr = np.asarray(shap_interaction_values, dtype=float)
    if arr.ndim != 3:
        raise ValueError(f"shap_interaction_values must be 3D (N, F, F); got shape {arr.shape}")
    if arr.shape[1] != arr.shape[2]:
        raise ValueError(
            f"shap_interaction_values must have square last two dims; got {arr.shape[1:]}"
        )
    if arr.shape[1] != len(feature_names):
        raise ValueError(
            f"shap_interaction_values has {arr.shape[1]} features but feature_names has "
            f"{len(feature_names)}"
        )

    name_to_idx = aligned_index_map(graph, feature_names, on_unknown=on_unknown)

    nodes: list[str] = []
    for node in graph.nodes_in_order():
        if node == graph.root and not include_root:
            continue
        if only_concepts and graph.kind(node) == "feature":
            continue
        feats = [f for f in graph.descendant_features(node) if f in name_to_idx]
        if not feats:
            continue
        nodes.append(node)

    if not nodes:
        raise ValueError("no concepts (or features) overlap the supplied feature_names")

    feat_idxs: dict[str, list[int]] = {
        node: [name_to_idx[f] for f in graph.descendant_features(node) if f in name_to_idx]
        for node in nodes
    }
    feat_counts: dict[str, int] = {node: len(feat_idxs[node]) for node in nodes}

    n_concepts = len(nodes)
    matrix = np.zeros((n_concepts, n_concepts), dtype=float)
    for i, node_i in enumerate(nodes):
        idxs_i = feat_idxs[node_i]
        sub_i = arr[:, idxs_i, :]  # (N, |Ci|, F)
        for j, node_j in enumerate(nodes):
            if j < i:
                continue
            idxs_j = feat_idxs[node_j]
            cells = sub_i[:, :, idxs_j]  # (N, |Ci|, |Cj|)
            per_sample = cells.sum(axis=(1, 2))  # (N,)
            if agg == "mean_abs":
                value = float(np.abs(per_sample).mean())
            elif agg == "mean_signed":
                value = float(per_sample.mean())
            else:
                raise ValueError(f"unknown agg {agg!r}; expected 'mean_abs' or 'mean_signed'")
            matrix[i, j] = value
            matrix[j, i] = value

    df = pd.DataFrame(matrix, index=nodes, columns=nodes)
    df.attrs["agg"] = agg
    df.attrs["feature_count"] = feat_counts
    return df

Ablation

How much performance is lost when a concept's data is missing? Three strategies.

auc_drop

auc_drop(graph: ConceptGraph, model: Any, X: DataFrame | ndarray, y: ndarray | Series, feature_names: Sequence[str] | None = None, *, strategy: Strategy = 'permutation', metric: ScorerLike = 'roc_auc', n_repeats: int = 10, random_state: int | None = 42, train_fn: Callable[[DataFrame | ndarray, ndarray], Any] | None = None, X_train: DataFrame | ndarray | None = None, y_train: ndarray | Series | None = None, shap_values: ndarray | None = None, base_predictions: ndarray | None = None, skip_root: bool = True) -> DataFrame

Compute concept-level metric drop under ablation.

Source code in src/concept_graph_xai/metrics/ablation.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def auc_drop(
    graph: ConceptGraph,
    model: Any,
    X: pd.DataFrame | np.ndarray,
    y: np.ndarray | pd.Series,
    feature_names: Sequence[str] | None = None,
    *,
    strategy: Strategy = "permutation",
    metric: ScorerLike = "roc_auc",
    n_repeats: int = 10,
    random_state: int | None = 42,
    train_fn: Callable[[pd.DataFrame | np.ndarray, np.ndarray], Any] | None = None,
    X_train: pd.DataFrame | np.ndarray | None = None,
    y_train: np.ndarray | pd.Series | None = None,
    shap_values: np.ndarray | None = None,
    base_predictions: np.ndarray | None = None,
    skip_root: bool = True,
) -> pd.DataFrame:
    """Compute concept-level metric drop under ablation."""

    feats_in_X = _features_in_x(X, feature_names or list(graph.features()))
    y_arr = np.asarray(y)
    score = _resolve_scorer(metric)

    if strategy == "shap_marginal":
        df = _shap_marginal(graph, X, y_arr, score, shap_values, base_predictions, skip_root)
    elif strategy == "permutation":
        df = _permutation(
            graph, model, X, y_arr, feats_in_X, score, n_repeats, random_state, skip_root
        )
    elif strategy == "retrain":
        df = _retrain(
            graph,
            X,
            y_arr,
            feats_in_X,
            score,
            train_fn,
            X_train,
            y_train,
            skip_root,
        )
    else:
        raise ValueError(f"unknown strategy {strategy!r}")

    df["strategy"] = strategy
    return df

Correlation

Are concepts internally coherent? Do they go missing together? Do features look substitutable to the model?

CorrelationResult dataclass

CorrelationResult(matrix: DataFrame, blocks: list[tuple[str, int, int]], block_stats: DataFrame, method: CorrelationMethod)

Output of every correlation metric in this module.

matrix instance-attribute

matrix: DataFrame

Square DataFrame indexed/columned by feature name in graph order.

blocks instance-attribute

blocks: list[tuple[str, int, int]]

[(concept_path, start_idx, end_idx_exclusive), ...] over rows/cols.

block_stats instance-attribute

block_stats: DataFrame

One row per block. Columns: concept_path, size, mean_abs, median_abs, min, max.

feature_correlation

feature_correlation(graph: ConceptGraph, X: DataFrame | ndarray, *, feature_names: Sequence[str] | None = None, method: CorrelationMethod = 'spearman') -> CorrelationResult

Block-structured correlation matrix on feature values (P14).

Diagonal blocks reveal within-concept coherence; off-diagonal blocks reveal boundary leakage (features in different concepts that turn out to be highly correlated).

Accepts either a pd.DataFrame (column names taken from X.columns) or an np.ndarray of shape (N, F) (column names must be supplied via feature_names).

Source code in src/concept_graph_xai/metrics/correlation.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
def feature_correlation(
    graph: ConceptGraph,
    X: pd.DataFrame | np.ndarray,
    *,
    feature_names: Sequence[str] | None = None,
    method: CorrelationMethod = "spearman",
) -> CorrelationResult:
    """Block-structured correlation matrix on feature *values* (P14).

    Diagonal blocks reveal *within-concept coherence*; off-diagonal blocks
    reveal *boundary leakage* (features in different concepts that turn out to
    be highly correlated).

    Accepts either a ``pd.DataFrame`` (column names taken from
    ``X.columns``) or an ``np.ndarray`` of shape ``(N, F)`` (column names
    must be supplied via ``feature_names``).
    """

    if isinstance(X, pd.DataFrame):
        df_X = X
    elif isinstance(X, np.ndarray):
        if feature_names is None:
            raise ValueError("feature_correlation needs feature_names= when X is a numpy array")
        arr = np.asarray(X, dtype=float)
        if arr.ndim != 2:
            raise ValueError(f"X must be 2D (N, F); got shape {arr.shape}")
        if arr.shape[1] != len(feature_names):
            raise ValueError(
                f"X has {arr.shape[1]} columns but feature_names has {len(feature_names)}"
            )
        df_X = pd.DataFrame(arr, columns=list(feature_names))
    else:
        raise TypeError(
            f"feature_correlation requires a DataFrame or 2D ndarray, got {type(X).__name__}"
        )

    feats = _ordered_feature_names(graph, list(df_X.columns))
    if not feats:
        raise ValueError("no overlap between graph features and X columns")
    sub = df_X.loc[:, feats]
    matrix = sub.corr(method=method)
    blocks = block_boundaries(graph, feature_names=feats)
    block_stats = _block_aggregates(matrix.to_numpy(), blocks)
    return CorrelationResult(matrix=matrix, blocks=blocks, block_stats=block_stats, method=method)

nullity_correlation

nullity_correlation(graph: ConceptGraph, X: DataFrame, *, method: CorrelationMethod = 'spearman') -> CorrelationResult

Block-structured correlation matrix on feature missingness (P15a).

Built on X.isna(). A high diagonal-block value means the features in that concept tend to go missing together — directly relevant to the AUC drop "this branch is missing" scenario.

Source code in src/concept_graph_xai/metrics/correlation.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def nullity_correlation(
    graph: ConceptGraph,
    X: pd.DataFrame,
    *,
    method: CorrelationMethod = "spearman",
) -> CorrelationResult:
    """Block-structured correlation matrix on feature *missingness* (P15a).

    Built on ``X.isna()``. A high diagonal-block value means the features in
    that concept tend to go missing together — directly relevant to the AUC
    drop "this branch is missing" scenario.
    """

    if not isinstance(X, pd.DataFrame):
        raise TypeError("nullity_correlation requires a pandas DataFrame X")
    feats = _ordered_feature_names(graph, list(X.columns))
    if not feats:
        raise ValueError("no overlap between graph features and X columns")
    indicators = X.loc[:, feats].isna().astype(float)
    indicators = indicators.loc[:, indicators.std() > 0]
    if indicators.shape[1] == 0:
        empty = pd.DataFrame(np.zeros((len(feats), len(feats))), index=feats, columns=feats)
        blocks = block_boundaries(graph, feature_names=feats)
        return CorrelationResult(
            matrix=empty,
            blocks=blocks,
            block_stats=_block_aggregates(empty.to_numpy(), blocks),
            method=method,
        )
    raw = indicators.corr(method=method)
    matrix = raw.reindex(index=feats, columns=feats).fillna(0.0)
    blocks = block_boundaries(graph, feature_names=feats)
    block_stats = _block_aggregates(matrix.to_numpy(), blocks)
    return CorrelationResult(matrix=matrix, blocks=blocks, block_stats=block_stats, method=method)

shap_correlation

shap_correlation(graph: ConceptGraph, feature_names: Sequence[str], shap_values: ndarray, *, method: CorrelationMethod = 'spearman') -> CorrelationResult

Block-structured correlation of SHAP values across samples (P17).

Two raw-uncorrelated features can still be SHAP-redundant: diagonal blocks near 1 indicate features inside a concept push the model in the same way; off-diagonal blocks near 1 indicate the model treats different concepts as substitutes.

Source code in src/concept_graph_xai/metrics/correlation.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def shap_correlation(
    graph: ConceptGraph,
    feature_names: Sequence[str],
    shap_values: np.ndarray,
    *,
    method: CorrelationMethod = "spearman",
) -> CorrelationResult:
    """Block-structured correlation of *SHAP values* across samples (P17).

    Two raw-uncorrelated features can still be SHAP-redundant: diagonal blocks
    near 1 indicate features inside a concept push the model in the same way;
    off-diagonal blocks near 1 indicate the model treats different concepts as
    substitutes.
    """

    arr = np.asarray(shap_values, dtype=float)
    if arr.ndim != 2:
        raise ValueError(f"shap_values must be 2D (N, F); got shape {arr.shape}")
    if arr.shape[1] != len(feature_names):
        raise ValueError(
            f"shap_values has {arr.shape[1]} features, feature_names has {len(feature_names)}"
        )
    df = pd.DataFrame(arr, columns=list(feature_names))
    feats = _ordered_feature_names(graph, list(df.columns))
    sub = df.loc[:, feats]
    matrix = sub.corr(method=method)
    blocks = block_boundaries(graph, feature_names=feats)
    block_stats = _block_aggregates(matrix.to_numpy(), blocks)
    return CorrelationResult(matrix=matrix, blocks=blocks, block_stats=block_stats, method=method)

Missingness

How often does a feature / a whole concept go missing?

column_missing_rate

column_missing_rate(graph: ConceptGraph, X: DataFrame) -> DataFrame

Mean per-feature missing rate, plus a concept-level any-missing mean.

For a concept node, any_missing_rate is mean(any feature under the concept is NaN in row) — much weaker than :func:joint_missing_rate, but useful as an upper bound.

Source code in src/concept_graph_xai/metrics/missingness.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def column_missing_rate(graph: ConceptGraph, X: pd.DataFrame) -> pd.DataFrame:
    """Mean per-feature missing rate, plus a concept-level *any-missing* mean.

    For a concept node, ``any_missing_rate`` is ``mean(any feature under the
    concept is NaN in row)`` — much weaker than :func:`joint_missing_rate`,
    but useful as an upper bound.
    """

    if not isinstance(X, pd.DataFrame):
        raise TypeError("column_missing_rate requires a pandas DataFrame X")
    df = empty_concept_frame(graph)
    column_rate: list[float] = []
    any_rate: list[float] = []
    n_rows = len(X)
    for node in graph.nodes_in_order():
        feats = [f for f in graph.descendant_features(node) if f in X.columns]
        if not feats or n_rows == 0:
            # No features for this node, or no rows in X — both are
            # genuinely undefined, surface as NaN rather than 0.0.
            column_rate.append(float("nan") if n_rows == 0 else 0.0)
            any_rate.append(float("nan") if n_rows == 0 else 0.0)
            continue
        sub = X.loc[:, feats].isna()
        if graph.kind(node) == "feature":
            column_rate.append(float(sub.iloc[:, 0].mean()))
        else:
            column_rate.append(float(sub.mean().mean()))
        any_rate.append(float(sub.any(axis=1).mean()))
    df["column_missing_rate"] = column_rate
    df["any_missing_rate"] = any_rate
    df["feature_count"] = [len(graph.descendant_features(n)) for n in graph.nodes_in_order()]
    return df

joint_missing_rate

joint_missing_rate(graph: ConceptGraph, X: DataFrame) -> DataFrame

Per-concept joint-missing rate (P13/P15b).

For each concept (and each feature, trivially), the fraction of rows where every feature under that concept is NaN. This is the number that should drive the "is the AUC-drop scenario realistic?" judgement.

Returned columns: name, kind, depth, parent, feature_count, joint_missing_rate.

Source code in src/concept_graph_xai/metrics/missingness.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def joint_missing_rate(graph: ConceptGraph, X: pd.DataFrame) -> pd.DataFrame:
    """Per-concept joint-missing rate (P13/P15b).

    For each concept (and each feature, trivially), the fraction of rows where
    *every* feature under that concept is NaN. This is the number that should
    drive the "is the AUC-drop scenario realistic?" judgement.

    Returned columns: ``name``, ``kind``, ``depth``, ``parent``, ``feature_count``,
    ``joint_missing_rate``.
    """

    if not isinstance(X, pd.DataFrame):
        raise TypeError("joint_missing_rate requires a pandas DataFrame X")
    df = empty_concept_frame(graph)
    rate: list[float] = []
    feature_count: list[int] = []
    n_rows = len(X)
    for node in graph.nodes_in_order():
        feats = [f for f in graph.descendant_features(node) if f in X.columns]
        feature_count.append(len(feats))
        if not feats or n_rows == 0:
            rate.append(0.0)
            continue
        all_missing = X.loc[:, feats].isna().all(axis=1)
        rate.append(float(np.asarray(all_missing).mean()))
    df["feature_count"] = feature_count
    df["joint_missing_rate"] = rate
    return df

Coherence

Are concepts well-designed (coherent + important)?

coherence_importance

coherence_importance(graph: ConceptGraph, X: DataFrame, feature_names: Sequence[str], importances: ndarray, *, method: CorrelationMethod = 'spearman', coherence_threshold: float | None = None, importance_threshold: float | None = None) -> DataFrame

Per-concept coherence × importance table.

PARAMETER DESCRIPTION
graph

ConceptGraph.

TYPE: ConceptGraph

X

Feature matrix used to compute within-block correlation.

TYPE: DataFrame

feature_names

Inputs to :func:importance_sum. Per-sample (N, F) or per-feature (F,).

TYPE: Sequence[str]

importances

Inputs to :func:importance_sum. Per-sample (N, F) or per-feature (F,).

TYPE: Sequence[str]

method

Correlation method passed to :func:feature_correlation.

TYPE: CorrelationMethod DEFAULT: 'spearman'

coherence_threshold

Quadrant boundary on the coherence axis. Defaults to the median across concepts.

TYPE: float | None DEFAULT: None

importance_threshold

Quadrant boundary on the importance axis. Defaults to the median across concepts.

TYPE: float | None DEFAULT: None

RETURNS DESCRIPTION
DataFrame

One row per concept (root included). Columns include coherence, importance_sum, quadrant, plus all the structural columns from :func:empty_concept_frame.

Source code in src/concept_graph_xai/metrics/coherence.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def coherence_importance(
    graph: ConceptGraph,
    X: pd.DataFrame,
    feature_names: Sequence[str],
    importances: np.ndarray,
    *,
    method: CorrelationMethod = "spearman",
    coherence_threshold: float | None = None,
    importance_threshold: float | None = None,
) -> pd.DataFrame:
    """Per-concept coherence × importance table.

    Parameters
    ----------
    graph:
        ConceptGraph.
    X:
        Feature matrix used to compute within-block correlation.
    feature_names, importances:
        Inputs to :func:`importance_sum`. Per-sample (N, F) or per-feature (F,).
    method:
        Correlation method passed to :func:`feature_correlation`.
    coherence_threshold:
        Quadrant boundary on the coherence axis. Defaults to the median across
        concepts.
    importance_threshold:
        Quadrant boundary on the importance axis. Defaults to the median across
        concepts.

    Returns
    -------
    pandas.DataFrame
        One row per concept (root included). Columns include ``coherence``,
        ``importance_sum``, ``quadrant``, plus all the structural columns from
        :func:`empty_concept_frame`.
    """

    corr = feature_correlation(graph, X, method=method)
    block_lookup = corr.block_stats.set_index("concept_path")["mean_abs"].to_dict()

    imp_df = importance_sum(graph, feature_names, importances).copy()
    coherence: list[float] = []
    for path in imp_df.index:
        coherence.append(float(block_lookup.get(path, np.nan)))
    imp_df["coherence"] = coherence

    coh = np.asarray(imp_df["coherence"], dtype=float)
    imp = np.asarray(imp_df["importance_sum"], dtype=float)
    valid_coh = coh[~np.isnan(coh)]
    coh_thr = (
        float(np.median(valid_coh))
        if coherence_threshold is None and valid_coh.size > 0
        else float(coherence_threshold or 0.0)
    )
    valid_imp = imp[~np.isnan(imp)]
    imp_thr = (
        float(np.median(valid_imp))
        if importance_threshold is None and valid_imp.size > 0
        else float(importance_threshold or 0.0)
    )

    quadrants: list[str] = []
    for c, i in zip(coh, imp, strict=True):
        if np.isnan(c) or np.isnan(i):
            quadrants.append("undefined")
        elif c >= coh_thr and i >= imp_thr:
            quadrants.append("well_designed")
        elif c < coh_thr and i >= imp_thr:
            quadrants.append("kitchen_sink")
        elif c >= coh_thr and i < imp_thr:
            quadrants.append("redundant")
        else:
            quadrants.append("noise")
    imp_df["quadrant"] = quadrants
    imp_df.attrs["coherence_threshold"] = coh_thr
    imp_df.attrs["importance_threshold"] = imp_thr
    imp_df.attrs["method"] = method
    return imp_df

Segments

Per-segment per-concept SHAP aggregation for cohort analysis.

segment_importance

segment_importance(graph: ConceptGraph, feature_names: Sequence[str], shap_values: ndarray, segments: Series | str, *, X: DataFrame | None = None, agg: SegmentAgg = 'mean_abs', on_unknown: str = 'warn') -> DataFrame

Per-segment per-concept SHAP aggregate (long-form).

For each (concept, segment) pair, computes mean_n agg(s_c[n]) over the rows belonging to segment, where s_c[n] = sum_{f in concept.descendants} SHAP[n, f].

PARAMETER DESCRIPTION
graph

The ConceptGraph.

TYPE: ConceptGraph

feature_names

Names matching the columns of shap_values.

TYPE: Sequence[str]

shap_values

Per-sample SHAP, shape (N, F).

TYPE: ndarray

segments

Either a pd.Series (aligned to shap_values rows, NaNs ignored) or a column-name string referencing X.

TYPE: Series | str

X

DataFrame whose row order matches shap_values. Required when segments is a string.

TYPE: DataFrame | None DEFAULT: None

agg

"mean_abs" (default) reports mean_n |s_c[n]| — magnitude per segment. "mean_signed" reports mean_n s_c[n] — net signed direction (cancellation can shrink it).

TYPE: SegmentAgg DEFAULT: 'mean_abs'

on_unknown

Behaviour when feature_names contains entries not in the graph.

TYPE: str DEFAULT: 'warn'

RETURNS DESCRIPTION
DataFrame

Long-form with columns name, kind, depth, parent, segment, value, feature_count. df.attrs carries the agg choice and the stable segment_order list.

Source code in src/concept_graph_xai/metrics/segment.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def segment_importance(
    graph: ConceptGraph,
    feature_names: Sequence[str],
    shap_values: np.ndarray,
    segments: pd.Series | str,
    *,
    X: pd.DataFrame | None = None,
    agg: SegmentAgg = "mean_abs",
    on_unknown: str = "warn",
) -> pd.DataFrame:
    """Per-segment per-concept SHAP aggregate (long-form).

    For each ``(concept, segment)`` pair, computes ``mean_n agg(s_c[n])``
    over the rows belonging to ``segment``, where ``s_c[n] = sum_{f in
    concept.descendants} SHAP[n, f]``.

    Parameters
    ----------
    graph:
        The ConceptGraph.
    feature_names:
        Names matching the columns of ``shap_values``.
    shap_values:
        Per-sample SHAP, shape ``(N, F)``.
    segments:
        Either a ``pd.Series`` (aligned to ``shap_values`` rows, NaNs ignored)
        or a column-name string referencing ``X``.
    X:
        DataFrame whose row order matches ``shap_values``. Required when
        ``segments`` is a string.
    agg:
        ``"mean_abs"`` (default) reports ``mean_n |s_c[n]|`` — magnitude
        per segment. ``"mean_signed"`` reports ``mean_n s_c[n]`` — net
        signed direction (cancellation can shrink it).
    on_unknown:
        Behaviour when ``feature_names`` contains entries not in the graph.

    Returns
    -------
    pandas.DataFrame
        Long-form with columns ``name``, ``kind``, ``depth``, ``parent``,
        ``segment``, ``value``, ``feature_count``. ``df.attrs`` carries the
        ``agg`` choice and the stable ``segment_order`` list.
    """

    arr = np.asarray(shap_values, dtype=float)
    if arr.ndim != 2:
        raise ValueError(f"shap_values must be 2D (N, F); got {arr.shape}")
    if arr.shape[1] != len(feature_names):
        raise ValueError(
            f"shap_values has {arr.shape[1]} cols but feature_names has {len(feature_names)}"
        )

    seg_series = resolve_grouping(segments, X, arr.shape[0], param_name="segments")
    segment_order = grouping_order(seg_series)
    if not segment_order:
        raise ValueError("segments must contain at least one non-NA category")

    name_to_idx = aligned_index_map(graph, feature_names, on_unknown=on_unknown)

    nodes = graph.nodes_in_order()
    base = empty_concept_frame(graph)
    per_sample_per_node, feature_counts = per_sample_per_concept(graph, arr, name_to_idx)

    notna_mask = seg_series.notna().to_numpy()
    seg_strings = seg_series.astype(str)

    rows: list[dict[str, object]] = []
    for segment in segment_order:
        mask = notna_mask & (seg_strings.to_numpy() == segment)
        if not mask.any():
            continue
        block = per_sample_per_node[mask]
        if agg == "mean_abs":
            values = np.abs(block).mean(axis=0)
        elif agg == "mean_signed":
            values = block.mean(axis=0)
        else:
            raise ValueError(f"unknown agg {agg!r}; expected 'mean_abs' or 'mean_signed'")
        for k in range(len(nodes)):
            rows.append(
                {
                    "name": base.iloc[k]["name"],
                    "kind": base.iloc[k]["kind"],
                    "depth": base.iloc[k]["depth"],
                    "parent": base.iloc[k]["parent"],
                    "path": base.index[k],
                    "segment": segment,
                    "value": float(values[k]),
                    "feature_count": int(feature_counts[k]),
                }
            )

    df = pd.DataFrame(rows)
    df.attrs["agg"] = agg
    df.attrs["segment_order"] = segment_order
    return df

Disparity

Per-concept SHAP gap of each protected group against a reference group.

concept_disparity

concept_disparity(graph: ConceptGraph, feature_names: Sequence[str], shap_values: ndarray, protected: Series | str, *, reference: str, X: DataFrame | None = None, agg: DisparityAgg = 'mean_abs', on_unknown: str = 'warn') -> DataFrame

Per-concept additive SHAP gap vs a reference protected group.

For each (concept, group) pair, computes mean_n agg(s_c[n]) over rows in group minus the same quantity for reference. The reference group's row exists in the output with value=0 for every concept.

PARAMETER DESCRIPTION
graph

The ConceptGraph.

TYPE: ConceptGraph

feature_names

Names matching the columns of shap_values.

TYPE: Sequence[str]

shap_values

Per-sample SHAP, shape (N, F).

TYPE: ndarray

protected

Either a pd.Series aligned to shap_values rows (NaNs are ignored) or a column-name string referencing X.

TYPE: Series | str

reference

Label of the baseline group. Must appear in the resolved protected-attribute values.

TYPE: str

X

DataFrame whose row order matches shap_values. Required when protected is a string.

TYPE: DataFrame | None DEFAULT: None

agg

"mean_abs" (default) measures whether the model relies on the concept differently across groups — magnitude differential. "mean_signed" measures whether the concept pushes predictions in different directions across groups — treatment differential.

TYPE: DisparityAgg DEFAULT: 'mean_abs'

on_unknown

Behaviour when feature_names contains entries not in the graph.

TYPE: str DEFAULT: 'warn'

RETURNS DESCRIPTION
DataFrame

Long-form with columns name, kind, depth, parent, path, protected_group, value, reference_value, feature_count. df.attrs carries agg, reference_group, and protected_order (reference first, then the rest in input order).

Source code in src/concept_graph_xai/metrics/disparity.py
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def concept_disparity(
    graph: ConceptGraph,
    feature_names: Sequence[str],
    shap_values: np.ndarray,
    protected: pd.Series | str,
    *,
    reference: str,
    X: pd.DataFrame | None = None,
    agg: DisparityAgg = "mean_abs",
    on_unknown: str = "warn",
) -> pd.DataFrame:
    """Per-concept additive SHAP gap vs a reference protected group.

    For each ``(concept, group)`` pair, computes
    ``mean_n agg(s_c[n]) over rows in group`` minus the same quantity for
    ``reference``. The reference group's row exists in the output with
    ``value=0`` for every concept.

    Parameters
    ----------
    graph:
        The ConceptGraph.
    feature_names:
        Names matching the columns of ``shap_values``.
    shap_values:
        Per-sample SHAP, shape ``(N, F)``.
    protected:
        Either a ``pd.Series`` aligned to ``shap_values`` rows (NaNs are
        ignored) or a column-name string referencing ``X``.
    reference:
        Label of the baseline group. Must appear in the resolved
        protected-attribute values.
    X:
        DataFrame whose row order matches ``shap_values``. Required when
        ``protected`` is a string.
    agg:
        ``"mean_abs"`` (default) measures whether the model *relies* on
        the concept differently across groups — magnitude differential.
        ``"mean_signed"`` measures whether the concept *pushes
        predictions* in different directions across groups — treatment
        differential.
    on_unknown:
        Behaviour when ``feature_names`` contains entries not in the graph.

    Returns
    -------
    pandas.DataFrame
        Long-form with columns ``name``, ``kind``, ``depth``, ``parent``,
        ``path``, ``protected_group``, ``value``, ``reference_value``,
        ``feature_count``. ``df.attrs`` carries ``agg``,
        ``reference_group``, and ``protected_order`` (reference first,
        then the rest in input order).
    """

    arr = np.asarray(shap_values, dtype=float)
    if arr.ndim != 2:
        raise ValueError(f"shap_values must be 2D (N, F); got {arr.shape}")
    if arr.shape[1] != len(feature_names):
        raise ValueError(
            f"shap_values has {arr.shape[1]} cols but feature_names has {len(feature_names)}"
        )

    series = resolve_grouping(protected, X, arr.shape[0], param_name="protected")
    observed = grouping_order(series)
    if not observed:
        raise ValueError("protected must contain at least one non-NA category")
    if reference not in observed:
        raise KeyError(
            f"reference group {reference!r} not in observed protected values: {observed}"
        )
    # Stable order: reference first, then the rest in input order.
    protected_order = [reference, *(g for g in observed if g != reference)]

    name_to_idx = aligned_index_map(graph, feature_names, on_unknown=on_unknown)

    nodes = graph.nodes_in_order()
    base = empty_concept_frame(graph)
    per_sample_per_node, feature_counts = per_sample_per_concept(graph, arr, name_to_idx)

    notna_mask = series.notna().to_numpy()
    seg_strings = series.astype(str).to_numpy()

    def _per_group(group_label: str) -> np.ndarray:
        mask = notna_mask & (seg_strings == group_label)
        if not mask.any():
            return np.full(len(nodes), np.nan, dtype=float)
        block = per_sample_per_node[mask]
        if agg == "mean_abs":
            return np.asarray(np.abs(block).mean(axis=0), dtype=float)
        if agg == "mean_signed":
            return np.asarray(block.mean(axis=0), dtype=float)
        raise ValueError(f"unknown agg {agg!r}; expected 'mean_abs' or 'mean_signed'")

    reference_values = _per_group(reference)

    rows: list[dict[str, object]] = []
    for group in protected_order:
        group_values = reference_values if group == reference else _per_group(group)
        for k in range(len(nodes)):
            ref_val = float(reference_values[k])
            grp_val = float(group_values[k])
            rows.append(
                {
                    "name": base.iloc[k]["name"],
                    "kind": base.iloc[k]["kind"],
                    "depth": base.iloc[k]["depth"],
                    "parent": base.iloc[k]["parent"],
                    "path": base.index[k],
                    "protected_group": group,
                    "value": grp_val - ref_val,
                    "reference_value": ref_val,
                    "feature_count": int(feature_counts[k]),
                }
            )

    df = pd.DataFrame(rows)
    df.attrs["agg"] = agg
    df.attrs["reference_group"] = reference
    df.attrs["protected_order"] = protected_order
    return df

Drift

Per-period concept SHAP aggregation for drift monitoring: a long-form per-period table and a two-period delta table.

attribution_drift

attribution_drift(graph: ConceptGraph, periods: Sequence[PeriodSpec], *, agg: DriftAgg = 'mean_abs', on_unknown: str = 'warn') -> DataFrame

Per-period per-concept SHAP aggregate (long-form).

PARAMETER DESCRIPTION
graph

The ConceptGraph.

TYPE: ConceptGraph

periods

Ordered list of (period_label, shap_values, feature_names) tuples. Sample counts may differ across periods; feature counts must match feature_names for each entry.

TYPE: Sequence[PeriodSpec]

agg

"mean_abs" (default) reports mean_n |s_c[n]| — magnitude per period. "mean_signed" reports mean_n s_c[n] — net signed direction.

TYPE: DriftAgg DEFAULT: 'mean_abs'

on_unknown

Behaviour when feature_names contains entries not in the graph.

TYPE: str DEFAULT: 'warn'

RETURNS DESCRIPTION
DataFrame

Long-form with columns name, kind, depth, parent, path, period, value, feature_count. df.attrs carries the agg choice and the input period_order list.

Source code in src/concept_graph_xai/metrics/drift.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def attribution_drift(
    graph: ConceptGraph,
    periods: Sequence[PeriodSpec],
    *,
    agg: DriftAgg = "mean_abs",
    on_unknown: str = "warn",
) -> pd.DataFrame:
    """Per-period per-concept SHAP aggregate (long-form).

    Parameters
    ----------
    graph:
        The ConceptGraph.
    periods:
        Ordered list of ``(period_label, shap_values, feature_names)`` tuples.
        Sample counts may differ across periods; feature counts must match
        ``feature_names`` for each entry.
    agg:
        ``"mean_abs"`` (default) reports ``mean_n |s_c[n]|`` — magnitude
        per period. ``"mean_signed"`` reports ``mean_n s_c[n]`` — net
        signed direction.
    on_unknown:
        Behaviour when ``feature_names`` contains entries not in the graph.

    Returns
    -------
    pandas.DataFrame
        Long-form with columns ``name``, ``kind``, ``depth``, ``parent``,
        ``path``, ``period``, ``value``, ``feature_count``. ``df.attrs``
        carries the ``agg`` choice and the input ``period_order`` list.
    """

    if not periods:
        raise ValueError("periods must contain at least one entry")
    period_labels = [str(p[0]) for p in periods]
    if len(set(period_labels)) != len(period_labels):
        raise ValueError(f"period labels must be unique; got {period_labels}")

    base = empty_concept_frame(graph)
    rows: list[dict[str, object]] = []
    for period_label, shap_values, feature_names in periods:
        values, feature_counts = _aggregate_period(
            graph, str(period_label), shap_values, feature_names, agg, on_unknown
        )
        for k in range(len(base)):
            rows.append(
                {
                    "name": base.iloc[k]["name"],
                    "kind": base.iloc[k]["kind"],
                    "depth": base.iloc[k]["depth"],
                    "parent": base.iloc[k]["parent"],
                    "path": base.index[k],
                    "period": str(period_label),
                    "value": float(values[k]),
                    "feature_count": int(feature_counts[k]),
                }
            )

    df = pd.DataFrame(rows)
    df.attrs["agg"] = agg
    df.attrs["period_order"] = period_labels
    return df

concept_drift_delta

concept_drift_delta(graph: ConceptGraph, periods: Sequence[PeriodSpec], *, baseline: str | None = None, target: str | None = None, agg: DriftAgg = 'mean_abs', on_unknown: str = 'warn') -> DataFrame

Per-concept baseline / target / delta between two periods.

Convenience wrapper around :func:attribution_drift for the two-period delta view consumed by :func:concept_drift_sunburst.

PARAMETER DESCRIPTION
graph

The ConceptGraph.

TYPE: ConceptGraph

periods

Same as :func:attribution_drift — list of (period_label, shap_values, feature_names) tuples.

TYPE: Sequence[PeriodSpec]

baseline

Period label to treat as the reference. Defaults to the first period.

TYPE: str | None DEFAULT: None

target

Period label to compare against. Defaults to the last period.

TYPE: str | None DEFAULT: None

agg

Aggregation passed through to :func:attribution_drift.

TYPE: DriftAgg DEFAULT: 'mean_abs'

on_unknown

Pass-through.

TYPE: str DEFAULT: 'warn'

RETURNS DESCRIPTION
DataFrame

Indexed by concept path, columns name, kind, depth, parent, baseline, target, delta, feature_count. df.attrs carries the agg, baseline_period, target_period strings.

Source code in src/concept_graph_xai/metrics/drift.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def concept_drift_delta(
    graph: ConceptGraph,
    periods: Sequence[PeriodSpec],
    *,
    baseline: str | None = None,
    target: str | None = None,
    agg: DriftAgg = "mean_abs",
    on_unknown: str = "warn",
) -> pd.DataFrame:
    """Per-concept baseline / target / delta between two periods.

    Convenience wrapper around :func:`attribution_drift` for the
    two-period delta view consumed by :func:`concept_drift_sunburst`.

    Parameters
    ----------
    graph:
        The ConceptGraph.
    periods:
        Same as :func:`attribution_drift` — list of
        ``(period_label, shap_values, feature_names)`` tuples.
    baseline:
        Period label to treat as the reference. Defaults to the first
        period.
    target:
        Period label to compare against. Defaults to the last period.
    agg:
        Aggregation passed through to :func:`attribution_drift`.
    on_unknown:
        Pass-through.

    Returns
    -------
    pandas.DataFrame
        Indexed by concept path, columns ``name``, ``kind``, ``depth``,
        ``parent``, ``baseline``, ``target``, ``delta``,
        ``feature_count``. ``df.attrs`` carries the ``agg``,
        ``baseline_period``, ``target_period`` strings.
    """

    if not periods:
        raise ValueError("periods must contain at least one entry")
    period_labels = [str(p[0]) for p in periods]
    baseline_label = period_labels[0] if baseline is None else str(baseline)
    target_label = period_labels[-1] if target is None else str(target)
    if baseline_label not in period_labels:
        raise KeyError(f"baseline period {baseline_label!r} not in periods: {period_labels}")
    if target_label not in period_labels:
        raise KeyError(f"target period {target_label!r} not in periods: {period_labels}")
    if baseline_label == target_label:
        raise ValueError(f"baseline and target must differ; both set to {baseline_label!r}")

    long_df = attribution_drift(graph, periods, agg=agg, on_unknown=on_unknown)
    base_df = long_df[long_df["period"] == baseline_label].set_index("path")
    targ_df = long_df[long_df["period"] == target_label].set_index("path")

    base = empty_concept_frame(graph)
    base["baseline"] = base_df["value"].reindex(base.index).to_numpy(dtype=float)
    base["target"] = targ_df["value"].reindex(base.index).to_numpy(dtype=float)
    base["delta"] = base["target"] - base["baseline"]
    base["feature_count"] = base_df["feature_count"].reindex(base.index).to_numpy(dtype=int)
    base.attrs["agg"] = agg
    base.attrs["baseline_period"] = baseline_label
    base.attrs["target_period"] = target_label
    return base

Plotting

All plots return plotly.graph_objects.Figure. Static PNG via the [png] extra (kaleido).

sunburst

sunburst(graph: ConceptGraph, df: DataFrame, *, value: str = 'count', title: str | None = None, colorscale: str | None = None, color_value: str | None = None, color_by: ColorBy = 'auto', branch_palette: Sequence[str] | None = None, hide_root: bool = True, branchvalues: str = 'total', extra_hover: list[str] | None = None, hover_fmt: dict[str, str] | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst from a ConceptGraph + a metric DataFrame.

PARAMETER DESCRIPTION
graph

The ConceptGraph to render.

TYPE: ConceptGraph

df

Tidy DataFrame produced by one of the metric functions. Must be indexed by path and contain value (and color_value if coloring is requested).

TYPE: DataFrame

value

Column used for sector size. Defaults to "count".

TYPE: str DEFAULT: 'count'

title

Figure title.

TYPE: str | None DEFAULT: None

colorscale

Plotly colorscale name (e.g. "Viridis", "Reds"). When set, sectors are colored by color_value (which defaults to value).

TYPE: str | None DEFAULT: None

color_value

Column used for color intensity. Defaults to value when colorscale is set.

TYPE: str | None DEFAULT: None

color_by

How to colour sectors. "auto" (default) picks "value" when a colorscale is given and "branch" otherwise. "branch" forces categorical-per-top-level-branch colouring (using branch_palette). "value" forces colorscale-based colouring (raises if colorscale is not given). "none" disables per-sector colour overrides (raw Plotly defaults).

TYPE: ColorBy DEFAULT: 'auto'

branch_palette

CSS color sequence used when colouring by branch. Defaults to the Plotly qualitative palette.

TYPE: Sequence[str] | None DEFAULT: None

hide_root

When True (default) the root concept is omitted and its direct children form the centre ring. Pass False to keep the legacy rendering with the root sector visible.

TYPE: bool DEFAULT: True

branchvalues

Plotly sunburst branchvalues ("total" or "remainder").

TYPE: str DEFAULT: 'total'

extra_hover

Additional columns to append to the hover tooltip.

TYPE: list[str] | None DEFAULT: None

hover_fmt

Per-column format spec strings (e.g. {"importance_sum": ".4f"}).

TYPE: dict[str, str] | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/sunburst.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def sunburst(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    value: str = "count",
    title: str | None = None,
    colorscale: str | None = None,
    color_value: str | None = None,
    color_by: ColorBy = "auto",
    branch_palette: Sequence[str] | None = None,
    hide_root: bool = True,
    branchvalues: str = "total",
    extra_hover: list[str] | None = None,
    hover_fmt: dict[str, str] | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst from a ConceptGraph + a metric DataFrame.

    Parameters
    ----------
    graph:
        The ConceptGraph to render.
    df:
        Tidy DataFrame produced by one of the metric functions. Must be
        indexed by ``path`` and contain ``value`` (and ``color_value`` if
        coloring is requested).
    value:
        Column used for sector size. Defaults to ``"count"``.
    title:
        Figure title.
    colorscale:
        Plotly colorscale name (e.g. ``"Viridis"``, ``"Reds"``). When set,
        sectors are colored by ``color_value`` (which defaults to ``value``).
    color_value:
        Column used for color intensity. Defaults to ``value`` when
        ``colorscale`` is set.
    color_by:
        How to colour sectors. ``"auto"`` (default) picks ``"value"`` when a
        ``colorscale`` is given and ``"branch"`` otherwise. ``"branch"``
        forces categorical-per-top-level-branch colouring (using
        ``branch_palette``). ``"value"`` forces colorscale-based colouring
        (raises if ``colorscale`` is not given). ``"none"`` disables per-sector
        colour overrides (raw Plotly defaults).
    branch_palette:
        CSS color sequence used when colouring by branch. Defaults to the
        Plotly qualitative palette.
    hide_root:
        When ``True`` (default) the root concept is omitted and its direct
        children form the centre ring. Pass ``False`` to keep the legacy
        rendering with the root sector visible.
    branchvalues:
        Plotly sunburst branchvalues (``"total"`` or ``"remainder"``).
    extra_hover:
        Additional columns to append to the hover tooltip.
    hover_fmt:
        Per-column ``format`` spec strings (e.g. ``{"importance_sum": ".4f"}``).
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    arrays, ordered, sizes = sunburst_layout(graph, df, value=value, hide_root=hide_root)

    resolved = _resolve_color_by(color_by, colorscale)
    marker: dict[str, Any] = {}
    if resolved == "value":
        cv = color_value or value
        if cv not in ordered.columns:
            raise KeyError(f"color_value column {cv!r} not in DataFrame")
        cv_values = ordered[cv].fillna(0).to_numpy(dtype=float)
        cv_min = float(ordered[cv].min())
        cv_max = float(ordered[cv].max())
        marker.update(
            colors=cv_values,
            colorscale=colorscale,
            showscale=True,
            cmid=0 if (cv_min < 0 < cv_max) else None,
            colorbar={"title": cv},
        )
    elif resolved == "branch":
        marker["colors"] = branch_colors(graph, arrays["ids"], palette=branch_palette)

    hover_columns = [value]
    for col in ("kind", "feature_count", "used_feature_count", "is_used"):
        if col in ordered.columns and col not in hover_columns:
            hover_columns.append(col)
    if extra_hover:
        for col in extra_hover:
            if col not in hover_columns:
                hover_columns.append(col)

    hover = hover_text(ordered, hover_columns, fmt=hover_fmt)

    return build_sunburst_figure(
        arrays,
        sizes,
        marker=marker,
        hover=hover,
        title=title,
        branchvalues=branchvalues,
        layout_kwargs=layout_kwargs,
    )

utilization_map

utilization_map(graph: ConceptGraph, df: DataFrame, *, value: str = 'feature_count', used_color: str | None = None, unused_color: str = '#d3d3d3', branch_palette: Sequence[str] | None = None, hide_root: bool = True, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst where unused branches are grey.

The DataFrame must be the output of :func:concept_graph_xai.metrics.utilization (it requires the is_used column). By default sector area encodes feature_count and colour encodes both branch identity (hue) and is-used status (grey when not used) — the chart subsumes the standalone sunburst(..., feature_counts(...)) structural view.

PARAMETER DESCRIPTION
used_color

If None (default), used sectors are coloured by their top-level branch with hierarchical shading (sub-concepts get lighter shades of the branch hue). Pass a CSS colour to fall back to a single solid colour for every used sector (legacy behaviour).

TYPE: str | None DEFAULT: None

branch_palette

Custom palette for branch base hues. Defaults to the Plotly qualitative palette.

TYPE: Sequence[str] | None DEFAULT: None

hide_root

When True (default) the root concept is omitted; pass False to keep the legacy root sector.

TYPE: bool DEFAULT: True

Source code in src/concept_graph_xai/plotting/utilization_map.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def utilization_map(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    value: str = "feature_count",
    used_color: str | None = None,
    unused_color: str = "#d3d3d3",
    branch_palette: Sequence[str] | None = None,
    hide_root: bool = True,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst where unused branches are grey.

    The DataFrame must be the output of
    :func:`concept_graph_xai.metrics.utilization` (it requires the ``is_used``
    column). By default sector area encodes ``feature_count`` and colour
    encodes both branch identity (hue) and is-used status (grey when not used)
    — the chart subsumes the standalone ``sunburst(..., feature_counts(...))``
    structural view.

    Parameters
    ----------
    used_color:
        If ``None`` (default), used sectors are coloured by their top-level
        branch with hierarchical shading (sub-concepts get lighter shades of
        the branch hue). Pass a CSS colour to fall back to a single solid
        colour for every used sector (legacy behaviour).
    branch_palette:
        Custom palette for branch base hues. Defaults to the Plotly
        qualitative palette.
    hide_root:
        When ``True`` (default) the root concept is omitted; pass ``False``
        to keep the legacy root sector.
    """

    if "is_used" not in df.columns:
        raise KeyError(
            "utilization_map expects DataFrame from metrics.utilization (no is_used col)"
        )

    arrays, ordered, sizes = sunburst_layout(graph, df, value=value, hide_root=hide_root)

    is_used = ordered["is_used"].to_numpy()
    if used_color is None:
        used_palette = branch_colors(graph, arrays["ids"], palette=branch_palette)
        colors = [used_palette[i] if bool(u) else unused_color for i, u in enumerate(is_used)]
    else:
        colors = [used_color if bool(u) else unused_color for u in is_used]

    hover_cols = [
        c
        for c in (value, "is_used", "used_feature_count", "feature_count", "importance_sum")
        if c in ordered.columns
    ]
    hover = hover_text(ordered, hover_cols, fmt={"importance_sum": ".4f"})

    return build_sunburst_figure(
        arrays,
        sizes,
        marker={"colors": colors},
        hover=hover,
        title=title or "Concept utilization (grey = unused)",
        layout_kwargs=layout_kwargs,
    )

signed_concept_bar

signed_concept_bar(graph: ConceptGraph, df: DataFrame, *, only_concepts: bool = True, sort: bool = True, max_concepts: int | None = None, value: str | None = None, branch_palette: Sequence[str] | None = None, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a horizontal bar chart of per-concept signed SHAP with CI bars.

The DataFrame must come from :func:bootstrap_importance (it requires ci_lo / ci_hi columns and one of mean_signed_shap / mean_abs_shap).

PARAMETER DESCRIPTION
only_concepts

If True (default), drop feature leaves and the root from the chart.

TYPE: bool DEFAULT: True

sort

If True (default), order bars by |mean| descending.

TYPE: bool DEFAULT: True

max_concepts

Optionally cap the number of bars (top-K by |mean|).

TYPE: int | None DEFAULT: None

value

Override the column carrying the bar value. Defaults to whichever of mean_signed_shap / mean_abs_shap is present.

TYPE: str | None DEFAULT: None

branch_palette

Custom palette for branch base hues. Defaults to the Plotly qualitative palette.

TYPE: Sequence[str] | None DEFAULT: None

title

Figure title.

TYPE: str | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/signed_concept_bar.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def signed_concept_bar(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    only_concepts: bool = True,
    sort: bool = True,
    max_concepts: int | None = None,
    value: str | None = None,
    branch_palette: Sequence[str] | None = None,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a horizontal bar chart of per-concept signed SHAP with CI bars.

    The DataFrame must come from :func:`bootstrap_importance` (it requires
    ``ci_lo`` / ``ci_hi`` columns and one of ``mean_signed_shap`` /
    ``mean_abs_shap``).

    Parameters
    ----------
    only_concepts:
        If ``True`` (default), drop feature leaves and the root from the chart.
    sort:
        If ``True`` (default), order bars by ``|mean|`` descending.
    max_concepts:
        Optionally cap the number of bars (top-K by ``|mean|``).
    value:
        Override the column carrying the bar value. Defaults to whichever of
        ``mean_signed_shap`` / ``mean_abs_shap`` is present.
    branch_palette:
        Custom palette for branch base hues. Defaults to the Plotly qualitative
        palette.
    title:
        Figure title.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    if value is None:
        for candidate in ("mean_signed_shap", "mean_abs_shap"):
            if candidate in df.columns:
                value = candidate
                break
        if value is None:
            raise KeyError("no value column found; expected 'mean_signed_shap' or 'mean_abs_shap'")
    for required in (value, "ci_lo", "ci_hi"):
        if required not in df.columns:
            raise KeyError(f"required column {required!r} missing from DataFrame")

    work = df.copy()
    if only_concepts:
        work = work[(work["kind"] == "concept") & (work["name"] != graph.root)]
    if sort:
        work = (
            work.assign(_abs=work[value].abs())
            .sort_values("_abs", ascending=False)
            .drop(columns="_abs")
        )
    if max_concepts is not None:
        work = work.head(max_concepts)

    ids = ["/".join(graph.path(name)) for name in work["name"]]
    colors = branch_colors(graph, ids, palette=branch_palette)

    means = work[value].to_numpy(dtype=float)
    lo = work["ci_lo"].to_numpy(dtype=float)
    hi = work["ci_hi"].to_numpy(dtype=float)
    err_minus = means - lo
    err_plus = hi - means

    fig = go.Figure(
        go.Bar(
            x=means,
            y=work["name"].tolist(),
            orientation="h",
            marker={"color": colors, "line": {"color": "rgba(0,0,0,0.4)", "width": 0.5}},
            error_x={
                "type": "data",
                "symmetric": False,
                "array": err_plus,
                "arrayminus": err_minus,
                "color": "rgba(0,0,0,0.6)",
                "thickness": 1.5,
                "width": 5,
            },
            customdata=np.stack([lo, hi], axis=1),
            hovertemplate=(
                "%{y}<br>"
                f"{value}: %{{x:+.4f}}<br>"
                "CI: [%{customdata[0]:+.4f}, %{customdata[1]:+.4f}]<extra></extra>"
            ),
        )
    )
    fig.add_vline(x=0.0, line={"color": "black", "width": 1, "dash": "dash"})

    ci_pct = df.attrs.get("ci")
    suffix = f" — {round(ci_pct * 100)}% CI" if ci_pct is not None else ""
    fig.update_layout(
        title=title or f"Signed concept SHAP{suffix}",
        xaxis_title=value,
        yaxis_title="concept",
        yaxis={"autorange": "reversed"},
        margin={"t": 60, "l": 160, "r": 30, "b": 60},
        height=max(300, 30 * len(work) + 120),
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

concept_interaction_heatmap

concept_interaction_heatmap(matrix: DataFrame, *, title: str | None = None, show_diagonal_box: bool = True, annotate_top_k: int | None = 5, colorscale: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a concept × concept SHAP-interaction heatmap.

PARAMETER DESCRIPTION
matrix

Square DataFrame returned by :func:concept_interaction_matrix.

TYPE: DataFrame

title

Figure title.

TYPE: str | None DEFAULT: None

show_diagonal_box

If True (default), draw a faint box around the main diagonal so within-concept self-interaction is visually separated from cross-pair interactions.

TYPE: bool DEFAULT: True

annotate_top_k

Annotate the largest k off-diagonal cells with their value (default 5). Pass None to disable.

TYPE: int | None DEFAULT: 5

colorscale

Override the colorscale. Defaults to "Reds" for mean_abs and a diverging "RdBu" (centred at 0) for mean_signed.

TYPE: str | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/concept_interaction_heatmap.py
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def concept_interaction_heatmap(
    matrix: pd.DataFrame,
    *,
    title: str | None = None,
    show_diagonal_box: bool = True,
    annotate_top_k: int | None = 5,
    colorscale: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a concept × concept SHAP-interaction heatmap.

    Parameters
    ----------
    matrix:
        Square DataFrame returned by :func:`concept_interaction_matrix`.
    title:
        Figure title.
    show_diagonal_box:
        If ``True`` (default), draw a faint box around the main diagonal so
        within-concept self-interaction is visually separated from cross-pair
        interactions.
    annotate_top_k:
        Annotate the largest ``k`` off-diagonal cells with their value
        (default 5). Pass ``None`` to disable.
    colorscale:
        Override the colorscale. Defaults to ``"Reds"`` for ``mean_abs`` and a
        diverging ``"RdBu"`` (centred at 0) for ``mean_signed``.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    if matrix.shape[0] != matrix.shape[1]:
        raise ValueError(f"matrix must be square; got {matrix.shape}")
    if list(matrix.index) != list(matrix.columns):
        raise ValueError("matrix rows and columns must be identical")

    agg = matrix.attrs.get("agg", "mean_abs")
    z = matrix.to_numpy(dtype=float)
    n = z.shape[0]
    labels = list(matrix.index)

    value_fmt = "+.4f" if agg == "mean_signed" else ".4f"
    heatmap_kwargs: dict[str, Any] = {
        "z": z,
        "x": labels,
        "y": labels,
        "colorbar": {"title": agg},
        "hovertemplate": "%{y} ↔ %{x}<br>" + agg + ": %{z:" + value_fmt + "}<extra></extra>",
        **heatmap_color_kwargs(z, agg=agg, colorscale=colorscale),
    }

    fig = go.Figure(go.Heatmap(**heatmap_kwargs))

    shapes: list[dict[str, Any]] = []
    if show_diagonal_box:
        for i in range(n):
            shapes.append(
                {
                    "type": "rect",
                    "xref": "x",
                    "yref": "y",
                    "x0": i - 0.5,
                    "x1": i + 0.5,
                    "y0": i - 0.5,
                    "y1": i + 0.5,
                    "line": {"color": "rgba(0,0,0,0.4)", "width": 1.0},
                    "fillcolor": "rgba(0,0,0,0)",
                }
            )

    annotations: list[dict[str, Any]] = []
    if annotate_top_k is not None and annotate_top_k > 0:
        # Off-diagonal cells in the upper triangle (matrix is symmetric)
        candidates: list[tuple[float, int, int]] = []
        for i in range(n):
            for j in range(i + 1, n):
                candidates.append((float(z[i, j]), i, j))
        candidates.sort(key=lambda t: abs(t[0]), reverse=True)
        for value, i, j in candidates[:annotate_top_k]:
            text = f"{value:+.3f}" if agg == "mean_signed" else f"{value:.3f}"
            annotations.append(
                {
                    "x": j,
                    "y": i,
                    "xref": "x",
                    "yref": "y",
                    "text": text,
                    "showarrow": False,
                    "font": {"size": 10, "color": "black"},
                    "bgcolor": "rgba(255,255,255,0.65)",
                }
            )

    fig.update_layout(
        title=title or f"Concept × concept SHAP interaction ({agg})",
        xaxis={"side": "bottom", "tickangle": 45, "showgrid": False, "constrain": "domain"},
        yaxis={
            "autorange": "reversed",
            "showgrid": False,
            "scaleanchor": "x",
            "constrain": "domain",
        },
        shapes=shapes,
        annotations=annotations,
        margin={"t": 60, "l": 140, "r": 30, "b": 120},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

concept_sankey

concept_sankey(graph: ConceptGraph, feature_names: Sequence[str], shap_values: ndarray, *, max_features_per_concept: int | None = None, branch_palette: Sequence[str] | None = None, positive_color: str = '#2ca02c', negative_color: str = '#d62728', title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a SHAP-flow Sankey that walks the full concept hierarchy.

Tiers (left → right):

  • Features — one node per included feature.
  • Concept tiers — one node per concept on any included feature's path (excluding the hidden root). For deep trees this produces multiple intermediate tiers (e.g. feature → sub-concept → top-level concept).
  • Outcome — two nodes (+, −).

Link weights:

  • feature → direct-parent: sum_n |SHAP[n, f]| — total magnitude carried by the feature.
  • concept → its parent concept: sum_n |s_c[n]| where s_c[n] = sum_{f in c.descendants} SHAP[n, f]. This is the signed sum's magnitude, so within-concept cancellation narrows the band as you move right.
  • top-level concept → +: sum_n max(0, s_t[n]).
  • top-level concept → −: sum_n max(0, -s_t[n]).

Conservation is intentionally relaxed: at each concept node, total incoming flow ≥ outgoing flow whenever descendants' SHAP cancel within the concept. The shrinkage from left to right is the diagnostic — it tells you which concepts wash out under aggregation.

PARAMETER DESCRIPTION
graph

The ConceptGraph.

TYPE: ConceptGraph

feature_names

Names matching the columns of shap_values.

TYPE: Sequence[str]

shap_values

Per-sample SHAP, shape (N, F).

TYPE: ndarray

max_features_per_concept

Optional cap: per top-level concept, keep only the top-K descendant features by sum_n |SHAP|. Concepts on the ancestor chain of kept features remain.

TYPE: int | None DEFAULT: None

branch_palette

Custom palette for branch base hues.

TYPE: Sequence[str] | None DEFAULT: None

positive_color

Colours for the + / outcome nodes (and their incoming links).

TYPE: str DEFAULT: '#2ca02c'

negative_color

Colours for the + / outcome nodes (and their incoming links).

TYPE: str DEFAULT: '#2ca02c'

title

Figure title.

TYPE: str | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/concept_sankey.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
def concept_sankey(
    graph: ConceptGraph,
    feature_names: Sequence[str],
    shap_values: np.ndarray,
    *,
    max_features_per_concept: int | None = None,
    branch_palette: Sequence[str] | None = None,
    positive_color: str = "#2ca02c",
    negative_color: str = "#d62728",
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a SHAP-flow Sankey that walks the **full** concept hierarchy.

    Tiers (left → right):

    * **Features** — one node per included feature.
    * **Concept tiers** — one node per concept on any included feature's
      path (excluding the hidden root). For deep trees this produces
      multiple intermediate tiers (e.g. ``feature → sub-concept →
      top-level concept``).
    * **Outcome** — two nodes (+, −).

    Link weights:

    * ``feature → direct-parent``: ``sum_n |SHAP[n, f]|`` — total
      magnitude carried by the feature.
    * ``concept → its parent concept``: ``sum_n |s_c[n]|`` where
      ``s_c[n] = sum_{f in c.descendants} SHAP[n, f]``. This is the
      *signed* sum's magnitude, so within-concept cancellation
      narrows the band as you move right.
    * ``top-level concept → +``: ``sum_n max(0, s_t[n])``.
    * ``top-level concept → −``: ``sum_n max(0, -s_t[n])``.

    Conservation is intentionally relaxed: at each concept node, total
    *incoming* flow ≥ *outgoing* flow whenever descendants' SHAP
    cancel within the concept. The shrinkage from left to right is the
    diagnostic — it tells you which concepts wash out under aggregation.

    Parameters
    ----------
    graph:
        The ConceptGraph.
    feature_names:
        Names matching the columns of ``shap_values``.
    shap_values:
        Per-sample SHAP, shape ``(N, F)``.
    max_features_per_concept:
        Optional cap: per top-level concept, keep only the top-K
        descendant features by ``sum_n |SHAP|``. Concepts on the
        ancestor chain of kept features remain.
    branch_palette:
        Custom palette for branch base hues.
    positive_color, negative_color:
        Colours for the ``+`` / ``−`` outcome nodes (and their incoming
        links).
    title:
        Figure title.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    arr = np.asarray(shap_values, dtype=float)
    if arr.ndim != 2:
        raise ValueError(f"shap_values must be 2D (N, F); got {arr.shape}")
    if arr.shape[1] != len(feature_names):
        raise ValueError(
            f"shap_values has {arr.shape[1]} cols but feature_names has {len(feature_names)}"
        )

    name_to_col: dict[str, int] = {name: i for i, name in enumerate(feature_names)}

    # 1. Build per-feature ancestor chain (feature → direct parent → ... → top-level).
    #    Drop features with no concept ancestor (path is just (root, feature)) and
    #    features carrying zero SHAP magnitude.
    feature_magnitude: dict[str, float] = {}
    feature_chain: dict[str, list[str]] = {}
    feature_top_branch: dict[str, str] = {}
    for feat in graph.features():
        col = name_to_col.get(feat)
        if col is None:
            continue
        path = graph.path(feat)
        if len(path) < 3:
            continue  # feature directly under root → no concept layer to render
        magnitude = float(np.abs(arr[:, col]).sum())
        if magnitude == 0.0:
            continue
        feature_magnitude[feat] = magnitude
        # chain: [feature, direct_parent, ..., top-level concept]
        chain = [path[-1], *list(reversed(path[1:-1]))]
        feature_chain[feat] = chain
        feature_top_branch[feat] = path[1]

    if not feature_magnitude:
        raise ValueError(
            "no features carry non-zero SHAP magnitude under a concept; "
            "check feature_names alignment and graph structure"
        )

    # 2. Optional: cap features per top-level concept by magnitude.
    if max_features_per_concept is not None:
        per_branch: dict[str, list[str]] = {}
        for feat, branch in feature_top_branch.items():
            per_branch.setdefault(branch, []).append(feat)
        kept: set[str] = set()
        for feats in per_branch.values():
            feats_sorted = sorted(feats, key=lambda f: feature_magnitude[f], reverse=True)
            kept.update(feats_sorted[:max_features_per_concept])
        feature_magnitude = {f: m for f, m in feature_magnitude.items() if f in kept}
        feature_chain = {f: c for f, c in feature_chain.items() if f in kept}
        feature_top_branch = {f: b for f, b in feature_top_branch.items() if f in kept}
        if not feature_magnitude:
            raise ValueError("max_features_per_concept filtered out every feature")

    # 3. Concept set: every node appearing on any chain (excluding the feature).
    included_concepts_in_order: list[str] = []
    seen_concepts: set[str] = set()
    for chain in feature_chain.values():
        for node in chain[1:]:
            if node not in seen_concepts:
                seen_concepts.add(node)
                included_concepts_in_order.append(node)

    # 4. Per-concept signed per-sample value (sum of SHAP across its included
    #    descendant features). Used for both concept→parent magnitudes and
    #    top-level→outcome signed splits.
    concept_signed_per_sample: dict[str, np.ndarray] = {}
    for concept in seen_concepts:
        feats = [f for f in graph.descendant_features(concept) if f in feature_magnitude]
        if not feats:
            continue
        cols = [name_to_col[f] for f in feats]
        concept_signed_per_sample[concept] = arr[:, cols].sum(axis=1)

    # 5. Build node list, color map, and explicit (x, y) positions.
    #    Plotly's automatic layout minimizes link crossings and re-orders
    #    nodes within a tier — so we pin every node with explicit (x, y)
    #    instead. Vertical order within every tier follows the graph's DFS
    #    preorder, which groups every node next to its siblings under the
    #    same parent.
    node_labels: list[str] = []
    node_colors: list[str] = []
    feature_node_idx: dict[str, int] = {}
    concept_node_idx: dict[str, int] = {}

    branch_set = {feature_top_branch[f] for f in feature_magnitude}
    branch_ids = ["/".join(graph.path(b)) for b in branch_set]
    branch_colors_list = branch_colors(graph, branch_ids, palette=branch_palette)
    color_for_branch_path = dict(zip(branch_ids, branch_colors_list, strict=True))

    def node_color(top_branch: str) -> str:
        return color_for_branch_path.get("/".join(graph.path(top_branch)), "#cccccc")

    dfs_order = graph.nodes_in_order()
    feature_set = set(feature_magnitude)
    max_concept_depth = max((len(graph.path(c)) - 1 for c in seen_concepts), default=1)
    # Tier x-positions in (0, 1), strictly increasing left -> right:
    # tier 0 = features, tiers 1..max_concept_depth = concepts (deepest -> top),
    # final tier = outcomes.
    n_tiers = 1 + max_concept_depth + 1  # features + concept tiers + outcome
    margin = 1.0 / (n_tiers + 1)
    tier_x: dict[int, float] = {
        t: margin + t * (1.0 - 2 * margin) / max(n_tiers - 1, 1) for t in range(n_tiers)
    }

    # 5a. Features (tier 0) in DFS preorder -> ontological grouping
    feature_tier: list[str] = [n for n in dfs_order if n in feature_set]
    # 5b. Concepts grouped by depth, deepest first; within each depth, DFS order
    concept_tiers: dict[int, list[str]] = {}
    for current_depth in range(max_concept_depth, 0, -1):
        concept_tiers[current_depth] = [
            n for n in dfs_order if n in seen_concepts and len(graph.path(n)) - 1 == current_depth
        ]

    # Map depth -> tier index. tier 0 = features; tier t (1..max_concept_depth)
    # = concepts of depth (max_concept_depth - t + 1) so deepest concepts are
    # tier 1 (just right of features) and top-level concepts are tier
    # max_concept_depth (just left of outcomes).
    def concept_tier_index(depth: int) -> int:
        return max_concept_depth - depth + 1

    def y_positions(n: int) -> list[float]:
        if n == 1:
            return [0.5]
        # Plotly Sankey uses y=0 at the top and y=1 at the bottom (screen
        # coordinates, not Cartesian) — so rank 0 (first in DFS) gets the
        # smallest y and sits at the top of the diagram.
        return [(i + 0.5) / n for i in range(n)]

    node_x: list[float] = []
    node_y: list[float] = []

    for feat, y in zip(feature_tier, y_positions(len(feature_tier)), strict=True):
        feature_node_idx[feat] = len(node_labels)
        node_labels.append(feat)
        node_colors.append(node_color(feature_top_branch[feat]))
        node_x.append(tier_x[0])
        node_y.append(y)

    for depth in range(max_concept_depth, 0, -1):
        tier_nodes = concept_tiers[depth]
        tier_idx = concept_tier_index(depth)
        ys = y_positions(len(tier_nodes))
        for node, y in zip(tier_nodes, ys, strict=True):
            concept_node_idx[node] = len(node_labels)
            node_labels.append(node)
            node_colors.append(node_color(graph.path(node)[1]))
            node_x.append(tier_x[tier_idx])
            node_y.append(y)

    # + outcome on top, - outcome at the bottom (y=0 is the top in Plotly).
    pos_node_idx = len(node_labels)
    node_labels.append("+ outcome")
    node_colors.append(positive_color)
    node_x.append(tier_x[n_tiers - 1])
    node_y.append(0.25)
    neg_node_idx = len(node_labels)
    node_labels.append("- outcome")
    node_colors.append(negative_color)
    node_x.append(tier_x[n_tiers - 1])
    node_y.append(0.75)

    # 6. Build links.
    sources: list[int] = []
    targets: list[int] = []
    values: list[float] = []
    link_colors: list[str] = []

    # 6a. feature → direct parent
    for feat in feature_magnitude:
        chain = feature_chain[feat]
        direct_parent = chain[1]
        sources.append(feature_node_idx[feat])
        targets.append(concept_node_idx[direct_parent])
        values.append(feature_magnitude[feat])
        link_colors.append(_rgba(node_color(feature_top_branch[feat]), 0.35))

    # 6b. concept → its parent concept (skip top-level: their parent is the root)
    for concept in seen_concepts:
        path = graph.path(concept)
        if len(path) <= 2:
            continue  # top-level concept (its parent is root, handled at outcome step)
        parent = path[-2]
        if parent not in concept_node_idx:
            continue
        per_sample = concept_signed_per_sample.get(concept)
        if per_sample is None:
            continue
        weight = float(np.abs(per_sample).sum())
        if weight == 0.0:
            continue
        sources.append(concept_node_idx[concept])
        targets.append(concept_node_idx[parent])
        values.append(weight)
        link_colors.append(_rgba(node_color(path[1]), 0.35))

    # 6c. top-level concept → +/- outcome
    top_levels = {feature_top_branch[f] for f in feature_magnitude}
    for top in top_levels:
        per_sample = concept_signed_per_sample.get(top)
        if per_sample is None:
            continue
        pos_flow = float(np.maximum(per_sample, 0.0).sum())
        neg_flow = float(np.maximum(-per_sample, 0.0).sum())
        if pos_flow > 0:
            sources.append(concept_node_idx[top])
            targets.append(pos_node_idx)
            values.append(pos_flow)
            link_colors.append(_rgba(positive_color, 0.35))
        if neg_flow > 0:
            sources.append(concept_node_idx[top])
            targets.append(neg_node_idx)
            values.append(neg_flow)
            link_colors.append(_rgba(negative_color, 0.35))

    fig = go.Figure(
        go.Sankey(
            # arrangement="snap" respects explicit node x/y while still
            # snapping minor adjustments; "fixed" would freeze user dragging
            # too aggressively.
            arrangement="snap",
            node={
                "label": node_labels,
                "color": node_colors,
                "x": node_x,
                "y": node_y,
                "pad": 14,
                "thickness": 16,
                "line": {"color": "rgba(0,0,0,0.5)", "width": 0.5},
            },
            link={
                "source": sources,
                "target": targets,
                "value": values,
                "color": link_colors,
            },
        )
    )

    fig.update_layout(
        title=title or "Concept SHAP flow — feature -> concepts -> +/- outcome",
        margin={"t": 60, "l": 30, "r": 30, "b": 30},
        height=max(420, 22 * len(node_labels) + 200),
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

concept_violin

concept_violin(graph: ConceptGraph, feature_names: Sequence[str], shap_values: ndarray, *, only_concepts: bool = True, sort_by_importance: bool = True, max_concepts: int | None = None, title: str | None = None, points: ViolinPoints = 'outliers', box_visible: bool = True, meanline_visible: bool = True, branch_palette: Sequence[str] | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a per-concept violin of summed signed SHAP across samples.

Each row is a concept (or feature, if only_concepts=False); the violin shape is the KDE of the per-sample sum of SHAP across the concept's descendant features. A central box-and-whiskers and a mean line can be overlaid via box_visible and meanline_visible.

PARAMETER DESCRIPTION
graph

The ConceptGraph.

TYPE: ConceptGraph

feature_names

Names of the features matching the columns of shap_values.

TYPE: Sequence[str]

shap_values

Per-sample SHAP values of shape (N, F).

TYPE: ndarray

only_concepts

If True (default), drop feature leaves from the chart.

TYPE: bool DEFAULT: True

sort_by_importance

If True (default), order rows by mean(|summed SHAP|) descending.

TYPE: bool DEFAULT: True

max_concepts

Optionally cap the number of rows shown (top-K by importance).

TYPE: int | None DEFAULT: None

title

Figure title.

TYPE: str | None DEFAULT: None

points

Which raw points to overlay on the violin. "outliers" (default) shows only points beyond the whiskers; "all" shows every sample (slow for large N); False shows none.

TYPE: ViolinPoints DEFAULT: 'outliers'

box_visible

Draw the box-and-whiskers inside each violin. Default True.

TYPE: bool DEFAULT: True

meanline_visible

Draw the mean as a dashed line inside each violin. Default True.

TYPE: bool DEFAULT: True

branch_palette

Custom palette for branch base hues (each violin is tinted by its top-level branch with hierarchical shading). Defaults to the Plotly qualitative palette.

TYPE: Sequence[str] | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/concept_violin.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def concept_violin(
    graph: ConceptGraph,
    feature_names: Sequence[str],
    shap_values: np.ndarray,
    *,
    only_concepts: bool = True,
    sort_by_importance: bool = True,
    max_concepts: int | None = None,
    title: str | None = None,
    points: ViolinPoints = "outliers",
    box_visible: bool = True,
    meanline_visible: bool = True,
    branch_palette: Sequence[str] | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a per-concept violin of summed signed SHAP across samples.

    Each row is a concept (or feature, if ``only_concepts=False``); the violin
    shape is the KDE of the per-sample sum of SHAP across the concept's
    descendant features. A central box-and-whiskers and a mean line can be
    overlaid via ``box_visible`` and ``meanline_visible``.

    Parameters
    ----------
    graph:
        The ConceptGraph.
    feature_names:
        Names of the features matching the columns of ``shap_values``.
    shap_values:
        Per-sample SHAP values of shape ``(N, F)``.
    only_concepts:
        If True (default), drop feature leaves from the chart.
    sort_by_importance:
        If True (default), order rows by mean(|summed SHAP|) descending.
    max_concepts:
        Optionally cap the number of rows shown (top-K by importance).
    title:
        Figure title.
    points:
        Which raw points to overlay on the violin. ``"outliers"`` (default)
        shows only points beyond the whiskers; ``"all"`` shows every sample
        (slow for large N); ``False`` shows none.
    box_visible:
        Draw the box-and-whiskers inside each violin. Default ``True``.
    meanline_visible:
        Draw the mean as a dashed line inside each violin. Default ``True``.
    branch_palette:
        Custom palette for branch base hues (each violin is tinted by its
        top-level branch with hierarchical shading). Defaults to the Plotly
        qualitative palette.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    arr = np.asarray(shap_values, dtype=float)
    if arr.ndim != 2:
        raise ValueError(f"shap_values must be 2D (N, F); got {arr.shape}")
    if arr.shape[1] != len(feature_names):
        raise ValueError(
            f"shap_values has {arr.shape[1]} cols but feature_names has {len(feature_names)}"
        )

    name_to_idx: dict[str, int] = {n: i for i, n in enumerate(feature_names)}

    nodes: list[str] = []
    for node in graph.nodes_in_order():
        if node == graph.root:
            continue
        if only_concepts and graph.kind(node) == "feature":
            continue
        feats = [f for f in graph.descendant_features(node) if f in name_to_idx]
        if not feats:
            continue
        nodes.append(node)

    if not nodes:
        raise ValueError("no concepts (or features) match the SHAP feature names")

    summed: dict[str, np.ndarray] = {}
    importance: dict[str, float] = {}
    for node in nodes:
        idxs = [name_to_idx[f] for f in graph.descendant_features(node) if f in name_to_idx]
        block = arr[:, idxs]
        s = block.sum(axis=1)
        summed[node] = s
        importance[node] = float(np.abs(s).mean())

    if sort_by_importance:
        nodes = sorted(nodes, key=lambda n: importance[n], reverse=True)
    if max_concepts is not None:
        nodes = nodes[:max_concepts]

    node_ids = ["/".join(graph.path(n)) for n in nodes]
    colors = branch_colors(graph, node_ids, palette=branch_palette)

    fig = go.Figure()
    for node, color in zip(nodes, colors, strict=True):
        s = summed[node]
        fig.add_trace(
            go.Violin(
                x=s,
                y=[node] * len(s),
                name=node,
                orientation="h",
                points=points,
                box_visible=box_visible,
                meanline_visible=meanline_visible,
                fillcolor=color,
                line={"color": "rgba(0,0,0,0.6)", "width": 1},
                opacity=0.85,
                marker={"size": 3, "opacity": 0.5, "color": "rgba(0,0,0,0.5)"},
                showlegend=False,
                hoveron="violins+points",
                hovertemplate="%{y}<br>SHAP: %{x:+.4f}<extra></extra>",
                spanmode="hard",
            )
        )

    fig.add_vline(x=0.0, line={"color": "black", "width": 1, "dash": "dash"})

    fig.update_layout(
        title=title or "Concept SHAP distribution (violin)",
        xaxis_title="summed signed SHAP",
        yaxis_title="concept",
        yaxis={"autorange": "reversed"},
        violingap=0.3,
        violinmode="overlay",
        margin={"t": 60, "l": 160, "r": 30, "b": 60},
        height=max(300, 50 * len(nodes) + 120),
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

auc_drop_map

auc_drop_map(graph: ConceptGraph, df: DataFrame, *, value: str = 'auc_drop_mean', size: str = 'feature_count', colorscale: str = 'Reds', hide_root: bool = True, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst where each concept is colored by its AUC drop.

Sector area uses size (feature count by default), the colour intensity uses value (mean AUC drop by default). Set hide_root=False to keep the root sector visible.

Source code in src/concept_graph_xai/plotting/auc_drop_map.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def auc_drop_map(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    value: str = "auc_drop_mean",
    size: str = "feature_count",
    colorscale: str = "Reds",
    hide_root: bool = True,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst where each concept is colored by its AUC drop.

    Sector area uses ``size`` (feature count by default), the colour intensity
    uses ``value`` (mean AUC drop by default). Set ``hide_root=False`` to
    keep the root sector visible.
    """

    if value not in df.columns:
        raise KeyError(f"{value!r} not in DataFrame; run metrics.auc_drop first")
    if size not in df.columns:
        raise KeyError(f"{size!r} not in DataFrame")

    arrays, ordered, sizes = sunburst_layout(graph, df, value=size, hide_root=hide_root)

    drop_vals = ordered[value].to_numpy(dtype=float)
    drop_for_color = np.where(np.isnan(drop_vals), 0.0, drop_vals)
    cmax = float(np.nanmax(np.abs(drop_vals))) if not np.all(np.isnan(drop_vals)) else 1.0
    cmin = -cmax if (np.nanmin(drop_vals) < 0) else 0.0

    hover_cols = [
        c
        for c in (
            value,
            "auc_drop_std",
            "ablated_score_mean",
            "baseline_score",
            "feature_count",
            "strategy",
        )
        if c in ordered.columns
    ]
    hover = hover_text(
        ordered,
        hover_cols,
        fmt={
            value: "+.4f",
            "auc_drop_std": ".4f",
            "ablated_score_mean": ".4f",
            "baseline_score": ".4f",
        },
    )

    return build_sunburst_figure(
        arrays,
        sizes,
        marker={
            "colors": drop_for_color,
            "colorscale": colorscale,
            "cmin": cmin,
            "cmax": cmax,
            "showscale": True,
            "colorbar": {"title": value},
        },
        hover=hover,
        title=title or "AUC drop per concept",
        layout_kwargs=layout_kwargs,
    )

correlation_block

correlation_block(result: CorrelationResult, *, title: str | None = None, show_block_labels: bool = True, annotate_mean_abs: bool = True, colorscale: str = 'RdBu', zmid: float = 0.0, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a correlation matrix with concept-block separators.

Works on the output of any of :func:feature_correlation, :func:nullity_correlation, or :func:shap_correlation — they all return a :class:CorrelationResult.

PARAMETER DESCRIPTION
result

Output of one of the correlation metrics.

TYPE: CorrelationResult

title

Figure title.

TYPE: str | None DEFAULT: None

show_block_labels

Draw the concept name above each diagonal block.

TYPE: bool DEFAULT: True

annotate_mean_abs

Print mean(|r|) inside each diagonal block.

TYPE: bool DEFAULT: True

colorscale

Plotly colorscale name. Default RdBu is symmetric around zero.

TYPE: str DEFAULT: 'RdBu'

zmid

Mid value for the colorscale. Use 0 for a diverging palette.

TYPE: float DEFAULT: 0.0

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/correlation_block.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def correlation_block(
    result: CorrelationResult,
    *,
    title: str | None = None,
    show_block_labels: bool = True,
    annotate_mean_abs: bool = True,
    colorscale: str = "RdBu",
    zmid: float = 0.0,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a correlation matrix with concept-block separators.

    Works on the output of any of :func:`feature_correlation`,
    :func:`nullity_correlation`, or :func:`shap_correlation` — they all return
    a :class:`CorrelationResult`.

    Parameters
    ----------
    result:
        Output of one of the correlation metrics.
    title:
        Figure title.
    show_block_labels:
        Draw the concept name above each diagonal block.
    annotate_mean_abs:
        Print ``mean(|r|)`` inside each diagonal block.
    colorscale:
        Plotly colorscale name. Default ``RdBu`` is symmetric around zero.
    zmid:
        Mid value for the colorscale. Use ``0`` for a diverging palette.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    matrix = result.matrix
    n = matrix.shape[0]

    fig = go.Figure(
        go.Heatmap(
            z=matrix.to_numpy(),
            x=list(matrix.columns),
            y=list(matrix.index),
            colorscale=colorscale,
            zmid=zmid,
            zmin=-1.0,
            zmax=1.0,
            colorbar={"title": f"{result.method} ρ"},
            hovertemplate="%{x} ↔ %{y}<br>%{z:.3f}<extra></extra>",
        )
    )

    shapes: list[dict[str, Any]] = []
    annotations: list[dict[str, Any]] = []
    stats_lookup = result.block_stats.set_index("concept_path").to_dict("index")

    # Depth = number of slashes in concept_path. Root has depth 0; top-level
    # concepts under root have depth 1; sub-concepts depth 2, etc. We stack
    # block labels in horizontal rows below the heatmap, with the deepest
    # concept closest to the heatmap and the top-level concepts furthest down.
    # This stops nested blocks (e.g. "Behaviour" + "Delinquency") from writing
    # their labels on top of each other.
    block_depths = [path.count("/") for path, _s, _e in result.blocks]
    visible_depths = [d for d in block_depths if d > 0]
    max_depth = max(visible_depths) if visible_depths else 1
    row_height = 1.0
    label_top_y = -1.2  # closest to heatmap (deepest concepts)

    for (path, start, end), depth in zip(result.blocks, block_depths, strict=True):
        # Diagonal block border
        shapes.append(
            {
                "type": "rect",
                "xref": "x",
                "yref": "y",
                "x0": start - 0.5,
                "x1": end - 0.5,
                "y0": start - 0.5,
                "y1": end - 0.5,
                "line": {"color": "black", "width": 1.5},
                "fillcolor": "rgba(0,0,0,0)",
            }
        )
        if show_block_labels and end - start >= 1 and depth >= 1:
            label = path.split("/")[-1]
            # Deeper concepts → smaller magnitude y (closer to heatmap).
            # Top-level branches (depth=1) → most negative y (further down).
            row = max_depth - depth  # 0 for deepest, max_depth-1 for top-level
            y_pos = label_top_y - row * row_height
            font_size = 12 if depth == 1 else max(8, 11 - (depth - 1))
            annotations.append(
                {
                    "x": (start + end - 1) / 2,
                    "y": y_pos,
                    "xref": "x",
                    "yref": "y",
                    "text": f"<b>{label}</b>" if depth == 1 else label,
                    "showarrow": False,
                    "font": {"size": font_size},
                }
            )
        if annotate_mean_abs and end - start >= 2:
            stats = stats_lookup.get(path, {})
            mean_abs = stats.get("mean_abs")
            if mean_abs is not None:
                annotations.append(
                    {
                        "x": (start + end - 1) / 2,
                        "y": (start + end - 1) / 2,
                        "xref": "x",
                        "yref": "y",
                        "text": f"|ρ̄|={mean_abs:.2f}",
                        "showarrow": False,
                        "font": {"size": 10, "color": "black"},
                        "bgcolor": "rgba(255,255,255,0.6)",
                    }
                )

    label_band = max(1, max_depth) * row_height + 0.5  # space reserved below heatmap
    bottom_margin = int(60 + 22 * max_depth)

    fig.update_layout(
        title=title,
        xaxis={"side": "bottom", "tickangle": 45, "showgrid": False, "range": [-0.5, n - 0.5]},
        yaxis={
            "autorange": "reversed",
            "showgrid": False,
            "range": [n - 0.5, label_top_y - label_band],
        },
        shapes=shapes,
        annotations=annotations,
        margin={"t": 40, "l": 40, "r": 40, "b": bottom_margin},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

joint_missing_map

joint_missing_map(graph: ConceptGraph, df: DataFrame, *, value: str = 'joint_missing_rate', size: str = 'feature_count', colorscale: str = 'Reds', hide_root: bool = True, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst where each concept is coloured by its joint-missing rate.

The DataFrame must come from :func:joint_missing_rate. Sector size uses feature_count so the shape matches the existing sunburst plots; colour intensity uses joint_missing_rate. Set hide_root=False to keep the root sector visible.

Source code in src/concept_graph_xai/plotting/joint_missing_map.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def joint_missing_map(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    value: str = "joint_missing_rate",
    size: str = "feature_count",
    colorscale: str = "Reds",
    hide_root: bool = True,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst where each concept is coloured by its joint-missing rate.

    The DataFrame must come from :func:`joint_missing_rate`. Sector size uses
    ``feature_count`` so the shape matches the existing sunburst plots; colour
    intensity uses ``joint_missing_rate``. Set ``hide_root=False`` to keep
    the root sector visible.
    """

    if value not in df.columns:
        raise KeyError(f"{value!r} not in DataFrame; run joint_missing_rate first")
    if size not in df.columns:
        raise KeyError(f"{size!r} not in DataFrame")

    arrays, ordered, sizes = sunburst_layout(graph, df, value=size, hide_root=hide_root)
    rates = ordered[value].fillna(0).to_numpy(dtype=float)
    cmax = max(1e-6, float(rates.max()))

    hover_cols = [c for c in (value, "feature_count") if c in ordered.columns]
    hover = hover_text(ordered, hover_cols, fmt={value: ".3f"})

    return build_sunburst_figure(
        arrays,
        sizes,
        marker={
            "colors": rates,
            "colorscale": colorscale,
            "cmin": 0.0,
            "cmax": cmax,
            "showscale": True,
            "colorbar": {"title": value},
        },
        hover=hover,
        title=title or "Joint missingness per concept",
        layout_kwargs=layout_kwargs,
    )

coherence_importance_scatter

coherence_importance_scatter(df: DataFrame, *, only_concepts: bool = True, label_points: bool = True, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render the coherence × importance quadrant scatter.

PARAMETER DESCRIPTION
df

Output of :func:coherence_importance. Must carry coherence, importance_sum and quadrant columns. Threshold values are read from df.attrs["coherence_threshold"] and df.attrs["importance_threshold"].

TYPE: DataFrame

only_concepts

Drop rows where kind == "feature" so the chart shows only business concepts.

TYPE: bool DEFAULT: True

label_points

Annotate every point with the concept name.

TYPE: bool DEFAULT: True

Source code in src/concept_graph_xai/plotting/coherence_importance_scatter.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def coherence_importance_scatter(
    df: pd.DataFrame,
    *,
    only_concepts: bool = True,
    label_points: bool = True,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render the coherence × importance quadrant scatter.

    Parameters
    ----------
    df:
        Output of :func:`coherence_importance`. Must carry ``coherence``,
        ``importance_sum`` and ``quadrant`` columns. Threshold values are
        read from ``df.attrs["coherence_threshold"]`` and
        ``df.attrs["importance_threshold"]``.
    only_concepts:
        Drop rows where ``kind == "feature"`` so the chart shows only
        business concepts.
    label_points:
        Annotate every point with the concept name.
    """

    needed = {"coherence", "importance_sum", "quadrant"}
    missing = needed - set(df.columns)
    if missing:
        raise KeyError(f"missing columns from coherence_importance: {missing}")

    plot_df = df.copy()
    if only_concepts and "kind" in plot_df.columns:
        plot_df = plot_df.loc[plot_df["kind"] == "concept"].copy()

    coh_thr = float(df.attrs.get("coherence_threshold", 0.0))
    imp_thr = float(df.attrs.get("importance_threshold", 0.0))

    fig = go.Figure()
    for quadrant, color in _QUADRANT_COLOR.items():
        sub = plot_df.loc[plot_df["quadrant"] == quadrant]
        if sub.empty:
            continue
        fig.add_trace(
            go.Scatter(
                x=sub["coherence"],
                y=sub["importance_sum"],
                mode="markers+text" if label_points else "markers",
                text=sub["name"] if label_points else None,
                textposition="top center",
                marker={
                    "size": 12,
                    "color": color,
                    "line": {"color": "black", "width": 0.5},
                },
                name=quadrant.replace("_", " "),
                customdata=np.stack(
                    [
                        sub.get("feature_count", pd.Series([0] * len(sub))).to_numpy(),
                        sub.get("kind", pd.Series([""] * len(sub))).to_numpy(),
                    ],
                    axis=1,
                ),
                hovertemplate=(
                    "<b>%{text}</b><br>"
                    "coherence: %{x:.3f}<br>"
                    "importance: %{y:.4f}<br>"
                    "feature_count: %{customdata[0]}<br>"
                    "kind: %{customdata[1]}"
                    "<extra></extra>"
                ),
            )
        )

    fig.add_hline(y=imp_thr, line={"color": "black", "dash": "dash", "width": 1})
    fig.add_vline(x=coh_thr, line={"color": "black", "dash": "dash", "width": 1})

    fig.update_layout(
        title=title or "Concept coherence vs importance",
        xaxis_title=f"within-concept mean(|ρ|)  ({df.attrs.get('method', 'spearman')})",
        yaxis_title="summed |SHAP|",
        margin={"t": 60, "l": 60, "r": 30, "b": 60},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

regulatory_tag_overlay

regulatory_tag_overlay(graph: ConceptGraph, df: DataFrame | None = None, *, tag_key: str = 'tag', palette: dict[str, str] | None = None, untagged_color: str = '#dddddd', value: str = 'count', hide_root: bool = True, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst whose sectors are coloured by a node-metadata tag.

PARAMETER DESCRIPTION
graph

ConceptGraph; tag is read from graph.view(node).metadata[tag_key].

TYPE: ConceptGraph

df

Optional DataFrame providing the feature_count column (or any value column). Defaults to a count-based sunburst.

TYPE: DataFrame | None DEFAULT: None

tag_key

Metadata key carrying the categorical tag.

TYPE: str DEFAULT: 'tag'

palette

Optional tag -> css_color mapping. Unmapped tags get colours from a default palette.

TYPE: dict[str, str] | None DEFAULT: None

untagged_color

Colour for nodes that carry no value under tag_key.

TYPE: str DEFAULT: '#dddddd'

hide_root

When True (default) the root concept is omitted; pass False to keep the legacy root sector.

TYPE: bool DEFAULT: True

Source code in src/concept_graph_xai/plotting/regulatory_tag_overlay.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def regulatory_tag_overlay(
    graph: ConceptGraph,
    df: pd.DataFrame | None = None,
    *,
    tag_key: str = "tag",
    palette: dict[str, str] | None = None,
    untagged_color: str = "#dddddd",
    value: str = "count",
    hide_root: bool = True,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst whose sectors are coloured by a node-metadata tag.

    Parameters
    ----------
    graph:
        ConceptGraph; tag is read from ``graph.view(node).metadata[tag_key]``.
    df:
        Optional DataFrame providing the ``feature_count`` column (or any
        ``value`` column). Defaults to a count-based sunburst.
    tag_key:
        Metadata key carrying the categorical tag.
    palette:
        Optional ``tag -> css_color`` mapping. Unmapped tags get colours from
        a default palette.
    untagged_color:
        Colour for nodes that carry no value under ``tag_key``.
    hide_root:
        When ``True`` (default) the root concept is omitted; pass ``False``
        to keep the legacy root sector.
    """

    if df is None:
        from concept_graph_xai.metrics.counts import feature_counts

        df = feature_counts(graph)

    arrays, ordered, sizes = sunburst_layout(graph, df, value=value, hide_root=hide_root)

    rendered_nodes = [
        node for node in graph.nodes_in_order() if not (hide_root and node == graph.root)
    ]
    tags: list[str] = []
    for node in rendered_nodes:
        meta = graph.view(node).metadata
        tag = meta.get(tag_key)
        tags.append(str(tag) if tag is not None else "")

    palette_map = dict(palette) if palette else {}
    next_idx = 0
    for tag in tags:
        if tag and tag not in palette_map:
            palette_map[tag] = _DEFAULT_PALETTE[next_idx % len(_DEFAULT_PALETTE)]
            next_idx += 1

    colors = [palette_map.get(tag, untagged_color) for tag in tags]
    hover = hover_text(ordered.assign(tag=tags), [value, "tag"])

    return build_sunburst_figure(
        arrays,
        sizes,
        marker={"colors": colors},
        hover=hover,
        title=title or f"Concepts coloured by {tag_key!r}",
        layout_kwargs=layout_kwargs,
    )

segment_concept_heatmap

segment_concept_heatmap(graph: ConceptGraph, df: DataFrame, *, only_concepts: bool = True, hide_root: bool = True, sort_by: str | None = 'max', max_concepts: int | None = None, title: str | None = None, colorscale: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a concept × segment SHAP heatmap.

PARAMETER DESCRIPTION
graph

The ConceptGraph (used for depth-aware ordering).

TYPE: ConceptGraph

df

Long-form output of :func:segment_importance.

TYPE: DataFrame

only_concepts

If True (default), drop feature leaves.

TYPE: bool DEFAULT: True

hide_root

If True (default), drop the root concept row.

TYPE: bool DEFAULT: True

sort_by

"max" (default) orders concept rows by the maximum value across segments, descending. "depth" keeps graph DFS preorder. None keeps the order in the DataFrame.

TYPE: str | None DEFAULT: 'max'

max_concepts

Optionally cap the number of rows shown (top-K by chosen ordering).

TYPE: int | None DEFAULT: None

title

Figure title.

TYPE: str | None DEFAULT: None

colorscale

Override the colorscale. Defaults to "Reds" for mean_abs and a diverging "RdBu" (centred at 0) for mean_signed.

TYPE: str | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/segment_concept_heatmap.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def segment_concept_heatmap(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    only_concepts: bool = True,
    hide_root: bool = True,
    sort_by: str | None = "max",
    max_concepts: int | None = None,
    title: str | None = None,
    colorscale: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a concept × segment SHAP heatmap.

    Parameters
    ----------
    graph:
        The ConceptGraph (used for depth-aware ordering).
    df:
        Long-form output of :func:`segment_importance`.
    only_concepts:
        If ``True`` (default), drop feature leaves.
    hide_root:
        If ``True`` (default), drop the root concept row.
    sort_by:
        ``"max"`` (default) orders concept rows by the maximum value across
        segments, descending. ``"depth"`` keeps graph DFS preorder.
        ``None`` keeps the order in the DataFrame.
    max_concepts:
        Optionally cap the number of rows shown (top-K by chosen ordering).
    title:
        Figure title.
    colorscale:
        Override the colorscale. Defaults to ``"Reds"`` for ``mean_abs`` and
        a diverging ``"RdBu"`` (centred at 0) for ``mean_signed``.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    if df.empty:
        raise ValueError("DataFrame is empty; run segment_importance first")
    for col in ("name", "kind", "segment", "value"):
        if col not in df.columns:
            raise KeyError(f"required column {col!r} missing from DataFrame")

    work = df.copy()
    if only_concepts:
        work = work[work["kind"] == "concept"]
    if hide_root:
        work = work[work["name"] != graph.root]

    segment_order = df.attrs.get("segment_order") or list(
        dict.fromkeys(work["segment"].astype(str))
    )

    pivot = work.pivot_table(index="name", columns="segment", values="value", aggfunc="mean")
    pivot = pivot.reindex(columns=[s for s in segment_order if s in pivot.columns])

    if sort_by == "max":
        pivot = (
            pivot.assign(_max=pivot.max(axis=1))
            .sort_values("_max", ascending=False)
            .drop(columns="_max")
        )
    elif sort_by == "depth":
        dfs_order = [n for n in graph.nodes_in_order() if n in set(pivot.index)]
        pivot = pivot.reindex(index=dfs_order)
    elif sort_by is not None:
        raise ValueError(f"unknown sort_by={sort_by!r}; expected 'max', 'depth', or None")

    if max_concepts is not None:
        pivot = pivot.head(max_concepts)

    if pivot.empty:
        raise ValueError("no rows to plot after filtering")

    agg = df.attrs.get("agg", "mean_abs")
    z = pivot.to_numpy(dtype=float)
    value_fmt = "+.4f" if agg == "mean_signed" else ".4f"
    heatmap_kwargs: dict[str, Any] = {
        "z": z,
        "x": list(pivot.columns),
        "y": list(pivot.index),
        "colorbar": {"title": agg},
        "hovertemplate": "%{y} | %{x}<br>" + agg + ": %{z:" + value_fmt + "}<extra></extra>",
        **heatmap_color_kwargs(z, agg=agg, colorscale=colorscale),
    }

    fig = go.Figure(go.Heatmap(**heatmap_kwargs))

    fig.update_layout(
        title=title or f"Concept SHAP by segment ({agg})",
        xaxis={"side": "bottom", "tickangle": 0, "showgrid": False, "title": "segment"},
        yaxis={"autorange": "reversed", "showgrid": False, "title": "concept"},
        margin={"t": 60, "l": 160, "r": 30, "b": 80},
        height=max(300, 28 * len(pivot) + 160),
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

concept_pareto

concept_pareto(graph: ConceptGraph, df: DataFrame, *, only_concepts: bool = True, hide_root: bool = True, segment_palette: Sequence[str] | None = None, show_equality_line: bool = True, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render per-segment Lorenz / Pareto curves of concept importance.

For each cohort in the long-form DataFrame from :func:segment_importance, concepts are sorted by value descending and accumulated to produce a Lorenz curve:

  • x = (rank + 1) / n_concepts — cumulative share of concepts.
  • y = cum_value / total_value — cumulative share of importance.

A curve hugging the dashed 45° equality line means the cohort's importance is spread evenly across concepts; a curve bulging up-left means a few concepts dominate that cohort's SHAP budget.

PARAMETER DESCRIPTION
graph

ConceptGraph (used to drop the root row).

TYPE: ConceptGraph

df

Long-form DataFrame from :func:segment_importance — must contain name, kind, segment, value.

TYPE: DataFrame

only_concepts

If True (default), drop feature leaves before ranking.

TYPE: bool DEFAULT: True

hide_root

If True (default), drop the root concept row (it aggregates every feature and would distort the curve).

TYPE: bool DEFAULT: True

segment_palette

Custom palette for per-segment colours. Defaults to the Plotly qualitative palette.

TYPE: Sequence[str] | None DEFAULT: None

show_equality_line

Overlay the dashed 45° reference line. Default True.

TYPE: bool DEFAULT: True

title

Figure title.

TYPE: str | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/concept_pareto.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def concept_pareto(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    only_concepts: bool = True,
    hide_root: bool = True,
    segment_palette: Sequence[str] | None = None,
    show_equality_line: bool = True,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render per-segment Lorenz / Pareto curves of concept importance.

    For each cohort in the long-form DataFrame from
    :func:`segment_importance`, concepts are sorted by ``value`` descending
    and accumulated to produce a Lorenz curve:

    * x = ``(rank + 1) / n_concepts`` — cumulative share of concepts.
    * y = ``cum_value / total_value`` — cumulative share of importance.

    A curve hugging the dashed 45° equality line means the cohort's
    importance is spread evenly across concepts; a curve bulging up-left
    means a few concepts dominate that cohort's SHAP budget.

    Parameters
    ----------
    graph:
        ConceptGraph (used to drop the root row).
    df:
        Long-form DataFrame from :func:`segment_importance` — must contain
        ``name``, ``kind``, ``segment``, ``value``.
    only_concepts:
        If ``True`` (default), drop feature leaves before ranking.
    hide_root:
        If ``True`` (default), drop the root concept row (it aggregates
        every feature and would distort the curve).
    segment_palette:
        Custom palette for per-segment colours. Defaults to the Plotly
        qualitative palette.
    show_equality_line:
        Overlay the dashed 45° reference line. Default ``True``.
    title:
        Figure title.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    if df.empty:
        raise ValueError("DataFrame is empty; run segment_importance first")
    for col in ("name", "kind", "segment", "value"):
        if col not in df.columns:
            raise KeyError(f"required column {col!r} missing from DataFrame")

    work = df.copy()
    if only_concepts:
        work = work[work["kind"] == "concept"]
    if hide_root:
        work = work[work["name"] != graph.root]

    segment_order: list[str] = df.attrs.get("segment_order") or list(
        dict.fromkeys(work["segment"].astype(str))
    )

    palette: tuple[str, ...] = (
        tuple(segment_palette) if segment_palette else _DEFAULT_SEGMENT_PALETTE
    )
    if not palette:
        raise ValueError("segment_palette must contain at least one colour")

    fig = go.Figure()

    if show_equality_line:
        fig.add_trace(
            go.Scatter(
                x=[0.0, 1.0],
                y=[0.0, 1.0],
                mode="lines",
                line={"color": "rgba(0,0,0,0.4)", "dash": "dash", "width": 1},
                name="equality",
                hoverinfo="skip",
                showlegend=True,
            )
        )

    plotted_segments = 0
    for color_idx, segment in enumerate(segment_order):
        block = work[work["segment"] == segment]
        if block.empty:
            continue
        values = block["value"].to_numpy(dtype=float)
        names = block["name"].tolist()
        if values.size == 0 or float(values.sum()) <= 0:
            continue
        order = np.argsort(-values)  # descending
        sorted_values = values[order]
        sorted_names = [names[i] for i in order]
        n = len(sorted_values)
        cum_share_x = np.arange(1, n + 1, dtype=float) / n
        cum_share_y = np.cumsum(sorted_values) / float(sorted_values.sum())
        # Prepend (0, 0) so each curve starts at the origin.
        xs = np.concatenate([[0.0], cum_share_x])
        ys = np.concatenate([[0.0], cum_share_y])
        labels_with_origin = ["—", *sorted_names]
        color = palette[color_idx % len(palette)]
        fig.add_trace(
            go.Scatter(
                x=xs,
                y=ys,
                mode="lines+markers",
                line={"color": color, "width": 2},
                marker={"size": 6, "color": color},
                name=str(segment),
                text=labels_with_origin,
                hovertemplate=(
                    f"{segment}<br>"
                    "rank %{x:.2%} of concepts<br>"
                    "captures %{y:.2%} of importance<br>"
                    "(next concept: %{text})<extra></extra>"
                ),
            )
        )
        plotted_segments += 1

    if plotted_segments == 0:
        raise ValueError("no segment had non-zero importance; nothing to plot")

    fig.update_layout(
        title=title or "Concept importance concentration per segment (Lorenz / Pareto)",
        xaxis={
            "title": "cumulative share of concepts (ranked by importance desc)",
            "range": [0.0, 1.05],
            "tickformat": ".0%",
        },
        yaxis={
            "title": "cumulative share of importance",
            "range": [0.0, 1.05],
            "tickformat": ".0%",
        },
        legend={"title": "segment"},
        margin={"t": 60, "l": 70, "r": 30, "b": 60},
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

concept_disparity_heatmap

concept_disparity_heatmap(graph: ConceptGraph, df: DataFrame, *, only_concepts: bool = True, hide_root: bool = True, include_reference: bool = True, sort_by: SortBy | None = 'max_abs', max_concepts: int | None = None, title: str | None = None, colorscale: str = 'RdBu_r', layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a concept × protected-group disparity heatmap.

Diverging palette centred at 0 — the reference column is exactly zero, other columns are signed gaps. Positive gap = the model relies on this concept more for that group than for the reference; negative = less.

PARAMETER DESCRIPTION
graph

The ConceptGraph (used for depth-aware ordering).

TYPE: ConceptGraph

df

Long-form output of :func:concept_disparity.

TYPE: DataFrame

only_concepts

If True (default), drop feature leaves from the chart.

TYPE: bool DEFAULT: True

hide_root

If True (default), drop the root concept row.

TYPE: bool DEFAULT: True

include_reference

If True (default), keep the reference group's all-zero column as a visible baseline. Pass False to drop it for a tighter chart.

TYPE: bool DEFAULT: True

sort_by

"max_abs" (default) orders concept rows by the maximum |gap| across groups, descending — surfaces the most disparate concepts. "depth" keeps graph DFS preorder. None keeps the DataFrame order.

TYPE: SortBy | None DEFAULT: 'max_abs'

max_concepts

Optionally cap the number of rows shown (top-K by chosen ordering).

TYPE: int | None DEFAULT: None

title

Figure title. Defaults to "Concept SHAP disparity vs <reference>" using the reference label from df.attrs.

TYPE: str | None DEFAULT: None

colorscale

Plotly diverging colorscale name. Default "RdBu_r" so a positive gap (the model over-relies on this concept for the protected group) renders red — same convention as concept_drift_sunburst.

TYPE: str DEFAULT: 'RdBu_r'

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/concept_disparity_heatmap.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
def concept_disparity_heatmap(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    only_concepts: bool = True,
    hide_root: bool = True,
    include_reference: bool = True,
    sort_by: SortBy | None = "max_abs",
    max_concepts: int | None = None,
    title: str | None = None,
    colorscale: str = "RdBu_r",
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a concept × protected-group disparity heatmap.

    Diverging palette centred at 0 — the reference column is exactly
    zero, other columns are signed gaps. Positive gap = the model
    relies on this concept *more* for that group than for the reference;
    negative = less.

    Parameters
    ----------
    graph:
        The ConceptGraph (used for depth-aware ordering).
    df:
        Long-form output of :func:`concept_disparity`.
    only_concepts:
        If ``True`` (default), drop feature leaves from the chart.
    hide_root:
        If ``True`` (default), drop the root concept row.
    include_reference:
        If ``True`` (default), keep the reference group's all-zero
        column as a visible baseline. Pass ``False`` to drop it for a
        tighter chart.
    sort_by:
        ``"max_abs"`` (default) orders concept rows by the maximum
        ``|gap|`` across groups, descending — surfaces the most
        disparate concepts. ``"depth"`` keeps graph DFS preorder.
        ``None`` keeps the DataFrame order.
    max_concepts:
        Optionally cap the number of rows shown (top-K by chosen
        ordering).
    title:
        Figure title. Defaults to ``"Concept SHAP disparity vs
        <reference>"`` using the reference label from ``df.attrs``.
    colorscale:
        Plotly diverging colorscale name. Default ``"RdBu_r"`` so a
        positive gap (the model over-relies on this concept for the
        protected group) renders red — same convention as
        ``concept_drift_sunburst``.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    if df.empty:
        raise ValueError("DataFrame is empty; run concept_disparity first")
    for col in ("name", "kind", "protected_group", "value"):
        if col not in df.columns:
            raise KeyError(f"required column {col!r} missing from DataFrame")

    work = df.copy()
    if only_concepts:
        work = work[work["kind"] == "concept"]
    if hide_root:
        work = work[work["name"] != graph.root]

    reference_group = df.attrs.get("reference_group")
    protected_order: list[str] = df.attrs.get("protected_order") or list(
        dict.fromkeys(work["protected_group"].astype(str))
    )
    if not include_reference and reference_group is not None:
        protected_order = [g for g in protected_order if g != reference_group]
        work = work[work["protected_group"] != reference_group]

    pivot = work.pivot_table(
        index="name", columns="protected_group", values="value", aggfunc="mean"
    )
    pivot = pivot.reindex(columns=[g for g in protected_order if g in pivot.columns])

    if sort_by == "max_abs":
        pivot = (
            pivot.assign(_max=pivot.abs().max(axis=1))
            .sort_values("_max", ascending=False)
            .drop(columns="_max")
        )
    elif sort_by == "depth":
        dfs_order = [n for n in graph.nodes_in_order() if n in set(pivot.index)]
        pivot = pivot.reindex(index=dfs_order)
    elif sort_by is not None:
        raise ValueError(f"unknown sort_by={sort_by!r}; expected 'max_abs', 'depth', or None")

    if max_concepts is not None:
        pivot = pivot.head(max_concepts)

    if pivot.empty:
        raise ValueError("no rows to plot after filtering")

    agg = df.attrs.get("agg", "mean_abs")
    z = pivot.to_numpy(dtype=float)
    # Disparity is always signed — force diverging behaviour regardless of
    # df.attrs["agg"], because a magnitude-vs-magnitude gap can still go
    # negative (group < reference) and deserves a centred palette.
    color_kwargs = heatmap_color_kwargs(z, agg="mean_signed", colorscale=colorscale)

    auto_title = (
        f"Concept SHAP disparity vs {reference_group}"
        if reference_group
        else "Concept SHAP disparity"
    )
    auto_title += f" ({agg})"

    fig = go.Figure(
        go.Heatmap(
            z=z,
            x=list(pivot.columns),
            y=list(pivot.index),
            colorbar={"title": f"{agg} gap"},
            hovertemplate="%{y} | %{x}<br>gap: %{z:+.4f}<extra></extra>",
            **color_kwargs,
        )
    )

    fig.update_layout(
        title=title or auto_title,
        xaxis={"side": "bottom", "tickangle": 0, "showgrid": False, "title": "protected group"},
        yaxis={"autorange": "reversed", "showgrid": False, "title": "concept"},
        margin={"t": 60, "l": 160, "r": 30, "b": 80},
        height=max(300, 28 * len(pivot) + 160),
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

concept_drift_lines

concept_drift_lines(graph: ConceptGraph, df: DataFrame, *, only_concepts: bool = True, hide_root: bool = True, max_concepts: int | None = None, branch_palette: Sequence[str] | None = None, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render one line per concept across periods.

PARAMETER DESCRIPTION
graph

The ConceptGraph (used for branch-hierarchical colouring).

TYPE: ConceptGraph

df

Long-form DataFrame from :func:attribution_drift.

TYPE: DataFrame

only_concepts

If True (default), drop feature leaves.

TYPE: bool DEFAULT: True

hide_root

If True (default), drop the root concept row.

TYPE: bool DEFAULT: True

max_concepts

If set, keep only the K concepts with the highest max-across-periods value to avoid spaghetti charts. Default None shows every concept.

TYPE: int | None DEFAULT: None

branch_palette

Custom palette for branch base hues. Defaults to the Plotly qualitative palette.

TYPE: Sequence[str] | None DEFAULT: None

title

Figure title.

TYPE: str | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/concept_drift_lines.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def concept_drift_lines(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    only_concepts: bool = True,
    hide_root: bool = True,
    max_concepts: int | None = None,
    branch_palette: Sequence[str] | None = None,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render one line per concept across periods.

    Parameters
    ----------
    graph:
        The ConceptGraph (used for branch-hierarchical colouring).
    df:
        Long-form DataFrame from :func:`attribution_drift`.
    only_concepts:
        If ``True`` (default), drop feature leaves.
    hide_root:
        If ``True`` (default), drop the root concept row.
    max_concepts:
        If set, keep only the K concepts with the highest
        max-across-periods value to avoid spaghetti charts. Default
        ``None`` shows every concept.
    branch_palette:
        Custom palette for branch base hues. Defaults to the Plotly
        qualitative palette.
    title:
        Figure title.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    if df.empty:
        raise ValueError("DataFrame is empty; run attribution_drift first")
    for col in ("name", "kind", "period", "value"):
        if col not in df.columns:
            raise KeyError(f"required column {col!r} missing from DataFrame")

    work = df.copy()
    if only_concepts:
        work = work[work["kind"] == "concept"]
    if hide_root:
        work = work[work["name"] != graph.root]

    period_order: list[str] = df.attrs.get("period_order") or list(
        dict.fromkeys(work["period"].astype(str))
    )

    pivot = work.pivot_table(index="name", columns="period", values="value", aggfunc="mean")
    pivot = pivot.reindex(columns=[p for p in period_order if p in pivot.columns])

    # Always sort by max-across-periods desc so the legend lists the most
    # prominent concepts first; then cap to max_concepts if requested.
    max_per_concept = pivot.max(axis=1, skipna=True)
    pivot = pivot.loc[max_per_concept.sort_values(ascending=False).index]
    # Remember the pre-cap row count so the title suffix can say
    # "top K of N" honestly. After head(K), len(pivot) == K, which would
    # always trip the `max_concepts < len(pivot)` check to False.
    n_total = len(pivot)
    if max_concepts is not None and max_concepts > 0:
        pivot = pivot.head(max_concepts)

    if pivot.empty:
        raise ValueError("no concept rows to plot after filtering")

    concept_ids = ["/".join(graph.path(name)) for name in pivot.index]
    colors = branch_colors(graph, concept_ids, palette=branch_palette)

    agg = df.attrs.get("agg", "mean_abs")

    fig = go.Figure()
    for (name, row), color in zip(pivot.iterrows(), colors, strict=True):
        fig.add_trace(
            go.Scatter(
                x=list(pivot.columns),
                y=row.to_numpy(dtype=float),
                mode="lines+markers",
                line={"color": color, "width": 2},
                marker={"size": 7, "color": color},
                name=str(name),
                hovertemplate=(f"{name}<br>period: %{{x}}<br>{agg}: %{{y:.4f}}<extra></extra>"),
            )
        )

    suffix = (
        f" — top {max_concepts} of {n_total} concepts"
        if max_concepts is not None and max_concepts < n_total
        else ""
    )
    fig.update_layout(
        title=title or f"Concept SHAP drift across periods ({agg}){suffix}",
        xaxis={
            "title": "period",
            "type": "category",
            "categoryorder": "array",
            "categoryarray": list(pivot.columns),
        },
        yaxis={"title": agg},
        legend={"title": "concept"},
        margin={"t": 60, "l": 70, "r": 30, "b": 60},
        height=max(360, 30 * len(pivot) + 200),
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

concept_drift_sunburst

concept_drift_sunburst(graph: ConceptGraph, df: DataFrame, *, value: str = 'feature_count', delta_col: str = 'delta', colorscale: str = 'RdBu_r', hide_root: bool = True, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a sunburst coloured by per-concept SHAP drift.

Sector area uses value (default feature_count — additive, safe for branchvalues="total"). Sector colour uses delta_col with a diverging palette centred at 0. With the default RdBu_r colorscale, positive deltas (importance grew between baseline and target) render red and negative deltas (importance shrank) render blue.

PARAMETER DESCRIPTION
graph

The ConceptGraph.

TYPE: ConceptGraph

df

DataFrame from :func:concept_drift_delta — must contain feature_count (or whatever value is set to) plus baseline, target, delta.

TYPE: DataFrame

value

Column used for sector area. Default feature_count keeps the plot honest under branchvalues="total".

TYPE: str DEFAULT: 'feature_count'

delta_col

Column used for sector colour. Default delta.

TYPE: str DEFAULT: 'delta'

colorscale

Plotly diverging colorscale name. Default RdBu_r (positive = red, negative = blue).

TYPE: str DEFAULT: 'RdBu_r'

hide_root

Drop the root concept by default (consistent with the other sunburst plots).

TYPE: bool DEFAULT: True

title

Figure title.

TYPE: str | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

Source code in src/concept_graph_xai/plotting/concept_drift_sunburst.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def concept_drift_sunburst(
    graph: ConceptGraph,
    df: pd.DataFrame,
    *,
    value: str = "feature_count",
    delta_col: str = "delta",
    colorscale: str = "RdBu_r",
    hide_root: bool = True,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a sunburst coloured by per-concept SHAP drift.

    Sector area uses ``value`` (default ``feature_count`` — additive, safe
    for ``branchvalues="total"``). Sector colour uses ``delta_col`` with a
    diverging palette centred at 0. With the default ``RdBu_r`` colorscale,
    *positive* deltas (importance grew between baseline and target) render
    *red* and negative deltas (importance shrank) render *blue*.

    Parameters
    ----------
    graph:
        The ConceptGraph.
    df:
        DataFrame from :func:`concept_drift_delta` — must contain
        ``feature_count`` (or whatever ``value`` is set to) plus
        ``baseline``, ``target``, ``delta``.
    value:
        Column used for sector area. Default ``feature_count`` keeps the
        plot honest under ``branchvalues="total"``.
    delta_col:
        Column used for sector colour. Default ``delta``.
    colorscale:
        Plotly diverging colorscale name. Default ``RdBu_r`` (positive =
        red, negative = blue).
    hide_root:
        Drop the root concept by default (consistent with the other
        sunburst plots).
    title:
        Figure title.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.
    """

    for required in (value, delta_col):
        if required not in df.columns:
            raise KeyError(f"required column {required!r} missing from DataFrame")

    arrays, ordered, sizes = sunburst_layout(graph, df, value=value, hide_root=hide_root)

    delta_vals = ordered[delta_col].to_numpy(dtype=float)
    if np.all(np.isnan(delta_vals)):
        raise ValueError(
            f"all values in {delta_col!r} are NaN; nothing to colour. "
            "Pass at least one period with a defined value."
        )
    delta_for_color = np.where(np.isnan(delta_vals), 0.0, delta_vals)
    cmax = float(np.nanmax(np.abs(delta_vals))) or 1e-9

    hover_cols = [c for c in ("baseline", "target", delta_col, value) if c in ordered.columns]
    hover = hover_text(
        ordered,
        hover_cols,
        fmt={"baseline": ".4f", "target": ".4f", delta_col: "+.4f"},
    )

    baseline_label = df.attrs.get("baseline_period")
    target_label = df.attrs.get("target_period")
    auto_title = "Concept SHAP drift"
    if baseline_label and target_label:
        auto_title += f" — {baseline_label} -> {target_label}"

    return build_sunburst_figure(
        arrays,
        sizes,
        marker={
            "colors": delta_for_color,
            "colorscale": colorscale,
            "cmid": 0.0,
            "cmin": -cmax,
            "cmax": cmax,
            "showscale": True,
            "colorbar": {"title": delta_col},
        },
        hover=hover,
        title=title or auto_title,
        layout_kwargs=layout_kwargs,
    )

Prediction explainer

Single-prediction explanations rolled up to concept level, headlined by the concept waterfall.

ConceptPredictionExplainer

ConceptPredictionExplainer(graph: ConceptGraph, model: Any, X: DataFrame, shap_values: ndarray, base_value: float, *, feature_names: Sequence[str] | None = None)

Explain a single prediction using the concept tree.

PARAMETER DESCRIPTION
graph

The ConceptGraph.

TYPE: ConceptGraph

model

A fitted model. Used only to display the prediction alongside the waterfall; the SHAP values are the source of truth.

TYPE: Any

X

The feature matrix the SHAP values were computed on. Must be a DataFrame whose columns include every feature in the graph.

TYPE: DataFrame

shap_values

Per-sample SHAP values of shape (N, F) aligned with X's columns.

TYPE: ndarray

base_value

The SHAP base value (typically shap.TreeExplainer(model).expected_value). Required so the waterfall can show the absolute level.

TYPE: float

feature_names

Optional override for X.columns. Useful when the SHAP values were produced from a numpy array.

TYPE: Sequence[str] | None DEFAULT: None

Source code in src/concept_graph_xai/prediction_explainer.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def __init__(
    self,
    graph: ConceptGraph,
    model: Any,
    X: pd.DataFrame,
    shap_values: np.ndarray,
    base_value: float,
    *,
    feature_names: Sequence[str] | None = None,
) -> None:
    if not isinstance(X, pd.DataFrame):
        raise TypeError("X must be a pandas DataFrame")
    arr = np.asarray(shap_values, dtype=float)
    if arr.ndim != 2:
        raise ValueError(f"shap_values must be 2D (N, F); got {arr.shape}")
    if arr.shape[0] != len(X):
        raise ValueError(f"shap_values has {arr.shape[0]} rows but X has {len(X)}")

    names = list(feature_names) if feature_names is not None else list(X.columns)
    if arr.shape[1] != len(names):
        raise ValueError(
            f"shap_values has {arr.shape[1]} columns but feature_names has {len(names)}"
        )

    self.graph = graph
    self.model = model
    self.X = X
    self.shap_values = arr
    self.feature_names = names
    self.base_value = float(base_value)
    self._name_to_idx = {n: i for i, n in enumerate(names)}

breakdown

breakdown(row: int | str, *, depth: int = 1) -> DataFrame

Per-concept SHAP sum for a single prediction at the given depth.

PARAMETER DESCRIPTION
row

Either a positional index (int) or a label in X.index.

TYPE: int | str

depth

Depth of the concepts to roll up to. depth=1 aggregates each top-level concept (children of the root); higher depths reveal more granular concepts.

TYPE: int DEFAULT: 1

RETURNS DESCRIPTION
DataFrame

Sorted by shap_sum descending. Columns: name, path, depth, feature_count, shap_sum.

Source code in src/concept_graph_xai/prediction_explainer.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def breakdown(self, row: int | str, *, depth: int = 1) -> pd.DataFrame:
    """Per-concept SHAP sum for a single prediction at the given depth.

    Parameters
    ----------
    row:
        Either a positional index (``int``) or a label in ``X.index``.
    depth:
        Depth of the concepts to roll up to. ``depth=1`` aggregates each
        top-level concept (children of the root); higher depths reveal
        more granular concepts.

    Returns
    -------
    pandas.DataFrame
        Sorted by ``shap_sum`` descending. Columns: ``name``, ``path``,
        ``depth``, ``feature_count``, ``shap_sum``.
    """

    idx = self._resolve_row(row)
    shap_row = self.shap_values[idx]
    concepts = self._concepts_at_depth(depth)
    if not concepts:
        raise ValueError(f"no concepts at depth {depth}")

    rows: list[ConceptContribution] = []
    for node in concepts:
        feats = [f for f in self.graph.descendant_features(node) if f in self._name_to_idx]
        if not feats:
            continue
        idxs = [self._name_to_idx[f] for f in feats]
        shap_sum = float(shap_row[idxs].sum())
        rows.append(
            ConceptContribution(
                name=node,
                path="/".join(self.graph.path(node)),
                depth=depth,
                feature_count=len(feats),
                shap_sum=shap_sum,
            )
        )

    df = pd.DataFrame([r.__dict__ for r in rows])
    if df.empty:
        return df
    return df.sort_values("shap_sum", ascending=False).reset_index(drop=True)

waterfall

waterfall(row: int | str, *, depth: int = 1, title: str | None = None, layout_kwargs: dict[str, Any] | None = None) -> Figure

Render a per-prediction concept waterfall.

The chart starts at base_value, applies each concept's SHAP sum in descending magnitude, and ends at the predicted logit (base_value + sum(SHAP)). Bars are coloured red for negative contributions and green for positive.

PARAMETER DESCRIPTION
row

Row index or label.

TYPE: int | str

depth

Tree depth to roll up to. See :meth:breakdown.

TYPE: int DEFAULT: 1

title

Figure title.

TYPE: str | None DEFAULT: None

layout_kwargs

Passed verbatim to fig.update_layout.

TYPE: dict[str, Any] | None DEFAULT: None

RETURNS DESCRIPTION
Figure
Source code in src/concept_graph_xai/prediction_explainer.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def waterfall(
    self,
    row: int | str,
    *,
    depth: int = 1,
    title: str | None = None,
    layout_kwargs: dict[str, Any] | None = None,
) -> go.Figure:
    """Render a per-prediction concept waterfall.

    The chart starts at ``base_value``, applies each concept's SHAP sum in
    descending magnitude, and ends at the predicted logit
    (``base_value + sum(SHAP)``). Bars are coloured red for negative
    contributions and green for positive.

    Parameters
    ----------
    row:
        Row index or label.
    depth:
        Tree depth to roll up to. See :meth:`breakdown`.
    title:
        Figure title.
    layout_kwargs:
        Passed verbatim to ``fig.update_layout``.

    Returns
    -------
    plotly.graph_objects.Figure
    """

    idx = self._resolve_row(row)
    # breakdown() raises ValueError("no concepts at depth ...") on the
    # only failure mode that produces an empty frame, so no defensive
    # empty-check is needed here.
    df = self.breakdown(row, depth=depth)

    labels = ["base", *df["name"].tolist(), "prediction"]
    values = [self.base_value, *df["shap_sum"].tolist(), 0.0]
    measures = ["absolute", *(["relative"] * len(df)), "total"]

    prediction_logit = float(self.base_value + df["shap_sum"].sum())
    proba = float("nan")
    if self.model is not None and hasattr(self.model, "predict_proba"):
        proba_arr = np.asarray(self.model.predict_proba(self.X.iloc[[idx]]))
        if proba_arr.ndim == 2 and proba_arr.shape[1] >= 2:
            proba = float(proba_arr[0, 1])

    fig = go.Figure(
        go.Waterfall(
            orientation="h",
            y=labels,
            x=values,
            measure=measures,
            connector={"line": {"color": "rgba(80,80,80,0.4)"}},
            decreasing={"marker": {"color": "#d62728"}},
            increasing={"marker": {"color": "#2ca02c"}},
            totals={"marker": {"color": "#1f77b4"}},
            textposition="outside",
            texttemplate="%{x:+.3f}",
        )
    )

    subtitle = (
        f"row={row}  ·  base={self.base_value:+.3f}  ·  predicted logit={prediction_logit:+.3f}"
    )
    if not np.isnan(proba):
        subtitle += f"  ·  P(y=1)={proba:.3f}"

    fig.update_layout(
        title=title or f"Concept waterfall — {subtitle}",
        xaxis_title="SHAP contribution (logit space)",
        yaxis={"autorange": "reversed"},
        margin={"t": 60, "l": 160, "r": 60, "b": 60},
        height=max(300, 35 * (len(df) + 2) + 100),
    )
    if layout_kwargs:
        fig.update_layout(**layout_kwargs)
    return fig

ConceptContribution dataclass

ConceptContribution(name: str, path: str, depth: int, feature_count: int, shap_sum: float)

One row of the per-concept breakdown for a single prediction.