ho22joshua commited on
Commit
97f7eaf
·
1 Parent(s): be26097

test: add parity and end-to-end workflow coverage

Browse files
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)