Practical Example: ML for HEP
You are already familiar with the scale of data that physicists have to handle. Even checking whether a large experiment is operating properly may require looking through thousands of histograms and detector quantities. Machine Learning can help us reduce some of this human effort, search large datasets quickly, and identify patterns that may otherwise remain hidden.
One should nevertheless remember that the goal of ML in physics is not to replace the physics simulation or physical reasoning. The goal is to aid them. For example, automating parts of Data Quality Monitoring (DQM) could reduce the burden on detector shifters, while the final interpretation must still remain grounded in physics.
The goal of particle physics is to understand the nature, towards that we can use ML in several possible places

This also brings us toward an inference-based approach to understanding nature, or in other words, the Standard Model.

Jet classification with PyTorch¶
We will now work through a compact but complete example of a deep-learning analysis in High-Energy Physics. The aim is not merely to run a finished neural network. We will prepare the data, construct the model, train it, inspect its failures, and ask whether the final result makes physical sense.
The Task¶
We will use the hls4ml Jet High-Level Features dataset, stored as hls4ml_HLF.arff when available locally. Our goal is to classify jets from proton-proton collisions into five categories:
g: gluon jets, represented by label0;q: quark jets, represented by label1;w: hadronically decaying bosons, represented by label2;z: hadronically decaying bosons, represented by label3;t: hadronically decaying top quarks, represented by label4.
Each jet is represented by 16 high-level features describing properties such as its shape, mass, energy distribution, and constituent multiplicity.
The full dataset contains many events. To keep this suitable for a short course, we begin with a smaller random sample. Once the full pipeline works, you can always scale it up.
Imports and hardware check¶
First, we import the packages that will be needed throughout the notebook. We also ask PyTorch whether an accelerator is available:
CUDA is used for supported NVIDIA GPUs;
MPS is used for supported Apple Silicon GPUs;
otherwise, the notebook falls back to the CPU.
The same code can therefore run on several types of machines.
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
import pandas as pd
from scipy.io import arff
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import matplotlib.pyplot as plt
print(f"PyTorch version: {torch.__version__}")
print(f"CUDA (NVIDIA GPU) available: {torch.cuda.is_available()}")
print(f"MPS (Apple Silicon GPU) available: {torch.backends.mps.is_available()}") # NOTE: I am developing on a MacBook Pro with M1 chip, so I have access to MPS. But it is always good to confirm the hardware before committing to any analysis.
# Select device
device = torch.device("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")
print(f"Using device: {device}")PyTorch version: 2.7.0
CUDA (NVIDIA GPU) available: False
MPS (Apple Silicon GPU) available: True
Using device: mps
❓ Exercise¶
Q20: What is nn vs F? Why does PyTorch separate them? (Hint: Think about stateful object-oriented layers vs stateless mathematical functions).
Click to show answer
torch.nn, imported asnn, contains object-oriented building blocks such asnn.Linear,nn.ReLU, andnn.Dropout.These objects can store parameters or internal state and are automatically registered as part of a model.
torch.nn.functional, imported asF, contains stateless functions such asF.relu,F.softmax, andF.cross_entropy.PyTorch provides both styles so that we can use reusable layer objects when state or parameters are needed, while still having direct access to mathematical operations when no state needs to be stored.
Q21: Why do we use DataLoader instead of just feeding the entire dataset into the model at once? What memory and optimization benefits does it provide?
Click to show answer
A DataLoader divides the dataset into smaller mini-batches.
The complete dataset does not need to fit into memory at the same time.
The model parameters are updated several times during one epoch instead of only once.
Shuffling the data helps prevent the training from depending on the original ordering of the events.
Mini-batches allow CPUs and GPUs to process many events in parallel efficiently.
Smaller batches produce noisier gradient estimates, which can sometimes help the optimizer escape poor regions of parameter space.
Q22: What is CUDA or MPS? Why is GPU training so much faster than CPU training for deep learning?
Click to show answer
CUDA is the computing platform used by PyTorch for supported NVIDIA GPUs.
MPS is the backend used by PyTorch for supported Apple Silicon GPUs.
Neural-network training involves many matrix multiplications and other operations that can be performed independently.
A GPU contains many smaller processing units that can carry out these calculations in parallel.
A CPU is excellent for general sequential tasks, but a GPU is usually much faster for the highly parallel operations used in deep learning.
For more contact an expert, not physicists.
Load and Prepare the Dataset¶
We now prepare the dataset step by step before giving it to the neural network.
Load the ARFF file:
ARFF stands for Attribute-Relation File Format. It is a text-based format commonly used in machine learning that stores both the description of the dataset—such as feature names and data types—and the actual data values.Decode the class labels:
The labels may initially be stored as byte objects such asb'q'orb'g'. We decode them into ordinary Python strings such as'q'and'g', which are easier to inspect and work with.Convert labels into numbers:
A neural network cannot directly learn from text labels. We therefore map each jet class to an integer, for example,q → 0 g → 1 w → 2 z → 3 t → 4These integers tell PyTorch which class each event belongs to.
Take a random subset:
The complete dataset is quite large. To make the first training exercise faster, we randomly select a smaller number of events. Because the sample is chosen randomly, it should still approximately represent the full dataset.Split the data:
We divide the selected events into a training set and a test set. The model learns from the training set, while the test set is kept aside to check how well the trained model performs on events it has not seen before.Normalize the input features:
Different jet observables can have very different numerical ranges. We useStandardScalerto transform each feature so that it has approximately zero mean and unit variance. This prevents features with large numerical values from dominating the training.Importantly, the scaler is fitted only on the training data and then applied to the test data. This avoids accidentally passing information from the test set into the training procedure.
Convert everything into PyTorch tensors:
Finally, we convert the NumPy arrays into PyTorch tensors. The input features are stored as floating-point tensors, while the class labels are stored as integer tensors. These are the formats expected by the neural network and the classification loss function.
After these steps, the data are ready to be grouped into batches and passed through the neural network.
import os
torch.manual_seed(42)
np.random.seed(42)
if os.path.exists('hls4ml_HLF.arff'):
print("Loading hls4ml_HLF.arff (this might take ~5-10 seconds)...")
data, meta = arff.loadarff('hls4ml_HLF.arff')
df = pd.DataFrame(data)
if 'class' in df.columns and hasattr(df['class'].iloc[0], 'decode'):
df['class'] = df['class'].str.decode('utf-8')
else:
print("Loading dataset via OpenML (hls4ml_lhc_jets_hlf)...")
from sklearn.datasets import fetch_openml
openml_data = fetch_openml('hls4ml_lhc_jets_hlf', version=1, as_frame=True)
df = openml_data.frame
if 'target' in df.columns and 'class' not in df.columns:
df['class'] = df['target']
# Class mapping
class_names = ['g', 'q', 'w', 'z', 't']
class_mapping = {name: idx for idx, name in enumerate(class_names)}
df['label'] = df['class'].map(class_mapping)
# Extract features and labels
feature_cols = [col for col in df.columns if col not in ['class', 'label', 'target']]
X = df[feature_cols].values
y = df['label'].values
# --- WORKSHOP SUBSAMPLING ---
# The full dataset has 830,000 samples. Training on all of them on a CPU could take 20+ minutes.
# We will use a fast subset of 20,000 samples for the workshop.
# You can increase this to 100,000+ or the full dataset later to boost your final accuracy!
n_samples = 20000
indices = np.random.choice(len(X), n_samples, replace=False)
X_subset = X[indices]
y_subset = y[indices]
# Split into Train (80%) and Test (20%)
train_X_raw, test_X_raw, train_y, test_y = train_test_split(
X_subset, y_subset, test_size=0.2, random_state=42, stratify=y_subset
)
# --- FEATURE NORMALIZATION ---
# Neural networks perform best when input features are standardized (zero mean, unit variance).
scaler = StandardScaler()
train_X_scaled = scaler.fit_transform(train_X_raw)
test_X_scaled = scaler.transform(test_X_raw)
# Convert to PyTorch tensors
train_X = torch.tensor(train_X_scaled, dtype=torch.float32)
test_X = torch.tensor(test_X_scaled, dtype=torch.float32)
train_Y = torch.tensor(train_y, dtype=torch.long)
test_Y = torch.tensor(test_y, dtype=torch.long)
print("\n--- Dataset Summary ---")
print(f"Total features: {train_X.shape[1]}")
print(f"Training samples: {train_X.shape[0]}")
print(f"Test samples: {test_X.shape[0]}")
print(f"Class counts in subset:\n{pd.Series(y_subset).map({v:k for k,v in class_mapping.items()}).value_counts()}")Loading dataset via OpenML (hls4ml_lhc_jets_hlf)...
--- Dataset Summary ---
Total features: 16
Training samples: 16000
Test samples: 4000
Class counts in subset:
w 4093
g 4068
z 4033
t 4017
q 3789
Name: count, dtype: int64
What information are we giving to the neural network?¶
Before training the neural network, let us first inspect the input features.
Each row of the dataset represents one jet, while each column contains a numerical description of some aspect of that jet. These numbers summarize questions such as:
How many particles are inside the jet?
How is the jet momentum shared among those particles?
Is the jet narrow or spread out?
Does it look like one concentrated spray, two separate sprays, or three separate sprays?
Is its mass close to that of a , , or top quark?
These are high-level features: instead of giving every individual particle directly to the network, physicists have already combined the particle information into a small set of useful observables.
The following code prints the names of all the features used by our model.
# Print the names of all input features used by the model
print("\n--- Input Features ---")
print(f"Number of features: {len(feature_cols)}\n")
for index, feature_name in enumerate(feature_cols, start=1):
print(f"{index:2d}. {feature_name}")
--- Input Features ---
Number of features: 16
1. zlogz
2. c1_b0_mmdt
3. c1_b1_mmdt
4. c1_b2_mmdt
5. c2_b1_mmdt
6. c2_b2_mmdt
7. d2_b1_mmdt
8. d2_b2_mmdt
9. d2_a1_b1_mmdt
10. d2_a1_b2_mmdt
11. m2_b1_mmdt
12. m2_b2_mmdt
13. n2_b1_mmdt
14. n2_b2_mmdt
15. mass_mmdt
16. multiplicity
What do these features mean?¶
At first sight, names such as d2_a1_b2_mmdt may look as if some tired cat fell asleep on the keyboard. Fortunately, their physical meaning is much simpler than their names suggest.
Most of these observables study the internal energy pattern of a jet. A jet produced by a single quark or gluon usually resembles one main spray of particles. A highly boosted or boson decays into two quarks and can therefore leave a jet with a two-prong structure. A top quark can produce a more complicated three-prong structure.
Jet data can also be represented as images. The following examples were produced using the synthetically simulated JetNet dataset. The question is then whether the patterns inside these images can help us infer which particle produced a particular jet.

