154 """Evaluate the notebook's actual selection from an Athena ROOT output.
156 The function deliberately uses the local links exactly as the supplied
157 notebook does. It does *not* use the older MsTrkSeed / ActsMuons seed-link
158 matching method in muonEdgeTuner.py.
163 except ImportError
as error:
165 "PyROOT is unavailable. Run muonEdgeTuner.py from a configured Athena "
166 "environment so that `import ROOT` works."
169 input_file = ROOT.TFile.Open(str(root_file),
"READ")
170 if not input_file
or input_file.IsZombie():
171 raise RuntimeError(f
"Could not open ROOT output: {root_file}")
174 tree = input_file.Get(tree_name)
175 if not tree
or not tree.InheritsFrom(
"TTree"):
176 raise RuntimeError(f
"Could not find TTree '{tree_name}' in {root_file}.")
178 missing = [name
for name
in REQUIRED_BRANCHES
if not tree.GetBranch(name)]
181 f
"Missing required branch(es) in {root_file}: {', '.join(missing)}"
185 matched_truth_muons = 0
187 for entry_number
in range(int(tree.GetEntries())):
188 tree.GetEntry(entry_number)
190 truth_eta = tree.TruthMuons_eta
191 truth_segment_links = tree.TruthMuons_truthSegLinks
192 truth_to_acts_link = tree.TruthMuons_ActsMuonLink
193 acts_muon_pt = tree.ActsMuons_pt
198 if n_truth != n_segments
or n_truth != n_links:
200 f
"Truth-muon branch-size mismatch in entry {entry_number} of "
201 f
"{root_file}: eta={n_truth}, truthSegLinks={n_segments}, "
202 f
"ActsMuonLink={n_links}"
206 for truth_index
in range(n_truth):
212 or abs(float(truth_eta[truth_index])) >= 2.5
217 acts_link = int(truth_to_acts_link[truth_index])
218 if 0 <= acts_link < n_acts_muons:
219 matched_truth_muons += 1
225 f
"No denominator truth muons found in {root_file}. Required selection: "
226 "len(TruthMuons_truthSegLinks) > 0 and abs(TruthMuons_eta) < 2.5."
230 "truthMuonCount": truth_muons,
231 "matchedTruthMuonCount": matched_truth_muons,
232 "trackEfficiency": matched_truth_muons / truth_muons,
273 args: argparse.Namespace,
279 """Build one ML edge-classifier reconstruction command."""
283 "--edgeModel", args.edgeModel,
284 "--edgeThreshold", str(threshold),
285 "--enableEdgeClassifier",
289 "--enableBucketFilter" if args.enableBucketFilter
else "--disableBucketFilter"
292 if args.filterSegmentsWithoutMlConnections:
293 command.append(
"--filterSegmentsWithoutMlConnections")
294 if args.bucketModelPath:
295 command += [
"--bucketModel", args.bucketModelPath]
296 if args.bucketThreshold
is not None:
297 command += [
"--bucketThreshold", str(args.bucketThreshold)]
299 command += [
"--output-name", args.outputName]
300 if args.singleOutputMode:
301 command += [
"--single-output-mode", args.singleOutputMode]
303 command.append(
"--use-cpu")
305 command.append(
"--athenaDebug")
307 optional_int_arguments = (
308 (
"maxEdgesPerSegment",
"--maxEdgesPerSegment"),
309 (
"maxSegmentsPerBucket",
"--maxSegmentsPerBucket"),
310 (
"maxEdgesBeforeInference",
"--maxEdgesBeforeInference"),
311 (
"maxEdgesPerTargetChamber",
"--maxEdgesPerTargetChamber"),
312 (
"seedAnchorsPerComponent",
"--seedAnchorsPerComponent"),
313 (
"minSegmentsPerComponent",
"--minSegmentsPerComponent"),
315 for attribute, option
in optional_int_arguments:
316 value = getattr(args, attribute)
317 if value
is not None:
318 command += [option, str(value)]
321 (
"keepSameChamberEdgesBeforeInference",
"--keepSameChamberEdgesBeforeInference"),
322 (
"keepIsolatedNodesBeforeInference",
"--keepIsolatedNodesBeforeInference"),
323 (
"useDegreeCappedMlComponents",
"--useDegreeCappedMlComponents"),
324 (
"allowOneSidedMlEdges",
"--allowOneSidedMlEdges"),
325 (
"disableOrphanRecovery",
"--disableOrphanRecovery"),
326 (
"keepAllSegmentsPerChamber",
"--keepAllSegmentsPerChamber"),
328 for attribute, option
in optional_flags:
329 if getattr(args, attribute):
330 command.append(option)
334 command.extend(args.chainArg)
389 parser = argparse.ArgumentParser(
391 "Tune muonEdgeRecoChain.py --edgeThreshold using relative track "
392 "efficiency against a --skip-onnx --useStandardSeeder baseline "
393 "at every scan stage."
395 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
400 help=
"Input HITS file/list forwarded to muonEdgeRecoChain.py.",
405 help=
"ONNX segment-edge classifier model forwarded to --edgeModel.",
410 help=
"Explicit path to muonEdgeRecoChain.py. Defaults to a sibling file.",
414 default=
"MuonInference.muonEdgeRecoChain",
415 help=
"Module fallback when no local reco-chain source file is present.",
419 default=
"edge_threshold_tuning",
420 help=
"Directory where roots/, logs/, CSV and JSON outputs are written.",
422 parser.add_argument(
"--treeName", default=TREE_DEFAULT)
423 parser.add_argument(
"--threads", type=int, default=1)
424 parser.add_argument(
"--skipEvents", type=int, default=0)
425 parser.add_argument(
"--defaultGeoFile", default=
"RUN4")
426 parser.add_argument(
"--use-cpu", dest=
"use_cpu", action=
"store_true", default=
False)
431 help=
"Forward --athenaDebug to every ML edge-classifier reconstruction job.",
437 help=
"Reuse an existing ROOT output when its exact point is requested again.",
441 enableBucketFilter=
True,
442 filterSegmentsWithoutMlConnections=
True,
444 bucket_filter = parser.add_mutually_exclusive_group()
445 bucket_filter.add_argument(
446 "--enableBucketFilter",
447 dest=
"enableBucketFilter",
449 help=
"Run the bucket-filter preselection in every ML scan job.",
451 bucket_filter.add_argument(
452 "--disableBucketFilter",
453 dest=
"enableBucketFilter",
454 action=
"store_false",
455 help=
"Disable bucket filtering while tuning the edge threshold.",
457 segment_filter = parser.add_mutually_exclusive_group()
458 segment_filter.add_argument(
459 "--filterSegmentsWithoutMlConnections",
460 dest=
"filterSegmentsWithoutMlConnections",
463 "Use the ML-connected segment view in track finding, matching "
464 "reco_chain_ecfilter.sh."
467 segment_filter.add_argument(
468 "--keepSegmentsWithoutMlConnections",
469 dest=
"filterSegmentsWithoutMlConnections",
470 action=
"store_false",
471 help=
"Do not restrict the track-finder segment container to ML-connected segments.",
476 "--bucket-model-path",
477 dest=
"bucketModelPath",
479 help=
"Optional bucket-filter ONNX model; otherwise use the chain default.",
484 dest=
"bucketThreshold",
487 help=
"Optional fixed bucket-filter score threshold for every ML scan job.",
493 help=
"Optional bucket-filter ONNX output tensor name.",
496 "--single-output-mode",
497 choices=(
"logit",
"prob"),
498 dest=
"singleOutputMode",
500 help=
"Optional bucket-filter scalar ONNX-output interpretation.",
504 "--minRelativeEfficiency",
505 "--targetRelativeEfficiency",
506 "--targetEfficiency",
507 dest=
"minRelativeEfficiency",
511 "An edge-threshold point passes when edgeTrackEfficiency / "
512 "noMlTrackEfficiency is at least this value."
519 help=
"Lowest edge probability threshold considered.",
525 help=
"Highest edge probability threshold considered.",
527 parser.add_argument(
"--coarseEvents", type=int, default=10)
528 parser.add_argument(
"--coarseStep", type=float, default=0.05)
529 parser.add_argument(
"--mediumEvents", type=int, default=100)
530 parser.add_argument(
"--mediumStep", type=float, default=0.01)
531 parser.add_argument(
"--fineEvents", type=int, default=1000)
532 parser.add_argument(
"--fineStep", type=float, default=0.005)
534 edge_graph = parser.add_argument_group(
535 "Fixed SegmentEdgeInferenceAlg settings forwarded to every ML scan job"
537 edge_graph.add_argument(
"--maxEdgesPerSegment", type=int, default=
None)
538 edge_graph.add_argument(
"--maxSegmentsPerBucket", type=int, default=
None)
539 edge_graph.add_argument(
"--maxEdgesBeforeInference", type=int, default=
None)
540 edge_graph.add_argument(
"--maxEdgesPerTargetChamber", type=int, default=
None)
541 edge_graph.add_argument(
"--seedAnchorsPerComponent", type=int, default=
None)
542 edge_graph.add_argument(
"--minSegmentsPerComponent", type=int, default=
None)
543 edge_graph.add_argument(
"--keepSameChamberEdgesBeforeInference", action=
"store_true")
544 edge_graph.add_argument(
"--keepIsolatedNodesBeforeInference", action=
"store_true")
545 edge_graph.add_argument(
"--useDegreeCappedMlComponents", action=
"store_true")
546 edge_graph.add_argument(
"--allowOneSidedMlEdges", action=
"store_true")
547 edge_graph.add_argument(
"--disableOrphanRecovery", action=
"store_true")
548 edge_graph.add_argument(
"--keepAllSegmentsPerChamber", action=
"store_true")
554 help=
"Append one extra argv token to each ML edge-classifier command.",
557 "--baselineChainArg",
561 help=
"Append one extra argv token to each no-ML baseline command.",
564 return parser.parse_args()
604 work_dir = Path(args.workDir).expanduser().resolve()
605 roots_dir = work_dir /
"roots"
606 logs_dir = work_dir /
"logs"
607 csv_path = work_dir /
"edge_threshold_scan.csv"
608 summary_path = work_dir /
"summary.json"
611 roots_dir.mkdir(parents=
True, exist_ok=
True)
612 logs_dir.mkdir(parents=
True, exist_ok=
True)
615 ScanStage(
"coarse", args.coarseEvents, args.coarseStep),
616 ScanStage(
"medium", args.mediumEvents, args.mediumStep),
617 ScanStage(
"fine", args.fineEvents, args.fineStep),
619 rows: list[dict[str, Any]] = []
620 baselines: dict[str, dict[str, Any]] = {}
621 best_by_stage: dict[str, dict[str, Any] |
None] = {}
622 stage_notes: dict[str, str] = {}
624 def summary(status: str) -> dict[str, Any]:
626 best_by_stage.get(
"fine")
627 or best_by_stage.get(
"medium")
628 or best_by_stage.get(
"coarse")
632 "inputFile": args.inputFile,
633 "treeName": args.treeName,
634 "workDir": str(work_dir),
636 "edgeModel": args.edgeModel,
638 "bucketFilterEnabled": args.enableBucketFilter,
639 "filterSegmentsWithoutMlConnections": (
640 args.filterSegmentsWithoutMlConnections
642 "bucketModel": args.bucketModelPath,
643 "bucketThreshold": args.bucketThreshold,
646 "minimumRelativeEfficiency": args.minRelativeEfficiency,
648 "edgeTrackEfficiency / noMlTrackEfficiency >= "
649 "minimumRelativeEfficiency"
654 "len(TruthMuons_truthSegLinks[i]) > 0 and "
655 "abs(TruthMuons_eta[i]) < 2.5"
658 "denominator muon with 0 <= TruthMuons_ActsMuonLink[i] < "
661 "implementation":
"PyROOT; equivalent to the notebook's executable code",
664 {
"name": stage.name,
"nEvents": stage.n_events,
"step": stage.step}
667 "baselines": baselines,
668 "bestByStage": best_by_stage,
669 "stageNotes": stage_notes,
670 "recommendedThreshold": (
671 None if recommended
is None else recommended[
"threshold"]
673 "recommendedResult": recommended,
674 "scanCsv": str(csv_path),
677 def persist(status: str) ->
None:
681 def evaluate_baseline(stage: ScanStage) -> dict[str, Any]:
682 """Run/reuse one no-ML reference output for this stage."""
684 if stage.name
in baselines:
685 return baselines[stage.name]
687 tag = f
"noml_{stage.name}_events{stage.n_events:04d}"
688 out_root = roots_dir / f
"{tag}.root"
689 out_log = logs_dir / f
"{tag}.log"
691 row: dict[str, Any] = {
693 "nEvents": stage.n_events,
696 "rootFile": str(out_root),
698 "command":
" ".join(command),
701 if not args.skipExisting
or not out_root.is_file():
702 return_code =
_run(command, out_log)
703 row[
"returnCode"] = return_code
707 "error": f
"no-ML reconstruction returned {return_code}",
712 f
"No-ML baseline failed for stage={stage.name} with rc={return_code}. "
716 row[
"returnCode"] =
"reused"
718 if not out_root.is_file():
720 "status":
"missing_output",
721 "error":
"no-ML ROOT output is missing after reconstruction",
726 f
"No-ML ROOT output is missing for stage={stage.name}: {out_root}"
731 except Exception
as error:
732 row.update({
"status":
"evaluation_failed",
"error": str(error)})
739 "noMlTruthMuonCount": metrics[
"truthMuonCount"],
740 "noMlMatchedTruthMuonCount": metrics[
"matchedTruthMuonCount"],
741 "noMlTrackEfficiency": metrics[
"trackEfficiency"],
745 baselines[stage.name] = row
748 f
"[{stage.name:6s} | {stage.n_events:4d} events] "
749 f
"no-ML efficiency={metrics['trackEfficiency']:.6f} "
750 f
"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']})",
755 def evaluate_edge(stage: ScanStage, threshold: float) -> dict[str, Any]:
756 """Run/reuse and compare one edge-classifier threshold point."""
758 baseline = evaluate_baseline(stage)
761 f
"{stage.name}_events{stage.n_events:04d}_"
762 f
"threshold{_format_threshold(threshold)}"
764 out_root = roots_dir / f
"{tag}.root"
765 out_log = logs_dir / f
"{tag}.log"
769 n_events=stage.n_events,
772 row: dict[str, Any] = {
774 "nEvents": stage.n_events,
776 "threshold": threshold,
777 "minRelativeEfficiency": args.minRelativeEfficiency,
778 "rootFile": str(out_root),
780 "command":
" ".join(command),
783 if not args.skipExisting
or not out_root.is_file():
784 return_code =
_run(command, out_log)
785 row[
"returnCode"] = return_code
789 "error": f
"edge-classifier reconstruction returned {return_code}",
794 f
"Reconstruction failed at stage={stage.name}, threshold={threshold} "
795 f
"with rc={return_code}. See {out_log}"
798 row[
"returnCode"] =
"reused"
800 if not out_root.is_file():
802 "status":
"missing_output",
803 "error":
"edge-classifier ROOT output is missing after reconstruction",
808 f
"ROOT output is missing for stage={stage.name}, threshold={threshold}: "
814 except Exception
as error:
815 row.update({
"status":
"evaluation_failed",
"error": str(error)})
820 noml_efficiency = float(baseline[
"trackEfficiency"])
821 if noml_efficiency <= 0.0:
823 "status":
"evaluation_failed",
824 "error":
"no-ML baseline has zero track efficiency",
829 f
"No-ML efficiency is zero in stage={stage.name}; cannot calculate "
830 "relative efficiency."
833 relative_efficiency = float(metrics[
"trackEfficiency"]) / noml_efficiency
836 "noMlTruthMuonCount": baseline[
"truthMuonCount"],
837 "noMlMatchedTruthMuonCount": baseline[
"matchedTruthMuonCount"],
838 "noMlTrackEfficiency": noml_efficiency,
839 "truthCountMatchesNoMl": (
840 int(metrics[
"truthMuonCount"]) == int(baseline[
"truthMuonCount"])
842 "relativeTrackEfficiency": relative_efficiency,
843 "relativeEfficiencyLoss":
max(0.0, 1.0 - relative_efficiency),
844 "passesTarget": relative_efficiency >= args.minRelativeEfficiency,
850 f
"[{stage.name:6s} | {stage.n_events:4d} events] "
851 f
"threshold={threshold: .6f} "
852 f
"edge={metrics['trackEfficiency']:.6f} "
853 f
"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']}) "
854 f
"relative={relative_efficiency:.6f} "
855 f
"{'PASS' if row['passesTarget'] else 'FAIL'}",
862 start_threshold: float,
863 ) -> tuple[dict[str, Any] |
None, str]:
864 """Find the highest passing point, with first-point recovery downward."""
866 first = evaluate_edge(stage, start_threshold)
867 if bool(first[
"passesTarget"]):
875 candidate = evaluate_edge(stage, threshold)
876 if not bool(candidate[
"passesTarget"]):
877 return best,
"stopped_at_first_below_target"
879 return best,
"reached_upper_threshold_bound"
889 candidate = evaluate_edge(stage, threshold)
890 if bool(candidate[
"passesTarget"]):
891 return candidate,
"recovered_by_lowering_threshold"
893 return None,
"no_passing_threshold_in_range"
896 coarse, medium, fine = stages
901 coarse_best: dict[str, Any] |
None =
None
902 coarse_note =
"reached_upper_threshold_bound"
908 candidate = evaluate_edge(coarse, threshold)
909 if not bool(candidate[
"passesTarget"]):
910 coarse_note =
"stopped_at_first_below_target"
912 coarse_best = candidate
914 if coarse_best
is None:
915 best_by_stage[coarse.name] =
None
916 stage_notes[coarse.name] =
"no_passing_threshold_in_range"
917 persist(
"no_passing_threshold")
919 "No coarse edge threshold met the relative-efficiency target. "
920 "Lower --minRelativeEfficiency or extend --minThreshold."
923 best_by_stage[coarse.name] = coarse_best
924 stage_notes[coarse.name] = coarse_note
927 medium_best, medium_note = adaptive_scan(
929 float(coarse_best[
"threshold"]),
931 best_by_stage[medium.name] = medium_best
932 stage_notes[medium.name] = medium_note
933 if medium_best
is None:
934 persist(
"no_passing_threshold")
936 "No medium-stage edge threshold met the relative-efficiency target."
940 fine_best, fine_note = adaptive_scan(
942 float(medium_best[
"threshold"]),
944 best_by_stage[fine.name] = fine_best
945 stage_notes[fine.name] = fine_note
946 if fine_best
is None:
947 persist(
"no_passing_threshold")
949 "No fine-stage edge threshold met the relative-efficiency target."
952 except KeyboardInterrupt:
953 persist(
"interrupted")
955 f
"Interrupted. Partial results were saved under {work_dir}."
962 final = summary(
"completed")
963 print(f
"\nRecommended --edgeThreshold: {final['recommendedThreshold']:.6f}")
964 print(f
"Scan CSV: {csv_path}")
965 print(f
"Summary: {summary_path}")