Commit ·
97f7eaf
1
Parent(s): be26097
test: add parity and end-to-end workflow coverage
Browse files- examples/README.md +18 -0
- scripts/dev/shrink_root_sample.py +57 -0
- scripts/dev/smoke_end_to_end.py +131 -0
- tests/parity/conftest.py +88 -0
- tests/parity/test_legacy_node_features.py +100 -0
- tests/parity/test_model_parity.py +110 -0
examples/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Examples
|
| 2 |
+
|
| 3 |
+
The supported experiment interface is the root CLI with Hydra configuration;
|
| 4 |
+
examples do not define a second application framework. The repository's
|
| 5 |
+
reference groups cover multiclass pretraining, binary fine-tuning, resume,
|
| 6 |
+
evaluation, and NPZ prediction. Start with the commands in the top-level
|
| 7 |
+
[`README.md`](../README.md), then copy a config group into a local config file
|
| 8 |
+
when a dataset needs more than command-line overrides.
|
| 9 |
+
|
| 10 |
+
For a complete CPU smoke workflow using generated temporary ROOT data:
|
| 11 |
+
|
| 12 |
+
```bash
|
| 13 |
+
uv run python scripts/dev/smoke_end_to_end.py
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
Values such as input ROOT paths, cache locations, and checkpoint paths are
|
| 17 |
+
deliberately local placeholders. Production datasets and generated outputs do
|
| 18 |
+
not belong in this directory.
|
scripts/dev/shrink_root_sample.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create a small ROOT fixture from a Delphes sample."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import uproot
|
| 9 |
+
|
| 10 |
+
BRANCHES = (
|
| 11 |
+
"jet_pt",
|
| 12 |
+
"jet_eta",
|
| 13 |
+
"jet_phi",
|
| 14 |
+
"jet_btag",
|
| 15 |
+
"ph_pt",
|
| 16 |
+
"ph_eta",
|
| 17 |
+
"ph_phi",
|
| 18 |
+
"ele_pt",
|
| 19 |
+
"ele_eta",
|
| 20 |
+
"ele_phi",
|
| 21 |
+
"ele_charge",
|
| 22 |
+
"mu_pt",
|
| 23 |
+
"mu_eta",
|
| 24 |
+
"mu_phi",
|
| 25 |
+
"mu_charge",
|
| 26 |
+
"MET_met",
|
| 27 |
+
"MET_phi",
|
| 28 |
+
"weight",
|
| 29 |
+
"Number",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def shrink_sample(source: Path, target: Path, entries: int) -> None:
|
| 34 |
+
"""Copy the active branches and first ``entries`` events to ``target``."""
|
| 35 |
+
if entries < 1:
|
| 36 |
+
raise ValueError("entries must be positive")
|
| 37 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
with uproot.open(source) as source_file:
|
| 39 |
+
arrays = source_file["output"].arrays(
|
| 40 |
+
BRANCHES, entry_start=0, entry_stop=entries, library="ak"
|
| 41 |
+
)
|
| 42 |
+
with uproot.recreate(target) as target_file:
|
| 43 |
+
target_file["output"] = arrays
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main() -> None:
|
| 47 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 48 |
+
parser.add_argument("source", type=Path)
|
| 49 |
+
parser.add_argument("target", type=Path)
|
| 50 |
+
parser.add_argument("--entries", type=int, default=64)
|
| 51 |
+
args = parser.parse_args()
|
| 52 |
+
shrink_sample(args.source, args.target, args.entries)
|
| 53 |
+
print(f"Wrote {args.entries} events to {args.target}")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
main()
|
scripts/dev/smoke_end_to_end.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run the documented new-stack workflow on a tiny temporary ROOT sample.
|
| 2 |
+
|
| 3 |
+
This is an integration smoke test, not a physics example. It requires the
|
| 4 |
+
validated ``root-gnn`` extra and uses only the public CLI for preparation,
|
| 5 |
+
training, evaluation, and prediction.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import subprocess
|
| 11 |
+
import sys
|
| 12 |
+
import tempfile
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import awkward as ak
|
| 16 |
+
import numpy as np
|
| 17 |
+
import uproot
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _run(root: Path, *arguments: str) -> None:
|
| 21 |
+
command = [sys.executable, "-m", "gnn4colliders.cli", *arguments]
|
| 22 |
+
subprocess.run(command, cwd=root, check=True)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _write_root(path: Path) -> None:
|
| 26 |
+
events = 5
|
| 27 |
+
with uproot.recreate(path) as output:
|
| 28 |
+
output["Events"] = {
|
| 29 |
+
"jet_pt": ak.Array([[40.0, 25.0]] * events),
|
| 30 |
+
"jet_eta": ak.Array([[-1.0, 1.0]] * events),
|
| 31 |
+
"jet_phi": ak.Array([[-2.5, 2.5]] * events),
|
| 32 |
+
"eventNumber": np.arange(events, dtype=np.int64),
|
| 33 |
+
"weight": np.ones(events, dtype=np.float32),
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _prepare(root_file: Path, cache: Path) -> None:
|
| 38 |
+
_run(
|
| 39 |
+
root_file.parent,
|
| 40 |
+
"prepare",
|
| 41 |
+
f"data.files=[{root_file}]",
|
| 42 |
+
"data.tree_name=Events",
|
| 43 |
+
f"data.cache.path={cache}",
|
| 44 |
+
'data.feature_branches=[["jet_pt"],["jet_eta"],["jet_phi"],CALC_E,[1.0],[0.0],NODE_TYPE]',
|
| 45 |
+
"data.object_types=[vector]",
|
| 46 |
+
"data.scales=[1,1,1,1,1,1,1]",
|
| 47 |
+
"data.fold_var=eventNumber",
|
| 48 |
+
"data.weight_var=weight",
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _train(root: Path, cache: Path, output: Path, *extra: str) -> Path:
|
| 53 |
+
_run(
|
| 54 |
+
root,
|
| 55 |
+
"train",
|
| 56 |
+
f"data.cache.path={cache}",
|
| 57 |
+
"trainer.max_epochs=1",
|
| 58 |
+
"trainer.device=cpu",
|
| 59 |
+
"data.batch_size=1",
|
| 60 |
+
"model.hid_size=8",
|
| 61 |
+
"model.n_layers=1",
|
| 62 |
+
"model.n_proc_steps=1",
|
| 63 |
+
f"environment.output_root={output}",
|
| 64 |
+
*extra,
|
| 65 |
+
)
|
| 66 |
+
return output / "checkpoints" / "epoch_0000.pt"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def main() -> None:
|
| 70 |
+
try:
|
| 71 |
+
import dgl # noqa: F401
|
| 72 |
+
except ImportError as error: # pragma: no cover - environment-dependent
|
| 73 |
+
raise SystemExit(
|
| 74 |
+
"install the root-gnn extra before running this smoke test"
|
| 75 |
+
) from error
|
| 76 |
+
|
| 77 |
+
with tempfile.TemporaryDirectory(prefix="gnn4colliders-smoke-") as directory:
|
| 78 |
+
root = Path(directory)
|
| 79 |
+
root_file = root / "events.root"
|
| 80 |
+
cache = root / "graphs.pt"
|
| 81 |
+
target_cache = root / "target.pt"
|
| 82 |
+
_write_root(root_file)
|
| 83 |
+
_prepare(root_file, cache)
|
| 84 |
+
pretrained = _train(root, cache, root / "pretrain")
|
| 85 |
+
_prepare(root_file, target_cache)
|
| 86 |
+
fine_tuned = _train(
|
| 87 |
+
root,
|
| 88 |
+
target_cache,
|
| 89 |
+
root / "finetune",
|
| 90 |
+
"model=root_gnn/fine_tuned_edge_network",
|
| 91 |
+
"task=binary_classification",
|
| 92 |
+
f"checkpoint.pretrained={pretrained}",
|
| 93 |
+
"model.freeze_backbone=true",
|
| 94 |
+
)
|
| 95 |
+
_run(
|
| 96 |
+
root,
|
| 97 |
+
"evaluate",
|
| 98 |
+
f"data.cache.path={target_cache}",
|
| 99 |
+
"inference.split=test",
|
| 100 |
+
f"inference.checkpoint={fine_tuned}",
|
| 101 |
+
"model=root_gnn/fine_tuned_edge_network",
|
| 102 |
+
"task=binary_classification",
|
| 103 |
+
f"checkpoint.pretrained={pretrained}",
|
| 104 |
+
"model.hid_size=8",
|
| 105 |
+
"model.n_layers=1",
|
| 106 |
+
"model.n_proc_steps=1",
|
| 107 |
+
"trainer.device=cpu",
|
| 108 |
+
)
|
| 109 |
+
prediction = root / "predictions.npz"
|
| 110 |
+
_run(
|
| 111 |
+
root,
|
| 112 |
+
"predict",
|
| 113 |
+
f"data.cache.path={target_cache}",
|
| 114 |
+
"inference.split=test",
|
| 115 |
+
f"inference.checkpoint={fine_tuned}",
|
| 116 |
+
"model=root_gnn/fine_tuned_edge_network",
|
| 117 |
+
"task=binary_classification",
|
| 118 |
+
f"checkpoint.pretrained={pretrained}",
|
| 119 |
+
"model.hid_size=8",
|
| 120 |
+
"model.n_layers=1",
|
| 121 |
+
"model.n_proc_steps=1",
|
| 122 |
+
f"inference.output={prediction}",
|
| 123 |
+
"trainer.device=cpu",
|
| 124 |
+
)
|
| 125 |
+
if not prediction.is_file():
|
| 126 |
+
raise RuntimeError("smoke workflow did not produce predictions.npz")
|
| 127 |
+
print(f"smoke workflow succeeded in {root}")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
main()
|
tests/parity/conftest.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers for importing the legacy ROOT-GNN implementation in parity tests."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import types
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _dgl_is_importable():
|
| 12 |
+
try:
|
| 13 |
+
import dgl # noqa: F401
|
| 14 |
+
except ImportError:
|
| 15 |
+
return False
|
| 16 |
+
return True
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture(scope="session", autouse=True)
|
| 20 |
+
def require_root_gnn_dependencies():
|
| 21 |
+
if (
|
| 22 |
+
os.environ.get("GNN4COLLIDERS_REQUIRE_ROOT_GNN") == "1"
|
| 23 |
+
and not _dgl_is_importable()
|
| 24 |
+
):
|
| 25 |
+
pytest.fail(
|
| 26 |
+
"ROOT-GNN parity requires an importable DGL installation",
|
| 27 |
+
pytrace=False,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.fixture(scope="session")
|
| 32 |
+
def legacy_dataset_module():
|
| 33 |
+
"""Return the active legacy dataset module when DGL is available."""
|
| 34 |
+
pytest.importorskip("dgl")
|
| 35 |
+
legacy_root = Path(__file__).parents[2] / "legacy" / "root_gnn_dgl"
|
| 36 |
+
legacy_root_string = str(legacy_root)
|
| 37 |
+
if legacy_root_string not in sys.path:
|
| 38 |
+
sys.path.insert(0, legacy_root_string)
|
| 39 |
+
from root_gnn_base import dataset
|
| 40 |
+
|
| 41 |
+
return dataset
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.fixture(scope="session")
|
| 45 |
+
def legacy_model_module():
|
| 46 |
+
"""Return the legacy active model module for fixed-weight parity tests."""
|
| 47 |
+
pytest.importorskip("dgl")
|
| 48 |
+
legacy_root = Path(__file__).parents[2] / "legacy" / "root_gnn_dgl"
|
| 49 |
+
legacy_root_string = str(legacy_root)
|
| 50 |
+
if legacy_root_string not in sys.path:
|
| 51 |
+
sys.path.insert(0, legacy_root_string)
|
| 52 |
+
from models import GCN
|
| 53 |
+
|
| 54 |
+
return GCN
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@pytest.fixture(scope="session")
|
| 58 |
+
def legacy_dataset_module_without_dgl():
|
| 59 |
+
"""Import pure legacy preprocessing with a minimal DGL import shim."""
|
| 60 |
+
legacy_root = Path(__file__).parents[2] / "legacy" / "root_gnn_dgl"
|
| 61 |
+
legacy_root_string = str(legacy_root)
|
| 62 |
+
if legacy_root_string not in sys.path:
|
| 63 |
+
sys.path.insert(0, legacy_root_string)
|
| 64 |
+
try:
|
| 65 |
+
import dgl # noqa: F401
|
| 66 |
+
except ImportError:
|
| 67 |
+
dgl_module = types.ModuleType("dgl")
|
| 68 |
+
dgl_data_module = types.ModuleType("dgl.data")
|
| 69 |
+
dgl_data_module.DGLDataset = type("DGLDataset", (), {})
|
| 70 |
+
dgl_module.data = dgl_data_module
|
| 71 |
+
sys.modules["dgl"] = dgl_module
|
| 72 |
+
sys.modules["dgl.data"] = dgl_data_module
|
| 73 |
+
matplotlib_module = types.ModuleType("matplotlib")
|
| 74 |
+
matplotlib_pyplot_module = types.ModuleType("matplotlib.pyplot")
|
| 75 |
+
matplotlib_module.pyplot = matplotlib_pyplot_module
|
| 76 |
+
sys.modules["matplotlib"] = matplotlib_module
|
| 77 |
+
sys.modules["matplotlib.pyplot"] = matplotlib_pyplot_module
|
| 78 |
+
from root_gnn_base import dataset
|
| 79 |
+
|
| 80 |
+
return dataset
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@pytest.fixture(scope="session")
|
| 84 |
+
def legacy_utils_module(legacy_dataset_module_without_dgl):
|
| 85 |
+
"""Return legacy fold-selection helpers after the package path is set up."""
|
| 86 |
+
from root_gnn_base import utils
|
| 87 |
+
|
| 88 |
+
return utils
|
tests/parity/test_legacy_node_features.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Characterization tests for the active legacy node feature builder."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pytest
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest.fixture
|
| 9 |
+
def event():
|
| 10 |
+
return {
|
| 11 |
+
"jet_pt": np.array([100.0, 50.0], dtype=np.float32),
|
| 12 |
+
"ele_pt": np.array([20.0], dtype=np.float32),
|
| 13 |
+
"mu_pt": np.array([30.0], dtype=np.float32),
|
| 14 |
+
"ph_pt": np.array([40.0], dtype=np.float32),
|
| 15 |
+
"MET_met": np.float32(25.0),
|
| 16 |
+
"jet_eta": np.array([1.0, -0.5], dtype=np.float32),
|
| 17 |
+
"ele_eta": np.array([0.25], dtype=np.float32),
|
| 18 |
+
"mu_eta": np.array([-0.75], dtype=np.float32),
|
| 19 |
+
"ph_eta": np.array([0.5], dtype=np.float32),
|
| 20 |
+
"jet_phi": np.array([3.0, -3.0], dtype=np.float32),
|
| 21 |
+
"ele_phi": np.array([0.2], dtype=np.float32),
|
| 22 |
+
"mu_phi": np.array([-0.4], dtype=np.float32),
|
| 23 |
+
"ph_phi": np.array([1.0], dtype=np.float32),
|
| 24 |
+
"MET_phi": np.float32(-1.2),
|
| 25 |
+
"jet_btag": np.array([0.8, 0.1], dtype=np.float32),
|
| 26 |
+
"ele_charge": np.array([-1.0], dtype=np.float32),
|
| 27 |
+
"mu_charge": np.array([1.0], dtype=np.float32),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.fixture
|
| 32 |
+
def node_schema():
|
| 33 |
+
return (
|
| 34 |
+
[
|
| 35 |
+
["jet_pt", "ele_pt", "mu_pt", "ph_pt", "MET_met"],
|
| 36 |
+
["jet_eta", "ele_eta", "mu_eta", "ph_eta", 0],
|
| 37 |
+
["jet_phi", "ele_phi", "mu_phi", "ph_phi", "MET_phi"],
|
| 38 |
+
"CALC_E",
|
| 39 |
+
["jet_btag", 0, 0, 0, 0],
|
| 40 |
+
[0, "ele_charge", "mu_charge", 0, 0],
|
| 41 |
+
"NODE_TYPE",
|
| 42 |
+
],
|
| 43 |
+
["vector", "vector", "vector", "vector", "single"],
|
| 44 |
+
[0.1, 1, 1, 0.1, 1, 1, 1],
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_node_features_have_active_schema_and_order(
|
| 49 |
+
legacy_dataset_module_without_dgl, event, node_schema
|
| 50 |
+
):
|
| 51 |
+
names, types, scales = node_schema
|
| 52 |
+
features, lengths = legacy_dataset_module_without_dgl.node_features_from_tree(
|
| 53 |
+
event, names, types, torch.tensor(scales, dtype=torch.float32)
|
| 54 |
+
)
|
| 55 |
+
expected = np.array(
|
| 56 |
+
[
|
| 57 |
+
[10.0, 1.0, 3.0, 15.431, 0.8, 0.0, 0.0],
|
| 58 |
+
[5.0, -0.5, -3.0, 5.638, 0.1, 0.0, 0.0],
|
| 59 |
+
[2.0, 0.25, 0.2, 2.063, 0.0, -1.0, 1.0],
|
| 60 |
+
[3.0, -0.75, -0.4, 3.884, 0.0, 1.0, 2.0],
|
| 61 |
+
[4.0, 0.5, 1.0, 4.511, 0.0, 0.0, 3.0],
|
| 62 |
+
[2.5, 0.0, -1.2, 2.500, 0.0, 0.0, 4.0],
|
| 63 |
+
],
|
| 64 |
+
dtype=np.float32,
|
| 65 |
+
)
|
| 66 |
+
assert lengths == [2, 1, 1, 1, 1]
|
| 67 |
+
assert features.shape == (6, 7)
|
| 68 |
+
assert features.dtype == torch.float32
|
| 69 |
+
np.testing.assert_allclose(features.numpy(), expected, rtol=0, atol=2e-3)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_node_feature_builder_preserves_zero_length_vector(
|
| 73 |
+
legacy_dataset_module_without_dgl, node_schema
|
| 74 |
+
):
|
| 75 |
+
names, types, scales = node_schema
|
| 76 |
+
event = {
|
| 77 |
+
"jet_pt": np.array([], dtype=np.float32),
|
| 78 |
+
"ele_pt": np.array([20.0], dtype=np.float32),
|
| 79 |
+
"mu_pt": np.array([], dtype=np.float32),
|
| 80 |
+
"ph_pt": np.array([], dtype=np.float32),
|
| 81 |
+
"MET_met": np.float32(25.0),
|
| 82 |
+
"jet_btag": np.array([], dtype=np.float32),
|
| 83 |
+
"ele_charge": np.array([-1.0], dtype=np.float32),
|
| 84 |
+
"mu_charge": np.array([], dtype=np.float32),
|
| 85 |
+
"jet_eta": np.array([], dtype=np.float32),
|
| 86 |
+
"ele_eta": np.array([0.25], dtype=np.float32),
|
| 87 |
+
"mu_eta": np.array([], dtype=np.float32),
|
| 88 |
+
"ph_eta": np.array([], dtype=np.float32),
|
| 89 |
+
"jet_phi": np.array([], dtype=np.float32),
|
| 90 |
+
"ele_phi": np.array([0.2], dtype=np.float32),
|
| 91 |
+
"mu_phi": np.array([], dtype=np.float32),
|
| 92 |
+
"ph_phi": np.array([], dtype=np.float32),
|
| 93 |
+
"MET_phi": np.float32(-1.2),
|
| 94 |
+
}
|
| 95 |
+
features, lengths = legacy_dataset_module_without_dgl.node_features_from_tree(
|
| 96 |
+
event, names, types, torch.tensor(scales)
|
| 97 |
+
)
|
| 98 |
+
assert lengths == [0, 1, 0, 0, 1]
|
| 99 |
+
assert features.shape == (2, 7)
|
| 100 |
+
assert features[:, 6].tolist() == [1.0, 4.0]
|
tests/parity/test_model_parity.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fixed-weight parity tests for the active legacy and rewritten models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
from gnn4colliders.graphs import build_dgl_graph
|
| 9 |
+
from gnn4colliders.models.root_gnn import (
|
| 10 |
+
EdgeNetwork,
|
| 11 |
+
FineTunedEdgeNetwork,
|
| 12 |
+
load_legacy_edge_network_state_dict,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
pytest.importorskip("dgl")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _graph_and_globals():
|
| 19 |
+
graph = build_dgl_graph(
|
| 20 |
+
torch.tensor(
|
| 21 |
+
[[10.0, 0.2, 0.1], [8.0, -0.3, -0.2], [4.0, 0.1, 2.8]],
|
| 22 |
+
dtype=torch.float32,
|
| 23 |
+
)
|
| 24 |
+
)
|
| 25 |
+
return graph, torch.tensor([[1.0, 2.0]], dtype=torch.float32)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _new_model(graph, globals_):
|
| 29 |
+
return EdgeNetwork(graph, globals_, 8, 3, 2, 2, dropout=0.0).eval()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_edge_network_matches_legacy_forward_and_representation(
|
| 33 |
+
legacy_model_module,
|
| 34 |
+
):
|
| 35 |
+
graph, globals_ = _graph_and_globals()
|
| 36 |
+
new_model = _new_model(graph, globals_)
|
| 37 |
+
legacy_model = legacy_model_module.Edge_Network(
|
| 38 |
+
graph, globals_, 8, 3, 2, 2, dropout=0.0
|
| 39 |
+
).eval()
|
| 40 |
+
legacy_state = {
|
| 41 |
+
key.replace("classifier.", "classify."): value
|
| 42 |
+
for key, value in new_model.state_dict().items()
|
| 43 |
+
}
|
| 44 |
+
legacy_model.load_state_dict(legacy_state)
|
| 45 |
+
|
| 46 |
+
with torch.no_grad():
|
| 47 |
+
new_representation = new_model.forward_features(graph, globals_)
|
| 48 |
+
legacy_representation = legacy_model.representation(graph.clone(), globals_)[1]
|
| 49 |
+
new_logits = new_model(graph, globals_)
|
| 50 |
+
legacy_logits = legacy_model(graph.clone(), globals_)
|
| 51 |
+
assert torch.equal(new_representation, legacy_representation)
|
| 52 |
+
assert torch.equal(new_logits, legacy_logits)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_transfer_forward_matches_legacy_transfer(legacy_model_module, tmp_path):
|
| 56 |
+
graph, _ = _graph_and_globals()
|
| 57 |
+
globals_ = torch.empty((0, 0), dtype=torch.float32)
|
| 58 |
+
new_model = EdgeNetwork(graph, globals_, 8, 3, 2, 2, dropout=0.0).eval()
|
| 59 |
+
legacy_pretrained = legacy_model_module.Edge_Network(
|
| 60 |
+
graph, globals_, 8, 3, 2, 2, dropout=0.0
|
| 61 |
+
).eval()
|
| 62 |
+
legacy_state = {
|
| 63 |
+
key.replace("classifier.", "classify."): value
|
| 64 |
+
for key, value in new_model.state_dict().items()
|
| 65 |
+
}
|
| 66 |
+
legacy_pretrained.load_state_dict(legacy_state)
|
| 67 |
+
checkpoint_path = tmp_path / "pretrained.pt"
|
| 68 |
+
torch.save({"model_state_dict": legacy_pretrained.state_dict()}, checkpoint_path)
|
| 69 |
+
|
| 70 |
+
config = {
|
| 71 |
+
"module": "models.GCN",
|
| 72 |
+
"class": "Edge_Network",
|
| 73 |
+
"args": {
|
| 74 |
+
"hid_size": 8,
|
| 75 |
+
"out_size": 3,
|
| 76 |
+
"n_layers": 2,
|
| 77 |
+
"n_proc_steps": 2,
|
| 78 |
+
"dropout": 0.0,
|
| 79 |
+
},
|
| 80 |
+
}
|
| 81 |
+
legacy_transfer = legacy_model_module.Transferred_Learning_Finetuning(
|
| 82 |
+
str(checkpoint_path), config, graph, globals_, 8, 1, 2, 2, dropout=0.0
|
| 83 |
+
).eval()
|
| 84 |
+
transfer = FineTunedEdgeNetwork.from_pretrained(new_model, 1)
|
| 85 |
+
transfer.classifier.load_state_dict(legacy_transfer.classify.state_dict())
|
| 86 |
+
|
| 87 |
+
with torch.no_grad():
|
| 88 |
+
expected = legacy_transfer(graph.clone(), None)
|
| 89 |
+
actual = transfer(graph, None)
|
| 90 |
+
assert torch.equal(actual, expected)
|
| 91 |
+
assert torch.equal(
|
| 92 |
+
transfer.representation(graph, None), transfer.forward_features(graph, None)
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_legacy_prefix_loader_handles_checkpoint_prefixes():
|
| 97 |
+
graph, globals_ = _graph_and_globals()
|
| 98 |
+
source = _new_model(graph, globals_)
|
| 99 |
+
target = _new_model(graph, globals_)
|
| 100 |
+
prefixed = {
|
| 101 |
+
"module._orig_mod." + key.replace("classifier.", "classify."): value
|
| 102 |
+
for key, value in source.state_dict().items()
|
| 103 |
+
}
|
| 104 |
+
load_legacy_edge_network_state_dict(target, {"model_state_dict": prefixed})
|
| 105 |
+
for actual, expected in zip(target.parameters(), source.parameters()):
|
| 106 |
+
assert torch.equal(actual, expected)
|
| 107 |
+
transfer = FineTunedEdgeNetwork.from_pretrained(
|
| 108 |
+
source, 1, state_dict={"model_state_dict": prefixed}
|
| 109 |
+
)
|
| 110 |
+
assert isinstance(transfer.backbone.classifier, torch.nn.Identity)
|