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.
- graph_explain/__init__.py +79 -0
- graph_explain/backends/__init__.py +4 -0
- graph_explain/backends/base.py +103 -0
- graph_explain/backends/dgl.py +121 -0
- graph_explain/benchmarks/__init__.py +3 -0
- graph_explain/benchmarks/synthetic.py +246 -0
- graph_explain/cli.py +459 -0
- graph_explain/core/__init__.py +14 -0
- graph_explain/core/benchmark.py +284 -0
- graph_explain/core/evaluation.py +391 -0
- graph_explain/core/explainer.py +83 -0
- graph_explain/core/explanation.py +72 -0
- graph_explain/core/model_utils.py +44 -0
- graph_explain/core/registry.py +55 -0
- graph_explain/methods/__init__.py +39 -0
- graph_explain/methods/attention/attention.py +147 -0
- graph_explain/methods/base.py +25 -0
- graph_explain/methods/baseline/random_baseline.py +78 -0
- graph_explain/methods/counterfactual/counterfactual.py +304 -0
- graph_explain/methods/feature/graph_lime.py +141 -0
- graph_explain/methods/gradient/__init__.py +0 -0
- graph_explain/methods/gradient/grad_x_input.py +110 -0
- graph_explain/methods/gradient/guided_backprop.py +117 -0
- graph_explain/methods/gradient/integrated_gradients.py +115 -0
- graph_explain/methods/gradient/saliency.py +93 -0
- graph_explain/methods/perturbation/__init__.py +0 -0
- graph_explain/methods/perturbation/gnn_explainer.py +265 -0
- graph_explain/methods/perturbation/node_mask.py +136 -0
- graph_explain/methods/perturbation/pg_explainer.py +162 -0
- graph_explain/methods/perturbation/subgraphx.py +393 -0
- graph_explain/methods/relevance/deeplift.py +262 -0
- graph_explain/methods/relevance/gnn_lrp.py +219 -0
- graph_explain/narration/__init__.py +3 -0
- graph_explain/narration/narrator.py +185 -0
- graph_explain/visualization/__init__.py +4 -0
- graph_explain/visualization/interactive.py +73 -0
- graph_explain/visualization/static.py +90 -0
- graph_explain-0.7.0.dist-info/METADATA +332 -0
- graph_explain-0.7.0.dist-info/RECORD +42 -0
- graph_explain-0.7.0.dist-info/WHEEL +5 -0
- graph_explain-0.7.0.dist-info/entry_points.txt +2 -0
- 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,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,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]
|