Repository: scikit-hep/cabinetry

Issue:
Handle partially fixed parameters in `model_utils` API
The support for partially fixed parameters (such as `staterror` with zeros in some bins) in `pyhf` 0.7 can result in scenarios where the behavior of `suggested_fixed_as_bool` is undefined, see https://github.com/scikit-hep/pyhf/issues/1944.

This currently breaks `model_utils.prefit_uncertainties` in `cabinetry` (and also affects `model_utils.unconstrained_parameter_count`). Working around this should be possible by iterating bin by bin for affected parameters and using the `suggested_fixed` API instead.

Comments:

Patch:
diff --git a/src/cabinetry/model_utils.py b/src/cabinetry/model_utils.py
index cc6285e..f336e60 100644
--- a/src/cabinetry/model_utils.py
+++ b/src/cabinetry/model_utils.py
@@ -179,19 +179,19 @@ def prefit_uncertainties(model: pyhf.pdf.Model) -> np.ndarray:
     """
     pre_fit_unc = []  # pre-fit uncertainties for parameters
     for parameter in model.config.par_order:
-        # obtain pre-fit uncertainty for constrained, non-fixed parameters
-        if (
-            model.config.param_set(parameter).constrained
-            and not model.config.param_set(parameter).suggested_fixed_as_bool
-        ):
-            pre_fit_unc += model.config.param_set(parameter).width()
+        if model.config.param_set(parameter).constrained:
+            # pre-fit uncertainty for constrained parameters (if fixed, set to 0.0)
+            widths = [
+                width if not fixed else 0.0
+                for width, fixed in zip(
+                    model.config.param_set(parameter).width(),
+                    model.config.param_set(parameter).suggested_fixed,
+                )
+            ]
+            pre_fit_unc += widths
         else:
-            if model.config.param_set(parameter).n_parameters == 1:
-                # unconstrained normfactor or fixed parameter, uncertainty is 0
-                pre_fit_unc.append(0.0)
-            else:
-                # shapefactor
-                pre_fit_unc += [0.0] * model.config.param_set(parameter).n_parameters
+            # unconstrained: normfactor or shapefactor, uncertainty is 0
+            pre_fit_unc += [0.0] * model.config.param_set(parameter).n_parameters
     return np.asarray(pre_fit_unc)
 
 
@@ -476,11 +476,9 @@ def unconstrained_parameter_count(model: pyhf.pdf.Model) -> int:
     """
     n_pars = 0
     for parname in model.config.par_order:
-        if (
-            not model.config.param_set(parname).constrained
-            and not model.config.param_set(parname).suggested_fixed_as_bool
-        ):
-            n_pars += model.config.param_set(parname).n_parameters
+        if not model.config.param_set(parname).constrained:
+            # only consider non-constant parameters
+            n_pars += model.config.param_set(parname).suggested_fixed.count(False)
     return n_pars
 
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index aeb3cbf..eda236e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -379,6 +379,43 @@ def example_spec_modifiers():
     return spec
 
 
+@pytest.fixture
+def example_spec_zero_staterror():
+    spec = {
+        "channels": [
+            {
+                "name": "SR",
+                "samples": [
+                    {
+                        "data": [5.0, 0.0],
+                        "modifiers": [
+                            {"data": None, "name": "mu", "type": "normfactor"},
+                            {
+                                "data": [1.0, 0.0],
+                                "name": "staterror_SR",
+                                "type": "staterror",
+                            },
+                        ],
+                        "name": "Signal",
+                    }
+                ],
+            }
+        ],
+        "measurements": [
+            {
+                "config": {
+                    "parameters": [],
+                    "poi": "mu",
+                },
+                "name": "zero staterror",
+            }
+        ],
+        "observations": [{"data": [5, 0], "name": "SR"}],
+        "version": "1.0.0",
+    }
+    return spec
+
+
 # code below allows marking tests as slow and adds --runslow to run them
 # implemented following https://docs.pytest.org/en/6.2.x/example/simple.html
 def pytest_addoption(parser):
diff --git a/tests/test_model_utils.py b/tests/test_model_utils.py
index 257a00d..b1369d6 100644
--- a/tests/test_model_utils.py
+++ b/tests/test_model_utils.py
@@ -147,7 +147,10 @@ def test_asimov_parameters(example_spec, example_spec_shapefactor, example_spec_
 
 
 def test_prefit_uncertainties(
-    example_spec, example_spec_multibin, example_spec_shapefactor
+    example_spec,
+    example_spec_multibin,
+    example_spec_shapefactor,
+    example_spec_zero_staterror,
 ):
     model = pyhf.Workspace(example_spec).model()
     unc = model_utils.prefit_uncertainties(model)
@@ -161,6 +164,10 @@ def test_prefit_uncertainties(
     unc = model_utils.prefit_uncertainties(model)
     assert np.allclose(unc, [0.0, 0.0, 0.0])
 
+    model = pyhf.Workspace(example_spec_zero_staterror).model()
+    unc = model_utils.prefit_uncertainties(model)
+    assert np.allclose(unc, [0.0, 0.2, 0.0])  # partially fixed staterror
+
 
 def test__hashable_model_key(example_spec):
     # key matches for two models built from the same spec