Warning, /detector_benchmarks/benchmarks/backwards_ecal/backwards_ecal.org is written in an unsupported language. File is not indexed.
0001 #+PROPERTY: header-args:jupyter-python :session /jpy:localhost#8888:backwards_ecal :async yes :results drawer :exports both
0002
0003 #+TITLE: ePIC EEEMCal benchmark
0004 #+AUTHOR: Dmitry Kalinkin
0005 #+OPTIONS: d:t
0006
0007 #+LATEX_CLASS_OPTIONS: [9pt,letter]
0008 #+BIND: org-latex-image-default-width ""
0009 #+BIND: org-latex-image-default-option "scale=0.3"
0010 #+BIND: org-latex-images-centered nil
0011 #+BIND: org-latex-minted-options (("breaklines") ("bgcolor" "black!5") ("frame" "single"))
0012 #+LATEX_HEADER: \usepackage[margin=1in]{geometry}
0013 #+LATEX_HEADER: \setlength{\parindent}{0pt}
0014 #+LATEX: \sloppy
0015
0016 #+begin_src jupyter-python :results silent
0017 import os
0018 from pathlib import Path
0019
0020 import awkward as ak
0021 import boost_histogram as bh
0022 import dask_histogram as dh
0023 import numpy as np
0024 import uproot
0025 import vector
0026 #+end_src
0027
0028 #+begin_src jupyter-python :results silent
0029 from dask.distributed import Client
0030
0031 client = Client(os.environ.get("DASK_SCHEDULER", "localhost:8786"))
0032 #+end_src
0033
0034 #+begin_src jupyter-python
0035 vector.register_awkward()
0036
0037 from dask.distributed import WorkerPlugin
0038 class VectorImporter(WorkerPlugin):
0039 idempotent=True
0040
0041 def setup(self, worker):
0042 import vector
0043
0044 vector.register_awkward()
0045
0046 client.register_plugin(VectorImporter())
0047 #+end_src
0048
0049 * Plotting setup
0050
0051 #+begin_src jupyter-python :results silent
0052 import matplotlib as mpl
0053 import matplotlib.pyplot as plt
0054
0055 def setup_presentation_style():
0056 mpl.rcParams.update(mpl.rcParamsDefault)
0057 if int(os.environ.get("USE_PUBLICATION_STYLE", "0")):
0058 import mplhep
0059 plt.style.use([mplhep.style.CMSTex])
0060 plt.rcParams.update({
0061 "text.usetex": True,
0062 "savefig.pad_inches": 0.,
0063 "text.latex.preamble": r"\usepackage{amsmath}",
0064 "pgf.rcfonts": False,
0065 "font.family": "serif",
0066 "font.serif": [],
0067 "font.sans-serif": [],
0068 "mathtext.fontset": "cm",
0069 "figure.figsize": (3.4, 3.4),
0070 "font.size": 10,
0071 "lines.linewidth": 0.25,
0072 "xtick.major.width": 0.4,
0073 "xtick.minor.width": 0.3,
0074 "xtick.major.size": 6.0,
0075 "xtick.minor.size": 3.0,
0076 "ytick.major.width": 0.4,
0077 "ytick.minor.width": 0.3,
0078 "ytick.major.size": 6.0,
0079 "ytick.minor.size": 3.0,
0080 "axes.linewidth": 0.5,
0081 "lines.markersize": 5,
0082 "hatch.linewidth": 0.5
0083 })
0084 else:
0085 plt.style.use("ggplot")
0086 plt.rcParams.update({
0087 "axes.labelsize": 8,
0088 "axes.titlesize": 9,
0089 "figure.titlesize": 9,
0090 "figure.figsize": (4, 3),
0091 "legend.fontsize": 7,
0092 "xtick.labelsize": 8,
0093 "ytick.labelsize": 8,
0094 "xaxis.labellocation": "right",
0095 "yaxis.labellocation": "top",
0096 "pgf.rcfonts": False,
0097 })
0098
0099
0100 setup_presentation_style()
0101 #+end_src
0102
0103 * Parameters
0104
0105 #+begin_src jupyter-python :results silent
0106 DETECTOR_CONFIG=os.environ.get("DETECTOR_CONFIG")
0107 PLOT_TITLE=os.environ.get("PLOT_TITLE")
0108 INPUT_PATH_FORMAT=os.environ.get("INPUT_PATH_FORMAT", "EPIC/RECO/24.04.0/epic_craterlake/SINGLE/{particle}/{energy}/130to177deg/{particle}_{energy}_130to177deg.{ix:04d}.eicrecon.edm4eic.root")
0109
0110 output_dir=Path(os.environ.get("OUTPUT_DIR", "./"))
0111 output_dir.mkdir(parents=True, exist_ok=True)
0112 #+end_src
0113
0114 * Analysis
0115
0116 First, we define a requirement on what phase we will consider for our
0117 analysis. The following function filters single-particle events that
0118 are thrown within $-3.5 < \eta < -2.0$:
0119
0120 #+begin_src jupyter-python
0121 def filter_pointing(events):
0122 part_momentum = ak.zip({
0123 "m": events["MCParticles.mass"],
0124 "px": events["MCParticles.momentum.x"],
0125 "py": events["MCParticles.momentum.y"],
0126 "pz": events["MCParticles.momentum.z"],
0127 }, with_name="Momentum4D")
0128 cond = (part_momentum.eta[:,0] > -3.5) & (part_momentum.eta[:,0] < -2.)
0129 return events[cond]
0130 #+end_src
0131
0132 #+begin_src jupyter-python
0133 energies = [
0134 "100MeV",
0135 "200MeV",
0136 "500MeV",
0137 "1GeV",
0138 "2GeV",
0139 "5GeV",
0140 "10GeV",
0141 "20GeV",
0142 ]
0143 filter_name = [
0144 "MCParticles.*",
0145 "EcalEndcapNClusters.energy",
0146 ]
0147
0148 pi_eval = {}
0149 e_eval = {}
0150
0151 def readlist(path):
0152 with open(path, "rt") as fp:
0153 paths = [line.rstrip() for line in fp.readlines()]
0154 return paths
0155
0156 for energy in energies:
0157 pi_eval[energy] = uproot.dask(
0158 {path: "events" for path in readlist(INPUT_PATH_FORMAT.format(particle="pi-", energy=energy))},
0159 filter_name=filter_name,
0160 steps_per_file=1,
0161 open_files=False,
0162 ).map_partitions(filter_pointing)
0163 e_eval[energy] = uproot.dask(
0164 {path: "events" for path in readlist(INPUT_PATH_FORMAT.format(particle="e-", energy=energy))},
0165 filter_name=filter_name,
0166 steps_per_file=1,
0167 open_files=False,
0168 ).map_partitions(filter_pointing)
0169 #+end_src
0170
0171 ** Energy resolution
0172
0173 The first step in the analysis is plotting distributions of cluster energies.
0174 Only electron distribution is needed, but we also do pi- for the pion rejection
0175 study.
0176
0177 #+begin_src jupyter-python
0178 e_over_p_hist = {}
0179 e_over_p_axis = bh.axis.Regular(110, 0., 1.10)
0180
0181 for ix, energy in enumerate(energies):
0182 for particle_name, dataset in [("e-", e_eval[energy]), ("pi-", pi_eval[energy])]:
0183 energy_value = float(energy.replace("GeV", "").replace("MeV", "e-3"))
0184 def clf(events):
0185 return ak.drop_none(ak.max(events["EcalEndcapNClusters.energy"], axis=-1)) / energy_value
0186 e_over_p_hist[(particle_name, energy)] = dh.factory(
0187 clf(dataset),
0188 axes=(e_over_p_axis,),
0189 )
0190
0191 e_over_p_hist = client.gather(client.compute(e_over_p_hist))
0192 #+end_src
0193
0194 The next step is to plot the histograms together with fits and produce a plot
0195 for resolution vs energy that summarizes them.
0196
0197 #+begin_src jupyter-python
0198 fig, axs = plt.subplots(2, 4, sharex=True, sharey=True, figsize=(15, 6))
0199
0200 axs = np.ravel(np.array(axs))
0201
0202 sigmas_rel_FWHM_cb = {}
0203 fractions_below = {}
0204
0205 def norm_by_sum(arr):
0206 return arr / np.sum(arr)
0207
0208 for ix, energy in enumerate(energies):
0209 plt.sca(axs[ix])
0210 hist = e_over_p_hist[("e-", energy)]
0211 patch = plt.stairs(norm_by_sum(hist.values()), hist.axes[0].edges, label=rf"$e^-$ {PLOT_TITLE}")
0212 plt.title(f"{energy}")
0213
0214 import scipy.stats
0215 def f(x, n, beta, m, loc, scale):
0216 return n * scipy.stats.crystalball.pdf(x, beta, m, loc, scale)
0217 n0 = np.sum(hist.values()[10:])
0218 loc0 = hist.axes[0].centers[5:][np.argmax(hist.values()[5:])]
0219 p0 = (n0, 2., 3., loc0, 0.05)
0220
0221 try:
0222 import scipy.optimize
0223 par, pcov = scipy.optimize.curve_fit(f, hist.axes[0].centers[5:], hist.values()[5:], p0=p0, maxfev=10000)
0224 except RuntimeError:
0225 print("Initial parameters:", p0)
0226 print(hist)
0227 raise
0228 plt.plot(hist.axes[0].centers, f(hist.axes[0].centers, *par), label=rf"Crystal Ball fit", color="tab:green", lw=0.8)
0229
0230 def summarize_fit(par):
0231 _, _, _, loc_cb, scale_cb = par
0232 # Calculate FWHM
0233 y_max = np.max(f(np.linspace(0., 1., 100), *par))
0234 f_prime = lambda x: f(x, *par) - y_max / 2
0235 x_plus, = scipy.optimize.root(f_prime, loc_cb + scale_cb).x
0236 x_minus, = scipy.optimize.root(f_prime, loc_cb - scale_cb).x
0237 plt.axvline(x_minus, ls="--", lw=0.75, color=patch.get_facecolor(), label=r"$\mu - $FWHM")
0238 plt.axvline(x_plus, ls=":", lw=0.75, color=patch.get_facecolor(), label=r"$\mu + $FWHM")
0239 fwhm = (x_plus - x_minus) / loc_cb
0240 sigma_rel_FWHM_cb = fwhm / 2 / np.sqrt(2 * np.log(2))
0241
0242 cutoff_x = loc_cb - fwhm
0243 fraction_below = np.sum(hist.values()[hist.axes[0].centers < cutoff_x]) / np.sum(hist.values())
0244
0245 return sigma_rel_FWHM_cb, fraction_below
0246
0247 sigma_rel_FWHM_cb, fraction_below = summarize_fit(par)
0248 sigmas_rel_FWHM_cb.setdefault(PLOT_TITLE, {})[energy] = sigma_rel_FWHM_cb
0249 fractions_below.setdefault(PLOT_TITLE, {})[energy] = fraction_below
0250
0251 plt.legend()
0252 plt.xlabel("$E/p$", loc="right")
0253 plt.ylabel("Event yield", loc="top")
0254
0255 fig.savefig(output_dir / f"resolution_plots.pdf", bbox_inches="tight")
0256 fig.savefig(output_dir / f"resolution_plots.png", bbox_inches="tight")
0257 plt.show()
0258 plt.close(fig)
0259
0260 plt.figure()
0261 energy_values = np.array([float(energy.replace("GeV", "").replace("MeV", "e-3")) for energy in energies])
0262
0263 for clf_label, sigma_rel_FWHM_cb in sigmas_rel_FWHM_cb.items():
0264 sigma_over_e = np.array([sigma_rel_FWHM_cb[energy] for energy in energies]) * 100 # convert to %
0265
0266 def f(energy, stochastic, constant):
0267 return np.sqrt((stochastic / np.sqrt(energy)) ** 2 + constant ** 2)
0268 cond = energy_values >= 0.5
0269 try:
0270 import scipy.optimize
0271 par, pcov = scipy.optimize.curve_fit(f, energy_values[cond], sigma_over_e[cond], maxfev=10000)
0272 except RuntimeError:
0273 print("energy_values", energy_values[cond])
0274 print("sigma_over_e", sigma_over_e[cond])
0275 raise
0276 stochastic, constant = par
0277
0278 plt.plot(
0279 energy_values,
0280 sigma_over_e,
0281 marker=".",
0282 ls="none",
0283 label=f"{clf_label}"
0284 )
0285 xmin = np.min(energy_values[cond])
0286 xmax = np.max(energy_values[cond])
0287 xs = np.arange(xmin, xmax, 0.1)
0288 plt.plot(
0289 xs,
0290 f(xs, *par),
0291 ls="--",
0292 lw=0.5,
0293 label=fr"Functional fit: ${np.ceil(stochastic * 10) / 10:.1f}\% / \sqrt{{E}} \oplus {np.ceil(constant * 10) / 10:.1f}\%$",
0294 )
0295 xmin = np.min(energy_values)
0296 xmax = np.max(energy_values) * 1.05
0297 xs = np.arange(xmin, xmax + 0.1, 0.1)
0298 plt.fill_between(
0299 xs,
0300 np.sqrt((2 / np.sqrt(xs)) ** 2 + 1 ** 2),
0301 np.sqrt((3 / np.sqrt(xs)) ** 2 + 2 ** 2),
0302 lw=0., alpha=0.2, color="black", label=r"YR requirement $(2-3)\% / \sqrt{E} \oplus (1-2)\%$",
0303 )
0304 plt.xlim(0., xmax)
0305 plt.ylim(top=6.)
0306 plt.legend()
0307 plt.xlabel("Energy, GeV", loc="right")
0308 plt.ylabel(r"$\sigma_{E} / E$ derived from FWHM, \%", loc="top")
0309 plt.savefig(output_dir / f"resolution.pdf", bbox_inches="tight")
0310 plt.savefig(output_dir / f"resolution.png", bbox_inches="tight")
0311 plt.show()
0312 #+end_src
0313
0314 ** Pion rejection
0315
0316 #+begin_src jupyter-python
0317 fig, axs = plt.subplots(2, 4, sharex=True, sharey=True, figsize=(15, 6))
0318 fig_log, axs_log = plt.subplots(2, 4, sharex=True, sharey=True, figsize=(15, 6))
0319 fig_roc, axs_roc = plt.subplots(2, 4, sharex=True, sharey=True, figsize=(15, 6))
0320
0321 axs = np.ravel(np.array(axs))
0322 axs_log = np.ravel(np.array(axs_log))
0323 axs_roc = np.ravel(np.array(axs_roc))
0324
0325 rocs = {}
0326
0327 for ix, energy in enumerate(energies):
0328 energy_value = float(energy.replace("GeV", "").replace("MeV", "e-3"))
0329 def clf(events):
0330 return ak.drop_none(ak.max(events["EcalEndcapNClusters.energy"], axis=-1)) / energy_value
0331 e_pred = clf(e_eval[energy])
0332 pi_pred = clf(pi_eval[energy])
0333
0334 for do_log, ax in [(False, axs[ix]), (True, axs_log[ix])]:
0335 plt.sca(ax)
0336 patch = plt.stairs(norm_by_sum(e_over_p_hist[("e-", energy)].values()), e_over_p_hist[("e-", energy)].axes[0].edges, label=rf"$e^-$ {PLOT_TITLE}")
0337 patch = plt.stairs(norm_by_sum(e_over_p_hist[("pi-", energy)].values()), e_over_p_hist[("pi-", energy)].axes[0].edges, label=rf"$\pi^-$ {PLOT_TITLE}")
0338 plt.title(f"{energy}")
0339 plt.legend()
0340 plt.xlabel("Classifier output")
0341 plt.ylabel("Event yield")
0342 if do_log:
0343 plt.yscale("log")
0344
0345 plt.sca(axs_roc[ix])
0346 fpr = np.cumsum(e_over_p_hist[("pi-", energy)].values()[::-1])
0347 fpr /= fpr[-1]
0348 tpr = np.cumsum(e_over_p_hist[("e-", energy)].values()[::-1])
0349 tpr /= tpr[-1]
0350 cond = fpr != 0 # avoid infinite rejection (region of large uncertainty)
0351 cond &= tpr != 1 # avoid linear interpolation (region of large uncertainty)
0352 def mk_interp(tpr, fpr):
0353 def interp(eff):
0354 return np.interp(eff, tpr, fpr)
0355 return interp
0356 rocs.setdefault(PLOT_TITLE, {})[energy] = mk_interp(tpr, fpr)
0357 plt.plot(tpr[cond] * 100, 1 / fpr[cond], label=f"{PLOT_TITLE}")
0358 plt.yscale("log")
0359 plt.title(f"{energy}")
0360 plt.legend(loc="lower left")
0361 plt.xlabel("Electron efficiency, %")
0362 plt.ylabel("Pion rejection factor")
0363
0364 fig.savefig(output_dir / f"pred.pdf", bbox_inches="tight")
0365 fig.savefig(output_dir / f"pred.png", bbox_inches="tight")
0366 plt.close(fig)
0367 fig_log.savefig(output_dir / f"pred_log.pdf", bbox_inches="tight")
0368 fig_log.savefig(output_dir / f"pred_log.png", bbox_inches="tight")
0369 fig_log.show()
0370 fig_roc.savefig(output_dir / f"roc.pdf", bbox_inches="tight")
0371 fig_roc.savefig(output_dir / f"roc.png", bbox_inches="tight")
0372 fig_roc.show()
0373
0374 plt.figure()
0375 for clf_label, roc in rocs.items():
0376 plt.plot(
0377 [float(energy.replace("GeV", "").replace("MeV", "e-3")) for energy in energies],
0378 [1 / roc[energy](0.95) for energy in energies],
0379 marker=".",
0380 label=f"{clf_label}",
0381 )
0382 xmax = np.max(energy_values) * 1.05
0383 plt.xlim(0., xmax)
0384 plt.yscale("log")
0385 plt.legend()
0386 plt.xlabel("Energy, GeV")
0387 plt.ylabel(r"Pion rejection at 95\%")
0388 plt.savefig(output_dir / f"pion_rej.pdf", bbox_inches="tight")
0389 plt.savefig(output_dir / f"pion_rej.png", bbox_inches="tight")
0390 plt.show()
0391 #+end_src