ci: support macOS core development

#8
.github/workflows/ci.yml CHANGED
@@ -34,6 +34,18 @@ jobs:
34
  - run: uv sync --dev
35
  - run: uv run pytest
36
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  package:
38
  name: package
39
  runs-on: ubuntu-latest
 
34
  - run: uv sync --dev
35
  - run: uv run pytest
36
 
37
+ test-macos:
38
+ name: test (macOS ROOT-GNN CPU)
39
+ runs-on: macos-14
40
+ steps:
41
+ - uses: actions/checkout@v4
42
+ - uses: astral-sh/setup-uv@v6
43
+ with:
44
+ version: "0.8.x"
45
+ enable-cache: true
46
+ - run: uv sync --dev --extra root-gnn
47
+ - run: uv run pytest tests/unit tests/integration
48
+
49
  package:
50
  name: package
51
  runs-on: ubuntu-latest
README.md CHANGED
@@ -19,20 +19,50 @@ historical checkpoint investigation, not a supported runtime backend.
19
 
20
  ## Installation
21
 
22
- The supported development environment is Python 3.12 (`>=3.12,<3.13`), with
23
- PyTorch 2.2.2 and the optional ROOT-GNN stack DGL 2.4.0. The canonical setup
24
- is:
25
 
26
  ```bash
 
 
 
 
27
  uv sync --dev --extra root-gnn
28
  ```
29
 
30
  The core package can be installed without DGL when only shared data or task
31
  code is needed. ROOT-GNN models, graph construction, and ROOT-GNN parity tests
32
- require the `root-gnn` extra. The DGL extra uses the validated CUDA 12.1 wheel
33
- source configured in `pyproject.toml`; a compatible NVIDIA driver is still
34
- required. Do not add site-specific CUDA, Slurm, or filesystem paths to model
35
- or task configuration.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  ## Quick start
38
 
 
19
 
20
  ## Installation
21
 
22
+ The supported development environment is Python 3.12 (`>=3.12,<3.13`). Core
23
+ development is supported on macOS and Linux:
 
24
 
25
  ```bash
26
+ # macOS (Apple Silicon): CPU ROOT-GNN development and tests
27
+ uv sync --dev --extra root-gnn
28
+
29
+ # Linux x86_64 with an NVIDIA GPU: validated ROOT-GNN development
30
  uv sync --dev --extra root-gnn
31
  ```
32
 
33
  The core package can be installed without DGL when only shared data or task
34
  code is needed. ROOT-GNN models, graph construction, and ROOT-GNN parity tests
