ho22joshua commited on
Commit
2c516a8
·
1 Parent(s): 13fd35c

feat: add inference and ONNX export adapters

Browse files
docs/export.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ROOT-GNN ONNX export
2
+
3
+ ONNX export is an inference-only adapter for prepared ROOT-GNN graph batches.
4
+ It does not read ROOT files or move feature construction into the deployment
5
+ model. Install the optional dependencies with:
6
+
7
+ ```bash
8
+ uv sync --extra root-gnn --extra onnx
9
+ ```
10
+
11
+ Export a checkpoint with the project CLI:
12
+
13
+ ```bash
14
+ uv run gnn4colliders export \
15
+ export.checkpoint=/path/to/checkpoint.pt \
16
+ export.output=model.onnx \
17
+ data.cache.path=/path/to/graph-cache.pt
18
+ ```
19
+
20
+ The exported model accepts six tensor inputs: `node_features`,
21
+ `edge_features`, `edge_src`, `edge_dst`, `node_batch`, and `global_features`.
22
+ `node_batch` identifies the graph for each node; edge membership is derived
23
+ from `node_batch[edge_src]`. Graph, node, edge, and batch dimensions are
24
+ dynamic. The model returns raw `logits`; task postprocessing and metadata stay
25
+ outside ONNX.
26
+
27
+ The exporter uses ONNX opset 17, validates the structure with `onnx.checker`,
28
+ and writes compact provenance/schema metadata to `model.onnx.json`. It
29
+ supports `EdgeNetwork` multiclass models and `FineTunedEdgeNetwork` binary
30
+ models. Empty graphs are invalid; single-node graphs (zero edges) are
31
+ represented and pooled with a zero edge contribution.
32
+
33
+ Direct DGL export was not retained: the active model uses DGL graph mutation
34
+ and reductions (`apply_edges`, `update_all`, and graph pooling) that are not a
35
+ portable ONNX contract. The adapter expresses those operations with standard
36
+ tensor indexing, `index_add`, and per-graph means.
src/gnn4colliders/export/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional ONNX export for already-prepared ROOT-GNN graph tensors."""
2
+
3
+ from .onnx import (
4
+ RootGNNExportAdapter,
5
+ RootGNNExportInputs,
6
+ export_checkpoint_to_onnx,
7
+ export_root_gnn_onnx,
8
+ inputs_from_graph_batch,
9
+ validate_onnx,
10
+ validate_onnx_runtime,
11
+ )
12
+
13
+ __all__ = [
14
+ "RootGNNExportAdapter",
15
+ "RootGNNExportInputs",
16
+ "export_checkpoint_to_onnx",
17
+ "export_root_gnn_onnx",
18
+ "inputs_from_graph_batch",
19
+ "validate_onnx",
20
+ "validate_onnx_runtime",
21
+ ]
src/gnn4colliders/export/onnx.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tensor-only ROOT-GNN export and ONNX Runtime validation.
2
+
3
+ The native model intentionally remains DGL-based. This module is an
4
+ inference/deployment adapter whose public tensor contract is:
5
+ ``node_features``, ``edge_features``, ``edge_src``, ``edge_dst``,
6
+ ``node_batch``, and ``global_features``. ``global_features`` is a required
7
+ runtime input for a stable ONNX signature; for models without configured
8
+ globals it is ignored and may contain one zero column per graph.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import tempfile
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any, Mapping
20
+
21
+ import torch
22
+ from torch import nn
23
+
24
+ from gnn4colliders.data.metadata import FEATURE_SCHEMA_VERSION, GRAPH_SCHEMA_VERSION
25
+ from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
26
+ from gnn4colliders.training import CheckpointManager, load_model_weights
27
+
28
+ ONNX_OPSET = 17
29
+ _INPUT_NAMES = (
30
+ "node_features",
31
+ "edge_features",
32
+ "edge_src",
33
+ "edge_dst",
34
+ "node_batch",
35
+ "global_features",
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class RootGNNExportInputs:
41
+ """Numerical graph representation consumed by the exported model."""
42
+
43
+ node_features: torch.Tensor
44
+ edge_features: torch.Tensor
45
+ edge_src: torch.Tensor
46
+ edge_dst: torch.Tensor
47
+ node_batch: torch.Tensor
48
+ global_features: torch.Tensor
49
+
50
+ @property
51
+ def graph_count(self) -> int:
52
+ return int(self.global_features.shape[0])
53
+
54
+ def as_tuple(self) -> tuple[torch.Tensor, ...]:
55
+ return tuple(getattr(self, name) for name in _INPUT_NAMES)
56
+
57
+
58
+ def inputs_from_graph_batch(batch: Any) -> RootGNNExportInputs:
59
+ """Convert a ``GraphBatch`` to explicit, metadata-free export inputs."""
60
+ graph = batch.graph if hasattr(batch, "graph") else batch
61
+ if not hasattr(graph, "ndata") or not hasattr(graph, "edata"):
62
+ raise TypeError("example batch must contain a DGL graph")
63
+ node_features = graph.ndata["features"]
64
+ edge_features = graph.edata["features"]
65
+ if node_features.ndim != 2 or edge_features.ndim != 2:
66
+ raise ValueError("graph node and edge features must be rank-2 tensors")
67
+ try:
68
+ node_counts = graph.batch_num_nodes().to(dtype=torch.long)
69
+ edge_counts = graph.batch_num_edges().to(dtype=torch.long)
70
+ edge_src, edge_dst = graph.edges(order="eid")
71
+ except AttributeError as error:
72
+ raise ValueError("export requires a batched DGL graph") from error
73
+ if (
74
+ node_counts.numel() == 0
75
+ or int(node_counts.sum()) == 0
76
+ or bool((node_counts == 0).any())
77
+ ):
78
+ raise ValueError("empty-node graphs are not valid ROOT-GNN export inputs")
79
+ node_batch = torch.repeat_interleave(
80
+ torch.arange(len(node_counts), device=node_counts.device), node_counts
81
+ )
82
+ edge_batch = torch.repeat_interleave(
83
+ torch.arange(len(edge_counts), device=edge_counts.device), edge_counts
84
+ )
85
+ # The edge batch is deliberately derived from source node membership in the
86
+ # adapter; checking it here catches malformed graph batches early.
87
+ if edge_batch.numel() and not torch.equal(edge_batch, node_batch[edge_src]):
88
+ raise ValueError("DGL edge ordering does not agree with graph membership")
89
+ globals_ = getattr(batch, "global_features", None)
90
+ if globals_ is None:
91
+ globals_ = node_counts[:, None].to(dtype=node_features.dtype)
92
+ elif globals_.ndim == 1:
93
+ globals_ = globals_.unsqueeze(0)
94
+ if globals_.shape[0] != len(node_counts):
95
+ raise ValueError("global_features must have one row per graph")
96
+ return RootGNNExportInputs(
97
+ node_features=node_features,
98
+ edge_features=edge_features,
99
+ edge_src=edge_src.to(dtype=torch.long),
100
+ edge_dst=edge_dst.to(dtype=torch.long),
101
+ node_batch=node_batch.to(dtype=torch.long),
102
+ global_features=globals_,
103
+ )
104
+
105
+
106
+ def _mean_by_group(
107
+ values: torch.Tensor, groups: torch.Tensor, count: int
108
+ ) -> torch.Tensor:
109
+ result = values.new_zeros((count, values.shape[1]))
110
+ if values.shape[0]:
111
+ result = result.index_add(0, groups, values)
112
+ sizes = values.new_zeros((count, 1)).index_add(
113
+ 0, groups, values.new_ones((values.shape[0], 1))
114
+ )
115
+ result = result / sizes.clamp_min(1)
116
+ return result
117
+
118
+
119
+ def _sum_by_group(
120
+ values: torch.Tensor, groups: torch.Tensor, count: int
121
+ ) -> torch.Tensor:
122
+ result = values.new_zeros((count, values.shape[1]))
123
+ if values.shape[0]:
124
+ result = result.index_add(0, groups, values)
125
+ return result
126
+
127
+
128
+ class RootGNNExportAdapter(nn.Module):
129
+ """Reproduce an ``EdgeNetwork``/``FineTunedEdgeNetwork`` with tensors only."""
130
+
131
+ def __init__(self, model: nn.Module) -> None:
132
+ super().__init__()
133
+ if isinstance(model, FineTunedEdgeNetwork):
134
+ self.backbone = model.backbone
135
+ self.classifier = model.classifier
136
+ elif isinstance(model, EdgeNetwork):
137
+ self.backbone = model
138
+ self.classifier = model.classifier
139
+ else:
140
+ raise TypeError("model must be EdgeNetwork or FineTunedEdgeNetwork")
141
+ self.has_global = self.backbone.has_global
142
+
143
+ def forward(
144
+ self,
145
+ node_features: torch.Tensor,
146
+ edge_features: torch.Tensor,
147
+ edge_src: torch.Tensor,
148
+ edge_dst: torch.Tensor,
149
+ node_batch: torch.Tensor,
150
+ global_features: torch.Tensor,
151
+ ) -> torch.Tensor:
152
+ graph_count = global_features.shape[0]
153
+ node_h = self.backbone.node_encoder(node_features)
154
+ edge_h = self.backbone.edge_encoder(edge_features)
155
+ if self.has_global:
156
+ global_h = self.backbone.global_encoder(global_features)
157
+ else:
158
+ counts = _sum_by_group(
159
+ node_features.new_ones((node_features.shape[0], 1)),
160
+ node_batch,
161
+ graph_count,
162
+ )
163
+ global_h = self.backbone.global_encoder(counts)
164
+ edge_batch = node_batch[edge_src]
165
+ for _ in range(self.backbone.n_proc_steps):
166
+ edge_h = self.backbone.edge_update(
167
+ torch.cat(
168
+ (edge_h, node_h[edge_src], node_h[edge_dst], global_h[edge_batch]),
169
+ 1,
170
+ )
171
+ )
172
+ node_messages = node_h.new_zeros(node_h.shape).index_add(
173
+ 0, edge_dst, edge_h
174
+ )
175
+ node_h = self.backbone.node_update(
176
+ torch.cat((node_h, node_messages, global_h[node_batch]), 1)
177
+ )
178
+ global_h = self.backbone.global_update(
179
+ torch.cat(
180
+ (
181
+ global_h,
182
+ _mean_by_group(node_h, node_batch, graph_count),
183
+ _mean_by_group(edge_h, edge_batch, graph_count),
184
+ ),
185
+ 1,
186
+ )
187
+ )
188
+ return self.classifier(self.backbone.global_decoder(global_h))
189
+
190
+
191
+ def _optional_onnx() -> Any:
192
+ try:
193
+ import onnx
194
+ except ImportError as error:
195
+ raise ImportError(
196
+ "ONNX export requires the 'onnx' extra. Install with: "
197
+ "uv sync --extra root-gnn --extra onnx"
198
+ ) from error
199
+ return onnx
200
+
201
+
202
+ def validate_onnx(path: str | os.PathLike[str]) -> None:
203
+ """Run ONNX structural validation and fail clearly on invalid artifacts."""
204
+ onnx = _optional_onnx()
205
+ model = onnx.load(str(path))
206
+ onnx.checker.check_model(model)
207
+
208
+
209
+ def validate_onnx_runtime(
210
+ path: str | os.PathLike[str],
211
+ inputs: RootGNNExportInputs,
212
+ reference: torch.Tensor,
213
+ *,
214
+ rtol: float = 1e-4,
215
+ atol: float = 1e-5,
216
+ ) -> torch.Tensor:
217
+ """Run CPU ONNX Runtime and compare its raw logits with PyTorch."""
218
+ try:
219
+ import numpy as np
220
+ import onnxruntime as ort
221
+ except ImportError as error:
222
+ raise ImportError(
223
+ "ONNX Runtime validation requires the 'onnx' extra. Install with: "
224
+ "uv sync --extra root-gnn --extra onnx"
225
+ ) from error
226
+ session = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
227
+ values = {
228
+ name: tensor.detach().cpu().numpy()
229
+ for name, tensor in zip(_INPUT_NAMES, inputs.as_tuple())
230
+ }
231
+ result = torch.from_numpy(np.asarray(session.run(["logits"], values)[0]))
232
+ torch.testing.assert_close(result, reference.detach().cpu(), rtol=rtol, atol=atol)
233
+ return result
234
+
235
+
236
+ def _metadata(
237
+ model: nn.Module, *, checkpoint: str | None, task_config: Mapping[str, Any] | None
238
+ ) -> dict[str, Any]:
239
+ config = model.checkpoint_config()
240
+ return {
241
+ "model_family": "root_gnn",
242
+ "model_config": config,
243
+ "task_config": dict(task_config or {}),
244
+ "feature_schema_version": FEATURE_SCHEMA_VERSION,
245
+ "graph_schema_version": GRAPH_SCHEMA_VERSION,
246
+ "onnx_opset": ONNX_OPSET,
247
+ "input_names": list(_INPUT_NAMES),
248
+ "output_name": "logits",
249
+ "checkpoint": (Path(checkpoint).name if checkpoint else None),
250
+ "checkpoint_sha256": (
251
+ hashlib.sha256(Path(checkpoint).read_bytes()).hexdigest()
252
+ if checkpoint
253
+ else None
254
+ ),
255
+ }
256
+
257
+
258
+ def export_root_gnn_onnx(
259
+ model: nn.Module,
260
+ example_batch: Any,
261
+ output_path: str | os.PathLike[str],
262
+ *,
263
+ opset: int = ONNX_OPSET,
264
+ task_config: Mapping[str, Any] | None = None,
265
+ checkpoint: str | None = None,
266
+ overwrite: bool = False,
267
+ ) -> Path:
268
+ """Export and validate a ROOT-GNN model from an already prepared batch."""
269
+ if opset != ONNX_OPSET:
270
+ raise ValueError(f"only ONNX opset {ONNX_OPSET} is supported")
271
+ _optional_onnx()
272
+ inputs = (
273
+ example_batch
274
+ if isinstance(example_batch, RootGNNExportInputs)
275
+ else inputs_from_graph_batch(example_batch)
276
+ )
277
+ adapter = RootGNNExportAdapter(model).eval()
278
+ output = Path(output_path)
279
+ if output.suffix.lower() != ".onnx":
280
+ raise ValueError("export output must have a .onnx extension")
281
+ if output.exists() and not overwrite:
282
+ raise FileExistsError(f"export output already exists: {output}")
283
+ output.parent.mkdir(parents=True, exist_ok=True)
284
+ with tempfile.TemporaryDirectory(
285
+ prefix=f".{output.stem}-", dir=output.parent
286
+ ) as temp_dir:
287
+ temporary = Path(temp_dir) / output.name
288
+ torch.onnx.export(
289
+ adapter,
290
+ inputs.as_tuple(),
291
+ str(temporary),
292
+ input_names=list(_INPUT_NAMES),
293
+ output_names=["logits"],
294
+ opset_version=opset,
295
+ dynamic_axes={
296
+ "node_features": {0: "nodes"},
297
+ "edge_features": {0: "edges"},
298
+ "edge_src": {0: "edges"},
299
+ "edge_dst": {0: "edges"},
300
+ "node_batch": {0: "nodes"},
301
+ "global_features": {0: "graphs"},
302
+ "logits": {0: "graphs"},
303
+ },
304
+ do_constant_folding=True,
305
+ )
306
+ validate_onnx(temporary)
307
+ with torch.inference_mode():
308
+ reference = adapter(*inputs.as_tuple())
309
+ validate_onnx_runtime(temporary, inputs, reference)
310
+ temporary.replace(output)
311
+ metadata_path = output.with_suffix(output.suffix + ".json")
312
+ metadata_path.write_text(
313
+ json.dumps(
314
+ _metadata(model, checkpoint=checkpoint, task_config=task_config),
315
+ indent=2,
316
+ sort_keys=True,
317
+ )
318
+ + "\n"
319
+ )
320
+ return output
321
+
322
+
323
+ def _model_from_checkpoint(payload: Mapping[str, Any], example_batch: Any) -> nn.Module:
324
+ config = dict(payload.get("model_config") or {})
325
+ if str(config.get("family", "root_gnn")) != "root_gnn":
326
+ raise ValueError("checkpoint model family is not root_gnn")
327
+ graph = example_batch.graph if hasattr(example_batch, "graph") else None
328
+ if graph is None:
329
+ raise TypeError("checkpoint reconstruction needs a GraphBatch example")
330
+ kwargs = {
331
+ key: config[key]
332
+ for key in ("hid_size", "n_layers", "n_proc_steps", "dropout")
333
+ if key in config
334
+ }
335
+ model_class = str(config.get("class", "EdgeNetwork"))
336
+ if "FineTuned" in model_class or str(config.get("name", "")).startswith("fine"):
337
+ backbone = EdgeNetwork(
338
+ graph, getattr(example_batch, "global_features", None), out_size=1, **kwargs
339
+ )
340
+ model = FineTunedEdgeNetwork(
341
+ backbone,
342
+ int(config.get("out_size", 1)),
343
+ freeze_backbone=bool(config.get("freeze_backbone", False)),
344
+ )
345
+ else:
346
+ model = EdgeNetwork(
347
+ graph,
348
+ getattr(example_batch, "global_features", None),
349
+ out_size=int(config.get("out_size", 1)),
350
+ **kwargs,
351
+ )
352
+ load_model_weights(model, payload)
353
+ return model
354
+
355
+
356
+ def export_checkpoint_to_onnx(
357
+ checkpoint_path: str | os.PathLike[str],
358
+ output_path: str | os.PathLike[str],
359
+ *,
360
+ example_batch: Any,
361
+ opset: int = ONNX_OPSET,
362
+ overwrite: bool = False,
363
+ ) -> Path:
364
+ """Load a Task 11 checkpoint and export its reconstructed ROOT-GNN model."""
365
+ payload = CheckpointManager.load(checkpoint_path, map_location="cpu")
366
+ model = _model_from_checkpoint(payload, example_batch)
367
+ return export_root_gnn_onnx(
368
+ model,
369
+ example_batch,
370
+ output_path,
371
+ opset=opset,
372
+ task_config=payload.get("task_config"),
373
+ checkpoint=str(checkpoint_path),
374
+ overwrite=overwrite,
375
+ )
src/gnn4colliders/inference/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prediction, evaluation, and output writing infrastructure."""
2
+
3
+ from .predictor import Predictor, load_model_and_task_for_inference
4
+ from .results import EvaluationResult, PredictionResult
5
+ from .root_writer import write_root_scores
6
+ from .writers import write_npz
7
+
8
+ __all__ = [
9
+ "EvaluationResult",
10
+ "PredictionResult",
11
+ "Predictor",
12
+ "load_model_and_task_for_inference",
13
+ "write_npz",
14
+ "write_root_scores",
15
+ ]
src/gnn4colliders/inference/predictor.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model execution for prediction and full-split evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable, Mapping
7
+ from types import SimpleNamespace
8
+ from typing import Any
9
+
10
+ import torch
11
+ from torch import nn
12
+
13
+ from gnn4colliders.data.graph_dataset import GraphBatch
14
+ from gnn4colliders.distributed import (
15
+ DistributedContext,
16
+ gather_objects,
17
+ gather_tensor,
18
+ prepare_model,
19
+ )
20
+ from gnn4colliders.tasks import BinaryClassificationTask, MulticlassClassificationTask
21
+ from gnn4colliders.training.checkpoint import CheckpointManager, load_model_weights
22
+
23
+ from .results import EvaluationResult, PredictionResult
24
+
25
+
26
+ def _forward(model: nn.Module, batch: Any) -> torch.Tensor:
27
+ """Call the public GraphBatch or graph/global model boundary."""
28
+ if isinstance(batch, GraphBatch):
29
+ return model(batch.graph, batch.global_features)
30
+ try:
31
+ signature = inspect.signature(model.forward)
32
+ positional = [
33
+ parameter
34
+ for parameter in signature.parameters.values()
35
+ if parameter.kind
36
+ in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD)
37
+ ]
38
+ except (TypeError, ValueError):
39
+ positional = []
40
+ if len(positional) <= 1:
41
+ return model(batch)
42
+ if hasattr(batch, "graph"):
43
+ return model(batch.graph, getattr(batch, "global_features", None))
44
+ raise TypeError("model requires graph/global_features, but batch has no graph")
45
+
46
+
47
+ def _batch_metadata(batch: Any) -> Any:
48
+ return getattr(batch, "metadata", None)
49
+
50
+
51
+ def _labels(batch: Any) -> torch.Tensor | None:
52
+ labels = getattr(batch, "labels", None)
53
+ return None if labels is None else torch.as_tensor(labels).detach().cpu()
54
+
55
+
56
+ class Predictor:
57
+ """Run a task-aware model over an ordered loader."""
58
+
59
+ def __init__(
60
+ self,
61
+ model: nn.Module,
62
+ task: Any,
63
+ *,
64
+ device: torch.device | str = "cpu",
65
+ distributed_context: DistributedContext | None = None,
66
+ ) -> None:
67
+ self.distributed_context = (
68
+ distributed_context or DistributedContext.single_process(device)
69
+ )
70
+ self.device = self.distributed_context.device
71
+ self.model = prepare_model(model, self.distributed_context)
72
+ self.task = task
73
+
74
+ def predict(self, loader: Any) -> PredictionResult:
75
+ self.model.eval()
76
+ logits_parts: list[torch.Tensor] = []
77
+ label_parts: list[torch.Tensor] = []
78
+ sample_ids: list[str] = []
79
+ metadata_parts: list[Any] = []
80
+ has_labels = True
81
+
82
+ with torch.inference_mode():
83
+ for batch in loader:
84
+ moved = batch.to(self.device) if hasattr(batch, "to") else batch
85
+ logits = _forward(self.model, moved)
86
+ if not isinstance(logits, torch.Tensor):
87
+ raise TypeError("model must return a torch.Tensor of logits")
88
+ logits_parts.append(logits.detach().cpu())
89
+ labels = _labels(moved)
90
+ if labels is None:
91
+ has_labels = False
92
+ else:
93
+ label_parts.append(labels)
94
+ metadata = _batch_metadata(moved)
95
+ if metadata is not None:
96
+ ids = getattr(metadata, "sample_id", None)
97
+ if ids is not None:
98
+ sample_ids.extend(str(value) for value in ids)
99
+ metadata_parts.append(metadata)
100
+
101
+ if not logits_parts:
102
+ raise ValueError("cannot predict on an empty loader")
103
+ logits = torch.cat(logits_parts, dim=0)
104
+ if self.distributed_context.enabled:
105
+ logits = gather_tensor(logits, self.distributed_context)
106
+ if label_parts:
107
+ labels_local = torch.cat(label_parts, dim=0)
108
+ labels_result = gather_tensor(labels_local, self.distributed_context)
109
+ else:
110
+ labels_result = None
111
+ ids = tuple(
112
+ value
113
+ for group in gather_objects(tuple(sample_ids), self.distributed_context)
114
+ for value in group
115
+ )
116
+ local_metadata = _combine_metadata(metadata_parts)
117
+ if local_metadata is not None and hasattr(local_metadata, "weight"):
118
+ metadata = SimpleNamespace(
119
+ fold=gather_tensor(local_metadata.fold, self.distributed_context),
120
+ weight=gather_tensor(
121
+ local_metadata.weight, self.distributed_context
122
+ ),
123
+ sample_id=ids,
124
+ )
125
+ sample_ids = list(ids)
126
+ else:
127
+ labels_result = torch.cat(label_parts, dim=0) if has_labels else None
128
+ task_output = self.task.predictions(logits)
129
+ if not self.distributed_context.enabled:
130
+ metadata = _combine_metadata(metadata_parts)
131
+ if not sample_ids and metadata is not None:
132
+ sample_ids = [str(value) for value in getattr(metadata, "sample_id", ())]
133
+ if len(sample_ids) != logits.shape[0]:
134
+ raise ValueError(
135
+ "loader metadata must provide one sample_id for every prediction"
136
+ )
137
+ return PredictionResult(
138
+ sample_ids=tuple(sample_ids),
139
+ logits=logits,
140
+ scores=task_output["scores"].detach().cpu(),
141
+ predictions=task_output["predictions"].detach().cpu(),
142
+ labels=labels_result,
143
+ metadata=metadata,
144
+ extra=getattr(metadata, "extra", {}) if metadata is not None else {},
145
+ )
146
+
147
+ def evaluate(self, loader: Any) -> EvaluationResult:
148
+ result = self.predict(loader)
149
+ if result.labels is None:
150
+ raise ValueError("evaluation requires labels")
151
+ weights = result.weights
152
+ batch = SimpleNamespace(
153
+ labels=result.labels,
154
+ metadata=SimpleNamespace(weight=weights) if weights is not None else None,
155
+ )
156
+ if weights is None:
157
+ raise ValueError("evaluation requires named metadata.weight")
158
+ metrics = {
159
+ key: float(value)
160
+ for key, value in self.task.metrics(result.logits, batch).items()
161
+ }
162
+ return EvaluationResult(predictions=result, metrics=metrics)
163
+
164
+
165
+ def _combine_metadata(parts: list[Any]) -> Any | None:
166
+ if not parts:
167
+ return None
168
+ first = parts[0]
169
+ cls = type(first)
170
+ fields = ("fold", "weight", "sample_id", "extra")
171
+ values: dict[str, Any] = {}
172
+ for name in fields:
173
+ if not hasattr(first, name):
174
+ continue
175
+ current = [getattr(part, name) for part in parts]
176
+ if name in {"fold", "weight"}:
177
+ values[name] = torch.cat(
178
+ [torch.as_tensor(value).cpu() for value in current]
179
+ )
180
+ elif name == "sample_id":
181
+ values[name] = tuple(value for group in current for value in group)
182
+ elif name == "extra":
183
+ keys = sorted({key for group in current for key in group})
184
+ values[name] = {
185
+ key: tuple(value for group in current for value in group.get(key, ()))
186
+ for key in keys
187
+ }
188
+ try:
189
+ return cls(**values)
190
+ except TypeError:
191
+ return SimpleNamespace(**values)
192
+
193
+
194
+ def load_model_and_task_for_inference(
195
+ checkpoint: str,
196
+ *,
197
+ model_factory: Callable[[Mapping[str, Any]], nn.Module],
198
+ task_factory: Callable[[Mapping[str, Any]], Any] | None = None,
199
+ map_location: torch.device | str = "cpu",
200
+ ) -> tuple[nn.Module, Any]:
201
+ """Build a model from checkpoint metadata and load weights only.
202
+
203
+ Factories remain caller-owned because ROOT-GNN construction needs a sample
204
+ graph. The helper deliberately never restores optimizer or lifecycle state.
205
+ """
206
+ checkpoint_data = CheckpointManager.load(checkpoint, map_location=map_location)
207
+ model_config = checkpoint_data.get("model_config")
208
+ if not isinstance(model_config, Mapping):
209
+ raise ValueError("checkpoint does not contain reconstructable model_config")
210
+ model = model_factory(model_config)
211
+ load_model_weights(model, checkpoint_data)
212
+ task_config = checkpoint_data.get("task_config") or {}
213
+ if task_factory is not None:
214
+ task = task_factory(task_config)
215
+ else:
216
+ task_type = str(task_config.get("type", "")).lower()
217
+ if "multi" in task_type:
218
+ task = MulticlassClassificationTask(
219
+ **_task_kwargs(task_config, "absolute_weights")
220
+ )
221
+ elif "binary" in task_type:
222
+ task = BinaryClassificationTask(
223
+ **_task_kwargs(task_config, "absolute_weights", "threshold")
224
+ )
225
+ else:
226
+ raise ValueError("checkpoint does not contain a supported task_config")
227
+ return model, task
228
+
229
+
230
+ def _task_kwargs(config: Mapping[str, Any], *names: str) -> dict[str, Any]:
231
+ return {name: config[name] for name in names if name in config}
src/gnn4colliders/inference/results.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed outputs produced by model inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import torch
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PredictionResult:
14
+ """Predictions in the exact order in which the loader yielded events.
15
+
16
+ Tensor fields are CPU tensors. Keeping the tensors detached and on the
17
+ CPU makes a result safe to retain after prediction and avoids tying its
18
+ lifetime to a CUDA context.
19
+ """
20
+
21
+ sample_ids: tuple[str, ...]
22
+ logits: torch.Tensor
23
+ scores: torch.Tensor
24
+ predictions: torch.Tensor
25
+ labels: torch.Tensor | None = None
26
+ metadata: Any | None = None
27
+ extra: Mapping[str, Sequence[Any]] = field(default_factory=dict)
28
+
29
+ @property
30
+ def fold(self) -> torch.Tensor | None:
31
+ return _metadata_value(self.metadata, "fold")
32
+
33
+ @property
34
+ def weights(self) -> torch.Tensor | None:
35
+ return _metadata_value(self.metadata, "weight")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class EvaluationResult:
40
+ """A prediction result and metrics calculated over the complete split."""
41
+
42
+ predictions: PredictionResult
43
+ metrics: Mapping[str, float]
44
+
45
+
46
+ def _metadata_value(metadata: Any, name: str) -> torch.Tensor | None:
47
+ if metadata is None:
48
+ return None
49
+ value = getattr(metadata, name, None)
50
+ if value is None and isinstance(metadata, Mapping):
51
+ value = metadata.get(name)
52
+ return None if value is None else torch.as_tensor(value).cpu()
src/gnn4colliders/inference/root_writer.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional ROOT score output, kept separate from model execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ from .results import PredictionResult
11
+
12
+ _ENTRY_SUFFIX = re.compile(r":(\d+)$")
13
+
14
+
15
+ def write_root_scores(
16
+ result: PredictionResult,
17
+ *,
18
+ input_path: str | Path,
19
+ output_path: str | Path,
20
+ tree_name: str,
21
+ score_prefix: str = "score",
22
+ ) -> Path:
23
+ """Clone a ROOT tree's columns and add aligned score branches.
24
+
25
+ Sample IDs ending in ``:<entry>`` are aligned by source entry. If IDs do
26
+ not carry an entry suffix, the result must contain one score per source
27
+ entry; this makes accidental misalignment fail loudly. Unselected source
28
+ entries receive NaN scores and ``selection_pass=0``.
29
+ """
30
+ try:
31
+ import uproot
32
+ except ImportError as error: # pragma: no cover - optional dependency
33
+ raise ImportError("write_root_scores requires uproot") from error
34
+
35
+ source = Path(input_path)
36
+ target = Path(output_path)
37
+ target.parent.mkdir(parents=True, exist_ok=True)
38
+ with uproot.open(source) as root_file:
39
+ tree = root_file[tree_name]
40
+ columns = tree.arrays(library="np")
41
+ entries = int(tree.num_entries)
42
+
43
+ indices: list[int] = []
44
+ for sample_id in result.sample_ids:
45
+ match = _ENTRY_SUFFIX.search(sample_id)
46
+ if match is None:
47
+ if len(result.sample_ids) != entries:
48
+ raise ValueError(
49
+ "ROOT alignment requires sample IDs ending in ':<entry>' "
50
+ "when predictions do not cover every source entry"
51
+ )
52
+ indices = list(range(entries))
53
+ break
54
+ indices.append(int(match.group(1)))
55
+ if len(indices) != len(result.sample_ids) or any(
56
+ index < 0 or index >= entries for index in indices
57
+ ):
58
+ raise ValueError("prediction sample IDs contain invalid ROOT entry indices")
59
+
60
+ selection_pass = np.zeros(entries, dtype=np.int32)
61
+ selection_pass[indices] = 1
62
+ output = dict(columns)
63
+ output["selection_pass"] = selection_pass
64
+ scores = np.asarray(result.scores)
65
+ if scores.ndim == 1:
66
+ branch = np.full(entries, np.nan, dtype=np.float32)
67
+ branch[indices] = scores.astype(np.float32, copy=False)
68
+ output[score_prefix] = branch
69
+ elif scores.ndim == 2:
70
+ for class_index in range(scores.shape[1]):
71
+ branch = np.full(entries, np.nan, dtype=np.float32)
72
+ branch[indices] = scores[:, class_index].astype(np.float32, copy=False)
73
+ output[f"{score_prefix}_class_{class_index}"] = branch
74
+ else:
75
+ raise ValueError("scores must have shape [N] or [N, C]")
76
+
77
+ with uproot.recreate(target) as root_file:
78
+ root_file[tree_name] = output
79
+ return target
src/gnn4colliders/inference/writers.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Serialization of prediction results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ from .results import PredictionResult
11
+
12
+
13
+ def write_npz(result: PredictionResult, path: str | Path) -> Path:
14
+ """Write named prediction fields to a compressed NumPy archive."""
15
+ target = Path(path)
16
+ target.parent.mkdir(parents=True, exist_ok=True)
17
+ values: dict[str, Any] = {
18
+ "sample_id": np.asarray(result.sample_ids, dtype=str),
19
+ "logits": result.logits.numpy(),
20
+ "scores": result.scores.numpy(),
21
+ "predictions": result.predictions.numpy(),
22
+ }
23
+ if result.labels is not None:
24
+ values["labels"] = result.labels.numpy()
25
+ for name, attribute in (("fold", "fold"), ("weight", "weights")):
26
+ value = getattr(result, attribute)
27
+ if value is not None:
28
+ values[name] = value.numpy()
29
+ for name, value in result.extra.items():
30
+ values[name] = np.asarray(value)
31
+ np.savez_compressed(target, **values)
32
+ return target
tests/unit/export/test_onnx_export.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ pytest.importorskip("dgl")
5
+
6
+ from gnn4colliders.data import EventMetadata, GraphSample, batch_graph_samples
7
+ from gnn4colliders.export import (
8
+ RootGNNExportAdapter,
9
+ export_root_gnn_onnx,
10
+ inputs_from_graph_batch,
11
+ )
12
+ from gnn4colliders.graphs import build_dgl_graph
13
+ from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
14
+
15
+
16
+ def _batch():
17
+ samples = []
18
+ for index, count in enumerate((2, 3)):
19
+ nodes = torch.arange(count * 3, dtype=torch.float32).reshape(count, 3)
20
+ samples.append(
21
+ GraphSample(
22
+ build_dgl_graph(nodes),
23
+ torch.tensor(index),
24
+ torch.tensor([1.0, 2.0]),
25
+ EventMetadata(index, 1.0, f"fixture:{index}"),
26
+ )
27
+ )
28
+ return batch_graph_samples(samples)
29
+
30
+
31
+ def test_export_inputs_preserve_graph_membership_and_edges():
32
+ batch = _batch()
33
+ inputs = inputs_from_graph_batch(batch)
34
+ src, _ = batch.graph.edges(order="eid")
35
+ assert torch.equal(inputs.node_features, batch.graph.ndata["features"])
36
+ assert torch.equal(inputs.edge_features, batch.graph.edata["features"])
37
+ assert torch.equal(inputs.edge_src, src)
38
+ assert inputs.node_batch.tolist() == [0, 0, 1, 1, 1]
39
+ assert inputs.global_features.shape == (2, 2)
40
+
41
+
42
+ @pytest.mark.parametrize("fine_tuned", [False, True])
43
+ def test_tensor_adapter_matches_native_model(fine_tuned):
44
+ batch = _batch()
45
+ model = EdgeNetwork(batch.graph, batch.global_features, 8, 3, 2, 2).eval()
46
+ if fine_tuned:
47
+ model = FineTunedEdgeNetwork.from_pretrained(model, 1).eval()
48
+ inputs = inputs_from_graph_batch(batch)
49
+ with torch.inference_mode():
50
+ native = model(batch.graph, batch.global_features)
51
+ adapted = RootGNNExportAdapter(model)(*inputs.as_tuple())
52
+ torch.testing.assert_close(adapted, native, rtol=1e-5, atol=1e-5)
53
+
54
+
55
+ def test_onnx_export_is_optional_and_writes_validated_model(tmp_path):
56
+ pytest.importorskip("onnx")
57
+ pytest.importorskip("onnxruntime")
58
+ batch = _batch()
59
+ model = EdgeNetwork(batch.graph, batch.global_features, 8, 3, 2, 2).eval()
60
+ output = export_root_gnn_onnx(model, batch, tmp_path / "model.onnx")
61
+ assert output.exists()
62
+ assert output.with_suffix(".onnx.json").exists()
tests/unit/inference/test_predictor.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from types import SimpleNamespace
2
+
3
+ import numpy as np
4
+ import torch
5
+ from torch import nn
6
+
7
+ from gnn4colliders.data import BatchMetadata
8
+ from gnn4colliders.inference import Predictor, write_npz
9
+ from gnn4colliders.tasks import BinaryClassificationTask, MulticlassClassificationTask
10
+
11
+
12
+ class _Model(nn.Module):
13
+ def __init__(self, outputs):
14
+ super().__init__()
15
+ self.outputs = torch.as_tensor(outputs, dtype=torch.float32)
16
+ self.dropout = nn.Dropout(0.9)
17
+
18
+ def forward(self, batch):
19
+ count = (
20
+ batch.labels.shape[0]
21
+ if hasattr(batch, "labels")
22
+ else len(batch.metadata.sample_id)
23
+ )
24
+ return self.outputs[batch.offset : batch.offset + count]
25
+
26
+
27
+ def _loader(outputs, labels, *, offset=0, ids=("a", "b")):
28
+ metadata = BatchMetadata(
29
+ fold=torch.tensor([0, 1]),
30
+ weight=torch.tensor([1.0, 2.0]),
31
+ sample_id=ids,
32
+ extra={"source_file": ("events.root", "events.root")},
33
+ )
34
+ return [
35
+ SimpleNamespace(
36
+ labels=torch.tensor(labels),
37
+ metadata=metadata,
38
+ offset=offset,
39
+ to=lambda device: SimpleNamespace(
40
+ labels=torch.tensor(labels), metadata=metadata, offset=offset
41
+ ),
42
+ )
43
+ ]
44
+
45
+
46
+ def test_predictor_preserves_named_metadata_and_uses_task_scores():
47
+ loader = _loader([[-2.0], [2.0]], [0, 1])
48
+ result = Predictor(_Model([[-2.0], [2.0]]), BinaryClassificationTask()).predict(
49
+ loader
50
+ )
51
+ assert result.sample_ids == ("a", "b")
52
+ assert result.scores.tolist() == [
53
+ torch.sigmoid(torch.tensor(-2.0)).item(),
54
+ torch.sigmoid(torch.tensor(2.0)).item(),
55
+ ]
56
+ assert result.predictions.tolist() == [False, True]
57
+ assert result.weights.tolist() == [1.0, 2.0]
58
+ assert result.extra["source_file"] == ("events.root", "events.root")
59
+
60
+
61
+ def test_predictor_supports_unlabeled_multiclass_batches():
62
+ metadata = BatchMetadata(
63
+ fold=torch.tensor([0]), weight=torch.tensor([1.0]), sample_id=("x",)
64
+ )
65
+ batch = SimpleNamespace(metadata=metadata, offset=0, to=lambda device: batch)
66
+ result = Predictor(
67
+ _Model([[1.0, 3.0, 2.0]]), MulticlassClassificationTask()
68
+ ).predict([batch])
69
+ assert result.labels is None
70
+ assert result.scores.shape == (1, 3)
71
+ assert result.predictions.tolist() == [1]
72
+
73
+
74
+ def test_npz_round_trip(tmp_path):
75
+ result = Predictor(_Model([[-1.0], [1.0]]), BinaryClassificationTask()).predict(
76
+ _loader([[-1.0], [1.0]], [0, 1])
77
+ )
78
+ path = write_npz(result, tmp_path / "predictions.npz")
79
+ with np.load(path) as values:
80
+ assert values["sample_id"].tolist() == ["a", "b"]
81
+ np.testing.assert_array_equal(values["logits"], result.logits.numpy())
82
+ np.testing.assert_array_equal(values["labels"], result.labels.numpy())
83
+ np.testing.assert_array_equal(values["fold"], result.fold.numpy())