Repository: scikit-hep/pyhf

Issue:
Promote `model._modifications` to public API
### Summary

[`pyhf.pdf._MainModel._modifications`](https://github.com/scikit-hep/pyhf/blob/be5ae6561d482d18601c9301f28f7b242b5a7a2f/src/pyhf/pdf.py#L529-L543) is useful for model visualization and debugging. See for example https://github.com/scikit-hep/pyhf/pull/1639#discussion_r730752320. It also came up in the discussion https://github.com/scikit-hep/pyhf/discussions/1414. This function is currently not part of the public API. Given its usefulness, I propose changing that. This suggestion also came up in https://github.com/scikit-hep/pyhf/pull/1461#discussion_r633482129.

Two alternatives are possible: using the `_modifications` API even though it signals it is not public (accepting a possible breaking change in the future), or re-implementing it externally.

### Additional Information

See https://github.com/scikit-hep/pyhf/pull/1639#discussion_r730752320 for example usage, this API could presumably also be used for [`pyhf-validation/scripts/validate_systs.py`](https://github.com/pyhf/pyhf-validation/blob/master/scripts/validate_systs.py).

### Code of Conduct

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


Discussion:

Resolving patch:
diff --git a/src/pyhf/pdf.py b/src/pyhf/pdf.py
--- a/src/pyhf/pdf.py
+++ b/src/pyhf/pdf.py
@@ -650,7 +650,21 @@ def logpdf(self, maindata, pars):
         """
         return self.make_pdf(pars).log_prob(maindata)
 
-    def _modifications(self, pars):
+    def modifications(self, pars):
+        """
+        Obtain the additive and multiplicative modifications to the expected
+        event rates for the model parameters.
+
+        Args:
+            pars (:obj:`tensor`): The model parameters
+
+        Returns:
+            Tuple of additive and multiplicative modifications:
+                - deltas (:obj:`list`) is the result of an ``apply(pars)`` of combined modifiers
+                  with ``"addition"`` ``op_code``
+                - factors (:obj:`list`) is the result of ``apply(pars)`` of combined modifiers
+                  with ``"multiplication"`` ``op_code``
+        """
         deltas = list(
             filter(
                 lambda x: x is not None,
@@ -695,7 +709,7 @@ def expected_data(self, pars, return_by_sample=False):
         """
         tensorlib, _ = get_backend()
         pars = tensorlib.astensor(pars)
-        deltas, factors = self._modifications(pars)
+        deltas, factors = self.modifications(pars)
 
         allsum = tensorlib.concatenate(deltas + [self.nominal_rates])
 
@@ -826,8 +840,12 @@ def expected_auxdata(self, pars):
         pars = tensorlib.astensor(pars)
         return self.make_pdf(pars)[1].expected_data()
 
-    def _modifications(self, pars):
-        return self.main_model._modifications(pars)
+    def modifications(self, pars):
+        """
+        The modifications applied to the :class:`~pyhf.pdf._MainModel`. See
+        :func:`pyhf.pdf._MainModel.modifications` for details.
+        """
+        return self.main_model.modifications(pars)
 
     @property
     def nominal_rates(self):


Test patch:
diff --git a/tests/test_modifiers.py b/tests/test_modifiers.py
--- a/tests/test_modifiers.py
+++ b/tests/test_modifiers.py
@@ -77,7 +77,7 @@ def test_staterror_holes():
 
     model = pyhf.Model(spec, poi_name="")
     assert model.config.npars == 9
-    _, factors = model._modifications(
+    _, factors = model.modifications(
         pyhf.tensorlib.astensor([2, 2.0, 1.0, 1.0, 3.0, 4.0, 1.0, 5.0, 6.0])
     )
     assert model.config.param_set("staterror_1").suggested_fixed == [
@@ -151,7 +151,7 @@ def test_shapesys_holes():
     }
 
     model = pyhf.Model(spec, poi_name="mu")
-    _, factors = model._modifications(
+    _, factors = model.modifications(
         pyhf.tensorlib.astensor([1.0, 2.0, 1.0, 1.0, 3.0, 4.0, 1.0, 1.0, 5.0])
     )
     assert (factors[1][0, 0, 0, :] == [2.0, 1.0, 1.0, 3.0, 1.0, 1.0, 1.0, 1.0]).all()
diff --git a/tests/test_pdf.py b/tests/test_pdf.py
--- a/tests/test_pdf.py
+++ b/tests/test_pdf.py
@@ -754,7 +754,7 @@ def test_lumi_np_scaling():
         [[[1.0, 1.0]]],
         [[[1.0, 1.0]]],
     ]
-    assert pdf._modifications(np.array(pars))[1][0].tolist() == [mods]
+    assert pdf.modifications(np.array(pars))[1][0].tolist() == [mods]
     assert pdf.expected_data(pars).tolist() == [120.0, 110.0, 1.0]
 
     pars[poi_slice], pars[lumi_slice] = [[1.0], [alpha_lumi]]
@@ -763,7 +763,7 @@ def test_lumi_np_scaling():
         [[[1.0, 1.0]]],
         [[[alpha_lumi, alpha_lumi]]],
     ]
-    assert pdf._modifications(np.array(pars))[1][0].tolist() == [mods]
+    assert pdf.modifications(np.array(pars))[1][0].tolist() == [mods]
     assert pytest.approx(pdf.expected_data(pars).tolist()) == [
         100 + 20.0 * alpha_lumi,
         110.0 * alpha_lumi,