graph-explain 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. graph_explain/__init__.py +79 -0
  2. graph_explain/backends/__init__.py +4 -0
  3. graph_explain/backends/base.py +103 -0
  4. graph_explain/backends/dgl.py +121 -0
  5. graph_explain/benchmarks/__init__.py +3 -0
  6. graph_explain/benchmarks/synthetic.py +246 -0
  7. graph_explain/cli.py +459 -0
  8. graph_explain/core/__init__.py +14 -0
  9. graph_explain/core/benchmark.py +284 -0
  10. graph_explain/core/evaluation.py +391 -0
  11. graph_explain/core/explainer.py +83 -0
  12. graph_explain/core/explanation.py +72 -0
  13. graph_explain/core/model_utils.py +44 -0
  14. graph_explain/core/registry.py +55 -0
  15. graph_explain/methods/__init__.py +39 -0
  16. graph_explain/methods/attention/attention.py +147 -0
  17. graph_explain/methods/base.py +25 -0
  18. graph_explain/methods/baseline/random_baseline.py +78 -0
  19. graph_explain/methods/counterfactual/counterfactual.py +304 -0
  20. graph_explain/methods/feature/graph_lime.py +141 -0
  21. graph_explain/methods/gradient/__init__.py +0 -0
  22. graph_explain/methods/gradient/grad_x_input.py +110 -0
  23. graph_explain/methods/gradient/guided_backprop.py +117 -0
  24. graph_explain/methods/gradient/integrated_gradients.py +115 -0
  25. graph_explain/methods/gradient/saliency.py +93 -0
  26. graph_explain/methods/perturbation/__init__.py +0 -0
  27. graph_explain/methods/perturbation/gnn_explainer.py +265 -0
  28. graph_explain/methods/perturbation/node_mask.py +136 -0
  29. graph_explain/methods/perturbation/pg_explainer.py +162 -0
  30. graph_explain/methods/perturbation/subgraphx.py +393 -0
  31. graph_explain/methods/relevance/deeplift.py +262 -0
  32. graph_explain/methods/relevance/gnn_lrp.py +219 -0
  33. graph_explain/narration/__init__.py +3 -0
  34. graph_explain/narration/narrator.py +185 -0
  35. graph_explain/visualization/__init__.py +4 -0
  36. graph_explain/visualization/interactive.py +73 -0
  37. graph_explain/visualization/static.py +90 -0
  38. graph_explain-0.7.0.dist-info/METADATA +332 -0
  39. graph_explain-0.7.0.dist-info/RECORD +42 -0
  40. graph_explain-0.7.0.dist-info/WHEEL +5 -0
  41. graph_explain-0.7.0.dist-info/entry_points.txt +2 -0
  42. graph_explain-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,79 @@
