Commit ·
7997c78
1
Parent(s): 565354b
feat: add Hydra configuration and CLI workflows
Browse files- configs/checkpoint/default.yaml +6 -0
- configs/config.yaml +18 -0
- configs/data/delphes.yaml +4 -0
- configs/data/local_test.yaml +12 -0
- configs/environment/local.yaml +3 -0
- configs/environment/perlmutter.yaml +3 -0
- configs/inference/default.yaml +4 -0
- configs/model/root_gnn/edge_network.yaml +7 -0
- configs/model/root_gnn/fine_tuned_edge_network.yaml +9 -0
- configs/task/binary_classification.yaml +3 -0
- configs/task/pretraining_multiclass.yaml +3 -0
- configs/task/tth_cp_finetune.yaml +3 -0
- configs/trainer/debug.yaml +4 -0
- configs/trainer/default.yaml +15 -0
- pyproject.toml +3 -0
- src/gnn4colliders/cli/__init__.py +71 -0
- src/gnn4colliders/config/__init__.py +6 -0
- src/gnn4colliders/config/application.py +187 -0
- src/gnn4colliders/config/factories.py +139 -0
- src/gnn4colliders/config/validation.py +41 -0
configs/checkpoint/default.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
directory: ${environment.output_root}/checkpoints
|
| 2 |
+
resume: null
|
| 3 |
+
pretrained: null
|
| 4 |
+
save_every_epochs: 1
|
| 5 |
+
save_best: false
|
| 6 |
+
save_last: true
|
configs/config.yaml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defaults:
|
| 2 |
+
- data: local_test
|
| 3 |
+
- model: root_gnn/edge_network
|
| 4 |
+
- task: pretraining_multiclass
|
| 5 |
+
- trainer: default
|
| 6 |
+
- checkpoint: default
|
| 7 |
+
- inference: default
|
| 8 |
+
- environment: local
|
| 9 |
+
- _self_
|
| 10 |
+
experiment:
|
| 11 |
+
name: pretraining_multiclass
|
| 12 |
+
logging:
|
| 13 |
+
level: INFO
|
| 14 |
+
hydra:
|
| 15 |
+
run:
|
| 16 |
+
dir: .
|
| 17 |
+
job:
|
| 18 |
+
chdir: false
|
configs/data/delphes.yaml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defaults:
|
| 2 |
+
- /data/local_test
|
| 3 |
+
- _self_
|
| 4 |
+
tree_name: Events
|
configs/data/local_test.yaml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
files: []
|
| 2 |
+
tree_name: Events
|
| 3 |
+
batch_size: 32
|
| 4 |
+
num_workers: 0
|
| 5 |
+
shuffle: true
|
| 6 |
+
seed: 42
|
| 7 |
+
cache:
|
| 8 |
+
path: null
|
| 9 |
+
splits:
|
| 10 |
+
train_folds: [0, 1, 2]
|
| 11 |
+
validation_folds: [3]
|
| 12 |
+
test_folds: [4]
|
configs/environment/local.yaml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
device: cpu
|
| 2 |
+
data_root: null
|
| 3 |
+
output_root: outputs/${experiment.name}
|
configs/environment/perlmutter.yaml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
device: cuda
|
| 2 |
+
data_root: null
|
| 3 |
+
output_root: outputs/${experiment.name}
|
configs/inference/default.yaml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
checkpoint: null
|
| 2 |
+
output: ${environment.output_root}/predictions/predictions.npz
|
| 3 |
+
format: npz
|
| 4 |
+
split: test
|
configs/model/root_gnn/edge_network.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
family: root_gnn
|
| 2 |
+
name: edge_network
|
| 3 |
+
hid_size: 128
|
| 4 |
+
out_size: 12
|
| 5 |
+
n_layers: 2
|
| 6 |
+
n_proc_steps: 4
|
| 7 |
+
dropout: 0.1
|
configs/model/root_gnn/fine_tuned_edge_network.yaml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
family: root_gnn
|
| 2 |
+
name: fine_tuned_edge_network
|
| 3 |
+
hid_size: 128
|
| 4 |
+
out_size: 1
|
| 5 |
+
n_layers: 2
|
| 6 |
+
n_proc_steps: 4
|
| 7 |
+
dropout: 0.1
|
| 8 |
+
freeze_backbone: true
|
| 9 |
+
pretrained_checkpoint: null
|
configs/task/binary_classification.yaml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type: binary_classification
|
| 2 |
+
threshold: 0.5
|
| 3 |
+
absolute_weights: false
|
configs/task/pretraining_multiclass.yaml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
type: multiclass_classification
|
| 2 |
+
num_classes: 12
|
| 3 |
+
absolute_weights: false
|
configs/task/tth_cp_finetune.yaml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defaults:
|
| 2 |
+
- /task/binary_classification
|
| 3 |
+
- _self_
|
configs/trainer/debug.yaml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defaults:
|
| 2 |
+
- /trainer/default
|
| 3 |
+
- _self_
|
| 4 |
+
max_epochs: 1
|
configs/trainer/default.yaml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
max_epochs: 100
|
| 2 |
+
device: ${environment.device}
|
| 3 |
+
seed: 42
|
| 4 |
+
optimizer:
|
| 5 |
+
name: adam
|
| 6 |
+
learning_rate: 0.001
|
| 7 |
+
weight_decay: 0.0
|
| 8 |
+
scheduler:
|
| 9 |
+
name: null
|
| 10 |
+
early_stopping:
|
| 11 |
+
enabled: false
|
| 12 |
+
monitor: loss
|
| 13 |
+
mode: min
|
| 14 |
+
patience: 10
|
| 15 |
+
min_delta: 1.0e-8
|
pyproject.toml
CHANGED
|
@@ -22,6 +22,9 @@ ml = [
|
|
| 22 |
"torch>=2.0",
|
| 23 |
]
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
[dependency-groups]
|
| 26 |
dev = [
|
| 27 |
"pytest>=8",
|
|
|
|
| 22 |
"torch>=2.0",
|
| 23 |
]
|
| 24 |
|
| 25 |
+
[project.scripts]
|
| 26 |
+
gnn4colliders = "gnn4colliders.cli:main"
|
| 27 |
+
|
| 28 |
[dependency-groups]
|
| 29 |
dev = [
|
| 30 |
"pytest>=8",
|
src/gnn4colliders/cli/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single, thin command-line entry point for GNN4Colliders."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from hydra import compose, initialize_config_dir
|
| 10 |
+
from omegaconf import OmegaConf
|
| 11 |
+
|
| 12 |
+
from gnn4colliders.config.application import predict_or_evaluate, prepare, train
|
| 13 |
+
|
| 14 |
+
_COMMANDS = {"prepare", "train", "evaluate", "predict"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _help(command: str | None = None) -> None:
|
| 18 |
+
if command:
|
| 19 |
+
print(f"Usage: gnn4colliders {command} [key=value ...]")
|
| 20 |
+
print("Compose Hydra configuration with semantic overrides.")
|
| 21 |
+
else:
|
| 22 |
+
print("Usage: gnn4colliders <prepare|train|evaluate|predict> [key=value ...]")
|
| 23 |
+
print("Use gnn4colliders <command> --help for command help.")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _config(overrides: list[str]):
|
| 27 |
+
config_dir = Path(__file__).resolve().parents[3] / "configs"
|
| 28 |
+
with initialize_config_dir(version_base=None, config_dir=str(config_dir)):
|
| 29 |
+
return compose(config_name="config", overrides=overrides)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main(argv: list[str] | None = None) -> int:
|
| 33 |
+
args = list(sys.argv[1:] if argv is None else argv)
|
| 34 |
+
if not args or args[0] in {"-h", "--help"}:
|
| 35 |
+
_help()
|
| 36 |
+
return 0
|
| 37 |
+
command = args.pop(0)
|
| 38 |
+
if command not in _COMMANDS:
|
| 39 |
+
print(
|
| 40 |
+
f"unknown command {command!r}; choose from {', '.join(sorted(_COMMANDS))}",
|
| 41 |
+
file=sys.stderr,
|
| 42 |
+
)
|
| 43 |
+
return 2
|
| 44 |
+
if "--help" in args or "-h" in args:
|
| 45 |
+
_help(command)
|
| 46 |
+
return 0
|
| 47 |
+
try:
|
| 48 |
+
config = _config(args)
|
| 49 |
+
logging.basicConfig(
|
| 50 |
+
level=getattr(logging, str(config.logging.level).upper(), logging.INFO)
|
| 51 |
+
)
|
| 52 |
+
if command == "prepare":
|
| 53 |
+
print(f"prepared cache: {prepare(config)}")
|
| 54 |
+
elif command == "train":
|
| 55 |
+
print(f"saved checkpoint: {train(config)}")
|
| 56 |
+
elif command == "evaluate":
|
| 57 |
+
result = predict_or_evaluate(config, evaluate=True)
|
| 58 |
+
print(OmegaConf.to_yaml(result.metrics))
|
| 59 |
+
else:
|
| 60 |
+
result = predict_or_evaluate(config)
|
| 61 |
+
print(
|
| 62 |
+
f"wrote predictions: {config.inference.output} "
|
| 63 |
+
f"({len(result.sample_ids)} events)"
|
| 64 |
+
)
|
| 65 |
+
return 0
|
| 66 |
+
except (ValueError, FileNotFoundError, KeyError, ImportError) as error:
|
| 67 |
+
print(f"gnn4colliders: {error}", file=sys.stderr)
|
| 68 |
+
return 2
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
__all__ = ["main"]
|
src/gnn4colliders/config/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic configuration factories used by the command-line applications."""
|
| 2 |
+
|
| 3 |
+
from .factories import build_model, build_task, build_trainer
|
| 4 |
+
from .validation import validate_config
|
| 5 |
+
|
| 6 |
+
__all__ = ["build_model", "build_task", "build_trainer", "validate_config"]
|
src/gnn4colliders/config/application.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application-level orchestration kept separate from the CLI parser."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
from omegaconf import DictConfig, OmegaConf
|
| 11 |
+
|
| 12 |
+
from gnn4colliders.data import (
|
| 13 |
+
GraphDataLoader,
|
| 14 |
+
GraphDataset,
|
| 15 |
+
GraphSample,
|
| 16 |
+
GraphSampleCache,
|
| 17 |
+
RootEventDataset,
|
| 18 |
+
SplitDefinition,
|
| 19 |
+
select_split,
|
| 20 |
+
)
|
| 21 |
+
from gnn4colliders.features import build_node_features
|
| 22 |
+
from gnn4colliders.graphs import build_dgl_graph
|
| 23 |
+
from gnn4colliders.inference import Predictor, write_npz
|
| 24 |
+
from gnn4colliders.training import (
|
| 25 |
+
CheckpointManager,
|
| 26 |
+
restore_training_state,
|
| 27 |
+
seed_everything,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
from .factories import build_model, build_task, build_trainer
|
| 31 |
+
from .validation import validate_config
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def plain(config: DictConfig | dict[str, Any]) -> dict[str, Any]:
|
| 37 |
+
return OmegaConf.to_container(config, resolve=True) # type: ignore[return-value]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def save_resolved(config: DictConfig, output_root: str | Path) -> Path:
|
| 41 |
+
target = Path(output_root) / "resolved_config.yaml"
|
| 42 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
OmegaConf.save(config, target, resolve=True)
|
| 44 |
+
return target
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_samples(config: DictConfig) -> GraphDataset:
|
| 48 |
+
data = config.data
|
| 49 |
+
cache_path = data.cache.path
|
| 50 |
+
if not cache_path:
|
| 51 |
+
raise ValueError("data.cache.path is required for train/evaluate/predict")
|
| 52 |
+
cache = GraphSampleCache(cache_path)
|
| 53 |
+
if not cache.exists():
|
| 54 |
+
raise FileNotFoundError(f"graph sample cache does not exist: {cache_path}")
|
| 55 |
+
return GraphDataset(cache.load())
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def loaders(config: DictConfig) -> dict[str, GraphDataLoader]:
|
| 59 |
+
samples = load_samples(config)
|
| 60 |
+
split = SplitDefinition(
|
| 61 |
+
train_folds=frozenset(config.data.splits.train_folds),
|
| 62 |
+
validation_folds=frozenset(config.data.splits.validation_folds),
|
| 63 |
+
test_folds=frozenset(config.data.splits.test_folds),
|
| 64 |
+
)
|
| 65 |
+
result = {}
|
| 66 |
+
for name in ("train", "validation", "test"):
|
| 67 |
+
selected = GraphDataset(select_split(samples.samples, split, name))
|
| 68 |
+
result[name] = GraphDataLoader(
|
| 69 |
+
selected,
|
| 70 |
+
int(config.data.batch_size),
|
| 71 |
+
shuffle=bool(config.data.shuffle) if name == "train" else False,
|
| 72 |
+
seed=int(config.data.seed),
|
| 73 |
+
)
|
| 74 |
+
return result
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def prepare(config: DictConfig) -> Path:
|
| 78 |
+
data = config.data
|
| 79 |
+
if not data.files:
|
| 80 |
+
raise ValueError("prepare requires data.files")
|
| 81 |
+
feature_branches = data.get("feature_branches")
|
| 82 |
+
object_types = data.get("object_types")
|
| 83 |
+
scales = data.get("scales")
|
| 84 |
+
if feature_branches is None or object_types is None or scales is None:
|
| 85 |
+
raise ValueError(
|
| 86 |
+
"prepare requires data.feature_branches, object_types, and scales"
|
| 87 |
+
)
|
| 88 |
+
source = RootEventDataset(
|
| 89 |
+
data.files,
|
| 90 |
+
tree_name=str(data.tree_name),
|
| 91 |
+
label=data.get("label", 1),
|
| 92 |
+
feature_branches=feature_branches,
|
| 93 |
+
tracking_info=data.get("tracking_info", []),
|
| 94 |
+
global_features=data.get("global_features", []),
|
| 95 |
+
fold_var=str(data.get("fold_var", "eventNumber")),
|
| 96 |
+
weight_var=data.get("weight_var"),
|
| 97 |
+
)
|
| 98 |
+
samples: list[GraphSample] = []
|
| 99 |
+
for event in source:
|
| 100 |
+
features, _ = build_node_features(
|
| 101 |
+
event.objects, feature_branches, object_types, scales
|
| 102 |
+
)
|
| 103 |
+
graph = build_dgl_graph(features)
|
| 104 |
+
globals_ = torch.as_tensor(event.global_features, dtype=torch.float32)
|
| 105 |
+
samples.append(
|
| 106 |
+
GraphSample(
|
| 107 |
+
graph,
|
| 108 |
+
torch.as_tensor(event.label),
|
| 109 |
+
globals_ if globals_.numel() else None,
|
| 110 |
+
event.event_metadata,
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
path = Path(data.cache.path)
|
| 114 |
+
GraphSampleCache(path).save(samples)
|
| 115 |
+
return path
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _model_and_task(config: DictConfig, loader: GraphDataLoader):
|
| 119 |
+
first = next(iter(loader), None)
|
| 120 |
+
if first is None:
|
| 121 |
+
raise ValueError("selected data split is empty")
|
| 122 |
+
model_config = plain(config.model)
|
| 123 |
+
pretrained = config.checkpoint.pretrained
|
| 124 |
+
if pretrained:
|
| 125 |
+
model_config["pretrained_checkpoint"] = pretrained
|
| 126 |
+
model = build_model(
|
| 127 |
+
model_config, sample_graph=first.graph, sample_global=first.global_features
|
| 128 |
+
)
|
| 129 |
+
return model, build_task(plain(config.task))
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def train(config: DictConfig) -> Path:
|
| 133 |
+
validate_config(plain(config))
|
| 134 |
+
seed_everything(int(config.trainer.seed))
|
| 135 |
+
run_root = Path(config.environment.output_root)
|
| 136 |
+
save_resolved(config, run_root)
|
| 137 |
+
split_loaders = loaders(config)
|
| 138 |
+
model, task = _model_and_task(config, split_loaders["train"])
|
| 139 |
+
trainer = build_trainer(plain(config.trainer), model=model, task=task)
|
| 140 |
+
if config.checkpoint.resume:
|
| 141 |
+
payload = CheckpointManager.load(config.checkpoint.resume, map_location="cpu")
|
| 142 |
+
restore_training_state(
|
| 143 |
+
payload,
|
| 144 |
+
model=model,
|
| 145 |
+
trainer=trainer,
|
| 146 |
+
optimizer=trainer.optimizer,
|
| 147 |
+
scheduler=trainer.scheduler,
|
| 148 |
+
early_stopping=trainer.early_stopping,
|
| 149 |
+
)
|
| 150 |
+
validation_loader = (
|
| 151 |
+
split_loaders["validation"] if len(split_loaders["validation"]) else None
|
| 152 |
+
)
|
| 153 |
+
trainer.fit(
|
| 154 |
+
split_loaders["train"], validation_loader, epochs=int(config.trainer.max_epochs)
|
| 155 |
+
)
|
| 156 |
+
manager = CheckpointManager(config.checkpoint.directory)
|
| 157 |
+
return manager.save(
|
| 158 |
+
model=model,
|
| 159 |
+
trainer_state=trainer.state,
|
| 160 |
+
optimizer=trainer.optimizer,
|
| 161 |
+
scheduler=trainer.scheduler,
|
| 162 |
+
early_stopping=trainer.early_stopping,
|
| 163 |
+
task_config=plain(config.task),
|
| 164 |
+
model_config=plain(config.model),
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def predict_or_evaluate(config: DictConfig, *, evaluate: bool = False) -> Any:
|
| 169 |
+
validate_config(plain(config))
|
| 170 |
+
split_loaders = loaders(config)
|
| 171 |
+
model, task = _model_and_task(config, split_loaders[config.inference.split])
|
| 172 |
+
payload = CheckpointManager.load(config.inference.checkpoint, map_location="cpu")
|
| 173 |
+
from gnn4colliders.training import load_model_weights
|
| 174 |
+
|
| 175 |
+
load_model_weights(model, payload)
|
| 176 |
+
predictor = Predictor(model, task, device=str(config.trainer.device))
|
| 177 |
+
result = (
|
| 178 |
+
predictor.evaluate(split_loaders[config.inference.split])
|
| 179 |
+
if evaluate
|
| 180 |
+
else predictor.predict(split_loaders[config.inference.split])
|
| 181 |
+
)
|
| 182 |
+
if not evaluate:
|
| 183 |
+
output = Path(config.inference.output)
|
| 184 |
+
if str(config.inference.format).lower() != "npz":
|
| 185 |
+
raise ValueError("only npz inference output is currently supported")
|
| 186 |
+
write_npz(result, output)
|
| 187 |
+
return result
|
src/gnn4colliders/config/factories.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small allow-listed factories for configured application components."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Mapping
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
|
| 11 |
+
from gnn4colliders.tasks import BinaryClassificationTask, MulticlassClassificationTask
|
| 12 |
+
from gnn4colliders.training import (
|
| 13 |
+
EarlyStopping,
|
| 14 |
+
Trainer,
|
| 15 |
+
build_optimizer,
|
| 16 |
+
build_scheduler,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _get(config: Mapping[str, Any], name: str, default: Any = None) -> Any:
|
| 21 |
+
value = config.get(name, default)
|
| 22 |
+
return value
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def build_model(
|
| 26 |
+
config: Mapping[str, Any],
|
| 27 |
+
*,
|
| 28 |
+
sample_graph: Any,
|
| 29 |
+
sample_global: torch.Tensor | None = None,
|
| 30 |
+
) -> torch.nn.Module:
|
| 31 |
+
"""Build one of the supported models from semantic configuration."""
|
| 32 |
+
family = str(_get(config, "family", "root_gnn")).lower()
|
| 33 |
+
name = str(_get(config, "name", "")).lower()
|
| 34 |
+
if not name:
|
| 35 |
+
name = (
|
| 36 |
+
"fine_tuned_edge_network"
|
| 37 |
+
if "fine" in str(_get(config, "class", "")).lower()
|
| 38 |
+
else "edge_network"
|
| 39 |
+
)
|
| 40 |
+
if family != "root_gnn":
|
| 41 |
+
raise ValueError(f"unsupported model family {family!r}")
|
| 42 |
+
kwargs = {
|
| 43 |
+
"hid_size": int(_get(config, "hid_size", _get(config, "hidden_dim", 128))),
|
| 44 |
+
"out_size": int(_get(config, "out_size", 1)),
|
| 45 |
+
"n_layers": int(_get(config, "n_layers", 2)),
|
| 46 |
+
"n_proc_steps": int(
|
| 47 |
+
_get(config, "n_proc_steps", _get(config, "processing_steps", 4))
|
| 48 |
+
),
|
| 49 |
+
"dropout": float(_get(config, "dropout", 0.0)),
|
| 50 |
+
}
|
| 51 |
+
if name in {"edge_network", "edge"}:
|
| 52 |
+
return EdgeNetwork(sample_graph, sample_global, **kwargs)
|
| 53 |
+
if name in {"fine_tuned_edge_network", "finetuned_edge_network", "fine_tuned"}:
|
| 54 |
+
checkpoint = _get(config, "pretrained_checkpoint")
|
| 55 |
+
if not checkpoint:
|
| 56 |
+
raise ValueError(
|
| 57 |
+
"fine-tuned model requires model.pretrained_checkpoint or "
|
| 58 |
+
"checkpoint.pretrained"
|
| 59 |
+
)
|
| 60 |
+
from gnn4colliders.training import CheckpointManager, load_model_weights
|
| 61 |
+
|
| 62 |
+
payload = CheckpointManager.load(checkpoint, map_location="cpu")
|
| 63 |
+
base_config = dict(payload.get("model_config") or {})
|
| 64 |
+
base_config.update(
|
| 65 |
+
{key: value for key, value in kwargs.items() if key != "out_size"}
|
| 66 |
+
)
|
| 67 |
+
backbone = EdgeNetwork(
|
| 68 |
+
sample_graph,
|
| 69 |
+
sample_global,
|
| 70 |
+
hid_size=int(base_config.get("hid_size", kwargs["hid_size"])),
|
| 71 |
+
out_size=int(base_config.get("out_size", 1)),
|
| 72 |
+
n_layers=int(base_config.get("n_layers", kwargs["n_layers"])),
|
| 73 |
+
n_proc_steps=int(base_config.get("n_proc_steps", kwargs["n_proc_steps"])),
|
| 74 |
+
dropout=float(base_config.get("dropout", kwargs["dropout"])),
|
| 75 |
+
)
|
| 76 |
+
load_model_weights(backbone, payload)
|
| 77 |
+
return FineTunedEdgeNetwork(
|
| 78 |
+
backbone,
|
| 79 |
+
kwargs["out_size"],
|
| 80 |
+
freeze_backbone=bool(_get(config, "freeze_backbone", False)),
|
| 81 |
+
)
|
| 82 |
+
raise ValueError(f"unsupported root_gnn model {name!r}")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def build_task(config: Mapping[str, Any]) -> Any:
|
| 86 |
+
task_type = str(_get(config, "type", "multiclass_classification")).lower()
|
| 87 |
+
kwargs = {
|
| 88 |
+
"absolute_weights": bool(
|
| 89 |
+
_get(
|
| 90 |
+
config, "absolute_weights", _get(config, "use_absolute_weights", False)
|
| 91 |
+
)
|
| 92 |
+
)
|
| 93 |
+
}
|
| 94 |
+
if task_type in {"binary", "binary_classification"}:
|
| 95 |
+
return BinaryClassificationTask(
|
| 96 |
+
threshold=float(_get(config, "threshold", 0.5)), **kwargs
|
| 97 |
+
)
|
| 98 |
+
if task_type in {"multiclass", "multiclass_classification"}:
|
| 99 |
+
return MulticlassClassificationTask(**kwargs)
|
| 100 |
+
raise ValueError(f"unsupported task type {task_type!r}")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def build_trainer(
|
| 104 |
+
config: Mapping[str, Any], *, model: torch.nn.Module, task: Any
|
| 105 |
+
) -> Trainer:
|
| 106 |
+
optimizer_config = _get(config, "optimizer", {}) or {}
|
| 107 |
+
optimizer = build_optimizer(model, **dict(optimizer_config))
|
| 108 |
+
scheduler_config = _get(config, "scheduler", {}) or {}
|
| 109 |
+
scheduler = None
|
| 110 |
+
scheduler_step = "epoch"
|
| 111 |
+
if _get(scheduler_config, "name") not in (None, "", "none"):
|
| 112 |
+
scheduler_step = str(_get(scheduler_config, "step", "epoch"))
|
| 113 |
+
scheduler_kwargs = {
|
| 114 |
+
key: value
|
| 115 |
+
for key, value in dict(scheduler_config).items()
|
| 116 |
+
if key not in {"name", "step"}
|
| 117 |
+
}
|
| 118 |
+
scheduler = build_scheduler(
|
| 119 |
+
optimizer, name=str(_get(scheduler_config, "name")), **scheduler_kwargs
|
| 120 |
+
)
|
| 121 |
+
early_config = _get(config, "early_stopping", {}) or {}
|
| 122 |
+
early = None
|
| 123 |
+
if bool(_get(early_config, "enabled", False)):
|
| 124 |
+
early = EarlyStopping(
|
| 125 |
+
**{
|
| 126 |
+
key: value
|
| 127 |
+
for key, value in dict(early_config).items()
|
| 128 |
+
if key != "enabled"
|
| 129 |
+
}
|
| 130 |
+
)
|
| 131 |
+
return Trainer(
|
| 132 |
+
model,
|
| 133 |
+
task,
|
| 134 |
+
optimizer,
|
| 135 |
+
scheduler,
|
| 136 |
+
device=str(_get(config, "device", "cpu")),
|
| 137 |
+
early_stopping=early,
|
| 138 |
+
scheduler_step=scheduler_step,
|
| 139 |
+
)
|
src/gnn4colliders/config/validation.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Early validation for cross-group configuration relationships."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Mapping
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def validate_config(config: Mapping[str, object]) -> None:
|
| 9 |
+
data = config.get("data", {}) or {}
|
| 10 |
+
trainer = config.get("trainer", {}) or {}
|
| 11 |
+
model = config.get("model", {}) or {}
|
| 12 |
+
task = config.get("task", {}) or {}
|
| 13 |
+
if int(data.get("batch_size", 1)) < 1:
|
| 14 |
+
raise ValueError("data.batch_size must be positive")
|
| 15 |
+
if int(trainer.get("max_epochs", 1)) < 1:
|
| 16 |
+
raise ValueError("trainer.max_epochs must be positive")
|
| 17 |
+
task_type = str(task.get("type", "")).lower()
|
| 18 |
+
out_size = int(model.get("out_size", 1))
|
| 19 |
+
if "binary" in task_type and out_size != 1:
|
| 20 |
+
raise ValueError("binary classification requires model.out_size=1")
|
| 21 |
+
if "multi" in task_type and out_size != int(task.get("num_classes", out_size)):
|
| 22 |
+
raise ValueError("multiclass model.out_size must equal task.num_classes")
|
| 23 |
+
if str(model.get("name", "")).lower() in {"fine_tuned_edge_network", "fine_tuned"}:
|
| 24 |
+
checkpoint = model.get("pretrained_checkpoint") or (
|
| 25 |
+
config.get("checkpoint", {}) or {}
|
| 26 |
+
).get("pretrained")
|
| 27 |
+
if not checkpoint:
|
| 28 |
+
raise ValueError("fine-tuned models require checkpoint.pretrained")
|
| 29 |
+
checkpoint = config.get("checkpoint", {}) or {}
|
| 30 |
+
if checkpoint.get("resume") and checkpoint.get("pretrained"):
|
| 31 |
+
raise ValueError(
|
| 32 |
+
"checkpoint.resume and checkpoint.pretrained are distinct "
|
| 33 |
+
"workflows and cannot both be set"
|
| 34 |
+
)
|
| 35 |
+
splits = data.get("splits", {}) or {}
|
| 36 |
+
groups = [
|
| 37 |
+
set(splits.get(name, []))
|
| 38 |
+
for name in ("train_folds", "validation_folds", "test_folds")
|
| 39 |
+
]
|
| 40 |
+
if any(groups[i] & groups[j] for i in range(3) for j in range(i + 1, 3)):
|
| 41 |
+
raise ValueError("train, validation, and test folds must be disjoint")
|