35
+ require the `root-gnn` extra. On Linux x86_64, it uses the validated CUDA 12.1
36
+ wheels configured in `pyproject.toml`; a compatible NVIDIA driver is still
37
+ required. On Apple Silicon macOS, it installs the CPU DGL wheel, supporting
38
+ local graph/cache development. The default ROOT-GNN backend performs training
39
+ with native PyTorch graph tensors, so it runs on Apple MPS, NVIDIA CUDA, and
40
+ CPU; DGL remains a cache and legacy-compatibility adapter. Do not add
41
+ site-specific CUDA, Slurm, or filesystem paths to model or task configuration.
42
+
43
+ Use the MPS profile on an Apple Silicon Mac:
44
+
45
+ ```bash
46
+ uv run gnn4colliders train environment=macos
47
+ ```
48
+
49
+ ## Data samples
50
+
51
+ ROOT inputs are available from the
52
+ [HWresearch/Delphes dataset](https://huggingface.co/datasets/HWresearch/Delphes).
53
+ Download the 64-event smoke-test sample with the Hugging Face CLI:
54
+
55
+ ```bash
56
+ hf download HWresearch/Delphes testing/ttH_NLO_64.root \
57
+ --repo-type dataset --local-dir data/raw
58
+ ```
59
+
60
+ The sample is `data/raw/testing/ttH_NLO_64.root`, has tree name `output`, and
61
+ is suitable for checking the prepare/train workflow. The dataset also provides
62
+ larger process-specific ROOT samples under `samples/`, derived datasets under
63
+ `derived/`, and analysis-specific ntuples under `analyses/`. These data are
64
+ intentionally ignored by Git; inspect a selected ROOT file's tree and branches
65
+ before writing its preparation configuration.
66
 
67
  ## Quick start
68
 
docs/architecture.md CHANGED
@@ -28,7 +28,7 @@ shared collider feature construction
28
  | `data` | ROOT/Awkward ingestion, event samples, metadata, graph caches, folds, and batching |
29
  | `features` | Shared collider-object features and derived physics quantities |
30
  | `graphs` | Topology, edge features, and the DGL representation adapter |
31
- | `models/root_gnn` | ROOT-GNN encoders, message passing, classifier, and transfer boundary |
32
  | `tasks` | Loss, score/prediction, labels, weights, and metrics |
33
  | `training` | Optimizer lifecycle, validation, early stopping, checkpointing, and reproducibility |
34
  | `inference` | Ordered prediction/evaluation and NPZ/ROOT output adapters |
@@ -36,8 +36,12 @@ shared collider feature construction
36
  | `config` / `cli` | Semantic Hydra composition and thin user-facing commands |
37
 
38
  `EventSample` is shared infrastructure, not a ROOT-GNN object. `GraphSample`
39
- is the current representation-specific adapter. This separation is the
40
- extension point for a future sequence/token representation.
 
 
 
 
41
 
42
  The new public metadata contract is named `EventMetadata(fold, weight,
43
  sample_id, extra)`. The legacy positional tracking tensor is accepted only by
@@ -46,9 +50,8 @@ Level-2 graph cache; replacing it with a universal cache would couple future
46
  model families to DGL.
47
 
48
  For deployment, a prepared `GraphBatch` can pass through the isolated
49
- `RootGNNExportAdapter` into an ONNX model. This is an inference boundary only;
50
- the native model continues to consume DGL graphs and ONNX does not read ROOT
51
- or construct collider features.
52
 
53
  ## Current public workflow
54
 
 
28
  | `data` | ROOT/Awkward ingestion, event samples, metadata, graph caches, folds, and batching |
29
  | `features` | Shared collider-object features and derived physics quantities |
30
  | `graphs` | Topology, edge features, and the DGL representation adapter |
31
+ | `models/root_gnn` | ROOT-GNN encoders, portable tensor message passing, classifier, and transfer boundary |
32
  | `tasks` | Loss, score/prediction, labels, weights, and metrics |
33
  | `training` | Optimizer lifecycle, validation, early stopping, checkpointing, and reproducibility |
34
  | `inference` | Ordered prediction/evaluation and NPZ/ROOT output adapters |
 
36
  | `config` / `cli` | Semantic Hydra composition and thin user-facing commands |
37
 
38
  `EventSample` is shared infrastructure, not a ROOT-GNN object. `GraphSample`
39
+ is the current representation-specific adapter. At batching, the default
40
+ ROOT-GNN backend converts cached DGL graphs into `TensorGraph` values with
41
+ explicit node/edge indices and graph membership. Native message passing then
42
+ uses only PyTorch tensor operations, allowing the same model to train on CPU,
43
+ CUDA, and Apple MPS. This separation is the extension point for a future
44
+ sequence/token representation.
45
 
46
  The new public metadata contract is named `EventMetadata(fold, weight,
47
  sample_id, extra)`. The legacy positional tracking tensor is accepted only by
 
50
  model families to DGL.
51
 
52
  For deployment, a prepared `GraphBatch` can pass through the isolated
53
+ `RootGNNExportAdapter` into an ONNX model. ONNX is still an inference boundary:
54
+ it does not read ROOT or construct collider features.
 
55
 
56
  ## Current public workflow
57
 
docs/configuration.md CHANGED
@@ -88,7 +88,9 @@ to load weights into a new task head; these options are mutually exclusive.
88
 
89
  ## Environment profiles
90
 
91
- `environment=local` selects CPU by default. `environment=perlmutter` selects
 
 
92
  the CUDA device and a conventional output-root pattern. Profiles should hold
93
  device/output policy only; site-specific module loads and filesystem paths
94
  belong in a launcher or shell environment.
 
88
 
89
  ## Environment profiles
90
 
91
+ `environment=local` selects CPU by default. `environment=macos` selects the
92
+ Apple MPS device for the portable PyTorch ROOT-GNN backend.
93
+ `environment=perlmutter` selects
94
  the CUDA device and a conventional output-root pattern. Profiles should hold
95
  device/output policy only; site-specific module loads and filesystem paths
96
  belong in a launcher or shell environment.
docs/migration.md CHANGED
@@ -104,6 +104,13 @@ active bug when nonempty globals are supplied (`Pretrained_Output` ignores its
104
  argument); parity therefore characterizes its supported no-global path, while
105
  the rewritten model supports both global and fallback modes.
106
 
 
 
 
 
 
 
 
107
  Port `models.GCN.Edge_Network` first. Preserve constructor parameters,
108
  `forward(graph, global_feats)`, feature keys, processor order, MLP LayerNorm
109
  placement, and logits shape. Compare intermediate and final tensors on fixed
 
104
  argument); parity therefore characterizes its supported no-global path, while
105
  the rewritten model supports both global and fallback modes.
106
 
107
+ Task 22 adds `TensorEdgeNetwork`, a native PyTorch realization of the same
108
+ message-passing equations. It receives explicit node/edge index tensors from
109
+ the batching boundary, retains parameter names for legacy checkpoint loading,
110
+ and has fixed-weight CPU parity coverage against the DGL backend. It is the
111
+ default training backend for CPU, CUDA, and Apple MPS; DGL remains available as
112
+ a cache and compatibility adapter during the transition.
113
+
114
  Port `models.GCN.Edge_Network` first. Preserve constructor parameters,
115
  `forward(graph, global_feats)`, feature keys, processor order, MLP LayerNorm
116
  placement, and logits shape. Compare intermediate and final tensors on fixed
docs/testing.md CHANGED
@@ -19,6 +19,10 @@ uv run pytest -m gpu -v
19
  GNN4COLLIDERS_ROOT_FIXTURE=/path/to/reduced.root uv run pytest -m real_data -v
20
  ```
21
 
 
 
 
 
22
  Unit tests use deterministic, small tensors and generated ROOT files. DGL,
23
  ONNX, CUDA, distributed execution, and reduced real-data fixtures remain
24
  optional layers. Tests should assert public contracts and scientific
 
19
  GNN4COLLIDERS_ROOT_FIXTURE=/path/to/reduced.root uv run pytest -m real_data -v
20
  ```
21
 
22
+ On Apple Silicon macOS, `uv sync --dev --extra root-gnn` supports CPU
23
+ ROOT-GNN unit and integration testing. CUDA and the required ROOT-GNN parity
24
+ gate are validated on Linux x86_64 with the `root-gnn` extra installed.
25
+
26
  Unit tests use deterministic, small tensors and generated ROOT files. DGL,
