Commit ·
fe85bec
1
Parent(s): 2c516a8
feat: add ROOT-GNN tasks and training lifecycle
Browse files- src/gnn4colliders/models/__init__.py +1 -0
- src/gnn4colliders/models/root_gnn/__init__.py +6 -0
- src/gnn4colliders/models/root_gnn/blocks.py +39 -0
- src/gnn4colliders/models/root_gnn/edge_network.py +188 -0
- src/gnn4colliders/models/root_gnn/transfer.py +83 -0
- src/gnn4colliders/tasks/__init__.py +6 -0
- src/gnn4colliders/tasks/_common.py +78 -0
- src/gnn4colliders/tasks/binary_classification.py +78 -0
- src/gnn4colliders/tasks/multiclass_classification.py +80 -0
- src/gnn4colliders/training/__init__.py +37 -0
- src/gnn4colliders/training/checkpoint.py +328 -0
- src/gnn4colliders/training/early_stopping.py +72 -0
- src/gnn4colliders/training/optim.py +38 -0
- src/gnn4colliders/training/reproducibility.py +20 -0
- src/gnn4colliders/training/schedulers.py +22 -0
- src/gnn4colliders/training/state.py +31 -0
- src/gnn4colliders/training/trainer.py +272 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_real_root_model.py +109 -0
- tests/integration/test_real_root_sample.py +74 -0
- tests/integration/test_root_gnn_model.py +23 -0
- tests/unit/models/root_gnn/test_blocks.py +22 -0
- tests/unit/models/root_gnn/test_edge_cases.py +89 -0
- tests/unit/models/root_gnn/test_edge_network.py +41 -0
- tests/unit/tasks/test_classification.py +66 -0
- tests/unit/training/test_checkpoint.py +107 -0
- tests/unit/training/test_trainer.py +81 -0
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)
|