157 """Evaluate the notebook's actual selection from an Athena ROOT output.
159 The function deliberately uses the local links exactly as the supplied
160 notebook does. It does *not* use the older MsTrkSeed / ActsMuons seed-link
161 matching method in muonEdgeTuner.py.
166 except ImportError
as error:
168 "PyROOT is unavailable. Run muonBFTuner.py from a configured Athena "
169 "environment so that `import ROOT` works."
172 input_file = ROOT.TFile.Open(str(root_file),
"READ")
173 if not input_file
or input_file.IsZombie():
174 raise RuntimeError(f
"Could not open ROOT output: {root_file}")
177 tree = input_file.Get(tree_name)
178 if not tree
or not tree.InheritsFrom(
"TTree"):
179 raise RuntimeError(f
"Could not find TTree '{tree_name}' in {root_file}.")
181 missing = [name
for name
in REQUIRED_BRANCHES
if not tree.GetBranch(name)]
184 f
"Missing required branch(es) in {root_file}: {', '.join(missing)}"
188 matched_truth_muons = 0
190 for entry_number
in range(int(tree.GetEntries())):
191 tree.GetEntry(entry_number)
193 truth_eta = tree.TruthMuons_eta
194 truth_segment_links = tree.TruthMuons_truthSegLinks
195 truth_to_acts_link = tree.TruthMuons_ActsMuonLink
196 acts_muon_pt = tree.ActsMuons_pt
201 if n_truth != n_segments
or n_truth != n_links:
203 f
"Truth-muon branch-size mismatch in entry {entry_number} of "
204 f
"{root_file}: eta={n_truth}, truthSegLinks={n_segments}, "
205 f
"ActsMuonLink={n_links}"
209 for truth_index
in range(n_truth):
215 or abs(float(truth_eta[truth_index])) >= 2.5
220 acts_link = int(truth_to_acts_link[truth_index])
221 if 0 <= acts_link < n_acts_muons:
222 matched_truth_muons += 1
228 f
"No denominator truth muons found in {root_file}. Required selection: "
229 "len(TruthMuons_truthSegLinks) > 0 and abs(TruthMuons_eta) < 2.5."
233 "truthMuonCount": truth_muons,
234 "matchedTruthMuonCount": matched_truth_muons,
235 "trackEfficiency": matched_truth_muons / truth_muons,
354 parser = argparse.ArgumentParser(
356 "Tune muonBucketRecoChain.py --score-threshold using relative "
357 "track efficiency against a --skip-onnx no-ML baseline per stage."
359 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
364 help=
"Input HITS file/list forwarded to muonBucketRecoChain.py.",
369 help=
"Explicit path to muonBucketRecoChain.py. Defaults to a sibling file.",
373 default=
"MuonInference.muonBucketRecoChain",
374 help=
"Module fallback when no local reco-chain source file is present.",
378 default=
"bucket_filter_threshold_tuning",
379 help=
"Directory where roots/, logs/, CSV and JSON outputs are written.",
381 parser.add_argument(
"--treeName", default=TREE_DEFAULT)
382 parser.add_argument(
"--threads", type=int, default=1)
383 parser.add_argument(
"--skipEvents", type=int, default=0)
384 parser.add_argument(
"--defaultGeoFile", default=
"RUN4")
385 parser.add_argument(
"--noMonitorPlots", action=
"store_true", default=
False)
386 parser.add_argument(
"--use-cpu", dest=
"use_cpu", action=
"store_true", default=
False)
391 help=
"Reuse an existing ROOT output when its exact point is requested again.",
395 "--bucket-model-path",
397 dest=
"bucketModelPath",
399 help=
"Optional bucket-filter ONNX model; otherwise use the chain default.",
405 help=
"Optional ONNX output tensor name.",
408 "--single-output-mode",
409 choices=(
"logit",
"prob"),
410 dest=
"singleOutputMode",
412 help=
"Optional scalar ONNX output interpretation.",
416 "--minRelativeEfficiency",
417 "--targetRelativeEfficiency",
418 "--targetEfficiency",
419 dest=
"minRelativeEfficiency",
423 "A bucket-filter point passes when "
424 "bucketTrackEfficiency / noMlTrackEfficiency is at least this value."
427 parser.add_argument(
"--minThreshold", type=float, default=-1.0)
428 parser.add_argument(
"--maxThreshold", type=float, default=1.0)
429 parser.add_argument(
"--coarseEvents", type=int, default=10)
430 parser.add_argument(
"--coarseStep", type=float, default=0.1)
431 parser.add_argument(
"--mediumEvents", type=int, default=100)
432 parser.add_argument(
"--mediumStep", type=float, default=0.025)
433 parser.add_argument(
"--fineEvents", type=int, default=1000)
434 parser.add_argument(
"--fineStep", type=float, default=0.01)
436 return parser.parse_args()
458 work_dir = Path(args.workDir).expanduser().resolve()
459 roots_dir = work_dir /
"roots"
460 logs_dir = work_dir /
"logs"
461 csv_path = work_dir /
"bucket_threshold_scan.csv"
462 summary_path = work_dir /
"summary.json"
465 roots_dir.mkdir(parents=
True, exist_ok=
True)
466 logs_dir.mkdir(parents=
True, exist_ok=
True)
469 ScanStage(
"coarse", args.coarseEvents, args.coarseStep),
470 ScanStage(
"medium", args.mediumEvents, args.mediumStep),
471 ScanStage(
"fine", args.fineEvents, args.fineStep),
473 rows: list[dict[str, Any]] = []
474 baselines: dict[str, dict[str, Any]] = {}
475 best_by_stage: dict[str, dict[str, Any] |
None] = {}
476 stage_notes: dict[str, str] = {}
478 def summary(status: str) -> dict[str, Any]:
480 best_by_stage.get(
"fine")
481 or best_by_stage.get(
"medium")
482 or best_by_stage.get(
"coarse")
486 "inputFile": args.inputFile,
487 "treeName": args.treeName,
488 "workDir": str(work_dir),
491 "minimumRelativeEfficiency": args.minRelativeEfficiency,
493 "bucketTrackEfficiency / noMlTrackEfficiency >= "
494 "minimumRelativeEfficiency"
499 "len(TruthMuons_truthSegLinks[i]) > 0 and "
500 "abs(TruthMuons_eta[i]) < 2.5"
503 "denominator muon with 0 <= TruthMuons_ActsMuonLink[i] < "
506 "implementation":
"PyROOT; equivalent to the notebook's executable code",
509 {
"name": stage.name,
"nEvents": stage.n_events,
"step": stage.step}
512 "baselines": baselines,
513 "bestByStage": best_by_stage,
514 "stageNotes": stage_notes,
515 "recommendedThreshold": (
516 None if recommended
is None else recommended[
"threshold"]
518 "recommendedResult": recommended,
519 "scanCsv": str(csv_path),
522 def persist(status: str) ->
None:
526 def evaluate_baseline(stage: ScanStage) -> dict[str, Any]:
527 """Run/reuse one no-ML reference output for this stage."""
529 if stage.name
in baselines:
530 return baselines[stage.name]
532 tag = f
"noml_{stage.name}_events{stage.n_events:04d}"
533 out_root = roots_dir / f
"{tag}.root"
534 out_log = logs_dir / f
"{tag}.log"
536 row: dict[str, Any] = {
538 "nEvents": stage.n_events,
541 "rootFile": str(out_root),
543 "command":
" ".join(command),
546 if not args.skipExisting
or not out_root.is_file():
547 return_code =
_run(command, out_log)
548 row[
"returnCode"] = return_code
552 "error": f
"no-ML reconstruction returned {return_code}",
557 f
"No-ML baseline failed for stage={stage.name} with rc={return_code}. "
561 row[
"returnCode"] =
"reused"
563 if not out_root.is_file():
565 "status":
"missing_output",
566 "error":
"no-ML ROOT output is missing after reconstruction",
571 f
"No-ML ROOT output is missing for stage={stage.name}: {out_root}"
576 except Exception
as error:
577 row.update({
"status":
"evaluation_failed",
"error": str(error)})
584 "noMlTruthMuonCount": metrics[
"truthMuonCount"],
585 "noMlMatchedTruthMuonCount": metrics[
"matchedTruthMuonCount"],
586 "noMlTrackEfficiency": metrics[
"trackEfficiency"],
590 baselines[stage.name] = row
593 f
"[{stage.name:6s} | {stage.n_events:4d} events] "
594 f
"no-ML efficiency={metrics['trackEfficiency']:.6f} "
595 f
"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']})",
600 def evaluate_bucket(stage: ScanStage, threshold: float) -> dict[str, Any]:
601 """Run/reuse and compare one bucket-filter threshold point."""
603 baseline = evaluate_baseline(stage)
606 f
"{stage.name}_events{stage.n_events:04d}_"
607 f
"threshold{_format_threshold(threshold)}"
609 out_root = roots_dir / f
"{tag}.root"
610 out_log = logs_dir / f
"{tag}.log"
614 n_events=stage.n_events,
617 row: dict[str, Any] = {
619 "nEvents": stage.n_events,
621 "threshold": threshold,
622 "minRelativeEfficiency": args.minRelativeEfficiency,
623 "rootFile": str(out_root),
625 "command":
" ".join(command),
628 if not args.skipExisting
or not out_root.is_file():
629 return_code =
_run(command, out_log)
630 row[
"returnCode"] = return_code
634 "error": f
"bucket reconstruction returned {return_code}",
639 f
"Reconstruction failed at stage={stage.name}, threshold={threshold} "
640 f
"with rc={return_code}. See {out_log}"
643 row[
"returnCode"] =
"reused"
645 if not out_root.is_file():
647 "status":
"missing_output",
648 "error":
"bucket ROOT output is missing after reconstruction",
653 f
"ROOT output is missing for stage={stage.name}, threshold={threshold}: "
659 except Exception
as error:
660 row.update({
"status":
"evaluation_failed",
"error": str(error)})
665 noml_efficiency = float(baseline[
"trackEfficiency"])
666 if noml_efficiency <= 0.0:
668 "status":
"evaluation_failed",
669 "error":
"no-ML baseline has zero track efficiency",
674 f
"No-ML efficiency is zero in stage={stage.name}; cannot calculate "
675 "relative efficiency."
678 relative_efficiency = float(metrics[
"trackEfficiency"]) / noml_efficiency
681 "noMlTruthMuonCount": baseline[
"truthMuonCount"],
682 "noMlMatchedTruthMuonCount": baseline[
"matchedTruthMuonCount"],
683 "noMlTrackEfficiency": noml_efficiency,
684 "truthCountMatchesNoMl": (
685 int(metrics[
"truthMuonCount"]) == int(baseline[
"truthMuonCount"])
687 "relativeTrackEfficiency": relative_efficiency,
688 "relativeEfficiencyLoss":
max(0.0, 1.0 - relative_efficiency),
689 "passesTarget": relative_efficiency >= args.minRelativeEfficiency,
695 f
"[{stage.name:6s} | {stage.n_events:4d} events] "
696 f
"threshold={threshold: .6f} "
697 f
"bucket={metrics['trackEfficiency']:.6f} "
698 f
"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']}) "
699 f
"relative={relative_efficiency:.6f} "
700 f
"{'PASS' if row['passesTarget'] else 'FAIL'}",
707 start_threshold: float,
708 ) -> tuple[dict[str, Any] |
None, str]:
709 """Find the highest passing point, with first-point recovery downward."""
711 first = evaluate_bucket(stage, start_threshold)
712 if bool(first[
"passesTarget"]):
720 candidate = evaluate_bucket(stage, threshold)
721 if not bool(candidate[
"passesTarget"]):
722 return best,
"stopped_at_first_below_target"
724 return best,
"reached_upper_threshold_bound"
734 candidate = evaluate_bucket(stage, threshold)
735 if bool(candidate[
"passesTarget"]):
736 return candidate,
"recovered_by_lowering_threshold"
738 return None,
"no_passing_threshold_in_range"
741 coarse, medium, fine = stages
746 coarse_best: dict[str, Any] |
None =
None
747 coarse_note =
"reached_upper_threshold_bound"
753 candidate = evaluate_bucket(coarse, threshold)
754 if not bool(candidate[
"passesTarget"]):
755 coarse_note =
"stopped_at_first_below_target"
757 coarse_best = candidate
759 if coarse_best
is None:
760 best_by_stage[coarse.name] =
None
761 stage_notes[coarse.name] =
"no_passing_threshold_in_range"
762 persist(
"no_passing_threshold")
764 "No coarse threshold met the relative-efficiency target. "
765 "Lower --minRelativeEfficiency or extend --minThreshold."
768 best_by_stage[coarse.name] = coarse_best
769 stage_notes[coarse.name] = coarse_note
772 medium_best, medium_note = adaptive_scan(
774 float(coarse_best[
"threshold"]),
776 best_by_stage[medium.name] = medium_best
777 stage_notes[medium.name] = medium_note
778 if medium_best
is None:
779 persist(
"no_passing_threshold")
781 "No medium-stage threshold met the relative-efficiency target."
785 fine_best, fine_note = adaptive_scan(
787 float(medium_best[
"threshold"]),
789 best_by_stage[fine.name] = fine_best
790 stage_notes[fine.name] = fine_note
791 if fine_best
is None:
792 persist(
"no_passing_threshold")
794 "No fine-stage threshold met the relative-efficiency target."
797 except KeyboardInterrupt:
798 persist(
"interrupted")
800 f
"Interrupted. Partial results were saved under {work_dir}."
807 final = summary(
"completed")
808 print(f
"\nRecommended --score-threshold: {final['recommendedThreshold']:.6f}")
809 print(f
"Scan CSV: {csv_path}")
810 print(f
"Summary: {summary_path}")