Files
xie_x1andClaude Sonnet 5 2683fd3576 Initial commit: MLXIDDemo, a 12 keV end-to-end walkthrough of the MLXID pipeline
MC generation -> pile-up assembly -> CNN vs. classical eta-interpolation
(scored against MC ground truth) -> optional training -> qualitative
inference on real measurement data, self-contained and runnable from a
single conda env (see README.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:50:06 +02:00

145 lines
6.4 KiB
Python

"""
Train each CNN from scratch on the small demo dataset, a few epochs, to
prove the training pipeline runs end-to-end -- not to reproduce the shipped
checkpoint's accuracy (production trains on ~1e5-1e6 events/thread for
hundreds of epochs; see DeepLearning/Train_{1,2,3}Photon.py).
Pile-up loss matches predictions to ground truth via the permutation
minimizing L2 distance, then applies Smooth L1 -- same idea as
Train_2Photon.py / Train_3Photon.py's set loss, generalized to N=1..3.
Usage: python 04_train_demo.py --photons {1,2,3} [--epochs N]
Needs: torch, numpy. Use the `mlxid_demo` conda env.
"""
import sys
import itertools
import argparse
from pathlib import Path
import numpy as np
import torch
import torch.nn.functional as F
HERE = Path(__file__).resolve().parent.parent
sys.path.append(str(HERE / 'src'))
from datasets import singlePhotonDataset, doublePhotonDataset, triplePhotonDataset
from model_zoo import new_model
DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'
DATASET_FOR = {
1: dict(cls=singlePhotonDataset,
files=lambda: sorted(str(f) for f in (HERE / 'data/mc_samples').glob('*.npz'))),
2: dict(cls=doublePhotonDataset,
files=lambda: [str(HERE / 'data/pileup_samples/2photon_pileup_12keV_demo.npz')]),
3: dict(cls=triplePhotonDataset,
files=lambda: [str(HERE / 'data/pileup_samples/3photon_pileup_12keV_demo.npz')]),
}
def set_matched_smooth_l1(pred, gt_xy, beta=0.01):
"""pred: (B, 2*N); gt_xy: (B, N, 2), any order. Matches pred to gt via the
permutation minimizing L2 distance, then returns Smooth L1 on that matching."""
B = pred.shape[0]
n = gt_xy.shape[1]
pred_xy = pred.view(B, n, 2)
if n == 1:
return F.smooth_l1_loss(pred_xy, gt_xy, beta=beta)
with torch.no_grad():
best_perm, best_dist = None, None
for perm in itertools.permutations(range(n)):
d = ((pred_xy - gt_xy[:, perm, :]) ** 2).sum(dim=(1, 2))
if best_dist is None:
best_dist, best_perm = d, torch.tensor(perm, device=pred.device).expand(B, n).clone()
else:
better = d < best_dist
best_dist = torch.where(better, d, best_dist)
best_perm[better] = torch.tensor(perm, device=pred.device)
gt_matched = torch.gather(gt_xy, 1, best_perm.unsqueeze(-1).expand(-1, -1, 2))
return F.smooth_l1_loss(pred_xy, gt_matched, beta=beta)
def get_labels_xy(label, n_photons):
"""Normalize the three dataset classes' differing label layouts to (B, n_photons, 2)."""
if n_photons == 1:
return label[:, :2].unsqueeze(1)
if label.dim() == 3: ### triplePhotonDataset: (B, n_photons, 4)
return label[:, :, :2]
return label.view(label.shape[0], n_photons, -1)[:, :, :2] ### doublePhotonDataset: (B, n_photons*4)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--photons', type=int, choices=[1, 2, 3], required=True)
parser.add_argument('--epochs', type=int, default=20)
parser.add_argument('--batch-size', type=int, default=512)
parser.add_argument('--lr', type=float, default=5e-4)
args = parser.parse_args()
info = DATASET_FOR[args.photons]
dataset = info['cls'](info['files'](), sampleRatio=1.0, datasetName=f'{args.photons}ph-train-demo')
n_val = max(1, int(0.1 * len(dataset)))
train_set, val_set = torch.utils.data.random_split(
dataset, [len(dataset) - n_val, n_val], generator=torch.Generator().manual_seed(0))
train_loader = torch.utils.data.DataLoader(train_set, batch_size=args.batch_size, shuffle=True)
val_loader = torch.utils.data.DataLoader(val_set, batch_size=args.batch_size, shuffle=False)
model = new_model(args.photons, DEVICE)
optimizer = torch.optim.Adam(model.parameters(), lr=args.lr)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, factor=0.7, patience=5)
out_dir = HERE / 'models/retrained'
out_dir.mkdir(parents=True, exist_ok=True)
print(f'[04] Training {args.photons}-photon model from scratch on {len(train_set)} events '
f'({n_val} held out for validation), {args.epochs} epochs, device={DEVICE}')
for epoch in range(1, args.epochs + 1):
model.train()
train_loss = 0.0
for sample, label in train_loader:
sample, label = sample.to(DEVICE), label.to(DEVICE)
gt_xy = get_labels_xy(label, args.photons)
optimizer.zero_grad()
pred = model(sample)
loss = set_matched_smooth_l1(pred, gt_xy)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
train_loss += loss.item() * sample.size(0)
train_loss /= len(train_set)
model.eval()
val_sq_err, n_val_pts = 0.0, 0
with torch.no_grad():
for sample, label in val_loader:
sample, label = sample.to(DEVICE), label.to(DEVICE)
gt_xy = get_labels_xy(label, args.photons)
pred_xy = model(sample).view(sample.size(0), args.photons, 2)
if args.photons == 1:
d = ((pred_xy - gt_xy) ** 2).sum(dim=(1, 2))
else:
best = None
for perm in itertools.permutations(range(args.photons)):
dd = ((pred_xy - gt_xy[:, perm, :]) ** 2).sum(dim=(1, 2))
best = dd if best is None else torch.minimum(best, dd)
d = best
val_sq_err += d.sum().item()
n_val_pts += sample.size(0) * args.photons
val_rms = (val_sq_err / n_val_pts) ** 0.5
scheduler.step(val_rms)
print(f' epoch {epoch:3d}/{args.epochs} train_loss={train_loss:.5f} val_RMS={val_rms:.4f} px')
out_file = out_dir / f'{args.photons}photon_12keV_demo_retrained.pth'
torch.save(model.state_dict(), out_file)
print(f'[04] Saved retrained model -> {out_file}')
print('[04] Compare val_RMS above to 03_eval_mc_truth.py\'s pretrained-checkpoint numbers -- '
'this demo model is not expected to match production accuracy.')
if args.photons > 1:
print(f'[04] Note: val_RMS for {args.photons}-photon pile-up is expected to look noisy for '
f'many epochs -- production (Train_{args.photons}Photon.py) runs up to 1000 epochs '
f'on far more data for exactly this reason.')
if __name__ == '__main__':
main()