MalikShehram commited on
Commit
113cd1b
Β·
verified Β·
1 Parent(s): 4781b0b

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +694 -0
  2. requirements.txt +27 -0
app.py ADDED
@@ -0,0 +1,694 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ParticleNet β€” Graph Neural Network for Particle Collision Event Classification
3
+ ==============================================================================
4
+ Author : Your Name (edit this before pushing to Hugging Face)
5
+ Project : AI + Physics Portfolio β€” Project 7
6
+ Dataset : Synthetic CERN-style jet data (self-generated, no download needed)
7
+ Model : 3-layer Graph Convolutional Network (GCN) built with PyTorch Geometric
8
+ Demo : Hugging Face Spaces Β· Gradio interface
9
+
10
+ Physics context
11
+ ---------------
12
+ At colliders like the LHC at CERN, protons smash together millions of times per
13
+ second, producing sprays of particles called *jets*. Identifying what kind of
14
+ particle initiated a jet β€” a quark, gluon, W boson, top quark, or Higgs boson β€”
15
+ is a fundamental task in particle physics and a perfect graph learning problem:
16
+ each jet is naturally a graph where particles are nodes and their proximity in
17
+ momentum space defines the edges.
18
+
19
+ This demo trains a small GCN on synthetic data that mimics real jet substructure
20
+ features (pT, eta, phi, charge, particle ID), then lets you generate a random
21
+ event and watch the model classify it in real time, with full visual explanation.
22
+ """
23
+
24
+ import gradio as gr
25
+ import torch
26
+ import torch.nn.functional as F
27
+ from torch_geometric.nn import GCNConv, global_mean_pool
28
+ from torch_geometric.data import Data, Batch
29
+ import numpy as np
30
+ import plotly.graph_objects as go
31
+ import plotly.express as px
32
+ from plotly.subplots import make_subplots
33
+ import os, json, time
34
+
35
+ # ─────────────────────────────────────────────────────────────
36
+ # 0. CONSTANTS β€” physics-inspired class labels
37
+ # ─────────────────────────────────────────────────────────────
38
+ CLASS_NAMES = ["Gluon jet", "Light-quark jet", "W boson jet",
39
+ "Top quark jet", "Higgs boson jet"]
40
+ CLASS_COLORS = ["#378ADD", "#1D9E75", "#EF9F27", "#D85A30", "#7F77DD"]
41
+ NUM_CLASSES = len(CLASS_NAMES)
42
+
43
+ # Node feature names β€” each particle in the jet carries these 5 features
44
+ FEATURE_NAMES = ["transverse momentum (pT)",
45
+ "pseudorapidity (Ξ·)",
46
+ "azimuthal angle (Ο†)",
47
+ "electric charge",
48
+ "particle type ID"]
49
+
50
+ # ─────────────────────────────────────────────────────────────
51
+ # 1. SYNTHETIC DATA GENERATOR
52
+ # Produces CERN-style jet graphs with realistic feature
53
+ # distributions for each class. No internet required.
54
+ # ─────────────────────────────────────────────────────────────
55
+ def generate_jet(label: int, seed: int | None = None) -> Data:
56
+ """
57
+ Generate one synthetic jet graph.
58
+
59
+ Each jet has between 8 and 24 constituent particles (nodes).
60
+ Edges connect every particle to its 3 nearest neighbours in
61
+ (Ξ·, Ο†) space β€” this is how real jet algorithms work.
62
+
63
+ Feature distributions are loosely inspired by particle physics:
64
+ - Gluon jets: many soft particles, wide angular spread
65
+ - Quark jets: fewer, harder particles
66
+ - W jets: two sub-clusters (W β†’ qq decay signature)
67
+ - Top jets: three sub-clusters (t β†’ bqq)
68
+ - Higgs jets: two sub-clusters with b-quark enrichment
69
+ """
70
+ rng = np.random.default_rng(seed)
71
+
72
+ # --- number of particles varies by jet type ---
73
+ n_particles_map = {0: (12, 24), 1: (8, 18), 2: (10, 20),
74
+ 3: (14, 24), 4: (10, 20)}
75
+ lo, hi = n_particles_map[label]
76
+ n = rng.integers(lo, hi + 1)
77
+
78
+ # --- pT spectrum: power-law (harder for quarks/bosons) ---
79
+ alpha = {0: 3.5, 1: 2.8, 2: 2.5, 3: 2.2, 4: 2.4}[label]
80
+ pt = rng.pareto(alpha, n) * 10 + 1 # GeV, >1
81
+
82
+ # --- angular spread in (eta, phi) ---
83
+ spread = {0: 0.5, 1: 0.3, 2: 0.25, 3: 0.35, 4: 0.28}[label]
84
+
85
+ if label in (2, 3, 4):
86
+ # Multi-prong: split particles into sub-clusters
87
+ n_prongs = {2: 2, 3: 3, 4: 2}[label]
88
+ # Place cluster centres
89
+ centres_eta = rng.uniform(-spread, spread, n_prongs)
90
+ centres_phi = rng.uniform(-spread, spread, n_prongs)
91
+ assign = rng.integers(0, n_prongs, n)
92
+ eta = centres_eta[assign] + rng.normal(0, spread / 3, n)
93
+ phi = centres_phi[assign] + rng.normal(0, spread / 3, n)
94
+ else:
95
+ eta = rng.normal(0, spread, n)
96
+ phi = rng.normal(0, spread, n)
97
+
98
+ # --- charge: mostly neutral for gluons, mix for others ---
99
+ charge_prob = {0: 0.2, 1: 0.45, 2: 0.5, 3: 0.55, 4: 0.4}[label]
100
+ charge = rng.choice([-1, 0, 1], n,
101
+ p=[charge_prob / 2, 1 - charge_prob, charge_prob / 2])
102
+
103
+ # --- particle type ID: 0=photon,1=neutral hadron,2=charged hadron,3=electron,4=muon ---
104
+ pid_probs = {
105
+ 0: [0.15, 0.40, 0.35, 0.07, 0.03],
106
+ 1: [0.10, 0.25, 0.50, 0.10, 0.05],
107
+ 2: [0.08, 0.20, 0.55, 0.12, 0.05],
108
+ 3: [0.05, 0.15, 0.60, 0.12, 0.08],
109
+ 4: [0.12, 0.28, 0.48, 0.09, 0.03],
110
+ }
111
+ pid = rng.choice(5, n, p=pid_probs[label])
112
+
113
+ # --- build node feature matrix (n Γ— 5) ---
114
+ # Normalise to roughly [-1, 1] so the GCN trains easily
115
+ pt_norm = np.log1p(pt) / 5.0 # log scale for pT
116
+ eta_norm = eta / 1.0
117
+ phi_norm = phi / np.pi
118
+ chg_norm = charge.astype(float)
119
+ pid_norm = pid.astype(float) / 4.0 # [0,1]
120
+
121
+ x = torch.tensor(
122
+ np.stack([pt_norm, eta_norm, phi_norm, chg_norm, pid_norm], axis=1),
123
+ dtype=torch.float
124
+ )
125
+
126
+ # --- k-NN graph in (eta, phi) space, k=3 ---
127
+ coords = np.stack([eta, phi], axis=1)
128
+ from sklearn.neighbors import NearestNeighbors
129
+ k = min(3, n - 1)
130
+ nbrs = NearestNeighbors(n_neighbors=k + 1).fit(coords)
131
+ _, indices = nbrs.kneighbors(coords)
132
+ src, dst = [], []
133
+ for i, neighbours in enumerate(indices):
134
+ for j in neighbours[1:]: # skip self
135
+ src.append(i); dst.append(j)
136
+ src.append(j); dst.append(i) # undirected
137
+
138
+ edge_index = torch.tensor([src, dst], dtype=torch.long)
139
+ y = torch.tensor([label], dtype=torch.long)
140
+
141
+ # Store raw coords for visualisation
142
+ data = Data(x=x, edge_index=edge_index, y=y)
143
+ data.pt = torch.tensor(pt, dtype=torch.float)
144
+ data.eta = torch.tensor(eta, dtype=torch.float)
145
+ data.phi = torch.tensor(phi, dtype=torch.float)
146
+ return data
147
+
148
+
149
+ def generate_dataset(n_per_class: int = 200, seed: int = 42) -> list[Data]:
150
+ """Create a balanced training set with n_per_class jets per category."""
151
+ dataset = []
152
+ for label in range(NUM_CLASSES):
153
+ for i in range(n_per_class):
154
+ dataset.append(generate_jet(label, seed=seed * 1000 + label * 100 + i))
155
+ rng = np.random.default_rng(seed)
156
+ perm = rng.permutation(len(dataset))
157
+ return [dataset[i] for i in perm]
158
+
159
+
160
+ # ─────────────────────────────────────────────────────────────
161
+ # 2. GCN MODEL
162
+ # Three graph convolutional layers followed by global mean
163
+ # pooling and a linear classifier head.
164
+ # ─────────────────────────────────────────────────────────────
165
+ class ParticleGCN(torch.nn.Module):
166
+ """
167
+ A 3-layer Graph Convolutional Network for jet classification.
168
+
169
+ Architecture
170
+ ------------
171
+ Input (5 features per particle node)
172
+ β†’ GCNConv(5 β†’ 64) + ReLU + Dropout(0.2)
173
+ β†’ GCNConv(64 β†’ 128) + ReLU + Dropout(0.2)
174
+ β†’ GCNConv(128 β†’ 64) + ReLU
175
+ β†’ GlobalMeanPool (aggregate all particles into one jet vector)
176
+ β†’ Linear(64 β†’ 32) + ReLU
177
+ β†’ Linear(32 β†’ 5) (one logit per class)
178
+
179
+ Why GCNs for physics?
180
+ ---------------------
181
+ Unlike CNNs (which need a grid) or RNNs (which need a sequence),
182
+ GCNs operate on arbitrary graphs β€” perfect for jets where the
183
+ number of particles varies and their spatial relationships matter.
184
+ The message-passing mechanism lets each particle "talk to" its
185
+ neighbours, building up a representation of local jet substructure
186
+ before the global pool summarises the whole event.
187
+ """
188
+ def __init__(self, in_channels: int = 5, hidden: int = 64,
189
+ out_channels: int = NUM_CLASSES):
190
+ super().__init__()
191
+ self.conv1 = GCNConv(in_channels, hidden)
192
+ self.conv2 = GCNConv(hidden, hidden * 2)
193
+ self.conv3 = GCNConv(hidden * 2, hidden)
194
+ self.lin1 = torch.nn.Linear(hidden, 32)
195
+ self.lin2 = torch.nn.Linear(32, out_channels)
196
+ self.drop = torch.nn.Dropout(p=0.2)
197
+
198
+ def forward(self, x, edge_index, batch):
199
+ # Message passing through the jet graph
200
+ x = F.relu(self.conv1(x, edge_index))
201
+ x = self.drop(x)
202
+ x = F.relu(self.conv2(x, edge_index))
203
+ x = self.drop(x)
204
+ x = F.relu(self.conv3(x, edge_index))
205
+
206
+ # Pool all particle embeddings into a single jet embedding
207
+ x = global_mean_pool(x, batch)
208
+
209
+ # Classification head
210
+ x = F.relu(self.lin1(x))
211
+ x = self.lin2(x)
212
+ return x
213
+
214
+
215
+ # ─────────────────────────────────────────────────────────────
216
+ # 3. TRAINING
217
+ # Runs once at startup; saves model to disk for reuse.
218
+ # ─────────────────────────────────────────────────────────────
219
+ MODEL_PATH = "particlenet_gcn.pt"
220
+ HISTORY_PATH = "training_history.json"
221
+
222
+ def train_model(n_per_class: int = 300, epochs: int = 60,
223
+ lr: float = 1e-3) -> tuple[ParticleGCN, dict]:
224
+ """Train the GCN on synthetic jet data and return model + history."""
225
+ print("Generating synthetic jet dataset...")
226
+ dataset = generate_dataset(n_per_class=n_per_class)
227
+
228
+ # 80/20 train-val split
229
+ split = int(0.8 * len(dataset))
230
+ train_data, val_data = dataset[:split], dataset[split:]
231
+
232
+ def make_batch(subset):
233
+ return Batch.from_data_list(subset)
234
+
235
+ device = torch.device("cpu") # CPU is fine for this model size
236
+ model = ParticleGCN().to(device)
237
+ opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
238
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
239
+
240
+ history = {"train_loss": [], "val_loss": [],
241
+ "train_acc": [], "val_acc": []}
242
+
243
+ print(f"Training on {len(train_data)} jets for {epochs} epochs...")
244
+ for epoch in range(epochs):
245
+ # ── train ──
246
+ model.train()
247
+ batch = make_batch(train_data)
248
+ batch = batch.to(device)
249
+ opt.zero_grad()
250
+ out = model(batch.x, batch.edge_index, batch.batch)
251
+ loss = F.cross_entropy(out, batch.y)
252
+ loss.backward()
253
+ opt.step()
254
+ sched.step()
255
+ train_loss = loss.item()
256
+ train_acc = (out.argmax(1) == batch.y).float().mean().item()
257
+
258
+ # ── validate ──
259
+ model.eval()
260
+ with torch.no_grad():
261
+ vbatch = make_batch(val_data).to(device)
262
+ vout = model(vbatch.x, vbatch.edge_index, vbatch.batch)
263
+ vloss = F.cross_entropy(vout, vbatch.y).item()
264
+ vacc = (vout.argmax(1) == vbatch.y).float().mean().item()
265
+
266
+ history["train_loss"].append(round(train_loss, 4))
267
+ history["val_loss"].append(round(vloss, 4))
268
+ history["train_acc"].append(round(train_acc, 4))
269
+ history["val_acc"].append(round(vacc, 4))
270
+
271
+ if (epoch + 1) % 10 == 0:
272
+ print(f" Epoch {epoch+1:3d}/{epochs} "
273
+ f"loss={train_loss:.4f} val_loss={vloss:.4f} "
274
+ f"val_acc={vacc:.2%}")
275
+
276
+ torch.save(model.state_dict(), MODEL_PATH)
277
+ with open(HISTORY_PATH, "w") as f:
278
+ json.dump(history, f)
279
+ print(f"Model saved to {MODEL_PATH}")
280
+ return model, history
281
+
282
+
283
+ def load_or_train() -> tuple[ParticleGCN, dict]:
284
+ """Load pretrained model if available, otherwise train from scratch."""
285
+ model = ParticleGCN()
286
+ if os.path.exists(MODEL_PATH) and os.path.exists(HISTORY_PATH):
287
+ print("Loading pre-trained model...")
288
+ model.load_state_dict(torch.load(MODEL_PATH, map_location="cpu"))
289
+ with open(HISTORY_PATH) as f:
290
+ history = json.load(f)
291
+ else:
292
+ model, history = train_model()
293
+ model.eval()
294
+ return model, history
295
+
296
+
297
+ # ─────────────────────────────────────────────────────────────
298
+ # 4. VISUALISATION HELPERS
299
+ # ─────────────────────────────────────────────────────────────
300
+ def plot_jet_graph(data: Data, pred_label: int,
301
+ true_label: int, probs: np.ndarray) -> go.Figure:
302
+ """
303
+ Build an interactive Plotly figure showing:
304
+ Left β€” the jet graph in (Ξ·, Ο†) space, nodes sized by pT
305
+ Right β€” the class probability bar chart
306
+ """
307
+ eta = data.eta.numpy()
308
+ phi = data.phi.numpy()
309
+ pt = data.pt.numpy()
310
+ src, dst = data.edge_index.numpy()
311
+
312
+ fig = make_subplots(
313
+ rows=1, cols=2,
314
+ column_widths=[0.6, 0.4],
315
+ subplot_titles=[
316
+ f"Jet graph β€” {len(eta)} particles in (Ξ·, Ο†) space",
317
+ "Class probabilities"
318
+ ]
319
+ )
320
+
321
+ # ── edge traces ──
322
+ ex, ey = [], []
323
+ for s, d in zip(src, dst):
324
+ ex += [eta[s], eta[d], None]
325
+ ey += [phi[s], phi[d], None]
326
+ fig.add_trace(
327
+ go.Scatter(x=ex, y=ey, mode="lines",
328
+ line=dict(color="#B4B2A9", width=0.8),
329
+ hoverinfo="skip", name="Edges"),
330
+ row=1, col=1
331
+ )
332
+
333
+ # ── node traces, coloured by particle type ──
334
+ pid_labels = ["Photon", "Neutral hadron", "Charged hadron",
335
+ "Electron", "Muon"]
336
+ pid_colors = ["#EF9F27", "#5DCAA5", "#378ADD", "#D85A30", "#7F77DD"]
337
+ pid_arr = (data.x[:, 4].numpy() * 4).round().astype(int)
338
+
339
+ for pid_val in range(5):
340
+ mask = pid_arr == pid_val
341
+ if not mask.any():
342
+ continue
343
+ fig.add_trace(
344
+ go.Scatter(
345
+ x=eta[mask], y=phi[mask],
346
+ mode="markers",
347
+ marker=dict(
348
+ size=np.clip(np.log1p(pt[mask]) * 6, 4, 22),
349
+ color=pid_colors[pid_val],
350
+ line=dict(width=0.8, color="#2C2C2A"),
351
+ opacity=0.85
352
+ ),
353
+ name=pid_labels[pid_val],
354
+ hovertemplate=(
355
+ f"<b>{pid_labels[pid_val]}</b><br>"
356
+ "Ξ· = %{x:.3f}<br>Ο† = %{y:.3f}<br>"
357
+ "pT β‰ˆ %{customdata:.1f} GeV<extra></extra>"
358
+ ),
359
+ customdata=pt[mask]
360
+ ),
361
+ row=1, col=1
362
+ )
363
+
364
+ # ── probability bars ──
365
+ bar_colors = [
366
+ CLASS_COLORS[i] if i == pred_label
367
+ else "#D3D1C7"
368
+ for i in range(NUM_CLASSES)
369
+ ]
370
+ fig.add_trace(
371
+ go.Bar(
372
+ x=probs * 100,
373
+ y=CLASS_NAMES,
374
+ orientation="h",
375
+ marker_color=bar_colors,
376
+ text=[f"{p*100:.1f}%" for p in probs],
377
+ textposition="outside",
378
+ hovertemplate="%{y}: %{x:.2f}%<extra></extra>",
379
+ name="Probabilities"
380
+ ),
381
+ row=1, col=2
382
+ )
383
+
384
+ # ── ground truth marker on bar chart ──
385
+ fig.add_vline(x=0, row=1, col=2, line_width=0) # dummy for spacing
386
+
387
+ correct = pred_label == true_label
388
+ result_color = "#1D9E75" if correct else "#D85A30"
389
+ result_text = "CORRECT" if correct else "WRONG"
390
+
391
+ fig.update_layout(
392
+ title=dict(
393
+ text=(
394
+ f"<b>Prediction: {CLASS_NAMES[pred_label]}</b> "
395
+ f"<span style='color:{result_color}'>[{result_text}]</span> "
396
+ f"Β· True label: {CLASS_NAMES[true_label]}"
397
+ ),
398
+ font_size=15, x=0.02
399
+ ),
400
+ showlegend=True,
401
+ legend=dict(x=0.01, y=-0.15, orientation="h",
402
+ font_size=11, bgcolor="rgba(0,0,0,0)"),
403
+ height=480,
404
+ margin=dict(l=40, r=40, t=60, b=80),
405
+ plot_bgcolor="rgba(0,0,0,0)",
406
+ paper_bgcolor="rgba(0,0,0,0)",
407
+ font=dict(color="#3d3d3a"),
408
+ xaxis=dict(title="Pseudorapidity (Ξ·)", gridcolor="#E8E6DF",
409
+ zeroline=True, zerolinecolor="#B4B2A9"),
410
+ yaxis=dict(title="Azimuthal angle Ο† (rad)", gridcolor="#E8E6DF"),
411
+ xaxis2=dict(title="Probability (%)", range=[0, 115],
412
+ gridcolor="#E8E6DF"),
413
+ yaxis2=dict(autorange="reversed"),
414
+ bargap=0.25,
415
+ )
416
+ return fig
417
+
418
+
419
+ def plot_training_history(history: dict) -> go.Figure:
420
+ """Plot training and validation loss + accuracy curves."""
421
+ epochs = list(range(1, len(history["train_loss"]) + 1))
422
+
423
+ fig = make_subplots(
424
+ rows=1, cols=2,
425
+ subplot_titles=["Loss (cross-entropy)", "Accuracy"]
426
+ )
427
+ for split, color, dash in [("train", "#378ADD", "solid"),
428
+ ("val", "#1D9E75", "dash")]:
429
+ fig.add_trace(go.Scatter(
430
+ x=epochs, y=history[f"{split}_loss"],
431
+ name=f"{split} loss",
432
+ line=dict(color=color, dash=dash, width=2),
433
+ mode="lines"
434
+ ), row=1, col=1)
435
+ fig.add_trace(go.Scatter(
436
+ x=epochs, y=[v * 100 for v in history[f"{split}_acc"]],
437
+ name=f"{split} accuracy",
438
+ line=dict(color=color, dash=dash, width=2),
439
+ mode="lines"
440
+ ), row=1, col=2)
441
+
442
+ final_val_acc = history["val_acc"][-1] * 100
443
+ fig.update_layout(
444
+ title=dict(
445
+ text=f"Training curves β€” final validation accuracy: {final_val_acc:.1f}%",
446
+ font_size=14, x=0.02
447
+ ),
448
+ height=320,
449
+ margin=dict(l=40, r=40, t=50, b=40),
450
+ plot_bgcolor="rgba(0,0,0,0)",
451
+ paper_bgcolor="rgba(0,0,0,0)",
452
+ font=dict(color="#3d3d3a"),
453
+ legend=dict(orientation="h", y=-0.15, font_size=11,
454
+ bgcolor="rgba(0,0,0,0)"),
455
+ xaxis=dict(title="Epoch", gridcolor="#E8E6DF"),
456
+ yaxis=dict(title="Loss", gridcolor="#E8E6DF"),
457
+ xaxis2=dict(title="Epoch", gridcolor="#E8E6DF"),
458
+ yaxis2=dict(title="Accuracy (%)", gridcolor="#E8E6DF"),
459
+ )
460
+ return fig
461
+
462
+
463
+ def physics_explanation(label: int) -> str:
464
+ """Return a short physics description of each jet class."""
465
+ explanations = {
466
+ 0: (
467
+ "**Gluon jet** β€” Gluons are the force carriers of the strong nuclear "
468
+ "force (QCD). They produce jets with many soft, wide-angle particles "
469
+ "because gluons radiate more than quarks (higher colour charge). "
470
+ "Typical at the LHC: ~70% of jets in inclusive samples are gluon jets."
471
+ ),
472
+ 1: (
473
+ "**Light-quark jet** β€” Up, down, or strange quarks produce narrower, "
474
+ "harder jets with fewer particles. The jet charge and the ratio of "
475
+ "charged to neutral particles help distinguish these from gluon jets."
476
+ ),
477
+ 2: (
478
+ "**W boson jet** β€” When a highly boosted W decays hadronically "
479
+ "(W β†’ qqΜ„), both daughter quarks are caught inside a single large-R "
480
+ "jet. This creates a distinctive two-prong substructure visible in "
481
+ "the (Ξ·, Ο†) graph as two clusters of particles."
482
+ ),
483
+ 3: (
484
+ "**Top quark jet** β€” The heaviest known elementary particle (173 GeV). "
485
+ "A boosted top decays as t β†’ bW β†’ bqqΜ„, producing a *three-prong* "
486
+ "substructure. The b-quark sub-jet leaves a secondary vertex signature "
487
+ "that b-taggers exploit."
488
+ ),
489
+ 4: (
490
+ "**Higgs boson jet** β€” The Higgs decays predominantly to bbΜ„ at low "
491
+ "mass. In the boosted regime both b-quarks merge into one fat jet. "
492
+ "Like the W but enriched in b-quarks. Identifying these jets is "
493
+ "crucial for measuring Higgs couplings at the LHC."
494
+ ),
495
+ }
496
+ return explanations[label]
497
+
498
+
499
+ # ─────────────────────────────────────────────────────────────
500
+ # 5. GRADIO INTERFACE
501
+ # ─────────────────────────────────────────────────────────────
502
+ print("Loading / training ParticleNet GCN...")
503
+ MODEL, HISTORY = load_or_train()
504
+ print("Ready.")
505
+
506
+ # Pre-generate a pool of events we can index into with a slider
507
+ POOL_SIZE = 500
508
+ EVENT_POOL = [
509
+ generate_jet(label=i % NUM_CLASSES, seed=9999 + i)
510
+ for i in range(POOL_SIZE)
511
+ ]
512
+ # Shuffle the pool so classes are not simply in order
513
+ rng = np.random.default_rng(777)
514
+ pool_perm = rng.permutation(POOL_SIZE)
515
+ EVENT_POOL = [EVENT_POOL[i] for i in pool_perm]
516
+
517
+
518
+ def classify_event(event_index: int):
519
+ """
520
+ Core inference function called by Gradio.
521
+ Returns (jet_graph_figure, explanation_text, training_figure).
522
+ """
523
+ data = EVENT_POOL[int(event_index)]
524
+
525
+ # ── run GCN inference ──
526
+ with torch.no_grad():
527
+ batch = Batch.from_data_list([data])
528
+ logits = MODEL(batch.x, batch.edge_index, batch.batch)
529
+ probs = F.softmax(logits, dim=1).numpy()[0]
530
+ pred = int(probs.argmax())
531
+ true = int(data.y.item())
532
+
533
+ jet_fig = plot_jet_graph(data, pred, true, probs)
534
+ train_fig = plot_training_history(HISTORY)
535
+
536
+ n_particles = data.x.shape[0]
537
+ n_edges = data.edge_index.shape[1] // 2
538
+ confidence = probs[pred] * 100
539
+
540
+ expl = (
541
+ f"### Event #{int(event_index)+1} Β· {n_particles} particles Β· "
542
+ f"{n_edges} edges\n\n"
543
+ f"**Model prediction:** {CLASS_NAMES[pred]} "
544
+ f"(confidence: {confidence:.1f}%)\n\n"
545
+ f"**True label:** {CLASS_NAMES[true]}\n\n"
546
+ f"---\n\n"
547
+ f"{physics_explanation(true)}\n\n"
548
+ f"---\n\n"
549
+ f"**How the GCN works:** Each particle sends a message to its "
550
+ f"{min(3, n_particles-1)} nearest neighbours in (Ξ·, Ο†) space. "
551
+ f"After 3 rounds of message passing, all particle embeddings are "
552
+ f"averaged (global mean pool) to produce a single 64-dimensional "
553
+ f"jet embedding. A 2-layer MLP then maps this to the 5 class logits. "
554
+ f"Node size in the graph is proportional to log(pT) β€” larger nodes "
555
+ f"carry more transverse momentum."
556
+ )
557
+ return jet_fig, expl, train_fig
558
+
559
+
560
+ # ── custom CSS ──
561
+ CSS = """
562
+ #title-block { padding: 1.2rem 0 0.4rem; }
563
+ #title-block h1 { font-size: 1.6rem; font-weight: 500; margin: 0; }
564
+ #title-block p { font-size: 0.92rem; color: #5F5E5A; margin: 0.3rem 0 0; }
565
+ .badge {
566
+ display: inline-block;
567
+ font-size: 11px; font-weight: 500; padding: 2px 9px;
568
+ border-radius: 99px; margin-right: 4px;
569
+ background: #E6F1FB; color: #0C447C;
570
+ }
571
+ .gr-button-primary { background: #185FA5 !important; }
572
+ footer { display: none !important; }
573
+ """
574
+
575
+ with gr.Blocks(css=CSS, theme=gr.themes.Default(
576
+ primary_hue="blue",
577
+ font=gr.themes.GoogleFont("Inter")
578
+ )) as demo:
579
+
580
+ # ── header ──
581
+ gr.HTML("""
582
+ <div id="title-block">
583
+ <h1>ParticleNet β€” GNN Particle Collision Classifier</h1>
584
+ <p>
585
+ <span class="badge">Graph Neural Networks</span>
586
+ <span class="badge">CERN-style Jets</span>
587
+ <span class="badge">PyTorch Geometric</span>
588
+ <span class="badge">HEP Physics</span>
589
+ <span class="badge">AI + Physics Portfolio</span>
590
+ </p>
591
+ <p style="margin-top:0.6rem">
592
+ A 3-layer Graph Convolutional Network classifies particle collision events
593
+ (jets) into 5 categories β€” the same task performed at the LHC at CERN.
594
+ Each jet is a graph: particles are nodes, kNN edges in (Ξ·, Ο†) momentum space.
595
+ </p>
596
+ </div>
597
+ """)
598
+
599
+ gr.Markdown("---")
600
+
601
+ with gr.Row():
602
+ with gr.Column(scale=3):
603
+ event_slider = gr.Slider(
604
+ minimum=0, maximum=POOL_SIZE - 1, value=0, step=1,
605
+ label="Event index (scroll through 500 synthetic jets)",
606
+ info="Each position is a different randomly generated collision event"
607
+ )
608
+ classify_btn = gr.Button(
609
+ "Classify this jet β†’", variant="primary", size="lg"
610
+ )
611
+
612
+ with gr.Column(scale=1):
613
+ gr.Markdown("""
614
+ **Quick guide**
615
+
616
+ 1. Move the slider to pick an event
617
+ 2. Click **Classify this jet**
618
+ 3. See the jet graph, GCN prediction, and probability bars
619
+ 4. Read the physics explanation below
620
+
621
+ Node colour = particle type.
622
+ Node size = transverse momentum (pT).
623
+ """)
624
+
625
+ jet_plot = gr.Plot(label="Jet graph and class probabilities")
626
+ expl_box = gr.Markdown(label="Physics explanation & model reasoning")
627
+ train_plot = gr.Plot(label="GCN training history")
628
+
629
+ classify_btn.click(
630
+ fn=classify_event,
631
+ inputs=[event_slider],
632
+ outputs=[jet_plot, expl_box, train_plot]
633
+ )
634
+
635
+ # ── auto-run on slider change for snappy UX ──
636
+ event_slider.release(
637
+ fn=classify_event,
638
+ inputs=[event_slider],
639
+ outputs=[jet_plot, expl_box, train_plot]
640
+ )
641
+
642
+ gr.Markdown("---")
643
+
644
+ with gr.Accordion("Model architecture & physics background", open=False):
645
+ gr.Markdown("""
646
+ ### Graph Convolutional Network architecture
647
+
648
+ | Layer | Type | Input dim | Output dim |
649
+ |-------|------|-----------|------------|
650
+ | 1 | GCNConv + ReLU + Dropout(0.2) | 5 | 64 |
651
+ | 2 | GCNConv + ReLU + Dropout(0.2) | 64 | 128 |
652
+ | 3 | GCNConv + ReLU | 128 | 64 |
653
+ | 4 | GlobalMeanPool | 64 Γ— n_nodes | 64 |
654
+ | 5 | Linear + ReLU | 64 | 32 |
655
+ | 6 | Linear (classifier) | 32 | 5 |
656
+
657
+ ### Node features (per particle)
658
+ 1. **log(pT)** β€” transverse momentum on a log scale (GeV)
659
+ 2. **Ξ·** β€” pseudorapidity (relates to polar angle)
660
+ 3. **Ο†** β€” azimuthal angle (radians)
661
+ 4. **charge** β€” electric charge {-1, 0, +1}
662
+ 5. **PID** β€” particle type {photon, neutral hadron, charged hadron, electron, muon}
663
+
664
+ ### Why GNNs for particle physics?
665
+ Traditional jet classifiers use image-based CNNs (calorimeter images) or
666
+ dense networks on fixed-length feature vectors. GNNs are more natural because:
667
+ - jets have a **variable number** of particles β†’ no padding needed
668
+ - the **spatial relationship** between particles (proximity in momentum space) matters
669
+ - **permutation invariance** is built in β€” the order of particles in the list is irrelevant
670
+ - **message passing** lets the model learn multi-particle correlations automatically
671
+
672
+ ### Related real-world work
673
+ This demo is inspired by the ParticleNet paper (Qu & Gouskos, 2020) and the
674
+ IAIFI group at MIT (Prof. Jesse Thaler), who apply similar techniques to real
675
+ LHC data from the CMS experiment.
676
+
677
+ ### Dataset
678
+ Synthetic data generated with physics-motivated distributions (power-law pT
679
+ spectra, multi-prong angular structure for boosted bosons). For a real project,
680
+ replace the generator with the **JetNet** or **Top Quark Tagging** datasets
681
+ available on Zenodo.
682
+ """)
683
+
684
+ gr.HTML("""
685
+ <div style="font-size:12px;color:#888780;padding:1rem 0 0.5rem;border-top:1px solid #E8E6DF;margin-top:1rem">
686
+ ParticleNet Β· AI + Physics Portfolio Project 7 Β·
687
+ Built with PyTorch Geometric, Gradio, and Plotly Β·
688
+ Inspired by CERN/LHC jet physics and the NSF IAIFI at MIT
689
+ </div>
690
+ """)
691
+
692
+ if __name__ == "__main__":
693
+ # classify the first event on startup so the UI is not blank
694
+ demo.launch(show_error=True)
requirements.txt ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ParticleNet β€” GNN Particle Collision Classifier
2
+ # Hugging Face Spaces requirements
3
+ # Python 3.10+
4
+ #
5
+ # Install order matters on HF Spaces:
6
+ # torch must come before torch_geometric and its companions.
7
+
8
+ # ── core deep learning ──
9
+ torch==2.2.2
10
+ torchvision==0.17.2 # pulled in by torch ecosystem, keeps versions aligned
11
+
12
+ # ── graph neural networks ──
13
+ # PyTorch Geometric and its sparse/scatter backends
14
+ torch-geometric==2.5.3
15
+ torch-scatter==2.1.2
16
+ torch-sparse==0.6.18
17
+ torch-cluster==1.6.3
18
+
19
+ # ── scientific computing ──
20
+ numpy==1.26.4
21
+ scikit-learn==1.4.2 # used for kNN graph construction in the jet generator
22
+
23
+ # ── visualisation ──
24
+ plotly==5.22.0 # interactive jet graph + training curves
25
+
26
+ # ── Gradio UI ──
27
+ gradio==4.36.1