ci: support macOS core development
#8
by ho22joshua - opened
- .github/workflows/ci.yml +12 -0
- README.md +37 -7
- docs/architecture.md +9 -6
- docs/configuration.md +3 -1
- docs/migration.md +7 -0
- docs/testing.md +4 -0
- pyproject.toml +9 -7
- src/gnn4colliders/config/application.py +3 -0
- src/gnn4colliders/config/factories.py +11 -3
- src/gnn4colliders/configs/environment/macos.yaml +2 -0
- src/gnn4colliders/configs/model/root_gnn/edge_network.yaml +1 -0
- src/gnn4colliders/configs/model/root_gnn/fine_tuned_edge_network.yaml +1 -0
- src/gnn4colliders/data/__init__.py +2 -0
- src/gnn4colliders/data/graph_dataset.py +105 -12
- src/gnn4colliders/models/root_gnn/__init__.py +7 -1
- src/gnn4colliders/models/root_gnn/tensor_edge_network.py +173 -0
- tests/parity/conftest.py +14 -0
- tests/unit/graphs/test_dgl_graph.py +3 -0
- tests/unit/models/root_gnn/test_tensor_edge_network.py +67 -0
- tests/unit/training/test_trainer.py +13 -0
- tests/unit/validation/test_artifacts.py +0 -1
- uv.lock +0 -0
.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`)
|
| 23 |
-
|
| 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.
|
| 33 |
-
|
| 34 |
-
required.
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 50 |
-
|
| 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=
|
|
|
|
|
|
|
| 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 =
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
[
|
| 57 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 =
|
| 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 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
if not samples:
|
| 59 |
raise ValueError("cannot batch an empty sequence")
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|