ATLAS Offline Software
Loading...
Searching...
No Matches
muonEdgeTuner.py
Go to the documentation of this file.
1#!/usr/bin/env python
2# Copyright (C) 2002-2026 CERN for the benefit of the ATLAS collaboration
3"""Tune ``muonEdgeRecoChain.py --edgeThreshold`` against a no-ML baseline.
4
5For every scan stage, this script runs the standard no-ML reconstruction once:
6``muonEdgeRecoChain.py --skip-onnx --useStandardSeeder``. It then runs the
7ML edge-classifier chain at a sequence of ``--edgeThreshold`` values and
8compares each point with that stage's no-ML track efficiency.
9
10The tested ML configuration matches the current ``muonEdgeRecoChain.py``:
11
12* ``--enableBucketFilter`` (enabled by default, configurable);
13* ``--enableEdgeClassifier --useMlSeeder``;
14* ``--filterSegmentsWithoutMlConnections`` (enabled by default);
15* a required ``--edgeModel`` and a scanned ``--edgeThreshold``.
16
17The baseline mirrors ``reco_chain_noml.sh``. The same executable truth-muon
18selection used by ``muonBFTuner.py`` is evaluated from ``MsTrackValidTest``
19through PyROOT, so no uproot/awkward/numpy dependency is required.
20
21Stages:
22 * coarse: 10 events; threshold 0.0..1.0 in 0.05 steps;
23 * medium: 100 events; start at the last passing coarse point; 0.01 steps;
24 * fine: 1000 events; start at the last passing medium point; 0.005 steps.
25
26By default, a point passes when:
27 edgeTrackEfficiency / noMlTrackEfficiency >= 0.995
28"""
29
30from __future__ import annotations
31
32import argparse
33import csv
34import json
35import subprocess
36import sys
37from dataclasses import dataclass
38from decimal import Decimal, InvalidOperation
39from pathlib import Path
40from typing import Any, Iterable
41
42
43TREE_DEFAULT = "MsTrackValidTest"
44REQUIRED_BRANCHES = (
45 "TruthMuons_eta",
46 "TruthMuons_truthSegLinks",
47 "TruthMuons_ActsMuonLink",
48 "ActsMuons_pt",
49)
50
51
52@dataclass(frozen=True)
54 """A reconstruction-statistics / score-resolution scan stage."""
55
56 name: str
57 n_events: int
58 step: float
59
60
61def _decimal(value: float | str, name: str) -> Decimal:
62 """Return a finite Decimal, with an argparse-style diagnostic on failure."""
63
64 try:
65 result = Decimal(str(value))
66 except (InvalidOperation, ValueError) as error:
67 raise ValueError(f"{name} must be a finite decimal value") from error
68 if not result.is_finite():
69 raise ValueError(f"{name} must be a finite decimal value")
70 return result
71
72
73def _format_threshold(value: float) -> str:
74 """Return a deterministic, filename-safe number, e.g. -0.025 -> m0p025."""
75
76 text = format(_decimal(value, "threshold").normalize(), "f")
77 if "." not in text:
78 text += ".0"
79 return text.replace("-", "m").replace(".", "p")
80
81
83 start: float,
84 stop: float,
85 step: float,
86 *,
87 include_start: bool = True,
88) -> Iterable[float]:
89 """Yield a decimal threshold grid including ``stop`` when it lies on-grid."""
90
91 current = _decimal(start, "threshold")
92 end = _decimal(stop, "threshold")
93 increment = _decimal(step, "step")
94 if increment <= 0:
95 raise ValueError("step must be positive")
96 if not include_start:
97 current += increment
98 while current <= end:
99 yield float(current)
100 current += increment
101
102
104 start: float,
105 stop: float,
106 step: float,
107 *,
108 include_start: bool = False,
109) -> Iterable[float]:
110 """Yield a descending decimal threshold grid."""
111
112 current = _decimal(start, "threshold")
113 end = _decimal(stop, "threshold")
114 decrement = _decimal(step, "step")
115 if decrement <= 0:
116 raise ValueError("step must be positive")
117 if not include_start:
118 current -= decrement
119 while current >= end:
120 yield float(current)
121 current -= decrement
122
123
124def _run(command: list[str], log_path: Path) -> int:
125 """Run one reconstruction and send stdout/stderr to a per-job log."""
126
127 log_path.parent.mkdir(parents=True, exist_ok=True)
128 with log_path.open("w", encoding="utf-8") as log_file:
129 completed = subprocess.run(
130 command,
131 stdout=log_file,
132 stderr=subprocess.STDOUT,
133 text=True,
134 check=False,
135 )
136 return completed.returncode
137
138
139def _vector_size(value: Any) -> int:
140 """Obtain a PyROOT STL-vector size without assuming a Python list."""
141
142 try:
143 return len(value)
144 except TypeError:
145 try:
146 return int(value.size())
147 except AttributeError as error:
148 raise RuntimeError(
149 f"Object of type {type(value)!r} does not expose an STL-vector size"
150 ) from error
151
152
153def _read_notebook_efficiency(root_file: Path, tree_name: str) -> dict[str, int | float]:
154 """Evaluate the notebook's actual selection from an Athena ROOT output.
155
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.
159 """
160
161 try:
162 import ROOT
163 except ImportError as error:
164 raise RuntimeError(
165 "PyROOT is unavailable. Run muonEdgeTuner.py from a configured Athena "
166 "environment so that `import ROOT` works."
167 ) from error
168
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}")
172
173 try:
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}.")
177
178 missing = [name for name in REQUIRED_BRANCHES if not tree.GetBranch(name)]
179 if missing:
180 raise RuntimeError(
181 f"Missing required branch(es) in {root_file}: {', '.join(missing)}"
182 )
183
184 truth_muons = 0
185 matched_truth_muons = 0
186
187 for entry_number in range(int(tree.GetEntries())):
188 tree.GetEntry(entry_number)
189
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
194
195 n_truth = _vector_size(truth_eta)
196 n_segments = _vector_size(truth_segment_links)
197 n_links = _vector_size(truth_to_acts_link)
198 if n_truth != n_segments or n_truth != n_links:
199 raise RuntimeError(
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}"
203 )
204
205 n_acts_muons = _vector_size(acts_muon_pt)
206 for truth_index in range(n_truth):
207 # Exact notebook denominator:
208 # ak.num(TruthMuons_truthSegLinks[event], axis=-1) > 0
209 # and abs(TruthMuons_eta[event]) < 2.5
210 if (
211 _vector_size(truth_segment_links[truth_index]) <= 0
212 or abs(float(truth_eta[truth_index])) >= 2.5
213 ):
214 continue
215
216 truth_muons += 1
217 acts_link = int(truth_to_acts_link[truth_index])
218 if 0 <= acts_link < n_acts_muons:
219 matched_truth_muons += 1
220 finally:
221 input_file.Close()
222
223 if truth_muons == 0:
224 raise RuntimeError(
225 f"No denominator truth muons found in {root_file}. Required selection: "
226 "len(TruthMuons_truthSegLinks) > 0 and abs(TruthMuons_eta) < 2.5."
227 )
228
229 return {
230 "truthMuonCount": truth_muons,
231 "matchedTruthMuonCount": matched_truth_muons,
232 "trackEfficiency": matched_truth_muons / truth_muons,
233 }
234
235
236def _reco_launcher(args: argparse.Namespace) -> list[str]:
237 """Resolve muonEdgeRecoChain.py, preferring an explicit/local source."""
238
239 if args.recoChain:
240 chain = Path(args.recoChain).expanduser().resolve()
241 if not chain.is_file():
242 raise RuntimeError(f"--recoChain does not point to a file: {chain}")
243 return [sys.executable, str(chain)]
244
245 sibling_chain = Path(__file__).with_name("muonEdgeRecoChain.py")
246 if sibling_chain.is_file():
247 return [sys.executable, str(sibling_chain)]
248
249 return [sys.executable, "-m", args.recoModule]
250
251
253 args: argparse.Namespace,
254 *,
255 n_events: int,
256 out_root: Path,
257) -> list[str]:
258 """Build arguments common to edge-classifier and no-ML jobs."""
259
260 return [
261 *_reco_launcher(args),
262 "--threads", str(args.threads),
263 "--nEvents", str(n_events),
264 "--skipEvents", str(args.skipEvents),
265 "--inputFile", args.inputFile,
266 "--outRootFile", str(out_root),
267 "--defaultGeoFile", args.defaultGeoFile,
268 "--noPerfMon",
269 ]
270
271
273 args: argparse.Namespace,
274 *,
275 threshold: float,
276 n_events: int,
277 out_root: Path,
278) -> list[str]:
279 """Build one ML edge-classifier reconstruction command."""
280
281 command = _common_chain_command(args, n_events=n_events, out_root=out_root)
282 command += [
283 "--edgeModel", args.edgeModel,
284 "--edgeThreshold", str(threshold),
285 "--enableEdgeClassifier",
286 "--useMlSeeder",
287 ]
288 command.append(
289 "--enableBucketFilter" if args.enableBucketFilter else "--disableBucketFilter"
290 )
291
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)]
298 if args.outputName:
299 command += ["--output-name", args.outputName]
300 if args.singleOutputMode:
301 command += ["--single-output-mode", args.singleOutputMode]
302 if args.use_cpu:
303 command.append("--use-cpu")
304 if args.athenaDebug:
305 command.append("--athenaDebug")
306
307 optional_int_arguments = (
308 ("maxEdgesPerSegment", "--maxEdgesPerSegment"),
309 ("maxSegmentsPerBucket", "--maxSegmentsPerBucket"),
310 ("maxEdgesBeforeInference", "--maxEdgesBeforeInference"),
311 ("maxEdgesPerTargetChamber", "--maxEdgesPerTargetChamber"),
312 ("seedAnchorsPerComponent", "--seedAnchorsPerComponent"),
313 ("minSegmentsPerComponent", "--minSegmentsPerComponent"),
314 )
315 for attribute, option in optional_int_arguments:
316 value = getattr(args, attribute)
317 if value is not None:
318 command += [option, str(value)]
319
320 optional_flags = (
321 ("keepSameChamberEdgesBeforeInference", "--keepSameChamberEdgesBeforeInference"),
322 ("keepIsolatedNodesBeforeInference", "--keepIsolatedNodesBeforeInference"),
323 ("useDegreeCappedMlComponents", "--useDegreeCappedMlComponents"),
324 ("allowOneSidedMlEdges", "--allowOneSidedMlEdges"),
325 ("disableOrphanRecovery", "--disableOrphanRecovery"),
326 ("keepAllSegmentsPerChamber", "--keepAllSegmentsPerChamber"),
327 )
328 for attribute, option in optional_flags:
329 if getattr(args, attribute):
330 command.append(option)
331
332 # Escape hatch for chain switches introduced after this tuner. Each use
333 # appends one argv token; use it repeatedly for an option and its value.
334 command.extend(args.chainArg)
335 return command
336
337
339 args: argparse.Namespace,
340 *,
341 n_events: int,
342 out_root: Path,
343) -> list[str]:
344 """Build the no-ML baseline command from reco_chain_noml.sh."""
345
346 command = _common_chain_command(args, n_events=n_events, out_root=out_root)
347 command += ["--skip-onnx", "--useStandardSeeder"]
348 command.extend(args.baselineChainArg)
349 return command
350
351
352def _write_csv(rows: list[dict[str, Any]], path: Path) -> None:
353 fields = [
354 "stage",
355 "nEvents",
356 "mode",
357 "threshold",
358 "status",
359 "passesTarget",
360 "truthCountMatchesNoMl",
361 "truthMuonCount",
362 "matchedTruthMuonCount",
363 "trackEfficiency",
364 "noMlTruthMuonCount",
365 "noMlMatchedTruthMuonCount",
366 "noMlTrackEfficiency",
367 "relativeTrackEfficiency",
368 "relativeEfficiencyLoss",
369 "minRelativeEfficiency",
370 "rootFile",
371 "log",
372 "returnCode",
373 "command",
374 "error",
375 ]
376 path.parent.mkdir(parents=True, exist_ok=True)
377 with path.open("w", encoding="utf-8", newline="") as output:
378 writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore")
379 writer.writeheader()
380 writer.writerows(rows)
381
382
383def _write_json(payload: dict[str, Any], path: Path) -> None:
384 path.parent.mkdir(parents=True, exist_ok=True)
385 path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
386
387
388def _parse_args() -> argparse.Namespace:
389 parser = argparse.ArgumentParser(
390 description=(
391 "Tune muonEdgeRecoChain.py --edgeThreshold using relative track "
392 "efficiency against a --skip-onnx --useStandardSeeder baseline "
393 "at every scan stage."
394 ),
395 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
396 )
397 parser.add_argument(
398 "--inputFile",
399 required=True,
400 help="Input HITS file/list forwarded to muonEdgeRecoChain.py.",
401 )
402 parser.add_argument(
403 "--edgeModel",
404 required=True,
405 help="ONNX segment-edge classifier model forwarded to --edgeModel.",
406 )
407 parser.add_argument(
408 "--recoChain",
409 default=None,
410 help="Explicit path to muonEdgeRecoChain.py. Defaults to a sibling file.",
411 )
412 parser.add_argument(
413 "--recoModule",
414 default="MuonInference.muonEdgeRecoChain",
415 help="Module fallback when no local reco-chain source file is present.",
416 )
417 parser.add_argument(
418 "--workDir",
419 default="edge_threshold_tuning",
420 help="Directory where roots/, logs/, CSV and JSON outputs are written.",
421 )
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)
427 parser.add_argument(
428 "--athenaDebug",
429 action="store_true",
430 default=False,
431 help="Forward --athenaDebug to every ML edge-classifier reconstruction job.",
432 )
433 parser.add_argument(
434 "--skipExisting",
435 action="store_true",
436 default=False,
437 help="Reuse an existing ROOT output when its exact point is requested again.",
438 )
439
440 parser.set_defaults(
441 enableBucketFilter=True,
442 filterSegmentsWithoutMlConnections=True,
443 )
444 bucket_filter = parser.add_mutually_exclusive_group()
445 bucket_filter.add_argument(
446 "--enableBucketFilter",
447 dest="enableBucketFilter",
448 action="store_true",
449 help="Run the bucket-filter preselection in every ML scan job.",
450 )
451 bucket_filter.add_argument(
452 "--disableBucketFilter",
453 dest="enableBucketFilter",
454 action="store_false",
455 help="Disable bucket filtering while tuning the edge threshold.",
456 )
457 segment_filter = parser.add_mutually_exclusive_group()
458 segment_filter.add_argument(
459 "--filterSegmentsWithoutMlConnections",
460 dest="filterSegmentsWithoutMlConnections",
461 action="store_true",
462 help=(
463 "Use the ML-connected segment view in track finding, matching "
464 "reco_chain_ecfilter.sh."
465 ),
466 )
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.",
472 )
473
474 parser.add_argument(
475 "--bucketModel",
476 "--bucket-model-path",
477 dest="bucketModelPath",
478 default=None,
479 help="Optional bucket-filter ONNX model; otherwise use the chain default.",
480 )
481 parser.add_argument(
482 "--bucketThreshold",
483 "--score-threshold",
484 dest="bucketThreshold",
485 type=float,
486 default=None,
487 help="Optional fixed bucket-filter score threshold for every ML scan job.",
488 )
489 parser.add_argument(
490 "--output-name",
491 dest="outputName",
492 default=None,
493 help="Optional bucket-filter ONNX output tensor name.",
494 )
495 parser.add_argument(
496 "--single-output-mode",
497 choices=("logit", "prob"),
498 dest="singleOutputMode",
499 default=None,
500 help="Optional bucket-filter scalar ONNX-output interpretation.",
501 )
502
503 parser.add_argument(
504 "--minRelativeEfficiency",
505 "--targetRelativeEfficiency",
506 "--targetEfficiency",
507 dest="minRelativeEfficiency",
508 type=float,
509 default=0.995,
510 help=(
511 "An edge-threshold point passes when edgeTrackEfficiency / "
512 "noMlTrackEfficiency is at least this value."
513 ),
514 )
515 parser.add_argument(
516 "--minThreshold",
517 type=float,
518 default=0.0,
519 help="Lowest edge probability threshold considered.",
520 )
521 parser.add_argument(
522 "--maxThreshold",
523 type=float,
524 default=1.0,
525 help="Highest edge probability threshold considered.",
526 )
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)
533
534 edge_graph = parser.add_argument_group(
535 "Fixed SegmentEdgeInferenceAlg settings forwarded to every ML scan job"
536 )
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")
549 parser.add_argument(
550 "--chainArg",
551 action="append",
552 default=[],
553 metavar="ARG",
554 help="Append one extra argv token to each ML edge-classifier command.",
555 )
556 parser.add_argument(
557 "--baselineChainArg",
558 action="append",
559 default=[],
560 metavar="ARG",
561 help="Append one extra argv token to each no-ML baseline command.",
562 )
563
564 return parser.parse_args()
565
566
567def _validate_args(args: argparse.Namespace) -> None:
568 if args.threads <= 0:
569 raise SystemExit("--threads must be positive")
570 if args.skipEvents < 0:
571 raise SystemExit("--skipEvents must not be negative")
572 if not 0.0 <= args.minRelativeEfficiency <= 1.0:
573 raise SystemExit("--minRelativeEfficiency must be between 0 and 1")
574 if not 0.0 <= args.minThreshold < args.maxThreshold <= 1.0:
575 raise SystemExit(
576 "edge thresholds must satisfy 0.0 <= --minThreshold < "
577 "--maxThreshold <= 1.0"
578 )
579 if args.bucketThreshold is not None and not 0.0 <= args.bucketThreshold <= 1.0:
580 raise SystemExit("--bucketThreshold must be between 0 and 1")
581 for name in ("coarseEvents", "mediumEvents", "fineEvents"):
582 if getattr(args, name) <= 0:
583 raise SystemExit(f"--{name} must be positive")
584 for name in ("coarseStep", "mediumStep", "fineStep"):
585 if getattr(args, name) <= 0.0:
586 raise SystemExit(f"--{name} must be positive")
587 for name in (
588 "maxEdgesPerSegment",
589 "maxSegmentsPerBucket",
590 "maxEdgesBeforeInference",
591 "maxEdgesPerTargetChamber",
592 "seedAnchorsPerComponent",
593 "minSegmentsPerComponent",
594 ):
595 value = getattr(args, name)
596 if value is not None and value < 0:
597 raise SystemExit(f"--{name} must not be negative")
598
599
600def main() -> None:
601 args = _parse_args()
602 _validate_args(args)
603
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"
609
610 # Athena/THistSvc will not create parent directories itself.
611 roots_dir.mkdir(parents=True, exist_ok=True)
612 logs_dir.mkdir(parents=True, exist_ok=True)
613
614 stages = (
615 ScanStage("coarse", args.coarseEvents, args.coarseStep),
616 ScanStage("medium", args.mediumEvents, args.mediumStep),
617 ScanStage("fine", args.fineEvents, args.fineStep),
618 )
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] = {}
623
624 def summary(status: str) -> dict[str, Any]:
625 recommended = (
626 best_by_stage.get("fine")
627 or best_by_stage.get("medium")
628 or best_by_stage.get("coarse")
629 )
630 return {
631 "status": status,
632 "inputFile": args.inputFile,
633 "treeName": args.treeName,
634 "workDir": str(work_dir),
635 "recoLauncher": _reco_launcher(args),
636 "edgeModel": args.edgeModel,
637 "mlConfiguration": {
638 "bucketFilterEnabled": args.enableBucketFilter,
639 "filterSegmentsWithoutMlConnections": (
640 args.filterSegmentsWithoutMlConnections
641 ),
642 "bucketModel": args.bucketModelPath,
643 "bucketThreshold": args.bucketThreshold,
644 },
645 "target": {
646 "minimumRelativeEfficiency": args.minRelativeEfficiency,
647 "acceptedWhen": (
648 "edgeTrackEfficiency / noMlTrackEfficiency >= "
649 "minimumRelativeEfficiency"
650 ),
651 },
652 "selection": {
653 "denominator": (
654 "len(TruthMuons_truthSegLinks[i]) > 0 and "
655 "abs(TruthMuons_eta[i]) < 2.5"
656 ),
657 "numerator": (
658 "denominator muon with 0 <= TruthMuons_ActsMuonLink[i] < "
659 "len(ActsMuons_pt)"
660 ),
661 "implementation": "PyROOT; equivalent to the notebook's executable code",
662 },
663 "stages": [
664 {"name": stage.name, "nEvents": stage.n_events, "step": stage.step}
665 for stage in stages
666 ],
667 "baselines": baselines,
668 "bestByStage": best_by_stage,
669 "stageNotes": stage_notes,
670 "recommendedThreshold": (
671 None if recommended is None else recommended["threshold"]
672 ),
673 "recommendedResult": recommended,
674 "scanCsv": str(csv_path),
675 }
676
677 def persist(status: str) -> None:
678 _write_csv(rows, csv_path)
679 _write_json(summary(status), summary_path)
680
681 def evaluate_baseline(stage: ScanStage) -> dict[str, Any]:
682 """Run/reuse one no-ML reference output for this stage."""
683
684 if stage.name in baselines:
685 return baselines[stage.name]
686
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"
690 command = _noml_chain_command(args, n_events=stage.n_events, out_root=out_root)
691 row: dict[str, Any] = {
692 "stage": stage.name,
693 "nEvents": stage.n_events,
694 "mode": "noml",
695 "threshold": "",
696 "rootFile": str(out_root),
697 "log": str(out_log),
698 "command": " ".join(command),
699 }
700
701 if not args.skipExisting or not out_root.is_file():
702 return_code = _run(command, out_log)
703 row["returnCode"] = return_code
704 if return_code != 0:
705 row.update({
706 "status": "failed",
707 "error": f"no-ML reconstruction returned {return_code}",
708 })
709 rows.append(row)
710 persist("failed")
711 raise RuntimeError(
712 f"No-ML baseline failed for stage={stage.name} with rc={return_code}. "
713 f"See {out_log}"
714 )
715 else:
716 row["returnCode"] = "reused"
717
718 if not out_root.is_file():
719 row.update({
720 "status": "missing_output",
721 "error": "no-ML ROOT output is missing after reconstruction",
722 })
723 rows.append(row)
724 persist("failed")
725 raise RuntimeError(
726 f"No-ML ROOT output is missing for stage={stage.name}: {out_root}"
727 )
728
729 try:
730 metrics = _read_notebook_efficiency(out_root, args.treeName)
731 except Exception as error:
732 row.update({"status": "evaluation_failed", "error": str(error)})
733 rows.append(row)
734 persist("failed")
735 raise
736
737 row.update(metrics)
738 row.update({
739 "noMlTruthMuonCount": metrics["truthMuonCount"],
740 "noMlMatchedTruthMuonCount": metrics["matchedTruthMuonCount"],
741 "noMlTrackEfficiency": metrics["trackEfficiency"],
742 "status": "ok",
743 })
744 rows.append(row)
745 baselines[stage.name] = row
746 persist("running")
747 print(
748 f"[{stage.name:6s} | {stage.n_events:4d} events] "
749 f"no-ML efficiency={metrics['trackEfficiency']:.6f} "
750 f"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']})",
751 flush=True,
752 )
753 return row
754
755 def evaluate_edge(stage: ScanStage, threshold: float) -> dict[str, Any]:
756 """Run/reuse and compare one edge-classifier threshold point."""
757
758 baseline = evaluate_baseline(stage)
759
760 tag = (
761 f"{stage.name}_events{stage.n_events:04d}_"
762 f"threshold{_format_threshold(threshold)}"
763 )
764 out_root = roots_dir / f"{tag}.root"
765 out_log = logs_dir / f"{tag}.log"
766 command = _edge_chain_command(
767 args,
768 threshold=threshold,
769 n_events=stage.n_events,
770 out_root=out_root,
771 )
772 row: dict[str, Any] = {
773 "stage": stage.name,
774 "nEvents": stage.n_events,
775 "mode": "edge",
776 "threshold": threshold,
777 "minRelativeEfficiency": args.minRelativeEfficiency,
778 "rootFile": str(out_root),
779 "log": str(out_log),
780 "command": " ".join(command),
781 }
782
783 if not args.skipExisting or not out_root.is_file():
784 return_code = _run(command, out_log)
785 row["returnCode"] = return_code
786 if return_code != 0:
787 row.update({
788 "status": "failed",
789 "error": f"edge-classifier reconstruction returned {return_code}",
790 })
791 rows.append(row)
792 persist("failed")
793 raise RuntimeError(
794 f"Reconstruction failed at stage={stage.name}, threshold={threshold} "
795 f"with rc={return_code}. See {out_log}"
796 )
797 else:
798 row["returnCode"] = "reused"
799
800 if not out_root.is_file():
801 row.update({
802 "status": "missing_output",
803 "error": "edge-classifier ROOT output is missing after reconstruction",
804 })
805 rows.append(row)
806 persist("failed")
807 raise RuntimeError(
808 f"ROOT output is missing for stage={stage.name}, threshold={threshold}: "
809 f"{out_root}"
810 )
811
812 try:
813 metrics = _read_notebook_efficiency(out_root, args.treeName)
814 except Exception as error:
815 row.update({"status": "evaluation_failed", "error": str(error)})
816 rows.append(row)
817 persist("failed")
818 raise
819
820 noml_efficiency = float(baseline["trackEfficiency"])
821 if noml_efficiency <= 0.0:
822 row.update({
823 "status": "evaluation_failed",
824 "error": "no-ML baseline has zero track efficiency",
825 })
826 rows.append(row)
827 persist("failed")
828 raise RuntimeError(
829 f"No-ML efficiency is zero in stage={stage.name}; cannot calculate "
830 "relative efficiency."
831 )
832
833 relative_efficiency = float(metrics["trackEfficiency"]) / noml_efficiency
834 row.update(metrics)
835 row.update({
836 "noMlTruthMuonCount": baseline["truthMuonCount"],
837 "noMlMatchedTruthMuonCount": baseline["matchedTruthMuonCount"],
838 "noMlTrackEfficiency": noml_efficiency,
839 "truthCountMatchesNoMl": (
840 int(metrics["truthMuonCount"]) == int(baseline["truthMuonCount"])
841 ),
842 "relativeTrackEfficiency": relative_efficiency,
843 "relativeEfficiencyLoss": max(0.0, 1.0 - relative_efficiency),
844 "passesTarget": relative_efficiency >= args.minRelativeEfficiency,
845 "status": "ok",
846 })
847 rows.append(row)
848 persist("running")
849 print(
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'}",
856 flush=True,
857 )
858 return row
859
860 def adaptive_scan(
861 stage: ScanStage,
862 start_threshold: float,
863 ) -> tuple[dict[str, Any] | None, str]:
864 """Find the highest passing point, with first-point recovery downward."""
865
866 first = evaluate_edge(stage, start_threshold)
867 if bool(first["passesTarget"]):
868 best = first
869 for threshold in _ascending_thresholds(
870 start_threshold,
871 args.maxThreshold,
872 stage.step,
873 include_start=False,
874 ):
875 candidate = evaluate_edge(stage, threshold)
876 if not bool(candidate["passesTarget"]):
877 return best, "stopped_at_first_below_target"
878 best = candidate
879 return best, "reached_upper_threshold_bound"
880
881 # At the larger sample, the previous stage's value may already be too
882 # high. Walk down until the nearest passing value is recovered.
883 for threshold in _descending_thresholds(
884 start_threshold,
885 args.minThreshold,
886 stage.step,
887 include_start=False,
888 ):
889 candidate = evaluate_edge(stage, threshold)
890 if bool(candidate["passesTarget"]):
891 return candidate, "recovered_by_lowering_threshold"
892
893 return None, "no_passing_threshold_in_range"
894
895 try:
896 coarse, medium, fine = stages
897
898 # The score cut is assumed to become no more permissive as it rises.
899 # Therefore the coarse stage stops as soon as the first failing point is
900 # seen; the last passing point is the seed for the 100-event scan.
901 coarse_best: dict[str, Any] | None = None
902 coarse_note = "reached_upper_threshold_bound"
903 for threshold in _ascending_thresholds(
904 args.minThreshold,
905 args.maxThreshold,
906 coarse.step,
907 ):
908 candidate = evaluate_edge(coarse, threshold)
909 if not bool(candidate["passesTarget"]):
910 coarse_note = "stopped_at_first_below_target"
911 break
912 coarse_best = candidate
913
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")
918 raise RuntimeError(
919 "No coarse edge threshold met the relative-efficiency target. "
920 "Lower --minRelativeEfficiency or extend --minThreshold."
921 )
922
923 best_by_stage[coarse.name] = coarse_best
924 stage_notes[coarse.name] = coarse_note
925 persist("running")
926
927 medium_best, medium_note = adaptive_scan(
928 medium,
929 float(coarse_best["threshold"]),
930 )
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")
935 raise RuntimeError(
936 "No medium-stage edge threshold met the relative-efficiency target."
937 )
938 persist("running")
939
940 fine_best, fine_note = adaptive_scan(
941 fine,
942 float(medium_best["threshold"]),
943 )
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")
948 raise RuntimeError(
949 "No fine-stage edge threshold met the relative-efficiency target."
950 )
951
952 except KeyboardInterrupt:
953 persist("interrupted")
954 raise SystemExit(
955 f"Interrupted. Partial results were saved under {work_dir}."
956 )
957 except Exception:
958 persist("failed")
959 raise
960
961 persist("completed")
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}")
966
967
968if __name__ == "__main__":
969 main()
Double_t normalize(TF1 *func, Double_t *rampl=NULL, Double_t from=0., Double_t to=0., Double_t step=1.)
void print(char *figname, TCanvas *c1)
#define max(a, b)
Definition cfImp.cxx:41
std::string replace(std::string s, const std::string &s2, const std::string &s3)
Definition hcg.cxx:312
int _run(list[str] command, Path log_path)
None _write_json(dict[str, Any] payload, Path path)
str _format_threshold(float value)
dict[str, int|float] _read_notebook_efficiency(Path root_file, str tree_name)
list[str] _edge_chain_command(argparse.Namespace args, *, float threshold, int n_events, Path out_root)
int _vector_size(Any value)
list[str] _noml_chain_command(argparse.Namespace args, *, int n_events, Path out_root)
Iterable[float] _ascending_thresholds(float start, float stop, float step, *, bool include_start=True)
Iterable[float] _descending_thresholds(float start, float stop, float step, *, bool include_start=False)
list[str] _reco_launcher(argparse.Namespace args)
argparse.Namespace _parse_args()
Decimal _decimal(float|str value, str name)
list[str] _common_chain_command(argparse.Namespace args, *, int n_events, Path out_root)
None _write_csv(list[dict[str, Any]] rows, Path path)
None _validate_args(argparse.Namespace args)