feat: TabPFN-like PFN options: summary tokens, alternating attention, per-task momentum scale, synthetic tasks and an inference ensemble
Browse files- model.pfn_summary_tokens: learned tokens carry each event through the
encoder; a context event's label enters at the input.
- model.pfn_alternating: attention across the task's events after every
encoder block (TabPFN's alternation of feature and row attention).
- model.pfn_normalize: each task's momenta are scaled so the context's
geometric-mean object pT is 50 GeV.
- model.pfn_ensemble: in-context inference averages class probabilities
over passes with rotated, flipped events and permuted class labels.
- task.synthetic_tasks: a fraction of training tasks rank events of 1-3
processes by a random network of event observables.
Defaults keep the previous model and prior: outputs of the pooled PFN
match the previous code to 2.4e-7, and the prior draws the same tasks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- src/gnn4colliders/cli.py +15 -13
- src/gnn4colliders/config.py +19 -0
- src/gnn4colliders/models.py +3 -0
- src/gnn4colliders/pfn.py +361 -63
- tests/test_pfn.py +116 -12
|
@@ -321,19 +321,21 @@ def in_context_forward(
|
|
| 321 |
checkpoint = _checkpoint(config)
|
| 322 |
model = make_model(config, events, load_pretrained=False)
|
| 323 |
load_weights(model, load_checkpoint(checkpoint))
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
| 337 |
return forward, str(checkpoint)
|
| 338 |
|
| 339 |
|
|
|
|
| 321 |
checkpoint = _checkpoint(config)
|
| 322 |
model = make_model(config, events, load_pretrained=False)
|
| 323 |
load_weights(model, load_checkpoint(checkpoint))
|
| 324 |
+
sizes = {"num_classes": model_config.out_size, "batch_size": data.batch_size}
|
| 325 |
+
if model_config.name in ("tabpfn", "tabicl"):
|
| 326 |
+
forward = meta_tabular_forward(
|
| 327 |
+
model.to(device), events, context, device=device, **sizes
|
| 328 |
+
)
|
| 329 |
+
else:
|
| 330 |
+
forward = pfn_forward(
|
| 331 |
+
model.to(device),
|
| 332 |
+
events,
|
| 333 |
+
context,
|
| 334 |
+
device=device,
|
| 335 |
+
ensemble=model_config.pfn_ensemble,
|
| 336 |
+
seed=config.train.seed,
|
| 337 |
+
**sizes,
|
| 338 |
+
)
|
| 339 |
return forward, str(checkpoint)
|
| 340 |
|
| 341 |
|
|
@@ -120,6 +120,21 @@ class ModelConfig:
|
|
| 120 |
max_classes: int = 10
|
| 121 |
#: Attention blocks over each task's events.
|
| 122 |
pfn_layers: int = 6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
# TabPFN only.
|
| 124 |
tabpfn_version: str = "v3.5"
|
| 125 |
#: A checkpoint of that version (a path, or a file name in the TabPFN
|
|
@@ -177,6 +192,10 @@ class TaskConfig:
|
|
| 177 |
#: Smallest and largest number of context events (log-uniform).
|
| 178 |
context_events: list[int] = field(default_factory=lambda: [16, 2048])
|
| 179 |
query_events: int = 256
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
#: Momenta and energies of a task are scaled by a factor uniform in
|
| 181 |
#: ``[1 - energy_jitter, 1 + energy_jitter]``.
|
| 182 |
energy_jitter: float = 0.2
|
|
|
|
| 120 |
max_classes: int = 10
|
| 121 |
#: Attention blocks over each task's events.
|
| 122 |
pfn_layers: int = 6
|
| 123 |
+
#: Learned tokens added to each event's objects that carry the event
|
| 124 |
+
#: across the task (labels enter at the input); 0: the mean over the
|
| 125 |
+
#: encoded objects, with the label added after the encoder.
|
| 126 |
+
pfn_summary_tokens: int = 0
|
| 127 |
+
#: Attention across the task's events after every encoder block too (as
|
| 128 |
+
#: TabPFN alternates attention across features and across rows), so the
|
| 129 |
+
#: event encodings depend on the task; needs ``pfn_summary_tokens``.
|
| 130 |
+
pfn_alternating: bool = False
|
| 131 |
+
#: Scale each task's momenta and energies so that the geometric mean pT
|
| 132 |
+
#: of the context's objects is ``PT_REFERENCE`` (pfn.py).
|
| 133 |
+
pfn_normalize: bool = False
|
| 134 |
+
#: In-context inference averages the class probabilities of this many
|
| 135 |
+
#: passes, each with its own azimuthal rotation, pseudorapidity flip and
|
| 136 |
+
#: order of the class labels.
|
| 137 |
+
pfn_ensemble: int = 1
|
| 138 |
# TabPFN only.
|
| 139 |
tabpfn_version: str = "v3.5"
|
| 140 |
#: A checkpoint of that version (a path, or a file name in the TabPFN
|
|
|
|
| 192 |
#: Smallest and largest number of context events (log-uniform).
|
| 193 |
context_events: list[int] = field(default_factory=lambda: [16, 2048])
|
| 194 |
query_events: int = 256
|
| 195 |
+
#: Fraction of training tasks whose classes are not processes but
|
| 196 |
+
#: ranges of a random function of event observables (pfn.py), on events
|
| 197 |
+
#: of 1 to 3 processes.
|
| 198 |
+
synthetic_tasks: float = 0.0
|
| 199 |
#: Momenta and energies of a task are scaled by a factor uniform in
|
| 200 |
#: ``[1 - energy_jitter, 1 + energy_jitter]``.
|
| 201 |
energy_jitter: float = 0.2
|
|
@@ -663,6 +663,9 @@ def build_model(
|
|
| 663 |
n_heads=config.n_heads,
|
| 664 |
ffn_size=config.ffn_size or 4 * config.hid_size,
|
| 665 |
dropout=config.dropout,
|
|
|
|
|
|
|
|
|
|
| 666 |
)
|
| 667 |
if config.name in ("tabpfn", "tabicl"):
|
| 668 |
from . import tabular
|
|
|
|
| 663 |
n_heads=config.n_heads,
|
| 664 |
ffn_size=config.ffn_size or 4 * config.hid_size,
|
| 665 |
dropout=config.dropout,
|
| 666 |
+
summary_tokens=config.pfn_summary_tokens,
|
| 667 |
+
alternating=config.pfn_alternating,
|
| 668 |
+
normalize=config.pfn_normalize,
|
| 669 |
)
|
| 670 |
if config.name in ("tabpfn", "tabicl"):
|
| 671 |
from . import tabular
|
|
@@ -2,9 +2,9 @@
|
|
| 2 |
Hollmann et al. 2023) that classifies collider events in context.
|
| 3 |
|
| 4 |
A task is a set of context events with labels and query events without.
|
| 5 |
-
The PFN
|
| 6 |
-
|
| 7 |
-
|
| 8 |
Queries therefore never see each other, and the context's keys and values
|
| 9 |
can be computed once for any number of queries (:func:`in_context_forward`).
|
| 10 |
|
|
@@ -14,15 +14,26 @@ prior: each task takes ``task.task_classes`` random classes of
|
|
| 14 |
proportions and a log-uniform number of context events. Training tasks are
|
| 15 |
augmented by a random azimuthal rotation and longitudinal flip of each event
|
| 16 |
and a scale of the task's momenta and energies (``task.energy_jitter``).
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
| 23 |
import math
|
| 24 |
from collections.abc import Callable, Iterator
|
| 25 |
-
from dataclasses import dataclass
|
| 26 |
from typing import Any
|
| 27 |
|
| 28 |
import torch
|
|
@@ -30,8 +41,17 @@ from torch import nn
|
|
| 30 |
|
| 31 |
from .config import TaskConfig
|
| 32 |
from .data import PackedEvents, map_tensors
|
| 33 |
-
from .models import EncoderBlock, ObjectTransformer
|
| 34 |
-
from .sequences import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
#: Logit of the classes a task does not have.
|
| 37 |
ABSENT_LOGIT = -1e4
|
|
@@ -59,7 +79,18 @@ class TaskBatch:
|
|
| 59 |
|
| 60 |
class EventPFN(nn.Module):
|
| 61 |
"""Query logits ``[tasks * queries, max_classes]`` of a :class:`TaskBatch`;
|
| 62 |
-
the logits of classes a task does not have are ``ABSENT_LOGIT``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
def __init__(
|
| 65 |
self,
|
|
@@ -70,62 +101,199 @@ class EventPFN(nn.Module):
|
|
| 70 |
n_heads: int,
|
| 71 |
ffn_size: int,
|
| 72 |
dropout: float = 0.0,
|
|
|
|
|
|
|
|
|
|
| 73 |
) -> None:
|
| 74 |
super().__init__()
|
|
|
|
|
|
|
| 75 |
hid_size = encoder.hid_size
|
| 76 |
-
self.max_classes = max_classes
|
| 77 |
self.encoder = encoder
|
| 78 |
self.label_embedding = nn.Embedding(max_classes, hid_size)
|
| 79 |
-
self.
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
| 81 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
self.norm = nn.LayerNorm(hid_size)
|
| 83 |
self.classifier = nn.Linear(hid_size, max_classes)
|
| 84 |
|
| 85 |
-
def
|
| 86 |
-
"""``[events, hid_size]``
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
self,
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
classes = torch.arange(self.max_classes, device=logits.device)
|
| 118 |
absent = classes >= num_classes.reshape(-1, 1, 1)
|
| 119 |
return logits.masked_fill(absent, ABSENT_LOGIT)
|
| 120 |
|
| 121 |
def forward(self, batch: TaskBatch) -> torch.Tensor:
|
| 122 |
-
tasks,
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
return logits.flatten(0, 1)
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
@torch.inference_mode()
|
| 130 |
def in_context_forward(
|
| 131 |
model: EventPFN,
|
|
@@ -135,25 +303,60 @@ def in_context_forward(
|
|
| 135 |
num_classes: int,
|
| 136 |
batch_size: int,
|
| 137 |
device: torch.device,
|
|
|
|
|
|
|
| 138 |
) -> Callable[[SequenceBatch], torch.Tensor]:
|
| 139 |
"""A function from a batch of events to their logits ``[events,
|
| 140 |
-
num_classes]``, given the labelled ``context`` events (one task).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
if num_classes > model.max_classes:
|
| 142 |
raise ValueError(f"the PFN tells apart at most {model.max_classes} classes")
|
| 143 |
model.eval()
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
classes = torch.tensor([num_classes], device=device)
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
def forward(batch: SequenceBatch) -> torch.Tensor:
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
| 157 |
|
| 158 |
return forward
|
| 159 |
|
|
@@ -161,6 +364,45 @@ def in_context_forward(
|
|
| 161 |
# --- The prior ----------------------------------------------------------------
|
| 162 |
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
@dataclass(frozen=True)
|
| 165 |
class _Step:
|
| 166 |
"""The sampled events and augmentations of one meta-training step."""
|
|
@@ -263,6 +505,13 @@ class TaskLoader:
|
|
| 263 |
]
|
| 264 |
if len(self.pools) < 2 * config.mixture_labels[0]:
|
| 265 |
raise ValueError("the PFN prior needs events of at least two classes")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
def set_epoch(self, epoch: int) -> None:
|
| 268 |
self.epoch = epoch
|
|
@@ -291,15 +540,22 @@ class TaskLoader:
|
|
| 291 |
largest = min(config.task_classes[1], len(self.pools) // fewest, context)
|
| 292 |
smallest = min(config.task_classes[0], largest)
|
| 293 |
|
| 294 |
-
def dirichlet(count: int) -> torch.Tensor:
|
| 295 |
-
|
| 296 |
-
draw = torch.rand(count, generator=generator).clamp_min(1e-12)
|
| 297 |
-
share = -torch.log(draw)
|
| 298 |
-
return share / share.sum()
|
| 299 |
|
| 300 |
indices, labels, num_classes = [], [], []
|
| 301 |
for _ in range(tasks):
|
| 302 |
k = int(torch.randint(smallest, largest + 1, (), generator=generator))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
# Each class mixes 1 to ``most`` labels, no label in two classes.
|
| 304 |
# Without mixing, no draws are added: earlier priors are unchanged.
|
| 305 |
mixed = (
|
|
@@ -356,6 +612,48 @@ class TaskLoader:
|
|
| 356 |
flip=flip,
|
| 357 |
)
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
def __iter__(self) -> Iterator[Any]:
|
| 360 |
generator = self._generator()
|
| 361 |
steps = _Steps(self.events, [self.sample(generator) for _ in range(len(self))])
|
|
|
|
| 2 |
Hollmann et al. 2023) that classifies collider events in context.
|
| 3 |
|
| 4 |
A task is a set of context events with labels and query events without.
|
| 5 |
+
The PFN encodes every event with the object transformer, adds a label
|
| 6 |
+
embedding to the context events, and runs attention blocks over the task's
|
| 7 |
+
events in which every event attends to the context events only.
|
| 8 |
Queries therefore never see each other, and the context's keys and values
|
| 9 |
can be computed once for any number of queries (:func:`in_context_forward`).
|
| 10 |
|
|
|
|
| 14 |
proportions and a log-uniform number of context events. Training tasks are
|
| 15 |
augmented by a random azimuthal rotation and longitudinal flip of each event
|
| 16 |
and a scale of the task's momenta and energies (``task.energy_jitter``).
|
| 17 |
+
With ``task.synthetic_tasks``, a fraction of the training tasks instead
|
| 18 |
+
classify events of 1 to 3 processes by ranges of a random function of their
|
| 19 |
+
observables, as TabPFN's prior draws random functions: no two tasks repeat
|
| 20 |
+
and many decision boundaries are fine. A new dataset then needs no
|
| 21 |
+
training: its labelled training events are the context
|
| 22 |
+
(``data.train_events``).
|
| 23 |
+
|
| 24 |
+
The model options follow TabPFN too: summary tokens that carry each event
|
| 25 |
+
and its label through the encoder, attention across events between the
|
| 26 |
+
encoder blocks (``model.pfn_alternating``, TabPFN's alternation of
|
| 27 |
+
attention over features and over rows), a per-task scale of the momenta
|
| 28 |
+
(``model.pfn_normalize``) and an ensemble of transformed passes at
|
| 29 |
+
inference (``model.pfn_ensemble``).
|
| 30 |
"""
|
| 31 |
|
| 32 |
from __future__ import annotations
|
| 33 |
|
| 34 |
import math
|
| 35 |
from collections.abc import Callable, Iterator
|
| 36 |
+
from dataclasses import dataclass, replace
|
| 37 |
from typing import Any
|
| 38 |
|
| 39 |
import torch
|
|
|
|
| 41 |
|
| 42 |
from .config import TaskConfig
|
| 43 |
from .data import PackedEvents, map_tensors
|
| 44 |
+
from .models import NODE_TYPE, EncoderBlock, ObjectTransformer, pair_features
|
| 45 |
+
from .sequences import (
|
| 46 |
+
BTAG,
|
| 47 |
+
ENERGY,
|
| 48 |
+
ETA,
|
| 49 |
+
PHI,
|
| 50 |
+
PT,
|
| 51 |
+
SequenceBatch,
|
| 52 |
+
augment_tokens,
|
| 53 |
+
collate_sequence_batch,
|
| 54 |
+
)
|
| 55 |
|
| 56 |
#: Logit of the classes a task does not have.
|
| 57 |
ABSENT_LOGIT = -1e4
|
|
|
|
| 79 |
|
| 80 |
class EventPFN(nn.Module):
|
| 81 |
"""Query logits ``[tasks * queries, max_classes]`` of a :class:`TaskBatch`;
|
| 82 |
+
the logits of classes a task does not have are ``ABSENT_LOGIT``.
|
| 83 |
+
|
| 84 |
+
Each event is encoded by the object transformer ``encoder``. Without
|
| 85 |
+
summary tokens an event is the mean of its encoded objects plus, for a
|
| 86 |
+
context event, its label embedding. With ``summary_tokens`` each event
|
| 87 |
+
carries that many learned tokens through the encoder, and a context
|
| 88 |
+
event's label embedding is added to them at the input; with
|
| 89 |
+
``alternating`` the summary tokens also attend across the task's events
|
| 90 |
+
after every encoder block, so the encoding of an event depends on the
|
| 91 |
+
task. Then ``n_layers`` blocks attend across events. Across events,
|
| 92 |
+
every event attends to the context events only.
|
| 93 |
+
"""
|
| 94 |
|
| 95 |
def __init__(
|
| 96 |
self,
|
|
|
|
| 101 |
n_heads: int,
|
| 102 |
ffn_size: int,
|
| 103 |
dropout: float = 0.0,
|
| 104 |
+
summary_tokens: int = 0,
|
| 105 |
+
alternating: bool = False,
|
| 106 |
+
normalize: bool = False,
|
| 107 |
) -> None:
|
| 108 |
super().__init__()
|
| 109 |
+
if alternating and not summary_tokens:
|
| 110 |
+
raise ValueError("model.pfn_alternating needs model.pfn_summary_tokens")
|
| 111 |
hid_size = encoder.hid_size
|
| 112 |
+
self.max_classes, self.normalize = max_classes, normalize
|
| 113 |
self.encoder = encoder
|
| 114 |
self.label_embedding = nn.Embedding(max_classes, hid_size)
|
| 115 |
+
self.summary_tokens = summary_tokens
|
| 116 |
+
self.summary = (
|
| 117 |
+
nn.Parameter(0.02 * torch.randn(summary_tokens, hid_size))
|
| 118 |
+
if summary_tokens
|
| 119 |
+
else None
|
| 120 |
)
|
| 121 |
+
|
| 122 |
+
def blocks(count: int) -> nn.ModuleList:
|
| 123 |
+
return nn.ModuleList(
|
| 124 |
+
EncoderBlock(hid_size, n_heads, ffn_size, dropout) for _ in range(count)
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
self.event_blocks = blocks(len(encoder.blocks)) if alternating else None
|
| 128 |
+
self.blocks = blocks(n_layers)
|
| 129 |
self.norm = nn.LayerNorm(hid_size)
|
| 130 |
self.classifier = nn.Linear(hid_size, max_classes)
|
| 131 |
|
| 132 |
+
def _labelled(self, x: torch.Tensor, labels: torch.Tensor) -> torch.Tensor:
|
| 133 |
+
"""``x [events, tokens, hid_size]`` plus the label embedding of the
|
| 134 |
+
events whose ``labels`` are not -1 (the queries')."""
|
| 135 |
+
known = (labels >= 0).unsqueeze(-1)
|
| 136 |
+
return x + (self.label_embedding(labels.clamp_min(0)) * known).unsqueeze(1)
|
| 137 |
+
|
| 138 |
+
def _object_mask(self, batch: SequenceBatch) -> torch.Tensor:
|
| 139 |
+
"""The attention mask of :class:`ObjectTransformer`, with the summary
|
| 140 |
+
tokens first (no pair bias)."""
|
| 141 |
+
count, mask = self.summary_tokens, batch.token_mask
|
| 142 |
+
mask = nn.functional.pad(mask, (count, 0), value=True)[:, None, None, :]
|
| 143 |
+
if self.encoder.pair_encoder is None:
|
| 144 |
+
return mask
|
| 145 |
+
bias = self.encoder.pair_encoder(pair_features(batch.tokens))
|
| 146 |
+
bias = nn.functional.pad(bias.permute(0, 3, 1, 2), (count, 0, count, 0))
|
| 147 |
+
return bias.masked_fill(~mask, float("-inf"))
|
| 148 |
+
|
| 149 |
+
@staticmethod
|
| 150 |
+
def _across(
|
| 151 |
+
block: EncoderBlock,
|
| 152 |
+
x: torch.Tensor,
|
| 153 |
+
tasks: int,
|
| 154 |
+
context: int | None,
|
| 155 |
+
memory: tuple[torch.Tensor, torch.Tensor] | None,
|
| 156 |
+
) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor]]:
|
| 157 |
+
"""``x [events, tokens, hid_size]`` of ``tasks`` equal tasks after
|
| 158 |
+
``block`` attends across events: to the tokens of the first
|
| 159 |
+
``context`` events of each task, or to the keys and values
|
| 160 |
+
``memory``. Also returns those keys and values."""
|
| 161 |
+
events, tokens, hid_size = x.shape
|
| 162 |
+
x = x.reshape(tasks, -1, hid_size)
|
| 163 |
+
q, k, v = block.project(x).unbind(0)
|
| 164 |
+
memory = memory or (k[:, :, : context * tokens], v[:, :, : context * tokens])
|
| 165 |
+
x = block.attend(x, q, *memory)
|
| 166 |
+
return x.view(events, tokens, hid_size), memory
|
| 167 |
+
|
| 168 |
+
def encode(
|
| 169 |
self,
|
| 170 |
+
batches: list[SequenceBatch],
|
| 171 |
+
labels: list[torch.Tensor],
|
| 172 |
+
*,
|
| 173 |
+
tasks: int = 1,
|
| 174 |
+
context: int | None = None,
|
| 175 |
+
memory: list[tuple[torch.Tensor, torch.Tensor]] | None = None,
|
| 176 |
+
) -> tuple[torch.Tensor, list[tuple[torch.Tensor, torch.Tensor]]]:
|
| 177 |
+
"""The final tokens ``[events, tokens, hid_size]`` of the events of
|
| 178 |
+
``batches`` (in order, ``tasks`` equal tasks) with ``labels`` (-1:
|
| 179 |
+
a query), and the keys and values of every block across events.
|
| 180 |
+
Across events, they attend to the first ``context`` events of each
|
| 181 |
+
task or to ``memory`` (of an earlier call with the context)."""
|
| 182 |
+
count = self.summary_tokens
|
| 183 |
+
hidden = [self.encoder.embedding(batch.tokens) for batch in batches]
|
| 184 |
+
if count:
|
| 185 |
+
hidden = [
|
| 186 |
+
self._labelled(
|
| 187 |
+
torch.cat((self.summary.expand(len(x), -1, -1), x), 1), part
|
| 188 |
+
)
|
| 189 |
+
for x, part in zip(hidden, labels)
|
| 190 |
+
]
|
| 191 |
+
# The pair bias of one batch is kept; that of many (a large context)
|
| 192 |
+
# is recomputed by every block.
|
| 193 |
+
masks = [self._object_mask(batches[0])] if len(batches) == 1 else None
|
| 194 |
+
memory = iter(memory or [])
|
| 195 |
+
kept = []
|
| 196 |
+
for layer, block in enumerate(self.encoder.blocks):
|
| 197 |
+
hidden = [
|
| 198 |
+
block(x, masks[0] if masks else self._object_mask(batch))
|
| 199 |
+
for x, batch in zip(hidden, batches)
|
| 200 |
+
]
|
| 201 |
+
if self.event_blocks is None:
|
| 202 |
+
continue
|
| 203 |
+
summary = torch.cat([x[:, :count] for x in hidden])
|
| 204 |
+
summary, kv = self._across(
|
| 205 |
+
self.event_blocks[layer], summary, tasks, context, next(memory, None)
|
| 206 |
+
)
|
| 207 |
+
kept.append(kv)
|
| 208 |
+
parts = summary.split([len(x) for x in hidden])
|
| 209 |
+
hidden = [torch.cat((p, x[:, count:]), 1) for p, x in zip(parts, hidden)]
|
| 210 |
+
if count:
|
| 211 |
+
x = self.encoder.norm(torch.cat([x[:, :count] for x in hidden]))
|
| 212 |
+
else:
|
| 213 |
+
pooled = []
|
| 214 |
+
for x, batch in zip(hidden, batches):
|
| 215 |
+
mask = batch.token_mask.unsqueeze(-1)
|
| 216 |
+
encoded = self.encoder.norm(x) * mask
|
| 217 |
+
pooled.append(encoded.sum(1) / mask.sum(1).clamp_min(1))
|
| 218 |
+
x = self._labelled(torch.cat(pooled).unsqueeze(1), torch.cat(labels))
|
| 219 |
+
for block in self.blocks:
|
| 220 |
+
x, kv = self._across(block, x, tasks, context, next(memory, None))
|
| 221 |
+
kept.append(kv)
|
| 222 |
+
return x, kept
|
| 223 |
+
|
| 224 |
+
def logits(self, x: torch.Tensor, num_classes: torch.Tensor) -> torch.Tensor:
|
| 225 |
+
"""Logits ``[tasks, events, max_classes]`` of the final tokens of
|
| 226 |
+
:meth:`encode`, for tasks of ``num_classes`` classes."""
|
| 227 |
+
logits = self.classifier(self.norm(x.mean(1)))
|
| 228 |
+
logits = logits.view(len(num_classes), -1, self.max_classes)
|
| 229 |
classes = torch.arange(self.max_classes, device=logits.device)
|
| 230 |
absent = classes >= num_classes.reshape(-1, 1, 1)
|
| 231 |
return logits.masked_fill(absent, ABSENT_LOGIT)
|
| 232 |
|
| 233 |
def forward(self, batch: TaskBatch) -> torch.Tensor:
|
| 234 |
+
tasks, context = batch.context_labels.shape
|
| 235 |
+
events = batch.events
|
| 236 |
+
size = len(events.tokens) // tasks
|
| 237 |
+
if self.normalize:
|
| 238 |
+
sums, counts = log_pt_sums(events)
|
| 239 |
+
scale = normalizing_scale(
|
| 240 |
+
sums.view(tasks, -1)[:, :context].sum(1),
|
| 241 |
+
counts.view(tasks, -1)[:, :context].sum(1),
|
| 242 |
+
)
|
| 243 |
+
events = transformed(events, scale.repeat_interleave(size))
|
| 244 |
+
labels = nn.functional.pad(batch.context_labels, (0, size - context), value=-1)
|
| 245 |
+
x, _ = self.encode([events], [labels.flatten()], tasks=tasks, context=context)
|
| 246 |
+
logits = self.logits(x, batch.num_classes)[:, context:]
|
| 247 |
return logits.flatten(0, 1)
|
| 248 |
|
| 249 |
|
| 250 |
+
#: The geometric-mean object pT (in the units of the tokens, 10 GeV) that
|
| 251 |
+
#: ``model.pfn_normalize`` gives the context of every task: about that of
|
| 252 |
+
#: the Open Data prior (48 GeV), GamGam (52 GeV) and Delphes (63 GeV).
|
| 253 |
+
PT_REFERENCE = 5.0
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def log_pt_sums(batch: SequenceBatch) -> tuple[torch.Tensor, torch.Tensor]:
|
| 257 |
+
"""Per event, the sum of ``log pT`` of its objects with pT > 0, and
|
| 258 |
+
their number."""
|
| 259 |
+
pt = batch.tokens[..., PT]
|
| 260 |
+
real = batch.token_mask & (pt > 0)
|
| 261 |
+
return torch.where(real, pt.clamp_min(1e-12).log(), 0).sum(1), real.sum(1)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def normalizing_scale(sums: torch.Tensor, counts: torch.Tensor) -> torch.Tensor:
|
| 265 |
+
"""The scale of momenta and energies that makes the geometric-mean pT
|
| 266 |
+
of the objects behind ``sums`` and ``counts`` (:func:`log_pt_sums`)
|
| 267 |
+
``PT_REFERENCE``."""
|
| 268 |
+
mean = sums / counts.clamp_min(1)
|
| 269 |
+
return torch.exp(math.log(PT_REFERENCE) - mean)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def transformed(
|
| 273 |
+
batch: SequenceBatch,
|
| 274 |
+
scale: torch.Tensor | float = 1.0,
|
| 275 |
+
rotation: float = 0.0,
|
| 276 |
+
flip: float = 1.0,
|
| 277 |
+
) -> SequenceBatch:
|
| 278 |
+
"""``batch`` with momenta and energies scaled by ``scale`` (one number,
|
| 279 |
+
or one per event), azimuths rotated by ``rotation`` and pseudorapidities
|
| 280 |
+
multiplied by ``flip``."""
|
| 281 |
+
events = len(batch.tokens)
|
| 282 |
+
device = batch.tokens.device
|
| 283 |
+
|
| 284 |
+
def per_event(value: torch.Tensor | float) -> torch.Tensor:
|
| 285 |
+
return torch.as_tensor(value, device=device, dtype=torch.float).expand(events)
|
| 286 |
+
|
| 287 |
+
tokens = augment_tokens(
|
| 288 |
+
batch.tokens,
|
| 289 |
+
batch.token_mask,
|
| 290 |
+
scale=per_event(scale),
|
| 291 |
+
rotation=per_event(rotation),
|
| 292 |
+
flip=per_event(flip),
|
| 293 |
+
)
|
| 294 |
+
return replace(batch, tokens=tokens)
|
| 295 |
+
|
| 296 |
+
|
| 297 |
@torch.inference_mode()
|
| 298 |
def in_context_forward(
|
| 299 |
model: EventPFN,
|
|
|
|
| 303 |
num_classes: int,
|
| 304 |
batch_size: int,
|
| 305 |
device: torch.device,
|
| 306 |
+
ensemble: int = 1,
|
| 307 |
+
seed: int = 0,
|
| 308 |
) -> Callable[[SequenceBatch], torch.Tensor]:
|
| 309 |
"""A function from a batch of events to their logits ``[events,
|
| 310 |
+
num_classes]``, given the labelled ``context`` events (one task).
|
| 311 |
+
|
| 312 |
+
The context is encoded once. With ``ensemble`` passes, pass ``m`` rotates
|
| 313 |
+
every event by ``2 pi m / ensemble`` in azimuth, flips the
|
| 314 |
+
pseudorapidities of odd passes and numbers the classes in a random order
|
| 315 |
+
(seeded by ``seed``; the first pass keeps them); the logits are then the
|
| 316 |
+
logarithm of the mean class probabilities."""
|
| 317 |
if num_classes > model.max_classes:
|
| 318 |
raise ValueError(f"the PFN tells apart at most {model.max_classes} classes")
|
| 319 |
model.eval()
|
| 320 |
+
batches = [
|
| 321 |
+
collate_sequence_batch(events, part).to(device)
|
| 322 |
+
for part in context.split(batch_size)
|
| 323 |
+
]
|
| 324 |
+
labels = events.labels[context].to(device, torch.long).split(batch_size)
|
| 325 |
+
scale = 1.0
|
| 326 |
+
if model.normalize:
|
| 327 |
+
sums, counts = zip(*map(log_pt_sums, batches))
|
| 328 |
+
scale = normalizing_scale(torch.cat(sums).sum(), torch.cat(counts).sum())
|
| 329 |
+
generator = torch.Generator().manual_seed(seed)
|
| 330 |
+
passes = []
|
| 331 |
+
for member in range(ensemble):
|
| 332 |
+
order = (
|
| 333 |
+
torch.randperm(num_classes, generator=generator).to(device)
|
| 334 |
+
if member
|
| 335 |
+
else torch.arange(num_classes, device=device)
|
| 336 |
+
)
|
| 337 |
+
rotation = 2 * math.pi * member / ensemble
|
| 338 |
+
flip = -1.0 if member % 2 else 1.0
|
| 339 |
+
_, memory = model.encode(
|
| 340 |
+
[transformed(batch, scale, rotation, flip) for batch in batches],
|
| 341 |
+
[order[part] for part in labels],
|
| 342 |
+
context=len(context),
|
| 343 |
+
)
|
| 344 |
+
passes.append((order, rotation, flip, memory))
|
| 345 |
classes = torch.tensor([num_classes], device=device)
|
| 346 |
|
| 347 |
+
def logits(batch: SequenceBatch, order, rotation, flip, memory) -> torch.Tensor:
|
| 348 |
+
batch = transformed(batch, scale, rotation, flip)
|
| 349 |
+
queries = torch.full((len(batch.tokens),), -1, device=device)
|
| 350 |
+
x, _ = model.encode([batch], [queries], memory=memory)
|
| 351 |
+
# Class c of the dataset is class order[c] of this pass.
|
| 352 |
+
return model.logits(x, classes)[0, :, order]
|
| 353 |
+
|
| 354 |
+
@torch.inference_mode()
|
| 355 |
def forward(batch: SequenceBatch) -> torch.Tensor:
|
| 356 |
+
if len(passes) == 1:
|
| 357 |
+
return logits(batch, *passes[0])
|
| 358 |
+
probabilities = [logits(batch, *one).softmax(-1) for one in passes]
|
| 359 |
+
return torch.stack(probabilities).mean(0).log()
|
| 360 |
|
| 361 |
return forward
|
| 362 |
|
|
|
|
| 364 |
# --- The prior ----------------------------------------------------------------
|
| 365 |
|
| 366 |
|
| 367 |
+
def event_observables(events: PackedEvents) -> torch.Tensor:
|
| 368 |
+
"""``[events, 4 * types + 1]`` observables of every event: per object
|
| 369 |
+
type, the number of objects and ``log(1 + x)`` of their scalar sum pT,
|
| 370 |
+
leading pT and invariant mass; then the number of b-tagged objects."""
|
| 371 |
+
features = events.object_features
|
| 372 |
+
counts = events.object_offsets.diff()
|
| 373 |
+
event = torch.repeat_interleave(torch.arange(len(counts)), counts)
|
| 374 |
+
types = int(features[:, NODE_TYPE].max()) + 1
|
| 375 |
+
key = event * types + features[:, NODE_TYPE].long()
|
| 376 |
+
pt, eta, phi = features[:, PT], features[:, ETA], features[:, PHI]
|
| 377 |
+
|
| 378 |
+
def total(value: torch.Tensor) -> torch.Tensor:
|
| 379 |
+
sums = torch.zeros(len(counts) * types, dtype=torch.float64)
|
| 380 |
+
return sums.index_add_(0, key, value.double()).view(-1, types)
|
| 381 |
+
|
| 382 |
+
leading = torch.zeros(len(counts) * types).scatter_reduce_(0, key, pt, "amax")
|
| 383 |
+
mass2 = total(features[:, ENERGY]).square() - sum(
|
| 384 |
+
total(p).square()
|
| 385 |
+
for p in (pt * torch.cos(phi), pt * torch.sin(phi), pt * torch.sinh(eta))
|
| 386 |
+
)
|
| 387 |
+
return torch.cat(
|
| 388 |
+
(
|
| 389 |
+
total(torch.ones_like(pt)).float(),
|
| 390 |
+
total(pt).float().log1p(),
|
| 391 |
+
leading.view(-1, types).log1p(),
|
| 392 |
+
mass2.clamp_min(0).sqrt().float().log1p(),
|
| 393 |
+
torch.zeros(len(counts)).index_add_(0, event, features[:, BTAG])[:, None],
|
| 394 |
+
),
|
| 395 |
+
dim=1,
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _dirichlet(count: int, generator: torch.Generator) -> torch.Tensor:
|
| 400 |
+
"""Dirichlet(1) proportions of ``count`` parts."""
|
| 401 |
+
# rand can return exactly 0, whose -log is infinite.
|
| 402 |
+
share = -torch.log(torch.rand(count, generator=generator).clamp_min(1e-12))
|
| 403 |
+
return share / share.sum()
|
| 404 |
+
|
| 405 |
+
|
| 406 |
@dataclass(frozen=True)
|
| 407 |
class _Step:
|
| 408 |
"""The sampled events and augmentations of one meta-training step."""
|
|
|
|
| 505 |
]
|
| 506 |
if len(self.pools) < 2 * config.mixture_labels[0]:
|
| 507 |
raise ValueError("the PFN prior needs events of at least two classes")
|
| 508 |
+
# Standardized observables of the events, for synthetic tasks.
|
| 509 |
+
self.observables = None
|
| 510 |
+
if train and config.synthetic_tasks:
|
| 511 |
+
observables = event_observables(events)
|
| 512 |
+
used = observables[torch.cat(self.pools)]
|
| 513 |
+
mean, std = used.mean(0), used.std(0).clamp_min(1e-6)
|
| 514 |
+
self.observables = (observables - mean) / std
|
| 515 |
|
| 516 |
def set_epoch(self, epoch: int) -> None:
|
| 517 |
self.epoch = epoch
|
|
|
|
| 540 |
largest = min(config.task_classes[1], len(self.pools) // fewest, context)
|
| 541 |
smallest = min(config.task_classes[0], largest)
|
| 542 |
|
| 543 |
+
def dirichlet(count: int) -> torch.Tensor:
|
| 544 |
+
return _dirichlet(count, generator)
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
indices, labels, num_classes = [], [], []
|
| 547 |
for _ in range(tasks):
|
| 548 |
k = int(torch.randint(smallest, largest + 1, (), generator=generator))
|
| 549 |
+
# No draw without synthetic tasks: earlier priors are unchanged.
|
| 550 |
+
if (
|
| 551 |
+
self.observables is not None
|
| 552 |
+
and float(torch.rand((), generator=generator)) < config.synthetic_tasks
|
| 553 |
+
):
|
| 554 |
+
task_indices, task_labels = self._synthetic(k, size, generator)
|
| 555 |
+
indices.append(task_indices)
|
| 556 |
+
labels.append(task_labels)
|
| 557 |
+
num_classes.append(k)
|
| 558 |
+
continue
|
| 559 |
# Each class mixes 1 to ``most`` labels, no label in two classes.
|
| 560 |
# Without mixing, no draws are added: earlier priors are unchanged.
|
| 561 |
mixed = (
|
|
|
|
| 612 |
flip=flip,
|
| 613 |
)
|
| 614 |
|
| 615 |
+
def _synthetic(
|
| 616 |
+
self, k: int, size: int, generator: torch.Generator
|
| 617 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 618 |
+
"""The events and labels of a synthetic task of ``k`` classes:
|
| 619 |
+
events of 1 to 3 processes (Dirichlet proportions), classified by
|
| 620 |
+
ranges of a random one-hidden-layer network (16 tanh units) of 1 to
|
| 621 |
+
4 random observables, the ranges holding Dirichlet fractions of the
|
| 622 |
+
task's events. The first ``k`` events have the ``k`` labels."""
|
| 623 |
+
|
| 624 |
+
def dirichlet(count: int) -> torch.Tensor:
|
| 625 |
+
return _dirichlet(count, generator)
|
| 626 |
+
|
| 627 |
+
parts = int(torch.randint(1, 4, (), generator=generator))
|
| 628 |
+
chosen = torch.randperm(len(self.pools), generator=generator)[:parts]
|
| 629 |
+
part = torch.multinomial(dirichlet(parts), size, True, generator=generator)
|
| 630 |
+
task_indices = torch.empty(size, dtype=torch.long)
|
| 631 |
+
for which, pool_index in enumerate(chosen.tolist()):
|
| 632 |
+
pool, members = self.pools[pool_index], part == which
|
| 633 |
+
draw = torch.randint(len(pool), (int(members.sum()),), generator=generator)
|
| 634 |
+
task_indices[members] = pool[draw]
|
| 635 |
+
observables = self.observables[task_indices]
|
| 636 |
+
inputs = min(4, observables.shape[1])
|
| 637 |
+
inputs = int(torch.randint(1, inputs + 1, (), generator=generator))
|
| 638 |
+
columns = torch.randperm(observables.shape[1], generator=generator)[:inputs]
|
| 639 |
+
hidden = torch.randn(inputs, 16, generator=generator)
|
| 640 |
+
bias, output = torch.randn(2, 16, generator=generator)
|
| 641 |
+
value = torch.tanh(observables[:, columns] @ hidden + bias) @ output
|
| 642 |
+
# Ranks, ties (as of object counts) broken at random.
|
| 643 |
+
tie = torch.rand(size, generator=generator)
|
| 644 |
+
order = torch.argsort(value + 1e-6 * value.std() * tie)
|
| 645 |
+
rest = torch.multinomial(dirichlet(k), size - k, True, generator=generator)
|
| 646 |
+
sizes = torch.bincount(rest, minlength=k) + 1
|
| 647 |
+
task_labels = torch.empty(size, dtype=torch.long)
|
| 648 |
+
task_labels[order] = torch.repeat_interleave(
|
| 649 |
+
torch.randperm(k, generator=generator), sizes
|
| 650 |
+
)
|
| 651 |
+
first = torch.stack([(task_labels == c).nonzero()[0, 0] for c in range(k)])
|
| 652 |
+
others = torch.ones(size, dtype=torch.bool)
|
| 653 |
+
others[first] = False
|
| 654 |
+
arranged = torch.cat((first, others.nonzero().flatten()))
|
| 655 |
+
return task_indices[arranged], task_labels[arranged]
|
| 656 |
+
|
| 657 |
def __iter__(self) -> Iterator[Any]:
|
| 658 |
generator = self._generator()
|
| 659 |
steps = _Steps(self.events, [self.sample(generator) for _ in range(len(self))])
|
|
@@ -4,13 +4,21 @@ inference and the Delphes-to-GamGam workflow."""
|
|
| 4 |
import json
|
| 5 |
from dataclasses import replace
|
| 6 |
|
|
|
|
| 7 |
import torch
|
| 8 |
from conftest import GAMGAM_FIXTURES, TTH_FIXTURE, fixture_config, random_events
|
| 9 |
|
| 10 |
from gnn4colliders.cli import main
|
| 11 |
from gnn4colliders.config import ModelConfig, TaskConfig, save_config
|
|
|
|
| 12 |
from gnn4colliders.models import build_model
|
| 13 |
-
from gnn4colliders.pfn import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from gnn4colliders.sequences import collate_sequence_batch
|
| 15 |
|
| 16 |
SMALL = {"hid_size": 16, "n_layers": 1, "n_heads": 2, "pair_bias": True}
|
|
@@ -37,12 +45,35 @@ def _loader(events, train=True, **prior):
|
|
| 37 |
return TaskLoader(events, torch.arange(len(events)), config, 64, train=train)
|
| 38 |
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
torch.manual_seed(0)
|
| 42 |
-
config = ModelConfig(name="pfn", pfn_layers=2, max_classes=4, **SMALL)
|
| 43 |
return build_model(config, num_features=7, num_node_types=5).eval()
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
def test_tasks_follow_the_prior():
|
| 47 |
events = _events()
|
| 48 |
for batch in _loader(events, prior_classes=[1, 2, 3]):
|
|
@@ -112,8 +143,10 @@ def test_training_tasks_vary_by_epoch_and_validation_tasks_do_not():
|
|
| 112 |
assert torch.equal(first(validation, 0), first(validation, 1))
|
| 113 |
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
tasks, context = batch.context_labels.shape
|
| 118 |
tokens = batch.events.tokens.clone()
|
| 119 |
first_query = context # of the first task
|
|
@@ -129,21 +162,92 @@ def test_queries_see_only_the_context():
|
|
| 129 |
assert (expected[absent] == ABSENT_LOGIT).all()
|
| 130 |
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
|
|
|
| 134 |
context, queries = torch.arange(0, 40), torch.arange(40, 60)
|
|
|
|
| 135 |
forward = in_context_forward(
|
| 136 |
model, events, context, num_classes=4, batch_size=16, device=torch.device("cpu")
|
| 137 |
)
|
| 138 |
with torch.no_grad():
|
| 139 |
-
actual =
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
expected = model.queries(embedded[:, 40:], memory, torch.tensor([4]))[0]
|
| 144 |
torch.testing.assert_close(actual, expected)
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
def test_encoder_starts_from_a_jepa_checkpoint(tmp_path):
|
| 148 |
jepa = build_model(
|
| 149 |
ModelConfig(name="jepa", **SMALL), num_features=7, num_node_types=5
|
|
|
|
| 4 |
import json
|
| 5 |
from dataclasses import replace
|
| 6 |
|
| 7 |
+
import pytest
|
| 8 |
import torch
|
| 9 |
from conftest import GAMGAM_FIXTURES, TTH_FIXTURE, fixture_config, random_events
|
| 10 |
|
| 11 |
from gnn4colliders.cli import main
|
| 12 |
from gnn4colliders.config import ModelConfig, TaskConfig, save_config
|
| 13 |
+
from gnn4colliders.data import pack_events
|
| 14 |
from gnn4colliders.models import build_model
|
| 15 |
+
from gnn4colliders.pfn import (
|
| 16 |
+
ABSENT_LOGIT,
|
| 17 |
+
TaskBatch,
|
| 18 |
+
TaskLoader,
|
| 19 |
+
event_observables,
|
| 20 |
+
in_context_forward,
|
| 21 |
+
)
|
| 22 |
from gnn4colliders.sequences import collate_sequence_batch
|
| 23 |
|
| 24 |
SMALL = {"hid_size": 16, "n_layers": 1, "n_heads": 2, "pair_bias": True}
|
|
|
|
| 45 |
return TaskLoader(events, torch.arange(len(events)), config, 64, train=train)
|
| 46 |
|
| 47 |
|
| 48 |
+
#: The mean-pooled PFN, summary tokens, alternating attention and the
|
| 49 |
+
#: per-task scale of momenta.
|
| 50 |
+
VARIANTS = {
|
| 51 |
+
"pooled": {},
|
| 52 |
+
"summary": {"pfn_summary_tokens": 2},
|
| 53 |
+
"alternating": {"pfn_summary_tokens": 2, "pfn_alternating": True},
|
| 54 |
+
"normalized": {"pfn_summary_tokens": 1, "pfn_normalize": True},
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _pfn(**options):
|
| 59 |
torch.manual_seed(0)
|
| 60 |
+
config = ModelConfig(name="pfn", pfn_layers=2, max_classes=4, **SMALL, **options)
|
| 61 |
return build_model(config, num_features=7, num_node_types=5).eval()
|
| 62 |
|
| 63 |
|
| 64 |
+
def _task(events, context, queries):
|
| 65 |
+
"""One task: the ``context`` events with their labels, then ``queries``."""
|
| 66 |
+
indices = torch.cat((context, queries))
|
| 67 |
+
return TaskBatch(
|
| 68 |
+
events=collate_sequence_batch(events, indices),
|
| 69 |
+
context_labels=events.labels[context][None].long(),
|
| 70 |
+
num_classes=torch.tensor([4]),
|
| 71 |
+
labels=events.labels[queries],
|
| 72 |
+
weight=torch.ones(len(queries)),
|
| 73 |
+
event_index=queries,
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
def test_tasks_follow_the_prior():
|
| 78 |
events = _events()
|
| 79 |
for batch in _loader(events, prior_classes=[1, 2, 3]):
|
|
|
|
| 143 |
assert torch.equal(first(validation, 0), first(validation, 1))
|
| 144 |
|
| 145 |
|
| 146 |
+
@pytest.mark.parametrize("variant", VARIANTS)
|
| 147 |
+
def test_queries_see_only_the_context(variant):
|
| 148 |
+
model = _pfn(**VARIANTS[variant])
|
| 149 |
+
batch = next(iter(_loader(_events(), train=False)))
|
| 150 |
tasks, context = batch.context_labels.shape
|
| 151 |
tokens = batch.events.tokens.clone()
|
| 152 |
first_query = context # of the first task
|
|
|
|
| 162 |
assert (expected[absent] == ABSENT_LOGIT).all()
|
| 163 |
|
| 164 |
|
| 165 |
+
@pytest.mark.parametrize("variant", VARIANTS)
|
| 166 |
+
def test_in_context_inference_matches_a_task(variant):
|
| 167 |
+
model, events = _pfn(**VARIANTS[variant]), _events()
|
| 168 |
context, queries = torch.arange(0, 40), torch.arange(40, 60)
|
| 169 |
+
# The context in three parts, the queries in two.
|
| 170 |
forward = in_context_forward(
|
| 171 |
model, events, context, num_classes=4, batch_size=16, device=torch.device("cpu")
|
| 172 |
)
|
| 173 |
with torch.no_grad():
|
| 174 |
+
actual = torch.cat(
|
| 175 |
+
[forward(collate_sequence_batch(events, q)) for q in queries.split(16)]
|
| 176 |
+
)
|
| 177 |
+
expected = model(_task(events, context, queries))
|
|
|
|
| 178 |
torch.testing.assert_close(actual, expected)
|
| 179 |
|
| 180 |
|
| 181 |
+
def test_normalized_pfn_ignores_the_energy_scale():
|
| 182 |
+
model, events = _pfn(**VARIANTS["normalized"]), _events()
|
| 183 |
+
scaled = replace(events, object_features=events.object_features.clone())
|
| 184 |
+
scaled.object_features[:, [0, 3]] *= 1.3
|
| 185 |
+
context, queries = torch.arange(0, 40), torch.arange(40, 60)
|
| 186 |
+
with torch.no_grad():
|
| 187 |
+
expected = model(_task(events, context, queries))
|
| 188 |
+
actual = model(_task(scaled, context, queries))
|
| 189 |
+
torch.testing.assert_close(actual, expected, rtol=1e-4, atol=1e-4)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_ensemble_averages_probabilities_over_transformed_passes():
|
| 193 |
+
model, events = _pfn(**VARIANTS["alternating"]), _events()
|
| 194 |
+
context, queries = torch.arange(0, 40), torch.arange(40, 60)
|
| 195 |
+
|
| 196 |
+
def logits(ensemble):
|
| 197 |
+
forward = in_context_forward(
|
| 198 |
+
model,
|
| 199 |
+
events,
|
| 200 |
+
context,
|
| 201 |
+
num_classes=4,
|
| 202 |
+
batch_size=64,
|
| 203 |
+
device=torch.device("cpu"),
|
| 204 |
+
ensemble=ensemble,
|
| 205 |
+
)
|
| 206 |
+
return forward(collate_sequence_batch(events, queries))
|
| 207 |
+
|
| 208 |
+
single, ensemble = logits(1), logits(4)
|
| 209 |
+
torch.testing.assert_close(ensemble.exp().sum(1), torch.ones(20))
|
| 210 |
+
assert not torch.allclose(ensemble, single.log_softmax(1))
|
| 211 |
+
torch.testing.assert_close(logits(4), ensemble)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def test_synthetic_tasks_rank_events_of_few_labels():
|
| 215 |
+
events = _events()
|
| 216 |
+
loader = _loader(events, synthetic_tasks=1.0, prior_classes=None)
|
| 217 |
+
for batch in loader:
|
| 218 |
+
for task, classes in enumerate(_originals(events, batch)):
|
| 219 |
+
k = int(batch.num_classes[task])
|
| 220 |
+
assert set(batch.context_labels[task].tolist()) == set(range(k))
|
| 221 |
+
assert len(set().union(*classes)) <= 3
|
| 222 |
+
# Without synthetic tasks the prior draws exactly as before.
|
| 223 |
+
plain, zero = _loader(events), _loader(events, synthetic_tasks=0.0)
|
| 224 |
+
first = plain.sample(torch.Generator().manual_seed(1))
|
| 225 |
+
assert torch.equal(
|
| 226 |
+
first.indices, zero.sample(torch.Generator().manual_seed(1)).indices
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def test_event_observables_per_object_type():
|
| 231 |
+
# Two photons back to back (pT 3 and 4, eta 0), then MET of pT 2.
|
| 232 |
+
objects = [
|
| 233 |
+
torch.tensor(
|
| 234 |
+
[
|
| 235 |
+
[3.0, 0.0, 0.0, 3.0, 0.0, 0.0, 3.0],
|
| 236 |
+
[4.0, 0.0, torch.pi, 4.0, 1.0, 0.0, 3.0],
|
| 237 |
+
[2.0, 0.0, 1.0, 2.0, 0.0, 0.0, 4.0],
|
| 238 |
+
]
|
| 239 |
+
)
|
| 240 |
+
]
|
| 241 |
+
observables = event_observables(pack_events(objects, torch.tensor([0])))[0]
|
| 242 |
+
counts, total, leading, mass = observables[:20].view(4, 5)
|
| 243 |
+
assert counts.tolist() == [0, 0, 0, 2, 1]
|
| 244 |
+
torch.testing.assert_close(total[3], torch.log1p(torch.tensor(7.0)))
|
| 245 |
+
torch.testing.assert_close(leading[3], torch.log1p(torch.tensor(4.0)))
|
| 246 |
+
# m^2 = (3 + 4)^2 - (3 - 4)^2 = 48
|
| 247 |
+
torch.testing.assert_close(mass[3], torch.log1p(torch.tensor(48.0).sqrt()))
|
| 248 |
+
assert observables[20] == 1 # b-tagged objects
|
| 249 |
+
|
| 250 |
+
|
| 251 |
def test_encoder_starts_from_a_jepa_checkpoint(tmp_path):
|
| 252 |
jepa = build_model(
|
| 253 |
ModelConfig(name="jepa", **SMALL), num_features=7, num_node_types=5
|