trackfit-llm-large
A 174M-parameter Llama-style transformer, trained from scratch, that reconstructs straight-line particle tracks in a 12-layer strip detector. It reads the list of hit strips in one detector view and writes a short "program" that regenerates the event: the slope and intercept of each track (up to 3 tracks), which input hits are noise, and which hits the tracks should have produced but are missing.
input : [21,54,86,119,151,184,217,249,282,314,347,380]
output: gen_evt(slp=[0.6], icpt=[21.01], add=[], rem=[])
Detector and encoding
- 12 layers (0–11), 32 strips per layer (0–31), one view (x or y) at a time.
- Each hit is one integer:
hit = layer * 32 + strip, so hits are in[0, 383]. Decode withstrip = hit % 32,layer = hit // 32. - A track is the line
strip = slp * layer + icpt(slope in strips per layer, intercept = strip at layer 0). It produces a hit atround(slp * layer + icpt)in every layer where that value is inside 0–31.
The output call gen_evt(slp=[...], icpt=[...], add=[...], rem=[...]) means:
| field | meaning |
|---|---|
slp, icpt | one slope and one intercept per track (1, 2 or 3 tracks) |
add | input hits that are noise (not on any track) |
rem | hits the tracks cross that are missing from the input (detector inefficiency) |
Regenerating the hits from the call (tracks − rem + add) and comparing with the input gives two scores:
similarity S = |input ∩ regenerated| / |input| and dissimilarity D = |regenerated − input| / |regenerated|.
S = 1, D = 0 means the call reproduces the event exactly.
Slopes and intercepts are single vocabulary tokens taken from a fixed grid (allowed.csv, 41,497 (slope, intercept) pairs,
|slope| ≤ 8.17 strips/layer), so the model can only output values on that grid.
Files
| file | content |
|---|---|
model.safetensors, config.json, generation_config.json | the model (LlamaForCausalLM: hidden 1024, 12 layers, 16 heads) |
vocab.json, tokenizer_config.json | the word-level tokenizer (9,772 tokens) |
trackfit.py | tokenizer class TrackCallTokenizer, batched predict, parse_call, gen_evt, score, decode |
examples.jsonl | sample inputs: synthetic 1-, 2- and 3-track events (with the true call) and one real cosmic-muon event (x and y views) |
allowed.csv | the (slope, intercept) grid the model was trained on |
Quick start
pip install torch "transformers>=4.45" huggingface_hub
import json, sys, torch
from huggingface_hub import snapshot_download
from transformers import LlamaForCausalLM
path = snapshot_download("deepaksamuel-cuk/trackfit-llm-large")
sys.path.insert(0, path) # makes trackfit.py importable
from trackfit import TrackCallTokenizer, predict, score, parse_call
device = "cuda" if torch.cuda.is_available() else "cpu"
tokenizer = TrackCallTokenizer.from_pretrained(path)
model = LlamaForCausalLM.from_pretrained(path, dtype=torch.bfloat16 if device == "cuda" else torch.float32).to(device).eval()
hits = [21, 54, 86, 119, 151, 184, 217, 249, 282, 314, 347, 380] # one event, one view
call = predict(model, tokenizer, [hits])[0]
print(call) # gen_evt (slp=[0.6], icpt=[21.01], add=[], rem=[])
print(parse_call(call)) # {'slp': [0.6], 'icpt': [21.01], 'add': [], 'rem': []}
print(score(hits, call)) # (1.0, 0.0) -> S, D
(With transformers < 5, pass torch_dtype= instead of dtype=.) The model runs on CPU as well; a GPU is only needed
for large batches.
Run the examples
examples = [json.loads(line) for line in open(f"{path}/examples.jsonl")]
calls = predict(model, tokenizer, [e["input_hits"] for e in examples]) # batched greedy decoding
for e, call in zip(examples, calls):
S, D = score(e["input_hits"], call)
print(f"{e['name']} ({e['description']})")
print(f" input : {e['input_hits']}")
print(f" model : {call} S={S:.3f} D={D:.3f}")
if "target_call" in e:
print(f" truth : {e['target_call']}")
Expected output (identical on GPU in bfloat16 and on CPU in float32):
| example | model output | S | D |
|---|---|---|---|
synthetic_test_2 (1 track, clean) | gen_evt (slp=[0.6], icpt=[21.01], add=[], rem=[]) | 1 | 0 |
synthetic_test_1 (1 track, noisy) | gen_evt (slp=[1.8], icpt=[6.68], add=[73, 278, 310, 344], rem=[7, 40, 108, 142, 176, 209, 378]) | 1 | 0 |
synthetic_test_0 (2 tracks, noisy) | gen_evt (slp=[-0.3, 0.22], icpt=[9.64, 22.57], add=[56, 138, 233, 294, 314, 315], rem=[23, 55, 73, 87, 168, 200, 248, 280, 327, 358]) | 1 | 0 |
synthetic_test_6 (3 tracks, noisy) | gen_evt (slp=[0.74, -1.25, -5.74], icpt=[4.13, 24.5, 65.76], add=[23, 27, 54, 87, 118, 222, 239, 277, 298, 300, 365], rem=[55, 70, 102, 117, 135, 201, 266, 270, 299, 301, 363, 364]) | 1 | 0 |
real_..._event1_x (real muon, x view) | gen_evt (slp=[1.43], icpt=[-2.98], add=[100, 123, 197, 230, 232], rem=[64, 231, 264, 331, 365]) | 1 | 0 |
real_..._event1_y (real muon, y view) | gen_evt (slp=[2.1], icpt=[1.44], add=[29, 114, 120, 239, 261], rem=[1, 36, 70, 104, 308, 342, 377]) | 1 | 0 |
On noisy events the predicted slope/intercept can differ slightly from the true values (for example icpt=9.64 instead
of 9.89) while still reproducing every hit exactly (S = 1, D = 0): several nearby lines on the grid give the same
strips in every layer, and the add/rem lists adjust accordingly.
Using your own data
- Split each event into its x-view and y-view hits and encode each as
layer * 32 + strip(0–383). Run the two views separately. - Pass a list of events (each a list of ints) to
predict; it sorts the hits, left-pads the batch and decodes greedily. Batches of a few hundred to a few thousand events work well on a GPU. - Parse the calls with
parse_call. For a single track per view, the slopesm_x,m_y(strips/layer) give the zenith angleθ = arctan((pitch / layer_gap) · sqrt(m_x² + m_y²))and azimuthφ = atan2(m_y, m_x); for the detector used here, pitch = 3.0 cm and layer gap = 16.8 cm.
Plot one event:
import matplotlib.pyplot as plt, numpy as np
from trackfit import decode
p = parse_call(call)
strips, layers = zip(*(decode(h) for h in hits))
plt.scatter(strips, layers, color="k", label="hits")
lay = np.array([-0.5, 11.5])
for s, i in zip(p["slp"], p["icpt"]):
plt.plot(s * lay + i, lay, label=f"slp={s}, icpt={i}")
plt.xlim(-0.5, 31.5); plt.ylim(-0.5, 11.5); plt.xlabel("strip"); plt.ylabel("layer"); plt.legend(); plt.show()
Training
- Architecture:
LlamaForCausalLM, hidden size 1024, 12 layers, 16 attention heads, intermediate size 2816, 174,179,328 parameters, trained from scratch. - Data: synthetic events generated on the fly (120,000 new events per epoch), one third each 1-, 2- and 3-track, with tracks
drawn uniformly from
allowed.csv. Per track: noise class clean : add : rem : add+rem = 1 : 1 : 1 : 8; 1–5 noise hits and up to 7 missing hits per track, at least 5 hits kept on every track. Maximum per event: 15 noise hits and 21 missing hits (3 tracks). - Objective: next-token prediction on the call, weighted cross-entropy, warmup–stable–decay learning-rate schedule.
- Checkpoint: epoch 320, selected by the best mean similarity on 10,000 real cosmic-muon events (x and y views).
Results
Synthetic test set (20,000 events, never seen in training):
| events | perfect (S=1, D=0) | mean S | mean D | correct number of tracks |
|---|---|---|---|---|
| all | 92.83% | 0.9971 | 0.0028 | 98.68% |
| 1 track | 97.12% | 0.9967 | 0.0035 | 99.88% |
| 2 tracks | 95.74% | 0.9984 | 0.0016 | 98.34% |
| 3 tracks | 85.61% | 0.9962 | 0.0034 | 97.83% |
Real cosmic-muon data (703,694 events from one run, x and y views):
| view | perfect (S=1, D=0) | mean S | mean D |
|---|---|---|---|
| x | 95.70% | 0.9961 | 0.0055 |
| y | 94.58% | 0.9933 | 0.0066 |
The zenith angle from the model's x/y slopes agrees with an independent RANSAC fit to a median of 0.51°.
Result files
Per-event results behind the numbers above are in results/. Load them with pandas.read_csv; hit lists are stored
as JSON-style strings.
| file | rows | content |
|---|---|---|
results/cosmic_INORUN_20160718_211418_predictions.csv | 703,694 | every event of the real cosmic-muon file, x and y views: model, OLS and RANSAC (469 MB) |
results/test_mixedtrack_extended_results.csv | 20,000 | the synthetic test set (test-mixedtrack-extended in trkfit-llm-datasets): model, OLS and RANSAC, with the truth |
Cosmic file columns (_x / _y = detector view):
filename,eventid,x_hits,y_hits,n_hits_*,n_layers_*: the event and its hits.predicted_target_*,parsed_*,similarity_*,dissimilarity_*,perfect_*,pred_mult_*(number of predicted tracks),model_slopes_*,model_icpts_*(all tracks),model_slope_*,model_icpt_*(first track),n_add_pred_*,n_rem_pred_*,notrack_*(a predicted track has no hit inside the detector): the model's call and its scores.ols_slope_*,ols_icpt_*: ordinary least squares over every hit.ransac_slope_*,ransac_icpt_*,ransac_n_inliers_*: RANSAC (residual threshold 1 strip, 100 trials).zenith_deg_model/ols/ransac,azimuth_deg_model/ols/ransac: angles from each method's x and y slopes (the model's first track in each view).
Test file columns:
event,n_tracks,n_hits,input_hits,target_call,true_slopes,true_icpts: the event and its truth.predicted_target,parsed,similarity,dissimilarity,perfect,pred_mult,mult_ok(correct number of tracks),exact(all slopes and intercepts equal the truth),model_slopes,model_icpts,model_slope,model_icpt,n_add_pred,n_rem_pred,notrack: the model's call and its scores.ols_slope,ols_icpt,ols_slope_err,ols_icpt_err,ols_rms: ordinary least squares over every hit.ransac_slope,ransac_icpt,ransac_n_inliers: RANSAC with the settings above.
OLS and RANSAC fit a single straight line to all hits of an event. On 2- and 3-track test events they therefore describe
at most one of the tracks; compare them with the model on the n_tracks == 1 rows.
Limitations
- Slope limit. Slopes are capped at |slope| = 8.17 strips/layer (θ ≈ 55.6° from one view). Real tracks steeper than this are clipped to the limit, and on some short, sparse events the model falls back to a near-limit slope through one or two hits and labels the rest as noise. Both pile up at θ ≈ 52–58°. Such events have low S / high D in that view and can be flagged.
- Split single muons. On real data a muon that fires two neighbouring strips per layer is sometimes predicted as two nearly parallel tracks. About 3–5% of real events get a 2-track prediction in one view, but only a few tens show two clearly separated tracks in both views.
- Grid values. Slopes and intercepts are restricted to the
allowed.csvgrid; the model is a pattern-recognition step, not a precision fit. A least-squares refit on the hits the model assigns to each track gives continuous parameters. - Geometry. Trained for a 12-layer, 32-strip view only; other geometries need retraining.