Commit ·
2c516a8
1
Parent(s): 13fd35c
feat: add inference and ONNX export adapters
Browse files- docs/export.md +36 -0
- src/gnn4colliders/export/__init__.py +21 -0
- src/gnn4colliders/export/onnx.py +375 -0
- src/gnn4colliders/inference/__init__.py +15 -0
- src/gnn4colliders/inference/predictor.py +231 -0
- src/gnn4colliders/inference/results.py +52 -0
- src/gnn4colliders/inference/root_writer.py +79 -0
- src/gnn4colliders/inference/writers.py +32 -0
- tests/unit/export/test_onnx_export.py +62 -0
- tests/unit/inference/test_predictor.py +83 -0
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())
|