Repository: scikit-hep/pyhf

Issue:
Revert slight change in return types of `suggested_fixed`
### Summary

https://github.com/scikit-hep/pyhf/pull/1639 very slightly changed the return type of `model.config.suggested_fixed()`. It used to be `List[bool]`, but now some elements can be `numpy.bool_`, which behaves differently (see https://github.com/numpy/numpy/issues/9646):
```python
>>> import numpy as np
>>> isinstance(np.bool_(True), bool)
False
```
This makes typing / type checking in libraries using `pyhf` more complicated (spotted via https://github.com/scikit-hep/cabinetry/pull/301). Would it be possible to consistently return `List[bool]` again instead?

### Additional Information

<!--- Describe what you are showing in your example -->
The example below shows how `numpy.bool_` entries appear for some, but not all models.
<!--- and then paste your mock Python example code between the quotes below -->
```python (paste below)
import pyhf

spec = {
    "channels": [
        {
            "name": "SR",
            "samples": [
                {
                    "data": [25, 5],
                    "modifiers": [
                        {
                            "data": [5, 2],
                            "name": "staterror_SR",
                            "type": "staterror",
                        },
                    ],
                    "name": "Signal",
                }
            ],
        },
    ],
    "measurements": [{"config": {"parameters": [], "poi": ""}, "name": "fit"}],
    "observations": [
        {"data": [35, 8], "name": "SR"},
    ],
    "version": "1.0.0",
}


def check_types(model):
    for i, par in enumerate(model.config.suggested_fixed()):
        if not isinstance(par, bool):
            print(f"{model.config.par_names()[i]} has type {type(par)}")


model = pyhf.simplemodels.correlated_background([5], [10], [11], [9])
print("checking correlated_background model")
check_types(model)

model = pyhf.Workspace(spec).model()
print("checking custom model")
check_types(model)
```
Output for 9fbbbf9740175fcdfe73fedc4351953be18d2664 and later (including current `master`):
```
checking correlated_background model
checking custom model
staterror_SR[0] has type <class 'numpy.bool_'>
staterror_SR[1] has type <class 'numpy.bool_'>
```
Output for 5ea4e0a7751aa446c286b9697e01a9439622ebb5:
```
checking correlated_background model
checking custom model
```


### Code of Conduct

- [X] I agree to follow the Code of Conduct


Discussion:
This only seems to affect `staterror` modifiers. `shapesys.required_parset` uses a slightly different way to construct the information and returns a list of bools. `shapefactor.required_parset` does not seem to support per-bin settings at the moment and is also not affected, as are the remaining modifier types that don't have per-bin settings.

The `staterror` behavior can be changed to return `List[bool]` again at a few different levels, I will push a PR with a suggestion and some comments in case you'd like to have the old behavior restored.

Resolving patch:
diff --git a/src/pyhf/modifiers/staterror.py b/src/pyhf/modifiers/staterror.py
--- a/src/pyhf/modifiers/staterror.py
+++ b/src/pyhf/modifiers/staterror.py
@@ -1,4 +1,5 @@
 import logging
+from typing import List
 
 import pyhf
 from pyhf import events
@@ -8,7 +9,7 @@
 log = logging.getLogger(__name__)
 
 
-def required_parset(sigmas, fixed):
+def required_parset(sigmas, fixed: List[bool]):
     n_parameters = len(sigmas)
     return {
         'paramset_type': 'constrained_by_normal',
@@ -104,7 +105,8 @@ def finalize(self):
             for modifier_data in self.builder_data[modname].values():
                 modifier_data['data']['mask'] = masks[modname]
             sigmas = relerrs[masks[modname]]
-            fixed = [s == 0 for s in sigmas]
+            # list of bools, consistent with other modifiers (no numpy.bool_)
+            fixed = default_backend.tolist(sigmas == 0)
             # ensures non-Nan constraint term, but in a future PR we need to remove constraints for these
             sigmas[fixed] = 1.0
             self.required_parsets.setdefault(parname, [required_parset(sigmas, fixed)])
diff --git a/src/pyhf/parameters/paramsets.py b/src/pyhf/parameters/paramsets.py
--- a/src/pyhf/parameters/paramsets.py
+++ b/src/pyhf/parameters/paramsets.py
@@ -1,3 +1,5 @@
+from typing import List
+
 import pyhf
 
 __all__ = [
@@ -27,13 +29,13 @@ def __init__(self, **kwargs):
             )
 
     @property
-    def suggested_fixed(self):
+    def suggested_fixed(self) -> List[bool]:
         if type(self._suggested_fixed) == bool:
             return [self._suggested_fixed] * self.n_parameters
         return self._suggested_fixed
 
     @property
-    def suggested_fixed_as_bool(self):
+    def suggested_fixed_as_bool(self) -> bool:
         '''compresses list of same-value bools into single bool'''
         suggested_fixed = self.suggested_fixed
         first = suggested_fixed[0]
diff --git a/src/pyhf/pdf.py b/src/pyhf/pdf.py
--- a/src/pyhf/pdf.py
+++ b/src/pyhf/pdf.py
@@ -2,6 +2,7 @@
 
 import copy
 import logging
+from typing import List
 
 import pyhf.parameters
 import pyhf
@@ -346,7 +347,7 @@ def param_set(self, name):
         """
         return self.par_map[name]['paramset']
 
-    def suggested_fixed(self):
+    def suggested_fixed(self) -> List[bool]:
         """
         Identify the fixed parameters in the model.
 


Test patch:
diff --git a/tests/test_modifiers.py b/tests/test_modifiers.py
--- a/tests/test_modifiers.py
+++ b/tests/test_modifiers.py
@@ -91,12 +91,24 @@ def test_staterror_holes():
         True,
         False,
     ]
+    assert all(
+        [
+            isinstance(fixed, bool)
+            for fixed in model.config.param_set("staterror_1").suggested_fixed
+        ]
+    )
     assert model.config.param_set("staterror_2").suggested_fixed == [
         False,
         True,
         False,
         False,
     ]
+    assert all(
+        [
+            isinstance(fixed, bool)
+            for fixed in model.config.param_set("staterror_2").suggested_fixed
+        ]
+    )
     assert (factors[1][0, 0, 0, :] == [2.0, 1.0, 1.0, 3.0, 1.0, 1.0, 1.0, 1.0]).all()
     assert (factors[1][1, 0, 0, :] == [1.0, 1.0, 1.0, 1.0, 4.0, 1.0, 5.0, 6.0]).all()