Update TrkPID to use track-based observables - #9
Conversation
oksuzian
left a comment
There was a problem hiding this comment.
PR Review Summary — "Update TrkPID to use track-based observables" (#9)
Reviewed at head 3935ad52 (2026-08-04). First review of this PR; no prior reviews or comments to carry forward. Scope: retrain TrkPID on MDC2025 flat e−/μ− samples with track-based observables (E/P, p(χ²), hit-time-vs-track-time slope), plus a new calo-free TrkOnlyPID package, ntuple-skimming tools that add the fitted dt/dz and dt/dt branches, and a slope-method comparison script. Note: MLTrain is outside the Offline C++ coding-standards scope; findings below are about workflow correctness, not style conformance.
Decision
- 🔴 Request changes — two S1s, each a one/two-line fix: the new
TrkOnlyPIDtrainer still uses themixed_float16policy that commite202463identified as incompatible with TMVA SOFIE and fixed inTrkPIDonly, and the version-tagged output renaming leftplot_historyreading a filename that is no longer written, so the defaultTrkPIDrun now ends inFileNotFoundError.
Findings
-
🟠 [S1]
TrkOnlyPIDkeeps themixed_float16policy your own SOFIE fix removed fromTrkPID- Evidence:
TrkOnlyPID/TrkOnlyPIDTrain.py:116setsset_global_policy('mixed_float16'). Commite202463("Update to float32 for TMVA SOFIE") changed exactly this line to'float32'— but only inTrkPID/TrackPIDTrain.py:126.TrkOnlyPIDwas added before that fix (1665f05) and never picked it up. - Impact: the trained/saved
TrkOnlyPIDmodel reproduces the configuration you already found breaks the ONNX→SOFIE conversion, so it will fail at export time once the ONNX tooling is available (the PR body notes export is deferred), likely after the training work is considered done. - Suggested fix:
'mixed_float16'→'float32'inTrkOnlyPIDTrain.py, matchinge202463.
- Evidence:
-
🟠 [S1] Default
TrkPIDrun crashes at the end:plot_historyreads a filename the versioned save no longer writes- Evidence:
train_modelnow savesPID_model_v{version}.kerasandtrain_history_v{version}.json(TrkPID/TrackPIDTrain.py:149-151, renamed ine7dc3a8), but the plotting block still callsplot_history("train_history.json", ...)(TrkPID/TrackPIDTrain.py:439). On main, save and read were both unversioned, so this is a regression introduced by the rename.*.jsonis gitignored, so in a fresh area the unversioned file never exists. - Impact: a full default invocation (import → train → export → plots) dies with
FileNotFoundErrorafter the training time has been spent;plot_historyandplot_modeloutput is lost. - Suggested fix:
plot_history(f"train_history_v{version}.json", ...).
- Evidence:
-
🟡 [S2]
TrkOnlyPIDsave/load names are asymmetric, so--skip-traincan never load what a training run wrote- Evidence:
3935ad5version-tagged the load path (TrkOnlyPID_model_v{version}.keras,train_history_v{version}.json,TrkOnlyPIDTrain.py:374-375) and the ONNX output, but the save path still writes unversionedTrkOnlyPID_model.keras/train_history.json(TrkOnlyPIDTrain.py:139-141);plot_historyat line 408 also reads the unversioned name. - Impact: train-then-
--skip-trainround trip fails unless files are renamed by hand; same class of drift as finding 2, mirrored. - Suggested fix: version-tag the save path (and the
plot_historyargument) to match the load path, asTrkPIDdoes.
- Evidence:
-
🟡 [S2] The new background-efficiency bisection in
make_resultshas no termination guarantee- Evidence:
TrkPID/TrackPIDTrain.py:169-174andTrkOnlyPID/TrkOnlyPIDTrain.py:159-164(new in this PR) loopwhile abs(eff - bkg_eff)/bkg_eff > tolerancewith no iteration cap.effis a step function of the threshold (quantized in units of 1/nbkg, with plateaus wherever predictions tie). - Impact: potential risk, not observed — if no achievable efficiency lands within 1% of the target (small test set, or tied/saturated sigmoid outputs, e.g. a degenerate training), the interval collapses onto a plateau and the script hangs forever.
- Suggested fix: replace the loop with
threshold = np.quantile(dataset.loc[dataset['label']==0, 'prediction'], 1 - bkg_eff)— exact, one line, and removes the failure mode; otherwise add an iteration cap.
- Evidence:
-
🟡 [S2]
perform_tz_fithas no guard on degenerate hit lists- Evidence: both
make_inputs.pyfiles fit every track unconditionally; with ≤2 hitscurve_fitraises, and with exactly 3 points minus 2 parameters the code is fine butdof = len(z) - 2reaches 0 at 2 hits →ZeroDivisionError;curve_fitcan also raiseRuntimeErroron non-convergence. - Impact: potential risk — one pathological track aborts the whole multi-file skim, losing the run. Tracks in these ntuples normally have ≫3 hits, so this is defensive, but the skim is the expensive step.
- Suggested fix: guard
len(hits) < 3(and wrap the fit in try/except), pushing a sentinel (e.g. NaN) for that track so downstream selection can drop it.
- Evidence: both
-
🟡 [S2] Duplication across
TrkPID/TrkOnlyPIDis already biting (simplification lens — does not gate approval)- Evidence: the two
make_inputs.pydiffer by one blank line and one comment; the two pairs of 150-line.fileslists are byte-identical;TrkOnlyPIDTrain.pyis ~85% identical toTrackPIDTrain.py(import_evtntuple,apply_cut,make_results, all plotting). Finding 1 is the concrete cost: a fix applied to one copy silently missed the other. - Suggested fix: not asking for a refactor in this PR — but consider one shared
make_inputs.py(the branch additions are identical) and one copy of the dataset file lists, with the trainers importing shared helpers as a follow-up.
- Evidence: the two
-
🟡 [S2]
TrkOnlyPID/README.mddocuments a feature the model no longer uses- Evidence: README lists "tracker hit dt/dz slope divided by the expected slope from the track fit assuming an electron mass" as a v0 input, but v0 features are
['nActiveFrac', 'nNullFrac', 'fitcon', 'dtdt_slope'](TrkOnlyPIDTrain.py:346) — the dt/dz ratio was replaced by the dt/dt slope in3935ad5, and the TrkPID README was updated while this one wasn't. The README also names the output "TrkOnlyPID.onnx" vs the actualTrkOnlyPID_v{version}.onnx. (The PR description likewise still says "dt/dz over the expected slope".) - Suggested fix: update the v0 feature list and output name; one line in the PR body noting the dt/dz→dt/dt switch would help future archaeology.
- Evidence: README lists "tracker hit dt/dz slope divided by the expected slope from the track fit assuming an electron mass" as a v0 input, but v0 features are
-
⚪ [S3]
ROOT.TMath.GausIdoes not exist- Evidence:
TrkPID/compare_hit_slopes.py:36callsTMath.GausIin the equal-sigma branch ofanalytic_gaussian_overlap. TMath has noGausIin any ROOT version (checked v5.34 and v6.16 headers on cvmfs and current master docs); the normal CDF isTMath::Freq. - Impact: nearly dead code — the branch runs only when the two fitted sigmas agree to
math.isclosedefault 1e-9 — but it wouldAttributeErrorif it ever fired. - Suggested fix:
return 2 * ROOT.TMath.Freq(z).
- Evidence:
-
⚪ [S3] Housekeeping batch (no action required for approval)
- Inherited "cosmic muon" wording is now wrong:
apply_cutdocstrings and the confusion-matrix/rate printouts in both trainers label the background "cosmic muons", but the samples are flat μ− from target stops. - Dead/unused:
time_mod(mod 1695) is computed and never read;branches_mcimportstrkmc/trksegsmcbut onlytrkmcsimis used (extra I/O);nMatActiveFracis derived and unused in both feature sets;pconv→pcov. - Typos: "Unknown training verion" (both trainers), "unnessary" (both READMEs).
- Pre-existing, noting only since the lines are nearby:
plot_ROCprints "Accuracy at this threshold" but the value is the TPR; the TrkPID README link[TrackPIDTrain.py](TrkPIDTrain.py)targets a nonexistent filename.
- Inherited "cosmic muon" wording is now wrong:
Questions (non-gating)
- The t–z / t–t fits in
make_inputs.pyuse everytrkhitsentry unconditionally;TrkStrawHitInfo.stateencodes activity. Is including fit-deactivated hits intended (they may carry discrimination for the wrong-mass hypothesis) or should the fit filter on state? tf.keras.layers.Input(..., batch_size=32)bakes a fixed batch of 32 into the ONNX signature (pre-existing on main, copied intoTrkOnlyPID). Does the SOFIE-generated inference in Offline evaluate with batch 32, or does per-track batch-1 inference needbatch_size=None/1 at export? Worth settling before the first real export since that step is being redone anyway.
Verified 🟢 (checked, no action needed)
- 🟢
make_inputs.pyfield usage verified againstEventNtuple/inc/TrkStrawHitInfo.hh:earlyendis anint(valid array index),etimeisTDCTimes, andtottdrift,ptoca,udt,pocaall exist with the semantics used; the dt/dt construction (fit of hit time vs track TOCA; slope ≈ 1 under the correct mass hypothesis) is coherent. - 🟢 The
SetBranchStatussequence is correct: heavy branches dropped,CloneTree(0)excludes them from the output,trkhitsre-enabled after cloning for reading only — the skimmed tree carries the six new fit branches but nottrkhits. - 🟢
TrkPIDkeeps thetrkcalohit.activerequirement;TrkOnlyPIDcorrectly drops it (tracker-only). - 🟢 MC-truth selection (
trkmcsim[...,0].pdg== 11/13, highest-rank match) matches the FlateMinus/FlatMuMinus samples; the electron-hypothesisdtdz_exp(m = 0.511 MeV) matches the documented intent. - 🟢 TrkPID README v1 feature list matches the implemented
version == 1features. - 🟢
.gitignoreadditions appropriately keep models, CSVs, ROOT files, and figures out of the repo.
Validation check
- Build/tests run: none — MLTrain has no CI, and this review did not execute the scripts (no rootana env in session). Verification is static: three-dot diff against merge-base
2e5b8c3, per-commit archaeology, and field-level checks against EventNtuple headers and ROOT (cvmfs + docs). - Config contract check: n/a (no FHiCL).
- Cross-repo consistency: the PR body's plan to move the dt/dz-type observable into KalSeed/TrkInfo (EventNtuple) is the right long-term home; nothing in Offline/EventNtuple is required for this PR to merge.
Residual risk
- The exported-model path (ONNX → SOFIE → Offline) is untested by construction (tooling unavailable); findings 1 and the batch-size question both live there and will surface at export time.
Author follow-ups
'mixed_float16'→'float32'inTrkOnlyPIDTrain.py(finding 1).- Version-tag
plot_history's filename inTrackPIDTrain.py:439and theTrkOnlyPIDsave path (findings 2–3). - Consider the
np.quantilereplacement for the threshold bisection and a hit-count guard inperform_tz_fit(findings 4–5). - Refresh
TrkOnlyPID/README.mdfor the dt/dt feature (finding 7).
|
Responding to the AI comments:
|
oksuzian
left a comment
There was a problem hiding this comment.
PR Review Summary — re-review of "Update TrkPID to use track-based observables" (#9)
Reviewed at head 4c7926c4 (2026-08-04). Delta since the previously reviewed 3935ad52: one commit ("Address comments") responding point-by-point to the request-changes review. Every fix was verified in the diff, not the reply; the one behavioral question (fixed batch size vs Keras enforcement) was settled empirically in the rootana/current environment.
Decision
- 🟢 Approve. Both S1s and all actionable S2s from the request-changes review are fixed and verified; the remaining items are ⚪ S3 polish and one non-gating physics question. This approval supersedes my earlier request-changes.
Carry-forward accounting (vs the review posted at 3935ad52)
1. 🟢 [was S1] TrkOnlyPID mixed_float16 policy — FIXED in 4c7926c4, verified.
TrkOnlyPIDTrain.py now sets set_global_policy('float32'), matching the e202463 fix in TrkPID. The SOFIE-incompatible configuration is gone from both trainers.
2. 🟢 [was S1] plot_history filename regression — FIXED, verified.
Both trainers now call plot_history(f"train_history_v{version}.json", ...), matching what train_model writes. The default import→train→plot path no longer ends in FileNotFoundError.
3. 🟢 [was S2] TrkOnlyPID save/load asymmetry — FIXED, verified.
The save path is now version-tagged (TrkOnlyPID_model_v{version}.keras, train_history_v{version}.json), symmetric with the --skip-train load path.
4. 🟢 [was S2] Threshold bisection non-termination — FIXED, verified.
The loop is replaced with the exact np.quantile(dataset.loc[dataset['label']==0, 'prediction'], 1 - bkg_eff) one-liner in both trainers, plus a threshold printout. The hang mode is gone by construction.
5. 🟢 [was S2] perform_tz_fit degenerate-track guard — FIXED, verified.
if len(hits) < 3: return 0., 0., 0. in both make_inputs.py. A zero slope is far from the physical value (~1 for the dt/dt observable), so such tracks land as obvious outliers rather than crashing the skim. The author notes <3-hit tracks shouldn't exist; agreed — this is defensive, and sufficient.
6. ⚪ [was S2] Duplication across TrkPID/TrkOnlyPID — WITHDRAWN as an action item.
Author declines with rationale: the two MVAs are expected to diverge in the near future. That is a judgment call the author is entitled to, and the finding never gated. The observation stands as context (the mixed_float16 miss was its concrete cost) — no further action requested.
7. 🟢 [was S2] TrkOnlyPID/README.md stale feature list — FIXED, verified (minor residue below).
The v0 feature list now reads "straw hit time vs track time at the hit slope" and matches the implemented features in order. Residue: both READMEs still name the unversioned ONNX outputs ("TrkOnlyPID.onnx" / "TrackPID.onnx") while the code writes *_v{version}.onnx — now ⚪ S3.
8. 🟢 [was S3] ROOT.TMath.GausI — FIXED, verified. Now 2 * ROOT.TMath.Freq(z), as prescribed.
9. 🟡 [was S3] Housekeeping batch — PARTIAL, remaining bits are take-or-leave.
Fixed: the misleading "cosmic muon" confusion-matrix print is deleted outright, the rate printouts are relabeled — and the new FPR/FNR lines are actually more correct than the old ones (the old wording had FPR/FNR semantics crossed; the new FPR = FP/total_bkg, FNR = FN/total_sig is standard); "verion" and "unnessary" typos fixed; the pre-existing broken README link TrkPIDTrain.py → TrackPIDTrain.py fixed too. Still open (all ⚪, no action needed for approval): unused time_mod computation, trkmc/trksegsmc imported but unread (extra I/O), unused nMatActiveFrac, pconv → pcov, and the pre-existing "Accuracy at this threshold" print that reports the TPR.
New in the delta (3935ad52..4c7926c4)
1. 🟢 Fixed batch_size=1 verified safe end-to-end.
The Input-layer batch switch 32 → 1 (both trainers) raised the question of whether Keras enforces the static batch against fit/predict, which use their default batch of 32. Tested empirically in the Mu2e rootana/current environment (TF 2.20.0): a model declared with batch_size=1 trains and predicts cleanly at batch 32. So training is unaffected, and the ONNX signature now carries batch 1 — the right shape for per-track SOFIE inference in Offline. This also settles the second question from the previous review.
2. ⚪ [S3] The relabeled rate printouts drop the class names entirely.
"True Positive Rate: ...%" no longer says which class is positive. One header line ("positive = electron") would keep the log self-describing. Cosmetic.
Open question (non-gating, carried)
- The t–z / t–t fits in
make_inputs.pystill use everytrkhitsentry unconditionally;TrkStrawHitInfo.stateencodes activity. Is including fit-deactivated hits intended (they may carry discrimination for the wrong-mass hypothesis) or should the fit filter on state? Worth a one-line answer for the record; either answer is fine for this PR.
Validation check
- Build/tests run: none — MLTrain has no CI. Verification: full diff
3935ad52..4c7926c4read line-by-line against the prior findings; the batch-size behavior tested by running TF inrootana/current. - Config contract check: n/a.
- Cross-repo consistency: unchanged — the planned KalSeed/TrkInfo (EventNtuple) home for the dt observable remains future work; nothing required for this merge.
Residual risk
- Low. The ONNX→SOFIE export step remains untested by construction (tooling unavailable), but the two known landmines on that path (
mixed_float16, fixed batch 32) are both cleared.
Author follow-ups (non-blocking)
- README ONNX names →
*_v{version}.onnx(finding 7 residue). - Optional S3 sweep:
time_mod, unused MC branch imports,nMatActiveFrac,pconv→pcov, positive-class label in the rate printouts. - A one-line answer on the hit-
statequestion for the record.
This update includes:
To add the tracker hit dt/dz, I'm adding a branch to the EventNtuple tree for each track, as it's not yet in EventNtuple. I think this observable should ideally be added to the KalSeed data product and then naturally added to the TrkInfo struct in EventNtuple.
As the ONNX features aren't yet available in
pyenv rootanathe model has not been exported to ONNX or added to Offline processing yet.Input features and output predictions:
