Repository: scikit-hep/pyhf

Issue:
allow poi-less Models 
# Description

the `.set_poi()` call in the `Model` ctor should be optional. It's only necessary for hypotests, but e.g. you can do MLE fits without problems w/o a POI

It's also useful when teaching modifiers etc.


Discussion:

Resolving patch:
diff --git a/src/pyhf/exceptions/__init__.py b/src/pyhf/exceptions/__init__.py
--- a/src/pyhf/exceptions/__init__.py
+++ b/src/pyhf/exceptions/__init__.py
@@ -50,6 +50,14 @@ class InvalidWorkspaceOperation(Exception):
     """InvalidWorkspaceOperation is raised when an operation on a workspace fails."""
 
 
+class UnspecifiedPOI(Exception):
+    """
+    UnspecifiedPOI is raised when a given model does not have POI(s) defined but is used in contexts that need it.
+
+    This can occur when e.g. trying to calculate CLs on a POI-less model.
+    """
+
+
 class InvalidModel(Exception):
     """
     InvalidModel is raised when a given model does not have the right configuration, even though it validates correctly against the schema.
diff --git a/src/pyhf/infer/mle.py b/src/pyhf/infer/mle.py
--- a/src/pyhf/infer/mle.py
+++ b/src/pyhf/infer/mle.py
@@ -1,5 +1,6 @@
 """Module for Maximum Likelihood Estimation."""
 from .. import get_backend
+from ..exceptions import UnspecifiedPOI
 
 
 def twice_nll(pars, data, pdf):
@@ -76,6 +77,10 @@ def fixed_poi_fit(poi_val, data, pdf, init_pars=None, par_bounds=None, **kwargs)
         See optimizer API
 
     """
+    if pdf.config.poi_index is None:
+        raise UnspecifiedPOI(
+            'No POI is defined. A POI is required to fit with a fixed POI.'
+        )
     _, opt = get_backend()
     init_pars = init_pars or pdf.config.suggested_init()
     par_bounds = par_bounds or pdf.config.suggested_bounds()
diff --git a/src/pyhf/pdf.py b/src/pyhf/pdf.py
--- a/src/pyhf/pdf.py
+++ b/src/pyhf/pdf.py
@@ -221,7 +221,6 @@ def __init__(self, spec, **config_kwargs):
         _required_paramsets = _paramset_requirements_from_modelspec(
             spec, self.channel_nbins
         )
-
         poi_name = config_kwargs.pop('poi_name', 'mu')
 
         default_modifier_settings = {'normsys': {'interpcode': 'code1'}}
@@ -242,7 +241,9 @@ def __init__(self, spec, **config_kwargs):
         self.auxdata_order = []
 
         self._create_and_register_paramsets(_required_paramsets)
-        self.set_poi(poi_name)
+        if poi_name is not None:
+            self.set_poi(poi_name)
+
         self.npars = len(self.suggested_init())
         self.nmaindata = sum(self.channel_nbins.values())
 


Test patch:
diff --git a/src/pyhf/infer/test_statistics.py b/src/pyhf/infer/test_statistics.py
--- a/src/pyhf/infer/test_statistics.py
+++ b/src/pyhf/infer/test_statistics.py
@@ -1,5 +1,6 @@
 from .. import get_backend
 from .mle import fixed_poi_fit, fit
+from ..exceptions import UnspecifiedPOI
 
 
 def qmu(mu, data, pdf, init_pars, par_bounds):
@@ -42,6 +43,11 @@ def qmu(mu, data, pdf, init_pars, par_bounds):
     Returns:
         Float: The calculated test statistic, :math:`q_{\mu}`
     """
+    if pdf.config.poi_index is None:
+        raise UnspecifiedPOI(
+            'No POI is defined. A POI is required for profile likelihood based test statistics.'
+        )
+
     tensorlib, optimizer = get_backend()
     mubhathat, fixed_poi_fit_lhood_val = fixed_poi_fit(
         mu, data, pdf, init_pars, par_bounds, return_fitted_val=True
diff --git a/tests/test_pdf.py b/tests/test_pdf.py
--- a/tests/test_pdf.py
+++ b/tests/test_pdf.py
@@ -5,6 +5,26 @@
 import json
 
 
+def test_minimum_model_spec():
+    spec = {
+        'channels': [
+            {
+                'name': 'channel',
+                'samples': [
+                    {
+                        'name': 'goodsample',
+                        'data': [1.0],
+                        'modifiers': [
+                            {'type': 'normfactor', 'name': 'mu', 'data': None}
+                        ],
+                    },
+                ],
+            }
+        ]
+    }
+    pyhf.Model(spec)
+
+
 def test_pdf_inputs(backend):
     source = {
         "binning": [2, -0.5, 1.5],
@@ -182,6 +202,39 @@ def test_pdf_integration_staterror(backend):
     )
 
 
+def test_poiless_model(backend):
+    spec = {
+        'channels': [
+            {
+                'name': 'channel',
+                'samples': [
+                    {
+                        'name': 'goodsample',
+                        'data': [10.0],
+                        'modifiers': [
+                            {
+                                'type': 'normsys',
+                                'name': 'shape',
+                                'data': {"hi": 0.5, "lo": 1.5},
+                            }
+                        ],
+                    },
+                ],
+            }
+        ]
+    }
+    model = pyhf.Model(spec, poi_name=None)
+
+    data = [12] + model.config.auxdata
+    pyhf.infer.mle.fit(data, model)
+
+    with pytest.raises(pyhf.exceptions.UnspecifiedPOI):
+        pyhf.infer.mle.fixed_poi_fit(1.0, data, model)
+
+    with pytest.raises(pyhf.exceptions.UnspecifiedPOI):
+        pyhf.infer.hypotest(1.0, data, model)
+
+
 def test_pdf_integration_shapesys_zeros(backend):
     spec = {
         "channels": [