ho22joshua commited on
Commit
a61ae40
·
1 Parent(s): 3c84acc

feat: implement shared collider feature construction

Browse files
src/gnn4colliders/features/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Collider-domain feature transformations shared across model families."""
2
+
3
+ from .objects import NODE_FEATURE_NAMES, build_node_features, build_object_features
4
+
5
+ __all__ = ["NODE_FEATURE_NAMES", "build_node_features", "build_object_features"]
src/gnn4colliders/features/objects.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Architecture-independent collider-object feature construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from numbers import Number
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import torch
11
+
12
+ NODE_FEATURE_NAMES = (
13
+ "pt",
14
+ "eta",
15
+ "phi",
16
+ "energy",
17
+ "btag",
18
+ "charge",
19
+ "node_type",
20
+ )
21
+
22
+ _CALCULATED_ENERGY = "CALC_E"
23
+ _NODE_TYPE = "NODE_TYPE"
24
+
25
+
26
+ def _as_feature_tensor(values: list[Any], dtype: torch.dtype) -> torch.Tensor:
27
+ return torch.as_tensor(np.asarray(values, dtype=np.float32), dtype=dtype)
28
+
29
+
30
+ def build_node_features(
31
+ event: Mapping[str, Any],
32
+ feature_branches: Sequence[Any],
33
+ object_types: Sequence[str],
34
+ scales: Sequence[Number] | torch.Tensor,
35
+ *,
36
+ dtype: torch.dtype = torch.float32,
37
+ ) -> tuple[torch.Tensor, list[int]]:
38
+ """Build dense node features from one collider event.
39
+
40
+ ``feature_branches`` follows the active legacy schema: each item describes
41
+ one output column and contains one branch name or constant per object
42
+ type. ``object_types`` contains ``"vector"`` or ``"single"`` for each
43
+ object type. Rows are concatenated in object-type order.
44
+
45
+ The returned tensor has columns in :data:`NODE_FEATURE_NAMES` order for
46
+ the active schema, and the second result records rows contributed by each
47
+ object type. Inputs are read but never modified.
48
+ """
49
+ if not feature_branches:
50
+ return torch.empty((0, 0), dtype=dtype), []
51
+ first_column = feature_branches[0]
52
+ if len(first_column) != len(object_types):
53
+ raise ValueError("one branch specification is required per object type")
54
+
55
+ lengths: list[int] = []
56
+ for branch, object_type in zip(first_column, object_types):
57
+ if object_type == "single":
58
+ lengths.append(1)
59
+ elif object_type == "vector":
60
+ lengths.append(len(event[branch]))
61
+ else:
62
+ raise ValueError(f"unknown object type: {object_type!r}")
63
+
64
+ columns: list[torch.Tensor] = []
65
+ for column_index, specification in enumerate(feature_branches):
66
+ if specification == _CALCULATED_ENERGY:
67
+ columns.append(columns[0] * torch.cosh(columns[1]))
68
+ continue
69
+ if specification == _NODE_TYPE:
70
+ values = [
71
+ object_type_index
72
+ for object_type_index, length in enumerate(lengths)
73
+ for _ in range(length)
74
+ ]
75
+ columns.append(torch.tensor(values, dtype=dtype))
76
+ continue
77
+ if len(specification) != len(object_types):
78
+ raise ValueError(
79
+ f"feature column {column_index} has the wrong number of branches"
80
+ )
81
+
82
+ values: list[Any] = []
83
+ for object_type_index, (length, branch, object_type) in enumerate(
84
+ zip(lengths, specification, object_types)
85
+ ):
86
+ if isinstance(branch, Number):
87
+ values.extend([branch] * length)
88
+ elif branch == _CALCULATED_ENERGY:
89
+ start = sum(lengths[:object_type_index])
90
+ stop = start + length
91
+ values.extend(
92
+ (
93
+ columns[0][start:stop] * torch.cosh(columns[1][start:stop])
94
+ ).tolist()
95
+ )
96
+ elif object_type == "single":
97
+ values.append(event[branch])
98
+ else:
99
+ values.extend(event[branch])
100
+ columns.append(_as_feature_tensor(values, dtype))
101
+
102
+ features = torch.stack(columns, dim=1)
103
+ scale_tensor = torch.as_tensor(scales, dtype=dtype)
104
+ if scale_tensor.ndim != 1 or scale_tensor.numel() != features.shape[1]:
105
+ raise ValueError("scales must contain one value per feature column")
106
+ return features * scale_tensor, lengths
107
+
108
+
109
+ def build_object_features(
110
+ event: Mapping[str, Any],
111
+ feature_branches: Sequence[Any],
112
+ object_types: Sequence[str],
113
+ scales: Sequence[Number] | torch.Tensor,
114
+ *,
115
+ dtype: torch.dtype = torch.float32,
116
+ ) -> tuple[torch.Tensor, list[int]]:
117
+ """Alias for :func:`build_node_features` using domain-neutral wording."""
118
+ return build_node_features(
119
+ event, feature_branches, object_types, scales, dtype=dtype
120
+ )
tests/parity/test_new_node_features.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import torch
3
+
4
+ from gnn4colliders.features import build_node_features
5
+
6
+
7
+ def test_new_builder_matches_legacy_builder(legacy_dataset_module_without_dgl):
8
+ event = {
9
+ "jet_pt": np.array([100.0, 50.0], dtype=np.float32),
10
+ "ele_pt": np.array([20.0], dtype=np.float32),
11
+ "mu_pt": np.array([30.0], dtype=np.float32),
12
+ "ph_pt": np.array([40.0], dtype=np.float32),
13
+ "MET_met": np.float32(25.0),
14
+ "jet_eta": np.array([1.0, -0.5], dtype=np.float32),
15
+ "ele_eta": np.array([0.25], dtype=np.float32),
16
+ "mu_eta": np.array([-0.75], dtype=np.float32),
17
+ "ph_eta": np.array([0.5], dtype=np.float32),
18
+ "jet_phi": np.array([3.0, -3.0], dtype=np.float32),
19
+ "ele_phi": np.array([0.2], dtype=np.float32),
20
+ "mu_phi": np.array([-0.4], dtype=np.float32),
21
+ "ph_phi": np.array([1.0], dtype=np.float32),
22
+ "MET_phi": np.float32(-1.2),
23
+ "jet_btag": np.array([0.8, 0.1], dtype=np.float32),
24
+ "ele_charge": np.array([-1.0], dtype=np.float32),
25
+ "mu_charge": np.array([1.0], dtype=np.float32),
26
+ }
27
+ names = [
28
+ ["jet_pt", "ele_pt", "mu_pt", "ph_pt", "MET_met"],
29
+ ["jet_eta", "ele_eta", "mu_eta", "ph_eta", 0],
30
+ ["jet_phi", "ele_phi", "mu_phi", "ph_phi", "MET_phi"],
31
+ "CALC_E",
32
+ ["jet_btag", 0, 0, 0, 0],
33
+ [0, "ele_charge", "mu_charge", 0, 0],
34
+ "NODE_TYPE",
35
+ ]
36
+ object_types = ["vector", "vector", "vector", "vector", "single"]
37
+ scales = torch.tensor([0.1, 1, 1, 0.1, 1, 1, 1])
38
+ expected, expected_lengths = (
39
+ legacy_dataset_module_without_dgl.node_features_from_tree(
40
+ event, names, object_types, scales
41
+ )
42
+ )
43
+ actual, actual_lengths = build_node_features(event, names, object_types, scales)
44
+ assert actual_lengths == expected_lengths
45
+ torch.testing.assert_close(actual, expected, rtol=0, atol=0)
tests/unit/features/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Unit tests for shared collider feature construction."""
tests/unit/features/test_objects.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+ import torch
4
+
5
+ from gnn4colliders.features import NODE_FEATURE_NAMES, build_node_features
6
+
7
+
8
+ @pytest.fixture
9
+ def schema():
10
+ return (
11
+ [
12
+ ["jet_pt", "ele_pt", "mu_pt", "ph_pt", "MET_met"],
13
+ ["jet_eta", "ele_eta", "mu_eta", "ph_eta", 0],
14
+ ["jet_phi", "ele_phi", "mu_phi", "ph_phi", "MET_phi"],
15
+ "CALC_E",
16
+ ["jet_btag", 0, 0, 0, 0],
17
+ [0, "ele_charge", "mu_charge", 0, 0],
18
+ "NODE_TYPE",
19
+ ],
20
+ ["vector", "vector", "vector", "vector", "single"],
21
+ [0.1, 1, 1, 0.1, 1, 1, 1],
22
+ )
23
+
24
+
25
+ @pytest.fixture
26
+ def event():
27
+ return {
28
+ "jet_pt": np.array([100.0, 50.0], dtype=np.float32),
29
+ "ele_pt": np.array([20.0], dtype=np.float32),
30
+ "mu_pt": np.array([30.0], dtype=np.float32),
31
+ "ph_pt": np.array([40.0], dtype=np.float32),
32
+ "MET_met": np.float32(25.0),
33
+ "jet_eta": np.array([1.0, -0.5], dtype=np.float32),
34
+ "ele_eta": np.array([0.25], dtype=np.float32),
35
+ "mu_eta": np.array([-0.75], dtype=np.float32),
36
+ "ph_eta": np.array([0.5], dtype=np.float32),
37
+ "jet_phi": np.array([3.0, -3.0], dtype=np.float32),
38
+ "ele_phi": np.array([0.2], dtype=np.float32),
39
+ "mu_phi": np.array([-0.4], dtype=np.float32),
40
+ "ph_phi": np.array([1.0], dtype=np.float32),
41
+ "MET_phi": np.float32(-1.2),
42
+ "jet_btag": np.array([0.8, 0.1], dtype=np.float32),
43
+ "ele_charge": np.array([-1.0], dtype=np.float32),
44
+ "mu_charge": np.array([1.0], dtype=np.float32),
45
+ }
46
+
47
+
48
+ def test_schema_and_values(event, schema):
49
+ names, object_types, scales = schema
50
+ features, lengths = build_node_features(event, names, object_types, scales)
51
+ assert NODE_FEATURE_NAMES == (
52
+ "pt",
53
+ "eta",
54
+ "phi",
55
+ "energy",
56
+ "btag",
57
+ "charge",
58
+ "node_type",
59
+ )
60
+ assert lengths == [2, 1, 1, 1, 1]
61
+ assert features.shape == (6, 7)
62
+ assert features.dtype == torch.float32
63
+ np.testing.assert_allclose(
64
+ features.numpy(),
65
+ [
66
+ [10.0, 1.0, 3.0, 15.431, 0.8, 0.0, 0.0],
67
+ [5.0, -0.5, -3.0, 5.638, 0.1, 0.0, 0.0],
68
+ [2.0, 0.25, 0.2, 2.063, 0.0, -1.0, 1.0],
69
+ [3.0, -0.75, -0.4, 3.884, 0.0, 1.0, 2.0],
70
+ [4.0, 0.5, 1.0, 4.511, 0.0, 0.0, 3.0],
71
+ [2.5, 0.0, -1.2, 2.500, 0.0, 0.0, 4.0],
72
+ ],
73
+ rtol=0,
74
+ atol=2e-3,
75
+ )
76
+
77
+
78
+ def test_empty_vectors_and_input_immutability(event, schema):
79
+ names, object_types, scales = schema
80
+ event = dict(event)
81
+ event["jet_pt"] = np.array([], dtype=np.float32)
82
+ event["jet_eta"] = np.array([], dtype=np.float32)
83
+ event["jet_phi"] = np.array([], dtype=np.float32)
84
+ event["jet_btag"] = np.array([], dtype=np.float32)
85
+ before = event["ele_pt"].copy()
86
+ features, lengths = build_node_features(event, names, object_types, scales)
87
+ assert lengths == [0, 1, 1, 1, 1]
88
+ assert features.shape == (4, 7)
89
+ np.testing.assert_array_equal(event["ele_pt"], before)