A small guide to the names¶
First you should pardon poor physicists for not coming up with better names, nonetheless the following might help the curious souls. The feature names contain several pieces of information. For example,
d2_a1_b2_mmdtcan be read as
D2 observable | α = 1 | β = 2 | measured after mMDT groomingLet us decode the notation carefully.
zlogzis slightly different from the other variables. It describes how the jet momentum is shared among its constituent particles.C1,C2,D2,M2, andN2are the names of different families of jet-substructure observables. The letters label different mathematical combinations of energy-correlation functions; they are not independent physical parameters.The number in
C1,C2,D2,M2, orN2indicates the level of internal structure being examined. In this dataset,C1mainly describes one-prong properties such as the overall spread of a jet, while the observables ending in2are mainly designed to distinguish one-prong jets from jets with two hard branches.b0,b1, andb2mean that the angular exponent (\beta) is set torespectively.
The energy-correlation functions contain angular factors schematically of the form
where measures the angular separation between particles and .
Therefore:
: the angular separation receives no additional weighting;
: the observable depends linearly on the angle;
: particles separated by larger angles receive relatively more weight.
a1means that a second angular exponent, written as , has been fixed toThis appears in a generalized version of the observable in which the two-particle and three-particle correlations are allowed to use different angular exponents.
mmdtmeans that the observable was measured after applying the modified Mass Drop Tagger. This grooming procedure removes soft, wide-angle radiation so that the harder internal structure of the jet becomes easier to study.
For example:
| Feature name | Meaning |
|---|---|
zlogz | Describes how the jet momentum is shared among its particles |
c1_b0_mmdt | with , after mMDT grooming |
c1_b2_mmdt | with , after mMDT grooming |
d2_b1_mmdt | Standard with , after grooming |
d2_b2_mmdt | Standard with , after grooming |
d2_a1_b2_mmdt | Generalized with and , after grooming |
There is also a small curiosity in this dataset:
d2_b1_mmdt
d2_a1_b1_mmdtrepresent the same choice , so their numerical values are identical in this particular dataset.
The main physical idea remains simple: changing and changes how strongly an observable responds to the angular pattern of radiation inside the jet. The dataset therefore gives the neural network several different views of the same jet.
Let us now look briefly at what is hiding behind these names, for the first reading you can ignore the following details and may come back later to this page to learn them at your own time and pace.
The zlogz observable¶
Suppose that constituent carries a fraction of the total jet momentum:
These momentum fractions satisfy
The zlogz feature is
Because , we have , and therefore zlogz is normally negative.
This is closely related to the Shannon entropy,
Therefore,
This gives it a simple interpretation:
if one particle carries nearly all the jet momentum,
zlogzis closer to zero;if the momentum is shared among many particles,
zlogzbecomes more negative;equivalently, the corresponding Shannon entropy becomes larger.
For example, if the momentum is shared equally among particles,
then
Thus, zlogz measures how concentrated or distributed the momentum flow is inside the jet.
Two-particle and three-particle energy correlations¶
Let be the momentum fraction of particle , and let represent the angular separation between particles and .
A two-particle energy correlation has the schematic form
It examines pairs of particles and asks how much momentum they carry and how far apart they are.
A three-particle energy correlation has the schematic form
It examines groups of three particles and is sensitive to more complicated internal structures.
The , , and observables¶
Ignoring some normalization conventions, the observables are constructed schematically as
and
These ratios compare the amount of three-particle structure with the amount of two-particle structure.
The observable is particularly useful for distinguishing:
a jet with one dominant core, such as many ordinary quark or gluon jets;
a jet with two hard internal branches, such as a boosted or jet.
Why do some observables contain both and ?¶
The generalized version allows the three-particle and two-particle correlations to use different angular exponents:
Therefore,
d2_a1_b2_mmdtcorresponds to
The numerator and denominator are then looking at the angular pattern with different sensitivities.
When
the generalized expression reduces to the usual form. This explains why
d2_b1_mmdtand
d2_a1_b1_mmdtcontain the same information.
The and observables¶
The and observables are constructed using generalized energy-correlation functions.
They are written schematically as
and
The symbols and represent slightly different ways of combining the angular separations among groups of particles.
The detailed definitions are not important for our neural-network exercise. The physical messages are:
was designed to identify two-prong structure particularly well after jet grooming;
also identifies two-prong structure but does not require us to first choose explicit subjet axes;
both provide alternative views of the same internal radiation pattern.
The main lesson is that these complicated-looking feature names encode three fairly simple ingredients:
which type of particle correlation is being measured;
how strongly angular separation is weighted;
whether soft radiation was removed before calculating the observable.
The equations may look formidable, but physically they are different ways of asking:
How is the energy arranged inside this jet?
❓ Exercise¶
Q23: What happens if classes overlap significantly? How does that affect the theoretical maximum accuracy our model can achieve?
Click to show answer
If two classes occupy overlapping regions of feature space, then events from those classes can have very similar or even identical measured features.
No classifier can perfectly separate events when the available inputs do not contain enough information to distinguish them.
Even an ideal classifier will therefore make some mistakes.
This creates an irreducible classification error and places an upper limit on the achievable accuracy.
Adding a larger neural network cannot ever recover information that is absent from the input features.
Q24: Why do we normalize features using StandardScaler? What would happen to the weights in the first layer if one feature ranged from 0 to 1 and another ranged from 0 to 1,000,000? (Hint: Think about gradients and learning rates).
Click to show answer
StandardScaler transforms each feature so that it has approximately zero mean and unit variance.
Without normalization:
the feature ranging up to
1,000,000would dominate the numerical value entering the first layer;gradients associated with different features could have very different scales;
one global learning rate might be too large for some directions and too small for others;
the optimizer could converge slowly or become unstable.
Normalization places the features on comparable numerical scales and usually makes optimization easier.
Q25: Examine the class distributions. Are they balanced or unbalanced? How does class imbalance affect evaluation metrics (e.g., standard accuracy vs balanced accuracy)?
Click to show answer
The printed class counts should be inspected directly. For this dataset and a sufficiently large random subset, the five classes are expected to be approximately balanced.
If a dataset is strongly imbalanced, ordinary accuracy can become misleading. For example, a model could obtain high accuracy by repeatedly predicting the most common class while performing poorly on rare classes.
Ordinary accuracy gives every event equal weight.
Balanced accuracy calculates the recall for each class and then averages over the classes.
A confusion matrix and per-class recall are also useful for checking whether good overall performance hides poor performance for one class.
Define the Model Architecture¶
Here we define our custom neural network class by inheriting from nn.Module.
In PyTorch, we define our layers in __init__ and the network’s forward logic in forward.
class SimpleClassifier(nn.Module):
def __init__(self, input_dim, hidden_dim, num_classes):
super().__init__()
# Layer 1: Linear projection from inputs to hidden features
self.layer1 = nn.Linear(input_dim, hidden_dim)
# Non-linear activation
self.relu = nn.ReLU()
# Layer 2: Projection from hidden features to class logits
self.layer2 = nn.Linear(hidden_dim, num_classes)
def forward(self, x):
x = self.relu(self.layer1(x))
x = self.layer2(x)
return x
# Instantiate model
model = SimpleClassifier(
input_dim=16, # 16 High-Level Features
hidden_dim=32, # Size of the hidden layer representation
num_classes=5 # 5 jet classes
)
print(model)
print(f"Total trainable parameters: {sum(p.numel() for p in model.parameters() if p.requires_grad)}")SimpleClassifier(
(layer1): Linear(in_features=16, out_features=32, bias=True)
(relu): ReLU()
(layer2): Linear(in_features=32, out_features=5, bias=True)
)
Total trainable parameters: 709
❓ Exercise¶
Q26: Parameter Scaling: Change hidden_dim from 32 to 8, 64, 128, and 256. Note down how the parameter count changes. Can you compute the formula for the number of parameters in this model? (Hint: don’t forget biases!)
Click to show answer
For
Linear(input_dim → hidden_dim)
Linear(hidden_dim → num_classes)
the first layer contains
$$
(\text{input\_dim}\times\text{hidden\_dim})+\text{hidden\_dim}
$$
parameters, and the output layer contains
$$
(\text{hidden\_dim}\times\text{num\_classes})+\text{num\_classes}.
$$
Therefore,
$$
N_{\mathrm{parameters}}
=
(\text{input\_dim}+1)\,\text{hidden\_dim}
+
(\text{hidden\_dim}+1)\,\text{num\_classes}.
$$
For `input_dim = 16` and `num_classes = 5`,
$$
N_{\mathrm{parameters}}=22\,\text{hidden\_dim}+5.
$$
This gives:
- `hidden_dim = 8` $\rightarrow 181$ parameters
- `hidden_dim = 32` $\rightarrow 709$ parameters
- `hidden_dim = 64` $\rightarrow 1413$ parameters
- `hidden_dim = 128` $\rightarrow 2821$ parameters
- `hidden_dim = 256` $\rightarrow 5637$ parametersQ27: Going Deeper (Coding Challenge): Modify the SimpleClassifier class (or write a new class DeepClassifier below) to add a second hidden layer. Your network flow should be:
Linear(input_dim -> hidden_dim)ReLU()Linear(hidden_dim -> hidden_dim)ReLU()Linear(hidden_dim -> num_classes)
Click to show answer
One possible implementation is
class DeepClassifier(nn.Module):
def __init__(self, input_dim, hidden_dim, num_classes):
super().__init__()
self.layer1 = nn.Linear(input_dim, hidden_dim)
self.layer2 = nn.Linear(hidden_dim, hidden_dim)
self.output = nn.Linear(hidden_dim, num_classes)
self.relu = nn.ReLU()
def forward(self, x):
x = self.relu(self.layer1(x))
x = self.relu(self.layer2(x))
return self.output(x)
model = DeepClassifier(
input_dim=train_X.shape[1],
hidden_dim=64,
num_classes=len(class_names),
)The second hidden layer allows the model to construct a more complicated non-linear decision boundary. It also increases the number of trainable parameters and therefore the possibility of overfitting.
Q28: Dropout Regularization (Coding Challenge): Insert a dropout layer (nn.Dropout(p=0.3)) after the activation function(s) to mitigate overfitting. What does dropout do during training, and how does it behave during evaluation (model.eval())?
Click to show answer
One possible implementation is
class DeepClassifierWithDropout(nn.Module):
def __init__(self, input_dim, hidden_dim, num_classes):
super().__init__()
self.layer1 = nn.Linear(input_dim, hidden_dim)
self.layer2 = nn.Linear(hidden_dim, hidden_dim)
self.output = nn.Linear(hidden_dim, num_classes)
self.relu = nn.ReLU()
self.dropout = nn.Dropout(p=0.3)
def forward(self, x):
x = self.dropout(self.relu(self.layer1(x)))
x = self.dropout(self.relu(self.layer2(x)))
return self.output(x)During training, dropout randomly sets a fraction of the hidden activations to zero. This discourages the network from depending too strongly on a small set of neurons and acts as a regularizer.
When model.eval() is called, dropout is switched off and the full network is used for prediction. Calling model.train() turns dropout back on before further training.
DataLoader, Optimizer, and Loss¶
Next, we prepare our DataLoader for training, and choose our loss function and optimizer.
These three components dictate how batches are loaded, how errors are quantified, and how weights are adjusted.
# Create DataLoader to feed data in mini-batches during training
train_loader = DataLoader(
TensorDataset(train_X, train_Y),
batch_size=32, # Feed 32 samples at a time
shuffle=True # Shuffle every epoch to prevent ordering bias
)
# Loss function: Multi-class Cross Entropy
criterion = nn.CrossEntropyLoss()
# Optimizer: Adam optimizer with learning rate 0.01
learning_rate = 0.01
optimizer = torch.optim.Adam(
model.parameters(),
lr=learning_rate
)❓ Exercise¶
Q29: Batch Size Sweep: What happens if you change batch_size to 8, 16, 64, or 128? How does batch size affect training speed (seconds per epoch) and the smoothness of the loss curve?
Click to show answer
There is no single numerical result because the timing depends on the computer and accelerator.
The usual trends are:
Smaller batches require less memory but produce noisier estimates of the gradient.
Smaller batches make more optimizer updates during each epoch.
Larger batches usually produce smoother loss curves.
Larger batches can make better use of parallel hardware, although extremely large batches may not improve generalization.
The fastest batch size depends on the hardware, dataset, and model.
Q30: Optimizer Experimentation: Replace Adam with standard stochastic gradient descent (torch.optim.SGD(model.parameters(), lr=0.01)). Does standard SGD learn faster or slower than Adam? Why does Adam converge faster in complex settings?
Click to show answer
In this example, plain SGD will often learn more slowly than Adam unless its learning rate and momentum are tuned carefully.
Adam keeps running estimates of both the average gradient and the squared gradient. It uses these quantities to adapt the effective step size separately for different parameters. This often allows Adam to reach a useful solution quickly.
Plain SGD uses the same global learning rate for all parameters. Adding momentum can improve it:
torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
SGD may still perform very well, but it is generally more sensitive to the learning rate and learning-rate schedule. Please check yourself and let me know if you found something else. Remember that ML is both arts and science combined together
Q31: L2 Regularization: Add weight_decay=1e-4 to the Adam optimizer. Try values of 0, 1e-5, 1e-3, and 1e-2. How does L2 weight decay combat overfitting?
Click to show answer
Weight decay discourages the optimizer from producing unnecessarily large weights.
With no weight decay, a flexible model may fit statistical fluctuations in the training sample.
Moderate weight decay can produce smoother, less fragile decision boundaries and improve validation performance.
If the weight decay is too large, the model becomes overly constrained and may underfit.
For Adam-like optimization, AdamW is often preferred because it applies weight decay separately from the adaptive gradient update.
The Training Loop¶
The heart of deep learning in PyTorch is the training loop. We iterate over our epochs and mini-batches, performing forward passes, gradient calculations, and optimizer steps.
epochs = 20
# Move model to the selected hardware device (GPU or CPU)
model = model.to(device)
train_losses = []
train_accs = []
for epoch in range(epochs):
model.train() # Set model to training mode (enables Dropout/BatchNorm)
total_loss = 0.0
correct = 0
total = 0
for batch_X, batch_Y in train_loader:
# Move mini-batch to active device
batch_X, batch_Y = batch_X.to(device), batch_Y.to(device)
# 1. Zero out previous gradients
optimizer.zero_grad()
# 2. Forward pass: compute predictions (logits)
logits = model(batch_X)
# 3. Compute loss
loss = criterion(logits, batch_Y)
# 4. Backward pass: compute gradients of loss w.r.t parameters
loss.backward()
# 5. Optimizer step: update weights
optimizer.step()
# Collect batch statistics
total_loss += loss.item()
preds = logits.argmax(dim=1)
correct += (preds == batch_Y).sum().item()
total += len(batch_Y)
# Calculate epoch metrics
epoch_loss = total_loss / len(train_loader)
epoch_acc = 100.0 * correct / total
train_losses.append(epoch_loss)
train_accs.append(epoch_acc)
print(f"Epoch {epoch+1:02d}/{epochs:02d} | Loss: {epoch_loss:.4f} | Train Accuracy: {epoch_acc:.2f}%")Epoch 01/20 | Loss: 0.9445 | Train Accuracy: 67.12%
Epoch 02/20 | Loss: 0.8524 | Train Accuracy: 71.81%
Epoch 03/20 | Loss: 0.8146 | Train Accuracy: 72.65%
Epoch 04/20 | Loss: 0.7971 | Train Accuracy: 73.03%
Epoch 05/20 | Loss: 0.7842 | Train Accuracy: 73.06%
Epoch 06/20 | Loss: 0.7752 | Train Accuracy: 73.38%
Epoch 07/20 | Loss: 0.7722 | Train Accuracy: 73.60%
Epoch 08/20 | Loss: 0.7620 | Train Accuracy: 73.67%
Epoch 09/20 | Loss: 0.7582 | Train Accuracy: 73.63%
Epoch 10/20 | Loss: 0.7518 | Train Accuracy: 73.96%
Epoch 11/20 | Loss: 0.7502 | Train Accuracy: 74.04%
Epoch 12/20 | Loss: 0.7506 | Train Accuracy: 73.87%
Epoch 13/20 | Loss: 0.7458 | Train Accuracy: 73.89%
Epoch 14/20 | Loss: 0.7423 | Train Accuracy: 74.16%
Epoch 15/20 | Loss: 0.7366 | Train Accuracy: 74.57%
Epoch 16/20 | Loss: 0.7380 | Train Accuracy: 74.36%
Epoch 17/20 | Loss: 0.7354 | Train Accuracy: 74.57%
Epoch 18/20 | Loss: 0.7304 | Train Accuracy: 74.61%
Epoch 19/20 | Loss: 0.7312 | Train Accuracy: 74.44%
Epoch 20/20 | Loss: 0.7314 | Train Accuracy: 74.54%
❓ Exercise¶
Q32: Underfitting vs. Overfitting: Run the training for 5, 20, and 100 epochs. At what point does the training loss stop decreasing significantly? Does the model begin to overfit if trained for too long?
Click to show answer
The exact epoch depends on the model, random seed (interestingly!!), and dataset.
With too few epochs, both training and validation performance may remain poor. This is underfitting.
With more epochs, training loss should decrease and validation performance should initially improve.
If training loss continues to decrease while validation loss begins to increase, the model is overfitting.
Training loss alone cannot tell us whether the model is overfitting. A separate validation sample is needed to observe whether performance on unseen data has started to worsen.
Q33: Early Stopping Coding Challenge: Modify the training loop below (or implement a new version) to incorporate Early Stopping.
Instruction: Evaluate validation loss at the end of each epoch. Keep track of the best validation loss. If the validation loss does not improve for 5 consecutive epochs, print a message and break the loop early.
Click to show answer
The final test set should not be used repeatedly for early stopping. First reserve part of the training sample as a validation sample, and then monitor that validation loss.
One possible implementation is
import copy
from torch.utils.data import random_split
# Split the existing training tensors into a smaller training set
# and a validation set.
full_training_dataset = TensorDataset(train_X, train_Y)
n_validation = int(0.2 * len(full_training_dataset))
n_training = len(full_training_dataset) - n_validation
training_subset, validation_subset = random_split(
full_training_dataset,
[n_training, n_validation],
generator=torch.Generator().manual_seed(42),
)
early_train_loader = DataLoader(
training_subset,
batch_size=32,
shuffle=True,
)
validation_loader = DataLoader(
validation_subset,
batch_size=256,
shuffle=False,
)
patience = 5
best_validation_loss = float("inf")
epochs_without_improvement = 0
best_state = None
for epoch in range(100):
model.train()
for batch_X, batch_Y in early_train_loader:
batch_X = batch_X.to(device)
batch_Y = batch_Y.to(device)
optimizer.zero_grad()
logits = model(batch_X)
loss = criterion(logits, batch_Y)
loss.backward()
optimizer.step()
model.eval()
validation_loss = 0.0
with torch.no_grad():
for batch_X, batch_Y in validation_loader:
batch_X = batch_X.to(device)
batch_Y = batch_Y.to(device)
logits = model(batch_X)
validation_loss += criterion(logits, batch_Y).item()
validation_loss /= len(validation_loader)
print(
f"Epoch {epoch + 1:03d} | "
f"validation loss = {validation_loss:.4f}"
)
if validation_loss < best_validation_loss - 1e-5:
best_validation_loss = validation_loss
best_state = copy.deepcopy(model.state_dict())
epochs_without_improvement = 0
else:
epochs_without_improvement += 1
if epochs_without_improvement >= patience:
print("Early stopping: validation loss stopped improving.")
break
# Restore the best validation model.
if best_state is not None:
model.load_state_dict(best_state)The small tolerance 1e-5 prevents tiny numerical fluctuations from being treated as meaningful improvements.
Evaluate the Model¶
We must test our model on unseen data, Since thats the final goal anyway. During evaluation, we put the model in .eval() mode and wrap our code in with torch.no_grad(): to turn off gradient computation (saving memory and compute).
model.eval() # Set model to evaluation mode (disables Dropout/BatchNorm)
# Move test dataset to the active device
test_X = test_X.to(device)
test_Y = test_Y.to(device)
# Disable gradient computations
with torch.no_grad():
logits = model(test_X)
preds = logits.argmax(dim=1)
acc = (preds == test_Y).float().mean()
print(f"Test Accuracy: {acc.item() * 100:.2f}%")Test Accuracy: 74.12%
❓ Exercise¶
Q34: Inspect Predictions (Coding challenge): Write a quick snippet to print the first 10 predictions alongside their true labels. Identify which predictions are correct and which are wrong.
Click to show answer
preds_cpu = preds.detach().cpu()
truth_cpu = test_Y.detach().cpu()
for i in range(10):
predicted_class = class_names[preds_cpu[i].item()]
true_class = class_names[truth_cpu[i].item()]
status = "correct" if predicted_class == true_class else "wrong"
print(
f"Sample {i:02d}: "
f"predicted = {predicted_class}, "
f"true = {true_class}, "
f"result = {status}"
)Moving the tensors to the CPU makes them easy to inspect and convert to NumPy arrays if needed.
Q35: Locate Misclassifications (Coding challenge): Print the index and feature values of 3 samples that the model predicted incorrectly. What might have confused the model?
Click to show answer
preds_cpu = preds.detach().cpu()
truth_cpu = test_Y.detach().cpu()
test_X_cpu = test_X.detach().cpu()
wrong_indices = torch.where(preds_cpu != truth_cpu)[0]
# NOTE: Convert standardized features back to their original units.
original_test_features = scaler.inverse_transform(
test_X_cpu.numpy()
)
for index in wrong_indices[:3]:
i = index.item()
print(f"\nTest index: {i}")
print(
"Predicted class:",
class_names[preds_cpu[i].item()],
)
print(
"True class:",
class_names[truth_cpu[i].item()],
)
feature_values = pd.Series(
original_test_features[i],
index=feature_cols,
)
print(feature_values)A misclassified event may lie in a region where the physical classes overlap strongly. Detector resolution, fluctuations in jet radiation, or insufficient input information can also make the classes difficult to distinguish.
Three events are useful examples, but they are not enough to establish a general pattern. The confusion matrix provides a more systematic view.
Q36: Confusion Matrix (Coding challenge): Use sklearn.metrics.confusion_matrix and matplotlib.pyplot to compute and plot a Confusion Matrix.
Which particle classes are most frequently confused with each other? For example, quark q vs gluon g, or W boson w vs Z boson z. Why does this make physical sense?
Click to show answer
from sklearn.metrics import (
confusion_matrix,
ConfusionMatrixDisplay,
)
preds_cpu = preds.detach().cpu()
truth_cpu = test_Y.detach().cpu()
cm = confusion_matrix(
truth_cpu.numpy(),
preds_cpu.numpy(),
labels=range(len(class_names)),
)
fig, ax = plt.subplots(figsize=(6, 6))
display = ConfusionMatrixDisplay(
confusion_matrix=cm,
display_labels=class_names,
)
display.plot(
ax=ax,
cmap="Blues",
colorbar=False,
)
ax.set_title("Jet-classification confusion matrix")
plt.show()The exact result depends on the trained model, but two physically reasonable confusions are:
quark vs gluon: both are QCD jets and their high-level observables can overlap substantially;
W vs Z: both commonly produce similar two-prong hadronic jet structures, and their masses are relatively close compared with jet and detector resolution.
A top jet may be easier to distinguish when its larger mass and characteristic three-prong structure are visible in the selected features.
Logits vs Softmax Probabilities¶
Our model outputs raw values called logits. To convert them into interpretable probabilities that sum to 1, we apply the Softmax activation.
# Apply Softmax along the class dimension (dim=1)
probabilities = torch.softmax(logits, dim=1)
# Display the first 3 test samples
for i in range(3):
print(f"Sample {i+1}:")
print(f" Logits: {logits[i].cpu().numpy()}")
print(f" Probabilities: {probabilities[i].cpu().numpy()} (Sum: {probabilities[i].sum().item():.2f})")
print(f" Prediction: {class_names[preds[i].item()]} (Class {preds[i].item()})")
print(f" True Label: {class_names[test_Y[i].item()]} (Class {test_Y[i].item()})\n")Sample 1:
Logits: [ 0.4411444 0.570843 -0.20781091 0.09380799 -2.0222065 ]
Probabilities: [0.28962058 0.32972875 0.15135324 0.20463651 0.02466094] (Sum: 1.00)
Prediction: q (Class 1)
True Label: w (Class 2)
Sample 2:
Logits: [-0.7224077 0.25743017 -1.6749004 1.2461461 -1.5958525 ]
Probabilities: [0.08600207 0.22911161 0.03317772 0.6158019 0.0359068 ] (Sum: 1.00)
Prediction: z (Class 3)
True Label: z (Class 3)
Sample 3:
Logits: [-3.5942411 -2.0412083 -1.8226286 3.8388171 -1.7660123]
Probabilities: [5.8520585e-04 2.7655549e-03 3.4412029e-03 9.8956633e-01 3.6416519e-03] (Sum: 1.00)
Prediction: z (Class 3)
True Label: z (Class 3)
❓ Exercise¶
Q37: Can you recognize something familiar from statistical mechanics in the cross-entropy loss?
Click to show answer
Yes! The connection appears once the model’s raw outputs, called logits , are converted into class probabilities using the Softmax function:
This has exactly the same mathematical form as a Boltzmann distribution,
if we identify
and choose temperature .
So the neural network assigns an effective energy to every possible class:
a large logit means a low effective energy;
a low effective energy means a large probability;
the denominator of Softmax plays the role of the partition function .
For a true class distribution and a predicted distribution , the cross entropy is
This is closely related to the Shannon entropy,
but cross entropy measures the average surprise obtained when the data follow the true distribution , while we describe them using the model distribution .
For ordinary classification, the target is usually one-hot encoded. If the correct class is , then and all other . The loss therefore becomes
Using the Boltzmann-like form of Softmax,
or equivalently,
Thus, training encourages the network to lower the effective energy of the correct class relative to all competing classes.
So, in a rather delightful statistical-mechanics picture, the classifier creates a tiny thermal ensemble over all possible labels, and learning tries to make the correct label the lowest-energy state.
Q38: CrossEntropyLoss Detail: Look back at Cell 4 where we defined our loss function as nn.CrossEntropyLoss(). Notice that our model’s last layer in Cell 3 is a nn.Linear layer that directly outputs raw logits, not probabilities. What should be passed from the model to nn.CrossEntropyLoss()?
Click to show answer
The model should pass its raw, unnormalized logits directly to nn.CrossEntropyLoss().
The correct pattern is
loss = criterion(model(batch_X), batch_Y)
Softmax probabilities are useful for interpretation after training, but they should not be inserted before this loss function.
Q39: Why does nn.CrossEntropyLoss NOT want us to add a Softmax layer at the end of our model?
What two operations are combined inside nn.CrossEntropyLoss?
Click to show answer
For ordinary integer class labels, nn.CrossEntropyLoss combines:
LogSoftmax, andnegative log-likelihood loss,
NLLLoss.
Therefore, the loss expects raw logits. If Softmax were applied inside the model first, the loss would receive probabilities rather than the logits it is designed to process.
Q40: What is the numerical stability benefit of combining Softmax and Log operations together instead of computing them separately? (Hint: Think about floating-point exponentiation overflow and underflow).
Click to show answer
For one event with true class , the loss contains the combination
A stable implementation uses a shifted form of the logarithmic sum. If the largest logit is ,
Because every , the exponentials are no larger than one. This reduces the danger of overflow.
The combined calculation also avoids first producing probabilities that are so small that they underflow to zero before their logarithms are taken.
Save and Load the Model¶
Once you have trained your model, you’ll want to save its weights so you can deploy it later. In PyTorch, we save the state_dict() which contains the model’s weight and bias matrices.
# Save the trained weights to a file
torch.save(model.state_dict(), "classifier.pt")
print("Model weights saved to classifier.pt!")
# To load, we must first instantiate the architecture
loaded_model = SimpleClassifier(input_dim=16, hidden_dim=32, num_classes=5)
# Load the weights into the architecture
loaded_model.load_state_dict(torch.load("classifier.pt"))
loaded_model = loaded_model.to(device)
loaded_model.eval()
# Double check validation accuracy matches exactly
with torch.no_grad():
loaded_logits = loaded_model(test_X)
loaded_preds = loaded_logits.argmax(dim=1)
loaded_acc = (loaded_preds == test_Y).float().mean()
print(f"Loaded Model Test Accuracy: {loaded_acc.item() * 100:.2f}%")Model weights saved to classifier.pt!
Loaded Model Test Accuracy: 74.12%
❓ Exercise¶
Q41: Now it is time to put everything you have learned to the test.
Your objective is to modify the code across the cells, or write custom code in the scratch cell below, to achieve the highest possible test accuracy on the jet-classification dataset.
Explore some combination of the following:
Scale up the Data: Increase
n_samplesfrom20000to50000,150000, or use the entire830,000dataset.Wider/Deeper Model: Add more layers and hidden units, for example layers of size
128and64.Combat Overfitting: Introduce
nn.Dropout(p=0.2)ornn.Dropout(p=0.4)and weight decay.Learning Rate Scheduling: Start with a larger learning rate and reduce it as training progresses.
Batch Size Tuning: Try batch sizes such as
16,32,64,128, and256.Optimizer Experimentation: Compare Adam, AdamW, RMSprop, or SGD with momentum.
Can you surpass 80% accuracy? Record your architecture, training strategy, and best test accuracy.
Click to show suggestions/hints
There is no unique correct solution. The purpose is to compare several reasonable choices while avoiding repeated tuning on the final test set.
The following provides one possible starting point:
import copy
class ImprovedClassifier(nn.Module):
def __init__(self, input_dim, num_classes):
super().__init__()
self.network = nn.Sequential(
nn.Linear(input_dim, 128),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(128, 64),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(64, num_classes),
)
def forward(self, x):
return self.network(x)
improved_model = ImprovedClassifier(
input_dim=train_X.shape[1],
num_classes=len(class_names),
).to(device)
improved_loader = DataLoader(
TensorDataset(train_X, train_Y),
batch_size=256,
shuffle=True,
)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(
improved_model.parameters(),
lr=1e-3,
weight_decay=1e-4,
)
scheduler = torch.optim.lr_scheduler.StepLR(
optimizer,
step_size=10,
gamma=0.5,
)
for epoch in range(40):
improved_model.train()
running_loss = 0.0
correct = 0
total = 0
for batch_X, batch_Y in improved_loader:
batch_X = batch_X.to(device)
batch_Y = batch_Y.to(device)
optimizer.zero_grad()
logits = improved_model(batch_X)
loss = criterion(logits, batch_Y)
loss.backward()
optimizer.step()
running_loss += loss.item()
correct += (
logits.argmax(dim=1) == batch_Y
).sum().item()
total += len(batch_Y)
scheduler.step()
print(
f"Epoch {epoch + 1:02d} | "
f"loss = {running_loss / len(improved_loader):.4f} | "
f"accuracy = {100 * correct / total:.2f}%"
)
improved_model.eval()
with torch.no_grad():
final_logits = improved_model(test_X.to(device))
final_predictions = final_logits.argmax(dim=1)
final_accuracy = (
final_predictions == test_Y.to(device)
).float().mean().item()
print(f"Final test accuracy: {100 * final_accuracy:.2f}%")Why might this help?
Two hidden layers allow a richer non-linear decision boundary.
Batch normalization can stabilize the hidden activations.
Dropout and weight decay can reduce overfitting.
AdamW combines adaptive optimization with decoupled weight decay.
The learning-rate schedule makes the optimization steps smaller later in training.
Increasing the number of training events may improve performance as much as, or more than, increasing model complexity.
The final reported test accuracy should ideally be evaluated only after the architecture and hyperparameters have been selected.
# Write and run your custom challenge code here!
# Hint: Define a new model, configure a custom DataLoader & optimizer, train and evaluate.