Repository: scikit-hep/pyhf

Issue:
Can no longer set several model config attributes
### Summary

The changes in #1972 cause `model.config.poi_index` to not be writable anymore. `cabinetry` sets this value directly to support running `pyhf.infer.hypotest` with a configurable POI (without the need to re-build the model).

### OS / Environment

```console
n/a
```


### Steps to Reproduce

<!--- Paste your minimal failing Python example code between the quotes below -->
```python (paste below)
import pyhf

model = pyhf.simplemodels.uncorrelated_background(
    signal=[24.0, 22.0], bkg=[50.0, 52.0], bkg_uncertainty=[3.0, 7.0]
)
model.config.poi_index = 1
```

### File Upload (optional)

_No response_

### Expected Results

assignment succeeds

### Actual Results

```console
Traceback (most recent call last):
  File "test.py", line 7, in <module>
    model.config.poi_index = ""
AttributeError: can't set attribute
```


### pyhf Version

```console
pyhf, version 0.7.0rc4.dev2
```


### Code of Conduct

- [X] I agree to follow the Code of Conduct


Discussion:
This affects also `model.config.poi_name` and presumably the rest of the attributes that were decorated with `@property`: `par_order`, `auxdata`, `nmaindata`, `nauxdata`, as well as `model.config` itself. I could imagine people possibly wanting to override `auxdata` and potentially the full `model.config`, I have not had the need to do so myself.

Adding the following two setters restores the functionality `cabinetry` uses at the moment:
```diff
diff --git a/src/pyhf/pdf.py b/src/pyhf/pdf.py
index db59fa1c..969b26db 100644
--- a/src/pyhf/pdf.py
+++ b/src/pyhf/pdf.py
@@ -245,6 +245,13 @@ def poi_name(self):
         """
         return self._poi_name

+    @poi_name.setter
+    def poi_name(self, value):
+        """
+        Set the name of the POI parameter in the model.
+        """
+        self._poi_name = value
+
     @property
     def poi_index(self):
         """
@@ -252,6 +259,13 @@ def poi_index(self):
         """
         return self._poi_index

+    @poi_index.setter
+    def poi_index(self, value):
+        """
+        Set the index of the POI parameter in the model.
+        """
+        self._poi_index = value
+
     @property
     def auxdata(self):
         """
```
When this is fixed there should additionally be a test that is added to ensure that these can be set by the user.
This is not a bug. The API for setting the `poi_index` is done through `model.config.set_poi(name: str)`. https://scikit-hep.org/pyhf/_generated/pyhf.pdf._ModelConfig.html#pyhf.pdf._ModelConfig.set_poi

We never encouraged anyone to override these other properties you mentioned either. The whole point is to try and make `Model` less dynamically configurable because there was always confusion about how mutable it was meant to be.
Making this explicit by using `set_poi` is a good solution, but it currently does not support setting POIs back to "no POI" mode where `poi_name` / `poi_index` are `None`. This worked when setting the attribute directly so far.
```python
import pyhf

spec = {
    "channels": [
        {
            "name": "SR",
            "samples": [
                {
                    "data": [50],
                    "modifiers": [
                        {"data": None, "name": "mu", "type": "normfactor"},
                    ],
                    "name": "Signal",
                },
            ],
        }
    ],
    "measurements": [
        {
            "config": {
                "parameters": [],
                "poi": "",
            },
            "name": "meas",
        }
    ],
    "observations": [{"data": [50], "name": "SR"}],
    "version": "1.0.0",
}

ws = pyhf.Workspace(spec)
model = ws.model()
data = ws.data(model)

print(model.config.poi_name is None)  # True
print(model.config.poi_index is None)  # True

model.config.set_poi("mu")  # temporarily set for e.g. hypotest
pyhf.infer.hypotest(1.0, data, model)

# neither of the following works, expects string for a parameter that is in the model
# model.config.set_poi(None)
# model.config.set_poi("")
```

Resolving patch:
diff --git a/src/pyhf/pdf.py b/src/pyhf/pdf.py
--- a/src/pyhf/pdf.py
+++ b/src/pyhf/pdf.py
@@ -429,6 +429,8 @@ def set_poi(self, name):
         """
         Set the model parameter of interest to be model parameter ``name``.
 
+        If ``name`` is ``None``, this will unset the parameter of interest.
+
         Example:
             >>> import pyhf
             >>> model = pyhf.simplemodels.uncorrelated_background(
@@ -438,6 +440,11 @@ def set_poi(self, name):
             >>> model.config.poi_name
             'mu'
         """
+        if name is None:
+            self._poi_name = None
+            self._poi_index = None
+            return
+
         if name not in self.parameters:
             raise exceptions.InvalidModel(
                 f"The parameter of interest '{name:s}' cannot be fit as it is not declared in the model specification."


Test patch:
diff --git a/tests/test_public_api.py b/tests/test_public_api.py
--- a/tests/test_public_api.py
+++ b/tests/test_public_api.py
@@ -221,3 +221,15 @@ def test_set_schema_path_context(monkeypatch):
     new_path = pathlib.Path('a/new/path')
     with pyhf.schema(new_path):
         assert pyhf.schema.path == new_path
+
+
+def test_pdf_set_poi(backend):
+    model = pyhf.simplemodels.uncorrelated_background([5.0], [10.0], [2.5])
+    assert model.config.poi_index == 0
+    assert model.config.poi_name == 'mu'
+    model.config.set_poi('uncorr_bkguncrt')
+    assert model.config.poi_index == 1
+    assert model.config.poi_name == 'uncorr_bkguncrt'
+    model.config.set_poi(None)
+    assert model.config.poi_index is None
+    assert model.config.poi_name is None