27
  ONNX, CUDA, distributed execution, and reduced real-data fixtures remain
28
  optional layers. Tests should assert public contracts and scientific
pyproject.toml CHANGED
@@ -19,7 +19,10 @@ dependencies = [
19
 
20
  [project.optional-dependencies]
21
  root-gnn = [
22
- "dgl==2.4.0+cu121",
 
 
 
23
  ]
24
  onnx = [
25
  "onnx>=1.16,<2",
@@ -50,12 +53,11 @@ version = { attr = "gnn4colliders.__version__" }
50
  "gnn4colliders.configs" = ["*.yaml", "*/*.yaml", "*/*/*.yaml"]
51
 
52
  [tool.uv.sources]
53
- torch = { index = "pytorch-cu121" }
54
- dgl = { index = "dgl" }
55
-
56
- [tool.uv]
57
- environments = [
58
- "sys_platform == 'linux' and platform_machine == 'x86_64' and python_full_version >= '3.12' and python_full_version < '3.13'",
59
  ]
60
 
61
  [[tool.uv.index]]
 
19
 
20
  [project.optional-dependencies]
21
  root-gnn = [
22
+ "dgl==2.4.0+cu121; sys_platform == 'linux' and platform_machine == 'x86_64'",
23
+ "dgl==2.2.0; sys_platform == 'darwin' and platform_machine == 'arm64'",
24
+ "pydantic>=2,<3; sys_platform == 'darwin' and platform_machine == 'arm64'",
25
+ "torchdata>=0.7,<0.8; sys_platform == 'darwin' and platform_machine == 'arm64'",
26
  ]
27
  onnx = [
28
  "onnx>=1.16,<2",
 
53
  "gnn4colliders.configs" = ["*.yaml", "*/*.yaml", "*/*/*.yaml"]
54
 
55
  [tool.uv.sources]
56
+ torch = [
57
+ { index = "pytorch-cu121", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
58
+ ]
59
+ dgl = [
60
+ { index = "dgl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
 
61
  ]
62
 
63
  [[tool.uv.index]]
src/gnn4colliders/config/application.py CHANGED
@@ -72,6 +72,7 @@ def loaders(
72
  validation_folds=frozenset(config.data.splits.validation_folds),
73
  test_folds=frozenset(config.data.splits.test_folds),
74
  )
 
75
  result = {}
76
  for name in ("train", "validation", "test"):
77
  selected = GraphDataset(select_split(samples.samples, split, name))
@@ -87,6 +88,7 @@ def loaders(
87
  context,
88
  shuffle=bool(config.data.shuffle) if name == "train" else False,
89
  seed=int(config.data.seed),
 
90
  )
91
  if loader_type is DistributedGraphDataLoader
92
  else loader_type(
@@ -94,6 +96,7 @@ def loaders(
94
  int(config.data.batch_size),
95
  shuffle=bool(config.data.shuffle) if name == "train" else False,
96
  seed=int(config.data.seed),
 
97
  )
98
  )
99
  return result
 
72
  validation_folds=frozenset(config.data.splits.validation_folds),
73
  test_folds=frozenset(config.data.splits.test_folds),
74
  )
75
+ tensor_graphs = str(config.model.get("backend", "torch")).lower() == "torch"
76
  result = {}
77
  for name in ("train", "validation", "test"):
78
  selected = GraphDataset(select_split(samples.samples, split, name))
 
88
  context,
89
  shuffle=bool(config.data.shuffle) if name == "train" else False,
90
  seed=int(config.data.seed),
91
+ tensor_graphs=tensor_graphs,
92
  )
93
  if loader_type is DistributedGraphDataLoader
94
  else loader_type(
 
96
  int(config.data.batch_size),
97
  shuffle=bool(config.data.shuffle) if name == "train" else False,
98
  seed=int(config.data.seed),
99
+ tensor_graphs=tensor_graphs,
100
  )
101
  )
102
  return result
src/gnn4colliders/config/factories.py CHANGED
@@ -8,7 +8,11 @@ from typing import Any
8
  import torch
9
 
10
  from gnn4colliders.distributed import DistributedContext
11
- from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
 
 
 
 
12
  from gnn4colliders.tasks import BinaryClassificationTask, MulticlassClassificationTask
13
  from gnn4colliders.training import (
14
  EarlyStopping,
@@ -40,6 +44,10 @@ def build_model(
40
  )
41
  if family != "root_gnn":
42
  raise ValueError(f"unsupported model family {family!r}")
 
 
 
 
43
  kwargs = {
44
  "hid_size": int(_get(config, "hid_size", _get(config, "hidden_dim", 128))),
45
  "out_size": int(_get(config, "out_size", 1)),
@@ -50,7 +58,7 @@ def build_model(
50
  "dropout": float(_get(config, "dropout", 0.0)),
51
  }
52
  if name in {"edge_network", "edge"}:
53
- return EdgeNetwork(sample_graph, sample_global, **kwargs)
54
  if name in {"fine_tuned_edge_network", "finetuned_edge_network", "fine_tuned"}:
55
  checkpoint = _get(config, "pretrained_checkpoint")
56
  if not checkpoint:
@@ -65,7 +73,7 @@ def build_model(
65
  base_config.update(
66
  {key: value for key, value in kwargs.items() if key != "out_size"}
67
  )
68
- backbone = EdgeNetwork(
69
  sample_graph,
70
  sample_global,
71
  hid_size=int(base_config.get("hid_size", kwargs["hid_size"])),
 
8
  import torch
9
 
10
  from gnn4colliders.distributed import DistributedContext
11
+ from gnn4colliders.models.root_gnn import (
12
+ EdgeNetwork,
13
+ FineTunedEdgeNetwork,
14
+ TensorEdgeNetwork,
15
+ )
16
  from gnn4colliders.tasks import BinaryClassificationTask, MulticlassClassificationTask
17
  from gnn4colliders.training import (
18
  EarlyStopping,
 
44
  )
45
  if family != "root_gnn":
46
  raise ValueError(f"unsupported model family {family!r}")
47
+ backend = str(_get(config, "backend", "torch")).lower()
48
+ if backend not in {"torch", "dgl"}:
49
+ raise ValueError(f"unsupported root_gnn backend {backend!r}")
50
+ model_class = TensorEdgeNetwork if backend == "torch" else EdgeNetwork
51
  kwargs = {
52
  "hid_size": int(_get(config, "hid_size", _get(config, "hidden_dim", 128))),
53
  "out_size": int(_get(config, "out_size", 1)),
 
58
  "dropout": float(_get(config, "dropout", 0.0)),
59
  }
60
  if name in {"edge_network", "edge"}:
61
+ return model_class(sample_graph, sample_global, **kwargs)
62
  if name in {"fine_tuned_edge_network", "finetuned_edge_network", "fine_tuned"}:
63
  checkpoint = _get(config, "pretrained_checkpoint")
64
  if not checkpoint:
 
73
  base_config.update(
74
  {key: value for key, value in kwargs.items() if key != "out_size"}
75
  )
76
+ backbone = model_class(
77
  sample_graph,
78
  sample_global,
79
  hid_size=int(base_config.get("hid_size", kwargs["hid_size"])),
src/gnn4colliders/configs/environment/macos.yaml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ device: mps
2
+ output_root: outputs
src/gnn4colliders/configs/model/root_gnn/edge_network.yaml CHANGED
@@ -1,4 +1,5 @@
1
  family: root_gnn
 
2
  name: edge_network
3
  hid_size: 128
4
  out_size: 12
 
1
  family: root_gnn
2
+ backend: torch
3
  name: edge_network
4
  hid_size: 128
5
  out_size: 12
src/gnn4colliders/configs/model/root_gnn/fine_tuned_edge_network.yaml CHANGED
@@ -1,4 +1,5 @@
1
  family: root_gnn
 
2
  name: fine_tuned_edge_network
3
  hid_size: 128
4
  out_size: 1
 
1
  family: root_gnn
2
+ backend: torch
3
  name: fine_tuned_edge_network
4
  hid_size: 128
5
  out_size: 1
src/gnn4colliders/data/__init__.py CHANGED
@@ -8,6 +8,7 @@ from .graph_dataset import (
8
  GraphDataLoader,
9
  GraphDataset,
10
  GraphSample,
 
11
  batch_graph_samples,
12
  )
13
  from .metadata import (
@@ -26,6 +27,7 @@ __all__ = [
26
  "EventMetadata",
27
  "BatchMetadata",
28
  "GraphSample",
 
29
  "GraphBatch",
30
  "GraphDataset",
31
  "GraphDataLoader",
 
8
  GraphDataLoader,
9
  GraphDataset,
10
  GraphSample,
11
+ TensorGraph,
12
  batch_graph_samples,
13
  )
14
  from .metadata import (
 
27
  "EventMetadata",
28
  "BatchMetadata",
29
  "GraphSample",
30
+ "TensorGraph",
31
  "GraphBatch",
32
  "GraphDataset",
33
  "GraphDataLoader",
src/gnn4colliders/data/graph_dataset.py CHANGED
@@ -52,18 +52,102 @@ class GraphBatch:
52
  )
53
 
54
 
55
- def batch_graph_samples(samples: Sequence[GraphSample]) -> GraphBatch:
56
- """Batch samples in the supplied order using DGL's native batching."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  if not samples:
59
  raise ValueError("cannot batch an empty sequence")
60
- try:
61
- import dgl
62
- except ImportError as error: # pragma: no cover - optional dependency
63
- raise ImportError(
64
- "batch_graph_samples requires the 'root-gnn' extra"
65
- ) from error
66
- graphs = dgl.batch([sample.graph for sample in samples])
 
67
  labels = torch.stack([torch.as_tensor(sample.label) for sample in samples])
68
  globals_ = [sample.global_features for sample in samples]
69
  if any(value is None for value in globals_):
@@ -97,6 +181,7 @@ class GraphDataLoader:
97
  shuffle: bool = False,
98
  drop_last: bool = False,
99
  seed: int = 12345,
 
100
  ) -> None:
101
  if batch_size < 1:
102
  raise ValueError("batch_size must be positive")
@@ -105,6 +190,7 @@ class GraphDataLoader:
105
  self.shuffle = shuffle
106
  self.drop_last = drop_last
107
  self.seed = seed
 
108
  self.epoch = 0
109
 
110
  def set_epoch(self, epoch: int) -> None:
@@ -126,7 +212,7 @@ class GraphDataLoader:
126
  self.dataset[int(index)]
127
  for index in indices[start : start + self.batch_size]
128
  ]
129
- yield batch_graph_samples(selected)
130
 
131
 
132
  class DistributedGraphDataLoader(GraphDataLoader):
@@ -146,9 +232,15 @@ class DistributedGraphDataLoader(GraphDataLoader):
146
  shuffle=False,
147
  drop_last=False,
148
  seed=12345,
 
149
  ):
150
  super().__init__(
151
- dataset, batch_size, shuffle=shuffle, drop_last=drop_last, seed=seed
 
 
 
 
 
152
  )
153
  from gnn4colliders.distributed import DistributedIndices
154
 
@@ -181,7 +273,8 @@ class DistributedGraphDataLoader(GraphDataLoader):
181
  [
182
  self.dataset[index]
183
  for index in indices[start : start + self.batch_size]
184
- ]
 
185
  )
186
 
187
 
 
52
  )
53
 
54
 
55
+ @dataclass(frozen=True)
56
+ class TensorGraph:
57
+ """Batched graph tensors usable on CPU, CUDA, and Apple's MPS backend."""
58
+
59
+ node_features: torch.Tensor
60
+ edge_features: torch.Tensor
61
+ edge_src: torch.Tensor
62
+ edge_dst: torch.Tensor
63
+ node_batch: torch.Tensor
64
+ edge_batch: torch.Tensor
65
+ node_counts: torch.Tensor
66
+ edge_counts: torch.Tensor
67
+
68
+ def to(self, device: torch.device | str) -> "TensorGraph":
69
+ return TensorGraph(
70
+ *(
71
+ value.to(device)
72
+ for value in (
73
+ self.node_features,
74
+ self.edge_features,
75
+ self.edge_src,
76
+ self.edge_dst,
77
+ self.node_batch,
78
+ self.edge_batch,
79
+ self.node_counts,
80
+ self.edge_counts,
81
+ )
82
+ )
83
+ )
84
+
85
+
86
+ def _tensor_graphs(samples: Sequence[GraphSample]) -> TensorGraph:
87
+ node_features: list[torch.Tensor] = []
88
+ edge_features: list[torch.Tensor] = []
89
+ edge_src: list[torch.Tensor] = []
90
+ edge_dst: list[torch.Tensor] = []
91
+ node_counts: list[int] = []
92
+ edge_counts: list[int] = []
93
+ offset = 0
94
+ for sample in samples:
95
+ graph = sample.graph
96
+ if isinstance(graph, TensorGraph):
97
+ if graph.node_counts.numel() != 1:
98
+ raise ValueError("each GraphSample must contain one graph")
99
+ nodes, edges = graph.node_features, graph.edge_features
100
+ src, dst = graph.edge_src, graph.edge_dst
101
+ else:
102
+ try:
103
+ nodes = graph.ndata["features"]
104
+ edges = graph.edata["features"]
105
+ src, dst = graph.edges(order="eid")
106
+ except AttributeError as error:
107
+ raise TypeError(
108
+ "GraphSample graph must be DGL-like or TensorGraph"
109
+ ) from error
110
+ node_count, edge_count = int(nodes.shape[0]), int(edges.shape[0])
111
+ node_features.append(nodes)
112
+ edge_features.append(edges)
113
+ edge_src.append(src.to(dtype=torch.long) + offset)
114
+ edge_dst.append(dst.to(dtype=torch.long) + offset)
115
+ node_counts.append(node_count)
116
+ edge_counts.append(edge_count)
117
+ offset += node_count
118
+ node_count_tensor = torch.tensor(node_counts, dtype=torch.long)
119
+ edge_count_tensor = torch.tensor(edge_counts, dtype=torch.long)
120
+ return TensorGraph(
121
+ node_features=torch.cat(node_features),
122
+ edge_features=torch.cat(edge_features),
123
+ edge_src=torch.cat(edge_src),
124
+ edge_dst=torch.cat(edge_dst),
125
+ node_batch=torch.repeat_interleave(
126
+ torch.arange(len(samples)), node_count_tensor
127
+ ),
128
+ edge_batch=torch.repeat_interleave(
129
+ torch.arange(len(samples)), edge_count_tensor
130
+ ),
131
+ node_counts=node_count_tensor,
132
+ edge_counts=edge_count_tensor,
133
+ )
134
+
135
+
136
+ def batch_graph_samples(
137
+ samples: Sequence[GraphSample], *, tensor_graphs: bool = False
138
+ ) -> GraphBatch:
139
+ """Batch samples into either DGL or portable tensor graph batches."""
140
 
141
  if not samples:
142
  raise ValueError("cannot batch an empty sequence")
143
+ if tensor_graphs:
144
+ graphs = _tensor_graphs(samples)
145
+ else:
146
+ try:
147
+ import dgl
148
+ except ImportError as error: # pragma: no cover - optional dependency
149
+ raise ImportError("DGL batching requires the 'root-gnn' extra") from error
150
+ graphs = dgl.batch([sample.graph for sample in samples])
151
  labels = torch.stack([torch.as_tensor(sample.label) for sample in samples])
152
  globals_ = [sample.global_features for sample in samples]
153
  if any(value is None for value in globals_):
 
181
  shuffle: bool = False,
182
  drop_last: bool = False,
183
  seed: int = 12345,
184
+ tensor_graphs: bool = False,
185
  ) -> None:
186
  if batch_size < 1:
187
  raise ValueError("batch_size must be positive")
 
190
  self.shuffle = shuffle
191
  self.drop_last = drop_last
192
  self.seed = seed
193
+ self.tensor_graphs = tensor_graphs
194
  self.epoch = 0
195
 
196
  def set_epoch(self, epoch: int) -> None:
 
212
  self.dataset[int(index)]
213
  for index in indices[start : start + self.batch_size]
214
  ]
215
+ yield batch_graph_samples(selected, tensor_graphs=self.tensor_graphs)
216
 
217
 
218
  class DistributedGraphDataLoader(GraphDataLoader):
 
232
  shuffle=False,
233
  drop_last=False,
234
  seed=12345,
235
+ tensor_graphs=False,
236
  ):
237
  super().__init__(
238
+ dataset,
239
+ batch_size,
240
+ shuffle=shuffle,
241
+ drop_last=drop_last,
242
+ seed=seed,
243
+ tensor_graphs=tensor_graphs,
244
  )
245
  from gnn4colliders.distributed import DistributedIndices
246
 
 
273
  [
274
  self.dataset[index]
275
  for index in indices[start : start + self.batch_size]
276
+ ],
277
+ tensor_graphs=self.tensor_graphs,
278
  )
279
 
280
 
src/gnn4colliders/models/root_gnn/__init__.py CHANGED
@@ -1,6 +1,12 @@
1
  """ROOT-GNN model components."""
2
 
3
  from .edge_network import EdgeNetwork
 
4
  from .transfer import FineTunedEdgeNetwork, load_legacy_edge_network_state_dict
5
 
6
- __all__ = ["EdgeNetwork", "FineTunedEdgeNetwork", "load_legacy_edge_network_state_dict"]
 
 
 
 
 
 
1
  """ROOT-GNN model components."""
2
 
3
  from .edge_network import EdgeNetwork
4
+ from .tensor_edge_network import TensorEdgeNetwork
5
  from .transfer import FineTunedEdgeNetwork, load_legacy_edge_network_state_dict
6
 
7
+ __all__ = [
8
+ "EdgeNetwork",
9
+ "TensorEdgeNetwork",
10
+ "FineTunedEdgeNetwork",
11
+ "load_legacy_edge_network_state_dict",
12
+ ]
src/gnn4colliders/models/root_gnn/tensor_edge_network.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Portable tensor-only ROOT-GNN message passing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+ from gnn4colliders.data import TensorGraph
11
+
12
+ from .blocks import make_mlp
13
+
14
+
15
+ def _sum_by_group(
16
+ values: torch.Tensor, groups: torch.Tensor, count: int
17
+ ) -> torch.Tensor:
18
+ result = values.new_zeros((count, values.shape[1]))
19
+ return result.index_add_(0, groups, values)
20
+
21
+
22
+ def _mean_by_group(
23
+ values: torch.Tensor, groups: torch.Tensor, count: int
24
+ ) -> torch.Tensor:
25
+ sums = _sum_by_group(values, groups, count)
26
+ sizes = _sum_by_group(values.new_ones((values.shape[0], 1)), groups, count)
27
+ return sums / sizes.clamp_min(1)
28
+
29
+
30
+ class TensorEdgeNetwork(nn.Module):
31
+ """ROOT-GNN using only PyTorch tensors, including on MPS and CUDA.
32
+
33
+ Parameter names and update equations intentionally match :class:`EdgeNetwork`
34
+ so historical checkpoints load without conversion.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ sample_graph: TensorGraph,
40
+ sample_global: torch.Tensor | None,
41
+ hid_size: int,
42
+ out_size: int,
43
+ n_layers: int,
44
+ n_proc_steps: int,
45
+ dropout: float = 0.0,
46
+ **_: Any,
47
+ ) -> None:
48
+ super().__init__()
49
+ if not isinstance(sample_graph, TensorGraph):
50
+ raise TypeError("TensorEdgeNetwork requires a TensorGraph sample")
51
+ if n_proc_steps < 0:
52
+ raise ValueError("n_proc_steps must be non-negative")
53
+ global_width = 0 if sample_global is None else sample_global.shape[1]
54
+ self.has_global = global_width != 0
55
+ encoder_global_width = global_width or 1
56
+ self.hid_size = hid_size
57
+ self.n_layers = n_layers
58
+ self.n_proc_steps = n_proc_steps
59
+ self.node_feature_size = int(sample_graph.node_features.shape[1])
60
+ self.edge_feature_size = int(sample_graph.edge_features.shape[1])
61
+ self.global_feature_size = int(global_width)
62
+ self.dropout = float(dropout)
63
+ self.node_encoder = make_mlp(
64
+ self.node_feature_size, hid_size, hid_size, n_layers, dropout=dropout
65
+ )
66
+ self.edge_encoder = make_mlp(
67
+ self.edge_feature_size, hid_size, hid_size, n_layers, dropout=dropout
68
+ )
69
+ self.global_encoder = make_mlp(
70
+ encoder_global_width, hid_size, hid_size, n_layers, dropout=dropout
71
+ )
72
+ self.node_update = make_mlp(
73
+ 3 * hid_size, hid_size, hid_size, n_layers, dropout=dropout
74
+ )
75
+ self.edge_update = make_mlp(
76
+ 4 * hid_size, hid_size, hid_size, n_layers, dropout=dropout
77
+ )
78
+ self.global_update = make_mlp(
79
+ 3 * hid_size, hid_size, hid_size, n_layers, dropout=dropout
80
+ )
81
+ self.global_decoder = make_mlp(
82
+ hid_size, hid_size, hid_size, n_layers, dropout=dropout
83
+ )
84
+ self.classifier = nn.Linear(hid_size, out_size)
85
+
86
+ @property
87
+ def classify(self) -> nn.Linear:
88
+ return self.classifier
89
+
90
+ def checkpoint_config(self) -> dict[str, Any]:
91
+ return {
92
+ "family": "root_gnn",
93
+ "class": type(self).__name__,
94
+ "backend": "torch",
95
+ "node_feature_size": self.node_feature_size,
96
+ "edge_feature_size": self.edge_feature_size,
97
+ "global_feature_size": self.global_feature_size,
98
+ "hid_size": self.hid_size,
99
+ "out_size": int(self.classifier.out_features),
100
+ "n_layers": self.n_layers,
101
+ "n_proc_steps": self.n_proc_steps,
102
+ "dropout": self.dropout,
103
+ }
104
+
105
+ def _globals(
106
+ self, graph: TensorGraph, global_features: torch.Tensor | None
107
+ ) -> torch.Tensor:
108
+ if not self.has_global:
109
+ return graph.node_counts[:, None].to(
110
+ device=graph.node_features.device, dtype=torch.float32
111
+ )
112
+ if global_features is None:
113
+ raise ValueError("global_features are required for this model")
114
+ if global_features.ndim == 1:
115
+ if graph.node_counts.numel() != 1:
116
+ raise ValueError("one-dimensional global_features require one graph")
117
+ return global_features.unsqueeze(0)
118
+ return global_features
119
+
120
+ def forward_features(
121
+ self, graph: TensorGraph, global_features: torch.Tensor | None = None
122
+ ) -> torch.Tensor:
123
+ if hasattr(graph, "graph") and hasattr(graph, "global_features"):
124
+ global_features, graph = graph.global_features, graph.graph
125
+ if not isinstance(graph, TensorGraph):
126
+ raise TypeError("TensorEdgeNetwork requires TensorGraph inputs")
127
+ node_h = self.node_encoder(graph.node_features)
128
+ edge_h = self.edge_encoder(graph.edge_features)
129
+ global_h = self.global_encoder(self._globals(graph, global_features))
130
+ graph_count = int(graph.node_counts.numel())
131
+ for _ in range(self.n_proc_steps):
132
+ edge_h = self.edge_update(
133
+ torch.cat(
134
+ (
135
+ edge_h,
136
+ node_h[graph.edge_src],
137
+ node_h[graph.edge_dst],
138
+ global_h[graph.edge_batch],
139
+ ),
140
+ dim=1,
141
+ )
142
+ )
143
+ node_h = self.node_update(
144
+ torch.cat(
145
+ (
146
+ node_h,
147
+ _sum_by_group(edge_h, graph.edge_dst, node_h.shape[0]),
148
+ global_h[graph.node_batch],
149
+ ),
150
+ dim=1,
151
+ )
152
+ )
153
+ global_h = self.global_update(
154
+ torch.cat(
155
+ (
156
+ global_h,
157
+ _mean_by_group(node_h, graph.node_batch, graph_count),
158
+ _mean_by_group(edge_h, graph.edge_batch, graph_count),
159
+ ),
160
+ dim=1,
161
+ )
162
+ )
163
+ return self.global_decoder(global_h)
164
+
165
+ def forward(
166
+ self, graph: TensorGraph, global_features: torch.Tensor | None = None
167
+ ) -> torch.Tensor:
168
+ return self.classifier(self.forward_features(graph, global_features))
169
+
170
+ def representation(
171
+ self, graph: TensorGraph, global_features: torch.Tensor | None = None
172
+ ) -> torch.Tensor:
173
+ return self.forward_features(graph, global_features)
tests/parity/conftest.py CHANGED
@@ -68,6 +68,11 @@ def legacy_dataset_module_without_dgl():
68
  try:
69
  import dgl # noqa: F401
70
  except ImportError:
 
 
 
 
 
71
  dgl_module = types.ModuleType("dgl")
72
  dgl_data_module = types.ModuleType("dgl.data")
73
  dgl_data_module.DGLDataset = type("DGLDataset", (), {})
@@ -79,6 +84,15 @@ def legacy_dataset_module_without_dgl():
79
  matplotlib_module.pyplot = matplotlib_pyplot_module
80
  sys.modules["matplotlib"] = matplotlib_module
81
  sys.modules["matplotlib.pyplot"] = matplotlib_pyplot_module
 
 
 
 
 
 
 
 
 
82
  from root_gnn_base import dataset
83
 
84
  return dataset
 
68
  try:
69
  import dgl # noqa: F401
70
  except ImportError:
71
+ missing = object()
72
+ originals = {
73
+ name: sys.modules.get(name, missing)
74
+ for name in ("dgl", "dgl.data", "matplotlib", "matplotlib.pyplot")
75
+ }
76
  dgl_module = types.ModuleType("dgl")
77
  dgl_data_module = types.ModuleType("dgl.data")
78
  dgl_data_module.DGLDataset = type("DGLDataset", (), {})
 
84
  matplotlib_module.pyplot = matplotlib_pyplot_module
85
  sys.modules["matplotlib"] = matplotlib_module
86
  sys.modules["matplotlib.pyplot"] = matplotlib_pyplot_module
87
+ try:
88
+ from root_gnn_base import dataset
89
+ finally:
90
+ for name, original in originals.items():
91
+ if original is missing:
92
+ sys.modules.pop(name, None)
93
+ else:
94
+ sys.modules[name] = original
95
+ return dataset
96
  from root_gnn_base import dataset
97
 
98
  return dataset
tests/unit/graphs/test_dgl_graph.py CHANGED
@@ -1,7 +1,10 @@
 
1
  import torch
2
 
3
  from gnn4colliders.graphs import build_dgl_graph
4
 
 
 
5
 
6
  def test_build_dgl_graph_attaches_compatible_features():
7
  node_features = torch.tensor(
 
1
+ import pytest
2
  import torch
3
 
4
  from gnn4colliders.graphs import build_dgl_graph
5
 
6
+ pytest.importorskip("dgl")
7
+
8
 
9
  def test_build_dgl_graph_attaches_compatible_features():
10
  node_features = torch.tensor(
tests/unit/models/root_gnn/test_tensor_edge_network.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ import torch
5
+
6
+ from gnn4colliders.data import EventMetadata, GraphDataset, GraphSample
7
+ from gnn4colliders.data.graph_dataset import GraphDataLoader
8
+ from gnn4colliders.graphs import build_dgl_graph
9
+ from gnn4colliders.models.root_gnn import EdgeNetwork, TensorEdgeNetwork
10
+
11
+ pytest.importorskip("dgl")
12
+
13
+
14
+ def _batch():
15
+ samples = [
16
+ GraphSample(
17
+ build_dgl_graph(nodes),
18
+ torch.tensor(index),
19
+ torch.tensor([1.0, 2.0]),
20
+ EventMetadata(0, 1.0, str(index)),
21
+ )
22
+ for index, nodes in enumerate(
23
+ (
24
+ torch.tensor([[10.0, 0.2, 0.1], [8.0, -0.3, -0.2]]),
25
+ torch.tensor([[4.0, 0.1, 2.8]]),
26
+ )
27
+ )
28
+ ]
29
+ dgl_batch = next(iter(GraphDataLoader(GraphDataset(samples), 2)))
30
+ tensor_batch = next(
31
+ iter(GraphDataLoader(GraphDataset(samples), 2, tensor_graphs=True))
32
+ )
33
+ return dgl_batch, tensor_batch
34
+
35
+
36
+ def test_tensor_backend_matches_dgl_for_fixed_weights():
37
+ dgl_batch, tensor_batch = _batch()
38
+ dgl_model = EdgeNetwork(
39
+ dgl_batch.graph, dgl_batch.global_features, 8, 3, 2, 2
40
+ ).eval()
41
+ tensor_model = TensorEdgeNetwork(
42
+ tensor_batch.graph, tensor_batch.global_features, 8, 3, 2, 2
43
+ ).eval()
44
+ tensor_model.load_state_dict(dgl_model.state_dict())
45
+ with torch.no_grad():
46
+ expected = dgl_model(dgl_batch.graph, dgl_batch.global_features)
47
+ actual = tensor_model(tensor_batch.graph, tensor_batch.global_features)
48
+ torch.testing.assert_close(actual, expected, rtol=0, atol=0)
49
+ dgl_model(dgl_batch.graph, dgl_batch.global_features).sum().backward()
50
+ tensor_model(tensor_batch.graph, tensor_batch.global_features).sum().backward()
51
+ for expected_parameter, actual_parameter in zip(
52
+ dgl_model.parameters(), tensor_model.parameters(), strict=True
53
+ ):
54
+ torch.testing.assert_close(
55
+ actual_parameter.grad, expected_parameter.grad, rtol=0, atol=0
56
+ )
57
+
58
+
59
+ @pytest.mark.skipif(not torch.backends.mps.is_available(), reason="MPS unavailable")
60
+ def test_tensor_backend_runs_forward_and_backward_on_mps():
61
+ _, batch = _batch()
62
+ model = TensorEdgeNetwork(batch.graph, batch.global_features, 8, 3, 2, 1).to("mps")
63
+ moved = batch.to("mps")
64
+ logits = model(moved.graph, moved.global_features)
65
+ logits.square().mean().backward()
66
+ assert logits.device.type == "mps"
67
+ assert all(parameter.grad is not None for parameter in model.parameters())
tests/unit/training/test_trainer.py CHANGED
@@ -90,3 +90,16 @@ def test_graph_batch_to_cuda_preserves_named_metadata():
90
  assert moved.labels.device.type == "cuda"
91
  assert moved.metadata.weight.device.type == "cuda"
92
  assert moved.metadata.sample_id == ("event:0",)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  assert moved.labels.device.type == "cuda"
91
  assert moved.metadata.weight.device.type == "cuda"
92
  assert moved.metadata.sample_id == ("event:0",)
93
+
94
+
95
+ @pytest.mark.gpu
96
+ @pytest.mark.skipif(not torch.backends.mps.is_available(), reason="MPS unavailable")
97
+ def test_trainer_runs_optimizer_step_on_mps():
98
+ model = TinyModel()
99
+ optimizer = build_optimizer(model, learning_rate=0.01)
100
+ trainer = Trainer(model, BinaryClassificationTask(), optimizer, device="mps")
101
+ before = trainer.model.linear.weight.detach().clone()
102
+ result = trainer.train_batch(_batch([1, 2], [0, 1]))
103
+ assert torch.isfinite(torch.tensor(result.loss))
104
+ assert trainer.model.linear.weight.device.type == "mps"
105
+ assert not torch.equal(before, trainer.model.linear.weight.detach())
tests/unit/validation/test_artifacts.py CHANGED
@@ -1,7 +1,6 @@
1
  from __future__ import annotations
2
 
3
  import numpy as np
4
-
5
  from validation.artifacts import ValidationArtifact, load_artifact, save_artifact
6
  from validation.compare import compare_artifacts
7
 
 
1
  from __future__ import annotations
2
 
3
  import numpy as np
 
4
  from validation.artifacts import ValidationArtifact, load_artifact, save_artifact
5
  from validation.compare import compare_artifacts
6
 
uv.lock CHANGED
The diff for this file is too large to render. See raw diff