ho22joshua Claude Opus 5.5 commited on
Commit
040449e
·
1 Parent(s): 0feeab7

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 CHANGED
@@ -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
- build = (
325
- meta_tabular_forward
326
- if model_config.name in ("tabpfn", "tabicl")
327
- else pfn_forward
328
- )
329
- forward = build(
330
- model.to(device),
331
- events,
332
- context,
333
- num_classes=model_config.out_size,
334
- batch_size=data.batch_size,
335
- device=device,
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
 
src/gnn4colliders/config.py CHANGED
@@ -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
src/gnn4colliders/models.py CHANGED
@@ -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
src/gnn4colliders/pfn.py CHANGED
@@ -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 embeds every event with the object transformer (mean over objects),
6
- adds a label embedding to the context events, and runs attention blocks over
7
- the task's 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,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
- A new dataset then needs no training: its labelled training events are the
18
- context (``data.train_events``).
 
 
 
 
 
 
 
 
 
 
 
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 SequenceBatch, augment_tokens, collate_sequence_batch
 
 
 
 
 
 
 
 
 
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.blocks = nn.ModuleList(
80
- EncoderBlock(hid_size, n_heads, ffn_size, dropout) for _ in range(n_layers)
 
 
 
81
  )
 
 
 
 
 
 
 
 
82
  self.norm = nn.LayerNorm(hid_size)
83
  self.classifier = nn.Linear(hid_size, max_classes)
84
 
85
- def embed(self, batch: SequenceBatch) -> torch.Tensor:
86
- """``[events, hid_size]``: the mean encoding of each event's objects."""
87
- mask = batch.token_mask
88
- encoded = self.encoder(batch.tokens, mask)
89
- counts = mask.sum(dim=1, keepdim=True).clamp_min(1)
90
- return (encoded * mask.unsqueeze(-1)).sum(dim=1) / counts
91
-
92
- def context(
93
- self, embedded: torch.Tensor, labels: torch.Tensor
94
- ) -> list[tuple[torch.Tensor, torch.Tensor]]:
95
- """Each block's keys and values of the context events
96
- ``embedded [tasks, events, hid_size]`` with ``labels``."""
97
- x = embedded + self.label_embedding(labels)
98
- memory = []
99
- for block in self.blocks:
100
- q, k, v = block.project(x)
101
- memory.append((k, v))
102
- x = block.attend(x, q, k, v)
103
- return memory
104
-
105
- def queries(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  self,
107
- embedded: torch.Tensor,
108
- memory: list[tuple[torch.Tensor, torch.Tensor]],
109
- num_classes: torch.Tensor,
110
- ) -> torch.Tensor:
111
- """Logits ``[tasks, events, max_classes]`` of the query events
112
- ``embedded`` given the context ``memory`` of :meth:`context`."""
113
- x = embedded
114
- for block, (k, v) in zip(self.blocks, memory):
115
- x = block.attend(x, block.project(x)[0], k, v)
116
- logits = self.classifier(self.norm(x))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, size = batch.context_labels.shape
123
- embedded = self.embed(batch.events).view(tasks, -1, self.encoder.hid_size)
124
- memory = self.context(embedded[:, :size], batch.context_labels)
125
- logits = self.queries(embedded[:, size:], memory, batch.num_classes)
 
 
 
 
 
 
 
 
 
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
- embedded = torch.cat(
145
- [
146
- model.embed(collate_sequence_batch(events, part).to(device))
147
- for part in context.split(batch_size)
148
- ]
149
- )
150
- labels = events.labels[context].to(device, torch.long)
151
- memory = model.context(embedded.unsqueeze(0), labels.unsqueeze(0))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  classes = torch.tensor([num_classes], device=device)
153
 
 
 
 
 
 
 
 
 
154
  def forward(batch: SequenceBatch) -> torch.Tensor:
155
- logits = model.queries(model.embed(batch).unsqueeze(0), memory, classes)
156
- return logits[0, :, :num_classes]
 
 
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: # Dirichlet(1) proportions
295
- # rand can return exactly 0, whose -log is infinite.
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))])
tests/test_pfn.py CHANGED
@@ -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 ABSENT_LOGIT, TaskLoader, in_context_forward
 
 
 
 
 
 
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
- def _pfn():
 
 
 
 
 
 
 
 
 
 
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
- def test_queries_see_only_the_context():
116
- model, batch = _pfn(), next(iter(_loader(_events(), train=False)))
 
 
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
- def test_in_context_inference_matches_a_task():
133
- model, events = _pfn(), _events()
 
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 = forward(collate_sequence_batch(events, queries))
140
- everything = collate_sequence_batch(events, torch.cat((context, queries)))
141
- embedded = model.embed(everything).unsqueeze(0)
142
- memory = model.context(embedded[:, :40], events.labels[context][None])
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