ATLAS Offline Software
Loading...
Searching...
No Matches
muonBFTuner.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 GraphBucketFilter --score-threshold using relative muon-track efficiency.
4
5For every scan stage, this script first runs the no-bucket-filter reconstruction
6once (``muonBucketRecoChain.py --skip-onnx``). Every score-threshold point is
7then compared to that stage's no-ML result.
8
9The truth-muon selection is intentionally the executable selection from
10``plot_edge_vs_noml.ipynb``:
11
12 denominator:
13 len(TruthMuons_truthSegLinks[i]) > 0
14 and abs(TruthMuons_eta[i]) < 2.5
15
16 numerator:
17 denominator muon with
18 0 <= TruthMuons_ActsMuonLink[i] < len(ActsMuons_pt)
19
20No uproot/awkward/numpy dependency is required: ROOT is read through PyROOT,
21which is available in a configured Athena environment.
22
23Stages:
24 * coarse: 10 events; threshold -1.0..1.0 in 0.1 steps, stopping at the
25 first threshold below the target;
26 * medium: 100 events; start at the last passing coarse threshold; 0.025 steps;
27 * fine: 1000 events; start at the last passing medium threshold; 0.01 steps.
28
29By default, a threshold passes when:
30 bucketTrackEfficiency / noMlTrackEfficiency >= 0.995
31"""
32
33from __future__ import annotations
34
35import argparse
36import csv
37import json
38import subprocess
39import sys
40from dataclasses import dataclass
41from decimal import Decimal, InvalidOperation
42from pathlib import Path
43from typing import Any, Iterable
44
45
46TREE_DEFAULT = "MsTrackValidTest"
47REQUIRED_BRANCHES = (
48 "TruthMuons_eta",
49 "TruthMuons_truthSegLinks",
50 "TruthMuons_ActsMuonLink",
51 "ActsMuons_pt",
52)
53
54
55@dataclass(frozen=True)
57 """A reconstruction-statistics / score-resolution scan stage."""
58
59 name: str
60 n_events: int
61 step: float
62
63
64def _decimal(value: float | str, name: str) -> Decimal:
65 """Return a finite Decimal, with an argparse-style diagnostic on failure."""
66
67 try:
68 result = Decimal(str(value))
69 except (InvalidOperation, ValueError) as error:
70 raise ValueError(f"{name} must be a finite decimal value") from error
71 if not result.is_finite():
72 raise ValueError(f"{name} must be a finite decimal value")
73 return result
74
75
76def _format_threshold(value: float) -> str:
77 """Return a deterministic, filename-safe number, e.g. -0.025 -> m0p025."""
78
79 text = format(_decimal(value, "threshold").normalize(), "f")
80 if "." not in text:
81 text += ".0"
82 return text.replace("-", "m").replace(".", "p")
83
84
86 start: float,
87 stop: float,
88 step: float,
89 *,
90 include_start: bool = True,
91) -> Iterable[float]:
92 """Yield a decimal threshold grid including ``stop`` when it lies on-grid."""
93
94 current = _decimal(start, "threshold")
95 end = _decimal(stop, "threshold")
96 increment = _decimal(step, "step")
97 if increment <= 0:
98 raise ValueError("step must be positive")
99 if not include_start:
100 current += increment
101 while current <= end:
102 yield float(current)
103 current += increment
104
105
107 start: float,
108 stop: float,
109 step: float,
110 *,
111 include_start: bool = False,
112) -> Iterable[float]:
113 """Yield a descending decimal threshold grid."""
114
115 current = _decimal(start, "threshold")
116 end = _decimal(stop, "threshold")
117 decrement = _decimal(step, "step")
118 if decrement <= 0:
119 raise ValueError("step must be positive")
120 if not include_start:
121 current -= decrement
122 while current >= end:
123 yield float(current)
124 current -= decrement
125
126
127def _run(command: list[str], log_path: Path) -> int:
128 """Run one reconstruction and send stdout/stderr to a per-job log."""
129
130 log_path.parent.mkdir(parents=True, exist_ok=True)
131 with log_path.open("w", encoding="utf-8") as log_file:
132 completed = subprocess.run(
133 command,
134 stdout=log_file,
135 stderr=subprocess.STDOUT,
136 text=True,
137 check=False,
138 )
139 return completed.returncode
140
141
142def _vector_size(value: Any) -> int:
143 """Obtain a PyROOT STL-vector size without assuming a Python list."""
144
145 try:
146 return len(value)
147 except TypeError:
148 try:
149 return int(value.size())
150 except AttributeError as error:
151 raise RuntimeError(
152 f"Object of type {type(value)!r} does not expose an STL-vector size"
153 ) from error
154
155
156def _read_notebook_efficiency(root_file: Path, tree_name: str) -> dict[str, int | float]:
157 """Evaluate the notebook's actual selection from an Athena ROOT output.
158
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.
162 """
163
164 try:
165 import ROOT
166 except ImportError as error:
167 raise RuntimeError(
168 "PyROOT is unavailable. Run muonBFTuner.py from a configured Athena "
169 "environment so that `import ROOT` works."
170 ) from error
171
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}")
175
176 try:
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}.")
180
181 missing = [name for name in REQUIRED_BRANCHES if not tree.GetBranch(name)]
182 if missing:
183 raise RuntimeError(
184 f"Missing required branch(es) in {root_file}: {', '.join(missing)}"
185 )
186
187 truth_muons = 0
188 matched_truth_muons = 0
189
190 for entry_number in range(int(tree.GetEntries())):
191 tree.GetEntry(entry_number)
192
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
197
198 n_truth = _vector_size(truth_eta)
199 n_segments = _vector_size(truth_segment_links)
200 n_links = _vector_size(truth_to_acts_link)
201 if n_truth != n_segments or n_truth != n_links:
202 raise RuntimeError(
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}"
206 )
207
208 n_acts_muons = _vector_size(acts_muon_pt)
209 for truth_index in range(n_truth):
210 # Exact notebook denominator:
211 # ak.num(TruthMuons_truthSegLinks[event], axis=-1) > 0
212 # and abs(TruthMuons_eta[event]) < 2.5
213 if (
214 _vector_size(truth_segment_links[truth_index]) <= 0
215 or abs(float(truth_eta[truth_index])) >= 2.5
216 ):
217 continue
218
219 truth_muons += 1
220 acts_link = int(truth_to_acts_link[truth_index])
221 if 0 <= acts_link < n_acts_muons:
222 matched_truth_muons += 1
223 finally:
224 input_file.Close()
225
226 if truth_muons == 0:
227 raise RuntimeError(
228 f"No denominator truth muons found in {root_file}. Required selection: "
229 "len(TruthMuons_truthSegLinks) > 0 and abs(TruthMuons_eta) < 2.5."
230 )
231
232 return {
233 "truthMuonCount": truth_muons,
234 "matchedTruthMuonCount": matched_truth_muons,
235 "trackEfficiency": matched_truth_muons / truth_muons,
236 }
237
238
239def _reco_launcher(args: argparse.Namespace) -> list[str]:
240 """Resolve muonBucketRecoChain.py, preferring an explicit/local source."""
241
242 if args.recoChain:
243 chain = Path(args.recoChain).expanduser().resolve()
244 if not chain.is_file():
245 raise RuntimeError(f"--recoChain does not point to a file: {chain}")
246 return [sys.executable, str(chain)]
247
248 sibling_chain = Path(__file__).with_name("muonBucketRecoChain.py")
249 if sibling_chain.is_file():
250 return [sys.executable, str(sibling_chain)]
251
252 return [sys.executable, "-m", args.recoModule]
253
254
256 args: argparse.Namespace,
257 *,
258 n_events: int,
259 out_root: Path,
260) -> list[str]:
261 """Build arguments common to bucket-filter and no-ML jobs."""
262
263 command = [
264 *_reco_launcher(args),
265 "--threads", str(args.threads),
266 "--nEvents", str(n_events),
267 "--skipEvents", str(args.skipEvents),
268 "--inputFile", args.inputFile,
269 "--outRootFile", str(out_root),
270 "--defaultGeoFile", args.defaultGeoFile,
271 "--noPerfMon",
272 ]
273
274 if args.noMonitorPlots:
275 command.append("--noMonitorPlots")
276
277 return command
278
279
281 args: argparse.Namespace,
282 *,
283 threshold: float,
284 n_events: int,
285 out_root: Path,
286) -> list[str]:
287 """Build the bucket-filter reconstruction command for one score point."""
288
289 command = _common_chain_command(args, n_events=n_events, out_root=out_root)
290 command += ["--score-threshold", str(threshold)]
291
292 if args.bucketModelPath:
293 command += ["--bucket-model-path", args.bucketModelPath]
294 if args.outputName:
295 command += ["--output-name", args.outputName]
296 if args.singleOutputMode:
297 command += ["--single-output-mode", args.singleOutputMode]
298 if args.use_cpu:
299 command.append("--use-cpu")
300
301 return command
302
303
305 args: argparse.Namespace,
306 *,
307 n_events: int,
308 out_root: Path,
309) -> list[str]:
310 """Build the stage's no-bucket-filter baseline command."""
311
312 command = _common_chain_command(args, n_events=n_events, out_root=out_root)
313 command.append("--skip-onnx")
314 return command
315
316
317def _write_csv(rows: list[dict[str, Any]], path: Path) -> None:
318 fields = [
319 "stage",
320 "nEvents",
321 "mode",
322 "threshold",
323 "status",
324 "passesTarget",
325 "truthCountMatchesNoMl",
326 "truthMuonCount",
327 "matchedTruthMuonCount",
328 "trackEfficiency",
329 "noMlTruthMuonCount",
330 "noMlMatchedTruthMuonCount",
331 "noMlTrackEfficiency",
332 "relativeTrackEfficiency",
333 "relativeEfficiencyLoss",
334 "minRelativeEfficiency",
335 "rootFile",
336 "log",
337 "returnCode",
338 "command",
339 "error",
340 ]
341 path.parent.mkdir(parents=True, exist_ok=True)
342 with path.open("w", encoding="utf-8", newline="") as output:
343 writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore")
344 writer.writeheader()
345 writer.writerows(rows)
346
347
348def _write_json(payload: dict[str, Any], path: Path) -> None:
349 path.parent.mkdir(parents=True, exist_ok=True)
350 path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
351
352
353def _parse_args() -> argparse.Namespace:
354 parser = argparse.ArgumentParser(
355 description=(
356 "Tune muonBucketRecoChain.py --score-threshold using relative "
357 "track efficiency against a --skip-onnx no-ML baseline per stage."
358 ),
359 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
360 )
361 parser.add_argument(
362 "--inputFile",
363 required=True,
364 help="Input HITS file/list forwarded to muonBucketRecoChain.py.",
365 )
366 parser.add_argument(
367 "--recoChain",
368 default=None,
369 help="Explicit path to muonBucketRecoChain.py. Defaults to a sibling file.",
370 )
371 parser.add_argument(
372 "--recoModule",
373 default="MuonInference.muonBucketRecoChain",
374 help="Module fallback when no local reco-chain source file is present.",
375 )
376 parser.add_argument(
377 "--workDir",
378 default="bucket_filter_threshold_tuning",
379 help="Directory where roots/, logs/, CSV and JSON outputs are written.",
380 )
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)
387 parser.add_argument(
388 "--skipExisting",
389 action="store_true",
390 default=False,
391 help="Reuse an existing ROOT output when its exact point is requested again.",
392 )
393
394 parser.add_argument(
395 "--bucket-model-path",
396 "--bucketModel",
397 dest="bucketModelPath",
398 default=None,
399 help="Optional bucket-filter ONNX model; otherwise use the chain default.",
400 )
401 parser.add_argument(
402 "--output-name",
403 dest="outputName",
404 default=None,
405 help="Optional ONNX output tensor name.",
406 )
407 parser.add_argument(
408 "--single-output-mode",
409 choices=("logit", "prob"),
410 dest="singleOutputMode",
411 default=None,
412 help="Optional scalar ONNX output interpretation.",
413 )
414
415 parser.add_argument(
416 "--minRelativeEfficiency",
417 "--targetRelativeEfficiency",
418 "--targetEfficiency",
419 dest="minRelativeEfficiency",
420 type=float,
421 default=0.995,
422 help=(
423 "A bucket-filter point passes when "
424 "bucketTrackEfficiency / noMlTrackEfficiency is at least this value."
425 ),
426 )
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)
435
436 return parser.parse_args()
437
438
439def _validate_args(args: argparse.Namespace) -> None:
440 if args.threads <= 0:
441 raise SystemExit("--threads must be positive")
442 if args.minThreshold >= args.maxThreshold:
443 raise SystemExit("--minThreshold must be smaller than --maxThreshold")
444 if not 0.0 <= args.minRelativeEfficiency <= 1.0:
445 raise SystemExit("--minRelativeEfficiency must be between 0 and 1")
446 for name in ("coarseEvents", "mediumEvents", "fineEvents"):
447 if getattr(args, name) <= 0:
448 raise SystemExit(f"--{name} must be positive")
449 for name in ("coarseStep", "mediumStep", "fineStep"):
450 if getattr(args, name) <= 0.0:
451 raise SystemExit(f"--{name} must be positive")
452
453
454def main() -> None:
455 args = _parse_args()
456 _validate_args(args)
457
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"
463
464 # Athena/THistSvc will not create parent directories itself.
465 roots_dir.mkdir(parents=True, exist_ok=True)
466 logs_dir.mkdir(parents=True, exist_ok=True)
467
468 stages = (
469 ScanStage("coarse", args.coarseEvents, args.coarseStep),
470 ScanStage("medium", args.mediumEvents, args.mediumStep),
471 ScanStage("fine", args.fineEvents, args.fineStep),
472 )
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] = {}
477
478 def summary(status: str) -> dict[str, Any]:
479 recommended = (
480 best_by_stage.get("fine")
481 or best_by_stage.get("medium")
482 or best_by_stage.get("coarse")
483 )
484 return {
485 "status": status,
486 "inputFile": args.inputFile,
487 "treeName": args.treeName,
488 "workDir": str(work_dir),
489 "recoLauncher": _reco_launcher(args),
490 "target": {
491 "minimumRelativeEfficiency": args.minRelativeEfficiency,
492 "acceptedWhen": (
493 "bucketTrackEfficiency / noMlTrackEfficiency >= "
494 "minimumRelativeEfficiency"
495 ),
496 },
497 "selection": {
498 "denominator": (
499 "len(TruthMuons_truthSegLinks[i]) > 0 and "
500 "abs(TruthMuons_eta[i]) < 2.5"
501 ),
502 "numerator": (
503 "denominator muon with 0 <= TruthMuons_ActsMuonLink[i] < "
504 "len(ActsMuons_pt)"
505 ),
506 "implementation": "PyROOT; equivalent to the notebook's executable code",
507 },
508 "stages": [
509 {"name": stage.name, "nEvents": stage.n_events, "step": stage.step}
510 for stage in stages
511 ],
512 "baselines": baselines,
513 "bestByStage": best_by_stage,
514 "stageNotes": stage_notes,
515 "recommendedThreshold": (
516 None if recommended is None else recommended["threshold"]
517 ),
518 "recommendedResult": recommended,
519 "scanCsv": str(csv_path),
520 }
521
522 def persist(status: str) -> None:
523 _write_csv(rows, csv_path)
524 _write_json(summary(status), summary_path)
525
526 def evaluate_baseline(stage: ScanStage) -> dict[str, Any]:
527 """Run/reuse one no-ML reference output for this stage."""
528
529 if stage.name in baselines:
530 return baselines[stage.name]
531
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"
535 command = _noml_chain_command(args, n_events=stage.n_events, out_root=out_root)
536 row: dict[str, Any] = {
537 "stage": stage.name,
538 "nEvents": stage.n_events,
539 "mode": "noml",
540 "threshold": "",
541 "rootFile": str(out_root),
542 "log": str(out_log),
543 "command": " ".join(command),
544 }
545
546 if not args.skipExisting or not out_root.is_file():
547 return_code = _run(command, out_log)
548 row["returnCode"] = return_code
549 if return_code != 0:
550 row.update({
551 "status": "failed",
552 "error": f"no-ML reconstruction returned {return_code}",
553 })
554 rows.append(row)
555 persist("failed")
556 raise RuntimeError(
557 f"No-ML baseline failed for stage={stage.name} with rc={return_code}. "
558 f"See {out_log}"
559 )
560 else:
561 row["returnCode"] = "reused"
562
563 if not out_root.is_file():
564 row.update({
565 "status": "missing_output",
566 "error": "no-ML ROOT output is missing after reconstruction",
567 })
568 rows.append(row)
569 persist("failed")
570 raise RuntimeError(
571 f"No-ML ROOT output is missing for stage={stage.name}: {out_root}"
572 )
573
574 try:
575 metrics = _read_notebook_efficiency(out_root, args.treeName)
576 except Exception as error:
577 row.update({"status": "evaluation_failed", "error": str(error)})
578 rows.append(row)
579 persist("failed")
580 raise
581
582 row.update(metrics)
583 row.update({
584 "noMlTruthMuonCount": metrics["truthMuonCount"],
585 "noMlMatchedTruthMuonCount": metrics["matchedTruthMuonCount"],
586 "noMlTrackEfficiency": metrics["trackEfficiency"],
587 "status": "ok",
588 })
589 rows.append(row)
590 baselines[stage.name] = row
591 persist("running")
592 print(
593 f"[{stage.name:6s} | {stage.n_events:4d} events] "
594 f"no-ML efficiency={metrics['trackEfficiency']:.6f} "
595 f"({metrics['matchedTruthMuonCount']}/{metrics['truthMuonCount']})",
596 flush=True,
597 )
598 return row
599
600 def evaluate_bucket(stage: ScanStage, threshold: float) -> dict[str, Any]:
601 """Run/reuse and compare one bucket-filter threshold point."""
602
603 baseline = evaluate_baseline(stage)
604
605 tag = (
606 f"{stage.name}_events{stage.n_events:04d}_"
607 f"threshold{_format_threshold(threshold)}"
608 )
609 out_root = roots_dir / f"{tag}.root"
610 out_log = logs_dir / f"{tag}.log"
611 command = _bucket_chain_command(
612 args,
613 threshold=threshold,
614 n_events=stage.n_events,
615 out_root=out_root,
616 )
617 row: dict[str, Any] = {
618 "stage": stage.name,
619 "nEvents": stage.n_events,
620 "mode": "bucket",
621 "threshold": threshold,
622 "minRelativeEfficiency": args.minRelativeEfficiency,
623 "rootFile": str(out_root),
624 "log": str(out_log),
625 "command": " ".join(command),
626 }
627
628 if not args.skipExisting or not out_root.is_file():
629 return_code = _run(command, out_log)
630 row["returnCode"] = return_code
631 if return_code != 0:
632 row.update({
633 "status": "failed",
634 "error": f"bucket reconstruction returned {return_code}",
635 })
636 rows.append(row)
637 persist("failed")
638 raise RuntimeError(
639 f"Reconstruction failed at stage={stage.name}, threshold={threshold} "
640 f"with rc={return_code}. See {out_log}"
641 )
642 else:
643 row["returnCode"] = "reused"
644
645 if not out_root.is_file():
646 row.update({
647 "status": "missing_output",
648 "error": "bucket ROOT output is missing after reconstruction",
649 })
650 rows.append(row)
651 persist("failed")
652 raise RuntimeError(
653 f"ROOT output is missing for stage={stage.name}, threshold={threshold}: "
654 f"{out_root}"
655 )
656
657 try:
658 metrics = _read_notebook_efficiency(out_root, args.treeName)
659 except Exception as error:
660 row.update({"status": "evaluation_failed", "error": str(error)})
661 rows.append(row)
662 persist("failed")
663 raise
664
665 noml_efficiency = float(baseline["trackEfficiency"])
666 if noml_efficiency <= 0.0:
667 row.update({
668 "status": "evaluation_failed",
669 "error": "no-ML baseline has zero track efficiency",
670 })
671 rows.append(row)
672 persist("failed")
673 raise RuntimeError(
674 f"No-ML efficiency is zero in stage={stage.name}; cannot calculate "
675 "relative efficiency."
676 )
677
678 relative_efficiency = float(metrics["trackEfficiency"]) / noml_efficiency
679 row.update(metrics)
680 row.update({
681 "noMlTruthMuonCount": baseline["truthMuonCount"],
682 "noMlMatchedTruthMuonCount": baseline["matchedTruthMuonCount"],
683 "noMlTrackEfficiency": noml_efficiency,
684 "truthCountMatchesNoMl": (
685 int(metrics["truthMuonCount"]) == int(baseline["truthMuonCount"])
686 ),
687 "relativeTrackEfficiency": relative_efficiency,
688 "relativeEfficiencyLoss": max(0.0, 1.0 - relative_efficiency),
689 "passesTarget": relative_efficiency >= args.minRelativeEfficiency,
690 "status": "ok",
691 })
692 rows.append(row)
693 persist("running")
694 print(
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'}",
701 flush=True,
702 )
703 return row
704
705 def adaptive_scan(
706 stage: ScanStage,
707 start_threshold: float,
708 ) -> tuple[dict[str, Any] | None, str]:
709 """Find the highest passing point, with first-point recovery downward."""
710
711 first = evaluate_bucket(stage, start_threshold)
712 if bool(first["passesTarget"]):
713 best = first
714 for threshold in _ascending_thresholds(
715 start_threshold,
716 args.maxThreshold,
717 stage.step,
718 include_start=False,
719 ):
720 candidate = evaluate_bucket(stage, threshold)
721 if not bool(candidate["passesTarget"]):
722 return best, "stopped_at_first_below_target"
723 best = candidate
724 return best, "reached_upper_threshold_bound"
725
726 # At the larger sample, the previous stage's value may already be too
727 # high. Walk down until the nearest passing value is recovered.
728 for threshold in _descending_thresholds(
729 start_threshold,
730 args.minThreshold,
731 stage.step,
732 include_start=False,
733 ):
734 candidate = evaluate_bucket(stage, threshold)
735 if bool(candidate["passesTarget"]):
736 return candidate, "recovered_by_lowering_threshold"
737
738 return None, "no_passing_threshold_in_range"
739
740 try:
741 coarse, medium, fine = stages
742
743 # The score cut is assumed to become no more permissive as it rises.
744 # Therefore the coarse stage stops as soon as the first failing point is
745 # seen; the last passing point is the seed for the 100-event scan.
746 coarse_best: dict[str, Any] | None = None
747 coarse_note = "reached_upper_threshold_bound"
748 for threshold in _ascending_thresholds(
749 args.minThreshold,
750 args.maxThreshold,
751 coarse.step,
752 ):
753 candidate = evaluate_bucket(coarse, threshold)
754 if not bool(candidate["passesTarget"]):
755 coarse_note = "stopped_at_first_below_target"
756 break
757 coarse_best = candidate
758
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")
763 raise RuntimeError(
764 "No coarse threshold met the relative-efficiency target. "
765 "Lower --minRelativeEfficiency or extend --minThreshold."
766 )
767
768 best_by_stage[coarse.name] = coarse_best
769 stage_notes[coarse.name] = coarse_note
770 persist("running")
771
772 medium_best, medium_note = adaptive_scan(
773 medium,
774 float(coarse_best["threshold"]),
775 )
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")
780 raise RuntimeError(
781 "No medium-stage threshold met the relative-efficiency target."
782 )
783 persist("running")
784
785 fine_best, fine_note = adaptive_scan(
786 fine,
787 float(medium_best["threshold"]),
788 )
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")
793 raise RuntimeError(
794 "No fine-stage threshold met the relative-efficiency target."
795 )
796
797 except KeyboardInterrupt:
798 persist("interrupted")
799 raise SystemExit(
800 f"Interrupted. Partial results were saved under {work_dir}."
801 )
802 except Exception:
803 persist("failed")
804 raise
805
806 persist("completed")
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}")
811
812
813if __name__ == "__main__":
814 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)
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)
list[str] _bucket_chain_command(argparse.Namespace args, *, float threshold, int n_events, Path out_root)
None _validate_args(argparse.Namespace args)