1
+ from .backends import DGLAdapter, PyGAdapter, get_backend
2
+ from .core import (
3
+ Explainer,
4
+ Explanation,
5
+ compare,
6
+ get_algorithm,
7
+ instantiate,
8
+ register,
9
+ report_html,
10
+ )
11
+ from .core.evaluation import (
12
+ evaluate_fidelity_minus,
13
+ evaluate_fidelity_plus,
14
+ evaluate_gea,
15
+ evaluate_gea_graph,
16
+ evaluate_sparsity,
17
+ evaluate_stability,
18
+ )
19
+ from .methods import (
20
+ AttentionExplainer,
21
+ Counterfactual,
22
+ DeepLift,
23
+ GNNExplainer,
24
+ GNNGatedLRP,
25
+ GradXInput,
26
+ GraphLIME,
27
+ GuidedBackprop,
28
+ IntegratedGradients,
29
+ NodeMask,
30
+ PGExplainer,
31
+ RandomBaseline,
32
+ Saliency,
33
+ SubgraphX,
34
+ )
35
+ from .narration import Narrator, describe, narrate, summarize
36
+ from .visualization import show, visualize_interactive, visualize_static
37
+
38
+ __version__ = "0.7.0"
39
+
40
+ __all__ = [
41
+ "AttentionExplainer",
42
+ "Counterfactual",
43
+ "DGLAdapter",
44
+ "DeepLift",
45
+ "Explainer",
46
+ "Explanation",
47
+ "GNNExplainer",
48
+ "GNNGatedLRP",
49
+ "GradXInput",
50
+ "GraphLIME",
51
+ "GuidedBackprop",
52
+ "IntegratedGradients",
53
+ "Narrator",
54
+ "NodeMask",
55
+ "PGExplainer",
56
+ "PyGAdapter",
57
+ "RandomBaseline",
58
+ "Saliency",
59
+ "SubgraphX",
60
+ "__version__",
61
+ "compare",
62
+ "describe",
63
+ "evaluate_fidelity_minus",
64
+ "evaluate_fidelity_plus",
65
+ "evaluate_gea",
66
+ "evaluate_gea_graph",
67
+ "evaluate_sparsity",
68
+ "evaluate_stability",
69
+ "get_algorithm",
70
+ "get_backend",
71
+ "instantiate",
72
+ "narrate",
73
+ "register",
74
+ "report_html",
75
+ "show",
76
+ "summarize",
77
+ "visualize_interactive",
78
+ "visualize_static",
79
+ ]
@@ -0,0 +1,4 @@
1
+ from .base import Backend, PyGAdapter, get_backend
2
+ from .dgl import DGLAdapter
3
+
4
+ __all__ = ["Backend", "DGLAdapter", "PyGAdapter", "get_backend"]
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from inspect import signature
5
+ from typing import Any
6
+
7
+ import torch
8
+
9
+
10
+ class Backend(ABC):
11
+ name: str = "base"
12
+
13
+ @abstractmethod
14
+ def num_nodes(self, data: Any) -> int: ...
15
+
16
+ @abstractmethod
17
+ def node_features(self, data: Any) -> torch.Tensor: ...
18
+
19
+ @abstractmethod
20
+ def edge_index(self, data: Any) -> torch.Tensor: ...
21
+
22
+ @abstractmethod
23
+ def edge_weight(self, data: Any) -> torch.Tensor | None: ...
24
+
25
+ @abstractmethod
26
+ def node_labels(self, data: Any) -> torch.Tensor | None: ...
27
+
28
+ @abstractmethod
29
+ def to_networkx(self, data: Any): ...
30
+
31
+ def supports_edge_weight(self, model: Any) -> bool:
32
+ try:
33
+ params = signature(model.forward).parameters
34
+ except (TypeError, ValueError):
35
+ return False
36
+ return "edge_weight" in params
37
+
38
+ def forward(
39
+ self,
40
+ model: Any,
41
+ x: torch.Tensor,
42
+ edge_index: torch.Tensor,
43
+ edge_weight: torch.Tensor | None = None,
44
+ node_mask: torch.Tensor | None = None,
45
+ **model_kwargs: Any,
46
+ ) -> torch.Tensor:
47
+ x_masked = x
48
+ if node_mask is not None:
49
+ expand = (-1,) * x_masked.dim()
50
+ node_mask = node_mask.to(x_masked.dtype)
51
+ x_masked = x_masked * node_mask.view((node_mask.shape[0], *expand[1:]))
52
+ if edge_weight is not None and not self.supports_edge_weight(model):
53
+ raise ValueError(
54
+ "El modelo no acepta edge_weight. Los métodos por perturbación de "
55
+ "aristas requieren modelos GNN con soporte para edge_weight "
56
+ "(p.ej. GCNConv, GATConv)."
57
+ )
58
+ kwargs: dict = {}
59
+ if edge_weight is not None:
60
+ kwargs["edge_weight"] = edge_weight
61
+ kwargs.update(model_kwargs)
62
+ return model(x_masked, edge_index, **kwargs)
63
+
64
+
65
+ class PyGAdapter(Backend):
66
+ name = "pyg"
67
+
68
+ def num_nodes(self, data: Any) -> int:
69
+ return int(data.num_nodes)
70
+
71
+ def node_features(self, data: Any) -> torch.Tensor:
72
+ return data.x
73
+
74
+ def edge_index(self, data: Any) -> torch.Tensor:
75
+ return data.edge_index
76
+
77
+ def edge_weight(self, data: Any) -> torch.Tensor | None:
78
+ return getattr(data, "edge_weight", None)
79
+
80
+ def node_labels(self, data: Any) -> torch.Tensor | None:
81
+ return getattr(data, "y", None)
82
+
83
+ def to_networkx(self, data: Any):
84
+ from torch_geometric.utils import to_networkx
85
+
86
+ return to_networkx(data, to_undirected=True)
87
+
88
+
89
+ def get_backend(name: str) -> Backend:
90
+ if name == "pyg":
91
+ return PyGAdapter()
92
+ if name == "dgl":
93
+ from ..backends.dgl import DGLAdapter
94
+
95
+ return DGLAdapter()
96
+ raise ValueError(f"Backend desconocido: {name}. Disponibles: ['pyg', 'dgl']")
97
+
98
+
99
+ def default_mask_type(model: Any, data: Any) -> tuple[str | None, str | None]:
100
+ edge_mask_type = "object" if getattr(data, "edge_index", None) is not None else None
101
+ x = getattr(data, "x", None)
102
+ node_mask_type = "attributes" if x is not None else None
103
+ return node_mask_type, edge_mask_type
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import torch
6
+
7
+ from .base import Backend
8
+
9
+ _FEATURE_KEYS = ("feat", "x", "features")
10
+ _LABEL_KEYS = ("label", "y", "labels")
11
+ _WEIGHT_KEYS = ("w", "weight", "edge_weight")
12
+
13
+
14
+ def _first_available(mapping: dict, keys: tuple[str, ...], default):
15
+ for k in keys:
16
+ if k in mapping:
17
+ return mapping[k]
18
+ return default
19
+
20
+
21
+ class DGLAdapter(Backend):
22
+ """Adapter for `dgl.DGLGraph` graphs and DGL models.
23
+
24
+ Data convention: node features live in `ndata['feat']`, labels in
25
+ `ndata['label']` and edge weights in `edata['w']` ('x'/'weight' are also
26
+ accepted). The DGL model must read `g.ndata['feat']` and `g.edata['w']` in
27
+ its `forward(graph, feat)`.
28
+ """
29
+
30
+ name = "dgl"
31
+
32
+ def __init__(
33
+ self,
34
+ feat_key: str | None = None,
35
+ label_key: str | None = None,
36
+ edge_weight_key: str | None = None,
37
+ ):
38
+ self.feat_key = feat_key
39
+ self.label_key = label_key
40
+ self.edge_weight_key = edge_weight_key
41
+
42
+ @staticmethod
43
+ def _require_dgl():
44
+ try:
45
+ import dgl
46
+ except ImportError as exc:
47
+ raise ImportError(
48
+ "El backend 'dgl' requiere la librería DGL (pip install dgl). "
49
+ "Además, DGL necesita una versión de PyTorch con librerías "
50
+ "precompiladas de graphbolt (ver docs de instalación de DGL)."
51
+ ) from exc
52
+ return dgl
53
+
54
+ def num_nodes(self, data: Any) -> int:
55
+ if callable(getattr(data, "num_nodes", None)):
56
+ return int(data.num_nodes())
57
+ return int(data.num_nodes)
58
+
59
+ def node_features(self, data: Any) -> torch.Tensor:
60
+ ndata: dict = getattr(data, "ndata", {})
61
+ feat = ndata.get(self.feat_key) if self.feat_key else ndata.get("feat")
62
+ if feat is None:
63
+ feat = _first_available(ndata, _FEATURE_KEYS, None)
64
+ if feat is None:
65
+ raise ValueError(
66
+ "El grafo DGL no tiene features de nodo (ndata['feat']/['x'])."
67
+ )
68
+ return feat
69
+
70
+ def edge_index(self, data: Any) -> torch.Tensor:
71
+ u, v = data.edges()
72
+ return torch.stack([u, v], dim=0)
73
+
74
+ def edge_weight(self, data: Any) -> torch.Tensor | None:
75
+ edata: dict = getattr(data, "edata", {})
76
+ w = edata.get(self.edge_weight_key) if self.edge_weight_key else edata.get("w")
77
+ if w is None:
78
+ w = _first_available(edata, _WEIGHT_KEYS, None)
79
+ return w
80
+
81
+ def node_labels(self, data: Any) -> torch.Tensor | None:
82
+ ndata: dict = getattr(data, "ndata", {})
83
+ lab = ndata.get(self.label_key) if self.label_key else ndata.get("label")
84
+ if lab is None:
85
+ lab = _first_available(ndata, _LABEL_KEYS, None)
86
+ return lab
87
+
88
+ def to_networkx(self, data: Any):
89
+ return data.to_networkx()
90
+
91
+ def supports_edge_weight(self, model: Any) -> bool:
92
+ return True
93
+
94
+ def forward(
95
+ self,
96
+ model: Any,
97
+ x: torch.Tensor,
98
+ edge_index: torch.Tensor,
99
+ edge_weight: torch.Tensor | None = None,
100
+ node_mask: torch.Tensor | None = None,
101
+ **model_kwargs: Any,
102
+ ) -> torch.Tensor:
103
+ dgl = self._require_dgl()
104
+ x_masked = x
105
+ if node_mask is not None:
106
+ expand = (-1,) * x_masked.dim()
107
+ node_mask = node_mask.to(x_masked.dtype)
108
+ x_masked = x_masked * node_mask.view((node_mask.shape[0], *expand[1:]))
109
+ num_nodes = max(int(x_masked.size(0)), int(edge_index.max().item()) + 1)
110
+ g = dgl.graph(
111
+ (edge_index[0], edge_index[1]),
112
+ num_nodes=num_nodes,
113
+ )
114
+ g.ndata["feat"] = x_masked
115
+ g.ndata["x"] = x_masked
116
+ if edge_weight is not None:
117
+ g.edata["w"] = edge_weight
118
+ g.edata["weight"] = edge_weight
119
+ kwargs: dict = {}
120
+ kwargs.update(model_kwargs)
121
+ return model(g, x_masked, **kwargs)
@@ -0,0 +1,3 @@
1
+ from .synthetic import ba_shapes, build_data
2
+
3
+ __all__ = ["ba_shapes", "build_data"]
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import networkx as nx
4
+ import numpy as np
5
+ import torch
6
+
7
+
8
+ def _house_motif(offset: int, anchor_in_motif: int = 3):
9
+ edges_motif = [
10
+ (0, 1),
11
+ (0, 2),
12
+ (1, 2),
13
+ (1, 3),
14
+ (2, 4),
15
+ (3, 4),
16
+ ]
17
+ edges = [(u + offset, v + offset) for u, v in edges_motif]
18
+ labels = {}
19
+ for i in range(5):
20
+ labels[i + offset] = 0
21
+ labels[offset + anchor_in_motif] = 1
22
+ labels[offset + 0] = 2
23
+ for i in range(5):
24
+ if i != anchor_in_motif and i != 0:
25
+ labels[i + offset] = 3
26
+ return edges, labels
27
+
28
+
29
+ def ba_shapes(
30
+ base_nodes: int = 300,
31
+ num_houses: int = 80,
32
+ m: int = 5,
33
+ seed: int = 0,
34
+ num_features: int = 10,
35
+ feature_style: str = "degree",
36
+ ) -> tuple[
37
+ torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor
38
+ ]:
39
+ rng = np.random.default_rng(seed)
40
+ g = nx.barabasi_albert_graph(base_nodes, m, seed=seed)
41
+ g = g.to_undirected()
42
+
43
+ node_count = base_nodes
44
+ labels: dict[int, int] = {}
45
+
46
+ anchors = rng.choice(base_nodes, size=num_houses, replace=False)
47
+ for i, anchor in enumerate(anchors):
48
+ edges_m, lab = _house_motif(node_count)
49
+ g.add_nodes_from(range(node_count, node_count + 5))
50
+ g.add_edges_from(edges_m)
51
+ g.add_edge(anchor, node_count + 3)
52
+ labels.update(lab)
53
+ node_count += 5
54
+
55
+ degrees = np.array([d for _, d in g.degree()], dtype=np.float64)
56
+ max_deg = int(degrees.max()) + 1
57
+ feat_dim = max(max_deg, num_features)
58
+ if feature_style == "random":
59
+ x = rng.normal(0.0, 1.0, size=(node_count, num_features)).astype(np.float32)
60
+ else:
61
+ x = np.zeros((node_count, feat_dim), dtype=np.float32)
62
+ x[np.arange(node_count), np.minimum(degrees.astype(np.int64), feat_dim - 1)] = (
63
+ 1.0
64
+ )
65
+
66
+ edge_index = torch.tensor(np.array(g.edges(), dtype=np.int64).T, dtype=torch.long)
67
+ edge_index = torch.cat([edge_index, edge_index.flip(0)], dim=1)
68
+ y = torch.zeros(node_count, dtype=torch.long)
69
+ for n, l in labels.items():
70
+ y[n] = l
71
+ x = torch.from_numpy(x)
72
+
73
+ house_anchors = torch.from_numpy(anchors)
74
+
75
+ perm = rng.permutation(node_count)
76
+ train_mask = torch.zeros(node_count, dtype=torch.bool)
77
+ test_mask = torch.zeros(node_count, dtype=torch.bool)
78
+ split = int(0.3 * node_count)
79
+ train_mask[perm[:split]] = True
80
+ test_mask[perm[split:]] = True
81
+ train_mask[list(anchors)] = True
82
+ test_mask[list(anchors)] = True
83
+
84
+ return x, edge_index, y, train_mask, test_mask, house_anchors
85
+
86
+
87
+ def build_data(
88
+ base_nodes: int = 300,
89
+ num_houses: int = 80,
90
+ m: int = 5,
91
+ seed: int = 0,
92
+ num_features: int = 10,
93
+ feature_style: str = "degree",
94
+ ):
95
+ from torch_geometric.data import Data
96
+
97
+ x, edge_index, y, train_mask, test_mask, house_anchors = ba_shapes(
98
+ base_nodes=base_nodes,
99
+ num_houses=num_houses,
100
+ m=m,
101
+ seed=seed,
102
+ num_features=num_features,
103
+ feature_style=feature_style,
104
+ )
105
+ return Data(
106
+ x=x,
107
+ edge_index=edge_index,
108
+ y=y,
109
+ train_mask=train_mask,
110
+ test_mask=test_mask,
111
+ house_anchors=house_anchors,
112
+ base_nodes=base_nodes,
113
+ num_houses=num_houses,
114
+ )
115
+
116
+
117
+ def build_graph_classification(
118
+ num_pos: int = 20,
119
+ num_neg: int = 20,
120
+ base_nodes_range: tuple[int, int] = (15, 30),
121
+ m: int = 2,
122
+ seed: int = 0,
123
+ num_features: int = 8,
124
+ feature_style: str = "random",
125
+ ):
126
+ """Graph classification dataset with a known 'house' motif.
127
+
128
+ Returns a list of graph-level `Data` with a binary label `y` (1 if the graph
129
+ contains the house motif). Each graph carries `gt_edge_mask` (bool over the
130
+ directed edges, both directions included) and `gt_nodes` with the motif
131
+ nodes (+ hub).
132
+ """
133
+ from torch_geometric.data import Data
134
+
135
+ rng = np.random.default_rng(seed)
136
+ graphs: list[Data] = []
137
+ for cls in (1, 0):
138
+ count = num_pos if cls == 1 else num_neg
139
+ for _ in range(count):
140
+ base = int(rng.integers(base_nodes_range[0], base_nodes_range[1] + 1))
141
+ tree_seed = int(rng.integers(0, 2**31 - 1))
142
+ g = nx.barabasi_albert_graph(base, m, seed=tree_seed)
143
+ g = g.to_undirected()
144
+
145
+ gt_nodes: list[int] = []
146
+ gt_edges: list[tuple[int, int]] = []
147
+ if cls == 1:
148
+ offset = g.number_of_nodes()
149
+ anchor = int(rng.integers(0, base))
150
+ edges_m, _ = _house_motif(offset)
151
+ g.add_nodes_from(range(offset, offset + 5))
152
+ g.add_edges_from(edges_m)
153
+ g.add_edge(anchor, offset + 3)
154
+ gt_nodes = list(range(offset, offset + 5)) + [anchor]
155
+ gt_edges = list(edges_m) + [(anchor, offset + 3)]
156
+
157
+ if feature_style == "degree":
158
+ degrees = np.array([d for _, d in g.degree()], dtype=np.float64)
159
+ feat_dim = max(int(degrees.max()) + 1, num_features)
160
+ x = np.zeros((g.number_of_nodes(), feat_dim), dtype=np.float32)
161
+ x[
162
+ np.arange(g.number_of_nodes()),
163
+ np.minimum(degrees.astype(np.int64), feat_dim - 1),
164
+ ] = 1.0
165
+ else:
166
+ x = rng.normal(
167
+ 0.0,
168
+ 1.0,
169
+ size=(g.number_of_nodes(), num_features),
170
+ ).astype(np.float32)
171
+
172
+ edge_index = torch.tensor(
173
+ np.array(g.edges(), dtype=np.int64).T, dtype=torch.long
174
+ )
175
+ edge_index = torch.cat([edge_index, edge_index.flip(0)], dim=1)
176
+
177
+ gt_pairs = set(gt_edges) | {(v, u) for u, v in gt_edges}
178
+ gt_edge_mask = torch.zeros(edge_index.size(1), dtype=torch.bool)
179
+ if cls == 1:
180
+ for i, (s, d) in enumerate(
181
+ zip(edge_index[0].tolist(), edge_index[1].tolist())
182
+ ):
183
+ if (s, d) in gt_pairs:
184
+ gt_edge_mask[i] = True
185
+
186
+ graphs.append(
187
+ Data(
188
+ x=torch.from_numpy(x),
189
+ edge_index=edge_index,
190
+ y=torch.tensor([cls], dtype=torch.long),
191
+ num_nodes=g.number_of_nodes(),
192
+ gt_edge_mask=gt_edge_mask,
193
+ gt_nodes=sorted(gt_nodes),
194
+ )
195
+ )
196
+ return graphs
197
+
198
+
199
+ def ground_truth_edges_graph(data) -> list[int]:
200
+ """(Directed) indices of the motif edges for a graph of the dataset."""
201
+ mask = getattr(data, "gt_edge_mask", None)
202
+ if mask is None:
203
+ return []
204
+ return mask.nonzero(as_tuple=False).flatten().tolist()
205
+
206
+
207
+ HOUSE_SIZE = 5
208
+
209
+
210
+ def ground_truth_nodes(data, node: int) -> list[int]:
211
+ base_nodes = int(getattr(data, "base_nodes", 0))
212
+ if base_nodes <= 0:
213
+ return []
214
+ node = int(node)
215
+ if node >= base_nodes:
216
+ house_idx = (node - base_nodes) // HOUSE_SIZE
217
+ return list(
218
+ range(
219
+ base_nodes + house_idx * HOUSE_SIZE,
220
+ base_nodes + (house_idx + 1) * HOUSE_SIZE,
221
+ )
222
+ )
223
+ anchors = data.house_anchors
224
+ if anchors is not None and anchors.numel() and node in anchors.tolist():
225
+ idx = int((anchors == node).nonzero(as_tuple=False).flatten()[0])
226
+ members = list(
227
+ range(base_nodes + idx * HOUSE_SIZE, base_nodes + (idx + 1) * HOUSE_SIZE)
228
+ )
229
+ return members + [node]
230
+ return []
231
+
232
+
233
+ def ground_truth_edge_ids(data, node: int, edge_index) -> list[int]:
234
+ import torch
235
+
236
+ gt_nodes = set(ground_truth_nodes(data, node))
237
+ if not gt_nodes:
238
+ return []
239
+ src = edge_index[0]
240
+ dst = edge_index[1]
241
+ both = torch.isin(src, torch.as_tensor(list(gt_nodes))) & torch.isin(
242
+ dst, torch.as_tensor(list(gt_nodes))
243
+ )
244
+ if torch.is_tensor(both):
245
+ return both.nonzero(as_tuple=False).flatten().tolist()
246
+ return [i for i, ok in enumerate(both) if ok]