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.
- tabpfn_graph/__init__.py +7 -0
- tabpfn_graph/_adapters.py +308 -0
- tabpfn_graph/_estimators.py +179 -0
- tabpfn_graph/_features.py +1564 -0
- tabpfn_graph/py.typed +1 -0
- tabpfn_graph-0.2.0.dist-info/METADATA +281 -0
- tabpfn_graph-0.2.0.dist-info/RECORD +10 -0
- tabpfn_graph-0.2.0.dist-info/WHEEL +4 -0
- tabpfn_graph-0.2.0.dist-info/licenses/LICENSE +202 -0
- tabpfn_graph-0.2.0.dist-info/licenses/NOTICE +7 -0
tabpfn_graph/__init__.py
ADDED
|
@@ -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))
|