Skip to content

Commit d8814cc

Browse files
refactor: Factor out tmu-like and qmu-like test statistics (#1005)
* Add _tmu_like and _qmu_like functions to separate behavior * Add qmu, qmu_tilde, tmu, and tmu_tilde functions for more explicit API * Add functions to the docs * Add tests for new API and warnings raised
1 parent 4ef9bfc commit d8814cc

File tree

5 files changed

+313
-19
lines changed

5 files changed

+313
-19
lines changed

docs/api.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ Inference
122122

123123
hypotest
124124
test_statistics.qmu
125+
test_statistics.qmu_tilde
126+
test_statistics.tmu
127+
test_statistics.tmu_tilde
125128
mle.twice_nll
126129
mle.fit
127130
mle.fixed_poi_fit

src/pyhf/infer/calculators.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"""
1010
from .mle import fixed_poi_fit
1111
from .. import get_backend
12-
from .test_statistics import qmu
12+
from .test_statistics import qmu, qmu_tilde
1313

1414

1515
def generate_asimov_data(asimov_mu, data, pdf, init_pars, par_bounds):
@@ -155,14 +155,21 @@ def teststatistic(self, poi_test):
155155
156156
"""
157157
tensorlib, _ = get_backend()
158-
qmu_v = qmu(poi_test, self.data, self.pdf, self.init_pars, self.par_bounds)
158+
159+
teststat_func = qmu_tilde if self.qtilde else qmu
160+
161+
qmu_v = teststat_func(
162+
poi_test, self.data, self.pdf, self.init_pars, self.par_bounds
163+
)
159164
sqrtqmu_v = tensorlib.sqrt(qmu_v)
160165

161166
asimov_mu = 0.0
162167
asimov_data = generate_asimov_data(
163168
asimov_mu, self.data, self.pdf, self.init_pars, self.par_bounds
164169
)
165-
qmuA_v = qmu(poi_test, asimov_data, self.pdf, self.init_pars, self.par_bounds)
170+
qmuA_v = teststat_func(
171+
poi_test, asimov_data, self.pdf, self.init_pars, self.par_bounds
172+
)
166173
self.sqrtqmuA_v = tensorlib.sqrt(qmuA_v)
167174

168175
if not self.qtilde: # qmu

src/pyhf/infer/test_statistics.py

Lines changed: 179 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,51 @@
22
from .mle import fixed_poi_fit, fit
33
from ..exceptions import UnspecifiedPOI
44

5+
import logging
6+
7+
log = logging.getLogger(__name__)
8+
9+
10+
def _qmu_like(mu, data, pdf, init_pars, par_bounds):
11+
"""
12+
Clipped version of _tmu_like where the returned test statistic
13+
is 0 if muhat > 0 else tmu_like_stat.
14+
15+
If the lower bound of the POI is 0 this automatically implments
16+
qmu_tilde. Otherwise this is qmu (no tilde).
17+
"""
18+
tensorlib, optimizer = get_backend()
19+
tmu_like_stat, (_, muhatbhat) = _tmu_like(
20+
mu, data, pdf, init_pars, par_bounds, return_fitted_pars=True
21+
)
22+
qmu_like_stat = tensorlib.where(
23+
muhatbhat[pdf.config.poi_index] > mu, tensorlib.astensor(0.0), tmu_like_stat
24+
)
25+
return qmu_like_stat
26+
27+
28+
def _tmu_like(mu, data, pdf, init_pars, par_bounds, return_fitted_pars=False):
29+
"""
30+
Basic Profile Likelihood test statistic.
31+
32+
If the lower bound of the POI is 0 this automatically implments
33+
tmu_tilde. Otherwise this is tmu (no tilde).
34+
"""
35+
tensorlib, optimizer = get_backend()
36+
mubhathat, fixed_poi_fit_lhood_val = fixed_poi_fit(
37+
mu, data, pdf, init_pars, par_bounds, return_fitted_val=True
38+
)
39+
muhatbhat, unconstrained_fit_lhood_val = fit(
40+
data, pdf, init_pars, par_bounds, return_fitted_val=True
41+
)
42+
log_likelihood_ratio = fixed_poi_fit_lhood_val - unconstrained_fit_lhood_val
43+
tmu_like_stat = tensorlib.astensor(
44+
tensorlib.clip(log_likelihood_ratio, 0.0, max_value=None)
45+
)
46+
if return_fitted_pars:
47+
return tmu_like_stat, (mubhathat, muhatbhat)
48+
return tmu_like_stat
49+
550

651
def qmu(mu, data, pdf, init_pars, par_bounds):
752
r"""
@@ -30,8 +75,9 @@ def qmu(mu, data, pdf, init_pars, par_bounds):
3075
>>> test_mu = 1.0
3176
>>> init_pars = model.config.suggested_init()
3277
>>> par_bounds = model.config.suggested_bounds()
78+
>>> par_bounds[model.config.poi_index] = [-10.0, 10.0]
3379
>>> pyhf.infer.test_statistics.qmu(test_mu, data, model, init_pars, par_bounds)
34-
3.938244920380498
80+
array(3.9549891)
3581
3682
Args:
3783
mu (Number or Tensor): The signal strength parameter
@@ -47,16 +93,136 @@ def qmu(mu, data, pdf, init_pars, par_bounds):
4793
raise UnspecifiedPOI(
4894
'No POI is defined. A POI is required for profile likelihood based test statistics.'
4995
)
96+
if par_bounds[pdf.config.poi_index][0] == 0:
97+
log.warning(
98+
'qmu test statistic used for fit configuration with POI bounded at zero.\n'
99+
+ 'Use the qmu_tilde test statistic (pyhf.infer.test_statistics.qmu_tilde) instead.'
100+
)
101+
return _qmu_like(mu, data, pdf, init_pars, par_bounds)
50102

51-
tensorlib, optimizer = get_backend()
52-
mubhathat, fixed_poi_fit_lhood_val = fixed_poi_fit(
53-
mu, data, pdf, init_pars, par_bounds, return_fitted_val=True
54-
)
55-
muhatbhat, unconstrained_fit_lhood_val = fit(
56-
data, pdf, init_pars, par_bounds, return_fitted_val=True
57-
)
58-
qmu = fixed_poi_fit_lhood_val - unconstrained_fit_lhood_val
59-
qmu = tensorlib.where(
60-
muhatbhat[pdf.config.poi_index] > mu, tensorlib.astensor(0.0), qmu
61-
)
62-
return tensorlib.clip(qmu, 0, max_value=None)
103+
104+
def qmu_tilde(mu, data, pdf, init_pars, par_bounds):
105+
r"""
106+
The test statistic, :math:`\tilde{q}_{\mu}`, for establishing an upper
107+
limit on the strength parameter, :math:`\mu`, for models with
108+
bounded POI, as defiend in Equation (16) in :xref:`arXiv:1007.1727`.
109+
110+
Example:
111+
>>> import pyhf
112+
>>> pyhf.set_backend("numpy")
113+
>>> model = pyhf.simplemodels.hepdata_like(
114+
... signal_data=[12.0, 11.0], bkg_data=[50.0, 52.0], bkg_uncerts=[3.0, 7.0]
115+
... )
116+
>>> observations = [51, 48]
117+
>>> data = pyhf.tensorlib.astensor(observations + model.config.auxdata)
118+
>>> test_mu = 1.0
119+
>>> init_pars = model.config.suggested_init()
120+
>>> par_bounds = model.config.suggested_bounds()
121+
>>> pyhf.infer.test_statistics.qmu_tilde(test_mu, data, model, init_pars, par_bounds)
122+
array(3.93824492)
123+
124+
Args:
125+
mu (Number or Tensor): The signal strength parameter
126+
data (Tensor): The data to be considered
127+
pdf (~pyhf.pdf.Model): The statistical model adhering to the schema model.json
128+
init_pars (`list`): Values to initialize the model parameters at for the fit
129+
par_bounds (`list` of `list`\s or `tuple`\s): The extrema of values the model parameters are allowed to reach in the fit
130+
131+
Returns:
132+
Float: The calculated test statistic, :math:`\tilde{q}_{\mu}`
133+
"""
134+
if pdf.config.poi_index is None:
135+
raise UnspecifiedPOI(
136+
'No POI is defined. A POI is required for profile likelihood based test statistics.'
137+
)
138+
if par_bounds[pdf.config.poi_index][0] != 0:
139+
log.warning(
140+
'qmu_tilde test statistic used for fit configuration with POI not bounded at zero.\n'
141+
+ 'Use the qmu test statistic (pyhf.infer.test_statistics.qmu) instead.'
142+
)
143+
return _qmu_like(mu, data, pdf, init_pars, par_bounds)
144+
145+
146+
def tmu(mu, data, pdf, init_pars, par_bounds):
147+
r"""
148+
The test statistic, :math:`t_{\mu}`, for establishing a two-sided
149+
interval on the strength parameter, :math:`\mu`, as defiend in Equation (10)
150+
in :xref:`arXiv:1007.1727`.
151+
152+
Example:
153+
>>> import pyhf
154+
>>> pyhf.set_backend("numpy")
155+
>>> model = pyhf.simplemodels.hepdata_like(
156+
... signal_data=[12.0, 11.0], bkg_data=[50.0, 52.0], bkg_uncerts=[3.0, 7.0]
157+
... )
158+
>>> observations = [51, 48]
159+
>>> data = pyhf.tensorlib.astensor(observations + model.config.auxdata)
160+
>>> test_mu = 1.0
161+
>>> init_pars = model.config.suggested_init()
162+
>>> par_bounds = model.config.suggested_bounds()
163+
>>> par_bounds[model.config.poi_index] = [-10.0, 10.0]
164+
>>> pyhf.infer.test_statistics.tmu(test_mu, data, model, init_pars, par_bounds)
165+
array(3.9549891)
166+
167+
Args:
168+
mu (Number or Tensor): The signal strength parameter
169+
data (Tensor): The data to be considered
170+
pdf (~pyhf.pdf.Model): The statistical model adhering to the schema model.json
171+
init_pars (`list`): Values to initialize the model parameters at for the fit
172+
par_bounds (`list` of `list`\s or `tuple`\s): The extrema of values the model parameters are allowed to reach in the fit
173+
174+
Returns:
175+
Float: The calculated test statistic, :math:`t_{\mu}`
176+
"""
177+
if pdf.config.poi_index is None:
178+
raise UnspecifiedPOI(
179+
'No POI is defined. A POI is required for profile likelihood based test statistics.'
180+
)
181+
if par_bounds[pdf.config.poi_index][0] == 0:
182+
log.warning(
183+
'tmu test statistic used for fit configuration with POI bounded at zero.\n'
184+
+ 'Use the tmu_tilde test statistic (pyhf.infer.test_statistics.tmu_tilde) instead.'
185+
)
186+
return _tmu_like(mu, data, pdf, init_pars, par_bounds)
187+
188+
189+
def tmu_tilde(mu, data, pdf, init_pars, par_bounds):
190+
r"""
191+
The test statistic, :math:`t_{\mu}`, for establishing a two-sided
192+
interval on the strength parameter, :math:`\mu`, for models with
193+
bounded POI, as defiend in Equation (11) in :xref:`arXiv:1007.1727`.
194+
195+
Example:
196+
>>> import pyhf
197+
>>> pyhf.set_backend("numpy")
198+
>>> model = pyhf.simplemodels.hepdata_like(
199+
... signal_data=[12.0, 11.0], bkg_data=[50.0, 52.0], bkg_uncerts=[3.0, 7.0]
200+
... )
201+
>>> observations = [51, 48]
202+
>>> data = pyhf.tensorlib.astensor(observations + model.config.auxdata)
203+
>>> test_mu = 1.0
204+
>>> init_pars = model.config.suggested_init()
205+
>>> par_bounds = model.config.suggested_bounds()
206+
>>> pyhf.infer.test_statistics.tmu_tilde(test_mu, data, model, init_pars, par_bounds)
207+
array(3.93824492)
208+
209+
Args:
210+
mu (Number or Tensor): The signal strength parameter
211+
data (Tensor): The data to be considered
212+
pdf (~pyhf.pdf.Model): The statistical model adhering to the schema model.json
213+
init_pars (`list`): Values to initialize the model parameters at for the fit
214+
par_bounds (`list` of `list`\s or `tuple`\s): The extrema of values the model parameters are allowed to reach in the fit
215+
216+
Returns:
217+
Float: The calculated test statistic, :math:`\tilde{t}_{\mu}`
218+
"""
219+
if pdf.config.poi_index is None:
220+
raise UnspecifiedPOI(
221+
'No POI is defined. A POI is required for profile likelihood based test statistics.'
222+
)
223+
if par_bounds[pdf.config.poi_index][0] != 0:
224+
log.warning(
225+
'tmu_tilde test statistic used for fit configuration with POI not bounded at zero.\n'
226+
+ 'Use the tmu test statistic (pyhf.infer.test_statistics.tmu) instead.'
227+
)
228+
return _tmu_like(mu, data, pdf, init_pars, par_bounds)

tests/test_backend_consistency.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def generate_source_poisson(n_bins):
5858

5959
@pytest.mark.parametrize('n_bins', bins, ids=bin_ids)
6060
@pytest.mark.parametrize('invert_order', [False, True], ids=['normal', 'inverted'])
61-
def test_hypotest_q_mu(
61+
def test_hypotest_qmu_tilde(
6262
n_bins, invert_order, tolerance={'numpy': 1e-02, 'tensors': 5e-03}
6363
):
6464
"""
@@ -114,10 +114,10 @@ def test_hypotest_q_mu(
114114
for backend in backends:
115115
pyhf.set_backend(backend)
116116

117-
q_mu = pyhf.infer.test_statistics.qmu(
117+
qmu_tilde = pyhf.infer.test_statistics.qmu_tilde(
118118
1.0, data, pdf, pdf.config.suggested_init(), pdf.config.suggested_bounds(),
119119
)
120-
test_statistic.append(q_mu)
120+
test_statistic.append(qmu_tilde)
121121

122122
# compare to NumPy/SciPy
123123
test_statistic = np.array(test_statistic)

tests/test_teststats.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import pytest
2+
import pyhf
3+
import pyhf.infer.test_statistics
4+
import logging
5+
6+
7+
def test_qmu(caplog):
8+
mu = 1.0
9+
model = pyhf.simplemodels.hepdata_like([6], [9], [3])
10+
data = [9] + model.config.auxdata
11+
init_pars = model.config.suggested_init()
12+
par_bounds = model.config.suggested_bounds()
13+
14+
with caplog.at_level(logging.WARNING, "pyhf.infer.test_statistics"):
15+
pyhf.infer.test_statistics.qmu(mu, data, model, init_pars, par_bounds)
16+
assert "WARNING qmu test statistic used for fit" in caplog.text
17+
caplog.clear()
18+
19+
20+
def test_qmu_tilde(caplog):
21+
mu = 1.0
22+
model = pyhf.simplemodels.hepdata_like([6], [9], [3])
23+
data = [9] + model.config.auxdata
24+
init_pars = model.config.suggested_init()
25+
par_bounds = model.config.suggested_bounds()
26+
27+
par_bounds[model.config.poi_index] = [-10, 10]
28+
with caplog.at_level(logging.WARNING, "pyhf.infer.test_statistics"):
29+
pyhf.infer.test_statistics.qmu_tilde(mu, data, model, init_pars, par_bounds)
30+
assert "WARNING qmu_tilde test statistic used for fit" in caplog.text
31+
caplog.clear()
32+
33+
34+
def test_tmu(caplog):
35+
mu = 1.0
36+
model = pyhf.simplemodels.hepdata_like([6], [9], [3])
37+
data = [9] + model.config.auxdata
38+
init_pars = model.config.suggested_init()
39+
par_bounds = model.config.suggested_bounds()
40+
with caplog.at_level(logging.WARNING, "pyhf.infer.test_statistics"):
41+
pyhf.infer.test_statistics.tmu(mu, data, model, init_pars, par_bounds)
42+
assert "WARNING tmu test statistic used for fit" in caplog.text
43+
caplog.clear()
44+
45+
46+
def test_tmu_tilde(caplog):
47+
mu = 1.0
48+
model = pyhf.simplemodels.hepdata_like([6], [9], [3])
49+
data = [9] + model.config.auxdata
50+
init_pars = model.config.suggested_init()
51+
par_bounds = model.config.suggested_bounds()
52+
53+
par_bounds[model.config.poi_index] = [-10, 10]
54+
with caplog.at_level(logging.WARNING, "pyhf.infer.test_statistics"):
55+
pyhf.infer.test_statistics.tmu_tilde(mu, data, model, init_pars, par_bounds)
56+
assert "WARNING tmu_tilde test statistic used for fit" in caplog.text
57+
caplog.clear()
58+
59+
60+
def test_no_poi_test_stats():
61+
spec = {
62+
"channels": [
63+
{
64+
"name": "channel",
65+
"samples": [
66+
{
67+
"name": "sample",
68+
"data": [10.0],
69+
"modifiers": [
70+
{
71+
"type": "normsys",
72+
"name": "shape",
73+
"data": {"hi": 0.5, "lo": 1.5},
74+
}
75+
],
76+
},
77+
],
78+
}
79+
]
80+
}
81+
model = pyhf.Model(spec, poi_name=None)
82+
83+
test_poi = 1.0
84+
data = [12] + model.config.auxdata
85+
init_pars = model.config.suggested_init()
86+
par_bounds = model.config.suggested_bounds()
87+
88+
with pytest.raises(pyhf.exceptions.UnspecifiedPOI) as excinfo:
89+
pyhf.infer.test_statistics.qmu(test_poi, data, model, init_pars, par_bounds)
90+
assert (
91+
"No POI is defined. A POI is required for profile likelihood based test statistics."
92+
in str(excinfo.value)
93+
)
94+
95+
with pytest.raises(pyhf.exceptions.UnspecifiedPOI) as excinfo:
96+
pyhf.infer.test_statistics.qmu_tilde(
97+
test_poi, data, model, init_pars, par_bounds
98+
)
99+
assert (
100+
"No POI is defined. A POI is required for profile likelihood based test statistics."
101+
in str(excinfo.value)
102+
)
103+
104+
with pytest.raises(pyhf.exceptions.UnspecifiedPOI) as excinfo:
105+
pyhf.infer.test_statistics.tmu(test_poi, data, model, init_pars, par_bounds)
106+
assert (
107+
"No POI is defined. A POI is required for profile likelihood based test statistics."
108+
in str(excinfo.value)
109+
)
110+
111+
with pytest.raises(pyhf.exceptions.UnspecifiedPOI) as excinfo:
112+
pyhf.infer.test_statistics.tmu_tilde(
113+
test_poi, data, model, init_pars, par_bounds
114+
)
115+
assert (
116+
"No POI is defined. A POI is required for profile likelihood based test statistics."
117+
in str(excinfo.value)
118+
)

0 commit comments

Comments
 (0)