ho22joshua commited on
Commit
fe85bec
·
1 Parent(s): 2c516a8

feat: add ROOT-GNN tasks and training lifecycle

Browse files
src/gnn4colliders/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Architecture-specific machine-learning models."""
src/gnn4colliders/models/root_gnn/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """ROOT-GNN model components."""
2
+
3
+ from .edge_network import EdgeNetwork
4
+ from .transfer import FineTunedEdgeNetwork, load_legacy_edge_network_state_dict
5
+
6
+ __all__ = ["EdgeNetwork", "FineTunedEdgeNetwork", "load_legacy_edge_network_state_dict"]
src/gnn4colliders/models/root_gnn/blocks.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small neural-network blocks used by the active ROOT-GNN model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from torch import nn
8
+
9
+
10
+ def make_mlp(
11
+ in_size: int,
12
+ hidden_size: int,
13
+ out_size: int,
14
+ n_layers: int,
15
+ *,
16
+ dropout: float = 0.0,
17
+ activation: Callable[[], nn.Module] = nn.ReLU,
18
+ ) -> nn.Sequential:
19
+ """Build the legacy ``Make_MLP`` block.
20
+
21
+ Every linear layer is followed by activation and dropout, including the
22
+ final linear layer; LayerNorm is then applied to the output. This order
23
+ is part of the ROOT-GNN forward contract.
24
+ """
25
+ if n_layers < 1:
26
+ raise ValueError("n_layers must be positive")
27
+ layers: list[nn.Module] = []
28
+ if n_layers == 1:
29
+ sizes = [(in_size, out_size)]
30
+ else:
31
+ sizes = [(in_size, hidden_size)]
32
+ sizes.extend((hidden_size, hidden_size) for _ in range(n_layers - 2))
33
+ sizes.append((hidden_size, out_size))
34
+ for input_size, output_size in sizes:
35
+ layers.extend(
36
+ (nn.Linear(input_size, output_size), activation(), nn.Dropout(dropout))
37
+ )
38
+ layers.append(nn.LayerNorm(out_size))
39
+ return nn.Sequential(*layers)
src/gnn4colliders/models/root_gnn/edge_network.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The active edge-message-passing ROOT-GNN architecture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+ from .blocks import make_mlp
11
+
12
+
13
+ def _broadcast(values: torch.Tensor, counts: torch.Tensor) -> torch.Tensor:
14
+ return torch.repeat_interleave(values, counts, dim=0)
15
+
16
+
17
+ def _copy_destination(edges: Any) -> dict[str, torch.Tensor]:
18
+ """DGL 2.x has ``copy_u`` but no built-in destination copy primitive."""
19
+ return {"m_v": edges.dst["h"]}
20
+
21
+
22
+ class EdgeNetwork(nn.Module):
23
+ """ROOT-GNN ``Edge_Network`` with an explicit feature/classifier split."""
24
+
25
+ def __init__(
26
+ self,
27
+ sample_graph: Any,
28
+ sample_global: torch.Tensor | None,
29
+ hid_size: int,
30
+ out_size: int,
31
+ n_layers: int,
32
+ n_proc_steps: int,
33
+ dropout: float = 0.0,
34
+ **_: Any,
35
+ ) -> None:
36
+ super().__init__()
37
+ if n_proc_steps < 0:
38
+ raise ValueError("n_proc_steps must be non-negative")
39
+ node_features = sample_graph.ndata["features"]
40
+ edge_features = sample_graph.edata["features"]
41
+ global_width = 0 if sample_global is None else sample_global.shape[1]
42
+ self.has_global = global_width != 0
43
+ if not self.has_global:
44
+ global_width = 1
45
+ self.hid_size = hid_size
46
+ self.n_layers = n_layers
47
+ self.n_proc_steps = n_proc_steps
48
+ self.node_feature_size = int(node_features.shape[1])
49
+ self.edge_feature_size = int(edge_features.shape[1])
50
+ self.global_feature_size = (
51
+ int(sample_global.shape[1]) if sample_global is not None else 0
52
+ )
53
+ self.dropout = float(dropout)
54
+
55
+ self.node_encoder = make_mlp(
56
+ node_features.shape[1], hid_size, hid_size, n_layers, dropout=dropout
57
+ )
58
+ self.edge_encoder = make_mlp(
59
+ edge_features.shape[1], hid_size, hid_size, n_layers, dropout=dropout
60
+ )
61
+ self.global_encoder = make_mlp(
62
+ global_width, hid_size, hid_size, n_layers, dropout=dropout
63
+ )
64
+ self.node_update = make_mlp(
65
+ 3 * hid_size, hid_size, hid_size, n_layers, dropout=dropout
66
+ )
67
+ self.edge_update = make_mlp(
68
+ 4 * hid_size, hid_size, hid_size, n_layers, dropout=dropout
69
+ )
70
+ self.global_update = make_mlp(
71
+ 3 * hid_size, hid_size, hid_size, n_layers, dropout=dropout
72
+ )
73
+ self.global_decoder = make_mlp(
74
+ hid_size, hid_size, hid_size, n_layers, dropout=dropout
75
+ )
76
+ self.classifier = nn.Linear(hid_size, out_size)
77
+
78
+ def checkpoint_config(self) -> dict[str, Any]:
79
+ """Return constructor information without serializing a sample graph."""
80
+ return {
81
+ "family": "root_gnn",
82
+ "class": type(self).__name__,
83
+ "node_feature_size": self.node_feature_size,
84
+ "edge_feature_size": self.edge_feature_size,
85
+ "global_feature_size": self.global_feature_size,
86
+ "hid_size": self.hid_size,
87
+ "out_size": int(self.classifier.out_features),
88
+ "n_layers": self.n_layers,
89
+ "n_proc_steps": self.n_proc_steps,
90
+ "dropout": self.dropout,
91
+ }
92
+
93
+ # Compatibility with the legacy public attribute while keeping the new
94
+ # name in the model API.
95
+ @property
96
+ def classify(self) -> nn.Linear:
97
+ return self.classifier
98
+
99
+ def _features(
100
+ self, graph: Any, global_features: torch.Tensor | None
101
+ ) -> torch.Tensor:
102
+ if not self.has_global:
103
+ return graph.batch_num_nodes()[:, None].to(
104
+ device=graph.ndata["features"].device, dtype=torch.float32
105
+ )
106
+ if global_features is None:
107
+ raise ValueError("global_features are required for this model")
108
+ if global_features.ndim == 1:
109
+ if graph.batch_num_nodes().numel() != 1:
110
+ raise ValueError(
111
+ "one-dimensional global_features are valid only for one graph"
112
+ )
113
+ global_features = global_features.unsqueeze(0)
114
+ return global_features
115
+
116
+ def forward_features(
117
+ self, graph: Any, global_features: torch.Tensor | None = None
118
+ ) -> torch.Tensor:
119
+ """Return the decoded graph representation before classification."""
120
+ try:
121
+ import dgl
122
+ except ImportError as error: # pragma: no cover - optional dependency
123
+ raise ImportError("EdgeNetwork requires the 'root-gnn' extra") from error
124
+ if hasattr(graph, "graph") and hasattr(graph, "global_features"):
125
+ global_features = graph.global_features
126
+ graph = graph.graph
127
+ with graph.local_scope():
128
+ h = self.node_encoder(graph.ndata["features"])
129
+ e = self.edge_encoder(graph.edata["features"])
130
+ graph.ndata["h"] = h
131
+ graph.edata["e"] = e
132
+ h_global = self.global_encoder(self._features(graph, global_features))
133
+ counts = graph.batch_num_nodes()
134
+ for _ in range(self.n_proc_steps):
135
+ graph.apply_edges(dgl.function.copy_u("h", "m_u"))
136
+ graph.apply_edges(_copy_destination)
137
+ graph.edata["e"] = self.edge_update(
138
+ torch.cat(
139
+ (
140
+ graph.edata["e"],
141
+ graph.edata["m_u"],
142
+ graph.edata["m_v"],
143
+ _broadcast(h_global, graph.batch_num_edges()),
144
+ ),
145
+ dim=1,
146
+ )
147
+ )
148
+ graph.update_all(
149
+ dgl.function.copy_e("e", "m"), dgl.function.sum("m", "h_e")
150
+ )
151
+ graph.ndata["h"] = self.node_update(
152
+ torch.cat(
153
+ (
154
+ graph.ndata["h"],
155
+ graph.ndata["h_e"],
156
+ _broadcast(h_global, counts),
157
+ ),
158
+ dim=1,
159
+ )
160
+ )
161
+ if "w" in graph.ndata:
162
+ mask = torch.any(graph.ndata["features"] != 0, dim=1)
163
+ valid_counts = []
164
+ start = 0
165
+ for count in counts.tolist():
166
+ valid_counts.append(mask[start : start + count].sum())
167
+ start += count
168
+ denominator = (
169
+ torch.stack(valid_counts).to(h.dtype).clamp_min(1)[:, None]
170
+ )
171
+ mean_nodes = dgl.sum_nodes(graph, "h", "w") / denominator
172
+ else:
173
+ mean_nodes = dgl.mean_nodes(graph, "h")
174
+ h_global = self.global_update(
175
+ torch.cat((h_global, mean_nodes, dgl.mean_edges(graph, "e")), dim=1)
176
+ )
177
+ return self.global_decoder(h_global)
178
+
179
+ def forward(
180
+ self, graph: Any, global_features: torch.Tensor | None = None
181
+ ) -> torch.Tensor:
182
+ return self.classifier(self.forward_features(graph, global_features))
183
+
184
+ def representation(
185
+ self, graph: Any, global_features: torch.Tensor | None = None
186
+ ) -> torch.Tensor:
187
+ """Legacy alias for the reusable decoded representation."""
188
+ return self.forward_features(graph, global_features)
src/gnn4colliders/models/root_gnn/transfer.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit transfer-learning wrapper for ROOT-GNN."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ import torch
10
+ from torch import nn
11
+
12
+ from gnn4colliders.compat.checkpoint import map_legacy_edge_network_state_dict
13
+
14
+ from .edge_network import EdgeNetwork
15
+
16
+
17
+ def load_legacy_edge_network_state_dict(
18
+ model: nn.Module, state: Mapping[str, Any]
19
+ ) -> None:
20
+ """Load a legacy checkpoint/state dict after historical prefix cleanup."""
21
+ state_dict = state.get("model_state_dict", state)
22
+ model.load_state_dict(map_legacy_edge_network_state_dict(state_dict), strict=True)
23
+
24
+
25
+ class FineTunedEdgeNetwork(nn.Module):
26
+ """A pretrained ROOT-GNN backbone with a task-specific classifier."""
27
+
28
+ def __init__(
29
+ self, backbone: EdgeNetwork, out_size: int, *, freeze_backbone: bool = False
30
+ ) -> None:
31
+ super().__init__()
32
+ # Own an independent classifier-free copy: constructing a transfer
33
+ # model must not mutate the pretrained model supplied by the caller.
34
+ self.backbone = copy.deepcopy(backbone)
35
+ self.backbone.classifier = nn.Identity()
36
+ self.classifier = nn.Linear(backbone.hid_size, out_size)
37
+ self.freeze_backbone = freeze_backbone
38
+ self.set_backbone_trainable(not freeze_backbone)
39
+
40
+ def checkpoint_config(self) -> dict[str, Any]:
41
+ config = self.backbone.checkpoint_config()
42
+ config.update(
43
+ {
44
+ "class": type(self).__name__,
45
+ "out_size": int(self.classifier.out_features),
46
+ "freeze_backbone": self.freeze_backbone,
47
+ }
48
+ )
49
+ return config
50
+
51
+ @classmethod
52
+ def from_pretrained(
53
+ cls,
54
+ pretrained: EdgeNetwork,
55
+ out_size: int,
56
+ *,
57
+ freeze_backbone: bool = False,
58
+ state_dict: Mapping[str, Any] | None = None,
59
+ ) -> "FineTunedEdgeNetwork":
60
+ if state_dict is not None:
61
+ load_legacy_edge_network_state_dict(pretrained, state_dict)
62
+ return cls(pretrained, out_size, freeze_backbone=freeze_backbone)
63
+
64
+ def set_backbone_trainable(self, trainable: bool) -> None:
65
+ for parameter in self.backbone.parameters():
66
+ parameter.requires_grad = trainable
67
+ for parameter in self.classifier.parameters():
68
+ parameter.requires_grad = True
69
+
70
+ def forward_features(
71
+ self, graph: Any, global_features: torch.Tensor | None = None
72
+ ) -> torch.Tensor:
73
+ return self.backbone.forward_features(graph, global_features)
74
+
75
+ def forward(
76
+ self, graph: Any, global_features: torch.Tensor | None = None
77
+ ) -> torch.Tensor:
78
+ return self.classifier(self.forward_features(graph, global_features))
79
+
80
+ def representation(
81
+ self, graph: Any, global_features: torch.Tensor | None = None
82
+ ) -> torch.Tensor:
83
+ return self.forward_features(graph, global_features)
src/gnn4colliders/tasks/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Learning-task semantics for interpreting model outputs."""
2
+
3
+ from .binary_classification import BinaryClassificationTask
4
+ from .multiclass_classification import MulticlassClassificationTask
5
+
6
+ __all__ = ["BinaryClassificationTask", "MulticlassClassificationTask"]
src/gnn4colliders/tasks/_common.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small shared helpers for classification tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import torch
8
+
9
+
10
+ def batch_labels(batch: Any) -> torch.Tensor:
11
+ """Return labels from a GraphBatch-like object."""
12
+
13
+ try:
14
+ return batch.labels
15
+ except AttributeError as error:
16
+ raise TypeError("batch must provide a named 'labels' field") from error
17
+
18
+
19
+ def batch_weights(
20
+ batch: Any, *, device: torch.device, dtype: torch.dtype
21
+ ) -> torch.Tensor:
22
+ """Return event weights through named metadata, never positional tracking."""
23
+
24
+ try:
25
+ weights = batch.metadata.weight
26
+ except AttributeError as error:
27
+ raise TypeError("batch must provide named metadata.weight") from error
28
+ return torch.as_tensor(weights, device=device, dtype=dtype).reshape(-1)
29
+
30
+
31
+ def per_label_weighted_mean(
32
+ elementwise_loss: torch.Tensor,
33
+ labels: torch.Tensor,
34
+ weights: torch.Tensor,
35
+ ) -> torch.Tensor:
36
+ """Reproduce the legacy weighted loss reduction.
37
+
38
+ Each label receives its own weighted mean, and those means are then
39
+ averaged equally. In particular, this is not ordinary weighted BCE/CE.
40
+ """
41
+
42
+ result = elementwise_loss.new_zeros(())
43
+ unique_labels = torch.unique(labels)
44
+ for label in unique_labels:
45
+ mask = labels == label
46
+ result = (
47
+ result
48
+ + (weights[mask] * elementwise_loss[mask]).sum() / weights[mask].sum()
49
+ )
50
+ return result / len(unique_labels)
51
+
52
+
53
+ def positive_weight_mask(weights: torch.Tensor) -> torch.Tensor:
54
+ """The legacy metric path excludes non-positive original weights."""
55
+
56
+ return weights > 0
57
+
58
+
59
+ def weighted_binary_auc(
60
+ labels: torch.Tensor, scores: torch.Tensor, weights: torch.Tensor
61
+ ) -> float:
62
+ """Compute weighted binary ROC AUC, including half-credit ties."""
63
+
64
+ labels = labels.detach().cpu().to(dtype=torch.bool)
65
+ scores = scores.detach().cpu().to(dtype=torch.float64)
66
+ weights = weights.detach().cpu().to(dtype=torch.float64)
67
+ positive = labels
68
+ negative = ~labels
69
+ positive_weight = weights[positive].sum()
70
+ negative_weight = weights[negative].sum()
71
+ if positive_weight <= 0 or negative_weight <= 0:
72
+ raise ValueError("ROC AUC requires positive and negative samples")
73
+ pair_scores = scores[positive, None] - scores[None, negative]
74
+ pair_value = (pair_scores > 0).to(torch.float64) + 0.5 * (pair_scores == 0)
75
+ pair_weights = weights[positive, None] * weights[None, negative]
76
+ return float(
77
+ (pair_value * pair_weights).sum() / (positive_weight * negative_weight)
78
+ )
src/gnn4colliders/tasks/binary_classification.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Binary classification task semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import torch
8
+
9
+ from ._common import (
10
+ batch_labels,
11
+ batch_weights,
12
+ per_label_weighted_mean,
13
+ positive_weight_mask,
14
+ weighted_binary_auc,
15
+ )
16
+
17
+
18
+ class BinaryClassificationTask:
19
+ """Interpret one-logit model outputs as a weighted binary task."""
20
+
21
+ def __init__(self, *, absolute_weights: bool = False, threshold: float = 0.5):
22
+ self.absolute_weights = absolute_weights
23
+ self.threshold = threshold
24
+
25
+ @staticmethod
26
+ def _logits(logits: torch.Tensor) -> torch.Tensor:
27
+ if logits.ndim == 2 and logits.shape[1] == 1:
28
+ return logits[:, 0]
29
+ if logits.ndim == 1:
30
+ return logits
31
+ raise ValueError("binary logits must have shape [B] or [B, 1]")
32
+
33
+ @staticmethod
34
+ def _targets(batch: Any, *, device: torch.device) -> torch.Tensor:
35
+ labels = torch.as_tensor(batch_labels(batch), device=device)
36
+ if labels.ndim == 2 and labels.shape[1] == 1:
37
+ labels = labels[:, 0]
38
+ elif labels.ndim != 1:
39
+ raise ValueError("binary labels must have shape [B] or [B, 1]")
40
+ return labels.to(dtype=torch.float32)
41
+
42
+ def loss(self, logits: torch.Tensor, batch: Any) -> torch.Tensor:
43
+ raw_logits = self._logits(logits)
44
+ labels = self._targets(batch, device=raw_logits.device)
45
+ if labels.shape != raw_logits.shape:
46
+ raise ValueError("binary logits and labels must have the same batch size")
47
+ weights = batch_weights(batch, device=raw_logits.device, dtype=raw_logits.dtype)
48
+ if weights.shape != raw_logits.shape:
49
+ raise ValueError("metadata.weight must contain one value per event")
50
+ if self.absolute_weights:
51
+ weights = weights.abs()
52
+ elementwise = torch.nn.functional.binary_cross_entropy_with_logits(
53
+ raw_logits, labels.to(dtype=raw_logits.dtype), reduction="none"
54
+ )
55
+ return per_label_weighted_mean(elementwise, labels, weights)
56
+
57
+ def predictions(self, logits: torch.Tensor) -> dict[str, torch.Tensor]:
58
+ scores = torch.sigmoid(self._logits(logits))
59
+ return {"scores": scores, "predictions": scores >= self.threshold}
60
+
61
+ predict = predictions
62
+
63
+ def metrics(self, logits: torch.Tensor, batch: Any) -> dict[str, float]:
64
+ scores = self.predictions(logits)["scores"]
65
+ labels = self._targets(batch, device=scores.device)
66
+ weights = batch_weights(batch, device=scores.device, dtype=scores.dtype)
67
+ metric_weights = weights.abs() if self.absolute_weights else weights
68
+ mask = positive_weight_mask(weights)
69
+ try:
70
+ auc = weighted_binary_auc(
71
+ labels[mask] == 1, scores[mask], metric_weights[mask]
72
+ )
73
+ except ValueError:
74
+ auc = float("nan")
75
+ accuracy = (
76
+ (self.predictions(logits)["predictions"] == (labels == 1)).float().mean()
77
+ )
78
+ return {"accuracy": float(accuracy), "roc_auc": float(auc), "auc": float(auc)}
src/gnn4colliders/tasks/multiclass_classification.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multiclass classification task semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import torch
8
+
9
+ from ._common import (
10
+ batch_labels,
11
+ batch_weights,
12
+ per_label_weighted_mean,
13
+ positive_weight_mask,
14
+ weighted_binary_auc,
15
+ )
16
+
17
+
18
+ class MulticlassClassificationTask:
19
+ """Interpret ``[B, C]`` logits with legacy per-class weighting."""
20
+
21
+ def __init__(self, *, absolute_weights: bool = False):
22
+ self.absolute_weights = absolute_weights
23
+
24
+ @staticmethod
25
+ def _targets(batch: Any, *, device: torch.device) -> torch.Tensor:
26
+ labels = torch.as_tensor(batch_labels(batch), device=device)
27
+ if labels.ndim == 2 and labels.shape[1] == 1:
28
+ labels = labels[:, 0]
29
+ if labels.ndim != 1:
30
+ raise ValueError("multiclass labels must have shape [B] or [B, 1]")
31
+ return labels.to(dtype=torch.long)
32
+
33
+ def loss(self, logits: torch.Tensor, batch: Any) -> torch.Tensor:
34
+ if logits.ndim != 2 or logits.shape[1] < 2:
35
+ raise ValueError("multiclass logits must have shape [B, C], with C >= 2")
36
+ labels = self._targets(batch, device=logits.device)
37
+ if labels.shape[0] != logits.shape[0]:
38
+ raise ValueError(
39
+ "multiclass logits and labels must have the same batch size"
40
+ )
41
+ weights = batch_weights(batch, device=logits.device, dtype=logits.dtype)
42
+ if weights.shape != labels.shape:
43
+ raise ValueError("metadata.weight must contain one value per event")
44
+ if self.absolute_weights:
45
+ weights = weights.abs()
46
+ elementwise = torch.nn.functional.cross_entropy(
47
+ logits, labels, reduction="none"
48
+ )
49
+ return per_label_weighted_mean(elementwise, labels, weights)
50
+
51
+ @staticmethod
52
+ def predictions(logits: torch.Tensor) -> dict[str, torch.Tensor]:
53
+ if logits.ndim != 2 or logits.shape[1] < 2:
54
+ raise ValueError("multiclass logits must have shape [B, C], with C >= 2")
55
+ scores = torch.softmax(logits, dim=1)
56
+ return {"scores": scores, "predictions": scores.argmax(dim=1)}
57
+
58
+ predict = predictions
59
+
60
+ def metrics(self, logits: torch.Tensor, batch: Any) -> dict[str, float]:
61
+ output = self.predictions(logits)
62
+ labels = self._targets(batch, device=logits.device)
63
+ weights = batch_weights(batch, device=logits.device, dtype=logits.dtype)
64
+ metric_weights = weights.abs() if self.absolute_weights else weights
65
+ mask = positive_weight_mask(weights)
66
+ accuracy = (output["predictions"] == labels).float().mean()
67
+ try:
68
+ class_aucs = []
69
+ for class_index in range(output["scores"].shape[1]):
70
+ class_aucs.append(
71
+ weighted_binary_auc(
72
+ labels[mask] == class_index,
73
+ output["scores"][mask, class_index],
74
+ metric_weights[mask],
75
+ )
76
+ )
77
+ auc = sum(class_aucs) / len(class_aucs)
78
+ except ValueError:
79
+ auc = float("nan")
80
+ return {"accuracy": float(accuracy), "roc_auc": float(auc), "auc": float(auc)}
src/gnn4colliders/training/__init__.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Architecture-independent training infrastructure."""
2
+
3
+ from .checkpoint import (
4
+ CHECKPOINT_SCHEMA_VERSION,
5
+ CheckpointManager,
6
+ build_model_from_checkpoint,
7
+ load_legacy_checkpoint,
8
+ load_model_weights,
9
+ load_pretrained_edge_network,
10
+ restore_training_state,
11
+ )
12
+ from .early_stopping import EarlyStopping
13
+ from .optim import build_optimizer, trainable_parameters
14
+ from .reproducibility import seed_everything
15
+ from .schedulers import build_scheduler
16
+ from .state import BatchResult, EpochResult, TrainerState, TrainingHistory
17
+ from .trainer import Trainer
18
+
19
+ __all__ = [
20
+ "BatchResult",
21
+ "EarlyStopping",
22
+ "EpochResult",
23
+ "Trainer",
24
+ "TrainerState",
25
+ "TrainingHistory",
26
+ "build_optimizer",
27
+ "build_scheduler",
28
+ "seed_everything",
29
+ "trainable_parameters",
30
+ "CHECKPOINT_SCHEMA_VERSION",
31
+ "CheckpointManager",
32
+ "build_model_from_checkpoint",
33
+ "load_legacy_checkpoint",
34
+ "load_model_weights",
35
+ "load_pretrained_edge_network",
36
+ "restore_training_state",
37
+ ]
src/gnn4colliders/training/checkpoint.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small, explicit checkpoint persistence and compatibility adapters.
2
+
3
+ Checkpoint files are trusted PyTorch artifacts. The payload itself contains
4
+ only state dictionaries and primitive configuration/metadata, which keeps the
5
+ new format independent of live trainer or task objects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import random
12
+ import re
13
+ import socket
14
+ import subprocess
15
+ import tempfile
16
+ from collections.abc import Mapping
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+ import numpy as np
22
+ import torch
23
+ from torch import nn
24
+
25
+ from gnn4colliders.compat.checkpoint import (
26
+ load_legacy_checkpoint,
27
+ map_legacy_edge_network_state_dict,
28
+ normalize_legacy_state_dict_keys,
29
+ )
30
+ from gnn4colliders.data.metadata import FEATURE_SCHEMA_VERSION, GRAPH_SCHEMA_VERSION
31
+
32
+ from .state import TrainerState
33
+
34
+ CHECKPOINT_SCHEMA_VERSION = 1
35
+ _EPOCH_RE = re.compile(r"(?:epoch|model_epoch)[_-](\d+)")
36
+
37
+
38
+ def _primitive(value: Any) -> Any:
39
+ if value is None or isinstance(value, (str, int, float, bool)):
40
+ return value
41
+ if isinstance(value, Mapping):
42
+ return {str(key): _primitive(item) for key, item in value.items()}
43
+ if isinstance(value, (list, tuple)):
44
+ return [_primitive(item) for item in value]
45
+ if isinstance(value, np.generic):
46
+ return value.item()
47
+ raise TypeError(f"checkpoint configuration must be primitive, got {type(value)!r}")
48
+
49
+
50
+ def _state_dict(model: nn.Module) -> dict[str, Any]:
51
+ # Compiled and DDP models expose the useful state on the wrapped module.
52
+ target = getattr(model, "_orig_mod", model)
53
+ state = target.state_dict()
54
+ return {
55
+ key.removeprefix("module."): value.detach().cpu()
56
+ for key, value in state.items()
57
+ }
58
+
59
+
60
+ def _rng_state() -> dict[str, Any]:
61
+ state: dict[str, Any] = {
62
+ "python": random.getstate(),
63
+ "numpy": np.random.get_state(),
64
+ "torch": torch.get_rng_state(),
65
+ }
66
+ if torch.cuda.is_available():
67
+ state["torch_cuda"] = torch.cuda.get_rng_state_all()
68
+ return state
69
+
70
+
71
+ def _restore_rng(state: Mapping[str, Any]) -> None:
72
+ if "python" in state:
73
+ random.setstate(state["python"])
74
+ if "numpy" in state:
75
+ np.random.set_state(state["numpy"])
76
+ if "torch" in state:
77
+ torch.set_rng_state(state["torch"])
78
+ if "torch_cuda" in state and torch.cuda.is_available():
79
+ torch.cuda.set_rng_state_all(state["torch_cuda"])
80
+
81
+
82
+ def _git_commit() -> str | None:
83
+ try:
84
+ result = subprocess.run(
85
+ ["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True
86
+ )
87
+ except (OSError, subprocess.CalledProcessError):
88
+ return None
89
+ return result.stdout.strip() or None
90
+
91
+
92
+ def _model_config(model: nn.Module) -> dict[str, Any]:
93
+ config_method = getattr(model, "checkpoint_config", None)
94
+ if callable(config_method):
95
+ return _primitive(config_method())
96
+ return {"class": f"{type(model).__module__}.{type(model).__qualname__}"}
97
+
98
+
99
+ def _task_config(task: Any) -> dict[str, Any]:
100
+ values = {"type": type(task).__name__}
101
+ for name in ("absolute_weights", "threshold", "num_classes"):
102
+ if hasattr(task, name):
103
+ values[name] = _primitive(getattr(task, name))
104
+ return values
105
+
106
+
107
+ def load_model_weights(model: nn.Module, checkpoint: Mapping[str, Any]) -> None:
108
+ """Load only model weights; optimizer and lifecycle state are untouched."""
109
+ state = checkpoint.get("model_state_dict", checkpoint)
110
+ if not isinstance(state, Mapping):
111
+ raise ValueError("checkpoint model_state_dict must be a mapping")
112
+ normalized = normalize_legacy_state_dict_keys(state)
113
+ # Task 8 owns the historical classify -> classifier rename. Use its one
114
+ # compatibility path when applicable, while preserving generic models.
115
+ if type(model).__module__.startswith("gnn4colliders.models.root_gnn"):
116
+ normalized = map_legacy_edge_network_state_dict(normalized)
117
+ try:
118
+ model.load_state_dict(normalized, strict=True)
119
+ except RuntimeError as error:
120
+ raise ValueError(
121
+ f"checkpoint weights are incompatible with {type(model).__name__}"
122
+ ) from error
123
+
124
+
125
+ class CheckpointManager:
126
+ """Save and discover versioned checkpoints in a caller-selected directory."""
127
+
128
+ def __init__(self, directory: str | os.PathLike[str]) -> None:
129
+ self.directory = Path(directory)
130
+ self.directory.mkdir(parents=True, exist_ok=True)
131
+
132
+ def save(
133
+ self,
134
+ *,
135
+ model: nn.Module,
136
+ trainer_state: TrainerState,
137
+ optimizer: torch.optim.Optimizer | None = None,
138
+ scheduler: Any | None = None,
139
+ early_stopping: Any | None = None,
140
+ model_config: Mapping[str, Any] | None = None,
141
+ task_config: Mapping[str, Any] | None = None,
142
+ metadata: Mapping[str, Any] | None = None,
143
+ name: str | None = None,
144
+ monitor_name: str | None = None,
145
+ monitor_value: float | None = None,
146
+ include_rng: bool = True,
147
+ ) -> Path:
148
+ epoch = int(trainer_state.epoch)
149
+ payload: dict[str, Any] = {
150
+ "schema_version": CHECKPOINT_SCHEMA_VERSION,
151
+ "epoch": epoch,
152
+ "global_step": int(trainer_state.global_step),
153
+ "model_state_dict": _state_dict(model),
154
+ "optimizer_state_dict": optimizer.state_dict()
155
+ if optimizer is not None
156
+ else None,
157
+ "scheduler_state_dict": scheduler.state_dict()
158
+ if scheduler is not None
159
+ else None,
160
+ "early_stopping_state": early_stopping.state_dict()
161
+ if early_stopping is not None
162
+ else None,
163
+ "trainer_state": {
164
+ "epoch": epoch,
165
+ "global_step": int(trainer_state.global_step),
166
+ },
167
+ "model_config": _primitive(model_config)
168
+ if model_config is not None
169
+ else _model_config(model),
170
+ "task_config": _primitive(task_config) if task_config is not None else None,
171
+ "metadata": {
172
+ "created_at": datetime.now(timezone.utc).isoformat(),
173
+ "hostname": socket.gethostname(),
174
+ "git_commit": _git_commit(),
175
+ "feature_schema_version": FEATURE_SCHEMA_VERSION,
176
+ "graph_schema_version": GRAPH_SCHEMA_VERSION,
177
+ **(_primitive(metadata) if metadata is not None else {}),
178
+ },
179
+ "monitor_name": monitor_name,
180
+ "monitor_value": None if monitor_value is None else float(monitor_value),
181
+ }
182
+ if include_rng:
183
+ payload["rng_state"] = _rng_state()
184
+ filename = name or f"epoch_{epoch:04d}.pt"
185
+ target = self.directory / filename
186
+ with tempfile.NamedTemporaryFile(
187
+ dir=self.directory, prefix=f".{filename}.", suffix=".tmp", delete=False
188
+ ) as handle:
189
+ temporary = Path(handle.name)
190
+ try:
191
+ torch.save(payload, temporary)
192
+ os.replace(temporary, target)
193
+ finally:
194
+ temporary.unlink(missing_ok=True)
195
+ return target
196
+
197
+ @staticmethod
198
+ def _epoch(path: Path) -> int | None:
199
+ match = _EPOCH_RE.search(path.stem)
200
+ return int(match.group(1)) if match else None
201
+
202
+ def checkpoints(self) -> list[Path]:
203
+ return sorted(
204
+ (
205
+ path
206
+ for path in self.directory.glob("*.pt")
207
+ if self._epoch(path) is not None
208
+ ),
209
+ key=lambda path: self._epoch(path),
210
+ )
211
+
212
+ def latest(self) -> Path:
213
+ paths = self.checkpoints()
214
+ if not paths:
215
+ raise FileNotFoundError(f"no epoch checkpoints found in {self.directory}")
216
+ return paths[-1]
217
+
218
+ def best(self, *, mode: str = "min", monitor: str | None = None) -> Path:
219
+ if mode not in {"min", "max"}:
220
+ raise ValueError("mode must be 'min' or 'max'")
221
+ candidates: list[tuple[float, int, Path]] = []
222
+ for path in self.checkpoints():
223
+ payload = self.load(path)
224
+ if monitor is not None and payload.get("monitor_name") != monitor:
225
+ continue
226
+ value = payload.get("monitor_value")
227
+ if value is not None:
228
+ candidates.append((float(value), self._epoch(path) or -1, path))
229
+ if not candidates:
230
+ raise FileNotFoundError(
231
+ "no checkpoints contain the requested monitor value"
232
+ )
233
+ return (min if mode == "min" else max)(
234
+ candidates, key=lambda item: (item[0], item[1])
235
+ )[2]
236
+
237
+ @staticmethod
238
+ def load(
239
+ path: str | os.PathLike[str], *, map_location: Any = "cpu"
240
+ ) -> dict[str, Any]:
241
+ payload = torch.load(path, map_location=map_location, weights_only=False)
242
+ if not isinstance(payload, Mapping):
243
+ raise ValueError("checkpoint payload must be a mapping")
244
+ if "schema_version" not in payload:
245
+ return load_legacy_checkpoint(payload)
246
+ version = payload["schema_version"]
247
+ if version != CHECKPOINT_SCHEMA_VERSION:
248
+ raise ValueError(f"unsupported checkpoint schema version: {version!r}")
249
+ return dict(payload)
250
+
251
+
252
+ def restore_training_state(
253
+ checkpoint: Mapping[str, Any],
254
+ *,
255
+ model: nn.Module,
256
+ trainer: Any | None = None,
257
+ optimizer: torch.optim.Optimizer | None = None,
258
+ scheduler: Any | None = None,
259
+ early_stopping: Any | None = None,
260
+ restore_rng: bool = True,
261
+ ) -> TrainerState:
262
+ """Restore explicitly selected resume components and return trainer state."""
263
+ load_model_weights(model, checkpoint)
264
+ if optimizer is not None and checkpoint.get("optimizer_state_dict") is not None:
265
+ optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
266
+ if scheduler is not None and checkpoint.get("scheduler_state_dict") is not None:
267
+ scheduler.load_state_dict(checkpoint["scheduler_state_dict"])
268
+ if (
269
+ early_stopping is not None
270
+ and checkpoint.get("early_stopping_state") is not None
271
+ ):
272
+ early_stopping.load_state_dict(checkpoint["early_stopping_state"])
273
+ state_data = checkpoint.get("trainer_state", checkpoint)
274
+ state = TrainerState(
275
+ epoch=int(state_data.get("epoch", -1)),
276
+ global_step=int(state_data.get("global_step", 0)),
277
+ )
278
+ if trainer is not None:
279
+ trainer.state = state
280
+ if restore_rng and checkpoint.get("rng_state") is not None:
281
+ _restore_rng(checkpoint["rng_state"])
282
+ return state
283
+
284
+
285
+ def build_model_from_checkpoint(
286
+ checkpoint: Mapping[str, Any], factory: Callable[[Mapping[str, Any]], nn.Module]
287
+ ) -> nn.Module:
288
+ """Build a model through an explicit caller-owned factory."""
289
+ config = checkpoint.get("model_config")
290
+ if not isinstance(config, Mapping):
291
+ raise ValueError("checkpoint does not contain reconstructable model_config")
292
+ model = factory(config)
293
+ load_model_weights(model, checkpoint)
294
+ return model
295
+
296
+
297
+ def load_pretrained_edge_network(
298
+ checkpoint: Mapping[str, Any] | str | os.PathLike[str],
299
+ *,
300
+ sample_graph: Any,
301
+ sample_global: torch.Tensor | None = None,
302
+ map_location: Any = "cpu",
303
+ **overrides: Any,
304
+ ) -> nn.Module:
305
+ """Construct and load a new ROOT-GNN backbone for transfer learning."""
306
+ from gnn4colliders.models.root_gnn import EdgeNetwork
307
+
308
+ payload = (
309
+ CheckpointManager.load(checkpoint, map_location=map_location)
310
+ if not isinstance(checkpoint, Mapping)
311
+ else checkpoint
312
+ )
313
+ config = dict(payload.get("model_config") or {})
314
+ config.update(overrides)
315
+ config.pop("class", None)
316
+ required = {"hid_size", "out_size", "n_layers", "n_proc_steps"}
317
+ missing = required - config.keys()
318
+ if missing:
319
+ raise ValueError(
320
+ f"checkpoint lacks EdgeNetwork configuration: {sorted(missing)}"
321
+ )
322
+ model = EdgeNetwork(
323
+ sample_graph,
324
+ sample_global,
325
+ **{key: config[key] for key in required | {"dropout"} if key in config},
326
+ )
327
+ load_model_weights(model, payload)
328
+ return model
src/gnn4colliders/training/early_stopping.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory early-stopping policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+
8
+ class EarlyStopping:
9
+ """Stop after ``patience`` consecutive non-improvements.
10
+
11
+ Equality is a non-improvement, matching the legacy ``EarlyStop`` policy.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ *,
17
+ monitor: str = "loss",
18
+ mode: str = "min",
19
+ patience: int = 15,
20
+ min_delta: float = 1e-8,
21
+ ) -> None:
22
+ if mode not in {"min", "max"}:
23
+ raise ValueError("mode must be 'min' or 'max'")
24
+ if patience < 1:
25
+ raise ValueError("patience must be positive")
26
+ if min_delta < 0:
27
+ raise ValueError("min_delta must be non-negative")
28
+ self.monitor, self.mode, self.patience, self.min_delta = (
29
+ monitor,
30
+ mode,
31
+ patience,
32
+ min_delta,
33
+ )
34
+ self.best = math.inf if mode == "min" else -math.inf
35
+ self.num_bad_epochs = 0
36
+ self.should_stop = False
37
+
38
+ def update(self, value: float) -> bool:
39
+ value = float(value)
40
+ improved = (
41
+ value < self.best - self.min_delta
42
+ if self.mode == "min"
43
+ else value > self.best + self.min_delta
44
+ )
45
+ if improved:
46
+ self.best = value
47
+ self.num_bad_epochs = 0
48
+ else:
49
+ self.num_bad_epochs += 1
50
+ self.should_stop = self.num_bad_epochs >= self.patience
51
+ return self.should_stop
52
+
53
+ def state_dict(self) -> dict[str, object]:
54
+ """Return the primitive state needed to resume this policy."""
55
+ return {
56
+ "monitor": self.monitor,
57
+ "mode": self.mode,
58
+ "patience": self.patience,
59
+ "min_delta": self.min_delta,
60
+ "best": self.best,
61
+ "num_bad_epochs": self.num_bad_epochs,
62
+ "should_stop": self.should_stop,
63
+ }
64
+
65
+ def load_state_dict(self, state: dict[str, object]) -> None:
66
+ """Restore policy state, rejecting incompatible policy settings."""
67
+ for name in ("monitor", "mode", "patience", "min_delta"):
68
+ if name in state and getattr(self, name) != state[name]:
69
+ raise ValueError(f"early-stopping {name} does not match checkpoint")
70
+ self.best = float(state["best"])
71
+ self.num_bad_epochs = int(state["num_bad_epochs"])
72
+ self.should_stop = bool(state["should_stop"])
src/gnn4colliders/training/optim.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit construction helpers for the active optimizer workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+
11
+ def trainable_parameters(model: nn.Module) -> Iterable[nn.Parameter]:
12
+ """Yield only parameters enabled for optimization."""
13
+
14
+ return (parameter for parameter in model.parameters() if parameter.requires_grad)
15
+
16
+
17
+ def build_optimizer(
18
+ parameters: Iterable[nn.Parameter] | nn.Module,
19
+ *,
20
+ name: str = "adam",
21
+ learning_rate: float = 1e-4,
22
+ weight_decay: float = 0.0,
23
+ **kwargs: object,
24
+ ) -> torch.optim.Optimizer:
25
+ """Build an optimizer used by standard ROOT-GNN configurations."""
26
+
27
+ if isinstance(parameters, nn.Module):
28
+ parameters = trainable_parameters(parameters)
29
+ normalized = name.lower().replace("_", "")
30
+ if normalized == "adam":
31
+ return torch.optim.Adam(
32
+ parameters, lr=learning_rate, weight_decay=weight_decay, **kwargs
33
+ )
34
+ if normalized == "adamw":
35
+ return torch.optim.AdamW(
36
+ parameters, lr=learning_rate, weight_decay=weight_decay, **kwargs
37
+ )
38
+ raise ValueError(f"unsupported optimizer {name!r}; use 'adam' or 'adamw'")
src/gnn4colliders/training/reproducibility.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit process-level reproducibility configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+
7
+ import numpy as np
8
+ import torch
9
+
10
+
11
+ def seed_everything(seed: int, *, deterministic: bool = False) -> None:
12
+ """Seed Python, NumPy, and Torch; optionally request strict Torch behavior."""
13
+
14
+ random.seed(seed)
15
+ np.random.seed(seed)
16
+ torch.manual_seed(seed)
17
+ if torch.cuda.is_available():
18
+ torch.cuda.manual_seed_all(seed)
19
+ if deterministic:
20
+ torch.use_deterministic_algorithms(True)
src/gnn4colliders/training/schedulers.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scheduler construction with explicit epoch/metric stepping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+
8
+ def build_scheduler(
9
+ optimizer: torch.optim.Optimizer,
10
+ *,
11
+ name: str = "exponential",
12
+ gamma: float = 1.0,
13
+ **kwargs: object,
14
+ ) -> torch.optim.lr_scheduler.LRScheduler | torch.optim.lr_scheduler.ReduceLROnPlateau:
15
+ """Build the active epoch scheduler or a validation-driven scheduler."""
16
+
17
+ normalized = name.lower().replace("_", "")
18
+ if normalized in {"exponential", "exponentiallr"}:
19
+ return torch.optim.lr_scheduler.ExponentialLR(optimizer, gamma=gamma)
20
+ if normalized in {"reducelronplateau", "plateau"}:
21
+ return torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, **kwargs)
22
+ raise ValueError(f"unsupported scheduler {name!r}")
src/gnn4colliders/training/state.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Small in-memory types used by the training lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Mapping
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class BatchResult:
11
+ loss: float
12
+ logits: object
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class EpochResult:
17
+ loss: float
18
+ metrics: Mapping[str, float] = field(default_factory=dict)
19
+ num_samples: int = 0
20
+
21
+
22
+ @dataclass
23
+ class TrainingHistory:
24
+ train: list[EpochResult] = field(default_factory=list)
25
+ validation: list[EpochResult] = field(default_factory=list)
26
+
27
+
28
+ @dataclass
29
+ class TrainerState:
30
+ epoch: int = -1
31
+ global_step: int = 0
src/gnn4colliders/training/trainer.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-process, architecture-independent model training lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import logging
7
+ from types import SimpleNamespace
8
+ from typing import Any
9
+
10
+ import torch
11
+ from torch import nn
12
+
13
+ from gnn4colliders.data.graph_dataset import GraphBatch
14
+ from gnn4colliders.distributed import (
15
+ DistributedContext,
16
+ broadcast_bool,
17
+ gather_objects,
18
+ gather_tensor,
19
+ prepare_model,
20
+ )
21
+
22
+ from .early_stopping import EarlyStopping
23
+ from .state import BatchResult, EpochResult, TrainerState, TrainingHistory
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _model_forward(model: nn.Module, batch: GraphBatch) -> torch.Tensor:
29
+ """Call both supported model boundaries without masking model errors."""
30
+
31
+ try:
32
+ signature = inspect.signature(model.forward)
33
+ positional = [
34
+ parameter
35
+ for parameter in signature.parameters.values()
36
+ if parameter.kind
37
+ in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD)
38
+ ]
39
+ except (TypeError, ValueError):
40
+ positional = []
41
+ if len(positional) <= 1:
42
+ return model(batch)
43
+ return model(batch.graph, batch.global_features)
44
+
45
+
46
+ def _combined_batch(batches: list[GraphBatch], logits: torch.Tensor) -> Any:
47
+ labels = torch.cat([batch.labels.reshape(-1) for batch in batches])
48
+ weights = torch.cat([batch.metadata.weight.reshape(-1) for batch in batches])
49
+ sample_ids = tuple(
50
+ sample_id for batch in batches for sample_id in batch.metadata.sample_id
51
+ )
52
+ return SimpleNamespace(
53
+ labels=labels,
54
+ metadata=SimpleNamespace(weight=weights, sample_id=sample_ids),
55
+ logits=logits,
56
+ )
57
+
58
+
59
+ class Trainer:
60
+ """Orchestrate model, task, optimizer, and optional scheduler."""
61
+
62
+ def __init__(
63
+ self,
64
+ model: nn.Module,
65
+ task: Any,
66
+ optimizer: torch.optim.Optimizer,
67
+ scheduler: Any | None = None,
68
+ *,
69
+ device: torch.device | str = "cpu",
70
+ early_stopping: EarlyStopping | None = None,
71
+ scheduler_step: str = "epoch",
72
+ distributed_context: DistributedContext | None = None,
73
+ ) -> None:
74
+ if scheduler_step not in {"epoch", "validation"}:
75
+ raise ValueError("scheduler_step must be 'epoch' or 'validation'")
76
+ self.distributed_context = (
77
+ distributed_context or DistributedContext.single_process(device)
78
+ )
79
+ self.device = self.distributed_context.device
80
+ self.model = prepare_model(model, self.distributed_context)
81
+ self.task = task
82
+ self.optimizer = optimizer
83
+ self.scheduler = scheduler
84
+ self.early_stopping = early_stopping
85
+ self.scheduler_step = scheduler_step
86
+ self.state = TrainerState()
87
+
88
+ def _train_moved_batch(self, batch: GraphBatch) -> BatchResult:
89
+ self.optimizer.zero_grad(set_to_none=True)
90
+ logits = _model_forward(self.model, batch)
91
+ loss = self.task.loss(logits, batch)
92
+ if not torch.isfinite(loss):
93
+ raise FloatingPointError("non-finite training loss")
94
+ loss.backward()
95
+ self.optimizer.step()
96
+ self.state.global_step += 1
97
+ return BatchResult(loss=float(loss.detach().cpu()), logits=logits.detach())
98
+
99
+ def train_batch(self, batch: GraphBatch) -> BatchResult:
100
+ """Run one optimizer step, moving the supplied batch to the device."""
101
+
102
+ return self._train_moved_batch(batch.to(self.device))
103
+
104
+ def train_epoch(self, loader: Any) -> EpochResult:
105
+ self.model.train()
106
+ losses: list[float] = []
107
+ batches: list[GraphBatch] = []
108
+ outputs: list[torch.Tensor] = []
109
+ num_samples = 0
110
+ for batch in loader:
111
+ moved = batch.to(self.device)
112
+ result = self._train_moved_batch(moved)
113
+ losses.append(result.loss)
114
+ batches.append(moved)
115
+ outputs.append(result.logits)
116
+ num_samples += int(moved.labels.shape[0])
117
+ if not losses:
118
+ raise ValueError("cannot train on an empty loader")
119
+ logits = torch.cat(outputs, dim=0)
120
+ combined = _combined_batch(batches, logits)
121
+ if self.distributed_context.enabled:
122
+ combined = self._gather_combined(combined)
123
+ # Compute the reported loss over the global event set. Gradients
124
+ # still came from local losses and were synchronized by DDP.
125
+ global_loss = float(self.task.loss(combined.logits, combined).cpu())
126
+ else:
127
+ global_loss = sum(losses) / len(losses)
128
+ metrics = {
129
+ key: float(value)
130
+ for key, value in self.task.metrics(logits, combined).items()
131
+ }
132
+ # Legacy training reports the arithmetic mean of batch losses, while
133
+ # metrics are evaluated over every event in the complete epoch.
134
+ return EpochResult(
135
+ loss=global_loss, metrics=metrics, num_samples=int(combined.labels.shape[0])
136
+ )
137
+
138
+ def evaluate(
139
+ self, loader: Any, *, return_outputs: bool = False
140
+ ) -> EpochResult | Any:
141
+ self.model.eval()
142
+ batches: list[GraphBatch] = []
143
+ outputs: list[torch.Tensor] = []
144
+ with torch.inference_mode():
145
+ for batch in loader:
146
+ moved = batch.to(self.device)
147
+ outputs.append(_model_forward(self.model, moved).detach())
148
+ batches.append(moved)
149
+ if not outputs:
150
+ raise ValueError("cannot evaluate an empty loader")
151
+ logits = torch.cat(outputs, dim=0)
152
+ combined = _combined_batch(batches, logits)
153
+ if self.distributed_context.enabled:
154
+ combined = self._gather_combined(combined)
155
+ loss = self.task.loss(logits, combined)
156
+ metrics = {
157
+ key: float(value)
158
+ for key, value in self.task.metrics(logits, combined).items()
159
+ }
160
+ result = EpochResult(
161
+ loss=float(loss.cpu()),
162
+ metrics=metrics,
163
+ num_samples=int(logits.shape[0]),
164
+ )
165
+ if return_outputs:
166
+ return (
167
+ result,
168
+ logits.cpu(),
169
+ combined.labels.cpu(),
170
+ combined.metadata.weight.cpu(),
171
+ combined.metadata.sample_id,
172
+ )
173
+ return result
174
+
175
+ def _gather_combined(self, combined: Any) -> Any:
176
+ logits = gather_tensor(combined.logits, self.distributed_context)
177
+ labels = gather_tensor(combined.labels, self.distributed_context)
178
+ weights = gather_tensor(combined.metadata.weight, self.distributed_context)
179
+ ids = tuple(
180
+ item
181
+ for group in gather_objects(
182
+ combined.metadata.sample_id, self.distributed_context
183
+ )
184
+ for item in group
185
+ )
186
+ return SimpleNamespace(
187
+ labels=labels,
188
+ metadata=SimpleNamespace(weight=weights, sample_id=ids),
189
+ logits=logits,
190
+ )
191
+
192
+ def fit(
193
+ self,
194
+ train_loader: Any,
195
+ validation_loader: Any | None = None,
196
+ *,
197
+ epochs: int,
198
+ ) -> TrainingHistory:
199
+ """Train using validation data for every-epoch model selection.
200
+
201
+ The held-out test split is intentionally absent from this method and
202
+ should be passed to :meth:`evaluate` only after model selection is
203
+ complete. This avoids the legacy train/test/val naming inversion.
204
+ """
205
+
206
+ if epochs < 1:
207
+ raise ValueError("epochs must be positive")
208
+ if self.early_stopping is not None and validation_loader is None:
209
+ raise ValueError("early stopping requires a validation loader")
210
+ history = TrainingHistory()
211
+ # ``state.epoch`` is the last completed epoch. This makes a saved
212
+ # state unambiguous: a resumed fit starts at ``epoch + 1``.
213
+ start_epoch = self.state.epoch + 1
214
+ for epoch in range(start_epoch, start_epoch + epochs):
215
+ if hasattr(train_loader, "set_epoch"):
216
+ train_loader.set_epoch(epoch)
217
+ if validation_loader is not None and hasattr(
218
+ validation_loader, "set_epoch"
219
+ ):
220
+ validation_loader.set_epoch(epoch)
221
+ train_result = self.train_epoch(train_loader)
222
+ history.train.append(train_result)
223
+ validation_result = None
224
+ if validation_loader is not None:
225
+ validation_result = self.evaluate(validation_loader)
226
+ history.validation.append(validation_result)
227
+ if self.distributed_context.is_main_process:
228
+ logger.info("epoch=%d train_loss=%s", epoch, train_result.loss)
229
+ if (
230
+ validation_result is not None
231
+ and self.distributed_context.is_main_process
232
+ ):
233
+ logger.info(
234
+ "epoch=%d validation_loss=%s metrics=%s",
235
+ epoch,
236
+ validation_result.loss,
237
+ validation_result.metrics,
238
+ )
239
+ # Record the completed epoch before policy logic can terminate
240
+ # the loop, so a checkpoint after early stopping is resumable.
241
+ self.state.epoch = epoch
242
+ if self.early_stopping is not None:
243
+ if self.early_stopping.monitor == "loss":
244
+ monitored = validation_result.loss
245
+ else:
246
+ if validation_result is None:
247
+ raise ValueError("early stopping monitor requires validation")
248
+ try:
249
+ monitored = validation_result.metrics[
250
+ self.early_stopping.monitor
251
+ ]
252
+ except KeyError as error:
253
+ raise KeyError(
254
+ f"metric {self.early_stopping.monitor!r} was not produced"
255
+ ) from error
256
+ stop = (
257
+ self.early_stopping.update(monitored)
258
+ if self.distributed_context.is_main_process
259
+ else False
260
+ )
261
+ if broadcast_bool(stop, self.distributed_context):
262
+ break
263
+ if self.scheduler is not None:
264
+ if self.scheduler_step == "validation":
265
+ if validation_result is None:
266
+ raise ValueError(
267
+ "validation scheduler requires a validation loader"
268
+ )
269
+ self.scheduler.step(validation_result.loss)
270
+ else:
271
+ self.scheduler.step()
272
+ return history
tests/integration/__init__.py ADDED
File without changes
tests/integration/test_real_root_model.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ROOT-GNN model coverage over every entry in the 64-event ROOT fixture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import awkward as ak
9
+ import pytest
10
+ import torch
11
+
12
+ from gnn4colliders.data import EventMetadata, GraphSample, batch_graph_samples
13
+ from gnn4colliders.features import build_node_features
14
+ from gnn4colliders.graphs import build_dgl_graph
15
+ from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
16
+
17
+ pytest.importorskip("dgl")
18
+ uproot = pytest.importorskip("uproot")
19
+
20
+ FEATURE_BRANCHES = [
21
+ ["jet_pt", "ele_pt", "mu_pt", "ph_pt", "MET_met"],
22
+ ["jet_eta", "ele_eta", "mu_eta", "ph_eta", 0],
23
+ ["jet_phi", "ele_phi", "mu_phi", "ph_phi", "MET_phi"],
24
+ "CALC_E",
25
+ ["jet_btag", 0, 0, 0, 0],
26
+ [0, "ele_charge", "mu_charge", 0, 0],
27
+ "NODE_TYPE",
28
+ ]
29
+ OBJECT_TYPES = ["vector", "vector", "vector", "vector", "single"]
30
+ FEATURE_SCALES = [0.1, 1, 1, 0.1, 1, 1, 1]
31
+
32
+
33
+ def _fixture_path() -> Path:
34
+ configured = os.environ.get("GNN4COLLIDERS_ROOT_FIXTURE")
35
+ return Path(configured) if configured else Path("data/processed/ttH_NLO_64.root")
36
+
37
+
38
+ @pytest.fixture(scope="module")
39
+ def root_graph_samples():
40
+ path = _fixture_path()
41
+ if not path.exists():
42
+ pytest.skip(f"ROOT sample fixture is absent: {path}")
43
+ with uproot.open(path) as root_file:
44
+ arrays = root_file["output"].arrays(entry_start=0, entry_stop=64, library="ak")
45
+ samples = []
46
+ for index, event in enumerate(ak.Array(arrays)):
47
+ features, _ = build_node_features(
48
+ event, FEATURE_BRANCHES, OBJECT_TYPES, FEATURE_SCALES
49
+ )
50
+ samples.append(
51
+ GraphSample(
52
+ graph=build_dgl_graph(features),
53
+ label=torch.tensor(index % 2, dtype=torch.long),
54
+ global_features=torch.tensor(
55
+ [float(event["Number"]), float(event["weight"])],
56
+ dtype=torch.float32,
57
+ ),
58
+ metadata=EventMetadata(
59
+ fold=index % 4,
60
+ weight=float(event["weight"]),
61
+ sample_id=f"fixture:{index}",
62
+ ),
63
+ )
64
+ )
65
+ return samples
66
+
67
+
68
+ def test_all_64_real_root_graphs_run_as_one_graph_batch(root_graph_samples):
69
+ batch = batch_graph_samples(root_graph_samples)
70
+ model = EdgeNetwork(
71
+ root_graph_samples[0].graph,
72
+ torch.zeros(1, 2),
73
+ hid_size=16,
74
+ out_size=12,
75
+ n_layers=2,
76
+ n_proc_steps=2,
77
+ ).eval()
78
+ transferred = FineTunedEdgeNetwork.from_pretrained(
79
+ model, out_size=1, freeze_backbone=True
80
+ ).eval()
81
+ with torch.no_grad():
82
+ logits = model(batch)
83
+ transfer_logits = transferred(batch)
84
+ assert batch.graph.batch_num_nodes().shape == (64,)
85
+ assert logits.shape == (64, 12)
86
+ assert transfer_logits.shape == (64, 1)
87
+ assert torch.isfinite(logits).all()
88
+ assert torch.isfinite(transfer_logits).all()
89
+
90
+
91
+ def test_real_root_graph_batch_matches_individual_forward(root_graph_samples):
92
+ model = EdgeNetwork(
93
+ root_graph_samples[0].graph,
94
+ torch.zeros(1, 2),
95
+ hid_size=8,
96
+ out_size=1,
97
+ n_layers=2,
98
+ n_proc_steps=1,
99
+ ).eval()
100
+ batch = batch_graph_samples(root_graph_samples)
101
+ with torch.no_grad():
102
+ batched = model(batch)
103
+ individual = torch.cat(
104
+ [
105
+ model(sample.graph, sample.global_features)
106
+ for sample in root_graph_samples
107
+ ]
108
+ )
109
+ assert torch.allclose(batched, individual, atol=1e-5, rtol=1e-5)
tests/integration/test_real_root_sample.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke tests against a small fixture derived from the real ttH ROOT sample."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import awkward as ak
9
+ import pytest
10
+ import torch
11
+ import uproot
12
+
13
+ from gnn4colliders.features import build_node_features
14
+ from gnn4colliders.graphs import build_dgl_graph
15
+
16
+ pytest.importorskip("dgl")
17
+
18
+ FEATURE_BRANCHES = [
19
+ ["jet_pt", "ele_pt", "mu_pt", "ph_pt", "MET_met"],
20
+ ["jet_eta", "ele_eta", "mu_eta", "ph_eta", 0],
21
+ ["jet_phi", "ele_phi", "mu_phi", "ph_phi", "MET_phi"],
22
+ "CALC_E",
23
+ ["jet_btag", 0, 0, 0, 0],
24
+ [0, "ele_charge", "mu_charge", 0, 0],
25
+ "NODE_TYPE",
26
+ ]
27
+ OBJECT_TYPES = ["vector", "vector", "vector", "vector", "single"]
28
+ FEATURE_SCALES = [0.1, 1, 1, 0.1, 1, 1, 1]
29
+
30
+
31
+ def _fixture_path() -> Path:
32
+ configured = os.environ.get("GNN4COLLIDERS_ROOT_FIXTURE")
33
+ if configured:
34
+ return Path(configured)
35
+ return Path("data/processed/ttH_NLO_64.root")
36
+
37
+
38
+ @pytest.fixture(scope="module")
39
+ def root_tree():
40
+ path = _fixture_path()
41
+ if not path.exists():
42
+ pytest.skip(
43
+ f"ROOT sample fixture is absent: {path}; download ttH_NLO.root and "
44
+ "create the reduced fixture first"
45
+ )
46
+ root_file = uproot.open(path)
47
+ tree = root_file["output"]
48
+ try:
49
+ yield tree
50
+ finally:
51
+ root_file.close()
52
+
53
+
54
+ def test_real_root_sample_has_expected_fixture_shape(root_tree):
55
+ assert root_tree.num_entries == 64
56
+ assert {"jet_pt", "MET_met", "weight", "Number"}.issubset(root_tree.keys())
57
+
58
+
59
+ def test_real_root_events_build_features_and_graphs(root_tree):
60
+ arrays = root_tree.arrays(entry_start=0, entry_stop=8, library="ak")
61
+ for event in ak.Array(arrays):
62
+ event_features, lengths = build_node_features(
63
+ event, FEATURE_BRANCHES, OBJECT_TYPES, FEATURE_SCALES
64
+ )
65
+ graph = build_dgl_graph(event_features)
66
+
67
+ assert event_features.dtype == torch.float32
68
+ assert event_features.shape == (sum(lengths), 7)
69
+ assert graph.number_of_nodes() == event_features.shape[0]
70
+ expected_edges = event_features.shape[0] * max(event_features.shape[0] - 1, 1)
71
+ assert graph.number_of_edges() == expected_edges
72
+ assert graph.edata["features"].shape == (expected_edges, 3)
73
+ assert torch.isfinite(event_features).all()
74
+ assert torch.isfinite(graph.edata["features"]).all()
tests/integration/test_root_gnn_model.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from gnn4colliders.data import EventMetadata, GraphSample, batch_graph_samples
5
+ from gnn4colliders.graphs import build_dgl_graph
6
+ from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
7
+
8
+ pytest.importorskip("dgl")
9
+
10
+
11
+ def test_graph_batch_runs_through_pretraining_and_transfer_models():
12
+ graph = build_dgl_graph(torch.tensor([[10.0, 0.2, 0.1], [8.0, -0.3, -0.2]]))
13
+ sample = GraphSample(
14
+ graph=graph,
15
+ label=torch.tensor(1),
16
+ global_features=torch.tensor([1.0, 2.0]),
17
+ metadata=EventMetadata(fold=0, weight=1.0, sample_id="fixture:0"),
18
+ )
19
+ batch = batch_graph_samples([sample])
20
+ model = EdgeNetwork(graph, torch.zeros(1, 2), 8, 12, 2, 1)
21
+ transferred = FineTunedEdgeNetwork.from_pretrained(model, 1)
22
+ assert model(batch).shape == (1, 12)
23
+ assert transferred(batch).shape == (1, 1)
tests/unit/models/root_gnn/test_blocks.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from torch import nn
3
+
4
+ from gnn4colliders.models.root_gnn.blocks import make_mlp
5
+
6
+
7
+ def test_make_mlp_matches_legacy_layer_order():
8
+ block = make_mlp(3, 4, 5, 2, dropout=0.1)
9
+ assert [type(layer) for layer in block] == [
10
+ nn.Linear,
11
+ nn.ReLU,
12
+ nn.Dropout,
13
+ nn.Linear,
14
+ nn.ReLU,
15
+ nn.Dropout,
16
+ nn.LayerNorm,
17
+ ]
18
+
19
+
20
+ def test_make_mlp_rejects_empty_network():
21
+ with pytest.raises(ValueError, match="positive"):
22
+ make_mlp(3, 4, 5, 0)
tests/unit/models/root_gnn/test_edge_cases.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from gnn4colliders.graphs import build_dgl_graph
5
+ from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
6
+
7
+ pytest.importorskip("dgl")
8
+
9
+
10
+ def _graph(node_features):
11
+ graph = build_dgl_graph(torch.tensor(node_features, dtype=torch.float32))
12
+ return graph, torch.tensor([[1.0]], dtype=torch.float32)
13
+
14
+
15
+ def test_no_globals_uses_graph_node_count_as_legacy_fallback():
16
+ graph, _ = _graph([[10.0, 0.2, 0.1]])
17
+ model = EdgeNetwork(graph, torch.empty((1, 0)), 4, 1, 1, 0).eval()
18
+ with torch.no_grad():
19
+ logits = model(graph)
20
+ assert logits.shape == (1, 1)
21
+ assert torch.isfinite(logits).all()
22
+
23
+
24
+ def test_missing_or_mismatched_global_shapes_fail_clearly():
25
+ graph, globals_ = _graph([[10.0, 0.2, 0.1]])
26
+ model = EdgeNetwork(graph, globals_, 4, 1, 1, 0)
27
+ with pytest.raises(ValueError, match="global_features are required"):
28
+ model(graph)
29
+ batched_graph = build_dgl_graph(
30
+ torch.tensor([[10.0, 0.2, 0.1], [8.0, -0.3, -0.2]], dtype=torch.float32)
31
+ )
32
+ batched_graph = __import__("dgl").batch([graph, batched_graph])
33
+ with pytest.raises(ValueError, match="one-dimensional"):
34
+ model(batched_graph, torch.tensor([1.0]))
35
+
36
+
37
+ def test_model_preserves_float64_dtype():
38
+ graph = build_dgl_graph(
39
+ torch.tensor([[10.0, 0.2, 0.1], [8.0, -0.3, -0.2]], dtype=torch.float64)
40
+ )
41
+ globals_ = torch.tensor([[1.0]], dtype=torch.float64)
42
+ model = EdgeNetwork(graph, globals_, 4, 1, 1, 1).double().eval()
43
+ assert model(graph, globals_).dtype == torch.float64
44
+
45
+
46
+ def test_negative_processing_steps_are_rejected():
47
+ graph, globals_ = _graph([[10.0, 0.2, 0.1]])
48
+ with pytest.raises(ValueError, match="non-negative"):
49
+ EdgeNetwork(graph, globals_, 4, 1, 1, -1)
50
+
51
+
52
+ def test_single_node_graph_and_zero_processing_step_are_supported():
53
+ graph, globals_ = _graph([[10.0, 0.2, 0.1]])
54
+ model = EdgeNetwork(graph, globals_, 4, 1, 1, 0).eval()
55
+ assert graph.number_of_edges() == 1
56
+ assert model(graph, globals_).shape == (1, 1)
57
+
58
+
59
+ def test_weighted_padded_nodes_produce_finite_logits():
60
+ graph, globals_ = _graph([[10.0, 0.2, 0.1], [8.0, -0.3, -0.2], [0.0, 0.0, 0.0]])
61
+ graph.ndata["w"] = torch.tensor([[1.0], [1.0], [0.0]])
62
+ model = EdgeNetwork(graph, globals_, 4, 1, 1, 1).eval()
63
+ logits = model(graph, globals_)
64
+ assert logits.shape == (1, 1)
65
+ assert torch.isfinite(logits).all()
66
+
67
+
68
+ def test_unfrozen_transfer_backbone_receives_gradients():
69
+ graph, globals_ = _graph([[10.0, 0.2, 0.1], [8.0, -0.3, -0.2]])
70
+ source = EdgeNetwork(graph, globals_, 4, 3, 1, 1)
71
+ transfer = FineTunedEdgeNetwork.from_pretrained(source, 1, freeze_backbone=False)
72
+ transfer(graph, globals_).sum().backward()
73
+ assert any(
74
+ parameter.grad is not None for parameter in transfer.backbone.parameters()
75
+ )
76
+ assert all(
77
+ parameter.grad is not None for parameter in transfer.classifier.parameters()
78
+ )
79
+
80
+
81
+ def test_frozen_transfer_backbone_has_no_gradients_and_source_is_unchanged():
82
+ graph, globals_ = _graph([[10.0, 0.2, 0.1], [8.0, -0.3, -0.2]])
83
+ source = EdgeNetwork(graph, globals_, 4, 3, 1, 1)
84
+ source_classifier_type = type(source.classifier)
85
+ transfer = FineTunedEdgeNetwork.from_pretrained(source, 1, freeze_backbone=True)
86
+ transfer(graph, globals_).sum().backward()
87
+ assert isinstance(source.classifier, source_classifier_type)
88
+ assert isinstance(transfer.backbone.classifier, torch.nn.Identity)
89
+ assert all(parameter.grad is None for parameter in transfer.backbone.parameters())
tests/unit/models/root_gnn/test_edge_network.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import torch
3
+
4
+ from gnn4colliders.graphs import build_dgl_graph
5
+ from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
6
+
7
+ dgl = pytest.importorskip("dgl")
8
+
9
+
10
+ def _model(out_size=1):
11
+ graph = build_dgl_graph(torch.tensor([[10.0, 0.2, 0.1], [8.0, -0.3, -0.2]]))
12
+ return EdgeNetwork(graph, torch.zeros(1, 2), 8, out_size, 2, 2), graph
13
+
14
+
15
+ def test_edge_network_returns_raw_binary_logits_without_graph_leakage():
16
+ model, graph = _model()
17
+ globals_ = torch.tensor([[1.0, 2.0]])
18
+ before = set(graph.ndata.keys()) | set(graph.edata.keys())
19
+ logits = model(graph, globals_)
20
+ again = model(graph, globals_)
21
+ assert logits.shape == (1, 1)
22
+ assert logits.dtype == torch.float32
23
+ assert torch.allclose(logits, again)
24
+ assert torch.equal(
25
+ model.representation(graph, globals_), model.forward_features(graph, globals_)
26
+ )
27
+ assert model.classify is model.classifier
28
+ assert before == (set(graph.ndata.keys()) | set(graph.edata.keys()))
29
+
30
+
31
+ def test_edge_network_supports_multiclass_and_transfer_freezing():
32
+ model, graph = _model(out_size=12)
33
+ transferred = FineTunedEdgeNetwork.from_pretrained(model, 1, freeze_backbone=True)
34
+ assert transferred(graph, torch.zeros(1, 2)).shape == (1, 1)
35
+ assert not any(
36
+ parameter.requires_grad for parameter in transferred.backbone.parameters()
37
+ )
38
+ assert all(
39
+ parameter.requires_grad for parameter in transferred.classifier.parameters()
40
+ )
41
+ assert model(graph, torch.zeros(1, 2)).shape == (1, 12)
tests/unit/tasks/test_classification.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from types import SimpleNamespace
2
+
3
+ import pytest
4
+ import torch
5
+
6
+ from gnn4colliders.tasks import BinaryClassificationTask, MulticlassClassificationTask
7
+
8
+
9
+ def _batch(labels, weights):
10
+ return SimpleNamespace(
11
+ labels=torch.tensor(labels),
12
+ metadata=SimpleNamespace(weight=torch.tensor(weights)),
13
+ )
14
+
15
+
16
+ def test_binary_normalizes_column_or_vector_targets_and_uses_named_weight():
17
+ task = BinaryClassificationTask()
18
+ batch = _batch([[0], [0], [1], [1]], [1.0, 2.0, 1.0, 3.0])
19
+ logits = torch.tensor([[-1.0], [0.0], [0.5], [1.0]])
20
+ expected_elementwise = torch.nn.functional.binary_cross_entropy_with_logits(
21
+ logits[:, 0], batch.labels[:, 0].float(), reduction="none"
22
+ )
23
+ expected = (
24
+ (expected_elementwise[:2] * torch.tensor([1.0, 2.0])).sum() / 3
25
+ + (expected_elementwise[2:] * torch.tensor([1.0, 3.0])).sum() / 4
26
+ ) / 2
27
+ assert torch.allclose(task.loss(logits, batch), expected)
28
+
29
+
30
+ def test_binary_negative_weights_are_not_absolute_by_default():
31
+ batch = _batch([0, 0, 1, 1], [1.0, -2.0, 1.0, 1.0])
32
+ logits = torch.tensor([[0.0], [2.0], [0.0], [2.0]])
33
+ task = BinaryClassificationTask()
34
+ elementwise = torch.nn.functional.binary_cross_entropy_with_logits(
35
+ logits[:, 0], batch.labels.float(), reduction="none"
36
+ )
37
+ expected = (
38
+ (elementwise[:2] * torch.tensor([1.0, -2.0])).sum() / -1
39
+ + elementwise[2:].mean()
40
+ ) / 2
41
+ assert task.loss(logits, batch).item() == pytest.approx(expected.item())
42
+ assert BinaryClassificationTask(absolute_weights=True).loss(
43
+ logits, batch
44
+ ).item() != pytest.approx(expected.item())
45
+
46
+
47
+ def test_binary_predictions_and_undefined_auc():
48
+ task = BinaryClassificationTask()
49
+ batch = _batch([1, 1], [1.0, 2.0])
50
+ output = task.predictions(torch.tensor([[-2.0], [2.0]]))
51
+ assert output["scores"].shape == (2,)
52
+ assert output["predictions"].tolist() == [False, True]
53
+ assert (
54
+ task.metrics(torch.tensor([[-2.0], [2.0]]), batch)["roc_auc"]
55
+ != task.metrics(torch.tensor([[-2.0], [2.0]]), batch)["roc_auc"]
56
+ )
57
+
58
+
59
+ def test_multiclass_loss_and_metrics():
60
+ task = MulticlassClassificationTask()
61
+ batch = _batch([0, 1, 2], [1.0, 1.0, 1.0])
62
+ logits = torch.eye(3)
63
+ assert task.loss(logits, batch).item() > 0
64
+ output = task.predict(logits)
65
+ assert output["predictions"].tolist() == [0, 1, 2]
66
+ assert task.metrics(logits, batch)["accuracy"] == 1.0
tests/unit/training/test_checkpoint.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+
3
+ import numpy as np
4
+ import torch
5
+ from torch import nn
6
+
7
+ from gnn4colliders.training import (
8
+ CheckpointManager,
9
+ EarlyStopping,
10
+ TrainerState,
11
+ load_legacy_checkpoint,
12
+ load_model_weights,
13
+ restore_training_state,
14
+ )
15
+
16
+
17
+ def test_checkpoint_round_trip_and_selection(tmp_path):
18
+ model = nn.Linear(2, 1)
19
+ optimizer = torch.optim.Adam(model.parameters(), lr=0.1)
20
+ scheduler = torch.optim.lr_scheduler.ExponentialLR(optimizer, gamma=0.5)
21
+ model(torch.ones(2)).sum().backward()
22
+ optimizer.step()
23
+ scheduler.step()
24
+ early = EarlyStopping(patience=2)
25
+ early.update(3.0)
26
+ state = TrainerState(epoch=9, global_step=17)
27
+ manager = CheckpointManager(tmp_path)
28
+ manager.save(
29
+ model=model,
30
+ optimizer=optimizer,
31
+ scheduler=scheduler,
32
+ early_stopping=early,
33
+ trainer_state=state,
34
+ task_config={"type": "binary_classification", "threshold": 0.5},
35
+ monitor_name="loss",
36
+ monitor_value=0.4,
37
+ )
38
+ state.epoch = 10
39
+ manager.save(
40
+ model=model,
41
+ optimizer=optimizer,
42
+ scheduler=scheduler,
43
+ early_stopping=early,
44
+ trainer_state=state,
45
+ monitor_name="loss",
46
+ monitor_value=0.8,
47
+ )
48
+
49
+ assert manager.latest().name == "epoch_0010.pt"
50
+ assert manager.best(monitor="loss").name == "epoch_0009.pt"
51
+ payload = manager.load(manager.latest())
52
+ assert payload["schema_version"] == 1
53
+ assert payload["task_config"] is None # omitted explicitly for the second save
54
+
55
+ restored_model = nn.Linear(2, 1)
56
+ restored_optimizer = torch.optim.Adam(restored_model.parameters(), lr=0.1)
57
+ restored_scheduler = torch.optim.lr_scheduler.ExponentialLR(
58
+ restored_optimizer, gamma=0.5
59
+ )
60
+ restored_early = EarlyStopping(patience=2)
61
+ restored = restore_training_state(
62
+ payload,
63
+ model=restored_model,
64
+ optimizer=restored_optimizer,
65
+ scheduler=restored_scheduler,
66
+ early_stopping=restored_early,
67
+ )
68
+ assert restored == TrainerState(epoch=10, global_step=17)
69
+ assert restored_scheduler.last_epoch == scheduler.last_epoch
70
+ assert restored_early.best == early.best
71
+ assert all(
72
+ torch.equal(left, right)
73
+ for left, right in zip(model.parameters(), restored_model.parameters())
74
+ )
75
+
76
+
77
+ def test_weight_only_load_does_not_need_optimizer(tmp_path):
78
+ model = nn.Linear(1, 1)
79
+ manager = CheckpointManager(tmp_path)
80
+ path = manager.save(model=model, trainer_state=TrainerState(epoch=0))
81
+ fresh = nn.Linear(1, 1)
82
+ load_model_weights(fresh, manager.load(path))
83
+ assert torch.equal(model.weight, fresh.weight)
84
+ assert torch.equal(model.bias, fresh.bias)
85
+
86
+
87
+ def test_legacy_checkpoint_normalizes_supported_prefixes():
88
+ state = {"module._orig_mod.linear.weight": torch.ones(1, 1)}
89
+ normalized = load_legacy_checkpoint(
90
+ {"epoch": 4, "model_state_dict": state, "early_stop": {"count": 2}}
91
+ )
92
+ assert list(normalized["model_state_dict"]) == ["linear.weight"]
93
+ assert normalized["epoch"] == 4
94
+ assert normalized["early_stopping_state"]["num_bad_epochs"] == 2
95
+
96
+
97
+ def test_rng_state_is_restored(tmp_path):
98
+ manager = CheckpointManager(tmp_path)
99
+ model = nn.Linear(1, 1)
100
+ path = manager.save(model=model, trainer_state=TrainerState(epoch=0))
101
+ expected = (random.random(), np.random.random(), torch.rand(1))
102
+ payload = manager.load(path)
103
+ restore_training_state(payload, model=nn.Linear(1, 1))
104
+ actual = (random.random(), np.random.random(), torch.rand(1))
105
+ assert expected[0] == actual[0]
106
+ assert expected[1] == actual[1]
107
+ assert torch.equal(expected[2], actual[2])
tests/unit/training/test_trainer.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+
4
+ from gnn4colliders.data import BatchMetadata, EventMetadata, GraphBatch
5
+ from gnn4colliders.tasks import BinaryClassificationTask
6
+ from gnn4colliders.training import (
7
+ EarlyStopping,
8
+ Trainer,
9
+ build_optimizer,
10
+ build_scheduler,
11
+ )
12
+
13
+
14
+ class TinyModel(nn.Module):
15
+ def __init__(self):
16
+ super().__init__()
17
+ self.linear = nn.Linear(1, 1)
18
+
19
+ def forward(self, batch):
20
+ return self.linear(batch.graph)
21
+
22
+
23
+ def _batch(values, labels, weights=None):
24
+ values = torch.tensor(values, dtype=torch.float32).reshape(-1, 1)
25
+ if weights is None:
26
+ weights = [1.0] * len(labels)
27
+ return GraphBatch(
28
+ graph=values,
29
+ labels=torch.tensor(labels),
30
+ global_features=None,
31
+ metadata=BatchMetadata.from_events(
32
+ [
33
+ EventMetadata(fold=0, weight=weight, sample_id=f"event:{index}")
34
+ for index, weight in enumerate(weights)
35
+ ]
36
+ ),
37
+ )
38
+
39
+
40
+ def test_graph_batch_to_moves_named_tensors_without_losing_ids():
41
+ batch = _batch([1, 2], [0, 1])
42
+ moved = batch.to("cpu")
43
+ assert moved.labels.device.type == "cpu"
44
+ assert moved.metadata.weight.device.type == "cpu"
45
+ assert moved.metadata.sample_id == ("event:0", "event:1")
46
+
47
+
48
+ def test_trainer_aggregates_metrics_over_the_complete_epoch():
49
+ torch.manual_seed(4)
50
+ model = TinyModel()
51
+ optimizer = build_optimizer(model, learning_rate=0.01)
52
+ scheduler = build_scheduler(optimizer, gamma=0.5)
53
+ trainer = Trainer(model, BinaryClassificationTask(), optimizer, scheduler)
54
+ loader = [
55
+ _batch([1, 2], [0, 1]),
56
+ _batch([3, 4], [0, 1]),
57
+ ]
58
+ before = model.linear.weight.detach().clone()
59
+ result = trainer.train_epoch(loader)
60
+ assert result.num_samples == 4
61
+ assert set(result.metrics) == {"accuracy", "roc_auc", "auc"}
62
+ assert torch.isfinite(torch.tensor(result.loss))
63
+ assert not torch.equal(before, model.linear.weight.detach())
64
+ trainer.scheduler.step()
65
+ assert optimizer.param_groups[0]["lr"] == 0.005
66
+
67
+
68
+ def test_fit_uses_validation_for_early_stopping_and_records_history():
69
+ model = TinyModel()
70
+ optimizer = build_optimizer(model, learning_rate=0.01)
71
+ trainer = Trainer(
72
+ model,
73
+ BinaryClassificationTask(),
74
+ optimizer,
75
+ early_stopping=EarlyStopping(patience=1),
76
+ )
77
+ loader = [_batch([1, 2], [0, 1])]
78
+ history = trainer.fit(loader, loader, epochs=4)
79
+ assert len(history.train) == len(history.validation)
80
+ assert 1 <= len(history.train) <= 4
81
+ assert all(isinstance(item.loss, float) for item in history.train)