Sensitivity and Discovery Potential¶
This tutorial demonstrates how to estimate the sensitivity and discovery potential of a point-source analysis using SkyLLH.
Sensitivity (90 % CL): the mean injected signal μ_s such that 90 % of all signal trials produce a TS value larger than the median background TS (
h0_ts_quantile=0.5,p=0.9).5σ Discovery potential: μ_s such that 50 % of all signal trials exceed the critical TS corresponding to a 5σ one-sided Gaussian probability (2.87e-7).
Both quantities are then converted to a flux normalization using ana.mu2flux.
Compute time note: estimating sensitivity requires ≥ 10 000 background trials and several hundred signal trials per μ_s iteration — expect tens of minutes on a laptop. The cells below use
eps_p=0.05and a narrowmu_rangeto keep the tutorial run-time manageable while still illustrating the full workflow.
[ ]:
import numpy as np
from matplotlib import pyplot as plt
Setup¶
The setup mirrors fitting_a_source.ipynb: load the 14-year dataset and define NGC 1068 as the source.
[ ]:
from skyllh.analyses.i3.publicdata_ps.time_integrated_ps import create_analysis
from skyllh.core.config import Config
from skyllh.core.random import RandomStateService
from skyllh.core.source_model import PointLikeSource
from skyllh.datasets.i3.PublicData_14y_ps import create_dataset_collection
cfg = Config()
dsc = create_dataset_collection(cfg=cfg)
datasets = dsc['IC40', 'IC59', 'IC79', 'IC86_I-XI']
src_ra = 40.67 # degrees
src_dec = -0.01 # degrees
source = PointLikeSource(ra=np.radians(src_ra), dec=np.radians(src_dec))
ana = create_analysis(cfg=cfg, datasets=datasets, source=source)
Background trials¶
Generate background-only trials to characterise the null-hypothesis TS distribution and check that it peaks at zero (as expected for a well-behaved analysis).
The estimate_sensitivity function accepts pre-generated background trials via its h0_trials argument. If fewer trials are provided than what its internal precision criterion requires (≈ 10 000 for the default 0.5 quantile and eps=0.005), it will automatically extend them — so the 500 trials generated here serve as a starting point.
[ ]:
rss = RandomStateService(seed=1)
bg_trials = ana.do_trials(rss=rss, n=500)
fig, ax = plt.subplots(figsize=(6, 4))
ax.hist(bg_trials['ts'], bins=30, label='Background trials (n=500)')
ax.set_xlabel('Test statistic')
ax.set_ylabel('Counts')
ax.set_title('Background TS distribution — peaks at 0 as expected')
ax.legend()
plt.tight_layout()
plt.show()
Sensitivity estimate¶
estimate_sensitivity finds the mean number of signal events μ_s such that 90 % of signal trials (injecting μ_s events on average) yield TS > TS_critical, where TS_critical is defined by the 50th percentile of the background distribution.
Key parameters:
h0_trials— pre-generated background trials (the function auto-extends if needed)h0_ts_quantile=0.5— median background TS defines the critical valuep=0.9— 90 % of signal trials must exceed the critical TSeps_p=0.05— convergence tolerance onp(default 0.005 gives tighter results but needs more trials)mu_range=(0, 15)— search range for μ_s
[ ]:
from skyllh.core.utils.analysis import estimate_sensitivity
rss = RandomStateService(seed=2)
(mu_sens, mu_sens_err) = estimate_sensitivity(
ana=ana,
rss=rss,
h0_trials=bg_trials,
h0_ts_quantile=0.5,
p=0.9,
eps_p=0.05,
mu_range=(0, 15),
)
flux_sens = ana.mu2flux(mu_sens)
print(f'90% CL sensitivity: mu_s = {mu_sens:.2f} signal events')
print(f'Flux sensitivity at E0=1 TeV: {flux_sens:.2e} GeV^-1 cm^-2 s^-1')
Discovery potential¶
estimate_discovery_potential has the same interface as estimate_sensitivity but targets a different operating point: 50 % of signal trials must exceed the TS corresponding to a 5σ one-sided Gaussian probability (h0_ts_quantile=2.8665e-7, p=0.5).
Because the 5σ quantile requires very many background trials to determine directly from the tail of the TS distribution, the function automatically falls back to a truncated-gamma fit when the required trial count exceeds 500 000. This makes the background-trial requirement tractable (≈ 500 000 trials), but still makes discovery potential much slower to compute than sensitivity — expect hours of compute for a production result.
The cell below demonstrates the API; set run_discovery = True to actually execute it.
[ ]:
from skyllh.core.utils.analysis import estimate_discovery_potential
run_discovery = False # set to True to run (takes significant compute time)
if run_discovery:
rss = RandomStateService(seed=3)
(mu_disc, mu_disc_err) = estimate_discovery_potential(
ana=ana,
rss=rss,
h0_ts_quantile=2.8665e-7,
p=0.5,
eps_p=0.05,
mu_range=(0, 50),
)
flux_disc = ana.mu2flux(mu_disc)
print(f'5 sigma discovery potential: mu_s = {mu_disc:.2f} signal events')
print(f'Discovery flux at E0=1 TeV: {flux_disc:.2e} GeV^-1 cm^-2 s^-1')
else:
print('Discovery potential calculation skipped (set run_discovery=True to enable).')