tabpfn-graph 0.2.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.
@@ -0,0 +1,7 @@
1
+ """Graph-level prediction through stable, schema-learned feature tables."""
2
+
3
+ from ._estimators import GraphClassifier, GraphRegressor
4
+ from ._features import GraphFeatureExtractor, column_report
5
+
6
+ __all__ = ["GraphClassifier", "GraphFeatureExtractor", "GraphRegressor", "column_report"]
7
+ __version__ = "0.2.0"
@@ -0,0 +1,308 @@
1
+ """Input validation and conversion to the canonical NetworkX representation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal
8
+
9
+ import networkx as nx
10
+ import numpy as np
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AdaptedBatch:
15
+ graphs: list[nx.Graph]
16
+ kind: Literal["networkx", "pyg"]
17
+
18
+
19
+ def _is_pyg_data(value: Any) -> bool:
20
+ cls = type(value)
21
+ return cls.__module__.startswith("torch_geometric.") and hasattr(value, "edge_index")
22
+
23
+
24
+ def materialize_graphs(graphs: Iterable[Any], *, allow_empty: bool = False) -> list[Any]:
25
+ if isinstance(graphs, nx.Graph | str | bytes) or _is_pyg_data(graphs):
26
+ raise TypeError("graphs must be an iterable of graph objects, not a single graph")
27
+ try:
28
+ values = list(graphs)
29
+ except TypeError as exc:
30
+ raise TypeError(
31
+ "graphs must be an iterable of NetworkX graphs or PyG Data objects"
32
+ ) from exc
33
+ if not values and not allow_empty:
34
+ raise ValueError("at least one graph is required")
35
+ return values
36
+
37
+
38
+ def adapt_batch(
39
+ graphs: Iterable[Any],
40
+ *,
41
+ expected_kind: str | None = None,
42
+ allow_empty: bool = False,
43
+ ) -> AdaptedBatch:
44
+ values = materialize_graphs(graphs, allow_empty=allow_empty)
45
+ if not values:
46
+ if expected_kind not in {"networkx", "pyg"}:
47
+ raise ValueError("cannot infer graph input type from an empty batch")
48
+ return AdaptedBatch([], expected_kind) # type: ignore[arg-type]
49
+
50
+ nx_flags = [isinstance(value, nx.Graph) for value in values]
51
+ pyg_flags = [_is_pyg_data(value) for value in values]
52
+ if all(nx_flags):
53
+ kind: Literal["networkx", "pyg"] = "networkx"
54
+ result = values
55
+ elif all(pyg_flags):
56
+ kind = "pyg"
57
+ result = [_pyg_to_networkx(value) for value in values]
58
+ else:
59
+ raise TypeError(
60
+ "graph batches must be homogeneous and contain only NetworkX graphs "
61
+ "or only torch_geometric.data.Data objects"
62
+ )
63
+ if expected_kind is not None and kind != expected_kind:
64
+ raise TypeError(
65
+ f"extractor was fitted on {expected_kind} graphs but received {kind} graphs"
66
+ )
67
+ return AdaptedBatch(result, kind)
68
+
69
+
70
+ def _to_python(value: Any) -> Any:
71
+ if hasattr(value, "detach") and hasattr(value, "cpu"):
72
+ value = value.detach().cpu()
73
+ if hasattr(value, "numpy"):
74
+ value = value.numpy()
75
+ if isinstance(value, np.ndarray):
76
+ if value.ndim == 0:
77
+ return value.item()
78
+ return value.tolist()
79
+ if isinstance(value, np.generic):
80
+ return value.item()
81
+ return value
82
+
83
+
84
+ def _attribute_names(data: Any, method: str) -> list[str]:
85
+ fn = getattr(data, method, None)
86
+ if not callable(fn):
87
+ return []
88
+ return [str(name) for name in fn()]
89
+
90
+
91
+ def _pyg_to_networkx(data: Any) -> nx.Graph:
92
+ """Convert PyG Data while collapsing paired arcs of an undirected graph.
93
+
94
+ PyG commonly stores each undirected edge twice. If every non-loop arc has a
95
+ reverse arc with the same multiplicity, those pairs become one undirected
96
+ edge. Otherwise direction is preserved. Repeated logical edges remain
97
+ parallel edges.
98
+ """
99
+
100
+ edge_index = np.asarray(_to_python(data.edge_index), dtype=np.int64)
101
+ if edge_index.ndim != 2 or edge_index.shape[0] != 2:
102
+ raise ValueError("PyG edge_index must have shape (2, n_edges)")
103
+ pairs = list(zip(edge_index[0].tolist(), edge_index[1].tolist(), strict=True))
104
+ num_nodes = int(getattr(data, "num_nodes", 0) or 0)
105
+ if pairs:
106
+ num_nodes = max(num_nodes, max(max(u, v) for u, v in pairs) + 1)
107
+
108
+ counts: dict[tuple[int, int], int] = {}
109
+ for pair in pairs:
110
+ counts[pair] = counts.get(pair, 0) + 1
111
+ undirected = all(u == v or counts.get((v, u), 0) == count for (u, v), count in counts.items())
112
+ if undirected:
113
+ logical_positions: list[int] = []
114
+ seen: dict[tuple[int, int], int] = {}
115
+ for position, (u, v) in enumerate(pairs):
116
+ key = (min(u, v), max(u, v))
117
+ target = counts[(u, v)]
118
+ if seen.get(key, 0) < target and (u <= v or counts.get((v, u), 0) == 0):
119
+ logical_positions.append(position)
120
+ seen[key] = seen.get(key, 0) + 1
121
+ logical_pairs = [pairs[position] for position in logical_positions]
122
+ else:
123
+ logical_positions = list(range(len(pairs)))
124
+ logical_pairs = pairs
125
+
126
+ multiplicities: dict[tuple[int, int], int] = {}
127
+ for u, v in logical_pairs:
128
+ key = (min(u, v), max(u, v)) if undirected else (u, v)
129
+ multiplicities[key] = multiplicities.get(key, 0) + 1
130
+ multi = any(count > 1 for count in multiplicities.values())
131
+ graph_type = (
132
+ nx.MultiGraph
133
+ if undirected and multi
134
+ else nx.Graph
135
+ if undirected
136
+ else nx.MultiDiGraph
137
+ if multi
138
+ else nx.DiGraph
139
+ )
140
+ graph = graph_type()
141
+ graph.add_nodes_from(range(num_nodes))
142
+
143
+ node_names = _attribute_names(data, "node_attrs")
144
+ edge_names = _attribute_names(data, "edge_attrs")
145
+ for name in node_names:
146
+ values = _to_python(getattr(data, name))
147
+ if len(values) != num_nodes:
148
+ continue
149
+ for node, value in enumerate(values):
150
+ graph.nodes[node][name] = value
151
+
152
+ edge_values = {name: _to_python(getattr(data, name)) for name in edge_names}
153
+ for position, (u, v) in zip(logical_positions, logical_pairs, strict=True):
154
+ attrs = {
155
+ name: values[position]
156
+ for name, values in edge_values.items()
157
+ if hasattr(values, "__len__") and len(values) == len(pairs)
158
+ }
159
+ graph.add_edge(u, v, **attrs)
160
+
161
+ excluded = {"edge_index", "num_nodes", "y", "batch", "ptr", *node_names, *edge_names}
162
+ keys = data.keys() if callable(getattr(data, "keys", None)) else []
163
+ for name in keys:
164
+ if name in excluded:
165
+ continue
166
+ value = _to_python(getattr(data, name))
167
+ if np.isscalar(value) or isinstance(value, str):
168
+ graph.graph[str(name)] = value
169
+ return graph
170
+
171
+
172
+ def _project(
173
+ graph: nx.Graph,
174
+ *,
175
+ directed: bool,
176
+ weight: str | None,
177
+ weight_agg: str,
178
+ distance_key: str | None,
179
+ distance_from_similarity: bool,
180
+ ) -> nx.Graph:
181
+ """Collapse a graph onto a simple projection, optionally keeping direction.
182
+
183
+ Nodes and node attributes are copied and self-loops are dropped. Parallel
184
+ edges always collapse; reciprocal arcs collapse only when ``directed`` is
185
+ false.
186
+
187
+ When ``weight`` names an edge attribute, the collapsed edge carries the
188
+ aggregate of every contributing arc under the key ``"weight"``, so
189
+ downstream callers always read the canonical key regardless of what the
190
+ attribute is called on the input graph. Arcs whose weight is missing or
191
+ non-numeric contribute nothing; an edge with no usable weight is given
192
+ weight ``0.0`` and, for path purposes, an infinite traversal cost.
193
+
194
+ A negative weight is rejected rather than coerced: under similarity
195
+ semantics it has no meaningful inverse, and under distance semantics it
196
+ makes shortest paths ill-defined. Silently mapping it to an infinite cost
197
+ would delete the edge from every path and betweenness descriptor while
198
+ leaving it in the degree and clustering descriptors.
199
+ """
200
+
201
+ projected: nx.Graph = nx.DiGraph() if directed else nx.Graph()
202
+ projected.add_nodes_from((node, dict(attrs)) for node, attrs in graph.nodes(data=True))
203
+ if weight is None:
204
+ for u, v in graph.edges():
205
+ if u != v:
206
+ projected.add_edge(u, v)
207
+ return projected
208
+
209
+ collected: dict[tuple[Any, Any], list[float]] = {}
210
+ for u, v, attrs in graph.edges(data=True):
211
+ if u == v:
212
+ continue
213
+ key = (u, v) if directed or repr(u) <= repr(v) else (v, u)
214
+ value = attrs.get(weight)
215
+ collected.setdefault(key, [])
216
+ if isinstance(value, bool | np.bool_) or value is None:
217
+ continue
218
+ try:
219
+ number = float(value)
220
+ except (TypeError, ValueError):
221
+ continue
222
+ if not np.isfinite(number):
223
+ continue
224
+ if number < 0:
225
+ semantics = "similarity" if distance_from_similarity else "distance"
226
+ raise ValueError(
227
+ f"edge attribute {weight!r} has a negative value ({number}) on edge "
228
+ f"({u!r}, {v!r}); negative weights cannot be turned into a traversal cost "
229
+ f"under edge_weight_semantics={semantics!r}. Rescale the weights to be "
230
+ "non-negative, or drop those edges before extraction."
231
+ )
232
+ collected[key].append(number)
233
+
234
+ def aggregate(values: list[float]) -> float:
235
+ array = np.asarray(values, dtype=float)
236
+ if weight_agg == "mean":
237
+ return float(array.mean())
238
+ if weight_agg == "max":
239
+ return float(array.max())
240
+ if weight_agg == "min":
241
+ return float(array.min())
242
+ return float(array.sum())
243
+
244
+ for (u, v), values in collected.items():
245
+ total = aggregate(values) if values else 0.0
246
+ attributes: dict[str, float] = {"weight": total}
247
+ if distance_key is not None:
248
+ if not values:
249
+ # No usable weight anywhere on this edge: unreachable for paths.
250
+ attributes[distance_key] = float(np.inf)
251
+ elif distance_from_similarity:
252
+ # A zero similarity is the absence of a tie.
253
+ attributes[distance_key] = 1.0 / total if total > 0 else float(np.inf)
254
+ else:
255
+ # A zero distance is a legitimate free traversal.
256
+ attributes[distance_key] = total
257
+ projected.add_edge(u, v, **attributes)
258
+ return projected
259
+
260
+
261
+ def simple_undirected_projection(
262
+ graph: nx.Graph,
263
+ *,
264
+ weight: str | None = None,
265
+ weight_agg: str = "sum",
266
+ distance_key: str | None = None,
267
+ distance_from_similarity: bool = True,
268
+ ) -> nx.Graph:
269
+ """Return the documented descriptor projection.
270
+
271
+ Nodes and node attributes are copied; direction, reciprocal arcs, parallel
272
+ edges, and self-loops are collapsed/dropped. Native statistics are always
273
+ computed before this projection.
274
+ """
275
+
276
+ return _project(
277
+ graph,
278
+ directed=False,
279
+ weight=weight,
280
+ weight_agg=weight_agg,
281
+ distance_key=distance_key,
282
+ distance_from_similarity=distance_from_similarity,
283
+ )
284
+
285
+
286
+ def simple_directed_projection(
287
+ graph: nx.Graph,
288
+ *,
289
+ weight: str | None = None,
290
+ weight_agg: str = "sum",
291
+ distance_key: str | None = None,
292
+ distance_from_similarity: bool = True,
293
+ ) -> nx.Graph:
294
+ """Return the direction-preserving counterpart of the descriptor projection.
295
+
296
+ Used by descriptors that are only defined on directed graphs, so that they
297
+ see the configured edge weights under the canonical ``"weight"`` key with
298
+ parallel arcs aggregated the same way as everywhere else.
299
+ """
300
+
301
+ return _project(
302
+ graph,
303
+ directed=True,
304
+ weight=weight,
305
+ weight_agg=weight_agg,
306
+ distance_key=distance_key,
307
+ distance_from_similarity=distance_from_similarity,
308
+ )
@@ -0,0 +1,179 @@
1
+ """Scikit-learn-compatible graph-level predictor wrappers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin, clone
10
+ from sklearn.metrics import accuracy_score, r2_score
11
+ from sklearn.utils.multiclass import check_classification_targets
12
+ from sklearn.utils.validation import check_is_fitted, column_or_1d
13
+
14
+ from ._features import FeatureGroup, GraphFeatureExtractor
15
+
16
+
17
+ def _default_tabpfn(task: str, random_state: int | None) -> Any:
18
+ try:
19
+ from tabpfn import TabPFNClassifier, TabPFNRegressor
20
+ except ImportError as exc: # pragma: no cover - standard installs include it
21
+ raise ImportError(
22
+ "The default estimator requires tabpfn. Install the standard package with "
23
+ "`pip install tabpfn-graph`, or pass estimator= to use another estimator."
24
+ ) from exc
25
+ estimator_class = TabPFNClassifier if task == "classification" else TabPFNRegressor
26
+ signature = inspect.signature(estimator_class)
27
+ kwargs: dict[str, Any] = {}
28
+ if "random_state" in signature.parameters:
29
+ kwargs["random_state"] = random_state
30
+ if "inference_config" in signature.parameters:
31
+ # TabPFN performs its local string expansion only for pandas string dtype.
32
+ kwargs["inference_config"] = {"TRANSFORM_TEXT": True}
33
+ return estimator_class(**kwargs)
34
+
35
+
36
+ def _fit_with_optional_weight(
37
+ estimator: Any, features: Any, y: np.ndarray, sample_weight: Any
38
+ ) -> Any:
39
+ if sample_weight is None:
40
+ return estimator.fit(features, y)
41
+ try:
42
+ signature = inspect.signature(estimator.fit)
43
+ supports_weight = "sample_weight" in signature.parameters or any(
44
+ parameter.kind == inspect.Parameter.VAR_KEYWORD
45
+ for parameter in signature.parameters.values()
46
+ )
47
+ except (TypeError, ValueError):
48
+ supports_weight = True
49
+ if not supports_weight:
50
+ raise TypeError(f"{type(estimator).__name__}.fit does not accept sample_weight")
51
+ return estimator.fit(features, y, sample_weight=sample_weight)
52
+
53
+
54
+ class _BaseGraphPredictor(BaseEstimator):
55
+ _task: str
56
+
57
+ def __init__(
58
+ self,
59
+ estimator: Any = None,
60
+ *,
61
+ feature_extractor: GraphFeatureExtractor | None = None,
62
+ features: str | tuple[FeatureGroup, ...] = "balanced",
63
+ edge_weight: str | None = None,
64
+ edge_weight_semantics: str = "similarity",
65
+ undefined: str = "zero",
66
+ prune_uninformative: bool = False,
67
+ n_jobs: int | None = 1,
68
+ random_state: int | None = 0,
69
+ memory: str | None = None,
70
+ ) -> None:
71
+ self.estimator = estimator
72
+ self.feature_extractor = feature_extractor
73
+ self.features = features
74
+ self.edge_weight = edge_weight
75
+ self.edge_weight_semantics = edge_weight_semantics
76
+ self.undefined = undefined
77
+ self.prune_uninformative = prune_uninformative
78
+ self.n_jobs = n_jobs
79
+ self.random_state = random_state
80
+ self.memory = memory
81
+
82
+ def _make_extractor(self) -> GraphFeatureExtractor:
83
+ if self.feature_extractor is not None:
84
+ return clone(self.feature_extractor)
85
+ return GraphFeatureExtractor(
86
+ features=self.features,
87
+ edge_weight=self.edge_weight,
88
+ edge_weight_semantics=self.edge_weight_semantics, # type: ignore[arg-type]
89
+ undefined=self.undefined, # type: ignore[arg-type]
90
+ prune_uninformative=self.prune_uninformative,
91
+ n_jobs=self.n_jobs,
92
+ random_state=self.random_state,
93
+ memory=self.memory,
94
+ )
95
+
96
+ def fit(self, graphs: Any, y: Any, sample_weight: Any = None) -> _BaseGraphPredictor:
97
+ target = column_or_1d(y, warn=True)
98
+ if self._task == "classification":
99
+ check_classification_targets(target)
100
+ elif not np.issubdtype(np.asarray(target).dtype, np.number):
101
+ raise ValueError("regression targets must be numeric")
102
+ if sample_weight is not None and len(sample_weight) != len(target):
103
+ raise ValueError("sample_weight and y must have the same length")
104
+ self.feature_extractor_ = self._make_extractor()
105
+ table = self.feature_extractor_.fit_transform(graphs, target)
106
+ if len(table) != len(target):
107
+ raise ValueError(f"received {len(table)} graphs but {len(target)} target values")
108
+ self.estimator_ = (
109
+ clone(self.estimator)
110
+ if self.estimator is not None
111
+ else _default_tabpfn(self._task, self.random_state)
112
+ )
113
+ _fit_with_optional_weight(self.estimator_, table, target, sample_weight)
114
+ self.n_features_in_ = table.shape[1]
115
+ self.feature_names_in_ = np.asarray(table.columns, dtype=object)
116
+ if self._task == "classification":
117
+ self.classes_ = np.asarray(getattr(self.estimator_, "classes_", np.unique(target)))
118
+ return self
119
+
120
+ def transform(self, graphs: Any) -> Any:
121
+ check_is_fitted(self, "estimator_")
122
+ return self.feature_extractor_.transform(graphs)
123
+
124
+ def predict(self, graphs: Any) -> np.ndarray:
125
+ return (
126
+ np.asarray(self.estimator_.predict(self.transform(graphs)))
127
+ if hasattr(self, "estimator_")
128
+ else self._not_fitted()
129
+ )
130
+
131
+ def _not_fitted(self) -> Any:
132
+ check_is_fitted(self, "estimator_")
133
+ raise AssertionError("unreachable")
134
+
135
+ def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
136
+ check_is_fitted(self, "feature_extractor_")
137
+ return self.feature_extractor_.get_feature_names_out(input_features)
138
+
139
+ def __sklearn_is_fitted__(self) -> bool:
140
+ return hasattr(self, "estimator_")
141
+
142
+
143
+ class GraphClassifier(ClassifierMixin, _BaseGraphPredictor):
144
+ """Graph-level binary/multiclass classifier.
145
+
146
+ ``estimator=None`` lazily constructs a local ``TabPFNClassifier`` with raw
147
+ text transformation enabled. Passing a custom estimator leaves the feature
148
+ DataFrame untouched, including categorical and text columns.
149
+ """
150
+
151
+ _task = "classification"
152
+
153
+ def predict_proba(self, graphs: Any) -> np.ndarray:
154
+ check_is_fitted(self, "estimator_")
155
+ if not hasattr(self.estimator_, "predict_proba"):
156
+ raise AttributeError(
157
+ f"{type(self.estimator_).__name__} does not implement predict_proba"
158
+ )
159
+ return np.asarray(self.estimator_.predict_proba(self.transform(graphs)))
160
+
161
+ def decision_function(self, graphs: Any) -> np.ndarray:
162
+ check_is_fitted(self, "estimator_")
163
+ if not hasattr(self.estimator_, "decision_function"):
164
+ raise AttributeError(
165
+ f"{type(self.estimator_).__name__} does not implement decision_function"
166
+ )
167
+ return np.asarray(self.estimator_.decision_function(self.transform(graphs)))
168
+
169
+ def score(self, graphs: Any, y: Any, sample_weight: Any = None) -> float:
170
+ return float(accuracy_score(y, self.predict(graphs), sample_weight=sample_weight))
171
+
172
+
173
+ class GraphRegressor(RegressorMixin, _BaseGraphPredictor):
174
+ """Single-target graph-level regressor."""
175
+
176
+ _task = "regression"
177
+
178
+ def score(self, graphs: Any, y: Any, sample_weight: Any = None) -> float:
179
+ return float(r2_score(y, self.predict(graphs), sample_weight=sample_weight))