Repository: scikit-hep/pyhf

Issue:
Documentation of default Minuit strategy choice
### Summary

The documentation of `minuit_optimizer` lists `strategy` as a possible kwarg with default value of `None`, but it does not explain what that choice leads to. `pyhf` uses `0`/`1` depending on whether user-provided gradients are used (#1172 / #1183), and I believe it would be good to have that documented on the optimizer page. I'd be happy to submit a PR.

### Documentation Page Link

https://pyhf.readthedocs.io/en/v0.6.3/_generated/pyhf.optimize.opt_minuit.minuit_optimizer.html#pyhf.optimize.opt_minuit.minuit_optimizer

### Code of Conduct

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


Discussion:
Maybe I misunderstood the way `strategy` is supposed to be used, but it seems like `None` is actually not a supported setting:
```python
import pyhf

model = pyhf.simplemodels.uncorrelated_background(
    signal=[12.0, 11.0], bkg=[50.0, 52.0], bkg_uncertainty=[3.0, 7.0]
)
data = [51, 48] + model.config.auxdata
pyhf.set_backend("numpy", "minuit")
pyhf.infer.mle.fit(data, model, strategy=None)
```
will fail:
```pytb
Traceback (most recent call last):
  File "[...]/test.py", line 8, in <module>
    pyhf.infer.mle.fit(data, model, strategy=None)
  File "[...]/pyhf/src/pyhf/infer/mle.py", line 131, in fit
    return opt.minimize(
  File "[...]/pyhf/src/pyhf/optimize/mixins.py", line 185, in minimize
    result = self._internal_minimize(
  File "[...]/pyhf/src/pyhf/optimize/mixins.py", line 51, in _internal_minimize
    result = self._minimize(
  File "[...]/pyhf/src/pyhf/optimize/opt_minuit.py", line 117, in _minimize
    minimizer.strategy = strategy
  File "[...]/iminuit/minuit.py", line 203, in strategy
    self._strategy.strategy = value
TypeError: (): incompatible function arguments. The following argument types are supported:
    1. (arg0: iminuit._core.MnStrategy, arg1: int) -> None

Invoked with: <iminuit._core.MnStrategy object at 0x1420a0070>, None
```
`iminuit` only supports values of 0/1/2.

Is the intention from the `pyhf` side to support `None`, or to only support the values of 0/1/2? It is not completely clear to me which of the kwargs on that page is the relevant one (related to #1786), i.e. `__init__` vs `_minimize`.

Resolving patch:
diff --git a/src/pyhf/optimize/opt_minuit.py b/src/pyhf/optimize/opt_minuit.py
--- a/src/pyhf/optimize/opt_minuit.py
+++ b/src/pyhf/optimize/opt_minuit.py
@@ -28,7 +28,10 @@ def __init__(self, *args, **kwargs):
         Args:
             errordef (:obj:`float`): See minuit docs. Default is ``1.0``.
             steps (:obj:`int`): Number of steps for the bounds. Default is ``1000``.
-            strategy (:obj:`int`): See :attr:`iminuit.Minuit.strategy`. Default is ``None``.
+            strategy (:obj:`int`): See :attr:`iminuit.Minuit.strategy`.
+              Default is ``None``, which results in either
+              :attr:`iminuit.Minuit.strategy` ``0`` or ``1`` from the evaluation of
+              ``int(not pyhf.tensorlib.default_do_grad)``.
             tolerance (:obj:`float`): Tolerance for termination.
               See specific optimizer for detailed meaning.
               Default is ``0.1``.
@@ -99,11 +102,14 @@ def _minimize(
             fitresult (scipy.optimize.OptimizeResult): the fit result
         """
         maxiter = options.pop('maxiter', self.maxiter)
-        # 0: Fast, user-provided gradient
-        # 1: Default, no user-provided gradient
-        strategy = options.pop(
-            'strategy', self.strategy if self.strategy is not None else not do_grad
-        )
+        # do_grad value results in iminuit.Minuit.strategy of either:
+        #   0: Fast. Does not check a user-provided gradient.
+        #   1: Default. Checks user-provided gradient against numerical gradient.
+        strategy = options.pop("strategy", self.strategy)
+        # Guard against None from either self.strategy defaulting to None or
+        # passing strategy=None as options kwarg
+        if strategy is None:
+            strategy = 0 if do_grad else 1
         tolerance = options.pop('tolerance', self.tolerance)
         if options:
             raise exceptions.Unsupported(


Test patch:
diff --git a/tests/test_optim.py b/tests/test_optim.py
--- a/tests/test_optim.py
+++ b/tests/test_optim.py
@@ -173,27 +173,35 @@ def test_minuit_strategy_do_grad(mocker, backend):
     assert spy.spy_return.minuit.strategy == 1
 
 
-@pytest.mark.parametrize('strategy', [0, 1])
+@pytest.mark.parametrize('strategy', [0, 1, 2])
 def test_minuit_strategy_global(mocker, backend, strategy):
     pyhf.set_backend(
         pyhf.tensorlib, pyhf.optimize.minuit_optimizer(strategy=strategy, tolerance=0.2)
     )
     spy = mocker.spy(pyhf.optimize.minuit_optimizer, '_minimize')
-    m = pyhf.simplemodels.uncorrelated_background([50.0], [100.0], [10.0])
-    data = pyhf.tensorlib.astensor([125.0] + m.config.auxdata)
+    model = pyhf.simplemodels.uncorrelated_background([50.0], [100.0], [10.0])
+    data = pyhf.tensorlib.astensor([125.0] + model.config.auxdata)
 
-    pyhf.infer.mle.fit(data, m)
+    pyhf.infer.mle.fit(data, model)
     assert spy.call_count == 1
     assert spy.spy_return.minuit.strategy == strategy
 
-    pyhf.infer.mle.fit(data, m, strategy=0)
+    pyhf.infer.mle.fit(data, model, strategy=None)
     assert spy.call_count == 2
-    assert spy.spy_return.minuit.strategy == 0
+    assert spy.spy_return.minuit.strategy == int(not pyhf.tensorlib.default_do_grad)
 
-    pyhf.infer.mle.fit(data, m, strategy=1)
+    pyhf.infer.mle.fit(data, model, strategy=0)
     assert spy.call_count == 3
+    assert spy.spy_return.minuit.strategy == 0
+
+    pyhf.infer.mle.fit(data, model, strategy=1)
+    assert spy.call_count == 4
     assert spy.spy_return.minuit.strategy == 1
 
+    pyhf.infer.mle.fit(data, model, strategy=2)
+    assert spy.call_count == 5
+    assert spy.spy_return.minuit.strategy == 2
+
 
 def test_set_tolerance(backend):
     m = pyhf.simplemodels.uncorrelated_background([50.0], [100.0], [10.0])