Commit ·
be26097
1
Parent(s): fe85bec
feat: add explicit legacy compatibility adapters
Browse files
docs/compatibility.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Compatibility boundary
|
| 2 |
+
|
| 3 |
+
The package's canonical APIs use named `EventMetadata` fields and the version
|
| 4 |
+
1 checkpoint schema. Compatibility is explicit and one-way: old artifacts are
|
| 5 |
+
adapted into the new representation and are never rewritten implicitly.
|
| 6 |
+
|
| 7 |
+
| Artifact or behavior | Supported | Boundary | Notes |
|
| 8 |
+
| --- | :---: | --- | --- |
|
| 9 |
+
| New checkpoint (`schema_version: 1`) | yes | `CheckpointManager` | Full model/lifecycle resume when state is present |
|
| 10 |
+
| Legacy `model_epoch_N.pt` checkpoint | yes | `gnn4colliders.compat.load_legacy_checkpoint` | Model state and active early-stop fields are adapted |
|
| 11 |
+
| Legacy DDP/compiled prefixes | yes | `normalize_legacy_state_dict_keys` | Supports `module.` and `_orig_mod.` |
|
| 12 |
+
| Legacy ROOT-GNN classifier name | yes | `map_legacy_edge_network_state_dict` | Maps `classify` to `classifier` |
|
| 13 |
+
| Legacy optimizer state | partial | legacy checkpoint adapter | Loaded when present; scheduler state is not available in the historical format |
|
| 14 |
+
| Legacy scheduler state | no | — | Historical checkpoints do not carry a supported scheduler state |
|
| 15 |
+
| Positional tracking rows | yes, at ingestion boundary | `EventMetadata.from_legacy_tracking` | Exactly `tracking[0] = fold`, `tracking[1] = weight`; shorter rows fail |
|
| 16 |
+
| Generic/unknown tracking layouts | no | — | The package does not guess historical column meanings |
|
| 17 |
+
| Modern NPZ output | yes | `inference.write_npz` | Named fields: `sample_id`, `logits`, `scores`, `predictions`, and available metadata |
|
| 18 |
+
| Historical `tracking_info` NPZ output | no | — | No active consumer remains; positional output is intentionally unsupported |
|
| 19 |
+
| Legacy YAML `module`/`class`/`args` | compatibility only | configuration boundary | Accepted only where the semantic factory can safely interpret it; new configs use semantic model names |
|
| 20 |
+
|
| 21 |
+
The frozen `legacy/` tree remains available to parity tests and historical
|
| 22 |
+
investigation. Production modules do not import executable code from it.
|
src/gnn4colliders/compat/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explicit adapters for artifacts from the frozen legacy implementation."""
|
| 2 |
+
|
| 3 |
+
from .checkpoint import (
|
| 4 |
+
LEGACY_CHECKPOINT_SCHEMA_VERSION,
|
| 5 |
+
load_legacy_checkpoint,
|
| 6 |
+
map_legacy_edge_network_state_dict,
|
| 7 |
+
normalize_legacy_state_dict_keys,
|
| 8 |
+
)
|
| 9 |
+
from .metadata import event_metadata_from_legacy_tracking
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"LEGACY_CHECKPOINT_SCHEMA_VERSION",
|
| 13 |
+
"event_metadata_from_legacy_tracking",
|
| 14 |
+
"load_legacy_checkpoint",
|
| 15 |
+
"map_legacy_edge_network_state_dict",
|
| 16 |
+
"normalize_legacy_state_dict_keys",
|
| 17 |
+
]
|
src/gnn4colliders/compat/checkpoint.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Checkpoint adapters for the supported historical ROOT-GNN artifacts."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Mapping
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
LEGACY_CHECKPOINT_SCHEMA_VERSION = 0
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def normalize_legacy_state_dict_keys(state: Mapping[str, Any]) -> dict[str, Any]:
|
| 15 |
+
"""Remove the supported DDP and compilation wrappers deterministically."""
|
| 16 |
+
return {
|
| 17 |
+
key.removeprefix("module.").removeprefix("_orig_mod."): value
|
| 18 |
+
for key, value in state.items()
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def map_legacy_edge_network_state_dict(
|
| 23 |
+
state: Mapping[str, Any],
|
| 24 |
+
) -> dict[str, Any]:
|
| 25 |
+
"""Map historical ``classify`` keys to the modern ``classifier`` name."""
|
| 26 |
+
normalized = normalize_legacy_state_dict_keys(state)
|
| 27 |
+
return {
|
| 28 |
+
key.replace(".classify.", ".classifier.").replace(
|
| 29 |
+
"classify.", "classifier."
|
| 30 |
+
): value
|
| 31 |
+
for key, value in normalized.items()
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_legacy_checkpoint(
|
| 36 |
+
source: str | Path | Mapping[str, Any], *, map_location: Any = "cpu"
|
| 37 |
+
) -> dict[str, Any]:
|
| 38 |
+
"""Adapt an active ``model_epoch_N.pt`` payload without rewriting it."""
|
| 39 |
+
payload = (
|
| 40 |
+
torch.load(source, map_location=map_location, weights_only=False)
|
| 41 |
+
if not isinstance(source, Mapping)
|
| 42 |
+
else dict(source)
|
| 43 |
+
)
|
| 44 |
+
if not isinstance(payload, Mapping) or "model_state_dict" not in payload:
|
| 45 |
+
raise ValueError("not a supported legacy ROOT-GNN checkpoint")
|
| 46 |
+
early = payload.get("early_stop")
|
| 47 |
+
if isinstance(early, Mapping):
|
| 48 |
+
early = {
|
| 49 |
+
"patience": early.get("patience", 15),
|
| 50 |
+
"min_delta": early.get("threshold", 1e-8),
|
| 51 |
+
"mode": early.get("mode", "min"),
|
| 52 |
+
"best": early.get("current_best", float("inf")),
|
| 53 |
+
"num_bad_epochs": early.get("count", 0),
|
| 54 |
+
"should_stop": early.get("should_stop", False),
|
| 55 |
+
}
|
| 56 |
+
epoch = int(payload.get("epoch", -1))
|
| 57 |
+
return {
|
| 58 |
+
"schema_version": LEGACY_CHECKPOINT_SCHEMA_VERSION,
|
| 59 |
+
"legacy": True,
|
| 60 |
+
"epoch": epoch,
|
| 61 |
+
"global_step": 0,
|
| 62 |
+
"model_state_dict": normalize_legacy_state_dict_keys(
|
| 63 |
+
payload["model_state_dict"]
|
| 64 |
+
),
|
| 65 |
+
"optimizer_state_dict": payload.get("optimizer_state_dict"),
|
| 66 |
+
"scheduler_state_dict": None,
|
| 67 |
+
"early_stopping_state": early,
|
| 68 |
+
"trainer_state": {"epoch": epoch, "global_step": 0},
|
| 69 |
+
"model_config": None,
|
| 70 |
+
"task_config": None,
|
| 71 |
+
"metadata": {"legacy_format": True},
|
| 72 |
+
}
|
src/gnn4colliders/compat/metadata.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters for the historical positional event metadata layout."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Mapping, Sequence
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from gnn4colliders.data.metadata import EventMetadata
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def event_metadata_from_legacy_tracking(
|
| 12 |
+
tracking_row: Sequence[Any],
|
| 13 |
+
*,
|
| 14 |
+
sample_id: str,
|
| 15 |
+
extra: Mapping[str, Any] | None = None,
|
| 16 |
+
) -> EventMetadata:
|
| 17 |
+
"""Convert the only supported legacy layout into named metadata.
|
| 18 |
+
|
| 19 |
+
The historical contract is strictly ``tracking[0] = fold`` and
|
| 20 |
+
``tracking[1] = weight``. Additional historical columns are not
|
| 21 |
+
interpreted or propagated.
|
| 22 |
+
"""
|
| 23 |
+
if len(tracking_row) < 2:
|
| 24 |
+
raise ValueError(
|
| 25 |
+
"legacy tracking must contain at least two values: fold and weight"
|
| 26 |
+
)
|
| 27 |
+
return EventMetadata(
|
| 28 |
+
fold=int(tracking_row[0]),
|
| 29 |
+
weight=float(tracking_row[1]),
|
| 30 |
+
sample_id=sample_id,
|
| 31 |
+
extra=dict(extra or {}),
|
| 32 |
+
)
|
src/gnn4colliders/config/factories.py
CHANGED
|
@@ -7,6 +7,7 @@ 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 (
|
|
@@ -101,7 +102,11 @@ def build_task(config: Mapping[str, Any]) -> Any:
|
|
| 101 |
|
| 102 |
|
| 103 |
def build_trainer(
|
| 104 |
-
config: Mapping[str, Any],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
) -> Trainer:
|
| 106 |
optimizer_config = _get(config, "optimizer", {}) or {}
|
| 107 |
optimizer = build_optimizer(model, **dict(optimizer_config))
|
|
@@ -136,4 +141,5 @@ def build_trainer(
|
|
| 136 |
device=str(_get(config, "device", "cpu")),
|
| 137 |
early_stopping=early,
|
| 138 |
scheduler_step=scheduler_step,
|
|
|
|
| 139 |
)
|
|
|
|
| 7 |
|
| 8 |
import torch
|
| 9 |
|
| 10 |
+
from gnn4colliders.distributed import DistributedContext
|
| 11 |
from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
|
| 12 |
from gnn4colliders.tasks import BinaryClassificationTask, MulticlassClassificationTask
|
| 13 |
from gnn4colliders.training import (
|
|
|
|
| 102 |
|
| 103 |
|
| 104 |
def build_trainer(
|
| 105 |
+
config: Mapping[str, Any],
|
| 106 |
+
*,
|
| 107 |
+
model: torch.nn.Module,
|
| 108 |
+
task: Any,
|
| 109 |
+
distributed_context: DistributedContext | None = None,
|
| 110 |
) -> Trainer:
|
| 111 |
optimizer_config = _get(config, "optimizer", {}) or {}
|
| 112 |
optimizer = build_optimizer(model, **dict(optimizer_config))
|
|
|
|
| 141 |
device=str(_get(config, "device", "cpu")),
|
| 142 |
early_stopping=early,
|
| 143 |
scheduler_step=scheduler_step,
|
| 144 |
+
distributed_context=distributed_context,
|
| 145 |
)
|
tests/unit/compat/__init__.py
ADDED
|
File without changes
|
tests/unit/compat/test_adapters.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from gnn4colliders.compat import (
|
| 5 |
+
event_metadata_from_legacy_tracking,
|
| 6 |
+
map_legacy_edge_network_state_dict,
|
| 7 |
+
)
|
| 8 |
+
from gnn4colliders.data import EventMetadata
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_legacy_tracking_is_converted_to_named_metadata():
|
| 12 |
+
metadata = event_metadata_from_legacy_tracking(
|
| 13 |
+
[3.0, -2.5, 99.0], sample_id="event:7"
|
| 14 |
+
)
|
| 15 |
+
assert metadata == EventMetadata(fold=3, weight=-2.5, sample_id="event:7")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_legacy_tracking_rejects_ambiguous_short_rows():
|
| 19 |
+
with pytest.raises(ValueError, match="fold and weight"):
|
| 20 |
+
EventMetadata.from_legacy_tracking([3.0], sample_id="event:7")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_legacy_state_dict_mapping_is_canonical_and_deterministic():
|
| 24 |
+
state = {
|
| 25 |
+
"module._orig_mod.encoder.weight": torch.ones(2, 2),
|
| 26 |
+
"module._orig_mod.classify.bias": torch.zeros(1),
|
| 27 |
+
}
|
| 28 |
+
mapped = map_legacy_edge_network_state_dict(state)
|
| 29 |
+
assert set(mapped) == {"encoder.weight", "classifier.bias"}
|